Fix Claude agents management status detection (#5179)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c47e0d1848
commit
1ae8ebdc3f
File diff suppressed because it is too large
Load Diff
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export {
|
|||
normalizeTerminalTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isClaudeAgent,
|
||||
isClaudeManagementTitle,
|
||||
getAgentLabel
|
||||
} from '../../../shared/agent-detection'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue