diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index c1be98660..83d7a8bea 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -1,9 +1,12 @@ +/* eslint-disable max-lines -- Why: WSL fallback, retry safety, and glab parity share mocks. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslModule from '../wsl' -const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({ +const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), execFileSyncMock: vi.fn(), - spawnMock: vi.fn() + spawnMock: vi.fn(), + getDefaultWslDistroMock: vi.fn() })) vi.mock('child_process', () => ({ @@ -12,6 +15,11 @@ vi.mock('child_process', () => ({ spawn: spawnMock })) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + getDefaultWslDistro: getDefaultWslDistroMock +})) + import { ghExecFileAsync, glabExecFileAsync } from './runner' describe('ghExecFileAsync WSL fallback', () => { @@ -19,6 +27,8 @@ describe('ghExecFileAsync WSL fallback', () => { beforeEach(() => { execFileMock.mockReset() + getDefaultWslDistroMock.mockReset() + getDefaultWslDistroMock.mockReturnValue(null) Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' @@ -257,6 +267,30 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenCalledTimes(1) }) + it('retries cwd-less gh calls through the default WSL distro when host gh is missing', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: '{"resources":{}}', stderr: '' }) + }) + + await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toEqual({ + stdout: '{"resources":{}}', + stderr: '' + }) + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', "gh 'api' 'rate_limit'"], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('does not retry non-idempotent glab transient failures', async () => { execFileMock.mockImplementation((_binary, _args, _options, callback) => { callback( @@ -295,6 +329,30 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenCalledTimes(1) }) + it('retries cwd-less glab calls through the default WSL distro when host glab is missing', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: '[]', stderr: '' }) + }) + + await expect(glabExecFileAsync(['api', 'projects'])).resolves.toEqual({ + stdout: '[]', + stderr: '' + }) + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', "glab 'api' 'projects'"], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('still retries idempotent glab transient failures', async () => { execFileMock .mockImplementationOnce((_binary, _args, _options, callback) => { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index dffc475ec..870c670a6 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -11,7 +11,7 @@ consistent across every repo-scoped subprocess call. */ */ import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process' import { promisify } from 'util' -import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' +import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' const execFileAsync = promisify(execFile) @@ -121,6 +121,26 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { } } +function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null { + const distro = getDefaultWslDistro() + return distro ? resolveCommand(command, args, undefined, distro) : null +} + +function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean { + if (!err || typeof err !== 'object') { + return false + } + const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown } + if (e.code === 'ENOENT') { + return true + } + const message = typeof e.message === 'string' ? e.message.toLowerCase() : '' + return ( + message.includes('enoent') && + (message.includes(command) || e.path === command || e.syscall === 'spawn') + ) +} + /** * Given a command, its arguments, and a working directory, resolve whether * the invocation should be routed through wsl.exe. @@ -502,6 +522,7 @@ export async function ghExecFileAsync( let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) let lastError: unknown let attemptedHostFallback = false + let attemptedDefaultWslFallback = false for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { try { const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { @@ -515,6 +536,24 @@ export async function ghExecFileAsync( } catch (err) { lastError = err const { stderr } = extractExecError(err) + if ( + process.platform === 'win32' && + !attemptedDefaultWslFallback && + resolved.wsl === null && + !options.cwd && + !options.wslDistro && + isHostCommandMissing(err, 'gh') + ) { + const wslResolved = resolveDefaultWslCli('gh', args) + if (wslResolved) { + // Why: WSL-only Windows installs have no gh.exe on the host PATH, but + // global calls like rate_limit/auth do not carry a repo cwd to route by. + resolved = wslResolved + attemptedDefaultWslFallback = true + attempt = -1 + continue + } + } if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { resolved = resolveHostGitHubCli('gh', args) attemptedHostFallback = true @@ -578,8 +617,9 @@ export async function glabExecFileAsync( args: string[], options: GlabExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand('glab', args, options.cwd, options.wslDistro) + let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro) let lastError: unknown + let attemptedDefaultWslFallback = false for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { try { const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { @@ -593,6 +633,23 @@ export async function glabExecFileAsync( } catch (err) { lastError = err const { stderr } = extractExecError(err) + if ( + process.platform === 'win32' && + !attemptedDefaultWslFallback && + resolved.wsl === null && + !options.cwd && + !options.wslDistro && + isHostCommandMissing(err, 'glab') + ) { + const wslResolved = resolveDefaultWslCli('glab', args) + if (wslResolved) { + // Why: mirror gh's WSL-only fallback for global GitLab project/auth calls. + resolved = wslResolved + attemptedDefaultWslFallback = true + attempt = -1 + continue + } + } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length // Why: mirror gh's write-safety gate. A transient error after GitLab // applies a POST/PATCH/PUT/DELETE must not create duplicate comments, diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index bae8d145b..6f7a492cc 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: preflight tests share expensive process/preload mocks across install, auth, agent detection, and refresh branches. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, @@ -65,6 +65,7 @@ import { type HandlerMap = Record Promise> describe('preflight', () => { + const originalPlatform = process.platform const handlers: HandlerMap = {} const defaultBitbucketStatus = { configured: false, authenticated: false, account: null } const defaultAzureDevOpsStatus = { @@ -94,6 +95,10 @@ describe('preflight', () => { getAzureDevOpsAuthStatusMock.mockResolvedValue(defaultAzureDevOpsStatus) getGiteaAuthStatusMock.mockResolvedValue(defaultGiteaStatus) _resetPreflightCache() + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'darwin' + }) for (const key of Object.keys(handlers)) { delete handlers[key] @@ -104,6 +109,13 @@ describe('preflight', () => { }) }) + afterEach(() => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + }) + // Why: every preflight run probes (in order) `git --version`, `gh --version`, // `glab --version`, then in parallel `gh auth status` + `glab auth status` — // five execFile calls per cycle. Tests below provide values for all five. @@ -187,6 +199,49 @@ describe('preflight', () => { expect(status.glab).toEqual({ installed: true, authenticated: false }) }) + it('prefers the selected WSL distro when checking gh for a WSL workspace', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command === 'git') { + return { stdout: 'git version 2.0.0\n' } + } + if (command === 'gh') { + throw Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) + } + if (command === 'glab') { + throw Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }) + } + if (command === 'wsl.exe') { + const script = String(args[5]) + if (script === "'gh' --version") { + return { stdout: 'gh version 2.0.0\n' } + } + if (script === "'gh' auth status") { + return { stdout: 'github.com\n - Active account: true\n' } + } + throw new Error(`unexpected WSL script ${script}`) + } + throw new Error(`unexpected command ${String(command)}`) + }) + + const status = await runPreflightCheck(false, { wslDistro: 'Ubuntu' }) + + expect(status.gh).toEqual({ installed: true, authenticated: true }) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' --version"], + { encoding: 'utf-8', timeout: 5000 } + ) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' auth status"], + { encoding: 'utf-8', timeout: 5000 } + ) + }) + it('re-runs the probe when forced so updated gh auth state is visible without relaunch', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) @@ -304,6 +359,28 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor']) }) + it('detects agents from the selected WSL distro for a WSL workspace', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command === 'where') { + throw new Error('not found') + } + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + if (script === "command -v 'claude'") { + return { stdout: '/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) + }) + it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { // Why: the Agents settings Refresh button calls this path. It must (1) ask // the shell hydrator for a fresh PATH, (2) merge any new segments, then diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 101ae955f..c38939a22 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -10,9 +10,12 @@ import { getBitbucketAuthStatus } from '../bitbucket/client' import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' - const execFileAsync = promisify(execFile) +type PreflightRuntimeContext = { + wslDistro?: string | null +} + export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } @@ -47,9 +50,25 @@ export function _resetPreflightCache(): void { cached = null } -async function isCommandAvailable(command: string): Promise { +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +async function execCommandInWsl( + distro: string, + command: string +): Promise<{ stdout: string; stderr: string }> { + return execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], { + encoding: 'utf-8', + timeout: 5000 + }) as Promise<{ stdout: string; stderr: string }> +} + +async function isCommandAvailable(command: string, wslDistro?: string): Promise { try { - await execFileAsync(command, ['--version']) + await (wslDistro + ? execCommandInWsl(wslDistro, `${shellQuote(command)} --version`) + : execFileAsync(command, ['--version'])) return true } catch { return false @@ -59,10 +78,12 @@ async function isCommandAvailable(command: string): Promise { // Why: `which`/`where` is faster than spawning the agent binary itself and avoids // triggering any agent-specific startup side-effects. This gives a reliable // PATH-based check without requiring `--version` support from each agent. -async function isCommandOnPath(command: string): Promise { +async function isCommandOnPath(command: string, wslDistro?: string): Promise { const finder = process.platform === 'win32' ? 'where' : 'which' try { - const { stdout } = await execFileAsync(finder, [command], { encoding: 'utf-8' }) + const { stdout } = wslDistro + ? await execCommandInWsl(wslDistro, `command -v ${shellQuote(command)}`) + : await execFileAsync(finder, [command], { encoding: 'utf-8' }) return stdout .split(/\r?\n/) .map((line) => line.trim()) @@ -77,11 +98,32 @@ const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).map(([id, config]) cmd: config.detectCmd })) -export async function detectInstalledAgents(): Promise { +function getPreflightWslDistro(context?: PreflightRuntimeContext): string | null { + const distro = context?.wslDistro?.trim() + return process.platform === 'win32' && distro ? distro : null +} + +async function detectCommandRuntime( + command: string, + context?: PreflightRuntimeContext +): Promise<{ installed: boolean; wslDistro?: string }> { + const wslDistro = getPreflightWslDistro(context) + if (wslDistro && (await isCommandAvailable(command, wslDistro))) { + return { installed: true, wslDistro } + } + if (await isCommandAvailable(command)) { + return { installed: true } + } + return { installed: false } +} + +export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + const wslDistro = getPreflightWslDistro(context) const checks = await Promise.all( KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ id, - installed: await isCommandOnPath(cmd) + installed: + (wslDistro ? await isCommandOnPath(cmd, wslDistro) : false) || (await isCommandOnPath(cmd)) })) ) return checks.filter((c) => c.installed).map((c) => c.id) @@ -110,10 +152,12 @@ export type RefreshAgentsResult = { * Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case * without requiring an app restart. */ -export async function refreshShellPathAndDetectAgents(): Promise { +export async function refreshShellPathAndDetectAgents( + context?: PreflightRuntimeContext +): Promise { const hydration = await hydrateShellPath({ force: true }) const added = hydration.ok ? mergePathSegments(hydration.segments) : [] - const agents = await detectInstalledAgents() + const agents = await detectInstalledAgents(context) return { agents, addedPathSegments: added, @@ -134,11 +178,13 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return result.agents } -async function isGhAuthenticated(): Promise { +async function isGhAuthenticated(wslDistro?: string): Promise { try { - await execFileAsync('gh', ['auth', 'status'], { - encoding: 'utf-8' - }) + await (wslDistro + ? execCommandInWsl(wslDistro, `${shellQuote('gh')} auth status`) + : execFileAsync('gh', ['auth', 'status'], { + encoding: 'utf-8' + })) // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any // authentication issues for the checked hosts/accounts. return true @@ -155,9 +201,11 @@ async function isGhAuthenticated(): Promise { // Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth // status to stderr in some versions and stdout in others; check both. -async function isGlabAuthenticated(): Promise { +async function isGlabAuthenticated(wslDistro?: string): Promise { try { - await execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' }) + await (wslDistro + ? execCommandInWsl(wslDistro, `${shellQuote('glab')} auth status`) + : execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' })) return true } catch (error) { const stdout = (error as { stdout?: string }).stdout ?? '' @@ -167,8 +215,12 @@ async function isGlabAuthenticated(): Promise { } } -export async function runPreflightCheck(force = false): Promise { - if (cached && !force) { +export async function runPreflightCheck( + force = false, + context?: PreflightRuntimeContext +): Promise { + const cacheable = !getPreflightWslDistro(context) + if (cacheable && cached && !force) { return cached } @@ -182,46 +234,56 @@ export async function runPreflightCheck(force = false): Promise _resetKnownHostsCache() } - const [gitInstalled, ghInstalled, glabInstalled] = await Promise.all([ - isCommandAvailable('git'), - isCommandAvailable('gh'), - isCommandAvailable('glab') + const [gitProbe, ghProbe, glabProbe] = await Promise.all([ + detectCommandRuntime('git', context), + detectCommandRuntime('gh', context), + detectCommandRuntime('glab', context) ]) const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghInstalled ? isGhAuthenticated() : Promise.resolve(false), - glabInstalled ? isGlabAuthenticated() : Promise.resolve(false), + ghProbe.installed ? isGhAuthenticated(ghProbe.wslDistro) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe.wslDistro) : Promise.resolve(false), getBitbucketAuthStatus(), getAzureDevOpsAuthStatus(), getGiteaAuthStatus() ]) - cached = { - git: { installed: gitInstalled }, - gh: { installed: ghInstalled, authenticated: ghAuthenticated }, - glab: { installed: glabInstalled, authenticated: glabAuthenticated }, + const result = { + git: { installed: gitProbe.installed }, + gh: { installed: ghProbe.installed, authenticated: ghAuthenticated }, + glab: { installed: glabProbe.installed, authenticated: glabAuthenticated }, bitbucket, azureDevOps, gitea } - return cached + if (cacheable) { + cached = result + } + + return result } export function registerPreflightHandlers(): void { ipcMain.handle( 'preflight:check', - async (_event, args?: { force?: boolean }): Promise => { - return runPreflightCheck(args?.force) + async ( + _event, + args?: PreflightRuntimeContext & { force?: boolean } + ): Promise => { + return runPreflightCheck(args?.force, args) } ) - ipcMain.handle('preflight:detectAgents', async (): Promise => { - return detectInstalledAgents() - }) + ipcMain.handle( + 'preflight:detectAgents', + async (_event, args?: PreflightRuntimeContext): Promise => { + return detectInstalledAgents(args) + } + ) - ipcMain.handle('preflight:refreshAgents', async (): Promise => { - return refreshShellPathAndDetectAgents() + ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { + return refreshShellPathAndDetectAgents(args) }) // Why: remote worktrees need agent detection on the SSH host, not the local diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e545ea512..e24274906 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -86,6 +86,49 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string { // ─── WSL home directory resolution ────────────────────────────────── const wslHomeCache = new Map() +let wslDistroCache: string[] | null = null + +function normalizeWslListOutput(output: string): string[] { + // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through + // some Windows shells; strip them before line parsing. + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter(Boolean) +} + +function isUserWslDistro(distro: string): boolean { + return !distro.toLowerCase().startsWith('docker-desktop') +} + +export function listWslDistros(): string[] { + if (wslDistroCache) { + return wslDistroCache + } + + if (process.platform !== 'win32') { + wslDistroCache = [] + return wslDistroCache + } + + try { + const output = execFileSync('wsl.exe', ['--list', '--quiet'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }) + wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) + return wslDistroCache + } catch { + wslDistroCache = [] + return wslDistroCache + } +} + +export function getDefaultWslDistro(): string | null { + return listWslDistros()[0] ?? null +} /** * Get the home directory for a WSL distro, returned as a Windows UNC path. diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4e9878486..75d7ee774 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -389,9 +389,9 @@ export type RefreshAgentsResult = { } export type PreflightApi = { - check: (args?: { force?: boolean }) => Promise - detectAgents: () => Promise - refreshAgents: () => Promise + check: (args?: { force?: boolean; wslDistro?: string | null }) => Promise + detectAgents: (args?: { wslDistro?: string | null }) => Promise + refreshAgents: (args?: { wslDistro?: string | null }) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7a15d68af..59ef60807 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1231,9 +1231,10 @@ const api = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (): Promise => ipcRenderer.invoke('preflight:detectAgents'), - refreshAgents: (): Promise => - ipcRenderer.invoke('preflight:refreshAgents'), + detectAgents: (args?: { wslDistro?: string | null }): Promise => + ipcRenderer.invoke('preflight:detectAgents', args), + refreshAgents: (args?: { wslDistro?: string | null }): Promise => + ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args) }, diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index da9da5ed5..6b9c35827 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -2,6 +2,33 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types' +type LocalPreflightContext = { wslDistro?: string | null } | undefined + +function getWslDistroFromPath(path?: string | null): string | null { + if (!path) { + return null + } + const normalized = path.replace(/\\/g, '/') + const match = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(?:\/|$)/i) + return match?.[1] ?? null +} + +function getLocalPreflightContext(state: AppState): LocalPreflightContext { + const activeWorktree = state.activeWorktreeId + ? Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === state.activeWorktreeId) + : null + const activePath = + activeWorktree?.path ?? state.repos.find((repo) => repo.id === state.activeRepoId)?.path + const wslDistro = getWslDistroFromPath(activePath) + return wslDistro ? { wslDistro } : undefined +} + +function localPreflightContextKey(context: LocalPreflightContext): string { + return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host' +} + export type DetectedAgentsSlice = { detectedAgentIds: TuiAgent[] | null isDetectingAgents: boolean @@ -31,8 +58,9 @@ export type DetectedAgentsSlice = { // Why: these are module-scoped (not in the store) so we can deduplicate // concurrent callers without storing a Promise in Zustand state. -let detectPromise: Promise | null = null -let refreshPromise: Promise | null = null +let detectPromise: { key: string; promise: Promise } | null = null +let refreshPromise: { key: string; promise: Promise } | null = null +let detectedContextKey: string | null = null const remoteDetectPromises = new Map>() export const createDetectedAgentsSlice: StateCreator = ( @@ -46,19 +74,22 @@ export const createDetectedAgentsSlice: StateCreator { + const context = getLocalPreflightContext(get()) + const contextKey = localPreflightContextKey(context) const existing = get().detectedAgentIds - if (existing) { + if (existing && detectedContextKey === contextKey) { return Promise.resolve(existing) } - if (detectPromise) { - return detectPromise + if (detectPromise?.key === contextKey) { + return detectPromise.promise } set({ isDetectingAgents: true }) const pending = window.api.preflight - .detectAgents() + .detectAgents(context) .then((ids) => { const typed = ids as TuiAgent[] set({ detectedAgentIds: typed, isDetectingAgents: false }) + detectedContextKey = contextKey return typed }) .catch(() => { @@ -68,17 +99,19 @@ export const createDetectedAgentsSlice: StateCreator { - if (refreshPromise) { - return refreshPromise + const context = getLocalPreflightContext(get()) + const contextKey = localPreflightContextKey(context) + if (refreshPromise?.key === contextKey) { + return refreshPromise.promise } set({ isRefreshingAgents: true }) const pending = window.api.preflight - .refreshAgents() + .refreshAgents(context) .then((result) => { const typed = result.agents as TuiAgent[] set({ @@ -89,7 +122,8 @@ export const createDetectedAgentsSlice: StateCreator { @@ -99,7 +133,7 @@ export const createDetectedAgentsSlice: StateCreator { refreshPromise = null }) - refreshPromise = pending + refreshPromise = { key: contextKey, promise: pending } return pending },