diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index 3038d17e40d..3f385dab785 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -235,7 +235,7 @@ describe('resolveCliCommands', () => { delete process.env.Path }) - it('resolves a batch from PATH and install directories', () => { + it('resolves a batch from PATH and install directories', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) const pathDir = join(root, 'bin') const pathClaude = join(pathDir, 'claude') @@ -245,7 +245,7 @@ describe('resolveCliCommands', () => { makeExecutable(nvmCodex) makeExecutable(pnpmOpencode) - const resolved = resolveCliCommands(['claude', 'codex', 'opencode', 'missing'], { + const resolved = await resolveCliCommands(['claude', 'codex', 'opencode', 'missing'], { platform: 'darwin', pathEnv: pathDir, homePath: root @@ -257,13 +257,13 @@ describe('resolveCliCommands', () => { expect(resolved.get('missing')).toBe('missing') }) - it('deduplicates command names in the returned map', () => { + it('deduplicates command names in the returned map', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) const pathDir = join(root, 'bin') const pathClaude = join(pathDir, 'claude') makeExecutable(pathClaude) - const resolved = resolveCliCommands(['claude', 'claude'], { + const resolved = await resolveCliCommands(['claude', 'claude'], { platform: 'linux', pathEnv: pathDir, homePath: root @@ -275,7 +275,7 @@ describe('resolveCliCommands', () => { it.skipIf(process.platform === 'win32')( 'skips non-runnable batch candidates and continues through PATH', - () => { + async () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) const badDir = join(root, 'bad-bin') const goodDir = join(root, 'good-bin') @@ -283,7 +283,7 @@ describe('resolveCliCommands', () => { const goodCodex = join(goodDir, 'codex') makeExecutable(goodCodex) - const resolved = resolveCliCommands(['codex'], { + const resolved = await resolveCliCommands(['codex'], { platform: 'darwin', pathEnv: [badDir, goodDir].join(delimiter), homePath: root @@ -293,13 +293,13 @@ describe('resolveCliCommands', () => { } ) - it('matches Windows executable names case-insensitively, including .com', () => { + it('matches Windows executable names case-insensitively, including .com', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) const pathDir = join(root, 'bin') const commandPath = join(pathDir, 'CODEX.COM') makeExecutable(commandPath) - const resolved = resolveCliCommands(['codex'], { + const resolved = await resolveCliCommands(['codex'], { platform: 'win32', pathEnv: pathDir, homePath: root diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index cc4fb267f4f..8bfc11919d8 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,4 +1,5 @@ import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' +import { access, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { delimiter, dirname, join } from 'node:path' @@ -73,35 +74,49 @@ function findFirstExecutable( return null } -function resolveCommandsInDirectories( +// Why: bulk resolution runs on the Electron main process at startup, where a +// slow PATH entry (dead network mount, FUSE dir) must stall this promise, not +// the event loop — so this variant is async end to end. +async function resolveCommandsInDirectories( platform: NodeJS.Platform, directories: readonly string[], executableNamesByCommand: ReadonlyMap, resolved: Map -): void { +): Promise { for (const directory of directories) { - let entries: string[] + let entries: string[] | null try { - entries = readdirSync(directory) - } catch { - continue + entries = await readdir(directory) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') { + continue + } + // Why: search-only directories (x without r) fail readdir but still + // resolve by direct candidate probing, like the per-command resolver. + entries = null } - const entryByLookupName = new Map( - entries.map((entry) => [platform === 'win32' ? entry.toLowerCase() : entry, entry]) - ) + const entryByLookupName = entries + ? new Map(entries.map((entry) => [platform === 'win32' ? entry.toLowerCase() : entry, entry])) + : null for (const [commandName, executableNames] of executableNamesByCommand) { if (resolved.has(commandName)) { continue } for (const executableName of executableNames) { - const entry = entryByLookupName.get( - platform === 'win32' ? executableName.toLowerCase() : executableName - ) - if (!entry) { - continue + let candidate: string + if (entryByLookupName) { + const entry = entryByLookupName.get( + platform === 'win32' ? executableName.toLowerCase() : executableName + ) + if (!entry) { + continue + } + candidate = join(directory, entry) + } else { + candidate = join(directory, executableName) } - const candidate = join(directory, entry) - if (isRunnableCommand(platform, candidate)) { + if (await isRunnableCommandAsync(platform, candidate)) { resolved.set(commandName, candidate) break } @@ -128,6 +143,25 @@ function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolea } } +async function isRunnableCommandAsync( + platform: NodeJS.Platform, + candidate: string +): Promise { + try { + const stats = await stat(candidate) + if (!stats.isFile()) { + return false + } + if (platform === 'win32') { + return true + } + await access(candidate, constants.X_OK) + return true + } catch { + return false + } +} + function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { const directories = [ join(homePath, '.volta', 'bin'), @@ -221,10 +255,10 @@ export function resolveCliCommand( return versionManagerCandidate ?? commandName } -export function resolveCliCommands( +export async function resolveCliCommands( commandNames: readonly string[], options: ResolveCommandOptions = {} -): Map { +): Promise> { const platform = options.platform ?? process.platform const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null const pathDirectories = splitPath(pathEnv) @@ -245,9 +279,14 @@ export function resolveCliCommands( const resolved = new Map() // Why: agent detection resolves many commands at once. Reading each PATH - // directory once avoids hundreds of synchronous failed stat calls at startup. - resolveCommandsInDirectories(platform, pathDirectories, executableNamesByCommand, resolved) - resolveCommandsInDirectories(platform, installDirectories, executableNamesByCommand, resolved) + // directory once avoids hundreds of failed stat calls at startup. + await resolveCommandsInDirectories(platform, pathDirectories, executableNamesByCommand, resolved) + await resolveCommandsInDirectories( + platform, + installDirectories, + executableNamesByCommand, + resolved + ) for (const commandName of commandNamesUnique) { if (!resolved.has(commandName)) { resolved.set(commandName, commandName) diff --git a/src/main/ipc/local-agent-command-detection.ts b/src/main/ipc/local-agent-command-detection.ts index 8133030fb1f..5f24b894919 100644 --- a/src/main/ipc/local-agent-command-detection.ts +++ b/src/main/ipc/local-agent-command-detection.ts @@ -3,9 +3,14 @@ import { resolveCliCommands } from '../codex-cli/command' // Why: one bulk filesystem pass avoids a `which`/`where` subprocess per agent; // spawning that fan-out can hold Electron's main loop for over a second on macOS. -export function detectLocalCommands(commands: readonly string[]): Set { +// The pass itself must stay async — it runs on the main process, where a slow +// PATH entry (dead network mount) would otherwise freeze the whole app. +export async function detectLocalCommands(commands: readonly string[]): Promise> { + if (commands.length === 0) { + return new Set() + } try { - const resolvedCommands = resolveCliCommands(commands) + const resolvedCommands = await resolveCliCommands(commands) return new Set( commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) ) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index c5f2fa26cc9..9962b0f81e5 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -112,7 +112,7 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): KNOWN_TUI_AGENT_DETECTION_COMMANDS, process.platform ) - const foundCommands = detectLocalCommands(probeCommands) + const foundCommands = await detectLocalCommands(probeCommands) const agents = resolveDetectedTuiAgentIds( KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1f5a90cead1..a4ac1ffeec2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -60,7 +60,10 @@ import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' -import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' +import { + getSkillFreshnessUpdateDialogRequest, + subscribeSkillFreshnessUpdateDialog +} from './components/skills/skill-freshness-update-dialog' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -306,6 +309,33 @@ function WindowControls(): React.JSX.Element { ) } +const SkillFreshnessUpdateDialog = lazy( + () => + import('./components/skills/SkillFreshnessUpdateDialog').then((module) => ({ + default: module.SkillFreshnessUpdateDialog + })), + { reloadKey: 'skill-freshness-update-dialog' } +) + +// Why: the dialog statically pulls the inline terminal (xterm/WebGL) and the +// Markdown engine; mount it only once an open request exists so those modules +// stay off the startup path. +function SkillFreshnessUpdateDialogGate(): React.JSX.Element | null { + const requested = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + if (!requested) { + return null + } + return ( + + + + ) +} + const Landing = lazy(() => import('./components/Landing')) const WorktreeCreationPanel = lazy( () => import('./components/worktree-creation/WorktreeCreationPanel') @@ -2864,7 +2894,7 @@ function App(): React.JSX.Element { surface="overlay" compact > - +