Fix WSL tool detection on Windows (#2218)

This commit is contained in:
Jinwoo Hong 2026-05-18 01:24:45 -04:00 committed by GitHub
parent 4b7c9b7213
commit e59afeacc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 390 additions and 58 deletions

View File

@ -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<typeof WslModule>()),
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) => {

View File

@ -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,

View File

@ -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<string, (_event?: unknown, args?: { force?: boolean }) => Promise<unknown>>
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

View File

@ -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<boolean> {
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<boolean> {
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<boolean> {
// 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<boolean> {
async function isCommandOnPath(command: string, wslDistro?: string): Promise<boolean> {
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<string[]> {
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<string[]> {
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<RefreshAgentsResult> {
export async function refreshShellPathAndDetectAgents(
context?: PreflightRuntimeContext
): Promise<RefreshAgentsResult> {
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<boolean> {
async function isGhAuthenticated(wslDistro?: string): Promise<boolean> {
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<boolean> {
// 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<boolean> {
async function isGlabAuthenticated(wslDistro?: string): Promise<boolean> {
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<boolean> {
}
}
export async function runPreflightCheck(force = false): Promise<PreflightStatus> {
if (cached && !force) {
export async function runPreflightCheck(
force = false,
context?: PreflightRuntimeContext
): Promise<PreflightStatus> {
const cacheable = !getPreflightWslDistro(context)
if (cacheable && cached && !force) {
return cached
}
@ -182,46 +234,56 @@ export async function runPreflightCheck(force = false): Promise<PreflightStatus>
_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<PreflightStatus> => {
return runPreflightCheck(args?.force)
async (
_event,
args?: PreflightRuntimeContext & { force?: boolean }
): Promise<PreflightStatus> => {
return runPreflightCheck(args?.force, args)
}
)
ipcMain.handle('preflight:detectAgents', async (): Promise<string[]> => {
return detectInstalledAgents()
})
ipcMain.handle(
'preflight:detectAgents',
async (_event, args?: PreflightRuntimeContext): Promise<string[]> => {
return detectInstalledAgents(args)
}
)
ipcMain.handle('preflight:refreshAgents', async (): Promise<RefreshAgentsResult> => {
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

View File

@ -86,6 +86,49 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string {
// ─── WSL home directory resolution ──────────────────────────────────
const wslHomeCache = new Map<string, string>()
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.

View File

@ -389,9 +389,9 @@ export type RefreshAgentsResult = {
}
export type PreflightApi = {
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
detectAgents: () => Promise<string[]>
refreshAgents: () => Promise<RefreshAgentsResult>
check: (args?: { force?: boolean; wslDistro?: string | null }) => Promise<PreflightStatus>
detectAgents: (args?: { wslDistro?: string | null }) => Promise<string[]>
refreshAgents: (args?: { wslDistro?: string | null }) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
}

View File

@ -1231,9 +1231,10 @@ const api = {
}
linear: { connected: boolean }
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents'),
refreshAgents: (): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents'),
detectAgents: (args?: { wslDistro?: string | null }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectAgents', args),
refreshAgents: (args?: { wslDistro?: string | null }): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args)
},

View File

@ -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<TuiAgent[]> | null = null
let refreshPromise: Promise<TuiAgent[]> | null = null
let detectPromise: { key: string; promise: Promise<TuiAgent[]> } | null = null
let refreshPromise: { key: string; promise: Promise<TuiAgent[]> } | null = null
let detectedContextKey: string | null = null
const remoteDetectPromises = new Map<string, Promise<TuiAgent[]>>()
export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedAgentsSlice> = (
@ -46,19 +74,22 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
pathFailureReason: null,
ensureDetectedAgents: () => {
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<AppState, [], [], DetectedA
set({ isDetectingAgents: false })
return [] as TuiAgent[]
})
detectPromise = pending
detectPromise = { key: contextKey, promise: pending }
return pending
},
refreshDetectedAgents: () => {
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<AppState, [], [], DetectedA
})
// Why: once refresh has run, treat its result as the current detection
// snapshot so `ensureDetectedAgents` short-circuits.
detectPromise = Promise.resolve(typed)
detectedContextKey = contextKey
detectPromise = { key: contextKey, promise: Promise.resolve(typed) }
return typed
})
.catch(() => {
@ -99,7 +133,7 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
.finally(() => {
refreshPromise = null
})
refreshPromise = pending
refreshPromise = { key: contextKey, promise: pending }
return pending
},