mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge branch 'nwparker/term-speed-2-side-effect-channel' into nwparker/term-speed-2-side-effect-facts-complete
This commit is contained in:
@@ -2516,6 +2516,10 @@ export function connectPanePty(
|
||||
// hidden xterm is dirty, skipping is safer than sending stale CPR/DECRQM.
|
||||
}
|
||||
|
||||
function isHiddenStartupRendererQueryWindowActive(): boolean {
|
||||
return shouldSnapshotHiddenCodexOutput && hiddenStartupRendererQueryPending.length > 0
|
||||
}
|
||||
|
||||
function takeHiddenStartupRendererQueryPendingForForeground(data: string): {
|
||||
statelessQueryData: string
|
||||
statefulQueryData: string
|
||||
|
||||
+21
-531
@@ -1,539 +1,29 @@
|
||||
/**
|
||||
* Shared agent detection utilities — used by both the main process (stats
|
||||
* collection) and the renderer (activity indicators, unread badges).
|
||||
* Compatibility barrel for shared terminal agent-title detection.
|
||||
*
|
||||
* 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.
|
||||
* Why shared: main and renderer both consume OSC titles for facts, stats, and
|
||||
* UI state. Keep existing imports stable while the implementation stays split
|
||||
* into focused modules that satisfy max-lines.
|
||||
*/
|
||||
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
DROID_AGENT_NAME_RE,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
titleHasAgentName,
|
||||
titleHasAnyLegacyAgentName
|
||||
} from './agent-name-token-match'
|
||||
export type { AgentStatus } from './agent-title-core'
|
||||
export {
|
||||
isClaudeManagementTitle,
|
||||
isCursorNativeAgentTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiTerminalTitle,
|
||||
STRONG_IDLE_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE
|
||||
} from './agent-title-core'
|
||||
export { getAgentLabel, isClaudeAgent } from './agent-title-identity'
|
||||
export {
|
||||
clearWorkingIndicators,
|
||||
createAgentStatusTracker,
|
||||
detectAgentStatusFromTitle,
|
||||
normalizeTerminalTitle
|
||||
} from './agent-title-status'
|
||||
export { extractAllOscTitles, extractLastOscTitle } from './terminal-osc-title'
|
||||
|
||||
// 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(
|
||||
`(?<![\\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')
|
||||
const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format)
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
||||
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
|
||||
|
||||
// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation
|
||||
// class agents rotate through while working.
|
||||
// eslint-disable-next-line no-control-regex -- intentional unicode range
|
||||
const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g
|
||||
|
||||
/**
|
||||
* Extract the last OSC title-set sequence from raw PTY data.
|
||||
* Agent CLIs (Claude Code, Gemini, etc.) set OSC titles to announce their
|
||||
* identity and status. This is a single regex scan — comparable cost to one
|
||||
* normalizeTerminalChunk pass.
|
||||
*/
|
||||
export function extractLastOscTitle(data: string): string | null {
|
||||
if (!data.includes('\x1b]')) {
|
||||
return null
|
||||
}
|
||||
let last: string | null = null
|
||||
for (const m of data.matchAll(OSC_TITLE_RE)) {
|
||||
last = m[2]
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ALL OSC title-set sequences from raw PTY data, in order of appearance.
|
||||
* Why separate from extractLastOscTitle: node-pty and the main-process batch
|
||||
* window (PTY_BATCH_INTERVAL_MS) often coalesce multiple title changes into
|
||||
* one IPC payload. For fast agents (Pi's 80ms spinner + agent_end idle in the
|
||||
* same batch), returning only the last title silently drops the working
|
||||
* transition. Callers that care about driving UI state transitions
|
||||
* (working/idle spinner) need every title in the chunk. See issue #1083's
|
||||
* spinner-miss follow-up.
|
||||
*/
|
||||
export function extractAllOscTitles(data: string): string[] {
|
||||
if (!data.includes('\x1b]')) {
|
||||
return []
|
||||
}
|
||||
const titles: string[] = []
|
||||
for (const m of data.matchAll(OSC_TITLE_RE)) {
|
||||
titles.push(m[2])
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
export function isGeminiTerminalTitle(title: string): boolean {
|
||||
return (
|
||||
title.includes(GEMINI_PERMISSION) ||
|
||||
title.includes(GEMINI_WORKING) ||
|
||||
title.includes(GEMINI_SILENT_WORKING) ||
|
||||
title.includes(GEMINI_IDLE) ||
|
||||
title.toLowerCase().includes('gemini')
|
||||
)
|
||||
}
|
||||
|
||||
export function isPiTerminalTitle(title: string): boolean {
|
||||
return title.startsWith(PI_IDLE_PREFIX)
|
||||
}
|
||||
|
||||
function isPiAgentTitle(title: string): boolean {
|
||||
return (
|
||||
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
|
||||
)
|
||||
}
|
||||
|
||||
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 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)
|
||||
cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '')
|
||||
|
||||
// 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,
|
||||
initialTitle?: string
|
||||
): {
|
||||
handleTitle: (title: string) => void
|
||||
/** Seed the last-known status after creation without firing callbacks —
|
||||
* for trackers restored mid-session (app relaunch with persisted titles). */
|
||||
seedTitle: (title: string) => void
|
||||
/** Clear accumulated status so a stale working→idle transition cannot fire
|
||||
* after the owning transport is torn down. */
|
||||
reset: () => void
|
||||
} {
|
||||
// Why: trackers that start mid-session (parked-tab byte watchers) must seed
|
||||
// the last known status, or an agent that was working when its pane
|
||||
// unmounted never produces a working→idle transition. Seeding sets state
|
||||
// only — no callbacks fire.
|
||||
let lastStatus: AgentStatus | null =
|
||||
initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : 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
|
||||
}
|
||||
},
|
||||
seedTitle(title: string): void {
|
||||
lastStatus = detectAgentStatusFromTitle(title)
|
||||
},
|
||||
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 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'
|
||||
|
||||
/**
|
||||
* True for cursor-agent's bare native title ("Cursor Agent", trimmed,
|
||||
* case-insensitive). Title trackers drop it before it reaches stored state so
|
||||
* cursor's per-turn re-emissions cannot stomp Orca's synthesized spinner
|
||||
* titles. Anything with additional tokens ("⠋ Cursor Agent") passes through.
|
||||
*/
|
||||
export function isCursorNativeAgentTitle(title: string): boolean {
|
||||
return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
DROID_AGENT_NAME_RE,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
titleHasAgentName,
|
||||
titleHasAnyLegacyAgentName
|
||||
} from './agent-name-token-match'
|
||||
|
||||
export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName }
|
||||
|
||||
export type AgentStatus = 'working' | 'permission' | 'idle'
|
||||
|
||||
export const CLAUDE_IDLE = '\u2733' // ✳
|
||||
const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?`
|
||||
export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp(
|
||||
String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\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' // ✋
|
||||
|
||||
const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const
|
||||
const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const
|
||||
|
||||
// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as
|
||||
// "~/codex/ready"; the left side also blocks path separators for Windows/Unix.
|
||||
export const STRONG_IDLE_KEYWORDS_RE = new RegExp(
|
||||
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
|
||||
'i'
|
||||
)
|
||||
|
||||
// Why: mirrors the idle matcher so titles like "reworking" or
|
||||
// "is-thinking-cap" do not drive false active-agent UI.
|
||||
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
|
||||
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
|
||||
'i'
|
||||
)
|
||||
|
||||
export const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
|
||||
|
||||
export const PI_IDLE_PREFIX = '\u03c0 - ' // π -
|
||||
export const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent'
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- intentional unicode range
|
||||
export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g
|
||||
|
||||
export function isGeminiTerminalTitle(title: string): boolean {
|
||||
return (
|
||||
title.includes(GEMINI_PERMISSION) ||
|
||||
title.includes(GEMINI_WORKING) ||
|
||||
title.includes(GEMINI_SILENT_WORKING) ||
|
||||
title.includes(GEMINI_IDLE) ||
|
||||
title.toLowerCase().includes('gemini')
|
||||
)
|
||||
}
|
||||
|
||||
export function isPiTerminalTitle(title: string): boolean {
|
||||
return title.startsWith(PI_IDLE_PREFIX)
|
||||
}
|
||||
|
||||
export function isPiAgentTitle(title: string): boolean {
|
||||
return (
|
||||
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
|
||||
)
|
||||
}
|
||||
|
||||
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 containsLegacyAgentName(title: string): boolean {
|
||||
return titleHasAnyLegacyAgentName(title)
|
||||
}
|
||||
|
||||
export 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)
|
||||
)
|
||||
}
|
||||
|
||||
export function containsAny(title: string, words: readonly string[]): boolean {
|
||||
const lower = title.toLowerCase()
|
||||
return words.some((word) => lower.includes(word))
|
||||
}
|
||||
|
||||
export function isClaudeManagementTitle(title: string): boolean {
|
||||
return CLAUDE_MANAGEMENT_TITLE_RE.test(title)
|
||||
}
|
||||
|
||||
export function isCursorNativeAgentTitle(title: string): boolean {
|
||||
return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
CLAUDE_IDLE,
|
||||
DROID_AGENT_NAME_RE,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
containsBrailleSpinner,
|
||||
isClaudeManagementTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiAgentTitle,
|
||||
titleHasAgentName
|
||||
} from './agent-title-core'
|
||||
|
||||
/**
|
||||
* Returns true when the terminal title matches Claude Code's title conventions.
|
||||
* Used to scope prompt-cache-timer behavior to Claude sessions only.
|
||||
*/
|
||||
export function isClaudeAgent(title: string): boolean {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
return false
|
||||
}
|
||||
const lower = title.toLowerCase()
|
||||
|
||||
// Why: Claude title prefixes are stronger than task text, which can mention
|
||||
// other agents without changing the owning CLI.
|
||||
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
|
||||
return true
|
||||
}
|
||||
if (title.startsWith('. ') || title.startsWith('* ')) {
|
||||
return true
|
||||
}
|
||||
if (containsBrailleSpinner(title)) {
|
||||
return !lower.includes('cursor') && !lower.includes('openclaude')
|
||||
}
|
||||
|
||||
const trimmedTitle = title.trimStart()
|
||||
return (
|
||||
trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude')
|
||||
)
|
||||
}
|
||||
|
||||
export function getAgentLabel(title: string): string | null {
|
||||
if (isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: Claude task titles can mention another CLI; the prefix is the identity
|
||||
// signal, not arbitrary task text.
|
||||
if (
|
||||
title.startsWith(`${CLAUDE_IDLE} `) ||
|
||||
title === CLAUDE_IDLE ||
|
||||
title.startsWith('. ') ||
|
||||
title.startsWith('* ')
|
||||
) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
return 'Gemini CLI'
|
||||
}
|
||||
if (isPiAgentTitle(title)) {
|
||||
return 'Pi'
|
||||
}
|
||||
|
||||
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: match explicit names before Claude's generic braille heuristic.
|
||||
if (titleHasAgentName(title, 'cursor')) {
|
||||
return 'Cursor'
|
||||
}
|
||||
if (DROID_AGENT_NAME_RE.test(title)) {
|
||||
return 'Droid'
|
||||
}
|
||||
if (HERMES_AGENT_NAME_RE.test(title)) {
|
||||
return 'Hermes'
|
||||
}
|
||||
if (isClaudeAgent(title)) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
BRAILLE_SPINNER_RE,
|
||||
CLAUDE_IDLE,
|
||||
CURSOR_NATIVE_TITLE_LOWER,
|
||||
DROID_AGENT_NAME_RE,
|
||||
GEMINI_IDLE,
|
||||
GEMINI_PERMISSION,
|
||||
GEMINI_SILENT_WORKING,
|
||||
GEMINI_WORKING,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
STRONG_IDLE_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE_GLOBAL,
|
||||
containsAgentName,
|
||||
containsAny,
|
||||
containsBrailleSpinner,
|
||||
containsLegacyAgentName,
|
||||
isClaudeManagementTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiAgentTitle,
|
||||
isPiTerminalTitle
|
||||
} from './agent-title-core'
|
||||
import type { AgentStatus } from './agent-title-core'
|
||||
|
||||
/**
|
||||
* Strip working-status indicators so stale exit titles stop reporting working.
|
||||
*/
|
||||
export function clearWorkingIndicators(title: string): string {
|
||||
let cleaned = title
|
||||
|
||||
cleaned = cleaned.replace(GEMINI_WORKING, '')
|
||||
cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '')
|
||||
cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '')
|
||||
if (cleaned.startsWith('. ')) {
|
||||
cleaned = cleaned.slice(2)
|
||||
}
|
||||
if (containsAgentName(cleaned)) {
|
||||
cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '')
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
|
||||
return cleaned || title
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks agent status transitions from terminal title changes.
|
||||
*/
|
||||
export function createAgentStatusTracker(
|
||||
onBecameIdle: (title: string) => void,
|
||||
onBecameWorking?: () => void,
|
||||
onAgentExited?: () => void,
|
||||
initialTitle?: string
|
||||
): {
|
||||
handleTitle: (title: string) => void
|
||||
seedTitle: (title: string) => void
|
||||
reset: () => void
|
||||
} {
|
||||
// Why: trackers restored mid-session need a last-known status without firing
|
||||
// callbacks, or a hidden working agent can miss its later idle transition.
|
||||
let lastStatus: AgentStatus | null =
|
||||
initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : 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: reverting to a plain shell prompt after idle/permission means the
|
||||
// agent exited; while working it can just be a transient internal title.
|
||||
if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) {
|
||||
lastStatus = null
|
||||
onAgentExited?.()
|
||||
}
|
||||
if (newStatus !== null) {
|
||||
lastStatus = newStatus
|
||||
}
|
||||
},
|
||||
seedTitle(title: string): void {
|
||||
lastStatus = detectAgentStatusFromTitle(title)
|
||||
},
|
||||
reset(): void {
|
||||
lastStatus = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize high-churn agent titles into stable display labels before storage.
|
||||
*/
|
||||
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 animates every 80ms; collapse frames while preserving status.
|
||||
if (isPiAgentTitle(title)) {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === 'working') {
|
||||
return '\u280b Pi'
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return 'Pi'
|
||||
}
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) {
|
||||
return null
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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) {
|
||||
return null
|
||||
}
|
||||
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
|
||||
return 'permission'
|
||||
}
|
||||
// Why: boundary-aware regexes avoid cwd/path and substring false positives.
|
||||
if (STRONG_IDLE_KEYWORDS_RE.test(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
if (STRONG_WORKING_KEYWORDS_RE.test(title)) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.startsWith('. ')) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.startsWith('* ')) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// Why: Droid hook events are authoritative; native name-only titles should
|
||||
// not turn a still-sleeping execute tool into completion.
|
||||
if (hasDroidAgentName && !hasLegacyAgentName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
||||
const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
|
||||
|
||||
/**
|
||||
* Extract the last OSC title-set sequence from raw PTY data.
|
||||
* Agent CLIs set OSC titles to announce identity and status.
|
||||
*/
|
||||
export function extractLastOscTitle(data: string): string | null {
|
||||
if (!data.includes('\x1b]')) {
|
||||
return null
|
||||
}
|
||||
let last: string | null = null
|
||||
for (const m of data.matchAll(OSC_TITLE_RE)) {
|
||||
last = m[2]
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all OSC title-set sequences from raw PTY data, in order.
|
||||
* Why separate from extractLastOscTitle: coalesced PTY chunks can contain both
|
||||
* working and idle transitions, and UI status trackers need each title.
|
||||
*/
|
||||
export function extractAllOscTitles(data: string): string[] {
|
||||
if (!data.includes('\x1b]')) {
|
||||
return []
|
||||
}
|
||||
const titles: string[] = []
|
||||
for (const m of data.matchAll(OSC_TITLE_RE)) {
|
||||
titles.push(m[2])
|
||||
}
|
||||
return titles
|
||||
}
|
||||
Reference in New Issue
Block a user