Files
orca/src/shared/agent-process-recognition.ts
T
Neil ebeea319b2 Support modern Qoder commands and verify authenticated resume
Start and resume Qoder through the existing execution-host selector when only the documented qoder command exists. Preserve legacy qodercli preference, explicit commands, quoting and session identity; disconnected SSH execution refuses without local fallback.

Mobile history uses command-at-create only with the optional owned-create capability and an existing stable mutation identity. Reconcile authoritative execution-host inventory before retrying creation, adopt the same surviving operation, preserve WSL/incarnation metadata and restore only missing original launch metadata. Changed retry settings cannot replace original capture. Bounded evidence expires after 15 minutes; unavailable inventory or original evidence refuses recovery and leaves surviving execution untouched. Authoritative inventory proving absence preserves the existing recreation behavior; this is not a durable exactly-once ledger for completed one-shot side effects. Older hosts retain the acknowledged create-then-send path.

Scope mobile launch authority to the current committed host/client generation, and recheck operation ownership after asynchronous preparation before later sends. Retire the mutation once the host acknowledges the resume; later ownership changes stop navigation without reporting a completed resume as failed or reusing a cached legacy pane. Preserve genuinely interrupted mutation identity. Preserve current-main OpenCode validation and merged Pi/Cursor behavior. Correct unchanged-main editor test fixtures to their production insertion-range and store contracts while retaining original assertions and production behavior.

Credit: Neil Parker (@nwparker); Soperf and jyang for Qoder integration/history groundwork in #24614; actual Pullfrog and CodeRabbit reviews and the independent Source reviewer for the mobile retry, launch capture, evidence lifetime and connection ownership findings. Conservative positional process recognition and existing folder-history matching-worktree limitations remain documented.
2026-10-04 19:50:45 -07:00

247 lines
9.1 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)) ||
(['qoder', 'qodercli'].includes(normalizedExpected) &&
/^(?:qoder|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)
// Qoder's public dispatcher routes these commands to the IDE rather than the agent CLI.
if (firstNormalized === 'qoder' && tokens[1] && !tokens[1].startsWith('-')) {
return null
}
// 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
)
}