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 件は、どれも実データで答えが出て設計を決めた。
- サブエージェントのログ: 別ファイル(
<session-uuid>/subagents/agent-<hex>.jsonl)で、親のtoolUseResult.agentIdでつながる → スキャナはパスの構成からサブエージェントと親セッションを判定する - summary 行: 0 件で、表題の実体は
ai-title・custom-title・last-prompt→ 一覧の表題は summary 行を当てにせず、custom-title を優先し、無ければ ai-title を使う - /compact 後の履歴: 同じファイルに残り、
compact_boundaryの行が新しいルートになる → 会話は物理順に表示し、compact の境界は区切り線で示す(元の履歴はそのまま読める) - tool_result の欠損: 15,332 件中 4 件で、すべてファイル末尾の中断 → result を必須にせず、対応する result が無い tool_use もそのまま表示する
- 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)とコミット本文。