Get pinged when Claude finishes, but only when you've actually walked away.
The naive "push on every turn" is too loud during active work. This tool hooks Claude Code's lifecycle events and:
- delays each push by a few seconds, scaled to how long the turn took
- suppresses it if you're typing your next prompt right now (detected
via the tty's
atime/mtime) - cancels it when you hit Enter (next
UserPromptSubmitkills the pending wrapper)
Result: silent while you're at the keyboard, immediate when you've stepped away. Different sounds for π’ done / π΄ errored / π΅ needs-input so you can triage by ear.
Stdlib-only Python, three small files, no daemon, no pip install.
Hooks into Claude Code via ~/.claude/settings.json and dispatches Pushover
notifications:
- π’ done: Claude finished a turn cleanly
- π΄ errored: Claude finished with an error
- π΅ needs input: Claude is waiting on you (permission prompt, etc.)
Notifications are delayed by a duration that scales with how long the turn took, and suppressed when you're actively typing. If you're sitting at the keyboard, Claude already informed you visually, so the push would just be noise. If you walked away, you want to know.
Sign up at https://pushover.net. Pushover is a one-time $5 per platform purchase (iOS, Android, desktop) after a 30-day trial. The web account itself is free.
- iOS: https://apps.apple.com/app/pushover-notifications/id506088175
- Android: Play Store β "Pushover Notifications"
- Desktop: https://client.pushover.net (browser-based)
Sign in with the account you created. The device must be activated before the API will deliver to it.
After signing in to https://pushover.net, your User Key is shown on
the home page (a 30-character string, e.g. u5ABC...). This identifies
you as the recipient.
Go to https://pushover.net/apps/build and register a new application
(name it e.g. "Claude Code"). Pushover gives you back a 30-character
API Token/Key (e.g. a8XYZ...). This identifies the sender.
Free Pushover accounts allow up to 10,000 messages/month per application, plenty for hook-driven pushes.
pushover_send.py reads credentials from .pushover_config.json next to
itself. Copy the example and fill in your keys:
cp .pushover_config.example.json .pushover_config.json
$EDITOR .pushover_config.jsonFormat:
{
"api_token": "YOUR_APP_API_TOKEN",
"user_key": "YOUR_USER_KEY"
}Make sure it's not world-readable and not committed to git:
chmod 600 .pushover_config.json
echo '.pushover_config.json' >> .gitignorepython pushover_send.py "hello from claude code" --title "test" --priority 0You should get a notification on your registered device within a second
or two. If the API rejects the request, the script prints
pushover failed status=... {...} to stderr. Usually a key typo.
In ~/.claude/settings.json, register claude_hook.py for the events you
care about. Example fragment:
{
"hooks": {
"Stop": [{"type": "command", "command": "python /path/to/pushover/claude_hook.py"}],
"Notification": [{"type": "command", "command": "python /path/to/pushover/claude_hook.py"}],
"PostToolUse": [{"type": "command", "command": "python /path/to/pushover/claude_hook.py"}],
"UserPromptSubmit": [{"type": "command", "command": "python /path/to/pushover/claude_hook.py"}]
}
}All four hook events are required: UserPromptSubmit records turn-start,
PostToolUse stashes the error flag, Notification fires π΅ needs-input
pushes, and Stop fires π’/π΄ turn-end pushes.
claude_hook.py: invoked by Claude Code on every hook event, decides what to pushdelayed_send.py: schedules + conditionally fires the pushpushover_send.py: fires the Pushover API call
Delays are picked based on how long the previous turn took (time since the
last UserPromptSubmit):
| turn duration | scheduled delay |
|---|---|
| < 60s | 20s |
| 60s β 2min | 15s |
| 2 β 5min | 10s |
| > 5min | 0s (immediate) |
Rationale: short turn = you're actively working = you'll see the result on screen, no need to ping. Long turn = you probably walked away = ping immediately.
π΄ errored notifications always fire immediately. Errors are urgent.
Stopevent fires at end of turn.claude_hook.pylooks up the band β picks a delay (e.g. 20s).- It walks the parent process chain to find the user's
/dev/pts/N. - It spawns
delayed_send.pyin a new session, passing(delay, tty, send_argv). PID stored in/tmp/claude_pushover_pending_pid. delayed_send.pysleeps fordelayseconds.- After waking, it polls the tty's
max(atime, mtime)once per second for up to 15 seconds of grace:- If
now - max(atime, mtime) < 5sβ typing detected, skip. - If grace elapses with no typing β fire the push.
- If
- If you submit a new prompt at any time, the next
UserPromptSubmithook callsos.killpg(pid, SIGTERM)to cancel the pending wrapper.
State files are namespaced per-session by tty (e.g. pts3), so two Claude
Code sessions on the same host do not collide. <session> below is your
tty basename, or ppid<N> if no tty could be located:
/tmp/claude_pushover_<session>_turn_start: epoch timestamp of lastUserPromptSubmit/tmp/claude_pushover_<session>_pending_pid: PID + start time of in-flightdelayed_send.py(start time is checked on cancel to avoid signaling a recycled PID)/tmp/claude_pushover_<session>_error_flag: set byPostToolUseif a tool errored, consumed byStop/tmp/claude_pushover_<session>_last_send: rate-limit anchor (max one done push perRATE_LIMIT_SECONDS; needs-input and errored bypass)/tmp/claude_pushover_debug.log: append-only log; only written whenCLAUDE_PUSHOVER_DEBUG=1
The log is opt-in. To enable it, export CLAUDE_PUSHOVER_DEBUG=1 in the
shell that launches Claude Code, the env var propagates to spawned helpers:
export CLAUDE_PUSHOVER_DEBUG=1
claudeSample output:
[time] event=Stop
[time] scheduled send in 20s pid=N tty=/dev/pts/M title=...
[time] delayed[pid=N] skipped (typing, age 1.17s) tty=/dev/pts/M
[time] delayed[pid=N] firing (no typing in 15s grace, age 21.24s)
[time] cancelled pending send pid=N
Three terminal states for any scheduled push: fired, skipped, or cancelled. Any scheduled PID without one of these is still in flight.
Drop a .no-pushover file in the project's working directory.
In delayed_send.py:
| const | value | meaning |
|---|---|---|
TYPING_WINDOW |
5.0 | seconds of "freshness" that count as actively typing |
GRACE_SECONDS |
15.0 | extra time after wake-up to poll for typing to start |
In claude_hook.py:
| const | value | meaning |
|---|---|---|
RATE_LIMIT_SECONDS |
5 | minimum gap between consecutive done pushes; needs-input and errored always fire |
OPT_OUT_MARKER |
.no-pushover |
filename in cwd that disables pushes for that project |
Environment variables:
| var | meaning |
|---|---|
CLAUDE_PUSHOVER_DEBUG |
when set (any value), enables append-only debug logging to /tmp/claude_pushover_debug.log from all three scripts |
If the typing-skip path isn't behaving, watch the tty's stat in a separate terminal while you type in the Claude Code TUI:
watch -n 0.2 'stat /dev/pts/N' # replace N with your tty numberBoth Access and Modify lines should tick (at 1-second granularity) while you type. If neither updates, atime/mtime aren't reaching us and we'd need a different signal.
To find your tty number: from inside the Claude Code session, the schedule
log lines show tty=/dev/pts/N, that's the one being watched.
Stdlib unittest, no extra deps. Covers the delay bands, error-detection
heuristics, watch-friendly hint formatting, transcript scanning, and the
opt-out marker:
python3 -m unittest test_hook.py -vBuilt and verified on:
- Claude Code 2.1.124
- Python 3.12.3 (stdlib only, no
pip installstep) - Ubuntu 24.04 LTS, kernel 6.17,
zsh - Pushover iOS + web clients
Known dependencies on platform behavior:
/proc/<pid>/fd/0and/proc/<pid>/status(Linux procfs), used to walk the parent process chain and locate the user's/dev/pts/N/dev/pts/Non arelatime-mounted devpts (default on Linux)os.killpg+start_new_session=Truefor cancel-via-process-group
macOS and BSD are untested. If _find_user_tty() returns "-" on
your platform, pushes still work, you just lose the typing-skip
optimization (push fires after the scheduled delay regardless). Look for
tty=- in the debug log to spot this.
If you hit version-related errors, file an issue with claude --version
and python3 --version so we can pin the regression.
MIT. Fork it, ship it, build on it. Attribution appreciated but not required beyond keeping the license notice.
Claude Code exposes hooks for events in the agent loop: UserPromptSubmit,
PreToolUse, PostToolUse, Stop, Notification, etc. There is no
keystroke event, no focus event, no "user is composing" event.
UserPromptSubmit only fires on Enter, by which point composition is
already done.
This means a delayed push can be cancelled on submit, but suppressing it during composition requires a side-channel signal.
The first signal we tried was atime on /dev/pts/N. It works, but with
a catch. Modern Linux mounts default to relatime, which only updates
atime when:
atime < mtime, oratime < ctime, oratime > 24h old
Once atime advances past mtime, subsequent reads stop updating atime
until the next write moves mtime forward. For a TTY that means: type one
character, atime ticks once, then it freezes until the TUI redraws (which
advances mtime).
Every keystroke causes the Claude Code TUI to write to the terminal. At minimum, it echoes the character into the prompt line. That bumps mtime. When the TUI is idle waiting for input, no writes happen, so mtime stays put.
The wrapper reads max(atime, mtime), whichever is fresher, to get the
most recent activity timestamp regardless of which signal happens to be
live in a given configuration.
Pty special files have 1-second timestamp granularity (always
.000000000 nanoseconds). Multiple keystrokes within the same second
collapse into one tick. With a 5s TYPING_WINDOW, that's plenty. We
only need to know "did anything happen in the last few seconds."
The hook process itself has no controlling tty (Claude Code pipes
stdin/stdout to the hook). To find the user's tty, walk
/proc/<pid>/status upward, reading /proc/<pid>/fd/0 at each level.
The first ancestor with a /dev/pts/N link is the right one.
def _find_user_tty():
pid = os.getppid()
while pid > 1:
try:
target = os.readlink(f"/proc/{pid}/fd/0")
if target.startswith("/dev/pts/"):
return target
except OSError:
pass
pid = parse_ppid(f"/proc/{pid}/status")
return "-"The wrapper is spawned with start_new_session=True, which makes it a
new process group leader. To cancel, os.killpg(pid, SIGTERM). The
signal propagates to the Python interpreter sleeping inside, which exits
before reaching the os.execv that would have fired the push.