From d1a02f616665f84c8bd0ec14717566cc0dccb7f0 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 1 May 2026 20:15:39 -0500 Subject: [PATCH] Silently skip hook-event RPC when Crow app is not running (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Crow app is not running, `crow hook-event` previously exited non-zero with "Socket connection failed: Connection refused", causing Claude Code to surface a noisy "Stop hook error" on every session exit. Hook events are fire-and-forget — a missing listener is an expected state, not an error. Catch SocketError.connectionFailed in the hook-event command only, so other CLI commands still fail loudly when the app is unavailable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/HookEventCommand.swift | 19 +++++++++++++++++-- .../Tests/CrowCLITests/HookEventTests.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift b/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift index 8866293..377f448 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift @@ -24,10 +24,23 @@ 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), ]) @@ -35,6 +48,8 @@ public struct HookEventCmd: ParsableCommand { if result["error"] != nil { printJSON(result) } + } catch SocketError.connectionFailed { + return } } diff --git a/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift b/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift index 6890fba..304d9fe 100644 --- a/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift +++ b/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift @@ -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: [:] + ) +}