夜間に走らせたタスクが失敗していて、朝いちばんにログを開きました。
そこにあったのは、同じ行が何十回も繰り返された 400KB 近いテキストでした。スピナーが回った跡、進捗表示が上書きされた跡、色を付けるための制御列。肝心の「どのコマンドで、どう転んだのか」は、その堆積のどこかに埋もれていました。
grep で絞り込もうとしても、同じツール呼び出しが何度もヒットします。実際に走ったのが9回なのか37回なのかすら、その場では判断できませんでした。
個人開発で夜間に複数サイト分の処理を任せていると、朝のログが読めるかどうかは、その日に手を動かせる時間へそのまま跳ね返ります。原因の切り分けに1時間かかるなら、それは失敗の代償ではなく、記録のしかたの代償です。
原因は Claude Code の出力内容ではなく、描画のしかたにありました。2026-07-15 の更新で入った axScreenReader(プレーンテキスト描画への切り替え)は、支援技術のための設定として案内されていますが、無人実行のログを読める形に保つ道具としても、そのまま効きます。
ログが膨らむ理由は、内容ではなく描画にある
Claude Code のターミナル表示は、行を「追記」しているのではありません。カーソルを戻し、既に書いた領域を消し、書き直しています。人が画面を見ている間はこれが最適です。進捗が一箇所で更新され、視線が動かずに済みます。
ところがその過程は、パイプの先では全て履歴として残ります。カーソル移動 ESC[A、行消去 ESC[2K、色指定 ESC[38;5;…m。スピナーが1秒間に10回描き直せば、10行分の痕跡がログに積まれます。
つまりログの肥大は「Claude が喋りすぎた」のではなく、「画面のために10回書き直した工程が、そのまま記録された」結果です。ここを取り違えると、--output-format を変えたり出力を減らそうとしたりと、見当違いの方向に手が伸びます。私自身、最初はまさにそれをやりました。モデルに喋らせすぎたのだと思い込み、減らすべきものが無い場所を探していたわけです。
切り分けの方法ははっきりしています。制御列を除去した本文と、除去前の生バイトを別々に数えることです。
axScreenReader を有効にする3つの経路と、選び方
有効化の入口は3つあります。どれか1つで切り替わりますが、適用範囲が違うので、用途で選びます。
| 経路 | 指定 | 適用範囲 | 向いている場面 |
| CLI フラグ |
claude --ax-screen-reader |
その起動のみ |
単発の再現・条件を揃えた比較計測 |
| 環境変数 |
CLAUDE_AX_SCREEN_READER=1 |
そのプロセスと、そこから派生する子プロセス |
CI・スケジュール実行のラッパー(呼び出し側を書き換えずに済む) |
| settings |
"axScreenReader": true |
その設定が読まれる全ての起動 |
無人実行に専念させている端末・コンテナ |
私は環境変数を主に使っています。無人実行のラッパースクリプトは既にあり、そこに1行足すだけで、有人でターミナルを開いたときの表示は今まで通りに保てるからです。settings に書くと端末全体の既定が動くため、同じマシンで手作業もする場合は迷いが増えます。
判断の軸は「どの範囲まで巻き込みたいか」です。比較したいだけならフラグ、無人の系統だけを変えたいなら環境変数、その端末がもう無人専用ならば settings、という順で考えると迷いません。
同じタスクを2回流して計測する
体感で決めず、同一プロンプトを2条件で流して数えます。
#!/usr/bin/env bash
# capture.sh — 同じタスクを2条件で流し、ログの読みやすさを計測する
set -euo pipefail
PROMPT="${1:?usage: capture.sh <prompt-file>}"
OUT="${OUT_DIR:-./_capture}"
mkdir -p "$OUT"
# 端末幅を固定する。折り返し位置が変わると行数が比較できない
export COLUMNS=120
run_and_measure() {
local label="$1"
local raw="$OUT/${label}.raw"
local plain="$OUT/${label}.plain"
# script(1) で擬似TTYを与える。単なるリダイレクトだと非対話と判定されて
# 描画そのものが変わり、比較する条件が揃わない
script -q -c "claude -p \"$(cat "$PROMPT")\" --output-format text" /dev/null \
< /dev/null > "$raw" 2>&1 || true
# 制御列を落とした「本文」を作る
perl -pe 's/\e\[[0-9;?]*[A-Za-z]//g; s/\e\][^\a]*\a//g; s/\r//g' "$raw" > "$plain"
local bytes_raw bytes_plain esc lines uniq
bytes_raw=$(wc -c < "$raw")
bytes_plain=$(wc -c < "$plain")
esc=$(perl -ne '$c += () = /\e\[/g; END { print $c + 0 }' "$raw")
lines=$(grep -c '' "$plain" || true)
uniq=$(sort -u "$plain" | grep -c '' || true)
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$label" "$bytes_raw" "$bytes_plain" "$esc" "$lines" "$uniq"
}
printf 'label\traw_bytes\tplain_bytes\tesc\tlines\tuniq_lines\n'
( unset CLAUDE_AX_SCREEN_READER; run_and_measure tui )
( export CLAUDE_AX_SCREEN_READER=1; run_and_measure plain )
なぜ script(1) を挟むかというと、Claude Code は出力先が端末かどうかで描画を変えるためです。claude ... > log.txt と素直にリダイレクトすると、非対話向けの出力になり、普段の無人実行で実際に積まれているログとは別物を測ることになります。無人実行が擬似TTY配下(CI のジョブランナー、script 経由のラッパー等)で動いているなら、計測もその条件に揃えるべきです。
COLUMNS を固定するのも同じ理由です。折り返し位置が変われば行数が変わり、行数の比較が意味を失います。
手元の1タスク(MDX を数本読んで整合を確認する程度の作業)で流した結果です。
| 条件 | 生バイト | 本文バイト | ESC 出現 | 行数 | ユニーク行 |
| tui(既定) | 412,830 | 61,204 | 9,118 | 1,842 | 496 |
| plain(axScreenReader) | 74,102 | 71,946 | 214 | 638 | 601 |
数字は環境とタスクで動きます。だからこそ、この表を信じるのではなく capture.sh を自分のワークフローで流していただきたいところです。
計測結果の読み方 — バイト数の削減幅だけを見ない
生バイトは 412KB から 74KB へ、82% 減りました。ここだけを見ると気持ちのよい結果です。
しかし本文バイトは 61KB から 72KB へ、17% 増えています。プレーンテキスト側の方が、書かれている中身は多いのです。TUI は画面に収めるために長い出力を畳んだり省略したりしますが、プレーンテキスト描画はそれをそのまま流します。「軽くなった」と要約すると、この事実を取りこぼします。
読むべきはユニーク行の比率でした。
- tui: 496 / 1,842 = 27%
- plain: 601 / 638 = 94%
TUI 側は、ログの4分の3が同じ行の焼き直しです。この比率こそが「あとから読めるかどうか」を決めていました。バイト数は結果であって、指標ではありません。
実際の効き目は、単純な集計で確かめられます。Before は生ログを素朴に数えた場合です。
# Before: TUI の生ログをそのまま数える(再描画を重複計上する)
$ grep -o 'Bash(' _capture/tui.raw | grep -c ''
37
# After: プレーンテキスト出力を数える(実際の呼び出し回数と一致した)
$ grep -o 'Bash(' _capture/plain.plain | grep -c ''
9
朝のログで「9回なのか37回なのか判断できない」と書いた、その正体がこれです。数え方を直したのではなく、数えられる形で記録するようにしただけでした。
プレーンテキスト出力を台帳に落とす
行として素直に並ぶようになれば、あとは行指向で畳めます。人が読む前に、機械に一度通しておきます。
#!/usr/bin/env node
// ledger.mjs — プレーンテキストのログを行指向で読み、台帳(JSONL)に畳む
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const [, , file] = process.argv;
if (!file) {
console.error("usage: node ledger.mjs <plain.log>");
process.exit(2);
}
// 分類規則はここに隔離する。出力文言はバージョンで変わるため、
// 変更が要るときに触る場所を1箇所に閉じ込めておく
const RULES = [
{ kind: "tool_use", re: /^\s*\S{0,2}\s*(Bash|Read|Edit|Write|Grep|Glob)\((.*)\)\s*$/ },
{ kind: "error", re: /\b(Error|error:|failed|Traceback)\b/ },
{ kind: "permission", re: /\b(permission|approve|denied)\b/i },
{ kind: "result", re: /^\s*(Done|Completed|✓)\b/ },
];
const counts = new Map();
let lineNo = 0;
const rl = createInterface({
input: createReadStream(file),
crlfDelay: Infinity,
});
for await (const raw of rl) {
lineNo += 1;
const line = raw.trimEnd();
if (!line) continue;
const hit = RULES.find((rule) => rule.re.test(line));
const kind = hit ? hit.kind : "text";
counts.set(kind, (counts.get(kind) ?? 0) + 1);
// 分類できた行だけを台帳へ出す。text は数えるだけで捨てる
if (kind !== "text") {
console.log(JSON.stringify({ line: lineNo, kind, text: line.slice(0, 300) }));
}
}
const total = [...counts.values()].reduce((a, b) => a + b, 0);
const textRatio = ((counts.get("text") ?? 0) / total) * 100;
// 分類できなかった比率を毎回出す。これが跳ねたら規則が陳腐化した合図
console.error(
`summary total=${total} unclassified=${textRatio.toFixed(1)}% ` +
[...counts.entries()]
.filter(([k]) => k !== "text")
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k}=${v}`)
.join(" ")
);
なぜ「分類できなかった比率」を毎回出すのか。Claude Code の出力文言はバージョンで変わり得るからです。規則が黙って効かなくなると、台帳は「エラー0件」という嘘を静かに返します。未分類の比率が普段より跳ねたときだけ規則を見直す、という運用にしておけば、壊れたことに気づけます。
失敗した夜のログは、この台帳を通すと permission 2行と error 1行に畳まれました。400KB を目で追う仕事は、3行を読む仕事になりました。
有人と無人で設定を分ける
常用に切り替えるとき、私は端末ごと倒すのではなく、実行系統で分けました。
# ~/bin/claude-unattended — 無人実行はこのラッパー越しに呼ぶ
#!/usr/bin/env bash
set -euo pipefail
export CLAUDE_AX_SCREEN_READER=1
export COLUMNS=120
STAMP="$(TZ=Asia/Tokyo date +%Y-%m-%d_%H%M%S)"
LOG_DIR="${LOG_DIR:-$HOME/logs/claude}"
mkdir -p "$LOG_DIR"
RAW="$LOG_DIR/${STAMP}.log"
claude "$@" > "$RAW" 2>&1
STATUS=$?
# 成否にかかわらず台帳化する。失敗時だけ作ると、
# 「普段どうだったか」の比較対象が手元に残らない
node "$HOME/bin/ledger.mjs" "$RAW" > "$LOG_DIR/${STAMP}.jsonl" \
2> "$LOG_DIR/${STAMP}.summary"
exit "$STATUS"
exit "$STATUS" を最後に置いているのは、台帳化の成否でタスク全体の終了コードを塗り替えないためです。ログの都合が本体の判定を汚すと、原因の切り分けが一段面倒になります。
成功したときにも台帳を残すのは、比較のためです。失敗時だけ記録すると、tool_use が普段10前後なのか50前後なのかが分からず、異常かどうかを判断できません。手元では、成功時の要約行を並べておくだけで、失敗の輪郭がずいぶん見えるようになりました。
有人で claude を直接叩いたときは、これまで通りの TUI です。進捗が一箇所で更新される表示は、見ている人間にとってはやはり読みやすく、そこを壊す理由はありませんでした。
読み違えやすい4点
生バイトの削減を「出力が減った」と読まない
減ったのは再描画の痕跡であって、内容ではありません。むしろ本文は増えることがあります。保存容量の話と可読性の話は分けて扱ってください。
リダイレクトだけで計測しない
claude ... > log.txt は非対話向けの出力になり、無人実行の実態と食い違います。擬似TTY配下で動いているなら、計測も script(1) で条件を揃えます。
折り返しは端末幅に依存する
プレーンテキストでも、幅が変われば行数は変わります。COLUMNS を固定しないと、日によって行数が動き、比較が成立しません。
分類規則を増やしすぎない
規則が細かいほど、文言の変化で黙って壊れます。規則は台帳側の1箇所に隔離し、未分類の比率を監視して、跳ねたときだけ手を入れる。この形が、いちばん長持ちしました。
次にやること
まず capture.sh を、実際に夜回している1タスクで流してください。生バイトではなくユニーク行の比率を見て、それが 50% を大きく割っているなら、そのログは今すでに読めていません。
そこが確認できたら、無人の入口を1つだけラッパーに差し替えます。端末の既定を倒すのは、その系統で数日回してからで間に合います。
アクセシビリティのための設定が、そのまま無人実行の可読性に効いた——というのは私にとって発見でした。読みやすさを誰のために整えるかは違っても、整える対象は同じ「出力の形」だったのだと思います。実装の参考になれば幸いです。