0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

~/.claude が 6.57 GB あった。うち 2.78 GB は、止まったまま放置されたジョブが握るログの丸ごとコピーだった

0
Posted at

du -sh ~/.claude を打ったら 6.57 GB と出た。CLI ツールの設定ディレクトリである。

内訳を数えた。

~/.claude 合計 6.57 GB

                             サイズ     ファイル     比率
projects                 2788.0MB   10,138  41.4%
jobs                     2780.5MB   10,307  41.3%
security                  567.5MB    8,000   8.4%
plugins                   527.9MB   22,871   7.8%
file-history               51.4MB    2,236   0.8%
history.jsonl               6.5MB        1   0.1%

projects はセッションログである。これは納得できる。

jobs が同じサイズあるのは納得できなかった。 ファイル数まで近い(10,138 と 10,307)。

同じものを2回持っていた

まず疑ったのはハードリンクである。同じ実体を2箇所から数えていたら、内訳そのものが嘘になる。

複数箇所から参照されている実体 1 件(0 ならハードリンクによる二重計上はない)

全ファイルの inode を集めて重複を数えた。1件だけ重なったがサイズは0バイトだった。実体は別々。6.57 GB は本物である。

ではファイル名はどうか。jobs 配下の .jsonl の名前を、projects 側の名前と突き合わせた。

=== jobs のジョブ別
        1.4MB  state=done  .jsonl 1 本 / 実体側と同名 0.0%
        0.0MB  state=failed
        0.0MB  state=done
     2779.1MB  state=blocked  .jsonl 5,885 本 / 実体側と同名 100.0%
        0.0MB  state=failed
        0.0MB  state=blocked  .jsonl 1 本 / 実体側と同名 0.0%

1つのジョブが 2,779 MB を握っていて、その中の 5,885 本すべてが projects 側と同名だった。100%。

中を見ると <ジョブID>/tmp/snap/.claude/projects/... という構造になっていた。ジョブを走らせるときに .claude ディレクトリごとスナップショットを取り、その中にログディレクトリが丸ごと入っている。

そのジョブの state.json を見る。

{"state": "blocked", "createdAt": "2026-08-17T...", "updatedAt": "2026-08-18T..."}

blocked 終わっていない。終わっていないので片付けられない。片付けられないので、2.78 GB のログのコピーがそのまま残っている。

ついでに state=failed のジョブが2件あって、createdAt は48日前だった。サイズは0なので実害はないが、状態による掃除は動いていないことが分かる。donefailedblocked も、同じように残り続けている。

消す前に見るもの

ディスクを空けたいとして、この2.78 GB を消していいかは状態を見ないと決まらない

for d in ~/.claude/jobs/*/; do
  printf "%8s  %s  %s\n" "$(du -sh "$d" | cut -f1)" \
    "$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("state","?"))' "$d/state.json" 2>/dev/null || echo '?')" \
    "$(basename "$d")"
done

donefailed は再開しない。blocked は再開しうる。自分の場合、容量を持っているのが blocked の1件だったので、まず走らせたジョブを終わらせるか捨てるかを決めるのが先だった。ディスクの問題ではなく、放置したジョブの問題である。

projects の中身も、半分は自分が読んでいない

projects 側も内訳を出した。

projects の .jsonl 5,891 本 / 2.61 GB
  うちサブエージェント記録 2,944 本 / 1.72 GB / 1本 中央値 279 KB / 最大 27.1 MB

ログの本数の半分、容量の3分の2が、<セッションID>/subagents/agent-*.jsonl である。委譲したサブエージェント側の全記録だ。

以前、委譲で何が失われるかを測ったことがある。

サブが読んだツール結果 3,093 件 → 親に届いた 0 件
サブが書いた本文 530,652 字 → 親に届いた 292,082 字 = 55.0%

サブエージェントが読んだツール結果は1件も親に入らない。親が受け取るのは最終報告の文章だけである。だから報告は裏取りするしかない、と書いた。

その裏取りの材料が、この 1.72 GB だった。 サブが実際に打ったコマンドと、実際に返ってきた出力が、そのまま残っている。

# 直近の委譲記録を1本取る
F=$(ls -t ~/.claude/projects/*/*/subagents/agent-*.jsonl | head -1)

# その中の Bash 呼び出しと結果を並べる
python3 -c '
import json,sys
for line in open(sys.argv[1]):
    try: d=json.loads(line)
    except ValueError: continue
    c=(d.get("message") or {}).get("content")
    for b in c if isinstance(c,list) else []:
        if not isinstance(b,dict): continue
        if b.get("type")=="tool_use" and b.get("name")=="Bash":
            print("$", (b.get("input") or {}).get("command","")[:120])
        elif b.get("type")=="tool_result":
            t=b.get("content")
            t=t if isinstance(t,str) else json.dumps(t,ensure_ascii=False)
            print("   "+("NG " if b.get("is_error") else "")+t[:200].replace("\n"," "))
' "$F"

「テストは全部通りました」という報告の裏を取りたいなら、その委譲の agent-*.jsonl を開いて is_error を数えればいい。捏造されやすいのは報告の文章であって、ツール結果の行ではない。

残りの1.1 GB

security 567.5 MB の中身は Python パッケージ群と 292.8 MB の単一バイナリだった。plugins 527.9 MB は 22,871 ファイルで、大半が marketplace のクローンとキャッシュである。

どちらも自分が書いたデータではない。プラグインを1つ入れると数百 MB が来る、という感覚は持っておいたほうがいい。

この計測の限界

  • 1台のマシンの、1時点のスナップショットである。projectsjobs が41%ずつという比率は、ジョブを走らせる頻度とプラグインの入れ方で簡単に入れ替わる。ジョブを使わなければ jobs はほぼ0になるし、常用すればもっと増える。
  • 2.78 GB は blocked1ジョブに集中している。ジョブ1本の話であって、ジョブ機能一般の話ではない。
  • 重複は inode で見ているので、APFS のクローン(copy-on-write)は検出できていない。ファイルサイズを足し上げた値なので、実際にディスクを占有している量はこれより小さい可能性がある。
  • securityplugins の内訳は、入れたプラグインの構成そのものである。他人の環境の数字にはならない。
  • projects が30日で消えることは別途測ったが、これは cleanupPeriodDays を明示していない場合の観測値。設定していれば当然変わる。
  • ジョブの掃除がどの状態で走る設計なのかはドキュメントを確認していない。「donefailed も残っていた」は観測であって、仕様の記述ではない。

直した点

最初、jobs の 2.78 GB を「サブエージェントの記録」だと思って書きかけた。ファイル名が agent-*.jsonl だったからである。

実際にパスを最後まで見たら jobs/<id>/tmp/snap/.claude/projects/.../subagents/agent-*.jsonl で、スナップショットの中のコピーだった。本物は projects 側にある。記事に書いたコマンドを実際に走らせるまで、この取り違えに気づかなかった。

載せるコマンドは実際に走らせる。パスは末尾まで見る。

内訳と重複照合を出すスクリプトを置いておく。ファイル名もプロジェクト名も出力しない。

#!/usr/bin/env python3
"""~/.claude が何を溜めているかを実体サイズで内訳し、
中に「同じログの丸ごとコピー」が無いかを照合する。

    python3 claude_dir_size.py

ファイル名・プロジェクト名は出力しない。サイズと件数だけ。
"""
import os, json, glob, collections, statistics

ROOT = os.path.expanduser("~/.claude")


def walk(d):
    for r, _, fs in os.walk(d):
        for x in fs:
            p = os.path.join(r, x)
            if not os.path.islink(p):
                yield p


def size(paths):
    t = 0
    for p in paths:
        try:
            t += os.path.getsize(p)
        except OSError:
            pass
    return t


def jsonl_names(d):
    return {f for _, _, fs in os.walk(d) for f in fs if f.endswith(".jsonl")}


def main():
    top = []
    for name in sorted(os.listdir(ROOT)):
        p = os.path.join(ROOT, name)
        if os.path.islink(p):
            continue
        files = list(walk(p)) if os.path.isdir(p) else [p]
        top.append((size(files), len(files), name))
    total = sum(s for s, _, _ in top)
    print(f"~/.claude 合計 {total / 1024 ** 3:.2f} GB\n")
    print(f"{'':22}{'サイズ':>10}{'ファイル':>9}{'比率':>7}")
    for s, n, name in sorted(top, reverse=True)[:8]:
        print(f"{name:22}{s / 1024 ** 2:9.1f}MB{n:9,}{s / total * 100:6.1f}%")

    # ハードリンクなら du は二重に数える。まずそれを否定しておく。
    ino = collections.Counter()
    for p in walk(ROOT):
        try:
            ino[os.stat(p).st_ino] += 1
        except OSError:
            pass
    print(f"\n複数箇所から参照されている実体 {sum(1 for v in ino.values() if v > 1)}"
          f"(0 ならハードリンクによる二重計上はない)")

    proj = os.path.join(ROOT, "projects")
    real = jsonl_names(proj) if os.path.isdir(proj) else set()
    print(f"\nprojects の .jsonl {len(real):,} 本 / {size([p for p in walk(proj) if p.endswith('.jsonl')]) / 1024 ** 3:.2f} GB")
    sub = [p for p in walk(proj) if os.path.basename(p).startswith("agent-") and p.endswith(".jsonl")]
    if sub:
        s = sorted(os.path.getsize(p) for p in sub)
        print(f"  うちサブエージェント記録 {len(sub):,} 本 / {sum(s) / 1024 ** 3:.2f} GB"
              f" / 1本 中央値 {s[len(s) // 2] / 1024:.0f} KB / 最大 {s[-1] / 1024 ** 2:.1f} MB")

    # jobs は「ジョブごとの作業ディレクトリ」で、中に .claude ごと
    # コピーされていることがある。実体側と同名ファイルの割合を出す。
    jobs = os.path.join(ROOT, "jobs")
    if not os.path.isdir(jobs):
        return
    print("\n=== jobs のジョブ別")
    for d in sorted(glob.glob(os.path.join(jobs, "*")))    :
        if not os.path.isdir(d):
            continue
        b = size(list(walk(d)))
        st = "?"
        sp = os.path.join(d, "state.json")
        if os.path.isfile(sp):
            try:
                st = json.load(open(sp)).get("state", "?")
            except ValueError:
                pass
        line = f"  {b / 1024 ** 2:9.1f}MB  state={st}"
        mine = jsonl_names(d)
        if mine:
            dup = len(mine & real)
            line += f"  .jsonl {len(mine):,} 本 / 実体側と同名 {dup / len(mine) * 100:.1f}%"
        print(line)


if __name__ == "__main__":
    main()

関連

~/.claude の中身は、規約と設定と履歴が同じ場所に積み上がった結果です。どこに何を置くかを決めていないと、容量だけでなくどれが効いているかも分からなくなります。

層の切り方と、規約が効かなくなる4つのパターンをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?