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:
parent
37ba94a228
commit
f311539f9c
|
|
@ -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+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
|
||||
}
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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+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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
@ -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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Loading…
Reference in New Issue