Harden probe-free launch clearing against pane, remote, and title races

Review fixes: gate the vanished-activity clear to local panes (remote rows
drop on transport blips), require no live hook row anywhere in the tab,
make the observed-signal generation reset race-safe and self-re-seeding,
accept the neutral default title as exit evidence (inferred-interrupt reset
target), count a title naming the launched agent as its activity so
hookless launched agents can clear, scope layout-less completed rows to
single-pane tabs, share one exit-evidence predicate between the resolver
and the clear effect, and drop the dead tab-agent wrappers.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-02 19:20:01 -07:00
co-authored by Orca
parent d58772e265
commit 1f8219cb22
4 changed files with 314 additions and 109 deletions
+14 -7
View File
@@ -1,15 +1,22 @@
import { describe, expect, it } from 'vitest'
import {
hasCompletedTabAgent,
resolveCompletedTabAgent,
resolveFocusedCompletedTabAgent,
resolveFocusedTabAgent,
resolveSiblingCompletedTabAgent,
resolveSiblingTabAgent,
resolveTabAgent
resolveSiblingTabAgent
} from './tab-agent'
import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot } from '../../../shared/types'
import type { TerminalLayoutSnapshot, TuiAgent } from '../../../shared/types'
// Composed exactly the way useTabAgent layers the resolvers: focused pane
// first, then any sibling agent pane in the tab.
function resolveTabAgent(
map: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
return resolveFocusedTabAgent(map, layout, tabId) ?? resolveSiblingTabAgent(map, layout, tabId)
}
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
@@ -97,8 +104,8 @@ describe('resolveTabAgent', () => {
state: 'done' as const
}
}
expect(hasCompletedTabAgent(map, 'tab-1')).toBe(true)
expect(resolveCompletedTabAgent(map, 'tab-1')).toBe('openclaude')
expect(resolveFocusedCompletedTabAgent(map, undefined, 'tab-1')).toBe('openclaude')
expect(resolveSiblingCompletedTabAgent(map, undefined, 'tab-1')).toBeNull()
})
it('exposes focused and sibling completed hook identity separately', () => {
+4 -35
View File
@@ -4,26 +4,13 @@ import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/sta
import { agentTypeToIconAgent } from './agent-status'
/**
* Resolve a terminal tab's agent from hook-reported status. This is the
* PRIMARY identity signal for the tab-bar icon (see useTabAgent) — the same
* Resolve a terminal tab's agent from hook-reported status — the PRIMARY
* identity signal for the tab-bar icon (composed by 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
* reports an iconable agent.
* Focused-pane resolvers track the pane in view; sibling resolvers cover the
* rest of a split tab.
*/
export function resolveTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
tabId: string
): TuiAgent | null {
return (
resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ??
resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId)
)
}
export function resolveFocusedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
@@ -75,24 +62,6 @@ function agentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | n
return agentTypeToIconAgent(entry.agentType)
}
export function hasCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
tabId: string
): boolean {
return resolveCompletedTabAgent(agentStatusByPaneKey, tabId) !== null
}
export function resolveCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
tabId: string,
layout?: TerminalLayoutSnapshot
): TuiAgent | null {
return (
resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ??
resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId)
)
}
export function resolveFocusedCompletedTabAgent(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
layout: TerminalLayoutSnapshot | undefined,
+187 -38
View File
@@ -86,7 +86,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: 'Terminal 1',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('claude')
@@ -99,7 +98,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: 'zsh',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('claude')
@@ -112,7 +110,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '⠋ OpenClaude',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('openclaude')
@@ -125,7 +122,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✦ Gemini CLI',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('gemini')
@@ -136,7 +132,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: 'MiMo Code',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('mimo-code')
@@ -147,7 +142,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: 'π - my-project',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('pi')
@@ -160,8 +154,7 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Say hi',
hookAgent: null,
hasCompletedHook: true,
completedHookAgent: 'openclaude',
focusedCompletedHookAgent: 'openclaude',
launchAgent: 'openclaude'
})
).toBe('openclaude')
@@ -174,7 +167,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Say hi',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'openclaude'
})
).toBe('openclaude')
@@ -187,7 +179,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '⠸ codex-quarter-flash-202606191419',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
@@ -200,7 +191,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '⠸ investigating startup',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBeNull()
@@ -214,7 +204,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title,
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBeNull()
@@ -228,7 +217,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '⠸ Claude Code',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
@@ -241,7 +229,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ improve-pr-actions-customization',
hookAgent: 'codex',
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
@@ -254,7 +241,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'openclaude'
})
).toBe('openclaude')
@@ -267,7 +253,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('claude')
@@ -280,7 +265,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
@@ -293,7 +277,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Gemini CLI',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('claude')
@@ -306,7 +289,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✦ Gemini CLI',
hookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'gemini'
})
).toBe('claude')
@@ -320,7 +302,6 @@ describe('resolveTabAgentFromSignals', () => {
title: 'Terminal 1',
hookAgent: 'claude',
siblingHookAgent: 'gemini',
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('claude')
@@ -334,7 +315,6 @@ describe('resolveTabAgentFromSignals', () => {
title: 'Terminal 1',
hookAgent: null,
siblingHookAgent: 'claude',
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
@@ -348,7 +328,6 @@ describe('resolveTabAgentFromSignals', () => {
title: 'Terminal 1',
hookAgent: null,
siblingHookAgent: 'claude',
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
@@ -361,7 +340,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Gemini CLI',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'gemini'
})
).toBe('gemini')
@@ -374,7 +352,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '. Compare Opencode Vs Orca',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'opencode'
})
).toBe('opencode')
@@ -385,7 +362,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '* Review Codex behavior',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
@@ -398,7 +374,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
@@ -409,23 +384,64 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: '. Claude Code compare Opencode',
hookAgent: null,
hasCompletedHook: false,
launchAgent: undefined
})
).toBe('claude')
})
it('keeps launch identity at a shell title until hook evidence proves exit', () => {
it('clears local launch identity once observed activity vanishes at a shell title', () => {
// Why: matches the clear effect — the dropped hook row plus a shell title
// is the crash/kill exit evidence, so the resolver must not lag it.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
hasCompletedHook: false,
launchAgent: 'codex'
})
).toBe('codex')
).toBeNull()
})
it('keeps launch identity at a shell title while a sibling hook row is live', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
siblingHookAgent: 'gemini',
launchAgent: 'claude'
})
).toBe('claude')
})
it('keeps remote launch identity at a shell title without completed-hook evidence', () => {
// Why: remote hook rows also drop on transport blips, so vanished activity
// alone must not count as exit evidence for remote panes.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'zsh',
hookAgent: null,
launchAgent: 'claude'
})
).toBe('claude')
})
it('treats the neutral default title as exit evidence alongside shell titles', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'Terminal 2',
defaultTitle: 'Terminal 2',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
launchAgent: 'claude'
})
).toBeNull()
})
it('keeps hook identity for remote panes', () => {
@@ -435,7 +451,6 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: true,
title: 'Terminal 1',
hookAgent: 'codex',
hasCompletedHook: false,
launchAgent: 'claude'
})
).toBe('codex')
@@ -448,8 +463,7 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: true,
title: 'zsh',
hookAgent: null,
hasCompletedHook: true,
completedHookAgent: 'codex',
focusedCompletedHookAgent: 'codex',
launchAgent: 'codex'
})
).toBe('codex')
@@ -465,8 +479,7 @@ describe('resolveTabAgentFromSignals', () => {
isRemote: false,
title: 'zsh',
hookAgent: null,
hasCompletedHook: true,
completedHookAgent: 'claude',
focusedCompletedHookAgent: 'claude',
launchAgent: 'claude'
})
).toBeNull()
@@ -585,9 +598,7 @@ describe('useTabAgent', () => {
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.
// Why: crash/kill exits drop the live row without a completed hook.
await act(async () => {
useAppStore.setState({ agentStatusByPaneKey: {} })
})
@@ -672,4 +683,142 @@ describe('useTabAgent', () => {
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
expect(getForegroundProcess).not.toHaveBeenCalled()
})
it('does not clear remote launch identity when the hook row drops at a shell title', async () => {
const remotePtyId = 'remote:web-env-1@@terminal-1'
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': [remotePtyId] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: remotePtyId }
}
},
agentStatusByPaneKey: {
[paneKey]: workingAgentStatus(paneKey)
}
})
const remoteTab = { ...baseTab, ptyId: remotePtyId, launchAgent: 'claude' as const }
const root = await renderHookProbe(remoteTab)
// Why: remote rows also drop on transport blips (reconnect, snapshot gaps)
// that say nothing about the process — only a completed hook may clear.
await act(async () => {
useAppStore.setState({ agentStatusByPaneKey: {} })
})
await rerenderHookProbe(root, { ...remoteTab, title: 'zsh' })
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
expect(latestHookAgent).toBe('claude')
})
it('does not clear launch identity while a sibling hook row is still live', async () => {
const focusedPaneKey = makePaneKey('tab-1', LEAF_ID)
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() },
agentStatusByPaneKey: {
[focusedPaneKey]: workingAgentStatus(focusedPaneKey),
[siblingPaneKey]: workingAgentStatus(siblingPaneKey)
}
})
const splitTab = { ...baseTab, ptyId: 'pty-focus', launchAgent: 'claude' as const }
const root = await renderHookProbe(splitTab)
// Focused row drops but the sibling agent still runs: no exit evidence yet.
await act(async () => {
useAppStore.setState({
agentStatusByPaneKey: { [siblingPaneKey]: workingAgentStatus(siblingPaneKey) }
})
})
await rerenderHookProbe(root, { ...splitTab, title: 'zsh' })
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
await act(async () => {
useAppStore.setState({ agentStatusByPaneKey: {} })
})
await rerenderHookProbe(root, { ...splitTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
})
it('clears launch identity at the neutral default title after a completed hook', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
useAppStore.setState({
agentStatusByPaneKey: {
[paneKey]: completedAgentStatus(paneKey)
}
})
// Why: the inferred-interrupt flow resets titles to the tab's default
// ("Terminal N"), not a shell name — that must still count as exit.
await renderHookProbe({ ...baseTab, title: 'Terminal 3', defaultTitle: 'Terminal 3' })
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
})
it('clears hookless launch identity once its own title evidence ends at a shell', async () => {
const geminiTab = { ...baseTab, launchAgent: 'gemini' as const, title: '✦ Gemini CLI' }
// Why: agents without hook integration prove activity via a title naming
// the launched agent; the later shell title is then exit evidence.
const root = await renderHookProbe(geminiTab)
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
await rerenderHookProbe(root, { ...geminiTab, title: 'zsh' })
expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1')
})
it('does not treat a layout-less multi-pane completed row as focused exit evidence', async () => {
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
useAppStore.setState({
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
terminalLayoutsByTabId: {},
agentStatusByPaneKey: {
[siblingPaneKey]: completedAgentStatus(siblingPaneKey)
}
})
await renderHookProbe({ ...baseTab, ptyId: 'pty-focus', title: 'zsh', launchAgent: 'claude' })
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
})
it('does not clear launch identity on the commit that switches pane generations', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const boundLayout = {
root: { type: 'leaf', leafId: LEAF_ID } as const,
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
useAppStore.setState({
terminalLayoutsByTabId: { 'tab-1': boundLayout },
agentStatusByPaneKey: { [paneKey]: workingAgentStatus(paneKey) }
})
const root = await renderHookProbe(baseTab)
expect(latestHookAgent).toBe('codex')
// Why: a respawn/focus switch can land the new ptyId, the dropped row, and
// a shell title in one commit — the previous generation's observed signal
// must not clear the new generation's launch identity.
await act(async () => {
useAppStore.setState({
terminalLayoutsByTabId: {
'tab-1': { ...boundLayout, ptyIdsByLeafId: { [LEAF_ID]: 'pty-2' } }
},
agentStatusByPaneKey: {}
})
})
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
})
})
+109 -29
View File
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store'
import { isShellProcess } from '../../../shared/agent-detection'
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { isTerminalLeafId } from '../../../shared/stable-pane-id'
import {
resolveFocusedCompletedTabAgent,
resolveFocusedTabAgent,
@@ -14,14 +15,48 @@ import type { TerminalTab, TuiAgent } from '../../../shared/types'
export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type'
// A shell name, or the tab's neutral default title — where Orca's
// inferred-interrupt reset parks it. Blank titles are no evidence either way.
function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
const trimmed = title.trim()
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
}
/**
* Probe-free evidence that a launched agent exited: the title shows no agent,
* no live hook row remains in the tab, and either the hook completed or
* previously observed activity vanished. The vanished-activity disjunct is
* local-only: remote rows also drop on transport blips that say nothing about
* the process.
*/
export function resolveLaunchedAgentExitEvidence(args: {
title: string
defaultTitle?: string
isRemote: boolean
hasObservedAgentSignal: boolean
hookAgent: TuiAgent | null
siblingHookAgent?: TuiAgent | null
hasCompletedHook: boolean
}): boolean {
if (
!titleShowsNoAgent(args.title, args.defaultTitle) ||
args.hookAgent ||
args.siblingHookAgent
) {
return false
}
return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal)
}
export function resolveTabAgentFromSignals(args: {
hasObservedAgentSignal: boolean
isRemote: boolean
title: string
defaultTitle?: string
hookAgent: TuiAgent | null
siblingHookAgent?: TuiAgent | null
hasCompletedHook: boolean
completedHookAgent?: TuiAgent | null
focusedCompletedHookAgent?: TuiAgent | null
siblingCompletedHookAgent?: TuiAgent | null
launchAgent?: TuiAgent
}): TuiAgent | null {
const launchAgent = args.launchAgent ?? null
@@ -40,17 +75,26 @@ export function resolveTabAgentFromSignals(args: {
: launchAgent
? null
: explicitTitleAgent
const titleLooksShell = isShellProcess(args.title)
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
// 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
!args.isRemote && noAgentTitle && hasCompletedHook
? null
: (args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent ?? null)
const focusedHookAgent = args.hookAgent ?? null
const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null
// 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
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
title: args.title,
defaultTitle: args.defaultTitle,
isRemote: args.isRemote,
hasObservedAgentSignal: args.hasObservedAgentSignal,
hookAgent: focusedHookAgent,
siblingHookAgent: args.siblingHookAgent,
hasCompletedHook
})
const activeLaunchAgent = launchedAgentExited ? 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
@@ -97,7 +141,6 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
tab.id
)
)
const completedHookAgent = focusedCompletedHookAgent ?? siblingCompletedHookAgent
const hasCompletedHook = focusedCompletedHookAgent !== null
const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)
@@ -113,6 +156,16 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const ptyIds = s.ptyIdsByTabId[tab.id] ?? []
return ptyIds.length === 1 ? ptyIds[0]! : null
})
// Why: with no layout to prove which pane a completed row belongs to, only a
// single-pane tab may treat it as focused-pane exit evidence — a sibling's
// done row must not clear another pane's launch identity.
const completedHookScopeKnown = useAppStore((s) => {
const layout = s.terminalLayoutsByTabId[tab.id]
if (layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId)) {
return true
}
return (s.ptyIdsByTabId[tab.id] ?? []).length <= 1
})
const hasRemoteRuntimePty = useAppStore((s) => {
const layout = s.terminalLayoutsByTabId[tab.id]
const ptyIds = new Set(s.ptyIdsByTabId[tab.id] ?? [])
@@ -126,41 +179,67 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false)
const hasObservedAgentSignalRef = useRef(false)
const signalGenerationRef = useRef<string | null>(null)
const completedHookEvidence = hasCompletedHook && completedHookScopeKnown
useEffect(() => {
setHasObservedAgentSignal(false)
hasObservedAgentSignalRef.current = false
}, [ptyId, isRemoteLike])
useEffect(() => {
const fallbackAgentSignal =
!tab.launchAgent && (resolveExplicitTerminalTitleAgentType(tab.title) || siblingHookAgent)
if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) {
// Why: reset and re-seed in one effect so a pane respawn both invalidates
// the previous generation's signal and immediately re-observes a still-live
// hook row instead of leaving the signal stuck false.
const generation = `${ptyId ?? ''}|${String(isRemoteLike)}`
if (signalGenerationRef.current !== generation) {
signalGenerationRef.current = generation
hasObservedAgentSignalRef.current = false
setHasObservedAgentSignal(false)
}
const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(tab.title)
// Why: for launched panes, only a title naming the launched agent counts as
// its activity — other-agent or sibling evidence must not arm exit clearing
// for an agent that never produced evidence of its own.
const fallbackAgentSignal = tab.launchAgent
? explicitTitleAgent === tab.launchAgent
: Boolean(explicitTitleAgent || siblingHookAgent)
if (focusedHookAgent || completedHookEvidence || fallbackAgentSignal) {
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
}
}, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title])
}, [
ptyId,
isRemoteLike,
focusedHookAgent,
completedHookEvidence,
siblingHookAgent,
tab.launchAgent,
tab.title
])
useEffect(() => {
if (!tab.launchAgent) {
return
}
const titleLooksShell = isShellProcess(tab.title)
// 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))
// Why: AND the state with the ref — the ref is generation-safe within this
// commit (the observe effect above already reset it), while the state can
// lag one render behind a pane focus/respawn switch.
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
title: tab.title,
defaultTitle: tab.defaultTitle,
isRemote: isRemoteLike,
hasObservedAgentSignal: hasObservedAgentSignal && hasObservedAgentSignalRef.current,
hookAgent: focusedHookAgent,
siblingHookAgent,
hasCompletedHook: completedHookEvidence
})
if (launchedAgentExited) {
clearTabLaunchAgent(tab.id)
}
}, [
clearTabLaunchAgent,
completedHookEvidence,
focusedHookAgent,
hasCompletedHook,
siblingHookAgent,
hasObservedAgentSignal,
isRemoteLike,
tab.defaultTitle,
tab.id,
tab.launchAgent,
tab.title
@@ -170,10 +249,11 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
hasObservedAgentSignal,
isRemote: isRemoteLike,
title: tab.title,
defaultTitle: tab.defaultTitle,
hookAgent: focusedHookAgent,
siblingHookAgent,
hasCompletedHook,
completedHookAgent,
focusedCompletedHookAgent,
siblingCompletedHookAgent,
launchAgent: tab.launchAgent
})
}