














This setup was tested on my own machine with:
Codex CLI 0.113.0Claude Code 2.1.72macOS 26.3.1 (25D2128)arm64 Apple Silicon
Claude Code already documents the two parts you need:
terminal notifications and terminal integration
hooks for Notification and Stop
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.
Ghostty, duplicate alerts got annoying.That was the point where terminal-notifier became the better base layer.
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 onesBefore 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.
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.230313mzl4w4u92 for Cursordev.zed.ZedThis does not target one exact split or tab. It just brings the app to the front, which is good enough for this workflow.
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
Notification for permission prompts or other attention-needed states
Stop for completion
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"
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.
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:
Approval reminders in Codex are a separate tui.notifications problem.
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"
File: $HOME/.codex/config.toml
notify = ["/Users/you/.codex/notify.sh"]
Use any absolute path you want. I keep the script under ~/.codex/.
I hit one more annoying edge case in Ghostty: duplicate notifications.
What happened was:
terminal-notifierGhostty also surfaced a terminal-native desktop notificationThat 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-notifier gives me -activate, so click-to-focus still worksterminal-notifier gives me -group, so notifications stay scoped per projectClaude Code and Codex CLI behave the same wayGhostty’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.
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:
notify script can still fireSo the script does two things:
client valuesthread-id from the notify payload, query ~/.codex/state_5.sqlite, load the first session_meta line, and skip if originator == "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.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。