Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Per-engine command labels, explicit binary overrides, and local executable ownership.
*
* User-facing labels stay stable, while process discovery and Cursor recap use file identity so the
* colliding `agent` alias is never assigned from its basename alone.
*/
import { execFileSync } from 'node:child_process'
import { accessSync, constants, readlinkSync, realpathSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, delimiter, isAbsolute, join, normalize, sep } from 'node:path'
import { env } from '../config/env.js'
import { ENGINES, PROCESS_ENGINES, type AgentEngine } from '../engines/types.js'
// Keep this historical import surface for callers, but never duplicate the
// catalog here. `engines/types.ts` is the one iterable source of truth.
export { ENGINES, PROCESS_ENGINES }
/** Public vendor command shown to users and matched by the real-binary smoke matrix. */
export const ENGINE_CLI_COMMANDS: Readonly<Record<AgentEngine, string>> = {
claude: 'claude',
codex: 'codex',
// Cursor and Grok both publish `agent`; Cursor's documented, unambiguous command is
// `cursor-agent`, so creation must use that and keep `agent` for discovery only.
cursor: 'cursor-agent',
opencode: 'opencode',
pi: 'pi',
hermes: 'hermes',
commandcode: 'cmd',
devin: 'devin',
muse: 'muse',
amp: 'amp',
kilo: 'kilo',
grok: 'grok',
agy: 'agy',
copilot: 'copilot',
// A terminal has no command: the pane runs the user's login shell (engineLaunch.ts). The empty
// string is what keeps every "is this binary installed" probe honest — nothing to look for.
terminal: '',
}
export interface ExecutableFileIdentity {
path: string
realPath: string
fileKey: string
}
export type AgentAliasOwner = 'cursor' | 'grok' | 'unknown' | 'conflict'
/**
* Per-machine evidence used only for the colliding `agent` command.
*
* Cursor installs `agent` and `cursor-agent` as aliases of one launcher. Grok Build installs `agent`
* and `grok` as aliases of one compiled binary. Comparing the resolved file is independent of PATH
* order, symlink depth, package-manager prefix, and the name retained in `ps`.
*/
export interface AgentCommandOwnershipSnapshot {
/** Installed command/override identities for every engine, not only the colliding `agent` alias. */
engineFileKeys?: ReadonlyMap<AgentEngine, ReadonlySet<string>>
engineCandidates?: ReadonlyMap<AgentEngine, readonly ExecutableFileIdentity[]>
cursorFileKeys: ReadonlySet<string>
grokFileKeys: ReadonlySet<string>
conflictingFileKeys: ReadonlySet<string>
agentCandidates: readonly ExecutableFileIdentity[]
cursorAgentCandidates: readonly ExecutableFileIdentity[]
grokCandidates: readonly ExecutableFileIdentity[]
}
/** Commands whose public aliases can legitimately identify the same engine. */
export const ENGINE_CLI_ALIASES: Readonly<Record<AgentEngine, readonly string[]>> = {
claude: ['claude'],
codex: ['codex'],
cursor: ['agent', 'cursor-agent'],
opencode: ['opencode'],
pi: ['pi'],
hermes: ['hermes', 'hermes-agent'],
commandcode: ['cmd', 'command-code', 'commandcode'],
devin: ['devin'],
muse: ['muse'],
amp: ['amp'],
kilo: ['kilo', 'kilocode'],
grok: ['grok', 'agent'],
agy: ['agy'],
copilot: ['copilot'],
terminal: [],
}
let interactivePathCache: { shell: string; daemonPath: string; value: string[] } | null = null
/**
* Resolve commands from the same shell startup context used by New Agent.
*
* A GUI/launchd/systemd daemon commonly lacks ~/.local/bin, Homebrew, nvm and vendor installer edits.
* Looking only at its PATH made an installed native binary impossible to identify even though the pane
* launched it successfully. Tests deliberately use their injected PATH and never read a developer's rc.
*/
function interactivePathEntries(): string[] {
if (process.env.NODE_ENV === 'test') return []
const shell = process.env.SHELL
if (!shell || !isAbsolute(shell)) return []
const daemonPath = process.env.PATH ?? ''
if (interactivePathCache?.shell === shell && interactivePathCache.daemonPath === daemonPath) {
return interactivePathCache.value
}
try {
const flag = basename(shell).toLowerCase() === 'zsh' ? '-lic' : '-ic'
const marker = '__HARNESS_ENGINE_PATH__='
const stdout = execFileSync(shell, [flag, `printf '\n${marker}%s\n' "$PATH"`], {
encoding: 'utf8',
timeout: 5_000,
stdio: ['ignore', 'pipe', 'ignore'],
})
// Shell startup files are allowed to print banners. Read only our final marked line.
const path = stdout.split('\n').reverse().find((line) => line.startsWith(marker))?.slice(marker.length) ?? ''
const value = path.split(delimiter).filter(Boolean)
interactivePathCache = { shell, daemonPath, value }
return value
} catch {
interactivePathCache = { shell, daemonPath, value: [] }
return []
}
}
/** Only what a spawned child inherits. A hit here is safe to return as a bare name. */
function processPathEntries(): string[] {
return (process.env.PATH ?? '').split(delimiter).filter(Boolean)
}
function pathEntries(): string[] {
return [...new Set([
...processPathEntries(),
...interactivePathEntries(),
])]
}
function looksLikePath(command: string): boolean {
return isAbsolute(command) || command.includes('/') || command.includes('\\')
}
/** Resolve every visible command candidate, not only the first PATH hit. */
function commandCandidates(command: string): ExecutableFileIdentity[] {
const paths = looksLikePath(command)
? [command]
: pathEntries().map((entry) => join(entry, command))
const seen = new Set<string>()
const result: ExecutableFileIdentity[] = []
for (const path of paths) {
try { accessSync(path, constants.X_OK) } catch { continue }
const identity = executableFileIdentity(path)
if (!identity || seen.has(identity.path)) continue
seen.add(identity.path)
result.push(identity)
}
return result
}
/** Stable while a file exists; deliberately local-only and never sent over the wire. */
export function executableFileIdentity(path: string): ExecutableFileIdentity | null {
try {
// Stat through the supplied path first. `/proc/<pid>/exe` keeps the running inode reachable even
// after an auto-updater replaces its pathname; realpath may then end in "(deleted)" and be
// unstatable despite the process image still being valid.
const stat = statSync(path)
if (!stat.isFile()) return null
let realPath: string
try { realPath = realpathSync(path) } catch {
try { realPath = readlinkSync(path) } catch { realPath = normalize(path) }
}
return { path: normalize(path), realPath, fileKey: `${String(stat.dev)}:${String(stat.ino)}` }
} catch {
return null
}
}
function cursorInstallLayout(path: string): boolean {
const normalized = path.split(sep).join('/')
return /\/cursor-agent\/versions\/[^/]+\/cursor-agent$/.test(normalized)
}
function addAll(target: Set<string>, identities: readonly ExecutableFileIdentity[]): void {
for (const identity of identities) target.add(identity.fileKey)
}
function vendorFallbackCommands(engine: AgentEngine): string[] {
const home = homedir()
switch (engine) {
case 'claude': return [join(home, '.local', 'bin', 'claude')]
case 'codex': return [join(home, '.local', 'bin', 'codex')]
case 'cursor': return [join(home, '.local', 'bin', 'cursor-agent'), join(home, '.local', 'bin', 'agent')]
case 'opencode': return [join(home, '.opencode', 'bin', 'opencode')]
case 'hermes': return [join(home, '.local', 'bin', 'hermes')]
case 'devin': return [join(home, '.local', 'bin', 'devin')]
case 'muse': return [join(home, '.local', 'bin', 'muse')]
case 'amp': return [join(home, '.amp', 'bin', 'amp'), join(home, '.local', 'bin', 'amp')]
case 'kilo': return [join(home, '.kilo', 'bin', 'kilo'), join(home, '.local', 'bin', 'kilo')]
case 'grok': return [join(env.GROK_HOME, 'bin', 'grok'), join(home, '.local', 'bin', 'grok')]
case 'agy': return [join(home, '.local', 'bin', 'agy')]
case 'copilot': return [join(home, '.local', 'bin', 'copilot')]
default: return []
}
}
function uniqueIdentities(identities: readonly ExecutableFileIdentity[]): ExecutableFileIdentity[] {
const seen = new Set<string>()
return identities.filter((identity) => {
const key = `${identity.path}\0${identity.fileKey}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
/** Build once per process-table pass; callers pass the snapshot through every row comparison. */
export function engineBinaryOwnershipSnapshot(): AgentCommandOwnershipSnapshot {
const engineCandidates = new Map<AgentEngine, ExecutableFileIdentity[]>()
const engineFileKeys = new Map<AgentEngine, Set<string>>()
for (const engine of PROCESS_ENGINES) {
const configured = enginePathOverride(engine)
const commands = [
...(configured ? [configured] : []),
...ENGINE_CLI_ALIASES[engine],
...vendorFallbackCommands(engine),
]
const candidates = uniqueIdentities(commands.flatMap(commandCandidates))
engineCandidates.set(engine, candidates)
engineFileKeys.set(engine, new Set(candidates.map((candidate) => candidate.fileKey)))
}
const agentCandidates = commandCandidates('agent')
const cursorAgentCandidates = commandCandidates('cursor-agent')
const grokCandidates = [...commandCandidates('grok')]
const cursorFileKeys = new Set<string>()
const grokFileKeys = new Set<string>()
addAll(cursorFileKeys, cursorAgentCandidates)
addAll(grokFileKeys, grokCandidates)
// Existing explicit overrides are ownership declarations, but a declaration that resolves to the
// other vendor's canonical binary becomes a conflict below instead of silently winning by basename.
if (env.CURSOR_PATH) addAll(cursorFileKeys, commandCandidates(env.CURSOR_PATH))
if (env.GROK_PATH) addAll(grokFileKeys, commandCandidates(env.GROK_PATH))
// Grok's own state-root override already describes its standard installer root. This seed keeps
// discovery correct when the daemon PATH differs from an interactive tmux shell's PATH.
const grokHomeBin = commandCandidates(join(env.GROK_HOME, 'bin', 'grok'))[0]
if (grokHomeBin) {
grokFileKeys.add(grokHomeBin.fileKey)
if (!grokCandidates.some((candidate) => candidate.path === grokHomeBin.path)) grokCandidates.push(grokHomeBin)
}
// Cursor's launcher target carries a vendor-specific package layout even when `cursor-agent` itself
// is absent from the daemon PATH (for example, a pane inherited a newer interactive shell PATH).
for (const candidate of agentCandidates) {
if (cursorInstallLayout(candidate.realPath)) cursorFileKeys.add(candidate.fileKey)
}
const conflictingFileKeys = new Set(
[...cursorFileKeys].filter((fileKey) => grokFileKeys.has(fileKey)),
)
// `agent` is intentionally present in both public alias lists. Replace the generic sets with the
// vendor-qualified ownership derived above so one PATH entry never makes both engines match.
engineFileKeys.set('cursor', cursorFileKeys)
engineFileKeys.set('grok', grokFileKeys)
engineCandidates.set('cursor', (engineCandidates.get('cursor') ?? []).filter((candidate) =>
cursorFileKeys.has(candidate.fileKey) && !conflictingFileKeys.has(candidate.fileKey)))
engineCandidates.set('grok', (engineCandidates.get('grok') ?? []).filter((candidate) =>
grokFileKeys.has(candidate.fileKey) && !conflictingFileKeys.has(candidate.fileKey)))
return {
engineFileKeys,
engineCandidates,
cursorFileKeys,
grokFileKeys,
conflictingFileKeys,
agentCandidates,
cursorAgentCandidates,
grokCandidates,
}
}
/** Historical name retained while callers migrate; the snapshot now covers every engine. */
export function agentCommandOwnershipSnapshot(): AgentCommandOwnershipSnapshot {
return engineBinaryOwnershipSnapshot()
}
/** Engines owning the observed executable image. Multiple results mean the install itself is ambiguous. */
export function engineFileOwners(
fileKeys: readonly (string | null | undefined)[],
snapshot: AgentCommandOwnershipSnapshot,
): AgentEngine[] {
if (!snapshot.engineFileKeys) return []
return PROCESS_ENGINES.filter((engine) => fileKeys.some((fileKey) => !!fileKey && snapshot.engineFileKeys!.get(engine)?.has(fileKey)))
}
export function agentAliasOwner(
fileKeys: readonly (string | null | undefined)[],
snapshot: AgentCommandOwnershipSnapshot,
): AgentAliasOwner {
let cursor = false
let grok = false
for (const fileKey of fileKeys) {
if (!fileKey) continue
if (snapshot.conflictingFileKeys.has(fileKey)) return 'conflict'
if (snapshot.cursorFileKeys.has(fileKey)) cursor = true
if (snapshot.grokFileKeys.has(fileKey)) grok = true
}
if (cursor && grok) return 'conflict'
if (cursor) return 'cursor'
if (grok) return 'grok'
return 'unknown'
}
/**
* Cursor recap must never spawn Grok merely because Grok won the `agent` name in this daemon's PATH.
* Return null when the machine offers no verified Cursor command; the worker then fails explicitly.
*/
export function cursorRuntimeBin(snapshot = agentCommandOwnershipSnapshot()): string | null {
if (env.CURSOR_PATH) {
const configured = commandCandidates(env.CURSOR_PATH)[0]
if (!configured) return null
return agentAliasOwner([configured.fileKey], snapshot) === 'grok'
|| agentAliasOwner([configured.fileKey], snapshot) === 'conflict'
? null
: env.CURSOR_PATH
}
const agent = snapshot.agentCandidates[0]
if (agent && agentAliasOwner([agent.fileKey], snapshot) === 'cursor') return 'agent'
const cursorAgent = snapshot.cursorAgentCandidates[0]
if (cursorAgent && agentAliasOwner([cursorAgent.fileKey], snapshot) === 'cursor') return 'cursor-agent'
return null
}
/**
* Where OpenCode is, for the processes this daemon spawns.
*
* It installs itself to `~/.opencode/bin` and puts that on PATH by editing the user's SHELL profile —
* which a GUI-launched process never reads. This daemon is started by the desktop app, so `opencode`
* is simply not on its PATH: the recap pool logged `spawn opencode ENOENT` once a second and the
* model catalog came back empty every time, while the same command worked fine from a terminal.
*
* ONE answer for the whole daemon, because there were two and they disagreed: the recap path honoured
* `OPENCODE_PATH` and the catalog fetch did not.
*
* `OPENCODE_PATH` wins; a bare `opencode` remains the last answer, so a genuinely missing install
* still fails with the message it always did.
*/
export function opencodeBin(): string {
if (env.OPENCODE_PATH) return env.OPENCODE_PATH
// THE CHILD'S PATH FIRST, and only that one may answer with a bare name.
//
// `pathEntries()` searches the interactive login shell's PATH as well as this
// process's, and a spawned child inherits only the latter. A hit in a
// shell-only directory therefore resolved here and then failed to spawn with
// `ENOENT` — which is how the fallback below, added for exactly this bug, was
// being skipped: the loop kept FINDING opencode and kept returning a name the
// child could not resolve. Measured on a live daemon: 44 entries in the
// process PATH, 48 in the interactive one, and `~/.opencode/bin` among the
// four that only the shell knows about.
for (const entry of processPathEntries()) {
try {
accessSync(join(entry, 'opencode'), constants.X_OK)
return 'opencode'
} catch { /* keep looking */ }
}
// Anywhere else it is reachable, it travels as an absolute path.
for (const entry of pathEntries()) {
const candidate = join(entry, 'opencode')
try {
accessSync(candidate, constants.X_OK)
return candidate
} catch { /* keep looking */ }
}
const installed = join(homedir(), '.opencode', 'bin', 'opencode')
try {
accessSync(installed, constants.X_OK)
return installed
} catch {
return 'opencode'
}
}
/** Resolve an installed executable for real-engine verification without trusting a colliding alias. */
export function installedEngineBin(
engine: AgentEngine,
snapshot = agentCommandOwnershipSnapshot(),
): string | null {
const installed = snapshot.engineCandidates?.get(engine)
if (installed?.length && engine !== 'cursor' && engine !== 'grok') return installed[0].path
const command = engine === 'cursor' ? cursorRuntimeBin(snapshot) : engineBin(engine)
if (!command) return null
const candidates = commandCandidates(command)
if (engine === 'cursor' || engine === 'grok') {
return candidates.find((candidate) => agentAliasOwner([candidate.fileKey], snapshot) === engine)?.path ?? null
}
return candidates[0]?.path ?? null
}
/** Existing env override only; process matching must not treat a default basename as ownership proof. */
export function enginePathOverride(engine: AgentEngine): string | undefined {
switch (engine) {
case 'claude': return env.CLAUDE_PATH
case 'codex': return process.env.CODEX_PATH
case 'cursor': return env.CURSOR_PATH
case 'opencode': return env.OPENCODE_PATH
case 'pi': return env.PI_PATH
case 'hermes': return env.HERMES_PATH
case 'commandcode': return env.COMMANDCODE_PATH
case 'devin': return env.DEVIN_PATH
case 'muse': return env.MUSE_PATH
case 'amp': return env.AMP_PATH
case 'kilo': return env.KILO_PATH
case 'grok': return env.GROK_PATH
case 'agy': return env.AGY_PATH
case 'copilot': return env.COPILOT_PATH
case 'terminal': return undefined
}
}
export function engineBin(engine: AgentEngine): string {
switch (engine) {
case 'claude': return env.CLAUDE_PATH || ENGINE_CLI_COMMANDS.claude
case 'codex': return process.env.CODEX_PATH || ENGINE_CLI_COMMANDS.codex
// `cursor-agent` is the vendor-documented command; `agent` remains a discovery alias only
// because Grok publishes the same basename.
case 'cursor': return env.CURSOR_PATH || ENGINE_CLI_COMMANDS.cursor
case 'opencode': return env.OPENCODE_PATH || ENGINE_CLI_COMMANDS.opencode
case 'pi': return env.PI_PATH || ENGINE_CLI_COMMANDS.pi
case 'hermes': return env.HERMES_PATH || ENGINE_CLI_COMMANDS.hermes
// command-code's package `bin` lists `cmd` first and its README uses it.
case 'commandcode': return env.COMMANDCODE_PATH || ENGINE_CLI_COMMANDS.commandcode
case 'devin': return env.DEVIN_PATH || ENGINE_CLI_COMMANDS.devin
case 'muse': return env.MUSE_PATH || ENGINE_CLI_COMMANDS.muse
case 'amp': return env.AMP_PATH || ENGINE_CLI_COMMANDS.amp
case 'kilo': return env.KILO_PATH || ENGINE_CLI_COMMANDS.kilo
case 'grok': return env.GROK_PATH || ENGINE_CLI_COMMANDS.grok
case 'agy': return env.AGY_PATH || ENGINE_CLI_COMMANDS.agy
case 'copilot': return env.COPILOT_PATH || ENGINE_CLI_COMMANDS.copilot
// Never launched by name — `buildEngineLaunchArgv` builds the shell argv itself.
case 'terminal': return ENGINE_CLI_COMMANDS.terminal
}
}
52a6c9020This file is pinned to the selected release. Download the original to open it in your design tool.