@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-caution.log</string>

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

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

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

// 文字列の場合(元スクリプトが想定)
{"type": "user", "message": {"content": "こんにちは"}}

// 配列の場合(ツール実行結果など)
{"type": "user", "message": {"content": [{"type": "text", "text": "こんにちは"}]}}

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

問題4: 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でエラーを握り潰し、jqが失敗しても行カウンタを更新していました。そのため、jqが使えなかった間の会話は「同期済み」として扱われ、後からjqが使えるようになっても取り戻せません。原因の特定も非常に困難になります。

# 確認方法: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"

対策は次の3つです。

  • スクリプトの先頭でPATHを追加する
  • jqのエラーを捨てずに、ログファイルへ記録する
  • 行カウンタはjqが成功した時だけ更新する(失敗した場合は、次のループで同じ行から再試行される)

問題5: Obsidianが起動直後に真っ白になる(ログの肥大化)

しばらく使っていると、Obsidianを起動してもウィンドウが真っ白のまま動かなくなりました。

日次ファイルの大きさを確認すると、1日分のログが10MBを超えており、フォルダ全体で約140MBになっていました。

ls -lhS ~/Dropbox/Obsidian/claude | head -5

原因: 問題4の「見出しだけで中身が空」という症状を、当初は行カウンタの問題だと誤診し、「新しいセッションを検出したら、行カウンタを0に戻して先頭から読み直す」という修正を入れていました。これが逆効果でした。

複数のプロジェクトを行き来すると、最新のセッションが切り替わるたびに、そのセッションの会話が先頭から追記されます。さらに日付フィルタも外していたため、前日以前の会話まで今日のファイルに流れ込んでいました。

Obsidianは起動時に、前回開いていたタブの復元とインデックス作成を行います。そこで巨大な日次ファイルを読み込もうとして固まっていたと考えられます。

対処: 次のように修正しました。

  • 行カウンタはセッションごとに保持し、切り替わっても0に戻さない
  • 日付フィルタで、今日の会話だけを抽出する(日付フィルタが作れない時は同期を中止する)
  • 見出しは、会話内容がある時だけ書く

肥大化したファイルは、削除せずに退避しておくと安全です。

# 1. Obsidianを終了し、常駐を止める
launchctl unload ~/Library/LaunchAgents/com.claude.obsidian-sync.plist

# 2. 1MBを超える日次ファイルを退避(削除ではなく移動)
mkdir -p ~/Desktop/obsidian-claude-backup
find ~/Dropbox/Obsidian/claude -maxdepth 1 -name '*.md' -size +1M -exec mv {} ~/Desktop/obsidian-claude-backup/ \;

# 3. 修正版スクリプトに差し替えてから、常駐を再開
launchctl load ~/Library/LaunchAgents/com.claude.obsidian-sync.plist

修正版スクリプト

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

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

#!/bin/bash
# Watch Claude Code session and sync to Obsidian in real-time (append mode)

# LaunchAgent環境ではHomebrewのPATHが通らないため、明示的に追加する
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"
}

# セッション最初のユーザー発言を30文字に切り詰めて返す(見出し用)
get_session_title() {
    local session_file="$1"
    head -100 "$session_file" | jq -R -r '
        fromjson? | objects | 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
        else
            ""
        end) |
        select(length > 0) |
        select(test("<local-command|<command-name>|<system-reminder>|<task-notification>"; "i") | not) |
        gsub("\\s+"; " ") | sub("^ "; "") |
        if length > 30 then .[0:30] + "..." else . end
    ' 2>>"$LOG_FILE" | head -1
}

sync_session() {
    local session_file="$1"
    local TODAY=$(date +%Y-%-m-%-d)
    # 今日0時(JST)をUTCに変換。JST(UTC+9)前提なので、他のタイムゾーンでは -9H を調整する
    local TODAY_START_UTC=$(TZ=UTC date -v-9H -j -f "%Y-%m-%d %H:%M:%S" "$(date +%Y-%m-%d) 00:00:00" +%Y-%m-%dT%H:%M:%S 2>/dev/null)
    local OUTPUT_FILE="${OBSIDIAN_DIR}/${TODAY}.md"

    # 日付フィルタが作れないと過去の会話まで追記してしまうため、その場合は中止
    if [ -z "$TODAY_START_UTC" ]; then
        log "Failed to calculate today's start time"
        return 1
    fi

    # 同期済みの行数はセッションファイルごとに保持する(切り替わっても0に戻さない)
    local session_hash=$(echo "$session_file" | md5 -q | cut -c1-8)
    local last_line_file="${LAST_LINE_FILE}_${session_hash}"

    local last_line=0
    if [ -f "$last_line_file" ]; then
        last_line=$(cat "$last_line_file" 2>/dev/null || echo 0)
    fi

    local current_lines=$(wc -l < "$session_file" | tr -d ' ')
    [ "$current_lines" -gt "$last_line" ] || return 0

    # 新しい行のうち、今日の会話だけを抽出する(壊れた行は読み飛ばす)
    local new_content
    if ! new_content=$(tail -n +$((last_line + 1)) "$session_file" | jq -R -r --arg since "$TODAY_START_UTC" '
    fromjson? | objects |
    select(.type == "user" or .type == "assistant") |
    select((.timestamp // "9999") >= $since) |
    if .type == "user" then
        (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
                "**ユーザー**: " + $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
                    "**Claude**: " + .text
                end
            )
        else
            empty
        end
    else
        empty
    end
    ' 2>>"$LOG_FILE"); then
        # 行カウンタは更新しないので、次のループで同じ行から再試行される
        log "jq failed: $session_file"
        return 1
    fi

    if [ -n "$new_content" ]; then
        local is_new_file=false
        if [ ! -f "$OUTPUT_FILE" ]; then
            echo "# ${TODAY} Claudeとの会話" > "$OUTPUT_FILE"
            is_new_file=true
        fi

        # 見出しは「会話内容があり、かつ別のセッションに切り替わった時」だけ書く
        local last_session=""
        if [ -f "$LAST_SESSION_FILE" ]; then
            last_session=$(cat "$LAST_SESSION_FILE" 2>/dev/null)
        fi

        if [ "$is_new_file" = true ] || [ "$session_file" != "$last_session" ]; then
            local title=$(get_session_title "$session_file")
            {
                echo ""
                echo "---"
                echo ""
                echo "## ${TODAY} $(date '+%H:%M:%S') ${title:-(タイトルなし)}"
                echo ""
            } >> "$OUTPUT_FILE"
            echo "$session_file" > "$LAST_SESSION_FILE"
            log "Session switched: $session_file"
        fi

        echo "$new_content" >> "$OUTPUT_FILE"
        echo "" >> "$OUTPUT_FILE"
        log "Synced $((current_lines - last_line)) new lines to $OUTPUT_FILE"
    fi

    # jqが成功した時だけ行カウンタを更新する
    echo "$current_lines" > "$last_line_file"
}

# 直近60分以内に更新された最新のセッションファイルを探す(subagentsは除外)
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)
    if [ -n "$SESSION" ]; then
        sync_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-caution.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 Remotionの書き出し設定を見直して、ファイルサイズを小...

**ユーザー**: Remotionの書き出し設定を見直して、ファイルサイズを小さくしてください。
**Claude**: 現在の設定を確認します。
...

---

## 2026-1-27 14:30:22 マニュアル生成を開始してください

**ユーザー**: マニュアル生成を開始してください
**Claude**: マニュアル生成を開始します。
...

元記事との主な違い

項目 元記事 修正版
ユーザーメッセージ 文字列のみ対応 配列にも対応
セッション区切り なし ---と見出しで区切り
ログ機能 なし ~/.claude/watch-and-save.log
行カウンタ 全セッションで1つを共有 セッションごとに保持(切り替わっても0に戻さない)
plistパス ~使用 絶対パス
jqのPATH 未設定 /usr/local/bin等を明示的に追加
エラーログ 2>/dev/nullで破棄 ログファイルに記録
行カウンタの更新 常に更新 jqが成功した時のみ更新(失敗時は再試行)

トラブルシューティング

ログが記録されない場合

# エラーログを確認
cat ~/.claude/logs/obsidian-sync-caution.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)

Obsidianが起動直後に真っ白になる場合

日次ファイルが肥大化していないか確認します。

ls -lhS ~/Dropbox/Obsidian/claude | head -5

数MB以上のファイルが並んでいる場合は、問題5の手順で退避します。

まとめ

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

主なポイント:

  • jqのインストールを忘れずに
  • plistは絶対パスで指定
  • 配列形式のメッセージに対応
  • 行カウンタはセッションごとに保持し、切り替わっても0に戻さない(0に戻すとログが肥大化する)
  • 日付フィルタは外さない(前日以前の会話まで追記されてしまう)
  • スクリプト内でPATHを明示的に設定(LaunchAgent環境ではHomebrewのパスが通らない)

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

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


関連記事

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