mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
* 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
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
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)
|
||
}
|