高額モデルで走り続けているセッションを hook に自己検出させる(Claude Code)
セッション内で /model 選択した高額モデルは既定モデル設定でも PreToolUse でも止まらない。hook stdin の transcript_path で自セッションのモデルを読み、毎ターン警告を注入する型。全読み禁止・async exit・realpath 起動判定・BOM の落とし穴と、集計側に発生元セッションIDを載せる改修まで。
約8.3万トークンの節約 (API料金換算で約120円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「高額モデルで走り続けているセッションを hook に自己検出させる(Claude Code)」は、AIのしつけカテゴリのAI指示書(MDファイル)です。セッション内で /model 選択した高額モデルは既定モデル設定でも PreToolUse でも止まらない。hook stdin の transcript_path で自セッションのモデルを読み、毎ターン警告を注入する型。全読み禁止・async exit・realpath 起動判定・BOM の落とし穴と、集計側に発生元セッションIDを載せる改修まで。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約8.3万トークン(API料金換算で約120円)・87%のトークンを節約できます。
- カテゴリ
- AIのしつけ
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約9.5万トークン
- この巻物使用時
- 約1.2万トークン
- 節約量
- 約8.3万トークン (約120円)
- 更新日
- 2026-08-20
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/hook-claude-code/raw を読み込んで、この指示書どおりに実装して"
中身
高額モデルで走り続けているセッションを hook に自己検出させる(Claude Code)
これが解決する問題
Claude Code で「このモデルは使わない」というコスト規律を決めても、セッション内で /model を使って選んだモデルはそのセッションに張り付き、既定モデル設定では戻らない。結果、次のような事故が起きる。
- ある日
/model <高額モデル>を実行した長寿命セッションが、数週間 resume され続けて高額モデルを使い続ける settings.jsonの"model": "<安いモデル>"はセッション内の選択を上書きしないPreToolUseの hook でサブエージェント(Agent/Task)のmodel指定を deny していても、メインループのモデルには一切効かない- 日次のコスト集計が「高額モデル検出 🚨」と出しても、どのセッションが原因かを出していないので誰も直せず放置される
実例: 単一セッションが 40 日間で 891k output tokens を高額モデルで消費していたが、毎日 🚨 が出ていたのに止まらなかった。
鍵となる事実
hook の stdin には transcript_path が渡る。 よって hook は「今このセッションの直近アシスタント応答がどのモデルだったか」を自分の transcript から読める。「ホスト側のモデル選択は hook では検知できない」は誤り。
hook stdin の例:
{ "hook_event_name": "UserPromptSubmit", "session_id": "...", "transcript_path": "/path/to/<sessionId>.jsonl", "cwd": "..." }
transcript は JSONL で、アシスタント応答の行が message.model と message.usage.output_tokens を持つ。
実装
<リポジトリ>/tools/model-pin-guard.mjs として作り、UserPromptSubmit(毎ターン警告=見逃せない)と SessionStart(resume 直後)の両方に登録する。
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const TAIL_BYTES = 256 * 1024; // transcript は数十MBになる。全読み禁止
const BANNED = /<禁止したいモデル名の正規表現>/i;
function allowed(home, sessionId) {
// 「今回だけそのモデルで」を人が明示指定した時の例外。別 hook が書いた許可ファイルを読むだけにして二重実装しない
try {
const allow = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'model-allow.json'), 'utf8').replace(/^/, ''));
return Boolean(sessionId) && allow.sessionId === sessionId && Date.parse(allow.until) > Date.now();
} catch { return false; }
}
function inspectTranscript(transcriptPath) {
const size = fs.statSync(transcriptPath).size;
const start = Math.max(0, size - TAIL_BYTES);
const length = size - start;
const buffer = Buffer.alloc(length);
const fd = fs.openSync(transcriptPath, 'r');
try { fs.readSync(fd, buffer, 0, length, start); } finally { fs.closeSync(fd); }
let lines = buffer.toString('utf8').split(/\r?\n/);
if (start > 0) lines = lines.slice(1); // 先頭は行途中で切れているので捨てる
let currentModel = '', hits = 0, outputTokens = 0;
for (let i = lines.length - 1; i >= 0; i -= 1) { // 末尾から遡る=最初に見つかったものが最新
if (!lines[i].trim()) continue;
let row; try { row = JSON.parse(lines[i]); } catch { continue; }
const model = row?.message?.model;
if (typeof model !== 'string' || !model || model.includes('<synthetic>')) continue;
if (!currentModel) currentModel = model;
if (BANNED.test(model)) { hits += 1; outputTokens += Number(row?.message?.usage?.output_tokens) || 0; }
}
return { currentModel, hits, outputTokens };
}
async function main() {
let raw = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) raw += chunk;
if (!raw) return;
let input; try { input = JSON.parse(raw.replace(/^/, '')); } catch { return; }
const home = process.env.APP_HOME || os.homedir(); // テストが偽 HOME を使えるようにする
if (allowed(home, input.session_id)) return;
let detected; try { detected = inspectTranscript(input.transcript_path); } catch { return; }
if (!BANNED.test(detected.currentModel)) return;
const context = [
`🚨 このセッションは ${detected.currentModel} で動いています(規約で禁止/別課金)。`,
'直ちに `/model <安いモデル>` で切り替えるか、セッションを閉じて新しいセッションで続けてください。',
`検出: 末尾読み取り範囲の該当応答 ${detected.hits}件 / 出力 ${(detected.outputTokens / 1000).toFixed(0)}k tok。`,
].join('\n');
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: input.hook_event_name || 'UserPromptSubmit', additionalContext: context } }));
}
// エントリ判定は realpath 解決で行う(後述)。未処理 rejection で exit≠0 になると注入が捨てられるので必ず握る
if (isEntry(import.meta.url)) await main().catch(() => {});
登録(settings.json)は async: true を付けない。context 注入する hook に async を付けると黙殺される。
{ "hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"<リポジトリ>/tools/model-pin-guard.mjs\"", "timeout": 5 }] }],
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"<リポジトリ>/tools/model-pin-guard.mjs\"", "timeout": 5 }] }]
} }
必ず守る設計上の制約(どれも実機で踏んだ事故)
- transcript を全読みしない。 長寿命セッションの transcript は数十MBになる。
statSyncでサイズを取り、末尾数百KBだけreadSyncする。先頭行は行途中で切れているので捨てる。 process.exit()を async 関数の中で呼ばない。 Windows の Node がAssertion failed: !(handle->flags & UV_HANDLE_CLOSING)でクラッシュし、additionalContextを stdout に書けていても exit≠0 だと hook 失敗として注入が丸ごと捨てられる。早期終了はreturn、トップレベルは.catch(() => {})。- エントリ判定を素の文字列比較でやらない。
import.meta.url === argv[1]相当の比較は、配布パスが symlink/junction だと外れてmain()が一度も呼ばれず出力ゼロ・exit 0 で静かに死ぬ。fs.realpathSync.nativeで両側を解決し、Windows は大小無視で比較する。 - 設定ファイルの BOM に耐える。
JSON.parseの前に.replace(/^/, '')。BOM 付き設定を読めない実装は「登録が一度も成功しないのに1行のログだけ残す」形で壊れる。 - ブロックしない。
permissionDecisionは使わず警告注入だけにする。モデル切替は人間の操作であり、止めても前に進まない。 - 例外機構を二重実装しない。 「今回だけ明示指定」の許可は既存の許可ファイルを読むだけにする。
集計側も同時に直す(これが無いと警告は放置される)
日次のコスト集計が「禁止モデル検出(out N tok)」と数字だけ出していると、どのセッションが原因か分からず何日も放置される。集計時に transcript 単位で該当モデルの output tokens を積み、最大のもののセッションIDと最終検出日時を警告文に載せる。
// ~/.claude/projects/**/*.jsonl を走査。行ごとの timestamp で期間を絞る
// (ファイル mtime で絞ると長寿命セッションの全履歴を誤集計する)
let fileHitOut = 0, fileLatest = 0;
for (const line of lines) { /* ... */ if (family === 'banned') { fileHitOut += outTokens; fileLatest = Math.max(fileLatest, ts); } }
if (fileHitOut > (top?.outputTokens || 0)) top = { sessionId: path.basename(file, '.jsonl'), outputTokens: fileHitOut, latest: fileLatest };
// → 警告文に `発生元: ${top.sessionId}(最終 ${fmt(top.latest)})— そのセッションで /model を切り替えるか閉じる` を足す
配布するなら(複数台で運用する場合)
- hook を追加したら、登録スクリプトと「必須hookの棚卸し」の両方に載せる。 片方だけだと、既存端末は毎日同期していてもファイルは更新されるが
settings.jsonへの登録が行われず、一度も発火しない。 - 必須hookの欠落を
SessionStartで検知して自動再登録する(監視だけでなく自己修復まで)。 - 施策の「未使用」を数値で可視化する。使われていない仕組みは存在しないのと同じ。
テスト(CI で回すこと)
CI が実行するテストランナーの中に書く(新しいテストファイルを作ってもランナーが拾わなければ CI では走らない)。最低これだけ:
- 末尾のアシスタント応答が禁止モデルの偽 transcript → 警告が出る
- 末尾が許可モデル → 無出力
- 許可ファイルが同一 session・未失効 → 黙る
- 許可ファイルが別 session または失効済み → 警告が出る
transcript_path不在 / 壊れた JSON / stdin 空 → exit 0 かつ無出力(落ちない)- 巨大 transcript でも末尾判定: 先頭に禁止モデル行を1MB超、末尾に許可モデル行 → 警告が出ないこと(=全読みしていない)。逆パターンで出ることも確認
- ソースに
process.exit(が無く、realpath ベースのエントリ判定を使っていることの静的検査
Windows でも CI を回す。 実際に踏んだ不具合は Windows 固有だった(パス・改行・async exit のクラッシュ)。Linux だけで回すと同じ見逃しが再発する。
検証(「たぶん動く」で終わらせない)
実装後、本物の長寿命 transcript を渡して警告が出ることと、現在のセッションの transcript を渡して無出力であることを実測する。登録後は settings.json が BOM 無しで JSON.parse 可能なこと(=アプリが起動できること)と、バックアップが取れていることも確認する。
よくある質問
+「高額モデルで走り続けているセッションを hook に自己検出させる(Claude Code)」とは何ですか?
セッション内で /model 選択した高額モデルは既定モデル設定でも PreToolUse でも止まらない。hook stdin の transcript_path で自セッションのモデルを読み、毎ターン警告を注入する型。全読み禁止・async exit・realpath 起動判定・BOM の落とし穴と、集計側に発生元セッションIDを載せる改修まで。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約9.5万トークンかかりますが、この巻物を使えば約1.2万トークンで済みます。差し引き約8.3万トークン(API料金換算で約120円)・87%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
AI運用ルールを機械的に守らせる hook 設計 — ルール文が守られない本当の理由
チームでAIエージェントを使うと運用ルールが必ず守られなくなる。真因は「読んでいない」ではなく hook がそのマシンで登録されていない/委譲先が沈黙して壊れていること。禁止=実行前拒否・誘導=依頼時の具体コマンド注入・担保=セッション開始時の自己修復の3層、明示例外の短命トークン、warn→blockの段階昇格、BOM/サンドボックス/timeout など失敗が沈黙する罠と、環境依存で落ちないテストの作り方までを実測ベースでまとめた導入手順。
マキモノ検索スキル — AIが自分で巻物を探して使えるようになるMD
あなたのAIエージェント (Claude Code等) にこのMDを読ませると、開発タスクを受けたとき自動でマキモノAPIを検索し、最適な指示書を取得してから作業するようになります。導入は貼るだけ。
無人AIセッションのバックグラウンド委譲が静かに殺される事故を潰す
ヘッドレスで起動したAIエージェントがバックグラウンド委譲した子プロセスは、ターン終了で kill されるのに親は exit 0 を返す。機械的に deny するフック、通知の作り方、対応中フラグの戻し忘れ、Windows製worktreeがLinux側から解決できない罠までを含む恒久対策。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア