fix: read real Claude Code hook payload fields - #7
Open
ShabanK wants to merge 1 commit into
Open
Conversation
Nudgy branched on `event.matcher`, but Claude Code never sends `matcher` in hook input — it is a settings-side filter only. `event.matcher` was always nil, so: - Notification: neither waitingPermission nor waitingInput was ever set, so permission prompts and idle prompts fell through to the `info` style, which is disabled by default and silently dropped. This is the core "no longer working" bug. - StopFailure: `max_output_tokens` was never detected, so every stop failure was reported as an error. - Error popups always showed the generic fallback text. Use the fields Claude Code actually sends, verified against captures from Claude Code 2.1.235: Notification -> notification_type StopFailure -> error / error_details SessionStart -> source SessionEnd -> reason Stop -> last_assistant_message Also drop the SessionStart hook: that event only supports `command` and `mcp_tool` handlers, so the installed `http` handler was never invoked. It is now cleaned up as a legacy entry, and sessions are created lazily on their first real event. SessionEnd gets an explicit timeout since it otherwise shares a 1.5s budget. `matcher` is retained on the model for decode compatibility only. Tests previously hand-wrote a `matcher` field that Claude Code never emits, which kept the suite green while the app was broken. Fixtures are now verbatim captures from a real Claude Code session.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Nudgy stopped reacting to Claude Code events because it branched on
event.matcher. Claude Code never sendsmatcherin hook input — it is a settings-side filter used to decide whether a hook runs, not a field echoed back in the payload.event.matcherwas therefore alwaysnil, and every decision keyed off it collapsed.The most visible symptom: permission prompts and idle prompts never set
waitingPermission/waitingInput, so they fell through to theinfostyle, which is disabled by default and silently dropped.How this was diagnosed
Rather than reading the code alone, I pointed real Claude Code hooks (v2.1.235) at a logging HTTP endpoint and captured actual payloads.
NotificationandPermissionRequestdon't fire in headless-pmode, so an interactive PTY session was driven to trigger a genuine permission dialog.A/B against the built binary using a byte-identical real payload:
What was wrong
Notificationnotification_typematcherStopFailureerror/error_detailsmatcherSessionStartsourcematcherSessionEndreasonmatcherConsequences:
Notification—waitingPermissionandwaitingInputwere never set. Permission and idle prompts were silently swallowed. This is the core "no longer working" bug.StopFailure—max_output_tokenswas never detected, so every stop failure was reported as an error."Something went wrong"fallback.Notably,
notificationTypewas already correctly decoded on the model and simply never referenced anywhere inSources/.Changes
HookEvent— decode the real fields:error,error_details,source,reason,last_assistant_message. Added adiscriminatoraccessor since Claude Code names the sub-type field differently per event.matcheris retained for decode compatibility only, with a comment explaining why it must not be branched on.SessionManager—Notificationnow switches onnotification_type(permission_prompt,idle_prompt,agent_needs_input,agent_completed, elicitation dialogs);StopFailureswitches onerror.AppDelegate— error text now derives fromerror_details/last_assistant_message/ a readable mapping oferror. Added a log line recording the notification style and resulting session state, which is what made the A/B above observable.HookInstaller— dropped theSessionStarthook. That event only supportscommandandmcp_toolhandlers, so the installedhttphandler was never invoked. Verified directly: acommandprobe fired onSessionStartwhile anhttpprobe on the same event did not. It is now cleaned up as a legacy entry, and sessions are created lazily on their first real event.SessionEndgained an explicittimeout, since it otherwise shares a 1.5s budget.Logger—matcher:parameter renamed todetail:to match reality.Why this shipped broken
The tests hand-wrote a
matcherfield that Claude Code never emits, so the suite stayed green while the app was broken in production. Fixtures inHookEventTests.swiftare now verbatim captures from a real Claude Code session, with explicitXCTAssertNil(event.matcher)regression guards. Test helpers build events the way Claude Code actually sends them.Verification
Each state transition was confirmed end-to-end against the running binary:
notification_type: permission_promptwaitingPermissionnotification_type: idle_promptwaitingInputerror: rate_limiterrorerror: overloadederrorerror: max_output_tokensidleStopidleInstaller behaviour was also verified against a real
~/.claude/settings.json: a seeded legacySessionStartentry is removed,SessionStartis not reinstalled, and unrelated user hooks are preserved.Note:
swift testcould not be run locally — the machine has only Command Line Tools, soXCTestis unavailable. This is a pre-existing environment limitation unrelated to these changes, and is why verification was done against the running binary. CI should exercise the suite.Not addressed (follow-ups)
Deliberately kept out of scope to keep this reviewable:
TranscriptParserreconstructs the transcript path fromcwdby replacing/with-, but Claude also replaces.. For/Users/me/.config/nvimit computes-Users-me-.config-nvimwhile the real directory is-Users-me--config-nvim. The file isn't found,parseUsagereturnsnil, and token usage silently reads zero. Every hook payload already carriestranscript_pathdirectly, making the slug reconstruction unnecessary. This is silent data corruption and is probably the highest-value next fix.HTTPServercaps payloads at 64KB and returns 413 above that.Stoppayloads now includelast_assistant_message,background_tasks[]andsession_crons[], so a long response can exceed the cap and the completion notification is dropped — intermittently, on exactly the longest turns.{"status":"ok"}response body is not a valid hook output schema; the docs specify such a body is treated as a non-blocking error. An empty 200/204 is the correct "no decision" response. Matters most onPermissionRequest.