Claude Code modのturn.stepでキャッシュの読み書きを表示する
modのturn.stepフックを非同期ジェネレーターで書き、リクエストごとのキャッシュ読み取りと書き込みをtranscriptに出します。サブエージェントの絞り込み、ターン合計、モデルの差し替えまで。
Claude Codeのmodで1回のモデルリクエストごとのトークン使用量を見るには、turn.step にフックを書きます。このイベントは非同期ジェネレーターで受けるのが決まりで、yield* next(e) でストリームを流しながら、完了した result を手に入れます。result.usage にはキャッシュの読み取りと書き込みのトークン数が入っているので、数行でリクエスト単位のキャッシュ表示ができます。
3つのターンイベントの役割
ターンとは、1つのプロンプトに答えるまでにClaudeが行う作業の全体です。modからは3つのイベントで追えます。
| イベント | 発火するとき | フックでできること |
|---|---|---|
turn.start | 発火するときターンの開始 | フックでできること観察のみ。e.turnId が他の2つと共通のID |
turn.step | 発火するとき1回のモデルリクエストを送る直前 | フックでできること使用量の読み取り、next({ ...e, model }) での別モデルへの差し替え、モデルを呼ばずに答える |
turn.complete | 発火するときターンの終了(ユーザーの中断を含む) | フックでできること観察、または { text } を返して回答の下に1行を出す |
ツール呼び出しを含むターンは、リクエストが複数回に分かれます。そのため turn.step は1ターンに何度も発火します。中断されたターンでは turn.complete の e.isAborted が true です。e.answer が最終テキスト、e.durationMs が所要時間、e.usage がそのターンのトークン合計になります。
書き分けにも注意点があります。リファレンスによると、非同期ジェネレーターで書くのは turn.step と process.spawn のフックだけで、turn.start と turn.complete を含むほかのフックは通常の非同期関数です。turn.complete を async function* で書かないでください。後半のサンプルでは、この違いのとおりに書き分けています。
リクエストごとのキャッシュ読み書きを出す
まず最小のフックです。形は公式のキャッシュ表示の例と同じで、mods のhooksモジュールの register に置きます。modの作り方そのものはClaude Codeのmodを80行で作るで追えます。
// hooks/register.js
export function register(on) {
// function* にすると、レスポンスを少しずつ素通しできる
on('turn.step', async function* ($, e, next) {
// リクエストを送り、流れてくる部分を転送し、完了した結果を受け取る
const result = yield* next(e)
// トークン数を報告しない結果は飛ばす
if (result.usage) {
$.ui.log(
'cache read ' + result.usage.cache_read_input_tokens +
' · wrote ' + result.usage.cache_creation_input_tokens
)
}
// 結果は変えずに返し、ターンを普段どおり続ける
return result
})
}Claudeの回答はこのmodが無いときと同じようにストリームで画面に出ます。リクエストが終わるたびに、transcriptへ淡色の行が1本ずつ増えます。ツール呼び出しを含むターンでは、リクエストの数だけ行が並びます。
result.usage が持つのは input_tokens・output_tokens・cache_read_input_tokens・cache_creation_input_tokens の4項目と、答えた model です。if (result.usage) のガードは飾りではなく、トークン数が付かない結果が来ることを前提にした書き方です。
API側が自分で実行したツール(advisorなど)は result.serverToolUses に入ります。Claude Codeが実行する呼び出しではないので、tool.call や tool.check のフックは動きません。使用量の表示にこの呼び出しも並べたいときは、result.serverToolUses を同じフックで読みます。このフィールドはレスポンスに該当の呼び出しが無いと存在せず、Claude Code v2.1.290以降が必要です。読み方の実例はserverToolUsesでadvisorの呼び出しを読むにあります。
読み取り率を足して「効いているか」を1行で見る
読み取りと書き込みの生の数字は、並べるだけでは良し悪しが分かりません。割合にすると判断しやすくなります。分母には、1回のリクエストの入力全体を使います。statuslineの current_usage を扱ったClaude Codeのcurrent_usageで直近リクエストの内訳を読むで整理したとおり、input_tokens はキャッシュを経由しない分だけで、3つを足したものが入力の合計です。
export function register(on) {
on('turn.step', async function* ($, e, next) {
const result = yield* next(e)
const u = result.usage
if (u) {
const read = u.cache_read_input_tokens
const wrote = u.cache_creation_input_tokens
const total = u.input_tokens + read + wrote
// 入力が0のときの 0 除算を避ける
const ratio = total > 0 ? Math.round((read / total) * 100) : 0
$.ui.log(
u.model + ' · read ' + read + ' (' + ratio + '%) · wrote ' + wrote +
' · fresh ' + u.input_tokens
)
}
return result
})
}この ratio は、ここで定義した計算式による値です。公式がリクエスト単位の割合を返しているわけではありません。セッション全体のキャッシュ統計をstatuslineで見るなら、statuslineでprompt_cacheを表示するの hit_ratio が使えます。使い分けは次の表のとおりです。
| 見たいもの | 手段 |
|---|---|
| 1回のリクエストが、いまキャッシュを読んだか書いたか | 手段turn.step の result.usage |
| セッション全体のキャッシュの効き具合 | 手段statuslineの prompt_cache |
| 直近1回の内訳を常時表示 | 手段statuslineの current_usage |
読み取りが0で書き込みだけが大きい行は、そのリクエストでキャッシュが作り直されたサインです。キャッシュが利用上限にどう効くかはプロンプトキャッシュはClaude Codeの利用上限をどう軽くするかにあります。
サブエージェントのリクエストを混ぜない
turn.step はサブエージェントのリクエストでも発火します。このときイベントの e.agentId に値が入ります。メインの会話だけを見たいなら、フックの先頭で絞ります。
on('turn.step', async function* ($, e, next) {
const result = yield* next(e)
// サブエージェントのリクエストは表示しない
if (e.agentId === undefined && result.usage) {
$.ui.log('main · read ' + result.usage.cache_read_input_tokens)
}
return result
})逆に、サブエージェントの分だけ見たければ条件を反転します。ただし条件の外で yield* next(e) を先に済ませておくこと。next を呼ばずに抜けると、リクエスト自体が送られません。agentId は tool.check でも、サブエージェントかメインの会話かを見分ける同じ役目をします。承認の判断でこの値と ceiling を読む具体例はtool.checkでサブエージェントの呼び出しを見分けるに書きました。
ターン合計はturn.completeで読む
ターン全体のトークン合計は、turn.complete の e.usage にあります。リクエスト別の行を足したい場合は、turn.step で貯めてから turn.complete で出す形にします。turn.complete の返り値に text を持つオブジェクトを返すと、回答の下に1行が出ます。
export function register(on) {
// ターンIDごとの読み取り・書き込みの合計
const totals = new Map()
on('turn.step', async function* ($, e, next) {
const result = yield* next(e)
if (e.agentId === undefined && result.usage) {
const t = totals.get(e.turnId) ?? { read: 0, wrote: 0, steps: 0 }
t.read += result.usage.cache_read_input_tokens
t.wrote += result.usage.cache_creation_input_tokens
t.steps += 1
totals.set(e.turnId, t)
}
return result
})
on('turn.complete', async ($, e, next) => {
const t = totals.get(e.turnId)
// 取り出したら捨てる。残すと表がセッション中ずっと育つ
totals.delete(e.turnId)
if (!t || e.agentId !== undefined) return next(e)
return {
text: t.steps + ' requests · cache read ' + t.read + ' · wrote ' + t.wrote,
}
})
}集計せずに済ませたいなら、turn.complete の e.usage だけでも1行は作れます。公式の例は { text: 'Done in 12 seconds' } の形です。
on('turn.complete', async ($, e, next) => {
// 中断されたターンは e.isAborted が true になる
const state = e.isAborted ? 'aborted' : 'done'
return { text: state + ' in ' + Math.round(e.durationMs / 1000) + 's' }
})この形ではリクエスト別の読み書きは分かりません。ターンの所要時間と合計だけを見たい用途に向きます。ただし返り値で next(e) を呼ばないので、同じイベントを扱うほかのmodの表示は出なくなります。
turn.complete はサブエージェントのターンでも e.agentId つきで発火します。上の例では、メインでないターンを next(e) で素通しにしています。e.turnId は turn.start が持つターンの識別子で、公式の表は「他の2つのイベントでもターンを識別する」と書いています。turn.step と turn.complete で同じ値が入る前提の集計ですが、turn.step の中身で実際に確かめた例は公式にありません。集計に進む前に $.ui.log(e.turnId) で値が入っているかを見ておくと、ターンごとの合計がずれる事故を避けられます。
貯める先の Map はプロセスのメモリ上にあるだけです。セッションをまたいで残したい数字は $.store に書く手がありますが、保存できるのはJSONで合計4MiBまでです。1リクエストごとの履歴を無制限に積む使い方は向きません。
別のモデルへ送り直す
turn.step は読むだけのイベントではありません。next({ ...e, model }) と書けば、そのリクエストを別のモデルに送れます。同じ要領で next({ ...e, effort }) と書けば、思考の深さを表す effort の値を変えて送れます。next を呼ばずに結果を作って返せば、モデルを呼ばずに答えることもできます。
on('turn.step', async function* ($, e, next) {
// 使いたいモデルのIDを入れる(自分の環境で使える名前にする)
const model = 'YOUR_MODEL_ID'
const result = yield* next({ ...e, model })
if (result.usage) {
// 実際に答えたモデルは usage.model で分かる
$.ui.log('answered by ' + result.usage.model)
}
return result
})差し替えたリクエストでキャッシュの読み取りがどう変わるかは、公式の説明がありません。差し替えの前後で上の読み取り率の行を並べて、自分の環境で確かめてください。リクエストを差し替えるのではなく、会話とは別に1回だけ質問を投げたいなら、$.model.complete を使うmodから別のモデルへ単発で質問する方法が向いています。
同じイベントを2回登録しない
キャッシュ表示とモデル差し替えを別々のフックに分けたくなりますが、同じイベントに複数の on を置くときは条件が付きます。公式の説明は「イベントごと、matcherごとに1回」です。session.start をmatcherなしで2回登録すると、モジュールの読み込み自体が on("session.start") is registered twice without a matcher で失敗します。turn.step で同じ条件を2回書くと、同様に落ちると考えるのが安全です。表示も差し替えも1つのフックの中に入れてください。
matcherは、on の2番目に渡すオブジェクトです。イベントのフィールドと比べ、すべて一致したときだけフックが動きます。値は文字列・配列・正規表現のどれでも書けます。公式の例は tool.call の tool フィールドだけで、turn.step に使える項目は書かれていません。サブエージェントの絞り込みは、前の節のとおりフック内の e.agentId の判定で書くほうが確実です。
イベント名には '*' も書けます。テレメトリー以外のすべてのイベントに一致するので、ターンのイベントだけを見たい用途には広すぎます。
表示が出ないとき・壊れたとき
フックの失敗はセッションを止めません。失敗の仕方で動きが分かれます。
nextを呼ぶ前に例外が出たとき: そのフックを飛ばし、次のハンドラーが代わりに走るnextが解決した後に例外が出たとき: その時点の結果がそのまま使われ、再実行はない
turn.step のフックは yield* next(e) の後に処理を書くので、表示部分の例外は後者です。ログの行が出なくてもレスポンスは失われません。理由は、modの名前とイベント名を含む1行(たとえば my-mod: tool.call hook skipped: threw Error: boom の形)で報告されます。
フックの実行時間には1イベントあたり10秒の上限があります。next の中で待つ時間や、$.clock.sleep 以外のmods API呼び出しの中で待つ時間は数えません。超えたフックはスキップされます。ログを出す程度なら収まります。使用量を外部へ送る処理を足すときは、次の値が効きます。
| 項目 | 上限 |
|---|---|
$.http.fetch 1回(リダイレクトと本文を含む) | 上限30秒 |
$.http.fetch のリクエスト本文・レスポンス本文 | 上限それぞれ4MiB |
| リダイレクトの追跡 | 上限5回まで |
.catch ハンドラーの実行時間 | 上限1秒 |
フックの中で残り時間を知りたいときは、next.budget.remainingMs が使えます。.catch を付ければ、失敗したときの代わりの動きも書けます。ただし turn.step のフックは処理の大半が next の後なので、ここで失敗しても結果は残ります。ハンドラーの next.called が true になる場面が中心で、next(e) を返せば元の結果がそのまま使われます。
同じイベントを扱うmodが複数あると、1本の連鎖になります。先に走るmodが最も外側で、イベントを先に見て、結果を後から見ます。自分の yield* next(e) が返す result は、内側のmodが手を加えた後の値です。表示がずれて見えるときは、他のmodが turn.step を書き換えていないかを疑ってください。
testで検証する
表示のフックは、実機のAPIを呼ばずに claude plugin test で確かめられます。turn.step のスタブも非同期ジェネレーターで書き、usage に検証用の値を載せます。
// スタブの usage は検証用に自分で決めた値で、実機の出力ではない
on('turn.step', async function* ($, e) {
yield { kind: 'text', index: 0, text: 'ok' }
return {
turnId: e.turnId,
index: e.index,
answer: 'ok',
toolUses: [],
stopReason: 'end_turn',
usage: {
input_tokens: 10, output_tokens: 5,
cache_read_input_tokens: 90, cache_creation_input_tokens: 0,
model: 'stub',
},
}
})この例なら読み取り率は90 ÷ (10+90+0)で90%になるはずです。usage: null のスタブも用意すれば、ガードの分岐を確かめられます。1つのテストは、timeoutMs を指定しない限り5秒で打ち切られます。ジェネレーターのスタブで待ちを入れるときは気に留めてください。on の呼び方やアサーションまで含めたテストの書き方はmodのテストをclaude plugin testで書く手順にまとめています。