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
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,32 @@ public struct HookEventCmd: ParsableCommand {

public func run() throws {
let payload = parseHookPayload(from: FileHandle.standardInput.readDataToEndOfFile())
try forwardHookEvent(sessionID: session, eventName: event, payload: payload)
}
}

/// Forward a parsed hook event over the Unix socket.
///
/// Silently no-ops when the Crow app is not running (socket connection
/// refused or socket file absent). Hooks are fire-and-forget — a missing
/// listener is an expected state, not an error, so we must not exit
/// non-zero or write to stderr (it pollutes Claude Code's hook output).
/// Other socket errors (timeout, write/read failures) and JSON-RPC
/// errors still propagate so genuine misbehavior is visible.
func forwardHookEvent(sessionID: String, eventName: String, payload: [String: JSONValue]) throws {
do {
let result = try rpc("hook-event", params: [
"session_id": .string(session),
"event_name": .string(event),
"session_id": .string(sessionID),
"event_name": .string(eventName),
"payload": .object(payload),
])

// Silent on success — only print on error
if result["error"] != nil {
printJSON(result)
}
} catch SocketError.connectionFailed {
return
}
}

Expand Down
17 changes: 17 additions & 0 deletions Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,20 @@ import CrowIPC
let payload = parseHookPayload(from: data)
#expect(payload.isEmpty)
}

// MARK: - Connection Refused Handling

/// Regression test for #227: when the Crow app is not running, the hook-event
/// command must silently no-op instead of exiting non-zero. Otherwise Claude
/// Code surfaces "Stop hook error: …" on every session exit.
@Test func forwardHookEventSilentWhenAppNotRunning() throws {
let nonExistent = NSTemporaryDirectory() + "crow-test-\(UUID().uuidString).sock"
setenv("CROW_SOCKET", nonExistent, 1)
defer { unsetenv("CROW_SOCKET") }

try forwardHookEvent(
sessionID: UUID().uuidString,
eventName: "Stop",
payload: [:]
)
}
Loading