Skip to content

Instantly share code, notes, and snippets.

@GottZ
Last active April 9, 2026 22:30
Show Gist options
  • Select an option

  • Save GottZ/34ce27e42dd169515c83dad3aa513db0 to your computer and use it in GitHub Desktop.

Select an option

Save GottZ/34ce27e42dd169515c83dad3aa513db0 to your computer and use it in GitHub Desktop.
Smart terminal bell for Claude Code — only notify when ALL work is done (no intermediate bells from background agents)

Claude Code: Smart Idle Notification Hook

Problem

Claude Code's built-in notification fires independently per background task completion. When you run multiple background agents, you get a bell for each one finishing, plus another when the main session completes. If you have 5 agents, that's 6 bells instead of 1.

There's no built-in check for remaining pending tasks before sending the notification.

Solution

A single hook script (notify-on-idle.sh) that tracks background agent lifecycle via counter file and only sends a terminal bell when all agents have finished AND the main session is idle.

How it works

Hook Event Action
SessionStart Reset counter to 0 (handles resume/restart cleanly)
SubagentStart Counter +1
SubagentStop Counter -1
Stop If counter ≤ 0 → send bell. Otherwise: suppress.

tmux-aware: if $TMUX_PANE is set, the bell is written directly to the pane's TTY via #{pane_tty}, so tmux shows the bell flag in the status bar even when you're in a different window.

Note: An earlier version used tmux send-keys to send the bell, but that injects it as a keystroke into the shell, not as a terminal escape. Writing to the PTY directly is the correct approach.

Setup

1. Disable built-in notifications

/config

Set notifications to notifications_disabled.

2. Install the hook

mkdir -p ~/.claude/hooks
curl -fsSL https://gist.githubusercontent.com/GottZ/34ce27e42dd169515c83dad3aa513db0/raw/notify-on-idle.sh \
  -o ~/.claude/hooks/notify-on-idle.sh
chmod +x ~/.claude/hooks/notify-on-idle.sh

3. Configure hooks in ~/.claude/settings.json

Add these hook entries (merge with your existing hooks — don't overwrite):

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-on-idle.sh"
          }
        ]
      }
    ],
    "SubagentStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-on-idle.sh"
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-on-idle.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-on-idle.sh"
          }
        ]
      }
    ]
  }
}

4. Restart Claude Code

Hooks load at process start. Restart Claude Code for the changes to take effect.

How it handles edge cases

Scenario Behavior
No background agents Bell fires normally on every Stop
5 parallel agents Bell fires once — when the last agent completes and main session stops
Session resume after crash SessionStart resets counter to 0, clean state
Agent crashes without SubagentStop Counter stays positive, no bell until next SessionStart reset
tmux Bell written to pane TTY via #{pane_tty}, shows as window flag
No tmux Falls back to /dev/tty

Dependencies

  • bash, python3 (for JSON parsing from hook stdin)
  • Optional: tmux (for pane-aware bell)

The script

See notify-on-idle.sh below.


By GottZ — built during ctx development.

#!/bin/bash
# notify-on-idle.sh — Bell when all agents finished and main is idle.
# Counter-based: SubagentStart increments, SubagentStop decrements.
# Stop: bell only when counter is 0.
#
# Setup in ~/.claude/settings.json:
# Register this script for SubagentStart, SubagentStop, Stop, and SessionStart hooks.
# See: https://docs.anthropic.com/en/docs/claude-code/hooks
set -euo pipefail
INPUT=$(cat)
EVENT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('hook_event_name',''))" 2>/dev/null || echo "")
SESSION=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null || echo "unknown")
COUNTER_FILE="/tmp/claude-agents-${SESSION}"
case "$EVENT" in
SessionStart)
echo "0" > "$COUNTER_FILE"
;;
SubagentStart)
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
echo $(( COUNT + 1 )) > "$COUNTER_FILE"
;;
SubagentStop)
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
NEW=$(( COUNT - 1 ))
[ "$NEW" -lt 0 ] && NEW=0
echo "$NEW" > "$COUNTER_FILE"
;;
Stop)
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
if [ "$COUNT" -le 0 ]; then
# Write bell escape directly to the pane's tty.
# tmux send-keys would inject it as a keystroke, not a terminal bell.
if [ -n "${TMUX_PANE:-}" ]; then
TTY=$(tmux display-message -t "$TMUX_PANE" -p "#{pane_tty}" 2>/dev/null || true)
if [ -n "$TTY" ]; then
printf '\a' > "$TTY" 2>/dev/null || true
fi
else
printf '\a' > /dev/tty 2>/dev/null || true
fi
fi
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment