From 1f8219cb22ca5f68cb4e0c4bb936d97d1b502b28 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 2 Jul 2026 19:20:01 -0700 Subject: [PATCH] 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 --- src/renderer/src/lib/tab-agent.test.ts | 21 +- src/renderer/src/lib/tab-agent.ts | 39 +--- src/renderer/src/lib/use-tab-agent.test.ts | 225 +++++++++++++++++---- src/renderer/src/lib/use-tab-agent.ts | 138 ++++++++++--- 4 files changed, 314 insertions(+), 109 deletions(-) diff --git a/src/renderer/src/lib/tab-agent.test.ts b/src/renderer/src/lib/tab-agent.test.ts index e14583269df..770e5df9912 100644 --- a/src/renderer/src/lib/tab-agent.test.ts +++ b/src/renderer/src/lib/tab-agent.test.ts @@ -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, + 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', () => { diff --git a/src/renderer/src/lib/tab-agent.ts b/src/renderer/src/lib/tab-agent.ts index a23d3cde5cc..38b6a2dac12 100644 --- a/src/renderer/src/lib/tab-agent.ts +++ b/src/renderer/src/lib/tab-agent.ts @@ -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, - layout: TerminalLayoutSnapshot | undefined, - tabId: string -): TuiAgent | null { - return ( - resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ?? - resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId) - ) -} - export function resolveFocusedTabAgent( agentStatusByPaneKey: Record, layout: TerminalLayoutSnapshot | undefined, @@ -75,24 +62,6 @@ function agentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | n return agentTypeToIconAgent(entry.agentType) } -export function hasCompletedTabAgent( - agentStatusByPaneKey: Record, - tabId: string -): boolean { - return resolveCompletedTabAgent(agentStatusByPaneKey, tabId) !== null -} - -export function resolveCompletedTabAgent( - agentStatusByPaneKey: Record, - tabId: string, - layout?: TerminalLayoutSnapshot -): TuiAgent | null { - return ( - resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ?? - resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId) - ) -} - export function resolveFocusedCompletedTabAgent( agentStatusByPaneKey: Record, layout: TerminalLayoutSnapshot | undefined, diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index a60d610cac3..73978e4f339 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index ee61749fc69..5882084e2d8 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -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(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 }) }