From 7230066e88351fca34692cc6727c5ca9cb847974 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 24 Apr 2026 16:33:43 -0500 Subject: [PATCH] Ignore subagent hook events fired after a turn's Stop (#199) Claude Code 2.1.108's awaySummaryEnabled "session recap" generates a SubagentStop hook event ~minutes after the user's turn has ended. The old TaskCreated/TaskCompleted/SubagentStop arm unconditionally drove claudeState back to .working, trapping the sidebar dot until session end. Track lastTopLevelStopAt on SessionHookState; when set, suppress state elevation in the SubagentStart and Subagent/TaskCreated/TaskCompleted arms. Cleared on UserPromptSubmit (next real turn), SessionStart, and SessionEnd. Also adds a CROW_HOOK_DEBUG=1 env-gated [hook-event] NSLog stream documenting event arrival + ClaudeState transitions, used to confirm the fix and kept in for future hook-lifecycle bugs (Claude Code's hook schema changes often). Documented in docs/troubleshooting.md alongside the user-side workaround for older builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 6 ++++ Sources/Crow/App/AppDelegate.swift | 34 +++++++++++++++++-- docs/troubleshooting.md | 2 ++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index eb513c5..af2754f 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -353,6 +353,12 @@ public final class SessionHookState { public var lastToolActivity: ToolActivity? public var hookEvents: [HookEvent] = [] public var analytics: SessionAnalytics? + /// Timestamp of the most recent top-level Stop / StopFailure for this session. + /// Used to suppress state elevation from background activity (e.g. the + /// `awaySummaryEnabled` recap subagent in Claude Code ≥ 2.1.108) that + /// fires after the user's turn has ended. Cleared on the next + /// UserPromptSubmit, which marks the start of a new real turn. + public var lastTopLevelStopAt: Date? public init() {} } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 60b52fd..8231632 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -468,6 +468,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let capturedNotifManager = notificationManager let capturedService = sessionService let capturedTelemetryPort = sessionService.telemetryPort + let hookDebug = ProcessInfo.processInfo.environment["CROW_HOOK_DEBUG"] == "1" let router = CommandRouter(handlers: [ "new-session": { @Sendable params in @@ -809,6 +810,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let payload = params["payload"]?.objectValue ?? [:] + if hookDebug { + let shortID = String(sessionIDStr.prefix(8)) + let keys = payload.keys.sorted().joined(separator: ",") + NSLog("[hook-event] session=\(shortID) event=\(eventName) payload-keys=[\(keys)]") + } + // Build a human-readable summary from the event let summary: String = { switch eventName { @@ -858,6 +865,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return await MainActor.run { let state = capturedAppState.hookState(for: sessionID) + let stateBefore = state.claudeState // Append to ring buffer (keep last 50 events per session) state.hookEvents.append(event) @@ -931,13 +939,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { case "UserPromptSubmit": state.claudeState = .working + // A new real turn has begun — clear the post-Stop guard so + // legitimate subagents in this turn can elevate state again. + state.lastTopLevelStopAt = nil case "Stop": state.claudeState = .done state.lastToolActivity = nil + state.lastTopLevelStopAt = Date() case "StopFailure": state.claudeState = .waiting + state.lastTopLevelStopAt = Date() case "SessionStart": let source = payload["source"]?.stringValue ?? "startup" @@ -946,17 +959,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } else { state.claudeState = .idle } + state.lastTopLevelStopAt = nil case "SessionEnd": state.claudeState = .idle state.lastToolActivity = nil + state.lastTopLevelStopAt = nil case "SubagentStart": - state.claudeState = .working + // If a top-level Stop has already fired for this turn, the + // subagent is background work (e.g. the recap generator from + // Claude Code ≥ 2.1.108's awaySummaryEnabled feature). Don't + // elevate state — the user is genuinely done. + if state.lastTopLevelStopAt == nil { + state.claudeState = .working + } case "TaskCreated", "TaskCompleted", "SubagentStop": - // Stay in working state - if state.claudeState != .waiting { + // Stay in working state, but only while the turn is still live. + // After a top-level Stop, treat these as background activity + // and leave claudeState alone. + if state.claudeState != .waiting && state.lastTopLevelStopAt == nil { state.claudeState = .working } @@ -977,6 +1000,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { summary: summary ) + if hookDebug && state.claudeState != stateBefore { + let shortID = String(sessionIDStr.prefix(8)) + NSLog("[hook-event] session=\(shortID) event=\(eventName) state=\(stateBefore.rawValue)→\(state.claudeState.rawValue)") + } + return [ "received": .bool(true), "session_id": .string(sessionIDStr), diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 670567d..2682a12 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,6 +24,7 @@ | GitLab tickets missing | Run `glab auth status --hostname `; ensure `GITLAB_HOST` matches what's in `{devRoot}/.claude/config.json` | | Sidebar status dot stuck gray | Terminal never initialized — click the session tab to trigger `createSurface()` | | Sidebar status dot stuck yellow | Shell is spawning but the probe file never appeared. Check `[TerminalManager]` logs for shell-startup errors | +| Sidebar shows "working" forever after a `※ recap:` line | The Claude Code session recap (`awaySummaryEnabled`, on by default in v2.1.108+) fires hook events after a turn's `Stop`. Crow now ignores those — if you're on an older build, disable the recap by setting `"awaySummaryEnabled": false` in `~/.claude/settings.json`, toggling "Session recap" off via `/config` inside Claude Code, or exporting `CLAUDE_CODE_ENABLE_AWAY_SUMMARY=0`. | ## Debugging @@ -36,6 +37,7 @@ The app logs diagnostic information to stderr with component tags: - `[Ghostty]` — Surface creation success/failure - `[AppSupportDirectory]` — One-time `rm-ai-ide` → `crow` migration events - `[Scaffolder]` — Template file loading (development builds) +- `[hook-event]` — Claude Code hook event arrivals and `ClaudeState` transitions. Off by default. Set `CROW_HOOK_DEBUG=1` before launching to enable; useful when diagnosing why the sidebar status dot is in the wrong state. Run with log filtering to focus on a subsystem: