diff --git a/src/main/agent-hooks/server-nested-agent-roster.test.ts b/src/main/agent-hooks/server-nested-agent-roster.test.ts index c015197b7d6..b16987debf1 100644 --- a/src/main/agent-hooks/server-nested-agent-roster.test.ts +++ b/src/main/agent-hooks/server-nested-agent-roster.test.ts @@ -203,4 +203,87 @@ describe('nested agent hooks preserve the owning pane', () => { expect(pushed).not.toHaveBeenCalled() server.stop() }) + + // #30589: a background child that posts while the owner idles must not then silence the owner. + it("returns the pane to its owner when the owner's next hook follows a nested agent", async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const nestedClaude = { + agentProcess: JSON.stringify({ pid: 4242, platform: 'darwin', startTime: 'nested' }) + } + const post = (source: AgentHookSource, payload: Record, overrides = {}) => + postHookEvent(server, buildBody(payload, overrides), `/hook/${source}`) + try { + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'owner', + prompt: 'Start job' + }) + await post('codex', { hook_event_name: 'Stop', session_id: 'owner' }) + await post( + 'claude', + { hook_event_name: 'UserPromptSubmit', session_id: 'child', prompt: 'Background job' }, + nestedClaude + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ agentType: 'claude', state: 'working' }) + + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'owner', + prompt: 'Next task' + }) + await post( + 'claude', + { hook_event_name: 'PreToolUse', session_id: 'child', tool_name: 'Bash' }, + nestedClaude + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + agentType: 'codex', + state: 'working', + prompt: 'Next task', + providerSession: { id: 'owner' } + }) + } finally { + server.stop() + } + }) + + it('keeps a stale owner type from letting a nested codex exec take the pane over the relay', async () => { + const server = new AgentHookServer() + const dir = mkdtempSync(join(tmpdir(), 'orca-stale-owner-')) + const relay = new RelayAgentHookServer({ + endpointDir: dir, + forward: (event) => server.ingestRemote(event, 'conn-1') + }) + await relay.start() + const post = (source: AgentHookSource, payload: Record) => + fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/${source}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': relay.getCoordinates().token + }, + body: JSON.stringify(buildBody(payload)) + }) + try { + await post('codex', { hook_event_name: 'UserPromptSubmit', session_id: 'old', prompt: 'Old' }) + await post('codex', { hook_event_name: 'Stop', session_id: 'old' }) + await post('claude', { hook_event_name: 'UserPromptSubmit', session_id: 'c', prompt: 'Now' }) + await post('codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'exec', + prompt: 'Review' + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + agentType: 'claude', + prompt: 'Now', + providerSession: { id: 'c' } + }) + } finally { + relay.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 99e5d888501..d952649a6d1 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -122,6 +122,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA incoming: terminalOwnedPayload.payload.agentType, // Relay presence is the execution host's owner verdict, not the sender's identity. incomingProcess: payload.connectionId ? undefined : incoming.agentPresence?.process, + incomingSession: terminalOwnedPayload.providerSession?.id, now }) if ( diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index c7116b7acca..4e0541a0980 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -35,7 +35,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt authorityRestartId, structuredHost, programStatus, - programStatusOnly + programStatusOnly, + agentPresence }) => { if (state.mainWindow?.isDestroyed()) { return @@ -86,6 +87,9 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(orchestration ? { orchestration } : {}), ...(programStatus ? { programStatus } : {}), ...(programStatusOnly === true ? { programStatusOnly: true } : {}), + ...(agentPresence?.session && !agentPresence.ended + ? { paneOwner: { agent: agentPresence.agent, session: agentPresence.session } } + : {}), // Mark reconnect replays so cached completions do not notify again. ...(isReplay === true ? { isReplay: true } : {}) } diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 1a572f4fd15..17c7d65fe85 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -1,10 +1,5 @@ -import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-terminal-owner' import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract' import { pickHostTurnEnd, type AgentStatusIpcPayload } from '../../../../shared/agent-status-types' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../../shared/agent-status-identity' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { normalizeProgramStatusFacet } from '../../../../shared/program-status/facet' @@ -18,6 +13,7 @@ import { applyResolvedAgentTerminalTitleToTab, hasRuntimeBackedWorktreeAttribution, isAgentStatusForRecentlyClosedTab, + isInheritedFromActivePane, isSeenHostReplay, resolveHookPayloadAgentType, shouldApplyResolvedAgentTerminalTitleToTab @@ -199,31 +195,11 @@ export function createAgentStatusEventApplicator(args: { : statusPayloadWithProvenance // Why null: main stitches the pane's current facet onto every publish, so absence means cleared. const statusPayloadWithProgramStatus = { ...statusPayloadWithObservation, programStatus } - const identity = resolveAgentStatusIdentity({ - existing: - existingStatus && - sameAgentStatusTerminalOwner( - { - connectionId: existingStatus.connectionId ?? null, - worktreeId: existingStatus.worktreeId - }, - { - connectionId: - (ownershipConnectionId === undefined - ? existingStatus.connectionId - : ownershipConnectionId) ?? null, - worktreeId: data.worktreeId ?? owningWorktreeId ?? existingStatus.worktreeId - } - ) - ? existingStatus - : undefined, - incoming: statusPayload.agentType, - now: data.receivedAt - }) if ( existingStatus && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane + isInheritedFromActivePane(existingStatus, data, statusPayload.agentType, { + connectionId: ownershipConnectionId, + worktreeId: owningWorktreeId }) ) { return 'dropped' diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts index 9cbe939f764..7ab2aeeee76 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -2,9 +2,13 @@ import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authorit import type { AppState } from '../../store/types' import { titleHasAgentName } from '../../../../shared/agent-detection' import type { + AgentStatusEntry, AgentStatusIpcPayload, + AgentType, ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { resolveAgentStatusIdentity } from '../../../../shared/agent-status-identity' +import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-terminal-owner' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import type { useAppStore } from '../../store' @@ -130,4 +134,30 @@ export function resolveHookPayloadAgentType( return { ...payload, agentType: 'openclaude' } } +/** Nested CLIs inherit the pane key; drop their hooks unless the host names them the pane's owner. */ +export function isInheritedFromActivePane( + existing: AgentStatusEntry, + data: AgentStatusIpcPayload, + incoming: AgentType | undefined, + owner: { connectionId: string | null | undefined; worktreeId: string | undefined } +): boolean { + const sameTerminal = sameAgentStatusTerminalOwner( + { connectionId: existing.connectionId ?? null, worktreeId: existing.worktreeId }, + { + connectionId: + (owner.connectionId === undefined ? existing.connectionId : owner.connectionId) ?? null, + worktreeId: data.worktreeId ?? owner.worktreeId ?? existing.worktreeId + } + ) + return ( + sameTerminal && + resolveAgentStatusIdentity({ + existing: { ...existing, agentPresence: data.paneOwner }, + incoming, + incomingSession: data.providerSession?.id, + now: data.receivedAt + }).inheritedFromActivePane + ) +} + export { resolvePaneKey } from '../../lib/agent-status-pane-ownership' diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index ee9504659ce..db629b9467c 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -31,12 +31,13 @@ export function normalizeAgentStatusMetadata( data: AgentStatusIpcPayload, authorityRestartId?: string ): AgentStatusMetadata | undefined { - if (!data.providerSession && !data.launchToken && !authorityRestartId) { + if (!data.providerSession && !data.launchToken && !authorityRestartId && !data.paneOwner) { return undefined } return { ...(authorityRestartId ? { authorityRestartId } : {}), ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) + ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(data.paneOwner ? { paneOwner: data.paneOwner } : {}) } } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 14b268a2ee0..e9f0d892131 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -112,6 +112,7 @@ export type AgentStatusMetadata = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string terminalResumeEligible?: false + paneOwner?: { agent: AgentType; session: string } } export type AgentStatusUpdate = { diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index c4c88489b08..383e3045a46 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -97,9 +97,10 @@ export function buildAgentStatusLiveEntry( worktreeId: routing?.worktreeId ?? existing.worktreeId } ) - ? existing + ? { ...existing, agentPresence: metadata?.paneOwner } : undefined, incoming: payload.agentType, + incomingSession: metadata?.providerSession?.id, now: updatedAt }) const commandCodeNewTurn = diff --git a/src/renderer/src/store/slices/agent-status-pane-owner-reclaim.test.ts b/src/renderer/src/store/slices/agent-status-pane-owner-reclaim.test.ts new file mode 100644 index 00000000000..54b61704085 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-pane-owner-reclaim.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const NOW = Date.UTC(2026, 9, 25, 12) + +afterEach(() => vi.useRealTimers()) + +// #30589: a nested agent's row arrived while the owner idled; the owner's next hook must land. +function reclaim(session?: string) { + const store = createTestStore() + vi.useFakeTimers() + vi.setSystemTime(NOW) + store + .getState() + .setAgentStatus(PANE_KEY, { state: 'working', prompt: 'Background job', agentType: 'claude' }) + store.getState().setAgentStatus( + PANE_KEY, + { state: 'working', prompt: 'Next task', agentType: 'codex' }, + undefined, + { updatedAt: NOW + 1 }, + undefined, + session + ? { + paneOwner: { agent: 'codex', session: 'owner' }, + providerSession: { key: 'session_id', id: session } + } + : undefined + ) + return store.getState().agentStatusByPaneKey[PANE_KEY] +} + +describe('pane owner reclaim', () => { + it("accepts the owner's hook over a fresh nested-agent row", () => { + expect(reclaim('owner')).toMatchObject({ + agentType: 'codex', + prompt: 'Next task' + }) + }) + + it.each([undefined, 'nested-exec'])('suppresses a different agent from session %s', (session) => { + expect(reclaim(session)).toMatchObject({ agentType: 'claude', prompt: 'Background job' }) + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index fc3ea90263d..2836d384bf0 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -167,6 +167,7 @@ export function normalizeHookPayload( }, incoming: source, incomingProcess: agentProcess, + incomingSession: providerSession?.id, now: Date.now() }).inheritedFromActivePane ) { diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 19ec4c56d76..037cf035aca 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -27,7 +27,10 @@ export function transitionHookPresence( : undefined } if (sender && owner.process && isSameAgentProcess(sender, owner.process)) { - return incoming + const session = owner.session + return session + ? { ...incoming, agentPresence: { ...owner, ...incoming.agentPresence, session } } + : incoming } // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } @@ -47,8 +50,14 @@ export function transitionHookPresence( if (!agent || agent === 'unknown') { return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming } + const session = incoming.agentPresence?.session ?? incoming.providerSession?.id return { ...incoming, - agentPresence: { ...incoming.agentPresence, agent, ...(sender ? { process: sender } : {}) } + agentPresence: { + ...incoming.agentPresence, + agent, + ...(sender ? { process: sender } : {}), + ...(session ? { session } : {}) + } } } diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index bf6098d97a6..6577b6ede21 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -11,6 +11,8 @@ export type AgentProcessIdentity = { export type AgentProcessPresence = { agent: AgentType process?: AgentProcessIdentity + /** Provider session that claimed the pane; later turns of the owner reuse it. */ + session?: string ended?: true /** This host verifies process ownership before accepting a different provider. */ sessionOwner?: true @@ -70,9 +72,14 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence | return undefined } const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined + const session = + 'session' in value && typeof value.session === 'string' && value.session.length <= 256 + ? value.session + : undefined return { agent: value.agent, ...(process ? { process } : {}), + ...(session ? { session } : {}), ...('ended' in value && value.ended === true ? { ended: true as const } : {}), ...('sessionOwner' in value && value.sessionOwner === true ? { sessionOwner: true as const } diff --git a/src/shared/agent-status-identity.ts b/src/shared/agent-status-identity.ts index f5520a0dc01..56a935c901b 100644 --- a/src/shared/agent-status-identity.ts +++ b/src/shared/agent-status-identity.ts @@ -51,6 +51,7 @@ export function resolveAgentStatusIdentity(args: { existing?: ExistingAgentIdentity incoming?: AgentType incomingProcess?: AgentProcessIdentity + incomingSession?: string now: number staleAfterMs?: number }): AgentIdentityResolution { @@ -81,6 +82,17 @@ export function resolveAgentStatusIdentity(args: { inheritedFromActivePane: otherProcessOwner } } + const owner = args.existing.agentPresence + if ( + owner?.session && + !owner.ended && + owner.agent === incomingAgentType && + owner.session === args.incomingSession && + !otherProcessOwner + ) { + // Why: a child that posted while the owner idled must not silence the owner's own session. + return { agentType: incomingAgentType, inheritedFromActivePane: false } + } const canonical = resolveCanonicalPaneAgentIdentity({ hookAgent: incomingAgentType as TuiAgent, hookIsLive: true, diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 49bdd676584..6885df76d33 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -11,6 +11,7 @@ import type { } from './agent-status-run' import type { AgentStatusOrchestrationContext, + AgentType, ParsedAgentStatusPayload, TurnEndDisposition } from './agent-status-types' @@ -68,6 +69,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { programStatusOnly?: true /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ restoredUnconfirmed?: boolean + /** The pane's owning agent session on its execution host; absent from older hosts. */ + paneOwner?: { agent: AgentType; session: string } /** A re-send of a cached row (reconnect or listener replay), not a new transition. */ isReplay?: boolean /** Present on rows the structured session host projects; `owned` keeps them fresh past the