/history で日をまたいだ使い方の記録(日ごとのターン数・トークン・ツール回数)を縦棒グラフと曜日×時間帯のヒートマップで見る (直近 7 日の数をほかの mod にも見せる。記録は毎日 1 回控えを取れる)

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。
Anthropic とは関係のない、個人が作った非公式の mod です。
| mod | 呼び方 | 何をするか |
|---|---|---|
| mod-hub | /mod | 画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り |
| cc-dash | /dash | セッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる |
| work-bar | 入力欄の上に自動で出る | 作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン |
| aqua-pet | /aquarium | 作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる |
| project-board | /projects | フォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細 |
| git-pane | /git | 今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない |
| token-meter | /tokens | ターンごとのトークンの積み上げ棒とキャッシュの当たり率 |
| focus-timer | /focus-timer | ポモドーロ。減っていくリングと今日の本数 |
| session-recap | /session-recap | セッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約 |
| edit-log | /edits | このセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」 |
| todo-board | /todos | 今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」 |
| test-runner | /testrun | 設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる |
| prompt-book | /prompts | よく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え |
| live-commentary | /jikkyo | Claude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西 |
| turn-gacha | /gacha | ターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる |
| dev-rpg | /rpg | 作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる |
| code-omikuji | /omikuji | 今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言 |
| agent-watch | /agent-watch | 動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る |
| canvas-pane | /canvas | Claude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる |
| rule-switch | /rules | 「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる |
| wait-games | /games | 長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる |
| spec-board | /specs | SpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ) |
| ci-pane | /ci | 今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い |
| port-watch | /ports | 待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン |
| pre-check | /precheck | コミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで) |
| face-band | /face で出す / 隠す | 待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る |
| done-chime | /chime | 長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし |
| turn-haiku | /haiku | ターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧 |
| session-timeline | /timeline | このセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁 |
| file-map | /filemap | このセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ |
| toast-inbox | /inbox | ほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く |
| usage-history | /history | 日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる |
| fail-log | /errors | ツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける |
| daily-quest | /quest | 日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる |
| trophy-shelf | /trophies | dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も |
| mod-check | /mod-check | mod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする |
| compact-cue | 入力欄の上に自動で出る・/cue | コンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない |
| ship-guard | 常時・/guard | Claude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す |
| rule-watch | 入力欄の上に自動で出る・/rulewatch | Claude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる |
| enc-guard | 常時・/enc | 文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open |
| release-draft | /release | 前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ |
| reply-frame | 常時 | 返答をオレンジ、自分の発言を青の角丸の枠で囲む |
| danger-frame | 常時 | 危ないシェルのコマンドの行を赤い枠で囲む |
| long-turn-toast | 常時 | 長くかかったターン(既定 120 秒超)をトーストで知らせる |
| usage-pane | /usage-pane | 使用量の JSON を 3 本のバーで表示 |
| devlog-pane | /devlog | フォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示 |
| run-guard | 常時・/runguard | Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る |
| week-card | /week | 1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる |
reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。
VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>
ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>
Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>
好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:
/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。
mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。
コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。
claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。
最初に(mod-hub・toast-inbox・mod-check)
claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods
全部(48 本)をまとめて(bash / zsh):
for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done
PowerShell:
foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }
この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。
リポジトリを clone して、そのフォルダから読ませることもできます。
takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になりますCLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してくださいD:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...
/haiku の「自動で詠む」で止められますtaskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認ですgit config --global --add safe.directory <フォルダ> を足してください$.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消しますgit grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してくださいnpm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取りますhooks/register.tsx 311 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DayRec, Days, HistoryMetric, HistoryRange, HistoryRestore, HistoryWeekDay } from '../types'
5import { addDay, asDays, asHeat, busiest, chartSvg, dayKey, dayLabel, EMPTY_DAY, fmtNum, heatSvg, lastDays, mergeDays, mergeHeat, slot, SLOTS, summarize, UNIT, weekOf, withUnknown } from './history'
6import { backupDaily, followTheme, jstDay, listBackups, mayWrite, readTheme, rememberVersion } from './style'
7
8type $ = EngineInterface
9
10const PANE = 'usage-history'
11const TITLE = '使い方の記録'
12const DESCRIPTION = '日ごとのターン数やトークンの記録をグラフで見る'
13
14const CLOSED: HistoryRestore = { days: null, pick: null }
15
16const DAYS = atom({ plugin: 'usage-history', key: 'days' } as const, {} as Days)
17const HEAT = atom({ plugin: 'usage-history', key: 'heat' } as const, [] as number[])
18const WEEK = atom({ plugin: 'usage-history', key: 'week' } as const, [] as HistoryWeekDay[])
19const RANGE = atom({ plugin: 'usage-history', key: 'range' } as const, 7 as HistoryRange)
20const METRIC = atom({ plugin: 'usage-history', key: 'metric' } as const, 'turns' as HistoryMetric)
21const RESTORE = atom({ plugin: 'usage-history', key: 'restore' } as const, CLOSED)
22const NOTE = atom({ plugin: 'usage-history', key: 'note' } as const, '')
23
24const OLD_VERSION = '新しい版が入りました。VS Code を開き直すまで記録は書きません'
25const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
26
27// /history を自分で取れたか (取れなければ /usage-history にして、/history は横取りしない)
28let ownsMain = false
29
30/** 設定の backupDir (絶対パスでなければ空 = 控えない)。register で入れる */
31let backupDir = ''
32/** 控えを書いた (書かなくてよいと分かった) JST の日。同じ日は fs を読みに行かない */
33let backedDay = ''
34
35// 絶対パス (/x、\x、C:\x、C:/x) だけを受け付ける。相対パスや「C:」は今のフォルダから読まれてしまう
36const folderOption = (value: unknown) => {
37 const raw = String(value ?? '').trim()
38 return /^(?:[\\/]|[A-Za-z]:[\\/])/.test(raw) ? raw : ''
39}
40
41/** その日はじめての時だけ、store の生の値 (知らない欄ごと) を days と heat の 2 つの控えに書く */
42async function backup($: $, rawDays: unknown, rawHeat: unknown, now: number) {
43 if (backupDir === '' || rawDays === undefined || backedDay === jstDay(now)) return
44 try {
45 await backupDaily($, backupDir, 'days', rawDays, now)
46 if (rawHeat !== undefined) await backupDaily($, backupDir, 'heat', rawHeat, now)
47 backedDay = jstDay(now)
48 } catch {
49 // 書けなければ次のターンの終わりにもう一度
50 }
51}
52
53/** 日ごとの記録と、そこから作る直近 7 日 (他の mod が読む) を一緒に置き換える */
54async function putDays($: $, days: Days, now: number) {
55 await update($, DAYS, () => days)
56 await update($, WEEK, () => weekOf(days, now))
57}
58
59// まだ store に書いていない差分。メインのターンの終わりにまとめて 1 回書く
60let pendingDays: Days = {}
61let pendingHeat: number[] = []
62const bump = (key: string, d: Partial<DayRec>) => {
63 pendingDays[key] = addDay(pendingDays[key] ?? EMPTY_DAY, d)
64}
65
66// store への書き込みは 1 本ずつ
67let chain: Promise<unknown> = Promise.resolve()
68const serial = <T,>(f: () => Promise<T>) => {
69 const p = chain.then(f, f)
70 chain = p.catch(() => {})
71 return p
72}
73
74/** 書けなかった差分を、溜めている差分に戻す (次のターンの終わりにもう一度書く) */
75function putBack(days: Days, heat: number[]) {
76 for (const [k, d] of Object.entries(days)) bump(k, d)
77 if (heat.length > 0) pendingHeat = mergeHeat(pendingHeat, heat)
78}
79
80const BROKEN = '記録を読めなかったので、まだ書いていません。次のターンの終わりにもう一度読みます'
81const UNSAVED = '記録を書けませんでした。次のターンの終わりにもう一度書きます'
82
83/** store の key を読む。無ければ { v: undefined }、読めなければ (reject) undefined */
84const readKey = ($: $, key: 'days' | 'heat') =>
85 $.store.get(key).then(
86 v => ({ v }),
87 () => undefined,
88 )
89
90// 書く直前に読み直し、差分だけを足す (他のセッションが足した分を消さない)
91const flush = ($: $) =>
92 serial(async () => {
93 if (Object.keys(pendingDays).length === 0 && pendingHeat.length === 0) return
94 const now = await $.clock.now()
95 // 古い版のまま動いている時は書かない (新しい版の欄を消さないように。差分は溜めたまま)
96 if (!(await mayWrite($, now))) return
97 const gotDays = await readKey($, 'days')
98 const gotHeat = await readKey($, 'heat')
99 // 読めない時 (reject・形が違う) は書かない。差分は溜めたまま次に回す
100 const savedDays = gotDays === undefined ? undefined : gotDays.v === undefined ? {} : asDays(gotDays.v)
101 if (gotDays === undefined || savedDays === undefined) {
102 await update($, NOTE, () => BROKEN)
103 return
104 }
105 // heat は 168 枠でなければ上書きしない (heat の差分だけ溜めたまま)
106 const savedHeat = gotHeat === undefined ? undefined : gotHeat.v === undefined ? [] : asHeat(gotHeat.v)
107 const days = pendingDays
108 const heat = savedHeat === undefined ? [] : pendingHeat
109 pendingDays = {}
110 if (savedHeat !== undefined) pendingHeat = []
111 await backup($, gotDays.v, gotHeat?.v, now)
112 const nextDays = mergeDays(savedDays, days)
113 await putDays($, nextDays, now)
114 // この版の知らない欄 (新しい版が足した物) は読んだまま残す
115 const isDays = await $.store.set('days', withUnknown(gotDays.v, nextDays)).then(
116 () => true,
117 () => false,
118 )
119 let isHeat = true
120 if (savedHeat !== undefined && heat.length > 0) {
121 const nextHeat = mergeHeat(savedHeat, heat)
122 await update($, HEAT, () => nextHeat)
123 isHeat = await $.store.set('heat', nextHeat).then(
124 () => true,
125 () => false,
126 )
127 }
128 putBack(isDays ? {} : days, isHeat ? [] : heat)
129 const note = await read($, NOTE)
130 if (!isDays || !isHeat) await update($, NOTE, () => UNSAVED)
131 else if (savedHeat === undefined) await update($, NOTE, () => BROKEN)
132 else if (note === BROKEN || note === UNSAVED) await update($, NOTE, () => '')
133 })
134
135// ---------------------------------------------------------------- 控えから戻す
136
137async function openRestore($: $) {
138 // 日付はファイルから来るので、形の合う物だけ (画面の文字に制御文字を入れない)
139 const days = (await listBackups($, backupDir, 'days').catch(() => [])).map(b => b.day).filter(d => DATE_RE.test(d))
140 await update($, RESTORE, () => ({ days, pick: null }))
141}
142
143/** 控えの日の記録に戻す。store の days と heat を控えの値にして、画面の記録も読み直す */
144const restore = ($: $, day: string) =>
145 serial(async () => {
146 const now = await $.clock.now()
147 if (!(await mayWrite($, now))) {
148 await update($, NOTE, () => OLD_VERSION)
149 return
150 }
151 const b = (await listBackups($, backupDir, 'days').catch(() => [])).find(x => x.day === day)
152 const days = asDays(b?.value)
153 if (!b || !days) {
154 await update($, NOTE, () => 'この控えは読めない形でした')
155 return
156 }
157 const h = (await listBackups($, backupDir, 'heat').catch(() => [])).find(x => x.day === day)
158 const heat = asHeat(h?.value)
159 const isSaved = await (async () => {
160 await $.store.set('days', b.value)
161 if (heat) await $.store.set('heat', heat)
162 })().then(
163 () => true,
164 () => false,
165 )
166 if (!isSaved) {
167 await update($, NOTE, () => '記録を書けませんでした')
168 return
169 }
170 await putDays($, days, now)
171 if (heat) await update($, HEAT, () => heat)
172 await update($, RESTORE, () => CLOSED)
173 // heat の控えが無い (読めない) 日は、曜日と時間帯の記録を今のまま残す。混ざったことを言っておく
174 await update($, NOTE, () => (heat ? `${day} の記録に戻しました` : `${day} の日ごとの記録に戻しました (曜日と時間帯の記録は控えが無いので今のまま)`))
175 })
176
177const openPane = async ($: $) => {
178 await $.ui.open({ id: PANE, title: TITLE })
179 return { text: '使い方の記録を開きました。' }
180}
181
182export const register: Register = (on, options) => {
183 backupDir = folderOption(options.backupDir)
184 followTheme(on)
185
186 on('session.start', async ($, e, next) => {
187 const started = await next(e)
188 await rememberVersion($)
189 const got = await $.command.register({ name: 'history', description: DESCRIPTION }).catch(() => undefined)
190 ownsMain = got?.command === 'history'
191 if (!ownsMain) await $.command.register({ name: 'usage-history', description: DESCRIPTION }).catch(() => {})
192 await readTheme($)
193 const now = await $.clock.now()
194 const rawDays = await $.store.get('days').catch(() => undefined)
195 const rawHeat = await $.store.get('heat').catch(() => undefined)
196 await backup($, rawDays, rawHeat, now)
197 await putDays($, asDays(rawDays) ?? (await read($, DAYS)), now)
198 const heat = asHeat(rawHeat)
199 if (heat) await update($, HEAT, () => heat)
200 return started
201 })
202
203 on('session.end', async ($, e, next) => {
204 await flush($).catch(() => {})
205 return next(e)
206 })
207
208 // 数えるだけ。結果を待たず、落ちても呼び出しは止めない
209 on('tool.call', async ($, e, next) => {
210 bump(dayKey(await $.clock.now()), { tools: 1 })
211 return next(e)
212 }).catch(($, e, next) => next(e))
213
214 on('turn.complete', async ($, e, next) => {
215 const at = await $.clock.now()
216 const isMain = e.agentId === undefined
217 const isTurn = isMain && e.reason === 'answer'
218 const u = e.usage
219 bump(dayKey(at), {
220 turns: isTurn ? 1 : 0,
221 subTurns: isMain ? 0 : 1,
222 durationMs: isMain ? e.durationMs : 0,
223 inputTokens: u?.input_tokens ?? 0,
224 outputTokens: u?.output_tokens ?? 0,
225 cacheRead: u?.cache_read_input_tokens ?? 0,
226 })
227 if (isTurn) {
228 if (pendingHeat.length === 0) pendingHeat = new Array<number>(SLOTS).fill(0)
229 pendingHeat[slot(at)] = (pendingHeat[slot(at)] ?? 0) + 1
230 }
231 // サブエージェントの分は溜めておき、メインのターンの終わりに 1 回書く
232 if (isMain) void flush($).catch(() => {})
233 return next(e)
234 }).catch(($, e, next) => next(e))
235
236 on('command.run', { command: 'history' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
237 on('command.run', { command: 'usage-history' }, openPane)
238
239 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
240 const t = $.ui.resolve(e)
241 const { Box, Text, Button } = t
242 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
243 const now = await $.clock.now()
244 const days = await read($, DAYS)
245 const heat = await read($, HEAT)
246 const range = await read($, RANGE)
247 const metric = await read($, METRIC)
248 const restoring = await read($, RESTORE)
249 const note = await read($, NOTE)
250 const { total, top, value, average } = summarize(lastDays(days, now, range), metric)
251 const unit = UNIT[metric]
252 const peak = busiest(heat)
253 const line = `直近 ${range} 日 ${unit} 合計 ${fmtNum(value)} / 1 日平均 ${fmtNum(average)}`
254 const topLine = top ? `いちばん多かった日 ${dayLabel(top.key)} ${fmtNum(top.value)} ${unit}` : `直近 ${range} 日はまだ記録がありません`
255
256 return (
257 <Box flexDirection="column" gap={1}>
258 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
259 {([7, 30] as const).map(n => (
260 <Button key={`range-${n}`} dimColor={range !== n} label={`${n} 日`} variant={range === n ? 'primary' : undefined} onPress={() => update($, RANGE, () => n)} />
261 ))}
262 <Button
263 key="metric" dimColor
264 label={metric === 'turns' ? '出力トークンで見る' : 'ターン数で見る'}
265 onPress={() => update($, METRIC, m => (m === 'turns' ? 'output' : 'turns'))}
266 />
267 </Box>
268 {Svg ? (
269 <Box flexDirection="column" gap={1}>
270 <Svg key="chart" source={chartSvg(days, now, range, metric)} alt={line} />
271 <Svg key="heat" source={heatSvg(heat)} alt={peak ? `いちばん多い時間帯 ${peak.week}曜 ${peak.hour} 時台` : '曜日と時間帯のヒートマップ'} />
272 </Box>
273 ) : (
274 <Box flexDirection="column">
275 <Text bold>{line}</Text>
276 <Text dimColor>{`ツール ${fmtNum(total.tools)} 回 / サブエージェント ${fmtNum(total.subTurns)} ターン`}</Text>
277 <Text dimColor>{`入力 ${fmtNum(total.inputTokens)} / 出力 ${fmtNum(total.outputTokens)} / キャッシュ読み ${fmtNum(total.cacheRead)}`}</Text>
278 <Text>{topLine}</Text>
279 {peak && <Text>{`よく使う時間帯 ${peak.week}曜 ${peak.hour} 時台(${peak.turns} ターン)`}</Text>}
280 </Box>
281 )}
282 {restoring.days !== null && (
283 <Box key="restore-box" flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} paddingY={1}>
284 <Text color="yellow" bold>
285 控えから戻す
286 </Text>
287 {backupDir === '' ? (
288 <Text dimColor>設定の backupDir を入れると控えが取れます</Text>
289 ) : restoring.days.length === 0 ? (
290 <Text dimColor>まだ控えがありません (毎日はじめて記録を書く時に取ります)</Text>
291 ) : restoring.pick === null ? (
292 restoring.days.map(d => (
293 <Button key={`restore:${d}`} label={`${d} の記録`} dimColor onPress={() => void update($, RESTORE, r => ({ ...r, pick: d }))} />
294 ))
295 ) : (
296 <Box key="restore-ask" flexDirection="column">
297 <Text>{`${restoring.pick} の記録に戻しますか。今の記録は上書きされます`}</Text>
298 <Button key="restore:yes" label="戻す" variant="primary" onPress={() => void restore($, restoring.pick ?? '')} />
299 <Button key="restore:no" label="やめる" dimColor onPress={() => void update($, RESTORE, r => ({ ...r, pick: null }))} />
300 </Box>
301 )}
302 <Button key="restore:close" label="閉じる" dimColor onPress={() => void update($, RESTORE, () => CLOSED)} />
303 </Box>
304 )}
305 {note !== '' && <Text color="yellow">{note}</Text>}
306 <Button key="restore" label="控えから戻す" dimColor onPress={() => void (restoring.days === null ? openRestore($) : update($, RESTORE, () => CLOSED))} />
307 </Box>
308 )
309 })
310}
311hooks/history.ts 215 lines1// 日ごとの使い方の純関数(日付・足し方・刈り込み・集計・SVG)
2import type { DayRec, Days, HistoryMetric, HistoryRange, HistoryWeekDay } from '../types'
3import { C, HEADER_H, W, countUp, esc, fadeIn, frame, growColumn, header, panel } from './style'
4
5/** 残す日数 */
6export const KEEP_DAYS = 400
7export const SLOTS = 7 * 24
8export const EMPTY_DAY: DayRec = { turns: 0, tools: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, durationMs: 0, subTurns: 0 }
9const FIELDS = Object.keys(EMPTY_DAY) as (keyof DayRec)[]
10const WEEK = ['日', '月', '火', '水', '木', '金', '土']
11/** ヒートマップの行(月曜始まり) */
12const ROWS = ['月', '火', '水', '木', '金', '土', '日']
13
14const jst = (ms: number) => new Date(ms + 9 * 3600_000)
15
16/** 日本時間の 'YYYY-MM-DD' */
17export const dayKey = (ms: number) => jst(ms).toISOString().slice(0, 10)
18
19/** 曜日(月=0)×時刻のます目の番号 */
20export function slot(ms: number) {
21 const d = jst(ms)
22 return ((d.getUTCDay() + 6) % 7) * 24 + d.getUTCHours()
23}
24
25/** 'YYYY-MM-DD' → '10/18(土)' */
26export function dayLabel(key: string, withWeek = true) {
27 const d = new Date(`${key}T00:00:00Z`)
28 return `${d.getUTCMonth() + 1}/${d.getUTCDate()}${withWeek ? `(${WEEK[d.getUTCDay()]})` : ''}`
29}
30
31export const addDay = (a: DayRec, b: Partial<DayRec>): DayRec =>
32 Object.fromEntries(FIELDS.map(k => [k, a[k] + (b[k] ?? 0)])) as DayRec
33
34const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0)
35
36/** store から読んだ値を確かめる。形の合わない日は捨てる。オブジェクトでなければ undefined */
37export function asDays(v: unknown): Days | undefined {
38 if (typeof v !== 'object' || v === null || Array.isArray(v)) return undefined
39 const out: Days = {}
40 for (const [k, rec] of Object.entries(v)) {
41 if (!/^\d{4}-\d{2}-\d{2}$/.test(k) || typeof rec !== 'object' || rec === null) continue
42 out[k] = Object.fromEntries(FIELDS.map(f => [f, num((rec as Record<string, unknown>)[f])])) as DayRec
43 }
44 return out
45}
46
47export const asHeat = (v: unknown): number[] | undefined =>
48 Array.isArray(v) && v.length === SLOTS ? v.map(num) : undefined
49
50const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
51
52/**
53 * store に書く形。書く直前に読んだ raw のうち、この版の知らない欄 (日付でないキー・日ごとの知らない数) を残して days を重ねる。
54 * 古い版のまま動いているセッションが、新しい版の記録を消さないように。刈り込んだ古い日は戻さない
55 */
56export function withUnknown(raw: unknown, days: Days): Record<string, unknown> {
57 const o = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
58 const out: Record<string, unknown> = Object.fromEntries(Object.entries(o).filter(([k]) => !DATE_RE.test(k)))
59 for (const [k, d] of Object.entries(days)) {
60 const was = o[k]
61 const extra = typeof was === 'object' && was !== null && !Array.isArray(was) ? Object.entries(was).filter(([f]) => !(FIELDS as string[]).includes(f)) : []
62 out[k] = { ...Object.fromEntries(extra), ...d }
63 }
64 return out
65}
66
67/** 保存済みの日々に、このセッションで溜めた差分を足し、新しい KEEP_DAYS 日だけ残す */
68export function mergeDays(saved: Days, delta: Days): Days {
69 const out: Days = { ...saved }
70 for (const [k, d] of Object.entries(delta)) out[k] = addDay(out[k] ?? EMPTY_DAY, d)
71 return Object.fromEntries(
72 Object.keys(out)
73 .sort()
74 .slice(-KEEP_DAYS)
75 .map(k => [k, out[k] as DayRec]),
76 )
77}
78
79export const mergeHeat = (saved: readonly number[], delta: readonly number[]) => Array.from({ length: SLOTS }, (_, k) => (saved[k] ?? 0) + (delta[k] ?? 0))
80
81/** 今日で終わる n 日(古い順)。記録の無い日は 0 */
82export function lastDays(days: Days, now: number, n: number) {
83 return Array.from({ length: n }, (_, k) => {
84 const key = dayKey(now - (n - 1 - k) * 86_400_000)
85 return { key, rec: days[key] ?? EMPTY_DAY }
86 })
87}
88
89/** 直近 7 日の日ごとの数 (state の week。他の mod が読む形) */
90export const weekOf = (days: Days, now: number): HistoryWeekDay[] =>
91 lastDays(days, now, 7).map(({ key, rec }) => ({
92 day: key,
93 turns: rec.turns,
94 tools: rec.tools,
95 inputTokens: rec.inputTokens,
96 outputTokens: rec.outputTokens,
97 minutes: Math.round(rec.durationMs / 60_000),
98 }))
99
100export const metricOf =(rec: DayRec, metric: HistoryMetric) => (metric === 'turns' ? rec.turns : rec.outputTokens)
101
102export function summarize(list: readonly { key: string; rec: DayRec }[], metric: HistoryMetric) {
103 const total = list.reduce((a, d) => addDay(a, d.rec), EMPTY_DAY)
104 let top: { key: string; value: number } | undefined
105 for (const d of list) {
106 const value = metricOf(d.rec, metric)
107 if (value > 0 && (top === undefined || value >= top.value)) top = { key: d.key, value }
108 }
109 return { total, top, value: metricOf(total, metric), average: metricOf(total, metric) / Math.max(1, list.length) }
110}
111
112/** 1 万を超えたら「万」、1 億を超えたら「億」で 1 桁 */
113export function fmtNum(n: number) {
114 if (n >= 1e8) return `${(n / 1e8).toFixed(1)}億`
115 if (n >= 1e4) return `${(n / 1e4).toFixed(1)}万`
116 return Number.isInteger(n) || n >= 100 ? String(Math.round(n)) : n.toFixed(1)
117}
118
119export const UNIT: Record<HistoryMetric, string> = { turns: 'ターン', output: '出力トークン' }
120
121/** いちばん多い時間帯(ターン数)。記録が無ければ undefined */
122export function busiest(heat: readonly number[]) {
123 let best = -1
124 let max = 0
125 heat.forEach((v, k) => {
126 if (v > max) {
127 max = v
128 best = k
129 }
130 })
131 return best < 0 ? undefined : { week: ROWS[Math.floor(best / 24)] ?? '', hour: best % 24, turns: max }
132}
133
134/** 見出し・合計と 1 日平均・日ごとの縦棒・いちばん多かった日 */
135export function chartSvg(days: Days, now: number, range: HistoryRange, metric: HistoryMetric) {
136 const list = lastDays(days, now, range)
137 const { total, top, value, average } = summarize(list, metric)
138 const unit = UNIT[metric]
139 const parts: string[] = [header('日ごとの使い方', `直近 ${range} 日の${unit}`, C.green)]
140
141 // 合計と 1 日平均の 2 枚
142 const tileY = HEADER_H + 10
143 const tileW = (W - 14 * 2 - 10) / 2
144 for (const [k, [label, n]] of ([
145 ['合計', value],
146 ['1 日平均', average],
147 ] as const).entries()) {
148 const x = 14 + k * (tileW + 10)
149 parts.push(panel(x, tileY, tileW, 58))
150 parts.push(`<text x="${x + 12}" y="${tileY + 20}" font-size="12" fill="${C.dim}">${esc(`${label}(${unit})`)}</text>`)
151 parts.push(countUp(n, `x="${x + 12}" y="${tileY + 46}" font-size="22" font-weight="bold" fill="${C.text}"`, fmtNum, k * 0.1))
152 }
153 const subY = tileY + 58 + 18
154 parts.push(`<text x="14" y="${subY}" font-size="12" fill="${C.dim}">${esc(`ツール ${fmtNum(total.tools)} 回 / サブエージェント ${fmtNum(total.subTurns)} ターン`)}</text>`)
155 parts.push(`<text x="14" y="${subY + 16}" font-size="12" fill="${C.dim}">${esc(`入力 ${fmtNum(total.inputTokens)} / 出力 ${fmtNum(total.outputTokens)} / キャッシュ読み ${fmtNum(total.cacheRead)}`)}</text>`)
156
157 // 日ごとの縦棒。いちばん多かった日は黄色、今日は青、ほかは緑
158 const chartTop = subY + 34
159 const chartH = 110
160 const bottom = chartTop + chartH
161 const left = 14
162 const gap = range === 7 ? 10 : 3
163 const colW = (W - left * 2 - gap * (range - 1)) / range
164 const max = Math.max(1, ...list.map(d => metricOf(d.rec, metric)))
165 parts.push(`<line x1="${left}" y1="${bottom}" x2="${W - left}" y2="${bottom}" stroke="${C.line}"/>`)
166 list.forEach((d, k) => {
167 const v = metricOf(d.rec, metric)
168 const x = left + k * (colW + gap)
169 const h = v === 0 ? 0 : Math.max(2, ((chartH - 16) * v) / max)
170 const fill = top?.key === d.key ? C.yellow : k === list.length - 1 ? C.blue : C.green
171 parts.push(growColumn(x, bottom, colW, h, fill, k * (range === 7 ? 0.05 : 0.015)))
172 if (range === 7 && v > 0) parts.push(fadeIn(`<text x="${x + colW / 2}" y="${bottom - h - 4}" font-size="12" fill="${C.text}" text-anchor="middle">${esc(fmtNum(v))}</text>`, 0.5))
173 const isLabeled = range === 7 || (list.length - 1 - k) % 7 === 0
174 if (isLabeled) parts.push(`<text x="${x + colW / 2}" y="${bottom + 16}" font-size="12" fill="${C.dim}" text-anchor="middle">${esc(dayLabel(d.key, false))}</text>`)
175 })
176
177 const topY = bottom + 40
178 parts.push(
179 `<text x="14" y="${topY}" font-size="13" fill="${C.text}">${esc(
180 top ? `いちばん多かった日 ${dayLabel(top.key)} ${fmtNum(top.value)} ${unit}` : `直近 ${range} 日はまだ記録がありません`,
181 )}</text>`,
182 )
183 return frame(topY + 14, parts.join(''))
184}
185
186/** 曜日×時間帯のヒートマップ(濃さ = これまでのメインのターン数) */
187export function heatSvg(heat: readonly number[]) {
188 const left = 40
189 const top = 34
190 const cw = (W - left - 14) / 24
191 const ch = 18
192 const max = Math.max(1, ...heat)
193 const parts: string[] = [`<text x="14" y="22" font-size="13" font-weight="bold" fill="${C.text}">曜日と時間帯ごとのターン数(これまで)</text>`]
194 ROWS.forEach((label, r) => {
195 const y = top + r * ch
196 parts.push(`<text x="14" y="${y + 13}" font-size="12" fill="${C.dim}">${label}</text>`)
197 for (let h = 0; h < 24; h++) {
198 const v = heat[r * 24 + h] ?? 0
199 const x = left + h * cw
200 parts.push(
201 v === 0
202 ? `<rect x="${x.toFixed(1)}" y="${y}" width="${(cw - 2).toFixed(1)}" height="${ch - 2}" rx="2" fill="${C.face}"/>`
203 : `<rect x="${x.toFixed(1)}" y="${y}" width="${(cw - 2).toFixed(1)}" height="${ch - 2}" rx="2" fill="${C.green}" opacity="${(0.2 + (0.8 * v) / max).toFixed(2)}"/>`,
204 )
205 }
206 })
207 const labelY = top + 7 * ch + 14
208 for (const h of [0, 6, 12, 18, 23]) parts.push(`<text x="${(left + h * cw + cw / 2).toFixed(1)}" y="${labelY}" font-size="12" fill="${C.dim}" text-anchor="middle">${h}</text>`)
209 const b = busiest(heat)
210 parts.push(
211 `<text x="14" y="${labelY + 22}" font-size="12" fill="${C.dim}">${esc(b ? `いちばん多いのは ${b.week}曜 ${b.hour} 時台(${b.turns} ターン)` : 'まだ記録がありません')}</text>`,
212 )
213 return frame(labelY + 32, fadeIn(parts.join('')))
214}
215hooks/style.ts 315 lines1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7 bg: string
8 face: string
9 line: string
10 text: string
11 dim: string
12 weak: string
13 blue: string
14 green: string
15 yellow: string
16 red: string
17 purple: string
18 orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23 bg: '#0d1117',
24 face: '#161b22',
25 line: '#30363d',
26 text: '#c9d1d9',
27 dim: '#8b949e',
28 weak: '#8b949e',
29 blue: '#58a6ff',
30 green: '#3fb950',
31 yellow: '#d29922',
32 red: '#f85149',
33 purple: '#bc8cff',
34 orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39 bg: '#ffffff',
40 face: '#f6f8fa',
41 line: '#d0d7de',
42 text: '#1f2328',
43 dim: '#656d76',
44 weak: '#656d76',
45 blue: '#0969da',
46 green: '#1a7f37',
47 yellow: '#9a6700',
48 red: '#cf222e',
49 purple: '#8250df',
50 orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65 isLightNow = light
66 Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75 const rows = await $.config.list().catch(() => [])
76 setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81 on('config.set', { key: 'theme' }, async ($, e, next) => {
82 const r = await next(e)
83 if (r.deny === undefined) {
84 setTheme(isLightTheme(r.value))
85 $.ui.invalidate('ui.render')
86 }
87 return r
88 })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98 clean(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109 const h = jstDate(now).getUTCHours()
110 return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115 const m = jstDate(now).getUTCMonth() + 1
116 return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137 if (!dir.trim() || value === undefined) return false
138 const day = jstDay(now)
139 const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140 // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141 const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142 if (old === day) return false
143 await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144 return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149 try {
150 const b = JSON.parse(text) as Partial<Backup> | null
151 return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152 } catch {
153 return undefined
154 }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159 if (!dir.trim()) return []
160 const all = await Promise.all(
161 [0, 1, 2, 3, 4, 5, 6].map(wd =>
162 $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163 ),
164 )
165 return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177 let h = 0x811c9dc5
178 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179 return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187 const root = $.plugin.root
188 let manifest: string | undefined
189 for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190 manifest = await $.fs.read(p).then(t => t, () => undefined)
191 if (manifest !== undefined) break
192 }
193 if (manifest === undefined) return undefined
194 const names = await $.fs.list(`${root}/hooks`).then(
195 es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196 () => [] as string[],
197 )
198 const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199 return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207 const root = normPath($.plugin.root)
208 const at = root.lastIndexOf('/plugins/cache/')
209 if (at < 0) return false
210 const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211 try {
212 const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213 const mine = Object.entries(plugins)
214 .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215 .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216 .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217 return mine.length > 0 && !mine.includes(root)
218 } catch {
219 return false
220 }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225 loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234 if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235 staleAt = now
236 if (await replacedInCache($)) return (isStaleNow = true)
237 if (loadedPrint === undefined) return false
238 const p = await diskPrint($)
239 isStaleNow = p !== undefined && p !== loadedPrint
240 return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248 if (!(await isStale($, now))) return true
249 if (!toldStale) {
250 toldStale = true
251 $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252 }
253 return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264 `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268 `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275 const pad = 14
276 return [
277 `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278 `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279 `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280 sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281 growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282 ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287 `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288 `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292 `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293 `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294 `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298 `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305 const STEPS = 8
306 const d = 0.08
307 const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308 if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309 const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310 const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311 return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312 })
313 return frames.join('') + last
314}
315types/index.d.ts 63 lines1// usage-history の $.state の契約
2
3/** 1 日分(日本時間の日付ごと)の足し上げ */
4export type DayRec = {
5 /** メインのターン数(reason が answer で agentId が無い物) */
6 turns: number
7 /** tool.call の数(サブエージェントの分も含む) */
8 tools: number
9 /** 以下 4 つはメインとサブエージェントの合計 */
10 inputTokens: number
11 outputTokens: number
12 cacheRead: number
13 /** メインのターンの所要時間の合計 */
14 durationMs: number
15 /** サブエージェントのターン数 */
16 subTurns: number
17}
18
19/** 'YYYY-MM-DD'(日本時間) → その日の記録 */
20export type Days = Record<string, DayRec>
21
22export type HistoryRange = 7 | 30
23export type HistoryMetric = 'turns' | 'output'
24
25/** 直近 7 日の 1 日分(他の mod が読む形。week-card など) */
26export type HistoryWeekDay = {
27 /** 日本時間の 'YYYY-MM-DD' */
28 day: string
29 /** メインのターン数 */
30 turns: number
31 /** ツールの回数(サブエージェントの分も含む) */
32 tools: number
33 /** 入力・出力トークン(メインとサブエージェントの合計) */
34 inputTokens: number
35 outputTokens: number
36 /** メインのターンの所要時間の合計(分、四捨五入) */
37 minutes: number
38}
39
40/** 「控えから戻す」の欄。days が null なら閉じている。days は控えのある日 (新しい順)、pick は確かめ中の日 */
41export type HistoryRestore = { days: string[] | null; pick: string | null }
42
43declare module 'claude-code' {
44 interface PluginState {
45 'usage-history': {
46 /** 最大 400 日。$.store の 'days' にも残す */
47 days: Days
48 /** 曜日(月=0)×時刻(0-23 時)のメインのターン数、長さ 168。$.store の 'heat' にも残す */
49 heat: number[]
50 /**
51 * 今日で終わる直近 7 日(古い順、7 要素。記録の無い日は 0)。他の mod が読んでよい(書くのは usage-history)。
52 * セッションの始まりとメインのターンの終わりに置き換わる。usage-history が入っていない・読み込む前は undefined
53 */
54 week: HistoryWeekDay[]
55 range: HistoryRange
56 metric: HistoryMetric
57 restore: HistoryRestore
58 /** ボタンの結果などの一言。空なら出さない */
59 note: string
60 }
61 }
62}
63