fix(agent-status): keep process evidence enumerable

This commit is contained in:
Merge Sim
2026-08-31 13:06:16 -07:00
parent 4a56dc8555
commit ed5c485b8d
21 changed files with 352 additions and 238 deletions
@@ -30,7 +30,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId,
paneForegroundAgentByPaneKey
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey
} = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
// Why: compact and detailed cards need the same status-dot semantics:
@@ -46,6 +47,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
agentStatusPaneIdsByTabId,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
@@ -61,6 +63,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
agentStatusPaneIdsByTabId,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
@@ -23,7 +23,9 @@ type WorktreeActivityStatusState = Pick<
| 'retainedAgentsByPaneKey'
| 'runtimeAgentOrchestrationByPaneKey'
| 'paneForegroundAgentByPaneKey'
>
> & {
paneForegroundAgentObservationByPaneKey?: AppState['paneForegroundAgentObservationByPaneKey']
}
export function selectWorktreeActivityStatuses(
statusInputs: WorktreeActivityStatusState,
@@ -39,7 +41,8 @@ export function selectWorktreeActivityStatuses(
hasLiveDone,
hasRetainedDone,
agentStatusPaneIdsByTabId,
paneForegroundAgentByPaneKey
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
statuses.set(
worktreeId,
@@ -51,6 +54,7 @@ export function selectWorktreeActivityStatuses(
agentStatusPaneIdsByTabId,
terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(statusInputs, worktreeId),
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey,
hasPermission,
hasLiveWorking,
hasLiveMonitoring,
@@ -22,6 +22,7 @@ import {
} from './worktree-agent-freshness-selector'
import {
EMPTY_PANE_FOREGROUND_AGENTS,
EMPTY_PANE_FOREGROUND_OBSERVATIONS,
selectWorktreeAgentActivitySummary
} from './worktree-agent-activity-summary'
@@ -85,6 +86,11 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
? selectWorktreeAgentActivitySummary(s, worktreeId).paneForegroundAgentByPaneKey
: EMPTY_PANE_FOREGROUND_AGENTS
)
const paneForegroundAgentObservationByPaneKey = useAppStore((s) =>
active
? selectWorktreeAgentActivitySummary(s, worktreeId).paneForegroundAgentObservationByPaneKey
: EMPTY_PANE_FOREGROUND_OBSERVATIONS
)
const agentFreshnessSignature = useAppStore((s) =>
active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
)
@@ -116,6 +122,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey,
now
})
)
@@ -131,6 +138,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
terminalLayoutsByTabId,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey,
agentFreshnessSignature
])
}
@@ -0,0 +1,39 @@
export function paneForegroundAgentsEqual<T>(
previous: Record<string, T>,
next: Record<string, T>
): 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])
}
export function agentStatusPaneIdsByTabIdEqual(
previous: Record<string, ReadonlySet<string>>,
next: Record<string, ReadonlySet<string>>
): boolean {
if (previous === next) {
return true
}
const previousKeys = Object.keys(previous)
if (previousKeys.length !== Object.keys(next).length) {
return false
}
for (const tabId of previousKeys) {
const previousPaneIds = previous[tabId]
const nextPaneIds = next[tabId]
if (!nextPaneIds || previousPaneIds.size !== nextPaneIds.size) {
return false
}
for (const paneId of previousPaneIds) {
if (!nextPaneIds.has(paneId)) {
return false
}
}
}
return true
}
@@ -8,10 +8,17 @@ import {
} from '@/lib/agent-status-worktree-attribution'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
import type {
PaneForegroundAgentEntry,
PaneForegroundAgentObservation
} from '@/store/slices/pane-foreground-agent'
import {
agentStatusPaneIdsByTabIdEqual,
paneForegroundAgentsEqual
} from './worktree-agent-activity-summary-equality'
import { applyLiveAgentState } from './worktree-agent-state-flags'
export type WorktreeAgentActivitySummary = {
hasPermission: boolean
@@ -24,10 +31,12 @@ export type WorktreeAgentActivitySummary = {
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
/** Process identity narrowed to this worktree's panes. */
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
paneForegroundAgentObservationByPaneKey: Record<string, PaneForegroundAgentObservation>
}
const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record<string, ReadonlySet<string>> = {}
export const EMPTY_PANE_FOREGROUND_AGENTS: Record<string, PaneForegroundAgentEntry> = {}
export const EMPTY_PANE_FOREGROUND_OBSERVATIONS: Record<string, PaneForegroundAgentObservation> = {}
const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasPermission: false,
@@ -37,7 +46,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
hasLiveDone: false,
hasRetainedDone: false,
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID,
paneForegroundAgentByPaneKey: EMPTY_PANE_FOREGROUND_AGENTS
paneForegroundAgentByPaneKey: EMPTY_PANE_FOREGROUND_AGENTS,
paneForegroundAgentObservationByPaneKey: EMPTY_PANE_FOREGROUND_OBSERVATIONS
}
type AgentActivityTabsByWorktree = Record<string, readonly { id: string }[]>
@@ -52,6 +62,7 @@ export type AgentActivityInput = Pick<
tabsByWorktree: AgentActivityTabsByWorktree
runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey']
paneForegroundAgentByPaneKey?: AppState['paneForegroundAgentByPaneKey']
paneForegroundAgentObservationByPaneKey?: AppState['paneForegroundAgentObservationByPaneKey']
}
type AgentActivityCache = {
@@ -61,6 +72,9 @@ type AgentActivityCache = {
retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey']
runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined
paneForegroundAgentByPaneKey: AppState['paneForegroundAgentByPaneKey'] | undefined
paneForegroundAgentObservationByPaneKey:
| AppState['paneForegroundAgentObservationByPaneKey']
| undefined
summaries: Map<string, WorktreeAgentActivitySummary>
}
@@ -78,6 +92,7 @@ function getWorktreeAgentActivitySummaries(
): Map<string, WorktreeAgentActivitySummary> {
const runtimeAgentOrchestrationByPaneKey = state.runtimeAgentOrchestrationByPaneKey
const paneForegroundAgentByPaneKey = state.paneForegroundAgentByPaneKey
const paneForegroundAgentObservationByPaneKey = state.paneForegroundAgentObservationByPaneKey
if (
agentActivityCache &&
agentActivityCache.tabsByWorktree === state.tabsByWorktree &&
@@ -85,7 +100,9 @@ function getWorktreeAgentActivitySummaries(
agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId &&
agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey &&
agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey &&
agentActivityCache.paneForegroundAgentByPaneKey === paneForegroundAgentByPaneKey
agentActivityCache.paneForegroundAgentByPaneKey === paneForegroundAgentByPaneKey &&
agentActivityCache.paneForegroundAgentObservationByPaneKey ===
paneForegroundAgentObservationByPaneKey
) {
return agentActivityCache.summaries
}
@@ -111,7 +128,7 @@ function getWorktreeAgentActivitySummaries(
}
const now = Date.now()
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey ?? {})) {
const paneIdentity = parseAgentStatusPaneIdentity(paneKey)
if (!paneIdentity) {
continue
@@ -160,8 +177,13 @@ function getWorktreeAgentActivitySummaries(
const summary = summaryForWorktree(worktreeId)
if (summary.paneForegroundAgentByPaneKey === EMPTY_PANE_FOREGROUND_AGENTS) {
summary.paneForegroundAgentByPaneKey = {}
summary.paneForegroundAgentObservationByPaneKey = {}
}
summary.paneForegroundAgentByPaneKey[paneKey] = entry
const observation = paneForegroundAgentObservationByPaneKey?.[paneKey]
if (observation) {
summary.paneForegroundAgentObservationByPaneKey[paneKey] = observation
}
}
for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) {
@@ -197,6 +219,7 @@ function getWorktreeAgentActivitySummaries(
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
runtimeAgentOrchestrationByPaneKey,
paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey,
summaries
}
return summaries
@@ -220,70 +243,14 @@ function summariesEqual(
paneForegroundAgentsEqual(
previous.paneForegroundAgentByPaneKey,
next.paneForegroundAgentByPaneKey
) &&
paneForegroundAgentsEqual(
previous.paneForegroundAgentObservationByPaneKey,
next.paneForegroundAgentObservationByPaneKey
)
)
}
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>>
): boolean {
if (previous === next) {
return true
}
const previousKeys = Object.keys(previous)
if (previousKeys.length !== Object.keys(next).length) {
return false
}
for (const tabId of previousKeys) {
const previousPaneIds = previous[tabId]
const nextPaneIds = next[tabId]
if (!nextPaneIds || previousPaneIds.size !== nextPaneIds.size) {
return false
}
for (const paneId of previousPaneIds) {
if (!nextPaneIds.has(paneId)) {
return false
}
}
}
return true
}
function applyLiveAgentState(
summary: WorktreeAgentActivitySummary,
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted'>
): void {
if (entry.state === 'blocked' || entry.state === 'waiting') {
summary.hasPermission = true
} else if (entry.interrupted === true) {
// Interrupted is encoded as done, so it must be checked first.
summary.hasInterrupted = true
} else if (entry.state === 'working') {
if (entry.workingMode === 'monitoring') {
summary.hasLiveMonitoring = true
} else {
summary.hasLiveWorking = true
}
} else if (entry.state === 'done') {
summary.hasLiveDone = true
}
}
function addAgentStatusPaneId(
summary: WorktreeAgentActivitySummary,
tabId: string,
@@ -2,7 +2,8 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import {
resolveFreshPaneForegroundAgent,
type PaneForegroundAgentEntry
type PaneForegroundAgentEntry,
type PaneForegroundAgentObservation
} from '@/store/slices/pane-foreground-agent'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
@@ -19,6 +20,9 @@ export function freshProcessAgentForLeaf(args: {
layout: TerminalLayoutSnapshot | undefined
livePtyIds: readonly string[] | undefined
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | undefined
paneForegroundAgentObservationByPaneKey:
| Record<string, PaneForegroundAgentObservation>
| undefined
now: number
}): TuiAgent | null {
if (!args.paneForegroundAgentByPaneKey || !isTerminalLeafId(args.leafId)) {
@@ -26,6 +30,7 @@ export function freshProcessAgentForLeaf(args: {
}
return resolveFreshPaneForegroundAgent(
args.paneForegroundAgentByPaneKey[makePaneKey(args.tabId, args.leafId)],
args.paneForegroundAgentObservationByPaneKey?.[makePaneKey(args.tabId, args.leafId)],
{
now: args.now,
paneBoundPtyId: args.layout?.ptyIdsByLeafId?.[args.leafId],
@@ -40,6 +45,9 @@ export function appendProcessDerivedAgentRows(args: {
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | undefined
paneForegroundAgentObservationByPaneKey:
| Record<string, PaneForegroundAgentObservation>
| undefined
seenPaneKeys: Set<string>
rows: DashboardAgentRow[]
now: number
@@ -62,6 +70,7 @@ export function appendProcessDerivedAgentRows(args: {
layout: args.terminalLayoutsByTabId[tab.id],
livePtyIds: args.ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey,
now: args.now
})
if (!processAgent) {
@@ -26,7 +26,10 @@ 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'
import type {
PaneForegroundAgentEntry,
PaneForegroundAgentObservation
} from '@/store/slices/pane-foreground-agent'
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
if (!node) {
@@ -146,6 +149,7 @@ export function buildWorktreeAgentRows(args: {
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
paneForegroundAgentObservationByPaneKey?: Record<string, PaneForegroundAgentObservation>
now: number
}): DashboardAgentRow[] {
const rows: DashboardAgentRow[] = []
@@ -0,0 +1,22 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { WorktreeAgentActivitySummary } from './worktree-agent-activity-summary'
export function applyLiveAgentState(
summary: WorktreeAgentActivitySummary,
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted'>
): void {
if (entry.state === 'blocked' || entry.state === 'waiting') {
summary.hasPermission = true
} else if (entry.interrupted === true) {
// Interrupted is encoded as done, so it must be checked first.
summary.hasInterrupted = true
} else if (entry.state === 'working') {
if (entry.workingMode === 'monitoring') {
summary.hasLiveMonitoring = true
} else {
summary.hasLiveWorking = true
}
} else if (entry.state === 'done') {
summary.hasLiveDone = true
}
}
@@ -3,7 +3,10 @@ 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 type {
PaneForegroundAgentEntry,
PaneForegroundAgentObservation
} from '@/store/slices/pane-foreground-agent'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
@@ -49,10 +52,9 @@ describe('buildTitleDerivedAgentRows', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
const foreground: PaneForegroundAgentEntry = {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-claude'
shellForeground: false
}
const observation: PaneForegroundAgentObservation = { observedAt: 1_000, ptyId: 'pty-claude' }
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'zsh' })],
entries: [],
@@ -60,6 +62,7 @@ describe('buildTitleDerivedAgentRows', () => {
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
paneForegroundAgentByPaneKey: { [paneKey]: foreground },
paneForegroundAgentObservationByPaneKey: { [paneKey]: observation },
now: 1_000 + 1_000
})
@@ -79,11 +82,12 @@ describe('buildTitleDerivedAgentRows', () => {
paneForegroundAgentByPaneKey: {
[paneKey]: {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-old'
shellForeground: false
}
},
paneForegroundAgentObservationByPaneKey: {
[paneKey]: { observedAt: 1_000, ptyId: 'pty-old' }
},
now: 2_000
})
@@ -101,11 +105,12 @@ describe('buildTitleDerivedAgentRows', () => {
paneForegroundAgentByPaneKey: {
[paneKey]: {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-claude'
shellForeground: false
}
},
paneForegroundAgentObservationByPaneKey: {
[paneKey]: { observedAt: 1_000, ptyId: 'pty-claude' }
},
now: 31_001
})
@@ -3,7 +3,10 @@ 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 {
PaneForegroundAgentEntry,
PaneForegroundAgentObservation
} from '@/store/slices/pane-foreground-agent'
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext,
@@ -46,6 +49,7 @@ export function buildTitleDerivedAgentRows(args: {
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
paneForegroundAgentObservationByPaneKey?: Record<string, PaneForegroundAgentObservation>
seenPaneKeys: Set<string>
now: number
}): DashboardAgentRow[] {
@@ -89,6 +93,7 @@ export function buildTitleDerivedAgentRows(args: {
layout,
livePtyIds: ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey,
now: args.now
})
})
@@ -118,6 +123,7 @@ export function buildTitleDerivedAgentRows(args: {
layout,
livePtyIds: ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey,
now: args.now
})
})
@@ -133,6 +139,7 @@ export function buildTitleDerivedAgentRows(args: {
ptyIdsByTabId,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey,
seenPaneKeys: args.seenPaneKeys,
rows,
now: args.now
@@ -272,10 +272,17 @@ describe('connectPanePty', () => {
await advanceVisibleForegroundRead()
expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
agent: 'codex',
shellForeground: false
})
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(
cacheKey,
{
agent: 'codex',
shellForeground: false
},
{
observedAt: expect.any(Number),
ptyId
}
)
})
it('does not sample hidden restored PTYs', async () => {
@@ -758,10 +765,17 @@ describe('connectPanePty', () => {
await advanceVisibleForegroundRead()
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
agent: null,
shellForeground: true
})
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(
cacheKey,
{
agent: null,
shellForeground: true
},
{
observedAt: expect.any(Number),
ptyId
}
)
expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
})
@@ -807,11 +821,18 @@ describe('connectPanePty', () => {
agent: null,
shellForeground: true
})
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
agent: 'droid',
routingTrusted: true,
shellForeground: false
})
expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(
cacheKey,
{
agent: 'droid',
routingTrusted: true,
shellForeground: false
},
{
observedAt: expect.any(Number),
ptyId
}
)
})
it('never probes the foreground for a visible remote/SSH restored pane', async () => {
@@ -15,7 +15,6 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import type { TuiAgent } from '../../../../../shared/tui-agent'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config'
import { createPaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -159,13 +158,10 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
// 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,
createPaneForegroundAgentEntry(entry, {
observedAt: Date.now(),
ptyId: session.transport.getPtyId() ?? undefined
})
),
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry, {
observedAt: Date.now(),
ptyId: session.transport.getPtyId() ?? undefined
}),
hasKnownAgentIdentity: session.paneHasKnownAgentIdentity,
onConfirmedShellForeground: (reason) => {
session.clearStaleAgentTabTitleOnConfirmedShell()
@@ -199,17 +195,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
if (foreground?.routingConfirmationPending !== true) {
return
}
useAppStore.getState().setPaneForegroundAgent(
session.cacheKey,
createPaneForegroundAgentEntry(
{
agent: foreground.agent,
routingRevoked: true,
shellForeground: foreground.shellForeground
},
{ ptyId: session.transport.getPtyId() ?? undefined }
)
)
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: foreground.agent,
routingRevoked: true,
shellForeground: foreground.shellForeground
})
}
})
// Why: one command-finished policy whether the signal arrives as bytes
@@ -303,33 +293,23 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
}
// Why: cmd.exe and Git Bash have no OSC command boundaries. Keep the icon
// as a hint, but revoke bytes until one current provider confirmation lands.
useAppStore
.getState()
.setPaneForegroundAgent(
session.cacheKey,
createPaneForegroundAgentEntry(
{ agent: foreground.agent, routingRevoked: true, shellForeground: false },
{ ptyId: session.transport.getPtyId() ?? undefined }
)
)
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: foreground.agent,
routingRevoked: true,
shellForeground: false
})
session.visibleForegroundSamplePending = false
session.visibleForegroundSampleSettled = false
// Why: hook rows can suppress display-only sampling, but cannot restore
// byte authority after this function explicitly revoked routing trust.
session.sampleVisiblePaneForegroundAgent(true)
if (session.paneForegroundAgentTracker.hasReadInFlight()) {
useAppStore.getState().setPaneForegroundAgent(
session.cacheKey,
createPaneForegroundAgentEntry(
{
agent: foreground.agent,
routingRevoked: true,
shellForeground: false,
routingConfirmationPending: true
},
{ ptyId: session.transport.getPtyId() ?? undefined }
)
)
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: foreground.agent,
routingRevoked: true,
shellForeground: false,
routingConfirmationPending: true
})
}
}
session.commandLifecycle = createTerminalCommandLifecycle({
@@ -18,7 +18,6 @@ import {
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { installCommandInferredPaneAgent } from './command-inferred-pane-agent'
import { createPaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
export function installSleepingRecordAccess(session: ConnectPanePtySession): void {
session.getSleepingRecordForPane = (
@@ -160,15 +159,10 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi
if (metadata?.launchAgent) {
// Why: daemon launch identity can outlive the process while Orca is
// closed. Use it to request confirmation, never as current byte authority.
useAppStore
.getState()
.setPaneForegroundAgent(
session.cacheKey,
createPaneForegroundAgentEntry(
{ agent: metadata.launchAgent, shellForeground: false },
{ ptyId: session.transport.getPtyId() ?? undefined }
)
)
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: metadata.launchAgent,
shellForeground: false
})
}
return
}
+4 -3
View File
@@ -158,11 +158,12 @@ describe('getWorktreeStatus', () => {
paneForegroundAgentByPaneKey: {
[makePaneKey('tab-1', LEAF_ID_1)]: {
agent: 'claude',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-old'
shellForeground: false
}
},
paneForegroundAgentObservationByPaneKey: {
[makePaneKey('tab-1', LEAF_ID_1)]: { observedAt: 1_000, ptyId: 'pty-old' }
},
now: 2_000
}
)
+8 -1
View File
@@ -6,7 +6,10 @@ 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 {
PaneForegroundAgentEntry,
PaneForegroundAgentObservation
} from '@/store/slices/pane-foreground-agent'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
@@ -30,6 +33,7 @@ type WorktreeStatusHeuristicOptions = {
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
paneForegroundAgentObservationByPaneKey?: Record<string, PaneForegroundAgentObservation>
now?: number
}
@@ -92,6 +96,7 @@ function tabHasStatus(
layout: options.terminalLayoutsByTabId?.[tab.id],
livePtyIds: ptyIdsByTabId[tab.id],
paneForegroundAgentByPaneKey: options.paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey: options.paneForegroundAgentObservationByPaneKey,
now: options.now ?? Date.now()
})
const paneTitles = runtimePaneTitlesByTabId[tab.id]
@@ -178,6 +183,7 @@ export function resolveWorktreeStatus(args: {
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
paneForegroundAgentObservationByPaneKey?: Record<string, PaneForegroundAgentObservation>
now?: number
hasPermission: boolean
hasLiveWorking: boolean
@@ -196,6 +202,7 @@ export function resolveWorktreeStatus(args: {
terminalLayoutsByTabId: args.terminalLayoutsByTabId,
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId,
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey,
now: args.now
}
)
@@ -286,6 +286,7 @@ export type WebSessionTabsSyncState = Pick<
| 'automaticAgentResumeClaimsByTabId'
| 'migrationUnsupportedByPtyId'
| 'paneForegroundAgentByPaneKey'
| 'paneForegroundAgentObservationByPaneKey'
| 'pendingStartupByTabId'
| 'recentlyClosedAgentStatusTabIds'
| 'recentlyRetiredAgentStatusPaneKeys'
@@ -1400,6 +1401,7 @@ function buildRetractedMirroredTabSweepPatch(
agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {},
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {},
paneForegroundAgentObservationByPaneKey: state.paneForegroundAgentObservationByPaneKey ?? {},
recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {},
recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {},
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {},
@@ -1675,6 +1675,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
s.paneForegroundAgentByPaneKey,
retiredPaneKeySet
),
paneForegroundAgentObservationByPaneKey: removePaneKeys(
s.paneForegroundAgentObservationByPaneKey,
retiredPaneKeySet
),
unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet),
unreadAgentCompletionPanes: removePaneKeys(
s.unreadAgentCompletionPanes,
@@ -1800,6 +1804,11 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
),
acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to),
paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to),
paneForegroundAgentObservationByPaneKey: movePaneKeyedRecord(
s.paneForegroundAgentObservationByPaneKey,
from,
to
),
unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to),
unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to),
lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to),
@@ -16,20 +16,27 @@ function terminalTab(id: string, worktreeId: string): TerminalTab {
}
describe('pane foreground agent slice', () => {
it('keeps process freshness metadata out of the public identity shape', () => {
it('keeps process freshness metadata in an explicit observation map', () => {
const store = createTestStore()
store.getState().setPaneForegroundAgent('tab-1:leaf-1', {
agent: 'codex',
shellForeground: false,
observedAt: 1_000,
ptyId: 'pty-1'
})
store.getState().setPaneForegroundAgent(
'tab-1:leaf-1',
{
agent: 'codex',
shellForeground: false
},
{
observedAt: 1_000,
ptyId: 'pty-1'
}
)
const entry = store.getState().paneForegroundAgentByPaneKey['tab-1:leaf-1']
expect(entry).toMatchObject({ agent: 'codex', shellForeground: false })
expect(entry?.observedAt).toBe(1_000)
expect(entry?.ptyId).toBe('pty-1')
expect(Object.keys(entry ?? {})).toEqual(['agent', 'shellForeground'])
expect(store.getState().paneForegroundAgentObservationByPaneKey['tab-1:leaf-1']).toEqual({
observedAt: 1_000,
ptyId: 'pty-1'
})
})
it('sets, value-bails, and clears entries per pane key', () => {
@@ -21,33 +21,12 @@ 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
}
/** Keep freshness metadata out of the long-standing identity record shape. */
export function createPaneForegroundAgentEntry(
entry: PaneForegroundAgentEntry,
metadata: Pick<PaneForegroundAgentEntry, 'observedAt' | 'ptyId'> = entry
): PaneForegroundAgentEntry {
const { observedAt: _ignoredObservedAt, ptyId: _ignoredPtyId, ...publicEntry } = entry
Object.defineProperties(publicEntry, {
observedAt: {
configurable: true,
enumerable: false,
value: metadata.observedAt,
writable: true
},
ptyId: {
configurable: true,
enumerable: false,
value: metadata.ptyId,
writable: true
}
})
return publicEntry
/** PTY-bound, time-bounded process evidence kept separate from the public identity record. */
export type PaneForegroundAgentObservation = {
observedAt: number
ptyId?: string
}
/**
@@ -57,7 +36,12 @@ export function createPaneForegroundAgentEntry(
*/
export type PaneForegroundAgentSlice = {
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
setPaneForegroundAgent: (paneKey: string, entry: PaneForegroundAgentEntry) => void
paneForegroundAgentObservationByPaneKey: Record<string, PaneForegroundAgentObservation>
setPaneForegroundAgent: (
paneKey: string,
entry: PaneForegroundAgentEntry,
observation?: PaneForegroundAgentObservation
) => void
refreshPaneForegroundAgentObservation: (paneKey: string, agent: TuiAgent, ptyId?: string) => void
clearPaneForegroundAgent: (paneKey: string) => void
/** Wholesale teardown sweeps (tab close, worktree sleep/remove) retire pane
@@ -73,47 +57,54 @@ export const createPaneForegroundAgentSlice: StateCreator<
PaneForegroundAgentSlice
> = (set) => ({
paneForegroundAgentByPaneKey: {},
setPaneForegroundAgent: (paneKey, entry) => {
paneForegroundAgentObservationByPaneKey: {},
setPaneForegroundAgent: (paneKey, entry, observation) => {
set((s) => {
const normalizedEntry = createPaneForegroundAgentEntry(entry)
const current = s.paneForegroundAgentByPaneKey[paneKey]
if (
current &&
current.agent === normalizedEntry.agent &&
current.routingTrusted === normalizedEntry.routingTrusted &&
current.routingRevoked === normalizedEntry.routingRevoked &&
current.routingConfirmationPending === normalizedEntry.routingConfirmationPending &&
current.shellForeground === normalizedEntry.shellForeground &&
current.ptyId === normalizedEntry.ptyId
current.agent === entry.agent &&
current.routingTrusted === entry.routingTrusted &&
current.routingRevoked === entry.routingRevoked &&
current.routingConfirmationPending === entry.routingConfirmationPending &&
current.shellForeground === entry.shellForeground
) {
// 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 (normalizedEntry.observedAt === undefined) {
if (!observation) {
return s
}
const now = Date.now()
const currentObservation = s.paneForegroundAgentObservationByPaneKey[paneKey]
if (
current.observedAt !== undefined &&
now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
currentObservation &&
currentObservation.ptyId === observation.ptyId &&
now - currentObservation.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
) {
return s
}
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: createPaneForegroundAgentEntry(current, {
observedAt: now,
ptyId: current.ptyId
})
paneForegroundAgentObservationByPaneKey: {
...s.paneForegroundAgentObservationByPaneKey,
[paneKey]: { ...observation, observedAt: now }
}
}
}
const nextObservation = observation
? { ...observation }
: entry.agent !== current?.agent || entry.shellForeground
? undefined
: s.paneForegroundAgentObservationByPaneKey[paneKey]
const nextObservations = { ...s.paneForegroundAgentObservationByPaneKey }
if (nextObservation) {
nextObservations[paneKey] = nextObservation
} else {
delete nextObservations[paneKey]
}
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: normalizedEntry
}
[paneKey]: entry
},
paneForegroundAgentObservationByPaneKey: nextObservations
}
})
},
@@ -126,26 +117,25 @@ export const createPaneForegroundAgentSlice: StateCreator<
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: createPaneForegroundAgentEntry(
{ agent, shellForeground: false },
{ observedAt: now, ptyId: ptyId ?? current.ptyId }
)
[paneKey]: { agent, shellForeground: false }
},
paneForegroundAgentObservationByPaneKey: {
...s.paneForegroundAgentObservationByPaneKey,
[paneKey]: { observedAt: now, ptyId }
}
}
}
const currentObservation = s.paneForegroundAgentObservationByPaneKey[paneKey]
if (
current.observedAt !== undefined &&
now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
currentObservation &&
now - currentObservation.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
) {
return s
}
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: createPaneForegroundAgentEntry(current, {
observedAt: now,
ptyId: ptyId ?? current.ptyId
})
paneForegroundAgentObservationByPaneKey: {
...s.paneForegroundAgentObservationByPaneKey,
[paneKey]: { observedAt: now, ptyId: ptyId ?? currentObservation?.ptyId }
}
}
}
@@ -153,30 +143,41 @@ export const createPaneForegroundAgentSlice: StateCreator<
return {
paneForegroundAgentByPaneKey: {
...s.paneForegroundAgentByPaneKey,
[paneKey]: createPaneForegroundAgentEntry(
{ agent, shellForeground: false },
{ observedAt: now, ptyId }
)
[paneKey]: { agent, shellForeground: false }
},
paneForegroundAgentObservationByPaneKey: {
...s.paneForegroundAgentObservationByPaneKey,
[paneKey]: { observedAt: now, ptyId }
}
}
})
},
clearPaneForegroundAgent: (paneKey) => {
set((s) => {
if (!(paneKey in s.paneForegroundAgentByPaneKey)) {
if (
!(paneKey in s.paneForegroundAgentByPaneKey) &&
!(paneKey in s.paneForegroundAgentObservationByPaneKey)
) {
return s
}
const next = { ...s.paneForegroundAgentByPaneKey }
delete next[paneKey]
return { paneForegroundAgentByPaneKey: next }
const nextObservations = { ...s.paneForegroundAgentObservationByPaneKey }
delete nextObservations[paneKey]
return {
paneForegroundAgentByPaneKey: next,
paneForegroundAgentObservationByPaneKey: nextObservations
}
})
},
clearPaneForegroundAgentByTabPrefix: (tabIdPrefix) => {
set(
(s) =>
buildPaneForegroundAgentTabPrefixClearPatch(s.paneForegroundAgentByPaneKey, [
`${tabIdPrefix}:`
]) ?? s
buildPaneForegroundAgentTabPrefixClearPatch(
s.paneForegroundAgentByPaneKey,
s.paneForegroundAgentObservationByPaneKey,
[`${tabIdPrefix}:`]
) ?? s
)
},
clearPaneForegroundAgentByWorktree: (worktreeId) => {
@@ -186,7 +187,11 @@ export const createPaneForegroundAgentSlice: StateCreator<
set((s) => {
const prefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
return (
buildPaneForegroundAgentTabPrefixClearPatch(s.paneForegroundAgentByPaneKey, prefixes) ?? s
buildPaneForegroundAgentTabPrefixClearPatch(
s.paneForegroundAgentByPaneKey,
s.paneForegroundAgentObservationByPaneKey,
prefixes
) ?? s
)
})
}
@@ -195,39 +200,55 @@ export const createPaneForegroundAgentSlice: StateCreator<
/** Return process identity only while the evidence and its PTY are current. */
export function resolveFreshPaneForegroundAgent(
entry: PaneForegroundAgentEntry | undefined,
observation: PaneForegroundAgentObservation | undefined,
args: { now: number; paneBoundPtyId?: string; liveTabPtyIds?: readonly string[] }
): TuiAgent | null {
if (!entry?.agent || entry.shellForeground || entry.observedAt === undefined) {
if (!entry?.agent || entry.shellForeground || !observation) {
return null
}
if (args.now - entry.observedAt > PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS) {
if (args.now - observation.observedAt > PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS) {
return null
}
if (args.paneBoundPtyId !== undefined) {
return entry.ptyId === undefined || entry.ptyId === args.paneBoundPtyId ? entry.agent : null
return observation.ptyId === undefined || observation.ptyId === args.paneBoundPtyId
? entry.agent
: null
}
if (entry.ptyId !== undefined) {
return args.liveTabPtyIds?.includes(entry.ptyId) === true ? entry.agent : null
if (observation.ptyId !== undefined) {
return args.liveTabPtyIds?.includes(observation.ptyId) === true ? entry.agent : null
}
return (args.liveTabPtyIds?.length ?? 0) > 0 ? entry.agent : null
}
export function buildPaneForegroundAgentTabPrefixClearPatch(
entries: Record<string, PaneForegroundAgentEntry>,
observations: Record<string, PaneForegroundAgentObservation>,
tabPrefixes: readonly string[]
): Pick<PaneForegroundAgentSlice, 'paneForegroundAgentByPaneKey'> | null {
): Pick<
PaneForegroundAgentSlice,
'paneForegroundAgentByPaneKey' | 'paneForegroundAgentObservationByPaneKey'
> | null {
if (tabPrefixes.length === 0) {
return null
}
const staleKeys = Object.keys(entries).filter((paneKey) =>
tabPrefixes.some((prefix) => paneKey.startsWith(prefix))
)
const staleKeys = [
...new Set(
[...Object.keys(entries), ...Object.keys(observations)].filter((paneKey) =>
tabPrefixes.some((prefix) => paneKey.startsWith(prefix))
)
)
]
if (staleKeys.length === 0) {
return null
}
const next = { ...entries }
const nextObservations = { ...observations }
for (const paneKey of staleKeys) {
delete next[paneKey]
delete nextObservations[paneKey]
}
return {
paneForegroundAgentByPaneKey: next,
paneForegroundAgentObservationByPaneKey: nextObservations
}
return { paneForegroundAgentByPaneKey: next }
}
@@ -19,7 +19,7 @@ export type RetiredTerminalTabSweepActions = Pick<
/** The state the sweep reduces over: the two store maps plus everything the
* agent-status drop reads. Narrow so a non-store caller can pass its own view. */
export type RetiredTerminalTabSweepState = AgentStatusTabPrefixDropState &
Pick<AppState, 'paneForegroundAgentByPaneKey'>
Pick<AppState, 'paneForegroundAgentByPaneKey' | 'paneForegroundAgentObservationByPaneKey'>
/**
* The suppressor-aware store maps plus three module registries a retired terminal tab strands.
@@ -77,6 +77,7 @@ export function buildRetiredTerminalTabStateSweepPatch(
)
const foreground = buildPaneForegroundAgentTabPrefixClearPatch(
swept.paneForegroundAgentByPaneKey,
swept.paneForegroundAgentObservationByPaneKey,
[`${tabId}:`]
)
swept = { ...swept, ...patch, ...foreground }
@@ -104,6 +104,9 @@ export function buildWorktreePurgeState(
agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey),
acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey),
paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey),
paneForegroundAgentObservationByPaneKey: omitByPaneKeyTabPrefix(
s.paneForegroundAgentObservationByPaneKey
),
sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey),
unreadTerminalTabs: omitByTabId(s.unreadTerminalTabs),
unreadTerminalPanes: omitByPaneKeyTabPrefix(s.unreadTerminalPanes),