Fix Claude agents management status detection (#5179)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-11 01:43:38 -07:00 committed by GitHub
parent c47e0d1848
commit 1ae8ebdc3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1487 additions and 67 deletions

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
import {
extractLastOscTitle,
detectAgentStatusFromTitle,
isClaudeManagementTitle,
isShellProcess
} from '../../shared/agent-detection'
import type { AgentStatus } from '../../shared/agent-detection'
@ -672,6 +673,8 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & {
// serving a stale `lastAgentStatus` after the agent process exits and the
// shell takes over the title — the bug behind issue #1437.
lastOscTitle: string | null
lastOscTitleAt: number | null
paneTitleUpdatedAt: number | null
}
function isCursorAgentOrchestrationTarget(
@ -712,7 +715,11 @@ type RuntimePtyWorktreeRecord = {
lastExitCode: number | null
lastAgentStatus: AgentStatus | null
lastOscTitle: string | null
lastOscTitleAt: number | null
managementTitle: string | null
managementTitleAt: number | null
title: string | null
titleUpdatedAt: number | null
lastOutputAt: number | null
tailBuffer: string[]
tailPartialLine: string
@ -1388,6 +1395,7 @@ export class OrcaRuntimeService {
// iterates them all. Listeners are cleaned up via subscriptionCleanups.
private notificationListeners = new Set<(event: MobileNotificationEvent) => void>()
private ptysById = new Map<string, RuntimePtyWorktreeRecord>()
private titleObservationSequence = 0
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
private ptyOutputSequenceById = new Map<string, number>()
// Why: OSC 9999 status can span PTY chunks. Keeping parser state in the
@ -2065,6 +2073,7 @@ export class OrcaRuntimeService {
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
this.syncMobileSessionTabs(graph.mobileSessionTabs)
const nextLeaves = new Map<string, RuntimeLeafRecord>()
const graphSyncedAt = this.nextTitleObservationSequence()
// Why: renderer reloads can briefly republish the same leaf with no ptyId;
// keep live CLI handles usable while the UI graph rebuilds.
@ -2095,7 +2104,12 @@ export class OrcaRuntimeService {
tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0,
preview: existing?.ptyId === ptyId ? existing.preview : '',
lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null,
lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null
lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null,
lastOscTitleAt: existing?.ptyId === ptyId ? existing.lastOscTitleAt : null,
paneTitleUpdatedAt:
existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle
? existing.paneTitleUpdatedAt
: graphSyncedAt
})
if (leaf.ptyId) {
@ -2356,7 +2370,7 @@ export class OrcaRuntimeService {
args: { tabId: string; leafId: string; title: string | null; activate: boolean }
): void {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const title = args.title ?? pty.title ?? pty.lastOscTitle ?? 'Terminal'
const title = args.title ?? getLatestPtyTitle(pty) ?? 'Terminal'
const existingTab = existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' &&
@ -3347,8 +3361,11 @@ export class OrcaRuntimeService {
if (oscTitle !== null) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
const observedAt = this.nextTitleObservationSequence()
pty.lastOscTitle = oscTitle
pty.lastOscTitleAt = observedAt
pty.lastAgentStatus = agentStatus
this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt)
shouldTouchPtyBackedSessionTabs =
prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
@ -3407,6 +3424,7 @@ export class OrcaRuntimeService {
// way to clear a stale 'working' status after the agent exited and
// the shell took over the title — the stuck-spinner bug in #1437.
leaf.lastOscTitle = oscTitle
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
const prevStatus = leaf.lastAgentStatus
// Why: when a new OSC title doesn't classify as an agent state (e.g.
// bare shell title after the agent exits), clear lastAgentStatus so
@ -3753,11 +3771,19 @@ export class OrcaRuntimeService {
return
}
const status = detectAgentStatusFromTitle(title)
const pty = this.ptysById.get(ptyId)
if (pty) {
const observedAt = this.nextTitleObservationSequence()
pty.lastOscTitle = title
pty.lastOscTitleAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, title, 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.lastOscTitleAt = this.nextTitleObservationSequence()
if (status !== null) {
leaf.lastAgentStatus = status
}
@ -11185,7 +11211,15 @@ export class OrcaRuntimeService {
this.registerPty(result.id, worktree.id, repo?.connectionId ?? null)
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
pty.title = opts.title ?? null
if (opts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = opts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, opts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
}
pty.tabId = tabId
pty.paneKey = paneKey
}
@ -11676,7 +11710,7 @@ export class OrcaRuntimeService {
const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '')
const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, {
ptyId: pty.pty.ptyId,
title: pty.pty.title ?? pty.pty.lastOscTitle,
title: getLatestPtyTitle(pty.pty),
...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}),
...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {})
})
@ -12665,6 +12699,7 @@ export class OrcaRuntimeService {
): RuntimePtyWorktreeRecord {
let pty = this.ptysById.get(ptyId)
if (!pty) {
const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null
pty = {
ptyId,
worktreeId,
@ -12676,7 +12711,11 @@ export class OrcaRuntimeService {
lastExitCode: null,
lastAgentStatus: null,
lastOscTitle: null,
lastOscTitleAt: null,
managementTitle: null,
managementTitleAt: null,
title: state.title ?? null,
titleUpdatedAt: titleObservedAt,
lastOutputAt: state.lastOutputAt ?? null,
tailBuffer: [],
tailPartialLine: '',
@ -12684,6 +12723,9 @@ export class OrcaRuntimeService {
tailLinesTotal: 0,
preview: state.preview ?? ''
}
if (state.title) {
this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0)
}
this.ptysById.set(ptyId, pty)
// Why: restored/controller-discovered PTYs learn their worktree here
// without registerPty(), so URL enrichment must bind at this source.
@ -12713,7 +12755,10 @@ export class OrcaRuntimeService {
pty.preview = state.preview
}
if (state.title !== undefined && state.title !== null && state.title.length > 0) {
const observedAt = this.nextTitleObservationSequence()
pty.title = state.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, state.title, observedAt)
}
// Why: recordPtyWorktree is the common lifecycle point for every path that
// resolves a PTY's worktree, including renderer restore and controller list.
@ -12766,8 +12811,7 @@ export class OrcaRuntimeService {
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
if (worktreeId) {
this.recordPtyWorktree(session.id, worktreeId, {
connected: true,
title: session.title
connected: true
})
}
}
@ -12880,7 +12924,7 @@ export class OrcaRuntimeService {
branch: worktree?.branch ?? '',
tabId: leaf.tabId,
leafId: leaf.leafId,
title: tab?.title ?? null,
title: getLatestLeafTitle(leaf, tab?.title ?? null),
connected: leaf.connected,
writable: leaf.writable,
lastOutputAt: leaf.lastOutputAt,
@ -13260,6 +13304,26 @@ export class OrcaRuntimeService {
const paneKey = isTerminalLeafId(tab.leafId)
? makePaneKey(tab.parentTabId, tab.leafId)
: `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}`
const leafTitle = leaf
? getLatestAgentCandidateTitle(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }
)
: null
const ptyTitle = pty
? getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
: null
const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
const liveTitleEvidence = leafTitle ?? ptyTitle
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
const agentStatus =
tab.agentStatus &&
(liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent')
? { agentStatus: tab.agentStatus }
: null
// Why: web/mobile clients hold these handles across renderer graph syncs;
// leaf handles are graph-epoch-bound, but PTY handles remain streamable.
const terminalHandle = liveLeafPtyId
@ -13278,12 +13342,10 @@ export class OrcaRuntimeService {
id: tab.id,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.lastOscTitle ?? pty?.title ?? tab.title,
title,
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
...(tab.agentStatus
? { agentStatus: tab.agentStatus }
: this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
isActive: tab.isActive,
...(terminalHandle
@ -13338,6 +13400,14 @@ export class OrcaRuntimeService {
if (!pty?.lastAgentStatus) {
return {}
}
const ptyTitle = getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
if (ptyTitle !== null && ptyTitleClassification !== 'agent') {
return {}
}
const now = pty.lastOutputAt ?? Date.now()
return {
agentStatus: {
@ -13354,7 +13424,7 @@ export class OrcaRuntimeService {
...(terminalHandle ? { terminalHandle } : {}),
worktreeId: pty.worktreeId,
tabId: tab.parentTabId,
terminalTitle: pty.lastOscTitle ?? pty.title ?? tab.title,
terminalTitle: getLatestPtyTitle(pty) ?? tab.title,
stateHistory: []
}
}
@ -13428,6 +13498,14 @@ export class OrcaRuntimeService {
getAgentStatusForHandle(handle: string): string | null {
try {
const { leaf } = this.getLiveLeafForHandle(handle)
const title = getLatestAgentCandidateTitle(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt },
{ title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 }
)
if (title) {
return detectAgentStatusFromTitle(title)
}
return leaf.lastAgentStatus
} catch {
return null
@ -13563,35 +13641,68 @@ export class OrcaRuntimeService {
return makePaneKey(record.tabId, record.leafId)
}
// Why: OSC title detection via onPtyData is the tightest signal for agent
// presence, but the runtime may not see PTY data for daemon-hosted terminals
// (the daemon adapter stubs getForegroundProcess). This checks three signals
// in order: (1) lastAgentStatus from PTY data OSC titles, (2) the renderer-
// synced tab title (which reflects OSC titles from the xterm instance), (3)
// retained ready-tail text, and (4) the PTY foreground process. Returns true
// if any signal indicates a non-shell agent is running.
private setPtyManagementTitleFromObservedTitle(
pty: RuntimePtyWorktreeRecord,
title: string | null | undefined,
observedAt: number
): void {
const trimmed = title?.trim()
if (!trimmed) {
return
}
if (isClaudeManagementTitle(trimmed)) {
pty.managementTitle = trimmed
pty.managementTitleAt = observedAt
return
}
if (
detectAgentStatusFromTitle(trimmed) !== null &&
observedAt >= (pty.managementTitleAt ?? -1)
) {
pty.managementTitle = null
pty.managementTitleAt = null
}
}
private nextTitleObservationSequence(): number {
this.titleObservationSequence += 1
return this.titleObservationSequence
}
// Why: title detection is the tightest signal for agent presence, but a
// Claude management title is negative evidence for task-capable activity.
// Check pane-scoped titles before tab fallback, then retained ready-tail text,
// stale title status, and foreground process.
async isTerminalRunningAgent(handle: string): Promise<boolean> {
try {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
return await this.isPtyRunningAgent(pty.pty)
const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId)
return await this.isPtyRunningAgent(pty.pty, leaf)
}
const { leaf } = this.getLiveLeafForHandle(handle)
if (leaf.lastAgentStatus !== null) {
return true
}
// Why: check both the leaf-level pane title (synced from the renderer's
// runtimePaneTitlesByTabId) and the tab-level title. The tab title already
// includes OSC-enriched agent indicators (e.g. ✳ prefix) synced from the
// renderer's xterm instance.
const titleToCheck = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title
if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) {
const paneTitle = getLatestLeafTitle(leaf, null)
const paneTitleClassification = classifyAgentTitle(paneTitle)
if (paneTitleClassification === 'agent') {
return true
}
const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null
const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral'
if (tabTitleClassification === 'agent') {
return true
}
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
if (isKnownReadyPromptPreview(waitText)) {
return true
}
const hasCurrentTitleEvidence = paneTitle !== null || tabTitle !== null
if (leaf.lastAgentStatus !== null && !hasCurrentTitleEvidence) {
return true
}
if (!leaf.ptyId || !this.ptyController) {
return false
}
@ -13599,24 +13710,61 @@ export class OrcaRuntimeService {
if (!fg) {
return false
}
// Why: Claude's management UI runs under the Claude process but is not a
// task-capable agent session. Suppress that process only; another foreground
// agent can take over before titles update.
if (
(paneTitleClassification === 'management' || tabTitleClassification === 'management') &&
isExpectedAgentProcess(fg, 'claude')
) {
return false
}
return !isShellProcess(fg)
} catch {
return false
}
}
private async isPtyRunningAgent(pty: RuntimePtyWorktreeRecord): Promise<boolean> {
if (pty.lastAgentStatus !== null) {
private async isPtyRunningAgent(
pty: RuntimePtyWorktreeRecord,
leaf: RuntimeLeafRecord | null = null
): Promise<boolean> {
const leafTitle = leaf
? getLatestAgentCandidateTitle(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }
)
: null
const leafTitleClassification = classifyAgentTitle(leafTitle)
if (leafTitleClassification === 'agent') {
return true
}
const titleToCheck = pty.lastOscTitle ?? pty.title
if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) {
const ptyTitle = getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
if (leafTitle === null && ptyTitleClassification === 'agent') {
return true
}
const managementTitleClassification = classifyLatestAgentTitle({
title: pty.managementTitle,
updatedAt: pty.managementTitleAt
})
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
if (isKnownReadyPromptPreview(waitText)) {
return true
}
// Why: stale status is only a fallback when no current title evidence
// exists; neutral titles such as shells should clear it.
if (
pty.lastAgentStatus !== null &&
leafTitle === null &&
ptyTitle === null &&
managementTitleClassification !== 'management'
) {
return true
}
if (!this.ptyController) {
return false
}
@ -13624,9 +13772,20 @@ export class OrcaRuntimeService {
if (!fg) {
return false
}
const shouldSuppressClaudeForeground =
leafTitle !== null
? leafTitleClassification === 'management'
: managementTitleClassification === 'management'
if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) {
return false
}
return !isShellProcess(fg)
}
private getPrimaryLeafForPty(ptyId: string): RuntimeLeafRecord | null {
return this.getLeavesForPty(ptyId)[0] ?? null
}
deliverPendingMessagesForHandle(handle: string): void {
try {
const { leaf } = this.getLiveLeafForHandle(handle)
@ -13740,7 +13899,7 @@ export class OrcaRuntimeService {
branch: worktree?.branch ?? '',
tabId: `pty:${pty.ptyId}`,
leafId: `pty:${pty.ptyId}`,
title: pty.lastOscTitle ?? pty.title,
title: getLatestPtyTitle(pty),
connected: pty.connected,
writable: pty.connected,
lastOutputAt: pty.lastOutputAt,
@ -15962,12 +16121,16 @@ function getLeafWorktreeStatus(
): RuntimeWorktreeStatus {
// Why: recompute from the live title each call so worktree.ps mirrors what
// the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer
// the runtime-tracked OSC title (covers daemon-hosted terminals) over the
// renderer-pushed leaf.title and the tab title. Falling back to
// lastAgentStatus only when no title is available preserves a sensible
// signal for very fresh leaves before any title has been observed.
const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? ''
const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus
// the freshest pane/OSC title, then tab title. Falling back to lastAgentStatus
// only when no title is available preserves a sensible signal for very fresh
// leaves before any title has been observed.
const titleCandidates = [
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt },
{ title: tabTitle, updatedAt: 0 }
]
const latestTitle = getLatestAgentCandidateTitle(...titleCandidates)
const detected = latestTitle ? detectAgentStatusFromTitle(latestTitle) : leaf.lastAgentStatus
if (detected === 'permission') {
return 'permission'
}
@ -15977,6 +16140,54 @@ function getLeafWorktreeStatus(
return leaf.ptyId ? 'active' : 'inactive'
}
function classifyLatestAgentTitle(
...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[]
): 'agent' | 'management' | 'neutral' {
return classifyAgentTitle(getLatestAgentCandidateTitle(...titles))
}
function getLatestPtyTitle(pty: RuntimePtyWorktreeRecord): string | null {
return getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
}
function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): string | null {
return getLatestAgentCandidateTitle(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt },
{ title: tabTitle, updatedAt: 0 }
)
}
function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' {
if (!title) {
return 'neutral'
}
if (isClaudeManagementTitle(title)) {
return 'management'
}
return detectAgentStatusFromTitle(title) !== null ? 'agent' : 'neutral'
}
function getLatestAgentCandidateTitle(
...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[]
): string | null {
let latest: { title: string; updatedAt: number } | null = null
for (const candidate of titles) {
const title = candidate.title?.trim()
if (!title) {
continue
}
const updatedAt = candidate.updatedAt ?? 0
if (!latest || updatedAt > latest.updatedAt) {
latest = { title, updatedAt }
}
}
return latest?.title ?? null
}
function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus {
const detected = detectAgentStatusFromTitle(title)
if (detected === 'permission') {

View File

@ -101,4 +101,20 @@ describe('deriveWorktreeCardStatus', () => {
expect(status).toBe('done')
})
it('stays active when the only live terminal signal is the Claude agents screen', () => {
const status = deriveWorktreeCardStatus({
tabs: [makeTerminalTab('claude agents')],
browserTabs: [],
worktreeAgentEntries: [],
runtimePaneTitlesByTabId: {
'tab-1': {
1: 'claude agents'
}
},
now: 1_000
})
expect(status).toBe('active')
})
})

View File

@ -138,6 +138,22 @@ describe('buildTitleDerivedAgentRows', () => {
expect(rows).toHaveLength(0)
})
it('does not add title-derived rows for the Claude agents management screen', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [],
retained: [],
runtimePaneTitlesByTabId: {
'tab-1': { 1: 'claude agents' }
},
ptyIdsByTabId: { 'tab-1': ['pty-claude-agents'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows).toHaveLength(0)
})
it('does not turn generic Codex-launched task titles into Claude Code rows', () => {
const launchAgent: TuiAgent = 'codex'
const rows = buildWorktreeAgentRows({

View File

@ -10,6 +10,7 @@ import {
getAgentLabel,
isGeminiTerminalTitle,
isClaudeAgent,
isClaudeManagementTitle,
normalizeTerminalTitle,
isExplicitAgentStatusFresh,
mapAgentStatusStateToVisualStatus,
@ -170,6 +171,22 @@ describe('detectAgentStatusFromTitle', () => {
expect(detectAgentStatusFromTitle('⠋ OpenClaude')).toBe('working')
})
it('excludes the exact Claude agents management title', () => {
expect(detectAgentStatusFromTitle('claude agents')).toBeNull()
expect(detectAgentStatusFromTitle(' Claude Agents ')).toBeNull()
expect(detectAgentStatusFromTitle('claude.exe agents')).toBeNull()
expect(detectAgentStatusFromTitle('Claude.CMD agents')).toBeNull()
expect(detectAgentStatusFromTitle('claude.bat agents')).toBeNull()
expect(detectAgentStatusFromTitle('Claude.PS1 agents')).toBeNull()
expect(
detectAgentStatusFromTitle('C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd agents')
).toBeNull()
expect(
detectAgentStatusFromTitle('"C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd" agents')
).toBeNull()
expect(detectAgentStatusFromTitle('claude agents working')).toBe('working')
})
it('detects Pi idle titles', () => {
expect(detectAgentStatusFromTitle('π - my-project')).toBe('idle')
expect(detectAgentStatusFromTitle('π - session-name - my-project')).toBe('idle')
@ -409,6 +426,10 @@ describe('getAgentLabel', () => {
expect(getAgentLabel('Hermes ready')).toBe('Hermes')
})
it('does not label the Claude agents management title', () => {
expect(getAgentLabel('claude agents')).toBeNull()
})
it('labels GitHub Copilot CLI', () => {
expect(getAgentLabel('copilot working')).toBe('GitHub Copilot')
expect(getAgentLabel('copilot idle')).toBe('GitHub Copilot')
@ -452,6 +473,21 @@ describe('isClaudeAgent', () => {
expect(isClaudeAgent('ask claude later')).toBe(false)
expect(getAgentLabel('ask claude later')).toBeNull()
})
it('does not classify the Claude agents management title as a Claude agent', () => {
expect(isClaudeManagementTitle(' Claude Agents ')).toBe(true)
expect(isClaudeManagementTitle('claude.exe agents')).toBe(true)
expect(isClaudeManagementTitle('claude.cmd agents')).toBe(true)
expect(isClaudeManagementTitle('claude.bat agents')).toBe(true)
expect(isClaudeManagementTitle('claude.ps1 agents')).toBe(true)
expect(
isClaudeManagementTitle('C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd agents')
).toBe(true)
expect(
isClaudeManagementTitle('"C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd" agents')
).toBe(true)
expect(isClaudeAgent('claude agents')).toBe(false)
})
})
describe('createAgentStatusTracker', () => {

View File

@ -18,6 +18,7 @@ export {
normalizeTerminalTitle,
isGeminiTerminalTitle,
isClaudeAgent,
isClaudeManagementTitle,
getAgentLabel
} from '../../../shared/agent-detection'
import {

View File

@ -704,6 +704,46 @@ describe('buildMobileSessionTabSnapshots', () => {
])
})
it('does not publish terminal pane agent status for the Claude agents screen behind a custom title', () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const paneKey = `term-1:${leafId}`
const state = makeState({
tabBarOrderByWorktree: { 'wt-1': ['term-1'] },
tabsByWorktree: {
'wt-1': [{ id: 'term-1', title: 'claude agents', customTitle: 'Pinned', ptyId: 'pty-1' }]
} as unknown as AppState['tabsByWorktree'],
terminalLayoutsByTabId: {
'term-1': {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-1' }
}
} as AppState['terminalLayoutsByTabId'],
agentStatusByPaneKey: {
[paneKey]: {
state: 'working',
prompt: 'stale task',
updatedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'claude',
paneKey,
terminalTitle: 'claude working',
stateHistory: []
}
}
})
const [tab] = buildMobileSessionTabSnapshots(state)[0]?.tabs ?? []
expect(tab).toMatchObject({
type: 'terminal',
id: `term-1::${leafId}`,
title: 'Pinned'
})
expect(tab).not.toHaveProperty('agentStatus')
})
it('publishes generated terminal titles to mobile snapshots only when enabled', () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const base = makeState({

View File

@ -24,6 +24,7 @@ import type {
} from '../../../shared/runtime-types'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
import type {
TabGroup,
TabGroupLayoutNode,
@ -1074,15 +1075,20 @@ function buildMobileTerminalSurfaceTabs(
? paneTitles[Number(legacyPaneId)]
: undefined
const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null
const agentStatus = paneKey ? state.agentStatusByPaneKey?.[paneKey] : undefined
const title = resolveRuntimeTerminalTitle(
terminal,
generatedTitlesEnabled,
paneTitle ?? terminal.title ?? 'Terminal'
)
const agentStatusTitle = paneTitle ?? terminal.title ?? ''
const agentStatus =
paneKey && !isClaudeManagementTitle(agentStatusTitle)
? state.agentStatusByPaneKey?.[paneKey]
: undefined
return {
type: 'terminal' as const,
id: mobileTerminalSurfaceId(terminal.id, leafId),
title: resolveRuntimeTerminalTitle(
terminal,
generatedTitlesEnabled,
paneTitle ?? terminal.title ?? 'Terminal'
),
title,
...(terminal.quickCommandLabel?.trim()
? { quickCommandLabel: terminal.quickCommandLabel.trim() }
: {}),

View File

@ -17,10 +17,13 @@ import {
// Re-export so existing `agent-detection` importers keep working.
export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match'
export { isShellProcess } from './shell-process-detection'
export type AgentStatus = 'working' | 'permission' | 'idle'
const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix)
const CLAUDE_MANAGEMENT_TITLE_RE =
/^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i
const GEMINI_WORKING = '\u2726' // ✦
const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
@ -299,7 +302,7 @@ export function normalizeTerminalTitle(title: string): string {
* agents have different (or no) caching semantics.
*/
export function isClaudeAgent(title: string): boolean {
if (!title) {
if (!title || isClaudeManagementTitle(title)) {
return false
}
const lower = title.toLowerCase()
@ -335,7 +338,14 @@ export function isClaudeAgent(title: string): boolean {
return false
}
export function isClaudeManagementTitle(title: string): boolean {
return CLAUDE_MANAGEMENT_TITLE_RE.test(title)
}
export function getAgentLabel(title: string): string | null {
if (isClaudeManagementTitle(title)) {
return null
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
@ -409,6 +419,9 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
if (!title) {
return null
}
if (isClaudeManagementTitle(title)) {
return null
}
// Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's
// native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor -
// action required") is either an Orca-synthesized working/permission title
@ -488,21 +501,3 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
return null
}
// Why: shared between the runtime (dispatch guard, tui-idle fallback) and the
// renderer (agent-ready-wait, new-workspace). A bare shell is the only process
// type that garbles injected preambles, so this is the negative signal for
// "is an agent running".
const SHELL_NAMES = new Set(
'|bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu'.split('|')
)
export function isShellProcess(processName: string): boolean {
const normalized = processName
.trim()
.replace(/^["']|["']$/g, '')
.toLowerCase()
return (
SHELL_NAMES.has(normalized) || SHELL_NAMES.has(normalized.split(/[\\/]/).pop() ?? normalized)
)
}

View File

@ -0,0 +1,16 @@
// Why: shared between the runtime (dispatch guard, tui-idle fallback) and the
// renderer (agent-ready-wait, new-workspace). A bare shell is the negative
// signal for "is an agent running" because it garbles injected preambles.
const SHELL_NAMES = new Set(
'|bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu'.split('|')
)
export function isShellProcess(processName: string): boolean {
const normalized = processName
.trim()
.replace(/^["']|["']$/g, '')
.toLowerCase()
return (
SHELL_NAMES.has(normalized) || SHELL_NAMES.has(normalized.split(/[\\/]/).pop() ?? normalized)
)
}