Fix Grok and Pi terminal title normalization and status detection (#7880)

* fix(mobile): normalize Grok rotating OSC titles at the main observation boundary

Desktop already collapses Grok Build's rotating working frames via the
renderer's normalizeTerminalTitle, but the main process stored raw OSC
titles, so mobile session tabs (fed from pty.lastOscTitle) still saw a
distinct title every spinner frame and re-touched snapshots each time.

Apply normalizeTerminalTitle once where main records an observed OSC
title, before the prevTitle comparison that gates session-tab and
mobile-snapshot touches, and normalize hydration-seeded titles the same
way so the first live frame after a seed compares equal. Agent status
stays detected from the raw title, mirroring the renderer tracker.

Covers remoted/SSH PTYs too since they surface through the same main
observation path.

Co-authored-by: Orca <help@stably.ai>

* Fix Grok and Pi terminal title normalization and status detection

- Require a strict "spinner - phrase - grok" pattern for Grok working
  titles to prevent false positives on other agent tasks ending in
  "- grok" (such as Claude or Codex).
- Treat collapsed "Pi" and "OMP" synthetic titles as idle by default
  to prevent them from reverting to neutral status after normalization.
- Prevent duplicate mobile session tab updates during Grok status
  frame rotations by ensuring they normalize to a stable title.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-08 21:22:06 -07:00
committed by GitHub
co-authored by Orca
parent 7f3e851696
commit 6b4831d5ef
7 changed files with 174 additions and 25 deletions
+117 -1
View File
@@ -21,7 +21,7 @@ import type {
WorkspaceSessionState
} from '../../shared/types'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
import { MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection'
import { detectAgentStatusFromTitle, MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection'
import {
addWorktree,
assertWorktreeCleanForRemoval,
@@ -11272,6 +11272,122 @@ describe('OrcaRuntimeService', () => {
expect(pty?.lastAgentStatus).toBe('idle')
})
it('normalizes rotating Grok working-frame OSC titles to one stable stored title', async () => {
const runtime = new OrcaRuntimeService(store)
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;⠋ - Waiting for response… - grok\x07', 100)
const pty = (
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).ptysById.get('pty-1')
expect(pty?.lastOscTitle).toBe('⠋ Grok')
expect(pty?.lastAgentStatus).toBe('working')
// A different rotating frame must store an identical title — title
// equality is what stops per-frame session-tab and mobile-snapshot touch.
runtime.onPtyData('pty-1', '\x1b]0;⠴ - Thinking - grok\x07', 101)
expect(pty?.lastOscTitle).toBe('⠋ Grok')
expect(pty?.lastAgentStatus).toBe('working')
})
it('does not republish mobile session tabs for same-status Grok title frames', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
events.length = 0
runtime.onPtyData('laptop-created-pty', '\x1b]0;⠋ - Waiting for response… - grok\x07', 100)
runtime.onPtyData('laptop-created-pty', '\x1b]0;⠴ - Thinking - grok\x07', 101)
runtime.onPtyData('laptop-created-pty', '\x1b]0;⠙ - Responding - grok\x07', 102)
expect(events).toHaveLength(1)
expect(events[0]?.tabs[0]).toEqual(
expect.objectContaining({
type: 'terminal',
title: '⠋ Grok',
agentStatus: expect.objectContaining({ state: 'working' })
})
)
unsubscribe()
})
it('stores normalized Pi idle OSC titles that still classify as idle', async () => {
const runtime = new OrcaRuntimeService(store)
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;π - my-project\x07', 100)
const pty = (
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).ptysById.get('pty-1')
expect(pty?.lastOscTitle).toBe('Pi')
expect(pty?.lastAgentStatus).toBe('idle')
// Why: worktree.ps / mobile re-detect from stored lastOscTitle, not the
// raw OSC frame. Bare "Pi" must still classify as idle after normalize.
expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle')
})
it('normalizes hydration-seeded Grok and Pi titles the same as live OSC frames', async () => {
const runtime = new OrcaRuntimeService(store)
syncSinglePty(runtime)
;(
runtime as unknown as {
applySeededAgentStatus: (ptyId: string, title: string) => void
}
).applySeededAgentStatus('pty-1', '⠴ - Thinking - grok')
const pty = (
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
}
).ptysById.get('pty-1')
expect(pty?.lastOscTitle).toBe('⠋ Grok')
// Seed writes leaf status only; re-detect from the stored title must still
// report working so later live frames compare equal and do not thrash.
expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('working')
;(
runtime as unknown as {
applySeededAgentStatus: (ptyId: string, title: string) => void
}
).applySeededAgentStatus('pty-1', 'π - my-project')
expect(pty?.lastOscTitle).toBe('Pi')
expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle')
})
it('stores other-agent OSC titles that merely end in grok unchanged', async () => {
const runtime = new OrcaRuntimeService(store)
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;⠋ wire up grok\x07', 100)
const pty = (
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null }>
}
).ptysById.get('pty-1')
expect(pty?.lastOscTitle).toBe('⠋ wire up grok')
// Claude/Codex braille + task ending " - grok" is not a Grok frame shape.
runtime.onPtyData('pty-1', '\x1b]0;⠋ fix the flaky suite - grok\x07', 101)
expect(pty?.lastOscTitle).toBe('⠋ fix the flaky suite - grok')
})
it('seeds newly synced leaves from PTY pending ANSI state', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
+18 -6
View File
@@ -5,7 +5,8 @@ import {
extractLastOscTitle,
detectAgentStatusFromTitle,
isClaudeManagementTitle,
isShellProcess
isShellProcess,
normalizeTerminalTitle
} from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
@@ -5336,8 +5337,15 @@ export class OrcaRuntimeService {
// strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.)
// announce status via OSC 0/1/2 title sequences — this is the same
// detection path the renderer uses for notifications and sidebar badges.
const oscTitle = this.extractLastOscTitleForPty(ptyId, data)
const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null
const rawOscTitle = this.extractLastOscTitleForPty(ptyId, data)
// Why: collapse high-churn agent titles (Grok/Pi spinner frames, Gemini
// per-keystroke updates) once at the observation boundary so lastOscTitle —
// and the mobile session-tab titles/snapshots derived from it — stays
// stable instead of changing every animation frame. Status is detected
// from the raw title (mirroring the renderer's tracker) so working/idle
// transitions are unaffected by normalization.
const oscTitle = rawOscTitle === null ? null : normalizeTerminalTitle(rawOscTitle)
const agentStatus = rawOscTitle ? detectAgentStatusFromTitle(rawOscTitle) : null
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
let shouldTouchPtyBackedSessionTabs = false
@@ -5975,18 +5983,22 @@ export class OrcaRuntimeService {
return
}
const status = detectAgentStatusFromTitle(title)
// Why: live observations store normalized titles, so seeds must match —
// otherwise the first live frame after hydration compares unequal and
// touches session tabs once for no visible change.
const seededTitle = normalizeTerminalTitle(title)
const pty = this.ptysById.get(ptyId)
if (pty) {
const observedAt = this.nextTitleObservationSequence()
pty.lastOscTitle = title
pty.lastOscTitle = seededTitle
pty.lastOscTitleAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, title, observedAt)
this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
// Why: seed lastOscTitle even when the seeded title doesn't classify
// as an agent state, so worktree.ps recomputes status from the live
// title rather than treating the leaf as agentless.
leaf.lastOscTitle = title
leaf.lastOscTitle = seededTitle
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
if (status !== null) {
leaf.lastAgentStatus = status
+5 -1
View File
@@ -411,12 +411,16 @@ describe('normalizeTerminalTitle', () => {
// Names "grok" mid-title but ends with another agent → not a Grok frame.
expect(normalizeTerminalTitle('⠋ debugging grok - claude')).toBe('⠋ debugging grok - claude')
// Claude/Codex task text ending in "grok" must not collapse — only the
// Grok Build identity suffix " - grok" is the rotating-frame signal.
// Grok frame shape "spinner - phrase - grok" is the rotating-frame signal.
expect(normalizeTerminalTitle('⠋ wire up grok')).toBe('⠋ wire up grok')
expect(normalizeTerminalTitle('⠋ Codex is thinking about grok')).toBe(
'⠋ Codex is thinking about grok'
)
expect(normalizeTerminalTitle('⠋ support for Grok')).toBe('⠋ support for Grok')
// Trailing " - grok" alone is not enough without the post-spinner delimiter.
expect(normalizeTerminalTitle('⠋ fix the flaky suite - grok')).toBe(
'⠋ fix the flaky suite - grok'
)
})
it('collapses Pi spinner and idle titles to stable labels', () => {
+14 -1
View File
@@ -5,7 +5,8 @@ import {
extractAllOscTitles,
extractLastOscTitle,
getAgentLabel,
MAX_OSC_TITLE_CHARS
MAX_OSC_TITLE_CHARS,
normalizeTerminalTitle
} from './agent-detection'
import {
hasCompatibleAgentTitleIdentity,
@@ -88,15 +89,27 @@ describe('Pi-compatible title detection', () => {
it.each([
['\u280b OMP', 'OMP', 'working'],
['OMP ready', 'OMP', 'idle'],
['OMP', 'OMP', 'idle'],
['OMP - action required', 'OMP', 'permission'],
['\u280b Pi', 'Pi', 'working'],
['Pi ready', 'Pi', 'idle'],
// Why: normalizeTerminalTitle collapses idle π frames to bare "Pi"; re-detection
// from stored lastOscTitle must still classify idle, not neutral.
['Pi', 'Pi', 'idle'],
['Pi - action required', 'Pi', 'permission']
] as const)('classifies synthesized %s', (title, expectedLabel, expectedStatus) => {
expect(getAgentLabel(title)).toBe(expectedLabel)
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
})
it('re-detects status after display-title normalization for Pi idle frames', () => {
expect(normalizeTerminalTitle('π - my-project')).toBe('Pi')
expect(detectAgentStatusFromTitle(normalizeTerminalTitle('π - my-project'))).toBe('idle')
expect(detectAgentStatusFromTitle(normalizeTerminalTitle('\u280b π - my-project'))).toBe(
'working'
)
})
it.each([
['\u280b Pi', 'omp', '\u280b OMP'],
['Pi ready', 'omp', 'OMP ready'],
+5 -5
View File
@@ -3,7 +3,6 @@ export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle'
const PI_COMPATIBLE_SYNTHETIC_TITLE_RE =
/^\s*(?:[\u2800-\u28ff]\s+)?(pi|omp)(?:\s+-\s+action required|\s+(?:ready|idle|done))?\s*$/i
const PI_COMPATIBLE_IDLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
// Why: legacy Pi/OMP-compatible shells can emit the delimiter before cwd text exists.
const LEGACY_PI_COMPATIBLE_TITLE_RE = /^\s*(?:[\u2800-\u28ff]\s+)?π(?:\s*[-:]|\s)\s*.*$/u
@@ -44,10 +43,11 @@ export function getPiCompatibleSyntheticAgentStatus(
) {
return 'permission'
}
if (PI_COMPATIBLE_IDLE_RE.test(title)) {
return 'idle'
}
return null
// Why: bare "Pi"/"OMP" and ready/idle/done labels are all idle. Bare labels
// come from normalizeTerminalTitle collapsing π frames; they must re-detect
// as idle or stored lastOscTitle values classify as neutral after main-side
// normalization.
return 'idle'
}
export function isLegacyPiCompatibleTitle(title: string): boolean {
+5 -1
View File
@@ -21,10 +21,14 @@ describe('isGrokRotatingWorkingTitle', () => {
expect(isGrokRotatingWorkingTitle('⠋ ~/grok-scratch/ready')).toBe(false) // path fragment, not a trailing token
expect(isGrokRotatingWorkingTitle('⠋ grokking the plan')).toBe(false) // "grok" not a whole trailing token
expect(isGrokRotatingWorkingTitle('⠋ Codex')).toBe(false)
// Task text ending in "grok" is not the Grok Build identity suffix " - grok".
// Task text ending in "grok" is not the Grok frame shape "spinner - phrase - grok".
expect(isGrokRotatingWorkingTitle('⠋ wire up grok')).toBe(false)
expect(isGrokRotatingWorkingTitle('⠋ Codex is thinking about grok')).toBe(false)
expect(isGrokRotatingWorkingTitle('⠋ support for Grok')).toBe(false)
// Why: Claude/Codex braille + task can end with " - grok" without the
// post-spinner delimiter that marks a real Grok Build frame.
expect(isGrokRotatingWorkingTitle('⠋ fix the flaky suite - grok')).toBe(false)
expect(isGrokRotatingWorkingTitle('⠋ review grok integration - claude')).toBe(false)
})
})
+10 -10
View File
@@ -51,22 +51,22 @@ export function isPiTerminalTitle(title: string): boolean {
return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title)
}
// Why: Grok Build's working OSC titles interpolate a rotating status/tool phrase
// between the spinner and its name — "⠋ - Waiting for response - grok",
// "⠴ - Thinking - grok", "⠦ - Sleep 2s… - grok" — so every frame is a distinct
// title that flips the tab and sidebar labels. Require the identity suffix
// " - grok" (not arbitrary task text ending in "grok") so Claude/Codex titles
// like "⠋ wire up grok" stay intact. Spinner marks working; no-spinner session
// titles ("Fix the auth bug - grok") pass through. The bare "spinner + grok"
// branch keeps our own collapsed label idempotent under re-normalization.
const GROK_FRAME_IDENTITY_SUFFIX_RE = / - grok\s*$/i
// Why: Grok Build's working OSC titles use a fixed frame shape —
// "spinner - <rotating phrase> - grok" — so every frame is a distinct title
// that flips tab and sidebar labels. Require BOTH the post-spinner " - "
// delimiter and the trailing identity " - grok" so Claude/Codex task text
// like "⠋ fix the flaky suite - grok" or "⠋ wire up grok" is not mislabeled.
// Spinner marks working; no-spinner session titles ("Fix the auth bug - grok")
// pass through. The bare "spinner + grok" branch keeps our own collapsed
// label idempotent under re-normalization.
const GROK_ROTATING_FRAME_RE = /^[\u2800-\u28FF]+\s+-\s+[\s\S]+?\s-\s+grok\s*$/i
const GROK_COLLAPSED_WORKING_TITLE_RE = /^[\u2800-\u28FF]+\s+grok\s*$/i
export function isGrokRotatingWorkingTitle(title: string): boolean {
if (!containsBrailleSpinner(title)) {
return false
}
return GROK_FRAME_IDENTITY_SUFFIX_RE.test(title) || GROK_COLLAPSED_WORKING_TITLE_RE.test(title)
return GROK_ROTATING_FRAME_RE.test(title) || GROK_COLLAPSED_WORKING_TITLE_RE.test(title)
}
export function isPiAgentTitle(title: string): boolean {