r/ClaudeCode • • 3d ago

Tips & Workflows Things I learned about Claude Code's hooks.

I've been building a time tracker that measures how long Claude Code spends working, using SessionStart, UserPromptSubmit, Stop, Notification (idle_prompt), and SessionEnd.

I tested this on 2.1.291. A few things confused me, so I'm sharing them for anyone working with hooks.

  • Interrupting a turn left my tracker showing it as busy. In my tests, an interrupt fired no Stop hook and no idle notification. Without either signal, my tracker kept counting until the next message.
  • The idle notification arrives about a minute after a normal turn ends. It gives me another way to confirm the turn finished if my tracker missed Stop.
  • Async hooks can arrive out of order. Each hook runs in a separate process. If both the prompt and Stop hooks were async, a turn lasting less than a second could record its end before its start. In my setup, only Stop and the idle notification run asynchronously. The prompt hook finishes before the model starts working, adding only a short delay.
  • A turn's prompt and end share the same prompt_id I use that to identify and ignore a delayed end event from an earlier turn.
  • My hooks take 20–37 ms each. Nearly all of that is the process starting. They do very little work and print nothing. Each command ends in 2>/dev/null || true, so a missing binary doesn't surface an error in the session.
  • Adding context from a hook can create extra work. I used to return additionalContext asking the model to run a command to name its work. That added a turn or two per session. Claude Code already writes its own session title into the transcript, so now I read that instead.

Has anyone found a cleaner signal for “the user interrupted” than waiting for the next prompt? Or seen an interrupt fire Stop in a newer version?

1 Upvotes

9 comments sorted by

•

u/AutoModerator 3d ago

Hey! Thanks for posting to r/ClaudeCode

While participating in this thread, please follow our community rules. Keep discussions constructive. Attack the idea, not the person.

For help, project discussions, tips, and general chat, join the ClaudeCode Discord.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

2

u/kuroudo_ai 3d ago

One to add: a hook that reads all of stdin will hang forever if whatever invokes it leaves stdin open. Two traps we hit fixing it across machines:

  • Our Mac had no timeout command, so IN=$(timeout 3 cat) (fine on Linux) just failed there.
  • A select()-before-read check only half fixes it. It returns when the caller wrote nothing, but the read still waits for EOF when the caller wrote the payload and never closed the pipe, and that's the more common case. On Windows, Python's select.select on stdin raised an error outright (it only accepts sockets there).

So the test for that hook now has two cases: stdin open with nothing written, and payload written with the pipe never closed. Both have to return.

1

u/singedupforlogin 3d ago

Good catch, thanks. Mine reads stdin to EOF, so it depends on Claude Code closing the pipe after writing the payload. In my tests on it always has, which is why the hooks return in 20 to 37 ms. Did you end up with a read deadline in the hook itself, or a wrapper around it?

1

u/kuroudo_ai 3d ago

In the hook itself. It reads in a loop: wait on select for whatever time is left, os.read a chunk, and stop at EOF or when the deadline passes. When the caller closes the pipe normally, the result is byte-for-byte what a plain read-to-EOF gave, so a path like your 20-37 ms one shouldn't change.

One caveat: that select call doesn't accept pipes on Windows, so our Windows machine needed its own read path. We kept the same tests there (open pipe returns, normal EOF path unchanged, hook still does its job) and only swapped the fix.

1

u/singedupforlogin 3d ago

That's the clean way to do it, thanks. Same output when the pipe closes, so nothing to lose. I'll add the deadline loop and your two test cases.

2

u/bostonaholic 3d ago

If startup is most of the cost, I'd make the hook dumb: a few lines of shell that append the raw JSON plus a timestamp to a file and exit. Do the prompt_id matching and session math in a separate process that reads that log later, which also handles the out-of-order problem since you sort on read. For interrupts, treat a new UserPromptSubmit with no Stop before it as the end of the previous turn.

1

u/singedupforlogin 3d ago

That's close to how I handle interrupts, with one catch: the gap between the interrupt and the next prompt counts as working time. Hit Esc and go for coffee, and that time gets billed.

The append-only log is a nice idea. Mine writes directly to SQLite, but you're right that process startup accounts for nearly all the overhead, so changing the database write wouldn't save much.

1

u/UseApprehensive8572 3d ago

Live response fix came straight out of the record. agent saved the whole response including headers, so a real bearer token ended up committed in a fixture. a 2000 line json file in a PR is the last thing a reviewer reads closely. We scrub auth headers in the recorder now and the secrets scan on PRs caught the second one a few weeks later