惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
宝玉的分享
宝玉的分享
The Cloudflare Blog
Apple Machine Learning Research
Apple Machine Learning Research
V
Visual Studio Blog
Last Week in AI
Last Week in AI
Hugging Face - Blog
Hugging Face - Blog
博客园 - 司徒正美
博客园 - 三生石上(FineUI控件)
A
About on SuperTechFans
MyScale Blog
MyScale Blog
aimingoo的专栏
aimingoo的专栏
Microsoft Security Blog
Microsoft Security Blog
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Recent Announcements
Recent Announcements
大猫的无限游戏
大猫的无限游戏
IT之家
IT之家
P
Proofpoint News Feed
L
LangChain Blog
Blog — PlanetScale
Blog — PlanetScale
The GitHub Blog
The GitHub Blog
博客园 - 【当耐特】
Martin Fowler
Martin Fowler

Jacky Wong

Dear Ms. Green A few changes A Few Changes Did You Come Alone? Did You Come Alone? Problems with Vibe Coding pt.2 Problems with Vibe Coding pt.2 Problems with Vibe Coding Problems with Vibe Coding 可能是最后一次更换博客引擎 可能是最后一次更换博客引擎 Dating App Sucks Pt.2
Desktop notifications for Codex CLI and Claude Code
2026-03-10 · via Jacky Wong

Context

This setup was tested on my own machine with:

  • Codex CLI 0.113.0
  • Claude Code 2.1.72
  • macOS 26.3.1 (25D2128)
  • arm64 Apple Silicon

A macOS notification from Codex CLI with the subtitle Notification setup


Start with Claude Code’s official setup

Claude Code already documents the two parts you need:

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

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.ghostty
  • com.googlecode.iterm2
  • com.apple.Terminal
  • com.microsoft.VSCode
  • com.todesktop.230313mzl4w4u92 for Cursor
  • dev.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

  • Notification for permission prompts or other attention-needed states

  • Stop for 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
  • Ghostty also 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-notifier gives me -activate, so click-to-focus still works
  • terminal-notifier gives me -group, so notifications stay scoped per project
  • both Claude Code and Codex CLI behave 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 notify script can still fire
  • I get duplicate notifications for the same task

So the script does two things:

  1. fast path: skip obvious app-like client values
  2. fallback: read thread-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.


References