From d7cf9d2d3779b9dcff3fc07c200fe2f0c99158fb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:44:18 -0700 Subject: [PATCH] fix(ai-vault): resume a server session whose workspace was deleted; show remote hit previews (#33965) Since #27214 every SSH session is retagged runtime:, so the #24236 check that drops a deleted cwd (and its prebuilt `cd`) before resuming only ever ran for the legacy ssh: spelling. Resume on a paired or SSH-managed server therefore ran `cd '' &&` and failed. The check now covers runtime hosts too, probing the folder with the existing files.browseServerDir RPC; only a definite "missing" answer drops the cwd, so an older or unreachable host keeps today's behaviour. Search-hit cards carry no turns and remote hosts cannot reparse a first prompt, so a server session found through search showed "No first prompt" and "No conversation preview". Hits now borrow the preview of their scanned history row. --- .../components/right-sidebar/AiVaultPanel.tsx | 7 +- .../ai-vault-search-session.test.ts | 34 +++++- .../right-sidebar/ai-vault-search-session.ts | 34 ++++++ .../ai-vault-session-launch-actions.test.ts | 2 +- .../ai-vault-session-launch-actions.ts | 6 +- .../src/lib/ai-vault-resume-command.test.ts | 47 +++++++- ...i-vault-session-resume-preparation.test.ts | 104 +++++++++++++++++- .../ai-vault-session-resume-preparation.ts | 88 +++++++++++++-- 8 files changed, 299 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index de4d863894d..312ee8d6b93 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -35,6 +35,7 @@ import { AgentSessionContinuationDialog } from '@/components/agent-session-conti import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' import { useAiVaultSessionDeleteAction } from './ai-vault-session-delete-action' import { useAiVaultPanelSearch } from './use-ai-vault-search' +import { withAiVaultHistoryPreviews } from './ai-vault-search-session' import { aiVaultSearchScopeIdentity } from './ai-vault-search-scope-identity' import { AiVaultPanelSearch } from './AiVaultPanelSearch' import { useAiVaultPanelSessionResume } from './use-ai-vault-panel-session-resume' @@ -159,7 +160,11 @@ export default function AiVaultPanel({ ) const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort) const { searching, searchHits } = search - const sessions = searching ? search.sessions : history + const searchSessions = useMemo( + () => withAiVaultHistoryPreviews(search.sessions, history), + [history, search.sessions] + ) + const sessions = searching ? searchSessions : history const requestedScanKey = aiVaultLocalScanKey({ scopePaths, includeAntigravityIdeSessions: true, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts index 045d94a4539..0f981da5b36 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts @@ -3,9 +3,41 @@ import { searchHit } from '../../../../shared/ai-vault-search-test-fixture' import { aiVaultSearchHitToSession, canResumeAiVaultSearchHit, - hasAiVaultSearchHitPath + hasAiVaultSearchHitPath, + withAiVaultHistoryPreviews } from './ai-vault-search-session' +describe('withAiVaultHistoryPreviews', () => { + it("shows a paired server hit's conversation preview from its scanned history row", () => { + // A relay hit arrives without its transcript path, so its row id differs from the scanned row's. + const hitSession = aiVaultSearchHitToSession( + { ...searchHit(), source: { presence: 'present' as const } }, + 'runtime:env-1' + ) + const turns = [ + { role: 'user' as const, text: 'Fix the flaky test', timestamp: null }, + { role: 'assistant' as const, text: 'Done', timestamp: null } + ] + + const [hydrated] = withAiVaultHistoryPreviews( + [hitSession], + [ + { + ...hitSession, + id: 'runtime:env-1:codex:host-session:/host/transcript.jsonl', + filePath: '/host/transcript.jsonl', + previewMessages: turns, + firstUserPrompt: 'Fix the flaky test' + } + ] + ) + + expect(hydrated.previewMessages).toEqual(turns) + expect(hydrated.firstUserPrompt).toBe('Fix the flaky test') + expect(hydrated.resumeCommand).toBe('host-resume-command') + }) +}) + describe('aiVaultSearchHitToSession', () => { it('uses the selected execution host and only host-provided source fields', () => { const hit = { ...searchHit(), executionHostId: 'local' } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts index 64413275f7d..8a326994ce4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts @@ -34,6 +34,40 @@ export function aiVaultSearchHitToSession( } } +/** Hits carry no turns and remote hosts cannot reparse a first prompt, so borrow the scanned row's preview. */ +export function withAiVaultHistoryPreviews( + hitSessions: AiVaultSession[], + history: readonly AiVaultSession[] +): AiVaultSession[] { + if (hitSessions.length === 0 || history.length === 0) { + return hitSessions + } + // Relay hits are redacted of their transcript path, so join on host, agent and session id; skip ambiguous ones. + const historyByIdentity = new Map() + for (const session of history) { + const key = previewIdentity(session) + historyByIdentity.set(key, historyByIdentity.has(key) ? null : session) + } + return hitSessions.map((hitSession) => { + const row = historyByIdentity.get(previewIdentity(hitSession)) + return row && hitSession.previewMessages.length === 0 + ? { + ...hitSession, + previewMessages: row.previewMessages, + ...(row.previewMessagesTruncated ? { previewMessagesTruncated: true } : {}), + ...(row.lastUserPrompt !== undefined ? { lastUserPrompt: row.lastUserPrompt } : {}), + ...(row.firstUserPrompt !== undefined ? { firstUserPrompt: row.firstUserPrompt } : {}) + } + : hitSession + }) +} + +function previewIdentity( + session: Pick +): string { + return JSON.stringify([session.executionHostId, session.agent, session.sessionId]) +} + export function canResumeAiVaultSearchHit(hit: AiVaultSearchHit): boolean { return ( hit.structuredSession !== undefined || diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index b21aa25bcbe..d79f9d3c06d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({ prepareAiVaultSessionForFork: mocks.prepareAiVaultSessionForFork, prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume, prepareAiVaultSessionForTerminalResume: mocks.prepareAiVaultSessionForTerminalResume, - dropDeletedSshResumeCwd: async (session: unknown) => session, + dropDeletedRemoteResumeCwd: async (session: unknown) => session, aiVaultCliForkHostTooOldMessage: () => 'Update Orca on the host that runs this chat to resume it in a new CLI.' })) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 0f4bcae5b34..7dc65af3491 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -19,7 +19,7 @@ import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and import { useAppStore } from '@/store' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import { - dropDeletedSshResumeCwd, + dropDeletedRemoteResumeCwd, prepareAiVaultSessionForFork, prepareAiVaultSessionForResume, prepareAiVaultSessionForTerminalResume @@ -136,7 +136,7 @@ export function useAiVaultSessionLaunchActions({ targetWorktreeId, (worktreeId) => prepareAiVaultSessionForTerminalResume(session) - .then(dropDeletedSshResumeCwd) + .then(dropDeletedRemoteResumeCwd) .then((preparedSession) => buildResumeStartup(preparedSession, worktreeId)), (message) => describeAiVaultResumeFailure(new Error(message), session.agent) ) @@ -155,7 +155,7 @@ export function useAiVaultSessionLaunchActions({ commandOverride: agentCmdOverrides?.[session.agent] }) const prepared = await prepareAiVaultSessionForFork(session) - const target = await dropDeletedSshResumeCwd(prepared.session) + const target = await dropDeletedRemoteResumeCwd(prepared.session) // A copy the chat's host already made is the terminal's own conversation: a plain resume. const startup = prepared.copied ? buildAiVaultCopiedSessionStartupForWorktree( diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 4e1ee47e1aa..bc0198a9e35 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -5,12 +5,18 @@ import { buildAiVaultResumeCopyCommandForWorktree, buildAiVaultResumeStartupForWorktree } from './ai-vault-resume-command' -import { dropDeletedSshResumeCwd } from './ai-vault-session-resume-preparation' +import { dropDeletedRemoteResumeCwd } from './ai-vault-session-resume-preparation' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +const browseServerDir = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-server-directory-browser', () => ({ + browseRuntimeServerDirectory: browseServerDir +})) + afterEach(() => { vi.unstubAllGlobals() }) @@ -515,7 +521,7 @@ describe('ai vault resume command runtime', () => { const startup = buildAiVaultResumeStartupForWorktree({ state: makeState({ worktreePath: '/home/alice/wt/main' }), worktreeId: 'repo-1::worktree-1', - session: await dropDeletedSshResumeCwd(session) + session: await dropDeletedRemoteResumeCwd(session) }) // A deleted SSH cwd blocks startup, and Codex retains that missing folder even when resumed at root (#17745). @@ -528,6 +534,43 @@ describe('ai vault resume command runtime', () => { }) }) + it('resumes a paired server session at the workspace root once the server reports its folder deleted', async () => { + browseServerDir.mockImplementation(async (_environmentId: string, path: string) => { + if (path === '~') { + return { resolvedPath: '/home/alice', entries: [], pathFlavor: 'posix' } + } + throw new RuntimeRpcCallError({ + id: 'req', + ok: false, + error: { + code: 'runtime_error', + message: `ENOENT: no such file or directory, stat '${path}'` + } + }) + }) + const session = { + agent: 'codex' as const, + sessionId: 'session one', + cwd: '/home/alice/wt/feat-x', + codexHome: '/home/alice/.codex', + executionHostId: 'runtime:env-1' as const, + executionHostPlatform: 'linux' as const, + resumeCommand: + "cd '/home/alice/wt/feat-x' && CODEX_HOME='/home/alice/.codex' codex resume 'session one'" + } + + const startup = buildAiVaultResumeStartupForWorktree({ + state: makeState({ worktreePath: '/home/alice/wt/main' }), + worktreeId: 'repo-1::worktree-1', + session: await dropDeletedRemoteResumeCwd(session) + }) + + expect(startup.command).not.toContain('feat-x') + expect(startup.command).toContain("'-c' 'tui.resume_cwd=current' 'resume'") + expect(startup.cwd).toBeUndefined() + expect(browseServerDir).toHaveBeenCalledWith('env-1', '/home/alice/wt/feat-x') + }) + it('rebuilds remote real-home Codex commands without a stored home assignment', () => { const state = makeState({ worktreePath: '/home/alice/repo' }) state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index addae3c9a43..278a56f0f29 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -1,14 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from '../../../shared/ai-vault-types' import { - dropDeletedSshResumeCwd, + dropDeletedRemoteResumeCwd, prepareAiVaultSessionForFork, prepareAiVaultSessionForResume, prepareAiVaultSessionForTerminalResume } from './ai-vault-session-resume-preparation' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' + +const browseServerDir = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-server-directory-browser', () => ({ + browseRuntimeServerDirectory: browseServerDir +})) + afterEach(() => { vi.unstubAllGlobals() + browseServerDir.mockReset() }) describe('prepareAiVaultSessionForResume', () => { @@ -179,14 +187,14 @@ describe('prepareAiVaultSessionForFork', () => { }) }) -describe('dropDeletedSshResumeCwd', () => { +describe('dropDeletedRemoteResumeCwd', () => { it('leaves a live SSH session alone, scanner command and all', async () => { const pathExists = vi.fn().mockResolvedValue(true) vi.stubGlobal('window', { api: { fs: { pathExists } } }) const remote = session({ executionHostId: 'ssh:server-1', cwd: '/home/ada/wt/feat-x' }) // Preserve recorded cwd or ordinary remote resumes would start at the workspace root. - await expect(dropDeletedSshResumeCwd(remote)).resolves.toBe(remote) + await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote) expect(pathExists).toHaveBeenCalledWith({ filePath: '/home/ada/wt/feat-x', connectionId: 'server-1' @@ -198,7 +206,7 @@ describe('dropDeletedSshResumeCwd', () => { vi.stubGlobal('window', { api: { fs: { pathExists } } }) const local = session({ cwd: '/repo' }) - await expect(dropDeletedSshResumeCwd(local)).resolves.toBe(local) + await expect(dropDeletedRemoteResumeCwd(local)).resolves.toBe(local) expect(pathExists).not.toHaveBeenCalled() }) @@ -208,20 +216,104 @@ describe('dropDeletedSshResumeCwd', () => { const remote = session({ executionHostId: 'ssh:server-1', cwd: '/home/ada/wt/feat-x' }) // Losing the host cannot prove its folder is gone (ssh-execution-boundary.md). - await expect(dropDeletedSshResumeCwd(remote)).resolves.toBe(remote) + await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote) expect(pathExists).toHaveBeenCalled() }) + it('drops a paired server session folder only when that server reports it missing', async () => { + serveHostWithMissingFolders('posix') + const gone = session({ + executionHostId: 'runtime:env-1', + cwd: '/srv/wt/gone', + resumeCommand: "cd '/srv/wt/gone' && codex resume session-1" + }) + + const dropped = await dropDeletedRemoteResumeCwd(gone) + + expect(dropped.cwd).toBeNull() + expect(dropped.resumeCommand).toBeUndefined() + expect(browseServerDir).toHaveBeenCalledWith('env-1', '/srv/wt/gone') + }) + + it('keeps a paired server session folder that exists or cannot be checked', async () => { + const remote = session({ executionHostId: 'runtime:env-1', cwd: '/srv/wt/live' }) + browseServerDir.mockResolvedValueOnce({ resolvedPath: '/srv/wt/live', entries: [] }) + await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote) + + // A dropped connection proves nothing about the folder. + browseServerDir.mockRejectedValueOnce(new Error('Runtime environment is unreachable')) + await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote) + + // A path that merely contains "not found" is not the host saying the folder is gone. + browseServerDir.mockRejectedValueOnce(runtimeError('Workspace not found on this server')) + await expect(dropDeletedRemoteResumeCwd(remote)).resolves.toBe(remote) + }) + + it('keeps a folder the server browser would trim into a different path', async () => { + serveHostWithMissingFolders('posix') + const padded = session({ executionHostId: 'runtime:env-1', cwd: '/srv/repo ' }) + + await expect(dropDeletedRemoteResumeCwd(padded)).resolves.toBe(padded) + expect(browseServerDir).not.toHaveBeenCalled() + }) + + it('rebuilds a deleted-folder resume for a Windows server with its shell', async () => { + serveHostWithMissingFolders('win32') + const gone = session({ + executionHostId: 'runtime:windows-server', + cwd: 'C:\\Users\\ada\\wt\\gone', + resumeCommand: "cd 'C:\\Users\\ada\\wt\\gone' && codex resume session-1" + }) + + const dropped = await dropDeletedRemoteResumeCwd(gone) + + expect(dropped.cwd).toBeNull() + expect(dropped.executionHostPlatform).toBe('win32') + }) + + it("keeps a WSL session's Linux folder that its Windows host cannot stat", async () => { + serveHostWithMissingFolders('win32') + const wsl = session({ + executionHostId: 'runtime:windows-box', + cwd: '/home/ada/wt/live', + resumeCommand: "cd '/home/ada/wt/live' && codex resume session-1" + }) + + await expect(dropDeletedRemoteResumeCwd(wsl)).resolves.toBe(wsl) + }) + it('keeps the recorded folder for an agent that can only resume there', async () => { const pathExists = vi.fn().mockResolvedValue(false) vi.stubGlobal('window', { api: { fs: { pathExists } } }) const kimi = session({ agent: 'kimi', executionHostId: 'ssh:server-1', cwd: '/home/ada/wt/x' }) // Why: kimi-cli 1.52 silently opens a new empty session with the same id from any other folder. - await expect(dropDeletedSshResumeCwd(kimi)).resolves.toBe(kimi) + await expect(dropDeletedRemoteResumeCwd(kimi)).resolves.toBe(kimi) }) }) +/** A host whose home browses as `flavor`, and on which every other folder is missing. */ +function serveHostWithMissingFolders(flavor: 'posix' | 'win32'): void { + browseServerDir.mockImplementation(async (_environmentId: string, path: string) => { + if (path === '~') { + return { + resolvedPath: flavor === 'win32' ? 'C:\\Users\\ada' : '/home/ada', + entries: [], + pathFlavor: flavor + } + } + throw runtimeError(`ENOENT: no such file or directory, stat '${path}'`) + }) +} + +function runtimeError(message: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'req', + ok: false, + error: { code: 'runtime_error', message } + }) +} + function stubPreparation(prepareSessionResume: ReturnType): void { vi.stubGlobal('window', { api: { aiVault: { prepareSessionResume } } }) } diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts index 04e549e4d64..f55be7c5693 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -11,10 +11,15 @@ import { isPerAccountManagedCodexHome } from '../../../shared/ai-vault-resume-preparation' import { - getSshTargetIdForExecutionHost, LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId + parseExecutionHostId, + parseRoutableExecutionHostId } from '../../../shared/execution-host' +import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { FilesystemPathFlavor } from '../../../shared/filesystem-entry-types' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { readIpcErrorMessage } from './ipc-error' import type { AiVaultResumeCommandSession } from './ai-vault-resume-command' import { agentForksThroughChat } from '../../../shared/agent-resume-argv' @@ -193,22 +198,87 @@ export function aiVaultSessionNeedsResumePreparation( // Kimi is folder-keyed: wrong cwd rejects resume or silently opens an empty same-ID session (1.52+). const RESUMES_ONLY_IN_RECORDED_CWD: ReadonlySet = new Set(['kimi', 'muse']) -/** After the SSH host confirms missing cwd, drop it and its prebuilt cd command so resume starts at the workspace root (#17745). */ -export async function dropDeletedSshResumeCwd( +/** After the session's remote host confirms missing cwd, drop it and its prebuilt cd command so resume starts at the workspace root (#17745, #26509). */ +export async function dropDeletedRemoteResumeCwd( session: AiVaultResumeCommandSession ): Promise { - const connectionId = getSshTargetIdForExecutionHost(session.executionHostId) - if (!connectionId || !session.cwd || RESUMES_ONLY_IN_RECORDED_CWD.has(session.agent)) { + const host = parseRoutableExecutionHostId(session.executionHostId) + const cwd = session.cwd + if ( + (host?.kind !== 'ssh' && host?.kind !== 'runtime') || + !cwd || + RESUMES_ONLY_IN_RECORDED_CWD.has(session.agent) + ) { return session } + let hostFlavor: FilesystemPathFlavor | null = null try { - if (await window.api.fs.pathExists({ filePath: session.cwd, connectionId })) { - return session + if (host.kind === 'ssh') { + if (await window.api.fs.pathExists({ filePath: cwd, connectionId: host.targetId })) { + return session + } + } else { + hostFlavor = await runtimeServerFolderMissingFlavor(host.environmentId, cwd) + if (!hostFlavor) { + return session + } } } catch { // Why: only a definite ENOENT proves the folder is gone; losing the host proves nothing. return session } const { resumeCommand: _scannerCommand, ...rest } = session - return { ...rest, cwd: null } + // The rebuilt command must quote for the host's shell; an unstamped row would otherwise build POSIX. + return hostFlavor === 'win32' && !rest.executionHostPlatform + ? { ...rest, cwd: null, executionHostPlatform: 'win32' } + : { ...rest, cwd: null } +} + +/** The host's path flavor when it proves `cwd` is gone, else null. files.stat needs the deleted workspace, so browse instead. */ +async function runtimeServerFolderMissingFlavor( + environmentId: string, + cwd: string +): Promise { + // The browser trims, expands `~` and resolves relative paths, so it would probe another folder; keep those. + if (cwd.trim() !== cwd || (!cwd.startsWith('/') && !isWindowsAbsolutePathLike(cwd))) { + return null + } + try { + await browseRuntimeServerDirectory(environmentId, cwd) + return null + } catch (error) { + if (!isDefiniteMissingPathError(error)) { + throw error + } + } + // A WSL session on a Windows host records a Linux cwd the host cannot stat; absence there proves nothing. + const flavor = await runtimeServerPathFlavor(environmentId) + return flavor === aiVaultCwdPathFlavor(cwd) ? flavor : null +} + +function isDefiniteMissingPathError(error: unknown): boolean { + return ( + error instanceof RuntimeRpcCallError && + error.code === 'runtime_error' && + /^(?:ENOENT|ENOTDIR):/.test(error.message) + ) +} + +const runtimeServerPathFlavors = new Map>() + +function runtimeServerPathFlavor(environmentId: string): Promise { + // Keyed by pairing: a same-id re-pair may name a machine with another OS. + const key = `${environmentId}:${getRuntimeEnvironmentRevision(environmentId) ?? ''}` + let flavor = runtimeServerPathFlavors.get(key) + if (!flavor) { + flavor = browseRuntimeServerDirectory(environmentId, '~').then((listing) => listing.pathFlavor) + // A failed probe must not pin its verdict; the next resume asks again. + flavor.catch(() => runtimeServerPathFlavors.delete(key)) + runtimeServerPathFlavors.set(key, flavor) + } + return flavor +} + +function aiVaultCwdPathFlavor(cwd: string): FilesystemPathFlavor { + return isWindowsAbsolutePathLike(cwd) ? 'win32' : 'posix' }