Fix tab agent identity detection (#5283)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
5f3b61aa65
commit
abb2477efb
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue