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.
This commit is contained in:
Brennan Benson
2026-08-05 20:11:20 -07:00
parent 79896cb9a6
commit 1982f19eee
10 changed files with 160 additions and 4 deletions
+1
View File
@@ -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 } : {}),
+3
View File
@@ -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',
+3
View File
@@ -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
+2
View File
@@ -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 } : {}),
@@ -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')
@@ -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
+2 -1
View File
@@ -3286,7 +3286,8 @@ export function useIpcEvents(): void {
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: statusWorktreeId,
payload: notificationPayload
payload: notificationPayload,
...(data.hookEventName ? { hookEventName: data.hookEventName } : {})
})
}
return 'applied'
+76
View File
@@ -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,
+24 -2
View File
@@ -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'
+4
View File
@@ -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. */