クラウドサインのエンジニアをしている辻@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 もバイブコーディングですぐに実装可能です。
気になる人はぜひ試してみてください。