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..2c0b97ad2bb0 --- /dev/null +++ b/mobile/src/session/mobile-agent-presence.test.ts @@ -0,0 +1,47 @@ +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('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 + 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..e7ae1e477e56 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,3 +1,7 @@ +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' @@ -58,12 +62,14 @@ export function resolveMobileNativeChat( if (tab.type !== 'terminal') { return null } - const liveAgent = 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 dfc7be812e81..df3eb17b76a2 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -1,3 +1,7 @@ +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' @@ -17,7 +21,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,14 +32,17 @@ type MobileTerminalTabAgentIdentity = { export function resolveMobileTerminalTabOwnedAgentId( tab: MobileTerminalTabAgentIdentity ): string | null { - const hookAgentType = tab.agentStatus?.agentType?.trim() + const presence = tab.agentStatus?.agentPresence + const owner = selectLiveOwnerAgent(presence) + if (owner) { + return owner + } + // 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( @@ -42,7 +52,10 @@ export function resolveMobileTerminalTabAgentId( 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/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/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/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..6d7312b0735b --- /dev/null +++ b/src/main/agent-hooks/server-agent-presence-remote-replay.test.ts @@ -0,0 +1,232 @@ +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 { 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(() => + 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: '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 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) } + ) +} +class InspectableServer extends AgentHookServer { + storedRow(paneKey: string) { + return this.state.lastStatusByPaneKey.get(paneKey) + } +} +async function setup() { + let connected = true + const main = new InspectableServer() + const frames: AgentHookRelayEnvelope[] = [] + const dir = mkdtempSync(join(tmpdir(), 'remote-replay-')) + dirs.push(dir) + const relay = new RelayAgentHookServer({ + endpointDir: dir, + forward: (event) => { + frames.push(event) + if (connected) { + main.ingestRemote(event, 'ssh-1') + } + } + }) + servers.push(main, relay) + 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) => { + 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, 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 relayHook('SessionStart', 'b', replacement.process) + 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('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('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('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) + const staleLive = frames.at(-1)! + probe.mockResolvedValue('exited') + await relay.checkAgentPresence(PANE) + const exited = main.getStatusSnapshot()[0]?.agentPresence + 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 } = await setup() + await main.start({ env: 'production' }) + await postHookEvent(main, claudeHook('SessionStart', 'a', owner.process)) + 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 mismatched exits from an old relay on the exact-owner fence', async () => { + const { main, request } = await setup() + const event = { + ...request, + source: 'claude', + payload: { state: 'done', prompt: '', agentType: 'claude' }, + agentPresence: owner + } + main.ingestRemote(event, '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-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/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a44813104424..c245630f6d2a 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 @@ -29,20 +30,43 @@ 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 - const transitioned = - relayOwner?.process && !relayOwner.ended - ? incoming - : transitionHookPresence(incoming, this.state.lastStatusByPaneKey.get(incoming.paneKey)) + // 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 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 && + storedOwner !== undefined && + !isSameAgentProcess(storedOwner, relayOwner.process))) + const transitioned = relayDecided ? incoming : transitionHookPresence(incoming, stored) 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 } - 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 = 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 +77,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 +95,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, diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 31a27eb416e1..cc07a7d0cf40 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, @@ -30,6 +31,7 @@ import { import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' +import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' @@ -52,6 +54,8 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ 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, + // The host owner record on mirrored session tabs; main's own renderer reads it over agent-status IPC. + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, // Main marks `removing` on its own worktree IPC listings unconditionally; the renderer never // lists its own host's worktrees over runtime RPC, where this capability decides mark vs omit. WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY @@ -121,4 +125,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/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/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 9fab2267d250..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 @@ -567,3 +567,35 @@ describe('hook-driven session tabs republish (#11761)', () => { unsubscribe() }) }) + +it('uses the host owner for headless identity but not for the keyboard check', 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') + // 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') + } + 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-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 4e8087e5a950..8e81686477a3 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' @@ -45,6 +48,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, @@ -166,6 +170,22 @@ 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 selectKeyboardAgentPresence(rows) + } catch { + return undefined + } + } + protected readonly openCodeRunLifetime = new OpenCodeRunLifetimeStatus({ isObservablePty: (ptyId) => { const pty = this.ptysById.get(ptyId) @@ -199,6 +219,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId 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 66d6e50335d2..718056da745e 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,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +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' @@ -93,16 +98,21 @@ 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 = [...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/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..a0d4235310ee --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-presence-projection.test.ts @@ -0,0 +1,94 @@ +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' +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 +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', + parentTabId: 'tab', + leafId: 'leaf', + title: 'zsh', + isActive: true, + status: 'pending-handle', + terminal: null, + ...(agentStatus ? { agentStatus } : {}), + ...(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 + ) + }) + 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 new file mode 100644 index 000000000000..cfd5abc61369 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-presence-projection.ts @@ -0,0 +1,37 @@ +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. + 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/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index be3a1d2de42c..73632ed77cf2 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' @@ -31,7 +32,11 @@ export function projectSessionTabsForClient( clientCapabilities: Parameters[2] ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + projectSessionTabAgentStatus( + projectSessionTabPresenceForClient(snapshot, clientCapabilities), + clientKind, + clientCapabilities + ), clientCapabilities ) } 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..23f4e43ccdd6 --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-presence-projection.ts @@ -0,0 +1,42 @@ +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 + } + const identity = { + 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 } : {}) + } + // 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 new file mode 100644 index 000000000000..82f32be947e8 --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-presence.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +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' +import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { makePaneKey } from '../../shared/stable-pane-id' + +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', + worktreeId: 'folder', + connectionId: null, + state: 'done', + prompt: '', + receivedAt: 10, + stateStartedAt: 5, + agentType: 'claude', + agentPresence: { + agent: 'claude', + process: processIdentity, + ...(ended ? { ended: true } : {}) + }, + ...(ended ? { providerSessionOnly: true } : {}) + } + return row +} + +function projection(agentStatus?: AgentStatusEntry) { + let rows: AgentStatusIpcPayload[] = [] + 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 ? { agentStatus } : {}) + } + ] + } + 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, + getTitleDisplayClear: () => null, + issuePtyHandle: () => 'terminal', + recordPty: () => { + throw new Error('unexpected pty') + }, + buildPtyStatus: () => ({}), + sanitizeGroups: () => undefined, + pruneGroupLayout: () => null, + collectTabIds: () => new Set() + } + return { + setRows: (next: AgentStatusIpcPayload[]) => { + rows = next + }, + tabFor: (capabilities: string[]) => + projectSessionTabsForClient( + projectRuntimeMobileSessionTabs(snapshot, host), + 'mobile', + capabilities + ).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), + 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', () => { + 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]) + // 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: 99999, stateStartedAt: 99999 } + }) + expect(tabFor([])).not.toHaveProperty('agentStatus.agentPresence') + setRows([]) + expect(tabFor(capable)).not.toHaveProperty('agentStatus.agentPresence') + }) +}) 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 36e44b58d3b4..48ca854b77c7 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -1,23 +1,30 @@ +import { + HOST_AGENT_PRESENCE_STATUS, + projectHostAgentPresenceStatus +} from './runtime-mobile-agent-presence-projection' import { normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, 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, @@ -31,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) { @@ -279,8 +233,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 ?? @@ -292,8 +249,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..5b5e4cd0802f --- /dev/null +++ b/src/main/runtime/runtime-terminal-agent-presence.test.ts @@ -0,0 +1,332 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' +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'), + '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 | 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, + getLiveLeaf: () => leaf(args.title), + getPrimaryLeaf: () => null, + getTrackedPty: () => null, + getTabTitle: () => null, + getForegroundProcess: readForeground, + getTitleDisplayClear: () => null + }) + const controller: RuntimePtyController = { + write: () => false, + kill: () => false, + getForegroundProcess: readForeground, + confirmForegroundProcess: readForeground + } + const query = new RuntimeTerminalAgentStatusQuery({ + getAgentPresence: () => args.presence, + getController: () => controller, + 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), + getTitleDisplayClear: () => null + }) + 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: args.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, query } = terminal({ + presence: owner('claude', ended), + title: 'zsh', + foreground: 'claude', + explicit: 'working' + }) + expect(await presence.isRunning('terminal')).toBe(!ended) + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: !ended, + status: ended ? null : 'working' + }) + }) + + 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({ + 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 + }) + }) + + 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) + }) + + 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') + ) + } + }) +}) + +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 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 119ce1d8f042..23a16432fc00 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -1,9 +1,17 @@ +import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { + paneEvidenceAgent, + paneEvidenceCounts, + withoutEndedOwnerTitle +} from '../../shared/ended-agent-owner-evidence' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, 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' @@ -23,6 +31,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 @@ -41,6 +50,32 @@ 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, + 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) {} @@ -55,6 +90,7 @@ export class RuntimeTerminalAgentPresence { if (this.deps.isLiveStructuredAgent?.(handle)) { return true } + const presence = this.deps.getAgentPresence?.(handle) try { // Why display records: presence reads what the pane shows, as before the stale-working // clear stopped rewriting records. A cwd spinner clears to a neutral title, so the @@ -66,7 +102,8 @@ export class RuntimeTerminalAgentPresence { return await this.isPtyRunning( getPtyDisplayRecord(pty, clear), leaf ? getLeafDisplayRecord(leaf, clear) : null, - options + options, + presence ) } const liveLeaf = this.deps.getLiveLeaf(handle) @@ -74,8 +111,9 @@ export class RuntimeTerminalAgentPresence { liveLeaf, liveLeaf.ptyId ? this.deps.getTitleDisplayClear(liveLeaf.ptyId) : null ) - 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 @@ -84,7 +122,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 @@ -104,44 +142,40 @@ export class RuntimeTerminalAgentPresence { if (!leaf.ptyId) { return false } - 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 - } - return await this.isRecognizedForegroundAgentProcess( - leaf.ptyId, - foreground, - suppressClaude, - options.retryForegroundWrappers !== false - ) + return await this.isForegroundAgent(leaf.ptyId, presence, suppressClaude, options) } catch { return false } } private async isPtyRunning( - pty: RuntimePtyWorktreeRecord, + livePty: RuntimePtyWorktreeRecord, leaf: RuntimeLeafRecord | null, - options: RuntimeTerminalAgentPresenceOptions + options: RuntimeTerminalAgentPresenceOptions, + 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)) { @@ -167,22 +201,35 @@ 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' + return await this.isForegroundAgent(pty.ptyId, presence, suppressClaude, options) + } + + /** 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) + if (!foreground) { + return false + } if (suppressClaude && isExpectedAgentProcess(foreground, 'claude')) { return false } return await this.isRecognizedForegroundAgentProcess( - pty.ptyId, + ptyId, foreground, suppressClaude, - options.retryForegroundWrappers !== false + options.retryForegroundWrappers !== false, + presence ) } @@ -200,11 +247,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 @@ -218,8 +269,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 cc0c4cf1a62a..df0ec4f3c476 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,5 +1,11 @@ +import type { AgentProcessPresence } from '../../shared/agent-process-presence' +import { + selectLiveOwnerAgent, + withoutEndedOwnerTitle +} from '../../shared/ended-agent-owner-evidence' import { detectAgentStatusFromTitle, + isClaudeManagementTitle, isOpenCodeNativeTitle, isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, @@ -30,6 +36,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 } @@ -75,6 +82,7 @@ export class RuntimeTerminalAgentStatusQuery { const terminal = this.getSnapshot(handle, ptyId, clear) const explicitStatus = this.deps.getExplicitStatus(handle) const lifecycle = getDisplayPromptLifecycle(this.deps.getLifecycleStatus(ptyId), clear) + const presence = this.deps.getAgentPresence?.(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && @@ -102,12 +110,20 @@ export class RuntimeTerminalAgentStatusQuery { ) { return { handle, isRunningAgent: true, status: 'permission' } } + // Presence answers only whether an agent owns the pane; the permission evidence above still wins. + 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. + // Why: permission titles can linger after hooks report the agent resumed. Fresh hook state is + // 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 = - !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + !titleBlocks && + (ownerInFront || !(await this.terminalHasShellForegroundProcess(handle, ptyId))) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, @@ -115,7 +131,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 +148,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) @@ -231,6 +252,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) { diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index e03f458134f2..fa7de865b9bb 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -43,7 +43,9 @@ 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), prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, 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 c29ec93c11e1..f75650911553 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1681,45 +1681,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 ) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index acf12dd58610..8d57f174ad28 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,3 +1,4 @@ +import { createRelayAgentPresenceObservation } from './relay-agent-presence-observation' import { handleRelayHookRequest } from './agent-hook-request' import { transitionHookPresence } from '../shared/agent-hook-presence-transition' import { RelayAgentPresence } from './relay-agent-presence' @@ -90,6 +91,7 @@ export class RelayAgentHookServer { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false + private readonly observePresence = createRelayAgentPresenceObservation() private readonly presenceChecks = new RelayAgentPresence({ rows: () => this.state.lastStatusByPaneKey.values(), checkOwner: (paneKey, successor) => this.checkAgentPresence(paneKey, successor) @@ -298,9 +300,7 @@ export class RelayAgentHookServer { 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/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/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/renderer/src/lib/agent-presence-selectors.ts b/src/renderer/src/lib/agent-presence-selectors.ts index 132bfbdbeac9..9b74b42ece23 100644 --- a/src/renderer/src/lib/agent-presence-selectors.ts +++ b/src/renderer/src/lib/agent-presence-selectors.ts @@ -1,11 +1,21 @@ 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 { + evidenceAfterEndedAgent, + paneEvidenceAgent, + paneEvidenceCounts, + selectLiveOwnerAgent, + withoutEndedOwnerEvidence +} from '../../../shared/ended-agent-owner-evidence' const EMPTY_PRESENCE: AgentPresenceByPaneKey = Object.freeze({}) let previousPresence: AgentPresenceByPaneKey | undefined @@ -52,77 +62,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 { - return countsAfterEndedAgent(endedOwnerAgent(presence), agent) -} - -export function paneEvidenceAgent( - presence: AgentProcessPresence | undefined, - agent: T | null | undefined -): T | null { - return evidenceAfterEndedAgent(endedOwnerAgent(presence), agent) -} - -/** The same rule when only the exited owner's agent is at hand (the tab strip's projection). */ -export function evidenceAfterEndedAgent( - endedAgent: string | undefined, - agent: T | null | undefined -): T | null { - return agent && countsAfterEndedAgent(endedAgent, agent) ? agent : null -} - -function endedOwnerAgent(presence: AgentProcessPresence | undefined): string | undefined { - return presence?.process && presence.ended ? presence.agent : undefined -} - -function countsAfterEndedAgent( - endedAgent: string | undefined, - agent: string | null | undefined -): boolean { - return endedAgent === undefined || (Boolean(agent) && agent !== 'unknown' && agent !== endedAgent) -} - -type PaneEvidenceSignals = { - title: string - hookAgent: TuiAgent | null - focusedCompletedHookAgent?: TuiAgent | null - processAgent?: TuiAgent | null - sleepingSessionAgent?: 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), - sleepingSessionAgent: paneEvidenceAgent(presence, args.sleepingSessionAgent), - 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/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 d692a5b83d39..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,7 +95,7 @@ export function buildMobileSessionAgentStatusByWorktree( bucket = new Map() byWorktreeId.set(worktreeId, bucket) } - bucket.set(paneKey, agentStatusByPaneKey[paneKey]) + bucket.set(paneKey, withoutHostPresence(agentStatusByPaneKey[paneKey])) } 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..84f321138831 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-presence.test.ts @@ -0,0 +1,225 @@ +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, + shouldApplyWebSessionTabsSnapshot +} 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 +const relaunchedProcess = { pid: 43, platform: 'linux', startTime: 'boot:43' } as const +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) + 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) + }) + + 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', () => { + // 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() + // Case A: a live owner first seen at 1000, then its exit published on a status stamped 400. + const live = { + ...state, + ...applyWebSessionTabsSnapshot(state, snapshot(false, { updatedAt: 1000 }), ENV, NOW) + } + 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', () => { + 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() + }) + + 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() + }) + + 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() + }) + + 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 e6166f809f75..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,9 @@ +import { paneEvidenceCounts } from '../../../../shared/ended-agent-owner-evidence' +import { + isSameAgentProcess, + readAgentProcessPresence, + type AgentProcessPresence +} from '../../../../shared/agent-process-presence' import { agentStatusAuthorityObservedAt, type AgentStatusEntry @@ -21,6 +27,7 @@ import { isMirroredAgentPaneKeyForTabs, batchAgentPaneKeysForTabs, remapHostAgentStatus, + toMirroredPaneKey, updateBatchAgentPaneKey } from './agent-status-primitives' import { @@ -57,6 +64,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[], @@ -67,7 +85,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 +115,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 @@ -101,14 +125,58 @@ 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) + const previousPresence = nextPresenceByPaneKey[hostEntry.paneKey] + // 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 && + (previousPresence?.connectionId !== environmentId || + !isSameOwnerRecord(previousPresence.presence, presence)) + ) { + nextPresenceByPaneKey = { + ...nextPresenceByPaneKey, + [hostEntry.paneKey]: { + presence, + receivedAt: hostEntry.updatedAt, + connectionId: environmentId, + worktreeId + } + } + presenceChanged = true + } + // Why: an exited owner's own status is history; a later agent's status in the pane still shows. + if ( + !paneEvidenceCounts(nextPresenceByPaneKey[hostEntry.paneKey]?.presence, hostEntry.agentType) + ) { + endedPaneKeys.add(hostEntry.paneKey) continue } 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.connectionId === undefined - ? { ...hostEntry, connectionId: environmentId } - : hostEntry, + attributed.connectionId === undefined + ? { ...attributed, connectionId: environmentId } + : attributed, existing, now ) @@ -132,6 +200,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 +250,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 +310,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..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 @@ -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) } @@ -164,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>, @@ -180,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 ?? {}, 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/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 787e130821d3..391118e56eb0 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, @@ -101,6 +102,7 @@ describe('WebRuntimeClient', () => { WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_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 78dbf6f05537..57be53d45447 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, @@ -73,6 +74,7 @@ export async function routeWebRuntimeConnectionFrame( WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] }) 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-process-presence.ts b/src/shared/agent-process-presence.ts index cbde61981bfd..d0d84a6b7b14 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -12,6 +12,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 +59,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 +73,23 @@ 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 } + +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 c4f0a7024473..c8ed1023463a 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. @@ -75,6 +76,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/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/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index a8a38dc4b350..6941609963e8 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, @@ -18,6 +19,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ 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, + // Why: the paired client mirrors the host's tabs and reads their owner record; the CLI does not. + AGENT_PROCESS_PRESENCE_RUNTIME_CAPABILITY, // Only the renderer shows Deleting for a `removing` row; CLI and mobile get those rows omitted. WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/ended-agent-owner-evidence.ts b/src/shared/ended-agent-owner-evidence.ts new file mode 100644 index 000000000000..d85120567a2e --- /dev/null +++ b/src/shared/ended-agent-owner-evidence.ts @@ -0,0 +1,90 @@ +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 { + return countsAfterEndedAgent(endedOwnerAgent(presence), agent) +} + +export function paneEvidenceAgent( + presence: AgentProcessPresence | undefined, + agent: T | null | undefined +): T | null { + return evidenceAfterEndedAgent(endedOwnerAgent(presence), agent) +} + +/** The same rule when only the exited owner's agent is at hand (the tab strip's projection). */ +export function evidenceAfterEndedAgent( + endedAgent: string | undefined, + agent: T | null | undefined +): T | null { + return agent && countsAfterEndedAgent(endedAgent, agent) ? agent : null +} + +function endedOwnerAgent(presence: AgentProcessPresence | undefined): string | undefined { + return presence?.process && presence.ended ? presence.agent : undefined +} + +function countsAfterEndedAgent( + endedAgent: string | undefined, + agent: string | null | undefined +): boolean { + return endedAgent === undefined || (Boolean(agent) && agent !== 'unknown' && agent !== endedAgent) +} + +/** 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 + sleepingSessionAgent?: 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), + sleepingSessionAgent: paneEvidenceAgent(presence, args.sleepingSessionAgent), + launchAgent: paneEvidenceAgent(presence, args.launchAgent) ?? undefined + } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5d3217f00f3d..73786c318c7c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -33,6 +33,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 @@ -77,13 +79,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 = @@ -389,6 +385,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,