Files
orca/src/shared/agent-process-recognition.ts
T
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00

343 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 './tui-agent'
import { filterHeadlessOneShotAgentCommand } from './agent-headless-command'
import { getFirstCommandToken } from './command-token-scanner'
import { isFreshOmpLaunchCommand } from './omp-fresh-launch'
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')
}
// 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 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 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 (PYTHON_PROCESS_RE.test(firstNormalized) && token === '-m') {
return tokens[index + 1] ?? null
}
if (token.startsWith('-')) {
const name = token.split('=', 1)[0] ?? ''
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)
}
// 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}.`) ||
(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 as TuiAgent) ||
agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined
)
}