diff --git a/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts b/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts index 71338ddf1d4..a6af7c8aed5 100644 --- a/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts +++ b/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts @@ -1408,12 +1408,12 @@ describe('agent_kind enqueue snapshot', () => { expect(result).toEqual({ committed: true }); }); - it('writeChain 延迟期间切换引擎,消息仍使用事件入队时的 agent_kind', async () => { + it.each(['cc', 'cursor'] as const)('writeChain 延迟期间切换引擎,消息仍使用事件入队时的 %s', async (agentKind) => { let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const blocker = enqueueDurableWrite('agent-kind-test-blocker', () => gate); - noteSessionAgentKind(SESSION, 'cc'); + noteSessionAgentKind(SESSION, agentKind); onToolUseEvent( SESSION, { toolUseId: 'before-switch', toolName: 'Read', input: { file_path: '/tmp/a' } }, @@ -1426,7 +1426,7 @@ describe('agent_kind enqueue snapshot', () => { expect(createMessage).toHaveBeenCalledWith( SESSION, - expect.objectContaining({ role: 'tool_use', agentKind: 'cc' }), + expect.objectContaining({ role: 'tool_use', agentKind }), broadcastGuard(), ); }); @@ -4077,7 +4077,12 @@ it('retains explicit commentary/final phases in durable assistant metadata for n }); it('persists bound results with the final seal, and a failed result lookup cannot suppress the reply', async () => { - const results = [{ delegationId: 'job' }] as any; + const results: Awaited> = [{ + v: 1, role: 'delegation-result', delegationId: 'job', fromBotId: 'owner-bot', + fromBotName: 'Owner', toBotId: null, toBotName: '', parentSessionId: SESSION, + childSessionId: 'child', objective: 'Test result', + result: { runSequence: 1, status: 'completed', text: 'done', artifacts: [] }, + }]; vi.mocked(readTaskResultsForReply).mockResolvedValueOnce(results); await markAssistantTurnCompleted(SESSION, 'summary', undefined, ['bot-delegation-completion:job']); expect(patchMessageAgentMetaWithResult).toHaveBeenCalledWith(SESSION, 'summary', { turnCompleted: true, botTaskResults: results }); diff --git a/apps/desktop/src/main/__tests__/queuedMessageTrustBoundary.test.ts b/apps/desktop/src/main/__tests__/queuedMessageTrustBoundary.test.ts index 6ca395cc5dd..01199f1e4f2 100644 --- a/apps/desktop/src/main/__tests__/queuedMessageTrustBoundary.test.ts +++ b/apps/desktop/src/main/__tests__/queuedMessageTrustBoundary.test.ts @@ -20,6 +20,26 @@ const compiled = ts.transpileModule(`(${validator})`, { }).outputText; describe('queued IPC host policy boundary', () => { + it.each([false, true])('accepts Cursor input with device-link=%s and rejects unknown engines', (remote) => { + const validate = runInNewContext(compiled, { + isDeviceLinkInvoke: () => remote, + requireSessionRefs: () => undefined, + requireTrustedReferenceContexts: () => undefined, + throwIpcError: (_code: string, message: string) => { throw new Error(message); }, + }); + const input = { + clientId: 'cursor-first-turn', text: 'Hello', persistedContent: 'Hello', + chatMessage: { clientId: 'cursor-first-turn', role: 'user', content: 'Hello' }, + createOpts: { agentKind: 'cursor', model: 'cursor-default' }, + toolsDisabled: true, + }; + const result = validate(input); + expect(result.createOpts).toEqual(input.createOpts); + expect(result.text).toBe(input.text); + expect(result).not.toHaveProperty('toolsDisabled'); + expect(() => validate({ ...input, createOpts: { agentKind: 'unknown' } })).toThrow('agentKind invalid'); + }); + it.each([false, true])('strips caller tool policy with device-link=%s', (remote) => { const validate = runInNewContext(compiled, { isDeviceLinkInvoke: () => remote, diff --git a/apps/desktop/src/main/device-link/__tests__/dispatchSendSafety.test.ts b/apps/desktop/src/main/device-link/__tests__/dispatchSendSafety.test.ts index 781b11a8d52..f605cad4e8a 100644 --- a/apps/desktop/src/main/device-link/__tests__/dispatchSendSafety.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/dispatchSendSafety.test.ts @@ -13,6 +13,7 @@ import { DeviceLinkError, DEVICE_LINK_CAPABILITY_COMPACT_MESSAGE_HISTORY_V1, CONTROLLER_CAPABILITY_SET_MODEL_EXPLICIT_PROVIDER_NULL_V1, + CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1, DEVICE_LINK_CAPABILITY_BACKGROUND_LINK_V1, DL_SUBSCRIBE_CHANNEL, MAX_FRAME_BYTES, @@ -87,6 +88,24 @@ beforeEach(() => { __testing.reset(); }); +it('projects Cursor picker capabilities independently for old and current controllers', async () => { + const capabilities = { + supportsSessionAgentSwitch: true, + availableModels: [{ id: 'cursor-default' }, { id: 'native[reasoning=medium,fast=false]' }], + permissionModes: [{ id: 'ask' }, { id: 'default' }], + }; + registry.register('maker:get-capabilities', () => capabilities); + subscriptions.subscribe('legacy-cursor-phone', ['sessions'], 'legacy', []); + subscriptions.subscribe('current-cursor-phone', ['sessions'], 'current', [CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1]); + const [legacy, current] = await Promise.all([ + runInvoke('legacy-cursor-phone', { channel: 'maker:get-capabilities', args: ['cursor'] }), + runInvoke('current-cursor-phone', { channel: 'maker:get-capabilities', args: ['cursor'] }), + ]); + expect(legacy).toEqual({ ok: true, result: { ...capabilities, supportsSessionAgentSwitch: false } }); + expect(current).toEqual({ ok: true, result: capabilities }); + expect(capabilities.supportsSessionAgentSwitch).toBe(true); +}); + it('strips Desktop credential links from authorization history and live metadata pushes', () => { const card = { v: 1, sessionId: 's1', createdAt: 1, target: { kind: 'plugin', id: 'p' }, snapshot: { kind: 'plugin_setup', requestId: 'r', revision: 1, ghost: { id: 'p', name: 'Plugin' }, diff --git a/apps/desktop/src/main/device-link/__tests__/providerListProjection.test.ts b/apps/desktop/src/main/device-link/__tests__/providerListProjection.test.ts index c9af0a99236..d0b7cbe074b 100644 --- a/apps/desktop/src/main/device-link/__tests__/providerListProjection.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/providerListProjection.test.ts @@ -49,6 +49,34 @@ const projectForCurrentController = (result: unknown) => modelVisibilityOverrides?: Record; }; +describe('Cursor model picker capability projection', () => { + const capabilities = { + supportsSessionAgentSwitch: true, + permissionModes: [{ id: 'ask' }, { id: 'default' }], + availableModels: [{ id: 'cursor-default' }, { id: 'native[reasoning=medium,fast=false]' }], + }; + + it('lets older controllers list Cursor models through their current-engine picker', () => { + const projected = __testing.projectInvokeResultForTunnel( + 'maker:get-capabilities', capabilities, false, ['cursor'], false, + ); + expect(projected).toEqual({ ...capabilities, supportsSessionAgentSwitch: false }); + expect(capabilities.supportsSessionAgentSwitch).toBe(true); + }); + + it('keeps engine switching available to controllers with the complete Cursor picker', () => { + expect(__testing.projectInvokeResultForTunnel( + 'maker:get-capabilities', capabilities, false, ['cursor'], true, + )).toBe(capabilities); + }); + + it.each(['claude-code', 'codex', 'pi'])('preserves the %s capability contract for older controllers', agent => { + expect(__testing.projectInvokeResultForTunnel( + 'maker:get-capabilities', capabilities, false, [agent], false, + )).toBe(capabilities); + }); +}); + describe('schedule binding list projection', () => { const fields = { id: 'heartbeat', name: 'Heartbeat', status: 'paused', targetSessionId: 'task', diff --git a/apps/desktop/src/main/device-link/dispatch.ts b/apps/desktop/src/main/device-link/dispatch.ts index 2b40350a88b..093276e7423 100644 --- a/apps/desktop/src/main/device-link/dispatch.ts +++ b/apps/desktop/src/main/device-link/dispatch.ts @@ -60,6 +60,7 @@ import { DL_VOICE_CREDENTIAL_SYNC_CHANNEL, DL_VOICE_DICTIONARY_LEARNING_CHANNEL, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, + CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1, DL_VOICE_DICTIONARY_GET_CHANNEL, DL_TELEGRAM_STATUS_CHANNEL, DL_TELEGRAM_SET_ONLINE_CHANNEL, @@ -156,7 +157,7 @@ const pushFailures = createPushFailureLog((summary) => log.warn('push delivery f function reportPushFailure(dst: string, channel: string, error: unknown): void { pushFailures.record(shortId(dst), channel, error instanceof DeviceLinkError ? error.code : 'UNKNOWN'); } -let readHistoryToolName = (_sessionId: string, _toolUseId: string): string => ''; +let readHistoryToolName: (sessionId: string, toolUseId: string) => string = () => ''; export function setHistoryToolNameReader(read: typeof readHistoryToolName): void { readHistoryToolName = read; } @@ -692,7 +693,16 @@ function projectInvokeResultForTunnel( result: unknown, supportsFullLogoKinds = false, args: readonly unknown[] = [], + supportsCursorModelPicker = false, ): unknown { + // Older controllers omit Cursor from their cross-engine picker. Keeping a Cursor + // task on its current-engine picker restores its native models without changing + // model ids, execution permissions, or the host's actual switching capability. + if (channel === 'maker:get-capabilities' && args[0] === 'cursor' && !supportsCursorModelPicker + && result && typeof result === 'object' && !Array.isArray(result) + && 'supportsSessionAgentSwitch' in result && result.supportsSessionAgentSwitch === true) { + return { ...result, supportsSessionAgentSwitch: false }; + } // Opt-in only: legacy/desktop controllers still receive complete runtime capabilities. // Home only needs run state; repeating the model catalog per runtime blocks slow links. const options = args[0]; @@ -2546,7 +2556,7 @@ async function handleFrame(client: DeviceLinkClient, env: Envelope): Promise if (!agentMeta || typeof agentMeta !== 'object' || Array.isArray(agentMeta)) { return { ...patch }; } - const { recoveryCheckpoint: _, ...safe } = agentMeta as Record; + const safe = { ...agentMeta as Record }; + delete safe.recoveryCheckpoint; return { ...sanitizeBotAuthorizationMetaForRemote(safe), ...patch }; } @@ -3842,7 +3854,8 @@ function sanitizeRemoteMessage(record: Record): Record = { ...record }; if (hasPrivateMetadata) { - const { recoveryCheckpoint: _, ...safeMeta } = agentMeta as Record; + const safeMeta = { ...agentMeta as Record }; + delete safeMeta.recoveryCheckpoint; result.agentMeta = sanitizeBotAuthorizationMetaForRemote(safeMeta); } if (hasCheckpointInContent) { @@ -4655,6 +4668,7 @@ async function executeRemoteInvoke(src: string, payload: InvokePayload | undefin ) || listingCapabilities.includes(CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2), args, + subscriptions.controllerSupports(src, CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1), )); // 供应商组摘要与运行数只给同账号电脑(受邀者与共享任务访客另有投影,这里不加,scrubSharedProvider 再兜一层)。 const decorated = payload.channel === 'maker:provider:list' && isSameAccountController(src) && providerGroupRemoteHandler diff --git a/apps/desktop/src/main/device-link/index.ts b/apps/desktop/src/main/device-link/index.ts index bc8a9c79a7b..463ff2cbe12 100644 --- a/apps/desktop/src/main/device-link/index.ts +++ b/apps/desktop/src/main/device-link/index.ts @@ -31,6 +31,7 @@ import { CONTROLLER_CAPABILITY_SESSION_TEXT_SNAPSHOT_V1, CONTROLLER_CAPABILITY_SESSION_LIST_MESSAGES_V1, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, + CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1, CONTROLLER_CAPABILITY_SET_MODEL_EXPLICIT_PROVIDER_NULL_V1, MAKER_EVENT_BATCH_CHANNEL, expandMakerEventBatchPayload, @@ -532,6 +533,7 @@ const CONTROLLER_CAPABILITIES = [ SHARED_TASK_CAPABILITY, CONTROLLER_CAPABILITY_SESSION_TEXT_SNAPSHOT_V1, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, + CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1, CONTROLLER_CAPABILITY_SET_MODEL_EXPLICIT_PROVIDER_NULL_V1, // 桌面控制桌面时同样收微批:批的收益是**relay 帧数**,只要有一个控制端不支持, // 被控端就得为它保留逐帧流,聚合出站速率照旧能招来 1013 并连带踢掉已启用微批的 diff --git a/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts b/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts index 5aa1f388803..39ec8971f05 100644 --- a/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts +++ b/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts @@ -52,6 +52,16 @@ function over( } describe('resolveHookSessionConfig', () => { + it('uses native permission requests for Cursor when no mode was selected', () => { + expect(resolveHookSessionConfig(deps(), over({ agentKind: 'cursor' })).permissionMode).toBe('ask'); + }); + it('preserves an explicit Cursor selection and its supported permission mode', () => { + const config = resolveHookSessionConfig(deps({ + getModels: () => [{ id: 'cursor-default', efforts: [], defaultEffort: null }], + getPermissionModes: () => ['ask', 'default'], + }), over({ agentKind: 'cursor', model: 'cursor-default', permissionMode: 'ask' })); + expect(config).toMatchObject({ agentKind: 'cursor', model: 'cursor-default', permissionMode: 'ask' }); + }); it('无 override: 全部落草稿默认, 权限落 bypass', () => { const r = resolveHookSessionConfig(deps(), over()); expect(r).toEqual({ diff --git a/apps/desktop/src/main/hook-control/defaults.ts b/apps/desktop/src/main/hook-control/defaults.ts index 9ebc102f711..c0417fad0c1 100644 --- a/apps/desktop/src/main/hook-control/defaults.ts +++ b/apps/desktop/src/main/hook-control/defaults.ts @@ -37,24 +37,24 @@ import type { Effort } from '@cindy/maker-core'; /** 依赖注入面: IM 默认值 + 各 agent 当前可用模型清单。 */ export interface HookDefaultsDeps { readDefaults: () => { - agentKind: 'claude-code' | 'codex' | 'pi'; - agents: Record< - 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; + agents: Partial; + >>; }; - getModels: (agentKind: 'claude-code' | 'codex' | 'pi') => Array<{ + getModels: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Array<{ id: string; efforts: readonly string[]; defaultEffort: string | null; }>; /** 该 agent 支持的权限档 id 清单(capabilities.permissionModes)。 */ - getPermissionModes: (agentKind: 'claude-code' | 'codex' | 'pi') => readonly string[]; + getPermissionModes: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => readonly string[]; log: { warn(msg: string): void }; } export interface ResolvedHookSessionConfig { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; effort: Effort | undefined; permissionMode: string; @@ -66,7 +66,7 @@ export interface ResolvedHookSessionConfig { providerId: string | null; } -const AGENT_KINDS = new Set(['claude-code', 'codex', 'pi']); +const AGENT_KINDS = new Set(['claude-code', 'codex', 'pi', 'cursor']); /** * 合成新 hook 会话的 agent/model/effort。 @@ -84,9 +84,9 @@ export function resolveHookSessionConfig( const defaults = deps.readDefaults(); // 1. agent: 显式合法值 > 草稿默认 - const agentKind: 'claude-code' | 'codex' | 'pi' = + const agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor' = overrides.agentKind !== null && AGENT_KINDS.has(overrides.agentKind) - ? (overrides.agentKind as 'claude-code' | 'codex' | 'pi') + ? (overrides.agentKind as 'claude-code' | 'codex' | 'pi' | 'cursor') : defaults.agentKind; const models = deps.getModels(agentKind); @@ -149,7 +149,8 @@ export function resolveHookSessionConfig( // (claude-code: ask/acceptEdits/auto/bypassPermissions; codex: ask/auto/ // bypassPermissions), 故取 [0] 即最严档。 // 只有「从未填过显式档」才走 bypass 历史默认, 该行为保持不变。 - let permissionMode = 'bypassPermissions'; + // Keep Cursor's conservative unconfigured hook default; explicit Auto/Full access is supported. + let permissionMode = agentKind === 'cursor' ? 'ask' : 'bypassPermissions'; if (overrides.permissionMode !== null) { const supported = deps.getPermissionModes(agentKind); if (supported.includes(overrides.permissionMode)) { @@ -163,7 +164,7 @@ export function resolveHookSessionConfig( // agent 未声明任何档位(异常/测试桩)—— 与「有档位但不含显式值」是两种情况, // 日志分开写,免得排障时把「无档位声明」误读成「已收紧到最严档」。 deps.log.warn( - `hook override permissionMode '${overrides.permissionMode}' rejected: ${agentKind} declares no permission modes; using legacy default 'bypassPermissions'`, + `hook override permissionMode '${overrides.permissionMode}' rejected: ${agentKind} declares no permission modes; using default '${permissionMode}'`, ); } } diff --git a/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts b/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts index af746ab0664..96a88a734ae 100644 --- a/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts +++ b/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts @@ -118,6 +118,18 @@ describe('im default settings store', () => { expect(persisted.agents).toBeDefined(); }); + it('persists and reloads Cursor defaults for global and channel scopes', () => { + const cursor = { providerId: 'cursor', model: 'native-model', effort: 'low' as const }; + writeImDefaultSettingsPatch({ agentKind: 'cursor', agents: { cursor } }); + expect(readImDefaultSettings().agents.cursor).toEqual(cursor); + expect(readImDefaultSettingsState().customizedKeys).toContain('agents.cursor'); + const persisted = JSON.parse(fs.readFileSync(settingsFile(), 'utf-8')); + expect(persisted.global.agents.cursor).toEqual(cursor); + writeImDefaultSettingsPatch({ agents: { cursor: { ...cursor, model: 'channel-model' } } }, 'feishu'); + expect(readImDefaultSettings('feishu').agents.cursor).toEqual({ ...cursor, model: 'channel-model' }); + expect(readImDefaultSettings().agents.cursor).toEqual(cursor); + }); + it('persists a Pi-specific default instead of dropping it from sparse overrides', () => { writeImDefaultSettingsPatch({ agents: { @@ -189,6 +201,7 @@ describe('im default settings store', () => { model: 'claude-sonnet-5', effort: 'high', }, + cursor: IM_DEFAULT_SETTINGS.agents.cursor, }); }); diff --git a/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts b/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts index 9d28b37cc70..9f9624f4980 100644 --- a/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts +++ b/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts @@ -40,12 +40,13 @@ describe('parseImDefaultSettingsPatch', () => { ); }); - it('parses all three harnesses symmetrically', () => { + it('parses all four harnesses symmetrically', () => { const parsed = parseImDefaultSettingsPatch({ agents: { 'claude-code': { model: ' m-cc ', effort: 'high', providerId: ' p1 ' }, codex: { model: 'm-codex', effort: 'low', providerId: null }, pi: { model: 'm-pi', effort: 'medium', providerId: ' ' }, + cursor: { model: 'm-cursor', effort: 'medium', providerId: 'cursor' }, }, }); expect(parsed.agents).toEqual({ @@ -53,6 +54,7 @@ describe('parseImDefaultSettingsPatch', () => { 'claude-code': { model: 'm-cc', effort: 'high', providerId: 'p1' }, codex: { model: 'm-codex', effort: 'low', providerId: null }, pi: { model: 'm-pi', effort: 'medium', providerId: null }, + cursor: { model: 'm-cursor', effort: 'medium', providerId: 'cursor' }, }); }); @@ -75,6 +77,7 @@ describe('parseImDefaultSettingsPatch', () => { 'claude-code': { providerId: 'p-cc', model: 'm-cc', effort: 'low' }, codex: { providerId: 'p-codex', model: 'm-codex', effort: 'low' }, pi: { providerId: 'p-pi', model: 'm-pi', effort: 'low' }, + cursor: { providerId: 'cursor', model: 'm-cursor', effort: 'low' }, }, }; for (const key of Object.keys(IM_DEFAULT_SETTINGS) as Array) { diff --git a/apps/desktop/src/main/im/defaultSettingsStore.ts b/apps/desktop/src/main/im/defaultSettingsStore.ts index dcdfc00e377..d0b1234d93a 100644 --- a/apps/desktop/src/main/im/defaultSettingsStore.ts +++ b/apps/desktop/src/main/im/defaultSettingsStore.ts @@ -75,6 +75,10 @@ function normalizeSettings(raw: unknown): ImDefaultSettings { 'codex', rawAgentOrLegacy(rawAgents, 'codex', agentKind, legacySettings), ), + cursor: normalizeAgentSettings( + 'cursor', + rawAgentOrLegacy(rawAgents, 'cursor', agentKind, legacySettings), + ), // 键序必须与 IM_DEFAULT_SETTINGS.agents 一致:legacy 检测(信号 4)靠 // JSON.stringify 与系统默认整体比对,键序漂移会让检测失灵。 pi: normalizeAgentSettings( @@ -361,7 +365,7 @@ function settingsOverrides( overrides.groupPermissionMode = value.groupPermissionMode; } const agents: Partial> = {}; - for (const agentKind of ['claude-code', 'codex', 'pi'] as const) { + for (const agentKind of ['claude-code', 'codex', 'cursor', 'pi'] as const) { if (!agentSettingsEqual(value.agents[agentKind], defaults.agents[agentKind])) { agents[agentKind] = value.agents[agentKind]; } @@ -377,7 +381,7 @@ function settingsCustomizedKeys(value: ImDefaultSettings, defaults: ImDefaultSet if (value.groupPermissionMode !== defaults.groupPermissionMode) { keys.push('groupPermissionMode'); } - for (const agentKind of ['claude-code', 'codex', 'pi'] as const) { + for (const agentKind of ['claude-code', 'codex', 'cursor', 'pi'] as const) { if (!agentSettingsEqual(value.agents[agentKind], defaults.agents[agentKind])) { keys.push(`agents.${agentKind}`); } @@ -397,6 +401,7 @@ function cloneSettings(settings: ImDefaultSettings): ImDefaultSettings { agents: { 'claude-code': { ...settings.agents['claude-code'] }, codex: { ...settings.agents.codex }, + cursor: { ...settings.agents.cursor }, pi: { ...settings.agents.pi }, }, }; diff --git a/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts b/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts index 3c6c8322825..7c31d83f4fa 100644 --- a/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts +++ b/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts @@ -55,9 +55,8 @@ export function parseImDefaultSettingsPatch(raw: unknown): ImDefaultSettingsPatc } const agentInput = input.agents as Record; const agentsPatch: NonNullable = {}; - // 三个 harness 必须对称解析;漏掉 pi 会让 IM 设置页切 Pi 后改模型静默丢弃 - // (store 本身支持 pi,见 defaultSettingsStore / IM_DEFAULT_SETTINGS.agents.pi)。 - for (const kind of ['claude-code', 'codex', 'pi'] as const) { + // Every declared engine must round-trip through the same settings patch. + for (const kind of ['claude-code', 'codex', 'cursor', 'pi'] as const) { if (kind in agentInput) { agentsPatch[kind] = parseImDefaultAgentSettings(kind, agentInput[kind]); } diff --git a/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPersistence.test.ts b/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPersistence.test.ts index 33e3264ab14..1509ff57074 100644 --- a/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPersistence.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPersistence.test.ts @@ -245,7 +245,7 @@ describe('agent input queue snapshot durability boundary', () => { expect(query.mock.calls[0]?.[0]).not.toContain('SELECT payload'); }); - it('matches restore de-duplication and clear-boundary filtering for cold queued counts', async () => { + it.each(['pi', 'cursor'] as const)('matches %s restore de-duplication and clear-boundary filtering for cold queued counts', async (agentKind) => { const db = new Database(':memory:'); db.exec(` CREATE TABLE sessions ( @@ -274,6 +274,7 @@ describe('agent input queue snapshot durability boundary', () => { const missingReceipt = queued('missing receipt', 'client-missing-receipt'); const waiting = { ...queued('waiting', 'client-waiting'), + createOpts: { ...queued('waiting').createOpts, agentKind }, hostAcceptedAtMs: 301, }; const staleScheduler = { diff --git a/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPi.test.ts b/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPi.test.ts index 53b0ffef94b..2a0d710dd7d 100644 --- a/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPi.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/agentInputQueueSnapshotsPi.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { isRestorableQueuedMessage } from '../agentInputQueueSnapshots.js'; -function queued(agentKind: 'claude-code' | 'codex' | 'pi') { +function queued(agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') { return { clientId: 'client-1', text: 'continue', @@ -13,7 +13,7 @@ function queued(agentKind: 'claude-code' | 'codex' | 'pi') { } describe('agent input queue snapshot restore', () => { - it.each(['claude-code', 'codex', 'pi'] as const)('accepts the %s agent kind', (agentKind) => { + it.each(['claude-code', 'codex', 'pi', 'cursor'] as const)('accepts the %s agent kind', (agentKind) => { expect(isRestorableQueuedMessage(queued(agentKind))).toBe(true); }); diff --git a/apps/desktop/src/main/localDb/__tests__/sessionCreateCursor.test.ts b/apps/desktop/src/main/localDb/__tests__/sessionCreateCursor.test.ts new file mode 100644 index 00000000000..255c32d369e --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/sessionCreateCursor.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { sessionCreateToRow } from '../mapper.js'; + +describe('Cursor draft session creation', () => { + it('allows the Cursor draft vendor through the IPC create gate', () => { + const source = readFileSync(new URL('../ipc/sessions.ts', import.meta.url), 'utf8'); + const gate = source.match(/const ALLOWED_AGENT_KINDS = new Set\(\[([^\]]+)\]\)/); + expect(gate).not.toBeNull(); + expect(gate![1].match(/'[^']+'/g)).toEqual(["'cc'", "'codex'", "'pi'", "'cursor'"]); + }); + it('uses the native Cursor default when no model was supplied', () => { + expect(sessionCreateToRow('cursor-default', { agentKind: 'cursor' }, 1700000000000).model).toBe('cursor-default'); + }); + it('persists the Cursor identity and native default without substituting Claude', () => { + const row = sessionCreateToRow('cursor-session', { agentKind: 'cursor', model: 'cursor-default', + permissionMode: 'ask', workingDir: '/repo', providerId: 'cursor' }, 1700000000000); + expect(row).toMatchObject({ agentKind: 'cursor', model: 'cursor-default', permissionMode: 'ask', providerId: 'cursor' }); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/sessionOpening.test.ts b/apps/desktop/src/main/localDb/__tests__/sessionOpening.test.ts index 4cc19ed1e47..2d1cba4938b 100644 --- a/apps/desktop/src/main/localDb/__tests__/sessionOpening.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/sessionOpening.test.ts @@ -22,7 +22,7 @@ const remoteRequest = { lead: { leadSessionId: 'lead-1', leadTitle: 'Lead task', workerLabel: 'tester' }, }; function remoteOpener() { - const bootstrapSession = vi.fn(async (_row: OpenedSessionRow, _assertCurrent: () => void) => undefined); + const bootstrapSession = vi.fn(async () => undefined); const broadcastSessionCreated = vi.fn(); const open = createOrcaRemoteWorkerSessionOpener({ openSession, @@ -40,7 +40,7 @@ beforeEach(() => { setSessionOpeningModelAdmission(async request => { const route = resolveSessionExecutionSelection({ selection: request, availableAgents: ['codex'], availableModels: [{ id: 'model', efforts: [] }], hasCindyAiApiKey: false, - providerRouting: { availability: { 'claude-code': [], pi: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'] }] }, + providerRouting: { availability: { cursor: [], 'claude-code': [], pi: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'] }] }, resolveDefaultProviderIdForModel: () => 'connected' } }); return { ...request, model: route.model, providerId: route.providerId, effort: route.effort ?? '' }; }); @@ -117,7 +117,7 @@ it.each(['medium', 'low', ''] as const)('returns the execution provider admitted availableAgents: ['codex'], availableModels: [{ id: 'model', efforts: ['high'], defaultEffort: 'high' }], hasCindyAiApiKey: false, providerRouting: { - availability: { 'claude-code': [], pi: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'], + availability: { 'claude-code': [], pi: [], cursor: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'], effortMetaByModel: { model: { efforts: effort ? [effort] : [], defaultEffort: effort || null } } }] }, resolveDefaultProviderIdForModel: () => 'connected', }, @@ -140,7 +140,7 @@ it.each([ availableAgents: ['codex'], availableModels: [{ id: 'model', efforts: [], supportsFastMode: true }], hasCindyAiApiKey: false, providerRouting: { - availability: { 'claude-code': [], pi: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'], + availability: { 'claude-code': [], pi: [], cursor: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'], fastModels: supportsFast ? ['model'] : [] }] }, resolveDefaultProviderIdForModel: () => 'connected', }, @@ -160,7 +160,7 @@ it('keeps the current unsupported explicit Fast rejection before remote persiste availableAgents: ['codex'], availableModels: [{ id: 'model', efforts: [], supportsFastMode: true }], hasCindyAiApiKey: false, providerRouting: { - availability: { 'claude-code': [], pi: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'], fastModels: [] }] }, + availability: { 'claude-code': [], pi: [], cursor: [], codex: [{ id: 'connected', name: 'Connected', models: ['model'], fastModels: [] }] }, resolveDefaultProviderIdForModel: () => 'connected', }, }); diff --git a/apps/desktop/src/main/localDb/agentInputQueueSnapshots.ts b/apps/desktop/src/main/localDb/agentInputQueueSnapshots.ts index d5d5f3565e7..80ed5f0aecb 100644 --- a/apps/desktop/src/main/localDb/agentInputQueueSnapshots.ts +++ b/apps/desktop/src/main/localDb/agentInputQueueSnapshots.ts @@ -205,7 +205,8 @@ function stripInlineBase64(items: AgentInputQueuedMessage[]): AgentInputQueuedMe const files = item.files?.map((f) => { if (!f.base64) return f; changed = true; - const { base64: _dropped, ...rest } = f; + const rest = { ...f }; + delete rest.base64; return rest; }); const images = item.chatMessage.images?.filter((img) => !('base64' in img)); @@ -320,7 +321,7 @@ export async function loadAgentInputQueueSnapshotCounts( AND json_type(snapshot_item.value, '$.chatMessage') = 'object' AND json_type(snapshot_item.value, '$.createOpts') = 'object' AND json_extract(snapshot_item.value, '$.createOpts.agentKind') - IN ('claude-code', 'codex', 'pi') + IN ('claude-code', 'codex', 'pi', 'cursor') AND COALESCE( json_extract(snapshot_item.value, '$.origin.kind'), '' @@ -378,7 +379,8 @@ export function isRestorableQueuedMessage(value: unknown): value is AgentInputQu !!msg.createOpts && typeof msg.createOpts === 'object' && (msg.createOpts.agentKind === 'claude-code' || msg.createOpts.agentKind === 'codex' || - msg.createOpts.agentKind === 'pi') + msg.createOpts.agentKind === 'pi' || + msg.createOpts.agentKind === 'cursor') ); } diff --git a/apps/desktop/src/main/localDb/chatHistoryReader.ts b/apps/desktop/src/main/localDb/chatHistoryReader.ts index de5ea9a5d7b..01087039dcd 100644 --- a/apps/desktop/src/main/localDb/chatHistoryReader.ts +++ b/apps/desktop/src/main/localDb/chatHistoryReader.ts @@ -30,7 +30,7 @@ const messageRowid = sql`"messages"."rowid"`; // ── Types ─────────────────────────────────────────────────────────────────── export type HistoryOrder = 'asc' | 'desc'; -export type HistoryAgentKind = 'cc' | 'codex' | 'pi'; +export type HistoryAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; export interface HistoryCursor { createdAt: number; // unix ms diff --git a/apps/desktop/src/main/localDb/conversationSearch.ts b/apps/desktop/src/main/localDb/conversationSearch.ts index 5a4e73f6ca6..62413006dfa 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.ts @@ -265,7 +265,7 @@ function normalizeStatusFilter( } function normalizeAgentFilter(value: ConversationSearchFilters['agentKind']): ConversationSearchAgentFilter { - return value === 'cc' || value === 'codex' || value === 'pi' ? value : 'all'; + return value === 'cc' || value === 'codex' || value === 'pi' || value === 'cursor' ? value : 'all'; } function normalizeLastActivity( diff --git a/apps/desktop/src/main/localDb/dailyModelUsage.ts b/apps/desktop/src/main/localDb/dailyModelUsage.ts index 36ac53500cf..1f42cf5187e 100644 --- a/apps/desktop/src/main/localDb/dailyModelUsage.ts +++ b/apps/desktop/src/main/localDb/dailyModelUsage.ts @@ -16,7 +16,7 @@ import { currentLedgerCurrency } from '../usage/ledgerCurrency.js'; const log = createLogger('localDb/dailyModelUsage'); export interface DailyModelUsageDelta { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; money?: RegionalMoney | null; inputTokensDelta: number; diff --git a/apps/desktop/src/main/localDb/ipc/bots.ts b/apps/desktop/src/main/localDb/ipc/bots.ts index 558cc6c63a9..5041800bd68 100644 --- a/apps/desktop/src/main/localDb/ipc/bots.ts +++ b/apps/desktop/src/main/localDb/ipc/bots.ts @@ -9,7 +9,6 @@ import { listBotSkillsForBot } from '../../maker-ipc/botSkillService.js'; import { provisionDefaultBot } from '../../maker-ipc/botDefaultProvisioning.js'; import { BOT_TEMPLATE_PRESET_AVATARS, CINDY_DEFAULT_IDENTITY } from '../../../shared/botTemplatePreset.js'; import fs from 'node:fs/promises'; -import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { app, BrowserWindow, dialog, ipcMain } from 'electron'; @@ -499,11 +498,12 @@ export async function reconcileBotProfileFolder( return derived; } -function botSessionAgentKind(config: { harness?: unknown }): 'cc' | 'codex' | 'pi' { - return config.harness === 'codex' ? 'codex' : config.harness === 'pi' ? 'pi' : 'cc'; +function botSessionAgentKind(config: { harness?: unknown }): 'cc' | 'codex' | 'pi' | 'cursor' { + return config.harness === 'codex' ? 'codex' : config.harness === 'pi' ? 'pi' : config.harness === 'cursor' ? 'cursor' : 'cc'; } function defaultBotModelForConfig(config: Record): string { + if (botSessionAgentKind(config) === 'cursor') return 'cursor-default'; return botSessionAgentKind(config) === 'pi' ? NEW_BOT_DEFAULT_PI_MODEL : 'claude-sonnet-4-6'; } @@ -886,7 +886,7 @@ async function readProfile( fastMode: primaryModelRoute?.fastMode ?? config.fastMode === true, harness: primaryModelRoute?.harness ?? - (config.harness === 'codex' || config.harness === 'pi' ? config.harness : 'claude'), + (config.harness === 'codex' || config.harness === 'pi' || config.harness === 'cursor' ? config.harness : 'claude'), modelChain, modelChainOverride: Array.isArray(config.modelChainOverride) ? normalizeBotModelChain(config.modelChainOverride) @@ -980,7 +980,8 @@ async function readRemoteBotProfile(client: ReturnType, botI if (!isBotVisibleRemotely(profile)) return null; const canonicalResolution = await reconcileCanonicalLink(botId, client); owner.assertCurrent(); - const { hiddenAt: _hiddenAt, ...visibleProfile } = profile; + const visibleProfile: Omit = { ...profile }; + Reflect.deleteProperty(visibleProfile, 'hiddenAt'); return { ...visibleProfile, canonicalSessionId: canonicalResolution.canonicalSessionId ?? undefined, @@ -1167,15 +1168,6 @@ function readLastReadAtMap(raw: unknown): Map { return out; } -async function fileExists(candidate: string): Promise { - try { - await fs.access(candidate); - return true; - } catch { - return false; - } -} - async function defaultNewBotCapabilities(): Promise> { const modelChain = await readEffectiveBotModelChain({ modelChainOverride: null, diff --git a/apps/desktop/src/main/localDb/ipc/history.ts b/apps/desktop/src/main/localDb/ipc/history.ts index e22a700159c..58073531337 100644 --- a/apps/desktop/src/main/localDb/ipc/history.ts +++ b/apps/desktop/src/main/localDb/ipc/history.ts @@ -21,7 +21,7 @@ import { sessions } from '../schema'; import { readLatestSessionTerminal, type SessionTerminalHint } from '../sessionTerminal'; import { requireObject, requireString, throwIpcError } from '../../utils/ipcValidate'; -const VALID_AGENT_KINDS: readonly HistoryAgentKind[] = ['cc', 'codex', 'pi']; +const VALID_AGENT_KINDS: readonly HistoryAgentKind[] = ['cc', 'codex', 'pi', 'cursor']; const VALID_ORDERS: readonly HistoryOrder[] = ['asc', 'desc']; const VALID_ROLES: readonly HistoryRole[] = [ 'user', diff --git a/apps/desktop/src/main/localDb/ipc/messages.ts b/apps/desktop/src/main/localDb/ipc/messages.ts index 8e29fa41591..fea8fc6a175 100644 --- a/apps/desktop/src/main/localDb/ipc/messages.ts +++ b/apps/desktop/src/main/localDb/ipc/messages.ts @@ -1066,7 +1066,6 @@ export async function commitMessageDeletion( preview: string | null; }> { const now = Date.now(); - const db = getDbClient().drizzle; const result = await getDbClient().tx('message.delete', { sessionId, clientIds, @@ -1152,7 +1151,7 @@ export async function commitContextRebuild( reason: 'context-overflow' | 'model-window-switch' | 'pi-prompt-timeout' | 'native-session-recovery'; sourceUserClientId: string | null; - sourceAgentKind?: 'cc' | 'codex' | 'pi'; + sourceAgentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; sourceModel?: string | null; sourceProviderId?: string | null; expectedClearedAt?: number | null; @@ -1187,7 +1186,7 @@ export async function commitContextRebuild( export async function findLatestContextRebuildMeta(sessionId: string): Promise<{ reason?: string; sourceUserClientId?: string | null; - sourceAgentKind?: 'cc' | 'codex' | 'pi'; + sourceAgentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; sourceModel?: string | null; sourceProviderId?: string | null; } | null> { @@ -1213,7 +1212,8 @@ export async function findLatestContextRebuildMeta(sessionId: string): Promise<{ typeof parsed.sourceUserClientId === 'string' ? parsed.sourceUserClientId : null, ...(parsed.sourceAgentKind === 'cc' || parsed.sourceAgentKind === 'codex' || - parsed.sourceAgentKind === 'pi' + parsed.sourceAgentKind === 'pi' || + parsed.sourceAgentKind === 'cursor' ? { sourceAgentKind: parsed.sourceAgentKind } : {}), ...(typeof parsed.sourceModel === 'string' ? { sourceModel: parsed.sourceModel } : {}), @@ -1586,7 +1586,7 @@ export async function createMessage( * agentMeta 需要它;main 侧 SDK 事件落库路径必传,renderer pending echo 等 * 无 SDK 元信息的行留空(null 回落 session.agentKind)。 */ - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor' | null; createdAt?: number; }, opts?: { @@ -2752,7 +2752,7 @@ export interface ParkedEngineSession { */ export async function findParkedEngineSession( sessionId: string, - targetDbKind: 'cc' | 'codex' | 'pi', + targetDbKind: 'cc' | 'codex' | 'pi' | 'cursor', ): Promise { const db = getDbClient().drizzle; const [sessRow] = await db diff --git a/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts b/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts index 464ae0a45d3..3a475cfe115 100644 --- a/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts +++ b/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts @@ -73,7 +73,7 @@ export async function createPluginDraftSession(params: { */ defaults?: { permissionMode?: 'ask' | 'plan' | 'acceptEdits' | 'auto'; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; model?: string; effort?: string; fastMode?: boolean; @@ -140,7 +140,7 @@ export async function createPluginTaskSession(params: { onPersistenceStarted?: () => void; shouldContinue?: () => boolean; title: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; model?: string; effort?: string; fastMode?: boolean; diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 11465df43f9..7d92863ffc2 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -23,6 +23,7 @@ import { writePiSubagentDeletedTombstone, } from '@cindy/maker-core/pi-subagent-runs'; import { DEFAULT_DRAFT_SESSION_TITLE, normalizeAutoTitle } from '@cindy/maker-shared/session-title'; +import type { SharedPermissionMode } from '@cindy/maker-shared/permission-mode'; import { getDbClient } from '../client/current'; import * as currentDb from '../client/current'; @@ -636,13 +637,14 @@ const REMOTE_PERSIST_FIELDS = new Set([ export async function applyAgentSwitchToSessionRow( sessionId: string, patch: { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null | undefined; sdkSessionId?: string | null; /** 目标引擎下的 effort / fastMode(意图登记时 renderer 按目标目录解析,apply 一并落库)。 */ effort?: string; fastMode?: boolean; + permissionMode?: SharedPermissionMode; contextWindow?: number | null; /** 远程 Agent 换电脑:undefined = 不动,null = 改回任务所在电脑。 */ agentDeviceId?: string | null; @@ -667,6 +669,7 @@ export async function applyAgentSwitchToSessionRow( setObj.effort = persistableEffort; } if (patch.fastMode !== undefined) setObj.fastMode = patch.fastMode; + if (patch.permissionMode !== undefined) setObj.permissionMode = patch.permissionMode; if (typeof patch.contextWindow === 'number' && patch.contextWindow > 0) { setObj.contextWindow = Math.floor(patch.contextWindow); } @@ -676,6 +679,7 @@ export async function applyAgentSwitchToSessionRow( .returning({ id: sessions.id, agentKind: sessions.agentKind, model: sessions.model, providerId: sessions.providerId, effort: sessions.effort, fastMode: sessions.fastMode, + permissionMode: sessions.permissionMode, }); if (!committed || !isOwnerScopeCurrent(ownerScope)) return; broadcastSessionPatched( @@ -688,6 +692,7 @@ export async function applyAgentSwitchToSessionRow( ...(patch.agentDeviceId !== undefined ? { agentDeviceId: patch.agentDeviceId } : {}), ...(persistableEffort !== undefined ? { effort: persistableEffort } : {}), ...(patch.fastMode !== undefined ? { fastMode: patch.fastMode } : {}), + ...(patch.permissionMode !== undefined ? { permissionMode: patch.permissionMode } : {}), ...(typeof patch.contextWindow === 'number' && patch.contextWindow > 0 ? { contextWindow: Math.floor(patch.contextWindow) } : {}), @@ -1061,7 +1066,7 @@ export interface OverwritableAutoTitleTarget { * `reconcileCreateOptsAgainstDb` 处理的正是同一类漂移),用错 agent 会让标题 * 走错供应商 —— 纯 Codex / 纯 Claude 用户会因此只拿到 fallback 标题。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** * 是否仍停在建会话时的裸默认标题。合成占位(纯附件消息)只允许覆写这一种 —— * fork 占位与上一条附件写下的合成占位都要保留到用户真正打字为止。 @@ -1077,7 +1082,7 @@ export async function getOverwritableAutoTitle( const row = await selectSessionWithCount(db, id); if (!row) return null; const agentKind = - row.agentKind === 'codex' || row.agentKind === 'pi' ? row.agentKind : 'claude-code'; + row.agentKind === 'codex' || row.agentKind === 'pi' || row.agentKind === 'cursor' ? row.agentKind : 'claude-code'; const overwritable = row.title === DEFAULT_DRAFT_SESSION_TITLE || (!!row.parentSessionId && row.title.startsWith(FORK_PLACEHOLDER_TITLE_PREFIX)) || @@ -1365,7 +1370,7 @@ export function registerSessionIpc( const id = resolveBusinessSessionId(bodyObj.id); const createBody = bodyObj as Parameters[1]; // M16: agentKind 白名单校验(防止 renderer 传非法值) - const ALLOWED_AGENT_KINDS = new Set(['cc', 'codex', 'pi']); + const ALLOWED_AGENT_KINDS = new Set(['cc', 'codex', 'pi', 'cursor']); if (bodyObj.agentKind !== undefined && !ALLOWED_AGENT_KINDS.has(bodyObj.agentKind as string)) { throwIpcError('INVALID_PARAMS', `invalid agentKind: ${String(bodyObj.agentKind)}`); } diff --git a/apps/desktop/src/main/localDb/mapper.ts b/apps/desktop/src/main/localDb/mapper.ts index 659b298334c..3febe0ce2f9 100644 --- a/apps/desktop/src/main/localDb/mapper.ts +++ b/apps/desktop/src/main/localDb/mapper.ts @@ -337,7 +337,7 @@ export function messageToCamel(row: MessageRow): Message { content, toolUseId: row.toolUseId, agentMeta, - agentKind: (row.agentKind as 'cc' | 'codex' | 'pi' | null) ?? null, + agentKind: (row.agentKind as 'cc' | 'codex' | 'pi' | 'cursor' | null) ?? null, createdAt: new Date(row.createdAt).toISOString(), }; } @@ -403,7 +403,7 @@ export function sessionCreateToRow( : DEFAULT_DRAFT_SESSION_TITLE, workingDir: normalizeWorkingDirForStorage(body?.workingDir), workspaceKind: body?.workspaceKind ?? 'project', - model: body?.model ?? 'claude-sonnet-4-6', + model: body?.model ?? (body?.agentKind === 'cursor' ? 'cursor-default' : 'claude-sonnet-4-6'), effort: (body?.effort as SessionInsert['effort']) ?? 'high', permissionMode: (body?.permissionMode as SessionInsert['permissionMode']) ?? 'ask', status: 'active', @@ -511,7 +511,7 @@ export function messageCreateToRow( content: unknown; toolUseId?: string; agentMeta?: AgentMeta | null; - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor' | null; createdAt?: number; }, now: number, diff --git a/apps/desktop/src/main/localDb/orcaTeamStore.ts b/apps/desktop/src/main/localDb/orcaTeamStore.ts index 98ba5de12a0..9d964d5b911 100644 --- a/apps/desktop/src/main/localDb/orcaTeamStore.ts +++ b/apps/desktop/src/main/localDb/orcaTeamStore.ts @@ -18,7 +18,7 @@ const log = createLogger('orca-team-store'); export type OrcaRole = 'lead' | 'worker'; export type OrcaTeamStatus = 'active' | 'completed' | 'cancelled' | 'failed'; -export type MakerAgentKind = 'claude-code' | 'codex' | 'pi'; +export type MakerAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; // Worker 状态枚举与 "占用槽位" 判定下沉到 renderer-safe 模块, // 让 main (本文件) 与 renderer (useWorkers) 共享同一份算法, 避免 F6 那种 @@ -887,7 +887,7 @@ export async function getRemoteWorkerByProxySession(proxySessionId: string): Pro } function fromDbAgentKind(agentKind: string): MakerAgentKind { - return agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'claude-code'; + return agentKind === 'codex' || agentKind === 'pi' || agentKind === 'cursor' ? agentKind : 'claude-code'; } function msToIso(ms: number | null | undefined): string | null { diff --git a/apps/desktop/src/main/localDb/schema.ts b/apps/desktop/src/main/localDb/schema.ts index f36246c0e6e..5cf69a75f31 100644 --- a/apps/desktop/src/main/localDb/schema.ts +++ b/apps/desktop/src/main/localDb/schema.ts @@ -396,7 +396,7 @@ export const botRuntimeSnapshots = sqliteTable( .notNull() .references(() => sessions.id, { onDelete: 'cascade' }), profileVersion: integer('profile_version').notNull(), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), workingDir: text('working_dir').notNull(), memoryScopeKey: text('memory_scope_key'), configuredJson: text('configured_json').notNull().default('{}'), @@ -918,7 +918,7 @@ export const subagentRuns = sqliteTable( sessionId: text('session_id') .notNull() .references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), - provider: text('provider', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + provider: text('provider', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), logicalAgentId: text('logical_agent_id').notNull(), parentToolUseId: text('parent_tool_use_id'), /** JSON string[] containing task/tool aliases observed for this logical child. */ @@ -988,7 +988,7 @@ export const subagentRunAliases = sqliteTable( sessionId: text('session_id') .notNull() .references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), - provider: text('provider', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + provider: text('provider', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), alias: text('alias').notNull(), runId: text('run_id') .notNull() @@ -1366,9 +1366,9 @@ export const schedules = sqliteTable( * 引擎 fireOne 优先用 intervalMs 算 nextFireAt;旧 cron 数据 0015 migration 自动回填。 */ intervalMs: integer('interval_ms'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), /** NULL preserves legacy bound-task Harness inheritance. */ - modelAgentKind: text('model_agent_kind', { enum: ['claude-code', 'codex', 'pi'] }), + modelAgentKind: text('model_agent_kind', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }), model: text('model'), /** * 显式选定的供应商(来源)id。NULL = 回落该 agent 原生默认来源(no-break, @@ -1487,7 +1487,7 @@ export const sessionGoals = sqliteTable( /** usageLimited 时记录的限额重置时刻(unix ms);到点自动续跑。其它状态为 null。 */ usageResetAt: integer('usage_reset_at'), lastReason: text('last_reason'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), startedAt: integer('started_at').notNull(), updatedAt: integer('updated_at').notNull(), }, @@ -1785,7 +1785,7 @@ export const dailyModelUsage = sqliteTable( { /** 本地时区 YYYY-MM-DD 字符串 (localDayKey)。 */ day: text('day').notNull(), - /** 'claude-code' | 'codex' | 'pi' — 网关模型 id 可能跨 agent 撞名, 需区分。 */ + /** 'claude-code' | 'codex' | 'pi' | 'cursor' — 网关模型 id 可能跨 agent 撞名, 需区分。 */ agentKind: text('agent_kind').notNull(), /** SDK 模型 id; 拿不到时兜底 'unknown'。 */ model: text('model').notNull(), @@ -1838,7 +1838,7 @@ export const skillUsageSources = sqliteTable( rawFilePath: text('raw_file_path').primaryKey(), /** 当前源文件最后一次用哪个解析器版本扫描。用于 analyzer 升级时渐进重建。 */ analyzerVersion: text('analyzer_version').notNull().default('6'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), sessionId: text('session_id').notNull(), sdkSessionId: text('sdk_session_id').notNull(), mtimeMs: integer('mtime_ms').notNull().default(0), @@ -1868,7 +1868,7 @@ export const skillUsageExposures = sqliteTable( rawLineNo: integer('raw_line_no').notNull(), sessionId: text('session_id').notNull(), sdkSessionId: text('sdk_session_id').notNull(), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'cursor'] }).notNull(), skillName: text('skill_name').notNull(), skillPath: text('skill_path'), /** 规范 SKILL.md 文档 hash;拿不到规范文档时为 NULL,不参与版本聚合。 */ diff --git a/apps/desktop/src/main/maker-host/__tests__/cursorModelCatalog.test.ts b/apps/desktop/src/main/maker-host/__tests__/cursorModelCatalog.test.ts new file mode 100644 index 00000000000..e485510a400 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/cursorModelCatalog.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ModelDescriptor } from '@cindy/maker-core'; +import { BUNDLED_CATALOG } from '@cindy/model-providers'; +const state = vi.hoisted(() => ({ owner: 'owner-a' })); +vi.mock('../../appSessionState.js', () => ({ activeOwnerScopeKey: () => state.owner })); +import { clearCursorDiscoveredModels, getCursorDiscoveredModel, hasCursorDiscoveredModels, setCursorDiscoveredModels, withCursorDiscoveredModels } from '../cursor-model-catalog.js'; + +describe('Cursor ACP catalog projection', () => { + beforeEach(() => { state.owner = 'owner-a'; clearCursorDiscoveredModels(); }); + const discovered: ModelDescriptor = { id: 'account-native-model', displayName: 'Native model', + contextWindow: 0, efforts: [], defaultEffort: null, newSessionDefault: ['cursor'] }; + it('never fabricates an Auto or fallback model before discovery', () => { + expect(withCursorDiscoveredModels(BUNDLED_CATALOG).providers.find(p => p.id === 'cursor')?.models.cursor).toEqual([]); + expect(hasCursorDiscoveredModels()).toBe(false); + }); + it('preserves discovered identity and native default even when the server catalog predates Cursor', () => { + setCursorDiscoveredModels([discovered], state.owner); + const catalog = { ...BUNDLED_CATALOG, providers: BUNDLED_CATALOG.providers.filter(p => p.id !== 'cursor') }; + const result = withCursorDiscoveredModels(catalog); + expect(result.providers.find(p => p.id === 'cursor')?.models.cursor).toEqual([ + { id: discovered.id, name: discovered.displayName, contextWindow: 0, efforts: [], defaultEffort: null, newSessionDefault: ['cursor'] }, + ]); + expect(catalog.providers.some(p => p.id === 'cursor')).toBe(false); + }); + it('drops late discoveries and never reuses a different owner snapshot', () => { + setCursorDiscoveredModels([discovered], state.owner); + expect(getCursorDiscoveredModel(discovered.id)).toEqual(discovered); + expect(getCursorDiscoveredModel('missing')).toBeUndefined(); + state.owner = 'owner-b'; + expect(hasCursorDiscoveredModels()).toBe(false); + expect(getCursorDiscoveredModel(discovered.id)).toBeUndefined(); + setCursorDiscoveredModels([discovered], 'owner-a'); + expect(getCursorDiscoveredModel(discovered.id)).toBeUndefined(); + expect(withCursorDiscoveredModels(BUNDLED_CATALOG).providers.find(p => p.id === 'cursor')?.models.cursor).toEqual([]); + }); + it('projects native per-model effort and Fast capabilities for desktop and mobile pickers', () => { + setCursorDiscoveredModels([{ ...discovered, efforts: ['low', 'high', 'max'], defaultEffort: 'high', supportsFastMode: true }], state.owner); + expect(withCursorDiscoveredModels(BUNDLED_CATALOG).providers.find(p => p.id === 'cursor')?.models.cursor?.[0]) + .toMatchObject({ efforts: ['low', 'high', 'max'], defaultEffort: 'high', supportsFastMode: true }); + }); + it('preserves native presentation metadata and limits the legacy alias to admission', () => { + const model = { ...discovered, group: 'cursor:models', sortOrder: 2, description: 'Native description' }; + setCursorDiscoveredModels([model], state.owner); + const offers = (legacy = false) => withCursorDiscoveredModels(BUNDLED_CATALOG, { includeLegacyDefault: legacy }) + .providers.find(provider => provider.id === 'cursor')!.models.cursor!; + expect(offers()).toMatchObject([{ id: model.id, group: model.group, sortOrder: 2, description: model.description }]); + expect(offers().some(model => model.id === 'cursor-default')).toBe(false); + expect(offers(true).find(model => model.id === 'cursor-default')).toMatchObject({ defaultEnabled: false, efforts: [], supportsFastMode: false }); + state.owner = 'owner-b'; + expect(offers(true)).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/cursorModelRouteGuard.test.ts b/apps/desktop/src/main/maker-host/__tests__/cursorModelRouteGuard.test.ts new file mode 100644 index 00000000000..2827c23dda1 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/cursorModelRouteGuard.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BUNDLED_CATALOG, type Catalog, type ProviderView } from '@cindy/model-providers'; + +const state = vi.hoisted(() => ({ + owner: 'cursor-route-owner', + suspended: false, + disabled: false, + listProviders: vi.fn(), +})); +vi.mock('../../appSessionState.js', () => ({ activeOwnerScopeKey: () => state.owner })); +vi.mock('../active-catalog.js', () => ({ + getActiveCatalog: () => BUNDLED_CATALOG, + isXdGatewayPaymentRequiredRoute: () => false, +})); +vi.mock('../createDesktopProviderService.js', () => ({ + getDesktopProviderService: () => ({ listProviders: state.listProviders }), +})); +vi.mock('../model-disable-store.js', () => ({ readModelDisableOverrides: () => ({}) })); + +import { clearCursorDiscoveredModels, setCursorDiscoveredModels } from '../cursor-model-catalog.js'; +import { pinExclusiveSessionProvider, verdictForModelRoute } from '../model-route-guard-live.js'; + +describe('Cursor native model route admission', () => { + beforeEach(() => { + state.suspended = false; + state.disabled = false; + clearCursorDiscoveredModels(); + setCursorDiscoveredModels([{ + id: 'native-cursor-model', displayName: 'Native Cursor model', contextWindow: 0, + efforts: [], defaultEffort: null, + }], state.owner); + state.listProviders.mockImplementation(async ({ catalog }: { catalog: Catalog }): Promise => { + const cursor = catalog.providers.find(provider => provider.id === 'cursor')!; + return [{ ...cursor, connected: true, suspended: state.suspended, + models: { cursor: (cursor.models.cursor ?? []).map(model => ({ ...model, disabled: state.disabled })) } }]; + }); + }); + + it('uses discovered membership when applying a suspended Cursor connection', async () => { + state.suspended = true; + await expect(verdictForModelRoute('cursor', 'native-cursor-model', 'cursor')) + .resolves.toEqual({ kind: 'reject', reason: 'explicit-source-disabled' }); + await expect(verdictForModelRoute('cursor', 'native-cursor-model', null)) + .resolves.toEqual({ kind: 'reject', reason: 'model-disabled' }); + }); + + it('rejects a disabled native model and permits it after re-enabling', async () => { + state.disabled = true; + await expect(verdictForModelRoute('cursor', 'native-cursor-model', 'cursor')) + .resolves.toEqual({ kind: 'reject', reason: 'explicit-source-disabled' }); + state.disabled = false; + await expect(verdictForModelRoute('cursor', 'native-cursor-model', 'cursor')) + .resolves.toEqual({ kind: 'pass' }); + }); + + it('keeps existing-session resume separate from new-route admission', async () => { + state.suspended = true; + await expect(pinExclusiveSessionProvider('cursor', 'native-cursor-model', 'cursor')) + .resolves.toBeUndefined(); + }); + it('continues accepting the historical default alias while enforcing its native source state', async () => { + await expect(verdictForModelRoute('cursor', 'cursor-default', 'cursor')).resolves.toEqual({ kind: 'pass' }); + state.suspended = true; + await expect(verdictForModelRoute('cursor', 'cursor-default', 'cursor')) + .resolves.toEqual({ kind: 'reject', reason: 'explicit-source-disabled' }); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/cursorNativeHost.test.ts b/apps/desktop/src/main/maker-host/__tests__/cursorNativeHost.test.ts new file mode 100644 index 00000000000..95c58ce53a7 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/cursorNativeHost.test.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { discoverCursorAgentBinarySync } from '../cursor-binary-discovery.js'; +import { createDesktopCursorAuthAdapter, parseCursorAuthStatus } from '../cursor-auth-adapter.js'; + +describe('Cursor native host boundary', () => { + it('finds the native user-installed executable without scanning relative PATH entries', () => { + const home = '/users/test'; + const found = path.posix.join(home, '.local', 'bin', 'cursor-agent'); + const seen: string[] = []; + expect(discoverCursorAgentBinarySync({ platform: 'darwin', home, pathEnv: '.:/opt/bin', + executable: candidate => { seen.push(candidate); return candidate === found; }, + })).toEqual({ installed: true, binaryPath: found }); + expect(seen).toEqual([found]); + }); + it('uses Windows path semantics and accepts only native executables', () => { + const expected = path.win32.join('C:\\Tools', 'cursor-agent.exe'); + expect(discoverCursorAgentBinarySync({ platform: 'win32', home: 'C:\\Users\\test', + pathEnv: '.;C:\\Tools', executable: candidate => candidate === expected, + })).toEqual({ installed: true, binaryPath: expected }); + }); + it('accepts canonical agent only in known install roots, never an unrelated PATH binary', () => { + const canonical = path.posix.join('/users/test', '.local', 'bin', 'agent'); + expect(discoverCursorAgentBinarySync({ platform: 'linux', home: '/users/test', + pathEnv: '/some/tool', executable: candidate => candidate === canonical, + })).toEqual({ installed: true, binaryPath: canonical }); + expect(discoverCursorAgentBinarySync({ platform: 'linux', home: '/users/test', + pathEnv: '/some/tool', executable: candidate => candidate === '/some/tool/agent', + })).toEqual({ installed: false }); + const windows = path.win32.join('C:\\Local', 'cursor-agent', 'agent.exe'); + expect(discoverCursorAgentBinarySync({ platform: 'win32', home: 'C:\\Users\\test', + localAppData: 'C:\\Local', executable: candidate => candidate === windows, + })).toEqual({ installed: true, binaryPath: windows }); + }); + it('reports an absent binary without silently selecting another harness', () => { + expect(discoverCursorAgentBinarySync({ platform: 'linux', home: '/users/test', + pathEnv: '.', executable: () => false, + })).toEqual({ installed: false }); + }); + it('does not treat a signed-out response as authenticated', () => { + expect(parseCursorAuthStatus('Not logged in').authenticated).toBe(false); + expect(parseCursorAuthStatus('Logged in as test@example.invalid').authenticated).toBe(true); + expect(parseCursorAuthStatus('{"isAuthenticated":true}').authenticated).toBe(true); + expect(parseCursorAuthStatus('broken response').authenticated).toBe(false); + }); + it('never reads, copies, logs in, or deletes native credentials', async () => { + const adapter = createDesktopCursorAuthAdapter('/unused/cursor-agent', async () => 'Logged in'); + expect(await adapter.getState()).toMatchObject({ authenticated: true }); + expect(await adapter.getAuthEnv()).toEqual({}); + expect(await adapter.getOneShotAuth?.()).toBeNull(); + expect(await adapter.triggerLogin()).toMatchObject({ authenticated: false }); + await expect(adapter.logout()).rejects.toThrow('cursor-agent logout'); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/cursorSessionStorage.test.ts b/apps/desktop/src/main/maker-host/__tests__/cursorSessionStorage.test.ts new file mode 100644 index 00000000000..aff75d837ce --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/cursorSessionStorage.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const h = vi.hoisted(() => ({ row: {} as Record, inserted: {} as Record, model: undefined as { efforts: string[]; supportsFastMode: boolean } | undefined })); +vi.mock('../cursor-model-catalog.js', () => ({ getCursorDiscoveredModel: () => h.model })); +vi.mock('../../localDb/client/current.js', () => ({ + getDbClient: () => ({ drizzle: { + insert: () => ({ values: async (row: Record) => { h.inserted = row; h.row = row; } }), + select: () => ({ from: () => ({ where: () => ({ limit: async () => [h.row] }) }) }), + } }), +})); +vi.mock('../../localDb/mapper.js', () => ({ normalizeRemoteHostId: (value: string | undefined) => value?.trim() || null })); +vi.mock('../../localDb/schema.js', () => ({ sessions: { id: 'id' } })); +vi.mock('../../../shared/workingDir.js', () => ({ normalizeWorkingDirForStorage: (value: string | undefined) => value ?? null })); +import { DesktopSessionStorage } from '../session-storage.js'; +beforeEach(() => { h.row = {}; h.inserted = {}; h.model = undefined; }); +describe('Cursor persisted session capabilities', () => { + it('keeps the legacy NOT NULL effort storage but removes unsupported controls on reopening', async () => { + const storage = new DesktopSessionStorage(); + await storage.create({ id: 'cursor', title: 'Task', workDir: '/project', model: 'cursor-default', agentKind: 'cursor' }); + expect(h.inserted).toMatchObject({ agentKind: 'cursor', effort: 'high' }); + h.row.fastMode = true; + const reopened = await storage.get('cursor'); + expect(reopened).toMatchObject({ agentKind: 'cursor', model: 'cursor-default', fastMode: false }); + expect(reopened?.effort).toBeUndefined(); + }); + it('retains other harness effort and fast settings', async () => { + h.row = { id: 'codex', agentKind: 'codex', effort: 'high', fastMode: true }; + expect(await new DesktopSessionStorage().get('codex')).toMatchObject({ effort: 'high', fastMode: true }); + }); + it('retains Cursor tuning across reopening, including before native discovery completes', async () => { + h.row = { id: 'cursor', agentKind: 'cursor', model: 'grok-4.7', effort: 'xhigh', fastMode: true }; + const storage = new DesktopSessionStorage(); + expect(await storage.get('cursor')).toMatchObject({ effort: 'xhigh', fastMode: true }); + h.model = { efforts: ['low', 'medium', 'high', 'xhigh'], supportsFastMode: true }; + expect(await storage.get('cursor')).toMatchObject({ effort: 'xhigh', fastMode: true }); + }); + it('removes legacy placeholders when the discovered native model has no controls', async () => { + h.row = { id: 'cursor', agentKind: 'cursor', model: 'auto-native', effort: 'high', fastMode: true }; + h.model = { efforts: [], supportsFastMode: false }; + expect(await new DesktopSessionStorage().get('cursor')).toMatchObject({ effort: undefined, fastMode: false }); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/model-route-guard-live.test.ts b/apps/desktop/src/main/maker-host/__tests__/model-route-guard-live.test.ts index 194486098aa..366db276848 100644 --- a/apps/desktop/src/main/maker-host/__tests__/model-route-guard-live.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/model-route-guard-live.test.ts @@ -30,6 +30,10 @@ vi.mock('../active-catalog.js', () => ({ getActiveCatalog: () => h.catalog, })); +vi.mock('../cursor-model-catalog.js', () => ({ + withCursorDiscoveredModels: (catalog: unknown) => catalog, +})); + vi.mock('../model-disable-store.js', () => ({ readModelDisableOverrides: vi.fn(() => ({})), })); diff --git a/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts b/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts index 0bfe06efef6..35e37be56d5 100644 --- a/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts @@ -104,7 +104,7 @@ describe('getRemoteNewMakerDefaults (device-link 远程草稿镜像)', () => { }); }); - it('草稿变更广播快照始终包含 claude-code、codex、pi 三个槽', () => { + it('草稿变更广播快照始终包含全部四个引擎槽', () => { seed({ lastByVendor: { pi: { model: 'claude-sonnet-4-6' } }, modelChosenByVendor: { pi: false }, @@ -113,7 +113,7 @@ describe('getRemoteNewMakerDefaults (device-link 远程草稿镜像)', () => { }); const snapshot = getRemoteNewMakerDefaultsByVendor(); - expect(Object.keys(snapshot)).toEqual(['claudeCode', 'codex', 'pi']); + expect(Object.keys(snapshot)).toEqual(['claudeCode', 'codex', 'pi', 'cursor']); expect(snapshot.pi).toMatchObject({ model: 'claude-sonnet-4-6', modelChosenByUser: false, @@ -229,3 +229,27 @@ it('reads remembered tuning only from the matching owner snapshot, with preset-f expect(syncNewMakerDraftCache(payload, { dataOwnerId: 'B', ownerGeneration: 4 }, 'B:4', false)).toBe(false); expect(getNewMakerModelTuning('B:4', 'codex', 'provider', 'target')).toEqual({}); }); + + +describe('Cursor draft identity', () => { + it('retains a Cursor application default in the owner-fenced mirror', () => { + const owner = { dataOwnerId: 'cursor-owner', ownerGeneration: 1 }; + const selectedRoute = { harness: 'cursor', model: 'native', providerId: 'cursor', effort: '', fastMode: false }; + expect(syncNewMakerDraftCache({ ownerStamp: owner, selectedRoute, lastByVendor: {}, + fastModeByModel: {}, effortByModel: {} }, owner, 'cursor-owner:1', false)).toBe(true); + expect(getSelectedNewMakerRoute('cursor-owner:1')).toEqual(selectedRoute); + expect(getSelectedNewMakerRoute('other-owner:1')).toBeUndefined(); + }); + + it('keeps Cursor selection separate from Codex and Pi', () => { + seed({ lastByVendor: { + cursor: { model: 'cursor-native', providerId: 'cursor' }, + codex: { model: 'codex-native', providerId: 'openai' }, + pi: { model: 'pi-native', providerId: 'local' }, + }, fastModeByModel: {}, effortByModel: {} }); + expect(getRemoteNewMakerDefaults('cursor')).toMatchObject({ model: 'cursor-native', providerId: 'cursor' }); + expect(getWorkerDefaultsFromNewMaker('cursor')).toMatchObject({ model: 'cursor-native', providerId: 'cursor' }); + expect(getRemoteNewMakerDefaults('codex').model).toBe('codex-native'); + expect(getRemoteNewMakerDefaults('pi').model).toBe('pi-native'); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/providerModelRefresh.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerModelRefresh.test.ts index e277b24d090..954ca24eada 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerModelRefresh.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerModelRefresh.test.ts @@ -11,6 +11,7 @@ function deps( ): BuiltinProviderModelRefreshDeps { return { refreshXd: vi.fn(async () => {}), + refreshCursor: vi.fn(async () => {}), refreshAnthropic: vi.fn(async () => true), refreshOpenAi: vi.fn(async () => true), refreshOpenAiMedia: vi.fn(async () => true), @@ -23,6 +24,7 @@ function deps( describe('refreshBuiltinProviderModels', () => { it.each([ ['xd', 'refreshXd'], + ['cursor', 'refreshCursor'], ['anthropic', 'refreshAnthropic'], ['openai', 'refreshOpenAi'], ['xai', 'refreshXai'], diff --git a/apps/desktop/src/main/maker-host/__tests__/providerService.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerService.test.ts index 63d96ab1051..8fc06a7d494 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerService.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerService.test.ts @@ -10,7 +10,7 @@ const bundledCatalog = () => BUNDLED_CATALOG; describe('createProviderService', () => { it('snapshot mode suppresses side effects and skips both account presentation readers', async () => { - const read = vi.fn((_options: ConnectionReadOptions) => true); + const read = vi.fn<(options: ConnectionReadOptions) => boolean>(() => true); const account = vi.fn(async () => ({ source: 'unknown' as const })); const svc = createProviderService({ getCatalog: bundledCatalog, @@ -131,7 +131,7 @@ describe('createProviderService', () => { const providers = await svc.listProviders(); const byId = Object.fromEntries(providers.map((p) => [p.id, p.connected])); // gemini(内置 API-key 供应商)未注入 builtinApiKeyConnected ⇒ 缺省未连接。 - expect(byId).toEqual({ anthropic: false, openai: false, xai: false, xd: true, gemini: false }); + expect(byId).toEqual({ anthropic: false, openai: false, xai: false, xd: true, gemini: false, cursor: false }); }); it('builtin API-key provider connection follows builtinApiKeyConnected (2026-07 图像多来源)', async () => { diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index 06402f70b41..d0e42c29f0e 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -213,7 +213,7 @@ export interface XdGatewayModelInfo { /** * 新对话默认种子的 agent 标记(服务端 /models 下发的 newSessionDefault)。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: AgentKind[]; /** 展示图标 id(AI Gateway 设定;缺省 / 未知值渲染层回落来源供应商标)。 */ icon?: string; modalities?: { input: string[]; output: string[] }; @@ -706,7 +706,7 @@ function modelRegistryMetaFields( ): RegistryMetaFields | undefined { // 模型 registry 的路由与 perAgent 覆盖只按 claude-code / codex 建键;Pi 是动态 BYOM, // 无 registry per-agent 覆盖,按 agent 无关处理(取条目基线元数据)。 - const registryAgent = agent === 'pi' ? undefined : agent; + const registryAgent = agent === 'claude-code' || agent === 'codex' ? agent : undefined; const catalog = base ?? BUNDLED_CATALOG; const matched = findModelRegistryRoute(catalog.modelRegistry, providerId, modelId, registryAgent); if (!matched) return undefined; @@ -973,9 +973,10 @@ function materializeXaiAccountModels( const registry = modelRegistryMetaFields('xai', agent, entry.id); // The server catalog may predate a client-known variant. Its bundled visibility // remains a sparse fallback; explicit server and later user choices still win. + const registryAgent = agent === 'claude-code' || agent === 'codex' ? agent : undefined; const bundledEntry = findModelRegistryRoute(BUNDLED_CATALOG.modelRegistry, 'xai', entry.id, - agent === 'pi' ? undefined : agent)?.entry; - const bundledDefaultEnabled = (agent === 'pi' ? undefined : bundledEntry?.perAgent?.[agent]?.defaultEnabled) + registryAgent)?.entry; + const bundledDefaultEnabled = (registryAgent ? bundledEntry?.perAgent?.[registryAgent]?.defaultEnabled : undefined) ?? bundledEntry?.defaultEnabled; const { efforts, defaultEffort } = resolveXaiAccountCapabilities( entry, @@ -1094,8 +1095,9 @@ function declaredPiModels(providerId: string, discovered: readonly CatalogModel[ // A sibling Harness discovers membership, not Pi-specific thinking tiers. // Keep portable tiers as a fallback for unknown models; known models inherit // the Registry, and Codex-only labels cannot become Pi capabilities. - const { efforts: _efforts, defaultEffort: _defaultEffort, ...metadata } = - model.discoveredMetadata ?? catalogModelMetadata(model); + const metadata = { ...(model.discoveredMetadata ?? catalogModelMetadata(model)) }; + delete metadata.efforts; + delete metadata.defaultEffort; const efforts = model.efforts.filter(effort => PI_REASONING_EFFORTS.some(level => level === effort)); byId.set(id, { ...model, id, piApi, efforts, diff --git a/apps/desktop/src/main/maker-host/botMcpCatalog.ts b/apps/desktop/src/main/maker-host/botMcpCatalog.ts index 1a0e54640b0..f871fffecb7 100644 --- a/apps/desktop/src/main/maker-host/botMcpCatalog.ts +++ b/apps/desktop/src/main/maker-host/botMcpCatalog.ts @@ -14,6 +14,10 @@ function isCustomMcpAvailable(input: { if (input.agentKind === 'codex') { return !input.remoteHostId && input.custom?.transport !== 'sse'; } + // Cursor shares Pi's HTTP bridge and URL validation; native ACP cannot mount SSE. + if (input.agentKind === 'cursor') { + return !input.remoteHostId && (!input.custom || isPiCustomMcpProviderAvailable(input.provider)); + } if (input.agentKind === 'pi' && input.custom) { return isPiCustomMcpProviderAvailable(input.provider, { remoteHostId: input.remoteHostId }); } diff --git a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts index 5c0e730a55b..bcfbf62416c 100644 --- a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts +++ b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts @@ -261,7 +261,7 @@ export function refreshCatalogDerivedModels( target: ModelCapabilitiesTarget, catalog: Catalog, ): void { - for (const agent of ['claude-code', 'codex', 'pi'] as const) { + for (const agent of ['claude-code', 'codex', 'pi', 'cursor'] as const) { let availableModels: ModelDescriptor[]; try { availableModels = target.getCapabilities(agent).availableModels; diff --git a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts index c928106960b..88456efb99a 100644 --- a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts +++ b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts @@ -1,3 +1,4 @@ +import { withCursorDiscoveredModels, hasCursorDiscoveredModels } from './cursor-model-catalog.js'; import { readProviderPresentation } from './provider-presentation-store.js'; import { filterLegacyGptContextProfiles } from './legacy-context-profiles.js'; import { subscriptionAccountState, isXaiSubscriptionProviderId, resetSubscriptionAccountCaches } from './subscription-account-auth.js'; @@ -975,7 +976,7 @@ let singleton: ProviderService | null = null; * Cindy account session keeps the full active catalog. */ export function getDesktopSelectableCatalog(): Catalog { - return filterProviderCatalogForAccount(filterLegacyGptContextProfiles(getActiveCatalog()), { + return filterProviderCatalogForAccount(filterLegacyGptContextProfiles(withCursorDiscoveredModels(getActiveCatalog())), { canUseCindyGateway: getAppCapabilities().canUseCindyGateway, }); } @@ -1004,6 +1005,7 @@ export function getDesktopProviderService(options: { allowSideEffects?: boolean getProviderPresentation: readProviderPresentation, getCatalog: getDesktopSelectableCatalog, connection: { + cursor: () => hasCursorDiscoveredModels(), xd: () => getAppCapabilities().canUseCindyGateway && readClaudeApiKey() != null, // Claude/Codex 是原生 Harness,可继承本机 CLI 凭证;xAI 是下游 provider, // 只能读取已经由 Cindy OAuth 明确绑定的 token,禁止在连接态读取时自动认领。 diff --git a/apps/desktop/src/main/maker-host/cursor-auth-adapter.ts b/apps/desktop/src/main/maker-host/cursor-auth-adapter.ts new file mode 100644 index 00000000000..abc97eb065c --- /dev/null +++ b/apps/desktop/src/main/maker-host/cursor-auth-adapter.ts @@ -0,0 +1,31 @@ +/** Native CLI owns Cursor credentials. Cindy reads status without reading or copying secrets. */ +import { execFile } from 'node:child_process'; +import type { AuthAdapter, AuthState } from '@cindy/maker-core'; + +export function parseCursorAuthStatus(output: string): AuthState { + try { + const value = JSON.parse(output) as { isAuthenticated?: boolean; status?: string }; + return { authenticated: value.isAuthenticated === true || value.status === 'authenticated', authSource: 'oauth' }; + } catch { + return { authenticated: /\blogged in\b/i.test(output) && !/\bnot logged in\b/i.test(output), authSource: 'oauth' }; + } +} + +export function createDesktopCursorAuthAdapter(binaryPath: string, readStatus = (): Promise => + new Promise((resolve, reject) => execFile(binaryPath, ['status'], { + timeout: 15_000, maxBuffer: 64 * 1024, encoding: 'utf8', windowsHide: true, + }, (error, stdout) => error ? reject(error) : resolve(stdout))), +): AuthAdapter { + return { + async getState() { + try { return parseCursorAuthStatus(await readStatus()); } + catch { return { authenticated: false, errorReason: 'cursor_cli_login_required' }; } + }, + async triggerLogin() { + return { authenticated: false, errorReason: 'Run agent login (or cursor-agent login) in a terminal, then refresh Cursor models.' }; + }, + async logout() { throw new Error('Run agent logout (or cursor-agent logout) in a terminal to disconnect Cursor.'); }, + async getAuthEnv() { return {}; }, + async getOneShotAuth() { return null; }, + }; +} diff --git a/apps/desktop/src/main/maker-host/cursor-binary-discovery.ts b/apps/desktop/src/main/maker-host/cursor-binary-discovery.ts new file mode 100644 index 00000000000..f7597db7c07 --- /dev/null +++ b/apps/desktop/src/main/maker-host/cursor-binary-discovery.ts @@ -0,0 +1,51 @@ +/** Discover a user-installed native Cursor CLI without installing or invoking a shell. */ +import { accessSync, constants, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +export interface CursorBinaryDiscoveryDeps { + platform: NodeJS.Platform; + home: string; + pathEnv?: string; + localAppData?: string; + executable(candidate: string): boolean; +} +export type CursorBinaryStatus = { installed: true; binaryPath: string } | { installed: false }; + +export function discoverCursorAgentBinarySync(deps: CursorBinaryDiscoveryDeps = { + platform: process.platform, + home: homedir(), + pathEnv: process.env.PATH, + localAppData: process.env.LOCALAPPDATA, + executable: (candidate) => { + try { + if (!statSync(candidate).isFile()) return false; + accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK); + return true; + } catch { return false; } + }, +}): CursorBinaryStatus { + const paths = deps.platform === 'win32' ? path.win32 : path.posix; + // Do not accept .cmd/.bat wrappers: ACP uses shell:false and must never interpolate a command. + const names = deps.platform === 'win32' ? ['cursor-agent.exe'] : ['cursor-agent']; + // The official generic `agent` name is accepted only in Cursor's known install roots. + const officialDirs = [paths.join(deps.home, '.local', 'bin'), + ...(deps.platform === 'win32' && deps.localAppData && paths.isAbsolute(deps.localAppData) + ? [paths.join(deps.localAppData, 'cursor-agent')] : [])]; + for (const dir of officialDirs) { + for (const name of [...names, deps.platform === 'win32' ? 'agent.exe' : 'agent']) { + const candidate = paths.join(dir, name); + if (deps.executable(candidate)) return { installed: true, binaryPath: candidate }; + } + } + const dirs = (deps.pathEnv ?? '').split(paths.delimiter); + for (const dir of dirs) { + // Relative PATH entries must not resolve against an untrusted project working directory. + if (!paths.isAbsolute(dir)) continue; + for (const name of names) { + const candidate = paths.join(dir, name); + if (deps.executable(candidate)) return { installed: true, binaryPath: candidate }; + } + } + return { installed: false }; +} diff --git a/apps/desktop/src/main/maker-host/cursor-model-catalog.ts b/apps/desktop/src/main/maker-host/cursor-model-catalog.ts new file mode 100644 index 00000000000..01e1cfe9ba7 --- /dev/null +++ b/apps/desktop/src/main/maker-host/cursor-model-catalog.ts @@ -0,0 +1,39 @@ +/** Owner-scoped, in-memory ACP discovery; no invented models, account credentials, or disk cache. */ +import { cursorDefaultModel, type ModelDescriptor } from '@cindy/maker-core'; +import { BUNDLED_CATALOG, type Catalog } from '@cindy/model-providers'; +import { activeOwnerScopeKey } from '../appSessionState.js'; + +let snapshot: { owner: string; models: ModelDescriptor[] } | null = null; +export function setCursorDiscoveredModels(models: ModelDescriptor[], owner: string): void { + if (activeOwnerScopeKey() !== owner) return; + snapshot = { owner, models }; +} +export function clearCursorDiscoveredModels(): void { snapshot = null; } +export function getCursorDiscoveredModel(modelId: string): ModelDescriptor | undefined { + return snapshot?.owner === activeOwnerScopeKey() ? snapshot.models.find(model => model.id === modelId) : undefined; +} +export function hasCursorDiscoveredModels(): boolean { + return snapshot?.owner === activeOwnerScopeKey() && snapshot.models.length > 0; +} +export function withCursorDiscoveredModels(catalog: Catalog, options: { includeLegacyDefault?: boolean } = {}): Catalog { + const models = snapshot?.owner === activeOwnerScopeKey() ? snapshot.models : []; + // Only admission checks need the old alias. Pickers and settings advertise native membership. + const offers = options.includeLegacyDefault && models.length ? [...models, cursorDefaultModel] : models; + const builtin = BUNDLED_CATALOG.providers.find(provider => provider.id === 'cursor'); + const providers = catalog.providers.some(provider => provider.id === 'cursor') || !builtin + ? catalog.providers : [...catalog.providers, builtin]; + return { ...catalog, providers: providers.map(provider => provider.id !== 'cursor' ? provider : { + ...provider, + models: { ...provider.models, cursor: offers.map(model => ({ + id: model.id, name: model.displayName, contextWindow: model.contextWindow, + efforts: [...model.efforts], defaultEffort: model.defaultEffort, + ...(model.group !== undefined ? { group: model.group } : {}), + ...(model.sortOrder !== undefined ? { sortOrder: model.sortOrder } : {}), + ...(model.defaultEnabled !== undefined ? { defaultEnabled: model.defaultEnabled } : {}), + ...(model.supportsFastMode !== undefined ? { supportsFastMode: model.supportsFastMode } : {}), + ...(model.newSessionDefault ? { newSessionDefault: model.newSessionDefault } : {}), + ...(model.description ? { description: model.description } : {}), + ...(model.supportsImageInput !== undefined ? { supportsImageInput: model.supportsImageInput } : {}), + })) }, + }) }; +} diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index fc38968218f..c4dc63d7e16 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -1,3 +1,6 @@ +import { discoverCursorAgentBinarySync } from './cursor-binary-discovery.js'; +import { createDesktopCursorAuthAdapter } from './cursor-auth-adapter.js'; +import { setCursorDiscoveredModels, clearCursorDiscoveredModels, hasCursorDiscoveredModels } from './cursor-model-catalog.js'; import { readAgentCapabilityCatalog, RUNTIME_MCP_NAMES_KEY, type AgentCapabilityQuery } from './agentCapabilityCatalog.js'; import { listCindyManagedSkills, prepareCindyCodexSkills } from './managed-skills.js'; import { createCompanionImportProvider } from '../bot-import/importProvider.js'; @@ -36,7 +39,6 @@ import { app, BrowserWindow } from 'electron'; import { createHash, randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import fsSync from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { @@ -44,6 +46,7 @@ import { isBotMcpServerAllowed, ClaudeCodeAgent, CodexAgent, + CursorAgent, configureDefaultImageResizer, type AgentKind, type InteractionRequest, @@ -148,12 +151,7 @@ import { desktopMakerLogger } from './logger-adapter.js'; import { guardedOutboundFetch, outboundFetch } from './outbound-fetch.js'; import { readCustomProviderKey } from '../secrets/providerSecretStore.js'; import { createVisionBridge } from '../vision-bridge/vision-bridge.js'; -import { - getVisionBridgeController, - setVisionBridgeController, -} from '../vision-bridge/vision-bridge-controller.js'; -import { createToolResultImageDescriptor } from '../vision-bridge/tool-result-image-descriptor.js'; -import * as blobStore from '../cindy-media/blobStore.js'; +import { setVisionBridgeController } from '../vision-bridge/vision-bridge-controller.js'; import { buildPiVisionBridgeEnv } from '../vision-bridge/pi-vision-bridge-env.js'; import { captureCodexLocalAuthPolicy, resolveVisionBackendRoute, setVisionGatewayKeyReader } from './provider-route.js'; import { resolveSessionCcDebugFile, trackSessionCcDebugFile } from '../logger.js'; @@ -407,6 +405,14 @@ let botRuntimeResourcePreflight: | ((opts: MakerSessionCreateOpts) => Promise) | null = null; let _registerPiAgent: (() => boolean) | null = null; +let _refreshCursorModels: ((force?: boolean) => Promise) | null = null; +/** Bounded native model discovery; reusable after the user signs in outside Cindy. */ +export async function refreshCursorModels(force = false): Promise { + await _refreshCursorModels?.(force); + if (force && !hasCursorDiscoveredModels()) { + throw new Error('Cursor CLI is unavailable or signed out. Install the official Cursor CLI, run agent login, and retry. Restart Cindy after installing the CLI.'); + } +} /** 视觉桥实例(层 A/B/C 共用),在 resetMaker 时释放缓存。 */ let _visionBridgeInstance: ReturnType | null = null; @@ -956,7 +962,7 @@ export function getMaker(): Maker { if (!session || (context.sessionInstanceId && session.instanceId !== context.sessionInstanceId)) return { ok: false, errorCode: 'CALLER_UNAVAILABLE' }; const agentKind = context.agentKind; - if (agentKind !== 'claude-code' && agentKind !== 'codex' && agentKind !== 'pi') + if (agentKind !== 'claude-code' && agentKind !== 'codex' && agentKind !== 'pi' && agentKind !== 'cursor') return { ok: false, errorCode: 'UNKNOWN_AGENT' }; const catalog = await listBotRuntimeMcpServers({ agentKind, remoteHostId: context.remoteHostId }); const providers = (_mcpProviders[agentKind] ?? []).map((provider) => ({ @@ -2458,7 +2464,6 @@ export function getMaker(): Maker { getRemotePiTransport: async ( remoteHostId, { - binaryPath: _localBinaryPath, remoteBinaryPath: providedRemoteBinaryPath, args, cwd, @@ -2630,6 +2635,30 @@ export function getMaker(): Maker { const piAgent = buildPiAgentForDesktop(); if (piAgent) makerAgents.pi = piAgent; + // Cursor is optional and uses the user's native CLI/account. Discovery never sends a prompt. + const cursorBinary = discoverCursorAgentBinarySync(); + const cursorAgent = cursorBinary.installed ? new CursorAgent({ + binaryPath: cursorBinary.binaryPath, + auth: createDesktopCursorAuthAdapter(cursorBinary.binaryPath), + logger: desktopMakerLogger, + runtimeConfig: { + get makerMemoryEnabled() { return makerMemoryManager.isEnabled(); }, + userDataPath: app.getPath('userData'), + }, + makerMemory: makerMemoryManager, + mcpProviders: piMcpProviders, + preparePiExtraSpawnConfig: (providers, ctx) => { + if (!ctx?.workingDir) throw new Error('Cursor MCP requires a task working directory'); + return getPiExtraSpawnConfig(providers, desktopMakerLogger, { ...ctx, workingDir: ctx.workingDir, agentKind: 'cursor' }); + }, + registerLocalAgentProcess: ({ pid, kind, role }) => registerAgentProcess(pid, kind, role), + reviewAutoPermissionAction, + }) : null; + if (cursorAgent) { + makerAgents.cursor = cursorAgent; + _mcpProviders.cursor = piMcpProviders; + } + setVisionGatewayKeyReader(readClaudeApiKey); _visionBridgeInstance = createVisionBridge({ getProviderById: (providerId) => @@ -3018,6 +3047,35 @@ export function getMaker(): Maker { // 为 false —— 一次性调用会被 skipped-unauthed 白白消费掉唯一机会。授权就绪后的重试 // 由 codex auth 事件驱动(见下方 requestCodexModelBackfill 的调用点)。 const makerRef = _maker; + if (cursorAgent) { + const owner = activeOwnerScopeKey(); + let pending: Promise | null = null; + let lastAttempt = 0; + _refreshCursorModels = (force = false) => { + if (pending) return pending; + if (!force && Date.now() - lastAttempt < 30_000) return Promise.resolve(); + lastAttempt = Date.now(); + pending = (async () => { + try { + // The adapter owns the probe directory until native exit is confirmed. + const models = await cursorAgent.discoverModels(); + if (_maker !== makerRef || activeOwnerScopeKey() !== owner || isAppSessionBoundaryPending()) return; + setCursorDiscoveredModels(models, owner); + refreshSelectableModelsAndBroadcast({ agentKind: 'cursor' }); + } catch { + if (_maker === makerRef && activeOwnerScopeKey() === owner) { + setCursorDiscoveredModels([], owner); + refreshSelectableModelsAndBroadcast({ agentKind: 'cursor' }); + } + desktopMakerLogger.info('Cursor model discovery unavailable; sign in with cursor-agent login'); + } + })().catch(() => { + desktopMakerLogger.warn('Cursor model discovery could not prepare its workspace'); + }).finally(() => { pending = null; }); + return pending; + }; + void refreshCursorModels(); + } _sessionArchiveSync = createSessionArchiveSync({ capture: () => { const snapshot = getCurrentDbClientSnapshot(); @@ -3174,6 +3232,8 @@ export async function preflightBotRuntimeResources( * 重置 Maker 单例(切账号 / 测试用)。 */ export function resetMaker(): void { + clearCursorDiscoveredModels(); + _refreshCursorModels = null; disposeRemoteAgentHost(); _sessionArchiveSync?.stop(); _sessionArchiveSync = null; diff --git a/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts b/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts index 389e9eee27e..1b132d6ce1d 100644 --- a/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts +++ b/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts @@ -477,7 +477,7 @@ export function applyLocalOverridesToRoot( warnings: ModelPlaneWarning[] = [], policyProviderId = providerId, ): CatalogModel[] { - let out = [...models]; + const out = [...models]; for (const [key, entry] of Object.entries(overrides.additions)) { const parsed = parseKey(key); if (!parsed || parsed.providerId !== providerId) continue; @@ -600,7 +600,7 @@ export function hasLocalContextWindowOverride( const entry = overrides[section][`${encodeURIComponent(providerId)}:${modelId}`]; return ( entry && - (section === 'patches' || agent === 'pi' + (section === 'patches' || agent === 'pi' || agent === 'cursor' ? (!entry.agents || entry.agents.includes(agent)) : entryMembershipAgents(entry, policyProviderId).includes(agent)) && effectiveFields(entry, agent).contextWindow !== undefined diff --git a/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts b/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts index 7d5d807c6ed..a195740fcb0 100644 --- a/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts +++ b/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts @@ -85,7 +85,7 @@ export function isRegistryTombstoneForConsumer( const policy = MODEL_PLANE_POLICIES.get(providerId); if (!registry || !policy) return false; - if (agent === 'pi') return false; + if (agent !== 'claude-code' && agent !== 'codex') return false; const registryAgent = policy.roots.includes(agent) || policy.membershipGatedBridges.includes(agent) ? agent : null; if (!registryAgent) return false; diff --git a/apps/desktop/src/main/maker-host/model-route-guard-live.ts b/apps/desktop/src/main/maker-host/model-route-guard-live.ts index 57168beaa61..66c90598b5c 100644 --- a/apps/desktop/src/main/maker-host/model-route-guard-live.ts +++ b/apps/desktop/src/main/maker-host/model-route-guard-live.ts @@ -33,6 +33,7 @@ import { isXdGatewayPaymentRequiredRoute, } from './active-catalog.js'; import { readModelDisableOverrides } from './model-disable-store.js'; +import { withCursorDiscoveredModels } from './cursor-model-catalog.js'; import { getNewTaskDraftSelection } from './newMakerDefaultsCache.js'; import { isRegistryTombstoneForConsumer, @@ -53,11 +54,16 @@ import { * hidden media model and make checkModelRoute treat it as catalog-unknown. */ async function listRouteGuardProviders( - catalog = getActiveCatalog(), + catalog = routeGuardCatalog(), ): Promise { return getDesktopProviderService().listProviders({ catalog }); } +/** Native membership plus the admission-only alias required by older saved routes. */ +function routeGuardCatalog() { + return withCursorDiscoveredModels(getActiveCatalog(), { includeLegacyDefault: true }); +} + function tombstoneGuardOptions( catalog: ReturnType, ): ModelRouteGuardOptions { @@ -83,7 +89,7 @@ function listTrustedDefaultRouteProviders(): Promise { return getDesktopProviderService().listProviders({ allowSideEffects: true, waitForDiscovery: true, - getCatalog: getActiveCatalog, + getCatalog: routeGuardCatalog, }); } @@ -147,7 +153,7 @@ export async function resolveNewTaskDefaultModelRoute( /** 该 agent 的静态原生默认来源偏好(nativeDefaultSourceId 的无 rail 近似)。 */ function staticNativeDefaults(agent: AgentKind): readonly string[] { - return agent === 'codex' ? ['openai', 'xd'] : ['xd']; + return agent === 'cursor' ? ['cursor'] : agent === 'codex' ? ['openai', 'xd'] : ['xd']; } /** @@ -195,7 +201,7 @@ export async function verdictForModelRoute( model: string, providerId: string | null, ): Promise { - const catalog = getActiveCatalog(); + const catalog = routeGuardCatalog(); const guardOptions = tombstoneGuardOptions(catalog); let views: ProviderView[]; try { @@ -247,7 +253,7 @@ export async function resolveLenientSessionRoute( /** 仅 desiredFastMode=true 且路由被本解析改动时给出:落地拷贝不支持 Fast ⇒ false。 */ fastMode?: boolean; }> { - const catalog = getActiveCatalog(); + const catalog = routeGuardCatalog(); const guardOptions = tombstoneGuardOptions(catalog); let views: ProviderView[]; try { @@ -302,7 +308,7 @@ export async function resolveScheduledModelSelectionLive( selection: ScheduledModelSelection, ): Promise { const providers = await getDesktopProviderService().listProviders({ - allowSideEffects: false, catalog: getActiveCatalog(), + allowSideEffects: false, catalog: routeGuardCatalog(), }); return resolveScheduledModelSelection(selection, providers); } @@ -349,6 +355,7 @@ const DEFAULT_ONESHOT_MODEL: Record = { codex: 'gpt-5.4-mini', // pi oneShot 未实现(BaseAgent 默认抛 NotSupported);占位与 claude 同款网关小模型。 pi: 'claude-haiku-4-5', + cursor: 'cursor-default', }; /** diff --git a/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts b/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts index fa69b921442..a2fb11e086d 100644 --- a/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts +++ b/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts @@ -1,4 +1,4 @@ -import { normalizeBotModelChain, type BotModelRoute } from '../../shared/botModelChain.js'; +import { normalizeAppModelRoute, type AppModelRoute } from '../../shared/appDefaultModelSelection.js'; import { isDataOwnerPushStamp, type DataOwnerPushStamp } from '../../shared/dataOwnerPush.js'; import { DEFAULT_ORCA_WORKER_PERMISSION_MODE, @@ -19,7 +19,7 @@ import { * Vendor 名称差异: renderer 用 'cc' / 'codex' / 'pi'; worker spawn 路径用 * 'claude-code' / 'codex' / 'pi'。getWorkerDefaultsFromNewMaker 内部做映射。 */ -type VendorKey = 'cc' | 'codex' | 'pi'; +type VendorKey = 'cc' | 'codex' | 'pi' | 'cursor'; interface VendorPrefsSnapshot { model?: string; @@ -36,7 +36,7 @@ interface VendorPrefsSnapshot { export interface NewMakerDraftSnapshot { /** Model picker preferences captured in the same owner-fenced envelope. */ providerModelMemory?: ProviderModelMemorySnapshot; - selectedRoute?: BotModelRoute; + selectedRoute?: AppModelRoute; lastByVendor: Partial>; /** 每个 vendor 是否由用户在 New Maker picker 明确选过模型;旧 renderer 缺省不提供。 */ modelChosenByVendor?: Partial>; @@ -102,7 +102,7 @@ export function syncNewMakerDraftCache( const record = (value: unknown) => !!value && typeof value === 'object' && !Array.isArray(value); if (!record(p.lastByVendor) || !record(p.fastModeByModel) || !record(p.effortByModel)) return false; setNewMakerDraftCache({ - selectedRoute: normalizeBotModelChain([p.selectedRoute])[0], + selectedRoute: normalizeAppModelRoute(p.selectedRoute) ?? undefined, ...(record(p.providerModelMemory) ? { providerModelMemory: p.providerModelMemory } : {}), lastByVendor: p.lastByVendor!, ...(record(p.modelChosenByVendor) ? { modelChosenByVendor: p.modelChosenByVendor } : {}), @@ -144,7 +144,7 @@ export interface WorkerDefaultsFromNewMaker { * 权威 `${agent}:*` 全局槽优先,来源副本兜底(被控端旧快照可能只写过来源槽)。 */ export function getThinkingEnabledFromMemory( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', providerId: string | null | undefined, model: string | undefined, ): boolean | undefined { @@ -160,10 +160,10 @@ export function getThinkingEnabledFromMemory( * 缓存未就绪 / 该 vendor 没有偏好 → 返回空对象, 调用方按自己的兜底规则处理。 */ export function getWorkerDefaultsFromNewMaker( - workerAgent: 'claude-code' | 'codex' | 'pi', + workerAgent: 'claude-code' | 'codex' | 'pi' | 'cursor', ): WorkerDefaultsFromNewMaker { if (!cache) return {}; - const vendor: VendorKey = workerAgent === 'claude-code' ? 'cc' : workerAgent === 'pi' ? 'pi' : 'codex'; + const vendor: VendorKey = workerAgent === 'claude-code' ? 'cc' : workerAgent; const prefs = cache.lastByVendor[vendor]; if (!prefs?.model) return {}; const model = prefs.model; @@ -182,7 +182,7 @@ export function getWorkerDefaultsFromNewMaker( */ export function getNewTaskDraftSelection( ownerScope: string, - agent: 'claude-code' | 'codex' | 'pi', + agent: 'claude-code' | 'codex' | 'pi' | 'cursor', ): { model?: string; providerId?: string | null; chosenByUser: boolean } | null { if (!cache || selectedRouteOwner !== ownerScope) return null; const vendor: VendorKey = agent === 'claude-code' ? 'cc' : agent; @@ -232,10 +232,10 @@ export interface RemoteNewMakerDefaults { } export function getRemoteNewMakerDefaults( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', ): RemoteNewMakerDefaults { const vendor: VendorKey = - agentKind === 'claude-code' ? 'cc' : agentKind === 'pi' ? 'pi' : 'codex'; + agentKind === 'claude-code' ? 'cc' : agentKind; // providerModelMemory(草稿列表行真实读源)与「该 vendor 是否选过模型」无关:即便 cache 未就绪 / // 该 vendor 无选中模型(lastByVendor 空),只要被控端有模型级预设就要全量回给控制端, // 否则 req1「完整镜像被控端草稿模型列表」在这条边界上回落 capabilities 默认。故在所有早返回里都带上它。 @@ -269,21 +269,23 @@ export function getRemoteNewMakerDefaultsByVendor(): { claudeCode: RemoteNewMakerDefaults; codex: RemoteNewMakerDefaults; pi: RemoteNewMakerDefaults; + cursor: RemoteNewMakerDefaults; } { return { claudeCode: getRemoteNewMakerDefaults('claude-code'), codex: getRemoteNewMakerDefaults('codex'), pi: getRemoteNewMakerDefaults('pi'), + cursor: getRemoteNewMakerDefaults('cursor'), }; } /** Read only the active owner’s current selection; never reuse another account’s mirror. */ -export function getSelectedNewMakerRoute(ownerScope: string): BotModelRoute | undefined { +export function getSelectedNewMakerRoute(ownerScope: string): AppModelRoute | undefined { return selectedRouteOwner === ownerScope ? cache?.selectedRoute : undefined; } /** Same snapshot as the selected route; never consume the unfenced legacy preference cache. */ -export function getNewMakerModelTuning(ownerScope: string, agent: 'claude-code' | 'codex' | 'pi', +export function getNewMakerModelTuning(ownerScope: string, agent: 'claude-code' | 'codex' | 'pi' | 'cursor', providerId: string, model: string): { effort?: string; fastMode?: boolean } { if (selectedRouteOwner !== ownerScope || !cache) return {}; const memory = cache.providerModelMemory; diff --git a/apps/desktop/src/main/maker-host/provider-model-refresh.ts b/apps/desktop/src/main/maker-host/provider-model-refresh.ts index 96ac48cedb0..89d96684d01 100644 --- a/apps/desktop/src/main/maker-host/provider-model-refresh.ts +++ b/apps/desktop/src/main/maker-host/provider-model-refresh.ts @@ -26,6 +26,7 @@ export async function refreshModelsWithCatalog(deps: { export interface BuiltinProviderModelRefreshDeps { refreshXd(): Promise; + refreshCursor?(): Promise; refreshAnthropic(): Promise; refreshOpenAi(): Promise; refreshOpenAiMedia(): Promise; @@ -38,6 +39,10 @@ export async function refreshBuiltinProviderModels( deps: BuiltinProviderModelRefreshDeps, ): Promise { switch (providerId) { + case 'cursor': + if (!deps.refreshCursor) throw new Error('Cursor model discovery is unavailable'); + await deps.refreshCursor(); + return; case 'xd': await deps.refreshXd(); return; diff --git a/apps/desktop/src/main/maker-host/provider-service.ts b/apps/desktop/src/main/maker-host/provider-service.ts index c6a61f487d4..662a9c9be93 100644 --- a/apps/desktop/src/main/maker-host/provider-service.ts +++ b/apps/desktop/src/main/maker-host/provider-service.ts @@ -64,6 +64,8 @@ export interface ProviderListOptions extends ConnectionReadOptions { /** 内置三家供应商「是否已连接」的判定器(由 host 注入,读各自凭证存储)。 */ export interface ProviderConnectionReaders { + /** Native ACP discovery is the connection authority; no HTTP routing is involved. */ + cursor?: (opts: ConnectionReadOptions) => boolean | Promise; /** XD 网关:托管 api_key 是否存在。 */ xd: (opts: ConnectionReadOptions) => boolean | Promise; /** Anthropic:内置 Claude Code CLI 是否已登录且 Cindy 获准使用。 */ @@ -146,18 +148,21 @@ export function createProviderService(deps: ProviderServiceDeps): ProviderServic snapshotOnly: opts?.snapshotOnly === true, waitForDiscovery: opts?.waitForDiscovery === true, }; - const [xd, anthropic, openai, xai] = await Promise.all([ + const [xd, anthropic, openai, xai, cursor] = await Promise.all([ Promise.resolve(deps.connection.xd(readOpts)), Promise.resolve(deps.connection.anthropic(readOpts)), Promise.resolve(deps.connection.openai(readOpts)), Promise.resolve(deps.connection.xai(readOpts)), + Promise.resolve(deps.connection.cursor?.(readOpts) ?? false), ]); const catalog = opts?.getCatalog?.() ?? opts?.catalog ?? deps.getCatalog(); - const connected: ConnectionState = { xd, anthropic, openai, xai }; + const connected: ConnectionState = { xd, anthropic, openai, xai, cursor }; for (const p of catalog.providers) { // 无鉴权供应商无需任何登录或密钥:只要目录声明有效,就可立即参与模型选择。 // 该规则与 source 无关,覆盖远端目录下发的 built-in/self-hosted 条目。 - if (p.auth.method === 'none') { + if (p.id === 'cursor') { + connected[p.id] = cursor; + } else if (p.auth.method === 'none') { connected[p.id] = p.agents.some((agent) => { const routing = p.routing[agent]; return routing !== undefined && routing.disabled !== true; diff --git a/apps/desktop/src/main/maker-host/session-storage.ts b/apps/desktop/src/main/maker-host/session-storage.ts index 66161a17085..6d6f15aa181 100644 --- a/apps/desktop/src/main/maker-host/session-storage.ts +++ b/apps/desktop/src/main/maker-host/session-storage.ts @@ -26,8 +26,9 @@ import { sessions } from '../localDb/schema.js'; import { normalizeRemoteHostId } from '../localDb/mapper.js'; import { DESKTOP_VISIBLE_SESSION_SOURCES } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; +import { getCursorDiscoveredModel } from './cursor-model-catalog.js'; -type DbAgentKind = 'cc' | 'codex' | 'pi'; +type DbAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; // 形态映射走 shared/agentKindConversion 正本(支持 pi;此前 pi 被误落成 codex)。 function toDbKind(k: AgentKind): DbAgentKind { @@ -45,6 +46,8 @@ function normalizeWorkspaceKind(value: unknown): WorkspaceKind { type SessionRow = typeof sessions.$inferSelect; function rowToMeta(row: SessionRow): SessionMeta { + const cursorModel = row.agentKind === 'cursor' ? getCursorDiscoveredModel(row.model) : undefined; + const cursorDefault = row.agentKind === 'cursor' && row.model === 'cursor-default'; // 注意: row.status (DB 的 'active'|'archived'|'deleted') 是产品语义, 由 sidebar IPC 自管, // 不映射到 SessionMeta —— maker-core 接口已不再持有 status 字段。 return { @@ -54,9 +57,11 @@ function rowToMeta(row: SessionRow): SessionMeta { title: row.title, model: row.model, workspaceKind: row.workspaceKind, - effort: row.effort, + // Ignore legacy NOT NULL placeholders for fixed-effort native models, while + // retaining persisted tuning (including while discovery is still pending). + effort: cursorDefault || cursorModel?.efforts.length === 0 ? undefined : row.effort, permissionMode: row.permissionMode, - fastMode: row.fastMode, + fastMode: cursorDefault || cursorModel?.supportsFastMode === false ? false : row.fastMode, planMode: row.planModeEnabled, ...(row.source === 'review' ? { reviewMode: true as const } : {}), sdkSessionId: row.sdkSessionId ?? undefined, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts index 2a8743f5eba..77f6a7ce853 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts @@ -61,15 +61,15 @@ describe('maker auth IPC handlers', () => { expect(getAgentAuthState).toHaveBeenCalledWith('codex'); }); - it('accepts Pi at the auth IPC boundary exposed by preload', async () => { + it.each(['pi', 'cursor'])('accepts %s at the auth IPC boundary exposed by preload', async (agentKind) => { const harness = new IpcHarness(); const getAgentAuthState = vi.fn().mockResolvedValue({ authenticated: true }); registerMakerAuthHandlers(harness, createMakerStub({ getAgentAuthState }), vi.fn(), () => null); - await expect(harness.invoke(MAKER_INVOKE.AUTH_GET_STATE, 'pi')).resolves.toEqual({ + await expect(harness.invoke(MAKER_INVOKE.AUTH_GET_STATE, agentKind)).resolves.toEqual({ authenticated: true, }); - expect(getAgentAuthState).toHaveBeenCalledWith('pi'); + expect(getAgentAuthState).toHaveBeenCalledWith(agentKind); }); it('normalizes login progress and broadcasts final auth state', async () => { diff --git a/apps/desktop/src/main/maker-ipc/__tests__/localCliDetect.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/localCliDetect.test.ts index 1b9fa822bb0..6aa460c238f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/localCliDetect.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/localCliDetect.test.ts @@ -3,7 +3,7 @@ * 全内存 stub 零落盘)。claude 走跨平台 hasClaudeLogin,codex 走文件 stat。 */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { join } from 'node:path'; import { scanLocalCliAuth, type LocalCliScanDeps } from '../localCliDetect.js'; @@ -23,6 +23,7 @@ function depsWith( isDirectory: async (p) => dirSet.has(p), isFile: async (p) => fileSet.has(p), hasClaudeLogin: () => claudeLogin, + probeCursorCli: async () => ({ installed: false, loggedIn: false }), isCredentialSharedWithCindy: (cli) => shared(cli), }; } @@ -30,7 +31,7 @@ function depsWith( describe('scanLocalCliAuth', () => { it('都未安装未登录 → installed/loggedIn 全 false', async () => { const r = await scanLocalCliAuth(depsWith([], [], false)); - expect(r).toHaveLength(2); + expect(r).toHaveLength(3); expect(r.every((d) => !d.installed && !d.loggedIn)).toBe(true); }); @@ -76,6 +77,7 @@ describe('scanLocalCliAuth', () => { throw new Error('EACCES'); }, hasClaudeLogin: () => false, + probeCursorCli: async () => ({ installed: false, loggedIn: false }), isCredentialSharedWithCindy: () => true, }; await expect(scanLocalCliAuth(deps)).rejects.toThrow('EACCES'); @@ -123,4 +125,23 @@ describe('scanLocalCliAuth', () => { expect(r.find((d) => d.cli === 'codex-cli')).toMatchObject({ sharedWithCindy: true }); expect(r.find((d) => d.cli === 'claude-cli')).toMatchObject({ sharedWithCindy: true }); }); + + it.each([false, true])('detects the installed native Cursor CLI independently of model discovery (loggedIn=%s)', async (loggedIn) => { + const deps = depsWith([], []); + deps.probeCursorCli = vi.fn(async () => ({ installed: true, loggedIn })); + const results = await scanLocalCliAuth(deps); + expect(results.find((d) => d.cli === 'cursor-cli')).toEqual({ + cli: 'cursor-cli', providerId: 'cursor', installed: true, loggedIn, sharedWithCindy: loggedIn, + }); + expect(deps.probeCursorCli).toHaveBeenCalledOnce(); + }); + + it('isolates a failed Cursor status probe from the other native CLI suggestions', async () => { + const deps = depsWith(['.codex'], [join('.codex', 'auth.json')], true); + deps.probeCursorCli = async () => { throw new Error('probe unavailable'); }; + const results = await scanLocalCliAuth(deps); + expect(results.find((d) => d.cli === 'cursor-cli')).toMatchObject({ installed: false, loggedIn: false }); + expect(results.find((d) => d.cli === 'codex-cli')).toMatchObject({ installed: true, loggedIn: true }); + expect(results.find((d) => d.cli === 'claude-cli')).toMatchObject({ installed: true, loggedIn: true }); + }); }); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/mcpHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/mcpHandlers.test.ts index e79a46dc895..e5d964c459f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/mcpHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/mcpHandlers.test.ts @@ -86,7 +86,7 @@ afterEach(() => { }); describe('mcp:custom:* CRUD handlers', () => { - it.each(['claude-code', 'codex', 'pi'] as const)( + it.each(['claude-code', 'codex', 'pi', 'cursor'] as const)( 'projects registered MCP availability for %s while preserving the configuration list', async (agentKind) => { mountDb(); @@ -125,7 +125,7 @@ describe('mcp:custom:* CRUD handlers', () => { expect(result).toEqual({ agentKind, servers: rawList.map((server) => ({ ...server, available: ['mytools', 'local-http'].includes(server.id) || (server.id === 'events' && agentKind === 'claude-code') - || (server.id === 'public-http' && agentKind !== 'pi'), + || (server.id === 'public-http' && agentKind !== 'pi' && agentKind !== 'cursor'), })) }); }, ); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts index 809e956895b..568d271e716 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts @@ -1955,17 +1955,20 @@ describe('OrcaTeamService', () => { ]); }); - it.each(['done', 'error'] as const)( - 'updates worker %s status, broadcasts, and auto-bridges pending output', - async (status) => { + it.each((['codex', 'pi', 'cursor'] as const).flatMap(agentKind => + (['done', 'error'] as const).map(status => ({ agentKind, status }))))( + 'preserves $agentKind worker identity and auto-bridges $status output', + async ({ agentKind, status }) => { const leadMessages: string[] = []; - const { calls, getWorker, service } = createDeps({ + const { calls, getWorker, setWorker, service } = createDeps({ sendAutoBridgeToLead: vi.fn(async (_leadSessionId, message) => { leadMessages.push(message); return { accepted: true }; }), }); + const worker = createWorker(); + setWorker({ ...worker, session: { ...worker.session, agentKind } }); await service.sendToWorker({ callerLeadSessionId: 'lead-1', targetSessionId: 'worker-session-1', @@ -1978,6 +1981,7 @@ describe('OrcaTeamService', () => { }); expect(getWorker().status).toBe(status); + expect(getWorker().session.agentKind).toBe(agentKind); expect(calls).toEqual([ 'dispatchWorkerMessage:worker-1', 'updateWorkerStatus:running', diff --git a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts index 1a0a414d33f..747cdbdb7fd 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts @@ -730,12 +730,13 @@ const WORKER_SESSION_ID = '123e4567-e89b-42d3-a456-426614174000'; describe('buildNoProviderMessage (pi first-class)', () => { const snap = (name: string): OrcaWorkerProviderSnapshot => ({ name }) as OrcaWorkerProviderSnapshot; it('names Pi (not Claude Code) when pi has no connected provider', () => { - const msg = buildNoProviderMessage('pi', { 'claude-code': [], codex: [], pi: [] }); + const msg = buildNoProviderMessage('pi', { cursor: [], 'claude-code': [], codex: [], pi: [] }); expect(msg).toContain('Pi 当前没有可用的模型供应商'); expect(msg).not.toContain('Claude Code 当前没有'); }); it('suggests pi as a fallback agent when pi alone has a connected provider', () => { const msg = buildNoProviderMessage('codex', { + cursor: [], 'claude-code': [], codex: [], pi: [snap('Cindy AI')], @@ -767,6 +768,7 @@ function providerRoutingContext( 'claude-code': partial['claude-code'] ?? [], codex: partial.codex ?? [], pi: partial.pi ?? [], + cursor: partial.cursor ?? [], }; return { availability, @@ -2252,6 +2254,7 @@ describe('OrcaWorkerCreationService', () => { const { deps, service } = createDeps({ getWorkerPermissionMode: vi.fn(() => workerPermissionMode), getProviderRoutingContext: vi.fn(async () => providerRoutingContext({ + cursor: [], 'claude-code': [{ id: 'xd', name: 'XD Gateway', models: ['claude-sonnet-4-6'] }], codex: [{ id: 'xd', name: 'XD Gateway', models: ['gpt-5.5'] }], pi: [{ id: 'xd', name: 'XD Gateway', models: ['claude-sonnet-4-6'] }], @@ -2597,6 +2600,7 @@ describe('OrcaWorkerCreationService', () => { it('pins the sole runtime provider when only the worker model is explicit', async () => { const availability = { + cursor: [], 'claude-code': [], codex: [ { id: 'custom-codex', name: 'Custom Codex', models: ['gpt-5.5'] }, @@ -2908,6 +2912,7 @@ describe('OrcaWorkerCreationService', () => { describe('buildNoProviderMessage', () => { it('suggests the other agent when it has a connected provider', () => { const msg = buildNoProviderMessage('codex', { + cursor: [], 'claude-code': [{ id: 'xd', name: 'XD Gateway', models: ['claude-sonnet-4-6'] }], pi: [], codex: [], @@ -2918,7 +2923,7 @@ describe('buildNoProviderMessage', () => { }); it('omits the agent suggestion when no agent has a connected provider', () => { - const msg = buildNoProviderMessage('claude-code', { 'claude-code': [], codex: [], pi: [] }); + const msg = buildNoProviderMessage('claude-code', { cursor: [], 'claude-code': [], codex: [], pi: [] }); expect(msg).toContain('Claude Code 当前没有可用的模型供应商'); expect(msg).toContain('设置 → 模型供应商'); expect(msg).not.toContain('改用'); @@ -3331,6 +3336,7 @@ describe('SSH remote worker model/provider compatibility gate (R23 P2)', () => { const { service, deps } = createDeps({ getLeadSessionRow: vi.fn(async () => remoteLeadRow), getProviderRoutingContext: vi.fn(async () => providerRoutingContext({ + cursor: [], 'claude-code': [], codex: [{ id: 'xd', name: 'XD Gateway', models: ['gpt-5.5'] }], pi: [{ id: 'xd', name: 'XD Gateway', models: ['claude-sonnet-4-6'] }], @@ -3439,7 +3445,7 @@ describe('SSH remote worker model/provider compatibility gate (R23 P2)', () => { it('applies plan limit in atomic reservation',async()=>{const {deps,service}=createDeps({validateCreationPlan:vi.fn(async()=>2)});await service.createWorker({leadSessionId:'lead-1',role:'eval',agent:'codex',label:'sample'});expect(deps.reserveWorkerCreation).toHaveBeenCalledWith(expect.objectContaining({hardLimit:2}));}); it('uses one canonical label at every plan check without mutating the caller', async () => { - const validateCreationPlan = vi.fn(async (_params: OrcaWorkerCreateParams) => 2); + const validateCreationPlan = vi.fn<(params: OrcaWorkerCreateParams) => Promise>(async () => 2); const { service } = createDeps({ validateCreationPlan }); const params = Object.freeze({ leadSessionId: 'lead-1', teamId: 'team-1', role: 'eval', agent: 'codex' as const, label: ' SAMPLE ', workerPermissionMode: 'auto' as const }); await expect(service.createWorkerInTeam(params)).resolves.toMatchObject({ ok: true }); @@ -3697,3 +3703,45 @@ it('rejects a plan changed during preparation without reserving or bootstrapping const result=await service.createWorker({leadSessionId:'lead-1',role:'eval',agent:'codex',label:'sample'}); expect(result.ok).toBe(false);expect(deps.reserveWorkerCreation).not.toHaveBeenCalled();expect(deps.bootstrapSession).not.toHaveBeenCalled(); }); + + +describe('Cursor native worker admission', () => { + it('rejects Cursor on another execution device before reserving or opening a Worker', async () => { + const { deps, service } = createDeps(); + const result = await service.createWorker({ + leadSessionId: 'lead-1', role: 'worker', label: 'cursor', agent: 'cursor', executionDeviceId: 'device-b', + }); + expect(result).toMatchObject({ ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require a local workspace' }); + expect(deps.reserveWorkerCreation).not.toHaveBeenCalled(); + expect(deps.bootstrapSession).not.toHaveBeenCalled(); + }); + it('rejects Cursor when its Agent would run on another computer', async () => { + const { deps, service } = createDeps(); + const result = await service.createWorker({ + leadSessionId: 'lead-1', role: 'worker', label: 'cursor-remote', agent: 'cursor', + agentDeviceId: 'device-b', model: 'native', providerId: 'cursor', + }); + expect(result).toMatchObject({ ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require a local workspace' }); + expect(deps.reserveWorkerCreation).not.toHaveBeenCalled(); + expect(deps.bootstrapSession).not.toHaveBeenCalled(); + }); + it('preserves the selected worker permission preference for Cursor', async () => { + const { deps, service } = createDeps({ + getAvailableModels: () => [{ id: 'native', efforts: [], defaultEffort: null }], + getProviderRoutingContext: async () => providerRoutingContext({ cursor: [{ id: 'cursor', name: 'Cursor', models: ['native'] }] }), + }); + const result = await service.createWorker({ leadSessionId: 'lead-1', role: 'worker', label: 'cursor', agent: 'cursor', model: 'native', providerId: 'cursor' }); + expect(result).toMatchObject({ ok: true }); + expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({ agentKind: 'cursor', permissionMode: 'auto' })); + }); + it.each(['ask', 'auto', 'bypassPermissions'] as const)('honors explicit Cursor worker permission %s', async workerPermissionMode => { + const { deps, service } = createDeps({ + getAvailableModels: () => [{ id: 'native', efforts: [], defaultEffort: null }], + getProviderRoutingContext: async () => providerRoutingContext({ cursor: [{ id: 'cursor', name: 'Cursor', models: ['native'] }] }), + }); + const result = await service.createWorker({ leadSessionId: 'lead-1', role: 'worker', label: 'cursor', agent: 'cursor', + workerPermissionMode, model: 'native', providerId: 'cursor' }); + expect(result).toMatchObject({ ok: true }); + expect(deps.bootstrapSession).toHaveBeenCalledWith(expect.objectContaining({ agentKind: 'cursor', permissionMode: workerPermissionMode })); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/pluginTaskSnapshot.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/pluginTaskSnapshot.test.ts index f3b6e38ea0b..0bccc424d5a 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pluginTaskSnapshot.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pluginTaskSnapshot.test.ts @@ -150,7 +150,7 @@ it.each(['provider', 'receipt', 'directory', 'creator'])('keeps default-route co readProviderRouting: async () => { if (++lookups === 1 && phase === 'provider') change(); const providers = ['one','two'].map(id => ({ id, name: id, models: ['model','other'] })); - return { availability: { 'claude-code': providers, codex: providers, pi: providers }, resolveDefaultProviderIdForModel: () => 'one' }; + return { availability: { cursor: [], 'claude-code': providers, codex: providers, pi: providers }, resolveDefaultProviderIdForModel: () => 'one' }; }, }), routeUnavailable: () => { throw Error('unavailable'); }, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts index 7cea2c65766..3981330988f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts @@ -51,6 +51,7 @@ const providerRouting = ( defaults: Partial> = {}, ): OrcaWorkerProviderRoutingContext => ({ availability: { + cursor: [], 'claude-code': [{ id: 'anthropic', name: 'Anthropic', @@ -281,6 +282,7 @@ describe('resolveSendToSessionExecutionConfig', () => { const undeclared = { id: 'custom/step-5-preview', efforts: [], defaultEffort: null, effortsUnknown: true }; const routing: OrcaWorkerProviderRoutingContext = { availability: { + cursor: [], codex: [], pi: [], 'claude-code': [{ id: 'custom-anthropic', @@ -356,6 +358,7 @@ describe('resolveSendToSessionExecutionConfig', () => { }], providerRouting: { availability: { + cursor: [], 'claude-code': [], codex: [{ id: 'xd', diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts index d10a39a9133..db2699dbc5d 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts @@ -558,7 +558,7 @@ describe('deferred switch (turn running)', () => { const store = new Map< string, { - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null | undefined; effort?: string; @@ -1305,7 +1305,10 @@ describe('远程 Agent:选模型时换 Agent 所在电脑', () => { it('不带位置(旧控制端 / 内部调用)保持原位置,同引擎仍走换模型', async () => { const h = relocationHarness(); - const { agentDeviceId: _omit, ...withoutLocation } = backToTaskComputer; + const withoutLocation: Omit & { + agentDeviceId?: typeof backToTaskComputer.agentDeviceId; + } = { ...backToTaskComputer }; + delete withoutLocation.agentDeviceId; await performSessionAgentSwitch(h.deps, withoutLocation); expect(h.selectSameAgentModel).toHaveBeenCalledTimes(1); expect(h.pending.get('s1')?.targetAgentDeviceId).toBeUndefined(); @@ -1682,3 +1685,55 @@ describe('relocateForFreshStart (opening a task elsewhere when its own computer expect(noModel.deps.applyAgentSwitchToDb).not.toHaveBeenCalled(); }); }); + +describe('Cursor switch capability normalization', () => { + it.each(['ask', 'default', 'auto', 'bypassPermissions'] as const)('preserves supported permission %s when switching to Cursor', async permissionMode => { + const { deps } = makeDeps({ getSessionRow: async () => makeRow({ permissionMode }) }); + await performSessionAgentSwitch(deps, { sessionId: 's1', targetAgentKind: 'cursor', model: 'native', providerId: 'cursor', applyNow: true }); + expect(deps.applyAgentSwitchToDb).toHaveBeenCalledWith('s1', expect.objectContaining({ agentKind: 'cursor', permissionMode })); + }); + it.each(['acceptEdits', 'plan'] as const)('tightens unsupported %s permission to Ask when switching to Cursor', async permissionMode => { + const { deps } = makeDeps({ getSessionRow: async () => makeRow({ permissionMode }) }); + await performSessionAgentSwitch(deps, { sessionId: 's1', targetAgentKind: 'cursor', model: 'native', applyNow: true }); + expect(deps.applyAgentSwitchToDb).toHaveBeenCalledWith('s1', expect.objectContaining({ permissionMode: 'ask' })); + }); + it('defaults missing permission to Ask and disables unspecified Fast', async () => { + const { deps } = makeDeps(); + await performSessionAgentSwitch(deps, { sessionId: 's1', targetAgentKind: 'cursor', model: 'native', providerId: 'cursor', applyNow: true }); + expect(deps.applyAgentSwitchToDb).toHaveBeenCalledWith('s1', expect.objectContaining({ + agentKind: 'cursor', model: 'native', permissionMode: 'ask', fastMode: false, + })); + }); + it('retains explicit native tuning when switching to Cursor', async () => { + const { deps } = makeDeps(); + await performSessionAgentSwitch(deps, { sessionId: 's1', targetAgentKind: 'cursor', model: 'native', effort: 'high', fastMode: true, applyNow: true }); + expect(deps.applyAgentSwitchToDb).toHaveBeenCalledWith('s1', expect.objectContaining({ agentKind: 'cursor', effort: 'high', fastMode: true, permissionMode: 'ask' })); + }); + it('rejects Cursor when the target workspace is on another computer', async () => { + const { deps } = makeDeps(); + await expect(performSessionAgentSwitch(deps, { + sessionId: 's1', targetAgentKind: 'cursor', model: 'native', providerId: 'cursor', agentDeviceId: 'office-pc', applyNow: true, + })).rejects.toThrow(/Cursor requires a workspace on the executing desktop/); + expect(deps.applyAgentSwitchToDb).not.toHaveBeenCalled(); + }); + it('rejects Cursor when a provider group places the switch on another computer', async () => { + const resolveProviderGroupTarget = vi.fn(async () => ({ agentDeviceId: 'office-pc', providerId: 'cursor' })); + const { deps } = makeDeps({ resolveProviderGroupTarget }); + await expect(performSessionAgentSwitch(deps, { + sessionId: 's1', targetAgentKind: 'cursor', model: 'native', providerId: 'cursor', agentDeviceId: 'group-host', applyNow: true, + })).rejects.toThrow(/Cursor requires a workspace on the executing desktop/); + expect(resolveProviderGroupTarget).toHaveBeenCalled(); + expect(deps.applyAgentSwitchToDb).not.toHaveBeenCalled(); + }); + it('allows Cursor when a provider group resolves the switch back to this computer', async () => { + const { deps } = makeDeps({ + resolveProviderGroupTarget: vi.fn(async () => ({ agentDeviceId: null, providerId: 'cursor' })), + }); + await performSessionAgentSwitch(deps, { + sessionId: 's1', targetAgentKind: 'cursor', model: 'native', providerId: 'cursor', agentDeviceId: 'group-host', applyNow: true, + }); + expect(deps.applyAgentSwitchToDb).toHaveBeenCalledWith('s1', expect.objectContaining({ + agentKind: 'cursor', model: 'native', permissionMode: 'ask', + })); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionExecutionSelection.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionExecutionSelection.test.ts index dece7719b11..aa78f83ae74 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionExecutionSelection.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionExecutionSelection.test.ts @@ -6,6 +6,7 @@ const panel = { agentKind: 'pi' as const, model: 'z-ai/glm-5.3-flash', providerI const metadata = (id: string) => ({ id, efforts: ['medium', 'high'], defaultEffort: 'medium', supportsFastMode: false }); const routing = { availability: { + cursor: [], codex: [{ id: 'openai', name: 'OpenAI', models: [caller.model] }], pi: [{ id: 'xd', name: 'Cindy AI', models: [panel.model] }], 'claude-code': [{ id: 'xd', name: 'Cindy AI', models: [panel.model] }, { id: 'glm', name: 'GLM', models: ['glm-5.3'] }], diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionRequest.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionRequest.test.ts index e23bdb75932..dcc316ca664 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionRequest.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionRequest.test.ts @@ -3,6 +3,13 @@ import { describe, expect, it, vi } from 'vitest'; import { readCreateSessionOpts, withCreateSessionStderr } from '../sessionRequest'; describe('session IPC request parsing', () => { + it('preserves Cursor locally and rejects unsupported remote execution before allocation', () => { + const input = { agentKind: 'cursor', model: 'native-model', workingDir: 'workspace' }; + expect(readCreateSessionOpts(input).agentKind).toBe('cursor'); + expect(readCreateSessionOpts({ ...input, effort: 'high', fastMode: true })).toMatchObject({ effort: 'high', fastMode: true }); + expect(() => readCreateSessionOpts({ ...input, remoteHostId: 'ssh-host' })).toThrow('[UNSUPPORTED_CAPABILITY]'); + expect(() => readCreateSessionOpts({ ...input, agentDeviceId: 'other-device' })).toThrow('[UNSUPPORTED_CAPABILITY]'); + }); it.each(['cindy-library:/outside', ' cindy-library:/outside '])( 'rejects external Host-owned slots: %s', (dir) => { expect(() => readCreateSessionOpts({ diff --git a/apps/desktop/src/main/maker-ipc/agentHandoff.ts b/apps/desktop/src/main/maker-ipc/agentHandoff.ts index 52eee3c1d49..426d1b0351c 100644 --- a/apps/desktop/src/main/maker-ipc/agentHandoff.ts +++ b/apps/desktop/src/main/maker-ipc/agentHandoff.ts @@ -24,7 +24,7 @@ import { import { imChannelDisplayName } from '../../shared/imMessageSource.js'; /** DB 层引擎标识(sessions.agent_kind / messages.agent_kind 的值域)。 */ -export type DbAgentKind = 'cc' | 'codex' | 'pi'; +export type DbAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; /** 构造交接文本所需的最小消息投影(content 已 JSON.parse,即 camel Message.content)。 */ export interface HandoffSourceMessage { diff --git a/apps/desktop/src/main/maker-ipc/appDefaultModelControl.ts b/apps/desktop/src/main/maker-ipc/appDefaultModelControl.ts index 9e865a70ad7..e7da3b0b0b0 100644 --- a/apps/desktop/src/main/maker-ipc/appDefaultModelControl.ts +++ b/apps/desktop/src/main/maker-ipc/appDefaultModelControl.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { isModelVisible, type ProviderView } from '@cindy/model-providers'; import { defaultBotModelChain } from '../../shared/botDefaultModelChain.js'; import type { BotModelRoute } from '../../shared/botModelChain.js'; -import { sameModelRoute, type AppDefaultModelSelection } from '../../shared/appDefaultModelSelection.js'; +import { sameModelRoute, type AppDefaultModelSelection, type AppModelRoute } from '../../shared/appDefaultModelSelection.js'; import { activeOwnerScopeKey, getActiveDataOwnerPushStamp, isAppSessionBoundaryPending } from '../appSessionState.js'; import { getDesktopProviderService } from '../maker-host/createDesktopProviderService.js'; import { getMakerIfReady } from '../maker-host/index.js'; @@ -17,9 +17,9 @@ export function configureAppDefaultModelSelection(dispatch: typeof dispatchSelec export function availableAppDefaultModels(input: { providers: readonly ProviderView[]; - currentRoute?: BotModelRoute | null; - tuning?: (agent: 'claude-code' | 'codex' | 'pi', providerId: string, model: string) => { effort?: string; fastMode?: boolean }; - availableAgents: ReadonlySet<'cc' | 'codex' | 'pi'>; + currentRoute?: AppModelRoute | null; + tuning?: (agent: 'claude-code' | 'codex' | 'pi' | 'cursor', providerId: string, model: string) => { effort?: string; fastMode?: boolean }; + availableAgents: ReadonlySet<'cc' | 'codex' | 'pi' | 'cursor'>; enabled: NonNullable[0]['isModelEnabled']>; }) { const current = defaultBotModelChain({ providers: input.providers, providersLoading: false, diff --git a/apps/desktop/src/main/maker-ipc/authHandlers.ts b/apps/desktop/src/main/maker-ipc/authHandlers.ts index 8daaf530f74..56e169adc29 100644 --- a/apps/desktop/src/main/maker-ipc/authHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/authHandlers.ts @@ -19,7 +19,7 @@ const log = createLogger('maker-ipc:authHandlers'); export type MakerIpcBroadcast = (channel: string, payload: unknown) => void; /** IPC 允许的 agent 种类;运行时枚举校验不能靠 TypeScript 强转替代。 */ -const AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const satisfies readonly AgentKind[]; +const AGENT_KINDS = ['claude-code', 'codex', 'pi', 'cursor'] as const satisfies readonly AgentKind[]; const AGENT_LOGIN_MODES = ['browser', 'device-code', 'local'] as const satisfies readonly AgentLoginMode[]; const MAX_LOGIN_PROGRESS_CHARS = 16_384; const LOGIN_OWNER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; diff --git a/apps/desktop/src/main/maker-ipc/botCreationDraft.ts b/apps/desktop/src/main/maker-ipc/botCreationDraft.ts index 0b7e7b7feb7..31f1f814c58 100644 --- a/apps/desktop/src/main/maker-ipc/botCreationDraft.ts +++ b/apps/desktop/src/main/maker-ipc/botCreationDraft.ts @@ -1,4 +1,5 @@ import { isModelVisible } from '@cindy/model-providers'; +import type { AgentKind } from '@cindy/maker-core'; import { getModelVisibilityOverride, waitForModelVisibilityMirror } from '../maker-host/model-visibility-mirror.js'; import { getDesktopProviderService } from '../maker-host/createDesktopProviderService.js'; import fs from 'node:fs/promises'; @@ -89,7 +90,7 @@ export async function generateBotCreationDraft( await waitForModelVisibilityMirror(); const providers = await getDesktopProviderService().listProviders({ allowSideEffects: false }); assertOwner(); - const assertEnabled = (selection: { agentKind: 'claude-code' | 'codex' | 'pi'; providerId: string; model: string }) => { + const assertEnabled = (selection: { agentKind: AgentKind; providerId: string; model: string }) => { const provider = providers.find(p => p.id === selection.providerId); const model = provider?.models[selection.agentKind]?.find(m => m.id === selection.model); if (!model || !isModelVisible(getModelVisibilityOverride(selection.agentKind, selection.providerId, selection.model), model.defaultEnabled)) diff --git a/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts b/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts index 118470807b3..a2d11a3aeda 100644 --- a/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts +++ b/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts @@ -62,7 +62,7 @@ export interface GhostErrandRunnerDeps { ghostId: string; shouldContinue?: () => boolean; title: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; model?: string; effort?: string; fastMode?: boolean; diff --git a/apps/desktop/src/main/maker-ipc/localCliDetect.ts b/apps/desktop/src/main/maker-ipc/localCliDetect.ts index 3956770969b..80a950eb976 100644 --- a/apps/desktop/src/main/maker-ipc/localCliDetect.ts +++ b/apps/desktop/src/main/maker-ipc/localCliDetect.ts @@ -2,8 +2,8 @@ * localCliDetect(main)—— 本机 agent CLI 安装 / 登录态扫描的实现。 * * 纯函数 + 注入 fs 依赖(规则 14:handler body 可脱 Electron 单测)。 - * 只做存在性 stat(目录用 isDirectory、文件用 isFile,防同名文件顶替误报—— - * 见 memory: 存在性探测用 stat 而非 access),**绝不读取凭证内容**(规则 23)。 + * Claude/Codex 使用存在性 stat 与既有登录探测;Cursor 使用其原生 CLI status。 + * stat 区分目录和文件,避免同名文件顶替误报;绝不读取或返回凭证内容。 * 任一条目探测失败按「未安装」处理(fail-quiet:检测建议是增强,不是功能依赖)。 */ @@ -14,6 +14,8 @@ import { stat } from 'node:fs/promises'; import { hasClaudeNativeLogin } from '../maker-host/claude-native-auth.js'; import { isCodexAuthInheritedFromSystemCli } from '../maker-host/auth-adapters.js'; import { isNativeProviderAuthSelfAuthorized } from '../maker-host/nativeProviderAuthBinding.js'; +import { discoverCursorAgentBinarySync } from '../maker-host/cursor-binary-discovery.js'; +import { createDesktopCursorAuthAdapter } from '../maker-host/cursor-auth-adapter.js'; import { LOCAL_CLI_DETECT_MAP, type LocalCliDetection, @@ -31,6 +33,8 @@ export interface LocalCliScanDeps { * 生产 = hasClaudeNativeLogin();只返 boolean,不暴露凭证内容(规则 23)。 */ hasClaudeLogin(): boolean; + /** Cursor reports installation and login through its own CLI; no credential files are read. */ + probeCursorCli(): Promise<{ installed: boolean; loggedIn: boolean }>; /** * Cindy 用的凭证是否确实就是这份本机凭证(填 `LocalCliDetection.sharedWithCindy`)。 * 只在该 CLI 已登录时被调用;判据按 CLI 分派,见 createLocalCliScanDeps。 @@ -63,8 +67,15 @@ export function createLocalCliScanDeps(): LocalCliScanDeps { return false; } }, + probeCursorCli: async () => { + const binary = discoverCursorAgentBinarySync(); + if (!binary.installed) return { installed: false, loggedIn: false }; + const state = await createDesktopCursorAuthAdapter(binary.binaryPath).getState(); + return { installed: true, loggedIn: state.authenticated }; + }, isCredentialSharedWithCindy: (cli) => { try { + if (cli === 'cursor-cli') return true; const providerId = cli === 'claude-cli' ? 'anthropic' : 'openai'; // 用户在 Cindy 里**亲自授权过**这家 → 不是继承,无论凭证此刻是否与本机共用。 // 少了这道判据,「在 Cindy 里点过 Claude 授权」的用户下次进新建页会被告知 @@ -115,5 +126,11 @@ export async function scanLocalCliAuth(deps: LocalCliScanDeps): Promise ({ installed: false, loggedIn: false })); + results.push({ + cli: 'cursor-cli', providerId: 'cursor', installed: cursor.installed, + loggedIn: cursor.installed && cursor.loggedIn, + sharedWithCindy: cursor.installed && cursor.loggedIn, + }); return results; } diff --git a/apps/desktop/src/main/maker-ipc/mcpHandlers.ts b/apps/desktop/src/main/maker-ipc/mcpHandlers.ts index 793a02f14d0..477a0589b62 100644 --- a/apps/desktop/src/main/maker-ipc/mcpHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/mcpHandlers.ts @@ -57,7 +57,7 @@ export interface McpHandlerDeps { } const listContextSchema = z.object({ - agentKind: z.enum(['claude-code', 'codex', 'pi']), + agentKind: z.enum(['claude-code', 'codex', 'pi', 'cursor']), botSessionId: z.string().min(1).optional(), modelChain: z.array(z.object({ harness: z.enum(['claude', 'codex', 'pi']), diff --git a/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts b/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts index 01cb7ab0627..0e0e566b85e 100644 --- a/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts +++ b/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts @@ -22,7 +22,7 @@ export function sshCodexWorkerRoutingContext(views: ProviderView[]): OrcaWorkerP return { remoteCodexModels: models, availability: { - 'claude-code': [], pi: [], + 'claude-code': [], pi: [], cursor: [], codex: models.length ? [{ id: 'openai', name: views[0]!.name, models: models.map((model) => model.id), fastModels: models.filter((model) => model.supportsFastMode).map((model) => model.id), @@ -62,6 +62,7 @@ export function deviceWorkerRoutingContext(views: ProviderView[], agent: AgentKi 'claude-code': availabilityFor('claude-code'), codex: availabilityFor('codex'), pi: availabilityFor('pi'), + cursor: availabilityFor('cursor'), }; const models = deviceRoutableModels(views, agent); return { @@ -149,7 +150,7 @@ export async function readOrcaWorkerProviderRoutingContext(deps: { modelRegistry, provider.id, model.id, - agent === 'pi' ? undefined : agent, + agent === 'pi' || agent === 'cursor' ? undefined : agent, ); return matched ? [[model.id, matched.entry.id]] : []; }), @@ -184,6 +185,7 @@ export async function readOrcaWorkerProviderRoutingContext(deps: { 'claude-code': availabilityFor('claude-code'), codex: availabilityFor('codex'), pi: availabilityFor('pi'), + cursor: availabilityFor('cursor'), }, resolveDefaultProviderIdForModel: (agent, model) => effectiveSourceIdForModel(views, null, model, agent), diff --git a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts index fa2fb623ecb..b53c57f4166 100644 --- a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts +++ b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts @@ -496,10 +496,12 @@ function selectWorkerModel(params: { return input.model ?? defaults.model ?? (input.agent === lead.agentKind && inheritLeadModel ? lead.model : undefined) - // 新建任务草稿还没推过来时不写死型号:取目录里排第一、且有已连接来源提供的模型; - // 一个都没有则留空,由后续模型解析明确报错。 - ?? params.availableModels.find((model) => - params.providers.some((provider) => provider.models.includes(model.id)))?.id + // Cursor 没有跨引擎默认型号:只取本机原生目录的第一项,取不到就留下空串交给后续校验。 + // 其它引擎不写死型号:取目录里排第一、且有已连接来源提供的模型;一个都没有则留空。 + ?? (input.agent === 'cursor' + ? (params.availableModels[0]?.id ?? '') + : params.availableModels.find((model) => + params.providers.some((provider) => provider.models.includes(model.id)))?.id) ?? ''; } @@ -673,7 +675,7 @@ export function budgetModelRequiresApiKeyMessage(model: string): string { /** agent 的人类可读名,用于 preflight 失败信息。 */ function agentDisplayName(agent: AgentKind): string { - return agent === 'codex' ? 'Codex' : agent === 'pi' ? 'Pi' : 'Claude Code'; + return agent === 'cursor' ? 'Cursor' : agent === 'codex' ? 'Codex' : agent === 'pi' ? 'Pi' : 'Claude Code'; } /** @@ -1068,6 +1070,13 @@ export function createOrcaWorkerCreationService(deps: OrcaWorkerCreationDeps): O if (!workerLocation.ok) { return { ok: false, errorCode: 'INVALID_PARAMS', message: workerLocation.message }; } + // Cursor 只在本机工作区运行:SSH、Lead 的 Agent 在别的电脑、运行设备、以及把 Agent + // 放到另一台电脑或分享上,都不创建。跨设备入口不提供 Cursor。 + if (params.agent === 'cursor' && ( + lead.remoteHostId || lead.agentDeviceId || params.executionDeviceId !== undefined || workerLocation.agentDeviceId + )) { + return { ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require a local workspace' }; + } if (params.executionDeviceId !== undefined) { return createRemoteWorkerInTeam({ params, @@ -1108,6 +1117,10 @@ export function createOrcaWorkerCreationService(deps: OrcaWorkerCreationDeps): O : lead; const explicitSourceId = groupFollow && requestedSourceId !== null ? groupFollow.providerId : requestedSourceId; const workerAgentDeviceId = groupFollow ? groupFollow.agentDeviceId : workerLocation.agentDeviceId; + // 跟 Lead 的供应商组可能把本机 Worker 改放到另一台电脑。Cursor 不跟随过去。 + if (params.agent === 'cursor' && workerAgentDeviceId) { + return { ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require a local workspace' }; + } // Lead 的模型与来源属于它自己所在位置的目录:Worker 换了位置就不沿用 Lead 的来源(来源 id 在 // 不同电脑上不是一回事),只在同一位置时继承;模型只在那一处也有时沿用。 const sameLocationAsLead = workerAgentDeviceId === leadAgentDeviceId(followedLead); diff --git a/apps/desktop/src/main/maker-ipc/providerHandlers.ts b/apps/desktop/src/main/maker-ipc/providerHandlers.ts index 71c5ff69972..e3e8740baf2 100644 --- a/apps/desktop/src/main/maker-ipc/providerHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/providerHandlers.ts @@ -1647,7 +1647,7 @@ export function registerProviderHandlers( typeof target.providerId !== 'string' || target.providerId.length === 0 || target.providerId.length > MAX_DISABLE_ID_LENGTH || - !VALID_AGENTS.includes(String(target.agent)) || + !(VALID_AGENTS.includes(String(target.agent)) || target.agent === 'cursor') || typeof target.modelId !== 'string' || target.modelId.length === 0 || target.modelId.length > MAX_DISABLE_ID_LENGTH diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 2a3f2fd4bda..6545eb05bb6 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -1,3 +1,5 @@ +import { withCursorDiscoveredModels } from '../maker-host/cursor-model-catalog.js'; +import { refreshCursorModels } from '../maker-host/index.js'; import { configureBotTodoDispatch, settleBotTodoForSession } from './botTodoAccess.js'; import { createBotTodoDispatch } from './botTodoDispatch.js'; import { assertBotTaskCoordination, classifySessionMessagePurpose, coordinationInput } from './botTaskCoordination.js'; @@ -103,7 +105,6 @@ import { projectRemoteBotDelegations } from './remoteBotDelegations.js'; import { readCodexContextWindowInfo } from '../maker-host/codex-context-window.js'; import { readSshCodexModelList, assertSshCodexModel, isVerifiedSshCodexResume } from '../remote-ssh/codex-model-list.js'; import { prepareCodexCustomContextCatalog } from '../maker-host/codex-custom-context-catalog.js'; -import { inferProviderIdForModel } from '../maker-host/provider-route.js'; import { resolveConfiguredContextWindow, resolveDesktopModelContextProviderId } from '../maker-host/model-context-settings.js'; import { getCodexHome } from '../maker-host/auth-adapters.js'; import { getCachedBinaryStatus } from '../agent-binaries/index.js'; @@ -140,7 +141,6 @@ import type { } from '@cindy/maker-core'; import { effectiveSourceIdForModel, - isModelSelectableForNewRoute, buildUserProvider, mergeDiscoveredRuntimeModels, findCatalogModel, @@ -182,7 +182,7 @@ import { initializePluginOauthCards } from '../plugin-oauth/cards.js'; import { currentOauthIdentityScope, loadOauthSigningKey } from '../plugin-oauth/desktopIdentity.js'; import { readDeviceLinkSettings, readLastKnownDeviceNames } from '../device-link/settings-store.js'; import { getDeviceLinkStatus, getMobileNotifyGeneration, sendMobileBotGroupNotify } from '../device-link/index.js'; -import type { AgentMeta, Session as RendererSession } from '../../renderer/lib/ccAgent.types'; +import type { AgentMeta } from '../../renderer/lib/ccAgent.types'; import { deriveAutoTitleSeed, isAutomaticInputOriginKind, @@ -231,9 +231,6 @@ import { import { initGhostSetupCoordinator } from '../cindy-brain/ghostSetupCoordinator.js'; import { classifyGhostVisibility } from '../cindy-brain/ghostVisibility.js'; import { resolveSafe as resolveCindyMediaUrl } from '../cindy-media/blobStore.js'; -import { ingestMedia } from '../cindy-media/ingest.js'; -import { removeRefs as removeMediaRefs } from '../cindy-media/ledger.js'; -import { sniffMediaMime } from '../cindy-media/sniffMediaMime.js'; import { toolNotFoundMessage } from '../cindy-brain/pipeDispatcher.js'; import { getGhostSetupChangeBus } from '../cindy-brain/ghostSetupChangeBus.js'; import { isGhostDisabledForWorkdir } from '../cindy-brain/ghostWorkdirPrefs.js'; @@ -483,7 +480,7 @@ import { orcaWorkerCreationReservations, sessions, } from '../localDb/schema.js'; -import { nextBotModelRoute, normalizeBotModelChain, type BotModelRoute } from '../../shared/botModelChain.js'; +import { nextBotModelRoute, type BotModelRoute } from '../../shared/botModelChain.js'; import { createBotModelRouteReconciler } from './botModelRouteReconciler.js'; import { readEffectiveBotModelChain, readEffectiveBotModelSelection } from '../maker-host/bot-model-chain-settings-store.js'; import { @@ -960,17 +957,12 @@ import { UI_ACTION_TRIGGER_PREFIX } from '@cindy/maker-shared/synthetic-trigger' import { createContextOverflowRollover, hasModelWindowContextToProtect, - isContextOverflowErrorData, - isOversizedHistoryErrorData, isPiPromptRpcTimeoutError, lookupVerifiedContextWindow, persistedUserContentToWireMessage, type ModelWindowSwitchPreparationResult, } from './contextOverflowRollover.js'; -import { - classifyCodexHistoryOversized, - reserveCodexForkCleanup, -} from '../maker-host/codex-local-sessions.js'; +import { classifyCodexHistoryOversized } from '../maker-host/codex-local-sessions.js'; import { readCodexThreadStorageReadOnly } from '../maker-host/codex-thread-storage.js'; import { hydrateQueuedAgentReferences } from './agentInputReferences.js'; import { agentHandoffPending } from './agentHandoffPendingSingleton.js'; @@ -2721,7 +2713,7 @@ export function stopOrcaIdleWatcher(): void { } function requireAgentKind(value: unknown): AgentKind { - if (value === 'claude-code' || value === 'codex' || value === 'pi') return value; + if (value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'cursor') return value; throwIpcError('INVALID_PARAMS', 'agentKind required'); } @@ -6193,7 +6185,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }, ); - ipcMain.handle(MAKER_INVOKE.GET_CAPABILITIES, (_e, agentKind: unknown) => { + ipcMain.handle(MAKER_INVOKE.GET_CAPABILITIES, async (_e, agentKind: unknown) => { + if (agentKind === 'cursor') await refreshCursorModels(); return { ...maker.getCapabilities(requireAgentKind(agentKind)), // host 级 optional 能力;旧 desktop 缺省为 false。两个 agent 查询都带回, @@ -6250,8 +6243,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) active?: unknown; markModelChoice?: unknown; }; - if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') { - throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi'); + if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi' && p.agent !== 'cursor') { + throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi|cursor'); } if (p.providerId !== undefined && typeof p.providerId !== 'string') { throwIpcError('INVALID_PARAMS', 'providerId must be string'); @@ -6325,8 +6318,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (typeof p.sessionId !== 'string' || !p.sessionId) { throwIpcError('INVALID_PARAMS', 'sessionId required'); } - if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') { - throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi'); + if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi' && p.agent !== 'cursor') { + throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi|cursor'); } if (typeof p.providerId !== 'string' || !p.providerId) { throwIpcError('INVALID_PARAMS', 'providerId required'); @@ -6365,7 +6358,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if ( typeof p.sessionId !== 'string' || !p.sessionId || - (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') || + (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi' && p.agent !== 'cursor') || typeof p.providerId !== 'string' || !p.providerId || typeof p.model !== 'string' || @@ -6396,6 +6389,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) refreshProvider: (providerId) => refreshBuiltinProviderModels(providerId, { refreshXd: options.refreshXdGatewayModels, + refreshCursor: () => refreshCursorModels(true), // Claude 订阅清单来自 Claude Code SDK:用本机 CLI 的登录读一次 supportedModels // (Cindy 不带订阅凭证请求 Anthropic,也不发送消息)。 refreshAnthropic: refreshAnthropicModelsFromProbe, @@ -6499,7 +6493,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // 动态清单重新发现:目前没有需要主动重拉的供应商(anthropic 清单来自 Claude Code // 会话 init 的 SDK 捕获,没有 HTTP 发现通道,也就没有失败态)。 rediscoverModels: async () => null, - refreshBuiltinModels: refreshProviderModelsManually, + refreshBuiltinModels: (providerId) => providerId === 'cursor' + ? refreshCursorModels(true) : refreshProviderModelsManually(providerId), requestModelsAutoRefresh: requestProviderModelAutoRefresh, scanLocalCli: () => scanLocalCliAuth(createLocalCliScanDeps()), // 「模型 / 供应商停用」override 写入(main 侧持久化,handler 写后广播 PROVIDER_CHANGED)。 @@ -7832,6 +7827,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) o.effort = runtimeOverride.effort ?? undefined; o.fastMode = runtimeOverride.fastMode; } + // Old generic session defaults may contain high/medium even though Cursor has no effort control. + // Explicit new selections are rejected at the request boundary; persisted defaults are neutral. + if (o.agentKind === 'cursor') { + o.effort = undefined; + o.fastMode = false; + } + assertAccess?.(); await applyPersistedReviewMode(o); await applyPersistedCindyMakeMarker(o, readSessionSource); @@ -8485,19 +8487,23 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } } - await ensureRemoteHostReady(remoteHostIdToEnsure); - const ensureAgentKind: 'claude-code' | 'codex' | 'pi' | null = + const ensureAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor' | null = session?.agentKind === 'codex' || session?.agentKind === 'claude-code' || - session?.agentKind === 'pi' + session?.agentKind === 'pi' || + session?.agentKind === 'cursor' ? session.agentKind : createOpts && typeof createOpts === 'object' ? (() => { const ak = (createOpts as { agentKind?: unknown }).agentKind; - return ak === 'codex' || ak === 'claude-code' || ak === 'pi' ? ak : null; + return ak === 'codex' || ak === 'claude-code' || ak === 'pi' || ak === 'cursor' ? ak : null; })() : null; if (!ensureAgentKind) return; + if (ensureAgentKind === 'cursor') { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor does not support SSH execution'); + } + await ensureRemoteHostReady(remoteHostIdToEnsure); // claude-code 远端走 cc-mgr.mjs daemon。首次 /context 也必须像 send 一样 // 触发 cc-manager 安装/升级, 否则 query/getContextUsage 可能因旧 bundle 不存在而失败。 @@ -9033,6 +9039,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (source.source === 'review') { throwIpcError('INVALID_PARAMS', 'A review task cannot start another review'); } + if (source.agentKind === 'cursor') { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor does not support read-only review'); + } if (source.remoteHostId) { throwIpcError('UNSUPPORTED_CAPABILITY', 'Review is local-only in this version'); } @@ -9689,6 +9698,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) sdkSessionId: sessions.sdkSessionId, source: sessions.source, agentDeviceId: sessions.agentDeviceId, + permissionMode: sessions.permissionMode, }) .from(sessions) .where(eq(sessions.id, sessionId)) @@ -11690,7 +11700,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // Bot group chat is local to this Desktop in phase 1; device-link does not route these channels. const botGroupNotReady = { ok: false as const, errorCode: 'HOST_NOT_READY' as const, message: '伙伴群聊服务尚未就绪' }; // Narrow chat operations; credentials and transport stay in main. - ipcMain.handle(MAKER_INVOKE.CHAT_SERVER_STATUS, async (event, input) => { + ipcMain.handle(MAKER_INVOKE.CHAT_SERVER_STATUS, async (event) => { assertTrustedAppRendererEvent(event); const chat = botGroupChatServiceHolder?.chatServer; return chat ? chat.status() : { enabled: false, connected: false }; @@ -11735,12 +11745,12 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const chat = botGroupChatServiceHolder?.chatServer; return chat ? chat.manage(input) : { ok: false, errorCode: 'HOST_NOT_READY' }; }); - ipcMain.handle(MAKER_INVOKE.CHAT_SERVER_OWNEDBOTS, async (event, input) => { + ipcMain.handle(MAKER_INVOKE.CHAT_SERVER_OWNEDBOTS, async (event) => { assertTrustedAppRendererEvent(event); const chat = botGroupChatServiceHolder?.chatServer; return chat ? chat.ownedBots() : { ok: false, errorCode: 'HOST_NOT_READY' }; }); - ipcMain.handle(MAKER_INVOKE.CHAT_SERVER_REFRESHPROFILE, async (event, input) => { + ipcMain.handle(MAKER_INVOKE.CHAT_SERVER_REFRESHPROFILE, async (event) => { assertTrustedAppRendererEvent(event); const chat = botGroupChatServiceHolder?.chatServer; return chat ? chat.refreshProfile() : { ok: false, errorCode: 'HOST_NOT_READY' }; @@ -12087,7 +12097,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) assertCurrent(); const [row] = await snapshot.client.drizzle.select().from(sessions).where(eq(sessions.id, taskId)).limit(1); assertCurrent(); - if (!row || row.source !== 'plugin' || row.remoteHostId || (row.orcaRole && row.orcaRole !== 'lead') || !['cc', 'codex', 'pi'].includes(row.agentKind)) return null; + if (!row || row.source !== 'plugin' || row.remoteHostId || (row.orcaRole && row.orcaRole !== 'lead') || !['cc', 'codex', 'pi', 'cursor'].includes(row.agentKind)) return null; const runtime = await readSessionRuntimeProfiles(taskId); assertCurrent(); // A queued input will execute the accepted next-send route. Keep its @@ -12937,7 +12947,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) agentDeviceId?: unknown; }; const workerAgent: AgentKind = - body.workerAgent === 'codex' ? 'codex' : body.workerAgent === 'pi' ? 'pi' : 'claude-code'; + body.workerAgent === 'cursor' ? 'cursor' : body.workerAgent === 'codex' ? 'codex' : body.workerAgent === 'pi' ? 'pi' : 'claude-code'; const delegateTask = typeof body.delegateTask === 'string' ? body.delegateTask : undefined; if ( body.workerPermissionMode !== undefined && @@ -13629,7 +13639,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const getProviderRoutingContext = () => readOrcaWorkerProviderRoutingContext({ providerService: getDesktopProviderService(), - getCatalog: getActiveCatalog, + getCatalog: () => withCursorDiscoveredModels(getActiveCatalog()), }); const assertPluginWorkerAutoAuthorized = (pluginId: string, task: { status: string; permissionMode?: string; planModeEnabled?: boolean }) => { @@ -14459,8 +14469,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } const chain = await readEffectiveBotModelChain(config); const toAgentKind = (harness: string): AgentKind => - harness === 'codex' ? 'codex' : harness === 'pi' ? 'pi' : 'claude-code'; - const currentHarness = current.agentKind === 'codex' + harness === 'cursor' ? 'cursor' : harness === 'codex' ? 'codex' : harness === 'pi' ? 'pi' : 'claude-code'; + const currentHarness = current.agentKind === 'cursor' ? 'cursor' : current.agentKind === 'codex' ? 'codex' : current.agentKind === 'pi' ? 'pi' @@ -15271,7 +15281,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }, listAvailableModels: async ({ agent, callerSessionId, agentDeviceId: requestedAgentDeviceId }) => { try { - const agents: AgentKind[] = agent ? [agent] : ['codex', 'claude-code', 'pi']; + const registeredAgents: AgentKind[] = agent ? [agent] : maker.listAvailableAgents(); // SSH Lead 的 Worker 在 SSH 主机上运行 Agent,不能放到别的电脑:不列可选位置。 const sshLead = callerSessionId ? Boolean(await readSessionRemoteHostIdCached(callerSessionId)) : false; // 不指定位置时列 Lead 所在位置(Lead 的 Agent 在另一台电脑运行就列那台;Lead 归在供应商组里就列组那一项 @@ -15288,6 +15298,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ? requestedAgentDeviceId : null; const locations = sshLead ? undefined : await listWorkerAgentLocations(); + // Cursor 只在本机工作区。跨设备位置和 SSH Lead 不列 Cursor,避免跨设备入口提供它。 + const agents = agentDeviceId || sshLead + ? registeredAgents.filter((kind) => kind !== 'cursor') + : registeredAgents; if (agentDeviceId) { let views: ProviderView[]; try { @@ -15300,7 +15314,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) agentDeviceId, ...(locations ? { locations } : {}), ...Object.fromEntries(agents.map((a) => [ - a === 'codex' ? 'codex' : a === 'pi' ? 'pi' : 'claude_code', + a === 'claude-code' ? 'claude_code' : a, deviceAvailableModels(views, a), ])), }; @@ -15318,7 +15332,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) for (const a of agents) { const caps = maker.getCapabilities(a); // key 必须区分 pi,否则 pi 模型会被塞进 claude_code 键与 CC 模型混淆。 - const key = a === 'codex' ? 'codex' : a === 'pi' ? 'pi' : 'claude_code'; + const key = a === 'claude-code' ? 'claude_code' : a; const providers = providerRouting.availability[a] ?? []; result[key] = caps.availableModels.map((m) => ({ id: m.id, @@ -15450,7 +15464,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return null; } if (!session || !lead || !session.workingDir) throw new Error('Delegated task unavailable'); - const agentKind = session.agentKind === 'cc' ? 'cc' : session.agentKind === 'pi' ? 'pi' : session.agentKind === 'codex' ? 'codex' : null; + const agentKind = session.agentKind === 'cursor' ? 'cursor' : session.agentKind === 'cc' ? 'cc' : session.agentKind === 'pi' ? 'pi' : session.agentKind === 'codex' ? 'codex' : null; if (!agentKind) throw new Error('Delegated task route unavailable'); const data = readPluginTaskPlanReceipt(receipt.payload); const item = link ? data.teamPlan?.items.find(item => item.label === link.label) : undefined; @@ -18017,7 +18031,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if ( msg.createOpts.agentKind !== 'claude-code' && msg.createOpts.agentKind !== 'codex' && - msg.createOpts.agentKind !== 'pi' + msg.createOpts.agentKind !== 'pi' && + msg.createOpts.agentKind !== 'cursor' ) { throwIpcError('INVALID_PARAMS', 'queued.createOpts.agentKind invalid'); } @@ -19439,7 +19454,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ? atomicSelection.effort : ((runtimeStatus.effort ?? null) as SessionRuntimeProfile['effort']); const targetFastMode = atomicSelection?.fastMode ?? runtimeStatus.fastMode; - const agentKind = dbToMakerAgentKind(runtimeStatus.agentKind as 'cc' | 'codex' | 'pi'); + const agentKind = dbToMakerAgentKind(runtimeStatus.agentKind as 'cc' | 'codex' | 'pi' | 'cursor'); if (internalOptions.source === 'user' && !internalOptions.applyingUserSelectionOnSend && !runtimeStatus.orcaRole) { assertRuntimeOwnerCurrent(); diff --git a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts index df9b369e5fe..aade8433f03 100644 --- a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts +++ b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts @@ -31,6 +31,7 @@ */ import type { AgentKind } from '@cindy/maker-core'; +import { permissionModeOrAsk, type SharedPermissionMode } from '@cindy/maker-shared/permission-mode'; import { getDeviceLinkInvokeContext } from '../device-link/invoke-context.js'; import { createSharedTaskSettingGuard } from './sharedTaskSetting.js'; @@ -71,6 +72,7 @@ export function toMakerAgentKind(dbKind: string): AgentKind { export function agentEngineLabel(dbKind: DbAgentKind): string { if (dbKind === 'codex') return 'Codex'; if (dbKind === 'pi') return 'Pi'; + if (dbKind === 'cursor') return 'Cursor'; return 'Claude Code'; } @@ -119,6 +121,7 @@ export interface AgentSwitchSessionRow { orcaRole: string | null; sdkSessionId: string | null; source?: string | null; + permissionMode?: string | null; /** Agent 在哪台电脑运行(null / 缺省 = 任务所在电脑)。 */ agentDeviceId?: string | null; } @@ -171,7 +174,7 @@ export interface MakerSessionAgentSwitchHandlerDeps { * (测试最小 harness)。 */ assertModelRouteUsable?( - agent: 'claude-code' | 'codex' | 'pi', + agent: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId: string | null, /** 被切换的任务;Agent 在另一台电脑运行的任务按那台的目录裁决(本机不校验)。 */ @@ -185,7 +188,7 @@ export interface MakerSessionAgentSwitchHandlerDeps { */ assertAgentDeviceRouteUsable?( deviceId: string, - agent: 'claude-code' | 'codex' | 'pi', + agent: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId: string | null, ): Promise; @@ -222,6 +225,7 @@ export interface MakerSessionAgentSwitchHandlerDeps { /** 目标引擎下的 effort / fastMode(意图登记时由 renderer 解析,apply 时一并落库)。 */ effort?: string; fastMode?: boolean; + permissionMode?: SharedPermissionMode; /** Agent 换电脑:undefined = 不动,null = 改回任务所在电脑。 */ agentDeviceId?: string | null; }, @@ -570,7 +574,7 @@ export async function performSessionAgentSwitch( if (typeof sessionId !== 'string' || sessionId.length === 0) { throwIpcError('INVALID_PARAMS', 'sessionId required'); } - if (targetAgentKind !== 'claude-code' && targetAgentKind !== 'codex' && targetAgentKind !== 'pi') { + if (targetAgentKind !== 'claude-code' && targetAgentKind !== 'codex' && targetAgentKind !== 'pi' && targetAgentKind !== 'cursor') { throwIpcError('INVALID_PARAMS', 'targetAgentKind must be claude-code | codex | pi'); } if (typeof model !== 'string' || model.length === 0) { @@ -686,6 +690,11 @@ export async function performSessionAgentSwitch( commitProviderGroupBinding = chosen.commitBinding; } } + // 组选完位置后再判:Cursor 只在执行这台电脑的本机工作区运行。显式远程位置,或组把 + // 这次切换放到另一台电脑,都拒绝。选回本机时 targetAgentDeviceId 为空,仍允许。 + if (targetAgentKind === 'cursor' && targetAgentDeviceId) { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor requires a workspace on the executing desktop'); + } const agentDeviceChanges = targetAgentDeviceId !== currentAgentDeviceId; // 位置不变的强制换电脑(只认内部 applyNow 调用,且 Agent 在另一台电脑上):交接、提交、分隔条都按换电脑处理。 const forcedRelocation = @@ -867,6 +876,11 @@ export async function performSessionAgentSwitch( model, providerId: normalizedProviderId, sdkSessionId: parked?.sdkSessionId ?? null, + ...(targetAgentKind === 'cursor' ? { + permissionMode: row.permissionMode === 'acceptEdits' || row.permissionMode === 'plan' + ? 'ask' as const : permissionModeOrAsk(row.permissionMode), + fastMode: false, + } : {}), ...(typeof params.effort === 'string' && params.effort ? { effort: params.effort } : {}), ...(typeof params.fastMode === 'boolean' ? { fastMode: params.fastMode } : {}), ...(agentDeviceChanges ? { agentDeviceId: targetAgentDeviceId } : {}), diff --git a/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts b/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts index 5baf4843c9d..76d9e77773c 100644 --- a/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts +++ b/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts @@ -342,12 +342,12 @@ export function prepareSessionEvent( markSessionTurnStarted(session.id); } if ( - (event.source === 'claude-code' || event.source === 'codex' || event.source === 'pi') && + (event.source === 'claude-code' || event.source === 'codex' || event.source === 'pi' || event.source === 'cursor') && !deps.turnModelPromiseBySession.has(session.id) ) { deps.turnModelPromiseBySession.set(session.id, deps.readSessionModelForUsage(session.id)); } - if ((event.source === 'pi' || event.source === 'codex' || event.source === 'claude-code') + if ((event.source === 'pi' || event.source === 'codex' || event.source === 'claude-code' || event.source === 'cursor') && (!wasInTurn || !deps.turnUsageContextBySession.has(session.id))) { deps.turnUsageContextBySession.set(session.id, captureTurnUsageContext(session.id)); } @@ -461,7 +461,7 @@ export function prepareSessionEvent( // EVENT broadcast 后结束逻辑 turn,并保留 terminal grace 给 renderer 收尾; // 可重试 error 保持 running。 shouldMarkTurnTerminalIdleAfterBroadcast = true; - if (event.source === 'claude-code' || event.source === 'codex' || event.source === 'pi') { + if (event.source === 'claude-code' || event.source === 'codex' || event.source === 'pi' || event.source === 'cursor') { deps.turnModelPromiseBySession.delete(session.id); // A paired done may still carry usage after this error. Retain its billing // identity until done; a new product turn overwrites it using wasInTurn. diff --git a/apps/desktop/src/main/maker-ipc/sessionEventStream.ts b/apps/desktop/src/main/maker-ipc/sessionEventStream.ts index 824536c4ac4..3506413de47 100644 --- a/apps/desktop/src/main/maker-ipc/sessionEventStream.ts +++ b/apps/desktop/src/main/maker-ipc/sessionEventStream.ts @@ -111,7 +111,7 @@ export function persistSessionStreamEvent( // harness event into Cindy's durable record on the same FIFO as chat // messages. No launch/control path or provider payload is modified. const source = - event.source === 'claude-code' || event.source === 'codex' || event.source === 'pi' + event.source === 'claude-code' || event.source === 'codex' || event.source === 'pi' || event.source === 'cursor' ? event.source : undefined; const observedAt = Date.now(); diff --git a/apps/desktop/src/main/maker-ipc/sessionExecutionSelection.ts b/apps/desktop/src/main/maker-ipc/sessionExecutionSelection.ts index 2a621caed7c..d3b63114cee 100644 --- a/apps/desktop/src/main/maker-ipc/sessionExecutionSelection.ts +++ b/apps/desktop/src/main/maker-ipc/sessionExecutionSelection.ts @@ -68,7 +68,7 @@ export function createSessionExecutionResolver(deps: SessionExecutionResolverDep assertOwner(); let source: SessionExecutionSelection | undefined; if (existing) { - if (!['cc', 'claude-code', 'codex', 'pi'].includes(existing.agentKind)) throw new Error('任务 Agent 不可用,请重新选择模型'); + if (!['cc', 'claude-code', 'codex', 'pi', 'cursor'].includes(existing.agentKind)) throw new Error('任务 Agent 不可用,请重新选择模型'); source = { agentKind: (existing.agentKind === 'cc' ? 'claude-code' : existing.agentKind) as AgentKind, model: existing.model, providerId: existing.providerId, effort: (existing.effort || undefined) as Effort | undefined, fastMode: existing.fastMode === true }; diff --git a/apps/desktop/src/main/maker-ipc/sessionRequest.ts b/apps/desktop/src/main/maker-ipc/sessionRequest.ts index 593e1236505..c73c0010a84 100644 --- a/apps/desktop/src/main/maker-ipc/sessionRequest.ts +++ b/apps/desktop/src/main/maker-ipc/sessionRequest.ts @@ -71,7 +71,7 @@ export interface ReadCreateSessionOptsDeps { } function readAgentKind(value: unknown): AgentKind { - if (value === 'claude-code' || value === 'codex' || value === 'pi') return value; + if (value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'cursor') return value; throwIpcError('INVALID_PARAMS', 'agentKind required'); } @@ -121,6 +121,9 @@ export function readCreateSessionOpts( throwIpcError('INVALID_PARAMS', 'extraDirs must not contain Host-owned library slots'); } const agentKind = readAgentKind(body.agentKind); + if (agentKind === 'cursor' && (body.remoteHostId || body.agentDeviceId)) { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor requires a workspace on the executing desktop'); + } const model = requireString(body.model, 'model'); const workspaceKind = readWorkspaceKind(body.workspaceKind); const explicitWorkingDir = readExplicitWorkingDir(body.workingDir); diff --git a/apps/desktop/src/main/maker-ipc/sessionRuntimeHarnessSelection.ts b/apps/desktop/src/main/maker-ipc/sessionRuntimeHarnessSelection.ts index ae9d079d9a5..936fb0c586c 100644 --- a/apps/desktop/src/main/maker-ipc/sessionRuntimeHarnessSelection.ts +++ b/apps/desktop/src/main/maker-ipc/sessionRuntimeHarnessSelection.ts @@ -60,7 +60,7 @@ export async function setSessionRuntimeHarness( }, ): Promise { const { targetSessionId: id, expectedGeneration, patch } = params; - if (!['claude-code', 'codex', 'pi'].includes(patch.harness) || !patch.model?.trim()) { + if (!['claude-code', 'codex', 'pi', 'cursor'].includes(patch.harness) || !patch.model?.trim()) { return { ok: false, errorCode: 'INVALID_ARGS', diff --git a/apps/desktop/src/main/maker-ipc/title.ts b/apps/desktop/src/main/maker-ipc/title.ts index 1bc28ad9063..8e774089c2d 100644 --- a/apps/desktop/src/main/maker-ipc/title.ts +++ b/apps/desktop/src/main/maker-ipc/title.ts @@ -264,7 +264,7 @@ const AUTO_TITLE_TEXT_MAX = 2000; /** sessionId 长度上限(UUID / cuid 都远小于此)。 */ const SESSION_ID_MAX = 128; -const TITLE_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const satisfies readonly AgentKind[]; +const TITLE_AGENT_KINDS = ['claude-code', 'codex', 'pi', 'cursor'] as const satisfies readonly AgentKind[]; interface GenerateTitleRequest { message: string; diff --git a/apps/desktop/src/main/maker-ipc/workingDirectoryPreflight.ts b/apps/desktop/src/main/maker-ipc/workingDirectoryPreflight.ts index 16bcb2e04d7..b0d949bb42b 100644 --- a/apps/desktop/src/main/maker-ipc/workingDirectoryPreflight.ts +++ b/apps/desktop/src/main/maker-ipc/workingDirectoryPreflight.ts @@ -46,7 +46,7 @@ export function createWorkingDirectoryPreflight(deps: WorkingDirectoryPreflightD const cindyMakeWorkspace = isCindyMakeWorktreePath(getUserDataPath(), requestedWorkingDir); if (cindyMakeWorkspace) workingDirectoryRecovery.discard(sessionId); let workingDir = workingDirectoryRecovery.resolve(sessionId, requestedWorkingDir); - const source: AgentKind = agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'claude-code'; + const source: AgentKind = agentKind === 'codex' || agentKind === 'pi' || agentKind === 'cursor' ? agentKind : 'claude-code'; // suppressMissingBroadcast: 调用方(SEND 事务)手里还有 DB 权威值可兜底时, // 首检失败只记日志不广播错误横幅——兜底成功的话用户不该看到假错误。 const suppress = opts?.suppressMissingBroadcast === true; diff --git a/apps/desktop/src/main/maker-orchestration/fork.ts b/apps/desktop/src/main/maker-orchestration/fork.ts index 06583bfd331..57767014fbc 100644 --- a/apps/desktop/src/main/maker-orchestration/fork.ts +++ b/apps/desktop/src/main/maker-orchestration/fork.ts @@ -65,7 +65,7 @@ function normalizePositiveInt(value: unknown): number { const messageRowid = sql`rowid`; -type DbAgentKind = 'cc' | 'codex' | 'pi'; +type DbAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; interface MessagePosition { createdAt: number; @@ -244,12 +244,6 @@ function parseContextRebuildBoundary(content: string): ParsedContextRebuildBound } } -function parseContextRebuildReason( - content: string, -): 'context-overflow' | 'model-window-switch' | 'pi-prompt-timeout' | 'native-session-recovery' | null { - return parseContextRebuildBoundary(content)?.reason ?? null; -} - function parseAgentSwitchBoundary(content: string): ParsedAgentSwitchBoundary | null { try { const parsed = JSON.parse(content) as Record; diff --git a/apps/desktop/src/main/mcp-integrations/piEnvironment.ts b/apps/desktop/src/main/mcp-integrations/piEnvironment.ts index 79493826e3e..dbad8f688cc 100644 --- a/apps/desktop/src/main/mcp-integrations/piEnvironment.ts +++ b/apps/desktop/src/main/mcp-integrations/piEnvironment.ts @@ -512,7 +512,8 @@ async function doStart( if ( active?.agentKind !== 'pi' && active?.agentKind !== 'codex' && - active?.agentKind !== 'claude-code' + active?.agentKind !== 'claude-code' && + active?.agentKind !== 'cursor' ) { return undefined; } diff --git a/apps/desktop/src/main/messagePersistBroadcaster.ts b/apps/desktop/src/main/messagePersistBroadcaster.ts index 2be06612c23..29ad04324c7 100644 --- a/apps/desktop/src/main/messagePersistBroadcaster.ts +++ b/apps/desktop/src/main/messagePersistBroadcaster.ts @@ -368,19 +368,19 @@ type CreateDbMessageBody = Parameters[1]; type OwnerScope = ReturnType | null; /** - * session-agent-switch:每会话当前 agent 引擎('cc'/'codex'),由 register.ts + * session-agent-switch:每会话当前 agent 引擎('cc'/'codex'/'pi'/'cursor'),由 register.ts * wireSessionToIpc 在 session 建立时登记。broadcaster 落库的 SDK 事件行 * (assistant/tool/thinking/error)逐行 stamp 到 messages.agent_kind——切换后 * session.agent_kind 只代表"当前引擎",历史行的 agent_meta 必须按写入时引擎解析。 * clearSessionPersistState 时清理。 */ -const dbAgentKindBySession = new Map(); +const dbAgentKindBySession = new Map(); -export function noteSessionAgentKind(sessionId: string, dbAgentKind: 'cc' | 'codex' | 'pi'): void { +export function noteSessionAgentKind(sessionId: string, dbAgentKind: 'cc' | 'codex' | 'pi' | 'cursor'): void { dbAgentKindBySession.set(sessionId, dbAgentKind); } -export function getSessionDbAgentKind(sessionId: string): 'cc' | 'codex' | 'pi' | null { +export function getSessionDbAgentKind(sessionId: string): 'cc' | 'codex' | 'pi' | 'cursor' | null { return dbAgentKindBySession.get(sessionId) ?? null; } diff --git a/apps/desktop/src/main/process-monitor/__tests__/codex-process-registry.test.ts b/apps/desktop/src/main/process-monitor/__tests__/codex-process-registry.test.ts index 6a29b431d3e..ea19075fa5a 100644 --- a/apps/desktop/src/main/process-monitor/__tests__/codex-process-registry.test.ts +++ b/apps/desktop/src/main/process-monitor/__tests__/codex-process-registry.test.ts @@ -13,6 +13,13 @@ beforeEach(() => { }); describe('codex process registry', () => { + it('preserves Cursor process identity without classifying it as Codex', () => { + const dispose = registerAgentProcess(303, 'cursor', 'task-host'); + expect(resolveAgentProcessRegistration(303)).toMatchObject({ kind: 'cursor', role: 'task-host' }); + expect(resolveCodexProcessRole(303)).toBeNull(); + dispose(); + expect(resolveAgentProcessRegistration(303)).toBeNull(); + }); it('登记角色并在 disposer 时清理', () => { const dispose = registerCodexProcessRole(101, 'task-host'); expect(resolveCodexProcessRole(101)).toBe('task-host'); diff --git a/apps/desktop/src/main/process-monitor/agent-scan.ts b/apps/desktop/src/main/process-monitor/agent-scan.ts index a9aa07af338..9d8fc4eeef3 100644 --- a/apps/desktop/src/main/process-monitor/agent-scan.ts +++ b/apps/desktop/src/main/process-monitor/agent-scan.ts @@ -27,7 +27,7 @@ import { runWindowsProcessScanWorker } from './windowsProcessScanWorkerClient.js const execFileAsync = promisify(execFile); -export type MonitoredAgentKind = 'claude' | 'codex' | 'pi'; +export type MonitoredAgentKind = 'claude' | 'codex' | 'pi' | 'cursor'; export interface OsProcessRow { pid: number; diff --git a/apps/desktop/src/main/process-monitor/sampler.ts b/apps/desktop/src/main/process-monitor/sampler.ts index f3dbd8393ea..441cac796c3 100644 --- a/apps/desktop/src/main/process-monitor/sampler.ts +++ b/apps/desktop/src/main/process-monitor/sampler.ts @@ -69,6 +69,7 @@ const AGENT_KIND_TO_USAGE_KIND: Record = { claude: 'agent-claude', codex: 'agent-codex', pi: 'agent-pi', + cursor: 'agent-cursor', }; export interface ProcessMonitorSampler { diff --git a/apps/desktop/src/main/remote-agent/controller/service.ts b/apps/desktop/src/main/remote-agent/controller/service.ts index 4bc0abb1dc6..0cb303c73d7 100644 --- a/apps/desktop/src/main/remote-agent/controller/service.ts +++ b/apps/desktop/src/main/remote-agent/controller/service.ts @@ -22,7 +22,7 @@ import type { PiExtraSpawnConfigContext, StartSessionOptions, } from '@cindy/maker-core'; -import { resolveMemoryScopeKey } from '@cindy/maker-core'; +import { NotSupportedError, resolveMemoryScopeKey } from '@cindy/maker-core'; import type { ExecutorCaptureHooks } from '../executor/executor'; import type { PdfTextExtractor } from '../executor/files'; @@ -148,6 +148,9 @@ export function createDeviceAgentStarter(deps: DeviceAgentServiceDeps) { const log = deps.logger.child('remote-agent'); const pollerFor = (deviceId: string): RemoteAgentPoller => remoteAgentPollerFor(deviceId, deps.remoteInvoke, log); return async (input: { agentKind: AgentKind; deviceId: string; options: StartSessionOptions }): Promise => { + if (input.agentKind === 'cursor') { + throw new NotSupportedError('deviceHosted', { supported: false, reason: 'not-implemented' }); + } const opts: StartSessionOptions = { ...input.options }; const poller = pollerFor(input.deviceId); // 记忆在本机:对方只用这里给的索引快照,工具读写也回到本机记忆库。 diff --git a/apps/desktop/src/main/scheduler-host/__tests__/runnerModelSelection.test.ts b/apps/desktop/src/main/scheduler-host/__tests__/runnerModelSelection.test.ts index db26ae27173..3c043101c71 100644 --- a/apps/desktop/src/main/scheduler-host/__tests__/runnerModelSelection.test.ts +++ b/apps/desktop/src/main/scheduler-host/__tests__/runnerModelSelection.test.ts @@ -116,9 +116,7 @@ function createSessionHarness(): FakeSessionHarness { }); // setModel 声明 string 入参(签名与真实 Session.setModel 一致),下面 mockImplementation 才能 // 按 (m) 更新 session.model;测试可再用 mockRejectedValue / mockImplementation 覆盖。 - const setModel = vi.fn( - async (_m: string, _opts?: { providerId?: string | null }): Promise => {}, - ); + const setModel = vi.fn<(model: string, opts?: { providerId?: string | null }) => Promise>(async () => {}); const setEffort = vi.fn(async () => undefined); const session = { id: 'scheduler-session', @@ -142,7 +140,7 @@ function createSessionHarness(): FakeSessionHarness { // session.model(而非 getSessionMeta 快照)确定复用会话实际在跑的模型,harness 必须同样更新。 // 需要模拟"setModel 抛错"的用例用 mockRejectedValue 覆盖本实现(reject → 不更新 = Claude 语义); // 模拟 Codex"await 前先改 model 再抛"的用例用 mockImplementation 显式先改 model 再 throw。 - setModel.mockImplementation(async (m: string, _opts?: { providerId?: string | null }) => { + setModel.mockImplementation(async (m: string) => { (session as { model: string }).model = m; }); @@ -274,7 +272,7 @@ function createRunnerHarness( listActiveSessions: vi.fn(() => opts.activeSessions ?? [h.session]), closeSession, // issue #456:runner fire 时按所选模型 efforts reconcile effort;测试经 availableModels 注入能力。 - getCapabilities: vi.fn((_agent: string) => ({ availableModels: opts.availableModels ?? [] })), + getCapabilities: vi.fn(() => ({ availableModels: opts.availableModels ?? [] })), // 默认 false = fresh spawn(opts.model/effort 已生效);true 模拟进程内 // 复用 active session 的路径(createSession 忽略 opts, setModel/setEffort 是唯一通道)。 isSessionAlive: vi.fn(() => opts.sessionAlive ?? false), @@ -581,6 +579,39 @@ describe('MakerScheduleRunner model selection', () => { }); describe('non-heartbeat (每次新建 session)', () => { + it('runs a standalone Cursor schedule with the normal unattended permission while keeping its native model', async () => { + const h = createSessionHarness(); + const harness = createRunnerHarness(h); + await fireToCompletion(harness, h, baseSchedule({ agentKind: 'cursor', model: 'cursor-default' })); + expect(harness.createSession).toHaveBeenCalledWith(expect.objectContaining({ + agentKind: 'cursor', model: 'cursor-default', permissionMode: 'bypassPermissions', + })); + }); + + it('resolves a blank Cursor schedule through the new-task default instead of a Claude model', async () => { + const h = createSessionHarness(); + const resolveDefaultModelRoute = vi.fn(async () => ({ + model: 'grok-4.7', + providerId: 'cursor', + })); + const harness = createRunnerHarness(h, null, { resolveDefaultModelRoute }); + + const opts = await fireToCompletion( + harness, + h, + baseSchedule({ agentKind: 'cursor', model: undefined }), + ); + + expect(resolveDefaultModelRoute).toHaveBeenCalledWith('cursor', null); + expect(opts.model).toBe('grok-4.7'); + expect(harness.createSession).toHaveBeenCalledWith(expect.objectContaining({ + agentKind: 'cursor', + model: 'grok-4.7', + providerId: 'cursor', + permissionMode: 'bypassPermissions', + })); + }); + it('schedule.model 留空时 Claude 按新建任务默认实时解析 model + 来源,不写死型号', async () => { const h = createSessionHarness(); const resolveDefaultModelRoute = vi.fn(async () => ({ diff --git a/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts b/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts index de00e85aa3f..135b094839c 100644 --- a/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts +++ b/apps/desktop/src/main/scheduler-host/__tests__/scriptCapabilityBroker.test.ts @@ -401,11 +401,14 @@ describe('SchedulerScriptCapabilityBroker', () => { expect(sendToSessionMock).not.toHaveBeenCalled(); }); - it('dispatches sessions with host-owned create defaults from the schedule', async () => { + it.each([ + { agentKind: 'codex', model: 'gpt-5.5', providerId: 'provider-1' }, + { agentKind: 'cursor', model: 'cursor-default', providerId: 'cursor' }, + ] as const)('dispatches $agentKind sessions with host-owned create defaults from the schedule', async (selection) => { sendToSessionMock.mockResolvedValue({ ok: true, targetSessionId: 'session-1', - agentKind: 'codex', + agentKind: selection.agentKind, wakeKind: 'created', targetTitle: 'Triage DING-1', targetLastUserSendAt: null, @@ -420,7 +423,7 @@ describe('SchedulerScriptCapabilityBroker', () => { }, }, new Set(['sessions.dispatch']), - { schedule: schedule() }, + { schedule: schedule(selection) }, ); expect(sendToSessionMock).toHaveBeenCalledWith({ @@ -429,9 +432,7 @@ describe('SchedulerScriptCapabilityBroker', () => { title: 'Triage DING-1', useWorktree: false, createDefaults: { - agentKind: 'codex', - model: 'gpt-5.5', - providerId: 'provider-1', + ...selection, effort: 'high', fastMode: true, workingDir: 'C:\\project', @@ -668,8 +669,7 @@ describe('SchedulerScriptCapabilityBroker', () => { const { fsSlot } = wireRealChannel(tmp); // 真实 dispatcher:资格审 + callId 配对 + 错误折叠全真,只有「意识进程」 // 本身由 sendToGhost 内联模拟(先经 fs 槽写盘,再 handleToolResult 交卷)。 - let dispatcher!: GhostPipeDispatcher; - dispatcher = new GhostPipeDispatcher({ + const dispatcher: GhostPipeDispatcher = new GhostPipeDispatcher({ getGhost: (id) => (id === 'xd-atlassian' ? makeInstalledGhost(id) : null), runtimeStateOf: () => 'running', spawn: async () => ({ ok: true }), diff --git a/apps/desktop/src/main/scheduler-host/project-automation-loader.ts b/apps/desktop/src/main/scheduler-host/project-automation-loader.ts index 4d789a8b06f..04a27db6469 100644 --- a/apps/desktop/src/main/scheduler-host/project-automation-loader.ts +++ b/apps/desktop/src/main/scheduler-host/project-automation-loader.ts @@ -47,9 +47,9 @@ export interface ProjectScheduleConfig { recurring?: boolean; manual?: boolean; intervalMs?: number; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 显式模型选择的 Harness;省略时保留旧配置的跟随绑定任务语义。 */ - modelAgentKind?: 'claude-code' | 'codex' | 'pi'; + modelAgentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; model?: string; /** 可选:显式来源(供应商)id。省略 → 走原生默认来源(与旧配置字节级一致)。详见 Schedule.providerId。 */ providerId?: string; @@ -604,7 +604,7 @@ function optionalNumber(value: unknown): boolean { } function optionalAgentKind(value: unknown): boolean { - return value === undefined || value === 'claude-code' || value === 'codex' || value === 'pi'; + return value === undefined || value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'cursor'; } function optionalNotify(value: unknown): boolean { diff --git a/apps/desktop/src/main/scheduler-host/runner.ts b/apps/desktop/src/main/scheduler-host/runner.ts index 81a6c4b88e8..b669cce7596 100644 --- a/apps/desktop/src/main/scheduler-host/runner.ts +++ b/apps/desktop/src/main/scheduler-host/runner.ts @@ -57,7 +57,6 @@ import type { Effort, PermissionMode, Session, - TurnContinuationState, } from '@cindy/maker-core'; import { clampEffortToSupported } from '@cindy/model-providers'; import { @@ -197,12 +196,10 @@ const INTERRUPTED_ERROR_DONE_FALLBACK_MS = 250; * 一个可用来源都没有就让本次运行明确失败。表单层的回退(任务上次选择 → 对话上次选择 → * 新建任务默认)保存时落显式值,runner 这条兜 MCP 创建的 / 历史遗留的空 model 任务。 * - * 普通 schedule 的 permissionMode 两个 agent 都用 'bypassPermissions'(types/common.ts:23 注释确认 - * codex 支持子集 ask/auto/bypassPermissions)—— 调度本质是 unattended,bypass 是 + * 普通 schedule 的 permissionMode 各引擎都用 'bypassPermissions' —— 调度本质是 unattended,bypass 是 * 既有独立调度策略。绑定任务的心跳与伙伴例行任务不使用此默认值,继承原任务的权限与计划模式。 */ function defaultPermissionModeForSchedule(): PermissionMode { - // 两个 agent 都支持 bypassPermissions(types/common.ts:23),暂不按 agentKind 分支 return 'bypassPermissions'; } diff --git a/apps/desktop/src/main/skillhub/scanner.ts b/apps/desktop/src/main/skillhub/scanner.ts index 00e435b50d7..a646ae69b7c 100644 --- a/apps/desktop/src/main/skillhub/scanner.ts +++ b/apps/desktop/src/main/skillhub/scanner.ts @@ -70,10 +70,10 @@ export interface Skill { /** 同一 URL 基键存在多个来源时,详情路由必须携带 sourceKey。 */ requiresSourceKey?: boolean; /** 来自哪个 agent 引擎。 */ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 发现该 skill 的所有引擎专属路径(去重后)。~/.agents/ 通用路径不算引擎。 */ linkedEngines: Array<{ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'cursor'; label: string; runtimeStatus?: PiRuntimeCapabilityStatus; }>; @@ -281,7 +281,7 @@ export async function scanAllSkills( if (!engines.has(item.engine)) { engines.set(item.engine, { engine: item.engine, - label: item.engine === 'claude-code' ? 'Claude' : item.engine === 'codex' ? 'Codex' : 'Pi', + label: item.engine === 'claude-code' ? 'Claude' : item.engine === 'codex' ? 'Codex' : item.engine === 'cursor' ? 'Cursor' : 'Pi', ...(item.runtimeStatus ? { runtimeStatus: item.runtimeStatus } : {}), }); } @@ -380,7 +380,7 @@ export async function scanAllSkills( if (!engineSet.has(eng)) { engineSet.set(eng, { engine: eng, - label: eng === 'claude-code' ? 'Claude' : eng === 'codex' ? 'Codex' : 'Pi', + label: eng === 'claude-code' ? 'Claude' : eng === 'codex' ? 'Codex' : eng === 'cursor' ? 'Cursor' : 'Pi', ...(item.runtimeStatus ? { runtimeStatus: item.runtimeStatus } : {}), }); } diff --git a/apps/desktop/src/main/skillhub/usageAnalyzer.ts b/apps/desktop/src/main/skillhub/usageAnalyzer.ts index 8b1f1374f6b..54b8293c43b 100644 --- a/apps/desktop/src/main/skillhub/usageAnalyzer.ts +++ b/apps/desktop/src/main/skillhub/usageAnalyzer.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { stripTrailingPathSeparators } from '../../shared/pathText'; -export type SkillUsageAgentKind = 'claude-code' | 'codex' | 'pi'; +export type SkillUsageAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type SkillUsageExposureSource = | 'claude_skill_tool' diff --git a/apps/desktop/src/main/skillhub/usageStore.ts b/apps/desktop/src/main/skillhub/usageStore.ts index 6e3b8194598..3af49dda232 100644 --- a/apps/desktop/src/main/skillhub/usageStore.ts +++ b/apps/desktop/src/main/skillhub/usageStore.ts @@ -921,7 +921,7 @@ function toEvidenceIndex(row: Record): SkillUsageEvidenceIndex rawLineNo: numberValue(row.rawLineNo), sessionId: stringValue(row.sessionId), sdkSessionId: stringValue(row.sdkSessionId), - agentKind: stringValue(row.agentKind) === 'claude-code' ? 'claude-code' : stringValue(row.agentKind) === 'pi' ? 'pi' : 'codex', + agentKind: stringValue(row.agentKind) === 'claude-code' ? 'claude-code' : stringValue(row.agentKind) === 'pi' ? 'pi' : stringValue(row.agentKind) === 'cursor' ? 'cursor' : 'codex', skillName: stringValue(row.skillName), skillPath: stringValue(row.skillPath) || null, skillDocumentHash: stringValue(row.skillDocumentHash) || null, @@ -1363,7 +1363,7 @@ function skillUsageEvidenceFilter( function toRecentSourceRecords(rows: Array>): SkillUsageRecentSourceRecord[] { return rows.map((row) => { - const agentKind: SkillUsageAgentKind = stringValue(row.agentKind) === 'claude-code' ? 'claude-code' : stringValue(row.agentKind) === 'pi' ? 'pi' : 'codex'; + const agentKind: SkillUsageAgentKind = stringValue(row.agentKind) === 'claude-code' ? 'claude-code' : stringValue(row.agentKind) === 'pi' ? 'pi' : stringValue(row.agentKind) === 'cursor' ? 'cursor' : 'codex'; return { rawFilePath: stringValue(row.rawFilePath), agentKind, diff --git a/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts b/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts index 428959fb1e7..9dd863f490a 100644 --- a/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts +++ b/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts @@ -317,6 +317,10 @@ describe('billing model keys', () => { }); describe('getSubscriptionValuePriceFor', () => { + it('does not assign another harness subscription pricing to Cursor', () => { + expect(getSubscriptionValuePriceFor('cursor', 'claude-sonnet-5', null)).toBeUndefined(); + expect(getSubscriptionValuePriceFor('cursor', 'gpt-5.5', null)).toBeUndefined(); + }); it('routes Pi exclusive Grok ids through the subscription-direct quote', () => { expect(getSubscriptionValuePriceFor('pi', 'grok-4.6', null)).toMatchObject({ providerId: 'xai', diff --git a/apps/desktop/src/main/usage/usageHistory.ts b/apps/desktop/src/main/usage/usageHistory.ts index e22add3b676..0919c56be48 100644 --- a/apps/desktop/src/main/usage/usageHistory.ts +++ b/apps/desktop/src/main/usage/usageHistory.ts @@ -91,7 +91,7 @@ export interface UsageHistoryDay { } export interface UsageHistoryModel { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; /** SDK 实报美元 (Claude); Codex 恒 0。 */ money: RegionalMoney; @@ -106,7 +106,7 @@ export interface UsageHistoryModel { /** 每日 × 模型的一行明细 — 右栏堆叠柱状图的分段数据。 */ export interface UsageHistoryModelDay { day: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; /** 可比金额: Claude 实报 $; Codex 为价格表估算 (无价格 → 0, 只出现在图例 token 行)。 */ money: RegionalMoney; @@ -522,12 +522,14 @@ export function piSubscriptionUsageModelKey(model: string): string { * 各级都 miss → undefined(该行只显示 token,不臆造金额)。 */ export function getSubscriptionValuePriceFor( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, pricing: ModelPricingMap | null, at?: string | Date, overrides?: ModelPriceOverridesSnapshot, ): ModelPriceQuote | undefined { + // Cursor usage does not imply Claude/Codex subscription pricing. + if (agentKind === 'cursor') return undefined; if (agentKind === 'codex') { return ( getSubscriptionDirectValuePrice(model, 'codex', pricing, at, overrides) ?? @@ -786,7 +788,7 @@ export async function readUsageHistoryWith( isSubscriptionUsageModel(r.model) && r.money.kind !== 'value-estimate' && !getSubscriptionValuePriceFor( - r.agentKind === 'codex' ? 'codex' : r.agentKind === 'pi' ? 'pi' : 'claude-code', + r.agentKind === 'codex' ? 'codex' : r.agentKind === 'pi' ? 'pi' : r.agentKind === 'cursor' ? 'cursor' : 'claude-code', displayModelName(r.model), pricing, r.day, @@ -807,7 +809,7 @@ export async function readUsageHistoryWith( if (row.day === todayKey) todayTokens += rowTokens; } for (const row of modelRows) { - const agentKind = row.agentKind === 'codex' ? 'codex' : row.agentKind === 'pi' ? 'pi' : 'claude-code'; + const agentKind = row.agentKind === 'codex' ? 'codex' : row.agentKind === 'pi' ? 'pi' : row.agentKind === 'cursor' ? 'cursor' : 'claude-code'; // claude 订阅行同样带 #billing= 后缀, 展示名统一剥后缀; key 保留原始 model // (api / subscription 两个计费维度分行聚合)。 const model = displayModelName(row.model); @@ -871,7 +873,9 @@ export async function readUsageHistoryWith( ? ('codex' as const) : row.agentKind === 'pi' ? ('pi' as const) - : ('claude-code' as const); + : row.agentKind === 'cursor' + ? ('cursor' as const) + : ('claude-code' as const); const model = displayModelName(row.model); const apiMoney = row.money.kind === 'actual-cost' ? row.money : zeroActual(); const subscriptionEstimateMoney = isSubscriptionUsageModel(row.model) diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f8616a0ba68..4009e56aef3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2530,16 +2530,17 @@ contextBridge.exposeInMainWorld('electronAPI', { * fire-and-forget。 */ syncNewMakerDraft: (snapshot: { + appDefaultModelRequestId?: string; ownerStamp: import('../shared/dataOwnerPush').DataOwnerPushStamp; - selectedRoute?: import('../shared/botModelChain').BotModelRoute; + selectedRoute?: import('../shared/appDefaultModelSelection').AppModelRoute; lastByVendor: Partial< Record< - 'cc' | 'codex' | 'pi', + 'cc' | 'codex' | 'pi' | 'cursor', { model?: string; effort?: string; permissionMode?: string; providerId?: string | null } > >; /** 每个 vendor 是否由用户在 New Maker 中明确选过模型;device-link 默认校准据此保护显式选择。 */ - modelChosenByVendor: Partial>; + modelChosenByVendor: Partial>; fastModeByModel: Record; effortByModel: Record; providerModelMemory?: Record< @@ -2555,7 +2556,7 @@ contextBridge.exposeInMainWorld('electronAPI', { /** Renderer localStorage workerCreationPrefs → main 内存镜像。 */ syncWorkerCreationPrefs: (snapshot: { - workerPermissionMode: 'auto' | 'bypassPermissions'; + workerPermissionMode: 'ask' | 'auto' | 'bypassPermissions'; }): void => ipcRenderer.send('maker:sync-worker-creation-prefs', snapshot), /** @@ -5954,12 +5955,12 @@ contextBridge.exposeInMainWorld('electronAPI', { // 详见下方 maker 块的 auth / agent / usage 三个子对象。 // ─── Maker Core IPC ───────────────────────────────────────────────────── - // renderer 通过统一 maker API 按 agentKind 调用 Claude Code / Codex / Pi。 + // renderer 通过统一 maker API 按 agentKind 调用 Claude Code / Codex / Pi / Cursor。 maker: { - listAvailableAgents: (): Promise> => + listAvailableAgents: (): Promise> => ipcRenderer.invoke('maker:list-available-agents'), onAgentsChanged: fanOutMakerAgentsChanged, - getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'): Promise => ipcRenderer.invoke('maker:get-capabilities', agentKind), listBotDelegations: ( parentSessionId: string, @@ -6513,7 +6514,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }): Promise<{ ok: true; runId: string; reviewerSessionId: string }> => ipcRenderer.invoke('maker:review:start', input), listAgentCommands: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params: { sessionId?: string; allowManagedPiPackagePreview?: boolean } = {}, ): Promise<{ success: boolean; @@ -6523,7 +6524,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }> => ipcRenderer.invoke('maker:list-agent-commands', agentKind, params), listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params: { workingDir?: string; remoteHostId?: string; @@ -6642,7 +6643,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onGoalStatusChanged: fanOutGoalStatusChanged, scanAtResources: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params: { workingDir: string; cap?: number; query?: string }, ): Promise<{ success: boolean; @@ -6675,7 +6676,7 @@ contextBridge.exposeInMainWorld('electronAPI', { createSession: (opts: { /** 可选: 复用外部 sessionId(本端 chat 用 local-db:sessions:create 拿到的 id) */ id?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; title?: string; @@ -6738,7 +6739,7 @@ contextBridge.exposeInMainWorld('electronAPI', { /** 显式选定的模型来源(标准面板 per-worker 选择);缺省 = 跟随默认路由解析。 */ providerId?: string | null; /** Worker 创建默认权限;缺省沿用当前偏好,显式值会更新偏好。 */ - workerPermissionMode?: 'auto' | 'bypassPermissions'; + workerPermissionMode?: 'ask' | 'auto' | 'bypassPermissions'; /** 新建 Lead 专用:等首条输入 accepted 且可查询后再派任务。 */ deferDelegateTask?: boolean; /** 首个 Worker 放到同账号另一台电脑运行;缺省 = 本机。 */ @@ -6754,7 +6755,7 @@ contextBridge.exposeInMainWorld('electronAPI', { workerSessionId: string; workerId: string; dispatched: boolean; - workerPermissionMode: 'auto' | 'bypassPermissions'; + workerPermissionMode: 'ask' | 'auto' | 'bypassPermissions'; uiAssignmentSnapshotBeforeMs: number; }> => ipcRenderer.invoke('maker:session:enable-orca', leadSessionId, opts), @@ -6791,7 +6792,7 @@ contextBridge.exposeInMainWorld('electronAPI', { message: string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; orcaRole?: 'lead' | 'worker' | null; @@ -6836,7 +6837,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getContextUsage: ( sessionId: string, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; orcaRole?: 'lead' | 'worker' | null; @@ -6878,7 +6879,7 @@ contextBridge.exposeInMainWorld('electronAPI', { listActive: (): Promise< Array<{ sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workDir: string; capabilities: unknown; isTurnRunning: boolean; @@ -6958,7 +6959,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // switched=false 且无 deferred = 同引擎 no-op(用户选回当前引擎,意图已清)。 switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId?: string | null, effort?: string, @@ -6967,7 +6968,7 @@ contextBridge.exposeInMainWorld('electronAPI', { options?: { agentDeviceId?: string | null }, ): Promise<{ switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; engineReady: boolean; deferred?: boolean; @@ -6999,7 +7000,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionAgentSwitchIntent: ( sessionId: string, ): Promise<{ - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort?: string; @@ -7579,7 +7580,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Stage 2 C1: chat utility (前身 cc-agent:generate-title / cc-agent:plan-file-write) generateTitle: ( message: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', sessionId?: string, ): Promise<{ title: string | null }> => ipcRenderer.invoke('maker:generate-title', { message, agentKind, sessionId }), @@ -7594,14 +7595,14 @@ contextBridge.exposeInMainWorld('electronAPI', { autoTitle: (request: { sessionId: string; text: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; isUserText?: boolean; }): Promise<{ applied: boolean; done: boolean }> => ipcRenderer.invoke('maker:auto-title', request), /** 输入框推荐提示词:turn 结束后预测用户下一步输入(turn 完成 → 调 IPC → 返回预测文本)。 */ predictNextPrompt: (request: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; messages: Array<{ role: string; content: string }>; workingDir?: string; turnGen: number; @@ -7669,18 +7670,18 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 鉴权 (取代老 electronAPI.codex.auth.*) ──────────────────────── auth: { - getState: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getState: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'): Promise => ipcRenderer.invoke('maker:auth:get-state', agentKind), triggerLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', options?: { mode?: 'browser' | 'device-code' | 'local'; ownerId?: string }, ): Promise => ipcRenderer.invoke('maker:auth:trigger-login', agentKind, options), cancelLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', options?: { releaseOwner?: boolean; ownerId?: string }, ): Promise => ipcRenderer.invoke('maker:auth:cancel-login', agentKind, options), logout: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', ownerScope?: { dataOwnerId: string | null; ownerGeneration: number }, ): Promise => ipcRenderer.invoke('maker:auth:logout', agentKind, ownerScope), onStateChanged: fanOutMakerAuthStateChanged, @@ -7698,7 +7699,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 联合状态 (取代老 electronAPI.codex.binary.getStatus) ────────── agent: { - getStatus: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getStatus: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'): Promise => ipcRenderer.invoke('maker:agent:status', agentKind), /** spawn 当前应用使用的 binary `--version`, 进程内缓存。About 面板用。 */ getBinaryVersion: ( @@ -7717,10 +7718,10 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 今日累计 (取代老 electronAPI.codex.usage.* + electronAPI.onUsageTodaySpendChanged) ─ usage: { - getToday: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getToday: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'): Promise => ipcRenderer.invoke('maker:usage:today', agentKind), getAccount: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', providerId?: string, ): Promise => ipcRenderer.invoke('maker:usage:account', agentKind, providerId), /** Codex app-server authoritative windows and banked reset-credit metadata. */ diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 535a421428a..5a5502404f6 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -122,7 +122,7 @@ function syncNewMakerPrefs(appDefaultModelRequestId?: string) { }); // main 缓存两用途:① collab worker spawn 读 model/effort/fastMode;② device-link 远程 // 草稿镜像读全量(model/effort/fast/permission/source)+「是否显式选过模型」。故 - // lastByVendor 覆盖 cc/codex/pi,并带上 permissionMode + providerId(worker spawn + // lastByVendor 覆盖 cc/codex/pi/cursor,并带上 permissionMode + providerId(worker spawn // 不消费这两项,远程草稿镜像才用)。fire-and-forget。 const selected = draft.lastByVendor[draft.vendor]; window.electronAPI.syncNewMakerDraft({ @@ -154,11 +154,18 @@ function syncNewMakerPrefs(appDefaultModelRequestId?: string) { permissionMode: draft.lastByVendor.pi.permissionMode, providerId: draft.lastByVendor.pi.providerId ?? null, }, + cursor: { + model: draft.lastByVendor.cursor.model, + effort: draft.lastByVendor.cursor.effort, + permissionMode: draft.lastByVendor.cursor.permissionMode, + providerId: draft.lastByVendor.cursor.providerId ?? null, + }, }, modelChosenByVendor: { cc: draft.modelChosenByVendor.cc === true, codex: draft.modelChosenByVendor.codex === true, pi: draft.modelChosenByVendor.pi === true, + cursor: draft.modelChosenByVendor.cursor === true, }, fastModeByModel: draft.fastModeByModel, effortByModel: draft.effortByModel, diff --git a/apps/desktop/src/renderer/__tests__/addProviderWizardCursorEntry.test.tsx b/apps/desktop/src/renderer/__tests__/addProviderWizardCursorEntry.test.tsx new file mode 100644 index 00000000000..b5559e7121e --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/addProviderWizardCursorEntry.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProviderView } from '@cindy/model-providers'; + +const { refreshModels, toastError } = vi.hoisted(() => ({ + refreshModels: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'zh-CN' } }), +})); +vi.mock('@/lib/toast', () => ({ toast: { error: toastError, success: vi.fn() } })); +vi.mock('@/lib/customProviders', () => ({ + createCustomProvider: vi.fn(), + deleteCustomProvider: vi.fn(), +})); +vi.mock('@/lib/providerModels', () => ({ providerMonogram: () => 'C' })); + +import { AddProviderWizard } from '@/components/settings/AddProviderWizard'; + +const cursor: ProviderView = { + id: 'cursor', + name: 'Cursor', + source: 'builtin', + agents: ['cursor'], + auth: { method: 'none' }, + access: { kind: 'subscription', product: 'Cursor' }, + routing: {}, + models: { cursor: [] }, + connected: false, +}; + +function renderWizard(entry = false, provider = cursor) { + const onDone = vi.fn(); + render( + , + ); + return { onDone }; +} + +beforeEach(() => { + refreshModels.mockReset().mockResolvedValue({ ok: true, providerId: 'cursor' }); + window.electronAPI = { + platform: 'darwin', + openExternal: vi.fn(async () => undefined), + maker: { + listProviderPresets: vi.fn(async () => ({ presets: [] })), + scanLocalCli: vi.fn(async () => ({ detections: [] })), + localModelList: vi.fn(async () => ({ + status: { runtime: 'ollama', kind: 'absent', appInstalled: false }, + models: [], + memoryGb: 0, + })), + onProviderOAuthProgress: vi.fn(() => () => undefined), + refreshBuiltinProviderModels: refreshModels, + providerOAuthLogin: vi.fn(), + auth: { triggerLogin: vi.fn() }, + }, + } as unknown as typeof window.electronAPI; +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('Cursor native provider setup', () => { + it('offers Cursor before any native models have been discovered, including search', async () => { + renderWizard(); + fireEvent.change(screen.getByPlaceholderText('settings.providers.wizard.searchPlaceholder'), { + target: { value: 'cursor' }, + }); + fireEvent.click(await screen.findByText('Cursor')); + expect(screen.getByTestId('cursor-provider-setup')).toBeTruthy(); + expect(screen.queryByText('settings.providers.button.authorize')).toBeNull(); + expect(screen.queryByText('settings.providers.openai.addIndependentAccount')).toBeNull(); + }); + + it('opens native instructions directly instead of an OAuth flow', async () => { + const { onDone } = renderWizard(true); + expect(await screen.findByTestId('cursor-provider-setup')).toBeTruthy(); + expect(screen.getByText('cursor-agent login')).toBeTruthy(); + expect(screen.queryByText('settings.providers.button.authorize')).toBeNull(); + expect(onDone).not.toHaveBeenCalled(); + expect(window.electronAPI.maker.providerOAuthLogin).not.toHaveBeenCalled(); + }); + + it('refreshes through the existing native provider API before finishing', async () => { + const { onDone } = renderWizard(true); + fireEvent.click( + screen.getByRole('button', { name: 'settings.providers.models.refreshBuiltinAria' }), + ); + await waitFor(() => expect(onDone).toHaveBeenCalledWith('cursor')); + expect(refreshModels).toHaveBeenCalledOnce(); + expect(refreshModels).toHaveBeenCalledWith('cursor'); + expect(window.electronAPI.maker.auth.triggerLogin).not.toHaveBeenCalled(); + expect(window.electronAPI.maker.providerOAuthLogin).not.toHaveBeenCalled(); + }); + + it('keeps the setup open and retryable when native discovery fails', async () => { + refreshModels.mockRejectedValueOnce(new Error('native discovery unavailable')); + const { onDone } = renderWizard(true); + const refresh = screen.getByRole('button', { + name: 'settings.providers.models.refreshBuiltinAria', + }); + fireEvent.click(refresh); + await waitFor(() => + expect(toastError).toHaveBeenCalledWith('settings.providers.cursor.refreshFailed'), + ); + expect(onDone).not.toHaveBeenCalled(); + fireEvent.click(refresh); + await waitFor(() => expect(onDone).toHaveBeenCalledWith('cursor')); + expect(refreshModels).toHaveBeenCalledTimes(2); + }); + + it('does not offer a duplicate native connection when Cursor is already connected', async () => { + renderWizard(false, { ...cursor, connected: true }); + await waitFor(() => expect(window.electronAPI.maker.scanLocalCli).toHaveBeenCalled()); + expect(screen.queryByText('Cursor')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx index 22f61e4a41b..fdddbbc9021 100644 --- a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx +++ b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx @@ -894,7 +894,7 @@ describe('AddProviderWizard — preset 直达', () => { // 同一 model id 在两端窗口可以不同(如 cc=1M / codex=272K):共享一个发现值 // 会让其中一端显示与压缩阈值双错,必须按 agent 分槽各取各的端点上报值。 vi.mocked(window.electronAPI.maker.fetchProviderModels).mockImplementation( - async ({ agent }: { agent: 'claude-code' | 'codex' | 'pi' }) => ({ + async ({ agent }) => ({ ok: true, models: [ { diff --git a/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts b/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts index 32ecd7b0f2c..82dca9eaa2e 100644 --- a/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts +++ b/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts @@ -106,12 +106,12 @@ describe('useAgentCapabilities deviceId-aware cache', () => { ); }); - it('本机目录快照在可选 Pi 不可用时仍返回 Claude Code 与 Codex 能力', async () => { + it.each(['pi', 'cursor'] as const)('本机目录快照在可选 %s 不可用时仍返回其余能力', async (missing) => { const { getCapabilities } = stubElectron(); getCapabilities.mockImplementation(async (agent: string) => { - if (agent === 'pi') + if (agent === missing) throw new Error( - "[MAKER_NOT_FOUND] Agent 'pi' is not registered (available: claude-code, codex)", + `[MAKER_NOT_FOUND] Agent '${missing}' is not registered`, ); return caps(`local:${agent}`); }); @@ -120,8 +120,9 @@ describe('useAgentCapabilities deviceId-aware cache', () => { await expect(mod.loadLocalCapabilitiesSnapshot()).resolves.toEqual([ ['claude-code', caps('local:claude-code')], ['codex', caps('local:codex')], + ...(['pi', 'cursor'] as const).filter(agent => agent !== missing).map(agent => [agent, caps(`local:${agent}`)]), ]); - expect(getCapabilities).toHaveBeenCalledWith('pi'); + expect(getCapabilities).toHaveBeenCalledWith(missing); }); it('本机目录快照在核心 agent 不可用时仍拒绝提交部分能力', async () => { @@ -522,8 +523,8 @@ describe('useAgentCapabilities deviceId-aware cache', () => { mod.prefetchDeviceCapabilities('dev-1'), mod.prefetchDeviceCapabilities('dev-1'), ]); - // cc + codex + pi 各一次 = 3 次,而非 6 次 - expect(invoke).toHaveBeenCalledTimes(3); + // Each of the four agents is fetched once, even with concurrent callers. + expect(invoke).toHaveBeenCalledTimes(4); }); it('驱逐:evict 只清该设备,本地与其它设备保留', async () => { @@ -586,8 +587,10 @@ describe('useAgentCapabilities deviceId-aware cache', () => { const mod = await import('@/hooks/useAgentCapabilities'); const claudeListener = vi.fn(); const codexListener = vi.fn(); + const cursorListener = vi.fn(); mod.subscribeDeviceCapabilities('dev-1', 'claude-code', claudeListener); mod.subscribeDeviceCapabilities('dev-1', 'codex', codexListener); + mod.subscribeDeviceCapabilities('dev-1', 'cursor', cursorListener); await mod.prefetchDeviceCapabilities('dev-1'); @@ -615,21 +618,25 @@ describe('useAgentCapabilities deviceId-aware cache', () => { const mod = await import('@/hooks/useAgentCapabilities'); const claudeListener = vi.fn(); const codexListener = vi.fn(); + const cursorListener = vi.fn(); mod.subscribeDeviceCapabilities('dev-1', 'claude-code', claudeListener); mod.subscribeDeviceCapabilities('dev-1', 'codex', codexListener); + mod.subscribeDeviceCapabilities('dev-1', 'cursor', cursorListener); const stale = mod.prefetchDeviceCapabilities('dev-1'); mod.evictDeviceCapabilities('dev-1'); const fresh = mod.prefetchDeviceCapabilities('dev-1'); - // 每轮按 ALL_AGENT_KINDS 顺序 push 三个 resolver(cc/codex/pi): - // 第一轮(stale)= [0][1][2],第二轮(fresh)= [3][4][5]。 - resolvers[3](caps('fresh:claude')); - resolvers[4](caps('fresh:codex')); - resolvers[5](caps('fresh:pi')); + // Each refresh starts four requests, including native Cursor. + expect(invoke).toHaveBeenCalledTimes(8); + resolvers[4](caps('fresh:claude')); + resolvers[5](caps('fresh:codex')); + resolvers[6](caps('fresh:pi')); + resolvers[7](caps('fresh:cursor')); await fresh; resolvers[0](caps('stale:claude')); resolvers[1](caps('stale:codex')); resolvers[2](caps('stale:pi')); + resolvers[3](caps('stale:cursor')); await stale; expect(claudeListener).toHaveBeenNthCalledWith(1, { status: 'loading' }); @@ -652,5 +659,8 @@ describe('useAgentCapabilities deviceId-aware cache', () => { expect(mod.getCachedCapabilities('codex', 'dev-1')?.availableModels[0].displayName).toBe( 'fresh:codex', ); + expect(cursorListener).toHaveBeenCalledTimes(2); + expect(cursorListener).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ready' })); + expect(mod.getCachedCapabilities('cursor', 'dev-1')?.availableModels[0].displayName).toBe('fresh:cursor'); }); }); diff --git a/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts b/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts index 8b7ea348891..51efdb36f45 100644 --- a/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts +++ b/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts @@ -17,6 +17,15 @@ const R = resolve(__dirname, '..'); const read = (rel: string) => readFileSync(resolve(R, rel), 'utf8').replace(/\r\n/g, '\n'); describe('resolveCollabEntryPolicy 五类场景', () => { + it.each([ + { workspaceKind: 'project', workingDir: '/repo' }, + { workspaceKind: 'dialogue', workingDir: null }, + { workspaceKind: 'project', workingDir: '/repo', deviceLinkDeviceId: 'host' }, + ])('does not offer orchestration for native Cursor: %j', (target) => { + expect(resolveCollabEntryPolicy({ ...target, agentKind: 'cursor' }).eligible).toBe(false); + expect(resolveCollabEntryPolicy({ ...target, agentKind: 'codex' }).eligible).toBe(true); + }); + it('本地项目:可挂入口,查本机项目级', () => { expect( resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: '/Users/me/proj' }), diff --git a/apps/desktop/src/renderer/__tests__/cursorPermissionPolicy.test.ts b/apps/desktop/src/renderer/__tests__/cursorPermissionPolicy.test.ts new file mode 100644 index 00000000000..13742c1e3ff --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/cursorPermissionPolicy.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const locales = ['en', 'zh-CN', 'zh-TW', 'ja', 'ko'] as const; + +describe('Cursor native permission copy', () => { + it.each(locales)('describes all Cursor approval modes independently of Pi in %s', (locale) => { + const desktop = JSON.parse(readFileSync(resolve('src/renderer/i18n/locales', locale, 'common.json'), 'utf8')); + const mobile = JSON.parse(readFileSync(resolve('../mobile/src/i18n/locales', locale, 'session.json'), 'utf8')); + const modes = desktop.newChat.permissionSelector.modes; + const full = desktop.newChat.chatInput.fullAccessConfirmation.cursor; + expect(full.description).toContain('Cursor'); + expect(full.note).toContain('Cursor'); + for (const item of ['files', 'commands', 'network']) expect(full.items[item].description).toContain('Cursor'); + for (const mode of ['ask', 'default', 'auto', 'bypassPermissions']) { + expect(modes.cursor[mode].label.trim()).not.toBe(''); + expect(modes.cursor[mode].description).toContain('Cursor'); + expect(modes.cursor[mode].description).not.toBe(modes.pi.ask.description); + const mobileHint = mode === 'auto' ? mobile.collab.cursorPermissionAutoHint + : mode === 'bypassPermissions' ? mobile.collab.cursorPermissionFullHint : mobile.collab.permissionAskHint; + expect(mobileHint).toBe(modes.cursor[mode].description); + } + }); + + it('does not promise a prompt for every workspace edit', () => { + const desktop = JSON.parse(readFileSync(resolve('src/renderer/i18n/locales/en/common.json'), 'utf8')); + const description = desktop.newChat.permissionSelector.modes.cursor.ask.description; + expect(description).toContain('native configured permission policy'); + expect(description).toContain('only the approval requests Cursor sends'); + expect(description).toContain('workspace edits may run without a prompt'); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/deviceLinkCreateArgs.test.ts b/apps/desktop/src/renderer/__tests__/deviceLinkCreateArgs.test.ts index b9f265970cd..d3f80f45d59 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkCreateArgs.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkCreateArgs.test.ts @@ -499,3 +499,10 @@ describe('resolveDeviceLinkSubmission', () => { expect(fromSend.providerId).toBe('stale-source'); }); }); + +describe('Cursor native creation', () => { + it('forwards native model tuning while preserving the Cursor approval policy', () => { + const args = buildDeviceLinkCreateArgs({ agentKind: 'cursor', model: 'account-model', effort: 'medium', permissionMode: 'auto', fastMode: true, planModeEnabled: true }); + expect(args).toMatchObject({ agentKind: 'cursor', model: 'account-model', effort: 'medium', permissionMode: 'auto', fastMode: true, planMode: true }); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts b/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts index 169e7a16259..1606a3b8eb4 100644 --- a/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts +++ b/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts @@ -121,3 +121,12 @@ describe('makerChatStore.mirrorSessionFields', () => { expect(makerChatStore.getSnapshot(s).agentSwitchIntent).toBeNull(); }); }); + +describe('Cursor native send options', () => { + it('preserves Cursor permission, plan mode, effort and Fast on task recovery', () => { + const s = sid(); + makerChatStore.setSessionRuntime(s, { agentKind: 'cursor', fastMode: true, planModeEnabled: true }); + const options = buildCreateOptsForCurrentSession(s, 'account-model', 'medium', 'auto', '/tmp'); + expect(options).toMatchObject({ agentKind: 'cursor', model: 'account-model', permissionMode: 'auto', effort: 'medium', fastMode: true, planMode: true }); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/fullAccessCursorConfirmation.test.tsx b/apps/desktop/src/renderer/__tests__/fullAccessCursorConfirmation.test.tsx new file mode 100644 index 00000000000..a621b98eb18 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/fullAccessCursorConfirmation.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import copy from '../i18n/locales/en/common.json'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key.split('.').reduce((value, part) => + (value as Record)[part], copy) as string }), +})); + +import { FullAccessConfirmContent } from '../components/new-chat/FullAccessConfirmContent'; + +describe('Full access confirmation scope', () => { + it('describes Cursor approval requests and preserves its native permission boundary', () => { + render(); + expect(screen.getByText('Directly allow file read and modification requests from Cursor')).toBeTruthy(); + expect(screen.getByText('Directly allow terminal command requests from Cursor')).toBeTruthy(); + expect(screen.getByText('Directly allow network operation requests from Cursor')).toBeTruthy(); + expect(screen.getByText(/Cursor’s native permission policy still applies/)).toBeTruthy(); + expect(screen.queryByText('Read and modify files outside the workspace')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts b/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts index f6b2445c290..f37fdeda3c9 100644 --- a/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts @@ -265,6 +265,19 @@ describe('makerChatStore active view tracking', () => { disposeAgain(); }); + it('rehydrates a Cursor task with its own reducer and native session identity', async () => { + const sessionId = sid('cursor-history'); + vi.mocked(sessionService.get).mockResolvedValueOnce({ + agentKind: 'cursor', remoteHostId: null, sdkSessionId: 'cursor-native-session', + fastMode: false, contextTokens: 0, contextWindow: 0, totalCostUsd: 0, + } as Awaited>); + makerChatStore.ensureInitialMessages(sessionId); + await flushPromises(); + expect(makerChatStore.getSnapshot(sessionId)).toMatchObject({ + agentKind: 'cursor', sdkSessionId: 'cursor-native-session', + }); + }); + it('leaveView removes a session and records lastViewedAt', () => { const sessionId = sid('leave'); const dispose = enter(sessionId); diff --git a/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts b/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts index 15fec03aae4..207a3d555c7 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts @@ -1482,7 +1482,7 @@ describe('Shared create project picker', () => { // 会被静默丢掉、或撞上对端同名的无关目录 —— chip 显示的并不是真实授予的上下文。 it('hides the reference-directory picker on remote drafts', () => { expect(newMakerDraftRouteSource).toContain( - 'onExtraDirsChange={isDeviceLinkDraft ? undefined : handleExtraDirsChange}', + 'isDeviceLinkDraft ||\n (capabilityAgentKind === \'cursor\' && capabilities?.extraDirs?.supported !== true)', ); // The single add entry grants write access only through the local Main picker; // legacy read-only directories retain their independent removal callback. @@ -1509,7 +1509,7 @@ describe('Shared create project picker', () => { // the executing side explicitly supports the setter. it('hides remote add while preserving capability-gated writable grant revocation', () => { expect(newMakerDraftRouteSource).toContain( - 'isDeviceLinkDraft || isRemoteProjectDraft\n ? undefined\n : handleWritableDirsChange', + 'isDeviceLinkDraft || isRemoteProjectDraft ||\n (capabilityAgentKind === \'cursor\' && capabilities?.writableDirs?.supported !== true)\n ? undefined\n : handleWritableDirsChange', ); expect(agentCapabilitiesHookSource).toContain('writableDirs?: CapabilityStatus;'); expect(ccAgentSessionViewSource).toContain( diff --git a/apps/desktop/src/renderer/__tests__/permissionSelectorMorph.test.tsx b/apps/desktop/src/renderer/__tests__/permissionSelectorMorph.test.tsx index e3852f40cf5..a5ca3904899 100644 --- a/apps/desktop/src/renderer/__tests__/permissionSelectorMorph.test.tsx +++ b/apps/desktop/src/renderer/__tests__/permissionSelectorMorph.test.tsx @@ -29,7 +29,14 @@ const PERMISSION_MODES = [ let mockPermissionModes = PERMISSION_MODES; vi.mock('@/hooks/useAgentCapabilities', () => ({ - useAgentCapabilities: () => ({ capabilities: { permissionModes: mockPermissionModes } }), + useAgentCapabilities: (agent: string) => ({ capabilities: { permissionModes: agent === 'cursor' + ? [ + { id: 'ask', displayName: '默认权限', description: 'Cursor native policy' }, + { id: 'default', displayName: '默认权限', description: 'Cursor native policy' }, + { id: 'auto', displayName: '自动审批', description: 'Cursor auto-review' }, + { id: 'bypassPermissions', displayName: '完全访问', description: 'Cursor full access' }, + ] + : mockPermissionModes } }), })); import { PermissionSelector } from '../components/new-chat/PermissionSelector'; @@ -53,6 +60,29 @@ function getTrigger(): HTMLElement { } describe('PermissionSelector (MorphPopover pilot)', () => { + it.each(['ask', 'default'] as const)('merges Cursor permission aliases while retaining the active %s id', async permissionMode => { + const { onChange } = renderSelector({ vendorKey: 'cursor', permissionMode }); + fireEvent.click(getTrigger()); + await screen.findByRole('listbox'); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options[0].getAttribute('aria-selected')).toBe('true'); + expect(options[0].getAttribute('data-permission-mode')).toBe(permissionMode); + fireEvent.click(options[0]); + expect(onChange).toHaveBeenCalledWith(permissionMode); + }); + + it('offers and selects Cursor auto-review and full access from its capabilities', async () => { + const { onChange } = renderSelector({ vendorKey: 'cursor' }); + fireEvent.click(getTrigger()); + await screen.findByRole('listbox'); + expect(screen.getAllByRole('option')).toHaveLength(3); + expect(screen.getByRole('option', { name: '完全访问' })).toBeTruthy(); + expect(screen.getByRole('option', { name: '默认权限' })).toBeTruthy(); + fireEvent.click(screen.getByRole('option', { name: '自动审批' })); + expect(onChange).toHaveBeenCalledWith('auto'); + }); + // 2026-07-22:PermissionSelector 只在 composer 使用,已统一为「恒走脱身上浮 morph」—— // 移除 origin/main 的 useMorphPopover opt-in/Radix 回退开关,故删去原「默认用 Radix」用例。 it('点击 trigger 打开 listbox,四档选项齐全,aria-expanded 同步', async () => { diff --git a/apps/desktop/src/renderer/__tests__/providersSectionDeepLink.test.tsx b/apps/desktop/src/renderer/__tests__/providersSectionDeepLink.test.tsx index 2cb7c929b9e..3c0f5cbbd4a 100644 --- a/apps/desktop/src/renderer/__tests__/providersSectionDeepLink.test.tsx +++ b/apps/desktop/src/renderer/__tests__/providersSectionDeepLink.test.tsx @@ -43,7 +43,9 @@ const { })); vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'zh-CN' } }), + useTranslation: () => ({ t: (key: string, params?: { agent?: string }) => + key === 'settings.providers.detail.singleAgentNote' ? `${key} ${params?.agent}` : key, + i18n: { language: 'zh-CN' } }), })); vi.mock('@/hooks/useProviders', () => ({ @@ -220,6 +222,33 @@ afterEach(() => { }); describe('ProvidersSection — 深链定位', () => { + it('keeps an installed Cursor CLI visible and retryable without discovered models', async () => { + providersState.providers = [makeProvider('cursor', { + name: 'Cursor', agents: ['cursor'], auth: { method: 'none' }, + models: { cursor: [] }, connected: false, + })]; + vi.mocked(window.electronAPI.maker.scanLocalCli).mockResolvedValue({ detections: [{ + cli: 'cursor-cli', providerId: 'cursor', installed: true, loggedIn: true, sharedWithCindy: true, + }] }); + renderAt('?tab=providers'); + fireEvent.click(await screen.findByRole('button', { name: /^Cursor/ })); + expect(await screen.findByTestId('cursor-provider-setup')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'settings.providers.models.refreshBuiltinAria' })).toBeTruthy(); + expect(screen.queryByTestId('wizard-stub')).toBeNull(); + }); + + it('labels the connected Cursor header with its actual engine and connection state', async () => { + providersState.providers = [makeProvider('cursor', { + name: 'Cursor', agents: ['cursor'], auth: { method: 'none' }, + models: { cursor: [] }, connected: true, + })]; + renderAt('?tab=providers&connect=cursor'); + const subtitle = await screen.findByText(/^settings.providers.cursor.localAccount/); + expect(subtitle.textContent).toContain('singleAgentNote Cursor'); + expect(screen.getByText('settings.providers.pill.connected')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'settings.providers.button.disconnect' })).toBeNull(); + }); + it('does not rediscover a deleted local Codex provider into the settings list', async () => { providersState.providers.push(makeProvider('openai', { name: 'Deleted local account', removed: true, imageModels: [{ id: 'image', name: 'Image' }], @@ -504,7 +533,9 @@ describe('ProvidersSection — 深链定位', () => { it('a cancelled login cannot clear a newer login on the same account', async () => { const completions: ((result: { ok: boolean }) => void)[] = []; - const login = vi.fn((_id: string, _options?: { ownerId?: string }) => new Promise<{ ok: boolean }>((resolve) => completions.push(resolve))); + const login = vi.fn<(id: string, options?: { ownerId?: string }) => Promise<{ ok: boolean }>>( + () => new Promise((resolve) => completions.push(resolve)), + ); let progress!: Parameters[0]; const openExternal = vi.fn(async () => ({ success: true })); window.electronAPI.openExternal = openExternal; diff --git a/apps/desktop/src/renderer/__tests__/scheduleDefaultModel.test.ts b/apps/desktop/src/renderer/__tests__/scheduleDefaultModel.test.ts index fa194be1e0d..ba9a32a68f3 100644 --- a/apps/desktop/src/renderer/__tests__/scheduleDefaultModel.test.ts +++ b/apps/desktop/src/renderer/__tests__/scheduleDefaultModel.test.ts @@ -32,7 +32,7 @@ class MemLocalStorage { } } -function model(id: string, sortOrder = 0): CatalogModel { +function model(id: string, sortOrder = 0, mode?: string): CatalogModel { return { id, name: id, @@ -43,6 +43,7 @@ function model(id: string, sortOrder = 0): CatalogModel { defaultEffort: null, supportsFastMode: false, status: 'active', + ...(mode ? { mode } : {}), } as CatalogModel; } @@ -73,6 +74,7 @@ const PROVIDERS: ProviderView[] = [ codex: [model('gpt-6-luna', 0)], }), provider('anthropic', { 'claude-code': [model('claude-opus-5-5', 0)] }, 'subscription'), + provider('cursor', { cursor: [model('grok-4.7', 0, 'chat')] }, 'subscription'), ]; const SCHEDULE_PREFS_KEY = 'xdt:scheduleFormPrefs:v1'; @@ -142,6 +144,7 @@ describe('getScheduleDefaultModel 三级回退', () => { const { getScheduleDefaultModel } = await loadModule(); expect(getScheduleDefaultModel('claude-code', PROVIDERS)).toBe('claude-opus-5-5'); expect(getScheduleDefaultModel('codex', PROVIDERS)).toBe('gpt-6-luna'); + expect(getScheduleDefaultModel('cursor', PROVIDERS)).toBe('grok-4.7'); }); it('三级:默认模型与来源成对返回;一级没记来源时为 null', async () => { diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx index 90a27f9cbec..8994d64f9fa 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx +++ b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx @@ -30,6 +30,8 @@ vi.mock('react-i18next', async (importOriginal) => ({ 'settings.providers.openai.title': 'OpenAI', 'settings.providers.xd.title': 'Cindy AI', 'newChat.modelSelector.modelListAria': '模型列表', + 'newChat.modelSelector.cursorGroups.models': 'Cursor Models', + 'newChat.modelSelector.cursorGroups.other': 'Other Models', 'newChat.modelSelector.search.noResults': '无匹配模型', 'newChat.modelSelector.search.placeholderAll': '搜索模型…', 'newChat.modelSelector.unified.favoritesGroup': '收藏', @@ -245,6 +247,66 @@ function rowFor(name: string): HTMLElement { return within(list).getByText(name).closest('[data-unified-anchor]') as HTMLElement; } +it('renders Cursor’s native effort slider and Fast toggle and forwards live changes', async () => { + const onEffortChange = vi.fn(); + const onFastModeChange = vi.fn(); + renderPanel({ vendorKey: 'cursor', modelId: 'grok-native', currentProviderId: 'cursor', effort: 'low', + fastMode: false, onEffortChange, onFastModeChange, unifiedAgents: ['cursor'], + providersOverride: [{ id: 'cursor', name: 'Cursor', source: 'builtin', connected: true, agents: ['cursor'], + auth: { method: 'none' }, routing: {}, models: { cursor: [{ id: 'grok-native', name: 'Grok Native', + contextWindow: 0, efforts: ['low', 'medium', 'high'], defaultEffort: 'high', supportsFastMode: true }] } }], + }); + const row = rowFor('Grok Native'); + await act(async () => { fireEvent.click(within(row).getByRole('button', { name: '自定义' })); }); + const flyout = await screen.findByTestId('unified-model-config-flyout'); + const slider = within(flyout).getByRole('slider'); + await act(async () => { fireEvent.keyDown(slider, { key: 'ArrowRight' }); }); + expect(onEffortChange).toHaveBeenCalledWith('medium', FROM_PANEL); + await act(async () => { fireEvent.click(within(flyout).getByRole('button', { name: 'newChat.modelSelector.unified.fastTip' })); }); + expect(onFastModeChange).toHaveBeenCalledWith(true); +}); + +it('keeps Cursor pools visible in an existing task and selects third-party models through Cursor', async () => { + const change = vi.fn(); + const onCrossEngineSelect = vi.fn(); + renderPanel({ vendorKey: 'cursor', modelId: 'grok-4.7', currentProviderId: 'cursor', effort: 'high', + actualRoute: true, unifiedAgents: ['cursor'], onProviderChange: change, + sessionEngineFilter: { currentAgent: 'cursor', runtimeAgent: 'cursor', onCrossEngineSelect }, + providersOverride: [{ id: 'cursor', name: 'Cursor', source: 'builtin', connected: true, agents: ['cursor'], + auth: { method: 'none' }, routing: {}, models: { cursor: [ + { id: 'default', name: 'Auto', contextWindow: 0, group: 'cursor:auto', sortOrder: 0, efforts: [], defaultEffort: null }, + { id: 'grok-4.7', name: 'Grok 4.7', contextWindow: 0, group: 'cursor:models', sortOrder: 1, efforts: ['low', 'high'], defaultEffort: 'high', supportsFastMode: true }, + { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', contextWindow: 0, group: 'cursor:other', sortOrder: 2, efforts: ['low', 'medium'], defaultEffort: 'medium' }, + ] } }], + }); + const list = screen.getByRole('listbox'); + expect(within(list).getAllByRole('group').map(group => group.getAttribute('aria-label'))) + .toEqual(['Cursor', 'Cursor Models', 'Other Models']); + expect(within(list).queryByText('Cursor Default')).toBeNull(); + expect(within(within(list).getByRole('group', { name: 'Cursor Models' })).getByText('Grok 4.7')).toBeTruthy(); + await act(async () => fireEvent.click(rowFor('GPT-5.6 Sol'))); + expect(change).toHaveBeenCalledWith('cursor', 'gpt-5.6-sol', 'medium', false); + expect(onCrossEngineSelect).not.toHaveBeenCalled(); +}); + +it('keeps a saved Cursor default task connected while offering Auto only once', async () => { + render(React.createElement(ModelSelector, { + vendorKey: 'cursor', modelId: 'cursor-default', currentProviderId: 'cursor', actualRoute: true, + effort: 'medium', onModelChange: vi.fn(), onEffortChange: vi.fn(), onProviderChange: vi.fn(), + unifiedAgents: ['cursor'], providersOverride: [{ id: 'cursor', name: 'Cursor', source: 'builtin', + connected: true, agents: ['cursor'], auth: { method: 'none' }, routing: {}, models: { cursor: [ + { id: 'default', name: 'Auto', contextWindow: 0, efforts: [], defaultEffort: null, + group: 'cursor:auto', newSessionDefault: ['cursor'] }, + ] } }], + })); + const trigger = screen.getByText('Auto').closest('button')!; + expect(trigger.getAttribute('aria-label')).not.toContain('source.unavailable'); + await act(async () => fireEvent.click(trigger)); + const list = await screen.findByRole('listbox'); + expect(within(list).getAllByText('Auto')).toHaveLength(1); + expect(within(list).queryByText('Cursor Default')).toBeNull(); +}); + async function openRowFlyout(name: string): Promise { await act(async () => { fireEvent.click(within(rowFor(name)).getByRole('button', { name: '自定义' })); @@ -3500,7 +3562,7 @@ describe('统一面板 · 行内折扣徽标', () => { isFavoriteRow: false, justFavorited: false, interactionDisabled: false, - effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi', effort: string) => effort, + effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi' | 'cursor', effort: string) => effort, providers: [], onReveal: vi.fn(), onRevealForKeyboard: vi.fn(), diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts index 6aa716a162f..5a7e6800bc5 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts +++ b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts @@ -79,6 +79,20 @@ function favoriteOf(over: Partial = {}): ModelFavoriteItem { }; } +it.each(['all', 'provider', 'engine'] as const)('retains Cursor pools, source identity and native order in the %s view', (kind) => { + const entries = [ + entryOf({ providerId: 'cursor', modelId: 'default', group: 'cursor:auto', sortOrder: 0, candidates: ['cursor'], recommended: 'cursor', nativeAgent: 'cursor', capabilities: { cursor: capability('cursor') } }), + entryOf({ providerId: 'cursor', modelId: 'grok-4.7', group: 'cursor:models', sortOrder: 1, candidates: ['cursor'], recommended: 'cursor', nativeAgent: 'cursor', capabilities: { cursor: capability('cursor') } }), + entryOf({ providerId: 'cursor', modelId: 'gpt-5.6-sol', group: 'cursor:other', sortOrder: 2, candidates: ['cursor'], recommended: 'cursor', nativeAgent: 'cursor', capabilities: { cursor: capability('cursor') } }), + ]; + const sections = buildUnifiedListSections({ entries, favorites: [], query: '', + rail: kind === 'all' ? { kind } : kind === 'provider' ? { kind, providerId: 'cursor' } : { kind, agent: 'cursor' }, + recommendation: { agent: 'cursor', providerId: 'cursor', modelId: 'grok-4.7' }, + }); + expect(sections.map(section => section.group?.modelGroup)).toEqual(['cursor:auto', 'cursor:models', 'cursor:other']); + expect(sections.flatMap(section => section.rows).map(row => row.anchor)).toEqual(entries.map(entry => ({ kind: 'model', providerId: 'cursor', modelId: entry.modelId }))); +}); + describe('resolveUnifiedRowConfig', () => { it('无 override 时落推荐引擎与目录默认档', () => { const config = resolveUnifiedRowConfig({ entry: entryOf() }); diff --git a/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts b/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts index b4ae699138c..9cc8739f45e 100644 --- a/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts +++ b/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts @@ -166,6 +166,7 @@ describe('pickVoiceInputDialogCopy(语音输入缺认证文案)', () => { 'codex-voice-unauth': { title: 'codex', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, 'codex-binary-missing': { title: 'binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, 'pi-binary-missing': { title: 'pi-binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, + 'cursor-unavailable': { title: 'cursor', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, }; it('api-key + providers 使用 XD Gateway 文案', () => { diff --git a/apps/desktop/src/renderer/cindy-brain/OneshotModelPinPicker.tsx b/apps/desktop/src/renderer/cindy-brain/OneshotModelPinPicker.tsx index a9b350f1ccf..097301da14b 100644 --- a/apps/desktop/src/renderer/cindy-brain/OneshotModelPinPicker.tsx +++ b/apps/desktop/src/renderer/cindy-brain/OneshotModelPinPicker.tsx @@ -29,7 +29,7 @@ export interface OneshotPinOption { function knownAgent(value: string): value is AgentKind { - return value === 'claude-code' || value === 'codex' || value === 'pi'; + return value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'cursor'; } /** This projection only contains host-approved pins; it never expands the allowlist. */ diff --git a/apps/desktop/src/renderer/cindy-brain/PluginTaskPrefs.tsx b/apps/desktop/src/renderer/cindy-brain/PluginTaskPrefs.tsx index 8b7ef74ec05..9d5fe078233 100644 --- a/apps/desktop/src/renderer/cindy-brain/PluginTaskPrefs.tsx +++ b/apps/desktop/src/renderer/cindy-brain/PluginTaskPrefs.tsx @@ -23,7 +23,7 @@ const PERMISSION_ALLOWED = new Set(['ask', 'acceptEdits', 'auto']); const TASK_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']); interface TaskConfig { - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; model?: string; effort?: string; fastMode?: boolean; @@ -74,9 +74,9 @@ export function PluginTaskPrefs({ [config, ghostId, t], ); - const followVendor: 'cc' | 'codex' | 'pi' = - draft.vendor === 'pi' ? 'pi' : draft.vendor === 'codex' ? 'codex' : 'cc'; - const vendor: 'cc' | 'codex' | 'pi' = config.agentKind ?? followVendor; + const followVendor: 'cc' | 'codex' | 'pi' | 'cursor' = + draft.vendor === 'cursor' ? 'cursor' : draft.vendor === 'pi' ? 'pi' : draft.vendor === 'codex' ? 'codex' : 'cc'; + const vendor: 'cc' | 'codex' | 'pi' | 'cursor' = config.agentKind ?? followVendor; const pickerAgents = useModelPickerAgents(vendor === 'cc' ? 'claude-code' : vendor); const customized = [config.agentKind, config.model, config.providerId, config.effort, config.fastMode] diff --git a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx index 383b70e414c..a3585d32682 100644 --- a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx @@ -80,7 +80,7 @@ interface AgentTaskCardProps { sessionId?: string; /** Current owning harness. Pi's durable-detail sidebar must never surface * after the session has switched to Claude Code or Codex. */ - sessionAgentKind?: 'cc' | 'codex' | 'pi'; + sessionAgentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; } function readInputString(input: unknown, keys: string[]): string | undefined { diff --git a/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx b/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx index b1cf40e29bc..031e7d9b376 100644 --- a/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx @@ -262,7 +262,7 @@ export const AssistantMessage = memo(function AssistantMessage({ // fork-from-reply: 在 AI 回复上分叉 — 复制含本回复所在 turn 的全部上下文 // 到新会话(语义在 main 侧 forkSessionAtMessage 按 role 分支)。capability / // remote gate 与 UserMessage 同款;assistant 分叉无可预填文本 → 不传 draftText。 - const makerKind: MakerAgentKind = agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'claude-code'; + const makerKind: MakerAgentKind = agentKind === 'codex' || agentKind === 'pi' || agentKind === 'cursor' ? agentKind : 'claude-code'; // device-link 远程会话:fork 能力按被控端读(本机会话 deviceId undefined,行为不变)。 // deviceId 取自 ChatSessionFileContext(MessageStream 顶层订阅式构造,见该文件注释)。 const sessionFileOrigin = useSessionFileOrigin(); diff --git a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx index d06ece65e8a..5e3a381c3f7 100644 --- a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx @@ -84,7 +84,7 @@ interface ErrorBannerProps { usageLimitRecovery?: UsageLimitRecoveryHint | null; /** 当前 session 的 agent kind。codex 的 401 / Missing bearer 必须 hide Retry, * 否则 retry 撞同一个 in-memory auth retry-loop 产生重复失败 turn。 */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; agentDeviceId?: string | null; /** 当前 session 的远端 host id;非空 + agentKind='codex' 时显「同步登录态」按钮。 * 本地 codex 401 仍 hide Retry, 但只能提示用户去自己 fix login (没有 sync 入口)。 */ diff --git a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx index 26d5bf1dba9..37ef20cc4b2 100644 --- a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx @@ -139,7 +139,7 @@ export function ErrorTailErrorBanner({ errorText: string; onContinue: () => Promise | void; onDismiss: () => void; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; agentDeviceId?: string | null; remoteHostId?: string; deviceLinkDeviceId?: string | null; diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index f4e846426fa..f1660af7d7b 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -421,7 +421,7 @@ interface MessageStreamProps { sessionTitle?: string | null; /** Owning agent kind — propagated to UserMessage so capability gates * (fork/rewind icon visibility) can read the right agent's capabilities. */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; /** Owning session's remote SSH host id (null for local sessions). Forwarded * so message-level controls can gate features unsupported on remote * (e.g. rewind on cc-remote daemon sessions). */ @@ -2563,7 +2563,7 @@ function renderWorkGroupChild( workingDir: string; sessionId?: string; sessionTitle?: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; remoteHostId?: string | null; sessionSource?: string | null; isSessionStreaming: boolean; @@ -6681,7 +6681,7 @@ const MessageItem = memo(function MessageItem({ remoteHostId?: string | null; /** Forwarded to User/AssistantMessage so they can read this agent's * capabilities (gates Fork/Rewind icon visibility). */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; /** Whether this session currently has an in-flight SDK turn. Rewind uses it * to require an idle live query; fork can still target stable history. */ sessionRunning?: boolean; diff --git a/apps/desktop/src/renderer/components/chat/SystemCard.tsx b/apps/desktop/src/renderer/components/chat/SystemCard.tsx index 790814958d9..c6c973dbd9d 100644 --- a/apps/desktop/src/renderer/components/chat/SystemCard.tsx +++ b/apps/desktop/src/renderer/components/chat/SystemCard.tsx @@ -1144,7 +1144,7 @@ function AgentSwitchCard({ data, sessionId }: { data?: Record; const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const engineLabel = (kind: unknown): string => - kind === 'codex' ? 'Codex' : kind === 'pi' ? 'Pi' : 'Claude Code'; + kind === 'codex' ? 'Codex' : kind === 'cursor' ? 'Cursor' : kind === 'pi' ? 'Pi' : 'Claude Code'; const fromLabel = engineLabel(data?.fromAgentKind); const toLabel = engineLabel(data?.toAgentKind); const toModel = typeof data?.toModel === 'string' ? data.toModel : ''; diff --git a/apps/desktop/src/renderer/components/chat/UserMessage.tsx b/apps/desktop/src/renderer/components/chat/UserMessage.tsx index f708a5549d3..606c396d448 100644 --- a/apps/desktop/src/renderer/components/chat/UserMessage.tsx +++ b/apps/desktop/src/renderer/components/chat/UserMessage.tsx @@ -851,7 +851,7 @@ export function UserMessage({ // 传了 agentKind → 按 capabilities.fork/rewind.supported 决定 icon 显示 // renderer 'cc' ↔ maker 'claude-code' 别名映射 (DB / Session 用 'cc', maker IPC 用 'claude-code') const makerKind: MakerAgentKind = - agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'claude-code'; + agentKind === 'codex' || agentKind === 'pi' || agentKind === 'cursor' ? agentKind : 'claude-code'; // device-link 远程会话:fork/rewind 能力按被控端读(本机会话 deviceId undefined,行为不变)。 // 媒体来源(device/ssh)用于把附件/文件预览 URL 改写到 cindy-remote-media://(入方向媒体)。 // 取自 ChatSessionFileContext(MessageStream 顶层订阅式构造,deviceId 迟到注册时 diff --git a/apps/desktop/src/renderer/components/icons/CursorMark.tsx b/apps/desktop/src/renderer/components/icons/CursorMark.tsx new file mode 100644 index 00000000000..049b58a2e79 --- /dev/null +++ b/apps/desktop/src/renderer/components/icons/CursorMark.tsx @@ -0,0 +1,31 @@ +/** + * CursorMark —— Cursor 官方 mark(等距立方体 glyph),currentColor 单色, + * 跟随主题/状态染色(同 ClaudeMark variant="mono" 语义)。 + * + * path 取自官方品牌资产的单色版(simple-icons `cursor`,CC0):外框立方体 + + * 反向子路径挖出正面三角(nonzero 成孔),故不能加 fillRule="evenodd"。 + * 官方渐变彩色版本仓内暂无消费点,不做 brand variant。 + */ + +interface CursorMarkProps { + size?: number; + className?: string; +} + +export function CursorMark({ size = 14, className }: CursorMarkProps) { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index c287af015ad..983b1cf6e82 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -739,7 +739,7 @@ interface ChatInputProps { * M35: Vendor lock — when provided, ModelSelector only shows models * belonging to this vendor ('cc' for Claude, 'codex' for OpenAI Codex). */ - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: 'cc' | 'codex' | 'pi' | 'cursor'; /** * Optional override for the composerDraftStore key used to persist editor * content (and via attachmentState, attachments) across mount/unmount. @@ -863,7 +863,7 @@ interface ChatInputProps { * `lastByVendor.model` 并原样进 createSession,写错就是首条请求路由到一个不存在的模型。 */ onUnifiedDraftSelect?: (selection: { - vendor: 'cc' | 'codex' | 'pi'; + vendor: 'cc' | 'codex' | 'pi' | 'cursor'; providerId: string; /** 选中引擎的 **wire model id**。 */ modelId: string; @@ -921,17 +921,18 @@ function rememberCatalogModelPrefs( } /** 统一模型选择器联合列表的候选引擎全集(与 SELECTABLE_VENDORS 同一顺序)。 */ -const UNIFIED_AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +const UNIFIED_AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi', 'cursor']; /** AgentKind → NewMaker vendor(useAvailableAgents 用 vendor 口径)。 */ -function agentKindToVendor(kind: AgentKind): 'cc' | 'codex' | 'pi' { - return kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; +function agentKindToVendor(kind: AgentKind): 'cc' | 'codex' | 'pi' | 'cursor' { + return kind === 'codex' ? 'codex' : kind === 'cursor' ? 'cursor' : kind === 'pi' ? 'pi' : 'cc'; } -function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi'): AgentKind | null { +function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi' | 'cursor'): AgentKind | null { if (v === 'cc') return 'claude-code'; if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'cursor') return 'cursor'; return null; } @@ -1743,7 +1744,7 @@ export function ChatInput({ // (localStorage,按 agent 分槽、sanitize 恒有种子值)。默认模型/档位偏好已全量本地化, // 不再依赖服务端 UserPreferences(登录态失效/离线时模型与档位选择必须照常工作)。 const localVendorDefaults = - getDraft().lastByVendor[vendorKey === 'pi' ? 'pi' : vendorKey === 'codex' ? 'codex' : 'cc']; + getDraft().lastByVendor[vendorKey === 'cursor' ? 'cursor' : vendorKey === 'pi' ? 'pi' : vendorKey === 'codex' ? 'codex' : 'cc']; // session-agent-switch 意图制:意图期内 chip / 选择器显示用户选择的目标 // (model/effort/provider/fast),props(镜像 DB)仍是旧引擎值——真切换在下一条 // 消息发送时刻 apply,patched 回流后意图清除、显示交回 props。意图存放在 @@ -1846,8 +1847,9 @@ export function ChatInput({ const ccCaps = useAgentCapabilities('claude-code', catalogDeviceId); const codexCaps = useAgentCapabilities('codex', catalogDeviceId); const piCaps = useAgentCapabilities('pi', catalogDeviceId); + const cursorCaps = useAgentCapabilities('cursor', catalogDeviceId); const activeAgentCapabilities = - agentKind === 'codex' + agentKind === 'cursor' ? cursorCaps.capabilities : agentKind === 'codex' ? codexCaps.capabilities : agentKind === 'pi' ? piCaps.capabilities @@ -1978,7 +1980,7 @@ export function ChatInput({ return 'pi'; } return null; - }, [activeModel, agentKind, runtimeEffective, composerSelection.pending, composerSelection.display.agentKind, ccCaps.capabilities, codexCaps.capabilities, piCaps.capabilities]); + }, [activeModel, agentKind, runtimeEffective, composerSelection.pending, composerSelection.display.agentKind, ccCaps.capabilities, codexCaps.capabilities, piCaps.capabilities, cursorCaps.capabilities]); // 供应商连接态。effectiveSourceId / sendProviderId / dispatchSend 预检用它。device-link 远程会话 / // 草稿用**被控端**供应商目录(隧道),否则用本机(两 hook 都无条件调用,按 deviceLinkDeviceId 取)。 const sshCodexHostId = currentModelAgentKind === 'codex' && !deviceLinkDeviceId ? remoteHostId : null; @@ -2006,6 +2008,7 @@ export function ChatInput({ cc: ccCaps, codex: codexCaps, pi: piCaps, + cursor: cursorCaps, providers: remoteProviders, }); const providersLoading = catalogDeviceId @@ -3581,7 +3584,6 @@ export function ChatInput({ useEffect(() => { if (!composerLongPressVoiceInputEnabled) return; let capture: { pointerId: number; target: Element } | null = null; - let gesture: ComposerLongPressVoiceGesture; const handleLostPointerCapture = (event: Event) => { if (!(event instanceof PointerEvent)) return; @@ -3603,7 +3605,7 @@ export function ChatInput({ } }; - gesture = createComposerLongPressVoiceGesture({ + const gesture = createComposerLongPressVoiceGesture({ getState: () => voiceInputGetStateRef.current(), start: () => handleVoiceInputStartRef.current(), stop: () => voiceInputStopRef.current(), @@ -3937,7 +3939,7 @@ export function ChatInput({ saveComposerTextAfterAsyncTransition(prevEditorKey, editor.getJSON(), recoveryCheckpoint!); }; - let cancelled = false; + const cancelled = false; const isCurrentTransition = () => !cancelled && !editor.isDestroyed && @@ -6208,7 +6210,7 @@ export function ChatInput({ ? [targetAgentKind] : currentModelAgentKind ? [currentModelAgentKind] - : ['claude-code', 'codex', 'pi']; + : ['claude-code', 'codex', 'pi', 'cursor']; if (providerId) { for (const kind of kinds) { const scoped = resolveProviderModelEfforts({ @@ -6246,7 +6248,7 @@ export function ChatInput({ deviceProviders: remoteProviders.providers, localProviders: providers, capabilities: - currentModelAgentKind === 'codex' + currentModelAgentKind === 'cursor' ? cursorCaps.capabilities : currentModelAgentKind === 'codex' ? codexCaps.capabilities : currentModelAgentKind === 'pi' ? piCaps.capabilities @@ -6263,6 +6265,7 @@ export function ChatInput({ ccCaps.capabilities, codexCaps.capabilities, piCaps.capabilities, + cursorCaps.capabilities, ], ); @@ -6315,7 +6318,7 @@ export function ChatInput({ opts.remoteDeviceId ?? getSessionDeviceId(sessionId) ?? deviceLinkDeviceId; const markModelChoice = opts.markModelChoice === true; if (!remoteDeviceId) { - const vendor = agentKind === 'codex' ? 'codex' : agentKind === 'pi' ? 'pi' : 'cc'; + const vendor = agentKind === 'codex' ? 'codex' : agentKind === 'cursor' ? 'cursor' : agentKind === 'pi' ? 'pi' : 'cc'; const persistPrefs = markModelChoice ? patchVendorPrefs : patchVendorPrefsPreservingModelChoice; @@ -6597,6 +6600,7 @@ export function ChatInput({ runtimeAgentKind, deviceLinkDeviceId, piCaps.capabilities, + cursorCaps.capabilities, ], ); @@ -6664,7 +6668,7 @@ export function ChatInput({ ) => void | boolean | Promise; }>({ byProvider: () => {}, byModel: () => {} }); const confirmAgentBrowseSwitch = useCallback( - (targetAgent: 'claude-code' | 'codex' | 'pi' | null) => + (targetAgent: 'claude-code' | 'codex' | 'pi' | 'cursor' | null) => confirmAgentSwitchRisk({ // 不必再问的两种:回原引擎(same-engine no-op),或点的就是已经确认过的意图目标 // Harness(只换模型,不换引擎)。换到第三家仍要问(Chris 2026-08-20:Claude 任务里 @@ -6688,7 +6692,7 @@ export function ChatInput({ ); const performAgentSwitch = useCallback( async ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', newModelId: string, providerId: string | null = null, // 意图期内的档位/Fast 改动经此显式覆盖(用户手选优先于记忆/默认解析)。 @@ -6774,7 +6778,7 @@ export function ChatInput({ deviceProviders: remoteProviders.providers, localProviders: providers, capabilities: - targetAgentKind === 'codex' + targetAgentKind === 'cursor' ? cursorCaps.capabilities : targetAgentKind === 'codex' ? codexCaps.capabilities : targetAgentKind === 'pi' ? piCaps.capabilities @@ -7039,6 +7043,7 @@ export function ChatInput({ ccCaps.capabilities, codexCaps.capabilities, piCaps.capabilities, + cursorCaps.capabilities, syncSessionDraftModelPrefs, ], ); @@ -7302,7 +7307,7 @@ export function ChatInput({ /** 选中引擎的 **wire model id** —— 唯一可发送、可当记忆键的那个 id。 */ modelId: string; effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: 'cc' | 'codex' | 'pi' | 'cursor'; fast: boolean; favoriteUid: string | null; /** 行的归一化 id(面板行身份)。草稿层不消费,更不作为发送 id。 */ @@ -8361,9 +8366,9 @@ export function ChatInput({ if (requiresFullAccessConfirmation(previousMode, newMode)) { const confirmed = await confirmDialog({ title: t('newChat.chatInput.fullAccessConfirmation.title'), - description: t('newChat.chatInput.fullAccessConfirmation.description'), + description: t(vendorKey === 'cursor' ? 'newChat.chatInput.fullAccessConfirmation.cursor.description' : 'newChat.chatInput.fullAccessConfirmation.description'), // 逐类权限清单(文件 / 终端命令 / 网络)+ 高风险操作仍确认的脚注。 - content: , + content: , // 高风险授权:开场朗读必须覆盖清单全文,SR 用户听全权限再确认。 describeContent: true, // 带清单的确认框放宽到 440(§4:普通确认 400,富内容可适度放宽)。 @@ -8392,7 +8397,7 @@ export function ChatInput({ toast.error(t('newChat.chatInput.permissionSwitchFailed')); } }, - [sessionId, onPermissionModeDidChange, t, confirmDialog, settingsLocked], + [sessionId, onPermissionModeDidChange, t, confirmDialog, settingsLocked, vendorKey], ); useEffect(() => { handlePermissionModeChangeRef.current = handlePermissionModeChange; @@ -9345,7 +9350,7 @@ export function ChatInput({ currentVendor: vendorKey, // 两步分段的目标是 vendor 口径,确认门按 AgentKind 判(与意图 // 记录同形),在边界上转一次 —— 见 confirmAgentBrowseSwitch。 - confirmBrowseSwitch: (targetVendor: 'cc' | 'codex' | 'pi') => + confirmBrowseSwitch: (targetVendor: 'cc' | 'codex' | 'pi' | 'cursor') => confirmAgentBrowseSwitch(vendorKeyToAgentKind(targetVendor)), onSwitch: performAgentSwitch, } diff --git a/apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx b/apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx index 901d9b7d34f..824d21e0fb1 100644 --- a/apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx @@ -6,7 +6,7 @@ import { MorphPopover } from '@/components/ui/morph-popover'; import { Tip } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -export type CollabWorkerKind = 'cc' | 'codex' | 'pi'; +export type CollabWorkerKind = 'cc' | 'codex' | 'pi' | 'cursor'; export interface CollaborationMenuConfig { enabled: boolean; diff --git a/apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx b/apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx index cb493d9c944..e3d64e5ab23 100644 --- a/apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx +++ b/apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx @@ -17,8 +17,9 @@ const ITEMS: Array<{ key: 'files' | 'commands' | 'network'; icon: LucideIcon }> { key: 'network', icon: Globe }, ]; -export function FullAccessConfirmContent() { +export function FullAccessConfirmContent({ cursor = false }: { cursor?: boolean }) { const { t } = useTranslation(); + const copyKey = cursor ? 'newChat.chatInput.fullAccessConfirmation.cursor' : 'newChat.chatInput.fullAccessConfirmation'; return (
@@ -44,7 +45,7 @@ export function FullAccessConfirmContent() { {t(`newChat.chatInput.fullAccessConfirmation.items.${key}.title`)}

- {t(`newChat.chatInput.fullAccessConfirmation.items.${key}.description`)} + {t(`${copyKey}.items.${key}.description`)}

@@ -53,7 +54,7 @@ export function FullAccessConfirmContent() { {/* 兜底边界的宽心话:内置高风险操作仍会确认。作为脚注而非清单项 —— 它不是"将被放开"的权限,混进清单会削弱上面三条的警示语义。 */}

- {t('newChat.chatInput.fullAccessConfirmation.note')} + {t(`${copyKey}.note`)}

); diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 5e38c93ead2..ac324bf2ad4 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -626,7 +626,7 @@ function RemoteModelLoadNotice({ } export interface ModelSelectorAgentIdentity { - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'; /** * current = 已由会话/runtime 元数据确认的当前 Agent; * pending = 已登记、将在下一条消息应用的切换目标。 @@ -638,8 +638,8 @@ export function resolveModelSelectorAgentIdentity( runtimeAgentKind: AgentKind | null | undefined, pendingTarget: AgentKind | null | undefined, ): ModelSelectorAgentIdentity | undefined { - const toVendorKey = (kind: AgentKind): 'cc' | 'codex' | 'pi' => - kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; + const toVendorKey = (kind: AgentKind): 'cc' | 'codex' | 'pi' | 'cursor' => + kind === 'codex' ? 'codex' : kind === 'cursor' ? 'cursor' : kind === 'pi' ? 'pi' : 'cc'; if (pendingTarget) { return { vendorKey: toVendorKey(pendingTarget), @@ -771,7 +771,7 @@ interface ModelSelectorProps { /** 非选中模型行的 effort/fast 全局预设读写器(按本机 / 被控设备隔离)。 */ modelMemory?: ModelMemoryAccessors; /** When provided, only models with this vendorKey are shown in the dropdown. */ - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: 'cc' | 'codex' | 'pi' | 'cursor'; /** * 已创建会话的 trigger 同时展示 Agent 与模型,避免 Claude Code 使用 OpenAI 模型时 * 只看来源图标而误判成 Codex。必须由权威 session/runtime 身份或明确切换 intent 提供, @@ -893,7 +893,7 @@ interface ModelSelectorProps { * device-link / SSH 远程不传(v1 不支持切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: 'cc' | 'codex' | 'pi' | 'cursor'; /** * 进入非当前 Agent 浏览态前确认;false 时保持原分段,什么都不改。 * @@ -901,14 +901,14 @@ interface ModelSelectorProps { * 判据是「会话上已有**指向该目标**的切换意图」。不传目标,它只能判「有没有意图」, * 于是先切 Codex 再选 Pi 时确认框永久静默(见 agentSwitchConfirmation.hasSwitchIntent)。 */ - confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi') => Promise; + confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi' | 'cursor') => Promise; /** * 返回值(若有)= 切换事务**真的登记成功了没有**;本两步分段路径不消费它, * 声明成宽联合只是为了让同一个 `performAgentSwitch` 能同时喂给这里与统一面板的 * `onCrossEngineSelect`(后者按真实结果决定要不要做清理动作)。 */ onSwitch: ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', modelId: string, providerId: string | null, ) => void | boolean | Promise; @@ -936,7 +936,7 @@ interface ModelSelectorContentProps { thinkingEnabled?: boolean; onThinkingChange?: (enabled: boolean) => void | Promise; modelMemory?: ModelMemoryAccessors; - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: 'cc' | 'codex' | 'pi' | 'cursor'; /** device-link 远程会话所属被控端 id(列被控端模型)。 */ deviceId?: string; /** 远程 Agent 选择入口(语义同 ModelSelectorProps.remoteAgent)。 */ @@ -1031,7 +1031,7 @@ interface ModelSelectorContentProps { anchor: { uid: string; wireModelId: string; - engine: 'cc' | 'codex' | 'pi'; + engine: 'cc' | 'codex' | 'pi' | 'cursor'; /** 选中时的显式来源。来源也是锚点身份的一部分:同 wire id 同引擎、仅来源不同的 * 配置是两份配置,少了它,别的窗口把会话来源从 A 切到 B 后,面板仍在 A 的收藏上 * 打勾(2026-08-17 review)。 */ @@ -1056,7 +1056,7 @@ interface ModelSelectorContentProps { modelId: string; /** 该行生效档位;该 (模型, 引擎) 不可调档时为 undefined。 */ effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: 'cc' | 'codex' | 'pi' | 'cursor'; fast: boolean; favoriteUid: string | null; /** 配置浮层「恢复推荐」的应用动作;调用方应删除 override,不得重新记忆推荐值。 */ @@ -1082,16 +1082,16 @@ interface ModelSelectorContentProps { fluidWidth?: boolean; /** 语义同 ModelSelectorProps.agentSwitch(显式两步引擎切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: 'cc' | 'codex' | 'pi' | 'cursor'; /** 语义同 ModelSelectorProps.agentSwitch.confirmBrowseSwitch(带本次目标引擎)。 */ - confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi') => Promise; + confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi' | 'cursor') => Promise; /** * 返回值(若有)= 切换事务**真的登记成功了没有**;本两步分段路径不消费它, * 声明成宽联合只是为了让同一个 `performAgentSwitch` 能同时喂给这里与统一面板的 * `onCrossEngineSelect`(后者按真实结果决定要不要做清理动作)。 */ onSwitch: ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', modelId: string, providerId: string | null, ) => void | boolean | Promise; @@ -1112,10 +1112,11 @@ interface ModelSelectorContentProps { interactionDisabled?: boolean; } -function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi'): AgentKind | null { +function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi' | 'cursor'): AgentKind | null { if (v === 'cc') return 'claude-code'; if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'cursor') return 'cursor'; return null; } @@ -1143,6 +1144,7 @@ export function resolveRemoteModelListStatus({ cc, codex, pi, + cursor, providers, }: { deviceId?: string; @@ -1150,11 +1152,12 @@ export function resolveRemoteModelListStatus({ cc: RemoteCapabilityLoadState; codex: RemoteCapabilityLoadState; pi: RemoteCapabilityLoadState; + cursor?: RemoteCapabilityLoadState; providers: RemoteProviderLoadState; }): RemoteModelListStatus { if (!deviceId) return 'idle'; const required = agentKind - ? [agentKind === 'claude-code' ? cc : agentKind === 'codex' ? codex : pi] + ? [agentKind === 'claude-code' ? cc : agentKind === 'codex' ? codex : agentKind === 'cursor' ? (cursor ?? { loading: false, capabilities: null, error: 'unsupported' }) : pi] : [cc, codex, pi]; if (required.some((state) => !!state.error)) return 'error'; if (providers.error && !providers.unsupported) return 'error'; @@ -1295,11 +1298,11 @@ function ModelSelectorContentView({ const modelTagDensity = modelTagDensityForWidth(paneWidth ?? (fluidWidth ? null : 320)); // session-agent-switch:两步式引擎切换的浏览态。browseVendor 初始 = 会话当前引擎; // 切到另一家 tab 只是「浏览目标引擎的模型」,选中模型行才真正触发切换事务。 - const [browseVendor, setBrowseVendor] = useState<'cc' | 'codex' | 'pi'>( + const [browseVendor, setBrowseVendor] = useState<'cc' | 'codex' | 'pi' | 'cursor'>( agentSwitch?.currentVendor ?? vendorKey ?? 'cc', ); const browseSwitchPendingRef = useRef(false); - const handleBrowseVendorChange = async (next: 'cc' | 'codex' | 'pi') => { + const handleBrowseVendorChange = async (next: 'cc' | 'codex' | 'pi' | 'cursor') => { if (interactionDisabled || next === browseVendor || browseSwitchPendingRef.current) return; // 返回当前引擎(含已有意图时浏览原引擎准备撤销)不需要确认;只有从 // currentVendor 进入另一 Agent 浏览态才调用上层风险确认。确认前绝不翻分段。 @@ -1326,9 +1329,9 @@ function ModelSelectorContentView({ const unifiedAgents = (browsingSelectedCatalog ? requestedUnifiedAgents : undefined) ?? (vendorKey && agentKind && !onUnifiedSelect && !sessionEngineFilter ? [agentKind] : undefined); const browseTargetLabel = - browseVendor === 'codex' ? 'Codex' : browseVendor === 'pi' ? 'Pi' : 'Claude Code'; + browseVendor === 'codex' ? 'Codex' : browseVendor === 'cursor' ? 'Cursor' : browseVendor === 'pi' ? 'Pi' : 'Claude Code'; const enqueueAgentSwitch = ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', targetModelId: string, targetProviderId: string | null, ) => { @@ -1342,6 +1345,7 @@ function ModelSelectorContentView({ const cc = useAgentCapabilities('claude-code', deviceId); const codex = useAgentCapabilities('codex', deviceId); const pi = useAgentCapabilities('pi', deviceId); + const cursor = useAgentCapabilities('cursor', deviceId); // 本机折扣 GPT 仍按本机 API key gate;device-link 必须只看被控端 provider 状态。 // 旧被控端不支持 provider:list 时按远端 capabilities 退化,不得误用控制端 key。 const { hasSavedKey } = useApiKey(); @@ -1375,6 +1379,7 @@ function ModelSelectorContentView({ cc, codex, pi, + cursor, providers: remoteProviders, }); // 分享来的供应商:分享者电脑上的 Cindy 太旧,只答得了模型目录、答不了 Agent 能力 @@ -1878,6 +1883,7 @@ function ModelSelectorContentView({ deviceCcModels: cc.capabilities?.availableModels ?? [], deviceCodexModels: codex.capabilities?.availableModels ?? [], devicePiModels: pi.capabilities?.availableModels ?? [], + deviceCursorModels: cursor.capabilities?.availableModels ?? [], excludeSubscriptionDirect, excludeChatBridgedCodex, }), @@ -1888,6 +1894,7 @@ function ModelSelectorContentView({ cc.capabilities, codex.capabilities, pi.capabilities, + cursor.capabilities, excludeSubscriptionDirect, excludeChatBridgedCodex, ], @@ -1908,9 +1915,10 @@ function ModelSelectorContentView({ ccModels: cc.capabilities?.availableModels ?? [], codexModels: codex.capabilities?.availableModels ?? [], piModels: pi.capabilities?.availableModels ?? [], + cursorModels: cursor.capabilities?.availableModels ?? [], providers, }); - }, [agentKind, cc.capabilities, codex.capabilities, pi.capabilities, currentModel, providers]); + }, [agentKind, cc.capabilities, codex.capabilities, pi.capabilities, cursor.capabilities, currentModel, providers]); const effortMeta = useMemo(() => { const levels = @@ -1920,9 +1928,11 @@ function ModelSelectorContentView({ ? (codex.capabilities?.effortLevels ?? []) : currentAgentKind === 'pi' ? (pi.capabilities?.effortLevels ?? []) - : []; + : currentAgentKind === 'cursor' + ? (cursor.capabilities?.effortLevels ?? []) + : []; return new Map(levels.map((e) => [e.id, e.displayName])); - }, [currentAgentKind, cc.capabilities, codex.capabilities, pi.capabilities]); + }, [currentAgentKind, cc.capabilities, codex.capabilities, pi.capabilities, cursor.capabilities]); // 档名多语言:i18n 词表(effortLevels.*) → 模型级 effortDisplayNames → // capabilities displayName(未知档兜底) → 原 id。 const effortLabelFor = (m: RowModel, e: Effort) => modelEffortLabel(t, m, e, effortMeta.get(e)); @@ -1933,6 +1943,7 @@ function ModelSelectorContentView({ const hasFastModeCap = useMemo(() => { if (currentAgentKind === 'claude-code') return !!cc.capabilities?.hasFastMode; if (currentAgentKind === 'codex') return !!codex.capabilities?.hasFastMode; + if (currentAgentKind === 'cursor') return !!cursor.capabilities?.hasFastMode; if (currentAgentKind === 'pi') return !!pi.capabilities?.hasFastMode; return false; }, [currentAgentKind, cc.capabilities, codex.capabilities, pi.capabilities]); @@ -2061,6 +2072,7 @@ function ModelSelectorContentView({ ccModels: cc.capabilities?.availableModels ?? [], codexModels: codex.capabilities?.availableModels ?? [], piModels: pi.capabilities?.availableModels ?? [], + cursorModels: cursor.capabilities?.availableModels ?? [], providers, }); if (!rowAgentKind) return true; @@ -2183,7 +2195,7 @@ function ModelSelectorContentView({ ? remoteProviders.modelVisibilityOverrides === undefined ? null : new Set( - (agentKind ? [agentKind] : (['claude-code', 'codex', 'pi'] as const)).flatMap((agent) => + (agentKind ? [agentKind] : (['claude-code', 'codex', 'pi', 'cursor'] as const)).flatMap((agent) => visibleModelUnion(providers, agent, (providerId, model) => isDeviceModelVisible( remoteProviders.modelVisibilityOverrides, @@ -2195,7 +2207,7 @@ function ModelSelectorContentView({ ), ) : new Set( - (agentKind ? [agentKind] : (['claude-code', 'codex', 'pi'] as const)).flatMap((agent) => + (agentKind ? [agentKind] : (['claude-code', 'codex', 'pi', 'cursor'] as const)).flatMap((agent) => visibleModelUnion(providers, agent, (providerId, model) => isModelEnabled(agent, providerId, model), ).map((model) => model.id), @@ -2319,7 +2331,7 @@ function ModelSelectorContentView({ // trigger 来源 icon / 路由立即正确(null = flat 退化行,交给默认路由)。 if (browsing && agentSwitch) { enqueueAgentSwitch( - browseVendor === 'codex' ? 'codex' : browseVendor === 'pi' ? 'pi' : 'claude-code', + browseVendor === 'codex' ? 'codex' : browseVendor === 'cursor' ? 'cursor' : browseVendor === 'pi' ? 'pi' : 'claude-code', id, providerId, ); @@ -3209,10 +3221,12 @@ function ModelSelectorContentView({ ? (cc.capabilities?.effortLevels ?? []) : agent === 'codex' ? (codex.capabilities?.effortLevels ?? []) - : (pi.capabilities?.effortLevels ?? []); + : agent === 'cursor' + ? (cursor.capabilities?.effortLevels ?? []) + : (pi.capabilities?.effortLevels ?? []); return modelEffortLabel(t, null, value, levels.find((e) => e.id === value)?.displayName); }, - [cc.capabilities, codex.capabilities, pi.capabilities, t], + [cc.capabilities, codex.capabilities, pi.capabilities, cursor.capabilities, t], ); const unifiedAgentFastCapable = useCallback( (agent: AgentKind): boolean => @@ -3220,8 +3234,10 @@ function ModelSelectorContentView({ ? !!cc.capabilities?.hasFastMode : agent === 'codex' ? !!codex.capabilities?.hasFastMode - : !!pi.capabilities?.hasFastMode), - [cc.capabilities, codex.capabilities, pi.capabilities, onFastModeChange, onUnifiedSelect, fastModeConfigurable], + : agent === 'cursor' + ? !!cursor.capabilities?.hasFastMode + : !!pi.capabilities?.hasFastMode), + [cc.capabilities, codex.capabilities, pi.capabilities, cursor.capabilities, onFastModeChange, onUnifiedSelect, fastModeConfigurable], ); if (providersOverrideState && providersOverrideState.status !== 'ready') { @@ -4005,7 +4021,7 @@ export function ModelSelector({ if (!confirmBrowseSwitch) return agentSwitch; return { ...agentSwitch, - confirmBrowseSwitch: async (targetVendor: 'cc' | 'codex' | 'pi') => { + confirmBrowseSwitch: async (targetVendor: 'cc' | 'codex' | 'pi' | 'cursor') => { setKeepOpenForAgentConfirmation(true); try { return await confirmBrowseSwitch(targetVendor); @@ -4067,6 +4083,7 @@ export function ModelSelector({ const cc = useAgentCapabilities('claude-code', deviceId); const codex = useAgentCapabilities('codex', deviceId); const pi = useAgentCapabilities('pi', deviceId); + const cursor = useAgentCapabilities('cursor', deviceId); const gatewayPricing = useGatewayModelPricing(); const referencePricing = useReferenceModelPricing(); const { accountTier: modelAccessAccountTier } = useModelAccessStatus(); @@ -4083,13 +4100,14 @@ export function ModelSelector({ cc, codex, pi, + cursor, providers: remoteProviders, }); const remoteModelLoading = providersOverrideState?.status === 'loading' || (!!deviceId && remoteModelListStatus === 'loading'); const remoteModelLoadFailed = providersOverrideState?.status === 'error' || (!!deviceId && remoteModelListStatus === 'error'); const localModelLoading = !deviceId && !providersOverride && !localProviders.loadFailed && ( localProviders.loading || - (agentKind === 'codex' ? codex.loading : agentKind === 'pi' ? pi.loading : cc.loading) + (agentKind === 'cursor' ? cursor.loading : agentKind === 'codex' ? codex.loading : agentKind === 'pi' ? pi.loading : cc.loading) ); const visibleModels = useMemo( () => @@ -4100,6 +4118,7 @@ export function ModelSelector({ deviceCcModels: cc.capabilities?.availableModels ?? [], deviceCodexModels: codex.capabilities?.availableModels ?? [], devicePiModels: pi.capabilities?.availableModels ?? [], + deviceCursorModels: cursor.capabilities?.availableModels ?? [], excludeSubscriptionDirect, excludeChatBridgedCodex, }), @@ -4110,6 +4129,7 @@ export function ModelSelector({ cc.capabilities, codex.capabilities, pi.capabilities, + cursor.capabilities, excludeSubscriptionDirect, excludeChatBridgedCodex, ], @@ -4156,7 +4176,7 @@ export function ModelSelector({ agentIdentity && !fallbackOption?.active ? agentIdentity.vendorKey === 'cc' ? t('newChat.modelSelector.trigger.agent.claudeCode') - : agentIdentity.vendorKey === 'pi' + : agentIdentity.vendorKey === 'cursor' ? 'Cursor' : agentIdentity.vendorKey === 'pi' ? t('newChat.modelSelector.trigger.agent.pi') : t('newChat.modelSelector.trigger.agent.codex') : null; @@ -4318,7 +4338,7 @@ export function ModelSelector({ const model = findCatalogModel(provider, selection.model, selection.agentKind); const selectionKey = JSON.stringify([deviceId ?? null, selection.providerId, selection.agentKind, selection.model]); const name = selectionKey === labelKey ? localizedName : model?.name ? localizedModelName(model.name, t) : undefined; - const vendor = selection.agentKind === 'claude-code' ? 'Claude Code' : selection.agentKind === 'pi' ? 'Pi' : 'Codex'; + const vendor = selection.agentKind === 'claude-code' ? 'Claude Code' : selection.agentKind === 'cursor' ? 'Cursor' : selection.agentKind === 'pi' ? 'Pi' : 'Codex'; return [vendor, name ?? t('newChat.modelSelector.trigger.placeholder'), provider ? providerDisplayName(provider, t) : selection.providerId, selection.effort ? modelEffortLabel(t, model, selection.effort) : null, selection.fastMode ? t('newChat.modelSelector.meta.fastBadge') : null].filter(Boolean).join(' · '); diff --git a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx index 7043b73f7fc..7cb226e2d75 100644 --- a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx @@ -9,6 +9,7 @@ import { Check, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { deduplicateDefaultPermissionOptions } from '@cindy/maker-shared/permission-mode'; import { cn } from '@/lib/utils'; import { currentFocusedRow } from '@/components/ui/dropdown-menu-highlight'; @@ -35,7 +36,7 @@ interface PermissionSelectorProps { footer?: ReactNode; permissionMode: PermissionMode; onPermissionModeChange: (mode: PermissionMode) => void; - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: 'cc' | 'codex' | 'pi' | 'cursor'; /** device-link 远程会话所属被控端 id;非空 = 权限档从被控端读(本地会话 undefined,行为不变)。 */ deviceId?: string; /** 禁用 trigger。用于断线远程会话等只读 composer 状态。 */ @@ -79,8 +80,9 @@ const PERMISSION_ICONS: Record = { bypassPermissions: TriangleAlert, }; -function vendorKeyToAgentKind(v: 'cc' | 'codex' | 'pi'): AgentKind { +function vendorKeyToAgentKind(v: 'cc' | 'codex' | 'pi' | 'cursor'): AgentKind { if (v === 'codex') return 'codex'; + if (v === 'cursor') return 'cursor'; if (v === 'pi') return 'pi'; return 'claude-code'; } @@ -140,8 +142,11 @@ export function PermissionSelector({ // device-link:deviceId 非空 → 权限档从被控端读(本地会话 undefined,行为不变)。 const { capabilities } = useAgentCapabilities(agentKind, deviceId); - const options: PermissionModeDescriptor[] = (capabilities?.permissionModes ?? []).filter( - (option) => allowedModes === undefined || allowedModes.includes(option.id), + const options: PermissionModeDescriptor[] = deduplicateDefaultPermissionOptions( + (capabilities?.permissionModes ?? []).filter( + (option) => allowedModes === undefined || allowedModes.includes(option.id), + ), + permissionMode, ); const effectiveMode = options.length > 0 && !fallbackModeLabel ? normalizeMode(permissionMode, options) : permissionMode; diff --git a/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx b/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx index 82b5c3af7e1..3dc84ae9a22 100644 --- a/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { unifiedModelEntries, + providerModelDisplayGroupTitle, type CatalogModel, type ProviderView, type UnifiedModelEntry, @@ -972,7 +973,10 @@ export function UnifiedModelPanel({ : section.kind === 'recommended' ? t('newChat.modelSelector.unified.recommended') : section.group - ? providerHeading(section.group.providerId) + ? providerModelDisplayGroupTitle(providerHeading(section.group.providerId), section.group.modelGroup, { + models: t('newChat.modelSelector.cursorGroups.models'), + other: t('newChat.modelSelector.cursorGroups.other'), + }) : ''; const rows = sections.flatMap((section) => section.rows); diff --git a/apps/desktop/src/renderer/components/new-chat/agentOptions.ts b/apps/desktop/src/renderer/components/new-chat/agentOptions.ts index db999482111..c0c9140dc3e 100644 --- a/apps/desktop/src/renderer/components/new-chat/agentOptions.ts +++ b/apps/desktop/src/renderer/components/new-chat/agentOptions.ts @@ -16,6 +16,7 @@ import type { ComponentType } from 'react'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; +import { CursorMark } from '@/components/icons/CursorMark'; import { PiMark } from '@/components/icons/PiMark'; import { SELECTABLE_VENDORS, type SelectableVendor } from '@/lib/agentVendors'; @@ -30,6 +31,7 @@ const VENDOR_PRESENTATION: Record> cc: { label: 'Claude', Mark: ClaudeMark }, codex: { label: 'Codex', Mark: CodexMark }, pi: { label: 'Pi', Mark: PiMark }, + cursor: { label: 'Cursor', Mark: CursorMark }, }; export const AGENT_OPTIONS: readonly AgentOption[] = SELECTABLE_VENDORS.map((vendor) => ({ diff --git a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts index 2925e5600c7..903e9237f86 100644 --- a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts +++ b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts @@ -18,6 +18,7 @@ */ import type { ProviderView, UnifiedAgentCapability, UnifiedModelEntry } from '@cindy/model-providers'; +import { providerModelDisplayGroups } from '@cindy/model-providers'; import type { AgentKind } from '@/hooks/useAgentCapabilities'; import type { SelectableVendor } from '@/lib/agentVendors'; @@ -30,12 +31,12 @@ export type UnifiedEngine = SelectableVendor; /** vendor → AgentKind(查目录 / 能力 / 记忆时用)。 */ export function agentKindOfEngine(engine: UnifiedEngine): AgentKind { - return engine === 'cc' ? 'claude-code' : engine === 'codex' ? 'codex' : 'pi'; + return engine === 'cc' ? 'claude-code' : engine; } /** AgentKind → vendor(落 store / draft 时用)。未知值回落 cc,与既有 sanitize 方向一致。 */ export function engineOfAgentKind(agent: AgentKind): UnifiedEngine { - return agent === 'codex' ? 'codex' : agent === 'pi' ? 'pi' : 'cc'; + return agent === 'claude-code' ? 'cc' : agent; } /** @@ -392,7 +393,7 @@ export interface UnifiedListSection { * providerLabel,与模型设置页同一套名字(Chris 2026-08-16 裁决:废除「授权登录」 * 合并组 —— 分组名必须直接回答"这是哪家的",不引入第二套口径)。 */ - group?: { type: 'provider'; providerId: string }; + group?: { type: 'provider'; providerId: string; modelGroup?: string | null }; rows: UnifiedListRow[]; } @@ -591,7 +592,8 @@ export function buildUnifiedListSections(args: { const sameEngine = clusterByProvider(visible, args.providerOrder).flatMap((cluster) => cluster.items) .filter((entry) => entry !== current && entry.availability !== 'requires_payment' && (effectiveEngineOf?.(entry) ?? resolveUnifiedRowConfig({ entry }).engine) === engineOfAgentKind(recommendation.agent)); - const recommended = [...(current ? [current] : []), ...sameEngine]; + // Cursor's pool boundary must remain visible even in an existing task's All view. + const recommended = [...(current ? [current] : []), ...sameEngine].filter(entry => entry.providerId !== 'cursor'); if (recommended.length) { sections.push({ key: 'recommended', kind: 'recommended', rows: recommended.map((entry) => ({ anchor: { kind: 'model', providerId: entry.providerId, modelId: entry.modelId }, entry, @@ -606,15 +608,19 @@ export function buildUnifiedListSections(args: { ? arrangeEngineRailClusters(clustered, rail.agent, effectiveEngineOf) : clustered; for (const cluster of arranged) { - sections.push({ - key: `group:provider:${cluster.providerId}`, - kind: 'group', - group: { type: 'provider' as const, providerId: cluster.providerId }, - rows: cluster.items.map((entry) => ({ - anchor: { kind: 'model', providerId: entry.providerId, modelId: entry.modelId }, - entry, - })), - }); + for (const subgroup of providerModelDisplayGroups(cluster.providerId, cluster.items, entry => entry.group)) { + sections.push({ + key: `group:provider:${cluster.providerId}${subgroup.group ? `:${subgroup.group}` : ''}`, + kind: 'group', + group: { type: 'provider' as const, providerId: cluster.providerId, + ...(subgroup.group ? { modelGroup: subgroup.group } : {}), + }, + rows: subgroup.items.map((entry) => ({ + anchor: { kind: 'model', providerId: entry.providerId, modelId: entry.modelId }, + entry, + })), + }); + } } return sections; } diff --git a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx index a29c732dbae..e410eaae87a 100644 --- a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx +++ b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx @@ -51,6 +51,7 @@ import { LocalOllamaInstall, offersManagedOllamaInstall } from './LocalOllamaIns import { MANAGED_LLAMACPP_PROVIDER_ID } from '../../../shared/llamaCpp'; import { OAuthBrowserLink, OAuthDeviceCodeCard } from './OAuthDeviceCodeCard'; import { SettingsTextInput } from './SettingsTextInput'; +import { CursorProviderSetup } from './CursorProviderSetup'; import { PROVIDER_MEDIA_FIELDS, @@ -90,6 +91,7 @@ interface AddProviderWizardProps { type Selection = | { kind: 'oauth'; provider: ProviderView } + | { kind: 'cursor-cli'; provider: ProviderView } | { kind: 'preset'; preset: ProviderPreset } /** 内置 API-key 供应商(如 Gemini 图像来源,2026-07):保存 key 即连接,无自定义供应商落库。 */ | { kind: 'builtinApiKey'; provider: ProviderView } @@ -101,6 +103,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; function presetRuntimeBaseUrl( @@ -383,6 +386,7 @@ export function AddProviderWizard({ entry?.kind === 'builtin' ? providers.find((x) => x.id === entry.providerId) : undefined; const [sel, setSel] = useState(() => { if (!entryProvider) return null; + if (entryProvider.id === 'cursor') return { kind: 'cursor-cli', provider: entryProvider }; return entryProvider.auth?.method === 'apiKey' ? { kind: 'builtinApiKey', provider: entryProvider } : { kind: 'oauth', provider: entryProvider }; @@ -544,6 +548,10 @@ export function AddProviderWizard({ [presets], ); const q = query.trim().toLowerCase(); + const cursorChoice = providers.find((p) => + p.id === 'cursor' && p.source === 'builtin' && !hasRetainedBuiltinConnection(p) && + (!q || p.name.toLowerCase().includes(q) || 'cursor'.includes(q)), + ); const filteredOauth = q ? oauthChoices.filter((p) => p.name.toLowerCase().includes(q)) : oauthChoices; @@ -659,6 +667,20 @@ export function AddProviderWizard({ setApiKey(''); setStep(2); }, []); + const connectCursor = useCallback(async () => { + if (savingRef.current) return; + savingRef.current = true; + setSaving(true); + try { + await window.electronAPI.maker.refreshBuiltinProviderModels('cursor'); + onDone('cursor'); + } catch { + toast.error(t('settings.providers.cursor.refreshFailed')); + } finally { + savingRef.current = false; + setSaving(false); + } + }, [onDone, t]); const connectLlamaCpp = useCallback(async () => { if (savingRef.current) return; if (providers.some(p => p.id === MANAGED_LLAMACPP_PROVIDER_ID)) { @@ -1630,9 +1652,17 @@ export function AddProviderWizard({ ))} )} - {listedOauth.length > 0 && ( + {(listedOauth.length > 0 || cursorChoice) && ( <> {t('settings.providers.wizard.groupSubscription')} + {cursorChoice && ( + { setSel({ kind: 'cursor-cli', provider: cursorChoice }); setStep(2); }} + /> + )} {listedOauth.map((p) => ( connectOllama()} /> )} + {step === 2 && sel?.kind === 'cursor-cli' && } + {step === 2 && sel?.kind === 'oauth' && (
@@ -2216,6 +2248,12 @@ export function AddProviderWizard({ + {sel?.kind === 'cursor-cli' && step === 2 && ( + + )} {sel?.kind === 'builtinApiKey' && step === 2 && ( +
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx b/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx index 22597e5f358..73fc61ab0aa 100644 --- a/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx +++ b/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx @@ -490,8 +490,8 @@ function PrefsField({ } /** hook prefs 的 agentKind → 选择器的 vendor key。 */ -function toVendorKey(agentKind: string | null): 'cc' | 'codex' | 'pi' { - return agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'cc'; +function toVendorKey(agentKind: string | null): 'cc' | 'codex' | 'pi' | 'cursor' { + return agentKind === 'codex' || agentKind === 'pi' || agentKind === 'cursor' ? agentKind : 'cc'; } /** 目录卡片内的偏好编辑行(完整模型配置 / 权限)。alias 为该行当前生效别名。 */ @@ -529,7 +529,7 @@ export function WorkspacePrefsEditor({ const disabled = !state.editable || state.pendingWs === alias; const vendorKey = toVendorKey(eff.agentKind.id); - const pickerAgents = useModelPickerAgents(vendorKey === 'cc' ? 'claude-code' : vendorKey === 'pi' ? 'pi' : 'codex'); + const pickerAgents = useModelPickerAgents(vendorKey === 'cc' ? 'claude-code' : vendorKey === 'cursor' ? 'cursor' : vendorKey === 'pi' ? 'pi' : 'codex'); /** 落一个模型选择(分段行与 flat 行共用): 随手写入 (agent, model) 配对并校准 effort。 */ const applyModel = (next: string) => { diff --git a/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx b/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx index 47d19dc5ef1..80672ba218b 100644 --- a/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx @@ -41,13 +41,13 @@ import { mergeSettingsPatch, } from './imDefaultSettingsLogic'; -function vendorKeyFor(agentKind: ImDefaultAgentKind): 'cc' | 'codex' | 'pi' { +function vendorKeyFor(agentKind: ImDefaultAgentKind): 'cc' | 'codex' | 'pi' | 'cursor' { return agentKind === 'claude-code' ? 'cc' : agentKind; } /** AgentSelect 的 vendor → IM 默认配置的 agentKind。 */ function agentKindOfVendor(vendor: string): ImDefaultAgentKind { - return vendor === 'cc' ? 'claude-code' : vendor === 'pi' ? 'pi' : 'codex'; + return vendor === 'cc' ? 'claude-code' : vendor === 'cursor' ? 'cursor' : vendor === 'pi' ? 'pi' : 'codex'; } export interface ImDefaultSettingsSummary { @@ -80,6 +80,7 @@ export function ImDefaultSettingsSection({ const cc = useAgentCapabilities('claude-code'); const codex = useAgentCapabilities('codex'); const pi = useAgentCapabilities('pi'); + const cursor = useAgentCapabilities('cursor'); const [settings, setSettings] = useState(null); const [pending, setPending] = useState(false); @@ -120,6 +121,7 @@ export function ImDefaultSettingsSection({ admissionFiltered: true, }), codex: deriveModelsFromProviders(providers, 'codex', { admissionFiltered: true }), + cursor: deriveModelsFromProviders(providers, 'cursor', { admissionFiltered: true }), pi: deriveModelsFromProviders(providers, 'pi', { admissionFiltered: true }), }; return { @@ -129,11 +131,12 @@ export function ImDefaultSettingsSection({ codex: fromProviders.codex.length ? fromProviders.codex : (codex.capabilities?.availableModels ?? []), + cursor: fromProviders.cursor, pi: fromProviders.pi.length ? fromProviders.pi : (pi.capabilities?.availableModels ?? []), }; - }, [providers, cc.capabilities, codex.capabilities, pi.capabilities]); + }, [providers, cc.capabilities, codex.capabilities, pi.capabilities, cursor.capabilities]); const resolveProviderId = useCallback( (agentKind: ImDefaultAgentKind, modelId: string, providerId: string | null): string | null => { @@ -231,7 +234,7 @@ export function ImDefaultSettingsSection({ ? cc : settings.agentKind === 'codex' ? codex - : pi; + : settings.agentKind === 'cursor' ? cursor : pi; const selectedAgentCapabilitiesReady = !selectedAgentCaps.loading && selectedAgentCaps.error === null && diff --git a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx index 133efd2f307..e6816d2cff8 100644 --- a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx @@ -35,6 +35,7 @@ import { Tip } from '@/components/ui/tooltip'; import { Switch } from '@/components/ui/switch'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; +import { CursorMark } from '@/components/icons/CursorMark'; import { PiMark } from '@/components/icons/PiMark'; import { useModelContextLimit } from '@/hooks/useModelContextLimit'; import { modelPriceDetailRows, type ModelPricePresentation } from '@/lib/modelPriceFormat'; @@ -82,6 +83,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; // Editing uses whole decimal K, rounded down to avoid suggesting a value above the upstream @@ -94,6 +96,7 @@ const AGENT_MARK: Record ReactNode> = { 'claude-code': (size) => , codex: (size) => , pi: (size) => , + cursor: (size) => , }; /** 抽屉里的档位顺序 = 目录枚举顺序(弱到强)。ultra 只在模型真的提供时出现。 */ diff --git a/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx b/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx index d718a204d05..080652a8b07 100644 --- a/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx @@ -20,6 +20,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; interface Props { diff --git a/apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx b/apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx index 0b7c663f1f2..9f02238a2a4 100644 --- a/apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx @@ -859,7 +859,7 @@ export function ProviderConnectionDialog({ // 的 runtime 上,handleSave 的守卫拦不住"用户已经看不到"的这条草稿,表单 // 卡死报错却找不到对应输入框(review P1)。 const first = configuredPresetAgents(p)[0]; - if (first) setActiveTab(first); + if (first && first !== 'cursor') setActiveTab(first); // 预设整体替换名称/鉴权/全部 runtime:任何既有字段错误的指向(字段值、 // 行结构、tab)都已失效。程序化赋值不触发输入的 change,须在此显式清除 // (review P1)。 diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index c29bbdef3a9..547937408e4 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -1,3 +1,4 @@ +import { refreshLocalCapabilities } from '@/hooks/useAgentCapabilities'; import { useCodexRateLimits } from '@/hooks/useCodexRateLimits'; import { useCodexRateLimitReset } from '@/hooks/useCodexRateLimitReset'; import { CodexResetCredits } from '../status/CodexResetCredits'; @@ -91,6 +92,7 @@ import { useProviderSubscriptionCard } from './useProviderSubscriptionCard'; import { QuotaHoverCard } from '../status/QuotaHoverCard'; import { ProviderConnectionDialog } from './ProviderConnectionDialog'; import { AddProviderWizard, type WizardEntry } from './AddProviderWizard'; +import { CursorProviderSetup } from './CursorProviderSetup'; import { OllamaProviderDetail } from './OllamaProviderDetail'; import { OwnRemoteProviderDetail, @@ -705,6 +707,8 @@ function DetailHeader({ agent: provider.agents[0] === 'claude-code' ? 'Claude Code' + : provider.agents[0] === 'cursor' + ? 'Cursor' : provider.agents[0] === 'pi' ? 'Pi' : 'Codex', @@ -1072,7 +1076,6 @@ function OpenAiHeader({ refresh, triggerLogin, cancelLogin, - logout, } = useCodexAuth(); const reconnectRequired = state.kind === 'reconnect-required'; const loggingIn = state.kind === 'login-pending'; @@ -2575,6 +2578,8 @@ export function ProvidersSection() { const openaiHasImageCap = p.id === 'openai' && (p.imageModels?.length ?? 0) > 0; if ( p.id === 'xd' || + // Native installation remains manageable even when model discovery has not succeeded. + (p.id === 'cursor' && detections.some((d) => d.providerId === 'cursor' && d.installed)) || p.connected || p.removed === false || (p.id === 'openai' && openaiReconnectRequired) || @@ -2600,7 +2605,7 @@ export function ProvidersSection() { } } return rows; - }, [providers, openaiReconnectRequired]); + }, [providers, openaiReconnectRequired, detections]); const orderedVisibleProviders = useMemo( () => applyProviderOrder(visibleProviders, providerOrder), @@ -2768,7 +2773,7 @@ export function ProvidersSection() { const model = searchParams.get('model')?.trim() || null; const agentParam = searchParams.get('agent'); const agent = - agentParam === 'claude-code' || agentParam === 'codex' || agentParam === 'pi' + agentParam === 'claude-code' || agentParam === 'codex' || agentParam === 'pi' || agentParam === 'cursor' ? agentParam : undefined; const importId = searchParams.get('import'); @@ -2939,6 +2944,7 @@ export function ProvidersSection() { if (!isBuiltinRefreshableProviderId(p.id) || !beginProviderRefresh(p.id)) return; try { await window.electronAPI.maker.refreshBuiltinProviderModels(p.id); + await refreshLocalCapabilities(); toast.success(t('settings.providers.models.refreshDone')); refetch(); } catch (err) { @@ -2949,7 +2955,9 @@ export function ProvidersSection() { if (ipcError?.code === 'MODEL_CATALOG_FETCH_DISABLED') { toast.info(t('settings.providers.models.refreshFetchDisabled')); } else { - toast.error(t('settings.providers.models.refreshFailed')); + toast.error(t(p.id === 'cursor' + ? 'settings.providers.cursor.refreshFailed' + : 'settings.providers.models.refreshFailed')); } } finally { finishProviderRefresh(p.id); @@ -2997,6 +3005,14 @@ export function ProvidersSection() { if (p.id === 'openai') return ; if (p.id === 'xai') return ; + if (p.id === 'cursor') return ( + : undefined} /> + ); if (p.source === 'builtin' && p.auth.method === 'apiKey' && isBuiltinApiKeyProviderId(p.id)) { return ( diff --git a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx index 1bf7604c01f..ccbeeab12c5 100644 --- a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx +++ b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx @@ -93,6 +93,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; /** diff --git a/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx index 776583faf81..2648c12e64f 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx @@ -91,6 +91,7 @@ function defaults(agentKind: ImDefaultSettingsState['agentKind']): ImDefaultSett 'claude-code': { providerId: null, model: 'claude-opus-4-8', effort: 'xhigh' }, codex: { providerId: null, model: 'codex/gpt-5.5', effort: 'high' }, pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high' }, + cursor: { providerId: 'cursor', model: 'cursor-default', effort: 'medium' }, }, isCustomized: false, customizedKeys: [], @@ -100,7 +101,7 @@ function defaults(agentKind: ImDefaultSettingsState['agentKind']): ImDefaultSett describe('ImDefaultSettingsSection Pi channel warning', () => { it('saves the complete selection in one update while preserving permissions', async () => { - const save = vi.fn(async (_patch: unknown) => defaults('codex')); + const save = vi.fn<(patch: unknown) => Promise>(async () => defaults('codex')); window.electronAPI.maker.imDefaultSettingsSet = save; render(); await screen.findByText('settings.imBot.defaults.modelLabel'); diff --git a/apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts b/apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts index 18af16f8340..f9a9317ad56 100644 --- a/apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts +++ b/apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts @@ -29,6 +29,7 @@ export function usageHistoryAgentColor(agentKind: UsageAgentKind): string { const colors: Record = { 'claude-code': 'var(--engine-badge-cc)', codex: 'var(--engine-badge-codex)', + cursor: 'var(--text-primary)', pi: MODEL_COLORS[0], }; return colors[agentKind]; diff --git a/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx b/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx index ce930516220..666ed3c576e 100644 --- a/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx +++ b/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx @@ -18,10 +18,11 @@ import { cn } from '@/lib/utils'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; +import { CursorMark } from '@/components/icons/CursorMark'; import { CodexMark } from '@/components/icons/CodexMark'; import { RemoteSignalOverlay } from '@/components/icons/RemoteSourceMark'; -export type VendorIconKind = 'cc' | 'codex' | 'pi'; +export type VendorIconKind = 'cc' | 'codex' | 'pi' | 'cursor'; /** * 各 glyph 笔画右上角在自身方框里的位置(0–1,已留出与波纹点的间隙)。 @@ -32,6 +33,7 @@ const REMOTE_SIGNAL_ANCHOR: Record = { cc: { x: 0.95, y: 0.13 }, codex: { x: 0.9, y: 0.1 }, pi: { x: 0.83, y: 0.36 }, + cursor: { x: 0.9, y: 0.15 }, }; /** @@ -40,7 +42,7 @@ const REMOTE_SIGNAL_ANCHOR: Record = { * 吞成 Claude 脸,2026-07-30 实测 bug)。兼容 'claude-code' 别名与 null。 */ export function agentKindToVendor(kind: string | null | undefined): VendorIconKind { - return kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; + return kind === 'codex' ? 'codex' : kind === 'cursor' ? 'cursor' : kind === 'pi' ? 'pi' : 'cc'; } interface VendorIconProps { @@ -80,7 +82,9 @@ export function VendorIcon({ return ( - {vendor === 'codex' ? ( + {vendor === 'cursor' ? ( + + ) : vendor === 'codex' ? ( ) : vendor === 'pi' ? ( { - if (!visibleVendors.includes(selection.engine)) return; + if (selection.engine === 'cursor' || !visibleVendors.includes(selection.engine)) return; replace(index, { harness: harnessFor(selection.engine), providerId: selection.providerId, diff --git a/apps/desktop/src/renderer/features/bots/BotWorkbench.tsx b/apps/desktop/src/renderer/features/bots/BotWorkbench.tsx index 0171f7b6094..479a0c05e99 100644 --- a/apps/desktop/src/renderer/features/bots/BotWorkbench.tsx +++ b/apps/desktop/src/renderer/features/bots/BotWorkbench.tsx @@ -1291,7 +1291,7 @@ function originLabel(origin: string, t: Translate): string { ? 'delegated' : origin === 'claude-code' ? 'claudeCode' - : origin === 'codex' || origin === 'pi' + : origin === 'codex' || origin === 'pi' || origin === 'cursor' ? origin : 'existing'; return t(`bots.workbench.kind.${key}`); diff --git a/apps/desktop/src/renderer/features/bots/BotsHomeView.tsx b/apps/desktop/src/renderer/features/bots/BotsHomeView.tsx index 01296342c60..a8bb28018d2 100644 --- a/apps/desktop/src/renderer/features/bots/BotsHomeView.tsx +++ b/apps/desktop/src/renderer/features/bots/BotsHomeView.tsx @@ -127,7 +127,7 @@ export function BotSettings({ const { availableVendors, loaded: availableAgentsLoaded } = useAvailableAgents(); const hiddenVendors = useMemo(() => { if (!availableAgentsLoaded) return []; - return (['cc', 'codex', 'pi'] as const).filter((item) => !availableVendors.has(item)); + return (['cc', 'codex', 'pi', 'cursor'] as const).filter((item) => !availableVendors.has(item)); }, [availableAgentsLoaded, availableVendors]); useSyncExternalStore(subscribeBotGlobalModel, () => JSON.stringify(getEffectiveBotModelChain())); const displayedModelChain = @@ -743,7 +743,7 @@ export function BotsHomeView() { const { availableVendors, loaded: availableAgentsLoaded } = useAvailableAgents(); const hiddenVendors = useMemo(() => { if (!availableAgentsLoaded) return []; - return (['cc', 'codex', 'pi'] as const).filter((item) => !availableVendors.has(item)); + return (['cc', 'codex', 'pi', 'cursor'] as const).filter((item) => !availableVendors.has(item)); }, [availableAgentsLoaded, availableVendors]); const hasDefaultModel = useSyncExternalStore( subscribeBotGlobalModel, diff --git a/apps/desktop/src/renderer/features/bots/botStore.ts b/apps/desktop/src/renderer/features/bots/botStore.ts index 878e438a15d..f5e90bb5f30 100644 --- a/apps/desktop/src/renderer/features/bots/botStore.ts +++ b/apps/desktop/src/renderer/features/bots/botStore.ts @@ -147,7 +147,7 @@ export interface BotSessionProjection { profileVersion?: number; runtimeSnapshot?: { profileVersion: number; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; status: 'prepared' | 'applied' | 'degraded' | 'failed'; preparedAt: number; appliedAt?: number; @@ -352,9 +352,7 @@ function readLegacyBotGlobalModelChain(): BotModelRoute[] | null { } } -export function getEffectiveBotModelChain( - _fallbackHarness: BotHarness = NEW_BOT_DEFAULT_HARNESS, -): BotModelRoute[] { +export function getEffectiveBotModelChain(): BotModelRoute[] { const stored = getBotGlobalModelChain(); if (stored) return stored; const providers = getCachedProvidersSnapshot(); @@ -445,7 +443,7 @@ function defaultCapabilities( ): BotCapabilities { const vendor = vendorForHarness(harness); const prefs = getDraft().lastByVendor[vendor]; - const globalChain = getEffectiveBotModelChain(harness); + const globalChain = getEffectiveBotModelChain(); const primary = globalChain[0] ?? { harness, model: '', providerId: null, effort: '', fastMode: false }; const model = primary.model; return { diff --git a/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts b/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts index e604f16ca64..8db364cd127 100644 --- a/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts +++ b/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts @@ -132,7 +132,7 @@ export type WorkbenchTile = id: string; title: string; state: WorkbenchTaskState; - origin: 'claude-code' | 'codex' | 'pi'; + origin: 'claude-code' | 'codex' | 'pi' | 'cursor'; verdict: WorkbenchShownVerdict; next: string | null; startedAtMs: null; diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 54b9c3633b2..2a2354fe4db 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -2220,12 +2220,13 @@ export function CCAgentSessionView({ // device-link 与 SSH 都只在实际执行端明确声明 setter 能力后开放;ChatInput 另行按 // 文件系统来源关闭远端“新增”,因此这里开放的远端 callback 只会用于撤销。 const writableDirsChangeSupported = - canExposeWritableDirsChange({ + (displayAgentKind !== 'cursor' || sessionCaps?.writableDirs?.supported === true) && + (canExposeWritableDirsChange({ capabilities: sessionCaps, deviceId: remoteDeviceId, remoteHostId: session?.remoteHostId, }) || - (session?.remoteHostId != null && sessionCaps?.writableDirs?.supported === true); + (session?.remoteHostId != null && sessionCaps?.writableDirs?.supported === true)); // 这里曾有 useErrorReadAck:ErrorBanner 在视图内聚焦驻留 1.5s 即 explicit 清红点。 // 2026-07 统一后展示不再产生已读 —— 横幅还在就说明告警未处理,红点必须留着。 // 红角标现在只由用户处置横幅(handleRetry / handleSilentStopContinue / @@ -2783,7 +2784,7 @@ export function CCAgentSessionView({ // F-COLLAB: 协同模式真实状态。enabled 来自 session.orcaRole === 'lead'; // worker(显示用)从 active workflow 的 Worker session 列表查到 agentKind。 // 切换协同走 IPC enableOrca / disableOrca,失败时 toast。 - const [collabWorker, setCollabWorker] = useState<'cc' | 'codex' | 'pi'>('codex'); + const [collabWorker, setCollabWorker] = useState<'cc' | 'codex' | 'pi' | 'cursor'>('codex'); // enableBusy 只盖"开启协同"路径;关闭走 useStopOrcaCollab hook 自己管 busy。 const [enableBusy, setEnableBusy] = useState(false); const [createWorkerOpen, setCreateWorkerOpen] = useState(false); @@ -2937,6 +2938,7 @@ export function CCAgentSessionView({ // ChatInput「+」菜单启用协同变成 Lead,否则 doc 模式下首次开启入口完全没有。 const collabWorkspaceKind = session?.workspaceKind; const collabEntry = resolveCollabEntryPolicy({ + agentKind: displayAgentKind, workspaceKind: collabWorkspaceKind, workingDir: session?.workingDir, orcaRole: session?.orcaRole, @@ -3888,7 +3890,7 @@ export function CCAgentSessionView({ // 重连后由被控端 enqueue / steer 路径做权威校验。这样离开任务后旧 outbox 也不会 // 再弹出旧页面的认证对话框或导航回旧路由。 if (!remoteDeviceId) { - const authVendor = displayAgentKind === 'pi' ? 'pi' : isCodex ? 'codex' : 'cc'; + const authVendor = displayAgentKind === 'cursor' ? 'cursor' : displayAgentKind === 'pi' ? 'pi' : isCodex ? 'codex' : 'cc'; const { proceed } = await vendorAuthGate.checkAndConfirm(authVendor, { // 已建会话:suspended 来源计入(停用不打断运行中会话,门禁只看凭证连接态, // PR #744 review 第十七轮)。 @@ -4386,7 +4388,7 @@ export function CCAgentSessionView({ // 用三值化后的 agent 映射选默认模型:Pi 会话必须回退到 Pi 目录默认,而不是被 // `isCodex ? 'codex' : 'cc'` 误写成 CC 首选(可能是更贵的 Opus)(codex review)。 const defaultModel = getDefaultModelForVendor( - agent === 'pi' ? 'pi' : agent === 'codex' ? 'codex' : 'cc', + agent === 'cursor' ? 'cursor' : agent === 'pi' ? 'pi' : agent === 'codex' ? 'codex' : 'cc', ); sessionService .update(sessionId, { model: defaultModel.id }) @@ -5657,7 +5659,11 @@ export function CCAgentSessionView({ onComposerDropHandled={resetFullAreaDragState} vendorKey={normalizeDbAgentKind(displayAgentKind)} extraDirs={session?.extraDirs ?? []} - onExtraDirsChange={handleExtraDirsChange} + onExtraDirsChange={ + displayAgentKind !== 'cursor' || sessionCaps?.extraDirs?.supported === true + ? handleExtraDirsChange + : undefined + } writableDirs={session?.writableDirs ?? []} writableGrantScope={sessionId} onWritableDirsChange={ @@ -6439,7 +6445,7 @@ function formatTokenCount(n: number): string { */ function getModelContextWindow( model: string, - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor', deviceId?: string, ): number | undefined { const found = getModelsForVendor(vendorKey, deviceId).find((m) => m.id === model); @@ -6463,7 +6469,7 @@ function ContextCapacityRing({ providerId?: string | null; contextTokens: number; model: string; - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'; /** SDK-reported context window; 0 = not yet known → use hardcoded fallback. */ sdkContextWindow: number; verifiedContextWindow?: number | null; diff --git a/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx b/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx index 50af31345fa..7e59199a7f3 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx @@ -73,7 +73,7 @@ const LOCAL_WORKER_MODEL_MEMORY = { export interface CreateWorkerForm { role: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; effort?: Effort; fast?: boolean; @@ -177,7 +177,7 @@ export function CreateWorkerPopover({ const navigate = useNavigate(); const [role, setRole] = useState('developer'); const [customRole, setCustomRole] = useState(''); - const [agent, setAgent] = useState<'claude-code' | 'codex' | 'pi'>('codex'); + const [agent, setAgent] = useState<'claude-code' | 'codex' | 'pi' | 'cursor'>('codex'); const [model, setModel] = useState(DEFAULT_WORKER_CREATION_PREFS.codex.model); const [effort, setEffort] = useState(DEFAULT_WORKER_CREATION_PREFS.codex.effort); const [fast, setFast] = useState(DEFAULT_WORKER_CREATION_PREFS.codex.fast); @@ -204,6 +204,8 @@ export function CreateWorkerPopover({ // 模型、供应商与能力按 Worker 的 Agent 实际运行的电脑读取:选了远程供应商读那台(或分享), // 远程控制的 Lead 读它所在的电脑,选了运行设备则读运行设备。权限档始终跟 Lead 所在电脑走。 const deviceId = agentDeviceId ?? leadDeviceId ?? executionDeviceId ?? undefined; + // Cursor 只在本机工作区。SSH、运行设备、远程供应商,或 Lead 的 Agent 在别的电脑时不提供。 + const cursorLocalWorkspace = !sshRemote && executionDeviceId === null && agentDeviceId === null && !leadAgentDeviceId; const executionDevice = executionDevices.find((d) => d.deviceId === executionDeviceId) ?? null; const trimmedRemoteDir = remoteDir.trim(); const remoteDirInvalid = @@ -212,6 +214,7 @@ export function CreateWorkerPopover({ const ccCaps = useAgentCapabilities('claude-code', deviceId); const codexCaps = useAgentCapabilities('codex', deviceId); const piCaps = useAgentCapabilities('pi', deviceId); + const cursorCaps = useAgentCapabilities('cursor', deviceId); const pickerAgents = useModelPickerAgents(agent, deviceId); const localProviders = useProviders(); const remoteProviders = useDeviceProviders(deviceId); @@ -229,7 +232,7 @@ export function CreateWorkerPopover({ const providersError = deviceId ? remoteProviders.error : null; const visibilityVersion = useModelVisibilityVersion(); useAgentDeviceModelMemoryVersion(); - const activeCapabilitiesState = agent === 'codex' ? codexCaps : agent === 'pi' ? piCaps : ccCaps; + const activeCapabilitiesState = agent === 'cursor' ? cursorCaps : agent === 'codex' ? codexCaps : agent === 'pi' ? piCaps : ccCaps; const activeCaps = activeCapabilitiesState.capabilities; // 协同的执行端是任务所在电脑:Worker 权限与「能否给 Worker 选 Agent 位置」按它的能力判, // 不跟着模型目录换到 Agent 所在电脑。这两位是整台电脑的协议位,用那台一定注册的 Claude Code @@ -344,7 +347,7 @@ export function CreateWorkerPopover({ // fast=true 清掉,回退默认来源支持 Fast 也不会恢复(codex review)。收窄后按 // 「实际会生效的来源」口径判定,不经历 false 窗口。 const currentModelSupportsFast = Boolean( - (agent === 'codex' || agent === 'pi') && + (agent === 'codex' || agent === 'pi' || agent === 'cursor') && activeCaps?.hasFastMode && providerFastSupported(narrowProviderSource(providerSource, model), model), ); @@ -416,6 +419,16 @@ export function CreateWorkerPopover({ useEffect(() => { if (open && !agentLocationChosenRef.current) setAgentDeviceId(leadAgentDeviceId ?? null); }, [leadAgentDeviceId, open]); + // 位置落到别的电脑、SSH 或运行设备后,已记住的 Cursor 不再是可选项,回到 Codex。 + useEffect(() => { + if (cursorLocalWorkspace || agent !== 'cursor') return; + const remembered = prefs.codex; + setAgent('codex'); + setModel(remembered.model); + setEffort(remembered.effort); + setFast(remembered.fast); + setProviderSource(leadDeviceId || agentDeviceId ? null : remembered.providerId); + }, [agent, agentDeviceId, cursorLocalWorkspace, leadDeviceId, prefs]); // 可选运行设备:每次打开读一次;读不到就只有这台电脑,不提示错误。 useEffect(() => { @@ -449,13 +462,19 @@ export function CreateWorkerPopover({ ); useEffect(() => { + if (agent === 'cursor' && activeCaps?.permissionModes.length) { + const supported = activeCaps.permissionModes.some(({ id }) => id === selectedWorkerPermissionMode + || (selectedWorkerPermissionMode === 'ask' && id === 'default')); + if (!supported) setSelectedWorkerPermissionMode('ask'); + return; + } if ( !supportsWorkerPermissionModeSelection && selectedWorkerPermissionMode !== 'auto' ) { setSelectedWorkerPermissionMode('auto'); } - }, [selectedWorkerPermissionMode, supportsWorkerPermissionModeSelection]); + }, [activeCaps, agent, selectedWorkerPermissionMode, supportsWorkerPermissionModeSelection]); // capabilities 可能尚未加载或模型被移除;加载后把当前选择收敛到可用模型和 effort。 useEffect(() => { @@ -503,7 +522,7 @@ export function CreateWorkerPopover({ const vendorKey = agentKindToVendor(agent); const updateAgent = useCallback( - (nextAgent: 'claude-code' | 'codex' | 'pi') => { + (nextAgent: 'claude-code' | 'codex' | 'pi' | 'cursor') => { if (nextAgent === agent) return; // 切走前把当前 agent 的 live 编辑(模型/effort/Fast/来源)快照进内存 prefs: // 恢复读的是 prefs,不快照会把「改了还没提交就切了个 tab」的编辑静默回滚到 @@ -735,8 +754,8 @@ export function CreateWorkerPopover({ if (requiresFullAccessConfirmation(selectedWorkerPermissionMode, nextMode)) { const confirmed = await confirmDialog({ title: t('newChat.chatInput.fullAccessConfirmation.title'), - description: t('newChat.chatInput.fullAccessConfirmation.description'), - content: , + description: t(agent === 'cursor' ? 'newChat.chatInput.fullAccessConfirmation.cursor.description' : 'newChat.chatInput.fullAccessConfirmation.description'), + content: , describeContent: true, maxWidth: 440, confirmText: t('newChat.chatInput.fullAccessConfirmation.confirm'), @@ -747,7 +766,7 @@ export function CreateWorkerPopover({ } setSelectedWorkerPermissionMode(nextMode); }, - [confirmDialog, selectedWorkerPermissionMode, t], + [agent, confirmDialog, selectedWorkerPermissionMode, t], ); const handleCreate = useCallback(async () => { @@ -967,7 +986,7 @@ export function CreateWorkerPopover({ value={vendorKey} width={220} ariaLabel={t('orca.createWorker.agentLabel')} - onChange={(next) => updateAgent(next === 'codex' ? 'codex' : next === 'pi' ? 'pi' : 'claude-code')} + onChange={(next) => updateAgent(next === 'codex' ? 'codex' : next === 'cursor' ? 'cursor' : next === 'pi' ? 'pi' : 'claude-code')} /> )} @@ -986,8 +1005,10 @@ export function CreateWorkerPopover({ setFast((v) => !v)} /> )} kind !== 'pi') : pickerAgents} + fastModeConfigurable={['codex', 'pi', 'cursor']} + unifiedAgents={(sshRemote + ? (pickerAgents ?? ['claude-code', 'codex']).filter((kind) => kind !== 'pi') + : pickerAgents)?.filter((kind) => cursorLocalWorkspace || kind !== 'cursor')} onUnifiedSelect={deviceId && remoteProviders.unsupported ? undefined : (selection) => { const nextAgent = selection.engine === 'cc' ? 'claude-code' : selection.engine; updateAgent(nextAgent); @@ -1041,16 +1062,16 @@ export function CreateWorkerPopover({ // worker 创建链的显式 Fast 派发支持 Codex 与 Pi(resolveWorkerConfig 对二者 // 消费 input.fast,并按模型 supportsFastMode 收口):cc 层面为 no-op,不接线, // 面板就不显示 Fast 开关,避免「开关能开、提交被丢」的名不副实(codex review)。 - fastMode={!(agent === 'codex' || agent === 'pi') ? undefined : fast} + fastMode={!(agent === 'codex' || agent === 'pi' || agent === 'cursor') ? undefined : fast} onFastModeChange={ - !(agent === 'codex' || agent === 'pi') ? undefined : updateFast + !(agent === 'codex' || agent === 'pi' || agent === 'cursor') ? undefined : updateFast } />
{noAvailableLocalModels ? (

{t('orca.createWorker.noAvailableModels', { - agent: agent === 'codex' ? 'Codex' : agent === 'pi' ? 'Pi' : 'Claude Code', + agent: agent === 'codex' ? 'Codex' : agent === 'cursor' ? 'Cursor' : agent === 'pi' ? 'Pi' : 'Claude Code', })}

) : null} @@ -1087,8 +1108,8 @@ export function CreateWorkerPopover({ dense ariaContext={t('orca.createWorker.permissionLabel')} allowedModes={ - supportsWorkerPermissionModeSelection - ? ORCA_WORKER_PERMISSION_MODES + agent === 'cursor' && !supportsWorkerPermissionModeSelection ? ['ask', 'default'] : supportsWorkerPermissionModeSelection + ? ORCA_WORKER_PERMISSION_MODES.filter((mode) => agent === 'cursor' || mode !== 'ask') : AUTO_ONLY_WORKER_PERMISSION_MODES } /> diff --git a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx index 199d2330269..9f41b553bed 100644 --- a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx @@ -434,8 +434,8 @@ function draftEnableOrcaOptions( /** `providers` 是哪一处的目录(Agent 所在位置);null = 任务所在电脑。 */ providersAgentDeviceId: string | null = null, ) { - const preferredAgent: 'claude-code' | 'codex' | 'pi' = - collab.worker === 'codex' ? 'codex' : collab.worker === 'pi' ? 'pi' : 'claude-code'; + const preferredAgent: 'claude-code' | 'codex' | 'pi' | 'cursor' = + collab.worker === 'codex' ? 'codex' : collab.worker === 'cursor' ? 'cursor' : collab.worker === 'pi' ? 'pi' : 'claude-code'; // Worker 类型也是**设备作用域**的(codex review P2):在只连了 Codex 的设备 A 选了 Codex // Worker,切到只连 Claude 的设备 B 时,workerConfig 虽然被清了,collab.worker 仍是 codex, // 透传过去必撞被控端的 NO_PROVIDER_FOR_AGENT 预检,协同又静默降级成单会话。 @@ -443,7 +443,7 @@ function draftEnableOrcaOptions( // 没有已连接供应商、而另一个有,就改用另一个;两个都没有则原样透传,由 main 的精确 // preflight 报可操作错误(不在这里编一个同样跑不起来的值)。 // 仅在目录就绪时收窄,理由同下方 providerId:未就绪的空快照会误判成"都没有"。 - const workerAgent: 'claude-code' | 'codex' | 'pi' = (() => { + const workerAgent: 'claude-code' | 'codex' | 'pi' | 'cursor' = (() => { if (!providersReady) return preferredAgent; if (connectedProvidersForAgent(providers, preferredAgent).length > 0) return preferredAgent; const fallback = (['claude-code', 'codex', 'pi'] as const).find( @@ -808,8 +808,8 @@ export function NewMakerDraftRoute() { * 并清掉 —— 持久化之后那等于一切引擎只能记住最后一次选择。 */ const draftFavoriteAnchor = useDraftFavoriteAnchor(normalizeDbAgentKind(draft.vendor)); - const persistedAgentKind: 'cc' | 'codex' | 'pi' = normalizeDbAgentKind(draft.vendor); - const authVendor: 'cc' | 'codex' | 'pi' = persistedAgentKind; + const persistedAgentKind: 'cc' | 'codex' | 'pi' | 'cursor' = normalizeDbAgentKind(draft.vendor); + const authVendor: 'cc' | 'codex' | 'pi' | 'cursor' = persistedAgentKind; const capabilityAgentKind = dbToMakerAgentKind(persistedAgentKind); // 品牌区跟随当前主题;icon / logo 的固定布局统一由 ThemeBrandLockup 负责。 @@ -1005,8 +1005,8 @@ export function NewMakerDraftRoute() { catalogDeviceId, ); const hiddenSwitcherVendors = useMemo(() => { - if (!availableAgentsLoaded) return []; - return (['cc', 'codex', 'pi'] as const).filter((vendor) => !availableVendors.has(vendor)); + if (!availableAgentsLoaded) return ['cursor']; + return (['cc', 'codex', 'pi', 'cursor'] as const).filter((vendor) => !availableVendors.has(vendor)); }, [availableAgentsLoaded, availableVendors]); /** * 「这份草稿要建到对端设备上」—— 只看 deviceId,**不再要求 workingDir**(#807)。 @@ -1112,6 +1112,7 @@ export function NewMakerDraftRoute() { // "有没有选项目目录" 给出 —— 与它提交给 createSession 的值同源,不让 helper 反推。 const collabWorkspaceKind = effectiveWorkingDir ? 'project' : 'dialogue'; const collabEntry = resolveCollabEntryPolicy({ + agentKind: capabilityAgentKind, workspaceKind: collabWorkspaceKind, workingDir: effectiveWorkingDir, remoteHostId: effectiveRemoteHostId, @@ -2179,7 +2180,7 @@ export function NewMakerDraftRoute() { const carryDraftFavoriteAnchorToSession = useCallback( ( newSessionId: string, - engine: 'cc' | 'codex' | 'pi', + engine: 'cc' | 'codex' | 'pi' | 'cursor', model: string, providerId: string | null, ): void => { @@ -2412,7 +2413,7 @@ export function NewMakerDraftRoute() { const handleRemoteProjectAdded = useCallback( async (target: RemoteProjectTarget) => { // vendor 由外层 VendorSegmentedSwitcher (draft.vendor) 单一决策 —— dialog 不再让用户选。 - const draftVendor: 'cc' | 'codex' | 'pi' = normalizeDbAgentKind(draft.vendor); + const draftVendor: 'cc' | 'codex' | 'pi' | 'cursor' = normalizeDbAgentKind(draft.vendor); if (target.kind === 'device-link') { // device-link:**不**像 SSH 立即建会话(会在被控端留空会话)。改为把当前草稿指向该被控 @@ -3963,7 +3964,7 @@ export function NewMakerDraftRoute() { remoteSessionId, message, createArgs.model, - createArgs.effort, + createArgs.effort ?? '', createArgs.permissionMode, remoteSendWorkingDir, rehydratedFiles, @@ -4069,7 +4070,7 @@ export function NewMakerDraftRoute() { // agent 启动时看到的工作区已是迁移后的状态。fail-soft:检测错误只 warn,不阻塞 send。 try { const wd = effectiveWorkingDir; - if (wd && !isRemoteProjectDraft && persistedAgentKind !== 'pi') { + if (wd && !isRemoteProjectDraft && (persistedAgentKind === 'cc' || persistedAgentKind === 'codex')) { const r = await crossAgentConvertService.detect( wd, persistedAgentKind === 'cc' ? 'claude-code' : persistedAgentKind, @@ -5848,9 +5849,15 @@ export function NewMakerDraftRoute() { // 不传 onChange 时 ExtraDirsButton 直接不渲染引用目录段(「新建目标」/ 计划模式 / // Plugin 入口不受影响)。进入远程设备时 extraDirs 已被清空,不会留下无法删除的残留。 // 恢复这个能力要把 picker 路由到对端(设备域浏览器已有 fs:list-dir),见 follow-up。 - onExtraDirsChange={isDeviceLinkDraft ? undefined : handleExtraDirsChange} + onExtraDirsChange={ + isDeviceLinkDraft || + (capabilityAgentKind === 'cursor' && capabilities?.extraDirs?.supported !== true) + ? undefined + : handleExtraDirsChange + } onWritableDirsChange={ - isDeviceLinkDraft || isRemoteProjectDraft + isDeviceLinkDraft || isRemoteProjectDraft || + (capabilityAgentKind === 'cursor' && capabilities?.writableDirs?.supported !== true) ? undefined : handleWritableDirsChange } @@ -5970,7 +5977,7 @@ export function NewMakerDraftRoute() { onCreate={(form: CreateWorkerForm) => { patchCollab({ enabled: true, - worker: form.agent === 'codex' ? 'codex' : form.agent === 'pi' ? 'pi' : 'cc', + worker: form.agent === 'codex' ? 'codex' : form.agent === 'cursor' ? 'cursor' : form.agent === 'pi' ? 'pi' : 'cc', workerConfig: { role: form.role, model: form.model, diff --git a/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts b/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts index b5d78245e4e..5438bfe2385 100644 --- a/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts +++ b/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts @@ -11,7 +11,11 @@ * renderer 这一层只是体验层,不构成权限边界。 */ +import type { MakerAgentKindWire } from '../../../shared/agentKindConversion'; + export interface CollabEntryTarget { + /** Native Cursor ACP does not support Cindy orchestration. */ + agentKind?: MakerAgentKindWire; /** * 会话/草稿的 workspace 形态。**必须由调用方显式给出,这里不从 workingDir 反推** —— * dialogue 会话也有 workingDir(main 按 workspaceKind='dialogue' 自动分配 @@ -72,6 +76,7 @@ function nonEmpty(value: string | null | undefined): string | null { * 又要在被控端跳过项目级 —— 两个字段互相独立,不是二选一。 */ export function resolveCollabEntryPolicy(target: CollabEntryTarget): CollabEntryPolicyScope { + if (target.agentKind === 'cursor') return NOT_ELIGIBLE; if (target.orcaRole === 'worker' || target.orcaRemoteWorker) return NOT_ELIGIBLE; if (target.workspaceKind !== 'project' && target.workspaceKind !== 'dialogue') { return NOT_ELIGIBLE; diff --git a/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts index d1c904a127a..8b021b53247 100644 --- a/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts +++ b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts @@ -23,8 +23,8 @@ import type { AgentKind } from '@/hooks/useAgentCapabilities'; import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; export interface DeviceLinkCreateParams { - /** 草稿 vendor 形态:'cc' | 'codex' | 'pi'(persistedAgentKind)。 */ - agentKind: 'cc' | 'codex' | 'pi'; + /** 草稿 vendor 形态:'cc' | 'codex' | 'pi' | 'cursor'(persistedAgentKind)。 */ + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; /** * 被控端上的项目目录。缺省 / 空白 = 在该设备上建**不绑项目的 standalone dialogue**, * workspaceKind 随之派生为 'dialogue',运行目录由被控端分配。 @@ -63,7 +63,7 @@ export interface DeviceLinkCreateParams { } export interface DeviceLinkCreateArgs { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 仅远程 worktree 流程出现(与 worktree:create 登记的绑定同 id)。 */ id?: string; /** 仅项目会话出现;dialogue 不带此字段(被控端自行分配运行目录)。 */ @@ -71,7 +71,7 @@ export interface DeviceLinkCreateArgs { /** 由 workingDir 派生 —— 有目录 'project',无目录 'dialogue'。归属一致的关键。 */ workspaceKind: WorkspaceKind; model: string; - effort: Effort; + effort?: Effort; permissionMode: PermissionMode; fastMode: boolean; planMode?: boolean; @@ -96,7 +96,7 @@ export function buildDeviceLinkCreateArgs(p: DeviceLinkCreateParams): DeviceLink ...(dir ? { workingDir: dir } : {}), workspaceKind: dir ? 'project' : 'dialogue', model: p.model, - effort: p.effort, + ...(p.effort ? { effort: p.effort } : {}), permissionMode: p.permissionMode, fastMode: p.fastMode, ...(p.planModeEnabled ? { planMode: true } : {}), @@ -126,7 +126,7 @@ export interface DeviceLinkSubmissionCandidate { } export interface DeviceLinkSubmissionParams { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; workingDir?: string; id?: string; extraDirs?: string[]; @@ -225,7 +225,7 @@ export function buildProvisionalRemoteSession(p: ProvisionalRemoteSessionParams) workingDir: p.workDir, workspaceKind: p.args.workspaceKind, model: p.args.model, - effort: p.args.effort, + effort: p.args.effort ?? '', permissionMode: p.args.permissionMode, providerId: p.args.providerId ?? null, sdkSessionId: null, @@ -242,7 +242,7 @@ export function buildProvisionalRemoteSession(p: ProvisionalRemoteSessionParams) // 回流都会把它冲回 null,会话就先掉进项目外的草稿区、首条落地后再跳回项目。 userSendAt: null, status: 'active', - // Session.agentKind 是本机形态('cc' | 'codex' | 'pi'),args 里是 maker-core 形态,这里转回来。 + // Session.agentKind 是本机形态('cc' | 'codex' | 'pi' | 'cursor'),args 里是 maker-core 形态,这里转回来。 agentKind: p.args.agentKind === 'claude-code' ? 'cc' : p.args.agentKind, // 乐观行就带上 Agent 所在电脑:会话页的模型按钮与目录从一开始就按那台显示。 ...(p.args.agentDeviceId ? { agentDeviceId: p.args.agentDeviceId } : {}), diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/helpers/sidebarFilterCore.ts b/apps/desktop/src/renderer/features/cc-agent/hooks/helpers/sidebarFilterCore.ts index 62756a1be50..8f513059f26 100644 --- a/apps/desktop/src/renderer/features/cc-agent/hooks/helpers/sidebarFilterCore.ts +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/helpers/sidebarFilterCore.ts @@ -52,7 +52,7 @@ export const DIALOGUE_FILTER_KEY = 'dialogue'; /** 'all' 字符串字面量 = 选中"全部";string[] = 勾选的 projectKey 和/或 DIALOGUE_FILTER_KEY。 */ export type FilterProjects = 'all' | string[]; /** Harness filter;沿用 vendor 存储键,兼容已有筛选偏好。 */ -export type FilterVendor = 'all' | 'cc' | 'codex' | 'pi'; +export type FilterVendor = 'all' | 'cc' | 'codex' | 'pi' | 'cursor'; /** * Sidebar 主列表分组方式(侧边栏重设计 D 期)。 * - project: 「按项目分组」开——有项目的任务收进项目行(默认)。 diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/workerProjectionStore.ts b/apps/desktop/src/renderer/features/cc-agent/hooks/workerProjectionStore.ts index 7eefbe4e1bd..7b8d9353a30 100644 --- a/apps/desktop/src/renderer/features/cc-agent/hooks/workerProjectionStore.ts +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/workerProjectionStore.ts @@ -16,7 +16,7 @@ export interface WorkerInfo { workerId: string; sessionId: string; role: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; effort: string | null; label: string | null; @@ -188,7 +188,7 @@ function mapWorkerRecord(raw: Record): WorkerInfo { sessionId: raw.sessionId as string, role: (raw.role as string) ?? 'developer', agent: - session?.agentKind === 'codex' ? 'codex' : session?.agentKind === 'pi' ? 'pi' : 'claude-code', + session?.agentKind === 'codex' ? 'codex' : session?.agentKind === 'cursor' ? 'cursor' : session?.agentKind === 'pi' ? 'pi' : 'claude-code', model: (session?.model as string) ?? 'claude-sonnet-4-6', effort: (session?.effort as string | null) ?? null, label: (raw.label as string | null) ?? null, diff --git a/apps/desktop/src/renderer/features/cc-agent/lib/__tests__/orcaAgentDisplay.test.ts b/apps/desktop/src/renderer/features/cc-agent/lib/__tests__/orcaAgentDisplay.test.ts index f0615df269d..ae2289320d4 100644 --- a/apps/desktop/src/renderer/features/cc-agent/lib/__tests__/orcaAgentDisplay.test.ts +++ b/apps/desktop/src/renderer/features/cc-agent/lib/__tests__/orcaAgentDisplay.test.ts @@ -14,6 +14,7 @@ describe('orcaAgentDisplay pi support', () => { it('normalizes pi to pi (not claude-code)', () => { expect(normalizeOrcaDisplayAgentKind('pi')).toBe('pi'); expect(normalizeOrcaDisplayAgentKind('codex')).toBe('codex'); + expect(normalizeOrcaDisplayAgentKind('cursor')).toBe('cursor'); expect(normalizeOrcaDisplayAgentKind('cc')).toBe('claude-code'); expect(normalizeOrcaDisplayAgentKind('claude-code')).toBe('claude-code'); expect(normalizeOrcaDisplayAgentKind('unknown')).toBe('claude-code'); @@ -24,6 +25,8 @@ describe('orcaAgentDisplay pi support', () => { expect(orcaVendorForAgentKind('pi')).toBe('pi'); expect(orcaAgentLabel('codex')).toBe('Codex'); expect(orcaVendorForAgentKind('codex')).toBe('codex'); + expect(orcaAgentLabel('cursor')).toBe('Cursor'); + expect(orcaVendorForAgentKind('cursor')).toBe('cursor'); expect(orcaAgentLabel('claude-code')).toBe('Claude'); expect(orcaVendorForAgentKind('claude-code')).toBe('cc'); }); diff --git a/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts b/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts index bdd1241048f..9dc9a81b818 100644 --- a/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts +++ b/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts @@ -1,17 +1,18 @@ -export type OrcaDisplayAgentKind = 'claude-code' | 'codex' | 'pi'; -export type OrcaDisplayVendor = 'cc' | 'codex' | 'pi'; +export type OrcaDisplayAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; +export type OrcaDisplayVendor = 'cc' | 'codex' | 'pi' | 'cursor'; export function normalizeOrcaDisplayAgentKind(agentKind: unknown): OrcaDisplayAgentKind { if (agentKind === 'codex') return 'codex'; + if (agentKind === 'cursor') return 'cursor'; if (agentKind === 'pi') return 'pi'; if (agentKind === 'cc' || agentKind === 'claude-code') return 'claude-code'; return 'claude-code'; } export function orcaAgentLabel(agentKind: OrcaDisplayAgentKind): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude'; + return agentKind === 'codex' ? 'Codex' : agentKind === 'cursor' ? 'Cursor' : agentKind === 'pi' ? 'Pi' : 'Claude'; } export function orcaVendorForAgentKind(agentKind: OrcaDisplayAgentKind): OrcaDisplayVendor { - return agentKind === 'codex' ? 'codex' : agentKind === 'pi' ? 'pi' : 'cc'; + return agentKind === 'codex' ? 'codex' : agentKind === 'cursor' ? 'cursor' : agentKind === 'pi' ? 'pi' : 'cc'; } diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts index d7c8d784c20..e61bac2f719 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts @@ -42,7 +42,7 @@ export interface SessionTaskItem { /** 标题链取不到任何来源时为 ''(UI 层负责 i18n 兜底)。 */ title: string; status: AgentTaskStatus; - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'cursor'; update?: AgentTaskUpdate; toolCallClientId?: string; toolUseId?: string; diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx index 6b37900e5b7..cd40073d8a6 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx @@ -42,12 +42,14 @@ const KIND_ICON: Record = { 'agent-claude': Bot, 'agent-codex': Bot, 'agent-pi': Bot, + 'agent-cursor': Bot, }; const AGENT_NAME: Record = { 'agent-claude': 'Claude Code', 'agent-codex': 'Codex', 'agent-pi': 'Pi', + 'agent-cursor': 'Cursor', }; const UTILITY_LABEL_KEY: Record = { diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx index e535e4cef78..67628b5f5bd 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx @@ -133,7 +133,7 @@ function ScopedSubagentsBody({ */ const detailReadsInFlightRef = useRef(0); const transcriptReadsInFlightRef = useRef(0); - const selectedProviderHint = state.selectedProvider === 'pi' ? 'pi' : null; + const selectedProviderHint = state.selectedProvider === 'cursor' ? 'cursor' : state.selectedProvider === 'pi' ? 'pi' : null; const selectedRunAlias = state.selectedProvider && state.selectedProvider !== 'pi' ? null : (state.selectedRunId ?? null); diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx index 6f0773676e9..48683411bc0 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx @@ -17,7 +17,7 @@ export interface SubagentsState { selectedProvider?: SubagentProvider | null; } -const SUBAGENT_PROVIDERS = new Set(['claude-code', 'codex', 'pi']); +const SUBAGENT_PROVIDERS = new Set(['claude-code', 'codex', 'pi', 'cursor']); function SubagentsTabPillTitle({ t }: { state: SubagentsState; t: TFunction }) { return <>{t('rightSidebar.tabs.kinds.subagents')}; diff --git a/apps/desktop/src/renderer/features/scheduler/components/AgentMark.tsx b/apps/desktop/src/renderer/features/scheduler/components/AgentMark.tsx index e459038fa77..90a998d9754 100644 --- a/apps/desktop/src/renderer/features/scheduler/components/AgentMark.tsx +++ b/apps/desktop/src/renderer/features/scheduler/components/AgentMark.tsx @@ -11,6 +11,7 @@ import { CodexMark } from '@/components/icons/CodexMark'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; +import { CursorMark } from '@/components/icons/CursorMark'; import { PiMark } from '@/components/icons/PiMark'; import type { AgentKind } from '@cindy/maker-scheduler'; @@ -21,6 +22,7 @@ interface Props { } export function AgentMark({ agentKind, size = 14, className }: Props) { + if (agentKind === 'cursor') return ; if (agentKind === 'codex') return ; // Pi 运行历史此前落到 ClaudeMark 兜底,显示成 Claude 身份(codex review)。 if (agentKind === 'pi') return ; diff --git a/apps/desktop/src/renderer/features/scheduler/components/RunHistoryPane.tsx b/apps/desktop/src/renderer/features/scheduler/components/RunHistoryPane.tsx index d028fb85191..fb9e3bd4130 100644 --- a/apps/desktop/src/renderer/features/scheduler/components/RunHistoryPane.tsx +++ b/apps/desktop/src/renderer/features/scheduler/components/RunHistoryPane.tsx @@ -89,7 +89,7 @@ export function RunHistoryPane({ const sessionAgentMap = useMemo(() => { const m = new Map(); for (const sess of allSessions) { - m.set(sess.id, sess.agentKind === 'cc' ? 'claude-code' : sess.agentKind === 'pi' ? 'pi' : 'codex'); + m.set(sess.id, sess.agentKind === 'cc' ? 'claude-code' : sess.agentKind === 'cursor' ? 'cursor' : sess.agentKind === 'pi' ? 'pi' : 'codex'); } return m; }, [allSessions]); diff --git a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx index 7769823b68f..f25e2b288b7 100644 --- a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx +++ b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx @@ -54,7 +54,7 @@ import type { SessionReference } from '../../../../shared/sessionReference'; import { isReviewSessionSource } from '../../../../shared/sessionSource'; export type Destination = 'local' | 'worktree' | 'thread'; -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; interface ChipButtonProps { icon?: React.ReactNode; @@ -1337,7 +1337,7 @@ export function ThreadPickerInline({ value, onSelect, onOpen, reference }: { )} {sessions.map((s) => ( ))} diff --git a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts index 9152501102f..6f69937f081 100644 --- a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts +++ b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts @@ -87,6 +87,7 @@ function defaultScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': EMPTY_AGENT_PREFS, codex: EMPTY_AGENT_PREFS, pi: EMPTY_AGENT_PREFS, + cursor: EMPTY_AGENT_PREFS, }, }; } @@ -97,7 +98,7 @@ function loadScheduleFormPrefs(): ScheduleFormPrefs { const raw = window.localStorage.getItem(SCHEDULE_FORM_PREFS_KEY); if (!raw) return defaultScheduleFormPrefs(); const parsed = JSON.parse(raw) as Partial; - const agentKind = parsed.agentKind === 'codex' ? 'codex' : parsed.agentKind === 'pi' ? 'pi' : 'claude-code'; + const agentKind = parsed.agentKind === 'codex' ? 'codex' : parsed.agentKind === 'cursor' ? 'cursor' : parsed.agentKind === 'pi' ? 'pi' : 'claude-code'; const workingDir = typeof parsed.workingDir === 'string' ? parsed.workingDir : ''; const workspaceKind = normalizePrefsWorkspaceKind(parsed.workspaceKind, workingDir); return { @@ -109,6 +110,7 @@ function loadScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': sanitizeAgentPrefs(parsed.lastByAgent?.['claude-code']), codex: sanitizeAgentPrefs(parsed.lastByAgent?.codex), pi: sanitizeAgentPrefs(parsed.lastByAgent?.pi), + cursor: sanitizeAgentPrefs(parsed.lastByAgent?.cursor), }, }; } catch { @@ -173,7 +175,7 @@ export function getScheduleDefaultRoute( if (prefs.model.trim() && compatible(prefsProviderId)) { return { model: prefs.model, providerId: prefsProviderId }; } - const vendor = agentKind === 'codex' ? 'codex' : agentKind === 'pi' ? 'pi' : 'cc'; + const vendor = agentKind === 'codex' ? 'codex' : agentKind === 'cursor' ? 'cursor' : agentKind === 'pi' ? 'pi' : 'cc'; const draft = getDraft(); const chatLast = getPersistedVendorModel(vendor); const chatProviderId = draft.lastByVendor[vendor]?.providerId || null; diff --git a/apps/desktop/src/renderer/features/scheduler/lib/__tests__/scheduleFormLogic.test.ts b/apps/desktop/src/renderer/features/scheduler/lib/__tests__/scheduleFormLogic.test.ts index 24f18b1962a..1e620dd11de 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/__tests__/scheduleFormLogic.test.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/__tests__/scheduleFormLogic.test.ts @@ -510,6 +510,10 @@ describe('buildScheduleInput — 非 heartbeat 分支(行为锁定,不动 create expect(hasKey(input, 'fastMode')).toBe(true); expect(input.fastMode).toBe(true); }); + it('preserves Cursor native effort and Fast selections', () => { + const input = buildScheduleInput(makeForm({ agentKind: 'cursor', model: 'grok-4.7', effort: 'xhigh', fastMode: true })); + expect(input).toMatchObject({ agentKind: 'cursor', model: 'grok-4.7', effort: 'xhigh', fastMode: true }); + }); it('claude-code 不序列化 fastMode(runner 忽略此字段)', () => { const input = buildScheduleInput(makeForm({ agentKind: 'claude-code', fastMode: true })); @@ -646,6 +650,7 @@ describe('hasRealBinding / agentKind 映射', () => { it('sessionAgentKindToScheduleAgentKind 映射', () => { expect(sessionAgentKindToScheduleAgentKind('cc')).toBe('claude-code'); expect(sessionAgentKindToScheduleAgentKind('codex')).toBe('codex'); + expect(sessionAgentKindToScheduleAgentKind('cursor')).toBe('cursor'); }); }); diff --git a/apps/desktop/src/renderer/features/scheduler/lib/formatters.ts b/apps/desktop/src/renderer/features/scheduler/lib/formatters.ts index 6a0bba8736c..26a217ac123 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/formatters.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/formatters.ts @@ -251,6 +251,8 @@ export function humanizeAgentKind(k: AgentKind): string { return 'Codex'; case 'pi': return 'Pi'; + case 'cursor': + return 'Cursor'; default: { // 穷尽性检查:AgentKind 新增引擎时这里编译报错,避免再次静默回落成 Claude。 const unreachable: never = k; diff --git a/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts b/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts index 9ea4f22ccba..9ad6de2d4d5 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts @@ -14,9 +14,9 @@ export interface ProjectScheduleConfig { recurring?: boolean; manual?: boolean; intervalMs?: number; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 显式模型选择的 Harness;省略时保留旧配置的跟随绑定任务语义。 */ - modelAgentKind?: 'claude-code' | 'codex' | 'pi'; + modelAgentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; model?: string; /** 显式来源(供应商)id;省略 = 使用该 Agent 的原生默认来源。 */ providerId?: string; @@ -95,7 +95,7 @@ export function formToProjectConfig( providerId: form.providerId.trim() || undefined, effort: form.effort || undefined, // Codex / Pi 都生效(runner.ts:665);只认 codex 会丢弃 Pi 任务的 Fast(codex review)。 - fastMode: (form.agentKind === 'codex' || form.agentKind === 'pi') && form.fastMode ? true : undefined, + fastMode: (form.agentKind === 'codex' || form.agentKind === 'pi' || form.agentKind === 'cursor') && form.fastMode ? true : undefined, useWorktree: form.useWorktree, persistentSession: form.persistentSession, silentWhenIdle: form.silentWhenIdle, diff --git a/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts b/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts index 7291957ae92..fd5ab883397 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts @@ -138,8 +138,8 @@ export interface ScheduleFormState { recurring: boolean; /** 手动模式:true → 创建后永不自动 fire,只能 Run now。UI 上需要 recurring=false 才能勾。 */ manual: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; - modelAgentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; + modelAgentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** The bound task's baseline, independent of the editable model choice. Form-only. */ boundAgent?: { sessionId: string; agentKind: ScheduleFormState['agentKind'] }; model: string; @@ -378,9 +378,10 @@ export function applyRunMode( /** renderer Session.agentKind('cc'|'codex')→ schedule agentKind 映射。 */ export function sessionAgentKindToScheduleAgentKind( - kind: 'cc' | 'codex' | 'pi', + kind: 'cc' | 'codex' | 'pi' | 'cursor', ): ScheduleFormState['agentKind'] { if (kind === 'codex') return 'codex'; + if (kind === 'cursor') return 'cursor'; if (kind === 'pi') return 'pi'; return 'claude-code'; } @@ -603,10 +604,8 @@ export function buildScheduleInput(form: ScheduleFormState): CreateScheduleInput // 让 update patch 按「key 在 + undefined」把旧 provider_id 清成 NULL。 base.providerId = form.providerId.trim() || undefined; if (form.effort && isEffortValue(form.effort)) base.effort = form.effort; - // fastMode 对 Codex / Pi 都生效(runner.ts:665 明确 claude-code 忽略此字段);只序列化 - // codex 会让用户在 Pi 任务里开的 Fast 被静默丢弃(codex review)。表单侧 Fast 开关已按 - // capability × 模型 supportsFastMode 门控,Pi 只有真支持时才可能为 true。 - if (form.agentKind === 'codex' || form.agentKind === 'pi') base.fastMode = form.fastMode; + // Fast 按引擎和模型的原生能力门控;保留显式 false,避免执行端沿用之前的值。 + if (form.agentKind === 'codex' || form.agentKind === 'pi' || form.agentKind === 'cursor') base.fastMode = form.fastMode; return base; } diff --git a/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts b/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts index 3bdfbf56a77..025ff82952b 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts @@ -1,6 +1,6 @@ interface LocalSkillRouteEntry { id: string; - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'cursor'; kind: SkillhubKind; scope: SkillhubScope; name: string; diff --git a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts index 3866d14cb68..89df1659ec4 100644 --- a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts +++ b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts @@ -17,12 +17,12 @@ import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; const log = createLogger('useAgentCapabilities'); -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; // capability 生命周期(预取 / 驱逐通知 / 本地快照刷新 / 启动预载)必须覆盖全部 agent, // 少一个就会让该 agent 的远程会话在断链或 provider revision 后收不到 loading 事件、 // 也不再被重新预取,界面永久停在旧模型/能力快照(codex review)。新增 agent 只改这里。 -const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const; +const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi', 'cursor'] as const; // renderer 视角: id 全部是不透明 string, 渲染只读 displayName。 // effort 的合法 id 集合 = capabilities.effortLevels 上每个项的 id。 @@ -55,7 +55,7 @@ export interface ModelDescriptor { * 解耦;生产环境 XD 网关由服务端按区域下发)。消费点见 modelDefinitions.newSessionDefaultModelId * 与 draftModelCalibration:被标记且可用的模型优先作新对话默认。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'cursor')[]; } export interface EffortDescriptor { @@ -94,6 +94,8 @@ export interface AgentCapabilities { * device-link 老被控端序列化的 capabilities 无此字段 → undefined = 不支持。 */ planMode?: CapabilityStatus; + /** Additional read-only directories mounted into the native task. */ + extraDirs?: CapabilityStatus; /** * 会话级外部可写目录能力。device-link 老被控端无此字段 → undefined = 不支持, * 控制端必须隐藏会触发新 channel 的入口。 @@ -226,7 +228,7 @@ function isOptionalNewSessionDefault(value: unknown): boolean { if (!Array.isArray(value) || value.length === 0) return false; if ( value.some( - (agent) => agent !== 'claude-code' && agent !== 'codex' && agent !== 'pi', + (agent) => agent !== 'claude-code' && agent !== 'codex' && agent !== 'pi' && agent !== 'cursor', ) ) { return false; @@ -640,8 +642,8 @@ export async function loadLocalCapabilitiesSnapshot(): Promise - v === 'claude-code' || v === 'codex' || v === 'pi') as RuntimeAgentKind[]) : []; + v === 'claude-code' || v === 'codex' || v === 'pi' || v === 'cursor') as RuntimeAgentKind[]) : []; } const api = getMakerApi(); if (!api) throw new Error('maker IPC not available'); @@ -310,7 +310,7 @@ export function __resetAvailableAgentsCacheForTest(): void { export function useModelPickerAgents(current: RuntimeAgentKind, deviceId?: string | null): readonly RuntimeAgentKind[] | undefined { const { availableVendors, loaded } = useAvailableAgents(deviceId); if (!loaded) return undefined; - return (['claude-code', 'codex', 'pi'] as const).filter( + return (['claude-code', 'codex', 'pi', 'cursor'] as const).filter( (agent) => agent === current || availableVendors.has(toVendor(agent)), ); } diff --git a/apps/desktop/src/renderer/hooks/useMakerSession.ts b/apps/desktop/src/renderer/hooks/useMakerSession.ts index 2e66605b971..478e83bd68e 100644 --- a/apps/desktop/src/renderer/hooks/useMakerSession.ts +++ b/apps/desktop/src/renderer/hooks/useMakerSession.ts @@ -15,13 +15,13 @@ interface MakerEvent { interface SessionInfo { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workDir: string; capabilities: unknown; } interface CreateSessionParams { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; /** 与 maker-core/types/common.ts 的 Effort union 一致 */ diff --git a/apps/desktop/src/renderer/hooks/useUsageHistory.ts b/apps/desktop/src/renderer/hooks/useUsageHistory.ts index 663486e53d2..61d4be7399d 100644 --- a/apps/desktop/src/renderer/hooks/useUsageHistory.ts +++ b/apps/desktop/src/renderer/hooks/useUsageHistory.ts @@ -34,7 +34,7 @@ import type { Session } from '@/lib/ccAgent.types'; import { onPatch as onSessionPatch } from '@/lib/sessionsBus'; export interface UsageHistoryModel { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; money: RegionalMoney; estimatedMoney: RegionalMoney | null; @@ -47,7 +47,7 @@ export interface UsageHistoryModel { /** 每日 × 模型明细 — 右栏堆叠柱状图分段。 */ export interface UsageHistoryModelDay { day: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; money: RegionalMoney; apiMoney: RegionalMoney; @@ -201,7 +201,7 @@ function finiteNumber(value: unknown, fallback = 0): number { } function isAgentKind(value: unknown): value is UsageHistoryModel['agentKind'] { - return value === 'claude-code' || value === 'codex' || value === 'pi'; + return value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'cursor'; } function parseDevices(value: unknown): UsageHistoryDevice[] | undefined { diff --git a/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts b/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts index 82c88c06991..2029c8d124f 100644 --- a/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts +++ b/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts @@ -55,12 +55,20 @@ type CopyKey = | 'voice-direct-api-key-unauth' | 'codex-voice-unauth' | 'codex-binary-missing' - | 'pi-binary-missing'; + | 'pi-binary-missing' + | 'cursor-unavailable'; function buildCopy(t: (key: string) => string): Record { return { // 无可用来源(cc / codex 同构):该 agent 一个已连接来源都没有 → 复用模型选择器同款 // 「无可用来源 → 去连接」文案,跳供应商页。send 门禁与 agent 类型无关,两个 agent 共用这一条。 + 'cursor-unavailable': { + title: 'Cursor', + description: t('logic.confirm.cursorSetupDescription'), + confirmText: t('logic.confirm.gotIt'), + cancelText: t('logic.confirm.cancel'), + settingsTab: 'providers', + }, 'no-source': { title: t('newChat.noProvider.title'), description: t('newChat.noProvider.description'), @@ -121,6 +129,7 @@ function pickCopy( vendor: AgentKind, readiness: Readiness, ): DialogCopy | null { + if (vendor === 'cursor' && (readiness === 'binary-missing' || readiness === 'unauthenticated')) return copy['cursor-unavailable']; if (readiness === 'binary-missing' && vendor === 'codex') return copy['codex-binary-missing']; if (readiness === 'binary-missing' && vendor === 'pi') return copy['pi-binary-missing']; if (readiness !== 'unauthenticated') return null; @@ -238,6 +247,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const cc = useVendorReadiness('cc'); const codex = useVendorReadiness('codex'); const pi = useVendorReadiness('pi'); + const cursor = useVendorReadiness('cursor'); const checkAndConfirm = useCallback( async ( @@ -273,7 +283,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const deviceId = options?.deviceId; if (deviceId) { const providerAgent: ProviderAgentKind = - vendor === 'codex' ? 'codex' : vendor === 'pi' ? 'pi' : 'claude-code'; + vendor === 'codex' ? 'codex' : vendor === 'cursor' ? 'cursor' : vendor === 'pi' ? 'pi' : 'claude-code'; const includeSuspended = options?.existingSessionRoute === true; // 模型选择器已读到的来源快照(被控端来源变化推送会令其失效)显示已有可用来源时直接复用: // provider:list 经隧道实测 1–5 秒,重拉一遍会整段挡在远程新建任务的第一步。 @@ -332,7 +342,10 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const device = remoteProjectsStore.getDeviceName(deviceId) ?? deviceId; let title: string; let description: string; - if (remoteReadiness === 'binary-missing') { + if (vendor === 'cursor') { + title = 'Cursor'; + description = t('logic.confirm.cursorSetupDescription'); + } else if (remoteReadiness === 'binary-missing') { title = t(vendor === 'pi' ? 'logic.confirm.remotePiBinaryMissingTitle' : 'logic.confirm.remoteCodexBinaryMissingTitle'); @@ -355,7 +368,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { } // 触发一次最新检查——避免 stale state 误放行。 - const target = vendor === 'codex' ? codex : vendor === 'pi' ? pi : cc; + const target = vendor === 'cursor' ? cursor : vendor === 'codex' ? codex : vendor === 'pi' ? pi : cc; // 已建会话的发送门禁计入 suspended 来源(见 useVendorReadiness 注释);草稿不传。 const readiness = await target.revalidate({ includeSuspended: options?.existingSessionRoute === true, @@ -377,7 +390,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { } return { proceed: false }; }, - [cc, codex, pi, confirm, copy, navigate, t], + [cc, codex, pi, cursor, confirm, copy, navigate, t], ); return { checkAndConfirm }; diff --git a/apps/desktop/src/renderer/hooks/useVendorReadiness.ts b/apps/desktop/src/renderer/hooks/useVendorReadiness.ts index 6daac49112b..1e54ef249db 100644 --- a/apps/desktop/src/renderer/hooks/useVendorReadiness.ts +++ b/apps/desktop/src/renderer/hooks/useVendorReadiness.ts @@ -24,20 +24,20 @@ export type Readiness = 'ready' | 'unauthenticated' | 'binary-missing' | 'loadin * 两种不同的恢复路径,不能把 Pi 的缺包状态伪装成未授权。 */ export function readinessFromBinaryStatus( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor', binaryReady: boolean, ): Readiness | null { return vendorKey !== 'cc' && !binaryReady ? 'binary-missing' : null; } -export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi'): { +export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'): { readiness: Readiness; revalidate: (opts?: { includeSuspended?: boolean }) => Promise; } { const [readiness, setReadiness] = useState('loading'); const revalidate = useCallback(async (opts?: { includeSuspended?: boolean }): Promise => { - const agent: AgentKind = vendorKey === 'cc' ? 'claude-code' : vendorKey === 'pi' ? 'pi' : 'codex'; + const agent: AgentKind = vendorKey === 'cc' ? 'claude-code' : vendorKey === 'cursor' ? 'cursor' : vendorKey === 'pi' ? 'pi' : 'codex'; // 轴 2(codex / pi,正交于来源):本地二进制是运行时前提,缺了连发都发不了 → 优先返回 // binary-missing。binary 状态走 maker:agent:status(其 authReady 是 codex OAuth 专属,已被 diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 560beb2a583..4e662207e81 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -2574,6 +2574,13 @@ } }, "providers": { + "cursor": { + "localAccount": "Local Cursor CLI account", + "setup": "Install Cursor CLI using the official instructions, then restart Cindy. Sign in to Cursor in your terminal, then refresh the model list.", + "installGuide": "View installation instructions", + "modelsUnavailable": "Models not ready", + "refreshFailed": "Could not read Cursor models. Check that the CLI is installed and signed in, then retry. Restart Cindy if you just installed the CLI." + }, "llamacpp": { "ownedElsewhere": "The model service is running in another Cindy instance. Download models or change context there, or stop its service first.", "primaryPick": "Primary pick · try this first", @@ -9823,6 +9830,10 @@ "providerGroupRunningOn": "Running on {{device}}", "providerGroupRunningHere": "Running on this computer", "providerGroupUnassigned": "The Provider Group picks a computer when the task starts; usage then shows for that computer" + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } }, "permissionSelector": { @@ -9830,6 +9841,24 @@ "listAria": "Permission modes", "autoFallback": "Auto-review is temporarily unavailable. Switched to Default permissions and will ask when needed.", "modes": { + "cursor": { + "ask": { + "label": "Default permissions", + "description": "Cursor follows its native configured permission policy. Cindy shows only the approval requests Cursor sends; workspace edits may run without a prompt." + }, + "default": { + "label": "Default permissions", + "description": "Cursor follows its native configured permission policy. Cindy shows only the approval requests Cursor sends; workspace edits may run without a prompt." + }, + "auto": { + "label": "Auto-review", + "description": "Cindy automatically reviews approval requests from Cursor and asks you when needed. Cursor’s native permission policy still applies." + }, + "bypassPermissions": { + "label": "Full access", + "description": "Cindy allows approval requests from Cursor without another confirmation. Highest risk; Cursor’s native permission policy still applies." + } + }, "claude-code": { "ask": { "label": "Default permissions", @@ -10046,6 +10075,21 @@ "permissionSwitchFailed": "Permission mode change failed; the previous setting was kept", "retryPluginWriteAccess": "Confirm plugin write permission again", "fullAccessConfirmation": { + "cursor": { + "description": "Cindy will directly allow approval requests from Cursor without asking for each one:", + "items": { + "files": { + "description": "Directly allow file read and modification requests from Cursor" + }, + "commands": { + "description": "Directly allow terminal command requests from Cursor" + }, + "network": { + "description": "Directly allow network operation requests from Cursor" + } + }, + "note": "Cursor’s native permission policy still applies. This choice does not change its native sandbox or persistent grants. Built-in high-risk operations still require confirmation." + }, "title": "Enable Full access?", "description": "Full access disables the workspace sandbox and skips routine approvals. Cindy can then do the following without asking:", "items": { @@ -13917,6 +13961,7 @@ "codexVoiceAuthDescription": "Voice input uses a connected ChatGPT account for GPT realtime speech recognition. The account is not connected or needs an update. Open Model Providers to connect ChatGPT?", "codexBinaryMissingTitle": "Codex component not ready", "codexBinaryMissingDescription": "The Codex component is not ready yet, so this feature cannot be used. Try again later or check the Codex connection in Settings → Providers.", + "cursorSetupDescription": "Install Cursor CLI on the computer running this task and restart Cindy. Then run cursor-agent login in its terminal and refresh the model list.", "piBinaryMissingTitle": "Pi component not ready", "piBinaryMissingDescription": "The Pi component is not ready yet, so this feature cannot be used. Try again later or check the Pi connection in Settings → Providers.", "goToSettings": "Open Settings", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 37328152153..fdf8d148db0 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -2550,6 +2550,13 @@ } }, "providers": { + "cursor": { + "localAccount": "この端末の Cursor CLI アカウント", + "setup": "公式の手順で Cursor CLI をインストールし、Cindy を再起動してください。その後、ターミナルで Cursor にログインし、モデル一覧を更新してください。", + "installGuide": "インストール手順を表示", + "modelsUnavailable": "モデルの準備ができていません", + "refreshFailed": "Cursor のモデルを取得できませんでした。CLI がインストール済みでログインしていることを確認し、再試行してください。CLI をインストールした直後は Cindy を再起動してください。" + }, "llamacpp": { "ownedElsewhere": "モデルサービスは別の Cindy インスタンスで実行中です。モデルのダウンロードやコンテキストの変更はそちらで行うか、先にそのサービスを停止してください。", "primaryPick": "第一候補 · まず試す", @@ -9785,6 +9792,10 @@ "providerGroupRunningOn": "現在 {{device}} で実行中", "providerGroupRunningHere": "現在このパソコンで実行中", "providerGroupUnassigned": "開始時にプロバイダーグループがパソコンを選び、使用量はそのパソコンの分を表示します" + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } }, "permissionSelector": { @@ -9792,6 +9803,24 @@ "listAria": "権限モード", "autoFallback": "自動レビューは一時的に利用できないため、デフォルト権限に切り替えました。必要な操作は確認します。", "modes": { + "cursor": { + "ask": { + "label": "デフォルトの権限", + "description": "Cursor はネイティブに設定された権限ポリシーに従います。Cindy は Cursor から送られた承認要求のみを表示し、ワークスペース内の編集は確認なしで実行される場合があります。" + }, + "default": { + "label": "デフォルトの権限", + "description": "Cursor はネイティブに設定された権限ポリシーに従います。Cindy は Cursor から送られた承認要求のみを表示し、ワークスペース内の編集は確認なしで実行される場合があります。" + }, + "auto": { + "label": "自動レビュー", + "description": "Cindy が Cursor からの承認要求を自動レビューし、必要な場合は確認を求めます。Cursor のネイティブ権限ポリシーは引き続き適用されます。" + }, + "bypassPermissions": { + "label": "フルアクセス", + "description": "Cindy が Cursor からの承認要求を追加確認なしで許可します。最もリスクが高いモードです。Cursor のネイティブ権限ポリシーは引き続き適用されます。" + } + }, "claude-code": { "ask": { "label": "デフォルト権限", @@ -10008,6 +10037,21 @@ "permissionSwitchFailed": "権限モードの変更に失敗したため、以前の設定を維持しました", "retryPluginWriteAccess": "プラグインの書き込み権限を再確認", "fullAccessConfirmation": { + "cursor": { + "description": "Cindy は Cursor からの承認リクエストを、個別に確認せず許可します:", + "items": { + "files": { + "description": "Cursor からのファイルの読み取り・変更リクエストを許可" + }, + "commands": { + "description": "Cursor からのターミナルコマンドのリクエストを許可" + }, + "network": { + "description": "Cursor からのネットワーク操作のリクエストを許可" + } + }, + "note": "Cursor 本来の権限ポリシーは引き続き適用されます。この選択でネイティブのサンドボックスや永続的な許可は変更されません。組み込みの高リスク操作には引き続き確認が必要です。" + }, "title": "Full access を有効にしますか?", "description": "Full access はワークスペースのサンドボックスを無効にし、通常の承認を省略します。Cindy は確認なしで次の操作ができるようになります:", "items": { @@ -13865,6 +13909,7 @@ "codexVoiceAuthDescription": "音声入力は、接続済みの ChatGPT アカウントで GPT リアルタイム音声認識を使用します。アカウントが未接続か、接続の更新が必要です。モデルプロバイダーを開いて ChatGPT に接続しますか?", "codexBinaryMissingTitle": "Codex コンポーネントの準備ができていません", "codexBinaryMissingDescription": "Codex コンポーネントの準備がまだできていないため、この機能は使用できません。しばらくしてから再試行するか、「設定 → プロバイダー」で Codex の接続状態を確認してください。", + "cursorSetupDescription": "このタスクを実行するコンピューターに Cursor CLI をインストールし、Cindy を再起動してください。その後、ターミナルで cursor-agent login を実行し、モデル一覧を更新してください。", "piBinaryMissingTitle": "Pi コンポーネントの準備ができていません", "piBinaryMissingDescription": "Pi コンポーネントの準備がまだできていないため、この機能は使用できません。しばらくしてから再試行するか、「設定 → プロバイダー」で Pi の接続状態を確認してください。", "goToSettings": "設定を開く", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 2d58992732e..cf8930b33a1 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -2550,6 +2550,13 @@ } }, "providers": { + "cursor": { + "localAccount": "이 컴퓨터의 Cursor CLI 계정", + "setup": "공식 안내에 따라 Cursor CLI를 설치한 후 Cindy를 다시 시작하세요. 터미널에서 Cursor에 로그인한 다음 모델 목록을 새로 고침하세요.", + "installGuide": "설치 안내 보기", + "modelsUnavailable": "모델이 아직 준비되지 않음", + "refreshFailed": "Cursor 모델을 읽지 못했습니다. CLI가 설치되어 있고 로그인되어 있는지 확인한 후 다시 시도하세요. CLI를 방금 설치했다면 Cindy를 먼저 다시 시작하세요." + }, "llamacpp": { "ownedElsewhere": "다른 Cindy 인스턴스에서 모델 서비스가 실행 중입니다. 해당 인스턴스에서 모델을 다운로드하거나 컨텍스트를 변경하거나, 먼저 서비스를 중지하세요.", "primaryPick": "우선 선택 · 먼저 사용", @@ -9785,6 +9792,10 @@ "providerGroupRunningOn": "현재 {{device}}에서 실행 중", "providerGroupRunningHere": "현재 이 컴퓨터에서 실행 중", "providerGroupUnassigned": "시작하면 제공자 그룹이 컴퓨터를 고르고, 사용량은 그 컴퓨터 기준으로 표시됩니다" + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } }, "permissionSelector": { @@ -9792,6 +9803,24 @@ "listAria": "권한 모드", "autoFallback": "자동 리뷰를 일시적으로 사용할 수 없어 기본 권한으로 전환했습니다. 필요한 작업은 확인을 요청합니다.", "modes": { + "cursor": { + "ask": { + "label": "기본 권한", + "description": "Cursor는 자체 설정된 권한 정책을 따릅니다. Cindy는 Cursor가 보내는 승인 요청만 표시하며, 작업 공간 내 편집은 확인 없이 실행될 수 있습니다." + }, + "default": { + "label": "기본 권한", + "description": "Cursor는 자체 설정된 권한 정책을 따릅니다. Cindy는 Cursor가 보내는 승인 요청만 표시하며, 작업 공간 내 편집은 확인 없이 실행될 수 있습니다." + }, + "auto": { + "label": "자동 리뷰", + "description": "Cindy가 Cursor의 승인 요청을 자동으로 검토하고 필요할 때 확인을 요청합니다. Cursor의 기본 권한 정책은 계속 적용됩니다." + }, + "bypassPermissions": { + "label": "전체 접근", + "description": "Cindy가 추가 확인 없이 Cursor의 승인 요청을 허용합니다. 가장 위험한 모드이며 Cursor의 기본 권한 정책은 계속 적용됩니다." + } + }, "claude-code": { "ask": { "label": "기본 권한", @@ -10008,6 +10037,21 @@ "permissionSwitchFailed": "권한 모드 변경에 실패해 이전 설정을 유지했습니다", "retryPluginWriteAccess": "플러그인 쓰기 권한 다시 확인", "fullAccessConfirmation": { + "cursor": { + "description": "Cindy는 Cursor가 보내는 승인 요청을 개별 확인 없이 허용합니다:", + "items": { + "files": { + "description": "Cursor의 파일 읽기 및 수정 요청을 바로 허용" + }, + "commands": { + "description": "Cursor의 터미널 명령 요청을 바로 허용" + }, + "network": { + "description": "Cursor의 네트워크 작업 요청을 바로 허용" + } + }, + "note": "Cursor의 기본 권한 정책은 계속 적용됩니다. 이 선택은 기본 샌드박스나 지속적인 권한 설정을 변경하지 않습니다. 내장 고위험 작업에는 여전히 확인이 필요합니다." + }, "title": "Full access를 활성화할까요?", "description": "Full access는 작업 공간 샌드박스를 비활성화하고 일반 승인을 건너뜁니다. Cindy는 확인 없이 다음 작업을 할 수 있습니다:", "items": { @@ -13865,6 +13909,7 @@ "codexVoiceAuthDescription": "음성 입력은 연결된 ChatGPT 계정으로 GPT 실시간 음성 인식을 사용합니다. 계정이 연결되지 않았거나 연결을 업데이트해야 합니다. 모델 제공자를 열어 ChatGPT를 연결하시겠습니까?", "codexBinaryMissingTitle": "Codex 구성 요소가 준비되지 않음", "codexBinaryMissingDescription": "Codex 구성 요소가 아직 준비되지 않아 이 기능을 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 Codex 연결 상태를 확인하세요.", + "cursorSetupDescription": "이 작업을 실행하는 컴퓨터에 Cursor CLI를 설치하고 Cindy를 다시 시작하세요. 터미널에서 cursor-agent login을 실행한 후 모델 목록을 새로 고침하세요.", "piBinaryMissingTitle": "Pi 구성 요소가 준비되지 않음", "piBinaryMissingDescription": "Pi 구성 요소가 아직 준비되지 않아 이 기능을 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 Pi 연결 상태를 확인하세요.", "goToSettings": "설정 열기", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index bd3b185cee9..89bdf5b03df 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -2550,6 +2550,13 @@ } }, "providers": { + "cursor": { + "localAccount": "使用本机 Cursor CLI 账号", + "setup": "先按官方说明安装 Cursor CLI,安装后重启 Cindy。然后在终端登录 Cursor,再刷新模型列表。", + "installGuide": "查看安装说明", + "modelsUnavailable": "模型尚未就绪", + "refreshFailed": "未能读取 Cursor 模型。请确认 CLI 已安装并登录,再重试;刚安装时请先重启 Cindy。" + }, "llamacpp": { "ownedElsewhere": "模型服务正在另一个 Cindy 实例中运行。请在那里下载模型或修改上下文,或先在该实例停止服务。", "primaryPick": "主推 · 先试这个", @@ -9785,6 +9792,10 @@ "providerGroupRunningOn": "当前在 {{device}} 上运行", "providerGroupRunningHere": "当前在这台电脑上运行", "providerGroupUnassigned": "开始后由供应商组选一台电脑,用量按那台显示" + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } }, "permissionSelector": { @@ -9792,6 +9803,24 @@ "listAria": "权限模式", "autoFallback": "自动审批暂时不可用,已切换为默认权限;需要时会询问你。", "modes": { + "cursor": { + "ask": { + "label": "默认权限", + "description": "Cursor 遵循其原生配置的权限策略。Cindy 只展示 Cursor 发来的授权请求;工作区内的编辑可能无需确认便直接执行。" + }, + "default": { + "label": "默认权限", + "description": "Cursor 遵循其原生配置的权限策略。Cindy 只展示 Cursor 发来的授权请求;工作区内的编辑可能无需确认便直接执行。" + }, + "auto": { + "label": "自动审批", + "description": "Cindy 自动审核 Cursor 发来的授权请求,必要时由你确认。Cursor 原生权限策略仍生效。" + }, + "bypassPermissions": { + "label": "完全访问", + "description": "Cindy 直接允许 Cursor 发来的授权请求,无需再次确认。风险最高;Cursor 原生权限策略仍生效。" + } + }, "claude-code": { "ask": { "label": "默认权限", @@ -10008,6 +10037,21 @@ "permissionSwitchFailed": "权限模式切换失败,已保留原设置", "retryPluginWriteAccess": "重新确认插件写权限", "fullAccessConfirmation": { + "cursor": { + "description": "Cindy 将直接允许 Cursor 发来的授权请求,不再逐项询问:", + "items": { + "files": { + "description": "直接允许 Cursor 发来的文件读取和修改请求" + }, + "commands": { + "description": "直接允许 Cursor 发来的终端命令请求" + }, + "network": { + "description": "直接允许 Cursor 发来的联网操作请求" + } + }, + "note": "Cursor 原生权限策略仍然生效。此选择不会更改原生沙箱或长期授权;内置高风险操作仍会要求确认。" + }, "title": "开启 Full access?", "description": "Full access 会关闭工作区沙箱并跳过常规审批,Cindy 无需询问即可:", "items": { @@ -13865,6 +13909,7 @@ "codexVoiceAuthDescription": "语音输入需要使用已连接的 ChatGPT 账号调用 GPT 实时语音识别。当前账号尚未连接或需要更新,是否前往「模型供应商」连接 ChatGPT?", "codexBinaryMissingTitle": "Codex 组件未就绪", "codexBinaryMissingDescription": "Codex 组件尚未准备好,暂时无法使用这个功能。请稍后重试,或前往「模型供应商」检查 Codex 连接状态。", + "cursorSetupDescription": "请在运行任务的电脑上安装 Cursor CLI 并重启 Cindy,然后在终端运行 cursor-agent login,登录后刷新模型列表。", "piBinaryMissingTitle": "Pi 组件未就绪", "piBinaryMissingDescription": "Pi 组件尚未准备好,暂时无法使用这个功能。请稍后重试,或前往「模型供应商」检查 Pi 连接状态。", "goToSettings": "前往设置", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index c3cc15d75c4..c53665ffdb9 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -2550,6 +2550,13 @@ } }, "providers": { + "cursor": { + "localAccount": "使用本機 Cursor CLI 帳號", + "setup": "先依官方說明安裝 Cursor CLI,安裝後重新啟動 Cindy。接著在終端機登入 Cursor,再重新整理模型列表。", + "installGuide": "查看安裝說明", + "modelsUnavailable": "模型尚未就緒", + "refreshFailed": "無法讀取 Cursor 模型。請確認 CLI 已安裝並登入後重試;剛安裝時請先重新啟動 Cindy。" + }, "llamacpp": { "ownedElsewhere": "模型服務正在另一個 Cindy 執行個體中執行。請在那裡下載模型或修改上下文,或先停止該執行個體的服務。", "primaryPick": "主推 · 先試這個", @@ -9785,13 +9792,35 @@ "providerGroupRunningHere": "目前在這台電腦上執行", "providerGroupUnassigned": "開始後由供應商組選一台電腦,用量依那台顯示" }, - "modelListAria": "模型清單" + "modelListAria": "模型清單", + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" + } }, "permissionSelector": { "triggerAria": "選擇權限模式:{{label}}", "listAria": "權限模式", "autoFallback": "自動審批暫時不可用,已切換為預設權限;需要時會詢問你。", "modes": { + "cursor": { + "ask": { + "label": "預設權限", + "description": "Cursor 遵循其原生設定的權限政策。Cindy 只顯示 Cursor 傳來的授權請求;工作區內的編輯可能無須確認便直接執行。" + }, + "default": { + "label": "預設權限", + "description": "Cursor 遵循其原生設定的權限政策。Cindy 只顯示 Cursor 傳來的授權請求;工作區內的編輯可能無須確認便直接執行。" + }, + "auto": { + "label": "自動審批", + "description": "Cindy 自動審核 Cursor 傳來的授權請求,必要時由你確認。Cursor 原生權限政策仍然生效。" + }, + "bypassPermissions": { + "label": "完全訪問", + "description": "Cindy 直接允許 Cursor 傳來的授權請求,無須再次確認。風險最高;Cursor 原生權限政策仍然生效。" + } + }, "claude-code": { "ask": { "label": "預設權限", @@ -10008,6 +10037,21 @@ "permissionSwitchFailed": "權限模式切換失敗,已保留原設定", "retryPluginWriteAccess": "重新確認插件寫入權限", "fullAccessConfirmation": { + "cursor": { + "description": "Cindy 將直接允許 Cursor 發來的授權請求,不再逐項詢問:", + "items": { + "files": { + "description": "直接允許 Cursor 發來的檔案讀取與修改請求" + }, + "commands": { + "description": "直接允許 Cursor 發來的終端指令請求" + }, + "network": { + "description": "直接允許 Cursor 發來的連網操作請求" + } + }, + "note": "Cursor 原生權限策略仍然生效。此選擇不會更改原生沙箱或長期授權;內建高風險操作仍會要求確認。" + }, "title": "開啟 Full access?", "description": "Full access 會關閉工作區沙箱並跳過常規審批,Cindy 無需詢問即可:", "items": { @@ -13865,6 +13909,7 @@ "codexVoiceAuthDescription": "語音輸入需要使用已連線的 ChatGPT 帳號呼叫 GPT 即時語音識別。當前帳號尚未連線或需要更新,是否前往「模型供應商」連線 ChatGPT?", "codexBinaryMissingTitle": "Codex 元件未就緒", "codexBinaryMissingDescription": "Codex 元件尚未準備好,暫時無法使用這個功能。請稍後重試,或前往「模型供應商」檢查 Codex 連線狀態。", + "cursorSetupDescription": "請在執行任務的電腦上安裝 Cursor CLI 並重新啟動 Cindy,然後在終端機執行 cursor-agent login,登入後重新整理模型列表。", "piBinaryMissingTitle": "Pi 元件未就緒", "piBinaryMissingDescription": "Pi 元件尚未準備好,暫時無法使用這個功能。請稍後重試,或前往「模型供應商」檢查 Pi 連線狀態。", "goToSettings": "前往設定", diff --git a/apps/desktop/src/renderer/lib/agentVendors.ts b/apps/desktop/src/renderer/lib/agentVendors.ts index 519d1fc0f58..65c572fae49 100644 --- a/apps/desktop/src/renderer/lib/agentVendors.ts +++ b/apps/desktop/src/renderer/lib/agentVendors.ts @@ -14,7 +14,7 @@ import type { MakerVendor } from './ccAgent.types'; -export const SELECTABLE_VENDORS = ['cc', 'codex', 'pi'] as const satisfies readonly MakerVendor[]; +export const SELECTABLE_VENDORS = ['cc', 'codex', 'pi', 'cursor'] as const satisfies readonly MakerVendor[]; export type SelectableVendor = (typeof SELECTABLE_VENDORS)[number]; diff --git a/apps/desktop/src/renderer/lib/atResourceService.ts b/apps/desktop/src/renderer/lib/atResourceService.ts index e524f1843c4..1a1e48b6b61 100644 --- a/apps/desktop/src/renderer/lib/atResourceService.ts +++ b/apps/desktop/src/renderer/lib/atResourceService.ts @@ -83,7 +83,7 @@ const EMPTY_QUERY_SECTIONS: ReadonlyArray> = [ new Set(['plugin-command']), ]; -export type PaletteAgentKind = 'claude-code' | 'codex' | 'pi'; +export type PaletteAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export interface AtResourceScanContext { /** Current local task. Its built-in browser tabs are the only tabs exposed. */ diff --git a/apps/desktop/src/renderer/lib/ccAgent.types.ts b/apps/desktop/src/renderer/lib/ccAgent.types.ts index 26f8b5c7e8f..881ebf9caeb 100644 --- a/apps/desktop/src/renderer/lib/ccAgent.types.ts +++ b/apps/desktop/src/renderer/lib/ccAgent.types.ts @@ -23,7 +23,7 @@ export type DeviceLinkConnectionStatus = 'connected' | 'disconnected'; * 暂时只有 'cc'(Claude Code)。未来扩展 'codex' 等时新增枚举值即可, * schema 不动;老 session DEFAULT 'cc' 兜底。 */ -export type AgentKind = 'cc' | 'codex' | 'pi'; +export type AgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; export type MakerVendor = AgentKind | 'orca'; export type OrcaRole = 'lead' | 'worker'; @@ -473,7 +473,7 @@ export type UsageHistorySession = Pick< >; export interface SessionRuntimeProfileProjection { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort: Effort | null; @@ -500,8 +500,8 @@ export type MessageRole = * 不作为对话正文渲染,也绝不回发给 agent(注入走 main 的 wire 前缀通道)。 */ export interface AgentSwitchContent { - fromAgentKind: 'cc' | 'codex' | 'pi'; - toAgentKind: 'cc' | 'codex' | 'pi'; + fromAgentKind: 'cc' | 'codex' | 'pi' | 'cursor'; + toAgentKind: 'cc' | 'codex' | 'pi' | 'cursor'; fromModel: string | null; toModel: string | null; /** Agent 切换时的来源快照;缺失表示旧版边界数据。 */ @@ -534,7 +534,7 @@ export interface Message { * session-agent-switch 后 session.agentKind 只代表当前活跃引擎,历史行按本字段解析; * null = 切换功能上线前的老消息(回落 session.agentKind)。 */ - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor' | null; /** Structured guard details for a persisted tool-loop terminal error. */ toolLoop?: ToolLoopErrorDetails; createdAt: string; // ISO 8601 diff --git a/apps/desktop/src/renderer/lib/customProviderRuntimeFill.ts b/apps/desktop/src/renderer/lib/customProviderRuntimeFill.ts index 37baac75a9b..ebbc92b251e 100644 --- a/apps/desktop/src/renderer/lib/customProviderRuntimeFill.ts +++ b/apps/desktop/src/renderer/lib/customProviderRuntimeFill.ts @@ -82,9 +82,7 @@ function effectiveWire(agent: RuntimeFillAgent, value: ProviderWireProtocol | un return value ?? defaultWire(agent); } -function protocolSupported(_agent: RuntimeFillAgent, _wire: ProviderWireProtocol) { - return true; -} +const protocolSupported: (agent: RuntimeFillAgent, wire: ProviderWireProtocol) => boolean = () => true; /** Pi's native provider config does not consume the shared route-only request path. */ function fieldSupported( diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 5d9afd143b1..aa779999ebf 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -685,7 +685,7 @@ export interface ChatMessage { export type AgentTaskStatus = 'running' | 'completed' | 'failed' | 'stopped'; export interface AgentTaskUpdate { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'cursor'; taskId: string; parentToolUseId?: string; status: AgentTaskStatus; @@ -2461,7 +2461,7 @@ export type MessageDeliveryMode = 'queue' | 'steer'; /** 仅影响 selector/chip 的乐观展示;agentKind 始终保留真实 reducer 路由。 */ export interface AgentSwitchIntentRecord { - target: 'claude-code' | 'codex' | 'pi'; + target: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort?: string; @@ -2482,7 +2482,7 @@ export interface SessionChatState { * Codex reducer。ensureInitialMessages 从 DB sessions.agent_kind 读出来灌进。 * 默认 'claude-code' 兼容老路径(老 session row 没有此字段时按 Claude 处理)。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 下一条消息发送时才由 main 应用的跨引擎切换意图。 */ agentSwitchIntent: AgentSwitchIntentRecord | null; /** @@ -3803,7 +3803,6 @@ function updateIslandsAfterAroundMerge( // 块完全落在主段更老一侧:与既有孤岛合并(经块连通的孤岛合并成一座)或新建一座。 let mergedOldestClientId: string | null = null; let mergedNewestClientId: string | null = null; - let mergedAny = false; const next: LoadedWindowIsland[] = []; for (const island of islands) { const oldestIdx = messageIndexByClientId(messages, island.oldestClientId); @@ -3814,7 +3813,6 @@ function updateIslandsAfterAroundMerge( next.push(island); continue; } - mergedAny = true; if (mergedOldestClientId === null || oldestIdx < blockStart) { mergedOldestClientId = island.oldestClientId; } @@ -4140,8 +4138,7 @@ function persistTurnErrorDeferredTracked( ): void { // 必须在 live error 已经 setState 之后调用,这样抓到的是这一代横幅的 epoch。 const epoch = _liveErrorEpoch.get(sessionId) ?? 0; - let pending: Promise; - pending = makerApiFor(sessionId) + const pending: Promise = makerApiFor(sessionId) .input.persistTurnErrorDeferred(sessionId, errData, agentMeta) .then((persistId) => { const id = typeof persistId === 'string' && persistId ? persistId : undefined; @@ -5570,7 +5567,7 @@ export function handleStreamEvent( : null; const isCodexReconnectProgress = event.type === 'error' && - (event.source === 'codex' || event.source === 'pi') && + (event.source === 'codex' || event.source === 'pi' || event.source === 'cursor') && !isTerminalErrorData(event.data) && reconnectAttempt !== null && !isCodexUserActionableRetryError(event.data); @@ -7304,7 +7301,7 @@ type MakerEventPayload = { event?: { type: string; data: unknown; - source?: 'claude-code' | 'codex' | 'pi' | 'vision-bridge'; + source?: 'claude-code' | 'codex' | 'pi' | 'cursor' | 'vision-bridge'; agentMeta?: Record; turnContinuationId?: number; turnScope?: 'turn' | 'background'; @@ -7356,7 +7353,7 @@ type PendingTextDeltaBatch = { text: string; dataOwner: DataOwnerGeneration; ingress: LiveIngressContext; - source?: 'claude-code' | 'codex' | 'pi' | 'vision-bridge'; + source?: 'claude-code' | 'codex' | 'pi' | 'cursor' | 'vision-bridge'; persistId?: string; agentMeta?: Record; }; @@ -10091,7 +10088,7 @@ setRemoteTerminalErrorProbe(hasSessionTerminalError); interface ActiveSessionSnapshot { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; isTurnRunning: boolean; } @@ -10141,7 +10138,7 @@ function isActiveSessionSnapshot(value: unknown): value is ActiveSessionSnapshot const item = value as Record; return ( typeof item.sessionId === 'string' && - (item.agentKind === 'claude-code' || item.agentKind === 'codex' || item.agentKind === 'pi') && + (item.agentKind === 'claude-code' || item.agentKind === 'codex' || item.agentKind === 'pi' || item.agentKind === 'cursor') && typeof item.isTurnRunning === 'boolean' ); } @@ -11126,8 +11123,8 @@ async function pumpRemoteOptimisticSends(sessionId: string): Promise { if (existing) return existing; // Self-reference is intentional: a detached clear/owner generation must not // keep draining after a newer pump replaces this Promise in the registry. - // eslint-disable-next-line prefer-const let run!: Promise; + // eslint-disable-next-line prefer-const -- The pump reads its identity after awaited preparation. run = (async () => { while (true) { const record = firstUnacceptedRemoteOptimisticSend(sessionId); @@ -11286,16 +11283,17 @@ function retryInvalidatedInitialHistoryFetchIfNeeded( } /** - * DB sessions.agent_kind('cc' / 'codex' / 'pi')→ maker-core AgentKind 的唯一映射点。 + * DB sessions.agent_kind('cc' / 'codex' / 'pi' / 'cursor')→ maker-core AgentKind 的唯一映射点。 * 缺失 / 异常值走 fallback(默认 'claude-code',老 row 兼容)。所有从 session * row 派生 agentKind 的地方必须走这里,不要在调用点手写三元(历史上多处各写 * 一份,遗漏 fallback 语义差异被 review 逐个揪出)。 */ function dbAgentKindToMakerKind( dbKind: string | null | undefined, - fallback: 'claude-code' | 'codex' | 'pi' = 'claude-code', -): 'claude-code' | 'codex' | 'pi' { + fallback: 'claude-code' | 'codex' | 'pi' | 'cursor' = 'claude-code', +): 'claude-code' | 'codex' | 'pi' | 'cursor' { if (dbKind === 'codex') return 'codex'; + if (dbKind === 'cursor') return 'cursor'; if (dbKind === 'cc') return 'claude-code'; if (dbKind === 'pi') return 'pi'; return fallback; @@ -13878,7 +13876,7 @@ export function buildCreateOptsForCurrentSession( agentKind: current.agentKind, workingDir, model, - effort, + ...(effort ? { effort } : {}), permissionMode, fastMode: current.fastMode, planMode: current.planModeEnabled, @@ -14473,7 +14471,7 @@ function autoTitleFallbackLabels(): AutoTitleFallbackLabels { function scheduleAutoName( sessionId: string, text: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', isUserText = true, ): void { // 与 main 共用 normalizeAutoTitle,两端算出的占位串逐字一致,回流时不跳变。 @@ -14589,7 +14587,7 @@ function clearAutoTitlePreviewSafely(sessionId: string): void { function maybeAutoNameUnnamedSession( sessionId: string, seed: AutoTitleSeed | null, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', ): void { if (!seed?.isUserText) return; if (sessions.get(sessionId)?.autoTitleDisabled === true) return; @@ -14989,7 +14987,7 @@ async function sendMessageCore( // 用会话真实 agentKind 起名 — 之前写死 'claude-code',导致 Codex 会话也 // 用 Claude haiku 起标题:纯 Codex 用户(无 Claude 鉴权)会 oneShot 失败 → // fallback 原话,表现为"Codex 会话标题没有智能总结"。current.agentKind 已是 - // maker 格式('claude-code' | 'codex' | 'pi'),直接透传。起名走立即占位 + 后台覆盖。 + // maker 格式('claude-code' | 'codex' | 'pi' | 'cursor'),直接透传。起名走立即占位 + 后台覆盖。 if (autoTitleSeed) { scheduleAutoName( sessionId, @@ -17303,7 +17301,7 @@ function sendUiTrigger(sessionId: string, prompt: string): Promise { * sdkSessionId——否则 buildCreateOpts 会把旧引擎的原生会话 id 当 resume 目标 * (main 侧 reconcileCreateOptsWithDb 是兜底,这里是第一现场收敛)。 */ -function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' | 'pi'): void { +function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'): void { if (!sessionId) return; setState(sessionId, (s) => { const nextProviderId = s.agentSwitchIntent ? s.agentSwitchIntent.providerId : s.sessionProviderId; @@ -17335,7 +17333,7 @@ function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' */ function noteAgentSwitchIntent( sessionId: string, - target: 'claude-code' | 'codex' | 'pi', + target: 'claude-code' | 'codex' | 'pi' | 'cursor', opts: { model: string; providerId: string | null; @@ -17394,7 +17392,7 @@ function normalizeAgentSwitchIntent(value: unknown): AgentSwitchIntentRecord | n if ( item.targetAgentKind !== 'claude-code' && item.targetAgentKind !== 'codex' - && item.targetAgentKind !== 'pi' + && item.targetAgentKind !== 'pi' && item.targetAgentKind !== 'cursor' ) return null; if (typeof item.model !== 'string' || item.model.length === 0) return null; // providerId 缺失按 null(与 main projectPendingAgentSwitchIntent 的 `?? null` 对齐); @@ -17459,7 +17457,7 @@ function mirrorAgentSwitchIntent(sessionId: string, value: unknown): void { function setSessionRuntime( sessionId: string, opts: { - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; fastMode?: boolean; planModeEnabled?: boolean; /** Seed before SessionView hydrates the DB row; sendMessage reads this for SSH routing. */ @@ -17564,7 +17562,7 @@ function mirrorSessionFields( // 新引擎的事件会被旧引擎 reducer 错误处理(2026-07-20 审计实锤)。随引擎翻转 // 同步清 sdkSessionId(旧引擎的原生会话 id 对新引擎无意义,与 noteAgentSwitched // 口径一致)。幂等:发起窗口已 noteAgentSwitched → 同值 no-op。 - if (patch.agentKind === 'cc' || patch.agentKind === 'codex' || patch.agentKind === 'pi') { + if (patch.agentKind === 'cc' || patch.agentKind === 'codex' || patch.agentKind === 'pi' || patch.agentKind === 'cursor') { const nextKind = dbToMakerAgentKind(patch.agentKind); setState(sessionId, (s) => { // New hosts publish the full runtime snapshot before explicitly clearing @@ -17841,7 +17839,7 @@ export const makerChatStore = { taskType?: string; toolUseId?: string; title?: string; - provider?: 'pi' | 'claude-code'; + provider?: 'pi' | 'claude-code' | 'cursor'; }>, opts?: { staleRunningCandidates?: ReadonlySet; @@ -17860,7 +17858,7 @@ export const makerChatStore = { next.taskUpdates?.has(t.taskId) || (t.toolUseId ? next.taskUpdates?.has(t.toolUseId) : false); if (seen) continue; - const provider = t.provider === 'pi' ? 'pi' : 'claude-code'; + const provider = t.provider === 'cursor' ? 'cursor' : t.provider === 'pi' ? 'pi' : 'claude-code'; next = handleStreamEvent(next, { sessionId, type: 'agent_task_update', diff --git a/apps/desktop/src/renderer/lib/makerTransport.ts b/apps/desktop/src/renderer/lib/makerTransport.ts index 9338d65b919..10428bbb4f6 100644 --- a/apps/desktop/src/renderer/lib/makerTransport.ts +++ b/apps/desktop/src/renderer/lib/makerTransport.ts @@ -285,7 +285,7 @@ export function makerApiForDevice(deviceId: string): RoutableMaker { /** Mutation 前按明确 deviceId 重新读取被控端能力,避免复用可能过期的 renderer cache。 */ export function agentCapabilitiesForDevice( deviceId: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', ): Promise<{ supportsOrcaWorkerPermissionMode?: boolean; supportsDeferredOrcaUiAssignment?: boolean; diff --git a/apps/desktop/src/renderer/lib/modelDefinitions.ts b/apps/desktop/src/renderer/lib/modelDefinitions.ts index e74d211799e..27030cb2237 100644 --- a/apps/desktop/src/renderer/lib/modelDefinitions.ts +++ b/apps/desktop/src/renderer/lib/modelDefinitions.ts @@ -17,7 +17,7 @@ export interface ModelDefinition { description: string; efforts: readonly Effort[]; defaultEffort: Effort | null; - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'; contextWindow?: number; supportsFastMode?: boolean; /** 目录展示排序;缺省排末尾(见 getDefaultModelForVendor)。 */ @@ -29,10 +29,10 @@ export interface ModelDefinition { * 解耦)。缺省 = 不作为默认。getDefaultModelForVendor / newSessionDefaultModelId 据它选默认; * Pi 只接受自己的 v3 标记,不借用其它 Agent 的默认策略。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'cursor')[]; } -function toLegacy(m: ModelDescriptor, vendorKey: 'cc' | 'codex' | 'pi'): ModelDefinition { +function toLegacy(m: ModelDescriptor, vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'): ModelDefinition { return { id: m.id, label: m.displayName, @@ -104,12 +104,12 @@ export function getModelById(modelId: string, deviceId?: string): ModelDefinitio } /** vendor → capabilities 缓存的 agent 键(pi 有自己的能力清单)。 */ -function agentKindForVendor(vendorKey: 'cc' | 'codex' | 'pi'): 'claude-code' | 'codex' | 'pi' { - return vendorKey === 'codex' ? 'codex' : vendorKey === 'pi' ? 'pi' : 'claude-code'; +function agentKindForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'): 'claude-code' | 'codex' | 'pi' | 'cursor' { + return vendorKey === 'codex' ? 'codex' : vendorKey === 'cursor' ? 'cursor' : vendorKey === 'pi' ? 'pi' : 'claude-code'; } export function getModelsForVendor( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor', deviceId?: string, ): readonly ModelDefinition[] { // 直接读该 vendor 对应 agent 的能力缓存 —— 不经 allCachedModels(后者只聚合 cc/codex 供 @@ -138,8 +138,8 @@ function firstByCatalogOrder(models: readonly ModelDefinition[]): ModelDefinitio /** vendor → 新对话默认所依据的目录 Agent 标记。 */ function defaultMarkerAgentForVendor( - vendorKey: 'cc' | 'codex' | 'pi', -): 'claude-code' | 'codex' | 'pi' { + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor', +): 'claude-code' | 'codex' | 'pi' | 'cursor' { if (vendorKey === 'cc') return 'claude-code'; return vendorKey; } @@ -155,7 +155,7 @@ function defaultMarkerAgentForVendor( * 本函数返回 null、默认行为不变。 */ export function newSessionDefaultModelId( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor', deviceId?: string, ): string | null { const agent = defaultMarkerAgentForVendor(vendorKey); @@ -177,7 +177,7 @@ export function newSessionDefaultModelId( * 入口复用校准后的结果(resolveNewTaskDefaultRoute),不直接读这里的种子。 */ export function getDefaultModelForVendor( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor', deviceId?: string, ): ModelDefinition { const list = getModelsForVendor(vendorKey, deviceId); @@ -210,7 +210,8 @@ const COLD_START_CODEX_MODEL_ID = 'gpt-5.6-sol'; const COLD_START_PI_MODEL_ID = 'claude-sonnet-5'; /** 冷启动占位 id 的只读导出(newMakerDraft 的种子默认复用,避免另一处写死)。 */ -export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi'): string { +export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'): string { + if (vendorKey === 'cursor') return ''; return vendorKey === 'codex' ? COLD_START_CODEX_MODEL_ID : vendorKey === 'pi' @@ -219,6 +220,7 @@ export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi'): str } /** 冷启动占位的展示名(与占位 id 同源,仅首帧短暂可见)。 */ -function coldStartLabelForVendor(vendorKey: 'cc' | 'codex' | 'pi'): string { +function coldStartLabelForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'): string { + if (vendorKey === 'cursor') return 'Cursor'; return vendorKey === 'codex' ? 'GPT-5.6-Sol' : vendorKey === 'pi' ? 'Sonnet 5' : 'Opus 5'; } diff --git a/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts b/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts index 5bd131c3490..3bdc611d954 100644 --- a/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts +++ b/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts @@ -5,4 +5,5 @@ export const MODEL_HARNESS_COLOR: Readonly> = { 'claude-code': 'var(--engine-badge-cc)', codex: 'var(--engine-badge-codex)', pi: 'var(--engine-badge-pi)', + cursor: 'var(--text-primary)', }; diff --git a/apps/desktop/src/renderer/lib/providerModelFetch.ts b/apps/desktop/src/renderer/lib/providerModelFetch.ts index 6200ecc1e40..e0e166970b2 100644 --- a/apps/desktop/src/renderer/lib/providerModelFetch.ts +++ b/apps/desktop/src/renderer/lib/providerModelFetch.ts @@ -34,7 +34,7 @@ export function firstProviderChatModel; +type ProviderProbeAgent = Extract; export interface ProviderConnectionProbeRoute { api?: PiModelApi; diff --git a/apps/desktop/src/renderer/lib/providerModels.ts b/apps/desktop/src/renderer/lib/providerModels.ts index ccb23176adc..bde0ddea6aa 100644 --- a/apps/desktop/src/renderer/lib/providerModels.ts +++ b/apps/desktop/src/renderer/lib/providerModels.ts @@ -228,6 +228,7 @@ export function selectVisibleModels(params: { deviceCcModels: ModelDescriptor[]; deviceCodexModels: ModelDescriptor[]; devicePiModels?: ModelDescriptor[]; + deviceCursorModels?: ModelDescriptor[]; /** * SSH 远程会话(remoteHostId)传 true:订阅直连模型(chatgpt/ / xai/)不再被过滤, * 而是保留在清单中由调用方按 isSubscriptionDirectModel 标记禁用(置灰 + 原因提示)。 @@ -253,6 +254,7 @@ export function selectVisibleModels(params: { deviceCcModels, deviceCodexModels, devicePiModels = [], + deviceCursorModels = [], excludeSubscriptionDirect, excludeChatBridgedCodex, } = params; @@ -271,12 +273,14 @@ export function selectVisibleModels(params: { model.id.startsWith('chatgpt/') && model.id.endsWith('[1m]') )); + const cursor = excludeSubscriptionDirect ? [] : pass(deviceId ? deviceCursorModels : deriveModelsFromProviders(providers, 'cursor')); + if (agentKind === 'cursor') return cursor; if (agentKind === 'claude-code') return cc; if (agentKind === 'codex') return codex; if (agentKind === 'pi') return pi; const merged = [...cc]; const seen = new Set(merged.map((m) => m.id)); - for (const list of [codex, pi]) { + for (const list of [codex, pi, cursor]) { for (const m of list) { if (seen.has(m.id)) continue; seen.add(m.id); @@ -297,12 +301,14 @@ export function resolveVisibleModelAgentKind(params: { ccModels: ModelDescriptor[]; codexModels: ModelDescriptor[]; piModels?: ModelDescriptor[]; + cursorModels?: ModelDescriptor[]; providers: ProviderView[]; }): AgentKind | null { - const { modelId, agentKind, ccModels, codexModels, piModels = [], providers } = params; + const { modelId, agentKind, ccModels, codexModels, piModels = [], cursorModels = [], providers } = params; if (agentKind) return agentKind; if (ccModels.some((model) => model.id === modelId)) return 'claude-code'; if (codexModels.some((model) => model.id === modelId)) return 'codex'; + if (cursorModels.some((model) => model.id === modelId)) return 'cursor'; if (piModels.some((model) => model.id === modelId)) return 'pi'; if (providers.some((provider) => providerOffersModel(provider, modelId, 'claude-code'))) { return 'claude-code'; diff --git a/apps/desktop/src/renderer/lib/providerSubtitle.ts b/apps/desktop/src/renderer/lib/providerSubtitle.ts index fe183a4215b..fa9a496a93a 100644 --- a/apps/desktop/src/renderer/lib/providerSubtitle.ts +++ b/apps/desktop/src/renderer/lib/providerSubtitle.ts @@ -4,6 +4,7 @@ const AGENT_DISPLAY_LABELS: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; export function providerAgentSupportLabel(provider?: Pick | null): string { diff --git a/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts b/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts index d72c1102fa8..15375702461 100644 --- a/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts +++ b/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts @@ -16,7 +16,7 @@ import { toast } from './toast'; import type { ProviderErrorCode } from '../../shared/providerErrors'; interface ProviderUpstreamErrorPayload { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string; providerName?: string; code: ProviderErrorCode; diff --git a/apps/desktop/src/renderer/lib/sessionService.ts b/apps/desktop/src/renderer/lib/sessionService.ts index 92d9a5c2969..18afa51bffb 100644 --- a/apps/desktop/src/renderer/lib/sessionService.ts +++ b/apps/desktop/src/renderer/lib/sessionService.ts @@ -105,7 +105,7 @@ export async function create(body?: { fastMode?: boolean; /** 计划模式一级开关(与 permissionMode 正交); 草稿开着计划模式时随建会话落库。 */ planModeEnabled?: boolean; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; orcaRole?: OrcaRole | null; /** 附加只读引用目录列表 (绝对路径); main 端 mapper 会 JSON.stringify 后写库。 */ extraDirs?: string[]; @@ -130,7 +130,10 @@ export async function create(body?: { */ source?: 'cindy-make'; }): Promise { - return wrap(window.electronAPI.localDb.sessions.create(body)); + const nativeBody = body?.agentKind === 'cursor' + ? { ...body, effort: undefined, permissionMode: 'ask', fastMode: false } + : body; + return wrap(window.electronAPI.localDb.sessions.create(nativeBody)); } export function get(id: string): Promise { diff --git a/apps/desktop/src/renderer/state/modelEnginePrefs.ts b/apps/desktop/src/renderer/state/modelEnginePrefs.ts index 918d14cc6b0..9796bc1ab67 100644 --- a/apps/desktop/src/renderer/state/modelEnginePrefs.ts +++ b/apps/desktop/src/renderer/state/modelEnginePrefs.ts @@ -19,7 +19,7 @@ * modelChosenByVendor / providerModelMemory 的 `:*` 深度+Fast 槽 / * modelVisibilityPrefs)。老用户空表即「全部跟随推荐」,旧数据不搬不猜。 * - * 为什么 agent 用 'cc' | 'codex' | 'pi'(SelectableVendor)而不是 useAgentCapabilities + * 为什么 agent 用 'cc' | 'codex' | 'pi' | 'cursor'(SelectableVendor)而不是 useAgentCapabilities * 的 AgentKind('claude-code' | ...): * 本 store 的下游是**选择器 → newMakerDraft** 那条链路 —— 选中确定后要派生 * `(vendor, model, effort, fastMode, providerId)` 写进 newMakerDraft(规格 §2.4),而 diff --git a/apps/desktop/src/renderer/state/modelFavorites.ts b/apps/desktop/src/renderer/state/modelFavorites.ts index fbca19fdccf..dd2bef0b007 100644 --- a/apps/desktop/src/renderer/state/modelFavorites.ts +++ b/apps/desktop/src/renderer/state/modelFavorites.ts @@ -19,7 +19,7 @@ import { parseModelFavoriteMutation, sameModelFavorite, type ModelFavoriteMutati * 认不出,反之亦然)。这里只收 EFFORT_VALUES 里的 canonical key,非法值**丢字段** * (不是丢整条),调用层看到 effort === undefined 就回落该 (模型, 引擎) 的推荐档。 * - * 为什么 agent 用 'cc' | 'codex' | 'pi':与 modelEnginePrefs 同一理由(下游是选择器 → + * 为什么 agent 用 'cc' | 'codex' | 'pi' | 'cursor':与 modelEnginePrefs 同一理由(下游是选择器 → * newMakerDraft 的 vendor 口径,规格 §2.4),详见那个文件的文件头。 * * 只存用户显式动作的产物(configuration-and-overrides §2),唯一例外是**种子收藏** diff --git a/apps/desktop/src/renderer/state/newMakerDraft.ts b/apps/desktop/src/renderer/state/newMakerDraft.ts index ea852f97905..e56379a156b 100644 --- a/apps/desktop/src/renderer/state/newMakerDraft.ts +++ b/apps/desktop/src/renderer/state/newMakerDraft.ts @@ -20,8 +20,7 @@ import { useSyncExternalStore } from 'react'; import { isDataOwnerPushStampCurrent } from '@/contexts/dataOwnerGeneration'; -import { sameModelRoute, type AppDefaultModelSelection } from '../../shared/appDefaultModelSelection'; -import type { BotModelRoute } from '../../shared/botModelChain'; +import { sameModelRoute, type AppDefaultModelSelection, type AppModelRoute } from '../../shared/appDefaultModelSelection'; import type { MakerVendor } from '@/lib/ccAgent.types'; import { isSelectableVendor } from '@/lib/agentVendors'; @@ -99,7 +98,7 @@ export interface CollabWorkerConfig { export interface CollabDraft { enabled: boolean; - worker: 'cc' | 'codex' | 'pi'; + worker: 'cc' | 'codex' | 'pi' | 'cursor'; workerConfig?: CollabWorkerConfig; } @@ -199,6 +198,7 @@ export interface NewMakerDraft { * 在目录里都是默认隐藏的模型 —— 种子默认模型压根不在用户看到的清单里。 */ function defaultVendorPrefs(vendor: MakerVendor): VendorPrefs { + if (vendor === 'cursor') return { model: getDefaultModelForVendor('cursor').id, effort: 'medium', permissionMode: 'ask', planMode: false, providerId: 'cursor' }; if (vendor === 'pi') { return { // pi 走 XD 网关(anthropic-messages 可达面),默认给网关中档模型; @@ -256,6 +256,7 @@ function makeDefault(): NewMakerDraft { lastByVendor: { cc: defaultVendorPrefs('cc'), pi: defaultVendorPrefs('pi'), + cursor: defaultVendorPrefs('cursor'), orca: defaultVendorPrefs('orca'), codex: defaultVendorPrefs('codex'), }, @@ -289,7 +290,7 @@ function sanitize(raw: unknown): NewMakerDraft { if (!raw || typeof raw !== 'object') return def; const r = raw as Partial; // 引擎白名单按 SELECTABLE_VENDORS(选择器同一张表的来源)校验 —— 新增引擎时这里零改动。 - // 曾经是逐个写死的三元(`r.vendor === 'codex' || r.vendor === 'pi' ? … : 'cc'`), + // 曾经是逐个写死的三元(`r.vendor === 'codex' || r.vendor === 'pi' || r.vendor === 'cursor' ? … : 'cc'`), // 每上线一个引擎都得手工补一次;漏补则用户选中新引擎、重启后被静默重置回 Claude。 // F-COLLAB (2026-05): 'orca' 不在表内,历史 localStorage 残留会走同一条回退路径 // 迁到 'cc'(它已被 ChatInput 底部的协同 toggle 取代),避免空白入口。 @@ -331,7 +332,7 @@ function sanitize(raw: unknown): NewMakerDraft { // collab 校验: 老版本无此字段 → 默认 OFF + codex worker。 const collabRaw = (r as { collab?: Partial }).collab; const collabWorker: CollabDraft['worker'] = - collabRaw?.worker === 'cc' ? 'cc' : collabRaw?.worker === 'pi' ? 'pi' : 'codex'; + collabRaw?.worker === 'cc' ? 'cc' : collabRaw?.worker === 'cursor' ? 'cursor' : collabRaw?.worker === 'pi' ? 'pi' : 'codex'; // remote 项目的协同 codex / cc draft 均放行:worker 创建已继承 remoteHostId // (在同一台远端主机 spawn,见 OrcaLeadSessionSnapshot.remoteHostId),两端 // 远端 MCP 注入均已落地 (codex daemon config + cc per-query http 注入)。 @@ -392,7 +393,7 @@ function sanitize(raw: unknown): NewMakerDraft { ? (r.modelChosenByVendor as Record) : {}; const modelChosenByVendor: Partial> = {}; - for (const v of ['cc', 'orca', 'codex', 'pi'] as const) { + for (const v of ['cc', 'orca', 'codex', 'pi', 'cursor'] as const) { if (modelChosenRaw[v] === true) modelChosenByVendor[v] = true; } // 老版本没有独立的组合标记:显式选过模型/来源/思考深度/Fast 都是足够强的 @@ -423,7 +424,7 @@ function sanitize(raw: unknown): NewMakerDraft { const legacyCcModel = legacyCcModelCandidate && (r.defaultTupleCustomized === undefined || !isKnownProductTuple('cc', legacyCcPrefs)); - const legacySourceSelection = (['cc', 'orca', 'codex', 'pi'] as const).some((slotVendor) => { + const legacySourceSelection = (['cc', 'orca', 'codex', 'pi', 'cursor'] as const).some((slotVendor) => { const prefs = lastByVendorRaw[slotVendor]; if ( !prefs || @@ -486,6 +487,7 @@ function sanitize(raw: unknown): NewMakerDraft { lastByVendor: { cc: sanitizeVendorPrefs(lastByVendorRaw.cc, 'cc'), pi: sanitizeVendorPrefs(lastByVendorRaw.pi, 'pi'), + cursor: sanitizeVendorPrefs(lastByVendorRaw.cursor, 'cursor'), orca: sanitizeVendorPrefs(lastByVendorRaw.orca, 'orca'), codex: sanitizeVendorPrefs(lastByVendorRaw.codex, 'codex'), }, @@ -961,7 +963,7 @@ export function applyAppDefaultModelSelection(selection: AppDefaultModelSelectio const stored = readStoredDraftRecord(); const base = stored ? sanitize(stored) : currentDraft; const prefs = base.lastByVendor[base.vendor]; - const current: BotModelRoute | null = prefs.model ? { + const current: AppModelRoute | null = prefs.model ? { harness: base.vendor === 'cc' || base.vendor === 'orca' ? 'claude' : base.vendor, model: prefs.model, providerId: prefs.providerId ?? null, effort: prefs.effort ?? '', fastMode: base.fastModeByModel[prefs.model] === true, @@ -1007,7 +1009,7 @@ export function fallbackUnavailableVendor(availableVendors: ReadonlySet; + vendor: Extract; providerId: string; model: string; effort?: Effort | null; diff --git a/apps/desktop/src/renderer/state/workerCreationPrefs.ts b/apps/desktop/src/renderer/state/workerCreationPrefs.ts index 5cf6d43be42..d984b989bed 100644 --- a/apps/desktop/src/renderer/state/workerCreationPrefs.ts +++ b/apps/desktop/src/renderer/state/workerCreationPrefs.ts @@ -14,7 +14,7 @@ import { const STORAGE_KEY = 'workerCreationPrefs'; const CHANGE_EVENT = 'cindy:worker-creation-prefs-changed'; -export type WorkerAgentKind = 'codex' | 'claude-code' | 'pi'; +export type WorkerAgentKind = 'codex' | 'claude-code' | 'pi' | 'cursor'; export interface WorkerAgentPrefs { model: string; @@ -29,6 +29,7 @@ export interface WorkerCreationPrefs { codex: WorkerAgentPrefs; 'claude-code': WorkerAgentPrefs; pi: WorkerAgentPrefs; + cursor: WorkerAgentPrefs; /** 新 Worker 的默认权限;UI 与 Orca tool 共用。 */ workerPermissionMode: OrcaWorkerPermissionMode; } @@ -38,6 +39,7 @@ export const DEFAULT_WORKER_CREATION_PREFS: WorkerCreationPrefs = { lastAgent: DEFAULT_ORCA_WORKER_AGENT, codex: { model: DEFAULT_ORCA_WORKER_MODELS.codex, effort: DEFAULT_ORCA_WORKER_EFFORT as Effort, fast: false, providerId: null }, 'claude-code': { model: DEFAULT_ORCA_WORKER_MODELS['claude-code'], effort: DEFAULT_ORCA_WORKER_EFFORT as Effort, fast: false, providerId: null }, + cursor: { model: DEFAULT_ORCA_WORKER_MODELS.cursor, effort: 'medium', fast: false, providerId: 'cursor' }, pi: { model: DEFAULT_ORCA_WORKER_MODELS.pi, effort: DEFAULT_ORCA_WORKER_EFFORT as Effort, fast: false, providerId: null }, workerPermissionMode: DEFAULT_ORCA_WORKER_PERMISSION_MODE, }; @@ -48,6 +50,7 @@ function defaultPrefs(): WorkerCreationPrefs { codex: { ...DEFAULT_WORKER_CREATION_PREFS.codex }, 'claude-code': { ...DEFAULT_WORKER_CREATION_PREFS['claude-code'] }, pi: { ...DEFAULT_WORKER_CREATION_PREFS.pi }, + cursor: { ...DEFAULT_WORKER_CREATION_PREFS.cursor }, }; } @@ -73,12 +76,13 @@ export function readWorkerCreationPrefs(): WorkerCreationPrefs { lastAgent: parsed.lastAgent === 'claude-code' ? 'claude-code' - : parsed.lastAgent === 'pi' + : parsed.lastAgent === 'cursor' ? 'cursor' : parsed.lastAgent === 'pi' ? 'pi' : 'codex', codex: agentPrefs('codex'), 'claude-code': agentPrefs('claude-code'), pi: agentPrefs('pi'), + cursor: agentPrefs('cursor'), workerPermissionMode: resolveOrcaWorkerPermissionMode(parsed.workerPermissionMode), }; } catch { diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index f1f147440eb..96760f0a4fb 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -161,7 +161,7 @@ interface DeviceLinkPresenceSnapshot { /** .cshare 导入向导的预览数据(main 侧 SharePreview 的镜像)。 */ interface SessionSharePreview { title: string; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; workspaceKind: 'project' | 'dialogue'; originalWorkingDir: string | null; exportedAt: string; @@ -530,7 +530,7 @@ interface WechatChannelSettingsState { type DiscordBotSessionAuthCheckResult = { ok: boolean; missing: 'gateway-key' | 'agent-oauth' | 'provider-key' | 'provider-disconnected' | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; providerLabel: string | null; @@ -609,7 +609,7 @@ interface OrcaTeamRecord { id: string; leadSessionId: string; status: 'active' | 'completed' | 'cancelled' | 'failed'; - workerPermissionMode: 'auto' | 'bypassPermissions'; + workerPermissionMode: 'ask' | 'auto' | 'bypassPermissions'; completedAt: string | null; createdAt: string; updatedAt: string; @@ -631,7 +631,7 @@ interface OrcaWorkerRecord { session: { id: string; title: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; effort: string; @@ -721,7 +721,7 @@ interface CCAgentStreamEvent { | 'thinking' | 'compact_boundary'; data: unknown; - source?: 'claude-code' | 'codex' | 'pi' | 'vision-bridge'; + source?: 'claude-code' | 'codex' | 'pi' | 'cursor' | 'vision-bridge'; /** * agent-meta: SDK 元信息(按 session.agentKind 解析)。当事件来自一条 SDK * message(assistant / tool_use / thinking final / done 等)时由 main 透传过来。 @@ -2426,15 +2426,15 @@ interface ElectronAPI { syncNewMakerDraft: (snapshot: { appDefaultModelRequestId?: string; ownerStamp: import('../shared/dataOwnerPush').DataOwnerPushStamp; - selectedRoute?: import('../shared/botModelChain').BotModelRoute; + selectedRoute?: import('../shared/appDefaultModelSelection').AppModelRoute; lastByVendor: Partial< Record< - 'cc' | 'codex' | 'pi', + 'cc' | 'codex' | 'pi' | 'cursor', { model?: string; effort?: string; permissionMode?: string; providerId?: string | null } > >; /** 每个 vendor 是否由用户在 New Maker 中明确选过模型;device-link 默认校准据此保护显式选择。 */ - modelChosenByVendor: Partial>; + modelChosenByVendor: Partial>; fastModeByModel: Record; effortByModel: Record; providerModelMemory?: Record< @@ -2450,7 +2450,7 @@ interface ElectronAPI { /** Renderer localStorage workerCreationPrefs → main 内存镜像。 */ syncWorkerCreationPrefs: (snapshot: { - workerPermissionMode: 'auto' | 'bypassPermissions'; + workerPermissionMode: 'ask' | 'auto' | 'bypassPermissions'; }) => void; /** 被控端 renderer → 自身 main:providerModelMemory 全量快照镜像(草稿列表行真实读源)。 */ @@ -2464,7 +2464,7 @@ interface ElectronAPI { /** 被控端 renderer → 自身 main:会话「非选中模型」effort/fast 变化镜像(转发给控制端)。 */ syncSessionModelPref: (pref: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string; model: string; effort?: string; @@ -2475,7 +2475,7 @@ interface ElectronAPI { onMakerDraftPrefApply: ( cb: (payload: { appDefaultSelection?: import('../shared/appDefaultModelSelection').AppDefaultModelSelection; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string; modelId: string; active: boolean; @@ -2507,14 +2507,14 @@ interface ElectronAPI { /** Orca tool 显式修改 Worker 默认权限后,回写 renderer localStorage。 */ onWorkerCreationPrefsApply: ( - cb: (payload: { workerPermissionMode: 'auto' | 'bypassPermissions' }) => void, + cb: (payload: { workerPermissionMode: 'ask' | 'auto' | 'bypassPermissions' }) => void, ) => () => void; /** 被控端本地 main → 自身 renderer:控制端写穿的会话「模型 effort/fast」pref(调本地 setter)。 */ onMakerSessionPrefApply: ( cb: (payload: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string; model: string; effort?: string; @@ -3276,7 +3276,7 @@ interface ElectronAPI { workingDir: string; cap?: number; query?: string; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; }) => Promise<{ success: boolean; error?: string; @@ -4716,7 +4716,7 @@ interface ElectronAPI { permissionMode?: string; fastMode?: boolean; planModeEnabled?: boolean; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; /** 附加只读引用目录列表 (绝对路径); main 端 mapper 会 JSON.stringify 后写库。 */ extraDirs?: string[]; @@ -5339,9 +5339,9 @@ interface ElectronAPI { * apps/desktop/src/main/maker-ipc/ 的 handlers + apps/desktop/src/main/maker-host/。 */ maker: { - listAvailableAgents: () => Promise>; + listAvailableAgents: () => Promise>; onAgentsChanged: (cb: () => void) => () => void; - getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Promise; listBotDelegations: ( parentSessionId: string, ) => Promise; @@ -5460,12 +5460,12 @@ interface ElectronAPI { // 自定义供应商配置 CRUD(配置与 runtime 密钥均由 main 原子排队)。 createCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, options?: CustomProviderUpdateOptions, ) => Promise; updateCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, options?: CustomProviderUpdateOptions, ) => Promise; disconnectCustomProvider: ( @@ -5537,11 +5537,11 @@ interface ElectronAPI { /** 供应商「测试连接」—— 与真实会话同路由口径的最小探测请求(结构化结果,code 走 providerError.* i18n)。 */ testProviderConnection: ( input: - | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' } + | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' | 'cursor' } | { kind: 'adhoc'; spec: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; baseUrl: string; modelId: string; authMethod: 'apiKey' | 'oauth' | 'none'; @@ -5562,7 +5562,7 @@ interface ElectronAPI { }>; /** 供应商「获取模型列表」—— 表单值透传,结构化结果(code 走 providerError.* i18n)。 */ fetchProviderModels: (input: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; baseUrl: string; authMethod: 'apiKey' | 'oauth' | 'none'; wireProtocol?: import('@cindy/model-providers').ProviderWireProtocol; @@ -5657,7 +5657,7 @@ interface ElectronAPI { /** 自定义供应商上游错误订阅(返回 off);code 走 providerError.* i18n。 */ onProviderUpstreamError: ( cb: (event: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string; providerName?: string; code: import('../shared/providerErrors').ProviderErrorCode; @@ -5813,7 +5813,7 @@ interface ElectronAPI { attachments?: import('./lib/fileTypes').SerializedAttachedFile[]; }) => Promise<{ ok: true; runId: string; reviewerSessionId: string }>; listAgentCommands: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params?: { sessionId?: string; allowManagedPiPackagePreview?: boolean }, ) => Promise<{ success: boolean; @@ -5823,7 +5823,7 @@ interface ElectronAPI { }>; listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params: { workingDir?: string; remoteHostId?: string; @@ -5907,7 +5907,7 @@ interface ElectronAPI { ) => () => void; scanAtResources: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params: { workingDir: string; cap?: number; query?: string }, ) => Promise<{ success: boolean; @@ -5939,7 +5939,7 @@ interface ElectronAPI { createSession: (opts: { /** 可选: 复用外部 sessionId(本端 chat 用 local-db:sessions:create 拿到的 id) */ id?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; title?: string; @@ -5984,7 +5984,7 @@ interface ElectronAPI { enableOrca: ( leadSessionId: string, opts: { - workerAgent: 'claude-code' | 'codex' | 'pi'; + workerAgent: 'claude-code' | 'codex' | 'pi' | 'cursor'; delegateTask?: string; role?: string; label?: string; @@ -5994,7 +5994,7 @@ interface ElectronAPI { /** 显式选定的模型来源(标准面板 per-worker 选择);缺省 = 跟随默认路由解析。 */ providerId?: string | null; /** Worker 创建默认权限;缺省沿用当前偏好,显式值会更新偏好。 */ - workerPermissionMode?: 'auto' | 'bypassPermissions'; + workerPermissionMode?: 'ask' | 'auto' | 'bypassPermissions'; /** 新建 Lead 专用:等首条输入 accepted 且可查询后再派任务。 */ deferDelegateTask?: boolean; /** 首个 Worker 放到同账号另一台电脑运行;缺省 = 本机。 */ @@ -6009,7 +6009,7 @@ interface ElectronAPI { workerSessionId: string; workerId: string; dispatched: boolean; - workerPermissionMode: 'auto' | 'bypassPermissions'; + workerPermissionMode: 'ask' | 'auto' | 'bypassPermissions'; uiAssignmentSnapshotBeforeMs: number; }>; @@ -6038,7 +6038,7 @@ interface ElectronAPI { message: string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; @@ -6085,7 +6085,7 @@ interface ElectronAPI { getContextUsage: ( sessionId: string, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; @@ -6118,7 +6118,7 @@ interface ElectronAPI { listActive: () => Promise< Array<{ sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workDir: string; capabilities: unknown; isTurnRunning: boolean; @@ -6282,7 +6282,7 @@ interface ElectronAPI { */ switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId?: string | null, effort?: string, @@ -6291,7 +6291,7 @@ interface ElectronAPI { options?: { agentDeviceId?: string | null }, ) => Promise<{ switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; engineReady: boolean; deferred?: boolean; @@ -6303,7 +6303,7 @@ interface ElectronAPI { * 重开视图 / device-link 远程会话重连后恢复乐观显示用。 */ getSessionAgentSwitchIntent: (sessionId: string) => Promise<{ - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort?: string; @@ -6343,13 +6343,13 @@ interface ElectronAPI { setWritableDirs: (sessionId: string, dirs: string[]) => Promise; // Memory 控制 (Settings → Personalization → Memory section) - memoryGet: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + memoryGet: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Promise<{ enabled: boolean; source: 'agent-default' | 'host-runtime' | 'user-config'; stats?: { entryCount?: number; sizeBytes?: number; storagePath?: string }; }>; memorySet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', enabled: boolean, ) => Promise<{ effective: 'immediate' | 'next-session'; @@ -6357,7 +6357,7 @@ interface ElectronAPI { customizedKeys: string[]; defaults: { maker: boolean; claudeCode: boolean; codex: boolean; pi: boolean }; }>; - memoryReset: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + memoryReset: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Promise<{ removedEntries?: number; removedBytes?: number; }>; @@ -6678,7 +6678,7 @@ interface ElectronAPI { // Stage 2 C1: chat utility (前身 cc-agent:generate-title / cc-agent:plan-file-write) generateTitle: ( message: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', sessionId?: string, ) => Promise<{ title: string | null }>; /** Optional status copy from public execution facts only. */ @@ -6695,13 +6695,13 @@ interface ElectronAPI { autoTitle: (request: { sessionId: string; text: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; isUserText?: boolean; }) => Promise<{ applied: boolean; done: boolean }>; /** 输入框推荐提示词:turn 结束后预测用户下一步输入。 */ predictNextPrompt: (request: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; messages: Array<{ role: string; content: string }>; workingDir?: string; turnGen: number; @@ -6777,25 +6777,25 @@ interface ElectronAPI { /* ── Agent 鉴权 (取代老 codex.auth.*) ── */ auth: { - getState: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getState: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Promise; triggerLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', options?: { mode?: 'browser' | 'device-code' | 'local'; ownerId?: string }, ) => Promise; cancelLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', options?: { releaseOwner?: boolean; ownerId?: string }, ) => Promise; logout: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', ownerScope?: { dataOwnerId: string | null; ownerGeneration: number }, ) => Promise; onStateChanged: ( - cb: (s: { agentKind: 'claude-code' | 'codex' | 'pi' } & CodexAuthState) => void, + cb: (s: { agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor' } & CodexAuthState) => void, ) => () => void; onLoginProgress: ( cb: (p: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; phase: string; mode?: 'browser' | 'device-code' | 'local'; detail?: string; @@ -6814,7 +6814,7 @@ interface ElectronAPI { /* ── Agent 联合状态 (binary + auth, 取代老 codex.binary.getStatus) ── */ agent: { - getStatus: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + getStatus: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Promise<{ binaryReady: boolean; binaryPath: string; authReady: boolean; @@ -6823,10 +6823,10 @@ interface ElectronAPI { /** spawn 当前应用使用的 binary `--version`, 进程内缓存。About 面板用。 */ /** checkLatest 额外比较当前通道的线上版本;不传只读本地版本,离线也不等待。 */ getBinaryVersion: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', options?: { checkLatest?: boolean }, ) => Promise<{ - kind: 'claude-code' | 'codex' | 'pi'; + kind: 'claude-code' | 'codex' | 'pi' | 'cursor'; binaryPath: string | null; version: string | null; latestVersion: string | null; @@ -6840,7 +6840,7 @@ interface ElectronAPI { /* ── Agent 今日累计 (取代老 codex.usage.* + onUsageTodaySpendChanged) ── */ usage: { - getToday: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + getToday: (agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor') => Promise<{ day: string; money?: import('../shared/regionalMoney').RegionalMoney; costUsd?: number; @@ -6852,7 +6852,7 @@ interface ElectronAPI { cacheCreationTokens?: number; }>; getAccount: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', providerId?: string, ) => Promise; /** Codex app-server authoritative windows and banked reset-credit metadata. */ @@ -6965,7 +6965,7 @@ interface ElectronAPI { crossAgent: { detect: ( workingDir: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', ) => Promise<{ items: CrossAgentMigrationItem[] }>; convert: (items: CrossAgentMigrationItem[]) => Promise<{ total: number; @@ -7032,7 +7032,7 @@ interface ElectronAPI { scheduleName?: string; workingDir?: string; providerId?: string; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'cursor'; model?: string; /** 绑定会话任务:workingDir 空时 main 按会话 meta.workDir 解析落盘/自测目录。 */ targetSessionId?: string; @@ -7208,10 +7208,10 @@ interface SkillhubSkill { /** 同一 URL 基键存在多个来源时,详情路由必须携带 sourceKey。 */ requiresSourceKey?: boolean; /** 来自哪个 agent 引擎。 */ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 发现该 skill 的所有引擎专属路径(去重后)。 */ linkedEngines: Array<{ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'cursor'; label: string; runtimeStatus?: 'discovered' | 'approved' | 'loaded' | 'failed' | 'unknown'; }>; @@ -7338,7 +7338,7 @@ interface SkillUsageEvidenceIndex { rawLineNo: number; sessionId: string; sdkSessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; skillName: string; skillPath: string | null; skillDocumentHash: string | null; diff --git a/apps/desktop/src/shared/__tests__/botDefaultModelChain.test.ts b/apps/desktop/src/shared/__tests__/botDefaultModelChain.test.ts index ea651cc7efa..fd3bba7ba84 100644 --- a/apps/desktop/src/shared/__tests__/botDefaultModelChain.test.ts +++ b/apps/desktop/src/shared/__tests__/botDefaultModelChain.test.ts @@ -9,6 +9,12 @@ const providers = [{ id: 'my-connection', source: 'user', connected: true, agent const args = { providers, providersLoading: false, availableAgents: new Set(['codex'] as const), availableAgentsLoaded: true }; describe('Bot strictly follows the available Cindy default', () => { + it('does not substitute a Bot engine when the application default is Cursor', () => { + expect(defaultBotModelChain({ ...args, preferredRoute: { + harness: 'cursor', providerId: 'cursor', model: 'native', effort: '', fastMode: false, + } })).toEqual([]); + }); + it('uses the exact selected route and tuning without adding catalog recommendations', () => { expect(defaultBotModelChain({ ...args, preferredRoute: selected })).toEqual([selected]); }); diff --git a/apps/desktop/src/shared/__tests__/botModelChain.test.ts b/apps/desktop/src/shared/__tests__/botModelChain.test.ts index ee4ebfa1ada..e6d55cd4e31 100644 --- a/apps/desktop/src/shared/__tests__/botModelChain.test.ts +++ b/apps/desktop/src/shared/__tests__/botModelChain.test.ts @@ -8,6 +8,13 @@ import { } from '../botModelChain'; describe('Bot model chain', () => { + it('drops unsupported Cursor Bot routes instead of reinterpreting them as Claude', () => { + expect(normalizeBotModelChain([{ harness: 'cursor', model: 'native' }])).toEqual([]); + expect(normalizeBotModelChain(undefined, { harness: 'cursor', model: 'native' })).toEqual([]); + expect(nextBotModelRoute([{ harness: 'pi', model: 'fallback' }], + { harness: 'cursor', model: 'native', providerId: 'cursor' })).toBeNull(); + }); + it('keeps a legacy single-model Bot as a one-route chain', () => { expect( normalizeBotModelChain(undefined, { diff --git a/apps/desktop/src/shared/__tests__/cursorAgentKind.test.ts b/apps/desktop/src/shared/__tests__/cursorAgentKind.test.ts new file mode 100644 index 00000000000..e6ac5bc3976 --- /dev/null +++ b/apps/desktop/src/shared/__tests__/cursorAgentKind.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { dbToMakerAgentKind, makerToDbAgentKind, normalizeDbAgentKind } from '../agentKindConversion'; + +describe('Cursor native identity', () => { + it('round trips without falling back to Claude', () => { + expect(dbToMakerAgentKind('cursor')).toBe('cursor'); + expect(makerToDbAgentKind('cursor')).toBe('cursor'); + expect(normalizeDbAgentKind('cursor')).toBe('cursor'); + }); + it('preserves historical defaults for unknown values', () => { + expect(dbToMakerAgentKind(undefined)).toBe('claude-code'); + expect(normalizeDbAgentKind('unknown')).toBe('cc'); + }); +}); diff --git a/apps/desktop/src/shared/agentInputQueue.ts b/apps/desktop/src/shared/agentInputQueue.ts index 54b1152a817..de8e96bc947 100644 --- a/apps/desktop/src/shared/agentInputQueue.ts +++ b/apps/desktop/src/shared/agentInputQueue.ts @@ -160,7 +160,7 @@ export interface AgentInputChatMessage { } export interface AgentInputCreateOpts { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; providerId?: string | null; @@ -1040,9 +1040,8 @@ const REF_CONTINUATION_CHARS = new Set([ * 「ASCII 路径 + 紧跟一个汉字/假名」这个形状判成边界是安全的(PR #510 review P1)。 */ function isScriptChangeBoundary(ref: string, next: string): boolean { - // eslint-disable-next-line no-control-regex - if (!/^[\x00-\x7F]*$/.test(ref)) return false; - return /[^\x00-\x7F]/.test(next) && /\p{L}/u.test(next); + if (!/^\p{ASCII}*$/u.test(ref)) return false; + return /\P{ASCII}/u.test(next) && /\p{L}/u.test(next); } function isRefBoundary(ch: string): boolean { diff --git a/apps/desktop/src/shared/agentIsland.ts b/apps/desktop/src/shared/agentIsland.ts index ba291c46ed0..a5dac7d1278 100644 --- a/apps/desktop/src/shared/agentIsland.ts +++ b/apps/desktop/src/shared/agentIsland.ts @@ -760,7 +760,7 @@ export interface AgentIslandRemoteSessionInput { title: string | null; workingDir: string | null; workspaceKind: string | null; - /** DB 形态('cc' | 'codex' | 'pi')。 */ + /** DB 形态('cc' | 'codex' | 'pi' | 'cursor')。 */ agentKind: string | null; phase: AgentIslandSessionPhase; detail: string; diff --git a/apps/desktop/src/shared/agentKindConversion.ts b/apps/desktop/src/shared/agentKindConversion.ts index e66b72f0e17..001fe87e99a 100644 --- a/apps/desktop/src/shared/agentKindConversion.ts +++ b/apps/desktop/src/shared/agentKindConversion.ts @@ -1,6 +1,6 @@ /** - * agentKindConversion —— DB/renderer 形态('cc' | 'codex' | 'pi')与 maker-core - * 形态('claude-code' | 'codex' | 'pi')的唯一双向映射。 + * agentKindConversion —— DB/renderer 形态('cc' | 'codex' | 'pi' | 'cursor')与 maker-core + * 形态('claude-code' | 'codex' | 'pi' | 'cursor')的唯一双向映射。 * * 背景:sessions.agent_kind 历史上存 renderer 形态('cc' 起家,default 'cc'), * maker-core 用 'claude-code'。三值化前全仓散落 `x === 'cc' ? 'claude-code' : @@ -9,23 +9,25 @@ */ /** DB(sessions.agent_kind)与 renderer 侧的 agent 形态。 */ -export type DbAgentKind = 'cc' | 'codex' | 'pi'; +export type DbAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; /** maker-core / IPC 契约侧的 agent 形态。 */ -export type MakerAgentKindWire = 'claude-code' | 'codex' | 'pi'; +export type MakerAgentKindWire = 'claude-code' | 'codex' | 'pi' | 'cursor'; export function dbToMakerAgentKind(db: string | null | undefined): MakerAgentKindWire { if (db === 'codex') return 'codex'; + if (db === 'cursor') return 'cursor'; if (db === 'pi') return 'pi'; return 'claude-code'; // 'cc' 与历史缺省 } export function makerToDbAgentKind(maker: string | null | undefined): DbAgentKind { if (maker === 'codex') return 'codex'; + if (maker === 'cursor') return 'cursor'; if (maker === 'pi') return 'pi'; return 'cc'; // 'claude-code' 与历史缺省 } /** 宽输入归一成 DbAgentKind;非法值回落 'cc'(与 sessions 表 default 同语义)。 */ export function normalizeDbAgentKind(value: string | null | undefined): DbAgentKind { - return value === 'codex' || value === 'pi' ? value : 'cc'; + return value === 'codex' || value === 'pi' || value === 'cursor' ? value : 'cc'; } diff --git a/apps/desktop/src/shared/appDefaultModelSelection.ts b/apps/desktop/src/shared/appDefaultModelSelection.ts index 49df32e46e6..f9a3d9da2a3 100644 --- a/apps/desktop/src/shared/appDefaultModelSelection.ts +++ b/apps/desktop/src/shared/appDefaultModelSelection.ts @@ -1,16 +1,33 @@ -import type { BotModelRoute } from './botModelChain.js'; +import { normalizeBotModelRoute, type BotModelRoute } from './botModelChain.js'; import type { DataOwnerPushStamp } from './dataOwnerPush.js'; +/** Ordinary task defaults also include engines without Bot runtime support. */ +export type AppModelRoute = Omit & { + harness: BotModelRoute['harness'] | 'cursor'; +}; + +export function normalizeAppModelRoute(value: unknown): AppModelRoute | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (record.harness !== 'cursor') return normalizeBotModelRoute(value); + if (typeof record.model !== 'string' || !record.model.trim()) return null; + return { + harness: 'cursor', model: record.model.trim(), + providerId: typeof record.providerId === 'string' && record.providerId.trim() ? record.providerId.trim() : null, + effort: typeof record.effort === 'string' ? record.effort : '', fastMode: record.fastMode === true, + }; +} + /** Host-only write-through request; existing remote preference payloads remain unchanged. */ export interface AppDefaultModelSelection { requestId: string; route: BotModelRoute; - expectedRoute: BotModelRoute | null; + expectedRoute: AppModelRoute | null; ownerStamp: DataOwnerPushStamp; expiresAt: number; } -export function sameModelRoute(a: BotModelRoute | null | undefined, b: BotModelRoute | null | undefined): boolean { +export function sameModelRoute(a: AppModelRoute | null | undefined, b: AppModelRoute | null | undefined): boolean { if (!a || !b) return !a && !b; return a.harness === b.harness && a.providerId === b.providerId && a.model === b.model && a.effort === b.effort && a.fastMode === b.fastMode; diff --git a/apps/desktop/src/shared/botCreation.ts b/apps/desktop/src/shared/botCreation.ts index 0975c63d53e..d6ad16fb0d6 100644 --- a/apps/desktop/src/shared/botCreation.ts +++ b/apps/desktop/src/shared/botCreation.ts @@ -5,7 +5,7 @@ export interface BotCreationRequest { name?: string; description?: string; modelRoute: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string | null; model: string; }; diff --git a/apps/desktop/src/shared/botDefaultModelChain.ts b/apps/desktop/src/shared/botDefaultModelChain.ts index 7f5cb85838c..a821df18189 100644 --- a/apps/desktop/src/shared/botDefaultModelChain.ts +++ b/apps/desktop/src/shared/botDefaultModelChain.ts @@ -1,13 +1,14 @@ import { effectiveSourceIdForModel } from '@cindy/model-providers'; import type { resolveNewMakerDefaultTuples } from './newMakerDefaultTuple.js'; import type { BotModelRoute } from './botModelChain.js'; +import type { AppModelRoute } from './appDefaultModelSelection.js'; /** Adapt the client's ordered defaults to Bot routes without another selection policy. */ export function defaultBotModelChain( - args: Parameters[0] & { preferredRoute?: BotModelRoute | null }, + args: Parameters[0] & { preferredRoute?: AppModelRoute | null }, ): BotModelRoute[] { const preferred = args.preferredRoute; - if (preferred && !args.providersLoading && args.availableAgentsLoaded) { + if (preferred && preferred.harness !== 'cursor' && !args.providersLoading && args.availableAgentsLoaded) { const agent = preferred.harness === 'claude' ? 'claude-code' : preferred.harness; const vendor = agent === 'claude-code' ? 'cc' : agent; if (!args.availableAgents.has(vendor)) return []; @@ -19,7 +20,7 @@ export function defaultBotModelChain( const providerId = effectiveSourceIdForModel(providers, preferred.providerId, preferred.model, agent); if (providerId) { // A selected app default is not consent to add factory fallback models. - return [{ ...preferred, providerId }]; + return [{ ...preferred, harness: preferred.harness, providerId }]; } } // Missing, stale or unavailable Cindy defaults are not permission to choose a replacement. diff --git a/apps/desktop/src/shared/botModelChain.ts b/apps/desktop/src/shared/botModelChain.ts index fcf2e4db974..1bfecc853aa 100644 --- a/apps/desktop/src/shared/botModelChain.ts +++ b/apps/desktop/src/shared/botModelChain.ts @@ -26,6 +26,8 @@ export function normalizeBotHarness(value: unknown): BotHarness { export function normalizeBotModelRoute(value: unknown): BotModelRoute | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; + // Cursor cannot honor a Bot's runtime profile; never reinterpret that route as Claude. + if (record.harness === 'cursor') return null; if (typeof record.model !== 'string' || !record.model.trim()) return null; return { harness: normalizeBotHarness(record.harness), @@ -86,10 +88,11 @@ export function primaryBotModelRoute( /** Select the next explicit route; never invent one from the global catalog. */ export function nextBotModelRoute( value: unknown, - current: Pick, + current: Omit, 'harness'> & { harness: BotHarness | 'cursor' }, visitedRoutes: readonly string[] = [], isUsable: (route: BotModelRoute) => boolean = () => true, ): BotModelRoute | null { + if (current.harness === 'cursor') return null; const chain = normalizeBotModelChain(value); let currentIndex = chain.findIndex((route) => route.harness === current.harness diff --git a/apps/desktop/src/shared/botWorkbench.ts b/apps/desktop/src/shared/botWorkbench.ts index 3e48eb0062b..243603e032a 100644 --- a/apps/desktop/src/shared/botWorkbench.ts +++ b/apps/desktop/src/shared/botWorkbench.ts @@ -238,7 +238,7 @@ export function cleanWorkbenchTitle(raw: string | null | undefined, fallback = ' export type WorkbenchTaskState = 'running' | 'waiting' | 'queued' | 'stopped' | 'automation' | 'done'; /** 一格任务从哪里来:原有任务、伙伴替主人开的后台任务、本机其他工具 / 其它 Cindy 里的会话。 */ -export type WorkbenchTaskOrigin = 'existing' | 'delegated' | 'claude-code' | 'codex' | 'pi'; +export type WorkbenchTaskOrigin = 'existing' | 'delegated' | 'claude-code' | 'codex' | 'pi' | 'cursor'; export type WorkbenchDelegationStatus = | 'queued' diff --git a/apps/desktop/src/shared/cindyMakeDoctor.ts b/apps/desktop/src/shared/cindyMakeDoctor.ts index 2db19f162f0..ac6b87c495c 100644 --- a/apps/desktop/src/shared/cindyMakeDoctor.ts +++ b/apps/desktop/src/shared/cindyMakeDoctor.ts @@ -94,7 +94,7 @@ export interface MakeDependencyProgress { } export interface CindyMakeTaskOptions { - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; model?: string; effort?: string; providerId?: string | null; diff --git a/apps/desktop/src/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index c540cd2c510..c83ea2909ba 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -2,7 +2,7 @@ import { projectDraftSessionTitle } from '@cindy/maker-shared/session-title'; import type { SessionSource } from './sessionSource'; -export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi'; +export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; export type ConversationSearchWorkspaceKind = 'project' | 'dialogue'; export type ConversationSearchSessionStatus = 'active' | 'archived' | 'deleted'; export type ConversationSearchOrcaRole = 'lead' | 'worker'; diff --git a/apps/desktop/src/shared/imDefaultSettings.ts b/apps/desktop/src/shared/imDefaultSettings.ts index 0b16fe5b1fa..557fed67245 100644 --- a/apps/desktop/src/shared/imDefaultSettings.ts +++ b/apps/desktop/src/shared/imDefaultSettings.ts @@ -1,4 +1,4 @@ -export type ImDefaultAgentKind = 'claude-code' | 'codex' | 'pi'; +export type ImDefaultAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type ImDefaultPermissionMode = 'ask' | 'default' | 'acceptEdits' | 'plan' | 'auto' | 'bypassPermissions'; export type ImDefaultEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; @@ -65,6 +65,7 @@ export const IM_DEFAULT_SETTINGS: ImDefaultSettings = { model: 'codex/gpt-5.5', effort: 'high', }, + cursor: { providerId: 'cursor', model: '', effort: 'medium' }, // Pi 走网关中档模型作为 IM 新会话默认值,可在各渠道设置中覆盖。 pi: { providerId: null, @@ -89,7 +90,7 @@ export const IM_DEFAULT_EFFORT_OVERRIDES: Readonly(['claude-code', 'codex', 'pi']); +const AGENT_KINDS = new Set(['claude-code', 'codex', 'pi', 'cursor']); const EFFORTS = new Set([ 'minimal', 'low', diff --git a/apps/desktop/src/shared/issueRuntimeMetadata.ts b/apps/desktop/src/shared/issueRuntimeMetadata.ts index 4953d299d00..c5bf5bd70f5 100644 --- a/apps/desktop/src/shared/issueRuntimeMetadata.ts +++ b/apps/desktop/src/shared/issueRuntimeMetadata.ts @@ -1,11 +1,12 @@ -export type IssueAgentKind = 'claude-code' | 'codex' | 'pi'; +export type IssueAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; -export type IssueHarness = 'Claude Code' | 'Codex' | 'Pi'; +export type IssueHarness = 'Claude Code' | 'Codex' | 'Pi' | 'Cursor'; const ISSUE_HARNESS_BY_AGENT_KIND: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; export const ISSUE_MODEL_ID_MAX = 200; @@ -15,7 +16,7 @@ export function issueHarnessForAgentKind(agentKind: IssueAgentKind): IssueHarnes } export function parseIssueHarness(value: unknown): IssueHarness | undefined { - return value === 'Claude Code' || value === 'Codex' || value === 'Pi' ? value : undefined; + return value === 'Claude Code' || value === 'Codex' || value === 'Pi' || value === 'Cursor' ? value : undefined; } /** Keep a custom model ID safe for a single Markdown environment line and the confirmation UI. */ diff --git a/apps/desktop/src/shared/localCliDetect.ts b/apps/desktop/src/shared/localCliDetect.ts index d3957f9772d..345e1811188 100644 --- a/apps/desktop/src/shared/localCliDetect.ts +++ b/apps/desktop/src/shared/localCliDetect.ts @@ -6,9 +6,10 @@ * 用户自己在目录里找渠道。 * * 映射表设计为数据驱动(cliId → 建议 providerId),后续扩国产 CLI(qwen / iflow / - * gemini / kimi 等)只需加条目;当前只做 claude / codex 两条(优先级最高、判据最稳)。 + * gemini / kimi 等)只需加条目。Claude/Codex 使用映射表;Cursor 由 main + * 复用原生可执行文件发现与 CLI status 探测,不从配置目录推断安装或登录。 * - * 登录态判据只做**存在性判断**,绝不落盘 / 不进日志(CLAUDE.md 规则 23): + * 登录态只返回布尔值,绝不落盘 / 不进日志(CLAUDE.md 规则 23): * - `file` 探测:stat 凭证文件是否存在(Codex 的 ~/.codex/auth.json)。 * - `claude-oauth` 探测:Claude Code 登录态由 CLI 自己保存(macOS 在系统钥匙串, * 其它平台在 ~/.claude),只 stat 文件会漏掉 Mac 上正常登录(且可能连 ~/.claude @@ -17,12 +18,13 @@ */ /** 可检测的本机 CLI 标识。 */ -export type LocalCliId = 'claude-cli' | 'codex-cli'; +export type LocalCliId = 'claude-cli' | 'codex-cli' | 'cursor-cli'; /** 插进「检测到 {{cli}}」等句子的商品名;各语言共用,不走 i18n。 */ const LOCAL_CLI_DISPLAY_NAME: Record = { 'claude-cli': 'Claude Code CLI', 'codex-cli': 'Codex CLI', + 'cursor-cli': 'Cursor CLI', }; export function localCliDisplayName(cli: LocalCliId): string { @@ -35,9 +37,9 @@ export type CredentialProbe = 'file' | 'claude-oauth'; /** 单条检测结果:CLI 是否安装 / 是否已登录 + 建议接入的供应商 id。 */ export interface LocalCliDetection { cli: LocalCliId; - /** 建议接入的内置供应商 id(anthropic / openai,与 active-catalog 的 provider id 对齐)。 */ + /** 建议接入的内置供应商 id,与 active-catalog 的 provider id 对齐。 */ providerId: string; - /** 配置目录存在(~/.claude / ~/.codex)。 */ + /** Claude/Codex 配置目录存在,或已发现 Cursor 原生可执行文件。 */ installed: boolean; /** 登录态凭证文件存在(只 stat 不读)。 */ loggedIn: boolean; diff --git a/apps/desktop/src/shared/modelAccess.ts b/apps/desktop/src/shared/modelAccess.ts index 883de378560..03ca4db0992 100644 --- a/apps/desktop/src/shared/modelAccess.ts +++ b/apps/desktop/src/shared/modelAccess.ts @@ -191,7 +191,7 @@ export interface ModelAccessGatewayModel extends ModelGroupPricing { */ currency?: 'USD' | 'CNY'; /** 进哪些 runtime tab;Desktop 固定使用 v3,本字段由服务端明确下发。 */ - agents?: ('claude-code' | 'codex' | 'pi')[]; + agents?: ('claude-code' | 'codex' | 'pi' | 'cursor')[]; name?: string; group?: string; description?: string; @@ -211,14 +211,14 @@ export interface ModelAccessGatewayModel extends ModelGroupPricing { * 服务端权威、按区域下发)。与 sortOrder / defaultEnabled 独立;客户端据它选新对话默认。 * 缺省 = 不作为任何 agent 的默认。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'cursor')[]; /** * 展示图标 id(AI Gateway 侧登记,见 @cindy/model-providers CatalogModel.icon / * resolveModelIconKind);缺省或未知值客户端回落来源供应商标。 */ icon?: string; /** per-tab 能力覆盖(基线字段之上按 agent 应用)。 */ - perAgent?: Partial>; + perAgent?: Partial>; } /** diff --git a/apps/desktop/src/shared/modelPriceQuote.ts b/apps/desktop/src/shared/modelPriceQuote.ts index 6bd57a3b5c4..b3849d50a6d 100644 --- a/apps/desktop/src/shared/modelPriceQuote.ts +++ b/apps/desktop/src/shared/modelPriceQuote.ts @@ -289,7 +289,7 @@ function referencePriceQuoteForVariant( // 路由)在此按 agent 无关的参考价解析 —— pi 一律降级为 undefined 传给协议函数。 const resolved = resolveModelReferencePrice(registry, providerId, modelId, { ...options, - agent: options.agent === 'pi' ? undefined : options.agent, + agent: options.agent === 'claude-code' || options.agent === 'codex' ? options.agent : undefined, }); if (!resolved) return undefined; const day = referencePriceCalendarDate(options.at); diff --git a/apps/desktop/src/shared/newMakerDefaultTuple.ts b/apps/desktop/src/shared/newMakerDefaultTuple.ts index 37b3453a5d6..2218b881cff 100644 --- a/apps/desktop/src/shared/newMakerDefaultTuple.ts +++ b/apps/desktop/src/shared/newMakerDefaultTuple.ts @@ -7,10 +7,10 @@ import { type ProviderView, } from '@cindy/model-providers'; -type MakerVendor = 'cc' | 'codex' | 'pi' | 'orca'; +type MakerVendor = 'cc' | 'codex' | 'pi' | 'cursor' | 'orca'; export interface NewMakerDefaultTuple { - vendor: Extract; + vendor: Extract; providerId: string; model: string; effort: Effort | null; diff --git a/apps/desktop/src/shared/orca-worker-permission-mode.ts b/apps/desktop/src/shared/orca-worker-permission-mode.ts index f79387c3617..e1a18499bf3 100644 --- a/apps/desktop/src/shared/orca-worker-permission-mode.ts +++ b/apps/desktop/src/shared/orca-worker-permission-mode.ts @@ -1,4 +1,4 @@ -export const ORCA_WORKER_PERMISSION_MODES = ['auto', 'bypassPermissions'] as const; +export const ORCA_WORKER_PERMISSION_MODES = ['ask', 'auto', 'bypassPermissions'] as const; export type OrcaWorkerPermissionMode = (typeof ORCA_WORKER_PERMISSION_MODES)[number]; diff --git a/apps/desktop/src/shared/piPackages.ts b/apps/desktop/src/shared/piPackages.ts index 089c21c0e2a..96bf1cf3b73 100644 --- a/apps/desktop/src/shared/piPackages.ts +++ b/apps/desktop/src/shared/piPackages.ts @@ -151,7 +151,7 @@ export function listPiRuntimePaletteCommands( /** Runtime-confirmed Pi package commands belong only to the Pi command palette. */ export function mergePiPackageCommands( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', builtins: PiPackageSlashCommand[], packageCommands: Array<{ name: string; description: string }>, ): PiPackageSlashCommand[] { @@ -169,10 +169,10 @@ export function mergePiPackageCommands( } export function shouldListPiPackageCommands( - requestedAgentKind: 'claude-code' | 'codex' | 'pi', + requestedAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', sessionIdProvided: boolean, session: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; reviewMode?: true; remoteHostId?: string; } | null, diff --git a/apps/desktop/src/shared/pluginTasks.ts b/apps/desktop/src/shared/pluginTasks.ts index f3e5e3db7f9..ebee2b55e39 100644 --- a/apps/desktop/src/shared/pluginTasks.ts +++ b/apps/desktop/src/shared/pluginTasks.ts @@ -1,6 +1,6 @@ /** Ordinary local Session API. No Bot/Orca identity or permission override is accepted. */ export interface PluginTaskRoute { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; providerId: string; model: string; effort: string; diff --git a/apps/desktop/src/shared/processMonitor.ts b/apps/desktop/src/shared/processMonitor.ts index 8a01f133128..3f7fa2942b3 100644 --- a/apps/desktop/src/shared/processMonitor.ts +++ b/apps/desktop/src/shared/processMonitor.ts @@ -23,7 +23,8 @@ export type ProcessUsageKind = | 'utility' | 'agent-claude' | 'agent-codex' - | 'agent-pi'; + | 'agent-pi' + | 'agent-cursor'; /** Codex 本地 app-server 的产品职责;仅传枚举,不暴露 host key / 凭据 / 命令行。 */ export type AgentProcessRole = 'task-host' | 'control-plane-service'; @@ -72,5 +73,5 @@ export interface TerminateAgentProcessRequest { /** terminate 成功返回(失败一律走 IPC 错误协议 throwIpcError)。 */ export interface TerminateAgentProcessResult { pid: number; - kind: 'claude' | 'codex' | 'pi'; + kind: 'claude' | 'codex' | 'pi' | 'cursor'; } diff --git a/apps/desktop/src/shared/providerModelRefresh.ts b/apps/desktop/src/shared/providerModelRefresh.ts index b813e4133f0..c0a3425fe28 100644 --- a/apps/desktop/src/shared/providerModelRefresh.ts +++ b/apps/desktop/src/shared/providerModelRefresh.ts @@ -3,7 +3,7 @@ * * Custom providers keep their existing endpoint-driven additions-only refresh flow. */ -export const BUILTIN_REFRESHABLE_PROVIDER_IDS = ['xd', 'anthropic', 'openai', 'xai'] as const; +export const BUILTIN_REFRESHABLE_PROVIDER_IDS = ['xd', 'anthropic', 'openai', 'xai', 'cursor'] as const; export type BuiltinRefreshableProviderId = (typeof BUILTIN_REFRESHABLE_PROVIDER_IDS)[number]; diff --git a/apps/desktop/src/shared/sessionReference.ts b/apps/desktop/src/shared/sessionReference.ts index f700cd51d82..16219c46127 100644 --- a/apps/desktop/src/shared/sessionReference.ts +++ b/apps/desktop/src/shared/sessionReference.ts @@ -10,5 +10,5 @@ export interface SessionReference { state: 'available' | 'deleted' | 'missing'; status?: 'active' | 'archived' | 'deleted'; title?: string; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; } diff --git a/apps/desktop/src/shared/turnChangeSet.ts b/apps/desktop/src/shared/turnChangeSet.ts index 1f9f7f96f14..262680f1c5e 100644 --- a/apps/desktop/src/shared/turnChangeSet.ts +++ b/apps/desktop/src/shared/turnChangeSet.ts @@ -2,7 +2,7 @@ import type { DiffChangeKind, FileDiff } from './gitReviewWire'; export const TURN_CHANGE_SET_MAX_DIFF_BYTES = 12 * 1024 * 1024; -export type TurnChangeProvider = 'codex' | 'claude-code' | 'pi'; +export type TurnChangeProvider = 'codex' | 'claude-code' | 'pi' | 'cursor'; export type TurnChangeSetState = 'complete' | 'partial'; export type TurnChangeWorkspaceState = 'applied' | 'undone'; export type TurnChangeAction = 'undo' | 'reapply'; diff --git a/apps/desktop/vite.main.config.ts b/apps/desktop/vite.main.config.ts index 80ed4690197..449bda7667d 100644 --- a/apps/desktop/vite.main.config.ts +++ b/apps/desktop/vite.main.config.ts @@ -16,6 +16,8 @@ const loginFixturesStub = path.resolve( // development rebuild lets Rollup invalidate that burst as one graph update, // keeping the watcher peak bounded without changing packaged builds. const DEV_WATCH_BUILD_DELAY_MS = 250; +const pollDevWatcher = process.platform === 'darwin' + && process.env.XDT_DESKTOP_DEV_WATCH_POLLING === '1'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), 'VITE_'); @@ -110,7 +112,14 @@ export default defineConfig(({ mode }) => { }, build: { watch: - mode === 'development' ? { buildDelay: DEV_WATCH_BUILD_DELAY_MS } : undefined, + mode === 'development' + ? { + buildDelay: DEV_WATCH_BUILD_DELAY_MS, + ...(pollDevWatcher + ? { chokidar: { useFsEvents: false, usePolling: true, interval: 1_000 } } + : {}), + } + : undefined, rollupOptions: { output: { // Keep the vendored @cindy/browser-control-runtime (including its diff --git a/apps/desktop/vite.renderer.config.ts b/apps/desktop/vite.renderer.config.ts index 411d282eaa7..1464ebddbde 100644 --- a/apps/desktop/vite.renderer.config.ts +++ b/apps/desktop/vite.renderer.config.ts @@ -209,6 +209,9 @@ function discoverPureInternalPackages(): { specifiers: string[]; names: string[] const { specifiers: INTERNAL_PURE_PACKAGE_EXCLUDES, names: INTERNAL_PURE_PACKAGE_NAMES } = discoverPureInternalPackages(); +const pollDevWatcher = process.platform === 'darwin' + && process.env.XDT_DESKTOP_DEV_WATCH_POLLING === '1'; + /** * 「带第三方依赖」的内部 workspace 包(maker-core / maker-cc-manager 等)仍走 optimizeDeps * 预打包(见上方注释:排除它们会把 CJS 子依赖拖出预打包,不安全)。但 Vite 的预打包缓存失效 @@ -347,6 +350,7 @@ const rendererConfig = { }, server: { watch: { + ...(pollDevWatcher ? { useFsEvents: false, usePolling: true, interval: 1_000 } : {}), // 被 exclude 的内部包以 node_modules 软链路径进模块图,默认 `**/node_modules/**` // 忽略规则会让 watcher 对它们的源码变更全盲(变更后引用新导出的组件热更、被引用包 // 却停在旧模块 → 白屏,见 discoverPureInternalPackages 顶注)。反向 glob 豁免之。 diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 7ec6707822e..40a27e3705d 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -8027,7 +8027,7 @@ export default function SessionScreen() { const selectSessionPermissionMode = useCallback((mode: string) => { void (async () => { if (!currentSession) return; - if (!await confirmFullAccessChange(currentSession.permissionMode, mode)) return; + if (!await confirmFullAccessChange(currentSession.permissionMode, mode, { agentKind: currentSession.agentKind })) return; await runControlAction( () => maker.setPermissionMode(sessionId, mode), { permissionMode: mode }, @@ -9699,6 +9699,7 @@ export default function SessionScreen() { ) : contextSheetView === 'collab' || contextSheetView === 'collab-create' ? ( { const result = normalizeMobileAgentCapabilities(await maker.getCapabilities(agent)); if (!result) throw new Error('Capabilities unavailable'); diff --git a/apps/mobile/app/sessions/new.tsx b/apps/mobile/app/sessions/new.tsx index aab0c6e1b50..82b83611d54 100644 --- a/apps/mobile/app/sessions/new.tsx +++ b/apps/mobile/app/sessions/new.tsx @@ -1474,6 +1474,7 @@ export default function NewRemoteSessionScreen() { const seqAtTrigger = ++runtimeActionSeqRef.current; void (async () => { const confirmed = await confirmFullAccessChange(draft.permissionMode, nextPermissionMode, { + agentKind: storedAgentKind, restoringRememberedChoice: storedPermissionMode !== undefined, }); if (cancelled) return; @@ -1528,6 +1529,7 @@ export default function NewRemoteSessionScreen() { let cancelled = false; const seqAtTrigger = runtimeActionSeqRef.current; void confirmFullAccessChange(draft.permissionMode, remembered, { + agentKind: draft.agentKind, restoringRememberedChoice: true, }).then((confirmed) => { if (cancelled || !confirmed || seqAtTrigger !== runtimeActionSeqRef.current) return; @@ -1575,6 +1577,7 @@ export default function NewRemoteSessionScreen() { : storedPermissionMode ?? defaultPermissionModeForNewSessionAgent(nextAgentKind); let cancelled = false; void confirmFullAccessChange(draft.permissionMode, nextPermissionMode, { + agentKind: nextAgentKind, restoringRememberedChoice: storedPermissionMode !== undefined, }).then((confirmed) => { if (cancelled || userTouchedRuntimeRef.current) return; @@ -2488,7 +2491,7 @@ export default function NewRemoteSessionScreen() { setAvailableAgentKinds( new Set( (Array.isArray(agents) ? agents : []).filter( - (a): a is NewSessionAgentKind => a === 'claude-code' || a === 'codex' || a === 'pi', + (a): a is NewSessionAgentKind => a === 'claude-code' || a === 'codex' || a === 'pi' || a === 'cursor', ), ), ); @@ -2888,6 +2891,7 @@ export default function NewRemoteSessionScreen() { const permission = config.agent === draft.agentKind ? draft.permissionMode : storedPermission ?? defaultPermissionModeForNewSessionAgent(config.agent); if (config.agent !== draft.agentKind && !await confirmFullAccessChange(draft.permissionMode, permission, { + agentKind: config.agent, restoringRememberedChoice: storedPermission !== undefined, })) return false; if (deviceAtStart !== selectedDeviceRef.current || sequence !== runtimeActionSeqRef.current) return false; @@ -2966,7 +2970,7 @@ export default function NewRemoteSessionScreen() { // 非 plan 档写 per-agent 记忆(内存 + 落盘;对齐桌面 lastByVendor)。 const selectPermissionMode = useCallback((mode: string) => { void (async () => { - if (!await confirmFullAccessChange(draft.permissionMode, mode)) return; + if (!await confirmFullAccessChange(draft.permissionMode, mode, { agentKind: draft.agentKind })) return; patchDraft({ permissionMode: mode }); if (mode === 'plan') return; // 老被控端兼容档,不入记忆 // 同步进本地 state:本次会话内切走再切回也能拿到最新记忆(落盘不回写 state)。 @@ -4362,6 +4366,7 @@ export default function NewRemoteSessionScreen() { storedPermissionMode ?? defaultPermissionModeForNewSessionAgent(nextKind); void confirmFullAccessChange(draft.permissionMode, nextPermissionMode, { + agentKind: nextKind, restoringRememberedChoice: storedPermissionMode !== undefined, }).then(async (confirmed) => { // 被控电脑默认还在读:等读完再选模型(切换已标记手动,之后自动默认不会再补)。 @@ -6964,6 +6969,7 @@ export default function NewRemoteSessionScreen() { <> { + it('requires a confirmed host runtime before offering Cursor creation', () => { + expect(availableNewSessionAgentOptions(null).some((option) => option.kind === 'cursor')).toBe(false); + expect(availableNewSessionAgentOptions(new Set(['claude-code', 'codex', 'pi'])).some((option) => option.kind === 'cursor')).toBe(false); + expect(availableNewSessionAgentOptions(new Set(['cursor']))).toEqual([{ kind: 'cursor', label: 'Cursor' }]); + }); + it('normalizes new and restored native selections before sending to the host', () => { + const draft = withAgentDefaults(DEFAULT_NEW_SESSION_DRAFT, 'cursor'); + expect(draft.permissionMode).toBe('ask'); + const restored = { ...draft, model: 'account-model', providerId: 'cursor', permissionMode: 'auto', effort: 'medium', fastMode: true }; + const options = buildRemoteCreateSessionOptions(restored); + expect(options).toMatchObject({ agentKind: 'cursor', model: 'account-model', providerId: 'cursor', permissionMode: 'auto', fastMode: true, effort: 'medium' }); + expect(withAgentDefaults(restored, 'cursor').fastMode).toBe(true); + expect(withAgentDefaults(restored, 'cursor').permissionMode).toBe('auto'); + }); + it('preserves the native identity in persisted sessions and switching intents', () => { + expect(sessionAgentKind({ agentKind: 'cursor' })).toBe('cursor'); + expect(mobileAgentLabel('cursor')).toBe('Cursor'); + expect(normalizeSessionAgentSwitchIntent({ targetAgentKind: 'cursor', model: 'native-model', providerId: 'cursor' })) + .toMatchObject({ targetAgentKind: 'cursor', model: 'native-model', providerId: 'cursor' }); + }); +}); diff --git a/apps/mobile/src/__tests__/cursorSessionModelPicker.test.ts b/apps/mobile/src/__tests__/cursorSessionModelPicker.test.ts new file mode 100644 index 00000000000..d8d0e0a59f1 --- /dev/null +++ b/apps/mobile/src/__tests__/cursorSessionModelPicker.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import type { AgentKind } from '@cindy/model-providers/types'; +import type { ProviderView } from '@cindy/model-providers/registry'; +import { mobileUnifiedEntries, resolveMobileModelConfig } from '@/session/unifiedMobileModels'; + +// Read the actual existing-task picker props; a fixture with a corrected agents list +// would miss this regression even if all shared catalog tests passed. +const source = ts.createSourceFile('screen.tsx', readFileSync( + resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8', +), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +let expression: ts.Expression | undefined; +function visit(node: ts.Node) { + if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) + && node.tagName.getText(source) === 'ModelPickerSheet') { + const attributes = node.attributes.properties.filter(ts.isJsxAttribute); + const id = attributes.find(attribute => attribute.name.getText(source) === 'testID'); + if (id?.initializer && ts.isStringLiteral(id.initializer) && id.initializer.text === 'session.modelSheet') { + const unified = attributes.find(attribute => attribute.name.getText(source) === 'unified'); + if (unified?.initializer && ts.isJsxExpression(unified.initializer) + && unified.initializer.expression && ts.isObjectLiteralExpression(unified.initializer.expression)) { + const agents = unified.initializer.expression.properties.find(property => + ts.isPropertyAssignment(property) && property.name.getText(source) === 'agents'); + if (agents && ts.isPropertyAssignment(agents)) expression = agents.initializer; + } + } + } + ts.forEachChild(node, visit); +} +visit(source); +if (!expression) throw new Error('Existing-task model picker agents were not found'); +const code = ts.transpileModule(`const agents = ${expression.getText(source)};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, +}).outputText; +const pickerAgents = new Function('sessionAgentSwitchSupported', 'sessionAgentKind', + `${code}; return agents;`) as (canSwitch: boolean, current: AgentKind) => AgentKind[]; + +const cursor: ProviderView = { + id: 'cursor', name: 'Cursor', source: 'builtin', auth: { method: 'none' }, connected: true, agents: ['cursor'], routing: {}, + models: { cursor: ['cursor-default', 'native-one', 'native-two'].map((id, index) => ({ + id, name: ['Cursor Default', 'Native Model One', 'Native Model Two'][index], + contextWindow: 0, efforts: [], defaultEffort: null, + })) }, +}; +const current = { providerId: 'cursor', modelId: 'cursor-default', agent: 'cursor' as const }; + +describe('Cursor models in the actual mobile existing-task picker', () => { + it.each(['cursor', 'codex'] as const)('lists native model choices while the current engine is %s', agent => { + const entries = mobileUnifiedEntries([cursor], pickerAgents(true, agent), {}, true, current); + expect(entries.map(entry => entry.modelId)).toEqual(['cursor-default', 'native-one', 'native-two']); + expect(entries.map(entry => entry.displayName)).toEqual(['Cursor Default', 'Native Model One', 'Native Model Two']); + expect(entries.every(entry => entry.nativeAgent === 'cursor')).toBe(true); + }); + it('retains the current engine when the host cannot switch engines', () => { + expect(pickerAgents(false, 'cursor')).toEqual(['cursor']); + expect(mobileUnifiedEntries([cursor], pickerAgents(false, 'cursor'), {}, true, current)).toHaveLength(3); + }); + it('continues honoring the host model visibility settings', () => { + const entries = mobileUnifiedEntries([cursor], pickerAgents(true, 'cursor'), { + 'cursor:cursor:native-one': false, + }, true, current); + expect(entries.map(entry => entry.modelId)).toEqual(['cursor-default', 'native-two']); + }); + it('selects the exact native model and keeps its Cursor engine and source', () => { + const entries = mobileUnifiedEntries([cursor], pickerAgents(true, 'cursor'), {}, true, current); + const entry = entries.find(item => item.modelId === 'native-two'); + expect(entry).toBeDefined(); + expect(resolveMobileModelConfig(entry!, { pinned: 'cursor', fastCapable: () => false })).toEqual({ + providerId: 'cursor', modelId: 'native-two', agent: 'cursor', effort: '', fast: false, + }); + }); +}); diff --git a/apps/mobile/src/__tests__/deviceCatalogRefresh.test.ts b/apps/mobile/src/__tests__/deviceCatalogRefresh.test.ts index 3ed1f159982..9c624b47845 100644 --- a/apps/mobile/src/__tests__/deviceCatalogRefresh.test.ts +++ b/apps/mobile/src/__tests__/deviceCatalogRefresh.test.ts @@ -101,7 +101,8 @@ describe('catalog invalidation under a slow device link', () => { for (let i = 0; i < 4; i++) { refresh.notify('a'); await vi.advanceTimersByTimeAsync(2); } await flush(); expect(readProviders).toHaveBeenCalledTimes(1); - expect(readCapabilities).toHaveBeenCalledTimes(3); + expect(readCapabilities).toHaveBeenCalledTimes(4); + expect(readCapabilities.mock.calls.map(([, agent]) => agent)).toEqual(['claude-code', 'codex', 'pi', 'cursor']); // A mounted page joins the same capability read as the push refresh. const pageRead = vi.fn(); const joined = caps.fetchAgentCapabilities('a', 'codex', pageRead); @@ -118,7 +119,7 @@ describe('catalog invalidation under a slow device link', () => { await flush(); expect(pageRead).not.toHaveBeenCalled(); expect(readProviders).toHaveBeenCalledTimes(2); - expect(readCapabilities).toHaveBeenCalledTimes(6); + expect(readCapabilities).toHaveBeenCalledTimes(8); last.resolve(catalog('latest')); await flush(); expect(published).toHaveBeenCalledExactlyOnceWith(catalog('latest')); diff --git a/apps/mobile/src/__tests__/fullAccessConfirmation.test.ts b/apps/mobile/src/__tests__/fullAccessConfirmation.test.ts index 87d10a00b98..bec5ad53b19 100644 --- a/apps/mobile/src/__tests__/fullAccessConfirmation.test.ts +++ b/apps/mobile/src/__tests__/fullAccessConfirmation.test.ts @@ -17,6 +17,12 @@ function confirmingAlert() { } describe("getFullAccessConfirmationCopy", () => { + it.each(['en', 'zh-CN', 'zh-TW', 'ja', 'ko'])('describes native Cursor approval boundaries in %s', locale => { + const copy = getFullAccessConfirmationCopy(locale, 'cursor'); + expect(copy.description).toContain('Cursor'); + expect(copy.description).not.toBe(getFullAccessConfirmationCopy(locale).description); + expect(copy.confirm).toBe(getFullAccessConfirmationCopy(locale).confirm); + }); it("selects the supported system language and falls back to English", () => { expect(getFullAccessConfirmationCopy("ja").confirm).toBe( "フルアクセスを有効にする", @@ -37,6 +43,12 @@ describe("getFullAccessConfirmationCopy", () => { }); describe("confirmFullAccessChange", () => { + it('keeps explicit Full access confirmation and uses the selected Cursor scope', async () => { + const showAlert = confirmingAlert(); + await expect(confirmFullAccessChange('auto', 'bypassPermissions', { agentKind: 'cursor', showAlert })).resolves.toBe(true); + expect(showAlert).toHaveBeenCalledOnce(); + expect(showAlert.mock.calls[0][1]).toBe(getFullAccessConfirmationCopy(undefined, 'cursor').description); + }); it("does not show an alert when the change does not enter Full access", async () => { const showAlert = vi.fn(); diff --git a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts index 6821b4df942..dde176686c1 100644 --- a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts +++ b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts @@ -397,9 +397,9 @@ describe('mobile home desktop-first surface', () => { expect(providerMarkSource).not.toContain('CLAUDE_AGENT_PATH'); expect(providerMarkSource).not.toContain('CODEX_AGENT_FLOWER_PATH'); expect(vendorIconSource).toContain("import { MobileAgentMark } from './MobileAgentMark';"); - // vendor → Agent mark 的映射要带上 pi,不能把 π 吞成 Claude 脸。 + // Keep both Pi and Cursor identities when wrapping the mark with a remote signal. expect(vendorIconSource).toContain( - "const agentKind: AgentMarkKind = vendor === 'codex' || vendor === 'pi' ? vendor : 'claude-code';", + "const agentKind: AgentMarkKind = vendor === 'codex' || vendor === 'pi' || vendor === 'cursor' ? vendor : 'claude-code';", ); expect(vendorIconSource).toContain(''); expect(vendorIconSource).not.toContain('viewBox="136 137 282 158"'); diff --git a/apps/mobile/src/__tests__/modelPickerHarnessSwitch.test.ts b/apps/mobile/src/__tests__/modelPickerHarnessSwitch.test.ts index 3e13c7b8ff6..94a35352ca0 100644 --- a/apps/mobile/src/__tests__/modelPickerHarnessSwitch.test.ts +++ b/apps/mobile/src/__tests__/modelPickerHarnessSwitch.test.ts @@ -137,3 +137,16 @@ it('does not move the Agent when the controller may not change its location', as expect(await h.render()({ ...config, agent: 'codex' })).toBe(true); expect(h.setComposerModel).toHaveBeenCalled(); }); + +it('changes the exact Cursor model even when an older controller uses the current-engine picker', async () => { + const h = harness(true, true, { agentKind: 'cursor', model: 'cursor-default', providerId: 'cursor', effort: '' }, { + sessionAgentKind: 'cursor', sessionAgentSwitchSupported: false, + }); + const nativeModel = 'native[context=272k,reasoning=medium,fast=false]'; + expect(await h.render()({ agent: 'cursor', providerId: 'cursor', modelId: nativeModel, effort: '', fast: false })) + .toBe(true); + expect(h.setComposerModel).toHaveBeenCalledWith(expect.objectContaining({ model: nativeModel, providerId: 'cursor' })); + expect(h.writeSessionAgentSwitchIntent).not.toHaveBeenCalled(); + expect(h.confirmMobileSessionAgentSwitch).not.toHaveBeenCalled(); + expect(h.state.agent).toBe('cursor'); +}); diff --git a/apps/mobile/src/__tests__/newSession.test.ts b/apps/mobile/src/__tests__/newSession.test.ts index d26abf4e602..e257720be59 100644 --- a/apps/mobile/src/__tests__/newSession.test.ts +++ b/apps/mobile/src/__tests__/newSession.test.ts @@ -1403,7 +1403,7 @@ describe('new session model', () => { it('exposes Pi as a first-class agent and preserves Fast for Pi sessions', () => { expect(NEW_SESSION_AGENT_OPTIONS.map((option) => option.kind)).toEqual([ - 'claude-code', 'codex', 'pi', + 'claude-code', 'codex', 'pi', 'cursor', ]); const pi = withAgentDefaults({ ...DRAFT_WITH_MODEL, fastMode: true }, 'pi'); expect(pi).toMatchObject({ agentKind: 'pi', model: '', fastMode: true }); diff --git a/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts b/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts index 67a4b60a9df..5b9c1bab2f9 100644 --- a/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts +++ b/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts @@ -19,6 +19,11 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ })); describe('newSessionPreferenceStore', () => { + it.each(['ask', 'default', 'auto', 'bypassPermissions'])('keeps the saved Cursor permission %s on reload', async mode => { + const { readNewSessionPreferences, saveNewSessionPreferences } = await import('@/session/newSessionPreferenceStore'); + await saveNewSessionPreferences({ permissionModeForAgent: { agentKind: 'cursor', mode } }); + expect((await readNewSessionPreferences()).permissionModeByAgent.cursor).toBe(mode); + }); beforeEach(async () => { vi.clearAllMocks(); store.clear(); diff --git a/apps/mobile/src/__tests__/orcaTeam.test.ts b/apps/mobile/src/__tests__/orcaTeam.test.ts index d77fa02d1f5..b9424fc4d5f 100644 --- a/apps/mobile/src/__tests__/orcaTeam.test.ts +++ b/apps/mobile/src/__tests__/orcaTeam.test.ts @@ -493,3 +493,12 @@ describe('mobile Orca collaboration mutations', () => { }); }); + +describe('Cursor worker native settings', () => { + it('preserves Cursor identity, model tuning and native approval', () => { + const form = orcaWorkerFormFromPrefs(defaultOrcaWorkerCreationPrefs(), 'cursor'); + expect(form.permissionMode).toBe('bypassPermissions'); + const options = buildOrcaEnableOptions({ ...form, permissionMode: 'auto', model: { id: 'account-model', providerId: 'cursor', effort: 'medium', fast: true } }); + expect(options).toMatchObject({ model: 'account-model', providerId: 'cursor', workerPermissionMode: 'auto', fast: true, effort: 'medium' }); + }); +}); diff --git a/apps/mobile/src/__tests__/orcaWorkerPrefs.test.ts b/apps/mobile/src/__tests__/orcaWorkerPrefs.test.ts index 6f5ade781b7..bdcfdbfe2ab 100644 --- a/apps/mobile/src/__tests__/orcaWorkerPrefs.test.ts +++ b/apps/mobile/src/__tests__/orcaWorkerPrefs.test.ts @@ -30,6 +30,7 @@ describe('Worker creation preferences', () => { agents: { codex: { model: 'codex/gpt-5.5', effort: 'high', fast: false }, 'claude-code': { model: 'claude-opus-4-7', effort: 'high', fast: false }, + cursor: { model: 'cursor-default', effort: 'medium', fast: false }, pi: { model: 'claude-sonnet-4-6', effort: 'high', fast: false }, }, workerPermissionMode: 'bypassPermissions', diff --git a/apps/mobile/src/__tests__/unifiedMobilePicker.test.tsx b/apps/mobile/src/__tests__/unifiedMobilePicker.test.tsx index 763e34aeb08..d342c5f145d 100644 --- a/apps/mobile/src/__tests__/unifiedMobilePicker.test.tsx +++ b/apps/mobile/src/__tests__/unifiedMobilePicker.test.tsx @@ -86,6 +86,37 @@ it('search spans all sources and clearing preserves the selected filter',async() await act(async()=>test.view.onQuery(''));expect(test.view.groups).toHaveLength(0);expect(test.view.filter).toBe('favorites'); }); +it.each([null, 'other-mac'])('keeps Cursor pools and native configuration on computer %s', async deviceId => { + test.entries = [ + { modelId: 'default', displayName: 'Auto', group: 'cursor:auto', efforts: [], defaultEffort: null, fast: false }, + { modelId: 'grok-4.7', displayName: 'Grok 4.7', group: 'cursor:models', efforts: ['low', 'high'], defaultEffort: 'high', fast: true }, + { modelId: 'gpt-5.6-sol', displayName: 'GPT-5.6 Sol', group: 'cursor:other', efforts: ['low', 'medium'], defaultEffort: 'medium', fast: false }, + ].map(({ efforts, defaultEffort, fast, ...entry }) => ({ + ...entry, providerId: 'cursor', candidates: ['cursor'], recommended: 'cursor', nativeAgent: 'cursor', + capabilities: { cursor: { wireModelId: entry.modelId, efforts, defaultEffort, supportsFastMode: fast } }, + })); + const provider = { id: 'cursor', name: 'Cursor', models: {}, connected: true }; + const onSelect = vi.fn(async () => true); + await mount(onSelect, { + providers: deviceId ? [] : [provider], agentKind: 'cursor', activeModelId: 'grok-4.7', selectedProviderId: 'cursor', + unified: { scope: 'user-device', agents: ['cursor'], loadCapabilities: async () => ({}), + onSelect, + ...(deviceId ? { remote: { catalogs: [{ deviceId, name: 'Other Mac', status: 'ready', providers: [provider] }], selectedDeviceId: null } } : {}), + }, + }); + expect(test.view.groups.map((g: any) => g.title)).toEqual([ + deviceId ? 'models.unified.remoteProvider' : 'Cursor', + `${deviceId ? 'models.unified.remoteProvider · ' : ''}models.cursorGroups.models`, + `${deviceId ? 'models.unified.remoteProvider · ' : ''}models.cursorGroups.other`, + ]); + const row = test.view.groups[2].rows[0]; + expect(row.entry.modelId).toBe('gpt-5.6-sol'); + expect(row.config).toMatchObject({ providerId: 'cursor', modelId: 'gpt-5.6-sol', agent: 'cursor', effort: 'medium', fast: false }); + await act(async () => test.view.onSelect(row)); + // The same native model is routed to the chosen execution computer, never to a GPT provider. + expect(onSelect).toHaveBeenCalledWith(row.config, { deviceId }); +}); + it('preserves remote provider branding in model rows and source choices',async()=>{ await mount(undefined,{providers:[{id:'account',name:'OpenAI · account',models:{},connected:true,logoKind:'openai'}]}); expect(test.view.groups[0].rows[0].providerMark).toMatchObject({providerId:'account',name:'OpenAI · account',logoKind:'openai'}); diff --git a/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx b/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx index 2104c2c263b..0d48b71bed4 100644 --- a/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx +++ b/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx @@ -88,6 +88,17 @@ it('does not let a late capability response from A overwrite a model chosen on B expect(latest!.modelPicker.flatModelOptions.map((option) => option.id)).toEqual(['b-model']); }); +it('does not offer Cursor as a Worker on another execution device', async () => { + const lead = { ...fakeMaker(), orca: { listExecutionDevices: vi.fn(async () => ({ devices: [ + { deviceId: 'b', name: 'B', supported: true }, + ] })) } } as unknown as MobileMakerTransport; + const remote = { ...fakeMaker(), listAvailableAgents: vi.fn(async () => ['cursor', 'pi']) } as unknown as MobileMakerTransport; + await act(async () => { root.render( remote} />); await flush(); }); + await act(async () => { latest!.patch({ executionDeviceId: 'b' }); await flush(); }); + expect(latest!.agents).toEqual(['pi']); + expect(latest!.form.agent).toBe('pi'); +}); + function LocationProbe({ maker, lead, selectable }: { maker: MobileMakerTransport; lead: string | null; selectable: boolean }) { latest = useOrcaWorkerForm({ maker, prefsScope: 'user-1', active: true, setSheetOpen: () => undefined, @@ -142,6 +153,13 @@ it('leaves the Worker location unset when the controlled computer cannot honor i expect(latest!.form.agentDeviceId).toBeUndefined(); }); +it('does not offer Cursor when the Lead Agent is on another computer', async () => { + const maker = { ...fakeMaker(), listAvailableAgents: vi.fn(async () => ['cursor', 'codex', 'pi']) } as unknown as MobileMakerTransport; + await act(async () => { root.render(); await flush(); }); + expect(latest!.agents).not.toContain('cursor'); + expect(latest!.form.agent).not.toBe('cursor'); +}); + it('keeps local Worker creation available on older Leads without the device-list channel', async () => { const lead = { ...fakeMaker(), orca: { listExecutionDevices: vi.fn(async () => { throw new Error('[DEVICE_LINK_CHANNEL_NOT_ALLOWED] old'); }) } } as unknown as MobileMakerTransport; await act(async () => { root.render( fakeMaker()} />); await flush(); }); @@ -548,6 +566,40 @@ it('restores the remembered Agent after a device switch instead of the previous expect(latest!.form.agent).toBe('claude-code'); }); +it('offers Cursor only after the host confirms it and preserves permission on roster restoration', async () => { + const maker = { ...fakeMaker(), listAvailableAgents: vi.fn(async () => ['cursor']) } as unknown as MobileMakerTransport; + function NativeProbe() { + latest = useOrcaWorkerForm({ maker, prefsScope: 'user-1', active: true, setSheetOpen: () => undefined }); + return null; + } + await act(async () => { root.render(); await flush(); }); + expect(latest!.agents).toEqual(['cursor']); + expect(latest!.form.agent).toBe('cursor'); + expect(latest!.form.permissionMode).toBe('bypassPermissions'); +}); + +it('preserves the permission mode when confirming a Cursor model choice', async () => { + await act(async () => root.render()); + await act(async () => { await latest!.modelPicker.select({ agent: 'cursor', providerId: 'cursor', modelId: 'cursor-default', effort: '', fast: false }); }); + expect(latest!.form.agent).toBe('cursor'); + expect(latest!.form.permissionMode).toBe('bypassPermissions'); +}); + +it.each(['ask', 'default'])('keeps an older Cursor host with only %s approvals usable', async mode => { + const maker = { ...fakeMaker(), + listAvailableAgents: vi.fn(async () => ['cursor']), + getCapabilities: vi.fn(async () => ({ permissionModes: [{ id: mode, displayName: 'Default permissions' }] })), + } as unknown as MobileMakerTransport; + function NativeProbe() { + latest = useOrcaWorkerForm({ maker, prefsScope: 'user-1', active: true, setSheetOpen: () => undefined }); + return null; + } + await act(async () => { root.render(); await flush(); }); + expect(latest!.form.agent).toBe('cursor'); + expect(latest!.form.permissionMode).toBe('ask'); + expect(latest!.permissionModes).toEqual(['ask']); +}); + it('lets a Worker task archive itself back to the Lead', async () => { const { Alert } = await import('react-native'); const { useSessionOrcaCollab } = await import('@/session/useSessionOrcaCollab'); diff --git a/apps/mobile/src/components/MobileAgentMark.tsx b/apps/mobile/src/components/MobileAgentMark.tsx index fb82ccf0432..404030cf527 100644 --- a/apps/mobile/src/components/MobileAgentMark.tsx +++ b/apps/mobile/src/components/MobileAgentMark.tsx @@ -19,7 +19,7 @@ const CODEX_SMALL_STROKE = 2; const CODEX_LARGE_STROKE = 1.6; export interface MobileAgentMarkProps { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; color: string; size?: number; } @@ -29,7 +29,9 @@ export function MobileAgentMark({ agentKind, color, size = iconSize.sm }: Mobile const codexStrokeWidth = size <= iconSize.sm ? CODEX_SMALL_STROKE : CODEX_LARGE_STROKE; return ( - {agentKind === 'pi' ? ( + {agentKind === 'cursor' ? ( + + ) : agentKind === 'pi' ? ( // Keep all three strokes in one native path. Separate horizontal/vertical // paths have degenerate bounds and can disappear in the iOS SVG renderer. = { 'claude-code': { x: 0.95, y: 0.13 }, codex: { x: 0.9, y: 0.1 }, pi: { x: 0.93, y: 0.19 }, + cursor: { x: 0.9, y: 0.15 }, }; interface MobileVendorIconProps { @@ -80,13 +81,13 @@ export function MobileVendorIcon({ }; }, [animate, opacity]); - const agentKind: AgentMarkKind = vendor === 'codex' || vendor === 'pi' ? vendor : 'claude-code'; + const agentKind: AgentMarkKind = vendor === 'codex' || vendor === 'pi' || vendor === 'cursor' ? vendor : 'claude-code'; const mark = ; const anchor = REMOTE_SIGNAL_ANCHOR[agentKind]; return ( diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx index 9e3c181e315..0d4d4ad341e 100644 --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx @@ -21,6 +21,7 @@ import { CONTROLLER_CAPABILITY_SESSION_TEXT_SNAPSHOT_V1, CONTROLLER_CAPABILITY_SESSION_LIST_MESSAGES_V1, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, + CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1, DEVICE_LINK_CAPABILITY_COMPACT_MESSAGE_HISTORY_V1, DL_SUBSCRIBE_CHANNEL, DL_UNSUBSCRIBE_CHANNEL, @@ -232,6 +233,7 @@ const CONTROLLER_CAPABILITIES = [ SHARED_TASK_CAPABILITY, CONTROLLER_CAPABILITY_SESSION_TEXT_SNAPSHOT_V1, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, + CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1, // maker:event 微批:被控端把同一会话的连续事件合并成一帧,本端拆包后逐条消费 // (见 remoteSessionStore 的 MAKER_EVENT_BATCH_CHANNEL 分支)。 CONTROLLER_CAPABILITY_MAKER_EVENT_BATCH_V1, diff --git a/apps/mobile/src/device-link/deviceCatalogRefresh.ts b/apps/mobile/src/device-link/deviceCatalogRefresh.ts index 843485cbb57..a4573408729 100644 --- a/apps/mobile/src/device-link/deviceCatalogRefresh.ts +++ b/apps/mobile/src/device-link/deviceCatalogRefresh.ts @@ -13,7 +13,7 @@ import { normalizeMobileAgentCapabilities } from '@/session/agentCapabilities'; * parallel RPCs. Reuse peer recovery's serialized rerun and cancellation rules. */ export function createDeviceCatalogRefresh(options: { readProviders(deviceId: string): Promise; - readCapabilities(deviceId: string, agent: 'claude-code' | 'codex' | 'pi'): Promise; + readCapabilities(deviceId: string, agent: 'claude-code' | 'codex' | 'pi' | 'cursor'): Promise; connectionEpoch(): number; canRead?(deviceId: string): boolean; }) { @@ -40,7 +40,7 @@ export function createDeviceCatalogRefresh(options: { fetchDeviceProviders(id, () => read(() => options.readProviders(id))).then(() => { if (!disposed && getDeviceProvidersGen(id) === providerGeneration) markDeviceFetchEpoch(id, epoch); }), - ...(['claude-code', 'codex', 'pi'] as const).map(async (agent) => { + ...(['claude-code', 'codex', 'pi', 'cursor'] as const).map(async (agent) => { const raw = await fetchAgentCapabilities(id, agent, () => read(() => options.readCapabilities(id, agent))); const normalized = normalizeMobileAgentCapabilities(raw); if (!disposed && normalized) commitAgentCapabilities(id, agent, generation, normalized); diff --git a/apps/mobile/src/device-link/mobileMakerTransport.ts b/apps/mobile/src/device-link/mobileMakerTransport.ts index c3c842e59ae..b0625b58b7b 100644 --- a/apps/mobile/src/device-link/mobileMakerTransport.ts +++ b/apps/mobile/src/device-link/mobileMakerTransport.ts @@ -101,7 +101,7 @@ export interface SendOptions { } export interface CreateSessionOptions { - agentKind: "claude-code" | "codex" | "pi"; + agentKind: "claude-code" | "codex" | "pi" | "cursor"; /** * 控制端预生成的 sessionId(新建会话乐观管线用):被控端 readCreateSessionOpts * 自手机远控首版(2026-06-21)起透传 body.id,maker-core createSession 对 @@ -133,7 +133,7 @@ export interface CreateSessionResult { usedProjectContext?: boolean; } -export type MobileAgentKind = "claude-code" | "codex" | "pi"; +export type MobileAgentKind = "claude-code" | "codex" | "pi" | "cursor"; /** * 订阅家族 → 被控端余量快照 channel。ChatGPT 走 Codex 自有控制面 diff --git a/apps/mobile/src/i18n/locales/en/interaction.json b/apps/mobile/src/i18n/locales/en/interaction.json index 9d998071944..162282c8f6c 100644 --- a/apps/mobile/src/i18n/locales/en/interaction.json +++ b/apps/mobile/src/i18n/locales/en/interaction.json @@ -22,6 +22,7 @@ "highRisk": "High risk", "autoReviewUnavailable": "Automatic review could not finish. Confirm whether to allow this action.", "fullAccessConfirm": { + "cursorDescription": "Cindy will directly allow approval requests from Cursor without asking for each one:\n\nDirectly allow file read and modification requests from Cursor\nDirectly allow terminal command requests from Cursor\nDirectly allow network operation requests from Cursor\n\nCursor’s native permission policy still applies. This choice does not change its native sandbox or persistent grants. Built-in high-risk operations still require confirmation.", "title": "Enable Full access?", "description": "Full access disables the workspace sandbox and skips routine approvals. {{appName}} can modify files outside the workspace and run network commands without asking; built-in high-risk operations will still require confirmation.", "confirm": "Enable Full access", diff --git a/apps/mobile/src/i18n/locales/en/models.json b/apps/mobile/src/i18n/locales/en/models.json index 03ff9f45bcc..2dcb2357594 100644 --- a/apps/mobile/src/i18n/locales/en/models.json +++ b/apps/mobile/src/i18n/locales/en/models.json @@ -107,5 +107,9 @@ "minute": "m", "second": "s" } + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } } diff --git a/apps/mobile/src/i18n/locales/en/session.json b/apps/mobile/src/i18n/locales/en/session.json index 3da0ca4756e..4f0dafe0a6e 100644 --- a/apps/mobile/src/i18n/locales/en/session.json +++ b/apps/mobile/src/i18n/locales/en/session.json @@ -597,6 +597,10 @@ "modelDefault": "Default", "modelDefaultHint": "Uses the computer's default Worker model", "permissionLabel": "Permission mode", + "permissionAsk": "Default permissions", + "cursorPermissionAutoHint": "Cindy automatically reviews approval requests from Cursor and asks you when needed. Cursor’s native permission policy still applies.", + "cursorPermissionFullHint": "Cindy allows approval requests from Cursor without another confirmation. Highest risk; Cursor’s native permission policy still applies.", + "permissionAskHint": "Cursor follows its native configured permission policy. Cindy shows only the approval requests Cursor sends; workspace edits may run without a prompt.", "permissionAuto": "Auto-review", "permissionFull": "Full access", "permissionAutoHint": "Reads and writes inside the workspace and auto-reviews elevation requests. High-risk actions may be denied or need confirmation.", diff --git a/apps/mobile/src/i18n/locales/ja/interaction.json b/apps/mobile/src/i18n/locales/ja/interaction.json index b06904b9075..a501506b93e 100644 --- a/apps/mobile/src/i18n/locales/ja/interaction.json +++ b/apps/mobile/src/i18n/locales/ja/interaction.json @@ -22,6 +22,7 @@ "highRisk": "高リスク", "autoReviewUnavailable": "自動レビューを完了できませんでした。この操作を許可するか確認してください。", "fullAccessConfirm": { + "cursorDescription": "Cindy は Cursor からの承認リクエストを、個別に確認せず許可します:\n\nCursor からのファイルの読み取り・変更リクエストを許可\nCursor からのターミナルコマンドのリクエストを許可\nCursor からのネットワーク操作のリクエストを許可\n\nCursor 本来の権限ポリシーは引き続き適用されます。この選択でネイティブのサンドボックスや永続的な許可は変更されません。組み込みの高リスク操作には引き続き確認が必要です。", "title": "フルアクセスを有効にしますか?", "description": "フルアクセスはワークスペースのサンドボックスを無効にし、通常の承認を省略します。{{appName}} はワークスペース外のファイル変更やネットワークコマンドを確認なしで実行できます。組み込みの高リスク操作では引き続き確認が必要です。", "confirm": "フルアクセスを有効にする", diff --git a/apps/mobile/src/i18n/locales/ja/models.json b/apps/mobile/src/i18n/locales/ja/models.json index f6a0570b2ee..026ae3607c7 100644 --- a/apps/mobile/src/i18n/locales/ja/models.json +++ b/apps/mobile/src/i18n/locales/ja/models.json @@ -107,5 +107,9 @@ "minute": "分", "second": "秒" } + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } } diff --git a/apps/mobile/src/i18n/locales/ja/session.json b/apps/mobile/src/i18n/locales/ja/session.json index 1e87be33534..e16279e49b3 100644 --- a/apps/mobile/src/i18n/locales/ja/session.json +++ b/apps/mobile/src/i18n/locales/ja/session.json @@ -597,6 +597,10 @@ "modelDefault": "デフォルト", "modelDefaultHint": "コンピューター側の Worker 既定モデルを使います", "permissionLabel": "権限モード", + "permissionAsk": "デフォルトの権限", + "cursorPermissionAutoHint": "Cindy が Cursor からの承認要求を自動レビューし、必要な場合は確認を求めます。Cursor のネイティブ権限ポリシーは引き続き適用されます。", + "cursorPermissionFullHint": "Cindy が Cursor からの承認要求を追加確認なしで許可します。最もリスクが高いモードです。Cursor のネイティブ権限ポリシーは引き続き適用されます。", + "permissionAskHint": "Cursor はネイティブに設定された権限ポリシーに従います。Cindy は Cursor から送られた承認要求のみを表示し、ワークスペース内の編集は確認なしで実行される場合があります。", "permissionAuto": "自動承認", "permissionFull": "フルアクセス", "permissionAutoHint": "ワークスペース内の読み書きを許可し、権限昇格を自動承認します。高リスクの操作は拒否または確認が必要になることがあります。", diff --git a/apps/mobile/src/i18n/locales/ko/interaction.json b/apps/mobile/src/i18n/locales/ko/interaction.json index 3827a678b06..d9a8512a119 100644 --- a/apps/mobile/src/i18n/locales/ko/interaction.json +++ b/apps/mobile/src/i18n/locales/ko/interaction.json @@ -22,6 +22,7 @@ "highRisk": "고위험", "autoReviewUnavailable": "자동 리뷰를 끝내지 못했습니다. 이 작업을 허용할지 확인해 주세요.", "fullAccessConfirm": { + "cursorDescription": "Cindy는 Cursor가 보내는 승인 요청을 개별 확인 없이 허용합니다:\n\nCursor의 파일 읽기 및 수정 요청을 바로 허용\nCursor의 터미널 명령 요청을 바로 허용\nCursor의 네트워크 작업 요청을 바로 허용\n\nCursor의 기본 권한 정책은 계속 적용됩니다. 이 선택은 기본 샌드박스나 지속적인 권한 설정을 변경하지 않습니다. 내장 고위험 작업에는 여전히 확인이 필요합니다.", "title": "전체 접근을 활성화할까요?", "description": "전체 접근은 작업 공간 샌드박스를 비활성화하고 일반 승인을 건너뜁니다. {{appName}}가 작업 공간 밖의 파일을 수정하고 네트워크 명령을 묻지 않고 실행할 수 있습니다. 기본 제공 고위험 작업은 계속 확인을 요청합니다.", "confirm": "전체 접근 활성화", diff --git a/apps/mobile/src/i18n/locales/ko/models.json b/apps/mobile/src/i18n/locales/ko/models.json index 6241eba2ae4..b6cfadae5ce 100644 --- a/apps/mobile/src/i18n/locales/ko/models.json +++ b/apps/mobile/src/i18n/locales/ko/models.json @@ -107,5 +107,9 @@ "minute": "분", "second": "초" } + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } } diff --git a/apps/mobile/src/i18n/locales/ko/session.json b/apps/mobile/src/i18n/locales/ko/session.json index 9440777c093..08005092020 100644 --- a/apps/mobile/src/i18n/locales/ko/session.json +++ b/apps/mobile/src/i18n/locales/ko/session.json @@ -593,6 +593,10 @@ "modelDefault": "기본값", "modelDefaultHint": "컴퓨터의 Worker 기본 모델을 사용합니다", "permissionLabel": "권한 모드", + "permissionAsk": "기본 권한", + "cursorPermissionAutoHint": "Cindy가 Cursor의 승인 요청을 자동으로 검토하고 필요할 때 확인을 요청합니다. Cursor의 기본 권한 정책은 계속 적용됩니다.", + "cursorPermissionFullHint": "Cindy가 추가 확인 없이 Cursor의 승인 요청을 허용합니다. 가장 위험한 모드이며 Cursor의 기본 권한 정책은 계속 적용됩니다.", + "permissionAskHint": "Cursor는 자체 설정된 권한 정책을 따릅니다. Cindy는 Cursor가 보내는 승인 요청만 표시하며, 작업 공간 내 편집은 확인 없이 실행될 수 있습니다.", "permissionAuto": "자동 승인", "permissionFull": "전체 액세스", "permissionAutoHint": "작업 공간 안에서 읽고 쓰며 권한 상승 요청을 자동 승인합니다. 위험도가 높은 작업은 거부되거나 확인이 필요할 수 있습니다.", diff --git a/apps/mobile/src/i18n/locales/zh-CN/interaction.json b/apps/mobile/src/i18n/locales/zh-CN/interaction.json index 39b1d66118f..f9e6fb3563e 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/interaction.json +++ b/apps/mobile/src/i18n/locales/zh-CN/interaction.json @@ -22,6 +22,7 @@ "highRisk": "高风险", "autoReviewUnavailable": "自动审批没完成,请确认要不要允许这次操作。", "fullAccessConfirm": { + "cursorDescription": "Cindy 将直接允许 Cursor 发来的授权请求,不再逐项询问:\n\n直接允许 Cursor 发来的文件读取和修改请求\n直接允许 Cursor 发来的终端命令请求\n直接允许 Cursor 发来的联网操作请求\n\nCursor 原生权限策略仍然生效。此选择不会更改原生沙箱或长期授权;内置高风险操作仍会要求确认。", "title": "开启完全访问?", "description": "完全访问会关闭工作区沙箱并跳过常规审批。{{appName}} 可以修改工作区外的文件、执行联网命令且不再询问;内置高风险操作仍会要求确认。", "confirm": "开启完全访问", diff --git a/apps/mobile/src/i18n/locales/zh-CN/models.json b/apps/mobile/src/i18n/locales/zh-CN/models.json index 11b56c4379f..0c27c069201 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/models.json +++ b/apps/mobile/src/i18n/locales/zh-CN/models.json @@ -107,5 +107,9 @@ "minute": "分钟", "second": "秒" } + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } } diff --git a/apps/mobile/src/i18n/locales/zh-CN/session.json b/apps/mobile/src/i18n/locales/zh-CN/session.json index 1b867bb3527..0fc7928b183 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/session.json +++ b/apps/mobile/src/i18n/locales/zh-CN/session.json @@ -593,6 +593,10 @@ "modelDefault": "默认", "modelDefaultHint": "跟随电脑端的 Worker 默认模型", "permissionLabel": "权限模式", + "permissionAsk": "默认权限", + "cursorPermissionAutoHint": "Cindy 自动审核 Cursor 发来的授权请求,必要时由你确认。Cursor 原生权限策略仍生效。", + "cursorPermissionFullHint": "Cindy 直接允许 Cursor 发来的授权请求,无需再次确认。风险最高;Cursor 原生权限策略仍生效。", + "permissionAskHint": "Cursor 遵循其原生配置的权限策略。Cindy 只展示 Cursor 发来的授权请求;工作区内的编辑可能无需确认便直接执行。", "permissionAuto": "自动审批", "permissionFull": "完全访问", "permissionAutoHint": "允许在工作区内读写,并自动审批提权请求;高风险操作可能被拒绝或要求确认。", diff --git a/apps/mobile/src/i18n/locales/zh-TW/interaction.json b/apps/mobile/src/i18n/locales/zh-TW/interaction.json index 1c082c73e9b..7b1ec758c20 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/interaction.json +++ b/apps/mobile/src/i18n/locales/zh-TW/interaction.json @@ -22,6 +22,7 @@ "highRisk": "高風險", "autoReviewUnavailable": "自動審批沒完成,請確認要不要允許這次操作。", "fullAccessConfirm": { + "cursorDescription": "Cindy 將直接允許 Cursor 發來的授權請求,不再逐項詢問:\n\n直接允許 Cursor 發來的檔案讀取與修改請求\n直接允許 Cursor 發來的終端指令請求\n直接允許 Cursor 發來的連網操作請求\n\nCursor 原生權限策略仍然生效。此選擇不會更改原生沙箱或長期授權;內建高風險操作仍會要求確認。", "title": "開啟完全訪問?", "description": "完全訪問會關閉工作區沙箱並跳過常規審批。{{appName}} 可以修改工作區外的檔案、執行聯網命令且不再詢問;內建高風險操作仍會要求確認。", "confirm": "開啟完全訪問", diff --git a/apps/mobile/src/i18n/locales/zh-TW/models.json b/apps/mobile/src/i18n/locales/zh-TW/models.json index e157d232c3f..9ce980775d4 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/models.json +++ b/apps/mobile/src/i18n/locales/zh-TW/models.json @@ -107,5 +107,9 @@ "minute": "分鐘", "second": "秒" } + }, + "cursorGroups": { + "models": "Cursor Models", + "other": "Other Models" } } diff --git a/apps/mobile/src/i18n/locales/zh-TW/session.json b/apps/mobile/src/i18n/locales/zh-TW/session.json index 678a3b5a5e4..cad78efb8ba 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/session.json +++ b/apps/mobile/src/i18n/locales/zh-TW/session.json @@ -597,6 +597,10 @@ "modelDefault": "預設", "modelDefaultHint": "沿用電腦端的 Worker 預設模型", "permissionLabel": "權限模式", + "permissionAsk": "預設權限", + "cursorPermissionAutoHint": "Cindy 自動審核 Cursor 傳來的授權請求,必要時由你確認。Cursor 原生權限政策仍然生效。", + "cursorPermissionFullHint": "Cindy 直接允許 Cursor 傳來的授權請求,無須再次確認。風險最高;Cursor 原生權限政策仍然生效。", + "permissionAskHint": "Cursor 遵循其原生設定的權限政策。Cindy 只顯示 Cursor 傳來的授權請求;工作區內的編輯可能無須確認便直接執行。", "permissionAuto": "自動審批", "permissionFull": "完全存取", "permissionAutoHint": "允許在工作區內讀寫,並自動審批提權請求;高風險操作可能被拒絕或要求確認。", diff --git a/apps/mobile/src/session/CompanionModelChain.tsx b/apps/mobile/src/session/CompanionModelChain.tsx index b9a9a4c1ff0..033939c9d25 100644 --- a/apps/mobile/src/session/CompanionModelChain.tsx +++ b/apps/mobile/src/session/CompanionModelChain.tsx @@ -17,12 +17,12 @@ import { ModelPickerSheet } from './ModelPickerSheet'; import { normalizeMobileAgentCapabilities } from './agentCapabilities'; import type { MobileModelConfiguration } from './unifiedMobileModels'; import type { ProfileValues } from './companionProfileData'; -export interface CompanionModelRoute { harness: 'claude' | 'codex' | 'pi'; model: string; providerId: string | null; effort: string; fastMode: boolean } +export interface CompanionModelRoute { harness: 'claude' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort: string; fastMode: boolean } export function readCompanionModelChain(value: unknown): CompanionModelRoute[] { if (typeof value !== 'string') return []; try { const rows: unknown = JSON.parse(value); - if (!Array.isArray(rows) || rows.length > 5 || rows.some(r => !r || !['claude', 'codex', 'pi'].includes(r.harness) || typeof r.model !== 'string' || !r.model || !(typeof r.providerId === 'string' || r.providerId === null) || typeof r.effort !== 'string' || typeof r.fastMode !== 'boolean')) return []; + if (!Array.isArray(rows) || rows.length > 5 || rows.some(r => !r || !['claude', 'codex', 'pi', 'cursor'].includes(r.harness) || typeof r.model !== 'string' || !r.model || !(typeof r.providerId === 'string' || r.providerId === null) || typeof r.effort !== 'string' || typeof r.fastMode !== 'boolean')) return []; return rows; } catch { return []; } } @@ -78,7 +78,7 @@ export function CompanionModelPicker({ visible, deviceId, route, onClose, onClos flatOptions={[]} agentKind={agent} capabilities={null} activeModelId={route?.model ?? ''} selectedProviderId={route?.providerId ?? null} selectedEffort={route?.effort ?? ''} selectedFastMode={route?.fastMode ?? false} existingSessionRoute disabled={!catalog.ready} loading={catalog.loading} emptyHint={catalog.error ?? t('devices.companionProfile.modelsUnavailable')} - unified={{ scope: JSON.stringify([user?.id, deviceId]), agents: ['claude-code', 'codex', 'pi'], + unified={{ scope: JSON.stringify([user?.id, deviceId]), agents: ['claude-code', 'codex', 'pi', 'cursor'], loadCapabilities: async agent => { const value = normalizeMobileAgentCapabilities(await maker.getCapabilities(agent)); if (!value) throw new Error('Capabilities unavailable'); return value; }, onSelect: select }} onSelectFlatModel={() => {}} onSelectProviderRow={() => {}} permissionOptions={[]} activePermissionMode="ask" onSelectPermissionMode={() => {}} hidePermissionTrigger keyboardAvoidingBehavior="padding" />; } diff --git a/apps/mobile/src/session/ContextSheetCollabView.tsx b/apps/mobile/src/session/ContextSheetCollabView.tsx index 10ddfa00dd1..d2a79b7ffa3 100644 --- a/apps/mobile/src/session/ContextSheetCollabView.tsx +++ b/apps/mobile/src/session/ContextSheetCollabView.tsx @@ -70,6 +70,7 @@ export interface OrcaWorkerFormViewProps { /** 权限切换交给页面:进入完全访问前需要确认。 */ onPermissionChange(mode: OrcaWorkerPermissionMode): void; agents: readonly OrcaWorkerAgentKind[]; + permissionModes?: readonly OrcaWorkerPermissionMode[]; onPickModel(): void; onPickDirectory(): void; busy: boolean; @@ -88,6 +89,7 @@ export function OrcaWorkerFormView({ onAgentChange, onPermissionChange, agents, + permissionModes, onPickModel, onPickDirectory, busy, @@ -229,13 +231,17 @@ export function OrcaWorkerFormView({ label={t('session.collab.permissionLabel')} onChange={onPermissionChange} options={[ + ...(form.agent === 'cursor' ? [{ id: 'ask' as const, label: t('session.collab.permissionAsk') }] : []), { id: 'auto' as const, label: t('session.collab.permissionAuto') }, { id: 'bypassPermissions' as const, label: t('session.collab.permissionFull') }, - ]} + ].filter(option => !permissionModes || permissionModes.includes(option.id))} testID="collab.permissionOptions" value={form.permissionMode} /> - diff --git a/apps/mobile/src/session/ConversationSearchFilterSheet.tsx b/apps/mobile/src/session/ConversationSearchFilterSheet.tsx index 931610f6317..e3b798e10a1 100644 --- a/apps/mobile/src/session/ConversationSearchFilterSheet.tsx +++ b/apps/mobile/src/session/ConversationSearchFilterSheet.tsx @@ -26,7 +26,7 @@ import type { const SORT_OPTIONS: ConversationSearchSortBy[] = ['relevance', 'activityDesc', 'activityAsc']; const STATUS_OPTIONS: ConversationSearchStatusFilter[] = ['active', 'archived', 'all']; -const AGENT_OPTIONS: ConversationSearchAgentFilter[] = ['all', 'cc', 'codex', 'pi']; +const AGENT_OPTIONS: ConversationSearchAgentFilter[] = ['all', 'cc', 'codex', 'pi', 'cursor']; const LAST_ACTIVITY_OPTIONS: ConversationSearchLastActivityFilter[] = ['1d', '3d', '7d', '30d', 'all']; export function ConversationSearchFilterSheet({ @@ -158,7 +158,7 @@ export function ConversationSearchFilterSheet({ {AGENT_OPTIONS.map((value) => ( onAgentKindChange(value)} selected={agentKind === value} testID={`home.searchFilter.agent.${value}`} diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx index c49b5d07f49..e79a4fa366e 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -4601,6 +4601,7 @@ const AGENT_TASK_PROVIDER_LABEL: Record 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; function AgentTaskStatusIcon({ status, size = iconSize.md }: { status: AgentTaskStatus; size?: number }) { diff --git a/apps/mobile/src/session/UnifiedModelPickerSheet.tsx b/apps/mobile/src/session/UnifiedModelPickerSheet.tsx index 26605bae8dd..e064393f3b3 100644 --- a/apps/mobile/src/session/UnifiedModelPickerSheet.tsx +++ b/apps/mobile/src/session/UnifiedModelPickerSheet.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import type { MobileProviderMarkProps } from "./MobileProviderMark"; import type { AgentKind } from "@cindy/model-providers/types"; import type { UnifiedModelEntry } from "@cindy/model-providers"; +import { providerModelDisplayGroups, providerModelDisplayGroupTitle } from "@cindy/model-providers"; import type { MobileAgentCapabilities } from "./agentCapabilities"; import type { ModelPickerSheetProps } from "./ModelPickerSheet"; import { buildMobileModelSections } from "./providerModelSections"; @@ -172,7 +173,7 @@ export interface UnifiedMobilePickerViewProps { price: string | null; }; } -const ALL_AGENTS: readonly AgentKind[] = ["claude-code", "codex", "pi"]; +const ALL_AGENTS: readonly AgentKind[] = ["claude-code", "codex", "pi", "cursor"]; export function UnifiedModelPickerSheet( p: ModelPickerSheetProps & { unified: UnifiedMobilePickerOptions }, ) { @@ -649,7 +650,7 @@ export function UnifiedModelPickerSheet( const recommended = all && p.existingSessionRoute ? filtered - .filter((row) => row.selected || row.config.agent === p.agentKind) + .filter((row) => row.entry.providerId !== 'cursor' && (row.selected || row.config.agent === p.agentKind)) .sort((a, b) => Number(b.selected) - Number(a.selected)) : []; if (recommended.length) @@ -664,11 +665,13 @@ export function UnifiedModelPickerSheet( (row) => row.entry.providerId === provider.id && !recommended.includes(row), ); - if (group.length) + for (const subgroup of providerModelDisplayGroups(provider.id, group, row => row.entry.group)) groups.push({ - key: provider.id, - title: providerName(provider.id), - rows: group, + key: `${provider.id}${subgroup.group ? `:${subgroup.group}` : ''}`, + title: providerModelDisplayGroupTitle(providerName(provider.id), subgroup.group, { + models: t("models.cursorGroups.models"), other: t("models.cursorGroups.other"), + }), + rows: subgroup.items, }); } // 其他电脑的供应商接在后面:每个供应商一段,标题带电脑名;「收藏」与本机供应商视图里不出现。 @@ -679,11 +682,13 @@ export function UnifiedModelPickerSheet( const group = deviceRows.filter( (row) => row.entry.providerId === provider.id && matches(row), ); - if (group.length) + for (const subgroup of providerModelDisplayGroups(provider.id, group, row => row.entry.group)) groups.push({ - key: id, - title: remoteProviderLabel(catalog, provider.id), - rows: group, + key: `${id}${subgroup.group ? `:${subgroup.group}` : ''}`, + title: providerModelDisplayGroupTitle(remoteProviderLabel(catalog, provider.id), subgroup.group, { + models: t("models.cursorGroups.models"), other: t("models.cursorGroups.other"), + }), + rows: subgroup.items, }); } const sourceRow = target diff --git a/apps/mobile/src/session/agentAuthGate.ts b/apps/mobile/src/session/agentAuthGate.ts index 1426e2dc363..d11596169fb 100644 --- a/apps/mobile/src/session/agentAuthGate.ts +++ b/apps/mobile/src/session/agentAuthGate.ts @@ -24,7 +24,7 @@ export interface AgentAuthGateInput { loading: boolean; /** 目录拉取失败(典型:旧被控端不识别通道)。 */ error: string | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** * true = 已建会话的发送门禁:计入 suspended 来源(停用是准入轴,不打断运行中 * 会话,门禁只回答「凭证还连着吗」)。缺省 false = 新建草稿:suspended 不算可 @@ -45,7 +45,7 @@ export function agentAuthGateVerdict(input: AgentAuthGateInput): AgentAuthGateVe } /** 未鉴权时的提示文案(与 describeAgentAuthError 的引导口径一致)。 */ -export function agentAuthGateHint(agentKind: 'claude-code' | 'codex' | 'pi'): string { - const label = agentKind === 'claude-code' ? 'Claude' : agentKind === 'pi' ? 'Pi' : 'Codex'; +export function agentAuthGateHint(agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'): string { + const label = agentKind === 'claude-code' ? 'Claude' : agentKind === 'cursor' ? 'Cursor' : agentKind === 'pi' ? 'Pi' : 'Codex'; return i18n.t('session.row.authGateHint', { agent: label }); } diff --git a/apps/mobile/src/session/composerPalette.ts b/apps/mobile/src/session/composerPalette.ts index f45f62d5954..1eb635bdfd0 100644 --- a/apps/mobile/src/session/composerPalette.ts +++ b/apps/mobile/src/session/composerPalette.ts @@ -2,6 +2,6 @@ import type { RemoteSession } from './types'; export * from '@cindy/maker-shared/composer-palette'; -export function agentKindForSession(session: Pick): 'claude-code' | 'codex' | 'pi' { - return session.agentKind === 'codex' || session.agentKind === 'pi' ? session.agentKind : 'claude-code'; +export function agentKindForSession(session: Pick): 'claude-code' | 'codex' | 'pi' | 'cursor' { + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'cursor' ? session.agentKind : 'claude-code'; } diff --git a/apps/mobile/src/session/conversationSearchFilterMenu.ts b/apps/mobile/src/session/conversationSearchFilterMenu.ts index cd1d8537278..52d976ae327 100644 --- a/apps/mobile/src/session/conversationSearchFilterMenu.ts +++ b/apps/mobile/src/session/conversationSearchFilterMenu.ts @@ -28,6 +28,7 @@ const AGENT_OPTIONS: ConversationSearchAgentFilter[] = [ "cc", "codex", "pi", + "cursor", ]; const LAST_ACTIVITY_OPTIONS: ConversationSearchLastActivityFilter[] = [ "1d", diff --git a/apps/mobile/src/session/fullAccessConfirmation.ts b/apps/mobile/src/session/fullAccessConfirmation.ts index 5ccd115a73f..3924c5004c5 100644 --- a/apps/mobile/src/session/fullAccessConfirmation.ts +++ b/apps/mobile/src/session/fullAccessConfirmation.ts @@ -6,6 +6,7 @@ import { getManualLocaleOverride } from "@/i18n/appLanguage"; import { resolveSystemLocale } from "@/i18n/locale"; import { FULL_ACCESS_CONFIRMATION_COPY, + CURSOR_FULL_ACCESS_CONFIRMATION_COPY, type FullAccessConfirmationCopy, } from "./fullAccessConfirmationCopy"; @@ -14,9 +15,10 @@ export function getFullAccessConfirmationCopy( languageTag = getManualLocaleOverride() ?? getLocales()[0]?.languageTag ?? getLocales()[0]?.languageCode, + agentKind?: string, ): FullAccessConfirmationCopy { const language = resolveSystemLocale(languageTag); - return FULL_ACCESS_CONFIRMATION_COPY[language]; + return (agentKind === 'cursor' ? CURSOR_FULL_ACCESS_CONFIRMATION_COPY : FULL_ACCESS_CONFIRMATION_COPY)[language]; } type ShowAlert = ( @@ -30,6 +32,7 @@ export interface FullAccessConfirmationOptions { /** 仅用于把新建任务默认权限恢复为该 agent 上一次明确选过的档位。 */ restoringRememberedChoice?: boolean; showAlert?: ShowAlert; + agentKind?: string; } /** @@ -56,7 +59,7 @@ export function confirmFullAccessChange( resolve(confirmed); }; - const copy = getFullAccessConfirmationCopy(); + const copy = getFullAccessConfirmationCopy(undefined, options.agentKind); (options.showAlert ?? Alert.alert)( copy.title, copy.description, diff --git a/apps/mobile/src/session/fullAccessConfirmationCopy.ts b/apps/mobile/src/session/fullAccessConfirmationCopy.ts index fd6f1804c6b..ffdc6cc634b 100644 --- a/apps/mobile/src/session/fullAccessConfirmationCopy.ts +++ b/apps/mobile/src/session/fullAccessConfirmationCopy.ts @@ -24,15 +24,15 @@ export type FullAccessConfirmationCopy = Readonly<{ cancel: string; }>; -type FullAccessConfirmationSource = { permission: { fullAccessConfirm: FullAccessConfirmationCopy } }; +type FullAccessConfirmationSource = { permission: { fullAccessConfirm: FullAccessConfirmationCopy & { cursorDescription: string } } }; /** locale JSON 里的 {{appName}} 由 i18next defaultVariables 注入;这里不经 i18next,手动替换。 */ -function project(source: FullAccessConfirmationSource): FullAccessConfirmationCopy { +function project(source: FullAccessConfirmationSource, cursor = false): FullAccessConfirmationCopy { const copy = source.permission.fullAccessConfirm; const fill = (value: string) => value.replace(/\{\{appName\}\}/g, BRAND_NAME); return { title: fill(copy.title), - description: fill(copy.description), + description: fill(cursor ? copy.cursorDescription : copy.description), confirm: fill(copy.confirm), cancel: fill(copy.cancel), }; @@ -48,3 +48,11 @@ export const FULL_ACCESS_CONFIRMATION_COPY: Record< "zh-CN": project(zhCNInteraction), "zh-TW": project(zhTWInteraction), }; + +export const CURSOR_FULL_ACCESS_CONFIRMATION_COPY: typeof FULL_ACCESS_CONFIRMATION_COPY = { + en: project(enInteraction, true), + ja: project(jaInteraction, true), + ko: project(koInteraction, true), + "zh-CN": project(zhCNInteraction, true), + "zh-TW": project(zhTWInteraction, true), +}; diff --git a/apps/mobile/src/session/inputProjection.ts b/apps/mobile/src/session/inputProjection.ts index fcd569b8441..b550ad7d16e 100644 --- a/apps/mobile/src/session/inputProjection.ts +++ b/apps/mobile/src/session/inputProjection.ts @@ -151,7 +151,7 @@ export function buildQueuedTextMessage( const workingDir = session.workingDir || ''; const effort = session.effort || ''; const permissionMode = permissionModeOrAsk(session.permissionMode); - const agentKind = session.agentKind === 'codex' || session.agentKind === 'pi' + const agentKind = session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'cursor' ? session.agentKind : 'claude-code'; const persistedContent = stringifyUserContent( diff --git a/apps/mobile/src/session/mobileHomeListCache.ts b/apps/mobile/src/session/mobileHomeListCache.ts index 8131afaf8bb..ddbc4d32b9c 100644 --- a/apps/mobile/src/session/mobileHomeListCache.ts +++ b/apps/mobile/src/session/mobileHomeListCache.ts @@ -212,7 +212,7 @@ function coerceCachedSession(item: unknown): RemoteSession | null { permissionMode: typeof item.permissionMode === 'string' ? item.permissionMode : '', fastMode: item.fastMode === true, status, - agentKind: item.agentKind === 'codex' || item.agentKind === 'pi' ? item.agentKind : 'cc', + agentKind: item.agentKind === 'codex' || item.agentKind === 'pi' || item.agentKind === 'cursor' ? item.agentKind : 'cc', userSendAt: typeof item.userSendAt === 'string' ? item.userSendAt : null, createdAt, updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : createdAt, diff --git a/apps/mobile/src/session/mobileModelPreferences.ts b/apps/mobile/src/session/mobileModelPreferences.ts index aafd596c1aa..999f79184f8 100644 --- a/apps/mobile/src/session/mobileModelPreferences.ts +++ b/apps/mobile/src/session/mobileModelPreferences.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useRemoteMobileFavorites } from './useRemoteMobileFavorites'; import type { MobileModelPreferences, MobileModelFavorite } from './unifiedMobileModels'; import type { AgentKind } from '@cindy/model-providers/types'; -const agents = new Set(['claude-code', 'codex', 'pi']); +const agents = new Set(['claude-code', 'codex', 'pi', 'cursor']); export function sanitizeModelPreferences(value: unknown): MobileModelPreferences { const result: MobileModelPreferences = { favorites: [], engines: {} }; if (!value || typeof value !== 'object') return result; diff --git a/apps/mobile/src/session/mobilePermissionPickerOptions.ts b/apps/mobile/src/session/mobilePermissionPickerOptions.ts index 48450682a80..7e720d845af 100644 --- a/apps/mobile/src/session/mobilePermissionPickerOptions.ts +++ b/apps/mobile/src/session/mobilePermissionPickerOptions.ts @@ -1,3 +1,4 @@ +import { deduplicateDefaultPermissionOptions } from '@cindy/maker-shared/permission-mode'; import type { MobileChoiceOption } from '@/session/agentCapabilities'; /** @@ -9,29 +10,5 @@ export function permissionOptionsForDisplay( options: readonly MobileChoiceOption[], activeMode: string, ): MobileChoiceOption[] { - const result: MobileChoiceOption[] = []; - let defaultPermissionsIndex: number | undefined; - - for (const option of options) { - if (option.id === 'plan') continue; - if (option.id !== 'default' && option.id !== 'ask') { - result.push(option); - continue; - } - - if (defaultPermissionsIndex === undefined) { - defaultPermissionsIndex = result.length; - result.push(option); - continue; - } - - const existing = result[defaultPermissionsIndex]; - const existingIsActive = existing.id === activeMode; - const candidateIsActive = option.id === activeMode; - if (candidateIsActive || (!existingIsActive && existing.id === 'default' && option.id === 'ask')) { - result[defaultPermissionsIndex] = option; - } - } - - return result; + return deduplicateDefaultPermissionOptions(options.filter(option => option.id !== 'plan'), activeMode); } diff --git a/apps/mobile/src/session/newSession.ts b/apps/mobile/src/session/newSession.ts index 9cc94ba6289..a74763d1d73 100644 --- a/apps/mobile/src/session/newSession.ts +++ b/apps/mobile/src/session/newSession.ts @@ -17,13 +17,14 @@ import { effectiveSourceIdForModel } from '@cindy/model-providers/registry'; import { reconcileEffortForModel, type ProviderModelRow } from './providerModelSections'; import type { RemoteSession } from './types'; -export type NewSessionAgentKind = 'claude-code' | 'codex' | 'pi'; +export type NewSessionAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type NewSessionWorkspaceKind = 'project' | 'dialogue'; export const NEW_SESSION_AGENT_OPTIONS: readonly { kind: NewSessionAgentKind; label: string }[] = [ { kind: 'claude-code', label: 'Claude' }, { kind: 'codex', label: 'Codex' }, { kind: 'pi', label: 'Pi' }, + { kind: 'cursor', label: 'Cursor' }, ]; /** @@ -35,7 +36,7 @@ export const NEW_SESSION_AGENT_OPTIONS: readonly { kind: NewSessionAgentKind; la export function availableNewSessionAgentOptions( available: ReadonlySet | null, ): readonly { kind: NewSessionAgentKind; label: string }[] { - if (!available) return NEW_SESSION_AGENT_OPTIONS; + if (!available) return NEW_SESSION_AGENT_OPTIONS.filter((option) => option.kind !== 'cursor'); const filtered = NEW_SESSION_AGENT_OPTIONS.filter((option) => available.has(option.kind)); // 防御:被控端异常返回空集时不至于把入口清空到无法创建(至少保留 Claude)。 return filtered.length > 0 ? filtered : NEW_SESSION_AGENT_OPTIONS.filter((o) => o.kind === 'claude-code'); @@ -168,7 +169,7 @@ export function parseNewSessionDeviceOptions( } export function normalizeNewSessionAgentKind(value: unknown): NewSessionAgentKind | null { - return value === 'claude-code' || value === 'codex' || value === 'pi' ? value : null; + return value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'cursor' ? value : null; } export function pickNewSessionDefaultDevice(input: { @@ -202,8 +203,8 @@ export const DEFAULT_NEW_SESSION_DRAFT: NewSessionDraft = { }; /** 新建交互式会话的权限种子默认;三个 agent 都保留 Auto-review。 */ -export function defaultPermissionModeForNewSessionAgent(_agentKind: NewSessionAgentKind): string { - return 'auto'; +export function defaultPermissionModeForNewSessionAgent(agentKind: NewSessionAgentKind): string { + return agentKind === 'cursor' ? 'ask' : 'auto'; } export function withAgentDefaults( @@ -255,7 +256,7 @@ export function summarizeNewSessionDraft( content: NewSessionDraftContentState = {}, ): NewSessionDraftSummary { const validationMessage = validateNewSessionDraft(draft, content); - const agentLabel = draft.agentKind === 'codex' ? 'Codex' : draft.agentKind === 'pi' ? 'Pi' : 'Claude'; + const agentLabel = draft.agentKind === 'codex' ? 'Codex' : draft.agentKind === 'cursor' ? 'Cursor' : draft.agentKind === 'pi' ? 'Pi' : 'Claude'; const model = draft.model.trim() || i18n.t('session.new.noModelSelected'); const effort = draft.effort.trim(); const workspaceLabel = draft.workspaceKind === 'dialogue' @@ -406,7 +407,7 @@ type NewSessionDefaultModel = { id: string; efforts: readonly string[]; defaultEffort: string | null; - newSessionDefault?: readonly ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: readonly ('claude-code' | 'codex' | 'pi' | 'cursor')[]; }; function isNewSessionDefaultForAgent( diff --git a/apps/mobile/src/session/newSessionPreferenceStore.ts b/apps/mobile/src/session/newSessionPreferenceStore.ts index 17ccc470aac..33d0f51989d 100644 --- a/apps/mobile/src/session/newSessionPreferenceStore.ts +++ b/apps/mobile/src/session/newSessionPreferenceStore.ts @@ -138,7 +138,7 @@ function normalizePermissionModeByAgent(value: unknown): Partial> = {}; - for (const agent of ['claude-code', 'codex', 'pi'] as const) { + for (const agent of ['claude-code', 'codex', 'pi', 'cursor'] as const) { const mode = readString(record[agent]); if (mode && isRememberablePermissionMode(mode)) { out[agent] = mode; diff --git a/apps/mobile/src/session/orcaTeam.ts b/apps/mobile/src/session/orcaTeam.ts index d7fe59199a3..fc14f0d46b8 100644 --- a/apps/mobile/src/session/orcaTeam.ts +++ b/apps/mobile/src/session/orcaTeam.ts @@ -85,7 +85,7 @@ export function isAbsoluteOrcaWorkerDir(value: string): boolean { } export function orcaAgentKindForSession(session: Pick): OrcaWorkerAgentKind { - return session.agentKind === 'codex' || session.agentKind === 'pi' ? session.agentKind : 'claude-code'; + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'cursor' ? session.agentKind : 'claude-code'; } /** 由记忆构造表单:角色回到 developer,初始任务不记忆(与桌面一致)。 */ @@ -538,5 +538,5 @@ export function orcaWorkerDisplayName(worker: Pick | undefined, ): ReadonlyMap | undefined { const parkedMap = sessionParkedTaskUpdates.get(sessionId); diff --git a/apps/mobile/src/session/sessionAgentSwitch.ts b/apps/mobile/src/session/sessionAgentSwitch.ts index 8ec3cd6ffcb..aef44dcd261 100644 --- a/apps/mobile/src/session/sessionAgentSwitch.ts +++ b/apps/mobile/src/session/sessionAgentSwitch.ts @@ -5,7 +5,7 @@ import type { MobileAgentCapabilities } from './agentCapabilities'; import type { RemoteSession, RemoteSessionAgentSwitchIntent } from './types'; -export type MobileSessionAgentKind = 'claude-code' | 'codex' | 'pi'; +export type MobileSessionAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 将不可信 device-link payload 收窄为公开 intent;非法值按“无意图”处理。 */ export function normalizeSessionAgentSwitchIntent( @@ -16,7 +16,7 @@ export function normalizeSessionAgentSwitchIntent( if ( item.targetAgentKind !== 'claude-code' && item.targetAgentKind !== 'codex' - && item.targetAgentKind !== 'pi' + && item.targetAgentKind !== 'pi' && item.targetAgentKind !== 'cursor' ) return null; if (typeof item.model !== 'string' || item.model.length === 0) return null; // providerId 缺失(undefined)按 null 处理,与桌面 projectPendingAgentSwitchIntent 的 @@ -48,7 +48,7 @@ export function normalizeSessionAgentSwitchIntent( /** DB 会话行的 cc/codex 映射到 maker agent kind。 */ export function sessionAgentKind(session: Pick): MobileSessionAgentKind { - return session.agentKind === 'codex' || session.agentKind === 'pi' + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'cursor' ? session.agentKind : 'claude-code'; } @@ -64,14 +64,14 @@ export function supportsMobileSessionAgentSwitch( } export function mobileAgentLabel(agentKind: MobileSessionAgentKind): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + return agentKind === 'codex' ? 'Codex' : agentKind === 'cursor' ? 'Cursor' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; } export function mobileAgentLabelFromUnknown(agentKind: unknown): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + return agentKind === 'codex' ? 'Codex' : agentKind === 'cursor' ? 'Cursor' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; } -export function mobileAgentVendor(agentKind: MobileSessionAgentKind): 'cc' | 'codex' | 'pi' { +export function mobileAgentVendor(agentKind: MobileSessionAgentKind): 'cc' | 'codex' | 'pi' | 'cursor' { return agentKind === 'claude-code' ? 'cc' : agentKind; } diff --git a/apps/mobile/src/session/sessionControls.ts b/apps/mobile/src/session/sessionControls.ts index 65b537422ea..a689f524b06 100644 --- a/apps/mobile/src/session/sessionControls.ts +++ b/apps/mobile/src/session/sessionControls.ts @@ -35,7 +35,7 @@ export function shouldFallbackToLegacyCodexUsage(error: unknown): boolean { export function buildContextUsageCreateOpts(session: RemoteSession): Record { return { - agentKind: session.agentKind === 'codex' || session.agentKind === 'pi' + agentKind: session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'cursor' ? session.agentKind : 'claude-code', workingDir: session.workingDir ?? '', diff --git a/apps/mobile/src/session/sessionMenu.ts b/apps/mobile/src/session/sessionMenu.ts index 6ab29afd3ee..6015fd677c9 100644 --- a/apps/mobile/src/session/sessionMenu.ts +++ b/apps/mobile/src/session/sessionMenu.ts @@ -219,7 +219,7 @@ function readErrorCode(error: Error): string { } function buildSessionMenuMetaLine(session: RemoteSession): string { - const agentLabel = session.agentKind === 'codex' ? 'Codex' : session.agentKind === 'pi' ? 'Pi' : 'Claude'; + const agentLabel = session.agentKind === 'codex' ? 'Codex' : session.agentKind === 'cursor' ? 'Cursor' : session.agentKind === 'pi' ? 'Pi' : 'Claude'; const parts = [agentLabel]; const worktree = sessionWorktreeInfo(session); const workspace = workspaceName(session); diff --git a/apps/mobile/src/session/sessionModelMirror.ts b/apps/mobile/src/session/sessionModelMirror.ts index 547c13c68bb..d8718a8364b 100644 --- a/apps/mobile/src/session/sessionModelMirror.ts +++ b/apps/mobile/src/session/sessionModelMirror.ts @@ -97,7 +97,7 @@ export function applySessionModelPrefPush(payload: unknown): void { fast?: unknown; }; if (typeof p.sessionId !== 'string' || !p.sessionId) return; - if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') return; + if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi' && p.agent !== 'cursor') return; if (typeof p.providerId !== 'string' || !p.providerId) return; if (typeof p.model !== 'string' || !p.model) return; if (typeof p.effort === 'string' && p.effort.length > 0) { diff --git a/apps/mobile/src/session/types.ts b/apps/mobile/src/session/types.ts index 0fea7529f2f..4e21d8d1433 100644 --- a/apps/mobile/src/session/types.ts +++ b/apps/mobile/src/session/types.ts @@ -74,7 +74,7 @@ export interface RemoteSession { interruptedTurnStartedAt?: number | null; lastTurnEndedAt?: number | null; status: RemoteSessionStatus; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; /** * 远程 Agent:非空 = Agent 在同账号另一台电脑上运行(desktop sessions.agent_device_id), * 任务与文件仍留在被控电脑;null / 缺省 = Agent 就在被控电脑运行(含旧被控端)。 @@ -118,7 +118,7 @@ export interface RemoteSession { } export interface RemoteSessionRuntimeProfile { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort: string | null; @@ -235,7 +235,7 @@ export interface QueuedRemoteMessage { /** Interface language of this phone. The desktop stamps it only for a remote turn. */ uiLanguage?: string; createOpts: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; model: string; effort?: string; diff --git a/apps/mobile/src/session/useConversationSearchFilterMenu.ts b/apps/mobile/src/session/useConversationSearchFilterMenu.ts index 434c442731e..14f7a997eec 100644 --- a/apps/mobile/src/session/useConversationSearchFilterMenu.ts +++ b/apps/mobile/src/session/useConversationSearchFilterMenu.ts @@ -43,6 +43,7 @@ export function useConversationSearchFilterMenu({ cc: t("devices.list.search.filter.agent.cc"), codex: t("devices.list.search.filter.agent.codex"), pi: t("devices.list.search.filter.agent.pi"), + cursor: "Cursor", }, allProjectsLabel: t("devices.list.search.filter.allProjects"), lastActivity, diff --git a/apps/mobile/src/session/useSessionOrcaCollab.ts b/apps/mobile/src/session/useSessionOrcaCollab.ts index 7e383eb64a0..f17ddfc00b7 100644 --- a/apps/mobile/src/session/useSessionOrcaCollab.ts +++ b/apps/mobile/src/session/useSessionOrcaCollab.ts @@ -73,8 +73,19 @@ import { useOrcaWorkerDirectoryPicker } from './useOrcaWorkerDirectoryPicker'; export type CollabSheetView = 'collab' | 'collab-create'; -const ALL_AGENTS: readonly OrcaWorkerAgentKind[] = ['claude-code', 'codex', 'pi']; +const ALL_AGENTS: readonly OrcaWorkerAgentKind[] = ['claude-code', 'codex', 'pi', 'cursor']; +// Cursor is optional and must be confirmed by the execution host before display. +const LEGACY_AGENT_FALLBACK: readonly OrcaWorkerAgentKind[] = ['claude-code', 'codex', 'pi']; + +/** Cursor Worker 只在本机工作区。运行设备、远程供应商,或 Lead 的 Agent 在别的电脑时都不提供。 */ +function isCursorWorkerLocal( + form: Pick, + leadAgentDeviceId: string | null, +): boolean { + return !form.executionDeviceId && !orcaWorkerAgentElsewhere(form) && !leadAgentDeviceId; +} const EMPTY_MODEL_OPTIONS: readonly MobileModelOption[] = []; +const CURSOR_WORKER_PERMISSION_MODES: readonly OrcaWorkerPermissionMode[] = ['ask', 'auto', 'bypassPermissions']; // ─── 团队状态 ──────────────────────────────────────────────────────────────── @@ -322,9 +333,10 @@ export function useOrcaWorkerForm(params: { const [executionDevicesLoading, setExecutionDevicesLoading] = useState(false); const [executionDevicesError, setExecutionDevicesError] = useState(null); const [customRoleMode, setCustomRoleMode] = useState(false); - const [agents, setAgents] = useState(ALL_AGENTS); + const [agents, setAgents] = useState(LEGACY_AGENT_FALLBACK); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [modelsByAgent, setModelsByAgent] = useState>>({}); + const [cursorPermissionModes, setCursorPermissionModes] = useState(CURSOR_WORKER_PERMISSION_MODES); const makerRef = useRef(executionMaker); makerRef.current = executionMaker; const formRef = useRef(form); @@ -406,13 +418,14 @@ export function useOrcaWorkerForm(params: { /** * 按被控端能力收敛模型选择;能力读不到时保留原选择(提交时由被控端裁决)。 - * 只改模型字段,且只有最近一次请求的结果生效(期间又收敛 / 用户又改了模型则丢弃)。 + * Cursor 权限同样按这台电脑实际支持的档位收敛;只有最近一次请求的结果生效。 */ const converge = useCallback((agent: OrcaWorkerAgentKind) => { const generation = ++convergeGenRef.current; const source = makerRef.current; source.getCapabilities(agent) .then((raw) => { + if (makerRef.current !== source) return; const capabilities = normalizeMobileAgentCapabilities(raw); // 能力按 Agent 缓存,供老被控端(没有来源目录)时模型选择器的扁平回退列表使用。 // 只缓存当前这台电脑的结果:换电脑后迟到的上一台响应不写入。 @@ -420,9 +433,15 @@ export function useOrcaWorkerForm(params: { setModelsByAgent((current) => ({ ...current, [agent]: capabilities.availableModels })); } if (generation !== convergeGenRef.current || makerRef.current !== source) return; + const modes = agent === 'cursor' && capabilities?.permissionModes.length + ? CURSOR_WORKER_PERMISSION_MODES.filter(mode => capabilities.permissionModes.some(option => + option.id === mode || (mode === 'ask' && option.id === 'default'))) + : undefined; + if (modes) setCursorPermissionModes(modes.length ? modes : ['ask']); // Agent 在另一台电脑(远程供应商)时模型属于那台的目录,不按被控电脑的能力收敛。 setForm((current) => (current.agent === agent && !orcaWorkerAgentElsewhere(current) - ? { ...current, model: convergeOrcaWorkerModel(current.model, capabilities) } + ? { ...current, model: convergeOrcaWorkerModel(current.model, capabilities), + ...(modes && !modes.includes(current.permissionMode) ? { permissionMode: 'ask' as const } : {}) } : current)); }) .catch(() => undefined); @@ -435,9 +454,10 @@ export function useOrcaWorkerForm(params: { if (rosterMakerRef.current === executionMaker) return; rosterMakerRef.current = executionMaker; convergeGenRef.current += 1; - agentsRef.current = ALL_AGENTS; - setAgents(ALL_AGENTS); + agentsRef.current = LEGACY_AGENT_FALLBACK; + setAgents(LEGACY_AGENT_FALLBACK); setModelsByAgent({}); + setCursorPermissionModes(CURSOR_WORKER_PERMISSION_MODES); }, [executionMaker]); // 读被控端实际注册的 Agent。复位时列表可能还是乐观的三个:结果回来后,当前 Agent 不在这台 @@ -450,7 +470,10 @@ export function useOrcaWorkerForm(params: { makerRef.current.listAvailableAgents() .then((available) => { if (cancelled) return; - const next = ALL_AGENTS.filter((agent) => available.includes(agent)); + // The cross-device Worker protocol currently admits only Claude Code, Codex and Pi. + // Cursor may be installed on the selected computer, but is not a remote Worker option. + const next = ALL_AGENTS.filter((agent) => available.includes(agent) + && (agent !== 'cursor' || isCursorWorkerLocal(formRef.current, leadAgentDeviceIdRef.current))); if (next.length === 0) return; agentsRef.current = next; setAgents(next); @@ -517,6 +540,7 @@ export function useOrcaWorkerForm(params: { /** 切 Agent:带出该 Agent 上次的模型 / 推理强度 / Fast(对齐桌面)。 */ const changeAgent = useCallback((agent: OrcaWorkerAgentKind) => { + if (agent === 'cursor' && !isCursorWorkerLocal(formRef.current, leadAgentDeviceIdRef.current)) return; touchedRef.current = true; agentChosenRef.current = true; const remembered = prefsRef.current.agents[agent]; @@ -565,9 +589,9 @@ export function useOrcaWorkerForm(params: { const changePermission = useCallback(async (mode: OrcaWorkerPermissionMode) => { touchedRef.current = true; - if (!await confirmFullAccessChange(form.permissionMode, mode)) return; + if (!await confirmFullAccessChange(form.permissionMode, mode, { agentKind: form.agent })) return; setForm((current) => ({ ...current, permissionMode: mode })); - }, [form.permissionMode]); + }, [form.agent, form.permissionMode]); const openPicker = useCallback(() => { setSheetOpen(false); @@ -580,6 +604,12 @@ export function useOrcaWorkerForm(params: { source?: { deviceId: string | null }, ): Promise => { if (!ALL_AGENTS.includes(config.agent as OrcaWorkerAgentKind)) return false; + const nextDevice = source !== undefined && agentLocationSelectableRef.current + ? source.deviceId + : formRef.current.agentDeviceId; + if (config.agent === 'cursor' && ( + formRef.current.executionDeviceId || typeof nextDevice === 'string' || leadAgentDeviceIdRef.current + )) return false; touchedRef.current = true; agentChosenRef.current = true; convergeGenRef.current += 1; @@ -637,6 +667,7 @@ export function useOrcaWorkerForm(params: { changeAgent, changePermission, agents, + permissionModes: form.agent === 'cursor' ? cursorPermissionModes : ['auto', 'bypassPermissions'] as readonly OrcaWorkerPermissionMode[], pickerAgents, valid: canSubmitOrcaWorkerForm(form, customRoleMode) && (!form.executionDeviceId || executionDevices.some((device) => device.deviceId === form.executionDeviceId && device.supported)), diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index 4ae43ab356e..63077f0a12e 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -38,7 +38,7 @@ | `desktop.overlay.toast` | desktop | Toast | App 常驻 ToastContainer(用户可见出口,不展开业务逻辑) | Toast, ToastContainer | apps/desktop/src/renderer/components/ui/toast/Toast.tsx, apps/desktop/src/renderer/components/ui/toast/ToastContainer.tsx, apps/desktop/src/renderer/components/ui/toast/index.ts | 5 | 4 | 3 | | `desktop.plugins.app-main` | desktop | 插件主视图 | hash `/apps/:ghostId`(GhostMainViewFeatureLayout) | GhostMainViewFeatureLayout, GhostMainViewHost | apps/desktop/src/renderer/features/plugin/GhostMainViewFeatureLayout.tsx, apps/desktop/src/renderer/features/plugin/GhostMainViewHost.tsx | 0 | 0 | 0 | | `desktop.plugins.installed` | desktop | 已装插件 | hash `/plugins`(GhostPluginPage) | AddMarketplaceDialog, GhostPluginDetailView, GhostPluginPage, MarketPluginDetailView, MyPublishesSection, PluginManagementLayout, PluginScopePicker, UpdateAllDialog | apps/desktop/src/renderer/features/plugin/AddMarketplaceDialog.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx, apps/desktop/src/renderer/features/plugin/MarketPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/MyPublishesSection.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/plugin/PluginScopePicker.tsx, apps/desktop/src/renderer/features/plugin/UpdateAllDialog.tsx, apps/desktop/src/renderer/features/plugin/plugin-motion.css | 28 | 0 | 48 | -| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BillingSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CindyMakeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CindyMakeSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionRow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DialogueDirectorySection.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GeneralSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.settings-search.ts, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HarnessVersionMenuRow.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.settings-search.ts, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LlamaCppCandidates.tsx, apps/desktop/src/renderer/components/settings/LlamaCppProviderDetail.tsx, apps/desktop/src/renderer/components/settings/LocalModelDownloadUI.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx, apps/desktop/src/renderer/components/settings/PersonalizationDetails.settings-search.ts, apps/desktop/src/renderer/components/settings/PersonalizationSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/PiKernelVersionRow.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderImportDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.settings-search.ts, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopPermissions.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopSetting.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.settings-search.ts, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchBox.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchNavigation.ts, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SharedTasks.settings-search.ts, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.settings-search.ts, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WallpaperSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.settings-search.ts, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.settings-search.ts, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/cindyMakeVisibility.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dialogueDirectoryDisplay.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/settingsSearchCatalog.ts, apps/desktop/src/renderer/components/settings/settingsSearchTypes.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBarsTooltip.tsx, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageDeviceSelect.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.settings-search.ts, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageCharts.css, apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/useProviderSubscriptionCard.ts, apps/desktop/src/renderer/components/settings/useSettingsSectionTarget.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 137 | 100 | 544 | +| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BillingSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CindyMakeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CindyMakeSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionRow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CursorProviderSetup.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DialogueDirectorySection.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GeneralSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.settings-search.ts, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HarnessVersionMenuRow.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.settings-search.ts, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LlamaCppCandidates.tsx, apps/desktop/src/renderer/components/settings/LlamaCppProviderDetail.tsx, apps/desktop/src/renderer/components/settings/LocalModelDownloadUI.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx, apps/desktop/src/renderer/components/settings/PersonalizationDetails.settings-search.ts, apps/desktop/src/renderer/components/settings/PersonalizationSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/PiKernelVersionRow.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderImportDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.settings-search.ts, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopPermissions.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopSetting.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.settings-search.ts, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchBox.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchNavigation.ts, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SharedTasks.settings-search.ts, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.settings-search.ts, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WallpaperSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.settings-search.ts, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.settings-search.ts, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/cindyMakeVisibility.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dialogueDirectoryDisplay.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/settingsSearchCatalog.ts, apps/desktop/src/renderer/components/settings/settingsSearchTypes.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBarsTooltip.tsx, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageDeviceSelect.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.settings-search.ts, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageCharts.css, apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/useProviderSubscriptionCard.ts, apps/desktop/src/renderer/components/settings/useSettingsSectionTarget.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 137 | 100 | 544 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, DeviceSectionHeader, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, ProjectsSection, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/AppBadgeAttentionSync.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainEntryRedirect.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/WallpaperVideo.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/DatabaseSizeWarningBanner.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/NavigationCountBadge.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarNavigationCustomize.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/sidebar/sidebarNavigationPrefs.ts, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/cc-agent/sidebar/DeviceSectionHeader.tsx, apps/desktop/src/renderer/features/cc-agent/sidebar/sections/ProjectsSection.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBotWorkbenchTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openRoutinesTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/bot-workbench/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/cindy-make/CindyMakeTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/cindy-make/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/retired-feature/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/routines/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/tabAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/generated/tokens.css, apps/desktop/src/renderer/styles/globals.css | 144 | 46 | 200 | | `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及统一详情 `/skillhub/detail`(旧详情链接重定向) | InstallTargetPicker, PluginManagementLayout, SkillDetailLayout, SkillhubDetailRoute, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubLocalLayout, SkillhubMarketDetailView | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailRoute.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubLocalLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketDetailView.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillDetailLayout.tsx | 35 | 0 | 43 | | `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketDetailView, SkillhubMarketListView, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/components/HomeMarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/LocalSkillControls.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInstallStatus.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketListError.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketLocalSkills.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/OfficialSkillBadge.tsx, apps/desktop/src/renderer/features/skillhub/components/PlatformTagSelector.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillCategoryFilterBar.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillDetailLayout.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillTagList.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 43 | 0 | 37 | diff --git a/docs/dev-rules/cursor-harness.md b/docs/dev-rules/cursor-harness.md new file mode 100644 index 00000000000..65bbfb23a2c --- /dev/null +++ b/docs/dev-rules/cursor-harness.md @@ -0,0 +1,183 @@ +# Cursor Agent ACP integration + +Cursor is an additional native harness alongside Claude Code, Codex, and Pi. It is +not a Claude/Codex subprocess tool or a one-shot command wrapper. + +## Setup and execution location + +Install the official [Cursor CLI](https://cursor.com/docs/cli/installation), run +`agent login` on the computer that will execute the task, and restart Cindy after +installing the executable. Credentials remain in Cursor's native credential store. +Cindy never copies credentials, directly rewrites Cursor's global configuration, +installs Cursor automatically, or changes the native sandbox. Model/mode choices +use the advertised ACP APIs; native persistence remains owned by Cursor. + +Desktop Settings → Model Providers offers Cursor in the add-provider wizard even +before native model discovery succeeds. Its setup step shows CLI installation and +terminal login instructions, then uses the existing native model-refresh API; +it does not start an OAuth flow or create a second connection. An installed Cursor +CLI stays visible in the settings rail while models are unavailable, with setup +instructions and a retryable refresh. The installation/login scan asks the CLI +for status and returns booleans only. The detail header identifies the Cursor +engine and shows a connected state only when its discovered source is ready. +Regression coverage: `addProviderWizardCursorEntry.test.tsx`, +`providersSectionDeepLink.test.tsx`, and `localCliDetect.test.ts`. + +The desktop host discovers supported native executables and starts the exact +absolute executable with `acp`, in the task working directory. Mobile and another +desktop control that host through the existing device-link APIs. SSH execution, +device-hosted execution on a different work computer, and native transcript +export/import are not implemented and must fail explicitly rather than route to +another harness. + +## Protocol and lifecycle + +The implementation follows the [official Cursor ACP documentation](https://cursor.com/docs/cli/acp) +and [ACP v1](https://agentclientprotocol.com/protocol/v1/schema): UTF-8 +newline-delimited JSON-RPC, initialize, native authentication, new/load, prompt, +streaming updates, request_permission, cancel. One live session owns one process. +A native session ID is persisted through the existing Cindy session_id event. +Loading suppresses native transcript replay because Cindy already has those +messages. Missing/unsupported session loading fails explicitly without replacing +or deleting saved history. + +Control calls are bounded. Stop cancels pending interactions and sends the native +cancel notification. If the prompt does not settle, Cindy closes the process; +failed exit confirmation keeps the handle fenced and cleanup retryable. It must +not release MCP context or working-directory ownership before exit is confirmed. +Process frames and queued bytes are bounded; native stderr and RPC payloads are +not logged by the transport. + +## Models and input + +Models come from the actual ACP session's model config options (or advertised +legacy model state). `cursor-default` is only Cindy's route sentinel: it omits an +explicit model override and is never sent as a model ID. It remains accepted for +old saved tasks, but is absent from public catalogs and model pickers. After a +successful handshake, the handle exposes the actual native current model ID; +the admission-only alias still requires a connected, discovered Cursor source. +Shared source lookup resolves an old saved alias only for the built-in Cursor +provider with an advertised native default. It retains the saved route and +uses that default's label without inserting a picker row or guessing the saved +native session's effort/Fast state before the native handle is opened. +Model mutations use the advertised config ID and exact value. Native AUTO is +listed only when the CLI lists it. Unknown context windows remain unknown. +Cindy negotiates Cursor's native `_meta.parameterizedModelPicker` capability and +reads `cursor/list_available_models` to discover each offered model's parameter +options without selecting models or sending prompts. Older CLIs that do not +implement this optional extension retain their advertised model catalog. + +Native grouped choices, descriptions, and offered order survive catalog +projection to desktop and mobile. Cursor's All view keeps Auto separate and +shows `Cursor Models` / `Other Models` within the Cursor source, instead of +flattening that source into generic recommendations. Native group names take +precedence. When ACP omits headings, `cursorModelGroups.ts` classifies only +offered IDs verified against Cursor's official usage-limits documentation +(2026-10-09); unknown first-party versions stay unclassified. This display +metadata never changes model membership, execution source, or routing. Auto may +consume either pool according to the selected model; headings do not promise +the eventual pool of Auto or subagent requests. Pool balances are not invented +from model-list data. + +The existing desktop/mobile effort slider and Fast toggle use those native +per-model options. Standard effort values map only from advertised +`thought_level` choices; Fast uses the advertised `fast` config in `model_config` +(or its legacy `_model_config` category). Mutations retain the exact config ID +and offered value and consume the complete returned `configOptions`. Model +changes can replace the available controls. Unsupported values fail explicitly; +Auto and other fixed models do not receive simulated controls. Persisted settings +are reapplied before prompting on new/load and are not stripped by remote creation +or task recovery. Generic thinking switches and arbitrary extra parameters are +not synthesized. No model IDs with guessed bracket parameters are constructed. + +Existing user context and a frozen Maker Memory index are included once with the +first user prompt, because ACP has no system-message API. Cursor's native system +instructions are unchanged. Cindy's Memory/tool bridge uses the existing +session-scoped HTTP MCP infrastructure. Cursor native subagents inherit the same MCP tools, +and ACP does not attest individual callers, so root/descendant provenance stays +unknown rather than claiming an attested root. Existing privileged collaboration +guards remain in force. Cindy-owned lead/worker creation, messaging and final-output +auto-reporting retain their existing host ownership checks. Direct native +`send_to_lead` is intentionally rejected without root-call attestation; the host +terminal-output bridge remains available. Failure to support required HTTP MCP is +reported, not silently dropped. Images are enabled only when advertised by the +CLI. The client does not advertise filesystem or terminal RPC implementations. + +## Approval and extensions + +Ask/Default, Auto-review, and Full access are implemented for native approval +requests. Ask/Default forwards them to Cindy's existing interaction resolver. +Auto-review uses the shared deterministic classifier and host reviewer against the +accepted user authorization; an unavailable reviewer produces the existing notice +and a marked confirmation card. Full access allows these requests directly. All +three return the exact advertised `allow_once`/`reject_once` option ID; native +persistent `allow_always` grants are never synthesized. A permission-mode change +re-evaluates pending tool approvals and invalidates stale automated decisions. +Stop/close cancels approvals even while review is running. Plan approval and +questions remain explicit in every permission mode. + +Creation, task recovery, engine switching, device-link control, mobile preferences, +and Orca worker creation preserve supported selected permission modes. An +unsupported legacy mode is tightened to Ask on engine switching; existing Ask +choices and the interactive Cursor seed default are unchanged. No native global +configuration, sandbox, or persistent allowlist is rewritten. +Remote worker forms use the execution host's advertised permission subset, so +older Cursor hosts remain usable with Ask/Default. +Independent schedules use the same existing Full access default as other +harnesses so native approvals do not strand unattended work. Bound task heartbeats +retain the task's saved permission and plan mode. + +Cursor's pre-existing native grants and policies remain authoritative, so this is +not an interception guarantee for every native tool. Cindy per-turn policies +requiring interception of every tool are explicitly unsupported. Restricted Reviewer and Bot runtime profiles are rejected +before native startup because their read-only/tool/Skill policy cannot be enforced +through the currently implemented ACP interface. Nonempty additional directory grants, +read-only Library roots, and pinned Skill invocations are likewise rejected rather +than silently ignored. Ordinary working-directory-only tasks remain supported. + +Blocking `cursor/create_plan` and `cursor/ask_question` requests always receive a +response. Plan approval stays explicit and cannot be auto-resolved by a mode +change. Edited plans are rejected with the proposed edit because ACP cannot +represent acceptance of modified text. Desktop/mobile JSON multi-select answers +map to opaque native IDs; ambiguous labels, duplicate question prompts, and free +text are explicitly skipped rather than fabricated. + +Partial tool updates merge by tool-call ID. Text/thinking, tool results and file +diff content use existing Cindy events. The native `cursor/task` extension is a +completion notification; it does not establish a controllable durable child, so +Cindy must not invent resume/stop handles from it. Reported context occupancy is +not converted into billable token usage or estimated subscription cost. + +## Verification + +Deterministic mock-process and transport fixtures cover framing, startup, +new/load, multi-turn streams, models, permissions, plan/question responses, +cancellation, failed cleanup retry, MCP identity/context, and protocol errors. +A real authenticated Cursor run on Windows/macOS/Linux and desktop/mobile visual +verification remain separate integration checks; mocks must not be described as +live CLI or UI verification. Existing Claude/Codex/Pi prompts and runtime pins are +not changed. + +The transport interface/process-shutdown approach references the earlier closed +[PR #2386](https://github.com/makecindy/cindy/pull/2386); the integration is authored +against current main rather than cherry-picking that stale branch. + +## Opt-in native smoke + +With an officially installed CLI already authenticated by the user, run +`CINDY_CURSOR_TEST_BINARY=/absolute/path/to/agent node --import tsx scripts/smoke-cursor-acp.mts`. +Use the intended native `HOME` and XDG directories. The script never reads credentials. +It creates an isolated Git fixture, exercises short multi-turn/file/cancel/load requests, +and checks a synthetic authenticated loopback MCP handshake and bridge cleanup. +This consumes the selected account's model quota and is not part of CI. Workspace +file edits may be allowed natively without a permission callback; a successful edit +alone does not validate the approval UI. The synthetic MCP server does not validate +Memory semantics. The fixture is removed only after native process closure. + +For execution environments that interrupt long jobs, an operator may resume a known +synthetic fixture using `CINDY_CURSOR_SMOKE_STATE` (a private JSON checkpoint with +`kind: "cindy-cursor-smoke-v1"`, `cwd`, and native `sessionId`) together with +`CINDY_CURSOR_SMOKE_STAGE=context|file|cancel`. Run only one process against a +checkpoint at a time. Segmented runs preserve the fixture and update only stage +results; remove it after all processes have closed. Never use an ordinary user +project or a real credential as a smoke fixture. diff --git a/docs/dev-rules/desktop-development.md b/docs/dev-rules/desktop-development.md index ea239fa1eaa..8f056e6a306 100644 --- a/docs/dev-rules/desktop-development.md +++ b/docs/dev-rules/desktop-development.md @@ -175,6 +175,20 @@ localStorage 按 **origin + userData 目录** 分家——dev 的 renderer 从 - 只修改 renderer 时优先使用现有实例的热更新,不重复重启。 - 不确定运行实例来自哪个 checkout 时,先运行 `pnpm desktop:whoami -- --all` 核对。 +### 文件元数据事件导致开发窗口反复白屏 + +若 macOS 上的源码文件仅 `ctime` 变化、`mtime` 和内容未变,FSEvents 仍可能让 Vite +连续热更新或整页重载。确认这一现象后,可对当前开发实例启用轮询监听: + +```bash +XDT_DESKTOP_DEV_WATCH_POLLING=1 pnpm restart:desktop:remote --region=global +``` + +启动器会把开关传到 macOS Terminal 中的 Forge 进程;主进程和主窗口监听器明确关闭 +FSEvents,以 1 秒间隔轮询。此模式仅在显式传入 `1` 时启用,冷启动就绪等待延长至 +240 秒;Windows 和未设置开关的开发实例维持原配置。单独设置 +`CHOKIDAR_USEPOLLING=1` 不够:Chokidar 会先按 macOS 默认值选中 FSEvents。 + ## 分层验证 工作目录误报缺失或切到备用目录时,参见[工作目录异常日志判读](../working-directory-diagnostics.md), diff --git a/docs/dev-rules/maker-core-and-agent-behavior.md b/docs/dev-rules/maker-core-and-agent-behavior.md index 18391d1abd5..14ca042f2f7 100644 --- a/docs/dev-rules/maker-core-and-agent-behavior.md +++ b/docs/dev-rules/maker-core-and-agent-behavior.md @@ -13,7 +13,7 @@ Orca 多 Agent 协同另见 [`orca-team-architecture.md`](orca-team-architecture ## 启动失败与工作目录占用 -Claude Code、Codex、Pi 共用 Maker 的启动失败清理契约:只有明确未启动进程或已确认 +Claude Code、Codex、Pi、Cursor 共用 Maker 的启动失败清理契约:只有明确未启动进程或已确认 进程退出时,adapter 才返回 `AgentStartupStoppedError`;Maker 释放本次启动的目录租约, 并向调用方还原原始错误。准备环境失败也必须进入该契约,不能把尚未启动的任务永久 记为可能仍占用目录。鉴权失败保留原 `AgentNotAuthenticatedError` 类型。 @@ -24,8 +24,12 @@ transport;关闭未确认时复用原有隔离清理记录和 `AgentStartupCle 保留运行期文件与目录保护,重试确认退出后才释放。旧隔离进程的清理失败不能被新一轮 “尚未启动”的状态覆盖。此恢复只影响失败任务,不重置设备连接或其他任务,不自动重放消息。 +Cursor 的 ACP 关闭未确认时,adapter 保留可重试清理记录,`whenStopped` 只在后续 +确认退出后完成;重试启动和 dispose 都必须继续清理旧进程。模型发现目录由 adapter +持有,并在原生进程退出后删除,宿主不能在 finally 中提前回收仍被占用的目录。 + 回归见 `claude-code/__tests__/startup-cleanup.test.ts`、 -`pi/__tests__/pi-startsession-cleanup.test.ts` 与 `maker.test.ts`。 +`pi/__tests__/pi-startsession-cleanup.test.ts`、`cursor/index.test.ts` 与 `maker.test.ts`。 ## 工具循环与无响应的分工 diff --git a/docs/dev-rules/protocol-compatibility.md b/docs/dev-rules/protocol-compatibility.md index a6670a74820..79adb80f84d 100644 --- a/docs/dev-rules/protocol-compatibility.md +++ b/docs/dev-rules/protocol-compatibility.md @@ -1455,6 +1455,17 @@ Mobile 据此区分已关闭与已删除的旧选择:保留任务或草稿原 不增加分页、客户端重组或重试,不提高传输大小上限;本机 Desktop 设置仍读取完整目录。 “关闭后必须重选”的提示与发送前检查随 Mobile 更新;旧版控制端仍沿用各自既有选择处理。 +## Cursor 既有任务的手机模型选择 + +控制端通过既有 link-open / subscribe 的 append-only capabilities 声明 +`cursor-model-picker-v1`,表示其跨引擎模型列表包含 Cursor。声明者接收主机原有完整能力; +未声明的旧控制端在读取 `maker:get-capabilities('cursor')` 时,仅将 +`supportsSessionAgentSwitch` 投影为 false,让既有 Cursor 任务使用当前引擎模型列表。 +原生型号 ID、模型目录、权限与主机执行能力均保留;其他引擎的响应不变。 +这使已识别 Cursor 的旧手机可以继续选 Cursor 型号,完整跨引擎选择需控制端更新。 +未知能力声明由旧主机忽略。不新增 channel、relay 类型、协议版本、数据库迁移或 +Mobile 原生 fingerprint 输入,服务端无需改动。 + ### 伙伴群聊成员操作与发送错误(#5604–#5606) `bot-group-chat` 数据追加可选 `supportsMemberRemoval`;仅为 true 时手机调用 diff --git a/docs/pr-assets/cursor-acp/permissions-dark.png b/docs/pr-assets/cursor-acp/permissions-dark.png new file mode 100644 index 00000000000..f9796c286e0 Binary files /dev/null and b/docs/pr-assets/cursor-acp/permissions-dark.png differ diff --git a/docs/pr-assets/cursor-acp/permissions-light.png b/docs/pr-assets/cursor-acp/permissions-light.png new file mode 100644 index 00000000000..89ee1538b77 Binary files /dev/null and b/docs/pr-assets/cursor-acp/permissions-light.png differ diff --git a/docs/pr-assets/cursor-acp/provider-settings-dark.png b/docs/pr-assets/cursor-acp/provider-settings-dark.png new file mode 100644 index 00000000000..b0c6214ed59 Binary files /dev/null and b/docs/pr-assets/cursor-acp/provider-settings-dark.png differ diff --git a/docs/pr-assets/cursor-acp/provider-settings-light.png b/docs/pr-assets/cursor-acp/provider-settings-light.png new file mode 100644 index 00000000000..389e1eb6524 Binary files /dev/null and b/docs/pr-assets/cursor-acp/provider-settings-light.png differ diff --git a/docs/product-rules/model-selector-unified.md b/docs/product-rules/model-selector-unified.md index c628039f30a..634babce005 100644 --- a/docs/product-rules/model-selector-unified.md +++ b/docs/product-rules/model-selector-unified.md @@ -36,6 +36,8 @@ - 分组:收藏 → 推荐(仅已有任务)→ 供应商分组。推荐区先放当前模型,再放同生效引擎的模型;推荐中的模型从下方供应商组移除,空组不渲染标题或间距。供应商侧栏仍基于完整目录。新任务不展示同引擎推荐。组间顺序遵循「设置 → 模型供应商」的拖动排序(本地会话;device-link 用被控端快照顺序,不套控制端本地序),组内按 group/sortOrder;订阅账号(OpenAI、Anthropic)有账号模型清单时 sortOrder 以账号返回顺序为准,只在目录里有的模型接在其后,目录 sortOrder 仅在拿不到账号清单时兜底,见 [模型排序](../dev-rules/model-catalog-maintenance.md#ordering)。收藏区条目**不**从供应商组中去重移除(收藏是配置副本,模型本体仍在原地)。 - 折扣表达:价格档 `¥/$` 串按折后价连续填充(付费亮、折扣灰),后跟淡染徽标 `↓60%`;详情(title+浮层)写全「折扣中 · 标准价 X · 省 Y%」。中文计价 ¥,英文 $。 +Cursor 来源在 Desktop / Mobile 的全部和来源视图中保留 `Auto`、`Cursor Models`、`Other Models` 分组,不抽到同引擎推荐区;组内沿用原生顺序和参数能力。分组是 Cursor 来源内的显示信息,Claude、GPT 等模型仍通过 Cursor 执行,远程标题保留电脑名。只展示当前账号实际返回的模型;`Cursor Default` 旧占位仅供历史任务兼容,不列为新选择。`Auto` 的实际用量可能归入任一池子,分组不承诺自动路由或子代理的计费结果;上游未提供的池子余额不模拟展示。 + 供应商图标下方以 22 × 3px 横线显示账号通用周限额的**剩余比例**;图标格统一高 38px,数据更新不改变布局。常态灰色,剩余低于 30% 为警示色,剩余不高于 10% 为严重色,复用额度语义 token。悬停与键盘聚焦的 Tip 显示完整连接名、周剩余百分比和“还有多久重置”。紧凑区域优先倒计时,使用上游重置时间计算,不把已过期窗口推断为已补满。 余量读取复用现有账号缓存,按连接 ID 隔离。只取明确的周窗口,不把短窗口或模型专属额度当账号周限额;未取得数据、断开连接、窗口已结束、API 计费供应商不显示横线。远程目录不读取控制端本机余量,改读被控端的余量:与会话底部用量 chip 共用同一份被控端镜像,打开选择器时,chip 正在展示且 60 秒内刚更新过的账号直接复用,不重复查询;没有其它组件在监听的账号每次打开都重新读取,避免错过期间的登出或换号。Anthropic / xAI 当前原生账号读取接口仅适用于各自内置连接,不能借给同品牌自定义连接。 diff --git a/packages/device-link/src/__tests__/modelFavorites.test.ts b/packages/device-link/src/__tests__/modelFavorites.test.ts index 6564192b1ac..759f70b0a72 100644 --- a/packages/device-link/src/__tests__/modelFavorites.test.ts +++ b/packages/device-link/src/__tests__/modelFavorites.test.ts @@ -42,3 +42,14 @@ it("strips unknown fields and rejects invalid identities, duplicates, and effort }), ).toThrow("identity"); }); + +it.each(["cc", "codex", "pi", "cursor"] as const)("preserves %s favorite identity across list and mutation parsing", (agent) => { + const favorite = { uid: "native-fav", providerId: "native", modelId: "native/exact-id", agent }; + expect(parseModelFavorites([favorite])).toEqual([favorite]); + expect(parseModelFavoriteMutation({ kind: "add", item: favorite })).toEqual({ + kind: "add", item: { ...favorite, uid: "" }, + }); +}); +it("continues to reject unknown harness favorites", () => { + expect(() => parseModelFavorites([{ ...item, agent: "unrecognized" }])).toThrow("Invalid model favorite"); +}); diff --git a/packages/device-link/src/modelFavorites.ts b/packages/device-link/src/modelFavorites.ts index 9673035fab7..6b66233cdbe 100644 --- a/packages/device-link/src/modelFavorites.ts +++ b/packages/device-link/src/modelFavorites.ts @@ -6,7 +6,7 @@ export interface RemoteModelFavorite { uid: string; providerId: string; modelId: string; - agent: "cc" | "codex" | "pi"; + agent: "cc" | "codex" | "pi" | "cursor"; effort?: string; fast?: true; } @@ -32,7 +32,7 @@ export function parseRemoteModelFavorite( !text(p.providerId) || p.providerId === "*" || !text(p.modelId) || - !["cc", "codex", "pi"].includes(p.agent) || + !["cc", "codex", "pi", "cursor"].includes(p.agent) || (p.effort !== undefined && !["minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes( p.effort, diff --git a/packages/device-link/src/protocol.ts b/packages/device-link/src/protocol.ts index e52ba838cdc..dede0a27b56 100644 --- a/packages/device-link/src/protocol.ts +++ b/packages/device-link/src/protocol.ts @@ -140,6 +140,9 @@ export interface LinkOpenPayload { /** 控制端能安全消费完整 ProviderLogoKind(含 #527 新增品牌)的 maker:provider:list 投影。 */ export const CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2 = 'provider-logo-kinds-v2'; +/** Includes Cursor when browsing models across engines in an existing task. */ +export const CONTROLLER_CAPABILITY_CURSOR_MODEL_PICKER_V1 = 'cursor-model-picker-v1'; + /** 控制端能区分 set-model 中显式 providerId:null 与 JSON optional 占位。 */ export const CONTROLLER_CAPABILITY_SET_MODEL_EXPLICIT_PROVIDER_NULL_V1 = 'set-model-explicit-provider-null-v1'; diff --git a/packages/lizi-mcps/src/__tests__/createWorkerTool.test.ts b/packages/lizi-mcps/src/__tests__/createWorkerTool.test.ts index d8c52b129b7..d838fcbca1b 100644 --- a/packages/lizi-mcps/src/__tests__/createWorkerTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/createWorkerTool.test.ts @@ -50,6 +50,13 @@ function setupWorkerSession() { } describe('create_worker tool', () => { + it.each(['claude-code', 'codex', 'pi', 'cursor'] as const)('preserves %s worker harness identity', async (agent) => { + const { registry, createWorker } = setup(); + const result = await registry.call('create_worker', { role: 'developer', agent, label: 'worker' }); + expect(result.isError).toBeUndefined(); + expect(createWorker).toHaveBeenCalledWith(expect.objectContaining({ agent })); + }); + it('forwards the explicit working_dir to the host', async () => { const { registry, createWorker } = setup(); const result = await registry.call('create_worker', { diff --git a/packages/lizi-mcps/src/__tests__/listAvailableModelsTool.test.ts b/packages/lizi-mcps/src/__tests__/listAvailableModelsTool.test.ts index 7771ee25d5b..d18b238dc17 100644 --- a/packages/lizi-mcps/src/__tests__/listAvailableModelsTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/listAvailableModelsTool.test.ts @@ -11,6 +11,40 @@ function parse(result: XdtHelperToolResult) { } describe('list_available_models tool', () => { + it.each(['openai-codex/gpt-5.5', 'codex/gpt-5.5'])( + 'distinguishes the gateway route from an opaque Cursor ID: %s', + async (id) => { + const registry = new XdtHelperToolRegistry(); + registerListAvailableModelsTool(registry, { + listAvailableModels: async () => ({ + ok: true as const, + codex: [{ id, label: 'Gateway route' }], + cursor: [{ id, label: 'Native Cursor choice' }], + }), + }); + + const result = parse(await registry.call('list_available_models', {})); + + expect(result.codex).toEqual([{ id, label: 'Gateway route', tier: 'budget' }]); + expect(result.cursor).toEqual([{ id, label: 'Native Cursor choice', tier: 'standard' }]); + }, + ); + + it('preserves native Cursor model IDs and keeps the Pi catalog separate', async () => { + const listAvailableModels = vi.fn(async () => ({ + ok: true as const, + cursor: [{ id: 'codex/native-exact-id', label: 'Native model' }], + pi: [{ id: 'pi-model', label: 'Pi model' }], + })); + const registry = new XdtHelperToolRegistry(); + registerListAvailableModelsTool(registry, { listAvailableModels }); + const result = parse(await registry.call('list_available_models', { agent: 'cursor' })); + expect(listAvailableModels).toHaveBeenCalledWith({ agent: 'cursor' }); + expect(result.cursor).toEqual([{ id: 'codex/native-exact-id', label: 'Native model', tier: 'standard' }]); + expect(result.pi).toEqual([{ id: 'pi-model', label: 'Pi model', tier: 'standard' }]); + expect(result.claude_code).toBeUndefined(); + }); + it('returns provider-aware routes without removing the existing model fields', async () => { const listAvailableModels = vi.fn(async () => ({ ok: true as const, diff --git a/packages/lizi-mcps/src/__tests__/listSessionsTool.test.ts b/packages/lizi-mcps/src/__tests__/listSessionsTool.test.ts index 96a106464ac..9bfbf31a73f 100644 --- a/packages/lizi-mcps/src/__tests__/listSessionsTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/listSessionsTool.test.ts @@ -64,6 +64,15 @@ function historyWithSessions(): XdtHelperHistoryDeps { } describe("list_sessions queuedCount", () => { + it.each(["cc", "codex", "pi", "cursor"] as const)("preserves %s in history filters", async (agentKind) => { + const history = historyWithSessions(); + const registry = new XdtHelperToolRegistry(); + registerListSessionsTool(registry, { history }); + const result = await registry.call("list_sessions", { agent_kind: agentKind }); + expect(result.isError).toBeUndefined(); + expect(history.listSessions).toHaveBeenCalledWith(expect.objectContaining({ agentKind })); + }); + it("adds live queuedCount values for the returned page", async () => { const sessionQueue: SessionQueueDeps = { listSessionQueue: vi.fn(), diff --git a/packages/lizi-mcps/src/__tests__/providersHarnessIdentity.test.ts b/packages/lizi-mcps/src/__tests__/providersHarnessIdentity.test.ts new file mode 100644 index 00000000000..33f80a6b9a2 --- /dev/null +++ b/packages/lizi-mcps/src/__tests__/providersHarnessIdentity.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createLiziMcpProviders } from '../providers.js'; + +const factories = vi.hoisted(() => ({ + scheduler: vi.fn(), helper: vi.fn(), docs: vi.fn(), orca: vi.fn(), +})); +vi.mock('../cindy_schedulerMcpServer.js', () => ({ createSchedulerMcpServer: factories.scheduler })); +vi.mock('../lizi_xdtHelperMcpServer.js', () => ({ createXdtHelperMcpServer: factories.helper })); +vi.mock('../cindy_docsMcpServer.js', () => ({ createCindyDocsMcpServer: factories.docs })); +vi.mock('../orca/index.js', () => ({ createOrcaMcpServer: factories.orca })); +// This test exercises factory context routing, not browser runtime startup. +vi.mock('../browser/index.js', () => ({ createBrowserMcpServer: vi.fn() })); +vi.mock('../computer/index.js', () => ({ createComputerMcpServer: vi.fn() })); +vi.mock('../android/index.js', () => ({ createAndroidMcpServer: vi.fn() })); + +beforeEach(() => vi.clearAllMocks()); + +describe('MCP provider harness identity', () => { + it.each(['claude-code', 'codex', 'pi', 'cursor'] as const)('preserves %s at every task-aware factory', (agentKind) => { + const providers = createLiziMcpProviders({ + scheduler: {} as never, xdtHelper: {}, docs: {} as never, orca: {} as never, + }); + const context = { agentKind, workingDir: '/repo', sessionId: 'task-1', vendorOptions: { orcaRole: 'worker' } }; + for (const provider of providers) provider.toClaudeSdkConfig(context); + for (const factory of Object.values(factories)) { + expect(factory).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(context)); + } + }); +}); diff --git a/packages/lizi-mcps/src/__tests__/sendToSessionTool.test.ts b/packages/lizi-mcps/src/__tests__/sendToSessionTool.test.ts index d6bad572665..80da6d5187a 100644 --- a/packages/lizi-mcps/src/__tests__/sendToSessionTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/sendToSessionTool.test.ts @@ -53,6 +53,21 @@ function parse(result: XdtHelperToolResult) { } describe('send_to_session tool', () => { + it('passes an explicit native Cursor route to the ordinary task host', async () => { + const { registry, sendToSession } = setup({ result: { + ok: true, targetSessionId: 'cursor-task', agentKind: 'cursor', + wakeKind: 'created', targetTitle: null, targetLastUserSendAt: null, + } }); + const result = await registry.call('send_to_session', { + message: 'hello', agent_kind: 'cursor', model: 'cursor-default', + }); + expect(result.isError).toBeUndefined(); + expect(sendToSession).toHaveBeenCalledWith(expect.objectContaining({ + agentKind: 'cursor', model: 'cursor-default', message: 'hello', + })); + expect(parse(result)).toMatchObject({ agent_kind: 'cursor', target_session_id: 'cursor-task' }); + }); + it('注册到 handoff 类目, 不混入 control(改名场景选错隔离的核心)', () => { const { registry } = setup(); const handoff = registry.list('handoff').map((t) => t.name); diff --git a/packages/lizi-mcps/src/__tests__/sessionControlTools.test.ts b/packages/lizi-mcps/src/__tests__/sessionControlTools.test.ts index 85e00817d0b..a6a4605058e 100644 --- a/packages/lizi-mcps/src/__tests__/sessionControlTools.test.ts +++ b/packages/lizi-mcps/src/__tests__/sessionControlTools.test.ts @@ -94,6 +94,17 @@ function setup(opts?: { sessionId?: string | undefined }) { } describe('cindy_helper session control tools', () => { + it('forwards a Cursor switch without rewriting its native model or adding a provider', async () => { + const { deps, registry } = setup(); + await registry.call('set_session_runtime', { + session_id: 'existing-task', harness: 'cursor', model: 'native/exact-model', expected_generation: 1, + }); + expect(deps.setSessionRuntime).toHaveBeenCalledWith({ + targetSessionId: 'existing-task', expectedGeneration: 1, + patch: { harness: 'cursor', model: 'native/exact-model' }, + }); + }); + it('forwards a complete harness selection and reports the next-send boundary', async () => { const { deps, registry } = setup(); const old = { agentKind: 'claude-code' as const, model: 'claude-fable-5', providerId: null, effort: 'high' as const, fastMode: false }; @@ -165,9 +176,10 @@ describe('cindy_helper session control tools', () => { // 校验明细,调用方一轮自纠。 const { registry } = setup(); const result = parse(await registry.call('set_session_runtime', { effort: 'high' })); + const data = result.data as Record | undefined; expect(result).toMatchObject({ ok: false, errorCode: 'INVALID_ARGS' }); - expect(JSON.stringify(result.data?.validation_errors ?? result)).toContain('expected_generation'); - expect(result.data?.schema).toBeTruthy(); + expect(JSON.stringify(data?.validation_errors ?? result)).toContain('expected_generation'); + expect(data?.schema).toBeTruthy(); }); it('updates and cancels only through the caller-bound ownership context', async () => { diff --git a/packages/lizi-mcps/src/__tests__/submitGithubIssueTool.test.ts b/packages/lizi-mcps/src/__tests__/submitGithubIssueTool.test.ts index 6ab5087f656..773a2fabac0 100644 --- a/packages/lizi-mcps/src/__tests__/submitGithubIssueTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/submitGithubIssueTool.test.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { XdtHelperToolRegistry } from '../lizi_xdtHelperToolRegistry.js'; import type { XdtHelperToolResult } from '../lizi_xdtHelperToolRegistry.js'; @@ -66,6 +67,8 @@ describe('submit_github_issue tool', () => { it('澄清流程交给 agent 判断,并与自动附加的当前任务环境区分', () => { const { registry } = setup(); const tool = registry.get('submit_github_issue'); + const bodyDescription = tool?.inputShape.body + ? z.globalRegistry.get(tool.inputShape.body)?.description : undefined; expect(tool?.description).toContain('缺什么问什么'); expect(tool?.description).toContain('不要套固定问卷或章节清单'); expect(tool?.description).toContain('普通建议不必按缺陷来问'); @@ -76,15 +79,15 @@ describe('submit_github_issue tool', () => { expect(tool?.description).toContain('本工具不能把对话里的图片传到 GitHub'); expect(tool?.description).toContain('禁止写「已提供截图」'); expect(tool?.description).toContain('不要写仓库路径、实现方案、验收清单'); - expect(tool?.inputShape.body.description).toContain('按这条反馈本身组织'); - expect(tool?.inputShape.body.description).not.toContain('bug 优先用'); - expect(tool?.inputShape.body.description).toContain('禁止声称截图已附'); - expect(tool?.inputShape.body.description).toContain('提交时的任务环境'); - expect(tool?.inputShape.body.description).toContain('实际故障'); - expect(tool?.inputShape.body.description).toContain( + expect(bodyDescription).toContain('按这条反馈本身组织'); + expect(bodyDescription).not.toContain('bug 优先用'); + expect(bodyDescription).toContain('禁止声称截图已附'); + expect(bodyDescription).toContain('提交时的任务环境'); + expect(bodyDescription).toContain('实际故障'); + expect(bodyDescription).toContain( '不要复制系统自动附加的当前任务快照', ); - expect(tool?.inputShape.body.description).not.toMatch( + expect(bodyDescription).not.toMatch( /不要写环境信息\(客户端版本 \/ 版本区域 \/ OS \/ Harness \/ 模型 ID/, ); }); diff --git a/packages/lizi-mcps/src/cindy-docs/types.ts b/packages/lizi-mcps/src/cindy-docs/types.ts index 8238867e530..943ae455610 100644 --- a/packages/lizi-mcps/src/cindy-docs/types.ts +++ b/packages/lizi-mcps/src/cindy-docs/types.ts @@ -138,6 +138,6 @@ export interface DocsMcpDeps { * workingDir 为根,解析不出来就 fail closed(见 _paths.ts)。 */ export interface DocsMcpSessionCtx extends LiziMcpSessionContext { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; } diff --git a/packages/lizi-mcps/src/cindy_schedulerMcpServer.ts b/packages/lizi-mcps/src/cindy_schedulerMcpServer.ts index b1befd6f06c..7d83d53853d 100644 --- a/packages/lizi-mcps/src/cindy_schedulerMcpServer.ts +++ b/packages/lizi-mcps/src/cindy_schedulerMcpServer.ts @@ -53,7 +53,7 @@ import type { LiziMcpSessionContext, SchedulerMcpDeps } from './types.js'; * resolveLiziMcpSessionContext 补回当前 thread 的 ctx。 */ export interface SchedulerMcpSessionCtx { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; getSessionContext?: () => LiziMcpSessionContext | undefined; sessionId?: string; diff --git a/packages/lizi-mcps/src/computer/computerMcpServer.test.ts b/packages/lizi-mcps/src/computer/computerMcpServer.test.ts index 24813c80786..53a3333b7c4 100644 --- a/packages/lizi-mcps/src/computer/computerMcpServer.test.ts +++ b/packages/lizi-mcps/src/computer/computerMcpServer.test.ts @@ -1251,7 +1251,7 @@ describe('createComputerMcpServer', () => { const authorizer = vi.fn(async (request: { path: string; toolName?: string }) => { expect(request.path).toBe(screenshot); expect(request.toolName).toBe('cindy-computer:get_window_state'); - return { allowed: true, isCurrent: () => true }; + return { allowed: true as const, isCurrent: () => true }; }); setSessionPathAuthorizer(authorizer); const h = await makeHarness(deps, { diff --git a/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts b/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts index 4f5343c013f..0731fb31aa0 100644 --- a/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts +++ b/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts @@ -821,7 +821,7 @@ export interface XdtHelperMcpDeps { * 字段惰性创建, 工具 handler 闭包捕获这些值。 */ export interface XdtHelperMcpSessionCtx { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; remoteHostId?: string; getSessionContext?: () => import('./types.js').LiziMcpSessionContext | undefined; diff --git a/packages/lizi-mcps/src/providers.ts b/packages/lizi-mcps/src/providers.ts index fbc49fe9b16..72e3bd4886f 100644 --- a/packages/lizi-mcps/src/providers.ts +++ b/packages/lizi-mcps/src/providers.ts @@ -329,7 +329,7 @@ export function createLiziMcpProviders( type: 'sdk', name: 'cindy_scheduler', instance: createSchedulerMcpServer(opts.scheduler!, { - agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : 'claude-code', + agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : ctx.agentKind === 'cursor' ? 'cursor' : 'claude-code', workingDir: ctx.workingDir, ...(ctx.getSessionContext ? { getSessionContext: ctx.getSessionContext } : {}), sessionId: ctx.sessionId, @@ -371,7 +371,7 @@ export function createLiziMcpProviders( instance: createXdtHelperMcpServer({ ...opts.xdtHelper!, ...(opts.xdtHelper!.botRoutines ? { botRoutines: { ...opts.xdtHelper!.botRoutines, scheduler: opts.scheduler } } : {}), }, { - agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : 'claude-code', + agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : ctx.agentKind === 'cursor' ? 'cursor' : 'claude-code', workingDir: ctx.workingDir, ...(ctx.getSessionContext ? { getSessionContext: ctx.getSessionContext } : {}), sessionId: ctx.sessionId, @@ -393,7 +393,7 @@ export function createLiziMcpProviders( type: 'sdk', name: 'cindy_docs', instance: createCindyDocsMcpServer(opts.docs!, { - agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : 'claude-code', + agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : ctx.agentKind === 'cursor' ? 'cursor' : 'claude-code', workingDir: ctx.workingDir, ...(ctx.getSessionContext ? { getSessionContext: ctx.getSessionContext } : {}), sessionId: ctx.sessionId, @@ -416,7 +416,7 @@ export function createLiziMcpProviders( type: 'sdk', name: 'cindy_orca', instance: createOrcaMcpServer(opts.orca!, { - agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : 'claude-code', + agentKind: ctx.agentKind === 'codex' ? 'codex' : ctx.agentKind === 'pi' ? 'pi' : ctx.agentKind === 'cursor' ? 'cursor' : 'claude-code', workingDir: ctx.workingDir, ...(ctx.getSessionContext ? { getSessionContext: ctx.getSessionContext } : {}), sessionId: ctx.sessionId, diff --git a/packages/lizi-mcps/src/scheduler/_enums.ts b/packages/lizi-mcps/src/scheduler/_enums.ts index 14ca29af92a..c1b1e1cceb6 100644 --- a/packages/lizi-mcps/src/scheduler/_enums.ts +++ b/packages/lizi-mcps/src/scheduler/_enums.ts @@ -9,7 +9,7 @@ * runner.ts 已经做白名单校验,MCP 这一层提前在 zod schema 里拦更友好 * (非白名单值直接变 INVALID_ARGS,不会进入 runner)。 */ -export const AGENT_KIND = ['claude-code', 'codex', 'pi'] as const; +export const AGENT_KIND = ['claude-code', 'codex', 'pi', 'cursor'] as const; export const EFFORT = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; export const SCHEDULE_STATUS = ['active', 'paused', 'expired'] as const; export const EXECUTION_MODE = ['agent', 'script'] as const; diff --git a/packages/lizi-mcps/src/scheduler/setPreRunHook.ts b/packages/lizi-mcps/src/scheduler/setPreRunHook.ts index f8f282f596d..2a899c83b64 100644 --- a/packages/lizi-mcps/src/scheduler/setPreRunHook.ts +++ b/packages/lizi-mcps/src/scheduler/setPreRunHook.ts @@ -89,7 +89,7 @@ export function registerScheduleSetPreRunHookTool( let currentCommand: string | undefined; let currentTimeoutMs: number | undefined; let providerId: string | undefined; - let agentKind: 'codex' | 'claude-code' | 'pi' | undefined; + let agentKind: 'codex' | 'claude-code' | 'pi' | 'cursor' | undefined; let model: string | undefined; if (scheduleId) { const schedule = await scheduler.get(scheduleId); diff --git a/packages/lizi-mcps/src/types.ts b/packages/lizi-mcps/src/types.ts index 1108de80281..18a8d8aa76d 100644 --- a/packages/lizi-mcps/src/types.ts +++ b/packages/lizi-mcps/src/types.ts @@ -542,7 +542,7 @@ export type ControlResult = * adding a new vendor (e.g. 'gemini') without updating this union will cause * LLM tool calls to fail zod enum validation. */ -export type ControlWorkerAgent = 'claude-code' | 'codex' | 'pi'; +export type ControlWorkerAgent = 'claude-code' | 'codex' | 'pi' | 'cursor'; /** Browser automation MCP host deps. Core browser execution is injected by host. */ export interface BrowserMcpDeps { diff --git a/packages/lizi-mcps/src/xdt-helper/_history_types.ts b/packages/lizi-mcps/src/xdt-helper/_history_types.ts index 5c5ee1acc6f..d0ce5990990 100644 --- a/packages/lizi-mcps/src/xdt-helper/_history_types.ts +++ b/packages/lizi-mcps/src/xdt-helper/_history_types.ts @@ -8,7 +8,7 @@ import type { ControlResult } from '../types.js'; -export type HistoryAgentKind = 'cc' | 'codex' | 'pi'; +export type HistoryAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; export type HistoryOrder = 'asc' | 'desc'; /** Stable business errors exposed by cross-device history readers. */ diff --git a/packages/lizi-mcps/src/xdt-helper/bot_capabilities.ts b/packages/lizi-mcps/src/xdt-helper/bot_capabilities.ts index 3c29adf4c96..3cc0216de7b 100644 --- a/packages/lizi-mcps/src/xdt-helper/bot_capabilities.ts +++ b/packages/lizi-mcps/src/xdt-helper/bot_capabilities.ts @@ -50,7 +50,7 @@ export interface BotCapabilityCallbacks { } export interface AppDefaultModelRoute { - harness: 'claude' | 'codex' | 'pi'; providerId: string | null; + harness: 'claude' | 'codex' | 'pi' | 'cursor'; providerId: string | null; model: string; effort: string; fastMode: boolean; } diff --git a/packages/lizi-mcps/src/xdt-helper/bot_workbench.ts b/packages/lizi-mcps/src/xdt-helper/bot_workbench.ts index 165096177e1..bf9b435cc91 100644 --- a/packages/lizi-mcps/src/xdt-helper/bot_workbench.ts +++ b/packages/lizi-mcps/src/xdt-helper/bot_workbench.ts @@ -79,7 +79,7 @@ export interface WorkbenchDigestWire { export interface WorkbenchTaskWire { /** Cindy 任务是 session id;还没接过来的本机会话是 `claude:` / `codex:` / `pi:`。 */ taskId: string; - source: 'cindy' | 'claude-code' | 'codex' | 'pi'; + source: 'cindy' | 'claude-code' | 'codex' | 'pi' | 'cursor'; /** 已经是 Cindy 里的任务(本机会话被继续过之后也会变成 true)。 */ imported: boolean; /** 清洗过的原始标题。 */ diff --git a/packages/lizi-mcps/src/xdt-helper/create_worker.ts b/packages/lizi-mcps/src/xdt-helper/create_worker.ts index d4a0cf71a99..e9f310a28d2 100644 --- a/packages/lizi-mcps/src/xdt-helper/create_worker.ts +++ b/packages/lizi-mcps/src/xdt-helper/create_worker.ts @@ -87,7 +87,7 @@ export const createWorkerSpecSchema = z.object({ .max(32) .describe('worker 角色: developer / reviewer / tester / merger 或自定义 string'), agent: z - .enum(['claude-code', 'codex', 'pi']) + .enum(['claude-code', 'codex', 'pi', 'cursor']) .describe('worker agent 类型'), model: z .string() @@ -148,7 +148,7 @@ const DESCRIPTION = [ '', '参数:', '- role: worker 角色 (developer / reviewer / tester / merger 或自定义 string)', - '- agent: worker agent 类型 (codex / claude-code / pi)', + '- agent: worker agent 类型 (codex / claude-code / pi / cursor)', '- model: 可选, worker 使用的模型 id; 不传走 host 端默认 fallback', '- provider_id: 可选, 模型来源 provider id。优先使用 list_available_models 对应模型返回的 provider_id;显式传入后 host 会严格校验该来源已连接且提供所选模型。', '- effort: 可选, reasoning/thinking 强度 (low / medium / high / xhigh / max / ultra)。Codex: 映射 OpenAI reasoning effort(max/ultra 仅部分模型如 GPT-5.6 Sol 支持); Claude Code: 映射 extended thinking token 预算(无 ultra,自动降级为 max)。显式传入时必须匹配所选 model 能力;当前 worker 模型都不把 minimal 作为可选思考档。', diff --git a/packages/lizi-mcps/src/xdt-helper/get_chat_history.ts b/packages/lizi-mcps/src/xdt-helper/get_chat_history.ts index 71305326e8b..a871464243d 100644 --- a/packages/lizi-mcps/src/xdt-helper/get_chat_history.ts +++ b/packages/lizi-mcps/src/xdt-helper/get_chat_history.ts @@ -117,7 +117,7 @@ export function registerGetChatHistoryTool( .optional() .describe('ISO 8601 (不含): 过滤 messages.createdAt < to。'), agent_kind: z - .enum(['cc', 'codex', 'pi']) + .enum(['cc', 'codex', 'pi', 'cursor']) .optional() .describe('按 session.agentKind 过滤; 不传 = 全部返。'), roles: z diff --git a/packages/lizi-mcps/src/xdt-helper/get_current_session_id.ts b/packages/lizi-mcps/src/xdt-helper/get_current_session_id.ts index 20084b39692..29759c89b66 100644 --- a/packages/lizi-mcps/src/xdt-helper/get_current_session_id.ts +++ b/packages/lizi-mcps/src/xdt-helper/get_current_session_id.ts @@ -5,7 +5,7 @@ import { errorPayload, okPayload } from './_payload.js'; export interface GetCurrentSessionIdDeps { getSessionContext: () => { sessionId?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; }; } diff --git a/packages/lizi-mcps/src/xdt-helper/list_available_models.ts b/packages/lizi-mcps/src/xdt-helper/list_available_models.ts index ffc58e1c442..87539f23735 100644 --- a/packages/lizi-mcps/src/xdt-helper/list_available_models.ts +++ b/packages/lizi-mcps/src/xdt-helper/list_available_models.ts @@ -48,12 +48,12 @@ function isBudgetWireModel(modelId: string): boolean { ); } -function tagTier(models: ModelDescriptor[] | undefined): TaggedModel[] | undefined { +function tagTier(models: ModelDescriptor[] | undefined, nativeCatalog = false): TaggedModel[] | undefined { if (!models) return undefined; return models.map((m) => ({ id: m.id, label: m.label, - tier: isBudgetWireModel(m.id) ? 'budget' : 'standard', + tier: !nativeCatalog && isBudgetWireModel(m.id) ? 'budget' : 'standard', ...(m.providers ? { providers: m.providers.map((provider) => ({ @@ -72,7 +72,7 @@ export interface ListAvailableModelsDeps { /** 调用方任务(可选):它的 Agent 在另一台电脑运行时,按那台的模型目录列出。 */ getSessionContext?: () => { sessionId?: string }; listAvailableModels: (params: { - agent?: 'claude-code' | 'codex' | 'pi'; + agent?: 'claude-code' | 'codex' | 'pi' | 'cursor'; callerSessionId?: string; /** 列哪里的模型:省略 = Lead 所在位置;null = 任务所在电脑;string = 那台电脑或分享。 */ agentDeviceId?: string | null; @@ -84,6 +84,7 @@ export interface ListAvailableModelsDeps { codex?: ModelDescriptor[]; claude_code?: ModelDescriptor[]; pi?: ModelDescriptor[]; + cursor?: ModelDescriptor[]; }, string>>; } @@ -94,10 +95,10 @@ function toToolAgentDeviceId(value: string | null): string { const DESCRIPTION = [ '列出每个 agent 当前 host 支持的 model id 清单, 用于 create_worker 前确认 model 名拼写。', - 'Codex 和 Claude Code 支持的 model 完全不同, 不可跨用。', + '各引擎的 model id 不可跨用;Cursor 必须原样使用其原生目录公布的 id,不根据显示名称推断。', '', '参数:', - '- agent: 可选, codex / claude-code / pi; 不传返三者', + '- agent: 可选, codex / claude-code / pi / cursor; 不传返所有引擎。Cursor 只列本机工作区,跨设备位置不提供', '- agent_device_id: 可选, 列哪里的模型(Worker 的 Agent 可在别的电脑或分享来的供应商上运行): 取返回的 locations[].agent_device_id, "local" 为这台电脑; 不传列 Lead 所在位置', '', '返回值:', @@ -105,6 +106,7 @@ const DESCRIPTION = [ '- codex: Codex agent 的可用 model 列表 [{id, label, tier, providers, default_provider_id}]', '- claude_code: Claude Code agent 的可用 model 列表 [{id, label, tier, providers, default_provider_id}]', '- pi: Pi agent 的可用 model 列表 [{id, label, tier, providers, default_provider_id}]', + '- cursor: Cursor agent 原生公布的 model 列表 [{id, label, tier}]', '- providers: 当前已连接且实际提供该模型的来源 [{provider_id, provider_name}]。创建 Worker 时把选定的 provider_id 原样传给 create_worker/create_workers。', '- default_provider_id: 未显式选择来源时 host 当前解析出的默认来源;providers 只有一项时直接使用该项。', '', @@ -127,9 +129,9 @@ export function registerListAvailableModelsTool( description: DESCRIPTION, inputShape: { agent: z - .enum(['codex', 'claude-code', 'pi']) + .enum(['codex', 'claude-code', 'pi', 'cursor']) .optional() - .describe('可选, 只查某一 agent 的 model 列表; 不传返三者'), + .describe('可选, 只查某一 agent 的 model 列表; 不传返所有引擎。Cursor 只在本机工作区列出'), agent_device_id: z .string() .trim() @@ -170,6 +172,8 @@ export function registerListAvailableModelsTool( codex: tagTier(result.codex), claude_code: tagTier(result.claude_code), pi: tagTier(result.pi), + // Cursor IDs are opaque native IDs, never Cindy gateway budget routes. + cursor: tagTier(result.cursor, true), }); }, }); diff --git a/packages/lizi-mcps/src/xdt-helper/list_sessions.ts b/packages/lizi-mcps/src/xdt-helper/list_sessions.ts index 0036eff2877..7f5035a15ad 100644 --- a/packages/lizi-mcps/src/xdt-helper/list_sessions.ts +++ b/packages/lizi-mcps/src/xdt-helper/list_sessions.ts @@ -33,7 +33,7 @@ const DESCRIPTION = [ '【过滤参数】所有参数可任意组合, 不传 = 不过滤:', ' - workdir: 精确匹配 sessions.workingDir', ' - from / to: ISO 8601 时间窗 (from 含, to 不含), 过滤 sessions.createdAt', - ' - agent_kind: cc(Claude Code) | codex', + ' - agent_kind: cc(Claude Code) | codex | pi | cursor', ' - include_deleted: 默认 false (排除 status=deleted)', '', '【输出】messageCount 已过滤被 rewind 软删的消息, 是用户可见的真实条数。', @@ -73,9 +73,9 @@ export function registerListSessionsTool( .optional() .describe('ISO 8601 时间字符串(不含), 过滤 sessions.createdAt < to。'), agent_kind: z - .enum(['cc', 'codex']) + .enum(['cc', 'codex', 'pi', 'cursor']) .optional() - .describe('过滤 agent 类型, 不传 = 两者都返。'), + .describe('过滤 agent 类型, 不传 = 所有引擎都返。'), include_deleted: z .boolean() .default(false) diff --git a/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts b/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts index 9c554727bfa..561284ee6fc 100644 --- a/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts +++ b/packages/lizi-mcps/src/xdt-helper/search_chat_history.ts @@ -97,7 +97,7 @@ export function registerSearchChatHistoryTool( from: z.string().optional().describe('ISO 8601(含): 过滤 messages.createdAt >= from。'), to: z.string().optional().describe('ISO 8601(不含): 过滤 messages.createdAt < to。'), agent_kind: z - .enum(['cc', 'codex', 'pi']) + .enum(['cc', 'codex', 'pi', 'cursor']) .optional() .describe('按 session.agentKind 过滤; 不传 = 全部搜。'), roles: z diff --git a/packages/lizi-mcps/src/xdt-helper/send_to_session.ts b/packages/lizi-mcps/src/xdt-helper/send_to_session.ts index 17d531f8a3d..7869a4ccbdc 100644 --- a/packages/lizi-mcps/src/xdt-helper/send_to_session.ts +++ b/packages/lizi-mcps/src/xdt-helper/send_to_session.ts @@ -86,7 +86,7 @@ const DESCRIPTION = [ "- 传 target_session_id → jump:投递到该既有 session(已关闭会自动 resume),wake_kind 返 resumed / already-active;目标正在跑 turn 时消息进入其输入队列、当前 turn 结束后自动派发,wake_kind 返 queued(无需重试)。", "- 不传 target_session_id → create:为业务对象新建一个专属 session。workingDir / Agent / model / effort / Fast 缺省继承当前 session;可用 agent_kind / model / effort / fast 显式覆盖目标执行配置。host 会在创建前按目标 Agent 的模型能力校验,非法组合结构化失败,不静默换模型。wake_kind 返 created,并回传 target_session_id 供调用方建立绑定。", "", - "【执行配置(仅 create 模式)】agent_kind 可选 claude-code / codex / pi;model 使用宿主模型目录返回的精确 id;effort / fast 按目标模型能力校验。只改 Agent 却留下不属于目标 Agent 的继承模型会返 INVALID_ARGS,不会自动挑列表第一项。跨 Agent/model 时模型来源交给 host 既有默认路由解析。完全不传这些字段时保持原有 dispatcher 继承行为。jump 模式忽略这些字段,不修改既有 session。", + "【执行配置(仅 create 模式)】agent_kind 可选 claude-code / codex / pi / cursor;model 使用宿主模型目录返回的精确 id;effort / fast 按目标模型能力校验。只改 Agent 却留下不属于目标 Agent 的继承模型会返 INVALID_ARGS,不会自动挑列表第一项。跨 Agent/model 时模型来源交给 host 既有默认路由解析。完全不传这些字段时保持原有 dispatcher 继承行为。jump 模式忽略这些字段,不修改既有 session。", "", "【working_dir(仅 create 模式)】缺省新 session 继承当前 session 的 workingDir;传 working_dir(绝对路径,目录须已存在)可把新 session 直接落到另一个项目目录——「从项目 A 的对话把任务 handoff 到项目 B 的全新对话」一次调用完成,无需先在 B 里找一个旧对话中转。与 use_worktree 组合时,worktree 的 base git 仓库从 working_dir 解析。路径不是绝对路径 / 不存在 / 不是目录 → 返 INVALID_ARGS(message 带原因)。jump 模式忽略此参数。", "", @@ -173,7 +173,7 @@ export function registerSendToSessionTool( "仅 create 模式可选:新 session 的工作目录(绝对路径,目录须已存在)。缺省继承当前 session 的 workingDir;用于把任务 handoff 到另一个项目目录的全新对话。不合法返 INVALID_ARGS。jump 模式忽略。", ), agent_kind: z - .enum(["claude-code", "codex", "pi"]) + .enum(["claude-code", "codex", "pi", "cursor"]) .optional() .describe( "仅 create 模式可选:目标 Agent/harness。缺省继承 dispatcher;jump 模式忽略。", diff --git a/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts b/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts index bef99b7b96f..2ab9fa9cc67 100644 --- a/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts +++ b/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts @@ -30,7 +30,7 @@ export interface SendToWorkerDeps { }) => Promise< ControlResult< { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; wakeKind: 'resumed' | 'already-active' | 'queued' | 'steered'; targetTitle: string | null; targetLastUserSendAt: string | null; @@ -48,7 +48,7 @@ export interface SendToWorkerDeps { }) => Promise< ControlResult< { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; queuedMessageId: string; stopOutcome: | 'requested' diff --git a/packages/lizi-mcps/src/xdt-helper/session_control.ts b/packages/lizi-mcps/src/xdt-helper/session_control.ts index 66b71788eb9..aa0897f5b6e 100644 --- a/packages/lizi-mcps/src/xdt-helper/session_control.ts +++ b/packages/lizi-mcps/src/xdt-helper/session_control.ts @@ -432,7 +432,7 @@ export function registerSetSessionRuntimeTool( '提供 harness 时必须同时指定 model:完整选择会保存到目标任务,并在下一条消息发送时切换(返回 next_send);' + '省略 harness 保持临时调整语义。不修改全局默认选择。', inputShape: { - harness: z.enum(['claude-code', 'codex', 'pi']).optional().describe('目标执行引擎;提供时必须同时指定 model。'), + harness: z.enum(['claude-code', 'codex', 'pi', 'cursor']).optional().describe('目标执行引擎;提供时必须同时指定 model。'), session_id: z.string().min(1).optional().describe('目标 session id;省略时使用当前任务。'), provider_id: z .string() diff --git a/packages/lizi-mcps/src/xdt-helper/set_current_session_title.ts b/packages/lizi-mcps/src/xdt-helper/set_current_session_title.ts index 2ab8b066137..e45a2a03cc8 100644 --- a/packages/lizi-mcps/src/xdt-helper/set_current_session_title.ts +++ b/packages/lizi-mcps/src/xdt-helper/set_current_session_title.ts @@ -16,7 +16,7 @@ export type SetCurrentSessionTitleResult = ControlResult< export interface SetCurrentSessionTitleDeps { getSessionContext: () => { sessionId?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; }; setCurrentSessionTitle(params: { diff --git a/packages/lizi-mcps/src/xdt-helper/submit_github_issue.ts b/packages/lizi-mcps/src/xdt-helper/submit_github_issue.ts index 15d65db67eb..a7c9c312d36 100644 --- a/packages/lizi-mcps/src/xdt-helper/submit_github_issue.ts +++ b/packages/lizi-mcps/src/xdt-helper/submit_github_issue.ts @@ -55,13 +55,13 @@ export type SubmitGithubIssueHostResult = export interface SubmitGithubIssueDeps { getSessionContext: () => { sessionId?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; }; /** host 回调:弹确认卡片 → 用户确认后提交到 server。 */ submit: (req: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; workingDir: string; title: string; body: string; diff --git a/packages/maker-core/src/agents/acp/client.test.ts b/packages/maker-core/src/agents/acp/client.test.ts new file mode 100644 index 00000000000..5ab62624cb3 --- /dev/null +++ b/packages/maker-core/src/agents/acp/client.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AcpClient, AcpRpcError } from './client.js'; +import type { AcpTransport } from './transport.js'; +function fixture(options: ConstructorParameters[1] = {}) { + const writes: Record[] = []; + let line: (value: string) => void = () => {}; + let closed: (info: { reason: string }) => void = () => {}; + const transport: AcpTransport = { + writeLine: vi.fn(async value => { writes.push(JSON.parse(value)); }), + onLine: handler => { line = handler; return vi.fn(); }, + onClose: handler => { closed = handler; return vi.fn(); }, close: vi.fn(async () => {}), + }; + const client = new AcpClient(transport, options); + return { client, transport, writes, receive: (value: unknown) => line(JSON.stringify(value)), raw: (value: string) => line(value), closed }; +} +afterEach(() => vi.useRealTimers()); +describe('ACP JSON-RPC client', () => { + it('correlates ID zero and concurrent replies, ignores unknown IDs', async () => { + const f = fixture(); + const first = f.client.request('initialize', {}); const second = f.client.request('session/new', {}); + expect(f.writes[0].id).toBe(0); + f.receive({ jsonrpc: '2.0', id: 99, result: 'unknown' }); + f.receive({ jsonrpc: '2.0', id: 1, result: 'second' }); f.receive({ jsonrpc: '2.0', id: 0, result: 'first' }); + await expect(first).resolves.toBe('first'); await expect(second).resolves.toBe('second'); + }); + it('routes notifications and answers server requests with ID zero', async () => { + const onNotification = vi.fn(); const f = fixture({ onNotification, onRequest: async () => ({ ok: true }) }); + f.receive({ jsonrpc: '2.0', method: 'session/update', params: { text: 'hello' } }); + f.receive({ jsonrpc: '2.0', id: 0, method: 'session/request_permission', params: {} }); + await Promise.resolve(); + expect(onNotification).toHaveBeenCalledWith('session/update', { text: 'hello' }); + expect(f.writes).toContainEqual({ jsonrpc: '2.0', id: 0, result: { ok: true } }); + }); + it('returns method-not-found and sanitized internal errors', async () => { + const f = fixture(); f.receive({ jsonrpc: '2.0', id: 'remote', method: 'unknown' }); + expect(f.writes[0]).toEqual({ jsonrpc: '2.0', id: 'remote', error: { code: -32601, message: 'Method not found' } }); + const g = fixture({ onRequest: () => { throw new Error('private secret'); } }); + g.receive({ jsonrpc: '2.0', id: 0, method: 'bad' }); + expect(JSON.stringify(g.writes)).not.toContain('private secret'); + expect(g.writes[0]).toMatchObject({ error: { code: -32603 } }); + }); + it('rejects malformed responses and validates protocol envelopes', async () => { + const f = fixture(); f.raw('{'); f.receive([]); f.receive({ jsonrpc: '1.0', method: 'bad' }); + expect(f.writes.map(value => (value.error as { code: number }).code)).toEqual([-32700, -32600, -32600]); + const pending = f.client.request('test'); + f.receive({ jsonrpc: '2.0', id: 0, result: null, error: { code: 1, message: 'bad' } }); + await expect(pending).rejects.toThrow('Invalid ACP response'); + }); + it('preserves RPC error code and data for explicit caller handling', async () => { + const f = fixture(); const pending = f.client.request('test'); + f.receive({ jsonrpc: '2.0', id: 0, error: { code: -32000, message: 'Denied', data: { reason: 'auth' } } }); + await expect(pending).rejects.toMatchObject({ code: -32000, data: { reason: 'auth' } }); + }); + it('bounds requests and removes abort listeners on every terminal path', async () => { + vi.useFakeTimers(); const f = fixture(); const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, 'removeEventListener'); + const pending = f.client.request('test', {}, { signal: controller.signal, timeoutMs: 20 }); + const assertion = expect(pending).rejects.toThrow('timed out'); + await vi.advanceTimersByTimeAsync(20); await assertion; + expect(remove).toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); + f.receive({ jsonrpc: '2.0', id: 0, result: 'late' }); + const aborted = f.client.request('test', {}, { signal: controller.signal }); controller.abort(); + await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }); + expect(vi.getTimerCount()).toBe(0); + await expect(f.client.request('test', {}, { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }); + await expect(f.client.request('test', {}, { timeoutMs: Infinity })).rejects.toThrow('bounded'); + }); + it('rejects pending requests on disconnect and supports repeated close', async () => { + const f = fixture(); const pending = f.client.request('test'); f.closed({ reason: 'lost' }); + await expect(pending).rejects.toThrow('lost'); + await expect(f.client.notify('test')).rejects.toThrow('lost'); + await Promise.all([f.client.close(), f.client.close()]); expect(f.transport.close).toHaveBeenCalledTimes(1); + }); + it('cleans synchronous serialization and async write failures', async () => { + const f = fixture(); const cyclic: Record = {}; cyclic.self = cyclic; + await expect(f.client.request('test', cyclic)).rejects.toThrow('serialization'); + vi.mocked(f.transport.writeLine).mockRejectedValueOnce(new Error('secret')); + await expect(f.client.request('test')).rejects.toThrow('write failed'); + }); + it('returns explicit RPC handler errors and suppresses replies after close', async () => { + const f = fixture({ onRequest: () => { throw new AcpRpcError(-32602, 'Invalid params'); } }); + f.receive({ jsonrpc: '2.0', id: 7, method: 'test' }); + expect(f.writes[0]).toMatchObject({ error: { code: -32602 } }); + let resolve!: (value: unknown) => void; + const g = fixture({ onRequest: () => new Promise(r => { resolve = r; }) }); + g.receive({ jsonrpc: '2.0', id: 0, method: 'test' }); await g.client.close(); resolve('late'); + await Promise.resolve(); expect(g.writes).toHaveLength(0); + }); +}); diff --git a/packages/maker-core/src/agents/acp/client.ts b/packages/maker-core/src/agents/acp/client.ts new file mode 100644 index 00000000000..a0669af9661 --- /dev/null +++ b/packages/maker-core/src/agents/acp/client.ts @@ -0,0 +1,120 @@ +import { AcpRpcError, isRecord, isRpcId, type AcpRpcId } from './protocol.js'; +import type { AcpTransport } from './transport.js'; +export { AcpRpcError } from './protocol.js'; + +export interface AcpClientOptions { + onNotification?: (method: string, params: unknown) => void | Promise; + onRequest?: (method: string, params: unknown) => unknown | Promise; + onClose?: (error: Error) => void; + requestTimeoutMs?: number; +} +export interface AcpRequestOptions { signal?: AbortSignal; timeoutMs?: number } +interface Pending { resolve(value: unknown): void; reject(error: Error): void; cleanup(): void } + +/** One client per process. No payloads, stderr, or remote error data are logged. */ +export class AcpClient { + private nextId = 0; + private pending = new Map(); + private disconnected?: Error; + private closing?: Promise; + private unsubscribe: Array<() => void> = []; + constructor(private readonly transport: AcpTransport, private readonly options: AcpClientOptions = {}) { + this.unsubscribe.push(transport.onClose(({ reason }) => this.disconnect(new Error(reason)))); + this.unsubscribe.push(transport.onLine(line => this.receive(line))); + } + getPid(): number | null { return this.transport.getPid?.() ?? null; } + request(method: string, params?: unknown, options: AcpRequestOptions = {}): Promise { + if (this.disconnected) return Promise.reject(this.disconnected); + if (options.signal?.aborted) return Promise.reject(this.abortError()); + const timeoutMs = options.timeoutMs ?? this.options.requestTimeoutMs ?? 60_000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + return Promise.reject(new Error('ACP request timeout must be a positive bounded duration')); + } + const id = this.nextId++; + return new Promise((resolve, reject) => { + const onAbort = () => this.settle(id, undefined, this.abortError()); + const timer = setTimeout(() => this.settle(id, undefined, new Error('ACP request timed out')), timeoutMs); + const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', onAbort); }; + this.pending.set(id, { resolve: value => resolve(value as T), reject, cleanup }); + options.signal?.addEventListener('abort', onAbort, { once: true }); + // Serialization may throw synchronously (e.g. cyclic caller data). + try { + void this.transport.writeLine(JSON.stringify({ jsonrpc: '2.0', id, method, params })) + .catch(() => this.settle(id, undefined, new Error('ACP request write failed'))); + } catch { this.settle(id, undefined, new Error('ACP request serialization failed')); } + }); + } + async notify(method: string, params?: unknown): Promise { + if (this.disconnected) throw this.disconnected; + await this.transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method, params })); + } + close(): Promise { + if (this.closing) return this.closing; + this.disconnect(new Error('ACP client closed')); + this.closing = this.transport.close().then(() => { + for (const unsubscribe of this.unsubscribe) unsubscribe(); + this.unsubscribe = []; + }).catch(error => { this.closing = undefined; throw error; }); + return this.closing; + } + private abortError(): Error { const error = new Error('ACP request aborted'); error.name = 'AbortError'; return error; } + private settle(id: AcpRpcId, value?: unknown, error?: Error): void { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + pending.cleanup(); + if (error) pending.reject(error); else pending.resolve(value); + } + private disconnect(error: Error): void { + if (this.disconnected) return; + this.disconnected = error; + for (const id of this.pending.keys()) this.settle(id, undefined, error); + try { this.options.onClose?.(error); } catch { /* observer isolation */ } + } + private receive(line: string): void { + if (this.disconnected) return; + let value: unknown; + try { value = JSON.parse(line); } catch { this.replyError(null, -32700, 'Parse error'); return; } + if (!isRecord(value) || value.jsonrpc !== '2.0') { this.replyError(null, -32600, 'Invalid Request'); return; } + const hasId = Object.hasOwn(value, 'id'); + if (Object.hasOwn(value, 'method')) { + if (typeof value.method !== 'string' || (hasId && !isRpcId(value.id)) || 'result' in value || 'error' in value || + ('params' in value && !isRecord(value.params) && !Array.isArray(value.params))) { + this.replyError(isRpcId(value.id) ? value.id : null, -32600, 'Invalid Request'); return; + } + if (hasId) { + void this.handleRequest(value.id as AcpRpcId, value.method, value.params); + } else { + try { void Promise.resolve(this.options.onNotification?.(value.method, value.params)).catch(() => {}); } + catch { /* observer isolation */ } + } + return; + } + if (!hasId) { this.replyError(null, -32600, 'Invalid Request'); return; } + // Ignore unsolicited/late responses and error replies with null IDs. Never answer a response. + if (!isRpcId(value.id) || !this.pending.has(value.id)) return; + const hasResult = Object.hasOwn(value, 'result'); + const hasError = Object.hasOwn(value, 'error'); + if (hasResult === hasError || (hasError && (!isRecord(value.error) || !Number.isInteger(value.error.code) || typeof value.error.message !== 'string'))) { + this.settle(value.id, undefined, new Error('Invalid ACP response')); return; + } + if (hasError && isRecord(value.error)) { + this.settle(value.id, undefined, new AcpRpcError(value.error.code as number, value.error.message as string, value.error.data)); + } else this.settle(value.id, value.result); + } + private async handleRequest(id: AcpRpcId, method: string, params: unknown): Promise { + try { + if (!this.options.onRequest) throw new AcpRpcError(-32601, 'Method not found'); + const result = await this.options.onRequest(method, params); + if (!this.disconnected) await this.transport.writeLine(JSON.stringify({ jsonrpc: '2.0', id, result: result ?? null })); + } catch (error) { + this.replyError(id, error instanceof AcpRpcError ? error.code : -32603, + error instanceof AcpRpcError ? error.message : 'Internal error'); + } + } + private replyError(id: AcpRpcId | null, code: number, message: string): void { + if (this.disconnected) return; + try { void this.transport.writeLine(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })).catch(() => {}); } + catch { /* shutdown or failed transport */ } + } +} diff --git a/packages/maker-core/src/agents/acp/protocol.ts b/packages/maker-core/src/agents/acp/protocol.ts new file mode 100644 index 00000000000..c1e8e1732e5 --- /dev/null +++ b/packages/maker-core/src/agents/acp/protocol.ts @@ -0,0 +1,15 @@ +/** ACP v1 uses JSON-RPC 2.0 over newline-delimited UTF-8 JSON. */ +export const ACP_PROTOCOL_VERSION = 1; +export type AcpRpcId = string | number; +export class AcpRpcError extends Error { + constructor(public readonly code: number, message: string, public readonly data?: unknown) { + super(message); + this.name = 'AcpRpcError'; + } +} +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +export function isRpcId(value: unknown): value is AcpRpcId { + return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); +} diff --git a/packages/maker-core/src/agents/acp/transport.test.ts b/packages/maker-core/src/agents/acp/transport.test.ts new file mode 100644 index 00000000000..c1bbc0e1dda --- /dev/null +++ b/packages/maker-core/src/agents/acp/transport.test.ts @@ -0,0 +1,85 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { spawnAcpTransport } from './transport.js'; +vi.mock('node:child_process', () => ({ spawn: vi.fn() })); +function fixture(extra: Partial[0]> = {}) { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + pid: 99999999, exitCode: null as number | null, signalCode: null as string | null, kill: vi.fn(), + }); + const spawnChild = vi.fn(() => child); + const exit = () => { child.exitCode = 0; child.emit('exit', 0, null); }; + vi.spyOn(process, 'kill').mockImplementation(() => { exit(); return true; }); + const transport = spawnAcpTransport({ binaryPath: '/fake/cursor/agent', cwd: '/fake/project', spawnChild: spawnChild as unknown as typeof spawn, platformOverride: 'linux', ...extra }); + return { child, transport, spawnChild, exit }; +} +afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); +describe('ACP stdio transport', () => { + it('uses the exact absolute binary and ACP argument with no shell or permission bypass', async () => { + const f = fixture(); + expect(f.spawnChild).toHaveBeenCalledWith('/fake/cursor/agent', ['acp'], expect.objectContaining({ cwd: '/fake/project', shell: false, stdio: ['pipe', 'pipe', 'pipe'] })); + await f.transport.close(); + expect(() => spawnAcpTransport({ binaryPath: 'agent', cwd: '/project' })).toThrow('absolute'); + }); + it('frames split UTF-8 and multiple lines without corrupting characters', async () => { + const f = fixture(); const lines: string[] = []; + const bytes = Buffer.from('{"text":"你好"}\n{"id":0}\r\n'); + f.child.stdout.write(bytes.subarray(0, 10)); + f.transport.onLine(line => lines.push(line)); + f.child.stdout.write(bytes.subarray(10, 12)); f.child.stdout.write(bytes.subarray(12)); + expect(lines).toEqual(['{"text":"你好"}', '{"id":0}']); await f.transport.close(); + }); + it('bounds incomplete and initial queued frames and drains stderr without exposing it', async () => { + const f = fixture({ maxFrameBytes: 8 }); const closed = vi.fn(); f.transport.onClose(closed); + f.child.stderr.write('secret credential'); f.child.stdout.write('123456789'); + expect(closed).toHaveBeenCalledWith({ reason: 'ACP frame exceeds limit' }); + await f.transport.close(); + const g = fixture({ maxFrameBytes: 8 }); g.child.stdout.write('12345\n67890\n'); + const late = vi.fn(); g.transport.onClose(late); + expect(late).toHaveBeenCalledWith({ reason: 'ACP initial frames exceed limit' }); await g.transport.close(); + }); + it('writes exactly one newline and rejects multiline or oversized output', async () => { + const f = fixture({ maxFrameBytes: 8 }); let data = ''; f.child.stdin.on('data', chunk => { data += chunk.toString(); }); + await f.transport.writeLine('{"id":0}'); expect(data).toBe('{"id":0}\n'); + await expect(f.transport.writeLine('bad\nline')).rejects.toThrow('frame'); await f.transport.close(); + await expect(f.transport.writeLine('{}')).rejects.toThrow('closed'); + }); + it('escalates shutdown and requires observed exit, supports retry after failure', async () => { + vi.useFakeTimers(); const f = fixture({ shutdownGraceMs: 10, killTimeoutMs: 20 }); + vi.mocked(process.kill).mockImplementation(() => true); + const close = f.transport.close(); const assertion = expect(close).rejects.toThrow('could not be confirmed'); + expect(f.transport.close()).toBe(close); + await vi.advanceTimersByTimeAsync(30); await assertion; + expect(process.kill).toHaveBeenCalledWith(-f.child.pid, 'SIGTERM'); + expect(process.kill).toHaveBeenCalledWith(-f.child.pid, 'SIGKILL'); + expect(f.transport.getPid?.()).toBe(f.child.pid); + f.exit(); await f.transport.close(); expect(f.transport.getPid?.()).toBeNull(); + }); + it('requires successful Windows tree termination, never upgrades failed cleanup to success', async () => { + vi.useFakeTimers(); + const killer = Object.assign(new EventEmitter(), { kill: vi.fn() }); + vi.mocked(spawn).mockReturnValue(killer as unknown as ChildProcess); + const f = fixture({ platformOverride: 'win32', killTimeoutMs: 20 }); + f.child.kill.mockImplementation(() => { f.exit(); }); + const closed = f.transport.close(); + const rejected = expect(closed).rejects.toThrow('tree exit could not be confirmed'); + killer.emit('exit', 1); await rejected; + await expect(f.transport.close()).rejects.toThrow('tree exit could not be confirmed'); + expect(spawn).toHaveBeenCalledWith(expect.stringMatching(/taskkill\.exe$/), ['/PID', String(f.child.pid), '/T', '/F'], expect.objectContaining({ shell: false })); + }); + it('accepts Windows shutdown only after successful tree kill and child exit', async () => { + const killer = Object.assign(new EventEmitter(), { kill: vi.fn() }); + vi.mocked(spawn).mockReturnValue(killer as unknown as ChildProcess); + const f = fixture({ platformOverride: 'win32' }); + const closed = f.transport.close(); f.exit(); killer.emit('exit', 0); await closed; + expect(f.transport.getPid?.()).toBeNull(); + }); + it('proves failed spawn has no child and propagates stdout EOF', async () => { + const f = fixture(); Object.assign(f.child, { pid: undefined }); f.child.emit('error', new Error('private path')); + await f.transport.close(); expect(f.transport.getPid?.()).toBeNull(); + const g = fixture(); const close = vi.fn(); g.transport.onClose(close); g.child.stdout.emit('end'); + expect(close).toHaveBeenCalledWith({ reason: 'ACP output ended' }); await g.transport.close(); + }); +}); diff --git a/packages/maker-core/src/agents/acp/transport.ts b/packages/maker-core/src/agents/acp/transport.ts new file mode 100644 index 00000000000..685a17d16be --- /dev/null +++ b/packages/maker-core/src/agents/acp/transport.ts @@ -0,0 +1,168 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { isAbsolute } from 'node:path'; + +// Transport interface and process-group shutdown pattern adapted from Cindy PR #2386. +// Framing is byte-bounded; unlike that prototype, close never reports success without exit proof. +export interface AcpTransport { + writeLine(line: string): Promise; + onLine(handler: (line: string) => void): () => void; + onClose(handler: (info: { reason: string }) => void): () => void; + close(): Promise; + getPid?(): number | null; +} +export interface AcpTransportOptions { + binaryPath: string; + cwd: string; + env?: NodeJS.ProcessEnv; + maxFrameBytes?: number; + shutdownGraceMs?: number; + killTimeoutMs?: number; + /** Injection is only for process/lifecycle tests; production always supplies AgentDeps.binaryPath. */ + spawnChild?: typeof spawn; + platformOverride?: NodeJS.Platform; +} +export function spawnAcpTransport(options: AcpTransportOptions): AcpTransport { + if (!isAbsolute(options.binaryPath)) throw new Error('ACP binaryPath must be absolute'); + if (!isAbsolute(options.cwd)) throw new Error('ACP cwd must be absolute'); + const maxBytes = options.maxFrameBytes ?? 16 * 1024 * 1024; + const grace = options.shutdownGraceMs ?? 1_000; + const killWait = options.killTimeoutMs ?? 3_000; + if (![maxBytes, grace, killWait].every(value => Number.isSafeInteger(value) && value > 0 && value <= 2_147_483_647)) { + throw new Error('Invalid ACP transport limits'); + } + const posix = (options.platformOverride ?? process.platform) !== 'win32'; + const child = (options.spawnChild ?? spawn)(options.binaryPath, ['acp'], { + cwd: options.cwd, env: options.env, stdio: ['pipe', 'pipe', 'pipe'], shell: false, detached: posix, + }) as ChildProcessWithoutNullStreams; + const lines = new Set<(line: string) => void>(); + const closes = new Set<(info: { reason: string }) => void>(); + let buffered = Buffer.alloc(0); + let queued: string[] = []; + let queuedBytes = 0; + let armed = false; + let reason: string | undefined; + let exited = child.exitCode !== null || child.signalCode !== null; + let closing: Promise | undefined; + let windowsTreeUnconfirmed = false; + const disconnect = (why: string) => { + if (reason) return; + reason = why; + buffered = Buffer.alloc(0); queued = []; queuedBytes = 0; + for (const listener of closes) { try { listener({ reason: why }); } catch { /* observer */ } } + }; + const fail = (why: string) => { + disconnect(why); + // Retain the rejected cleanup for callers to retry; do not make an unhandled rejection. + void transport.close().catch(() => {}); + }; + child.on('error', () => { + // A failed spawn has no process. Other child errors are not exit evidence. + if (child.pid === undefined) exited = true; + fail('ACP process error'); + }); + child.on('exit', () => { exited = true; disconnect('ACP process exited'); }); + child.stdin.on('error', () => fail('ACP input disconnected')); + child.stdout.on('error', () => fail('ACP output disconnected')); + child.stdout.on('end', () => fail('ACP output ended')); + // Drain without logging, retaining, or exposing secrets that CLI diagnostics might contain. + child.stderr.resume(); + child.stderr.on('error', () => {}); + child.stdout.on('data', (chunk: Buffer) => { + if (reason) return; + let offset = 0; + while (offset < chunk.length && !reason) { + const newline = chunk.indexOf(10, offset); + const end = newline < 0 ? chunk.length : newline; + const piece = chunk.subarray(offset, end); + if (buffered.length + piece.length > maxBytes) { fail('ACP frame exceeds limit'); return; } + buffered = buffered.length ? Buffer.concat([buffered, piece]) : Buffer.from(piece); + if (newline < 0) return; + const line = buffered.toString('utf8').replace(/\r$/, ''); + buffered = Buffer.alloc(0); + offset = newline + 1; + if (!line) continue; + if (!armed) { + queuedBytes += Buffer.byteLength(line); + if (queuedBytes > maxBytes) { fail('ACP initial frames exceed limit'); return; } + queued.push(line); + } else for (const listener of lines) { try { listener(line); } catch { /* observer */ } } + } + }); + function waitForExit(ms: number): Promise { + if (exited) return Promise.resolve(true); + return new Promise(resolve => { + const done = (value: boolean) => { clearTimeout(timer); child.removeListener('exit', onExit); child.removeListener('error', onError); resolve(value); }; + const onExit = () => done(true); + const onError = () => { if (exited) done(true); }; + const timer = setTimeout(() => done(false), ms); + child.once('exit', onExit); child.on('error', onError); + }); + } + function signalTree(signal: NodeJS.Signals): void { + if (child.pid === undefined) return; + if (posix) { + try { process.kill(-child.pid, signal); return; } catch { /* direct child fallback */ } + } + if (!exited) { try { child.kill(signal); } catch { /* verified by waitForExit */ } } + } + async function killWindowsTree(): Promise { + if (child.pid === undefined || exited) return true; + return new Promise(resolve => { + // taskkill is a system utility, never a PATH-selected executable or shell command. + const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'; + const killer = spawn(`${systemRoot}\\System32\\taskkill.exe`, ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', shell: false }); + let finished = false; + const done = (success: boolean) => { if (finished) return; finished = true; clearTimeout(timer); resolve(success); }; + const timer = setTimeout(() => { try { killer.kill(); } catch { /* best effort */ } done(false); }, killWait); + killer.once('error', () => done(false)); killer.once('exit', code => done(code === 0)); + }); + } + const transport: AcpTransport = { + writeLine(line) { + if (reason || !child.stdin.writable) return Promise.reject(new Error('ACP transport closed')); + if (line.includes('\n') || Buffer.byteLength(line) > maxBytes) return Promise.reject(new Error('Invalid ACP output frame')); + return new Promise((resolve, reject) => { + child.stdin.write(`${line}\n`, 'utf8', error => error ? reject(new Error('ACP write failed')) : resolve()); + }); + }, + onLine(handler) { + lines.add(handler); + if (!armed) { + armed = true; + const initial = queued; queued = []; queuedBytes = 0; + for (const line of initial) { + if (reason) break; + try { handler(line); } catch { /* observer isolation */ } + } + } + return () => { lines.delete(handler); }; + }, + onClose(handler) { + closes.add(handler); + if (reason) handler({ reason }); + return () => { closes.delete(handler); }; + }, + getPid: () => exited ? null : child.pid ?? null, + close() { + if (closing) return closing; + disconnect('ACP transport closed'); + closing = (async () => { + try { child.stdin.end(); } catch { /* best effort */ } + if (posix) { + signalTree('SIGTERM'); + await waitForExit(grace); + // Signal the group even if its leader exited; descendants may still own inherited pipes. + signalTree('SIGKILL'); + if (!await waitForExit(killWait)) throw new Error('ACP process exit could not be confirmed'); + } else { + const treeStopped = !windowsTreeUnconfirmed && await killWindowsTree(); + if (!treeStopped) windowsTreeUnconfirmed = true; + if (!treeStopped) signalTree('SIGKILL'); + if (!await waitForExit(killWait) || !treeStopped) throw new Error('ACP process tree exit could not be confirmed'); + } + })().catch(error => { closing = undefined; throw error; }); + return closing; + }, + }; + return transport; +} diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index b5b4f8591e0..4de93315627 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -436,7 +436,7 @@ export interface CodexAppServerProcessRegistration { export interface LocalAgentProcessRegistration { pid: number; - kind: 'claude' | 'pi'; + kind: 'claude' | 'pi' | 'cursor'; role: 'task-host' | 'control-plane-service'; } diff --git a/packages/maker-core/src/agents/cursor/index.test.ts b/packages/maker-core/src/agents/cursor/index.test.ts new file mode 100644 index 00000000000..a60629d8f2f --- /dev/null +++ b/packages/maker-core/src/agents/cursor/index.test.ts @@ -0,0 +1,734 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CursorAgent, CURSOR_DEFAULT_MODEL, type CursorAgentDeps } from './index.js'; +import { AgentStartupCleanupPendingError, PINNED_SKILL_INVOCATION, AUTO_REVIEW_USER_INTENT } from '../base-agent.js'; +import { LIBRARY_READ_ROOT } from '../shared/library-native-read.js'; +import type { AcpTransport } from '../acp/transport.js'; +import type { InteractionDecision, AgentEvent, InteractionRequest } from '../../types/events.js'; +import { readCursorModels } from './models.js'; +import { cursorAnswers } from './questions.js'; +import { createConsoleLogger } from '../../interfaces/logger.js'; +import { CursorTranslator } from './translator.js'; +import { access, rm } from 'node:fs/promises'; + +interface FakeMessage { + id?: string | number; + method?: string; + params: Record & { prompt: Array<{ type: string; text?: string }> }; + result: unknown; + error: { code: number }; +} + +class FakeTransport implements AcpTransport { + lines = new Set<(line: string) => void>(); + closes = new Set<(info: { reason: string }) => void>(); + written: FakeMessage[] = []; + failClose = false; + closeCount = 0; + session = { sessionId: 'native-1', modes: { availableModes: [{ id: 'agent' }, { id: 'plan' }] }, + configOptions: [{ id: 'native-model-picker', category: 'model', currentValue: 'auto-native', + options: [{ value: 'auto-native', name: 'Auto' }, { value: 'model-b', name: 'Model B' }] }] }; + held = new Set(['session/prompt']); + loadReplay = false; + onWrite?: (message: FakeMessage) => void; + async writeLine(line: string) { + const message: FakeMessage = JSON.parse(line); + this.written.push(message); + this.onWrite?.(message); + if (!message.method || message.id === undefined || this.held.has(message.method)) return; + let result: unknown = {}; + if (message.method === 'initialize') result = { protocolVersion: 1, + agentCapabilities: { loadSession: true, mcpCapabilities: { http: true } }, authMethods: [{ id: 'cursor_login' }] }; + if (message.method === 'session/new' || message.method === 'session/load') { + if (message.method === 'session/load' && this.loadReplay) this.update('agent_message_chunk', { content: { type: 'text', text: 'old' } }); + result = this.session; + } + queueMicrotask(() => this.emit({ jsonrpc: '2.0', id: message.id, result })); + } + emit(message: unknown) { for (const listener of this.lines) listener(JSON.stringify(message)); } + update(sessionUpdate: string, data = {}) { this.emit({ jsonrpc: '2.0', method: 'session/update', + params: { sessionId: 'native-1', update: { sessionUpdate, ...data } } }); } + finish() { const request = this.written.filter(item => item.method === 'session/prompt').at(-1)!; + this.emit({ jsonrpc: '2.0', id: request.id, result: { stopReason: 'end_turn' } }); } + onLine(handler: (line: string) => void) { this.lines.add(handler); return () => { this.lines.delete(handler); }; } + onClose(handler: (info: { reason: string }) => void) { this.closes.add(handler); return () => { this.closes.delete(handler); }; } + async close() { this.closeCount++; if (this.failClose) throw new Error('exit unconfirmed'); + for (const listener of this.closes) listener({ reason: 'closed' }); } +} +function create(fake = new FakeTransport(), extra: Partial = {}) { + const agent = new CursorAgent({ binaryPath: '/fake/cursor-agent', runtimeConfig: {}, + auth: { getState: vi.fn(async () => ({ authenticated: true })), getAuthEnv: vi.fn(async () => ({})), + triggerLogin: vi.fn(async () => ({ authenticated: true })), logout: vi.fn(async () => {}) }, + logger: createConsoleLogger('cursor-test'), + createCursorTransport: () => fake, ...extra, + }); + return { fake, agent, start: (options = {}) => agent.startSession({ workingDir: '/tmp', model: CURSOR_DEFAULT_MODEL, ...options }) }; +} +async function tick() { await new Promise(resolve => setTimeout(resolve, 0)); } + +function permission(fake: FakeTransport, id: string, tool = { kind: 'execute', rawInput: { command: 'curl -X POST https://example.invalid -d fixture' } }) { + fake.emit({ jsonrpc: '2.0', id, method: 'session/request_permission', params: { + sessionId: 'native-1', toolCall: { toolCallId: `tool-${id}`, ...tool }, options: [ + { kind: 'allow_always', optionId: 'machine-wide' }, + { kind: 'allow_once', optionId: 'opaque-allow' }, { kind: 'reject_once', optionId: 'opaque-deny' }, + ], + } }); +} + +describe('Cursor approval modes', () => { + it.each([undefined, 'native-1'])('supports full access on new and resumed tasks (%s) without persistent native grants', async resumeSessionId => { + const { fake, start } = create(); + const handle = await start({ permissionMode: 'bypassPermissions', resumeSessionId }); + await handle.send({ type: 'user', content: 'run the fixture command' }); + permission(fake, 'full'); await tick(); + expect(fake.written.find(item => item.id === 'full')?.result).toEqual({ outcome: { outcome: 'selected', optionId: 'opaque-allow' } }); + expect(fake.written.some(item => item.method === 'session/set_config_option')).toBe(false); + await handle.close(); + }); + + it('automatically allows a concrete safe command without calling the reviewer or opening a card', async () => { + const review = vi.fn(); const surface = vi.fn(); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto' }); + handle.setInteractionResolver(surface); + await handle.send({ type: 'user', content: 'check the directory' }); + permission(fake, 'safe', { kind: 'execute', rawInput: { command: 'pwd' } }); await tick(); + expect(fake.written.find(item => item.id === 'safe')?.result).toEqual({ outcome: { outcome: 'selected', optionId: 'opaque-allow' } }); + expect(review).not.toHaveBeenCalled(); expect(surface).not.toHaveBeenCalled(); + await handle.close(); + }); + + it.each(['allow', 'block'] as const)('uses the shared reviewer and current user authorization for %s', async verdict => { + const review = vi.fn>(async () => ({ verdict, reason: 'fixture verdict' })); + const surface = vi.fn(); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto', sessionId: 'business-1', providerId: 'cursor' }); + handle.setInteractionResolver(surface); + await handle.send({ type: 'user', content: 'Read the fixture endpoint only; do not publish anything.' }); + permission(fake, 'review'); await tick(); + expect(review).toHaveBeenCalledWith(expect.objectContaining({ agentKind: 'cursor', model: 'auto-native', + providerId: 'cursor', sessionId: 'business-1', workspaceRoots: ['/tmp'], writableRoots: ['/tmp'], + action: { kind: 'exec', command: 'curl -X POST https://example.invalid -d fixture', cwd: '/tmp' } })); + expect(JSON.stringify(review.mock.calls[0][0].userIntent)).toContain('do not publish anything'); + expect(fake.written.find(item => item.id === 'review')?.result).toEqual({ outcome: { outcome: 'selected', optionId: verdict === 'allow' ? 'opaque-allow' : 'opaque-deny' } }); + expect(surface).not.toHaveBeenCalled(); + await handle.close(); + }); + + it('falls back to a marked confirmation card and one notice when automatic review is unavailable', async () => { + const { fake, start } = create(); + const handle = await start({ permissionMode: 'auto' }); const events: AgentEvent[] = []; + const pump = (async () => { for await (const event of handle.events()) events.push(event); })(); + const surface = vi.fn<(request: InteractionRequest) => Promise>(async () => ({ kind: 'permission', behavior: 'allow' })); + handle.setInteractionResolver(surface); + await handle.send({ type: 'user', content: 'Read the fixture endpoint' }); + permission(fake, 'first'); await tick(); permission(fake, 'second'); await tick(); + expect(surface).toHaveBeenCalledTimes(2); + expect(surface.mock.calls[0][0]).toMatchObject({ kind: 'permission', metadata: { autoReviewUnavailable: true } }); + await handle.close(); await pump; + expect(events.filter(event => event.type === 'error' && String((event.data as { message?: string }).message).startsWith('[AUTO_REVIEW_UNAVAILABLE]'))).toHaveLength(1); + }); + + it('releases an existing tool confirmation when switched to full access, once', async () => { + const { fake, start } = create(); const handle = await start(); + let answer!: (decision: InteractionDecision) => void; + handle.setInteractionResolver(() => new Promise(resolve => { answer = resolve; })); + await handle.send({ type: 'user', content: 'run the fixture command' }); + permission(fake, 'pending'); await tick(); + await handle.setPermissionMode!('bypassPermissions'); await tick(); + answer({ kind: 'permission', behavior: 'deny' }); await tick(); + expect(fake.written.filter(item => item.id === 'pending')).toHaveLength(1); + expect(fake.written.find(item => item.id === 'pending')?.result).toEqual({ outcome: { outcome: 'selected', optionId: 'opaque-allow' } }); + await handle.close(); + }); + + it('uses the host-restored authorization on a resumed task instead of authorizing from continuation text', async () => { + const review = vi.fn>(async () => ({ verdict: 'block' })); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto', resumeSessionId: 'native-1' }); + const intent = 'Read the fixture only. Do not upload or publish anything.'; + await handle.send({ type: 'user', content: 'Continue' }, { [AUTO_REVIEW_USER_INTENT]: intent }); + permission(fake, 'restored'); await tick(); + expect(review.mock.calls[0][0].userIntent).toBe(intent); + expect(fake.written.find(item => item.id === 'restored')?.result).toEqual({ outcome: { outcome: 'selected', optionId: 'opaque-deny' } }); + await handle.close(); + }); + + it('shows an unavailable-review notice again on a new turn after the earlier banner was cleared', async () => { + const { fake, start } = create(); const handle = await start({ permissionMode: 'auto' }); + const events: AgentEvent[] = []; + const pump = (async () => { for await (const event of handle.events()) events.push(event); })(); + handle.setInteractionResolver(async () => ({ kind: 'permission', behavior: 'deny' })); + for (const id of ['first-turn', 'second-turn']) { + await handle.send({ type: 'user', content: 'Read the fixture endpoint' }); + permission(fake, id); await tick(); fake.finish(); await tick(); + } + await handle.close(); await pump; + expect(events.filter(event => event.type === 'error' && String((event.data as { message?: string }).message).startsWith('[AUTO_REVIEW_UNAVAILABLE]'))).toHaveLength(2); + }); + + it('ignores an old automatic allow after switching back to default permissions', async () => { + let reviewed!: (decision: { verdict: 'allow' }) => void; + const review = vi.fn>(() => new Promise(resolve => { reviewed = resolve; })); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto' }); + let answer!: (decision: InteractionDecision) => void; + handle.setInteractionResolver(() => new Promise(resolve => { answer = resolve; })); + await handle.send({ type: 'user', content: 'Read the fixture endpoint' }); + permission(fake, 'stale'); await tick(); + await handle.setPermissionMode!('ask'); await tick(); + reviewed({ verdict: 'allow' }); await tick(); + expect(fake.written.some(item => item.id === 'stale')).toBe(false); + answer({ kind: 'permission', behavior: 'deny' }); await tick(); + expect(fake.written.find(item => item.id === 'stale')?.result).toEqual({ outcome: { outcome: 'selected', optionId: 'opaque-deny' } }); + await handle.close(); + }); + + it('cancels a pending automatic review immediately on Stop and ignores the late verdict', async () => { + let reviewed!: (decision: { verdict: 'allow' }) => void; + const review = vi.fn>(() => new Promise(resolve => { reviewed = resolve; })); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto' }); + await handle.send({ type: 'user', content: 'Read the fixture endpoint' }); + permission(fake, 'stop'); await tick(); + const abort = handle.abort(); await tick(); + expect(fake.written.find(item => item.id === 'stop')?.result).toEqual({ outcome: { outcome: 'cancelled' } }); + reviewed({ verdict: 'allow' }); fake.finish(); await abort; await tick(); + expect(fake.written.filter(item => item.id === 'stop')).toHaveLength(1); + await handle.close(); + }); + + it('reviews host MCP actions against the same accepted user intent and rejects after close', async () => { + const review = vi.fn>(async () => ({ verdict: 'allow' })); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto' }); + await handle.send({ type: 'user', content: 'Only inspect the fixture issue' }); + const action = { kind: 'other' as const, description: 'Look up the fixture issue' }; + expect(await handle.reviewAutoPermissionAction!(action)).toMatchObject({ verdict: 'allow' }); + expect(JSON.stringify(review.mock.calls[0][0].userIntent)).toContain('Only inspect the fixture issue'); + fake.finish(); await tick(); await handle.close(); + expect(await handle.reviewAutoPermissionAction!(action)).toMatchObject({ verdict: 'block' }); + }); + + it('retains a blocked action for the next human follow-up without treating it as consent', async () => { + const review = vi.fn>(async () => ({ verdict: 'block' })); + const { fake, start } = create(undefined, { reviewAutoPermissionAction: review }); + const handle = await start({ permissionMode: 'auto' }); + await handle.send({ type: 'user', content: 'Inspect the fixture' }); permission(fake, 'blocked'); await tick(); + fake.finish(); await tick(); + await handle.send({ type: 'user', content: 'Why was that denied?' }); permission(fake, 'follow-up'); await tick(); + expect(review.mock.calls[1][0].precedingBlockedActions).toEqual([{ kind: 'exec', command: 'curl -X POST https://example.invalid -d fixture', cwd: '/tmp' }]); + expect(JSON.stringify(review.mock.calls[1][0].userIntent)).toContain('Why was that denied'); + await handle.close(); + }); +}); + +function parameterizedFixture() { + const fake = new FakeTransport(); + const select = (id: string, category: string, currentValue: string, values: string[]) => ({ + id, category, type: 'select', currentValue, options: values.map(value => ({ value, name: value })), + }); + const models = [ + { value: 'auto-native', name: 'Auto', configOptions: [] }, + { value: 'model-b', name: 'Grok', configOptions: [ + select('native-reasoning-id', 'thought_level', 'high', ['low', 'medium', 'high', 'xhigh']), + select('fast', 'model_config', 'true', ['false', 'true']), + ] }, + { value: 'kimi', name: 'Kimi', configOptions: [select('reasoning', 'thought_level', 'max', ['low', 'high', 'max'])] }, + ]; + let currentModel = 'auto-native'; + let configs: Record[] = []; + const refresh = () => { configs = [select('native-model-picker', 'model', currentModel, models.map(model => model.value)), + ...structuredClone(models.find(model => model.value === currentModel)!.configOptions)]; }; + refresh(); + fake.onWrite = message => { + let result: unknown; + if (message.method === 'cursor/list_available_models') result = { models }; + if (message.method === 'session/new' || message.method === 'session/load') result = { ...fake.session, configOptions: configs }; + if (message.method === 'session/set_config_option') { + if (message.params.configId === 'native-model-picker') { currentModel = String(message.params.value); refresh(); } + else configs = configs.map(option => option.id === message.params.configId ? { ...option, currentValue: message.params.value } : option); + result = { configOptions: configs }; + } + if (result !== undefined) fake.emit({ jsonrpc: '2.0', id: message.id, result }); + }; + return { ...create(fake), models, getConfigs: () => structuredClone(configs) }; +} + +describe('Cursor native model parameters', () => { + it('negotiates the native parameterized picker and advertises each model’s actual controls', async () => { + const { fake, agent, start } = parameterizedFixture(); + const handle = await start(); + expect(handle.model).toBe('auto-native'); + expect(agent.capabilities.availableModels.some(model => model.id === CURSOR_DEFAULT_MODEL)).toBe(false); + expect(fake.written.find(message => message.method === 'initialize')!.params.clientCapabilities) + .toMatchObject({ _meta: { parameterizedModelPicker: true }, session: { configOptions: { boolean: {} } } }); + expect(agent.capabilities.hasFastMode).toBe(true); + expect(agent.capabilities.effort.supported).toBe(true); + expect(agent.capabilities.availableModels.find(model => model.id === 'model-b')) + .toMatchObject({ efforts: ['low', 'medium', 'high', 'xhigh'], defaultEffort: 'high', supportsFastMode: true }); + expect(agent.capabilities.availableModels.find(model => model.id === 'kimi')) + .toMatchObject({ efforts: ['low', 'high', 'max'], defaultEffort: 'max', supportsFastMode: false }); + expect(agent.capabilities.availableModels.find(model => model.id === 'auto-native')) + .toMatchObject({ efforts: [], defaultEffort: null, supportsFastMode: false }); + expect(fake.written.some(message => message.method === 'session/prompt')).toBe(false); + await handle.close(); + }); + it('resolves old default selections to the native current model without advertising a synthetic choice', async () => { + const { agent, start } = parameterizedFixture(); + const handle = await start({ resumeSessionId: 'native-1', model: CURSOR_DEFAULT_MODEL }); + expect(handle.model).toBe('auto-native'); + await handle.setModel!('model-b'); + expect(handle.model).toBe('model-b'); + await handle.setModel!(CURSOR_DEFAULT_MODEL); + expect(handle.model).toBe('auto-native'); + expect(agent.capabilities.availableModels.map(model => model.id)).not.toContain(CURSOR_DEFAULT_MODEL); + await handle.close(); + }); + it('applies startup effort and explicit Fast off before the first prompt, using native IDs', async () => { + const { fake, start, getConfigs } = parameterizedFixture(); + const handle = await start({ model: 'model-b', effort: 'low', fastMode: false }); + expect(fake.written.filter(message => message.method === 'session/set_config_option').map(message => message.params)) + .toEqual([ + { sessionId: 'native-1', configId: 'native-model-picker', value: 'model-b' }, + { sessionId: 'native-1', configId: 'native-reasoning-id', value: 'low' }, + { sessionId: 'native-1', configId: 'fast', value: 'false' }, + ]); + expect(getConfigs().find(option => option.id === 'fast')?.currentValue).toBe('false'); + expect(handle.getFastMode!()).toBe(false); + await handle.send({ type: 'user', content: 'use my settings' }); + expect(fake.written.at(-1)?.method).toBe('session/prompt'); + fake.finish(); await tick(); await handle.close(); + }); + it('switches effort on the same model and reads back the acknowledged Fast value', async () => { + const { start, getConfigs } = parameterizedFixture(); + const handle = await start({ model: 'model-b', fastMode: false }); + await handle.setModel!('model-b', { effort: 'xhigh' }); + await handle.setFastMode!(true); + expect(getConfigs().find(option => option.id === 'native-reasoning-id')?.currentValue).toBe('xhigh'); + expect(handle.getFastMode!()).toBe(true); + await handle.close(); + }); + it('uses the new model’s native effort ID and removes unsupported Fast', async () => { + const { fake, start, getConfigs } = parameterizedFixture(); + const handle = await start({ model: 'model-b' }); + await handle.setModel!('kimi', { effort: 'high' }); + expect(getConfigs().find(option => option.id === 'reasoning')?.currentValue).toBe('high'); + expect(fake.written.filter(message => message.method === 'session/set_config_option').at(-1)?.params.configId).toBe('reasoning'); + await expect(handle.setFastMode!(true)).rejects.toThrow('fastMode'); + await expect(handle.setFastMode!(false)).resolves.toBeUndefined(); + expect(handle.getFastMode!()).toBe(false); + await handle.close(); + }); + it('rejects an unsupported target effort before changing the model or dispatching a prompt', async () => { + const { fake, start } = parameterizedFixture(); + const handle = await start(); + await expect(handle.setModel!('model-b', { effort: 'max' })).rejects.toThrow('effort'); + expect(fake.written.some(message => message.method === 'session/set_config_option')).toBe(false); + await handle.close(); + await expect(start({ model: 'model-b', effort: 'ultra' })).rejects.toThrow('effort'); + expect(fake.written.some(message => message.method === 'session/prompt')).toBe(false); + }); + it('reapplies persisted effort and Fast when loading the same native task', async () => { + const { fake, start, getConfigs } = parameterizedFixture(); + const handle = await start({ resumeSessionId: 'native-1', model: 'model-b', effort: 'medium', fastMode: false }); + expect(handle.id).toBe('native-1'); + expect(fake.written.some(message => message.method === 'session/new')).toBe(false); + expect(getConfigs().find(option => option.id === 'native-reasoning-id')?.currentValue).toBe('medium'); + expect(handle.getFastMode!()).toBe(false); + await handle.close(); + }); + it('consumes complete config updates and refuses settings removed by the native agent', async () => { + const { fake, start, getConfigs } = parameterizedFixture(); + const handle = await start({ model: 'model-b' }); + fake.update('config_option_update', { configOptions: getConfigs().filter(option => option.id !== 'fast') }); + await expect(handle.setFastMode!(true)).rejects.toThrow('fastMode'); + await handle.setEffort!('low'); + await handle.close(); + }); + it('does not mutate configuration while a turn is running', async () => { + const { fake, start } = parameterizedFixture(); + const handle = await start({ model: 'model-b' }); + await handle.send({ type: 'user', content: 'wait' }); + const mutations = fake.written.filter(message => message.method === 'session/set_config_option').length; + await expect(handle.setEffort!('low')).rejects.toThrow('active turn'); + await expect(handle.setFastMode!(false)).rejects.toThrow('active turn'); + expect(fake.written.filter(message => message.method === 'session/set_config_option')).toHaveLength(mutations); + fake.finish(); await tick(); await handle.close(); + }); + it('keeps older CLIs usable when the optional native extension is absent', async () => { + const { fake, agent, start } = create(); + fake.onWrite = message => { + if (message.method === 'cursor/list_available_models') fake.emit({ jsonrpc: '2.0', id: message.id, + error: { code: -32601, message: 'Method not found' } }); + }; + const handle = await start({ model: 'model-b', fastMode: false }); + expect(agent.capabilities.hasFastMode).toBe(false); + expect(agent.capabilities.effort.supported).toBe(false); + await expect(handle.setEffort!('high')).rejects.toThrow('effort'); + await expect(handle.setFastMode!(true)).rejects.toThrow('fastMode'); + await handle.close(); + }); + it('uses the negotiated boolean type when Fast is a native boolean option', async () => { + const { fake, start, getConfigs } = parameterizedFixture(); + const handle = await start({ model: 'model-b' }); + fake.update('config_option_update', { configOptions: getConfigs().map(option => option.id === 'fast' + ? { id: 'fast', category: 'model_config', type: 'boolean', currentValue: true } : option) }); + await handle.setFastMode!(false); + expect(fake.written.filter(message => message.method === 'session/set_config_option').at(-1)?.params) + .toEqual({ sessionId: 'native-1', configId: 'fast', type: 'boolean', value: false }); + expect(handle.getFastMode!()).toBe(false); + await handle.close(); + }); + it('retains the actual setting when the native agent rejects a change', async () => { + const { fake, start } = parameterizedFixture(); + const handle = await start({ model: 'model-b' }); + const respond = fake.onWrite!; + fake.onWrite = message => { + if (message.method === 'session/set_config_option' && message.params.configId === 'fast') { + fake.emit({ jsonrpc: '2.0', id: message.id, error: { code: -32602, message: 'Fast unavailable' } }); + } else respond(message); + }; + await expect(handle.setFastMode!(false)).rejects.toThrow('Fast unavailable'); + expect(handle.getFastMode!()).toBe(true); + await handle.setEffort!('low'); + await handle.close(); + }); +}); + +describe('Cursor native ACP lifecycle', () => { + it('describes native approval coverage without promising a prompt for every edit', () => { + const { agent, fake } = create(); + expect(agent.capabilities.permissionModes.map(mode => mode.id)).toEqual(['ask', 'default', 'auto', 'bypassPermissions']); + for (const mode of agent.capabilities.permissionModes) { + expect(mode.description).toContain('native configured permission policy'); + expect(mode.description).toContain('only the approval requests Cursor sends'); + expect(mode.description).toContain('workspace edits may run without a prompt'); + } + expect(agent.capabilities.turnPermissionPolicy?.supported.supported).toBe(false); + expect(fake.written).toEqual([]); + }); + it('starts once, persists native identity and streams multiple turns on one process', async () => { + const { fake, agent, start } = create(); + expect(agent.capabilities.availableModels).toEqual([]); + const handle = await start(); + const events: AgentEvent[] = []; + const consume = (async () => { for await (const event of handle.events()) events.push(event); })(); + expect(handle.id).toBe('native-1'); + await handle.send({ type: 'user', content: 'one' }); + fake.update('agent_message_chunk', { content: { type: 'text', text: 'hello' } }); + fake.finish(); await tick(); + await handle.send({ type: 'user', content: 'two' }); fake.finish(); await tick(); + await handle.close(); await consume; + expect(fake.written.filter(item => item.method === 'session/new')).toHaveLength(1); + expect(fake.written.filter(item => item.method === 'session/prompt')).toHaveLength(2); + expect(events.filter(item => item.type === 'done')).toHaveLength(2); + expect(events.find(item => item.type === 'session_id')?.data).toBe('native-1'); + expect(events.find(item => item.type === 'text')?.data).toEqual({ text: 'hello', isFinal: false }); + }); + it('suppresses native load replay and preserves saved identity', async () => { + const { fake, start } = create(); fake.loadReplay = true; + const handle = await start({ resumeSessionId: 'native-1' }); + const iterator = handle.events()[Symbol.asyncIterator](); + expect((await iterator.next()).value.type).toBe('session_id'); + await handle.close(); + expect((await iterator.next()).done).toBe(true); + expect(fake.written.some(item => item.method === 'session/new')).toBe(false); + }); + it('preserves a loaded native plan mode and switches it off before sending', async () => { + const { fake, start } = create(); + Object.assign(fake.session.modes, { currentModeId: 'plan' }); + const handle = await start({ resumeSessionId: 'native-1' }); + expect(handle.getPlanMode!()).toBe(true); + await handle.send({ type: 'user', content: 'execute' }, { planMode: false }); + expect(fake.written.find(item => item.method === 'session/set_mode')?.params) + .toEqual({ sessionId: 'native-1', modeId: 'agent' }); + expect(handle.getPlanMode!()).toBe(false); + await handle.close(); + }); + it('applies an explicit startup mode after loading the native mode', async () => { + const { fake, start } = create(); + Object.assign(fake.session.modes, { currentModeId: 'plan' }); + const handle = await start({ resumeSessionId: 'native-1', planMode: false }); + expect(fake.written.find(item => item.method === 'session/set_mode')?.params.modeId).toBe('agent'); + expect(handle.getPlanMode!()).toBe(false); + await handle.close(); + }); + it('keeps mode notifications during load while suppressing transcript replay', async () => { + const { fake, start } = create(); + fake.loadReplay = true; + fake.onWrite = message => { + if (message.method === 'session/load') fake.update('current_mode_update', { currentModeId: 'plan' }); + }; + const handle = await start({ resumeSessionId: 'native-1' }); + expect(handle.getPlanMode!()).toBe(true); + await handle.close(); + }); + it('retains failed startup cleanup across retries and resolves exit proof only after close succeeds', async () => { + const dispose = vi.fn(); + const { fake, agent, start } = create(undefined, { preparePiExtraSpawnConfig: async () => ({ disposeSessionCtx: dispose }) }); + fake.failClose = true; + fake.onWrite = message => { + if (message.method === 'initialize') fake.emit({ jsonrpc: '2.0', id: message.id, error: { code: -32099, message: 'startup failed' } }); + }; + const failure = await start({ sessionId: 'failed-task' }).catch(error => error); + expect(failure).toBeInstanceOf(AgentStartupCleanupPendingError); + let stopped = false; + void failure.whenStopped.then(() => { stopped = true; }); + await expect(start({ sessionId: 'failed-task' })).rejects.toBeInstanceOf(AgentStartupCleanupPendingError); + await expect(agent.dispose()).rejects.toThrow(); + expect(fake.written.filter(item => item.method === 'initialize')).toHaveLength(1); + expect(stopped).toBe(false); + expect(dispose).not.toHaveBeenCalled(); + fake.failClose = false; + await agent.dispose(); + await failure.whenStopped; + expect(stopped).toBe(true); + expect(dispose).toHaveBeenCalledTimes(1); + }); + it.each(['startup', 'probe-close'])('retains its model probe directory until %s cleanup is confirmed', async failurePoint => { + const fake = new FakeTransport(); + let directory = ''; + const { agent } = create(fake, { createCursorTransport: options => { directory = options.cwd; return fake; } }); + fake.failClose = true; + if (failurePoint === 'startup') fake.onWrite = message => { + if (message.method === 'initialize') fake.emit({ jsonrpc: '2.0', id: message.id, error: { code: -32099, message: 'startup failed' } }); + }; + try { + await expect(agent.discoverModels()).rejects.toThrow(); + await expect(access(directory)).resolves.toBeUndefined(); + await expect(agent.dispose()).rejects.toThrow(); + await expect(access(directory)).resolves.toBeUndefined(); + fake.failClose = false; + await agent.dispose(); + await vi.waitFor(async () => { await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' }); }); + } finally { + fake.failClose = false; + await agent.dispose(); + if (directory) await rm(directory, { recursive: true, force: true }); + } + }); + it('selects exact advertised config id/value and never sends default sentinel', async () => { + const { fake, start } = create(); const handle = await start({ model: 'model-b' }); + expect(fake.written.find(item => item.method === 'session/set_config_option')!.params) + .toEqual({ sessionId: 'native-1', configId: 'native-model-picker', value: 'model-b' }); + await handle.setModel!(CURSOR_DEFAULT_MODEL); + expect(fake.written.filter(item => item.method === 'session/set_config_option').at(-1)!.params.value).toBe('auto-native'); + await handle.close(); + }); + it('answers approval with opaque allow_once ID, never grants always', async () => { + const { fake, start } = create(); const handle = await start(); + handle.setInteractionResolver(async () => ({ kind: 'permission', behavior: 'allow', permissionUpdates: [{}] })); + await handle.send({ type: 'user', content: 'do it' }); + fake.emit({ jsonrpc: '2.0', id: 'permission-1', method: 'session/request_permission', params: { + sessionId: 'native-1', toolCall: { toolCallId: 'tool1', title: 'write', kind: 'edit' }, + options: [{ kind: 'allow_always', optionId: 'global' }, { kind: 'allow_once', optionId: 'one-opaque' }], + } }); + await tick(); + expect(fake.written.find(item => item.id === 'permission-1')!.result).toEqual({ outcome: { outcome: 'selected', optionId: 'one-opaque' } }); + await handle.close(); + }); + it('cancels pending permissions and waits for prompt completion', async () => { + const { fake, start } = create(); const handle = await start(); + handle.setInteractionResolver(() => new Promise(() => {})); + await handle.send({ type: 'user', content: 'do it' }); + fake.emit({ jsonrpc: '2.0', id: 'p', method: 'session/request_permission', params: { sessionId: 'native-1', toolCall: {}, options: [] } }); + await tick(); + const abort = handle.abort(); await tick(); + expect(fake.written.find(item => item.id === 'p')!.result).toEqual({ outcome: { outcome: 'cancelled' } }); + expect(fake.written.find(item => item.method === 'session/cancel')!.id).toBeUndefined(); + fake.finish(); await abort; await handle.close(); + }); + it('keeps reservation and aborts preparation before any prompt is dispatched', async () => { + const { fake, start } = create(); const handle = await start(); fake.held.add('session/set_mode'); + const first = handle.send({ type: 'user', content: 'first' }, { planMode: true }); + await expect(handle.send({ type: 'user', content: 'second' })).rejects.toThrow('active turn'); + const rejected = expect(first).rejects.toThrow(); + await handle.abort(); await rejected; + expect(fake.written.some(item => item.method === 'session/prompt')).toBe(false); + await handle.close(); + }); + it('permits retry of unconfirmed process close without releasing bridge prematurely', async () => { + const dispose = vi.fn(); const { fake, start } = create(undefined, { + preparePiExtraSpawnConfig: async () => ({ disposeSessionCtx: dispose }), + }); + const handle = await start(); fake.failClose = true; + await expect(handle.close()).rejects.toThrow('exit unconfirmed'); expect(dispose).not.toHaveBeenCalled(); + await expect(handle.send({ type: 'user', content: 'blocked' })).rejects.toThrow('closed'); + fake.failClose = false; await handle.close(); expect(dispose).toHaveBeenCalledTimes(1); + }); + it('keeps native instructions intact and passes scoped Memory/context and MCP identity once', async () => { + const prepare = vi.fn>(async () => ({ mcpBridge: { token: 'fake-test-token', servers: [{ name: 'memory', url: 'http://127.0.0.1/mcp' }] }, disposeSessionCtx: vi.fn() })); + const { fake, start } = create(undefined, { preparePiExtraSpawnConfig: prepare }); + const handle = await start({ sessionId: 'cindy-1', sessionInstanceId: 'instance-1', makerMemoryEnabled: true, + makerMemoryScopeKey: 'bot:example', makerMemoryIndexSnapshot: 'Remembered context', userPrompt: 'User preference', vendorOptions: { orcaRole: 'lead' } }); + expect(prepare.mock.calls[0][1]).toMatchObject({ agentKind: 'cursor', sessionId: 'cindy-1', sessionInstanceId: 'instance-1', + memoryScopeKey: 'bot:example', memoryEnabled: true, mcpCallerAttested: false, mcpCallerKind: 'unknown', vendorOptions: { orcaRole: 'lead' } }); + expect(fake.written.find(item => item.method === 'session/new')!.params.mcpServers).toEqual([ + { type: 'http', name: 'memory', url: 'http://127.0.0.1/mcp', headers: [{ name: 'Authorization', value: 'Bearer fake-test-token' }] }, + ]); + await handle.send({ type: 'user', content: 'one' }); fake.finish(); await tick(); + await handle.send({ type: 'user', content: 'two' }); fake.finish(); await tick(); + const prompts = fake.written.filter(item => item.method === 'session/prompt'); + expect(prompts[0].params.prompt[0].text).toBe('Remembered context\n\nUser preference'); + expect(prompts[1].params.prompt).toEqual([{ type: 'text', text: 'two' }]); + await handle.close(); + }); + it('rejects malformed prompt results instead of reporting successful completion', async () => { + const { fake, start } = create(); const handle = await start(); const events: AgentEvent[] = []; + const consume = (async () => { for await (const event of handle.events()) events.push(event); })(); + await handle.send({ type: 'user', content: 'one' }); + const prompt = fake.written.find(item => item.method === 'session/prompt')!; + fake.emit({ jsonrpc: '2.0', id: prompt.id, result: {} }); await tick(); + await handle.close(); await consume; + expect(events.some(event => event.type === 'done')).toBe(false); + expect(events.find(event => event.type === 'error')?.data).toMatchObject({ reason: 'cursor_invalid_prompt_result' }); + }); + it('returns method-not-found for unsupported client requests even while idle', async () => { + const { fake, start } = create(); const handle = await start(); + fake.emit({ jsonrpc: '2.0', id: 'unknown', method: 'fs/write_text_file', params: {} }); await tick(); + expect(fake.written.find(item => item.id === 'unknown')!.error.code).toBe(-32601); + await handle.close(); + }); + it.each(['default', 'auto', 'bypassPermissions'] as const)('does not auto-approve a pending plan when permission mode changes to %s', async mode => { + const { fake, start } = create(); const handle = await start(); let answer: ((value: InteractionDecision) => void) | undefined; + handle.setInteractionResolver(() => new Promise(resolve => { answer = resolve; })); + await handle.send({ type: 'user', content: 'plan' }); + fake.emit({ jsonrpc: '2.0', id: 'plan', method: 'cursor/create_plan', params: { plan: 'Proposed changes' } }); await tick(); + await handle.setPermissionMode!(mode); + expect(fake.written.some(item => item.id === 'plan')).toBe(false); + answer!({ kind: 'plan_review', behavior: 'deny', reason: 'Revise' }); await tick(); + expect(fake.written.find(item => item.id === 'plan')!.result).toEqual({ outcome: { outcome: 'rejected', reason: 'Revise' } }); + await handle.close(); + }); + it('cancels in-flight native startup when the owning agent is disposed', async () => { + const { fake, agent, start } = create(); fake.held.add('initialize'); + const starting = start(); const rejected = expect(starting).rejects.toThrow('aborted'); + await tick(); await agent.dispose(); await rejected; + expect(fake.closeCount).toBeGreaterThan(0); + expect(fake.written.some(item => item.method === 'session/new')).toBe(false); + }); + it('keeps uncertain disconnected turns running until process exit can be confirmed', async () => { + const dispose = vi.fn(); const { fake, start } = create(undefined, { preparePiExtraSpawnConfig: async () => ({ disposeSessionCtx: dispose }) }); + const handle = await start(); const events: AgentEvent[] = []; + const consume = (async () => { for await (const event of handle.events()) events.push(event); })(); + await handle.send({ type: 'user', content: 'work' }); fake.failClose = true; + for (const listener of fake.closes) listener({ reason: 'lost transport' }); await tick(); + expect(handle.isTurnRunning!()).toBe(true); + expect(dispose).not.toHaveBeenCalled(); + expect(events.find(event => event.type === 'error')?.data).toMatchObject({ isTerminal: false, reason: 'cursor_cleanup_pending' }); + fake.failClose = false; await handle.abort(); await consume; + expect(dispose).toHaveBeenCalledTimes(1); + }); + it('maps the standard authentication-required error to native login guidance', async () => { + const { fake, start } = create(); fake.held.add('authenticate'); + fake.onWrite = message => { if (message.method === 'authenticate') fake.emit({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'Authentication required' } }); }; + await expect(start()).rejects.toMatchObject({ name: 'AgentNotAuthenticatedError', agentKind: 'cursor' }); + expect(fake.closeCount).toBe(1); + }); + it.each([{ reviewMode: true }, { botRuntimeProfile: {} }])('rejects restricted startup profiles before creating a native process: %j', async options => { + const { fake, start } = create(); + await expect(start(options)).rejects.toThrow('restricted Reviewer or Bot runtime profiles'); + expect(fake.written).toEqual([]); + }); + it.each([{ extraDirs: ['/read-only'] }, { writableDirs: ['/write'] }, { [LIBRARY_READ_ROOT]: '/library' }])('rejects directory grant promises it cannot enforce: %j', async options => { + const { fake, start } = create(); + await expect(start(options)).rejects.toThrow('additional directory grants'); + expect(fake.written).toEqual([]); + }); + it('rejects unsupported remote and per-turn policy boundaries explicitly', async () => { + const { start } = create(); + await expect(start({ remoteHostId: 'ssh' })).rejects.toThrow('only on the task host'); + const handle = await start(); + await expect(handle.send({ type: 'user', content: 'readonly' }, { turnPermissionPolicy: { forceConfirmToolCall: () => true, origin: { kind: 'desktop' }, confirmationSurface: 'desktop' } })) + .rejects.toThrow('Turn permission policy'); + await expect(handle.send({ type: 'user', content: '/approved-skill' }, { [PINNED_SKILL_INVOCATION]: {} as never })).rejects.toThrow('pinned Skill'); + await expect(handle.send({ type: 'user', content: 'no tools' }, { toolsDisabled: true })).rejects.toThrow('tools-disabled'); + await handle.close(); + }); +}); + +describe('Cursor model and event contracts', () => { + it('preserves native grouping, descriptions and order while discovering only offered models', () => { + const result = readCursorModels({ configOptions: [{ id: 'model', category: 'model', currentValue: 'default', options: [ + { value: 'default', name: 'Auto' }, + { name: 'Cursor Models', options: [{ value: 'grok-4.7', name: 'Grok 4.7', description: 'Native description' }] }, + { name: 'Other Models', options: [{ value: 'opaque-third-party', name: 'Native offer' }] }, + { name: 'Experimental', options: [{ value: 'future', name: 'Future model' }] }, + ] }] }); + expect(result.models.map(model => [model.id, model.group, model.sortOrder])).toEqual([ + ['default', 'cursor:auto', 0], ['grok-4.7', 'cursor:models', 1], + ['opaque-third-party', 'cursor:other', 2], ['future', 'cursor:native:Experimental', 3], + ]); + expect(result.models[1].description).toBe('Native description'); + expect(result.models.some(model => model.id === CURSOR_DEFAULT_MODEL)).toBe(false); + }); + it('includes renderable status text on turn start and context usage updates', async () => { + const { fake, start } = create(); + const handle = await start(); + const events: AgentEvent[] = []; + const consume = (async () => { for await (const event of handle.events()) events.push(event); })(); + await handle.send({ type: 'user', content: 'hello' }); + fake.update('usage_update', { used: 100, size: 200 }); + fake.finish(); + await tick(); + await handle.close(); + await consume; + const statuses = events.filter(event => event.type === 'status').map(event => event.data); + expect(statuses).toHaveLength(2); + for (const status of statuses) { + expect(status).toMatchObject({ isRunning: true, status: expect.stringMatching(/\S/) }); + } + expect(statuses[1]).toMatchObject({ contextTokens: 100, contextWindow: 200, tokenUsage: 0 }); + }); + + it('merges todo patches across turns and replaces the snapshot only when merge is false', async () => { + const { fake, start } = create(); + const handle = await start(); + const events: AgentEvent[] = []; + const consume = (async () => { for await (const event of handle.events()) events.push(event); })(); + const todos = (items: unknown[], merge: boolean) => fake.emit({ jsonrpc: '2.0', method: 'cursor/update_todos', params: { todos: items, merge } }); + await handle.send({ type: 'user', content: 'plan' }); + todos([{ id: 'a', content: 'A', status: 'pending' }, { id: 'b', content: 'B', status: 'pending' }, { id: 'c', content: 'C', status: 'pending' }], false); + fake.finish(); await tick(); + await handle.send({ type: 'user', content: 'continue' }); + todos([{ id: 'b', status: 'completed' }], true); + todos([{ id: 'd', content: 'D', status: 'in_progress' }], true); + todos([], true); + todos([{ id: 'e', content: 'E', status: 'pending' }], false); + todos([], false); + await handle.close(); await consume; + const plans = events.filter(event => event.type === 'tool_use').map(event => event.data); + expect(plans[1]).toMatchObject({ input: { plan: [ + { step: 'A', status: 'pending' }, { step: 'B', status: 'completed' }, { step: 'C', status: 'pending' }, + ] } }); + expect(plans[2]).toMatchObject({ input: { plan: [{ step: 'A' }, { step: 'B' }, { step: 'C' }, { step: 'D' }] } }); + expect(plans[3]).toEqual(plans[2]); + expect(plans[4]).toMatchObject({ input: { plan: [{ step: 'E', status: 'pending' }] } }); + expect(plans[5]).toMatchObject({ input: { plan: [] } }); + }); + it('does not invent a model catalog or context size', () => { + expect(readCursorModels({}).models).toEqual([]); + const catalog = readCursorModels({ configOptions: [{ id: 'x', category: 'model', currentValue: 'runtime', + options: [{ group: 'vendor', options: [{ value: 'runtime', name: 'Actual' }] }] }] }); + expect(catalog.models[0]).toMatchObject({ id: 'runtime', contextWindow: 0, newSessionDefault: ['cursor'] }); + }); + it('merges partial tool events without losing name/input and emits one terminal result', () => { + const events: AgentEvent[] = []; const translator = new CursorTranslator(event => events.push(event)); + translator.update({ sessionUpdate: 'tool_call', toolCallId: '1', title: 'read', kind: 'read', rawInput: { path: 'a' } }); + translator.update({ sessionUpdate: 'tool_call_update', toolCallId: '1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: 'contents' } }] }); + translator.update({ sessionUpdate: 'tool_call_update', toolCallId: '1', status: 'completed' }); + expect(events.filter(event => event.type === 'tool_use')).toHaveLength(1); + expect(events.find(event => event.type === 'tool_result_full')?.data).toEqual({ toolUseId: '1', fullText: 'contents', isError: false }); + expect(events.filter(event => event.type === 'tool_result_full')).toHaveLength(1); + }); + it('maps desktop/mobile JSON multi-select including commas to opaque option IDs', () => { + expect(cursorAnswers([{ id: 'q', prompt: 'Pick', allowMultiple: true, options: [ + { id: 'a', label: 'Alpha, one' }, { id: 'b', label: 'Beta' }, + ] }], { Pick: '["Alpha, one","Beta"]' })).toEqual({ outcome: { outcome: 'answered', answers: [{ questionId: 'q', selectedOptionIds: ['a', 'b'] }] } }); + }); + it('does not fabricate an answer for free text or duplicate option labels', () => { + const question = { id: 'q', prompt: 'Pick', options: [{ id: 'a', label: 'Same' }, { id: 'b', label: 'Same' }] }; + expect(cursorAnswers([question], { Pick: 'Same' })).toMatchObject({ outcome: { outcome: 'skipped' } }); + expect(cursorAnswers([question], { Pick: 'Custom' })).toMatchObject({ outcome: { outcome: 'skipped' } }); + }); +}); diff --git a/packages/maker-core/src/agents/cursor/index.ts b/packages/maker-core/src/agents/cursor/index.ts new file mode 100644 index 00000000000..b7d76fab1eb --- /dev/null +++ b/packages/maker-core/src/agents/cursor/index.ts @@ -0,0 +1,748 @@ +/** Cursor's own persistent agent, connected over the official ACP stdio protocol. */ +import { mkdtemp, open, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { redactSensitiveText } from '@cindy/maker-shared/error-redaction'; +import { + BaseAgent, PINNED_SKILL_INVOCATION, AgentNotAuthenticatedError, AgentStartupStoppedError, + AgentStartupCleanupPendingError, TurnPermissionPolicyUnsupportedError, + type AgentDeps, type AgentSessionHandle, type StartSessionOptions, + type SendOptions, type PiExtraSpawnConfig, +} from '../base-agent.js'; +import { LIBRARY_READ_ROOT } from '../shared/library-native-read.js'; +import { cursorAnswers } from './questions.js'; +import { AcpClient, AcpRpcError } from '../acp/client.js'; +import { spawnAcpTransport, type AcpTransport } from '../acp/transport.js'; +import type { Capabilities, ModelDescriptor } from '../../types/capabilities.js'; +import { NotSupportedError } from '../../types/capabilities.js'; +import type { Effort, PermissionMode, UserMessage } from '../../types/common.js'; +import type { AgentEvent, InteractionDecision, InteractionRequest, InteractionResolver } from '../../types/events.js'; +import { createAsyncQueue } from '../shared/async-queue.js'; +import { resolveMemoryScopeKey } from '../../memory/scope-resolver.js'; +import { CursorTranslator, cursorToolInput, cursorToolName } from './translator.js'; +import { cursorReviewAction } from './permissions.js'; +import type { ReviewableAction } from '../shared/auto-review.js'; +import { + appendAutoReviewUserIntent, normalizeAutoReviewUserIntent, createAutoReviewActionContext, + resolveAutoReviewDecision, withAutoReviewContext, createAutoReviewUnavailableNotice, + createAutoReviewConfirmUndeliveredNotice, annotatePermissionRequestForUnavailableReview, + isSystemPermissionDenialReason, composeAutoReviewIntentWithApprovedPlan, + composeAutoReviewIntentWithClarification, type AutoReviewUserIntent, +} from '../shared/auto-review-decision.js'; +import { CURSOR_DEFAULT_MODEL, readCursorModels, readCursorModelControls, record, type AcpRecord, type CursorConfigOption } from './models.js'; + +export { CURSOR_DEFAULT_MODEL, cursorDefaultModel } from './models.js'; +const yes = { supported: true } as const; +const no = { supported: false, reason: 'not-implemented' } as const; +const NATIVE_PERMISSION_DESCRIPTION = 'Cursor follows its native configured permission policy. Cindy shows only the approval requests Cursor sends; workspace edits may run without a prompt.'; +const CONTROL_TIMEOUT = 30_000; +const CANCEL_TIMEOUT = 10_000; + +export interface CursorAgentDeps extends AgentDeps { + /** Test/embedding seam; production uses the exact host-resolved executable. */ + createCursorTransport?: (options: { binaryPath: string; cwd: string; env: NodeJS.ProcessEnv }) => AcpTransport; +} + +export class CursorAgent extends BaseAgent { + readonly kind = 'cursor' as const; + readonly capabilities: Capabilities = { + switchModel: yes, availableModels: [], hasFastMode: false, + effort: no, effortLevels: [], reasoningDisplay: ['off', 'full'], + permissionModes: [ + { id: 'ask', displayName: 'Default permissions', description: NATIVE_PERMISSION_DESCRIPTION }, + { id: 'default', displayName: 'Default permissions', description: NATIVE_PERMISSION_DESCRIPTION }, + { id: 'auto', displayName: 'Auto-review', description: `Cindy automatically reviews the approval requests Cursor sends. ${NATIVE_PERMISSION_DESCRIPTION}` }, + { id: 'bypassPermissions', displayName: 'Full access', description: `Cindy allows the approval requests Cursor sends without confirmation. ${NATIVE_PERMISSION_DESCRIPTION}` }, + ], + setPermissionModeMidSession: yes, + // ACP approvals cover native permission requests, not every tool invocation. + // Do not claim enforcement of Cindy's per-tool read-only policy. + turnPermissionPolicy: { supported: no, unsupportedPermissionModes: ['ask', 'default', 'auto', 'bypassPermissions', 'acceptEdits', 'plan'] }, + planMode: no, + multimodal: { text: yes, image: no, file: yes }, + fork: no, rewind: no, abort: yes, sameTurnSteer: no, + memory: { supported: no }, extraDirs: no, writableDirs: no, + }; + private sessions = new Set(); + private startups = new Map>(); + private failedStartupCleanups = new Map Promise; + pendingError: AgentStartupCleanupPendingError; + confirmStopped: () => void; + promise?: Promise; + }>(); + private discoveryDirectories = new Map(); + private disposed = false; + + constructor(protected override deps: CursorAgentDeps) { super(deps); } + + async discoverModels(workingDir?: string): Promise { + const temporary = workingDir ? undefined : await mkdtemp(path.join(tmpdir(), 'cindy-cursor-models-')); + let handle: AgentSessionHandle | undefined; + let cleanupConfirmed = true; + try { + handle = await this.startSession({ workingDir: workingDir ?? temporary!, model: CURSOR_DEFAULT_MODEL, + permissionMode: 'ask', makerMemoryEnabled: false, vendorOptions: { cursorDiscoveryOnly: true } }); + if (temporary) this.discoveryDirectories.set(handle, temporary); + return this.capabilities.availableModels.map(model => ({ ...model })); + } catch (error) { + if (error instanceof AgentStartupCleanupPendingError) { + cleanupConfirmed = false; + // Preserve the probe cwd while a native process may still own it. + void error.whenStopped.then(async () => { if (temporary) await rm(temporary, { recursive: true, force: true }); }).catch(() => {}); + } + throw error; + } finally { + await handle?.close(); + if (!handle && temporary && cleanupConfirmed) await rm(temporary, { recursive: true, force: true }); + } + } + + private async retryFailedStartupCleanup(key: string): Promise { + const entry = this.failedStartupCleanups.get(key); + if (!entry) return; + const cleanup = entry.promise ?? entry.close(); + entry.promise = cleanup; + try { await cleanup; } + catch { + if (entry.promise === cleanup) entry.promise = undefined; + throw entry.pendingError; + } + if (this.failedStartupCleanups.get(key) === entry) { + this.failedStartupCleanups.delete(key); + entry.confirmStopped(); + } + } + + override async dispose(): Promise { + this.disposed = true; + for (const startup of this.startups.keys()) startup.abort(); + // Startup can enter quarantine after abort; wait before taking the cleanup snapshot. + await Promise.allSettled([...this.startups.values()]); + const results = await Promise.allSettled([ + ...[...this.failedStartupCleanups.keys()].map(key => this.retryFailedStartupCleanup(key)), + ...[...this.sessions].map(session => session.close()), + ]); + const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []); + if (errors.length) throw new AggregateError(errors, 'Cursor process cleanup is not confirmed'); + } + + override async refreshLocalModels(): Promise { await this.discoverModels(); return true; } + + async startSession(opts: StartSessionOptions): Promise { + const startup = new AbortController(); + let finish!: () => void; + this.startups.set(startup, new Promise(resolve => { finish = resolve; })); + const cleanupKey = opts.sessionId ?? opts.resumeSessionId ?? opts.workingDir; + try { + if (this.disposed) throw new AgentStartupStoppedError(new Error('Cursor agent has been disposed')); + // Keep the previous native writer outside the new startup's stopped-error boundary. + await this.retryFailedStartupCleanup(cleanupKey); + return await this.startSessionNow(opts, startup, cleanupKey); + } finally { + this.startups.delete(startup); + finish(); + } + } + + private async startSessionNow(opts: StartSessionOptions, startup: AbortController, cleanupKey: string): Promise { + const assertCurrent = () => { if (this.disposed || startup.signal.aborted) throw new Error('Cursor agent has been disposed'); }; + let client: AcpClient | undefined; + let bridge: PiExtraSpawnConfig | null = null; + const queue = createAsyncQueue(); + let sessionId = ''; + let model = opts.model || CURSOR_DEFAULT_MODEL; + let loading = true; + let closed = false; + let active: Promise | undefined; + let running = false; + let cancelled = false; + let resolver: InteractionResolver | undefined; + let mode: PermissionMode = opts.permissionMode ?? 'ask'; + let planMode = false; + let sessionState: AcpRecord = {}; + let parameterizedModels: unknown; + let turnOptions: SendOptions | undefined; + let promptCapabilities: AcpRecord = {}; + let firstPrompt = !opts.resumeSessionId; + let context = ''; + let closePromise: Promise | undefined; + let fenced = false; + let preparingController: AbortController | undefined; + let defaultModel: string | undefined; + let unregisterProcess: (() => void) | undefined; + let configuring = false; + let permissionGeneration = 0; + let userIntent: AutoReviewUserIntent = ''; + const actionContext = createAutoReviewActionContext(); + const pending = new Map(); + const emit = (event: AgentEvent) => { + if (!closed) queue.push({ ...event, source: 'cursor', turnAttemptToken: turnOptions?.turnAttemptToken }); + }; + const translator = new CursorTranslator(emit); + const emitReviewNotice = (message: string) => emit({ type: 'error', data: { message, isTerminal: false } }); + const reviewUnavailable = createAutoReviewUnavailableNotice(emitReviewNotice); + const confirmUndelivered = createAutoReviewConfirmUndeliveredNotice(emitReviewNotice); + const reviewAutoAction = async (action: ReviewableAction) => { + if (closed || fenced || cancelled || !running) { + return { verdict: 'block' as const, reason: 'Cursor has no active authorized turn.' }; + } + const intent = userIntent; + const generation = permissionGeneration; + const request = { sessionId: opts.sessionId, agentKind: 'cursor' as const, providerId: opts.providerId, + model, userIntent: intent, precedingBlockedActions: actionContext.precedingBlockedActions, + action, workspaceRoots: [opts.workingDir], writableRoots: [opts.workingDir], platform: process.platform }; + const decision = await withAutoReviewContext(request, this.deps.reviewAutoPermissionAction, + prepared => resolveAutoReviewDecision(prepared, this.deps.reviewAutoPermissionAction)); + if (closed || fenced || cancelled || !running || intent !== userIntent + || generation !== permissionGeneration || model !== request.model) { + return { verdict: 'block' as const, reason: 'Cursor authorization changed; retry against the current request.' }; + } + actionContext.record(action, decision); + return decision; + }; + const clearInteractions = () => { + for (const interaction of pending.values()) interaction.cancel(); + pending.clear(); + }; + const interact = async (request: InteractionRequest, action?: ReviewableAction): Promise => { + if (closed || fenced || cancelled) return undefined; + return new Promise(resolve => { + let settled = false; + let offered = false; + let attempt = 0; + const finish = (decision?: InteractionDecision) => { + if (settled) return; + settled = true; + attempt++; + pending.delete(request.requestId); + if (offered) emit({ type: 'interaction_dismissed', data: { requestId: request.requestId, reason: decision ? 'resolved' : 'cancelled' } }); + resolve(decision); + }; + const decide = async () => { + const token = ++attempt; + if (offered) { + offered = false; + emit({ type: 'interaction_dismissed', data: { requestId: request.requestId, reason: 'permission_mode_changed' } }); + } + if (settled || closed || fenced || cancelled) { finish(); return; } + let displayRequest = request; + let unavailable = false; + if (request.kind === 'permission' && action) { + if (mode === 'bypassPermissions') { finish({ kind: 'permission', behavior: 'allow' }); return; } + if (mode === 'auto') { + const decision = await reviewAutoAction(action); + if (settled || token !== attempt) return; + if (closed || fenced || cancelled) { finish(); return; } + if (decision.verdict !== 'ask') { + finish({ kind: 'permission', behavior: decision.verdict === 'allow' ? 'allow' : 'deny', reason: decision.reason }); + return; + } + unavailable = decision.unavailable === true; + if (unavailable) { reviewUnavailable.notify(); displayRequest = annotatePermissionRequestForUnavailableReview(request); } + } + } + if (!resolver) { if (unavailable) confirmUndelivered.notify(); finish(); return; } + offered = true; + emit({ type: 'interaction_request', data: displayRequest }); + Promise.resolve().then(() => resolver!(displayRequest)).then(decision => { + if (settled || token !== attempt) return; + if (unavailable && decision.kind === 'permission' && decision.behavior === 'deny' + && isSystemPermissionDenialReason(decision.reason)) confirmUndelivered.notify(); + finish(decision); + }, () => { if (!settled && token === attempt) { if (unavailable) confirmUndelivered.notify(); finish(); } }); + }; + pending.set(request.requestId, { cancel: () => finish(), + ...(request.kind === 'permission' ? { modeChanged: () => { void decide(); } } : {}) }); + void decide(); + }); + }; + const onRequest = async (method: string, raw: unknown): Promise => { + if (!['session/request_permission', 'cursor/create_plan', 'cursor/ask_question'].includes(method)) { + throw new AcpRpcError(-32601, `Unsupported ACP method: ${method}`); + } + const params = record(raw); + const cancelledResult = { outcome: { outcome: 'cancelled' } }; + if (closed || loading || cancelled || !running) return cancelledResult; + if (typeof params.sessionId === 'string' && params.sessionId !== sessionId) return cancelledResult; + const requestId = `cursor:${randomUUID()}`; + if (method === 'session/request_permission') { + const patch = record(params.toolCall); + const tool = { ...translator.tool(patch.toolCallId), ...patch }; + const decision = await interact({ kind: 'permission', requestId, + toolUseId: typeof tool.toolCallId === 'string' ? tool.toolCallId : undefined, + toolName: cursorToolName(tool), input: cursorToolInput(tool), + title: typeof tool.title === 'string' ? tool.title : undefined, + }, cursorReviewAction(tool, opts.workingDir)); + if (!decision || cancelled || closed) return cancelledResult; + const allow = decision.kind === 'permission' && decision.behavior === 'allow'; + const options = Array.isArray(params.options) ? params.options.map(record) : []; + // ACP allow_always can write machine-wide grants. Cindy approval is only + // for this request; never escalate it into native persistent permission. + const option = options.find(option => option.kind === (allow ? 'allow_once' : 'reject_once')); + return typeof option?.optionId === 'string' + ? { outcome: { outcome: 'selected', optionId: option.optionId } } : cancelledResult; + } + if (method === 'cursor/create_plan') { + if (typeof params.plan !== 'string') return { outcome: { outcome: 'rejected', reason: 'Invalid plan' } }; + const decision = await interact({ kind: 'plan_review', requestId, plan: params.plan, + toolUseId: typeof params.toolCallId === 'string' ? params.toolCallId : undefined }); + if (!decision || cancelled || closed) return cancelledResult; + // An edited plan cannot be silently represented as acceptance of the + // original: Cursor's extension has no edited-plan response field. + const accepted = decision.kind === 'plan_review' && decision.behavior === 'allow' && + (!decision.editedPlan || decision.editedPlan === params.plan); + if (accepted) userIntent = composeAutoReviewIntentWithApprovedPlan(userIntent, params.plan); + return accepted ? { outcome: { outcome: 'accepted' } } + : { outcome: { outcome: 'rejected', reason: decision.kind === 'plan_review' + ? decision.editedPlan ?? decision.reason ?? 'Plan not approved' : 'Plan not approved' } }; + } + if (method === 'cursor/ask_question') { + const questions = Array.isArray(params.questions) ? params.questions.map(record) : []; + if (!questions.length || questions.some(question => typeof question.id !== 'string' || typeof question.prompt !== 'string')) { + return { outcome: { outcome: 'skipped', reason: 'Invalid questions' } }; + } + const decision = await interact({ kind: 'ask_user_question', requestId, + questions: questions.map(question => ({ question: question.prompt as string, + header: question.id as string, multiSelect: question.allowMultiple === true, + options: (Array.isArray(question.options) ? question.options : []).map(record) + .filter(option => typeof option.label === 'string').map(option => ({ label: option.label as string })), + })) }); + if (!decision || cancelled || closed || (decision.kind === 'ask_user_question' && decision.dismissed)) return cancelledResult; + if (decision.kind !== 'ask_user_question') return { outcome: { outcome: 'skipped' } }; + const answers = cursorAnswers(questions, decision.answers); + if (record(record(answers).outcome).outcome === 'answered') { + userIntent = composeAutoReviewIntentWithClarification(userIntent, questions.map(question => ({ + question: String(question.prompt), answer: decision.answers[String(question.id)] ?? decision.answers[String(question.prompt)], + }))); + } + return answers; + } + // Unsupported blocking extension calls receive an explicit response. + throw new AcpRpcError(-32601, `Unsupported ACP method: ${method}`); + }; + const updateCatalog = (raw: unknown) => { + sessionState = { ...sessionState, ...record(raw) }; + const catalog = readCursorModels(sessionState, parameterizedModels); + const modes = record(sessionState.modes); + if (typeof modes.currentModeId === 'string') planMode = modes.currentModeId === 'plan'; + this.capabilities.planMode = Array.isArray(modes.availableModes) && modes.availableModes.some(mode => record(mode).id === 'plan') ? yes : no; + this.capabilities.switchModel = catalog.configId ? yes : no; + // The legacy sentinel remains an accepted input, never a second Auto row. + this.capabilities.availableModels = catalog.models; + this.capabilities.hasFastMode = catalog.models.some(item => item.supportsFastMode === true); + const efforts = [...new Set(catalog.models.flatMap(item => item.efforts))]; + this.capabilities.effort = efforts.length ? yes : no; + this.capabilities.effortLevels = efforts.map(id => ({ id, displayName: id })); + }; + const control = async (method: string, params: unknown, signal?: AbortSignal) => { + if (fenced || closed || configuring) throw new Error('Cursor session is closed or configuring'); + configuring = true; + try { return await client!.request(method, params, { timeoutMs: CONTROL_TIMEOUT, signal: signal ?? (this.startups.has(startup) ? startup.signal : undefined) }); } + catch (error) { + if (!(error instanceof AcpRpcError)) { fenced = true; await client!.close().catch(() => {}); } + throw error; + } finally { configuring = false; } + }; + const setConfigOption = async (option: CursorConfigOption, value: string | boolean) => { + if (running) throw new Error('Cannot change Cursor configuration during an active turn'); + if (option.currentValue === value) return; + const response = await control('session/set_config_option', { sessionId, configId: option.id, value, + ...(option.type === 'boolean' ? { type: 'boolean' } : {}) }); + updateCatalog(response); + // Legacy agents may acknowledge a mutation without returning configOptions. + if (!Array.isArray(record(response).configOptions)) { + const configs = Array.isArray(sessionState.configOptions) ? sessionState.configOptions : []; + updateCatalog({ configOptions: configs.map(item => record(item).id === option.id ? { ...record(item), currentValue: value } : item) }); + } else { + const actual = (sessionState.configOptions as unknown[]).map(record).find(item => item.id === option.id); + if (actual?.currentValue !== value) throw new Error('Cursor did not apply the requested configuration'); + } + }; + const setEffort = async (effort: Effort) => { + if (fenced || closed || configuring) throw new Error('Cursor session is closed or configuring'); + const controls = readCursorModelControls(sessionState.configOptions); + if (!controls.effort || !controls.efforts.includes(effort)) throw new NotSupportedError('effort', { supported: false, reason: 'sdk-missing' }); + await setConfigOption(controls.effort, effort); + }; + const setFastMode = async (enabled: boolean) => { + if (fenced || closed || configuring) throw new Error('Cursor session is closed or configuring'); + const { fast } = readCursorModelControls(sessionState.configOptions); + if (!fast) { + if (!enabled) return; + throw new NotSupportedError('fastMode', { supported: false, reason: 'sdk-missing' }); + } + await setConfigOption(fast, fast.type === 'boolean' ? enabled : String(enabled)); + }; + const setModel = async (next: string, options?: { effort?: Effort }) => { + if (fenced || closed || configuring) throw new Error('Cursor session is closed or configuring'); + if (running) throw new Error('Cannot change Cursor model during an active turn'); + const catalog = readCursorModels(sessionState, parameterizedModels); + const target = next === CURSOR_DEFAULT_MODEL ? defaultModel : next; + if (options?.effort && !catalog.models.find(item => item.id === target)?.efforts.includes(options.effort)) { + throw new NotSupportedError('effort', { supported: false, reason: 'sdk-missing' }); + } + if (target && target === catalog.currentModel) { model = target; if (options?.effort) await setEffort(options.effort); return; } + if (!target || !catalog.configId || !catalog.models.some(item => item.id === target)) { + throw new NotSupportedError('setModel', { supported: false, reason: 'sdk-missing' }); + } + await setConfigOption({ id: catalog.configId, type: 'select', currentValue: catalog.currentModel, options: [] }, target); + model = target; + if (options?.effort) await setEffort(options.effort); + }; + const setPlanMode = async (enabled: boolean, preparing = false) => { + if (fenced || closed || configuring) throw new Error('Cursor session is closed or configuring'); + if (running && !preparing) throw new Error('Cannot change Cursor mode during an active turn'); + if (enabled === planMode) return; + const modes = record(sessionState.modes); + const available = Array.isArray(modes.availableModes) ? modes.availableModes.map(record) : []; + const target = enabled ? 'plan' : 'agent'; + if (!available.some(item => item.id === target)) throw new NotSupportedError('setPlanMode', { supported: false, reason: 'sdk-missing' }); + await control('session/set_mode', { sessionId, modeId: target }, preparing ? preparingController?.signal : undefined); + planMode = enabled; + emit({ type: 'plan_mode_changed', data: { enabled } }); + }; + const close = (): Promise => { + if (closePromise) return closePromise; + fenced = true; + closePromise = (async () => { + cancelled = true; + preparingController?.abort(); + clearInteractions(); + if (running && sessionId) await client?.notify('session/cancel', { sessionId }).catch(() => {}); + await client?.close(); // Exit proof comes before releasing host MCP/working-dir ownership. + await active?.catch(() => {}); + if (!closed) { + unregisterProcess?.(); + bridge?.disposeSessionCtx?.(); + queue.end(); + } + closed = true; + running = false; + active = undefined; + if (handle) { + const directory = this.discoveryDirectories.get(handle); + if (directory) await rm(directory, { recursive: true, force: true }); + this.discoveryDirectories.delete(handle); + this.sessions.delete(handle); + } + })().catch(error => { closePromise = undefined; throw error; }); + return closePromise; + }; + let handle: AgentSessionHandle | undefined; + try { + assertCurrent(); + if (opts.extraDirs?.length || opts.writableDirs?.length || opts[LIBRARY_READ_ROOT]) { + throw new Error('Cursor ACP cannot enforce additional directory grants or a read-only Library root'); + } + if (opts.reviewMode || opts.botRuntimeProfile) { + throw new Error('Cursor ACP does not support restricted Reviewer or Bot runtime profiles'); + } + if (opts.remoteHostId || opts.deviceHosted) throw new Error('Cursor ACP currently runs only on the task host computer'); + if (!path.isAbsolute(opts.workingDir)) throw new Error('Cursor ACP requires an absolute working directory'); + if (opts.thinkingEnabled !== undefined) throw new Error('Cursor ACP does not advertise a generic thinking switch'); + if (!this.capabilities.permissionModes.some(option => option.id === mode)) throw new NotSupportedError('permissionMode', no); + const auth = await this.deps.auth.getState(); + if (!auth.authenticated) throw new AgentNotAuthenticatedError('cursor', 'Cursor Agent is not authenticated. Run agent login on the task host computer.'); + const env = { ...process.env, ...await this.deps.auth.getAuthEnv() }; + const discoveryOnly = opts.vendorOptions?.cursorDiscoveryOnly === true; + const memoryEnabled = !discoveryOnly && (opts.makerMemoryEnabled ?? this.deps.runtimeConfig.makerMemoryEnabled) === true; + const scope = await resolveMemoryScopeKey(opts.makerMemoryScopeKey ?? opts.workingDir); + if (!discoveryOnly && this.deps.preparePiExtraSpawnConfig) { + bridge = await this.deps.preparePiExtraSpawnConfig(this.deps.mcpProviders ?? [], { + agentKind: 'cursor', sessionId: opts.sessionId, sessionInstanceId: opts.sessionInstanceId, + workingDir: opts.workingDir, memoryScopeKey: scope, memoryEnabled, + vendorOptions: opts.vendorOptions, mcpCallerKind: 'unknown', mcpCallerAttested: false, + }); + } + if (firstPrompt && !discoveryOnly) { + const memory = memoryEnabled ? opts.makerMemoryIndexSnapshot + ?? (this.deps.makerMemory ? await (await this.deps.makerMemory.getStore(scope)).getIndex() : '') : ''; + // ACP has no system-message API. Pass caller-owned context once with the + // first user prompt, keeping Cursor's native instructions untouched. + context = [opts.botProfilePrompt, opts.botProfileContextPrompt, memory, opts.userPrompt].filter(Boolean).join('\n\n'); + } + assertCurrent(); + const transport = (this.deps.createCursorTransport ?? spawnAcpTransport)({ binaryPath: this.deps.binaryPath, cwd: opts.workingDir, env }); + client = new AcpClient(transport, { + requestTimeoutMs: CONTROL_TIMEOUT, onRequest, + onNotification: (method, raw) => { + const params = record(raw); + if (method === 'session/update') { + if (params.sessionId !== sessionId) return; + const update = record(params.update); + if (update.sessionUpdate === 'config_option_update') updateCatalog({ configOptions: update.configOptions }); + else if (update.sessionUpdate === 'current_mode_update' && typeof update.currentModeId === 'string') { + updateCatalog({ modes: { ...record(sessionState.modes), currentModeId: update.currentModeId } }); + if (!loading) emit({ type: 'plan_mode_changed', data: { enabled: planMode } }); + } + if (running) translator.update(update); + } else if (!loading && running && method === 'cursor/update_todos') { + if (typeof params.sessionId === 'string' && params.sessionId !== sessionId) return; + translator.updateTodos(params.todos, params.merge === true); + } else if (!loading && running && method === 'cursor/task') { + // This extension is a completion notice, not a controllable durable + // child session. Do not invent start/resume/stop handles. + if (typeof params.toolCallId === 'string') emit({ type: 'agent_task_update', data: { + provider: 'cursor', taskId: params.toolCallId, parentToolUseId: params.toolCallId, + status: 'completed', title: params.description, model: params.model, + } }); + } + }, + onClose: () => { + clearInteractions(); + if (!loading && !running && !fenced && !closed) { + fenced = true; + emit({ type: 'error', data: { message: 'Cursor Agent disconnected. Reopen this task to resume.', isTerminal: true, reason: 'cursor_acp_disconnected' } }); + } + }, + }); + const pid = client.getPid(); + const registration = pid ? this.deps.registerLocalAgentProcess?.({ pid, kind: 'cursor', role: 'task-host' }) : undefined; + if (typeof registration === 'function') unregisterProcess = registration; + const init = record(await client.request('initialize', { + protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, + _meta: { parameterizedModelPicker: true }, session: { configOptions: { boolean: {} } } }, + clientInfo: { name: 'cindy', version: '1.0.0' }, + }, { signal: startup.signal })); + if (init.protocolVersion !== 1) throw new Error('Cursor ACP protocol version is unsupported'); + const capabilities = record(init.agentCapabilities); + promptCapabilities = record(capabilities.promptCapabilities); + this.capabilities.multimodal.image = promptCapabilities.image === true ? yes : no; + const methods = Array.isArray(init.authMethods) ? init.authMethods.map(record) : []; + if (methods.some(method => method.id === 'cursor_login')) await client.request('authenticate', { methodId: 'cursor_login' }, { signal: startup.signal }); + const mcpServers: unknown[] = []; + if (bridge?.mcpBridge?.servers.length) { + if (record(capabilities.mcpCapabilities).http !== true) throw new Error('This Cursor ACP version does not support Cindy HTTP MCP tools'); + for (const server of bridge.mcpBridge.servers) { + const headers = server.remote + ? Object.entries(server.remote.headerEnvVars).map(([name, envVar]) => ({ name, value: bridge!.mcpEnv?.[envVar] ?? env[envVar] })) + : [{ name: 'Authorization', value: `Bearer ${bridge.mcpBridge.token}` }]; + if (headers.some(header => typeof header.value !== 'string')) throw new Error('Cursor MCP authentication environment is unavailable'); + mcpServers.push({ type: 'http', name: server.name, url: server.url, headers }); + } + } + // Cursor's native extension supplies each offered model's parameter choices + // without selecting it or sending a prompt. Older CLIs keep their ACP catalog. + try { parameterizedModels = record(await client.request('cursor/list_available_models', {}, { signal: startup.signal })).models; } + catch (error) { if (!(error instanceof AcpRpcError) || error.code !== -32601) throw error; } + updateCatalog({}); + if (opts.resumeSessionId) { + if (capabilities.loadSession !== true) throw new Error('This Cursor ACP version cannot resume saved tasks'); + validateSessionId(opts.resumeSessionId); + sessionId = opts.resumeSessionId; + updateCatalog(await client.request('session/load', { sessionId, cwd: opts.workingDir, mcpServers }, { signal: startup.signal })); + } else { + const result = record(await client.request('session/new', { cwd: opts.workingDir, mcpServers }, { signal: startup.signal })); + sessionId = validateSessionId(result.sessionId); + updateCatalog(result); + } + if (discoveryOnly) this.deps.logger.debug('Cursor native catalog discovered', { + sessionModelCount: this.capabilities.availableModels.length, + parameterizedModelCount: Array.isArray(parameterizedModels) ? parameterizedModels.length : 0, + }); + // session/load replays native history while loading=true. Cindy already + // owns the transcript, so replay is never emitted as a new user turn. + loading = false; + defaultModel = readCursorModels(sessionState).currentModel; + const requestedModel = model; + model = defaultModel ?? CURSOR_DEFAULT_MODEL; + if (requestedModel !== CURSOR_DEFAULT_MODEL) await setModel(requestedModel); + if (opts.effort) await setEffort(opts.effort); + if (opts.fastMode !== undefined) await setFastMode(opts.fastMode); + if (opts.planMode !== undefined) await setPlanMode(opts.planMode); + emit({ type: 'session_id', data: sessionId }); + if (planMode) emit({ type: 'plan_mode_changed', data: { enabled: true } }); + const validateSend = (options: SendOptions = {}) => { + if (closed || fenced) throw new Error('Cursor session is closed'); + if (running || configuring) throw new Error('Cursor already has an active turn or configuration'); + if (options.signal?.aborted) throw new Error('Cursor send cancelled before dispatch'); + if (options[PINNED_SKILL_INVOCATION]) throw new Error('Cursor ACP cannot enforce a pinned Skill identity'); + if (options.toolsDisabled) throw new Error('Cursor ACP cannot guarantee a tools-disabled turn'); + if (options.turnPermissionPolicy) throw new TurnPermissionPolicyUnsupportedError('cursor', mode); + }; + handle = { + id: sessionId, agentKind: 'cursor', get model() { return model; }, + events: () => queue, + getUsageSnapshot: () => ({ ...translator.usage }), + setInteractionResolver: next => { resolver = next; }, + reviewAutoPermissionAction: reviewAutoAction, + validateSendOptions: validateSend, + isTurnRunning: () => running, + async send(message: UserMessage, options: SendOptions = {}) { + validateSend(options); + // Reserve synchronously before reading attachments/mode changes, so a + // concurrent caller cannot dispatch another prompt during preparation. + running = true; + cancelled = false; + turnOptions = options; + translator.beginTurn(); + preparingController = new AbortController(); + const signalAbort = () => { cancelled = true; preparingController?.abort(); }; + options.signal?.addEventListener('abort', signalAbort, { once: true }); + try { + if (options.planMode !== undefined) await setPlanMode(options.planMode, true); + const blocks = await promptBlocks(message, promptCapabilities); + if (closed || cancelled || options.signal?.aborted) throw new Error('Cursor send cancelled before dispatch'); + const nextIntent = appendAutoReviewUserIntent(firstPrompt ? undefined : userIntent, message.content, options); + actionContext.advance(typeof nextIntent !== 'string'); + userIntent = normalizeAutoReviewUserIntent(nextIntent); + reviewUnavailable.reset(); confirmUndelivered.reset(); + if (firstPrompt && context) blocks.unshift({ type: 'text', text: context }); + emit({ type: 'status', data: { ...translator.usage, isRunning: true, status: 'Working' } }); + // ACP prompt returns only at the END of the turn. Dispatch promptly; + // never await completion from Session.send (which would block UI). + active = client!.request('session/prompt', { sessionId, prompt: blocks }, { timeoutMs: 24 * 60 * 60 * 1000 }); + firstPrompt = false; + preparingController = undefined; + options.signal?.removeEventListener('abort', signalAbort); + const finish = () => { + running = false; + active = undefined; + clearInteractions(); + }; + active.then(async result => { + const response = record(result); + if (!['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'].includes(String(response.stopReason))) { + fenced = true; + try { await client!.close(); } catch { + emit({ type: 'error', data: { message: 'Cursor process stop is not confirmed. Retry closing this task.', isTerminal: false, reason: 'cursor_cleanup_pending' } }); + return; + } + finish(); + emit({ type: 'error', data: { message: 'Cursor returned an invalid prompt result', isTerminal: true, reason: 'cursor_invalid_prompt_result' } }); + turnOptions = undefined; + return; + } + finish(); + if (!closed) emit({ type: 'done', data: { ...translator.usage, + stopReason: response.stopReason, cancelled: response.stopReason === 'cancelled' } }); + turnOptions = undefined; + }, async error => { + // A transport failure/timeout is not proof the native turn stopped. + // Fence and confirm exit before publishing terminal idle state. + if (!(error instanceof AcpRpcError)) { + fenced = true; + cancelled = true; + clearInteractions(); + try { await client!.close(); } catch { + emit({ type: 'error', data: { message: 'Cursor process stop is not confirmed. Retry closing this task.', isTerminal: false, reason: 'cursor_cleanup_pending' } }); + return; + } + } + finish(); + if (!closed) emit({ type: 'error', data: { message: safeError(error), isTerminal: true, + reason: cancelled ? 'cursor_cancelled' : 'cursor_acp_error' } }); + turnOptions = undefined; + }); + } catch (error) { + preparingController = undefined; + options.signal?.removeEventListener('abort', signalAbort); + running = false; + turnOptions = undefined; + throw error; + } + }, + async steer() { throw new NotSupportedError('sameTurnSteer', no); }, + async abort() { + if (fenced) { await close(); return; } + if (!running) return; + cancelled = true; + preparingController?.abort(); + clearInteractions(); + await client!.notify('session/cancel', { sessionId }).catch(() => {}); + const current = active; + if (current) { + let timer: ReturnType | undefined; + try { + await Promise.race([current.catch(() => {}), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Cursor did not confirm cancellation')), CANCEL_TIMEOUT); + })]); + } catch { + // A missing acknowledgement never means idle. Fence this process, + // rather than allowing the next turn to run concurrently. + await close(); + } finally { if (timer) clearTimeout(timer); } + } + }, + close, + setModel, + setEffort, setFastMode, + getFastMode: () => { const value = readCursorModelControls(sessionState.configOptions).fast?.currentValue; return value === true || value === 'true'; }, + async setPermissionMode(next) { + if (closed || fenced) throw new Error('Cursor session is closed'); + if (!['ask', 'default', 'auto', 'bypassPermissions'].includes(next)) throw new NotSupportedError('permissionMode', no); + if (mode === next) return; + mode = next; + permissionGeneration++; + reviewUnavailable.reset(); confirmUndelivered.reset(); + // Re-evaluate only tool approvals. Plans and questions still need an explicit answer. + for (const interaction of pending.values()) interaction.modeChanged?.(); + }, + setPlanMode, getPlanMode: () => planMode, getExecutionPlanMode: () => planMode, + }; + assertCurrent(); + this.sessions.add(handle); + return handle; + } catch (error) { + try { + await close(); + } catch { + let confirmStopped!: () => void; + const whenStopped = new Promise(resolve => { confirmStopped = resolve; }); + const pendingError = new AgentStartupCleanupPendingError('Cursor startup cleanup has not confirmed process exit', { cause: error, whenStopped }); + this.failedStartupCleanups.set(cleanupKey, { close, pendingError, confirmStopped }); + throw pendingError; + } + if (error instanceof AgentNotAuthenticatedError) throw error; + if (error instanceof AcpRpcError && error.code === -32000) { + throw new AgentNotAuthenticatedError('cursor', 'Cursor Agent authentication expired. Run agent login on the task host computer.'); + } + throw new AgentStartupStoppedError(error instanceof AcpRpcError ? new AcpRpcError(error.code, safeError(error)) : error); + } + } +} + +function validateSessionId(id: unknown): string { + if (typeof id !== 'string' || !id.trim() || id.length > 4096 || [...id].some(char => char.charCodeAt(0) < 32)) { + throw new Error('Cursor ACP returned an invalid session identity'); + } + return id; +} +function safeError(error: unknown): string { + // Native errors can contain request payloads/tokens. Keep transport diagnosis + // useful without reflecting arbitrary upstream error.data into UI/logs. + return error instanceof Error ? redactSensitiveText(error.message).slice(0, 500) : 'Cursor ACP request failed'; +} +async function promptBlocks(message: UserMessage, capabilities: AcpRecord): Promise { + if (typeof message.content === 'string') return [{ type: 'text', text: message.content }]; + const blocks: AcpRecord[] = []; + for (const item of message.content) { + if (item.type === 'text') blocks.push({ type: 'text', text: item.text }); + else if (item.type === 'image') { + if (capabilities.image !== true) throw new Error('This Cursor ACP version does not support image input'); + const file = await open(item.path, 'r'); + let bytes: Buffer; + try { + const info = await file.stat(); + if (!info.isFile() || info.size > 10 * 1024 * 1024) throw new Error('Cursor image input must be a regular file of at most 10 MiB'); + bytes = Buffer.alloc(info.size); + let offset = 0; + while (offset < bytes.length) { + const result = await file.read(bytes, offset, bytes.length - offset, offset); + if (!result.bytesRead) throw new Error('Cursor image input changed while reading'); + offset += result.bytesRead; + } + if ((await file.read(Buffer.alloc(1), 0, 1, bytes.length)).bytesRead) throw new Error('Cursor image input changed while reading'); + } finally { await file.close(); } + blocks.push({ type: 'image', data: bytes.toString('base64'), mimeType: item.mimeType ?? 'image/png' }); + } else { + const name = item.type === 'mention' ? item.name : path.basename(item.path); + if (capabilities.embeddedContext === true) blocks.push({ type: 'resource_link', name, uri: pathToFileURL(item.path).href }); + else blocks.push({ type: 'text', text: `${name}: ${item.path}` }); + } + } + return blocks; +} diff --git a/packages/maker-core/src/agents/cursor/models.ts b/packages/maker-core/src/agents/cursor/models.ts new file mode 100644 index 00000000000..a60febc90fb --- /dev/null +++ b/packages/maker-core/src/agents/cursor/models.ts @@ -0,0 +1,126 @@ +import type { ModelDescriptor } from '../../types/capabilities.js'; +import type { Effort } from '../../types/common.js'; +import { cursorModelGroup } from '@cindy/model-providers'; + +export type AcpRecord = Record; +export function record(value: unknown): AcpRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as AcpRecord : {}; +} +export interface CursorModelOption { id: string; name: string; description?: string; group?: string } +export interface CursorConfigOption { + id: string; + category?: string; + type: 'select' | 'boolean'; + currentValue?: string | boolean; + options: CursorModelOption[]; +} +const EFFORTS: readonly Effort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + +export function readCursorConfigOptions(value: unknown): CursorConfigOption[] { + if (!Array.isArray(value)) return []; + return value.flatMap(raw => { + const option = record(raw); + if (typeof option.id !== 'string' || !option.id) return []; + const choices: CursorModelOption[] = []; + const collect = (options: unknown): void => { + if (!Array.isArray(options)) return; + for (const raw of options) { + const item = record(raw); + if (Array.isArray(item.options)) collect(item.options); + else if (typeof item.value === 'string' && item.value.trim()) { + choices.push({ id: item.value, name: typeof item.name === 'string' ? item.name : item.value }); + } + } + }; + collect(option.options); + if (option.type !== undefined && option.type !== 'select' && option.type !== 'boolean') return []; + return [{ id: option.id, category: typeof option.category === 'string' ? option.category : undefined, + type: option.type === 'boolean' ? 'boolean' as const : 'select' as const, + currentValue: typeof option.currentValue === 'string' || typeof option.currentValue === 'boolean' ? option.currentValue : undefined, + options: [...new Map(choices.map(item => [item.id, item])).values()] }]; + }); +} + +/** Keep opaque config IDs/values intact; only standard Cindy effort values map to its slider. */ +export function readCursorModelControls(configs: unknown) { + const options = readCursorConfigOptions(configs); + const effort = options.find(option => option.category === 'thought_level' && option.type === 'select' + && option.options.some(item => EFFORTS.includes(item.id as Effort))); + const efforts = EFFORTS.filter(value => effort?.options.some(item => item.id === value)); + const fast = options.find(option => option.id === 'fast' + && (option.category === 'model_config' || option.category === '_model_config') + && (option.type === 'boolean' || ['false', 'true'].every(value => option.options.some(item => item.id === value)))); + return { effort, efforts, fast }; +} +export interface CursorModelCatalog { + models: ModelDescriptor[]; + configId?: string; + currentModel?: string; +} + +/** Only advertise models the running CLI has actually offered. No guessed AUTO catalog. */ +export function readCursorModels(session: unknown, parameterizedModels?: unknown): CursorModelCatalog { + const data = record(session); + const configs = Array.isArray(data.configOptions) ? data.configOptions : []; + const config = configs.map(record).find(option => option.category === 'model'); + const choices: CursorModelOption[] = []; + function collect(options: unknown, group?: string): void { + if (!Array.isArray(options)) return; + for (const raw of options) { + const option = record(raw); + if (Array.isArray(option.options)) collect(option.options, typeof option.name === 'string' ? option.name : group); + else if (typeof option.value === 'string' && option.value.trim()) { + choices.push({ id: option.value, name: typeof option.name === 'string' ? option.name : option.value, + ...(typeof option.description === 'string' ? { description: option.description } : {}), + ...(group ? { group } : {}), + }); + } + } + } + collect(config?.options); + // Older ACP agents expose SessionModelState. Discovery is useful even when + // their experimental model mutation method is unavailable: don't guess it. + const legacy = record(data.models); + if (!choices.length && Array.isArray(legacy.availableModels)) { + for (const raw of legacy.availableModels) { + const item = record(raw); + if (typeof item.modelId === 'string' && item.modelId.trim()) { + choices.push({ id: item.modelId, name: typeof item.name === 'string' ? item.name : item.modelId, + ...(typeof item.description === 'string' ? { description: item.description } : {}), + }); + } + } + } + const current = config?.currentValue ?? legacy.currentModelId; + const currentModel = typeof current === 'string' && current.trim() ? current : undefined; + const parameters = new Map((Array.isArray(parameterizedModels) ? parameterizedModels : []) + .map(record).filter(item => typeof item.value === 'string').map(item => [item.value, item.configOptions])); + return { + configId: typeof config?.id === 'string' ? config.id : undefined, + currentModel, + models: [...new Map(choices.map(item => [item.id, item])).values()].map((item, sortOrder) => { + const defaults = readCursorModelControls(parameters.get(item.id)); + const controls = item.id === currentModel ? readCursorModelControls(configs) : defaults; + const defaultEffort = defaults.effort?.currentValue ?? controls.effort?.currentValue; + const group = cursorModelGroup(item.id, item.name, item.group); + return { id: item.id, displayName: item.name, contextWindow: 0, + sortOrder, + ...(group ? { group } : {}), + ...(item.description !== undefined ? { description: item.description } : {}), + efforts: controls.efforts, + defaultEffort: typeof defaultEffort === 'string' && controls.efforts.includes(defaultEffort as Effort) ? defaultEffort as Effort : null, + supportsFastMode: controls.fast !== undefined, + ...(item.id === currentModel ? { newSessionDefault: ['cursor' as const] } : {}), + }; + }), + }; +} + +/** A sentinel meaning “let this CLI choose”; it is not an AUTO model claim. */ +export const CURSOR_DEFAULT_MODEL = 'cursor-default'; +export const cursorDefaultModel: ModelDescriptor = { + id: CURSOR_DEFAULT_MODEL, displayName: 'Cursor Default', contextWindow: 0, + efforts: [], defaultEffort: null, supportsFastMode: false, + defaultEnabled: false, +}; diff --git a/packages/maker-core/src/agents/cursor/permissions.test.ts b/packages/maker-core/src/agents/cursor/permissions.test.ts new file mode 100644 index 00000000000..9c88db640d6 --- /dev/null +++ b/packages/maker-core/src/agents/cursor/permissions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { reviewAction } from '../shared/auto-review.js'; +import { cursorReviewAction } from './permissions.js'; + +describe('Cursor native approval evidence', () => { + const workingDir = '/synthetic/project'; + it('does not execute or approve a display title as command evidence', () => { + const action = cursorReviewAction({ kind: 'execute', title: 'pwd', rawInput: {} }, workingDir); + expect(action).toMatchObject({ kind: 'exec', command: '' }); + expect(reviewAction(action, [workingDir])).not.toBe('auto-approve'); + }); + it('retains a reported unknown execution directory instead of claiming workspace authority', () => { + const action = cursorReviewAction({ kind: 'execute', rawInput: { command: 'rm -rf cache', cwd: '' } }, workingDir); + expect(action).toMatchObject({ cwdUnknown: true }); + expect(reviewAction(action, [workingDir])).not.toBe('auto-approve'); + }); + it('does not silently approve an edit without canonical write evidence', () => { + const action = cursorReviewAction({ kind: 'edit', locations: [{ path: `${workingDir}/alias/output.txt` }] }, workingDir); + expect(reviewAction(action, [workingDir])).not.toBe('auto-approve'); + }); + it('does not treat a multi-location read as only its first safe target', () => { + const action = cursorReviewAction({ kind: 'read', rawInput: { path: `${workingDir}/README.md` }, + locations: [{ path: `${workingDir}/README.md` }, { path: '/synthetic/.aws/credentials' }] }, workingDir); + expect(reviewAction(action, [workingDir])).not.toBe('auto-approve'); + }); + it('keeps an opaque MCP operation in the shared reviewer instead of classifying its title as a read', () => { + const action = cursorReviewAction({ kind: 'other', title: 'Read fixture', rawInput: { method: 'send', destination: 'fixture' } }, workingDir); + expect(reviewAction(action, [workingDir])).not.toBe('auto-approve'); + }); +}); diff --git a/packages/maker-core/src/agents/cursor/permissions.ts b/packages/maker-core/src/agents/cursor/permissions.ts new file mode 100644 index 00000000000..7bbfc8041ee --- /dev/null +++ b/packages/maker-core/src/agents/cursor/permissions.ts @@ -0,0 +1,36 @@ +import type { ReviewableAction } from '../shared/auto-review.js'; +import { cursorToolInput } from './translator.js'; +import { record, type AcpRecord } from './models.js'; + +/** Review only concrete native input; a display title is not command/path evidence. */ +export function cursorReviewAction(tool: AcpRecord, workingDir: string): ReviewableAction { + const input = cursorToolInput(tool); + const text = (...keys: string[]) => keys.map(key => input[key]).find( + (value): value is string => typeof value === 'string' && value.trim().length > 0, + ); + const locations = Array.isArray(tool.locations) ? tool.locations.map(record) : []; + const target = text('path', 'filePath', 'file_path', 'target_file', 'targetPath', 'absolutePath') + ?? (locations.length === 1 && typeof locations[0].path === 'string' ? locations[0].path : undefined); + switch (tool.kind) { + case 'execute': + return { kind: 'exec', command: text('command', 'cmd') ?? '', + cwd: text('cwd', 'workingDirectory') ?? workingDir, + ...(('cwd' in input || 'workingDirectory' in input) && !text('cwd', 'workingDirectory') + ? { cwdUnknown: true } : {}) }; + case 'read': + case 'search': + // Missing or multiple targets cannot be silently classified as a safe read. + if (target && locations.length <= 1) return { kind: 'read', path: target, ...(tool.kind === 'search' ? { scope: 'tree' as const } : {}) }; + break; + case 'edit': + case 'delete': + // ACP provides lexical locations, not execution-time canonical write roots. + if (target && locations.length <= 1 && tool.kind === 'edit') return { kind: 'file-write', path: target, + resolvedPath: null, resolvedWritableRoots: null }; + break; + case 'fetch': + return { kind: 'network', target: text('url', 'uri', 'query'), operation: text('method') }; + } + return { kind: 'other', description: JSON.stringify({ kind: tool.kind, input, + ...(locations.length ? { locations } : {}) }) }; +} diff --git a/packages/maker-core/src/agents/cursor/questions.ts b/packages/maker-core/src/agents/cursor/questions.ts new file mode 100644 index 00000000000..45e1399c033 --- /dev/null +++ b/packages/maker-core/src/agents/cursor/questions.ts @@ -0,0 +1,32 @@ +import { record } from './models.js'; + +/** Cindy desktop/mobile serialize multiple selected labels as a JSON string[]. */ +export function cursorAnswers(questions: unknown[], answers: Record): unknown { + const parsed = questions.map(record); + if (new Set(parsed.map(item => item.prompt)).size !== parsed.length) { + return { outcome: { outcome: 'skipped', reason: 'Duplicate question prompts cannot be represented by this client' } }; + } + const results: Array<{ questionId: string; selectedOptionIds: string[] }> = []; + for (const question of parsed) { + const answer = answers[String(question.id)] ?? answers[String(question.prompt)]; + let labels: string[] = typeof answer === 'string' && answer ? [answer] : []; + if (question.allowMultiple === true && answer) { + try { + const decoded: unknown = JSON.parse(answer); + if (Array.isArray(decoded) && decoded.every(value => typeof value === 'string')) labels = decoded; + } catch { /* a single selected label remains a valid answer */ } + } + const options = (Array.isArray(question.options) ? question.options : []).map(record); + const selected: string[] = []; + for (const label of labels) { + const matches = options.filter(option => option.label === label && typeof option.id === 'string'); + if (matches.length !== 1) return { outcome: { outcome: 'skipped', reason: 'Free-text or ambiguous answers are not supported by Cursor ACP' } }; + selected.push(matches[0].id as string); + } + if (!selected.length || (question.allowMultiple !== true && selected.length !== 1)) { + return { outcome: { outcome: 'skipped', reason: 'No unambiguous option selected' } }; + } + results.push({ questionId: String(question.id), selectedOptionIds: [...new Set(selected)] }); + } + return { outcome: { outcome: 'answered', answers: results } }; +} diff --git a/packages/maker-core/src/agents/cursor/translator.ts b/packages/maker-core/src/agents/cursor/translator.ts new file mode 100644 index 00000000000..2ae90dac404 --- /dev/null +++ b/packages/maker-core/src/agents/cursor/translator.ts @@ -0,0 +1,119 @@ +import type { AgentEvent, UsageSnapshot } from '../../types/events.js'; +import { record, type AcpRecord } from './models.js'; + +/** ACP tool_call_update is a partial patch, not a replacement tool call. */ +export class CursorTranslator { + private tools = new Map(); + private completedTools = new Set(); + // Cursor todo patches are session state and survive individual prompt turns. + private todos = new Map(); + readonly usage: UsageSnapshot = { tokenUsage: 0, contextTokens: 0, contextWindow: 0, costUsd: 0 }; + + constructor(private emit: (event: AgentEvent) => void) {} + + beginTurn(): void { + this.tools.clear(); + this.completedTools.clear(); + this.usage.tokenUsage = 0; + } + + updateTodos(raw: unknown, merge: boolean): void { + if (!Array.isArray(raw)) return; + if (!merge) this.todos.clear(); + for (const item of raw) { + const todo = record(item); + if (typeof todo.id !== 'string' || !todo.id) continue; + this.todos.set(todo.id, { ...this.todos.get(todo.id), ...todo }); + } + this.update({ sessionUpdate: 'plan', entries: [...this.todos.values()] }); + } + + update(raw: unknown): void { + const update = record(raw); + const event = (type: AgentEvent['type'], data: unknown) => this.emit({ type, data, source: 'cursor' }); + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'agent_thought_chunk': { + const content = record(update.content); + if (content.type === 'text' && typeof content.text === 'string') { + event(update.sessionUpdate === 'agent_message_chunk' ? 'text' : 'thinking', { + text: content.text, isFinal: false, + }); + } + break; + } + case 'tool_call': + case 'tool_call_update': { + if (typeof update.toolCallId !== 'string' || !update.toolCallId) break; + const id = update.toolCallId; + const previous = this.tools.get(id); + const tool = { ...previous, ...update }; + this.tools.set(id, tool); + if (!previous || ('rawInput' in update && !('rawInput' in previous))) { + event('tool_use', { + toolUseId: id, toolName: cursorToolName(tool), + input: cursorToolInput(tool), + }); + } + if ((tool.status === 'completed' || tool.status === 'failed') && !this.completedTools.has(id)) { + this.completedTools.add(id); + const fullText = toolText(tool); + event('tool_result_full', { toolUseId: id, fullText, isError: tool.status === 'failed' }); + event('tool_result', { summary: tool.status === 'failed' ? 'failed' : 'done', toolUseIds: [id] }); + } + break; + } + case 'plan': { + if (!Array.isArray(update.entries)) break; + event('tool_use', { toolUseId: 'cursor-plan', toolName: 'update_plan', input: { + plan: update.entries.map(record).map(entry => ({ + step: String(entry.content ?? ''), status: entry.status, + })), + } }); + event('tool_result_full', { toolUseId: 'cursor-plan', fullText: '', isError: false }); + break; + } + case 'usage_update': { + // ACP used/size describe context occupancy, never per-request billable usage. + if (finite(update.used)) this.usage.contextTokens = update.used; + if (finite(update.size)) this.usage.contextWindow = update.size; + event('status', { ...this.usage, isRunning: true, status: 'Working' }); + break; + } + } + } + + tool(id: unknown): AcpRecord { return typeof id === 'string' ? this.tools.get(id) ?? {} : {}; } +} + +function finite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} +export function cursorToolName(tool: AcpRecord): string { + const names: Record = { read: 'Read', edit: 'Edit', execute: 'Bash', search: 'Search' }; + return (typeof tool.kind === 'string' ? names[tool.kind] : undefined) + ?? (typeof tool.title === 'string' ? tool.title : 'Cursor Tool'); +} +export function cursorToolInput(tool: AcpRecord): AcpRecord { + const raw = record(tool.rawInput); + return Object.keys(raw).length ? raw : { + ...(typeof tool.title === 'string' ? { description: tool.title } : {}), + ...(Array.isArray(tool.locations) ? { locations: tool.locations } : {}), + ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), + }; +} +function toolText(tool: AcpRecord): string { + if (typeof tool.rawOutput === 'string') return tool.rawOutput; + const sections: string[] = []; + if (Array.isArray(tool.content)) for (const raw of tool.content) { + const item = record(raw); + if (item.type === 'content') { + const content = record(item.content); + if (typeof content.text === 'string') sections.push(content.text); + } else if (item.type === 'diff') { + sections.push(JSON.stringify({ path: item.path, oldText: item.oldText, newText: item.newText })); + } + } + if (!sections.length && tool.rawOutput !== undefined) sections.push(JSON.stringify(tool.rawOutput)); + return sections.join('\n'); +} diff --git a/packages/maker-core/src/agents/index.ts b/packages/maker-core/src/agents/index.ts index bedd5ebace0..fc64c98294e 100644 --- a/packages/maker-core/src/agents/index.ts +++ b/packages/maker-core/src/agents/index.ts @@ -143,3 +143,6 @@ export { isClaudeProjectKeyExact, findClaudeSessionJsonl, } from './claude-code/claude-projects-fs.js'; + +export { CursorAgent } from './cursor/index.js'; +export { CURSOR_DEFAULT_MODEL, cursorDefaultModel } from './cursor/models.js'; diff --git a/packages/maker-core/src/maker.test.ts b/packages/maker-core/src/maker.test.ts index fce31a195fb..a9f6e227493 100644 --- a/packages/maker-core/src/maker.test.ts +++ b/packages/maker-core/src/maker.test.ts @@ -4241,11 +4241,11 @@ describe('Session permission mode leases', () => { }); describe('Maker invalid-resume persistence bridge', () => { - it('injects a compare-and-clear callback for resumed Claude sessions', async () => { + it.each(['claude-code', 'pi', 'cursor'] as const)('injects a compare-and-clear callback for resumed %s sessions', async (agentKind) => { const storage = createStorage(); await storage.create({ id: 'session-1', - agentKind: 'claude-code', + agentKind, workDir: '/repo', title: 'Resume me', model: 'claude-opus-4-6', @@ -4254,16 +4254,16 @@ describe('Maker invalid-resume persistence bridge', () => { const startSession = vi.fn(async (opts: CreateSessionOptions) => { expect(await opts.onInvalidResumeSession?.('sdk-old')).toBe(true); expect(await opts.onInvalidResumeSession?.('sdk-old')).toBe(false); - return createHandle({ id: '', agentKind: 'claude-code' }); + return createHandle({ id: '', agentKind }); }); const maker = new Maker({ - agents: { 'claude-code': createAgent(startSession, 'claude-code') }, + agents: { [agentKind]: createAgent(startSession, agentKind) }, storage, logger: createLogger(), }); await maker.createSession({ id: 'session-1', - agentKind: 'claude-code', + agentKind, workingDir: '/repo', model: 'claude-opus-4-6', resumeSessionId: 'sdk-old', diff --git a/packages/maker-core/src/maker.ts b/packages/maker-core/src/maker.ts index ea3b2fde95d..2d03a2768ac 100644 --- a/packages/maker-core/src/maker.ts +++ b/packages/maker-core/src/maker.ts @@ -775,7 +775,7 @@ export class Maker { sessionId: id, sessionInstanceId, onInvalidResumeSession: - opts.agentKind === 'claude-code' || opts.agentKind === 'pi' + opts.agentKind === 'claude-code' || opts.agentKind === 'pi' || opts.agentKind === 'cursor' ? (expectedSdkSessionId) => this.invalidateAndClearSdkSessionId(id, expectedSdkSessionId) : undefined, @@ -792,7 +792,7 @@ export class Maker { // 的 fresh-session self-reference 恢复),需要同一把 CAS 才能把它清掉,否则下一次 // send 会 resume 同一个不存在的会话反复失败。 onInvalidResumeSession: - opts.agentKind === 'claude-code' || opts.agentKind === 'pi' + opts.agentKind === 'claude-code' || opts.agentKind === 'pi' || opts.agentKind === 'cursor' ? (expectedSdkSessionId) => this.invalidateAndClearSdkSessionId(id, expectedSdkSessionId) : undefined, diff --git a/packages/maker-core/src/session.origin.test.ts b/packages/maker-core/src/session.origin.test.ts index a9159ca7b58..bb257689bc6 100644 --- a/packages/maker-core/src/session.origin.test.ts +++ b/packages/maker-core/src/session.origin.test.ts @@ -579,18 +579,19 @@ describe('Session per-turn origin 打标', () => { await session.close(); }); - it('still stamps an in-flight start-failure after the prior turn already observed done', async () => { + it.each(['codex', 'pi', 'cursor'] as const)('stamps %s in-flight start-failure after the prior turn already observed done', async (agentKind) => { const { handle, emit, setTurnRunning, releaseDispatch } = createControllableHandle({ + agentKind, dispatchEvent: { type: 'error', data: { message: 'second failed', isTerminal: true }, - source: 'codex', + source: agentKind, }, dispatchOnSend: 2, holdDispatch: true, holdOnSend: 2, }); - const session = makeSession(handle); + const session = makeSession(handle, agentKind); const seen: AgentEvent[] = []; session.onEvent((event) => seen.push({ ...event })); diff --git a/packages/maker-core/src/session.ts b/packages/maker-core/src/session.ts index 2bd3f25819d..2ebd4feea59 100644 --- a/packages/maker-core/src/session.ts +++ b/packages/maker-core/src/session.ts @@ -2673,7 +2673,7 @@ export class Session { ( this.lastResolvedInFlightSend && resolvedGeneration === this.turnGeneration && - (this.agentKind === 'codex' || this.agentKind === 'pi') + (this.agentKind === 'codex' || this.agentKind === 'pi' || this.agentKind === 'cursor') ); this.observeTurnControl(event, resolvedGeneration); // fan-out 前打 turn origin(所有 listener 拿到同一份);事件对象由 translator diff --git a/packages/maker-core/src/types/capabilities.ts b/packages/maker-core/src/types/capabilities.ts index a6fb721816b..515cb553327 100644 --- a/packages/maker-core/src/types/capabilities.ts +++ b/packages/maker-core/src/types/capabilities.ts @@ -284,7 +284,7 @@ export interface ModelDescriptor { * host 派生时透传)。与 sortOrder / defaultEnabled 独立;渲染层选新对话默认时优先取被 * 标记且可用可见的模型(见 modelDefinitions getDefaultModelForVendor)。maker-core 运行时不读它。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'cursor')[]; /** * 模型计费($/1M tokens,源自目录/网关刷新,host 派生时透传)。pi 用它生成 * models.json 的 cost 让 pi 自行计价;缺省按 0 计(用量页不显示钱数)。 diff --git a/packages/maker-core/src/types/common.ts b/packages/maker-core/src/types/common.ts index 46dd432444b..19acc3c1b42 100644 --- a/packages/maker-core/src/types/common.ts +++ b/packages/maker-core/src/types/common.ts @@ -5,7 +5,7 @@ * 故意保持兼容以便 desktop adapter 层零代码翻译。 */ -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type WorkspaceKind = 'project' | 'dialogue'; /** diff --git a/packages/maker-core/src/types/customizations.ts b/packages/maker-core/src/types/customizations.ts index ea1a66291cd..814c00d51b7 100644 --- a/packages/maker-core/src/types/customizations.ts +++ b/packages/maker-core/src/types/customizations.ts @@ -26,7 +26,7 @@ export interface AgentCustomizationFile { export interface AgentCustomization { /** 来自哪个 agent。等价于 AgentKind, 但避开循环依赖故用宽松 string。 */ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** * Engine-specific 词汇: diff --git a/packages/maker-core/src/types/events.ts b/packages/maker-core/src/types/events.ts index 5724a6471ee..333213fc0f6 100644 --- a/packages/maker-core/src/types/events.ts +++ b/packages/maker-core/src/types/events.ts @@ -87,7 +87,7 @@ export interface AgentTaskUsage { } export interface AgentTaskUpdateEventData { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** Provider task id when available; falls back to the parent tool call id. */ taskId: string; /** The tool_use id that launched or controls this subagent task. */ @@ -161,7 +161,7 @@ export interface AgentEvent { type: AgentEventType; data: unknown; /** 事件来源标识,便于调试 */ - source?: 'claude-code' | 'codex' | 'pi'; + source?: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** * Events that finish work owned by a completed turn can still arrive after a * later turn has started (for example, a V1 collab child). These are still diff --git a/packages/maker-scheduler/src/__tests__/scheduler.test.ts b/packages/maker-scheduler/src/__tests__/scheduler.test.ts index b41dbf1a2d0..74cbbbbae6c 100644 --- a/packages/maker-scheduler/src/__tests__/scheduler.test.ts +++ b/packages/maker-scheduler/src/__tests__/scheduler.test.ts @@ -216,13 +216,13 @@ describe('Scheduler', () => { }); - it('persists an explicit Harness with its model and clears it when following the target', async () => { - const saved = await h.scheduler.create({ ...baseInput, modelAgentKind: 'pi', model: 'grok-4.6' }); - expect(saved.modelAgentKind).toBe('pi'); + it.each(['pi', 'cursor'] as const)('persists an explicit %s Harness and clears it when following the target', async (modelAgentKind) => { + const saved = await h.scheduler.create({ ...baseInput, modelAgentKind, model: 'grok-4.6' }); + expect(saved.modelAgentKind).toBe(modelAgentKind); await expect(h.scheduler.update(saved.id, { model: undefined })).rejects.toThrow(/Harness/); const followed = await h.scheduler.update(saved.id, { modelAgentKind: undefined, model: undefined }); expect(followed?.modelAgentKind).toBeUndefined(); - await expect(h.scheduler.create({ ...baseInput, modelAgentKind: 'pi', model: '' })).rejects.toThrow(/Harness/); + await expect(h.scheduler.create({ ...baseInput, modelAgentKind, model: '' })).rejects.toThrow(/Harness/); }); it('create() computes nextFireAt and adds to active map', async () => { diff --git a/packages/maker-scheduler/src/engine/scheduler.ts b/packages/maker-scheduler/src/engine/scheduler.ts index d18d5062624..0b303236b26 100644 --- a/packages/maker-scheduler/src/engine/scheduler.ts +++ b/packages/maker-scheduler/src/engine/scheduler.ts @@ -60,7 +60,7 @@ function validateScheduleExecutionShape( opts: { checkAgentPrompt: boolean } = { checkAgentPrompt: true }, ): void { if (schedule.modelAgentKind != null) { - if (!['claude-code', 'codex', 'pi'].includes(schedule.modelAgentKind) || !schedule.model?.trim()) { + if (!['claude-code', 'codex', 'pi', 'cursor'].includes(schedule.modelAgentKind) || !schedule.model?.trim()) { throw new Error('Explicit scheduled model Harness requires a supported Harness and model'); } } diff --git a/packages/maker-scheduler/src/types.ts b/packages/maker-scheduler/src/types.ts index c08ef5334e4..543fdb1e4a4 100644 --- a/packages/maker-scheduler/src/types.ts +++ b/packages/maker-scheduler/src/types.ts @@ -1,5 +1,5 @@ export type ScheduleKind = 'cron'; -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type ScheduleStatus = 'active' | 'paused' | 'expired'; export type ScheduleWorkspaceKind = 'project' | 'dialogue'; export type ScheduleExecutionMode = 'agent' | 'script'; diff --git a/packages/maker-shared/src/__tests__/permissionMode.test.ts b/packages/maker-shared/src/__tests__/permissionMode.test.ts index c4de3a64211..9db7389b09e 100644 --- a/packages/maker-shared/src/__tests__/permissionMode.test.ts +++ b/packages/maker-shared/src/__tests__/permissionMode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + deduplicateDefaultPermissionOptions, permissionModeOrAsk, requiresFullAccessConfirmation, } from '../permissionMode.js'; @@ -25,6 +26,33 @@ describe('permissionModeOrAsk', () => { ); }); +describe('deduplicateDefaultPermissionOptions', () => { + const ask = { id: 'ask', displayName: 'Default permissions' }; + const legacy = { id: 'default', displayName: 'Default permissions' }; + const plan = { id: 'plan', displayName: 'Plan' }; + const auto = { id: 'auto', displayName: 'Auto' }; + + it.each(['ask', 'default'])('retains the selected %s alias regardless of server order', mode => { + for (const aliases of [[ask, legacy], [legacy, ask]]) { + const input = [plan, ...aliases, auto]; + expect(deduplicateDefaultPermissionOptions(input, mode)).toEqual([ + plan, mode === 'ask' ? ask : legacy, auto, + ]); + expect(input).toHaveLength(4); + } + }); + + it('prefers ask when neither alias is selected, preserving the other modes', () => { + expect(deduplicateDefaultPermissionOptions([plan, legacy, ask, auto], 'auto')) + .toEqual([plan, ask, auto]); + }); + + it('retains a legacy-only host option and its original descriptor', () => { + expect(deduplicateDefaultPermissionOptions([legacy], 'ask')[0]).toBe(legacy); + expect(deduplicateDefaultPermissionOptions([], 'ask')).toEqual([]); + }); +}); + describe('requiresFullAccessConfirmation', () => { it.each(['ask', 'default', 'acceptEdits', 'plan', 'auto', undefined, 'future-mode'])( 'requires confirmation when entering Full access from %j', diff --git a/packages/maker-shared/src/agentCapabilities.ts b/packages/maker-shared/src/agentCapabilities.ts index a52309ef345..2cc3172e149 100644 --- a/packages/maker-shared/src/agentCapabilities.ts +++ b/packages/maker-shared/src/agentCapabilities.ts @@ -9,7 +9,7 @@ export interface MobileModelOption { /** Host-advertised catalog window when available; omitted by older Desktop versions. */ contextWindow?: number; /** 区域门控后的新任务默认标记。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'cursor')[]; } export interface MobileChoiceOption { @@ -359,8 +359,8 @@ function normalizeModelOption(value: unknown): MobileModelOption | null { : undefined; const newSessionDefault = Array.isArray(value.newSessionDefault) ? [...new Set(value.newSessionDefault.filter( - (item): item is 'claude-code' | 'codex' | 'pi' => - item === 'claude-code' || item === 'codex' || item === 'pi', + (item): item is 'claude-code' | 'codex' | 'pi' | 'cursor' => + item === 'claude-code' || item === 'codex' || item === 'pi' || item === 'cursor', ))] : []; return { diff --git a/packages/maker-shared/src/agentTask.ts b/packages/maker-shared/src/agentTask.ts index 5837d87d956..2ab433a63ef 100644 --- a/packages/maker-shared/src/agentTask.ts +++ b/packages/maker-shared/src/agentTask.ts @@ -138,7 +138,7 @@ export function normalizeWorkflowProgressEntries( } export interface AgentTaskUpdate { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'cursor'; taskId: string; parentToolUseId?: string; status: AgentTaskStatus; @@ -333,7 +333,7 @@ export const PI_SUBAGENT_TOOL_NAME = 'subagent'; */ export function normalizeAgentTaskUpdate( data: unknown, - source?: 'claude-code' | 'codex' | 'pi', + source?: 'claude-code' | 'codex' | 'pi' | 'cursor', ): AgentTaskUpdate | null { if (!data || typeof data !== 'object') return null; const raw = data as Record; @@ -348,9 +348,9 @@ export function normalizeAgentTaskUpdate( rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped' ? rawStatus : 'running'; - const provider = raw.provider === 'codex' || raw.provider === 'claude-code' || raw.provider === 'pi' + const provider = raw.provider === 'codex' || raw.provider === 'claude-code' || raw.provider === 'pi' || raw.provider === 'cursor' ? raw.provider - : source === 'codex' || source === 'pi' + : source === 'codex' || source === 'pi' || source === 'cursor' ? source : 'claude-code'; const usageRaw = raw.usage && typeof raw.usage === 'object' ? raw.usage as Record : null; @@ -430,7 +430,7 @@ export function isSameAgentTaskAlias(left: AgentTaskUpdate, right: AgentTaskUpda export function applyAgentTaskUpdateEvent( prevMap: ReadonlyMap | undefined, data: unknown, - source: 'claude-code' | 'codex' | 'pi' | undefined, + source: 'claude-code' | 'codex' | 'pi' | 'cursor' | undefined, nowIso: string, ): Map | null { const update = normalizeAgentTaskUpdate(data, source); @@ -485,7 +485,7 @@ export function findAgentTaskUpdate( */ export interface AgentTaskCardModel { status: AgentTaskStatus; - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** Best title, or null when nothing usable was found (caller supplies its own fallback). */ title: string | null; description?: string; @@ -588,7 +588,7 @@ export function buildAgentTaskCardModel(input: { // callers (AgentTaskCard / listSessionTasks). With no tool name (history // replay of a legacy update card) fall back to the provider heuristic below, // matching AgentTaskCard's claudeProtocolResult. - const providerFallback: 'claude-code' | 'codex' | 'pi' = + const providerFallback: 'claude-code' | 'codex' | 'pi' | 'cursor' = update?.provider ?? (toolName?.startsWith('collab:') ? 'codex' diff --git a/packages/maker-shared/src/botDelegation.ts b/packages/maker-shared/src/botDelegation.ts index c5db4eb3b72..ff6e8617724 100644 --- a/packages/maker-shared/src/botDelegation.ts +++ b/packages/maker-shared/src/botDelegation.ts @@ -43,7 +43,7 @@ export interface BotDelegationArtifact { export interface BotDelegationCapabilitySnapshot { profileVersion: number; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; model: string; capabilitiesSha256: string; identitySha256: string; @@ -114,7 +114,7 @@ function isCapabilitySnapshot(target: unknown): target is BotDelegationCapabilit if (!isRecord(target)) return false; return ( typeof target.profileVersion === 'number' - && (target.agentKind === 'cc' || target.agentKind === 'codex' || target.agentKind === 'pi') + && (target.agentKind === 'cc' || target.agentKind === 'codex' || target.agentKind === 'pi' || target.agentKind === 'cursor') && typeof target.model === 'string' && typeof target.capabilitiesSha256 === 'string' && typeof target.identitySha256 === 'string' diff --git a/packages/maker-shared/src/conversationSearch.ts b/packages/maker-shared/src/conversationSearch.ts index c1125dfcf05..a35e7fbae7a 100644 --- a/packages/maker-shared/src/conversationSearch.ts +++ b/packages/maker-shared/src/conversationSearch.ts @@ -11,7 +11,7 @@ import { collapseWorktreeDirForGrouping } from './worktreePaths.js'; * 不包含桌面本机 SQLite、hybrid / 向量搜索、机器切换栏 origin 解析。 */ -export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi'; +export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; export type ConversationSearchWorkspaceKind = 'project' | 'dialogue'; export type ConversationSearchSessionStatus = 'active' | 'archived' | 'deleted'; export type ConversationSearchOrcaRole = 'lead' | 'worker'; diff --git a/packages/maker-shared/src/deviceLinkContract.ts b/packages/maker-shared/src/deviceLinkContract.ts index f43c48ea896..73c4bd92822 100644 --- a/packages/maker-shared/src/deviceLinkContract.ts +++ b/packages/maker-shared/src/deviceLinkContract.ts @@ -250,7 +250,7 @@ export interface MobileCodexRateLimitResetResult { /** 下一条消息发送时才会应用的跨 Agent 切换意图。 */ export interface MobileSessionAgentSwitchIntent { - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; providerId: string | null; effort?: string; @@ -260,7 +260,7 @@ export interface MobileSessionAgentSwitchIntent { /** desktop 登记 / 取消跨 Agent 意图后的稳定结果。 */ export interface MobileSessionAgentSwitchResult { switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; engineReady: boolean; deferred?: boolean; diff --git a/packages/maker-shared/src/fixtures.ts b/packages/maker-shared/src/fixtures.ts index 32c7b8e8a89..baa3221b56c 100644 --- a/packages/maker-shared/src/fixtures.ts +++ b/packages/maker-shared/src/fixtures.ts @@ -18,7 +18,7 @@ export interface RemoteControlSessionFixture { workingDir: string | null; workspaceKind: 'project' | 'dialogue'; status: 'active' | 'archived' | 'deleted'; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; model: string; effort: string; permissionMode: string; diff --git a/packages/maker-shared/src/orcaTeam.ts b/packages/maker-shared/src/orcaTeam.ts index 3ddd945f8e7..f21c6035933 100644 --- a/packages/maker-shared/src/orcaTeam.ts +++ b/packages/maker-shared/src/orcaTeam.ts @@ -11,8 +11,8 @@ export const ORCA_PREDEFINED_WORKER_ROLES = ['developer', 'designer', 'reviewer' /** 自定义角色名的长度上限(被控端 `maker:worker:create` 同口径校验)。 */ export const ORCA_WORKER_ROLE_MAX_LENGTH = 32; -export type OrcaWorkerAgentKind = 'claude-code' | 'codex' | 'pi'; -export type OrcaWorkerPermissionMode = 'auto' | 'bypassPermissions'; +export type OrcaWorkerAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; +export type OrcaWorkerPermissionMode = 'ask' | 'auto' | 'bypassPermissions'; export type OrcaWorkerStatus = 'idle' | 'running' | 'done' | 'error'; /** @@ -26,6 +26,7 @@ export const DEFAULT_ORCA_WORKER_MODELS: Readonly( + options: readonly T[], + activeMode: string, +): T[] { + const result: T[] = []; + let defaultPermissionsIndex: number | undefined; + for (const option of options) { + if (option.id !== 'default' && option.id !== 'ask') { + result.push(option); + continue; + } + if (defaultPermissionsIndex === undefined) { + defaultPermissionsIndex = result.length; + result.push(option); + continue; + } + const existing = result[defaultPermissionsIndex]; + if (option.id === activeMode + || (existing.id !== activeMode && existing.id === 'default' && option.id === 'ask')) { + result[defaultPermissionsIndex] = option; + } + } + return result; +} diff --git a/packages/maker-shared/src/scheduleForm.ts b/packages/maker-shared/src/scheduleForm.ts index b6136c8a69c..62f02f4269c 100644 --- a/packages/maker-shared/src/scheduleForm.ts +++ b/packages/maker-shared/src/scheduleForm.ts @@ -468,10 +468,11 @@ export function buildMobileScheduleInput(draft: MobileScheduleDraft): RemoteSche const model = draft.model.trim(); if (model) input.model = model; const effort = draft.effort.trim(); - if (isMobileScheduleEffort(effort)) input.effort = effort; + if (draft.agentKind !== 'cursor' && isMobileScheduleEffort(effort)) input.effort = effort; // Fast 对 Codex 与 Pi 都生效(runner 对 claude-code 忽略此字段,并按模型 supportsFastMode // 收口);只序列化 codex 会让 Pi 任务里开的 Fast 被静默丢弃。 if (draft.agentKind === 'codex' || draft.agentKind === 'pi') input.fastMode = draft.fastMode; + if (draft.agentKind === 'cursor') { input.effort = ''; input.fastMode = false; } return input; } diff --git a/packages/maker-shared/src/scheduleModel.ts b/packages/maker-shared/src/scheduleModel.ts index c91ac9fde9f..6c36e23a423 100644 --- a/packages/maker-shared/src/scheduleModel.ts +++ b/packages/maker-shared/src/scheduleModel.ts @@ -468,6 +468,7 @@ function formatRunDuration(ms: number, localizer?: PresentationLocalizer): strin function humanizeAgentKind(agentKind: RemoteSchedule['agentKind']): string { if (agentKind === 'codex') return 'Codex'; if (agentKind === 'pi') return 'Pi'; + if (agentKind === 'cursor') return 'Cursor'; return 'Claude'; } diff --git a/packages/maker-shared/src/scheduleTypes.ts b/packages/maker-shared/src/scheduleTypes.ts index eec8dda4250..27b399ac251 100644 --- a/packages/maker-shared/src/scheduleTypes.ts +++ b/packages/maker-shared/src/scheduleTypes.ts @@ -1,5 +1,5 @@ export type RemoteScheduleStatus = 'active' | 'paused' | 'expired'; -export type RemoteScheduleAgentKind = 'claude-code' | 'codex' | 'pi'; +export type RemoteScheduleAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type RemoteScheduleWorkspaceKind = 'project' | 'dialogue'; export type RemoteScheduleRunStatus = 'running' | 'success' | 'failed' | 'aborted' | 'interrupted' | 'skipped'; export type RemoteScheduleExecutionMode = 'agent' | 'script'; diff --git a/packages/maker-shared/src/sessionActionStrip.ts b/packages/maker-shared/src/sessionActionStrip.ts index d3344a76e0e..8e2e2e6d146 100644 --- a/packages/maker-shared/src/sessionActionStrip.ts +++ b/packages/maker-shared/src/sessionActionStrip.ts @@ -216,7 +216,7 @@ function overviewActionCopy(input: { } function agentLabel(agentKind: SessionActionStripSessionLike['agentKind']): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + return agentKind === 'codex' ? 'Codex' : agentKind === 'cursor' ? 'Cursor' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; } function sessionStatusLabel(status: SessionActionStripSessionLike['status']): string { diff --git a/packages/maker-shared/src/sessionList.ts b/packages/maker-shared/src/sessionList.ts index 1ed68e4fbfa..3fac1ac8f65 100644 --- a/packages/maker-shared/src/sessionList.ts +++ b/packages/maker-shared/src/sessionList.ts @@ -988,7 +988,7 @@ function mergeScheduleInfo(group: readonly RemoteSessionListItem[]): RemoteSessi } function agentLabel(agentKind: RemoteSession['agentKind']): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + return agentKind === 'codex' ? 'Codex' : agentKind === 'cursor' ? 'Cursor' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; } function sessionStatusLabel(status: RemoteSession['status']): string { diff --git a/packages/maker-shared/src/subagentWorkspace.ts b/packages/maker-shared/src/subagentWorkspace.ts index 1ae56c8a81c..e25a20cf13a 100644 --- a/packages/maker-shared/src/subagentWorkspace.ts +++ b/packages/maker-shared/src/subagentWorkspace.ts @@ -8,7 +8,7 @@ * may add opaque `providerRunIds` without changing the product model. */ -export type SubagentProvider = 'claude-code' | 'codex' | 'pi'; +export type SubagentProvider = 'claude-code' | 'codex' | 'pi' | 'cursor'; export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped'; diff --git a/packages/maker-shared/src/systemCard.ts b/packages/maker-shared/src/systemCard.ts index 1b57478026f..4ea18bf7110 100644 --- a/packages/maker-shared/src/systemCard.ts +++ b/packages/maker-shared/src/systemCard.ts @@ -110,7 +110,7 @@ export function buildSystemCardData( return { workingDir: session?.workingDir ?? '' }; } return { - agent: session?.agentKind === 'codex' ? 'Codex' : session?.agentKind === 'pi' ? 'Pi' : 'Claude Code', + agent: session?.agentKind === 'codex' ? 'Codex' : session?.agentKind === 'cursor' ? 'Cursor' : session?.agentKind === 'pi' ? 'Pi' : 'Claude Code', fastMode: session?.fastMode === true, model: session?.model ?? '', permissionMode: session?.permissionMode ?? '', diff --git a/packages/model-providers/src/__tests__/catalog.test.ts b/packages/model-providers/src/__tests__/catalog.test.ts index 8c7699e023a..92173fe73f4 100644 --- a/packages/model-providers/src/__tests__/catalog.test.ts +++ b/packages/model-providers/src/__tests__/catalog.test.ts @@ -171,6 +171,7 @@ describe("bundled catalog validity (dynamic-first contract)", () => { "xai", "xd", "gemini", + "cursor", ]); expect(BUNDLED_CATALOG.providers.every((p) => p.source === "builtin")).toBe( true, @@ -288,7 +289,12 @@ describe("bundled catalog validity (dynamic-first contract)", () => { it("provides routing + a models[agent] array for every agent the provider declares", () => { for (const p of BUNDLED_CATALOG.providers) { for (const a of p.agents) { - expect(p.routing[a], `${p.id} routing[${a}]`).toBeTruthy(); + if (a === 'cursor') { + expect(p.id).toBe('cursor'); + expect(p.routing[a]).toBeUndefined(); // Native ACP has no HTTP route. + } else { + expect(p.routing[a], `${p.id} routing[${a}]`).toBeTruthy(); + } expect(Array.isArray(p.models[a]), `${p.id} models[${a}]`).toBe(true); } } diff --git a/packages/model-providers/src/__tests__/cursorModelCompatibility.test.ts b/packages/model-providers/src/__tests__/cursorModelCompatibility.test.ts new file mode 100644 index 00000000000..468bf0ea2a0 --- /dev/null +++ b/packages/model-providers/src/__tests__/cursorModelCompatibility.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { BUNDLED_CATALOG } from '../catalog.js'; +import { chatEligibleSourcesForModel, getModel, providerOffersModel, type ProviderView } from '../registry.js'; +import type { CatalogModel } from '../types.js'; + +function cursorProvider(models: CatalogModel[] = []): ProviderView { + return { ...BUNDLED_CATALOG.providers.find(provider => provider.id === 'cursor')!, connected: true, + models: { cursor: models } }; +} +const auto: CatalogModel = { id: 'default', name: 'Auto', contextWindow: 0, + efforts: [], defaultEffort: null, newSessionDefault: ['cursor'] }; + +describe('saved Cursor default routes', () => { + it('keeps an old task usable and labels it from the native default without advertising a duplicate choice', () => { + const provider = cursorProvider([auto]); + expect(getModel(provider, 'cursor-default', 'cursor')).toMatchObject({ id: 'cursor-default', name: 'Auto' }); + expect(chatEligibleSourcesForModel([provider], 'cursor-default', 'cursor')).toEqual([provider]); + expect(providerOffersModel(provider, 'cursor-default', 'cursor')).toBe(false); + expect(provider.models.cursor?.map(model => model.id)).toEqual(['default']); + }); + it('requires a discovered native default and never supplies invented controls or aliases to other sources', () => { + const provider = cursorProvider([{ ...auto, id: 'native-grok', name: 'Grok', efforts: ['high'], supportsFastMode: true }]); + expect(getModel(provider, 'cursor-default', 'cursor')).toMatchObject({ name: 'Grok', efforts: [], defaultEffort: null, supportsFastMode: false }); + expect(getModel(cursorProvider(), 'cursor-default', 'cursor')).toBeUndefined(); + expect(getModel(cursorProvider([{ ...auto, newSessionDefault: undefined }]), 'cursor-default', 'cursor')).toBeUndefined(); + expect(getModel({ ...provider, id: 'other' }, 'cursor-default', 'cursor')).toBeUndefined(); + expect(getModel({ ...provider, source: 'user' }, 'cursor-default', 'cursor')).toBeUndefined(); + }); + it('preserves disconnected, suspended and disabled-model restrictions for the old route', () => { + const provider = cursorProvider([auto]); + for (const unavailable of [{ ...provider, connected: false }, { ...provider, suspended: true }, + cursorProvider([{ ...auto, disabled: true }])]) { + expect(chatEligibleSourcesForModel([unavailable], 'cursor-default', 'cursor')).toEqual([]); + } + }); +}); diff --git a/packages/model-providers/src/__tests__/cursorModelGroups.test.ts b/packages/model-providers/src/__tests__/cursorModelGroups.test.ts new file mode 100644 index 00000000000..71374873271 --- /dev/null +++ b/packages/model-providers/src/__tests__/cursorModelGroups.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { CURSOR_MODEL_GROUPS as groups, cursorModelGroup, providerModelDisplayGroups, providerModelDisplayGroupTitle } from '../cursorModelGroups.js'; + +describe('Cursor native model display groups', () => { + it('keeps Auto separate from both documented pools and does not classify unknown first-party versions', () => { + expect(cursorModelGroup('default', 'Auto')).toBe(groups.auto); + expect(cursorModelGroup('grok-4.7', 'Grok 4.7')).toBe(groups.models); + expect(cursorModelGroup('composer-2.5', 'Composer 2.5')).toBe(groups.models); + expect(cursorModelGroup('claude-opus-5-5', 'Claude Opus 5.5')).toBe(groups.other); + expect(cursorModelGroup('gpt-5.6-sol', 'GPT-5.6 Sol')).toBe(groups.other); + expect(cursorModelGroup('grok-future', 'Future Grok')).toBeUndefined(); + expect(cursorModelGroup('composer-future', 'Future Composer')).toBeUndefined(); + expect(cursorModelGroup('opaque-new-offer', 'New model')).toBeUndefined(); + }); + it('prefers explicit native headings to the verified fallback and preserves new native groups', () => { + expect(cursorModelGroup('claude-opus-5-5', 'Claude Opus 5.5', 'Cursor Models')).toBe(groups.models); + expect(cursorModelGroup('grok-4.7', 'Grok 4.7', 'Other Models')).toBe(groups.other); + expect(cursorModelGroup('opaque', 'Opaque model', 'Experimental')).toBe('cursor:native:Experimental'); + }); + it('partitions existing offers without changing their identity, order, or configuration', () => { + const items = [ + { id: 'claude', group: groups.other, effort: 'medium' }, + { id: 'grok', group: groups.models, effort: 'xhigh' }, + { id: 'auto', group: groups.auto, effort: null }, + { id: 'composer', group: groups.models, fast: true }, + { id: 'future' }, + ]; + const result = providerModelDisplayGroups('cursor', items, item => item.group); + expect(result.map(group => [group.group, group.items.map(item => item.id)])).toEqual([ + [groups.auto, ['auto']], [groups.models, ['grok', 'composer']], [groups.other, ['claude']], [null, ['future']], + ]); + expect(result[1].items[0]).toBe(items[1]); + expect(result.flatMap(group => group.items)).toHaveLength(items.length); + expect(items.map(item => item.id)).toEqual(['claude', 'grok', 'auto', 'composer', 'future']); + }); + it('leaves other suppliers intact and never fabricates a group for an empty catalog', () => { + const items = [{ id: 'grok', group: groups.models }, { id: 'claude', group: groups.other }]; + expect(providerModelDisplayGroups('other-account', items, item => item.group)).toEqual([{ group: null, items }]); + expect(providerModelDisplayGroups('cursor', [], () => undefined)).toEqual([]); + }); + it('retains the computer identity on remote pool headings', () => { + const labels = { models: 'Cursor Models', other: 'Other Models' }; + expect(providerModelDisplayGroupTitle('Cursor', groups.models, labels)).toBe('Cursor Models'); + expect(providerModelDisplayGroupTitle('Cursor', groups.other, labels)).toBe('Other Models'); + expect(providerModelDisplayGroupTitle('blue mac · Cursor', groups.models, labels)).toBe('blue mac · Cursor · Cursor Models'); + expect(providerModelDisplayGroupTitle('Cursor', groups.auto, labels)).toBe('Cursor'); + expect(providerModelDisplayGroupTitle('Cursor', 'cursor:native:Experimental', labels)).toBe('Experimental'); + }); +}); diff --git a/packages/model-providers/src/__tests__/cursorNative.test.ts b/packages/model-providers/src/__tests__/cursorNative.test.ts new file mode 100644 index 00000000000..2c1d62c289c --- /dev/null +++ b/packages/model-providers/src/__tests__/cursorNative.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { BUNDLED_CATALOG, parseCatalog } from '../catalog.js'; +import { connectedProvidersForAgent, resolveRoute } from '../registry.js'; +import { unifiedModelEntries } from '../unifiedSelection.js'; +import { modelProtocolComparison } from '../modelProtocol.js'; +import type { CatalogModel, Provider } from '../types.js'; + +const cursor = BUNDLED_CATALOG.providers.find((provider) => provider.id === 'cursor')!; +const model: CatalogModel = { + id: 'account-model', name: 'Account model', contextWindow: 100_000, + efforts: [], defaultEffort: null, +}; + +describe('native Cursor provider', () => { + it('ships no invented model or HTTP route', () => { + expect(cursor).toMatchObject({ agents: ['cursor'], models: { cursor: [] }, routing: {} }); + expect(() => parseCatalog(BUNDLED_CATALOG)).not.toThrow(); + }); + it('selects only live native membership without reusing HTTP bridges', () => { + const provider = { ...cursor, connected: true, models: { cursor: [model] } }; + expect(connectedProvidersForAgent([provider], 'cursor')).toEqual([provider]); + expect(connectedProvidersForAgent([{ ...provider, connected: false }], 'cursor')).toEqual([]); + expect(unifiedModelEntries({ providers: [provider] })).toMatchObject([{ candidates: ['cursor'], recommended: 'cursor' }]); + expect(resolveRoute([provider], 'cursor', model.id, 'cursor')).toBeNull(); + expect(modelProtocolComparison(provider, { cursor: model }).forAgent('cursor')).toEqual({ + harness: null, outbound: null, localConversion: false, mode: 'unknown', + }); + }); + it('rejects Cursor compatibility routes and third-party native impersonation', () => { + const parse = (provider: Provider) => parseCatalog({ ...BUNDLED_CATALOG, providers: [provider] }); + expect(() => parse({ ...cursor, id: 'custom-cursor' })).toThrow(); + expect(() => parse({ ...cursor, routing: { cursor: { + upstream: 'https://example.com', authStrategy: 'none', + } } })).toThrow(); + }); +}); diff --git a/packages/model-providers/src/__tests__/source-registry.test.ts b/packages/model-providers/src/__tests__/source-registry.test.ts index 937431c6292..3995b8f6f32 100644 --- a/packages/model-providers/src/__tests__/source-registry.test.ts +++ b/packages/model-providers/src/__tests__/source-registry.test.ts @@ -255,6 +255,7 @@ describe("mergeWithBundled", () => { "xai", "xd", "gemini", + "cursor", ]); // 远端独有的新供应商追加在 bundled 之后。 const withExtra: Catalog = { @@ -270,6 +271,7 @@ describe("mergeWithBundled", () => { "xai", "xd", "gemini", + "cursor", "newvendor", ]); }); @@ -1310,6 +1312,7 @@ describe("loadCatalog", () => { expect(cat.version).toBe(BUNDLED_CATALOG.version); expect(cat.providers.map((p) => p.id).sort()).toEqual([ "anthropic", + "cursor", "gemini", "openai", "xai", diff --git a/packages/model-providers/src/__tests__/unifiedSelection.test.ts b/packages/model-providers/src/__tests__/unifiedSelection.test.ts index 8fde11b7809..6d449933281 100644 --- a/packages/model-providers/src/__tests__/unifiedSelection.test.ts +++ b/packages/model-providers/src/__tests__/unifiedSelection.test.ts @@ -273,7 +273,7 @@ describe('candidateAgentsForModel', () => { 'codex', 'pi', ]); - expect(UNIFIED_AGENT_PRIORITY).toEqual(['claude-code', 'codex', 'pi']); + expect(UNIFIED_AGENT_PRIORITY).toEqual(['claude-code', 'codex', 'pi', 'cursor']); }); it('bridge 壳与 root 条目寻址同一逻辑模型,候选是并集', () => { diff --git a/packages/model-providers/src/builtin.ts b/packages/model-providers/src/builtin.ts index 82690a0eb50..07ab10f50f8 100644 --- a/packages/model-providers/src/builtin.ts +++ b/packages/model-providers/src/builtin.ts @@ -259,12 +259,25 @@ const GEMINI_PROVIDER: Provider = { }; /** 内置供应商(顺序契约见文件头;gemini 追加在 xd 之后,聊天分段与 first-wins 契约零影响)。 */ +/** Cursor owns authentication and execution through its native ACP CLI. Membership is discovered live. */ +const CURSOR_PROVIDER: Provider = { + id: 'cursor', + name: 'Cursor', + source: 'builtin', + agents: ['cursor'], + auth: { method: 'none' }, + access: { kind: 'subscription', product: 'Cursor' }, + routing: {}, + models: { cursor: [] }, +}; + export const BUILTIN_PROVIDERS: Provider[] = [ ANTHROPIC_PROVIDER, OPENAI_PROVIDER, XAI_PROVIDER, XD_PROVIDER, GEMINI_PROVIDER, + CURSOR_PROVIDER, ].map((provider) => projectProviderMediaModels(provider, bundledModelRegistry, { addDeclared: true })); /** 打包进 App 的内置目录(离线兜底 / 远端拉取失败时使用)。 */ diff --git a/packages/model-providers/src/byok.ts b/packages/model-providers/src/byok.ts index 0be80049a2e..953a0bfe783 100644 --- a/packages/model-providers/src/byok.ts +++ b/packages/model-providers/src/byok.ts @@ -21,7 +21,7 @@ export type ByokModel = Omit & { nativeApi?: ByokNativeApi; perAgent: Partial< Record< - "claude-code" | "codex" | "pi", + "claude-code" | "codex" | "pi" | "cursor", Omit, "wireProtocol"> & { wireProtocol: ByokWireProtocol; } diff --git a/packages/model-providers/src/catalog.ts b/packages/model-providers/src/catalog.ts index 10f91ec16d8..6047227058b 100644 --- a/packages/model-providers/src/catalog.ts +++ b/packages/model-providers/src/catalog.ts @@ -34,7 +34,7 @@ import { isProviderRequestPath } from './provider-url.js'; export { BUNDLED_CATALOG, BUILTIN_PROVIDERS, claudeSubscriptionOnlyForClaudeCode } from './builtin.js'; -const AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +const AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi', 'cursor']; const EFFORTS: readonly Effort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; const WIRE_PROTOCOLS = ['anthropic-messages', 'openai-responses', 'openai-chat', 'google-generative-ai'] as const; @@ -317,6 +317,18 @@ function validateProvider(p: Provider, allowEmptyModalities = false): void { ); // 约束:供应商声明支持的每个 agent,都必须有对应路由描述符 + per-agent 模型数组。 for (const agent of p.agents) { + if (agent === 'cursor') { + assert(p.id === 'cursor' && p.source === 'builtin' && p.auth.method === 'none', + 'Cursor only supports its native CLI provider'); + assert(p.agents.length === 1 && Object.keys(p.routing).length === 0, + 'Cursor cannot declare HTTP or compatibility routes'); + assert(Array.isArray(p.models.cursor), 'Cursor requires a native model list'); + for (const model of p.models.cursor) { + assert(model.route === undefined, 'Cursor models cannot declare HTTP routes'); + validateModel(model, p.id, agent, undefined); + } + continue; + } const routing = p.routing[agent]; assert(routing, `provider '${p.id}' declares agent '${agent}' but no routing[${agent}]`); if (routing.upstream !== undefined) { diff --git a/packages/model-providers/src/cursorModelGroups.ts b/packages/model-providers/src/cursorModelGroups.ts new file mode 100644 index 00000000000..b57aec9ee7d --- /dev/null +++ b/packages/model-providers/src/cursorModelGroups.ts @@ -0,0 +1,61 @@ +/** Cursor picker groups are display metadata, never routing or billing authorization. */ +export const CURSOR_MODEL_GROUPS = { + auto: 'cursor:auto', + models: 'cursor:models', + other: 'cursor:other', +} as const; + +// Verified against https://prod.cursor.com/help/models-and-usage/usage-limits +// on 2026-10-09. ACP currently omits pool labels. Only classify offered models; +// unknown first-party versions stay unclassified until upstream identifies them. +const CURSOR_POOL_MODELS = new Set(['grok-4.7', 'grok-4.6', 'grok-4.5', 'composer-2.5']); +const THIRD_PARTY_MODEL = /^(?:claude-|gpt-|codex-|gemini-|muse-|kimi-|glm-)/; +const NATIVE_GROUP_PREFIX = 'cursor:native:'; +const CANONICAL_GROUPS = new Set(Object.values(CURSOR_MODEL_GROUPS)); + +export function cursorModelGroup(id: string, name: string, nativeGroup?: string): string | undefined { + if (name === 'Auto') return CURSOR_MODEL_GROUPS.auto; + if (nativeGroup) { + if (nativeGroup === 'Cursor Models') return CURSOR_MODEL_GROUPS.models; + if (nativeGroup === 'Other Models') return CURSOR_MODEL_GROUPS.other; + // Catalog group metadata is bounded to 128 characters. + return nativeGroup.length <= 128 - NATIVE_GROUP_PREFIX.length ? `${NATIVE_GROUP_PREFIX}${nativeGroup}` : undefined; + } + if (CURSOR_POOL_MODELS.has(id)) return CURSOR_MODEL_GROUPS.models; + if (THIRD_PARTY_MODEL.test(id)) return CURSOR_MODEL_GROUPS.other; + return undefined; +} + +/** Split within a provider; retain native order, source identity and unclassified offers. */ +export function providerModelDisplayGroups( + providerId: string, + items: readonly T[], + groupOf: (item: T) => string | undefined, +): { group: string | null; items: T[] }[] { + if (!items.length) return []; + if (providerId !== 'cursor') return [{ group: null, items: [...items] }]; + const groups = new Map(); + for (const item of items) { + const value = groupOf(item); + const group = value && (CANONICAL_GROUPS.has(value) + || value.startsWith(NATIVE_GROUP_PREFIX)) ? value : null; + const bucket = groups.get(group); + if (bucket) bucket.push(item); + else groups.set(group, [item]); + } + const order: (string | null)[] = [CURSOR_MODEL_GROUPS.auto, CURSOR_MODEL_GROUPS.models, CURSOR_MODEL_GROUPS.other]; + for (const group of groups.keys()) if (!order.includes(group)) order.push(group); + return order.filter(group => groups.has(group)).map(group => ({ group, items: groups.get(group)! })); +} + +/** Official pool names are localized by each client; remote headings retain their computer name. */ +export function providerModelDisplayGroupTitle( + provider: string, + group: string | null | undefined, + labels: { models: string; other: string }, +): string { + const label = group === CURSOR_MODEL_GROUPS.models ? labels.models + : group === CURSOR_MODEL_GROUPS.other ? labels.other + : group?.startsWith(NATIVE_GROUP_PREFIX) ? group.slice(NATIVE_GROUP_PREFIX.length) : null; + return label ? provider === 'Cursor' ? label : `${provider} · ${label}` : provider; +} diff --git a/packages/model-providers/src/index.ts b/packages/model-providers/src/index.ts index 79177f79bc6..5a6794225e1 100644 --- a/packages/model-providers/src/index.ts +++ b/packages/model-providers/src/index.ts @@ -333,3 +333,4 @@ export { providerSetupLink } from './providerSetupLinks.js'; export { providerPresetOAuth, providerPresetOAuthRuntimes, providerOAuthContract } from './providerPresetOAuth.js'; export { alignModelApiRoute, providerWireProtocolForApi, providerBaseUrlForApi } from "./providerInterfaceRoutes.js"; +export { CURSOR_MODEL_GROUPS, cursorModelGroup, providerModelDisplayGroups, providerModelDisplayGroupTitle } from './cursorModelGroups.js'; diff --git a/packages/model-providers/src/invocation.ts b/packages/model-providers/src/invocation.ts index 39a3385ca16..9a10c986233 100644 --- a/packages/model-providers/src/invocation.ts +++ b/packages/model-providers/src/invocation.ts @@ -137,7 +137,7 @@ function availableModelsFor( } function isKnownAgent(v: string | null | undefined): v is AgentKind { - return v === 'claude-code' || v === 'codex' || v === 'pi'; + return v === 'claude-code' || v === 'codex' || v === 'pi' || v === 'cursor'; } /** diff --git a/packages/model-providers/src/modelProtocol.ts b/packages/model-providers/src/modelProtocol.ts index 506981bad7b..cbfad0f9026 100644 --- a/packages/model-providers/src/modelProtocol.ts +++ b/packages/model-providers/src/modelProtocol.ts @@ -32,6 +32,10 @@ export function modelProtocolComparison( const forAgent = (agent: AgentKind) => { const model = models[agent]; if (!model) return null; + // Cursor ACP owns its transport; do not infer an Anthropic HTTP bridge. + if (agent === 'cursor') return { + harness: null, outbound: null, localConversion: false, mode: 'unknown' as const, + }; const routing = provider.routing?.[agent]; const outbound = model.api ?? ( agent === 'pi' diff --git a/packages/model-providers/src/provider-identity.ts b/packages/model-providers/src/provider-identity.ts index c838b715ff0..3c2ce6af8e0 100644 --- a/packages/model-providers/src/provider-identity.ts +++ b/packages/model-providers/src/provider-identity.ts @@ -8,6 +8,7 @@ export function isLocalOnlyProviderForAgent( provider: Pick, agent: AgentKind, ): boolean { + if (agent === 'cursor') return true; if (agent === 'codex' && provider.routing?.codex?.wireProtocol === 'openai-chat') return true; if (provider.auth?.method === 'oauth') { const brand = providerCatalogId(provider); diff --git a/packages/model-providers/src/providerBranding.ts b/packages/model-providers/src/providerBranding.ts index e9da20f29fe..946a36539ab 100644 --- a/packages/model-providers/src/providerBranding.ts +++ b/packages/model-providers/src/providerBranding.ts @@ -10,6 +10,7 @@ // Additional supplier marks: Lobe Icons 5.14.0 (MIT; included in THIRD-PARTY-NOTICES). export const PROVIDER_LOGO_PATHS = { + cursor: 'M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23', // Original S monogram for the self-hosted Sub2API connection (not upstream artwork). sub2api: 'M5 3h14v3H8v4h8a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H5v-3h11v-5H8a3 3 0 0 1-3-3V3z', antgroup: "M15.984 1.383c-.259.593-.877.914-1.334 1.348-.702.669-.998 1.726-1.218 2.682 3.37.775 5.995 3.532 6.486 7.025.566 4.016-1.85 7.859-5.744 9.134-3.894 1.276-8.153-.38-10.126-3.935A8.339 8.339 0 016.457 6.833a18.703 18.703 0 01-.376-.802l-.174-.408a8.698 8.698 0 01-.618-1.987 7.523 7.523 0 01-.051-.37 3.717 3.717 0 01-.024-.34 2.506 2.506 0 01.132-.872 1.26 1.26 0 01.384-.594.842.842 0 011.165.11c.694.82.169 2.078.142 3.02.009.575.111 1.144.301 1.684l-.103.052a8.404 8.404 0 001.318-.79c.28-.243.45-.742.781-.864.33-.121.682.146.946.297.278.145.587.219.901.214l.462.007c.076.002.152.005.226.011.212.01.422.026.63.05l.196-1.08c.04-.216.082-.43.126-.645l.141-.641c.14-.7.42-1.367.823-1.96.305-.431.75-.746 1.262-.89.877-.217 1.24.629.937 1.348zm-2.244 5.44c-2.841.002-4.898 2.532-4.628 5.64.27 3.11 2.781 5.642 5.613 5.666 2.832.024 4.932-2.512 4.658-5.644-.273-3.133-2.802-5.666-5.643-5.663zm.357.57a4.707 4.707 0 013.304 1.445 5.703 5.703 0 011.643 3.49 4.841 4.841 0 01-1.141 3.705v.015a3.923 3.923 0 01-2.808 1.303 4.664 4.664 0 01-3.367-1.387 5.678 5.678 0 01-1.71-3.518 4.832 4.832 0 011.14-3.753 3.923 3.923 0 012.813-1.3h.126z", @@ -123,6 +124,7 @@ const PROVIDER_LOGO_KIND_BY_ID: Readonly> = { xd: 'xd', xai: 'xai', gemini: 'google', + cursor: 'cursor', openrouter: 'openrouter', deepseek: 'deepseek', 'zhipu-glm-cn': 'zhipu', diff --git a/packages/model-providers/src/registry.ts b/packages/model-providers/src/registry.ts index 8b4c08b9880..7ef78a405b9 100644 --- a/packages/model-providers/src/registry.ts +++ b/packages/model-providers/src/registry.ts @@ -200,6 +200,8 @@ export function buildRegistry( /** 该供应商的指定 runtime 是否可参与选择 / 路由。 */ function hasEnabledAgentRuntime(provider: Provider, agent: AgentKind): boolean { + if (agent === 'cursor') return provider.id === 'cursor' && provider.source === 'builtin' + && provider.agents.includes('cursor'); const routing = provider.routing?.[agent]; return provider.agents.includes(agent) && routing !== undefined && routing.disabled !== true; } @@ -241,7 +243,19 @@ export function getModel( modelId: string, agent: AgentKind, ): CatalogModel | undefined { - return (provider.models[agent] ?? []).find((m) => m.id === modelId); + const models = provider.models[agent] ?? []; + const exact = models.find((m) => m.id === modelId); + if (exact) return exact; + // Older Cursor tasks omitted an explicit model through this private alias. + // Resolve its label/source only from a live native default, without inserting + // a second picker row or guessing the saved native session's parameter state. + if (provider.id === 'cursor' && provider.source === 'builtin' && agent === 'cursor' && modelId === 'cursor-default') { + const nativeDefault = models.find(model => model.newSessionDefault?.includes('cursor')); + if (nativeDefault) return { ...nativeDefault, id: modelId, contextWindow: 0, + efforts: [], defaultEffort: null, supportsFastMode: false, defaultEnabled: false, + newSessionDefault: undefined }; + } + return undefined; } /** @@ -268,7 +282,8 @@ export function sourcesForModel( (includeDisabled || !p.suspended) && (!onlyConnected || p.connected) && hasEnabledAgentRuntime(p, agent) && - providerOffersModel(p, modelId, agent) && + // Metadata lookup also recognizes the private alias in saved Cursor tasks. + getModel(p, modelId, agent) !== undefined && (includeDisabled || getModel(p, modelId, agent)?.disabled !== true), ); } diff --git a/packages/model-providers/src/types.ts b/packages/model-providers/src/types.ts index f12e861a3a4..b8258615fd2 100644 --- a/packages/model-providers/src/types.ts +++ b/packages/model-providers/src/types.ts @@ -21,7 +21,7 @@ import type { ModelMetadata } from "./modelMetadataLayers.js"; import type { ModelRegistry } from "./modelAccessBean.js"; /** 承载模型的 agent runtime —— 与 maker-core AgentKind 对齐。 */ -export type AgentKind = "claude-code" | "codex" | "pi"; +export type AgentKind = "claude-code" | "codex" | "pi" | "cursor"; /** 推理强度档位 —— 与 maker-core Effort 对齐。 */ export type Effort = diff --git a/packages/model-providers/src/unifiedSelection.ts b/packages/model-providers/src/unifiedSelection.ts index c0f309ce2a9..d6db3a11faf 100644 --- a/packages/model-providers/src/unifiedSelection.ts +++ b/packages/model-providers/src/unifiedSelection.ts @@ -78,7 +78,7 @@ import type { AgentKind, CatalogModel, Effort, PiModelApi, Provider } from './ty * apps/desktop/src/renderer/lib/providerModels.ts)一致,也与 user-provider.ts 的 * `AGENT_ORDER` 一致 —— 三处同序不是巧合:cc 是覆盖面最广的运行时,pi 是通用兜底。 */ -export const UNIFIED_AGENT_PRIORITY: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +export const UNIFIED_AGENT_PRIORITY: readonly AgentKind[] = ['claude-code', 'codex', 'pi', 'cursor']; /** * 已有协议或原生底座暂不可用时,保留历史兼容回落序。 @@ -118,6 +118,7 @@ const BRIDGE_NAMESPACE_PREFIXES: readonly string[] = [CHATGPT_MODEL_PREFIX, XAI_ const BUILTIN_ROOT_PREFERENCE: ReadonlyMap = new Map([ ['openai', 'codex'], ['anthropic', 'claude-code'], + ['cursor', 'cursor'], ]); /** diff --git a/packages/model-providers/src/user-provider.ts b/packages/model-providers/src/user-provider.ts index 48913ca12db..0d268fc6556 100644 --- a/packages/model-providers/src/user-provider.ts +++ b/packages/model-providers/src/user-provider.ts @@ -214,7 +214,7 @@ function registryEffortMetadata( modelId: string, agent: AgentKind, ): RegistryEffortMetadata | undefined { - if (agent === "pi" || !registry) return undefined; + if (agent === "pi" || agent === "cursor" || !registry) return undefined; // Stage 1 — exact lookup: only the original modelId. const exactMatches = expandedRegistryEntries(registry).filter((entry) => diff --git a/packages/orca-workflow/src/__tests__/orca-bridge-mcp.test.ts b/packages/orca-workflow/src/__tests__/orca-bridge-mcp.test.ts index 6a2000756c9..c50a15702b5 100644 --- a/packages/orca-workflow/src/__tests__/orca-bridge-mcp.test.ts +++ b/packages/orca-workflow/src/__tests__/orca-bridge-mcp.test.ts @@ -295,10 +295,12 @@ describe('orca_worker_bridge MCP helpers', () => { }); it.each([ - ['descendant', true, 'NESTED_AGENT_NOT_ALLOWED'], - ['unknown', true, 'CALLER_PROVENANCE_REQUIRED'], - ['root', false, 'CALLER_PROVENANCE_REQUIRED'], - ] as const)('rejects %s/attested=%s before every send side effect', async ( + ['codex', 'descendant', true, 'NESTED_AGENT_NOT_ALLOWED'], + ['codex', 'unknown', true, 'CALLER_PROVENANCE_REQUIRED'], + ['codex', 'root', false, 'CALLER_PROVENANCE_REQUIRED'], + ['cursor', 'unknown', false, 'CALLER_PROVENANCE_REQUIRED'], + ] as const)('rejects %s %s/attested=%s before every send side effect', async ( + agentKind, mcpCallerKind, mcpCallerAttested, expectedCode, @@ -338,7 +340,7 @@ describe('orca_worker_bridge MCP helpers', () => { }, }); const runtimeContext: McpProviderContext = { - agentKind: 'codex', + agentKind, workingDir: '/repo', sessionId: 'worker-session-1', mcpCallerKind, @@ -350,7 +352,7 @@ describe('orca_worker_bridge MCP helpers', () => { }, }; const server = getServer(provider, { - agentKind: 'codex', + agentKind, workingDir: '/repo', getSessionContext: () => runtimeContext, }); diff --git a/scripts/__tests__/restart-desktop-remote.test.mjs b/scripts/__tests__/restart-desktop-remote.test.mjs index 5237d6e5028..7bef88a35a5 100644 --- a/scripts/__tests__/restart-desktop-remote.test.mjs +++ b/scripts/__tests__/restart-desktop-remote.test.mjs @@ -1054,6 +1054,15 @@ test("devEnvPrefix omits harness envs when unset (whitelist stays opt-in)", () = assert.equal(devEnvPrefix({}, "darwin"), "CINDY_CUA_SMOKE='0' "); }); +test("dev watcher polling is opt-in and reaches the Terminal child", () => { + const env = { XDT_DESKTOP_DEV_WATCH_POLLING: "1" }; + assert.match(devEnvPrefix(env, "darwin"), /XDT_DESKTOP_DEV_WATCH_POLLING='1'/); + assert.match(devEnvPrefix(env, "win32"), /XDT_DESKTOP_DEV_WATCH_POLLING=1/); + assert.ok(!unsetKeys(darwinStaleDevEnvUnset(env)).includes("XDT_DESKTOP_DEV_WATCH_POLLING")); + assert.ok(unsetKeys(darwinStaleDevEnvUnset({})).includes("XDT_DESKTOP_DEV_WATCH_POLLING")); + assert.equal(devEnvPrefix({ XDT_DESKTOP_DEV_WATCH_POLLING: "true" }, "darwin"), "CINDY_CUA_SMOKE='0' "); +}); + test("devEnvPrefix passes the explicit isolated OAuth write escape hatch", () => { assert.equal( devEnvPrefix( diff --git a/scripts/restart-desktop-remote.mjs b/scripts/restart-desktop-remote.mjs index 8fe4ef00220..1a2e5f12706 100644 --- a/scripts/restart-desktop-remote.mjs +++ b/scripts/restart-desktop-remote.mjs @@ -28,7 +28,12 @@ const rootDir = path.resolve(__dirname, '..'); const gracefulTimeoutMs = 3000; const forceTimeoutMs = 5000; const pollIntervalMs = 150; -const startupReadyTimeoutMs = 120_000; +// Opt-in polling makes the first Vite build slower; keep the readiness verdict +// pending long enough for the window to finish its cold module load. +const startupReadyTimeoutMs = process.platform === 'darwin' + && process.env.XDT_DESKTOP_DEV_WATCH_POLLING === '1' + ? 240_000 + : 120_000; export const ISOLATED_AUTH_LAUNCH_PROOF_FILE = '.isolated-auth-launch-proof.json'; const isolatedAuthLaunchProofTtlMs = 10 * 60_000; const forceKillLabel = process.platform === 'win32' ? 'taskkill /F /T' : 'kill -9'; @@ -971,6 +976,8 @@ function devEnvEntries(env) { ['XDT_ISOLATED_AUTH_PROOF', env.XDT_ISOLATED_AUTH_PROOF], // CDP 端口覆写(bootstrap-electron 消费): 并行多开沙箱时给后起实例换端口。 ['XDT_CDP_PORT', env.XDT_CDP_PORT], + // 文件元数据事件异常频繁时,显式让 Vite/Rollup 改用轮询监听。 + ['XDT_DESKTOP_DEV_WATCH_POLLING', env.XDT_DESKTOP_DEV_WATCH_POLLING === '1' ? '1' : undefined], // A long-lived Terminal can retain a previous smoke run's environment. // Override its value even when this invocation did not request the smoke. ['CINDY_CUA_SMOKE', ['1', 'cursor-goal'].includes(env.CINDY_CUA_SMOKE) ? env.CINDY_CUA_SMOKE : '0'], diff --git a/scripts/smoke-cursor-acp.mts b/scripts/smoke-cursor-acp.mts new file mode 100644 index 00000000000..3d5b835f336 --- /dev/null +++ b/scripts/smoke-cursor-acp.mts @@ -0,0 +1,201 @@ +/** Opt-in account-backed smoke; never runs during CI/unit tests. + * HOME must select the CLI's native authenticated profile. Credentials are not read here. + * CINDY_CURSOR_TEST_BINARY=/absolute/agent node --import tsx scripts/smoke-cursor-acp.mts + */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { CursorAgent, CURSOR_DEFAULT_MODEL } from '../packages/maker-core/src/agents/cursor/index.js'; +import type { AgentSessionHandle } from '../packages/maker-core/src/agents/base-agent.js'; +import type { AgentEvent, InteractionRequest } from '../packages/maker-core/src/types/events.js'; +import type { Logger } from '../packages/maker-core/src/interfaces/logger.js'; +import { createDesktopCursorAuthAdapter } from '../apps/desktop/src/main/maker-host/cursor-auth-adapter.js'; + +const binaryPath = process.env.CINDY_CURSOR_TEST_BINARY; +if (!binaryPath || !path.isAbsolute(binaryPath)) throw new Error('Set CINDY_CURSOR_TEST_BINARY to the official absolute Cursor CLI executable'); +const auth = createDesktopCursorAuthAdapter(binaryPath); +if (!(await auth.getState()).authenticated) throw new Error('Cursor native login required; run agent login with the intended HOME first'); +const checkpointPath = process.env.CINDY_CURSOR_SMOKE_STATE; +const selectedStage = process.env.CINDY_CURSOR_SMOKE_STAGE; +if (Boolean(selectedStage) !== Boolean(checkpointPath)) throw new Error('Checkpoint and stage must be provided together'); +if (selectedStage && !['context', 'file', 'cancel'].includes(selectedStage)) throw new Error('Unknown smoke stage'); +const checkpoint = checkpointPath ? JSON.parse(await readFile(checkpointPath, 'utf8')) : undefined; +if (checkpoint && (checkpoint.kind !== 'cindy-cursor-smoke-v1' || !path.basename(checkpoint.cwd).startsWith('cindy-cursor-live-') || !path.isAbsolute(checkpoint.cwd) || typeof checkpoint.sessionId !== 'string')) throw new Error('Invalid synthetic checkpoint'); +const workingDir = checkpoint?.cwd ?? await mkdtemp(path.join(tmpdir(), 'cindy-cursor-live-')); +async function saveCheckpoint(values: Record) { + if (!checkpointPath || !checkpoint) return; + Object.assign(checkpoint, values); + await writeFile(checkpointPath, JSON.stringify(checkpoint), { mode: 0o600 }); +} +// Bound native project discovery to this fixture, not the executor's ancestor .git. +execFileSync('git', ['init', '--quiet', workingDir], { stdio: 'ignore' }); +const target = path.join(workingDir, 'probe.txt'); +const noop = () => {}; +const logger: Logger = { trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop, child() { return this; } }; +let mcpUrl = ''; +let mcpInitializations = 0; +let mcpToolLists = 0; +let bridgeLeases = 0; +const mcpToken = 'synthetic-smoke-only'; +const mcpServer = createServer((request, response) => { + if (request.headers.authorization !== `Bearer ${mcpToken}`) { response.writeHead(401).end(); return; } + if (request.method !== 'POST') { response.writeHead(405).end(); return; } + let body = ''; + request.on('data', chunk => { body += chunk; if (Buffer.byteLength(body) > 65_536) request.destroy(); }); + request.on('end', () => { + try { + const message = JSON.parse(body); + if (message.id === undefined) { response.writeHead(202).end(); return; } + let result: unknown = {}; + if (message.method === 'initialize') { + mcpInitializations++; + result = { protocolVersion: message.params?.protocolVersion ?? '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'cindy-smoke', version: '1.0.0' } }; + } else if (message.method === 'tools/list') { mcpToolLists++; result = { tools: [] }; } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); + } catch { response.writeHead(400).end(); } + }); +}); +const agent = new CursorAgent({ binaryPath, auth, runtimeConfig: {}, logger, + preparePiExtraSpawnConfig: async () => { + if (!mcpUrl) return null; + bridgeLeases++; + return { mcpBridge: { token: mcpToken, servers: [{ name: 'cindy_smoke', url: mcpUrl }] }, disposeSessionCtx: () => { bridgeLeases--; } }; + }, +}); +let handle: AgentSessionHandle | undefined; +let pump: Promise | undefined; +let permissionRequests = 0; +let permissionAllows = 0; +let current: { text: string; toolEvents: number; cancel?: boolean; cancelledSent: boolean; + resolve(result: { text: string; toolEvents: number; stopReason: string }): void; reject(error: Error): void } | undefined; +const report = (stage: string, data: Record = {}) => console.log(JSON.stringify({ stage, ...data })); + +function verifiedTarget(request: InteractionRequest): boolean { + if (request.kind !== 'permission' || !['Edit', 'Write', 'Read'].includes(request.toolName)) return false; + const paths: string[] = []; + const collect = (value: unknown, key = '') => { + if (typeof value === 'string' && /^(path|filePath|file_path|target_file|targetPath|absolutePath)$/i.test(key)) paths.push(value); + else if (Array.isArray(value)) value.forEach(item => collect(item)); + else if (value && typeof value === 'object') for (const [key, item] of Object.entries(value)) collect(item, key); + }; + collect(request.input); + return paths.length > 0 && paths.every(value => path.resolve(workingDir, value) === target); +} +async function start(resumeSessionId?: string) { + handle = await agent.startSession({ workingDir, model: CURSOR_DEFAULT_MODEL, permissionMode: 'ask', makerMemoryEnabled: false, resumeSessionId }); + handle.setInteractionResolver(async request => { + if (request.kind === 'permission') { + permissionRequests++; + const allow = verifiedTarget(request); + if (allow) permissionAllows++; + report('permission', { allowed: allow }); + return { kind: 'permission', behavior: allow ? 'allow' : 'deny' }; + } + if (request.kind === 'plan_review') return { kind: 'plan_review', behavior: 'deny', reason: 'Smoke test does not approve plans' }; + return { kind: 'ask_user_question', answers: {}, dismissed: true }; + }); + pump = (async () => { + for await (const event of handle!.events()) consume(event); + })(); + report(resumeSessionId ? 'loaded' : 'created', { nativeIdentityPresent: !!handle.id, + modelOptions: agent.capabilities.availableModels.length, nativeModels: agent.capabilities.availableModels.filter(model => model.id !== CURSOR_DEFAULT_MODEL).length, imageInput: agent.capabilities.multimodal.image.supported }); +} +function consume(event: AgentEvent) { + if (!current) return; + const data = event.data && typeof event.data === 'object' ? event.data as Record : {}; + if (event.type === 'text' && typeof data.text === 'string') { + current.text = (current.text + data.text).slice(0, 32_768); + if (current.cancel && !current.cancelledSent) { + current.cancelledSent = true; + void handle!.abort().catch(error => current?.reject(error)); + } + } + if (event.type === 'tool_use' || event.type === 'tool_result_full') current.toolEvents++; + if (event.type === 'error') current.reject(new Error(typeof data.message === 'string' ? data.message : 'Cursor error')); + if (event.type === 'done') current.resolve({ text: current.text, toolEvents: current.toolEvents, stopReason: String(data.stopReason) }); +} +async function turn(prompt: string, cancel = false) { + let timer: ReturnType | undefined; + let cancelTimer: ReturnType | undefined; + const result = new Promise<{ text: string; toolEvents: number; stopReason: string }>((resolve, reject) => { + current = { text: '', toolEvents: 0, cancel, cancelledSent: false, resolve, reject }; + timer = setTimeout(() => reject(new Error('Native smoke turn exceeded 90 seconds')), 90_000); + }); + void result.catch(() => {}); + try { + const sending = handle!.send({ type: 'user', content: prompt }); + if (cancel) cancelTimer = setTimeout(() => { if (current && !current.cancelledSent) { current.cancelledSent = true; void handle!.abort().catch(error => current?.reject(error)); } }, 1500); + await sending; + return await result; + } finally { if (timer) clearTimeout(timer); if (cancelTimer) clearTimeout(cancelTimer); current = undefined; } +} +try { + try { + await new Promise((resolve, reject) => { mcpServer.once('error', reject); mcpServer.listen(0, '127.0.0.1', resolve); }); + const address = mcpServer.address(); + assert.ok(address && typeof address === 'object'); + mcpUrl = `http://127.0.0.1:${address.port}/mcp`; + } catch (error) { + if (!['EPERM', 'EACCES'].includes(String((error as NodeJS.ErrnoException).code))) throw error; + report('http-mcp', { skipped: 'local_port_binding_denied' }); + } + if (selectedStage && checkpoint) { + await saveCheckpoint({ activeStage: selectedStage }); + await start(checkpoint.sessionId); + if (selectedStage === 'context') { + const resumed = await turn('What codeword did I give you earlier in this task? Reply only with that word. Do not use tools.'); + assert.ok(resumed.text.includes('PEBBLE'), 'Persisted codeword not retained'); + await saveCheckpoint({ multiTurn: true, load: true, activeStage: null }); + report('persistent-multi-turn', { passed: true }); + } else if (selectedStage === 'file') { + const file = await turn('Using a file-edit tool, create probe.txt in the current working directory containing exactly cindy-cursor-smoke. Do not execute shell commands, use the network, or modify any other file.'); + assert.equal((await readFile(target, 'utf8')).trim(), 'cindy-cursor-smoke'); + await saveCheckpoint({ file: true, activeStage: null, permissionRequests, permissionAllows }); + report('file-tool', { passed: true, toolEvents: file.toolEvents, permissionRequests, permissionAllows }); + } else { + const stopped = await turn('Write the numbers 1 through 500, one number per line. Do not use tools.', true); + assert.equal(stopped.stopReason, 'cancelled', 'Native cancellation not confirmed'); + await saveCheckpoint({ cancel: true, activeStage: null }); + report('cancel', { confirmed: true }); + } + } else { + await start(); + const first = await turn('Remember the codeword PEBBLE for this task. Reply only ACK. Do not use tools.'); + assert.ok(/ACK/i.test(first.text), 'Unexpected first reply'); + report('first-turn', { passed: true }); + const second = await turn('What codeword did I give you? Reply only with that word. Do not use tools.'); + assert.ok(second.text.includes('PEBBLE'), 'Multi-turn codeword not retained'); + report('multi-turn', { passed: true }); + const file = await turn('Using a file-edit tool, create probe.txt in the current working directory containing exactly cindy-cursor-smoke. Do not execute shell commands, use the network, or modify any other file.'); + assert.ok((await readFile(target, 'utf8')).trim() === 'cindy-cursor-smoke', 'Scratch file contents mismatch'); + report('file-tool', { passed: true, toolEvents: file.toolEvents, permissionRequests, permissionAllows }); + const nativeId = handle!.id; + await handle!.close(); await pump; + await start(nativeId); + const resumed = await turn('What codeword did I give you earlier in this task? Reply only with that word. Do not use tools.'); + assert.ok(resumed.text.includes('PEBBLE'), 'Persisted codeword not retained'); + report('persistent-load', { passed: true }); + const stopped = await turn('Write the numbers 1 through 500, one number per line. Do not use tools.', true); + report('cancel', { confirmed: stopped.stopReason === 'cancelled', stopReason: stopped.stopReason }); + assert.equal(stopped.stopReason, 'cancelled', 'Native completion raced cancellation; cancellation is not confirmed'); + } + if (mcpUrl) { + assert.ok(mcpInitializations > 0 && mcpToolLists > 0, 'Native HTTP MCP handshake was not observed'); + report('http-mcp', { passed: true, initializations: mcpInitializations, toolLists: mcpToolLists }); + } + report('complete', { passed: true }); +} catch (error) { + report('failed', { errorType: error instanceof Error ? error.name : 'UnknownError' }); + throw new Error('Cursor native smoke failed; no model/account response content was logged'); +} finally { + await handle?.close(); await pump; + await agent.dispose(); + assert.equal(bridgeLeases, 0, 'MCP bridge lease was not released'); + await new Promise(resolve => mcpServer.close(() => resolve())); + // Do not remove the cwd if closing the native process failed. + if (!checkpoint) await rm(workingDir, { recursive: true, force: true }); +}