Native macOS notification system for Claude Code. Get notified when Claude finishes a task or needs your permission — click the notification to jump straight back to your terminal.
- Native macOS notifications via
UNUserNotificationCenter— no third-party dependencies - Click-to-focus: clicking a notification jumps straight to your terminal with full keyboard focus
- Exact-tab focus (Ghostty): on Ghostty, the click lands on the exact tab running that Claude session — even with several tabs open in the same folder — by matching the tab's unique terminal UUID
- Differentiated alerts: distinct sounds and messages for "task complete" vs "permission needed"
- Smart suppression: skips notifications when any terminal is already focused — no noise about what you're looking at
- Claude icon: notifications display the Claude app icon
- Daemon mode: runs persistently in the background, watching a trigger file — no process spawning per notification
- Auto-detects your terminal: focuses whichever terminal you're actually using (Ghostty, Warp, iTerm2, Kitty, Alacritty, WezTerm, Hyper, Terminal.app) — no configuration needed, even with several terminals open at once
- Lightweight: compiled Swift binary, background daemon with zero CPU when idle
- macOS 14+ (tested on macOS 26 Tahoe)
- Swift compiler (
swiftc) — included with Xcode Command Line Tools - Claude.app installed (for the notification icon)
- Claude Code CLI
git clone https://github.com/IARFLOW/ClaudeCodeNotify.git
cd ClaudeCodeNotify
bash scripts/install.shWhen macOS asks to allow notifications, click Allow.
ClaudeCodeNotify runs as a background daemon that watches the file ~/.claude/notify-trigger. Claude Code hooks write notification data to this file, and the daemon picks it up instantly, sends the notification, and clears the file.
The trigger file format is (the last three fields are optional and backward compatible):
message|subtitle|sound|category|term|cwd|tabId
For example: Finished and waiting|Done|Glass.aiff|STOP|ghostty|/Users/me/project|<uuid>
When you click a notification, it activates your terminal with full keyboard focus — no mouse click needed.
The daemon detects which terminal you're using automatically: it tracks the most recently active terminal application, so a click always returns to the terminal you were last working in — even if you have several terminals open, or switched to a browser while waiting. No need to hardcode a terminal.
Exact-tab focus on Ghostty. Ghostty gives each terminal a stable, unique id (UUID) and exposes it over AppleScript. A SessionStart hook captures the UUID of the tab where you launched Claude (at that moment it's the focused tab) and saves it under /tmp/claude-ghostty/<session_id>. The Stop / Notification hooks read it back and pass it in the trigger. On click, the daemon runs focus (first terminal whose id is "<uuid>"), landing on the precise tab — even if several tabs share the same directory. If the UUID is gone (tab closed) it falls back to matching the working directory, then to plain app activation. Never worse than app-level focus.
Add the following to your ~/.claude/settings.json. The SessionStart hook captures the Ghostty tab's UUID; the Stop and Notification hooks read it back and include it in the trigger (they parse stdin with jq):
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "input=$(cat); sid=$(printf '%s' \"$input\" | jq -r '.session_id // empty'); if [ \"$TERM_PROGRAM\" = \"ghostty\" ] && [ -n \"$sid\" ]; then mkdir -p /tmp/claude-ghostty; tid=$(osascript -e 'tell application \"Ghostty\" to return id of focused terminal of selected tab of first window' 2>/dev/null); printf '%s' \"$tid\" > \"/tmp/claude-ghostty/$sid\"; fi"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "input=$(cat); cwd=$(printf '%s' \"$input\" | jq -r '.cwd // empty'); sid=$(printf '%s' \"$input\" | jq -r '.session_id // empty'); tid=$(cat \"/tmp/claude-ghostty/$sid\" 2>/dev/null); printf 'Needs your permission to continue|Action Required|Basso.aiff|PERMISSION_PROMPT|%s|%s|%s' \"$TERM_PROGRAM\" \"$cwd\" \"$tid\" > ~/.claude/notify-trigger"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "input=$(cat); cwd=$(printf '%s' \"$input\" | jq -r '.cwd // empty'); sid=$(printf '%s' \"$input\" | jq -r '.session_id // empty'); tid=$(cat \"/tmp/claude-ghostty/$sid\" 2>/dev/null); printf 'Finished and waiting for your response|Done|Glass.aiff|STOP|%s|%s|%s' \"$TERM_PROGRAM\" \"$cwd\" \"$tid\" > ~/.claude/notify-trigger"
}
]
}
]
}
}Note: The
Stophook fires every time Claude finishes responding. TheNotificationhook withpermission_promptmatcher fires when Claude needs your approval to proceed. TheSessionStarthook only does anything under Ghostty; on other terminals it's a no-op and the click falls back to app-level focus.Requirements for exact-tab focus:
jqon yourPATH, and macOS Automation permission. The first time a hook or the daemon scripts Ghostty, macOS prompts "… wants to control Ghostty" — click Allow (also under System Settings → Privacy & Security → Automation).
By default you don't need to configure anything — the daemon auto-detects the terminal you're using. It recognizes Ghostty, Warp, iTerm2, Kitty, Alacritty, WezTerm, Hyper, and Terminal.app, and resolves which one to focus in this order:
CLAUDE_NOTIFY_TERMINALenvironment variable, if you set one (explicit override)- The terminal that was most recently active (tracked live as you switch apps)
- The first recognized terminal currently running
- Warp, as a last-resort fallback
To force a specific terminal — for example if you run an unlisted one, or always want clicks to land on one terminal — set its bundle identifier:
# Add to your ~/.zshrc or ~/.bashrc
export CLAUDE_NOTIFY_TERMINAL="com.googlecode.iterm2"Common terminal bundle identifiers
| Terminal | Bundle ID |
|---|---|
| Ghostty | com.mitchellh.ghostty |
| Warp | dev.warp.Warp-Stable |
| iTerm2 | com.googlecode.iterm2 |
| Terminal.app | com.apple.Terminal |
| Kitty | net.kovidgoyal.kitty |
| Alacritty | org.alacritty |
| WezTerm | com.github.wez.wezterm |
| Hyper | co.zeit.hyper |
To find any app's bundle ID:
defaults read /Applications/YourTerminal.app/Contents/Info.plist CFBundleIdentifierBasso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
To keep the daemon running automatically, create ~/Library/LaunchAgents/com.iarflow.claudecodenotify.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.iarflow.claudecodenotify</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/ClaudeCodeNotify.app/Contents/MacOS/ClaudeCodeNotify</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>Then load it:
launchctl load ~/Library/LaunchAgents/com.iarflow.claudecodenotify.plistBy default, macOS banners disappear after a few seconds. To make them stay:
- Open System Settings > Notifications
- Find Claude Code in the app list
- Change notification style from Banners to Alerts
If click-to-focus stops activating your terminal even though notifications are still being delivered, macOS's internal notification router (usernoted) may have a stale delegate reference. Reset it with:
killall usernotedusernoted respawns automatically in under a second with no data loss. This is a generic macOS fix, not specific to ClaudeCodeNotify — it's worth knowing for any notification-based app.
-
Check the daemon is running:
pgrep -fl ClaudeCodeNotify
There should be exactly one process. If there are zero, load the LaunchAgent. If there are more than one, something went wrong with install — run
pkill -f ClaudeCodeNotifyand reinstall. -
Test the daemon directly, bypassing Claude Code hooks:
echo 'Test|From terminal|Glass.aiff|IDLE_PROMPT' > ~/.claude/notify-trigger
Run this from an app other than your terminal (otherwise the built-in focus suppression kicks in). If you get a notification, the daemon is healthy and the issue is in your hook configuration.
-
Verify the app has notification permission in System Settings → Notifications → Claude Code.
launchctl unload ~/Library/LaunchAgents/com.iarflow.claudecodenotify.plist
rm ~/Library/LaunchAgents/com.iarflow.claudecodenotify.plist
rm -rf /Applications/ClaudeCodeNotify.appThen remove the hooks section from ~/.claude/settings.json.
MIT