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()