If you’ve been wanting Claude Code desktop notifications in iTerm2, you know the problem: kick off a long task, switch to your browser or Slack, and spend the next few minutes periodically alt-tabbing back to see if Claude is done. Or worse — you come back ten minutes later and Claude has been waiting on a permission prompt the whole time.

Here’s how to set up proper macOS desktop notifications that fire when Claude finishes a task or needs a permission decision — and clicking the notification jumps you straight to the right iTerm2 tab.

What You Get with Claude Code Desktop Notifications

  • A macOS notification when Claude Code finishes a task (fires after a 60-second delay — short tasks where you’re watching won’t spam you)
  • A notification when Claude needs a permission decision (after 30 seconds)
  • Smart suppression: no notification if you’re already in that iTerm2 tab
  • Click-to-focus: clicking the notification brings you to the exact iTerm2 tab and session
macOS notification banner showing Claude needs your permission
Claude Code fires a permission-prompt notification — click it to jump straight to the right iTerm2 tab.

Prerequisites

Install terminal-notifier via Homebrew:

brew install terminal-notifier

Then open System Settings → Notifications → terminal-notifier and set the alert style to Alerts (not Banners — Banners auto-dismiss before you can click them).

You’ll also need to grant Automation permission in System Settings → Privacy & Security → Automation for iTerm2. This is required for both the active-tab suppression check and click-to-focus to work.

Three Files to Create

The setup is three files:

  1. ~/.claude/notify-standalone.sh — the hook script that fires on Claude events
  2. ~/.claude/focus_iterm.scpt — the AppleScript that focuses the right tab when you click the notification
  3. A hooks block to add to ~/.claude/settings.json — wires the scripts into Claude Code

The Hook Script

Create ~/.claude/notify-standalone.sh and make it executable with chmod +x ~/.claude/notify-standalone.sh:

#!/usr/bin/env bash

INPUT=$(cat)

HOOK_EVENT=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('hook_event_name',''))" 2>/dev/null)
CWD=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null)

[ -z "$CWD" ] && exit 0

case "$HOOK_EVENT" in
    Stop)
        MESSAGE="Task complete"
        DELAY=60
        ;;
    Notification)
        MESSAGE=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message','Needs your input'))" 2>/dev/null)
        MESSAGE="${MESSAGE:-Needs your input}"
        DELAY=30
        ;;
    *)
        exit 0
        ;;
esac

find_tty() {
    local pid=$$
    while [ "$pid" -gt 1 ]; do
        local tty_short
        tty_short=$(ps -p "$pid" -o tty= 2>/dev/null | tr -d ' ')
        if [ -n "$tty_short" ] && [ "$tty_short" != "??" ]; then
            echo "$tty_short"
            return 0
        fi
        pid=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ')
    done
    return 1
}

TTY_NAME=$(find_tty)

if [ -n "$TTY_NAME" ]; then
    echo "$CWD" > "/tmp/claude-focus-${TTY_NAME}"
fi

check_active_tab() {
    osascript <<'EOF'
tell application "System Events"
    set frontApp to name of first application process whose frontmost is true
end tell
if frontApp is "iTerm2" then
    tell application "iTerm2"
        try
            set curTTY to tty of current session of current tab of current window
            return do shell script "basename " & quoted form of curTTY
        end try
    end tell
end if
return ""
EOF
}

# Upfront check — user is already in this tab, suppress immediately
if [ -n "$TTY_NAME" ]; then
    active_tty=$(check_active_tab)
    if [ "$active_tty" = "$TTY_NAME" ]; then
        exit 0
    fi
fi

sleep "$DELAY"

# Final check — user returned to this tab during the delay
if [ -n "$TTY_NAME" ]; then
    active_tty=$(check_active_tab)
    if [ "$active_tty" = "$TTY_NAME" ]; then
        exit 0
    fi
fi

terminal-notifier \
    -title "Claude Code" \
    -message "$MESSAGE" \
    -sound default \
    -execute "osascript /Users/YOUR_USERNAME/.claude/focus_iterm.scpt '$CWD'"

Replace YOUR_USERNAME with your actual macOS username on the last line. To change the delays, edit DELAY=60 (task completion) and DELAY=30 (permission prompts).

The Focus Script

Create ~/.claude/focus_iterm.scpt:

on run argv
    set targetCWD to item 1 of argv
    tell application "iTerm2"
        repeat with aWindow in windows
            repeat with aTab in tabs of aWindow
                repeat with aSession in sessions of aTab
                    try
                        set ttyPath to tty of aSession
                        set ttyName to do shell script "basename " & quoted form of ttyPath
                        set mapFile to "/tmp/claude-focus-" & ttyName
                        set sessionCWD to do shell script "cat " & quoted form of mapFile
                        if sessionCWD is targetCWD then
                            select aWindow
                            tell aWindow to select aTab
                            tell aTab to select aSession
                            activate
                            return
                        end if
                    end try
                end repeat
            end repeat
        end repeat
    end tell
end run

When you click a notification, this AppleScript loops through all open iTerm2 windows and tabs, reads the TTY map files written by the hook script, and focuses the session whose working directory matches.

Wiring It Into Claude Code

Add this to ~/.claude/settings.json, merging with any existing hooks key. Replace YOUR_USERNAME with your macOS username:

"hooks": {
    "Stop": [{
        "hooks": [{ "type": "command", "command": "/Users/YOUR_USERNAME/.claude/notify-standalone.sh", "async": true }]
    }],
    "Notification": [{
        "matcher": "permission_prompt",
        "hooks": [{ "type": "command", "command": "/Users/YOUR_USERNAME/.claude/notify-standalone.sh", "async": true }]
    }]
}

Both hooks use "async": true so Claude Code doesn’t block while the script sleeps through the delay. The Notification hook uses "matcher": "permission_prompt" to filter out idle_prompt events — you get notified when Claude actually needs a permission decision, not every time it’s waiting for your next message.

How the Suppression Logic Works

The interesting part is the two-checkpoint suppression. Here’s the full flow for a Stop event:

  1. Claude finishes → hook fires notify-standalone.sh asynchronously
  2. Script walks its process tree to find the ancestor TTY (e.g. ttys002), inherited from the iTerm2 shell session
  3. Writes the current working directory to /tmp/claude-focus-ttys002
  4. Upfront check: Is iTerm2 frontmost and is the active tab this session’s TTY? If yes → you’re already here → exit, no notification
  5. Sleep for the event-specific delay (60s for Stop, 30s for permission)
  6. Final check: Same AppleScript check — if you returned during the delay → skip notification
  7. If neither check suppressed it → show the notification
  8. Click → focus_iterm.scpt loops all sessions, matches the CWD, and focuses the right tab

The suppression uses AppleScript rather than TTY mtime. Claude Code’s own UI (status line, cursor updates) continuously writes to the TTY, so the mtime always looks recent regardless of whether you’re actually watching — making it a misleading signal. The AppleScript active-tab check is the right approach.

Suppression Scenarios at a Glance

Scenario Notified?
You’re in this tab when the hook fires No — suppressed immediately
You were away but returned during the delay No — suppressed by final check
You were away for the full delay Yes
You switched to a different iTerm2 tab Yes — active tab TTY doesn’t match
macOS Notification Center showing multiple Claude Code notifications including task complete and permission prompts
Notifications accumulate in the Notification Center — both permission prompts and task-complete alerts show up here.

Gotchas

  • Notification style must be Alerts, not Banners. Banners auto-dismiss before you can click them.
  • Automation permission is required. Grant it in System Settings → Privacy & Security → Automation for iTerm2. Without it, the AppleScript checks fail silently.
  • Click-to-focus only works after the hook has fired at least once in a given shell session — the first run creates the /tmp map file. A brand-new terminal session won’t have it yet.
  • Doesn’t work in VS Code or IntelliJ terminals. Use the Claude Code Notifications plugin for IntelliJ or the Claude Notifications extension for VS Code instead.

Debugging

If notifications aren’t behaving as expected, add a temporary log file. Right after the TTY_NAME=$(find_tty) line, add:

LOG=/tmp/claude-hook-debug.log

Then sprinkle lines like echo "$(date): active=$active_tty TTY=$TTY_NAME" >> "$LOG" at key decision points. Remove the log lines once you’ve identified the issue.

Once this is all wired up, your Claude Code desktop notifications will tap you on the shoulder when Claude needs you — no more nervous tab-checking.

Leave a Reply

Your email address will not be published. Required fields are marked *