extend(ai-vault): support Cursor sessions with host-aware context isolat

Extends AI Vault session history to surface Cursor agent sessions with proper isolation across native, WSL, and SSH execution contexts. Implements cursor sidecar discovery and parsing (local and remote), host-matched command resolution, and metadata-only conversation tracking for sessions without readable transcripts. Adds mobile support for Cursor history resume and strengthens execution-context validation to prevent resuming sessions in incompatible target environments.
This commit is contained in:
Jinjing
2026-08-05 12:44:29 -07:00
parent b1b291db08
commit d6a27423fc
146 changed files with 7695 additions and 895 deletions
@@ -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": "재개 명령이 복사됨"
},
@@ -129,7 +129,9 @@ function AgentHistoryCardRow({
<View style={styles.cardMetaRow}>
<Text style={styles.cardMetaText}>{card.agentLabel}</Text>
<Text style={styles.cardMetaText}>
{card.messageCount} {card.messageCount === 1 ? 'message' : 'messages'}
{card.messageCount === 0 && card.hasConversation
? 'Conversation available'
: `${card.messageCount} ${card.messageCount === 1 ? 'message' : 'messages'}`}
</Text>
{showCurrentWorktreeBadge && card.isCurrentWorktree ? (
<View style={styles.currentBadge}>
@@ -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<Worktree[]>([])
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
const { worktrees, worktreesLoaded } = useMobileAgentHistoryWorktrees(
client,
connState === 'connected'
)
const [query, setQuery] = useState('')
const [resumingSessionId, setResumingSessionId] = useState<string | null>(null)
const [resumeMessage, setResumeMessage] = useState<string | null>(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,
@@ -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' }),
@@ -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
}
}
@@ -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', () => {
@@ -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<AiVaultSession, 'id'>[],
sessions: readonly Pick<AiVaultSession, 'id' | 'agent' | 'cwd'>[],
resumingSessionId: string | null
): ReadonlyMap<string, MobileAgentHistoryResumeActionState> {
return new Map(
sessions.map((session) => [
session.id,
{
disabled: resumingSessionId !== null,
disabled: resumingSessionId !== null || (session.agent === 'cursor' && !session.cwd),
loading: resumingSessionId === session.id
}
])
@@ -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<RpcClient, 'sendRequest'> | null,
connected: boolean
): { worktrees: Worktree[]; worktreesLoaded: boolean } {
const [worktrees, setWorktrees] = useState<Worktree[]>([])
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 }
}
@@ -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)) {
@@ -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'
})
})
})
@@ -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<RpcClient, 'sendRequest'>
settings?: MobileAiVaultResumeSettings | null
wslDistro?: string | null
}): Promise<string | null> {
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<RpcClient, 'sendRequest'>
settings?: MobileAiVaultResumeSettings | null
wslDistro?: string | null
session: Pick<AiVaultSession, 'agent' | 'cwd'>
}): Promise<string | null> {
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
}
@@ -8,6 +8,7 @@ export type Worktree = {
repoId: string
hostId?: ExecutionHostId
terminalPlatform?: NodeJS.Platform
wslDistro?: string
repo: string
branch: string
displayName: string
@@ -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', () => {
@@ -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) &&
@@ -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<AiVaultListResult>>()
}))
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)
})
})
@@ -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<AiVaultListResult>
lastUsedAt: number
}
let cacheByProvider = new WeakMap<IFilesystemProvider, Map<string, CacheEntry>>()
export async function listCachedRemoteAiVaultSessions(args: {
provider: IFilesystemProvider
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
limit?: number
scopePaths?: readonly string[]
force?: boolean
signal?: AbortSignal
}): Promise<AiVaultListResult> {
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<string, CacheEntry> {
const existing = cacheByProvider.get(provider)
if (existing) {
return existing
}
const created = new Map<string, CacheEntry>()
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<string, CacheEntry>, 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.'
}
]
}
}
@@ -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<void>((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)
})
})
@@ -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): <T>(run: () => Promise<T>)
}
}
}
export const AI_VAULT_DIRECT_SSH_SCAN_CONCURRENCY = 4
export async function mapDirectSshScans<T, U>(
items: readonly T[],
mapper: (item: T, signal: AbortSignal) => Promise<U>,
signal: AbortSignal
): Promise<U[]> {
const results: U[] = []
let nextIndex = 0
async function worker(): Promise<void> {
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')
}
}
@@ -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<RemoteCursorSidecarDiscovery> {
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<string, Set<string>>()
for (const cwd of scan.scopeCwds) {
const slug = cursorLegacySlug(cwd)
if (!slug) {
continue
}
const buckets = bySlug.get(slug) ?? new Set<string>()
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<Parameters<typeof discoverRemoteCursorSidecars>[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()
}
@@ -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.'
})
)
})
})
@@ -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
@@ -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<string, { content: string; mtimeMs: number }>()
private readonly readDirErrors = new Map<string, Error>()
private readonly statErrors = new Map<string, Error>()
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<DirEntry[]> {
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<string, DirEntry>()
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<FileReadResult> {
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<FileStat> {
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<CursorSidecarScanResponse> {
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<string> => 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<never> {
throw new Error('unsupported')
}
function normalize(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '')
}
@@ -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,
@@ -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 `<session>/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
}
@@ -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<string, { content: string; mtimeMs: number }>()
private readonly readDirErrors = new Map<string, Error>()
private readonly statErrors = new Map<string, Error>()
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<DirEntry[]> {
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<string, DirEntry>()
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<FileReadResult> {
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<FileStat> {
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<string> => path
search = unsupported
listFiles = unsupported
watch = unsupported
}
async function unsupported(): Promise<never> {
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 () => {
+63 -35
View File
@@ -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<AiVaultListResult> {
}
export async function scanRemoteAiVaultSessions(
args: RemoteAiVaultScanArgs
): Promise<AiVaultListResult> {
return withSpan('aiVault.scan', (span) => scanRemoteAiVaultSessionsInSpan(args, span))
}
async function scanRemoteAiVaultSessionsInSpan(
args: RemoteAiVaultScanArgs,
span: ActiveSpan
): Promise<AiVaultListResult> {
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<string, AiVaultSession>()
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)))
@@ -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,
+8 -1
View File
@@ -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
})
}
}
@@ -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(),
+23 -2
View File
@@ -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))
}
@@ -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 }
@@ -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
@@ -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'),
@@ -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()
)
})
})
@@ -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<FileWithMtime[]> {
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<FileWithMtime[]> {
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<string | null> {
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<FileWithMtime | null> {
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'
}
@@ -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<ReturnType<typeof reconcileCursorCandidates>> {
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<CursorCandidateSelectionGroup<SessionFileCandidate>>()
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<SessionFileCandidate>[],
parsed: ParsedCursorCandidate[],
args: {
platform: NodeJS.Platform
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
parseStats: SessionParseStats
}
): Promise<void> {
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<ParsedCursorCandidate | null> {
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
}
}
@@ -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
}
@@ -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)
}
@@ -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)
})
})
@@ -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
}
@@ -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<typeof reconcileCursorCandidates>[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()
})
})
@@ -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<string>
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<string>()
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<string, ParsedCursorCandidate[]>()
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<string, ParsedCursorCandidate[]>()
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<string, ParsedCursorCandidate[]>
): Map<string, ParsedCursorCandidate[]> {
const assigned = new Map<string, ParsedCursorCandidate[]>()
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
})
}
}
@@ -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<AiVaultSession | null>
}): Promise<ReturnType<typeof reconcileCursorCandidates>> {
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<CursorCandidateSelectionGroup<RemoteSessionCandidate>>()
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<RemoteSessionCandidate>[],
parsed: ParsedCursorCandidate[],
args: {
context: RemoteScannerContext
issues: AiVaultScanIssue[]
parseLegacy: (candidate: RemoteSessionCandidate) => Promise<AiVaultSession | null>
}
): Promise<void> {
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<AiVaultSession | null>
}
): Promise<ParsedCursorCandidate | null> {
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
}
}
@@ -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])
})
})
@@ -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<T> = {
candidates: T[]
key: string
mtimeMs: number
scoped: boolean
scopePriority: number
}
type CursorSelectionAdapter<T> = {
getCwdEvidence(candidate: T): CursorCwdEvidence | undefined
getFile(candidate: T): FileWithMtime
getLayout(candidate: T): CursorLayout
getStorageContextKey(candidate: T): string
}
type CursorSelectionIdentity<T> = {
candidate: T
contextKey: string
cwdEvidence?: CursorCwdEvidence
file: FileWithMtime
layout: CursorLayout
logicalKey: string
sessionId: string
sidecarBucket: string | null
}
export function buildCursorCandidateSelectionGroups<T>(args: {
candidates: readonly T[]
platform: NodeJS.Platform
adapter: CursorSelectionAdapter<T>
}): CursorCandidateSelectionGroup<T>[] {
const identities = args.candidates
.map((candidate) => cursorSelectionIdentity(candidate, args.platform, args.adapter))
.filter((identity): identity is CursorSelectionIdentity<T> => Boolean(identity))
const sidecarBucketsByLogicalKey = new Map<string, Set<string>>()
const scopedSidecarBucketsByLogicalKey = new Map<string, Set<string>>()
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<string, CursorCandidateSelectionGroup<T>>()
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<T>(
groups: readonly CursorCandidateSelectionGroup<T>[],
processedGroups: ReadonlySet<CursorCandidateSelectionGroup<T>>,
limit: number
): CursorCandidateSelectionGroup<T>[] {
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<T>(
candidate: T,
platform: NodeJS.Platform,
adapter: CursorSelectionAdapter<T>
): CursorSelectionIdentity<T> | 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<T>(
identity: CursorSelectionIdentity<T>,
sidecarBuckets: ReadonlySet<string> | undefined,
scopedSidecarBuckets: ReadonlySet<string> | 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<string, Set<string>>, key: string, value: string): void {
const values = map.get(key) ?? new Set<string>()
values.add(value)
map.set(key, values)
}
function cursorScopePriority(evidence: CursorCwdEvidence | undefined): number {
return evidence?.kind === 'scope-bucket' ? 2 : evidence ? 1 : 0
}
@@ -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> = {}
): 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)
})
})
})
@@ -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<CursorSidecarParseResult> {
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<CursorSidecarParseResult> {
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<string, unknown>
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
}
}
@@ -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<SessionFileDiscovery>[] {
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<SessionFileDiscovery> {
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<string, CursorCwdEvidence>()
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<SessionFileDiscovery> {
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<string, CursorCwdEvidence>()
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<string, CursorCwdEvidence>
): Promise<FileWithMtime[]> {
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<string[]> {
const candidates = new Set<string>()
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<string, FileWithMtime>()
for (const file of files) {
byPath.set(file.path, file)
}
return [...byPath.values()].sort(
(left, right) => cursorSessionActivityMtimeMs(right) - cursorSessionActivityMtimeMs(left)
)
}
+16 -2
View File
@@ -12,11 +12,13 @@ export async function discoverFiles(args: {
extensions: string[]
filePredicate?: (path: string) => boolean
directoryPredicate?: (name: string, depth: number) => boolean
recordDirectoryErrors?: boolean
}): Promise<SessionFileDiscovery> {
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<string[]> {
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'
}
@@ -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<string, SessionParseCacheEntry>()
export function resetSessionParseCacheForTests(): void {
cache.clear()
resetCursorSidecarParseCacheForTests()
}
// Persisted subset of a cache entry: the non-serializable `resume` parser
@@ -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<SessionFileDiscovery>[] {
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[],
@@ -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.
@@ -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<string, CursorCwdEvidence>
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 = {
+37 -26
View File
@@ -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<string, AiVaultSession>()
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
})
}
}
@@ -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<AiVaultListResult>((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)
})
})
+38
View File
@@ -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<AbortController>()
export async function scanAllAiVaultHosts<SshHost, RuntimeHost>(args: {
sshHosts: readonly SshHost[]
runtimeHosts: readonly RuntimeHost[]
runtimeIssues: readonly AiVaultListResult[]
limit?: number
scanLocal: () => Promise<AiVaultListResult>
scanSsh: (host: SshHost, signal: AbortSignal) => Promise<AiVaultListResult>
scanRuntime: (host: RuntimeHost) => Promise<AiVaultListResult>
}): Promise<AiVaultListResult> {
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()
}
+23
View File
@@ -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
}
+44
View File
@@ -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<AiVaultSubagentListResult> {
// 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 `<root>/../../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 })
}
+47 -1
View File
@@ -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) })
)
+62 -79
View File
@@ -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<ExecutionHostScope, 'all'>
): 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<AiVaultSubagentListResult> =>
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<AiVaultSubagentListResult> {
// 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 `<root>/../../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()
+61 -3
View File
@@ -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',
+43 -73
View File
@@ -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>): 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<string[]> {
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<string[]> {
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<RefreshAgentsResult> {
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<string[]> {
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<boolean> {
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',
@@ -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<string[]> {
return (await detectInstalledAgentCommands(context)).agents
}
export async function detectInstalledAgentCommands(
context?: PreflightRuntimeContext
): Promise<DetectedTuiAgentsResult> {
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<string>,
wslTarget?: ReturnType<typeof getPreflightWslTarget>
): Promise<void> {
const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter(
(command) => command.capabilityProbe && foundCommands.has(command.cmd)
)
let resolvedLocalCommands = new Map<string, string>()
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<string[]> {
await hydrateShellPathForAgentDetection(context)
return detectInstalledAgents(context)
}
export async function detectInstalledAgentCommandsWithShellPathHydration(
context?: PreflightRuntimeContext
): Promise<DetectedTuiAgentsResult> {
await hydrateShellPathForAgentDetection(context)
return detectInstalledAgentCommands(context)
}
export const detectInstalledAgentInventoryWithShellPathHydration =
detectInstalledAgentCommandsWithShellPathHydration
export async function detectRemoteAgentCommands(args: {
connectionId: string
}): Promise<DetectedTuiAgentsResult> {
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<string[]> {
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
@@ -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<DirEntry[]>
readFile(filePath: string): Promise<FileReadResult>
scanCursorSidecars?(
request: CursorSidecarScanRequest,
options?: { signal?: AbortSignal }
): Promise<CursorSidecarScanResponse>
readTerminalArtifact?(
filePath: string,
options: TerminalArtifactAccessOptions
@@ -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')
})
})
@@ -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<CursorSidecarScanResponse> {
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
}
}
@@ -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
+7 -2
View File
@@ -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()
})
})
+96 -14
View File
@@ -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<string, NodeJS.Platform>
wslDistroByRepoId: ReadonlyMap<string, string>
}
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<string, RuntimeWorktreePsSummary>()
// 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<ResolvedWorktreeSnapshot> {
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<ResolvedWorktreeSnapshot> {
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 {
@@ -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
+27
View File
@@ -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,
@@ -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()
})
})
@@ -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<Record<TuiAgent, string>>
connectionId?: string | null
wslDistro?: string | null
workspacePath: string
}): Promise<Partial<Record<TuiAgent, string>>> {
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
}
}
+4
View File
@@ -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',
+13
View File
@@ -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<string, string>
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<string, string>
}
export type PreflightRuntimeContext = {
wslDistro?: string | null
wslDefault?: boolean
@@ -732,8 +739,14 @@ export type PreflightRuntimeContext = {
export type PreflightApi = {
check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise<PreflightStatus>
detectAgents: (args?: PreflightRuntimeContext) => Promise<string[]>
detectAgentInventory: (args?: PreflightRuntimeContext) => Promise<DetectedAgentInventoryV1>
detectAgentCommands: (args?: PreflightRuntimeContext) => Promise<DetectedAgentCommandsResult>
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
detectRemoteAgentInventory: (args: { connectionId: string }) => Promise<DetectedAgentInventoryV1>
detectRemoteAgentCommands: (args: {
connectionId: string
}) => Promise<DetectedAgentCommandsResult>
detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
wslAvailable: boolean
wslDistros: string[]
+14
View File
@@ -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<string[]> =>
ipcRenderer.invoke('preflight:detectAgents', args),
detectAgentInventory: (args?: PreflightRuntimeContext): Promise<DetectedAgentInventoryV1> =>
ipcRenderer.invoke('preflight:detectAgentInventory', args),
detectAgentCommands: (args?: PreflightRuntimeContext): Promise<DetectedAgentCommandsResult> =>
ipcRenderer.invoke('preflight:detectAgentCommands', args),
refreshAgents: (args?: PreflightRuntimeContext): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args),
detectRemoteAgentInventory: (args: {
connectionId: string
}): Promise<DetectedAgentInventoryV1> =>
ipcRenderer.invoke('preflight:detectRemoteAgentInventory', args),
detectRemoteAgentCommands: (args: {
connectionId: string
}): Promise<DetectedAgentCommandsResult> =>
ipcRenderer.invoke('preflight:detectRemoteAgentCommands', args),
detectRemoteWindowsTerminalCapabilities: (args: {
connectionId: string
}): Promise<{
+277
View File
@@ -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<Map<string, Bucket>> {
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<string>()
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<string, Bucket>()
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<string, Bucket>,
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<CursorSidecarScanCandidate[]> {
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<CursorSidecarScanCandidate | null> => {
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')
}
}
+301
View File
@@ -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<string> {
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<void> {
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<void> {
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)
})
})
+157
View File
@@ -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<CursorSidecarScanResponse> {
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<void> {
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')
}
}
+11 -16
View File
@@ -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<string, unknown>) {
const filePath = stringParam(params.filePath)
const options = verifiedTerminalArtifactOptions(params)
@@ -41,7 +40,7 @@ export async function readVerifiedTerminalArtifact(params: Record<string, unknow
options.maxBytes ?? (mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE),
mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE
)
const buffer = await readBoundedFileFromHandle(handle, sizeLimit)
const buffer = await readBoundedFileHandle(handle, sizeLimit)
if (mimeType) {
return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType }
}
@@ -69,7 +68,7 @@ export async function writeVerifiedTerminalArtifact(
let originalMode: number | null = null
try {
originalMode = (await verifiedHandleStat(handle, options)).mode ?? null
const existing = await readBoundedFileFromHandle(handle, writeLimit)
const existing = await readBoundedFileHandle(handle, writeLimit)
if (isBinaryBuffer(existing)) {
throw new Error('binary_file')
}
@@ -111,16 +110,6 @@ function terminalArtifactImageMimeType(filePath: string): string | undefined {
return mimeType === 'image/svg+xml' ? undefined : mimeType
}
async function readBoundedFileFromHandle(handle: FileHandle, maxBytes: number): Promise<Buffer> {
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<FileHandle> {
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
+4
View File
@@ -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))
+48
View File
@@ -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<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
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])
+102 -52
View File
@@ -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<string, unknown>): Promise<{ agents: string[] }> {
const commands = params.commands as AgentDetectionCommand[]
if (!Array.isArray(commands)) {
return { agents: [] }
private async detectAgentInventory(
params: Record<string, unknown>
): Promise<DetectedAgentInventoryV1> {
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<string, string> = {}
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<boolean> {
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<boolean> {
return Boolean(await resolveCommandOnPathForRelay(command, options))
}
export async function resolveCommandOnPathForRelay(
command: string,
options: RelayCommandLookupOptions = {}
): Promise<string | null> {
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 {
@@ -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(
@@ -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')
})
})
@@ -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 })
}
}
@@ -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)
@@ -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
@@ -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)
})
})
@@ -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)
@@ -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 }
@@ -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<AiVaultSession, 'filePath' | 'executionHostId'>
type AiVaultLogSession = Pick<AiVaultSession, 'filePath' | 'transcriptFilePath' | 'executionHostId'>
// 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<void> {
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)) {
@@ -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<AiVaultSession, 'filePath' | 'executionHostId'>
session: Pick<AiVaultSession, 'filePath' | 'transcriptFilePath' | 'executionHostId'>
): boolean {
const filePath = session.filePath?.trim()
const filePath = getAiVaultTranscriptPath(session)
if (!filePath) {
return false
}
@@ -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 () => {
@@ -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<ExecutionHostId>())
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<ReturnType<typeof setTimeout> | 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<Set<string> | 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 }
}
@@ -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 })
})
})
@@ -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<Pick<AppState, 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings'>>
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<AiVaultSession, 'messageCount' | 'previewMessages'>,
state: Pick<AiVaultSessionResumeState, 'blocked'> | null
session: Pick<AiVaultSession, 'messageCount' | 'previewMessages' | 'hasConversation'> &
Partial<Pick<AiVaultSession, 'agent' | 'cwd' | 'resumeCommand'>>,
state:
| (Pick<AiVaultSessionResumeState, 'blocked'> &
Partial<Pick<AiVaultSessionResumeState, 'cursorCommandAvailable'>>)
| 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
}
}
@@ -52,11 +52,16 @@ export function SessionMetadata({
{liveState && liveState !== 'done' ? <AgentStateDot state={liveState} /> : null}
<span className="min-w-0 shrink-[2] truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 tabular-nums">
{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 }
)}
</span>
{session.subagentTranscriptCount > 0 ? (
<>
@@ -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
})
@@ -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<string, { worktreeId?: string | null }>
ensureDetectedAgents: (worktreeId?: string) => Promise<unknown>
ensureRemoteDetectedAgents: (targetId: string) => Promise<unknown>
ensureRuntimeDetectedAgents: (environmentId: string) => Promise<unknown>
}): void {
useEffect(() => {
const cursorSessions = sessions.filter((session) => session.agent === 'cursor')
const cursorHostIds = new Set(cursorSessions.map((session) => session.executionHostId))
const localWorktreeIds = new Set<string>()
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
])
}

Some files were not shown because too many files have changed in this diff Show More