Claude Code modでsession.compactを止めsession.appendで履歴を書き換える
modのsession.compactは{ skip: 理由 }で圧縮を見送り、session.appendは保存直前の各行のcontentを書き換えます。session.endのreasonと/branchの扱いも載せます。
modの session.compact フックは、会話が圧縮される直前に呼ばれ、{ skip: 理由 } を返すとその圧縮を見送らせられます。session.append は、会話が保存する行ごとに保存の直前で呼ばれ、中身を書き換えられます。どちらもClaude Codeのmodが扱うセッション系イベントで、リファレンスの「Session」節に載っています。
この記事は、この2つを中心に、同じ節にある session.end の reason と session.attach / session.detach の読み方までまとめます。リファレンスの記述はv2.1.290時点のもので、各イベントのフィールドの細部は、手元のビルドが書き出す型定義で確かめる前提です。
session.compactで圧縮を見送る
session.compact が発火するのは、会話がこれから圧縮されるときです。フックが返せる値は { skip: reason } で、next を呼ばずにこの形で答えると、その圧縮を回避できます。
// hooks/register.js
export function register(on) {
on('session.compact', async ($, e, next) => {
// 理由を文字列で返すと、この圧縮は見送られる
return { skip: 'keep full history while debugging' }
})
}これは「圧縮を止める」書き方の最小形です。何も止めたくないときは next(e) を返します。next は次のハンドラ、末尾ならClaude Code自身の処理を呼ぶ関数で、フック共通の約束事です。戻り値の表に { skip: reason } しか載っていないイベントでも、見送らない場面の通し方はこの約束事に従います。
止めたままにすると何が起きるか
圧縮はコンテキストウィンドウが埋まったときの逃げ道です。常に skip を返すと、その逃げ道が塞がります。圧縮の発火条件と要約後に残る情報はcompactの仕組みの解説にあります。圧縮を止めたままウィンドウが埋まったときの扱いは、リファレンスのSession節に載っていません。
そのため、無条件に止めるフックは避けたほうが安全です。挙動は、skip を返すフックを1セッションで入れ、/context で使用率を見ながら確かめられます。条件を付ける手がかりは、$.session に用意されたメソッドです。usage は { startedAt, context, rateLimits, cost } を返し、context には tokens・window・percent が入ります。
on('session.compact', async ($, e, next) => {
const { context } = await $.session.usage()
// ウィンドウに余裕があるときだけ見送る
if (context.percent < 90) {
return { skip: 'context at ' + context.percent + '%' }
}
return next(e)
})しきい値の90は例で、手元の運用に合わせて決める数字です。
条件の材料は usage のほかにもあります。$.session.messages() は直近のやり取りを、新しい順に最大4,096件まで返します。$.session.model や turns と組み合わせれば、「長い調査のセッションだけ見送る」といった切り分けもできます。
session.compact のフックにも、1イベントあたり10秒の実行時間の上限があります。数えられるのはフック自身の処理時間で、next の中や、$.clock.sleep 以外のmods API呼び出しの待ち時間は含まれません。$.session.usage() の待ちは数えられず、自分で作ったPromiseを待つ時間は数えられます。上限を超えたフックはスキップされ、その圧縮は通常どおり進みます。重い判定をフックの中に書くと、止めたつもりの圧縮が走ります。
フックを書くうえで1つ守ることがあります。同じイベントを、同じ条件(matcher)なしで2回 on に登録すると、モジュールの読み込みが失敗します。session.compact の条件分岐は、1つのフックの中に全部入れます。
同名のメソッドとの混同に注意
$.session には compact と append というメソッドもあり、イベント名と綴りが重なります。イベントは「Claude Codeが圧縮・保存しようとした」ことを受け取る側、メソッドはmodの側から呼ぶ側と、向きが逆です。メソッドの引数は、apiページの該当節か手元のビルドの型定義で確かめてから使います。
settings側のPreCompact hookとの違い
圧縮の前後に処理を挟むだけなら、modでなくsettingsのhooksでも足ります。手順はPreCompact/PostCompact hookの記事にまとめています。mod側の session.compact は、同じイベントの前に立って圧縮そのものを見送らせられる点が違いです。
session.appendで保存直前の行を書き換える
session.append は、会話が保持する行ごとに1回ずつ、保存の直前に発火します。行の例として、プロンプト・応答のブロック・ツールの結果・通知(notice)が挙げられています。フックは next({ ...e, message }) で、message.content を変えたコピーを渡します。
書き換えられるのは、その行のテキストブロックと、行に含まれる tool_result ブロックの content です。
on('session.append', async ($, e, next) => {
// 例: 履歴に残る前に、APIキーらしき文字列を伏せる
const redact = (t) => t.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED]')
const content = Array.isArray(e.message.content)
? e.message.content.map((b) =>
b.type === 'text' ? { ...b, text: redact(b.text) } : b)
: e.message.content
return next({ ...e, message: { ...e.message, content } })
})上のコードは、戻り値の形(next({ ...e, message }))に沿った例示です。content が配列で届くか、ブロックの type や text が実際のフィールド名かは、使う前に手元のビルドの型定義で確かめてください。modの作成手順に、型定義を書き出す節があります。tool_result の content も、同じ要領で書き換えます。
tool_result は、Bashの出力やファイルの読み取り結果が入る行です。.env の中身や、トークンを含むコマンド出力がここに残りやすいので、伏せ字化の対象としてはテキストより効果が大きい場面があります。
書き換えで気をつける点
- 変更を加えたコピーを
nextに渡す。eは深く凍結されていて、代入するとエラーになる - 行ごとに発火するため、フックは短く保つ。1つのフックの実行時間は1イベントあたり10秒が上限で、超えるとそのフックはスキップされる
- 書き換えた内容が、以降のターンでモデルに渡る文脈にも反映されるかは、リファレンスに載っていません。履歴に残る文字列を変える用途と、モデルの見え方を変える用途は、分けて試します
- 例外を投げたフックはスキップされ、行は書き換えなしで次のハンドラへ進む。伏せ字化のフックが壊れると、秘密情報はそのまま保存される
最後の点は、伏せ字化の用途で効きます。on の戻り値に .catch を付けると、失敗時に代わりに答えるハンドラを置けます。ハンドラの中で next.called を見れば、元のフックが next を呼んだあとで失敗したのか、前で失敗したのかを見分けられます。ハンドラの実行時間の上限は1秒で、元のフックの10秒より短い点も合わせて覚えておきます。
また、複数のmodが同じイベントを持つときは、先に登録されたmodが外側に立ちます。先頭のmodが書き換えれば、あとのmodは元の文字列を見られません。組織が prependPlugins で入れたmodは、ユーザー導入のmodより前に並びます。自分のmodが書き換え前の行を見たいのか、書き換え後を見たいのかで、置き場所が変わります。
秘密情報の伏せ字化は典型例ですが、session.append は保存前の内容を見られる位置でもあります。フックが外部へ送信する処理を持たないか、導入前にコードを確認してください。管理者側でユーザー導入のmodを止めたいときは、allowManagedModsOnlyが使えます。
session.endのreasonと/branchの扱い
session.end は、セッションが終わるときと、/clear・/resume・/branch が実行されるときに発火します。e.reason の値は次の5つです。
| reason | 意味 |
|---|---|
clear | 意味/clear で履歴が消された |
resume | 意味/resume で別のセッションへ移った |
logout | 意味ログアウトした |
prompt_input_exit | 意味プロンプト入力欄から終了した |
other | 意味上のどれにも当たらない終わり方 |
見落としやすいのは、/branch が resume として報告される点です。reason === 'resume' で分岐するフックは、/resume と /branch を区別できません。区別が必要なフックは、$.session の id や messages といった別の手がかりで見分ける設計になります。
終わりの処理には時間の制約があります。session.end の全フックを合わせた時間は、SessionEndフックの予算までで、変更しなければ1.5秒です。後片付けを重くしないでください。
session.start との対称性にも注意が要ります。session.start はmodごとに1回、最初のプロンプトの前(modをリロードしたあとも)に発火し、/clear・/resume・/branch のあとには発火しません。つまり、/clear の前後で「終了は来るのに開始は来ない」状態になります。開始時にセットした状態を終了時に片付ける作りだと、/clear のあとで状態が空のまま動き続けます。
回避策の1つは、状態を session.start で作らず、使う時点で読み出す作りにすることです。複数セッションで共有したい値は $.store(同じマシンの全セッションが共有するキー・バリューストア、合計4 MiBのJSON)に置けますが、共有されるぶんキーにはセッションごとの識別子を含めます。session.end で reason が logout や prompt_input_exit のときだけ片付ければ、/clear や /resume で消したくない値が残ります。ただし終了時のフックは全体で1.5秒しかないため、削除は軽い処理に絞ります。
session.attachとsession.detach
別のアプリがセッションに接続したとき、または切断したときに、それぞれ session.attach と session.detach が発火します。どちらも返せるのは next(e) だけで、挙動を変えずに接続の有無を観測するイベントです。
on('session.attach', async ($, e, next) => {
$.ui.log('another app attached')
return next(e)
})接続するアプリの種類や e のフィールドは、型定義を見るか、modのtestでイベントを発火させて e の中身を出力すると確かめられます。
session.measure も同じ節にあり、各ターンの後と、プラン上限の使用率が変わったときに発火します。こちらも next(e) を返す観測用で、使用率を記録したいときの入口になります。
フックを入れたら確かめること
フックを入れるときは、まず1セッションだけ --plugin-dir で読み込んで試します。保存時にフックのモジュールが再読み込みされるため、修正と確認を繰り返せます。
claude plugin validate ./my-mod
claude --plugin-dir ./my-modclaude plugin validate は、モジュールが扱うイベントと呼び出すmods APIを一覧にします。session.compact と session.append を登録したのに出てこないときは、登録の綴りを疑います。フックが例外を出すとスキップされるため、効いていないように見えることがあります。原因の切り分けはmodが動かないときの切り分けに症状別にまとめています。イベントを発火させて検証する方法はmodのtestの書き方が扱います。
まとめ
session.compact は「圧縮するか」を、session.append は「何を保存するか」を握ります。止めるのも書き換えるのも強い介入なので、条件を付けて入れるのが前提です。session.end では /branch が resume になること、session.start が /clear の後に来ないことの2点を押さえておくと、状態管理の穴を避けられます。
modの tool.call を使った介入はtool.callの観測・書き換えで扱っています。