From d6a27423fc71f2d13f9df7321ddd924db7d4ae79 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:20:54 -0700 Subject: [PATCH] extend(ai-vault): support Cursor sessions with host-aware context isolat Extends AI Vault session history to surface Cursor agent sessions with proper isolation across native, WSL, and SSH execution contexts. Implements cursor sidecar discovery and parsing (local and remote), host-matched command resolution, and metadata-only conversation tracking for sessions without readable transcripts. Adds mobile support for Cursor history resume and strengthens execution-context validation to prevent resuming sessions in incompatible target environments. --- config/scripts/locale-ko-key-overrides.json | 3 - .../MobileAgentSessionHistoryList.tsx | 4 +- .../MobileAgentSessionHistoryPanel.tsx | 57 ++-- .../agent-history-resume-target.test.ts | 20 ++ .../agent-history-resume-target.ts | 30 +- .../agent-history-session-card.test.ts | 21 ++ .../agent-history-session-card.ts | 6 +- .../use-mobile-agent-history-worktrees.ts | 36 +++ mobile/src/session/ai-vault-resume-launch.ts | 4 + .../src/session/mobile-cursor-command.test.ts | 52 +++ mobile/src/session/mobile-cursor-command.ts | 45 +++ mobile/src/worktree/workspace-list-types.ts | 1 + .../worktree/worktree-list-snapshot.test.ts | 6 + mobile/src/worktree/worktree-list-snapshot.ts | 1 + .../cached-remote-session-list.test.ts | 150 +++++++++ .../ai-vault/cached-remote-session-list.ts | 154 +++++++++ .../remote-session-scan-concurrency.test.ts | 48 +++ .../remote-session-scan-concurrency.ts | 38 +++ ...remote-session-scanner-cursor-discovery.ts | 201 ++++++++++++ .../remote-session-scanner-cursor.test.ts | 141 ++++++++ .../remote-session-scanner-discovery.ts | 2 +- .../remote-session-scanner-memory-provider.ts | 130 ++++++++ .../remote-session-scanner-sources.ts | 20 +- .../ai-vault/remote-session-scanner-types.ts | 8 +- .../ai-vault/remote-session-scanner.test.ts | 99 +----- src/main/ai-vault/remote-session-scanner.ts | 98 ++++-- .../ai-vault/runtime-session-scanner.test.ts | 21 ++ src/main/ai-vault/runtime-session-scanner.ts | 9 +- .../session-list-result-validation.ts | 2 + src/main/ai-vault/session-list-results.ts | 25 +- .../ai-vault/session-scanner-accumulator.ts | 8 +- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../session-scanner-codex-workers.test.ts | 1 + ...session-scanner-cursor-integration.test.ts | 118 +++++++ .../session-scanner-cursor-local-files.ts | 145 +++++++++ .../session-scanner-cursor-local-pipeline.ts | 152 +++++++++ .../session-scanner-cursor-materialize.ts | 178 +++++++++++ .../session-scanner-cursor-observability.ts | 74 +++++ .../session-scanner-cursor-paths.test.ts | 83 +++++ .../ai-vault/session-scanner-cursor-paths.ts | 150 +++++++++ .../session-scanner-cursor-reconcile.test.ts | 179 +++++++++++ .../session-scanner-cursor-reconcile.ts | 201 ++++++++++++ .../session-scanner-cursor-remote-pipeline.ts | 161 ++++++++++ .../session-scanner-cursor-selection.test.ts | 137 ++++++++ .../session-scanner-cursor-selection.ts | 176 ++++++++++ .../session-scanner-cursor-sidecar.test.ts | 149 +++++++++ .../session-scanner-cursor-sidecar.ts | 204 ++++++++++++ .../session-scanner-cursor-sources.ts | 241 ++++++++++++++ .../ai-vault/session-scanner-discovery.ts | 18 +- .../ai-vault/session-scanner-parse-cache.ts | 6 +- .../session-scanner-source-discovery.ts | 23 +- .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 18 ++ src/main/ai-vault/session-scanner.ts | 63 ++-- src/main/ipc/ai-vault-all-host-scan.test.ts | 36 +++ src/main/ipc/ai-vault-all-host-scan.ts | 38 +++ src/main/ipc/ai-vault-refresh-policy.ts | 23 ++ src/main/ipc/ai-vault-subagent-list.ts | 44 +++ src/main/ipc/ai-vault.test.ts | 48 ++- src/main/ipc/ai-vault.ts | 141 ++++---- src/main/ipc/preflight.test.ts | 64 +++- src/main/ipc/preflight.ts | 116 +++---- src/main/ipc/tui-agent-inventory-detection.ts | 152 +++++++++ .../providers/filesystem-provider-contract.ts | 8 + .../providers/ssh-cursor-sidecar-scan.test.ts | 54 ++++ src/main/providers/ssh-cursor-sidecar-scan.ts | 30 ++ src/main/providers/ssh-filesystem-provider.ts | 6 + src/main/runtime/orca-runtime.test.ts | 9 +- src/main/runtime/orca-runtime.ts | 110 ++++++- .../runtime/rpc/methods/preflight.test.ts | 52 +++ src/main/runtime/rpc/methods/preflight.ts | 27 ++ .../runtime/runtime-cursor-command.test.ts | 99 ++++++ src/main/runtime/runtime-cursor-command.ts | 29 ++ src/main/runtime/runtime-rpc.ts | 4 + src/preload/api-types.ts | 13 + src/preload/index.ts | 14 + src/relay/cursor-sidecar-scan-discovery.ts | 277 ++++++++++++++++ src/relay/cursor-sidecar-scan.test.ts | 301 ++++++++++++++++++ src/relay/cursor-sidecar-scan.ts | 157 +++++++++ src/relay/fs-handler-terminal-artifact.ts | 27 +- src/relay/fs-handler.ts | 4 + src/relay/preflight-handler.test.ts | 48 +++ src/relay/preflight-handler.ts | 154 ++++++--- .../FloatingTerminalWindowControls.tsx | 10 +- .../onboarding-folder-agent-startup.test.ts | 14 + .../onboarding/use-onboarding-flow.ts | 11 +- .../components/right-sidebar/AiVaultPanel.tsx | 87 ++--- .../AiVaultSessionVirtualList.tsx | 6 +- .../ai-vault-session-continuation.test.ts | 9 + .../ai-vault-session-continuation.ts | 15 +- .../ai-vault-session-launch-actions.ts | 17 +- .../ai-vault-session-log-open.ts | 5 +- .../ai-vault-session-path-actions.ts | 5 +- .../ai-vault-session-refresh.test.ts | 50 +-- .../right-sidebar/ai-vault-session-refresh.ts | 156 +++++---- .../ai-vault-session-resume.test.ts | 29 ++ .../right-sidebar/ai-vault-session-resume.ts | 34 +- .../ai-vault-session-row-display.tsx | 15 +- .../buildSourceControlAgentDeliveryPlan.ts | 16 +- .../use-ai-vault-cursor-agent-detection.ts | 62 ++++ .../use-ai-vault-session-resume-controls.ts | 111 +++++++ .../components/sidebar/NonGitFolderDialog.tsx | 11 +- .../folder-workspace-composer-submit.ts | 18 +- .../tab-group/AiVaultSessionDropLayer.tsx | 9 +- src/renderer/src/hooks/useComposerState.ts | 22 +- .../src/hooks/useDetectedAgents.test.tsx | 9 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- .../src/lib/ai-vault-cursor-command.test.ts | 86 +++++ .../src/lib/ai-vault-cursor-command.ts | 131 ++++++++ .../src/lib/ai-vault-resume-command.test.ts | 39 ++- .../src/lib/ai-vault-resume-command.ts | 26 +- .../src/lib/ai-vault-resume-target.ts | 114 ++++--- .../github-work-item-background-request.ts | 21 +- .../lib/launch-agent-background-session.ts | 3 +- .../src/lib/launch-agent-in-new-tab.ts | 3 +- .../src/lib/launch-work-item-direct.ts | 15 +- .../lib/onboarding-folder-agent-startup.ts | 13 +- .../src/lib/sleeping-agent-session-launch.ts | 8 +- .../src/lib/tui-agent-startup.test.ts | 24 +- .../src/store/slices/detected-agents.test.ts | 70 +++- .../src/store/slices/detected-agents.ts | 67 +++- src/renderer/src/store/slices/repos.ts | 8 +- .../store/slices/runtime-detected-agents.ts | 60 +++- src/renderer/src/web/web-preload-api.ts | 43 +++ src/shared/ai-vault-resume-command.test.ts | 27 ++ src/shared/ai-vault-resume-context.test.ts | 95 ++++++ src/shared/ai-vault-resume-context.ts | 74 +++++ src/shared/ai-vault-session-id.ts | 54 ++++ src/shared/ai-vault-transcript-path.test.ts | 28 ++ src/shared/ai-vault-transcript-path.ts | 10 + src/shared/ai-vault-types.ts | 9 +- src/shared/cursor-command.test.ts | 47 +++ src/shared/cursor-command.ts | 23 ++ src/shared/cursor-sidecar-scan.ts | 160 ++++++++++ src/shared/detected-agent-inventory.ts | 94 ++++++ src/shared/node-verified-bounded-text-file.ts | 129 ++++++++ src/shared/runtime-types.ts | 1 + src/shared/tui-agent-config.ts | 1 + src/shared/tui-agent-detection-commands.ts | 38 +++ src/shared/tui-agent-launch-command.ts | 3 + .../ssh-ai-vault-session-history-fixtures.ts | 148 +++++++++ .../e2e/ssh-ai-vault-session-history.spec.ts | 181 ++++------- 146 files changed, 7695 insertions(+), 895 deletions(-) create mode 100644 mobile/src/agent-history/use-mobile-agent-history-worktrees.ts create mode 100644 mobile/src/session/mobile-cursor-command.test.ts create mode 100644 mobile/src/session/mobile-cursor-command.ts create mode 100644 src/main/ai-vault/cached-remote-session-list.test.ts create mode 100644 src/main/ai-vault/cached-remote-session-list.ts create mode 100644 src/main/ai-vault/remote-session-scan-concurrency.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-cursor-discovery.ts create mode 100644 src/main/ai-vault/remote-session-scanner-cursor.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-memory-provider.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-integration.test.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-local-files.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-local-pipeline.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-materialize.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-observability.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-paths.test.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-paths.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-reconcile.test.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-reconcile.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-remote-pipeline.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-selection.test.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-selection.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-sidecar.test.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-sidecar.ts create mode 100644 src/main/ai-vault/session-scanner-cursor-sources.ts create mode 100644 src/main/ipc/ai-vault-all-host-scan.test.ts create mode 100644 src/main/ipc/ai-vault-all-host-scan.ts create mode 100644 src/main/ipc/ai-vault-refresh-policy.ts create mode 100644 src/main/ipc/ai-vault-subagent-list.ts create mode 100644 src/main/ipc/tui-agent-inventory-detection.ts create mode 100644 src/main/providers/ssh-cursor-sidecar-scan.test.ts create mode 100644 src/main/providers/ssh-cursor-sidecar-scan.ts create mode 100644 src/main/runtime/runtime-cursor-command.test.ts create mode 100644 src/main/runtime/runtime-cursor-command.ts create mode 100644 src/relay/cursor-sidecar-scan-discovery.ts create mode 100644 src/relay/cursor-sidecar-scan.test.ts create mode 100644 src/relay/cursor-sidecar-scan.ts create mode 100644 src/renderer/src/components/right-sidebar/use-ai-vault-cursor-agent-detection.ts create mode 100644 src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.ts create mode 100644 src/renderer/src/lib/ai-vault-cursor-command.test.ts create mode 100644 src/renderer/src/lib/ai-vault-cursor-command.ts create mode 100644 src/shared/ai-vault-resume-context.test.ts create mode 100644 src/shared/ai-vault-resume-context.ts create mode 100644 src/shared/ai-vault-session-id.ts create mode 100644 src/shared/ai-vault-transcript-path.test.ts create mode 100644 src/shared/ai-vault-transcript-path.ts create mode 100644 src/shared/cursor-command.test.ts create mode 100644 src/shared/cursor-command.ts create mode 100644 src/shared/cursor-sidecar-scan.ts create mode 100644 src/shared/detected-agent-inventory.ts create mode 100644 src/shared/node-verified-bounded-text-file.ts create mode 100644 tests/e2e/ssh-ai-vault-session-history-fixtures.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..fc8a253b7a5 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1613,9 +1613,6 @@ "auto.components.right.sidebar.AiVaultPanel.refreshSessionHistory": { "ko": "세션 기록 새로고침" }, - "auto.components.right.sidebar.AiVaultPanel.remoteBrowseLocalHistory": { - "ko": "원격 워크스페이스는 로컬 기록을 탐색할 수 있습니다. 재개 작업은 로컬 워크스페이스에서 실행됩니다." - }, "auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied": { "ko": "재개 명령이 복사됨" }, diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx index 9bc7ceedd91..53bbb4fb61d 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx @@ -129,7 +129,9 @@ function AgentHistoryCardRow({ {card.agentLabel} - {card.messageCount} {card.messageCount === 1 ? 'message' : 'messages'} + {card.messageCount === 0 && card.hasConversation + ? 'Conversation available' + : `${card.messageCount} ${card.messageCount === 1 ? 'message' : 'messages'}`} {showCurrentWorktreeBadge && card.isCurrentWorktree ? ( diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index abad3a84780..5d61edc763a 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -1,11 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' import { ChevronLeft, RefreshCw } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' import { useHostClient } from '../transport/client-context' -import type { RpcSuccess } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' import { getWorktreeLabel } from '../session/worktree-label' import { @@ -36,6 +35,9 @@ import { } from './agent-history-resume-target' import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card' import { styles } from './agent-history-styles' +import { resolveRequiredMobileCursorResumeCommand } from '../session/mobile-cursor-command' +import { parseWslUncPath } from '../../../src/shared/wsl-paths' +import { useMobileAgentHistoryWorktrees } from './use-mobile-agent-history-worktrees' export type MobileAgentSessionHistoryPanelProps = { hostId: string @@ -56,8 +58,10 @@ export function MobileAgentSessionHistoryPanel({ }: MobileAgentSessionHistoryPanelProps) { const router = useRouter() const { client, state: connState } = useHostClient(hostId) - const [worktrees, setWorktrees] = useState([]) - const [worktreesLoaded, setWorktreesLoaded] = useState(false) + const { worktrees, worktreesLoaded } = useMobileAgentHistoryWorktrees( + client, + connState === 'connected' + ) const [query, setQuery] = useState('') const [resumingSessionId, setResumingSessionId] = useState(null) const [resumeMessage, setResumeMessage] = useState(null) @@ -67,39 +71,6 @@ export function MobileAgentSessionHistoryPanel({ ) const worktreeLabel = getWorktreeLabel(name, worktreeId) - // Why: the worktree list seeds the host-local scopePaths derivation and the - // active-worktree path for the "current worktree" badge. - useEffect(() => { - if (!client || connState !== 'connected') { - return - } - let cancelled = false - void (async () => { - try { - const worktreeResponse = await client.sendRequest('worktree.ps', { limit: 10000 }) - if (cancelled) { - return - } - if (worktreeResponse.ok) { - const result = (worktreeResponse as RpcSuccess).result as { worktrees: Worktree[] } - setWorktrees(result.worktrees) - } - } catch { - // Why: worktree list is best-effort context; the session scan still runs - // (without it, scoped tabs can't narrow and fall back to the full list). - } finally { - // Why: mark loaded even on failure so a scoped tab proceeds with an - // unscoped fetch instead of holding a spinner forever. - if (!cancelled) { - setWorktreesLoaded(true) - } - } - })() - return () => { - cancelled = true - } - }, [client, connState]) - const { scope, screenState, @@ -201,11 +172,21 @@ export function MobileAgentSessionHistoryPanel({ } const preparedSession = await prepareMobileAiVaultSessionResume(client, session) + const resumeWorktrees = freshWorktrees ?? worktrees + const cursorCommand = await resolveRequiredMobileCursorResumeCommand({ + client, + settings, + wslDistro: + resumeWorktrees.find((worktree) => worktree.worktreeId === target.worktreeId) + ?.wslDistro ?? parseWslUncPath(target.workspacePath ?? '')?.distro, + session + }) const launch = buildMobileAiVaultResumeLaunch({ session: preparedSession, hostPlatform: platform, hostTerminalWindowsShell, - settings + settings, + cursorCommand }) await resumeAiVaultSessionInTerminal(client, target.worktreeId, { ...launch, diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts index d59feb98e15..d61dadd6e1a 100644 --- a/mobile/src/agent-history/agent-history-resume-target.test.ts +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -202,6 +202,26 @@ describe('mobile AI Vault resume target guards', () => { }) }) + it('matches a configured WSL runtime when the workspace path is a Windows path', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ + agent: 'cursor', + cwd: '/mnt/c/src/repo', + filePath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.cursor\\chats\\bucket\\session\\meta.json' + }), + activeWorktreeId: 'wsl-wt', + worktrees: [ + worktree({ + worktreeId: 'wsl-wt', + path: 'C:\\src\\repo', + wslDistro: 'Ubuntu' + }) + ], + repos + }) + expect(target).toMatchObject({ status: 'ready', worktreeId: 'wsl-wt' }) + }) + it('blocks runtime targets when no supported fallback is available', () => { const target = resolveMobileAiVaultSessionResumeTarget({ session: session({ cwd: '/Users/ada/runtime/app' }), diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts index a63a7ca92c7..0dee7de79ea 100644 --- a/mobile/src/agent-history/agent-history-resume-target.ts +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -1,12 +1,15 @@ import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { canResumeAiVaultSessionInExecutionContext } from '../../../src/shared/ai-vault-resume-context' import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import { parseWslUncPath } from '../../../src/shared/wsl-paths' import type { Worktree } from '../worktree/workspace-list-types' import { canResumeInMobileSessionWorktree, @@ -148,16 +151,31 @@ export function resolveMobileAiVaultSessionResumeTarget(args: { if (!isSupportedMobileAiVaultResumeTargetStatus(targetStatus)) { continue } + const targetWorktree = args.worktrees.find( + (worktree) => worktree.worktreeId === candidateWorktreeId + ) + const targetRepo = args.repos.find((repo) => repo.id === targetWorktree?.repoId) + if ( + !canResumeAiVaultSessionInExecutionContext({ + agent: args.session.agent, + sessionFilePath: args.session.filePath, + sessionExecutionHostId: args.session.executionHostId, + targetStatus, + targetExecutionHostId: + normalizeExecutionHostId(targetWorktree?.hostId) ?? + (targetRepo ? getRepoExecutionHostId(targetRepo) : LOCAL_EXECUTION_HOST_ID), + targetWslDistro: + targetWorktree?.wslDistro ?? parseWslUncPath(targetWorktree?.path ?? '')?.distro ?? null + }) + ) { + continue + } return { status: 'ready', worktreeId: candidateWorktreeId, targetStatus, - workspacePath: - args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)?.path ?? - null, - terminalPlatform: - args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId) - ?.terminalPlatform ?? null + workspacePath: targetWorktree?.path ?? null, + terminalPlatform: targetWorktree?.terminalPlatform ?? null } } diff --git a/mobile/src/agent-history/agent-history-session-card.test.ts b/mobile/src/agent-history/agent-history-session-card.test.ts index d38285aee14..ace88e82901 100644 --- a/mobile/src/agent-history/agent-history-session-card.test.ts +++ b/mobile/src/agent-history/agent-history-session-card.test.ts @@ -50,6 +50,16 @@ describe('buildMobileAgentHistoryCard', () => { expect(card.isCurrentWorktree).toBe(true) }) + it('preserves metadata-only conversation availability in the card model', () => { + const card = buildMobileAgentHistoryCard( + session({ messageCount: 0, hasConversation: true, previewMessages: [] }), + null, + NOW + ) + expect(card.hasConversation).toBe(true) + expect(card.messageCount).toBe(0) + }) + it('omits the current-worktree badge when cwd is outside the active worktree', () => { const card = buildMobileAgentHistoryCard(session(), '/Users/ada/other', NOW) expect(card.isCurrentWorktree).toBe(false) @@ -76,6 +86,17 @@ describe('buildMobileAgentHistoryResumeActionState', () => { const state = buildMobileAgentHistoryResumeActionState([session({ id: 'claude:1' })], null) expect(state.get('claude:1')).toEqual({ disabled: false, loading: false }) }) + + it('defers Cursor command validation until the target worktree is known', () => { + const cursor = session({ id: 'cursor:1', agent: 'cursor' }) + expect(buildMobileAgentHistoryResumeActionState([cursor], null).get(cursor.id)).toEqual({ + disabled: false, + loading: false + }) + expect( + buildMobileAgentHistoryResumeActionState([{ ...cursor, cwd: null }], null).get(cursor.id) + ).toEqual({ disabled: true, loading: false }) + }) }) describe('isSessionInActiveWorktree', () => { diff --git a/mobile/src/agent-history/agent-history-session-card.ts b/mobile/src/agent-history/agent-history-session-card.ts index e18ac3f5410..47be3bba654 100644 --- a/mobile/src/agent-history/agent-history-session-card.ts +++ b/mobile/src/agent-history/agent-history-session-card.ts @@ -11,6 +11,7 @@ export type MobileAgentHistoryCard = { title: string lastMessage: string messageCount: number + hasConversation: boolean timeAgo: string isCurrentWorktree: boolean } @@ -47,20 +48,21 @@ export function buildMobileAgentHistoryCard( title: session.title || 'Untitled session', lastMessage: latestTurn?.text.trim() ?? '', messageCount: session.messageCount, + hasConversation: session.hasConversation === true, timeAgo: Number.isFinite(updatedAtMs) ? formatTimeAgo(updatedAtMs, now) : '', isCurrentWorktree: isSessionInActiveWorktree(session, activeWorktreePath) } } export function buildMobileAgentHistoryResumeActionState( - sessions: readonly Pick[], + sessions: readonly Pick[], resumingSessionId: string | null ): ReadonlyMap { return new Map( sessions.map((session) => [ session.id, { - disabled: resumingSessionId !== null, + disabled: resumingSessionId !== null || (session.agent === 'cursor' && !session.cwd), loading: resumingSessionId === session.id } ]) diff --git a/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts b/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts new file mode 100644 index 00000000000..e40c5203f15 --- /dev/null +++ b/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { Worktree } from '../worktree/workspace-list-types' + +export function useMobileAgentHistoryWorktrees( + client: Pick | null, + connected: boolean +): { worktrees: Worktree[]; worktreesLoaded: boolean } { + const [worktrees, setWorktrees] = useState([]) + const [worktreesLoaded, setWorktreesLoaded] = useState(false) + useEffect(() => { + if (!client || !connected) { + return + } + let cancelled = false + void (async () => { + try { + const response = await client.sendRequest('worktree.ps', { limit: 10000 }) + if (!cancelled && response.ok) { + setWorktrees(((response as RpcSuccess).result as { worktrees: Worktree[] }).worktrees) + } + } catch { + // Scope context is best effort; the session scan can still proceed unscoped. + } finally { + if (!cancelled) { + setWorktreesLoaded(true) + } + } + })() + return () => { + cancelled = true + } + }, [client, connected]) + return { worktrees, worktreesLoaded } +} diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index aa8c1bf98a6..e7e0f91e412 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -85,6 +85,7 @@ export function buildMobileAiVaultResumeLaunch(args: { hostPlatform: NodeJS.Platform hostTerminalWindowsShell?: string | null settings?: MobileAiVaultResumeSettings | null + cursorCommand?: string | null }): MobileAiVaultResumeLaunch { const shell = args.hostPlatform === 'win32' @@ -94,6 +95,9 @@ export function buildMobileAiVaultResumeLaunch(args: { const cmdOverrides = normalizeMobileAiVaultResumeCommandOverrides( args.settings?.agentCmdOverrides ) + if (args.session.agent === 'cursor' && !cmdOverrides.cursor && args.cursorCommand?.trim()) { + cmdOverrides.cursor = args.cursorCommand.trim() + } const commandOverride = cmdOverrides[args.session.agent] ?? null const resumeFilePath = normalizeAiVaultResumeFilePath(args.session.filePath, args.hostPlatform) if (isResumableTuiAgent(args.session.agent)) { diff --git a/mobile/src/session/mobile-cursor-command.test.ts b/mobile/src/session/mobile-cursor-command.test.ts new file mode 100644 index 00000000000..c34be3ff03a --- /dev/null +++ b/mobile/src/session/mobile-cursor-command.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveMobileCursorCommand } from './mobile-cursor-command' + +describe('resolveMobileCursorCommand', () => { + it('preserves the host-matched Cursor command', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor agent' } + } + }) + + await expect(resolveMobileCursorCommand({ client: { sendRequest } })).resolves.toBe( + 'cursor agent' + ) + expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', undefined) + }) + + it('fails closed without a valid inventory unless an override exists', async () => { + const sendRequest = vi.fn().mockResolvedValue({ ok: false }) + await expect(resolveMobileCursorCommand({ client: { sendRequest } })).resolves.toBeNull() + await expect( + resolveMobileCursorCommand({ + client: { sendRequest }, + settings: { agentCmdOverrides: { cursor: 'cursor-dev' } } + }) + ).resolves.toBe('cursor-dev') + }) + + it('requests inventory from the exact WSL distro context', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor agent' } + } + }) + + await expect( + resolveMobileCursorCommand({ + client: { sendRequest }, + wslDistro: ' Ubuntu ' + }) + ).resolves.toBe('cursor agent') + expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', { + wslDistro: 'Ubuntu' + }) + }) +}) diff --git a/mobile/src/session/mobile-cursor-command.ts b/mobile/src/session/mobile-cursor-command.ts new file mode 100644 index 00000000000..db5d34df96c --- /dev/null +++ b/mobile/src/session/mobile-cursor-command.ts @@ -0,0 +1,45 @@ +import { resolveEffectiveCursorCommand } from '../../../src/shared/cursor-command' +import { detectedAgentInventorySchema } from '../../../src/shared/detected-agent-inventory' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileAiVaultResumeSettings } from './ai-vault-resume-launch' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' + +export async function resolveMobileCursorCommand(args: { + client: Pick + settings?: MobileAiVaultResumeSettings | null + wslDistro?: string | null +}): Promise { + const wslDistro = args.wslDistro?.trim() + const response = await args.client.sendRequest( + 'preflight.detectAgentInventory', + wslDistro ? { wslDistro } : undefined + ) + if (!response.ok) { + return resolveEffectiveCursorCommand(args.settings?.agentCmdOverrides?.cursor, null) + } + const result = detectedAgentInventorySchema.safeParse(response.result) + return resolveEffectiveCursorCommand( + args.settings?.agentCmdOverrides?.cursor, + result.success ? result.data : null + ) +} + +export async function resolveRequiredMobileCursorResumeCommand(args: { + client: Pick + settings?: MobileAiVaultResumeSettings | null + wslDistro?: string | null + session: Pick +}): Promise { + if (args.session.agent !== 'cursor') { + return null + } + const command = await resolveMobileCursorCommand(args) + if (!args.session.cwd || !command) { + throw new Error( + args.session.cwd + ? 'Cursor CLI not detected on this host.' + : 'Cursor did not record a resumable workspace for this session.' + ) + } + return command +} diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4ddb135affe..f220624f0bc 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -8,6 +8,7 @@ export type Worktree = { repoId: string hostId?: ExecutionHostId terminalPlatform?: NodeJS.Platform + wslDistro?: string repo: string branch: string displayName: string diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index d6d3af1930c..2165cfbd4ce 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -119,6 +119,12 @@ describe('areWorktreeListsEqual', () => { [worktree({ terminalPlatform: 'linux' })] ) ).toBe(false) + expect( + areWorktreeListsEqual( + [worktree({ wslDistro: 'Ubuntu' })], + [worktree({ wslDistro: 'Debian' })] + ) + ).toBe(false) }) it('detects lineage changes', () => { diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index bb9df6cf9af..cd285fd4a9c 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -27,6 +27,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean { left.branch === right.branch && (left.hostId ?? null) === (right.hostId ?? null) && (left.terminalPlatform ?? null) === (right.terminalPlatform ?? null) && + (left.wslDistro ?? null) === (right.wslDistro ?? null) && left.displayName === right.displayName && (left.workspaceStatus ?? null) === (right.workspaceStatus ?? null) && (left.sortOrder ?? null) === (right.sortOrder ?? null) && diff --git a/src/main/ai-vault/cached-remote-session-list.test.ts b/src/main/ai-vault/cached-remote-session-list.test.ts new file mode 100644 index 00000000000..ce9eb99f736 --- /dev/null +++ b/src/main/ai-vault/cached-remote-session-list.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import type { IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const { scanMock } = vi.hoisted(() => ({ + scanMock: vi.fn<() => Promise>() +})) + +vi.mock('./remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: scanMock +})) + +import { + listCachedRemoteAiVaultSessions, + resetCachedRemoteAiVaultSessionsForTests +} from './cached-remote-session-list' + +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-29T00:00:00.000Z' +} + +function args(provider: IFilesystemProvider, scopePaths: string[] = []) { + return { + provider, + executionHostId: 'ssh:dev' as const, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-29T00:00:00.000Z')) + scanMock.mockReset().mockResolvedValue(EMPTY_RESULT) + resetCachedRemoteAiVaultSessionsForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('listCachedRemoteAiVaultSessions', () => { + it('reuses completed results within 15 seconds and expires them afterward', async () => { + const provider = {} as IFilesystemProvider + await listCachedRemoteAiVaultSessions(args(provider)) + await listCachedRemoteAiVaultSessions(args(provider)) + expect(scanMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(15_001) + await listCachedRemoteAiVaultSessions(args(provider)) + expect(scanMock).toHaveBeenCalledTimes(2) + }) + + it('shares identical in-flight scans even when the later caller forces', async () => { + const provider = {} as IFilesystemProvider + let resolve: (result: AiVaultListResult) => void = () => {} + scanMock.mockReturnValueOnce(new Promise((done) => (resolve = done))) + + const first = listCachedRemoteAiVaultSessions(args(provider)) + const second = listCachedRemoteAiVaultSessions({ ...args(provider), force: true }) + expect(scanMock).toHaveBeenCalledTimes(1) + resolve(EMPTY_RESULT) + await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + }) + + it('isolates providers and bypasses only a completed entry on force', async () => { + const firstProvider = {} as IFilesystemProvider + const secondProvider = {} as IFilesystemProvider + await listCachedRemoteAiVaultSessions(args(firstProvider)) + await listCachedRemoteAiVaultSessions(args(secondProvider)) + await listCachedRemoteAiVaultSessions({ ...args(firstProvider), force: true }) + expect(scanMock).toHaveBeenCalledTimes(3) + }) + + it('keys only the effective normalized, deduped and truncated scope paths', async () => { + const provider = {} as IFilesystemProvider + const base = Array.from({ length: 64 }, (_, index) => `/repo/${String(index).padStart(2, '0')}`) + await listCachedRemoteAiVaultSessions(args(provider, [...base, base[0]])) + await listCachedRemoteAiVaultSessions(args(provider, base.toReversed())) + const overflow = await listCachedRemoteAiVaultSessions( + args(provider, [...base, '/zzz-ignored-overflow']) + ) + expect(scanMock).toHaveBeenCalledTimes(1) + expect(overflow.issues).toContainEqual( + expect.objectContaining({ + executionHostId: 'ssh:dev', + message: 'Cursor sidecar scan truncated by the scope paths limit.' + }) + ) + }) + + it('isolates host identity, platform, home, and limit within one provider generation', async () => { + const provider = {} as IFilesystemProvider + const base = args(provider) + await listCachedRemoteAiVaultSessions(base) + await listCachedRemoteAiVaultSessions({ ...base, executionHostId: 'ssh:other' }) + await listCachedRemoteAiVaultSessions({ ...base, remoteHome: '/root' }) + await listCachedRemoteAiVaultSessions({ + ...base, + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + await listCachedRemoteAiVaultSessions({ ...base, limit: 20 }) + expect(scanMock).toHaveBeenCalledTimes(5) + }) + + it('bounds completed cache keys per provider', async () => { + const provider = {} as IFilesystemProvider + for (let index = 0; index < 9; index += 1) { + await listCachedRemoteAiVaultSessions({ + ...args(provider), + limit: index + 1 + }) + vi.advanceTimersByTime(1) + } + await listCachedRemoteAiVaultSessions({ ...args(provider), limit: 1 }) + expect(scanMock).toHaveBeenCalledTimes(10) + }) + + it('evicts the least recently used completed key', async () => { + const provider = {} as IFilesystemProvider + for (let limit = 1; limit <= 8; limit++) { + await listCachedRemoteAiVaultSessions({ ...args(provider), limit }) + vi.advanceTimersByTime(1) + } + await listCachedRemoteAiVaultSessions({ ...args(provider), limit: 1 }) + vi.advanceTimersByTime(1) + await listCachedRemoteAiVaultSessions({ ...args(provider), limit: 9 }) + expect(scanMock).toHaveBeenCalledTimes(9) + + await listCachedRemoteAiVaultSessions({ ...args(provider), limit: 1 }) + expect(scanMock).toHaveBeenCalledTimes(9) + await listCachedRemoteAiVaultSessions({ ...args(provider), limit: 2 }) + expect(scanMock).toHaveBeenCalledTimes(10) + }) + + it('does not cache a failed scan and retries the same key', async () => { + const provider = {} as IFilesystemProvider + scanMock.mockRejectedValueOnce(new Error('relay unavailable')) + + await expect(listCachedRemoteAiVaultSessions(args(provider))).rejects.toThrow( + 'relay unavailable' + ) + await expect(listCachedRemoteAiVaultSessions(args(provider))).resolves.toBe(EMPTY_RESULT) + expect(scanMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ai-vault/cached-remote-session-list.ts b/src/main/ai-vault/cached-remote-session-list.ts new file mode 100644 index 00000000000..adf8122789f --- /dev/null +++ b/src/main/ai-vault/cached-remote-session-list.ts @@ -0,0 +1,154 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { + CURSOR_SIDECAR_SCAN_VERSION, + cursorRemoteScopePathsWereTruncated, + normalizeCursorRemoteScopePaths +} from '../../shared/cursor-sidecar-scan' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' + +const REMOTE_AI_VAULT_CACHE_TTL_MS = 15_000 +export const REMOTE_AI_VAULT_CACHE_MAX_KEYS_PER_PROVIDER = 8 + +type CacheEntry = { + result?: AiVaultListResult + expiresAt: number + inflight?: Promise + lastUsedAt: number +} + +let cacheByProvider = new WeakMap>() + +export async function listCachedRemoteAiVaultSessions(args: { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform + limit?: number + scopePaths?: readonly string[] + force?: boolean + signal?: AbortSignal +}): Promise { + const effectiveScopePaths = normalizeCursorRemoteScopePaths( + args.scopePaths ?? [], + args.hostPlatform.os + ) + const scopePathsTruncated = cursorRemoteScopePathsWereTruncated( + args.scopePaths ?? [], + args.hostPlatform.os + ) + const providerCache = providerEntries(args.provider) + const key = remoteRequestKey({ ...args, scopePaths: effectiveScopePaths }) + const existing = providerCache.get(key) + const now = Date.now() + if (existing?.inflight) { + existing.lastUsedAt = now + return withScopePathTruncationIssue(await existing.inflight, args, scopePathsTruncated) + } + if (args.force !== true && existing?.result && existing.expiresAt > now) { + existing.lastUsedAt = now + return withScopePathTruncationIssue(existing.result, args, scopePathsTruncated) + } + + const entry: CacheEntry = existing ?? { expiresAt: 0, lastUsedAt: now } + entry.lastUsedAt = now + const inflight = scanRemoteAiVaultSessions({ + provider: args.provider, + executionHostId: args.executionHostId, + remoteHome: args.remoteHome, + hostPlatform: args.hostPlatform, + limit: args.limit, + scopePaths: effectiveScopePaths, + signal: args.signal + }) + .then((result) => { + entry.result = result + entry.expiresAt = Date.now() + REMOTE_AI_VAULT_CACHE_TTL_MS + return result + }) + .finally(() => { + if (entry.inflight === inflight) { + entry.inflight = undefined + } + }) + entry.inflight = inflight + providerCache.delete(key) + providerCache.set(key, entry) + evictRemoteCacheKeys(providerCache, key) + return withScopePathTruncationIssue(await inflight, args, scopePathsTruncated) +} + +export function resetCachedRemoteAiVaultSessionsForTests(): void { + cacheByProvider = new WeakMap() +} + +function providerEntries(provider: IFilesystemProvider): Map { + const existing = cacheByProvider.get(provider) + if (existing) { + return existing + } + const created = new Map() + cacheByProvider.set(provider, created) + return created +} + +function remoteRequestKey(args: { + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform + limit?: number + scopePaths?: readonly string[] +}): string { + const scopePaths = [ + ...new Set((args.scopePaths ?? []).map((path) => path.trim()).filter(Boolean)) + ].sort() + return JSON.stringify({ + executionHostId: args.executionHostId, + remoteHome: args.remoteHome, + hostPlatform: args.hostPlatform, + limit: args.limit ?? 'default', + protocolVersion: CURSOR_SIDECAR_SCAN_VERSION, + scopePaths + }) +} + +function evictRemoteCacheKeys(entries: Map, activeKey: string): void { + while (entries.size > REMOTE_AI_VAULT_CACHE_MAX_KEYS_PER_PROVIDER) { + const candidate = [...entries.entries()] + .filter(([key, entry]) => key !== activeKey && !entry.inflight) + .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0] + if (!candidate) { + return + } + entries.delete(candidate[0]) + } +} + +function withScopePathTruncationIssue( + result: AiVaultListResult, + args: { + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform + }, + truncated: boolean +): AiVaultListResult { + if (!truncated) { + return result + } + return { + ...result, + issues: [ + ...result.issues, + { + executionHostId: args.executionHostId, + agent: 'cursor', + path: joinRemotePath(args.hostPlatform, args.remoteHome, '.cursor', 'chats'), + message: 'Cursor sidecar scan truncated by the scope paths limit.' + } + ] + } +} diff --git a/src/main/ai-vault/remote-session-scan-concurrency.test.ts b/src/main/ai-vault/remote-session-scan-concurrency.test.ts new file mode 100644 index 00000000000..7b226d02eb8 --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-concurrency.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY, + mapDirectSshScans +} from './remote-session-scan-concurrency' + +describe('mapDirectSshScans', () => { + it('caps concurrent scans and preserves input ordering', async () => { + let active = 0 + let maximum = 0 + const result = await mapDirectSshScans( + Array.from({ length: 12 }, (_, index) => index), + async (value) => { + active++ + maximum = Math.max(maximum, active) + await new Promise((resolve) => setTimeout(resolve, (value % 3) + 1)) + active-- + return value * 2 + }, + new AbortController().signal + ) + + expect(maximum).toBe(AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY) + expect(result).toEqual(Array.from({ length: 12 }, (_, index) => index * 2)) + }) + + it('does not start queued scans after cancellation', async () => { + const controller = new AbortController() + const releases: (() => void)[] = [] + const started: number[] = [] + const pending = mapDirectSshScans( + Array.from({ length: 8 }, (_, index) => index), + async (value) => { + started.push(value) + await new Promise((resolve) => releases.push(resolve)) + return value + }, + controller.signal + ) + await vi.waitFor(() => expect(started).toHaveLength(AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY)) + + controller.abort() + releases.splice(0).forEach((release) => release()) + + await expect(pending).rejects.toThrow('ai_vault_scan_cancelled') + expect(started).toHaveLength(AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY) + }) +}) diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts index 77b8e371b51..0879ebfba9f 100644 --- a/src/main/ai-vault/remote-session-scan-concurrency.ts +++ b/src/main/ai-vault/remote-session-scan-concurrency.ts @@ -13,9 +13,16 @@ export function limitRemoteScanFilesystemConcurrency( maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY ): RemoteSessionFilesystemProvider { const gate = createConcurrencyGate(maxInFlight) + const scanCursorSidecars = provider.scanCursorSidecars?.bind(provider) return { readDir: (dirPath) => gate(() => provider.readDir(dirPath)), readFile: (filePath) => gate(() => provider.readFile(filePath)), + ...(scanCursorSidecars + ? { + scanCursorSidecars: (request, options) => + gate(() => scanCursorSidecars(request, options)) + } + : {}), stat: (filePath) => gate(() => provider.stat(filePath)) } } @@ -45,3 +52,34 @@ function createConcurrencyGate(maxInFlight: number): (run: () => Promise) } } } + +export const AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY = 4 + +export async function mapDirectSshScans( + items: readonly T[], + mapper: (item: T, signal: AbortSignal) => Promise, + signal: AbortSignal +): Promise { + const results: U[] = [] + let nextIndex = 0 + async function worker(): Promise { + while (nextIndex < items.length) { + throwIfAborted(signal) + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(items[index], signal) + } + } + await Promise.all( + Array.from({ length: Math.min(AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY, items.length) }, () => + worker() + ) + ) + return results +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw new Error('ai_vault_scan_cancelled') + } +} diff --git a/src/main/ai-vault/remote-session-scanner-cursor-discovery.ts b/src/main/ai-vault/remote-session-scanner-cursor-discovery.ts new file mode 100644 index 00000000000..a28ac29fbd6 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-cursor-discovery.ts @@ -0,0 +1,201 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + cursorSidecarScanResponseSchema, + defaultCursorSidecarScanRequest, + type CursorSidecarScanResponse +} from '../../shared/cursor-sidecar-scan' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import { + cursorBucketForCwd, + cursorLegacySlug, + isAbsoluteCursorTargetPath +} from './session-scanner-cursor-paths' + +export type RemoteCursorSidecarDiscovery = { + candidates: RemoteSessionCandidate[] + scan: CursorSidecarScanResponse | null +} + +export async function discoverRemoteCursorSidecars(args: { + remoteHome: string + context: RemoteScannerContext + scopePaths: readonly string[] + issues: AiVaultScanIssue[] + signal?: AbortSignal +}): Promise { + const chatsRoot = joinRemotePath(args.context.hostPlatform, args.remoteHome, '.cursor', 'chats') + if (!args.context.provider.scanCursorSidecars) { + recordCapabilityIssue(args, chatsRoot) + return { candidates: [], scan: null } + } + + let scan: CursorSidecarScanResponse + try { + const request = defaultCursorSidecarScanRequest( + chatsRoot, + args.scopePaths, + args.context.hostPlatform.os + ) + scan = cursorSidecarScanResponseSchema.parse( + await args.context.provider.scanCursorSidecars(request, { signal: args.signal }) + ) + } catch (error) { + if ((error as Error).message === 'remote_cursor_sidecar_scan_unavailable') { + recordCapabilityIssue(args, chatsRoot) + } else { + args.issues.push({ + executionHostId: args.context.executionHostId, + agent: 'cursor', + path: chatsRoot, + message: `Cursor sidecar scan failed: ${errorMessage(error)}` + }) + } + return { candidates: [], scan: null } + } + + for (const issue of scan.issues) { + args.issues.push({ + executionHostId: args.context.executionHostId, + agent: 'cursor', + path: issue.path, + message: issue.message + }) + } + const source = remoteCursorSidecarSource(chatsRoot, args.context.executionHostId) + return { + candidates: scan.sidecars.map((sidecar) => { + const file: FileWithMtime = { + path: sidecar.metaPath, + mtimeMs: sidecar.metaMtimeMs, + modifiedAt: safeDateIso(sidecar.metaMtimeMs), + sizeBytes: sidecar.metaSizeBytes, + cursorStoreMtimeMs: sidecar.storeMtimeMs + } + const scopeCwd = validatedRemoteScopeCwd(sidecar, args.context, args.issues) + return { + source, + file, + cursorSidecarContent: sidecar.content, + ...(scopeCwd + ? { + cursorCwdEvidence: { + kind: 'scope-bucket' as const, + cwd: scopeCwd, + bucket: sidecar.bucket + } + } + : {}) + } + }), + scan + } +} + +function validatedRemoteScopeCwd( + sidecar: CursorSidecarScanResponse['sidecars'][number], + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): string | null { + if (!sidecar.scopeCwd) { + return null + } + if ( + isAbsoluteCursorTargetPath(sidecar.scopeCwd, context.hostPlatform.os) && + cursorBucketForCwd(sidecar.scopeCwd, context.hostPlatform.os) === sidecar.bucket + ) { + return sidecar.scopeCwd + } + issues.push({ + executionHostId: context.executionHostId, + agent: 'cursor', + path: sidecar.metaPath, + message: 'Cursor scope metadata does not match its storage bucket.' + }) + return null +} + +export function attachRemoteCursorLegacyScopeEvidence( + candidates: readonly RemoteSessionCandidate[], + scan: CursorSidecarScanResponse | null, + context: RemoteScannerContext +): RemoteSessionCandidate[] { + if (!scan || scan.scopeCwds.length === 0) { + return [...candidates] + } + const bySlug = new Map>() + for (const cwd of scan.scopeCwds) { + const slug = cursorLegacySlug(cwd) + if (!slug) { + continue + } + const buckets = bySlug.get(slug) ?? new Set() + buckets.add(cursorBucketForCwd(cwd, context.hostPlatform.os)) + bySlug.set(slug, buckets) + } + return candidates.map((candidate) => { + if (candidate.source.agent !== 'cursor' || candidate.source.cursorLayout !== 'legacy') { + return candidate + } + const slug = remoteLegacyProjectSlug(candidate) + const buckets = slug ? bySlug.get(slug) : undefined + if (!buckets || buckets.size !== 1) { + return candidate + } + return { + ...candidate, + cursorCwdEvidence: { + kind: 'legacy-scope-only', + cwd: null, + bucket: buckets.values().next().value as string + } + } + }) +} + +function remoteLegacyProjectSlug(candidate: RemoteSessionCandidate): string | null { + const root = candidate.source.rootDir.replace(/\\/gu, '/').replace(/\/+$/u, '') + const filePath = candidate.file.path.replace(/\\/gu, '/') + return filePath.startsWith(`${root}/`) + ? filePath.slice(root.length + 1).split('/')[0] || null + : null +} + +function remoteCursorSidecarSource( + rootDir: string, + storageContextKey: string +): RemoteSessionSource { + return { + agent: 'cursor', + rootDir, + extensions: ['.json'], + cursorLayout: 'sidecar', + cursorStorageContextKey: storageContextKey, + parse: async () => null + } +} + +function recordCapabilityIssue( + args: Pick[0], 'context' | 'issues'>, + chatsRoot: string +): void { + args.issues.push({ + executionHostId: args.context.executionHostId, + agent: 'cursor', + path: chatsRoot, + message: + 'Cursor session metadata scanning is unavailable on this SSH relay. Reconnect after updating Orca.' + }) +} + +function safeDateIso(value: number): string { + return Math.abs(value) <= 8_640_000_000_000_000 + ? new Date(value).toISOString() + : new Date(0).toISOString() +} diff --git a/src/main/ai-vault/remote-session-scanner-cursor.test.ts b/src/main/ai-vault/remote-session-scanner-cursor.test.ts new file mode 100644 index 00000000000..7dcb57ec262 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-cursor.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-memory-provider' +import { cursorBucketForCwd, cursorLegacySlug } from './session-scanner-cursor-paths' + +describe('scanRemoteAiVaultSessions Cursor sidecars', () => { + it('reconciles one bounded SSH sidecar scan with its legacy transcript', async () => { + const provider = new MemoryRemoteProvider() + const cwd = '/home/ada/repo' + const sessionId = 'cursor-session' + const bucket = cursorBucketForCwd(cwd, 'linux') + const metaPath = `/home/ada/.cursor/chats/${bucket}/${sessionId}/meta.json` + const sidecarContent = JSON.stringify({ + createdAtMs: 1_750_000_000_000, + hasConversation: true, + title: 'Remote Cursor' + }) + const transcriptPath = + `/home/ada/.cursor/projects/${cursorLegacySlug(cwd)}` + + `/agent-transcripts/${sessionId}/${sessionId}.jsonl` + provider.cursorScanResponse = { + version: 1, + scopeCwds: [cwd], + sidecars: [ + { + bucket, + sessionId, + metaPath, + content: sidecarContent, + metaMtimeMs: 1_750_000_000_100, + metaSizeBytes: 100, + storeMtimeMs: 1_750_000_000_200, + scopeCwd: cwd + } + ], + issues: [], + counters: { + rootReaddir: 1, + bucketReaddir: 1, + fileLstat: 2, + boundedReads: 1, + scopeRealpath: 1, + returnedBytes: Buffer.byteLength(sidecarContent), + elapsedMs: 1 + }, + truncated: { + scopePaths: false, + buckets: false, + sessionDirs: false, + sidecarBytes: false + } + } + provider.addFile( + transcriptPath, + jsonLines([ + { + role: 'user', + message: { content: [{ type: 'text', text: 'Legacy preview' }] } + } + ]), + 1_750_000_000_300 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: [cwd] + }) + + expect(provider.cursorScanCalls).toBe(1) + expect(provider.readFilePaths).not.toContain(metaPath) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + id: 'ssh:dev-box:cursor:cursor-session', + cwd, + filePath: metaPath, + transcriptFilePath: transcriptPath, + hasConversation: true + }) + }) + + it('rejects relay scope cwd evidence that does not hash to the returned bucket', async () => { + const provider = new MemoryRemoteProvider() + const content = JSON.stringify({ + createdAtMs: 1_750_000_000_000, + hasConversation: true + }) + provider.cursorScanResponse = { + version: 1, + scopeCwds: ['/home/ada/wrong'], + sidecars: [ + { + bucket: '11111111111111111111111111111111', + sessionId: 'mismatched-scope', + metaPath: + '/home/ada/.cursor/chats/11111111111111111111111111111111/mismatched-scope/meta.json', + content, + metaMtimeMs: 1_750_000_000_100, + metaSizeBytes: Buffer.byteLength(content), + storeMtimeMs: 1_750_000_000_200, + scopeCwd: '/home/ada/wrong' + } + ], + issues: [], + counters: { + rootReaddir: 1, + bucketReaddir: 1, + fileLstat: 2, + boundedReads: 1, + scopeRealpath: 1, + returnedBytes: Buffer.byteLength(content), + elapsedMs: 1 + }, + truncated: { + scopePaths: false, + buckets: false, + sessionDirs: false, + sidecarBytes: false + } + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: ['/home/ada/wrong'] + }) + + expect(result.sessions[0]?.cwd).toBeNull() + expect(result.issues).toContainEqual( + expect.objectContaining({ + executionHostId: 'ssh:dev-box', + message: 'Cursor scope metadata does not match its storage bucket.' + }) + ) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index a309f51077b..1d0e4d92682 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -15,7 +15,7 @@ import type { RemoteSessionSource } from './remote-session-scanner-types' -const REMOTE_DISCOVERY_CONCURRENCY = 8 +export const REMOTE_DISCOVERY_CONCURRENCY = 8 export async function discoverRemoteSourceCandidates(args: { source: RemoteSessionSource diff --git a/src/main/ai-vault/remote-session-scanner-memory-provider.ts b/src/main/ai-vault/remote-session-scanner-memory-provider.ts new file mode 100644 index 00000000000..f0b9e0b9cb6 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-memory-provider.ts @@ -0,0 +1,130 @@ +import type { + CursorSidecarScanRequest, + CursorSidecarScanResponse +} from '../../shared/cursor-sidecar-scan' +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] + readonly readFilePaths: string[] = [] + cursorScanCalls = 0 + cursorScanResponse: CursorSidecarScanResponse | null = null + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (name) { + entries.set(name, { name, isDirectory: rest.length > 0, isSymlink: false }) + } + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + this.readFilePaths.push(normalize(filePath)) + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + async scanCursorSidecars(_request: CursorSidecarScanRequest): Promise { + this.cursorScanCalls++ + return this.cursorScanResponse ?? emptyCursorScanResponse() + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +function emptyCursorScanResponse(): CursorSidecarScanResponse { + return { + version: 1, + scopeCwds: [], + sidecars: [], + issues: [], + counters: { + rootReaddir: 0, + bucketReaddir: 0, + fileLstat: 0, + boundedReads: 0, + scopeRealpath: 0, + returnedBytes: 0, + elapsedMs: 0 + }, + truncated: { + scopePaths: false, + buckets: false, + sessionDirs: false, + sidecarBytes: false + } + } +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index d704c2da671..5a86536a462 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -67,14 +67,18 @@ export function remoteSessionSources( ['.copilot', 'session-state'], parseCopilotSessionContent ), - jsonlSource( - 'cursor', - remoteHome, - hostPlatform, - ['.cursor', 'projects'], - parseCursorSessionContent, - (path) => remotePathSegments(path).includes('agent-transcripts') - ), + { + ...jsonlSource( + 'cursor', + remoteHome, + hostPlatform, + ['.cursor', 'projects'], + parseCursorSessionContent, + (path) => remotePathSegments(path).includes('agent-transcripts') + ), + cursorLayout: 'legacy', + cursorStorageContextKey: '' + }, source( 'hermes', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 58fb255e3cc..4bf113fab51 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -2,7 +2,7 @@ import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import type { FileWithMtime } from './session-scanner-types' +import type { CursorCwdEvidence, CursorLayout, FileWithMtime } from './session-scanner-types' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { @@ -16,7 +16,7 @@ export type RemoteScannerContext = { export type RemoteSessionFilesystemProvider = Pick< IFilesystemProvider, - 'readDir' | 'readFile' | 'stat' + 'readDir' | 'readFile' | 'scanCursorSidecars' | 'stat' > export type RemoteParserOptions = { @@ -39,6 +39,8 @@ export type RemoteSessionSource = { // Claude layout: count `/subagents/*.jsonl` siblings from the walked // listing and drop them from candidates instead of indexing them as sessions. collectSubagentSiblingCounts?: boolean + cursorLayout?: CursorLayout + cursorStorageContextKey?: string parse: ( file: FileWithMtime, content: string, @@ -50,4 +52,6 @@ export type RemoteSessionCandidate = { source: RemoteSessionSource file: FileWithMtime subagentTranscriptCount?: number + cursorCwdEvidence?: CursorCwdEvidence + cursorSidecarContent?: string } diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a4705ee1bfb..e8ea82b8f9f 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,104 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - private readonly readDirErrors = new Map() - private readonly statErrors = new Map() - readonly readDirPaths: string[] = [] - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - failStat(path: string, error: Error): void { - this.statErrors.set(normalize(path), error) - } - - failReadDir(path: string, error: Error): void { - this.readDirErrors.set(normalize(path), error) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - this.readDirPaths.push(dir) - const readDirError = this.readDirErrors.get(dir) - if (readDirError) { - throw readDirError - } - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const statError = this.statErrors.get(normalize(filePath)) - if (statError) { - throw statError - } - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-memory-provider' describe('scanRemoteAiVaultSessions', () => { it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 2523cd22a43..a4f125ec757 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -7,6 +7,7 @@ import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { withSpan, type ActiveSpan } from '../observability/tracer' import { codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, @@ -20,6 +21,7 @@ import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' import { sessionSortTime } from './session-scanner-accumulator' +import { mergeAiVaultSessions } from './session-list-results' import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' @@ -27,6 +29,11 @@ import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' +import { + attachRemoteCursorLegacyScopeEvidence, + discoverRemoteCursorSidecars +} from './remote-session-scanner-cursor-discovery' +import { processRemoteCursorCandidates } from './session-scanner-cursor-remote-pipeline' const REMOTE_SCAN_CONCURRENCY = 8 const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2 @@ -36,7 +43,7 @@ const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2 // silently dropped older in-scope sessions the scope contract guarantees. const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000 -export async function scanRemoteAiVaultSessions(args: { +type RemoteAiVaultScanArgs = { provider: RemoteSessionFilesystemProvider executionHostId: ExecutionHostId remoteHome: string @@ -45,7 +52,18 @@ export async function scanRemoteAiVaultSessions(args: { unlimited?: boolean scopePaths?: readonly string[] signal?: AbortSignal -}): Promise { +} + +export async function scanRemoteAiVaultSessions( + args: RemoteAiVaultScanArgs +): Promise { + return withSpan('aiVault.scan', (span) => scanRemoteAiVaultSessionsInSpan(args, span)) +} + +async function scanRemoteAiVaultSessionsInSpan( + args: RemoteAiVaultScanArgs, + span: ActiveSpan +): Promise { throwIfAiVaultScanCancelled(args.signal) const limit = aiVaultScanLimit(args) const issues: AiVaultScanIssue[] = [] @@ -72,17 +90,30 @@ export async function scanRemoteAiVaultSessions(args: { } }) } + const [genericCandidates, cursorSidecars] = await Promise.all([ + mapRemoteScanBatches( + remoteSessionSources(args.remoteHome, args.hostPlatform), + REMOTE_SCAN_CONCURRENCY, + (source) => discoverRemoteSourceCandidates({ source, context, issues }), + args.signal + ), + discoverRemoteCursorSidecars({ + remoteHome: args.remoteHome, + context, + scopePaths: args.scopePaths ?? [], + issues, + signal: args.signal + }) + ]) + const scopedGenericCandidates = attachRemoteCursorLegacyScopeEvidence( + genericCandidates.flat(), + cursorSidecars.scan, + context + ) const candidates = dedupeCodexRolloutFileAliases( - ( - await mapRemoteScanBatches( - remoteSessionSources(args.remoteHome, args.hostPlatform), - REMOTE_SCAN_CONCURRENCY, - (source) => discoverRemoteSourceCandidates({ source, context, issues }), - args.signal - ) - ) - .flat() - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), + [...scopedGenericCandidates, ...cursorSidecars.candidates].sort( + (left, right) => right.file.mtimeMs - left.file.mtimeMs + ), { isCodex: (candidate) => candidate.source.agent === 'codex', getFilePath: (candidate) => candidate.file.path, @@ -91,14 +122,27 @@ export async function scanRemoteAiVaultSessions(args: { } ) + const cursorCandidates = candidates.filter((candidate) => candidate.source.agent === 'cursor') + const nonCursorCandidates = candidates.filter((candidate) => candidate.source.agent !== 'cursor') + const cursorResult = await processRemoteCursorCandidates({ + candidates: cursorCandidates, + limit, + scopeLimit: REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT, + context, + issues, + span, + scan: cursorSidecars.scan, + parseLegacy: (candidate) => parseRemoteSessionCandidate(candidate, context, issues) + }) + throwIfAiVaultScanCancelled(args.signal) const parsed = await parseRemoteSessionCandidates({ - candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER), + candidates: nonCursorCandidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER), context, issues, limit }) const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions) - const cappedSessions = parsedSessions + const cappedSessions = [...parsedSessions, ...cursorResult.sessions] .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) @@ -106,7 +150,7 @@ export async function scanRemoteAiVaultSessions(args: { isRemoteSessionInScope(session, scopePaths) ) const extraScopeSessions = await scanRemoteInScopeSessions({ - candidates, + candidates: nonCursorCandidates, context, issues, scopePaths, @@ -115,13 +159,14 @@ export async function scanRemoteAiVaultSessions(args: { }) const scopeSessions = dedupeCodexSessionsBySessionId([ ...parsedScopeSessions, - ...extraScopeSessions + ...extraScopeSessions, + ...cursorResult.sessions.filter((session) => cursorResult.scopedSessionIds.has(session.id)) ]) .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) - + throwIfAiVaultScanCancelled(args.signal) return { - sessions: mergeRemoteSessions(cappedSessions, scopeSessions), + sessions: mergeAiVaultSessions(cappedSessions, scopeSessions), issues, scannedAt: new Date().toISOString() } @@ -251,23 +296,6 @@ async function parseRemoteSessionCandidate( } } -function mergeRemoteSessions( - cappedSessions: AiVaultSession[], - scopeSessions: AiVaultSession[] -): AiVaultSession[] { - if (scopeSessions.length === 0) { - return cappedSessions - } - const byId = new Map() - for (const session of cappedSessions) { - byId.set(session.id, session) - } - for (const session of scopeSessions) { - byId.set(session.id, session) - } - return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) -} - function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean { const cwd = session.cwd return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 922a6af96d7..0e6082af30e 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -127,6 +127,27 @@ describe('runtime AI Vault session scanner', () => { ]) }) + it('preserves metadata-only conversation and explicit transcript absence', async () => { + const cursor = { + ...session('local', 'cursor-session'), + agent: 'cursor' as const, + messageCount: 0, + hasConversation: true, + transcriptFilePath: null + } + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: result([cursor]) + }) + + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {}) + expect(scanResult.sessions[0]).toMatchObject({ + agent: 'cursor', + hasConversation: true, + transcriptFilePath: null + }) + }) + it('prepares a resume on the transcript-owning runtime', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 1c1c248ee31..9b497631ca0 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -12,6 +12,7 @@ import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' +import { buildAiVaultSessionId } from '../../shared/ai-vault-session-id' import { parseAiVaultListResult } from './session-list-result-validation' export type RuntimeAiVaultHostInfo = { @@ -145,7 +146,13 @@ function retagRuntimeSession( return { ...session, executionHostId, - id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + id: buildAiVaultSessionId({ + executionHostId, + agent: session.agent, + sessionId: session.sessionId, + filePath: session.filePath, + previousId: session.id + }) } } diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 6d69a06af45..f5d0ee3356b 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -47,6 +47,8 @@ const aiVaultSessionSchema = z.object({ updatedAt: z.string().nullable(), modifiedAt: z.string(), messageCount: z.number(), + hasConversation: z.boolean().optional(), + transcriptFilePath: z.string().nullable().optional(), totalTokens: z.number(), previewMessages: z.array(sessionPreviewMessageSchema), previewMessagesTruncated: z.boolean().optional(), diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index 54e2fff7d5a..f04b16244c5 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -4,8 +4,9 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { sessionSortTime } from './session-scanner-accumulator' +import { buildAiVaultSessionId } from '../../shared/ai-vault-session-id' import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' +import { sessionSortTime } from './session-scanner-accumulator' export function aiVaultScanIssueResult(args: { executionHostId?: ExecutionHostId @@ -48,7 +49,13 @@ export function restampAiVaultListResult( : { ...session, executionHostId, - id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + id: buildAiVaultSessionId({ + executionHostId, + agent: session.agent, + sessionId: session.sessionId, + filePath: session.filePath, + previousId: session.id + }) } ), issues: result.issues.map((issue) => ({ ...issue, executionHostId })), @@ -78,3 +85,17 @@ export function mergeAiVaultListResults( scannedAt: new Date().toISOString() } } + +export function mergeAiVaultSessions( + cappedSessions: readonly AiVaultSession[], + scopeSessions: readonly AiVaultSession[] +): AiVaultSession[] { + if (scopeSessions.length === 0) { + return [...cappedSessions] + } + const byId = new Map(cappedSessions.map((session) => [session.id, session])) + for (const session of scopeSessions) { + byId.set(session.id, session) + } + return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index caf08f4259d..cea3e14ca57 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -7,6 +7,7 @@ import { } from '../../shared/ai-vault-types' import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { buildAiVaultSessionId } from '../../shared/ai-vault-session-id' import type { FileWithMtime, ResumableSessionParseState, @@ -101,7 +102,12 @@ export function finalizeSession( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID return { - id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + id: buildAiVaultSessionId({ + executionHostId, + agent: accumulator.agent, + sessionId, + filePath: accumulator.filePath + }), executionHostId, ...(options.executionHostPlatform ? { executionHostPlatform: options.executionHostPlatform } diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 1c2c9e84e8e..ad1801365bf 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -41,6 +41,9 @@ export async function parseAgentSessionFile( case 'copilot': return parseCopilotSessionFile(candidate.file, platform) case 'cursor': + if (candidate.cursorLayout === 'sidecar') { + return null + } return parseCursorSessionFile(candidate.file, platform) case 'opencode': { // Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index d8a8e2850ac..14cdabe921f 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), + cursorChatsDir: join(root, 'cursor-chats'), opencodeStorageDir: join(root, 'opencode-storage'), opencodeDbPaths: [], grokSessionsDir: join(root, 'grok-sessions'), diff --git a/src/main/ai-vault/session-scanner-cursor-integration.test.ts b/src/main/ai-vault/session-scanner-cursor-integration.test.ts new file mode 100644 index 00000000000..96e4dc73393 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-integration.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { cursorBucketForCwd, cursorLegacySlug } from './session-scanner-cursor-paths' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Cursor session scanner integration', () => { + it('attributes a cwd-less sidecar to an exact folder-workspace scope and merges legacy data', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-integration-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspace = join(root, 'folder-workspace') + const sessionId = 'opaque-session' + const bucket = cursorBucketForCwd(workspace, 'linux') + const sessionDir = join(roots.cursorChatsDir, bucket, sessionId) + const transcriptPath = join( + roots.cursorProjectsDir, + cursorLegacySlug(workspace), + 'agent-transcripts', + sessionId, + `${sessionId}.jsonl` + ) + await Promise.all([ + mkdir(workspace, { recursive: true }), + mkdir(sessionDir, { recursive: true }), + mkdir(join(transcriptPath, '..'), { recursive: true }) + ]) + await Promise.all([ + writeFile( + join(sessionDir, 'meta.json'), + JSON.stringify({ + createdAtMs: 1_750_000_000_000, + updatedAtMs: 1_750_000_001_000, + hasConversation: true, + title: 'Scoped Cursor session' + }) + ), + writeFile(join(sessionDir, 'store.db'), ''), + writeFile( + transcriptPath, + jsonLines([ + { role: 'user', message: { content: [{ type: 'text', text: 'Legacy prompt' }] } }, + { role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } } + ]) + ) + ]) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + executionHostId: 'local', + scopePaths: [workspace], + limit: 1 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + id: `local:cursor:${sessionId}`, + cwd: workspace, + filePath: join(sessionDir, 'meta.json'), + transcriptFilePath: transcriptPath, + title: 'Scoped Cursor session', + messageCount: 2, + hasConversation: true + }) + }) + + it('backfills only the matching bucket when one session id collides across buckets', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-collision-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const scopePath = join(root, 'scoped-workspace') + const otherPaths = [join(root, 'newest-workspace'), join(root, 'middle-workspace')] + const sessionId = 'shared-session-id' + await mkdir(scopePath, { recursive: true }) + + const sessionPaths = await Promise.all( + [scopePath, ...otherPaths].map(async (cwd, index) => { + const sessionDir = join(roots.cursorChatsDir, cursorBucketForCwd(cwd, 'linux'), sessionId) + await mkdir(sessionDir, { recursive: true }) + await Promise.all([ + writeFile( + join(sessionDir, 'meta.json'), + JSON.stringify({ + createdAtMs: 1_750_000_000_000 + index, + updatedAtMs: 1_750_000_001_000 + index, + hasConversation: true, + title: `Collision ${index}` + }) + ), + writeFile(join(sessionDir, 'store.db'), '') + ]) + return join(sessionDir, 'meta.json') + }) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + executionHostId: 'local', + scopePaths: [scopePath], + limit: 1 + }) + + expect(result.sessions.map((session) => session.filePath).sort()).toEqual( + [sessionPaths[0], sessionPaths[2]].sort() + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-local-files.ts b/src/main/ai-vault/session-scanner-cursor-local-files.ts new file mode 100644 index 00000000000..7c2be4f7050 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-local-files.ts @@ -0,0 +1,145 @@ +import { lstat, readdir, realpath } from 'node:fs/promises' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + CURSOR_SIDECAR_MAX_BYTES, + cursorSessionStorePath, + isCursorSidecarDirectory, + isCursorSidecarPath +} from './session-scanner-cursor-paths' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' + +export async function discoverLocalCursorSidecars( + chatsDir: string, + issues: AiVaultScanIssue[] +): Promise { + let buckets + try { + buckets = await readdir(chatsDir, { withFileTypes: true }) + } catch (error) { + if (!isMissingCursorPathError(error)) { + issues.push({ agent: 'cursor', path: chatsDir, message: errorMessage(error) }) + } + return [] + } + const files: FileWithMtime[] = [] + for (const bucket of buckets) { + if ( + !bucket.isDirectory() || + bucket.isSymbolicLink() || + !isCursorSidecarDirectory(bucket.name, 0) + ) { + continue + } + const bucketDir = join(chatsDir, bucket.name) + let sessions + try { + sessions = await readdir(bucketDir, { withFileTypes: true }) + } catch (error) { + if (!isMissingCursorPathError(error)) { + issues.push({ agent: 'cursor', path: bucketDir, message: errorMessage(error) }) + } + continue + } + for (const session of sessions) { + if ( + session.isDirectory() && + !session.isSymbolicLink() && + isCursorSidecarDirectory(session.name, 1) + ) { + const metaPath = join(bucketDir, session.name, 'meta.json') + if (isCursorSidecarPath(chatsDir, metaPath)) { + const file = await cursorLocalFileMetadata(metaPath) + if (file) { + files.push(file) + } + } + } + } + } + return files +} + +export async function validateLocalCursorSidecars( + files: readonly FileWithMtime[], + issues: AiVaultScanIssue[] +): Promise { + const retained: FileWithMtime[] = [] + for (const file of files) { + try { + const [metaStat, storeStat] = await Promise.all([ + lstat(file.path), + lstat(cursorSessionStorePath(file.path)) + ]) + if ( + !metaStat.isFile() || + metaStat.isSymbolicLink() || + !storeStat.isFile() || + storeStat.isSymbolicLink() + ) { + continue + } + if (metaStat.size > CURSOR_SIDECAR_MAX_BYTES) { + issues.push({ + agent: 'cursor', + path: file.path, + message: 'Cursor session metadata exceeds the read limit.' + }) + continue + } + retained.push({ + ...file, + sizeBytes: metaStat.size, + cursorStoreMtimeMs: storeStat.mtimeMs + }) + } catch (error) { + if (!isMissingCursorPathError(error)) { + issues.push({ agent: 'cursor', path: file.path, message: errorMessage(error) }) + } + } + } + return retained +} + +export async function localCursorRootRealPath( + chatsDir: string, + issues: AiVaultScanIssue[] +): Promise { + try { + return await realpath(chatsDir) + } catch (error) { + if (!isMissingCursorPathError(error)) { + issues.push({ agent: 'cursor', path: chatsDir, message: errorMessage(error) }) + } + return null + } +} + +export async function cursorLocalFileMetadata(filePath: string): Promise { + try { + const fileStat = await lstat(filePath) + if (!fileStat.isFile() || fileStat.isSymbolicLink()) { + return null + } + return { + path: filePath, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + } + } catch { + return null + } +} + +export function isMissingCursorPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} diff --git a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts new file mode 100644 index 00000000000..1ff1a702c5e --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts @@ -0,0 +1,152 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { ActiveSpan } from '../observability/tracer' +import { parseAgentSessionFileCached } from './session-scanner-parse-cache' +import type { SessionParseStats } from './session-scanner-parse-cache' +import { recordCursorScanSpan } from './session-scanner-cursor-observability' +import { + reconcileCursorCandidates, + type ParsedCursorCandidate +} from './session-scanner-cursor-reconcile' +import { parseCursorSidecarFileCached } from './session-scanner-cursor-sidecar' +import type { SessionFileCandidate } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import { + buildCursorCandidateSelectionGroups, + canStopCursorGroupSelection, + selectCursorScopedGroups, + type CursorCandidateSelectionGroup +} from './session-scanner-cursor-selection' + +const CURSOR_PARSE_CONCURRENCY = 8 + +export async function processLocalCursorCandidates(args: { + candidates: readonly SessionFileCandidate[] + limit: number + scopeLimit: number + platform: NodeJS.Platform + executionHostId: ExecutionHostId + issues: AiVaultScanIssue[] + parseStats: SessionParseStats + span: ActiveSpan +}): Promise> { + const groups = buildCursorCandidateSelectionGroups({ + candidates: args.candidates, + platform: args.platform, + adapter: { + getCwdEvidence: (candidate) => candidate.cursorCwdEvidence, + getFile: (candidate) => candidate.file, + getLayout: (candidate) => candidate.cursorLayout ?? 'legacy', + getStorageContextKey: (candidate) => candidate.cursorStorageContextKey ?? 'native' + } + }) + const parsed: ParsedCursorCandidate[] = [] + const processedGroups = new Set>() + for (let index = 0; index < groups.length; index += CURSOR_PARSE_CONCURRENCY) { + const preview = reconcileCursorCandidates({ + candidates: parsed, + executionHostId: args.executionHostId, + platform: args.platform, + issues: [] + }) + if (canStopCursorGroupSelection(preview.sessions, args.limit, groups[index]?.mtimeMs)) { + break + } + const batch = groups.slice(index, index + CURSOR_PARSE_CONCURRENCY) + await parseCursorGroups(batch, parsed, args) + batch.forEach((group) => processedGroups.add(group)) + } + const scopedGroups = selectCursorScopedGroups(groups, processedGroups, args.scopeLimit) + for (let index = 0; index < scopedGroups.length; index += CURSOR_PARSE_CONCURRENCY) { + const batch = scopedGroups.slice(index, index + CURSOR_PARSE_CONCURRENCY) + await parseCursorGroups(batch, parsed, args) + } + const reconciled = reconcileCursorCandidates({ + candidates: parsed, + executionHostId: args.executionHostId, + platform: args.platform, + issues: args.issues + }) + recordCursorScanSpan({ + span: args.span, + sidecarCandidatePaths: args.candidates + .filter((candidate) => candidate.cursorLayout === 'sidecar') + .map((candidate) => candidate.file.path), + parsedCandidates: parsed, + issues: args.issues, + reconcileStats: reconciled.stats + }) + return reconciled +} + +async function parseCursorGroups( + groups: readonly CursorCandidateSelectionGroup[], + parsed: ParsedCursorCandidate[], + args: { + platform: NodeJS.Platform + executionHostId: ExecutionHostId + issues: AiVaultScanIssue[] + parseStats: SessionParseStats + } +): Promise { + const candidates = groups.flatMap((group) => group.candidates) + for (let index = 0; index < candidates.length; index += CURSOR_PARSE_CONCURRENCY) { + const batch = candidates.slice(index, index + CURSOR_PARSE_CONCURRENCY) + const results = await Promise.all( + batch.map((candidate) => parseCursorCandidate(candidate, args)) + ) + parsed.push(...results.filter((result): result is ParsedCursorCandidate => Boolean(result))) + } +} + +async function parseCursorCandidate( + candidate: SessionFileCandidate, + args: { + platform: NodeJS.Platform + executionHostId: ExecutionHostId + issues: AiVaultScanIssue[] + parseStats: SessionParseStats + } +): Promise { + const layout = candidate.cursorLayout ?? 'legacy' + try { + if (layout === 'sidecar') { + const result = await parseCursorSidecarFileCached({ + file: candidate.file, + platform: args.platform, + executionHostId: args.executionHostId, + expectedRootRealPath: candidate.cursorExpectedRootRealPath + }) + if (result.issue) { + args.issues.push(result.issue) + } + return result.evidence + ? { + layout, + storageContextKey: candidate.cursorStorageContextKey ?? 'native', + file: candidate.file, + cwdEvidence: candidate.cursorCwdEvidence, + sidecar: result.evidence + } + : null + } + const legacy = await parseAgentSessionFileCached(candidate, args.platform, args.parseStats) + return legacy + ? { + layout, + storageContextKey: candidate.cursorStorageContextKey ?? 'native', + file: candidate.file, + cwdEvidence: candidate.cursorCwdEvidence, + legacy + } + : null + } catch (error) { + args.issues.push({ + executionHostId: args.executionHostId, + agent: 'cursor', + path: candidate.file.path, + message: errorMessage(error) + }) + return null + } +} diff --git a/src/main/ai-vault/session-scanner-cursor-materialize.ts b/src/main/ai-vault/session-scanner-cursor-materialize.ts new file mode 100644 index 00000000000..368ceab57ba --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-materialize.ts @@ -0,0 +1,178 @@ +import { + isAiVaultSessionResumableContent, + type AiVaultScanIssue, + type AiVaultSession +} from '../../shared/ai-vault-types' +import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command' +import { buildAiVaultSessionId } from '../../shared/ai-vault-session-id' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { CursorSidecarEvidence } from './session-scanner-cursor-sidecar' +import type { + CursorPhysicalGroup, + CursorReconcileStats, + ParsedCursorCandidate +} from './session-scanner-cursor-reconcile' + +export function materializeCursorSession(args: { + group: CursorPhysicalGroup + executionHostId: ExecutionHostId + platform: NodeJS.Platform + issues: AiVaultScanIssue[] + commandOverride?: string | null + stats: CursorReconcileStats +}): AiVaultSession | null { + const sidecars = args.group.candidates.filter( + (candidate): candidate is ParsedCursorCandidate & { sidecar: CursorSidecarEvidence } => + Boolean(candidate.sidecar) + ) + const legacy = args.group.candidates.filter( + (candidate): candidate is ParsedCursorCandidate & { legacy: AiVaultSession } => + Boolean(candidate.legacy) + ) + if (sidecars.some((candidate) => candidate.sidecar.isSubagent)) { + args.stats.suppressedSubagents++ + return null + } + const legacyWithContent = legacy.filter((candidate) => + isAiVaultSessionResumableContent(candidate.legacy) + ) + if ( + !sidecars.some((candidate) => candidate.sidecar.hasConversation) && + legacyWithContent.length === 0 + ) { + args.stats.falseOnlyGroups++ + return null + } + + const sidecarSource = [...sidecars].sort(compareSidecars)[0] + const legacySource = [...legacy].sort(compareLegacy)[0] + if (sidecarSource && legacySource) { + args.stats.reconciledCounterparts++ + } + const strongCwd = selectStrongCwd(args.group.candidates, args.executionHostId, args.issues) + const filePath = sidecarSource?.file.path ?? legacySource?.file.path + if (!filePath) { + return null + } + const modifiedAt = latestIso([ + sidecarSource?.sidecar.updatedAt, + legacySource?.legacy.updatedAt, + legacySource?.legacy.modifiedAt + ]) + const transcriptFilePath = legacySource?.file.path ?? null + + return { + id: buildAiVaultSessionId({ + executionHostId: args.executionHostId, + agent: 'cursor', + sessionId: args.group.sessionId, + filePath, + cursorStorageContextKey: args.group.storageContextKey, + cursorBucketCollision: args.group.bucketCollision, + cursorLegacyPathCollision: args.group.legacyPathCollision + }), + executionHostId: args.executionHostId, + executionHostPlatform: args.platform, + agent: 'cursor', + sessionId: args.group.sessionId, + title: + sidecarSource?.sidecar.title ?? + legacySource?.legacy.title ?? + `Cursor ${args.group.sessionId.slice(0, 8)}`, + cwd: strongCwd, + branch: legacySource?.legacy.branch ?? null, + model: null, + filePath, + codexHome: null, + createdAt: earliestIso([sidecarSource?.sidecar.createdAt, legacySource?.legacy.createdAt]), + updatedAt: latestNullableIso([ + sidecarSource?.sidecar.updatedAt, + legacySource?.legacy.updatedAt + ]), + modifiedAt, + messageCount: legacySource?.legacy.messageCount ?? 0, + hasConversation: sidecars.some((candidate) => candidate.sidecar.hasConversation), + transcriptFilePath, + totalTokens: legacySource?.legacy.totalTokens ?? 0, + previewMessages: legacySource?.legacy.previewMessages ?? [], + ...(legacySource?.legacy.lastUserPrompt + ? { lastUserPrompt: legacySource.legacy.lastUserPrompt } + : {}), + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: buildAiVaultResumeCommand({ + agent: 'cursor', + sessionId: args.group.sessionId, + cwd: strongCwd, + platform: args.platform, + commandOverride: args.commandOverride + }), + subagent: null + } +} + +function selectStrongCwd( + candidates: readonly ParsedCursorCandidate[], + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[] +): string | null { + const scope = candidates + .map((candidate) => candidate.cwdEvidence) + .find((evidence) => evidence?.kind === 'scope-bucket' && evidence.cwd)?.cwd + const sidecar = candidates + .map((candidate) => candidate.sidecar?.cwdEvidence) + .find((evidence) => evidence?.kind === 'sidecar-bucket-match' && evidence.cwd)?.cwd + if (scope && sidecar && scope !== sidecar) { + issues.push({ + executionHostId, + agent: 'cursor', + path: candidates[0].file.path, + message: 'Cursor scope path conflicts with recorded session metadata; using scope evidence.' + }) + } + return scope ?? sidecar ?? null +} + +function compareSidecars( + left: ParsedCursorCandidate & { sidecar: CursorSidecarEvidence }, + right: ParsedCursorCandidate & { sidecar: CursorSidecarEvidence } +): number { + const scopeDelta = + Number(right.cwdEvidence?.kind === 'scope-bucket') - + Number(left.cwdEvidence?.kind === 'scope-bucket') + return ( + scopeDelta || + Date.parse(right.sidecar.updatedAt) - Date.parse(left.sidecar.updatedAt) || + right.file.mtimeMs - left.file.mtimeMs || + left.file.path.localeCompare(right.file.path) + ) +} + +function compareLegacy( + left: ParsedCursorCandidate & { legacy: AiVaultSession }, + right: ParsedCursorCandidate & { legacy: AiVaultSession } +): number { + return ( + right.legacy.messageCount - left.legacy.messageCount || + right.file.mtimeMs - left.file.mtimeMs || + left.file.path.localeCompare(right.file.path) + ) +} + +function latestIso(values: (string | null | undefined)[]): string { + return latestNullableIso(values) ?? new Date(0).toISOString() +} + +function latestNullableIso(values: (string | null | undefined)[]): string | null { + const valid = values + .filter((value): value is string => Boolean(value && Number.isFinite(Date.parse(value)))) + .sort((left, right) => Date.parse(right) - Date.parse(left)) + return valid[0] ?? null +} + +function earliestIso(values: (string | null | undefined)[]): string | null { + const valid = values + .filter((value): value is string => Boolean(value && Number.isFinite(Date.parse(value)))) + .sort((left, right) => Date.parse(left) - Date.parse(right)) + return valid[0] ?? null +} diff --git a/src/main/ai-vault/session-scanner-cursor-observability.ts b/src/main/ai-vault/session-scanner-cursor-observability.ts new file mode 100644 index 00000000000..b2df7c4cb92 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-observability.ts @@ -0,0 +1,74 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { CursorSidecarScanResponse } from '../../shared/cursor-sidecar-scan' +import type { ActiveSpan } from '../observability/tracer' +import type { + CursorReconcileStats, + ParsedCursorCandidate +} from './session-scanner-cursor-reconcile' + +export function recordCursorScanSpan(args: { + span: ActiveSpan + sidecarCandidatePaths: readonly string[] + parsedCandidates: readonly ParsedCursorCandidate[] + issues: readonly AiVaultScanIssue[] + reconcileStats: CursorReconcileStats +}): void { + const sidecarPaths = new Set(args.sidecarCandidatePaths) + const parsedSidecars = args.parsedCandidates.filter((candidate) => candidate.sidecar).length + const failedSidecarPaths = new Set( + args.issues + .filter((issue) => issue.agent === 'cursor' && sidecarPaths.has(issue.path)) + .map((issue) => issue.path) + ) + args.span.setAttribute('cursorSidecarCandidates', sidecarPaths.size) + args.span.setAttribute('cursorSidecarParsed', parsedSidecars) + args.span.setAttribute( + 'cursorSidecarSilentlySkipped', + Math.max(0, sidecarPaths.size - parsedSidecars - failedSidecarPaths.size) + ) + args.span.setAttribute( + 'cursorSidecarOversized', + args.issues.filter( + (issue) => + issue.agent === 'cursor' && + (issue.message.includes('exceeds the read limit') || + issue.message.includes('file_too_large')) + ).length + ) + args.span.setAttribute('cursorSuppressedSubagents', args.reconcileStats.suppressedSubagents) + args.span.setAttribute('cursorFalseOnlyGroups', args.reconcileStats.falseOnlyGroups) + args.span.setAttribute('cursorReconciledCounterparts', args.reconcileStats.reconciledCounterparts) + args.span.setAttribute('cursorAmbiguousLegacyIds', args.reconcileStats.ambiguousLegacyIds) + args.span.setAttribute('cursorBucketCollisions', args.reconcileStats.bucketCollisions) +} + +export function recordRemoteCursorScanSpan( + span: ActiveSpan, + scan: CursorSidecarScanResponse | null +): void { + span.setAttribute('cursorSidecarRpcCount', scan ? 1 : 0) + span.setAttribute('cursorRemoteCapabilityOrSchemaFailure', scan === null) + if (!scan) { + return + } + const counters = scan.counters + span.setAttribute( + 'cursorRemoteFilesystemOperations', + counters.rootReaddir + + counters.bucketReaddir + + counters.fileLstat + + counters.boundedReads + + counters.scopeRealpath + ) + span.setAttribute('cursorRemoteRootReaddir', counters.rootReaddir) + span.setAttribute('cursorRemoteBucketReaddir', counters.bucketReaddir) + span.setAttribute('cursorRemoteFileLstat', counters.fileLstat) + span.setAttribute('cursorRemoteBoundedReads', counters.boundedReads) + span.setAttribute('cursorRemoteScopeRealpath', counters.scopeRealpath) + span.setAttribute('cursorRemoteReturnedBytes', counters.returnedBytes) + span.setAttribute('cursorRemoteElapsedMs', counters.elapsedMs) + span.setAttribute('cursorRemoteTruncatedScopePaths', scan.truncated.scopePaths) + span.setAttribute('cursorRemoteTruncatedBuckets', scan.truncated.buckets) + span.setAttribute('cursorRemoteTruncatedSessionDirs', scan.truncated.sessionDirs) + span.setAttribute('cursorRemoteTruncatedSidecarBytes', scan.truncated.sidecarBytes) +} diff --git a/src/main/ai-vault/session-scanner-cursor-paths.test.ts b/src/main/ai-vault/session-scanner-cursor-paths.test.ts new file mode 100644 index 00000000000..c45e64f0038 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-paths.test.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + cursorBucketForCwd, + cursorLegacySlug, + cursorScopeCwdCandidates, + cursorWindowsPathVariants, + resolveCursorLocalRoots, + resolveCursorTargetPath +} from './session-scanner-cursor-paths' + +describe('Cursor session paths', () => { + it('resolves config and data roots with independent Cursor precedence', () => { + expect( + resolveCursorLocalRoots('/home/ada', { + CURSOR_CONFIG_DIR: ' /config/cursor ', + CURSOR_DATA_DIR: ' /data/cursor ', + XDG_CONFIG_HOME: '/xdg' + }) + ).toEqual({ + chatsDir: '/config/cursor/chats', + projectsDir: '/data/cursor/projects' + }) + expect(resolveCursorLocalRoots('/home/ada', { XDG_CONFIG_HOME: '/xdg' })).toEqual({ + chatsDir: '/xdg/cursor/chats', + projectsDir: '/home/ada/.cursor/projects' + }) + }) + + it.each([ + ['linux' as const, '/repo/../repo/a', '/repo/a'], + ['win32' as const, 'C:\\repo\\..\\repo\\a', 'C:\\repo\\a'] + ])('hashes the target-platform resolved path on %s', (platform, input, resolved) => { + expect(resolveCursorTargetPath(input, platform)).toBe(resolved) + expect(cursorBucketForCwd(input, platform)).toBe( + createHash('md5').update(resolved).digest('hex') + ) + }) + + it('probes only exact Windows drive-letter variants', () => { + expect(cursorWindowsPathVariants('c:\\Repo\\Mixed')).toEqual([ + 'c:\\Repo\\Mixed', + 'C:\\Repo\\Mixed' + ]) + }) + + it('converts only matching-distro WSL UNC scope paths', () => { + const scopePath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + expect( + cursorScopeCwdCandidates({ + scopePath, + platform: 'linux', + storageContextKey: 'wsl:ubuntu' + }) + ).toEqual(['/home/ada/repo']) + expect( + cursorScopeCwdCandidates({ + scopePath, + platform: 'linux', + storageContextKey: 'wsl:debian' + }) + ).toEqual([]) + expect( + cursorScopeCwdCandidates({ + scopePath, + platform: 'win32', + storageContextKey: 'native' + }) + ).toEqual([]) + }) + + it('uses Cursor’s untruncated lossy transcript slug', () => { + const value = `/repo/${'long segment '.repeat(30)}` + const slug = cursorLegacySlug(value) + expect(slug).toBe( + value + .replace(/[^a-zA-Z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + ) + expect(slug.length).toBeGreaterThan(92) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-paths.ts b/src/main/ai-vault/session-scanner-cursor-paths.ts new file mode 100644 index 00000000000..1af78291223 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-paths.ts @@ -0,0 +1,150 @@ +import { createHash } from 'node:crypto' +import { homedir } from 'node:os' +import { join, posix, relative, sep, win32 } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { FileWithMtime } from './session-scanner-types' + +export { CURSOR_SIDECAR_MAX_BYTES } from '../../shared/cursor-sidecar-scan' +export const CURSOR_BUCKET_PATTERN = /^[0-9a-f]{32}$/ + +export function resolveCursorLocalRoots( + homeDir = homedir(), + env: NodeJS.ProcessEnv = process.env +): { chatsDir: string; projectsDir: string } { + const configRoot = + nonblank(env.CURSOR_CONFIG_DIR) ?? + (nonblank(env.XDG_CONFIG_HOME) ? join(nonblank(env.XDG_CONFIG_HOME)!, 'cursor') : null) ?? + join(homeDir, '.cursor') + const dataRoot = nonblank(env.CURSOR_DATA_DIR) ?? join(homeDir, '.cursor') + return { + chatsDir: join(configRoot, 'chats'), + projectsDir: join(dataRoot, 'projects') + } +} + +export function cursorBucketForCwd(cwd: string, platform: NodeJS.Platform): string { + const resolved = resolveCursorTargetPath(cwd, platform) + return createHash('md5').update(resolved).digest('hex') +} + +export function resolveCursorTargetPath(cwd: string, platform: NodeJS.Platform): string { + return platform === 'win32' ? win32.resolve(cwd) : posix.resolve(cwd) +} + +export function isAbsoluteCursorTargetPath(cwd: string, platform: NodeJS.Platform): boolean { + return platform === 'win32' ? win32.isAbsolute(cwd) : posix.isAbsolute(cwd) +} + +export function cursorWindowsPathVariants(pathValue: string): string[] { + const resolved = win32.resolve(pathValue) + const drive = resolved.match(/^([A-Za-z]):/) + if (!drive) { + return [resolved] + } + return [ + ...new Set([ + resolved, + `${drive[1].toUpperCase()}${resolved.slice(1)}`, + `${drive[1].toLowerCase()}${resolved.slice(1)}` + ]) + ] +} + +export function cursorScopeCwdCandidates(args: { + scopePath: string + platform: NodeJS.Platform + storageContextKey: string +}): string[] { + const trimmed = args.scopePath.trim() + if (!trimmed) { + return [] + } + if (args.storageContextKey.startsWith('wsl:')) { + const parsed = parseWslUncPath(trimmed) + const distro = args.storageContextKey.slice('wsl:'.length) + if (!parsed || parsed.distro.toLowerCase() !== distro.toLowerCase()) { + return [] + } + return [posix.resolve(parsed.linuxPath)] + } + if ( + args.storageContextKey === 'native' && + args.platform === 'win32' && + parseWslUncPath(trimmed) + ) { + return [] + } + if (!isAbsoluteCursorTargetPath(trimmed, args.platform)) { + return [] + } + return args.platform === 'win32' ? cursorWindowsPathVariants(trimmed) : [posix.resolve(trimmed)] +} + +export function cursorLegacySlug(cwd: string): string { + return cwd + .replace(/[^a-zA-Z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export function isSafeCursorSessionBasename(name: string): boolean { + return Boolean( + name && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\') + ) +} + +export function isCursorSidecarDirectory(name: string, depth: number): boolean { + if (depth === 0) { + return CURSOR_BUCKET_PATTERN.test(name) + } + return depth === 1 && isSafeCursorSessionBasename(name) +} + +export function isCursorSidecarPath(chatsRoot: string, filePath: string): boolean { + const segments = relative(chatsRoot, filePath).split(sep) + return ( + segments.length === 3 && + CURSOR_BUCKET_PATTERN.test(segments[0]) && + isSafeCursorSessionBasename(segments[1]) && + segments[2] === 'meta.json' + ) +} + +export function cursorSidecarSessionId(filePath: string, platform: NodeJS.Platform): string | null { + const pathOps = platform === 'win32' ? win32 : posix + const sessionId = pathOps.basename(pathOps.dirname(filePath)) + return isSafeCursorSessionBasename(sessionId) ? sessionId : null +} + +export function cursorSidecarBucket(filePath: string, platform: NodeJS.Platform): string | null { + const pathOps = platform === 'win32' ? win32 : posix + const bucket = pathOps.basename(pathOps.dirname(pathOps.dirname(filePath))) + return CURSOR_BUCKET_PATTERN.test(bucket) ? bucket : null +} + +export function cursorStorageContextKey(rootDir: string): string { + const parsed = parseWslUncPath(rootDir) + return parsed ? `wsl:${parsed.distro}` : 'native' +} + +export function cursorContextPathForHash( + pathValue: string, + storageContextKey: string, + platform: NodeJS.Platform +): string | null { + const candidates = cursorScopeCwdCandidates({ scopePath: pathValue, storageContextKey, platform }) + return candidates[0] ?? null +} + +export function cursorSessionStorePath(metaPath: string): string { + return join(metaPath, '..', 'store.db') +} + +export function cursorSessionActivityMtimeMs(file: FileWithMtime): number { + return Math.max(file.mtimeMs, file.cursorStoreMtimeMs ?? 0) +} + +function nonblank(value: string | undefined): string | null { + const trimmed = value?.trim() + return trimmed || null +} diff --git a/src/main/ai-vault/session-scanner-cursor-reconcile.test.ts b/src/main/ai-vault/session-scanner-cursor-reconcile.test.ts new file mode 100644 index 00000000000..9771a5c7ed0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-reconcile.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { cursorBucketForCwd } from './session-scanner-cursor-paths' +import { + reconcileCursorCandidates, + type ParsedCursorCandidate +} from './session-scanner-cursor-reconcile' +import type { CursorSidecarEvidence } from './session-scanner-cursor-sidecar' +import type { FileWithMtime } from './session-scanner-types' + +function file(path: string, mtimeMs = 20): FileWithMtime { + return { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + cursorStoreMtimeMs: mtimeMs + } +} + +function sidecar( + args: { + id?: string + cwd?: string + bucket?: string + context?: string + conversation?: boolean + subagent?: boolean + } = {} +): ParsedCursorCandidate { + const id = args.id ?? 'session' + const cwd = args.cwd ?? '/repo' + const bucket = args.bucket ?? cursorBucketForCwd(cwd, 'linux') + const sidecarFile = file(`/home/ada/.cursor/chats/${bucket}/${id}/meta.json`) + const evidence: CursorSidecarEvidence = { + sessionId: id, + title: 'Sidecar title', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:01:00.000Z', + hasConversation: args.conversation ?? true, + isSubagent: args.subagent ?? false, + file: sidecarFile, + cwdEvidence: { kind: 'sidecar-bucket-match', cwd } + } + return { + layout: 'sidecar', + storageContextKey: args.context ?? 'native', + file: sidecarFile, + sidecar: evidence + } +} + +function legacy( + args: { + id?: string + context?: string + path?: string + messageCount?: number + } = {} +): ParsedCursorCandidate { + const id = args.id ?? 'session' + const transcript = file( + args.path ?? `/home/ada/.cursor/projects/repo/agent-transcripts/${id}/${id}.jsonl` + ) + const session: AiVaultSession = { + id: `local:cursor:${id}:${transcript.path}`, + executionHostId: 'local', + executionHostPlatform: 'linux', + agent: 'cursor', + sessionId: id, + title: 'Legacy title', + cwd: null, + branch: 'feature', + model: null, + filePath: transcript.path, + codexHome: null, + createdAt: '2026-06-30T00:00:00.000Z', + updatedAt: '2026-07-02T00:00:00.000Z', + modifiedAt: '2026-07-02T00:00:00.000Z', + messageCount: args.messageCount ?? 2, + totalTokens: 10, + previewMessages: [{ role: 'user', text: 'hello', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "cursor-agent --resume 'session'", + subagent: null + } + return { + layout: 'legacy', + storageContextKey: args.context ?? 'native', + file: transcript, + legacy: session + } +} + +function reconcile(candidates: ParsedCursorCandidate[]) { + const issues: Parameters[0]['issues'] = [] + return { + ...reconcileCursorCandidates({ + candidates, + executionHostId: 'local', + platform: 'linux', + issues + }), + issues + } +} + +describe('reconcileCursorCandidates', () => { + it('merges one unambiguous sidecar and transcript field by field', () => { + const sidecarCandidate = sidecar() + const legacyCandidate = legacy() + const result = reconcile([sidecarCandidate, legacyCandidate]) + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + id: 'local:cursor:session', + title: 'Sidecar title', + cwd: '/repo', + branch: 'feature', + filePath: sidecarCandidate.file.path, + transcriptFilePath: legacyCandidate.file.path, + messageCount: 2, + hasConversation: true, + previewMessages: [{ role: 'user', text: 'hello', timestamp: null }] + }) + expect(result.stats.reconciledCounterparts).toBe(1) + }) + + it('keeps native and WSL copies separate', () => { + const result = reconcile([ + sidecar({ context: 'native' }), + sidecar({ context: 'wsl:Ubuntu' }), + sidecar({ context: 'wsl:Debian' }) + ]) + expect(result.sessions).toHaveLength(3) + expect(new Set(result.sessions.map((session) => session.id)).size).toBe(3) + }) + + it('separates bucket collisions and leaves legacy evidence ambiguous', () => { + const scoped = sidecar({ bucket: '11111111111111111111111111111111' }) + scoped.cwdEvidence = { + kind: 'scope-bucket', + cwd: '/repo', + bucket: '11111111111111111111111111111111' + } + const result = reconcile([ + scoped, + sidecar({ bucket: '22222222222222222222222222222222' }), + legacy() + ]) + expect(result.sessions).toHaveLength(3) + expect(result.sessions.filter((session) => session.cwd !== null)).toHaveLength(2) + expect(result.sessions.find((session) => session.filePath.endsWith('.jsonl'))).toMatchObject({ + cwd: null, + transcriptFilePath: expect.stringContaining('.jsonl') + }) + expect(result.issues.map((issue) => issue.message)).toEqual( + expect.arrayContaining([ + expect.stringContaining('multiple storage buckets'), + expect.stringContaining('ambiguous storage bucket') + ]) + ) + const scopedSession = result.sessions.find((session) => session.filePath === scoped.file.path) + expect(scopedSession).toBeDefined() + expect(result.scopedSessionIds).toEqual(new Set([scopedSession?.id])) + }) + + it('suppresses subagents and drops false-only groups while legacy content revives false', () => { + expect(reconcile([sidecar({ subagent: true }), legacy()]).sessions).toEqual([]) + expect(reconcile([sidecar({ conversation: false })]).sessions).toEqual([]) + expect(reconcile([sidecar({ conversation: false }), legacy()]).sessions).toHaveLength(1) + }) + + it('never promotes weak legacy scope evidence into public cwd', () => { + const candidate = legacy() + candidate.cwdEvidence = { kind: 'legacy-scope-only', cwd: null } + expect(reconcile([candidate]).sessions[0]?.cwd).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-reconcile.ts b/src/main/ai-vault/session-scanner-cursor-reconcile.ts new file mode 100644 index 00000000000..1ed69498da6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-reconcile.ts @@ -0,0 +1,201 @@ +import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { CursorSidecarEvidence } from './session-scanner-cursor-sidecar' +import { materializeCursorSession } from './session-scanner-cursor-materialize' +import { cursorSidecarBucket } from './session-scanner-cursor-paths' +import type { CursorCwdEvidence, CursorLayout, FileWithMtime } from './session-scanner-types' + +export type ParsedCursorCandidate = { + layout: CursorLayout + storageContextKey: string + file: FileWithMtime + cwdEvidence?: CursorCwdEvidence + sidecar?: CursorSidecarEvidence | null + legacy?: AiVaultSession | null +} + +export type CursorReconcileStats = { + suppressedSubagents: number + falseOnlyGroups: number + reconciledCounterparts: number + ambiguousLegacyIds: number + bucketCollisions: number +} + +export type CursorPhysicalGroup = { + storageContextKey: string + sessionId: string + candidates: ParsedCursorCandidate[] + bucketCollision?: string + legacyPathCollision?: string +} + +export function reconcileCursorCandidates(args: { + candidates: readonly ParsedCursorCandidate[] + executionHostId: ExecutionHostId + platform: NodeJS.Platform + issues: AiVaultScanIssue[] + commandOverride?: string | null +}): { + sessions: AiVaultSession[] + scopedSessionIds: ReadonlySet + stats: CursorReconcileStats +} { + const stats: CursorReconcileStats = { + suppressedSubagents: 0, + falseOnlyGroups: 0, + reconciledCounterparts: 0, + ambiguousLegacyIds: 0, + bucketCollisions: 0 + } + const groups = buildPhysicalGroups(args.candidates, args.platform, { ...args, stats }) + const sessions: AiVaultSession[] = [] + const scopedSessionIds = new Set() + for (const group of groups) { + const session = materializeCursorSession({ ...args, group, stats }) + if (!session) { + continue + } + sessions.push(session) + if (group.candidates.some((candidate) => candidate.cwdEvidence?.kind === 'scope-bucket')) { + scopedSessionIds.add(session.id) + } + } + return { sessions, scopedSessionIds, stats } +} + +function buildPhysicalGroups( + candidates: readonly ParsedCursorCandidate[], + platform: NodeJS.Platform, + issueContext: { + executionHostId: ExecutionHostId + issues: AiVaultScanIssue[] + stats: CursorReconcileStats + } +): CursorPhysicalGroup[] { + const logicalGroups = new Map() + for (const candidate of candidates) { + const sessionId = candidate.sidecar?.sessionId ?? candidate.legacy?.sessionId + if (!sessionId) { + continue + } + const key = `${candidate.storageContextKey}\0${sessionId}` + logicalGroups.set(key, [...(logicalGroups.get(key) ?? []), candidate]) + } + + const groups: CursorPhysicalGroup[] = [] + for (const candidatesForId of logicalGroups.values()) { + const first = candidatesForId[0] + const sessionId = first.sidecar?.sessionId ?? first.legacy?.sessionId + if (!sessionId) { + continue + } + const sidecarsByBucket = new Map() + const legacy = candidatesForId.filter((candidate) => candidate.legacy) + for (const candidate of candidatesForId) { + if (!candidate.sidecar) { + continue + } + const bucket = cursorSidecarBucket(candidate.file.path, platform) + if (bucket) { + sidecarsByBucket.set(bucket, [...(sidecarsByBucket.get(bucket) ?? []), candidate]) + } + } + + const bucketCollision = sidecarsByBucket.size > 1 + if (bucketCollision) { + issueContext.stats.bucketCollisions++ + issueContext.issues.push({ + executionHostId: issueContext.executionHostId, + agent: 'cursor', + path: `cursor:${sessionId}`, + message: 'Cursor session id exists in multiple storage buckets.' + }) + } + const legacyByBucket = assignLegacyCandidatesToBuckets(legacy, sidecarsByBucket) + for (const [bucket, sidecars] of sidecarsByBucket) { + groups.push({ + storageContextKey: first.storageContextKey, + sessionId, + candidates: [...sidecars, ...(legacyByBucket.get(bucket) ?? [])], + ...(bucketCollision ? { bucketCollision: bucket } : {}) + }) + } + + if (sidecarsByBucket.size === 0) { + appendLegacyOnlyGroups(groups, first.storageContextKey, sessionId, legacy) + } else { + const assigned = new Set([...legacyByBucket.values()].flat()) + const unresolved = legacy.filter((candidate) => !assigned.has(candidate)) + if (unresolved.length === 0) { + continue + } + issueContext.stats.ambiguousLegacyIds++ + issueContext.issues.push({ + executionHostId: issueContext.executionHostId, + agent: 'cursor', + path: `cursor:${sessionId}`, + message: 'Cursor legacy transcript has an ambiguous storage bucket.' + }) + appendLegacyOnlyGroups( + groups, + first.storageContextKey, + sessionId, + unresolved, + sidecarsByBucket.size > 1 + ) + } + } + return groups +} + +function assignLegacyCandidatesToBuckets( + legacy: readonly ParsedCursorCandidate[], + sidecarsByBucket: ReadonlyMap +): Map { + const assigned = new Map() + if (sidecarsByBucket.size === 1) { + const bucket = sidecarsByBucket.keys().next().value as string + assigned.set(bucket, [...legacy]) + return assigned + } + for (const candidate of legacy) { + const bucket = candidate.cwdEvidence?.bucket + if (!bucket) { + continue + } + const sidecars = sidecarsByBucket.get(bucket) + if ( + sidecars && + candidate.cwdEvidence?.kind === 'legacy-scope-only' && + sidecars.some( + (sidecar) => + sidecar.cwdEvidence?.kind === 'scope-bucket' && sidecar.cwdEvidence.bucket === bucket + ) + ) { + assigned.set(bucket, [...(assigned.get(bucket) ?? []), candidate]) + } + } + return assigned +} + +function appendLegacyOnlyGroups( + groups: CursorPhysicalGroup[], + storageContextKey: string, + sessionId: string, + legacy: readonly ParsedCursorCandidate[], + forcePathCollision = false +): void { + if (legacy.length === 1 && !forcePathCollision) { + groups.push({ storageContextKey, sessionId, candidates: [legacy[0]] }) + return + } + for (const candidate of legacy) { + groups.push({ + storageContextKey, + sessionId, + candidates: [candidate], + legacyPathCollision: candidate.file.path + }) + } +} diff --git a/src/main/ai-vault/session-scanner-cursor-remote-pipeline.ts b/src/main/ai-vault/session-scanner-cursor-remote-pipeline.ts new file mode 100644 index 00000000000..a187849485f --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-remote-pipeline.ts @@ -0,0 +1,161 @@ +import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import type { ActiveSpan } from '../observability/tracer' +import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' +import { + recordCursorScanSpan, + recordRemoteCursorScanSpan +} from './session-scanner-cursor-observability' +import { + reconcileCursorCandidates, + type ParsedCursorCandidate +} from './session-scanner-cursor-reconcile' +import { parseCursorSidecarContent } from './session-scanner-cursor-sidecar' +import type { CursorSidecarScanResponse } from '../../shared/cursor-sidecar-scan' +import { errorMessage } from './session-scanner-values' +import { + buildCursorCandidateSelectionGroups, + canStopCursorGroupSelection, + selectCursorScopedGroups, + type CursorCandidateSelectionGroup +} from './session-scanner-cursor-selection' + +const REMOTE_CURSOR_PARSE_CONCURRENCY = 8 + +export async function processRemoteCursorCandidates(args: { + candidates: readonly RemoteSessionCandidate[] + limit: number + scopeLimit: number + context: RemoteScannerContext + issues: AiVaultScanIssue[] + span: ActiveSpan + scan: CursorSidecarScanResponse | null + parseLegacy: (candidate: RemoteSessionCandidate) => Promise +}): Promise> { + const groups = buildCursorCandidateSelectionGroups({ + candidates: args.candidates, + platform: args.context.hostPlatform.os, + adapter: { + getCwdEvidence: (candidate) => candidate.cursorCwdEvidence, + getFile: (candidate) => candidate.file, + getLayout: (candidate) => candidate.source.cursorLayout ?? 'legacy', + getStorageContextKey: (candidate) => + candidate.source.cursorStorageContextKey || args.context.executionHostId + } + }) + const parsed: ParsedCursorCandidate[] = [] + const processedGroups = new Set>() + for (let index = 0; index < groups.length; index += REMOTE_CURSOR_PARSE_CONCURRENCY) { + const preview = reconcileCursorCandidates({ + candidates: parsed, + executionHostId: args.context.executionHostId, + platform: args.context.hostPlatform.os, + issues: [] + }) + if (canStopCursorGroupSelection(preview.sessions, args.limit, groups[index]?.mtimeMs)) { + break + } + const batch = groups.slice(index, index + REMOTE_CURSOR_PARSE_CONCURRENCY) + await parseCursorGroups(batch, parsed, args) + batch.forEach((group) => processedGroups.add(group)) + } + const scopedGroups = selectCursorScopedGroups(groups, processedGroups, args.scopeLimit) + for (let index = 0; index < scopedGroups.length; index += REMOTE_CURSOR_PARSE_CONCURRENCY) { + await parseCursorGroups( + scopedGroups.slice(index, index + REMOTE_CURSOR_PARSE_CONCURRENCY), + parsed, + args + ) + } + const reconciled = reconcileCursorCandidates({ + candidates: parsed, + executionHostId: args.context.executionHostId, + platform: args.context.hostPlatform.os, + issues: args.issues + }) + recordCursorScanSpan({ + span: args.span, + sidecarCandidatePaths: args.candidates + .filter((candidate) => candidate.source.cursorLayout === 'sidecar') + .map((candidate) => candidate.file.path), + parsedCandidates: parsed, + issues: args.issues, + reconcileStats: reconciled.stats + }) + recordRemoteCursorScanSpan(args.span, args.scan) + return reconciled +} + +async function parseCursorGroups( + groups: readonly CursorCandidateSelectionGroup[], + parsed: ParsedCursorCandidate[], + args: { + context: RemoteScannerContext + issues: AiVaultScanIssue[] + parseLegacy: (candidate: RemoteSessionCandidate) => Promise + } +): Promise { + const candidates = groups.flatMap((group) => group.candidates) + for (let index = 0; index < candidates.length; index += REMOTE_CURSOR_PARSE_CONCURRENCY) { + const batch = candidates.slice(index, index + REMOTE_CURSOR_PARSE_CONCURRENCY) + const results = await Promise.all( + batch.map((candidate) => parseCursorCandidate(candidate, args)) + ) + parsed.push(...results.filter((result): result is ParsedCursorCandidate => Boolean(result))) + } +} + +async function parseCursorCandidate( + candidate: RemoteSessionCandidate, + args: { + context: RemoteScannerContext + issues: AiVaultScanIssue[] + parseLegacy: (candidate: RemoteSessionCandidate) => Promise + } +): Promise { + const layout = candidate.source.cursorLayout ?? 'legacy' + if (layout === 'legacy') { + const legacy = await args.parseLegacy(candidate) + return legacy + ? { + layout, + storageContextKey: + candidate.source.cursorStorageContextKey || args.context.executionHostId, + file: candidate.file, + cwdEvidence: candidate.cursorCwdEvidence, + legacy + } + : null + } + try { + if (candidate.cursorSidecarContent === undefined) { + return null + } + const result = parseCursorSidecarContent({ + file: candidate.file, + content: candidate.cursorSidecarContent, + platform: args.context.hostPlatform.os, + executionHostId: args.context.executionHostId + }) + if (result.issue) { + args.issues.push(result.issue) + } + return result.evidence + ? { + layout, + storageContextKey: + candidate.source.cursorStorageContextKey || args.context.executionHostId, + file: candidate.file, + cwdEvidence: candidate.cursorCwdEvidence, + sidecar: result.evidence + } + : null + } catch (error) { + args.issues.push({ + executionHostId: args.context.executionHostId, + agent: 'cursor', + path: candidate.file.path, + message: errorMessage(error) + }) + return null + } +} diff --git a/src/main/ai-vault/session-scanner-cursor-selection.test.ts b/src/main/ai-vault/session-scanner-cursor-selection.test.ts new file mode 100644 index 00000000000..e816710f28b --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-selection.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + buildCursorCandidateSelectionGroups, + canStopCursorGroupSelection, + selectCursorScopedGroups +} from './session-scanner-cursor-selection' +import type { CursorCwdEvidence, CursorLayout, FileWithMtime } from './session-scanner-types' + +type Candidate = { + context: string + evidence?: CursorCwdEvidence + file: FileWithMtime + layout: CursorLayout +} + +const adapter = { + getCwdEvidence: (candidate: Candidate) => candidate.evidence, + getFile: (candidate: Candidate) => candidate.file, + getLayout: (candidate: Candidate) => candidate.layout, + getStorageContextKey: (candidate: Candidate) => candidate.context +} + +function candidate( + path: string, + layout: CursorLayout, + mtimeMs: number, + cursorStoreMtimeMs?: number +): Candidate { + return { + context: 'native', + file: { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + cursorStoreMtimeMs + }, + layout + } +} + +describe('Cursor candidate selection', () => { + it('completes an older legacy counterpart in the selected sidecar group', () => { + const bucket = '11111111111111111111111111111111' + const sidecar = candidate(`/chats/${bucket}/session/meta.json`, 'sidecar', 100) + const unrelated = candidate('/projects/other/agent-transcripts/other/other.jsonl', 'legacy', 90) + const legacy = candidate('/projects/repo/agent-transcripts/session/session.jsonl', 'legacy', 1) + + const groups = buildCursorCandidateSelectionGroups({ + candidates: [sidecar, unrelated, legacy], + platform: 'linux', + adapter + }) + + expect(groups).toHaveLength(2) + expect(groups[0]?.candidates).toEqual([sidecar, legacy]) + expect(groups[1]?.candidates).toEqual([unrelated]) + }) + + it('keeps bucket collisions together but ambiguous legacy paths independent', () => { + const first = candidate( + '/chats/11111111111111111111111111111111/session/meta.json', + 'sidecar', + 100 + ) + const second = candidate( + '/chats/22222222222222222222222222222222/session/meta.json', + 'sidecar', + 90 + ) + const legacy = candidate('/projects/repo/agent-transcripts/session/session.jsonl', 'legacy', 80) + + const groups = buildCursorCandidateSelectionGroups({ + candidates: [first, second, legacy], + platform: 'linux', + adapter + }) + + expect(groups).toHaveLength(2) + expect(groups[0]?.candidates).toEqual([first, second]) + expect(groups[1]?.candidates).toEqual([legacy]) + }) + + it('selects scoped keys after the normal recency cutoff', () => { + const visible = { + updatedAt: '2026-07-01T00:00:00.000Z', + modifiedAt: '2026-07-01T00:00:00.000Z' + } as AiVaultSession + expect(canStopCursorGroupSelection([visible], 1, Date.parse('2026-06-01'))).toBe(true) + expect(canStopCursorGroupSelection([visible], 2, Date.parse('2026-06-01'))).toBe(false) + }) + + it('prioritizes scope buckets over weak legacy matches before truncation', () => { + const strong = candidate( + '/chats/11111111111111111111111111111111/strong/meta.json', + 'sidecar', + 1 + ) + strong.evidence = { + kind: 'scope-bucket', + cwd: '/repo', + bucket: '11111111111111111111111111111111' + } + const weak = candidate('/projects/repo/agent-transcripts/weak/weak.jsonl', 'legacy', 100) + weak.evidence = { kind: 'legacy-scope-only', cwd: null } + const groups = buildCursorCandidateSelectionGroups({ + candidates: [weak, strong], + platform: 'linux', + adapter + }) + + expect(selectCursorScopedGroups(groups, new Set(), 1)[0]?.candidates).toEqual([strong]) + }) + + it('ranks sidecars by the latest metadata or store activity', () => { + const activeStore = candidate( + '/chats/11111111111111111111111111111111/active/meta.json', + 'sidecar', + 1, + 200 + ) + const newerMetadata = candidate( + '/chats/22222222222222222222222222222222/metadata/meta.json', + 'sidecar', + 100, + 50 + ) + + const groups = buildCursorCandidateSelectionGroups({ + candidates: [newerMetadata, activeStore], + platform: 'linux', + adapter + }) + + expect(groups.map((group) => group.candidates[0])).toEqual([activeStore, newerMetadata]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-selection.ts b/src/main/ai-vault/session-scanner-cursor-selection.ts new file mode 100644 index 00000000000..c8262852714 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-selection.ts @@ -0,0 +1,176 @@ +import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' +import { + cursorSessionActivityMtimeMs, + cursorSidecarBucket, + cursorSidecarSessionId +} from './session-scanner-cursor-paths' +import type { CursorCwdEvidence, CursorLayout, FileWithMtime } from './session-scanner-types' +import type { AiVaultSession } from '../../shared/ai-vault-types' + +export type CursorCandidateSelectionGroup = { + candidates: T[] + key: string + mtimeMs: number + scoped: boolean + scopePriority: number +} + +type CursorSelectionAdapter = { + getCwdEvidence(candidate: T): CursorCwdEvidence | undefined + getFile(candidate: T): FileWithMtime + getLayout(candidate: T): CursorLayout + getStorageContextKey(candidate: T): string +} + +type CursorSelectionIdentity = { + candidate: T + contextKey: string + cwdEvidence?: CursorCwdEvidence + file: FileWithMtime + layout: CursorLayout + logicalKey: string + sessionId: string + sidecarBucket: string | null +} + +export function buildCursorCandidateSelectionGroups(args: { + candidates: readonly T[] + platform: NodeJS.Platform + adapter: CursorSelectionAdapter +}): CursorCandidateSelectionGroup[] { + const identities = args.candidates + .map((candidate) => cursorSelectionIdentity(candidate, args.platform, args.adapter)) + .filter((identity): identity is CursorSelectionIdentity => Boolean(identity)) + const sidecarBucketsByLogicalKey = new Map>() + const scopedSidecarBucketsByLogicalKey = new Map>() + for (const identity of identities) { + if (identity.layout !== 'sidecar' || !identity.sidecarBucket) { + continue + } + addToSetMap(sidecarBucketsByLogicalKey, identity.logicalKey, identity.sidecarBucket) + if (identity.cwdEvidence?.kind === 'scope-bucket') { + addToSetMap(scopedSidecarBucketsByLogicalKey, identity.logicalKey, identity.sidecarBucket) + } + } + + const groups = new Map>() + for (const identity of identities) { + const key = selectionKey( + identity, + sidecarBucketsByLogicalKey.get(identity.logicalKey), + scopedSidecarBucketsByLogicalKey.get(identity.logicalKey) + ) + const existing = groups.get(key) + if (existing) { + existing.candidates.push(identity.candidate) + existing.mtimeMs = Math.max(existing.mtimeMs, cursorSessionActivityMtimeMs(identity.file)) + existing.scoped ||= Boolean(identity.cwdEvidence) + existing.scopePriority = Math.max( + existing.scopePriority, + cursorScopePriority(identity.cwdEvidence) + ) + continue + } + groups.set(key, { + candidates: [identity.candidate], + key, + mtimeMs: cursorSessionActivityMtimeMs(identity.file), + scoped: Boolean(identity.cwdEvidence), + scopePriority: cursorScopePriority(identity.cwdEvidence) + }) + } + return [...groups.values()].sort( + (left, right) => right.mtimeMs - left.mtimeMs || left.key.localeCompare(right.key) + ) +} + +export function selectCursorScopedGroups( + groups: readonly CursorCandidateSelectionGroup[], + processedGroups: ReadonlySet>, + limit: number +): CursorCandidateSelectionGroup[] { + return groups + .filter((group) => group.scoped && !processedGroups.has(group)) + .sort( + (left, right) => + right.scopePriority - left.scopePriority || + right.mtimeMs - left.mtimeMs || + left.key.localeCompare(right.key) + ) + .slice(0, limit) +} + +export function canStopCursorGroupSelection( + sessions: readonly AiVaultSession[], + limit: number, + nextGroupMtimeMs: number | undefined +): boolean { + if (sessions.length < limit || typeof nextGroupMtimeMs !== 'number') { + return false + } + const cutoff = [...sessions] + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + return typeof cutoff === 'number' && nextGroupMtimeMs < cutoff +} + +function cursorSelectionIdentity( + candidate: T, + platform: NodeJS.Platform, + adapter: CursorSelectionAdapter +): CursorSelectionIdentity | null { + const file = adapter.getFile(candidate) + const layout = adapter.getLayout(candidate) + const sessionId = + layout === 'sidecar' + ? cursorSidecarSessionId(file.path, platform) + : sessionIdFromFileName(file.path) + if (!sessionId) { + return null + } + const contextKey = adapter.getStorageContextKey(candidate) + return { + candidate, + contextKey, + cwdEvidence: adapter.getCwdEvidence(candidate), + file, + layout, + logicalKey: `${contextKey}\0${sessionId}`, + sessionId, + sidecarBucket: layout === 'sidecar' ? cursorSidecarBucket(file.path, platform) : null + } +} + +function selectionKey( + identity: CursorSelectionIdentity, + sidecarBuckets: ReadonlySet | undefined, + scopedSidecarBuckets: ReadonlySet | undefined +): string { + if (identity.layout === 'sidecar') { + return `sidecar\0${identity.logicalKey}` + } + if (sidecarBuckets?.size === 1) { + return `sidecar\0${identity.logicalKey}` + } + const evidenceBucket = identity.cwdEvidence?.bucket + if ( + identity.cwdEvidence?.kind === 'legacy-scope-only' && + evidenceBucket && + sidecarBuckets?.has(evidenceBucket) && + scopedSidecarBuckets?.has(evidenceBucket) + ) { + return `sidecar\0${identity.logicalKey}` + } + return `legacy\0${identity.logicalKey}\0${identity.file.path}` +} + +function addToSetMap(map: Map>, key: string, value: string): void { + const values = map.get(key) ?? new Set() + values.add(value) + map.set(key, values) +} + +function cursorScopePriority(evidence: CursorCwdEvidence | undefined): number { + return evidence?.kind === 'scope-bucket' ? 2 : evidence ? 1 : 0 +} diff --git a/src/main/ai-vault/session-scanner-cursor-sidecar.test.ts b/src/main/ai-vault/session-scanner-cursor-sidecar.test.ts new file mode 100644 index 00000000000..619e326549c --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-sidecar.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { win32 } from 'node:path' +import { cursorBucketForCwd } from './session-scanner-cursor-paths' +import { parseCursorSidecarContent } from './session-scanner-cursor-sidecar' +import type { FileWithMtime } from './session-scanner-types' + +function fileFor( + cwd: string, + platform: NodeJS.Platform, + overrides: Partial = {} +): FileWithMtime { + const path = + platform === 'win32' + ? win32.join( + 'C:\\Users\\Ada\\.cursor\\chats', + cursorBucketForCwd(cwd, platform), + 'opaque session', + 'meta.json' + ) + : `/home/ada/.cursor/chats/${cursorBucketForCwd(cwd, platform)}/opaque session/meta.json` + return { + path, + mtimeMs: 20, + modifiedAt: '1970-01-01T00:00:00.020Z', + sizeBytes: 100, + cursorStoreMtimeMs: 30, + ...overrides + } +} + +describe('parseCursorSidecarContent', () => { + it('parses the documented schema and ignores schemaVersion and store-only fields', () => { + const cwd = '/workspace/repo' + const result = parseCursorSidecarContent({ + file: fileFor(cwd, 'linux'), + platform: 'linux', + content: JSON.stringify({ + schemaVersion: 99, + createdAtMs: 10.9, + updatedAtMs: 20.8, + hasConversation: true, + isSubagent: true, + title: ' Session title ', + cwd, + agentId: 'wrong-id', + name: 'wrong-title', + latestRootBlobId: 'private' + }) + }) + + expect(result.issue).toBeNull() + expect(result.evidence).toMatchObject({ + sessionId: 'opaque session', + title: 'Session title', + createdAt: '1970-01-01T00:00:00.010Z', + updatedAt: '1970-01-01T00:00:00.020Z', + hasConversation: true, + isSubagent: true, + cwdEvidence: { kind: 'sidecar-bucket-match', cwd } + }) + }) + + it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, 8_640_000_000_000_001])( + 'silently skips an invalid createdAtMs value: %s', + (createdAtMs) => { + const result = parseCursorSidecarContent({ + file: fileFor('/repo', 'linux'), + platform: 'linux', + content: JSON.stringify({ createdAtMs, hasConversation: true }) + }) + expect(result).toEqual({ evidence: null, issue: null }) + } + ) + + it.each([undefined, 0, -1, Number.POSITIVE_INFINITY, 8_640_000_000_000_001])( + 'falls back to the sibling store mtime for updatedAtMs %s', + (updatedAtMs) => { + const result = parseCursorSidecarContent({ + file: fileFor('/repo', 'linux', { cursorStoreMtimeMs: 42.9 }), + platform: 'linux', + content: JSON.stringify({ createdAtMs: 10, updatedAtMs, hasConversation: 1 }) + }) + expect(result.evidence).toMatchObject({ + updatedAt: '1970-01-01T00:00:00.042Z', + hasConversation: false + }) + } + ) + + it.each([undefined, 0, -1, Number.POSITIVE_INFINITY, 8_640_000_000_000_001])( + 'skips when updatedAtMs and the sibling store mtime are unusable: %s', + (cursorStoreMtimeMs) => { + const result = parseCursorSidecarContent({ + file: fileFor('/repo', 'linux', { cursorStoreMtimeMs }), + platform: 'linux', + content: JSON.stringify({ createdAtMs: 10, updatedAtMs: 0, hasConversation: true }) + }) + expect(result).toEqual({ evidence: null, issue: null }) + } + ) + + it('reports malformed JSON without exposing the content', () => { + const file = fileFor('/repo', 'linux') + const result = parseCursorSidecarContent({ + file, + platform: 'linux', + executionHostId: 'ssh:dev', + content: '{"secret":' + }) + expect(result.evidence).toBeNull() + expect(result.issue).toEqual({ + executionHostId: 'ssh:dev', + agent: 'cursor', + path: file.path, + message: 'Malformed Cursor session metadata.' + }) + }) + + it('rejects relative cwd and records a bucket mismatch for an absolute cwd', () => { + const file = fileFor('/correct', 'linux') + const relative = parseCursorSidecarContent({ + file, + platform: 'linux', + content: JSON.stringify({ createdAtMs: 10, cwd: 'relative/repo' }) + }) + const mismatch = parseCursorSidecarContent({ + file, + platform: 'linux', + content: JSON.stringify({ createdAtMs: 10, cwd: '/wrong' }) + }) + expect(relative.evidence?.cwdEvidence).toBeNull() + expect(relative.issue).toBeNull() + expect(mismatch.evidence?.cwdEvidence).toBeNull() + expect(mismatch.issue?.message).toContain('does not match') + }) + + it('uses Windows path semantics independently of the scanner platform', () => { + const cwd = 'c:\\Users\\Ada\\repo' + const result = parseCursorSidecarContent({ + file: fileFor(cwd, 'win32'), + platform: 'win32', + content: JSON.stringify({ createdAtMs: 10, hasConversation: true, cwd }) + }) + expect(result.evidence?.cwdEvidence).toEqual({ + kind: 'sidecar-bucket-match', + cwd: win32.resolve(cwd) + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-sidecar.ts b/src/main/ai-vault/session-scanner-cursor-sidecar.ts new file mode 100644 index 00000000000..28544144103 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-sidecar.ts @@ -0,0 +1,204 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { readVerifiedBoundedTextFile } from '../../shared/node-verified-bounded-text-file' +import { + CURSOR_SIDECAR_MAX_BYTES, + cursorBucketForCwd, + cursorSidecarBucket, + cursorSidecarSessionId, + isAbsoluteCursorTargetPath, + resolveCursorTargetPath +} from './session-scanner-cursor-paths' +import type { CursorCwdEvidence, FileWithMtime } from './session-scanner-types' + +export type CursorSidecarEvidence = { + sessionId: string + title: string + createdAt: string + updatedAt: string + hasConversation: boolean + isSubagent: boolean + file: FileWithMtime + cwdEvidence: CursorCwdEvidence | null +} + +export type CursorSidecarParseResult = { + evidence: CursorSidecarEvidence | null + issue: AiVaultScanIssue | null +} + +const SIDECAR_CACHE_MAX_ENTRIES = 4096 +const sidecarCache = new Map< + string, + { + mtimeMs: number + sizeBytes: number | null + storeMtimeMs: number | null + platform: NodeJS.Platform + result: CursorSidecarParseResult + } +>() + +export async function parseCursorSidecarFile(args: { + file: FileWithMtime + platform: NodeJS.Platform + executionHostId?: ExecutionHostId + expectedRootRealPath?: string +}): Promise { + if (!args.expectedRootRealPath) { + throw new Error('cursor_sidecar_root_unavailable') + } + return parseCursorSidecarContent({ + ...args, + content: await readVerifiedBoundedTextFile(args.file.path, { + expectedRootRealPath: args.expectedRootRealPath, + maxBytes: CURSOR_SIDECAR_MAX_BYTES + }) + }) +} + +export async function parseCursorSidecarFileCached(args: { + file: FileWithMtime + platform: NodeJS.Platform + executionHostId?: ExecutionHostId + expectedRootRealPath?: string +}): Promise { + const cached = sidecarCache.get(args.file.path) + if ( + cached && + cached.mtimeMs === args.file.mtimeMs && + cached.storeMtimeMs === (args.file.cursorStoreMtimeMs ?? null) && + cached.platform === args.platform && + (cached.sizeBytes === null || + args.file.sizeBytes === undefined || + cached.sizeBytes === args.file.sizeBytes) + ) { + sidecarCache.delete(args.file.path) + sidecarCache.set(args.file.path, cached) + return cached.result + } + const result = await parseCursorSidecarFile(args) + sidecarCache.delete(args.file.path) + sidecarCache.set(args.file.path, { + mtimeMs: args.file.mtimeMs, + sizeBytes: args.file.sizeBytes ?? null, + storeMtimeMs: args.file.cursorStoreMtimeMs ?? null, + platform: args.platform, + result + }) + if (sidecarCache.size > SIDECAR_CACHE_MAX_ENTRIES) { + const oldest = sidecarCache.keys().next() + if (!oldest.done) { + sidecarCache.delete(oldest.value) + } + } + return result +} + +export function resetCursorSidecarParseCacheForTests(): void { + sidecarCache.clear() +} + +export function parseCursorSidecarContent(args: { + file: FileWithMtime + content: string + platform: NodeJS.Platform + executionHostId?: ExecutionHostId +}): CursorSidecarParseResult { + let value: unknown + try { + value = JSON.parse(args.content) as unknown + } catch { + return { + evidence: null, + issue: issue(args, 'Malformed Cursor session metadata.') + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { evidence: null, issue: null } + } + const record = value as Record + if (typeof record.createdAtMs !== 'number' || !Number.isFinite(record.createdAtMs)) { + return { evidence: null, issue: null } + } + const sessionId = cursorSidecarSessionId(args.file.path, args.platform) + const bucket = cursorSidecarBucket(args.file.path, args.platform) + if (!sessionId || !bucket) { + return { evidence: null, issue: null } + } + + const createdAtMs = representableDateMs(record.createdAtMs) + if (createdAtMs === null) { + return { evidence: null, issue: null } + } + const parsedUpdatedAtMs = representableDateMs(record.updatedAtMs) + const storeMtimeMs = representableDateMs(args.file.cursorStoreMtimeMs) + const updatedAtMs = + parsedUpdatedAtMs !== null && parsedUpdatedAtMs > 0 + ? parsedUpdatedAtMs + : storeMtimeMs !== null && storeMtimeMs > 0 + ? storeMtimeMs + : null + if (updatedAtMs === null) { + return { evidence: null, issue: null } + } + const rawTitle = typeof record.title === 'string' ? record.title.trim() : '' + const cwdResult = validatedSidecarCwd(record.cwd, args.platform, bucket) + + return { + evidence: { + sessionId, + title: rawTitle || `Cursor ${sessionId.slice(0, 8)}`, + createdAt: new Date(createdAtMs).toISOString(), + updatedAt: new Date(updatedAtMs).toISOString(), + hasConversation: record.hasConversation === true, + isSubagent: record.isSubagent === true, + file: args.file, + cwdEvidence: cwdResult.cwd ? { kind: 'sidecar-bucket-match', cwd: cwdResult.cwd } : null + }, + issue: cwdResult.mismatched + ? issue(args, 'Cursor session cwd does not match its storage bucket.') + : null + } +} + +function representableDateMs(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + const floored = Math.floor(value) + return Math.abs(floored) <= 8_640_000_000_000_000 ? floored : null +} + +function validatedSidecarCwd( + value: unknown, + platform: NodeJS.Platform, + bucket: string +): { cwd: string | null; mismatched: boolean } { + if (typeof value !== 'string' || !value.trim()) { + return { cwd: null, mismatched: false } + } + const cwd = value.trim() + if (!isAbsoluteCursorTargetPath(cwd, platform)) { + return { cwd: null, mismatched: false } + } + const resolved = resolveCursorTargetPath(cwd, platform) + return cursorBucketForCwd(resolved, platform) === bucket + ? { cwd: resolved, mismatched: false } + : { cwd: null, mismatched: true } +} + +function issue( + args: { + file: FileWithMtime + executionHostId?: ExecutionHostId + }, + message: string +): AiVaultScanIssue { + return { + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), + agent: 'cursor', + path: args.file.path, + message + } +} diff --git a/src/main/ai-vault/session-scanner-cursor-sources.ts b/src/main/ai-vault/session-scanner-cursor-sources.ts new file mode 100644 index 00000000000..60abe845de8 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-sources.ts @@ -0,0 +1,241 @@ +import { lstat, readdir, realpath } from 'node:fs/promises' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + cursorBucketForCwd, + cursorLegacySlug, + cursorSessionActivityMtimeMs, + cursorScopeCwdCandidates, + cursorStorageContextKey, + isSafeCursorSessionBasename, + resolveCursorLocalRoots +} from './session-scanner-cursor-paths' +import { + cursorLocalFileMetadata, + discoverLocalCursorSidecars, + isMissingCursorPathError, + localCursorRootRealPath, + validateLocalCursorSidecars +} from './session-scanner-cursor-local-files' +import { discoverFiles } from './session-scanner-discovery' +import type { + AiVaultScanOptions, + CursorCwdEvidence, + FileWithMtime, + SessionFileDiscovery +} from './session-scanner-types' +import { errorMessage } from './session-scanner-values' + +const CURSOR_SCOPE_PATH_LIMIT = 64 + +type CursorRootPair = { + chatsDir: string + projectsDir: string + storageContextKey: string + targetPlatform: NodeJS.Platform +} + +export function cursorRootPairs( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[] +): CursorRootPair[] { + const defaults = resolveCursorLocalRoots() + const nativePlatform = options.platform ?? process.platform + return [ + { + chatsDir: options.cursorChatsDir ?? defaults.chatsDir, + projectsDir: options.cursorProjectsDir ?? defaults.projectsDir, + storageContextKey: 'native', + targetPlatform: nativePlatform + }, + ...wslHomeDirs.map((homeDir) => ({ + chatsDir: join(homeDir, '.cursor', 'chats'), + projectsDir: join(homeDir, '.cursor', 'projects'), + storageContextKey: cursorStorageContextKey(homeDir), + targetPlatform: 'linux' as const + })) + ] +} + +export function cursorDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return cursorRootPairs(options, wslHomeDirs).flatMap((roots) => [ + discoverCursorSidecars({ roots, options, limit, issues }), + discoverCursorLegacy({ roots, options, limit, issues }) + ]) +} + +async function discoverCursorSidecars(args: { + roots: CursorRootPair + options: AiVaultScanOptions + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const expectedRootRealPath = await localCursorRootRealPath(args.roots.chatsDir, args.issues) + if (!expectedRootRealPath) { + return { + agent: 'cursor', + rootDir: args.roots.chatsDir, + files: [], + cursorLayout: 'sidecar', + cursorStorageContextKey: args.roots.storageContextKey + } + } + const discovered = await discoverLocalCursorSidecars(args.roots.chatsDir, args.issues) + const evidenceByPath = new Map() + const scopedFiles = await discoverScopedSidecars(args, evidenceByPath) + const files = await validateLocalCursorSidecars( + dedupeFiles([...scopedFiles, ...discovered]), + args.issues + ) + const rankedFiles = dedupeFiles(files) + const scopedPaths = new Set(evidenceByPath.keys()) + const retained = dedupeFiles([ + ...rankedFiles.filter((file) => scopedPaths.has(file.path)), + ...rankedFiles.filter((file) => !scopedPaths.has(file.path)).slice(0, args.limit) + ]) + return { + agent: 'cursor', + rootDir: args.roots.chatsDir, + files: retained, + cursorLayout: 'sidecar', + cursorStorageContextKey: args.roots.storageContextKey, + cursorCwdEvidenceByPath: evidenceByPath, + cursorExpectedRootRealPath: expectedRootRealPath + } +} + +async function discoverCursorLegacy(args: { + roots: CursorRootPair + options: AiVaultScanOptions + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const discovered = await discoverFiles({ + rootDir: args.roots.projectsDir, + limit: args.limit, + agent: 'cursor', + issues: args.issues, + extensions: ['.jsonl'], + filePredicate: (filePath) => filePath.split(/[\\/]/).includes('agent-transcripts') + }) + const evidenceByPath = new Map() + const scopedFiles: FileWithMtime[] = [] + for (const cwd of await localScopeCandidates(args)) { + const slug = cursorLegacySlug(cwd) + if (!slug) { + continue + } + const scopeDiscovery = await discoverFiles({ + rootDir: join(args.roots.projectsDir, slug, 'agent-transcripts'), + limit: Math.max(args.limit, 2000), + agent: 'cursor', + issues: args.issues, + extensions: ['.jsonl'] + }) + for (const file of scopeDiscovery.files) { + evidenceByPath.set(file.path, { + kind: 'legacy-scope-only', + cwd: null, + bucket: cursorBucketForCwd(cwd, args.roots.targetPlatform) + }) + scopedFiles.push(file) + } + } + return { + agent: 'cursor', + rootDir: args.roots.projectsDir, + files: dedupeFiles([...scopedFiles, ...discovered.files]), + cursorLayout: 'legacy', + cursorStorageContextKey: args.roots.storageContextKey, + cursorCwdEvidenceByPath: evidenceByPath + } +} + +async function discoverScopedSidecars( + args: { + roots: CursorRootPair + options: AiVaultScanOptions + limit: number + issues: AiVaultScanIssue[] + }, + evidenceByPath: Map +): Promise { + const files: FileWithMtime[] = [] + for (const cwd of await localScopeCandidates(args)) { + const bucket = cursorBucketForCwd(cwd, args.roots.targetPlatform) + const bucketDir = join(args.roots.chatsDir, bucket) + let entries + try { + const bucketStat = await lstat(bucketDir) + if (!bucketStat.isDirectory() || bucketStat.isSymbolicLink()) { + continue + } + entries = await readdir(bucketDir, { withFileTypes: true }) + } catch (error) { + if (!isMissingCursorPathError(error)) { + args.issues.push({ agent: 'cursor', path: bucketDir, message: errorMessage(error) }) + } + continue + } + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + !isSafeCursorSessionBasename(entry.name) + ) { + continue + } + const metaPath = join(bucketDir, entry.name, 'meta.json') + const file = await cursorLocalFileMetadata(metaPath) + if (file) { + evidenceByPath.set(metaPath, { kind: 'scope-bucket', cwd, bucket }) + files.push(file) + } + } + } + return files +} + +async function localScopeCandidates(args: { + roots: CursorRootPair + options: AiVaultScanOptions +}): Promise { + const candidates = new Set() + for (const scopePath of (args.options.scopePaths ?? []).slice(0, CURSOR_SCOPE_PATH_LIMIT)) { + for (const cwd of cursorScopeCwdCandidates({ + scopePath, + platform: args.roots.targetPlatform, + storageContextKey: args.roots.storageContextKey + })) { + candidates.add(cwd) + } + try { + const resolved = await realpath(scopePath) + for (const cwd of cursorScopeCwdCandidates({ + scopePath: resolved, + platform: args.roots.targetPlatform, + storageContextKey: args.roots.storageContextKey + })) { + candidates.add(cwd) + } + } catch { + // A scope path need not exist in every selected storage context. + } + } + return [...candidates] +} + +function dedupeFiles(files: readonly FileWithMtime[]): FileWithMtime[] { + const byPath = new Map() + for (const file of files) { + byPath.set(file.path, file) + } + return [...byPath.values()].sort( + (left, right) => cursorSessionActivityMtimeMs(right) - cursorSessionActivityMtimeMs(left) + ) +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index d93d6f01fea..a4120345eaa 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -12,11 +12,13 @@ export async function discoverFiles(args: { extensions: string[] filePredicate?: (path: string) => boolean directoryPredicate?: (name: string, depth: number) => boolean + recordDirectoryErrors?: boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate + directoryPredicate: args.directoryPredicate, + recordDirectoryErrors: args.recordDirectoryErrors }) const files: FileWithMtime[] = [] for (const path of paths) { @@ -76,13 +78,17 @@ export async function walkSessionFiles( // Return false to skip descending into a directory; depth 0 is a child of // rootDir, so pruned subtrees are never stat'd or parsed. directoryPredicate?: (name: string, depth: number) => boolean + recordDirectoryErrors?: boolean }, depth = 0 ): Promise { let entries try { entries = await readdir(dirPath, { withFileTypes: true }) - } catch { + } catch (err) { + if (options.recordDirectoryErrors && !isMissingPathError(err)) { + issues.push({ agent, path: dirPath, message: errorMessage(err) }) + } return [] } @@ -107,3 +113,11 @@ export async function walkSessionFiles( } return files } + +function isMissingPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 572002197e0..36ae82c41f5 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -12,6 +12,7 @@ import { createCopilotSessionResumeState } from './session-scanner-copilot-parse import { createCursorSessionResumeState } from './session-scanner-cursor-parser' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' +import { resetCursorSidecarParseCacheForTests } from './session-scanner-cursor-sidecar' // Sized past the default recency cap (1000) plus the in-scope cap (2000) so a // full steady-state result set stays resident between forced rescans. @@ -51,7 +52,9 @@ function resumableStateFactoryFor( case 'codex': return () => createCodexSessionResumeState(candidate.file, candidate.codexHome) case 'cursor': - return () => createCursorSessionResumeState(candidate.file) + return candidate.cursorLayout === 'sidecar' + ? null + : () => createCursorSessionResumeState(candidate.file) case 'copilot': return () => createCopilotSessionResumeState(candidate.file) case 'droid': @@ -93,6 +96,7 @@ const cache = new Map() export function resetSessionParseCacheForTests(): void { cache.clear() + resetCursorSidecarParseCacheForTests() } // Persisted subset of a cache entry: the non-serializable `resume` parser diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index cd369c41496..0f3f3670ec9 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -10,6 +10,7 @@ import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner import { normalizeAgentSessionsDir } from './session-scanner-values' import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { antigravityDiscoveries } from './session-scanner-antigravity-sources' +import { cursorDiscoveries } from './session-scanner-cursor-sources' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -20,7 +21,6 @@ const COPILOT_SESSIONS_DIR = join( process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), 'session-state' ) -const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') @@ -147,27 +147,6 @@ function standardDiscoveries( ] } -function cursorDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, wslHomeDirs, [ - '.cursor', - 'projects' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'cursor', - issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('agent-transcripts') - }) - ) -} - function grokDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index f69bf2939c7..77530537463 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -9,6 +9,7 @@ export function isolatedScanRoots(root: string) { antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), + cursorChatsDir: join(root, 'cursor-chats'), opencodeStorageDir: join(root, 'opencode-storage'), // Why: prevent the SQLite scanner from picking up the real // ~/.local/share/opencode/opencode.db during tests. diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 13b40698cfd..fc0cb08b34d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -18,6 +18,7 @@ export type AiVaultScanOptions = { antigravityBrainDir?: string copilotSessionsDir?: string cursorProjectsDir?: string + cursorChatsDir?: string opencodeStorageDir?: string // Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB // here so they don't depend on the real ~/.local/share/opencode. @@ -59,6 +60,7 @@ export type FileWithMtime = { dev?: number ino?: number nlink?: number + cursorStoreMtimeMs?: number } export type SessionFileCandidate = { @@ -66,12 +68,28 @@ export type SessionFileCandidate = { file: FileWithMtime codexHome: string | null antigravityHistoryPath?: string + cursorLayout?: CursorLayout + cursorStorageContextKey?: string + cursorCwdEvidence?: CursorCwdEvidence + cursorExpectedRootRealPath?: string } export type SessionFileDiscovery = { agent: AiVaultAgent rootDir: string files: FileWithMtime[] + cursorLayout?: CursorLayout + cursorStorageContextKey?: string + cursorCwdEvidenceByPath?: ReadonlyMap + cursorExpectedRootRealPath?: string +} + +export type CursorLayout = 'sidecar' | 'legacy' + +export type CursorCwdEvidence = { + kind: 'scope-bucket' | 'sidecar-bucket-match' | 'legacy-scope-only' + cwd: string | null + bucket?: string } export type SessionParseResult = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 3a98b7018ac..3a5f95e7749 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -5,8 +5,10 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { buildAiVaultSessionId } from '../../shared/ai-vault-session-id' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { mergeAiVaultSessions } from './session-list-results' import { codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, @@ -41,6 +43,7 @@ import type { import { clampPositiveInteger, errorMessage } from './session-scanner-values' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth' +import { processLocalCursorCandidates } from './session-scanner-cursor-local-pipeline' const SESSION_PARSE_CONCURRENCY = 8 const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2 @@ -96,7 +99,11 @@ export async function scanAiVaultSessions( antigravityHistoryPath: discovery.agent === 'antigravity' ? antigravityHistoryPathForBrainDir(discovery.rootDir) - : undefined + : undefined, + cursorLayout: discovery.cursorLayout, + cursorStorageContextKey: discovery.cursorStorageContextKey, + cursorCwdEvidence: discovery.cursorCwdEvidenceByPath?.get(file.path), + cursorExpectedRootRealPath: discovery.cursorExpectedRootRealPath }) ) ) @@ -109,8 +116,21 @@ export async function scanAiVaultSessions( } ) + const cursorCandidates = candidates.filter((candidate) => candidate.agent === 'cursor') + const nonCursorCandidates = candidates.filter((candidate) => candidate.agent !== 'cursor') + const cursorResult = await processLocalCursorCandidates({ + candidates: cursorCandidates, + limit, + scopeLimit: limit, + platform, + executionHostId, + issues, + parseStats, + span + }) + throwIfAiVaultScanCancelled(options.signal) const parsedSessions = await parseSessionCandidates({ - candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), + candidates: nonCursorCandidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), limit, platform, executionHostId, @@ -120,7 +140,10 @@ export async function scanAiVaultSessions( antigravityWorkspaceResolver }) - const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) + const cappedSessions = dedupeCodexSessionsBySessionId([ + ...parsedSessions, + ...cursorResult.sessions + ]) .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) @@ -138,6 +161,9 @@ export async function scanAiVaultSessions( // Scope discovery can return without parsing anything, so an abort landing // here would otherwise persist and return a cancelled scan as complete. throwIfAiVaultScanCancelled(options.signal) + const cursorScopeSessions = cursorResult.sessions.filter((session) => + cursorResult.scopedSessionIds.has(session.id) + ) span.setAttribute('candidates', candidates.length) span.setAttribute('reused', parseStats.reused) @@ -145,37 +171,16 @@ export async function scanAiVaultSessions( span.setAttribute('fullParses', parseStats.fullParses) span.setAttribute('bytesRead', parseStats.bytesRead) span.setAttribute('issues', issues.length) - scheduleSessionParseCachePersist(parseStats) return { - sessions: mergeSessions(cappedSessions, scopeSessions), + sessions: mergeAiVaultSessions(cappedSessions, [...scopeSessions, ...cursorScopeSessions]), issues: issues.map((issue) => ({ executionHostId, ...issue })), scannedAt: new Date().toISOString() } }) } -// In-scope sessions are guaranteed regardless of the recency cap, so the global -// (already capped) result and the scope result are unioned and de-duplicated by -// session id, then re-sorted DESC. -function mergeSessions( - cappedSessions: AiVaultSession[], - scopeSessions: AiVaultSession[] -): AiVaultSession[] { - if (scopeSessions.length === 0) { - return cappedSessions - } - const byId = new Map() - for (const session of cappedSessions) { - byId.set(session.id, session) - } - for (const session of scopeSessions) { - byId.set(session.id, session) - } - return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) -} - async function scanInScopeSessions(args: { discoveries: SessionFileDiscovery[] scopePaths: readonly string[] @@ -323,7 +328,13 @@ function withSessionExecutionHost( return { ...session, executionHostId, - id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + id: buildAiVaultSessionId({ + executionHostId, + agent: session.agent, + sessionId: session.sessionId, + filePath: session.filePath, + previousId: session.id + }) } } diff --git a/src/main/ipc/ai-vault-all-host-scan.test.ts b/src/main/ipc/ai-vault-all-host-scan.test.ts new file mode 100644 index 00000000000..d55b805b014 --- /dev/null +++ b/src/main/ipc/ai-vault-all-host-scan.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { scanAllAiVaultHosts } from './ai-vault-all-host-scan' + +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-29T00:00:00.000Z' +} + +describe('scanAllAiVaultHosts', () => { + it('keeps concurrent callers independently cancellable', async () => { + const resolvers: ((result: AiVaultListResult) => void)[] = [] + const signals: AbortSignal[] = [] + const scanSsh = (_host: string, signal: AbortSignal) => { + signals.push(signal) + return new Promise((resolve) => resolvers.push(resolve)) + } + const args = { + sshHosts: ['ssh-1'], + runtimeHosts: [] as string[], + runtimeIssues: [], + scanLocal: async () => EMPTY_RESULT, + scanSsh, + scanRuntime: async () => EMPTY_RESULT + } + + const first = scanAllAiVaultHosts(args) + const second = scanAllAiVaultHosts(args) + + expect(signals).toHaveLength(2) + expect(signals.every((signal) => !signal.aborted)).toBe(true) + resolvers.forEach((resolve) => resolve(EMPTY_RESULT)) + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + }) +}) diff --git a/src/main/ipc/ai-vault-all-host-scan.ts b/src/main/ipc/ai-vault-all-host-scan.ts new file mode 100644 index 00000000000..95986362150 --- /dev/null +++ b/src/main/ipc/ai-vault-all-host-scan.ts @@ -0,0 +1,38 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { mapDirectSshScans } from '../ai-vault/remote-session-scan-concurrency' +import { mergeAiVaultListResults } from '../ai-vault/session-list-results' + +const activeAbortControllers = new Set() + +export async function scanAllAiVaultHosts(args: { + sshHosts: readonly SshHost[] + runtimeHosts: readonly RuntimeHost[] + runtimeIssues: readonly AiVaultListResult[] + limit?: number + scanLocal: () => Promise + scanSsh: (host: SshHost, signal: AbortSignal) => Promise + scanRuntime: (host: RuntimeHost) => Promise +}): Promise { + const controller = new AbortController() + activeAbortControllers.add(controller) + try { + const [localResult, sshResults, runtimeResults] = await Promise.all([ + args.scanLocal(), + mapDirectSshScans(args.sshHosts, args.scanSsh, controller.signal), + Promise.all(args.runtimeHosts.map(args.scanRuntime)) + ]) + return mergeAiVaultListResults( + [localResult, ...sshResults, ...runtimeResults, ...args.runtimeIssues], + args.limit + ) + } finally { + activeAbortControllers.delete(controller) + } +} + +export function resetAllAiVaultHostScansForTests(): void { + for (const controller of activeAbortControllers) { + controller.abort() + } + activeAbortControllers.clear() +} diff --git a/src/main/ipc/ai-vault-refresh-policy.ts b/src/main/ipc/ai-vault-refresh-policy.ts new file mode 100644 index 00000000000..453225a6dd9 --- /dev/null +++ b/src/main/ipc/ai-vault-refresh-policy.ts @@ -0,0 +1,23 @@ +import type { AiVaultListArgs } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' + +export function shouldBypassAiVaultMergedCache(args: AiVaultListArgs | undefined): boolean { + return ( + args?.force === true || + args?.refreshReason === 'manual' || + (args?.refreshReason === 'session-start' && Boolean(args.refreshExecutionHostId)) + ) +} + +export function shouldForceAiVaultHost( + args: AiVaultListArgs | undefined, + executionHostId: ExecutionHostId +): boolean { + if (args?.force === true || args?.refreshReason === 'manual') { + return true + } + if (args?.refreshReason === 'passive' && executionHostId === LOCAL_EXECUTION_HOST_ID) { + return true + } + return args?.refreshReason === 'session-start' && args.refreshExecutionHostId === executionHostId +} diff --git a/src/main/ipc/ai-vault-subagent-list.ts b/src/main/ipc/ai-vault-subagent-list.ts new file mode 100644 index 00000000000..072b6462665 --- /dev/null +++ b/src/main/ipc/ai-vault-subagent-list.ts @@ -0,0 +1,44 @@ +import { resolve } from 'node:path' +import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' +import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' +import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { + AiVaultSubagentListArgs, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' + +// Provider-gated: only Claude materializes Task subagent transcripts as +// sibling files today; other agents resolve to an empty list. +export async function listAiVaultSubagentSessions( + args?: AiVaultSubagentListArgs +): Promise { + // IPC payloads are untyped at runtime; malformed input resolves empty like + // every other rejected input instead of throwing. + if ( + !args || + args.agent !== 'claude' || + typeof args.parentFilePath !== 'string' || + !args.parentFilePath.trim() + ) { + return { sessions: [], issues: [] } + } + // Why: subagent transcripts are read from the local filesystem. The UI + // skips remote sessions (their transcripts live on the remote host); return + // empty defensively rather than reading local paths for a remote session. + const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID + if (executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return { sessions: [], issues: [] } + } + // Why: the path is renderer-supplied; only list files under a known Claude + // projects root so a crafted path can't readdir/preview arbitrary dirs. + // resolve() collapses `..` segments first — isPathInsideOrEqual compares + // textually and would otherwise pass `/../../etc/x.jsonl`. + const parentFilePath = resolve(args.parentFilePath) + const roots = claudeProjectsRootDirs({ wslHomeDirs: await getAiVaultWslHomeDirs() }) + if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { + return { sessions: [], issues: [] } + } + return listClaudeSubagentSessions({ parentFilePath }) +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 1ecc3bb0a89..c15536bd23f 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -105,6 +105,20 @@ describe('listAiVaultSessions host routing', () => { ) }) + it('forwards manual force refreshes through the SSH result cache', async () => { + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + force: true + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + provider, + executionHostId: 'ssh:dev-box' + }) + ) + }) + it('uses one target-side relay scan when the SSH relay supports it', async () => { mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( result([session('local', 'remote-session')]) @@ -262,6 +276,37 @@ describe('listAiVaultSessions host routing', () => { expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) }) + it('keeps successful hosts when one SSH scan fails', async () => { + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([ + hostInfo('unavailable'), + hostInfo('dev-box') + ]) + mocks.getActiveSshAiVaultHostInfo.mockImplementation((targetId) => hostInfo(targetId)) + mocks.scanRemoteAiVaultSessions.mockImplementation(({ executionHostId }) => + executionHostId === 'ssh:unavailable' + ? Promise.reject(new Error('relay unavailable')) + : Promise.resolve(result([session('ssh:dev-box', 'remote-session')])) + ) + + const scanResult = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect( + mocks.scanRemoteAiVaultSessions.mock.calls.map(([scanArgs]) => scanArgs.executionHostId) + ).toEqual(['ssh:unavailable', 'ssh:dev-box']) + expect(scanResult.issues).toMatchObject([ + { + executionHostId: 'ssh:unavailable', + agent: 'codex', + path: 'unavailable', + message: 'relay unavailable' + } + ]) + expect(scanResult.sessions.map((entry) => entry.executionHostId)).toEqual([ + 'ssh:dev-box', + 'local' + ]) + }) + it('merges paired runtime servers for all hosts', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [ @@ -278,7 +323,8 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( 'remote-server', { - executionHostScope: 'runtime:remote-server' + executionHostScope: 'runtime:remote-server', + force: false }, expect.objectContaining({ timeoutMs: expect.any(Number) }) ) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5d3b0bd2e79..d4e7433f8c3 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,15 +1,10 @@ import { app, ipcMain } from 'electron' -import { resolve } from 'node:path' import { configureAiVaultSessionSources, - getAiVaultWslHomeDirs, listAiVaultSessions as listCachedLocalAiVaultSessions, resetAiVaultSessionListCacheForTests, type AiVaultSessionSources } from '../ai-vault/cached-session-list' -import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' -import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { aiVaultScanIssueResult, cancelledAiVaultListResult, @@ -22,9 +17,7 @@ import { isAiVaultScanCancelledError, type AiVaultFirstUserPromptArgs, type AiVaultListArgs, - type AiVaultListResult, - type AiVaultSubagentListArgs, - type AiVaultSubagentListResult + type AiVaultListResult } from '../../shared/ai-vault-types' import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' @@ -46,6 +39,9 @@ import { } from './ai-vault-runtime-scan' import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache' import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth' +import { mapDirectSshScans } from '../ai-vault/remote-session-scan-concurrency' +import { shouldBypassAiVaultMergedCache, shouldForceAiVaultHost } from './ai-vault-refresh-policy' +import { listAiVaultSubagentSessions } from './ai-vault-subagent-list' const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 // Why: a remote home with many agent roots routinely needs seconds to walk, @@ -86,12 +82,16 @@ async function listAiVaultSessions( }) const depth = requestedAiVaultSessionDepth(args) const scanKey = JSON.stringify({ key, depth }) + const force = + executionHostScope === 'all' + ? shouldBypassAiVaultMergedCache(args) + : shouldForceAiVaultHost(args, executionHostScope) // Why: every renderer request carries its own cancellation signal, so // coalescing has to survive them — the coordinator hands all same-key callers // one scan and only aborts it once every one of them has cancelled. return scanCoordinator.run({ key: scanKey, - force: args?.force, + force, signal: options.signal, start: (scanSignal) => { const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key) @@ -102,7 +102,7 @@ async function listAiVaultSessions( cacheKey: key, depth, scopePaths, - force: args?.force === true, + force, scan }) } @@ -127,40 +127,50 @@ async function scanAiVaultSessionsByHostScope( ...(runtimeHosts.issue ? [runtimeHosts.issue] : []), ...(sshHosts.issue ? [sshHosts.issue] : []) ] - const scannedResults = await Promise.all([ + const scanSignal = signal ?? new AbortController().signal + const [localResult, sshResults, runtimeScanResults] = await Promise.all([ scanLocalAiVaultSessionsForAllScope(args, signal), - ...sshHosts.hostInfos.map((hostInfo) => - scanHostLegWithCache({ - cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, - depth, - scopePaths, - force: args?.force === true, - scan: () => - scanSshAiVaultSessions(hostInfo.targetId, args, { - signal, - timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS, - relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS - }) - }) + mapDirectSshScans( + sshHosts.hostInfos, + (hostInfo, workerSignal) => { + const executionHostId = toSshExecutionHostId(hostInfo.targetId) + const hostArgs = withAiVaultHostForce(args, executionHostId) + return scanHostLegWithCache({ + cacheKey: `${cacheKey}|${executionHostId}`, + depth, + scopePaths, + force: hostArgs.force === true, + scan: () => + scanSshAiVaultSessions(hostInfo.targetId, hostArgs, { + signal: workerSignal, + timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS, + relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS + }) + }) + }, + scanSignal ), - ...runtimeHosts.hostInfos.map((hostInfo) => - scanHostLegWithCache({ - cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, - depth, - scopePaths, - force: args?.force === true, - scan: () => - scanRuntimeAiVaultSessions({ - hostInfo, - scanner: handlerOptions.scanRuntimeAiVaultSessions, - listArgs: args, - options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS } - }) + Promise.all( + runtimeHosts.hostInfos.map((hostInfo) => { + const hostArgs = withAiVaultHostForce(args, hostInfo.executionHostId) + return scanHostLegWithCache({ + cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, + depth, + scopePaths, + force: hostArgs.force === true, + scan: () => + scanRuntimeAiVaultSessions({ + hostInfo, + scanner: handlerOptions.scanRuntimeAiVaultSessions, + listArgs: hostArgs, + options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS } + }) + }) }) ) ]) return mergeAiVaultListResults( - [...scannedResults, ...runtimeResults], + [localResult, ...sshResults, ...runtimeScanResults, ...runtimeResults], args?.limit, args?.unlimited ) @@ -168,7 +178,9 @@ async function scanAiVaultSessionsByHostScope( const parsed = parseExecutionHostId(executionHostScope) if (parsed?.kind === 'ssh') { - return scanSshAiVaultSessions(parsed.targetId, args, { signal }) + return scanSshAiVaultSessions(parsed.targetId, withAiVaultHostForce(args, executionHostScope), { + signal + }) } if (parsed?.kind === 'runtime') { return scanRuntimeAiVaultSessions({ @@ -177,7 +189,7 @@ async function scanAiVaultSessionsByHostScope( executionHostId: toRuntimeExecutionHostId(parsed.environmentId) }, scanner: handlerOptions.scanRuntimeAiVaultSessions, - listArgs: args, + listArgs: withAiVaultHostForce(args, executionHostScope), options: { signal } }) } @@ -235,13 +247,20 @@ async function scanLocalAiVaultSessions( { limit: args?.limit, unlimited: args?.unlimited, - force: args?.force, + force: shouldForceAiVaultHost(args, LOCAL_EXECUTION_HOST_ID), scopePaths: args?.scopePaths }, { signal } ) } +function withAiVaultHostForce( + args: AiVaultListArgs | undefined, + executionHostId: Exclude +): AiVaultListArgs { + return { ...args, force: shouldForceAiVaultHost(args, executionHostId) } +} + export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options // Why: configure the SAME shared cache module the runtime RPC method uses so @@ -277,10 +296,8 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo } ) registerAiVaultResumeHandler(options) - ipcMain.handle( - 'aiVault:listSubagentSessions', - (_event, args?: AiVaultSubagentListArgs): Promise => - listAiVaultSubagentSessions(args) + ipcMain.handle('aiVault:listSubagentSessions', (_event, args) => + listAiVaultSubagentSessions(args) ) ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) => handleAiVaultGetFirstUserPrompt(args) @@ -294,40 +311,6 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) } -// Provider-gated: only Claude materializes Task subagent transcripts as -// sibling files today; other agents resolve to an empty list. -async function listAiVaultSubagentSessions( - args?: AiVaultSubagentListArgs -): Promise { - // IPC payloads are untyped at runtime; malformed input resolves empty like - // every other rejected input instead of throwing. - if ( - !args || - args.agent !== 'claude' || - typeof args.parentFilePath !== 'string' || - !args.parentFilePath.trim() - ) { - return { sessions: [], issues: [] } - } - // Why: subagent transcripts are read from the local filesystem. The UI - // skips remote sessions (their transcripts live on the remote host); return - // empty defensively rather than reading local paths for a remote session. - const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID - if (executionHostId !== LOCAL_EXECUTION_HOST_ID) { - return { sessions: [], issues: [] } - } - // Why: the path is renderer-supplied; only list files under a known Claude - // projects root so a crafted path can't readdir/preview arbitrary dirs. - // resolve() collapses `..` segments first — isPathInsideOrEqual compares - // textually and would otherwise pass `/../../etc/x.jsonl`. - const parentFilePath = resolve(args.parentFilePath) - const roots = claudeProjectsRootDirs({ wslHomeDirs: await getAiVaultWslHomeDirs() }) - if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { - return { sessions: [], issues: [] } - } - return listClaudeSubagentSessions({ parentFilePath }) -} - function resetAiVaultCacheForTests(): void { resetAiVaultHostLegCacheForTests() scanCoordinator = new AiVaultScanCoordinator() diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index c959bda20a1..9a4ec4fa1fd 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -570,6 +570,12 @@ describe('preflight', () => { it('only reports agents when which/where resolves to a real executable path', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { + if ( + (command === '/Users/test/.local/bin/cursor-agent' || command === 'cursor-agent') && + args?.[0] === '--help' + ) { + return { stdout: 'Cursor Agent help\n' } + } if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) } @@ -670,7 +676,7 @@ describe('preflight', () => { ) await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'codex', 'opencode']) - expect(resolveCliCommandsMock).toHaveBeenCalledTimes(1) + expect(resolveCliCommandsMock).toHaveBeenCalledTimes(2) }) it('does not double-count an agent already found on PATH via the install-dir resolver', async () => { @@ -689,8 +695,11 @@ describe('preflight', () => { ) await expect(detectInstalledAgents()).resolves.toEqual(['claude']) - expect(resolveCliCommandsMock).toHaveBeenCalledTimes(1) - expect(resolveCliCommandsMock).toHaveBeenCalledWith(expect.not.arrayContaining(['claude'])) + expect(resolveCliCommandsMock).toHaveBeenCalledTimes(2) + expect(resolveCliCommandsMock).toHaveBeenNthCalledWith( + 1, + expect.not.arrayContaining(['claude']) + ) }) it('treats an agent as not installed when the install-dir resolver throws', async () => { @@ -710,6 +719,12 @@ describe('preflight', () => { it('registers agent detection through the shared launch config commands', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { + if ( + (command === '/Users/test/.local/bin/cursor-agent' || command === 'cursor-agent') && + args?.[0] === '--help' + ) { + return { stdout: 'Cursor Agent help\n' } + } if (command !== 'which') { throw new Error(`unexpected command ${String(command)}`) } @@ -727,6 +742,26 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) }) + it('reports the successful cursor agent alias in the versioned inventory', async () => { + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command === 'cursor' && args?.[0] === 'agent' && args?.[1] === '--help') { + return { stdout: 'Cursor agent help\n' } + } + if (command === 'which' && String(args[0]) === 'cursor') { + return { stdout: '/Users/test/.local/bin/cursor\n' } + } + throw new Error('not found') + }) + registerPreflightHandlers() + + await expect(handlers['preflight:detectAgentInventory']()).resolves.toEqual({ + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor agent' } + }) + await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor']) + }) + it('hydrates shell PATH before user-facing agent detection', async () => { const originalPath = process.env.PATH process.env.PATH = '/usr/bin' @@ -855,6 +890,27 @@ describe('preflight', () => { }) }) + it('preserves the legacy Cursor launch command when inventory RPC is unavailable', async () => { + const request = vi + .fn() + .mockRejectedValueOnce(new Error('method_not_found')) + .mockResolvedValueOnce({ agents: ['cursor'] }) + getActiveMultiplexerMock.mockReturnValue({ + isDisposed: () => false, + request + }) + + registerPreflightHandlers() + + await expect( + handlers['preflight:detectRemoteAgentInventory'](undefined, { connectionId: 'ssh-1' }) + ).resolves.toEqual({ + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' } + }) + }) + it('returns no remote agents when the SSH connection is unavailable', async () => { getActiveMultiplexerMock.mockReturnValue(null) @@ -1041,6 +1097,7 @@ describe('preflight', () => { expect(result).toEqual({ agents: ['claude'], + matchedCommands: { claude: 'claude' }, addedPathSegments: [], shellHydrationOk: true, pathSource: 'sync_seed_only', @@ -1082,6 +1139,7 @@ describe('preflight', () => { expect(result).toEqual({ agents: ['opencode'], + matchedCommands: { opencode: 'opencode' }, addedPathSegments: ['/Users/test/.opencode/bin'], shellHydrationOk: true, pathSource: 'shell_hydrate', diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 5619495c1cf..15299c08b6f 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -6,27 +6,40 @@ import { getBitbucketAuthStatus } from '../bitbucket/client' import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' -import { getActiveMultiplexer } from './ssh' -import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' -import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' +import type { WslPreflightTarget } from './preflight-wsl-agent-detection' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' -import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' import { execCommandInWsl, execLocalPreflightCommand, isCommandAvailable, - isCommandOnPath, shellQuote } from './preflight-command-exec' import { detectRemoteWindowsTerminalCapabilities, type RemoteWindowsTerminalCapabilities } from './preflight-remote-windows-terminal-capabilities' +import type { DetectedTuiAgentsResult } from './tui-agent-detection-commands' import { - getTuiAgentDetectionProbeCommands, - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - resolveDetectedTuiAgentIds -} from './tui-agent-detection-commands' + detectInstalledAgentCommands, + detectInstalledAgentCommandsWithShellPathHydration, + detectInstalledAgentInventoryWithShellPathHydration, + detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgentCommands, + detectRemoteAgentInventory, + detectRemoteAgents +} from './tui-agent-inventory-detection' + +export { + detectInstalledAgentCommands, + detectInstalledAgentCommandsWithShellPathHydration, + detectInstalledAgentInventoryWithShellPathHydration, + detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, + detectRemoteAgentCommands, + detectRemoteAgentInventory, + detectRemoteAgents +} export type PreflightStatus = { git: { installed: boolean } @@ -65,10 +78,6 @@ export function _resetPreflightCache(): void { cached = null } -function uniqueAgentIds(ids: Iterable): string[] { - return [...new Set(ids)] -} - async function detectCommandRuntime( command: string, context?: PreflightRuntimeContext @@ -85,52 +94,10 @@ async function detectCommandRuntime( return { installed: false } } -export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - const foundCommands = await detectWslCommandsOnPath( - wslTarget, - getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') - ) - return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') - } - - const probeCommands = getTuiAgentDetectionProbeCommands( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - process.platform - ) - const pathChecks = await Promise.all( - probeCommands.map(async (cmd) => ({ - cmd, - installedOnPath: await isCommandOnPath(cmd) - })) - ) - const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) - // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution - // computes user install dirs once instead of blocking once per missed CLI. - const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const foundCommands = new Set( - pathChecks - .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) - .map(({ cmd }) => cmd) - ) - return resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - foundCommands, - process.platform - ) -} - -export async function detectInstalledAgentsWithShellPathHydration( - context?: PreflightRuntimeContext -): Promise { - await hydrateShellPathForAgentDetection(context) - return detectInstalledAgents(context) -} - export type RefreshAgentsResult = { /** Agents detected after hydrating PATH from the user's login shell. */ agents: string[] + matchedCommands?: DetectedTuiAgentsResult['matchedCommands'] /** PATH segments that were added this refresh (empty if nothing new). */ addedPathSegments: string[] /** True when the shell spawn succeeded. False = relied on existing PATH. */ @@ -155,9 +122,10 @@ export async function refreshShellPathAndDetectAgents( context?: PreflightRuntimeContext ): Promise { if (getPreflightWslTarget(context)) { - const agents = await detectInstalledAgents(context) + const detected = await detectInstalledAgentCommands(context) return { - agents, + agents: detected.agents, + matchedCommands: detected.matchedCommands, addedPathSegments: [], shellHydrationOk: true, pathSource: 'sync_seed_only', @@ -167,9 +135,10 @@ export async function refreshShellPathAndDetectAgents( const hydration = await hydrateShellPath({ force: true }) const added = hydration.ok ? mergePathSegments(hydration.segments) : [] - const agents = await detectInstalledAgents(context) + const detected = await detectInstalledAgentCommands(context) return { - agents, + agents: detected.agents, + matchedCommands: detected.matchedCommands, addedPathSegments: added, shellHydrationOk: hydration.ok, pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only', @@ -177,19 +146,6 @@ export async function refreshShellPathAndDetectAgents( } } -export async function detectRemoteAgents(args: { connectionId: string }): Promise { - const mux = getActiveMultiplexer(args.connectionId) - if (!mux || mux.isDisposed()) { - // Why: remote agent detection is passive UI polling. A disconnected host has - // no detectable agents until reconnect, but should not spam IPC errors. - return [] - } - const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS - })) as { agents: string[] } - return uniqueAgentIds(result.agents) -} - async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { try { await (wslTarget @@ -293,6 +249,12 @@ export function registerPreflightHandlers(): void { ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => detectInstalledAgentsWithShellPathHydration(args) ) + ipcMain.handle('preflight:detectAgentCommands', async (_event, args?: PreflightRuntimeContext) => + detectInstalledAgentCommandsWithShellPathHydration(args) + ) + ipcMain.handle('preflight:detectAgentInventory', async (_event, args?: PreflightRuntimeContext) => + detectInstalledAgentInventoryWithShellPathHydration(args) + ) ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { return refreshShellPathAndDetectAgents(args) @@ -308,6 +270,14 @@ export function registerPreflightHandlers(): void { return detectRemoteAgents(args) } ) + ipcMain.handle( + 'preflight:detectRemoteAgentCommands', + async (_event, args: { connectionId: string }) => detectRemoteAgentCommands(args) + ) + ipcMain.handle( + 'preflight:detectRemoteAgentInventory', + async (_event, args: { connectionId: string }) => detectRemoteAgentInventory(args) + ) ipcMain.handle( 'preflight:detectRemoteWindowsTerminalCapabilities', diff --git a/src/main/ipc/tui-agent-inventory-detection.ts b/src/main/ipc/tui-agent-inventory-detection.ts new file mode 100644 index 00000000000..cc8b3b2a5c2 --- /dev/null +++ b/src/main/ipc/tui-agent-inventory-detection.ts @@ -0,0 +1,152 @@ +import { resolveCliCommands } from '../codex-cli/command' +import { + detectedAgentInventorySchema, + emptyDetectedAgentInventory, + legacyDetectedAgentInventory +} from '../../shared/detected-agent-inventory' +import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' +import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' +import { + execCommandInWsl, + execLocalPreflightCommand, + isCommandOnPath, + shellQuote +} from './preflight-command-exec' +import type { PreflightRuntimeContext } from './preflight-runtime-target' +import { getPreflightWslTarget } from './preflight-runtime-target' +import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import { getActiveMultiplexer } from './ssh' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgents, + type DetectedTuiAgentsResult +} from './tui-agent-detection-commands' + +export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + return (await detectInstalledAgentCommands(context)).agents +} + +export async function detectInstalledAgentCommands( + context?: PreflightRuntimeContext +): Promise { + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + const foundCommands = await detectWslCommandsOnPath( + wslTarget, + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') + ) + await removeFailedCapabilityProbes(foundCommands, wslTarget) + return resolveDetectedTuiAgents(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') + } + + const probeCommands = getTuiAgentDetectionProbeCommands( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + process.platform + ) + const pathChecks = await Promise.all( + probeCommands.map(async (cmd) => ({ + cmd, + installedOnPath: await isCommandOnPath(cmd) + })) + ) + const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) + const installDirCommands = detectCommandsInInstallDirs(missedCommands) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + await removeFailedCapabilityProbes(foundCommands) + return resolveDetectedTuiAgents( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + foundCommands, + process.platform + ) +} + +async function removeFailedCapabilityProbes( + foundCommands: Set, + wslTarget?: ReturnType +): Promise { + const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( + (command) => command.capabilityProbe && foundCommands.has(command.cmd) + ) + let resolvedLocalCommands = new Map() + if (!wslTarget) { + try { + resolvedLocalCommands = resolveCliCommands(commands.map((command) => command.cmd)) + } catch { + // PATH resolution below remains the bounded fallback. + } + } + await Promise.all( + commands.map(async (command) => { + try { + if (wslTarget) { + const invocation = [command.cmd, ...(command.capabilityProbe?.args ?? [])] + .map(shellQuote) + .join(' ') + await execCommandInWsl(wslTarget, invocation) + } else { + await execLocalPreflightCommand(resolvedLocalCommands.get(command.cmd) ?? command.cmd, [ + ...(command.capabilityProbe?.args ?? []) + ]) + } + } catch { + foundCommands.delete(command.cmd) + } + }) + ) +} + +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + +export async function detectInstalledAgentCommandsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgentCommands(context) +} + +export const detectInstalledAgentInventoryWithShellPathHydration = + detectInstalledAgentCommandsWithShellPathHydration + +export async function detectRemoteAgentCommands(args: { + connectionId: string +}): Promise { + const mux = getActiveMultiplexer(args.connectionId) + if (!mux || mux.isDisposed()) { + return emptyDetectedAgentInventory() + } + try { + return detectedAgentInventorySchema.parse( + await mux.request('preflight.detectAgentInventory', { + version: 1, + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS + }) + ) + } catch { + return legacyDetectedAgentInventory( + (await detectRemoteAgents(args)) as DetectedTuiAgentsResult['agents'] + ) + } +} + +export async function detectRemoteAgents(args: { connectionId: string }): Promise { + const mux = getActiveMultiplexer(args.connectionId) + if (!mux || mux.isDisposed()) { + return [] + } + const result = (await mux.request('preflight.detectAgents', { + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS + })) as { agents: string[] } + return [...new Set(result.agents)] +} + +export const detectRemoteAgentInventory = detectRemoteAgentCommands diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index 855f36da98c..241d4975a11 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -1,4 +1,8 @@ import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' +import type { + CursorSidecarScanRequest, + CursorSidecarScanResponse +} from '../../shared/cursor-sidecar-scan' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' export type FileStat = { @@ -21,6 +25,10 @@ export type FileReadResult = { export type IFilesystemProvider = { readDir(dirPath: string): Promise readFile(filePath: string): Promise + scanCursorSidecars?( + request: CursorSidecarScanRequest, + options?: { signal?: AbortSignal } + ): Promise readTerminalArtifact?( filePath: string, options: TerminalArtifactAccessOptions diff --git a/src/main/providers/ssh-cursor-sidecar-scan.test.ts b/src/main/providers/ssh-cursor-sidecar-scan.test.ts new file mode 100644 index 00000000000..e83e278ec9e --- /dev/null +++ b/src/main/providers/ssh-cursor-sidecar-scan.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { defaultCursorSidecarScanRequest } from '../../shared/cursor-sidecar-scan' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { scanSshCursorSidecars } from './ssh-cursor-sidecar-scan' + +describe('scanSshCursorSidecars', () => { + it('uses one versioned relay request and validates the response', async () => { + const response = { + version: 1, + scopeCwds: [], + sidecars: [], + issues: [], + counters: { + rootReaddir: 0, + bucketReaddir: 0, + fileLstat: 0, + boundedReads: 0, + scopeRealpath: 0, + returnedBytes: 0, + elapsedMs: 0 + }, + truncated: { + scopePaths: false, + buckets: false, + sessionDirs: false, + sidecarBytes: false + } + } + const requestRpc = vi.fn().mockResolvedValue(response) + const request = defaultCursorSidecarScanRequest('/home/user/.cursor/chats', [], 'linux') + + await expect( + scanSshCursorSidecars({ request: requestRpc } as unknown as SshChannelMultiplexer, request) + ).resolves.toEqual(response) + expect(requestRpc).toHaveBeenCalledWith( + 'fs.scanCursorSidecars', + request, + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ) + }) + + it('fails closed when an older relay lacks the scan capability', async () => { + const requestRpc = vi.fn().mockRejectedValue( + Object.assign(new Error('Method not found'), { + code: JsonRpcErrorCode.MethodNotFound + }) + ) + const request = defaultCursorSidecarScanRequest('/home/user/.cursor/chats', [], 'linux') + await expect( + scanSshCursorSidecars({ request: requestRpc } as unknown as SshChannelMultiplexer, request) + ).rejects.toThrow('remote_cursor_sidecar_scan_unavailable') + }) +}) diff --git a/src/main/providers/ssh-cursor-sidecar-scan.ts b/src/main/providers/ssh-cursor-sidecar-scan.ts new file mode 100644 index 00000000000..e76ad301614 --- /dev/null +++ b/src/main/providers/ssh-cursor-sidecar-scan.ts @@ -0,0 +1,30 @@ +import { + cursorSidecarScanRequestSchema, + cursorSidecarScanResponseSchema, + type CursorSidecarScanRequest, + type CursorSidecarScanResponse +} from '../../shared/cursor-sidecar-scan' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' + +const CURSOR_SIDECAR_SCAN_TIMEOUT_MS = 45_000 + +export async function scanSshCursorSidecars( + mux: SshChannelMultiplexer, + request: CursorSidecarScanRequest, + options?: { signal?: AbortSignal } +): Promise { + const validatedRequest = cursorSidecarScanRequestSchema.parse(request) + try { + const result = await mux.request('fs.scanCursorSidecars', validatedRequest, { + signal: options?.signal, + timeoutMs: CURSOR_SIDECAR_SCAN_TIMEOUT_MS + }) + return cursorSidecarScanResponseSchema.parse(result) + } catch (error) { + if (isMethodNotFoundError(error)) { + throw new Error('remote_cursor_sidecar_scan_unavailable') + } + throw error + } +} diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 74f31b8e6bf..e2d088ec961 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -24,7 +24,9 @@ import type { import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' +import type { CursorSidecarScanRequest } from '../../shared/cursor-sidecar-scan' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanSshCursorSidecars } from './ssh-cursor-sidecar-scan' const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 export class SshFilesystemProvider implements IFilesystemProvider { @@ -109,6 +111,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + async scanCursorSidecars(request: CursorSidecarScanRequest, options?: { signal?: AbortSignal }) { + return scanSshCursorSidecars(this.mux, request, options) + } + async readTerminalArtifact( filePath: string, options: TerminalArtifactAccessOptions diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0454246661c..03f3eddf402 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -33277,7 +33277,8 @@ describe('OrcaRuntimeService', () => { expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ repoId: TEST_REPO_ID, - terminalPlatform: 'linux' + terminalPlatform: 'linux', + wslDistro: 'Ubuntu' }) expect(getProjects).toHaveBeenCalledTimes(1) expect(getSettings).toHaveBeenCalledTimes(2) @@ -33339,8 +33340,12 @@ describe('OrcaRuntimeService', () => { const staleResult = await stalePoll // Why: invalidation can let a newer scan finish first; each result keeps the platform map from its own generation. - expect(staleResult.worktrees[0]).toMatchObject({ terminalPlatform: 'linux' }) + expect(staleResult.worktrees[0]).toMatchObject({ + terminalPlatform: 'linux', + wslDistro: 'Ubuntu' + }) expect(freshPoll.worktrees[0]).toMatchObject({ terminalPlatform: 'win32' }) + expect(freshPoll.worktrees[0]?.wslDistro).toBeUndefined() }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e23498f370..0cb19ed25b7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -417,6 +417,7 @@ import { } from '../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' +import { resolveRuntimeAgentCommandOverrides } from './runtime-cursor-command' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -2623,6 +2624,7 @@ class WorktreeIdRequiresFullPathError extends Error { type ResolvedWorktreeSnapshot = { worktrees: ResolvedWorktree[] platformByRepoId: ReadonlyMap + wslDistroByRepoId: ReadonlyMap } type ResolvedWorktreeCache = ResolvedWorktreeSnapshot & { @@ -17331,6 +17333,7 @@ export class OrcaRuntimeService { const freshPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId + const wslDistroByRepoId = resolvedWorktreeSnapshot.wslDistroByRepoId const summaries = new Map() // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), @@ -17360,6 +17363,7 @@ export class OrcaRuntimeService { linkedPR = { number: meta.linkedPR, state: 'unknown' } } const terminalPlatform = platformByRepoId.get(worktree.repoId) ?? process.platform + const wslDistro = wslDistroByRepoId.get(worktree.repoId) // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, // so a stale same-path entry would nest replacement checkouts under old parents. @@ -17372,6 +17376,7 @@ export class OrcaRuntimeService { repoId: worktree.repoId, ...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}), terminalPlatform, + ...(wslDistro ? { wslDistro } : {}), repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, @@ -17420,12 +17425,14 @@ export class OrcaRuntimeService { continue } const worktree = folderWorkspaceToWorktree(folderWorkspace) + const wslDistro = parseWslUncPath(worktree.path)?.distro summaries.set(worktree.id, { // Why: folder workspaces use the same mobile grouping/order contract as // git worktrees, but legacy records may be missing order metadata. workspaceKind: 'folder-workspace', worktreeId: worktree.id, repoId: worktree.repoId, + ...(wslDistro ? { wslDistro } : {}), repo: projectGroup.name, path: worktree.path, branch: worktree.branch, @@ -19097,6 +19104,27 @@ export class OrcaRuntimeService { return getAgentLaunchPlatformForRepo(repo, projectRuntime) } + private getAgentLaunchWslDistroForRepo(repo: Repo): string | null { + if (repo.connectionId) { + return null + } + const projectRuntime = resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) + const runtime = + projectRuntime?.status === 'resolved' + ? projectRuntime.runtime + : projectRuntime?.repair.preferredRuntime + return runtime?.kind === 'wsl' ? runtime.distro : (parseWslUncPath(repo.path)?.distro ?? null) + } + + private getAgentLaunchWslDistroForWorkspace(scope: TerminalWorkspaceLaunchScope): string | null { + if (scope.connectionId) { + return null + } + return scope.repo + ? this.getAgentLaunchWslDistroForRepo(scope.repo) + : (parseWslUncPath(scope.path)?.distro ?? null) + } + private getAgentLaunchPlatformForWorkspace(scope: TerminalWorkspaceLaunchScope): NodeJS.Platform { if (scope.repo) { return this.getAgentLaunchPlatformForRepo(scope.repo) @@ -20908,10 +20936,17 @@ export class OrcaRuntimeService { isRemote, terminalWindowsShell: settings.terminalWindowsShell }) + const cmdOverrides = await resolveRuntimeAgentCommandOverrides({ + agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + connectionId: repo.connectionId, + wslDistro: this.getAgentLaunchWslDistroForRepo(repo), + workspacePath: repo.path + }) const draftLaunchPlan = buildAgentDraftLaunchPlan({ agent, draft: content, - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, @@ -20935,7 +20970,7 @@ export class OrcaRuntimeService { const startupPlan = buildAgentStartupPlan({ agent, prompt: '', - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, @@ -20960,11 +20995,15 @@ export class OrcaRuntimeService { } } - private buildStartupForAgent( + private async buildStartupForAgent( repo: Repo, agent: TuiAgent, prompt: string | undefined - ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { + ): Promise<{ + agent: TuiAgent + startup: WorktreeStartupLaunch + followup?: WorktreeStartupFollowup + }> { if (!this.store) { throw new Error('runtime_unavailable') } @@ -20981,10 +21020,17 @@ export class OrcaRuntimeService { isRemote, terminalWindowsShell: settings.terminalWindowsShell }) + const cmdOverrides = await resolveRuntimeAgentCommandOverrides({ + agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + connectionId: repo.connectionId, + wslDistro: this.getAgentLaunchWslDistroForRepo(repo), + workspacePath: repo.path + }) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, @@ -21457,7 +21503,7 @@ export class OrcaRuntimeService { } const agentStartup = !args.startup && args.startupAgent - ? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt) + ? await this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt) : null const draftStartup = !args.startup && !agentStartup && args.startupDraft @@ -24629,7 +24675,13 @@ export class OrcaRuntimeService { const startupPlan = buildAgentStartupPlan({ agent, prompt: '', - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides: await resolveRuntimeAgentCommandOverrides({ + agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + connectionId: workspace.connectionId, + wslDistro: this.getAgentLaunchWslDistroForWorkspace(workspace), + workspacePath: workspace.path + }), agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform, @@ -24769,7 +24821,13 @@ export class OrcaRuntimeService { const startup = buildAgentResumeStartupPlan({ agent: request.agent, providerSession: identity.providerSession, - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides: await resolveRuntimeAgentCommandOverrides({ + agent: request.agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + connectionId: workspace.connectionId, + wslDistro: this.getAgentLaunchWslDistroForWorkspace(workspace), + workspacePath: workspace.path + }), agentArgs: request.agentArgs !== undefined ? request.agentArgs @@ -24932,7 +24990,13 @@ export class OrcaRuntimeService { }) const startupArgs = { agent: request.agent, - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides: await resolveRuntimeAgentCommandOverrides({ + agent: request.agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + connectionId: workspace.connectionId, + wslDistro: this.getAgentLaunchWslDistroForWorkspace(workspace), + workspacePath: workspace.path + }), agentArgs: request.agentArgs !== undefined ? request.agentArgs @@ -25596,7 +25660,7 @@ export class OrcaRuntimeService { if (!repo) { throw new Error('Repository for the selected workspace is no longer available.') } - const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) + const startup = await this.buildStartupForAgent(repo, opts.agent, opts.prompt) if (repo.connectionId) { await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) } else { @@ -25976,7 +26040,13 @@ export class OrcaRuntimeService { const startupPlan = buildAgentStartupPlan({ agent: opts.agent, prompt: opts.agentPrompt ?? '', - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides: await resolveRuntimeAgentCommandOverrides({ + agent: opts.agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + connectionId: workspace.connectionId, + wslDistro: this.getAgentLaunchWslDistroForWorkspace(workspace), + workspacePath: workspace.path + }), agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv), platform, @@ -28506,7 +28576,7 @@ export class OrcaRuntimeService { private async listResolvedWorktreeSnapshot(): Promise { if (!this.store) { - return { worktrees: [], platformByRepoId: new Map() } + return { worktrees: [], platformByRepoId: new Map(), wslDistroByRepoId: new Map() } } const now = Date.now() if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { @@ -28530,7 +28600,7 @@ export class OrcaRuntimeService { private async computeResolvedWorktrees(generation: number): Promise { if (!this.store) { - return { worktrees: [], platformByRepoId: new Map() } + return { worktrees: [], platformByRepoId: new Map(), wslDistroByRepoId: new Map() } } const now = Date.now() const metaById = this.store.getAllWorktreeMeta() ?? {} @@ -28542,6 +28612,17 @@ export class OrcaRuntimeService { getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) ]) ) + const wslDistroByRepoId = new Map( + repos.flatMap((repo): [string, string][] => { + const resolution = projectRuntimeByRepoId.get(repo.id) + const runtime = + resolution?.status === 'resolved' + ? resolution.runtime + : resolution?.repair.preferredRuntime + const distro = runtime?.kind === 'wsl' ? runtime.distro?.trim() : null + return distro ? [[repo.id, distro]] : [] + }) + ) const perRepoWorktrees = await Promise.all( repos.map(async (repo) => { if (isFolderRepo(repo)) { @@ -28617,10 +28698,11 @@ export class OrcaRuntimeService { this.resolvedWorktreeCache = { worktrees, platformByRepoId, + wslDistroByRepoId, expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS } } - return { worktrees, platformByRepoId } + return { worktrees, platformByRepoId, wslDistroByRepoId } } private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index e8f1ef82930..c935f6d3c14 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -5,13 +5,17 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { PREFLIGHT_METHODS } from './preflight' const { + detectInstalledAgentInventoryWithShellPathHydrationMock, detectInstalledAgentsWithShellPathHydrationMock, + detectRemoteAgentInventoryMock, detectRemoteAgentsMock, detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ + detectInstalledAgentInventoryWithShellPathHydrationMock: vi.fn(), detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), + detectRemoteAgentInventoryMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), @@ -19,7 +23,10 @@ const { })) vi.mock('../../../ipc/preflight', () => ({ + detectInstalledAgentInventoryWithShellPathHydration: + detectInstalledAgentInventoryWithShellPathHydrationMock, detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, + detectRemoteAgentInventory: detectRemoteAgentInventoryMock, detectRemoteAgents: detectRemoteAgentsMock, detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, @@ -72,6 +79,51 @@ describe('preflight RPC methods', () => { }) }) + it('preserves matched commands through the versioned runtime inventory methods', async () => { + const localInventory = { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor agent' } + } + const remoteInventory = { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' } + } + detectInstalledAgentInventoryWithShellPathHydrationMock.mockResolvedValueOnce(localInventory) + detectRemoteAgentInventoryMock.mockResolvedValueOnce(remoteInventory) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + + await expect( + dispatcher.dispatch(makeRequest('preflight.detectAgentInventory', { wslDistro: 'Ubuntu' })) + ).resolves.toMatchObject({ ok: true, result: localInventory }) + await expect( + dispatcher.dispatch( + makeRequest('preflight.detectRemoteAgentInventory', { connectionId: 'ssh-1' }) + ) + ).resolves.toMatchObject({ ok: true, result: remoteInventory }) + expect(detectInstalledAgentInventoryWithShellPathHydrationMock).toHaveBeenCalledWith({ + wslDistro: 'Ubuntu' + }) + expect(detectRemoteAgentInventoryMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) + }) + + it('keeps no-params inventory requests backward compatible', async () => { + detectInstalledAgentInventoryWithShellPathHydrationMock.mockResolvedValueOnce({ + version: 1, + agents: [], + matchedCommands: {} + }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + + await expect( + dispatcher.dispatch(makeRequest('preflight.detectAgentInventory')) + ).resolves.toMatchObject({ ok: true }) + expect(detectInstalledAgentInventoryWithShellPathHydrationMock).toHaveBeenCalledWith({}) + }) + it('detects agents on remote SSH connections through runtime RPC', async () => { detectRemoteAgentsMock.mockResolvedValueOnce(['claude']) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index aafbb96319c..c61dc5220e9 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,8 +2,12 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, + detectRemoteAgentCommands, + detectRemoteAgentInventory, detectRemoteWindowsTerminalCapabilities, detectInstalledAgentsWithShellPathHydration, + detectInstalledAgentCommandsWithShellPathHydration, + detectInstalledAgentInventoryWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../ipc/preflight' @@ -11,6 +15,9 @@ import { const PreflightCheck = z.object({ force: z.boolean().optional() }) +const PreflightLocalAgentContext = z.object({ + wslDistro: z.string().min(1).optional() +}) const PreflightDetectRemoteAgents = z.object({ connectionId: z.string().min(1) }) @@ -29,11 +36,31 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ params: null, handler: async () => detectInstalledAgentsWithShellPathHydration() }), + defineMethod({ + name: 'preflight.detectAgentCommands', + params: null, + handler: async () => detectInstalledAgentCommandsWithShellPathHydration() + }), + defineMethod({ + name: 'preflight.detectAgentInventory', + params: PreflightLocalAgentContext, + handler: async (params) => detectInstalledAgentInventoryWithShellPathHydration(params) + }), defineMethod({ name: 'preflight.detectRemoteAgents', params: PreflightDetectRemoteAgents, handler: async (params) => detectRemoteAgents(params) }), + defineMethod({ + name: 'preflight.detectRemoteAgentCommands', + params: PreflightDetectRemoteAgents, + handler: async (params) => detectRemoteAgentCommands(params) + }), + defineMethod({ + name: 'preflight.detectRemoteAgentInventory', + params: PreflightDetectRemoteAgents, + handler: async (params) => detectRemoteAgentInventory(params) + }), defineMethod({ name: 'preflight.detectRemoteWindowsTerminalCapabilities', params: PreflightDetectRemoteWindowsTerminalCapabilities, diff --git a/src/main/runtime/runtime-cursor-command.test.ts b/src/main/runtime/runtime-cursor-command.test.ts new file mode 100644 index 00000000000..26a6927194f --- /dev/null +++ b/src/main/runtime/runtime-cursor-command.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + detectLocal: vi.fn(), + detectRemote: vi.fn() +})) + +vi.mock('../ipc/tui-agent-inventory-detection', () => ({ + detectInstalledAgentCommandsWithShellPathHydration: mocks.detectLocal, + detectRemoteAgentCommands: mocks.detectRemote +})) + +import { resolveRuntimeAgentCommandOverrides } from './runtime-cursor-command' + +const inventory = (command?: string) => ({ + version: 1 as const, + agents: command ? (['cursor'] as const) : [], + matchedCommands: command ? { cursor: command } : {} +}) + +beforeEach(() => { + mocks.detectLocal.mockReset().mockResolvedValue(inventory()) + mocks.detectRemote.mockReset().mockResolvedValue(inventory()) +}) + +describe('resolveRuntimeAgentCommandOverrides', () => { + it('keeps an explicit override authoritative without probing', async () => { + await expect( + resolveRuntimeAgentCommandOverrides({ + agent: 'cursor', + cmdOverrides: { cursor: 'cursor-dev' }, + workspacePath: '/repo' + }) + ).resolves.toEqual({ cursor: 'cursor-dev' }) + expect(mocks.detectLocal).not.toHaveBeenCalled() + }) + + it('uses the native or exact WSL inventory match', async () => { + mocks.detectLocal.mockResolvedValueOnce(inventory('cursor agent')) + await expect( + resolveRuntimeAgentCommandOverrides({ + agent: 'cursor', + cmdOverrides: {}, + workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + }) + ).resolves.toEqual({ cursor: 'cursor agent' }) + expect(mocks.detectLocal).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' }) + }) + + it('prefers configured WSL for an ordinary Windows workspace path', async () => { + mocks.detectLocal.mockResolvedValueOnce(inventory('cursor-agent')) + await expect( + resolveRuntimeAgentCommandOverrides({ + agent: 'cursor', + cmdOverrides: {}, + wslDistro: ' Ubuntu ', + workspacePath: 'C:\\src\\repo' + }) + ).resolves.toEqual({ cursor: 'cursor-agent' }) + expect(mocks.detectLocal).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' }) + }) + + it('uses only the selected SSH connection inventory', async () => { + mocks.detectRemote.mockResolvedValueOnce(inventory('cursor-agent')) + await expect( + resolveRuntimeAgentCommandOverrides({ + agent: 'cursor', + cmdOverrides: {}, + connectionId: 'ssh-1', + workspacePath: '/repo' + }) + ).resolves.toEqual({ cursor: 'cursor-agent' }) + expect(mocks.detectRemote).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) + expect(mocks.detectLocal).not.toHaveBeenCalled() + }) + + it('leaves Cursor unavailable when probing fails or has no capability match', async () => { + mocks.detectLocal.mockRejectedValueOnce(new Error('unavailable')) + await expect( + resolveRuntimeAgentCommandOverrides({ + agent: 'cursor', + cmdOverrides: {}, + workspacePath: '/repo' + }) + ).resolves.toEqual({}) + }) + + it('does not probe commands for other agents', async () => { + const overrides = { claude: 'claude-dev' } + await expect( + resolveRuntimeAgentCommandOverrides({ + agent: 'claude', + cmdOverrides: overrides, + workspacePath: '/repo' + }) + ).resolves.toBe(overrides) + expect(mocks.detectLocal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-cursor-command.ts b/src/main/runtime/runtime-cursor-command.ts new file mode 100644 index 00000000000..2f350be577b --- /dev/null +++ b/src/main/runtime/runtime-cursor-command.ts @@ -0,0 +1,29 @@ +import { resolveEffectiveCursorCommand } from '../../shared/cursor-command' +import type { TuiAgent } from '../../shared/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + detectInstalledAgentCommandsWithShellPathHydration, + detectRemoteAgentCommands +} from '../ipc/tui-agent-inventory-detection' + +export async function resolveRuntimeAgentCommandOverrides(args: { + agent: TuiAgent + cmdOverrides: Partial> + connectionId?: string | null + wslDistro?: string | null + workspacePath: string +}): Promise>> { + if (args.agent !== 'cursor' || args.cmdOverrides.cursor?.trim()) { + return args.cmdOverrides + } + try { + const wslDistro = args.wslDistro?.trim() || parseWslUncPath(args.workspacePath)?.distro + const inventory = args.connectionId + ? await detectRemoteAgentCommands({ connectionId: args.connectionId }) + : await detectInstalledAgentCommandsWithShellPathHydration(wslDistro ? { wslDistro } : {}) + const command = resolveEffectiveCursorCommand(null, inventory) + return command ? { ...args.cmdOverrides, cursor: command } : args.cmdOverrides + } catch { + return args.cmdOverrides + } +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b6cb7ecf259..a846298e8eb 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -352,7 +352,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'pairing.provisionRelay', 'preflight.check', 'preflight.detectAgents', + 'preflight.detectAgentInventory', + 'preflight.detectAgentCommands', 'preflight.detectRemoteAgents', + 'preflight.detectRemoteAgentInventory', + 'preflight.detectRemoteAgentCommands', 'projectGroup.list', 'repo.baseRefDefault', 'repo.gitAvailable', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0dc78393f28..d0470b27a9a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,6 +10,7 @@ import type { } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { BrowserFindSource } from '../shared/browser-find-source' +import type { DetectedAgentInventoryV1 } from '../shared/detected-agent-inventory' import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, @@ -714,6 +715,7 @@ export type PreflightStatus = { export type RefreshAgentsResult = { agents: string[] + matchedCommands?: Record addedPathSegments: string[] shellHydrationOk: boolean /** Drives agent_picks `on_path:false` triage (dashboard 1562016). `'shell_hydrate'` = detection saw the user's @@ -723,6 +725,11 @@ export type RefreshAgentsResult = { pathFailureReason: ShellHydrationFailureReason } +export type DetectedAgentCommandsResult = { + agents: string[] + matchedCommands?: Record +} + export type PreflightRuntimeContext = { wslDistro?: string | null wslDefault?: boolean @@ -732,8 +739,14 @@ export type PreflightRuntimeContext = { export type PreflightApi = { check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise detectAgents: (args?: PreflightRuntimeContext) => Promise + detectAgentInventory: (args?: PreflightRuntimeContext) => Promise + detectAgentCommands: (args?: PreflightRuntimeContext) => Promise refreshAgents: (args?: PreflightRuntimeContext) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise + detectRemoteAgentInventory: (args: { connectionId: string }) => Promise + detectRemoteAgentCommands: (args: { + connectionId: string + }) => Promise detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ wslAvailable: boolean wslDistros: string[] diff --git a/src/preload/index.ts b/src/preload/index.ts index 36e26870868..ad041ca5e1c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -207,6 +207,7 @@ import type { import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { PreflightRuntimeContext, + DetectedAgentCommandsResult, RefreshAgentsResult, NativeChatAppendedPayload, NativeChatReadSessionResult, @@ -221,6 +222,7 @@ import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-e import { createBrowserFindSubscriptions } from './browser-find-subscriptions' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' +import type { DetectedAgentInventoryV1 } from '../shared/detected-agent-inventory' import type { Automation, AutomationCreateInput, @@ -2167,10 +2169,22 @@ const api = { }> => ipcRenderer.invoke('preflight:check', args), detectAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:detectAgents', args), + detectAgentInventory: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:detectAgentInventory', args), + detectAgentCommands: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:detectAgentCommands', args), refreshAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args), + detectRemoteAgentInventory: (args: { + connectionId: string + }): Promise => + ipcRenderer.invoke('preflight:detectRemoteAgentInventory', args), + detectRemoteAgentCommands: (args: { + connectionId: string + }): Promise => + ipcRenderer.invoke('preflight:detectRemoteAgentCommands', args), detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }): Promise<{ diff --git a/src/relay/cursor-sidecar-scan-discovery.ts b/src/relay/cursor-sidecar-scan-discovery.ts new file mode 100644 index 00000000000..b9a1e718cfc --- /dev/null +++ b/src/relay/cursor-sidecar-scan-discovery.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto' +import type { Dirent, Stats } from 'node:fs' +import { lstat, readdir, realpath } from 'node:fs/promises' +import { join, posix, win32 } from 'node:path' +import type { + CursorSidecarScanRequest, + CursorSidecarScanResponse +} from '../shared/cursor-sidecar-scan' +import type { RequestContext } from './dispatcher' + +const BUCKET_PATTERN = /^[0-9a-f]{32}$/u +const BUCKET_READ_CONCURRENCY = 8 + +export type CursorSidecarScanCaps = { + buckets: number + sessions: number + scopes: number + sidecarBytes: number + aggregateBytes: number +} + +type Bucket = { name: string; path: string; scopeCwd: string | null } + +export type CursorSidecarScanCandidate = Bucket & { + sessionId: string + metaPath: string + meta: Stats + store: Stats +} + +export async function discoverCursorSidecarCandidates(args: { + request: CursorSidecarScanRequest + caps: CursorSidecarScanCaps + response: CursorSidecarScanResponse + context: RequestContext +}): Promise<{ rootRealPath: string; candidates: CursorSidecarScanCandidate[] } | null> { + const chatsRoot = args.request.chatsRoot + let rootRealPath: string + let rootEntries: Dirent[] + try { + rootRealPath = await realpath(chatsRoot) + args.response.counters.rootReaddir++ + rootEntries = await readdir(chatsRoot, { withFileTypes: true }) + } catch (error) { + if (!isMissing(error)) { + addIssue(args.response, chatsRoot, error) + } + return null + } + throwIfCancelled(args.context) + + const direct = await scopeBuckets(args.request, chatsRoot, args) + const enumerated = enumeratedBuckets(rootEntries, chatsRoot, direct, args) + const sessions = await retainSessions([...direct.values(), ...enumerated], args) + const candidates = await eligibleCandidates(sessions, args) + return { rootRealPath, candidates } +} + +async function scopeBuckets( + request: CursorSidecarScanRequest, + chatsRoot: string, + args: { + caps: CursorSidecarScanCaps + response: CursorSidecarScanResponse + context: RequestContext + } +): Promise> { + const paths = [...new Set(request.scopePaths.map((value) => value.trim()).filter(Boolean))].sort() + args.response.truncated.scopePaths = paths.length > args.caps.scopes + const cwds = new Set() + for (const scopePath of paths.slice(0, args.caps.scopes)) { + for (const cwd of targetPathVariants(scopePath)) { + cwds.add(cwd) + } + try { + args.response.counters.scopeRealpath++ + const resolved = await realpath(scopePath) + for (const cwd of targetPathVariants(resolved)) { + cwds.add(cwd) + } + } catch { + // Scope paths are allowed to be absent on the owning host. + } + throwIfCancelled(args.context) + } + const buckets = new Map() + args.response.scopeCwds = [...cwds].sort() + for (const cwd of args.response.scopeCwds) { + const name = createHash('md5').update(cwd).digest('hex') + buckets.set(name, { name, path: join(chatsRoot, name), scopeCwd: cwd }) + } + return buckets +} + +function enumeratedBuckets( + entries: readonly Dirent[], + chatsRoot: string, + direct: ReadonlyMap, + args: { caps: CursorSidecarScanCaps; response: CursorSidecarScanResponse } +): Bucket[] { + const retained = entries + .filter( + (entry) => + entry.isDirectory() && + !entry.isSymbolicLink() && + BUCKET_PATTERN.test(entry.name) && + !direct.has(entry.name) + ) + .sort((left, right) => left.name.localeCompare(right.name)) + args.response.truncated.buckets = retained.length > args.caps.buckets + return retained.slice(0, args.caps.buckets).map((entry) => ({ + name: entry.name, + path: join(chatsRoot, entry.name), + scopeCwd: null + })) +} + +async function retainSessions( + buckets: readonly Bucket[], + args: { + caps: CursorSidecarScanCaps + response: CursorSidecarScanResponse + context: RequestContext + } +): Promise<(Bucket & { sessionId: string })[]> { + const retained: (Bucket & { sessionId: string })[] = [] + for ( + let index = 0; + index < buckets.length && retained.length < args.caps.sessions; + index += BUCKET_READ_CONCURRENCY + ) { + const batch = buckets.slice(index, index + BUCKET_READ_CONCURRENCY) + const listings = await Promise.all( + batch.map(async (bucket) => { + try { + if (bucket.scopeCwd) { + const stats = await lstat(bucket.path) + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return { bucket, entries: [] as Dirent[] } + } + } + args.response.counters.bucketReaddir++ + return { + bucket, + entries: await readdir(bucket.path, { withFileTypes: true }) + } + } catch (error) { + if (!isMissing(error)) { + addIssue(args.response, bucket.path, error) + } + return { bucket, entries: [] as Dirent[] } + } + }) + ) + throwIfCancelled(args.context) + const batchSessions = listings.flatMap(({ bucket, entries }) => + entries + .filter( + (entry) => entry.isDirectory() && !entry.isSymbolicLink() && safeBasename(entry.name) + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => ({ ...bucket, sessionId: entry.name })) + ) + const capacity = args.caps.sessions - retained.length + retained.push(...batchSessions.slice(0, capacity)) + if (batchSessions.length > capacity || index + batch.length < buckets.length) { + args.response.truncated.sessionDirs = retained.length >= args.caps.sessions + } + if (retained.length >= args.caps.sessions) { + break + } + } + return retained +} + +async function eligibleCandidates( + sessions: readonly (Bucket & { sessionId: string })[], + args: { + caps: CursorSidecarScanCaps + response: CursorSidecarScanResponse + context: RequestContext + } +): Promise { + const candidates: CursorSidecarScanCandidate[] = [] + for (let index = 0; index < sessions.length; index += BUCKET_READ_CONCURRENCY) { + const batch = sessions.slice(index, index + BUCKET_READ_CONCURRENCY) + const results = await Promise.all( + batch.map(async (session): Promise => { + const sessionDir = join(session.path, session.sessionId) + const metaPath = join(sessionDir, 'meta.json') + try { + args.response.counters.fileLstat += 2 + const [meta, store] = await Promise.all([ + lstat(metaPath), + lstat(join(sessionDir, 'store.db')) + ]) + if ( + !meta.isFile() || + meta.isSymbolicLink() || + !store.isFile() || + store.isSymbolicLink() + ) { + return null + } + if (meta.size > args.caps.sidecarBytes) { + addIssue( + args.response, + metaPath, + new Error('Cursor session metadata exceeds the read limit.') + ) + return null + } + return { ...session, metaPath, meta, store } + } catch (error) { + if (!isMissing(error)) { + addIssue(args.response, metaPath, error) + } + return null + } + }) + ) + throwIfCancelled(args.context) + candidates.push( + ...results.filter((result): result is CursorSidecarScanCandidate => Boolean(result)) + ) + } + return candidates.sort( + (left, right) => + Number(right.scopeCwd !== null) - Number(left.scopeCwd !== null) || + Math.max(right.meta.mtimeMs, right.store.mtimeMs) - + Math.max(left.meta.mtimeMs, left.store.mtimeMs) || + `${left.name}\0${left.sessionId}`.localeCompare(`${right.name}\0${right.sessionId}`) + ) +} + +function targetPathVariants(value: string): string[] { + const pathOps = process.platform === 'win32' ? win32 : posix + if (!pathOps.isAbsolute(value)) { + return [] + } + const resolved = pathOps.resolve(value) + if (process.platform !== 'win32') { + return [resolved] + } + const match = /^([A-Za-z]):/u.exec(resolved) + return match + ? [ + ...new Set([ + resolved, + `${match[1].toUpperCase()}${resolved.slice(1)}`, + `${match[1].toLowerCase()}${resolved.slice(1)}` + ]) + ] + : [resolved] +} + +function safeBasename(value: string): boolean { + return Boolean(value && value !== '.' && value !== '..' && !/[\\/]/u.test(value)) +} + +function addIssue(response: CursorSidecarScanResponse, path: string, error: unknown): void { + response.issues.push({ + path, + message: error instanceof Error ? error.message.slice(0, 1_024) : 'Cursor scan failed.' + }) +} + +function isMissing(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +function throwIfCancelled(context: RequestContext): void { + if (context.isStale() || context.signal?.aborted) { + throw new Error('cursor_sidecar_scan_cancelled') + } +} diff --git a/src/relay/cursor-sidecar-scan.test.ts b/src/relay/cursor-sidecar-scan.test.ts new file mode 100644 index 00000000000..ca09a68bf88 --- /dev/null +++ b/src/relay/cursor-sidecar-scan.test.ts @@ -0,0 +1,301 @@ +import { chmod, mkdtemp, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { scanCursorSidecars } from './cursor-sidecar-scan' +import { defaultCursorSidecarScanRequest } from '../shared/cursor-sidecar-scan' +import { cursorBucketForCwd } from '../main/ai-vault/session-scanner-cursor-paths' + +const roots: string[] = [] +const context = { clientId: 1, isStale: () => false } + +async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-scan-')) + roots.push(root) + return root +} + +async function addSession( + chatsRoot: string, + bucket: string, + sessionId: string, + content = JSON.stringify({ createdAtMs: 10, hasConversation: true }) +): Promise { + const sessionDir = join(chatsRoot, bucket, sessionId) + await mkdir(sessionDir, { recursive: true }) + await Promise.all([ + writeFile(join(sessionDir, 'meta.json'), content), + writeFile(join(sessionDir, 'store.db'), '') + ]) +} + +async function setSessionMtime( + chatsRoot: string, + bucket: string, + sessionId: string, + mtimeMs: number +): Promise { + const timestamp = new Date(mtimeMs) + await Promise.all( + ['meta.json', 'store.db'].map((name) => + utimes(join(chatsRoot, bucket, sessionId, name), timestamp, timestamp) + ) + ) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('scanCursorSidecars', () => { + it('discovers an exact scope bucket in one bounded owning-host operation', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const cwd = join(root, 'workspace') + await mkdir(cwd) + const bucket = cursorBucketForCwd(cwd, process.platform) + await addSession(chatsRoot, bucket, 'opaque-id') + + const result = await scanCursorSidecars( + defaultCursorSidecarScanRequest(chatsRoot, [cwd, cwd], process.platform), + context + ) + + expect(result.sidecars).toHaveLength(1) + expect(result.sidecars[0]).toMatchObject({ + bucket, + sessionId: 'opaque-id', + scopeCwd: cwd + }) + expect(result.counters).toMatchObject({ + rootReaddir: 1, + bucketReaddir: 1, + fileLstat: 2, + boundedReads: 1, + scopeRealpath: 1 + }) + expect(result.truncated).toEqual({ + scopePaths: false, + buckets: false, + sessionDirs: false, + sidecarBytes: false + }) + }) + + it('keeps missing files silent and rejects symlinked sidecars', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const bucket = '11111111111111111111111111111111' + await addSession(chatsRoot, bucket, 'valid') + const missingStore = join(chatsRoot, bucket, 'missing-store') + await mkdir(missingStore) + await writeFile(join(missingStore, 'meta.json'), '{}') + const linked = join(chatsRoot, bucket, 'linked') + await mkdir(linked) + await Promise.all([ + symlink(join(chatsRoot, bucket, 'valid', 'meta.json'), join(linked, 'meta.json')), + writeFile(join(linked, 'store.db'), '') + ]) + + const result = await scanCursorSidecars( + defaultCursorSidecarScanRequest(chatsRoot, [], process.platform), + context + ) + expect(result.sidecars.map((sidecar) => sidecar.sessionId)).toEqual(['valid']) + expect(result.issues).toEqual([]) + }) + + it('clamps session and aggregate-content bounds and reports one issue per dimension', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const bucket = '22222222222222222222222222222222' + await addSession(chatsRoot, bucket, 'a') + await addSession(chatsRoot, bucket, 'b') + const request = defaultCursorSidecarScanRequest(chatsRoot, [], process.platform) + request.maxSessionDirs = 1 + request.maxAggregateBytes = 1 + + const result = await scanCursorSidecars(request, context) + expect(result.sidecars).toEqual([]) + expect(result.truncated).toMatchObject({ sessionDirs: true, sidecarBytes: true }) + expect(result.issues.filter((issue) => issue.message.includes('truncated'))).toHaveLength(2) + }) + + it('does not report session truncation when the retained count exactly fits', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + await addSession(chatsRoot, '33333333333333333333333333333333', 'only') + const request = defaultCursorSidecarScanRequest(chatsRoot, [], process.platform) + request.maxSessionDirs = 1 + + const result = await scanCursorSidecars(request, context) + expect(result.sidecars).toHaveLength(1) + expect(result.truncated.sessionDirs).toBe(false) + }) + + it('does not report session truncation when the retained count exactly matches the cap', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + await addSession(chatsRoot, '33333333333333333333333333333333', 'only-session') + const request = defaultCursorSidecarScanRequest(chatsRoot, [], process.platform) + request.maxSessionDirs = 1 + + const result = await scanCursorSidecars(request, context) + + expect(result.sidecars).toHaveLength(1) + expect(result.truncated.sessionDirs).toBe(false) + }) + + it('prioritizes exact-scope candidates before newer unrelated sidecars at the byte cap', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const cwd = join(root, 'workspace') + await mkdir(cwd) + const scopeBucket = cursorBucketForCwd(cwd, process.platform) + const otherBucket = '44444444444444444444444444444444' + const scopedContent = JSON.stringify({ createdAtMs: 10, title: 'scoped' }) + await addSession(chatsRoot, scopeBucket, 'scoped', scopedContent) + await addSession(chatsRoot, otherBucket, 'newer', scopedContent) + await setSessionMtime(chatsRoot, scopeBucket, 'scoped', 1_000) + await setSessionMtime(chatsRoot, otherBucket, 'newer', 10_000) + const request = defaultCursorSidecarScanRequest(chatsRoot, [cwd], process.platform) + request.maxAggregateBytes = Buffer.byteLength(scopedContent) + + const result = await scanCursorSidecars(request, context) + + expect(result.sidecars.map((sidecar) => sidecar.sessionId)).toEqual(['scoped']) + expect(result.truncated.sidecarBytes).toBe(true) + }) + + it('orders equal-mtime candidates by lexical physical key before truncating', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const content = JSON.stringify({ createdAtMs: 10 }) + const firstBucket = '55555555555555555555555555555555' + const secondBucket = '66666666666666666666666666666666' + await addSession(chatsRoot, secondBucket, 'session', content) + await addSession(chatsRoot, firstBucket, 'session', content) + await setSessionMtime(chatsRoot, firstBucket, 'session', 1_000) + await setSessionMtime(chatsRoot, secondBucket, 'session', 1_000) + const request = defaultCursorSidecarScanRequest(chatsRoot, [], process.platform) + request.maxAggregateBytes = Buffer.byteLength(content) + + const result = await scanCursorSidecars(request, context) + + expect(result.sidecars.map((sidecar) => sidecar.bucket)).toEqual([firstBucket]) + }) + + it('lets direct scope buckets bypass only the enumerated bucket quota', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const cwd = join(root, 'workspace') + await mkdir(cwd) + const scopeBucket = cursorBucketForCwd(cwd, process.platform) + await addSession(chatsRoot, scopeBucket, 'scoped') + await addSession(chatsRoot, '77777777777777777777777777777777', 'first-enumerated') + await addSession(chatsRoot, '88888888888888888888888888888888', 'truncated-enumerated') + const request = defaultCursorSidecarScanRequest(chatsRoot, [cwd], process.platform) + request.maxBuckets = 1 + + const result = await scanCursorSidecars(request, context) + + expect(result.sidecars.map((sidecar) => sidecar.sessionId).sort()).toEqual([ + 'first-enumerated', + 'scoped' + ]) + expect(result.truncated.buckets).toBe(true) + expect(result.counters.bucketReaddir).toBe(2) + }) + + it('skips sidecars already over the per-file cap without opening them', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + await addSession(chatsRoot, '99999999999999999999999999999999', 'oversized', 'x'.repeat(32)) + const request = defaultCursorSidecarScanRequest(chatsRoot, [], process.platform) + request.maxSidecarBytes = 16 + + const result = await scanCursorSidecars(request, context) + + expect(result.sidecars).toEqual([]) + expect(result.counters.boundedReads).toBe(0) + expect(result.issues).toContainEqual( + expect.objectContaining({ message: 'Cursor session metadata exceeds the read limit.' }) + ) + }) + + it('normalizes scope truncation deterministically inside the owning-host scan', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const first = join(root, 'a-workspace') + const second = join(root, 'z-workspace') + await Promise.all([mkdir(first), mkdir(second), mkdir(chatsRoot)]) + const request = defaultCursorSidecarScanRequest(chatsRoot, [second, first], process.platform) + request.maxScopePaths = 1 + + const result = await scanCursorSidecars(request, context) + + expect(result.scopeCwds).toContain(first) + expect(result.scopeCwds).not.toContain(second) + expect(result.truncated.scopePaths).toBe(true) + }) + + it('stops when the relay request is already cancelled', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + await mkdir(chatsRoot) + + await expect( + scanCursorSidecars(defaultCursorSidecarScanRequest(chatsRoot, [], process.platform), { + clientId: 1, + isStale: () => true + }) + ).rejects.toThrow('cursor_sidecar_scan_cancelled') + }) + + it.skipIf(process.platform === 'win32')( + 'continues after an unreadable bucket and reports its path', + async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const blockedBucket = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const validBucket = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + await addSession(chatsRoot, blockedBucket, 'blocked') + await addSession(chatsRoot, validBucket, 'valid') + const blockedPath = join(chatsRoot, blockedBucket) + await chmod(blockedPath, 0) + try { + const result = await scanCursorSidecars( + defaultCursorSidecarScanRequest(chatsRoot, [], process.platform), + context + ) + expect(result.sidecars.map((sidecar) => sidecar.sessionId)).toEqual(['valid']) + expect(result.issues).toContainEqual(expect.objectContaining({ path: blockedPath })) + } finally { + await chmod(blockedPath, 0o700) + } + } + ) + + it('rejects malformed versioned requests before touching the filesystem', async () => { + await expect( + scanCursorSidecars( + { + ...defaultCursorSidecarScanRequest('/missing', [], process.platform), + version: 2 + }, + context + ) + ).rejects.toThrow() + }) + + it('treats a missing chats root as an empty source', async () => { + const root = await createRoot() + const result = await scanCursorSidecars( + defaultCursorSidecarScanRequest(join(root, 'missing'), [], process.platform), + context + ) + expect(result.sidecars).toEqual([]) + expect(result.issues).toEqual([]) + expect(result.counters.rootReaddir).toBe(0) + }) +}) diff --git a/src/relay/cursor-sidecar-scan.ts b/src/relay/cursor-sidecar-scan.ts new file mode 100644 index 00000000000..677586b6669 --- /dev/null +++ b/src/relay/cursor-sidecar-scan.ts @@ -0,0 +1,157 @@ +import type { + CursorSidecarScanRequest, + CursorSidecarScanResponse +} from '../shared/cursor-sidecar-scan' +import { + CURSOR_REMOTE_MAX_AGGREGATE_BYTES, + CURSOR_REMOTE_MAX_BUCKETS, + CURSOR_REMOTE_MAX_SCOPE_PATHS, + CURSOR_REMOTE_MAX_SESSION_DIRS, + CURSOR_SIDECAR_MAX_BYTES, + CURSOR_SIDECAR_SCAN_VERSION, + cursorSidecarScanRequestSchema, + cursorSidecarScanResponseSchema +} from '../shared/cursor-sidecar-scan' +import { readVerifiedBoundedTextFile } from '../shared/node-verified-bounded-text-file' +import type { RequestContext } from './dispatcher' +import { + discoverCursorSidecarCandidates, + type CursorSidecarScanCandidate, + type CursorSidecarScanCaps +} from './cursor-sidecar-scan-discovery' + +export async function scanCursorSidecars( + input: unknown, + context: RequestContext +): Promise { + const request = cursorSidecarScanRequestSchema.parse(input) + const startedAt = Date.now() + const caps = clampCaps(request) + const response = emptyResponse() + const discovery = await discoverCursorSidecarCandidates({ + request, + caps, + response, + context + }) + if (!discovery) { + return finish(response, startedAt) + } + await readCandidates(discovery.candidates, discovery.rootRealPath, caps, response, context) + return finish(response, startedAt) +} + +function clampCaps(request: CursorSidecarScanRequest): CursorSidecarScanCaps { + return { + buckets: Math.min(request.maxBuckets, CURSOR_REMOTE_MAX_BUCKETS), + sessions: Math.min(request.maxSessionDirs, CURSOR_REMOTE_MAX_SESSION_DIRS), + scopes: Math.min(request.maxScopePaths, CURSOR_REMOTE_MAX_SCOPE_PATHS), + sidecarBytes: Math.min(request.maxSidecarBytes, CURSOR_SIDECAR_MAX_BYTES), + aggregateBytes: Math.min(request.maxAggregateBytes, CURSOR_REMOTE_MAX_AGGREGATE_BYTES) + } +} + +async function readCandidates( + candidates: readonly CursorSidecarScanCandidate[], + rootRealPath: string, + caps: CursorSidecarScanCaps, + response: CursorSidecarScanResponse, + context: RequestContext +): Promise { + for (let index = 0; index < candidates.length; index += 1) { + throwIfCancelled(context) + const candidate = candidates[index] + try { + response.counters.boundedReads++ + const content = await readVerifiedBoundedTextFile(candidate.metaPath, { + expectedRootRealPath: rootRealPath, + maxBytes: caps.sidecarBytes + }) + const bytes = Buffer.byteLength(content, 'utf8') + if (response.counters.returnedBytes + bytes > caps.aggregateBytes) { + response.truncated.sidecarBytes = true + break + } + response.counters.returnedBytes += bytes + response.sidecars.push({ + bucket: candidate.name, + sessionId: candidate.sessionId, + metaPath: candidate.metaPath, + content, + metaMtimeMs: candidate.meta.mtimeMs, + metaSizeBytes: candidate.meta.size, + storeMtimeMs: candidate.store.mtimeMs, + scopeCwd: candidate.scopeCwd + }) + if (response.counters.returnedBytes >= caps.aggregateBytes && index < candidates.length - 1) { + response.truncated.sidecarBytes = true + break + } + } catch (error) { + if (!isMissing(error)) { + addIssue(response, candidate.metaPath, error) + } + } + } +} + +function emptyResponse(): CursorSidecarScanResponse { + return { + version: CURSOR_SIDECAR_SCAN_VERSION, + scopeCwds: [], + sidecars: [], + issues: [], + counters: { + rootReaddir: 0, + bucketReaddir: 0, + fileLstat: 0, + boundedReads: 0, + scopeRealpath: 0, + returnedBytes: 0, + elapsedMs: 0 + }, + truncated: { scopePaths: false, buckets: false, sessionDirs: false, sidecarBytes: false } + } +} + +function finish(response: CursorSidecarScanResponse, startedAt: number): CursorSidecarScanResponse { + response.counters.elapsedMs = Math.max(0, Date.now() - startedAt) + if (response.truncated.scopePaths) { + addAggregateIssue(response, 'scope paths') + } + if (response.truncated.buckets) { + addAggregateIssue(response, 'buckets') + } + if (response.truncated.sessionDirs) { + addAggregateIssue(response, 'session directories') + } + if (response.truncated.sidecarBytes) { + addAggregateIssue(response, 'sidecar bytes') + } + return cursorSidecarScanResponseSchema.parse(response) +} + +function addAggregateIssue(response: CursorSidecarScanResponse, dimension: string): void { + response.issues.push({ + path: 'cursor sidecar scan', + message: `Cursor sidecar scan truncated by the ${dimension} limit.` + }) +} + +function addIssue(response: CursorSidecarScanResponse, path: string, error: unknown): void { + response.issues.push({ + path, + message: error instanceof Error ? error.message.slice(0, 1_024) : 'Cursor scan failed.' + }) +} + +function isMissing(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +function throwIfCancelled(context: RequestContext): void { + if (context.isStale() || context.signal?.aborted) { + throw new Error('cursor_sidecar_scan_cancelled') + } +} diff --git a/src/relay/fs-handler-terminal-artifact.ts b/src/relay/fs-handler-terminal-artifact.ts index 02af753f134..9c71b21b37e 100644 --- a/src/relay/fs-handler-terminal-artifact.ts +++ b/src/relay/fs-handler-terminal-artifact.ts @@ -10,6 +10,7 @@ import { MAX_PREVIEWABLE_BINARY_SIZE, MAX_TEXT_FILE_SIZE } from './fs-handler-utils' +import { openNoFollow, readBoundedFileHandle } from '../shared/node-verified-bounded-text-file' type TerminalArtifactStat = { size: number @@ -28,8 +29,6 @@ type VerifiedTerminalArtifactOptions = { maxBytes?: number } -const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 - export async function readVerifiedTerminalArtifact(params: Record) { const filePath = stringParam(params.filePath) const options = verifiedTerminalArtifactOptions(params) @@ -41,7 +40,7 @@ export async function readVerifiedTerminalArtifact(params: Record { - const safeLimit = Math.max(0, Math.floor(maxBytes)) - const buffer = Buffer.alloc(safeLimit + 1) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) - if (bytesRead > safeLimit) { - throw new Error('file_too_large') - } - return buffer.subarray(0, bytesRead) -} - async function openVerifiedTerminalArtifact( filePath: string, options: VerifiedTerminalArtifactOptions, @@ -128,9 +117,15 @@ async function openVerifiedTerminalArtifact( ): Promise { await assertRealPathStillGranted(filePath, options.expectedRealPath) try { - return await open(filePath, flags | OPEN_NOFOLLOW) + if (flags === constants.O_RDONLY) { + return await openNoFollow(filePath) + } + return await open(filePath, flags) } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + if ( + (error as NodeJS.ErrnoException).code === 'ELOOP' || + (error as Error).message === 'verified_file_changed' + ) { throw new Error('terminal_file_grant_stale') } throw error diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 9c146af1450..0b6be95b2b3 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -36,6 +36,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' +import { scanCursorSidecars } from './cursor-sidecar-scan' async function isDirectoryEntry( dirPath: string, @@ -104,6 +105,9 @@ export class FsHandler { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) + this.dispatcher.onRequest('fs.scanCursorSidecars', (p, context) => + scanCursorSidecars(p, context) + ) this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p)) diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 80bc2ba490a..db86bcce232 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -233,6 +233,54 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it('returns a versioned inventory with the capability-probed Cursor command', async () => { + execFileAsyncMock.mockImplementation(async (file, args) => { + if (file === '/relay/bin/cursor' && args[0] === 'agent' && args[1] === '--help') { + return { stdout: 'Cursor agent help\n' } + } + const script = String(args[1]) + if (script.includes("'cursor-agent'")) { + return { stdout: '__ORCA_AGENT_PATH__/relay/bin/cursor-agent\n' } + } + if (script.includes("'cursor'")) { + return { stdout: '__ORCA_AGENT_PATH__/relay/bin/cursor\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + new PreflightHandler(dispatcher as never) + const inventory = requestHandlers.get('preflight.detectAgentInventory') + await expect( + inventory!({ + version: 1, + commands: [ + { + id: 'cursor', + cmd: 'cursor-agent', + capabilityProbe: { args: ['--help'], matchedCommand: 'cursor-agent' } + }, + { + id: 'cursor', + cmd: 'cursor', + capabilityProbe: { args: ['agent', '--help'], matchedCommand: 'cursor agent' } + } + ] + }) + ).resolves.toEqual({ + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor agent' } + }) + }) + it('honors required commands when reporting detected agents', async () => { execFileAsyncMock.mockImplementation(async (_file, args) => { const script = String(args[1]) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 101c796137c..47903b95d13 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -8,6 +8,13 @@ import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' +import { + detectedAgentInventory, + detectedAgentInventoryRequestSchema, + emptyDetectedAgentInventory, + type DetectedAgentInventoryRequestV1, + type DetectedAgentInventoryV1 +} from '../shared/detected-agent-inventory' const execFileAsync = promisify(execFile) @@ -23,14 +30,7 @@ type RelayCommandLookupOptions = { accountLoginShell?: string | null } -type AgentDetectionRuntime = NodeJS.Platform | 'wsl' - -type AgentDetectionCommand = { - id: string - cmd: string - requiredCommands?: readonly string[] - unsupportedRuntimes?: readonly AgentDetectionRuntime[] -} +type AgentDetectionCommand = DetectedAgentInventoryRequestV1['commands'][number] const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) @@ -45,7 +45,13 @@ export class PreflightHandler { } private registerHandlers(): void { - this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p)) + this.dispatcher.onRequest('preflight.detectAgents', async (params) => { + const inventory = await this.detectAgentInventory(params) + return { agents: inventory.agents } + }) + this.dispatcher.onRequest('preflight.detectAgentInventory', (params) => + this.detectAgentInventory(params) + ) this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () => this.detectWindowsTerminalCapabilities() ) @@ -54,11 +60,17 @@ export class PreflightHandler { // Why: the client sends the command list rather than importing TUI_AGENT_CONFIG // on the relay side. This keeps the relay bundle minimal and makes the protocol // self-describing — the relay doesn't need to know the agent catalog. - private async detectAgents(params: Record): Promise<{ agents: string[] }> { - const commands = params.commands as AgentDetectionCommand[] - if (!Array.isArray(commands)) { - return { agents: [] } + private async detectAgentInventory( + params: Record + ): Promise { + const parsed = detectedAgentInventoryRequestSchema.safeParse({ + ...params, + version: params.version ?? 1 + }) + if (!parsed.success) { + return emptyDetectedAgentInventory() } + const commands = parsed.data.commands const probeCommands = [ ...new Set( commands @@ -70,27 +82,58 @@ export class PreflightHandler { const results = await Promise.all( probeCommands.map(async (cmd) => ({ cmd, - installed: await this.isCommandOnPath(cmd) + resolvedPath: await resolveCommandOnPathForRelay(cmd) })) ) const foundCommands = new Set( - results.filter((result) => result.installed).map(({ cmd }) => cmd) + results.filter((result) => result.resolvedPath).map(({ cmd }) => cmd) + ) + await Promise.all( + commands + .filter((command) => command.capabilityProbe && foundCommands.has(command.cmd)) + .map(async (command) => { + const resolvedPath = results.find((result) => result.cmd === command.cmd)?.resolvedPath + if (!resolvedPath) { + foundCommands.delete(command.cmd) + return + } + try { + await execFileAsync(resolvedPath, [...(command.capabilityProbe?.args ?? [])], { + encoding: 'utf-8', + env: buildRelayCommandEnv(process.env, process.platform), + timeout: 5000, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + } catch { + foundCommands.delete(command.cmd) + } + }) ) - return { - agents: [ - ...new Set( - commands - .filter( - (command) => - !isDetectionUnsupportedInRuntime(command, process.platform) && - foundCommands.has(command.cmd) && - (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) - ) - .map(({ id }) => id) - ) - ] + const agents = [ + ...new Set( + commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, process.platform) && + foundCommands.has(command.cmd) && + (!command.capabilityProbe || foundCommands.has(command.cmd)) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + ) + ] + const matchedCommands: Record = {} + for (const command of commands) { + if ( + agents.includes(command.id) && + foundCommands.has(command.cmd) && + matchedCommands[command.id] === undefined + ) { + matchedCommands[command.id] = command.capabilityProbe?.matchedCommand ?? command.cmd + } } + return detectedAgentInventory(agents, matchedCommands) } private async detectWindowsTerminalCapabilities(): Promise<{ @@ -114,19 +157,11 @@ export class PreflightHandler { hostPlatform: process.platform } } - - // Why: SSH exec channels give the relay a minimal environment without shell - // startup files sourced. Ask the user's configured shell so agent dirs added - // by zsh/bash/fish startup hooks match the remote terminal experience. - // Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there. - private async isCommandOnPath(command: string): Promise { - return isCommandOnPathForRelay(command) - } } function isDetectionUnsupportedInRuntime( command: AgentDetectionCommand, - runtime: AgentDetectionRuntime + runtime: NodeJS.Platform | 'wsl' ): boolean { return command.unsupportedRuntimes?.includes(runtime) === true } @@ -172,6 +207,13 @@ export async function isCommandOnPathForRelay( command: string, options: RelayCommandLookupOptions = {} ): Promise { + return Boolean(await resolveCommandOnPathForRelay(command, options)) +} + +export async function resolveCommandOnPathForRelay( + command: string, + options: RelayCommandLookupOptions = {} +): Promise { const platform = options.platform ?? process.platform const env = options.env ?? process.env const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell) @@ -184,31 +226,39 @@ export async function isCommandOnPathForRelay( timeout: 5000, ...(spec.windowsHide ? { windowsHide: true } : {}) }) - if (hasAbsoluteCommandPath(stdout, platform)) { - return true + const resolvedPath = firstAbsoluteCommandPath(stdout, platform) + if (resolvedPath) { + return resolvedPath } } catch { // Try the inherited-PATH fallback before reporting the agent missing. } } - return false + return null } export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { + return Boolean(firstAbsoluteCommandPath(output, platform)) +} + +function firstAbsoluteCommandPath(output: string, platform: NodeJS.Platform): string | null { const pathOps = platform === 'win32' ? win32 : path - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .some((line) => { - const resolvedPath = - platform === 'win32' - ? line - : line.startsWith(AGENT_PATH_PREFIX) - ? line.slice(AGENT_PATH_PREFIX.length) - : '' - return pathOps.isAbsolute(resolvedPath) - }) + return ( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => { + const resolvedPath = + platform === 'win32' + ? line + : line.startsWith(AGENT_PATH_PREFIX) + ? line.slice(AGENT_PATH_PREFIX.length) + : '' + return pathOps.isAbsolute(resolvedPath) ? resolvedPath : null + }) + .find((resolvedPath): resolvedPath is string => Boolean(resolvedPath)) ?? null + ) } function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index d2ff07bfd57..c1dbbdeb9a0 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -8,6 +8,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { cursorLaunchCommandFromMatch } from '../../../../shared/cursor-command' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { @@ -74,10 +75,17 @@ export function FloatingTerminalWindowControls({ return } const state = useAppStore.getState() + const cmdOverrides = { ...state.settings?.agentCmdOverrides } + if (defaultAgent === 'cursor' && !cmdOverrides.cursor?.trim()) { + const detectedCommand = cursorLaunchCommandFromMatch(state.detectedAgentCommands?.cursor) + if (detectedCommand) { + cmdOverrides.cursor = detectedCommand + } + } const startupPlan = buildAgentStartupPlan({ agent: defaultAgent, prompt: '', - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + cmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv), sessionOptions: resolveNativeChatSessionOptionDefaults( diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 18068817358..a392d75c5de 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -114,4 +114,18 @@ describe('buildOnboardingFolderAgentStartup', () => { } }) }) + + it('uses the detected Cursor command for skipped-onboarding folder startup', () => { + expect( + buildDismissedOnboardingFolderAgentStartup( + { + ...getDefaultSettings('/tmp/orca-workspaces'), + defaultTuiAgent: 'cursor' + }, + { ...getDefaultOnboardingState(), outcome: 'dismissed' }, + false, + 'cursor agent' + )?.command + ).toContain('cursor agent') + }) }) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index b21b4c742e8..7075b4b191e 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -30,6 +30,7 @@ import { STEPS, type StepNumber } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { buildOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { resolveAiVaultCursorCommand } from '@/lib/ai-vault-cursor-command' import { resolveOnboardingSettingsHydration } from './onboarding-settings-hydration' import { openProjectDefaultCheckout } from '../sidebar/project-added-default-checkout' import { translate } from '@/i18n/i18n' @@ -549,7 +550,15 @@ export function useOnboardingFlow( const worktree = worktrees[0] ?? null if (worktree) { // Why: non-git folders skip the composer, so seed their first terminal with the chosen default agent here. - const startup = buildOnboardingFolderAgentStartup(settings) + const state = useAppStore.getState() + const startup = buildOnboardingFolderAgentStartup( + settings, + resolveAiVaultCursorCommand({ + state, + worktreeId: worktree.id, + commandOverride: settings?.agentCmdOverrides?.cursor + }) + ) activateAndRevealWorktree(worktree.id, { startup }) } } diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index e048925c734..f21cb1a21d0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -25,10 +25,6 @@ import { buildAiVaultProjectContext, buildAiVaultSessionProjectById } from './ai-vault-session-projects' -import { - resolveAiVaultSessionResumeActions, - resolveAiVaultSessionResumeState -} from './ai-vault-session-resume' import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' import { useAiVaultSessionWorktreeMap, @@ -37,6 +33,7 @@ import { import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' +import { getAiVaultTranscriptPath } from '../../../../shared/ai-vault-transcript-path' import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' @@ -49,6 +46,8 @@ import { import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' +import { useAiVaultCursorAgentDetection } from './use-ai-vault-cursor-agent-detection' +import { useAiVaultSessionResumeControls } from './use-ai-vault-session-resume-controls' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -59,13 +58,24 @@ export default function AiVaultPanel(): React.JSX.Element { const projectHostSetupProjection = useProjectHostSetupProjection() const resumeTargetState = useAppStore( useShallow((state) => ({ + activeRepoId: state.activeRepoId, + activeWorktreeId: state.activeWorktreeId, folderWorkspaces: state.folderWorkspaces, projectGroups: state.projectGroups, + projects: state.projects, repos: state.repos, - worktreesByRepo: state.worktreesByRepo + settings: state.settings, + worktreesByRepo: state.worktreesByRepo, + detectedAgentCommands: state.detectedAgentCommands, + detectedAgentCommandsByContext: state.detectedAgentCommandsByContext, + remoteDetectedAgentCommands: state.remoteDetectedAgentCommands, + runtimeDetectedAgentCommands: state.runtimeDetectedAgentCommands })) ) const settings = useAppStore((s) => s.settings) + const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents) + const ensureRemoteDetectedAgents = useAppStore((s) => s.ensureRemoteDetectedAgents) + const ensureRuntimeDetectedAgents = useAppStore((s) => s.ensureRuntimeDetectedAgents) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const agentCmdOverrides = settings?.agentCmdOverrides const { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree } = @@ -165,6 +175,14 @@ export default function AiVaultPanel(): React.JSX.Element { worktrees: allWorktrees }) const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null + useAiVaultCursorAgentDetection({ + sessions, + effectiveActiveWorktreeId, + sessionWorktreeById, + ensureDetectedAgents, + ensureRemoteDetectedAgents, + ensureRuntimeDetectedAgents + }) // `current` is stamped per row at read time so the map above stays cached. const getSessionWorktreeInfo = useCallback( (session: AiVaultSession) => @@ -258,33 +276,14 @@ export default function AiVaultPanel(): React.JSX.Element { ) }, []) - const getSessionResumeState = useCallback( - (session: AiVaultSession) => - resolveAiVaultSessionResumeState({ - sessionFilePath: session.filePath, - sessionExecutionHostId: session.executionHostId, - worktreeInfo: getSessionWorktreeInfo(session), - activeWorktreeId: effectiveActiveWorktreeId, - worktrees: allWorktrees, - repos, - targetState: resumeTargetState - }), - [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState] - ) - - const getSessionResumeActions = useCallback( - (session: AiVaultSession) => - resolveAiVaultSessionResumeActions({ - sessionFilePath: session.filePath, - sessionExecutionHostId: session.executionHostId, - worktreeInfo: getSessionWorktreeInfo(session), - activeWorktreeId: effectiveActiveWorktreeId, - worktrees: allWorktrees, - repos, - targetState: resumeTargetState - }), - [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState] - ) + const { getSessionResumeState, getSessionResumeActions } = useAiVaultSessionResumeControls({ + activeWorktreeId: effectiveActiveWorktreeId, + worktrees: allWorktrees, + repos, + targetState: resumeTargetState, + cursorCommandOverride: agentCmdOverrides?.cursor, + getSessionWorktreeInfo + }) const handleScopeChange = useCallback((nextScope: AiVaultScope) => { preferredScopeRef.current = nextScope @@ -333,7 +332,7 @@ export default function AiVaultPanel(): React.JSX.Element { onHideEmptySessionsChange={setHideEmptySessions} onSessionLimitChange={setSessionLimit} onReset={resetViewOptions} - onRefresh={() => void refresh({ force: true })} + onRefresh={() => void refresh({ reason: 'manual' })} /> {error ? ( @@ -373,14 +372,22 @@ export default function AiVaultPanel(): React.JSX.Element { translate('auto.components.right.sidebar.AiVaultPanel.sessionId', 'Session ID') ) } - onCopyPath={(session) => - void copyText( - session.filePath, - translate('auto.components.right.sidebar.AiVaultPanel.logPath', 'Log path') - ) - } + onCopyPath={(session) => { + const transcriptPath = getAiVaultTranscriptPath(session) + if (transcriptPath) { + void copyText( + transcriptPath, + translate('auto.components.right.sidebar.AiVaultPanel.logPath', 'Log path') + ) + } + }} onOpenLog={(session) => void openAiVaultSessionLogInOrca(session)} - onRevealLog={(session) => void window.api.shell.openPath(session.filePath)} + onRevealLog={(session) => { + const transcriptPath = getAiVaultTranscriptPath(session) + if (transcriptPath) { + void window.api.shell.openPath(transcriptPath) + } + }} onOpenCwd={(session) => { if (session.cwd) { void window.api.shell.openPath(session.cwd) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 2139b60b01d..f5688c86cc9 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -310,7 +310,11 @@ function AiVaultVirtualRow({ const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null const continuationWorktreeId = row.type === 'session' && - canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId) + canContinueAiVaultSessionInNewSession( + row.session, + resumeState?.worktreeId, + resumeState?.cursorCommandAvailable + ) ? resumeState?.worktreeId : null // Gate resume on real content: a zero-turn transcript would resume into an diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts index 28ccfbe056f..96d34ca929c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts @@ -78,4 +78,13 @@ describe('AI Vault session continuation', () => { expect(request.source.lastPrompt).toBeNull() expect(request.source.lastAssistantMessage).toBe('The component tests still need work.') }) + + it('requires Cursor cwd and host command before offering continuation', () => { + const cursor = session('cursor') + expect(canContinueAiVaultSessionInNewSession(cursor, 'worktree-1', false)).toBe(false) + expect( + canContinueAiVaultSessionInNewSession({ ...cursor, cwd: null }, 'worktree-1', true) + ).toBe(false) + expect(canContinueAiVaultSessionInNewSession(cursor, 'worktree-1', true)).toBe(true) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts index 3baff2bf272..8603cb7c4f6 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts @@ -1,13 +1,22 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { getAiVaultTranscriptPath } from '../../../../shared/ai-vault-transcript-path' export function canContinueAiVaultSessionInNewSession( session: AiVaultSession, - targetWorktreeId: string | null | undefined + targetWorktreeId: string | null | undefined, + cursorCommandAvailable = false ): boolean { + if ( + session.agent === 'cursor' && + (!session.cwd || !session.resumeCommand.trim() || !cursorCommandAvailable) + ) { + return false + } return Boolean( targetWorktreeId && - (session.filePath.trim() || session.previewMessages.some((message) => message.text.trim())) + (getAiVaultTranscriptPath(session) || + session.previewMessages.some((message) => message.text.trim())) ) } @@ -23,7 +32,7 @@ export function prepareAiVaultSessionContinuation(args: { sourceAgent: session.agent, sourceTitle: session.title, sourceWorkingDirectory: session.cwd, - transcriptPath: session.filePath.trim() || null, + transcriptPath: getAiVaultTranscriptPath(session), // Why: preview user entries can be tool results or injected skill text; only provider-authenticated prompts are safe hints. lastPrompt: session.lastUserPrompt ?? null, lastAssistantMessage: latestAssistantPreview(session) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 43504842aaa..1b870ce704d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -12,8 +12,7 @@ import { } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { - canResumeAiVaultSessionOnTarget, - getAiVaultResumeWorkspaceExecutionHostId, + canResumeAiVaultSessionForWorkspace, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -94,6 +93,7 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ + sessionAgent: session.agent, sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, @@ -153,6 +153,7 @@ export function useAiVaultSessionLaunchActions({ const handleContinueInNewSession = useCallback( (session: AiVaultSession, targetWorktreeId: string): void => { const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ + sessionAgent: session.agent, sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, @@ -238,6 +239,7 @@ export type AiVaultSessionLaunchTarget = | { status: 'ready'; worktreeId: string } export function resolveAiVaultSessionLaunchTarget(args: { + sessionAgent?: AiVaultSession['agent'] sessionFilePath: string | null sessionExecutionHostId?: AiVaultSession['executionHostId'] | null activeWorktreeId: string | null @@ -253,16 +255,13 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( - args.targetState, - targetWorktreeId - ) if ( - !canResumeAiVaultSessionOnTarget({ + !canResumeAiVaultSessionForWorkspace({ + state: args.targetState, + workspaceId: targetWorktreeId, sessionFilePath: args.sessionFilePath, sessionExecutionHostId: args.sessionExecutionHostId, - targetStatus, - targetExecutionHostId + sessionAgent: args.sessionAgent }) ) { return { status: 'unsupported', targetStatus } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts index 2f4b6ff495c..ff68cd3bd69 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts @@ -7,8 +7,9 @@ import { translate } from '@/i18n/i18n' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' +import { getAiVaultTranscriptPath } from '../../../../shared/ai-vault-transcript-path' -type AiVaultLogSession = Pick +type AiVaultLogSession = Pick // Why: rapid double-clicks of View Log during the authorize await must share one // in-flight open (and toast-once on failure) so a slow FS grant can't spawn @@ -48,7 +49,7 @@ function focusEditorContent(): void { * capability by itself and never redirects the open to a remote host. */ export async function openAiVaultSessionLogInOrca(session: AiVaultLogSession): Promise { - const filePath = session.filePath?.trim() + const filePath = getAiVaultTranscriptPath(session) // Defensive: UI availability should already withhold blank/remote/synthetic // paths. Bail silently rather than toast — there is no user-actionable error. if (!filePath || !canOpenAiVaultSessionLogInOrca(session)) { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts index 5bbbdcf2d1d..09303367f75 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -4,6 +4,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { getAiVaultTranscriptPath } from '../../../../shared/ai-vault-transcript-path' export function canUseLocalAiVaultSessionPathActions( executionHostId: ExecutionHostId | null | undefined @@ -28,9 +29,9 @@ export function isSyntheticAiVaultSessionPath(filePath: string): boolean { * a provider-owned log-resource contract. */ export function canOpenAiVaultSessionLogInOrca( - session: Pick + session: Pick ): boolean { - const filePath = session.filePath?.trim() + const filePath = getAiVaultTranscriptPath(session) if (!filePath) { return false } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 3af723343fb..d2cb451a5d0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -206,15 +206,16 @@ afterEach(() => { }) describe('useAiVaultSessionRefresh refocus behavior', () => { - it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + it('uses passive refresh intent on mount', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ executionHostScope: 'local', - force: true + refreshReason: 'passive' }) + expect(listSessionsMock.mock.calls[0]?.[0]).not.toMatchObject({ force: true }) }) it.each(['ssh:dev-box', 'runtime:remote-server'] as const)( @@ -351,7 +352,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) - it('force re-scans on refocus once the throttle allows it', async () => { + it('uses passive cache-preserving refresh intent on refocus', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) @@ -360,23 +361,22 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await fireWindowFocused() expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ refreshReason: 'passive' }) + expect(lastCallArgs()).not.toMatchObject({ force: true }) }) - it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + it('lets the main-process cache dedupe repeated passive refocus scans', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Within the throttle window nothing runs yet — the event must not be - // dropped, so it lands as one trailing scan when the throttle frees up. await fireWindowFocused() await dispatch(document, 'visibilitychange') - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(3) await advance(THROTTLE_MS + 1) - expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ refreshReason: 'passive' }) }) it('ignores focus/visibility events while the document is hidden', async () => { @@ -393,19 +393,19 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) }) - it('stops listening and cancels trailing scans after unmount', async () => { + it('stops listening after unmount', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Queue a trailing forced scan, then unmount before it fires. await fireWindowFocused() + expect(listSessionsMock).toHaveBeenCalledTimes(2) roots.splice(0).forEach((root) => act(() => root.unmount())) await advance(THROTTLE_MS + 1) await fireWindowFocused() await dispatch(document, 'visibilitychange') - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) expect(cancelListSessionsMock).toHaveBeenCalledTimes(1) }) @@ -479,7 +479,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(lastCallArgs()).toMatchObject({ force: true }) }) - it('counts a manual force refresh against the rescan throttle', async () => { + it('keeps a later refocus passive after a manual force refresh', async () => { await renderHook() await flushMicrotasks() @@ -489,14 +489,15 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { }) expect(listSessionsMock).toHaveBeenCalledTimes(2) - // The button just scanned; an immediate refocus defers to trailing. await fireWindowFocused() - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ refreshReason: 'passive' }) + expect(lastCallArgs()).not.toMatchObject({ force: true }) }) }) describe('useAiVaultSessionRefresh in-app agent session behavior', () => { - it('force re-scans when an agent session starts inside Orca', async () => { + it('refreshes only the exact host when an agent session starts inside Orca', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) @@ -505,20 +506,27 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => { await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ + refreshReason: 'session-start', + refreshExecutionHostId: 'local' + }) + expect(lastCallArgs()).not.toMatchObject({ force: true }) }) - it('defers an in-throttle session start to a trailing forced scan', async () => { + it('refreshes a new in-app session immediately without broad force', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) await advance(THROTTLE_MS + 1) expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ + refreshReason: 'session-start', + refreshExecutionHostId: 'local' + }) }) it('ignores agent activity on already-known sessions', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index e9cafb17d8a..411df54ec8d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -4,8 +4,14 @@ import { type AiVaultListResult, type AiVaultSession } from '../../../../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { aiVaultSessionResultCacheKey, @@ -14,24 +20,7 @@ import { resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' -// Panel entry and window refocus must show sessions started since the last -// scan, so they bypass the main process's 15s cache — but a full scan parses -// up to ~1000 transcripts, so bound forced scans to one per interval. Module -// scope so the throttle survives panel remounts (the panel unmounts per tab). -const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 -let lastForcedRescanAt = 0 - -function consumeForcedRescanBudget(): boolean { - const now = Date.now() - if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { - return false - } - lastForcedRescanAt = now - return true -} - export function resetAiVaultForcedRescanThrottleForTest(): void { - lastForcedRescanAt = 0 resetAiVaultSessionResultCacheForTest() } @@ -39,7 +28,13 @@ export function resetAiVaultForcedRescanThrottleForTest(): void { // still rejects, so both shapes have to be recognised. export const isAiVaultScanCancellation = isAiVaultScanCancelledError -type AiVaultRefreshArgs = { force?: boolean; background?: boolean; reuseLoadedDepth?: boolean } +type AiVaultRefreshArgs = { + force?: boolean + background?: boolean + reason?: 'manual' | 'passive' | 'session-start' + refreshExecutionHostId?: ExecutionHostId + reuseLoadedDepth?: boolean +} export function useAiVaultSessionRefresh( scopePaths: readonly string[], @@ -62,6 +57,7 @@ export function useAiVaultSessionRefresh( const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) const pendingBackgroundRef = useRef(true) + const pendingRefreshHostIdsRef = useRef(new Set()) const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}` @@ -109,19 +105,17 @@ export function useAiVaultSessionRefresh( // scan so the current scoped view is refreshed after the older scan settles. if (refreshInFlightRef.current) { pendingRefreshRef.current = true - pendingForceRef.current ||= args.force === true + pendingForceRef.current ||= args.force === true || args.reason === 'manual' pendingBackgroundRef.current &&= args.background === true + if (args.reason === 'session-start' && args.refreshExecutionHostId) { + pendingRefreshHostIdsRef.current.add(args.refreshExecutionHostId) + } return } refreshInFlightRef.current = true const refreshId = refreshIdRef.current + 1 refreshIdRef.current = refreshId - // A manual force scan counts against the throttle so an auto rescan right - // after the button press doesn't trigger a second full scan. - if (args.force === true) { - lastForcedRescanAt = Date.now() - } // Background (refocus) refreshes usually resolve from the main-process // cache; suppressing the loading flag avoids a spinner flash on every // return to the app. @@ -137,7 +131,9 @@ export function useAiVaultSessionRefresh( unlimited: selectedLimit === 'unlimited', scopePaths: scopePathsRef.current, executionHostScope: hostScope, - force: args.force, + force: args.force ?? false, + refreshReason: args.reason ?? (args.force ? 'manual' : 'passive'), + refreshExecutionHostId: args.refreshExecutionHostId, requestToken: requestTokenRef.current }) // A superseded scan resolves cancelled rather than rejecting, so the @@ -164,7 +160,7 @@ export function useAiVaultSessionRefresh( executionHostScope: hostScope, limit: selectedLimit, result, - replaceHostEntries: args.force === true + replaceHostEntries: args.force === true || args.reason === 'manual' }) setScanResult(result) setSessions(result.sessions) @@ -192,7 +188,21 @@ export function useAiVaultSessionRefresh( const background = pendingBackgroundRef.current pendingForceRef.current = false pendingBackgroundRef.current = true - void refresh({ force, background }) + if (force) { + pendingRefreshHostIdsRef.current.clear() + } + const hostIterator = pendingRefreshHostIdsRef.current.values().next() + const refreshExecutionHostId = hostIterator.done ? undefined : hostIterator.value + if (refreshExecutionHostId) { + pendingRefreshHostIdsRef.current.delete(refreshExecutionHostId) + } + pendingRefreshRef.current = pendingRefreshHostIdsRef.current.size > 0 + void refresh({ + force, + background, + reason: force ? 'manual' : refreshExecutionHostId ? 'session-start' : 'passive', + refreshExecutionHostId + }) } } // Deps intentionally avoid changing scope values: refresh reads them @@ -201,28 +211,6 @@ export function useAiVaultSessionRefresh( [currentScanScopeKey] ) - // Forced rescans triggered by events (refocus, agent-session starts) run - // immediately when the throttle allows, otherwise once as soon as it frees - // up — dropping the event would leave a just-started session invisible - // until some unrelated later trigger. - const forcedRescanTimerRef = useRef | null>(null) - const requestForcedRescan = useCallback(() => { - const waitMs = lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS - Date.now() - if (waitMs <= 0) { - lastForcedRescanAt = Date.now() - void refresh({ background: true, force: true }) - return - } - if (forcedRescanTimerRef.current !== null) { - return - } - forcedRescanTimerRef.current = setTimeout(() => { - forcedRescanTimerRef.current = null - lastForcedRescanAt = Date.now() - void refresh({ background: true, force: true }) - }, waitMs) - }, [refresh]) - useEffect(() => { mountedRef.current = true const requestToken = requestTokenRef.current @@ -233,40 +221,26 @@ export function useAiVaultSessionRefresh( void window.api.aiVault.cancelListSessions({ requestToken }) - if (forcedRescanTimerRef.current !== null) { - clearTimeout(forcedRescanTimerRef.current) - forcedRescanTimerRef.current = null - } } }, []) - // Remote scans can take long enough for normal tab navigation to feel stuck, - // so re-entering a remote panel uses its host/scope cache. Explicit refresh, - // app refocus and new in-app agent sessions still force a fresh scan. + // Reuse loaded renderer depth on entry; otherwise apply the host-aware passive policy. useEffect(() => { if (refreshInFlightRef.current) { void window.api.aiVault.cancelListSessions({ requestToken: requestTokenRef.current }) } - const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID - const force = refreshOnEntry && consumeForcedRescanBudget() - void refresh({ force, reuseLoadedDepth: true }) - if (refreshOnEntry && !force) { - requestForcedRescan() - } - }, [executionHostScope, refresh, requestForcedRescan, scanScopeKey]) + void refresh({ reason: 'passive', reuseLoadedDepth: true }) + }, [executionHostScope, refresh, scanScopeKey]) - // Sessions started while the app was backgrounded should appear when the - // user returns, so refocus also bypasses the scan cache (throttled). OS - // refocus arrives via the main process — renderer DOM focus events don't - // fire on macOS app activation; visibilitychange covers minimize-restore. + // Main-process focus covers macOS activation; visibility covers restore. useEffect(() => { const onRefocus = (): void => { if (document.visibilityState !== 'visible') { return } - requestForcedRescan() + void refresh({ background: true, reason: 'passive' }) } const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) document.addEventListener('visibilitychange', onRefocus) @@ -274,39 +248,57 @@ export function useAiVaultSessionRefresh( unsubscribeWindowFocus?.() document.removeEventListener('visibilitychange', onRefocus) } - }, [requestForcedRescan]) + }, [refresh]) // Sessions started inside Orca never blur the window, so refocus alone // can't surface them. Agent hooks already report provider sessions; re-scan // only when a session id we haven't seen appears — state transitions are // deliberately ignored, they fire constantly while agents work. - const agentSessionIdsKey = useAppStore((s) => { - const ids: string[] = [] - for (const entry of Object.values(s.agentStatusByPaneKey)) { + const agentSessionIdsKey = useAppStore((state) => { + const ids: [ExecutionHostId, string][] = [] + for (const entry of Object.values(state.agentStatusByPaneKey)) { if (entry.providerSession?.id) { - ids.push(entry.providerSession.id) + const hostId = entry.connectionId + ? toSshExecutionHostId(entry.connectionId) + : entry.worktreeId + ? getExecutionHostIdForWorktree(state, entry.worktreeId) + : LOCAL_EXECUTION_HOST_ID + ids.push([hostId, entry.providerSession.id]) } } - return ids.sort().join('\n') + return JSON.stringify( + ids.sort((left, right) => left.join('\0').localeCompare(right.join('\0'))) + ) }) const seenAgentSessionIdsRef = useRef | null>(null) useEffect(() => { - const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') + const entries = JSON.parse(agentSessionIdsKey) as [ExecutionHostId, string][] + const ids = entries.map(([hostId, sessionId]) => `${hostId}\0${sessionId}`) // The mount refresh already covers sessions live at mount time. if (seenAgentSessionIdsRef.current === null) { seenAgentSessionIdsRef.current = new Set(ids) return } const seen = seenAgentSessionIdsRef.current - const freshIds = ids.filter((id) => !seen.has(id)) - if (freshIds.length === 0) { + const freshEntries = entries.filter( + ([hostId, sessionId]) => !seen.has(`${hostId}\0${sessionId}`) + ) + if (freshEntries.length === 0) { return } - for (const id of freshIds) { - seen.add(id) + for (const [hostId, sessionId] of freshEntries) { + seen.add(`${hostId}\0${sessionId}`) + const selectedHost = executionHostScopeRef.current + if (selectedHost !== 'all' && selectedHost !== hostId) { + continue + } + void refresh({ + background: true, + reason: 'session-start', + refreshExecutionHostId: hostId + }) } - requestForcedRescan() - }, [agentSessionIdsKey, requestForcedRescan]) + }, [agentSessionIdsKey, refresh]) return { error, loading, refresh, scanResult, sessions } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index 97803382577..bf2369fc914 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -608,4 +608,33 @@ describe('aiVaultSessionRowResumeGating', () => { canCopyResumeCommand: true }) }) + + it('requires both strong cwd and a live host command for Cursor', () => { + const cursorSession = { + agent: 'cursor' as const, + cwd: '/repo', + resumeCommand: "cursor-agent --resume 'session'", + messageCount: 0, + hasConversation: true, + previewMessages: [] + } + expect( + aiVaultSessionRowResumeGating(cursorSession, { + blocked: false, + cursorCommandAvailable: false + }) + ).toEqual({ resumeDisabled: true, canCopyResumeCommand: false }) + expect( + aiVaultSessionRowResumeGating( + { ...cursorSession, cwd: null }, + { blocked: false, cursorCommandAvailable: true } + ) + ).toEqual({ resumeDisabled: true, canCopyResumeCommand: false }) + expect( + aiVaultSessionRowResumeGating(cursorSession, { + blocked: false, + cursorCommandAvailable: true + }) + ).toEqual({ resumeDisabled: false, canCopyResumeCommand: true }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 9465144b705..2caeef90d93 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -2,7 +2,9 @@ import type { Repo, Worktree } from '../../../../shared/types' import { canResumeAiVaultSessionOnTarget, getAiVaultResumeWorkspaceExecutionHostId, - getAiVaultResumeWorkspaceTargetStatus + getAiVaultResumeWorkspacePath, + getAiVaultResumeWorkspaceTargetStatus, + getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-target' import { isAiVaultSessionResumableContent, @@ -20,12 +22,14 @@ import { export type AiVaultSessionResumeTargetState = Pick< AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo' -> +> & + Partial> export type AiVaultSessionResumeState = { blocked: boolean worktreeId: string | null usesSessionWorktree: boolean + cursorCommandAvailable?: boolean } export type AiVaultSessionResumeAction = { @@ -39,6 +43,7 @@ export type AiVaultSessionResumeActions = { } export function resolveAiVaultSessionResumeState(args: { + sessionAgent?: AiVaultSession['agent'] sessionFilePath: string | null sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null @@ -64,6 +69,7 @@ export function resolveAiVaultSessionResumeState(args: { const targetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, sessionExecutionHostId: args.sessionExecutionHostId, + sessionAgent: args.sessionAgent, worktreeId, targetState }) @@ -85,6 +91,7 @@ export function resolveAiVaultSessionResumeState(args: { } export function resolveAiVaultSessionResumeActions(args: { + sessionAgent?: AiVaultSession['agent'] sessionFilePath: string | null sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null @@ -102,12 +109,14 @@ export function resolveAiVaultSessionResumeActions(args: { const sessionTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, sessionExecutionHostId: args.sessionExecutionHostId, + sessionAgent: args.sessionAgent, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, sessionExecutionHostId: args.sessionExecutionHostId, + sessionAgent: args.sessionAgent, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -150,6 +159,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( } function resolveSupportedResumeWorktreeId(args: { + sessionAgent?: AiVaultSession['agent'] sessionFilePath: string | null sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeId: string | null @@ -172,8 +182,11 @@ function resolveSupportedResumeWorktreeId(args: { !canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, sessionExecutionHostId: args.sessionExecutionHostId, + sessionAgent: args.sessionAgent, targetStatus, - targetExecutionHostId + targetExecutionHostId, + targetWorkspacePath: getAiVaultResumeWorkspacePath(args.targetState, args.worktreeId), + targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, args.worktreeId) }) ) { return null @@ -207,13 +220,20 @@ function resolveAiVaultResumeTargetState(args: { // copying the command stays available for blocked-but-real sessions, so the copy // affordance is gated on content alone. export function aiVaultSessionRowResumeGating( - session: Pick, - state: Pick | null + session: Pick & + Partial>, + state: + | (Pick & + Partial>) + | null ): { resumeDisabled: boolean; canCopyResumeCommand: boolean } { const hasResumableContent = isAiVaultSessionResumableContent(session) + const hasCursorResumeTarget = + session.agent !== 'cursor' || + Boolean(session.cwd && session.resumeCommand?.trim() && state?.cursorCommandAvailable === true) return { - resumeDisabled: (state?.blocked ?? true) || !hasResumableContent, - canCopyResumeCommand: hasResumableContent + resumeDisabled: (state?.blocked ?? true) || !hasResumableContent || !hasCursorResumeTarget, + canCopyResumeCommand: hasResumableContent && hasCursorResumeTarget } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx index a2c9f1410cf..784ca5761b6 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -52,11 +52,16 @@ export function SessionMetadata({ {liveState && liveState !== 'done' ? : null} {agentLabel(session.agent)} - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} + {session.messageCount === 0 && session.hasConversation + ? translate( + 'auto.components.right.sidebar.AiVaultSessionRow.conversationAvailable', + 'Conversation available' + ) + : translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} {session.subagentTranscriptCount > 0 ? ( <> diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d3afd06e139..b9864c391c4 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -4,6 +4,7 @@ import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import { resolveCursorCommandOverrides } from '@/lib/ai-vault-cursor-command' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null @@ -31,21 +32,28 @@ export function buildSourceControlAgentDeliveryPlan({ if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() } + const state = useAppStore.getState() const result = planSourceControlAgentActionLaunch({ agent: selectedAgent, commandInput, agentArgs, sessionOptions: selectedAgent ? resolveNativeChatSessionOptionDefaults( - useAppStore.getState().settings?.nativeChatSessionOptions, + state.settings?.nativeChatSessionOptions, selectedAgent ) : undefined, promptDelivery, detectedAgents, - disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, - cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides, - terminalWindowsShell: useAppStore.getState().settings?.terminalWindowsShell, + disabledAgents: state.settings?.disabledTuiAgents, + cmdOverrides: selectedAgent + ? resolveCursorCommandOverrides({ + state, + agent: selectedAgent, + cmdOverrides: state.settings?.agentCmdOverrides ?? {} + }) + : state.settings?.agentCmdOverrides, + terminalWindowsShell: state.settings?.terminalWindowsShell, platform: launchPlatform, isRemote }) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-cursor-agent-detection.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-cursor-agent-detection.ts new file mode 100644 index 00000000000..358fcc82161 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-cursor-agent-detection.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { parseExecutionHostId } from '../../../../shared/execution-host' + +export function useAiVaultCursorAgentDetection({ + sessions, + effectiveActiveWorktreeId, + sessionWorktreeById, + ensureDetectedAgents, + ensureRemoteDetectedAgents, + ensureRuntimeDetectedAgents +}: { + sessions: readonly AiVaultSession[] + effectiveActiveWorktreeId: string | null + sessionWorktreeById: ReadonlyMap + ensureDetectedAgents: (worktreeId?: string) => Promise + ensureRemoteDetectedAgents: (targetId: string) => Promise + ensureRuntimeDetectedAgents: (environmentId: string) => Promise +}): void { + useEffect(() => { + const cursorSessions = sessions.filter((session) => session.agent === 'cursor') + const cursorHostIds = new Set(cursorSessions.map((session) => session.executionHostId)) + const localWorktreeIds = new Set() + if (effectiveActiveWorktreeId) { + localWorktreeIds.add(effectiveActiveWorktreeId) + } + for (const session of cursorSessions) { + const worktreeId = sessionWorktreeById.get(session.id)?.worktreeId + if (worktreeId) { + localWorktreeIds.add(worktreeId) + } + } + let hasLocalCursorSession = false + for (const hostId of cursorHostIds) { + const host = parseExecutionHostId(hostId) + if (host?.kind === 'ssh') { + void ensureRemoteDetectedAgents(host.targetId) + } else if (host?.kind === 'runtime') { + void ensureRuntimeDetectedAgents(host.environmentId) + } else { + hasLocalCursorSession = true + } + } + if (!hasLocalCursorSession) { + return + } + if (localWorktreeIds.size === 0) { + void ensureDetectedAgents() + return + } + for (const worktreeId of localWorktreeIds) { + void ensureDetectedAgents(worktreeId) + } + }, [ + effectiveActiveWorktreeId, + ensureDetectedAgents, + ensureRemoteDetectedAgents, + ensureRuntimeDetectedAgents, + sessionWorktreeById, + sessions + ]) +} diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.ts new file mode 100644 index 00000000000..dc7340b9d4b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.ts @@ -0,0 +1,111 @@ +import { useCallback } from 'react' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo, Worktree } from '../../../../shared/types' +import { resolveAiVaultCursorCommand, type CursorCommandState } from '@/lib/ai-vault-cursor-command' +import { + resolveAiVaultSessionResumeActions, + resolveAiVaultSessionResumeState, + type AiVaultSessionResumeTargetState +} from './ai-vault-session-resume' +import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' + +export function useAiVaultSessionResumeControls({ + activeWorktreeId, + worktrees, + repos, + targetState, + cursorCommandOverride, + getSessionWorktreeInfo +}: { + activeWorktreeId: string | null + worktrees: readonly Worktree[] + repos: readonly Repo[] + targetState: AiVaultSessionResumeTargetState & CursorCommandState + cursorCommandOverride?: string | null + getSessionWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null +}): { + getSessionResumeState: ( + session: AiVaultSession + ) => ReturnType + getSessionResumeActions: ( + session: AiVaultSession + ) => ReturnType +} { + const cursorCommandAvailable = useCallback( + (worktreeId: string | null): boolean => + Boolean( + worktreeId && + resolveAiVaultCursorCommand({ + state: targetState, + worktreeId, + commandOverride: cursorCommandOverride + }) + ), + [cursorCommandOverride, targetState] + ) + const getSessionResumeState = useCallback( + (session: AiVaultSession) => { + const state = resolveAiVaultSessionResumeState({ + sessionAgent: session.agent, + sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, + worktreeInfo: getSessionWorktreeInfo(session), + activeWorktreeId, + worktrees, + repos, + targetState + }) + return session.agent === 'cursor' + ? { + ...state, + cursorCommandAvailable: cursorCommandAvailable(state.worktreeId) + } + : state + }, + [ + activeWorktreeId, + cursorCommandAvailable, + getSessionWorktreeInfo, + repos, + targetState, + worktrees + ] + ) + const getSessionResumeActions = useCallback( + (session: AiVaultSession) => { + const actions = resolveAiVaultSessionResumeActions({ + sessionAgent: session.agent, + sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, + worktreeInfo: getSessionWorktreeInfo(session), + activeWorktreeId, + worktrees, + repos, + targetState + }) + if (session.agent !== 'cursor') { + return actions + } + return { + worktree: { + ...actions.worktree, + disabled: + actions.worktree.disabled || !cursorCommandAvailable(actions.worktree.worktreeId) + }, + newTab: { + ...actions.newTab, + disabled: actions.newTab.disabled || !cursorCommandAvailable(actions.newTab.worktreeId) + } + } + }, + [ + activeWorktreeId, + cursorCommandAvailable, + getSessionWorktreeInfo, + repos, + targetState, + worktrees + ] + ) + return { getSessionResumeState, getSessionResumeActions } +} diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 8cc5ed91d69..641c89a5792 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { resolveAiVaultCursorCommand } from '@/lib/ai-vault-cursor-command' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' @@ -81,12 +82,18 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { ) if (folderWorktree) { const onboarding = await window.api.onboarding.get().catch(() => null) + const currentState = useAppStore.getState() // Why: SSH users can hit this dialog from Add Project after // dismissing onboarding, bypassing the local addNonGitFolder path. const startup = buildDismissedOnboardingFolderAgentStartup( - useAppStore.getState().settings, + currentState.settings, onboarding, - hadProjectBeforeAdd + hadProjectBeforeAdd, + resolveAiVaultCursorCommand({ + state: currentState, + worktreeId: folderWorktree.id, + commandOverride: currentState.settings?.agentCmdOverrides?.cursor + }) ) activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 2b64f589e68..73f3356add2 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -24,6 +24,9 @@ import type { LaunchSource } from '../../../../shared/telemetry-events' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { TaskSourceContext } from '../../../../shared/task-source-context' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { normalizeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { useAppStore } from '@/store' +import { resolveCursorCommandOverrides } from '@/lib/ai-vault-cursor-command' import { getLinkedItemDisplayName, toFolderWorkspaceLinkedTask @@ -201,13 +204,24 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote, terminalWindowsShell }) + const effectiveCmdOverrides = quickAgent + ? resolveCursorCommandOverrides({ + state: useAppStore.getState(), + agent: quickAgent, + cmdOverrides: agentCmdOverrides ?? {}, + executionHostId: projectGroup.connectionId + ? toSshExecutionHostId(projectGroup.connectionId) + : normalizeExecutionHostId(projectGroup.executionHostId), + workspacePath: projectGroup.parentPath + }) + : agentCmdOverrides const startupPlan = quickAgent && linkedWorkItem ? buildFolderWorkspaceLinkedStartupPlan({ agent: quickAgent, linkedWorkItem, note, - agentCmdOverrides, + agentCmdOverrides: effectiveCmdOverrides, agentArgs, agentEnv, sessionOptions, @@ -219,7 +233,7 @@ export async function submitFolderWorkspaceCreate({ ? buildAgentStartupPlan({ agent: quickAgent, prompt: note, - cmdOverrides: agentCmdOverrides ?? {}, + cmdOverrides: effectiveCmdOverrides ?? {}, agentArgs, agentEnv, sessionOptions, diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 3c9ddd26369..81992819dc6 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -3,7 +3,9 @@ import { toast } from 'sonner' import { canResumeAiVaultSessionOnTarget, getAiVaultResumeWorkspaceExecutionHostId, - getAiVaultResumeWorkspaceTargetStatus + getAiVaultResumeWorkspacePath, + getAiVaultResumeWorkspaceTargetStatus, + getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-target' import { AI_VAULT_SESSION_DRAG_END_EVENT, @@ -192,8 +194,11 @@ export default function AiVaultSessionDropLayer({ !canResumeAiVaultSessionOnTarget({ sessionFilePath: payload.sessionFilePath ?? null, sessionExecutionHostId: payload.sessionExecutionHostId ?? null, + sessionAgent: payload.agent, targetStatus, - targetExecutionHostId + targetExecutionHostId, + targetWorkspacePath: getAiVaultResumeWorkspacePath(state, worktreeId), + targetWslDistro: getAiVaultResumeWorkspaceWslDistro(state, worktreeId) }) ) { toast.error( diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 6281e81d152..c7a39bf060e 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { resolveCursorCommandOverrides } from '@/lib/ai-vault-cursor-command' import { getDefaultRepoHookSettings } from '../../../shared/constants' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -3765,7 +3766,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const startupPlan = buildAgentStartupPlan({ agent: tuiAgent, prompt: submitStartupPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, + cmdOverrides: resolveCursorCommandOverrides({ + state: useAppStore.getState(), + agent: tuiAgent, + repoId: selectedRepo?.id, + cmdOverrides: settings?.agentCmdOverrides ?? {} + }), agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), sessionOptions: resolveNativeChatSessionOptionDefaults( @@ -4316,7 +4322,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : buildAgentDraftLaunchPlan({ agent, draft: quickDraftPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, + cmdOverrides: resolveCursorCommandOverrides({ + state: useAppStore.getState(), + agent, + repoId: selectedRepo?.id, + cmdOverrides: settings?.agentCmdOverrides ?? {} + }), agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), sessionOptions: resolveNativeChatSessionOptionDefaults( @@ -4348,7 +4359,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS startupPlan = buildAgentStartupPlan({ agent, prompt: quickPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, + cmdOverrides: resolveCursorCommandOverrides({ + state: useAppStore.getState(), + agent, + repoId: selectedRepo?.id, + cmdOverrides: settings?.agentCmdOverrides ?? {} + }), agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), sessionOptions: resolveNativeChatSessionOptionDefaults( diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index a4f0d7f3f96..94c6b358239 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -71,7 +71,9 @@ beforeEach(() => { runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } - : [] + : method === 'preflight.detectAgentInventory' + ? { version: 1, agents: [], matchedCommands: {} } + : [] return Promise.resolve({ id: method, ok: true, @@ -170,7 +172,7 @@ describe('useDetectedAgents (runtime call site)', () => { expect( runtimeEnvironmentCall.mock.calls.filter( - ([{ method }]) => method === 'preflight.detectAgents' + ([{ method }]) => method === 'preflight.detectAgentInventory' ) ).toHaveLength(2) }) @@ -245,7 +247,8 @@ describe('useDetectedAgents (runtime call site)', () => { } } else { detectCalls += 1 - result = detectCalls === 1 ? [] : ['kilo'] + const agents = detectCalls === 1 ? [] : ['kilo'] + result = { version: 1, agents, matchedCommands: {} } } return Promise.resolve({ id: method, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c4913683d8e..033f3e942bd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11368,7 +11368,6 @@ "refreshSessionHistory": "Refresh Session History", "searchSessions": "Search sessions", "clearSearch": "Clear search", - "remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.", "transcriptsSkipped": "{{count}} transcript skipped", "noAgentSessionsFound": "No agent sessions found", "noSessionsMatchFilters": "No sessions match the current filters", @@ -11499,6 +11498,7 @@ "copySessionId": "Copy Session ID", "copyLogPath": "Copy Log Path", "messageCount": "{{value0}} msgs", + "conversationAvailable": "Conversation available", "tokenCount": "{{value0}} tok", "hideDetails": "Hide Details", "showDetails": "Show Details", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 68a9bcb8fbd..18d1b81efc6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11123,7 +11123,6 @@ "refreshSessionHistory": "Actualizar historial de sesiones", "searchSessions": "Buscar sesiones", "clearSearch": "Limpiar búsqueda", - "remoteBrowseLocalHistory": "Los espacios de trabajo remotos pueden explorar el historial local. Las acciones de reanudación se ejecutan desde espacios de trabajo locales.", "transcriptsSkipped": "{{count}} transcripciones omitidas", "noAgentSessionsFound": "No se encontraron sesiones de agentes", "noSessionsMatchFilters": "Ninguna sesión coincide con los filtros actuales", @@ -11252,6 +11251,7 @@ "copySessionId": "Copiar ID de sesión", "copyLogPath": "Copiar ruta de registro", "messageCount": "{{value0}} mens.", + "conversationAvailable": "Conversación disponible", "tokenCount": "{{value0}} tok", "toggleSessionDetails": "Detalles de sesión de {{value0}}", "hideDetails": "Ocultar detalles", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9ef5a69cbae..16cfb066177 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11123,7 +11123,6 @@ "refreshSessionHistory": "セッション履歴を更新", "searchSessions": "セッションを検索", "clearSearch": "Clear search", - "remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.", "transcriptsSkipped": "{{count}} transcript skipped", "noAgentSessionsFound": "No agent sessions found", "noSessionsMatchFilters": "No sessions match the current filters", @@ -11252,6 +11251,7 @@ "copySessionId": "セッション ID をコピー", "copyLogPath": "ログパスをコピー", "messageCount": "{{value0}} 件のメッセージ", + "conversationAvailable": "会話あり", "tokenCount": "{{value0}} トークン", "toggleSessionDetails": "{{value0}} のセッション詳細", "hideDetails": "詳細を非表示", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d6c3e34fcd1..1d31f37b1d7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11123,7 +11123,6 @@ "refreshSessionHistory": "세션 기록 새로고침", "searchSessions": "세션 검색", "clearSearch": "검색 지우기", - "remoteBrowseLocalHistory": "원격 워크스페이스는 로컬 기록을 탐색할 수 있습니다. 재개 작업은 로컬 워크스페이스에서 실행됩니다.", "transcriptsSkipped": "대화 기록 {{count}}개 건너뜀", "noAgentSessionsFound": "agent 세션을 찾을 수 없습니다", "noSessionsMatchFilters": "현재 필터와 일치하는 세션이 없습니다", @@ -11252,6 +11251,7 @@ "copySessionId": "세션 ID 복사", "copyLogPath": "로그 경로 복사", "messageCount": "메시지 {{value0}}개", + "conversationAvailable": "대화 사용 가능", "tokenCount": "토큰 {{value0}}개", "toggleSessionDetails": "{{value0}} 세션 세부정보", "hideDetails": "세부정보 숨기기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c5f255fe4e9..bb80b412661 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11143,7 +11143,6 @@ "refreshSessionHistory": "刷新会话历史", "searchSessions": "搜索会话", "clearSearch": "清除搜索", - "remoteBrowseLocalHistory": "远程工作区可以浏览本地历史。恢复操作从本地工作区运行。", "transcriptsSkipped": "跳过了 {{count}} 个记录", "noAgentSessionsFound": "未找到智能体会话", "noSessionsMatchFilters": "没有会话符合当前筛选条件", @@ -11272,6 +11271,7 @@ "copySessionId": "复制会话 ID", "copyLogPath": "复制日志路径", "messageCount": "{{value0}} 条消息", + "conversationAvailable": "有可用对话", "tokenCount": "{{value0}} token", "toggleSessionDetails": "{{value0}} 会话详情", "hideDetails": "隐藏详情", diff --git a/src/renderer/src/lib/ai-vault-cursor-command.test.ts b/src/renderer/src/lib/ai-vault-cursor-command.test.ts new file mode 100644 index 00000000000..d77108d03fc --- /dev/null +++ b/src/renderer/src/lib/ai-vault-cursor-command.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { resolveAiVaultCursorCommand } from './ai-vault-cursor-command' + +function state( + overrides: Partial< + Pick< + AppState, + | 'detectedAgentCommands' + | 'detectedAgentCommandsByContext' + | 'remoteDetectedAgentCommands' + | 'runtimeDetectedAgentCommands' + > + > = {} +): Parameters[0]['state'] { + return { + activeRepoId: null, + activeWorktreeId: null, + folderWorkspaces: [], + projectGroups: [], + projects: [], + repos: [], + settings: {} as AppState['settings'], + worktreesByRepo: {}, + ...overrides + } as Parameters[0]['state'] +} + +describe('resolveAiVaultCursorCommand', () => { + it('uses isolated native and WSL inventory matches', () => { + const source = state({ + detectedAgentCommandsByContext: { + host: { cursor: 'cursor-agent' }, + 'wsl:Ubuntu': { cursor: 'cursor agent' } + } + }) + expect(resolveAiVaultCursorCommand({ state: source })).toBe('cursor-agent') + expect( + resolveAiVaultCursorCommand({ + state: source, + workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + }) + ).toBe('cursor agent') + }) + + it('uses only the exact SSH or runtime host inventory', () => { + const source = state({ + remoteDetectedAgentCommands: { + dev: { cursor: 'cursor agent' }, + other: { cursor: 'cursor-agent' } + }, + runtimeDetectedAgentCommands: { + 'env-1': { cursor: 'cursor-agent' } + } + }) + expect( + resolveAiVaultCursorCommand({ + state: source, + executionHostId: 'ssh:dev' + }) + ).toBe('cursor agent') + expect( + resolveAiVaultCursorCommand({ + state: source, + executionHostId: 'runtime:env-1' + }) + ).toBe('cursor-agent') + expect( + resolveAiVaultCursorCommand({ + state: source, + executionHostId: 'runtime:env-2' + }) + ).toBeNull() + }) + + it('prefers an explicit override over every detected match', () => { + expect( + resolveAiVaultCursorCommand({ + state: state({ + detectedAgentCommandsByContext: { host: { cursor: 'cursor-agent' } } + }), + commandOverride: 'cursor-dev' + }) + ).toBe('cursor-dev') + }) +}) diff --git a/src/renderer/src/lib/ai-vault-cursor-command.ts b/src/renderer/src/lib/ai-vault-cursor-command.ts new file mode 100644 index 00000000000..303ab3c1da6 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-cursor-command.ts @@ -0,0 +1,131 @@ +import { resolveEffectiveCursorCommand } from '../../../shared/cursor-command' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { TuiAgent } from '../../../shared/types' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' +import { getLocalAgentPreflightContext, localPreflightContextKey } from './local-preflight-context' +import type { AppState } from '@/store/types' +import type { AiVaultSession } from '../../../shared/ai-vault-types' + +export type CursorCommandState = Pick< + AppState, + 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo' +> & + Partial< + Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'detectedAgentCommands' + | 'detectedAgentCommandsByContext' + | 'projects' + | 'remoteDetectedAgentCommands' + | 'runtimeDetectedAgentCommands' + | 'settings' + > + > + +export function resolveAiVaultCursorCommand(args: { + state: CursorCommandState + worktreeId?: string | null + repoId?: string | null + executionHostId?: ExecutionHostId | null + workspacePath?: string | null + commandOverride?: string | null +}): string | null { + const repo = args.repoId + ? args.state.repos.find((candidate) => candidate.id === args.repoId) + : null + const host = parseExecutionHostId( + args.executionHostId ?? + (repo + ? getRepoExecutionHostId(repo) + : getExecutionHostIdForWorktree(args.state, args.worktreeId ?? args.state.activeWorktreeId)) + ) + let matches: Partial> | undefined + if (host?.kind === 'ssh') { + matches = args.state.remoteDetectedAgentCommands?.[host.targetId] + } else if (host?.kind === 'runtime') { + matches = args.state.runtimeDetectedAgentCommands?.[host.environmentId] + } else { + const workspaceDistro = parseWslUncPath(args.workspacePath ?? '')?.distro + const contextKey = workspaceDistro + ? `wsl:${workspaceDistro}` + : localPreflightContextKey( + getLocalAgentPreflightContext( + (args.repoId + ? { ...args.state, activeRepoId: args.repoId, activeWorktreeId: null } + : args.state) as AppState, + undefined, + undefined, + args.worktreeId + ) + ) + matches = args.state.detectedAgentCommandsByContext?.[contextKey] + } + return resolveEffectiveCursorCommand(args.commandOverride, { + version: 1, + agents: matches?.cursor ? ['cursor'] : [], + matchedCommands: matches ?? {} + }) +} + +export function resolveCursorCommandOverrides(args: { + state: CursorCommandState + agent: TuiAgent + cmdOverrides: Partial> + worktreeId?: string | null + repoId?: string | null + executionHostId?: ExecutionHostId | null + workspacePath?: string | null +}): Partial> { + if (args.agent !== 'cursor') { + return args.cmdOverrides + } + const command = resolveAiVaultCursorCommand({ + state: args.state, + worktreeId: args.worktreeId, + repoId: args.repoId, + executionHostId: args.executionHostId, + workspacePath: args.workspacePath, + commandOverride: args.cmdOverrides.cursor + }) + return command ? { ...args.cmdOverrides, cursor: command } : args.cmdOverrides +} + +export function resolveWorktreeAgentCommandOverrides( + state: CursorCommandState, + agent: TuiAgent, + worktreeId: string +): Partial> { + return resolveCursorCommandOverrides({ + state, + agent, + worktreeId, + cmdOverrides: { ...state.settings?.agentCmdOverrides } + }) +} + +export function resolveAiVaultResumeCommandOverride(args: { + state: CursorCommandState + worktreeId?: string | null + session: Pick + commandOverride?: string | null +}): string | null | undefined { + if (args.session.agent !== 'cursor') { + return args.commandOverride + } + const command = resolveAiVaultCursorCommand(args) + if (!args.session.cwd || !command?.trim()) { + throw new Error( + args.session.cwd + ? 'Cursor CLI not detected on this host.' + : 'Cursor did not record a resumable workspace for this session.' + ) + } + return command +} diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 9ad997974f5..bb51836e0f8 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -22,7 +22,8 @@ type AiVaultResumeCommandState = Pick< | 'repos' | 'settings' | 'worktreesByRepo' -> +> & + Partial> function makeState(args: { worktreePath: string @@ -142,7 +143,8 @@ describe('ai vault resume command runtime', () => { sessionId: 'session one', cwd: 'C:\\Users\\alice\\repo', codexHome: null - } + }, + commandOverride: 'cursor-agent' }) ).toBe( "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'" @@ -634,6 +636,39 @@ describe('ai vault resume command runtime', () => { ) }) + it('rebuilds remote Windows Cursor resumes from the host-matched command', () => { + const state = makeState({ worktreePath: 'C:/Users/alice/repo' }) + state.repos = [ + { + id: 'repo-1', + path: 'C:/Users/alice/repo', + executionHostId: 'ssh:win-box' + } + ] as never + state.remoteDetectedAgentCommands = { + 'win-box': { cursor: 'cursor agent' } + } + + expect( + buildQueuedAiVaultResumeCommand({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'cursor', + sessionId: 'session one', + cwd: "C:/Users/alice/repo's", + codexHome: null, + executionHostId: 'ssh:win-box', + executionHostPlatform: 'win32', + resumeCommand: + 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && cursor-agent --resume ""session one"""' + } + }) + ).toBe( + "Set-Location -LiteralPath 'C:/Users/alice/repo''s'; cursor agent --resume 'session one'" + ) + }) + it('ignores a stored resume command for local-host sessions', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 8a1551b1ee6..34f81206c26 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -30,6 +30,7 @@ import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { resolveAiVaultResumeCommandOverride } from '@/lib/ai-vault-cursor-command' type AiVaultResumeCommandSession = Pick< AiVaultSession, @@ -58,7 +59,16 @@ type AiVaultResumeWorktreeArgs = { | 'repos' | 'settings' | 'worktreesByRepo' - > + > & + Partial< + Pick< + AppState, + | 'detectedAgentCommands' + | 'detectedAgentCommandsByContext' + | 'remoteDetectedAgentCommands' + | 'runtimeDetectedAgentCommands' + > + > worktreeId?: string | null session: AiVaultResumeCommandSession commandOverride?: string | null @@ -77,11 +87,7 @@ export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWork return `${clearHomes}${separator}${command}` } -export function buildAiVaultResumeStartupForWorktree( - args: AiVaultResumeWorktreeArgs -): AiVaultResumeStartup { - return buildAiVaultResumeForWorktree(args) -} +export const buildAiVaultResumeStartupForWorktree = buildAiVaultResumeForWorktree /** * Rebuilds a drag-drop resume startup under the account home the host @@ -122,13 +128,15 @@ export function buildAiVaultDropRepinStartup(args: { function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { const providerSession = getAiVaultAgentProviderSession(args.session) + const commandOverride = resolveAiVaultResumeCommandOverride(args) if ( args.session.executionHostId && args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && args.session.resumeCommand && args.session.agent !== 'omp' && + args.session.agent !== 'cursor' && !(args.session.agent === 'codex' && args.session.codexHome === null) && - !args.commandOverride?.trim() + !commandOverride?.trim() ) { return { command: args.session.resumeCommand, @@ -160,7 +168,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault providerSession, cmdOverrides: { ...args.state.settings?.agentCmdOverrides, - ...(args.commandOverride?.trim() ? { [args.session.agent]: args.commandOverride } : {}) + ...(commandOverride?.trim() ? { [args.session.agent]: commandOverride } : {}) }, platform, shell: liveShell, @@ -212,7 +220,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault resumeFilePath, cwd: args.session.cwd, platform, - commandOverride: args.commandOverride, + commandOverride, codexHome, // Why: non-resumable agents queue through this fallback too, so it must // quote for the live Windows shell like the startup-plan branch above. diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index fca20f25cc8..217ab1a41c6 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -9,10 +9,13 @@ import { import type { Repo } from '../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { isWslUncPath } from '../../../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../../../shared/wsl-paths' +import type { AiVaultAgent } from '../../../shared/ai-vault-types' +import { canResumeAiVaultSessionInExecutionContext } from '../../../shared/ai-vault-resume-context' import type { AppState } from '@/store/types' import { getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' +import { getLocalAgentPreflightContext } from './local-preflight-context' export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' @@ -46,49 +49,40 @@ export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | u export function canResumeAiVaultSessionOnTarget(args: { sessionFilePath: string | null | undefined sessionExecutionHostId?: ExecutionHostId | null + sessionAgent?: AiVaultAgent targetStatus: AiVaultResumeTargetStatus targetExecutionHostId?: ExecutionHostId | null + targetWorkspacePath?: string | null + targetWslDistro?: string | null }): boolean { - const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) - const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) - if (args.targetStatus === 'runtime') { - // Runtime session stores live on one paired server; only queue resumes back - // onto that exact server host. - return Boolean( - sessionExecutionHostId && - targetExecutionHostId && - sessionExecutionHostId === targetExecutionHostId - ) - } - if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { - return false - } - if (sessionExecutionHostId) { - if (targetExecutionHostId) { - if (sessionExecutionHostId === targetExecutionHostId) { - return true - } - // Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the - // file lives under a WSL UNC path reachable from any SSH shell into this - // machine, so we bypass the exact host-id match for that case. - return ( - sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID && - args.targetStatus === 'ssh' && - isWslStoredAiVaultSessionFile(args.sessionFilePath) - ) - } - if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) { - return false - } - } - // Why: vault sessions are scanned from this machine's disk (host home dirs - // plus local WSL homes). An SSH shell can only reach the WSL-stored ones - // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist - // on a remote filesystem, so queuing a resume there is guaranteed to fail. - if (args.targetStatus === 'ssh') { - return isWslStoredAiVaultSessionFile(args.sessionFilePath) - } - return true + return canResumeAiVaultSessionInExecutionContext({ + agent: args.sessionAgent, + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus: args.targetStatus, + targetExecutionHostId: args.targetExecutionHostId, + targetWslDistro: + args.targetWslDistro ?? parseWslUncPath(args.targetWorkspacePath ?? '')?.distro ?? null + }) +} + +export function canResumeAiVaultSessionForWorkspace(args: { + state: Pick & + Partial> + workspaceId: string + sessionFilePath: string | null | undefined + sessionExecutionHostId?: ExecutionHostId | null + sessionAgent?: AiVaultAgent +}): boolean { + return canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + sessionAgent: args.sessionAgent, + targetStatus: getAiVaultResumeWorkspaceTargetStatus(args.state, args.workspaceId), + targetExecutionHostId: getAiVaultResumeWorkspaceExecutionHostId(args.state, args.workspaceId), + targetWorkspacePath: getAiVaultResumeWorkspacePath(args.state, args.workspaceId), + targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.state, args.workspaceId) + }) } export function isUnsupportedAiVaultResumeRepo( @@ -166,6 +160,44 @@ export function getAiVaultResumeWorkspaceTargetStatus( return getAiVaultResumeRepoTargetStatus(state.repos.find((repo) => repo.id === repoId)) } +export function getAiVaultResumeWorkspacePath( + state: Pick, + workspaceId: string +): string | null { + const workspaceKey = parseWorkspaceKey(workspaceId) + if (workspaceKey?.type === 'folder') { + return ( + state.folderWorkspaces.find((workspace) => workspace.id === workspaceKey.folderWorkspaceId) + ?.folderPath ?? null + ) + } + const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId + return getIndexedWorktreeMap(state.worktreesByRepo ?? {}).get(worktreeId)?.path ?? null +} + +export function getAiVaultResumeWorkspaceWslDistro( + state: Pick & + Partial< + Pick + >, + workspaceId: string +): string | null { + const workspaceKey = parseWorkspaceKey(workspaceId) + if (workspaceKey?.type !== 'folder' && state.settings && state.projects && state.repos) { + const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId + const context = getLocalAgentPreflightContext( + state as AppState, + undefined, + undefined, + worktreeId + ) + if (context?.wslDistro) { + return context.wslDistro + } + } + return parseWslUncPath(getAiVaultResumeWorkspacePath(state, workspaceId) ?? '')?.distro ?? null +} + type AiVaultResumeRepoOwnerWithId = AiVaultResumeRepoOwner & { id: string } function getAiVaultResumeFolderTargetStatus( diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts index 60ebed0477a..c5357608910 100644 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ b/src/renderer/src/lib/github-work-item-background-request.ts @@ -27,6 +27,7 @@ import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import { resolveCursorCommandOverrides } from '@/lib/ai-vault-cursor-command' export type GitHubWorkItemBackgroundStoreSnapshot = { repos: readonly Repo[] @@ -55,6 +56,16 @@ export type GitHubWorkItemBackgroundStoreSnapshot = { ensureRuntimeDetectedAgents: ReturnType< typeof useAppStore.getState >['ensureRuntimeDetectedAgents'] + detectedAgentCommands?: ReturnType['detectedAgentCommands'] + detectedAgentCommandsByContext?: ReturnType< + typeof useAppStore.getState + >['detectedAgentCommandsByContext'] + remoteDetectedAgentCommands?: ReturnType< + typeof useAppStore.getState + >['remoteDetectedAgentCommands'] + runtimeDetectedAgentCommands?: ReturnType< + typeof useAppStore.getState + >['runtimeDetectedAgentCommands'] } export type BuildInitialGitHubWorkItemRequestArgs = { @@ -196,11 +207,17 @@ export function buildGitHubWorkItemStartupPlan(args: { isRemote, terminalWindowsShell: store.settings?.terminalWindowsShell }) + const cmdOverrides = resolveCursorCommandOverrides({ + state: store as ReturnType, + agent, + repoId: repo.id, + cmdOverrides: store.settings?.agentCmdOverrides ?? {} + }) const draftLaunchPlan = draftPrompt ? buildAgentDraftLaunchPlan({ agent, draft: draftPrompt, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, + cmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), platform, @@ -223,7 +240,7 @@ export function buildGitHubWorkItemStartupPlan(args: { : buildAgentStartupPlan({ agent, prompt: quickPrompt, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, + cmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), platform, diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index f65204d6558..dc33c42d8e7 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -40,6 +40,7 @@ import { } from '@/lib/adopt-agent-background-session-tab' import { createBackgroundAgentStatusConsumer } from '@/lib/background-agent-status-consumer' import { isWslUncPath } from '../../../shared/wsl-paths' +import { resolveWorktreeAgentCommandOverrides } from '@/lib/ai-vault-cursor-command' export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs @@ -52,7 +53,7 @@ export async function launchAgentBackgroundSession( if (!worktree) { throw new Error('The target workspace is no longer available.') } - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const cmdOverrides = resolveWorktreeAgentCommandOverrides(store, agent, worktreeId) const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) // Folder launch ownership cannot be derived from a repo row (#2989). diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d3e3718d151..4018aebba96 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -29,6 +29,7 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { resolveWorktreeAgentCommandOverrides } from '@/lib/ai-vault-cursor-command' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -101,7 +102,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI isRemote, terminalWindowsShell: store.settings?.terminalWindowsShell }) - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const cmdOverrides = resolveWorktreeAgentCommandOverrides(store, agent, worktreeId) const effectiveAgentArgs = agentArgs !== undefined ? agentArgs diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index f9cc02e4a0e..fcafe02822f 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { resolveCursorCommandOverrides } from '@/lib/ai-vault-cursor-command' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' @@ -276,7 +277,19 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom agentArgs, draftContent, promptDelivery, - settings, + settings: settings + ? { + ...settings, + agentCmdOverrides: effectiveAgent + ? resolveCursorCommandOverrides({ + state: store, + agent: effectiveAgent, + worktreeId, + cmdOverrides: settings.agentCmdOverrides ?? {} + }) + : settings.agentCmdOverrides + } + : settings, launchPlatform, // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` // rename must not be applied for remote launches. diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 57a1feb39b8..1d904dd4eaf 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -30,7 +30,8 @@ function getClientPlatform(): NodeJS.Platform { } export function buildOnboardingFolderAgentStartup( - settings: GlobalSettings | null + settings: GlobalSettings | null, + cursorCommand?: string | null ): OnboardingFolderAgentStartup | undefined { const agent = settings?.defaultTuiAgent if ( @@ -45,7 +46,10 @@ export function buildOnboardingFolderAgentStartup( const startupPlan = buildAgentStartupPlan({ agent, prompt: '', - cmdOverrides: settings.agentCmdOverrides ?? {}, + cmdOverrides: + agent === 'cursor' && cursorCommand + ? { ...settings.agentCmdOverrides, cursor: cursorCommand } + : settings.agentCmdOverrides, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), sessionOptions: resolveNativeChatSessionOptionDefaults( @@ -91,10 +95,11 @@ export function shouldSeedFolderAgentAfterDismissedOnboarding( export function buildDismissedOnboardingFolderAgentStartup( settings: GlobalSettings | null, onboarding: OnboardingState | null, - hasExistingProject: boolean + hasExistingProject: boolean, + cursorCommand?: string | null ): OnboardingFolderAgentStartup | undefined { if (!shouldSeedFolderAgentAfterDismissedOnboarding(onboarding, hasExistingProject)) { return undefined } - return buildOnboardingFolderAgentStartup(settings) + return buildOnboardingFolderAgentStartup(settings, cursorCommand) } diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 7aedee34513..ae9d61f0423 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' +import { resolveCursorCommandOverrides } from '@/lib/ai-vault-cursor-command' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' @@ -71,7 +72,12 @@ export function launchSleepingAgentSession( const startupPlan = buildAgentResumeStartupPlan({ agent: record.agent, providerSession: record.providerSession, - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + cmdOverrides: resolveCursorCommandOverrides({ + state, + agent: record.agent, + worktreeId: record.worktreeId, + cmdOverrides: state.settings?.agentCmdOverrides ?? {} + }), agentArgs: launchConfig !== undefined ? launchConfig.agentArgs diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index d40bab3f4eb..c2911900243 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -149,7 +149,7 @@ describe('buildAgentStartupPlan', () => { buildAgentStartupPlan({ agent: 'cursor', prompt: 'Review this file', - cmdOverrides: {}, + cmdOverrides: { cursor: 'cursor-agent' }, platform: 'darwin' }) ).toEqual({ @@ -161,6 +161,28 @@ describe('buildAgentStartupPlan', () => { }) }) + it('does not fall back to a Cursor command that was not proven in this context', () => { + expect( + buildAgentStartupPlan({ + agent: 'cursor', + prompt: 'Review this file', + cmdOverrides: {}, + platform: 'darwin' + }) + ).toBeNull() + }) + + it('uses the capability-proven Cursor subcommand form exactly', () => { + expect( + buildAgentStartupPlan({ + agent: 'cursor', + prompt: 'Review this file', + cmdOverrides: { cursor: 'cursor agent' }, + platform: 'linux' + })?.launchCommand + ).toBe("cursor agent 'Review this file'") + }) + it('applies command overrides without changing the prompt syntax contract', () => { expect( buildAgentStartupPlan({ diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 1755f355072..0e17a4c5ea6 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -443,7 +443,9 @@ describe('createDetectedAgentsSlice remote detection', () => { runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } - : ['codex'] + : method === 'preflight.detectAgentInventory' + ? { version: 1, agents: ['codex'], matchedCommands: { codex: 'codex' } } + : ['codex'] return Promise.resolve({ id: method, ok: true, @@ -558,12 +560,50 @@ describe('createDetectedAgentsSlice remote detection', () => { }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { selector: 'env-1', - method: 'preflight.detectAgents', + method: 'preflight.detectAgentInventory', params: undefined, timeoutMs: undefined }) }) + it('preserves the legacy Cursor command when runtime inventory is unavailable', async () => { + const store = createTestStore() + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'preflight.detectAgentInventory') { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'method_not_found', message: `Unknown method: ${method}` }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + const result = + method === 'status.get' + ? { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : ['cursor'] + return Promise.resolve({ + id: method, + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect(store.getState().ensureRuntimeDetectedAgents('env-1')).resolves.toEqual(['cursor']) + expect(store.getState().runtimeDetectedAgentCommands['env-1']).toEqual({ + cursor: 'cursor-agent' + }) + }) + it('re-runs runtime detection after an empty result instead of pinning it', async () => { const store = createTestStore() // An empty [] is truthy, so a prior "no agents found" must not be cached: @@ -583,9 +623,12 @@ describe('createDetectedAgentsSlice remote detection', () => { runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } - } else { + } else if (method === 'preflight.detectAgentInventory') { detectCalls += 1 - result = detectCalls === 1 ? [] : ['kilo'] + const agents = detectCalls === 1 ? [] : ['kilo'] + result = { version: 1, agents, matchedCommands: {} } + } else { + result = [] } return Promise.resolve({ id: method, @@ -675,7 +718,7 @@ describe('createDetectedAgentsSlice remote detection', () => { }) } return new Promise((resolve) => { - if (method === 'preflight.detectAgents') { + if (method === 'preflight.detectAgentInventory') { resolveDetect = resolve } else { resolveRefresh = resolve @@ -687,7 +730,7 @@ describe('createDetectedAgentsSlice remote detection', () => { await vi.waitFor(() => { expect( runtimeEnvironmentCall.mock.calls.filter( - ([{ method }]) => method === 'preflight.detectAgents' + ([{ method }]) => method === 'preflight.detectAgentInventory' ) ).toHaveLength(1) }) @@ -718,9 +761,9 @@ describe('createDetectedAgentsSlice remote detection', () => { await expect(refresh).resolves.toEqual(['kilo']) resolveDetect({ - id: 'preflight.detectAgents', + id: 'preflight.detectAgentInventory', ok: true, - result: ['claude'], + result: { version: 1, agents: ['claude'], matchedCommands: { claude: 'claude' } }, _meta: { runtimeId: 'remote-runtime' } }) await expect(detect).resolves.toEqual(['claude']) @@ -758,7 +801,7 @@ describe('createDetectedAgentsSlice remote detection', () => { runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } - : ['kilo'] + : ['cursor'] return Promise.resolve({ id: method, ok: true, @@ -767,8 +810,13 @@ describe('createDetectedAgentsSlice remote detection', () => { }) }) - await expect(store.getState().refreshRuntimeDetectedAgents('env-1')).resolves.toEqual(['kilo']) - expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['kilo']) + await expect(store.getState().refreshRuntimeDetectedAgents('env-1')).resolves.toEqual([ + 'cursor' + ]) + expect(store.getState().runtimeDetectedAgentCommands['env-1']).toEqual({ + cursor: 'cursor-agent' + }) + expect(store.getState().runtimeDetectedAgentIds['env-1']).toEqual(['cursor']) expect(store.getState().isRefreshingRuntimeAgents['env-1']).toBe(false) }) diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index e3a4f4947a0..49a9a4e389f 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -8,6 +8,8 @@ import { export type DetectedAgentsSlice = { detectedAgentIds: TuiAgent[] | null + detectedAgentCommands: Partial> + detectedAgentCommandsByContext: Record>> isDetectingAgents: boolean isRefreshingAgents: boolean /** Telemetry classification of the most recent refreshAgents() run. `null` @@ -29,6 +31,7 @@ export type DetectedAgentsSlice = { // detectedAgentIds field is connection-unaware, so remote state lives in a // separate map keyed by SSH connectionId. remoteDetectedAgentIds: Record + remoteDetectedAgentCommands: Record>> isDetectingRemoteAgents: Record ensureRemoteDetectedAgents: ( connectionId: string, @@ -57,6 +60,8 @@ export const createDetectedAgentsSlice: StateCreator ({ detectedAgentIds: null, + detectedAgentCommands: {}, + detectedAgentCommandsByContext: {}, isDetectingAgents: false, isRefreshingAgents: false, pathSource: null, @@ -78,12 +83,28 @@ export const createDetectedAgentsSlice: StateCreator { - const typed = ids as TuiAgent[] + const detection = window.api.preflight.detectAgentInventory + ? window.api.preflight.detectAgentInventory(context) + : window.api.preflight.detectAgentCommands + ? window.api.preflight.detectAgentCommands(context) + : window.api.preflight + .detectAgents(context) + .then((agents): { agents: string[]; matchedCommands?: Record } => ({ + agents + })) + const pending = detection + .then((result) => { + const typed = result.agents as TuiAgent[] if (requestGeneration === localDetectionGeneration) { - set({ detectedAgentIds: typed, isDetectingAgents: false }) + set({ + detectedAgentIds: typed, + detectedAgentCommands: result.matchedCommands ?? {}, + detectedAgentCommandsByContext: { + ...get().detectedAgentCommandsByContext, + [contextKey]: result.matchedCommands ?? {} + }, + isDetectingAgents: false + }) detectedContextKey = contextKey } return typed @@ -95,6 +116,7 @@ export const createDetectedAgentsSlice: StateCreator { @@ -187,13 +217,25 @@ export const createDetectedAgentsSlice: StateCreator { - const typed = ids as TuiAgent[] + const detection = window.api.preflight.detectRemoteAgentInventory + ? window.api.preflight.detectRemoteAgentInventory({ connectionId }) + : window.api.preflight.detectRemoteAgentCommands + ? window.api.preflight.detectRemoteAgentCommands({ connectionId }) + : window.api.preflight + .detectRemoteAgents({ connectionId }) + .then((agents): { agents: string[]; matchedCommands?: Record } => ({ + agents + })) + const pending = detection + .then((result) => { + const typed = result.agents as TuiAgent[] if (remoteDetectPromises.get(connectionId) === pending) { set((s) => ({ remoteDetectedAgentIds: { ...s.remoteDetectedAgentIds, [connectionId]: typed }, + remoteDetectedAgentCommands: { + ...s.remoteDetectedAgentCommands, + [connectionId]: result.matchedCommands ?? {} + }, isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false } })) } @@ -252,7 +294,12 @@ export const createDetectedAgentsSlice: StateCreator { const { [connectionId]: _, ...restAgents } = s.remoteDetectedAgentIds const { [connectionId]: __, ...restLoading } = s.isDetectingRemoteAgents - return { remoteDetectedAgentIds: restAgents, isDetectingRemoteAgents: restLoading } + const { [connectionId]: ___, ...restCommands } = s.remoteDetectedAgentCommands + return { + remoteDetectedAgentIds: restAgents, + remoteDetectedAgentCommands: restCommands, + isDetectingRemoteAgents: restLoading + } }) } }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index f8fa9ebc61e..86f84231e0e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -72,6 +72,7 @@ import { import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { resolveAiVaultCursorCommand } from '@/lib/ai-vault-cursor-command' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' @@ -3268,7 +3269,12 @@ export const createRepoSlice: StateCreator = (set, const startup = buildDismissedOnboardingFolderAgentStartup( get().settings, onboarding, - hadProjectBeforeAdd + hadProjectBeforeAdd, + resolveAiVaultCursorCommand({ + state: get(), + worktreeId: folderWorktree.id, + commandOverride: get().settings?.agentCmdOverrides?.cursor + }) ) activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', diff --git a/src/renderer/src/store/slices/runtime-detected-agents.ts b/src/renderer/src/store/slices/runtime-detected-agents.ts index 466c131f4e7..a03773f0641 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.ts @@ -2,12 +2,14 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TuiAgent } from '../../../../shared/types' import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { legacyDetectedAgentInventory } from '../../../../shared/detected-agent-inventory' // Why: remote runtime hosts are not SSH connections, but their launch surfaces // (tab bar, quick launch, Settings → Agents under an Active Server) still have // to probe the host where the workspace actually runs. export type RuntimeDetectedAgentsSlice = { runtimeDetectedAgentIds: Record + runtimeDetectedAgentCommands: Record>> isDetectingRuntimeAgents: Record isRefreshingRuntimeAgents: Record ensureRuntimeDetectedAgents: (environmentId: string) => Promise @@ -42,6 +44,7 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< RuntimeDetectedAgentsSlice > = (set, get) => ({ runtimeDetectedAgentIds: {}, + runtimeDetectedAgentCommands: {}, isDetectingRuntimeAgents: {}, isRefreshingRuntimeAgents: {}, @@ -66,18 +69,32 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: true } })) - const pending = callRuntimeRpc( - { kind: 'environment', environmentId }, - 'preflight.detectAgents' - ) - .then((ids) => { - const typed = ids as TuiAgent[] + const pending = callRuntimeRpc<{ + agents: TuiAgent[] + matchedCommands?: Partial> + }>({ kind: 'environment', environmentId }, 'preflight.detectAgentInventory') + .catch((error) => { + if (!isRuntimeMethodNotFoundError(error)) { + throw error + } + return callRuntimeRpc( + { kind: 'environment', environmentId }, + 'preflight.detectAgents' + ).then(legacyDetectedAgentInventory) + }) + .then((result) => { + const typed = result.agents as TuiAgent[] // Why: skip committing if the environment was removed (retained out) // while the detect was in flight — otherwise it re-adds a stale entry // that retainRuntimeDetectedAgents just pruned. if (runtimeDetectPromises.get(environmentId) === pending) { set((s) => ({ runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, + runtimeDetectedAgentCommands: { + ...s.runtimeDetectedAgentCommands, + [environmentId]: + ('matchedCommands' in result ? result.matchedCommands : undefined) ?? {} + }, isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false } })) } @@ -120,11 +137,11 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< isRefreshingRuntimeAgents: { ...s.isRefreshingRuntimeAgents, [environmentId]: true } })) - const pending = callRuntimeRpc<{ agents: TuiAgent[] }>( - { kind: 'environment', environmentId }, - 'preflight.refreshAgents' - ) - .then((result) => result.agents) + const pending = callRuntimeRpc<{ + agents: TuiAgent[] + matchedCommands?: Partial> + }>({ kind: 'environment', environmentId }, 'preflight.refreshAgents') + .then((result) => result) .catch((error) => { if (!isRuntimeMethodNotFoundError(error)) { throw error @@ -134,15 +151,20 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< return callRuntimeRpc( { kind: 'environment', environmentId }, 'preflight.detectAgents' - ) + ).then(legacyDetectedAgentInventory) }) - .then((ids) => { - const typed = ids as TuiAgent[] + .then((result) => { + const typed = result.agents as TuiAgent[] // Why: same guard as ensureRuntimeDetectedAgents — if the environment // was retained out mid-refresh, don't re-add a pruned entry. if (runtimeRefreshPromises.get(environmentId) === pending) { set((s) => ({ runtimeDetectedAgentIds: { ...s.runtimeDetectedAgentIds, [environmentId]: typed }, + runtimeDetectedAgentCommands: { + ...s.runtimeDetectedAgentCommands, + [environmentId]: + ('matchedCommands' in result ? result.matchedCommands : undefined) ?? {} + }, isDetectingRuntimeAgents: { ...s.isDetectingRuntimeAgents, [environmentId]: false @@ -183,8 +205,10 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< const { [environmentId]: _, ...restAgents } = s.runtimeDetectedAgentIds const { [environmentId]: __, ...restLoading } = s.isDetectingRuntimeAgents const { [environmentId]: ___, ...restRefreshing } = s.isRefreshingRuntimeAgents + const { [environmentId]: ____, ...restCommands } = s.runtimeDetectedAgentCommands return { runtimeDetectedAgentIds: restAgents, + runtimeDetectedAgentCommands: restCommands, isDetectingRuntimeAgents: restLoading, isRefreshingRuntimeAgents: restRefreshing } @@ -206,6 +230,7 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< set((s) => { let changed = false const nextAgents = { ...s.runtimeDetectedAgentIds } + const nextCommands = { ...s.runtimeDetectedAgentCommands } const nextLoading = { ...s.isDetectingRuntimeAgents } const nextRefreshing = { ...s.isRefreshingRuntimeAgents } for (const id of Object.keys(nextAgents)) { @@ -214,6 +239,12 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< changed = true } } + for (const id of Object.keys(nextCommands)) { + if (!keep.has(id)) { + delete nextCommands[id] + changed = true + } + } for (const id of Object.keys(nextLoading)) { if (!keep.has(id)) { delete nextLoading[id] @@ -229,6 +260,7 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< return changed ? { runtimeDetectedAgentIds: nextAgents, + runtimeDetectedAgentCommands: nextCommands, isDetectingRuntimeAgents: nextLoading, isRefreshingRuntimeAgents: nextRefreshing } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 55ad967929a..ea171dba01a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -9,6 +9,7 @@ import type { import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { parseHostAccessLink } from '../../../shared/remote-pairing-address' import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' +import type { DetectedAgentInventoryV1 } from '../../../shared/detected-agent-inventory' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, @@ -2823,6 +2824,27 @@ function createPreflightApi(): NonNullable['preflight']> { } return callRuntimeResult('preflight.detectAgents').catch(() => []) }, + detectAgentInventory: async () => { + if (!requireActiveEnvironmentOrNull()) { + return { version: 1, agents: [], matchedCommands: {} } + } + return callRuntimeResult('preflight.detectAgentInventory').catch( + () => ({ + version: 1, + agents: [], + matchedCommands: {} + }) + ) + }, + detectAgentCommands: async () => { + if (!requireActiveEnvironmentOrNull()) { + return { agents: [], matchedCommands: {} } + } + return callRuntimeResult<{ + agents: string[] + matchedCommands?: Record + }>('preflight.detectAgentCommands').catch(() => ({ agents: [], matchedCommands: {} })) + }, refreshAgents: () => requireActiveEnvironmentOrNull() ? callRuntimeResult('preflight.refreshAgents') @@ -2833,6 +2855,27 @@ function createPreflightApi(): NonNullable['preflight']> { requireActiveEnvironmentOrNull() ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) : [], + detectRemoteAgentInventory: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult( + 'preflight.detectRemoteAgentInventory', + args + ).catch(() => ({ + version: 1, + agents: [], + matchedCommands: {} + })) + : { version: 1, agents: [], matchedCommands: {} }, + detectRemoteAgentCommands: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult<{ + agents: string[] + matchedCommands?: Record + }>('preflight.detectRemoteAgentCommands', args).catch(() => ({ + agents: [], + matchedCommands: {} + })) + : { agents: [], matchedCommands: {} }, detectRemoteWindowsTerminalCapabilities: async (args) => requireActiveEnvironmentOrNull() ? callRuntimeResult( diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index f1f06dabe7e..5a565177afa 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -122,4 +122,31 @@ describe('buildAiVaultResumeCommand', () => { }) ).toBe("cd '/Users/ada/repo' && omp --resume '019f27cd-4268-7000-96e7-62f42a55c144'") }) + + it('quotes hostile Cursor cwd and session values for POSIX shells', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'cursor', + sessionId: "id'; echo pwn", + cwd: "/repo/'quoted;$(touch /tmp/pwn)", + platform: 'linux', + commandOverride: 'cursor-agent' + }) + ).toBe("cd '/repo/'\\''quoted;$(touch /tmp/pwn)' && cursor-agent --resume 'id'\\''; echo pwn'") + }) + + it('quotes hostile Cursor cwd and session values for PowerShell', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'cursor', + sessionId: "id'; Write-Host pwn", + cwd: "C:\\repo\\'; Remove-Item *", + platform: 'win32', + shell: 'powershell', + commandOverride: 'cursor agent' + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\repo\\''; Remove-Item *'; cursor agent --resume 'id''; Write-Host pwn'" + ) + }) }) diff --git a/src/shared/ai-vault-resume-context.test.ts b/src/shared/ai-vault-resume-context.test.ts new file mode 100644 index 00000000000..0248dd59312 --- /dev/null +++ b/src/shared/ai-vault-resume-context.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { canResumeAiVaultSessionInExecutionContext } from './ai-vault-resume-context' + +const WSL_CURSOR_PATH = + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.cursor\\chats\\bucket\\session\\meta.json' + +describe('canResumeAiVaultSessionInExecutionContext', () => { + it('isolates native and WSL Cursor sessions', () => { + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionFilePath: '/home/ada/.cursor/chats/bucket/session/meta.json', + sessionExecutionHostId: 'local', + targetStatus: 'local', + targetExecutionHostId: 'local' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionFilePath: WSL_CURSOR_PATH, + sessionExecutionHostId: 'local', + targetStatus: 'local', + targetExecutionHostId: 'local', + targetWslDistro: 'ubuntu' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionFilePath: WSL_CURSOR_PATH, + sessionExecutionHostId: 'local', + targetStatus: 'local', + targetExecutionHostId: 'local', + targetWslDistro: 'Debian' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionFilePath: '/home/ada/.cursor/chats/bucket/session/meta.json', + sessionExecutionHostId: 'local', + targetStatus: 'local', + targetExecutionHostId: 'local', + targetWslDistro: 'Ubuntu' + }) + ).toBe(false) + }) + + it('requires the exact SSH or runtime host and matching target kind', () => { + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionExecutionHostId: 'ssh:dev', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionExecutionHostId: 'ssh:dev', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:other' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionExecutionHostId: 'runtime:env-1', + targetStatus: 'runtime', + targetExecutionHostId: 'runtime:env-1' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionInExecutionContext({ + agent: 'cursor', + sessionExecutionHostId: 'runtime:env-1', + targetStatus: 'ssh', + targetExecutionHostId: 'runtime:env-1' + }) + ).toBe(false) + }) + + it('excludes Cursor from the legacy Local-WSL to SSH exception', () => { + const target = { + sessionFilePath: WSL_CURSOR_PATH, + sessionExecutionHostId: 'local' as const, + targetStatus: 'ssh' as const, + targetExecutionHostId: 'ssh:dev' as const + } + expect(canResumeAiVaultSessionInExecutionContext({ ...target, agent: 'claude' })).toBe(true) + expect(canResumeAiVaultSessionInExecutionContext({ ...target, agent: 'cursor' })).toBe(false) + }) +}) diff --git a/src/shared/ai-vault-resume-context.ts b/src/shared/ai-vault-resume-context.ts new file mode 100644 index 00000000000..a284520b053 --- /dev/null +++ b/src/shared/ai-vault-resume-context.ts @@ -0,0 +1,74 @@ +import type { AiVaultAgent } from './ai-vault-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from './execution-host' +import { parseWslUncPath } from './wsl-paths' + +export type AiVaultResumeExecutionTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' + +export function canResumeAiVaultSessionInExecutionContext(args: { + agent?: AiVaultAgent + sessionFilePath?: string | null + sessionExecutionHostId?: ExecutionHostId | null + targetStatus: AiVaultResumeExecutionTargetStatus + targetExecutionHostId?: ExecutionHostId | null + targetWslDistro?: string | null +}): boolean { + return args.agent === 'cursor' + ? canResumeCursorInExecutionContext(args) + : canResumeLegacySessionInExecutionContext(args) +} + +function canResumeCursorInExecutionContext( + args: Parameters[0] +): boolean { + const sessionHost = + normalizeExecutionHostId(args.sessionExecutionHostId) ?? LOCAL_EXECUTION_HOST_ID + const targetHost = normalizeExecutionHostId(args.targetExecutionHostId) + const parsedSessionHost = parseExecutionHostId(sessionHost) + if (parsedSessionHost?.kind === 'ssh') { + return args.targetStatus === 'ssh' && targetHost === sessionHost + } + if (parsedSessionHost?.kind === 'runtime') { + return args.targetStatus === 'runtime' && targetHost === sessionHost + } + if ( + args.targetStatus !== 'local' || + (targetHost !== null && targetHost !== LOCAL_EXECUTION_HOST_ID) + ) { + return false + } + const sourceDistro = parseWslUncPath(args.sessionFilePath ?? '')?.distro.toLowerCase() ?? null + const targetDistro = args.targetWslDistro?.trim().toLowerCase() || null + return sourceDistro === targetDistro +} + +function canResumeLegacySessionInExecutionContext( + args: Parameters[0] +): boolean { + const sessionHost = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetHost = normalizeExecutionHostId(args.targetExecutionHostId) + if (args.targetStatus === 'runtime') { + return Boolean(sessionHost && targetHost && sessionHost === targetHost) + } + if (args.targetStatus !== 'local' && args.targetStatus !== 'ssh') { + return false + } + if (sessionHost && targetHost) { + if (sessionHost === targetHost) { + return true + } + return ( + sessionHost === LOCAL_EXECUTION_HOST_ID && + args.targetStatus === 'ssh' && + Boolean(parseWslUncPath(args.sessionFilePath ?? '')) + ) + } + if (sessionHost && sessionHost !== LOCAL_EXECUTION_HOST_ID) { + return false + } + return args.targetStatus !== 'ssh' || Boolean(parseWslUncPath(args.sessionFilePath ?? '')) +} diff --git a/src/shared/ai-vault-session-id.ts b/src/shared/ai-vault-session-id.ts new file mode 100644 index 00000000000..8b23abd4a0c --- /dev/null +++ b/src/shared/ai-vault-session-id.ts @@ -0,0 +1,54 @@ +import { createHash } from 'node:crypto' +import type { AiVaultAgent } from './ai-vault-types' +import type { ExecutionHostId } from './execution-host' + +export function buildAiVaultSessionId(args: { + executionHostId: ExecutionHostId + agent: AiVaultAgent + sessionId: string + filePath: string + previousId?: string + cursorStorageContextKey?: string + cursorBucketCollision?: string + cursorLegacyPathCollision?: string +}): string { + const prefix = `${args.executionHostId}:${args.agent}:${args.sessionId}` + if (args.agent !== 'cursor') { + return `${prefix}:${args.filePath}` + } + const preservedSuffix = cursorIdentitySuffix(args.previousId, args.sessionId) + if (preservedSuffix !== null) { + return `${prefix}${preservedSuffix}` + } + const context = + args.cursorStorageContextKey && + args.cursorStorageContextKey !== 'native' && + args.cursorStorageContextKey !== args.executionHostId + ? `:ctx-${stableIdentityHash(args.cursorStorageContextKey)}` + : '' + const collision = args.cursorBucketCollision + ? `:bucket-${args.cursorBucketCollision}` + : args.cursorLegacyPathCollision + ? `:legacy-${stableIdentityHash(args.cursorLegacyPathCollision)}` + : '' + return `${prefix}${context}${collision}` +} + +function cursorIdentitySuffix(previousId: string | undefined, sessionId: string): string | null { + if (!previousId) { + return null + } + const marker = `:cursor:${sessionId}` + const markerIndex = previousId.lastIndexOf(marker) + if (markerIndex < 0) { + return null + } + const suffix = previousId.slice(markerIndex + marker.length) + return suffix === '' || /^(?::(?:ctx|bucket|legacy)-[a-zA-Z0-9._-]+)+$/.test(suffix) + ? suffix + : null +} + +function stableIdentityHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 16) +} diff --git a/src/shared/ai-vault-transcript-path.test.ts b/src/shared/ai-vault-transcript-path.test.ts new file mode 100644 index 00000000000..ea4540da265 --- /dev/null +++ b/src/shared/ai-vault-transcript-path.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { getAiVaultTranscriptPath } from './ai-vault-transcript-path' + +describe('getAiVaultTranscriptPath', () => { + it('uses an explicit transcript path', () => { + expect( + getAiVaultTranscriptPath({ + filePath: '/metadata/meta.json', + transcriptFilePath: '/logs/session.jsonl' + }) + ).toBe('/logs/session.jsonl') + }) + + it('hides logs when the producer explicitly reports no transcript', () => { + expect( + getAiVaultTranscriptPath({ + filePath: '/metadata/meta.json', + transcriptFilePath: null + }) + ).toBeNull() + }) + + it('falls back to filePath for older producers', () => { + expect(getAiVaultTranscriptPath({ filePath: '/legacy/session.jsonl' })).toBe( + '/legacy/session.jsonl' + ) + }) +}) diff --git a/src/shared/ai-vault-transcript-path.ts b/src/shared/ai-vault-transcript-path.ts new file mode 100644 index 00000000000..dcb0da6dd5f --- /dev/null +++ b/src/shared/ai-vault-transcript-path.ts @@ -0,0 +1,10 @@ +import type { AiVaultSession } from './ai-vault-types' + +export function getAiVaultTranscriptPath( + session: Pick +): string | null { + if (session.transcriptFilePath === null) { + return null + } + return (session.transcriptFilePath ?? session.filePath).trim() || null +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 6e132fce990..df40376b7c1 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -94,6 +94,10 @@ export type AiVaultSession = { updatedAt: string | null modifiedAt: string messageCount: number + /** Provider evidence that content exists when an exact turn count is unavailable. */ + hasConversation?: boolean + /** Explicit transcript path; null means this producer has no readable transcript. */ + transcriptFilePath?: string | null totalTokens: number previewMessages: AiVaultSessionPreviewMessage[] /** Older messages fell out of the newest-N window: the earliest preview turn @@ -151,9 +155,10 @@ export type AiVaultFirstUserPromptResult = { // Conversation previews count as evidence too: some parsers (e.g. Grok, OpenCode // fallback schemas) only learn the turn count from metadata that may be absent. export function isAiVaultSessionResumableContent( - session: Pick + session: Pick ): boolean { return ( + session.hasConversation === true || session.messageCount > 0 || session.previewMessages.some( (message) => message.role === 'user' || message.role === 'assistant' @@ -193,6 +198,8 @@ export type AiVaultListArgs = { limit?: number unlimited?: boolean force?: boolean + refreshReason?: 'manual' | 'passive' | 'session-start' + refreshExecutionHostId?: ExecutionHostId // Active workspace/project paths. The global result is recency-capped, so these // guarantee a scoped view still surfaces its own (possibly older) sessions. scopePaths?: readonly string[] diff --git a/src/shared/cursor-command.test.ts b/src/shared/cursor-command.test.ts new file mode 100644 index 00000000000..c974d0a0ae7 --- /dev/null +++ b/src/shared/cursor-command.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { resolveEffectiveCursorCommand } from './cursor-command' + +describe('resolveEffectiveCursorCommand', () => { + it('prefers an explicit override over host inventory', () => { + expect( + resolveEffectiveCursorCommand(' custom-cursor ', { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' } + }) + ).toBe('custom-cursor') + }) + + it.each([ + ['cursor-agent', 'cursor-agent'], + ['cursor agent', 'cursor agent'] + ])('maps the detected %s form to %s', (matched, expected) => { + expect( + resolveEffectiveCursorCommand(null, { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: matched } + }) + ).toBe(expected) + }) + + it('does not accept an unprobed cursor executable match', () => { + expect( + resolveEffectiveCursorCommand(null, { + version: 1, + agents: ['cursor'], + matchedCommands: { cursor: 'cursor' } + }) + ).toBeNull() + }) + + it('does not infer availability from an agent id without a matched command', () => { + expect( + resolveEffectiveCursorCommand(null, { + version: 1, + agents: ['cursor'], + matchedCommands: {} + }) + ).toBeNull() + }) +}) diff --git a/src/shared/cursor-command.ts b/src/shared/cursor-command.ts new file mode 100644 index 00000000000..440d2690cf0 --- /dev/null +++ b/src/shared/cursor-command.ts @@ -0,0 +1,23 @@ +import type { DetectedAgentInventoryV1 } from './detected-agent-inventory' + +export function resolveEffectiveCursorCommand( + override: string | null | undefined, + inventory: DetectedAgentInventoryV1 | null | undefined +): string | null { + const configured = override?.trim() + if (configured) { + return configured + } + return cursorLaunchCommandFromMatch(inventory?.matchedCommands.cursor) +} + +export function cursorLaunchCommandFromMatch(match: string | null | undefined): string | null { + const normalized = match?.trim() + if (normalized === 'cursor-agent') { + return 'cursor-agent' + } + if (normalized === 'cursor agent') { + return 'cursor agent' + } + return null +} diff --git a/src/shared/cursor-sidecar-scan.ts b/src/shared/cursor-sidecar-scan.ts new file mode 100644 index 00000000000..1e7956ab85f --- /dev/null +++ b/src/shared/cursor-sidecar-scan.ts @@ -0,0 +1,160 @@ +import { posix, win32 } from 'node:path' +import { z } from 'zod' + +export const CURSOR_SIDECAR_SCAN_VERSION = 1 as const +export const CURSOR_SIDECAR_MAX_BYTES = 262_144 +export const CURSOR_REMOTE_MAX_AGGREGATE_BYTES = 16_777_216 +export const CURSOR_REMOTE_MAX_BUCKETS = 256 +export const CURSOR_REMOTE_MAX_SCOPE_PATHS = 64 +export const CURSOR_REMOTE_MAX_SESSION_DIRS = 2_000 + +const positiveSafeInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const finiteNonnegativeNumber = z.number().finite().nonnegative() +const boundedPath = z.string().min(1).max(32_768) +const safeSessionBasename = z + .string() + .min(1) + .max(4_096) + .refine((value) => value !== '.' && value !== '..' && !/[\\/]/u.test(value)) + +export const cursorSidecarScanRequestSchema = z + .object({ + version: z.literal(CURSOR_SIDECAR_SCAN_VERSION), + chatsRoot: boundedPath, + scopePaths: z.array(boundedPath).max(CURSOR_REMOTE_MAX_SCOPE_PATHS), + maxBuckets: positiveSafeInteger, + maxSessionDirs: positiveSafeInteger, + maxScopePaths: positiveSafeInteger, + maxSidecarBytes: positiveSafeInteger, + maxAggregateBytes: positiveSafeInteger + }) + .strict() + +const cursorSidecarScanItemSchema = z + .object({ + bucket: z.string().regex(/^[0-9a-f]{32}$/u), + sessionId: safeSessionBasename, + metaPath: boundedPath, + content: z.string(), + metaMtimeMs: finiteNonnegativeNumber, + metaSizeBytes: finiteNonnegativeNumber, + storeMtimeMs: finiteNonnegativeNumber, + scopeCwd: boundedPath.nullable() + }) + .strict() + .superRefine((item, context) => { + if (utf8ByteLength(item.content) > CURSOR_SIDECAR_MAX_BYTES) { + context.addIssue({ code: 'custom', message: 'Cursor sidecar exceeds the byte cap.' }) + } + }) + +const cursorSidecarScanCountersSchema = z + .object({ + rootReaddir: z.number().int().nonnegative(), + bucketReaddir: z.number().int().nonnegative(), + fileLstat: z.number().int().nonnegative(), + boundedReads: z.number().int().nonnegative(), + scopeRealpath: z.number().int().nonnegative(), + returnedBytes: z.number().int().nonnegative(), + elapsedMs: finiteNonnegativeNumber + }) + .strict() + +const cursorSidecarScanTruncationSchema = z + .object({ + scopePaths: z.boolean(), + buckets: z.boolean(), + sessionDirs: z.boolean(), + sidecarBytes: z.boolean() + }) + .strict() + +const cursorSidecarScanIssueSchema = z + .object({ + path: boundedPath, + message: z.string().min(1).max(1_024) + }) + .strict() + +export const cursorSidecarScanResponseSchema = z + .object({ + version: z.literal(CURSOR_SIDECAR_SCAN_VERSION), + scopeCwds: z.array(boundedPath).max(CURSOR_REMOTE_MAX_SCOPE_PATHS * 6), + sidecars: z.array(cursorSidecarScanItemSchema).max(CURSOR_REMOTE_MAX_SESSION_DIRS), + issues: z + .array(cursorSidecarScanIssueSchema) + .max( + CURSOR_REMOTE_MAX_SESSION_DIRS + + CURSOR_REMOTE_MAX_BUCKETS + + CURSOR_REMOTE_MAX_SCOPE_PATHS * 6 + + 8 + ), + counters: cursorSidecarScanCountersSchema, + truncated: cursorSidecarScanTruncationSchema + }) + .strict() + .superRefine((response, context) => { + const totalBytes = response.sidecars.reduce( + (total, sidecar) => total + utf8ByteLength(sidecar.content), + 0 + ) + if ( + totalBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES || + totalBytes !== response.counters.returnedBytes + ) { + context.addIssue({ code: 'custom', message: 'Invalid Cursor sidecar byte total.' }) + } + }) + +export type CursorSidecarScanRequest = z.infer +export type CursorSidecarScanResponse = z.infer + +export function defaultCursorSidecarScanRequest( + chatsRoot: string, + scopePaths: readonly string[], + platform: NodeJS.Platform +): CursorSidecarScanRequest { + return { + version: CURSOR_SIDECAR_SCAN_VERSION, + chatsRoot, + scopePaths: normalizeCursorRemoteScopePaths(scopePaths, platform), + maxBuckets: CURSOR_REMOTE_MAX_BUCKETS, + maxSessionDirs: CURSOR_REMOTE_MAX_SESSION_DIRS, + maxScopePaths: CURSOR_REMOTE_MAX_SCOPE_PATHS, + maxSidecarBytes: CURSOR_SIDECAR_MAX_BYTES, + maxAggregateBytes: CURSOR_REMOTE_MAX_AGGREGATE_BYTES + } +} + +export function normalizeCursorRemoteScopePaths( + values: readonly string[], + platform: NodeJS.Platform +): string[] { + return normalizedCursorRemoteScopePaths(values, platform).slice(0, CURSOR_REMOTE_MAX_SCOPE_PATHS) +} + +export function cursorRemoteScopePathsWereTruncated( + values: readonly string[], + platform: NodeJS.Platform +): boolean { + return normalizedCursorRemoteScopePaths(values, platform).length > CURSOR_REMOTE_MAX_SCOPE_PATHS +} + +function normalizedCursorRemoteScopePaths( + values: readonly string[], + platform: NodeJS.Platform +): string[] { + const pathOps = platform === 'win32' ? win32 : posix + return [ + ...new Set( + values + .map((value) => value.trim()) + .filter((value) => value.length > 0 && pathOps.isAbsolute(value)) + .map((value) => pathOps.resolve(value)) + ) + ].sort() +} + +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength +} diff --git a/src/shared/detected-agent-inventory.ts b/src/shared/detected-agent-inventory.ts new file mode 100644 index 00000000000..7e70be6f555 --- /dev/null +++ b/src/shared/detected-agent-inventory.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' +import { ALL_TUI_AGENTS } from './tui-agent-display-names' +import type { TuiAgent } from './types' + +export const DETECTED_AGENT_INVENTORY_VERSION = 1 as const + +export type DetectedAgentInventoryV1 = { + version: 1 + agents: TuiAgent[] + matchedCommands: Partial> +} + +const knownAgentIds = new Set(ALL_TUI_AGENTS) +const tuiAgentSchema = z + .string() + .refine((value) => knownAgentIds.has(value)) + .transform((value) => value as TuiAgent) + +export const detectedAgentInventoryRequestSchema = z + .object({ + version: z.literal(DETECTED_AGENT_INVENTORY_VERSION), + commands: z + .array( + z + .object({ + id: tuiAgentSchema, + cmd: z.string().min(1).max(4_096), + capabilityProbe: z + .object({ + args: z.array(z.string().max(4_096)).max(8), + matchedCommand: z.string().min(1).max(4_096) + }) + .strict() + .optional(), + requiredCommands: z.array(z.string().min(1).max(4_096)).max(8).optional(), + unsupportedRuntimes: z + .array( + z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd', + 'wsl' + ]) + ) + .max(16) + .optional() + }) + .strict() + ) + .max(ALL_TUI_AGENTS.length * 4) + }) + .strict() + +export type DetectedAgentInventoryRequestV1 = z.infer + +export const detectedAgentInventorySchema = z + .object({ + version: z.literal(DETECTED_AGENT_INVENTORY_VERSION), + agents: z.array(tuiAgentSchema).max(ALL_TUI_AGENTS.length), + matchedCommands: z + .record(z.string(), z.string().min(1).max(4_096)) + .refine((commands) => Object.keys(commands).every((agent) => knownAgentIds.has(agent))) + .transform((commands) => commands as Partial>) + }) + .strict() + +export function emptyDetectedAgentInventory(): DetectedAgentInventoryV1 { + return { version: DETECTED_AGENT_INVENTORY_VERSION, agents: [], matchedCommands: {} } +} + +export function detectedAgentInventory( + agents: readonly TuiAgent[], + matchedCommands: Partial> +): DetectedAgentInventoryV1 { + return detectedAgentInventorySchema.parse({ + version: DETECTED_AGENT_INVENTORY_VERSION, + agents: [...new Set(agents)], + matchedCommands + }) +} + +export function legacyDetectedAgentInventory( + agents: readonly TuiAgent[] +): DetectedAgentInventoryV1 { + return detectedAgentInventory(agents, agents.includes('cursor') ? { cursor: 'cursor-agent' } : {}) +} diff --git a/src/shared/node-verified-bounded-text-file.ts b/src/shared/node-verified-bounded-text-file.ts new file mode 100644 index 00000000000..6fa8b45a339 --- /dev/null +++ b/src/shared/node-verified-bounded-text-file.ts @@ -0,0 +1,129 @@ +import { constants } from 'node:fs' +import type { Stats } from 'node:fs' +import { lstat, open, realpath } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, sep } from 'node:path' + +const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 + +export type VerifiedBoundedTextFileOptions = { + maxBytes: number + expectedRootRealPath: string +} + +export async function readVerifiedBoundedTextFile( + filePath: string, + options: VerifiedBoundedTextFileOptions +): Promise { + const maxBytes = validatedByteLimit(options.maxBytes) + const resolvedFilePath = await realpath(filePath) + if (!isPathInsideRoot(options.expectedRootRealPath, resolvedFilePath)) { + throw new Error('verified_file_outside_root') + } + const lexicalRoot = await findLexicalRoot(filePath, options.expectedRootRealPath) + const beforeOpen = await assertRegularDescendantPath(lexicalRoot, filePath) + const handle = await openNoFollow(filePath) + try { + const opened = await handle.stat() + if (!opened.isFile() || !sameFileIdentity(beforeOpen, opened)) { + throw new Error('verified_file_changed') + } + return (await readBoundedFileHandle(handle, maxBytes)).toString('utf8') + } finally { + await handle.close() + } +} + +async function findLexicalRoot(filePath: string, expectedRootRealPath: string): Promise { + let candidate = dirname(filePath) + for (let depth = 0; depth < 256; depth += 1) { + if (samePath(await realpath(candidate), expectedRootRealPath)) { + return candidate + } + const parent = dirname(candidate) + if (parent === candidate) { + break + } + candidate = parent + } + throw new Error('verified_file_outside_root') +} + +async function assertRegularDescendantPath(rootPath: string, filePath: string): Promise { + const segments = relative(rootPath, filePath).split(sep).filter(Boolean) + if (segments.length === 0 || segments.some((segment) => segment === '..')) { + throw new Error('verified_file_outside_root') + } + let current = rootPath + for (let index = 0; index < segments.length; index += 1) { + current = join(current, segments[index]) + const stats = await lstat(current) + if (stats.isSymbolicLink()) { + throw new Error('verified_file_changed') + } + if (index < segments.length - 1 && !stats.isDirectory()) { + throw new Error('verified_file_changed') + } + if (index === segments.length - 1) { + if (!stats.isFile()) { + throw new Error('verified_file_not_regular') + } + return stats + } + } + throw new Error('verified_file_not_regular') +} + +export async function readBoundedFileHandle(handle: FileHandle, maxBytes: number): Promise { + const safeLimit = validatedByteLimit(maxBytes) + const buffer = Buffer.alloc(safeLimit + 1) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + if (bytesRead > safeLimit) { + throw new Error('file_too_large') + } + return buffer.subarray(0, bytesRead) +} + +export async function openNoFollow(filePath: string): Promise { + try { + return await open(filePath, constants.O_RDONLY | OPEN_NOFOLLOW) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new Error('verified_file_changed') + } + throw error + } +} + +function validatedByteLimit(value: number): number { + if (!Number.isSafeInteger(value) || value < 0 || value >= 0x7fffffff) { + throw new Error('invalid_bounded_file_limit') + } + return value +} + +function isPathInsideRoot(rootRealPath: string, fileRealPath: string): boolean { + const difference = relative(rootRealPath, fileRealPath) + return ( + difference !== '' && + !isAbsolute(difference) && + difference !== '..' && + !difference.startsWith(`..${sep}`) + ) +} + +function samePath(left: string, right: string): boolean { + return relative(left, right) === '' +} + +function sameFileIdentity(beforeOpen: Stats, opened: Stats): boolean { + if ( + Number.isFinite(beforeOpen.dev) && + Number.isFinite(beforeOpen.ino) && + Number.isFinite(opened.dev) && + Number.isFinite(opened.ino) + ) { + return beforeOpen.dev === opened.dev && beforeOpen.ino === opened.ino + } + return beforeOpen.size === opened.size && beforeOpen.mtimeMs === opened.mtimeMs +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6e012e60b42..032b60fc19e 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -757,6 +757,7 @@ export type RuntimeWorktreePsSummary = { repoId: string hostId?: Worktree['hostId'] terminalPlatform?: NodeJS.Platform + wslDistro?: string repo: string path: string branch: string diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ffaa334fda9..6284d0fa820 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -227,6 +227,7 @@ export const TUI_AGENT_CONFIG: Record = { }, cursor: { detectCmd: 'cursor-agent', + detectCmdAliases: ['cursor'], launchCmd: 'cursor-agent', expectedProcess: 'cursor-agent', promptInjectionMode: 'argv', diff --git a/src/shared/tui-agent-detection-commands.ts b/src/shared/tui-agent-detection-commands.ts index b0ca1b443b8..e166f86f074 100644 --- a/src/shared/tui-agent-detection-commands.ts +++ b/src/shared/tui-agent-detection-commands.ts @@ -5,14 +5,21 @@ import { type TuiAgentConfig, type TuiAgentDetectionRuntime } from './tui-agent-config' +import { detectedAgentInventory, type DetectedAgentInventoryV1 } from './detected-agent-inventory' export type TuiAgentDetectionCommand = { id: TuiAgent cmd: string + capabilityProbe?: { + args: readonly string[] + matchedCommand: string + } requiredCommands?: readonly string[] unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] } +export type DetectedTuiAgentsResult = DetectedAgentInventoryV1 + export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { @@ -31,6 +38,14 @@ function buildTuiAgentDetectionCommand( return { id, cmd, + ...(id === 'cursor' + ? { + capabilityProbe: { + args: cmd === 'cursor' ? ['agent', '--help'] : ['--help'], + matchedCommand: cmd === 'cursor' ? 'cursor agent' : 'cursor-agent' + } + } + : {}), ...(config.detectRequiredCommands?.length ? { requiredCommands: config.detectRequiredCommands } : {}), @@ -69,6 +84,29 @@ export function resolveDetectedTuiAgentIds( return [...new Set(detected)] } +export function resolveDetectedTuiAgents( + commands: readonly TuiAgentDetectionCommand[], + foundCommands: ReadonlySet, + runtime: TuiAgentDetectionRuntime, + capableCommands: ReadonlySet = foundCommands +): DetectedTuiAgentsResult { + const eligible = commands.filter( + (command) => + !isDetectionUnsupportedInRuntime(command, runtime) && + foundCommands.has(command.cmd) && + (!command.capabilityProbe || capableCommands.has(command.cmd)) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + const agents = [...new Set(eligible.map((command) => command.id))] + const matchedCommands: Partial> = {} + for (const command of eligible) { + if (matchedCommands[command.id] === undefined) { + matchedCommands[command.id] = command.capabilityProbe?.matchedCommand ?? command.cmd + } + } + return detectedAgentInventory(agents, matchedCommands) +} + export function isDetectionUnsupportedInRuntime( command: TuiAgentDetectionCommand, runtime: TuiAgentDetectionRuntime diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index d433fc61721..0644a99aa0e 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -28,6 +28,9 @@ export function resolveAgentLaunchCommand(args: { isRemote?: boolean }): ResolvedAgentLaunchCommand { const override = args.cmdOverrides[args.agent] + if (args.agent === 'cursor' && !override?.trim()) { + return { ok: false, error: 'Cursor launch command is unavailable for this context.' } + } const command = override || getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { diff --git a/tests/e2e/ssh-ai-vault-session-history-fixtures.ts b/tests/e2e/ssh-ai-vault-session-history-fixtures.ts new file mode 100644 index 00000000000..9ffcb2c823a --- /dev/null +++ b/tests/e2e/ssh-ai-vault-session-history-fixtures.ts @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto' +import { + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures' + +export function seedRemoteAiVaultHistory( + target: DockerSshRelayTarget, + args: { + defaultSessionId: string + runtimeSessionId: string + claudeSessionId: string + cursorSessionId: string + defaultTitle: string + runtimeTitle: string + claudeTitle: string + cursorTitle: string + } +): void { + const cursorBucket = createHash('md5').update(DOCKER_SSH_RELAY_REMOTE_REPO_PATH).digest('hex') + const cursorSessionDir = `/root/.cursor/chats/${cursorBucket}/${args.cursorSessionId}` + const cursorTranscriptDir = + `/root/.cursor/projects/${cursorLegacySlug(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)}` + + `/agent-transcripts/${args.cursorSessionId}` + dockerExec( + target, + [ + 'mkdir -p /root/.codex/sessions/2026/07/04', + 'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04', + 'mkdir -p /root/.claude/projects/orca', + `mkdir -p '${cursorSessionDir}'`, + `mkdir -p '${cursorTranscriptDir}'` + ].join(' && ') + ) + dockerWriteFile( + target, + '/root/.codex/session_index.jsonl', + jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]), + '600' + ) + dockerWriteFile( + target, + `/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`, + codexTranscript({ + sessionId: args.defaultSessionId, + title: args.defaultTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T01:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`, + codexTranscript({ + sessionId: args.runtimeSessionId, + title: args.runtimeTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T02:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`, + claudeTranscript({ + sessionId: args.claudeSessionId, + title: args.claudeTitle, + timestamp: '2026-07-04T03:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `${cursorSessionDir}/meta.json`, + JSON.stringify({ + createdAtMs: 1_751_588_400_000, + updatedAtMs: 1_751_588_401_000, + hasConversation: true, + title: args.cursorTitle + }), + '600' + ) + dockerWriteFile(target, `${cursorSessionDir}/store.db`, '', '600') + dockerWriteFile( + target, + `${cursorTranscriptDir}/${args.cursorSessionId}.jsonl`, + jsonLines([ + { role: 'user', message: { content: [{ type: 'text', text: args.cursorTitle }] } }, + { role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } } + ]), + '600' + ) + dockerWriteFile(target, '/usr/local/bin/cursor-agent', '#!/bin/sh\nexit 0\n', '755') +} + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { id: args.sessionId, cwd: args.cwd } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} + +function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string { + return jsonLines([ + { + sessionId: args.sessionId, + timestamp: args.timestamp, + type: 'user', + message: { content: [{ type: 'text', text: args.title }] } + }, + { + sessionId: args.sessionId, + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' } + } + ]) +} + +function cursorLegacySlug(value: string): string { + return value + .replace(/[^a-zA-Z0-9]/gu, '-') + .replace(/-+/gu, '-') + .replace(/^-+|-+$/gu, '') +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} diff --git a/tests/e2e/ssh-ai-vault-session-history.spec.ts b/tests/e2e/ssh-ai-vault-session-history.spec.ts index 0cb6a485ca4..01efa794b90 100644 --- a/tests/e2e/ssh-ai-vault-session-history.spec.ts +++ b/tests/e2e/ssh-ai-vault-session-history.spec.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { @@ -7,8 +8,8 @@ import { type DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' import { connectDockerRemote } from './ssh-codex-reconnect-replay-driver' -import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { seedRemoteAiVaultHistory } from './ssh-ai-vault-session-history-fixtures' const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' @@ -16,7 +17,7 @@ test.describe('SSH Agent Session History', () => { test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') test.skip(process.platform === 'win32', 'Docker SSH tests use POSIX ssh tooling.') - test('shows remote session history only for the SSH host and resumes Codex on that worktree', async ({ + test('isolates SSH history and resumes host-owned Codex and Cursor sessions', async ({ orcaPage }, testInfo: TestInfo) => { test.slow() @@ -25,9 +26,11 @@ test.describe('SSH Agent Session History', () => { const defaultSessionId = `remote-ai-vault-${stamp}` const runtimeSessionId = `remote-ai-vault-runtime-${stamp}` const claudeSessionId = `remote-ai-vault-claude-${stamp}` + const cursorSessionId = randomUUID() const defaultTitle = `Remote AI Vault ${stamp}` const runtimeTitle = `Remote Runtime AI Vault ${stamp}` const claudeTitle = `Remote Claude AI Vault ${stamp}` + const cursorTitle = `Remote Cursor AI Vault ${stamp}` try { target = startDockerSshRelayTarget(testInfo) @@ -35,9 +38,11 @@ test.describe('SSH Agent Session History', () => { defaultSessionId, runtimeSessionId, claudeSessionId, + cursorSessionId, defaultTitle, runtimeTitle, - claudeTitle + claudeTitle, + cursorTitle }) await waitForSessionReady(orcaPage) @@ -46,14 +51,22 @@ test.describe('SSH Agent Session History', () => { const sshScope = `ssh:${encodeURIComponent(remote.targetId)}` const scan = await orcaPage.evaluate( - async ({ sshScope, defaultTitle, runtimeTitle, claudeTitle }) => { + async ({ + sshScope, + defaultTitle, + runtimeTitle, + claudeTitle, + cursorTitle, + workspacePath + }) => { const local = await window.api.aiVault.listSessions({ executionHostScope: 'local', force: true }) const ssh = await window.api.aiVault.listSessions({ executionHostScope: sshScope, - force: true + force: true, + scopePaths: [workspacePath] }) const all = await window.api.aiVault.listSessions({ executionHostScope: 'all', @@ -61,6 +74,7 @@ test.describe('SSH Agent Session History', () => { }) return { localHasRemote: local.sessions.some((session) => session.title === defaultTitle), + localHasCursor: local.sessions.some((session) => session.title === cursorTitle), sshTitles: ssh.sessions.map((session) => session.title), allHasRuntime: all.sessions.some((session) => session.title === runtimeTitle), allHasClaude: all.sessions.some((session) => session.title === claudeTitle), @@ -71,12 +85,30 @@ test.describe('SSH Agent Session History', () => { .map((session) => session.executionHostId), remoteCommands: ssh.sessions .filter((session) => session.title === defaultTitle || session.title === runtimeTitle) - .map((session) => session.resumeCommand) + .map((session) => session.resumeCommand), + cursorSessions: ssh.sessions + .filter((session) => session.title === cursorTitle) + .map((session) => ({ + cwd: session.cwd, + executionHostId: session.executionHostId, + transcriptFilePath: session.transcriptFilePath + })), + allCursorHostIds: all.sessions + .filter((session) => session.title === cursorTitle) + .map((session) => session.executionHostId) } }, - { sshScope, defaultTitle, runtimeTitle, claudeTitle } + { + sshScope, + defaultTitle, + runtimeTitle, + claudeTitle, + cursorTitle, + workspacePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH + } ) expect(scan.localHasRemote).toBe(false) + expect(scan.localHasCursor).toBe(false) expect(scan.sshTitles).toEqual( expect.arrayContaining([defaultTitle, runtimeTitle, claudeTitle]) ) @@ -87,9 +119,32 @@ test.describe('SSH Agent Session History', () => { expect(scan.remoteCommands.join('\n')).toContain( "CODEX_HOME='/root/.local/share/orca/codex-runtime-home/home'" ) + expect(scan.cursorSessions).toEqual([ + { + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + executionHostId: sshScope, + transcriptFilePath: expect.stringContaining(`${cursorSessionId}.jsonl`) + } + ]) + expect(scan.allCursorHostIds).toEqual([sshScope]) + + const inventory = await orcaPage.evaluate( + (connectionId) => window.api.preflight.detectRemoteAgentInventory({ connectionId }), + remote.targetId + ) + expect(inventory).toMatchObject({ + version: 1, + agents: expect.arrayContaining(['cursor']), + matchedCommands: { cursor: 'cursor-agent' } + }) + await orcaPage.evaluate( + (connectionId) => window.__store?.getState().ensureRemoteDetectedAgents(connectionId), + remote.targetId + ) const defaultSessionTitle = orcaPage.getByText(defaultTitle, { exact: true }) const runtimeSessionTitle = orcaPage.getByText(runtimeTitle, { exact: true }) + const cursorSessionTitle = orcaPage.getByText(cursorTitle, { exact: true }) await openAiVaultSidebar(orcaPage) await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) @@ -119,116 +174,20 @@ test.describe('SSH Agent Session History', () => { .toContain(`CODEX_HOME='/root/.codex' codex resume '${defaultSessionId}'`) const queuedWorktreeId = await readLastQueuedStartupWorktreeId(orcaPage) expect(queuedWorktreeId).toBe(remote.worktreeId) + + await cursorSessionTitle.first().click() + await orcaPage.getByText('Resume in Worktree', { exact: true }).click() + await expect + .poll(() => readLastQueuedStartupCommand(orcaPage), { timeout: 30_000 }) + .toContain( + `cd '${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}' && cursor-agent --resume '${cursorSessionId}'` + ) } finally { cleanupDockerSshRelayTarget(target) } }) }) -function seedRemoteAiVaultHistory( - target: DockerSshRelayTarget, - args: { - defaultSessionId: string - runtimeSessionId: string - claudeSessionId: string - defaultTitle: string - runtimeTitle: string - claudeTitle: string - } -): void { - dockerExec( - target, - [ - 'mkdir -p /root/.codex/sessions/2026/07/04', - 'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04', - 'mkdir -p /root/.claude/projects/orca' - ].join(' && ') - ) - dockerWriteFile( - target, - '/root/.codex/session_index.jsonl', - jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]), - '600' - ) - dockerWriteFile( - target, - `/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`, - codexTranscript({ - sessionId: args.defaultSessionId, - title: args.defaultTitle, - cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - timestamp: '2026-07-04T01:00:00.000Z' - }), - '600' - ) - dockerWriteFile( - target, - `/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`, - codexTranscript({ - sessionId: args.runtimeSessionId, - title: args.runtimeTitle, - cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - timestamp: '2026-07-04T02:00:00.000Z' - }), - '600' - ) - dockerWriteFile( - target, - `/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`, - claudeTranscript({ - sessionId: args.claudeSessionId, - title: args.claudeTitle, - timestamp: '2026-07-04T03:00:00.000Z' - }), - '600' - ) -} - -function codexTranscript(args: { - sessionId: string - title: string - cwd: string - timestamp: string -}): string { - return jsonLines([ - { - timestamp: args.timestamp, - type: 'session_meta', - payload: { id: args.sessionId, cwd: args.cwd } - }, - { - timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.title }] - } - } - ]) -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} - -function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string { - return jsonLines([ - { - sessionId: args.sessionId, - timestamp: args.timestamp, - type: 'user', - message: { content: [{ type: 'text', text: args.title }] } - }, - { - sessionId: args.sessionId, - timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), - type: 'assistant', - message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' } - } - ]) -} - async function openAiVaultSidebar(page: Page): Promise { await page.evaluate(() => { const store = window.__store