Skip to content

Instantly share code, notes, and snippets.

@geowa4
Created March 30, 2026 13:18
Show Gist options
  • Select an option

  • Save geowa4/251c0d3af2500d4bfa5e8e0b30287f86 to your computer and use it in GitHub Desktop.

Select an option

Save geowa4/251c0d3af2500d4bfa5e8e0b30287f86 to your computer and use it in GitHub Desktop.
Claude Code hooks: tmux display-message notifications for task completion, long commands, and background subagents

Claude Code → tmux display-message Notifications

Get a tmux status-line notification whenever Claude Code finishes a task, needs your input, completes a long-running command, or finishes a background subagent — no external services, no macOS-specific tools, just tmux.

What it does

Hook event Matcher Behavior
Stop all ✅ Claude finished in my-project
Notification all 👀 Claude needs input in my-project
PreToolUse Bash Silently records start timestamp
PostToolUse Bash ⚡ Done (47s): npm test (≥ threshold only)
PreToolUse Task Stashes run_in_background flag (silent)
SubagentStop all 🏁 Background task done: explore auth module

Bash commands that finish in under 10 seconds produce no notification. Tune the threshold with export CLAUDE_NOTIFY_THRESHOLD=15 (seconds).

Foreground subagents produce no notification — Claude is already waiting on them. Only subagents launched with run_in_background: true trigger a 🏁 message when they complete.

All hooks run with "async": true so they never block Claude's execution.

Install

# 1. Put the script on your PATH
mkdir -p ~/.local/bin
cp claude-tmux-notify.sh ~/.local/bin/claude-tmux-notify
chmod +x ~/.local/bin/claude-tmux-notify

# 2. Merge the hooks into your Claude Code user settings
#    If you already have hooks, merge the event arrays manually.
#    Otherwise just copy the file:
cp claude-hooks-settings.json ~/.claude/settings.json

#    Or, to merge into an existing settings.json, use jq:
#    jq -s '.[0] * .[1]' ~/.claude/settings.json claude-hooks-settings.json > /tmp/merged.json \
#      && mv /tmp/merged.json ~/.claude/settings.json

Requirements

  • tmux (any modern version)
  • jq (recommended; the script has a grep/sed fallback but jq is more reliable)
  • Claude Code with hooks support

How it works

  1. Claude Code fires a hook event and passes a JSON payload on stdin.
  2. Stop / Notification — the script always shows a tmux message.
  3. Bash commands — PreToolUse silently writes a start timestamp to $TMPDIR/claude-tmux-notify/. When PostToolUse fires, the script computes elapsed time and only notifies if the command ran ≥ CLAUDE_NOTIFY_THRESHOLD seconds (default 10).
  4. Background subagents — PreToolUse for the Task tool checks tool_input.run_in_background. If true, it stashes the task description. When SubagentStop fires, the script checks for the stashed flag and only notifies for background tasks; foreground subagents are silent.
  5. State files are keyed on session_id + TMUX_PANE, so parallel Claude sessions in different panes don't collide.
  6. Every hook handler sets "async": true so the notification never blocks Claude from continuing.

Optional: persistent pane title

Uncomment the two lines near the bottom of the script to also set the pane border title (visible with pane-border-status enabled in tmux.conf). This gives you a lasting indicator until you clear it:

set -g pane-border-status top
set -g pane-border-format " #{pane_title} "

Customization ideas

  • Threshold: export CLAUDE_NOTIFY_THRESHOLD=30 to only notify for commands taking 30+ seconds. Set to 0 to notify on every bash command.
  • Sound: add printf '\a' to the script for a terminal bell, or paplay /usr/share/sounds/... for a Linux desktop sound.
  • Desktop notification: chain notify-send (Linux) or terminal-notifier (macOS) after the tmux message for cross-app alerts.
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-tmux-notify",
"async": true,
"timeout": 10
}
]
}
],
"SubagentStop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-tmux-notify",
"async": true,
"timeout": 10
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-tmux-notify",
"async": true,
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-tmux-notify",
"async": true,
"timeout": 10
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-tmux-notify",
"async": true,
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-tmux-notify",
"async": true,
"timeout": 10
}
]
}
]
}
}
#!/usr/bin/env bash
# claude-tmux-notify
# Claude Code hook script: displays a tmux status-line message when Claude
# stops, needs input, or finishes a long-running bash command / background task.
#
# Install:
# mkdir -p ~/.local/bin
# cp claude-tmux-notify ~/.local/bin/claude-tmux-notify
# chmod +x ~/.local/bin/claude-tmux-notify
#
# How it works:
# Stop / Notification → always notify
# PreToolUse (Bash) → stash start timestamp (silent)
# PostToolUse (Bash) → notify only if elapsed >= threshold
# PreToolUse (Task) → stash run_in_background flag (silent)
# SubagentStop → notify only if the subagent was background
set -euo pipefail
# --- Configuration -----------------------------------------------------------
# Minimum seconds a bash command must run before triggering a notification.
NOTIFY_THRESHOLD="${CLAUDE_NOTIFY_THRESHOLD:-10}"
# Scratch directory for timestamps and flags between Pre/Post hooks.
STATE_DIR="${TMPDIR:-/tmp}/claude-tmux-notify"
# tmux display duration in milliseconds.
DISPLAY_MS=5000
# -----------------------------------------------------------------------------
# Bail out if we're not inside tmux
if [[ -z "${TMUX:-}" ]]; then
exit 0
fi
# Read the hook payload from stdin
PAYLOAD=$(cat)
# --- Parse JSON payload ------------------------------------------------------
if command -v jq &>/dev/null; then
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name // "Unknown"')
TOOL=$(echo "$PAYLOAD" | jq -r '.tool_name // ""')
TOOL_CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""')
TOOL_DESC=$(echo "$PAYLOAD" | jq -r '.tool_input.description // ""')
RUN_BG=$(echo "$PAYLOAD" | jq -r '.tool_input.run_in_background // false')
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // ""')
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // ""')
else
EVENT=$(echo "$PAYLOAD" | grep -o '"hook_event_name":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')
TOOL=$(echo "$PAYLOAD" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')
TOOL_CMD=""
TOOL_DESC=""
RUN_BG="false"
SESSION_ID=""
CWD=$(echo "$PAYLOAD" | grep -o '"cwd":"[^"]*"' | head -1 | sed 's/.*:"\(.*\)"/\1/')
fi
PROJECT=$(basename "${CWD:-unknown}")
TARGET="${TMUX_PANE:-%0}"
# Unique key for this session+pane (safe for parallel sessions)
KEY="${SESSION_ID:-default}_${TMUX_PANE:-0}"
mkdir -p "$STATE_DIR"
# --- Helpers -----------------------------------------------------------------
short_str() {
local s="$1" max="${2:-40}"
if [[ ${#s} -gt $max ]]; then
echo "${s:0:$(( max - 3 ))}..."
else
echo "$s"
fi
}
notify() {
tmux display-message -d "$DISPLAY_MS" -t "$TARGET" "$1"
}
# --- PreToolUse: stash state, exit silently ----------------------------------
if [[ "$EVENT" == "PreToolUse" ]]; then
# Bash tool: record start timestamp for duration check
if [[ "$TOOL" == "Bash" ]]; then
date +%s > "${STATE_DIR}/bash_${KEY}"
exit 0
fi
# Task tool: record whether this subagent runs in background
if [[ "$TOOL" == "Task" && "$RUN_BG" == "true" ]]; then
echo "$TOOL_DESC" > "${STATE_DIR}/bg_task_${KEY}"
exit 0
fi
exit 0
fi
# --- PostToolUse (Bash): notify only if command was slow ---------------------
if [[ "$EVENT" == "PostToolUse" && "$TOOL" == "Bash" ]]; then
TS_FILE="${STATE_DIR}/bash_${KEY}"
if [[ -f "$TS_FILE" ]]; then
START=$(cat "$TS_FILE")
rm -f "$TS_FILE"
NOW=$(date +%s)
ELAPSED=$(( NOW - START ))
if (( ELAPSED >= NOTIFY_THRESHOLD )); then
SHORT=$(short_str "$TOOL_CMD")
notify "⚡ Done (${ELAPSED}s): ${SHORT}"
fi
fi
exit 0
fi
# --- SubagentStop: notify only if subagent was background --------------------
if [[ "$EVENT" == "SubagentStop" ]]; then
BG_FILE="${STATE_DIR}/bg_task_${KEY}"
if [[ -f "$BG_FILE" ]]; then
DESC=$(cat "$BG_FILE")
rm -f "$BG_FILE"
SHORT=$(short_str "$DESC" 50)
notify "🏁 Background task done: ${SHORT}"
fi
# Foreground subagents: no notification needed
exit 0
fi
# --- Stop / Notification: always notify --------------------------------------
case "$EVENT" in
Stop)
notify "✅ Claude finished in ${PROJECT}"
;;
Notification)
notify "👀 Claude needs input in ${PROJECT}"
;;
*)
notify "🤖 Claude event (${EVENT}) in ${PROJECT}"
;;
esac
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment