弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

Claude Code: VSCode のターミナルで実行中のタスク完了を OS 通知させたい

クラウドサインのエンジニアをしている辻@t0daaayです。

Claude Code の公式ドキュメントにタスク完了時の通知方法が掲載されています。
しかし、VSCode 上のターミナルで Claude Code を使いたい場合は通知音を鳴らすことしかできず、タスク完了に気づけないことが多々ありました。
iterm2 を使えば OS 通知させることは可能なようですが、VSCode の Claude Code の拡張機能の恩恵を受けられる点で VSCode 上で実行したいという気持ちがありました。

そんな中、X で AI エージェントのタスク完了を OS の通知として出力させている人を見かけました。

私も実際に 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 通知を表示してくれるようになりました。

実際の通知の画像。提案通知として「DwRadio のリファクタリング案を提案しました」、実装完了通知として「SCSS から CSS への書き直しを完了しました」と通知がされていることが確認できる。

問題点

このやり方は設定が簡単である一方で、あくまでルールファイルへの記述のため、ルールが無視されて通知されないケースが発生することを確認しています。
またコマンド実行の許可を求められた際に通知してくれないので、気づかないうちに作業が止まっているケースが発生しました。
それらを解決するために、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 "$@"

主な仕組み

  1. ファイル監視: fswatch.claude/projects 配下の会話ログファイルを監視
  2. . 通知判定: JSON の最終行から "role":"assistant" または "status":"completed" を検出
  3. . 通知送信: osascript を使って macOS の通知センターに通知と通知音を送信
  4. . デバウンス: 連続する応答を待ってから通知(5 秒後に送信)

実際の通知

適切に通知がされていることが分かります。

実際の通知の画像。会話履歴に対して適切な通知がされていることが確認できる。

できなかったこと

実は少し妥協した部分もあります。本当はエージェントのタスク完了メッセージに限って通知をしたかったのですが、それはできませんでした。
理由は、最後の返答の判別が難しいためです。 過去、会話ログの JSON の stop_reason キーに end_turn が付与されることで、最後の返答であることが判別できたのですが、仕様変更で付与されなくなってしまいました(issue)。
そのため、不完全な対応にはなりますが前述したデバウンスの実装で 5 秒遅延して続きの出力がないことを確認し通知する形で対応しています。

おわりに

エージェントの実行中は待ち時間が多いので、通知できる仕組みがあるとかなり便利です。
やり方 1 の方はルールファイルをコピーしていただくだけで実現できますし、やり方 2 もバイブコーディングですぐに実装可能です。
気になる人はぜひ試してみてください。