Skip to content

Instantly share code, notes, and snippets.

@jwilger
Last active January 14, 2026 20:27
Show Gist options
  • Select an option

  • Save jwilger/9322cf6537bcbd7440d5650f2d23eaa6 to your computer and use it in GitHub Desktop.

Select an option

Save jwilger/9322cf6537bcbd7440d5650f2d23eaa6 to your computer and use it in GitHub Desktop.
Claude Code Notification Hook - Smart desktop notifications for Linux (niri compositor)

Claude Code Notification Hook

A notification system for Claude Code that provides smart, context-aware desktop notifications on Linux.

Features

  • Intelligent focus detection: Short notifications when terminal is focused, sticky ones when not
  • Focus button: Click "Focus Window" to jump back to your terminal
  • Auto-dismiss: Notifications automatically dismiss when you focus the terminal
  • Project context: Shows the project name in notification title
  • Low priority: All notifications use low urgency to avoid being intrusive

Dependencies

This script is designed for Linux systems with:

  • niri - A scrollable-tiling Wayland compositor (github.com/YaLTeR/niri)
  • notify-send - Desktop notification tool (usually from libnotify)
  • jq - JSON processor
  • Kitty terminal (optional) - Uses KITTY_PID for focus detection

Note: This script uses niri-specific commands (niri msg focused-window, niri msg windows, niri msg event-stream). To adapt for other compositors/window managers, you'll need to replace these with equivalent commands for your setup.

Installation

  1. Save claude-notify to ~/.local/bin/claude-notify
  2. Make it executable: chmod +x ~/.local/bin/claude-notify
  3. Add the hook configuration to your Claude settings

Configuration

Add this to your ~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/claude-notify"
          }
        ]
      }
    ]
  }
}

Adapting for Other Window Managers

The script relies on three niri-specific functions that you'll need to replace:

  1. is_terminal_focused() - Check if the terminal has focus
  2. find_terminal_window_id() - Find the window ID of the terminal
  3. focus_terminal() - Focus the terminal window

For Hyprland, you might use hyprctl activewindow and hyprctl dispatch focuswindow. For Sway/i3, you might use swaymsg -t get_tree and swaymsg [pid=X] focus. For X11, you might use xdotool getactivewindow and xdotool windowactivate.

#!/usr/bin/env bash
# Claude Code Notification Helper (all notifications are low priority)
# - If terminal is focused: short timeout since user is already looking
# - If terminal is NOT focused: sticky notification with Focus button, auto-dismiss on focus
set -euo pipefail
NOTIF_STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/claude-notifications"
mkdir -p "$NOTIF_STATE_DIR"
# Read JSON input from stdin
INPUT=$(cat)
# Extract fields from the notification payload
MESSAGE=$(echo "$INPUT" | jq -r '.message // "Claude needs attention"')
CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
NOTIFICATION_TYPE=$(echo "$INPUT" | jq -r '.notification_type // ""')
# KITTY_PID is inherited from Claude's environment - uniquely identifies the terminal
TERMINAL_PID="${KITTY_PID:-}"
# Extract project name from the working directory
if [[ -n "$CWD" ]]; then
PROJECT_NAME=$(basename "$CWD")
else
PROJECT_NAME="Unknown"
fi
# Create a unique key for this session
SESSION_KEY="${SESSION_ID:-${TERMINAL_PID:-$PROJECT_NAME}}"
SESSION_KEY_SAFE=$(echo "$SESSION_KEY" | tr '/' '_' | tr -cd '[:alnum:]_-')
NOTIF_ID_FILE="$NOTIF_STATE_DIR/$SESSION_KEY_SAFE.notif"
WATCHER_PID_FILE="$NOTIF_STATE_DIR/$SESSION_KEY_SAFE.watcher"
# Build the notification title with project context
TITLE="Claude Code [$PROJECT_NAME]"
# Always use low urgency to avoid being intrusive
URGENCY="low"
# Cleanup any existing notification/watcher for this session
cleanup_existing() {
if [[ -f "$WATCHER_PID_FILE" ]]; then
local old_pid
old_pid=$(cat "$WATCHER_PID_FILE" 2>/dev/null || echo "")
if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then
kill "$old_pid" 2>/dev/null || true
fi
rm -f "$WATCHER_PID_FILE"
fi
if [[ -f "$NOTIF_ID_FILE" ]]; then
local old_notif_id
old_notif_id=$(cat "$NOTIF_ID_FILE" 2>/dev/null || echo "")
if [[ -n "$old_notif_id" ]]; then
# Close by replacing with auto-expire notification
notify-send --replace-id="$old_notif_id" --expire-time=1 --app-name="Claude Code" " " " " 2>/dev/null || true
fi
rm -f "$NOTIF_ID_FILE"
fi
}
cleanup_existing
# Check if our terminal is currently focused
is_terminal_focused() {
if [[ -z "$TERMINAL_PID" ]]; then
return 1 # Unknown, assume not focused
fi
local focused_pid
focused_pid=$(niri msg focused-window 2>/dev/null | grep -E "^\s*PID:" | sed 's/.*PID:\s*//' | tr -d ' ')
[[ "$focused_pid" == "$TERMINAL_PID" ]]
}
# Find the niri window ID for our terminal
find_terminal_window_id() {
if [[ -z "$TERMINAL_PID" ]]; then
return 1
fi
local windows_data current_id=""
windows_data=$(niri msg windows 2>/dev/null)
while IFS= read -r line; do
if [[ "$line" =~ Window\ ID\ ([0-9]+): ]]; then
current_id="${BASH_REMATCH[1]}"
fi
if [[ "$line" =~ PID:\ ([0-9]+) ]]; then
if [[ "${BASH_REMATCH[1]}" == "$TERMINAL_PID" ]]; then
echo "$current_id"
return 0
fi
fi
done <<< "$windows_data"
return 1
}
# Focus the terminal window
focus_terminal() {
local window_id
window_id=$(find_terminal_window_id)
if [[ -n "$window_id" ]]; then
niri msg action focus-window --id "$window_id" 2>/dev/null
fi
}
if is_terminal_focused; then
# Terminal is focused - short timeout since user is already looking
notify-send \
--app-name="Claude Code" \
--urgency="$URGENCY" \
--expire-time=3000 \
"$TITLE" \
"$MESSAGE" 2>/dev/null || true
exit 0
fi
# Terminal is NOT focused - show sticky notification with Focus button
# Run in background so we don't block the hook
(
# Use a temp file to pass the notification ID from notify-send
NOTIF_ID_TEMP=$(mktemp)
# Start notify-send with action in background, save ID immediately
{
notify-send \
--app-name="Claude Code" \
--urgency="$URGENCY" \
--expire-time=0 \
--print-id \
-A "focus=Focus Window" \
"$TITLE" \
"$MESSAGE" 2>/dev/null
} > "$NOTIF_ID_TEMP" &
NOTIFY_PID=$!
# Wait briefly for the notification ID to be written
sleep 0.2
# Read the notification ID (first line)
NOTIF_ID=$(head -1 "$NOTIF_ID_TEMP" 2>/dev/null || echo "")
if [[ -n "$NOTIF_ID" && "$NOTIF_ID" =~ ^[0-9]+$ ]]; then
echo "$NOTIF_ID" > "$NOTIF_ID_FILE"
# Find window ID for focus watching
TARGET_WINDOW_ID=$(find_terminal_window_id)
if [[ -n "$TARGET_WINDOW_ID" ]]; then
# Start watcher that dismisses notification when terminal is focused
(
niri msg event-stream 2>/dev/null | while IFS= read -r event; do
# Event format: "Window focus changed: Some(41)"
if echo "$event" | grep -qE "Window focus changed: Some\(${TARGET_WINDOW_ID}\)"; then
# User focused our terminal - dismiss notification by replacing with auto-expire
notify-send --replace-id="$NOTIF_ID" --expire-time=1 --app-name="Claude Code" " " " " 2>/dev/null || true
# Kill the notify-send process if still waiting
kill "$NOTIFY_PID" 2>/dev/null || true
rm -f "$NOTIF_ID_FILE" "$WATCHER_PID_FILE" "$NOTIF_ID_TEMP"
exit 0
fi
done
) &
echo $! > "$WATCHER_PID_FILE"
fi
fi
# Wait for notify-send to complete (user clicked action or notification was dismissed)
wait "$NOTIFY_PID" 2>/dev/null || true
# Check if user clicked "focus"
CLICKED_ACTION=$(tail -n +2 "$NOTIF_ID_TEMP" 2>/dev/null | head -1 || echo "")
if [[ "$CLICKED_ACTION" == "focus" ]]; then
focus_terminal
fi
# Cleanup
rm -f "$NOTIF_ID_TEMP"
# Kill watcher if still running
if [[ -f "$WATCHER_PID_FILE" ]]; then
watcher_pid=$(cat "$WATCHER_PID_FILE" 2>/dev/null || echo "")
if [[ -n "$watcher_pid" ]]; then
kill "$watcher_pid" 2>/dev/null || true
fi
rm -f "$WATCHER_PID_FILE"
fi
rm -f "$NOTIF_ID_FILE"
) &
exit 0
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-notify"
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment