fix(agents): keep OMP identity and forward ask/approval events (#15713)

* fix(agents): keep OMP identity and forward ask/approval events (STA-4130)

A live OMP pane was re-owned as Pi because the generic pi-compatible
fallback always won, ask events blocked without a question payload, and
OMP suppressed tool_approval_* unless an extension registered handlers.

Mark Pi as the title-group fallback so a specific OMP identity is not
downgraded, publish OMP ask input as the existing questions envelope, and
forward tool_approval_requested/resolved onto blocked/working.

STA-4130
Related to #14278

Co-authored-by: devatnull <59279509+devatnull@users.noreply.github.com>

* fix(agents): keep launch Pi ownership over OMP wrapper frames (STA-4130)

The pi-compatible fallback treated every generic Pi owner as inferred, so an
explicit launch-Pi pane (and a launchless Pi pane with an OMP-shaped title)
was re-owned as OMP. Launch provenance now stays authoritative; only an
inferred status-frame owner yields to a specific sibling, and same-group
titles no longer count as reuse.

STA-4130

* fix(agents): drop Pi wrapper idle titles while OMP hook is active (STA-4130)

Title-completion suppression compared pick-a-winner ownership, so a Pi ready
frame looked like a different agent than a live OMP hook and fired a spurious
task-complete notification. Reuse checks now use the title-identity group.

STA-4130

* fix(agents): restore OMP approval forwarding after merge

* fix(agents): restore title-owner API after merge

* test(agents): update identity inventory ratchet

---------

Co-authored-by: devatnull <59279509+devatnull@users.noreply.github.com>
Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-08-30 18:51:24 -07:00
committed by GitHub
co-authored by devatnull Merge Sim
parent e54cfc1901
commit a0d36f5290
25 changed files with 634 additions and 171 deletions
@@ -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',
@@ -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()
})
})
+30 -26
View File
@@ -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, {
@@ -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<string, AgentStatusOrchestrationContext> | 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 }
}
@@ -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'
)
}
@@ -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<string, AgentStatusOrchestrationContext> | 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) {
@@ -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
}
@@ -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', () => {
@@ -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,
@@ -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<AgentStatusEntry, 'state' | 'updatedAt' | 'agentType'> | 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)
}
@@ -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
@@ -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
)
}
@@ -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
@@ -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
@@ -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'
@@ -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'
+2 -1
View File
@@ -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'
@@ -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)
}
@@ -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,
@@ -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
@@ -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<string, unknown>,
@@ -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) {
+33 -5
View File
@@ -18,6 +18,11 @@ type TitleProfileMatch = {
type TitleLabelProfileMatch = Pick<TitleProfileMatch, 'profile'>
export type CompatibleAgentOwnerOptions = {
/** Whether the owner comes from explicit launch intent. */
ownerIsLaunch?: boolean
}
const COMPATIBLE_IDLE_TITLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
/**
@@ -114,7 +119,8 @@ function hasIdleSuffix(title: string, sourceProfile: SyntheticAgentTitleProfile)
*/
export function resolveCompatibleAgentTypeForOwner(
incomingAgentType: AgentType | null | undefined,
ownerAgentType: AgentType | null | undefined
ownerAgentType: AgentType | null | undefined,
_options?: CompatibleAgentOwnerOptions
): AgentType | undefined {
if (!incomingAgentType) {
return undefined
@@ -137,7 +143,8 @@ export function resolveCompatibleAgentTypeForOwner(
*/
export function normalizeCompatibleAgentTitleForOwner(
title: string,
ownerAgentType: AgentType | null | undefined
ownerAgentType: AgentType | null | undefined,
_options?: CompatibleAgentOwnerOptions
): string {
const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType)
if (!ownerProfile?.titleIdentityGroup) {
@@ -188,11 +195,16 @@ export function normalizeCompatibleAgentTitleForOwner(
*/
export function normalizeCompatibleAgentStatusEntryForOwner(
entry: AgentStatusEntry,
ownerAgentType: AgentType | null | undefined
ownerAgentType: AgentType | null | undefined,
options?: CompatibleAgentOwnerOptions
): AgentStatusEntry {
const agentType = resolveCompatibleAgentTypeForOwner(entry.agentType, ownerAgentType)
const agentType = resolveCompatibleAgentTypeForOwner(entry.agentType, ownerAgentType, options)
const terminalTitle = entry.terminalTitle
? normalizeCompatibleAgentTitleForOwner(entry.terminalTitle, agentType ?? ownerAgentType)
? normalizeCompatibleAgentTitleForOwner(
entry.terminalTitle,
agentType ?? ownerAgentType,
options
)
: entry.terminalTitle
if (agentType === entry.agentType && terminalTitle === entry.terminalTitle) {
return entry
@@ -203,3 +215,19 @@ export function normalizeCompatibleAgentStatusEntryForOwner(
...(terminalTitle ? { terminalTitle } : {})
}
}
/** Returns whether two agents share the same title identity group. */
export function shareCompatibleTitleIdentityGroup(
left: AgentType | null | undefined,
right: AgentType | null | undefined
): boolean {
if (!left || !right) {
return false
}
if (left === right) {
return true
}
const leftGroup = getSyntheticAgentTitleProfile(left)?.titleIdentityGroup
const rightGroup = getSyntheticAgentTitleProfile(right)?.titleIdentityGroup
return Boolean(leftGroup && leftGroup === rightGroup)
}
@@ -153,6 +153,7 @@ const INVENTORY: readonly InventoryGroup[] = [
classification: 'parser-implementation',
paths: [
['src/renderer/src/lib/pane-agent-evidence.ts', 2],
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
'src/shared/terminal-title-agent-type.ts'
]
},
@@ -201,11 +202,9 @@ const INVENTORY: readonly InventoryGroup[] = [
helper: 'resolvePaneAgentOwner',
classification: 'identity-consumer',
paths: [
['src/main/runtime/orca-runtime.ts', 3],
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2],
['src/renderer/src/lib/use-tab-agent.ts', 2],
['src/renderer/src/runtime/web-session-tabs-sync.ts', 3]
['src/renderer/src/lib/use-tab-agent.ts', 2]
]
},
{
@@ -218,8 +217,9 @@ const INVENTORY: readonly InventoryGroup[] = [
classification: 'identity-consumer',
paths: [
['src/main/runtime/orca-runtime.ts', 3],
['src/renderer/src/components/sidebar/worktree-agent-rows.ts', 2],
['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
['src/renderer/src/lib/tab-agent-from-signals.ts', 2],
['src/renderer/src/lib/use-tab-agent.ts', 2]
]
},
@@ -232,8 +232,7 @@ const INVENTORY: readonly InventoryGroup[] = [
'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts',
3
],
['src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts', 3],
['src/renderer/src/components/terminal-pane/use-notification-dispatch.ts', 2]
['src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts', 3]
]
},
{
@@ -343,7 +342,7 @@ describe('pane agent identity inventory ratchet', () => {
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) {
+21 -1
View File
@@ -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
})
})
})
+34 -12
View File
@@ -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
}