Files
orca/src/shared/agent-process-recognition.ts
T
EvgeniiandBrennan Benson 63af0bdd24 feat(agents): add Prime Agent as a supported TUI agent with session history (#12935)
* feat(agents): add Prime Agent as a supported TUI agent with session history

Wire Prime Intellect's prime-agent CLI (a Pi fork) into the desktop and
mobile agent catalogs following the Trae registration pattern, and into
the Agent Session History browser following the OMP pattern:

- types.ts, tui-agent-config.ts: register 'prime-agent' with argv prompt
  injection behind a `--` separator (its own help documents `--` as
  "treat all following arguments as messages"; without it, prompts
  starting with `help`/`agents`/`-…` dispatch as subcommands or
  flags), plus csi-u Shift+Enter encoding matching the Pi TUI it embeds.
- agent-kind.ts, telemetry-events.ts, agent-status-types.ts,
  agent-type-label.ts, tui-agent-display-names.ts,
  tui-agent-selection.ts, skills-cli-agent-keys.ts: standard per-agent
  registrations.
- agent-headless-command.ts: `-p/--print` one-shot runs share the
  print-mode matcher with Claude/Trae so they are not mistaken for live
  interactive panes.
- agent-process-recognition: the npm shim launches a generic bundled
  cli.js, so only the exact package path is an authoritative identity
  (same as Pi and cursor-agent). The three per-agent regex branches are
  now one table in agent-node-entrypoint-identities.ts — the module was
  at its max-lines budget and a table makes the next agent one entry.
- AI Vault: sessions are Pi's message-graph JSONL under
  ~/.prime/agent/sessions (override PRIME_AGENT_CODING_AGENT_DIR —
  Prime Agent brands Pi's env contract instead of sharing
  PI_CODING_AGENT_DIR); parsed by the shared message-graph parser with
  incremental append-resume; discovered locally, in WSL homes, and over
  remote SSH; resumes by absolute transcript path
  (`prime-agent --resume <path>`) like OMP, with session-id fallback.
- skill-discovery-sources.ts: ~/.prime/agent/skills home source.
- Catalog, i18n (en/es/ja/ko/zh), mobile registries, and a bundled
  64x64 favicon (required by mobile's offline-icon invariant).

Scanner-test fixtures for OMP and Prime Agent move into
session-scanner-test-fixtures.ts and the incremental fixture into its
own module, keeping every touched file inside its max-lines budget
without ratchet bumps.

* fix(ai-vault): map custom Prime Agent roots to their sessions child

PRIME_AGENT_CODING_AGENT_DIR is consumed verbatim by the CLI as its agent
config dir, with transcripts always in <agentDir>/sessions — unlike
PI_CODING_AGENT_DIR's <home>/agent/sessions shape the shared normalizer
models. A custom root with a non-special basename (or a `.prime` leaf)
was therefore scanned as-is instead of its sessions child. Dedicated
normalizePrimeAgentSessionsDir appends `sessions` to every configured
root, taking only an explicit `.../sessions` path as-is; the shared
Pi/OMP normalizer drops the `.prime` widening it no longer needs.

Raised in review on #12935.

* fix(ai-vault): guard degenerate Prime Agent roots and cover the remote source

normalizePrimeAgentSessionsDir stripped a filesystem-root value ('/' or '//')
to '', which then joined into the relative root 'sessions' and would walk the
main-process cwd. session-scanner-roots.ts already carries this guard for the
OMP variant; apply the same fallback here.

The remote SSH source had no test: deleting jsonlSource('prime-agent', ...)
left the suite green, unlike the local path which is pinned by the
AI_VAULT_AGENTS exhaustiveness assertion in session-scanner.test.ts. Add a
case that fixes the .prime/agent/sessions root segments, the .jsonl
extension, and parser routing.

Raised in review on #12935.

* fix(ai-vault): honor Prime Agent's sessions-root env and non-interactive modes

Verified against upstream PrimeIntellect-ai/prime-agent source rather than
inferred from the CLI's help text.

config.ts getSessionsDir() reads PRIME_AGENT_SESSION_DIR (and its legacy
PRIME_AGENT_CODING_AGENT_SESSION_DIR alias) ahead of the agent dir and uses it
verbatim; setting either left the vault silently empty. It also appends
`sessions` to the agent dir unconditionally, with no basename escape hatch, so
PRIME_AGENT_CODING_AGENT_DIR=/data/sessions writes to /data/sessions/sessions
while Orca scanned /data/sessions. getAgentDir() and the session-dir override
both run through expandTildePath, so a `~` value set outside a shell resolves.

cli/args.ts also spells the non-interactive runs `--mode json|rpc|acp|daemon`,
which the shared print-mode matcher does not know, so those panes were counted
as live interactive agents and the paste-submit path would write user text into
a JSON-RPC/ACP stream. Match upstream exactly: only the space-separated form,
since `--mode=json` is not parsed by the CLI and does start the TUI.

Raised in review on #12935.

* fix(ai-vault): keep Prime Agent roots absolute and remote segments posix

Two holes in the previous commit.

The degenerate-root guard only rejected pure-separator values, so a relative
env value still resolved against the main-process cwd:
PRIME_AGENT_CODING_AGENT_DIR='.' scanned '<cwd>/sessions' and, worse,
PRIME_AGENT_SESSION_DIR='.' scanned the cwd itself. Require an absolute path in
both branches and fall back to the default otherwise.

remotePrimeAgentSessionsSegments() built its segments with the local-platform
join, so on a Windows client scanning a posix SSH host it produced
'\.prime\agent\sessions' and split('/') collapsed it to one bogus segment —
remote discovery would have found nothing. Remote roots are posix regardless of
client platform, so keep them literal. Pi and OMP are unaffected: their
normalizer returns a '.../sessions' input unchanged and never joins.

Raised in review on #12935.

* test(ai-vault): pin Windows drive roots to the Prime Agent default fallback

'C:\' and 'C:/' strip to the drive-relative 'C:', which isAbsolute
rejects on every platform — assert they land in the default fallback so
a looser truthiness check can't reintroduce a 'C:sessions' scan root.

Raised in review on #12935.

* test(ai-vault): pin the drive-relative root form and state what the posix runner can assert

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-09 14:50:02 -07:00

332 lines
11 KiB
TypeScript

import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config'
import { EXACT_NODE_ENTRYPOINT_IDENTITIES } from './agent-node-entrypoint-identities'
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 recognizedAgentForProcess(normalized: string): RecognizedAgentProcess | null {
const agent = agentForNormalizedProcess(normalized)
return agent ? { agent, processName: normalized } : null
}
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)
}
const isPythonProcessName = (normalized: string): boolean => PYTHON_PROCESS_RE.test(normalized)
const optionName = (token: string): string => token.split('=', 1)[0] ?? ''
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 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)
}
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)
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
}
const keep = options?.includeHeadlessOneShot === true
const tokens = tokenizeCommandLine(commandLine)
const firstNormalized = normalizeProcessName(tokens[0])
let direct = recognizeAgentProcess(tokens[0])
// 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 = isPythonProcessName(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 as TuiAgent) ||
agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined
)
}