Skip to content

rmf34/pushover

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

1 Commit
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Claude Code β†’ Pushover

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 UserPromptSubmit kills 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.

What it does

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.

Setup

1. Create a Pushover account

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.

2. Install the mobile / desktop client

Sign in with the account you created. The device must be activated before the API will deliver to it.

3. Grab your user key

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.

4. Create an application to get an API token

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.

5. Drop the keys into .pushover_config.json

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.json

Format:

{
  "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' >> .gitignore

6. Smoke-test the send path

python pushover_send.py "hello from claude code" --title "test" --priority 0

You 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.

7. Wire the hook into Claude Code

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.

Files

  • claude_hook.py: invoked by Claude Code on every hook event, decides what to push
  • delayed_send.py: schedules + conditionally fires the push
  • pushover_send.py: fires the Pushover API call

Delay bands

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.

How a delayed push works

  1. Stop event fires at end of turn.
  2. claude_hook.py looks up the band β†’ picks a delay (e.g. 20s).
  3. It walks the parent process chain to find the user's /dev/pts/N.
  4. It spawns delayed_send.py in a new session, passing (delay, tty, send_argv). PID stored in /tmp/claude_pushover_pending_pid.
  5. delayed_send.py sleeps for delay seconds.
  6. 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.
  7. If you submit a new prompt at any time, the next UserPromptSubmit hook calls os.killpg(pid, SIGTERM) to cancel the pending wrapper.

State files

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 last UserPromptSubmit
  • /tmp/claude_pushover_<session>_pending_pid: PID + start time of in-flight delayed_send.py (start time is checked on cancel to avoid signaling a recycled PID)
  • /tmp/claude_pushover_<session>_error_flag: set by PostToolUse if a tool errored, consumed by Stop
  • /tmp/claude_pushover_<session>_last_send: rate-limit anchor (max one done push per RATE_LIMIT_SECONDS; needs-input and errored bypass)
  • /tmp/claude_pushover_debug.log: append-only log; only written when CLAUDE_PUSHOVER_DEBUG=1

Reading the debug log

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
claude

Sample 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.

Per-project opt-out

Drop a .no-pushover file in the project's working directory.

Tunable constants

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

Debugging the typing-detection signal

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 number

Both 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.

Tests

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 -v

Tested with

Built and verified on:

  • Claude Code 2.1.124
  • Python 3.12.3 (stdlib only, no pip install step)
  • Ubuntu 24.04 LTS, kernel 6.17, zsh
  • Pushover iOS + web clients

Known dependencies on platform behavior:

  • /proc/<pid>/fd/0 and /proc/<pid>/status (Linux procfs), used to walk the parent process chain and locate the user's /dev/pts/N
  • /dev/pts/N on a relatime-mounted devpts (default on Linux)
  • os.killpg + start_new_session=True for 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.

License

MIT. Fork it, ship it, build on it. Attribution appreciated but not required beyond keeping the license notice.

What we learned

The hook event surface is server-side only

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.

Tty atime under relatime

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, or
  • atime < ctime, or
  • atime > 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).

mtime is the more reliable signal

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.

Timestamp resolution is 1 second

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."

Walking the parent chain to find the tty

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 "-"

Cancel via process group

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.

About

Get a push notification when Claude finishes, but only when you have actually walked away.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages