From 01727afae05484fd5ec9609466d806e92f5a1be4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:07:06 -0700 Subject: [PATCH] fix(agent-status): stop start-less SubagentStop from minting phantom working Claude compact emits PreCompact -> SubagentStop (never started) -> SessionStart(compact) -> PostCompact. On a freshly resumed pane with no cached lead state, the child-driven default minted a sticky empty-prompt 'working' that no Stop ever clears (STA-2915). Emit child-driven status only on positive evidence: a lead record, a live working child, or an actual roster change. --- src/shared/agent-hook-listener.test.ts | 36 ++++++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 21 +++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 098ceeff9a2..23408957a61 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3096,6 +3096,42 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.subagents).toBeUndefined() }) + it('does not mint working from a start-less SubagentStop on a pane with no lead record', () => { + // Why: the live /compact flow on claude 2.1.186–2.1.220 is PreCompact → SubagentStop + // (never started) → SessionStart(compact) → PostCompact. On a resumed session in a + // fresh process this minted a sticky empty-prompt 'working' no Stop ever clears (STA-2915). + const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'compact-1' }) + expect(stopped).toBeNull() + }) + + it('re-emits the lead done state on a start-less SubagentStop after a completed turn', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) + const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'compact-1' }) + expect(stopped?.payload.state).toBe('done') + }) + + it('still emits working for a spawn on a pane with no lead record', () => { + const spawned = claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + expect(spawned?.payload.state).toBe('working') + expect(spawned?.payload.subagents).toEqual([expect.objectContaining({ id: 'a1' })]) + }) + + it('keeps child-driven working when a live child survives an unknown stop with no lead record', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'ghost' }) + expect(stopped?.payload.state).toBe('working') + expect(stopped?.payload.subagents).toEqual([expect.objectContaining({ id: 'a1' })]) + }) + it('keeps gating on tracked children when background_tasks is absent (older Claude)', () => { claudeEvent({ hook_event_name: 'SubagentStart', diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e64b1941e4e..a675b496d7a 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2433,10 +2433,11 @@ function normalizeClaudeSubagentLifecycleEvent( return null } const roster = getOrCreateClaudeSubagentRoster(state, paneKey) + let rosterChanged = false if (eventName === 'TeammateIdle') { const teammateName = lifecycleId // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. - idleClaudeTeammateByName(roster, teammateName) + rosterChanged = idleClaudeTeammateByName(roster, teammateName) clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => claudeTeammateIdMatchesName(waitingAgentId, teammateName) ) @@ -2449,14 +2450,17 @@ function normalizeClaudeSubagentLifecycleEvent( { agentType: readString(hookPayload, 'agent_type') }, Date.now() ) + rosterChanged = true } else { + // Why: a stop of an untracked id (e.g. compact's start-less SubagentStop) is a roster no-op, not evidence. + rosterChanged = roster.has(agentId) // Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it. stopClaudeSubagent(roster, agentId) // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload, rosterChanged) } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ @@ -2553,12 +2557,19 @@ function buildClaudeChildDrivenStatusPayload( state: HookListenerState, eventName: unknown, paneKey: string, - hookPayload: Record + hookPayload: Record, + rosterChanged = false ): ParsedAgentStatusPayload | null { - // Why: default 'working' — a spawn proves activity even before the lead's first state-bearing event (e.g. Orca restarted mid-session). const lead = state.claudeLeadStateByPaneKey.get(paneKey) - const leadState = lead?.state ?? 'working' const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + // Why: no lead record, no live child, and an unchanged roster mean the event proves nothing is + // running. Compact's start-less SubagentStop is exactly that; minting the default below left a + // sticky 'working' no Stop ever clears on freshly resumed panes (STA-2915). + if (!lead && !rosterChanged && !claudeRosterHasWorkingSubagent(roster)) { + return null + } + // Why: default 'working' — a spawn proves activity even before the lead's first state-bearing event (e.g. Orca restarted mid-session). + const leadState = lead?.state ?? 'working' return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { stateName: leadState === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : leadState,