every Tech Blog

株式会社エブリーのTech Blogです。

Claude Code を快適に使うための macOS デスクトップ通知セットアップ 〜 alerter と Hook でクリックから VSCode 復帰まで 〜

Claude Code を快適に使うための macOS デスクトップ通知セットアップ
Claude Code を快適に使うための macOS デスクトップ通知セットアップ

 こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。

背景

 弊社ではClaude を非エンジニアも含めた全社に展開しており、業務のあらゆる場面で生成AI の活用を推進しています。

 そんな中、我々のチーム内でも今年3月から本格的にCursor から移行してClaude Code (VSCode 拡張機能)を日常的に使うようになってから、両者の明らかな違いを実感することになりました。

 それは、Cursor が標準搭載しているmacOS デスクトップ通知機能でした。Claude Code にはその機能がないためAgent にプロンプトを投げた後、私自身が他の作業を並行しているとClaude Code 側が permission_prompt のWait でタスクが一向に完了できない状態やタスク完了状態に気付くのが随分遅れてしまうということがしばしばありました(業務効率化のためのAgent ツールなのに、、)。

 Claude Code には Hooks という仕組みが用意されています。これは Stop(応答終了)や Notification(許可待ち等)、PreToolUse(ツール実行直前)といったライフサイクルイベントに対して任意のシェルコマンドを実行できる公式機能で、JSON がイベント情報として標準入力から渡ってきます。

 本記事ではこの Hooks と alerter というコマンドラインツールを組み合わせて、

  • タスク完了・許可待ち・入力待ちのデスクトップ通知を出す
  • 通知をクリックすると、対象プロジェクトの VSCode ウィンドウが自動でアクティブになる(全画面の別アプリ上からでも切り替わる)
  • VSCode 拡張版でも許可待ち通知を取りこぼさない

という環境を構築した内容をまとめます。macOS 26 系(Tahoe)環境で動作確認しています。

なぜ alerter を採用したのか

 macOS から通知を出すだけなら選択肢は複数あります。今回の要件「通知をクリックしたら VSCode がアクティブになる」を満たせるものを比較した結果を表にまとめます。

ツール 通知表示 クリックイベントの取得 備考
terminal-notifier 環境依存 可能(旧来の定番) 公式リポジトリの最新リリースは 2017 年 11 月(v2.0.0)で、近年の macOS での動作不具合 Issue(#307#312#319 ほか)が未解決のままです。私の環境(macOS 26 系)では通知が出ませんでした。
osascriptdisplay notification 動作する 不可 AppleScript 公式ドキュメント(Standard Additions: display notification)に「戻り値なし」と明記されており、クリック結果を取得する手段がありません。
alerter 動作する 可能 公式リポジトリによれば、terminal-notifier を Swift で書き直した後継で、macOS 13.0 以降対応。クリック時に @CONTENTCLICKED / @ACTIONCLICKED を stdout に出力するため、外部プロセスでの後処理が可能です。

 alerter がクリック結果を stdout に返してくれるおかげで、「クリック → open -a "Visual Studio Code" で対象プロジェクトを開く」という連携を、標準ツールの組み合わせだけで実現できました。

1. alerter のインストール

 Homebrew で導入します(公式の導入手順に準拠)。

brew install vjeantet/tap/alerter

 インストール確認:

which alerter   # /opt/homebrew/bin/alerter
alerter --version

2. 通知スクリプトの作成

 2 つのスクリプトを ~/.claude/ に配置し、実行権限を付与します。前者は Stop / Notification hook 用、後者は VSCode 拡張向けの PreToolUse hook 用です。

chmod +x ~/.claude/notify_alerter.sh
chmod +x ~/.claude/notify_pretool.sh

2-1. notify_alerter.sh(Stop / Notification hook 用)

 タスク完了通知および、CLI 版 Claude Code での許可待ち通知を処理します。Hook に渡ってくる JSON の仕様は 公式リファレンスの Stop / Notification セクションに従っています。notification_type として permission_prompt / idle_prompt が返ってくるため、これで分岐しています。

#!/bin/bash

input=$(cat)
echo "$(date '+%H:%M:%S') $input" >> /tmp/claude_notify_debug.log
cwd=$(echo "$input" | jq -r '.cwd')
project=$(basename "$cwd")
notification_type=$(echo "$input" | jq -r '.notification_type')

# ターミナルアプリの Bundle ID を自動検出
get_terminal_bundle_id() {
  if [[ -n "${__CFBundleIdentifier}" ]]; then
    echo "${__CFBundleIdentifier}"
    return
  fi

  case "${TERM_PROGRAM}" in
    "Apple_Terminal") echo "com.apple.Terminal" ;;
    "iTerm.app")      echo "com.googlecode.iterm2" ;;
    "ghostty")        echo "com.mitchellh.ghostty" ;;
    "WarpTerminal")   echo "dev.warp.Warp-Stable" ;;
    *)
      local pid parent comm
      pid=$$
      while [[ "${pid}" -ne 1 ]] 2>/dev/null; do
        parent=$(ps -p "${pid}" -o ppid= 2>/dev/null | tr -d ' ') || break
        [[ -z "${parent}" ]] && break
        comm=$(ps -p "${parent}" -o comm= 2>/dev/null)
        case "${comm}" in
          *Terminal*)  echo "com.apple.Terminal"; return ;;
          *iTerm*)     echo "com.googlecode.iterm2"; return ;;
          *Cursor*)    echo "com.todesktop.230313mzl4w4u92"; return ;;
          *Code*)      echo "com.microsoft.VSCode"; return ;;
          *ghostty*)   echo "com.mitchellh.ghostty"; return ;;
          *warp*)      echo "dev.warp.Warp-Stable"; return ;;
          *)           ;;
        esac
        pid="${parent}"
      done
      echo ""
      ;;
  esac
}

BUNDLE_ID=$(get_terminal_bundle_id)

send_notification() {
  local message="$1"
  local sound="$2"
  local group="$3"
  local args=(--title "Claude Code" --subtitle "${project}" --message "${message}")

  if [[ -n "${sound}" ]]; then
    args+=(--sound "${sound}")
  fi

  args+=(--sender "com.microsoft.VSCode")
  # --group: 同じグループの通知は前のプロセスを自動終了して置き換える
  args+=(--group "${group:-claude-default}")
  # --timeout: プロセスのゾンビ化防止(秒)。通知自体は macOS 通知センターに残る
  local timeout=86400
  local timeout_file="$HOME/.claude/notify_timeout.conf"
  if [[ -f "${timeout_file}" ]]; then
    timeout=$(cat "${timeout_file}" | tr -d '[:space:]')
  fi
  args+=(--timeout "${timeout}")

  # alerter はクリック待ちでブロックするため、nohup + disown で完全にデタッチ
  nohup bash -c "
    result=\$(alerter $(printf '%q ' "${args[@]}") 2>/dev/null)
    if [[ \"\${result}\" == \"@CONTENTCLICKED\" || \"\${result}\" == \"@ACTIONCLICKED\" ]] && [[ -n \"${cwd}\" ]]; then
      open -a \"Visual Studio Code\" \"${cwd}\"
    fi
  " &>/dev/null &
  disown
}

case "${notification_type}" in
  "permission_prompt")
    send_notification "許可待ち" "Ping" "claude-permission"
    ;;
  "idle_prompt")
    send_notification "入力待ち" "Purr" "claude-idle"
    ;;
  "stop")
    send_notification "タスク完了" "Glass" "claude-stop"
    ;;
  *)
    send_notification "通知" "default" "claude-other"
    ;;
esac

2-2. notify_pretool.sh(PreToolUse hook 用)

 こちらは VSCode 拡張環境向けの「許可待ち通知」の代替実装です。詳細は「4. VSCode 拡張での Notification hook の扱い」で後述します。

 ざっくり説明すると、次の 4 つの設定ファイルの permissions.allow リストと照合し、自動許可されないツールの実行前にのみ通知を送るというロジックです。

  • ~/.claude/settings.json(グローバル)
  • ~/.claude/settings.local.json(グローバルローカル)
  • $cwd/.claude/settings.json(プロジェクト)
  • $cwd/.claude/settings.local.json(プロジェクトローカル)
#!/bin/bash

# PreToolUse hook: 許可が必要なツール実行前に通知を送る
# settings.json の allow リストにマッチするツールはスキップする

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
cwd=$(echo "$input" | jq -r '.cwd')
project=$(basename "$cwd")

# 常に自動許可されるツール(通知不要)
case "${tool_name}" in
  Glob|Grep|TodoWrite|Agent|Skill|ToolSearch|SendMessage)
    exit 0
    ;;
esac

# ユーザー個別のスキップリスト(~/.claude/notify_skip_tools.txt)
SKIP_FILE="$HOME/.claude/notify_skip_tools.txt"
if [[ -f "${SKIP_FILE}" ]]; then
  while IFS= read -r skip_tool; do
    [[ -z "${skip_tool}" || "${skip_tool}" == \#* ]] && continue
    if [[ "${tool_name}" == "${skip_tool}" ]]; then
      exit 0
    fi
  done < "${SKIP_FILE}"
fi

# allow リストと照合する関数
check_allow_list() {
  local settings_file="$1"
  [[ -f "${settings_file}" ]] || return

  # Bash ツール: コマンドプレフィックスで照合
  if [[ "${tool_name}" == "Bash" ]]; then
    local command
    command=$(echo "$input" | jq -r '.tool_input.command')
    while IFS= read -r pattern; do
      if [[ "${pattern}" =~ ^Bash\((.+)(:\*|\*)?\)$ ]]; then
        local prefix="${BASH_REMATCH[1]}"
        prefix="${prefix%:*}"
        if [[ "${command}" == "${prefix}"* ]]; then
          exit 0
        fi
      fi
    done < <(jq -r '.permissions.allow[]' "${settings_file}" 2>/dev/null)
  fi

  # Read ツール: パスパターンで照合
  if [[ "${tool_name}" == "Read" ]]; then
    local file_path
    file_path=$(echo "$input" | jq -r '.tool_input.file_path')
    while IFS= read -r pattern; do
      if [[ "${pattern}" =~ ^Read\(//(.+)\)$ ]]; then
        local path_pattern="${BASH_REMATCH[1]}"
        local path_prefix="${path_pattern%%/**}"
        if [[ "${file_path}" == "${path_prefix}"* ]]; then
          exit 0
        fi
      fi
    done < <(jq -r '.permissions.allow[]' "${settings_file}" 2>/dev/null)
  fi

  # MCP ツール・WebSearch 等: 完全一致で照合
  while IFS= read -r pattern; do
    if [[ "${pattern}" == "${tool_name}" ]]; then
      exit 0
    fi
  done < <(jq -r '.permissions.allow[]' "${settings_file}" 2>/dev/null)
}

# グローバル設定
check_allow_list "$HOME/.claude/settings.json"
check_allow_list "$HOME/.claude/settings.local.json"

# プロジェクト設定
check_allow_list "$cwd/.claude/settings.json"
check_allow_list "$cwd/.claude/settings.local.json"

# 許可リストにマッチしない → 通知を送る
echo "$(date '+%H:%M:%S') PRETOOL_NOTIFY: ${tool_name}" >> /tmp/claude_notify_debug.log

nohup bash -c "
  timeout=86400
  timeout_file=\"\$HOME/.claude/notify_timeout.conf\"
  if [[ -f \"\${timeout_file}\" ]]; then
    timeout=\$(cat \"\${timeout_file}\" | tr -d '[:space:]')
  fi
  result=\$(alerter --title 'Claude Code' --subtitle '${project}' --message '許可待ち: ${tool_name}' --sound Ping --sender com.microsoft.VSCode --group claude-pretool --timeout \"\${timeout}\" 2>/dev/null)
  if [[ \"\${result}\" == '@CONTENTCLICKED' || \"\${result}\" == '@ACTIONCLICKED' ]] && [[ -n '${cwd}' ]]; then
    open -a 'Visual Studio Code' '${cwd}'
  fi
" &>/dev/null &
disown

exit 0

3. Claude Code の hooks 設定

 ~/.claude/settings.jsonhooks セクションに以下を追加します(公式リファレンスの書式に準拠)。

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"cwd\": \"'\"$(pwd)\"'\", \"notification_type\": \"stop\"}' | ~/.claude/notify_alerter.sh"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/notify_alerter.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/notify_pretool.sh"
          }
        ]
      }
    ]
  }
}

各 Hook の役割

Hook 発火タイミング 用途 VSCode 拡張 CLI
Stop Claude が応答を終えて停止したタイミング 「タスク完了」通知 動作する 動作する
Notification 許可待ち・入力待ちなどの通知イベント 「許可待ち」「入力待ち」通知 permission_prompt が発火しないケースあり 動作する
PreToolUse ツール実行の直前 VSCode での「許可待ち」通知の代替 動作する 動作する

4. VSCode 拡張での Notification hook の扱い

 公式リファレンスでは、Notification hook の notification_type として permission_prompt / idle_prompt / auth_success / elicitation_dialog の 4 種が定義されています。しかし、私の環境で動作確認したところ、VSCode 拡張版では許可ダイアログが出ても Notification hook(permission_prompt)が発火しないケースがあり、「許可待ちなのに通知が来ない」という状態になっていました。CLI 版では同じ設定で期待どおり発火しています。

 そのため、VSCode 拡張で使う場合は PreToolUse hook(必ず発火する)でツール実行直前に自前で判定するという回避策を取っています。流れは以下です。

  1. PreToolUse hook がツール実行直前に発火する
  2. notify_pretool.sh がツール名(と Bash の場合はコマンド、Read の場合はファイルパス)を受け取り、4 つの設定ファイルの permissions.allow と照合する
  3. allow リストにマッチしなかったときだけ通知を送る(=「このあと許可ダイアログが出るはず」というタイミング)

 この方式であれば、Notification hook の発火有無にかかわらず、VSCode でも CLI でも漏れなく許可待ち通知を届けられます。CLI 版では Notification hook が正常動作するため、重複しないよう --groupclaude-permissionclaude-pretool で分けています(後述)。

5. macOS のセキュリティ許可

 alerteropen -a の組み合わせは、macOS のアクセシビリティ・オートメーション等の追加許可なしで動作しました。初回のみ通知センター側で通知の表示許可を求められる程度で、特別な設定は不要です。

6. 動作確認

通知テスト

# タスク完了通知
echo '{"cwd": "'$(pwd)'", "notification_type": "stop"}' | ~/.claude/notify_alerter.sh

# 許可待ち通知(CLI の Notification hook 用)
echo '{"cwd": "'$(pwd)'", "notification_type": "permission_prompt"}' | ~/.claude/notify_alerter.sh

確認項目

  • タスク完了通知がデスクトップに表示される
  • 許可待ち通知が表示される(VSCode: PreToolUse / CLI: Notification)
  • VSCode アイコンが通知に表示される(--sender com.microsoft.VSCode
  • 通知をクリックすると対象プロジェクトの VSCode ウィンドウがアクティブになる
  • 全画面の別アプリ(Chrome 等)から通知をクリックしても正しいウィンドウに切り替わる
  • 通知後に Claude が WAIT 状態にならず即座に続行する

デバッグログ

 通知が来ないときはデバッグログを確認します:

tail -f /tmp/claude_notify_debug.log

7. alerter のプロセス管理で学んだこと

 運用してみて一番ハマったのがプロセス管理です。

問題: プロセスのゾンビ化

 alerterクリックされるまで stdout をブロックし続ける仕様です(公式リポジトリの README にある @CONTENTCLICKED / @ACTIONCLICKED / @TIMEOUT / @CLOSED のいずれかが出力されるまでプロセスが生きる)。通知バッジを macOS 通知センターから消去しても alerter プロセスは終了しません。放置すると各プロセスがメモリを消費し、長時間の利用で数 GB に達するケースがありました。

対策1: --group(プロセス蓄積の防止)

 同じ --group の通知が新たに発行されると、前のプロセスが自動で kill されます。グループは用途別に分けており、同時に存在するプロセスは最大 4 つになる設計です:

グループ 用途
claude-stop タスク完了
claude-permission 許可待ち(CLI Notification hook)
claude-pretool 許可待ち(VSCode PreToolUse hook)
claude-idle 入力待ち

対策2: --timeout(最終的なプロセス回収)

 --group だけでは最後の 4 プロセスが残り続けるため、--timeout でプロセスの最大生存時間を設定して確実に回収します。

  • デフォルト: 86400 秒(1 日)
  • カスタム: ~/.claude/notify_timeout.conf に秒数を書く
# 例: 2 時間に変更
echo 7200 > ~/.claude/notify_timeout.conf

 なお、timeout が切れてもプロセスが終了するだけで、macOS 通知センターの通知バッジは残ります。

溜まったプロセスの手動クリーンアップ

# alerter プロセス数を確認
ps aux | grep alerter | grep -v grep | wc -l

# 全 alerter プロセスを終了
pkill -f alerter

8. なぜ nohup + disown が必要だったか

 前述のとおり alerter はクリック待ちでブロックします。単純に (...) & でバックグラウンド実行しても、Claude Code の hook ランナーが子プロセスの終了を待ってしまい、Claude 本体が WAIT 状態のまま止まる(トークンも消費し続けてしまう)という問題がありました。

 nohup ... & で SIGHUP を無視させ、さらに disown でジョブテーブルから外すことで、hook プロセスから完全に切り離せます。これにより、通知の表示・クリック待ちとは独立して Claude が動作を継続できるようになりました。

9. 通知のカスタマイズ

特定ツールの通知をスキップする

 VSCode の「Edit Automatically」などセッションレベルで自動許可しているツールは settings.json に記録されないため、~/.claude/notify_skip_tools.txt に 1 行 1 ツール名で記載する仕組みを入れてあります:

# セッションレベルで自動許可しているツール名を 1 行 1 つで記載
Edit

 もしくは notify_pretool.sh の先頭付近にあるスキップリスト(Glob|Grep|TodoWrite|...)に追記する方法でも同等です。

サウンド

 macOS 標準のサウンド名を指定できます: Ping, Purr, Glass, default, Basso, Blow, Bottle, Frog, Funk, Hero, Morse, Pop, Sosumi, Submarine, Tink

--sender(通知アイコン)

 --sender に Bundle ID を指定すると通知アイコンが変わります。現在は com.microsoft.VSCode を指定して VSCode アイコンを表示しています。

アプリ Bundle ID
VSCode com.microsoft.VSCode
Cursor com.todesktop.230313mzl4w4u92
Terminal com.apple.Terminal
iTerm2 com.googlecode.iterm2
Ghostty com.mitchellh.ghostty

 ただし --sender を指定すると、そのアプリの macOS 通知設定に依存することになります。対象アプリの通知を OFF にしていると通知が表示されなくなるため注意が必要です。

まとめ

 本記事では、Claude Code の Hooks 機能と alerter を組み合わせて、

  • タスク完了・許可待ち・入力待ちのデスクトップ通知を出す
  • 通知クリックでプロジェクトの VSCode ウィンドウを自動でアクティブにする
  • VSCode 拡張でも PreToolUse hook で許可待ち通知を取りこぼさない

というセットアップ方法と、その過程で踏んだプロセス管理の落とし穴(ゾンビ化 → --group / --timeout / nohup + disown での回収)をご紹介しました。

 Claude Code をバックグラウンドで走らせつつ他の作業を並行して進めるスタイルにおいては、「気づかずに長時間止まっていた」という時間を減らすだけで、体感の生産性が目に見えて向上します。CLI と VSCode 拡張で挙動が異なる部分は PreToolUse hook で吸収できるので、Hooks の仕様を把握したうえで自分の開発スタイルに合わせてカスタマイズしてみてください。

通知例
通知例

最後に

エブリーでは、ともに働く仲間を募集しています。

テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください!

corp.every.tv