Clickable macOS notifications for Claude Code + AeroSpace
A persistent banner you can click — or hotkey — to jump straight back to the terminal window where Claude is waiting.
Problem
Claude Code already nudges you when it needs input (dock badge, optional beep). But if you run multiple sessions across AeroSpace workspaces, the visual signal is trapped wherever that terminal happens to live. You can hear the beep from another workspace, but you have no fast way to get to the right window — especially when several sessions are live.
Goal: a persistent, clickable macOS notification per session. Click it (or hit a keybind) and AeroSpace focuses the exact window that produced it.
Pieces
terminal-notifier— fires banners with an-executeclick-action. Install withbrew install terminal-notifier, then in System Settings → Notifications → terminal-notifier set style to Alerts (persistent).- Claude Code hooks —
UserPromptSubmit,Notification,Stop. aerospace focus --window-id <id>— switches workspace and focuses the window in one shot.- Karabiner-Elements complex modification for the keyboard shortcut.
The window-id capture problem
First instinct: walk up $PPID from the hook process to find which window it's in. Doesn't work — Terminal.app (and most terminals) host every window under a single app PID. On my machine: 5 Terminal windows, all PID 94303.
Trick: capture the window-id at UserPromptSubmit time. When you submit a prompt, your focused AeroSpace window is the session's window, by definition. Cache it per session_id, re-capture on every prompt. That self-heals if you drag the window to a different workspace later.
Hook script
One script, dispatched by arg. Lives at ~/.claude/hooks/aerospace-notify.sh:
#!/usr/bin/env bash
set -uo pipefail
CMD="${1:-}"
CACHE_DIR="$HOME/.claude/hooks/.window-ids"
PENDING_DIR="$HOME/.claude/hooks/.pending"
AEROSPACE="/opt/homebrew/bin/aerospace"
NOTIFIER="/opt/homebrew/bin/terminal-notifier"
mkdir -p "$CACHE_DIR" "$PENDING_DIR"
focused_wid() {
"$AEROSPACE" list-windows --focused --format '%{window-id}' 2>/dev/null | tr -d ' '
}
ack() {
local safe="$1" wid="$2"
[ -n "$wid" ] && "$AEROSPACE" focus --window-id "$wid" 2>/dev/null
[ -n "$safe" ] && rm -f "$PENDING_DIR/$safe"
[ -n "$safe" ] && "$NOTIFIER" -remove "$safe" >/dev/null 2>&1
return 0
}
case "$CMD" in
focus)
ack "${2:-}" "${3:-}"; exit 0 ;;
capture|notify|stop)
INPUT=$(cat)
SID=$(jq -r '.session_id // empty' <<<"$INPUT")
CWD=$(jq -r '.cwd // empty' <<<"$INPUT")
MSG_IN=$(jq -r '.message // empty' <<<"$INPUT")
[ -z "$SID" ] && exit 0
SAFE=$(printf '%s' "$SID" | tr -c 'A-Za-z0-9-_' '_')
CACHE="$CACHE_DIR/$SAFE"
case "$CMD" in
capture)
WID=$(focused_wid)
[ -n "$WID" ] && printf '%s\n' "$WID" > "$CACHE"
rm -f "$PENDING_DIR/$SAFE"
"$NOTIFIER" -remove "$SAFE" >/dev/null 2>&1 || true
;;
notify|stop)
[ ! -f "$CACHE" ] && exit 0
WID=$(cat "$CACHE")
[ "$WID" = "$(focused_wid)" ] && exit 0 # already looking at it
MSG="${MSG_IN:-Waiting for input}"
[ "$CMD" = "stop" ] && MSG="Done"
printf '%s\n' "$WID" > "$PENDING_DIR/$SAFE"
"$NOTIFIER" \
-title "Claude Code" \
-subtitle "$(basename "${CWD:-session}")" \
-message "$MSG" \
-group "$SAFE" \
-execute "$HOME/.claude/hooks/aerospace-notify.sh focus $SAFE $WID" \
>/dev/null 2>&1 || true
;;
esac
;;
esac Key ideas baked in:
- Skip-if-focused: no banner if the session's window is already in front — the existing beep is enough.
-group $session_id: new notifications replace prior ones for the same session. No pile-up.- Pending marker file: written on fire, deleted on any acknowledgement (click, hotkey, or next prompt).
- No
-sender: piggybacking Terminal's bundle breaks-execute— the click just opens Terminal preferences.
Wiring (~/.claude/settings.json)
"hooks": {
"UserPromptSubmit": [{ "hooks": [{
"type": "command",
"command": "$HOME/.claude/hooks/aerospace-notify.sh capture",
"async": true
}]}],
"Notification": [{ "hooks": [
{ "type": "command", "command": "afplay /System/Library/Sounds/Tink.aiff &", "async": true },
{ "type": "command", "command": "$HOME/.claude/hooks/aerospace-notify.sh notify", "async": true }
]}],
"Stop": [{ "hooks": [{
"type": "command",
"command": "$HOME/.claude/hooks/aerospace-notify.sh stop",
"async": true
}]}]
} Keyboard shortcut (Karabiner)
A tiny second script picks the newest pending marker and goes through the same ack path:
#!/usr/bin/env bash
PENDING_DIR="$HOME/.claude/hooks/.pending"
LATEST=$(ls -t "$PENDING_DIR" 2>/dev/null | head -n 1)
[ -z "$LATEST" ] && exit 0
WID=$(cat "$PENDING_DIR/$LATEST")
exec "$HOME/.claude/hooks/aerospace-notify.sh" focus "$LATEST" "$WID" Bind it via a Karabiner complex modification. Drop this JSON at ~/.config/karabiner/assets/complex_modifications/claude-notify-jump.json and enable it from the Karabiner UI:
{
"title": "Claude Code: jump to latest notification",
"rules": [{
"description": "Right Command + J → jump to latest Claude notification window",
"manipulators": [{
"type": "basic",
"from": { "key_code": "j", "modifiers": { "mandatory": ["right_command"] } },
"to": [{ "shell_command": "$HOME/.claude/hooks/aerospace-jump-latest.sh" }]
}]
}]
} right_command, not left — left ⌘+J is bound by too many apps to shadow globally.
Gotchas worth knowing
- On first fire, macOS prompts to allow
terminal-notifierto post notifications. Approve, then set the style to Alerts for persistence. aerospace list-windows --focusedis the trustworthy way to tie a session to a window — PID walking isn't enough.- Stale window-ids (if the user closes the window) cause
aerospace focusto silently no-op. Acceptable. - Hooks edited in
settings.jsononly load on new Claude Code sessions. Restart your terminal sessions after editing.