diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1ba85b677..2d534757a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -165,6 +165,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess, @@ -1497,11 +1498,13 @@ export function connectPanePty( (entry) => entry.id === deps.tabId ) return ( - tab?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - commandInferredPaneAgent ?? - state.agentStatusByPaneKey[cacheKey]?.agentType + resolvePaneAgentOwner({ + launchAgent: tab?.launchAgent, + startupLaunchAgent: paneStartup?.launchAgent, + initialStatusAgent: paneStartup?.initialAgentStatus?.agent, + commandInferredAgent: commandInferredPaneAgent, + hookAgent: state.agentStatusByPaneKey[cacheKey]?.agentType + }) ?? undefined ) } // Why: the renderer veto (owner evidence beating a Gemini-looking title) must diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts new file mode 100644 index 000000000..abe494ee1 --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { resolveTabAgentFromSignals } from './use-tab-agent' + +// Pi/OMP share a title-identity group: OMP wraps Pi and emits Pi-compatible +// wrapper title frames. These tests pin how the tab-icon resolver keeps an +// OMP-owned pane on OMP (and a Pi-owned pane on Pi) as those frames arrive, +// including when the pane loses its host-owned launchAgent on a mirrored or +// restored client. +describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { + it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal 1', + hookAgent: null, + siblingHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ OMP', + hookAgent: 'omp', + launchAgent: 'pi' + }) + ).toBe('pi') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'zsh', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + siblingCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + }) + + it('keeps a restored/mirrored OMP pane on OMP when launchAgent is gone', () => { + // Why: a mirrored or restored OMP pane loses its host-owned launchAgent but + // keeps emitting Pi-compatible wrapper title frames. Durable pane identity + // (last completed hook / hibernated session) must anchor those frames to OMP + // instead of letting them repaint the tab as Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + sleepingSessionAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('does not flap between OMP and Pi as a launchAgent-less pane cycles hooks', () => { + // The flicker: identity must not flip when the live hook row appears/clears. + const withLiveHook = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'omp', + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + const afterHookCleared = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + expect(withLiveHook).toBe('omp') + expect(afterHookCleared).toBe('omp') + }) + + it('keeps a launchAgent-less Pi pane on Pi and rejects a stale OMP session record', () => { + // The fallback must not over-reach: a genuine Pi pane (recent Pi hook) stays + // Pi even if a stale hibernated OMP record is present. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + sleepingSessionAgent: 'omp', + launchAgent: undefined + }) + ).toBe('pi') + + // An OMP-compatible title on a launchAgent-less Pi pane still resolves to Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ OMP', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: undefined + }) + ).toBe('pi') + }) +}) + +// The tab icon is a pane's IDENTITY, not its activity state: a hook record +// identifies the pane whether the agent is mid-turn (live) or idle (done). These +// pin that separation so identity can't collapse back into the (non- +// distinguishing) title layer. +describe('resolveTabAgentFromSignals — identity vs liveness', () => { + it('surfaces the focused idle identity from the record, not the title', () => { + // Agent went idle between turns; the title names no agent. Identity still + // comes from the pane's own done-hook record. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('ranks the focused idle identity above a hibernated session and launch bootstrap', () => { + // The agent that actually ran and idled here beats both a hibernation record + // and stale launch intent. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + sleepingSessionAgent: 'claude', + launchAgent: 'codex' + }) + ).toBe('omp') + }) + + it('never lets a title override a live hook (ground truth)', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Claude Code', + hookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('lets a different-group title reclaim a reused idle pane without launch metadata', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Claude Code', + hookAgent: null, + focusedCompletedHookAgent: 'codex', + launchAgent: undefined + }) + ).toBe('claude') + }) + + it('keeps a launchAgent-less pane with a live Pi hook stable on Pi', () => { + // A launchless pane whose live hook reports Pi resolves to Pi and stays Pi + // when the hook clears (the completed record is Pi too) — no flip to OMP. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: undefined + }) + ).toBe('pi') + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: undefined + }) + ).toBe('pi') + }) + + it('keeps a sibling idle identity when the focused pane returns to its shell', () => { + // Focused pane's local shell-exit evidence must not clear the sibling's idle + // identity — the sibling agent is still there. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + siblingCompletedHookAgent: 'gemini', + launchAgent: undefined + }) + ).toBe('gemini') + }) + + it('does not let a sibling pane re-own the focused pane ambiguous Pi title', () => { + // A split-pane sibling running OMP says nothing about which Pi-variant the + // focused pane runs; the focused pane's own Pi title must stay Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + focusedCompletedHookAgent: null, + siblingCompletedHookAgent: 'omp', + launchAgent: undefined + }) + ).toBe('pi') + }) + + it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => { + // hasObservedAgentSignal starts false for one mount commit; a completed hook + // is itself activity evidence, so the reuse title reclaims immediately + // instead of flashing the prior agent's idle identity. (claude ran+idled, + // then a hookless codex reused the pane and emits its own title.) + const onMount = resolveTabAgentFromSignals({ + hasObservedAgentSignal: false, + isRemote: false, + title: '⠋ Codex', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + launchAgent: undefined + }) + const afterObserved = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ Codex', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + launchAgent: undefined + }) + expect(onMount).toBe('codex') + expect(afterObserved).toBe('codex') + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index d4d6031b2..9152159f1 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -271,80 +271,8 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => { - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: '⠋ Pi', - hookAgent: null, - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: '⠋ Pi', - hookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: false, - title: '⠋ Pi', - hookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: 'Terminal 1', - hookAgent: null, - siblingHookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: '⠋ OMP', - hookAgent: 'omp', - launchAgent: 'pi' - }) - ).toBe('pi') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: true, - title: 'zsh', - hookAgent: null, - focusedCompletedHookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - - expect( - resolveTabAgentFromSignals({ - hasObservedAgentSignal: true, - isRemote: false, - title: 'zsh', - hookAgent: null, - siblingCompletedHookAgent: 'pi', - launchAgent: 'omp' - }) - ).toBe('omp') - }) + // Pi/OMP identity (shared title-identity group, launchAgent-loss flicker) + // lives in use-tab-agent-pi-identity.test.ts. it('prefers explicit hook identity over a conflicting title mention', () => { expect( diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 504204971..93051d41d 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -12,6 +12,7 @@ import { } from './tab-agent' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' import type { TerminalTab, TuiAgent } from '../../../shared/types' // A shell name, or the tab's neutral default title — where Orca's @@ -83,50 +84,85 @@ export function resolveTabAgentFromSignals(args: { launchAgent?: TuiAgent }): TuiAgent | null { const launchAgent = args.launchAgent ?? null - const explicitTitleAgent = resolveSignalAgentForLaunchOwner( - resolveExplicitTerminalTitleAgentType(args.title), - launchAgent - ) - // Why: explicit titles can override stale launches after activity, but - // Pi-compatible wrapper signals first resolve through the launch owner so - // OMP-created sessions do not repaint as Pi. - // Why: OSC 133;D proved this local pane's foreground is back at the shell, - // so any title-derived identity is stale by definition — a TUI that died - // with a stuck title must not keep painting the tab through the title layer. + // The focused pane's durable agent owner: launch intent first, then this + // pane's own host-stamped hook identity (live or completed), then its + // hibernated session record. It anchors every identity decision for THIS pane + // — it re-owns ambiguous Pi-compatible wrapper titles (OMP emits Pi's frames) + // and keeps a mirrored or restored pane, which dropped its host-owned + // launchAgent, resolving through the durable record instead of the title. + // Why: strictly focused-pane-scoped — a sibling split-pane's identity says + // nothing about which Pi-variant this pane runs, so it must not re-own the + // focused title (it would mislabel a genuine Pi pane as its sibling's OMP). + const owner = resolvePaneAgentOwner({ + launchAgent, + hookAgent: args.hookAgent, + completedHookAgent: args.focusedCompletedHookAgent, + sleepingSessionAgent: args.sleepingSessionAgent + }) as TuiAgent | null + + // A pane's identity comes from its hook record regardless of activity state — + // being idle between turns must not erase which agent is here. We keep the + // live/idle split only because it governs how a title may override identity: a + // LIVE hook is ground truth (a title never overrides it), while an IDLE record + // is durable but a cross-group title can reclaim a pane reused for a different + // agent. Sibling identities normalize against launchAgent only (the tab's + // shared launch intent), never the focused pane's own hook identity. + const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner) + const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) + // Why: OSC 133;D proved this local pane's foreground is back at the shell, so + // the finished agent's idle identity is stale and must stop painting the tab. + // Remote titles lag their runtime, so keep the idle identity there. const processProvesShell = !args.isRemote && args.processShellForeground === true - const titleOverridesLaunch = - launchAgent !== null && - explicitTitleAgent !== null && - explicitTitleAgent !== launchAgent && - args.hasObservedAgentSignal - const titleAgent = processProvesShell - ? null - : titleOverridesLaunch - ? explicitTitleAgent - : launchAgent - ? null - : explicitTitleAgent 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 = + const idleIdentitySuppressed = !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook - ? null - : resolveSignalAgentForLaunchOwner( - args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent, - launchAgent - ) - const focusedHookAgent = resolveSignalAgentForLaunchOwner(args.hookAgent, launchAgent) - const siblingHookAgent = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) - const fallbackHookAgent = siblingHookAgent ?? completedHookAgent + const idleFocusedIdentity = idleIdentitySuppressed + ? null + : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner) + // Why: `idleIdentitySuppressed` is the FOCUSED pane's own exit evidence, so it + // must not clear a sibling split-pane's idle identity — a focused pane back at + // its shell says nothing about whether the sibling's agent has exited. + const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( + args.siblingCompletedHookAgent, + launchAgent + ) + + // The title carries identity in only two roles: (a) a reuse override — it + // names a DIFFERENT-group agent than the pane's known identity, proving the + // pane was reused for a new agent — or (b) a legacy standalone identity when + // the pane has no hook at all. Within the same title-identity group it says + // nothing (OMP wraps Pi and emits identical frames), so the durable record + // wins; re-owning explicitTitleAgent through `owner` enforces that. + const explicitTitleAgent = resolveSignalAgentForLaunchOwner( + resolveExplicitTerminalTitleAgentType(args.title), + owner + ) + const priorIdentity = idleFocusedIdentity ?? launchAgent + // Why: a completed hook is itself proof the pane has shown activity, so it + // arms the reuse override without waiting for `hasObservedAgentSignal` — which + // starts false for one mount commit and would otherwise flash the exited + // agent's idle identity before the new (hookless) agent's title reclaims. + const titleReclaimsReusedPane = + priorIdentity !== null && + explicitTitleAgent !== null && + explicitTitleAgent !== priorIdentity && + (args.hasObservedAgentSignal || hasCompletedHook) + const titleAgent = processProvesShell + ? null + : titleReclaimsReusedPane + ? explicitTitleAgent + : priorIdentity + ? null + : explicitTitleAgent + const launchedAgentExited = resolveLaunchedAgentExitEvidence({ title: args.title, defaultTitle: args.defaultTitle, isRemote: args.isRemote, hasObservedAgentSignal: args.hasObservedAgentSignal, - hookAgent: focusedHookAgent, - siblingHookAgent, + hookAgent: liveFocusedIdentity, + siblingHookAgent: liveSiblingIdentity, hasCompletedHook, processAgent: args.processAgent, processShellForeground: args.processShellForeground @@ -134,53 +170,48 @@ export function resolveTabAgentFromSignals(args: { const activeLaunchAgent = launchedAgentExited ? null : launchAgent const processAgent = args.processAgent ?? null const sleepingSessionAgent = args.sleepingSessionAgent ?? null - // 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. Process identity (the recognized foreground - // process) is ground truth below hooks — it covers agents that emit neither - // hooks nor titles. sleepingSessionAgent ranks above launch bootstrap: a - // hibernated pane's captured session identity is pane-scoped proof that the - // launch identity went stale (a codex launch later reused for claude), and it - // is the only live signal while the pane is asleep (its PTY, hooks, and - // process are all gone). + // Identity-first precedence. The live focused hook is ground truth while the + // agent works; process identity covers agents with neither hook nor title; the + // title then acts only in its reuse-override / legacy roles; and the pane's + // durable idle identity — the record for an agent that ran here and went idle — + // ranks above the hibernated session, the launch bootstrap, and sibling panes. return ( - focusedHookAgent ?? + liveFocusedIdentity ?? processAgent ?? titleAgent ?? + idleFocusedIdentity ?? sleepingSessionAgent ?? activeLaunchAgent ?? - fallbackHookAgent + liveSiblingIdentity ?? + idleSiblingIdentity ) } /** * Resolve which coding-harness agent a terminal tab is running, for its tab-bar * icon. Identity flows through the same already-computed state as the sidebar - * agent rows — no foreground probing. Layered signals, most-authoritative - * first: + * agent rows — no foreground probing. It is a pane's IDENTITY, kept separate + * from its activity state: a hook record identifies the pane whether the agent + * is working or idle. Identity-first precedence: * - * 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. + * 1. Live focused hook — provider identity from native integrations while the + * agent is actively working; ground truth, never overridden by a title. * 2. Process identity — the recognized foreground process, read at OSC 133 * command boundaries (local panes only); covers agents that emit neither - * hooks nor titles, and its shell-foreground mark is title-independent - * exit evidence. - * 3. Sleeping session identity — a hibernated pane's captured session record - * (agent + provider session). Ranks below title but above launchAgent - * because it is pane-scoped proof of reuse: a codex launch later reused for - * claude leaves a claude sleeping record, and while the pane is asleep this - * is the only live identity signal (its PTY, hooks, and process are gone). - * 4. launchAgent — what Orca launched here; instant bootstrap before hooks - * arrive, cleared once hook/process/title evidence shows the launched - * agent exited. - * 5. 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 - * titles never identify an agent. + * hooks nor titles, and its shell-foreground mark is title-independent exit + * evidence. + * 3. Title — only as a reuse override (it names a DIFFERENT-group agent than the + * pane's known identity, proving reuse) or as a legacy standalone identity + * when the pane has no hook. Within the same title-identity group it carries + * no identity (OMP wraps Pi with identical frames), so the record wins. + * 4. Idle focused identity — the pane's own hook record after the agent went + * idle between turns; the durable answer to "which agent is this" once the + * live hook, process, and any reuse-title are absent. Suppressed on a local + * pane once OSC 133;D proves the agent exited. + * 5. Sleeping session identity — a hibernated pane's captured session record. + * 6. launchAgent — what Orca launched here; the bootstrap before any hook, hook + * record, or process signal exists, cleared once exit evidence shows it left. + * 7. Sibling-pane identity (live, then idle) — split-tab fallback. */ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const focusedHookAgent = useAppStore((s) => diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 47942c674..e28072d53 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -48,6 +48,7 @@ import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent' @@ -542,10 +543,12 @@ function buildMirroredTerminalTabs( .filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0) const launchAgent = activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent - const ownerAgent = - launchAgent ?? - activeSurface.agentStatus?.agentType ?? - surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus?.agentType + const ownerAgent = resolvePaneAgentOwner({ + launchAgent, + hookAgent: activeSurface.agentStatus?.agentType, + siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus + ?.agentType + }) const title = normalizeCompatibleAgentTitleForOwner( activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal', ownerAgent @@ -624,7 +627,10 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null if (!paneKey) { return null } - const ownerAgent = surface.launchAgent ?? surface.agentStatus.agentType + const ownerAgent = resolvePaneAgentOwner({ + launchAgent: surface.launchAgent, + hookAgent: surface.agentStatus.agentType + }) return { ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent), paneKey diff --git a/src/shared/pane-agent-owner.test.ts b/src/shared/pane-agent-owner.test.ts new file mode 100644 index 000000000..1d57f0b79 --- /dev/null +++ b/src/shared/pane-agent-owner.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { resolvePaneAgentOwner } from './pane-agent-owner' + +describe('resolvePaneAgentOwner', () => { + it('leads with launch intent', () => { + expect( + resolvePaneAgentOwner({ launchAgent: 'omp', hookAgent: 'pi', sleepingSessionAgent: 'claude' }) + ).toBe('omp') + expect(resolvePaneAgentOwner({ startupLaunchAgent: 'codex', hookAgent: 'claude' })).toBe( + 'codex' + ) + expect( + resolvePaneAgentOwner({ initialStatusAgent: 'gemini', commandInferredAgent: 'codex' }) + ).toBe('gemini') + }) + + it('falls through to the durable host-stamped hook identity when launch intent is gone', () => { + // The mirror/restore case: launchAgent dropped, live hook is the anchor. + expect(resolvePaneAgentOwner({ hookAgent: 'omp' })).toBe('omp') + // Live hook cleared: the last completed hook carries the identity. + expect(resolvePaneAgentOwner({ completedHookAgent: 'omp' })).toBe('omp') + // Nothing live at all: the hibernated session record is the last resort. + expect(resolvePaneAgentOwner({ sleepingSessionAgent: 'omp' })).toBe('omp') + }) + + it('ranks live/recent evidence above the hibernated record so a stale record cannot hijack', () => { + expect(resolvePaneAgentOwner({ hookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe('pi') + expect(resolvePaneAgentOwner({ completedHookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe( + 'pi' + ) + }) + + it('prefers focused over sibling evidence at each tier', () => { + expect(resolvePaneAgentOwner({ hookAgent: 'omp', siblingHookAgent: 'pi' })).toBe('omp') + expect( + resolvePaneAgentOwner({ completedHookAgent: 'omp', siblingCompletedHookAgent: 'pi' }) + ).toBe('omp') + }) + + it('returns null when no owner evidence exists', () => { + expect(resolvePaneAgentOwner({})).toBeNull() + expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull() + }) +}) diff --git a/src/shared/pane-agent-owner.ts b/src/shared/pane-agent-owner.ts new file mode 100644 index 000000000..65659a55e --- /dev/null +++ b/src/shared/pane-agent-owner.ts @@ -0,0 +1,58 @@ +import type { AgentType } from './agent-status-types' + +/** + * The owner-evidence signals a terminal pane can carry, strongest launch intent + * first, ending in the durable host-stamped identity. Every field is optional so + * each call site passes only what it holds; absent signals are skipped. + */ +export type PaneAgentOwnerSignals = { + /** Tab-scoped launch intent — what Orca launched into this tab. */ + launchAgent?: AgentType | null + /** Never-cleared per-connection launch seed (pane connection only). */ + startupLaunchAgent?: AgentType | null + /** Startup-provided initial agent status (pane connection only). */ + initialStatusAgent?: AgentType | null + /** Agent inferred from a manually typed shell command (pane connection only). */ + commandInferredAgent?: AgentType | null + /** Live focused-pane hook identity — host-stamped, published, mirror-safe. */ + hookAgent?: AgentType | null + /** Live sibling-pane hook identity. */ + siblingHookAgent?: AgentType | null + /** Last completed focused-pane hook — survives the live hook row clearing. */ + completedHookAgent?: AgentType | null + /** Last completed sibling-pane hook. */ + siblingCompletedHookAgent?: AgentType | null + /** Hibernated session record — the final durable identity while a pane sleeps. */ + sleepingSessionAgent?: AgentType | null +} + +/** + * The single authoritative resolver for "which agent owns this pane", shared by + * the tab-icon resolver, the terminal-pane display/renderer owner, and the + * mirrored-tab title owner so they cannot drift apart. + * + * Why this precedence: launch intent is the authoritative bootstrap before any + * process signal exists, so it leads. Once launch metadata is gone — a mirrored + * or restored pane drops the host-owned launchAgent — the owner must fall + * through to a durable pane identity rather than to the raw title, because a + * wrapper agent's title (OMP emits Pi-compatible frames) cannot be told apart + * from the agent it wraps. The host-stamped hook identity is that durable, + * published, mirror-safe anchor; the last completed hook and the hibernated + * session record carry it across the windows where no live hook exists. Ranking + * launch/live-hook above the completed/sleeping records keeps a genuine pane on + * its real agent and stops a stale record from hijacking it. + */ +export function resolvePaneAgentOwner(signals: PaneAgentOwnerSignals): AgentType | null { + return ( + signals.launchAgent ?? + signals.startupLaunchAgent ?? + signals.initialStatusAgent ?? + signals.commandInferredAgent ?? + signals.hookAgent ?? + signals.siblingHookAgent ?? + signals.completedHookAgent ?? + signals.siblingCompletedHookAgent ?? + signals.sleepingSessionAgent ?? + null + ) +}