328 lines
9.9 KiB
TypeScript
328 lines
9.9 KiB
TypeScript
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config'
|
|
import type { AgentType } from './agent-status-types'
|
|
import type { TuiAgent } from './types'
|
|
import { filterHeadlessOneShotAgentCommand } from './agent-headless-command'
|
|
import { getFirstCommandToken } from './command-token-scanner'
|
|
|
|
export type RecognizedAgentProcess = { agent: TuiAgent; 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 STATIC_INTERPRETER_PROCESS_NAMES = new Set([
|
|
'node',
|
|
'python',
|
|
'python3',
|
|
'bash',
|
|
'zsh',
|
|
'sh',
|
|
'fish',
|
|
'pwsh',
|
|
'powershell'
|
|
])
|
|
|
|
const FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES = new Set(['node', 'python', 'python3'])
|
|
const PYTHON_PROCESS_RE = /^python(?:\d+(?:\.\d+)*)?$/
|
|
const INTERPRETER_OPTIONS_WITH_VALUE = new Set([
|
|
'-r',
|
|
'--require',
|
|
'--import',
|
|
'--loader',
|
|
'--experimental-loader'
|
|
])
|
|
const INTERPRETER_OPTIONS_WITH_INLINE_SOURCE = new Set(['-e', '--eval', '-p', '--print', '--check'])
|
|
const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record<string, readonly string[]> = {
|
|
codex: ['node_modules/@openai/codex/'],
|
|
gemini: ['node_modules/@google/gemini-cli/']
|
|
}
|
|
const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/']
|
|
|
|
const PROCESS_TO_AGENT = new Map<string, TuiAgent>()
|
|
const AGENT_TYPE_IDS = new Set<TuiAgent>()
|
|
|
|
for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [
|
|
TuiAgent,
|
|
(typeof TUI_AGENT_CONFIG)[TuiAgent]
|
|
][]) {
|
|
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): TuiAgent | 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')
|
|
}
|
|
if (normalized.startsWith('grok-')) {
|
|
return PROCESS_TO_AGENT.get('grok')
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function tokenizeCommandLine(commandLine: string): string[] {
|
|
const tokens: string[] = []
|
|
let current = ''
|
|
let quote: '"' | "'" | null = null
|
|
let escaped = false
|
|
for (let index = 0; index < commandLine.length; index += 1) {
|
|
const char = commandLine[index]
|
|
if (escaped) {
|
|
current += char
|
|
escaped = false
|
|
continue
|
|
}
|
|
if (char === '\\' && quote !== "'") {
|
|
const next = commandLine[index + 1]
|
|
if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) {
|
|
escaped = true
|
|
continue
|
|
}
|
|
}
|
|
if ((char === '"' || char === "'") && quote === null) {
|
|
quote = char
|
|
continue
|
|
}
|
|
if (quote === char) {
|
|
quote = null
|
|
continue
|
|
}
|
|
if (/\s/.test(char) && quote === null) {
|
|
if (current) {
|
|
tokens.push(current)
|
|
current = ''
|
|
}
|
|
continue
|
|
}
|
|
current += char
|
|
}
|
|
if (current) {
|
|
tokens.push(current)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
function tokenLooksExecutable(token: string, index: number, firstNormalized: string): boolean {
|
|
if (index === 0) {
|
|
return true
|
|
}
|
|
if (!isInterpreterProcessName(firstNormalized)) {
|
|
return false
|
|
}
|
|
// Why: only inspect interpreter script paths. Prompt text can mention other
|
|
// agents ("compare opencode vs orca"), and treating every argv token as an
|
|
// executable would reintroduce the substring-style false identity class that
|
|
// foreground-process detection is meant to avoid.
|
|
return token.includes('/') || token.includes('\\') || PROCESS_EXTENSION_RE.test(token)
|
|
}
|
|
|
|
function isInterpreterProcessName(normalized: string): boolean {
|
|
return STATIC_INTERPRETER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized)
|
|
}
|
|
|
|
function isPythonProcessName(normalized: string): boolean {
|
|
return PYTHON_PROCESS_RE.test(normalized)
|
|
}
|
|
|
|
function optionName(token: string): string {
|
|
const eq = token.indexOf('=')
|
|
return eq === -1 ? token : token.slice(0, eq)
|
|
}
|
|
|
|
function findInterpreterEntrypointToken(tokens: string[], firstNormalized: string): string | null {
|
|
if (!isInterpreterProcessName(firstNormalized)) {
|
|
return null
|
|
}
|
|
for (let index = 1; index < tokens.length; index += 1) {
|
|
const token = tokens[index]
|
|
if (token === '--') {
|
|
continue
|
|
}
|
|
if (isPythonProcessName(firstNormalized) && token === '-m') {
|
|
return tokens[index + 1] ?? null
|
|
}
|
|
if (token.startsWith('-')) {
|
|
const name = optionName(token)
|
|
if (INTERPRETER_OPTIONS_WITH_INLINE_SOURCE.has(name)) {
|
|
return null
|
|
}
|
|
if (INTERPRETER_OPTIONS_WITH_VALUE.has(name) && name === token) {
|
|
index += 1
|
|
}
|
|
continue
|
|
}
|
|
if (tokenLooksExecutable(token, index, firstNormalized)) {
|
|
return token
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function comparablePath(token: string): string {
|
|
return token
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\\/g, '/')
|
|
.toLowerCase()
|
|
}
|
|
|
|
function recognizeNodeScriptEntrypoint(token: string): RecognizedAgentProcess | null {
|
|
const normalized = normalizeProcessName(token, { stripInterpreterScriptExtension: true })
|
|
const markers = NODE_PACKAGE_SCRIPT_ENTRYPOINTS[normalized]
|
|
if (!markers) {
|
|
return null
|
|
}
|
|
const path = comparablePath(token)
|
|
if (!markers.some((marker) => path.includes(marker))) {
|
|
return null
|
|
}
|
|
const agent = agentForNormalizedProcess(normalized)
|
|
if (!agent) {
|
|
return null
|
|
}
|
|
return { agent, processName: normalized }
|
|
}
|
|
|
|
function recognizePythonModule(
|
|
moduleName: string | null | undefined
|
|
): RecognizedAgentProcess | null {
|
|
if (!moduleName || moduleName.startsWith('-')) {
|
|
return null
|
|
}
|
|
const normalized = moduleName.split('.', 1)[0]?.toLowerCase() ?? ''
|
|
const agent = agentForNormalizedProcess(normalized)
|
|
if (!agent) {
|
|
return null
|
|
}
|
|
return { agent, processName: 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, '')
|
|
const agent = agentForNormalizedProcess(normalized)
|
|
if (!agent) {
|
|
return null
|
|
}
|
|
return { agent, processName: normalized }
|
|
}
|
|
|
|
function recognizePythonEntrypoint(
|
|
tokens: string[],
|
|
entrypoint: string
|
|
): RecognizedAgentProcess | null {
|
|
const moduleFlagIndex = tokens.findIndex((token) => token === '-m')
|
|
if (moduleFlagIndex > 0) {
|
|
return recognizePythonModule(tokens[moduleFlagIndex + 1])
|
|
}
|
|
return recognizeAgentProcess(entrypoint) ?? recognizePythonScriptEntrypoint(entrypoint)
|
|
}
|
|
|
|
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}.`)
|
|
)
|
|
}
|
|
|
|
export function recognizeAgentProcess(
|
|
processName: string | null | undefined
|
|
): RecognizedAgentProcess | null {
|
|
const normalized = normalizeProcessName(processName)
|
|
const agent = agentForNormalizedProcess(normalized)
|
|
if (!agent) {
|
|
return null
|
|
}
|
|
return { agent, processName: normalized }
|
|
}
|
|
export function recognizeAgentProcessFromCommandLine(
|
|
commandLine: string | null | undefined
|
|
): RecognizedAgentProcess | null {
|
|
if (!commandLine) {
|
|
return null
|
|
}
|
|
const tokens = tokenizeCommandLine(commandLine)
|
|
const firstNormalized = normalizeProcessName(tokens[0])
|
|
const directRecognition = filterHeadlessOneShotAgentCommand(
|
|
recognizeAgentProcess(tokens[0]),
|
|
tokens
|
|
)
|
|
if (directRecognition) {
|
|
return directRecognition
|
|
}
|
|
const entrypoint = findInterpreterEntrypointToken(tokens, firstNormalized)
|
|
if (!entrypoint) {
|
|
return null
|
|
}
|
|
const entrypointRecognition = isPythonProcessName(firstNormalized)
|
|
? recognizePythonEntrypoint(tokens, entrypoint)
|
|
: (recognizeAgentProcess(entrypoint) ?? recognizeNodeScriptEntrypoint(entrypoint))
|
|
return filterHeadlessOneShotAgentCommand(entrypointRecognition, 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 as TuiAgent) ||
|
|
agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined
|
|
)
|
|
}
|