feat(ai-vault): add cursor command cache with host isolation

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.
This commit is contained in:
Jinjing
2026-08-05 12:48:01 -07:00
parent 1cff3be4e4
commit a07efd89fb
23 changed files with 256 additions and 99 deletions
@@ -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({
@@ -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
})
})
@@ -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 {
@@ -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,
@@ -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 }
)
})
})
+3 -1
View File
@@ -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
@@ -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
@@ -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 })
@@ -43,14 +43,18 @@ export async function processLocalCursorCandidates(args: {
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
// 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)
@@ -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()
}
}
}
+16 -12
View File
@@ -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]] : []
})
)
@@ -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(
+72 -4
View File
@@ -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<string | null>
}
const cursorCommandCache = new Map<string, CursorCommandCacheEntry>()
export async function resolveRuntimeAgentCommandOverrides(args: {
agent: TuiAgent
cmdOverrides: Partial<Record<TuiAgent, string>>
@@ -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<string | null> {
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<string | null> {
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])
}
}
@@ -86,6 +86,7 @@ async function scopeBuckets(
const buckets = new Map<string, Bucket>()
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 })
}
+24 -21
View File
@@ -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()
+7 -9
View File
@@ -32,7 +32,7 @@ type VerifiedTerminalArtifactOptions = {
export async function readVerifiedTerminalArtifact(params: Record<string, unknown>) {
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<FileHandle> {
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' ||
-1
View File
@@ -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)
@@ -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<Pick<AiVaultSession, 'agent' | 'cwd' | 'resumeCommand'>>,
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(
@@ -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
}
}
@@ -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({
@@ -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,
@@ -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
+5 -5
View File
@@ -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()