@kentaro さんの記事「会話を自動でObsidianに記録する仕組みを作った 」を見て、Claude Codeとの会話を自動でObsidianに記録する仕組みを導入してみました。

非常に便利な仕組みですが、実際に動かすまでにいくつかハマったポイントがあったので、解決策とともに共有します。

    元記事の仕組み(概要)

    Claude Code セッション
        ↓ (jsonlファイルに記録)
    ~/.claude/projects/*/session.jsonl
        ↓ (5秒ごとに監視)
    watch-and-save.sh (LaunchAgentで常駐)
        ↓ (変更検知時に抽出・整形)
    ~/obsidian/claude/YYYY-M-D.md
    

    発生した問題と解決策

    問題1: jqがインストールされていない

    元記事のスクリプトはjq(JSONパーサー)を使用しますが、macOSにはデフォルトでインストールされていません。

    # 確認
    which jq
    # → jq not found
    
    # 解決策
    brew install jq
    

    問題2: plistで~が展開されない

    元記事のLaunchAgent設定:

    <string>~/.claude/hooks/watch-and-save.sh</string>
    

    LaunchAgentのplistでは~が展開されないため、絶対パスに変更する必要があります。

    <!-- 修正後 -->
    <string>/Users/[ユーザー名]/.claude/hooks/watch-and-save.sh</string>
    <string>/Users/[ユーザー名]/.claude/logs/obsidian-sync.log</string>
    <string>/Users/[ユーザー名]/.claude/logs/obsidian-sync-error.log</string>
    

    問題3: ユーザーメッセージが配列の場合に対応していない

    これが最大のハマりポイントでした。

    Claude Codeのセッションファイルでは、ユーザーメッセージの.message.content文字列の場合と配列の場合があります。

    // 文字列の場合(元スクリプトが想定)
    {"type": "user", "message": {"content": "こんにちは"}}
    
    // 配列の場合(ツール実行結果など)
    {"type": "user", "message": {"content": [{"type": "text", "text": "こんにちは"}]}}
    

    元スクリプトのjqフィルタは文字列のみを想定していたため、配列の場合にエラーが発生していました。

    問題4: 新しいセッションで内容が記録されない

    セッションヘッダーは書き込まれるのに、会話内容が空という症状が発生しました。

    原因: 新しいセッション検出時に、過去の追跡ファイル(last_line_file)の値を使っていたため、「新しい行がない」と判定されていました。

    問題5: LaunchAgent環境でjqのPATHが通らない

    最もハマりやすい問題です。セットアップ直後は動くのに、Mac再起動後に会話内容が記録されなくなるという症状が発生しました。

    原因: LaunchAgentのデフォルトPATHは /usr/bin:/bin:/usr/sbin:/sbin のみです。Homebrewでインストールしたjq/usr/local/bin/jq(Intel Mac)または /opt/homebrew/bin/jq(Apple Silicon)に配置されますが、このパスはLaunchAgentのPATHに含まれていません。

    初回セットアップ時はシェルのPATH設定が引き継がれて動作しますが、Mac再起動後にLaunchAgentが再開されるとPATHがデフォルトに戻り、jqが見つからなくなります。

    スクリプト内で2>/dev/nullとしているとエラーが握り潰され、原因の特定が非常に困難になります。

    # 確認方法:LaunchAgent環境をシミュレートしてjqが見つかるか確認
    env -i PATH="/usr/bin:/bin:/usr/sbin:/sbin" bash -c 'which jq'
    # → jq not found ← これが原因
    
    # 解決策:スクリプトの先頭でPATHを追加
    export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"
    

    修正版スクリプト

    上記の問題をすべて解決し、さらにセッション区切り機能を追加した修正版スクリプトです。

    ~/.claude/hooks/watch-and-save.sh:

    #!/bin/bash
    # Watch Claude Code session and sync to Obsidian in real-time (append mode)
    
    # Ensure Homebrew tools (jq etc.) are accessible in LaunchAgent environment
    export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"
    
    OBSIDIAN_DIR="$HOME/Dropbox/Obsidian/claude"  # ← 自分のObsidian vaultに変更
    SESSION_DIR="$HOME/.claude/projects"
    LAST_LINE_FILE="$HOME/.claude/last-synced-line"
    LAST_SESSION_FILE="$HOME/.claude/last-session-file"
    LOG_FILE="$HOME/.claude/watch-and-save.log"
    
    mkdir -p "$OBSIDIAN_DIR"
    
    log() {
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
    }
    
    # Truncate string to specified character length (handles multibyte)
    truncate_string() {
        local str="$1"
        local max_len="${2:-30}"
        str=$(echo "$str" | tr 'n' ' ' | sed 's/  */ /g')
        echo "$str" | awk -v len="$max_len" '{
            if (length($0) > len) {
                print substr($0, 1, len) "..."
            } else {
                print $0
            }
        }'
    }
    
    # Get first user message from session file
    get_first_user_message() {
        local session_file="$1"
        head -100 "$session_file" | jq -r '
            select(.type == "user") |
            if (.message.content | type) == "array" then
                (.message.content | map(select(.type == "text") | .text) | join(" "))
            elif (.message.content | type) == "string" then
                .message.content
            elif (.content | type) == "string" then
                .content
            else
                ""
            end
        ' 2>>"$LOG_FILE" | grep -v '^___PRE_BLOCK_6___#39; | head -1
    }
    
    sync_session() {
        local session_file="$1"
        local TODAY=$(date +%Y-%-m-%-d)
        local OUTPUT_FILE="${OBSIDIAN_DIR}/${TODAY}.md"
        local CURRENT_TIME=$(date '+%H:%M:%S')
    
        # Create file with header if it doesn't exist
        if [ ! -f "$OUTPUT_FILE" ]; then
            echo "# ${TODAY} Claudeとの会話" > "$OUTPUT_FILE"
            echo "" >> "$OUTPUT_FILE"
        fi
    
        # Check if this is a new session
        local last_session=""
        if [ -f "$LAST_SESSION_FILE" ]; then
            last_session=$(cat "$LAST_SESSION_FILE" 2>/dev/null || echo "")
        fi
    
        local is_new_session=false
        if [ "$session_file" != "$last_session" ]; then
            is_new_session=true
        fi
    
        # Get last synced line number for this specific session file
        local session_hash=$(echo "$session_file" | md5 -q | cut -c1-8)
        local last_line_file="${LAST_LINE_FILE}_${session_hash}"
    
        local last_line=0
        # Only use saved line number if this is NOT a new session
        if [ "$is_new_session" = false ] && [ -f "$last_line_file" ]; then
            last_line=$(cat "$last_line_file" 2>/dev/null || echo 0)
        fi
    
        # Count current lines in session file
        local current_lines=$(wc -l < "$session_file" | tr -d ' ')
    
        # Only process new lines
        if [ "$current_lines" -gt "$last_line" ]; then
            # Check if session changed - add header
            if [ "$session_file" != "$last_session" ]; then
                local first_msg=$(get_first_user_message "$session_file")
                local truncated_msg=$(truncate_string "$first_msg" 30)
                
                echo "" >> "$OUTPUT_FILE"
                echo "---" >> "$OUTPUT_FILE"
                echo "" >> "$OUTPUT_FILE"
                echo "## ${TODAY} ${CURRENT_TIME} ${truncated_msg}" >> "$OUTPUT_FILE"
                echo "" >> "$OUTPUT_FILE"
                
                echo "$session_file" > "$LAST_SESSION_FILE"
                log "New session detected: $session_file"
            fi
    
            local new_content=$(tail -n +$((last_line + 1)) "$session_file" | jq -r '
            select(.type == "user" or .type == "assistant") |
            if .type == "user" then
                # Handle both string and array content
                (if (.message.content | type) == "array" then
                    (.message.content | map(select(.type == "text") | .text) | join(" "))
                elif (.message.content | type) == "string" then
                    .message.content
                elif (.content | type) == "string" then
                    .content
                else
                    ""
                end) as $content |
                if ($content | length) > 0 then
                    if ($content | test("<local-command|<command-name>|<system-reminder>|<task-notification>"; "i")) then
                        empty
                    else
                        "<strong>ユーザー</strong>: " + $content
                    end
                else
                    empty
                end
            elif .type == "assistant" then
                if (.message.content | type) == "array" then
                    (.message.content[] | select(.type == "text") |
                        if (.text | test("^No response requested"; "i")) then
                            empty
                        else
                            "<strong>Claude</strong>: " + .text
                        end
                    )
                else
                    empty
                end
            else
                empty
            end
            ' 2>>"$LOG_FILE")
    
            if [ -n "$new_content" ]; then
                echo "$new_content" >> "$OUTPUT_FILE"
                echo "" >> "$OUTPUT_FILE"
                log "Synced $((current_lines - last_line)) new lines to $OUTPUT_FILE"
                # コンテンツ抽出成功時のみ行カウンタを更新(失敗時はリトライ可能にする)
                echo "$current_lines" > "$last_line_file"
            fi
        fi
    }
    
    find_session() {
        find "$SESSION_DIR" -path "*/subagents/*" -prune -o 
            -name "*.jsonl" -type f -mmin -60 -size +1000c -print 
            2>/dev/null | xargs ls -t 2>/dev/null | head -1
    }
    
    echo "Watching for Claude session changes (append mode)..."
    log "Started watching for Claude session changes"
    
    while true; do
        SESSION=$(find_session) || true
        if [ -n "$SESSION" ]; then
            sync_session "$SESSION" || log "Error syncing session: $SESSION"
        fi
        sleep 5
    done
    

    修正版LaunchAgent

    ~/Library/LaunchAgents/com.claude.obsidian-sync.plist:

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
        "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
        <key>Label</key>
        <string>com.claude.obsidian-sync</string>
        <key>ProgramArguments</key>
        <array>
            <string>/bin/bash</string>
            <string>/Users/[ユーザー名]/.claude/hooks/watch-and-save.sh</string>
        </array>
        <key>RunAtLoad</key>
        <true/>
        <key>KeepAlive</key>
        <true/>
        <key>StandardOutPath</key>
        <string>/Users/[ユーザー名]/.claude/logs/obsidian-sync.log</string>
        <key>StandardErrorPath</key>
        <string>/Users/[ユーザー名]/.claude/logs/obsidian-sync-error.log</string>
    </dict>
    </plist>
    

    注意: /Users/[ユーザー名]/ は自分のホームディレクトリに置き換えてください。

    セットアップ手順

    # 1. jqをインストール
    brew install jq
    
    # 2. ディレクトリ作成
    mkdir -p ~/.claude/hooks
    mkdir -p ~/.claude/logs
    
    # 3. スクリプトを配置(上記の内容を保存)
    nano ~/.claude/hooks/watch-and-save.sh
    
    # 4. 実行権限を付与
    chmod +x ~/.claude/hooks/watch-and-save.sh
    
    # 5. plistを配置(上記の内容を保存、パスを自分のものに変更)
    nano ~/Library/LaunchAgents/com.claude.obsidian-sync.plist
    
    # 6. LaunchAgentを読み込み
    launchctl load ~/Library/LaunchAgents/com.claude.obsidian-sync.plist
    
    # 7. 動作確認
    launchctl list | grep claude
    # → PIDが表示されればOK
    

    出力例

    修正版では、セッションごとに区切りが入ります:

    # 2026-1-27 Claudeとの会話
    
    ---
    
    ## 2026-1-27 12:01:55 watch-and-save.sh が正常に動きそう...
    
    <strong>ユーザー</strong>: /Users/sara-hina/.claude/hooks/watch-and-save.sh が正常に動きそうかチェックしてもらえますか。
    <strong>Claude</strong>: スクリプトを確認しました。いくつか問題点と改善提案があります。
    ...
    
    ---
    
    ## 2026-1-27 14:30:22 マニュアル生成を開始してください
    
    <strong>ユーザー</strong>: マニュアル生成を開始してください
    <strong>Claude</strong>: マニュアル生成を開始します。
    ...
    

    元記事との主な違い

    項目 元記事 修正版
    ユーザーメッセージ 文字列のみ対応 配列にも対応
    セッション区切り なし ---と見出しで区切り
    ログ機能 なし ~/.claude/watch-and-save.log
    新セッション処理 過去の行数を引き継ぐ 0から開始
    plistパス ~使用 絶対パス
    jqのPATH 未設定 /usr/local/bin等を明示的に追加
    エラーログ 2>/dev/nullで破棄 ログファイルに記録
    行カウンタ 常に更新 コンテンツ抽出成功時のみ更新

    トラブルシューティング

    ログが記録されない場合

    # エラーログを確認
    cat ~/.claude/logs/obsidian-sync-error.log
    
    # 通常ログを確認
    cat ~/.claude/watch-and-save.log
    
    # セッションファイルが見つかるか確認
    find ~/.claude/projects -name "*.jsonl" -type f -mmin -60 -size +1000c 2>/dev/null | head -3
    

    LaunchAgentを再起動する

    launchctl unload ~/Library/LaunchAgents/com.claude.obsidian-sync.plist
    launchctl load ~/Library/LaunchAgents/com.claude.obsidian-sync.plist
    

    同期位置をリセットする

    rm -f ~/.claude/last-synced-line_*
    

    Mac再起動後にログが記録されなくなった場合

    jqがLaunchAgent環境で見つからない可能性があります。

    # LaunchAgent環境をシミュレートしてjqの可否を確認
    env -i PATH="/usr/bin:/bin:/usr/sbin:/sbin" bash -c 'which jq'
    
    # 「jq not found」と出た場合、スクリプト先頭に以下があるか確認
    # export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"
    
    # jqの実際のパスを確認
    which jq
    # → /usr/local/bin/jq(Intel)または /opt/homebrew/bin/jq(Apple Silicon)
    

    まとめ

    実際に動かすにはいくつかの修正が必要でした。

    主なポイント:

    • jqのインストールを忘れずに
    • plistは絶対パスで指定
    • 配列形式のメッセージに対応
    • 新しいセッションは0行目から開始
    • スクリプト内でPATHを明示的に設定(LaunchAgent環境ではHomebrewのパスが通らない)

    これでClaude Codeとの会話が自動でObsidianに蓄積されるようになりました。過去の会話を検索・参照できるのは非常に便利です。

    元記事を書いてくださった@kentaroさんに感謝します!


    関連記事

    Claude CodeにSlash Commandsで、開発日誌を書いてもらう方法 Claude CodeのSlash Commandsを使って、開発の判断理由や試行錯誤の過程を含む詳細な開発日誌を自動生成する方法を解説。設定ファイルのテンプレートと実際の出力例も公開。  続きを読む