Fix tab agent identity detection (#5283)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-12 14:15:27 -07:00 committed by GitHub
parent 5f3b61aa65
commit abb2477efb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 485 additions and 38 deletions

View File

@ -5,9 +5,15 @@ import { tmpdir } from 'os'
import { join } from 'path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
const {
spawnMock,
isPwshAvailableMock,
validateWorkingDirectoryMock,
resolveAgentForegroundProcessMock
} = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
throw new Error(
@ -33,6 +39,10 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
}
})
vi.mock('../providers/agent-foreground-process', () => ({
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
}))
import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
@ -75,6 +85,10 @@ describe('createPtySubprocess', () => {
beforeEach(() => {
spawnMock.mockReset()
isPwshAvailableMock.mockReset()
resolveAgentForegroundProcessMock.mockReset()
resolveAgentForegroundProcessMock.mockImplementation(
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
)
validateWorkingDirectoryMock.mockClear()
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
@ -196,6 +210,38 @@ describe('createPtySubprocess', () => {
expect(handle.getForegroundProcess()).toBe('codex')
})
it('serves daemon wrapper agent foreground from an async cache without blocking', async () => {
const proc = mockPtyProcess()
proc.process = 'node'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('node')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node')
resolveForeground('codex')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('treats node-pty terminal name as inconclusive foreground process', () => {
const proc = mockPtyProcess()
proc.process = 'xterm-256color'

View File

@ -27,8 +27,12 @@ import { getWslContextFromSessionId } from './wsl-session-context'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process'
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
export type PtySubprocessOptions = {
sessionId: string
@ -462,8 +466,51 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let dead = false
let disposed = false
let nodePtyKillIssued = false
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
const getFallbackForegroundProcess = (): string | null =>
normalizeForegroundProcessName(proc.process)
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
if (dead || process.platform === 'win32' || !proc.pid) {
return
}
if (
!fallbackProcess ||
isShellProcess(fallbackProcess) ||
recognizeAgentProcess(fallbackProcess)
) {
return
}
const now = Date.now()
if (
foregroundRefreshInFlight ||
now - lastForegroundRefreshStartedAt < FOREGROUND_AGENT_CACHE_TTL_MS
) {
return
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
// Why: daemon `getForegroundProcess()` is sync and runs on the IPC hot path.
// Refresh wrapper-derived identities (node/python → codex/gemini/etc.) in
// the background and serve them from a short cache on later reads.
void resolveAgentForegroundProcess(proc.pid, fallbackProcess)
.then((processName) => {
if (dead || !processName || !recognizeAgentProcess(processName)) {
return
}
cachedAgentForeground = { processName, refreshedAt: Date.now() }
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
})
.finally(() => {
foregroundRefreshInFlight = false
})
}
proc.onExit(() => {
dead = true
cachedAgentForeground = null
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
// (unixTerminal.js:219-229). After the child exits, the master socket's
// 'close' event can fire before our dispose() path gets to neutralize
@ -489,7 +536,23 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
return null
}
try {
return normalizeForegroundProcessName(proc.process)
const fallbackProcess = getFallbackForegroundProcess()
if (fallbackProcess && isShellProcess(fallbackProcess)) {
cachedAgentForeground = null
return fallbackProcess
}
if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) {
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
return fallbackProcess
}
scheduleAgentForegroundRefresh(fallbackProcess)
if (
cachedAgentForeground &&
Date.now() - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return cachedAgentForeground.processName
}
return fallbackProcess
} catch {
return null
}

View File

@ -0,0 +1,94 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
const execFileAsync = promisify(execFile)
type ProcessRow = {
pid: number
ppid: number
stat: string
command: string
}
function parsePsRows(stdout: string): ProcessRow[] {
const rows: ProcessRow[] = []
for (const line of stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
stat: match[3],
command: match[4]
})
}
return rows
}
function collectDescendants(
rows: ProcessRow[],
rootPid: number
): (ProcessRow & { depth: number })[] {
const childrenByParent = new Map<number, ProcessRow[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (ProcessRow & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of childrenByParent.get(row.pid) ?? []) {
stack.push({ row: child, depth: depth + 1 })
}
}
return descendants
}
function candidateScore(row: ProcessRow & { depth: number }): number {
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
// parent shell but still lets the native child confirm the same identity.
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
export async function resolveAgentForegroundProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null
): Promise<string | null> {
if (process.platform === 'win32' || !shellPid) {
return fallbackProcess
}
try {
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,stat=,command='], {
encoding: 'utf8',
timeout: 3000
})
return resolveAgentForegroundProcessFromPs(stdout, shellPid) ?? fallbackProcess
} catch {
// Fall through to node-pty's process name. Foreground process inspection is
// best-effort because terminal identity should never break PTY operation.
}
return fallbackProcess
}
function resolveAgentForegroundProcessFromPs(stdout: string, shellPid: number): string | null {
const candidates = collectDescendants(parsePsRows(stdout), shellPid).sort(
(a, b) => candidateScore(b) - candidateScore(a)
)
for (const candidate of candidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
return recognized.processName
}
}
return null
}

View File

@ -39,6 +39,7 @@ import {
resolveWindowsGitBashShellPath
} from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcess } from './agent-foreground-process'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
@ -710,7 +711,7 @@ export class LocalPtyProvider implements IPtyProvider {
return null
}
try {
return proc.process || null
return await resolveAgentForegroundProcess(proc.pid, proc.process || null)
} catch {
return null
}

View File

@ -411,6 +411,12 @@ describe('getAgentLabel', () => {
expect(getAgentLabel('⠋ π - my-project')).toBe('Pi')
})
it('treats Claude Code prefixed task titles as Claude even when they mention another CLI', () => {
expect(getAgentLabel('✳ Gemini CLI')).toBe('Claude Code')
expect(getAgentLabel('. Compare Opencode Vs Orca')).toBe('Claude Code')
expect(getAgentLabel('* Review Codex behavior')).toBe('Claude Code')
})
it('labels supported agent families consistently', () => {
expect(getAgentLabel('✦ Gemini CLI')).toBe('Gemini CLI')
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')

View File

@ -32,7 +32,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('keeps a title-identified agent visible over a stale shell foreground sample', () => {
it('lets shell foreground clear stale identity even when the title still names an agent', () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
@ -44,7 +44,7 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('claude')
).toBeNull()
})
it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => {
@ -62,12 +62,40 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('openclaude')
})
it('keeps title fallback for real Gemini and Pi titles', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✦ Gemini CLI',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('gemini')
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'π - my-project',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('pi')
})
it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Say hi',
hookAgent: null,
@ -78,7 +106,7 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('openclaude')
})
it("uses OpenClaude launch intent over Claude's generic task-title heuristic before hooks arrive", () => {
it('uses Claude-owned title identity before OpenClaude launch intent when hooks have not arrived', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
@ -90,7 +118,7 @@ describe('resolveTabAgentFromSignals', () => {
hasCompletedHook: false,
launchAgent: 'openclaude'
})
).toBe('openclaude')
).toBe('claude')
})
it("uses Codex hook identity over Claude's generic task-title heuristic", () => {
@ -153,21 +181,107 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('falls back to title, hook, and launch intent when foreground is inconclusive', () => {
it('prefers explicit hook identity over a conflicting title mention', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Gemini CLI',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('claude')
})
it('prefers explicit hook identity over ordinary non-Claude title identity', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✦ Gemini CLI',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'gemini'
})
).toBe('claude')
})
it('does not let launch intent turn Claude-owned task text into Gemini', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Claude Code',
hookAgent: 'codex',
title: '✳ Gemini CLI',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'gemini'
})
).toBe('claude')
})
it('does not let launch intent turn Claude-owned task text into OpenCode', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '. Compare Opencode Vs Orca',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'opencode'
})
).toBe('claude')
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '* Review Codex behavior',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('claude')
})
it('treats Claude-prefixed task text as Claude before launch intent when no hook arrived', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Gemini CLI',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '. Compare Opencode Vs Orca',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
})
it('skips local foreground authority for remote worktrees', () => {
expect(
resolveTabAgentFromSignals({

View File

@ -32,13 +32,6 @@ function agentFromTitle(title: string): TuiAgent | null {
return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
}
function isGenericClaudeTitle(title: string, titleAgent: TuiAgent | null): boolean {
if (titleAgent !== 'claude') {
return false
}
return !/(?<![\w./\\-])claude(?![\w./\\-])/i.test(title)
}
function getTitleForegroundKey(title: string): string {
const titleAgent = agentFromTitle(title)
if (titleAgent) {
@ -71,31 +64,25 @@ export function resolveTabAgentFromSignals(args: {
}): TuiAgent | null {
const titleAgent = agentFromTitle(args.title)
const titleLooksShell = isShellProcess(args.title)
const completedHookAgent =
titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent
const hookAgent = args.hookAgent ?? completedHookAgent ?? null
const launchAgent =
args.hasCompletedHook || (titleLooksShell && args.hasObservedAgentSignal)
? null
: (args.launchAgent ?? null)
const explicitAgent = args.hookAgent ?? args.completedHookAgent ?? launchAgent
// Why: OpenClaude can emit Claude-style `✳ <task>` titles. Prefer explicit
// hook/launch identity only for those generic task-title matches.
const titleResolutionAgent =
isGenericClaudeTitle(args.title, titleAgent) && explicitAgent && explicitAgent !== 'claude'
? explicitAgent
: titleAgent
const fallbackAgent = titleResolutionAgent ?? explicitAgent
if (args.isRemote || args.foreground === undefined) {
return fallbackAgent
return hookAgent ?? titleAgent ?? launchAgent
}
if (args.foreground) {
return args.foreground
}
if (titleResolutionAgent) {
return titleResolutionAgent
// Why: once a local pane has returned to a shell, a stale hook should not keep
// painting it as an agent tab.
if (args.shellForegroundAfterAgentSignal) {
return null
}
// Why: a freshly spawned agent tab can briefly report the shell before the
// queued launch command owns the PTY. Only let shell clear the icon after
// this pane has actually been observed running an agent.
return args.shellForegroundAfterAgentSignal ? null : fallbackAgent
return hookAgent ?? titleAgent ?? launchAgent
}
/**
@ -109,9 +96,10 @@ export function resolveTabAgentFromSignals(args: {
* starts/exits/takes a turn), never on an interval, and only for local panes
* (SSH foreground inspection is a 15s-timeout RPC). A recognized agent wins;
* a recognized shell authoritatively means "no agent".
* 2. Title catches agents whose process name isn't self-identifying (Claude
* 2. Hook status accurate provider identity from native integrations, and
* available for SSH/remote panes where foreground polling is too costly.
* 3. Title catches agents whose process name isn't self-identifying (Claude
* runs as `node`; its "✳ Claude Code" title still identifies it).
* 3. Hook status accurate but only updates on the agent's hook events.
* 4. launchAgent what Orca launched here; instant bootstrap before any check.
*/
export function useTabAgent(tab: TerminalTab): TuiAgent | null {

View File

@ -346,6 +346,16 @@ export function getAgentLabel(title: string): string | null {
if (isClaudeManagementTitle(title)) {
return null
}
// Why: Claude Code title text is often the task title. If that task mentions
// another CLI, the Claude-specific prefix is the identity signal, not the words.
if (
title.startsWith(`${CLAUDE_IDLE} `) ||
title === CLAUDE_IDLE ||
title.startsWith('. ') ||
title.startsWith('* ')
) {
return 'Claude Code'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}

View File

@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
isExpectedAgentProcess,
isRecognizedAgentType,
recognizeAgentProcess
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
} from './agent-process-recognition'
describe('agent process recognition', () => {
@ -24,6 +25,10 @@ describe('agent process recognition', () => {
})
it('matches expected agents from platform-specific foreground process paths', () => {
expect(recognizeAgentProcess('claude')).toEqual({
agent: 'claude',
processName: 'claude'
})
expect(
isExpectedAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\claude.exe`, 'claude')
).toBe(true)
@ -58,4 +63,32 @@ describe('agent process recognition', () => {
})
expect(isRecognizedAgentType('vibe')).toBe(true)
})
it('recognizes agent CLIs launched through interpreter wrappers', () => {
expect(
recognizeAgentProcessFromCommandLine('node /Users/dev/.nvm/versions/node/bin/codex')
).toEqual({ agent: 'codex', processName: 'codex' })
expect(
recognizeAgentProcessFromCommandLine('node /Users/dev/.nvm/versions/node/bin/gemini')
).toEqual({ agent: 'gemini', processName: 'gemini' })
expect(recognizeAgentProcessFromCommandLine('python3 /opt/homebrew/bin/hermes --tui')).toEqual({
agent: 'hermes',
processName: 'hermes'
})
})
it('does not classify prompt text as a wrapped agent command', () => {
expect(
recognizeAgentProcessFromCommandLine(
'node /tmp/not-an-agent.js "compare opencode vs orca in Gemini CLI"'
)
).toBeNull()
})
it('recognizes versioned Grok process names observed from the installed CLI', () => {
expect(recognizeAgentProcess('grok-0.2.51')).toEqual({
agent: 'grok',
processName: 'grok-0.2.51'
})
})
})

View File

@ -22,6 +22,18 @@ function firstCommandToken(command: string): string {
return command.trim().split(/\s+/)[0] ?? ''
}
const INTERPRETER_PROCESS_NAMES = new Set([
'node',
'python',
'python3',
'bash',
'zsh',
'sh',
'fish',
'pwsh',
'powershell'
])
const PROCESS_TO_AGENT = new Map<string, TuiAgent>()
const AGENT_TYPE_IDS = new Set<TuiAgent>()
@ -37,7 +49,12 @@ for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [
]) {
const normalized = normalizeProcessName(candidate)
if (normalized) {
PROCESS_TO_AGENT.set(normalized, agent)
// 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)
}
}
}
}
@ -52,9 +69,64 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined {
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 (const char of commandLine) {
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\' && quote !== "'") {
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 (!INTERPRETER_PROCESS_NAMES.has(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('\\') || EXTENSION_RE.test(token)
}
export function isExpectedAgentProcess(
processName: string | null | undefined,
expectedProcess: string
@ -81,6 +153,26 @@ export function recognizeAgentProcess(
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])
for (const [index, token] of tokens.entries()) {
if (!tokenLooksExecutable(token, index, firstNormalized)) {
continue
}
const recognized = recognizeAgentProcess(token)
if (recognized) {
return recognized
}
}
return null
}
export function isRecognizedAgentType(agentType: AgentType | null | undefined): boolean {
if (typeof agentType !== 'string') {
return false