Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
07c6df0
feat: add native Cursor Agent ACP support
9398-haha Oct 8, 2026
ac32ab6
fix: complete Cursor ACP lifecycle and desktop integration
9398-haha Oct 8, 2026
93db8e7
fix: preserve Cursor draft sync and finish desktop lint cleanup
9398-haha Oct 8, 2026
4100b04
fix: complete Cursor queue, auth and status runtime boundaries
9398-haha Oct 8, 2026
ddbdc5b
fix: align Cursor composer and optional runtime capabilities
9398-haha Oct 8, 2026
772c292
fix(cursor): restore mobile model choices and merge permission aliases
9398-haha Oct 9, 2026
335c079
Merge current main into Cursor ACP integration
9398-haha Oct 9, 2026
0d7f303
fix(cursor): enable native model effort and Fast controls
9398-haha Oct 9, 2026
fd49dc8
fix: preserve Cursor model pools and remove duplicate default choice
9398-haha Oct 9, 2026
abf5f31
fix: keep saved Cursor default routes usable without duplicate choices
9398-haha Oct 9, 2026
d225194
fix(cursor): support shared approval modes and preserve selections
9398-haha Oct 9, 2026
08f5b72
fix(cursor): describe native scope in full access confirmations
9398-haha Oct 9, 2026
3ebdab3
docs(cursor): attach approval mode UI evidence
9398-haha Oct 9, 2026
cf2d919
fix(cursor): expose native provider setup and recovery in settings
9398-haha Oct 10, 2026
b4c99b9
merge: sync upstream main for Cursor integration
9398-haha Oct 10, 2026
414ceeb
merge: sync latest upstream provider settings
9398-haha Oct 10, 2026
17d55e5
docs: refresh Cursor settings screenshots after main sync
9398-haha Oct 10, 2026
e54d475
fix(dev): stabilize macOS preview under metadata events
9398-haha Oct 10, 2026
b05e122
merge: sync upstream main for Cursor ACP
9398-haha Oct 10, 2026
da68e67
merge: sync upstream main for Cursor ACP
9398-haha Oct 10, 2026
1f68a1a
merge: sync upstream main for Cursor ACP
9398-haha Oct 11, 2026
abc9f6e
merge: sync upstream main for Cursor ACP
9398-haha Oct 11, 2026
13c6670
merge: sync upstream main for Cursor ACP
9398-haha Oct 11, 2026
b8ab6b8
merge: sync upstream main for Cursor ACP
cursoragent Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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' } },
Expand All @@ -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(),
);
});
Expand Down Expand Up @@ -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<ReturnType<typeof readTaskResultsForReply>> = [{
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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,34 @@ const projectForCurrentController = (result: unknown) =>
modelVisibilityOverrides?: Record<string, boolean>;
};

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',
Expand Down
22 changes: 18 additions & 4 deletions apps/desktop/src/main/device-link/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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; }


Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -2546,7 +2556,7 @@ async function handleFrame(client: DeviceLinkClient, env: Envelope): Promise<voi
if (!src || !env.id) return;
handleLinkOpen(client, src, env.id, env.payload as LinkOpenPayload | undefined);
return;
case 'link-close':
case 'link-close': {
if (!src) return;
// transport-timeout 是对端对「它作为被控端服务本机控制」的那条 link 做的
// peer 级瞬时重置,与本机作为被控端服务对端控制的**反向**状态无关。
Expand Down Expand Up @@ -2574,6 +2584,7 @@ async function handleFrame(client: DeviceLinkClient, env: Envelope): Promise<voi
if (deactivated || wasKnown) syncForwarding();
log.info(`control link closed by ${shortId(src)}`);
return;
}
case 'invoke':
if (!src || !env.id) return;
await handleInvoke(client, src, env.id, env.payload as InvokePayload);
Expand Down Expand Up @@ -3825,7 +3836,8 @@ function mergeRemoteAgentMeta(agentMeta: unknown, patch: Record<string, unknown>
if (!agentMeta || typeof agentMeta !== 'object' || Array.isArray(agentMeta)) {
return { ...patch };
}
const { recoveryCheckpoint: _, ...safe } = agentMeta as Record<string, unknown>;
const safe = { ...agentMeta as Record<string, unknown> };
delete safe.recoveryCheckpoint;
return { ...sanitizeBotAuthorizationMetaForRemote(safe), ...patch };
}

Expand All @@ -3842,7 +3854,8 @@ function sanitizeRemoteMessage(record: Record<string, unknown>): Record<string,
if (!hasPrivateMetadata && !hasCheckpointInContent) return record;
const result: Record<string, unknown> = { ...record };
if (hasPrivateMetadata) {
const { recoveryCheckpoint: _, ...safeMeta } = agentMeta as Record<string, unknown>;
const safeMeta = { ...agentMeta as Record<string, unknown> };
delete safeMeta.recoveryCheckpoint;
result.agentMeta = sanitizeBotAuthorizationMetaForRemote(safeMeta);
}
if (hasCheckpointInContent) {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/device-link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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,
Expand Down Expand Up @@ -531,6 +532,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 并连带踢掉已启用微批的
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/hook-control/__tests__/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
25 changes: 13 additions & 12 deletions apps/desktop/src/main/hook-control/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<
'claude-code' | 'codex' | 'pi' | 'cursor',
{ providerId: string | null; model: string; effort: string }
>;
>>;
};
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;
Expand All @@ -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。
Expand All @@ -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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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}'`,
);
}
}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -189,6 +201,7 @@ describe('im default settings store', () => {
model: 'claude-sonnet-5',
effort: 'high',
},
cursor: IM_DEFAULT_SETTINGS.agents.cursor,
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,21 @@ 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({
// 字符串两端空白收敛, 空白串的 providerId 归 null(隐式默认路由)
'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' },
});
});

Expand All @@ -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<keyof ImDefaultSettings>) {
Expand Down
Loading