Claude Code × tmux もう完了通知を見逃さない通知設定

開発

Claude Codeが裏のペインで作業しているとき、完了に気づけないことない?

afplayでシステム音を鳴らす手もあるけど、イヤホンなしだと環境音に紛れて聞き逃すんだよね。macOSの通知センターに出してくれれば、どのアプリで作業していても視覚的に気づける。というわけでHooksで通知を実装しようとしたら、3回ハマった。

NotifiCLIは使えなかった

最初に試したのはNotifiCLI。SwiftUIベースでmacOSネイティブの通知が出せるツール。

ところが-actionsオプションを付けるとユーザーの操作を待つブロッキング型の動作になる。hookがユーザーの操作を待ち続けるので、その間Claude Codeの処理が止まってしまう。

ここで30分溶かした。NotifiCLIのREADMEにはブロッキングの挙動が書いてあるんだけど、使う前にちゃんと読まなかった自分が悪い。

筆者

terminal-notifierで解決した

terminal-notifierはノンブロッキング型。通知を表示してすぐreturnするので、hookのタイムアウトに引っかからない。

brew install terminal-notifier

-executeオプションを使うと、通知をクリックしたときに任意のコマンドを実行できる。これが後で重要になる。

設定は2つのイベントに分けている。

イベント用途
Stopタスク完了Hero
Notification入力待ちGlass

Stopは「作業が終わったよ」、Notificationは「入力を待ってるよ」という使い分け。

最後のプロンプトを通知に表示する

「完了しました」だけの通知だと、複数セッション動かしているときにどのタスクが終わったのかわからない。

Hooksはstdinでセッション情報をJSON形式で受け取れる。その中にtranscript_path(会話ログのパス)が含まれているので、ここから最後のユーザープロンプトを抽出して通知の本文に表示するようにした。

# stdinからhookデータを読み取り
STDIN_DATA=$(cat)

# transcript_pathを取得
TRANSCRIPT_PATH=$(echo "$STDIN_DATA" | \
    python3 -c "import sys,json; \
    print(json.load(sys.stdin).get('transcript_path',''))" \
    2>/dev/null)

実際の通知はこんな感じ。

Claude Code完了通知のスクリーンショット。タイトル「Claude Code」、サブタイトル「完了」、メッセージに最後のプロンプトが表示されている

要素内容
-title固定Claude Code
-subtitleイベント種別完了 / 入力待ち
-message最後のプロンプト「テストを全部通して」

指示した内容がそのまま通知に出るので、どのタスクが終わったか一目でわかる。複数セッションを並行で動かしているときに特に便利。

最初は「Task completed」としか出してなくて、通知来ても「どれ?」ってなってた。プロンプトを出すようにしたら体験が全然違う。

筆者

-executeの罠: tmux command not found

terminal-notifierの-executeで通知クリック時にtmuxコマンドを実行しようとしたら、tmux: command not found

原因は、-executeのコマンドが/bin/shで実行されること。/bin/shのPATHに/opt/homebrew/binが含まれていないから、Homebrewでインストールしたtmuxが見つからない。

# /bin/sh のデフォルトPATH
/usr/bin:/bin:/usr/sbin:/sbin

# tmux の実際のパス
/opt/homebrew/bin/tmux

解決策はシンプルで、フルパスを指定するだけ。

TMUX_BIN="/opt/homebrew/bin/tmux"
OSASCRIPT_BIN="/usr/bin/osascript"

通知タップでペインに戻る

terminal-notifierの通知をタップしたとき、Claude Codeが動いているtmuxペインに直接フォーカスしたい。やりたいことは3つ。

  1. Ghosttyをアクティブにする(別アプリで作業中の場合)
  2. tmuxのウィンドウを選択する
  3. tmuxのペインを選択する

select-windowだけだとウィンドウ内の最後にフォーカスしたペインが選ばれるので、ペイン単位の指定が必要になる。

# Ghosttyをアクティブ → ウィンドウ選択 → ペイン選択
osascript -e 'tell application "Ghostty" to activate'
sleep 0.3
tmux select-window -t 'session:1'
tmux select-pane -t 'session:1.0'

ここで重要なのが-groupオプション。Claude Codeが短時間に複数回hookを発火させると通知が連続で飛んでくる。-groupで同じキーを指定すると、同一グループの通知は上書きされるのでスパムを防げる。

terminal-notifier \
    -group "claude-code-${TMUX_SESSION}-${TMUX_WINDOW}" &

コピペで使える設定テンプレート

ここまでの内容を全部まとめたスクリプトと設定ファイル。

notify-complete.sh

#!/bin/bash
# Claude Code完了通知スクリプト
# terminal-notifier使用(ノンブロッキング)
# 通知タップ時に該当tmuxペインをアクティブにする
#
# Usage: notify-complete.sh [stop|notification]

EVENT_TYPE="${1:-stop}"

# stdinからhookデータを読み取り
STDIN_DATA=$(cat)

# transcript_pathから最後のユーザープロンプトを抽出
LAST_PROMPT=""
TRANSCRIPT_PATH=$(echo "$STDIN_DATA" | \
    python3 -c "import sys,json; \
    print(json.load(sys.stdin).get('transcript_path',''))" \
    2>/dev/null)

if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
    LAST_PROMPT=$(python3 -c "
import json, sys
last = ''
with open('$TRANSCRIPT_PATH') as f:
    for line in f:
        data = json.loads(line)
        if data.get('type') == 'user':
            msg = data.get('message', {})
            if isinstance(msg, dict):
                content = msg.get('content', '')
                if isinstance(content, list):
                    for c in content:
                        if isinstance(c, dict) and c.get('type') == 'text':
                            text = c['text'].strip()
                            if text and not text.startswith('<'):
                                last = text
                elif isinstance(content, str) and not content.startswith('<'):
                    last = content.strip()
# 60文字で切り詰め
if len(last) > 60:
    last = last[:57] + '...'
print(last)
" 2>/dev/null)
fi

# イベント種別で音とメッセージを分ける
if [ "$EVENT_TYPE" = "notification" ]; then
    SOUND="Glass"
    MESSAGE="入力待ち"
else
    SOUND="Hero"
    MESSAGE="完了"
fi

# 最後のプロンプトがあれば通知メッセージに使う
NOTIFY_MESSAGE="${LAST_PROMPT:-${MESSAGE}}"

# TMUX_PANEから現在のペイン情報を取得
if [ -n "$TMUX_PANE" ]; then
    TMUX_SESSION=$(tmux display-message -t "$TMUX_PANE" \
        -p '#{session_name}' 2>/dev/null)
    TMUX_WINDOW=$(tmux display-message -t "$TMUX_PANE" \
        -p '#{window_index}' 2>/dev/null)
    TMUX_WINDOW_NAME=$(tmux display-message -t "$TMUX_PANE" \
        -p '#{window_name}' 2>/dev/null)
    TMUX_PANE_INDEX=$(tmux display-message -t "$TMUX_PANE" \
        -p '#{pane_index}' 2>/dev/null)
else
    TMUX_SESSION=$(tmux display-message \
        -p '#{session_name}' 2>/dev/null)
    TMUX_WINDOW=$(tmux display-message \
        -p '#{window_index}' 2>/dev/null)
    TMUX_WINDOW_NAME=$(tmux display-message \
        -p '#{window_name}' 2>/dev/null)
    TMUX_PANE_INDEX=$(tmux display-message \
        -p '#{pane_index}' 2>/dev/null)
fi

# tmux外で実行された場合はフォールバック
if [ -z "$TMUX_SESSION" ] || [ -z "$TMUX_WINDOW" ]; then
    osascript -e "display notification \"${NOTIFY_MESSAGE}\" \
        with title \"Claude Code\"" &
    afplay "/System/Library/Sounds/${SOUND}.aiff" &
    exit 0
fi

# -execute は /bin/sh で実行されるためフルパスが必要
TMUX_BIN="/opt/homebrew/bin/tmux"
OSASCRIPT_BIN="/usr/bin/osascript"

# クリック時のコマンド:
# ターミナルをアクティブ → tmuxウィンドウ選択 → ペイン選択
CLICK_CMD="${OSASCRIPT_BIN} -e \
    'tell application \"Ghostty\" to activate'; \
    sleep 0.3; \
    ${TMUX_BIN} select-window \
        -t '${TMUX_SESSION}:${TMUX_WINDOW}'; \
    ${TMUX_BIN} select-pane \
        -t '${TMUX_SESSION}:${TMUX_WINDOW}.${TMUX_PANE_INDEX}'"

# terminal-notifierで通知(ノンブロッキング)
terminal-notifier \
    -title "Claude Code" \
    -subtitle "${MESSAGE}" \
    -message "${NOTIFY_MESSAGE}" \
    -sound "${SOUND}" \
    -execute "${CLICK_CMD}" \
    -group "claude-code-${TMUX_SESSION}-${TMUX_WINDOW}" &

スクリプトを配置して実行権限を付ける。

chmod +x ~/.config/claude/scripts/notify-complete.sh

settings.json(hooks部分)

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.config/claude/scripts/notify-complete.sh stop"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.config/claude/scripts/notify-complete.sh notification"
          }
        ]
      }
    ]
  }
}

~/.claude/settings.json(グローバル)か.claude/settings.json(プロジェクト)のどちらにでも置ける。既存のhooks設定がある場合は、StopNotificationの配列に追加するだけ。

tmux.conf(bell設定)

# 他ウィンドウのbell検知を有効化
set -g monitor-bell on
set -g bell-action any

この設定がないと、tmux上でClaude Codeが別ウィンドウにいる場合にbell通知が伝播しない。

まとめ

macOS通知の実装で踏んだ3つの罠を振り返る。

  1. NotifiCLIのブロッキング: -actionsがユーザー操作を待つためClaude Codeの処理が止まる。ノンブロッキング型のterminal-notifierを選ぶ
  2. /bin/shのPATH問題: -execute/bin/shで実行される。Homebrewのバイナリはフルパス指定が必要
  3. ペインフォーカス: select-windowだけではペインが特定できない。select-paneも組み合わせて、-groupで通知スパムを防止

afplayの音だけだと気づけなかった完了通知が、視覚的に確認できるようになった。特にブラウザで調べ物をしている最中に通知が飛んでくるのは体験としてかなり良い。

Claude Code Hooksを遊び倒す|海外勢のネタ設定が面白すぎた開発 ターミナルの主役はClaude Code中心になった! Claude Codeファーストなターミナル構築開発 Ghostty × tmux CmdキーだけでtmuxをChromeのように操作する設定テンプレートMac
Thanks for reading!
Claude CodeHooksterminal-notifiertmuxmacOS