mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user