/** * Shared agent detection utilities — used by both the main process (stats * collection) and the renderer (activity indicators, unread badges). * * Why shared: the main process needs the same OSC title extraction and agent * status detection for stat tracking that the renderer uses for UI indicators. * Duplicating this logic would risk drift between the two detection paths. */ import { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName, titleHasAnyLegacyAgentName } from './agent-name-token-match' // Re-export so existing `agent-detection` importers keep working. export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' export { isShellProcess } from './shell-process-detection' export type AgentStatus = 'working' | 'permission' | 'idle' const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) const CLAUDE_MANAGEMENT_TITLE_RE = /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i const GEMINI_WORKING = '\u2726' // ✦ const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ const GEMINI_IDLE = '\u25C7' // ◇ const GEMINI_PERMISSION = '\u270B' // ✋ // Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles // like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'. // `as const` so consumers receive literal-union types. const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const // Why: working keywords used inside `detectAgentStatusFromTitle` to map // titles like "Codex working", "Aider thinking", "OpenCode running" to // AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay // in lock-step when stripping working indicators from stale titles. const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const // Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that // would make the "keyword" part of a larger token. Plain `\b` alone is // insufficient because `-` is a non-word character in JS regex, so `\bready\b` // still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and // `r`). // // Lookarounds are intentionally ASYMMETRIC: // - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`, // Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle // signal by having the agent name sit earlier in the same path and the // keyword land right after a path separator. Orca is a cross-platform // Electron app, so Windows path separators must be handled too. // - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like // "Codex done." / "Aider idle." / "OpenCode ready!" still match — path // separators after the keyword are not a false-positive vector in // practice and blocking them would regress trailing-punctuation titles. // // Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain // substring false positives ("already"/"redone"/"idleness"). export const STRONG_IDLE_KEYWORDS_RE = new RegExp( `(?= 0x2800 && codePoint <= 0x28ff) { return true } } return false } function containsLegacyAgentName(title: string): boolean { return titleHasAnyLegacyAgentName(title) } function containsAgentName(title: string): boolean { return ( containsLegacyAgentName(title) || AGY_AGENT_NAME_RE.test(title) || DROID_AGENT_NAME_RE.test(title) || HERMES_AGENT_NAME_RE.test(title) ) } function containsAny(title: string, words: readonly string[]): boolean { const lower = title.toLowerCase() return words.some((word) => lower.includes(word)) } /** * Strip working-status indicators from a title so that * `detectAgentStatusFromTitle` will no longer return 'working'. * Used to clear stale titles when an agent exits without resetting its title. */ export function clearWorkingIndicators(title: string): string { let cleaned = title // Gemini working symbols cleaned = cleaned.replace(GEMINI_WORKING, '') cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') // Braille spinner characters (U+2800–U+28FF) // eslint-disable-next-line no-control-regex -- intentional unicode range cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') // Claude Code ". " working prefix if (cleaned.startsWith('. ')) { cleaned = cleaned.slice(2) } // Strip working keywords that detectAgentStatusFromTitle would pick up // when the title also contains an agent name. if (containsAgentName(cleaned)) { cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') } // Collapse whitespace after removals cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() return cleaned || title } /** * Tracks agent status transitions from terminal title changes. * Fires `onBecameIdle` when an agent transitions from working to idle/permission, * like haunt's attention flag — the key trigger for unread notifications. */ export function createAgentStatusTracker( onBecameIdle: (title: string) => void, onBecameWorking?: () => void, onAgentExited?: () => void ): { handleTitle: (title: string) => void /** Clear accumulated status so a stale working→idle transition cannot fire * after the owning transport is torn down. */ reset: () => void } { let lastStatus: AgentStatus | null = null return { handleTitle(title: string): void { const newStatus = detectAgentStatusFromTitle(title) if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { onBecameIdle(title) } if (lastStatus !== 'working' && newStatus === 'working') { onBecameWorking?.() } // Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"), // detectAgentStatusFromTitle returns null. If we were idle or in a permission // prompt, this means the user exited the agent — clear session-tied state // (like the prompt-cache countdown). We intentionally do NOT fire this when // lastStatus is 'working', because active agents can briefly flash shell // titles during internal operations without actually exiting. if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { lastStatus = null onAgentExited?.() } if (newStatus !== null) { lastStatus = newStatus } }, reset(): void { lastStatus = null } } } /** * Normalize high-churn agent titles into stable display labels before storing * them in app state. Gemini CLI can emit per-keystroke title updates, which * otherwise causes broad rerenders and visible flashing. */ export function normalizeTerminalTitle(title: string): string { if (!title) { return title } if (isGeminiTerminalTitle(title)) { const status = detectAgentStatusFromTitle(title) if (status === 'permission') { return `${GEMINI_PERMISSION} Gemini CLI` } if (status === 'working') { return `${GEMINI_WORKING} Gemini CLI` } if (status === 'idle') { return `${GEMINI_IDLE} Gemini CLI` } } // Why: Pi's titlebar extension animates every 80ms with different braille // frames. Collapsing those frames into one stable label avoids renderer // churn while preserving the working/idle transition Orca keys off. if (isPiAgentTitle(title)) { const status = detectAgentStatusFromTitle(title) if (status === 'working') { return '\u280b Pi' } if (status === 'idle') { return 'Pi' } } return title } /** * Returns true when the terminal title matches Claude Code's title conventions. * Used to scope prompt-cache-timer behavior to Claude sessions only — other * agents have different (or no) caching semantics. */ export function isClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title)) { return false } const lower = title.toLowerCase() // Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ", // braille spinners) followed by the task description. The task text can // legitimately mention other agents, so Claude-specific prefixes must win. if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { return true } // Why: ". " (working) and "* " (idle) are Claude Code title conventions. // Other supported agents do not use them, and rejecting titles that mention // another agent in the task text caused false negatives for real Claude tabs. if (title.startsWith('. ') || title.startsWith('* ')) { return true } if (containsBrailleSpinner(title)) { // Why: named non-Claude agents can carry braille spinners too; Claude-only // prompt-cache paths must not fire for those explicit agent titles. return !lower.includes('cursor') && !lower.includes('openclaude') } // Why: permission/action-required Claude titles can omit the usual prefixes. // Token-match so cwd/worktree titles like "claude-scratch" do not become // Claude tabs, while task text that merely mentions Claude still stays out. const trimmedTitle = title.trimStart() if ( trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude') ) { return true } return false } export function isClaudeManagementTitle(title: string): boolean { return CLAUDE_MANAGEMENT_TITLE_RE.test(title) } export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } // Why: Pi working titles include a braille spinner prefix, which would be // mistaken for Claude Code if we checked `isClaudeAgent` first. if (isPiAgentTitle(title)) { return 'Pi' } // Why: Codex/OpenCode/Aider can also use braille spinner prefixes while // working. Prefer explicit name matches before Claude's generic spinner // heuristic so mixed-agent hovercards stay truthful. Token-match (not // substring) so cwd/worktree titles like "opencode-blinker" don't mint a // false agent identity. if (titleHasAgentName(title, 'codex')) { return 'Codex' } if (titleHasAgentName(title, 'openclaude')) { return 'OpenClaude' } if (titleHasAgentName(title, 'copilot')) { return 'GitHub Copilot' } if (titleHasAgentName(title, 'grok')) { return 'Grok' } if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { return 'Antigravity' } if (titleHasAgentName(title, 'opencode')) { return 'OpenCode' } if (titleHasAgentName(title, 'aider')) { return 'Aider' } // Why: the cursor-agent native title is the literal string "Cursor Agent" // (verified against the 2026.04.17 release) — Orca synthesizes the same // label from hook events so the braille-spinner + agent-name path lights // up working/permission/idle transitions in the renderer. Match before // `isClaudeAgent` because Claude's generic braille heuristic would // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. if (titleHasAgentName(title, 'cursor')) { return 'Cursor' } // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. // Token matching avoids labeling ordinary Android terminal titles as Droid. if (DROID_AGENT_NAME_RE.test(title)) { return 'Droid' } // Why: synthesized "⠋ Hermes" working titles need to be matched before // Claude's generic braille-spinner heuristic. if (HERMES_AGENT_NAME_RE.test(title)) { return 'Hermes' } if (isClaudeAgent(title)) { return 'Claude Code' } return null } // Why: cursor-agent's native OSC title is the literal string "Cursor Agent" // across the entire turn — it carries zero working/idle information. Orca // synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor - // action required" for permission) from cursor's hook events; the bare // native title must be a no-op so cursor's per-turn re-emissions cannot // stomp the synthesized state back to idle. const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title) { return null } if (isClaudeManagementTitle(title)) { return null } // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - // action required") is either an Orca-synthesized working/permission title // or a tighter match worth classifying. if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { return null } // Gemini CLI symbols are the most specific and should take precedence. if (title.includes(GEMINI_PERMISSION)) { return 'permission' } if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { return 'working' } if (title.includes(GEMINI_IDLE)) { return 'idle' } // Claude Code uses ✳ prefix for idle — must check before braille/agent-name // because the title text is the task description, not "Claude Code". if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { return 'idle' } if (isPiTerminalTitle(title)) { return 'idle' } if (containsBrailleSpinner(title)) { return 'working' } const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) const hasLegacyAgentName = containsLegacyAgentName(title) if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { if (containsAny(title, ['action required', 'permission', 'waiting'])) { return 'permission' } // Why: hyphen/word-char-aware boundary match (not plain substring, and // stricter than `\b` — which treats `-` as a boundary) so titles like // "~/codex already built" do not classify as idle via the substring // "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment. if (STRONG_IDLE_KEYWORDS_RE.test(title)) { return 'idle' } // Why: hyphen/word-char-aware boundary match (not plain substring, and // stricter than `\b`) so titles like "~/codex reworking diff" or // "is-thinking-cap" do not classify as working via the substrings // "reworking" ⊃ "working" or the `-`-adjacent "thinking" in // "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a // false 'working' is worse than a false 'idle' because it drives // active-agent UI (spinners, counts). if (STRONG_WORKING_KEYWORDS_RE.test(title)) { return 'working' } // Claude Code title prefixes: ". " = working, "* " = idle if (title.startsWith('. ')) { return 'working' } if (title.startsWith('* ')) { return 'idle' } // Why: Factory Droid can publish native titles like "Factory Droid needs // input" while an Execute tool is still sleeping. Droid's hook events are // authoritative; don't turn a name-only native title into a completion. if (hasDroidAgentName && !hasLegacyAgentName) { return null } return 'idle' } return null }