From 77254c9f0667054351236c22e1470a3f87e263da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:53:33 -0700 Subject: [PATCH 01/27] fix(agent-presence): project execution-host ownership across remote clients --- .../src/session/mobile-agent-presence.test.ts | 41 +++++ .../session/mobile-native-chat-eligibility.ts | 7 +- .../src/session/mobile-terminal-tab-agent.ts | 14 +- .../server-agent-presence-discovery.test.ts | 100 +++++++++++ ...erver-agent-presence-remote-replay.test.ts | 153 ++++++++++++++++ .../server/server-agent-presence-discovery.ts | 119 +++++++++++++ .../server/server-agent-presence.ts | 7 +- .../server/server-ingest-remote.ts | 11 +- .../server/server-remote-agent-presence.ts | 39 +++++ src/main/orcad/orcad-entry.ts | 1 + .../providers/agent-foreground-process.ts | 31 +++- .../providers/agent-presence-discovery.ts | 56 ++++++ .../providers/agent-process-presence-probe.ts | 11 ++ .../windows-agent-foreground-process.ts | 19 +- ...ntime-hook-agent-status-projection.test.ts | 31 ++++ .../orca-runtime-preserved-branch-cleanup.ts | 5 + ...e-prune-mobile-session-tab-group-layout.ts | 20 ++- ...ntime-serialize-agent-prompt-submission.ts | 8 + src/main/runtime/orca-runtime-state-fields.ts | 3 + .../orca-runtime-stop-requested-pty-ids.ts | 18 ++ ...runtime-write-orchestration-pointer-pty.ts | 12 +- .../session-tab-presence-projection.test.ts | 67 +++++++ .../session-tab-presence-projection.ts | 29 ++++ .../rpc/methods/session-tabs-inventory.ts | 3 +- .../runtime-agent-presence-discovery.ts | 53 ++++++ ...untime-mobile-agent-presence-projection.ts | 35 ++++ .../runtime-mobile-agent-presence.test.ts | 163 ++++++++++++++++++ .../runtime-mobile-agent-status-builder.ts | 10 +- .../runtime-mobile-session-projection.ts | 15 +- .../runtime-terminal-agent-presence.test.ts | 39 +++++ .../runtime-terminal-agent-presence.ts | 6 + .../runtime-terminal-agent-status-query.ts | 10 ++ .../runtime/runtime-worktree-agent-rows.ts | 1 + .../runtime/runtime-worktree-agent-source.ts | 2 + .../runtime-worktree-pty-agent-sources.ts | 5 +- .../startup/main-process-runtime-service.ts | 1 + src/relay/agent-hook-cached-pane-status.ts | 16 +- src/relay/agent-hook-server.ts | 56 +++--- src/relay/pty-handler.ts | 19 ++ src/relay/relay-agent-hook-runtime.ts | 11 +- .../relay-agent-presence-discovery.test.ts | 81 +++++++++ src/relay/relay-agent-presence-discovery.ts | 86 +++++++++ src/relay/relay-agent-presence-observation.ts | 14 ++ .../src/lib/tab-agent-from-signals.ts | 10 +- .../mobile-session-inputs.ts | 4 +- .../web-session-tabs-sync-presence.test.ts | 67 +++++++ .../agent-status-patch.ts | 62 ++++++- .../agent-status-primitives.ts | 12 +- .../state-equality-core.ts | 1 + .../runtime/web-session-tabs-sync/state.ts | 1 + .../agent-hook-listener/listener-event.ts | 2 + src/shared/agent-hook-presence-transition.ts | 30 +++- .../agent-process-presence-candidate.test.ts | 31 ++++ .../agent-process-presence-candidate.ts | 54 ++++++ src/shared/agent-process-presence-probe.ts | 17 +- .../agent-process-presence-windows.test.ts | 43 +++++ src/shared/agent-process-presence.ts | 37 ++++ src/shared/agent-status-types.ts | 2 + src/shared/protocol-version.ts | 4 + .../remote-runtime-client-capabilities.ts | 2 + src/shared/runtime-worktree-contracts.ts | 2 + 61 files changed, 1721 insertions(+), 88 deletions(-) create mode 100644 mobile/src/session/mobile-agent-presence.test.ts create mode 100644 src/main/agent-hooks/server-agent-presence-discovery.test.ts create mode 100644 src/main/agent-hooks/server-agent-presence-remote-replay.test.ts create mode 100644 src/main/agent-hooks/server/server-agent-presence-discovery.ts create mode 100644 src/main/agent-hooks/server/server-remote-agent-presence.ts create mode 100644 src/main/providers/agent-presence-discovery.ts create mode 100644 src/main/providers/agent-process-presence-probe.ts create mode 100644 src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tab-presence-projection.ts create mode 100644 src/main/runtime/runtime-agent-presence-discovery.ts create mode 100644 src/main/runtime/runtime-mobile-agent-presence-projection.ts create mode 100644 src/main/runtime/runtime-mobile-agent-presence.test.ts create mode 100644 src/main/runtime/runtime-terminal-agent-presence.test.ts create mode 100644 src/relay/relay-agent-presence-discovery.test.ts create mode 100644 src/relay/relay-agent-presence-discovery.ts create mode 100644 src/relay/relay-agent-presence-observation.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts create mode 100644 src/shared/agent-process-presence-candidate.test.ts create mode 100644 src/shared/agent-process-presence-candidate.ts create mode 100644 src/shared/agent-process-presence-windows.test.ts diff --git a/mobile/src/session/mobile-agent-presence.test.ts b/mobile/src/session/mobile-agent-presence.test.ts new file mode 100644 index 000000000000..c935211bd86c --- /dev/null +++ b/mobile/src/session/mobile-agent-presence.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import { resolveMobileTerminalTabAgentId } from './mobile-terminal-tab-agent' + +function tab(ended?: true) { + const agentStatus: AgentStatusEntry = { + state: 'done', + prompt: '', + paneKey: 'tab:leaf', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentType: 'claude', + providerSession: { key: 'session_id', id: 'session' }, + agentPresence: { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' }, + ...(ended ? { ended } : {}) + } + } + return { type: 'terminal', title: 'claude', launchAgent: 'claude' as const, agentStatus } +} +describe('host process presence on mobile', () => { + it('retains an idle owner under a shell title', () => { + const live = { ...tab(), title: 'zsh' } + expect(resolveMobileTerminalTabAgentId(live)).toBe('claude') + expect(resolveMobileNativeChat(live)?.sessionId).toBe('session') + }) + it('does not revive an ended owner from title, launch or transcript identity', () => { + expect(resolveMobileTerminalTabAgentId(tab(true))).toBeNull() + expect(resolveMobileNativeChat(tab(true))).toBeNull() + }) + it('keeps old-host and unidentified rows on the legacy path', () => { + const legacy = tab() + delete legacy.agentStatus.agentPresence + expect(resolveMobileNativeChat(legacy)?.agent).toBe('claude') + legacy.agentStatus.agentPresence = { agent: 'claude', ended: true } + expect(resolveMobileTerminalTabAgentId(legacy)).toBe('claude') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index c04f5ec72dc1..f3ce9d7b302c 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,3 +1,4 @@ +import { selectAgentPresence } from '../../../src/shared/agent-process-presence' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' @@ -58,7 +59,11 @@ export function resolveMobileNativeChat( if (tab.type !== 'terminal') { return null } - const liveAgent = tab.agentStatus?.agentType ?? null + const presence = selectAgentPresence(tab.agentStatus?.agentPresence) + if (presence === null) { + return null + } + const liveAgent = presence ?? tab.agentStatus?.agentType ?? null const agent = liveAgent ? isNativeChatSupportedAgent(liveAgent) ? liveAgent diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index dfc7be812e81..5124c3be8348 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -1,3 +1,4 @@ +import { selectAgentPresence } from '../../../src/shared/agent-process-presence' import { stripLeadingAgentTitleDecorationOrEmpty } from '../../../src/shared/agent-title-decoration' import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/terminal-title-agent-type' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' @@ -17,7 +18,10 @@ import type { MobileSessionTab } from './mobile-session-route-types' */ type MobileTerminalTabAgentIdentity = { title: string - agentStatus?: { agentType?: AgentStatusEntry['agentType'] | null } | null + agentStatus?: { + agentType?: AgentStatusEntry['agentType'] | null + agentPresence?: AgentStatusEntry['agentPresence'] + } | null launchAgent?: TuiAgent | null } @@ -25,6 +29,10 @@ type MobileTerminalTabAgentIdentity = { export function resolveMobileTerminalTabOwnedAgentId( tab: MobileTerminalTabAgentIdentity ): string | null { + const presence = selectAgentPresence(tab.agentStatus?.agentPresence) + if (presence !== undefined) { + return presence + } const hookAgentType = tab.agentStatus?.agentType?.trim() if (hookAgentType && hookAgentType !== 'unknown') { return hookAgentType @@ -38,6 +46,10 @@ export function resolveMobileTerminalTabOwnedAgentId( export function resolveMobileTerminalTabAgentId( tab: MobileTerminalTabAgentIdentity ): string | null { + const presence = selectAgentPresence(tab.agentStatus?.agentPresence) + if (presence !== undefined) { + return presence + } const ownedAgent = resolveMobileTerminalTabOwnedAgentId(tab) if (ownedAgent) { return ownedAgent diff --git a/src/main/agent-hooks/server-agent-presence-discovery.test.ts b/src/main/agent-hooks/server-agent-presence-discovery.test.ts new file mode 100644 index 000000000000..2a8e29faf224 --- /dev/null +++ b/src/main/agent-hooks/server-agent-presence-discovery.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { PANE } from './server.test-fixtures' +import type { AgentPresenceDiscoveryRequest } from './server/server-agent-presence-discovery' +import type { AgentProcessPresence } from '../../shared/agent-process-presence' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) +const presence: AgentProcessPresence = { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' } +} +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } +}) +function setup() { + const server = new AgentHookServer() + servers.push(server) + const request: AgentPresenceDiscoveryRequest = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + ptyId: 'pty-1', + terminalHandle: 'terminal-1', + isCurrent: () => true, + discover: vi.fn(async () => presence) + } + return { server, request } +} +describe('execution host presence discovery', () => { + it('captures once and preserves an unidentified owner’s turn', async () => { + const { server, request } = setup() + server.ingestTerminalStatus({ + ...request, + payload: { agentType: 'claude', state: 'working', prompt: 'keep this turn' } + }) + const before = server.getStatusSnapshot()[0] + await server.discoverAgentPresence(request) + expect(server.getStatusSnapshot()[0]).toMatchObject({ ...before, agentPresence: presence }) + await server.discoverAgentPresence(request) + expect(request.discover).toHaveBeenCalledTimes(1) + }) + it('admits a hookless owner without manufacturing a working turn', async () => { + const { server, request } = setup() + await server.discoverAgentPresence(request) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', agentPresence: presence }) + }) + it('rejects a late result after the terminal incarnation changes', async () => { + const { server, request } = setup() + request.isCurrent = () => false + await server.discoverAgentPresence(request) + expect(server.getStatusSnapshot()).toEqual([]) + }) + it('rejects capture if a hook replaces the observed row during the read', async () => { + const { server, request } = setup() + request.discover = async () => { + server.ingestTerminalStatus({ + ...request, + payload: { agentType: 'codex', state: 'working', prompt: '' } + }) + return presence + } + await server.discoverAgentPresence(request) + expect(server.getStatusSnapshot()[0]?.agentPresence?.process).toBeUndefined() + }) + it('does not let a nested different agent replace an unidentified owner', async () => { + const { server, request } = setup() + server.ingestTerminalStatus({ + ...request, + payload: { agentType: 'codex', state: 'working', prompt: '' } + }) + await server.discoverAgentPresence(request) + expect(server.getStatusSnapshot()[0]?.agentPresence).toEqual({ agent: 'codex' }) + }) +}) + +it('accepts a relay host capture for an unidentified remote owner without probing its PID locally', async () => { + const { server, request } = setup() + const event = { + paneKey: request.paneKey, + tabId: request.tabId, + worktreeId: request.worktreeId, + source: 'claude', + payload: { agentType: 'claude', state: 'done', prompt: '' } + } + server.ingestRemote(event, 'ssh-1') + server.ingestRemote( + { + ...event, + hookEventName: 'AgentProcessCaptured', + agentPresence: { ...presence, observation: { epoch: 'relay', sequence: 1 } } + }, + 'ssh-1' + ) + expect(server.getStatusSnapshot()[0]?.agentPresence).toMatchObject(presence) + await expect(server.checkAgentPresence(request.paneKey)).resolves.toBe('unverifiable') +}) diff --git a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts new file mode 100644 index 000000000000..0b3e7a969da4 --- /dev/null +++ b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts @@ -0,0 +1,153 @@ +import { transitionHookPresence } from '../../shared/agent-hook-presence-transition' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { PANE } from './server.test-fixtures' +import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) +const owner = { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' } +} as const +const replacement = { + agent: 'codex', + process: { pid: 43, platform: 'linux', startTime: 'boot:43' } +} as const +const servers: { stop(): void }[] = [] +afterEach(() => { + servers.splice(0).forEach((server) => server.stop()) + probe.mockResolvedValue('live') +}) +function setup() { + let connected = true + const main = new AgentHookServer() + const frames: AgentHookRelayEnvelope[] = [] + const relay = new RelayAgentHookServer({ + endpointDir: '/unused-replay-test', + forward: (event) => { + frames.push(event) + if (connected) { + main.ingestRemote(event, 'ssh-1') + } + } + }) + servers.push(main, relay) + const request = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'folder-1', isCurrent: () => true } + return { + main, + relay, + request, + frames, + connect: (value: boolean) => { + connected = value + } + } +} +describe('execution host presence replay', () => { + it.each([false, true])( + 'converges after offline exit and replacement (replacement ended=%s)', + async (ended) => { + const { main, relay, request, frames, connect } = setup() + await relay.discoverAgentPresence({ ...request, discover: async () => owner }) + connect(false) + probe.mockResolvedValue('exited') + await relay.checkAgentPresence(PANE) + const staleExit = frames.at(-1)! + await relay.discoverAgentPresence({ ...request, discover: async () => replacement }) + if (ended) { + await relay.checkAgentPresence(PANE) + } else { + probe.mockResolvedValue('live') + } + connect(true) + relay.replayCachedPayloadsForPanes() + await relay.checkAgentPresence(PANE) + expect(main.getStatusSnapshot()[0]?.agentPresence).toMatchObject({ + ...replacement, + ...(ended ? { ended: true } : {}) + }) + main.ingestRemote(staleExit, 'ssh-1') + main.ingestRemote({ ...frames[0], isReplay: true }, 'ssh-1') + main.ingestRemote({ ...staleExit, isReplay: true }, 'ssh-1') + expect(main.getStatusSnapshot()[0]?.agentPresence?.process).toEqual(replacement.process) + expect(main.getStatusSnapshot()[0]?.agentPresence?.ended).toBe(ended ? true : undefined) + } + ) + it('retains the ordering of a connected exit when an older live frame replays', async () => { + const { main, relay, request, frames } = setup() + await relay.discoverAgentPresence({ ...request, discover: async () => owner }) + const staleLive = frames.at(-1)! + probe.mockResolvedValue('exited') + await relay.checkAgentPresence(PANE) + const exited = main.getStatusSnapshot()[0]?.agentPresence + expect(exited?.observation).toEqual(frames.at(-1)?.agentPresence?.observation) + main.ingestRemote({ ...staleLive, isReplay: true }, 'ssh-1') + expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(exited) + expect(exited?.ended).toBe(true) + }) + it('does not accept host provenance from local hook or terminal bytes', async () => { + const { main, request } = setup() + await main.discoverAgentPresence({ + ...request, + ptyId: 'pty', + terminalHandle: 'terminal', + discover: async () => owner + }) + const normalized = normalizeHookPayload( + createHookListenerState(), + 'claude', + { + env: 'production', + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + agentProcess: { ...replacement.process, observation: { epoch: 'fake', sequence: 99 } }, + agentPresenceFromExecutionHost: true, + agentPresenceObservation: { epoch: 'fake', sequence: 99 }, + payload: { hook_event_name: 'SessionStart', session_id: 'nested', source: 'startup' } + }, + 'production' + ) + expect(normalized?.agentPresenceFromExecutionHost).toBeUndefined() + expect(normalized).not.toBeNull() + expect(normalized?.agentPresence?.observation).toBeUndefined() + if (!normalized) { + throw new Error('missing normalized hook') + } + expect( + transitionHookPresence(normalized, { ...normalized, agentPresence: owner })?.agentPresence + ).toEqual(owner) + const bytes = { + ...request, + ptyId: 'pty', + terminalHandle: 'terminal', + agentPresence: replacement, + agentPresenceFromExecutionHost: true, + agentPresenceObservation: { epoch: 'fake', sequence: 99 }, + payload: { agentType: 'codex', state: 'done' as const, prompt: '' } + } + main.ingestTerminalStatus(bytes) + expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(owner) + }) + it('keeps old relay omission and raw mismatched exits on the legacy path', () => { + const { main, request } = setup() + const event = { + ...request, + source: 'claude', + payload: { state: 'done', prompt: '', agentType: 'claude' }, + agentPresence: owner + } + main.ingestRemote(event, 'ssh-1') + main.ingestRemote({ ...event, agentPresence: replacement }, 'ssh-1') + expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(owner) + main.ingestRemote({ ...event, agentPresence: { ...replacement, ended: true } }, 'ssh-1') + expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(owner) + }) +}) diff --git a/src/main/agent-hooks/server/server-agent-presence-discovery.ts b/src/main/agent-hooks/server/server-agent-presence-discovery.ts new file mode 100644 index 000000000000..fb119991852a --- /dev/null +++ b/src/main/agent-hooks/server/server-agent-presence-discovery.ts @@ -0,0 +1,119 @@ +import { + isSameAgentProcess, + type AgentProcessPresence +} from '../../../shared/agent-process-presence' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerLifecycle } from './server-lifecycle' + +export type AgentPresenceDiscoveryRequest = { + paneKey: string + ptyId: string + tabId: string + worktreeId: string + terminalHandle: string + isCurrent(): boolean + discover(): Promise +} + +export abstract class AgentHookServerPresenceDiscovery extends AgentHookServerLifecycle { + private discoveryGeneration = 0 + + override stop(): void { + this.discoveryGeneration += 1 + this.presenceDiscoveries.clear() + super.stop() + } + + private readonly presenceDiscoveries = new Map>() + + discoverAgentPresence(request: AgentPresenceDiscoveryRequest): Promise { + const generation = this.discoveryGeneration + const paneKey = this.resolvePaneKeyAlias(request.paneKey) + const before = this.state.lastStatusByPaneKey.get(paneKey) + if (before?.agentPresence?.process && !before.agentPresence.ended) { + return Promise.resolve() + } + if (before && (before.connectionId !== null || before.worktreeId !== request.worktreeId)) { + return Promise.resolve() + } + const pending = this.presenceDiscoveries.get(paneKey) + if (pending) { + return pending + } + const discovery = request + .discover() + .then((presence) => { + if ( + generation !== this.discoveryGeneration || + !presence?.process || + presence.ended || + !request.isCurrent() || + this.resolvePaneKeyAlias(request.paneKey) !== paneKey || + this.state.lastStatusByPaneKey.get(paneKey) !== before || + this.getAgentStatusDisposition(paneKey) !== 'accept' + ) { + return + } + if ( + before?.agentPresence?.ended && + before.agentPresence.process && + isSameAgentProcess(before.agentPresence.process, presence.process) + ) { + return + } + if ( + before?.agentPresence && + !before.agentPresence.ended && + before.agentPresence.agent !== presence.agent + ) { + return + } + if (!before) { + this.ingestTerminalStatus({ + ...request, + connectionId: null, + payload: { state: 'done', prompt: '', agentType: presence.agent } + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main stores only timing-enriched rows through its validated ingress. + const row = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!row || row.connectionId !== null || row.worktreeId !== request.worktreeId) { + return + } + if (before && row !== before) { + return + } + const observedAt = Math.max(Date.now(), row.receivedAt + 1) + const captured: EnrichedAgentHookEventPayload = before?.agentPresence?.ended + ? { + paneKey, + tabId: request.tabId, + worktreeId: request.worktreeId, + terminalHandle: request.terminalHandle, + connectionId: null, + payload: { state: 'done', prompt: '', agentType: presence.agent }, + agentPresence: presence, + receivedAt: observedAt, + stateStartedAt: observedAt + } + : { ...row, agentPresence: presence } + if (!this.writeLegacyStatusRow(captured)) { + return + } + this.commitStatusRowMutation(row, captured) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(captured) + }) + .catch(() => undefined) + .finally(() => { + if (this.presenceDiscoveries.get(paneKey) === discovery) { + this.presenceDiscoveries.delete(paneKey) + } + }) + this.presenceDiscoveries.set(paneKey, discovery) + return discovery + } +} diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index 99b08dec3ea1..3e7eec3d2193 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -1,3 +1,4 @@ +import { readHostAgentProcess } from '../../providers/agent-process-presence-probe' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { isSameAgentProcess, @@ -5,9 +6,9 @@ import { type AgentProcessVerdict } from '../../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' -import { AgentHookServerLifecycle } from './server-lifecycle' +import { AgentHookServerPresenceDiscovery } from './server-agent-presence-discovery' -export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecycle { +export abstract class AgentHookServerAgentPresence extends AgentHookServerPresenceDiscovery { private readonly presenceChecks = new WeakMap< AgentHookEventPayload, Promise @@ -57,7 +58,7 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy return pending } const presence = row.agentPresence - const check = probeAgentProcessPresence(presence.process) + const check = probeAgentProcessPresence(presence.process, readHostAgentProcess) .then((verdict) => { if ( this.state.lastStatusByPaneKey.get(resolved) !== row || diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 28eac6e56dc1..e1ecec454902 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,3 +1,4 @@ +import { admitRemoteAgentPresence } from './server-remote-agent-presence' import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { track } from '../../telemetry/client' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' @@ -138,6 +139,14 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS toolAgentType, providerSession } = normalizeRemoteEnvelopeFields(envelope) + const remotePresence = admitRemoteAgentPresence( + envelope, + trimmedConnectionId, + this.state.lastStatusByPaneKey.get(paneKey) + ) + if (!remotePresence) { + return + } // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). const validatedPayload = normalizeAgentStatusPayload(envelope.payload) if (!validatedPayload) { @@ -259,7 +268,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS }) const event: AgentHookEventPayload & { authorityRestartId?: string } = { paneKey, - agentPresence: readAgentProcessPresence(envelope.agentPresence), + ...remotePresence, source: effectiveSource, ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } diff --git a/src/main/agent-hooks/server/server-remote-agent-presence.ts b/src/main/agent-hooks/server/server-remote-agent-presence.ts new file mode 100644 index 000000000000..ea2abe5437a4 --- /dev/null +++ b/src/main/agent-hooks/server/server-remote-agent-presence.ts @@ -0,0 +1,39 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + isSameAgentProcess, + readAgentProcessPresence +} from '../../../shared/agent-process-presence' + +export function admitRemoteAgentPresence( + envelope: { agentPresence?: unknown }, + connectionId: string | null, + previous: AgentHookEventPayload | undefined +): Pick | null { + const agentPresence = readAgentProcessPresence(envelope.agentPresence) + const observation = agentPresence?.observation + if (!connectionId || !observation || !agentPresence?.process) { + return { agentPresence } + } + const recorded = previous?.agentPresence?.observation + if ( + previous?.connectionId === connectionId && + recorded?.epoch === observation.epoch && + recorded.sequence > observation.sequence + ) { + return null + } + if ( + recorded?.epoch === observation.epoch && + recorded.sequence === observation.sequence && + previous?.connectionId === connectionId && + previous.agentPresence?.process && + (!isSameAgentProcess(previous.agentPresence.process, agentPresence.process) || + previous.agentPresence.ended !== agentPresence.ended) + ) { + return null + } + return { + agentPresence, + agentPresenceFromExecutionHost: true + } +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 872fbb0a1275..19f4d49067d2 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -235,6 +235,7 @@ async function startOrcadRuntime( publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, + discoverHookAgentPresence: (request) => agentHookServer.discoverAgentPresence(request), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 84b147ca3e95..8b030973608b 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,5 +1,6 @@ +import { selectDiscoveredAgentOwner } from '../../shared/agent-process-presence-candidate' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' +import { resolveOuterWrapperForegroundIdentity } from '../../shared/foreground-wrapper-agent' import type { ProcessTableRow } from '../../shared/process-table-snapshot' import { getFreshProcessTableSnapshot, @@ -37,7 +38,7 @@ export type AgentForegroundProcessResolution = { /** * Windows: pid of the process a recognized name belongs to — a liveness * anchor callers may check against the pane's job. Absent when the name is a - * fallback, ambiguous, or resolved on POSIX (where `+` already marks it). + * fallback or ambiguous. */ processId?: number /** Windows: the scan proved the caller's `anchorProcessId` is now a non-agent. */ @@ -166,9 +167,18 @@ export async function resolveAgentForegroundProcessWithAvailability( if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) { return { available: false, processName: fallbackProcess } } + const foreground = resolveAgentForegroundIdentityFromPs(rows, shellPid) + const identity = + options.capturePresence && foreground + ? selectDiscoveredAgentOwner( + foreground.processId, + collectDescendantsFromIndex(getProcessTableIndex(rows), shellPid) + ) + : foreground return { available: true, - processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess + processName: identity?.processName ?? fallbackProcess, + ...(identity ? { processId: identity.processId } : {}) } } catch { // Why: a failed scan cannot prove fallback ownership; callers retain the last recognized agent. @@ -180,6 +190,13 @@ export function resolveAgentForegroundProcessFromPs( rows: readonly ProcessTableRow[], shellPid: number ): string | null { + return resolveAgentForegroundIdentityFromPs(rows, shellPid)?.processName ?? null +} + +export function resolveAgentForegroundIdentityFromPs( + rows: readonly ProcessTableRow[], + shellPid: number +): { processName: string; processId: number } | null { // Memoized per snapshot identity, so the caller's own index build is reused. const index = getProcessTableIndex(rows) const shellRow = index.byPid.get(shellPid) @@ -200,8 +217,12 @@ export function resolveAgentForegroundProcessFromPs( const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates) if (selected) { // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. - return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundIdentity. + return resolveOuterWrapperForegroundIdentity( + selected.recognized, + selected.candidate, + candidates + ) } return null } diff --git a/src/main/providers/agent-presence-discovery.ts b/src/main/providers/agent-presence-discovery.ts new file mode 100644 index 000000000000..4ea492fbdf1d --- /dev/null +++ b/src/main/providers/agent-presence-discovery.ts @@ -0,0 +1,56 @@ +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' +import { readHostAgentProcess } from './agent-process-presence-probe' + +export async function discoverLocalAgentPresence( + rootProcessId: number +): Promise { + const platform = process.platform + if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') { + return undefined + } + const candidate = await resolveAgentForegroundProcessWithAvailability( + rootProcessId, + platform === 'win32' ? 'cmd.exe' : 'sh', + { + capturePresence: true, + fresh: true, + forceProcessScan: true + } + ) + const agent = recognizeAgentProcess(candidate.processName)?.agent + if (!candidate.available || !candidate.processId || !agent) { + return undefined + } + const birth = await readHostAgentProcess(candidate.processId) + if (birth.verdict !== 'live' || birth.zombie || birth.stopped) { + return undefined + } + const confirmed = await resolveAgentForegroundProcessWithAvailability( + rootProcessId, + platform === 'win32' ? 'cmd.exe' : 'sh', + { + capturePresence: true, + fresh: true, + forceProcessScan: true + } + ) + if ( + !confirmed.available || + confirmed.processId !== candidate.processId || + recognizeAgentProcess(confirmed.processName)?.agent !== agent + ) { + return undefined + } + const finalBirth = await readHostAgentProcess(candidate.processId) + if ( + finalBirth.verdict !== 'live' || + finalBirth.startTime !== birth.startTime || + finalBirth.zombie || + finalBirth.stopped + ) { + return undefined + } + return { agent, process: { pid: candidate.processId, platform, startTime: birth.startTime } } +} diff --git a/src/main/providers/agent-process-presence-probe.ts b/src/main/providers/agent-process-presence-probe.ts new file mode 100644 index 000000000000..58c0270447b3 --- /dev/null +++ b/src/main/providers/agent-process-presence-probe.ts @@ -0,0 +1,11 @@ +import { + readAgentProcess, + windowsAgentProcessReader +} from '../../shared/agent-process-presence-probe' +import { readWindowsProcessCreationTime } from '../windows/windows-process-table' + +export async function readHostAgentProcess(pid: number) { + return process.platform === 'win32' + ? windowsAgentProcessReader(readWindowsProcessCreationTime)(pid) + : readAgentProcess(pid) +} diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 8ffba73c24d9..509f6b8fe39d 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -1,3 +1,4 @@ +import { projectDiscoveredAgentOwner } from '../../shared/agent-process-presence-candidate' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -17,6 +18,7 @@ import { } from './windows-foreground-process-rows' export type AgentForegroundResolutionOptions = { + capturePresence?: boolean contextPaths?: readonly string[] /** Require a Windows process-table scan started after this request. */ fresh?: boolean @@ -24,13 +26,8 @@ export type AgentForegroundResolutionOptions = { forceProcessScan?: boolean /** Lazily proves which global descendants still belong to this ConPTY. */ readWindowsConsoleAttachedProcessIds?: () => Promise | null> - /** - * A caller's cached liveness anchor. When a scan row holds this pid but no - * longer recognizes as the cached agent, the pid was recycled by a different - * process (command lines are immutable): the resolution reports it foreign. - */ + /** Detects PID reuse when the cached agent no longer matches. */ anchorProcessId?: number - /** The cached agent name the anchor pid is supposed to prove. */ anchorProcessName?: string } @@ -121,7 +118,11 @@ export async function resolveWindowsAgentForegroundProcessWithAvailability( anchorRow.command !== anchorRow.name) return { available: true, - ...resolveWindowsForegroundIdentity(filteredCandidates, fallbackProcess, options.contextPaths), + ...projectDiscoveredAgentOwner( + resolveWindowsForegroundIdentity(filteredCandidates, fallbackProcess, options.contextPaths), + filteredCandidates, + options.capturePresence + ), ...(anchorPidForeign ? { anchorPidForeign: true } : {}) } } @@ -299,9 +300,7 @@ function candidateMatchesContextPath( return false } const haystack = normalizePathForCommandMatch(candidate.command) - return normalizedContextPaths.some((contextPath) => - commandLineContainsPath(haystack, contextPath) - ) + return normalizedContextPaths.some((path) => commandLineContainsPath(haystack, path)) } function normalizePathForCommandMatch(value: string): string { diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 9fab2267d250..a2cc6ce6e1cd 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -567,3 +567,34 @@ describe('hook-driven session tabs republish (#11761)', () => { unsubscribe() }) }) + +it('uses the same host owner for headless terminal identity and running-agent queries', async () => { + const rows = [ + hookRow({ + state: 'done', + prompt: '', + toolName: undefined, + interactivePrompt: undefined, + agentPresence: { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' } + } + }) + ] + const runtime = await createRuntimeWithHookRows(rows) + observePaneTitle(runtime, 'zsh') + const terminal = (await runtime.listTerminals()).terminals[0] + expect(terminal.agentIdentity).toBe('claude') + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true) + const owner = rows[0].agentPresence + if (!owner) { + throw new Error('missing fixture owner') + } + rows[0] = { ...rows[0], providerSessionOnly: true, agentPresence: { ...owner, ended: true } } + expect((await runtime.listTerminals()).terminals[0].agentIdentity).toBeUndefined() + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) +}) diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99ae..9aea18622a90 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import type { AgentPresenceDiscoveryRequest } from '../agent-hooks/server/server-agent-presence-discovery' import { OrcaRuntimeWithTerminalDrivers } from './orca-runtime-terminal-drivers' import { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import type { IPtyProvider } from '../providers/types' @@ -90,6 +91,10 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + protected readonly discoverHookAgentPresenceFn: + | ((request: AgentPresenceDiscoveryRequest) => Promise) + | null + protected readonly checkHookAgentPresenceFn: | ((paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>) | null diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 75005726c2db..1b5d12841c3d 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -117,12 +117,20 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { - return buildRuntimeMobileAgentStatus(pty, tab, terminalHandle, retained, getHookRowsForPane, { - getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), - getLeaf: (candidate) => - this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) ?? null, - getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) - }) + return buildRuntimeMobileAgentStatus( + pty, + tab, + terminalHandle, + retained, + getHookRowsForPane, + { + getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), + getLeaf: (candidate) => + this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) ?? null, + getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) + }, + false + ) } protected getFreshRetainedAgentStatusForMobileTab( diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 44fa2411df15..08fbffd81722 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { discoverRuntimeAgentPresence } from './runtime-agent-presence-discovery' import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' @@ -76,6 +77,13 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if (!this.checkHookAgentPresenceFn) { return null } + await discoverRuntimeAgentPresence({ + pty: this.ptysById.get(ptyId), + current: () => this.ptysById.get(ptyId), + issueHandle: (pty) => this.issuePtyHandle(pty), + provider: this.getLocalProviderFn, + admit: this.discoverHookAgentPresenceFn + }) const verdicts = await Promise.all( Array.from(this.collectAgentStatusPaneKeysForPty(ptyId), (paneKey) => this.checkHookAgentPresenceFn(paneKey) diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ca95bf9dc270..0b97b7c16917 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import type { AgentPresenceDiscoveryRequest } from '../agent-hooks/server/server-agent-presence-discovery' import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands' import type { RuntimeStore } from './runtime-store-contract' import type { StatsCollector } from '../stats/collector' @@ -82,6 +83,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + discoverHookAgentPresence?: (request: AgentPresenceDiscoveryRequest) => Promise checkHookAgentPresence?: ( paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> @@ -233,6 +235,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.discoverHookAgentPresenceFn = deps?.discoverHookAgentPresence ?? null this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a7175383df0d..40b552c68786 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -42,6 +42,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId protected readonly ptyExitListenersByPtyId = new Map void>>() protected readonly terminalAgentPresence = new RuntimeTerminalAgentPresence({ + getAgentPresence: (handle) => this.getRecordedAgentPresence(handle), isLiveStructuredAgent: (handle) => Boolean(resolveStructuredWorkerAuthority(handle, this._orchestrationDb)), getLivePty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, @@ -162,12 +163,29 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId } }) + protected getRecordedAgentPresence(handle: string) { + try { + const ptyId = + this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId + if (!ptyId) { + return undefined + } + const rows = Array.from(this.collectAgentStatusPaneKeysForPty(ptyId)).flatMap( + (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? [] + ) + return rows.sort((a, b) => b.receivedAt - a.receivedAt)[0]?.agentPresence + } catch { + return undefined + } + } + protected readonly terminalAgentStatus = new RuntimeTerminalAgentStatusQuery({ getController: () => this.ptyController, getLivePty: (handle) => this.getLivePtyForHandle(handle), getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle), getPrimaryLeaf: (ptyId) => this.getPrimaryLeafForPty(ptyId), getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, + getAgentPresence: (handle) => this.getRecordedAgentPresence(handle), getExplicitStatus: (handle) => this.getFreshExplicitAgentStatusForHandle(handle), getLifecycleStatus: (ptyId) => this.agentPromptLifecycleByPtyId.get(ptyId), isRunning: (handle) => this.isTerminalRunningAgent(handle) diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 538ab4d6d4d0..11120429ff48 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectAgentPresence } from '../../shared/agent-process-presence' import { OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness } from './orca-runtime-refresh-floating-workspace-pty-liveness' import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' import type { WriteSettlement } from '../../shared/pty-write-settlement' @@ -93,9 +94,14 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith title: string | null, paneKey: string | null ): { agentIdentity?: TuiAgent } { - const hookRow = paneKey - ? selectRuntimeHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) - : null + const rows = paneKey ? (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) : [] + const presence = selectAgentPresence( + [...rows].sort((a, b) => b.receivedAt - a.receivedAt)[0]?.agentPresence + ) + if (presence !== undefined) { + return presence === null ? {} : { agentIdentity: presence } + } + const hookRow = selectRuntimeHookAgentRowForPane(rows) const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null const agentIdentity = resolvePublishedPaneAgentIdentity({ hookAgent, diff --git a/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts new file mode 100644 index 000000000000..1919169d8b30 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts @@ -0,0 +1,67 @@ +import { UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from '../../../../shared/runtime-types' +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { HOST_AGENT_PRESENCE_STATUS } from '../../runtime-mobile-agent-presence-projection' +import { projectSessionTabPresenceForClient } from './session-tab-presence-projection' + +const status = { + state: 'done', + prompt: '', + paneKey: 'tab:leaf', + updatedAt: 10, + stateStartedAt: 10, + stateHistory: [], + agentPresence: { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' }, + ended: true + } +} as const +function snapshot(covered: boolean): RuntimeMobileSessionTabsResult { + const tab = { + type: 'terminal', + id: 'tab::leaf', + parentTabId: 'tab', + leafId: 'leaf', + title: 'zsh', + isActive: true, + status: 'pending-handle', + terminal: null, + ...(covered ? { [HOST_AGENT_PRESENCE_STATUS]: status } : {}) + } as const + return { + publicationEpoch: UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH, + snapshotVersion: 1, + activeGroupId: null, + worktree: 'folder', + activeTabId: tab.id, + activeTabType: 'terminal', + tabs: [tab] + } +} +describe('connection scoped presence projection', () => { + it('keeps old clients on the exact legacy row', () => { + expect(projectSessionTabPresenceForClient(snapshot(true), []).tabs).toEqual( + snapshot(false).tabs + ) + expect(JSON.stringify(snapshot(true))).not.toContain('agentPresence') + }) + it('publishes an ended owner to a capable connection', () => { + expect( + projectSessionTabPresenceForClient(snapshot(true), [ + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY + ]).tabs[0] + ).toMatchObject({ agentStatus: status }) + }) + it('does not cover an unidentified row or retain another connection’s capability', () => { + const capable = [AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY] + expect(projectSessionTabPresenceForClient(snapshot(false), capable).tabs).toEqual( + snapshot(false).tabs + ) + projectSessionTabPresenceForClient(snapshot(true), capable) + expect(projectSessionTabPresenceForClient(snapshot(true), undefined).tabs).toEqual( + snapshot(false).tabs + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tab-presence-projection.ts b/src/main/runtime/rpc/methods/session-tab-presence-projection.ts new file mode 100644 index 000000000000..7d7edcef8490 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-presence-projection.ts @@ -0,0 +1,29 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { HOST_AGENT_PRESENCE_STATUS } from '../../runtime-mobile-agent-presence-projection' + +export function projectSessionTabPresenceForClient( + snapshot: RuntimeMobileSessionTabsResult, + capabilities: readonly string[] | undefined +): RuntimeMobileSessionTabsResult { + const supported = capabilities?.includes(AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY) === true + return { + ...snapshot, + tabs: snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + if (!(HOST_AGENT_PRESENCE_STATUS in tab)) { + return tab + } + const { [HOST_AGENT_PRESENCE_STATUS]: status, ...legacy } = tab + // The symbol is produced only by the execution host's in-process projection. + if (!supported || !status || typeof status !== 'object' || !('agentPresence' in status)) { + return legacy + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This private symbol is minted only by projectHostAgentPresenceStatus; JSON cannot supply it. + return { ...legacy, agentStatus: status as AgentStatusEntry } + }) + } +} diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index 3ab06a298fc6..eadc47166e98 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -1,3 +1,4 @@ +import { projectSessionTabPresenceForClient } from './session-tab-presence-projection' import { isDeepStrictEqual } from 'node:util' import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' @@ -34,7 +35,7 @@ export function projectSessionTabsForClient( ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( projectSessionTabAgentStatus( - snapshot, + projectSessionTabPresenceForClient(snapshot, clientCapabilities), clientKind, clientCapabilities, structuredNativeChatEnabled diff --git a/src/main/runtime/runtime-agent-presence-discovery.ts b/src/main/runtime/runtime-agent-presence-discovery.ts new file mode 100644 index 000000000000..1f085907fbf8 --- /dev/null +++ b/src/main/runtime/runtime-agent-presence-discovery.ts @@ -0,0 +1,53 @@ +import type { AgentPresenceDiscoveryRequest } from '../agent-hooks/server/server-agent-presence-discovery' +import type { IPtyProvider } from '../providers/types' +import { discoverLocalAgentPresence } from '../providers/agent-presence-discovery' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +export async function discoverRuntimeAgentPresence(args: { + pty: RuntimePtyWorktreeRecord | undefined + current(): RuntimePtyWorktreeRecord | undefined + issueHandle(pty: RuntimePtyWorktreeRecord): string + provider: (() => IPtyProvider) | null + admit: ((request: AgentPresenceDiscoveryRequest) => Promise) | null +}): Promise { + const { pty, admit, provider } = args + if ( + !pty || + !admit || + !provider || + pty.connectionId || + pty.isWsl || + (process.platform === 'win32' && pty.isWsl !== false) || + !pty.incarnationId || + !pty.tabId || + !pty.paneKey + ) { + return + } + const { incarnationId, tabId, paneKey, worktreeId } = pty + const isCurrent = () => + args.current() === pty && + pty.incarnationId === incarnationId && + pty.paneKey === paneKey && + pty.tabId === tabId && + pty.worktreeId === worktreeId && + !pty.connectionId + await admit({ + paneKey, + tabId, + worktreeId, + ptyId: pty.ptyId, + terminalHandle: args.issueHandle(pty), + isCurrent, + discover: async () => { + const inventory = await provider().listProcesses() + const owned = inventory.find( + (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === incarnationId + ) + if (!isCurrent() || !owned?.rootProcessId || owned.wslDistro) { + return undefined + } + return discoverLocalAgentPresence(owned.rootProcessId) + } + }) +} diff --git a/src/main/runtime/runtime-mobile-agent-presence-projection.ts b/src/main/runtime/runtime-mobile-agent-presence-projection.ts new file mode 100644 index 000000000000..8ac50637dbad --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-presence-projection.ts @@ -0,0 +1,35 @@ +import { + pickParsedAgentStatusPayload, + type AgentStatusEntry, + type AgentStatusIpcPayload +} from '../../shared/agent-status-types' + +// Derived alongside the legacy projection; symbols cannot enter JSON or client publications. +export const HOST_AGENT_PRESENCE_STATUS = Symbol('host-agent-presence-status') + +export function projectHostAgentPresenceStatus( + rows: readonly AgentStatusIpcPayload[] +): AgentStatusEntry | undefined { + const row = rows.reduce( + (latest, candidate) => + !latest || candidate.receivedAt > latest.receivedAt ? candidate : latest, + undefined + ) + if (!row?.agentPresence?.process) { + return undefined + } + return { + ...pickParsedAgentStatusPayload(row), + agentType: row.agentPresence.agent, + agentPresence: row.agentPresence, + paneKey: row.paneKey, + updatedAt: row.receivedAt, + stateStartedAt: row.stateStartedAt ?? row.receivedAt, + stateHistory: [], + ...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}), + ...(row.terminalHandle ? { terminalHandle: row.terminalHandle } : {}), + ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), + ...(row.tabId ? { tabId: row.tabId } : {}), + ...(row.providerSession ? { providerSession: row.providerSession } : {}) + } +} diff --git a/src/main/runtime/runtime-mobile-agent-presence.test.ts b/src/main/runtime/runtime-mobile-agent-presence.test.ts new file mode 100644 index 000000000000..b5bdcb4d7338 --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-presence.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' + +const processIdentity = { pid: 4001, platform: 'linux', startTime: 'boot:123' } as const + +function projectOwner(ended: boolean) { + const now = Date.now() + const row: AgentStatusIpcPayload = { + paneKey: 'tab:leaf', + tabId: 'tab', + worktreeId: 'folder-1', + connectionId: null, + state: 'done', + prompt: '', + receivedAt: now, + stateStartedAt: now, + agentType: 'claude', + agentPresence: { + agent: 'claude', + process: processIdentity, + ...(ended ? { ended: true } : {}) + }, + ...(ended ? { providerSessionOnly: true } : {}) + } + return buildRuntimeMobileAgentStatus( + null, + { + type: 'terminal', + id: 'tab::leaf', + parentTabId: 'tab', + leafId: 'leaf', + title: 'zsh', + launchAgent: 'claude', + isActive: true + }, + null, + null, + () => [row], + { + getPaneKey: () => row.paneKey, + getLeaf: () => null, + getTrackedTitle: () => null + } + ) +} + +describe('headless mobile owner projection', () => { + it('preserves an idle host owner without a renderer or an agent title', () => { + expect(projectOwner(false)).toMatchObject({ + agentStatus: { + agentPresence: { agent: 'claude', process: processIdentity } + } + }) + }) + + it('carries a positive exit through the snapshot even with a stale launch hint', () => { + expect(projectOwner(true)).toMatchObject({ + agentStatus: { + agentPresence: { agent: 'claude', process: processIdentity, ended: true } + } + }) + }) +}) + +import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection' +import { projectSessionTabsForClient } from './rpc/methods/session-tabs-inventory' +import { AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { makePaneKey } from '../../shared/stable-pane-id' + +it('negotiates the full snapshot and never accepts client-published presence as host evidence', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab', leafId) + const row: AgentStatusIpcPayload = { + paneKey, + tabId: 'tab', + worktreeId: 'folder', + connectionId: null, + state: 'done', + prompt: '', + receivedAt: 10, + stateStartedAt: 5, + providerSessionOnly: true, + agentType: 'claude', + agentPresence: { agent: 'claude', process: processIdentity, ended: true } + } + const spoofedAuthority = { agentPresenceFromExecutionHost: true } + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: 'folder', + publicationEpoch: 'epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'tab', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab::leaf', + parentTabId: 'tab', + leafId, + title: 'zsh', + isActive: true, + launchAgent: 'claude', + agentStatus: { + state: 'done', + prompt: '', + paneKey, + updatedAt: 99999, + stateStartedAt: 99999, + stateHistory: [], + agentType: 'claude', + agentPresence: { + agent: 'claude', + process: processIdentity, + observation: { epoch: 'client', sequence: 999 } + }, + ...spoofedAuthority + } + } + ] + } + let rows = [row] + const host: RuntimeMobileSessionProjectionHost = { + tabs: new Map(), + leaves: new Map(), + ptysById: new Map(), + getLiveBrowserTabs: () => new Map(), + getProviderSessionRows: () => rows, + getProviderSessionSnapshot: () => rows, + getStatusSnapshot: () => [], + getLeafKey: () => '', + findPty: () => null, + getRetainedStatus: () => null, + getTrackedTitle: () => null, + issuePtyHandle: () => 'terminal', + recordPty: () => { + throw new Error('unexpected pty') + }, + buildPtyStatus: () => ({}), + sanitizeGroups: () => undefined, + pruneGroupLayout: () => null, + collectTabIds: () => new Set() + } + const result = projectRuntimeMobileSessionTabs(snapshot, host) + const capable = [AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY] + expect(projectSessionTabsForClient(result, 'mobile', capable, false).tabs[0]).toMatchObject({ + agentStatus: { agentPresence: row.agentPresence, updatedAt: 10, stateStartedAt: 5 } + }) + expect(projectSessionTabsForClient(result, 'mobile', [], false).tabs[0]).not.toHaveProperty( + 'agentStatus.agentPresence' + ) + rows = [] + expect( + projectSessionTabsForClient( + projectRuntimeMobileSessionTabs(snapshot, host), + 'mobile', + capable, + false + ).tabs[0] + ).not.toHaveProperty('agentStatus.agentPresence') +}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 5ce39d49d6aa..bd4f954b6012 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -1,3 +1,4 @@ +import { projectHostAgentPresenceStatus } from './runtime-mobile-agent-presence-projection' import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner @@ -30,9 +31,16 @@ export function buildRuntimeMobileAgentStatus( terminalHandle: string | null, retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[], - host: RuntimeMobileAgentStatusHost + host: RuntimeMobileAgentStatusHost, + includeAgentPresence = true ): { agentStatus: AgentStatusEntry } | Record { const paneKey = host.getPaneKey(tab) + if (includeAgentPresence) { + const status = projectHostAgentPresenceStatus(getHookRowsForPane(paneKey)) + if (status) { + return { agentStatus: status } + } + } // Why: neither the live-status projection nor a title-derived status carries a // provider session — only the full hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index db1ef0619cab..14afb5ee9eb0 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -1,3 +1,7 @@ +import { + HOST_AGENT_PRESENCE_STATUS, + projectHostAgentPresenceStatus +} from './runtime-mobile-agent-presence-projection' import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, @@ -270,8 +274,11 @@ export function projectRuntimeMobileSessionTabs( const projectedStatusEntry = projectedAgentStatus.agentStatus as | (AgentStatusEntry & { turnCompletedAt?: number }) | undefined - const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } = - projectedStatusEntry ?? {} + const { + turnCompletedAt: projectedTurnCompletedAt, + agentPresence: _clientPresence, + ...clientStatusFields + } = projectedStatusEntry ?? {} const rawTurnCompletedAt = hookAgentStatus?.live?.payload.turnCompletedAt ?? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ?? @@ -283,8 +290,10 @@ export function projectRuntimeMobileSessionTabs( const clientAgentStatus: { agentStatus?: AgentStatusEntry } = projectedStatusEntry ? { agentStatus: clientStatusFields as AgentStatusEntry } : {} + const presenceStatus = projectHostAgentPresenceStatus(getHookRowsForPane(paneKey)) tabs.push({ - type: 'terminal', + ...(presenceStatus ? { [HOST_AGENT_PRESENCE_STATUS]: presenceStatus } : {}), + type: 'terminal' as const, id: tab.id, parentTabId: tab.parentTabId, leafId: tab.leafId, diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts new file mode 100644 index 000000000000..555d09a91a96 --- /dev/null +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' +import type { AgentProcessPresence } from '../../shared/agent-process-presence' + +describe('headless terminal presence', () => { + it.each([false, true])('uses host presence under a shell title (ended=%s)', async (ended) => { + const presence: AgentProcessPresence = { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' }, + ...(ended ? { ended: true } : {}) + } + const query = new RuntimeTerminalAgentStatusQuery({ + getAgentPresence: () => presence, + getController: () => null, + getLivePty: () => null, + getLiveLeaf: () => { + throw new Error('unexpected leaf lookup') + }, + getPrimaryLeaf: () => null, + getTabTitle: () => 'zsh', + getExplicitStatus: () => null, + getLifecycleStatus: () => null, + isRunning: async () => false + }) + vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') + vi.spyOn(query, 'getSnapshot').mockReturnValue({ + waitText: '', + waitBlockedAt: null, + title: 'zsh', + titleStatus: null, + titleStatusIsLive: false + }) + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: !ended, + status: null + }) + }) +}) diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index aad71c1a08aa..babc5f21856f 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -1,3 +1,4 @@ +import { selectAgentPresence, type AgentProcessPresence } from '../../shared/agent-process-presence' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -20,6 +21,7 @@ const WRAPPER_RETRY_INTERVAL_MS = 150 const WRAPPER_RETRY_TIMEOUT_MS = 6_500 type RuntimeTerminalAgentPresenceDependencies = { + getAgentPresence?(handle: string): AgentProcessPresence | undefined /** A structured agent session of this runtime; it has no pane, so no PTY probe can see it. */ isLiveStructuredAgent?(handle: string): boolean getLivePty(handle: string): RuntimePtyWorktreeRecord | null @@ -50,6 +52,10 @@ export class RuntimeTerminalAgentPresence { if (this.deps.isLiveStructuredAgent?.(handle)) { return true } + const presence = selectAgentPresence(this.deps.getAgentPresence?.(handle)) + if (presence !== undefined) { + return presence !== null + } try { const pty = this.deps.getLivePty(handle) if (pty) { diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 5a06ea3f688b..af536f1b8c2a 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,3 +1,4 @@ +import { selectAgentPresence, type AgentProcessPresence } from '../../shared/agent-process-presence' import { detectAgentStatusFromTitle, isOpenCodeNativeTitle, @@ -26,6 +27,7 @@ export type RuntimeTerminalAgentStatusSnapshot = { } type Dependencies = { + getAgentPresence?(handle: string): AgentProcessPresence | undefined getController(): RuntimePtyController | null getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord } @@ -66,6 +68,14 @@ export class RuntimeTerminalAgentStatusQuery { const terminal = this.getSnapshot(handle, ptyId) const explicitStatus = this.deps.getExplicitStatus(handle) const lifecycle = this.deps.getLifecycleStatus(ptyId) + const presence = selectAgentPresence(this.deps.getAgentPresence?.(handle)) + if (presence !== undefined) { + return { + handle, + isRunningAgent: presence !== null, + status: presence === null ? null : (explicitStatus?.status ?? lifecycle?.status ?? null) + } + } const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 310b0f6bc524..b09c5a0889cd 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -53,6 +53,7 @@ export function attachRuntimeWorktreeAgentRows(args: { state: source.state, ...(source.workingMode ? { workingMode: source.workingMode } : {}), agentType: source.agentType, + ...(source.agentPresence ? { agentPresence: source.agentPresence } : {}), prompt: source.prompt, taskTitle: orchestration?.taskTitle ?? null, displayName: orchestration?.displayName ?? null, diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index fd98d7914b1d..6b251af86001 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,8 +1,10 @@ +import type { AgentProcessPresence } from '../../shared/agent-process-presence' import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import type { AgentMainAgentStatus } from '../../shared/main-agent-status' export type RuntimeWorktreeAgentSource = { + agentPresence?: AgentProcessPresence paneKey: string ptyId?: string tabId?: string diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index e03f458134f2..86e127f6f368 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -43,7 +43,10 @@ export function collectRuntimeWorktreePtyAgentSources(args: { payload: hookPayload, state: entry.state, ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), - agentType: entry.agentType ?? null, + agentType: entry.agentPresence?.process + ? entry.agentPresence.agent + : (entry.agentType ?? null), + ...(entry.agentPresence ? { agentPresence: entry.agentPresence } : {}), prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 7ab3bad8957d..717d6beeeddf 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -116,6 +116,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + discoverHookAgentPresence: (request) => agentHookServer.discoverAgentPresence(request), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), diff --git a/src/relay/agent-hook-cached-pane-status.ts b/src/relay/agent-hook-cached-pane-status.ts index 2302dae587f8..107548be0a5b 100644 --- a/src/relay/agent-hook-cached-pane-status.ts +++ b/src/relay/agent-hook-cached-pane-status.ts @@ -1,5 +1,6 @@ +import { buildRelayHookEnvelope } from './agent-hook-envelope-build' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' -import type { AgentHookSource } from '../shared/agent-hook-relay' +import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' export type CachedPaneEnvelopeMeta = { source: AgentHookSource; env?: string; version?: string } @@ -58,3 +59,16 @@ export function evictCachedPanesOverCap( dropPane(oldest) } } + +export function replayCachedPanePresence( + input: Parameters[0], + check: (paneKey: string) => Promise, + forward: (event: AgentHookRelayEnvelope) => void +): number { + const replayable = selectReplayableCachedPanes(input) + for (const { event, meta } of replayable) { + void check(event.paneKey) + forward(buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true })) + } + return replayable.length +} diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 126d0470abb0..079c7e05331b 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,7 +1,9 @@ +import { createRelayAgentPresenceObservation } from './relay-agent-presence-observation' +import { RelayAgentPresenceDiscovery } from './relay-agent-presence-discovery' import { handleRelayHookRequest } from './agent-hook-request' import { transitionHookPresence } from '../shared/agent-hook-presence-transition' import { RelayAgentPresence } from './relay-agent-presence' -import { isSameAgentProcess } from '../shared/agent-process-presence' +import { isSameAgentProcess, type AgentProcessPresence } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -13,8 +15,7 @@ import { import { clearAllListenerCaches, clearPaneCacheState, - createHookListenerState, - type HookListenerState + createHookListenerState } from '../shared/agent-hook-listener/listener-state' import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' import { @@ -41,7 +42,7 @@ import { import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' -import { MAX_CACHED_PANES, selectReplayableCachedPanes } from './agent-hook-cached-pane-status' +import { MAX_CACHED_PANES, replayCachedPanePresence } from './agent-hook-cached-pane-status' export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void @@ -75,7 +76,7 @@ export class RelayAgentHookServer { private endpointDir: string private endpointFilePath: string private endpointFileWritten = false - private state: HookListenerState = createHookListenerState() + private state = createHookListenerState() private transportInterference = createHookTransportInterferenceTracker((report) => { process.stderr.write(`${describeHookTransportInterference(report)}\n`) }) @@ -90,6 +91,12 @@ export class RelayAgentHookServer { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false + private readonly presenceDiscoveries = new RelayAgentPresenceDiscovery( + (paneKey) => this.state.lastStatusByPaneKey.get(paneKey), + (event, source, captured) => + this.applyEvent(event, source, this.env, ORCA_HOOK_PROTOCOL_VERSION, { captured }) + ) + private readonly observePresence = createRelayAgentPresenceObservation() private readonly presenceChecks = new RelayAgentPresence() private retryScheduler: AgentHookResultRetryScheduler @@ -199,6 +206,7 @@ export class RelayAgentHookServer { this.token = '' this.endpointFileWritten = false this.retryScheduler.clearAll() + this.presenceDiscoveries.clear() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() } @@ -206,27 +214,24 @@ export class RelayAgentHookServer { /** Request-driven replay: re-forwards each cached paneKey payload as a fresh notification. Forwards are * issued before the request handler returns, so the response trails all replayed notifications. */ replayCachedPayloadsForPanes(): number { - const cachedSnapshot = new Map(this.state.lastStatusByPaneKey) - const replayable = selectReplayableCachedPanes({ - cachedByPaneKey: cachedSnapshot, - metaByPaneKey: this.lastEnvelopeMetaByPaneKey, - isPaneSurfaceRetired: this.isPaneSurfaceRetired, - dropPane: (paneKey) => this.clearPaneState(paneKey) - }) - for (const { event, meta } of replayable) { - void this.checkAgentPresence(event.paneKey) - this.forward( - buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true }) - ) - } - return replayable.length + return replayCachedPanePresence( + { + cachedByPaneKey: this.state.lastStatusByPaneKey, + metaByPaneKey: this.lastEnvelopeMetaByPaneKey, + isPaneSurfaceRetired: this.isPaneSurfaceRetired, + dropPane: (paneKey) => this.clearPaneState(paneKey) + }, + (paneKey) => this.checkAgentPresence(paneKey), + this.forward + ) } + discoverAgentPresence = this.presenceDiscoveries.discover.bind(this.presenceDiscoveries) + checkAgentPresence(paneKey: string): Promise { - const row = this.state.lastStatusByPaneKey.get(paneKey) const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) return this.presenceChecks.check( - row, + this.state.lastStatusByPaneKey.get(paneKey), () => this.state.lastStatusByPaneKey.get(paneKey), (event) => { if (meta) { @@ -278,18 +283,17 @@ export class RelayAgentHookServer { source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean; checkPresence?: boolean } = {} + options: { isReplay?: boolean; checkPresence?: boolean; captured?: AgentProcessPresence } = {} ): AgentHookEventPayload | undefined { const transitioned = transitionHookPresence( incoming, - this.state.lastStatusByPaneKey.get(incoming.paneKey) + this.state.lastStatusByPaneKey.get(incoming.paneKey), + options.captured ) if (!transitioned) { return undefined } - const event = transitioned.agentPresence?.ended - ? { ...transitioned, providerSessionOnly: true } - : transitioned + const event = this.observePresence(transitioned) // Why: this post came from a process still running inside a pane whose tab the user closed. // Caching or forwarding it makes every connected client advertise a live, resumable agent pane // that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 53d666a12f58..fa7b6335463f 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -719,6 +719,25 @@ export class PtyHandler { private agentPresenceTrigger: ((paneKey: string) => void) | null = null + getAgentPresenceDiscoveryTarget(paneKey: string) { + const managed = Array.from(this.ptys.values()).find((pty) => pty.paneKey === paneKey) + if (!managed || managed.disposed || managed.wslDistro || process.platform === 'win32') { + return undefined + } + const incarnation = managed.incarnationId + return { + rootProcessId: managed.pty.pid, + tabId: managed.tabId, + worktreeId: managed.worktreeId, + terminalHandle: managed.terminalHandle, + isCurrent: () => + this.ptys.get(managed.id) === managed && + !managed.disposed && + managed.incarnationId === incarnation && + managed.paneKey === paneKey + } + } + setAgentPresenceTrigger(listener: ((paneKey: string) => void) | null): void { this.agentPresenceTrigger = listener } diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 6c21a9d6fc70..247e88d70357 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -1,3 +1,4 @@ +import { discoverLocalAgentPresence } from '../main/providers/agent-presence-discovery' import type { RelayDispatcher } from './dispatcher' import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' import { RelayAgentHookServer } from './agent-hook-server' @@ -65,7 +66,15 @@ export class RelayAgentHookRuntime { private registerPtyEnvironment(): void { this.ptyHandler.setAgentPresenceTrigger((paneKey) => { - void this.hookServer.checkAgentPresence(paneKey) + const target = this.ptyHandler.getAgentPresenceDiscoveryTarget(paneKey) + const capture = target + ? this.hookServer.discoverAgentPresence({ + ...target, + paneKey, + discover: () => discoverLocalAgentPresence(target.rootProcessId) + }) + : Promise.resolve() + void capture.then(() => this.hookServer.checkAgentPresence(paneKey)) }) this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) diff --git a/src/relay/relay-agent-presence-discovery.test.ts b/src/relay/relay-agent-presence-discovery.test.ts new file mode 100644 index 000000000000..38d0542cabc3 --- /dev/null +++ b/src/relay/relay-agent-presence-discovery.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAgentHookServer } from './agent-hook-server' +import { makePaneKey } from '../shared/stable-pane-id' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') +) +vi.mock('../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +const paneKey = makePaneKey('tab', '11111111-1111-4111-8111-111111111111') +const presence = { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' } +} as const +const servers: RelayAgentHookServer[] = [] +afterEach(() => { + servers.splice(0).forEach((server) => server.stop()) + probe.mockResolvedValue('live') +}) +function setup() { + let connected = true + const frames: AgentHookRelayEnvelope[] = [] + const server = new RelayAgentHookServer({ + endpointDir: '/unused-presence-test', + forward: (event) => { + if (connected) { + frames.push(event) + } + } + }) + servers.push(server) + const request = { + paneKey, + tabId: 'tab', + worktreeId: 'folder', + isCurrent: () => true, + discover: vi.fn(async () => presence) + } + return { + server, + request, + frames, + connect: (value: boolean) => { + connected = value + } + } +} +describe('relay discovery and offline replay', () => { + it('captures once and replays an exit that happened without a client', async () => { + const { server, request, frames, connect } = setup() + await server.discoverAgentPresence(request) + await server.discoverAgentPresence(request) + expect(request.discover).toHaveBeenCalledTimes(1) + connect(false) + probe.mockResolvedValue('exited') + await server.checkAgentPresence(paneKey) + expect(frames.at(-1)?.agentPresence?.ended).toBeUndefined() + connect(true) + server.replayCachedPayloadsForPanes() + expect(frames.at(-1)?.agentPresence).toMatchObject({ ...presence, ended: true }) + }) + it('rechecks a silent death on reconnect, then replays the settled fact', async () => { + const { server, request, frames } = setup() + await server.discoverAgentPresence(request) + probe.mockResolvedValue('exited') + server.replayCachedPayloadsForPanes() + await server.checkAgentPresence(paneKey) + expect(frames.at(-1)?.agentPresence?.ended).toBe(true) + server.replayCachedPayloadsForPanes() + expect(frames.at(-1)?.agentPresence?.ended).toBe(true) + }) + it('does not admit a detached terminal or resurrect the same ended process', async () => { + const { server, request, frames } = setup() + await server.discoverAgentPresence({ ...request, isCurrent: () => false }) + expect(frames).toEqual([]) + await server.discoverAgentPresence(request) + probe.mockResolvedValue('exited') + await server.checkAgentPresence(paneKey) + await server.discoverAgentPresence(request) + expect(frames.at(-1)?.agentPresence?.ended).toBe(true) + }) +}) diff --git a/src/relay/relay-agent-presence-discovery.ts b/src/relay/relay-agent-presence-discovery.ts new file mode 100644 index 000000000000..758183b9b415 --- /dev/null +++ b/src/relay/relay-agent-presence-discovery.ts @@ -0,0 +1,86 @@ +import { + AGENT_PROCESS_CAPTURE_EVENT, + type AgentProcessPresence +} from '../shared/agent-process-presence' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import { isAgentHookSource, type AgentHookSource } from '../shared/agent-hook-relay' + +export type RelayPresenceDiscoveryRequest = { + paneKey: string + tabId?: string + worktreeId?: string + terminalHandle?: string + isCurrent(): boolean + discover(): Promise +} + +export class RelayAgentPresenceDiscovery { + private generation = 0 + + clear(): void { + this.generation += 1 + this.pending.clear() + } + + private readonly pending = new Map>() + + constructor( + private readonly getRow: (paneKey: string) => AgentHookEventPayload | undefined, + private readonly publish: ( + event: AgentHookEventPayload, + source: AgentHookSource, + captured: AgentProcessPresence + ) => void + ) {} + + discover(request: RelayPresenceDiscoveryRequest): Promise { + const generation = this.generation + const current = () => this.getRow(request.paneKey) + const before = current() + if (before?.agentPresence?.process && !before.agentPresence.ended) { + return Promise.resolve() + } + const pending = this.pending.get(request.paneKey) + if (pending) { + return pending + } + const discovery = request + .discover() + .then((presence) => { + if ( + generation !== this.generation || + !presence?.process || + !isAgentHookSource(presence.agent) || + !request.isCurrent() || + current() !== before + ) { + return + } + const event = + before && !before.agentPresence?.ended + ? { ...before, agentPresence: presence } + : { + paneKey: request.paneKey, + tabId: request.tabId, + worktreeId: request.worktreeId, + terminalHandle: request.terminalHandle, + connectionId: null, + payload: { state: 'done' as const, prompt: '', agentType: presence.agent }, + agentPresence: presence + } + this.publish( + { ...event, hookEventName: AGENT_PROCESS_CAPTURE_EVENT }, + presence.agent, + presence + ) + }) + .catch(() => undefined) + .finally(() => { + if (this.pending.get(request.paneKey) === discovery) { + this.pending.delete(request.paneKey) + } + }) + this.pending.set(request.paneKey, discovery) + return discovery + } +} diff --git a/src/relay/relay-agent-presence-observation.ts b/src/relay/relay-agent-presence-observation.ts new file mode 100644 index 000000000000..df11bbd45a9e --- /dev/null +++ b/src/relay/relay-agent-presence-observation.ts @@ -0,0 +1,14 @@ +import { randomUUID } from 'node:crypto' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' + +export function createRelayAgentPresenceObservation() { + const epoch = randomUUID() + let sequence = 0 + return (event: AgentHookEventPayload): AgentHookEventPayload => ({ + ...event, + ...(event.agentPresence?.ended ? { providerSessionOnly: true } : {}), + ...(event.agentPresence?.process + ? { agentPresence: { ...event.agentPresence, observation: { epoch, sequence: ++sequence } } } + : {}) + }) +} diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts index 40026a5d7a31..c8dce2bb2799 100644 --- a/src/renderer/src/lib/tab-agent-from-signals.ts +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -1,18 +1,12 @@ import type { AgentProcessPresence } from '../../../shared/agent-process-presence' -import { isTuiAgent } from '../../../shared/tui-agent-config' +import { selectAgentPresence } from '../../../shared/agent-process-presence' import type { TuiAgent } from '../../../shared/tui-agent' import { resolveLegacyLaunchedAgentExitEvidence, resolveLegacyTabAgentFromSignals } from './legacy-unidentified-agent-presence' -/** Undefined means this host has not published a process identity. */ -export function selectAgentPresence(presence?: AgentProcessPresence): TuiAgent | null | undefined { - if (!presence?.process) { - return undefined - } - return !presence.ended && isTuiAgent(presence.agent) ? presence.agent : null -} +export { selectAgentPresence } from '../../../shared/agent-process-presence' export function resolveTabAgentFromSignals( args: Parameters[0] & { diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index d692a5b83d39..5c3f812c7dc1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -77,7 +77,9 @@ export function buildMobileSessionAgentStatusByWorktree( bucket = new Map() byWorktreeId.set(worktreeId, bucket) } - bucket.set(paneKey, agentStatusByPaneKey[paneKey]) + // The execution host projects presence; renderer publications carry turn presentation only. + const { agentPresence: _hostPresence, ...status } = agentStatusByPaneKey[paneKey] + bucket.set(paneKey, status) } return byWorktreeId } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts new file mode 100644 index 000000000000..3de3958fcea1 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) +const presence = { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' } +} as const +function snapshot(ended: boolean) { + return makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + title: 'zsh', + launchAgent: 'claude', + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 10, + stateStartedAt: 10, + paneKey: makePaneKey('host-tab-1', LEAF_ID), + agentType: 'claude', + stateHistory: [], + agentPresence: { ...presence, ...(ended ? { ended: true } : {}) } + } + } + ]) +} +describe('paired host presence', () => { + beforeEach(resetWebSessionTabsSyncTestState) + it('keeps host evidence clocks and applies an exit independently of client turn clocks', () => { + const state = makeState() + const first = applyWebSessionTabsSnapshot(state, snapshot(false), ENV, NOW) + expect(first?.agentPresenceByPaneKey).toBeDefined() + const paneKey = Object.keys(first?.agentPresenceByPaneKey ?? {})[0] + expect(first?.agentPresenceByPaneKey?.[paneKey]).toMatchObject({ + presence, + receivedAt: 10, + connectionId: ENV + }) + const mirrored = { ...state, ...first } + const turn = mirrored.agentStatusByPaneKey[paneKey] + mirrored.agentStatusByPaneKey = { + ...mirrored.agentStatusByPaneKey, + [paneKey]: { ...turn, state: 'working', updatedAt: NOW + 99999 } + } + const next = applyWebSessionTabsSnapshot(mirrored, snapshot(true), ENV, NOW + 1) + expect(next?.agentPresenceByPaneKey?.[paneKey]?.presence.ended).toBe(true) + expect(next?.agentStatusByPaneKey?.[paneKey]).toBeUndefined() + expect(next?.tabsByWorktree?.[WT] ?? mirrored.tabsByWorktree[WT]).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index e6166f809f75..d37244eaa384 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -1,3 +1,4 @@ +import { readAgentProcessPresence } from '../../../../shared/agent-process-presence' import { agentStatusAuthorityObservedAt, type AgentStatusEntry @@ -67,7 +68,10 @@ export function buildMirroredAgentStatusPatch( retractedTabIds: ReadonlySet, now: number, batchContext?: WebSessionTabsBatchContext -): Pick | null { +): Pick< + WebSessionTabsSyncState, + 'agentStatusByPaneKey' | 'agentPresenceByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' +> | null { const mirroredTabIds = new Set() for (const tab of currentTerminalTabs) { if (isWebTerminalSurfaceTabId(tab.id)) { @@ -94,6 +98,9 @@ export function buildMirroredAgentStatusPatch( ) } } + let nextPresenceByPaneKey = state.agentPresenceByPaneKey ?? {} + let presenceChanged = false + const endedPaneKeys = new Set() const nextByPaneKey = new Map() for (const surface of terminalSurfaceTabs) { const retainedSurface = retainedSurfaceByHostTabAndPrunedLeafId @@ -103,12 +110,43 @@ export function buildMirroredAgentStatusPatch( if (!hostEntry) { continue } + const presence = readAgentProcessPresence(hostEntry.agentPresence) + const previousPresence = nextPresenceByPaneKey[hostEntry.paneKey] + if ( + presence?.process && + (!previousPresence || + previousPresence.connectionId !== environmentId || + hostEntry.updatedAt >= previousPresence.receivedAt) && + (previousPresence?.receivedAt !== hostEntry.updatedAt || + previousPresence.connectionId !== environmentId || + JSON.stringify(previousPresence.presence) !== JSON.stringify(presence)) + ) { + nextPresenceByPaneKey = { + ...nextPresenceByPaneKey, + [hostEntry.paneKey]: { + presence, + receivedAt: hostEntry.updatedAt, + connectionId: environmentId, + worktreeId + } + } + presenceChanged = true + } + if ( + nextPresenceByPaneKey[hostEntry.paneKey]?.presence.ended && + nextPresenceByPaneKey[hostEntry.paneKey]?.presence.process + ) { + endedPaneKeys.add(hostEntry.paneKey) + continue + } const existing = nextByPaneKey.get(hostEntry.paneKey) ?? state.agentStatusByPaneKey[hostEntry.paneKey] const entry = withMirroredEvidenceReceipt( - hostEntry.connectionId === undefined - ? { ...hostEntry, connectionId: environmentId } - : hostEntry, + { + ...hostEntry, + worktreeId: hostEntry.worktreeId ?? worktreeId, + connectionId: hostEntry.connectionId ?? environmentId + }, existing, now ) @@ -132,6 +170,7 @@ export function buildMirroredAgentStatusPatch( ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' ? { workingMode: entry.workingMode } : {}), + agentPresence: entry.agentPresence, paneKey: entry.paneKey, worktreeId: entry.worktreeId ?? existing.worktreeId, tabId: entry.tabId, @@ -181,7 +220,10 @@ export function buildMirroredAgentStatusPatch( // there is nothing to arbitrate, and a client asleep past the stale // boundary would otherwise erase every pane it owns on the first snapshot // after wake (STA-3107) instead of decaying it like a local pane. - if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) { + if ( + !endedPaneKeys.has(paneKey) && + isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey]) + ) { continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { @@ -238,13 +280,17 @@ export function buildMirroredAgentStatusPatch( sortRelevantChange = sortRelevantChange || entrySortRelevantChange } - if (!changed) { + if (!changed && !presenceChanged) { return null } return { + ...(presenceChanged ? { agentPresenceByPaneKey: nextPresenceByPaneKey } : {}), agentStatusByPaneKey: nextAgentStatusByPaneKey, - agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch, - sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch + agentStatusEpoch: + aggregateRelevantChange || presenceChanged + ? state.agentStatusEpoch + 1 + : state.agentStatusEpoch, + sortEpoch: sortRelevantChange || presenceChanged ? state.sortEpoch + 1 : state.sortEpoch } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index a3587232e8eb..a098f487db19 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -48,9 +48,15 @@ export function remapHostAgentStatus( hookAgent: surface.agentStatus.agentType }) return { - ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, { - ownerIsLaunch: ownerRecord?.ownerIsLaunch === true - }), + ...normalizeCompatibleAgentStatusEntryForOwner( + surface.agentStatus, + surface.agentStatus.agentPresence?.process + ? surface.agentStatus.agentPresence.agent + : ownerRecord?.agent, + { + ownerIsLaunch: ownerRecord?.ownerIsLaunch === true + } + ), paneKey, tabId: toWebTerminalSurfaceTabId(surface.parentTabId) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index 407b7f6c2f3f..8676f1958392 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -43,6 +43,7 @@ export function agentStatusEntryEqual( return false } return ( + JSON.stringify(a.agentPresence) === JSON.stringify(b.agentPresence) && a.state === b.state && a.workingMode === b.workingMode && a.prompt === b.prompt && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 786b2b04e98e..29c503ed5d6c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -228,6 +228,7 @@ export type WebSessionTabsSyncState = Pick< Partial< Pick< AppState, + | 'agentPresenceByPaneKey' | 'acknowledgedAgentsByPaneKey' | 'activityClearedAtByPaneKey' | 'agentLaunchConfigByPaneKey' diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index a954313cf158..6a73275b16ba 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -6,6 +6,8 @@ import type { AgentProviderSessionMetadata } from '../agent-session-resume' export type AgentHookEventPayload = { paneKey: string agentPresence?: AgentProcessPresence + /** Internal admission from the authenticated relay ingress; never read from hook/client bytes. */ + agentPresenceFromExecutionHost?: true /** Authenticated hook route that produced this event. */ source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 46a71c1a7c4f..ced2cba888f5 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,10 +1,11 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' -import { isSameAgentProcess } from './agent-process-presence' +import { isSameAgentProcess, type AgentProcessPresence } from './agent-process-presence' /** A pane has one owning agent; only the owner's own proven process can end it. */ export function transitionHookPresence( incoming: AgentHookEventPayload, - previous: AgentHookEventPayload | undefined + previous: AgentHookEventPayload | undefined, + captured?: AgentProcessPresence ): AgentHookEventPayload | undefined { const recorded = previous?.agentPresence // Dismissing a turn does not release its identified process owner. @@ -16,6 +17,20 @@ export function transitionHookPresence( // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved // exit); other agents' SessionEnd hooks are ordinary status updates. const exit = incoming.agentPresence?.ended === true + // A replay is the relay store’s ordered snapshot, not a new hook from a possibly stale sender. + if ( + (!exit || incoming.isReplay === true) && + incoming.agentPresenceFromExecutionHost && + sender && + typeof incoming.connectionId === 'string' + ) { + return !exit && + recorded?.ended && + recorded.process && + isSameAgentProcess(recorded.process, sender) + ? undefined + : incoming + } if (owner) { if (exit) { const fromOwner = owner.process && sender && isSameAgentProcess(owner.process, sender) @@ -23,10 +38,19 @@ export function transitionHookPresence( ? { ...incoming, payload: previous?.payload ?? incoming.payload, - agentPresence: { ...owner, ended: true } + agentPresence: { + ...owner, + ...(incoming.agentPresenceFromExecutionHost && incoming.agentPresence?.observation + ? { observation: incoming.agentPresence.observation } + : {}), + ended: true + } } : undefined } + if (!owner.process && captured?.process && !captured.ended && captured.agent === owner.agent) { + return { ...incoming, agentPresence: captured } + } // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } } diff --git a/src/shared/agent-process-presence-candidate.test.ts b/src/shared/agent-process-presence-candidate.test.ts new file mode 100644 index 000000000000..ab1341889f38 --- /dev/null +++ b/src/shared/agent-process-presence-candidate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { selectDiscoveredAgentOwner } from './agent-process-presence-candidate' +const root = { pid: 10, ppid: 1, command: 'claude' } +describe('discovered process ownership', () => { + it('binds nested agents to the outer terminal owner', () => { + expect( + selectDiscoveredAgentOwner(12, [ + root, + { pid: 11, ppid: 10, command: 'sh' }, + { pid: 12, ppid: 11, command: 'claude' } + ]) + ).toEqual({ processId: 10, processName: 'claude' }) + expect(selectDiscoveredAgentOwner(12, [root, { pid: 12, ppid: 10, command: 'codex' }])).toEqual( + { processId: 10, processName: 'claude' } + ) + }) + it('refuses sibling owners and malformed ancestry', () => { + expect( + selectDiscoveredAgentOwner(10, [root, { pid: 12, ppid: 1, command: 'claude' }]) + ).toBeNull() + expect(selectDiscoveredAgentOwner(99, [root])).toBeNull() + }) + it('keeps the wrapper process as owner', () => { + expect( + selectDiscoveredAgentOwner(12, [ + { ...root, command: 'omp' }, + { pid: 12, ppid: 10, command: 'pi' } + ]) + ).toEqual({ processId: 10, processName: 'omp' }) + }) +}) diff --git a/src/shared/agent-process-presence-candidate.ts b/src/shared/agent-process-presence-candidate.ts new file mode 100644 index 000000000000..59482bc3a554 --- /dev/null +++ b/src/shared/agent-process-presence-candidate.ts @@ -0,0 +1,54 @@ +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import type { ForegroundAgentCandidate } from './foreground-wrapper-agent' + +/** Presence belongs to the outer agent, even while one of its nested agents owns the foreground. */ +export function selectDiscoveredAgentOwner( + processId: number, + candidates: readonly ForegroundAgentCandidate[] +): { processName: string; processId: number } | null { + const byPid = new Map(candidates.map((candidate) => [candidate.pid, candidate])) + let current = byPid.get(processId) + let owner: { processName: string; processId: number } | null = null + const seen = new Set() + while (current && !seen.has(current.pid)) { + seen.add(current.pid) + const recognized = + recognizeAgentProcessFromCommandLine(current.command) ?? + recognizeAgentProcessFromCommandLine(current.name) + if (recognized) { + owner = { processName: recognized.processName, processId: current.pid } + } + current = byPid.get(current.ppid) + } + if (!owner || current) { + return null + } + for (const candidate of candidates) { + if ( + !recognizeAgentProcessFromCommandLine(candidate.command) && + !recognizeAgentProcessFromCommandLine(candidate.name) + ) { + continue + } + let ancestor: ForegroundAgentCandidate | undefined = candidate + const visited = new Set() + while (ancestor && ancestor.pid !== owner.processId && !visited.has(ancestor.pid)) { + visited.add(ancestor.pid) + ancestor = byPid.get(ancestor.ppid) + } + if (ancestor?.pid !== owner.processId) { + return null + } + } + return owner +} + +export function projectDiscoveredAgentOwner( + identity: T, + candidates: readonly ForegroundAgentCandidate[], + capture: boolean | undefined +): T | { processName: string | null; processId?: number } { + return capture && identity.processId !== undefined + ? (selectDiscoveredAgentOwner(identity.processId, candidates) ?? { processName: null }) + : identity +} diff --git a/src/shared/agent-process-presence-probe.ts b/src/shared/agent-process-presence-probe.ts index 22fac38b7dbd..cd8c54382f21 100644 --- a/src/shared/agent-process-presence-probe.ts +++ b/src/shared/agent-process-presence-probe.ts @@ -72,7 +72,7 @@ export async function readAgentProcess(pid: number): Promise number | null +): (pid: number) => Promise { + return async (pid) => { + try { + const birth = readCreationTime(pid) + return birth !== null && Number.isFinite(birth) && birth > 0 + ? { verdict: 'live', startTime: String(birth), zombie: false } + : { verdict: 'unverifiable' } + } catch { + return { verdict: 'unverifiable' } + } + } +} diff --git a/src/shared/agent-process-presence-windows.test.ts b/src/shared/agent-process-presence-windows.test.ts new file mode 100644 index 000000000000..f4b605d4a9de --- /dev/null +++ b/src/shared/agent-process-presence-windows.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { + probeAgentProcessPresence, + windowsAgentProcessReader +} from './agent-process-presence-probe' +const identity = { pid: 42, platform: 'win32', startTime: '123' } as const +describe('Windows native birth observation', () => { + it('keeps missing and failed native reads unverifiable', async () => { + for (const native of [ + () => null, + () => { + throw new Error('denied') + } + ]) { + expect( + await probeAgentProcessPresence(identity, windowsAgentProcessReader(native), 'win32') + ).toBe('unverifiable') + } + }) + it('compares births without converting null into death', async () => { + expect( + await probeAgentProcessPresence( + identity, + windowsAgentProcessReader(() => 123), + 'win32' + ) + ).toBe('live') + expect( + await probeAgentProcessPresence( + identity, + windowsAgentProcessReader(() => 124), + 'win32' + ) + ).toBe('exited') + }) + it('does not read a foreign host platform', async () => { + const native = vi.fn(() => 123) + expect( + await probeAgentProcessPresence(identity, windowsAgentProcessReader(native), 'linux') + ).toBe('unverifiable') + expect(native).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index cbde61981bfd..4ffab2dfa00d 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -1,3 +1,5 @@ +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' import type { AgentType } from './agent-status-types' @@ -12,6 +14,8 @@ export type AgentProcessPresence = { agent: AgentType process?: AgentProcessIdentity ended?: true + /** Relay ordering survives replay and owner-only retention without renewing evidence. */ + observation?: AgentPresenceObservation } export type AgentProcessVerdict = 'live' | 'unverifiable' | 'exited' @@ -57,9 +61,12 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence | ) { return undefined } + const observation = + 'observation' in value ? readAgentPresenceObservation(value.observation) : undefined const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined return { agent: value.agent, + ...(observation ? { observation } : {}), ...(process ? { process } : {}), ...('ended' in value && value.ended === true ? { ended: true as const } : {}) } @@ -68,3 +75,33 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence | export function isSameAgentProcess(a: AgentProcessIdentity, b: AgentProcessIdentity): boolean { return a.pid === b.pid && a.platform === b.platform && a.startTime === b.startTime } + +/** Undefined means this host has not published a process identity. */ +export function selectAgentPresence(presence?: AgentProcessPresence): TuiAgent | null | undefined { + if (!presence?.process) { + return undefined + } + return !presence.ended && isTuiAgent(presence.agent) ? presence.agent : null +} + +export const AGENT_PROCESS_CAPTURE_EVENT = 'AgentProcessCaptured' + +export type AgentPresenceObservation = { epoch: string; sequence: number } + +export function readAgentPresenceObservation(value: unknown): AgentPresenceObservation | undefined { + if ( + !value || + typeof value !== 'object' || + !('epoch' in value) || + !('sequence' in value) || + typeof value.epoch !== 'string' || + value.epoch.length === 0 || + value.epoch.length > 128 || + typeof value.sequence !== 'number' || + !Number.isSafeInteger(value.sequence) || + value.sequence < 1 + ) { + return undefined + } + return { epoch: value.epoch, sequence: value.sequence } +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 5a9d34ae7d38..9ee92447eaae 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -1,3 +1,4 @@ +import type { AgentProcessPresence } from './agent-process-presence' // ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles; // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. @@ -74,6 +75,7 @@ export type AgentStatusOrchestrationContext = { } export type AgentStatusEntry = { + agentPresence?: AgentProcessPresence /** Renderer-local status-feed confirmation for children; absent on hook rows. */ subagentObservation?: 'live' | 'unverifiable' state: AgentStatusState diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d3880313897a..45457865d464 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -31,6 +31,8 @@ export { SKILL_INSTALL_RESULT_V2_CAPABILITY } from './skill-install-capability' // this client build requires a newer server. Exact app-version equality is // never required; these numbers define the supported compatibility window. +export const AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY = 'agent-process-presence.v1' as const + export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 @@ -316,6 +318,7 @@ export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY = export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -394,6 +397,7 @@ export const RUNTIME_CAPABILITIES = [ SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 445f7c7a5337..50eb6f340a11 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -3,6 +3,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, @@ -25,6 +26,7 @@ export function remoteRuntimeClientCapabilities( SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 3d95fb0a3dc9..afb89deceece 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentProcessPresence } from './agent-process-presence' import type { AgentStatusState, AgentType, AgentWorkingMode } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' import type { BaseRefSearchResult, Repo } from './repo-types' @@ -11,6 +12,7 @@ import type { RuntimeListingHostScope } from './runtime-listing-host-scope' import type { GitWorktreeInfo, Worktree } from './worktree/types' export type RuntimeWorktreeAgentRow = { + agentPresence?: AgentProcessPresence paneKey: string parentPaneKey: string | null state: AgentStatusState From 988a39764cbc4bd0cc34d781c4909cdf9acf76ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:35:41 -0700 Subject: [PATCH 02/27] refactor(agent-presence): move hookless and Windows capture out of step 3 Step 3 now only carries owners that hooks already identify (Claude on macOS/Linux, local and over SSH) to mobile, paired clients and the CLI. Hookless and native-Windows panes stay uncovered and keep step 2's behaviour; their capture moves to a follow-up with its own design. Removes process discovery on the host and relay, its title/command triggers and wiring, the Windows single-process reader, and the capture upgrade in the presence transition. The relay keeps stamping presence with its epoch/sequence. The remote replay tests now establish owners through Claude hooks carrying their process. The offline "replacement exits too" replay case is red after the step-2 merge (its cleanup rebuilds the ended owner from the stale mirror row); the host-frame replacement fix that follows makes it green. --- .../server-agent-presence-discovery.test.ts | 100 --------------- ...erver-agent-presence-remote-replay.test.ts | 72 +++++++---- .../server/server-agent-presence-discovery.ts | 119 ------------------ .../server/server-agent-presence.ts | 7 +- src/main/orcad/orcad-entry.ts | 1 - .../providers/agent-foreground-process.ts | 31 +---- .../providers/agent-presence-discovery.ts | 56 --------- .../providers/agent-process-presence-probe.ts | 11 -- .../windows-agent-foreground-process.ts | 19 +-- .../orca-runtime-preserved-branch-cleanup.ts | 5 - ...ntime-serialize-agent-prompt-submission.ts | 8 -- src/main/runtime/orca-runtime-state-fields.ts | 3 - .../runtime-agent-presence-discovery.ts | 53 -------- .../startup/main-process-runtime-service.ts | 1 - src/relay/agent-hook-cached-pane-status.ts | 16 +-- src/relay/agent-hook-server.ts | 50 ++++---- src/relay/pty-handler.ts | 19 --- src/relay/relay-agent-hook-runtime.ts | 11 +- .../relay-agent-presence-discovery.test.ts | 81 ------------ src/relay/relay-agent-presence-discovery.ts | 86 ------------- src/shared/agent-hook-presence-transition.ts | 16 +-- .../agent-process-presence-candidate.test.ts | 31 ----- .../agent-process-presence-candidate.ts | 54 -------- src/shared/agent-process-presence-probe.ts | 17 +-- .../agent-process-presence-windows.test.ts | 43 ------- src/shared/agent-process-presence.ts | 2 - 26 files changed, 96 insertions(+), 816 deletions(-) delete mode 100644 src/main/agent-hooks/server-agent-presence-discovery.test.ts delete mode 100644 src/main/agent-hooks/server/server-agent-presence-discovery.ts delete mode 100644 src/main/providers/agent-presence-discovery.ts delete mode 100644 src/main/providers/agent-process-presence-probe.ts delete mode 100644 src/main/runtime/runtime-agent-presence-discovery.ts delete mode 100644 src/relay/relay-agent-presence-discovery.test.ts delete mode 100644 src/relay/relay-agent-presence-discovery.ts delete mode 100644 src/shared/agent-process-presence-candidate.test.ts delete mode 100644 src/shared/agent-process-presence-candidate.ts delete mode 100644 src/shared/agent-process-presence-windows.test.ts diff --git a/src/main/agent-hooks/server-agent-presence-discovery.test.ts b/src/main/agent-hooks/server-agent-presence-discovery.test.ts deleted file mode 100644 index 2a8e29faf224..000000000000 --- a/src/main/agent-hooks/server-agent-presence-discovery.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AgentHookServer } from './server' -import { PANE } from './server.test-fixtures' -import type { AgentPresenceDiscoveryRequest } from './server/server-agent-presence-discovery' -import type { AgentProcessPresence } from '../../shared/agent-process-presence' - -vi.mock('../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) -const presence: AgentProcessPresence = { - agent: 'claude', - process: { pid: 42, platform: 'linux', startTime: 'boot:42' } -} -const servers: AgentHookServer[] = [] -afterEach(() => { - for (const server of servers.splice(0)) { - server.stop() - } -}) -function setup() { - const server = new AgentHookServer() - servers.push(server) - const request: AgentPresenceDiscoveryRequest = { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'folder-1', - ptyId: 'pty-1', - terminalHandle: 'terminal-1', - isCurrent: () => true, - discover: vi.fn(async () => presence) - } - return { server, request } -} -describe('execution host presence discovery', () => { - it('captures once and preserves an unidentified owner’s turn', async () => { - const { server, request } = setup() - server.ingestTerminalStatus({ - ...request, - payload: { agentType: 'claude', state: 'working', prompt: 'keep this turn' } - }) - const before = server.getStatusSnapshot()[0] - await server.discoverAgentPresence(request) - expect(server.getStatusSnapshot()[0]).toMatchObject({ ...before, agentPresence: presence }) - await server.discoverAgentPresence(request) - expect(request.discover).toHaveBeenCalledTimes(1) - }) - it('admits a hookless owner without manufacturing a working turn', async () => { - const { server, request } = setup() - await server.discoverAgentPresence(request) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', agentPresence: presence }) - }) - it('rejects a late result after the terminal incarnation changes', async () => { - const { server, request } = setup() - request.isCurrent = () => false - await server.discoverAgentPresence(request) - expect(server.getStatusSnapshot()).toEqual([]) - }) - it('rejects capture if a hook replaces the observed row during the read', async () => { - const { server, request } = setup() - request.discover = async () => { - server.ingestTerminalStatus({ - ...request, - payload: { agentType: 'codex', state: 'working', prompt: '' } - }) - return presence - } - await server.discoverAgentPresence(request) - expect(server.getStatusSnapshot()[0]?.agentPresence?.process).toBeUndefined() - }) - it('does not let a nested different agent replace an unidentified owner', async () => { - const { server, request } = setup() - server.ingestTerminalStatus({ - ...request, - payload: { agentType: 'codex', state: 'working', prompt: '' } - }) - await server.discoverAgentPresence(request) - expect(server.getStatusSnapshot()[0]?.agentPresence).toEqual({ agent: 'codex' }) - }) -}) - -it('accepts a relay host capture for an unidentified remote owner without probing its PID locally', async () => { - const { server, request } = setup() - const event = { - paneKey: request.paneKey, - tabId: request.tabId, - worktreeId: request.worktreeId, - source: 'claude', - payload: { agentType: 'claude', state: 'done', prompt: '' } - } - server.ingestRemote(event, 'ssh-1') - server.ingestRemote( - { - ...event, - hookEventName: 'AgentProcessCaptured', - agentPresence: { ...presence, observation: { epoch: 'relay', sequence: 1 } } - }, - 'ssh-1' - ) - expect(server.getStatusSnapshot()[0]?.agentPresence).toMatchObject(presence) - await expect(server.checkAgentPresence(request.paneKey)).resolves.toBe('unverifiable') -}) diff --git a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts index 0b3e7a969da4..5c453d5c7fb8 100644 --- a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts +++ b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts @@ -1,9 +1,13 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { transitionHookPresence } from '../../shared/agent-hook-presence-transition' import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { RelayAgentHookServer } from '../../relay/agent-hook-server' -import { PANE } from './server.test-fixtures' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' +import type { AgentProcessIdentity } from '../../shared/agent-process-presence' import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' const probe = vi.hoisted(() => @@ -17,20 +21,35 @@ const owner = { process: { pid: 42, platform: 'linux', startTime: 'boot:42' } } as const const replacement = { - agent: 'codex', + agent: 'claude', process: { pid: 43, platform: 'linux', startTime: 'boot:43' } } as const const servers: { stop(): void }[] = [] +const dirs: string[] = [] afterEach(() => { servers.splice(0).forEach((server) => server.stop()) + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) probe.mockResolvedValue('live') }) -function setup() { +function claudeHook( + event: string, + session: string, + agentProcess: AgentProcessIdentity, + extra: Record = {} +) { + return buildBody( + { hook_event_name: event, session_id: session, source: 'startup', ...extra }, + { worktreeId: 'folder-1', agentProcess: JSON.stringify(agentProcess) } + ) +} +async function setup() { let connected = true const main = new AgentHookServer() const frames: AgentHookRelayEnvelope[] = [] + const dir = mkdtempSync(join(tmpdir(), 'remote-replay-')) + dirs.push(dir) const relay = new RelayAgentHookServer({ - endpointDir: '/unused-replay-test', + endpointDir: dir, forward: (event) => { frames.push(event) if (connected) { @@ -39,10 +58,22 @@ function setup() { } }) servers.push(main, relay) - const request = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'folder-1', isCurrent: () => true } + await relay.start() + const request = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'folder-1' } + // Why: Claude hooks carry the sender's process, so they establish the relay's owner. + const relayHook = async (...args: Parameters) => { + const { port, token } = relay.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify(claudeHook(...args)) + }) + expect(response.status).toBe(204) + } return { main, relay, + relayHook, request, frames, connect: (value: boolean) => { @@ -54,13 +85,13 @@ describe('execution host presence replay', () => { it.each([false, true])( 'converges after offline exit and replacement (replacement ended=%s)', async (ended) => { - const { main, relay, request, frames, connect } = setup() - await relay.discoverAgentPresence({ ...request, discover: async () => owner }) + const { main, relay, relayHook, frames, connect } = await setup() + await relayHook('SessionStart', 'a', owner.process) connect(false) probe.mockResolvedValue('exited') await relay.checkAgentPresence(PANE) const staleExit = frames.at(-1)! - await relay.discoverAgentPresence({ ...request, discover: async () => replacement }) + await relayHook('SessionStart', 'b', replacement.process) if (ended) { await relay.checkAgentPresence(PANE) } else { @@ -81,25 +112,20 @@ describe('execution host presence replay', () => { } ) it('retains the ordering of a connected exit when an older live frame replays', async () => { - const { main, relay, request, frames } = setup() - await relay.discoverAgentPresence({ ...request, discover: async () => owner }) + const { main, relay, relayHook, frames } = await setup() + await relayHook('SessionStart', 'a', owner.process) const staleLive = frames.at(-1)! probe.mockResolvedValue('exited') await relay.checkAgentPresence(PANE) const exited = main.getStatusSnapshot()[0]?.agentPresence - expect(exited?.observation).toEqual(frames.at(-1)?.agentPresence?.observation) main.ingestRemote({ ...staleLive, isReplay: true }, 'ssh-1') expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(exited) expect(exited?.ended).toBe(true) }) it('does not accept host provenance from local hook or terminal bytes', async () => { - const { main, request } = setup() - await main.discoverAgentPresence({ - ...request, - ptyId: 'pty', - terminalHandle: 'terminal', - discover: async () => owner - }) + const { main, request } = await setup() + await main.start({ env: 'production' }) + await postHookEvent(main, claudeHook('SessionStart', 'a', owner.process)) const normalized = normalizeHookPayload( createHookListenerState(), 'claude', @@ -136,8 +162,8 @@ describe('execution host presence replay', () => { main.ingestTerminalStatus(bytes) expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(owner) }) - it('keeps old relay omission and raw mismatched exits on the legacy path', () => { - const { main, request } = setup() + it('keeps mismatched exits from an old relay on the exact-owner fence', async () => { + const { main, request } = await setup() const event = { ...request, source: 'claude', @@ -145,9 +171,9 @@ describe('execution host presence replay', () => { agentPresence: owner } main.ingestRemote(event, 'ssh-1') - main.ingestRemote({ ...event, agentPresence: replacement }, 'ssh-1') - expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(owner) - main.ingestRemote({ ...event, agentPresence: { ...replacement, ended: true } }, 'ssh-1') + const mismatchedExit = { ...event, agentPresence: { ...replacement, ended: true } } as const + main.ingestRemote(mismatchedExit, 'ssh-1') + main.ingestRemote({ ...mismatchedExit, isReplay: true }, 'ssh-1') expect(main.getStatusSnapshot()[0]?.agentPresence).toEqual(owner) }) }) diff --git a/src/main/agent-hooks/server/server-agent-presence-discovery.ts b/src/main/agent-hooks/server/server-agent-presence-discovery.ts deleted file mode 100644 index fb119991852a..000000000000 --- a/src/main/agent-hooks/server/server-agent-presence-discovery.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - isSameAgentProcess, - type AgentProcessPresence -} from '../../../shared/agent-process-presence' -import type { EnrichedAgentHookEventPayload } from './server-types' -import { AgentHookServerLifecycle } from './server-lifecycle' - -export type AgentPresenceDiscoveryRequest = { - paneKey: string - ptyId: string - tabId: string - worktreeId: string - terminalHandle: string - isCurrent(): boolean - discover(): Promise -} - -export abstract class AgentHookServerPresenceDiscovery extends AgentHookServerLifecycle { - private discoveryGeneration = 0 - - override stop(): void { - this.discoveryGeneration += 1 - this.presenceDiscoveries.clear() - super.stop() - } - - private readonly presenceDiscoveries = new Map>() - - discoverAgentPresence(request: AgentPresenceDiscoveryRequest): Promise { - const generation = this.discoveryGeneration - const paneKey = this.resolvePaneKeyAlias(request.paneKey) - const before = this.state.lastStatusByPaneKey.get(paneKey) - if (before?.agentPresence?.process && !before.agentPresence.ended) { - return Promise.resolve() - } - if (before && (before.connectionId !== null || before.worktreeId !== request.worktreeId)) { - return Promise.resolve() - } - const pending = this.presenceDiscoveries.get(paneKey) - if (pending) { - return pending - } - const discovery = request - .discover() - .then((presence) => { - if ( - generation !== this.discoveryGeneration || - !presence?.process || - presence.ended || - !request.isCurrent() || - this.resolvePaneKeyAlias(request.paneKey) !== paneKey || - this.state.lastStatusByPaneKey.get(paneKey) !== before || - this.getAgentStatusDisposition(paneKey) !== 'accept' - ) { - return - } - if ( - before?.agentPresence?.ended && - before.agentPresence.process && - isSameAgentProcess(before.agentPresence.process, presence.process) - ) { - return - } - if ( - before?.agentPresence && - !before.agentPresence.ended && - before.agentPresence.agent !== presence.agent - ) { - return - } - if (!before) { - this.ingestTerminalStatus({ - ...request, - connectionId: null, - payload: { state: 'done', prompt: '', agentType: presence.agent } - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main stores only timing-enriched rows through its validated ingress. - const row = this.state.lastStatusByPaneKey.get(paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!row || row.connectionId !== null || row.worktreeId !== request.worktreeId) { - return - } - if (before && row !== before) { - return - } - const observedAt = Math.max(Date.now(), row.receivedAt + 1) - const captured: EnrichedAgentHookEventPayload = before?.agentPresence?.ended - ? { - paneKey, - tabId: request.tabId, - worktreeId: request.worktreeId, - terminalHandle: request.terminalHandle, - connectionId: null, - payload: { state: 'done', prompt: '', agentType: presence.agent }, - agentPresence: presence, - receivedAt: observedAt, - stateStartedAt: observedAt - } - : { ...row, agentPresence: presence } - if (!this.writeLegacyStatusRow(captured)) { - return - } - this.commitStatusRowMutation(row, captured) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(captured) - }) - .catch(() => undefined) - .finally(() => { - if (this.presenceDiscoveries.get(paneKey) === discovery) { - this.presenceDiscoveries.delete(paneKey) - } - }) - this.presenceDiscoveries.set(paneKey, discovery) - return discovery - } -} diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index 52d07870a254..88654e90209e 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -1,4 +1,3 @@ -import { readHostAgentProcess } from '../../providers/agent-process-presence-probe' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { isSameAgentProcess, @@ -7,10 +6,10 @@ import { type AgentProcessVerdict } from '../../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' -import { AgentHookServerPresenceDiscovery } from './server-agent-presence-discovery' import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerLifecycle } from './server-lifecycle' -export abstract class AgentHookServerAgentPresence extends AgentHookServerPresenceDiscovery { +export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecycle { private readonly presenceChecks = new WeakMap< AgentHookEventPayload, Promise @@ -69,7 +68,7 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerPresen if (pending && !successor) { return pending } - const check = probeAgentProcessPresence(owner, readHostAgentProcess) + const check = probeAgentProcessPresence(owner) .then((verdict) => { // Why: fence on the owner, not the row object — cleanup can rewrite the row mid-probe. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Server admission enriches every stored row with receipt and turn clocks. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index fbffcbe541b3..944105ff4b25 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -235,7 +235,6 @@ async function startOrcadRuntime( publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, - discoverHookAgentPresence: (request) => agentHookServer.discoverAgentPresence(request), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, { kind: 'terminal-ended' }), diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 8b030973608b..84b147ca3e95 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,6 +1,5 @@ -import { selectDiscoveredAgentOwner } from '../../shared/agent-process-presence-candidate' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { resolveOuterWrapperForegroundIdentity } from '../../shared/foreground-wrapper-agent' +import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import type { ProcessTableRow } from '../../shared/process-table-snapshot' import { getFreshProcessTableSnapshot, @@ -38,7 +37,7 @@ export type AgentForegroundProcessResolution = { /** * Windows: pid of the process a recognized name belongs to — a liveness * anchor callers may check against the pane's job. Absent when the name is a - * fallback or ambiguous. + * fallback, ambiguous, or resolved on POSIX (where `+` already marks it). */ processId?: number /** Windows: the scan proved the caller's `anchorProcessId` is now a non-agent. */ @@ -167,18 +166,9 @@ export async function resolveAgentForegroundProcessWithAvailability( if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) { return { available: false, processName: fallbackProcess } } - const foreground = resolveAgentForegroundIdentityFromPs(rows, shellPid) - const identity = - options.capturePresence && foreground - ? selectDiscoveredAgentOwner( - foreground.processId, - collectDescendantsFromIndex(getProcessTableIndex(rows), shellPid) - ) - : foreground return { available: true, - processName: identity?.processName ?? fallbackProcess, - ...(identity ? { processId: identity.processId } : {}) + processName: resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess } } catch { // Why: a failed scan cannot prove fallback ownership; callers retain the last recognized agent. @@ -190,13 +180,6 @@ export function resolveAgentForegroundProcessFromPs( rows: readonly ProcessTableRow[], shellPid: number ): string | null { - return resolveAgentForegroundIdentityFromPs(rows, shellPid)?.processName ?? null -} - -export function resolveAgentForegroundIdentityFromPs( - rows: readonly ProcessTableRow[], - shellPid: number -): { processName: string; processId: number } | null { // Memoized per snapshot identity, so the caller's own index build is reused. const index = getProcessTableIndex(rows) const shellRow = index.byPid.get(shellPid) @@ -217,12 +200,8 @@ export function resolveAgentForegroundIdentityFromPs( const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates) if (selected) { // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundIdentity. - return resolveOuterWrapperForegroundIdentity( - selected.recognized, - selected.candidate, - candidates - ) + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) } return null } diff --git a/src/main/providers/agent-presence-discovery.ts b/src/main/providers/agent-presence-discovery.ts deleted file mode 100644 index 4ea492fbdf1d..000000000000 --- a/src/main/providers/agent-presence-discovery.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { recognizeAgentProcess } from '../../shared/agent-process-recognition' -import type { AgentProcessPresence } from '../../shared/agent-process-presence' -import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' -import { readHostAgentProcess } from './agent-process-presence-probe' - -export async function discoverLocalAgentPresence( - rootProcessId: number -): Promise { - const platform = process.platform - if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') { - return undefined - } - const candidate = await resolveAgentForegroundProcessWithAvailability( - rootProcessId, - platform === 'win32' ? 'cmd.exe' : 'sh', - { - capturePresence: true, - fresh: true, - forceProcessScan: true - } - ) - const agent = recognizeAgentProcess(candidate.processName)?.agent - if (!candidate.available || !candidate.processId || !agent) { - return undefined - } - const birth = await readHostAgentProcess(candidate.processId) - if (birth.verdict !== 'live' || birth.zombie || birth.stopped) { - return undefined - } - const confirmed = await resolveAgentForegroundProcessWithAvailability( - rootProcessId, - platform === 'win32' ? 'cmd.exe' : 'sh', - { - capturePresence: true, - fresh: true, - forceProcessScan: true - } - ) - if ( - !confirmed.available || - confirmed.processId !== candidate.processId || - recognizeAgentProcess(confirmed.processName)?.agent !== agent - ) { - return undefined - } - const finalBirth = await readHostAgentProcess(candidate.processId) - if ( - finalBirth.verdict !== 'live' || - finalBirth.startTime !== birth.startTime || - finalBirth.zombie || - finalBirth.stopped - ) { - return undefined - } - return { agent, process: { pid: candidate.processId, platform, startTime: birth.startTime } } -} diff --git a/src/main/providers/agent-process-presence-probe.ts b/src/main/providers/agent-process-presence-probe.ts deleted file mode 100644 index 58c0270447b3..000000000000 --- a/src/main/providers/agent-process-presence-probe.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - readAgentProcess, - windowsAgentProcessReader -} from '../../shared/agent-process-presence-probe' -import { readWindowsProcessCreationTime } from '../windows/windows-process-table' - -export async function readHostAgentProcess(pid: number) { - return process.platform === 'win32' - ? windowsAgentProcessReader(readWindowsProcessCreationTime)(pid) - : readAgentProcess(pid) -} diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 509f6b8fe39d..8ffba73c24d9 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -1,4 +1,3 @@ -import { projectDiscoveredAgentOwner } from '../../shared/agent-process-presence-candidate' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -18,7 +17,6 @@ import { } from './windows-foreground-process-rows' export type AgentForegroundResolutionOptions = { - capturePresence?: boolean contextPaths?: readonly string[] /** Require a Windows process-table scan started after this request. */ fresh?: boolean @@ -26,8 +24,13 @@ export type AgentForegroundResolutionOptions = { forceProcessScan?: boolean /** Lazily proves which global descendants still belong to this ConPTY. */ readWindowsConsoleAttachedProcessIds?: () => Promise | null> - /** Detects PID reuse when the cached agent no longer matches. */ + /** + * A caller's cached liveness anchor. When a scan row holds this pid but no + * longer recognizes as the cached agent, the pid was recycled by a different + * process (command lines are immutable): the resolution reports it foreign. + */ anchorProcessId?: number + /** The cached agent name the anchor pid is supposed to prove. */ anchorProcessName?: string } @@ -118,11 +121,7 @@ export async function resolveWindowsAgentForegroundProcessWithAvailability( anchorRow.command !== anchorRow.name) return { available: true, - ...projectDiscoveredAgentOwner( - resolveWindowsForegroundIdentity(filteredCandidates, fallbackProcess, options.contextPaths), - filteredCandidates, - options.capturePresence - ), + ...resolveWindowsForegroundIdentity(filteredCandidates, fallbackProcess, options.contextPaths), ...(anchorPidForeign ? { anchorPidForeign: true } : {}) } } @@ -300,7 +299,9 @@ function candidateMatchesContextPath( return false } const haystack = normalizePathForCommandMatch(candidate.command) - return normalizedContextPaths.some((path) => commandLineContainsPath(haystack, path)) + return normalizedContextPaths.some((contextPath) => + commandLineContainsPath(haystack, contextPath) + ) } function normalizePathForCommandMatch(value: string): string { diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 9aea18622a90..37f8121f99ae 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -1,5 +1,4 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import type { AgentPresenceDiscoveryRequest } from '../agent-hooks/server/server-agent-presence-discovery' import { OrcaRuntimeWithTerminalDrivers } from './orca-runtime-terminal-drivers' import { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import type { IPtyProvider } from '../providers/types' @@ -91,10 +90,6 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null - protected readonly discoverHookAgentPresenceFn: - | ((request: AgentPresenceDiscoveryRequest) => Promise) - | null - protected readonly checkHookAgentPresenceFn: | ((paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>) | null diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 08fbffd81722..44fa2411df15 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,5 +1,4 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { discoverRuntimeAgentPresence } from './runtime-agent-presence-discovery' import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' @@ -77,13 +76,6 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if (!this.checkHookAgentPresenceFn) { return null } - await discoverRuntimeAgentPresence({ - pty: this.ptysById.get(ptyId), - current: () => this.ptysById.get(ptyId), - issueHandle: (pty) => this.issuePtyHandle(pty), - provider: this.getLocalProviderFn, - admit: this.discoverHookAgentPresenceFn - }) const verdicts = await Promise.all( Array.from(this.collectAgentStatusPaneKeysForPty(ptyId), (paneKey) => this.checkHookAgentPresenceFn(paneKey) diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 0b97b7c16917..ca95bf9dc270 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -1,5 +1,4 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import type { AgentPresenceDiscoveryRequest } from '../agent-hooks/server/server-agent-presence-discovery' import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands' import type { RuntimeStore } from './runtime-store-contract' import type { StatsCollector } from '../stats/collector' @@ -83,7 +82,6 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void - discoverHookAgentPresence?: (request: AgentPresenceDiscoveryRequest) => Promise checkHookAgentPresence?: ( paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> @@ -235,7 +233,6 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null - this.discoverHookAgentPresenceFn = deps?.discoverHookAgentPresence ?? null this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) diff --git a/src/main/runtime/runtime-agent-presence-discovery.ts b/src/main/runtime/runtime-agent-presence-discovery.ts deleted file mode 100644 index 1f085907fbf8..000000000000 --- a/src/main/runtime/runtime-agent-presence-discovery.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AgentPresenceDiscoveryRequest } from '../agent-hooks/server/server-agent-presence-discovery' -import type { IPtyProvider } from '../providers/types' -import { discoverLocalAgentPresence } from '../providers/agent-presence-discovery' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -export async function discoverRuntimeAgentPresence(args: { - pty: RuntimePtyWorktreeRecord | undefined - current(): RuntimePtyWorktreeRecord | undefined - issueHandle(pty: RuntimePtyWorktreeRecord): string - provider: (() => IPtyProvider) | null - admit: ((request: AgentPresenceDiscoveryRequest) => Promise) | null -}): Promise { - const { pty, admit, provider } = args - if ( - !pty || - !admit || - !provider || - pty.connectionId || - pty.isWsl || - (process.platform === 'win32' && pty.isWsl !== false) || - !pty.incarnationId || - !pty.tabId || - !pty.paneKey - ) { - return - } - const { incarnationId, tabId, paneKey, worktreeId } = pty - const isCurrent = () => - args.current() === pty && - pty.incarnationId === incarnationId && - pty.paneKey === paneKey && - pty.tabId === tabId && - pty.worktreeId === worktreeId && - !pty.connectionId - await admit({ - paneKey, - tabId, - worktreeId, - ptyId: pty.ptyId, - terminalHandle: args.issueHandle(pty), - isCurrent, - discover: async () => { - const inventory = await provider().listProcesses() - const owned = inventory.find( - (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === incarnationId - ) - if (!isCurrent() || !owned?.rootProcessId || owned.wslDistro) { - return undefined - } - return discoverLocalAgentPresence(owned.rootProcessId) - } - }) -} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 682ece483f93..36283eb58ea6 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -116,7 +116,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), - discoverHookAgentPresence: (request) => agentHookServer.discoverAgentPresence(request), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, { kind: 'terminal-ended' }), diff --git a/src/relay/agent-hook-cached-pane-status.ts b/src/relay/agent-hook-cached-pane-status.ts index 107548be0a5b..2302dae587f8 100644 --- a/src/relay/agent-hook-cached-pane-status.ts +++ b/src/relay/agent-hook-cached-pane-status.ts @@ -1,6 +1,5 @@ -import { buildRelayHookEnvelope } from './agent-hook-envelope-build' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' -import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' +import type { AgentHookSource } from '../shared/agent-hook-relay' export type CachedPaneEnvelopeMeta = { source: AgentHookSource; env?: string; version?: string } @@ -59,16 +58,3 @@ export function evictCachedPanesOverCap( dropPane(oldest) } } - -export function replayCachedPanePresence( - input: Parameters[0], - check: (paneKey: string) => Promise, - forward: (event: AgentHookRelayEnvelope) => void -): number { - const replayable = selectReplayableCachedPanes(input) - for (const { event, meta } of replayable) { - void check(event.paneKey) - forward(buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true })) - } - return replayable.length -} diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 079c7e05331b..4f411dc3ce6c 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,9 +1,8 @@ import { createRelayAgentPresenceObservation } from './relay-agent-presence-observation' -import { RelayAgentPresenceDiscovery } from './relay-agent-presence-discovery' import { handleRelayHookRequest } from './agent-hook-request' import { transitionHookPresence } from '../shared/agent-hook-presence-transition' import { RelayAgentPresence } from './relay-agent-presence' -import { isSameAgentProcess, type AgentProcessPresence } from '../shared/agent-process-presence' +import { isSameAgentProcess } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -15,7 +14,8 @@ import { import { clearAllListenerCaches, clearPaneCacheState, - createHookListenerState + createHookListenerState, + type HookListenerState } from '../shared/agent-hook-listener/listener-state' import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' import { @@ -42,7 +42,7 @@ import { import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' -import { MAX_CACHED_PANES, replayCachedPanePresence } from './agent-hook-cached-pane-status' +import { MAX_CACHED_PANES, selectReplayableCachedPanes } from './agent-hook-cached-pane-status' export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void @@ -76,7 +76,7 @@ export class RelayAgentHookServer { private endpointDir: string private endpointFilePath: string private endpointFileWritten = false - private state = createHookListenerState() + private state: HookListenerState = createHookListenerState() private transportInterference = createHookTransportInterferenceTracker((report) => { process.stderr.write(`${describeHookTransportInterference(report)}\n`) }) @@ -91,11 +91,6 @@ export class RelayAgentHookServer { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false - private readonly presenceDiscoveries = new RelayAgentPresenceDiscovery( - (paneKey) => this.state.lastStatusByPaneKey.get(paneKey), - (event, source, captured) => - this.applyEvent(event, source, this.env, ORCA_HOOK_PROTOCOL_VERSION, { captured }) - ) private readonly observePresence = createRelayAgentPresenceObservation() private readonly presenceChecks = new RelayAgentPresence() private retryScheduler: AgentHookResultRetryScheduler @@ -206,7 +201,6 @@ export class RelayAgentHookServer { this.token = '' this.endpointFileWritten = false this.retryScheduler.clearAll() - this.presenceDiscoveries.clear() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() } @@ -214,24 +208,27 @@ export class RelayAgentHookServer { /** Request-driven replay: re-forwards each cached paneKey payload as a fresh notification. Forwards are * issued before the request handler returns, so the response trails all replayed notifications. */ replayCachedPayloadsForPanes(): number { - return replayCachedPanePresence( - { - cachedByPaneKey: this.state.lastStatusByPaneKey, - metaByPaneKey: this.lastEnvelopeMetaByPaneKey, - isPaneSurfaceRetired: this.isPaneSurfaceRetired, - dropPane: (paneKey) => this.clearPaneState(paneKey) - }, - (paneKey) => this.checkAgentPresence(paneKey), - this.forward - ) + const cachedSnapshot = new Map(this.state.lastStatusByPaneKey) + const replayable = selectReplayableCachedPanes({ + cachedByPaneKey: cachedSnapshot, + metaByPaneKey: this.lastEnvelopeMetaByPaneKey, + isPaneSurfaceRetired: this.isPaneSurfaceRetired, + dropPane: (paneKey) => this.clearPaneState(paneKey) + }) + for (const { event, meta } of replayable) { + void this.checkAgentPresence(event.paneKey) + this.forward( + buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true }) + ) + } + return replayable.length } - discoverAgentPresence = this.presenceDiscoveries.discover.bind(this.presenceDiscoveries) - checkAgentPresence(paneKey: string): Promise { + const row = this.state.lastStatusByPaneKey.get(paneKey) const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) return this.presenceChecks.check( - this.state.lastStatusByPaneKey.get(paneKey), + row, () => this.state.lastStatusByPaneKey.get(paneKey), (event) => { if (meta) { @@ -283,12 +280,11 @@ export class RelayAgentHookServer { source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean; checkPresence?: boolean; captured?: AgentProcessPresence } = {} + options: { isReplay?: boolean; checkPresence?: boolean } = {} ): AgentHookEventPayload | undefined { const transitioned = transitionHookPresence( incoming, - this.state.lastStatusByPaneKey.get(incoming.paneKey), - options.captured + this.state.lastStatusByPaneKey.get(incoming.paneKey) ) if (!transitioned) { return undefined diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fa7b6335463f..53d666a12f58 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -719,25 +719,6 @@ export class PtyHandler { private agentPresenceTrigger: ((paneKey: string) => void) | null = null - getAgentPresenceDiscoveryTarget(paneKey: string) { - const managed = Array.from(this.ptys.values()).find((pty) => pty.paneKey === paneKey) - if (!managed || managed.disposed || managed.wslDistro || process.platform === 'win32') { - return undefined - } - const incarnation = managed.incarnationId - return { - rootProcessId: managed.pty.pid, - tabId: managed.tabId, - worktreeId: managed.worktreeId, - terminalHandle: managed.terminalHandle, - isCurrent: () => - this.ptys.get(managed.id) === managed && - !managed.disposed && - managed.incarnationId === incarnation && - managed.paneKey === paneKey - } - } - setAgentPresenceTrigger(listener: ((paneKey: string) => void) | null): void { this.agentPresenceTrigger = listener } diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 247e88d70357..6c21a9d6fc70 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -1,4 +1,3 @@ -import { discoverLocalAgentPresence } from '../main/providers/agent-presence-discovery' import type { RelayDispatcher } from './dispatcher' import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' import { RelayAgentHookServer } from './agent-hook-server' @@ -66,15 +65,7 @@ export class RelayAgentHookRuntime { private registerPtyEnvironment(): void { this.ptyHandler.setAgentPresenceTrigger((paneKey) => { - const target = this.ptyHandler.getAgentPresenceDiscoveryTarget(paneKey) - const capture = target - ? this.hookServer.discoverAgentPresence({ - ...target, - paneKey, - discover: () => discoverLocalAgentPresence(target.rootProcessId) - }) - : Promise.resolve() - void capture.then(() => this.hookServer.checkAgentPresence(paneKey)) + void this.hookServer.checkAgentPresence(paneKey) }) this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) diff --git a/src/relay/relay-agent-presence-discovery.test.ts b/src/relay/relay-agent-presence-discovery.test.ts deleted file mode 100644 index 38d0542cabc3..000000000000 --- a/src/relay/relay-agent-presence-discovery.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayAgentHookServer } from './agent-hook-server' -import { makePaneKey } from '../shared/stable-pane-id' -import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' -const probe = vi.hoisted(() => - vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') -) -vi.mock('../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) -const paneKey = makePaneKey('tab', '11111111-1111-4111-8111-111111111111') -const presence = { - agent: 'claude', - process: { pid: 42, platform: 'linux', startTime: 'boot:42' } -} as const -const servers: RelayAgentHookServer[] = [] -afterEach(() => { - servers.splice(0).forEach((server) => server.stop()) - probe.mockResolvedValue('live') -}) -function setup() { - let connected = true - const frames: AgentHookRelayEnvelope[] = [] - const server = new RelayAgentHookServer({ - endpointDir: '/unused-presence-test', - forward: (event) => { - if (connected) { - frames.push(event) - } - } - }) - servers.push(server) - const request = { - paneKey, - tabId: 'tab', - worktreeId: 'folder', - isCurrent: () => true, - discover: vi.fn(async () => presence) - } - return { - server, - request, - frames, - connect: (value: boolean) => { - connected = value - } - } -} -describe('relay discovery and offline replay', () => { - it('captures once and replays an exit that happened without a client', async () => { - const { server, request, frames, connect } = setup() - await server.discoverAgentPresence(request) - await server.discoverAgentPresence(request) - expect(request.discover).toHaveBeenCalledTimes(1) - connect(false) - probe.mockResolvedValue('exited') - await server.checkAgentPresence(paneKey) - expect(frames.at(-1)?.agentPresence?.ended).toBeUndefined() - connect(true) - server.replayCachedPayloadsForPanes() - expect(frames.at(-1)?.agentPresence).toMatchObject({ ...presence, ended: true }) - }) - it('rechecks a silent death on reconnect, then replays the settled fact', async () => { - const { server, request, frames } = setup() - await server.discoverAgentPresence(request) - probe.mockResolvedValue('exited') - server.replayCachedPayloadsForPanes() - await server.checkAgentPresence(paneKey) - expect(frames.at(-1)?.agentPresence?.ended).toBe(true) - server.replayCachedPayloadsForPanes() - expect(frames.at(-1)?.agentPresence?.ended).toBe(true) - }) - it('does not admit a detached terminal or resurrect the same ended process', async () => { - const { server, request, frames } = setup() - await server.discoverAgentPresence({ ...request, isCurrent: () => false }) - expect(frames).toEqual([]) - await server.discoverAgentPresence(request) - probe.mockResolvedValue('exited') - await server.checkAgentPresence(paneKey) - await server.discoverAgentPresence(request) - expect(frames.at(-1)?.agentPresence?.ended).toBe(true) - }) -}) diff --git a/src/relay/relay-agent-presence-discovery.ts b/src/relay/relay-agent-presence-discovery.ts deleted file mode 100644 index 758183b9b415..000000000000 --- a/src/relay/relay-agent-presence-discovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - AGENT_PROCESS_CAPTURE_EVENT, - type AgentProcessPresence -} from '../shared/agent-process-presence' -import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' -import { isAgentHookSource, type AgentHookSource } from '../shared/agent-hook-relay' - -export type RelayPresenceDiscoveryRequest = { - paneKey: string - tabId?: string - worktreeId?: string - terminalHandle?: string - isCurrent(): boolean - discover(): Promise -} - -export class RelayAgentPresenceDiscovery { - private generation = 0 - - clear(): void { - this.generation += 1 - this.pending.clear() - } - - private readonly pending = new Map>() - - constructor( - private readonly getRow: (paneKey: string) => AgentHookEventPayload | undefined, - private readonly publish: ( - event: AgentHookEventPayload, - source: AgentHookSource, - captured: AgentProcessPresence - ) => void - ) {} - - discover(request: RelayPresenceDiscoveryRequest): Promise { - const generation = this.generation - const current = () => this.getRow(request.paneKey) - const before = current() - if (before?.agentPresence?.process && !before.agentPresence.ended) { - return Promise.resolve() - } - const pending = this.pending.get(request.paneKey) - if (pending) { - return pending - } - const discovery = request - .discover() - .then((presence) => { - if ( - generation !== this.generation || - !presence?.process || - !isAgentHookSource(presence.agent) || - !request.isCurrent() || - current() !== before - ) { - return - } - const event = - before && !before.agentPresence?.ended - ? { ...before, agentPresence: presence } - : { - paneKey: request.paneKey, - tabId: request.tabId, - worktreeId: request.worktreeId, - terminalHandle: request.terminalHandle, - connectionId: null, - payload: { state: 'done' as const, prompt: '', agentType: presence.agent }, - agentPresence: presence - } - this.publish( - { ...event, hookEventName: AGENT_PROCESS_CAPTURE_EVENT }, - presence.agent, - presence - ) - }) - .catch(() => undefined) - .finally(() => { - if (this.pending.get(request.paneKey) === discovery) { - this.pending.delete(request.paneKey) - } - }) - this.pending.set(request.paneKey, discovery) - return discovery - } -} diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index ced2cba888f5..84e97f572e9b 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,11 +1,10 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' -import { isSameAgentProcess, type AgentProcessPresence } from './agent-process-presence' +import { isSameAgentProcess } from './agent-process-presence' /** A pane has one owning agent; only the owner's own proven process can end it. */ export function transitionHookPresence( incoming: AgentHookEventPayload, - previous: AgentHookEventPayload | undefined, - captured?: AgentProcessPresence + previous: AgentHookEventPayload | undefined ): AgentHookEventPayload | undefined { const recorded = previous?.agentPresence // Dismissing a turn does not release its identified process owner. @@ -38,19 +37,10 @@ export function transitionHookPresence( ? { ...incoming, payload: previous?.payload ?? incoming.payload, - agentPresence: { - ...owner, - ...(incoming.agentPresenceFromExecutionHost && incoming.agentPresence?.observation - ? { observation: incoming.agentPresence.observation } - : {}), - ended: true - } + agentPresence: { ...owner, ended: true } } : undefined } - if (!owner.process && captured?.process && !captured.ended && captured.agent === owner.agent) { - return { ...incoming, agentPresence: captured } - } // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } } diff --git a/src/shared/agent-process-presence-candidate.test.ts b/src/shared/agent-process-presence-candidate.test.ts deleted file mode 100644 index ab1341889f38..000000000000 --- a/src/shared/agent-process-presence-candidate.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { selectDiscoveredAgentOwner } from './agent-process-presence-candidate' -const root = { pid: 10, ppid: 1, command: 'claude' } -describe('discovered process ownership', () => { - it('binds nested agents to the outer terminal owner', () => { - expect( - selectDiscoveredAgentOwner(12, [ - root, - { pid: 11, ppid: 10, command: 'sh' }, - { pid: 12, ppid: 11, command: 'claude' } - ]) - ).toEqual({ processId: 10, processName: 'claude' }) - expect(selectDiscoveredAgentOwner(12, [root, { pid: 12, ppid: 10, command: 'codex' }])).toEqual( - { processId: 10, processName: 'claude' } - ) - }) - it('refuses sibling owners and malformed ancestry', () => { - expect( - selectDiscoveredAgentOwner(10, [root, { pid: 12, ppid: 1, command: 'claude' }]) - ).toBeNull() - expect(selectDiscoveredAgentOwner(99, [root])).toBeNull() - }) - it('keeps the wrapper process as owner', () => { - expect( - selectDiscoveredAgentOwner(12, [ - { ...root, command: 'omp' }, - { pid: 12, ppid: 10, command: 'pi' } - ]) - ).toEqual({ processId: 10, processName: 'omp' }) - }) -}) diff --git a/src/shared/agent-process-presence-candidate.ts b/src/shared/agent-process-presence-candidate.ts deleted file mode 100644 index 59482bc3a554..000000000000 --- a/src/shared/agent-process-presence-candidate.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' -import type { ForegroundAgentCandidate } from './foreground-wrapper-agent' - -/** Presence belongs to the outer agent, even while one of its nested agents owns the foreground. */ -export function selectDiscoveredAgentOwner( - processId: number, - candidates: readonly ForegroundAgentCandidate[] -): { processName: string; processId: number } | null { - const byPid = new Map(candidates.map((candidate) => [candidate.pid, candidate])) - let current = byPid.get(processId) - let owner: { processName: string; processId: number } | null = null - const seen = new Set() - while (current && !seen.has(current.pid)) { - seen.add(current.pid) - const recognized = - recognizeAgentProcessFromCommandLine(current.command) ?? - recognizeAgentProcessFromCommandLine(current.name) - if (recognized) { - owner = { processName: recognized.processName, processId: current.pid } - } - current = byPid.get(current.ppid) - } - if (!owner || current) { - return null - } - for (const candidate of candidates) { - if ( - !recognizeAgentProcessFromCommandLine(candidate.command) && - !recognizeAgentProcessFromCommandLine(candidate.name) - ) { - continue - } - let ancestor: ForegroundAgentCandidate | undefined = candidate - const visited = new Set() - while (ancestor && ancestor.pid !== owner.processId && !visited.has(ancestor.pid)) { - visited.add(ancestor.pid) - ancestor = byPid.get(ancestor.ppid) - } - if (ancestor?.pid !== owner.processId) { - return null - } - } - return owner -} - -export function projectDiscoveredAgentOwner( - identity: T, - candidates: readonly ForegroundAgentCandidate[], - capture: boolean | undefined -): T | { processName: string | null; processId?: number } { - return capture && identity.processId !== undefined - ? (selectDiscoveredAgentOwner(identity.processId, candidates) ?? { processName: null }) - : identity -} diff --git a/src/shared/agent-process-presence-probe.ts b/src/shared/agent-process-presence-probe.ts index cd8c54382f21..22fac38b7dbd 100644 --- a/src/shared/agent-process-presence-probe.ts +++ b/src/shared/agent-process-presence-probe.ts @@ -72,7 +72,7 @@ export async function readAgentProcess(pid: number): Promise number | null -): (pid: number) => Promise { - return async (pid) => { - try { - const birth = readCreationTime(pid) - return birth !== null && Number.isFinite(birth) && birth > 0 - ? { verdict: 'live', startTime: String(birth), zombie: false } - : { verdict: 'unverifiable' } - } catch { - return { verdict: 'unverifiable' } - } - } -} diff --git a/src/shared/agent-process-presence-windows.test.ts b/src/shared/agent-process-presence-windows.test.ts deleted file mode 100644 index f4b605d4a9de..000000000000 --- a/src/shared/agent-process-presence-windows.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - probeAgentProcessPresence, - windowsAgentProcessReader -} from './agent-process-presence-probe' -const identity = { pid: 42, platform: 'win32', startTime: '123' } as const -describe('Windows native birth observation', () => { - it('keeps missing and failed native reads unverifiable', async () => { - for (const native of [ - () => null, - () => { - throw new Error('denied') - } - ]) { - expect( - await probeAgentProcessPresence(identity, windowsAgentProcessReader(native), 'win32') - ).toBe('unverifiable') - } - }) - it('compares births without converting null into death', async () => { - expect( - await probeAgentProcessPresence( - identity, - windowsAgentProcessReader(() => 123), - 'win32' - ) - ).toBe('live') - expect( - await probeAgentProcessPresence( - identity, - windowsAgentProcessReader(() => 124), - 'win32' - ) - ).toBe('exited') - }) - it('does not read a foreign host platform', async () => { - const native = vi.fn(() => 123) - expect( - await probeAgentProcessPresence(identity, windowsAgentProcessReader(native), 'linux') - ).toBe('unverifiable') - expect(native).not.toHaveBeenCalled() - }) -}) diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index 4ffab2dfa00d..aeebe0f1fd17 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -84,8 +84,6 @@ export function selectAgentPresence(presence?: AgentProcessPresence): TuiAgent | return !presence.ended && isTuiAgent(presence.agent) ? presence.agent : null } -export const AGENT_PROCESS_CAPTURE_EVENT = 'AgentProcessCaptured' - export type AgentPresenceObservation = { epoch: string; sequence: number } export function readAgentPresenceObservation(value: unknown): AgentPresenceObservation | undefined { From 49cc2651e099e7a071b7e6e1d485d608de12752d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:38:36 -0700 Subject: [PATCH 03/27] fix(agent-presence): keep permission and management checks ahead of host presence A pane's identified owner now answers only whether an agent is there. The live permission title, the approval-prompt screen text and the `claude agents` management screen keep their order ahead of it, so a Cursor approval menu still reads as waiting for permission (sends are refused instead of pressing Enter on "Run (once)") and the management screen is never treated as a task agent. A live owner still stands in for the shell-title and foreground reads; an ended owner still reads as no agent. --- .../runtime-terminal-agent-presence.test.ts | 146 +++++++++++++++--- .../runtime-terminal-agent-presence.ts | 31 ++-- .../runtime-terminal-agent-status-query.ts | 23 ++- 3 files changed, 153 insertions(+), 47 deletions(-) diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts index 555d09a91a96..0cd9f154872b 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -1,34 +1,101 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' +import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' +import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import type { AgentProcessPresence } from '../../shared/agent-process-presence' +const CURSOR_APPROVAL = readFileSync( + join(__dirname, '__fixtures__', 'cursor-agent-approval-prompt.txt'), + 'utf8' +) + +function owner(agent: string, ended = false): AgentProcessPresence { + return { + agent, + process: { pid: 42, platform: 'linux', startTime: 'boot:42' }, + ...(ended ? { ended: true as const } : {}) + } +} + +function leaf(title: string): RuntimeLeafRecord { + return { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: title, + ptyGeneration: 1, + connected: true, + writable: true, + lastOutputAt: null, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastAgentStatusObservedLive: false, + lastOscTitle: title, + lastOscTitleAt: 1, + paneTitleUpdatedAt: 1, + tailBuffer: [], + tailTranscriptBuffer: [], + tailTranscriptChars: 0, + tailPartialLine: '', + tailPendingAnsi: '', + tailRedrawCursor: null, + tailTruncated: false, + tailLinesTotal: 0, + preview: '', + waitBlockedAt: null + } +} + +function terminal(args: { + presence: AgentProcessPresence | undefined + title: string + foreground: string + waitText?: string + explicit?: 'working' | 'done' +}) { + const presence = new RuntimeTerminalAgentPresence({ + getAgentPresence: () => args.presence, + getLivePty: () => null, + getLiveLeaf: () => leaf(args.title), + getPrimaryLeaf: () => null, + getTrackedPty: () => null, + getTabTitle: () => null, + getForegroundProcess: async () => args.foreground + }) + const query = new RuntimeTerminalAgentStatusQuery({ + getAgentPresence: () => args.presence, + getController: () => null, + getLivePty: () => null, + getLiveLeaf: () => ({ leaf: leaf(args.title) }), + getPrimaryLeaf: () => null, + getTabTitle: () => args.title, + getExplicitStatus: () => + args.explicit ? { status: args.explicit, updatedAt: Date.now() - 1000 } : null, + getLifecycleStatus: () => null, + isRunning: (handle) => presence.isRunning(handle) + }) + vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') + vi.spyOn(query, 'getSnapshot').mockReturnValue({ + waitText: args.waitText ?? '', + waitBlockedAt: args.waitText ? Date.now() : null, + title: args.title, + titleStatus: null, + titleStatusIsLive: true + }) + return { presence, query } +} + describe('headless terminal presence', () => { it.each([false, true])('uses host presence under a shell title (ended=%s)', async (ended) => { - const presence: AgentProcessPresence = { - agent: 'claude', - process: { pid: 42, platform: 'linux', startTime: 'boot:42' }, - ...(ended ? { ended: true } : {}) - } - const query = new RuntimeTerminalAgentStatusQuery({ - getAgentPresence: () => presence, - getController: () => null, - getLivePty: () => null, - getLiveLeaf: () => { - throw new Error('unexpected leaf lookup') - }, - getPrimaryLeaf: () => null, - getTabTitle: () => 'zsh', - getExplicitStatus: () => null, - getLifecycleStatus: () => null, - isRunning: async () => false - }) - vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') - vi.spyOn(query, 'getSnapshot').mockReturnValue({ - waitText: '', - waitBlockedAt: null, + const { query } = terminal({ + presence: owner('claude', ended), title: 'zsh', - titleStatus: null, - titleStatusIsLive: false + foreground: 'zsh' }) expect(await query.getStatus('terminal')).toEqual({ handle: 'terminal', @@ -36,4 +103,35 @@ describe('headless terminal presence', () => { status: null }) }) + + it('keeps the approval-menu permission ahead of an identified owner', async () => { + // Cursor reports its approval gate as working; only the screen shows the menu. + const { query } = terminal({ + presence: owner('cursor'), + title: 'Cursor Agent', + foreground: 'cursor-agent', + waitText: CURSOR_APPROVAL, + explicit: 'working' + }) + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: true, + status: 'permission' + }) + }) + + it('keeps the management screen from reading as a task agent for an identified owner', async () => { + const { presence, query } = terminal({ + presence: owner('claude'), + title: 'claude agents', + foreground: 'claude', + explicit: 'working' + }) + expect(await presence.isRunning('terminal')).toBe(false) + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: false, + status: null + }) + }) }) diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index babc5f21856f..2525bff41dad 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -5,6 +5,7 @@ import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import { isOpenCodeNativeTitle } from '../../shared/agent-detection' +import type { TuiAgent } from '../../shared/tui-agent' import { isKnownReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' @@ -52,14 +53,14 @@ export class RuntimeTerminalAgentPresence { if (this.deps.isLiveStructuredAgent?.(handle)) { return true } - const presence = selectAgentPresence(this.deps.getAgentPresence?.(handle)) - if (presence !== undefined) { - return presence !== null + const owner = selectAgentPresence(this.deps.getAgentPresence?.(handle)) + if (owner === null) { + return false } try { const pty = this.deps.getLivePty(handle) if (pty) { - return await this.isPtyRunning(pty, this.deps.getPrimaryLeaf(pty.ptyId), options) + return await this.isPtyRunning(pty, this.deps.getPrimaryLeaf(pty.ptyId), options, owner) } const leaf = this.deps.getLiveLeaf(handle) const trackedPty = leaf.ptyId ? this.deps.getTrackedPty(leaf.ptyId) : null @@ -92,12 +93,15 @@ export class RuntimeTerminalAgentPresence { if (!leaf.ptyId) { return false } + const suppressClaude = + paneClassification === 'management' || tabClassification === 'management' + if (owner) { + return !(suppressClaude && owner === 'claude') + } const foreground = await this.readForegroundProcess(leaf.ptyId, options) if (!foreground) { return false } - const suppressClaude = - paneClassification === 'management' || tabClassification === 'management' if (suppressClaude && isExpectedAgentProcess(foreground, 'claude')) { return false } @@ -115,7 +119,8 @@ export class RuntimeTerminalAgentPresence { private async isPtyRunning( pty: RuntimePtyWorktreeRecord, leaf: RuntimeLeafRecord | null, - options: RuntimeTerminalAgentPresenceOptions + options: RuntimeTerminalAgentPresenceOptions, + owner: TuiAgent | undefined ): Promise { const leafTitle = leaf ? getLatestAgentCandidateTitle( @@ -155,14 +160,18 @@ export class RuntimeTerminalAgentPresence { ) { return true } - const foreground = await this.readForegroundProcess(pty.ptyId, options) - if (!foreground) { - return false - } const suppressClaude = leafTitle !== null ? leafClassification === 'management' : managementClassification === 'management' + // Why: a live identified owner stands in for the foreground read, never for a management screen. + if (owner) { + return !(suppressClaude && owner === 'claude') + } + const foreground = await this.readForegroundProcess(pty.ptyId, options) + if (!foreground) { + return false + } if (suppressClaude && isExpectedAgentProcess(foreground, 'claude')) { return false } diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index af536f1b8c2a..5ba43bb6e3dc 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,6 +1,7 @@ import { selectAgentPresence, type AgentProcessPresence } from '../../shared/agent-process-presence' import { detectAgentStatusFromTitle, + isClaudeManagementTitle, isOpenCodeNativeTitle, isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, @@ -69,13 +70,6 @@ export class RuntimeTerminalAgentStatusQuery { const explicitStatus = this.deps.getExplicitStatus(handle) const lifecycle = this.deps.getLifecycleStatus(ptyId) const presence = selectAgentPresence(this.deps.getAgentPresence?.(handle)) - if (presence !== undefined) { - return { - handle, - isRunningAgent: presence !== null, - status: presence === null ? null : (explicitStatus?.status ?? lifecycle?.status ?? null) - } - } const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && @@ -103,12 +97,17 @@ export class RuntimeTerminalAgentStatusQuery { ) { return { handle, isRunningAgent: true, status: 'permission' } } + // Presence answers only whether an agent owns the pane; the permission evidence above still wins. + if (presence === null) { + return { handle, isRunningAgent: false, status: null } + } if (explicitStatus) { - // Why: permission titles can linger after hooks report the agent resumed. - // Fresh hook state is tighter, but current shell/management evidence wins. - const isRunningAgent = - !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + // Why: permission titles can linger after hooks report the agent resumed. Fresh hook state is + // tighter, but current shell/management evidence wins; a live owner answers for the shell. + const isRunningAgent = presence + ? !(terminal.title !== null && isClaudeManagementTitle(terminal.title)) + : !terminalTitleBlocksExplicitAgentStatus(terminal.title) && + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, From 6e81d2572df72bce84be520f43c658b8381ee600 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:41:09 -0700 Subject: [PATCH 04/27] fix(agent-presence): derive paired-client presence per host snapshot A paired desktop/web client latched an ended owner record: once Claude exited in a pane, a later agent the host cannot identify (for example Codex) stayed hidden there until the pane closed. A newer host row without a process now drops the mirrored record, so that pane falls back to its row signals as it does on the host; an older snapshot still cannot revive an ended owner. A host retraction also sweeps the mirrored presence record with the tab's other per-pane state instead of leaking it. --- .../web-session-tabs-sync-presence.test.ts | 111 ++++++++++++++---- .../agent-status-patch.ts | 15 ++- .../agent-status-primitives.ts | 4 +- 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts index 3de3958fcea1..df6fb88640d6 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync' import { ENV, @@ -16,30 +17,43 @@ const presence = { agent: 'claude', process: { pid: 42, platform: 'linux', startTime: 'boot:42' } } as const -function snapshot(ended: boolean) { - return makeSnapshot([ - { - type: 'terminal', - id: HOST_SURFACE_ID, - parentTabId: 'host-tab-1', - leafId: LEAF_ID, - title: 'zsh', - launchAgent: 'claude', - isActive: true, - status: 'ready', - terminal: 'terminal-1', - agentStatus: { - state: 'done', - prompt: '', - updatedAt: 10, - stateStartedAt: 10, - paneKey: makePaneKey('host-tab-1', LEAF_ID), - agentType: 'claude', - stateHistory: [], - agentPresence: { ...presence, ...(ended ? { ended: true } : {}) } - } - } - ]) +function snapshot( + ended: boolean, + row: Partial = {}, + version?: number, + surfaces = true +) { + return makeSnapshot( + surfaces + ? [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + title: 'zsh', + launchAgent: 'claude', + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 10, + stateStartedAt: 10, + paneKey: makePaneKey('host-tab-1', LEAF_ID), + agentType: 'claude', + stateHistory: [], + agentPresence: { ...presence, ...(ended ? { ended: true } : {}) }, + ...row + } + } + ] + : [], + version === undefined + ? {} + : { snapshotVersion: version, ...(surfaces ? {} : { activeTabId: null }) } + ) } describe('paired host presence', () => { beforeEach(resetWebSessionTabsSyncTestState) @@ -64,4 +78,53 @@ describe('paired host presence', () => { expect(next?.agentStatusByPaneKey?.[paneKey]).toBeUndefined() expect(next?.tabsByWorktree?.[WT] ?? mirrored.tabsByWorktree[WT]).toHaveLength(1) }) + + it('drops an ended owner when a newer host row no longer carries a process', () => { + const state = makeState() + const ended = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot(true), ENV, NOW) } + const paneKey = Object.keys(ended.agentPresenceByPaneKey ?? {})[0] + expect(ended.agentPresenceByPaneKey?.[paneKey]?.presence.ended).toBe(true) + // A later agent the host cannot identify (for example Codex) publishes only a legacy row. + const successor: Partial = { + state: 'working', + prompt: 'refactor', + updatedAt: 20, + stateStartedAt: 20, + agentType: 'codex', + agentPresence: undefined + } + const next = { + ...ended, + ...applyWebSessionTabsSnapshot(ended, snapshot(false, successor, 2), ENV, NOW + 1) + } + expect(next.agentPresenceByPaneKey?.[paneKey]).toBeUndefined() + expect(next.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + agentType: 'codex' + }) + }) + + it('does not resurrect an ended owner from an older live snapshot', () => { + const state = makeState() + const endedRow = { updatedAt: 20, stateStartedAt: 20 } + const ended = { + ...state, + ...applyWebSessionTabsSnapshot(state, snapshot(true, endedRow), ENV, NOW) + } + const paneKey = Object.keys(ended.agentPresenceByPaneKey ?? {})[0] + const stale = applyWebSessionTabsSnapshot(ended, snapshot(false, {}, 2), ENV, NOW + 1) + const next = { ...ended, ...stale } + expect(next.agentPresenceByPaneKey?.[paneKey]?.presence.ended).toBe(true) + }) + + it('releases mirrored presence when the host retracts the terminal', () => { + const state = makeState() + const live = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot(false), ENV, NOW) } + const paneKey = Object.keys(live.agentPresenceByPaneKey ?? {})[0] + expect(live.agentPresenceByPaneKey?.[paneKey]?.presence.process).toBeDefined() + const retracted = applyWebSessionTabsSnapshot(live, snapshot(false, {}, 2, false), ENV, NOW + 1) + const next = { ...live, ...retracted } + expect(Object.values(next.tabsByWorktree).flat()).toHaveLength(0) + expect(next.agentPresenceByPaneKey?.[paneKey]).toBeUndefined() + }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index d37244eaa384..253c3882a078 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -112,11 +112,18 @@ export function buildMirroredAgentStatusPatch( } const presence = readAgentProcessPresence(hostEntry.agentPresence) const previousPresence = nextPresenceByPaneKey[hostEntry.paneKey] - if ( + const hostIsCurrent = + !previousPresence || + previousPresence.connectionId !== environmentId || + hostEntry.updatedAt >= previousPresence.receivedAt + if (!presence?.process && hostIsCurrent && previousPresence?.connectionId === environmentId) { + // Why: presence is derived per snapshot; a newer uncovered row means the host no longer vouches for that owner. + const { [hostEntry.paneKey]: _released, ...remaining } = nextPresenceByPaneKey + nextPresenceByPaneKey = remaining + presenceChanged = true + } else if ( presence?.process && - (!previousPresence || - previousPresence.connectionId !== environmentId || - hostEntry.updatedAt >= previousPresence.receivedAt) && + hostIsCurrent && (previousPresence?.receivedAt !== hostEntry.updatedAt || previousPresence.connectionId !== environmentId || JSON.stringify(previousPresence.presence) !== JSON.stringify(presence)) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index a098f487db19..66692e3f2d1b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -170,7 +170,7 @@ export function buildRetractedMirroredTabSweepPatch( nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], agentStatusPatch: Pick< WebSessionTabsSyncState, - 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' + 'agentStatusByPaneKey' | 'agentPresenceByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' > | null, removedTerminalResourceIds: readonly string[], scopedPaneKeysByTabId: ReadonlyMap>, @@ -186,6 +186,8 @@ export function buildRetractedMirroredTabSweepPatch( acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, activityClearedAtByPaneKey: state.activityClearedAtByPaneKey ?? {}, agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, + agentPresenceByPaneKey: + agentStatusPatch?.agentPresenceByPaneKey ?? state.agentPresenceByPaneKey ?? {}, agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, From 4d55d5838e15fbcc4afc9703a42cb7948209d906 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:42:23 -0700 Subject: [PATCH 05/27] fix(agent-presence): let a host frame for a new owner process replace the stale mirror row After an SSH reconnect, the desktop kept the row it held for the old agent when the execution host reported a different owner process: the dead agent's permission prompt held the new agent's turn, and an owner that started and exited while disconnected never replaced the old one. A host-stamped frame whose owner process differs from the stored row's now replaces that row instead of inheriting its turn, identity or exit fence from it. Terminal join and ordering watermarks still come from the stored row. --- ...erver-agent-presence-remote-replay.test.ts | 20 ++++++++++++ .../server/server-status-update.ts | 31 ++++++++++++------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts index 5c453d5c7fb8..62420556176d 100644 --- a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts +++ b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts @@ -111,6 +111,26 @@ describe('execution host presence replay', () => { expect(main.getStatusSnapshot()[0]?.agentPresence?.ended).toBe(ended ? true : undefined) } ) + it('shows the replacement when the old owner was waiting on permission at disconnect', async () => { + const { main, relay, relayHook, connect } = await setup() + await relayHook('SessionStart', 'a', owner.process) + await relayHook('PermissionRequest', 'a', owner.process, { tool_name: 'Bash' }) + expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting' }) + connect(false) + main.clearStatusEntriesForConnection('ssh-1') + probe.mockResolvedValue('exited') + await relay.checkAgentPresence(PANE) + probe.mockResolvedValue('live') + await relayHook('SessionStart', 'b', replacement.process) + await relayHook('PreToolUse', 'b', replacement.process, { tool_name: 'Read' }) + connect(true) + relay.replayCachedPayloadsForPanes() + // The dead owner's permission prompt must not hold the new agent's turn. + expect(main.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + agentPresence: replacement + }) + }) it('retains the ordering of a connected exit when an older live frame replays', async () => { const { main, relay, relayHook, frames } = await setup() await relayHook('SessionStart', 'a', owner.process) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 516d17f4170a..0662fb8d505f 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,4 +1,5 @@ import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' +import { isSameAgentProcess } from '../../../shared/agent-process-presence' import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted @@ -31,10 +32,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // Why: a relay already chose the live owner on its own host; re-deciding against a record it // replaced would pin the pane to an owner this desktop can never check. Exits still need our fence. const relayOwner = incoming.connectionId !== null ? incoming.agentPresence : undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const stored = this.state.lastStatusByPaneKey.get(incoming.paneKey) as + | EnrichedAgentHookEventPayload + | undefined const transitioned = - relayOwner?.process && !relayOwner.ended - ? incoming - : transitionHookPresence(incoming, this.state.lastStatusByPaneKey.get(incoming.paneKey)) + relayOwner?.process && !relayOwner.ended ? incoming : transitionHookPresence(incoming, stored) if (!transitioned) { return undefined } @@ -42,7 +45,14 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!this.canWriteLegacyStatusRow(payload)) { return undefined } - if (payload.agentPresence?.ended) { + // Why: the execution host already replaced this owner; the mirrored row is a dead process's, not a parent turn. + const hostOwner = payload.agentPresenceFromExecutionHost && payload.agentPresence?.process + const replacesStaleOwner = Boolean( + hostOwner && + stored?.agentPresence?.process && + !isSameAgentProcess(stored.agentPresence.process, hostOwner) + ) + if (payload.agentPresence?.ended && !replacesStaleOwner) { this.reconcileEndedProcessForPaneKeys([payload.paneKey], { kind: 'owner-exited', presence: payload.agentPresence @@ -53,15 +63,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. - const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const rowBefore = mutationBefore ?? previous + const previous = replacesStaleOwner ? undefined : stored + const rowBefore = mutationBefore ?? stored const terminalHandle = payload.terminalHandle ?? - (previous?.terminalHandle && this.sameTerminalOwner(previous, payload) - ? previous.terminalHandle + (stored?.terminalHandle && this.sameTerminalOwner(stored, payload) + ? stored.terminalHandle : undefined) const terminalOwnedPayload = terminalHandle === payload.terminalHandle ? payload : { ...payload, terminalHandle } @@ -74,7 +81,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ? this.connectionTimestampWatermarkById.get(terminalOwnedPayload.connectionId) : undefined // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. - const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const restoredStatusWatermark = stored?.restoredUnconfirmed ? stored.receivedAt : undefined const now = Math.max( Date.now(), (connectionClearWatermark ?? -1) + 1, From f03b3f4236da85310faaa092f82d4f47b1f131b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:43:45 -0700 Subject: [PATCH 06/27] refactor(agent-presence): drop the unread ps presence field and the dead projection default `worktree ps` rows no longer carry the owner record (no reader used it, and it published process ids and relay ordering on --json); their agent type still comes from an identified owner. The mobile status builder's default presence branch was never reached in production, so it and the reformatted caller go back to step 2; its tests now run the real snapshot projection and client negotiation instead. --- ...e-prune-mobile-session-tab-group-layout.ts | 20 +- .../runtime-mobile-agent-presence.test.ts | 178 ++++++++---------- .../runtime-mobile-agent-status-builder.ts | 10 +- .../runtime/runtime-worktree-agent-rows.ts | 1 - .../runtime/runtime-worktree-agent-source.ts | 2 - .../runtime-worktree-pty-agent-sources.ts | 1 - src/shared/runtime-worktree-contracts.ts | 2 - 7 files changed, 83 insertions(+), 131 deletions(-) diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 1b5d12841c3d..75005726c2db 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -117,20 +117,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { - return buildRuntimeMobileAgentStatus( - pty, - tab, - terminalHandle, - retained, - getHookRowsForPane, - { - getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), - getLeaf: (candidate) => - this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) ?? null, - getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) - }, - false - ) + return buildRuntimeMobileAgentStatus(pty, tab, terminalHandle, retained, getHookRowsForPane, { + getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), + getLeaf: (candidate) => + this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) ?? null, + getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) + }) } protected getFreshRetainedAgentStatusForMobileTab( diff --git a/src/main/runtime/runtime-mobile-agent-presence.test.ts b/src/main/runtime/runtime-mobile-agent-presence.test.ts index b5bdcb4d7338..bcd769f88099 100644 --- a/src/main/runtime/runtime-mobile-agent-presence.test.ts +++ b/src/main/runtime/runtime-mobile-agent-presence.test.ts @@ -1,68 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' - -const processIdentity = { pid: 4001, platform: 'linux', startTime: 'boot:123' } as const - -function projectOwner(ended: boolean) { - const now = Date.now() - const row: AgentStatusIpcPayload = { - paneKey: 'tab:leaf', - tabId: 'tab', - worktreeId: 'folder-1', - connectionId: null, - state: 'done', - prompt: '', - receivedAt: now, - stateStartedAt: now, - agentType: 'claude', - agentPresence: { - agent: 'claude', - process: processIdentity, - ...(ended ? { ended: true } : {}) - }, - ...(ended ? { providerSessionOnly: true } : {}) - } - return buildRuntimeMobileAgentStatus( - null, - { - type: 'terminal', - id: 'tab::leaf', - parentTabId: 'tab', - leafId: 'leaf', - title: 'zsh', - launchAgent: 'claude', - isActive: true - }, - null, - null, - () => [row], - { - getPaneKey: () => row.paneKey, - getLeaf: () => null, - getTrackedTitle: () => null - } - ) -} - -describe('headless mobile owner projection', () => { - it('preserves an idle host owner without a renderer or an agent title', () => { - expect(projectOwner(false)).toMatchObject({ - agentStatus: { - agentPresence: { agent: 'claude', process: processIdentity } - } - }) - }) - - it('carries a positive exit through the snapshot even with a stale launch hint', () => { - expect(projectOwner(true)).toMatchObject({ - agentStatus: { - agentPresence: { agent: 'claude', process: processIdentity, ended: true } - } - }) - }) -}) - +import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection' import { projectSessionTabsForClient } from './rpc/methods/session-tabs-inventory' import { AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY } from '../../shared/protocol-version' @@ -70,9 +7,12 @@ import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-sessio import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { makePaneKey } from '../../shared/stable-pane-id' -it('negotiates the full snapshot and never accepts client-published presence as host evidence', () => { - const leafId = '11111111-1111-4111-8111-111111111111' - const paneKey = makePaneKey('tab', leafId) +const processIdentity = { pid: 4001, platform: 'linux', startTime: 'boot:123' } as const +const leafId = '11111111-1111-4111-8111-111111111111' +const paneKey = makePaneKey('tab', leafId) +const capable = [AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY] + +function ownerRow(ended: boolean) { const row: AgentStatusIpcPayload = { paneKey, tabId: 'tab', @@ -82,11 +22,19 @@ it('negotiates the full snapshot and never accepts client-published presence as prompt: '', receivedAt: 10, stateStartedAt: 5, - providerSessionOnly: true, agentType: 'claude', - agentPresence: { agent: 'claude', process: processIdentity, ended: true } + agentPresence: { + agent: 'claude', + process: processIdentity, + ...(ended ? { ended: true } : {}) + }, + ...(ended ? { providerSessionOnly: true } : {}) } - const spoofedAuthority = { agentPresenceFromExecutionHost: true } + return row +} + +function projection(agentStatus?: AgentStatusEntry) { + let rows: AgentStatusIpcPayload[] = [] const snapshot: RuntimeMobileSessionTabsSnapshot = { worktree: 'folder', publicationEpoch: 'epoch', @@ -103,25 +51,10 @@ it('negotiates the full snapshot and never accepts client-published presence as title: 'zsh', isActive: true, launchAgent: 'claude', - agentStatus: { - state: 'done', - prompt: '', - paneKey, - updatedAt: 99999, - stateStartedAt: 99999, - stateHistory: [], - agentType: 'claude', - agentPresence: { - agent: 'claude', - process: processIdentity, - observation: { epoch: 'client', sequence: 999 } - }, - ...spoofedAuthority - } + ...(agentStatus ? { agentStatus } : {}) } ] } - let rows = [row] const host: RuntimeMobileSessionProjectionHost = { tabs: new Map(), leaves: new Map(), @@ -143,21 +76,62 @@ it('negotiates the full snapshot and never accepts client-published presence as pruneGroupLayout: () => null, collectTabIds: () => new Set() } - const result = projectRuntimeMobileSessionTabs(snapshot, host) - const capable = [AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY] - expect(projectSessionTabsForClient(result, 'mobile', capable, false).tabs[0]).toMatchObject({ - agentStatus: { agentPresence: row.agentPresence, updatedAt: 10, stateStartedAt: 5 } + return { + setRows: (next: AgentStatusIpcPayload[]) => { + rows = next + }, + tabFor: (capabilities: string[]) => + projectSessionTabsForClient( + projectRuntimeMobileSessionTabs(snapshot, host), + 'mobile', + capabilities, + false + ).tabs[0] + } +} + +describe('headless mobile owner projection', () => { + it('preserves an idle host owner without a renderer or an agent title', () => { + const { setRows, tabFor } = projection() + setRows([ownerRow(false)]) + expect(tabFor(capable)).toMatchObject({ + agentStatus: { agentPresence: { agent: 'claude', process: processIdentity } } + }) + }) + + it('carries a positive exit through the snapshot even with a stale launch hint', () => { + const { setRows, tabFor } = projection() + setRows([ownerRow(true)]) + expect(tabFor(capable)).toMatchObject({ + launchAgent: 'claude', + agentStatus: { agentPresence: { agent: 'claude', process: processIdentity, ended: true } } + }) + }) + + it('negotiates the full snapshot and never accepts client-published presence as host evidence', () => { + const row = ownerRow(true) + const spoofedAuthority = { agentPresenceFromExecutionHost: true } + const { setRows, tabFor } = projection({ + state: 'done', + prompt: '', + paneKey, + updatedAt: 99999, + stateStartedAt: 99999, + stateHistory: [], + agentType: 'claude', + agentPresence: { + agent: 'claude', + process: processIdentity, + observation: { epoch: 'client', sequence: 999 } + }, + ...spoofedAuthority + }) + setRows([row]) + expect(tabFor(capable)).toMatchObject({ + agentStatus: { agentPresence: row.agentPresence, updatedAt: 10, stateStartedAt: 5 } + }) + expect(tabFor([])).not.toHaveProperty('agentStatus.agentPresence') + setRows([]) + expect(tabFor(capable)).not.toHaveProperty('agentStatus.agentPresence') }) - expect(projectSessionTabsForClient(result, 'mobile', [], false).tabs[0]).not.toHaveProperty( - 'agentStatus.agentPresence' - ) - rows = [] - expect( - projectSessionTabsForClient( - projectRuntimeMobileSessionTabs(snapshot, host), - 'mobile', - capable, - false - ).tabs[0] - ).not.toHaveProperty('agentStatus.agentPresence') }) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index bd4f954b6012..5ce39d49d6aa 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -1,4 +1,3 @@ -import { projectHostAgentPresenceStatus } from './runtime-mobile-agent-presence-projection' import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner @@ -31,16 +30,9 @@ export function buildRuntimeMobileAgentStatus( terminalHandle: string | null, retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[], - host: RuntimeMobileAgentStatusHost, - includeAgentPresence = true + host: RuntimeMobileAgentStatusHost ): { agentStatus: AgentStatusEntry } | Record { const paneKey = host.getPaneKey(tab) - if (includeAgentPresence) { - const status = projectHostAgentPresenceStatus(getHookRowsForPane(paneKey)) - if (status) { - return { agentStatus: status } - } - } // Why: neither the live-status projection nor a title-derived status carries a // provider session — only the full hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index b09c5a0889cd..310b0f6bc524 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -53,7 +53,6 @@ export function attachRuntimeWorktreeAgentRows(args: { state: source.state, ...(source.workingMode ? { workingMode: source.workingMode } : {}), agentType: source.agentType, - ...(source.agentPresence ? { agentPresence: source.agentPresence } : {}), prompt: source.prompt, taskTitle: orchestration?.taskTitle ?? null, displayName: orchestration?.displayName ?? null, diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 6b251af86001..fd98d7914b1d 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,10 +1,8 @@ -import type { AgentProcessPresence } from '../../shared/agent-process-presence' import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import type { AgentMainAgentStatus } from '../../shared/main-agent-status' export type RuntimeWorktreeAgentSource = { - agentPresence?: AgentProcessPresence paneKey: string ptyId?: string tabId?: string diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 86e127f6f368..fa7de865b9bb 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -46,7 +46,6 @@ export function collectRuntimeWorktreePtyAgentSources(args: { agentType: entry.agentPresence?.process ? entry.agentPresence.agent : (entry.agentType ?? null), - ...(entry.agentPresence ? { agentPresence: entry.agentPresence } : {}), prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index afb89deceece..3d95fb0a3dc9 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -1,4 +1,3 @@ -import type { AgentProcessPresence } from './agent-process-presence' import type { AgentStatusState, AgentType, AgentWorkingMode } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' import type { BaseRefSearchResult, Repo } from './repo-types' @@ -12,7 +11,6 @@ import type { RuntimeListingHostScope } from './runtime-listing-host-scope' import type { GitWorktreeInfo, Worktree } from './worktree/types' export type RuntimeWorktreeAgentRow = { - agentPresence?: AgentProcessPresence paneKey: string parentPaneKey: string | null state: AgentStatusState From c08f8d8597be3cdfbfb59e5a5734add1fe02a56d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:55:09 -0700 Subject: [PATCH 07/27] fix(agent-presence): treat an exited owner as history on the phone and CLI After an identified Claude exited, the phone, `terminal show`, the send guard, dispatch and terminal summaries read its pane as "no agent" until another hooked agent ran there, so a hookless agent started next was hidden (worse than before presence). They now use step 2's rule: a live identified owner decides its pane; once it exits, only evidence naming that owner (its hook row, launch intent, title or foreground process) is set aside, and any other agent shows exactly as it did before. The rule moves unchanged from the desktop renderer to src/shared so the phone, main and renderer share one implementation; the renderer module re-exports it. --- .../src/session/mobile-agent-presence.test.ts | 6 ++ .../session/mobile-native-chat-eligibility.ts | 15 ++-- .../src/session/mobile-terminal-tab-agent.ts | 29 +++---- ...runtime-write-orchestration-pointer-pty.ts | 24 +++--- .../runtime-terminal-agent-presence.test.ts | 33 +++++++- .../runtime-terminal-agent-presence.ts | 81 +++++++++++++------ .../runtime-terminal-agent-status-query.ts | 25 +++--- .../src/lib/agent-presence-selectors.ts | 68 +++------------- src/shared/agent-process-presence.ts | 10 --- src/shared/ended-agent-owner-evidence.ts | 72 +++++++++++++++++ 10 files changed, 230 insertions(+), 133 deletions(-) create mode 100644 src/shared/ended-agent-owner-evidence.ts diff --git a/mobile/src/session/mobile-agent-presence.test.ts b/mobile/src/session/mobile-agent-presence.test.ts index c935211bd86c..2c0b97ad2bb0 100644 --- a/mobile/src/session/mobile-agent-presence.test.ts +++ b/mobile/src/session/mobile-agent-presence.test.ts @@ -31,6 +31,12 @@ describe('host process presence on mobile', () => { expect(resolveMobileTerminalTabAgentId(tab(true))).toBeNull() expect(resolveMobileNativeChat(tab(true))).toBeNull() }) + it('shows a hookless agent started after the owner exited, as it would without presence', () => { + const successor = { ...tab(true), title: 'aider' } + expect(resolveMobileTerminalTabAgentId(successor)).toBe('aider') + const withoutPresence = { ...successor, agentStatus: undefined, launchAgent: undefined } + expect(resolveMobileTerminalTabAgentId(withoutPresence)).toBe('aider') + }) it('keeps old-host and unidentified rows on the legacy path', () => { const legacy = tab() delete legacy.agentStatus.agentPresence diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index f3ce9d7b302c..e7ae1e477e56 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,4 +1,7 @@ -import { selectAgentPresence } from '../../../src/shared/agent-process-presence' +import { + paneEvidenceAgent, + selectLiveOwnerAgent +} from '../../../src/shared/ended-agent-owner-evidence' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' @@ -59,16 +62,14 @@ export function resolveMobileNativeChat( if (tab.type !== 'terminal') { return null } - const presence = selectAgentPresence(tab.agentStatus?.agentPresence) - if (presence === null) { - return null - } - const liveAgent = presence ?? tab.agentStatus?.agentType ?? null + const presence = tab.agentStatus?.agentPresence + const liveAgent = + selectLiveOwnerAgent(presence) ?? paneEvidenceAgent(presence, tab.agentStatus?.agentType) const agent = liveAgent ? isNativeChatSupportedAgent(liveAgent) ? liveAgent : null - : tab.launchAgent + : paneEvidenceAgent(presence, tab.launchAgent) if (!agent || !isNativeChatSupportedAgent(agent)) { return null } diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index 5124c3be8348..df3eb17b76a2 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -1,4 +1,7 @@ -import { selectAgentPresence } from '../../../src/shared/agent-process-presence' +import { + paneEvidenceAgent, + selectLiveOwnerAgent +} from '../../../src/shared/ended-agent-owner-evidence' import { stripLeadingAgentTitleDecorationOrEmpty } from '../../../src/shared/agent-title-decoration' import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/terminal-title-agent-type' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' @@ -29,32 +32,30 @@ type MobileTerminalTabAgentIdentity = { export function resolveMobileTerminalTabOwnedAgentId( tab: MobileTerminalTabAgentIdentity ): string | null { - const presence = selectAgentPresence(tab.agentStatus?.agentPresence) - if (presence !== undefined) { - return presence + const presence = tab.agentStatus?.agentPresence + const owner = selectLiveOwnerAgent(presence) + if (owner) { + return owner } - const hookAgentType = tab.agentStatus?.agentType?.trim() + // Why: an exited owner's own hook row and launch intent are history; other agents still show. + const hookAgentType = paneEvidenceAgent(presence, tab.agentStatus?.agentType?.trim()) if (hookAgentType && hookAgentType !== 'unknown') { return hookAgentType } - if (tab.launchAgent) { - return tab.launchAgent - } - return null + return paneEvidenceAgent(presence, tab.launchAgent) } export function resolveMobileTerminalTabAgentId( tab: MobileTerminalTabAgentIdentity ): string | null { - const presence = selectAgentPresence(tab.agentStatus?.agentPresence) - if (presence !== undefined) { - return presence - } const ownedAgent = resolveMobileTerminalTabOwnedAgentId(tab) if (ownedAgent) { return ownedAgent } - return resolveExplicitTerminalTitleAgentType(tab.title) + return paneEvidenceAgent( + tab.agentStatus?.agentPresence, + resolveExplicitTerminalTitleAgentType(tab.title) + ) } export function getMobileSessionTabTitle(tab: MobileSessionTab): string { diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 11120429ff48..4e4e9209868e 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -1,5 +1,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { selectAgentPresence } from '../../shared/agent-process-presence' +import { + paneEvidenceAgent, + selectLiveOwnerAgent, + withoutEndedOwnerTitle +} from '../../shared/ended-agent-owner-evidence' import { OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness } from './orca-runtime-refresh-floating-workspace-pty-liveness' import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' import type { WriteSettlement } from '../../shared/pty-write-settlement' @@ -95,20 +99,20 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith paneKey: string | null ): { agentIdentity?: TuiAgent } { const rows = paneKey ? (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) : [] - const presence = selectAgentPresence( - [...rows].sort((a, b) => b.receivedAt - a.receivedAt)[0]?.agentPresence - ) - if (presence !== undefined) { - return presence === null ? {} : { agentIdentity: presence } + const presence = [...rows].sort((a, b) => b.receivedAt - a.receivedAt)[0]?.agentPresence + const owner = selectLiveOwnerAgent(presence) + if (owner) { + return { agentIdentity: owner } } const hookRow = selectRuntimeHookAgentRowForPane(rows) const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null + // Why: an exited owner's own evidence is history; evidence naming another agent still counts. const agentIdentity = resolvePublishedPaneAgentIdentity({ - hookAgent, + hookAgent: paneEvidenceAgent(presence, hookAgent), hookIsLive: hookRow?.agentIsLive, - launchAgent, - foregroundAgent, - title + launchAgent: paneEvidenceAgent(presence, launchAgent), + foregroundAgent: paneEvidenceAgent(presence, foregroundAgent), + title: withoutEndedOwnerTitle(title, presence) }) return agentIdentity ? { agentIdentity } : {} } diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts index 0cd9f154872b..d3c95ce6b609 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -57,6 +57,7 @@ function terminal(args: { foreground: string waitText?: string explicit?: 'working' | 'done' + titleStatus?: 'working' }) { const presence = new RuntimeTerminalAgentPresence({ getAgentPresence: () => args.presence, @@ -84,7 +85,7 @@ function terminal(args: { waitText: args.waitText ?? '', waitBlockedAt: args.waitText ? Date.now() : null, title: args.title, - titleStatus: null, + titleStatus: args.titleStatus ?? null, titleStatusIsLive: true }) return { presence, query } @@ -134,4 +135,34 @@ describe('headless terminal presence', () => { status: null }) }) + + it('shows a hookless agent started after the owner exited, as it would without presence', async () => { + for (const presence of [owner('claude', true), undefined]) { + const { presence: running, query } = terminal({ + presence, + title: 'aider', + foreground: 'aider' + }) + expect(await running.isRunning('terminal')).toBe(true) + expect(await query.getStatus('terminal')).toMatchObject({ isRunningAgent: true }) + } + }) + + it('does not revive an exited owner from its own title or foreground process', async () => { + const evidence = { + title: '✳ Claude Code', + foreground: 'claude', + titleStatus: 'working' + } as const + const exited = terminal({ presence: owner('claude', true), ...evidence }) + expect(await exited.presence.isRunning('terminal')).toBe(false) + expect(await exited.query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: false, + status: null + }) + // The same evidence without presence reads as a running Claude. + const legacy = terminal({ presence: undefined, ...evidence }) + expect(await legacy.presence.isRunning('terminal')).toBe(true) + }) }) diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index 2525bff41dad..e2a78abf9a52 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -1,4 +1,10 @@ -import { selectAgentPresence, type AgentProcessPresence } from '../../shared/agent-process-presence' +import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import { + paneEvidenceAgent, + paneEvidenceCounts, + selectLiveOwnerAgent, + withoutEndedOwnerTitle +} from '../../shared/ended-agent-owner-evidence' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -39,6 +45,15 @@ export type RuntimeTerminalAgentPresenceOptions = { foregroundProcess?: string | null } +/** Once the identified owner exited, its own launch intent no longer describes the pane. */ +function withoutEndedOwnerLaunch( + pty: T, + presence: AgentProcessPresence | undefined +): T { + const launchAgent = paneEvidenceAgent(presence, pty.launchAgent) + return launchAgent === pty.launchAgent ? pty : { ...pty, launchAgent } +} + export class RuntimeTerminalAgentPresence { constructor(private readonly deps: RuntimeTerminalAgentPresenceDependencies) {} @@ -53,18 +68,16 @@ export class RuntimeTerminalAgentPresence { if (this.deps.isLiveStructuredAgent?.(handle)) { return true } - const owner = selectAgentPresence(this.deps.getAgentPresence?.(handle)) - if (owner === null) { - return false - } + const presence = this.deps.getAgentPresence?.(handle) try { const pty = this.deps.getLivePty(handle) if (pty) { - return await this.isPtyRunning(pty, this.deps.getPrimaryLeaf(pty.ptyId), options, owner) + return await this.isPtyRunning(pty, this.deps.getPrimaryLeaf(pty.ptyId), options, presence) } const leaf = this.deps.getLiveLeaf(handle) - const trackedPty = leaf.ptyId ? this.deps.getTrackedPty(leaf.ptyId) : null - const paneTitle = getLatestLeafTitle(leaf, null) + const tracked = leaf.ptyId ? this.deps.getTrackedPty(leaf.ptyId) : null + const trackedPty = tracked && withoutEndedOwnerLaunch(tracked, presence) + const paneTitle = withoutEndedOwnerTitle(getLatestLeafTitle(leaf, null), presence) const paneClassification = classifyAgentTitle(paneTitle) if ( trackedPty @@ -73,7 +86,7 @@ export class RuntimeTerminalAgentPresence { ) { return true } - const tabTitle = this.deps.getTabTitle(leaf.tabId) + const tabTitle = withoutEndedOwnerTitle(this.deps.getTabTitle(leaf.tabId), presence) const tabClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' if ( trackedPty @@ -95,6 +108,7 @@ export class RuntimeTerminalAgentPresence { } const suppressClaude = paneClassification === 'management' || tabClassification === 'management' + const owner = selectLiveOwnerAgent(presence) if (owner) { return !(suppressClaude && owner === 'claude') } @@ -109,7 +123,8 @@ export class RuntimeTerminalAgentPresence { leaf.ptyId, foreground, suppressClaude, - options.retryForegroundWrappers !== false + options.retryForegroundWrappers !== false, + presence ) } catch { return false @@ -117,24 +132,31 @@ export class RuntimeTerminalAgentPresence { } private async isPtyRunning( - pty: RuntimePtyWorktreeRecord, + livePty: RuntimePtyWorktreeRecord, leaf: RuntimeLeafRecord | null, options: RuntimeTerminalAgentPresenceOptions, - owner: TuiAgent | undefined + presence: AgentProcessPresence | undefined ): Promise { - const leafTitle = leaf - ? getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } - ) - : null + const pty = withoutEndedOwnerLaunch(livePty, presence) + const leafTitle = withoutEndedOwnerTitle( + leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null, + presence + ) const leafClassification = classifyAgentTitle(leafTitle) if (ptyTitleProvesAgentPresence(pty, leafTitle, leafClassification)) { return true } - const ptyTitle = getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + const ptyTitle = withoutEndedOwnerTitle( + getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ), + presence ) const ptyClassification = classifyAgentTitle(ptyTitle) if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyClassification)) { @@ -165,6 +187,7 @@ export class RuntimeTerminalAgentPresence { ? leafClassification === 'management' : managementClassification === 'management' // Why: a live identified owner stands in for the foreground read, never for a management screen. + const owner = selectLiveOwnerAgent(presence) if (owner) { return !(suppressClaude && owner === 'claude') } @@ -179,7 +202,8 @@ export class RuntimeTerminalAgentPresence { pty.ptyId, foreground, suppressClaude, - options.retryForegroundWrappers !== false + options.retryForegroundWrappers !== false, + presence ) } @@ -197,11 +221,15 @@ export class RuntimeTerminalAgentPresence { ptyId: string, foregroundProcess: string, suppressClaude: boolean, - retryForegroundWrappers: boolean + retryForegroundWrappers: boolean, + presence: AgentProcessPresence | undefined ): Promise { const recognized = recognizeAgentProcess(foregroundProcess) if (recognized) { - return !(suppressClaude && isExpectedAgentProcess(recognized.processName, 'claude')) + return ( + paneEvidenceCounts(presence, recognized.agent) && + !(suppressClaude && isExpectedAgentProcess(recognized.processName, 'claude')) + ) } if (!isAgentForegroundWrapperProcess(foregroundProcess)) { return false @@ -215,8 +243,9 @@ export class RuntimeTerminalAgentPresence { const refreshed = await this.deps.getForegroundProcess(ptyId) const refreshedRecognition = recognizeAgentProcess(refreshed) if (refreshedRecognition) { - return !( - suppressClaude && isExpectedAgentProcess(refreshedRecognition.processName, 'claude') + return ( + paneEvidenceCounts(presence, refreshedRecognition.agent) && + !(suppressClaude && isExpectedAgentProcess(refreshedRecognition.processName, 'claude')) ) } if (!refreshed || !isAgentForegroundWrapperProcess(refreshed)) { diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 5ba43bb6e3dc..e949302ceb86 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,4 +1,8 @@ -import { selectAgentPresence, type AgentProcessPresence } from '../../shared/agent-process-presence' +import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import { + selectLiveOwnerAgent, + withoutEndedOwnerTitle +} from '../../shared/ended-agent-owner-evidence' import { detectAgentStatusFromTitle, isClaudeManagementTitle, @@ -69,7 +73,7 @@ export class RuntimeTerminalAgentStatusQuery { const terminal = this.getSnapshot(handle, ptyId) const explicitStatus = this.deps.getExplicitStatus(handle) const lifecycle = this.deps.getLifecycleStatus(ptyId) - const presence = selectAgentPresence(this.deps.getAgentPresence?.(handle)) + const presence = this.deps.getAgentPresence?.(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && @@ -98,13 +102,11 @@ export class RuntimeTerminalAgentStatusQuery { return { handle, isRunningAgent: true, status: 'permission' } } // Presence answers only whether an agent owns the pane; the permission evidence above still wins. - if (presence === null) { - return { handle, isRunningAgent: false, status: null } - } + const owner = selectLiveOwnerAgent(presence) if (explicitStatus) { // Why: permission titles can linger after hooks report the agent resumed. Fresh hook state is // tighter, but current shell/management evidence wins; a live owner answers for the shell. - const isRunningAgent = presence + const isRunningAgent = owner ? !(terminal.title !== null && isClaudeManagementTitle(terminal.title)) : !terminalTitleBlocksExplicitAgentStatus(terminal.title) && !(await this.terminalHasShellForegroundProcess(handle, ptyId)) @@ -115,7 +117,12 @@ export class RuntimeTerminalAgentStatusQuery { status: isRunningAgent ? explicitStatus.status : null } } - if (terminal.titleStatus) { + // An exited owner's own title is history; titles naming other agents still count. + const titleStatus = + withoutEndedOwnerTitle(terminal.title, presence) === terminal.title + ? terminal.titleStatus + : null + if (titleStatus) { // Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity, // not identity, so resolve both through the identity/foreground evidence path. if ( @@ -127,10 +134,10 @@ export class RuntimeTerminalAgentStatusQuery { return { handle, isRunningAgent, - status: isRunningAgent ? terminal.titleStatus : null + status: isRunningAgent ? titleStatus : null } } - return { handle, isRunningAgent: true, status: terminal.titleStatus } + return { handle, isRunningAgent: true, status: titleStatus } } const isRunningAgent = await this.deps.isRunning(handle) diff --git a/src/renderer/src/lib/agent-presence-selectors.ts b/src/renderer/src/lib/agent-presence-selectors.ts index 68f0a4f33c20..0044b49f57ee 100644 --- a/src/renderer/src/lib/agent-presence-selectors.ts +++ b/src/renderer/src/lib/agent-presence-selectors.ts @@ -1,11 +1,20 @@ import { shallow } from 'zustand/shallow' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { AgentProcessPresence } from '../../../shared/agent-process-presence' -import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' -import { isTuiAgent } from '../../../shared/tui-agent-config' +import { + selectLiveOwnerAgent, + withoutEndedOwnerEvidence, + type PaneEvidenceSignals +} from '../../../shared/ended-agent-owner-evidence' import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentPresenceByPaneKey, AgentPresenceRecord } from '@/store/slices/agent-presence' -import { isLegacyUnidentified } from './legacy-unidentified-agent-presence' + +export { + paneEvidenceAgent, + paneEvidenceCounts, + selectLiveOwnerAgent, + withoutEndedOwnerEvidence +} from '../../../shared/ended-agent-owner-evidence' const EMPTY_PRESENCE: AgentPresenceByPaneKey = Object.freeze({}) let previousPresence: AgentPresenceByPaneKey | undefined @@ -52,59 +61,6 @@ export function selectFocusedPanePresence( return only.length === 1 ? only[0].presence : undefined } -/** A live identified owner decides its own pane; undefined leaves the pane to legacy signals. */ -export function selectLiveOwnerAgent(presence?: AgentProcessPresence): TuiAgent | undefined { - if (!presence || isLegacyUnidentified(presence)) { - return undefined - } - return isTuiAgent(presence.agent) ? presence.agent : undefined -} - -/** After the identified owner exits, only evidence naming a different agent still describes the pane. */ -export function paneEvidenceCounts( - presence: AgentProcessPresence | undefined, - agent: string | null | undefined -): boolean { - if (!presence?.process || !presence.ended) { - return true - } - return Boolean(agent) && agent !== 'unknown' && agent !== presence.agent -} - -export function paneEvidenceAgent( - presence: AgentProcessPresence | undefined, - agent: T | null | undefined -): T | null { - return agent && paneEvidenceCounts(presence, agent) ? agent : null -} - -type PaneEvidenceSignals = { - title: string - hookAgent: TuiAgent | null - focusedCompletedHookAgent?: TuiAgent | null - processAgent?: TuiAgent | null - launchAgent?: TuiAgent -} - -/** The signals a legacy resolver may still read once an ended owner's own evidence is set aside. */ -export function withoutEndedOwnerEvidence( - args: T, - presence: AgentProcessPresence | undefined -): T { - if (!presence?.process || !presence.ended) { - return args - } - const titleAgent = resolveExplicitTerminalTitleAgentType(args.title) - return { - ...args, - title: titleAgent === presence.agent ? '' : args.title, - hookAgent: paneEvidenceAgent(presence, args.hookAgent), - focusedCompletedHookAgent: paneEvidenceAgent(presence, args.focusedCompletedHookAgent), - processAgent: paneEvidenceAgent(presence, args.processAgent), - launchAgent: paneEvidenceAgent(presence, args.launchAgent) ?? undefined - } -} - /** One per-pane rule for every identity reader: live owner, else legacy minus ended-owner evidence. */ export function resolvePaneAgentWithPresence( presence: AgentProcessPresence | undefined, diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index aeebe0f1fd17..d0d84a6b7b14 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -1,5 +1,3 @@ -import { isTuiAgent } from './tui-agent-config' -import type { TuiAgent } from './tui-agent' import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' import type { AgentType } from './agent-status-types' @@ -76,14 +74,6 @@ export function isSameAgentProcess(a: AgentProcessIdentity, b: AgentProcessIdent return a.pid === b.pid && a.platform === b.platform && a.startTime === b.startTime } -/** Undefined means this host has not published a process identity. */ -export function selectAgentPresence(presence?: AgentProcessPresence): TuiAgent | null | undefined { - if (!presence?.process) { - return undefined - } - return !presence.ended && isTuiAgent(presence.agent) ? presence.agent : null -} - export type AgentPresenceObservation = { epoch: string; sequence: number } export function readAgentPresenceObservation(value: unknown): AgentPresenceObservation | undefined { diff --git a/src/shared/ended-agent-owner-evidence.ts b/src/shared/ended-agent-owner-evidence.ts new file mode 100644 index 000000000000..1f10d6daa60d --- /dev/null +++ b/src/shared/ended-agent-owner-evidence.ts @@ -0,0 +1,72 @@ +import type { AgentProcessPresence } from './agent-process-presence' +import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' + +function isEndedIdentifiedOwner(presence: AgentProcessPresence | undefined): boolean { + return presence?.process !== undefined && presence.ended === true +} + +/** A live identified owner decides its own pane; undefined leaves the pane to legacy signals. */ +export function selectLiveOwnerAgent(presence?: AgentProcessPresence): TuiAgent | undefined { + if (!presence?.process || presence.ended) { + return undefined + } + return isTuiAgent(presence.agent) ? presence.agent : undefined +} + +/** After the identified owner exits, only evidence naming a different agent still describes the pane. */ +export function paneEvidenceCounts( + presence: AgentProcessPresence | undefined, + agent: string | null | undefined +): boolean { + if (!isEndedIdentifiedOwner(presence)) { + return true + } + return Boolean(agent) && agent !== 'unknown' && agent !== presence?.agent +} + +export function paneEvidenceAgent( + presence: AgentProcessPresence | undefined, + agent: T | null | undefined +): T | null { + return agent && paneEvidenceCounts(presence, agent) ? agent : null +} + +/** A title naming the ended owner is that owner's history, not evidence of an agent. */ +export function withoutEndedOwnerTitle( + title: T, + presence: AgentProcessPresence | undefined +): T | '' { + return title && + isEndedIdentifiedOwner(presence) && + resolveExplicitTerminalTitleAgentType(title) === presence?.agent + ? '' + : title +} + +export type PaneEvidenceSignals = { + title: string + hookAgent: TuiAgent | null + focusedCompletedHookAgent?: TuiAgent | null + processAgent?: TuiAgent | null + launchAgent?: TuiAgent +} + +/** The signals a legacy resolver may still read once an ended owner's own evidence is set aside. */ +export function withoutEndedOwnerEvidence( + args: T, + presence: AgentProcessPresence | undefined +): T { + if (!isEndedIdentifiedOwner(presence)) { + return args + } + return { + ...args, + title: withoutEndedOwnerTitle(args.title, presence), + hookAgent: paneEvidenceAgent(presence, args.hookAgent), + focusedCompletedHookAgent: paneEvidenceAgent(presence, args.focusedCompletedHookAgent), + processAgent: paneEvidenceAgent(presence, args.processAgent), + launchAgent: paneEvidenceAgent(presence, args.launchAgent) ?? undefined + } +} From 20c33433547a6169fd0c3fa5f4af453f8c10a964 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:56:27 -0700 Subject: [PATCH 08/27] test(agent-presence): use a real explicit status state in the headless presence fixture --- src/main/runtime/runtime-terminal-agent-presence.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts index d3c95ce6b609..7808e84dd99d 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -56,7 +56,7 @@ function terminal(args: { title: string foreground: string waitText?: string - explicit?: 'working' | 'done' + explicit?: 'working' titleStatus?: 'working' }) { const presence = new RuntimeTerminalAgentPresence({ From 1bea899d96b4bc5a370861360b2241ac815c0a2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:12:42 -0700 Subject: [PATCH 09/27] fix(agent-presence): let a shell in front veto a live owner for keyboard routing A live identified owner proves the agent exists, not that it holds the terminal. After Ctrl-Z (the probe reads a stopped process as unverifiable, so the owner stays live), the running-agent check and the status query said Claude was running, so guarded sends, dispatch --inject, agent-launch reuse and worker-start typed into the shell. The foreground read now decides again: a confirmed shell in front says no, and the owner stands in only when the read cannot answer (unavailable, or a process name the recogniser does not know). A live owner still overrides a shell-like title, and the management, permission and exited-owner rules are unchanged. --- .../runtime-terminal-agent-presence.test.ts | 60 +++++++++++++++++-- .../runtime-terminal-agent-presence.ts | 40 ++++++------- .../runtime-terminal-agent-status-query.ts | 11 ++-- 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts index 7808e84dd99d..fdbb41b572b5 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -4,6 +4,9 @@ import { describe, expect, it, vi } from 'vitest' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import type { OrcaRuntimeService } from './orca-runtime' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' import type { AgentProcessPresence } from '../../shared/agent-process-presence' const CURSOR_APPROVAL = readFileSync( @@ -54,7 +57,7 @@ function leaf(title: string): RuntimeLeafRecord { function terminal(args: { presence: AgentProcessPresence | undefined title: string - foreground: string + foreground: string | null waitText?: string explicit?: 'working' titleStatus?: 'working' @@ -68,9 +71,15 @@ function terminal(args: { getTabTitle: () => null, getForegroundProcess: async () => args.foreground }) + const controller: RuntimePtyController = { + write: () => false, + kill: () => false, + getForegroundProcess: async () => args.foreground, + confirmForegroundProcess: async () => args.foreground + } const query = new RuntimeTerminalAgentStatusQuery({ getAgentPresence: () => args.presence, - getController: () => null, + getController: () => controller, getLivePty: () => null, getLiveLeaf: () => ({ leaf: leaf(args.title) }), getPrimaryLeaf: () => null, @@ -93,18 +102,59 @@ function terminal(args: { describe('headless terminal presence', () => { it.each([false, true])('uses host presence under a shell title (ended=%s)', async (ended) => { - const { query } = terminal({ + const { presence, query } = terminal({ presence: owner('claude', ended), title: 'zsh', - foreground: 'zsh' + foreground: 'claude', + explicit: 'working' }) + expect(await presence.isRunning('terminal')).toBe(!ended) expect(await query.getStatus('terminal')).toEqual({ handle: 'terminal', isRunningAgent: !ended, - status: null + status: ended ? null : 'working' }) }) + it('answers for an owner whose foreground read is unavailable', async () => { + const { presence, query } = terminal({ + presence: owner('claude'), + title: 'zsh', + foreground: null + }) + expect(await presence.isRunning('terminal')).toBe(true) + expect(await query.getStatus('terminal')).toMatchObject({ isRunningAgent: true }) + }) + + it('refuses to send into the shell in front of a suspended owner', async () => { + // Ctrl-Z leaves the owner alive (the probe reads a stopped process as unverifiable). + for (const explicit of ['working', undefined] as const) { + const { presence, query } = terminal({ + presence: owner('claude'), + title: 'user@host: ~/repo', + foreground: 'bash', + explicit + }) + expect(await presence.isRunning('terminal')).toBe(false) + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: false, + status: null + }) + const runtime: Pick = { + getTerminalAgentStatus: (handle) => query.getStatus(handle) + } + await expect( + assertTerminalAgentSendable({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guard reads only getTerminalAgentStatus. + runtime: runtime as OrcaRuntimeService, + handle: 'terminal', + assertWritable: () => {} + }) + ).rejects.toThrow('terminal_guard_no_agent') + } + }) + it('keeps the approval-menu permission ahead of an identified owner', async () => { // Cursor reports its approval gate as working; only the screen shows the menu. const { query } = terminal({ diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index e2a78abf9a52..53b283f747ff 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -10,7 +10,7 @@ import { isExpectedAgentProcess, recognizeAgentProcess } from '../../shared/agent-process-recognition' -import { isOpenCodeNativeTitle } from '../../shared/agent-detection' +import { isOpenCodeNativeTitle, isShellProcess } from '../../shared/agent-detection' import type { TuiAgent } from '../../shared/tui-agent' import { isKnownReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -108,24 +108,7 @@ export class RuntimeTerminalAgentPresence { } const suppressClaude = paneClassification === 'management' || tabClassification === 'management' - const owner = selectLiveOwnerAgent(presence) - if (owner) { - return !(suppressClaude && owner === 'claude') - } - const foreground = await this.readForegroundProcess(leaf.ptyId, options) - if (!foreground) { - return false - } - if (suppressClaude && isExpectedAgentProcess(foreground, 'claude')) { - return false - } - return await this.isRecognizedForegroundAgentProcess( - leaf.ptyId, - foreground, - suppressClaude, - options.retryForegroundWrappers !== false, - presence - ) + return await this.isForegroundAgent(leaf.ptyId, presence, suppressClaude, options) } catch { return false } @@ -186,12 +169,23 @@ export class RuntimeTerminalAgentPresence { leafTitle !== null ? leafClassification === 'management' : managementClassification === 'management' - // Why: a live identified owner stands in for the foreground read, never for a management screen. + return await this.isForegroundAgent(pty.ptyId, presence, suppressClaude, options) + } + + /** Presence proves the agent exists; the foreground read still decides who holds the keyboard. */ + private async isForegroundAgent( + ptyId: string, + presence: AgentProcessPresence | undefined, + suppressClaude: boolean, + options: RuntimeTerminalAgentPresenceOptions + ): Promise { + const foreground = await this.readForegroundProcess(ptyId, options).catch(() => null) const owner = selectLiveOwnerAgent(presence) - if (owner) { + // Why: a live owner answers only when the read cannot (unavailable, or a name like wsl.exe); + // a shell in front, as after Ctrl-Z, still owns the keyboard. Never over a management screen. + if (owner && !(foreground && isShellProcess(foreground))) { return !(suppressClaude && owner === 'claude') } - const foreground = await this.readForegroundProcess(pty.ptyId, options) if (!foreground) { return false } @@ -199,7 +193,7 @@ export class RuntimeTerminalAgentPresence { return false } return await this.isRecognizedForegroundAgentProcess( - pty.ptyId, + ptyId, foreground, suppressClaude, options.retryForegroundWrappers !== false, diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index e949302ceb86..9d2dbed41d6b 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -105,11 +105,12 @@ export class RuntimeTerminalAgentStatusQuery { const owner = selectLiveOwnerAgent(presence) if (explicitStatus) { // Why: permission titles can linger after hooks report the agent resumed. Fresh hook state is - // tighter, but current shell/management evidence wins; a live owner answers for the shell. - const isRunningAgent = owner - ? !(terminal.title !== null && isClaudeManagementTitle(terminal.title)) - : !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + // tighter, but current shell/management evidence wins; a live owner overrides only a shell title. + const titleBlocks = owner + ? terminal.title !== null && isClaudeManagementTitle(terminal.title) + : terminalTitleBlocksExplicitAgentStatus(terminal.title) + const isRunningAgent = + !titleBlocks && !(await this.terminalHasShellForegroundProcess(handle, ptyId)) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, From 78d9746f5eeca0a0f53fa528e2e66741bd7151bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:16:50 -0700 Subject: [PATCH 10/27] refactor(agent-presence): decide relay-owned frames in one place After step 2's relay-owner adoption, the transition's host-owned branch was reachable only for replayed exits. That acceptance now sits beside the adoption in applyNormalizedStatus (a host-stamped replay of the relay's own settled exit), so the shared transition is back to step 2's host-independent rule and live exits keep the exact-owner fence. --- .../agent-hooks/server/server-status-update.ts | 9 ++++++--- src/shared/agent-hook-presence-transition.ts | 14 -------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 0662fb8d505f..e6b082f8ed30 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -30,14 +30,17 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { // Why: a relay already chose the live owner on its own host; re-deciding against a record it - // replaced would pin the pane to an owner this desktop can never check. Exits still need our fence. + // replaced would pin the pane to an owner this desktop can never check. Exits still need our + // fence, except the host-stamped replay of the relay's own settled exit. const relayOwner = incoming.connectionId !== null ? incoming.agentPresence : undefined // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. const stored = this.state.lastStatusByPaneKey.get(incoming.paneKey) as | EnrichedAgentHookEventPayload | undefined - const transitioned = - relayOwner?.process && !relayOwner.ended ? incoming : transitionHookPresence(incoming, stored) + const relayDecided = + relayOwner?.process && + (!relayOwner.ended || (incoming.agentPresenceFromExecutionHost && incoming.isReplay === true)) + const transitioned = relayDecided ? incoming : transitionHookPresence(incoming, stored) if (!transitioned) { return undefined } diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index a3fee1abee5e..a7401d4ecc98 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -42,20 +42,6 @@ export function transitionHookPresence( // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved // exit); other agents' SessionEnd hooks are ordinary status updates. const exit = incoming.agentPresence?.ended === true - // A replay is the relay store’s ordered snapshot, not a new hook from a possibly stale sender. - if ( - (!exit || incoming.isReplay === true) && - incoming.agentPresenceFromExecutionHost && - sender && - typeof incoming.connectionId === 'string' - ) { - return !exit && - recorded?.ended && - recorded.process && - isSameAgentProcess(recorded.process, sender) - ? undefined - : incoming - } if (owner) { if (exit) { const fromOwner = owner.process && sender && isSameAgentProcess(owner.process, sender) From 7fd2b5c65bb01e963d48284d476eecacd1de6782 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:17:00 -0700 Subject: [PATCH 11/27] fix(agent-presence): keep host provenance off stored status rows The remote-ingress tag that says "the execution host decided this owner" describes one admission, but it was spread onto the stored row and so into the last-status file and retained rows, where nothing reads it. It is now taken out before the row is written and used only for that admission's owner-replacement decision. --- .../server-agent-presence-remote-replay.test.ts | 13 ++++++++++++- src/main/agent-hooks/server/server-status-update.ts | 8 ++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts index 62420556176d..e0add6ae777d 100644 --- a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts +++ b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts @@ -42,9 +42,14 @@ function claudeHook( { worktreeId: 'folder-1', agentProcess: JSON.stringify(agentProcess) } ) } +class InspectableServer extends AgentHookServer { + storedRow(paneKey: string) { + return this.state.lastStatusByPaneKey.get(paneKey) + } +} async function setup() { let connected = true - const main = new AgentHookServer() + const main = new InspectableServer() const frames: AgentHookRelayEnvelope[] = [] const dir = mkdtempSync(join(tmpdir(), 'remote-replay-')) dirs.push(dir) @@ -131,6 +136,12 @@ describe('execution host presence replay', () => { agentPresence: replacement }) }) + it('keeps host provenance off the stored row', async () => { + const { main, relayHook } = await setup() + await relayHook('SessionStart', 'a', owner.process) + expect(main.storedRow(PANE)?.agentPresence).toMatchObject(owner) + expect(main.storedRow(PANE)).not.toHaveProperty('agentPresenceFromExecutionHost') + }) it('retains the ordering of a connected exit when an older live frame replays', async () => { const { main, relay, relayHook, frames } = await setup() await relayHook('SessionStart', 'a', owner.process) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index e6b082f8ed30..f893f64d6811 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -44,12 +44,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!transitioned) { return undefined } - const { authorityRestartId, ...payload } = { ...incoming, ...transitioned } + // Host provenance describes this admission, never the stored row. + const { authorityRestartId, agentPresenceFromExecutionHost, ...payload } = { + ...incoming, + ...transitioned + } if (!this.canWriteLegacyStatusRow(payload)) { return undefined } // Why: the execution host already replaced this owner; the mirrored row is a dead process's, not a parent turn. - const hostOwner = payload.agentPresenceFromExecutionHost && payload.agentPresence?.process + const hostOwner = agentPresenceFromExecutionHost && payload.agentPresence?.process const replacesStaleOwner = Boolean( hostOwner && stored?.agentPresence?.process && From 05f873b58110838807a8e229235209c7a1c19874 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:18:20 -0700 Subject: [PATCH 12/27] fix(agent-presence): add host presence to the published tab status instead of replacing it Capable clients received the host's hook row in place of the tab's published status, so a paired client lost the pane's activity history, unread count, prompt search and orchestration labels, and the phone lost the title correction for a stale working turn. The host's owner record is now added to the published status; the hook row stands in only when the pane published no status at all (Claude idle under a shell title). Old clients still get the exact legacy row. --- .../session-tab-presence-projection.test.ts | 29 ++++++++++++++++++- .../session-tab-presence-projection.ts | 10 ++++++- .../runtime-mobile-agent-presence.test.ts | 3 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts index 1919169d8b30..a0d4235310ee 100644 --- a/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts @@ -1,4 +1,5 @@ import { UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from '../../../../shared/runtime-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -18,7 +19,22 @@ const status = { ended: true } } as const -function snapshot(covered: boolean): RuntimeMobileSessionTabsResult { +const published: AgentStatusEntry = { + state: 'working', + prompt: 'second task', + paneKey: 'tab:leaf', + updatedAt: 30, + stateStartedAt: 20, + turnStartedAt: 20, + agentType: 'claude', + lastCompletedAssistantMessage: 'first answer', + orchestration: { taskId: 'task-1', dispatchId: 'dispatch-1', taskTitle: 'Refactor' }, + stateHistory: [{ state: 'done', prompt: 'first task', startedAt: 10 }] +} +function snapshot( + covered: boolean, + agentStatus?: AgentStatusEntry +): RuntimeMobileSessionTabsResult { const tab = { type: 'terminal', id: 'tab::leaf', @@ -28,6 +44,7 @@ function snapshot(covered: boolean): RuntimeMobileSessionTabsResult { isActive: true, status: 'pending-handle', terminal: null, + ...(agentStatus ? { agentStatus } : {}), ...(covered ? { [HOST_AGENT_PRESENCE_STATUS]: status } : {}) } as const return { @@ -64,4 +81,14 @@ describe('connection scoped presence projection', () => { snapshot(false).tabs ) }) + it('adds host presence to the published status instead of replacing it', () => { + const capable = [AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY] + expect(projectSessionTabPresenceForClient(snapshot(true, published), capable).tabs[0]).toEqual({ + ...snapshot(false, published).tabs[0], + agentStatus: { ...published, agentPresence: status.agentPresence } + }) + expect(projectSessionTabPresenceForClient(snapshot(true, published), []).tabs).toEqual( + snapshot(false, published).tabs + ) + }) }) diff --git a/src/main/runtime/rpc/methods/session-tab-presence-projection.ts b/src/main/runtime/rpc/methods/session-tab-presence-projection.ts index 7d7edcef8490..cfd5abc61369 100644 --- a/src/main/runtime/rpc/methods/session-tab-presence-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-presence-projection.ts @@ -23,7 +23,15 @@ export function projectSessionTabPresenceForClient( return legacy } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This private symbol is minted only by projectHostAgentPresenceStatus; JSON cannot supply it. - return { ...legacy, agentStatus: status as AgentStatusEntry } + const hostStatus = status as AgentStatusEntry + // Why: presence is an identity fact; the published row keeps its history, labels and title + // corrections. The host row stands in only when a pane published no status at all. + return { + ...legacy, + agentStatus: legacy.agentStatus + ? { ...legacy.agentStatus, agentPresence: hostStatus.agentPresence } + : hostStatus + } }) } } diff --git a/src/main/runtime/runtime-mobile-agent-presence.test.ts b/src/main/runtime/runtime-mobile-agent-presence.test.ts index bcd769f88099..3b55f0eb1f96 100644 --- a/src/main/runtime/runtime-mobile-agent-presence.test.ts +++ b/src/main/runtime/runtime-mobile-agent-presence.test.ts @@ -127,8 +127,9 @@ describe('headless mobile owner projection', () => { ...spoofedAuthority }) setRows([row]) + // The published row keeps its own fields; only the host's presence replaces the client's. expect(tabFor(capable)).toMatchObject({ - agentStatus: { agentPresence: row.agentPresence, updatedAt: 10, stateStartedAt: 5 } + agentStatus: { agentPresence: row.agentPresence, updatedAt: 99999, stateStartedAt: 99999 } }) expect(tabFor([])).not.toHaveProperty('agentStatus.agentPresence') setRows([]) From abfdb221ddef2f7ea74a3cff223ba4eaed0882f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:19:53 -0700 Subject: [PATCH 13/27] fix(agent-presence): keep host-local rows local on the paired client The mirror had started turning a host row's null connection into the environment id, which made the tab palette drop every mirrored agent's prompt and task metadata, and it attributed any row without a worktree to the snapshot's worktree. It is back to main's rule (only a missing connection takes the environment id); only rows carrying a host-identified owner get the snapshot's worktree, so their exit and retraction can still clear them. --- .../web-session-tabs-sync-presence.test.ts | 15 +++++++++++++++ .../web-session-tabs-sync/agent-status-patch.ts | 14 +++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts index df6fb88640d6..e33f6d48fcb8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -127,4 +127,19 @@ describe('paired host presence', () => { expect(Object.values(next.tabsByWorktree).flat()).toHaveLength(0) expect(next.agentPresenceByPaneKey?.[paneKey]).toBeUndefined() }) + + it('keeps a host-local row local and leaves unidentified attribution to the host', () => { + const state = makeState() + const mirror = (row: Partial) => { + const next = { + ...state, + ...applyWebSessionTabsSnapshot(state, snapshot(false, row), ENV, NOW) + } + return Object.values(next.agentStatusByPaneKey)[0] + } + expect(mirror({ connectionId: null })).toMatchObject({ connectionId: null, worktreeId: WT }) + const unidentified = mirror({ connectionId: null, agentPresence: undefined }) + expect(unidentified?.connectionId).toBeNull() + expect(unidentified?.worktreeId).toBeUndefined() + }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 253c3882a078..81e365c92b5a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -148,12 +148,16 @@ export function buildMirroredAgentStatusPatch( } const existing = nextByPaneKey.get(hostEntry.paneKey) ?? state.agentStatusByPaneKey[hostEntry.paneKey] + // Why: an identified row must be owned here so its exit and retraction can clear it; other rows + // keep the host's own attribution. + const attributed = + presence?.process && hostEntry.worktreeId === undefined + ? { ...hostEntry, worktreeId } + : hostEntry const entry = withMirroredEvidenceReceipt( - { - ...hostEntry, - worktreeId: hostEntry.worktreeId ?? worktreeId, - connectionId: hostEntry.connectionId ?? environmentId - }, + attributed.connectionId === undefined + ? { ...attributed, connectionId: environmentId } + : attributed, existing, now ) From 4773e1776e8d7f794d65c81a13e6dad42be6fe8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:20:13 -0700 Subject: [PATCH 14/27] fix(agent-presence): project an exited owner as identity only When a pane has no published status, the host's own row stands in for capable clients. For an owner that has exited it now carries only the owner record and clocks, not the exited agent's provider session, model or type, so a later agent in that pane can never open the old agent's transcript in Chat or inherit its model or paste format. --- .../runtime-mobile-agent-presence-projection.ts | 15 +++++++++++---- .../runtime-mobile-agent-presence.test.ts | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/runtime-mobile-agent-presence-projection.ts b/src/main/runtime/runtime-mobile-agent-presence-projection.ts index 8ac50637dbad..23f4e43ccdd6 100644 --- a/src/main/runtime/runtime-mobile-agent-presence-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-presence-projection.ts @@ -18,9 +18,7 @@ export function projectHostAgentPresenceStatus( if (!row?.agentPresence?.process) { return undefined } - return { - ...pickParsedAgentStatusPayload(row), - agentType: row.agentPresence.agent, + const identity = { agentPresence: row.agentPresence, paneKey: row.paneKey, updatedAt: row.receivedAt, @@ -29,7 +27,16 @@ export function projectHostAgentPresenceStatus( ...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}), ...(row.terminalHandle ? { terminalHandle: row.terminalHandle } : {}), ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), - ...(row.tabId ? { tabId: row.tabId } : {}), + ...(row.tabId ? { tabId: row.tabId } : {}) + } + // Why: an exited owner is identity history; its session, model and type must not reach a successor. + if (row.agentPresence.ended) { + return { ...identity, state: row.state, prompt: '' } + } + return { + ...pickParsedAgentStatusPayload(row), + ...identity, + agentType: row.agentPresence.agent, ...(row.providerSession ? { providerSession: row.providerSession } : {}) } } diff --git a/src/main/runtime/runtime-mobile-agent-presence.test.ts b/src/main/runtime/runtime-mobile-agent-presence.test.ts index 3b55f0eb1f96..31af576b0511 100644 --- a/src/main/runtime/runtime-mobile-agent-presence.test.ts +++ b/src/main/runtime/runtime-mobile-agent-presence.test.ts @@ -101,11 +101,23 @@ describe('headless mobile owner projection', () => { it('carries a positive exit through the snapshot even with a stale launch hint', () => { const { setRows, tabFor } = projection() - setRows([ownerRow(true)]) - expect(tabFor(capable)).toMatchObject({ + setRows([ + { + ...ownerRow(true), + model: 'claude-opus', + providerSession: { key: 'session_id', id: 'claude-session' } + } + ]) + const tab = tabFor(capable) + expect(tab).toMatchObject({ launchAgent: 'claude', agentStatus: { agentPresence: { agent: 'claude', process: processIdentity, ended: true } } }) + // An exited owner is identity only; its session, model and type must not reach a successor. + const status = tab?.type === 'terminal' ? tab.agentStatus : undefined + expect(status).not.toHaveProperty('providerSession') + expect(status).not.toHaveProperty('model') + expect(status).not.toHaveProperty('agentType') }) it('negotiates the full snapshot and never accepts client-published presence as host evidence', () => { From a8b41aa31a2008af39bae64fc9bf877a7396a8e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:21:08 -0700 Subject: [PATCH 15/27] fix(agent-presence): release a mirrored owner when the host publishes the pane without status The paired client kept a mirrored owner record when a newer snapshot still listed the pane but carried no status for it. Snapshots arrive in version order and capable hosts always publish a status for an owned pane, so such a pane no longer has an owner and its record is dropped. --- .../web-session-tabs-sync-presence.test.ts | 18 ++++++++++++++++++ .../agent-status-patch.ts | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts index e33f6d48fcb8..db19cd23656c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -142,4 +142,22 @@ describe('paired host presence', () => { expect(unidentified?.connectionId).toBeNull() expect(unidentified?.worktreeId).toBeUndefined() }) + + it('releases a live owner when a newer snapshot shows the pane without status', () => { + const state = makeState() + const live = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot(false), ENV, NOW) } + const paneKey = Object.keys(live.agentPresenceByPaneKey ?? {})[0] + expect(live.agentPresenceByPaneKey?.[paneKey]?.presence.process).toBeDefined() + const bare = snapshot(false, {}, 2) + const tab = bare.tabs[0] + if (tab?.type !== 'terminal') { + throw new Error('missing terminal') + } + const { agentStatus: _dropped, ...withoutStatus } = tab + const next = { + ...live, + ...applyWebSessionTabsSnapshot(live, { ...bare, tabs: [withoutStatus] }, ENV, NOW + 1) + } + expect(next.agentPresenceByPaneKey?.[paneKey]).toBeUndefined() + }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 81e365c92b5a..ad15a881d8ab 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -22,6 +22,7 @@ import { isMirroredAgentPaneKeyForTabs, batchAgentPaneKeysForTabs, remapHostAgentStatus, + toMirroredPaneKey, updateBatchAgentPaneKey } from './agent-status-primitives' import { @@ -108,6 +109,13 @@ export function buildMirroredAgentStatusPatch( ?.get(surface.leafId) const hostEntry = remapHostAgentStatus(surface, retainedSurface) if (!hostEntry) { + // Why: snapshots arrive version-ordered, so a pane published without status no longer has an owner here. + const paneKey = toMirroredPaneKey(surface, retainedSurface?.leafId) + if (paneKey && nextPresenceByPaneKey[paneKey]?.connectionId === environmentId) { + const { [paneKey]: _released, ...remaining } = nextPresenceByPaneKey + nextPresenceByPaneKey = remaining + presenceChanged = true + } continue } const presence = readAgentProcessPresence(hostEntry.agentPresence) From 01dd4ed0cdc95264e079d2d054a61a92ecc9567b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:21:40 -0700 Subject: [PATCH 16/27] perf(agent-presence): republish mirrored presence only when the owner changes Every status ping from a covered pane rewrote the paired client's owner record and bumped its status and sort epochs. The record is now written only when the owner's agent, process or exit changes, so pings no longer re-sort the sidebar or invalidate the per-tab presence index. --- .../web-session-tabs-sync-presence.test.ts | 9 ++++++++ .../agent-status-patch.ts | 22 +++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts index db19cd23656c..f7ad346cd209 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -160,4 +160,13 @@ describe('paired host presence', () => { } expect(next.agentPresenceByPaneKey?.[paneKey]).toBeUndefined() }) + + it('does not republish presence for a status ping from the same owner', () => { + const state = makeState() + const live = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot(false), ENV, NOW) } + const ping = { updatedAt: 20, prompt: 'next', state: 'working' } as const + const next = applyWebSessionTabsSnapshot(live, snapshot(false, ping, 2), ENV, NOW + 1) + expect(next).not.toBeNull() + expect(next).not.toHaveProperty('agentPresenceByPaneKey') + }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index ad15a881d8ab..af2f5df941e3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -1,4 +1,8 @@ -import { readAgentProcessPresence } from '../../../../shared/agent-process-presence' +import { + isSameAgentProcess, + readAgentProcessPresence, + type AgentProcessPresence +} from '../../../../shared/agent-process-presence' import { agentStatusAuthorityObservedAt, type AgentStatusEntry @@ -59,6 +63,17 @@ function withMirroredEvidenceReceipt( return { ...entry, mirroredEvidenceReceivedAt: receivedAt } } +/** A status ping re-sends the same owner; only a new agent, process or exit changes presence. */ +function isSameOwnerRecord(a: AgentProcessPresence, b: AgentProcessPresence): boolean { + return ( + a.agent === b.agent && + a.ended === b.ended && + a.process !== undefined && + b.process !== undefined && + isSameAgentProcess(a.process, b.process) + ) +} + export function buildMirroredAgentStatusPatch( state: WebSessionTabsSyncState, currentTerminalTabs: readonly TerminalTab[], @@ -132,9 +147,8 @@ export function buildMirroredAgentStatusPatch( } else if ( presence?.process && hostIsCurrent && - (previousPresence?.receivedAt !== hostEntry.updatedAt || - previousPresence.connectionId !== environmentId || - JSON.stringify(previousPresence.presence) !== JSON.stringify(presence)) + (previousPresence?.connectionId !== environmentId || + !isSameOwnerRecord(previousPresence.presence, presence)) ) { nextPresenceByPaneKey = { ...nextPresenceByPaneKey, From 41071c4dcecc53e505bc978188c0b3214273af16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:29:50 -0700 Subject: [PATCH 17/27] fix(agent-presence): keep main's keyboard rules for owners recorded on another host A live owner recorded from another host (a WSL guest relay or an SSH relay) cannot be checked by this host, so it no longer stands in for an unrecognised foreground such as wsl.exe or overrides a shell title in the running-agent and status checks. Such panes read exactly as on main: a WSL Claude that died without its exit hook no longer reads as present with sends going to the shell. Local owners this host can check keep the stand-in; exited owners still hide only their own evidence. --- .../orca-runtime-stop-requested-pty-ids.ts | 7 +++- .../runtime-terminal-agent-presence.test.ts | 41 ++++++++++++++++++- .../runtime-terminal-agent-presence.ts | 18 ++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 40b552c68786..b82742d987ad 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -5,7 +5,10 @@ import { localOrchestrationCliCommand } from './orchestration/cli-command' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' -import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' +import { + RuntimeTerminalAgentPresence, + selectKeyboardAgentPresence +} from './runtime-terminal-agent-presence' import type { RuntimeNotifier } from './runtime-notifier-contract' import { RuntimeClientEventBus } from './runtime-client-event-bus' import { RuntimeNativeChatDraftResolutions } from './runtime-native-chat-draft-resolutions' @@ -173,7 +176,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId const rows = Array.from(this.collectAgentStatusPaneKeysForPty(ptyId)).flatMap( (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? [] ) - return rows.sort((a, b) => b.receivedAt - a.receivedAt)[0]?.agentPresence + return selectKeyboardAgentPresence(rows) } catch { return undefined } diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts index fdbb41b572b5..ae5fd80bff1f 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -2,12 +2,16 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' -import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' +import { + RuntimeTerminalAgentPresence, + selectKeyboardAgentPresence +} from './runtime-terminal-agent-presence' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { OrcaRuntimeService } from './orca-runtime' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' const CURSOR_APPROVAL = readFileSync( join(__dirname, '__fixtures__', 'cursor-agent-approval-prompt.txt'), @@ -215,4 +219,39 @@ describe('headless terminal presence', () => { const legacy = terminal({ presence: undefined, ...evidence }) expect(await legacy.presence.isRunning('terminal')).toBe(true) }) + + it('keeps main rules for a live owner recorded on another host (WSL guest, SSH)', async () => { + const row = (connectionId: string | null): AgentStatusIpcPayload => ({ + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId, + state: 'done', + prompt: '', + receivedAt: 10, + stateStartedAt: 10, + agentType: 'claude', + agentPresence: owner('claude') + }) + const remoteOwner = selectKeyboardAgentPresence([row('wsl:Ubuntu')]) + for (const [title, running] of [ + ['user@host: ~/repo', false], + ['✳ Claude Code', true] + ] as const) { + const remote = terminal({ presence: remoteOwner, title, foreground: 'wsl.exe' }) + const main = terminal({ presence: undefined, title, foreground: 'wsl.exe' }) + expect(await remote.presence.isRunning('terminal')).toBe(running) + expect(await main.presence.isRunning('terminal')).toBe(running) + expect(await remote.query.getStatus('terminal')).toEqual( + await main.query.getStatus('terminal') + ) + } + // A local owner this host can check still answers for an unrecognised foreground. + const local = terminal({ + presence: selectKeyboardAgentPresence([row(null)]), + title: 'user@host: ~/repo', + foreground: 'wsl.exe' + }) + expect(await local.presence.isRunning('terminal')).toBe(true) + }) }) diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index 53b283f747ff..b88f9c3599a1 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -1,4 +1,5 @@ import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { paneEvidenceAgent, paneEvidenceCounts, @@ -45,6 +46,23 @@ export type RuntimeTerminalAgentPresenceOptions = { foregroundProcess?: string | null } +/** The pane's newest recorded owner, as far as this host can vouch for it at the keyboard. */ +export function selectKeyboardAgentPresence( + rows: readonly AgentStatusIpcPayload[] +): AgentProcessPresence | undefined { + const row = rows.reduce( + (newest, candidate) => + !newest || candidate.receivedAt > newest.receivedAt ? candidate : newest, + undefined + ) + const presence = row?.agentPresence + // Why: this host cannot check a live owner recorded on another host (SSH, a WSL guest), so it + // must not outrank the pane's own evidence here; that pane keeps main's rules. + return presence?.process && !presence.ended && row?.connectionId + ? { agent: presence.agent } + : presence +} + /** Once the identified owner exited, its own launch intent no longer describes the pane. */ function withoutEndedOwnerLaunch( pty: T, From 4147516c1f835ec856e14b509ced8eeb5eb555c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:53:40 -0700 Subject: [PATCH 18/27] fix(agent-presence): let the owner answer for the keyboard only when it is in front After Ctrl-Z the owner stays live, so a user who then ran ssh, vim, less, python or tmux in that pane still read as "agent running", and guarded sends and dispatch typed into that program. Presence now only proves the agent exists: the running-agent check follows main's foreground rules (null, failed, shell or any other program all answer as on main), and a fresh hook status overrides a shell-like title only when the program in front is recognised as the owner's own agent. --- .../runtime-terminal-agent-presence.test.ts | 115 ++++++++++++++---- .../runtime-terminal-agent-presence.ts | 13 +- .../runtime-terminal-agent-status-query.ts | 21 +++- 3 files changed, 116 insertions(+), 33 deletions(-) diff --git a/src/main/runtime/runtime-terminal-agent-presence.test.ts b/src/main/runtime/runtime-terminal-agent-presence.test.ts index ae5fd80bff1f..55777bad4f16 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' import { RuntimeTerminalAgentPresence, @@ -61,11 +61,17 @@ function leaf(title: string): RuntimeLeafRecord { function terminal(args: { presence: AgentProcessPresence | undefined title: string - foreground: string | null + foreground: string | null | Error waitText?: string explicit?: 'working' titleStatus?: 'working' }) { + const readForeground = async () => { + if (args.foreground instanceof Error) { + throw args.foreground + } + return args.foreground + } const presence = new RuntimeTerminalAgentPresence({ getAgentPresence: () => args.presence, getLivePty: () => null, @@ -73,13 +79,13 @@ function terminal(args: { getPrimaryLeaf: () => null, getTrackedPty: () => null, getTabTitle: () => null, - getForegroundProcess: async () => args.foreground + getForegroundProcess: readForeground }) const controller: RuntimePtyController = { write: () => false, kill: () => false, - getForegroundProcess: async () => args.foreground, - confirmForegroundProcess: async () => args.foreground + getForegroundProcess: readForeground, + confirmForegroundProcess: readForeground } const query = new RuntimeTerminalAgentStatusQuery({ getAgentPresence: () => args.presence, @@ -120,16 +126,6 @@ describe('headless terminal presence', () => { }) }) - it('answers for an owner whose foreground read is unavailable', async () => { - const { presence, query } = terminal({ - presence: owner('claude'), - title: 'zsh', - foreground: null - }) - expect(await presence.isRunning('terminal')).toBe(true) - expect(await query.getStatus('terminal')).toMatchObject({ isRunningAgent: true }) - }) - it('refuses to send into the shell in front of a suspended owner', async () => { // Ctrl-Z leaves the owner alive (the probe reads a stopped process as unverifiable). for (const explicit of ['working', undefined] as const) { @@ -246,12 +242,89 @@ describe('headless terminal presence', () => { await main.query.getStatus('terminal') ) } - // A local owner this host can check still answers for an unrecognised foreground. - const local = terminal({ - presence: selectKeyboardAgentPresence([row(null)]), - title: 'user@host: ~/repo', - foreground: 'wsl.exe' + }) +}) + +async function settle(work: Promise): Promise { + const done = work.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + await vi.runAllTimersAsync() + const result = await done + if ('error' in result) { + throw result.error + } + return result.value +} + +async function sendGuardAnswer(query: RuntimeTerminalAgentStatusQuery): Promise { + const runtime: Pick = { + getTerminalAgentStatus: (handle) => query.getStatus(handle) + } + try { + await settle( + assertTerminalAgentSendable({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guard reads only getTerminalAgentStatus. + runtime: runtime as OrcaRuntimeService, + handle: 'terminal', + assertWritable: () => {} + }) + ) + return 'sends' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +describe('keyboard routing for a live owner that is not in front', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // After Ctrl-Z the owner stays live (a stopped process reads as unverifiable), so only the + // program in front may decide; every row must answer exactly as it does without presence. + it.each([ + ['ssh', 'user@prod: ~', undefined], + ['vim', 'vim notes.md', undefined], + ['python3', 'python3', undefined], + ['less', 'git log', undefined], + ['tmux', 'tmux', undefined], + ['bash', 'user@host: ~/repo', undefined], + [null, 'user@host: ~/repo', undefined], + [new Error('foreground read failed'), 'user@host: ~/repo', undefined], + ['vim', 'zsh', 'working'] + ] as const)( + 'matches main with %s in front (title %s, hook %s)', + async (foreground, title, explicit) => { + const withOwner = terminal({ presence: owner('claude'), title, foreground, explicit }) + const main = terminal({ presence: undefined, title, foreground, explicit }) + const mainRunning = await settle(main.presence.isRunning('terminal')) + expect(mainRunning).toBe(false) + expect(await settle(withOwner.presence.isRunning('terminal'))).toBe(mainRunning) + expect(await settle(withOwner.query.getStatus('terminal'))).toEqual( + await settle(main.query.getStatus('terminal')) + ) + expect(await sendGuardAnswer(withOwner.query)).toBe('terminal_guard_no_agent') + expect(await sendGuardAnswer(main.query)).toBe('terminal_guard_no_agent') + } + ) + + it('keeps the owner under a shell title when the owner itself is in front', async () => { + const withOwner = terminal({ + presence: owner('claude'), + title: 'zsh', + foreground: 'claude', + explicit: 'working' + }) + expect(await settle(withOwner.query.getStatus('terminal'))).toEqual({ + handle: 'terminal', + isRunningAgent: true, + status: 'working' }) - expect(await local.presence.isRunning('terminal')).toBe(true) + expect(await sendGuardAnswer(withOwner.query)).toBe('sends') }) }) diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index b88f9c3599a1..236758249107 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -3,7 +3,6 @@ import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { paneEvidenceAgent, paneEvidenceCounts, - selectLiveOwnerAgent, withoutEndedOwnerTitle } from '../../shared/ended-agent-owner-evidence' import { @@ -11,7 +10,7 @@ import { isExpectedAgentProcess, recognizeAgentProcess } from '../../shared/agent-process-recognition' -import { isOpenCodeNativeTitle, isShellProcess } from '../../shared/agent-detection' +import { isOpenCodeNativeTitle } from '../../shared/agent-detection' import type { TuiAgent } from '../../shared/tui-agent' import { isKnownReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -190,20 +189,16 @@ export class RuntimeTerminalAgentPresence { return await this.isForegroundAgent(pty.ptyId, presence, suppressClaude, options) } - /** Presence proves the agent exists; the foreground read still decides who holds the keyboard. */ + /** Presence proves the agent exists; only the program in front decides who holds the keyboard. */ private async isForegroundAgent( ptyId: string, presence: AgentProcessPresence | undefined, suppressClaude: boolean, options: RuntimeTerminalAgentPresenceOptions ): Promise { + // Why: a live owner never answers for the read. When the owner itself is in front, the + // recognition below already says so; any other program (Ctrl-Z then vim, ssh) keeps main's no. const foreground = await this.readForegroundProcess(ptyId, options).catch(() => null) - const owner = selectLiveOwnerAgent(presence) - // Why: a live owner answers only when the read cannot (unavailable, or a name like wsl.exe); - // a shell in front, as after Ctrl-Z, still owns the keyboard. Never over a management screen. - if (owner && !(foreground && isShellProcess(foreground))) { - return !(suppressClaude && owner === 'claude') - } if (!foreground) { return false } diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 9d2dbed41d6b..6236932a4a56 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -105,12 +105,16 @@ export class RuntimeTerminalAgentStatusQuery { const owner = selectLiveOwnerAgent(presence) if (explicitStatus) { // Why: permission titles can linger after hooks report the agent resumed. Fresh hook state is - // tighter, but current shell/management evidence wins; a live owner overrides only a shell title. - const titleBlocks = owner + // tighter, but current shell/management evidence wins; only the owner itself in front + // overrides a shell title. + const ownerInFront = + owner !== undefined && (await this.foregroundIsOwner(handle, ptyId, owner)) + const titleBlocks = ownerInFront ? terminal.title !== null && isClaudeManagementTitle(terminal.title) : terminalTitleBlocksExplicitAgentStatus(terminal.title) const isRunningAgent = - !titleBlocks && !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + !titleBlocks && + (ownerInFront || !(await this.terminalHasShellForegroundProcess(handle, ptyId))) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, @@ -241,6 +245,17 @@ export class RuntimeTerminalAgentStatusQuery { } } + private async foregroundIsOwner(handle: string, ptyId: string, owner: string): Promise { + let foregroundProcess: string | null = null + try { + foregroundProcess = (await this.deps.getController()?.getForegroundProcess(ptyId)) ?? null + } catch { + foregroundProcess = null + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return recognizeAgentProcess(foregroundProcess)?.agent === owner + } + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { const controller = this.deps.getController() if (!controller) { From cee22f28300c1270f814d66732514695e5372be7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:57:09 -0700 Subject: [PATCH 19/27] fix(agent-presence): accept a replayed host exit only against a different identified owner A host-stamped replay of an old owner's exit could delete a later agent's row that carries no process identity (for example a hookless agent in the same SSH pane), where step 2 dropped that frame. The replay now bypasses the transition only when it replaces a different stored owner that has a process; otherwise the transition decides, and it drops an exit that has no owner. --- ...erver-agent-presence-remote-replay.test.ts | 22 +++++++++++++++++++ .../server/server-status-update.ts | 9 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts index e0add6ae777d..6d7312b0735b 100644 --- a/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts +++ b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts @@ -142,6 +142,28 @@ describe('execution host presence replay', () => { expect(main.storedRow(PANE)?.agentPresence).toMatchObject(owner) expect(main.storedRow(PANE)).not.toHaveProperty('agentPresenceFromExecutionHost') }) + it('keeps a later unidentified row when the old owner exit replays', async () => { + const { main, relay, relayHook, frames, connect } = await setup() + await relayHook('SessionStart', 'a', owner.process) + probe.mockResolvedValue('exited') + await relay.checkAgentPresence(PANE) + // A later agent in the same pane reports only through terminal bytes (no process identity). + main.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + connectionId: 'ssh-1', + payload: { state: 'working', prompt: 'fix the build', agentType: 'codex' } + }) + connect(false) + relay.replayCachedPayloadsForPanes() + main.ingestRemote(frames.at(-1)!, 'ssh-1') + expect(main.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + agentType: 'codex', + prompt: 'fix the build' + }) + }) it('retains the ordering of a connected exit when an older live frame replays', async () => { const { main, relay, relayHook, frames } = await setup() await relayHook('SessionStart', 'a', owner.process) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index f893f64d6811..0f710076306e 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -37,9 +37,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const stored = this.state.lastStatusByPaneKey.get(incoming.paneKey) as | EnrichedAgentHookEventPayload | undefined + const storedOwner = stored?.agentPresence?.process + // Why: a replayed exit only settles the owner it names against a different identified stored + // owner; with no stored process it goes through the transition, which drops an ownerless exit. const relayDecided = relayOwner?.process && - (!relayOwner.ended || (incoming.agentPresenceFromExecutionHost && incoming.isReplay === true)) + (!relayOwner.ended || + (incoming.agentPresenceFromExecutionHost && + incoming.isReplay === true && + storedOwner !== undefined && + !isSameAgentProcess(storedOwner, relayOwner.process))) const transitioned = relayDecided ? incoming : transitionHookPresence(incoming, stored) if (!transitioned) { return undefined From 84a1bccdd63736089ee37f78a79ae7dad648f5c8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:01:52 -0700 Subject: [PATCH 20/27] test(agent-presence): pin the headless owner as identity only, not as the keyboard answer --- .../orca-runtime-hook-agent-status-projection.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index a2cc6ce6e1cd..4769abf2137a 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -568,7 +568,7 @@ describe('hook-driven session tabs republish (#11761)', () => { }) }) -it('uses the same host owner for headless terminal identity and running-agent queries', async () => { +it('uses the host owner for headless identity but not for the keyboard check', async () => { const rows = [ hookRow({ state: 'done', @@ -585,7 +585,8 @@ it('uses the same host owner for headless terminal identity and running-agent qu observePaneTitle(runtime, 'zsh') const terminal = (await runtime.listTerminals()).terminals[0] expect(terminal.agentIdentity).toBe('claude') - await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true) + // Presence names the owner; with nothing proving it is in front, the keyboard check stays main's. + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) const owner = rows[0].agentPresence if (!owner) { throw new Error('missing fixture owner') From 79ee51cac76caa07cffab547211e885b00082c5d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:45:22 -0700 Subject: [PATCH 21/27] fix(agent-presence): order mirrored owners by snapshot and keep a later agent's status The paired client ordered owner records by the published status clock, which the host does not keep monotonic (a shell-title correction can stamp an older time), so a relaunched Claude or an exit could be ignored and the pane hidden until a later status. Snapshots already arrive in delivery/version order and the host re-derives presence for each one, so the mirror now takes each snapshot's owner as published. It also uses the shared ended-owner rule: after an owner exits, only statuses naming that agent are set aside, so a later agent's status (for example a hookless agent) still shows, as on the phone and the host desktop. --- .../web-session-tabs-sync-presence.test.ts | 67 +++++++++++++++++-- .../agent-status-patch.ts | 15 ++--- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts index f7ad346cd209..84f321138831 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync' +import { + applyWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot +} from './web-session-tabs-sync' import { ENV, HOST_SURFACE_ID, @@ -17,6 +20,7 @@ const presence = { agent: 'claude', process: { pid: 42, platform: 'linux', startTime: 'boot:42' } } as const +const relaunchedProcess = { pid: 43, platform: 'linux', startTime: 'boot:43' } as const function snapshot( ended: boolean, row: Partial = {}, @@ -105,16 +109,65 @@ describe('paired host presence', () => { }) it('does not resurrect an ended owner from an older live snapshot', () => { + // Snapshot order, not the status clock, decides: an older version never reaches the mirror. + const ended = snapshot(true, {}, 2) + expect(shouldApplyWebSessionTabsSnapshot(ended, ENV)).toBe(true) + expect(shouldApplyWebSessionTabsSnapshot(snapshot(false, {}, 1), ENV)).toBe(false) + }) + + it('takes each newer snapshot owner even when its status clock is older', () => { const state = makeState() - const endedRow = { updatedAt: 20, stateStartedAt: 20 } - const ended = { + // Case A: a live owner first seen at 1000, then its exit published on a status stamped 400. + const live = { ...state, - ...applyWebSessionTabsSnapshot(state, snapshot(true, endedRow), ENV, NOW) + ...applyWebSessionTabsSnapshot(state, snapshot(false, { updatedAt: 1000 }), ENV, NOW) } - const paneKey = Object.keys(ended.agentPresenceByPaneKey ?? {})[0] - const stale = applyWebSessionTabsSnapshot(ended, snapshot(false, {}, 2), ENV, NOW + 1) - const next = { ...ended, ...stale } + const paneKey = Object.keys(live.agentPresenceByPaneKey ?? {})[0] + const exited = { + ...live, + ...applyWebSessionTabsSnapshot(live, snapshot(true, { updatedAt: 400 }, 2), ENV, NOW + 1) + } + expect(exited.agentPresenceByPaneKey?.[paneKey]?.presence.ended).toBe(true) + expect(exited.agentStatusByPaneKey[paneKey]).toBeUndefined() + // Case C: a relaunched Claude (new process) published on an older status clock. + const relaunched = { + ...exited, + ...applyWebSessionTabsSnapshot( + exited, + snapshot( + false, + { updatedAt: 300, agentPresence: { agent: 'claude', process: relaunchedProcess } }, + 3 + ), + ENV, + NOW + 2 + ) + } + expect(relaunched.agentPresenceByPaneKey?.[paneKey]?.presence).toEqual({ + agent: 'claude', + process: relaunchedProcess + }) + expect(relaunched.agentStatusByPaneKey[paneKey]).toBeDefined() + }) + + it('keeps a later agent status in the pane after the owner exited', () => { + // Case B: a hookless successor publishes under the retained exited owner record. + const state = makeState() + const next = { + ...state, + ...applyWebSessionTabsSnapshot( + state, + snapshot(true, { state: 'working', prompt: 'refactor', agentType: 'aider' }), + ENV, + NOW + ) + } + const paneKey = Object.keys(next.agentPresenceByPaneKey ?? {})[0] expect(next.agentPresenceByPaneKey?.[paneKey]?.presence.ended).toBe(true) + expect(next.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + agentType: 'aider' + }) }) it('releases mirrored presence when the host retracts the terminal', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index af2f5df941e3..8131409ba452 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -1,3 +1,4 @@ +import { paneEvidenceCounts } from '../../../../shared/ended-agent-owner-evidence' import { isSameAgentProcess, readAgentProcessPresence, @@ -135,18 +136,14 @@ export function buildMirroredAgentStatusPatch( } const presence = readAgentProcessPresence(hostEntry.agentPresence) const previousPresence = nextPresenceByPaneKey[hostEntry.paneKey] - const hostIsCurrent = - !previousPresence || - previousPresence.connectionId !== environmentId || - hostEntry.updatedAt >= previousPresence.receivedAt - if (!presence?.process && hostIsCurrent && previousPresence?.connectionId === environmentId) { - // Why: presence is derived per snapshot; a newer uncovered row means the host no longer vouches for that owner. + // Why: snapshots are already delivery/version ordered and the host re-derives presence for each + // one; the published status clock is not a presence clock, so it must not reorder owners. + if (!presence?.process && previousPresence?.connectionId === environmentId) { const { [hostEntry.paneKey]: _released, ...remaining } = nextPresenceByPaneKey nextPresenceByPaneKey = remaining presenceChanged = true } else if ( presence?.process && - hostIsCurrent && (previousPresence?.connectionId !== environmentId || !isSameOwnerRecord(previousPresence.presence, presence)) ) { @@ -161,9 +158,9 @@ export function buildMirroredAgentStatusPatch( } presenceChanged = true } + // Why: an exited owner's own status is history; a later agent's status in the pane still shows. if ( - nextPresenceByPaneKey[hostEntry.paneKey]?.presence.ended && - nextPresenceByPaneKey[hostEntry.paneKey]?.presence.process + !paneEvidenceCounts(nextPresenceByPaneKey[hostEntry.paneKey]?.presence, hostEntry.agentType) ) { endedPaneKeys.add(hostEntry.paneKey) continue From 2e9c59fa2a806a6492937f91abf7f94764f0b541 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:35:33 -0700 Subject: [PATCH 22/27] fix(agent-presence): advertise the owner record only from clients that read it The capability that asks a host for the owner record on session tabs was in the shared remote list, so the CLI advertised it without reading session tabs, while the browser web client, which mirrors host tabs like the paired desktop, never did. It now comes from the paired desktop's Electron remote list, the browser web client's list and the mobile list, not the CLI's. The desktop renderer's own-main list records it as a remote-only decision: main's renderer reads presence over agent-status IPC. A test pins that the paired desktop connection negotiates it and the CLI does not. --- .../mobile-runtime-client-capabilities.test.ts | 5 +++++ .../mobile-runtime-client-capabilities.ts | 5 ++++- ...esktop-renderer-runtime-capabilities.test.ts | 17 ++++++++++++++++- src/renderer/src/web/web-runtime-client.test.ts | 4 +++- .../web/web-runtime-connection-frame-router.ts | 4 +++- src/shared/protocol-version.ts | 5 +++-- .../remote-runtime-client-capabilities.ts | 2 -- 7 files changed, 34 insertions(+), 8 deletions(-) diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index b0c38166dff4..67e425859831 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, @@ -34,6 +35,10 @@ describe('mobile runtime client capabilities', () => { expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toContain(AGENT_LAUNCH_RUNTIME_CAPABILITY) }) + it('opts into the host owner record that tab icons and Chat read', () => { + expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toContain(AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY) + }) + it('stays inside the bounds the host parses, which fail closed to no capabilities at all', () => { expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES.length).toBeLessThanOrEqual(HOST_CAPABILITY_LIMIT) for (const capability of MOBILE_RUNTIME_CLIENT_CAPABILITIES) { diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index e388c7ff2d8c..891b29035144 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -1,5 +1,6 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -20,7 +21,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Mobile renders either launch outcome — a structured chat or a terminal agent — so it may ask // the host to pick. Without this the host refuses `agent.launch` and every mobile create with an // agent stays a PTY. - AGENT_LAUNCH_RUNTIME_CAPABILITY + AGENT_LAUNCH_RUNTIME_CAPABILITY, + // Tab icons and Chat read the host's owner record; without it the host sends the legacy row. + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 4a8fbab955a2..fc24d4060fa1 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -27,6 +28,7 @@ import { type RuntimeCapability } from '../../shared/protocol-version' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' +import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' /** Advertised to a remote host and deliberately NOT to main: each would change local behaviour or @@ -47,7 +49,9 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Opts into a delta feed in place of the full tab list — a remote-transport concern. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + // The host owner record on mirrored session tabs; main's own renderer reads it over agent-status IPC. + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all @@ -100,4 +104,15 @@ describe('desktop renderer runtime client capabilities', () => { ) ).toEqual([...LOCAL_ONLY_BY_DECISION].sort()) }) + + it('asks a paired host for the owner record on the tabs it mirrors, and only there', () => { + // What the paired desktop's request and control connections send (remoteRuntimeClientCapabilities + // over the Electron list), against the CLI's bare list, which never reads session tabs. + expect(remoteRuntimeClientCapabilities(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES)).toContain( + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY + ) + expect(remoteRuntimeClientCapabilities()).not.toContain( + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY + ) + }) }) diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 7e17e914433f..df9fe57869c5 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -12,6 +12,7 @@ import { } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -95,7 +96,8 @@ describe('WebRuntimeClient', () => { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY ] }) diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index e9fa20fa0150..51afdb9a1a38 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -1,6 +1,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import { + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -67,7 +68,8 @@ export async function routeWebRuntimeConnectionFrame( AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY ] }) return diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 45457865d464..0780f0395d51 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -318,7 +318,6 @@ export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY = export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -336,7 +335,9 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + // Why: the paired client mirrors the host's tabs and reads their owner record; the CLI does not. + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY ] as const export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 50eb6f340a11..445f7c7a5337 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -3,7 +3,6 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, @@ -26,7 +25,6 @@ export function remoteRuntimeClientCapabilities( SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, From 64bc3968112daaac01cd2e0b5549203223287f20 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:37:47 -0700 Subject: [PATCH 23/27] fix(agent-presence): keep published agent statuses stable when stripping host presence Removing the host's owner record from renderer-published statuses built a fresh object for every status on every republish, so the per-worktree mobile snapshot cache missed and rebuilt unchanged worktrees. A status without an owner record is now published as is, and one with it maps to a single stripped copy per source entry. --- .../mobile-session-agent-status.test.ts | 56 +++++++++++++++++++ .../mobile-session-inputs.ts | 22 +++++++- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-agent-status.test.ts diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-agent-status.test.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-agent-status.test.ts new file mode 100644 index 000000000000..5fdfe2b4b066 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-agent-status.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { buildMobileSessionAgentStatusByWorktree } from './mobile-session-inputs' + +const leafA = '11111111-1111-4111-8111-111111111111' +const leafB = '22222222-2222-4222-8222-222222222222' +const plainPane = makePaneKey('tab-1', leafA) +const ownedPane = makePaneKey('tab-1', leafB) + +const agentStatusByPaneKey: AppState['agentStatusByPaneKey'] = { + [plainPane]: { + state: 'working', + prompt: 'plain', + paneKey: plainPane, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + }, + [ownedPane]: { + state: 'done', + prompt: 'owned', + paneKey: ownedPane, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentPresence: { + agent: 'claude', + process: { pid: 42, platform: 'linux', startTime: 'boot:42' } + } + } +} +const tabsByWorktree: AppState['tabsByWorktree'] = { + wt: [ + { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt', + title: 'zsh', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] +} + +describe('mobile session agent status publication', () => { + it('strips host presence while keeping each published status stable across builds', () => { + const first = buildMobileSessionAgentStatusByWorktree(agentStatusByPaneKey, tabsByWorktree) + const second = buildMobileSessionAgentStatusByWorktree(agentStatusByPaneKey, tabsByWorktree) + expect(first.get('wt')?.get(plainPane)).toBe(agentStatusByPaneKey[plainPane]) + expect(first.get('wt')?.get(ownedPane)).not.toHaveProperty('agentPresence') + expect(second.get('wt')?.get(ownedPane)).toBe(first.get('wt')?.get(ownedPane)) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index 5c3f812c7dc1..4d1f9ccca4d0 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -51,6 +51,24 @@ export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIn return graphState.cachedOpenFileIndexes } +type PublishedAgentStatus = AppState['agentStatusByPaneKey'][string] +const strippedHostPresence = new WeakMap() + +/** The execution host projects presence; renderer publications carry turn presentation only. */ +function withoutHostPresence(entry: PublishedAgentStatus): PublishedAgentStatus { + if (entry.agentPresence === undefined) { + return entry + } + // Why: one stable copy per source entry, so the per-worktree snapshot cache still hits. + let stripped = strippedHostPresence.get(entry) + if (!stripped) { + const { agentPresence: _hostPresence, ...rest } = entry + stripped = rest + strippedHostPresence.set(entry, stripped) + } + return stripped +} + export function buildMobileSessionAgentStatusByWorktree( agentStatusByPaneKey: AppState['agentStatusByPaneKey'], tabsByWorktree: AppState['tabsByWorktree'] @@ -77,9 +95,7 @@ export function buildMobileSessionAgentStatusByWorktree( bucket = new Map() byWorktreeId.set(worktreeId, bucket) } - // The execution host projects presence; renderer publications carry turn presentation only. - const { agentPresence: _hostPresence, ...status } = agentStatusByPaneKey[paneKey] - bucket.set(paneKey, status) + bucket.set(paneKey, withoutHostPresence(agentStatusByPaneKey[paneKey])) } return byWorktreeId } From 5c44f10f6518038e7399e2c485d4cc29218dda9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:26:57 -0700 Subject: [PATCH 24/27] refactor(protocol): move browser capability constants to their own module (frees protocol-version line budget) --- src/cli/handlers/browser-identity.ts | 2 +- src/main/runtime/orca-runtime-get-status.ts | 4 +++- .../stream-remote/remote-browser-page-pane.tsx | 2 +- src/shared/browser-runtime-capabilities.ts | 8 ++++++++ src/shared/protocol-version.ts | 6 ------ 5 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 src/shared/browser-runtime-capabilities.ts diff --git a/src/cli/handlers/browser-identity.ts b/src/cli/handlers/browser-identity.ts index a13365b9822f..b6c28ae050b0 100644 --- a/src/cli/handlers/browser-identity.ts +++ b/src/cli/handlers/browser-identity.ts @@ -3,7 +3,7 @@ import type { BrowserIdentityModeStatus, BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' -import { BROWSER_IDENTITY_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { BROWSER_IDENTITY_RUNTIME_CAPABILITY } from '../../shared/browser-runtime-capabilities' import type { RuntimeStatus } from '../../shared/runtime-types' import type { CommandHandler, HandlerContext } from '../dispatch' import { getRequiredStringFlag } from '../flags' diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 39b8dbf0dcfb..66bdcbe29e33 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -9,7 +9,9 @@ import type { RuntimeCapability } from '../../shared/protocol-version' import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, BROWSER_HEADLESS_RUNTIME_CAPABILITY, - BROWSER_IDENTITY_RUNTIME_CAPABILITY, + BROWSER_IDENTITY_RUNTIME_CAPABILITY +} from '../../shared/browser-runtime-capabilities' +import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx index f447b5432d1b..3c0c20b1929b 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' +import { BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY } from '../../../../../shared/browser-runtime-capabilities' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' diff --git a/src/shared/browser-runtime-capabilities.ts b/src/shared/browser-runtime-capabilities.ts new file mode 100644 index 000000000000..97bc85deb9c2 --- /dev/null +++ b/src/shared/browser-runtime-capabilities.ts @@ -0,0 +1,8 @@ +// Conditional browser capabilities: getStatus() advertises each only when this host can honour it. + +// Why: signals a host owns browser pages with no renderer (headless serve via the +// offscreen backend). Advertised only when that backend is actually available, so +// clients never fall back to a local desktop browser tab for a remote-owned page. +export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const +export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0780f0395d51..31ecb7767323 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -77,13 +77,7 @@ export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE = // conditional like browser.headless.v1. export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-titles.v1' as const -// Why: signals a host owns browser pages with no renderer (headless serve via the -// offscreen backend). Advertised only when that backend is actually available, so -// clients never fall back to a local desktop browser tab for a remote-owned page. -export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const -export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const -export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const // Why: older hosts discard browser.tabCreate's page field, so clients may only // treat a preallocated page ID as canonical when this is advertised. export const BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY = From e6353da96a132eb384866ec856fe2273f2467c7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:40:12 -0700 Subject: [PATCH 25/27] fix(ssh): forward the relay's hook envelope to main as the WSL link does The SSH notification handler copied a fixed list of envelope fields and left out the owner record (agentPresence with its process and relay ordering), so no identified SSH owner ever reached main: the step 1/2 owner paths and this PR's reconnect handling never ran over SSH. It now forwards the envelope like the WSL link, with ingestRemote re-validating every field; the host-set run-capability field is applied last so a relay cannot override it, and host provenance is still decided only at remote ingress. --- ...ay-session-agent-hooks.integration.test.ts | 42 ++++++++++++++++++ src/main/ssh/ssh-relay-session.ts | 43 +++---------------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 0ac77cfd32c3..dfc4493c10c6 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -6,6 +6,7 @@ import type { SshConnection } from './ssh-connection' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { RelayDispatcher } from '../../relay/dispatcher' +import { AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES } from '../../shared/agent-status-legacy-adapter' import { AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD, @@ -636,6 +637,47 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { ingestSpy.mockRestore() }) + it('forwards the relay owner record to main and keeps host-set fields', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') + session = createSession('conn-presence') + await session.establish({} as SshConnection) + const agentPresence = { + agent: 'claude', + process: { pid: 4242, platform: 'linux', startTime: 'boot:4242' }, + observation: { epoch: 'relay-epoch', sequence: 7 } + } as const + relay.notifyAgentHook({ + ...makeEnvelope({ + source: 'claude', + hookEventName: 'SessionStart', + agentPresence, + payload: { state: 'done', prompt: '', agentType: 'claude' } + }), + // A relay must not be able to widen its own run capabilities. + advertisedAgentStatusCapabilities: ['forged-capability'] + }) + await vi.waitFor(() => + expect(ingestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + agentPresence, + advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES + }), + 'conn-presence' + ) + ) + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + connectionId: 'conn-presence', + agentPresence + }) + ingestSpy.mockRestore() + }) + it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index e79116912616..65757960ac29 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1618,45 +1618,16 @@ export class SshRelaySession { ) { return } - // Why: forward the agent CLI's env/version verbatim (not the relay's) so warn-once protocol-mismatch diagnostics fire for remote events too. + // Why: forward the relay's envelope as the WSL link does (ingestRemote re-validates every field), + // so fields like the owner record reach main; host-set fields go last so a relay can't override them. + type IngestEnvelope = Parameters[0] agentHookServer.ingestRemote( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: wire-deserialized notification whose paneKey/isReplay/launchToken types were checked above; ingestRemote re-validates the rest. { - paneKey: envelope.paneKey, - launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, - tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, - worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, - env: typeof envelope.env === 'string' ? envelope.env : undefined, - version: typeof envelope.version === 'string' ? envelope.version : undefined, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey: - typeof envelope.promptInteractionKey === 'string' - ? envelope.promptInteractionKey - : undefined, - hookEventName: - typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, - source: envelope.source, - providerPromptId: envelope.providerPromptId, - grokPromptBoundary: envelope.grokPromptBoundary === true ? true : undefined, - compactTrigger: envelope.compactTrigger, - toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined, - toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined, - teammateName: - typeof envelope.teammateName === 'string' ? envelope.teammateName : undefined, - toolAgentType: - typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - providerSession: envelope.providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. - shedFields: envelope.shedFields, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, + ...envelope, // Why: the SSH relay protocol advertises no run-serving capability. - advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES, - payload: envelope.payload - }, + advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES + } as IngestEnvelope, this.targetId ) }) From 3f2a5cb607c02fc95e89d3508b1a5c1b1428b560 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:04:03 -0700 Subject: [PATCH 26/27] test(agent-presence): give the mobile presence test host the title display-clear accessor --- src/main/runtime/runtime-mobile-agent-presence.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/runtime-mobile-agent-presence.test.ts b/src/main/runtime/runtime-mobile-agent-presence.test.ts index 07a69d7c8883..82f32be947e8 100644 --- a/src/main/runtime/runtime-mobile-agent-presence.test.ts +++ b/src/main/runtime/runtime-mobile-agent-presence.test.ts @@ -67,6 +67,7 @@ function projection(agentStatus?: AgentStatusEntry) { findPty: () => null, getRetainedStatus: () => null, getTrackedTitle: () => null, + getTitleDisplayClear: () => null, issuePtyHandle: () => 'terminal', recordPty: () => { throw new Error('unexpected pty') From 1e7ef81796b2c64f42b18e9771ae58dd37f29df2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:24:33 -0700 Subject: [PATCH 27/27] refactor(runtime): move pane status-row lookups out of runtime-mobile-session-projection.ts to stay under max-lines --- .../runtime-mobile-session-pane-row-lookup.ts | 68 +++++++++++++++++++ .../runtime-mobile-session-projection.ts | 64 ++--------------- 2 files changed, 75 insertions(+), 57 deletions(-) create mode 100644 src/main/runtime/runtime-mobile-session-pane-row-lookup.ts diff --git a/src/main/runtime/runtime-mobile-session-pane-row-lookup.ts b/src/main/runtime/runtime-mobile-session-pane-row-lookup.ts new file mode 100644 index 000000000000..d6e27617736e --- /dev/null +++ b/src/main/runtime/runtime-mobile-session-pane-row-lookup.ts @@ -0,0 +1,68 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' +import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' + +export function createHookRowsForPaneLookup( + host: Pick< + RuntimeMobileSessionProjectionHost, + 'getProviderSessionRows' | 'getProviderSessionSnapshot' + > +): (paneKey: string) => AgentStatusIpcPayload[] { + // Production reads hook rows by pane; the snapshot fallback remains for tests + // and embedders that have not adopted the narrow getter. + let hookRowsByPaneKey: Map | null = null + const hookRowsForPane = new Map() + return (paneKey: string): AgentStatusIpcPayload[] => { + const cached = hookRowsForPane.get(paneKey) + if (cached) { + return cached + } + const direct = host.getProviderSessionRows(paneKey) + if (direct) { + hookRowsForPane.set(paneKey, direct) + return direct + } + hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot()) + const rows = hookRowsByPaneKey.get(paneKey) ?? [] + hookRowsForPane.set(paneKey, rows) + return rows + } +} + +export function createStatusRowsLookup( + host: Pick +): (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] { + let statusRowsByPaneKey: Map | null = null + let statusRowsByTerminalHandle: Map | null = null + return (paneKey: string, terminalHandle: string | null): AgentStatusIpcPayload[] => { + if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) { + statusRowsByPaneKey = new Map() + statusRowsByTerminalHandle = new Map() + for (const row of host.getStatusSnapshot()) { + const paneRows = statusRowsByPaneKey.get(row.paneKey) + if (paneRows) { + paneRows.push(row) + } else { + statusRowsByPaneKey.set(row.paneKey, [row]) + } + if (row.terminalHandle) { + const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle) + if (handleRows) { + handleRows.push(row) + } else { + statusRowsByTerminalHandle.set(row.terminalHandle, [row]) + } + } + } + } + const paneRows = statusRowsByPaneKey.get(paneKey) ?? [] + if (!terminalHandle) { + return paneRows + } + const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? [] + if (paneRows.length === 0) { + return handleRows + } + return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))] + } +} diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index b759c4cb9112..48ca854b77c7 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -8,20 +8,23 @@ import { resolveCompatibleAgentTypeForOwner } from '../../shared/agent-title-owner' import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner' -import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { AgentStatusEntry } from '../../shared/agent-status-types' import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' -import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import { renewRuntimeMobileAgentStatusFromPtyTitle, selectRuntimeHookAgentRowForPane } from './runtime-mobile-agent-status-projection' import { finalizeRuntimeMobileSessionTabsResult } from './runtime-mobile-session-result-finalization' import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' +import { + createHookRowsForPaneLookup, + createStatusRowsLookup +} from './runtime-mobile-session-pane-row-lookup' import { getLatestAgentCandidateTitle, getLeafDisplayRecord, @@ -35,61 +38,8 @@ export function projectRuntimeMobileSessionTabs( ): RuntimeMobileSessionTabsResult { const tabs: RuntimeMobileSessionClientTab[] = [] const liveBrowserTabsByPageId = host.getLiveBrowserTabs(snapshot.worktree) - // Production reads hook rows by pane; the snapshot fallback remains for tests - // and embedders that have not adopted the narrow getter. - let hookRowsByPaneKey: Map | null = null - const hookRowsForPane = new Map() - const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { - const cached = hookRowsForPane.get(paneKey) - if (cached) { - return cached - } - const direct = host.getProviderSessionRows(paneKey) - if (direct) { - hookRowsForPane.set(paneKey, direct) - return direct - } - hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot()) - const rows = hookRowsByPaneKey.get(paneKey) ?? [] - hookRowsForPane.set(paneKey, rows) - return rows - } - let statusRowsByPaneKey: Map | null = null - let statusRowsByTerminalHandle: Map | null = null - const getStatusRows = ( - paneKey: string, - terminalHandle: string | null - ): AgentStatusIpcPayload[] => { - if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) { - statusRowsByPaneKey = new Map() - statusRowsByTerminalHandle = new Map() - for (const row of host.getStatusSnapshot()) { - const paneRows = statusRowsByPaneKey.get(row.paneKey) - if (paneRows) { - paneRows.push(row) - } else { - statusRowsByPaneKey.set(row.paneKey, [row]) - } - if (row.terminalHandle) { - const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle) - if (handleRows) { - handleRows.push(row) - } else { - statusRowsByTerminalHandle.set(row.terminalHandle, [row]) - } - } - } - } - const paneRows = statusRowsByPaneKey.get(paneKey) ?? [] - if (!terminalHandle) { - return paneRows - } - const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? [] - if (paneRows.length === 0) { - return handleRows - } - return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))] - } + const getHookRowsForPane = createHookRowsForPaneLookup(host) + const getStatusRows = createStatusRowsLookup(host) // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) {