Claude Media
Claude Codeのmodを80行で作る — Token Weatherの手順と3つの実例

Claude Codeのmodを80行で作る — Token Weatherの手順と3つの実例

Claude Code 2.1.287から使えるmodを、約80行のToken Weatherで空のフォルダーから作ります。Blast RadiusとReplay Theaterで、見る型と答える型の違いも確かめます。

Claude Code 2.1.287から、プラグインに入れたJavaScriptまたはTypeScriptのモジュールで、セッション内のイベントを横取りできるようになりました。この仕組みがmodです。「この数字が常に見えたら」「この種のコマンドの前だけ一度止めたい」といった不満を、自分で埋められます。

Claude開発者ブログがAddy Osmaniの執筆で公開したチュートリアルは、この仕組みを空のフォルダーから組み立てる内容です。題材は、コンテキストウィンドウの埋まり具合を天気予報で見せる約80行のmod「Token Weather」。続けてBlast RadiusとReplay Theaterという2つの大きめのmodを紹介します。ここでは骨格を組み直して、仕組み、作る手順、型の違い、導入の注意を順に追います。

modとは何か — hookが同じ住所に入ってくる

modは、Claude Codeのセッション内で動く小さなJavaScript/TypeScriptファイルです。中身は普通のプラグインで、.claude-plugin/plugin.jsonのマニフェストを持ちます。振る舞いだけがモジュールに入ります。

必要なものは次の2点です。

  • Claude Code 2.1.287以降を使う
  • 特別な有効化は要らない(modは既定で有効)

modはターミナルとデスクトップアプリの両方で動きます。ただし、ペインなどの描画が出るのはターミナルとデスクトップアプリのCodeタブだけです。VS Code拡張のチャットパネルやclaude -p(非対話モード)では、ハンドラーは動いても描画は出ません。

Claude Codeにはすでに、設定、権限ルール、スラッシュコマンド、スキル、ステータスラインという調整手段があります。modはその先で、Claude Codeの動作を書き換えたり差し替えたり、独自のUIを描いたりできます。modの実体は「プラグインに同梱されたhook」で、各modがセッション内のあらゆるイベントを見ます。

導入の経緯はv2.1.287のリリースノートにまとまっています。こちらは変更点の一覧、この記事は作り方の側です。

手で書かなくても、説明だけで作れる

modを作る入口はコードではありません。claudeを起動して、欲しいmodを文章で説明します。ホットリロードを許可するかを一度聞かれるので許可すると、ターンの終了時にmodが現れます。Claude Codeにはmodの書き方を知る組み込みのガイドがあり、状態の置き場所、claude plugin validateでの検証、どのイベントにhookするかをそちらが担います。

依頼文は、たとえば次の形です。

Make me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.
 
What it should show, on one line:
- A weather icon and word for how full the context window is: under 25% ☀ Clear (yellow), 25–49% ☁ Cloudy (cyan), 50–74% ☂ Showers (blue), 75–89% ☇ Storm (magenta), 90% and up ↯ Compact soon (red).
- The percentage used, then the tokens used out of the window, like "134.4k / 200k".
- A small chart of the last 12 turns, drawn with ▁▂▃▄▅▆▇█.
- How much the last turn added, like "▲ +98.3k last turn".
 
It should update after every turn.

依頼文が決めているのは「何を見たいか」だけで、APIの知識は要りません。以降の調整も「Stormを70%からにして」「末尾に金額を足して」と頼めば、その場で再読み込みされます。このときのmodは、そのセッションだけで読み込まれます。フォルダーは後で片付けられるため、残すときはフォルダーごと別の場所へコピーして、プラグインとして入れ直します。

modの3つの動き — 見る・書き換える・答える

modのコードは、register(on, options)を持つモジュールです。on(event, matcher?, hook)でhookを足し、どのhookも同じ形をしています。

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  // $ the mods API: ui, session, state, store, fs, process, clock, http, tool, command, model, ...
  // e this event's input, as plain data
  // next passes e to the other plugins and then to Claude Code's own behavior
  return next(e);
});

hookはミドルウェアのように連なります。自分のhookが動き、next(e)で次のプラグインへ渡し、最後にClaude Code本来の処理が走ります。この連なりの中でできる動きは、次の3つです。

動き書き方使いどころ
見る書き方const r = await next(e); /* look */ return r使いどころファイル編集の記録、ターン後の計測
書き換える書き方return next({ ...e, command: safer })使いどころ後段が見る内容を変える
答える書き方return { deny: "…" }(nextを呼ばない)使いどころツール呼び出しの拒否、コマンドやツールの自前提供

扱えるイベントは、ツール呼び出し、送信されたプロンプト、ターンの開始と終了、セッションの開始と終了、スラッシュコマンド、そしてui.renderです。ui.renderは、画面に出るあらゆる部品の描画を指します。

modのモジュールは専用のサンドボックスで動き、DOMもNodeもありません。外とのやり取りは、すべて第1引数の$を通ります。

settings hookと何が違うのか

settingsのhookは、イベントごとにシェルコマンドを起動し、JSONを標準入出力で受け渡します。modは一度読み込まれてセッションに居続けます。状態を持てて、イベントのたびに更新されるUIを描けて、ペインを開く、プロセスを走らせる、スラッシュコマンドや、モデルが呼べるツールを登録する、といった働きかけもできます。

Claude Code自身も、この仕組みを使っています。AGENTS.mdへの対応や、会話の横に出る/diffペインは、modとして作られています。ソースとテストはanthropics/claude-codeリポジトリのmods/にあり、チームの作り方をそのまま読めます。

Token Weatherを作る — 空のフォルダーから6ステップ

Token Weatherは、各ターンの後にコンテキストウィンドウの埋まり具合を読み、プロンプトの上に1行で描くmodです。天気のアイコン、パーセント、使用トークンとウィンドウの比、直近ターンの小さなチャート、直前のターンで増えた量が並びます。天気の区切りはこの表です。

使用率予報
25%未満予報☀ Clear
25〜49%予報☁ Cloudy
50〜74%予報☂ Showers
75〜89%予報☇ Storm
90%以上予報↯ Compact soon

デモでは、ターンごとにファイルを読ませて、200kのウィンドウに対して18%、67%、81%と進みます。バンドはClearからShowers、Stormへ移ります。

手順

Token Weatherができるまで

  1. 1

    フォルダー

    マニフェストとhooks.jsonを置く。

  2. 2

    描く

    AbovePromptに固定の1行を出す。

  3. 3

    $.state

    実測値を読み、履歴を$.stateに置く。

  4. 4

    予報

    使用率から天気を決め、チャートと増減を描く。

  5. 5

    検証

    validateとtestで範囲と挙動を確かめる。

  6. 6

    配る

    マーケットプレイスにして入れ直す。

ステップ1: フォルダーとマニフェスト

まず、Claude Codeのバージョンが足りているかを確かめます。

claude --version
# 2.1.287 or later

フォルダーの構成は次のとおりです。

token-weather/
├── .claude-plugin/
│   ├── plugin.json
│   └── types/          (written by Claude Code when it loads the mod)
├── hooks/
│   ├── hooks.json
│   └── token-weather.mjs
├── types/
│   └── index.d.ts      (added in step 3)
└── tests/
    └── token-weather.test.ts   (added in step 5)

plugin.jsonは標準のプラグインマニフェストです。

{
  "name": "token-weather",
  "version": "0.1.0",
  "description": "A live forecast of the context window, drawn above the prompt.",
  "author": { "name": "You" }
}

hooks/hooks.jsonはモジュールを指します。modが持てるモジュールは1つだけです。

{ "modules": ["./token-weather.mjs"] }

ステップ2: まず何かを描く

プロンプトの真上の帯はAbovePromptという部品で、Claude Code自身は何も描きません。最初の対象に向いています。ui.renderにhookして、要素のツリーを返します。

// hooks/token-weather.mjs
export function register(on) {
  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
    const { Box, Text } = $.ui.resolve(e);
    return Box({
      paddingX: 1,
      children: [Text({ color: "yellow", bold: true, children: "☀  Clear skies" })],
    });
  });
}

要素はグローバルではありません。$.ui.resolve(e)が、描画先の面に合ったコンストラクターを返します。面ごとに使える部品が少しずつ違うためです。JSXも使えて、ファクトリーはhです。プラグインを読み込んで起動します。

claude --plugin-dir ./token-weather

プロンプトの上に「☀ Clear skies」が出ます。セッションは開いたままにします。フォルダーは監視されているので、保存のたびにモジュールがその場で読み込み直されます。この再読み込みの速さが、modを書く楽しさの大半です。

ステップ3: 実測値を読み、$.stateに置く

$.session.usage()は、ステータスラインと同じ数値を返します。ステータスラインの値を扱う記事としては、prompt_cacheをstatuslineに出す手順があります。context.tokensは直近の応答で数えられた入力トークン量、context.windowはモデルのウィンドウ、context.percentはその比です。この呼び出しにコストはかかりません。内訳(breakdown)を求めたときだけ、トークン数を数えるリクエストが飛びます。

読み取りはセッション開始時と、各ターンの終わりに行います。

on("session.start", async ($, e, next) => {
  const result = await next(e);
  await takeReading($);
  return result;
});
 
on("turn.complete", async ($, e, next) => {
  const result = await next(e);
  if (!e.agentId) {
    await takeReading($); // main-loop turns only, not subagents
  }
  return result;
});

どちらも先にnext(e)を呼んでから観測します。つまり、前節の「見る」の動きです。サブエージェントのターンはe.agentIdの有無で除いています。

履歴の置き場所には落とし穴があります。モジュールの変数にlet readings = [];と置くのは自然ですが、ホットリロードは新しい読み込みです。registerが再実行され、session.startが再び発火し、モジュール変数は初期値に戻ります。履歴は$.stateに置きます。ホスト側がセッションを通して保持する名前付きの値で、再読み込みを越えて残ります。

// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };
 
async function takeReading($) {
  const { context } = await $.session.usage();
  if (!context?.window) return;
  const tokens = context.tokens ?? 0;
  const percent = context.percent ?? Math.round((tokens / context.window) * 100);
  const { value: history = [] } = await $.state.get(readings);
  const updated = [...history, { tokens, window: context.window, percent }];
  await $.state.set(readings, updated.slice(-HISTORY));
}

stateの値は、プラグインの型契約に宣言します。マニフェストが指す小さな.d.tsで、types/index.d.tsはこうです。

export type TokenWeatherReading = { tokens: number; window: number; percent: number };
 
declare module "claude-code" {
  interface PluginState {
    "token-weather": { readings: TokenWeatherReading[] };
  }
}

あわせてplugin.jsonに"types": "./types/index.d.ts"を足します。プラグインのマニフェストリファレンスでも、typesは「modの$.stateの値と$の名詞を宣言する.d.tsファイルへのパス」と説明されています。宣言を省くとclaude plugin validateが、直し方を示すエラーで止めます。

token-weather.readings is not declared: the manifest's types contract must name it in interface PluginState { … }

この宣言の見返りが、再描画の自動化です。描画hookの中で呼んだ$.state.getは、その描画を購読します。以後の$.state.setのたびに帯が描き直されるので、$.ui.invalidateを自分で呼ぶ場面はありません。

ステップ4: 予報を描く

ここまでを合わせた完成形のモジュールです。

// Token Weather: a live forecast of the context window, above the prompt.
const HISTORY = 12;
const BARS = "▁▂▃▄▅▆▇█";
const FORECAST = [
  { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
  { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
  { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
  { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
  { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
];
 
// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };
 
export function register(on) {
  on("session.start", async ($, e, next) => {
    const result = await next(e);
    await takeReading($);
    return result;
  });
  on("turn.complete", async ($, e, next) => {
    const result = await next(e);
    if (!e.agentId) {
      await takeReading($); // main-loop turns only, not subagents
    }
    return result;
  });
  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
    const { value: history = [] } = await $.state.get(readings);
    if (e.props.hasSurvey || history.length === 0) {
      return next(e);
    }
    const { Box, Text } = $.ui.resolve(e);
    return band(Box, Text, history, e.props.bodyColumns);
  });
}
 
async function takeReading($) {
  const { context } = await $.session.usage();
  if (!context?.window) return;
  const tokens = context.tokens ?? 0;
  const percent = context.percent ?? Math.round((tokens / context.window) * 100);
  const { value: history = [] } = await $.state.get(readings);
  const updated = [...history, { tokens, window: context.window, percent }];
  await $.state.set(readings, updated.slice(-HISTORY));
}
 
function band(Box, Text, history, columns) {
  const now = history[history.length - 1];
  const f = FORECAST.find((b) => now.percent < b.upTo);
  const parts = [
    Text({ color: f.color, bold: true, children: `${f.icon}  ${f.word}` }),
    Text({ children: `  ${now.percent}% of context` }),
    Text({ dimColor: true, children: `  ${short(now.tokens)} / ${short(now.window)}` }),
  ];
  if (columns >= 60) {
    parts.push(Text({ dimColor: true, children: "   last turns " }));
    parts.push(Text({ color: f.color, children: sparkline(history) }));
    if (history.length > 1) {
      parts.push(Text({ dimColor: true, children: trend(history) }));
    }
  }
  return Box({ flexDirection: "row", paddingX: 1, children: parts });
}
 
function sparkline(history) {
  const top = Math.max(...history.map((r) => r.tokens), 1);
  return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
}
 
function trend(history) {
  const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;
  if (delta === 0) return "  steady";
  return delta > 0 ? `  ▲ +${short(delta)} last turn` : `  ▼ ${short(-delta)} last turn`;
}
 
function short(n) {
  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
  return String(n);
}

ほかのmodにも持ち込める細部が3つあります。

  • 部品のpropsはe.propsにあります。hasSurveyはアンケートが帯を使いたい合図で、その間はnext(e)で譲ります。bodyColumnsは帯の実際の幅で、ペインが会話の横に付いている間は端末幅より狭くなります。幅に合わせてツリーを作ります。トップレベルにあるのはe.component、e.surface、e.requestId、e.viewportだけです。
  • 描くものがないときはnext(e)を返します。帯がClaude Codeと他のmodに戻ります。
  • 絵文字でなく、1セル幅の記号を使います。☀ ☁ ☂ ☇ ↯ はどの端末フォントでも幅がそろいます。

ステップ5: 検証とテスト

claude plugin validateは、Claude Codeと同じ読み方でマニフェストとモジュールのソースを読み、どのイベントにhookして何を呼ぶかを報告します。

claude plugin validate ./token-weather

ブログに載る出力は次のとおりです。stateの宣言、hookしているイベント、呼んでいるAPI、stateの読み書きが一覧になります。

> types ./types/index.d.ts declares state: token-weather.readings
> ./token-weather.mjs hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
> ./token-weather.mjs calls: $.session.usage (via takeReading), $.state.get, $.state.set (via takeReading), $.ui.resolve
> ./token-weather.mjs state writes: token-weather.readings
> ./token-weather.mjs state reads: token-weather.readings
√ Validation passed

手元のClaude Code 2.1.292でも動きを確かめました。空の設定ディレクトリ(CLAUDE_CONFIG_DIR)を指定し、ログインもモデル呼び出しもせずに、ステップ2の最小モジュールだけを置いたフォルダーへ実行した結果です(フォルダーのパスを示す行は省いています)。

Validating plugin manifest: …/.claude-plugin/plugin.json
Validating hooks: …/hooks/hooks.json
  > ./token-weather.mjs hooks: ui.render{component=AbovePrompt}
  > ./token-weather.mjs calls: $.ui.resolve
√ Validation passed

$.stateを使わない最小形では、state関連の行は出ません。モジュールにコードを足すたびに「hooksの行」「callsの行」が増えるので、実行前に呼び出し範囲を読む用途に向きます。claude plugin validate --helpには、警告もエラー扱いにする--strictと、結果をJSONで受け取る--jsonが載っています。

テストはtests/の*.test.tsを、本物のClaude Codeランタイムに対して走らせます。claude plugin test --helpの説明では、*.test.tsと*.test.tsxを1ファイルずつ子プロセスで実行し、テストファイルはclaude-code/testingからキットを読み込みます。失敗があると終了コード1で終わります。

claude plugin test ./token-weather

テストの中でonで登録したhookは、modの後ろで動きます。つまりClaude Codeが返すはずの答えを差し替えられます。$.session.usage()の返す値を自分で決められるので、36.1kトークンでClearが出て、134.4kに変えるとShowersと「67% of context」と「▲ +98.3k last turn」に切り替わる、という検証が書けます。テストはステップ3の再描画の挙動も確かめます。modが再描画を一度も求めなくても、turn.completeの後に帯が更新されることです。

ステップ6: 配る

modはプラグインなので、配り方もプラグインと同じです。マーケットプレイスは、.claude-plugin/marketplace.jsonを置いたフォルダーで足ります。

{
  "name": "my-mods",
  "owner": { "name": "You" },
  "plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}
claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user

大きめのmodが見せる型 — 止める・記録する

Token Weatherは観測と描画だけです。残る2つのmodは、イベントに割り込み、ペインを開き、入力を受けます。

Blast Radius — 危険なコマンドを実行前に止める

BashでClaudeがrm -rf、git reset --hard、git clean、force push、データベースのマイグレーションを呼ぶと、Blast Radiusはその呼び出しを保留します。コマンドが何に触れるかを調べ、ProceedとCancelのペインを開きます。2を押すと、理由つきの拒否がClaudeに返ります。1を押すと、書かれたとおりに実行されます。例では、rm -rf buildが消す9ファイル(1.1MB)が一覧に並びます。

使うhookは3つです。Bashへのtool.call、PaneとAbovePromptへのui.renderです。核は前掲の「答える」の動きです。

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  const risk = classify(String(e.command ?? ""));
  if (risk === null) return next(e); // everything else runs as normal
 
  const report = await measure($, risk, await $.session.cwd()); // git status, git clean -n, du, ...
  held = { command: e.command, risk, report, decision: null };
 
  const opened = await $.ui.open({ id: "blast-radius", title: "Blast Radius", focus: true });
  if (!opened.isPlaced) held.where = "band"; // too narrow for a pane: draw above the prompt
 
  while (held.decision === null && !next.signal.aborted) {
    // time inside $ calls doesn't count against the hook's time limit
    await $.process.run(["sleep", "0.25"]);
  }
 
  if (held.decision === "proceed") return next(e); // let it run
  return {
    deny: "Blast Radius held this command: the user pressed Cancel. " +
      `It would have: ${report.summary}.`,
  };
});
設計

Blast Radiusから持ち帰れる設計

  • ドライランで測る

    影響範囲は、各ツール自身のドライランで測ります。git status --porcelain、git clean -n、git log HEAD..origin/main、showmigrationsといったコマンドです。$.process.runには引数をargv配列で渡すので、パスの中身がシェルコードとして走ることはありません。

  • 待機ループで保留する

    呼び出しの保留は待機ループで行います。hookに与えられる時間は1回のdispatchで10秒ですが、$の呼び出しの中で待つ時間は数えられません。短いsleepプロセスで待ち、ボタンのonPressが決定を入れるか、next.signalが中止された(Escを押した)ときに抜けます。

  • ホットキーを付ける

    ボタンのホットキーはButton({ label: "Proceed", hotkey: "1", onPress })で付けます。クリック、TabとEnter、数字キーのどれでも押せます。

  • 狭いときは帯に縮退する

    幅が足りないときは帯に縮退します。端末が十分に広いとペインが会話の横に付きます。$.ui.openがisPlaced: falseを返したら、同じレポートをプロンプトの上に描きます。

Blast Radiusは安全網であって、権限の仕組みではありません。コマンドの文字列を読むだけなので、$(…)、エイリアス、rmを呼ぶスクリプトは素通りします。確実に止めたいなら権限ルールを使う、という位置づけです。

Replay Theater — 直前のターンの編集を1つずつ再生する

Replay Theaterは、ターンの間にEditとWriteの呼び出しを全部記録します。記録するのは対象ファイルと、編集前後のテキストです。ターンが終わるとプロンプトの上にヒントが出ます。rを押すか/replayと打つと、ペインが編集を1件ずつdiffで見せます。番号つきのステップ帯と、Prev、Next、Closeのボタンが付きます。編集をブロックも改変もせず、観測だけを行います。

on("tool.call", async ($, e, next) => {
  if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e))); // old/new text → diff
  return next(e); // the edit runs untouched
});
 
on("turn.start", ($, e, next) => {
  if (!e.agentId) state.pending = [];
  return next(e);
});
 
on("turn.complete", async ($, e, next) => {
  const r = await next(e);
  if (!e.agentId && state.pending.length) state.replay = state.pending; // one replay per turn
  return r;
});
 
on("session.start", async ($, e, next) => {
  const r = await next(e);
  await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
  return r;
});
 
on("command.run", { command: "replay" }, async ($, e) => {
  const opened = await openReplay($);
  return { text: opened ? "Replaying" : "No edits" };
});

見どころはイベントの対にあります。turn.startとturn.completeが編集を1ターンの束にまとめ、e.agentIdでサブエージェントのターンを混ぜません。スラッシュコマンドはsession.startで$.command.registerにより登録し、command.runで答えます。Writeでは、ファイルに書き込まれる直前に$.fs.readで旧内容を読むので、差分は本物です。置き場所は面が決めます。フルスクリーンではペインが右に付き、80桁では帯の上にインラインで開きます。modは、どちらでも同じツリーを描くだけです。

3つのmodは、どの動きの見本か

主な動きで分けると、見る型が2つ、答える型が1つです(Replay Theaterも/replayの実行には自分で答えます)。書き換える型は、この3つの中にありません。

mod主な動き触るイベント持つ状態
Token Weather主な動き見る触るイベントsession.start、turn.complete、ui.render持つ状態直近12ターンの読み値($.state)
Replay Theater主な動き見る触るイベントtool.call、turn.start、turn.complete、session.start、command.run持つ状態ターンごとの編集リスト
Blast Radius主な動き答える触るイベントtool.call、ui.render持つ状態保留中のコマンドと決定

この対応は、自作の出発点を決める手がかりになります。数字や履歴を出したいなら見る型、ブロックしたいなら答える型、入力を整えたいなら書き換える型が出発点です。書き換える型の題材には、prompt.submitにチームの規約を毎回足す使い方があります。

型宣言の置き場所と、描画が出ないときの確認

modを読み込むたびに、そのビルド向けの型宣言が.claude-plugin/types/に書かれます。エディターとtsc -pがそのまま使え、各イベント、$の各メソッド、各要素のpropsの基準になります。APIはリリース間で変わりうるため、バージョンに対する正解はこのフォルダーです。

描画が出ないときは、claude --debugを付けて起動し、hookが検証に通らないツリーを返したという行を探します。

配る前の注意 — modは自分の権限で動くコード

GitHubのリポジトリで配るなら、受け取る側は次の3コマンドで入れられます。リポジトリにマーケットプレイスのファイルを置けば、そのリポジトリがマーケットプレイスです。更新は普通のpushです。

/plugin marketplace add your-org/my-mods
/plugin install token-weather@my-mods
/reload-plugins

再読み込みでmodが始まります。見当たらないときは、Claude Codeを再起動します。

modを含むプラグインは、Claudeディレクトリにも提出できます。claude.ai/directory/manageから応募すると、リンクを知らない人にも見つけてもらえます。提出の流れはプラグイン提出ポータルの解説にあります。

modは、ステータスラインやhookの置き換えなのか

設定でできる範囲(設定、権限ルール、スラッシュコマンド、スキル、ステータスライン)は、modで置き換わるわけではありません。modは、設定でできる範囲の「その先」に置かれた仕組みです。数字はステータスラインと同じで、違いは出す場所と、履歴を持てる点です。ステータスラインが「今の値」の1行なら、Token Weatherは「直近12ターンの動き」を帯に描きます。

settings hookとの線引きは、状態とUIを持つかどうかです。ツール呼び出しの前に固定のチェックを走らせるだけなら、今までどおりシェルコマンドで足ります。保留して人に聞く、履歴をまたいで判断する、画面に描く、といった仕事は、読み込まれたままのmodが向きます。逆に言えば、modはhookの上位互換ではありません。置き場所が違う別の層です。

もう1つ、Blast Radiusの注記は軽く読めません。コマンド文字列を見て止める設計は、見落としの余地を自分で抱えます。守りたい境界があるなら、先に権限ルールで線を引き、modは判断材料を出す役に回します。コンテキストの中身を可視化する別の道はコンテキストウィンドウの可視化にもあります。

次に作るなら — 3つの問いの先にある題材

3つのmodは、それぞれ「コンテキストはどれだけ埋まったか」「このコマンドは何を消すか」「Claudeは何を変えたか」という問いから生まれています。自作の1本目は、自分が画面で確かめたい問いを1つ決めるところから始まります。題材の候補は次のとおりです。

  • $.session.usage()から、コストやレート制限のメーター。$.ui.statusでステータスラインに出す
  • prompt.submitのhookで、チームの規約を毎回のプロンプトに足す
  • Claudeがセッション中に読んだファイルを並べるペイン。見たものの地図になる
  • 長いターンが終わったら$.ui.toastで通知するフォーカスタイマー
  • 本番のkubectlコンテキストやterraform applyに合わせたtool.callのガード

まとめ

最初の1本は、状態を読んで描くだけの見る型が向いています。claude plugin validateの出力で、hookするイベントと呼ぶAPIの範囲が一覧で確かめられるためです。

見る型から答える型への距離は短いものです。動きの違いは、next(e)を返すか{ deny }を返すかにあり、どのhookも同じ($, e, next)の形なので、見る型で覚えた書き方がそのままBlast Radiusのようなガードにも使えます。

この記事を共有:XはてブLinkedIn