Skip to content

Instantly share code, notes, and snippets.

@joechrysler
Created March 17, 2026 13:50
Show Gist options
  • Select an option

  • Save joechrysler/d68e07300761b1385bc4373c00947ee4 to your computer and use it in GitHub Desktop.

Select an option

Save joechrysler/d68e07300761b1385bc4373c00947ee4 to your computer and use it in GitHub Desktop.
claude-notify
#!/usr/bin/env bash
# Claude Code notification hook β€” uses Sonnet to describe what needs attention
# Notifications are delayed 60s and cancelled if the user returns.
#
# Hook configuration (in .claude/settings.json):
# "hooks": {
# "Notification": [
# { "matcher": "permission_prompt",
# "hooks": [{ "type": "command", "command": "claude-notify 🏑 permission" }] },
# { "matcher": "idle_prompt",
# "hooks": [{ "type": "command", "command": "claude-notify 🏑 idle" }] }
# ]
# }
# Requires NOTIFICATIONS_HOST β€” base URL of an ntfy.sh instance (no trailing slash).
# e.g. export NOTIFICATIONS_HOST=http://my-server:8090 (self-hosted)
# export NOTIFICATIONS_HOST=https://ntfy.sh (hosted)
if [ -z "$NOTIFICATIONS_HOST" ]; then
echo "Error: NOTIFICATIONS_HOST is not set." >&2
echo "Set it to the base URL of your ntfy.sh instance (e.g. https://ntfy.sh)" >&2
exit 1
fi
PIDFILE="/tmp/claude-notify-pending.pid"
DEBUGLOG="/tmp/claude-notify-debug.log"
debug() {
[ "${CLAUDE_NOTIFY_DEBUG:-0}" = "1" ] || return 0
printf '[%s] %s
' "$(date '+%H:%M:%S')" "$*" >> "$DEBUGLOG"
}
# Kill any pending delayed notification from a previous invocation
if [ -f "$PIDFILE" ]; then
debug "cancelling previous pending notification (pid=$(cat "$PIDFILE"))"
kill "$(cat "$PIDFILE")" 2>/dev/null
rm -f "$PIDFILE"
fi
EMOJI="${1:-πŸ€–}"
EVENT="${2:-unknown}"
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd')
DIR=$(basename "$CWD")
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path')
# Capture tmux state while still in the foreground β€” background subshells
# cannot reliably use display-message (it requires a client context).
TMUX_SESSION=$(tmux display-message -p '#S' 2>/dev/null)
debug "--- hook fired: event=$EVENT dir=$DIR session=$TMUX_SESSION"
send_notification() {
local tmux_info context instruction
if [ -n "$TMUX_SESSION" ]; then
tmux_info="Tmux session: $TMUX_SESSION"
else
tmux_info=""
fi
context=$(tail -c 10000 "$TRANSCRIPT" 2>/dev/null | tail -80 || echo 'no context available')
if [ "$EVENT" = "permission" ]; then
instruction="The project is BLOCKED. Briefly say what it's blocked on (e.g. 'needs permission to run tests')."
else
instruction="The project is DONE. Briefly summarize what was accomplished."
fi
printf 'You are a push notification writer for iOS. Rules:
- Write ONLY 1-2 short sentences, no preamble
- NEVER use markdown formatting (no *, **, `, #, etc.) β€” iOS does not render it
- Start with %s then the project name "%s"
- Never say "Claude" β€” the notification source already shows that
- Never say "please return" or "come back" β€” that is obvious
- Be concise and informative, not verbose or redundant
%s
Project directory: %s
%s
Recent session transcript:
%s' \
"$EMOJI" "$DIR" "$instruction" "$DIR" "$tmux_info" "$context" \
| claude -p --model sonnet \
| curl -s -d @- "${NOTIFICATIONS_HOST}/claude"
}
# Get the latest client activity across all clients attached to our session.
# Uses list-clients (works reliably from background subshells) instead of
# display-message (which needs a client context that may not exist).
latest_client_activity() {
if [ -n "$TMUX_SESSION" ]; then
tmux list-clients -t "$TMUX_SESSION" -F '#{client_activity}' 2>/dev/null | sort -rn | head -1
fi
}
# Delay all notifications: wait 60s, then check if the user came back.
# Skip if:
# - the transcript grew (user interacted with Claude)
# - tmux saw client activity (user pressed keys)
filesize() { case $(uname -s) in Darwin) stat -f%z "$1" ;; *) stat -c%s "$1" ;; esac; }
TRANSCRIPT_SIZE=$(filesize "$TRANSCRIPT" 2>/dev/null || echo 0)
TMUX_ACTIVITY=$(latest_client_activity)
debug "initial: transcript_size=$TRANSCRIPT_SIZE tmux_activity=$TMUX_ACTIVITY"
(
sleep 60
debug "woke up after 60s, checking..."
NEW_SIZE=$(filesize "$TRANSCRIPT" 2>/dev/null || echo 0)
debug "transcript: before=$TRANSCRIPT_SIZE after=$NEW_SIZE"
if [ "$NEW_SIZE" != "$TRANSCRIPT_SIZE" ]; then
debug "SKIP: transcript grew β€” user interacted with Claude"
rm -f "$PIDFILE"; exit 0
fi
NEW_TMUX=$(latest_client_activity)
debug "tmux activity: before=$TMUX_ACTIVITY after=$NEW_TMUX"
if [ -n "$TMUX_ACTIVITY" ] && [ "$NEW_TMUX" != "$TMUX_ACTIVITY" ]; then
debug "SKIP: tmux client activity changed β€” user pressed keys"
rm -f "$PIDFILE"; exit 0
fi
debug "SEND: all checks passed, sending notification"
send_notification
rm -f "$PIDFILE"
) &
echo $! > "$PIDFILE"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment