Unify tab agent identity with the agent-status pipeline

Per Brennan's direction on this PR: tab icons must not run their own
foreground-process probe. Identity now flows through the same
already-computed state as the sidebar agent rows — hook entries (kept
live by the OSC 133 command-finished drop machinery), explicit titles,
and launch bootstrap.

The probe's one unique local job, clearing stale launch identity after
the launched agent exits, is replaced with probe-free evidence: a
completed hook at a shell title, or a previously observed live hook row
that was dropped (crash/kill path) while the title reads as a shell.

Behavior trades, accepted with this direction: agents that never emit
hooks resolve from title evidence or show no icon, and a done-but-open
agent whose title reads as a shell shows no icon (matches the existing
remote-pane behavior).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-02 18:30:46 -07:00
co-authored by Orca
parent c06507ee30
commit d58772e265
4 changed files with 116 additions and 356 deletions
@@ -93,7 +93,7 @@ export default function SortableTab({
// Older persisted tabs without this field fall back to the generic icon.
const shellForIcon = tab.shellOverride
// Why: foreground process and hook status make the tab icon reflect the
// Why: hook status and title evidence make the tab icon reflect the
// coding harness currently running in the pane, not just the launch command.
const tabAgent = useTabAgent(tab)
+3 -4
View File
@@ -5,10 +5,9 @@ import { agentTypeToIconAgent } from './agent-status'
/**
* Resolve a terminal tab's agent from hook-reported status. This is the
* FALLBACK signal for the tab-bar icon — the live foreground process
* (see useTabAgent) is the primary, dev-friendly source. Hook status is what
* drives the icon for SSH/remote panes (where foreground polling is too
* costly) and during the brief window before the first foreground poll lands.
* PRIMARY identity signal for the tab-bar icon (see useTabAgent) — the same
* already-computed state that drives the sidebar agent rows, kept live by the
* OSC 133 command-finished machinery that drops entries when a process exits.
*
* Prefers the focused pane's agent so a split tab's icon tracks the pane in
* view; falls back to any agent pane in the tab. Returns null when no pane
+84 -203
View File
@@ -79,27 +79,10 @@ function twoPaneLayout(): TerminalLayoutSnapshot {
}
describe('resolveTabAgentFromSignals', () => {
it('uses a recognized foreground agent as the live local source of truth', () => {
expect(
resolveTabAgentFromSignals({
foreground: 'codex',
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'Terminal 1',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('codex')
})
it('keeps launch intent during the pre-start shell window', () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'Terminal 1',
hookAgent: null,
@@ -109,27 +92,23 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('lets shell foreground clear stale identity even when the title still names an agent', () => {
it('trusts live hook identity at a shell title until the hook row is dropped', () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: true,
isRemote: false,
title: '✳ Claude Code',
title: 'zsh',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBeNull()
).toBe('claude')
})
it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '⠋ OpenClaude',
hookAgent: null,
@@ -142,9 +121,7 @@ describe('resolveTabAgentFromSignals', () => {
it('keeps title fallback for real Gemini, MiMo, and Pi titles', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✦ Gemini CLI',
hookAgent: null,
@@ -155,9 +132,7 @@ describe('resolveTabAgentFromSignals', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'MiMo Code',
hookAgent: null,
@@ -168,9 +143,7 @@ describe('resolveTabAgentFromSignals', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'π - my-project',
hookAgent: null,
@@ -183,9 +156,7 @@ describe('resolveTabAgentFromSignals', () => {
it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Say hi',
hookAgent: null,
@@ -199,9 +170,7 @@ describe('resolveTabAgentFromSignals', () => {
it('keeps launch identity over title identity while hooks have not arrived', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Say hi',
hookAgent: null,
@@ -214,9 +183,7 @@ describe('resolveTabAgentFromSignals', () => {
it("keeps Codex launch intent over Claude's generic spinner title fallback", () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '⠸ codex-quarter-flash-202606191419',
hookAgent: null,
@@ -229,9 +196,7 @@ describe('resolveTabAgentFromSignals', () => {
it('does not infer Claude identity from a generic spinner title without context', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '⠸ investigating startup',
hookAgent: null,
@@ -245,9 +210,7 @@ describe('resolveTabAgentFromSignals', () => {
for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title,
hookAgent: null,
@@ -261,9 +224,7 @@ describe('resolveTabAgentFromSignals', () => {
it('keeps launch identity over explicit title identity until stronger signals arrive', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '⠸ Claude Code',
hookAgent: null,
@@ -276,9 +237,7 @@ describe('resolveTabAgentFromSignals', () => {
it("uses Codex hook identity over Claude's generic task-title heuristic", () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ improve-pr-actions-customization',
hookAgent: 'codex',
@@ -288,12 +247,10 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => {
it('keeps launch identity over explicit Claude Code titles without hook evidence', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
@@ -306,9 +263,7 @@ describe('resolveTabAgentFromSignals', () => {
it('lets an explicit title override stale launch identity after the pane shows newer activity', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
@@ -321,9 +276,7 @@ describe('resolveTabAgentFromSignals', () => {
it('does not let an explicit title override launch identity before any activity is observed', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
@@ -333,42 +286,10 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('lets shell foreground clear the icon after an agent was observed running', () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBeNull()
})
it('does not let a pre-start shell sample suppress a later hook signal', () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'Terminal 1',
hookAgent: 'codex',
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
})
it('prefers explicit hook identity over a conflicting title mention', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Gemini CLI',
hookAgent: 'claude',
@@ -381,9 +302,7 @@ describe('resolveTabAgentFromSignals', () => {
it('prefers explicit hook identity over ordinary non-Claude title identity', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✦ Gemini CLI',
hookAgent: 'claude',
@@ -396,9 +315,7 @@ describe('resolveTabAgentFromSignals', () => {
it('lets focused-pane hook identity override launch metadata in split tabs', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'Terminal 1',
hookAgent: 'claude',
@@ -412,9 +329,7 @@ describe('resolveTabAgentFromSignals', () => {
it('keeps unresolved launch metadata ahead of sibling-pane hook fallback', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'Terminal 1',
hookAgent: null,
@@ -428,9 +343,7 @@ describe('resolveTabAgentFromSignals', () => {
it('uses sibling-pane hook fallback when no launch metadata exists', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'Terminal 1',
hookAgent: null,
@@ -441,12 +354,10 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => {
it('keeps launch identity over Claude-owned task text without hook evidence', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Gemini CLI',
hookAgent: null,
@@ -459,9 +370,7 @@ describe('resolveTabAgentFromSignals', () => {
it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '. Compare Opencode Vs Orca',
hookAgent: null,
@@ -472,9 +381,7 @@ describe('resolveTabAgentFromSignals', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '* Review Codex behavior',
hookAgent: null,
@@ -487,9 +394,7 @@ describe('resolveTabAgentFromSignals', () => {
it('treats Claude-prefixed title text as Claude only when it names Claude', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
@@ -500,9 +405,7 @@ describe('resolveTabAgentFromSignals', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: false,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: '. Claude Code compare Opencode',
hookAgent: null,
@@ -512,12 +415,10 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('claude')
})
it('keeps local launch identity when only a shell title suggests exit', () => {
it('keeps launch identity at a shell title until hook evidence proves exit', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'zsh',
hookAgent: null,
@@ -527,12 +428,10 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('skips local foreground authority for remote worktrees', () => {
it('keeps hook identity for remote panes', () => {
expect(
resolveTabAgentFromSignals({
foreground: null,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: true,
isRemote: true,
title: 'Terminal 1',
hookAgent: 'codex',
@@ -545,9 +444,7 @@ describe('resolveTabAgentFromSignals', () => {
it('keeps completed remote hook identity after the terminal title returns to a shell', () => {
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: true,
title: 'zsh',
hookAgent: null,
@@ -558,19 +455,21 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('keeps local launch identity after a completed hook until foreground proves shell exit', () => {
it('clears local launch identity once a completed hook and shell title prove exit', () => {
// Why: without foreground probing, a completed hook plus the title back at
// a shell is the process-gone evidence — the same signals that clear the
// sidebar row — so stale launch identity must not keep painting the tab.
expect(
resolveTabAgentFromSignals({
foreground: undefined,
hasObservedAgentSignal: true,
shellForegroundAfterAgentSignal: false,
isRemote: false,
title: 'zsh',
hookAgent: null,
hasCompletedHook: true,
completedHookAgent: 'claude',
launchAgent: 'claude'
})
).toBe('claude')
).toBeNull()
})
})
@@ -619,103 +518,86 @@ describe('useTabAgent', () => {
window.api = originalApi
})
it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => {
getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh')
it('never probes the foreground process', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
},
agentStatusByPaneKey: {
[paneKey]: workingAgentStatus(paneKey)
}
})
const root = await renderHookProbe(baseTab)
await rerenderHookProbe(root, { ...baseTab, title: '✳ Codex' })
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
expect(latestHookAgent).toBe('codex')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
it('does not clear launch identity while the live hook row persists at a shell title', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
},
agentStatusByPaneKey: {
[paneKey]: workingAgentStatus(paneKey)
}
})
await renderHookProbe({ ...baseTab, title: 'zsh' })
expect(latestHookAgent).toBe('codex')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
})
it('clears launch identity when a previously observed hook row drops at a shell title', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
},
agentStatusByPaneKey: {
[paneKey]: workingAgentStatus(paneKey)
}
})
const root = await renderHookProbe(baseTab)
expect(latestHookAgent).toBe('codex')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
// Why: crash/kill exits never fire a completed hook — the OSC 133
// command-finished machinery drops the live entry and the title returns
// to a shell. That already-computed evidence must clear launch intent.
await act(async () => {
useAppStore.setState({ agentStatusByPaneKey: {} })
})
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
expect(latestHookAgent).toBeNull()
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
})
it('retries helper foreground so daemon-derived agent beats stale launch identity', async () => {
vi.useFakeTimers()
getForegroundProcess.mockResolvedValueOnce('uv').mockResolvedValueOnce('claude')
try {
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
expect(latestHookAgent).toBe('opencode')
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
expect(latestHookAgent).toBe('claude')
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('does not retry unknown helper foreground without launch intent', async () => {
vi.useFakeTimers()
getForegroundProcess.mockResolvedValue('uv')
try {
await renderHookProbe({ ...baseTab, launchAgent: undefined })
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
expect(latestHookAgent).toBeNull()
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('keeps one post-throttle shell retry to observe daemon-derived launch identity', async () => {
vi.useFakeTimers()
getForegroundProcess
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('claude')
try {
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
expect(latestHookAgent).toBe('opencode')
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
await act(async () => {
await vi.advanceTimersByTimeAsync(250 + 1250 + 3500)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledTimes(4)
expect(latestHookAgent).toBe('opencode')
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledTimes(5)
expect(latestHookAgent).toBe('claude')
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
})
it('uses completed local hook status as launch lifecycle evidence after remount', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
getForegroundProcess.mockResolvedValueOnce('zsh')
useAppStore.setState({
agentStatusByPaneKey: {
[paneKey]: completedAgentStatus(paneKey)
@@ -724,9 +606,9 @@ describe('useTabAgent', () => {
await renderHookProbe({ ...baseTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
expect(latestHookAgent).toBeNull()
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => {
@@ -771,7 +653,6 @@ describe('useTabAgent', () => {
it('does not use completed sibling hook status as focused launch lifecycle evidence', async () => {
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
getForegroundProcess.mockResolvedValueOnce('zsh')
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() },
@@ -789,6 +670,6 @@ describe('useTabAgent', () => {
expect(latestHookAgent).toBe('claude')
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus')
expect(getForegroundProcess).not.toHaveBeenCalled()
})
})
+28 -148
View File
@@ -1,7 +1,5 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: tab agent foreground state is synchronized from PTY/remote agent signals and shell foreground events. */
import { useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { recognizeAgentProcess } from '../../../shared/agent-process-recognition'
import { isShellProcess } from '../../../shared/agent-detection'
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
@@ -16,31 +14,8 @@ import type { TerminalTab, TuiAgent } from '../../../shared/types'
export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type'
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const
function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string {
const titleAgent = launchAgent ? null : resolveExplicitTerminalTitleAgentType(title)
if (titleAgent) {
return `agent:${titleAgent}`
}
if (isShellProcess(title)) {
return 'shell'
}
const stableTitle = title
.trim()
.toLowerCase()
// Why: unknown agents may still animate leading status glyphs. Include the
// stable title body so first launch from "Terminal 1" triggers one poll,
// without polling on every spinner frame.
.replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '')
.slice(0, 48)
return `unknown:${stableTitle}`
}
export function resolveTabAgentFromSignals(args: {
foreground: TuiAgent | null | undefined
hasObservedAgentSignal: boolean
shellForegroundAfterAgentSignal: boolean
isRemote: boolean
title: string
hookAgent: TuiAgent | null
@@ -66,52 +41,36 @@ export function resolveTabAgentFromSignals(args: {
? null
: explicitTitleAgent
const titleLooksShell = isShellProcess(args.title)
// Why: remote panes cannot cheaply prove shell foreground after hook exit,
// so keep the last completed hook identity instead of flashing unknown.
// Why: remote pane titles can lag their runtime, so keep the last completed
// hook identity instead of flashing unknown when the title reads as a shell.
const completedHookAgent =
!args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent
const focusedHookAgent = args.hookAgent ?? null
const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null
const localShellForegroundClearedLaunch =
!args.isRemote && args.foreground === null && args.shellForegroundAfterAgentSignal
const remoteCompletedHookAtShellTitle = args.isRemote && titleLooksShell && args.hasCompletedHook
const activeLaunchAgent =
localShellForegroundClearedLaunch || remoteCompletedHookAtShellTitle ? null : launchAgent
// Why: titleAgent now ranks ahead of launch/fallback hooks because, once the
// Why: a completed hook with the title back at a shell is this pipeline's
// process-gone evidence — the same signals that clear the sidebar row.
const completedHookAtShellTitle = titleLooksShell && args.hasCompletedHook
const activeLaunchAgent = completedHookAtShellTitle ? null : launchAgent
// Why: titleAgent ranks ahead of launch/fallback hooks because, once the
// pane has shown activity, a live explicit title is the freshest identity
// signal — it beats a launchAgent gone stale through pane reuse. Before any
// activity, titleAgent is null while launchAgent exists, so launch bootstrap
// still wins the startup window.
if (args.isRemote || args.foreground === undefined) {
return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent
}
if (args.foreground) {
return args.foreground
}
// Why: once a local pane has returned to a shell, a stale hook should not keep
// painting it as an agent tab.
if (args.shellForegroundAfterAgentSignal) {
return null
}
return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent
}
/**
* Resolve which coding-harness agent a terminal tab is running, for its tab-bar
* icon. Layered signals, most-authoritative first:
* icon. Identity flows through the same already-computed state as the sidebar
* agent rows — no foreground probing. Layered signals, most-authoritative
* first:
*
* 1. Live foreground process — the ground truth for what's running *now*: the
* only signal that reverts to the terminal glyph when the agent exits to a
* shell, or flips when a different agent starts in the same pane. Checked
* event-driven (only when the tab's title changes — exactly when an agent
* starts/exits/takes a turn), never on an interval, and only for local panes
* (SSH foreground inspection is a 15s-timeout RPC). A recognized agent wins;
* a recognized shell authoritatively means "no agent".
* 2. Hook status — accurate provider identity from native integrations, and
* available for SSH/remote panes where foreground polling is too costly.
* 3. launchAgent — what Orca launched here; instant bootstrap before hooks or
* foreground polling arrive, and the owned identity for startup windows.
* 4. Title — legacy/unknown-session fallback, and the live override when a pane
* 1. Hook status — provider identity from native integrations; the live entry
* for the pane, dropped by the same OSC 133 command-finished machinery that
* clears the sidebar row when the process exits.
* 2. launchAgent — what Orca launched here; instant bootstrap before hooks
* arrive, cleared once hook/title evidence shows the launched agent exited.
* 3. Title — legacy/unknown-session fallback, and the live override when a pane
* is reused: once the pane has shown activity, a title that explicitly names
* a different agent than launchAgent wins over that stale launch identity.
* Otherwise it is ignored while launchAgent exists, and generic spinner-only
@@ -142,7 +101,8 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const hasCompletedHook = focusedCompletedHookAgent !== null
const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)
// The focused pane's PTY (single-pane tabs have exactly one leaf).
// The focused pane's PTY (single-pane tabs have exactly one leaf). Only used
// to reset per-process-generation signals when the pane is respawned.
const ptyId = useAppStore((s) => {
const layout = s.terminalLayoutsByTabId[tab.id]
const activeLeafId = layout?.activeLeafId
@@ -151,8 +111,6 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
return leafPty
}
const ptyIds = s.ptyIdsByTabId[tab.id] ?? []
// Why: without a focused leaf, a split tab's first PTY can be a sibling
// shell. Only single-PTY fallback foreground is authoritative.
return ptyIds.length === 1 ? ptyIds[0]! : null
})
const hasRemoteRuntimePty = useAppStore((s) => {
@@ -166,128 +124,50 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const isRemoteWorktree = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId))
const isRemoteLike = isRemoteWorktree || hasRemoteRuntimePty
// undefined = no conclusive local reading (defer to title/hook/launchAgent);
// null = foreground is a shell; TuiAgent = recognized agent process.
const [foreground, setForeground] = useState<TuiAgent | null | undefined>(undefined)
const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false)
const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false)
const hasObservedAgentSignalRef = useRef(false)
const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent)
useEffect(() => {
setForeground(undefined)
setHasObservedAgentSignal(false)
hasObservedAgentSignalRef.current = false
setShellForegroundAfterAgentSignal(false)
}, [ptyId, isRemoteLike])
useEffect(() => {
const fallbackAgentSignal =
!tab.launchAgent && (resolveExplicitTerminalTitleAgentType(tab.title) || siblingHookAgent)
// Why: a completed structured hook proves a launched agent existed, but
// local launch cleanup still waits for current foreground-shell evidence.
if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) {
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
}
}, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title])
useEffect(() => {
if (!ptyId || isRemoteLike) {
return
}
const localPtyId = ptyId
let cancelled = false
const helperForegroundRetryTimers: number[] = []
// Why: re-runs when ptyId or tab.title changes — a title change is the event
// signalling a possible foreground transition (agent start, exit, or turn).
// One RPC per transition, not a timer; cancellation coalesces rapid churn.
function readForeground(retryIndex = 0): void {
window.api.pty
.getForegroundProcess(localPtyId)
.then((process) => {
applyForegroundProcess(process, retryIndex)
})
.catch(() => {
if (!cancelled) {
setForeground(undefined)
}
})
}
function scheduleHelperForegroundRetry(retryIndex: number): void {
const delay = HELPER_FOREGROUND_RETRY_DELAYS_MS[retryIndex]
if (delay === undefined) {
return
}
// Why: the daemon resolves shell/helper -> agent ancestry asynchronously
// after the first foreground read, so give its short cache a bounded re-read.
const timer = window.setTimeout(() => {
readForeground(retryIndex + 1)
}, delay)
helperForegroundRetryTimers.push(timer)
}
function applyForegroundProcess(process: string | null, retryIndex: number): void {
if (cancelled) {
return
}
const recognized = recognizeAgentProcess(process)
if (recognized) {
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
setForeground(recognized.agent)
} else if (process && isShellProcess(process)) {
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
setForeground(null)
if (tab.launchAgent && !hasObservedAgentSignalRef.current) {
scheduleHelperForegroundRetry(retryIndex)
}
} else {
if (process && tab.launchAgent) {
// Why: for Orca-owned launches, an unrecognized non-shell process
// is enough lifecycle evidence to clear launch intent when the pane
// later returns to a shell, without using title text as identity.
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
}
setForeground(undefined)
if (process && tab.launchAgent) {
scheduleHelperForegroundRetry(retryIndex)
}
}
}
readForeground()
return () => {
cancelled = true
helperForegroundRetryTimers.forEach((timer) => window.clearTimeout(timer))
}
}, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey])
useEffect(() => {
if (!tab.launchAgent) {
return
}
const titleLooksShell = isShellProcess(tab.title)
const foregroundSawExitedAgent =
!isRemoteLike && foreground === null && shellForegroundAfterAgentSignal
const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell
if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) {
// Why: launched-agent exit evidence without probing — either the hook
// completed, or the pane's live hook row was dropped by command-finished
// after the pane had shown agent activity, while the title reads as a
// shell again. Crash/kill paths land in the second disjunct because the
// OSC 133 drop removes the live entry without a completed hook.
const launchedAgentExited =
titleLooksShell && (hasCompletedHook || (hasObservedAgentSignal && !focusedHookAgent))
if (launchedAgentExited) {
clearTabLaunchAgent(tab.id)
}
}, [
clearTabLaunchAgent,
foreground,
focusedHookAgent,
hasCompletedHook,
isRemoteLike,
shellForegroundAfterAgentSignal,
hasObservedAgentSignal,
tab.id,
tab.launchAgent,
tab.title
])
return resolveTabAgentFromSignals({
foreground,
hasObservedAgentSignal,
shellForegroundAfterAgentSignal,
isRemote: isRemoteLike,
title: tab.title,
hookAgent: focusedHookAgent,