朝、失敗した夜間タスクのログを開いて、手が止まりました。
親セッションの記録には「サブエージェントを4本起動した」という行と、最後に「1本が結果を返さなかった」という行しか残っていません。その4本が何を読み、どこで方針を変え、なぜ1本だけ黙ったのか。手がかりはどこにもありませんでした。
無人で走らせている以上、後から追えるのはログだけです。そのログに、肝心の中身が入っていない。
7月17日の更新で --forward-subagent-text フラグ(環境変数なら CLAUDE_CODE_FORWARD_SUBAGENT_TEXT)が入り、サブエージェントのテキストと思考を stream-json 出力に含められるようになりました。求めていたものです。
ただ、有効にして一晩回した翌朝、今度は別の問題に直面しました。ログが読める量ではなくなっていたのです。
有効にすると、何が流れてくるのか
まず、フラグの有無で出力がどう変わるかを確かめました。
# 従来: 親のイベントのみ
claude -p "4つのカテゴリの記事メタデータを検証して" \
--output-format stream-json > baseline.jsonl
# 7/17 以降: サブエージェントのテキストと思考も含む
claude -p "4つのカテゴリの記事メタデータを検証して" \
--output-format stream-json \
--forward-subagent-text > forwarded.jsonl
出力は JSON Lines です。1行1イベントなので、種別ごとの件数はすぐ数えられます。
# イベント種別の内訳を出す
jq -r '.type' forwarded.jsonl | sort | uniq -c | sort -rn
同じ指示を10回ずつ流し、その中央値を並べたものが次の表です。サブエージェント4本・親1本、Sonnet 5 での結果になります。
| 項目 | フラグなし | --forward-subagent-text | 倍率 |
| 行数(イベント数) | 218 | 4,930 | 22.6倍 |
| ファイルサイズ | 0.9 MB | 38.4 MB | 42.7倍 |
| うち thinking 由来 | 0 MB | 24.1 MB | — |
| うち assistant テキスト | 0.4 MB | 11.8 MB | 29.5倍 |
| 90日保存時の総量(1日3実行) | 0.24 GB | 10.4 GB | 43.3倍 |
42倍という数字そのものより、内訳のほうが設計の役に立ちました。増分の 63% が thinking です。そして thinking は、失敗の原因究明にはよく効く一方で、成功した実行では一度も開かれません。
つまり、増えた分の大半は「ほとんどの場合は読まないが、読みたい時には絶対に必要」という性質を持っています。全部捨てるのも、全部残すのも、どちらも間違いです。
3層に分けて、残すものを決める
そこで、イベントを3つの層に振り分けることにしました。
| 層 | 対象 | 保存期間 | 方針 |
| 骨格 | 親子の起動・終了・ツール呼び出し名・結果の成否 | 無期限 | 常に全量。これだけで実行の輪郭が復元できる |
| 本文 | assistant テキスト・ツール入力の要約 | 30日 | 先頭と末尾を残し、中間は文字数だけ記録 |
| 思考 | thinking ブロック | 7日/失敗時のみ無期限 | 成功実行では破棄。失敗した実行に紐づくものは残す |
「失敗時のみ残す」を実現するには、書き込みながら判断できません。実行が失敗したかどうかは、最後まで読まないと分からないためです。
そこで、いったん全部を一時ファイルに落とし、終了コードが確定してから振り分ける形にしました。ストリームを2回通す代わりに、判断を後ろに送れます。
フィルタを書く
そのまま動く実装です。標準入力から stream-json を受け、3層に振り分けて書き出します。
// filter-subagent-log.mjs
// 使い方: claude -p "..." --output-format stream-json --forward-subagent-text \
// | node filter-subagent-log.mjs --run-id 2026-07-17-nightly
import { createInterface } from 'node:readline';
import { createWriteStream, mkdirSync, rmSync } from 'node:fs';
const runId = process.argv[process.argv.indexOf('--run-id') + 1] ?? String(Date.now());
const dir = `./logs/${runId}`;
mkdirSync(dir, { recursive: true });
const out = {
skeleton: createWriteStream(`${dir}/skeleton.jsonl`),
body: createWriteStream(`${dir}/body.jsonl`),
thinking: createWriteStream(`${dir}/thinking.jsonl`),
};
const HEAD = 400; // 本文の先頭に残す文字数
const TAIL = 200; // 本文の末尾に残す文字数
// 中間を落として、落とした量を明示する
function clip(text) {
if (typeof text !== 'string' || text.length <= HEAD + TAIL) return text;
const dropped = text.length - HEAD - TAIL;
return `${text.slice(0, HEAD)}\n…[${dropped} chars clipped]…\n${text.slice(-TAIL)}`;
}
// 骨格に入れる種別。ここは削らない
const SKELETON_TYPES = new Set([
'system', 'result', 'tool_use', 'tool_result', 'subagent_start', 'subagent_stop',
]);
let failed = false;
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
let ev;
try {
ev = JSON.parse(line);
} catch {
// 壊れた行こそ後で効くので、骨格に生のまま逃がす
out.skeleton.write(JSON.stringify({ type: 'parse_error', raw: line.slice(0, 500) }) + '\n');
continue;
}
if (ev.type === 'result' && ev.subtype !== 'success') failed = true;
if (ev.is_error === true) failed = true;
if (SKELETON_TYPES.has(ev.type)) {
out.skeleton.write(JSON.stringify(ev) + '\n');
continue;
}
const blocks = ev.message?.content ?? [];
for (const b of blocks) {
const base = {
ts: ev.timestamp ?? new Date().toISOString(),
session_id: ev.session_id,
parent_tool_use_id: ev.parent_tool_use_id ?? null,
};
if (b.type === 'thinking') {
out.thinking.write(JSON.stringify({ ...base, thinking: b.thinking }) + '\n');
} else if (b.type === 'text') {
out.body.write(JSON.stringify({ ...base, text: clip(b.text) }) + '\n');
}
}
}
// 終了コードが分かってから、思考の扱いを決める
for (const s of Object.values(out)) s.end();
if (!failed) {
rmSync(`${dir}/thinking.jsonl`, { force: true });
console.error(`[log] ${runId}: success — thinking を破棄しました`);
} else {
console.error(`[log] ${runId}: failed — thinking を保持しました`);
}
parse_error を握り潰さず骨格へ逃がしている点だけ、意図を書き添えておきます。行が壊れているのは、たいてい出力が途中で切れた時です。そういう夜こそ、後から読みたくなります。
親と子を、後から突き合わせる
3層に分けても、どの行がどのサブエージェントのものか判別できなければ意味がありません。
Claude Code の stream-json では、サブエージェント由来のイベントに parent_tool_use_id が付きます。親側で Task ツールを呼んだ時の tool_use_id と一致するため、これを軸に組み立てられます。
# 骨格から、親のTask呼び出しを一覧する
jq -r 'select(.type=="tool_use" and .name=="Task")
| [.id, (.input.description // "-")] | @tsv' logs/$RUN/skeleton.jsonl
# 特定のサブエージェントの本文だけを時系列で追う
jq -r --arg id "$TOOL_USE_ID" \
'select(.parent_tool_use_id==$id) | "\(.ts) \(.text)"' logs/$RUN/body.jsonl
ここで一つ、実測して初めて分かったことがあります。
parent_tool_use_id が null のまま流れてくるイベントが、全体の 3〜5% ほど混ざります。セッション開始直後の system イベントと、ストリームが途中で切れた際の末尾に集中していました。親のものか子のものか、その行だけでは決められません。
対処として、session_id と時刻での近接を使う補完を入れています。厳密ではありませんが、事後解析の用途では十分でした。
// 相関IDが欠けた行を、直近の親Task呼び出しに寄せる
function attachFallbackParent(events) {
let lastTaskId = null;
return events.map((ev) => {
if (ev.type === 'tool_use' && ev.name === 'Task') lastTaskId = ev.id;
if (ev.parent_tool_use_id == null && lastTaskId) {
return { ...ev, parent_tool_use_id: lastTaskId, parent_inferred: true };
}
return ev;
});
}
parent_inferred: true を必ず立てています。推測で埋めた値と、実際に流れてきた値を混ぜてしまうと、後日その区別がつかなくなるためです。
公式に書かれていない落とし穴と、その対処
本番運用に載せて数週間回してみて、ドキュメントからは読み取れなかった注意点を4つ挙げます。いずれも私自身がつまずいた順に並べています。
1. 長時間セッションでは、フラグを常時オンにしない
7月16日の 2.1.209 で、headless/SDK セッションの大きなツール結果が際限なく積み上がる問題は修正されました。それでも、転送されたテキストは書き出し先のディスクを確実に食います。個人開発の夜間実行では、初回失敗した実行の再試行時だけオンにする形をお勧めします。私自身もそこに落ち着きました。
# 1回目は骨格のみ。失敗したら思考込みで再現を試みる
claude -p "$PROMPT" --output-format stream-json > run1.jsonl || \
claude -p "$PROMPT" --output-format stream-json --forward-subagent-text > run2.jsonl
2. thinking にはファイルパスとツール入力の断片が入る
サブエージェントの思考には、読んだファイルのパスや環境変数名がそのまま現れます。ログを共有ストレージに置くなら、書き出す前にマスクを挟む必要があります。私はトークンらしき文字列を潰す正規表現を1本、フィルタの clip() の手前に入れています。
3. 種別名は将来増える前提で書く
SKELETON_TYPES を Set で持ち、未知の種別は本文側へ落としています。知らない種別が来た時に例外で止まるより、雑にでも残るほうが後で助かります。
4. 骨格だけで実行の輪郭は復元できる
これが一番の発見でした。「1本が黙った」原因の切り分けは、骨格 0.9 MB の側だけで9割方つきます。thinking が必要になったのは、数週間で2回だけでした。増分の63%は、その2回のために積んでいることになります。
状況別に、いま置いている設定
| 状況 | フラグ | thinking 保存 | 理由 |
| 夜間の定期実行(成功が既定) | オフ | — | 骨格で足りる。ディスクを守る |
| 失敗後の再試行 | オン | 無期限 | 再現できる唯一の機会になりやすい |
| 新しいサブエージェント構成の投入初週 | オン | 7日 | 意図した分担で動いているかを目で確かめる |
| 手元での対話的な実行 | オフ | — | 画面で見えるものを二重に書く必要はない |
| 並列10本以上のワークフロー | オン+本文クリップを厳しく | 失敗時のみ | 本数に比例して増えるため、先頭200字まで詰める |
次の一手
まず1回、手元のいちばん重いタスクで --forward-subagent-text を付けて流し、jq -r '.type' out.jsonl | sort | uniq -c を叩いてみてください。自分の構成での内訳が出ます。thinking の比率が私の環境(63%)とどれだけ違うかで、フィルタのしきい値は変わってきます。
そのうえで、骨格・本文・思考の3つに分けて書き出すことを推奨します。ディスクに余裕がない場合は、思考を失敗時のみに絞るところから始めれば十分です。ここまでを整えておくと、次に夜のタスクが黙った朝、開くべきファイルが決まっています。
朝のログに答えが書かれている状態は、思っていたよりも静かな安心をくれるものでした。個人開発では、失敗そのものより「原因が分からないまま次の夜を迎えること」のほうが消耗します。
同じ朝を迎えた方の手元で、開くべきファイルが一つに決まるきっかけになれば嬉しく思います。