クラウドサインのエンジニアをしている辻@t0daaayです。
Claude Code の公式ドキュメントにタスク完了時の通知方法が掲載されています。
しかし、VSCode 上のターミナルで Claude Code を使いたい場合は通知音を鳴らすことしかできず、タスク完了に気づけないことが多々ありました。
iterm2 を使えば OS 通知させることは可能なようですが、VSCode の Claude Code の拡張機能の恩恵を受けられる点で VSCode 上で実行したいという気持ちがありました。
そんな中、X で AI エージェントのタスク完了を OS の通知として出力させている人を見かけました。
Claude Codeちゃんと作業報告してくれるようになって最高。通知音も質問と報告でちゃんと使い分けてくれる。 pic.twitter.com/ZOwTTQ0dB3
— おきたかめごろう (@okita_kamegoro) June 20, 2025
Claude Codeの通知問題、ローカルに生成されるファイル読み取って通知させれば解決するので最強
— ミロ (@ml0_1337) June 3, 2025
・好きな通知音が設定できる
・Cursorのターミナル経由でも通知来る
・プロジェクト名を通知タイトルに表示できる
・チャットのサマリーがサブタイトルに表示される pic.twitter.com/orUxFmE4f7
私も実際に 2 つの手法で実際に試してみました。
前提
- macOS 環境です。
- AppleScript を使って通知させています。試したい場合は、システム設定 → 通知 → スクリプトエディタで通知を許可してください。
やり方 1: ルールファイルで通知させる
CLAUDE.md
へのルールを追加で対応します。
ポップアップによる通知だけではなく、通知音も鳴らしてもらうように設定しています。
## 通知 タスク完了時に以下を実行してください。 ### 実装完了時 `osascript -e 'display notification "{{...の実装を完了しました}}" with title "実装完了" sound name "Glass"'` ### 提案完了時 `osascript -e 'display notification "{{...を提案しました}}" with title "提案" sound name "Funk"'` ### その他完了時 `osascript -e 'display notification "{{...を完了しました}}" with title "...完了" sound name "Submarine"'`
※利用可能な通知音(sound)は ls /System/Library/Sounds | sed 's/\.aiff$//'
で確認可能です。
実際の通知
タスク完了後、通知音と共に OS 通知を表示してくれるようになりました。
問題点
このやり方は設定が簡単である一方で、あくまでルールファイルへの記述のため、ルールが無視されて通知されないケースが発生することを確認しています。
またコマンド実行の許可を求められた際に通知してくれないので、気づかないうちに作業が止まっているケースが発生しました。
それらを解決するために、Claude Code の会話ログをフックに通知したいと考えました。
やり方 2: Claude Code の会話ログをフックに通知させる
やったこと
Claude Code では現在、.claude/projects
配下に、ワークスペースごとの会話履歴が .jsonl
ファイルとして蓄積されます。この履歴の更新を検知することで、メッセージ受信時に通知をさせるツールを作成しました。
実装
Claude Code の会話ログ(.jsonl
ファイル)をリアルタイム監視して、Claude の返答やツール使用時に OS 通知を送るスクリプトをバイブコーディングで作成しました。
ソースコード
社内ではこれを npm パッケージとして共有しています。
ソースコード
#!/usr/bin/env bash # Claude Code Monitor # プロジェクト配下の会話履歴ファイルの変更を監視してOS通知を表示 # =========================================== # 設定値 # =========================================== # デフォルト設定 DEFAULT_BASE_DIR="$HOME/.claude" DEFAULT_PROJECTS_SUBDIR="projects" DEFAULT_SCRIPT_NAME="Claude Code Monitor" DEFAULT_MAX_CONTENT_LENGTH=100 DEFAULT_ASSISTANT_EMOJI="🤖" DEFAULT_STARTUP_SOUND="Ping" DEFAULT_NOTIFICATION_SOUND="Glass" DEFAULT_FILE_EXTENSION=".jsonl" # 環境変数またはデフォルト値を使用 readonly BASE_DIR="${CLAUDE_BASE_DIR:-$DEFAULT_BASE_DIR}" readonly PROJECTS_DIR="${CLAUDE_PROJECTS_DIR:-$BASE_DIR/$DEFAULT_PROJECTS_SUBDIR}" readonly SCRIPT_NAME="${CLAUDE_SCRIPT_NAME:-$DEFAULT_SCRIPT_NAME}" readonly MAX_CONTENT_LENGTH="${CLAUDE_MAX_CONTENT_LENGTH:-$DEFAULT_MAX_CONTENT_LENGTH}" readonly ASSISTANT_EMOJI="${CLAUDE_ASSISTANT_EMOJI:-$DEFAULT_ASSISTANT_EMOJI}" readonly STARTUP_SOUND="${CLAUDE_STARTUP_SOUND:-$DEFAULT_STARTUP_SOUND}" readonly NOTIFICATION_SOUND="${CLAUDE_NOTIFICATION_SOUND:-$DEFAULT_NOTIFICATION_SOUND}" readonly FILE_EXTENSION="${CLAUDE_FILE_EXTENSION:-$DEFAULT_FILE_EXTENSION}" # =========================================== # 設定ファイル読み込み # =========================================== # 設定ファイルを読み込む関数 load_config_file() { local config_file="${1:-$HOME/.claude-monitor.conf}" if [[ -f "$config_file" ]]; then echo "[$SCRIPT_NAME] 設定ファイルを読み込み中: $config_file" # shellcheck source=/dev/null source "$config_file" fi } # 設定ファイルを読み込み load_config_file "$CLAUDE_CONFIG_FILE" # =========================================== # ユーティリティ関数 # =========================================== # JSON解析関数(sedベース) parse_json_field() { local json_line="$1" local field="$2" # message配下のフィールドを抽出 if [[ "$field" == "type" ]]; then # typeフィールドではなくroleフィールドを抽出 echo "$json_line" | sed -n 's/.*"message":{.*"role":"\([^"]*\)".*/\1/p' elif [[ "$field" == "role" ]]; then echo "$json_line" | sed -n 's/.*"message":{.*"role":"\([^"]*\)".*/\1/p' else echo "$json_line" | sed -n "s/.*\"$field\":\"\([^\"]*\)\".*/\1/p" fi } # JSON配列から最初のtext typeのcontentを抽出 extract_text_content() { local json_line="$1" # message.content配列内のtextタイプを探す local content_part=$(echo "$json_line" | sed -n 's/.*"message":{.*"content":\[\([^]]*\)\].*/\1/p') if [[ -n "$content_part" ]]; then # textタイプのcontentを抽出 echo "$content_part" | sed -n 's/.*"type":"text".*"text":"\([^"]*\)".*/\1/p' fi } # 通知を送信する関数 send_notification() { local title="$1" local message="$2" local sound="$3" if [[ -z "$title" || -z "$message" ]]; then echo "[エラー] 通知パラメータが不正です" >&2 return 1 fi osascript -e "display notification \"$message\" with title \"$title\" sound name \"${sound:-$NOTIFICATION_SOUND}\"" 2>/dev/null || { echo "[エラー] 通知の送信に失敗しました" >&2 return 1 } } # プロジェクト名を抽出する関数 get_project_name() { local file_path="$1" if [[ ! -f "$file_path" ]]; then echo "不明なプロジェクト" return 1 fi local project_dir=$(dirname "$file_path") local project_name=$(basename "$project_dir") # プロジェクト名をデコード(-Users-username-dev-xxx -> xxx) # ユーザー名部分を動的に取得 local username=$(whoami) echo "$project_name" | sed "s/^-Users-$username-[^-]*-//" | sed 's/-/ /g' } # ファイルの最終行を取得する関数 get_last_line() { local file_path="$1" if [[ ! -f "$file_path" ]]; then return 1 fi tail -n 1 "$file_path" 2>/dev/null || return 1 } # ファイルに通知対象メッセージが含まれているかチェックする関数 check_notification_target() { local file_path="$1" local last_line=$(get_last_line "$file_path") [[ -z "$last_line" ]] && return 1 # sedでrole fieldを抽出 local role_type=$(parse_json_field "$last_line" "role") if [[ "$role_type" == "assistant" ]]; then return 0 elif [[ "$role_type" == "tool_result" ]]; then # tool_resultの場合はstatus:completedのみ通知対象 local status=$(parse_json_field "$last_line" "status") [[ "$status" == "completed" ]] && return 0 fi # grepでも確認(message配下のroleを検索) if echo "$last_line" | grep -q '"message":{.*"role":"assistant"'; then return 0 elif echo "$last_line" | grep -q '"type":"tool_result"' && echo "$last_line" | grep -q '"status":"completed"'; then return 0 fi return 1 } # テキストを整形する関数 format_text() { local text="$1" echo "$text" | tr '\n' ' ' | sed 's/\\n/ /g' | sed 's/\\t/ /g' | sed 's/\\"/"/g' | cut -c1-"$MAX_CONTENT_LENGTH" } # Assistant の返答内容を抽出する関数 extract_assistant_content() { local file_path="$1" local last_line=$(get_last_line "$file_path") [[ -z "$last_line" ]] && return 1 # sedベースでtext contentを抽出 local text_content=$(extract_text_content "$last_line") if [[ -n "$text_content" ]]; then format_text "$text_content" elif echo "$last_line" | grep -q '"type":"tool_use"'; then echo "ツールの使用" fi } # =========================================== # デバウンス機能 # =========================================== # PIDファイルでファイル別の待機中通知を管理 PENDING_DIR="/tmp/claude-monitor-$$" mkdir -p "$PENDING_DIR" # 5秒後に通知をスケジュールする関数 schedule_notification() { local file="$1" local pid_file="$PENDING_DIR/$(basename "$file" .jsonl).pid" # 既存の待機中通知をキャンセル if [[ -f "$pid_file" ]]; then local old_pid=$(cat "$pid_file" 2>/dev/null) if [[ -n "$old_pid" ]]; then kill "$old_pid" 2>/dev/null || true fi rm -f "$pid_file" fi # 5秒後に通知するバックグラウンドプロセスを起動 ( sleep 5 # 5秒後に再度チェックして通知 if [[ -f "$file" ]] && check_notification_target "$file"; then local content=$(extract_assistant_content "$file") if [[ -n "$content" ]]; then local project_name=$(get_project_name "$file") send_notification "$ASSISTANT_EMOJI $project_name" "$content" "$NOTIFICATION_SOUND" fi fi # プロセス終了時にPIDファイルを削除 rm -f "$pid_file" ) & # バックグラウンドプロセスのPIDを保存 echo $! > "$pid_file" } # =========================================== # メイン処理関数 # =========================================== # ファイル処理関数(デバウンス付き) process_file_update() { local file="$1" if [[ ! -f "$file" ]]; then echo "[$SCRIPT_NAME] [エラー] ファイルが存在しません: $file" >&2 return 1 fi # 通知対象のファイルのみ5秒デバウンスでスケジュール if check_notification_target "$file"; then schedule_notification "$file" fi } # ヘルプ表示関数 show_help() { cat << EOF 使用方法: $0 [オプション] オプション: -d, --dir DIR 監視するプロジェクトディレクトリ (デフォルト: $DEFAULT_BASE_DIR/$DEFAULT_PROJECTS_SUBDIR) -c, --config FILE 設定ファイルのパス (デフォルト: $HOME/.claude-monitor.conf) -s, --sound SOUND 通知音 (デフォルト: $DEFAULT_NOTIFICATION_SOUND) -e, --emoji EMOJI アシスタント絵文字 (デフォルト: $DEFAULT_ASSISTANT_EMOJI) -l, --length N 表示する最大文字数 (デフォルト: $DEFAULT_MAX_CONTENT_LENGTH) -h, --help このヘルプを表示 環境変数: CLAUDE_BASE_DIR ベースディレクトリ CLAUDE_PROJECTS_DIR プロジェクトディレクトリ CLAUDE_CONFIG_FILE 設定ファイルパス CLAUDE_NOTIFICATION_SOUND 通知音 CLAUDE_ASSISTANT_EMOJI アシスタント絵文字 CLAUDE_MAX_CONTENT_LENGTH 最大文字数 設定ファイル例 (~/.claude-monitor.conf): CLAUDE_PROJECTS_DIR="/path/to/your/projects" CLAUDE_NOTIFICATION_SOUND="Ping" CLAUDE_ASSISTANT_EMOJI="🤖" CLAUDE_MAX_CONTENT_LENGTH=150 EOF } # コマンドライン引数解析 parse_arguments() { while [[ $# -gt 0 ]]; do case $1 in -d|--dir) PROJECTS_DIR="$2" shift 2 ;; -c|--config) load_config_file "$2" shift 2 ;; -s|--sound) NOTIFICATION_SOUND="$2" shift 2 ;; -e|--emoji) ASSISTANT_EMOJI="$2" shift 2 ;; -l|--length) MAX_CONTENT_LENGTH="$2" shift 2 ;; -h|--help) show_help exit 0 ;; *) echo "[$SCRIPT_NAME] [エラー] 不正なオプション: $1" >&2 echo "ヘルプを表示するには -h または --help を使用してください" >&2 exit 1 ;; esac done } # メイン処理 main() { # コマンドライン引数を解析 parse_arguments "$@" if [[ ! -d "$PROJECTS_DIR" ]]; then echo "[$SCRIPT_NAME] [エラー] 監視ディレクトリが存在しません: $PROJECTS_DIR" >&2 exit 1 fi if ! command -v fswatch >/dev/null 2>&1; then echo "[$SCRIPT_NAME] [エラー] fswatchコマンドが見つかりません" >&2 exit 1 fi echo "[$SCRIPT_NAME] 🤖 Claude Code メッセージ監視開始" echo "[$SCRIPT_NAME] 監視対象: $PROJECTS_DIR" echo "[$SCRIPT_NAME] 終了するには Ctrl+C を押してください" # 初期通知 send_notification "$SCRIPT_NAME" "🤖 Claude Code メッセージの監視を開始しました" "$STARTUP_SOUND" # fswatch でファイル変更を監視 fswatch -0 --event=Created --event=Updated --recursive "$PROJECTS_DIR" | while read -d "" file; do # .jsonl ファイルのみを対象 if [[ "$file" == *"$FILE_EXTENSION" ]]; then process_file_update "$file" || echo "[$SCRIPT_NAME] [エラー] ファイル処理に失敗: $file" >&2 fi done } # =========================================== # シグナルハンドラー # =========================================== # シグナルハンドラ cleanup() { echo -e "\n[$SCRIPT_NAME] 監視を停止しました" # 待機中の通知プロセスをすべて終了 if [[ -d "$PENDING_DIR" ]]; then for pid_file in "$PENDING_DIR"/*.pid; do if [[ -f "$pid_file" ]]; then local pid=$(cat "$pid_file" 2>/dev/null) if [[ -n "$pid" ]]; then kill "$pid" 2>/dev/null || true fi fi done rm -rf "$PENDING_DIR" fi send_notification "$SCRIPT_NAME" "会話履歴の監視を停止しました" "$STARTUP_SOUND" || true exit 0 } # SIGINT (Ctrl+C) をキャッチ trap cleanup SIGINT # メイン処理実行 main "$@"
主な仕組み
- ファイル監視:
fswatch
で.claude/projects
配下の会話ログファイルを監視 - . 通知判定: JSON の最終行から
"role":"assistant"
または"status":"completed"
を検出 - . 通知送信:
osascript
を使って macOS の通知センターに通知と通知音を送信 - . デバウンス: 連続する応答を待ってから通知(5 秒後に送信)
実際の通知
適切に通知がされていることが分かります。
できなかったこと
実は少し妥協した部分もあります。本当はエージェントのタスク完了メッセージに限って通知をしたかったのですが、それはできませんでした。
理由は、最後の返答の判別が難しいためです。 過去、会話ログの JSON の stop_reason
キーに end_turn
が付与されることで、最後の返答であることが判別できたのですが、仕様変更で付与されなくなってしまいました(issue)。
そのため、不完全な対応にはなりますが前述したデバウンスの実装で 5 秒遅延して続きの出力がないことを確認し通知する形で対応しています。
おわりに
エージェントの実行中は待ち時間が多いので、通知できる仕組みがあるとかなり便利です。
やり方 1 の方はルールファイルをコピーしていただくだけで実現できますし、やり方 2 もバイブコーディングですぐに実装可能です。
気になる人はぜひ試してみてください。