From 1982f19eeeac16f551eda54197d2e7779ac55f03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:11:20 -0700 Subject: [PATCH] fix(agent-hooks): give resumed Claude sessions a sidebar row at SessionStart (STA-3386) Claude's hook set never registered SessionStart and normalizeClaudeEvent dropped it at ingest, so a resumed session that idled produced zero hook traffic and earned no sidebar agent row until the first prompt. - Register SessionStart in CLAUDE_EVENTS (local + remote installs). - Map lead SessionStart (startup/resume/clear) to an idle 'done' row, resetting stale roster/task/cron/tool/prompt state like the Codex path; compact restarts and child-attributed SessionStart stay dropped. - Thread hookEventName through the agent-status IPC payload so the completion coordinator can tell a session connect from a turn result; a SessionStart 'done' no longer raises agent-task-complete. --- src/main/agent-hooks/server.ts | 1 + src/main/claude/hook-service.test.ts | 3 + src/main/claude/hook-settings.ts | 3 + src/main/index.ts | 2 + ...gent-hook-completion-notifications.test.ts | 37 +++++++++ .../agent-hook-completion-notifications.ts | 9 ++- src/renderer/src/hooks/useIpcEvents.ts | 3 +- src/shared/agent-hook-listener.test.ts | 76 +++++++++++++++++++ src/shared/agent-hook-listener.ts | 26 ++++++- src/shared/agent-status-types.ts | 4 + 10 files changed, 160 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d..9d2f8d01e59 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -394,6 +394,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, + ...(entry.hookEventName ? { hookEventName: entry.hookEventName } : {}), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index cd31d5577f5..0d390c3a9e3 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -378,6 +378,9 @@ describe('ClaudeHookService.installRemote', () => { // Code rejects unknown shapes silently and the agent-hooks pipeline // goes dark. for (const event of [ + // Why: SessionStart is the only signal a resumed/idle session ever emits; + // without it the sidebar row waits for the first prompt (STA-3386). + 'SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index b13c98ed6a9..de932276545 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -28,6 +28,9 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { } export const CLAUDE_EVENTS = [ + // Why: SessionStart is the only event a resumed/idle session emits before the + // first prompt; without it the sidebar row can't exist until the user types (STA-3386). + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: OpenClaude skips normal Stop hooks after API/model errors and emits diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94..8a04bbdf2b0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1426,6 +1426,7 @@ function openMainWindow(): BrowserWindow { providerSessionOnly, promptInteractionKey, restoredUnconfirmed, + hookEventName, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1460,6 +1461,7 @@ function openMainWindow(): BrowserWindow { connectionId, receivedAt, stateStartedAt, + ...(hookEventName ? { hookEventName } : {}), ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 228af0058eb..68b216bb427 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -421,6 +421,43 @@ describe('agent hook completion notifications', () => { ) }) + it('does not fire a completion notification for a SessionStart idle row', async () => { + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + // Why: Claude SessionStart lands as 'done' so a resumed session gets its sidebar + // row while idle (STA-3386) — connecting to a session is not completing a turn. + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + hookEventName: 'SessionStart', + payload: { + state: 'done', + prompt: '', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + } + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // Why: the resumed session's next real turn must still notify normally. + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { ...hookStatus('working'), agentType: 'claude' } + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { ...hookStatus('done'), agentType: 'claude', stateStartedAt: 1_700_000_020_000 } + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + it('does not notify twice when the same done hook snapshot replays after activation', async () => { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 125c0994514..fdd757e0deb 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -272,12 +272,19 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion export function observeAgentHookCompletionForNotification({ paneKey, worktreeId, - payload + payload, + hookEventName }: { paneKey: string worktreeId: string payload: AgentCompletionStatusSnapshot + hookEventName?: string }): void { + if (hookEventName === 'SessionStart' && payload.state === 'done') { + // Why: a SessionStart that lands 'done' is an idle session connecting (resume/launch, + // STA-3386) — not a completed turn, so it must not raise agent-task-complete. + return + } pruneClosedPaneCoordinators() if (!paneCanReceiveHookCompletion(paneKey)) { return diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index bed3e4e86c8..9932dc34863 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3286,7 +3286,8 @@ export function useIpcEvents(): void { observeAgentHookCompletionForNotification({ paneKey, worktreeId: statusWorktreeId, - payload: notificationPayload + payload: notificationPayload, + ...(data.hookEventName ? { hookEventName: data.hookEventName } : {}) }) } return 'applied' diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 73c1aae3a6d..7ddd2725017 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -1369,6 +1369,82 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.lastAssistantMessage).toBeUndefined() }) + it('maps Claude SessionStart to an idle done row so a resumed session earns its sidebar row before the first prompt', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: '44444444-4444-4444-8444-444444444444' + } + }, + 'production' + ) + + // Why: 'working' would show a phantom spinner on an idle TUI (Devin precedent); + // 'done' renders the row idle, which is the truth at SessionStart. + expect(event?.payload).toMatchObject({ state: 'done', prompt: '', agentType: 'claude' }) + expect(event?.payload.interrupted).toBeUndefined() + expect(event?.hookEventName).toBe('SessionStart') + // Why: SessionStart carries resume identity, so in-app resume works before any prompt. + expect(event?.providerSession).toMatchObject({ + key: 'session_id', + id: '44444444-4444-4444-8444-444444444444' + }) + }) + + it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => { + normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' }) + normalizeAndAccept(state, 'claude', { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' } + }) + normalizeAndAccept(state, 'claude', { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }) + + const event = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'startup' + }) + + // Why: a new process owns the pane; stale prompt/tool/children must not survive + // into the fresh session's idle row or gate it back up to 'working'. + expect(event?.payload.state).toBe('done') + expect(event?.payload.prompt).toBe('') + expect(event?.payload.toolName).toBeUndefined() + expect(event?.payload.subagents).toBeUndefined() + }) + + it('keeps the running Claude turn when SessionStart comes from a compact restart or a child session', () => { + normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'say hi' }) + + const compacted = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart', source: 'compact' } }, + 'production' + ) + const child = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', source: 'startup', agent_id: 'agent-7' } + }, + 'production' + ) + const stopped = normalizeAndAccept(state, 'claude', { hook_event_name: 'Stop' }) + + // Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle) and a + // child-attributed SessionStart must not flip the lead's live turn to an idle row. + expect(compacted).toBeNull() + expect(child).toBeNull() + expect(stopped?.payload).toMatchObject({ state: 'done', prompt: 'say hi' }) + }) + it('normalizes Devin documented lifecycle events', () => { const started = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 985f95b1876..6a76225f6dc 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2405,9 +2405,11 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. switch (source) { case 'claude': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. - // falls through + // Why: SessionStart lands an idle row (STA-3386) and must also drop stale + // tool/prompt caches left by the pane's previous session. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'kimi': + // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -2739,6 +2741,26 @@ function normalizeClaudeEvent( ) { return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } + if (eventName === 'SessionStart') { + // Why: SessionStart fires while the TUI is idle (startup/resume/clear), so land the row + // 'done' — 'working' would show a phantom spinner before any prompt (Devin precedent). + // This is the only signal a resumed session emits before its first prompt (STA-3386). + if (eventAgentId !== undefined || hookPayload['source'] === 'compact') { + // Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle), and a + // child-attributed SessionStart must not flip the lead's live turn to an idle row. + return null + } + // Why: a new process owns the pane; stale children/tasks/crons must not gate the + // fresh session's idle row back up to 'working' (mirrors the Codex SessionStart reset). + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: 'done', + updateToolSnapshot: true + }) + } const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0d902ecb0fc..7436109df56 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -219,6 +219,10 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Identifies the SSH connection the event arrived on, or null for local. * Only the remote-ingest path (`ingestRemote`) can stamp it; the HTTP path always sets null. See docs/design/agent-status-over-ssh.md §5. */ connectionId: string | null + /** Hook event that produced this status, when it came from a native hook. + * Why: lets receivers tell a session boundary from a turn result — a SessionStart + * 'done' is an idle connect (STA-3386), not a completion to notify about. */ + hookEventName?: string /** Timestamp (ms) when the hook server received this latest status event. */ receivedAt: number /** Timestamp (ms) when the current state first appeared for this pane. */