From a07efd89fb7e353e89a63c1da39f163a584a0f5c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:28:46 -0700 Subject: [PATCH] feat(ai-vault): add cursor command cache with host isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor command detection is expensive—spawning a shell process or making an SSH RPC. Cache detected commands with a 30-second TTL and keep entries separate per host (SSH connection, WSL distro, local) to avoid repeated probes and ensure each host resolves correctly. Thread wslDistro through mobile resume targets so session resumption identifies the right host. --- .../MobileAgentSessionHistoryPanel.tsx | 6 +- .../agent-history-resume-target.test.ts | 9 ++- .../agent-history-resume-target.ts | 15 ++-- .../use-mobile-agent-history-worktrees.ts | 7 +- .../src/session/mobile-cursor-command.test.ts | 13 +++- mobile/src/session/mobile-cursor-command.ts | 4 +- .../ai-vault/cached-remote-session-list.ts | 10 ++- ...session-scanner-cursor-integration.test.ts | 3 +- .../session-scanner-cursor-local-pipeline.ts | 20 +++-- src/main/ipc/tui-agent-inventory-detection.ts | 10 ++- src/main/runtime/orca-runtime.ts | 28 ++++--- .../runtime/runtime-cursor-command.test.ts | 28 ++++++- src/main/runtime/runtime-cursor-command.ts | 76 ++++++++++++++++++- src/relay/cursor-sidecar-scan-discovery.ts | 1 + src/relay/cursor-sidecar-scan.test.ts | 45 ++++++----- src/relay/fs-handler-terminal-artifact.ts | 16 ++-- src/relay/preflight-handler.ts | 1 - .../ai-vault-session-continuation.ts | 17 ++++- .../right-sidebar/ai-vault-session-resume.ts | 9 +-- .../src/lib/ai-vault-cursor-command.test.ts | 16 ++++ .../src/lib/ai-vault-cursor-command.ts | 6 +- .../src/lib/launch-work-item-direct.ts | 5 +- src/shared/cursor-sidecar-scan.ts | 10 +-- 23 files changed, 256 insertions(+), 99 deletions(-) diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 5d61edc763a..b2e49e1184a 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -36,7 +36,6 @@ import { 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 = { @@ -172,13 +171,10 @@ 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, + wslDistro: target.wslDistro, session }) const launch = buildMobileAiVaultResumeLaunch({ diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts index d61dadd6e1a..fa2afd315c7 100644 --- a/mobile/src/agent-history/agent-history-resume-target.test.ts +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -175,7 +175,8 @@ describe('mobile AI Vault resume target guards', () => { worktreeId: 'session-wt', targetStatus: 'local', workspacePath: '/Users/ada/repo/feature', - terminalPlatform: null + terminalPlatform: null, + wslDistro: null }) }) @@ -198,7 +199,8 @@ describe('mobile AI Vault resume target guards', () => { worktreeId: 'route-wt', targetStatus: 'local', workspacePath: '/Users/ada/repo/main', - terminalPlatform: null + terminalPlatform: null, + wslDistro: null }) }) @@ -274,7 +276,8 @@ describe('mobile AI Vault resume target guards', () => { worktreeId: 'route-wt', targetStatus: 'local', workspacePath: '/Users/ada/repo/main', - terminalPlatform: null + terminalPlatform: null, + wslDistro: null }) }) diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts index 0dee7de79ea..7d2eb40cb3e 100644 --- a/mobile/src/agent-history/agent-history-resume-target.ts +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -53,6 +53,7 @@ export type MobileAiVaultSessionResumeTarget = targetStatus: 'local' workspacePath: string | null terminalPlatform: NodeJS.Platform | null + wslDistro: string | null } | { status: 'blocked'; message: string } @@ -155,6 +156,8 @@ export function resolveMobileAiVaultSessionResumeTarget(args: { (worktree) => worktree.worktreeId === candidateWorktreeId ) const targetRepo = args.repos.find((repo) => repo.id === targetWorktree?.repoId) + const targetWslDistro = + targetWorktree?.wslDistro ?? parseWslUncPath(targetWorktree?.path ?? '')?.distro ?? null if ( !canResumeAiVaultSessionInExecutionContext({ agent: args.session.agent, @@ -164,8 +167,7 @@ export function resolveMobileAiVaultSessionResumeTarget(args: { targetExecutionHostId: normalizeExecutionHostId(targetWorktree?.hostId) ?? (targetRepo ? getRepoExecutionHostId(targetRepo) : LOCAL_EXECUTION_HOST_ID), - targetWslDistro: - targetWorktree?.wslDistro ?? parseWslUncPath(targetWorktree?.path ?? '')?.distro ?? null + targetWslDistro }) ) { continue @@ -175,7 +177,8 @@ export function resolveMobileAiVaultSessionResumeTarget(args: { worktreeId: candidateWorktreeId, targetStatus, workspacePath: targetWorktree?.path ?? null, - terminalPlatform: targetWorktree?.terminalPlatform ?? null + terminalPlatform: targetWorktree?.terminalPlatform ?? null, + wslDistro: targetWslDistro } } @@ -240,11 +243,7 @@ function getMobileAiVaultResumeFolderTargetStatus(args: { function getMobileAiVaultResumeExecutionHostTargetStatus( hostId: ExecutionHostId | null | undefined ): MobileAiVaultResumeTargetStatus { - const parsed = parseExecutionHostId(hostId) - if (!parsed) { - return 'unknown' - } - return parsed.kind + return parseExecutionHostId(hostId)?.kind ?? 'unknown' } function parseFolderWorkspaceRepoId(repoId: string): string | null { diff --git a/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts b/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts index 682ed90012e..5ba9d5cdd8c 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' +import { RESUME_RPC_TIMEOUT_MS } from '../session/ai-vault-resume-preparation' import type { RpcSuccess } from '../transport/types' import type { Worktree } from '../worktree/workspace-list-types' @@ -19,7 +20,11 @@ export function useMobileAgentHistoryWorktrees( let cancelled = false void (async () => { try { - const response = await client.sendRequest('worktree.ps', { limit: 10000 }) + const response = await client.sendRequest( + 'worktree.ps', + { limit: 10000 }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) if (!cancelled && response.ok) { setSnapshot({ client, diff --git a/mobile/src/session/mobile-cursor-command.test.ts b/mobile/src/session/mobile-cursor-command.test.ts index 25d3204b67e..7b68d5f56ac 100644 --- a/mobile/src/session/mobile-cursor-command.test.ts +++ b/mobile/src/session/mobile-cursor-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation' import { resolveMobileCursorCommand } from './mobile-cursor-command' describe('resolveMobileCursorCommand', () => { @@ -15,7 +16,9 @@ describe('resolveMobileCursorCommand', () => { await expect(resolveMobileCursorCommand({ client: { sendRequest } })).resolves.toBe( 'cursor agent' ) - expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', undefined) + expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', undefined, { + timeoutMs: RESUME_RPC_TIMEOUT_MS + }) }) it('fails closed without a valid inventory unless an override exists', async () => { @@ -47,8 +50,10 @@ describe('resolveMobileCursorCommand', () => { wslDistro: ' Ubuntu ' }) ).resolves.toBe('cursor agent') - expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgentInventory', { - wslDistro: 'Ubuntu' - }) + expect(sendRequest).toHaveBeenCalledWith( + 'preflight.detectAgentInventory', + { wslDistro: 'Ubuntu' }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) }) }) diff --git a/mobile/src/session/mobile-cursor-command.ts b/mobile/src/session/mobile-cursor-command.ts index 2c30e0e6b38..04064ffc6c8 100644 --- a/mobile/src/session/mobile-cursor-command.ts +++ b/mobile/src/session/mobile-cursor-command.ts @@ -1,6 +1,7 @@ import { resolveEffectiveCursorCommand } from '../../../src/shared/cursor-command' import { detectedAgentInventorySchema } from '../../../src/shared/detected-agent-inventory' import type { RpcClient } from '../transport/rpc-client' +import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation' import type { MobileAiVaultResumeSettings } from './ai-vault-resume-launch' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' @@ -16,7 +17,8 @@ export async function resolveMobileCursorCommand(args: { const wslDistro = args.wslDistro?.trim() const response = await args.client.sendRequest( 'preflight.detectAgentInventory', - wslDistro ? { wslDistro } : undefined + wslDistro ? { wslDistro } : undefined, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } ) if (!response.ok) { return null diff --git a/src/main/ai-vault/cached-remote-session-list.ts b/src/main/ai-vault/cached-remote-session-list.ts index adf8122789f..72cfac01c06 100644 --- a/src/main/ai-vault/cached-remote-session-list.ts +++ b/src/main/ai-vault/cached-remote-session-list.ts @@ -46,7 +46,15 @@ export async function listCachedRemoteAiVaultSessions(args: { const now = Date.now() if (existing?.inflight) { existing.lastUsedAt = now - return withScopePathTruncationIssue(await existing.inflight, args, scopePathsTruncated) + try { + return withScopePathTruncationIssue(await existing.inflight, args, scopePathsTruncated) + } catch (error) { + // The shared scan carries the initiating caller's abort semantics; re-issue our own + // scan rather than inheriting an abort this caller never requested. + if (args.signal?.aborted !== false) { + throw error + } + } } if (args.force !== true && existing?.result && existing.expiresAt > now) { existing.lastUsedAt = now diff --git a/src/main/ai-vault/session-scanner-cursor-integration.test.ts b/src/main/ai-vault/session-scanner-cursor-integration.test.ts index 96e4dc73393..096549e17a1 100644 --- a/src/main/ai-vault/session-scanner-cursor-integration.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-integration.test.ts @@ -79,7 +79,8 @@ describe('Cursor session scanner integration', () => { tempRoots.push(root) const roots = isolatedScanRoots(root) const scopePath = join(root, 'scoped-workspace') - const otherPaths = [join(root, 'newest-workspace'), join(root, 'middle-workspace')] + // Timestamps derive from array index, so the newest bucket must sort last. + const otherPaths = [join(root, 'middle-workspace'), join(root, 'newest-workspace')] const sessionId = 'shared-session-id' await mkdir(scopePath, { recursive: true }) diff --git a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts index 1ff1a702c5e..679dd3c4ca8 100644 --- a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts +++ b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts @@ -43,14 +43,18 @@ export async function processLocalCursorCandidates(args: { const parsed: ParsedCursorCandidate[] = [] const processedGroups = new Set>() for (let index = 0; index < groups.length; index += CURSOR_PARSE_CONCURRENCY) { - const preview = reconcileCursorCandidates({ - candidates: parsed, - executionHostId: args.executionHostId, - platform: args.platform, - issues: [] - }) - if (canStopCursorGroupSelection(preview.sessions, args.limit, groups[index]?.mtimeMs)) { - break + // Reconcile is O(parsed); sessions never outnumber candidates, so a short parsed + // count can't satisfy the limit and the preview would be wasted work. + if (parsed.length >= args.limit) { + 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) diff --git a/src/main/ipc/tui-agent-inventory-detection.ts b/src/main/ipc/tui-agent-inventory-detection.ts index cc8b3b2a5c2..cd67f250c2a 100644 --- a/src/main/ipc/tui-agent-inventory-detection.ts +++ b/src/main/ipc/tui-agent-inventory-detection.ts @@ -132,9 +132,13 @@ export async function detectRemoteAgentCommands(args: { }) ) } catch { - return legacyDetectedAgentInventory( - (await detectRemoteAgents(args)) as DetectedTuiAgentsResult['agents'] - ) + try { + return legacyDetectedAgentInventory( + (await detectRemoteAgents(args)) as DetectedTuiAgentsResult['agents'] + ) + } catch { + return emptyDetectedAgentInventory() + } } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0cb19ed25b7..20fab487570 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -19104,16 +19104,25 @@ export class OrcaRuntimeService { return getAgentLaunchPlatformForRepo(repo, projectRuntime) } + private resolveProjectRuntimeWslDistro( + resolution: ProjectExecutionRuntimeResolution | undefined + ): string | null { + const runtime = + resolution?.status === 'resolved' ? resolution.runtime : resolution?.repair.preferredRuntime + return runtime?.kind === 'wsl' ? runtime.distro?.trim() || null : null + } + 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) + return ( + this.resolveProjectRuntimeWslDistro( + resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) + ) ?? + parseWslUncPath(repo.path)?.distro ?? + null + ) } private getAgentLaunchWslDistroForWorkspace(scope: TerminalWorkspaceLaunchScope): string | null { @@ -28614,12 +28623,7 @@ export class OrcaRuntimeService { ) 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 + const distro = this.resolveProjectRuntimeWslDistro(projectRuntimeByRepoId.get(repo.id)) return distro ? [[repo.id, distro]] : [] }) ) diff --git a/src/main/runtime/runtime-cursor-command.test.ts b/src/main/runtime/runtime-cursor-command.test.ts index 26a6927194f..468e7de22cb 100644 --- a/src/main/runtime/runtime-cursor-command.test.ts +++ b/src/main/runtime/runtime-cursor-command.test.ts @@ -10,7 +10,10 @@ vi.mock('../ipc/tui-agent-inventory-detection', () => ({ detectRemoteAgentCommands: mocks.detectRemote })) -import { resolveRuntimeAgentCommandOverrides } from './runtime-cursor-command' +import { + resetRuntimeCursorCommandCacheForTests, + resolveRuntimeAgentCommandOverrides +} from './runtime-cursor-command' const inventory = (command?: string) => ({ version: 1 as const, @@ -19,6 +22,7 @@ const inventory = (command?: string) => ({ }) beforeEach(() => { + resetRuntimeCursorCommandCacheForTests() mocks.detectLocal.mockReset().mockResolvedValue(inventory()) mocks.detectRemote.mockReset().mockResolvedValue(inventory()) }) @@ -85,6 +89,28 @@ describe('resolveRuntimeAgentCommandOverrides', () => { ).resolves.toEqual({}) }) + it('probes once per host and keeps separate hosts isolated', async () => { + mocks.detectLocal.mockResolvedValue(inventory('cursor-agent')) + mocks.detectRemote.mockResolvedValue(inventory('cursor agent')) + const local = { agent: 'cursor' as const, cmdOverrides: {}, workspacePath: '/repo' } + await expect( + Promise.all([ + resolveRuntimeAgentCommandOverrides(local), + resolveRuntimeAgentCommandOverrides(local), + resolveRuntimeAgentCommandOverrides({ ...local, connectionId: 'ssh-1' }) + ]) + ).resolves.toEqual([ + { cursor: 'cursor-agent' }, + { cursor: 'cursor-agent' }, + { cursor: 'cursor agent' } + ]) + await expect(resolveRuntimeAgentCommandOverrides(local)).resolves.toEqual({ + cursor: 'cursor-agent' + }) + expect(mocks.detectLocal).toHaveBeenCalledTimes(1) + expect(mocks.detectRemote).toHaveBeenCalledTimes(1) + }) + it('does not probe commands for other agents', async () => { const overrides = { claude: 'claude-dev' } await expect( diff --git a/src/main/runtime/runtime-cursor-command.ts b/src/main/runtime/runtime-cursor-command.ts index 2f350be577b..29126e20bf9 100644 --- a/src/main/runtime/runtime-cursor-command.ts +++ b/src/main/runtime/runtime-cursor-command.ts @@ -6,6 +6,20 @@ import { detectRemoteAgentCommands } from '../ipc/tui-agent-inventory-detection' +// Why: every launch/resume/draft path awaits this, and probing spawns shell +// detection or an SSH inventory RPC. Keep it short so a fresh install is picked +// up quickly while a burst of terminals shares one probe. +const CURSOR_COMMAND_CACHE_TTL_MS = 30_000 +const CURSOR_COMMAND_CACHE_MAX_KEYS = 16 + +type CursorCommandCacheEntry = { + command?: string | null + expiresAt: number + inflight?: Promise +} + +const cursorCommandCache = new Map() + export async function resolveRuntimeAgentCommandOverrides(args: { agent: TuiAgent cmdOverrides: Partial> @@ -18,12 +32,66 @@ export async function resolveRuntimeAgentCommandOverrides(args: { } 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) + const command = await cachedCursorCommand(args.connectionId ?? null, wslDistro ?? null) return command ? { ...args.cmdOverrides, cursor: command } : args.cmdOverrides } catch { return args.cmdOverrides } } + +export function resetRuntimeCursorCommandCacheForTests(): void { + cursorCommandCache.clear() +} + +async function cachedCursorCommand( + connectionId: string | null, + wslDistro: string | null +): Promise { + const key = JSON.stringify({ connectionId, wslDistro }) + const existing = cursorCommandCache.get(key) + if (existing?.inflight) { + return existing.inflight + } + if (existing && existing.expiresAt > Date.now()) { + return existing.command ?? null + } + const entry: CursorCommandCacheEntry = existing ?? { expiresAt: 0 } + const inflight = detectCursorCommand(connectionId, wslDistro) + .then((command) => { + entry.command = command + entry.expiresAt = Date.now() + CURSOR_COMMAND_CACHE_TTL_MS + return command + }) + .finally(() => { + if (entry.inflight === inflight) { + entry.inflight = undefined + } + }) + entry.inflight = inflight + cursorCommandCache.delete(key) + cursorCommandCache.set(key, entry) + evictCursorCommandCache(key) + return inflight +} + +async function detectCursorCommand( + connectionId: string | null, + wslDistro: string | null +): Promise { + const inventory = connectionId + ? await detectRemoteAgentCommands({ connectionId }) + : await detectInstalledAgentCommandsWithShellPathHydration(wslDistro ? { wslDistro } : {}) + return resolveEffectiveCursorCommand(null, inventory) +} + +function evictCursorCommandCache(activeKey: string): void { + while (cursorCommandCache.size > CURSOR_COMMAND_CACHE_MAX_KEYS) { + const candidate = [...cursorCommandCache.entries()].find( + ([key, entry]) => key !== activeKey && !entry.inflight + ) + if (!candidate) { + return + } + cursorCommandCache.delete(candidate[0]) + } +} diff --git a/src/relay/cursor-sidecar-scan-discovery.ts b/src/relay/cursor-sidecar-scan-discovery.ts index b9a1e718cfc..b09b05c3cef 100644 --- a/src/relay/cursor-sidecar-scan-discovery.ts +++ b/src/relay/cursor-sidecar-scan-discovery.ts @@ -86,6 +86,7 @@ async function scopeBuckets( const buckets = new Map() args.response.scopeCwds = [...cwds].sort() for (const cwd of args.response.scopeCwds) { + // Cursor names its bucket dirs md5(cwd); this mirrors that, not a security primitive. const name = createHash('md5').update(cwd).digest('hex') buckets.set(name, { name, path: join(chatsRoot, name), scopeCwd: cwd }) } diff --git a/src/relay/cursor-sidecar-scan.test.ts b/src/relay/cursor-sidecar-scan.test.ts index ca09a68bf88..a3a7797566a 100644 --- a/src/relay/cursor-sidecar-scan.test.ts +++ b/src/relay/cursor-sidecar-scan.test.ts @@ -82,28 +82,31 @@ describe('scanCursorSidecars', () => { }) }) - 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'), '') - ]) + it.skipIf(process.platform === 'win32')( + '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([]) - }) + 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() diff --git a/src/relay/fs-handler-terminal-artifact.ts b/src/relay/fs-handler-terminal-artifact.ts index 9c71b21b37e..ca958d68ac0 100644 --- a/src/relay/fs-handler-terminal-artifact.ts +++ b/src/relay/fs-handler-terminal-artifact.ts @@ -32,7 +32,7 @@ type VerifiedTerminalArtifactOptions = { export async function readVerifiedTerminalArtifact(params: Record) { const filePath = stringParam(params.filePath) const options = verifiedTerminalArtifactOptions(params) - const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) + const handle = await openVerifiedTerminalArtifact(filePath, options) try { await verifiedHandleStat(handle, options) const mimeType = terminalArtifactImageMimeType(filePath) @@ -64,7 +64,7 @@ export async function writeVerifiedTerminalArtifact( if (Buffer.byteLength(content, 'utf8') > writeLimit) { throw new Error('file_too_large') } - const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) + const handle = await openVerifiedTerminalArtifact(filePath, options) let originalMode: number | null = null try { originalMode = (await verifiedHandleStat(handle, options)).mode ?? null @@ -81,7 +81,7 @@ export async function writeVerifiedTerminalArtifact( if (typeof originalMode === 'number') { await chmod(tempPath, originalMode & 0o7777) } - const freshHandle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) + const freshHandle = await openVerifiedTerminalArtifact(filePath, options) try { await verifiedHandleStat(freshHandle, options) } finally { @@ -110,17 +110,15 @@ function terminalArtifactImageMimeType(filePath: string): string | undefined { return mimeType === 'image/svg+xml' ? undefined : mimeType } +// Why: read-only + O_NOFOLLOW is the whole guard here; a caller-supplied flag +// set would make it trivial to reintroduce symlink-follow on a temp path. async function openVerifiedTerminalArtifact( filePath: string, - options: VerifiedTerminalArtifactOptions, - flags: number + options: VerifiedTerminalArtifactOptions ): Promise { await assertRealPathStillGranted(filePath, options.expectedRealPath) try { - if (flags === constants.O_RDONLY) { - return await openNoFollow(filePath) - } - return await open(filePath, flags) + return await openNoFollow(filePath) } catch (error) { if ( (error as NodeJS.ErrnoException).code === 'ELOOP' || diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 47903b95d13..30d5c32534e 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -117,7 +117,6 @@ export class PreflightHandler { (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) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts index 8603cb7c4f6..b34a7de9fbc 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts @@ -2,15 +2,24 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-contin import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { getAiVaultTranscriptPath } from '../../../../shared/ai-vault-transcript-path' +// Cursor resumes by re-running its CLI in the recorded cwd, so both gating +// paths need the same triple; keep them from drifting apart. +export function hasCursorResumeTarget( + session: Partial>, + cursorCommandAvailable: boolean +): boolean { + return ( + session.agent !== 'cursor' || + Boolean(session.cwd && session.resumeCommand?.trim() && cursorCommandAvailable) + ) +} + export function canContinueAiVaultSessionInNewSession( session: AiVaultSession, targetWorktreeId: string | null | undefined, cursorCommandAvailable = false ): boolean { - if ( - session.agent === 'cursor' && - (!session.cwd || !session.resumeCommand.trim() || !cursorCommandAvailable) - ) { + if (!hasCursorResumeTarget(session, cursorCommandAvailable)) { return false } return Boolean( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 2caeef90d93..c12802d070b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -18,6 +18,7 @@ import { canJumpToAiVaultSessionWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { hasCursorResumeTarget } from './ai-vault-session-continuation' export type AiVaultSessionResumeTargetState = Pick< AppState, @@ -228,12 +229,10 @@ export function aiVaultSessionRowResumeGating( | null ): { resumeDisabled: boolean; canCopyResumeCommand: boolean } { const hasResumableContent = isAiVaultSessionResumableContent(session) - const hasCursorResumeTarget = - session.agent !== 'cursor' || - Boolean(session.cwd && session.resumeCommand?.trim() && state?.cursorCommandAvailable === true) + const cursorResumable = hasCursorResumeTarget(session, state?.cursorCommandAvailable === true) return { - resumeDisabled: (state?.blocked ?? true) || !hasResumableContent || !hasCursorResumeTarget, - canCopyResumeCommand: hasResumableContent && hasCursorResumeTarget + resumeDisabled: (state?.blocked ?? true) || !hasResumableContent || !cursorResumable, + canCopyResumeCommand: hasResumableContent && cursorResumable } } diff --git a/src/renderer/src/lib/ai-vault-cursor-command.test.ts b/src/renderer/src/lib/ai-vault-cursor-command.test.ts index d77108d03fc..7943e5991fe 100644 --- a/src/renderer/src/lib/ai-vault-cursor-command.test.ts +++ b/src/renderer/src/lib/ai-vault-cursor-command.test.ts @@ -73,6 +73,22 @@ describe('resolveAiVaultCursorCommand', () => { ).toBeNull() }) + it('falls back to the flat command map only when no context-indexed map exists', () => { + expect( + resolveAiVaultCursorCommand({ + state: state({ detectedAgentCommands: { cursor: 'cursor-agent' } }) + }) + ).toBe('cursor-agent') + expect( + resolveAiVaultCursorCommand({ + state: state({ + detectedAgentCommands: { cursor: 'cursor-agent' }, + detectedAgentCommandsByContext: { 'wsl:Ubuntu': { cursor: 'cursor agent' } } + }) + }) + ).toBeNull() + }) + it('prefers an explicit override over every detected match', () => { expect( resolveAiVaultCursorCommand({ diff --git a/src/renderer/src/lib/ai-vault-cursor-command.ts b/src/renderer/src/lib/ai-vault-cursor-command.ts index 303ab3c1da6..4ee6aa19334 100644 --- a/src/renderer/src/lib/ai-vault-cursor-command.ts +++ b/src/renderer/src/lib/ai-vault-cursor-command.ts @@ -65,7 +65,11 @@ export function resolveAiVaultCursorCommand(args: { args.worktreeId ) ) - matches = args.state.detectedAgentCommandsByContext?.[contextKey] + // Older preflight/preload payloads carry only the flat map; keep a missing + // context result authoritative once the context-indexed map exists. + matches = args.state.detectedAgentCommandsByContext + ? args.state.detectedAgentCommandsByContext[contextKey] + : args.state.detectedAgentCommands } return resolveEffectiveCursorCommand(args.commandOverride, { version: 1, diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index fcafe02822f..f41c99651eb 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -282,9 +282,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ...settings, agentCmdOverrides: effectiveAgent ? resolveCursorCommandOverrides({ - state: store, + // Why: the pre-create snapshot lacks the new worktree, so + // Cursor would resolve against the active/local host. + state: latestStore, agent: effectiveAgent, worktreeId, + repoId: repo.id, cmdOverrides: settings.agentCmdOverrides ?? {} }) : settings.agentCmdOverrides diff --git a/src/shared/cursor-sidecar-scan.ts b/src/shared/cursor-sidecar-scan.ts index 1e7956ab85f..cb10782f249 100644 --- a/src/shared/cursor-sidecar-scan.ts +++ b/src/shared/cursor-sidecar-scan.ts @@ -22,11 +22,11 @@ export const cursorSidecarScanRequestSchema = z version: z.literal(CURSOR_SIDECAR_SCAN_VERSION), chatsRoot: boundedPath, scopePaths: z.array(boundedPath).max(CURSOR_REMOTE_MAX_SCOPE_PATHS), - maxBuckets: positiveSafeInteger, - maxSessionDirs: positiveSafeInteger, - maxScopePaths: positiveSafeInteger, - maxSidecarBytes: positiveSafeInteger, - maxAggregateBytes: positiveSafeInteger + maxBuckets: positiveSafeInteger.max(CURSOR_REMOTE_MAX_BUCKETS), + maxSessionDirs: positiveSafeInteger.max(CURSOR_REMOTE_MAX_SESSION_DIRS), + maxScopePaths: positiveSafeInteger.max(CURSOR_REMOTE_MAX_SCOPE_PATHS), + maxSidecarBytes: positiveSafeInteger.max(CURSOR_SIDECAR_MAX_BYTES), + maxAggregateBytes: positiveSafeInteger.max(CURSOR_REMOTE_MAX_AGGREGATE_BYTES) }) .strict()