From 23796cc655e58937ee08980f05ed09c4f52c6f4f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 21:38:11 -0400 Subject: [PATCH 1/6] feat(agent-status): one owner per pane row, decided once by the execution host Hook events carry a producer identity (agent, session, process, nestedIn); the pane's agentPresence records the owner and the sessions it has held. transitionHookPresence classifies each producer as owner, guest or claim (rules 1-5), shared by main for local panes and the relay for SSH/WSL panes. Guests leave the row unchanged and start one owner check per owner, and only the guest whose event proved the owner exited takes the pane. Main adopts relayed rows as given. Deletes the agent-type identity guess and the Codex-only root keeper on the host; the owner's sparse events carry its model and session forward instead. --- .../agent-hooks/server-agent-presence.test.ts | 67 +++-- .../agent-hooks/server-ingest-remote.test.ts | 156 +++-------- .../server-pane-owner-relayed.test.ts | 166 +++++++++++ .../agent-hooks/server-pane-owner.test.ts | 258 ++++++++++++++++++ .../server/server-agent-presence.ts | 14 +- .../agent-hooks/server/server-lifecycle.ts | 1 - src/main/agent-hooks/server/server-state.ts | 4 - .../server/server-status-update.ts | 170 +++++++----- src/relay/agent-hook-event-admission.ts | 38 ++- src/relay/agent-hook-server.ts | 27 +- src/relay/relay-agent-presence.ts | 37 ++- .../agent-hook-listener/listener-event.ts | 4 +- .../agent-hook-presence-transition.test.ts | 102 +++++++ src/shared/agent-hook-presence-transition.ts | 241 ++++++++++++++-- src/shared/agent-pane-owner-probes.test.ts | 76 ++++++ src/shared/agent-pane-owner-probes.ts | 101 +++++++ src/shared/agent-process-presence.ts | 20 ++ src/shared/agent-session-resume.ts | 2 +- 18 files changed, 1181 insertions(+), 303 deletions(-) create mode 100644 src/main/agent-hooks/server-pane-owner-relayed.test.ts create mode 100644 src/main/agent-hooks/server-pane-owner.test.ts create mode 100644 src/shared/agent-hook-presence-transition.test.ts create mode 100644 src/shared/agent-pane-owner-probes.test.ts create mode 100644 src/shared/agent-pane-owner-probes.ts diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index cd8da3aecb4c..1cbf7ae6c9e5 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -140,63 +140,72 @@ describe('host-owned hook presence', () => { expect(await server.checkAgentPresence(PANE)).toBeNull() }) - it('does not let a Claude started inside a working Codex turn end the pane', async () => { + it('adopts a relayed owner verdict as given, including its exit', async () => { const server = await createServer() const base = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' } + const owner = { + agent: 'claude', + process: { pid: 4002, platform: 'linux', startTime: 'boot:1' } + } server.ingestRemote( { ...base, - source: 'codex', + source: 'claude', hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'codex task', agentType: 'codex' } + agentPresence: owner, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } }, 'ssh-1' ) - // The relay has no identity resolution, so it can hand the nested Claude ownership. + // Why: the relay decided this exit; main never re-checks it against its own copy of the owner. server.ingestRemote( { ...base, source: 'claude', hookEventName: 'SessionEnd', providerSessionOnly: true, - agentPresence: { - agent: 'claude', - process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }, - ended: true - }, + agentPresence: { ...owner, ended: true }, payload: { state: 'done', prompt: '', agentType: 'claude' } }, 'ssh-1' ) - expect(state(server)).toBe('working') + expect(visible(server)).toBe(false) }) - it.each([ - ['working', []], - ['idle', ['Stop']] - ])('keeps a %s Codex pane when a Claude run inside it ends', async (_label, codexTail) => { + const codex = async (server: AgentHookServer, event: string): Promise => { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }), + '/hook/codex' + ) + expect(response.status).toBe(204) + } + + it('keeps a working Codex pane when a Claude run inside it ends', async () => { const server = await createServer() - const codex = async (event: string) => { - const response = await postHookEvent( - server, - buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }), - '/hook/codex' - ) - expect(response.status).toBe(204) - } - await codex('UserPromptSubmit') - for (const event of codexTail) { - await codex(event) - } - const before = state(server) + await codex(server, 'UserPromptSubmit') await hook(server, 'SessionStart', 'nested', undefined, 4002) await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) await hook(server, 'SessionEnd', 'nested', 'other', 4002) - expect(visible(server)).toBe(true) - expect(before).not.toBeNull() + const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + expect(row).toMatchObject({ state: 'working', agentType: 'codex', prompt: 'task' }) expect(await server.checkAgentPresence(PANE)).toBeNull() }) + // Why: Codex hooks report no process yet, so an idle Codex owner falls back to the freshness rule. + it('lets another agent take an idle owner the host cannot check', async () => { + const server = await createServer() + await codex(server, 'UserPromptSubmit') + await codex(server, 'Stop') + await hook(server, 'UserPromptSubmit', 'next', undefined, 4002) + expect(server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)).toMatchObject({ + state: 'working', + agentType: 'claude' + }) + await hook(server, 'SessionEnd', 'next', 'prompt_input_exit', 4002) + expect(visible(server)).toBe(false) + }) + it.each(['devin', 'qoder', 'codebuddy', 'copilot'])( 'settles %s to done on its own SessionEnd hook', async (agent) => { diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index 67f11a51a2bd..897fcca5cedf 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -194,131 +194,41 @@ describe('AgentHookServer ingestRemote', () => { ) }) - it('preserves active pane identity when a nested remote hook reports another agent', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'nested claude', - agentType: 'claude', - toolName: 'Read', - toolInput: '00-review-context.md' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - prompt: 'nested claude', - agentType: 'codex', - toolName: 'Read', - toolInput: '00-review-context.md', - receivedAt: 1_100 - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - prompt: 'nested claude', - agentType: 'codex' - }) - }) - ) - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('ignores nested remote done while the parent pane agent is still active', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'done', - prompt: 'nested claude', - agentType: 'claude', - toolName: 'Read', - toolInput: '00-review-context.md', - lastAssistantMessage: 'child finished' - } - }, - 'conn-1' - ) - - const snapshot = server.getStatusSnapshot() - expect(snapshot).toHaveLength(1) - expect(snapshot[0]).toMatchObject({ + // Why: the relay classifies its panes' producers (a nested agent is a guest there and never + // forwarded), so main adopts every relayed row as given and never re-guesses its identity. + it('adopts a relayed row as its relay built it', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, - state: 'working', - prompt: 'parent codex', - agentType: 'codex', - receivedAt: 1_000, - stateStartedAt: 1_000 + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { agent: 'codex', session: 'codex-a' }, + payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { agent: 'claude', session: 'claude-a' }, + payload: { state: 'working', prompt: 'claude now', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'claude now', agentType: 'claude' }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentPresence: { agent: 'claude', session: 'claude-a' }, + payload: expect.objectContaining({ agentType: 'claude' }) }) - expect(snapshot[0].toolName).toBeUndefined() - expect(snapshot[0].toolInput).toBeUndefined() - expect(snapshot[0].lastAssistantMessage).toBeUndefined() - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'working', - prompt: 'parent codex', - agentType: 'codex' - }) - }) - ) - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } + ) }) it('allows remote pane identity to change after the prior turn is done', () => { diff --git a/src/main/agent-hooks/server-pane-owner-relayed.test.ts b/src/main/agent-hooks/server-pane-owner-relayed.test.ts new file mode 100644 index 000000000000..a9712160bb6e --- /dev/null +++ b/src/main/agent-hooks/server-pane-owner-relayed.test.ts @@ -0,0 +1,166 @@ +// The pane owner rule on SSH/WSL panes: the relay classifies each hook producer and forwards only +// the rows it builds; the desktop adopts them as given. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' +import { AgentHookServer } from './server' +import { buildBody, PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const running: { stop: () => void }[] = [] +const paths: string[] = [] +afterEach(() => { + for (const server of running.splice(0)) { + server.stop() + } + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + probe.mockReset() + probe.mockResolvedValue('live') +}) + +type SshPane = { + relay: RelayAgentHookServer + desktop: AgentHookServer + pushed: AgentHookEventPayload[] + connected: boolean + post: (path: string, payload: Record, agentProcess?: string) => Promise +} + +async function startSshPane(): Promise { + const endpointDir = mkdtempSync(join(tmpdir(), 'orca-pane-owner-relayed-')) + paths.push(endpointDir) + const desktop = new AgentHookServer() + const pane: SshPane = { + desktop, + pushed: [], + connected: true, + relay: new RelayAgentHookServer({ + endpointDir, + token: 'pane-owner-token', + forward: (envelope) => { + if (pane.connected) { + pane.desktop.ingestRemote(envelope, 'ssh-1') + } + } + }), + post: async (path, payload, agentProcess) => { + const { port, token } = pane.relay.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify(buildBody(payload, agentProcess ? { agentProcess } : {})) + }) + expect(response.status).toBe(204) + } + } + desktop.setListener((event) => pane.pushed.push(event)) + running.push(pane.relay, desktop) + await pane.relay.start({ publishEndpoint: false }) + return pane +} + +const CLAUDE_PROCESS = JSON.stringify({ pid: 4001, platform: 'linux', startTime: 'boot:1' }) + +function claude(pane: SshPane, event: string, extra: Record = {}) { + return pane.post( + '/hook/claude', + { hook_event_name: event, session_id: 'claude-a', ...extra }, + CLAUDE_PROCESS + ) +} + +function codex(pane: SshPane, event: string, extra: Record = {}) { + return pane.post('/hook/codex', { + hook_event_name: event, + session_id: 'codex-x', + model: 'gpt-6-astra', + ...extra + }) +} + +function row(server: AgentHookServer) { + return server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) +} + +describe('pane owner rule (relayed panes)', () => { + it('keeps Claude the owner while a nested Codex reports into its pane (STA-9582)', async () => { + const pane = await startSshPane() + await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + await codex(pane, 'PostToolUse', { tool_name: 'Bash', tool_input: { command: 'ls' } }) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'claude task', + providerSession: { id: 'claude-a' } + }) + await codex(pane, 'Stop') + await claude(pane, 'Stop') + // Prompt after Claude's Stop is the relay listener's pane-wide cache (a separate change). + expect(row(pane.desktop)).toMatchObject({ + state: 'done', + agentType: 'claude', + providerSession: { id: 'claude-a' } + }) + expect(pane.pushed.length).toBeGreaterThan(0) + for (const event of pane.pushed) { + expect(event.connectionId).toBe('ssh-1') + expect(event.payload.agentType).toBe('claude') + expect(event.payload.model).toBeUndefined() + expect(event.providerSession?.id).toBe('claude-a') + } + }) + + it('hands the pane to the guest once the relay proves the owner exited', async () => { + const pane = await startSshPane() + await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) + await claude(pane, 'Stop') + probe.mockResolvedValue('exited') + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + await vi.waitFor(() => + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + agentType: 'codex', + providerSession: { id: 'codex-x' } + }) + ) + expect(probe).toHaveBeenCalledOnce() + }) + + it('gives a replayed row the live verdict, and a disconnect releases nothing', async () => { + const pane = await startSshPane() + await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) + const live = pane.pushed.at(-1)?.agentPresence + pane.connected = false + pane.desktop.clearStatusEntriesForConnection('ssh-1') + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + pane.connected = true + pane.relay.replayCachedPayloadsForPanes() + const replayed = row(pane.desktop) + expect(replayed).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'claude task', + providerSession: { id: 'claude-a' } + }) + expect(pane.pushed.at(-1)?.isReplay).toBe(true) + expect(pane.pushed.at(-1)?.agentPresence).toEqual(live) + expect(pane.pushed.at(-1)?.agentPresence).toMatchObject({ + agent: 'claude', + session: 'claude-a', + process: { pid: 4001 } + }) + }) +}) diff --git a/src/main/agent-hooks/server-pane-owner.test.ts b/src/main/agent-hooks/server-pane-owner.test.ts new file mode 100644 index 000000000000..0bc5665fb126 --- /dev/null +++ b/src/main/agent-hooks/server-pane-owner.test.ts @@ -0,0 +1,258 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const servers: AgentHookServer[] = [] +const paths: string[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + probe.mockReset() + probe.mockResolvedValue('live') +}) + +class OwnerTestServer extends AgentHookServer { + /** Stands in for a hook transport that reports `nestedIn` (a later change wires the env markers). */ + applyHook(event: AgentHookEventPayload): void { + this.applyNormalizedStatus(event) + } +} + +async function createServer(userDataPath?: string): Promise { + const server = new OwnerTestServer() + servers.push(server) + await server.start({ env: 'production', ...(userDataPath ? { userDataPath } : {}) }) + return server +} + +async function claude( + server: AgentHookServer, + event: string, + extra: Record = {}, + session = 'claude-a', + pid: number | null = 4001 +): Promise { + const agentProcess = + pid === null + ? undefined + : JSON.stringify({ pid, platform: process.platform, startTime: `birth-${pid}` }) + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: session, ...extra }, { agentProcess }) + ) + expect(response.status).toBe(204) +} + +async function codex( + server: AgentHookServer, + event: string, + extra: Record = {} +): Promise { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: 'codex-x', model: 'gpt-6-astra', ...extra }), + '/hook/codex' + ) + expect(response.status).toBe(204) +} + +function row(server: AgentHookServer) { + return server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) +} + +describe('pane owner rule (local panes)', () => { + it('keeps Claude the owner while a nested Codex reports into its pane (STA-9582)', async () => { + const server = await createServer() + const pushed: AgentHookEventPayload[] = [] + server.setListener((event) => pushed.push(event)) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await codex(server, 'PostToolUse', { tool_name: 'Bash', tool_input: { command: 'ls' } }) + expect(row(server)).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'claude task', + providerSession: { id: 'claude-a' } + }) + await codex(server, 'Stop') + await claude(server, 'Stop') + // Prompt after Claude's Stop is the listener's pane-wide cache (a separate change), so not asserted. + expect(row(server)).toMatchObject({ + state: 'done', + agentType: 'claude', + providerSession: { id: 'claude-a' } + }) + expect(row(server)?.model).toBeUndefined() + expect(pushed.length).toBeGreaterThan(0) + for (const event of pushed) { + expect(event.payload.agentType).toBe('claude') + expect(event.payload.model).toBeUndefined() + expect(event.providerSession?.id).toBe('claude-a') + } + }) + + it('keeps an idle Claude the owner while its process lives, then takes its next turn', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'first' }) + await claude(server, 'Stop') + await codex(server, 'UserPromptSubmit', { prompt: 'detached codex' }) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + await claude(server, 'UserPromptSubmit', { prompt: 'second' }) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'claude', prompt: 'second' }) + await codex(server, 'Stop') + expect(row(server)).toMatchObject({ state: 'working', agentType: 'claude' }) + await claude(server, 'Stop') + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + it('checks the owner once however many guest events arrive', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + for (let index = 0; index < 6; index += 1) { + await codex(server, 'PostToolUse', { tool_name: 'Bash', tool_input: { command: 'ls' } }) + } + await vi.waitFor(() => expect(probe).toHaveBeenCalled()) + expect(probe).toHaveBeenCalledOnce() + }) + + it('hands the pane to the guest whose event proved the owner exited', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'first' }) + await claude(server, 'Stop') + probe.mockResolvedValue('exited') + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await vi.waitFor(() => + expect(row(server)).toMatchObject({ + state: 'working', + agentType: 'codex', + prompt: 'codex task', + providerSession: { id: 'codex-x' } + }) + ) + }) + + it('never replays a guest onto an owner that ended on its own exit', async () => { + const server = await createServer() + let finish: (verdict: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + await claude(server, 'SessionEnd', { reason: 'prompt_input_exit' }) + finish('exited') + await new Promise((resolve) => setTimeout(resolve, 20)) + const remnant = row(server) + expect(remnant).toMatchObject({ + providerSessionOnly: true, + agentType: 'claude', + providerSession: { id: 'claude-a' } + }) + }) + + it('treats a same-type session without proof as the owner, as before', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'outer' }) + await claude(server, 'UserPromptSubmit', { prompt: 'inner' }, 'claude-b', 4002) + expect(row(server)).toMatchObject({ agentType: 'claude', prompt: 'inner' }) + }) + + // Why no process: an owner the host cannot check yields to liveness, so only proof keeps it here. + it('keeps the owner session across /clear and recognizes the old one', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'before clear' }, 'claude-a', null) + await claude(server, 'SessionEnd', { reason: 'clear' }, 'claude-a', null) + await claude(server, 'UserPromptSubmit', { prompt: 'after clear' }, 'claude-b', null) + await claude(server, 'Stop', {}, 'claude-b', null) + expect(row(server)).toMatchObject({ state: 'done', providerSession: { id: 'claude-b' } }) + server.applyHook({ + paneKey: PANE, + connectionId: null, + nestedIn: [{ agent: 'claude', session: 'claude-a' }], + payload: { state: 'working', prompt: 'nested codex', agentType: 'codex' } + }) + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + it('lets terminal signals write under the owner without taking it', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'osc', agentType: 'codex' } + }) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + expect(row(server)).toMatchObject({ agentType: 'codex', prompt: 'osc' }) + await claude(server, 'Stop') + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + it('never lets a terminal signal claim an ownerless pane', async () => { + const server = await createServer() + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'osc', agentType: 'codex' } + }) + expect(row(server)).toMatchObject({ agentType: 'codex' }) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + expect(row(server)).toMatchObject({ agentType: 'claude', prompt: 'claude task' }) + }) + + it('never inverts a restored owner when its nested producer reports first', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-pane-owner-')) + paths.push(userDataPath) + const first = await createServer(userDataPath) + await claude(first, 'UserPromptSubmit', { prompt: 'claude task' }) + first.flushStatusPersistSync() + first.stop() + const restarted = await createServer(userDataPath) + expect(row(restarted)).toMatchObject({ agentType: 'claude', restoredUnconfirmed: true }) + restarted.applyHook({ + paneKey: PANE, + connectionId: null, + nestedIn: [{ agent: 'claude', session: 'claude-a' }], + payload: { state: 'working', prompt: 'nested codex', agentType: 'codex' } + }) + expect(row(restarted)).toMatchObject({ agentType: 'claude', prompt: 'claude task' }) + await claude(restarted, 'Stop') + expect(row(restarted)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + it('never lets a nested producer claim an ownerless pane', async () => { + const server = await createServer() + server.applyHook({ + paneKey: PANE, + connectionId: null, + nestedIn: [{ agent: 'claude', session: 'claude-a' }], + payload: { state: 'working', prompt: 'nested codex', agentType: 'codex' } + }) + expect(row(server)).toBeUndefined() + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + expect(row(server)).toMatchObject({ agentType: 'claude' }) + }) +}) diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index ec09832ccb85..d86071f01c06 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -1,8 +1,5 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import { - isSameAgentProcess, - type AgentProcessVerdict -} from '../../../shared/agent-process-presence' +import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' import { AgentHookServerLifecycle } from './server-lifecycle' @@ -12,15 +9,6 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy Promise >() - /** A live hook proves its own process alive; only another process's hook casts doubt on the owner. */ - checkAgentPresenceAfterHook(event: AgentHookEventPayload, row: AgentHookEventPayload): void { - const sender = event.agentPresence?.process - const owner = row.agentPresence - if (sender && owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { - void this.checkAgentPresence(row.paneKey) - } - } - /** Whether this pane's owner carries a process identity that its execution host can check. */ hasVerifiableAgentProcess(paneKey: string): boolean { const presence = this.state.lastStatusByPaneKey.get( diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 4a8c54fe9357..c560d11362f7 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -143,7 +143,6 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { - this.checkAgentPresenceAfterHook(event, enriched) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleTranscriptPoll(source, aliasedBody, enriched) } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index db61f046e24d..3838c4adb1c1 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -272,10 +272,6 @@ export abstract class AgentHookServerState { ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean abstract checkAgentPresence(paneKey: string): Promise - abstract checkAgentPresenceAfterHook( - event: AgentHookEventPayload, - row: AgentHookEventPayload - ): void abstract reconcileEndedProcessForPaneKeys( paneKeys: Iterable, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 03ec7c778503..a1860c95d1df 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,14 +1,19 @@ -import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' +import { + carryOwnerFields, + transitionHookPresence, + transitionTerminalPresence +} from '../../../shared/agent-hook-presence-transition' +import { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../shared/agent-status-identity' import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentProcessIdentity, + AgentProcessPresence +} from '../../../shared/agent-process-presence' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/claude-owed-notification-expiry-timers' import { setClaudeMainAgentTurnState } from '../../../shared/agent-hook-listener/providers/claude-roster-state' @@ -22,11 +27,69 @@ import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' +function liveOwner(row: AgentHookEventPayload | undefined): AgentProcessPresence | undefined { + return row?.agentPresence?.ended ? undefined : row?.agentPresence +} + export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected readonly claudeOwedNotificationExpiry = new ClaudeOwedNotificationExpiryTimers( this.state ) + private readonly paneOwnerProbes = new PaneOwnerProbes({ + checkOwner: (paneKey) => this.checkAgentPresence(paneKey) + }) + + /** Main decides ownership for local panes only; a relayed row's owner was decided by its relay. */ + private transitionPanePresence( + incoming: AgentHookEventPayload, + onAccepted: (() => void) | undefined, + origin: AgentStatusObservationOrigin + ): { event: AgentHookEventPayload; probe?: AgentProcessIdentity } | undefined { + // 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(incoming.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (origin !== 'hook') { + const terminal = transitionTerminalPresence(incoming, previous) + // Why: main cannot check a remote owner; its relay does. + return incoming.connectionId === null ? terminal : { event: terminal.event } + } + if (incoming.connectionId !== null) { + // Why: host restatements (cancel inference, expiry) carry no owner; the relayed one stands. + return { + event: carryOwnerFields( + incoming.agentPresence ? incoming : { ...incoming, agentPresence: liveOwner(previous) }, + previous + ) + } + } + const transition = transitionHookPresence(incoming, previous, { + now: Date.now(), + rowUpdatedAt: previous?.receivedAt + }) + if (transition.kind === 'drop') { + return undefined + } + if (transition.kind === 'guest') { + this.paneOwnerProbes.guest( + incoming.paneKey, + { + producer: transition.producer, + holdable: transition.holdable, + apply: () => { + if (this.server) { + this.applyNormalizedStatus(incoming, onAccepted) + } + } + }, + transition.probe + ) + return undefined + } + return transition + } + // Why here: every stored row passes through, including a cancel inference and a pane move. protected override commitStatusRowMutation( before: EnrichedAgentHookEventPayload | null | undefined, @@ -50,14 +113,41 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const transitioned = transitionHookPresence( - incoming, - this.state.lastStatusByPaneKey.get(incoming.paneKey) - ) - if (!transitioned) { + const presence = this.transitionPanePresence(incoming, onAccepted, origin) + if (!presence) { return undefined } - const { authorityRestartId, ...payload } = { ...incoming, ...transitioned } + const { + authorityRestartId, + nestedIn: _nestedIn, + ...payload + } = { + ...incoming, + ...presence.event + } + const applied = this.applyOwnedStatus( + payload, + authorityRestartId, + onAccepted, + origin, + observedAt, + mutationBefore + ) + // Why after the write: the check is bound to the row it reads, so it must read this one. + if (presence.probe) { + this.paneOwnerProbes.probe(payload.paneKey, presence.probe) + } + return applied + } + + private applyOwnedStatus( + payload: AgentHookEventPayload, + authorityRestartId: string | undefined, + onAccepted: (() => void) | undefined, + origin: AgentStatusObservationOrigin, + observedAt: number | undefined, + mutationBefore: EnrichedAgentHookEventPayload | undefined + ): EnrichedAgentHookEventPayload | undefined { if (!this.canWriteLegacyStatusRow(payload)) { return undefined } @@ -136,59 +226,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) } : terminalOwnedPayload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' - ? previous - : undefined - const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession - : undefined - const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model - : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload - const identity = resolveAgentStatusIdentity({ - existing: previous - ? { - agentType: previous.payload.agentType, - state: previous.payload.state, - updatedAt: previous.receivedAt, - restoredUnconfirmed: previous.restoredUnconfirmed - } - : undefined, - incoming: rootContextPreservingPayload.payload.agentType, - now - }) - if ( - previous && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: rootContextPreservingPayload.payload.state - }) - ) { - this.commitStatusRowMutation(rowBefore, previous) - return previous - } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType - ? rootContextPreservingPayload - : { - ...rootContextPreservingPayload, - payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } - } - const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const attachedPayload = attachClaudePermissionToolUseId(previous, stateReconciledPayload) // Why before the permission hold: that hold adopts the event's `mainAgent`, and a relay's // restatement of a main agent the desktop cancelled must not replace the cancel. const latch = resolveCancelVerdictLatch(previous, attachedPayload, Date.now()) @@ -240,9 +278,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.clearAssistantMessageRetry(effectivePayload.paneKey) } onAccepted?.() - if (!identity.inheritedFromActivePane) { - this.maybeTrackAgentPromptSent(effectivePayload, previous) - } + this.maybeTrackAgentPromptSent(effectivePayload, previous) // Why carried forward only within one host: main's OSC parse resolves the handle, so a later // hook must not erase its terminal join; a connection change must not inherit another host's. const { claudeRunningNonAgentTask: _unpaired, ...unpairedPayload } = effectivePayload diff --git a/src/relay/agent-hook-event-admission.ts b/src/relay/agent-hook-event-admission.ts index 50cb84adccd5..ccad21fd7b6d 100644 --- a/src/relay/agent-hook-event-admission.ts +++ b/src/relay/agent-hook-event-admission.ts @@ -1,5 +1,5 @@ import { transitionHookPresence } from '../shared/agent-hook-presence-transition' -import { isSameAgentProcess } from '../shared/agent-process-presence' +import type { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' @@ -20,7 +20,7 @@ type RelayHookAdmissionHost = { clearPaneState: (paneKey: string) => void clearAssistantMessageRetry: (paneKey: string) => void forward: RelayHookForward - checkAgentPresence: (paneKey: string) => Promise + ownerProbes: PaneOwnerProbes } export function applyRelayHookEvent( @@ -39,10 +39,27 @@ export function applyRelayHookEvent( if (cancellation.hold) { return previous } - const transitioned = transitionHookPresence(cancellation.event, previous) - if (!transitioned) { + const transition = transitionHookPresence(cancellation.event, previous, { + now: Date.now(), + rowUpdatedAt: previous?.hostEvidenceObservedAt + }) + if (transition.kind === 'drop') { return undefined } + if (transition.kind === 'guest') { + // Why: the relay decides for its panes and forwards only rows, so a guest event forwards nothing. + host.ownerProbes.guest( + incoming.paneKey, + { + producer: transition.producer, + holdable: transition.holdable, + apply: () => applyRelayHookEvent(host, incoming, source, env, version, options) + }, + options.checkPresence === false ? undefined : transition.probe + ) + return undefined + } + const transitioned = transition.event const event = withRelayClaudeTurnRevision( previous, transitioned.agentPresence?.ended @@ -74,17 +91,8 @@ export function applyRelayHookEvent( host.metadata.delete(event.paneKey) host.metadata.set(event.paneKey, { source, env, version }) host.forward(buildRelayHookEnvelope(event, source, env, version, options)) - const sender = incoming.agentPresence?.process - const owner = event.agentPresence - // Why: a live hook proves its own process alive; only another process's hook casts doubt on the owner. - if ( - options.checkPresence !== false && - sender && - owner?.process && - !owner.ended && - !isSameAgentProcess(sender, owner.process) - ) { - void host.checkAgentPresence(event.paneKey) + if (transition.probe && options.checkPresence !== false) { + host.ownerProbes.probe(event.paneKey, transition.probe) } // Why: retries compare against the cached row by identity, so they must hold that exact row. return host.state.lastStatusByPaneKey.get(event.paneKey) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 0250f8a87b6c..198461a503d0 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -16,6 +16,7 @@ export type { } from './agent-hook-server-contract' import { handleRelayHookRequest } from './agent-hook-request' import { RelayAgentPresence } from './relay-agent-presence' +import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -72,7 +73,15 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false - private readonly presenceChecks = new RelayAgentPresence() + private readonly presenceChecks = new RelayAgentPresence({ + current: (paneKey) => this.state.lastStatusByPaneKey.get(paneKey), + publish: (paneKey, event) => { + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + if (meta) { + this.applyEvent(event, meta.source, meta.env, meta.version) + } + } + }) private retryScheduler: AgentHookResultRetryScheduler readonly claudeTerminalInterrupts = createRelayClaudeTerminalInterrupts(this.state, () => this.relayInterruptHost() @@ -235,18 +244,8 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { } } - checkAgentPresence(paneKey: string): Promise { - const row = this.state.lastStatusByPaneKey.get(paneKey) - const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) - return this.presenceChecks.check( - row, - () => this.state.lastStatusByPaneKey.get(paneKey), - (event) => { - if (meta) { - this.applyEvent(event, meta.source, meta.env, meta.version) - } - } - ) + checkAgentPresence(paneKey: string): Promise { + return this.presenceChecks.check(paneKey) } /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ @@ -310,7 +309,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { clearAssistantMessageRetry: (paneKey) => this.retryScheduler.clearAssistantMessageRetry(paneKey), forward: this.forward, - checkAgentPresence: (paneKey) => this.checkAgentPresence(paneKey) + ownerProbes: this.presenceChecks.owners }, incoming, source, diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts index f32278a1309d..3d0d37c7a005 100644 --- a/src/relay/relay-agent-presence.ts +++ b/src/relay/relay-agent-presence.ts @@ -1,16 +1,30 @@ import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { probeAgentProcessPresence } from '../shared/agent-process-presence-probe' +import { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' export class RelayAgentPresence { - private readonly pending = new WeakMap>() + /** Owner checks started by guests and other producers, rate-limited per owner. */ + readonly owners: PaneOwnerProbes - check( - row: AgentHookEventPayload | undefined, - current: () => AgentHookEventPayload | undefined, - publish: (event: AgentHookEventPayload) => void - ): Promise { + constructor( + private readonly host: { + current: (paneKey: string) => AgentHookEventPayload | undefined + publish: (paneKey: string, event: AgentHookEventPayload) => void + } + ) { + this.owners = new PaneOwnerProbes({ checkOwner: (paneKey) => this.check(paneKey) }) + } + + private readonly pending = new WeakMap< + AgentHookEventPayload, + Promise + >() + + check(paneKey: string): Promise { + const row = this.host.current(paneKey) if (!row?.agentPresence?.process || row.agentPresence.ended) { - return Promise.resolve() + return Promise.resolve(null) } const existing = this.pending.get(row) if (existing) { @@ -19,13 +33,18 @@ export class RelayAgentPresence { const presence = row.agentPresence const check = probeAgentProcessPresence(presence.process) .then((verdict) => { - if (verdict === 'exited' && current() === row) { - publish({ + // Why: `exited` means this check released the pane; a row that moved on was not released. + if (this.host.current(paneKey) !== row) { + return 'unverifiable' as const + } + if (verdict === 'exited') { + this.host.publish(paneKey, { ...row, hookEventName: 'AgentProcessExit', agentPresence: { ...presence, ended: true } }) } + return verdict }) .finally(() => this.pending.delete(row)) this.pending.set(row, check) diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index e2e2303e3c23..b3a7bbac5db6 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -1,4 +1,4 @@ -import type { AgentProcessPresence } from '../agent-process-presence' +import type { AgentProcessPresence, NestedAgentSession } from '../agent-process-presence' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' import type { AgentProviderSessionMetadata } from '../agent-session-resume' @@ -6,6 +6,8 @@ import type { AgentProviderSessionMetadata } from '../agent-session-resume' export type AgentHookEventPayload = { paneKey: string agentPresence?: AgentProcessPresence + /** Agent sessions the producing process runs inside; classification input only, never stored. */ + nestedIn?: readonly NestedAgentSession[] /** Authenticated hook route that produced this event. */ source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ diff --git a/src/shared/agent-hook-presence-transition.test.ts b/src/shared/agent-hook-presence-transition.test.ts new file mode 100644 index 000000000000..01b9127216c7 --- /dev/null +++ b/src/shared/agent-hook-presence-transition.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { transitionHookPresence } from './agent-hook-presence-transition' +import { readAgentProcessPresence } from './agent-process-presence' + +const NOW = 10_000_000 +const OWNER_PROCESS = { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' } + +function event( + agentType: string, + fields: Partial = {}, + state: 'working' | 'done' = 'working' +): AgentHookEventPayload { + return { + paneKey: 'pane', + connectionId: null, + payload: { state, prompt: '', agentType }, + ...fields + } +} + +function session(id: string) { + return { providerSession: { key: 'session_id' as const, id } } +} + +const owner = event( + 'claude', + { ...session('claude-a'), agentPresence: { agent: 'claude', session: 'claude-a' } }, + 'done' +) + +describe('transitionHookPresence', () => { + it("ignores a producer's own-type markers, which a nested agent overwrites", () => { + const own = event('codex', { + nestedIn: [{ agent: 'codex', session: 'claude-a' }] + }) + expect(transitionHookPresence(own, owner, { now: NOW, rowUpdatedAt: NOW }).kind).toBe('write') + expect( + transitionHookPresence(own, undefined, { now: NOW, rowUpdatedAt: undefined }) + ).toMatchObject({ kind: 'write', event: { agentPresence: { agent: 'codex' } } }) + }) + + it('never stores the nesting markers on the row', () => { + const transition = transitionHookPresence( + event('claude', { ...session('claude-a'), nestedIn: [{ agent: 'codex', session: 'x' }] }), + owner, + { now: NOW, rowUpdatedAt: NOW } + ) + expect(transition.kind === 'write' && 'nestedIn' in transition.event).toBe(false) + }) + + it('makes a different-type producer a guest of a live owner and asks to check it', () => { + const live = event('claude', { + agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' } + }) + expect( + transitionHookPresence(event('codex', session('codex-x')), live, { + now: NOW, + rowUpdatedAt: NOW + }) + ).toMatchObject({ kind: 'guest', holdable: true, probe: OWNER_PROCESS }) + }) + + it('bounds the sessions an owner remembers', () => { + let row: AgentHookEventPayload = owner + for (const id of ['b', 'c', 'd', 'e', 'f', 'g']) { + const transition = transitionHookPresence(event('claude', session(id)), row, { + now: NOW, + rowUpdatedAt: NOW + }) + if (transition.kind !== 'write') { + throw new Error('owner event was not written') + } + row = transition.event + } + expect(row.agentPresence).toEqual({ + agent: 'claude', + session: 'g', + heldSessions: ['f', 'e', 'd', 'c'] + }) + expect(readAgentProcessPresence(JSON.parse(JSON.stringify(row.agentPresence)))).toEqual( + row.agentPresence + ) + }) + + it("carries the owner's model and session through its own sparse events", () => { + const withModel = event('codex', { + ...session('codex-x'), + agentPresence: { agent: 'codex', session: 'codex-x' }, + payload: { state: 'working', prompt: '', agentType: 'codex', model: 'gpt-5.4' } + }) + expect( + transitionHookPresence(event('codex', { toolAgentId: 'child' }), withModel, { + now: NOW, + rowUpdatedAt: NOW + }) + ).toMatchObject({ + kind: 'write', + event: { providerSession: { id: 'codex-x' }, payload: { model: 'gpt-5.4' } } + }) + }) +}) diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 241ec382efaf..1bc6ff6036d7 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,45 +1,234 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' -import { isSameAgentProcess } from './agent-process-presence' +import { + isSameAgentProcess, + MAX_OWNER_HELD_SESSIONS, + type AgentProcessIdentity, + type AgentProcessPresence +} from './agent-process-presence' +import { isFreshNonDoneAgentStatus } from './agent-status-freshness' -/** A pane has one owning agent; only the owner's own proven process can end it. */ +/** Who produced a hook event; compared against the pane's owner (`agentPresence`). */ +export type HookProducerIdentity = { + agent: string | undefined + session?: string + process?: AgentProcessIdentity + /** Sessions of other agent types this producer runs inside (own-type markers prove nothing). */ + nestedIn: string[] +} + +export type HookPresenceTransition = + | { + kind: 'write' + event: AgentHookEventPayload + /** Owner process another producer cast doubt on; the host checks it without waiting. */ + probe?: AgentProcessIdentity + } + | { + kind: 'guest' + /** Stable per-producer key, so a later event of the same guest replaces its held one. */ + producer: string + /** A live guest event may take the pane if the probe it started finds the owner exited. */ + holdable: boolean + probe?: AgentProcessIdentity + } + | { kind: 'drop' } + +export type HookPresenceContext = { + now: number + /** When the owner last wrote the row; guests never refresh it. */ + rowUpdatedAt: number | undefined +} + +export function readHookProducer(incoming: AgentHookEventPayload): HookProducerIdentity { + const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType + return { + agent: agent && agent !== 'unknown' ? agent : undefined, + session: incoming.providerSession?.id, + process: incoming.agentPresence?.process, + nestedIn: (incoming.nestedIn ?? []) + .filter((entry) => entry.agent !== agent) + .map((entry) => entry.session) + } +} + +function ownerSessions(owner: AgentProcessPresence): string[] { + return owner.session ? [owner.session, ...(owner.heldSessions ?? [])] : (owner.heldSessions ?? []) +} + +function sameProcess( + a: AgentProcessIdentity | undefined, + b: AgentProcessIdentity | undefined +): boolean { + return a !== undefined && b !== undefined && isSameAgentProcess(a, b) +} + +/** PLAN rules 1-4, first match wins. */ +function classifyAgainstOwner( + producer: HookProducerIdentity, + owner: AgentProcessPresence, + previous: AgentHookEventPayload, + context: HookPresenceContext +): 'owner' | 'guest' | 'claim' { + const held = ownerSessions(owner) + if ( + (producer.session !== undefined && held.includes(producer.session)) || + sameProcess(producer.process, owner.process) + ) { + return 'owner' + } + if (producer.nestedIn.some((session) => held.includes(session))) { + return 'guest' + } + if (producer.agent === undefined || producer.agent === owner.agent) { + return 'owner' + } + // Why: only an owner the host cannot check falls back to freshness; a restored one never blocks. + const ownerReleased = + previous.restoredUnconfirmed === true || + (owner.process === undefined && + !isFreshNonDoneAgentStatus( + { state: previous.payload.state, updatedAt: context.rowUpdatedAt ?? 0 }, + context.now + )) + return ownerReleased ? 'claim' : 'guest' +} + +function withOwnerSession( + owner: AgentProcessPresence, + producer: HookProducerIdentity +): AgentProcessPresence { + const session = producer.session + // Why: a different process of the owner's type is not the owner, so it never rotates its session. + if ( + !session || + session === owner.session || + (producer.process && owner.process && !isSameAgentProcess(producer.process, owner.process)) + ) { + return owner + } + const heldSessions = ownerSessions(owner) + .filter((held) => held !== session) + .slice(0, MAX_OWNER_HELD_SESSIONS) + return { ...owner, session, ...(heldSessions.length > 0 ? { heldSessions } : {}) } +} + +/** The owner's own sparse events (child hooks, a relay that restarted) keep its model and session. */ +export function carryOwnerFields( + event: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined +): AgentHookEventPayload { + if ( + !previous || + previous.providerSessionOnly || + previous.agentPresence?.ended || + previous.payload.agentType !== event.payload.agentType + ) { + return event + } + const providerSession = event.providerSession ?? previous.providerSession + const model = event.payload.model ?? previous.payload.model + if (providerSession === event.providerSession && model === event.payload.model) { + return event + } + return { + ...event, + ...(providerSession ? { providerSession } : {}), + payload: model ? { ...event.payload, model } : event.payload + } +} + +function guestKey(producer: HookProducerIdentity): string { + const process = producer.process + return [ + producer.agent ?? '', + producer.session ?? '', + process ? `${process.pid}:${process.startTime}` : '' + ].join('|') +} + +/** A pane has one owning agent. Only the owner's hook events write the row; a different agent's are + * guests until the owner is proven gone. Shared by main (local rows) and the relay (remote rows). */ export function transitionHookPresence( incoming: AgentHookEventPayload, - previous: AgentHookEventPayload | undefined -): AgentHookEventPayload | undefined { + previous: AgentHookEventPayload | undefined, + context: HookPresenceContext +): HookPresenceTransition { + const { nestedIn: _nestedIn, ...event } = incoming const recorded = previous?.agentPresence const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined - const sender = incoming.agentPresence?.process + const producer = readHookProducer(incoming) // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved // exit); other agents' SessionEnd hooks are ordinary status updates. const exit = incoming.agentPresence?.ended === true - if (owner) { + const verdict = + owner && previous ? classifyAgainstOwner(producer, owner, previous, context) : 'claim' + if (owner && verdict === 'guest') { + return { kind: 'guest', producer: guestKey(producer), holdable: !exit, probe: owner.process } + } + if (owner && verdict === 'owner') { if (exit) { - const fromOwner = owner.process && sender && isSameAgentProcess(owner.process, sender) - return fromOwner + return sameProcess(owner.process, producer.process) ? { - ...incoming, - payload: previous?.payload ?? incoming.payload, - agentPresence: { ...owner, ended: true } + kind: 'write', + event: { + ...event, + payload: previous?.payload ?? event.payload, + agentPresence: { ...owner, ended: true } + } } + : { kind: 'drop' } + } + const nextOwner = withOwnerSession(owner, producer) + const probe = + producer.process && owner.process && !isSameAgentProcess(producer.process, owner.process) + ? owner.process : undefined + return { + kind: 'write', + event: carryOwnerFields({ ...event, agentPresence: nextOwner }, previous), + ...(probe ? { probe } : {}) } - // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. - return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } } - if (exit) { - return undefined + if (exit || (recorded?.ended && sameProcess(recorded.process, producer.process))) { + return { kind: 'drop' } } - if ( - recorded?.ended && - recorded.process && - sender && - isSameAgentProcess(recorded.process, sender) - ) { - return undefined + // Why: a producer nested inside another agent's session never takes an ownerless pane, so a + // restart that brings both back cannot hand the pane to the nested one. + if (!owner && producer.nestedIn.length > 0) { + return { kind: 'guest', producer: guestKey(producer), holdable: false } } - const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType - if (!agent || agent === 'unknown') { - return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming + if (!producer.agent) { + return { kind: 'write', event: { ...event, agentPresence: undefined } } } - return { ...incoming, agentPresence: { agent, ...(sender ? { process: sender } : {}) } } + return { + kind: 'write', + event: { + ...event, + agentPresence: { + agent: producer.agent, + ...(producer.process ? { process: producer.process } : {}), + ...(producer.session ? { session: producer.session } : {}) + } + } + } +} + +/** Terminal signals (OSC, titles, process-derived rows) never claim and never become guests: they + * write the row under its current owner. One naming another agent asks the host to check the owner. */ +export function transitionTerminalPresence( + incoming: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined +): { event: AgentHookEventPayload; probe?: AgentProcessIdentity } { + const recorded = previous?.agentPresence?.ended ? undefined : previous?.agentPresence + const event = { ...incoming, agentPresence: recorded } + const agent = incoming.payload.agentType + const probe = + recorded?.process && + !previous?.providerSessionOnly && + agent && + agent !== 'unknown' && + agent !== recorded.agent + ? recorded.process + : undefined + return probe ? { event, probe } : { event } } diff --git a/src/shared/agent-pane-owner-probes.test.ts b/src/shared/agent-pane-owner-probes.test.ts new file mode 100644 index 000000000000..11d43e9500ed --- /dev/null +++ b/src/shared/agent-pane-owner-probes.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentProcessVerdict } from './agent-process-presence' +import { + HELD_GUEST_WINDOW_MS, + OWNER_PROBE_COOLDOWN_MS, + PaneOwnerProbes +} from './agent-pane-owner-probes' + +const OWNER = { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' } + +function setup(verdict: AgentProcessVerdict | null = 'live') { + let now = 1_000 + const checkOwner = vi.fn(async () => verdict) + const probes = new PaneOwnerProbes({ checkOwner, now: () => now }) + return { + probes, + checkOwner, + advance: (ms: number) => { + now += ms + } + } +} + +describe('PaneOwnerProbes', () => { + it('checks one owner once per window, and again after it', () => { + const { probes, checkOwner, advance } = setup() + for (let index = 0; index < 5; index += 1) { + probes.guest('pane', { producer: 'codex', holdable: true, apply: vi.fn() }, OWNER) + } + expect(checkOwner).toHaveBeenCalledOnce() + advance(OWNER_PROBE_COOLDOWN_MS) + probes.guest('pane', { producer: 'codex', holdable: true, apply: vi.fn() }, OWNER) + expect(checkOwner).toHaveBeenCalledTimes(2) + }) + + it('applies only the latest event of the guest that started the probe', async () => { + const { probes } = setup('exited') + const first = vi.fn() + const latest = vi.fn() + const other = vi.fn() + probes.guest('pane', { producer: 'codex', holdable: true, apply: first }, OWNER) + probes.guest('pane', { producer: 'codex', holdable: true, apply: latest }, OWNER) + probes.guest('pane', { producer: 'gemini', holdable: true, apply: other }, OWNER) + await vi.waitFor(() => expect(latest).toHaveBeenCalledOnce()) + expect(first).not.toHaveBeenCalled() + expect(other).not.toHaveBeenCalled() + }) + + it('applies nothing when the probe was not started by the held guest', async () => { + const { probes, checkOwner } = setup('exited') + const apply = vi.fn() + probes.probe('pane', OWNER) + probes.guest('pane', { producer: 'codex', holdable: true, apply }, OWNER) + await vi.waitFor(() => expect(checkOwner).toHaveBeenCalledOnce()) + await Promise.resolve() + expect(apply).not.toHaveBeenCalled() + }) + + it('drops a held event older than the window', async () => { + let release: (verdict: AgentProcessVerdict) => void = () => {} + let now = 0 + const probes = new PaneOwnerProbes({ + checkOwner: () => + new Promise((resolve) => { + release = resolve + }), + now: () => now + }) + const apply = vi.fn() + probes.guest('pane', { producer: 'codex', holdable: true, apply }, OWNER) + now += HELD_GUEST_WINDOW_MS + 1 + release('exited') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(apply).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/agent-pane-owner-probes.ts b/src/shared/agent-pane-owner-probes.ts new file mode 100644 index 000000000000..182143044122 --- /dev/null +++ b/src/shared/agent-pane-owner-probes.ts @@ -0,0 +1,101 @@ +import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' + +/** One probe per owner per window, however many guest events arrive. */ +export const OWNER_PROBE_COOLDOWN_MS = 10_000 +/** How long a guest's latest event may still take the pane once its probe finds the owner gone. */ +export const HELD_GUEST_WINDOW_MS = 30_000 + +type OwnerProbe = { owner: string; startedAt: number; token: object } +type HeldGuest = { producer: string; heldAt: number; apply: () => void; probe?: object } + +function ownerKey(owner: AgentProcessIdentity): string { + return `${owner.platform}:${owner.pid}:${owner.startTime}` +} + +/** Starts owner liveness checks for a host's panes and hands a pane to the guest that proved the + * owner gone. Ingest never waits on a check; the host injects its own probe. */ +export class PaneOwnerProbes { + private readonly probes = new Map() + private readonly held = new Map() + + constructor( + private readonly deps: { + /** Checks the pane's current owner; resolves `exited` only once it released the pane. */ + checkOwner: (paneKey: string) => Promise + now?: () => number + } + ) {} + + /** A guest event: keep its latest live event (one slot per pane), and check the owner. */ + guest( + paneKey: string, + guest: { producer: string; holdable: boolean; apply: () => void }, + owner: AgentProcessIdentity | undefined + ): void { + const now = this.now() + this.sweep(now) + const current = this.held.get(paneKey) + if (guest.holdable && (!current || current.producer === guest.producer)) { + this.held.set(paneKey, { + producer: guest.producer, + heldAt: now, + apply: guest.apply, + ...(current?.probe ? { probe: current.probe } : {}) + }) + } + if (owner) { + this.probe(paneKey, owner, guest.holdable ? guest.producer : undefined) + } + } + + /** A signal other than a guest (another process of the owner's type, a terminal) doubts the owner. */ + probe(paneKey: string, owner: AgentProcessIdentity, startedBy?: string): void { + const now = this.now() + const key = ownerKey(owner) + const last = this.probes.get(paneKey) + if (last?.owner === key && now - last.startedAt < OWNER_PROBE_COOLDOWN_MS) { + return + } + const token = {} + this.probes.set(paneKey, { owner: key, startedAt: now, token }) + const held = this.held.get(paneKey) + if (held && startedBy !== undefined && held.producer === startedBy) { + held.probe = token + } + void this.deps.checkOwner(paneKey).then((verdict) => { + if (verdict !== 'exited') { + return + } + const candidate = this.held.get(paneKey) + // Why: only the guest whose event started this probe takes over; an owner that ended on its + // own exit keeps its resume remnant, so nothing is replayed onto it. + if (candidate?.probe !== token || this.now() - candidate.heldAt > HELD_GUEST_WINDOW_MS) { + return + } + this.held.delete(paneKey) + candidate.apply() + }) + } + + clear(paneKey: string): void { + this.held.delete(paneKey) + this.probes.delete(paneKey) + } + + private sweep(now: number): void { + for (const [paneKey, held] of this.held) { + if (now - held.heldAt > HELD_GUEST_WINDOW_MS) { + this.held.delete(paneKey) + } + } + for (const [paneKey, probe] of this.probes) { + if (now - probe.startedAt >= OWNER_PROBE_COOLDOWN_MS) { + this.probes.delete(paneKey) + } + } + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } +} diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index cbde61981bfd..28e4531bd388 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -1,3 +1,4 @@ +import { normalizeSessionId } from './agent-session-resume' import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' import type { AgentType } from './agent-status-types' @@ -11,9 +12,18 @@ export type AgentProcessIdentity = { export type AgentProcessPresence = { agent: AgentType process?: AgentProcessIdentity + /** Provider session the owner holds now. */ + session?: string + /** Sessions the owner held before (e.g. across Claude `/clear`), newest first. */ + heldSessions?: string[] ended?: true } +export const MAX_OWNER_HELD_SESSIONS = 4 + +/** An agent session a producer runs inside, read from the env markers its parent exported. */ +export type NestedAgentSession = { agent: AgentType; session: string } + export type AgentProcessVerdict = 'live' | 'unverifiable' | 'exited' export function readAgentProcessIdentity(value: unknown): AgentProcessIdentity | undefined { @@ -58,9 +68,19 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence | return undefined } const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined + const session = 'session' in value ? normalizeSessionId(value.session) : null + const heldSessions = + 'heldSessions' in value && Array.isArray(value.heldSessions) + ? value.heldSessions + .slice(0, MAX_OWNER_HELD_SESSIONS) + .map(normalizeSessionId) + .filter((held): held is string => held !== null) + : [] return { agent: value.agent, ...(process ? { process } : {}), + ...(session ? { session } : {}), + ...(heldSessions.length > 0 ? { heldSessions } : {}), ...('ended' in value && value.ended === true ? { ended: true as const } : {}) } } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ba1f5574ab1f..2adc8e1d0c31 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -97,7 +97,7 @@ export function hasUnsafeProviderSessionIdChars(value: string): boolean { return false } -function normalizeSessionId(value: unknown): string | null { +export function normalizeSessionId(value: unknown): string | null { if (typeof value !== 'string') { return null } From 956b9389354e70e79c57d5e8a2215ef1399f0ed7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 21:38:15 -0400 Subject: [PATCH 2/6] docs(agents): state the pane owner rule under Agent Status --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e41616285030..9c5a63c86cc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu ## Agent Status -The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy. +The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy. Each pane's row has one owner (`agentPresence`), decided by `transitionHookPresence` on the execution host (main for local panes, the relay for SSH/WSL; main adopts relayed rows as given): only the owner's hook events write the row, a different agent's events are guests that leave it unchanged, and ownership ends only on the owner's exit, a host check that it `exited`, or the freshness fallback for an owner the host cannot check. ## Agent Terminal Screens From 7abc91ccf094cf0a367469775c47b55ab1b03d1c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 22:48:56 -0400 Subject: [PATCH 3/6] fix(agent-status): terminal signals naming another agent never relabel a held owner's row A terminal signal (OSC, title, process-derived) that names a different agent than a held owner now leaves the row unchanged and only starts the owner check, matching the old identity fence. Once the check releases the pane the next signal writes normally; same-type signals and ownerless panes are unchanged. Main's pane presence decision moves to server-pane-presence.ts. --- .../agent-hooks/server-pane-owner.test.ts | 33 +++++-- .../server/server-pane-presence.ts | 66 ++++++++++++++ .../server/server-status-update.ts | 87 +++++-------------- src/shared/agent-hook-presence-transition.ts | 44 ++++++---- 4 files changed, 145 insertions(+), 85 deletions(-) create mode 100644 src/main/agent-hooks/server/server-pane-presence.ts diff --git a/src/main/agent-hooks/server-pane-owner.test.ts b/src/main/agent-hooks/server-pane-owner.test.ts index 0bc5665fb126..d85a0509d807 100644 --- a/src/main/agent-hooks/server-pane-owner.test.ts +++ b/src/main/agent-hooks/server-pane-owner.test.ts @@ -195,19 +195,40 @@ describe('pane owner rule (local panes)', () => { expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) }) - it('lets terminal signals write under the owner without taking it', async () => { - const server = await createServer() - await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + const osc = (server: AgentHookServer, agentType: string, state: 'working' | 'done') => server.ingestTerminalStatus({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'osc', agentType: 'codex' } + payload: { state, prompt: 'osc', agentType } }) + + it("leaves a held owner's row unchanged when a terminal signal names another agent", async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + const before = row(server) + osc(server, 'codex', 'done') await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) - expect(row(server)).toMatchObject({ agentType: 'codex', prompt: 'osc' }) - await claude(server, 'Stop') + expect(row(server)).toEqual(before) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'claude', prompt: 'task' }) + }) + + it('writes the next terminal signal once the check releases the owner', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + probe.mockResolvedValue('exited') + osc(server, 'codex', 'working') + await vi.waitFor(() => expect(row(server)?.providerSessionOnly).toBe(true)) + osc(server, 'codex', 'working') + expect(row(server)).toMatchObject({ state: 'working', agentType: 'codex', prompt: 'osc' }) + }) + + it("lets a terminal signal naming the owner's type update its state", async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + osc(server, 'claude', 'done') expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + expect(probe).not.toHaveBeenCalled() }) it('never lets a terminal signal claim an ownerless pane', async () => { diff --git a/src/main/agent-hooks/server/server-pane-presence.ts b/src/main/agent-hooks/server/server-pane-presence.ts new file mode 100644 index 000000000000..f043218f2d80 --- /dev/null +++ b/src/main/agent-hooks/server/server-pane-presence.ts @@ -0,0 +1,66 @@ +import { + carryOwnerFields, + transitionHookPresence, + transitionTerminalPresence +} from '../../../shared/agent-hook-presence-transition' +import type { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentProcessIdentity, + AgentProcessPresence +} from '../../../shared/agent-process-presence' +import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export type PanePresenceDecision = + | { event: AgentHookEventPayload; probe?: AgentProcessIdentity } + | { keep: EnrichedAgentHookEventPayload } + | undefined + +function liveOwner(row: AgentHookEventPayload | undefined): AgentProcessPresence | undefined { + return row?.agentPresence?.ended ? undefined : row?.agentPresence +} + +/** Main decides ownership for local panes only; a relayed row's owner was decided by its relay. */ +export function decidePanePresence( + incoming: AgentHookEventPayload, + previous: EnrichedAgentHookEventPayload | undefined, + origin: AgentStatusObservationOrigin, + probes: PaneOwnerProbes, + reapply: () => void +): PanePresenceDecision { + const context = { now: Date.now(), rowUpdatedAt: previous?.receivedAt } + if (origin !== 'hook') { + const terminal = transitionTerminalPresence(incoming, previous, context) + if (terminal.kind === 'write') { + return { event: terminal.event } + } + // Why: main cannot check a remote owner; its relay does. + if (terminal.probe && incoming.connectionId === null) { + probes.probe(incoming.paneKey, terminal.probe) + } + return previous ? { keep: previous } : undefined + } + if (incoming.connectionId !== null) { + // Why: host restatements (cancel inference, expiry) carry no owner; the relayed one stands. + return { + event: carryOwnerFields( + incoming.agentPresence ? incoming : { ...incoming, agentPresence: liveOwner(previous) }, + previous + ) + } + } + const transition = transitionHookPresence(incoming, previous, context) + if (transition.kind === 'drop') { + return undefined + } + if (transition.kind === 'guest') { + probes.guest( + incoming.paneKey, + { producer: transition.producer, holdable: transition.holdable, apply: reapply }, + transition.probe + ) + return undefined + } + return transition +} diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a1860c95d1df..0abbcf39e751 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,8 +1,3 @@ -import { - carryOwnerFields, - transitionHookPresence, - transitionTerminalPresence -} from '../../../shared/agent-hook-presence-transition' import { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' import { reconcileRemoteCodexState, @@ -10,10 +5,6 @@ import { } from '../../../shared/agent-hook-listener/providers/codex-state' import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import type { - AgentProcessIdentity, - AgentProcessPresence -} from '../../../shared/agent-process-presence' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/claude-owed-notification-expiry-timers' import { setClaudeMainAgentTurnState } from '../../../shared/agent-hook-listener/providers/claude-roster-state' @@ -26,10 +17,7 @@ import { import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' - -function liveOwner(row: AgentHookEventPayload | undefined): AgentProcessPresence | undefined { - return row?.agentPresence?.ended ? undefined : row?.agentPresence -} +import { decidePanePresence } from './server-pane-presence' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected readonly claudeOwedNotificationExpiry = new ClaudeOwedNotificationExpiryTimers( @@ -40,56 +28,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA checkOwner: (paneKey) => this.checkAgentPresence(paneKey) }) - /** Main decides ownership for local panes only; a relayed row's owner was decided by its relay. */ - private transitionPanePresence( - incoming: AgentHookEventPayload, - onAccepted: (() => void) | undefined, - origin: AgentStatusObservationOrigin - ): { event: AgentHookEventPayload; probe?: AgentProcessIdentity } | undefined { - // 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(incoming.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (origin !== 'hook') { - const terminal = transitionTerminalPresence(incoming, previous) - // Why: main cannot check a remote owner; its relay does. - return incoming.connectionId === null ? terminal : { event: terminal.event } - } - if (incoming.connectionId !== null) { - // Why: host restatements (cancel inference, expiry) carry no owner; the relayed one stands. - return { - event: carryOwnerFields( - incoming.agentPresence ? incoming : { ...incoming, agentPresence: liveOwner(previous) }, - previous - ) - } - } - const transition = transitionHookPresence(incoming, previous, { - now: Date.now(), - rowUpdatedAt: previous?.receivedAt - }) - if (transition.kind === 'drop') { - return undefined - } - if (transition.kind === 'guest') { - this.paneOwnerProbes.guest( - incoming.paneKey, - { - producer: transition.producer, - holdable: transition.holdable, - apply: () => { - if (this.server) { - this.applyNormalizedStatus(incoming, onAccepted) - } - } - }, - transition.probe - ) - return undefined - } - return transition - } - // Why here: every stored row passes through, including a cancel inference and a pane move. protected override commitStatusRowMutation( before: EnrichedAgentHookEventPayload | null | undefined, @@ -113,10 +51,31 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const presence = this.transitionPanePresence(incoming, onAccepted, origin) + const presence = decidePanePresence( + incoming, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + this.state.lastStatusByPaneKey.get(incoming.paneKey) as + | EnrichedAgentHookEventPayload + | undefined, + origin, + this.paneOwnerProbes, + () => { + if (this.server) { + this.applyNormalizedStatus(incoming, onAccepted) + } + } + ) if (!presence) { return undefined } + if ('keep' in presence) { + // Why: a terminal signal naming another agent never relabels a held owner's row. + if (mutationBefore) { + this.commitStatusRowMutation(mutationBefore, presence.keep) + this.emitEnrichedStatus(presence.keep) + } + return presence.keep + } const { authorityRestartId, nestedIn: _nestedIn, diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 1bc6ff6036d7..c4741a622b60 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -82,15 +82,23 @@ function classifyAgainstOwner( if (producer.agent === undefined || producer.agent === owner.agent) { return 'owner' } - // Why: only an owner the host cannot check falls back to freshness; a restored one never blocks. - const ownerReleased = + return isOwnerReleased(owner, previous, context) ? 'claim' : 'guest' +} + +// Why: only an owner the host cannot check falls back to freshness; a restored one never blocks. +function isOwnerReleased( + owner: AgentProcessPresence, + previous: AgentHookEventPayload, + context: HookPresenceContext +): boolean { + return ( previous.restoredUnconfirmed === true || (owner.process === undefined && !isFreshNonDoneAgentStatus( { state: previous.payload.state, updatedAt: context.rowUpdatedAt ?? 0 }, context.now )) - return ownerReleased ? 'claim' : 'guest' + ) } function withOwnerSession( @@ -213,22 +221,28 @@ export function transitionHookPresence( } } -/** Terminal signals (OSC, titles, process-derived rows) never claim and never become guests: they - * write the row under its current owner. One naming another agent asks the host to check the owner. */ +/** Terminal signals (OSC, titles, process-derived rows) never claim and never become guests. One + * naming the owner's type, or on an ownerless pane, writes under the current owner; one naming + * another agent leaves a held owner's row unchanged and asks the host to check the owner. */ export function transitionTerminalPresence( incoming: AgentHookEventPayload, - previous: AgentHookEventPayload | undefined -): { event: AgentHookEventPayload; probe?: AgentProcessIdentity } { + previous: AgentHookEventPayload | undefined, + context: HookPresenceContext +): + | { kind: 'write'; event: AgentHookEventPayload } + | { kind: 'keep'; probe?: AgentProcessIdentity } { const recorded = previous?.agentPresence?.ended ? undefined : previous?.agentPresence - const event = { ...incoming, agentPresence: recorded } + const owner = recorded && !previous?.providerSessionOnly ? recorded : undefined const agent = incoming.payload.agentType - const probe = - recorded?.process && - !previous?.providerSessionOnly && + if ( + owner && + previous && agent && agent !== 'unknown' && - agent !== recorded.agent - ? recorded.process - : undefined - return probe ? { event, probe } : { event } + agent !== owner.agent && + !isOwnerReleased(owner, previous, context) + ) { + return owner.process ? { kind: 'keep', probe: owner.process } : { kind: 'keep' } + } + return { kind: 'write', event: { ...incoming, agentPresence: recorded } } } From eb4e45af0f5477a6800baa1305d1988e09818ad4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 23:07:11 -0400 Subject: [PATCH 4/6] refactor(agent-status): simplify the pane owner rule's wiring - One held event per pane, replayed on any exited verdict; replay re-runs the rule, so classification and the check's row-identity guard gate it. Fixes a lost handover when a terminal- or same-type-started check put the guest's own check in cooldown. - transitionHookPresence returns write or skip; probe on skip means hold and check, set only for a live unproven guest whose owner has a process. - PaneOwnerProbes.admit applies a transition for both main and the relay; the relay's checkPresence gate is gone. - The terminal fence moves to terminal ingest as a predicate; a released owner plus a signal naming another agent leaves the pane ownerless. - One currentOwner(row); owner fields never carry to a restarted process. - The relay's loopback bind moves to agent-hook-loopback-listener.ts. --- .../agent-hooks/server-agent-presence.test.ts | 14 -- .../server-pane-owner-relayed.test.ts | 14 ++ .../agent-hooks/server-pane-owner.test.ts | 50 ++++- .../server/server-agent-presence.ts | 13 +- .../server/server-ingest-terminal.ts | 11 + .../server/server-pane-presence.ts | 66 ------ .../server/server-status-update.ts | 81 +++---- src/relay/agent-hook-event-admission.ts | 49 ++--- src/relay/agent-hook-loopback-listener.ts | 22 ++ src/relay/agent-hook-server.ts | 45 ++-- src/relay/relay-agent-presence.ts | 13 +- .../agent-hook-presence-transition.test.ts | 58 +++-- src/shared/agent-hook-presence-transition.ts | 207 +++++++++--------- src/shared/agent-pane-owner-probes.test.ts | 66 +++--- src/shared/agent-pane-owner-probes.ts | 79 +++---- 15 files changed, 386 insertions(+), 402 deletions(-) delete mode 100644 src/main/agent-hooks/server/server-pane-presence.ts create mode 100644 src/relay/agent-hook-loopback-listener.ts diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 1cbf7ae6c9e5..5549d042cbf5 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -104,20 +104,6 @@ describe('host-owned hook presence', () => { expect(visible(server)).toBe(false) }) - it('keeps the pane owned by its agent while a nested agent in it starts and ends', async () => { - const server = await createServer() - await hook(server, 'SessionStart', 'outer') - await hook(server, 'UserPromptSubmit', 'outer') - await hook(server, 'SessionStart', 'nested', undefined, 4002) - await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) - await hook(server, 'SessionEnd', 'nested', 'other', 4002) - expect(visible(server)).toBe(true) - await hook(server, 'PostToolUse', 'outer') - expect(state(server)).toBe('working') - await hook(server, 'SessionEnd', 'outer', 'other') - expect(visible(server)).toBe(false) - }) - it('never ends a pane from a SessionEnd without a process identity', async () => { const server = await createServer() await hook(server, 'SessionStart', 'outer', undefined, null) diff --git a/src/main/agent-hooks/server-pane-owner-relayed.test.ts b/src/main/agent-hooks/server-pane-owner-relayed.test.ts index a9712160bb6e..73795cd8601b 100644 --- a/src/main/agent-hooks/server-pane-owner-relayed.test.ts +++ b/src/main/agent-hooks/server-pane-owner-relayed.test.ts @@ -123,6 +123,20 @@ describe('pane owner rule (relayed panes)', () => { } }) + it('keeps a working Codex pane when a Claude run inside it ends (#23947)', async () => { + const pane = await startSshPane() + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + await claude(pane, 'SessionStart', { source: 'startup' }) + await claude(pane, 'UserPromptSubmit', { prompt: 'nested claude' }) + await claude(pane, 'SessionEnd', { reason: 'prompt_input_exit' }) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + agentType: 'codex', + prompt: 'codex task' + }) + expect(row(pane.desktop)?.providerSessionOnly).toBeUndefined() + }) + it('hands the pane to the guest once the relay proves the owner exited', async () => { const pane = await startSshPane() await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) diff --git a/src/main/agent-hooks/server-pane-owner.test.ts b/src/main/agent-hooks/server-pane-owner.test.ts index d85a0509d807..d18110ca3d2a 100644 --- a/src/main/agent-hooks/server-pane-owner.test.ts +++ b/src/main/agent-hooks/server-pane-owner.test.ts @@ -171,11 +171,15 @@ describe('pane owner rule (local panes)', () => { }) }) - it('treats a same-type session without proof as the owner, as before', async () => { + it('treats a same-type session without proof as the owner, but only its own exit ends it', async () => { const server = await createServer() await claude(server, 'UserPromptSubmit', { prompt: 'outer' }) await claude(server, 'UserPromptSubmit', { prompt: 'inner' }, 'claude-b', 4002) expect(row(server)).toMatchObject({ agentType: 'claude', prompt: 'inner' }) + await claude(server, 'SessionEnd', { reason: 'other' }, 'claude-b', 4002) + expect(row(server)?.providerSessionOnly).toBeUndefined() + await claude(server, 'SessionEnd', { reason: 'other' }) + expect(row(server)?.providerSessionOnly).toBe(true) }) // Why no process: an owner the host cannot check yields to liveness, so only proof keeps it here. @@ -231,6 +235,50 @@ describe('pane owner rule (local panes)', () => { expect(probe).not.toHaveBeenCalled() }) + it('hands the pane to a guest held while a terminal signal had already started the check', async () => { + const server = await createServer() + let finish: (verdict: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + osc(server, 'codex', 'working') + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + expect(probe).toHaveBeenCalledOnce() + finish('exited') + await vi.waitFor(() => + expect(row(server)).toMatchObject({ agentType: 'codex', prompt: 'codex task' }) + ) + }) + + it('leaves a released owner behind when a terminal signal names another agent', async () => { + const server = await createServer() + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await codex(server, 'Stop') + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + origin: 'process', + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'opencode' }) + const response = await postHookEvent( + server, + buildBody({ hook_event_name: 'SessionBusy', sessionID: 'ses_1' }), + '/hook/opencode' + ) + expect(response.status).toBe(204) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'opencode' }) + expect(row(server)?.observation?.origin).toBe('hook') + await codex(server, 'UserPromptSubmit', { prompt: 'late codex' }) + expect(row(server)).toMatchObject({ agentType: 'opencode' }) + }) + it('never lets a terminal signal claim an ownerless pane', async () => { const server = await createServer() server.ingestTerminalStatus({ diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index d86071f01c06..ef374c7ef6e0 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -2,6 +2,7 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/ import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' import { AgentHookServerLifecycle } from './server-lifecycle' +import { currentOwner } from '../../../shared/agent-hook-presence-transition' export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecycle { private readonly presenceChecks = new WeakMap< @@ -11,17 +12,18 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy /** Whether this pane's owner carries a process identity that its execution host can check. */ hasVerifiableAgentProcess(paneKey: string): boolean { - const presence = this.state.lastStatusByPaneKey.get( - this.resolvePaneKeyAlias(paneKey) - )?.agentPresence - return presence?.process !== undefined && !presence.ended + return ( + currentOwner(this.state.lastStatusByPaneKey.get(this.resolvePaneKeyAlias(paneKey))) + ?.process !== undefined + ) } checkAgentPresence(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) + const presence = currentOwner(row) // Why: an ended owner already published its exit, and an owner no hook identified cannot be checked. - if (!row?.agentPresence?.process || row.agentPresence.ended) { + if (!row || !presence?.process) { return Promise.resolve(null) } if (row.connectionId !== null) { @@ -31,7 +33,6 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy if (pending) { return pending } - const presence = row.agentPresence const check = probeAgentProcessPresence(presence.process) .then((verdict) => { if ( diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 004a6462d27b..041ab4875776 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -5,6 +5,10 @@ import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' +import { + currentOwner, + terminalSignalYieldsToOwner +} from '../../../shared/agent-hook-presence-transition' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { @@ -104,8 +108,15 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.yieldsToHookSince !== undefined && previous?.observation?.origin === 'hook' && previous.receivedAt >= event.yieldsToHookSince + const yieldsToOwner = terminalSignalYieldsToOwner(previous, event.payload.agentType) + const doubtedOwner = yieldsToOwner && connectionId === null ? currentOwner(previous) : undefined + if (doubtedOwner?.process) { + // Why: main cannot check a remote owner; its relay does. + this.paneOwnerProbes.probe(paneKey, doubtedOwner.process) + } if ( hookOwnsCommand || + yieldsToOwner || (previous?.payload.agentType === 'claude' && event.payload.agentType === 'claude' && isAgentStatusHeldOpenByChildWork(previous.payload) && diff --git a/src/main/agent-hooks/server/server-pane-presence.ts b/src/main/agent-hooks/server/server-pane-presence.ts deleted file mode 100644 index f043218f2d80..000000000000 --- a/src/main/agent-hooks/server/server-pane-presence.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - carryOwnerFields, - transitionHookPresence, - transitionTerminalPresence -} from '../../../shared/agent-hook-presence-transition' -import type { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' -import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import type { - AgentProcessIdentity, - AgentProcessPresence -} from '../../../shared/agent-process-presence' -import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' -import type { EnrichedAgentHookEventPayload } from './server-types' - -export type PanePresenceDecision = - | { event: AgentHookEventPayload; probe?: AgentProcessIdentity } - | { keep: EnrichedAgentHookEventPayload } - | undefined - -function liveOwner(row: AgentHookEventPayload | undefined): AgentProcessPresence | undefined { - return row?.agentPresence?.ended ? undefined : row?.agentPresence -} - -/** Main decides ownership for local panes only; a relayed row's owner was decided by its relay. */ -export function decidePanePresence( - incoming: AgentHookEventPayload, - previous: EnrichedAgentHookEventPayload | undefined, - origin: AgentStatusObservationOrigin, - probes: PaneOwnerProbes, - reapply: () => void -): PanePresenceDecision { - const context = { now: Date.now(), rowUpdatedAt: previous?.receivedAt } - if (origin !== 'hook') { - const terminal = transitionTerminalPresence(incoming, previous, context) - if (terminal.kind === 'write') { - return { event: terminal.event } - } - // Why: main cannot check a remote owner; its relay does. - if (terminal.probe && incoming.connectionId === null) { - probes.probe(incoming.paneKey, terminal.probe) - } - return previous ? { keep: previous } : undefined - } - if (incoming.connectionId !== null) { - // Why: host restatements (cancel inference, expiry) carry no owner; the relayed one stands. - return { - event: carryOwnerFields( - incoming.agentPresence ? incoming : { ...incoming, agentPresence: liveOwner(previous) }, - previous - ) - } - } - const transition = transitionHookPresence(incoming, previous, context) - if (transition.kind === 'drop') { - return undefined - } - if (transition.kind === 'guest') { - probes.guest( - incoming.paneKey, - { producer: transition.producer, holdable: transition.holdable, apply: reapply }, - transition.probe - ) - return undefined - } - return transition -} diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 0abbcf39e751..d62cbfedbad4 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,3 +1,8 @@ +import { + adoptRelayedRow, + terminalSignalOwner, + transitionHookPresence +} from '../../../shared/agent-hook-presence-transition' import { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' import { reconcileRemoteCodexState, @@ -17,14 +22,13 @@ import { import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' -import { decidePanePresence } from './server-pane-presence' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected readonly claudeOwedNotificationExpiry = new ClaudeOwedNotificationExpiryTimers( this.state ) - private readonly paneOwnerProbes = new PaneOwnerProbes({ + protected readonly paneOwnerProbes = new PaneOwnerProbes({ checkOwner: (paneKey) => this.checkAgentPresence(paneKey) }) @@ -51,62 +55,45 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const presence = decidePanePresence( - incoming, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. - this.state.lastStatusByPaneKey.get(incoming.paneKey) as - | EnrichedAgentHookEventPayload - | undefined, - origin, - this.paneOwnerProbes, - () => { - if (this.server) { - this.applyNormalizedStatus(incoming, onAccepted) - } - } - ) - if (!presence) { - return undefined - } - if ('keep' in presence) { - // Why: a terminal signal naming another agent never relabels a held owner's row. - if (mutationBefore) { - this.commitStatusRowMutation(mutationBefore, presence.keep) - this.emitEnrichedStatus(presence.keep) - } - return presence.keep + // 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(incoming.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const apply = (event: typeof incoming) => + this.applyOwnedStatus(event, onAccepted, origin, observedAt, mutationBefore) + // Why: terminals never claim; a relayed row's owner was decided by its relay; main decides + // ownership only for local hook producers. + if (origin !== 'hook') { + return apply({ + ...incoming, + agentPresence: terminalSignalOwner(previous, incoming.payload.agentType) + }) } - const { - authorityRestartId, - nestedIn: _nestedIn, - ...payload - } = { - ...incoming, - ...presence.event + if (incoming.connectionId !== null) { + return apply(adoptRelayedRow(incoming, previous)) } - const applied = this.applyOwnedStatus( - payload, - authorityRestartId, - onAccepted, - origin, - observedAt, - mutationBefore + return this.paneOwnerProbes.admit( + incoming.paneKey, + transitionHookPresence(incoming, previous, previous?.receivedAt), + { + write: apply, + reapply: () => { + if (this.server) { + this.applyNormalizedStatus(incoming, onAccepted) + } + } + } ) - // Why after the write: the check is bound to the row it reads, so it must read this one. - if (presence.probe) { - this.paneOwnerProbes.probe(payload.paneKey, presence.probe) - } - return applied } private applyOwnedStatus( - payload: AgentHookEventPayload, - authorityRestartId: string | undefined, + incoming: AgentHookEventPayload & { authorityRestartId?: string }, onAccepted: (() => void) | undefined, origin: AgentStatusObservationOrigin, observedAt: number | undefined, mutationBefore: EnrichedAgentHookEventPayload | undefined ): EnrichedAgentHookEventPayload | undefined { + const { authorityRestartId, ...payload } = incoming if (!this.canWriteLegacyStatusRow(payload)) { return undefined } diff --git a/src/relay/agent-hook-event-admission.ts b/src/relay/agent-hook-event-admission.ts index ccad21fd7b6d..d9c94f5aac70 100644 --- a/src/relay/agent-hook-event-admission.ts +++ b/src/relay/agent-hook-event-admission.ts @@ -29,7 +29,7 @@ export function applyRelayHookEvent( source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean; checkPresence?: boolean } = {} + options: { isReplay?: boolean } = {} ): AgentHookEventPayload | undefined { if (host.isCanonicalPane(incoming.paneKey)) { return undefined @@ -39,27 +39,29 @@ export function applyRelayHookEvent( if (cancellation.hold) { return previous } - const transition = transitionHookPresence(cancellation.event, previous, { - now: Date.now(), - rowUpdatedAt: previous?.hostEvidenceObservedAt - }) - if (transition.kind === 'drop') { - return undefined - } - if (transition.kind === 'guest') { - // Why: the relay decides for its panes and forwards only rows, so a guest event forwards nothing. - host.ownerProbes.guest( - incoming.paneKey, - { - producer: transition.producer, - holdable: transition.holdable, - apply: () => applyRelayHookEvent(host, incoming, source, env, version, options) - }, - options.checkPresence === false ? undefined : transition.probe - ) - return undefined - } - const transitioned = transition.event + // Why: the relay decides for its panes and forwards only rows, so a guest event forwards nothing. + return host.ownerProbes.admit( + incoming.paneKey, + transitionHookPresence(cancellation.event, previous, previous?.hostEvidenceObservedAt), + { + write: (transitioned) => + writeRelayRow(host, previous, transitioned, source, env, version, options), + reapply: () => { + applyRelayHookEvent(host, incoming, source, env, version, options) + } + } + ) +} + +function writeRelayRow( + host: RelayHookAdmissionHost, + previous: AgentHookEventPayload | undefined, + transitioned: AgentHookEventPayload, + source: AgentHookSource, + env: string | undefined, + version: string | undefined, + options: { isReplay?: boolean } +): AgentHookEventPayload | undefined { const event = withRelayClaudeTurnRevision( previous, transitioned.agentPresence?.ended @@ -91,9 +93,6 @@ export function applyRelayHookEvent( host.metadata.delete(event.paneKey) host.metadata.set(event.paneKey, { source, env, version }) host.forward(buildRelayHookEnvelope(event, source, env, version, options)) - if (transition.probe && options.checkPresence !== false) { - host.ownerProbes.probe(event.paneKey, transition.probe) - } // Why: retries compare against the cached row by identity, so they must hold that exact row. return host.state.lastStatusByPaneKey.get(event.paneKey) } diff --git a/src/relay/agent-hook-loopback-listener.ts b/src/relay/agent-hook-loopback-listener.ts new file mode 100644 index 000000000000..20421f6f0ab0 --- /dev/null +++ b/src/relay/agent-hook-loopback-listener.ts @@ -0,0 +1,22 @@ +import type { Server } from 'node:http' + +/** Binds the relay hook server on loopback only — reachable by the in-box agent CLI (127.0.0.1), + * not from outside the box. Resolves the bound port when the address reports one. */ +export function listenOnLoopback(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + server.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + server.off('error', onStartupError) + server.on('error', (err) => { + process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) + }) + const address = server.address() + resolve(address && typeof address === 'object' ? address.port : undefined) + } + server.once('error', onStartupError) + server.listen(port, '127.0.0.1', onListening) + }) +} diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 198461a503d0..b1db0dc93d6b 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -15,7 +15,9 @@ export type { RelayHookServerStartOptions } from './agent-hook-server-contract' import { handleRelayHookRequest } from './agent-hook-request' +import { listenOnLoopback } from './agent-hook-loopback-listener' import { RelayAgentPresence } from './relay-agent-presence' +import { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' @@ -82,6 +84,9 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { } } }) + private readonly ownerProbes = new PaneOwnerProbes({ + checkOwner: (paneKey) => this.checkAgentPresence(paneKey) + }) private retryScheduler: AgentHookResultRetryScheduler readonly claudeTerminalInterrupts = createRelayClaudeTerminalInterrupts(this.state, () => this.relayInterruptHost() @@ -112,7 +117,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { env: this.env, isListening: () => this.server !== null, applyEvent: (event, source, env, version) => { - this.applyEvent(event, source, env, version, { checkPresence: false }) + this.applyEvent(event, source, env, version) } }) } @@ -146,30 +151,15 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { return this.portFallbackApplied } - private listenOn(port: number): Promise { + private async listenOn(port: number): Promise { this.server = createServer((req, res) => this.handleRequest(req, res)) - return new Promise((resolve, reject) => { - const onStartupError = (err: Error): void => { - this.server?.off('listening', onListening) - // Why: clear failed server refs so later start() calls can retry. - this.server = null - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - resolve() - } - this.server!.once('error', onStartupError) - // Why: loopback only — reachable by the in-box agent CLI (127.0.0.1), not from outside the box. - this.server!.listen(port, '127.0.0.1', onListening) - }) + try { + this.port = (await listenOnLoopback(this.server, port)) ?? this.port + } catch (err) { + // Why: clear failed server refs so later start() calls can retry. + this.server = null + throw err + } } publishEndpointFile(): boolean { @@ -232,8 +222,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { getAgentLaunchToken: this.getAgentLaunchToken, isPaneBlocked: (paneKey) => this.isCanonicalPane(paneKey) || this.isPaneSurfaceRetired(paneKey), - apply: (event, meta) => - this.applyEvent(event, meta.source, meta.env, meta.version, { checkPresence: false }), + apply: (event, meta) => this.applyEvent(event, meta.source, meta.env, meta.version), armExpiry: (paneKey, meta) => this.retryScheduler.armClaudeOwedNotificationExpiry( meta.source, @@ -297,7 +286,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean; checkPresence?: boolean } = {} + options: { isReplay?: boolean } = {} ): AgentHookEventPayload | undefined { return applyRelayHookEvent( { @@ -309,7 +298,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { clearAssistantMessageRetry: (paneKey) => this.retryScheduler.clearAssistantMessageRetry(paneKey), forward: this.forward, - ownerProbes: this.presenceChecks.owners + ownerProbes: this.ownerProbes }, incoming, source, diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts index 3d0d37c7a005..a5ad1357bebc 100644 --- a/src/relay/relay-agent-presence.ts +++ b/src/relay/relay-agent-presence.ts @@ -1,20 +1,15 @@ import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { probeAgentProcessPresence } from '../shared/agent-process-presence-probe' -import { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' +import { currentOwner } from '../shared/agent-hook-presence-transition' export class RelayAgentPresence { - /** Owner checks started by guests and other producers, rate-limited per owner. */ - readonly owners: PaneOwnerProbes - constructor( private readonly host: { current: (paneKey: string) => AgentHookEventPayload | undefined publish: (paneKey: string, event: AgentHookEventPayload) => void } - ) { - this.owners = new PaneOwnerProbes({ checkOwner: (paneKey) => this.check(paneKey) }) - } + ) {} private readonly pending = new WeakMap< AgentHookEventPayload, @@ -23,14 +18,14 @@ export class RelayAgentPresence { check(paneKey: string): Promise { const row = this.host.current(paneKey) - if (!row?.agentPresence?.process || row.agentPresence.ended) { + const presence = currentOwner(row) + if (!row || !presence?.process) { return Promise.resolve(null) } const existing = this.pending.get(row) if (existing) { return existing } - const presence = row.agentPresence const check = probeAgentProcessPresence(presence.process) .then((verdict) => { // Why: `exited` means this check released the pane; a row that moved on was not released. diff --git a/src/shared/agent-hook-presence-transition.test.ts b/src/shared/agent-hook-presence-transition.test.ts index 01b9127216c7..877c0ec0ae69 100644 --- a/src/shared/agent-hook-presence-transition.test.ts +++ b/src/shared/agent-hook-presence-transition.test.ts @@ -34,17 +34,19 @@ describe('transitionHookPresence', () => { const own = event('codex', { nestedIn: [{ agent: 'codex', session: 'claude-a' }] }) - expect(transitionHookPresence(own, owner, { now: NOW, rowUpdatedAt: NOW }).kind).toBe('write') - expect( - transitionHookPresence(own, undefined, { now: NOW, rowUpdatedAt: undefined }) - ).toMatchObject({ kind: 'write', event: { agentPresence: { agent: 'codex' } } }) + expect(transitionHookPresence(own, owner, NOW, NOW).kind).toBe('write') + expect(transitionHookPresence(own, undefined, undefined, NOW)).toMatchObject({ + kind: 'write', + event: { agentPresence: { agent: 'codex' } } + }) }) it('never stores the nesting markers on the row', () => { const transition = transitionHookPresence( event('claude', { ...session('claude-a'), nestedIn: [{ agent: 'codex', session: 'x' }] }), owner, - { now: NOW, rowUpdatedAt: NOW } + NOW, + NOW ) expect(transition.kind === 'write' && 'nestedIn' in transition.event).toBe(false) }) @@ -53,21 +55,42 @@ describe('transitionHookPresence', () => { const live = event('claude', { agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' } }) - expect( - transitionHookPresence(event('codex', session('codex-x')), live, { - now: NOW, - rowUpdatedAt: NOW - }) - ).toMatchObject({ kind: 'guest', holdable: true, probe: OWNER_PROCESS }) + expect(transitionHookPresence(event('codex', session('codex-x')), live, NOW, NOW)).toEqual({ + kind: 'skip', + probe: OWNER_PROCESS + }) + }) + + it('holds and checks only a live unproven guest; proof and a guest exit need no check', () => { + const live = event('claude', { + agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' } + }) + const nested = event('codex', { nestedIn: [{ agent: 'claude', session: 'claude-a' }] }) + expect(transitionHookPresence(nested, live, NOW, NOW)).toEqual({ kind: 'skip' }) + const guestExit = event('codex', { + agentPresence: { agent: 'codex', process: { ...OWNER_PROCESS, pid: 4002 }, ended: true } + }) + expect(transitionHookPresence(guestExit, live, NOW, NOW)).toEqual({ kind: 'skip' }) + }) + + it("never carries a restarted process's predecessor model or session", () => { + const previous = event('claude', { + ...session('claude-a'), + agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' }, + payload: { state: 'done', prompt: '', agentType: 'claude', model: 'opus' } + }) + const restarted = event('claude', { + agentPresence: { agent: 'claude', process: { ...OWNER_PROCESS, pid: 4002 } } + }) + const transition = transitionHookPresence(restarted, previous, NOW, NOW) + expect(transition.kind === 'write' && transition.event.payload.model).toBeUndefined() + expect(transition.kind === 'write' && transition.event.providerSession).toBeUndefined() }) it('bounds the sessions an owner remembers', () => { let row: AgentHookEventPayload = owner for (const id of ['b', 'c', 'd', 'e', 'f', 'g']) { - const transition = transitionHookPresence(event('claude', session(id)), row, { - now: NOW, - rowUpdatedAt: NOW - }) + const transition = transitionHookPresence(event('claude', session(id)), row, NOW, NOW) if (transition.kind !== 'write') { throw new Error('owner event was not written') } @@ -90,10 +113,7 @@ describe('transitionHookPresence', () => { payload: { state: 'working', prompt: '', agentType: 'codex', model: 'gpt-5.4' } }) expect( - transitionHookPresence(event('codex', { toolAgentId: 'child' }), withModel, { - now: NOW, - rowUpdatedAt: NOW - }) + transitionHookPresence(event('codex', { toolAgentId: 'child' }), withModel, NOW, NOW) ).toMatchObject({ kind: 'write', event: { providerSession: { id: 'codex-x' }, payload: { model: 'gpt-5.4' } } diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index c4741a622b60..d0c4d9cb7111 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -7,8 +7,13 @@ import { } from './agent-process-presence' import { isFreshNonDoneAgentStatus } from './agent-status-freshness' -/** Who produced a hook event; compared against the pane's owner (`agentPresence`). */ -export type HookProducerIdentity = { +/** `write` stores the event; `skip` leaves the row unchanged. `probe` is an owner process another + * producer cast doubt on; on `skip` it also means the event is held until that owner is gone. */ +export type HookPresenceTransition = + | { kind: 'write'; event: AgentHookEventPayload; probe?: AgentProcessIdentity } + | { kind: 'skip'; probe?: AgentProcessIdentity } + +type HookProducer = { agent: string | undefined session?: string process?: AgentProcessIdentity @@ -16,30 +21,17 @@ export type HookProducerIdentity = { nestedIn: string[] } -export type HookPresenceTransition = - | { - kind: 'write' - event: AgentHookEventPayload - /** Owner process another producer cast doubt on; the host checks it without waiting. */ - probe?: AgentProcessIdentity - } - | { - kind: 'guest' - /** Stable per-producer key, so a later event of the same guest replaces its held one. */ - producer: string - /** A live guest event may take the pane if the probe it started finds the owner exited. */ - holdable: boolean - probe?: AgentProcessIdentity - } - | { kind: 'drop' } - -export type HookPresenceContext = { - now: number - /** When the owner last wrote the row; guests never refresh it. */ - rowUpdatedAt: number | undefined +/** The pane's owner. An ended owner, or an identity-only row (a resume remnant, a Pi session + * announcement), holds no pane. */ +export function currentOwner( + row: AgentHookEventPayload | undefined +): AgentProcessPresence | undefined { + return row?.agentPresence && !row.agentPresence.ended && !row.providerSessionOnly + ? row.agentPresence + : undefined } -export function readHookProducer(incoming: AgentHookEventPayload): HookProducerIdentity { +function readHookProducer(incoming: AgentHookEventPayload): HookProducer { const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType return { agent: agent && agent !== 'unknown' ? agent : undefined, @@ -62,13 +54,35 @@ function sameProcess( return a !== undefined && b !== undefined && isSameAgentProcess(a, b) } -/** PLAN rules 1-4, first match wins. */ +function otherProcess( + a: AgentProcessIdentity | undefined, + b: AgentProcessIdentity | undefined +): boolean { + return a !== undefined && b !== undefined && !isSameAgentProcess(a, b) +} + +// Why: only an owner the host cannot check falls back to freshness; a restored one never blocks. +function isOwnerReleased( + owner: AgentProcessPresence, + row: AgentHookEventPayload, + rowUpdatedAt: number | undefined, + now: number +): boolean { + return ( + row.restoredUnconfirmed === true || + (owner.process === undefined && + !isFreshNonDoneAgentStatus({ state: row.payload.state, updatedAt: rowUpdatedAt ?? 0 }, now)) + ) +} + +/** PLAN rules 1-4, first match wins: proven guests (rule 2) need no liveness check. */ function classifyAgainstOwner( - producer: HookProducerIdentity, + producer: HookProducer, owner: AgentProcessPresence, previous: AgentHookEventPayload, - context: HookPresenceContext -): 'owner' | 'guest' | 'claim' { + rowUpdatedAt: number | undefined, + now: number +): 'owner' | 'nested' | 'guest' | 'claim' { const held = ownerSessions(owner) if ( (producer.session !== undefined && held.includes(producer.session)) || @@ -77,41 +91,21 @@ function classifyAgainstOwner( return 'owner' } if (producer.nestedIn.some((session) => held.includes(session))) { - return 'guest' + return 'nested' } if (producer.agent === undefined || producer.agent === owner.agent) { return 'owner' } - return isOwnerReleased(owner, previous, context) ? 'claim' : 'guest' -} - -// Why: only an owner the host cannot check falls back to freshness; a restored one never blocks. -function isOwnerReleased( - owner: AgentProcessPresence, - previous: AgentHookEventPayload, - context: HookPresenceContext -): boolean { - return ( - previous.restoredUnconfirmed === true || - (owner.process === undefined && - !isFreshNonDoneAgentStatus( - { state: previous.payload.state, updatedAt: context.rowUpdatedAt ?? 0 }, - context.now - )) - ) + return isOwnerReleased(owner, previous, rowUpdatedAt, now) ? 'claim' : 'guest' } function withOwnerSession( owner: AgentProcessPresence, - producer: HookProducerIdentity + producer: HookProducer ): AgentProcessPresence { const session = producer.session // Why: a different process of the owner's type is not the owner, so it never rotates its session. - if ( - !session || - session === owner.session || - (producer.process && owner.process && !isSameAgentProcess(producer.process, owner.process)) - ) { + if (!session || session === owner.session || otherProcess(producer.process, owner.process)) { return owner } const heldSessions = ownerSessions(owner) @@ -120,8 +114,9 @@ function withOwnerSession( return { ...owner, session, ...(heldSessions.length > 0 ? { heldSessions } : {}) } } -/** The owner's own sparse events (child hooks, a relay that restarted) keep its model and session. */ -export function carryOwnerFields( +/** The owner's own sparse events (child hooks, a relay that restarted) keep its model and session; + * a restarted process of the same type starts clean. */ +function carryOwnerFields( event: AgentHookEventPayload, previous: AgentHookEventPayload | undefined ): AgentHookEventPayload { @@ -129,7 +124,8 @@ export function carryOwnerFields( !previous || previous.providerSessionOnly || previous.agentPresence?.ended || - previous.payload.agentType !== event.payload.agentType + previous.payload.agentType !== event.payload.agentType || + otherProcess(event.agentPresence?.process, previous.agentPresence?.process) ) { return event } @@ -145,33 +141,27 @@ export function carryOwnerFields( } } -function guestKey(producer: HookProducerIdentity): string { - const process = producer.process - return [ - producer.agent ?? '', - producer.session ?? '', - process ? `${process.pid}:${process.startTime}` : '' - ].join('|') -} - /** A pane has one owning agent. Only the owner's hook events write the row; a different agent's are * guests until the owner is proven gone. Shared by main (local rows) and the relay (remote rows). */ export function transitionHookPresence( incoming: AgentHookEventPayload, previous: AgentHookEventPayload | undefined, - context: HookPresenceContext + rowUpdatedAt: number | undefined, + now = Date.now() ): HookPresenceTransition { const { nestedIn: _nestedIn, ...event } = incoming - const recorded = previous?.agentPresence - const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined + const owner = currentOwner(previous) const producer = readHookProducer(incoming) // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved // exit); other agents' SessionEnd hooks are ordinary status updates. const exit = incoming.agentPresence?.ended === true const verdict = - owner && previous ? classifyAgainstOwner(producer, owner, previous, context) : 'claim' + owner && previous ? classifyAgainstOwner(producer, owner, previous, rowUpdatedAt, now) : 'claim' + if (verdict === 'nested') { + return { kind: 'skip' } + } if (owner && verdict === 'guest') { - return { kind: 'guest', producer: guestKey(producer), holdable: !exit, probe: owner.process } + return exit || !owner.process ? { kind: 'skip' } : { kind: 'skip', probe: owner.process } } if (owner && verdict === 'owner') { if (exit) { @@ -184,26 +174,23 @@ export function transitionHookPresence( agentPresence: { ...owner, ended: true } } } - : { kind: 'drop' } + : { kind: 'skip' } } - const nextOwner = withOwnerSession(owner, producer) - const probe = - producer.process && owner.process && !isSameAgentProcess(producer.process, owner.process) - ? owner.process - : undefined + const probe = otherProcess(producer.process, owner.process) ? owner.process : undefined return { kind: 'write', - event: carryOwnerFields({ ...event, agentPresence: nextOwner }, previous), + event: { + ...carryOwnerFields(event, previous), + agentPresence: withOwnerSession(owner, producer) + }, ...(probe ? { probe } : {}) } } - if (exit || (recorded?.ended && sameProcess(recorded.process, producer.process))) { - return { kind: 'drop' } - } + const ended = previous?.agentPresence?.ended ? previous.agentPresence : undefined // Why: a producer nested inside another agent's session never takes an ownerless pane, so a // restart that brings both back cannot hand the pane to the nested one. - if (!owner && producer.nestedIn.length > 0) { - return { kind: 'guest', producer: guestKey(producer), holdable: false } + if (exit || sameProcess(ended?.process, producer.process) || producer.nestedIn.length > 0) { + return { kind: 'skip' } } if (!producer.agent) { return { kind: 'write', event: { ...event, agentPresence: undefined } } @@ -221,28 +208,44 @@ export function transitionHookPresence( } } -/** Terminal signals (OSC, titles, process-derived rows) never claim and never become guests. One - * naming the owner's type, or on an ownerless pane, writes under the current owner; one naming - * another agent leaves a held owner's row unchanged and asks the host to check the owner. */ -export function transitionTerminalPresence( +/** Main adopts a relayed row as its relay built it; host restatements (cancel inference, expiry) + * carry no owner, so the relayed one stands. */ +export function adoptRelayedRow( incoming: AgentHookEventPayload, - previous: AgentHookEventPayload | undefined, - context: HookPresenceContext -): - | { kind: 'write'; event: AgentHookEventPayload } - | { kind: 'keep'; probe?: AgentProcessIdentity } { - const recorded = previous?.agentPresence?.ended ? undefined : previous?.agentPresence - const owner = recorded && !previous?.providerSessionOnly ? recorded : undefined - const agent = incoming.payload.agentType - if ( + previous: AgentHookEventPayload | undefined +): AgentHookEventPayload { + return carryOwnerFields( + incoming.agentPresence ? incoming : { ...incoming, agentPresence: currentOwner(previous) }, + previous + ) +} + +/** A terminal signal (OSC, title, process-derived) naming another agent than a held owner yields: + * terminals never claim, so it leaves the row unchanged and only casts doubt on the owner. */ +export function terminalSignalYieldsToOwner( + previous: (AgentHookEventPayload & { receivedAt: number }) | undefined, + agentType: string | undefined, + now = Date.now() +): boolean { + const owner = currentOwner(previous) + return Boolean( owner && previous && - agent && - agent !== 'unknown' && - agent !== owner.agent && - !isOwnerReleased(owner, previous, context) - ) { - return owner.process ? { kind: 'keep', probe: owner.process } : { kind: 'keep' } - } - return { kind: 'write', event: { ...incoming, agentPresence: recorded } } + agentType && + agentType !== 'unknown' && + agentType !== owner.agent && + !isOwnerReleased(owner, previous, previous.receivedAt, now) + ) +} + +/** Terminal signals write under the owner only when they name it (or no agent); one naming another + * agent after the owner was released leaves the pane ownerless. */ +export function terminalSignalOwner( + previous: AgentHookEventPayload | undefined, + agentType: string | undefined +): AgentProcessPresence | undefined { + const owner = currentOwner(previous) + return owner && (!agentType || agentType === 'unknown' || agentType === owner.agent) + ? owner + : undefined } diff --git a/src/shared/agent-pane-owner-probes.test.ts b/src/shared/agent-pane-owner-probes.test.ts index 11d43e9500ed..a3e73293ce95 100644 --- a/src/shared/agent-pane-owner-probes.test.ts +++ b/src/shared/agent-pane-owner-probes.test.ts @@ -7,70 +7,58 @@ import { } from './agent-pane-owner-probes' const OWNER = { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' } +const HOLD = { kind: 'skip' as const, probe: OWNER } -function setup(verdict: AgentProcessVerdict | null = 'live') { +function setup(checkOwner: () => Promise) { let now = 1_000 - const checkOwner = vi.fn(async () => verdict) const probes = new PaneOwnerProbes({ checkOwner, now: () => now }) - return { - probes, - checkOwner, - advance: (ms: number) => { - now += ms - } - } + return { probes, advance: (ms: number) => (now += ms) } } describe('PaneOwnerProbes', () => { it('checks one owner once per window, and again after it', () => { - const { probes, checkOwner, advance } = setup() + const checkOwner = vi.fn(async (): Promise => 'live') + const { probes, advance } = setup(checkOwner) for (let index = 0; index < 5; index += 1) { - probes.guest('pane', { producer: 'codex', holdable: true, apply: vi.fn() }, OWNER) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: vi.fn() }) } expect(checkOwner).toHaveBeenCalledOnce() advance(OWNER_PROBE_COOLDOWN_MS) - probes.guest('pane', { producer: 'codex', holdable: true, apply: vi.fn() }, OWNER) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: vi.fn() }) expect(checkOwner).toHaveBeenCalledTimes(2) }) - it('applies only the latest event of the guest that started the probe', async () => { - const { probes } = setup('exited') + it('replays only the latest held event, whichever signal started the check', async () => { + let release: (verdict: AgentProcessVerdict) => void = () => {} + const { probes } = setup( + () => + new Promise((resolve) => { + release = resolve + }) + ) const first = vi.fn() const latest = vi.fn() - const other = vi.fn() - probes.guest('pane', { producer: 'codex', holdable: true, apply: first }, OWNER) - probes.guest('pane', { producer: 'codex', holdable: true, apply: latest }, OWNER) - probes.guest('pane', { producer: 'gemini', holdable: true, apply: other }, OWNER) + probes.probe('pane', OWNER) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: first }) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: latest }) + release('exited') await vi.waitFor(() => expect(latest).toHaveBeenCalledOnce()) expect(first).not.toHaveBeenCalled() - expect(other).not.toHaveBeenCalled() - }) - - it('applies nothing when the probe was not started by the held guest', async () => { - const { probes, checkOwner } = setup('exited') - const apply = vi.fn() - probes.probe('pane', OWNER) - probes.guest('pane', { producer: 'codex', holdable: true, apply }, OWNER) - await vi.waitFor(() => expect(checkOwner).toHaveBeenCalledOnce()) - await Promise.resolve() - expect(apply).not.toHaveBeenCalled() }) it('drops a held event older than the window', async () => { let release: (verdict: AgentProcessVerdict) => void = () => {} - let now = 0 - const probes = new PaneOwnerProbes({ - checkOwner: () => + const { probes, advance } = setup( + () => new Promise((resolve) => { release = resolve - }), - now: () => now - }) - const apply = vi.fn() - probes.guest('pane', { producer: 'codex', holdable: true, apply }, OWNER) - now += HELD_GUEST_WINDOW_MS + 1 + }) + ) + const reapply = vi.fn() + probes.admit('pane', HOLD, { write: vi.fn(), reapply }) + advance(HELD_GUEST_WINDOW_MS + 1) release('exited') await new Promise((resolve) => setTimeout(resolve, 0)) - expect(apply).not.toHaveBeenCalled() + expect(reapply).not.toHaveBeenCalled() }) }) diff --git a/src/shared/agent-pane-owner-probes.ts b/src/shared/agent-pane-owner-probes.ts index 182143044122..a84e454cd066 100644 --- a/src/shared/agent-pane-owner-probes.ts +++ b/src/shared/agent-pane-owner-probes.ts @@ -1,87 +1,74 @@ +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import type { HookPresenceTransition } from './agent-hook-presence-transition' import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' /** One probe per owner per window, however many guest events arrive. */ export const OWNER_PROBE_COOLDOWN_MS = 10_000 -/** How long a guest's latest event may still take the pane once its probe finds the owner gone. */ +/** How long a held guest event may still take the pane once a probe finds the owner gone. */ export const HELD_GUEST_WINDOW_MS = 30_000 -type OwnerProbe = { owner: string; startedAt: number; token: object } -type HeldGuest = { producer: string; heldAt: number; apply: () => void; probe?: object } +type OwnerProbe = { owner: string; startedAt: number } +type HeldGuest = { heldAt: number; reapply: () => void } function ownerKey(owner: AgentProcessIdentity): string { return `${owner.platform}:${owner.pid}:${owner.startTime}` } -/** Starts owner liveness checks for a host's panes and hands a pane to the guest that proved the - * owner gone. Ingest never waits on a check; the host injects its own probe. */ +/** Applies a host's presence transitions and checks doubted owners without making ingest wait. + * The host injects its own check, which resolves `exited` only once it released the pane. */ export class PaneOwnerProbes { private readonly probes = new Map() private readonly held = new Map() constructor( private readonly deps: { - /** Checks the pane's current owner; resolves `exited` only once it released the pane. */ checkOwner: (paneKey: string) => Promise now?: () => number } ) {} - /** A guest event: keep its latest live event (one slot per pane), and check the owner. */ - guest( + admit( paneKey: string, - guest: { producer: string; holdable: boolean; apply: () => void }, - owner: AgentProcessIdentity | undefined - ): void { - const now = this.now() - this.sweep(now) - const current = this.held.get(paneKey) - if (guest.holdable && (!current || current.producer === guest.producer)) { - this.held.set(paneKey, { - producer: guest.producer, - heldAt: now, - apply: guest.apply, - ...(current?.probe ? { probe: current.probe } : {}) - }) + transition: HookPresenceTransition, + host: { write: (event: AgentHookEventPayload) => T; reapply: () => void } + ): T | undefined { + if (transition.kind === 'skip') { + if (transition.probe) { + // Why: one slot per pane; the latest guest event is what a released pane should show. + this.held.set(paneKey, { heldAt: this.now(), reapply: host.reapply }) + this.probe(paneKey, transition.probe) + } + return undefined } - if (owner) { - this.probe(paneKey, owner, guest.holdable ? guest.producer : undefined) + const written = host.write(transition.event) + // Why after the write: the host's check is bound to the row it reads, so it must read this one. + if (transition.probe) { + this.probe(paneKey, transition.probe) } + return written } - /** A signal other than a guest (another process of the owner's type, a terminal) doubts the owner. */ - probe(paneKey: string, owner: AgentProcessIdentity, startedBy?: string): void { + probe(paneKey: string, owner: AgentProcessIdentity): void { const now = this.now() + this.sweep(now) const key = ownerKey(owner) - const last = this.probes.get(paneKey) - if (last?.owner === key && now - last.startedAt < OWNER_PROBE_COOLDOWN_MS) { + if (this.probes.get(paneKey)?.owner === key) { return } - const token = {} - this.probes.set(paneKey, { owner: key, startedAt: now, token }) - const held = this.held.get(paneKey) - if (held && startedBy !== undefined && held.producer === startedBy) { - held.probe = token - } + this.probes.set(paneKey, { owner: key, startedAt: now }) void this.deps.checkOwner(paneKey).then((verdict) => { - if (verdict !== 'exited') { - return - } - const candidate = this.held.get(paneKey) - // Why: only the guest whose event started this probe takes over; an owner that ended on its - // own exit keeps its resume remnant, so nothing is replayed onto it. - if (candidate?.probe !== token || this.now() - candidate.heldAt > HELD_GUEST_WINDOW_MS) { + const held = this.held.get(paneKey) + if (verdict !== 'exited' || !held) { return } this.held.delete(paneKey) - candidate.apply() + // Why: replay re-classifies against the released row, so it only lands if it may claim it. + if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) { + held.reapply() + } }) } - clear(paneKey: string): void { - this.held.delete(paneKey) - this.probes.delete(paneKey) - } - private sweep(now: number): void { for (const [paneKey, held] of this.held) { if (now - held.heldAt > HELD_GUEST_WINDOW_MS) { From 423f4a89d46b004b88dc2d6c6157b487ab3a02f3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 23:24:13 -0400 Subject: [PATCH 5/6] fix(agent-status): owned rows keep the owner's type; one owner-check surface - An event naming no agent (OSC without agentType, a hook with 'unknown') writes under the owner's agent type and keeps its model. - classifyTerminalSignal replaces the two terminal helpers; a relayed owner main cannot check falls back to freshness instead of blocking forever. - A held guest is tied to the owner it waited behind and replays only when that owner is found exited. - Every host check (runtime liveness, relay command-finished and reconnect, startup spool) goes through PaneOwnerProbes.check; the raw probe is private. - Relay publish reports whether the exit was applied, so exited always means released. --- .../server-pane-owner-relayed.test.ts | 61 +++++++++ .../agent-hooks/server-pane-owner.test.ts | 15 +++ .../server/server-agent-presence.ts | 9 +- .../server/server-ingest-terminal.ts | 44 ++++--- src/main/agent-hooks/server/server-state.ts | 1 + .../server/server-status-update.ts | 22 ++-- src/relay/agent-hook-server.ts | 13 +- src/relay/relay-agent-presence.ts | 18 +-- src/shared/agent-hook-presence-transition.ts | 121 ++++++++++-------- src/shared/agent-pane-owner-probes.test.ts | 23 +++- src/shared/agent-pane-owner-probes.ts | 37 ++++-- 11 files changed, 246 insertions(+), 118 deletions(-) diff --git a/src/main/agent-hooks/server-pane-owner-relayed.test.ts b/src/main/agent-hooks/server-pane-owner-relayed.test.ts index 73795cd8601b..72fa22400779 100644 --- a/src/main/agent-hooks/server-pane-owner-relayed.test.ts +++ b/src/main/agent-hooks/server-pane-owner-relayed.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-freshness' import { AgentHookServer } from './server' import { buildBody, PANE } from './server.test-fixtures' @@ -177,4 +178,64 @@ describe('pane owner rule (relayed panes)', () => { process: { pid: 4001 } }) }) + + it("keeps a relayed owner's agent type and model for a terminal signal that names no agent", () => { + const desktop = new AgentHookServer() + desktop.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { agent: 'codex', session: 'codex-x' }, + payload: { state: 'working', prompt: 'codex task', agentType: 'codex', model: 'gpt-5.4' } + }, + 'ssh-1' + ) + desktop.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-1', + payload: { state: 'done', prompt: '', agentType: 'unknown' } + }) + expect(row(desktop)).toMatchObject({ state: 'done', agentType: 'codex', model: 'gpt-5.4' }) + }) + + // Why: main cannot check a remote process, so a relayed owner falls back to freshness here. + it('lets a terminal signal past a relayed owner only once the owner goes stale', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000_000) + const desktop = new AgentHookServer() + const signal = () => + desktop.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-1', + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + desktop.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { + agent: 'claude', + process: { pid: 4001, platform: 'linux', startTime: 'b' } + }, + payload: { state: 'working', prompt: 'claude task', agentType: 'claude' } + }, + 'ssh-1' + ) + signal() + expect(row(desktop)).toMatchObject({ agentType: 'claude' }) + expect(probe).not.toHaveBeenCalled() + vi.setSystemTime(1_000_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + signal() + expect(row(desktop)).toMatchObject({ agentType: 'opencode' }) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/agent-hooks/server-pane-owner.test.ts b/src/main/agent-hooks/server-pane-owner.test.ts index d18110ca3d2a..db457b519977 100644 --- a/src/main/agent-hooks/server-pane-owner.test.ts +++ b/src/main/agent-hooks/server-pane-owner.test.ts @@ -279,6 +279,21 @@ describe('pane owner rule (local panes)', () => { expect(row(server)).toMatchObject({ agentType: 'opencode' }) }) + it("keeps the owner's agent type and model for signals that name no agent", async () => { + const server = await createServer() + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + expect(row(server)).toMatchObject({ agentType: 'codex', model: 'gpt-6-astra' }) + osc(server, 'unknown', 'working') + expect(row(server)).toMatchObject({ agentType: 'codex', model: 'gpt-6-astra' }) + server.applyHook({ + paneKey: PANE, + connectionId: null, + providerSession: { key: 'session_id', id: 'codex-x' }, + payload: { state: 'done', prompt: 'codex task', agentType: 'unknown' } + }) + expect(row(server)).toMatchObject({ state: 'done', agentType: 'codex', model: 'gpt-6-astra' }) + }) + it('never lets a terminal signal claim an ownerless pane', async () => { const server = await createServer() server.ingestTerminalStatus({ diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index ef374c7ef6e0..bbb849d75a68 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -19,6 +19,10 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } checkAgentPresence(paneKey: string): Promise { + return this.paneOwnerProbes.check(paneKey) + } + + protected probeOwnerProcess(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) const presence = currentOwner(row) @@ -35,10 +39,7 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } const check = probeAgentProcessPresence(presence.process) .then((verdict) => { - if ( - this.state.lastStatusByPaneKey.get(resolved) !== row || - row.agentPresence !== presence - ) { + if (this.state.lastStatusByPaneKey.get(resolved) !== row) { return 'unverifiable' as const } if (verdict === 'exited') { diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 041ab4875776..d715ab9de4f8 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -6,8 +6,8 @@ import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-type import type { EnrichedAgentHookEventPayload } from './server-types' import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { - currentOwner, - terminalSignalYieldsToOwner + classifyTerminalSignal, + withOwnerAgentType } from '../../../shared/agent-hook-presence-transition' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' @@ -108,15 +108,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.yieldsToHookSince !== undefined && previous?.observation?.origin === 'hook' && previous.receivedAt >= event.yieldsToHookSince - const yieldsToOwner = terminalSignalYieldsToOwner(previous, event.payload.agentType) - const doubtedOwner = yieldsToOwner && connectionId === null ? currentOwner(previous) : undefined - if (doubtedOwner?.process) { - // Why: main cannot check a remote owner; its relay does. - this.paneOwnerProbes.probe(paneKey, doubtedOwner.process) + // Why: main cannot check a remote owner; its relay does. + const signal = classifyTerminalSignal(previous, event.payload.agentType, connectionId === null) + if (signal.kind === 'yield' && signal.probe) { + this.paneOwnerProbes.probe(paneKey, signal.probe) } if ( hookOwnsCommand || - yieldsToOwner || + signal.kind === 'yield' || (previous?.payload.agentType === 'claude' && event.payload.agentType === 'claude' && isAgentStatusHeldOpenByChildWork(previous.payload) && @@ -175,19 +174,26 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) ? previous.payload.mainAgent : undefined + const owner = signal.kind === 'write' ? signal.owner : undefined + // Why: a terminal signal that names no model leaves the owner's. + const preservedModel = owner && !event.payload.model ? previous?.payload.model : undefined + const row = { + paneKey, + tabId, + worktreeId, + connectionId, + agentPresence: owner, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + ...(terminalHandle ? { terminalHandle } : {}), + payload: { + ...event.payload, + ...(preservedMainAgent ? { mainAgent: preservedMainAgent } : {}), + ...(preservedModel ? { model: preservedModel } : {}) + } + } // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus( - { - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - payload: preservedMainAgent - ? { ...event.payload, mainAgent: preservedMainAgent } - : event.payload - }, + owner ? withOwnerAgentType(row, owner) : row, undefined, event.origin ?? 'osc', undefined, diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 3838c4adb1c1..ecf1957c95d6 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -272,6 +272,7 @@ export abstract class AgentHookServerState { ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean abstract checkAgentPresence(paneKey: string): Promise + protected abstract probeOwnerProcess(paneKey: string): Promise abstract reconcileEndedProcessForPaneKeys( paneKeys: Iterable, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index d62cbfedbad4..a1e077ba5260 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,6 +1,6 @@ import { adoptRelayedRow, - terminalSignalOwner, + currentOwner, transitionHookPresence } from '../../../shared/agent-hook-presence-transition' import { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' @@ -29,7 +29,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) protected readonly paneOwnerProbes = new PaneOwnerProbes({ - checkOwner: (paneKey) => this.checkAgentPresence(paneKey) + ownerOf: (paneKey) => + currentOwner(this.state.lastStatusByPaneKey.get(this.resolvePaneKeyAlias(paneKey)))?.process, + checkOwner: (paneKey) => this.probeOwnerProcess(paneKey) }) // Why here: every stored row passes through, including a cancel inference and a pane move. @@ -60,14 +62,11 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA | EnrichedAgentHookEventPayload | undefined const apply = (event: typeof incoming) => - this.applyOwnedStatus(event, onAccepted, origin, observedAt, mutationBefore) - // Why: terminals never claim; a relayed row's owner was decided by its relay; main decides - // ownership only for local hook producers. + this.applyOwnedStatus(event, previous, onAccepted, origin, observedAt, mutationBefore) + // Why: terminal ingest already placed its signal under the owner (terminals never claim); a + // relayed row's owner was decided by its relay; main decides only for local hook producers. if (origin !== 'hook') { - return apply({ - ...incoming, - agentPresence: terminalSignalOwner(previous, incoming.payload.agentType) - }) + return apply(incoming) } if (incoming.connectionId !== null) { return apply(adoptRelayedRow(incoming, previous)) @@ -88,6 +87,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA private applyOwnedStatus( incoming: AgentHookEventPayload & { authorityRestartId?: string }, + previous: EnrichedAgentHookEventPayload | undefined, onAccepted: (() => void) | undefined, origin: AgentStatusObservationOrigin, observedAt: number | undefined, @@ -108,10 +108,6 @@ 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 terminalHandle = payload.terminalHandle ?? diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index b1db0dc93d6b..d3d1150edd52 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -18,6 +18,7 @@ import { handleRelayHookRequest } from './agent-hook-request' import { listenOnLoopback } from './agent-hook-loopback-listener' import { RelayAgentPresence } from './relay-agent-presence' import { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' +import { currentOwner } from '../shared/agent-hook-presence-transition' import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' @@ -79,13 +80,15 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { current: (paneKey) => this.state.lastStatusByPaneKey.get(paneKey), publish: (paneKey, event) => { const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) - if (meta) { - this.applyEvent(event, meta.source, meta.env, meta.version) - } + return ( + meta !== undefined && + this.applyEvent(event, meta.source, meta.env, meta.version) !== undefined + ) } }) private readonly ownerProbes = new PaneOwnerProbes({ - checkOwner: (paneKey) => this.checkAgentPresence(paneKey) + ownerOf: (paneKey) => currentOwner(this.state.lastStatusByPaneKey.get(paneKey))?.process, + checkOwner: (paneKey) => this.presenceChecks.check(paneKey) }) private retryScheduler: AgentHookResultRetryScheduler readonly claudeTerminalInterrupts = createRelayClaudeTerminalInterrupts(this.state, () => @@ -234,7 +237,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { } checkAgentPresence(paneKey: string): Promise { - return this.presenceChecks.check(paneKey) + return this.ownerProbes.check(paneKey) } /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts index a5ad1357bebc..91f7fd74efca 100644 --- a/src/relay/relay-agent-presence.ts +++ b/src/relay/relay-agent-presence.ts @@ -7,7 +7,8 @@ export class RelayAgentPresence { constructor( private readonly host: { current: (paneKey: string) => AgentHookEventPayload | undefined - publish: (paneKey: string, event: AgentHookEventPayload) => void + /** Applies the exit; false when the pane could not take it (no cached envelope metadata). */ + publish: (paneKey: string, event: AgentHookEventPayload) => boolean } ) {} @@ -32,14 +33,15 @@ export class RelayAgentPresence { if (this.host.current(paneKey) !== row) { return 'unverifiable' as const } - if (verdict === 'exited') { - this.host.publish(paneKey, { - ...row, - hookEventName: 'AgentProcessExit', - agentPresence: { ...presence, ended: true } - }) + if (verdict !== 'exited') { + return verdict } - return verdict + const released = this.host.publish(paneKey, { + ...row, + hookEventName: 'AgentProcessExit', + agentPresence: { ...presence, ended: true } + }) + return released ? verdict : 'unverifiable' }) .finally(() => this.pending.delete(row)) this.pending.set(row, check) diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index d0c4d9cb7111..f78587bcada7 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -61,20 +61,32 @@ function otherProcess( return a !== undefined && b !== undefined && !isSameAgentProcess(a, b) } -// Why: only an owner the host cannot check falls back to freshness; a restored one never blocks. +// Why: only an owner this host cannot check falls back to freshness; a restored one never blocks. function isOwnerReleased( owner: AgentProcessPresence, row: AgentHookEventPayload, rowUpdatedAt: number | undefined, - now: number + now: number, + checkable = true ): boolean { return ( row.restoredUnconfirmed === true || - (owner.process === undefined && + ((!checkable || owner.process === undefined) && !isFreshNonDoneAgentStatus({ state: row.payload.state, updatedAt: rowUpdatedAt ?? 0 }, now)) ) } +/** An owned row's agent type is its owner's; an event that names no agent never erases it. */ +export function withOwnerAgentType( + event: AgentHookEventPayload, + owner: AgentProcessPresence +): AgentHookEventPayload { + const agentType = event.payload.agentType + return agentType && agentType !== 'unknown' + ? event + : { ...event, payload: { ...event.payload, agentType: owner.agent } } +} + /** PLAN rules 1-4, first match wins: proven guests (rule 2) need no liveness check. */ function classifyAgainstOwner( producer: HookProducer, @@ -104,8 +116,13 @@ function withOwnerSession( producer: HookProducer ): AgentProcessPresence { const session = producer.session - // Why: a different process of the owner's type is not the owner, so it never rotates its session. - if (!session || session === owner.session || otherProcess(producer.process, owner.process)) { + // Why: a different process of the owner's type, or a producer naming no agent, never rotates it. + if ( + !session || + !producer.agent || + session === owner.session || + otherProcess(producer.process, owner.process) + ) { return owner } const heldSessions = ownerSessions(owner) @@ -120,6 +137,7 @@ function carryOwnerFields( event: AgentHookEventPayload, previous: AgentHookEventPayload | undefined ): AgentHookEventPayload { + // Why: a row with no owner record still carries (older relays' envelopes send no agentPresence). if ( !previous || previous.providerSessionOnly || @@ -155,35 +173,32 @@ export function transitionHookPresence( // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved // exit); other agents' SessionEnd hooks are ordinary status updates. const exit = incoming.agentPresence?.ended === true - const verdict = - owner && previous ? classifyAgainstOwner(producer, owner, previous, rowUpdatedAt, now) : 'claim' - if (verdict === 'nested') { - return { kind: 'skip' } - } - if (owner && verdict === 'guest') { - return exit || !owner.process ? { kind: 'skip' } : { kind: 'skip', probe: owner.process } - } - if (owner && verdict === 'owner') { - if (exit) { + if (owner && previous) { + const verdict = classifyAgainstOwner(producer, owner, previous, rowUpdatedAt, now) + if (verdict === 'nested') { + return { kind: 'skip' } + } + if (verdict === 'guest') { + return exit || !owner.process ? { kind: 'skip' } : { kind: 'skip', probe: owner.process } + } + if (verdict === 'owner' && exit) { return sameProcess(owner.process, producer.process) ? { kind: 'write', - event: { - ...event, - payload: previous?.payload ?? event.payload, - agentPresence: { ...owner, ended: true } - } + event: { ...event, payload: previous.payload, agentPresence: { ...owner, ended: true } } } : { kind: 'skip' } } - const probe = otherProcess(producer.process, owner.process) ? owner.process : undefined - return { - kind: 'write', - event: { - ...carryOwnerFields(event, previous), - agentPresence: withOwnerSession(owner, producer) - }, - ...(probe ? { probe } : {}) + if (verdict === 'owner') { + const probe = otherProcess(producer.process, owner.process) ? owner.process : undefined + return { + kind: 'write', + event: { + ...carryOwnerFields(withOwnerAgentType(event, owner), previous), + agentPresence: withOwnerSession(owner, producer) + }, + ...(probe ? { probe } : {}) + } } } const ended = previous?.agentPresence?.ended ? previous.agentPresence : undefined @@ -214,38 +229,38 @@ export function adoptRelayedRow( incoming: AgentHookEventPayload, previous: AgentHookEventPayload | undefined ): AgentHookEventPayload { + const owner = incoming.agentPresence ?? currentOwner(previous) + const adopted = { ...incoming, agentPresence: owner } return carryOwnerFields( - incoming.agentPresence ? incoming : { ...incoming, agentPresence: currentOwner(previous) }, + owner && !owner.ended ? withOwnerAgentType(adopted, owner) : adopted, previous ) } -/** A terminal signal (OSC, title, process-derived) naming another agent than a held owner yields: - * terminals never claim, so it leaves the row unchanged and only casts doubt on the owner. */ -export function terminalSignalYieldsToOwner( +export type TerminalSignalVerdict = + | { kind: 'yield'; probe?: AgentProcessIdentity } + | { kind: 'write'; owner?: AgentProcessPresence } + +/** Terminal signals (OSC, title, process-derived) never claim. One naming another agent than a held + * owner yields: the row stays and the owner is doubted. Otherwise it writes under the owner when it + * names it (or no agent), and ownerless once the owner was released. */ +export function classifyTerminalSignal( previous: (AgentHookEventPayload & { receivedAt: number }) | undefined, agentType: string | undefined, + ownerCheckable: boolean, now = Date.now() -): boolean { - const owner = currentOwner(previous) - return Boolean( - owner && - previous && - agentType && - agentType !== 'unknown' && - agentType !== owner.agent && - !isOwnerReleased(owner, previous, previous.receivedAt, now) - ) -} - -/** Terminal signals write under the owner only when they name it (or no agent); one naming another - * agent after the owner was released leaves the pane ownerless. */ -export function terminalSignalOwner( - previous: AgentHookEventPayload | undefined, - agentType: string | undefined -): AgentProcessPresence | undefined { +): TerminalSignalVerdict { const owner = currentOwner(previous) - return owner && (!agentType || agentType === 'unknown' || agentType === owner.agent) - ? owner - : undefined + if (!owner || !previous) { + return { kind: 'write' } + } + if (!agentType || agentType === 'unknown' || agentType === owner.agent) { + return { kind: 'write', owner } + } + if (isOwnerReleased(owner, previous, previous.receivedAt, now, ownerCheckable)) { + return { kind: 'write' } + } + return ownerCheckable && owner.process + ? { kind: 'yield', probe: owner.process } + : { kind: 'yield' } } diff --git a/src/shared/agent-pane-owner-probes.test.ts b/src/shared/agent-pane-owner-probes.test.ts index a3e73293ce95..0028f7bf1b4c 100644 --- a/src/shared/agent-pane-owner-probes.test.ts +++ b/src/shared/agent-pane-owner-probes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentProcessVerdict } from './agent-process-presence' +import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' import { HELD_GUEST_WINDOW_MS, OWNER_PROBE_COOLDOWN_MS, @@ -11,8 +11,13 @@ const HOLD = { kind: 'skip' as const, probe: OWNER } function setup(checkOwner: () => Promise) { let now = 1_000 - const probes = new PaneOwnerProbes({ checkOwner, now: () => now }) - return { probes, advance: (ms: number) => (now += ms) } + let owner: AgentProcessIdentity = OWNER + const probes = new PaneOwnerProbes({ ownerOf: () => owner, checkOwner, now: () => now }) + return { + probes, + advance: (ms: number) => (now += ms), + replaceOwner: (next: AgentProcessIdentity) => (owner = next) + } } describe('PaneOwnerProbes', () => { @@ -61,4 +66,16 @@ describe('PaneOwnerProbes', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(reapply).not.toHaveBeenCalled() }) + + it('never replays a guest held behind an owner that is gone onto a later owner', async () => { + const checkOwner = vi.fn(async (): Promise => 'exited') + const { probes, replaceOwner } = setup(checkOwner) + const reapply = vi.fn() + checkOwner.mockResolvedValueOnce('live') + probes.admit('pane', HOLD, { write: vi.fn(), reapply }) + await vi.waitFor(() => expect(checkOwner).toHaveBeenCalledOnce()) + replaceOwner({ ...OWNER, pid: 4002 }) + await probes.check('pane') + expect(reapply).not.toHaveBeenCalled() + }) }) diff --git a/src/shared/agent-pane-owner-probes.ts b/src/shared/agent-pane-owner-probes.ts index a84e454cd066..40fff6fc0fad 100644 --- a/src/shared/agent-pane-owner-probes.ts +++ b/src/shared/agent-pane-owner-probes.ts @@ -4,24 +4,27 @@ import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process- /** One probe per owner per window, however many guest events arrive. */ export const OWNER_PROBE_COOLDOWN_MS = 10_000 -/** How long a held guest event may still take the pane once a probe finds the owner gone. */ +/** How long a held guest event may still take the pane once its owner is found gone. */ export const HELD_GUEST_WINDOW_MS = 30_000 type OwnerProbe = { owner: string; startedAt: number } -type HeldGuest = { heldAt: number; reapply: () => void } +type HeldGuest = { owner: string; heldAt: number; reapply: () => void } function ownerKey(owner: AgentProcessIdentity): string { return `${owner.platform}:${owner.pid}:${owner.startTime}` } -/** Applies a host's presence transitions and checks doubted owners without making ingest wait. - * The host injects its own check, which resolves `exited` only once it released the pane. */ +/** A host's one owner-check surface: applies presence transitions, checks doubted owners without + * making ingest wait, and hands a released pane to the guest held behind that same owner. */ export class PaneOwnerProbes { private readonly probes = new Map() private readonly held = new Map() constructor( private readonly deps: { + /** The pane's current owner process, if the host can check it. */ + ownerOf: (paneKey: string) => AgentProcessIdentity | undefined + /** The host's own check; resolves `exited` only once it released the pane. */ checkOwner: (paneKey: string) => Promise now?: () => number } @@ -35,7 +38,8 @@ export class PaneOwnerProbes { if (transition.kind === 'skip') { if (transition.probe) { // Why: one slot per pane; the latest guest event is what a released pane should show. - this.held.set(paneKey, { heldAt: this.now(), reapply: host.reapply }) + const owner = ownerKey(transition.probe) + this.held.set(paneKey, { owner, heldAt: this.now(), reapply: host.reapply }) this.probe(paneKey, transition.probe) } return undefined @@ -48,6 +52,7 @@ export class PaneOwnerProbes { return written } + /** Rate-limited check of an owner another signal cast doubt on. */ probe(paneKey: string, owner: AgentProcessIdentity): void { const now = this.now() this.sweep(now) @@ -56,16 +61,22 @@ export class PaneOwnerProbes { return } this.probes.set(paneKey, { owner: key, startedAt: now }) - void this.deps.checkOwner(paneKey).then((verdict) => { + void this.check(paneKey) + } + + /** Checks the pane's owner now; every host caller that can release a pane goes through here. */ + check(paneKey: string): Promise { + const owner = this.deps.ownerOf(paneKey) + return this.deps.checkOwner(paneKey).then((verdict) => { const held = this.held.get(paneKey) - if (verdict !== 'exited' || !held) { - return - } - this.held.delete(paneKey) - // Why: replay re-classifies against the released row, so it only lands if it may claim it. - if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) { - held.reapply() + if (verdict === 'exited' && owner && held?.owner === ownerKey(owner)) { + this.held.delete(paneKey) + // Why: replay re-classifies against the released row, so it only lands if it may claim it. + if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) { + held.reapply() + } } + return verdict }) } From f634863baa91120298b065a4176b6f2b71acc65f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 00:23:03 -0400 Subject: [PATCH 6/6] fix(ssh): forward the relay's pane owner to the desktop The SSH notification adapter copied a fixed field list into ingestRemote and left out agentPresence, so a relayed exit was never adopted over SSH and saved SSH rows had no owner or session. WSL already passed it through. --- .../server-pane-owner-relayed.test.ts | 42 +++++++++++++++++ ...ay-session-agent-hooks.integration.test.ts | 45 +++++++++++++++++++ src/main/ssh/ssh-relay-session.ts | 2 + 3 files changed, 89 insertions(+) diff --git a/src/main/agent-hooks/server-pane-owner-relayed.test.ts b/src/main/agent-hooks/server-pane-owner-relayed.test.ts index 72fa22400779..9cd413ef00d6 100644 --- a/src/main/agent-hooks/server-pane-owner-relayed.test.ts +++ b/src/main/agent-hooks/server-pane-owner-relayed.test.ts @@ -238,4 +238,46 @@ describe('pane owner rule (relayed panes)', () => { vi.useRealTimers() } }) + + it("persists a relayed row's owner and session, and restores it", async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-pane-owner-persist-')) + paths.push(userDataPath) + const owner = { + agent: 'claude', + process: { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' }, + session: 'claude-a' + } + const first = new AgentHookServer() + running.push(first) + await first.start({ env: 'production', userDataPath }) + first.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: owner, + providerSession: { key: 'session_id', id: 'claude-a' }, + payload: { state: 'working', prompt: 'claude task', agentType: 'claude' } + }, + 'ssh-1' + ) + first.flushStatusPersistSync() + first.stop() + const restored = new AgentHookServer() + running.push(restored) + await restored.start({ env: 'production', userDataPath }) + // Why: a relayed restatement with no owner keeps the restored one, so this reads it back. + const pushed: AgentHookEventPayload[] = [] + restored.setListener((event) => pushed.push(event)) + restored.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'claude task', agentType: 'claude' } + }, + 'ssh-1' + ) + expect(pushed.at(-1)?.agentPresence).toEqual(owner) + }) }) 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 19dac11ffd1b..7afba3e2eefc 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 @@ -306,6 +306,51 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it("adopts the relay's pane owner, including its exit", async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + session = createSession('conn-owner') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Mocked deployment never reads the connection. + await session.establish({} as SshConnection) + const owner = { + agent: 'claude', + process: { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' }, + session: 'claude-a' + } + const base = { + source: 'claude' as const, + agentPresence: owner, + providerSession: { key: 'session_id' as const, id: 'claude-a' } + } + relay.notifyAgentHook( + makeEnvelope({ + ...base, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } + }) + ) + await waitForStatusCount(events, 1) + const paneKey = `tab-ssh:${SSH_LEAF_ID}` + const live = () => agentHookServer.getStatusSnapshot().find((row) => row.paneKey === paneKey) + expect(live()).toMatchObject({ state: 'working', agentType: 'claude' }) + relay.notifyAgentHook( + makeEnvelope({ + ...base, + hookEventName: 'AgentProcessExit', + providerSessionOnly: true, + agentPresence: { ...owner, ended: true }, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } + }) + ) + await vi.waitFor(() => expect(live()?.providerSessionOnly).toBe(true)) + expect(live()?.providerSession).toEqual({ key: 'session_id', id: 'claude-a' }) + }) + it('preserves tmux evidence age and unavailable across the real notification adapter', 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 e7f7c60a15c0..0b3304093ae1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1772,6 +1772,8 @@ export class SshRelaySession { agentHookServer.ingestRemote( { paneKey: envelope.paneKey, + // Why: the relay decides the pane's owner; ingestRemote validates and adopts it. + agentPresence: envelope.agentPresence, hostTurnRevision: envelope.hostTurnRevision, launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined,