diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 71bcbf96534..f5d7ef7eb01 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -30,6 +30,34 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] : [] const ownerEnv = kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + // Why: OMP suppresses its approval lifecycle unless an extension listens for it, + // and it is the only signal that the run is parked on a permission prompt rather + // than still working. Prime has no OMP runtime, so the handlers would be dead there. + const approvalHandlers = + kind === 'prime-agent' + ? [] + : [ + ` pi.on('tool_approval_requested', (event${ctxParam}) => {`, + ...captureSessionMetadata, + ' if (!isOmpRuntime()) return', + " post('tool_approval_requested', {", + ' tool_name: event.toolName,', + ' reason: event.reason,', + ' approval_mode: event.approvalMode,', + ' })', + ' })', + '', + ` pi.on('tool_approval_resolved', (event${ctxParam}) => {`, + ...captureSessionMetadata, + ' if (!isOmpRuntime()) return', + " post('tool_approval_resolved', {", + ' tool_name: event.toolName,', + ' approved: event.approved,', + ' })', + ' })', + '' + ] + return [ '// Why: pi assistant messages carry content as an array of parts', "// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only", @@ -102,6 +130,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', ' })', '', + ...approvalHandlers, " // Why: capture the assistant's final text on each completed message", ' // so the dashboard preview reflects the most recent reply even before', ' // agent_end fires. message_end is the right hook because pi guarantees', diff --git a/src/main/pi/agent-status-omp-approval-forwarding.test.ts b/src/main/pi/agent-status-omp-approval-forwarding.test.ts new file mode 100644 index 00000000000..8f353b6895e --- /dev/null +++ b/src/main/pi/agent-status-omp-approval-forwarding.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' + +const APPROVAL_REQUEST = { + toolName: 'bash', + reason: 'tools.approval.bash: prompt', + approvalMode: 'prompt' +} + +describe('OMP approval forwarding', () => { + it.each([ + ['configured OMP', { kind: 'omp' as const }], + ['title-routed OMP', { kind: 'pi' as const, title: 'omp' }], + ['argv-routed OMP', { kind: 'pi' as const, argv: ['node', '/usr/local/bin/omp'] }] + ])('posts tool_approval_requested and resolved from %s', async (_name, args) => { + const harness = createAgentStatusExtensionHarness(args) + + expect(harness.handlers.tool_approval_requested).toBeTypeOf('function') + expect(harness.handlers.tool_approval_resolved).toBeTypeOf('function') + + await harness.callHook('tool_approval_requested', APPROVAL_REQUEST) + await harness.callHook('tool_approval_resolved', { toolName: 'bash', approved: true }) + + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2)) + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:4321/hook/omp') + expect( + harness.fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).payload) + ).toEqual([ + { + hook_event_name: 'tool_approval_requested', + tool_name: 'bash', + reason: 'tools.approval.bash: prompt', + approval_mode: 'prompt' + }, + { + hook_event_name: 'tool_approval_resolved', + tool_name: 'bash', + approved: true + } + ]) + }) + + it('does not post OMP approval events from a genuine Pi process', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + + expect(harness.handlers.tool_approval_requested).toBeTypeOf('function') + await harness.callHook('tool_approval_requested', APPROVAL_REQUEST) + expect(harness.fetchMock).not.toHaveBeenCalled() + }) + + it('does not register OMP approval handlers on Prime', () => { + const harness = createAgentStatusExtensionHarness({ kind: 'prime-agent' }) + + expect(harness.handlers.tool_approval_requested).toBeUndefined() + expect(harness.handlers.tool_approval_resolved).toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c41a1d5be69..8ed38c6faa4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -136,7 +136,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../shared/agent-title-owner' -import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner' +import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner' import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk @@ -8566,7 +8566,8 @@ export class OrcaRuntimeService { const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - ownerAgent + ownerAgent, + { ownerIsLaunch: Boolean(pty.launchAgent) } ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => @@ -36360,31 +36361,32 @@ export class OrcaRuntimeService { const launchAgent = tab.launchAgent ?? null const launchOwnerAgent = launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent: launchOwnerAgent, + hookAgent: + tab.agentStatus?.agentType ?? + hookAgentStatus?.agentType ?? + retainedAgentStatus?.payload.agentType ?? + null + }) const ownerAgent = - resolvePaneAgentOwner({ - launchAgent: launchOwnerAgent, - hookAgent: - tab.agentStatus?.agentType ?? - hookAgentStatus?.agentType ?? - retainedAgentStatus?.payload.agentType ?? - null - }) ?? - liveLeafPty?.foregroundAgent ?? - pty?.foregroundAgent ?? - null + ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null + const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } const title = normalizeCompatibleAgentTitleForOwner( trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, - ownerAgent + ownerAgent, + ownerOptions ) const liveTitleEvidence = leafTitle ?? ptyTitle // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. const rendererStatusAgent = - resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ?? + resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent, ownerOptions) ?? ownerAgent ?? undefined const hookSessionAgent = resolveCompatibleAgentTypeForOwner( hookAgentStatus?.providerSessionAgentType, - ownerAgent + ownerAgent, + ownerOptions ) const hookSessionMatchesRenderer = !rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent @@ -36403,7 +36405,8 @@ export class OrcaRuntimeService { ...tab.agentStatus, ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) }, - ownerAgent + ownerAgent, + ownerOptions ) : null, statusPty, @@ -36708,16 +36711,16 @@ export class OrcaRuntimeService { } } // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. - const ownerAgent = - resolvePaneAgentOwner({ - launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, - hookAgent: retained?.payload.agentType ?? hookRow.agentType - }) ?? - pty?.foregroundAgent ?? - null + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, + hookAgent: retained?.payload.agentType ?? hookRow.agentType + }) + const ownerAgent = ownerRecord?.agent ?? pty?.foregroundAgent ?? null + const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } const terminalTitle = normalizeCompatibleAgentTitleForOwner( trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, - ownerAgent + ownerAgent, + ownerOptions ) // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) @@ -36737,7 +36740,8 @@ export class OrcaRuntimeService { terminalTitle, ...providerSession }, - ownerAgent + ownerAgent, + ownerOptions ) // A live question outranks only the shell title that currently obscures it. const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, { diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts b/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts new file mode 100644 index 00000000000..eba9957d9c9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts @@ -0,0 +1,49 @@ +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext +} from '../../../../shared/agent-status-types' + +function orchestrationContextsEqual( + a: AgentStatusOrchestrationContext, + b: AgentStatusOrchestrationContext +): boolean { + return ( + a.taskId === b.taskId && + a.dispatchId === b.dispatchId && + a.taskTitle === b.taskTitle && + a.displayName === b.displayName && + a.parentTerminalHandle === b.parentTerminalHandle && + a.parentPaneKey === b.parentPaneKey && + a.coordinatorHandle === b.coordinatorHandle && + a.orchestrationRunId === b.orchestrationRunId + ) +} + +export function entryWithRuntimeOrchestration( + entry: AgentStatusEntry, + runtimeAgentOrchestrationByPaneKey: Record | undefined +): AgentStatusEntry { + const runtimeOrchestration = runtimeAgentOrchestrationByPaneKey?.[entry.paneKey] + const sameDispatch = + entry.orchestration && + runtimeOrchestration && + entry.orchestration.taskId === runtimeOrchestration.taskId && + entry.orchestration.dispatchId === runtimeOrchestration.dispatchId + if (entry.orchestration && runtimeOrchestration && !sameDispatch) { + return entry + } + const orchestration = + sameDispatch && entry.orchestration && runtimeOrchestration + ? { ...entry.orchestration, ...runtimeOrchestration } + : (runtimeOrchestration ?? entry.orchestration) + if (!orchestration || orchestration === entry.orchestration) { + return entry + } + if (entry.orchestration && orchestrationContextsEqual(entry.orchestration, orchestration)) { + return entry + } + // Why: runtime graph metadata can arrive after a hook status ping. Keep old + // fields only for the same dispatch; a reused terminal must not inherit a + // previous worker's stale parent. + return { ...entry, orchestration } +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-type.ts b/src/renderer/src/components/sidebar/worktree-agent-row-type.ts new file mode 100644 index 00000000000..46dc5ca821f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-row-type.ts @@ -0,0 +1,30 @@ +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { resolveAgentTypeFromTerminalTitle } from './worktree-title-derived-agent-rows' + +/** + * Resolves the sidebar row agent type, prioritizing launch agent configuration + * and normalizing compatible agent kinds. + */ +export function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType { + const launchOwner = { ownerIsLaunch: Boolean(tab?.launchAgent) } + const entryAgentType = resolveCompatibleAgentTypeForOwner( + entry.agentType, + tab?.launchAgent, + launchOwner + ) + if (entryAgentType && entryAgentType !== 'unknown') { + return entryAgentType + } + return ( + resolveAgentTypeFromTerminalTitle( + entry.terminalTitle ?? tab?.title, + tab?.launchAgent, + launchOwner + ) ?? + tab?.launchAgent ?? + entryAgentType ?? + 'unknown' + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 1fbca0fc743..b6c9d4d4e04 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -3,7 +3,6 @@ import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { AGENT_STATUS_STALE_AFTER_MS, - type AgentType, type AgentStatusEntry, type AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' @@ -18,79 +17,15 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' -import { - buildTitleDerivedAgentRows, - resolveAgentTypeFromTerminalTitle -} from './worktree-title-derived-agent-rows' +import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows' import { buildSubagentChildRows } from './worktree-subagent-child-rows' -import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' import { compareWorktreeAgentRows } from './worktree-agent-row-order' import { effectiveWorktreeAgentRowStartedAt, tabFromWorktreeAttributedStatusEntry } from './worktree-agent-row-fallback-tab' - -/** - * Resolves the sidebar row agent type, prioritizing launch agent configuration - * and normalizing compatible agent kinds. - */ -function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType { - const entryAgentType = resolveCompatibleAgentTypeForOwner(entry.agentType, tab?.launchAgent) - if (entryAgentType && entryAgentType !== 'unknown') { - return entryAgentType - } - return ( - resolveAgentTypeFromTerminalTitle(entry.terminalTitle ?? tab?.title, tab?.launchAgent) ?? - tab?.launchAgent ?? - entryAgentType ?? - 'unknown' - ) -} - -function orchestrationContextsEqual( - a: AgentStatusOrchestrationContext, - b: AgentStatusOrchestrationContext -): boolean { - return ( - a.taskId === b.taskId && - a.dispatchId === b.dispatchId && - a.taskTitle === b.taskTitle && - a.displayName === b.displayName && - a.parentTerminalHandle === b.parentTerminalHandle && - a.parentPaneKey === b.parentPaneKey && - a.coordinatorHandle === b.coordinatorHandle && - a.orchestrationRunId === b.orchestrationRunId - ) -} - -function entryWithRuntimeOrchestration( - entry: AgentStatusEntry, - runtimeAgentOrchestrationByPaneKey: Record | undefined -): AgentStatusEntry { - const runtimeOrchestration = runtimeAgentOrchestrationByPaneKey?.[entry.paneKey] - const sameDispatch = - entry.orchestration && - runtimeOrchestration && - entry.orchestration.taskId === runtimeOrchestration.taskId && - entry.orchestration.dispatchId === runtimeOrchestration.dispatchId - if (entry.orchestration && runtimeOrchestration && !sameDispatch) { - return entry - } - const orchestration = - sameDispatch && entry.orchestration && runtimeOrchestration - ? { ...entry.orchestration, ...runtimeOrchestration } - : (runtimeOrchestration ?? entry.orchestration) - if (!orchestration || orchestration === entry.orchestration) { - return entry - } - if (entry.orchestration && orchestrationContextsEqual(entry.orchestration, orchestration)) { - return entry - } - // Why: runtime graph metadata can arrive after a hook status ping. Keep old - // fields only for the same dispatch; a reused terminal must not inherit a - // previous worker's stale parent. - return { ...entry, orchestration } -} +import { resolveRowAgentType } from './worktree-agent-row-type' +import { entryWithRuntimeOrchestration } from './worktree-agent-row-orchestration' function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { if (!node) { diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 3d58d67e425..25d87c50801 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -17,7 +17,8 @@ import type { } from '../../../../shared/terminal-tab-types' import { normalizeCompatibleAgentTitleForOwner, - resolveCompatibleAgentTypeForOwner + resolveCompatibleAgentTypeForOwner, + type CompatibleAgentOwnerOptions } from '../../../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' @@ -141,7 +142,9 @@ function buildTitleDerivedAgentRow(args: { // Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity // group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership // is a separate, stricter question — it decides identity, so it uses ownerAgentType below. - const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent) + const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent, { + ownerIsLaunch: Boolean(args.tab.launchAgent) + }) const isClaudeAgentsTitle = isClaudeManagementTitle(title) // Why: `claude agents` is a live Claude Code Agent Teams surface, but the // shared detector keeps it neutral so runtime liveness probes do not treat @@ -259,17 +262,19 @@ function resolveTitleDerivedPaneOwner( */ export function resolveAgentTypeFromTerminalTitle( title: string | null | undefined, - ownerAgentType?: AgentType | null + ownerAgentType?: AgentType | null, + options?: CompatibleAgentOwnerOptions ): AgentType | null { if (!title) { return null } - const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) + const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, options) const label = resolveTitleActivityLabel(normalizedTitle) return label ? (resolveCompatibleAgentTypeForOwner( resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType), - ownerAgentType + ownerAgentType, + options ) ?? null) : null } diff --git a/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts index 960c354f509..7ac9730f1de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts @@ -9,6 +9,10 @@ describe('resolvePaneDisplayTitle', () => { it('passes an unowned title through unchanged', () => { expect(resolvePaneDisplayTitle('bash', undefined)).toBe('bash') }) + + it('rewrites an OMP wrapper title through explicit launch Pi ownership', () => { + expect(resolvePaneDisplayTitle('\u280b OMP', 'pi', true)).toBe('\u280b Pi') + }) }) describe('resolvePaneTitleDecision', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts b/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts index f89eb6bbeb5..7f441f1c1e0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts @@ -12,9 +12,10 @@ import { */ export function resolvePaneDisplayTitle( title: string, - ownerAgentType: AgentType | null | undefined + ownerAgentType: AgentType | null | undefined, + ownerIsLaunch = false ): string { - return normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) + return normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, { ownerIsLaunch }) } /** @@ -35,6 +36,8 @@ export type ResolvePaneTitleDecisionInput = { /** Owner used for the display label — may include sticky/tab-scoped launch * identity, which is correct for the visible label. */ displayOwnerAgentType: AgentType | null | undefined + /** True when displayOwnerAgentType is user-selected launch ownership. */ + displayOwnerIsLaunch?: boolean /** Owner used for the renderer veto — must be pane-scoped and current so a * sibling/reused pane's launch identity cannot keep GPU for a genuine * Gemini pane. */ @@ -45,7 +48,11 @@ export type ResolvePaneTitleDecisionInput = { } export function resolvePaneTitleDecision(input: ResolvePaneTitleDecisionInput): PaneTitleDecision { - const displayTitle = resolvePaneDisplayTitle(input.normalizedTitle, input.displayOwnerAgentType) + const displayTitle = resolvePaneDisplayTitle( + input.normalizedTitle, + input.displayOwnerAgentType, + input.displayOwnerIsLaunch === true + ) const rendererPolicy = resolvePaneRendererPolicy({ rawTitle: input.rawTitle, ownerAgentType: input.rendererOwnerAgentType, diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index 29b4ace2c1f..483ba4c792e 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -5,7 +5,7 @@ import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' import { showBlockedNotificationFallbackToast } from '@/lib/blocked-notification-fallback' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' -import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { shareCompatibleTitleIdentityGroup } from '../../../../shared/agent-title-owner' import { isFreshNonDoneAgentStatus, type AgentStatusEntry @@ -40,15 +40,13 @@ function hasFreshActiveHookStatus( snapshot: Pick | undefined, explicitTitleAgentType: string | null ): boolean { - const activeHookAgentForTitle = resolveCompatibleAgentTypeForOwner( - snapshot?.agentType, - explicitTitleAgentType - ) + // Why: pick-a-winner ownership would treat a Pi idle title as a different + // agent than a live OMP hook. Same-group titles are wrapper frames, not reuse. const titleNamesDifferentKnownAgent = explicitTitleAgentType && snapshot?.agentType && snapshot.agentType !== 'unknown' && - activeHookAgentForTitle !== explicitTitleAgentType + !shareCompatibleTitleIdentityGroup(snapshot.agentType, explicitTitleAgentType) return Boolean(isFreshNonDoneAgentStatus(snapshot) && !titleNamesDifferentKnownAgent) } diff --git a/src/renderer/src/lib/open-tab-occupant-agent.ts b/src/renderer/src/lib/open-tab-occupant-agent.ts index 40d4f0df74e..5f99e0f7505 100644 --- a/src/renderer/src/lib/open-tab-occupant-agent.ts +++ b/src/renderer/src/lib/open-tab-occupant-agent.ts @@ -14,7 +14,7 @@ import { resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' -import { resolveTabAgentFromSignals } from './use-tab-agent' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' export type OpenTabOccupantAgentInput = { tabId: string diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts new file mode 100644 index 00000000000..d4f5403b379 --- /dev/null +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -0,0 +1,171 @@ +import { isShellProcess } from '../../../shared/agent-detection' +import { + isClaudeIdentityFrameTitle, + resolveExplicitTerminalTitleAgentType +} from '../../../shared/terminal-title-agent-type' +import { + resolveCompatibleAgentTypeForOwner, + shareCompatibleTitleIdentityGroup +} from '../../../shared/agent-title-owner' +import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' +import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner' +import type { TuiAgent } from '../../../shared/tui-agent' + +// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence. +function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { + const trimmed = title.trim() + return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) +} + +/** + * Resolves wrapper-compatible signal identity against the pane owner. + */ +function resolveSignalAgentForLaunchOwner( + signalAgent: TuiAgent | null | undefined, + ownerAgent: TuiAgent | null, + ownerIsLaunch = false +): TuiAgent | null { + if (!signalAgent) { + return null + } + return (resolveCompatibleAgentTypeForOwner(signalAgent, ownerAgent, { ownerIsLaunch }) ?? + signalAgent) as TuiAgent +} + +/** + * Probe-free evidence a launched agent exited: title shows no agent, no live + * hook remains, and either the hook completed or observed activity vanished. + * Vanished-activity is local-only — remote rows also drop on transport blips. + */ +export function resolveLaunchedAgentExitEvidence(args: { + title: string + defaultTitle?: string + isRemote: boolean + hasObservedAgentSignal: boolean + hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null + hasCompletedHook: boolean + processAgent?: TuiAgent | null + processShellForeground?: boolean +}): boolean { + if (args.hookAgent || args.siblingHookAgent || args.processAgent) { + return false + } + // Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer. + if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) { + return true + } + if (!titleShowsNoAgent(args.title, args.defaultTitle)) { + return false + } + return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) +} + +/** + * Identity-first precedence: live hook > process > title > completed > sleeping + * > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence. + */ +export function resolveTabAgentFromSignals(args: { + hasObservedAgentSignal: boolean + isRemote: boolean + title: string + defaultTitle?: string + hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null + focusedCompletedHookAgent?: TuiAgent | null + siblingCompletedHookAgent?: TuiAgent | null + processAgent?: TuiAgent | null + processShellForeground?: boolean + sleepingSessionAgent?: TuiAgent | null + launchAgent?: TuiAgent +}): TuiAgent | null { + const launchAgent = args.launchAgent ?? null + // Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP). + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent, + hookAgent: args.hookAgent, + completedHookAgent: args.focusedCompletedHookAgent, + sleepingSessionAgent: args.sleepingSessionAgent + }) + const owner = (ownerRecord?.agent ?? null) as TuiAgent | null + const ownerIsLaunch = ownerRecord?.ownerIsLaunch === true + + // The live/idle split governs title override; siblings normalize against launch intent only. + const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner, ownerIsLaunch) + const liveSiblingIdentity = resolveSignalAgentForLaunchOwner( + args.siblingHookAgent, + launchAgent, + Boolean(launchAgent) + ) + // Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there. + const processProvesShell = !args.isRemote && args.processShellForeground === true + const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null + const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) + const idleIdentitySuppressed = + !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook + const idleFocusedIdentity = idleIdentitySuppressed + ? null + : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner, ownerIsLaunch) + // Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity. + const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( + args.siblingCompletedHookAgent, + launchAgent, + Boolean(launchAgent) + ) + const sleepingSessionAgent = args.sleepingSessionAgent ?? null + + // Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins. + const rawTitleAgent = resolveExplicitTerminalTitleAgentType(args.title) + const explicitTitleAgent = resolveSignalAgentForLaunchOwner(rawTitleAgent, owner, ownerIsLaunch) + const priorIdentity = idleFocusedIdentity ?? launchAgent + const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title) + // Why: a "claude" token in another agent's task text is a mention, not identity, so it must + // not take a pane from its known owner — only a title that PRESENTS Claude may (#8940). + const titleClaimsIdentity = + explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title) + // Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal. + // Raw title group, not the fallback-rewritten agent: inferred Pi owners would otherwise treat an OMP wrapper title as a different identity. + const titleReclaimsReusedPane = + priorIdentity !== null && + explicitTitleAgent !== null && + explicitTitleAgent !== priorIdentity && + !shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) && + titleClaimsIdentity && + (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) + // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. + const titleAgent = + processProvesShell || + sleepingSessionAgent || + (nativeOpenCodeTitle && idleFocusedIdentity !== null) + ? null + : titleReclaimsReusedPane + ? explicitTitleAgent + : priorIdentity + ? null + : explicitTitleAgent + + const launchedAgentExited = resolveLaunchedAgentExitEvidence({ + title: args.title, + defaultTitle: args.defaultTitle, + isRemote: args.isRemote, + hasObservedAgentSignal: args.hasObservedAgentSignal, + hookAgent: liveFocusedIdentity, + siblingHookAgent: liveSiblingIdentity, + hasCompletedHook, + processAgent: args.processAgent, + processShellForeground: args.processShellForeground + }) + const activeLaunchAgent = launchedAgentExited ? null : launchAgent + // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. + const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner, ownerIsLaunch) + return ( + liveFocusedIdentity ?? + processAgent ?? + titleAgent ?? + idleFocusedIdentity ?? + sleepingSessionAgent ?? + activeLaunchAgent ?? + liveSiblingIdentity ?? + idleSiblingIdentity + ) +} diff --git a/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts b/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts index a527a47758f..727ae04fd30 100644 --- a/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts +++ b/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts @@ -9,7 +9,8 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import { parseWorkspaceSession } from '../../../shared/workspace-session-schema' -import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' +import { useTabAgent } from './use-tab-agent' globalThis.IS_REACT_ACT_ENVIRONMENT = true 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 index 2dea897814f..f1e186d6f1b 100644 --- a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveTabAgentFromSignals } from './use-tab-agent' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' // 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 diff --git a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts index 380d5ba1d91..2a9d4eda9ca 100644 --- a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts +++ b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts @@ -10,9 +10,9 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import { resolveLaunchedAgentExitEvidence, - resolveTabAgentFromSignals, - useTabAgent -} from './use-tab-agent' + resolveTabAgentFromSignals +} from './tab-agent-from-signals' +import { useTabAgent } from './use-tab-agent' const initialAppState = useAppStore.getInitialState() const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts b/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts index 37c7345771e..08426c68f91 100644 --- a/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts +++ b/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts @@ -11,7 +11,8 @@ import type { import { makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' -import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' +import { useTabAgent } from './use-tab-agent' const initialAppState = useAppStore.getInitialState() const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 26dbaf201e9..c688f7e8f30 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -8,7 +8,8 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' -import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' +import { useTabAgent } from './use-tab-agent' const initialAppState = useAppStore.getInitialState() const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index a213b2dbfe3..3cdd6d8523e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -67,7 +67,7 @@ import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../../shared/agent-title-owner' -import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' +import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner' import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { readBrowserClientHostId } from './browser-client-host-identity' @@ -1181,7 +1181,7 @@ function buildMirroredTerminalTabs( } const launchAgent = activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent - const ownerAgent = resolvePaneAgentOwner({ + const ownerRecord = resolvePaneAgentOwnerRecord({ launchAgent, hookAgent: activeSurface.agentStatus?.agentType, siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus @@ -1189,7 +1189,8 @@ function buildMirroredTerminalTabs( }) const title = normalizeCompatibleAgentTitleForOwner( activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal', - ownerAgent + ownerRecord?.agent, + { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } ) const existing = existingById.get(localTabId) ?? @@ -1262,12 +1263,14 @@ function remapHostAgentStatus( if (!paneKey) { return null } - const ownerAgent = resolvePaneAgentOwner({ + const ownerRecord = resolvePaneAgentOwnerRecord({ launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent, hookAgent: surface.agentStatus.agentType }) return { - ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent), + ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, { + ownerIsLaunch: ownerRecord?.ownerIsLaunch === true + }), paneKey, tabId: toWebTerminalSurfaceTabId(surface.parentTabId) } diff --git a/src/shared/agent-hook-listener-pi-compatible.test.ts b/src/shared/agent-hook-listener-pi-compatible.test.ts index 8e835906f05..aeb19630e16 100644 --- a/src/shared/agent-hook-listener-pi-compatible.test.ts +++ b/src/shared/agent-hook-listener-pi-compatible.test.ts @@ -310,7 +310,18 @@ describe('shared agent-hook-listener', () => { expect(tool?.payload.interactivePrompt).toBeUndefined() }) - it('maps OMP ask to blocked without publishing a native prompt', () => { + it('maps OMP ask to blocked and publishes its questions payload', () => { + const questions = { + questions: [ + { + question: 'Choose', + options: [ + { label: 'x', description: 'First' }, + { label: 'y', description: 'Second' } + ] + } + ] + } const tool = normalizeHookPayload( state, 'omp', @@ -323,14 +334,7 @@ describe('shared agent-hook-listener', () => { payload: { hook_event_name: 'tool_execution_start', tool_name: 'ask', - tool_input: { - questions: [ - { - question: 'Choose', - options: ['x', 'y'] - } - ] - } + tool_input: questions } }, 'production' @@ -340,9 +344,60 @@ describe('shared agent-hook-listener', () => { agentType: 'omp', toolName: 'ask' }) - expect(tool?.payload.interactivePrompt).toBeUndefined() + expect(tool?.payload.interactivePrompt).toBe(JSON.stringify(questions)) }) + it('blocks an OMP pane on a tool approval request and clears it on resolution', () => { + const requested = normalizeHookPayload( + state, + 'omp', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'tool_approval_requested', + tool_name: 'bash', + reason: 'tools.approval.bash: prompt', + approval_mode: 'prompt' + } + }, + 'production' + ) + + expect(requested?.payload).toMatchObject({ + state: 'blocked', + agentType: 'omp', + toolName: 'bash', + toolInput: 'tools.approval.bash: prompt' + }) + + const resolved = normalizeHookPayload( + state, + 'omp', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'tool_approval_resolved', tool_name: 'bash', approved: true } + }, + 'production' + ) + + expect(resolved?.payload).toMatchObject({ state: 'working', toolName: 'bash' }) + expect(resolved?.payload.toolInput).toBeUndefined() + }) + + it.each(['tool_approval_requested', 'tool_approval_resolved'])( + 'ignores %s from Pi-compatible agents that do not emit it', + (hookEventName) => { + expect( + normalizeHookPayload( + state, + 'pi', + { paneKey: PANE_KEY, payload: { hook_event_name: hookEventName, tool_name: 'bash' } }, + 'production' + ) + ).toBeNull() + } + ) + it('captures Pi session ids on Pi-compatible status events', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index 5f0aeaf42c5..b3d66ee1d77 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -28,19 +28,23 @@ export function normalizePiCompatibleEvent( ((agentType === 'pi' && isAskUserQuestionTool(toolName)) || (agentType === 'omp' && toolName === 'ask')) && (eventName === 'tool_call' || eventName === 'tool_execution_start') + const isOmpApprovalRequest = agentType === 'omp' && eventName === 'tool_approval_requested' + const isOmpApprovalResolution = agentType === 'omp' && eventName === 'tool_approval_resolved' - const stateName = isPiCompatibleAsk - ? 'blocked' - : eventName === 'before_agent_start' || - eventName === 'agent_start' || - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' || - eventName === 'message_end' - ? 'working' - : eventName === 'agent_end' - ? 'done' - : null + const stateName = + isPiCompatibleAsk || isOmpApprovalRequest + ? 'blocked' + : isOmpApprovalResolution || + eventName === 'before_agent_start' || + eventName === 'agent_start' || + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' || + eventName === 'message_end' + ? 'working' + : eventName === 'agent_end' + ? 'done' + : null if (!stateName) { return null diff --git a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts index 21648548036..bb3e3251655 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts @@ -1,7 +1,29 @@ import type { ToolSnapshot } from '../listener-event' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' import { deriveInteractivePrompt } from '../interactive-tool' +/** OMP's `ask` carries the same questions/options payload as Pi's question tool. */ +function serializeQuestionPrompt(toolInput: unknown): string | undefined { + if (toolInput === undefined || toolInput === null) { + return undefined + } + try { + return JSON.stringify(toolInput) + } catch { + return undefined + } +} + +function isPiCompatibleAskTool( + agentKind: 'pi' | 'omp' | 'prime-agent', + toolName: string | undefined +): boolean { + return agentKind === 'omp' + ? toolName === 'ask' + : agentKind === 'pi' && isAskUserQuestionTool(toolName) +} + export function extractPiToolFields( eventName: unknown, hookPayload: Record, @@ -15,16 +37,33 @@ export function extractPiToolFields( const toolName = readString(hookPayload, 'tool_name') const rawToolInput = hookPayload.tool_input const toolInput = deriveToolInputPreview(toolName, rawToolInput) - // Why: OMP shares this extractor; only derive interactivePrompt for Pi so OMP ask_user_question metadata stays unchanged. + // Why: OMP's `ask` uses the same questions/options shape as Pi's question tool. const interactivePrompt = - agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start') - ? deriveInteractivePrompt(toolName, rawToolInput, eventName) + isPiCompatibleAskTool(agentKind, toolName) && + (eventName === 'tool_call' || eventName === 'tool_execution_start') + ? agentKind === 'omp' + ? serializeQuestionPrompt(rawToolInput) + : deriveInteractivePrompt(toolName, rawToolInput, eventName) : undefined return toolUpdate( { toolName, toolInput, interactivePrompt }, { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } ) } + if ( + agentKind === 'omp' && + (eventName === 'tool_approval_requested' || eventName === 'tool_approval_resolved') + ) { + return toolUpdate( + { + toolName: readString(hookPayload, 'tool_name'), + toolInput: + eventName === 'tool_approval_requested' ? readString(hookPayload, 'reason') : undefined, + interactivePrompt: undefined + }, + { hasToolInputField: true } + ) + } if (eventName === 'message_end' && hookPayload.role === 'assistant') { const text = readString(hookPayload, 'text') if (text) { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index dfa966669ea..2526c94572f 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -18,6 +18,11 @@ type TitleProfileMatch = { type TitleLabelProfileMatch = Pick +export type CompatibleAgentOwnerOptions = { + /** Whether the owner comes from explicit launch intent. */ + ownerIsLaunch?: boolean +} + const COMPATIBLE_IDLE_TITLE_RE = /(? { const byHelperAndPath = (left: (typeof actual)[number], right: (typeof actual)[number]) => left.helper.localeCompare(right.helper) || left.path.localeCompare(right.path) expect(actual.sort(byHelperAndPath)).toEqual(expected.sort(byHelperAndPath)) - }) + }, 30_000) it('pins direct single-source identity and action branches outside named helpers', () => { for (const site of DIRECT_SINGLE_SOURCE_SURFACES) { diff --git a/src/shared/pane-agent-owner.test.ts b/src/shared/pane-agent-owner.test.ts index 1d57f0b79c4..13cfd217c45 100644 --- a/src/shared/pane-agent-owner.test.ts +++ b/src/shared/pane-agent-owner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolvePaneAgentOwner } from './pane-agent-owner' +import { resolvePaneAgentOwner, resolvePaneAgentOwnerRecord } from './pane-agent-owner' describe('resolvePaneAgentOwner', () => { it('leads with launch intent', () => { @@ -40,5 +40,25 @@ describe('resolvePaneAgentOwner', () => { it('returns null when no owner evidence exists', () => { expect(resolvePaneAgentOwner({})).toBeNull() expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull() + expect(resolvePaneAgentOwnerRecord({})).toBeNull() + }) + + it('marks launch-tier evidence as launch ownership and status-tier as inferred', () => { + expect(resolvePaneAgentOwnerRecord({ launchAgent: 'pi', hookAgent: 'omp' })).toEqual({ + agent: 'pi', + ownerIsLaunch: true + }) + expect(resolvePaneAgentOwnerRecord({ startupLaunchAgent: 'pi', hookAgent: 'omp' })).toEqual({ + agent: 'pi', + ownerIsLaunch: true + }) + expect(resolvePaneAgentOwnerRecord({ hookAgent: 'omp' })).toEqual({ + agent: 'omp', + ownerIsLaunch: false + }) + expect(resolvePaneAgentOwnerRecord({ completedHookAgent: 'pi' })).toEqual({ + agent: 'pi', + ownerIsLaunch: false + }) }) }) diff --git a/src/shared/pane-agent-owner.ts b/src/shared/pane-agent-owner.ts index 65659a55e59..31b878d67ec 100644 --- a/src/shared/pane-agent-owner.ts +++ b/src/shared/pane-agent-owner.ts @@ -26,6 +26,27 @@ export type PaneAgentOwnerSignals = { sleepingSessionAgent?: AgentType | null } +export type ResolvedPaneAgentOwner = { + agent: AgentType + /** User-selected launch/startup/typed-command identity — not a status frame. */ + ownerIsLaunch: boolean +} + +const PANE_OWNER_RANK: readonly { + key: keyof PaneAgentOwnerSignals + ownerIsLaunch: boolean +}[] = [ + { key: 'launchAgent', ownerIsLaunch: true }, + { key: 'startupLaunchAgent', ownerIsLaunch: true }, + { key: 'initialStatusAgent', ownerIsLaunch: true }, + { key: 'commandInferredAgent', ownerIsLaunch: true }, + { key: 'hookAgent', ownerIsLaunch: false }, + { key: 'siblingHookAgent', ownerIsLaunch: false }, + { key: 'completedHookAgent', ownerIsLaunch: false }, + { key: 'siblingCompletedHookAgent', ownerIsLaunch: false }, + { key: 'sleepingSessionAgent', ownerIsLaunch: false } +] + /** * The single authoritative resolver for "which agent owns this pane", shared by * the tab-icon resolver, the terminal-pane display/renderer owner, and the @@ -42,17 +63,18 @@ export type PaneAgentOwnerSignals = { * 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 resolvePaneAgentOwnerRecord( + signals: PaneAgentOwnerSignals +): ResolvedPaneAgentOwner | null { + for (const { key, ownerIsLaunch } of PANE_OWNER_RANK) { + const agent = signals[key] + if (agent) { + return { agent, ownerIsLaunch } + } + } + return null +} + 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 - ) + return resolvePaneAgentOwnerRecord(signals)?.agent ?? null }