Claude Code のセッションログを読むビューアの設計判断|非公開の JSONL に壊れずに追従する

Claude Code のセッションログは非公開の内部フォーマットで、バージョンが上がれば形が変わりうる。このビューアでは、その依存を「戻せる判断」にするため、作る前にログを全量調べ、パーサは未知の行を落とさず保持し、索引はいつでも作り直せる派生物にした。それでも、単一スレッドのテストでは通らない実行経路の欠陥は、全フェーズの承認後に実際の起動で見つかった。

作ったのは、~/.claude/projects に溜まるセッションログ(JSONL)をプロジェクト → セッション → 会話の順にたどって閲覧・検索する、個人利用で読み取り専用のローカルツールである。目的は、Claude Code で実装したときのやりとりを見やすく表示し、意思決定の理由を簡単に検索できるようにすることだった。構成は FastAPI、SQLite(FTS5 trigram)の索引、React の SPA で、Phase 0(調査)〜4(検索・統計)をフェーズゲート制で進めた。サブエージェントは調査用・実装用・実装と分けたゲート用の 3 体である(委譲の考え方)。

1. 非公開フォーマットへの依存を「戻せる判断」に変える

フォーマットへの依存は本来、不可逆の判断(Type 1)になるが、要件の設計判断表では「Type 1 → Type 2 に変換済み」と分類した。未知を保持するパーサ、生 JSON の保持、使い捨ての索引がそろえば、仕様の変化はパーサの修正だけで吸収でき、可逆になるからだ。

# backend/app/models.py から抜粋
class TolerantModel(BaseModel):
    """未知フィールドを保持する共通基底(parser-contract 必須要件 1)。"""

    model_config = ConfigDict(extra="allow")


# …

class UnknownEntry(TolerantModel):
    """未知 type / 既知 type のスキーマ不一致行。生 JSON を丸ごと保持する。"""

    # …
    raw: dict[str, Any]
    # …


class ParseErrorEntry(TolerantModel):
    """JSON デコード失敗行。行番号・生テキスト付きで保持し、読み込みは止めない。"""

    # …
    line_no: int
    raw_text: str
    # …

全モデルがこの基底を継承して未知のフィールドを捨てず、未知の type やスキーマが合わない行は UnknownEntry に生 JSON ごと、JSON として読めない行は ParseErrorEntry に行番号つきで入り、読み込みは続く。索引は CLAUDE.md の原則「真実は常に JSONL 側」に従い、--rebuild で作り直せる。

調べた範囲(2 章)では、全行が UnknownEntry 0 件・ParseErrorEntry 0 件で通った。調査時点の結果で、今のバージョンでも同じとは言えない。失敗時の挙動を契約に書く考え方は契約ファーストで扱った。

2. 作る前に実データを全量調べる

Phase 0 は調査だけのフェーズにし、モデル配分でも「契約が全フェーズを縛る Type 1 相当」として、フェーズの中で最も厚くした。以下の観測はすべて Claude Code 2.1.207(2026 年 7 月)の環境で、ログ内の version 2.1.85〜2.1.207 を調べた時点のものである。読み取り専用の scripts/survey.py で集計し、調査用のサブエージェントに個別のサンプルを確かめさせた。~/.claude/projects の全量 168 ファイル・62,647 行・約 191MB を読み、JSON デコード失敗は 0 件だった。

要件の Open Questions 5 件は、どれも実データで答えが出て設計を決めた。

  1. サブエージェントのログ: 別ファイル(<session-uuid>/subagents/agent-<hex>.jsonl)で、親の toolUseResult.agentId でつながる → スキャナはパスの構成からサブエージェントと親セッションを判定する
  2. summary 行: 0 件で、表題の実体は ai-title・custom-title・last-prompt → 一覧の表題は summary 行を当てにせず、custom-title を優先し、無ければ ai-title を使う
  3. /compact 後の履歴: 同じファイルに残り、compact_boundary の行が新しいルートになる → 会話は物理順に表示し、compact の境界は区切り線で示す(元の履歴はそのまま読める)
  4. tool_result の欠損: 15,332 件中 4 件で、すべてファイル末尾の中断 → result を必須にせず、対応する result が無い tool_use もそのまま表示する
  5. parentUuid の分岐: 同じ親に複数の子(観測の最大は 3)が付き、後続行から parentUuid として参照される枝がアクティブ → 4 章の分岐表示

必須フィールドは全行で存在を確かめられたものに絞り、残りは Optional にした。匿名化では、キー名がデータの本文になる罠があった(toolUseResult.answers は質問文が、snapshot.trackedFileBackups はファイルパスがキー)。そこで survey.py は、識別子の形でないキー名を redact して出す。

3. 索引は SQLite FTS5 の使い捨てにする

索引は SQLite と FTS5(trigram)にした。統計なら JSONL を直接読めば足りるので DuckDB は採らず、会話の表示も毎回 JSONL を読み直す。索引は一覧と検索のためだけの派生物である。

# backend/app/db.py から抜粋
# スキーマ・索引の意味を変えたら必ず +1 する(不一致で自動 rebuild)
# v2 (Phase 4): tool_usage / activity_by_hour を追加(ツール使用頻度・時間帯統計)
INDEX_VERSION = 2
# …

def needs_rebuild(conn: sqlite3.Connection) -> bool:
    """スキーマ欠損 or 索引バージョン不一致なら True(全再構築が必要)。"""
    return get_index_version(conn) != INDEX_VERSION

起動時にバージョンが合わなければ全再構築し、普段は mtime とサイズの差分で更新する。Phase 1 では実データ 174 ファイル・44,052 検索行を約 33 秒で処理した(当時の手元環境での実測)。Phase 4 で統計用のテーブルを足したときも、INDEX_VERSION を 2 に上げるだけで既存の DB が作り直され、移行作業は要らなかった。検索語は _fts_phrase で FTS5 の literal phrase にし、演算子の注入と構文エラーを防いだ。

会話のページングは cursor が物理行番号で、読むたびに先頭から行を読み飛ばすので O(n) になる。Phase 2 のゲートでは、Phase 3 で大きなセッションのスクロールが破綻したら cursor をバイトオフセットにする(契約は変えない)と条件付きで先送りした。Phase 3 で運営者が最大のセッションのスクロールを確かめて承認したので、この条件は発動していない。

4. 会話を読める形にする

画面は一覧と会話の 2 ペインで、長いセッションは virtual scroll で扱い、Edit 系のツールは diff で見せる。未知の type と parse_error は、UI の規約に「データが見えないことが最悪」と書いたとおり raw JSON で出す。バックエンドの読み出し処理(reader)は type を検証しないので、判定はフロント側で既知の type のホワイトリストと照合する。分岐はアクティブな枝を自動で表示し、スイッチで他の枝も見られるようにした。

// frontend/src/features/conversation/branching.ts から抜粋
// アクティブ判定: 後続行から parentUuid として参照される枝がアクティブ
// (廃棄された枝の uuid はどの行からも参照されない、という Phase 0 の実データ観測に基づく)。
// …
  for (const members of membersByParent.values()) {
    if (members.length < 2) continue;
    let activeIndex = -1;
    for (const [i, member] of members.entries()) {
      const uuid = getUuid(member.raw);
      if (uuid !== undefined && allParentUuids.has(uuid)) {
        // このメンバー自身の uuid が、さらに後続の何かの parentUuid になっている
        // ただしそれが同一グループ内の別メンバーの一致でないことを確認
        const referencedByOther = lines.some((l) => {
          const p = getParentUuid(l.raw);
          return typeof p === "string" && p === uuid && !members.includes(l);
        });
        if (referencedByOther) {
          activeIndex = i;
          break;
        }
      }
    }
    // …

自動判定の結果が実データの根拠と一致することは確かめた。sidechain の分離は、Phase 0 の実データで 1 ファイル内の混在が観測されなかったのでセッション単位にした。

捨てたものもある。API の型のコード生成は、契約が小さいうちは過剰として使わなかった。デスクトップ配布(Tauri など)は、ローカル Web から後で移れるとして見送った。

実装セッションにはブラウザが無く、最大のセッション(4,686 メッセージ・5,593 生行)の API レスポンスで描画ロジックをトレースし、最後は運営者がブラウザで確かめて承認した。

5. ゲートが拾ったもの、検証が通らなかった経路

ゲート(別コンテキストのレビュー)が拾ったものから 2 つ挙げる。Phase 3 では、検索 UI と / での起動が、Phase 4(任意・スコープの再承認が必要)と決めた機能の先行実装だとブロッキングで指摘され、撤去して Phase 4 でヒット位置へのジャンプ込みで作り直した。Phase 4 では承認スコープの「トークン消費」の集計漏れを指摘され、/api/stats/tokens を足した。

通らなかった経路は全フェーズの承認後に出た。Phase 2 では、ゲート条件の「curl で全ユースケース確認」を、運営者がライブサーバの起動を中断して省略に合意したので TestClient の契約一致テストで代えた。Phase 3・4 で backend を起動して確かめたときも、逐次のリクエストでは並行リクエストとスレッドプールの経路を通らなかった。そして実際に起動して統計パネルを使うと、500 が返った。FastAPI は同期の yield 依存(DB 接続の生成・利用・クローズ)をスレッドプールの別スレッドで実行しうるのに、接続を SQLite 既定の check_same_thread=True で開いていたからだ。統計パネルが 3 本のリクエストを同時に送ったときに表に出たが、他のエンドポイントにも潜んでいた。1 リクエスト 1 接続で並行使用はしないので check_same_thread=False で直し、TestClient が素通りする経路を回帰テストで固定した。

# backend/tests/test_api.py から抜粋
def test_open_ro_connection_usable_across_threads(tmp_path: Path) -> None:
    # FastAPI は yield 依存の生成・利用・クローズを別スレッドへ回しうる。
    # open_ro の接続がスレッドを跨いで使えること(check_same_thread=False)を固定する。
    # TestClient は単一スレッドで回るためこの経路を素通りしてしまうので、明示的に別スレッドで検証。
    # …
    conn = open_ro(db_path)  # 生成: メインスレッド
    # …(worker() が別スレッドで conn を使い、例外を result に記録する)
    t = threading.Thread(target=worker)
    t.start()
    t.join()
    conn.close()  # クローズ: メインスレッド

    assert "error" not in result, result.get("error")
    # …

一括起動の .cmd でも、日本語のメッセージが既定のコードページ(932)で読まれて行が崩れ、断片がコマンドとして実行された。chcp 65001 では直らず、メッセージを ASCII にした。

運営者は振り返りで、次の 3 つが必要だと考えている。1 つ目は、実際の実行モデル(スレッドプール・並行リクエスト)を通る検証をゲートに入れること。2 つ目は、起動手順は実機で確かめるまでを工程に含め、Windows 向けのスクリプトは ASCII で書くこと。3 つ目は、ゲートの判定と先送り事項をリポジトリに残すこと。開発中はコミット本文とリポジトリ外のメモにしか無かった。

まとめ

非公開で変わるフォーマットには、先に全量を調べたうえで、未知を保持するパーサと使い捨ての索引で依存を戻せる形にして追従した。欠陥は、ゲートが見た範囲の外にある実行モデルと起動手順に残った。進め方はフェーズゲートで扱った。

--- 参照: セッションログビューア(2026-10-25 確認)の backend/(app・tests)、frontend の branching.ts、run-viewer.cmd、CLAUDE.md、.claude/rules/(ui-guidelines・coding-standards)、docs/(requirements・phase-plan・model-plan・jsonl-format-notes。調査日 2026-07-13、Claude Code 2.1.207)とコミット本文。