Split agent-detection into title status/display/identity domain modules (title evidence plan, PR 3) (#7612)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-07 17:50:37 -07:00 committed by GitHub
parent 37ba94a228
commit f311539f9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 541 additions and 502 deletions

View File

@ -1,501 +1,34 @@
/**
* Shared agent detection utilities used by both the main process (stats
* collection) and the renderer (activity indicators, unread badges).
* Compatibility barrel for terminal-title agent detection used by the main
* process (stats collection), the renderer (activity indicators, unread
* badges), and shared siblings.
*
* 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.
* The implementation was split into domain modules in Phase 3 of the title
* evidence work: identity/label detection `terminal-title-agent-type`, status
* classification `terminal-title-status`, and display normalization
* `terminal-title-display`. This barrel is kept so the existing main/mobile/
* renderer import paths that reference `agent-detection` stay stable.
*/
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
titleHasAgentName,
titleHasAnyLegacyAgentName
} from './agent-name-token-match'
import {
getPiCompatibleSyntheticAgentLabel,
getPiCompatibleSyntheticAgentStatus,
isLegacyPiCompatibleTitle
} from './pi-compatible-synthetic-title'
// Re-export so existing `agent-detection` importers keep working.
export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match'
export { titleHasAgentName } from './agent-name-token-match'
export {
extractAllOscTitles,
extractLastOscTitle,
MAX_OSC_TITLE_CHARS
} from './osc-title-extraction'
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(
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: mirrors STRONG_IDLE_KEYWORDS_RE — plain substring matching on the
// working keywords caused the symmetric class of false positives, e.g.
// "reworking" ⊃ "working", "overthinking" ⊃ "thinking", "rerunning" ⊃
// "running", hyphenated compounds like "is-thinking-cap", AND cwd-path
// fragments like "~/codex/working" or "C:\codex\working". Uses the same
// asymmetric lookarounds as STRONG_IDLE_KEYWORDS_RE (path separators blocked
// on the left only so "Codex working." still matches). A false 'working'
// classification is worse than the idle one because it drives active-agent
// UI (spinners, counts), so word-char- and left-path-separator-aware
// matching is required here too.
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: global-flag companion of STRONG_WORKING_KEYWORDS_RE used by
// clearWorkingIndicators to strip ALL occurrences in a single pass. Keeps
// clearing and detection in lock-step — both use identical [\w\-] lookarounds,
// so `clearWorkingIndicators` no longer strips keywords out of hyphenated
// compounds like "is-working-cap" that `detectAgentStatusFromTitle` would
// correctly refuse to classify as working.
const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
export function isGeminiTerminalTitle(title: string): boolean {
// Why: Gemini OSC glyphs are stronger evidence than any cwd/session text.
if (
title.includes(GEMINI_PERMISSION) ||
title.includes(GEMINI_WORKING) ||
title.includes(GEMINI_SILENT_WORKING) ||
title.includes(GEMINI_IDLE)
) {
return true
}
// Why: Pi/OMP titles include cwd/session text; substring matching made
// paths like "gemini-project" masquerade as Gemini CLI.
if (isPiAgentTitle(title)) {
return false
}
return titleHasAgentName(title, 'gemini')
}
export function isPiTerminalTitle(title: string): boolean {
return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title)
}
function isPiAgentTitle(title: string): boolean {
return isLegacyPiCompatibleTitle(title)
}
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
function containsAgentName(title: string): boolean {
return (
titleHasAnyLegacyAgentName(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+2800U+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 workingidle 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
}
// 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'
}
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
// generic agent-title heuristics would otherwise claim first.
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
if (piCompatibleSyntheticAgentLabel) {
return piCompatibleSyntheticAgentLabel
}
// 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, 'devin')) {
return 'Devin'
}
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) {
return 'Antigravity'
}
if (titleHasAgentName(title, 'opencode')) {
return 'OpenCode'
}
if (titleHasAgentName(title, 'mimo')) {
return 'MiMo Code'
}
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'
}
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
// Pi and braille-spinner checks below.
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
if (piCompatibleSyntheticAgentStatus) {
return piCompatibleSyntheticAgentStatus
}
// 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 = titleHasAnyLegacyAgentName(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
}
export {
getAgentLabel,
isClaudeAgent,
isClaudeManagementTitle,
isGeminiTerminalTitle,
isPiTerminalTitle
} from './terminal-title-agent-type'
export type { AgentStatus } from './terminal-title-status'
export {
createAgentStatusTracker,
detectAgentStatusFromTitle,
STRONG_IDLE_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE
} from './terminal-title-status'
export { clearWorkingIndicators, normalizeTerminalTitle } from './terminal-title-display'

View File

@ -1,6 +1,193 @@
import { getAgentLabel, titleHasAgentName } from './agent-detection'
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
titleHasAgentName
} from './agent-name-token-match'
import {
getPiCompatibleSyntheticAgentLabel,
isLegacyPiCompatibleTitle
} from './pi-compatible-synthetic-title'
import type { TuiAgent } from './types'
export 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
export const GEMINI_WORKING = '\u2726' // ✦
export const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
export const GEMINI_IDLE = '\u25C7' // ◇
export const GEMINI_PERMISSION = '\u270B' // ✋
export function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
export function isGeminiTerminalTitle(title: string): boolean {
// Why: Gemini OSC glyphs are stronger evidence than any cwd/session text.
if (
title.includes(GEMINI_PERMISSION) ||
title.includes(GEMINI_WORKING) ||
title.includes(GEMINI_SILENT_WORKING) ||
title.includes(GEMINI_IDLE)
) {
return true
}
// Why: Pi/OMP titles include cwd/session text; substring matching made
// paths like "gemini-project" masquerade as Gemini CLI.
if (isPiAgentTitle(title)) {
return false
}
return titleHasAgentName(title, 'gemini')
}
export function isPiTerminalTitle(title: string): boolean {
return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title)
}
export function isPiAgentTitle(title: string): boolean {
return isLegacyPiCompatibleTitle(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
}
// 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'
}
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
// generic agent-title heuristics would otherwise claim first.
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
if (piCompatibleSyntheticAgentLabel) {
return piCompatibleSyntheticAgentLabel
}
// 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, 'devin')) {
return 'Devin'
}
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) {
return 'Antigravity'
}
if (titleHasAgentName(title, 'opencode')) {
return 'OpenCode'
}
if (titleHasAgentName(title, 'mimo')) {
return 'MiMo Code'
}
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
}
// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for
// agents whose foreground PROCESS name isn't self-identifying (Claude Code runs
// as `node`, but its "✳ Claude Code" title resolves here). Agents whose process
@ -24,16 +211,6 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
OMP: 'omp'
}
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
function hasGenericClaudeStatusPrefix(title: string): boolean {
return (
containsBrailleSpinner(title) ||

View File

@ -0,0 +1,99 @@
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
titleHasAnyLegacyAgentName
} from './agent-name-token-match'
import {
GEMINI_IDLE,
GEMINI_PERMISSION,
GEMINI_SILENT_WORKING,
GEMINI_WORKING,
isGeminiTerminalTitle,
isPiAgentTitle
} from './terminal-title-agent-type'
import {
detectAgentStatusFromTitle,
STRONG_WORKING_KEYWORDS_RE_GLOBAL
} from './terminal-title-status'
function containsAgentName(title: string): boolean {
return (
titleHasAnyLegacyAgentName(title) ||
AGY_AGENT_NAME_RE.test(title) ||
DROID_AGENT_NAME_RE.test(title) ||
HERMES_AGENT_NAME_RE.test(title)
)
}
/**
* 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+2800U+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
}
/**
* 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
}

View File

@ -0,0 +1,230 @@
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
titleHasAnyLegacyAgentName
} from './agent-name-token-match'
import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title'
import {
CLAUDE_IDLE,
containsBrailleSpinner,
GEMINI_IDLE,
GEMINI_PERMISSION,
GEMINI_SILENT_WORKING,
GEMINI_WORKING,
isClaudeManagementTitle,
isPiTerminalTitle
} from './terminal-title-agent-type'
export type AgentStatus = 'working' | 'permission' | 'idle'
// 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(
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: mirrors STRONG_IDLE_KEYWORDS_RE — plain substring matching on the
// working keywords caused the symmetric class of false positives, e.g.
// "reworking" ⊃ "working", "overthinking" ⊃ "thinking", "rerunning" ⊃
// "running", hyphenated compounds like "is-thinking-cap", AND cwd-path
// fragments like "~/codex/working" or "C:\codex\working". Uses the same
// asymmetric lookarounds as STRONG_IDLE_KEYWORDS_RE (path separators blocked
// on the left only so "Codex working." still matches). A false 'working'
// classification is worse than the idle one because it drives active-agent
// UI (spinners, counts), so word-char- and left-path-separator-aware
// matching is required here too.
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: global-flag companion of STRONG_WORKING_KEYWORDS_RE used by
// clearWorkingIndicators to strip ALL occurrences in a single pass. Keeps
// clearing and detection in lock-step — both use identical [\w\-] lookarounds,
// so `clearWorkingIndicators` no longer strips keywords out of hyphenated
// compounds like "is-working-cap" that `detectAgentStatusFromTitle` would
// correctly refuse to classify as working.
export const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
function containsAny(title: string, words: readonly string[]): boolean {
const lower = title.toLowerCase()
return words.some((word) => lower.includes(word))
}
/**
* 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 workingidle 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
}
}
}
// 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'
}
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
// Pi and braille-spinner checks below.
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
if (piCompatibleSyntheticAgentStatus) {
return piCompatibleSyntheticAgentStatus
}
// 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 = titleHasAnyLegacyAgentName(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
}