From 9ee359b8701b1dd6b700eeaa8978e66d4893dd34 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:20:05 +0000 Subject: [PATCH 1/3] fix(agent-status): keep claude tmux teammate hooks off the lead's row A tmux teammate is a separate claude process that inherits the lead pane's ORCA_PANE_KEY, so its tool hooks replaced the lead's waiting state. Fixes #30833 --- .../agent-hooks/server-agent-presence.test.ts | 8 +- ...er-claude-owed-notification-expiry.test.ts | 4 + .../server-claude-tmux-teammate.test.ts | 99 +++++++++++++++++++ .../server/server-agent-presence.ts | 27 ++--- .../agent-hooks/server/server-lifecycle.ts | 4 +- src/shared/agent-hook-listener.ts | 10 +- src/shared/agent-hook-owner-presence.ts | 8 +- src/shared/agent-process-presence.ts | 12 ++- src/shared/agent-status-identity.ts | 21 +++- 9 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 src/main/agent-hooks/server-claude-tmux-teammate.test.ts diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 0d159fad3a50..070d6853464b 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -366,8 +366,8 @@ describe('host-owned hook presence', () => { expect(probe).not.toHaveBeenCalled() expect(visible(server)).toBe(true) await hook(server, 'SessionStart', 'relaunch', undefined, 4002) - await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) - await vi.waitFor(() => expect(visible(server)).toBe(false)) + expect(probe).toHaveBeenCalledOnce() + expect(state(server)).toBe('done') await hook(server, 'UserPromptSubmit', 'relaunch', undefined, 4002) expect(state(server)).toBe('working') }) @@ -505,10 +505,12 @@ describe('retained agent exit', () => { finish = resolve }) ) - await hook(server, 'SessionStart', 'replacement', undefined, 4002) + const replacement = hook(server, 'SessionStart', 'replacement', undefined, 4002) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) const overlapping = server.checkAgentPresenceSince(PANE, attachedAt) finish('exited') expect(await overlapping).not.toBe('exited') + await replacement }) it('keeps the exit through a delayed hook from the ended session without a process identity', async () => { diff --git a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts index ce86479d2b5f..ec6123562249 100644 --- a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts +++ b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts @@ -9,6 +9,10 @@ vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) })) +// Why: the fixture owner pid is not a real process; the pane's owner is alive in every case here. +vi.mock('../../shared/agent-process-presence-probe', () => ({ + probeAgentProcessPresence: vi.fn(async () => 'live') +})) beforeEach(() => { _internals.resetCachesForTests() diff --git a/src/main/agent-hooks/server-claude-tmux-teammate.test.ts b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts new file mode 100644 index 000000000000..62db698a36f1 --- /dev/null +++ b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import type { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) +const probe = vi.hoisted(() => vi.fn(async () => 'live')) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + probe.mockReset().mockResolvedValue('live') +}) + +const LEAD = { pid: 4001, session: 'lead-session' } +const TEAMMATE = { pid: 4002, session: 'teammate-session' } + +async function post( + server: AgentHookServer, + sender: typeof LEAD, + payload: Record +): Promise { + const response = await postHookEvent( + server, + buildBody( + { session_id: sender.session, ...payload }, + { + agentProcess: JSON.stringify({ + pid: sender.pid, + platform: 'linux', + startTime: `birth-${sender.pid}` + }) + } + ) + ) + expect(response.status).toBe(204) +} + +function paneState(server: AgentHookServer): string | undefined { + return server.getStatusSnapshot().find((row) => row.paneKey === PANE)?.state +} + +async function leadAsksQuestion(server: AgentHookServer): Promise { + await post(server, LEAD, { hook_event_name: 'SessionStart', source: 'startup' }) + await post(server, LEAD, { hook_event_name: 'UserPromptSubmit', prompt: 'Spawn a teammate' }) + const ask = { tool_name: 'AskUserQuestion', tool_use_id: 'toolu_ask' } + await post(server, LEAD, { hook_event_name: 'PreToolUse', ...ask }) + await post(server, LEAD, { hook_event_name: 'PermissionRequest', tool_name: 'AskUserQuestion' }) +} + +// #30833: a tmux teammate runs in Claude's own tmux server, inheriting the lead pane's ORCA_PANE_KEY. +describe('Claude tmux teammate hooks on the lead pane', () => { + it('keeps the lead waiting on its question while the teammate process runs tools', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + await leadAsksQuestion(server) + expect(paneState(server)).toBe('waiting') + + const teammate = { agent_type: 'general-purpose' } + await post(server, TEAMMATE, { + hook_event_name: 'SessionStart', + source: 'startup', + ...teammate + }) + for (const id of ['toolu_1', 'toolu_2']) { + const bash = { ...teammate, tool_name: 'Bash', tool_use_id: id } + await post(server, TEAMMATE, { hook_event_name: 'PreToolUse', ...bash }) + expect(paneState(server)).toBe('waiting') + await post(server, TEAMMATE, { hook_event_name: 'PostToolUse', ...bash }) + expect(paneState(server)).toBe('waiting') + } + await post(server, TEAMMATE, { hook_event_name: 'Stop', ...teammate }) + await post(server, TEAMMATE, { hook_event_name: 'TeammateIdle', teammate_name: 'worker' }) + expect(paneState(server)).toBe('waiting') + + await post(server, LEAD, { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'toolu_ask' + }) + expect(paneState(server)).toBe('working') + }) + + it('lets a relaunched Claude take the pane once the previous process has exited', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + await leadAsksQuestion(server) + probe.mockResolvedValue('exited') + + await post(server, TEAMMATE, { hook_event_name: 'UserPromptSubmit', prompt: 'Next task' }) + expect(paneState(server)).toBe('working') + }) +}) diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index 175773066d6b..3b83a1ac701c 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -23,20 +23,25 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy const sender = event.agentPresence?.process const owner = row.agentPresence if (sender && owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { - const paneKey = this.resolvePaneKeyAlias(row.paneKey) - const challenges = this.ownerChallengesByLiveSender - challenges.set(paneKey, (challenges.get(paneKey) ?? 0) + 1) - void this.checkAgentPresence(paneKey).finally(() => { - const remaining = (challenges.get(paneKey) ?? 1) - 1 - if (remaining > 0) { - challenges.set(paneKey, remaining) - } else { - challenges.delete(paneKey) - } - }) + void this.challengeAgentOwner(row.paneKey) } } + /** Probes the owner because another live process reports from its pane. */ + challengeAgentOwner(paneKey: string): Promise { + const resolved = this.resolvePaneKeyAlias(paneKey) + const challenges = this.ownerChallengesByLiveSender + challenges.set(resolved, (challenges.get(resolved) ?? 0) + 1) + return this.checkAgentPresence(resolved).finally(() => { + const remaining = (challenges.get(resolved) ?? 1) - 1 + if (remaining > 0) { + challenges.set(resolved, remaining) + } else { + challenges.delete(resolved) + } + }) + } + // Why: a live process already reports from these panes, so their owner's exit is not the pane's. private readonly ownerChallengesByLiveSender = new Map() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 3391b6548dc0..e93ad0427085 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -1,6 +1,7 @@ import { AgentHookStartup, listenForAgentHooks } from '../../../shared/agent-hook-startup' import { checkHookOwnerPresence } from '../../../shared/agent-hook-owner-presence' import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' @@ -27,6 +28,7 @@ type HookServerStartOptions = { export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { private readonly startup = new AgentHookStartup() + abstract challengeAgentOwner(paneKey: string): Promise /** Start the loopback listener after hydration and spool replay have settled. */ start(options?: HookServerStartOptions): Promise { @@ -138,7 +140,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook return } await checkHookOwnerPresence(this.state, source, aliasedBody, this.env, (paneKey) => - this.checkAgentPresence(paneKey) + this.challengeAgentOwner(paneKey) ) if (!isStatusCurrent()) { res.writeHead(204) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index a067e24e4b11..695097cf3b10 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -153,6 +153,8 @@ export function normalizeHookPayload( ) { return null } + // Why: presence needs the agent's own process; without it the hook cannot speak for liveness. + const sender = source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined const previousStatus = state.lastStatusByPaneKey.get(paneKey) const previousReceivedAt = previousStatus && @@ -177,6 +179,7 @@ export function normalizeHookPayload( agentPresence: previousStatus.providerSessionOnly ? undefined : previousStatus.agentPresence }, incoming: source, + incomingProcess: sender, now: Date.now() }).inheritedFromActivePane ) { @@ -222,11 +225,8 @@ export function normalizeHookPayload( } } - // Why: presence needs the agent's own process; without it the hook cannot speak for liveness. - const agentProcess = - source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined - const agentPresence = agentProcess - ? { agent: source, process: agentProcess, sessionOwner: true as const } + const agentPresence = sender + ? { agent: source, process: sender, sessionOwner: true as const } : undefined const sessionEndReason = readString(hookPayloadRecord, 'reason') if ( diff --git a/src/shared/agent-hook-owner-presence.ts b/src/shared/agent-hook-owner-presence.ts index 64c7aaebf9b4..816a42cafb8c 100644 --- a/src/shared/agent-hook-owner-presence.ts +++ b/src/shared/agent-hook-owner-presence.ts @@ -1,7 +1,7 @@ import { parseHookEnvelope } from './agent-hook-listener/hook-envelope' import type { HookListenerState } from './agent-hook-listener/listener-state' import type { AgentHookSource } from './agent-hook-relay' -import { hasOtherAgentProcessOwner } from './agent-process-presence' +import { hasOtherAgentProcessOwner, readAgentProcessIdentity } from './agent-process-presence' import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner' /** Confirm an old owner's exit on its execution host before accepting a replacement. */ @@ -26,7 +26,11 @@ export function checkHookOwnerPresence( { connectionId: previous.connectionId ?? null, worktreeId: previous.worktreeId }, { connectionId: null, worktreeId: envelope.worktreeId } ) && - hasOtherAgentProcessOwner(presence && { ...presence, suspended: undefined }, source) + hasOtherAgentProcessOwner( + presence && { ...presence, suspended: undefined }, + source, + readAgentProcessIdentity(envelope.record.agentProcess) + ) ) { return check(envelope.paneKey) } diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index d9e522a17a0f..51ebcea84b29 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -12,7 +12,7 @@ export type AgentProcessPresence = { agent: AgentType process?: AgentProcessIdentity ended?: true - /** This host verifies process ownership before accepting a different provider. */ + /** This host verifies process ownership before accepting another agent process. */ sessionOwner?: true /** Positive host observation of suspension, distinct from an unavailable process read. */ suspended?: true @@ -77,16 +77,20 @@ export function isSameAgentProcess(a: AgentProcessIdentity, b: AgentProcessIdent return a.pid === b.pid && a.platform === b.platform && a.startTime === b.startTime } +/** Another live agent owns the pane: a different provider, or the same one in another proven process. */ export function hasOtherAgentProcessOwner( presence: AgentProcessPresence | undefined, - incoming: string + incoming: string, + incomingProcess?: AgentProcessIdentity ): boolean { return Boolean( presence?.process && presence.sessionOwner && !presence.ended && !presence.suspended && - presence.agent !== incoming && - presence.agent !== 'unknown' + presence.agent !== 'unknown' && + // Why: a Claude tmux teammate is a separate claude process that inherits the lead's ORCA_PANE_KEY. + (presence.agent !== incoming || + (incomingProcess && !isSameAgentProcess(presence.process, incomingProcess))) ) } diff --git a/src/shared/agent-status-identity.ts b/src/shared/agent-status-identity.ts index b9ca92a89592..5e86c0d882dd 100644 --- a/src/shared/agent-status-identity.ts +++ b/src/shared/agent-status-identity.ts @@ -4,7 +4,11 @@ import { type AgentStatusState, type AgentType } from './agent-status-types' -import { hasOtherAgentProcessOwner, type AgentProcessPresence } from './agent-process-presence' +import { + hasOtherAgentProcessOwner, + type AgentProcessIdentity, + type AgentProcessPresence +} from './agent-process-presence' import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' import type { TuiAgent } from './tui-agent' @@ -46,6 +50,7 @@ function isActiveExistingIdentity( export function resolveAgentStatusIdentity(args: { existing?: ExistingAgentIdentity incoming?: AgentType + incomingProcess?: AgentProcessIdentity now: number staleAfterMs?: number }): AgentIdentityResolution { @@ -59,12 +64,24 @@ export function resolveAgentStatusIdentity(args: { inheritedFromActivePane: false } } - if (!args.existing || !existingAgentType || existingAgentType === incomingAgentType) { + if (!args.existing || !existingAgentType) { return { agentType: incomingAgentType, inheritedFromActivePane: false } } + if (existingAgentType === incomingAgentType) { + return { + agentType: incomingAgentType, + inheritedFromActivePane: + args.incomingProcess !== undefined && + hasOtherAgentProcessOwner( + args.existing.agentPresence, + incomingAgentType, + args.incomingProcess + ) + } + } const canonical = resolveCanonicalPaneAgentIdentity({ hookAgent: incomingAgentType as TuiAgent, hookIsLive: true, From fbf2c9d3336f81df24ad3e2e4b510397cf8c7669 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:32:50 +0000 Subject: [PATCH 2/3] fix(agent-status): keep the owner exit until a successor is admitted A teammate hook after the lead died forgot the exit before anything took the pane. --- .../server-claude-tmux-teammate.test.ts | 12 +++++++++ .../server/server-agent-presence.ts | 27 ++++++++----------- .../agent-hooks/server/server-lifecycle.ts | 4 +-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/main/agent-hooks/server-claude-tmux-teammate.test.ts b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts index 62db698a36f1..ed0a52971f02 100644 --- a/src/main/agent-hooks/server-claude-tmux-teammate.test.ts +++ b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts @@ -96,4 +96,16 @@ describe('Claude tmux teammate hooks on the lead pane', () => { await post(server, TEAMMATE, { hook_event_name: 'UserPromptSubmit', prompt: 'Next task' }) expect(paneState(server)).toBe('working') }) + + it('keeps the dead lead exit when only its teammate ends afterwards', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const attachedAt = Date.now() - 1 + await leadAsksQuestion(server) + probe.mockResolvedValue('exited') + + await post(server, TEAMMATE, { hook_event_name: 'SessionEnd', reason: 'other' }) + expect(await server.checkAgentPresenceSince(PANE, attachedAt)).toBe('exited') + }) }) diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index 3b83a1ac701c..175773066d6b 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -23,25 +23,20 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy const sender = event.agentPresence?.process const owner = row.agentPresence if (sender && owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { - void this.challengeAgentOwner(row.paneKey) + const paneKey = this.resolvePaneKeyAlias(row.paneKey) + const challenges = this.ownerChallengesByLiveSender + challenges.set(paneKey, (challenges.get(paneKey) ?? 0) + 1) + void this.checkAgentPresence(paneKey).finally(() => { + const remaining = (challenges.get(paneKey) ?? 1) - 1 + if (remaining > 0) { + challenges.set(paneKey, remaining) + } else { + challenges.delete(paneKey) + } + }) } } - /** Probes the owner because another live process reports from its pane. */ - challengeAgentOwner(paneKey: string): Promise { - const resolved = this.resolvePaneKeyAlias(paneKey) - const challenges = this.ownerChallengesByLiveSender - challenges.set(resolved, (challenges.get(resolved) ?? 0) + 1) - return this.checkAgentPresence(resolved).finally(() => { - const remaining = (challenges.get(resolved) ?? 1) - 1 - if (remaining > 0) { - challenges.set(resolved, remaining) - } else { - challenges.delete(resolved) - } - }) - } - // Why: a live process already reports from these panes, so their owner's exit is not the pane's. private readonly ownerChallengesByLiveSender = new Map() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e93ad0427085..3391b6548dc0 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -1,7 +1,6 @@ import { AgentHookStartup, listenForAgentHooks } from '../../../shared/agent-hook-startup' import { checkHookOwnerPresence } from '../../../shared/agent-hook-owner-presence' import { parsePaneKey } from '../../../shared/stable-pane-id' -import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' @@ -28,7 +27,6 @@ type HookServerStartOptions = { export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { private readonly startup = new AgentHookStartup() - abstract challengeAgentOwner(paneKey: string): Promise /** Start the loopback listener after hydration and spool replay have settled. */ start(options?: HookServerStartOptions): Promise { @@ -140,7 +138,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook return } await checkHookOwnerPresence(this.state, source, aliasedBody, this.env, (paneKey) => - this.challengeAgentOwner(paneKey) + this.checkAgentPresence(paneKey) ) if (!isStatusCurrent()) { res.writeHead(204) From d025508cf985ea1099b00df1d614c8f1a6d6daed Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:44:47 +0000 Subject: [PATCH 3/3] test(agent-status): drop relaunch case covered by the presence suite --- .../agent-hooks/server-claude-tmux-teammate.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/main/agent-hooks/server-claude-tmux-teammate.test.ts b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts index ed0a52971f02..404de7832c62 100644 --- a/src/main/agent-hooks/server-claude-tmux-teammate.test.ts +++ b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts @@ -86,17 +86,6 @@ describe('Claude tmux teammate hooks on the lead pane', () => { expect(paneState(server)).toBe('working') }) - it('lets a relaunched Claude take the pane once the previous process has exited', async () => { - const server = new AgentHookServer() - servers.push(server) - await server.start({ env: 'production' }) - await leadAsksQuestion(server) - probe.mockResolvedValue('exited') - - await post(server, TEAMMATE, { hook_event_name: 'UserPromptSubmit', prompt: 'Next task' }) - expect(paneState(server)).toBe('working') - }) - it('keeps the dead lead exit when only its teammate ends afterwards', async () => { const server = new AgentHookServer() servers.push(server)