Files
orca/src/shared/agent-process-recognition.ts
T
NeilandWooseong Kim af13da5db7 Recognize Build terminals and preserve Windows confirmation key routing
Recognize dsb terminal identity and preserve foreground Windows confirmation key routing.

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
2026-10-03 20:23:33 -07:00

242 lines
8.9 KiB
TypeScript

import { getTuiAgentDetectCommands, isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
import { EXACT_NODE_ENTRYPOINT_IDENTITIES } from './agent-node-entrypoint-identities'
import { NODE_PACKAGE_SCRIPT_ENTRYPOINTS } from './agent-node-package-entrypoints'
import type { AgentType } from './agent-status-types'
import type { TerminalAgent } from './terminal-agent'
import { filterHeadlessOneShotAgentCommand } from './agent-headless-command'
import { getFirstCommandToken } from './command-token-scanner'
import {
comparablePath,
findInterpreterEntrypointToken,
PYTHON_PROCESS_RE,
tokenizeCommandLine
} from './agent-command-line-entrypoint'
import { isFreshOmpLaunchCommand } from './omp-fresh-launch'
export type RecognizedAgentProcess = { agent: TerminalAgent; processName: string }
const PROCESS_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i
const INTERPRETER_SCRIPT_EXTENSION_RE = /\.(?:js|mjs|cjs)$/i
const PYTHON_SCRIPT_EXTENSION_RE = /\.(?:py|pyw)$/i
function normalizeProcessName(
processName: string | null | undefined,
options: { stripInterpreterScriptExtension?: boolean } = {}
): string {
if (!processName) {
return ''
}
const unquoted = processName.trim().replace(/^["']|["']$/g, '')
const basename = unquoted.split(/[\\/]/).pop() ?? unquoted
const withoutProcessExtension = basename.toLowerCase().replace(PROCESS_EXTENSION_RE, '')
if (options.stripInterpreterScriptExtension === true) {
return withoutProcessExtension.replace(INTERPRETER_SCRIPT_EXTENSION_RE, '')
}
return withoutProcessExtension
}
const FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES = new Set(['node', 'python', 'python3'])
const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/']
const PROCESS_TO_AGENT = new Map<string, TerminalAgent>([
['dsb', 'dsb'],
['deepseek-build', 'dsb'],
['deepseek-build-agent', 'dsb']
])
const AGENT_TYPE_IDS = new Set<string>(['dsb'])
for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG)) {
if (!isTuiAgent(agent)) {
continue
}
AGENT_TYPE_IDS.add(agent)
for (const candidate of [
config.expectedProcess,
...getTuiAgentDetectCommands(config),
getFirstCommandToken(config.launchCmd)
]) {
const normalized = normalizeProcessName(candidate)
if (normalized) {
// Why: claude-agent-teams is an Orca wrapper whose child process is the
// real `claude` binary. Do not let wrapper configs overwrite canonical
// CLI ownership for the same foreground process name.
if (!PROCESS_TO_AGENT.has(normalized)) {
PROCESS_TO_AGENT.set(normalized, agent)
}
}
}
}
function agentForNormalizedProcess(normalized: string): TerminalAgent | undefined {
const exact = PROCESS_TO_AGENT.get(normalized)
if (exact) {
return exact
}
// Why: node-pty can report Codex's packaged platform binary
// (for example codex-aarch64-ap) instead of the launch command.
if (normalized.startsWith('codex-')) {
return PROCESS_TO_AGENT.get('codex')
}
// Qoder's launcher resolves to a versioned native binary.
if (/^(?:qoderclicn|qodercn)-\d/.test(normalized)) {
return PROCESS_TO_AGENT.get('qoderclicn')
}
if (/^qodercli-\d/.test(normalized)) {
return PROCESS_TO_AGENT.get('qodercli')
}
if (normalized.startsWith('grok-')) {
return PROCESS_TO_AGENT.get('grok')
}
// Why: the `muse` launcher script execs a versioned `muse-bin-<version>` binary, so
// the foreground name never equals `muse` itself. The `muse-bin-` prefix also covers
// comm-truncated rows (`muse-bin-1.0.3-R`) without matching unrelated `muse-*` tools.
if (normalized.startsWith('muse-bin-')) {
return PROCESS_TO_AGENT.get('muse')
}
return undefined
}
function recognizedAgentForProcess(normalized: string): RecognizedAgentProcess | null {
const agent = agentForNormalizedProcess(normalized)
return agent ? { agent, processName: normalized } : null
}
function recognizeNodeScriptEntrypoint(token: string): RecognizedAgentProcess | null {
const path = comparablePath(token)
for (const identity of EXACT_NODE_ENTRYPOINT_IDENTITIES) {
if (identity.pattern.test(path)) {
return { agent: identity.agent, processName: identity.processName }
}
}
const normalized = normalizeProcessName(token, { stripInterpreterScriptExtension: true })
const markers = NODE_PACKAGE_SCRIPT_ENTRYPOINTS[normalized]
if (!markers) {
return null
}
if (!markers.some((marker) => path.includes(marker))) {
return null
}
return recognizedAgentForProcess(normalized)
}
function recognizePythonModule(
moduleName: string | null | undefined
): RecognizedAgentProcess | null {
if (!moduleName || moduleName.startsWith('-')) {
return null
}
const normalized = moduleName.split('.', 1)[0]?.toLowerCase() ?? ''
return recognizedAgentForProcess(normalized)
}
function recognizePythonScriptEntrypoint(token: string): RecognizedAgentProcess | null {
const path = comparablePath(token)
if (!PYTHON_SCRIPT_EXTENSION_RE.test(path)) {
return null
}
if (!PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES.some((marker) => path.includes(marker))) {
return null
}
const basename = path.split('/').pop() ?? ''
const normalized = basename.replace(PYTHON_SCRIPT_EXTENSION_RE, '')
return recognizedAgentForProcess(normalized)
}
function recognizePythonEntrypoint(
tokens: string[],
entrypoint: string
): RecognizedAgentProcess | null {
const moduleFlagIndex = tokens.indexOf('-m')
if (moduleFlagIndex > 0) {
return recognizePythonModule(tokens[moduleFlagIndex + 1])
}
return recognizeAgentProcess(entrypoint) ?? recognizePythonScriptEntrypoint(entrypoint)
}
// Why: `muse` execs a versioned `muse-bin-<version>` binary (see above), so the
// exact-name check never matches and readiness/follow-up delivery would stall.
// Scoped to muse: a generic `-suffix` rule would misclassify short agent names
// (see the ante-obsidian test).
export function isExpectedAgentProcess(
processName: string | null | undefined,
expectedProcess: string
): boolean {
const normalizedProcess = normalizeProcessName(processName)
const normalizedExpected = normalizeProcessName(expectedProcess)
if (!normalizedProcess || !normalizedExpected) {
return false
}
return (
normalizedProcess === normalizedExpected ||
normalizedProcess.startsWith(`${normalizedExpected}.`) ||
(['qoderclicn', 'qodercn'].includes(normalizedExpected) &&
/^(?:qoderclicn|qodercn)(?:-\d.*)?$/.test(normalizedProcess)) ||
(normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) ||
(normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-'))
)
}
export function recognizeAgentProcess(
processName: string | null | undefined
): RecognizedAgentProcess | null {
const normalized = normalizeProcessName(processName)
return recognizedAgentForProcess(normalized)
}
export function recognizeAgentProcessFromCommandLine(
commandLine: string | null | undefined,
// Why: TUI consumers (status hooks, shell shadows) filter out headless
// one-shots (`claude -p …`); non-interactivity guards include them — a
// one-shot agent can't answer a prompt either.
options?: { includeHeadlessOneShot?: boolean }
): RecognizedAgentProcess | null {
if (!commandLine) {
return null
}
if (isFreshOmpLaunchCommand(commandLine)) {
return recognizedAgentForProcess('omp')
}
const keep = options?.includeHeadlessOneShot === true
const tokens = tokenizeCommandLine(commandLine)
const firstNormalized = normalizeProcessName(tokens[0])
let direct = recognizedAgentForProcess(firstNormalized)
// Why: the generic Orca CLI is not an agent; only this subcommand launches its TUI mode.
if (direct?.agent === 'claude-agent-teams' && tokens[1]?.toLowerCase() !== 'claude-teams') {
direct = null
}
const directRecognition = keep ? direct : filterHeadlessOneShotAgentCommand(direct, tokens)
if (directRecognition) {
return directRecognition
}
const entrypoint = findInterpreterEntrypointToken(tokens, firstNormalized)
if (!entrypoint) {
return null
}
const viaEntrypoint = PYTHON_PROCESS_RE.test(firstNormalized)
? recognizePythonEntrypoint(tokens, entrypoint)
: (recognizeAgentProcess(entrypoint) ?? recognizeNodeScriptEntrypoint(entrypoint))
if (
viaEntrypoint?.agent === 'claude-agent-teams' &&
tokens[tokens.indexOf(entrypoint, 1) + 1]?.toLowerCase() !== 'claude-teams'
) {
return null
}
return keep ? viaEntrypoint : filterHeadlessOneShotAgentCommand(viaEntrypoint, tokens)
}
export function isAgentForegroundWrapperProcess(processName: string | null | undefined): boolean {
const normalized = normalizeProcessName(processName)
return (
FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized)
)
}
export function isRecognizedAgentType(agentType: AgentType | null | undefined): boolean {
if (typeof agentType !== 'string') {
return false
}
return (
AGENT_TYPE_IDS.has(agentType) ||
agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined
)
}