From 75d25191a8477535909e4ee961c7b584dca3b9ee Mon Sep 17 00:00:00 2001 From: oz6un Date: Mon, 20 Jul 2026 09:58:50 +0300 Subject: [PATCH] feat: notify only when Claude is waiting on you, not every turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook fired on Stop (once per turn), so an autonomous multi-step task produced 5-10 pushes when only the final 'over to you' matters. Stop has no field distinguishing done-waiting from stopped-mid-workflow (per hooks docs), so it's unfilterable — dropped it. Notification's idle_prompt is the real 'handed control back' signal and self-filters intermediate stops; the script now reads notification_type and denylists pure-noise sub-types (auth_success, elicitation_*). Denylist, not allowlist, so a missing field fails loud not silent. StopFailure kept. Event matrix + one real push verified on the box. Co-Authored-By: Claude Fable 5 --- README.md | 8 +++++--- files/claude-notify.tmpl | 25 ++++++++++++++++++------- files/claude-settings.json | 11 ----------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c996db1..0e3c998 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,11 @@ Optional: enable **Tailscale Serve** on your tailnet for HTTPS preview URLs — run `tailscale serve --bg ` for `https://..ts.net`, and `tailscale serve off` when done. - **`docker run -p 8080:80 …`** publishes to loopback, so containers are reachable at `devbox:8080` over the tailnet and invisible to the internet. Publishing to `0.0.0.0` bypasses this — see FOOTGUNS. -- **`claude` in any repo** pushes to your phone when it finishes or needs input (and stays quiet - while you're typing in tmux). A **"devbox health"** push is the hourly monitor flagging low disk - or a failed unit. +- **`claude` in any repo** pushes to your phone only when it's genuinely waiting on you — idle + after handing control back, or blocked on a decision — plus turn failures. It deliberately does + *not* ping on every completed turn (that floods during autonomous multi-step work), and stays + quiet while you're active in tmux. A **"devbox health"** push is the hourly monitor flagging low + disk or a failed unit. ## Rebuild and teardown diff --git a/files/claude-notify.tmpl b/files/claude-notify.tmpl index 5a9d4e7..3d0e980 100644 --- a/files/claude-notify.tmpl +++ b/files/claude-notify.tmpl @@ -1,6 +1,13 @@ #!/usr/bin/env bash # Claude Code hook -> Pushover push notifications. Hook JSON on stdin. -# Skips pushes when you're actively typing in the tmux session running Claude. +# Pushes ONLY when Claude genuinely needs you — it's idle waiting for your next +# prompt, or blocked on a permission — or a turn errored. It deliberately does +# NOT fire on Stop: Stop runs once per turn and has no field distinguishing +# "done, over to you" from "stopped mid-workflow, resuming" (confirmed via the +# hooks docs), so during autonomous multi-step work it floods you with a push +# per intermediate turn. `idle_prompt` is the real "handed control back to you" +# signal and self-filters those intermediate stops. +# Also skips pushes while you're active at the tmux session. # Rendered from claude-notify.tmpl by setup-user.sh (placeholders from secrets.env). PO_TOKEN="__PUSHOVER_TOKEN__" PO_USER="__PUSHOVER_USER__" @@ -8,6 +15,7 @@ PRESENCE_WINDOW=60 payload=$(cat) event=$(jq -r '.hook_event_name // "event"' <<<"$payload") +ntype=$(jq -r '.notification_type // empty' <<<"$payload") proj=$(basename "$(jq -r '.cwd // "?"' <<<"$payload")") # Presence check: if a tmux client on this session saw keyboard input in the @@ -23,14 +31,17 @@ fi sound="" case "$event" in Notification) - title="Claude needs input · $proj" + # Denylist the pure-noise sub-types (login confirmations, MCP form + # internals); everything else pushes — notably idle_prompt (Claude waiting + # on you), permission_prompt, agent_needs_input, elicitation_dialog. Using a + # denylist (not an allowlist) means a missing/renamed notification_type + # fails LOUD (still pushes) rather than silently swallowing every alert. + case "$ntype" in + auth_success|elicitation_complete|elicitation_response) exit 0 ;; + esac + title="Claude needs you · $proj" body=$(jq -r '.message // "Claude is waiting for you"' <<<"$payload") po_prio=1; sound=intermission ;; - Stop) - title="Claude finished · $proj" - body=$(jq -r '.last_assistant_message // empty' <<<"$payload" | tr '\n\t' ' ' | sed 's/ */ /g' | head -c 280) - body=${body:-"Turn complete"} - po_prio=0 ;; StopFailure) title="Claude turn FAILED · $proj" body="The turn ended on an API error (rate limit / server / auth). Check the session." diff --git a/files/claude-settings.json b/files/claude-settings.json index 5735084..978eb65 100644 --- a/files/claude-settings.json +++ b/files/claude-settings.json @@ -14,17 +14,6 @@ ] } ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "/home/__DEV_USER__/.local/bin/claude-notify", - "async": true - } - ] - } - ], "StopFailure": [ { "hooks": [