From bcd24eb691328458b5ef0f7dcadd864eaa01fc62 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:45:01 +0800 Subject: [PATCH 001/416] fix(review): review current code in the actual checkout Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../gitContextSessionDirResolver.test.ts | 36 ++++- .../main/git-context/sessionDirResolver.ts | 16 ++- .../__tests__/scopeResolver.test.ts | 24 ++++ .../src/main/git-review/scopeResolver.ts | 34 ++++- .../__tests__/reviewStartHandler.test.ts | 26 ++++ apps/desktop/src/main/maker-ipc/register.ts | 131 ++++++++++++------ .../src/main/maker-ipc/reviewStartHandler.ts | 18 ++- .../reviewer/__tests__/reviewEvidence.test.ts | 8 +- .../__tests__/reviewEvidenceSafety.test.ts | 14 ++ .../reviewer/__tests__/reviewPrompt.test.ts | 8 +- .../reviewSelfCheck.git-integration.test.ts | 117 ++++++++++++++++ .../reviewWorkspaceSelection.test.ts | 56 ++++++++ .../src/main/reviewer/reviewEvidence.ts | 14 +- .../src/main/reviewer/reviewEvidenceSafety.ts | 17 +++ .../desktop/src/main/reviewer/reviewPrompt.ts | 26 ++-- .../main/reviewer/reviewWorkspaceSelection.ts | 42 ++++++ .../__tests__/systemCardReview.test.tsx | 12 ++ .../renderer/components/chat/SystemCard.tsx | 17 ++- .../features/cc-agent/CCAgentSessionView.tsx | 1 + .../src/renderer/i18n/locales/en/common.json | 14 +- .../src/renderer/i18n/locales/ja/common.json | 14 +- .../src/renderer/i18n/locales/ko/common.json | 14 +- .../renderer/i18n/locales/zh-CN/common.json | 14 +- .../renderer/i18n/locales/zh-TW/common.json | 14 +- apps/desktop/src/shared/reviewRun.ts | 6 + .../product-rules/review-product-direction.md | 8 ++ 26 files changed, 603 insertions(+), 98 deletions(-) create mode 100644 apps/desktop/src/main/reviewer/__tests__/reviewSelfCheck.git-integration.test.ts create mode 100644 apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts create mode 100644 apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts diff --git a/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts b/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts index e5d5c73f389..e342049c39a 100644 --- a/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts +++ b/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts @@ -5,7 +5,10 @@ import path from 'node:path'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; + +// The injected resolver never opens Electron's persistent store. +vi.mock('electron-store', () => ({ default: class {} })); import type { GitHeadInfo } from '../git-context/headReader'; import { @@ -54,6 +57,26 @@ describe('extractDirCandidate', () => { expect(extractDirCandidate(toolUse('Bash', { command: 'ls' }))).toBeNull(); }); + it.each([ + 'cd /Users/x/wt-a && git status', + "cd '/Users/x/wt-a' && git diff", + 'cd -- "/Users/x/wt-a" && python edit.py', + ])('Claude Bash reports a literal command directory: %s', (command) => { + expect(extractDirCandidate(toolUse('Bash', { command }))).toBe('/Users/x/wt-a'); + }); + + it('accepts quoted directory spaces without interpreting shell expressions', () => { + expect(extractDirCandidate(toolUse('Bash', { command: "cd '/Users/x/code tree' && git status" }))) + .toBe('/Users/x/code tree'); + for (const command of [ + 'cd "$HOME/wt-a" && git status', + 'cd /Users/$(whoami)/wt-a && git status', + 'cd ../wt-a && git status', + 'echo ok && cd /Users/x/wt-a && git status', + 'cd /Users/x/wt-* && git status', + ]) expect(extractDirCandidate(toolUse('Bash', { command }))).toBeNull(); + }); + it('相对路径不采纳(cwd / file_path 都要求绝对)→ null', () => { expect(extractDirCandidate(toolUse('exec', { command: 'ls', cwd: '../wt-a' }))).toBeNull(); expect(extractDirCandidate(toolUse('Edit', { file_path: 'src/y.ts' }))).toBeNull(); @@ -105,6 +128,17 @@ describe('resolveSessionGitDir', () => { }; } + it('uses a Bash-only linked checkout instead of the unchanged task directory', async () => { + const res = await resolveSessionGitDir( + { sessionId: 's', fallbackWorktreePath: null, fallbackWorkingDir: '/Users/x/main' }, + deps({ + contents: [toolUse('Bash', { command: 'cd /Users/x/wt-a && git status --short' })], + gitDirs: { '/Users/x/main': 'main', '/Users/x/wt-a': 'integration' }, + }), + ); + expect(res).toMatchObject({ workdir: path.resolve('/Users/x/wt-a'), source: 'telemetry', head: branchHead('integration') }); + }); + it('遥测命中:最近一条候选目录是 git → source=telemetry', async () => { const res = await resolveSessionGitDir( { sessionId: 's', ...FALLBACKS }, diff --git a/apps/desktop/src/main/git-context/sessionDirResolver.ts b/apps/desktop/src/main/git-context/sessionDirResolver.ts index de51f0f234a..9f82f255803 100644 --- a/apps/desktop/src/main/git-context/sessionDirResolver.ts +++ b/apps/desktop/src/main/git-context/sessionDirResolver.ts @@ -10,7 +10,7 @@ * 信号(按 agent): * - Codex exec:每条命令 content.input.cwd = agent 当前 shell 目录,直接可信 * (实测:agent `cd` 进 worktree 后,后续每条命令的 cwd 都跟着变)。 - * - Claude Code:Bash 不带 cwd;改用 Edit/Write/MultiEdit/NotebookEdit 的 + * - Claude Code:Bash 的字面量开头 cd 或 Edit/Write/MultiEdit/NotebookEdit 的 * input.file_path 绝对路径,取其所在目录(Read 不算——读文件 ≠ 在此工作)。 * * 取「最近一条产出候选目录的 tool-use」(= 对话此刻在哪),用 readGitHead 校验: @@ -72,7 +72,7 @@ export interface SessionGitDirResult { * 从一条 tool-use 消息 content(已是 JSON 文本)解析候选工作目录: * - input.cwd 是绝对路径(Codex exec)→ 直接用; * - toolName ∈ 编辑类 且 input.file_path 是绝对路径 → 取 path.dirname; - * - 其它(Read / 普通 Bash / 脏 JSON / 相对路径)→ null。 + * - 其它(Read / 无字面量 cd 的 Bash / 脏 JSON / 相对路径)→ null。 * 纯函数,不碰 IO。 */ export function extractDirCandidate( @@ -103,6 +103,18 @@ export function extractDirCandidate( } } + // Claude's Bash tool does not report cwd. A literal leading `cd ... &&` + // identifies where this command ran; ordinary Bash/Read remain non-signals. + // Do not evaluate variables, substitutions, relative paths or later cd's. + if (toolName === 'Bash' && typeof (input as { command?: unknown }).command === 'string') { + const command = (input as { command: string }).command; + const match = /^\s*cd\s+(?:--\s+)?(?:'([^'\n]+)'|"([^"\n]+)"|([^\s;&|]+))\s*&&/.exec(command); + const dir = match?.[1] ?? match?.[2] ?? match?.[3]; + if (dir && !/[\x00-\x1f$`\\*?{}]/.test(dir) && path.isAbsolute(dir)) { + return posixDriveToWin32(dir, pathPlatform); + } + } + // cc 编辑类:先归一 POSIX 盘符路径(Windows 上 cc 常发 `/e/...`)再取 dirname。 if (EDIT_TOOL_NAMES.has(toolName)) { const fp = (input as { file_path?: unknown }).file_path; diff --git a/apps/desktop/src/main/git-review/__tests__/scopeResolver.test.ts b/apps/desktop/src/main/git-review/__tests__/scopeResolver.test.ts index 3af1d3ad606..313e9dd8ca5 100644 --- a/apps/desktop/src/main/git-review/__tests__/scopeResolver.test.ts +++ b/apps/desktop/src/main/git-review/__tests__/scopeResolver.test.ts @@ -2,11 +2,14 @@ import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +vi.mock('electron-store', () => ({ default: class {} })); + import { defaultScopeResolverDeps, resolveReviewScope, type ScopeResolverDeps, withSessionReviewRowSnapshot, + withSessionReviewWorkspace, } from '../scopeResolver'; import { GitRunError, type GitRunResult } from '../gitRunner'; @@ -34,6 +37,27 @@ function deps(patch: Partial = {}): ScopeResolverDeps { } describe('git-review scopeResolver', () => { + it('pins one Review directory and rereads its current HEAD without following new telemetry', async () => { + let head = 'a'.repeat(40); + const d = deps({ git: vi.fn(async (args, options) => ({ + stdout: args.includes('--show-toplevel') ? `${options.cwd}\n` + : args.includes('--verify') ? `${head}\n` + : args[0] === 'symbolic-ref' ? 'integration\n' : '', + stderr: '', exitCode: 0, + })) }); + await withSessionReviewWorkspace('s1', '/repo/actual', async () => { + expect(await resolveReviewScope('s1', d)).toMatchObject({ + repoRoot: path.resolve('/repo/actual'), branch: 'integration', headOid: head, + }); + head = 'b'.repeat(40); + expect((await resolveReviewScope('s1', d)).headOid).toBe(head); + expect(d.resolveSessionDir).not.toHaveBeenCalled(); + await resolveReviewScope('other-task', d); + expect(d.resolveSessionDir).toHaveBeenCalledTimes(1); + }); + expect((await resolveReviewScope('s1', d)).repoRoot).toBe(path.resolve('/repo/wt-store')); + }); + it('prefers managed WorktreeStore path over DB snapshot fallback', async () => { const d = deps(); const scope = await resolveReviewScope('s1', d); diff --git a/apps/desktop/src/main/git-review/scopeResolver.ts b/apps/desktop/src/main/git-review/scopeResolver.ts index bcdf97153db..48e32302369 100644 --- a/apps/desktop/src/main/git-review/scopeResolver.ts +++ b/apps/desktop/src/main/git-review/scopeResolver.ts @@ -26,6 +26,19 @@ export interface SessionReviewRow { } const sessionRowSnapshot = new AsyncLocalStorage(); +const selectedReviewWorkspace = new AsyncLocalStorage<{ sessionId: string; workingDir: string }>(); + +/** Pin one Review invocation to its host-resolved/user-selected directory. + * Evidence, freshness checks and the Reviewer must all address this same tree. + * This does not change the source task's persisted directory or other callers. + */ +export function withSessionReviewWorkspace( + sessionId: string, + workingDir: string, + task: () => Promise, +): Promise { + return selectedReviewWorkspace.run({ sessionId, workingDir }, task); +} export function withSessionReviewRowSnapshot( row: SessionReviewRow, @@ -216,11 +229,20 @@ export async function resolveReviewScope( const managedWorktreePath = deps.getManagedWorktreePath(sessionId); const fallbackWorktreePath = managedWorktreePath ?? row.worktreePath; - const resolved = await deps.resolveSessionDir({ - sessionId, - fallbackWorktreePath, - fallbackWorkingDir: row.workingDir, - }); + const selected = selectedReviewWorkspace.getStore(); + const pinnedDir = selected?.sessionId === sessionId ? selected.workingDir : null; + const pinnedHead = pinnedDir ? await readRemoteHeadState(pinnedDir, deps.git) : null; + const resolved = pinnedDir && pinnedHead + ? { + workdir: pinnedDir, + head: { kind: pinnedHead.isDetached ? 'detached' : 'branch', branch: pinnedHead.branch }, + source: 'workingDir' as const, + } + : await deps.resolveSessionDir({ + sessionId, + fallbackWorktreePath, + fallbackWorkingDir: row.workingDir, + }); // A remote row is handled above through the request-scoped SSH Git backend. // A remote result here would lack the authoritative host id, so fail closed. if (resolved.source === 'remote') { @@ -242,7 +264,7 @@ export async function resolveReviewScope( const resolutionChain = [ { source: 'telemetry', path: resolved.source === 'telemetry' ? resolved.workdir : null, ok: resolved.source === 'telemetry' }, { source: 'worktree', path: fallbackWorktreePath, ok: resolved.source === 'worktree' }, - { source: 'workingDir', path: row.workingDir, ok: resolved.source === 'workingDir' }, + { source: 'workingDir', path: pinnedDir ?? row.workingDir, ok: resolved.source === 'workingDir' }, ]; if (!resolved.workdir) { diff --git a/apps/desktop/src/main/maker-ipc/__tests__/reviewStartHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/reviewStartHandler.test.ts index 5b1a1d19931..b9d1d7240e7 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/reviewStartHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/reviewStartHandler.test.ts @@ -950,6 +950,32 @@ describe('maker:review:start IPC lifecycle', () => { expect(deps.closeReviewer).toHaveBeenCalledTimes(1); }); + it.each(['returned-failure', 'verification-exception'])( + 'keeps an existing conclusion when final verification encounters %s', async (mode) => { + const harness = new IpcHarness(); + const reviewer = new FakeReviewer(); + const launch = makeLaunch({ verifyBeforePublish: vi.fn(async () => { + if (mode === 'verification-exception') throw new Error('Git backend unavailable'); + return { code: 'artifact-unavailable' as const, message: 'Cannot verify source files' }; + }) }); + const workspace = { workingDir: '/repo/actual', baseRef: 'main', hasUncommittedChanges: true }; + const deps = makeDeps(reviewer, { + prepareRun: vi.fn(async () => makePreparedRun(launch, { workspace })), + }); + registerReviewStartHandler(harness, deps); + await harness.invoke(MAKER_INVOKE.START_REVIEW, reviewRequest()); + expect(deps.createSourceCard).toHaveBeenCalledWith(expect.objectContaining({ + meta: expect.objectContaining({ workspace }), + })); + reviewer.emit({ type: 'done', data: {} }); + await vi.waitFor(() => expect(deps.updateSourceCard).toHaveBeenCalledTimes(1)); + expect(deps.updateSourceCard).toHaveBeenCalledWith(expect.objectContaining({ + result: 'P1: real finding', meta: expect.objectContaining({ status: 'failed', workspace }), + })); + expect(deps.closeReviewer).toHaveBeenCalledTimes(1); + }, + ); + it('fails the visible card when evidence changes before reviewer bootstrap', async () => { const harness = new IpcHarness(); const reviewer = new FakeReviewer(); diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 363252ea331..7b94521e729 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -328,6 +328,8 @@ import { invalidateWorkersByLeadSingleFlight } from '../localDb/ipc/orcaWorkerLi import { messageToCamel, setSessionRuntimeProjector } from '../localDb/mapper.js'; import { visibleMessageTextForConversationSearch } from '../localDb/conversationSearch.pure.js'; import { buildReviewPrompt } from '../reviewer/reviewPrompt.js'; +import { resolveReviewScope, withSessionReviewWorkspace } from '../git-review/scopeResolver.js'; +import { selectReviewWorkspace } from '../reviewer/reviewWorkspaceSelection.js'; import { listReviewHistoricalAttachments, loadReviewEvidence, @@ -354,7 +356,7 @@ import { ReviewArtifactFingerprintChangedError, ReviewArtifactFingerprintLimitError, } from '../reviewer/reviewArtifactFingerprint.js'; -import { reviewChangeSetContentPaths } from '../reviewer/reviewEvidenceSafety.js'; +import { reviewChangeSetContentPaths, usableReviewChangeSet } from '../reviewer/reviewEvidenceSafety.js'; import { enforceReviewCreateOptions } from '../reviewer/reviewSessionPolicy.js'; import { reviewSourceIdentityMatches } from '../reviewer/reviewSourceIdentity.js'; import { buildReviewSessionTitle } from '../reviewer/reviewSessionTitle.js'; @@ -8303,7 +8305,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (!source.workingDir) { throwIpcError('INVALID_PARAMS', 'The source task has no working directory to review'); } - const sourceWorkingDir = source.workingDir; + const sourceScope = await resolveReviewScope(source.id); + let sourceWorkingDir = sourceScope.repoRoot ?? sourceScope.workdir ?? source.workingDir; + const inReviewWorkspace = (task: () => Promise): Promise => + withSessionReviewWorkspace(source.id, sourceWorkingDir, task); if (await sourceHasActiveTurn(source.id)) { throwIpcError( 'SESSION_RUNNING', @@ -8311,7 +8316,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ); } - let evidence: Awaited>; + let evidence!: Awaited>; + let workspaceSelected = false; + let historyCaptureIncomplete = false; let sourceArtifactFingerprint = ''; let authorizedArtifactPaths: string[] = []; let cleanupPreparedArtifacts: (() => Promise) | null = null; @@ -8327,37 +8334,69 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) cleanupRemoteAttachments = remote.cleanup; cleanupPreparedArtifacts = remote.cleanup; } - const historicalAttachments = await listReviewHistoricalAttachments(source.id); - const explicitArtifactGrant = await authorizeReviewExplicitArtifacts({ - workingDir: sourceWorkingDir, - focus: request.focus, - attachments: [...request.attachments, ...historicalAttachments], - resolvePath: resolveReviewArtifactPath, - confirm: (items) => confirmReviewExternalArtifacts(event as IpcMainInvokeEvent, items), - }); - authorizedArtifactPaths = explicitArtifactGrant.paths; - const prepared = await prepareStableReviewArtifactSnapshots({ - workingDir: sourceWorkingDir, - grant: explicitArtifactGrant, - owner: reviewRunOwner, - prepare: (snapshotGrant) => - loadReviewEvidence({ - sourceSessionId: source.id, - workingDir: sourceWorkingDir, - focus: request.focus, - attachments: request.attachments, - explicitArtifactGrant: snapshotGrant, - }), - }); - cleanupPreparedArtifacts = async () => { - try { - await prepared.cleanup(); - } finally { - await cleanupRemoteAttachments?.(); - } + const prepareEvidence = async () => { + const historicalAttachments = await listReviewHistoricalAttachments(source.id); + const explicitArtifactGrant = await authorizeReviewExplicitArtifacts({ + workingDir: sourceWorkingDir, + focus: request.focus, + attachments: [...request.attachments, ...historicalAttachments], + resolvePath: resolveReviewArtifactPath, + confirm: (items) => confirmReviewExternalArtifacts(event as IpcMainInvokeEvent, items), + }); + authorizedArtifactPaths = explicitArtifactGrant.paths; + const prepared = await prepareStableReviewArtifactSnapshots({ + workingDir: sourceWorkingDir, + grant: explicitArtifactGrant, + owner: reviewRunOwner, + prepare: (snapshotGrant) => + inReviewWorkspace(() => loadReviewEvidence({ + sourceSessionId: source.id, + workingDir: sourceWorkingDir, + focus: request.focus, + attachments: request.attachments, + explicitArtifactGrant: snapshotGrant, + })), + }); + cleanupPreparedArtifacts = async () => { + try { + await prepared.cleanup(); + } finally { + await cleanupRemoteAttachments?.(); + } + }; + evidence = prepared.value; + sourceArtifactFingerprint = prepared.fingerprint; }; - evidence = prepared.value; - sourceArtifactFingerprint = prepared.fingerprint; + await prepareEvidence(); + // An empty main checkout commonly means the work happened in a linked + // worktree. Historical turn metadata cannot tell us which current code + // the user wants; ask about the directory instead of refusing the run. + if ( + evidence.workspace && !evidence.workspace.disabledReason && + !evidence.workspace.dirty && !evidence.branch && + evidence.artifacts.length === 0 && !evidence.focusPath && !isDeviceLinkInvoke() + ) { + const selectedDir = await selectReviewWorkspace(event as IpcMainInvokeEvent, sourceWorkingDir); + if (!selectedDir) throwIpcError('MUTATION_CANCELLED', 'Review workspace selection cancelled'); + workspaceSelected = true; + if (path.resolve(selectedDir) !== path.resolve(sourceWorkingDir)) { + await cleanupPreparedArtifacts?.(); + sourceWorkingDir = selectedDir; + await prepareEvidence(); + } + } + // Reversible turn capture is advisory. Bash (even `git status`) and + // overlapping tasks mark it partial; a fresh Git baseline or explicitly + // selected artifacts must not be blocked by unrelated capture gaps. + const historicalChangeSet = evidence.changeSet; + evidence.changeSet = usableReviewChangeSet(evidence.changeSet, sourceWorkingDir, { + hasGitBaseline: !!evidence.workspaceFingerprint, + hasExplicitArtifacts: evidence.artifacts.length > 0 || workspaceSelected, + }); + historyCaptureIncomplete = !!historicalChangeSet && !evidence.changeSet; + // An explicit directory choice targets its current code, not a turn + // captured in the previous task directory. + if (workspaceSelected) evidence.changeSet = null; } catch (error) { await cleanupPreparedArtifacts?.(); if ( @@ -8377,7 +8416,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) !evidence.branch && !evidence.changeSet && evidence.artifacts.length === 0 && - !request.focus + !request.focus && + !workspaceSelected ) { await cleanupPreparedArtifacts?.(); // "Nothing to review" and "the branch was there but could not be read" @@ -8395,6 +8435,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) builtPrompt = buildReviewPrompt({ focus: evidence.focusPath ? `审查路径:${evidence.focusPath}` : request.focus, context: evidence.context, + historyCaptureIncomplete, workspace: evidence.workspace, branch: evidence.branch, ...(evidence.branchUnavailableReason @@ -8415,6 +8456,11 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) sourceAgentKind: source.agentKind as 'cc' | 'codex' | 'pi', prompt: builtPrompt.prompt, targetKind: builtPrompt.targetKind, + workspace: { + workingDir: sourceWorkingDir, + ...(evidence.branch ? { baseRef: evidence.branch.baseRef } : {}), + hasUncommittedChanges: !!evidence.workspace?.dirty, + }, onAccepted: () => acceptRemoteAttachments?.(), cleanup: async () => { await cleanupPreparedArtifacts?.(); @@ -8489,6 +8535,11 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); } const artifactPaths = [...new Set([...reviewReadPaths, ...changeSetContent.paths])]; + if (workspaceSelected && !evidence.workspaceFingerprint) { + // A selected non-Git directory has no HEAD/status baseline. Bind + // its contents with the existing bounded artifact fingerprinter. + artifactPaths.push(sourceWorkingDir); + } const artifactFingerprintOptions = { linkConfinementRoot: sourceWorkingDir }; let artifactFingerprint: string; try { @@ -8573,10 +8624,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }; } if ( - !(await reviewWorkspaceFingerprintIsCurrent( + !(await inReviewWorkspace(() => reviewWorkspaceFingerprintIsCurrent( source.id, evidence.workspaceFingerprint, - )) + ))) ) { return { code: 'source-files-changed', @@ -8586,7 +8637,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } // The workspace fingerprint pins HEAD, not the base it is compared // against; a moved base changes the branch diff without touching it. - if (!(await reviewBranchBaselineIsCurrent(source.id, evidence.branch))) { + if (!(await inReviewWorkspace(() => reviewBranchBaselineIsCurrent(source.id, evidence.branch)))) { return { code: 'source-files-changed', message: @@ -8633,10 +8684,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }; } if ( - !(await reviewWorkspaceFingerprintIsCurrent( + !(await inReviewWorkspace(() => reviewWorkspaceFingerprintIsCurrent( source.id, evidence.workspaceFingerprint, - )) + ))) ) { return { code: 'source-files-changed', @@ -8644,7 +8695,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) 'The task files changed while Review was running. Run /review again for the current result.', }; } - if (!(await reviewBranchBaselineIsCurrent(source.id, evidence.branch))) { + if (!(await inReviewWorkspace(() => reviewBranchBaselineIsCurrent(source.id, evidence.branch)))) { return { code: 'source-files-changed', message: diff --git a/apps/desktop/src/main/maker-ipc/reviewStartHandler.ts b/apps/desktop/src/main/maker-ipc/reviewStartHandler.ts index 9b63fad98ff..b52f3804dac 100644 --- a/apps/desktop/src/main/maker-ipc/reviewStartHandler.ts +++ b/apps/desktop/src/main/maker-ipc/reviewStartHandler.ts @@ -10,7 +10,6 @@ import { isTurnContinuationBoundaryEvent } from '@cindy/maker-shared/turn-contin import type { ReviewAttachmentInput } from '../reviewer/reviewEvidence.js'; import { - isStaleReviewFailureCode, type ReviewFailureCode, type ReviewRunMeta, type ReviewRunOwner, @@ -152,6 +151,7 @@ export interface PreparedReviewRun { sourceAgentKind: 'cc' | 'codex' | 'pi'; prompt: string; targetKind: ReviewTargetKind; + workspace?: ReviewRunMeta['workspace']; prepareLaunch(): Promise; /** Release transport staging once the Review prompt is durably accepted. */ onAccepted?(): void; @@ -288,6 +288,7 @@ export function registerReviewStartHandler( let settled = false; let settlementCause: 'provider-terminal' | 'reviewer-closed' | null = null; let reviewerClosed = false; + let completedResult = ''; let preparedRunCleaned = false; let preparedRunCleanup: (() => Promise) | null = null; let terminalFinalization: Promise | null = null; @@ -494,6 +495,7 @@ export function registerReviewStartHandler( sourceSessionId: request.sourceSessionId, status: 'running', targetKind: prepared.targetKind, + ...(prepared.workspace ? { workspace: prepared.workspace } : {}), startedAt, owner: deps.owner, }; @@ -542,16 +544,12 @@ export function registerReviewStartHandler( await closeReviewer(); return; } + completedResult = result; const staleReason = await launch.verifyBeforePublish(); if (staleReason) { - if (isStaleReviewFailureCode(staleReason.code)) { - // Keep the persisted status readable by older clients. The stable - // reason code distinguishes an out-of-date completed result from - // a Reviewer execution failure in current clients. - await updateSourceCard('failed', result, staleReason.message, staleReason.code); - } else { - await updateSourceCard('failed', '', staleReason.message, staleReason.code); - } + // Keep the completed conclusion visible, with its failed freshness + // check. Clients cannot treat it as a verified current result. + await updateSourceCard('failed', result, staleReason.message, staleReason.code); await closeReviewer(); return; } @@ -565,7 +563,7 @@ export function registerReviewStartHandler( }); await updateSourceCard( 'failed', - '', + completedResult, error instanceof Error ? error.message : String(error), ).catch((cardError) => { deps.warn('review failure card retry setup failed', { diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewEvidence.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewEvidence.test.ts index eac985685de..9b8401fa86d 100644 --- a/apps/desktop/src/main/reviewer/__tests__/reviewEvidence.test.ts +++ b/apps/desktop/src/main/reviewer/__tests__/reviewEvidence.test.ts @@ -1137,9 +1137,7 @@ describe('loadReviewEvidence attachment boundaries', () => { ]); }); - it('does not read the branch diff while uncommitted work exists', async () => { - // Uncommitted work is the review target; reading the branch as well would - // bury it under commits the user is not asking about. + it('reads the committed branch changes even when uncommitted work exists', async () => { const repoRoot = await tempDir(); readReviewDataMock.mockResolvedValue(cappedReviewData(repoRoot, 'src/a.ts')); @@ -1154,7 +1152,9 @@ describe('loadReviewEvidence attachment boundaries', () => { }, }); - expect(readReviewBranchDiffMock).not.toHaveBeenCalled(); + expect(readReviewBranchDiffMock).toHaveBeenCalledWith('source', null); + // This fixture has no commits above the baseline; reading it is still + // necessary to distinguish an unchanged branch from hidden committed work. expect(evidence.branch).toBeNull(); }); diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewEvidenceSafety.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewEvidenceSafety.test.ts index 29e2007c417..b8d5a4aedc1 100644 --- a/apps/desktop/src/main/reviewer/__tests__/reviewEvidenceSafety.test.ts +++ b/apps/desktop/src/main/reviewer/__tests__/reviewEvidenceSafety.test.ts @@ -8,6 +8,7 @@ import { reviewChangeSetContentPaths, sanitizeReviewChangeSet, sanitizeReviewDiffBucket, + usableReviewChangeSet, } from '../reviewEvidenceSafety.js'; function fileDiff(overrides: Partial = {}): FileDiff { @@ -158,6 +159,19 @@ describe('review change set content paths', () => { return { id: path, path, oldPath, status: 'modified', additions: 1, deletions: 0 }; } + it('does not make incomplete Claude Bash history a prerequisite for current Git review', () => { + const captured = changeSet([], '/repo', { + provider: 'claude-code', state: 'partial', isReversible: false, + incompleteReasons: ['opaque-tool', 'concurrent-workspace'], + }); + expect(reviewChangeSetContentPaths(captured, '/repo').truncated).toBe(true); + expect(usableReviewChangeSet(captured, '/repo', { hasGitBaseline: true, hasExplicitArtifacts: false })).toBeNull(); + expect(usableReviewChangeSet(captured, '/repo', { hasGitBaseline: false, hasExplicitArtifacts: true })).toBeNull(); + expect(usableReviewChangeSet(captured, '/repo', { hasGitBaseline: false, hasExplicitArtifacts: false })).toBe(captured); + const complete = changeSet([file('src/a.ts')]); + expect(usableReviewChangeSet(complete, '/repo', { hasGitBaseline: true, hasExplicitArtifacts: false })).toBe(complete); + }); + it('resolves changed and renamed-from paths against the recorded cwd', () => { expect( reviewChangeSetContentPaths( diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewPrompt.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewPrompt.test.ts index cbe31263876..53340e0f206 100644 --- a/apps/desktop/src/main/reviewer/__tests__/reviewPrompt.test.ts +++ b/apps/desktop/src/main/reviewer/__tests__/reviewPrompt.test.ts @@ -326,8 +326,7 @@ describe('buildReviewPrompt', () => { expect(result.prompt).not.toContain('src/unrelated.ts'); }); - it('prefers uncommitted work over the branch diff', () => { - // Uncommitted changes are what the user is looking at right now. + it('reviews branch commits together with the current uncommitted edits', () => { const result = buildReviewPrompt({ context: [], workspace: { @@ -353,10 +352,13 @@ describe('buildReviewPrompt', () => { }, changeSet: null, artifacts: [], + historyCaptureIncomplete: true, }); expect(result.prompt).toContain('src/wip.ts'); - expect(result.prompt).not.toContain('src/committed.ts'); + expect(result.prompt).toContain('src/committed.ts'); + expect(result.prompt).toContain('以磁盘上的当前代码为最终状态'); + expect(result.prompt).toContain('历史改动记录不完整'); }); it('says so when the branch diff was unreadable and there is no change set', () => { diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewSelfCheck.git-integration.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewSelfCheck.git-integration.test.ts new file mode 100644 index 00000000000..bd84b5d2511 --- /dev/null +++ b/apps/desktop/src/main/reviewer/__tests__/reviewSelfCheck.git-integration.test.ts @@ -0,0 +1,117 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, expect, it, vi } from 'vitest'; + +const runtime = vi.hoisted(() => ({ resolveScope: vi.fn(), captured: null as unknown })); +vi.mock('electron-store', () => ({ default: class {} })); +// This fixture is local-only; no SSH transport or agent process is started. +vi.mock('../../git-review/sshReviewBackend.js', () => ({ + withSessionReviewExecution: (_id: string, task: () => unknown) => task(), + createSshPreviewReaderDeps: vi.fn(), +})); +vi.mock('../../localDb/client/current.js', () => ({ + getDbClient: () => ({ drizzle: { select: () => ({ from: () => ({ where: () => ({ + orderBy: () => ({ limit: async () => [] }), limit: async () => [], + }) }) }) } }), +})); +vi.mock('../../turn-change-set/store.js', () => ({ + listTurnChangeSets: async () => runtime.captured ? [runtime.captured] : [], + getTurnChangeSets: async () => runtime.captured ? [runtime.captured] : [], +})); +vi.mock('../../git-review/ipc.js', async (importOriginal) => { + const actual = await importOriginal(); + const { readStatus } = await import('../../git-review/statusReader.js'); + const { readDiffs } = await import('../../git-review/diffReader.js'); + return { + ...actual, + readReviewData: (id: string) => actual.readReviewData(id, { + resolveScope: runtime.resolveScope, readStatus, readDiffs, isSessionRunning: () => false, + }), + readReviewBranchDiff: (id: string, base: string | null) => actual.readReviewBranchDiff(id, base, { + resolveScope: runtime.resolveScope, + }), + }; +}); + +import { readGitHead } from '../../git-context/headReader.js'; +import { resolveSessionGitDir } from '../../git-context/sessionDirResolver.js'; +import { runGit } from '../../git-review/gitRunner.js'; +import { resolveReviewScope, withSessionReviewWorkspace } from '../../git-review/scopeResolver.js'; +import { loadReviewEvidence, reviewWorkspaceFingerprintIsCurrent } from '../reviewEvidence.js'; +import { usableReviewChangeSet } from '../reviewEvidenceSafety.js'; +import { buildReviewPrompt } from '../reviewPrompt.js'; +import type { TurnChangeSetDetail } from '../../../shared/turnChangeSet.js'; + +let fixtureRoot: string | null = null; +afterEach(async () => { + runtime.captured = null; + vi.clearAllMocks(); + if (fixtureRoot) await fs.rm(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = null; +}); + +it('reviews real committed and dirty code in the Bash worktree despite an empty partial turn capture', async () => { + fixtureRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'cindy-self-review-'))); + const main = path.join(fixtureRoot, 'main'); + const actual = path.join(fixtureRoot, 'actual'); + await fs.mkdir(main); + const git = (args: string[], cwd = main) => runGit(args, { cwd }); + await git(['init', '-b', 'main']); + await git(['config', 'user.name', 'Review Test']); + await git(['config', 'user.email', 'review@example.test']); + await fs.writeFile(path.join(main, 'committed.ts'), 'export const committed = 1;\n'); + await fs.writeFile(path.join(main, 'dirty.ts'), 'export const current = 1;\n'); + await git(['add', 'committed.ts', 'dirty.ts']); + await git(['commit', '--no-gpg-sign', '-s', '-m', 'baseline']); + await git(['worktree', 'add', '-b', 'integration', actual]); + await fs.writeFile(path.join(actual, 'committed.ts'), 'export const committed = 0;\n'); + await git(['add', 'committed.ts'], actual); + await git(['commit', '--no-gpg-sign', '-s', '-m', 'committed change'], actual); + await fs.writeFile(path.join(actual, 'dirty.ts'), 'export const current = 0;\n'); + + let commands = [JSON.stringify({ toolName: 'Bash', input: { command: `cd "${actual}" && git status --short` } })]; + runtime.resolveScope.mockImplementation((sessionId: string) => resolveReviewScope(sessionId, { + getSessionRow: async () => ({ id: 'source', workingDir: main, worktreePath: null, remoteHostId: null }), + getManagedWorktreePath: () => null, + resolveSessionDir: (input) => resolveSessionGitDir(input, { + recentToolUseContents: async () => commands, probeGitDir: readGitHead, + }), + git: runGit, + })); + const sourceScope = await runtime.resolveScope('source'); + expect(sourceScope.repoRoot).toBe(actual); + + runtime.captured = { + id: 'turn', sessionId: 'source', anchorClientId: 'message', provider: 'claude-code', + providerTurnId: null, cwd: main, state: 'partial', workspaceState: 'applied', isReversible: false, + incompleteReasons: ['opaque-tool', 'concurrent-workspace'], createdAt: 1, completedAt: 2, + files: [], diffs: [], fileCount: 0, additions: 0, deletions: 0, + } satisfies TurnChangeSetDetail; + await withSessionReviewWorkspace('source', sourceScope.repoRoot, async () => { + const evidence = await loadReviewEvidence({ + sourceSessionId: 'source', workingDir: actual, attachments: [], focus: '检查代码质量', + explicitArtifactGrant: { paths: [], pathIdentities: new Map(), inlineAttachmentKeys: [] }, + }); + expect(evidence.workspace?.dirty).toBe(true); + expect(evidence.branch?.diffs.map((diff) => diff.path)).toContain('committed.ts'); + expect(evidence.workspace?.diffs.unstaged.map((diff) => diff.path)).toContain('dirty.ts'); + evidence.changeSet = usableReviewChangeSet(evidence.changeSet, actual, { + hasGitBaseline: !!evidence.workspaceFingerprint, hasExplicitArtifacts: false, + }); + expect(evidence.changeSet).toBeNull(); + const prompt = buildReviewPrompt({ ...evidence, historyCaptureIncomplete: true }).prompt; + expect(prompt).toContain('committed.ts'); + expect(prompt).toContain('dirty.ts'); + expect(prompt).toContain('历史改动记录不完整'); + expect(evidence.workspaceFingerprint).toBeTruthy(); + + // Later task telemetry may point back to main; this Review keeps its tree. + commands = [JSON.stringify({ toolName: 'exec', input: { cwd: main } })]; + expect(await reviewWorkspaceFingerprintIsCurrent('source', evidence.workspaceFingerprint)).toBe(true); + await fs.writeFile(path.join(actual, 'dirty.ts'), 'export const current = 2;\n'); + expect(await reviewWorkspaceFingerprintIsCurrent('source', evidence.workspaceFingerprint)).toBe(false); + }); + expect((await runtime.resolveScope('source')).repoRoot).toBe(main); +}, 30_000); diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts new file mode 100644 index 00000000000..e3a0c454d62 --- /dev/null +++ b/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts @@ -0,0 +1,56 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { assertTrusted, parent, showMessageBox, showOpenDialog } = vi.hoisted(() => ({ + assertTrusted: vi.fn(), + parent: { isDestroyed: vi.fn(() => false) }, + showMessageBox: vi.fn(), + showOpenDialog: vi.fn(), +})); +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: () => parent }, + dialog: { showMessageBox, showOpenDialog }, +})); +vi.mock('../../i18n.js', () => ({ t: (key: string) => key })); +vi.mock('../../security/trustedAppRenderer.js', () => ({ assertTrustedAppRendererEvent: assertTrusted })); + +import { selectReviewWorkspace } from '../reviewWorkspaceSelection.js'; + +beforeEach(() => { + vi.clearAllMocks(); + parent.isDestroyed.mockReturnValue(false); + assertTrusted.mockImplementation(() => {}); + showMessageBox.mockResolvedValue({ response: 1 }); + showOpenDialog.mockResolvedValue({ canceled: false, filePaths: ['/repo/actual'] }); +}); + +describe('Review directory selection', () => { + const event = { sender: {} } as IpcMainInvokeEvent; + + it('uses the directory picked in a native dialog without changing the source task', async () => { + expect(await selectReviewWorkspace(event, '/repo/main')).toBe('/repo/actual'); + expect(assertTrusted).toHaveBeenCalledWith(event); + expect(showMessageBox).toHaveBeenCalledWith(parent, expect.objectContaining({ detail: '/repo/main' })); + expect(showOpenDialog).toHaveBeenCalledWith(parent, expect.objectContaining({ properties: ['openDirectory'] })); + }); + + it('lets the user explicitly review the current tree even without a diff', async () => { + showMessageBox.mockResolvedValue({ response: 0 }); + expect(await selectReviewWorkspace(event, '/repo/main')).toBe('/repo/main'); + expect(showOpenDialog).not.toHaveBeenCalled(); + }); + + it.each(['question', 'picker', 'closed-window'])('cancels cleanly at %s', async (stage) => { + if (stage === 'question') showMessageBox.mockResolvedValue({ response: 2 }); + if (stage === 'picker') showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + if (stage === 'closed-window') parent.isDestroyed.mockReturnValue(true); + expect(await selectReviewWorkspace(event, '/repo/main')).toBeNull(); + }); + + it('rejects an untrusted caller before opening a directory picker', async () => { + assertTrusted.mockImplementation(() => { throw new Error('untrusted renderer'); }); + await expect(selectReviewWorkspace(event, '/repo/main')).rejects.toThrow('untrusted renderer'); + expect(showMessageBox).not.toHaveBeenCalled(); + expect(showOpenDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/reviewer/reviewEvidence.ts b/apps/desktop/src/main/reviewer/reviewEvidence.ts index 57454c4a56d..29d5c752bc1 100644 --- a/apps/desktop/src/main/reviewer/reviewEvidence.ts +++ b/apps/desktop/src/main/reviewer/reviewEvidence.ts @@ -109,12 +109,8 @@ function mapReviewWorkspace( } /** - * The branch's own commits, read only when the tree is clean. - * - * With uncommitted work present that work is the review target. Once it is - * committed the tree goes clean and the last turn is no longer a faithful - * stand-in for the branch — reviewing it would silently cover one turn while - * appearing to cover the whole branch. + * The branch's own commits. Read independently of current uncommitted edits; + * a last-turn record is not a faithful substitute for the whole deliverable. */ async function loadReviewBranchEvidence( sessionId: string, @@ -691,10 +687,10 @@ export async function loadReviewEvidence(input: { const changeSet = sanitizeReviewChangeSet(rawChangeSet).value; const workspaceSnapshot = await readReviewWorkspaceSnapshot(input.sourceSessionId); const workspace = workspaceSnapshot?.workspace ?? null; - // Only when there is no uncommitted work: that work, when present, is what - // the user is asking about, and reading the branch as well would bury it. + // Self-review covers the deliverable: branch commits plus current edits. + // A dirty tree must not hide the already committed part of that work. const branchEvidence = - workspace && !workspace.dirty && !workspace.disabledReason + workspace && !workspace.disabledReason ? await loadReviewBranchEvidence(input.sourceSessionId, readReviewBranchDiff) : { branch: null }; diff --git a/apps/desktop/src/main/reviewer/reviewEvidenceSafety.ts b/apps/desktop/src/main/reviewer/reviewEvidenceSafety.ts index b8be0f85338..e1f8c84673e 100644 --- a/apps/desktop/src/main/reviewer/reviewEvidenceSafety.ts +++ b/apps/desktop/src/main/reviewer/reviewEvidenceSafety.ts @@ -229,3 +229,20 @@ export function reviewChangeSetContentPaths( unenumerable || seen.size + redactedChangeEntryCount(changeSet) < changeSet.fileCount, }; } + +/** Turn capture is a fallback, not a prerequisite for reviewing current code. + * Never bind an incomplete historical record as if it were a full baseline. + * Fresh Git evidence / explicit artifacts have their own identity checks. + */ +export function usableReviewChangeSet( + changeSet: TurnChangeSetDetail | null, + workingDir: string, + current: { hasGitBaseline: boolean; hasExplicitArtifacts: boolean }, +): TurnChangeSetDetail | null { + if ( + changeSet && + (current.hasGitBaseline || current.hasExplicitArtifacts) && + reviewChangeSetContentPaths(changeSet, workingDir).truncated + ) return null; + return changeSet; +} diff --git a/apps/desktop/src/main/reviewer/reviewPrompt.ts b/apps/desktop/src/main/reviewer/reviewPrompt.ts index cc2b793e95a..8f875dea3b1 100644 --- a/apps/desktop/src/main/reviewer/reviewPrompt.ts +++ b/apps/desktop/src/main/reviewer/reviewPrompt.ts @@ -61,6 +61,7 @@ export interface ReviewBranchEvidence { export interface BuildReviewPromptInput { focus?: string; context: ReviewContextMessage[]; + historyCaptureIncomplete?: boolean; workspace: ReviewWorkspaceEvidence | null; branch?: ReviewBranchEvidence | null; /** Why no branch evidence is present, when it should have been. */ @@ -193,7 +194,7 @@ function cappedBucketSection( ); } -function workspaceDiffSection(workspace: ReviewWorkspaceEvidence): string { +function workspaceDiffSection(workspace: ReviewWorkspaceEvidence, budget = MAX_DIFF_CHARS): string { const parts: string[] = []; const diffs = [...workspace.diffs.staged, ...workspace.diffs.unstaged]; if (diffs.length > 0) parts.push(diffSection(diffs)); @@ -207,10 +208,10 @@ function workspaceDiffSection(workspace: ReviewWorkspaceEvidence): string { } return '(Git 状态显示存在未提交变更,但没有可嵌入的文本补丁;请用只读工具检查列出的工作区文件。)'; } - return clip(parts.join('\n\n'), MAX_DIFF_CHARS); + return clip(parts.join('\n\n'), budget); } -function branchDiffSection(branch: ReviewBranchEvidence): string { +function branchDiffSection(branch: ReviewBranchEvidence, budget = MAX_DIFF_CHARS): string { const parts: string[] = []; if (branch.diffs.length > 0) parts.push(diffSection(branch.diffs)); if (branch.capped) parts.push(cappedBucketSection('分支', branch.capped)); @@ -220,7 +221,7 @@ function branchDiffSection(branch: ReviewBranchEvidence): string { } return '(本分支相对基线有变更,但没有可嵌入的文本补丁;请用只读工具检查列出的文件。)'; } - return clip(parts.join('\n\n'), MAX_DIFF_CHARS); + return clip(parts.join('\n\n'), budget); } function coverageSection(input: BuildReviewPromptInput): string { @@ -229,7 +230,7 @@ function coverageSection(input: BuildReviewPromptInput): string { input.workspace.diffs.capped?.staged ? '已暂存' : null, input.workspace.diffs.capped?.unstaged ? '未暂存' : null, ].filter(Boolean); - const summary = `当前 Git 工作区有 ${input.workspace.totalFiles} 个未提交文件(已暂存 ${input.workspace.stagedFiles}、未暂存 ${input.workspace.unstagedFiles}、未跟踪 ${input.workspace.untrackedFiles})。`; + const summary = `当前 Git 工作区有 ${input.workspace.totalFiles} 个未提交文件(已暂存 ${input.workspace.stagedFiles}、未暂存 ${input.workspace.unstagedFiles}、未跟踪 ${input.workspace.untrackedFiles})。${input.branch ? `审查同时包含本分支相对基线 ${inlineLabel(input.branch.baseRef)} 的 ${input.branch.fileCount} 个文件的已提交变更;以磁盘上的当前代码为最终状态。` : ''}${input.branchUnavailableReason ? `已提交分支差异无法读取(${inlineWarning(input.branchUnavailableReason)}),不得声称已覆盖全部分支改动。` : ''}`; const sensitiveNote = (input.workspace.sensitiveFilesOmitted ?? 0) > 0 ? `其中 ${input.workspace.sensitiveFilesOmitted} 份敏感路径变更已从证据中排除;不得读取或评价其内容。` @@ -279,10 +280,13 @@ function coverageSection(input: BuildReviewPromptInput): string { } function changeEvidenceSection(input: BuildReviewPromptInput): string { - // Uncommitted work first: it is what the user is looking at right now. - // Then the branch's own commits, which are the deliverable once committed. - // The last turn is a fallback for tasks with no branch of their own. - if (input.workspace?.dirty) return workspaceDiffSection(input.workspace); + // Both describe the deliverable, with current edits applied over HEAD. + if (input.workspace?.dirty) { + return clip([ + ...(input.branch ? ['## 已提交分支改动\n\n' + branchDiffSection(input.branch, Math.floor(MAX_DIFF_CHARS / 2))] : []), + '## 当前未提交修改\n\n' + workspaceDiffSection(input.workspace, input.branch ? Math.floor(MAX_DIFF_CHARS / 2) : MAX_DIFF_CHARS), + ].join('\n\n'), MAX_DIFF_CHARS); + } if (input.branch) return branchDiffSection(input.branch); if (input.changeSet) return diffSection(input.changeSet.diffs); return '(无 Git 补丁。)'; @@ -352,7 +356,9 @@ export function buildReviewPrompt(input: BuildReviewPromptInput): BuiltReviewPro : '' }\n` : '(没有显式附件;请根据任务上下文,用只读工具检查当前工作目录中的实际成果。)'; - const coverage = coverageSection(input); + const coverage = coverageSection(input) + (input.historyCaptureIncomplete + ? '\n历史改动记录不完整,本次不把它当作完整补丁。审查以当前 Git 证据和显式成果为准;未提供的历史或未跟踪成果不得声称已覆盖。' + : ''); const prompt = `你是 Cindy 的独立成果审查员。你在一个全新、无开发历史记忆的只读任务中工作。 diff --git a/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts b/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts new file mode 100644 index 00000000000..47d3c8625fd --- /dev/null +++ b/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; + +import { BrowserWindow, dialog, type IpcMainInvokeEvent } from 'electron'; + +import { t } from '../i18n.js'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; + +/** No changes were found in the task's directory. Let the user choose the + * actual checkout, or explicitly review the current code without a diff. + * Selection grants only this Review read access; it never moves the source task. + */ +export async function selectReviewWorkspace( + event: IpcMainInvokeEvent, + workingDir: string, +): Promise { + assertTrustedAppRendererEvent(event); + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) return null; + const choice = await dialog.showMessageBox(parent, { + type: 'question', + title: t('review.workspaceSelection.title'), + message: t('review.workspaceSelection.message'), + detail: workingDir, + buttons: [ + t('review.workspaceSelection.current'), + t('review.workspaceSelection.choose'), + t('common.cancel'), + ], + defaultId: 1, + cancelId: 2, + noLink: true, + }); + if (parent.isDestroyed() || choice.response === 2) return null; + if (choice.response === 0) return workingDir; + const picked = await dialog.showOpenDialog(parent, { + title: t('review.workspaceSelection.title'), + buttonLabel: t('review.workspaceSelection.open'), + defaultPath: path.dirname(workingDir), + properties: ['openDirectory'], + }); + return !parent.isDestroyed() && !picked.canceled && picked.filePaths[0] ? picked.filePaths[0] : null; +} diff --git a/apps/desktop/src/renderer/__tests__/systemCardReview.test.tsx b/apps/desktop/src/renderer/__tests__/systemCardReview.test.tsx index a6f6aa9b37a..435ba603000 100644 --- a/apps/desktop/src/renderer/__tests__/systemCardReview.test.tsx +++ b/apps/desktop/src/renderer/__tests__/systemCardReview.test.tsx @@ -85,6 +85,18 @@ describe('SystemCard Review', () => { expect(screen.getByTestId('location').textContent).toBe('/cc-agent/review-task'); }); + it('keeps an unverified conclusion visible and resolves its directory from the persisted Review target', () => { + renderCard({ + status: 'failed', failureCode: 'artifact-unavailable', reviewerSessionId: 'review-task', + result: 'P1: src/auth.ts:42 needs attention', + workspace: { workingDir: '/repo/actual', baseRef: 'main', hasUncommittedChanges: true }, + }, '/repo/main'); + expect(screen.getByText('chat.systemCard.review.unverifiedResult')).toBeTruthy(); + expect(screen.getByTestId('review-markdown').getAttribute('data-working-dir')).toBe('/repo/actual'); + expect(screen.getByTestId('review-markdown').getAttribute('data-allow-privileged-links')).toBe('false'); + expect(screen.getByText(/\/repo\/actual/)).toBeTruthy(); + }); + it('renders a completed stale result with its rerun reason', () => { renderCard({ status: 'failed', diff --git a/apps/desktop/src/renderer/components/chat/SystemCard.tsx b/apps/desktop/src/renderer/components/chat/SystemCard.tsx index 0908cd61fbd..92eb0241927 100644 --- a/apps/desktop/src/renderer/components/chat/SystemCard.tsx +++ b/apps/desktop/src/renderer/components/chat/SystemCard.tsx @@ -1287,6 +1287,10 @@ function ReviewCard({ data, workingDir }: { data?: Record; work const reviewerSessionId = typeof data?.reviewerSessionId === 'string' ? data.reviewerSessionId : ''; const result = typeof data?.result === 'string' ? data.result : ''; + const workspace = data?.workspace && typeof data.workspace === 'object' + ? data.workspace as Record : null; + const reviewedDir = typeof workspace?.workingDir === 'string' ? workspace.workingDir : workingDir; + const reviewedBase = typeof workspace?.baseRef === 'string' ? workspace.baseRef : ''; const error = typeof data?.error === 'string' ? data.error : ''; const failureCode = readReviewFailureCode(data?.failureCode) ?? reviewFailureCodeFromLegacyError(error); @@ -1327,6 +1331,12 @@ function ReviewCard({ data, workingDir }: { data?: Record; work )} + {workspace && reviewedDir && ( +

+ {t('chat.systemCard.review.directory')}{reviewedDir} + {reviewedBase && <> · {t('chat.systemCard.review.baseline')}{reviewedBase}} +

+ )} {status === 'running' && (

{t('chat.systemCard.review.readOnlyHint')} @@ -1342,12 +1352,13 @@ function ReviewCard({ data, workingDir }: { data?: Record; work {failureMessage}

)} - {(status === 'completed' || status === 'stale') && result && ( + {result && status !== 'running' && (
+ {status === 'failed' &&

{t('chat.systemCard.review.unverifiedResult')}

}
)} diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 32fce959012..a22734a0607 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -3331,6 +3331,7 @@ export function CCAgentSessionView({ return { handled: true, accepted: true, message }; } catch (err) { const ipcError = extractIpcError(err); + if (ipcError?.code === 'MUTATION_CANCELLED') return { handled: true, accepted: false, message }; toast.error( ipcError ? t('review.toast.failed') diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 9b0ee188880..2f30b213d06 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -10185,9 +10185,12 @@ "sourceConversationChanged": "The source task changed. Run /review again for the current task.", "sourceFilesChanged": "The source task files changed. Run /review again for the current result.", "artifactChanged": "A review artifact changed. Run /review again for the current result.", - "artifactUnavailable": "Cindy couldn't safely read the review artifacts. Check the workspace or attachments, then run /review again.", + "artifactUnavailable": "Cindy could not prepare the files for review. Check the selected directory or attachments, then run /review again.", "providerFailed": "The review task failed" - } + }, + "directory": "Directory: ", + "baseline": "Base branch: ", + "unverifiedResult": "The reviewer produced these findings, but their validity could not be verified." } }, "markdownRenderer": { @@ -13317,6 +13320,13 @@ "inline": "in-memory attachment", "allow": "Allow once", "cancel": "Cancel" + }, + "workspaceSelection": { + "title": "Choose code to review", + "message": "No code diff is available in this directory. Review the current code, or select the directory where you worked.", + "current": "Review this directory", + "choose": "Choose another directory", + "open": "Review" } }, "learn": { diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index cf21a27b81e..6bd73ca1091 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -10168,9 +10168,12 @@ "sourceConversationChanged": "元のタスクが変更されました。現在のタスクに対して /review をもう一度実行してください。", "sourceFilesChanged": "元のタスクのファイルが変更されました。現在の成果に対して /review をもう一度実行してください。", "artifactChanged": "レビュー対象の成果が変更されました。現在の成果に対して /review をもう一度実行してください。", - "artifactUnavailable": "Cindy はレビュー対象の成果を安全に読み取れませんでした。ワークスペースまたは添付ファイルを確認して、/review をもう一度実行してください。", + "artifactUnavailable": "レビュー用のファイルを準備できませんでした。選択したフォルダーまたは添付ファイルを確認し、/review を再実行してください。", "providerFailed": "レビュータスクの実行に失敗しました" - } + }, + "directory": "フォルダー:", + "baseline": "基準ブランチ:", + "unverifiedResult": "以下の指摘は生成されましたが、現在も有効か確認できませんでした。" } }, "markdownRenderer": { @@ -13289,6 +13292,13 @@ "inline": "メモリ内の添付ファイル", "allow": "今回のみ許可", "cancel": "キャンセル" + }, + "workspaceSelection": { + "title": "レビューするコードを選択", + "message": "このディレクトリには利用できるコード差分がありません。現在のコードをレビューするか、実際に開発したディレクトリを選択できます。", + "current": "このフォルダーを確認", + "choose": "別のフォルダーを選択", + "open": "レビュー" } }, "learn": { diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 533f8d72b29..679bbca32ff 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -10168,9 +10168,12 @@ "sourceConversationChanged": "원본 작업이 변경되었습니다. 현재 작업에 대해 /review를 다시 실행하세요.", "sourceFilesChanged": "원본 작업의 파일이 변경되었습니다. 현재 결과물에 대해 /review를 다시 실행하세요.", "artifactChanged": "검토 대상 결과물이 변경되었습니다. 현재 결과물에 대해 /review를 다시 실행하세요.", - "artifactUnavailable": "Cindy가 검토 대상 결과물을 안전하게 읽지 못했습니다. 작업 공간 또는 첨부 파일을 확인한 후 /review를 다시 실행하세요.", + "artifactUnavailable": "검토할 파일을 준비하지 못했습니다. 선택한 폴더나 첨부 파일을 확인한 후 /review를 다시 실행하세요.", "providerFailed": "검토 작업 실행에 실패했습니다" - } + }, + "directory": "폴더: ", + "baseline": "기준 브랜치: ", + "unverifiedResult": "아래 결과가 생성되었지만 현재도 유효한지 확인하지 못했습니다." } }, "markdownRenderer": { @@ -13289,6 +13292,13 @@ "inline": "메모리 첨부 파일", "allow": "이번만 허용", "cancel": "취소" + }, + "workspaceSelection": { + "title": "검토할 코드 선택", + "message": "이 디렉터리에는 사용할 수 있는 코드 차이가 없습니다. 현재 코드를 검토하거나 실제 개발한 디렉터리를 선택하세요.", + "current": "현재 폴더 검토", + "choose": "다른 폴더 선택", + "open": "검토" } }, "learn": { 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 530cd2fee54..4cee33795eb 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -10168,9 +10168,12 @@ "sourceConversationChanged": "来源任务已变化,请针对当前任务重新运行 /review。", "sourceFilesChanged": "来源任务的文件已变化,请针对当前成果重新运行 /review。", "artifactChanged": "审查成果已变化,请针对当前成果重新运行 /review。", - "artifactUnavailable": "Cindy 无法安全读取审查成果,请检查工作区或附件后重新运行 /review。", + "artifactUnavailable": "Cindy 无法准备审查文件,请检查所选目录或附件后重新运行 /review。", "providerFailed": "审查任务执行失败" - } + }, + "directory": "目录:", + "baseline": "基线分支:", + "unverifiedResult": "以下结论已生成,但未能验证是否仍然有效。" } }, "markdownRenderer": { @@ -13289,6 +13292,13 @@ "inline": "内存附件", "allow": "仅本次允许", "cancel": "取消" + }, + "workspaceSelection": { + "title": "选择要审查的代码", + "message": "这个目录没有可用的代码差异。可以审查当前代码,或选择实际开发的目录。", + "current": "审查当前目录", + "choose": "选择其他目录", + "open": "审查" } }, "learn": { 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 02f454feb4c..0f44057f73c 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -10168,8 +10168,11 @@ "sourceFilesChanged": "來源任務的檔案已變更,請針對當前成果重新執行 /review。", "artifactChanged": "審查成果已變更,請針對當前成果重新執行 /review。", "providerFailed": "審查任務執行失敗", - "artifactUnavailable": "Cindy 無法安全讀取審查成品,請檢查工作區或附件後重新執行 /review" - } + "artifactUnavailable": "Cindy 無法準備審查檔案,請檢查所選目錄或附件後重新執行 /review。" + }, + "directory": "目錄:", + "baseline": "基準分支:", + "unverifiedResult": "以下結論已產生,但未能驗證是否仍然有效。" } }, "markdownRenderer": { @@ -13289,6 +13292,13 @@ "inline": "記憶體附件", "allow": "僅本次允許", "cancel": "取消" + }, + "workspaceSelection": { + "title": "選擇要審查的程式碼", + "message": "這個目錄沒有可用的程式碼差異。可以審查目前程式碼,或選擇實際開發的目錄。", + "current": "審查目前目錄", + "choose": "選擇其他目錄", + "open": "審查" } }, "learn": { diff --git a/apps/desktop/src/shared/reviewRun.ts b/apps/desktop/src/shared/reviewRun.ts index c81254148e3..2b13961f6d0 100644 --- a/apps/desktop/src/shared/reviewRun.ts +++ b/apps/desktop/src/shared/reviewRun.ts @@ -147,6 +147,12 @@ export interface ReviewRunMeta { reviewerSessionId?: string; status: ReviewRunStatus; targetKind: ReviewTargetKind; + /** Exact host-selected read scope, never the source task's stale label. */ + workspace?: { + workingDir: string; + baseRef?: string; + hasUncommittedChanges: boolean; + }; startedAt: number; /** * Present on runs created by owner-aware clients. Optional only so cards diff --git a/docs/product-rules/review-product-direction.md b/docs/product-rules/review-product-direction.md index 0e4551a532d..00ac24a03a3 100644 --- a/docs/product-rules/review-product-direction.md +++ b/docs/product-rules/review-product-direction.md @@ -67,6 +67,14 @@ DeepSeek 等模型只要能在相应 harness 的普通任务中使用,也应 权限、目录等冻结配置。 - Reviewer 只复核,不自动修码。修复回到来源任务,修复后的成果需要再次复核。 +### 代码自审的审阅对象与恢复 + +- `/review` 默认覆盖当前开发目录中本分支相对基线的已提交改动及未提交修改,不能因为工作区有未提交修改就省略分支改动。 +- 从任务的实际开发目录取证,Reviewer 读取和完成后的有效性校验也固定在同一目录;不能用 worktree 的补丁配项目主目录里的源码。来源任务本身的目录不因此被修改。 +- 找不到代码改动时,本机入口让用户选择实际开发目录,或明确审查当前目录的代码。结果卡展示实际目录与比较基线。 +- 历史变更捕获服务于回看与撤销,不是 Review 的启动资格。命令调用、并发或摘要截断造成的历史记录缺口,不能阻止基于实时 Git 证据或显式成果的审查。缺口必须说明,不能把不完整历史冒充完整比较基线。 +- Reviewer 已生成结论但后续校验失败时,保留结论作为未验证的只读记录,不能显示为成功或启用文件操作链接;不得清空结论后误导用户认为没有产出。 + ### 1.4 方法与专业边界 Review 可以借鉴成熟 code review 的缺陷标准、P0-P3、精确证据、结构化输出、只报告本次 From 2abcfbd810094077c94505b6a467aa76d80b760b Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:02:36 +0800 Subject: [PATCH 002/416] fix(review): recover ambiguous and non-Git workspaces Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../gitContextSessionDirResolver.test.ts | 11 +++++ .../main/git-context/sessionDirResolver.ts | 8 +++- apps/desktop/src/main/maker-ipc/register.ts | 12 ++---- .../reviewWorkspaceSelection.test.ts | 40 ++++++++++++++++++- .../main/reviewer/reviewWorkspaceSelection.ts | 15 +++++++ 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts b/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts index e342049c39a..08582197569 100644 --- a/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts +++ b/apps/desktop/src/main/__tests__/gitContextSessionDirResolver.test.ts @@ -77,6 +77,17 @@ describe('extractDirCandidate', () => { ]) expect(extractDirCandidate(toolUse('Bash', { command }))).toBeNull(); }); + it.each([ + 'cd /repo/main && cd ../feature && git diff', + 'cd /repo/main && git status; cd /repo/feature && git diff', + 'cd /repo/main &&\ncd -- /repo/feature && git diff', + 'cd /repo/main && command cd /repo/feature && git diff', + 'cd /repo/main && pushd /repo/feature && git diff', + 'cd /repo/main && popd && git diff', + ])('does not mistake the first directory for the final one: %s', (command) => { + expect(extractDirCandidate(toolUse('Bash', { command }))).toBeNull(); + }); + it('相对路径不采纳(cwd / file_path 都要求绝对)→ null', () => { expect(extractDirCandidate(toolUse('exec', { command: 'ls', cwd: '../wt-a' }))).toBeNull(); expect(extractDirCandidate(toolUse('Edit', { file_path: 'src/y.ts' }))).toBeNull(); diff --git a/apps/desktop/src/main/git-context/sessionDirResolver.ts b/apps/desktop/src/main/git-context/sessionDirResolver.ts index 9f82f255803..855b0d1d92b 100644 --- a/apps/desktop/src/main/git-context/sessionDirResolver.ts +++ b/apps/desktop/src/main/git-context/sessionDirResolver.ts @@ -105,12 +105,16 @@ export function extractDirCandidate( // Claude's Bash tool does not report cwd. A literal leading `cd ... &&` // identifies where this command ran; ordinary Bash/Read remain non-signals. - // Do not evaluate variables, substitutions, relative paths or later cd's. + // Do not evaluate variables, substitutions or relative paths. If another + // directory switch appears later, the leading directory is not a reliable + // signal; leave the actual directory to other telemetry or explicit selection. if (toolName === 'Bash' && typeof (input as { command?: unknown }).command === 'string') { const command = (input as { command: string }).command; const match = /^\s*cd\s+(?:--\s+)?(?:'([^'\n]+)'|"([^"\n]+)"|([^\s;&|]+))\s*&&/.exec(command); const dir = match?.[1] ?? match?.[2] ?? match?.[3]; - if (dir && !/[\x00-\x1f$`\\*?{}]/.test(dir) && path.isAbsolute(dir)) { + const remaining = match ? command.slice(match[0].length) : ''; + const hasLaterDirectorySwitch = /(?:^|[\s;&|()'"])(?:cd|pushd|popd)(?=[\s;&|()'"]|$)/.test(remaining); + if (dir && !hasLaterDirectorySwitch && !/[\x00-\x1f$`\\*?{}]/.test(dir) && path.isAbsolute(dir)) { return posixDriveToWin32(dir, pathPlatform); } } diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 7b94521e729..1b80e903ba5 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -329,7 +329,7 @@ import { messageToCamel, setSessionRuntimeProjector } from '../localDb/mapper.js import { visibleMessageTextForConversationSearch } from '../localDb/conversationSearch.pure.js'; import { buildReviewPrompt } from '../reviewer/reviewPrompt.js'; import { resolveReviewScope, withSessionReviewWorkspace } from '../git-review/scopeResolver.js'; -import { selectReviewWorkspace } from '../reviewer/reviewWorkspaceSelection.js'; +import { selectReviewWorkspace, shouldSelectReviewWorkspace } from '../reviewer/reviewWorkspaceSelection.js'; import { listReviewHistoricalAttachments, loadReviewEvidence, @@ -8368,14 +8368,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) sourceArtifactFingerprint = prepared.fingerprint; }; await prepareEvidence(); - // An empty main checkout commonly means the work happened in a linked - // worktree. Historical turn metadata cannot tell us which current code + // An empty checkout or non-Git source commonly means the work happened + // elsewhere. Historical turn metadata cannot tell us which current code // the user wants; ask about the directory instead of refusing the run. - if ( - evidence.workspace && !evidence.workspace.disabledReason && - !evidence.workspace.dirty && !evidence.branch && - evidence.artifacts.length === 0 && !evidence.focusPath && !isDeviceLinkInvoke() - ) { + if (shouldSelectReviewWorkspace(evidence, isDeviceLinkInvoke())) { const selectedDir = await selectReviewWorkspace(event as IpcMainInvokeEvent, sourceWorkingDir); if (!selectedDir) throwIpcError('MUTATION_CANCELLED', 'Review workspace selection cancelled'); workspaceSelected = true; diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts index e3a0c454d62..28b73ea1571 100644 --- a/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts +++ b/apps/desktop/src/main/reviewer/__tests__/reviewWorkspaceSelection.test.ts @@ -14,7 +14,18 @@ vi.mock('electron', () => ({ vi.mock('../../i18n.js', () => ({ t: (key: string) => key })); vi.mock('../../security/trustedAppRenderer.js', () => ({ assertTrustedAppRendererEvent: assertTrusted })); -import { selectReviewWorkspace } from '../reviewWorkspaceSelection.js'; +import { selectReviewWorkspace, shouldSelectReviewWorkspace } from '../reviewWorkspaceSelection.js'; +import type { LoadedReviewEvidence } from '../reviewEvidence.js'; + +function emptyEvidence(): Pick { + return { + workspace: { + dirty: false, totalFiles: 0, stagedFiles: 0, unstagedFiles: 0, untrackedFiles: 0, + disabledReason: null, diffs: { staged: [], unstaged: [] }, + }, + branch: null, artifacts: [], focusPath: null, + }; +} beforeEach(() => { vi.clearAllMocks(); @@ -27,6 +38,33 @@ beforeEach(() => { describe('Review directory selection', () => { const event = { sender: {} } as IpcMainInvokeEvent; + it.each([null, 'non-git'] as const)('recovers the actual checkout from an empty %s source', async (reason) => { + const evidence = emptyEvidence(); + evidence.workspace!.disabledReason = reason; + expect(shouldSelectReviewWorkspace(evidence, false)).toBe(true); + expect(await selectReviewWorkspace(event, '/projects')).toBe('/repo/actual'); + }); + + it.each(['git-unavailable', 'no-workdir', 'invalid-worktree', 'remote-session', 'no-session', 'unknown'] as const)( + 'does not hide a %s failure behind a directory picker', (reason) => { + const evidence = emptyEvidence(); + evidence.workspace!.disabledReason = reason; + expect(shouldSelectReviewWorkspace(evidence, false)).toBe(false); + }, + ); + + it('preserves explicit targets, existing code changes, and remote caller behavior', () => { + const evidence = emptyEvidence(); + expect(shouldSelectReviewWorkspace(evidence, true)).toBe(false); + expect(shouldSelectReviewWorkspace({ ...evidence, workspace: null }, false)).toBe(false); + expect(shouldSelectReviewWorkspace({ ...evidence, workspace: { ...evidence.workspace!, dirty: true } }, false)).toBe(false); + expect(shouldSelectReviewWorkspace({ ...evidence, artifacts: [{ kind: 'file', label: 'result.ts' }] }, false)).toBe(false); + expect(shouldSelectReviewWorkspace({ ...evidence, focusPath: '/repo/file.ts' }, false)).toBe(false); + expect(shouldSelectReviewWorkspace({ ...evidence, branch: { + baseRef: 'main', baseOid: 'base', mergeBaseOid: 'base', fileCount: 1, diffs: [], capped: null, + } }, false)).toBe(false); + }); + it('uses the directory picked in a native dialog without changing the source task', async () => { expect(await selectReviewWorkspace(event, '/repo/main')).toBe('/repo/actual'); expect(assertTrusted).toHaveBeenCalledWith(event); diff --git a/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts b/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts index 47d3c8625fd..b586ff1a5f7 100644 --- a/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts +++ b/apps/desktop/src/main/reviewer/reviewWorkspaceSelection.ts @@ -4,6 +4,21 @@ import { BrowserWindow, dialog, type IpcMainInvokeEvent } from 'electron'; import { t } from '../i18n.js'; import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; +import type { LoadedReviewEvidence } from './reviewEvidence.js'; + +/** A non-Git source directory also needs recovery, but a failed Git read or a + * remote task must not be turned into a local directory-selection fallback. + */ +export function shouldSelectReviewWorkspace( + evidence: Pick, + deviceLinkInvoke: boolean, +): boolean { + const workspace = evidence.workspace; + return !deviceLinkInvoke && !!workspace && + (!workspace.disabledReason || workspace.disabledReason === 'non-git') && + !workspace.dirty && !evidence.branch && + evidence.artifacts.length === 0 && !evidence.focusPath; +} /** No changes were found in the task's directory. Let the user choose the * actual checkout, or explicitly review the current code without a diff. From ee48a57468848389416f09cfd2c8edd0c26a84bc Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:21:38 +0800 Subject: [PATCH 003/416] test(review): keep remote guard ordering check current Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../src/main/__tests__/remoteAgentSetModelWiring.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/remoteAgentSetModelWiring.test.ts b/apps/desktop/src/main/__tests__/remoteAgentSetModelWiring.test.ts index d3585b69cae..fd05792f40d 100644 --- a/apps/desktop/src/main/__tests__/remoteAgentSetModelWiring.test.ts +++ b/apps/desktop/src/main/__tests__/remoteAgentSetModelWiring.test.ts @@ -67,11 +67,13 @@ describe('agent-device model selection wiring', () => { it('refuses Review for these tasks before collecting evidence', () => { const select = source.indexOf('.where(eq(sessions.id, request.sourceSessionId))'); const guard = source.indexOf('if (source.agentDeviceId) {', select); - const evidence = source.indexOf('const sourceWorkingDir = source.workingDir;', select); + const scope = source.indexOf('await resolveReviewScope(source.id)', select); + const evidence = source.indexOf('loadReviewEvidence({', select); expect(select).toBeGreaterThan(0); expect(guard).toBeGreaterThan(select); + expect(guard).toBeLessThan(scope); expect(guard).toBeLessThan(evidence); - expect(source.slice(guard, evidence)).toContain("throwIpcError('UNSUPPORTED_CAPABILITY'"); + expect(source.slice(guard, scope)).toContain("throwIpcError('UNSUPPORTED_CAPABILITY'"); }); it('keeps tasks created from these tasks on the same computer', () => { From 7dbc41935dbd77e3c8acf003bf46ccc35cecb7b4 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:43:55 +0800 Subject: [PATCH 004/416] test(review): verify branch gates in pinned workspace Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../reviewer/__tests__/reviewExternalInputWiring.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/reviewer/__tests__/reviewExternalInputWiring.test.ts b/apps/desktop/src/main/reviewer/__tests__/reviewExternalInputWiring.test.ts index 59909bca69d..0204163ca1d 100644 --- a/apps/desktop/src/main/reviewer/__tests__/reviewExternalInputWiring.test.ts +++ b/apps/desktop/src/main/reviewer/__tests__/reviewExternalInputWiring.test.ts @@ -95,10 +95,11 @@ describe('Review external input wiring', () => { ); expect(registerSource).toContain('if (changeSetContent.truncated) {'); // The workspace fingerprint pins HEAD, not the base being compared against, - // so both gates must recheck the branch baseline as well. + // so both gates must recheck the branch baseline as well, in the same + // pinned directory that supplied the reviewed evidence. expect( registerSource.match( - /if \(!\(await reviewBranchBaselineIsCurrent\(source\.id, evidence\.branch\)\)\)/g, + /if \(!\(await inReviewWorkspace\(\(\) => reviewBranchBaselineIsCurrent\(source\.id, evidence\.branch\)\)\)\)/g, ), ).toHaveLength(2); expect(registerSource).not.toContain( From 07c6df0285fb8b2b605360d3fd214313bf20818f Mon Sep 17 00:00:00 2001 From: Ken <9398ljlj@gmail.com> Date: Thu, 8 Oct 2026 20:50:27 +0800 Subject: [PATCH 005/416] feat: add native Cursor Agent ACP support Add Cursor CLI discovery, native ACP runtime, desktop and mobile selection, session lifecycle integration, model catalog, tests, and explicit limitations. Refs #1027 Inspired by the ACP transport and process-group handling in #2386. Signed-off-by: Ken <9398ljlj@gmail.com> --- .../hook-control/__tests__/defaults.test.ts | 10 + .../desktop/src/main/hook-control/defaults.ts | 25 +- .../__tests__/sessionCreateCursor.test.ts | 20 + .../src/main/localDb/chatHistoryReader.ts | 2 +- .../src/main/localDb/conversationSearch.ts | 2 +- .../src/main/localDb/dailyModelUsage.ts | 2 +- apps/desktop/src/main/localDb/ipc/bots.ts | 7 +- apps/desktop/src/main/localDb/ipc/history.ts | 2 +- apps/desktop/src/main/localDb/ipc/messages.ts | 11 +- .../localDb/ipc/pluginWorkspaceSessions.ts | 4 +- apps/desktop/src/main/localDb/ipc/sessions.ts | 12 +- apps/desktop/src/main/localDb/mapper.ts | 6 +- .../desktop/src/main/localDb/orcaTeamStore.ts | 4 +- apps/desktop/src/main/localDb/schema.ts | 18 +- .../__tests__/cursorModelCatalog.test.ts | 32 + .../__tests__/cursorNativeHost.test.ts | 54 ++ .../__tests__/cursorSessionStorage.test.ts | 28 + .../__tests__/newMakerDefaultsCache.test.ts | 19 +- .../__tests__/providerModelRefresh.test.ts | 2 + .../main/maker-host/catalog-to-descriptors.ts | 2 +- .../createDesktopProviderService.ts | 4 +- .../main/maker-host/cursor-auth-adapter.ts | 31 + .../maker-host/cursor-binary-discovery.ts | 51 ++ .../main/maker-host/cursor-model-catalog.ts | 30 + apps/desktop/src/main/maker-host/index.ts | 69 ++- .../main/maker-host/newMakerDefaultsCache.ts | 16 +- .../main/maker-host/provider-model-refresh.ts | 5 + .../src/main/maker-host/provider-service.ts | 11 +- .../src/main/maker-host/session-storage.ts | 7 +- .../__tests__/orcaTeamService.test.ts | 12 +- .../orcaWorkerCreationService.test.ts | 21 + .../sessionAgentSwitchHandler.test.ts | 17 + .../__tests__/sessionRequest.test.ts | 8 + .../src/main/maker-ipc/agentHandoff.ts | 2 +- .../main/maker-ipc/appDefaultModelControl.ts | 6 +- .../main/maker-ipc/orcaLifecycleService.ts | 13 +- .../maker-ipc/orcaProviderRoutingContext.ts | 6 +- .../maker-ipc/orcaWorkerCreationService.ts | 13 +- .../src/main/maker-ipc/providerHandlers.ts | 2 +- apps/desktop/src/main/maker-ipc/register.ts | 54 +- .../maker-ipc/sessionAgentSwitchHandler.ts | 15 +- .../main/maker-ipc/sessionEventPreparation.ts | 6 +- .../src/main/maker-ipc/sessionEventStream.ts | 2 +- .../maker-ipc/sessionExecutionSelection.ts | 2 +- .../src/main/maker-ipc/sessionRequest.ts | 8 +- .../sessionRuntimeHarnessSelection.ts | 2 +- .../maker-ipc/workingDirectoryPreflight.ts | 2 +- .../main/mcp-integrations/piEnvironment.ts | 3 +- .../__tests__/codex-process-registry.test.ts | 7 + .../src/main/process-monitor/agent-scan.ts | 2 +- .../src/main/process-monitor/sampler.ts | 1 + .../__tests__/cursorModelDefaults.test.ts | 7 + .../src/main/scheduler-host/model-defaults.ts | 1 + .../project-automation-loader.ts | 6 +- .../desktop/src/main/scheduler-host/runner.ts | 8 +- .../script-capability-broker.ts | 2 +- apps/desktop/src/main/skillhub/scanner.ts | 8 +- .../src/main/skillhub/usageAnalyzer.ts | 2 +- apps/desktop/src/main/skillhub/usageStore.ts | 4 +- .../main/usage/__tests__/usageHistory.test.ts | 4 + apps/desktop/src/main/usage/usageHistory.ts | 16 +- .../__tests__/cursorPermissionPolicy.test.ts | 27 + .../__tests__/deviceLinkCreateArgs.test.ts | 8 + .../__tests__/fastModeMirrorOnPatched.test.ts | 10 + .../cindy-brain/OneshotModelPinPicker.tsx | 2 +- .../renderer/cindy-brain/PluginTaskPrefs.tsx | 8 +- .../components/chat/AgentTaskCard.tsx | 2 +- .../components/chat/AssistantMessage.tsx | 2 +- .../renderer/components/chat/ErrorBanner.tsx | 2 +- .../components/chat/InterruptedTurnBanner.tsx | 2 +- .../components/chat/MessageStream.tsx | 6 +- .../renderer/components/chat/SystemCard.tsx | 2 +- .../renderer/components/chat/UserMessage.tsx | 2 +- .../renderer/components/icons/CursorMark.tsx | 31 + .../components/new-chat/ChatInput.tsx | 40 +- .../components/new-chat/ExtraDirsButton.tsx | 2 +- .../components/new-chat/ModelSelector.tsx | 74 ++- .../new-chat/PermissionSelector.tsx | 4 +- .../components/new-chat/agentOptions.ts | 2 + .../new-chat/unifiedModelSelection.ts | 4 +- .../components/settings/AddProviderWizard.tsx | 1 + .../settings/HookWorkspacePrefsEditor.tsx | 6 +- .../settings/ImDefaultSettingsSection.tsx | 11 +- .../settings/ModelAdvancedDrawer.tsx | 3 + .../settings/ModelPriceOverrideDialog.tsx | 1 + .../settings/ProviderConnectionDialog.tsx | 2 +- .../components/settings/ProvidersSection.tsx | 4 +- .../components/settings/UnifiedModelList.tsx | 1 + .../components/sidebar/VendorIcon.tsx | 10 +- .../components/status/TodaySpendChip.tsx | 2 +- .../features/bots/BotCapabilitySettings.tsx | 2 +- .../features/bots/BotModelChainEditor.tsx | 8 +- .../renderer/features/bots/BotWorkbench.tsx | 2 +- .../renderer/features/bots/BotsHomeView.tsx | 4 +- .../src/renderer/features/bots/botStore.ts | 6 +- .../features/bots/botWorkbenchProjection.ts | 2 +- .../features/cc-agent/CCAgentSessionView.tsx | 12 +- .../features/cc-agent/CreateWorkerPopover.tsx | 28 +- .../features/cc-agent/NewMakerDraftRoute.tsx | 22 +- .../features/cc-agent/deviceLinkCreateArgs.ts | 20 +- .../hooks/helpers/sidebarFilterCore.ts | 2 +- .../cc-agent/hooks/workerProjectionStore.ts | 4 +- .../features/cc-agent/lib/orcaAgentDisplay.ts | 8 +- .../background-tasks/listSessionTasks.ts | 2 +- .../resource-usage/ResourceUsageBody.tsx | 2 + .../plugins/subagents/SubagentsBody.tsx | 2 +- .../right-sidebar/plugins/subagents/index.tsx | 2 +- .../scheduler/components/AgentMark.tsx | 2 + .../scheduler/components/RunHistoryPane.tsx | 2 +- .../scheduler/components/ScheduleChips.tsx | 4 +- .../scheduler/hooks/useScheduleForm.ts | 7 +- .../scheduler/lib/projectAutomationConfig.ts | 6 +- .../scheduler/lib/scheduleFormLogic.ts | 9 +- .../features/skillhub/lib/localRoutes.ts | 2 +- .../renderer/hooks/useAgentCapabilities.ts | 8 +- .../src/renderer/hooks/useAvailableAgents.ts | 6 +- .../src/renderer/hooks/useMakerSession.ts | 4 +- .../src/renderer/hooks/useUsageHistory.ts | 6 +- .../src/renderer/hooks/useVendorAuthGate.ts | 23 +- .../src/renderer/hooks/useVendorReadiness.ts | 6 +- .../src/renderer/i18n/locales/en/common.json | 11 + .../src/renderer/i18n/locales/ja/common.json | 11 + .../src/renderer/i18n/locales/ko/common.json | 11 + .../renderer/i18n/locales/zh-CN/common.json | 11 + .../renderer/i18n/locales/zh-TW/common.json | 11 + apps/desktop/src/renderer/lib/agentVendors.ts | 2 +- .../src/renderer/lib/atResourceService.ts | 2 +- .../desktop/src/renderer/lib/ccAgent.types.ts | 10 +- .../renderer/lib/customProviderRuntimeFill.ts | 2 +- .../src/renderer/lib/makerChatStore.ts | 46 +- .../src/renderer/lib/makerTransport.ts | 2 +- .../src/renderer/lib/modelDefinitions.ts | 26 +- .../renderer/lib/modelHarnessPresentation.ts | 1 + .../src/renderer/lib/providerModelFetch.ts | 2 +- .../src/renderer/lib/providerModels.ts | 10 +- .../src/renderer/lib/providerSubtitle.ts | 1 + .../lib/providerUpstreamErrorToast.ts | 2 +- .../src/renderer/lib/sessionService.ts | 7 +- .../src/renderer/state/modelEnginePrefs.ts | 2 +- .../src/renderer/state/modelFavorites.ts | 2 +- .../src/renderer/state/newMakerDraft.ts | 15 +- .../src/renderer/state/workerCreationPrefs.ts | 8 +- apps/desktop/src/renderer/vite-env.d.ts | 104 ++-- .../shared/__tests__/cursorAgentKind.test.ts | 14 + apps/desktop/src/shared/agentInputQueue.ts | 2 +- apps/desktop/src/shared/agentIsland.ts | 2 +- .../desktop/src/shared/agentKindConversion.ts | 12 +- apps/desktop/src/shared/botCreation.ts | 2 +- apps/desktop/src/shared/botModelChain.ts | 4 +- apps/desktop/src/shared/botWorkbench.ts | 2 +- apps/desktop/src/shared/cindyMakeDoctor.ts | 2 +- apps/desktop/src/shared/conversationSearch.ts | 2 +- apps/desktop/src/shared/imDefaultSettings.ts | 5 +- .../src/shared/issueRuntimeMetadata.ts | 7 +- apps/desktop/src/shared/modelAccess.ts | 6 +- .../src/shared/newMakerDefaultTuple.ts | 4 +- .../src/shared/orca-worker-permission-mode.ts | 2 +- apps/desktop/src/shared/piPackages.ts | 6 +- apps/desktop/src/shared/pluginTasks.ts | 2 +- apps/desktop/src/shared/processMonitor.ts | 3 +- .../src/shared/providerModelRefresh.ts | 2 +- apps/desktop/src/shared/sessionReference.ts | 2 +- apps/mobile/app/sessions/new.tsx | 4 +- .../conversationSearchFilterMenu.test.ts | 1 + .../__tests__/cursorHostCompatibility.test.ts | 26 + .../__tests__/deviceCatalogRefresh.test.ts | 5 +- .../src/__tests__/homeDesktopFirst.test.ts | 2 +- apps/mobile/src/__tests__/newSession.test.ts | 2 +- apps/mobile/src/__tests__/orcaTeam.test.ts | 10 + .../src/__tests__/orcaWorkerPrefs.test.ts | 1 + .../src/__tests__/useOrcaWorkerForm.test.tsx | 19 + .../mobile/src/components/MobileAgentMark.tsx | 6 +- .../src/components/MobileVendorIcon.tsx | 4 +- .../src/device-link/deviceCatalogRefresh.ts | 4 +- .../src/device-link/mobileMakerTransport.ts | 4 +- apps/mobile/src/i18n/locales/en/session.json | 2 + apps/mobile/src/i18n/locales/ja/session.json | 2 + apps/mobile/src/i18n/locales/ko/session.json | 2 + .../src/i18n/locales/zh-CN/session.json | 2 + .../src/i18n/locales/zh-TW/session.json | 2 + .../src/session/CompanionModelChain.tsx | 6 +- .../src/session/ContextSheetCollabView.tsx | 4 +- .../session/ConversationSearchFilterSheet.tsx | 4 +- apps/mobile/src/session/MessageRenderer.tsx | 1 + .../src/session/UnifiedModelPickerSheet.tsx | 2 +- apps/mobile/src/session/agentAuthGate.ts | 6 +- apps/mobile/src/session/composerPalette.ts | 4 +- .../session/conversationSearchFilterMenu.ts | 1 + apps/mobile/src/session/inputProjection.ts | 2 +- .../mobile/src/session/mobileHomeListCache.ts | 2 +- .../src/session/mobileModelPreferences.ts | 2 +- apps/mobile/src/session/newSession.ts | 28 +- .../src/session/newSessionPreferenceStore.ts | 4 +- apps/mobile/src/session/orcaTeam.ts | 12 +- apps/mobile/src/session/orcaWorkerPrefs.ts | 5 +- apps/mobile/src/session/remoteSessionStore.ts | 2 +- apps/mobile/src/session/sessionAgentSwitch.ts | 12 +- apps/mobile/src/session/sessionControls.ts | 2 +- apps/mobile/src/session/sessionMenu.ts | 2 +- apps/mobile/src/session/sessionModelMirror.ts | 2 +- apps/mobile/src/session/types.ts | 6 +- .../useConversationSearchFilterMenu.ts | 1 + .../src/session/useSessionOrcaCollab.ts | 15 +- docs/dev-rules/cursor-harness.md | 122 ++++ .../src/__tests__/modelFavorites.test.ts | 11 + packages/device-link/src/modelFavorites.ts | 4 +- .../src/__tests__/createWorkerTool.test.ts | 7 + .../__tests__/listAvailableModelsTool.test.ts | 15 + .../src/__tests__/listSessionsTool.test.ts | 9 + .../providersHarnessIdentity.test.ts | 30 + .../src/__tests__/sessionControlTools.test.ts | 11 + packages/lizi-mcps/src/cindy-docs/types.ts | 2 +- .../lizi-mcps/src/cindy_schedulerMcpServer.ts | 2 +- .../lizi-mcps/src/lizi_xdtHelperMcpServer.ts | 2 +- packages/lizi-mcps/src/providers.ts | 8 +- packages/lizi-mcps/src/scheduler/_enums.ts | 2 +- .../lizi-mcps/src/scheduler/setPreRunHook.ts | 2 +- packages/lizi-mcps/src/types.ts | 2 +- .../src/xdt-helper/_history_types.ts | 2 +- .../src/xdt-helper/bot_capabilities.ts | 2 +- .../lizi-mcps/src/xdt-helper/bot_workbench.ts | 2 +- .../lizi-mcps/src/xdt-helper/create_worker.ts | 4 +- .../src/xdt-helper/get_chat_history.ts | 2 +- .../src/xdt-helper/get_current_session_id.ts | 2 +- .../src/xdt-helper/list_available_models.ts | 18 +- .../lizi-mcps/src/xdt-helper/list_sessions.ts | 6 +- .../src/xdt-helper/search_chat_history.ts | 2 +- .../src/xdt-helper/send_to_worker.ts | 4 +- .../src/xdt-helper/session_control.ts | 2 +- .../xdt-helper/set_current_session_title.ts | 2 +- .../src/xdt-helper/submit_github_issue.ts | 4 +- .../maker-core/src/agents/acp/client.test.ts | 89 +++ packages/maker-core/src/agents/acp/client.ts | 120 ++++ .../maker-core/src/agents/acp/protocol.ts | 15 + .../src/agents/acp/transport.test.ts | 85 +++ .../maker-core/src/agents/acp/transport.ts | 168 ++++++ packages/maker-core/src/agents/base-agent.ts | 2 +- .../src/agents/cursor/index.test.ts | 264 +++++++++ .../maker-core/src/agents/cursor/index.ts | 553 ++++++++++++++++++ .../maker-core/src/agents/cursor/models.ts | 62 ++ .../maker-core/src/agents/cursor/questions.ts | 32 + .../src/agents/cursor/translator.ts | 106 ++++ packages/maker-core/src/agents/index.ts | 3 + packages/maker-core/src/maker.test.ts | 10 +- packages/maker-core/src/maker.ts | 4 +- .../maker-core/src/session.origin.test.ts | 7 +- packages/maker-core/src/session.ts | 2 +- packages/maker-core/src/types/capabilities.ts | 2 +- packages/maker-core/src/types/common.ts | 2 +- .../maker-core/src/types/customizations.ts | 2 +- packages/maker-core/src/types/events.ts | 4 +- .../src/__tests__/scheduler.test.ts | 8 +- .../maker-scheduler/src/engine/scheduler.ts | 2 +- packages/maker-scheduler/src/types.ts | 2 +- .../maker-shared/src/agentCapabilities.ts | 6 +- packages/maker-shared/src/agentTask.ts | 14 +- packages/maker-shared/src/botDelegation.ts | 4 +- .../maker-shared/src/conversationSearch.ts | 2 +- .../maker-shared/src/deviceLinkContract.ts | 4 +- packages/maker-shared/src/fixtures.ts | 2 +- packages/maker-shared/src/orcaTeam.ts | 9 +- packages/maker-shared/src/scheduleForm.ts | 4 +- packages/maker-shared/src/scheduleModel.ts | 1 + packages/maker-shared/src/scheduleTypes.ts | 2 +- .../maker-shared/src/sessionActionStrip.ts | 2 +- packages/maker-shared/src/sessionList.ts | 2 +- .../maker-shared/src/subagentWorkspace.ts | 2 +- packages/maker-shared/src/systemCard.ts | 2 +- .../src/__tests__/catalog.test.ts | 8 +- .../src/__tests__/cursorNative.test.ts | 36 ++ .../src/__tests__/source-registry.test.ts | 3 + .../src/__tests__/unifiedSelection.test.ts | 2 +- packages/model-providers/src/builtin.ts | 13 + packages/model-providers/src/byok.ts | 2 +- packages/model-providers/src/catalog.ts | 14 +- packages/model-providers/src/invocation.ts | 2 +- packages/model-providers/src/modelProtocol.ts | 4 + .../model-providers/src/provider-identity.ts | 1 + .../model-providers/src/providerBranding.ts | 2 + packages/model-providers/src/registry.ts | 2 + packages/model-providers/src/types.ts | 2 +- .../model-providers/src/unifiedSelection.ts | 3 +- packages/model-providers/src/user-provider.ts | 2 +- .../src/__tests__/orca-bridge-mcp.test.ts | 14 +- scripts/smoke-cursor-acp.mts | 201 +++++++ 285 files changed, 3407 insertions(+), 620 deletions(-) create mode 100644 apps/desktop/src/main/localDb/__tests__/sessionCreateCursor.test.ts create mode 100644 apps/desktop/src/main/maker-host/__tests__/cursorModelCatalog.test.ts create mode 100644 apps/desktop/src/main/maker-host/__tests__/cursorNativeHost.test.ts create mode 100644 apps/desktop/src/main/maker-host/__tests__/cursorSessionStorage.test.ts create mode 100644 apps/desktop/src/main/maker-host/cursor-auth-adapter.ts create mode 100644 apps/desktop/src/main/maker-host/cursor-binary-discovery.ts create mode 100644 apps/desktop/src/main/maker-host/cursor-model-catalog.ts create mode 100644 apps/desktop/src/main/scheduler-host/__tests__/cursorModelDefaults.test.ts create mode 100644 apps/desktop/src/renderer/__tests__/cursorPermissionPolicy.test.ts create mode 100644 apps/desktop/src/renderer/components/icons/CursorMark.tsx create mode 100644 apps/desktop/src/shared/__tests__/cursorAgentKind.test.ts create mode 100644 apps/mobile/src/__tests__/cursorHostCompatibility.test.ts create mode 100644 docs/dev-rules/cursor-harness.md create mode 100644 packages/lizi-mcps/src/__tests__/providersHarnessIdentity.test.ts create mode 100644 packages/maker-core/src/agents/acp/client.test.ts create mode 100644 packages/maker-core/src/agents/acp/client.ts create mode 100644 packages/maker-core/src/agents/acp/protocol.ts create mode 100644 packages/maker-core/src/agents/acp/transport.test.ts create mode 100644 packages/maker-core/src/agents/acp/transport.ts create mode 100644 packages/maker-core/src/agents/cursor/index.test.ts create mode 100644 packages/maker-core/src/agents/cursor/index.ts create mode 100644 packages/maker-core/src/agents/cursor/models.ts create mode 100644 packages/maker-core/src/agents/cursor/questions.ts create mode 100644 packages/maker-core/src/agents/cursor/translator.ts create mode 100644 packages/model-providers/src/__tests__/cursorNative.test.ts create mode 100644 scripts/smoke-cursor-acp.mts 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..f46f68c5b95 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'; + // Cursor keeps native ACP permission requests; it does not implement bypass. + 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/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/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 f6e51c30275..76c8e86a8dc 100644 --- a/apps/desktop/src/main/localDb/ipc/bots.ts +++ b/apps/desktop/src/main/localDb/ipc/bots.ts @@ -495,11 +495,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'; } @@ -879,7 +880,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) 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 9fdbeb5b02f..e35f2d7f651 100644 --- a/apps/desktop/src/main/localDb/ipc/messages.ts +++ b/apps/desktop/src/main/localDb/ipc/messages.ts @@ -1134,7 +1134,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; @@ -1169,7 +1169,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> { @@ -1195,7 +1195,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 } : {}), @@ -1566,7 +1567,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?: { @@ -2695,7 +2696,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 4c3356a4202..2cdb66f6fea 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -636,13 +636,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?: 'ask'; contextWindow?: number | null; /** 远程 Agent 换电脑:undefined = 不动,null = 改回任务所在电脑。 */ agentDeviceId?: string | null; @@ -667,6 +668,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 +678,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 +691,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) } : {}), @@ -1055,7 +1059,7 @@ export interface OverwritableAutoTitleTarget { * `reconcileCreateOptsAgainstDb` 处理的正是同一类漂移),用错 agent 会让标题 * 走错供应商 —— 纯 Codex / 纯 Claude 用户会因此只拿到 fallback 标题。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; /** * 是否仍停在建会话时的裸默认标题。合成占位(纯附件消息)只允许覆写这一种 —— * fork 占位与上一条附件写下的合成占位都要保留到用户真正打字为止。 @@ -1071,7 +1075,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)) || @@ -1346,7 +1350,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 09b1763d3a7..dc931d6629a 100644 --- a/apps/desktop/src/main/localDb/mapper.ts +++ b/apps/desktop/src/main/localDb/mapper.ts @@ -331,7 +331,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(), }; } @@ -395,7 +395,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', @@ -502,7 +502,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 c7e7e5acdfd..ca46b3d0a3b 100644 --- a/apps/desktop/src/main/localDb/orcaTeamStore.ts +++ b/apps/desktop/src/main/localDb/orcaTeamStore.ts @@ -16,7 +16,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 那种 @@ -687,7 +687,7 @@ function workerToRecord( } 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 730c883e78b..54266efdf07 100644 --- a/apps/desktop/src/main/localDb/schema.ts +++ b/apps/desktop/src/main/localDb/schema.ts @@ -391,7 +391,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('{}'), @@ -892,7 +892,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. */ @@ -962,7 +962,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() @@ -1340,9 +1340,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, @@ -1461,7 +1461,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(), }, @@ -1759,7 +1759,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(), @@ -1812,7 +1812,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), @@ -1842,7 +1842,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..621288ed8fb --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/cursorModelCatalog.test.ts @@ -0,0 +1,32 @@ +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, 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); + state.owner = 'owner-b'; + expect(hasCursorDiscoveredModels()).toBe(false); + setCursorDiscoveredModels([discovered], 'owner-a'); + expect(withCursorDiscoveredModels(BUNDLED_CATALOG).providers.find(p => p.id === 'cursor')?.models.cursor).toEqual([]); + }); +}); 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..d7a55f8ba13 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/cursorSessionStorage.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const h = vi.hoisted(() => ({ row: {} as Record, inserted: {} as Record })); +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 = {}; }); +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 }); + }); +}); 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 8175a5f8e17..63b1be8c8e1 100644 --- a/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts @@ -103,7 +103,7 @@ describe('getRemoteNewMakerDefaults (device-link 远程草稿镜像)', () => { }); }); - it('草稿变更广播快照始终包含 claude-code、codex、pi 三个槽', () => { + it('草稿变更广播快照始终包含全部四个引擎槽', () => { seed({ lastByVendor: { pi: { model: 'claude-sonnet-4-6' } }, modelChosenByVendor: { pi: false }, @@ -112,7 +112,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, @@ -212,3 +212,18 @@ it('reads remembered tuning only from the matching owner snapshot, with source/e 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('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/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..80dc47973e7 --- /dev/null +++ b/apps/desktop/src/main/maker-host/cursor-model-catalog.ts @@ -0,0 +1,30 @@ +/** Owner-scoped, in-memory ACP discovery; no invented models, account credentials, or disk cache. */ +import 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 hasCursorDiscoveredModels(): boolean { + return snapshot?.owner === activeOwnerScopeKey() && snapshot.models.length > 0; +} +export function withCursorDiscoveredModels(catalog: Catalog): Catalog { + const models = snapshot?.owner === activeOwnerScopeKey() ? snapshot.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: models.map(model => ({ + id: model.id, name: model.displayName, contextWindow: model.contextWindow, + efforts: model.efforts, defaultEffort: model.defaultEffort, + ...(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 1c0eaf1220e..0d4f65098e4 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'; @@ -44,6 +47,7 @@ import { isBotMcpServerAllowed, ClaudeCodeAgent, CodexAgent, + CursorAgent, configureDefaultImageResizer, type AgentKind, type InteractionRequest, @@ -398,6 +402,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; @@ -947,7 +959,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) => ({ @@ -2578,6 +2590,28 @@ 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) => + getPiExtraSpawnConfig(providers, desktopMakerLogger, { ...ctx, 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) => @@ -2957,6 +2991,37 @@ 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 () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'cindy-cursor-models-')); + try { + const models = await cursorAgent.discoverModels(directory); + 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'); + } finally { + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined); + } + })().catch(() => { + desktopMakerLogger.warn('Cursor model discovery could not prepare its workspace'); + }).finally(() => { pending = null; }); + return pending; + }; + void refreshCursorModels(); + } _sessionArchiveSync = createSessionArchiveSync({ capture: () => { const snapshot = getCurrentDbClientSnapshot(); @@ -3113,6 +3178,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/newMakerDefaultsCache.ts b/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts index 40108eb2b69..d1c50d5404f 100644 --- a/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts +++ b/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts @@ -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; @@ -143,7 +143,7 @@ export interface WorkerDefaultsFromNewMaker { * 未推送 / 未记录 → undefined,调用方保持模型默认(开)。 */ export function getThinkingEnabledFromMemory( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', providerId: string | null | undefined, model: string | undefined, ): boolean | undefined { @@ -156,10 +156,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; @@ -208,10 +208,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 默认。故在所有早返回里都带上它。 @@ -245,11 +245,13 @@ 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'), }; } @@ -259,7 +261,7 @@ export function getSelectedNewMakerRoute(ownerScope: string): BotModelRoute | un } /** 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..966b953f5cd 100644 --- a/apps/desktop/src/main/maker-host/session-storage.ts +++ b/apps/desktop/src/main/maker-host/session-storage.ts @@ -27,7 +27,7 @@ import { normalizeRemoteHostId } from '../localDb/mapper.js'; import { DESKTOP_VISIBLE_SESSION_SOURCES } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; -type DbAgentKind = 'cc' | 'codex' | 'pi'; +type DbAgentKind = 'cc' | 'codex' | 'pi' | 'cursor'; // 形态映射走 shared/agentKindConversion 正本(支持 pi;此前 pi 被误落成 codex)。 function toDbKind(k: AgentKind): DbAgentKind { @@ -54,9 +54,10 @@ function rowToMeta(row: SessionRow): SessionMeta { title: row.title, model: row.model, workspaceKind: row.workspaceKind, - effort: row.effort, + // The legacy DB effort column is NOT NULL; its placeholder is not a Cursor capability. + effort: row.agentKind === 'cursor' ? undefined : row.effort, permissionMode: row.permissionMode, - fastMode: row.fastMode, + fastMode: row.agentKind === 'cursor' ? 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__/orcaTeamService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts index bdf1dc358da..aa19387871e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/orcaTeamService.test.ts @@ -1905,17 +1905,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', @@ -1928,6 +1931,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 b172c0bb41c..18e035f4a1f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts @@ -345,6 +345,7 @@ function providerRoutingContext( 'claude-code': partial['claude-code'] ?? [], codex: partial.codex ?? [], pi: partial.pi ?? [], + cursor: partial.cursor ?? [], }; return { availability, @@ -3275,3 +3276,23 @@ 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('uses Ask for native workers without inheriting a full-access preference', 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: 'ask' })); + }); + it('rejects unsupported explicit permissions before creating a worker', async () => { + const { deps, service } = createDeps(); + const result = await service.createWorker({ leadSessionId: 'lead-1', role: 'worker', label: 'cursor', agent: 'cursor', workerPermissionMode: 'auto' }); + expect(result).toMatchObject({ ok: false, errorCode: 'INVALID_PARAMS' }); + expect(deps.bootstrapSession).not.toHaveBeenCalled(); + expect(deps.reserveWorkerCreation).not.toHaveBeenCalled(); + }); +}); 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 83fb945da34..b8b9ad43269 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts @@ -1342,3 +1342,20 @@ describe('远程 Agent:选模型时换 Agent 所在电脑', () => { }); }); }); + + +describe('Cursor switch capability normalization', () => { + it('commits native Ask and disables Fast instead of inheriting the previous harness permission', 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('rejects unsupported explicit tuning before closing the previous session', async () => { + const { deps } = makeDeps(); + await expect(performSessionAgentSwitch(deps, { sessionId: 's1', targetAgentKind: 'cursor', model: 'native', effort: 'high', applyNow: true })).rejects.toThrow('[UNSUPPORTED_CAPABILITY]'); + expect(deps.closeSession).not.toHaveBeenCalled(); + expect(deps.applyAgentSwitchToDb).not.toHaveBeenCalled(); + }); +}); 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..c37a160019e 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,14 @@ 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' })).toThrow('[UNSUPPORTED_CAPABILITY]'); + expect(() => readCreateSessionOpts({ ...input, fastMode: true })).toThrow('[UNSUPPORTED_CAPABILITY]'); + 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 9cbf526dc9e..62212c3f605 100644 --- a/apps/desktop/src/main/maker-ipc/agentHandoff.ts +++ b/apps/desktop/src/main/maker-ipc/agentHandoff.ts @@ -23,7 +23,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 a599e590d4f..c519c1c2897 100644 --- a/apps/desktop/src/main/maker-ipc/appDefaultModelControl.ts +++ b/apps/desktop/src/main/maker-ipc/appDefaultModelControl.ts @@ -18,14 +18,14 @@ 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'>; + 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, availableAgents: input.availableAgents, availableAgentsLoaded: true, preferredRoute: input.currentRoute ?? undefined, isModelEnabled: input.enabled })[0]; - return input.providers.flatMap(provider => (['claude-code', 'codex', 'pi'] as const).flatMap(agent => + return input.providers.flatMap(provider => (['claude-code', 'codex', 'pi', 'cursor'] as const).flatMap(agent => (provider.models[agent] ?? []).flatMap(model => { const remembered = input.tuning?.(agent, provider.id, model.id); const route: BotModelRoute = { harness: agent === 'claude-code' ? 'claude' : agent, diff --git a/apps/desktop/src/main/maker-ipc/orcaLifecycleService.ts b/apps/desktop/src/main/maker-ipc/orcaLifecycleService.ts index 3a8ce4efe77..43e4015077d 100644 --- a/apps/desktop/src/main/maker-ipc/orcaLifecycleService.ts +++ b/apps/desktop/src/main/maker-ipc/orcaLifecycleService.ts @@ -193,9 +193,10 @@ export function createOrcaLifecycleService(deps: OrcaLifecycleDeps): OrcaLifecyc function workerPermissionModeForCreate( explicitMode: OrcaWorkerPermissionMode | undefined, override: Awaited>> | undefined, + agent?: AgentKind, ): { workerPermissionMode: OrcaWorkerPermissionMode; assertCurrent?: () => Promise } { if (override?.permissionMode !== undefined) return { workerPermissionMode: override.permissionMode, assertCurrent: override.assertCurrent }; - if (explicitMode === undefined) return { workerPermissionMode: deps.getWorkerPermissionMode(), assertCurrent: override?.assertCurrent }; + if (explicitMode === undefined) return { workerPermissionMode: agent === 'cursor' ? 'ask' : deps.getWorkerPermissionMode(), assertCurrent: override?.assertCurrent }; const resolved = resolveOrcaWorkerPermissionMode(explicitMode); deps.setWorkerPermissionMode(resolved); return { workerPermissionMode: resolved, assertCurrent: override?.assertCurrent }; @@ -247,13 +248,16 @@ export function createOrcaLifecycleService(deps: OrcaLifecycleDeps): OrcaLifecyc } async function createWorker(params: OrcaWorkerCreateParams): Promise { + if (params.agent === 'cursor' && params.workerPermissionMode !== undefined && params.workerPermissionMode !== 'ask') { + return { ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require Ask permission mode' }; + } const override = await deps.getWorkerPermissionModeOverride?.(params.leadSessionId); const team = await deps.getActiveTeamByLead(params.leadSessionId); await override?.assertCurrent(); if (!team) { return { ok: false, errorCode: 'NOT_FOUND', message: 'no active team for this lead' }; } - const { workerPermissionMode, assertCurrent } = workerPermissionModeForCreate(params.workerPermissionMode, override); + const { workerPermissionMode, assertCurrent } = workerPermissionModeForCreate(params.workerPermissionMode, override, params.agent); const initialTask = hasNonEmptyInitialTask(params.initialTask) ? params.initialTask : undefined; let assertCreatedCurrent = assertCurrent; const created = await deps.createWorkerInTeam({ @@ -379,7 +383,10 @@ export function createOrcaLifecycleService(deps: OrcaLifecycleDeps): OrcaLifecyc } async function enableTeam(params: OrcaEnableTeamParams): Promise { - const { workerPermissionMode, assertCurrent } = workerPermissionModeForCreate(params.workerPermissionMode, await deps.getWorkerPermissionModeOverride?.(params.leadSessionId)); + if (params.workerAgent === 'cursor' && params.workerPermissionMode !== undefined && params.workerPermissionMode !== 'ask') { + return { ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require Ask permission mode' }; + } + const { workerPermissionMode, assertCurrent } = workerPermissionModeForCreate(params.workerPermissionMode, await deps.getWorkerPermissionModeOverride?.(params.leadSessionId), params.workerAgent); let assertCreatedCurrent = assertCurrent; const normalized = normalizeEnableParams(params); const validationFailure = validateEnableParams(normalized); 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 e6b8ade7bea..11388a28342 100644 --- a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts +++ b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts @@ -381,12 +381,14 @@ function selectWorkerModel(params: { input: OrcaWorkerCreateParams; lead: OrcaLeadSessionSnapshot; defaults: OrcaWorkerDefaultsSnapshot; + availableModels: OrcaWorkerModelCapabilities[]; }): string { const { input, lead, defaults } = params; return input.model ?? defaults.model // pi 显式列出(与 model-defaults.ts 对齐,避免将来改 cc 默认时 pi 静默跟随)。 ?? (input.agent === lead.agentKind ? lead.model + : input.agent === 'cursor' ? params.availableModels[0]?.id ?? '' : input.agent === 'codex' ? 'gpt-5.5' : input.agent === 'pi' ? 'claude-sonnet-4-6' : 'claude-sonnet-4-6'); @@ -562,7 +564,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'; } /** @@ -638,13 +640,16 @@ export function createOrcaWorkerCreationService(deps: OrcaWorkerCreationDeps): O teamId: team.id, workerPermissionMode: params.workerPermissionMode === undefined - ? deps.getWorkerPermissionMode() + ? params.agent === 'cursor' ? 'ask' : deps.getWorkerPermissionMode() : resolveOrcaWorkerPermissionMode(params.workerPermissionMode), }, assertCurrent); } async function createWorkerInTeam(params: OrcaWorkerCreateInTeamParams, assertCurrent?: () => Promise, onCreated?: (assertCreatedCurrent: () => Promise) => void): Promise { + if (params.agent === 'cursor' && params.workerPermissionMode !== 'ask') { + return { ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require Ask permission mode' }; + } const role = normalizeRequiredText(params.role, 'role'); if (!role.ok) return { ok: false, errorCode: 'INVALID_PARAMS', message: role.message }; if (role.value.length > 32) { @@ -681,6 +686,9 @@ export function createOrcaWorkerCreationService(deps: OrcaWorkerCreationDeps): O if (!lead) { return { ok: false, errorCode: 'NOT_FOUND', message: `lead session ${params.leadSessionId} not found` }; } + if (params.agent === 'cursor' && (lead.remoteHostId || lead.agentDeviceId)) { + return { ok: false, errorCode: 'INVALID_PARAMS', message: 'Cursor workers require a local workspace' }; + } // 标准面板显式选定的来源(非空 string)直接生效,由下方精确 preflight 把关「已连接且 // 提供该模型」;空串/null/undefined 一律按未显式处理(与 IPC 边界同口径,service 作为 // 共用内核自防调用方漏归一)。 @@ -770,6 +778,7 @@ export function createOrcaWorkerCreationService(deps: OrcaWorkerCreationDeps): O input: params, lead, defaults, + availableModels, }); const leadProviderId = params.agent === lead.agentKind && typeof lead.providerId === 'string' && lead.providerId.trim() diff --git a/apps/desktop/src/main/maker-ipc/providerHandlers.ts b/apps/desktop/src/main/maker-ipc/providerHandlers.ts index 81dc3edb890..8c08ccffc14 100644 --- a/apps/desktop/src/main/maker-ipc/providerHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/providerHandlers.ts @@ -1645,7 +1645,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 1b2d7492264..0b88e5514a6 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 { openSession, setSessionOpeningModelAdmission } from '../localDb/sessionOpening.js'; import { createPluginTaskReviewResolver } from './pluginTaskReviewContext.js'; import { isPluginTaskPermissionAllowed, assertPluginTaskResult, createPluginTaskService, readPluginTaskPlanReceipt, PluginTaskError, type PluginTaskService } from './pluginTaskService.js'; @@ -2508,7 +2510,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'); } @@ -5912,7 +5914,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 查询都带回, @@ -5965,8 +5968,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'); @@ -6040,8 +6043,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'); @@ -6080,7 +6083,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' || @@ -6111,6 +6114,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, @@ -6214,7 +6218,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)。 @@ -7383,6 +7388,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); @@ -7972,7 +7984,7 @@ 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' @@ -7980,7 +7992,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) : 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; @@ -8565,7 +8577,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } return { - sourceAgentKind: source.agentKind as 'cc' | 'codex' | 'pi', + sourceAgentKind: source.agentKind as 'cc' | 'codex' | 'pi' | 'cursor', prompt: builtPrompt.prompt, targetKind: builtPrompt.targetKind, onAccepted: () => acceptRemoteAttachments?.(), @@ -11202,7 +11214,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 @@ -12037,7 +12049,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) deferDelegateTask?: 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 && @@ -12640,7 +12652,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 }) => { @@ -13447,8 +13459,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' @@ -14220,7 +14232,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }, listAvailableModels: async ({ agent, callerSessionId }) => { try { - const agents: AgentKind[] = agent ? [agent] : ['codex', 'claude-code', 'pi']; + const agents: AgentKind[] = agent ? [agent] : maker.listAvailableAgents(); // Agent 在另一台电脑运行的任务:Worker 也在那台运行,列那台的模型与来源。 const agentDeviceId = callerSessionId ? await readSessionAgentDeviceId(callerSessionId) : null; if (agentDeviceId) { @@ -14228,7 +14240,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return { ok: true, ...Object.fromEntries(agents.map((a) => [ - a === 'codex' ? 'codex' : a === 'pi' ? 'pi' : 'claude_code', + a === 'claude-code' ? 'claude_code' : a, deviceAvailableModels(views, a), ])), }; @@ -14246,7 +14258,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, @@ -14377,7 +14389,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; @@ -18263,7 +18275,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 2bfb3304850..3c7ae151248 100644 --- a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts +++ b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts @@ -71,6 +71,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'; } @@ -145,7 +146,7 @@ export interface MakerSessionAgentSwitchHandlerDeps { * (测试最小 harness)。 */ assertModelRouteUsable?( - agent: 'claude-code' | 'codex' | 'pi', + agent: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId: string | null, /** 被切换的任务;Agent 在另一台电脑运行的任务按那台的目录裁决(本机不校验)。 */ @@ -159,7 +160,7 @@ export interface MakerSessionAgentSwitchHandlerDeps { */ assertAgentDeviceRouteUsable?( deviceId: string, - agent: 'claude-code' | 'codex' | 'pi', + agent: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId: string | null, ): Promise; @@ -196,6 +197,7 @@ export interface MakerSessionAgentSwitchHandlerDeps { /** 目标引擎下的 effort / fastMode(意图登记时由 renderer 解析,apply 时一并落库)。 */ effort?: string; fastMode?: boolean; + permissionMode?: 'ask'; /** Agent 换电脑:undefined = 不动,null = 改回任务所在电脑。 */ agentDeviceId?: string | null; }, @@ -444,9 +446,12 @@ 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 (targetAgentKind === 'cursor' && (params.effort || params.fastMode === true)) { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor does not expose effort or Fast controls'); + } if (typeof model !== 'string' || model.length === 0) { throwIpcError('INVALID_PARAMS', 'model required'); } @@ -510,6 +515,9 @@ export async function performSessionAgentSwitch( const currentAgentDeviceId = row.agentDeviceId ?? null; const targetAgentDeviceId = requestedAgentDeviceId === undefined ? currentAgentDeviceId : requestedAgentDeviceId; + if (targetAgentKind === 'cursor' && targetAgentDeviceId) { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor requires a workspace on the executing desktop'); + } const agentDeviceChanges = targetAgentDeviceId !== currentAgentDeviceId; // 落到另一台电脑的完整切换(换引擎或换电脑):登记与落地前都按那台的目录裁决。同引擎同位置 // 的模型选择走 selectSameAgentModel,在 SET_MODEL 链路里裁决。 @@ -678,6 +686,7 @@ export async function performSessionAgentSwitch( model, providerId: normalizedProviderId, sdkSessionId: parked?.sdkSessionId ?? null, + ...(targetAgentKind === 'cursor' ? { permissionMode: 'ask' as const, 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 1efa14f2d58..efeafd8d491 100644 --- a/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts +++ b/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts @@ -338,12 +338,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)); } @@ -457,7 +457,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..e4d26fab5ab 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,12 @@ 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'); + } + if (agentKind === 'cursor' && (body.effort || body.fastMode === true)) { + throwIpcError('UNSUPPORTED_CAPABILITY', 'Cursor does not expose effort or Fast controls'); + } 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 85a7c5f2e1d..37b48b9008b 100644 --- a/apps/desktop/src/main/maker-ipc/sessionRuntimeHarnessSelection.ts +++ b/apps/desktop/src/main/maker-ipc/sessionRuntimeHarnessSelection.ts @@ -58,7 +58,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/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/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/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/scheduler-host/__tests__/cursorModelDefaults.test.ts b/apps/desktop/src/main/scheduler-host/__tests__/cursorModelDefaults.test.ts new file mode 100644 index 00000000000..e074a16cbb3 --- /dev/null +++ b/apps/desktop/src/main/scheduler-host/__tests__/cursorModelDefaults.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; +import { defaultModelFor } from '../model-defaults.js'; +describe('Cursor scheduler model identity', () => { + it('keeps the Cursor native default rather than selecting a Claude model', () => { + expect(defaultModelFor('cursor')).toBe('cursor-default'); + }); +}); diff --git a/apps/desktop/src/main/scheduler-host/model-defaults.ts b/apps/desktop/src/main/scheduler-host/model-defaults.ts index eaa3fcc4f2b..c6ad90505a8 100644 --- a/apps/desktop/src/main/scheduler-host/model-defaults.ts +++ b/apps/desktop/src/main/scheduler-host/model-defaults.ts @@ -12,6 +12,7 @@ import type { AgentKind } from '@cindy/maker-scheduler'; export function defaultModelFor(agentKind: AgentKind): string { + if (agentKind === 'cursor') return 'cursor-default'; if (agentKind === 'codex') return 'gpt-5.5'; // Pi 没有跨来源合法的静态默认。runner 会用实时连接目录解析 // {model,providerId};空字符串可阻止其它调用方制造“看似可用”的 Claude 假路由。 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 6b12732f5eb..884f22f30b7 100644 --- a/apps/desktop/src/main/scheduler-host/runner.ts +++ b/apps/desktop/src/main/scheduler-host/runner.ts @@ -206,9 +206,9 @@ const INTERRUPTED_ERROR_DONE_FALLBACK_MS = 250; * codex 支持子集 ask/auto/bypassPermissions)—— 调度本质是 unattended,bypass 是 * 既有独立调度策略。绑定任务的心跳与伙伴例行任务不使用此默认值,继承原任务的权限与计划模式。 */ -function defaultPermissionModeForSchedule(): PermissionMode { - // 两个 agent 都支持 bypassPermissions(types/common.ts:23),暂不按 agentKind 分支 - return 'bypassPermissions'; +function defaultPermissionModeForSchedule(agentKind: AgentKind): PermissionMode { + // Cursor preserves native ACP permission prompts; other harness defaults are unchanged. + return agentKind === 'cursor' ? 'ask' : 'bypassPermissions'; } /** @@ -1312,7 +1312,7 @@ export class MakerScheduleRunner implements ScheduleRunner { permissionMode: routinePermissions?.permissionMode ?? heartbeatPermissions?.permissionMode ?? - defaultPermissionModeForSchedule(), + defaultPermissionModeForSchedule(effectiveAgentKind), ...((routinePermissions ?? heartbeatPermissions) ? { planMode: (routinePermissions ?? heartbeatPermissions)!.planMode } : {}), diff --git a/apps/desktop/src/main/scheduler-host/script-capability-broker.ts b/apps/desktop/src/main/scheduler-host/script-capability-broker.ts index 21eaa08052c..f7752c64e57 100644 --- a/apps/desktop/src/main/scheduler-host/script-capability-broker.ts +++ b/apps/desktop/src/main/scheduler-host/script-capability-broker.ts @@ -385,7 +385,7 @@ export class SchedulerScriptCapabilityBroker implements ScriptCapabilityBroker { fastMode: !!schedule.fastMode, workingDir: schedule.workingDir ?? '', workspaceKind: 'project', - permissionMode: 'bypassPermissions', + permissionMode: schedule.agentKind === 'cursor' ? 'ask' : 'bypassPermissions', }, }); if (!result.ok) fail(result.errorCode, result.message); 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/renderer/__tests__/cursorPermissionPolicy.test.ts b/apps/desktop/src/renderer/__tests__/cursorPermissionPolicy.test.ts new file mode 100644 index 00000000000..0d4388d2cf3 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/cursorPermissionPolicy.test.ts @@ -0,0 +1,27 @@ +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 both native 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; + for (const mode of ['ask', 'default']) { + 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); + expect(mobile.collab.permissionAskHint).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 8a2bbcbd7fe..4af86f508f9 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkCreateArgs.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkCreateArgs.test.ts @@ -433,3 +433,11 @@ describe('resolveDeviceLinkSubmission', () => { expect(fromSend.providerId).toBe('stale-source'); }); }); + +describe('Cursor native creation', () => { + it('does not forward remembered reasoning or automatic approval from another harness', () => { + const args = buildDeviceLinkCreateArgs({ agentKind: 'cursor', model: 'account-model', effort: 'medium', permissionMode: 'auto', fastMode: true, planModeEnabled: true }); + expect(args).not.toHaveProperty('effort'); + expect(args).toMatchObject({ agentKind: 'cursor', model: 'account-model', permissionMode: 'ask', fastMode: false, planMode: true }); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts b/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts index 169e7a16259..337a9c91969 100644 --- a/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts +++ b/apps/desktop/src/renderer/__tests__/fastModeMirrorOnPatched.test.ts @@ -121,3 +121,13 @@ describe('makerChatStore.mirrorSessionFields', () => { expect(makerChatStore.getSnapshot(s).agentSwitchIntent).toBeNull(); }); }); + +describe('Cursor native send options', () => { + it('preserves discovered plan mode while dropping unsupported reasoning and Fast', () => { + 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: 'ask', fastMode: false, planMode: true }); + expect(options).not.toHaveProperty('effort'); + }); +}); 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 ec2557016a2..60b3b0ce14a 100644 --- a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx @@ -81,7 +81,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'; /** 当前 session 的远端 host id;非空 + agentKind='codex' 时显「同步登录态」按钮。 * 本地 codex 401 仍 hide Retry, 但只能提示用户去自己 fix login (没有 sync 入口)。 */ remoteHostId?: string; diff --git a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx index 5de1a10e2f4..7e43cc7cda4 100644 --- a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx @@ -138,7 +138,7 @@ export function ErrorTailErrorBanner({ errorText: string; onContinue: () => Promise | void; onDismiss: () => void; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'cursor'; remoteHostId?: string; deviceLinkDeviceId?: string | null; modelId?: string; diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 99c81facdb7..37fc1295e7f 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -420,7 +420,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). */ @@ -2526,7 +2526,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; @@ -6643,7 +6643,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 7ea6b9476f9..5fe8b22fa20 100644 --- a/apps/desktop/src/renderer/components/chat/SystemCard.tsx +++ b/apps/desktop/src/renderer/components/chat/SystemCard.tsx @@ -1129,7 +1129,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 acefaecd1b0..3f54c298548 100644 --- a/apps/desktop/src/renderer/components/chat/UserMessage.tsx +++ b/apps/desktop/src/renderer/components/chat/UserMessage.tsx @@ -871,7 +871,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 39d4e13799b..fc05b835af4 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -732,7 +732,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. @@ -856,7 +856,7 @@ interface ChatInputProps { * `lastByVendor.model` 并原样进 createSession,写错就是首条请求路由到一个不存在的模型。 */ onUnifiedDraftSelect?: (selection: { - vendor: 'cc' | 'codex' | 'pi'; + vendor: 'cc' | 'codex' | 'pi' | 'cursor'; providerId: string; /** 选中引擎的 **wire model id**。 */ modelId: string; @@ -911,17 +911,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; } @@ -1703,7 +1704,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。意图存放在 @@ -1806,8 +1807,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 @@ -1927,7 +1929,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; @@ -1955,6 +1957,7 @@ export function ChatInput({ cc: ccCaps, codex: codexCaps, pi: piCaps, + cursor: cursorCaps, providers: remoteProviders, }); const providersLoading = catalogDeviceId @@ -6172,7 +6175,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({ @@ -6210,7 +6213,7 @@ export function ChatInput({ deviceProviders: remoteProviders.providers, localProviders: providers, capabilities: - currentModelAgentKind === 'codex' + currentModelAgentKind === 'cursor' ? cursorCaps.capabilities : currentModelAgentKind === 'codex' ? codexCaps.capabilities : currentModelAgentKind === 'pi' ? piCaps.capabilities @@ -6227,6 +6230,7 @@ export function ChatInput({ ccCaps.capabilities, codexCaps.capabilities, piCaps.capabilities, + cursorCaps.capabilities, ], ); @@ -6271,7 +6275,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; @@ -6553,6 +6557,7 @@ export function ChatInput({ runtimeAgentKind, deviceLinkDeviceId, piCaps.capabilities, + cursorCaps.capabilities, ], ); @@ -6620,7 +6625,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 任务里 @@ -6644,7 +6649,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 改动经此显式覆盖(用户手选优先于记忆/默认解析)。 @@ -6729,7 +6734,7 @@ export function ChatInput({ deviceProviders: remoteProviders.providers, localProviders: providers, capabilities: - targetAgentKind === 'codex' + targetAgentKind === 'cursor' ? cursorCaps.capabilities : targetAgentKind === 'codex' ? codexCaps.capabilities : targetAgentKind === 'pi' ? piCaps.capabilities @@ -6990,6 +6995,7 @@ export function ChatInput({ ccCaps.capabilities, codexCaps.capabilities, piCaps.capabilities, + cursorCaps.capabilities, syncSessionDraftModelPrefs, ], ); @@ -7234,7 +7240,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。 */ @@ -9276,7 +9282,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/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 009e81a6886..e23c9dcad8f 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -611,7 +611,7 @@ function RemoteModelLoadNotice({ } export interface ModelSelectorAgentIdentity { - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'cursor'; /** * current = 已由会话/runtime 元数据确认的当前 Agent; * pending = 已登记、将在下一条消息应用的切换目标。 @@ -623,8 +623,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), @@ -737,7 +737,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 提供, @@ -859,7 +859,7 @@ interface ModelSelectorProps { * device-link / SSH 远程不传(v1 不支持切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: 'cc' | 'codex' | 'pi' | 'cursor'; /** * 进入非当前 Agent 浏览态前确认;false 时保持原分段,什么都不改。 * @@ -867,14 +867,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; @@ -902,7 +902,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)。 */ @@ -992,7 +992,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)。 */ @@ -1017,7 +1017,7 @@ interface ModelSelectorContentProps { modelId: string; /** 该行生效档位;该 (模型, 引擎) 不可调档时为 undefined。 */ effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: 'cc' | 'codex' | 'pi' | 'cursor'; fast: boolean; favoriteUid: string | null; /** 配置浮层「恢复推荐」的应用动作;调用方应删除 override,不得重新记忆推荐值。 */ @@ -1043,16 +1043,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; @@ -1073,10 +1073,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; } @@ -1104,6 +1105,7 @@ export function resolveRemoteModelListStatus({ cc, codex, pi, + cursor, providers, }: { deviceId?: string; @@ -1111,11 +1113,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 ?? { error: 'unsupported' }) : pi] : [cc, codex, pi]; if (required.some((state) => !!state.error)) return 'error'; if (providers.error && !providers.unsupported) return 'error'; @@ -1253,11 +1256,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 浏览态才调用上层风险确认。确认前绝不翻分段。 @@ -1284,9 +1287,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, ) => { @@ -1300,6 +1303,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(); @@ -1332,6 +1336,7 @@ function ModelSelectorContentView({ cc, codex, pi, + cursor, providers: remoteProviders, }); const retryRemoteModels = useCallback(() => { @@ -1626,6 +1631,7 @@ function ModelSelectorContentView({ deviceCcModels: cc.capabilities?.availableModels ?? [], deviceCodexModels: codex.capabilities?.availableModels ?? [], devicePiModels: pi.capabilities?.availableModels ?? [], + deviceCursorModels: cursor.capabilities?.availableModels ?? [], excludeSubscriptionDirect, excludeChatBridgedCodex, }), @@ -1636,6 +1642,7 @@ function ModelSelectorContentView({ cc.capabilities, codex.capabilities, pi.capabilities, + cursor.capabilities, excludeSubscriptionDirect, excludeChatBridgedCodex, ], @@ -1656,9 +1663,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 = @@ -1681,6 +1689,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]); @@ -1809,6 +1818,7 @@ function ModelSelectorContentView({ ccModels: cc.capabilities?.availableModels ?? [], codexModels: codex.capabilities?.availableModels ?? [], piModels: pi.capabilities?.availableModels ?? [], + cursorModels: cursor.capabilities?.availableModels ?? [], providers, }); if (!rowAgentKind) return true; @@ -1931,7 +1941,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, @@ -1943,7 +1953,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), @@ -2067,7 +2077,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, ); @@ -2955,16 +2965,16 @@ function ModelSelectorContentView({ : (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 => - (typeof fastModeConfigurable === 'boolean' ? fastModeConfigurable : fastModeConfigurable.includes(agent)) && !!(onFastModeChange || onUnifiedSelect) && (agent === 'claude-code' + agent !== 'cursor' && (typeof fastModeConfigurable === 'boolean' ? fastModeConfigurable : fastModeConfigurable.includes(agent)) && !!(onFastModeChange || onUnifiedSelect) && (agent === 'claude-code' ? !!cc.capabilities?.hasFastMode : agent === 'codex' ? !!codex.capabilities?.hasFastMode : !!pi.capabilities?.hasFastMode), - [cc.capabilities, codex.capabilities, pi.capabilities, onFastModeChange, onUnifiedSelect, fastModeConfigurable], + [cc.capabilities, codex.capabilities, pi.capabilities, cursor.capabilities, onFastModeChange, onUnifiedSelect, fastModeConfigurable], ); if (providersOverrideState && providersOverrideState.status !== 'ready') { @@ -3715,7 +3725,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); @@ -3777,6 +3787,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(); @@ -3793,13 +3804,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( () => @@ -3810,6 +3822,7 @@ export function ModelSelector({ deviceCcModels: cc.capabilities?.availableModels ?? [], deviceCodexModels: codex.capabilities?.availableModels ?? [], devicePiModels: pi.capabilities?.availableModels ?? [], + deviceCursorModels: cursor.capabilities?.availableModels ?? [], excludeSubscriptionDirect, excludeChatBridgedCodex, }), @@ -3820,6 +3833,7 @@ export function ModelSelector({ cc.capabilities, codex.capabilities, pi.capabilities, + cursor.capabilities, excludeSubscriptionDirect, excludeChatBridgedCodex, ], @@ -3866,7 +3880,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; @@ -4028,7 +4042,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..702dfbc1151 100644 --- a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx @@ -35,7 +35,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,7 +79,7 @@ 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 === 'pi') return 'pi'; return 'claude-code'; 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..92336585a7f 100644 --- a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts +++ b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts @@ -30,12 +30,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; } /** diff --git a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx index a29c732dbae..cb2a798035f 100644 --- a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx +++ b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx @@ -101,6 +101,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + cursor: 'Cursor', }; function presetRuntimeBaseUrl( 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 1469004d29e..f5828b4cf85 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 644b5dda43b..543ad6d8364 100644 --- a/apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx @@ -114,7 +114,7 @@ import { * 本面板配置 claude / codex / pi 三个 runtime。pi 是多协议 harness:BYOM 自定义/本地模型 * 走 pi 原生 provider 直连(不过 anthropic-compat 代理),故 pi tab 额外提供显式 api 选择器。 */ -type DialogAgentKind = Extract; +type DialogAgentKind = Extract; const AGENTS: DialogAgentKind[] = ['claude-code', 'codex', 'pi']; diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 717bca859fa..c608918dcfe 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'; /** * ProvidersSection —— 设置 → 模型供应商页(2026-07 重构:双栏管理)。 * @@ -2704,7 +2705,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'); @@ -2875,6 +2876,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) { 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/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 (!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 = @@ -736,7 +736,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..a93ec56e2d8 100644 --- a/apps/desktop/src/renderer/features/bots/botStore.ts +++ b/apps/desktop/src/renderer/features/bots/botStore.ts @@ -67,7 +67,7 @@ export interface BotModelOverride { export type { BotHarness, BotModelRoute }; export { BOT_MODEL_CHAIN_MAX }; -function vendorForHarness(harness: BotCapabilities['harness']): 'cc' | 'codex' | 'pi' { +function vendorForHarness(harness: BotCapabilities['harness']): 'cc' | 'codex' | 'pi' | 'cursor' { return harness === 'claude' ? 'cc' : harness; } @@ -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; @@ -279,7 +279,7 @@ function readGlobalModelOverrides(): Partial> = {}; - for (const vendor of ['cc', 'codex', 'pi'] as const) { + for (const vendor of ['cc', 'codex', 'pi', 'cursor'] as const) { const item = (value as Record)[vendor]; if (!item || typeof item !== 'object' || Array.isArray(item)) continue; const record = item as Record; diff --git a/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts b/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts index 4259af34366..e168d06c917 100644 --- a/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts +++ b/apps/desktop/src/renderer/features/bots/botWorkbenchProjection.ts @@ -131,7 +131,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 de208036936..76eba6b20f3 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -2734,7 +2734,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); @@ -3828,7 +3828,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 第十七轮)。 @@ -4326,7 +4326,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 }) @@ -5341,7 +5341,7 @@ export function CCAgentSessionView({ session?.remoteHostId && ( m.id === model); @@ -6386,7 +6386,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 e60b5cc1303..14a160235ea 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx @@ -57,7 +57,7 @@ const AUTO_ONLY_WORKER_PERMISSION_MODES = ['auto'] as const; export interface CreateWorkerForm { role: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; model: string; effort?: Effort; fast?: boolean; @@ -104,7 +104,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); @@ -122,6 +122,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); @@ -129,7 +130,7 @@ export function CreateWorkerPopover({ const providersLoading = deviceId ? remoteProviders.loading : localProviders.loading; const providersError = deviceId ? remoteProviders.error : null; const visibilityVersion = useModelVisibilityVersion(); - 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; const supportsWorkerPermissionModeSelection = !deviceId || activeCaps?.supportsOrcaWorkerPermissionMode === true; @@ -232,7 +233,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), ); @@ -286,7 +287,7 @@ export function CreateWorkerPopover({ setFast(agentPrefs.fast); setProviderSource(deviceId ? null : agentPrefs.providerId); setInitialTask(''); - setSelectedWorkerPermissionMode(stored.workerPermissionMode); + setSelectedWorkerPermissionMode(stored.lastAgent === 'cursor' ? 'ask' : stored.workerPermissionMode); setPrefsRestored(true); }, [deviceId, open]); @@ -345,7 +346,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」的编辑静默回滚到 @@ -364,6 +365,7 @@ export function CreateWorkerPopover({ }; setPrefs(snapshot); setAgent(nextAgent); + if (nextAgent === 'cursor') setSelectedWorkerPermissionMode('ask'); const remembered = snapshot[nextAgent]; setModel(remembered.model); setEffort(remembered.effort); @@ -615,7 +617,7 @@ export function CreateWorkerPopover({ providerId: submitProviderId, initialTask, ...(supportsWorkerPermissionModeSelection - ? { workerPermissionMode: selectedWorkerPermissionMode } + ? { workerPermissionMode: agent === 'cursor' ? 'ask' : selectedWorkerPermissionMode } : {}), }); } finally { @@ -753,7 +755,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')} /> )} @@ -817,16 +819,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} @@ -863,8 +865,8 @@ export function CreateWorkerPopover({ dense ariaContext={t('orca.createWorker.permissionLabel')} allowedModes={ - supportsWorkerPermissionModeSelection - ? ORCA_WORKER_PERMISSION_MODES + agent === 'cursor' ? ['ask'] : supportsWorkerPermissionModeSelection + ? ORCA_WORKER_PERMISSION_MODES.filter((mode) => 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 0ff4a1bdf32..6a8501c26ec 100644 --- a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx @@ -426,8 +426,8 @@ function draftEnableOrcaOptions( providersReady: boolean, deferDelegateTask = false, ) { - 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 预检,协同又静默降级成单会话。 @@ -435,7 +435,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( @@ -759,8 +759,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 负责。 @@ -925,8 +925,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)。 @@ -2056,7 +2056,7 @@ export function NewMakerDraftRoute() { const carryDraftFavoriteAnchorToSession = useCallback( ( newSessionId: string, - engine: 'cc' | 'codex' | 'pi', + engine: 'cc' | 'codex' | 'pi' | 'cursor', model: string, providerId: string | null, ): void => { @@ -2284,7 +2284,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 立即建会话(会在被控端留空会话)。改为把当前草稿指向该被控 @@ -3803,7 +3803,7 @@ export function NewMakerDraftRoute() { remoteSessionId, message, createArgs.model, - createArgs.effort, + createArgs.effort ?? '', createArgs.permissionMode, remoteSendWorkingDir, rehydratedFiles, @@ -5800,7 +5800,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/deviceLinkCreateArgs.ts b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts index fd75691ed69..ba99fcbde94 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',运行目录由被控端分配。 @@ -58,7 +58,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 不带此字段(被控端自行分配运行目录)。 */ @@ -66,7 +66,7 @@ export interface DeviceLinkCreateArgs { /** 由 workingDir 派生 —— 有目录 'project',无目录 'dialogue'。归属一致的关键。 */ workspaceKind: WorkspaceKind; model: string; - effort: Effort; + effort?: Effort; permissionMode: PermissionMode; fastMode: boolean; planMode?: boolean; @@ -89,9 +89,9 @@ export function buildDeviceLinkCreateArgs(p: DeviceLinkCreateParams): DeviceLink ...(dir ? { workingDir: dir } : {}), workspaceKind: dir ? 'project' : 'dialogue', model: p.model, - effort: p.effort, - permissionMode: p.permissionMode, - fastMode: p.fastMode, + ...(p.agentKind === 'cursor' ? {} : { effort: p.effort }), + permissionMode: p.agentKind === 'cursor' ? 'ask' : p.permissionMode, + fastMode: p.agentKind === 'cursor' ? false : p.fastMode, ...(p.planModeEnabled ? { planMode: true } : {}), // 空 / 缺省不放进 args:payload 干净,且被控端 bootstrapSession 也只在非空时才校验。 ...(p.extraDirs && p.extraDirs.length > 0 ? { extraDirs: p.extraDirs } : {}), @@ -118,7 +118,7 @@ export interface DeviceLinkSubmissionCandidate { } export interface DeviceLinkSubmissionParams { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'cursor'; workingDir?: string; id?: string; extraDirs?: string[]; @@ -211,7 +211,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, @@ -228,7 +228,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, extraDirs: p.args.extraDirs ?? [], writableDirs: p.args.writableDirs ?? [], 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 1a4a0ec5bb1..45a67eaea3c 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 5e3f879efca..99e1cdadbef 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; @@ -159,7 +159,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/orcaAgentDisplay.ts b/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts index bdd1241048f..3fb1c5b4183 100644 --- a/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts +++ b/apps/desktop/src/renderer/features/cc-agent/lib/orcaAgentDisplay.ts @@ -1,5 +1,5 @@ -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'; @@ -9,9 +9,9 @@ export function normalizeOrcaDisplayAgentKind(agentKind: unknown): OrcaDisplayAg } 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 174bc2ee4b4..b7d6ec4b67e 100644 --- a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx +++ b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx @@ -53,7 +53,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; @@ -1330,7 +1330,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 7ec19024bd2..62066061c8f 100644 --- a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts +++ b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts @@ -85,6 +85,7 @@ function defaultScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': EMPTY_AGENT_PREFS, codex: EMPTY_AGENT_PREFS, pi: EMPTY_AGENT_PREFS, + cursor: EMPTY_AGENT_PREFS, }, }; } @@ -95,7 +96,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 { @@ -107,6 +108,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 { @@ -154,6 +156,7 @@ export function getScheduleAgentPrefs(agentKind: ScheduleFormState['agentKind']) * 的事故见 2026-06 踩坑:任务里看着选了 Opus 4.8,实际每次跑 4.7)。 */ export function schedulerFallbackModel(agentKind: ScheduleFormState['agentKind']): string { + if (agentKind === 'cursor') return 'cursor-default'; // Pi 的来源/模型来自动态连接目录;没有能与 providerId 解耦的静态默认。 return agentKind === 'codex' ? 'gpt-5.5' : agentKind === 'pi' ? '' : 'claude-sonnet-4-6'; } @@ -168,7 +171,7 @@ export function schedulerFallbackModel(agentKind: ScheduleFormState['agentKind'] export function getScheduleDefaultModel(agentKind: ScheduleFormState['agentKind']): string { const prefs = getScheduleAgentPrefs(agentKind); if (prefs.model.trim()) return prefs.model; - const chatLast = getPersistedVendorModel(agentKind === 'codex' ? 'codex' : agentKind === 'pi' ? 'pi' : 'cc'); + const chatLast = getPersistedVendorModel(agentKind === 'codex' ? 'codex' : agentKind === 'cursor' ? 'cursor' : agentKind === 'pi' ? 'pi' : 'cc'); if (chatLast.trim()) return chatLast; return schedulerFallbackModel(agentKind); } 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 84a7c502705..7fdd79f752d 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,7 +378,7 @@ 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 === 'pi') return 'pi'; @@ -587,11 +587,12 @@ export function buildScheduleInput(form: ScheduleFormState): CreateScheduleInput // providerId 是可清除的来源 override:表单选回原生默认来源时值为空,仍须保留 key, // 让 update patch 按「key 在 + undefined」把旧 provider_id 清成 NULL。 base.providerId = form.providerId.trim() || undefined; - if (form.effort && isEffortValue(form.effort)) base.effort = form.effort; + if (form.agentKind !== 'cursor' && 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; + if (form.agentKind === 'cursor') { base.effort = ''; base.fastMode = false; } 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 71385bdffa8..4e60c1ac296 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 { @@ -221,7 +221,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; diff --git a/apps/desktop/src/renderer/hooks/useAvailableAgents.ts b/apps/desktop/src/renderer/hooks/useAvailableAgents.ts index c7bff45932b..7e0caa47c33 100644 --- a/apps/desktop/src/renderer/hooks/useAvailableAgents.ts +++ b/apps/desktop/src/renderer/hooks/useAvailableAgents.ts @@ -47,7 +47,7 @@ function refreshRemoteCapabilitiesOnce(deviceId: string): void { remoteCapabilitiesRefreshInFlight.set(deviceId, pending); } -type RuntimeAgentKind = 'claude-code' | 'codex' | 'pi'; +type RuntimeAgentKind = 'claude-code' | 'codex' | 'pi' | 'cursor'; /** runtime agent id → NewMaker vendor(其余保持同名)。 */ function toVendor(agent: RuntimeAgentKind): MakerVendor { @@ -80,7 +80,7 @@ async function fetchAvailableAgents(deviceId?: string | null): 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 383dd5489f2..ba5e06b3d5c 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -9281,6 +9281,16 @@ "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." + } + }, "claude-code": { "ask": { "label": "Default permissions", @@ -13343,6 +13353,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 800c9028849..d15a739887b 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -9266,6 +9266,16 @@ "listAria": "権限モード", "autoFallback": "自動レビューは一時的に利用できないため、デフォルト権限に切り替えました。必要な操作は確認します。", "modes": { + "cursor": { + "ask": { + "label": "デフォルトの権限", + "description": "Cursor はネイティブに設定された権限ポリシーに従います。Cindy は Cursor から送られた承認要求のみを表示し、ワークスペース内の編集は確認なしで実行される場合があります。" + }, + "default": { + "label": "デフォルトの権限", + "description": "Cursor はネイティブに設定された権限ポリシーに従います。Cindy は Cursor から送られた承認要求のみを表示し、ワークスペース内の編集は確認なしで実行される場合があります。" + } + }, "claude-code": { "ask": { "label": "デフォルト権限", @@ -13314,6 +13324,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 f62f24782bf..0d0e99fca24 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -9266,6 +9266,16 @@ "listAria": "권한 모드", "autoFallback": "자동 리뷰를 일시적으로 사용할 수 없어 기본 권한으로 전환했습니다. 필요한 작업은 확인을 요청합니다.", "modes": { + "cursor": { + "ask": { + "label": "기본 권한", + "description": "Cursor는 자체 설정된 권한 정책을 따릅니다. Cindy는 Cursor가 보내는 승인 요청만 표시하며, 작업 공간 내 편집은 확인 없이 실행될 수 있습니다." + }, + "default": { + "label": "기본 권한", + "description": "Cursor는 자체 설정된 권한 정책을 따릅니다. Cindy는 Cursor가 보내는 승인 요청만 표시하며, 작업 공간 내 편집은 확인 없이 실행될 수 있습니다." + } + }, "claude-code": { "ask": { "label": "기본 권한", @@ -13314,6 +13324,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 bc2cad99a2c..00067ac55a2 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -9266,6 +9266,16 @@ "listAria": "权限模式", "autoFallback": "自动审批暂时不可用,已切换为默认权限;需要时会询问你。", "modes": { + "cursor": { + "ask": { + "label": "默认权限", + "description": "Cursor 遵循其原生配置的权限策略。Cindy 只展示 Cursor 发来的授权请求;工作区内的编辑可能无需确认便直接执行。" + }, + "default": { + "label": "默认权限", + "description": "Cursor 遵循其原生配置的权限策略。Cindy 只展示 Cursor 发来的授权请求;工作区内的编辑可能无需确认便直接执行。" + } + }, "claude-code": { "ask": { "label": "默认权限", @@ -13314,6 +13324,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 9258516fbfd..d22fe2f6f9f 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -9266,6 +9266,16 @@ "listAria": "權限模式", "autoFallback": "自動審批暫時不可用,已切換為預設權限;需要時會詢問你。", "modes": { + "cursor": { + "ask": { + "label": "預設權限", + "description": "Cursor 遵循其原生設定的權限政策。Cindy 只顯示 Cursor 傳來的授權請求;工作區內的編輯可能無須確認便直接執行。" + }, + "default": { + "label": "預設權限", + "description": "Cursor 遵循其原生設定的權限政策。Cindy 只顯示 Cursor 傳來的授權請求;工作區內的編輯可能無須確認便直接執行。" + } + }, "claude-code": { "ask": { "label": "預設權限", @@ -13314,6 +13324,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 1a6eccb6156..cb05ba480d4 100644 --- a/apps/desktop/src/renderer/lib/ccAgent.types.ts +++ b/apps/desktop/src/renderer/lib/ccAgent.types.ts @@ -21,7 +21,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'; @@ -458,7 +458,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; @@ -485,8 +485,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 切换时的来源快照;缺失表示旧版边界数据。 */ @@ -519,7 +519,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..e3297f2ae27 100644 --- a/apps/desktop/src/renderer/lib/customProviderRuntimeFill.ts +++ b/apps/desktop/src/renderer/lib/customProviderRuntimeFill.ts @@ -5,7 +5,7 @@ import type { } from '@cindy/model-providers'; import { adaptCustomProviderModelEfforts, savedCustomProviderModelShape } from '@/../shared/piRuntimeInitialization'; -export type RuntimeFillAgent = Extract; +export type RuntimeFillAgent = Extract; export interface RuntimeFillHeaderRow { name: string; value: string; diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index c409b2ce13b..ca38a0244a4 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -674,7 +674,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; @@ -2448,7 +2448,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; @@ -2469,7 +2469,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; /** @@ -5548,7 +5548,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); @@ -7268,7 +7268,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'; @@ -7320,7 +7320,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; }; @@ -10038,7 +10038,7 @@ setRemoteTerminalErrorProbe(hasSessionTerminalError); interface ActiveSessionSnapshot { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor'; isTurnRunning: boolean; } @@ -10088,7 +10088,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' ); } @@ -11240,8 +11240,8 @@ function retryInvalidatedInitialHistoryFetchIfNeeded( */ 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 === 'cc') return 'claude-code'; if (dbKind === 'pi') return 'pi'; @@ -13758,9 +13758,9 @@ export function buildCreateOptsForCurrentSession( agentKind: current.agentKind, workingDir, model, - effort, - permissionMode, - fastMode: current.fastMode, + ...(current.agentKind === 'cursor' ? {} : { effort }), + permissionMode: current.agentKind === 'cursor' ? 'ask' : permissionMode, + fastMode: current.agentKind === 'cursor' ? false : current.fastMode, planMode: current.planModeEnabled, displayReasoning: 'summarized', userPrompt: getUserPrompt(), @@ -14353,7 +14353,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,两端算出的占位串逐字一致,回流时不跳变。 @@ -14469,7 +14469,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; @@ -14869,7 +14869,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, @@ -17182,7 +17182,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; @@ -17214,7 +17214,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; @@ -17273,7 +17273,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` 对齐); @@ -17338,7 +17338,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. */ @@ -17443,7 +17443,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 @@ -17719,7 +17719,7 @@ export const makerChatStore = { taskType?: string; toolUseId?: string; title?: string; - provider?: 'pi' | 'claude-code'; + provider?: 'pi' | 'claude-code' | 'cursor'; }>, opts?: { staleRunningCandidates?: ReadonlySet; @@ -17738,7 +17738,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 cecc2bfe3f4..2ada65b9fa2 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 c2ee6ae8f12..abcfbca9520 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( * useScheduleForm.ts getScheduleDefaultModel,不要把这里的默认接到 scheduler 上。 */ 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 78683c59f5c..0d4d4c1de2a 100644 --- a/apps/desktop/src/renderer/lib/sessionService.ts +++ b/apps/desktop/src/renderer/lib/sessionService.ts @@ -103,7 +103,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[]; @@ -128,7 +128,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 dccfe345dae..b67350e23c1 100644 --- a/apps/desktop/src/renderer/state/newMakerDraft.ts +++ b/apps/desktop/src/renderer/state/newMakerDraft.ts @@ -87,7 +87,7 @@ export interface CollabWorkerConfig { export interface CollabDraft { enabled: boolean; - worker: 'cc' | 'codex' | 'pi'; + worker: 'cc' | 'codex' | 'pi' | 'cursor'; workerConfig?: CollabWorkerConfig; } @@ -186,6 +186,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 可达面),默认给网关中档模型; @@ -243,6 +244,7 @@ function makeDefault(): NewMakerDraft { lastByVendor: { cc: defaultVendorPrefs('cc'), pi: defaultVendorPrefs('pi'), + cursor: defaultVendorPrefs('cursor'), orca: defaultVendorPrefs('orca'), codex: defaultVendorPrefs('codex'), }, @@ -276,7 +278,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 取代),避免空白入口。 @@ -318,7 +320,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 注入)。 @@ -377,7 +379,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 都是足够强的 @@ -408,7 +410,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 || @@ -471,6 +473,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'), }, @@ -983,7 +986,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 188e070f6a9..081610bc240 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -160,7 +160,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; @@ -529,7 +529,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; @@ -630,7 +630,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; @@ -718,7 +718,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 透传过来。 @@ -2420,12 +2420,12 @@ interface ElectronAPI { selectedRoute?: import('../shared/botModelChain').BotModelRoute; 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< @@ -2455,7 +2455,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; @@ -2466,7 +2466,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; @@ -2505,7 +2505,7 @@ interface ElectronAPI { onMakerSessionPrefApply: ( cb: (payload: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'cursor'; providerId: string; model: string; effort?: string; @@ -3267,7 +3267,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; @@ -4699,7 +4699,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[]; @@ -5315,9 +5315,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; @@ -5436,12 +5436,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: ( @@ -5513,11 +5513,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'; @@ -5538,7 +5538,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; @@ -5633,7 +5633,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; @@ -5789,7 +5789,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; @@ -5799,7 +5799,7 @@ interface ElectronAPI { }>; listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', params: { workingDir?: string; remoteHostId?: string; @@ -5883,7 +5883,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; @@ -5915,7 +5915,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; @@ -5960,7 +5960,7 @@ interface ElectronAPI { enableOrca: ( leadSessionId: string, opts: { - workerAgent: 'claude-code' | 'codex' | 'pi'; + workerAgent: 'claude-code' | 'codex' | 'pi' | 'cursor'; delegateTask?: string; role?: string; label?: string; @@ -6008,7 +6008,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; @@ -6055,7 +6055,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; @@ -6088,7 +6088,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; @@ -6252,7 +6252,7 @@ interface ElectronAPI { */ switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'cursor', model: string, providerId?: string | null, effort?: string, @@ -6261,7 +6261,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; @@ -6273,7 +6273,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; @@ -6313,13 +6313,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'; @@ -6327,7 +6327,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; }>; @@ -6648,7 +6648,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. */ @@ -6665,13 +6665,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; @@ -6747,25 +6747,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; @@ -6784,7 +6784,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; @@ -6793,10 +6793,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; @@ -6810,7 +6810,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; @@ -6822,7 +6822,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. */ @@ -6931,7 +6931,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; @@ -6998,7 +6998,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; @@ -7174,10 +7174,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'; }>; @@ -7304,7 +7304,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__/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 471246b84f7..0c072ecf1bf 100644 --- a/apps/desktop/src/shared/agentInputQueue.ts +++ b/apps/desktop/src/shared/agentInputQueue.ts @@ -159,7 +159,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; diff --git a/apps/desktop/src/shared/agentIsland.ts b/apps/desktop/src/shared/agentIsland.ts index 544fcb39e7f..ec9ca11e2ed 100644 --- a/apps/desktop/src/shared/agentIsland.ts +++ b/apps/desktop/src/shared/agentIsland.ts @@ -759,7 +759,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/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/botModelChain.ts b/apps/desktop/src/shared/botModelChain.ts index fcf2e4db974..7da9d0683a5 100644 --- a/apps/desktop/src/shared/botModelChain.ts +++ b/apps/desktop/src/shared/botModelChain.ts @@ -1,6 +1,6 @@ export const BOT_MODEL_CHAIN_MAX = 5; -export type BotHarness = 'claude' | 'codex' | 'pi'; +export type BotHarness = 'claude' | 'codex' | 'pi' | 'cursor'; /** One complete, ordered Bot runtime route. */ export interface BotModelRoute { @@ -20,7 +20,7 @@ export function botModelRouteRuntimeKey( } export function normalizeBotHarness(value: unknown): BotHarness { - return value === 'codex' || value === 'pi' || value === 'claude' ? value : 'claude'; + return value === 'codex' || value === 'pi' || value === 'cursor' || value === 'claude' ? value : 'claude'; } export function normalizeBotModelRoute(value: unknown): BotModelRoute | null { 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/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/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..35eb7c0124e 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'; 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/mobile/app/sessions/new.tsx b/apps/mobile/app/sessions/new.tsx index 3b50e560f67..2cb33ea9a0a 100644 --- a/apps/mobile/app/sessions/new.tsx +++ b/apps/mobile/app/sessions/new.tsx @@ -2146,7 +2146,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', ), ), ); @@ -2536,7 +2536,7 @@ export default function NewRemoteSessionScreen() { const targetCapabilities = normalizeMobileAgentCapabilities(await maker.getCapabilities(config.agent)); if (!targetCapabilities) return false; const storedPermission = newSessionPreferences?.permissionModeByAgent[config.agent]; - const permission = config.agent === draft.agentKind ? draft.permissionMode + const permission = config.agent === 'cursor' ? 'ask' : config.agent === draft.agentKind ? draft.permissionMode : storedPermission ?? defaultPermissionModeForNewSessionAgent(config.agent); if (config.agent !== draft.agentKind && !await confirmFullAccessChange(draft.permissionMode, permission, { restoringRememberedChoice: storedPermission !== undefined, diff --git a/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts b/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts index 5b23b102814..6534411203c 100644 --- a/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts +++ b/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts @@ -26,6 +26,7 @@ const labels = { cc: "Claude Code", codex: "Codex", pi: "Pi", + cursor: "Cursor", }, allProjectsLabel: "所有项目", lastActivityHeading: "最近活动", diff --git a/apps/mobile/src/__tests__/cursorHostCompatibility.test.ts b/apps/mobile/src/__tests__/cursorHostCompatibility.test.ts new file mode 100644 index 00000000000..690f2c36f68 --- /dev/null +++ b/apps/mobile/src/__tests__/cursorHostCompatibility.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { availableNewSessionAgentOptions, DEFAULT_NEW_SESSION_DRAFT, withAgentDefaults, buildRemoteCreateSessionOptions } from '../session/newSession'; +import { normalizeSessionAgentSwitchIntent, sessionAgentKind, mobileAgentLabel } from '../session/sessionAgentSwitch'; + +describe('Cursor host compatibility', () => { + 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: 'ask', fastMode: false }); + expect(options).not.toHaveProperty('effort'); + expect(withAgentDefaults(restored, 'cursor').permissionMode).toBe('ask'); + }); + 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__/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__/homeDesktopFirst.test.ts b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts index 05260a63d3c..538024b245a 100644 --- a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts +++ b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts @@ -399,7 +399,7 @@ 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';"); - expect(vendorIconSource).toContain("agentKind={vendor === 'codex' || vendor === 'pi' ? vendor : 'claude-code'}"); + expect(vendorIconSource).toContain("agentKind={vendor === 'codex' || vendor === 'pi' || vendor === 'cursor' ? vendor : 'claude-code'}"); expect(vendorIconSource).not.toContain('viewBox="136 137 282 158"'); expect(vendorIconSource).not.toContain('transform="translate('); expect(vendorIconSource).toContain('Easing.inOut(Easing.ease)'); diff --git a/apps/mobile/src/__tests__/newSession.test.ts b/apps/mobile/src/__tests__/newSession.test.ts index ba44e410342..01bf1b5b0bb 100644 --- a/apps/mobile/src/__tests__/newSession.test.ts +++ b/apps/mobile/src/__tests__/newSession.test.ts @@ -1333,7 +1333,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({ ...DEFAULT_NEW_SESSION_DRAFT, fastMode: true }, 'pi'); expect(pi).toMatchObject({ agentKind: 'pi', model: 'gpt-5.4', fastMode: true }); diff --git a/apps/mobile/src/__tests__/orcaTeam.test.ts b/apps/mobile/src/__tests__/orcaTeam.test.ts index 43629eb8449..a3c449a5987 100644 --- a/apps/mobile/src/__tests__/orcaTeam.test.ts +++ b/apps/mobile/src/__tests__/orcaTeam.test.ts @@ -378,3 +378,13 @@ describe('mobile Orca collaboration mutations', () => { }); }); + +describe('Cursor worker native settings', () => { + it('preserves Cursor identity with native approval and no unsupported model controls', () => { + const form = orcaWorkerFormFromPrefs(defaultOrcaWorkerCreationPrefs(), 'cursor'); + expect(form.permissionMode).toBe('ask'); + 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: 'ask', fast: false }); + expect(options).not.toHaveProperty('effort'); + }); +}); 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__/useOrcaWorkerForm.test.tsx b/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx index 8d29be6a205..5bd93fbed19 100644 --- a/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx +++ b/apps/mobile/src/__tests__/useOrcaWorkerForm.test.tsx @@ -353,6 +353,25 @@ 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 uses Ask 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('ask'); +}); + +it('normalizes a confirmed Cursor model choice to Ask', 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('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. 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 601da375946..e769ca70535 100644 --- a/apps/mobile/src/device-link/mobileMakerTransport.ts +++ b/apps/mobile/src/device-link/mobileMakerTransport.ts @@ -97,7 +97,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 对 @@ -129,7 +129,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/session.json b/apps/mobile/src/i18n/locales/en/session.json index c1779c07228..9369e9f8a34 100644 --- a/apps/mobile/src/i18n/locales/en/session.json +++ b/apps/mobile/src/i18n/locales/en/session.json @@ -580,6 +580,8 @@ "modelDefault": "Default", "modelDefaultHint": "Uses the computer's default Worker model", "permissionLabel": "Permission mode", + "permissionAsk": "Ask", + "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/session.json b/apps/mobile/src/i18n/locales/ja/session.json index 484ed46ae05..fde261f4636 100644 --- a/apps/mobile/src/i18n/locales/ja/session.json +++ b/apps/mobile/src/i18n/locales/ja/session.json @@ -580,6 +580,8 @@ "modelDefault": "デフォルト", "modelDefaultHint": "コンピューター側の Worker 既定モデルを使います", "permissionLabel": "権限モード", + "permissionAsk": "確認する", + "permissionAskHint": "Cursor はネイティブに設定された権限ポリシーに従います。Cindy は Cursor から送られた承認要求のみを表示し、ワークスペース内の編集は確認なしで実行される場合があります。", "permissionAuto": "自動承認", "permissionFull": "フルアクセス", "permissionAutoHint": "ワークスペース内の読み書きを許可し、権限昇格を自動承認します。高リスクの操作は拒否または確認が必要になることがあります。", diff --git a/apps/mobile/src/i18n/locales/ko/session.json b/apps/mobile/src/i18n/locales/ko/session.json index 9f0aafec5e2..5c1e2dafc4a 100644 --- a/apps/mobile/src/i18n/locales/ko/session.json +++ b/apps/mobile/src/i18n/locales/ko/session.json @@ -576,6 +576,8 @@ "modelDefault": "기본값", "modelDefaultHint": "컴퓨터의 Worker 기본 모델을 사용합니다", "permissionLabel": "권한 모드", + "permissionAsk": "확인 요청", + "permissionAskHint": "Cursor는 자체 설정된 권한 정책을 따릅니다. Cindy는 Cursor가 보내는 승인 요청만 표시하며, 작업 공간 내 편집은 확인 없이 실행될 수 있습니다.", "permissionAuto": "자동 승인", "permissionFull": "전체 액세스", "permissionAutoHint": "작업 공간 안에서 읽고 쓰며 권한 상승 요청을 자동 승인합니다. 위험도가 높은 작업은 거부되거나 확인이 필요할 수 있습니다.", diff --git a/apps/mobile/src/i18n/locales/zh-CN/session.json b/apps/mobile/src/i18n/locales/zh-CN/session.json index 51c88308033..bd0166df65d 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/session.json +++ b/apps/mobile/src/i18n/locales/zh-CN/session.json @@ -576,6 +576,8 @@ "modelDefault": "默认", "modelDefaultHint": "跟随电脑端的 Worker 默认模型", "permissionLabel": "权限模式", + "permissionAsk": "询问", + "permissionAskHint": "Cursor 遵循其原生配置的权限策略。Cindy 只展示 Cursor 发来的授权请求;工作区内的编辑可能无需确认便直接执行。", "permissionAuto": "自动审批", "permissionFull": "完全访问", "permissionAutoHint": "允许在工作区内读写,并自动审批提权请求;高风险操作可能被拒绝或要求确认。", diff --git a/apps/mobile/src/i18n/locales/zh-TW/session.json b/apps/mobile/src/i18n/locales/zh-TW/session.json index 4b69fd890be..251e3127886 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/session.json +++ b/apps/mobile/src/i18n/locales/zh-TW/session.json @@ -580,6 +580,8 @@ "modelDefault": "預設", "modelDefaultHint": "沿用電腦端的 Worker 預設模型", "permissionLabel": "權限模式", + "permissionAsk": "詢問", + "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 8dab601955c..7da9263681f 100644 --- a/apps/mobile/src/session/ContextSheetCollabView.tsx +++ b/apps/mobile/src/session/ContextSheetCollabView.tsx @@ -154,14 +154,14 @@ export function OrcaWorkerFormView({ disabled={busy} label={t('session.collab.permissionLabel')} onChange={onPermissionChange} - options={[ + 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') }, ]} 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 f3a848e0f85..ba7a74179ea 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -4564,6 +4564,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 435643c90a0..60b4e8f6127 100644 --- a/apps/mobile/src/session/UnifiedModelPickerSheet.tsx +++ b/apps/mobile/src/session/UnifiedModelPickerSheet.tsx @@ -148,7 +148,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 }, ) { 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/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 f1899bf1488..45d582e2a8f 100644 --- a/apps/mobile/src/session/mobileHomeListCache.ts +++ b/apps/mobile/src/session/mobileHomeListCache.ts @@ -210,7 +210,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/newSession.ts b/apps/mobile/src/session/newSession.ts index e179102c766..719dec0031a 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'); @@ -132,7 +133,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: { @@ -167,18 +168,19 @@ const DEFAULT_MODELS: Record = { 'claude-code': 'claude-sonnet-4-6', codex: 'gpt-5.4', pi: 'gpt-5.4', + cursor: '', }; /** 新建交互式会话的权限种子默认;三个 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( draft: NewSessionDraft, agentKind: NewSessionAgentKind, ): NewSessionDraft { - if (draft.agentKind === agentKind) return draft; + if (draft.agentKind === agentKind) return agentKind === 'cursor' ? { ...draft, permissionMode: 'ask', fastMode: false } : draft; return { ...draft, agentKind, @@ -223,7 +225,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' @@ -374,7 +376,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( @@ -688,7 +690,7 @@ export function pickMostRecentSessionRuntime( const model = session.model?.trim(); if (!model) continue; if (options.deviceId && session.deviceLinkDeviceId && session.deviceLinkDeviceId !== options.deviceId) continue; - const agentKind: NewSessionAgentKind = session.agentKind === 'codex' || session.agentKind === 'pi' + const agentKind: NewSessionAgentKind = session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'cursor' ? session.agentKind : 'claude-code'; if (options.agentKind && agentKind !== options.agentKind) continue; @@ -970,9 +972,9 @@ export function buildRemoteCreateSessionOptions(draft: NewSessionDraft): CreateS agentKind: draft.agentKind, workspaceKind: draft.workspaceKind, model: draft.model.trim(), - permissionMode: draft.permissionMode, - fastMode: draft.fastMode, - ...(effort ? { effort } : {}), + permissionMode: draft.agentKind === 'cursor' ? 'ask' : draft.permissionMode, + fastMode: draft.agentKind === 'cursor' ? false : draft.fastMode, + ...(effort && draft.agentKind !== 'cursor' ? { effort } : {}), // 仅显式选了非空来源才带 providerId(空 = NULL = 被控端默认路由,对齐桌面 deviceLinkCreateArgs)。 ...(providerId ? { providerId } : {}), }; @@ -1026,7 +1028,7 @@ export function sessionFromCreateResult( workspaceKind: fallback.workspaceKind, model: fallback.model, effort: fallback.effort, - permissionMode: fallback.permissionMode, + permissionMode: fallback.agentKind === 'cursor' ? 'ask' : fallback.permissionMode, fastMode: fallback.fastMode, status: 'active', agentKind: fallback.agentKind === 'claude-code' ? 'cc' : fallback.agentKind, diff --git a/apps/mobile/src/session/newSessionPreferenceStore.ts b/apps/mobile/src/session/newSessionPreferenceStore.ts index 17ccc470aac..2a8c2ade554 100644 --- a/apps/mobile/src/session/newSessionPreferenceStore.ts +++ b/apps/mobile/src/session/newSessionPreferenceStore.ts @@ -138,10 +138,10 @@ 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; + out[agent] = agent === 'cursor' ? 'ask' : mode; } } return out; diff --git a/apps/mobile/src/session/orcaTeam.ts b/apps/mobile/src/session/orcaTeam.ts index b8c97ea7b44..e4aa218b3db 100644 --- a/apps/mobile/src/session/orcaTeam.ts +++ b/apps/mobile/src/session/orcaTeam.ts @@ -51,7 +51,7 @@ export interface OrcaWorkerFormValue { } 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,初始任务不记忆(与桌面一致)。 */ @@ -64,7 +64,7 @@ export function orcaWorkerFormFromPrefs( role: 'developer', agent, model: { id: remembered.model, providerId: null, effort: remembered.effort, fast: remembered.fast }, - permissionMode: prefs.workerPermissionMode, + permissionMode: agent === 'cursor' ? 'ask' : prefs.workerPermissionMode, initialTask: '', }; } @@ -164,12 +164,12 @@ function formWireFields(form: OrcaWorkerFormValue) { const model = form.model; return { ...(model ? { model: model.id } : {}), - ...(model?.effort ? { effort: model.effort } : {}), + ...(form.agent !== 'cursor' && model?.effort ? { effort: model.effort } : {}), // 选了具体模型就显式发送 Fast(含 false):省略会让被控端沿用 Lead / 默认的 Fast,与表单相反。 // 「默认」模型(null)才省略,交给被控端一并解析。模型不支持 Fast 时被控端按 false 落。 - ...(model ? { fast: model.fast } : {}), + ...(model ? { fast: form.agent === 'cursor' ? false : model.fast } : {}), ...(model?.providerId ? { providerId: model.providerId } : {}), - workerPermissionMode: form.permissionMode, + workerPermissionMode: form.agent === 'cursor' ? 'ask' : form.permissionMode, }; } @@ -478,5 +478,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 530d1e59154..fee2ad8b682 100644 --- a/apps/mobile/src/session/sessionControls.ts +++ b/apps/mobile/src/session/sessionControls.ts @@ -34,7 +34,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 0497a5edfb8..4c463339c1e 100644 --- a/apps/mobile/src/session/sessionMenu.ts +++ b/apps/mobile/src/session/sessionMenu.ts @@ -211,7 +211,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 50dbd0ba870..68f1d61574d 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 就在被控电脑运行(含旧被控端)。 @@ -109,7 +109,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; @@ -226,7 +226,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 9151e461e95..916d43e94ec 100644 --- a/apps/mobile/src/session/useSessionOrcaCollab.ts +++ b/apps/mobile/src/session/useSessionOrcaCollab.ts @@ -68,7 +68,9 @@ import type { RemoteSession } from '@/session/types'; 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']; const EMPTY_MODEL_OPTIONS: readonly MobileModelOption[] = []; // ─── 团队状态 ──────────────────────────────────────────────────────────────── @@ -285,7 +287,7 @@ export function useOrcaWorkerForm(params: { return orcaWorkerFormFromPrefs(defaults, defaults.lastAgent); }); 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 makerRef = useRef(maker); @@ -362,8 +364,8 @@ export function useOrcaWorkerForm(params: { useEffect(() => { if (rosterMakerRef.current === maker) return; rosterMakerRef.current = maker; - agentsRef.current = ALL_AGENTS; - setAgents(ALL_AGENTS); + agentsRef.current = LEGACY_AGENT_FALLBACK; + setAgents(LEGACY_AGENT_FALLBACK); setModelsByAgent({}); }, [maker]); @@ -395,6 +397,7 @@ export function useOrcaWorkerForm(params: { setForm((value) => ({ ...value, agent: switched, + permissionMode: switched === 'cursor' ? 'ask' : value.permissionMode, model: { id: agentPrefs.model, providerId: null, effort: agentPrefs.effort, fast: agentPrefs.fast }, })); converge(switched); @@ -410,7 +413,7 @@ export function useOrcaWorkerForm(params: { convergeGenRef.current += 1; touchedRef.current = true; setCustomRoleMode(!isPredefinedOrcaRole(value.role.trim().toLowerCase())); - setForm(value); + setForm(value.agent === 'cursor' ? { ...value, permissionMode: 'ask' } : value); }, []); /** 恢复记忆(上次的 Agent 不在当前电脑上时取第一个可用 Agent)。初始任务不记忆。 */ @@ -447,6 +450,7 @@ export function useOrcaWorkerForm(params: { setForm((current) => ({ ...current, agent, + permissionMode: agent === 'cursor' ? 'ask' : current.permissionMode, model: { id: remembered.model, providerId: null, effort: remembered.effort, fast: remembered.fast }, })); converge(agent); @@ -489,6 +493,7 @@ export function useOrcaWorkerForm(params: { setForm((current) => ({ ...current, agent: config.agent as OrcaWorkerAgentKind, + permissionMode: config.agent === 'cursor' ? 'ask' : current.permissionMode, model: { id: config.modelId, providerId: config.providerId || null, diff --git a/docs/dev-rules/cursor-harness.md b/docs/dev-rules/cursor-harness.md new file mode 100644 index 00000000000..5cc0f047ba8 --- /dev/null +++ b/docs/dev-rules/cursor-harness.md @@ -0,0 +1,122 @@ +# 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. + +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 is displayed only +after a successful native handshake. It does not claim that AUTO is available. +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; +Fast/effort controls are not advertised or silently simulated. + +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 + +Only Ask/Default approval modes are implemented. Every permission request Cursor +actually sends is delegated to Cindy's existing interaction resolver. The exact +advertised `allow_once`/`reject_once` option ID is returned; native persistent +`allow_always` grants are never synthesized. 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/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/lizi-mcps/src/__tests__/createWorkerTool.test.ts b/packages/lizi-mcps/src/__tests__/createWorkerTool.test.ts index 3fb981e4018..db3b2b151fd 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 2fa6bc6305a..69de566a6ff 100644 --- a/packages/lizi-mcps/src/__tests__/listAvailableModelsTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/listAvailableModelsTool.test.ts @@ -11,6 +11,21 @@ function parse(result: XdtHelperToolResult) { } describe('list_available_models tool', () => { + 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__/sessionControlTools.test.ts b/packages/lizi-mcps/src/__tests__/sessionControlTools.test.ts index 85e00817d0b..d1680c0e29a 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 }; 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/lizi_xdtHelperMcpServer.ts b/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts index 8858f6719ed..94e85dc8761 100644 --- a/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts +++ b/packages/lizi-mcps/src/lizi_xdtHelperMcpServer.ts @@ -800,7 +800,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 adce2c39275..eb337808639 100644 --- a/packages/lizi-mcps/src/xdt-helper/bot_workbench.ts +++ b/packages/lizi-mcps/src/xdt-helper/bot_workbench.ts @@ -78,7 +78,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 5d446957891..37a3027dfc5 100644 --- a/packages/lizi-mcps/src/xdt-helper/create_worker.ts +++ b/packages/lizi-mcps/src/xdt-helper/create_worker.ts @@ -70,7 +70,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() @@ -127,7 +127,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 06157dddcf1..2d2c126c91d 100644 --- a/packages/lizi-mcps/src/xdt-helper/list_available_models.ts +++ b/packages/lizi-mcps/src/xdt-helper/list_available_models.ts @@ -39,12 +39,12 @@ interface TaggedModel { * label / description 里语义推断, 直接按 tier 精准匹配用户指定的档位。 * label (= host displayName, 同时是 UI 下拉展示名) 不受影响, 保持干净。 */ -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: m.id.startsWith('codex/') ? 'budget' : 'standard', + tier: !nativeCatalog && m.id.startsWith('codex/') ? 'budget' : 'standard', ...(m.providers ? { providers: m.providers.map((provider) => ({ @@ -63,26 +63,28 @@ export interface ListAvailableModelsDeps { /** 调用方任务(可选):它的 Agent 在另一台电脑运行时,按那台的模型目录列出。 */ getSessionContext?: () => { sessionId?: string }; listAvailableModels: (params: { - agent?: 'claude-code' | 'codex' | 'pi'; + agent?: 'claude-code' | 'codex' | 'pi' | 'cursor'; callerSessionId?: string; }) => Promise>; } 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; 不传返所有引擎', '', '返回值:', '- 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 只有一项时直接使用该项。', '', @@ -105,9 +107,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 列表; 不传返所有引擎'), }, handler: async ({ agent }) => { const callerSessionId = deps.getSessionContext?.().sessionId; @@ -122,6 +124,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_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 01f79fd0c07..4f577097538 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..4f5f646cfbd --- /dev/null +++ b/packages/maker-core/src/agents/cursor/index.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CursorAgent, CURSOR_DEFAULT_MODEL, type CursorAgentDeps } from './index.js'; +import { PINNED_SKILL_INVOCATION } from '../base-agent.js'; +import { LIBRARY_READ_ROOT } from '../shared/library-native-read.js'; +import type { AcpTransport } from '../acp/transport.js'; +import type { AgentEvent } 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'; + +class FakeTransport implements AcpTransport { + lines = new Set<(line: string) => void>(); + closes = new Set<(info: { reason: string }) => void>(); + written: any[] = []; + 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: any) => void; + async writeLine(line: string) { + const message = 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)); } + +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']); + 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('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 (..._args: unknown[]) => ({ 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('does not auto-approve a pending plan when permission mode changes', async () => { + const { fake, start } = create(); const handle = await start(); let answer: ((value: any) => 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!('default'); + 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('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..530cf91445c --- /dev/null +++ b/packages/maker-core/src/agents/cursor/index.ts @@ -0,0 +1,553 @@ +/** 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 { 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 { CURSOR_DEFAULT_MODEL, cursorDefaultModel, readCursorModels, record, type AcpRecord } 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 }, + ], + 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 Set(); + 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 } }); + 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 (temporary && cleanupConfirmed) await rm(temporary, { recursive: true, force: true }); + } + } + + override async dispose(): Promise { this.disposed = true; for (const startup of this.startups) startup.abort(); await Promise.all([...this.sessions].map(session => session.close())); } + + override async refreshLocalModels(): Promise { await this.discoverModels(); return true; } + + async startSession(opts: StartSessionOptions): Promise { + const startup = new AbortController(); + this.startups.add(startup); + 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 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; + const pending = new Map void>(); + const emit = (event: AgentEvent) => { + if (!closed) queue.push({ ...event, source: 'cursor', turnAttemptToken: turnOptions?.turnAttemptToken }); + }; + const translator = new CursorTranslator(emit); + const clearInteractions = () => { + for (const cancel of pending.values()) cancel(); + pending.clear(); + }; + const interact = async (request: InteractionRequest): Promise => { + if (closed || cancelled || !resolver) return undefined; + emit({ type: 'interaction_request', data: request }); + return new Promise(resolve => { + let settled = false; + const finish = (decision?: InteractionDecision) => { + if (settled) return; + settled = true; + pending.delete(request.requestId); + emit({ type: 'interaction_dismissed', data: { requestId: request.requestId, reason: decision ? 'resolved' : 'cancelled' } }); + resolve(decision); + }; + pending.set(request.requestId, () => finish()); + Promise.resolve().then(() => resolver!(request)).then(finish, () => finish()); + }); + }; + 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, + }); + 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. + return decision.kind === 'plan_review' && decision.behavior === 'allow' && + (!decision.editedPlan || decision.editedPlan === params.plan) + ? { 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' } }; + return cursorAnswers(questions, decision.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); + const modes = record(sessionState.modes); + this.capabilities.planMode = Array.isArray(modes.availableModes) && modes.availableModes.some(mode => record(mode).id === 'plan') ? yes : no; + this.capabilities.switchModel = catalog.configId ? yes : no; + this.capabilities.availableModels = catalog.models.length + ? [cursorDefaultModel, ...catalog.models] : [cursorDefaultModel]; + }; + 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 setModel = async (next: string) => { + 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'); + if (next === model) return; + const catalog = readCursorModels(sessionState); + const target = next === CURSOR_DEFAULT_MODEL ? defaultModel : next; + if (target && target === catalog.currentModel) { model = next; return; } + if (!target || !catalog.configId || !catalog.models.some(item => item.id === target)) { + throw new NotSupportedError('setModel', { supported: false, reason: 'sdk-missing' }); + } + const response = await control('session/set_config_option', { sessionId, configId: catalog.configId, value: target }); + updateCatalog(response); + // Some versions acknowledge with an empty object, retaining the advertised options. + const configs = Array.isArray(sessionState.configOptions) ? sessionState.configOptions : []; + sessionState.configOptions = configs.map(item => record(item).id === catalog.configId ? { ...record(item), currentValue: target } : item); + model = next; + }; + 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(() => {}); + closed = true; + running = false; + active = undefined; + unregisterProcess?.(); + bridge?.disposeSessionCtx?.(); + queue.end(); + if (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.fastMode || opts.effort || opts.thinkingEnabled !== undefined) throw new Error('Cursor ACP does not advertise these model options'); + if (!['ask', 'default'].includes(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 (loading || 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') { + planMode = update.currentModeId === 'plan'; + emit({ type: 'plan_mode_changed', data: { enabled: planMode } }); + } + if (running) translator.update(update); + } else if (!loading && running && method === 'cursor/update_todos') { + translator.update({ sessionUpdate: 'plan', entries: params.todos }); + } 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 }, + 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 }); + } + } + 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); + } + // 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 = CURSOR_DEFAULT_MODEL; + if (requestedModel !== CURSOR_DEFAULT_MODEL) await setModel(requestedModel); + if (opts.planMode) await setPlanMode(true); + emit({ type: 'session_id', data: sessionId }); + 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; }, + 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'); + if (firstPrompt && context) blocks.unshift({ type: 'text', text: context }); + emit({ type: 'status', data: { ...translator.usage, isRunning: true } }); + // 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, + async setPermissionMode(next) { + if (!['ask', 'default'].includes(next)) throw new NotSupportedError('permissionMode', no); + mode = next; // No pending approval, especially plan approval, is auto-resolved. + }, + setPlanMode, getPlanMode: () => planMode, getExecutionPlanMode: () => planMode, + }; + assertCurrent(); + this.sessions.add(handle); + return handle; + } catch (error) { + try { + await client?.close(); + unregisterProcess?.(); + bridge?.disposeSessionCtx?.(); + queue.end(); + } catch { + const whenStopped = client!.close().then(() => { unregisterProcess?.(); bridge?.disposeSessionCtx?.(); queue.end(); }); + // The rejected cleanup promise is observed by Maker's quarantine path. + throw new AgentStartupCleanupPendingError('Cursor startup cleanup has not confirmed process exit', { cause: error, whenStopped }); + } + 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); + } finally { this.startups.delete(startup); } + } +} + +function validateSessionId(id: unknown): string { + if (typeof id !== 'string' || !id.trim() || id.length > 4096 || /[\x00-\x1f]/.test(id)) { + 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..916da5d909e --- /dev/null +++ b/packages/maker-core/src/agents/cursor/models.ts @@ -0,0 +1,62 @@ +import type { ModelDescriptor } from '../../types/capabilities.js'; + +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 } +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): 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): void { + if (!Array.isArray(options)) return; + for (const raw of options) { + const option = record(raw); + if (Array.isArray(option.options)) collect(option.options); + else if (typeof option.value === 'string' && option.value.trim()) { + choices.push({ id: option.value, name: typeof option.name === 'string' ? option.name : option.value }); + } + } + } + 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 }); + } + } + } + const current = config?.currentValue ?? legacy.currentModelId; + const currentModel = typeof current === 'string' && current.trim() ? current : undefined; + return { + configId: typeof config?.id === 'string' ? config.id : undefined, + currentModel, + models: [...new Map(choices.map(item => [item.id, item])).values()].map(item => ({ + id: item.id, displayName: item.name, contextWindow: 0, + efforts: [], defaultEffort: null, supportsFastMode: false, + ...(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, + newSessionDefault: ['cursor'], +}; 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..d91625d9bae --- /dev/null +++ b/packages/maker-core/src/agents/cursor/translator.ts @@ -0,0 +1,106 @@ +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(); + 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; + } + + 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 }); + 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 15b4b531ee4..8ffd39c5f4c 100644 --- a/packages/maker-core/src/agents/index.ts +++ b/packages/maker-core/src/agents/index.ts @@ -141,3 +141,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 a6c929af98b..856311b3727 100644 --- a/packages/maker-core/src/maker.test.ts +++ b/packages/maker-core/src/maker.test.ts @@ -4223,11 +4223,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', @@ -4236,16 +4236,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 2eb8cf35a5f..4f48dee0153 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 36332d388d9..e18d934c886 100644 --- a/packages/maker-core/src/session.origin.test.ts +++ b/packages/maker-core/src/session.origin.test.ts @@ -567,18 +567,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 8bf5700ca37..afda999e9ec 100644 --- a/packages/maker-core/src/session.ts +++ b/packages/maker-core/src/session.ts @@ -2599,7 +2599,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 bb07e997f30..7f33b8d12e3 100644 --- a/packages/maker-core/src/types/events.ts +++ b/packages/maker-core/src/types/events.ts @@ -86,7 +86,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. */ @@ -160,7 +160,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/agentCapabilities.ts b/packages/maker-shared/src/agentCapabilities.ts index 619856a2a0f..dbcbd557efb 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 { @@ -350,8 +350,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 ea4f4a0ed2b..f0c2a130c65 100644 --- a/packages/maker-shared/src/deviceLinkContract.ts +++ b/packages/maker-shared/src/deviceLinkContract.ts @@ -246,7 +246,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; @@ -256,7 +256,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 d9adf99152e..0d9c9efd35b 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, key: string): boolean } function defaultModelFor(agentKind: RemoteScheduleAgentKind): string { + if (agentKind === 'cursor') return 'cursor-default'; if (agentKind === 'codex') return DEFAULT_CODEX_MODEL; // Pi 模型来自动态 BYOM 供应商目录,没有固定默认 id;留空 → 序列化时省略 → host 解析 // 该 Pi agent 的当前默认模型(用户仍可在自由文本模型框里显式指定)。 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 596da66877b..5c208c3e7a6 100644 --- a/packages/maker-shared/src/sessionList.ts +++ b/packages/maker-shared/src/sessionList.ts @@ -983,7 +983,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 3fd144f701f..60ee982630c 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__/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/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..02884596ab3 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; } diff --git a/packages/model-providers/src/types.ts b/packages/model-providers/src/types.ts index 66337c3357f..b7dea763fe5 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 6fe525cfe97..0683eacc0af 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/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 }); +} From efee0eb680a399bfed7df99e4a8095b83933105e Mon Sep 17 00:00:00 2001 From: MagicLizi Date: Thu, 8 Oct 2026 20:15:56 +0800 Subject: [PATCH 006/416] feat(provider-share): own remote providers in settings; share link entry top right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Providers, following hands-on feedback: - "Enter Share Link…" moves to the top right of the page and opens the link dialog; the paste row and empty-state note at the end of the shared group are gone. - "Providers shared with me" stays its own group and is hidden when nothing is shared. - Providers usable on my other computers (online, remote control on, "allow remote use" on, connected) are listed after my own providers with the remote badge and the computer name; selecting one shows a read-only detail with the models it offers. The share detail and this detail share one read-only model list. - Model picker: when the sharer's Cindy is too old to answer the agent capability read, say so ("ask them to update Cindy") instead of a generic load failure, which left invitees with no models and no reason. useControllableDevices now tolerates a window without the device-link bridge instead of throwing. The join page's fallback hint (server) still names the old "Paste Share Link" entry and needs the new name. Signed-off-by: MagicLizi --- .../components/new-chat/ModelSelector.tsx | 32 ++- .../settings/OwnRemoteProviders.tsx | 209 ++++++++++++++++++ .../components/settings/ProvidersSection.tsx | 75 +++++-- .../settings/ReadOnlyProviderModels.tsx | 84 +++++++ .../__tests__/OwnRemoteProviders.test.tsx | 112 ++++++++++ .../ProviderSharePasteDialog.tsx | 35 ++- .../ProviderShareReceivedRail.tsx | 186 +++++----------- .../ProviderShareReceivedRail.test.tsx | 22 +- .../renderer/hooks/useControllableDevices.ts | 9 +- .../src/renderer/i18n/locales/en/common.json | 15 +- .../src/renderer/i18n/locales/ja/common.json | 15 +- .../src/renderer/i18n/locales/ko/common.json | 15 +- .../renderer/i18n/locales/zh-CN/common.json | 15 +- .../renderer/i18n/locales/zh-TW/common.json | 15 +- docs/design-rules/design-inventory.md | 2 +- docs/product-rules/provider-sharing.md | 13 +- docs/provider-sharing-contract.md | 2 +- docs/provider-sharing-implementation-plan.md | 6 +- 18 files changed, 679 insertions(+), 183 deletions(-) create mode 100644 apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx create mode 100644 apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx create mode 100644 apps/desktop/src/renderer/components/settings/__tests__/OwnRemoteProviders.test.tsx diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 009e81a6886..e8df1f2c18b 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -106,11 +106,13 @@ import { } from '@/lib/providerModels'; import type { Effort } from '@/lib/userPreferences.types'; import type { SessionRuntimeProfileProjection } from '@/lib/ccAgent.types'; +import { isProviderShareAgentDeviceId } from '../../../shared/providerShare'; import { CHATGPT_MODEL_PREFIX, XAI_MODEL_PREFIX, isSubscriptionDirectModel, } from '../../../shared/subscriptionModels'; +import { extractIpcError } from '@/utils/ipcError'; import { isModelEnabled, useModelVisibilityVersion } from '@/state/modelVisibilityPrefs'; import { seedDefaultFavorite } from '@/state/modelFavorites'; import { useProviderModelMemoryVersion } from '@/state/providerModelMemory'; @@ -559,10 +561,13 @@ function RemoteModelLoadNotice({ status, onRetry, compact = false, + message, }: { status: 'loading' | 'error'; onRetry: () => void; compact?: boolean; + /** 失败时代替笼统的「读取失败」,说明具体原因(例如分享者需要更新 Cindy)。 */ + message?: string; }) { const { t } = useTranslation(); if (status === 'loading') { @@ -591,7 +596,7 @@ function RemoteModelLoadNotice({

- {t('newChat.modelSelector.remoteLoadFailed')} + {message ?? t('newChat.modelSelector.remoteLoadFailed')}

+ + ); + })} +
+ ); +} + +/** 右栏:自己另一台电脑上的一个供应商,只读。 */ +export function OwnRemoteProviderDetail({ entry }: { entry: OwnRemoteProvider }) { + const { t } = useTranslation(); + const name = providerDisplayName(entry.provider, t); + const models = useMemo( + () => readOnlyProviderModels(entry.provider, entry.modelVisibilityOverrides), + [entry.provider, entry.modelVisibilityOverrides], + ); + return ( +
+
+
+
+
+ +
+
+ + {name} + + + {entry.deviceName} + +
+
+ + +
+
+ +
+

+ {t('settings.providers.remote.detailNote', { device: entry.deviceName })} +

+
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 284995a962a..caed330f64b 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -88,6 +88,11 @@ import { QuotaHoverCard } from '../status/QuotaHoverCard'; import { ProviderConnectionDialog } from './ProviderConnectionDialog'; import { AddProviderWizard, type WizardEntry } from './AddProviderWizard'; import { OllamaProviderDetail } from './OllamaProviderDetail'; +import { + OwnRemoteProviderDetail, + OwnRemoteProviderRows, + useOwnRemoteProviders, +} from './OwnRemoteProviders'; import { LlamaCppProviderDetail } from './LlamaCppProviderDetail'; import { MANAGED_LLAMACPP_PROVIDER_ID } from '../../../shared/llamaCpp'; import { @@ -106,6 +111,7 @@ import { hasProviderLogo, ProviderLogoMark } from '@/components/icons/ProviderLo import { SortableList } from '@/components/sidebar/SortableList'; import { ProviderShareEntryButton } from '@/features/provider-share/ProviderShareEntryButton'; import { ProviderShareManagePage } from '@/features/provider-share/ProviderShareManagePage'; +import { ProviderSharePasteButton } from '@/features/provider-share/ProviderSharePasteDialog'; import { ProviderShareReceivedDetail, ProviderShareReceivedRailGroup, @@ -2409,17 +2415,33 @@ export function ProvidersSection() { const openaiReconnectRequired = codexAuth.state.kind === 'reconnect-required'; const [selectedId, setSelectedId] = useState(null); - // 左栏「分享给我的」里选中的一条;选中自己的供应商或登录引导时清空。 + // 左栏里选中的另一台电脑上的供应商:「分享给我的」一条,或自己其他电脑上的一个(两者互斥); + // 选中本机供应商或登录引导时清空。 const [selectedShareId, setSelectedShareId] = useState(null); + const [selectedRemoteKey, setSelectedRemoteKey] = useState(null); const { received: receivedShares } = useProviderShareReceived(); const selectedShare = selectedShareId ? receivedShares.find((share) => share.shareId === selectedShareId) ?? null : null; + const ownRemoteProviders = useOwnRemoteProviders(); + const selectedRemote = selectedRemoteKey + ? ownRemoteProviders.find((entry) => entry.key === selectedRemoteKey) ?? null + : null; + const otherDeviceSelected = selectedShare !== null || selectedRemote !== null; // 用户或深链、向导完成、导入完成等「打开某个供应商」时,右栏回到那个供应商(即使它就是当前选中项)。 const selectProvider = useCallback((providerId: string) => { setSelectedShareId(null); + setSelectedRemoteKey(null); setSelectedId(providerId); }, []); + const selectShare = useCallback((shareId: string) => { + setSelectedRemoteKey(null); + setSelectedShareId(shareId); + }, []); + const selectRemote = useCallback((key: string) => { + setSelectedShareId(null); + setSelectedRemoteKey(key); + }, []); const [pendingProviderOrder, setPendingProviderOrder] = useState<{ dataOwnerId: string | null; ids: string[]; @@ -3011,16 +3033,20 @@ export function ProvidersSection() { return (
{shareManagePage ?? (<> -
-

- {t('settings.providers.title')} -

-

- {t('settings.providers.subtitle')} -

+
+
+

+ {t('settings.providers.title')} +

+

+ {t('settings.providers.subtitle')} +

+
+ {/* 收到别人的供应商分享链接时从这里输入(加入网页唤起 Cindy 失败时也指引到这里)。 */} +
{/* 先取数据再渲染卡片(规则 7:首帧即终态高度,不出现连接态翻转的跳变帧)。 @@ -3046,7 +3072,7 @@ export function ProvidersSection() {
{showCindySignin && ( { selectProvider(CINDY_SIGNIN_ID); }} @@ -3066,7 +3092,7 @@ export function ProvidersSection() { renderItem={(provider, index) => ( {orderAnnouncement} - {/* 分享给我的供应商(受邀者):与自己的供应商同列,单独成组。 */} + {/* 自己其他电脑上能用的供应商:接在本机供应商后面,图标带远程角标。 */} + { + setFocusedModel(null); + selectRemote(key); + }} + /> + {/* 分享给我的供应商(受邀者):单独成组,没有分享时不显示。 */} { setFocusedModel(null); - setSelectedShareId(shareId); + selectShare(shareId); }} /> {suggestions.length > 0 && ( @@ -3139,11 +3174,19 @@ export function ProvidersSection() { {/* 右栏身份固定;说明、资产和模型共用 DetailHeader 的滚动区。 */}
{selectedShare ? ( + ) : selectedRemote ? ( + ) : cindySigninActive ? ( /* 登录引导是 brand-scale surface(DESIGN §3):48px 标识 → 24px 名字 → 一行价值主张 → 赠送余额徽标 → 黑 CTA,间距走 8px 系统。底部留白比 diff --git a/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx b/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx new file mode 100644 index 00000000000..fe8bc9ff870 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx @@ -0,0 +1,84 @@ +/** + * 另一台电脑上的供应商(自己其他电脑的远程供应商、别人分享给我的供应商)在设置右栏里的 + * 只读模型清单:开关与排序都由那台电脑决定,这里只列出它开放的模型。 + */ +import { useTranslation } from 'react-i18next'; + +import type { AgentKind, ProviderView } from '@cindy/model-providers'; + +import { isDeviceModelVisible } from '@/lib/providerModels'; + +export interface ReadOnlyProviderModel { + id: string; + name: string; +} + +const AGENT_ORDER: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; + +/** 那台电脑上这个供应商开放的模型:去掉停用与隐藏的,按 Agent 顺序去重。 */ +export function readOnlyProviderModels( + provider: ProviderView | null, + overrides: Record | undefined, +): ReadOnlyProviderModel[] { + if (!provider) return []; + const seen = new Set(); + const rows: ReadOnlyProviderModel[] = []; + for (const agent of AGENT_ORDER) { + for (const model of provider.models[agent] ?? []) { + if (model.disabled || seen.has(model.id)) continue; + if (!isDeviceModelVisible(overrides, agent, provider.id, model)) continue; + seen.add(model.id); + rows.push({ id: model.id, name: model.name || model.id }); + } + } + return rows; +} + +/** 「可用的模型」一节;note 非空时以一句说明代替清单(读取中、读不到、暂停等)。 */ +export function ReadOnlyProviderModelSection({ + title, + models, + note, + testId, +}: { + title: string; + models: readonly ReadOnlyProviderModel[]; + note: string | null; + testId?: string; +}) { + const { t } = useTranslation(); + return ( +
+
+ + {title} + + {note === null && ( + + {t('settings.providers.models.modelCount', { count: models.length })} + + )} +
+ {note !== null ? ( +

+ {note} +

+ ) : ( +
    + {models.map((model) => ( +
  • + {model.name} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/__tests__/OwnRemoteProviders.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/OwnRemoteProviders.test.tsx new file mode 100644 index 00000000000..f8e9f00bb61 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/__tests__/OwnRemoteProviders.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + OwnRemoteProviderDetail, + OwnRemoteProviderRows, + ownRemoteProviderKey, + useOwnRemoteProviders, + type OwnRemoteProvider, +} from '../OwnRemoteProviders'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && Object.keys(options).length > 0 ? `${key}:${JSON.stringify(options)}` : key, + i18n: { language: 'en' }, + }), +})); +const devices = vi.hoisted(() => ({ value: [] as Array<{ deviceId: string; name: string; platform: string | null }> })); +vi.mock('@/hooks/useControllableDevices', () => ({ useControllableDevices: () => devices.value })); +const catalogs = vi.hoisted(() => ({ value: new Map() })); +const requestedIds = vi.hoisted(() => [] as string[][]); +vi.mock('@/hooks/useDevicesProviders', () => ({ + useDevicesProviders: (ids: readonly string[]) => { + requestedIds.push([...ids]); + return catalogs.value; + }, +})); + +function provider(id: string, overrides: Record = {}) { + return { + id, + name: id === 'anthropic' ? 'Anthropic' : 'OpenAI', + agents: ['claude-code'], + connected: true, + remoteInvocationEnabled: true, + routing: {}, + models: { 'claude-code': [{ id: 'opus', name: 'Opus 5.5' }, { id: 'hidden', name: 'Hidden' }] }, + ...overrides, + }; +} + +beforeEach(() => { + devices.value = []; + catalogs.value = new Map(); + requestedIds.length = 0; +}); + +afterEach(() => cleanup()); + +describe('useOwnRemoteProviders', () => { + it('lists the connected providers my online computers open for remote use', () => { + devices.value = [ + { deviceId: 'mini-a', name: "Magi's Mac Mini", platform: 'darwin' }, + { deviceId: 'mini-b', name: 'Studio', platform: 'darwin' }, + ]; + catalogs.value = new Map([ + ['mini-a', { + providers: [ + provider('anthropic'), + provider('openai', { remoteInvocationEnabled: false }), + provider('xai', { connected: false }), + provider('xd', { suspended: true }), + ], + modelVisibilityOverrides: { 'claude-code:anthropic:hidden': false }, + loading: false, + error: null, + }], + ['mini-b', { providers: [], loading: true, error: null }], + ]); + const { result } = renderHook(() => useOwnRemoteProviders()); + expect(requestedIds.at(-1)).toEqual(['mini-a', 'mini-b']); + expect(result.current.map((entry) => [entry.deviceName, entry.provider.id])).toEqual([ + ["Magi's Mac Mini", 'anthropic'], + ]); + expect(result.current[0].key).toBe(ownRemoteProviderKey('mini-a', 'anthropic')); + }); +}); + +describe('OwnRemoteProviderRows / OwnRemoteProviderDetail', () => { + const entry: OwnRemoteProvider = { + key: ownRemoteProviderKey('mini-a', 'anthropic'), + deviceId: 'mini-a', + deviceName: "Magi's Mac Mini", + provider: provider('anthropic') as unknown as OwnRemoteProvider['provider'], + modelVisibilityOverrides: { 'claude-code:anthropic:hidden': false }, + }; + + it('renders nothing without remote providers', () => { + const { container } = render( undefined} />); + expect(container.innerHTML).toBe(''); + }); + + it('marks the row as remote, names the computer and selects it', () => { + const onSelect = vi.fn(); + render(); + const row = screen.getByTestId('own-remote-provider-row'); + expect(row.textContent).toContain('Anthropic'); + expect(row.textContent).toContain("Magi's Mac Mini"); + expect(row.querySelector('[data-remote-source-mark]')).not.toBeNull(); + fireEvent.click(row); + expect(onSelect).toHaveBeenCalledWith(entry.key); + }); + + it('shows the models that computer leaves visible, read-only', () => { + render(); + const list = screen.getByTestId('own-remote-provider-models'); + expect(Array.from(list.querySelectorAll('li')).map((item) => item.textContent)).toEqual(['Opus 5.5']); + expect(screen.queryByRole('switch')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/features/provider-share/ProviderSharePasteDialog.tsx b/apps/desktop/src/renderer/features/provider-share/ProviderSharePasteDialog.tsx index 5c6133ff530..9a359b89291 100644 --- a/apps/desktop/src/renderer/features/provider-share/ProviderSharePasteDialog.tsx +++ b/apps/desktop/src/renderer/features/provider-share/ProviderSharePasteDialog.tsx @@ -1,6 +1,6 @@ /** - * 粘贴分享链接:网页唤起 Cindy 失败时,加入页会让用户把链接粘贴到「设置 → 模型供应商」里, - * 交给同一个申请弹窗。 + * 输入分享链接:「设置 → 模型供应商」右上角的入口。网页唤起 Cindy 失败时,加入页会让用户 + * 到这里粘贴链接,交给同一个申请弹窗。 */ import { useRef, useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,36 @@ import { ProviderShareDialogTitle, } from './ProviderShareDialog'; -/** 粘贴分享链接:交给全局申请弹窗读取(链接带前后文字也可以,main 会从中找出链接)。 */ +/** 设置页右上角的「输入分享链接…」:点开输入弹窗,关闭后焦点回到按钮。 */ +export function ProviderSharePasteButton() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + return ( + <> + + {open && ( + { + setOpen(false); + buttonRef.current?.focus(); + }} + /> + )} + + ); +} + +/** 输入分享链接:交给全局申请弹窗读取(链接带前后文字也可以,main 会从中找出链接)。 */ export function ProviderSharePasteDialog({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const [value, setValue] = useState(''); diff --git a/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx b/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx index 1a0daf42269..2f3c718ab16 100644 --- a/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx +++ b/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx @@ -1,29 +1,30 @@ /** * 设置 → 模型供应商里「分享给我的」(受邀者)。别人分享给我的供应商与自己的供应商同在左栏, - * 单独成组、用分享者头像标明来自谁;点开后右栏显示状态、可用的模型与「退出」。 - * 组末是「粘贴分享链接」——分享链接的加入网页在唤起 Cindy 失败时会指引用户到这里粘贴。 + * 单独成组(没有分享时整组不显示)、用分享者头像标明来自谁;点开后右栏显示状态、可用的模型 + * 与「退出」。输入分享链接的入口在页面右上角(ProviderSharePasteButton)。 * * 模型清单读分享者电脑上分享给我的那份目录(`share:`),只读:开关与排序由分享者决定。 */ -import { Link2 } from 'lucide-react'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ProviderShareReceived } from '@cindy/device-link'; -import type { AgentKind } from '@cindy/model-providers'; import { hasProviderLogo, ProviderLogoMark } from '@/components/icons/ProviderLogoMark'; +import { + ReadOnlyProviderModelSection, + readOnlyProviderModels, +} from '@/components/settings/ReadOnlyProviderModels'; import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; import { Tip } from '@/components/ui/tooltip'; import { useDeviceProviders } from '@/hooks/useDeviceProviders'; -import { isDeviceModelVisible, providerMonogram } from '@/lib/providerModels'; +import { providerMonogram } from '@/lib/providerModels'; import { toast } from '@/lib/toast'; import { cn } from '@/lib/utils'; import { mapIpcErrorToI18nKey } from '@/utils/ipcError'; import { providerShareAgentDeviceId } from './providerShareFormat'; -import { ProviderSharePasteDialog } from './ProviderSharePasteDialog'; import { useProviderShareReceived } from './providerShareStore'; import { ShareAvatar } from './ShareAvatar'; @@ -54,7 +55,7 @@ function StatusDot({ status }: { status: ReceivedStatus }) { ); } -/** 左栏的「分享给我的供应商」一组;没有分享时只留标题、一句说明与粘贴入口。 */ +/** 左栏的「分享给我的供应商」一组;没有分享时整组不显示。 */ export function ProviderShareReceivedRailGroup({ selectedShareId, onSelect, @@ -64,8 +65,7 @@ export function ProviderShareReceivedRailGroup({ }) { const { t } = useTranslation(); const { received } = useProviderShareReceived(); - const [pasteOpen, setPasteOpen] = useState(false); - const pasteButtonRef = useRef(null); + if (received.length === 0) return null; return (
@@ -75,81 +75,53 @@ export function ProviderShareReceivedRailGroup({ > {t('providerShare.received.title')} - {received.length === 0 ? ( -

- {t('providerShare.received.empty')} -

- ) : ( - received.map((share) => { - const status = receivedStatus(share); - const owner = t('providerShare.received.fromOwner', { name: share.owner.displayName }); - const selected = selectedShareId === share.shareId; - return ( - { + const status = receivedStatus(share); + const owner = t('providerShare.received.fromOwner', { name: share.owner.displayName }); + const selected = selectedShareId === share.shareId; + return ( + + - - ); - }) - )} - - {pasteOpen && ( - { - setPasteOpen(false); - pasteButtonRef.current?.focus(); - }} - /> - )} + + {owner} + + + + + + ); + })}
); } -const AGENT_ORDER: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; - /** 右栏:一条分享给我的供应商。 */ export function ProviderShareReceivedDetail({ share }: { share: ProviderShareReceived }) { const { t } = useTranslation(); @@ -159,20 +131,10 @@ export function ProviderShareReceivedDetail({ share }: { share: ProviderShareRec const catalog = useDeviceProviders(status === 'active' ? providerShareAgentDeviceId(share.shareId) : undefined); const provider = catalog.providers.find((entry) => entry.id === share.providerId) ?? catalog.providers[0] ?? null; - const models = useMemo(() => { - if (!provider) return []; - const seen = new Set(); - const rows: { id: string; name: string }[] = []; - for (const agent of AGENT_ORDER) { - for (const model of provider.models[agent] ?? []) { - if (model.disabled || seen.has(model.id)) continue; - if (!isDeviceModelVisible(catalog.modelVisibilityOverrides, agent, provider.id, model)) continue; - seen.add(model.id); - rows.push({ id: model.id, name: model.name || model.id }); - } - } - return rows; - }, [provider, catalog.modelVisibilityOverrides]); + const models = useMemo( + () => readOnlyProviderModels(provider, catalog.modelVisibilityOverrides), + [provider, catalog.modelVisibilityOverrides], + ); const leave = async () => { const ok = await confirm({ @@ -273,38 +235,12 @@ export function ProviderShareReceivedDetail({ share }: { share: ProviderShareRec

-
-
- - {t('providerShare.received.modelsTitle')} - - {body === null && ( - - {t('settings.providers.models.modelCount', { count: models.length })} - - )} -
- {body !== null ? ( -

- {body} -

- ) : ( -
    - {models.map((model) => ( -
  • - {model.name} -
  • - ))} -
- )} -
+
); } diff --git a/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx b/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx index cd646f49713..4e8774b7af2 100644 --- a/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx +++ b/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ProviderShareReceived } from '@cindy/device-link'; +import { ProviderSharePasteButton } from '../ProviderSharePasteDialog'; import { ProviderShareReceivedDetail, ProviderShareReceivedRailGroup } from '../ProviderShareReceivedRail'; import { resetProviderShareStoreForTests } from '../providerShareStore'; @@ -67,13 +68,10 @@ beforeEach(() => { afterEach(() => cleanup()); -describe('ProviderShareReceivedRailGroup', () => { - it('stays visible when empty and hands a pasted link to the apply dialog', async () => { - render( undefined} />); - expect(screen.getByText('providerShare.received.title')).toBeTruthy(); - expect(screen.getByText('providerShare.received.empty')).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: 'providerShare.received.paste' })); +describe('ProviderSharePasteButton', () => { + it('hands an entered link to the apply dialog', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'providerShare.received.enterLink' })); const dialog = await screen.findByTestId('provider-share-paste-dialog'); fireEvent.click(screen.getByRole('button', { name: 'providerShare.received.pasteDialog.open' })); expect(joinSpy).not.toHaveBeenCalled(); @@ -86,12 +84,22 @@ describe('ProviderShareReceivedRailGroup', () => { expect(joinSpy).toHaveBeenCalledWith('see https://x.test/provider-share/join#abc'); await waitFor(() => expect(screen.queryByTestId('provider-share-paste-dialog')).toBeNull()); }); +}); + +describe('ProviderShareReceivedRailGroup', () => { + it('is hidden while nothing is shared with me', async () => { + render( undefined} />); + await waitFor(() => expect(command).toHaveBeenCalledWith({ action: 'received' })); + expect(screen.queryByTestId('provider-share-received')).toBeNull(); + expect(screen.queryByText('providerShare.received.title')).toBeNull(); + }); it('lists received shares next to the providers and selects one', async () => { received = [share]; const onSelect = vi.fn(); render(); const row = await screen.findByTestId('provider-share-received-row'); + expect(screen.getByText('providerShare.received.title')).toBeTruthy(); expect(row.textContent).toContain('Cindy AI'); expect(row.getAttribute('aria-label')).toContain('providerShare.received.statusPaused'); fireEvent.click(row); diff --git a/apps/desktop/src/renderer/hooks/useControllableDevices.ts b/apps/desktop/src/renderer/hooks/useControllableDevices.ts index 9455fb58119..facc3eb4f64 100644 --- a/apps/desktop/src/renderer/hooks/useControllableDevices.ts +++ b/apps/desktop/src/renderer/hooks/useControllableDevices.ts @@ -180,10 +180,13 @@ export function useControllableDevices(): ControllableDevice[] { const [devices, setDevices] = useState([]); useEffect(() => { + // 没有设备互联接口(测试、未挂 preload 的窗口)时当作没有可控设备。 + const deviceLink = (window as Partial).electronAPI?.deviceLink; + if (!deviceLink) return; let cancelled = false; const refresh = async () => { try { - const { devices: list } = await window.electronAPI.deviceLink.listDevices(); + const { devices: list } = await deviceLink.listDevices(); if (cancelled) return; const next = toControllableDevices(list); // 内容无变化则保持旧引用,避免无谓重渲染。 @@ -194,10 +197,10 @@ export function useControllableDevices(): ControllableDevice[] { } }; void refresh(); - const off = window.electronAPI.deviceLink.onPresenceChanged(() => { + const off = deviceLink.onPresenceChanged(() => { void refresh(); }); - const offControlTarget = window.electronAPI.deviceLink.onControlTargetChanged(() => { + const offControlTarget = deviceLink.onControlTargetChanged(() => { void refresh(); }); return () => { diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index e1ee0a16162..74250180439 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -351,8 +351,7 @@ }, "received": { "title": "Providers Shared with Me", - "empty": "No one has shared a provider with you yet. When you get a share link, open it or paste it here.", - "paste": "Paste Share Link", + "enterLink": "Enter Share Link…", "fromOwner": "Shared by {{name}}", "providerOnDevice": "{{provider}} · {{device}}", "statusActive": "Active", @@ -368,7 +367,7 @@ }, "left": "Left {{provider}} shared by {{name}}", "pasteDialog": { - "title": "Paste Share Link", + "title": "Enter Share Link", "description": "Paste the provider share link you received. You’ll see the details before deciding whether to send a request.", "label": "Share link", "placeholder": "Paste the link here", @@ -387,7 +386,8 @@ }, "picker": { "deviceName": "{{device}} · Shared by {{owner}}", - "unavailable": "Unavailable share" + "unavailable": "Unavailable share", + "hostOutdated": "The Cindy version on the owner’s computer is too old to list these models. Ask them to update Cindy, then try again." }, "notification": { "requestTitle": "New Share Request", @@ -2714,6 +2714,13 @@ "connectionRequired": "Connect to use these models. Your selections are saved." } }, + "remote": { + "rowLabel": "{{provider}} · {{device}}", + "statusReady": "Available", + "detailNote": "The agent runs on {{device}}, using that computer’s sign-in and quota. To use it, choose it in the model list when you start a task. To change this provider, go to Settings → Providers on that computer.", + "modelsTitle": "Available Models", + "modelsEmpty": "This provider has no models available on that computer yet." + }, "genericOAuth": { "subtitle": "Subscription account", "reopenLoginPage": "Reopen sign-in page", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index bb1784daa38..a1393fbdc2b 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -349,8 +349,7 @@ }, "received": { "title": "共有されたプロバイダー", - "empty": "まだ共有されたプロバイダーはありません。共有リンクを受け取ったら、開くかここに貼り付けてください。", - "paste": "共有リンクを貼り付け", + "enterLink": "共有リンクを入力…", "fromOwner": "{{name}} からの共有", "providerOnDevice": "{{provider}} · {{device}}", "statusActive": "利用可能", @@ -366,7 +365,7 @@ }, "left": "{{name}} から共有された {{provider}} から退出しました", "pasteDialog": { - "title": "共有リンクを貼り付け", + "title": "共有リンクを入力", "description": "受け取ったプロバイダーの共有リンクを貼り付けてください。内容を確認してから申請するかどうか決められます。", "label": "共有リンク", "placeholder": "ここにリンクを貼り付け", @@ -385,7 +384,8 @@ }, "picker": { "deviceName": "{{device}} · {{owner}} からの共有", - "unavailable": "利用できなくなった共有" + "unavailable": "利用できなくなった共有", + "hostOutdated": "共有した人のパソコンの Cindy が古いため、これらのモデルを読み込めません。相手に Cindy を更新してもらってから、もう一度お試しください。" }, "notification": { "requestTitle": "新しい共有の申請", @@ -2711,6 +2711,13 @@ "connectionRequired": "接続すると利用できます。モデルの選択は保持されます。" } }, + "remote": { + "rowLabel": "{{provider}} · {{device}}", + "statusReady": "利用可能", + "detailNote": "Agent は {{device}} で動き、そのパソコンのログインとクォータを使います。タスクを新規作成するときにモデル一覧で選ぶと使えます。このプロバイダーを変更するには、そのパソコンの「設定 → モデルプロバイダー」を開いてください。", + "modelsTitle": "利用できるモデル", + "modelsEmpty": "このプロバイダーには、そのパソコンで利用できるモデルがまだありません。" + }, "genericOAuth": { "subtitle": "サブスクリプションアカウント", "reopenLoginPage": "ログインページを再度開く", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 8b7db5230a6..f4c55c330f2 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -349,8 +349,7 @@ }, "received": { "title": "나에게 공유된 제공자", - "empty": "아직 공유받은 제공자가 없습니다. 공유 링크를 받으면 열거나 여기에 붙여넣으세요.", - "paste": "공유 링크 붙여넣기", + "enterLink": "공유 링크 입력…", "fromOwner": "{{name}}님의 공유", "providerOnDevice": "{{provider}} · {{device}}", "statusActive": "정상", @@ -366,7 +365,7 @@ }, "left": "{{name}}님이 공유한 {{provider}}에서 나갔습니다", "pasteDialog": { - "title": "공유 링크 붙여넣기", + "title": "공유 링크 입력", "description": "받은 제공자 공유 링크를 붙여넣으세요. 내용을 확인한 뒤 요청할지 결정할 수 있습니다.", "label": "공유 링크", "placeholder": "여기에 링크 붙여넣기", @@ -385,7 +384,8 @@ }, "picker": { "deviceName": "{{device}} · {{owner}}님의 공유", - "unavailable": "사용할 수 없는 공유" + "unavailable": "사용할 수 없는 공유", + "hostOutdated": "공유한 사람의 컴퓨터에 있는 Cindy 버전이 오래되어 이 모델을 불러올 수 없습니다. 상대방에게 Cindy를 업데이트해 달라고 한 뒤 다시 시도하세요." }, "notification": { "requestTitle": "새 공유 요청", @@ -2711,6 +2711,13 @@ "connectionRequired": "연결하면 사용할 수 있습니다. 모델 선택은 유지됩니다." } }, + "remote": { + "rowLabel": "{{provider}} · {{device}}", + "statusReady": "사용 가능", + "detailNote": "Agent는 {{device}}에서 실행되며 그 컴퓨터의 로그인과 할당량을 씁니다. 새 작업을 만들 때 모델 목록에서 선택하면 사용할 수 있습니다. 이 제공자를 바꾸려면 그 컴퓨터의 「설정 → 모델 제공자」로 가세요.", + "modelsTitle": "사용 가능한 모델", + "modelsEmpty": "이 제공자는 그 컴퓨터에서 아직 사용 가능한 모델을 공개하지 않았습니다." + }, "genericOAuth": { "subtitle": "구독 계정", "reopenLoginPage": "로그인 페이지 다시 열기", 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 ece765b5b08..9d7e683911e 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -349,8 +349,7 @@ }, "received": { "title": "分享给我的供应商", - "empty": "还没有人分享供应商给你。收到分享链接后,点开它或粘贴到这里。", - "paste": "粘贴分享链接", + "enterLink": "输入分享链接…", "fromOwner": "来自 {{name}} 的分享", "providerOnDevice": "{{provider}} · {{device}}", "statusActive": "正常", @@ -366,7 +365,7 @@ }, "left": "已退出 {{name}} 分享的 {{provider}}", "pasteDialog": { - "title": "粘贴分享链接", + "title": "输入分享链接", "description": "把对方发给你的供应商分享链接粘贴到这里,看过说明后再决定是否申请。", "label": "分享链接", "placeholder": "在这里粘贴链接", @@ -385,7 +384,8 @@ }, "picker": { "deviceName": "{{device}} · 来自 {{owner}} 的分享", - "unavailable": "已不可用的分享" + "unavailable": "已不可用的分享", + "hostOutdated": "分享者电脑上的 Cindy 版本太旧,读不到这些模型。请让对方更新 Cindy 后再试。" }, "notification": { "requestTitle": "新的分享申请", @@ -2711,6 +2711,13 @@ "connectionRequired": "连接后可用,已选模型会保留。" } }, + "remote": { + "rowLabel": "{{provider}} · {{device}}", + "statusReady": "可用", + "detailNote": "Agent 在 {{device}} 上运行,用的是那台电脑的登录和配额。新建任务时在模型列表里选择它就能用;要修改这个供应商,请到那台电脑的「设置 → 模型供应商」。", + "modelsTitle": "可用的模型", + "modelsEmpty": "这个供应商在那台电脑上还没有开放可用的模型。" + }, "genericOAuth": { "subtitle": "订阅账号", "reopenLoginPage": "重新打开登录页", 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 26cc978a963..311eaa75c4b 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -349,8 +349,7 @@ }, "received": { "title": "分享給我的供應商", - "empty": "還沒有人分享供應商給你。收到分享連結後,點開它或貼到這裡。", - "paste": "貼上分享連結", + "enterLink": "輸入分享連結…", "fromOwner": "來自 {{name}} 的分享", "providerOnDevice": "{{provider}} · {{device}}", "statusActive": "正常", @@ -366,7 +365,7 @@ }, "left": "已退出 {{name}} 分享的 {{provider}}", "pasteDialog": { - "title": "貼上分享連結", + "title": "輸入分享連結", "description": "把對方傳給你的供應商分享連結貼到這裡,看過說明後再決定是否申請。", "label": "分享連結", "placeholder": "在這裡貼上連結", @@ -385,7 +384,8 @@ }, "picker": { "deviceName": "{{device}} · 來自 {{owner}} 的分享", - "unavailable": "已無法使用的分享" + "unavailable": "已無法使用的分享", + "hostOutdated": "分享者電腦上的 Cindy 版本太舊,讀不到這些模型。請讓對方更新 Cindy 後再試。" }, "notification": { "requestTitle": "新的分享申請", @@ -2711,6 +2711,13 @@ "connectionRequired": "連線後可用,已選模型會保留。" } }, + "remote": { + "rowLabel": "{{provider}} · {{device}}", + "statusReady": "可用", + "detailNote": "Agent 在 {{device}} 上執行,使用的是那台電腦的登入和配額。新建任務時在模型列表裡選擇它就能用;要修改這個供應商,請到那台電腦的「設定 → 模型供應商」。", + "modelsTitle": "可用的模型", + "modelsEmpty": "這個供應商在那台電腦上還沒有開放可用的模型。" + }, "genericOAuth": { "subtitle": "訂閱帳號", "reopenLoginPage": "重新開啟登入頁面", diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index 42f3641d3e4..1d83542f171 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/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/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 | 536 | +| `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 | 542 | | `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/product-rules/provider-sharing.md b/docs/product-rules/provider-sharing.md index 078d0cc25a8..f3190278ed6 100644 --- a/docs/product-rules/provider-sharing.md +++ b/docs/product-rules/provider-sharing.md @@ -114,9 +114,16 @@ Desktop 与 Mobile 使用同一写法。 ### 5.2 供应商设置 -Desktop「设置 → 模型供应商」的左栏里,自己的供应商下方单独一组「分享给我的供应商」:每条用分享者 -头像标明来自谁,带状态点;点开在右栏看来源、状态、对方开放的模型(只读,开关由分享者决定),并可 -「退出」。组末是「粘贴分享链接」——网页唤起 Cindy 失败时,加入页指引用户到这里粘贴。 +Desktop「设置 → 模型供应商」的左栏(2026-10-08 用户裁决): + +- **自己其他电脑上能用的供应商**也列在左栏,接在本机供应商后面(不参与拖动排序):图标右上角带与模型 + 列表同一个远程角标,第二行写所在电脑;点开在右栏只读地看所在电脑、状态与开放的模型,修改要到那台 + 电脑上去。只列在线、允许远程控制的同账号电脑上已开「允许被远程调用」且已连接的供应商,与模型列表 + 能选的范围一致。 +- **分享给我的供应商**单独一组「分享给我的供应商」,没有分享时整组不显示:每条用分享者头像标明来自谁, + 带状态点;点开在右栏看来源、状态、对方开放的模型(只读,开关由分享者决定),并可「退出」。 +- **输入分享链接**:页面右上角一个「输入分享链接…」按钮,点开弹窗输入链接,之后进入同一个申请弹窗。 + 网页唤起 Cindy 失败时,加入页指引用户到这里输入。 ### 5.3 不可用时 diff --git a/docs/provider-sharing-contract.md b/docs/provider-sharing-contract.md index 57e5b120d2d..25db55e7e6d 100644 --- a/docs/provider-sharing-contract.md +++ b/docs/provider-sharing-contract.md @@ -123,7 +123,7 @@ interface ReceivedShare { 仿 `/shared-task/join`:口令在 `#` 之后(`#[?app=cindy|cindycn|cindydev]`),页面不访问数据库。 桌面浏览器一律唤起 `cindy://provider-share/join?invitation=&server=`(与共享任务一致:各区域与 dev 的桌面版 -都注册 `cindy://`;`app` 提示只给手机网页用)。唤起失败时提示把链接粘贴到 Cindy(设置 → 模型供应商 → 左栏「分享给我的供应商」→「粘贴分享链接」)。 +都注册 `cindy://`;`app` 提示只给手机网页用)。唤起失败时提示把链接粘贴到 Cindy(设置 → 模型供应商 → 右上角「输入分享链接…」;2026-10-08 起入口从左栏组末移到这里,加入页文案需同步)。 手机浏览器不唤起 App,显示「请在电脑上打开这个链接」并提供复制链接。五种语言、深浅色、严格 CSP。 ## 4. 身份名片(auth-server) diff --git a/docs/provider-sharing-implementation-plan.md b/docs/provider-sharing-implementation-plan.md index 62499660792..7409151cac7 100644 --- a/docs/provider-sharing-implementation-plan.md +++ b/docs/provider-sharing-implementation-plan.md @@ -55,7 +55,11 @@ 分享者电脑对受邀者放行这三项只读请求并按分享的供应商收窄(能力只留它的模型、可用 Agent 只留它支持的、就绪只回 `binaryReady`,不带分享者自己的登录状态)。此前只放行模型目录,受邀者点了分享来的模型也选不上。两边都要更新到含此修复的版本。 - **供应商设置**:「分享给我的供应商」从页面底部移进左栏,与自己的供应商同列、单独成组;右栏显示来源、状态、对方 - 开放的模型与「退出」;「粘贴分享链接」在组末。管理分享的入口图标改为分享图标。 + 开放的模型与「退出」。管理分享的入口图标改为分享图标。 +- **供应商设置(真机反馈二补)**:「输入分享链接…」移到页面右上角(点开弹窗),左栏组末的粘贴行与空态说明去掉; + 没有分享时「分享给我的供应商」整组不显示;自己其他电脑上能用的远程供应商也列进左栏(远程角标 + 所在电脑,只读详情)。 + 分享者电脑版本太旧、答不了 Agent 能力时,受邀者的模型列表直接提示「请让对方更新 Cindy」,不再只报读取失败。 + 加入页「唤起失败」的指引文案(服务端)需改成新入口名。 - **待产品决定**:受邀者能看到分享者电脑的设备名(macOS 默认是「<全名>的 MacBook」,可能含真实姓名)。 任务运行中途被拒绝、或轮询放弃时,个别错误仍是同账号文案(「到那台电脑上打开…」)。 - **P3 客户端**:凭证只在内存,不落盘;本机只记不含凭证的区域标记,没有标记的账号从不联系对方区域;对方区域请求 From 5f63c0eaaf5217a9cd6c75ad0fa260d9f0c5d123 Mon Sep 17 00:00:00 2001 From: MagicLizi Date: Thu, 8 Oct 2026 20:47:10 +0800 Subject: [PATCH 007/416] fix(provider-share): log why invitees can't read a share; say why in settings An invitee saw "couldn't read the models" for an active share with the owner online, and neither computer recorded why. - Owner: when a provider-share guest is refused (link open, admission or invoke), log the reason: host not running or on a stale account scope, snapshot not loaded, unknown share, member not active, remote control off, or the provider not open for remote use. At most one line per guest, place and reason a minute; the guest still only gets "not available". - Invitee: log each failed share read with its error code (once a minute per share, channel and code). - Settings: a refusal by the owner's computer gets its own explanation; other failures show the error code and a Reload Models action. Signed-off-by: MagicLizi --- .../__tests__/providerShareDispatch.test.ts | 17 +++++- .../__tests__/providerShareInvokeIpc.test.ts | 13 ++++- .../__tests__/providerShareRuntime.test.ts | 21 ++++++++ apps/desktop/src/main/device-link/dispatch.ts | 35 ++++++++++-- apps/desktop/src/main/device-link/ipc.ts | 30 ++++++++++- .../src/main/device-link/providerShareHost.ts | 54 ++++++++++++++----- .../main/device-link/providerShareRuntime.ts | 2 + .../settings/ReadOnlyProviderModels.tsx | 14 +++-- .../ProviderShareReceivedRail.tsx | 26 +++++++-- .../ProviderShareReceivedRail.test.tsx | 22 ++++++-- .../src/renderer/i18n/locales/en/common.json | 4 +- .../src/renderer/i18n/locales/ja/common.json | 4 +- .../src/renderer/i18n/locales/ko/common.json | 4 +- .../renderer/i18n/locales/zh-CN/common.json | 4 +- .../renderer/i18n/locales/zh-TW/common.json | 4 +- 15 files changed, 219 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/device-link/__tests__/providerShareDispatch.test.ts b/apps/desktop/src/main/device-link/__tests__/providerShareDispatch.test.ts index 20792009d44..d43bfbd2381 100644 --- a/apps/desktop/src/main/device-link/__tests__/providerShareDispatch.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/providerShareDispatch.test.ts @@ -13,8 +13,9 @@ vi.mock('electron', () => ({ powerSaveBlocker: { start: () => 0, stop: () => {}, isStarted: () => false }, nativeImage: { createFromPath: () => ({ isEmpty: () => true }) }, })); +const warn = vi.hoisted(() => vi.fn()); vi.mock('../../logger', () => ({ - createLogger: () => ({ info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createLogger: () => ({ info: vi.fn(), debug: vi.fn(), warn, error: vi.fn() }), })); vi.mock('../settings-store', async (importOriginal) => ({ ...(await importOriginal()), @@ -64,6 +65,20 @@ describe('provider share link admission', () => { expect(__testing.getActiveControllers()).toEqual([]); }); + it('logs why a guest is refused, at most once a minute per reason', async () => { + warn.mockClear(); + setProviderShareAccess({ + guestAccess: () => null, ensureKnown: async () => undefined, hasShares: () => true, + denial: () => 'provider-not-remote', + }); + await runInvoke(GUEST, { channel: 'maker:provider:list', args: [] }); + await runInvoke(GUEST, { channel: 'maker:provider:list', args: [] }); + const refusals = warn.mock.calls.filter(([line]) => String(line).startsWith('provider-share guest refused')); + expect(refusals).toHaveLength(1); + expect(String(refusals[0][0])).toContain('invoke maker:provider:list'); + expect(String(refusals[0][0])).toContain('provider-not-remote'); + }); + it('refuses guests without access or without the capability', () => { const client = fakeClient(); __testing.handleLinkOpen(client, GUEST, 'req-1', { capabilities: [PROVIDER_SHARE_RELAY_CAPABILITY] } as never, 0, true); diff --git a/apps/desktop/src/main/device-link/__tests__/providerShareInvokeIpc.test.ts b/apps/desktop/src/main/device-link/__tests__/providerShareInvokeIpc.test.ts index 20127d384fa..fbec273ceb3 100644 --- a/apps/desktop/src/main/device-link/__tests__/providerShareInvokeIpc.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/providerShareInvokeIpc.test.ts @@ -11,8 +11,9 @@ vi.mock('electron', () => ({ powerSaveBlocker: { start: () => 0, stop: () => {}, isStarted: () => false }, nativeImage: { createFromPath: () => ({ isEmpty: () => true }) }, })); +const warn = vi.hoisted(() => vi.fn()); vi.mock('../../logger', () => ({ - createLogger: () => ({ info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createLogger: () => ({ info: vi.fn(), debug: vi.fn(), warn, error: vi.fn() }), })); const noteFailure = vi.hoisted(() => vi.fn()); vi.mock('../providerShareGuest.js', async (importOriginal) => ({ @@ -53,6 +54,16 @@ describe('provider share renderer reads', () => { await expect(handleProviderShareInvoke(deps, 'share:s1', 'maker:agent:status', ['codex'])).resolves.toEqual({ binaryReady: true }); }); + it('logs why a read failed, once a minute per error', async () => { + warn.mockClear(); + invoke.mockResolvedValue({ ok: false, error: { code: 'DEVICE_OFFLINE', message: 'peer offline' } }); + for (let i = 0; i < 2; i++) { + await expect(handleProviderShareInvoke(deps, 'share:s9', 'maker:provider:list', [])).rejects.toThrow(); + } + const lines = warn.mock.calls.map(([line]) => String(line)).filter((line) => line.startsWith('provider share read failed')); + expect(lines).toEqual(['provider share read failed: maker:provider:list on share:s9: DEVICE_OFFLINE peer offline']); + }); + it('scrubs the owner identity from the provider list', async () => { invoke.mockResolvedValue({ ok: true, result: { providers: [{ id: 'openai', name: 'OpenAI · alice@corp.com', openAiAccount: { identity: 'alice@corp.com' } }] } }); const result = await handleProviderShareInvoke(deps, 'share:s1', 'maker:provider:list', []); diff --git a/apps/desktop/src/main/device-link/__tests__/providerShareRuntime.test.ts b/apps/desktop/src/main/device-link/__tests__/providerShareRuntime.test.ts index f1f7d907606..9ceda4250bc 100644 --- a/apps/desktop/src/main/device-link/__tests__/providerShareRuntime.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/providerShareRuntime.test.ts @@ -42,6 +42,7 @@ vi.mock('../providerShareApi.js', () => ({ import { applyLocalMemberChange, providerShareGuestAccess, + providerShareGuestDenial, providerShareMemberMatcher, refreshProviderShareHost, startProviderShareHost, @@ -104,6 +105,26 @@ describe('provider share host snapshot', () => { expect(providerShareGuestAccess(guest('m1'))).toBeNull(); }); + it('names the reason a guest is refused, for the diagnostic log', async () => { + expect(providerShareGuestDenial(guest('m1'))).toBe('host-not-running'); + state.owned = [share([member('m1'), member('m2', 'paused')])]; + startProviderShareHost(events()); + expect(providerShareGuestDenial(guest('m1'))).toBe('snapshot-not-loaded'); + await refreshProviderShareHost('test'); + expect(providerShareGuestDenial(guest('m1'))).toBeNull(); + expect(providerShareGuestDenial(guest('m2'))).toBe('member-not-active'); + expect(providerShareGuestDenial(providerShareGuestPeer('share-9', 'm1', 'laptop'))).toBe('share-unknown'); + expect(providerShareGuestDenial(guest('m1', 'host-device'))).toBe('self-device'); + state.allowed.clear(); + expect(providerShareGuestDenial(guest('m1'))).toBe('provider-not-remote'); + state.allowed.add('anthropic'); + state.remoteControl = false; + expect(providerShareGuestDenial(guest('m1'))).toBe('remote-control-off'); + state.remoteControl = true; + state.scope = 'cloud:other:2'; + expect(providerShareGuestDenial(guest('m1'))).toBe('host-scope-stale'); + }); + it('announces pending requests once and reconciles stale guests on the first snapshot', async () => { const request = { ...person, requestId: 'r1', pairingCode: '0427', createdAt: 'x', expiresAt: 'y' }; state.owned = [share([member('m1')], [request])]; diff --git a/apps/desktop/src/main/device-link/dispatch.ts b/apps/desktop/src/main/device-link/dispatch.ts index 867f8dff805..d09203735bd 100644 --- a/apps/desktop/src/main/device-link/dispatch.ts +++ b/apps/desktop/src/main/device-link/dispatch.ts @@ -111,7 +111,7 @@ import { redactMessageRowForSharedGuest, redactSharedGuestPush, } from './sharedTaskMessageOrigin'; -import { isProviderSharePeer, isSharedTaskPeer, PROVIDER_SHARE_RELAY_CAPABILITY, SHARED_TASK_CAPABILITY } from '@cindy/device-link'; +import { isProviderSharePeer, isSharedTaskPeer, parseProviderSharePeer, PROVIDER_SHARE_RELAY_CAPABILITY, SHARED_TASK_CAPABILITY } from '@cindy/device-link'; import { captureSharedTaskPeer, captureSharedTaskPush, assertSharedTaskInvoke, sharedTaskMetadataTopic, sharedTaskAccessFailure } from './sharedTaskDispatch.js'; import { runAsBackgroundDbRpc } from '../localDb/client/rpcAdmission.js'; import { fetchLocalMediaToOss } from './mediaFetch'; @@ -339,6 +339,8 @@ export interface ProviderShareAccessPort { /** 不认识的受邀者连进来时按需刷新一次分享快照。 */ ensureKnown(controller: string): Promise; hasShares(): boolean; + /** 受邀者被拒的原因(只进本机日志);放行时为 null。 */ + denial?(controller: string): string | null; } let providerShareAccess: ProviderShareAccessPort | null = null; @@ -350,6 +352,23 @@ function providerShareGuestAccess(controller: string): ReturnType(); +function logProviderShareDenial(src: string, where: string, reason?: string): void { + const why = reason ?? (providerShareAccess ? providerShareAccess.denial?.(src) ?? 'unknown' : 'host-not-wired'); + const key = `${src}\u0000${where}\u0000${why}`; + const now = Date.now(); + if (now - (providerShareDenialLoggedAt.get(key) ?? 0) < 60_000) return; + if (providerShareDenialLoggedAt.size >= 256) providerShareDenialLoggedAt.clear(); + providerShareDenialLoggedAt.set(key, now); + const peer = parseProviderSharePeer(src); + const who = peer?.role === 'guest' ? `share ${peer.shareId.slice(0, 8)} member ${peer.memberId.slice(0, 8)}` : shortId(src); + log.warn(`provider-share guest refused at ${where} from ${who}: ${why}`); +} + export interface RemoteAgentHandler { handle(controller: string, raw: unknown): Promise; abortAll(): void; @@ -2553,6 +2572,7 @@ function handleLinkOpen( } if (!providerShareGuestAccess(src) || !sanitizeControllerCapabilities(payload?.capabilities).includes(PROVIDER_SHARE_RELAY_CAPABILITY)) { + logProviderShareDenial(src, 'link-open', providerShareGuestAccess(src) ? 'missing-relay-capability' : undefined); client.closeLink(src, 'revoked', 'inbound'); return; } @@ -2992,7 +3012,11 @@ function settleRemoteInvokeWithOrphanDeadline( } function currentRemoteInvokeAdmissionFailure(src: string): InvokeResultPayload | null { - if (isProviderSharePeer(src)) return providerShareGuestAccess(src) ? null : PROVIDER_SHARE_ACCESS_FAILURE; + if (isProviderSharePeer(src)) { + if (providerShareGuestAccess(src)) return null; + logProviderShareDenial(src, 'admission'); + return PROVIDER_SHARE_ACCESS_FAILURE; + } if (isSharedTaskPeer(src)) return captureSharedTaskPeer(src) ? null : sharedTaskAccessFailure(src); if (!readDeviceLinkSettings().remoteControlEnabled) { return { ok: false, error: { code: 'REMOTE_DISABLED', message: 'remote control disabled' } }; @@ -4039,7 +4063,11 @@ async function runProviderShareInvoke( src: string, payload: InvokePayload | undefined, timing: RemoteInvokeTiming, ): Promise { const access = providerShareGuestAccess(src); - if (!access || !payload || typeof payload.channel !== 'string') return PROVIDER_SHARE_ACCESS_FAILURE; + if (!access) { + logProviderShareDenial(src, `invoke ${typeof payload?.channel === 'string' ? payload.channel : '?'}`); + return PROVIDER_SHARE_ACCESS_FAILURE; + } + if (!payload || typeof payload.channel !== 'string') return PROVIDER_SHARE_ACCESS_FAILURE; if (!PROVIDER_SHARE_CHANNELS.has(payload.channel)) { log.warn(`blocked provider-share channel from ${shortId(src)}: ${payload.channel}`); return { ok: false, error: { code: 'CHANNEL_NOT_ALLOWED', message: `channel '${payload.channel}' not allowed for shared providers` } }; @@ -4492,6 +4520,7 @@ export const __testing = { onRemoteInvokeBusyChanged = null; inFlightRemoteInvokeCount = 0; completedRemoteInvokeResults.clear(); + providerShareDenialLoggedAt.clear(); completedRemoteInvokeResultBytes = 0; inFlightRemoteInvokeResults.clear(); inFlightRemoteInvokeBytes = 0; diff --git a/apps/desktop/src/main/device-link/ipc.ts b/apps/desktop/src/main/device-link/ipc.ts index 9240b3c0103..e3a2b01ec81 100644 --- a/apps/desktop/src/main/device-link/ipc.ts +++ b/apps/desktop/src/main/device-link/ipc.ts @@ -760,6 +760,20 @@ function projectProviderShareResult(channel: string, value: unknown): unknown { return value; } +/** + * 分享来的供应商读不到时在本机日志记下错误(同一分享、同一请求、同一错误每分钟最多一条)。 + * 界面只能说「暂时读不到」,排查要靠这里和分享者电脑上的拒绝原因。 + */ +const providerShareReadFailureLoggedAt = new Map(); +function logProviderShareReadFailure(agentDeviceId: string, channel: string, code: string, message: string): void { + const key = `${agentDeviceId}\u0000${channel}\u0000${code}`; + const now = Date.now(); + if (now - (providerShareReadFailureLoggedAt.get(key) ?? 0) < 60_000) return; + if (providerShareReadFailureLoggedAt.size >= 256) providerShareReadFailureLoggedAt.clear(); + providerShareReadFailureLoggedAt.set(key, now); + log.warn(`provider share read failed: ${channel} on ${agentDeviceId}: ${code} ${message.slice(0, 200)}`); +} + export async function handleProviderShareInvoke( deps: Pick, agentDeviceId: string, @@ -769,7 +783,14 @@ export async function handleProviderShareInvoke( if (typeof channel !== 'string' || !PROVIDER_SHARE_RENDERER_CHANNELS.has(channel)) { throwIpcError('DEVICE_LINK_CHANNEL_NOT_ALLOWED', 'Not available for shared providers'); } - const target = await resolveRemoteAgentTargetWhenReady(agentDeviceId); + let target: string; + try { + target = await resolveRemoteAgentTargetWhenReady(agentDeviceId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logProviderShareReadFailure(agentDeviceId, channel, 'RESOLVE', message); + throw err; + } const callArgs = Array.isArray(args) ? args : []; let result: InvokeResultPayload; try { @@ -777,10 +798,17 @@ export async function handleProviderShareInvoke( ? await crossRegionInvoke(target, channel, callArgs) : await deps.invoke(target, channel, callArgs); } catch (err) { + logProviderShareReadFailure( + agentDeviceId, + channel, + err instanceof DeviceLinkError ? err.code : 'THROWN', + err instanceof Error ? err.message : String(err), + ); if (err instanceof DeviceLinkError && isProviderShareRefusal(err.code, err.message)) refuseProviderShare(); rethrowDeviceLinkError(err); } if (result.ok) return projectProviderShareResult(channel, result.result); + logProviderShareReadFailure(agentDeviceId, channel, result.error.code, result.error.message); // 分享者电脑拒绝了(暂停、关了远程控制或这个供应商的「允许被远程调用」):分享专属原因。 if (isProviderShareRefusal(result.error.code, result.error.message)) refuseProviderShare(); if (result.error.code === 'IPC_ERROR') throw new Error(result.error.message); diff --git a/apps/desktop/src/main/device-link/providerShareHost.ts b/apps/desktop/src/main/device-link/providerShareHost.ts index 5c941c9466c..e5635aa3e82 100644 --- a/apps/desktop/src/main/device-link/providerShareHost.ts +++ b/apps/desktop/src/main/device-link/providerShareHost.ts @@ -183,24 +183,52 @@ export function getProviderShareHostSnapshot(): readonly ProviderShareOwned[] { return runtime?.shares ?? []; } -/** - * 受邀者对端的准入:分享与成员在本机快照里 active、这台电脑允许远程控制、分享的供应商仍开放 - * 「允许被远程调用」。任一不满足返回 null。 - */ -export function providerShareGuestAccess(controller: string): ProviderShareGuestAccess | null { +/** 受邀者对端被拒的原因(只进日志,不发给对方)。 */ +export type ProviderShareGuestDenial = + | 'host-not-running' + | 'host-scope-stale' + | 'not-guest-peer' + | 'self-device' + | 'snapshot-not-loaded' + | 'share-unknown' + | 'share-on-other-device' + | 'member-not-active' + | 'remote-control-off' + | 'provider-not-remote'; + +type GuestAccessCheck = { access: ProviderShareGuestAccess } | { denied: ProviderShareGuestDenial }; + +function checkGuestAccess(controller: string): GuestAccessCheck { const rt = runtime; - if (!rt || !current(rt)) return null; + if (!rt || rt.stopped || runtime !== rt) return { denied: 'host-not-running' }; + if (!current(rt)) return { denied: 'host-scope-stale' }; const peer = parseProviderSharePeer(controller); - if (!peer || peer.role !== 'guest') return null; + if (!peer || peer.role !== 'guest') return { denied: 'not-guest-peer' }; const self = getDeviceId(); - if (self && peer.deviceId === self) return null; + if (self && peer.deviceId === self) return { denied: 'self-device' }; const share = rt.shares.find((item) => item.shareId === peer.shareId); - if (!share || (self && share.hostDeviceId !== self)) return null; + if (!share) return { denied: rt.fetchedAt === 0 ? 'snapshot-not-loaded' : 'share-unknown' }; + if (self && share.hostDeviceId !== self) return { denied: 'share-on-other-device' }; const member = share.members.find((item) => item.memberId === peer.memberId); - if (!member || member.status !== 'active') return null; - if (!readDeviceLinkSettings().remoteControlEnabled) return null; - if (!isRemoteProviderInvocationAllowed(share.providerId)) return null; - return { shareId: share.shareId, memberId: member.memberId, providerId: share.providerId }; + if (!member || member.status !== 'active') return { denied: 'member-not-active' }; + if (!readDeviceLinkSettings().remoteControlEnabled) return { denied: 'remote-control-off' }; + if (!isRemoteProviderInvocationAllowed(share.providerId)) return { denied: 'provider-not-remote' }; + return { access: { shareId: share.shareId, memberId: member.memberId, providerId: share.providerId } }; +} + +/** + * 受邀者对端的准入:分享与成员在本机快照里 active、这台电脑允许远程控制、分享的供应商仍开放 + * 「允许被远程调用」。任一不满足返回 null。 + */ +export function providerShareGuestAccess(controller: string): ProviderShareGuestAccess | null { + const result = checkGuestAccess(controller); + return 'access' in result ? result.access : null; +} + +/** 受邀者对端为什么被拒;放行时为 null。只用于诊断日志。 */ +export function providerShareGuestDenial(controller: string): ProviderShareGuestDenial | null { + const result = checkGuestAccess(controller); + return 'denied' in result ? result.denied : null; } /** 不认识的受邀者连进来:可能是刚同意的成员,按需立即拉一次(限频)。 */ diff --git a/apps/desktop/src/main/device-link/providerShareRuntime.ts b/apps/desktop/src/main/device-link/providerShareRuntime.ts index edd37a76116..74ba3dc50f7 100644 --- a/apps/desktop/src/main/device-link/providerShareRuntime.ts +++ b/apps/desktop/src/main/device-link/providerShareRuntime.ts @@ -57,6 +57,7 @@ import { getProviderShareHostSnapshot, markProviderShareHostActive, providerShareGuestAccess, + providerShareGuestDenial, providerShareMemberMatcher, refreshProviderShareHost, startProviderShareHost, @@ -406,6 +407,7 @@ export function registerProviderShareIpc(): void { guestAccess: providerShareGuestAccess, ensureKnown: ensureProviderSharePeerKnown, hasShares: () => getProviderShareHostSnapshot().length > 0, + denial: providerShareGuestDenial, }); // 同账号的手机经设备互联调用(已在同账号 allowlist);本机窗口也可调用。 ipcMain.handle(PROVIDER_SHARE_RECEIVED_CATALOGS_CHANNEL, async (event, ...args: unknown[]) => { diff --git a/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx b/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx index fe8bc9ff870..076961e11c2 100644 --- a/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx +++ b/apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx @@ -2,6 +2,7 @@ * 另一台电脑上的供应商(自己其他电脑的远程供应商、别人分享给我的供应商)在设置右栏里的 * 只读模型清单:开关与排序都由那台电脑决定,这里只列出它开放的模型。 */ +import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import type { AgentKind, ProviderView } from '@cindy/model-providers'; @@ -34,16 +35,18 @@ export function readOnlyProviderModels( return rows; } -/** 「可用的模型」一节;note 非空时以一句说明代替清单(读取中、读不到、暂停等)。 */ +/** 「可用的模型」一节;note 非空时以一句说明代替清单(读取中、读不到、暂停等),action 跟在说明下面。 */ export function ReadOnlyProviderModelSection({ title, models, note, + action, testId, }: { title: string; models: readonly ReadOnlyProviderModel[]; note: string | null; + action?: ReactNode; testId?: string; }) { const { t } = useTranslation(); @@ -63,9 +66,12 @@ export function ReadOnlyProviderModelSection({ )}
{note !== null ? ( -

- {note} -

+
+

+ {note} +

+ {action} +
) : (
    {models.map((model) => ( diff --git a/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx b/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx index 2f3c718ab16..df93fb1e32e 100644 --- a/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx +++ b/apps/desktop/src/renderer/features/provider-share/ProviderShareReceivedRail.tsx @@ -20,9 +20,10 @@ import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; import { Tip } from '@/components/ui/tooltip'; import { useDeviceProviders } from '@/hooks/useDeviceProviders'; import { providerMonogram } from '@/lib/providerModels'; +import { refreshRemoteCatalogSnapshot } from '@/lib/remoteCatalogSnapshot'; import { toast } from '@/lib/toast'; import { cn } from '@/lib/utils'; -import { mapIpcErrorToI18nKey } from '@/utils/ipcError'; +import { extractIpcError, mapIpcErrorToI18nKey } from '@/utils/ipcError'; import { providerShareAgentDeviceId } from './providerShareFormat'; import { useProviderShareReceived } from './providerShareStore'; @@ -128,7 +129,8 @@ export function ProviderShareReceivedDetail({ share }: { share: ProviderShareRec const { confirm } = useConfirmDialog(); const [leaving, setLeaving] = useState(false); const status = receivedStatus(share); - const catalog = useDeviceProviders(status === 'active' ? providerShareAgentDeviceId(share.shareId) : undefined); + const catalogDeviceId = providerShareAgentDeviceId(share.shareId); + const catalog = useDeviceProviders(status === 'active' ? catalogDeviceId : undefined); const provider = catalog.providers.find((entry) => entry.id === share.providerId) ?? catalog.providers[0] ?? null; const models = useMemo( @@ -166,7 +168,14 @@ export function ProviderShareReceivedDetail({ share }: { share: ProviderShareRec if (status === 'paused') body = t('providerShare.received.pausedNote'); else if (status === 'offline') body = t('providerShare.received.offlineNote'); else if (status === 'needs-update') body = t('providerShare.received.needsUpdateNote'); - else if (catalog.error) body = t('providerShare.received.modelsFailed'); + else if (catalog.error) { + // 分享者电脑拒绝(关了远程控制 / 允许被远程调用,或那边还没加载好分享)与连不上要分开说; + // 其余错误带上错误代码,截图就能看出卡在哪一步。 + const code = extractIpcError(new Error(catalog.error))?.code ?? null; + body = code === 'REMOTE_AGENT_SHARE_UNAVAILABLE' + ? t('providerShare.received.modelsRefused') + : t('providerShare.received.modelsFailedWithCode', { code: code ?? 'UNKNOWN' }); + } else if (catalog.loading) body = t('providerShare.received.modelsLoading'); else if (models.length === 0) body = t('providerShare.received.modelsEmpty'); @@ -239,6 +248,17 @@ export function ProviderShareReceivedDetail({ share }: { share: ProviderShareRec title={t('providerShare.received.modelsTitle')} models={models} note={body} + action={ + status === 'active' && catalog.error ? ( + + ) : null + } testId="provider-share-received-models" />
diff --git a/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx b/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx index 4e8774b7af2..a4aa3e104ad 100644 --- a/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx +++ b/apps/desktop/src/renderer/features/provider-share/__tests__/ProviderShareReceivedRail.test.tsx @@ -16,7 +16,8 @@ vi.mock('react-i18next', () => ({ }), })); vi.mock('@/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); -vi.mock('@/lib/remoteCatalogSnapshot', () => ({ refreshRemoteCatalogSnapshot: vi.fn(async () => undefined) })); +const refreshCatalog = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock('@/lib/remoteCatalogSnapshot', () => ({ refreshRemoteCatalogSnapshot: refreshCatalog })); const confirmSpy = vi.hoisted(() => vi.fn(async () => true)); vi.mock('@/components/ui/confirm-dialog-provider', () => ({ useConfirmDialog: () => ({ confirm: confirmSpy }) })); const joinSpy = vi.hoisted(() => vi.fn()); @@ -139,9 +140,22 @@ describe('ProviderShareReceivedDetail', () => { expect(Array.from(list.querySelectorAll('li')).map((item) => item.textContent)).toEqual(['Opus 5.5', 'GPT-5.5']); }); - it('says when the models cannot be read', () => { - catalog.value = { providers: [], loading: false, error: 'boom', unsupported: false }; + it('says why the models cannot be read and offers a reload', () => { + catalog.value = { + providers: [], loading: false, error: '[DEVICE_LINK_TIMEOUT] timed out', unsupported: false, + }; + render(); + expect(screen.getByText('providerShare.received.modelsFailedWithCode:{"code":"DEVICE_LINK_TIMEOUT"}')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'providerShare.received.retryModels' })); + expect(refreshCatalog).toHaveBeenCalledWith('share:share-1'); + }); + + it('tells a refusal by the owner computer apart from a failed read', () => { + catalog.value = { + providers: [], loading: false, + error: 'Error invoking remote method: Error: [REMOTE_AGENT_SHARE_UNAVAILABLE] not available', unsupported: false, + }; render(); - expect(screen.getByText('providerShare.received.modelsFailed')).toBeTruthy(); + expect(screen.getByText('providerShare.received.modelsRefused')).toBeTruthy(); }); }); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 74250180439..7e2af7b72a8 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -378,7 +378,9 @@ "detailNote": "The agent runs on {{name}}'s computer, using their sign-in and quota. To use it, choose it in the model list when you start a task.", "modelsTitle": "Available Models", "modelsLoading": "Loading the models they shared…", - "modelsFailed": "Couldn't load the models they shared. Try again later.", + "modelsRefused": "The owner’s computer isn’t allowing this share right now. It may not allow remote control, “Allow remote use” may be off for this provider, or their Cindy may still be starting. Try again shortly, or ask them to check both settings.", + "modelsFailedWithCode": "Couldn’t read the models they made available. Try again shortly. (Error code: {{code}})", + "retryModels": "Reload Models", "modelsEmpty": "They haven't made any models available yet.", "pausedNote": "They've paused this share. You can use it again once they resume it.", "offlineNote": "Their computer is offline. You can use it again once it is back online.", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index a1393fbdc2b..9c126d836e3 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -376,7 +376,9 @@ "detailNote": "Agent は {{name}} のパソコンで動き、相手のログインとクォータを使います。タスクを新規作成するときにモデル一覧で選ぶと使えます。", "modelsTitle": "利用できるモデル", "modelsLoading": "相手が公開しているモデルを読み込んでいます…", - "modelsFailed": "相手が公開しているモデルを読み込めませんでした。しばらくしてからもう一度お試しください。", + "modelsRefused": "相手のパソコンが今この共有の読み込みを許可していません。リモート操作を許可していないか、このプロバイダーの「リモート利用を許可」がオフになっているか、相手の Cindy が起動したばかりでまだ準備できていない可能性があります。しばらくしてから再試行するか、相手にこの 2 つの設定を確認してもらってください。", + "modelsFailedWithCode": "相手が公開しているモデルを読み込めませんでした。しばらくしてから再試行してください。(エラーコード:{{code}})", + "retryModels": "モデルを再読み込み", "modelsEmpty": "相手はまだ利用できるモデルを公開していません。", "pausedNote": "相手がこの共有を一時停止しています。再開されると使えるようになります。", "offlineNote": "相手のパソコンがオフラインです。オンラインに戻ると使えるようになります。", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index f4c55c330f2..06e1b845c08 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -376,7 +376,9 @@ "detailNote": "Agent는 {{name}}님의 컴퓨터에서 실행되며 상대방의 로그인과 할당량을 씁니다. 새 작업을 만들 때 모델 목록에서 선택하면 사용할 수 있습니다.", "modelsTitle": "사용 가능한 모델", "modelsLoading": "상대방이 공개한 모델을 불러오는 중…", - "modelsFailed": "상대방이 공개한 모델을 불러오지 못했습니다. 잠시 후 다시 시도하세요.", + "modelsRefused": "상대방 컴퓨터가 지금 이 공유를 읽도록 허용하지 않습니다. 원격 제어를 허용하지 않았거나 이 제공자의 원격 사용 허용이 꺼져 있거나, 상대방 Cindy가 방금 시작되어 아직 준비되지 않았을 수 있습니다. 잠시 후 다시 시도하거나 상대방에게 두 설정을 확인해 달라고 하세요.", + "modelsFailedWithCode": "상대방이 공개한 모델을 읽을 수 없습니다. 잠시 후 다시 시도하세요. (오류 코드: {{code}})", + "retryModels": "모델 다시 읽기", "modelsEmpty": "상대방이 아직 사용 가능한 모델을 공개하지 않았습니다.", "pausedNote": "상대방이 이 공유를 일시 중지했습니다. 다시 시작하면 사용할 수 있습니다.", "offlineNote": "상대방 컴퓨터가 오프라인입니다. 다시 온라인이 되면 사용할 수 있습니다.", 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 9d7e683911e..97ca25c95d7 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -376,7 +376,9 @@ "detailNote": "Agent 在 {{name}} 的电脑上运行,用的是对方的登录和配额。新建任务时在模型列表里选择它就能用。", "modelsTitle": "可用的模型", "modelsLoading": "正在读取对方开放的模型…", - "modelsFailed": "暂时读不到对方开放的模型,请稍后再试。", + "modelsRefused": "对方电脑暂时不让读取这个分享:可能没有允许远程控制、关了这个供应商的「允许被远程调用」,也可能对方的 Cindy 刚启动、还没准备好。请稍后重试,或让对方检查这两项设置。", + "modelsFailedWithCode": "暂时读不到对方开放的模型,请稍后重试。(错误代码:{{code}})", + "retryModels": "重新读取模型", "modelsEmpty": "对方还没有开放可用的模型。", "pausedNote": "对方暂停了这个分享,恢复后才能使用。", "offlineNote": "对方电脑不在线,上线后就能继续使用。", 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 311eaa75c4b..9162c4279ed 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -376,7 +376,9 @@ "detailNote": "Agent 在 {{name}} 的電腦上執行,使用的是對方的登入和配額。新建任務時在模型列表裡選擇它就能用。", "modelsTitle": "可用的模型", "modelsLoading": "正在讀取對方開放的模型…", - "modelsFailed": "暫時讀不到對方開放的模型,請稍後再試。", + "modelsRefused": "對方電腦暫時不讓讀取這個分享:可能沒有允許遠端控制、關了這個供應商的「允許遠端呼叫」,也可能對方的 Cindy 剛啟動、還沒準備好。請稍後重試,或請對方檢查這兩項設定。", + "modelsFailedWithCode": "暫時讀不到對方開放的模型,請稍後重試。(錯誤代碼:{{code}})", + "retryModels": "重新讀取模型", "modelsEmpty": "對方還沒有開放可用的模型。", "pausedNote": "對方暫停了這個分享,恢復後才能使用。", "offlineNote": "對方電腦不在線,上線後就能繼續使用。", From 90b66756b9063bdaf082afc6831b460d33abd738 Mon Sep 17 00:00:00 2001 From: MagicLizi Date: Thu, 8 Oct 2026 21:05:44 +0800 Subject: [PATCH 008/416] fix(settings): keep the computer name of remote providers in the tip Remote provider rows now use the same single-line layout as local providers (name, model count, status dot) with the remote badge; the computer name appears only in the hover tip and the accessible name. Signed-off-by: MagicLizi --- .../settings/OwnRemoteProviders.tsx | 22 +++++++++---------- .../__tests__/OwnRemoteProviders.test.tsx | 6 +++-- docs/product-rules/provider-sharing.md | 2 +- docs/provider-sharing-implementation-plan.md | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx b/apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx index 33fa0ad307d..31ed9361b3d 100644 --- a/apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx +++ b/apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx @@ -73,7 +73,7 @@ function RemoteProviderIcon({ provider, size }: { provider: ProviderView; size: ); } -/** 左栏里的远程供应商行:接在本机供应商下面,不参与拖动排序。 */ +/** 左栏里的远程供应商行:与本机供应商同一行式,接在它们下面、不参与拖动排序;所在电脑只写在悬停提示里。 */ export function OwnRemoteProviderRows({ entries, selectedKey, @@ -116,16 +116,16 @@ export function OwnRemoteProviderRows({ >
- - - {name} - - - {entry.deviceName} - + + {name} + + + {t('settings.providers.models.modelCount', { + count: readOnlyProviderModels(entry.provider, entry.modelVisibilityOverrides).length, + })}