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