fix(agent-status): reconcile fresh pane identity

This commit is contained in:
Merge Sim
2026-08-31 11:12:44 -07:00
parent 92470aac30
commit 27702f130b
17 changed files with 588 additions and 92 deletions
@@ -29,7 +29,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
hasInterrupted,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
agentStatusPaneIdsByTabId,
paneForegroundAgentByPaneKey
} = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
// Why: compact and detailed cards need the same status-dot semantics:
@@ -44,6 +45,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
agentStatusPaneIdsByTabId,
terminalLayoutRootsByTabId,
paneForegroundAgentByPaneKey,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
@@ -58,6 +60,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
runtimePaneTitlesForWorktree,
agentStatusPaneIdsByTabId,
terminalLayoutRootsByTabId,
paneForegroundAgentByPaneKey,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
@@ -37,7 +37,8 @@ export function selectWorktreeActivityStatuses(
hasInterrupted,
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId
agentStatusPaneIdsByTabId,
paneForegroundAgentByPaneKey
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
statuses.set(
worktreeId,
@@ -48,6 +49,7 @@ export function selectWorktreeActivityStatuses(
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId),
agentStatusPaneIdsByTabId,
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId),
paneForegroundAgentByPaneKey,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
@@ -267,6 +267,24 @@ describe('buildWorktreeAgentRows', () => {
expect(done?.state).toBe('done')
})
it('lets fresh pane activity replace a stale done row for the same pane', () => {
const staleAt = 1_000
const now = staleAt + AGENT_STATUS_STALE_AFTER_MS + 1
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'zsh' })],
entries: [makeEntry(PANE_KEY_1, staleAt, { state: 'done', updatedAt: staleAt })],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Claude Code' } },
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
terminalLayoutsByTabId: { 'tab-1': makeSinglePaneLayout(LEAF_ID_1) },
now
})
expect(rows.map((row) => [row.paneKey, row.state, row.agentType])).toEqual([
[PANE_KEY_1, 'working', 'claude']
])
})
it('decays a restored-unconfirmed working entry to idle even while recent', () => {
// Why: a hydrated nonterminal row may describe a turn that ended while no
// receiver was up; it must never render as confirmed working, however new.
@@ -20,6 +20,10 @@ import {
createWorktreeAgentFreshnessSelector,
EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
} from './worktree-agent-freshness-selector'
import {
EMPTY_PANE_FOREGROUND_AGENTS,
selectWorktreeAgentActivitySummary
} from './worktree-agent-activity-summary'
export { buildWorktreeAgentRows } from './worktree-agent-rows'
export {
@@ -74,6 +78,13 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
const runtimeAgentOrchestrationByPaneKey = useAppStore(
useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {}))
)
// Reuse the worktree-narrowed process identity projection so hookless live
// panes can still appear without subscribing every card to the global map.
const paneForegroundAgentByPaneKey = useAppStore((s) =>
active
? selectWorktreeAgentActivitySummary(s, worktreeId).paneForegroundAgentByPaneKey
: EMPTY_PANE_FOREGROUND_AGENTS
)
const agentFreshnessSignature = useAppStore((s) =>
active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
)
@@ -104,6 +115,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
ptyIdsByTabId,
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
now
})
)
@@ -118,6 +130,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
ptyIdsByTabId,
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
agentFreshnessSignature
])
}
@@ -11,6 +11,7 @@ import {
type AgentStatusEntry,
type AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
export type WorktreeAgentActivitySummary = {
hasPermission: boolean
@@ -21,9 +22,12 @@ export type WorktreeAgentActivitySummary = {
hasLiveDone: boolean
hasRetainedDone: boolean
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
/** Process identity narrowed to this worktree's panes. */
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
}
const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record<string, ReadonlySet<string>> = {}
export const EMPTY_PANE_FOREGROUND_AGENTS: Record<string, PaneForegroundAgentEntry> = {}
const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasPermission: false,
@@ -32,7 +36,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasInterrupted: false,
hasLiveDone: false,
hasRetainedDone: false,
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID,
paneForegroundAgentByPaneKey: EMPTY_PANE_FOREGROUND_AGENTS
}
type AgentActivityTabsByWorktree = Record<string, readonly { id: string }[]>
@@ -46,6 +51,7 @@ export type AgentActivityInput = Pick<
> & {
tabsByWorktree: AgentActivityTabsByWorktree
runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey']
paneForegroundAgentByPaneKey?: AppState['paneForegroundAgentByPaneKey']
}
type AgentActivityCache = {
@@ -54,6 +60,7 @@ type AgentActivityCache = {
migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId']
retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey']
runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined
paneForegroundAgentByPaneKey: AppState['paneForegroundAgentByPaneKey'] | undefined
summaries: Map<string, WorktreeAgentActivitySummary>
}
@@ -70,13 +77,15 @@ function getWorktreeAgentActivitySummaries(
state: AgentActivityInput
): Map<string, WorktreeAgentActivitySummary> {
const runtimeAgentOrchestrationByPaneKey = state.runtimeAgentOrchestrationByPaneKey
const paneForegroundAgentByPaneKey = state.paneForegroundAgentByPaneKey
if (
agentActivityCache &&
agentActivityCache.tabsByWorktree === state.tabsByWorktree &&
agentActivityCache.agentStatusEpoch === state.agentStatusEpoch &&
agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId &&
agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey &&
agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey
agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey &&
agentActivityCache.paneForegroundAgentByPaneKey === paneForegroundAgentByPaneKey
) {
return agentActivityCache.summaries
}
@@ -138,6 +147,23 @@ function getWorktreeAgentActivitySummaries(
}
}
// Narrow process identity to each worktree so sidebar rows can attribute
// title-derived activity without subscribing to every pane globally.
for (const [paneKey, entry] of Object.entries(paneForegroundAgentByPaneKey ?? {})) {
if (!entry.agent) {
continue
}
const worktreeId = worktreeIdForPaneKey(paneKey, tabIdToWorktreeId)
if (!worktreeId) {
continue
}
const summary = summaryForWorktree(worktreeId)
if (summary.paneForegroundAgentByPaneKey === EMPTY_PANE_FOREGROUND_AGENTS) {
summary.paneForegroundAgentByPaneKey = {}
}
summary.paneForegroundAgentByPaneKey[paneKey] = entry
}
for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) {
const summary = summaryForWorktree(retained.worktreeId)
summary.hasRetainedDone = true
@@ -170,6 +196,7 @@ function getWorktreeAgentActivitySummaries(
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
summaries
}
return summaries
@@ -189,10 +216,28 @@ function summariesEqual(
agentStatusPaneIdsByTabIdEqual(
previous.agentStatusPaneIdsByTabId,
next.agentStatusPaneIdsByTabId
) &&
paneForegroundAgentsEqual(
previous.paneForegroundAgentByPaneKey,
next.paneForegroundAgentByPaneKey
)
)
}
function paneForegroundAgentsEqual(
previous: Record<string, PaneForegroundAgentEntry>,
next: Record<string, PaneForegroundAgentEntry>
): boolean {
if (previous === next) {
return true
}
const previousKeys = Object.keys(previous)
if (previousKeys.length !== Object.keys(next).length) {
return false
}
return previousKeys.every((paneKey) => previous[paneKey] === next[paneKey])
}
function agentStatusPaneIdsByTabIdEqual(
previous: Record<string, ReadonlySet<string>>,
next: Record<string, ReadonlySet<string>>
@@ -0,0 +1,118 @@
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import {
resolveFreshPaneForegroundAgent,
type PaneForegroundAgentEntry
} from '@/store/slices/pane-foreground-agent'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
/** Identity-only observations need a distinct authority from title snapshots. */
export const PROCESS_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-process-projection'
export function freshProcessAgentForLeaf(args: {
tabId: string
leafId: string
layout: TerminalLayoutSnapshot | undefined
livePtyIds: readonly string[] | undefined
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | undefined
now: number
}): TuiAgent | null {
if (!args.paneForegroundAgentByPaneKey || !isTerminalLeafId(args.leafId)) {
return null
}
return resolveFreshPaneForegroundAgent(
args.paneForegroundAgentByPaneKey[makePaneKey(args.tabId, args.leafId)],
{
now: args.now,
paneBoundPtyId: args.layout?.ptyIdsByLeafId?.[args.leafId],
liveTabPtyIds: args.livePtyIds
}
)
}
/** Append identity-only rows for fresh process observations that have no hook/title row. */
export function appendProcessDerivedAgentRows(args: {
tabs: TerminalTab[]
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | undefined
seenPaneKeys: Set<string>
rows: DashboardAgentRow[]
now: number
}): void {
for (const [paneKey] of Object.entries(args.paneForegroundAgentByPaneKey ?? {})) {
if (args.seenPaneKeys.has(paneKey)) {
continue
}
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
}
const tab = args.tabs.find((candidate) => candidate.id === parsed.tabId)
if (!tab || !tabHasLivePty(args.ptyIdsByTabId, tab.id)) {
continue
}
const processAgent = freshProcessAgentForLeaf({
tabId: tab.id,
leafId: parsed.leafId,
layout: args.terminalLayoutsByTabId[tab.id],
livePtyIds: args.ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
now: args.now
})
if (!processAgent) {
continue
}
args.rows.push(
buildProcessDerivedAgentRow({
tab,
leafId: parsed.leafId,
agent: processAgent,
now: args.now
})
)
args.seenPaneKeys.add(paneKey)
}
}
function buildProcessDerivedAgentRow(args: {
tab: TerminalTab
leafId: string
agent: TuiAgent
now: number
}): DashboardAgentRow {
const paneKey = makePaneKey(args.tab.id, args.leafId)
const label = formatAgentTypeLabel(args.agent)
const entry: AgentStatusEntry = {
paneKey,
state: 'working',
prompt: label,
updatedAt: args.now,
stateStartedAt: args.now,
stateHistory: [],
agentType: args.agent,
terminalTitle: args.tab.title,
lastAssistantMessage: 'Idle',
observation: {
origin: 'process',
authorityId: PROCESS_DERIVED_AGENT_ROW_AUTHORITY_ID,
incarnation: 0,
revision: args.now,
observedAt: args.now,
kind: 'snapshot'
}
}
return {
paneKey,
entry,
tab: args.tab,
agentType: args.agent,
rowSource: 'live',
state: 'idle',
startedAt: args.now
}
}
@@ -26,6 +26,7 @@ import {
} from './worktree-agent-row-fallback-tab'
import { resolveRowAgentType } from './worktree-agent-row-type'
import { entryWithRuntimeOrchestration } from './worktree-agent-row-orchestration'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
if (!node) {
@@ -144,10 +145,12 @@ export function buildWorktreeAgentRows(args: {
ptyIdsByTabId?: Record<string, string[]>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
now: number
}): DashboardAgentRow[] {
const rows: DashboardAgentRow[] = []
const seenPaneKeys = new Set<string>()
const staleTerminalPaneKeys = new Set<string>()
const currentTabIds = new Set(args.tabs.map((tab) => tab.id))
const entriesByTabId = new Map<string, AgentStatusEntry[]>()
@@ -186,6 +189,9 @@ export function buildWorktreeAgentRows(args: {
})
rows.push(...buildSubagentChildRows({ parentEntry: rowEntry, tab, parentIsFresh: isFresh }))
seenPaneKeys.add(rowEntry.paneKey)
if (!isFresh && rowEntry.state === 'done') {
staleTerminalPaneKeys.add(rowEntry.paneKey)
}
}
}
@@ -198,7 +204,30 @@ export function buildWorktreeAgentRows(args: {
seenPaneKeys
})
rows.push(...buildTitleDerivedAgentRows({ ...args, seenPaneKeys }))
// Terminal outcomes are retained as history, but must yield to a fresh
// title/process observation for the same pane (e.g. a stale `done` row while
// the agent is visibly working again). Keep the history row when no newer
// evidence exists.
const titleSeenPaneKeys = new Set(seenPaneKeys)
for (const paneKey of staleTerminalPaneKeys) {
titleSeenPaneKeys.delete(paneKey)
}
const titleRows = buildTitleDerivedAgentRows({ ...args, seenPaneKeys: titleSeenPaneKeys })
if (staleTerminalPaneKeys.size > 0 && titleRows.length > 0) {
const replacedPaneKeys = new Set(titleRows.map((row) => row.paneKey))
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (
staleTerminalPaneKeys.has(rows[index].paneKey) &&
replacedPaneKeys.has(rows[index].paneKey)
) {
rows.splice(index, 1)
}
}
}
rows.push(...titleRows)
for (const paneKey of titleSeenPaneKeys) {
seenPaneKeys.add(paneKey)
}
// Why: orchestration workers can be attributed to a worktree by main before
// their tab is present in this renderer. Keep those live rows visible in the
@@ -0,0 +1,70 @@
import {
resolveCompatibleAgentTypeForOwner,
type CompatibleAgentOwnerOptions
} from '../../../../shared/agent-title-owner'
import type { AgentType } from '../../../../shared/agent-status-types'
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
import { resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
'Claude Code': 'claude',
OpenClaude: 'openclaude',
Codex: 'codex',
'Gemini CLI': 'gemini',
'GitHub Copilot': 'copilot',
Grok: 'grok',
Devin: 'devin',
Antigravity: 'antigravity',
OpenCode: 'opencode',
Aider: 'aider',
Cursor: 'cursor',
Droid: 'droid',
Hermes: 'hermes',
Pi: 'pi',
OMP: 'omp'
}
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
export function resolveTitleDerivedAgentType(
title: string,
label: string,
ownerAgentType?: AgentType | null,
processAgent?: AgentType | null
): AgentType | null {
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
if (agentType !== 'claude') {
return agentType
}
// Claude's spinner has no provider identity; a process observation may
// supply it when the title itself is neutral.
if (!CLAUDE_AGENT_TOKEN_RE.test(title) && processAgent !== 'claude') {
return null
}
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
return null
}
return agentType
}
export function resolveAgentTypeFromTerminalTitle(
title: string | null | undefined,
ownerAgentType?: AgentType | null,
options?: CompatibleAgentOwnerOptions,
processAgent?: AgentType | null
): AgentType | null {
if (!title) {
return null
}
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, options)
const label = resolveTitleActivityLabel(normalizedTitle)
return label
? (resolveCompatibleAgentTypeForOwner(
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType, processAgent),
ownerAgentType,
options
) ?? null)
: null
}
@@ -3,6 +3,7 @@ import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
@@ -44,6 +45,73 @@ function makeSingleLayout(leafId: string): TerminalLayoutSnapshot {
}
describe('buildTitleDerivedAgentRows', () => {
it('adds an idle identity row for a fresh live process with no hook or title', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
const foreground: PaneForegroundAgentEntry = {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-claude'
}
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'zsh' })],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
paneForegroundAgentByPaneKey: { [paneKey]: foreground },
now: 1_000 + 1_000
})
expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
[paneKey, 'claude', 'idle']
])
})
it('does not attribute process identity to a pane after its PTY is rebound', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'zsh' })],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-new'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
paneForegroundAgentByPaneKey: {
[paneKey]: {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-old'
}
},
now: 2_000
})
expect(rows).toHaveLength(0)
})
it('expires process identity after the observation TTL', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'zsh' })],
entries: [],
retained: [],
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
paneForegroundAgentByPaneKey: {
[paneKey]: {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-claude'
}
},
now: 31_001
})
expect(rows).toHaveLength(0)
})
it('adds title-derived rows for live agent panes that have no hook status yet', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
@@ -3,6 +3,7 @@ import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-statu
import { isCursorAgentTitle } from '../../../../shared/agent-title-core'
import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext,
@@ -15,13 +16,19 @@ import type {
TerminalPaneLayoutNode,
TerminalTab
} from '../../../../shared/terminal-tab-types'
import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner,
type CompatibleAgentOwnerOptions
} from '../../../../shared/agent-title-owner'
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
appendProcessDerivedAgentRows,
freshProcessAgentForLeaf
} from './worktree-agent-process-evidence'
export { freshProcessAgentForLeaf } from './worktree-agent-process-evidence'
export {
resolveAgentTypeFromTerminalTitle,
resolveTitleDerivedAgentType
} from './worktree-title-agent-type'
import { resolveTitleDerivedAgentType } from './worktree-title-agent-type'
/** Fixed, not per-process: title rows are a pure projection of the current title, so they are
* comparable across restarts in a way a sequenced authority's rows are not. Ordering against
@@ -32,32 +39,13 @@ const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
const EMPTY_TERMINAL_LAYOUTS: Record<string, TerminalLayoutSnapshot | undefined> = {}
const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
'Claude Code': 'claude',
OpenClaude: 'openclaude',
Codex: 'codex',
'Gemini CLI': 'gemini',
'GitHub Copilot': 'copilot',
Grok: 'grok',
Devin: 'devin',
Antigravity: 'antigravity',
OpenCode: 'opencode',
Aider: 'aider',
Cursor: 'cursor',
Droid: 'droid',
Hermes: 'hermes',
Pi: 'pi',
OMP: 'omp'
}
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
export function buildTitleDerivedAgentRows(args: {
tabs: TerminalTab[]
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
ptyIdsByTabId?: Record<string, string[]>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
seenPaneKeys: Set<string>
now: number
}): DashboardAgentRow[] {
@@ -94,7 +82,15 @@ export function buildTitleDerivedAgentRows(args: {
title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey,
processAgent: freshProcessAgentForLeaf({
tabId: tab.id,
leafId,
layout,
livePtyIds: ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
now: args.now
})
})
if (!row || args.seenPaneKeys.has(row.paneKey)) {
continue
@@ -115,7 +111,15 @@ export function buildTitleDerivedAgentRows(args: {
title: tab.title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey,
processAgent: freshProcessAgentForLeaf({
tabId: tab.id,
leafId,
layout,
livePtyIds: ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
now: args.now
})
})
if (!row || args.seenPaneKeys.has(row.paneKey)) {
continue
@@ -124,6 +128,16 @@ export function buildTitleDerivedAgentRows(args: {
args.seenPaneKeys.add(row.paneKey)
}
appendProcessDerivedAgentRows({
tabs: args.tabs,
ptyIdsByTabId,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
seenPaneKeys: args.seenPaneKeys,
rows,
now: args.now
})
return rows
}
@@ -138,6 +152,7 @@ function buildTitleDerivedAgentRow(args: {
ownerAgentType: AgentType | null
now: number
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
processAgent?: TuiAgent | null
}): DashboardAgentRow | null {
// Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity
// group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership
@@ -166,7 +181,7 @@ function buildTitleDerivedAgentRow(args: {
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
const titleAgentType = isClaudeAgentsTitle
? 'claude'
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType, args.processAgent)
// Why: a status frame proves activity, not identity, so the resolver drops it.
// Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940)
// surface only decorated task titles; fall back to the pane's known owner instead
@@ -219,30 +234,6 @@ function buildTitleDerivedAgentRow(args: {
}
}
export function resolveTitleDerivedAgentType(
title: string,
label: string,
ownerAgentType?: AgentType | null
): AgentType | null {
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
if (agentType !== 'claude') {
return agentType
}
// Why: Claude's task-title spinner heuristic has no provider identity. In
// split panes it can match arbitrary terminal spinners, so sidebar rows only
// accept Claude when the title itself names Claude.
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
return null
}
// Why: a "claude" word inside another agent's task text is a mention, not identity.
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
return null
}
return agentType
}
function resolveTitleDerivedPaneOwner(
tab: TerminalTab,
layout: TerminalLayoutSnapshot | undefined,
@@ -260,24 +251,6 @@ function resolveTitleDerivedPaneOwner(
* Determines the agent type from a terminal title, normalising Pi-compatible
* agents to their authoritative owner if specified.
*/
export function resolveAgentTypeFromTerminalTitle(
title: string | null | undefined,
ownerAgentType?: AgentType | null,
options?: CompatibleAgentOwnerOptions
): AgentType | null {
if (!title) {
return null
}
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, options)
const label = resolveTitleActivityLabel(normalizedTitle)
return label
? (resolveCompatibleAgentTypeForOwner(
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType),
ownerAgentType,
options
) ?? null)
: null
}
function titleStatusToRowState(
status: 'working' | 'permission' | 'idle'
@@ -38,6 +38,8 @@ export type AgentCompletionCoordinatorOptions = {
replacement: RecognizedAgentProcess
) => boolean
shouldSuppressConfirmedProcessExitCompletion?: (exited: RecognizedAgentProcess) => boolean
/** Re-arm renderer process identity freshness when a cadence inspection sees an agent. */
onForegroundAgentInspected?: (process: RecognizedAgentProcess) => void
isLive: () => boolean
shouldPollProcessCadence?: () => boolean
// Why: on hosts where one inspection forks a whole-process-table scan (local
@@ -77,6 +77,7 @@ export function createAgentCompletionProcessMonitor({
state.consecutiveInspectionErrors = 0
const recognized = recognizeAgentProcess(result.foregroundProcess)
if (recognized) {
options.onForegroundAgentInspected?.(recognized)
handleRecognizedProcess(recognized)
return true
}
@@ -155,7 +155,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
isTrackablePtyId: session.isForegroundTrackingAllowed,
readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id),
confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id),
publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry),
// Bind observations to the PTY that was inspected; a pane key may survive
// a rebind while the old process identity is still in the store.
publish: (entry) =>
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
...entry,
observedAt: Date.now(),
ptyId: session.transport.getPtyId() ?? undefined
}),
hasKnownAgentIdentity: session.paneHasKnownAgentIdentity,
onConfirmedShellForeground: (reason) => {
session.clearStaleAgentTabTitleOnConfirmedShell()
@@ -192,7 +199,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: foreground.agent,
routingRevoked: true,
shellForeground: foreground.shellForeground
shellForeground: foreground.shellForeground,
ptyId: session.transport.getPtyId() ?? undefined
})
}
})
@@ -290,7 +298,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: foreground.agent,
routingRevoked: true,
shellForeground: false
shellForeground: false,
ptyId: session.transport.getPtyId() ?? undefined
})
session.visibleForegroundSamplePending = false
session.visibleForegroundSampleSettled = false
@@ -302,7 +311,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
agent: foreground.agent,
routingRevoked: true,
shellForeground: false,
routingConfirmationPending: true
routingConfirmationPending: true,
ptyId: session.transport.getPtyId() ?? undefined
})
}
}
@@ -161,7 +161,8 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi
// closed. Use it to request confirmation, never as current byte authority.
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: metadata.launchAgent,
shellForeground: false
shellForeground: false,
ptyId: session.transport.getPtyId() ?? undefined
})
}
return
@@ -204,6 +204,17 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
currentAgentForExited !== exited.agent
)
},
onForegroundAgentInspected: (process) => {
const inspectedPtyId = session.transport.getPtyId()
// Process identity is local-only evidence; never publish an SSH/WSL
// inspection into the renderer's pane identity cache.
if (!inspectedPtyId || !session.isForegroundTrackingAllowed(inspectedPtyId)) {
return
}
useAppStore
.getState()
.refreshPaneForegroundAgentObservation(session.cacheKey, process.agent, inspectedPtyId)
},
dispatchCompletion: (title, meta) => {
if (meta?.source === 'process-exit') {
session.clearSuppressedTitleSideEffects()
+48 -8
View File
@@ -1,8 +1,12 @@
import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows'
import {
freshProcessAgentForLeaf,
resolveAgentTypeFromTerminalTitle
} from '@/components/sidebar/worktree-title-derived-agent-rows'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id'
import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
@@ -25,6 +29,8 @@ type WorktreeStatusHeuristicOptions = {
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
now?: number
}
const STATUS_LABELS: Record<WorktreeStatus, string> = {
@@ -49,7 +55,9 @@ export function getWorktreeStatus(
// Why: tab.title tracks only the most-recently-focused pane (onActivePaneChange in use-terminal-pane-lifecycle.ts); consult per-pane titles so the spinner reflects aggregate tab state.
const hasStatus = (status: 'permission' | 'working'): boolean =>
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status, options))
liveTabs.some((tab) =>
tabHasStatus(tab, runtimePaneTitlesByTabId, status, options, ptyIdsByTabId)
)
if (options.liveAgentStatus === 'permission' || hasStatus('permission')) {
return 'permission'
@@ -71,9 +79,21 @@ function tabHasStatus(
tab: Pick<TerminalTab, 'id' | 'title' | 'launchAgent'>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
status: 'permission' | 'working',
options: WorktreeStatusHeuristicOptions
options: WorktreeStatusHeuristicOptions,
ptyIdsByTabId: Record<string, string[]>
): boolean {
const agentStatusPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id]
const processAgentForLeaf = (leafId: string | null): TuiAgent | null =>
leafId === null
? null
: freshProcessAgentForLeaf({
tabId: tab.id,
leafId,
layout: options.terminalLayoutsByTabId?.[tab.id],
livePtyIds: ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: options.paneForegroundAgentByPaneKey,
now: options.now ?? Date.now()
})
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
const tabLayoutRoot =
@@ -93,7 +113,7 @@ function tabHasStatus(
}
if (
classifyTitleActivity(title) === status &&
titleStatusIsAgentAttributable(title, tab.launchAgent)
titleStatusIsAgentAttributable(title, processAgentForLeaf(leafId), tab.launchAgent)
) {
return true
}
@@ -106,13 +126,29 @@ function tabHasStatus(
}
return (
classifyTitleActivity(tab.title) === status &&
titleStatusIsAgentAttributable(tab.title, tab.launchAgent)
// A tab title can borrow process identity only when the layout proves it
// contains one pane; split tabs must not brand a sibling.
titleStatusIsAgentAttributable(
tab.title,
processAgentForLeaf(resolveSoleLeafId(tab.id, options)),
tab.launchAgent
)
)
}
function resolveSoleLeafId(tabId: string, options: WorktreeStatusHeuristicOptions): string | null {
const root =
options.terminalLayoutRootsByTabId?.[tabId] ?? options.terminalLayoutsByTabId?.[tabId]?.root
return root?.type === 'leaf' ? root.leafId : null
}
// Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row.
function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | null): boolean {
if (resolveAgentTypeFromTerminalTitle(title) !== null) {
function titleStatusIsAgentAttributable(
title: string,
processAgent?: TuiAgent | null,
launchAgent?: TuiAgent | null
): boolean {
if (resolveAgentTypeFromTerminalTitle(title, undefined, undefined, processAgent) !== null) {
return true
}
// Why: a spinner proves activity but not identity (Claude's thinking title has no provider
@@ -141,6 +177,8 @@ export function resolveWorktreeStatus(args: {
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
now?: number
hasPermission: boolean
hasLiveWorking: boolean
hasLiveMonitoring?: boolean
@@ -156,7 +194,9 @@ export function resolveWorktreeStatus(args: {
{
agentStatusPaneIdsByTabId: args.agentStatusPaneIdsByTabId,
terminalLayoutsByTabId: args.terminalLayoutsByTabId,
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId,
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
now: args.now
}
)
if (args.hasPermission) {
@@ -2,6 +2,11 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { TuiAgent } from '../../../../shared/tui-agent'
// Process identity is attribution evidence, not a permanent status row. Keep
// it fresh enough for sidebar projections while bounded when observations stop.
export const PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS = 30_000
const OBSERVATION_REFRESH_QUANTUM_MS = 5_000
export type PaneForegroundAgentEntry = {
/** Recognized agent process in the pane's foreground; null when unknown. */
agent: TuiAgent | null
@@ -16,6 +21,10 @@ export type PaneForegroundAgentEntry = {
/** True once the foreground is proven back at the shell (OSC 133;D) —
* process-grade launched-agent exit evidence, independent of titles. */
shellForeground: boolean
/** Main-clock timestamp for the process observation. */
observedAt?: number
/** PTY from which the process observation was read. */
ptyId?: string
}
/**
@@ -26,6 +35,7 @@ export type PaneForegroundAgentEntry = {
export type PaneForegroundAgentSlice = {
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
setPaneForegroundAgent: (paneKey: string, entry: PaneForegroundAgentEntry) => void
refreshPaneForegroundAgentObservation: (paneKey: string, agent: TuiAgent, ptyId?: string) => void
clearPaneForegroundAgent: (paneKey: string) => void
/** Wholesale teardown sweeps (tab close, worktree sleep/remove) retire pane
* keys without per-pane close events — clear their entries too. */
@@ -49,12 +59,74 @@ export const createPaneForegroundAgentSlice: StateCreator<
current.routingTrusted === entry.routingTrusted &&
current.routingRevoked === entry.routingRevoked &&
current.routingConfirmationPending === entry.routingConfirmationPending &&
current.shellForeground === entry.shellForeground
current.shellForeground === entry.shellForeground &&
current.ptyId === entry.ptyId
) {
return s
// Test/legacy callers may provide identity-only entries. Preserve the
// value-bail semantics for those records; only observed process
// evidence opts into the bounded freshness clock.
if (current.observedAt === undefined && entry.observedAt === undefined) {
return s
}
const now = Date.now()
if (
current.observedAt !== undefined &&
now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
) {
return s
}
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: { ...current, observedAt: now }
}
}
}
return {
paneForegroundAgentByPaneKey: { ...s.paneForegroundAgentByPaneKey, [paneKey]: entry }
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: entry
}
}
})
},
refreshPaneForegroundAgentObservation: (paneKey, agent, ptyId) => {
set((s) => {
const now = Date.now()
const current = s.paneForegroundAgentByPaneKey[paneKey]
if (current) {
if (current.agent !== agent) {
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: {
agent,
shellForeground: false,
observedAt: now,
ptyId: ptyId ?? current.ptyId
}
}
}
}
if (
current.observedAt !== undefined &&
now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
) {
return s
}
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: { ...current, observedAt: now }
}
}
}
// Coordinator inspection proves identity but not input-routing authority.
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: { agent, shellForeground: false, observedAt: now }
}
}
})
},
@@ -89,6 +161,26 @@ export const createPaneForegroundAgentSlice: StateCreator<
}
})
/** Return process identity only while the evidence and its PTY are current. */
export function resolveFreshPaneForegroundAgent(
entry: PaneForegroundAgentEntry | undefined,
args: { now: number; paneBoundPtyId?: string; liveTabPtyIds?: readonly string[] }
): TuiAgent | null {
if (!entry?.agent || entry.shellForeground || entry.observedAt === undefined) {
return null
}
if (args.now - entry.observedAt > PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS) {
return null
}
if (args.paneBoundPtyId !== undefined) {
return entry.ptyId === undefined || entry.ptyId === args.paneBoundPtyId ? entry.agent : null
}
if (entry.ptyId !== undefined) {
return args.liveTabPtyIds?.includes(entry.ptyId) === true ? entry.agent : null
}
return (args.liveTabPtyIds?.length ?? 0) > 0 ? entry.agent : null
}
export function buildPaneForegroundAgentTabPrefixClearPatch(
entries: Record<string, PaneForegroundAgentEntry>,
tabPrefixes: readonly string[]