Context
This setup was tested on my own machine with:
Codex CLI 0.113.0Claude Code 2.1.72macOS 26.3.1 (25D2128)arm64Apple Silicon
Start with Claude Code’s official setup
Claude Code already documents the two parts you need:
-
terminal notifications and terminal integration
-
hooks for
NotificationandStop
That is the right place to start. On macOS, the most obvious first implementation is also the simplest one: a tiny osascript wrapper.
File: $HOME/.claude/notify-osascript.sh
#!/bin/bash
set -euo pipefail
MESSAGE="${1:-Claude Code needs your attention}"
osascript -e "display notification \"$MESSAGE\" with title \"Claude Code\"" >/dev/null 2>&1 || true
And wire it into Claude’s hooks:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/notify-osascript.sh 'Task completed'"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/notify-osascript.sh 'Claude Code needs your attention'"
}
]
}
]
}
}
This worked, but only technically.
- Clicking the notification did not cleanly bring me back to the terminal app.
- There was no grouping, so notifications piled up.
- Once terminal-native notifications entered the picture, especially in
Ghostty, duplicate alerts got annoying.
That was the point where terminal-notifier became the better base layer.
Why I switched to terminal-notifier
The official repo is here:
Install it with Homebrew:
brew install terminal-notifier
Then verify it:
which terminal-notifier
terminal-notifier -help | head
The three features that made it worth switching:
-activate, so clicking the notification can bring my terminal app to the front-group, so I can keep one live notification per project instead of stacking old ones- better control over subtitle, sound, and macOS notification-center behavior
A shared notification helper
Before touching either tool, create one shared helper:
mkdir -p "$HOME/.local/bin"
File: $HOME/.local/bin/mac-notify.sh
#!/bin/bash
set -euo pipefail
TITLE="${1:?title is required}"
MESSAGE="${2:-}"
SUBTITLE="${3:-}"
GROUP="${4:-}"
SOUND="${5:-Submarine}"
case "${TERM_PROGRAM:-}" in
ghostty) BUNDLE_ID="com.mitchellh.ghostty" ;;
iTerm.app) BUNDLE_ID="com.googlecode.iterm2" ;;
Apple_Terminal) BUNDLE_ID="com.apple.Terminal" ;;
vscode) BUNDLE_ID="com.microsoft.VSCode" ;;
cursor) BUNDLE_ID="com.todesktop.230313mzl4w4u92" ;;
zed) BUNDLE_ID="dev.zed.Zed" ;;
*) BUNDLE_ID="" ;;
esac
TERMINAL_NOTIFIER=""
if [ -x /opt/homebrew/bin/terminal-notifier ]; then
TERMINAL_NOTIFIER="/opt/homebrew/bin/terminal-notifier"
elif command -v terminal-notifier >/dev/null 2>&1; then
TERMINAL_NOTIFIER="$(command -v terminal-notifier)"
fi
if [ -n "$TERMINAL_NOTIFIER" ]; then
ARGS=(
-title "$TITLE"
-message "$MESSAGE"
-sound "$SOUND"
)
if [ -n "$SUBTITLE" ]; then
ARGS+=(-subtitle "$SUBTITLE")
fi
if [ -n "$GROUP" ]; then
ARGS+=(-group "$GROUP")
fi
if [ -n "$BUNDLE_ID" ]; then
ARGS+=(-activate "$BUNDLE_ID")
fi
"$TERMINAL_NOTIFIER" "${ARGS[@]}"
exit 0
fi
SAFE_MESSAGE="${MESSAGE//\\/\\\\}"
SAFE_MESSAGE="${SAFE_MESSAGE//\"/\\\"}"
SAFE_SUBTITLE="${SUBTITLE//\\/\\\\}"
SAFE_SUBTITLE="${SAFE_SUBTITLE//\"/\\\"}"
osascript -e "display notification \"$SAFE_MESSAGE\" with title \"$TITLE\" subtitle \"$SAFE_SUBTITLE\" sound name \"$SOUND\"" >/dev/null 2>&1 || true
Make it executable:
chmod +x "$HOME/.local/bin/mac-notify.sh"
I scope notification groups by tool and project, not by message. That gives me one live Claude Code notification and one live Codex CLI notification per repo instead of a growing stack.
How click-to-focus works
The key line is:
-activate "$BUNDLE_ID"
terminal-notifier accepts a macOS bundle id and activates that app when the notification is clicked.
I map the common values from TERM_PROGRAM:
com.mitchellh.ghosttycom.googlecode.iterm2com.apple.Terminalcom.microsoft.VSCodecom.todesktop.230313mzl4w4u92for Cursordev.zed.Zed
This does not target one exact split or tab. It just brings the app to the front, which is good enough for this workflow.
Claude Code: attention notifications and completion notifications
I split notifications into two categories:
-
Notification: Claude needs me to do something, like approve a permission request or answer a prompt -
Stop: the main agent finished responding -
Notificationfor permission prompts or other attention-needed states -
Stopfor completion
Claude notification script
File: $HOME/.claude/notify.sh
#!/bin/bash
set -euo pipefail
MESSAGE="${1:-Claude Code needs your attention}"
PROJECT_DIR="${PWD:-$HOME}"
PROJECT_NAME="$(basename "$PROJECT_DIR")"
[ "$PROJECT_NAME" = "/" ] && PROJECT_NAME="Home"
PROJECT_HASH="$(printf '%s' "$PROJECT_DIR" | shasum -a 1 | awk '{print $1}' | cut -c1-12)"
GROUP="claude-code:${PROJECT_HASH}"
"$HOME/.local/bin/mac-notify.sh" "Claude Code" "$MESSAGE" "$PROJECT_NAME" "$GROUP"
chmod +x "$HOME/.claude/notify.sh"
Claude hooks configuration
File: $HOME/.claude/settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/notify.sh 'Task completed'"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/notify.sh 'Permission needed'"
}
]
},
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/notify.sh 'Waiting for your input'"
}
]
}
]
}
}
If you do not care about different notification types, an empty matcher "" is enough.
One detail worth remembering: Claude snapshots hooks at startup. If changes do not seem to apply, restart the session. Also check macOS notification permissions if nothing shows up.
Codex CLI: completion notifications
For Codex CLI, the mechanism is not hooks. It is notify.
Official docs:
As of 2026-03-10, Codex documents external notify for supported events like agent-turn-complete.
So in practice:
- completion notifications: yes
- Claude-style permission notifications through the same external script: no
Approval reminders in Codex are a separate tui.notifications problem.
Codex notify script
File: $HOME/.codex/notify.sh
#!/bin/bash
set -euo pipefail
PAYLOAD="${1:-}"
[ -n "$PAYLOAD" ] || exit 0
python3 - "$PAYLOAD" <<'PY'
import json
import pathlib
import sqlite3
import subprocess
import sys
import zlib
from datetime import datetime, timezone
CODEX_HOME = pathlib.Path.home() / '.codex'
def log_skip(reason: str, payload: dict, **extra: object) -> None:
log_path = CODEX_HOME / 'notify-filter.log'
data = {
'ts': datetime.now(timezone.utc).isoformat(),
'reason': reason,
'client': payload.get('client'),
'thread-id': payload.get('thread-id'),
'cwd': payload.get('cwd'),
}
data.update(extra)
with log_path.open('a', encoding='utf-8') as fh:
fh.write(json.dumps(data, ensure_ascii=True) + '\n')
def get_thread_originator(thread_id: str) -> tuple[str, str]:
db_path = CODEX_HOME / 'state_5.sqlite'
if not db_path.exists():
return '', ''
try:
with sqlite3.connect(db_path) as conn:
cur = conn.cursor()
cur.execute('select rollout_path, source from threads where id = ?', (thread_id,))
row = cur.fetchone()
except Exception:
return '', ''
if not row:
return '', ''
rollout_path, source = row
if not rollout_path:
return '', source or ''
try:
first_line = pathlib.Path(rollout_path).read_text(encoding='utf-8', errors='ignore').splitlines()[0]
payload = json.loads(first_line).get('payload', {})
except Exception:
return '', source or ''
return (payload.get('originator') or '').strip(), source or ''
try:
payload = json.loads(sys.argv[1])
except Exception:
raise SystemExit(0)
if payload.get('type') != 'agent-turn-complete':
raise SystemExit(0)
client = (payload.get('client') or '').strip().lower()
if client and ('app' in client or client == 'appserver'):
log_skip('skip-app-client', payload)
raise SystemExit(0)
thread_id = (payload.get('thread-id') or '').strip()
if thread_id:
originator, source = get_thread_originator(thread_id)
if originator == 'Codex Desktop':
log_skip('skip-desktop-originator', payload, originator=originator, source=source)
raise SystemExit(0)
cwd = payload.get('cwd') or ''
subtitle = pathlib.Path(cwd).name if cwd else 'Task completed'
message = (payload.get('last-assistant-message') or 'Task completed').replace('\n', ' ').strip()
if not message:
message = 'Task completed'
if cwd:
group = 'codex-cli:' + format(zlib.crc32(cwd.encode('utf-8')) & 0xFFFFFFFF, '08x')
else:
group = 'codex-cli:' + (payload.get('thread-id') or 'default')
subprocess.run(
[
str(pathlib.Path.home() / '.local' / 'bin' / 'mac-notify.sh'),
'Codex CLI',
message[:180],
subtitle,
group,
],
check=False,
)
PY
chmod +x "$HOME/.codex/notify.sh"
Codex config
File: $HOME/.codex/config.toml
notify = ["/Users/you/.codex/notify.sh"]
Use any absolute path you want. I keep the script under ~/.codex/.
If you use Ghostty, disable terminal-native desktop notifications
I hit one more annoying edge case in Ghostty: duplicate notifications.
What happened was:
- my script sent a notification through
terminal-notifier Ghosttyalso surfaced a terminal-native desktop notification
That produced two macOS notifications for one event.
On my machine, the clean fix was to keep terminal-notifier as the only notification channel and disable Ghostty’s terminal-native desktop notifications:
File: ~/Library/Application Support/com.mitchellh.ghostty/config
desktop-notifications = false
Why I prefer this setup:
terminal-notifiergives me-activate, so click-to-focus still worksterminal-notifiergives me-group, so notifications stay scoped per project- both
Claude CodeandCodex CLIbehave the same way
Ghostty’s config docs describe desktop-notifications as the switch that lets terminal apps show desktop notifications via escape sequences such as OSC 9 and OSC 777. Turning it off avoids the extra notification layer.
If you also use Codex App
This is the part that bit me.
At first I assumed filtering by the client field would be enough. It was not.
On my machine, some sessions started from Codex App looked like this in local session metadata:
{
"originator": "Codex Desktop",
"source": "vscode"
}
That creates a duplicate-notification problem:
- Codex App shows its own notification
- the local CLI
notifyscript can still fire - I get duplicate notifications for the same task
So the script does two things:
- fast path: skip obvious app-like
clientvalues - fallback: read
thread-idfrom thenotifypayload, query~/.codex/state_5.sqlite, load the firstsession_metaline, and skip iforiginator == "Codex Desktop"
That is why the script above checks local thread metadata instead of trusting only client.
I also log skipped events to:
~/.codex/notify-filter.log
That makes debugging much easier if Codex changes its session metadata format later.
This part is based on observed local behavior, not on a stable public contract from the docs. If OpenAI changes how Codex App identifies local sessions in future versions, the filter may need a small update.