Files
orca/src/shared/agent-title-identity.ts
T
Brennan Benson 137e724119 fix(agent-title): treat Claude Code quarter-circle spinners as working (#13889) (#13925)
* fix(agent-title): treat Claude Code quarter-circle spinners as working

Claude Code 2.1.228 swapped its busy OSC title spinner from braille
(U+2800-U+28FF) to quarter circles (U+25D0/U+25D1). Orca recognized a busy
Claude title only by braille codepoints, so the new frames matched nothing.

The summary-bearing busy frame ("<glyph> Say hi in one word") carries no
"claude" name token, so it resolved to no-status. The tracker's "idle or
permission followed by no-status means the agent exited" rule then fired
mid-turn, confirmPtyAgentExit confirmed it, and the chat surface routed
exitChat -- kicking the tab to the terminal view on every message.

Widen the accepted glyph set via a shared containsAgentSpinnerGlyph helper.
Agent-specific braille frame shapes (Grok, Pi, synthetic Cursor) stay pinned
to their own glyph set.

Fixes #13889

* fix(agent-title): satisfy static analysis and trim scope
2026-08-11 21:13:27 -07:00

122 lines
3.6 KiB
TypeScript

import {
AGY_AGENT_NAME_RE,
CLAUDE_IDLE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
containsAgentSpinnerGlyph,
isClaudeManagementTitle,
isCursorAgentTitle,
isGeminiTerminalTitle,
isPiAgentTitle,
titleHasAgentName
} from './agent-title-core'
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title'
/**
* 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) || isOpenCodeNativeTitle(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 (containsAgentSpinnerGlyph(title)) {
// Why: named non-Claude agents carry braille spinners too. Gate Cursor by its
// identity title, not the token, so a Claude title mentioning a cursor stays Claude.
return !isCursorAgentTitle(title) && !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: the native marker owns the whole title; its session text may name or
// include status glyphs from other agents without changing OpenCode identity.
if (isOpenCodeNativeTitle(title)) {
return 'OpenCode'
}
// 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'
}
// 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
}
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, '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: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed
// title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame.
if (isCursorAgentTitle(title)) {
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
}