Files
orca/src/shared/node-cli-command-resolution.ts
T
Brennan Benson ad4bb83fa1 Open the chat model picker without walking every worktree (#38660)
* fix(native-chat): list Claude and Codex models per account, not per folder

Opening the chat model picker rebuilt every worktree row to work out which
folder the read was for, because Claude and Codex model lists were keyed by
folder. Only the default model/effort depends on the project, so lists are now
keyed by account, WSL and the resolved CLI invocation and listed from the
account folder; the CLI is still resolved where the chat would launch. The
selected workspace is derived directly from its selector, and its own project
settings decide whether the account's default model and effort are shown.
The worktree projection and its cache are removed.

* test: merge duplicate node:os import

* fix(native-chat): list from Orca's probe folder, keep cwd-relative CLIs in their folder, one project-settings pass

- List Claude/Codex models from Orca's empty probe folder so a home folder's settings never read as another account's project settings.
- A command line naming files relative to the chat's folder lists there and is keyed to it.
- Read project settings once per binding for both the model and effort answers; per-model effort-only Claude settings keep the account model.
- A running chat's catalog withholds the account's configured choice where its project may pick its own.
- Bound the picker's workspace resolution so a stalled floating folder cannot hold it.

* fix(native-chat): catch quoted ./ arguments, list prewarm from Orca's probe folder, fake-timer the shared-listing test

* perf(native-chat): parse each project settings file once for both model and effort

* test: catalog e2e expectations list from Orca's probe folder; phone saturation keys per folder

* fix(native-chat): share a listing while any reader wants it; list custom command lines in the chat's folder; resolve a floating folder within the read's own wait

- A model listing shared by several folders keeps running while any reader still waits on it, queued or running, and stops (never starts) once the last one gives up.
- A Claude or Codex command line with user arguments or a relative command lists in the chat's folder; Claude reads only user settings there so project settings never narrow the account's list.
- A waiting read resolves a floating folder within its own deadline; a read that doesn't wait returns at once and starts the listing when the folder resolves.

* fix(native-chat): every reader of a shared model listing owns it until its own deadline; floating reads list their folder within their own budget

- Readers of the same request share one lookup that lives until the last reader's deadline, so later readers neither repeat CLI/settings setup nor lose the listing when an earlier one ends or is cancelled.
- A shared listing's requests carry no single reader's deadline: the provider timeout bounds them and the last reader leaving stops them.
- A floating picker read that doesn't wait starts its folder's listing under its own deadline and retirement, and says the listing is in progress so the picker follows with a waiting read.

* fix(native-chat): a shared model listing survives the chat that started it; unreadable workspaces list as the account

- Each reader of a shared lookup fences its own request, so one chat closing never ends another chat's listing; the lookup's budget is the latest deadline among readers still waiting, and its listeners are released when it ends.
- A picker for a workspace this host can't inspect (remote, WSL, ambiguous, stalled) lists as the account does from Orca's probe folder, never from wherever Orca itself runs.

* refactor(native-chat): readers of one model request share an ownerless setup and each own only their claim and the listing

- Same-request readers share one setup (folder, account, CLI scope and the folder's own default settings) that belongs to no reader: its budget is fixed when it starts, it ends with the service or the agent's retirement, and it is reused while it runs and while the listing it found runs.
- Each reader keeps its own claim, deadline and cancellation; a binding holds scope facts only and never cancels another reader's claim or a shared listing, so one chat closing never ends another chat's listing.
- A floating picker read that doesn't wait no longer asks to list the unresolved folder.

* fix(native-chat): shared catalog setups end on their budget or a workspace change, readers settle on cancel, and defaults and account seeding happen once

- A shared setup that runs out of its budget, or whose workspace or agent is retired, answers nothing at once and leaves the cache, so a reader with time left resolves afresh (a moved folder never reuses the old path).
- Cancelling a reader, or stopping the service, ends its wait at once.
- A folder's own default settings apply to its binding whenever they arrive; the account list is seeded once per shared setup, not per reader.
- A reader shares a setup only with readers whose own chat agrees on whether it may list.

* fix(native-chat): a settled catalog setup serves its whole listing, and a folder's default settings stay bounded and reach every reader

- A setup that has answered stays reusable while the listing it found runs, past its first reader's deadline, until it is ended or its agent retired, so later readers don't redo CLI and project-settings work.
- The setup's budget holds until the folder's default settings have answered too; settings that outlive it are read again while a reader still has time, and apply to the request's current binding whichever reader asked.

* refactor(native-chat): the catalog service owns each model refresh within one fixed budget; pickers only wait

A refresh (account, folder and CLI resolution, the folder's default settings,
admission and the listing) now belongs to the catalog service with one 30s
budget fixed before setup. Readers join it and wait only until their own
deadline or cancellation; none extends, shortens or ends it. Expiry withdraws
queued listings, stops the process and drops late answers. Defaults apply only
to the refresh's own binding, so a moved folder's late settings never describe
the new folder. A waiting read that used its whole budget is final.

Replaces per-reader claims, shared setups and reader-counted listing demand.

* fix(native-chat): retiring an agent or a chat's folder ends its catalog refresh at once, and a shared setup never rechecks the chat that started it

- Retiring an agent ends a refresh still stalled in setup, so a waiting picker
  answers at once instead of after the refresh's budget.
- A listing that runs in a chat's own folder stops, and its answer is dropped,
  when that folder's command context retires; listings shared across folders
  stay independent.
- Scope resolution uses only the passivity its readers had when they asked,
  so another chat's record going away mid-setup no longer turns it saved-only.
- A reader that stops waiting unsubscribes from the listing at once.

* fix(native-chat): one workspace's retirement no longer stops a catalog listing another workspace shares

A listing is keyed by the folder it lists in and the CLI invocation, so moving
one workspace never makes it describe the wrong folder; stopping it there
cancelled a listing another workspace on the same folder still needed. Agent
retirement and shutdown still stop listings at once.

* test(native-chat): a folder change keeps another workspace's shared catalog listing, and never answers the new folder with the old one's

* test(native-chat): a folder agent's listing survives another workspace on its folder moving

* test(native-chat): type the shared-setup harness's held stages without casts

* fix(native-chat): Claude and Codex pickers show the account's default in every folder

The picker no longer reads a repo's own model or effort settings for Claude and
Codex: they show the account's list and default everywhere, and the CLI still
applies the repo's settings at launch. OpenCode, OMP and Pi keep their project
checks. The model/effort split and the peek withholding go back to main's shapes.

* fix(native-chat): a Codex listing in the chat's folder never loads that folder's project config

A Codex command line that may name files in the chat's folder still lists there,
but now marks that folder and its parents untrusted for the listing, so a repo's
own model catalog can't replace the account's. The exported project-settings
check also answers no for Claude and Codex.

* fix(native-chat): Codex lists models only from Orca's probe folder

A Codex listing no longer starts in the chat's folder for custom or relative
command lines, so a repo's own config can never change the picker's list, as
other Codex hosts do. Chats still launch with the user's command line and folder.
Drops the untrusted-folder override.

* fix(native-chat): Codex listings never read project config above Orca's folder

With no project-root markers, Codex reads project config only from the listing's
own folder, so a trusted repo that contains Orca's profile can't supply the
account's model list.

* docs(native-chat): say what Codex listing isolation covers, and what managed config can still restore
2026-11-06 16:59:49 -08:00

289 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { homedir } from 'node:os'
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
import { firstWindowsPathEnvKey, isNativeExecutable } from './cli-runtime-pairing-inputs'
import { getFnmDefaultAliasDirectories } from './fnm-default-alias-dirs'
import { getNvmVersionDirectories, nvmVersionDirectories } from './nvm-version-directories'
import { isRunnableCommand, runCliFsReadsSync, type CliFsReads } from './cli-fs-reads'
import { getSystemCliInstallDirectories } from './system-cli-install-dirs'
export type ResolveCommandOptions = {
pathEnv?: string | null
platform?: NodeJS.Platform
homePath?: string
/** Skip the PATH scan; the caller already established absence there (agent detection batches it). */
skipPathSearch?: boolean
}
export function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
if (platform === 'win32') {
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
}
return [commandName]
}
function splitPath(
pathEnv: string | null | undefined,
pathDelimiter: string = delimiter
): string[] {
if (!pathEnv) {
return []
}
return pathEnv
.split(pathDelimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}
/** Like execvp, relative and empty PATH entries search from the launch cwd. */
export function pathEnvRelativeTo(pathEnv: string | undefined, cwd: string): string | undefined {
return pathEnv
?.split(delimiter)
.map((entry) => (isAbsolute(entry) ? entry : resolve(cwd, entry)))
.join(delimiter)
}
/** Whether a command line may name files relative to where it runs: a relative command, or any
* user-configured argument (no rule can tell `settings.json` or `--env-file=work env` from a
* word), so it may mean something else from another working directory. */
function invocationDependsOnCwd(command: string, userArgs: readonly string[]): boolean {
return userArgs.length > 0 || (!isAbsolute(command) && /[\\/]/.test(command))
}
/** Where a catalog listing runs: `listIn`, unless the command line may mean something else
* outside the chat's `launchDirectory`. */
export function catalogListingDirectory(
command: string,
userArgs: readonly string[],
listIn: string | undefined,
launchDirectory: string | undefined
): string | undefined {
return listIn &&
launchDirectory &&
launchDirectory !== listIn &&
invocationDependsOnCwd(command, userArgs)
? launchDirectory
: listIn
}
/** The environment for a child that runs elsewhere but must find what it would from `cwd`. */
export function envWithPathRelativeTo<T extends Record<string, string | undefined>>(
env: T,
cwd: string
): T {
const key = env.PATH !== undefined ? 'PATH' : env.Path !== undefined ? 'Path' : null
return key ? { ...env, [key]: pathEnvRelativeTo(env[key], cwd) } : env
}
function* firstRunnable(
platform: NodeJS.Platform,
directories: string[],
executableNames: string[]
): CliFsReads<string | null> {
for (const directory of directories) {
for (const executableName of executableNames) {
const path = join(directory, executableName)
if ((yield { kind: 'runnable', platform, path }) === true) {
return path
}
}
}
return null
}
function findFirstExecutable(
platform: NodeJS.Platform,
directories: string[],
executableNames: string[]
): string | null {
return runCliFsReadsSync(firstRunnable(platform, directories, executableNames))
}
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
const directories = [
join(homePath, '.volta', 'bin'),
join(homePath, '.asdf', 'shims'),
...getFnmDefaultAliasDirectories(platform, homePath),
join(homePath, '.local', 'share', 'mise', 'shims')
]
if (platform === 'win32') {
// Native Windows Claude installs here; GUI launches may lack the user PATH entry.
directories.push(join(homePath, '.local', 'bin'))
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
} else {
directories.push(join(homePath, '.local', 'bin'))
// pnpm global bins vary by platform, unlike npm’s ~/.local/bin.
if (platform === 'darwin') {
directories.push(join(homePath, 'Library', 'pnpm'))
} else {
directories.push(join(homePath, '.local', 'share', 'pnpm'))
}
directories.push(join(homePath, '.yarn', 'bin'))
}
directories.push(join(homePath, '.bun', 'bin'))
return directories
}
function getVersionManagerDirectories(
platform: NodeJS.Platform,
homePath: string,
executableNames: string[]
): string[] {
const directories = getBaseVersionManagerDirectories(platform, homePath)
const firstNvmMatch = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
if (firstNvmMatch) {
directories.unshift(dirname(firstNvmMatch))
}
return directories
}
// Share native/WSL ordering: version-manager bins must precede system Homebrew/npm/snap installs.
function* cliInstallDirectories(platform: NodeJS.Platform, homePath: string): CliFsReads<string[]> {
return [
...(yield* nvmVersionDirectories(homePath)),
...getBaseVersionManagerDirectories(platform, homePath),
...getSystemCliInstallDirectories(platform, homePath)
]
}
function getCliInstallDirectories(platform: NodeJS.Platform, homePath: string): string[] {
return runCliFsReadsSync(cliInstallDirectories(platform, homePath))
}
/** PATH entries first; install dirs are listed only once PATH misses. Run with cli-fs-reads. */
export function* cliCommandLookup(
commandName: string,
options: ResolveCommandOptions = {}
): CliFsReads<string> {
const platform = options.platform ?? process.platform
const names = getExecutableNames(platform, commandName)
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
return (
(yield* firstRunnable(platform, splitPath(pathEnv), names)) ??
(yield* firstRunnable(
platform,
yield* cliInstallDirectories(platform, options.homePath ?? homedir()),
names
)) ??
commandName
)
}
export function resolveCliCommand(
commandName: string,
options: ResolveCommandOptions = {}
): string {
return runCliFsReadsSync(cliCommandLookup(commandName, options))
}
/** Resolve a literal override without falling back to an unverified command name. */
export function resolveExecutableCommand(
command: string,
options: ResolveCommandOptions = {}
): string | null {
const platform = options.platform ?? process.platform
const expanded =
command.startsWith('~/') || command.startsWith('~\\')
? join(options.homePath ?? homedir(), command.slice(2))
: command
if (isAbsolute(expanded)) {
return isRunnableCommand(platform, expanded) ? expanded : null
}
// Relative paths depend on the workspace, which a session-less catalog cannot name.
if (expanded.includes('/') || expanded.includes('\\')) {
return null
}
const resolved = resolveCliCommand(expanded, options)
return isAbsolute(resolved) && isRunnableCommand(platform, resolved) ? resolved : null
}
export function resolveCliCommands(
commandNames: readonly string[],
options: ResolveCommandOptions = {}
): Map<string, string> {
const platform = options.platform ?? process.platform
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathDirectories = splitPath(pathEnv)
const homePath = options.homePath ?? homedir()
const installDirectories = getCliInstallDirectories(platform, homePath)
const resolved = new Map<string, string>()
for (const commandName of new Set(commandNames)) {
const executableNames = getExecutableNames(platform, commandName)
const pathCandidate = options.skipPathSearch
? undefined
: findFirstExecutable(platform, pathDirectories, executableNames)
const installCandidate =
pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames)
resolved.set(commandName, installCandidate ?? commandName)
}
return resolved
}
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('codex', options)
}
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('claude', options)
}
/** Pair resolved script CLIs with sibling Node on PATH to preserve their installed native-module ABI (#10932).
* Fallback discovery can find a v20 CLI while PATH leads with v22; env-node shebangs would crash on require.
* Prepend only if sibling Node exists and the CLI is not a compiled binary. */
export function withCliRuntimeOnPath<T extends NodeJS.ProcessEnv>(
commandPath: string,
env: T,
options: Pick<ResolveCommandOptions, 'platform'> = {}
): T {
const platform = options.platform ?? process.platform
if (!isAbsolute(commandPath)) {
return env
}
const commandDirectory = dirname(commandPath)
if (
!findFirstExecutable(platform, [commandDirectory], getExecutableNames(platform, 'node')) ||
isNativeExecutable(commandPath)
) {
return env
}
const pathKey = platform === 'win32' ? firstWindowsPathEnvKey(env) : 'PATH'
const pathDelimiter = platform === 'win32' ? ';' : delimiter
// Missing child PATH inherits Orca’s; pair over that inherited value.
const segments = splitPath(env[pathKey] ?? process.env[pathKey], pathDelimiter)
if (segments[0] === commandDirectory) {
return env
}
const next = [commandDirectory, ...segments.filter((entry) => entry !== commandDirectory)].join(
pathDelimiter
)
const paired = { ...env, [pathKey]: next }
if (platform === 'win32') {
// Windows environment keys ignore case but object spreads do not; remove alternate-case twins that could shadow the new PATH.
for (const name of Object.keys(paired)) {
if (name !== pathKey && name.toLowerCase() === pathKey.toLowerCase()) {
delete (paired as NodeJS.ProcessEnv)[name]
}
}
}
return paired as T
}
// Why: Node-script CLIs need their version-manager sibling `node` on PATH.
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
const platform = options.platform ?? process.platform
const homePath = options.homePath ?? homedir()
const nodeNames = getExecutableNames(platform, 'node')
return getVersionManagerDirectories(platform, homePath, nodeNames)
}