Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
34 changes: 31 additions & 3 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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
}

Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
| GitLab tickets missing | Run `glab auth status --hostname <your-host>`; 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

Expand All @@ -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:

Expand Down
Loading