Skip to content

fix: read real Claude Code hook payload fields - #7

Open
ShabanK wants to merge 1 commit into
Hamma111:mainfrom
ShabanK:fix/hook-payload-contract
Open

fix: read real Claude Code hook payload fields#7
ShabanK wants to merge 1 commit into
Hamma111:mainfrom
ShabanK:fix/hook-payload-contract

Conversation

@ShabanK

@ShabanK ShabanK commented Aug 19, 2026

Copy link
Copy Markdown

Summary

Nudgy stopped reacting to Claude Code events because it branched on event.matcher. Claude Code never sends matcher in hook input — it is a settings-side filter used to decide whether a hook runs, not a field echoed back in the payload. event.matcher was therefore always nil, 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 the info style, 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. Notification and PermissionRequest don't fire in headless -p mode, 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:

BEFORE: EVENT Notification | matcher=-                 -> Notification disabled for style: info
AFTER:  EVENT Notification | detail=permission_prompt  -> Showing warning notification | state=waitingPermission

What was wrong

Event Field Claude Code actually sends What Nudgy read
Notification notification_type matcher
StopFailure error / error_details matcher
SessionStart source matcher
SessionEnd reason matcher

Consequences:

  • NotificationwaitingPermission and waitingInput were never set. Permission and idle prompts were silently swallowed. This is the core "no longer working" bug.
  • StopFailuremax_output_tokens was never detected, so every stop failure was reported as an error.
  • Error popups — always showed the generic "Something went wrong" fallback.

Notably, notificationType was already correctly decoded on the model and simply never referenced anywhere in Sources/.

Changes

  • HookEvent — decode the real fields: error, error_details, source, reason, last_assistant_message. Added a discriminator accessor since Claude Code names the sub-type field differently per event. matcher is retained for decode compatibility only, with a comment explaining why it must not be branched on.
  • SessionManagerNotification now switches on notification_type (permission_prompt, idle_prompt, agent_needs_input, agent_completed, elicitation dialogs); StopFailure switches on error.
  • AppDelegate — error text now derives from error_details / last_assistant_message / a readable mapping of error. Added a log line recording the notification style and resulting session state, which is what made the A/B above observable.
  • HookInstallerdropped the SessionStart hook. That event only supports command and mcp_tool handlers, so the installed http handler was never invoked. Verified directly: a command probe fired on SessionStart while an http probe 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. SessionEnd gained an explicit timeout, since it otherwise shares a 1.5s budget.
  • Loggermatcher: parameter renamed to detail: to match reality.

Why this shipped broken

The tests hand-wrote a matcher field that Claude Code never emits, so the suite stayed green while the app was broken in production. Fixtures in HookEventTests.swift are now verbatim captures from a real Claude Code session, with explicit XCTAssertNil(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:

Payload Resulting state Style
notification_type: permission_prompt waitingPermission warning
notification_type: idle_prompt waitingInput question
error: rate_limit error error
error: overloaded error error
error: max_output_tokens idle success
Stop idle success

Installer behaviour was also verified against a real ~/.claude/settings.json: a seeded legacy SessionStart entry is removed, SessionStart is not reinstalled, and unrelated user hooks are preserved.

Note: swift test could not be run locally — the machine has only Command Line Tools, so XCTest is 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:

  1. TranscriptParser reconstructs the transcript path from cwd by replacing / with -, but Claude also replaces .. For /Users/me/.config/nvim it computes -Users-me-.config-nvim while the real directory is -Users-me--config-nvim. The file isn't found, parseUsage returns nil, and token usage silently reads zero. Every hook payload already carries transcript_path directly, making the slug reconstruction unnecessary. This is silent data corruption and is probably the highest-value next fix.
  2. HTTPServer caps payloads at 64KB and returns 413 above that. Stop payloads now include last_assistant_message, background_tasks[] and session_crons[], so a long response can exceed the cap and the completion notification is dropped — intermittently, on exactly the longest turns.
  3. {"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 on PermissionRequest.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant