mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
* 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
239 lines
7.3 KiB
TypeScript
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
|
|
}
|