mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
* feat(agent-status): add an order-independent title evidence parser
The chain this will replace is a first-match-wins scan of substring predicates, so its answer
is decided by list position rather than by how strong the evidence is. Four real recorded Grok
panes read as Codex today because Codex is checked first and their task text mentions it.
Reordering cannot fix that: whichever branch is hoisted, some other pair breaks.
collectAgentTitleEvidence collects every signal first and ranks by class afterwards:
vendor marker — a sigil or control sequence the agent itself emits; task text cannot forge it
anchored name — a name where a grammar reserves the position for identity (Orca's `- <agent>`
owner suffix, or the whole undecorated remainder)
free-text name — a name anywhere else
Anchored beats vendor marker, so `✳ agy` is Antigravity rather than Claude. Free text never
beats either, and never becomes identity on its own even as the only name present — an absent
icon is recoverable, a confidently wrong one is not. Conflicts within a class resolve to null.
No consumer imports this yet. Swapping getAgentLabel in place would move ~20 call sites at
once; each migrates behind the resolver, with its own evidence.
Measured against the live recorded-title corpus (747 distinct titles), 598 agree with the
current chain and 149 differ:
144 claude -> null spinner-only titles. Braille and quarter-circle frames are emitted by
many agents, so they prove the pane is busy and nothing about who it
is. These are answered by stronger signals once the resolver lands,
which is why the parser is not wired up on its own.
4 codex -> grok the misattributed Grok panes, fixed.
1 codex -> null a spinner-prefixed Claude pane whose task text names Codex.
Three parser defects were found and fixed by reviewing that delta rather than reasoning about
it: the OpenCode envelope was treated as a vetoable marker instead of an owning grammar, free
text was allowed to veto a vendor marker (which blinded 13 real Claude titles), and the owner
suffix matched the tail of a hyphenated worktree name (`review-14600-codex`).
* fix(agent-status): harden title evidence collection
* fix(agent-status): limit emitted display evidence
* fix(agent-status): anchor explicit title evidence
* test(agent-status): cover anchored token filtering
* fix(agent-status): complete explicit marker evidence
* fix(agent-status): recognize reserved owner ids
* fix(agent-status): reject cwd path titles
* fix(agent-status): avoid bare-name identity guesses
* fix(agent-status): require spinner for working labels
* fix(agent-status): honor opted-out synthetic profiles
* fix(agent-status): harden wrapper evidence boundaries
187 lines
6.9 KiB
TypeScript
187 lines
6.9 KiB
TypeScript
import {
|
|
AGY_AGENT_NAME_RE,
|
|
DROID_AGENT_NAME_RE,
|
|
HERMES_AGENT_NAME_RE,
|
|
titleHasAgentName,
|
|
titleHasAnyLegacyAgentName
|
|
} from './agent-name-token-match'
|
|
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
|
import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title'
|
|
import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
|
|
|
|
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 CURSOR_NATIVE_TITLE_LOWER = 'cursor agent'
|
|
|
|
// eslint-disable-next-line no-control-regex -- intentional unicode range
|
|
export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g
|
|
|
|
// Why: Claude Code 2.1.228 swapped its busy title spinner from braille to
|
|
// quarter circles (#13889), which read as "no agent" and looked like an exit.
|
|
// Reserve the whole quarter-circle block so a later frame addition cannot regress this.
|
|
export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g
|
|
|
|
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
|
|
}
|
|
// Why: Antigravity's models are named "Gemini <n.n> <Name>", so an agy pane's own
|
|
// title carries a whole `gemini` token. Gemini CLI is checked before Antigravity in
|
|
// getAgentLabel, so without this the model name wins and an agy pane reads as Gemini
|
|
// CLI. Only the token path defers — the four Gemini OSC glyphs stay decisive, and agy
|
|
// emits none of them.
|
|
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(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)
|
|
}
|
|
|
|
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 containsQuarterCircleSpinner(title: string): boolean {
|
|
for (const char of title) {
|
|
const codePoint = char.codePointAt(0)
|
|
if (codePoint !== undefined && codePoint >= 0x25d0 && codePoint <= 0x25d3) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Any spinner frame glyph an agent animates its OSC title with. Use this for
|
|
* generic "something is running" checks; agent-specific frame shapes (Grok,
|
|
* Pi, synthetic Cursor) stay pinned to their own glyph set.
|
|
*/
|
|
export function containsAgentSpinnerGlyph(title: string): boolean {
|
|
return containsBrailleSpinner(title) || containsQuarterCircleSpinner(title)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const CLAUDE_IDENTITY_FRAME_RE =
|
|
/^claude(?: code)?(?:\s+(?:ready|idle|done|working|thinking|running))?(?:\s*-\s*action required)?$/
|
|
|
|
export function isClaudeIdentityFrameSegment(title: string): boolean {
|
|
return CLAUDE_IDENTITY_FRAME_RE.test(
|
|
stripLeadingAgentTitleDecorationOrEmpty(title).trim().toLowerCase()
|
|
)
|
|
}
|
|
|
|
export function isClaudeIdentityFrameTitle(title: string): boolean {
|
|
return getWrapperTitleSegments(title).some(isClaudeIdentityFrameSegment)
|
|
}
|
|
|
|
// Why: `cursor` is also an ordinary editor noun that other agents type into their own
|
|
// task-summary titles, so a name token is not identity. Cursor's identifying titles are
|
|
// a closed set (the native literal plus the labels Orca synthesizes from Cursor hooks),
|
|
// so match that vocabulary instead.
|
|
export function isCursorAgentTitle(title: string | null | undefined): boolean {
|
|
if (typeof title !== 'string') {
|
|
return false
|
|
}
|
|
const trimmed = title.trim()
|
|
const lower = trimmed.toLowerCase()
|
|
if (
|
|
lower === CURSOR_NATIVE_TITLE_LOWER ||
|
|
lower === 'cursor ready' ||
|
|
lower === 'cursor - action required'
|
|
) {
|
|
return true
|
|
}
|
|
// Why: display labels can mention Cursor in another agent's task text. Only
|
|
// treat the controlled synthetic Cursor spinner title as Cursor identity.
|
|
return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed)
|
|
}
|
|
|
|
// Why: cursor-agent re-emits its bare native title every redraw, which would stomp
|
|
// Orca's hook-synthesized spinner state, but only once a Cursor-owned title already
|
|
// owns the pane. A hookless Cursor pane still needs the literal once, for identity.
|
|
export function shouldSuppressCursorNativeTitle(lastEmittedTitle: string | null): boolean {
|
|
return lastEmittedTitle !== null && isCursorAgentTitle(lastEmittedTitle)
|
|
}
|