Files
orca/src/shared/agent-title-status.ts
T
Brennan Benson cb41f3c5f4 fix(terminal): require agent identity for guarded sends (STA-4028) (#14092)
* fix(terminal): require agent identity for guarded sends (STA-4028)

Quarter-circle spinner glyphs (U+25D0-U+25D3) started classifying a title
as "working" in #13925, and that status alone authorized guarded agent
sends — which auto-submit with Enter — into any pane whose TUI animates
those generic progress frames.

Keep the glyphs as an activity signal, but stop treating a title whose
only agent evidence is a quarter circle as proof an agent owns the pane:
send authorization now falls through to recognized-agent identity in the
title or a recognized foreground process. Braille-spinner behavior is
unchanged.

* fix(terminal): preserve verified managed busy identity

* fix(terminal): bind busy identity to process incarnation

* chore(test): avoid duplicate terminal gate suite
2026-08-12 16:22:06 -07:00

239 lines
7.3 KiB
TypeScript

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,
QUARTER_CIRCLE_SPINNER_RE,
STRONG_IDLE_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE_GLOBAL,
containsAgentName,
containsAgentSpinnerGlyph,
containsAny,
containsQuarterCircleSpinner,
containsLegacyAgentName,
isClaudeManagementTitle,
isGeminiTerminalTitle,
isPiAgentTitle,
isPiTerminalTitle
} from './agent-title-core'
import type { AgentStatus } from './agent-title-core'
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title'
import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type'
/**
* 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, '')
cleaned = cleaned.replace(QUARTER_CIRCLE_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
restoreLastExit: () => AgentStatus | null
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
let restorableExitStatus: AgentStatus | null = null
return {
handleTitle(title: string): void {
const newStatus = detectAgentStatusFromTitle(title)
if (newStatus !== null) {
restorableExitStatus = null
}
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) {
restorableExitStatus = lastStatus
lastStatus = null
onAgentExited?.()
}
if (newStatus !== null) {
lastStatus = newStatus
}
},
seedTitle(title: string): void {
lastStatus = detectAgentStatusFromTitle(title)
restorableExitStatus = null
},
restoreLastExit(): AgentStatus | null {
const restoredStatus = lastStatus === null ? restorableExitStatus : null
if (restoredStatus !== null) {
lastStatus = restoredStatus
}
restorableExitStatus = null
return restoredStatus
},
reset(): void {
lastStatus = null
restorableExitStatus = 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'
}
}
// Why: Grok Build interpolates a rotating status/tool phrase between the
// spinner and its name, so its working frames change the title many times per
// turn. Collapse them to one stable label; idle/session titles carry no
// spinner and pass through, so the meaningful final title still shows (#7863).
if (isGrokRotatingWorkingTitle(title)) {
return '\u280b Grok'
}
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 (isOpenCodeNativeTitle(title)) {
return containsAgentSpinnerGlyph(title) ? 'working' : 'idle'
}
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
}
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
return 'idle'
}
if (isPiTerminalTitle(title)) {
return 'idle'
}
if (containsAgentSpinnerGlyph(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'
}
/**
* True when a quarter-circle spinner frame is the only agent evidence a title carries.
* Any TUI animates those glyphs, so they prove activity, not identity — callers that
* authorize writes into the pane need independent evidence (STA-4028, regression #13925).
*/
export function isQuarterCircleSpinnerOnlyAgentTitle(title: string | null | undefined): boolean {
if (!title || !containsQuarterCircleSpinner(title)) {
return false
}
return detectAgentStatusFromTitle(title.replace(QUARTER_CIRCLE_SPINNER_RE, '').trim()) === null
}