[mod] Usage stats pane: sessions, messages, tokens, active days, streak, favorite model and a daily heatmap

japan-da-man の Claude Code プラグイン(mods を含む)を管理するマーケットプレイス。
| 名前 | 内容 | | :- | :- | | japan-da-man-mods | まとめてインストール用。下のプラグインを全部入れる | | todo-mod | /todo で TODO パネルを開く。チェックを入れたタスクを Claude に依頼する | | token-weather | コンテキストの埋まり具合を天気でプロンプトの上に表示する(☀ Clear → ☁ Cloudy → ☂ Showers → ☇ Storm → ↯ Compact soon)。直近12ターンのグラフと、直前のターンの増減(▲ +98.3k last turn)付き。/weather で表示切り替え | | usage-stats | /stats で利用統計のパネルを開く。セッション数・メッセージ数・トークン数・利用日数・連続日数・よく使うモデル・日ごとのヒートマップ | | blast-radius | 危険なシェルコマンドを実行前に止め、何が消えるか・変わるかをペインに出して Proceed / Cancel で選ばせる(10分答えがなければ止める)。rm -r/-f、git reset --hard、force push、git clean、変更の破棄、git branch -D、git stash drop/clear、マイグレーション、find -delete、xargs rm。bash -c / eval の中も調べる。/blast-radius で履歴 | | replay-theater | ターンの中で Claude が行ったファイル編集を記録する。ターンが終わるとプロンプトの上に ▶ Replay: N edits が出て、r か /replay でペインを開き、編集を 1 件ずつ差分で見られる(Prev / Next / Close) | | spec-flow | 「〇〇な機能が欲しい」と話しかけるか /spec-flow:spec <要望> で、要求整理 → 影響画面確認 → 要件定義 → 機能案・UI案 を 1 段階ずつ承認しながら進める。曖昧な点はヒアリングし、影響調査と案出しはサブエージェントが行う。成果物は docs/specs/<id>/(UI 案の HTML モックは Desktop なら Artifact にも公開。--local で公開しない)。/specs でチェックリストを開き、チェックで承認・修正依頼もできる | | dev-flow | 汎用の開発フロー。skills plan(小さなタスクに分けて承認をもらってから着手)・tdd(RED → GREEN → REFACTOR)・debug(再現 → 仮説 → 検証で原因を確かめてから直す)・review(重要度つきの差分レビュー)。サブエージェント code-explorer(横断調査)・test-runner(テスト結果の要約)・reviewer(別コンテキストでレビュー、修正はしない)が長い出力をメインに持ち込まない | | dev-extras | 他の OSS から選んで取り込んだ skill(すべて MIT、出典は各 README と licenses/)。ponytail(最小の実装に絞る)・ponytail-review(作り込みすぎだけを探す)・grill-me(質問攻めで計画を詰める)・handoff(バックグラウンドエージェントに引き継ぐ)・verification-before-completion(検証してから完了と言う)・security-audit(Cloudflare 製のセキュリティ監査) | | mod-dev | skill add-mod。「〇〇する mod を作って」で雛形・テスト・登録まで行う。marketplace リポジトリでは marketplace.json / bundle / README も更新し、それ以外では単体の mod を作る |
claude plugin marketplace add japan-da-man/claude-code
claude plugin install japan-da-man-mods@japan-da-man
1つずつ入れる場合は claude plugin install <名前>@japan-da-man。
更新:
claude plugin marketplace update japan-da-man
claude plugin update <名前>@japan-da-man
bundle に増えたプラグインを入れるときは、update → install の順で実行する(update だけだと増えた分は入らず、bundle が failed to load になる):
claude plugin marketplace update japan-da-man
claude plugin update japan-da-man-mods@japan-da-man
claude plugin install japan-da-man-mods@japan-da-man
セッション内なら update のあと /reload-plugins でもよい。
インストールせずに作業中のディレクトリを読み込む(保存するとホットリロードされる):
claude --plugin-dir ./plugins/todo-mod
チェック:
claude plugin validate .
claude plugin validate ./plugins/todo-mod
テスト(tests/*.test.ts があるプラグイン):
cd plugins/<name> && claude plugin test
mod-dev を入れた状態でこのリポジトリを開き「〇〇する mod を追加して」と頼むと、skill add-mod が下の手順(雛形・テスト・marketplace / bundle / README 更新・validate)をまとめてやる。手でやる場合:
plugins/<name>/ に .claude-plugin/plugin.json と中身を置く.claude-plugin/marketplace.json の plugins にエントリを追加する(name は plugin.json と同じにする)plugins/japan-da-man-mods/.claude-plugin/plugin.json の dependencies にも名前を足すplugin.json に version を書いていないので、コミットごとに新しいバージョンとして扱われる。
mods は Claude Code v2.1.287 以降が必要。
token-weather・blast-radius・replay-theater は、Anthropic の claude-code-playground にある同名の mod(Apache-2.0)をもとに手を加えたもの。各プラグインに LICENSE を同梱し、各 hooks/register.js の先頭に元のコードの著作権表示と、こちらで変えた点を書いている。
hooks/register.js 264 lines1// Usage Stats: セッション数・メッセージ数・トークン数・利用日数・連続日数・よく使うモデルと
2// 日ごとのヒートマップを /stats のペインに表示する。
3// 記録は $.store に日ごとのキー(day:YYYY-MM-DD)で持つ。全セッションで共有される
4const PANE = 'usage-stats'
5const DAY_PREFIX = 'day:'
6const HEATMAP_WEEKS = 20
7const LEVEL_COLORS = ['#f3c6a5', '#ec9b6b', '#d97757', '#b5532f']
8const RANGES = [
9 { id: 'all', label: 'All', hotkey: 'a', days: Infinity },
10 { id: '30d', label: '30d', hotkey: 'm', days: 30 },
11 { id: '7d', label: '7d', hotkey: 'w', days: 7 },
12]
13
14// { 'YYYY-MM-DD': Day }。ペインを開いたときに store から読み直す
15let days = {}
16let tab = 'overview'
17let range = 'all'
18
19export function emptyDay() {
20 return { sessions: [], messages: 0, tokens: 0, models: {} }
21}
22
23// ローカル時刻の YYYY-MM-DD
24export function dateKey(date) {
25 const p = (n) => String(n).padStart(2, '0')
26 return date.getFullYear() + '-' + p(date.getMonth() + 1) + '-' + p(date.getDate())
27}
28
29function shiftDays(date, n) {
30 return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n)
31}
32
33export function formatNumber(n) {
34 if (n >= 1_000_000_000) return +(n / 1_000_000_000).toFixed(1) + 'B'
35 if (n >= 1_000_000) return +(n / 1_000_000).toFixed(1) + 'M'
36 if (n >= 1_000) return +(n / 1_000).toFixed(1) + 'k'
37 return String(n)
38}
39
40export function usageTokens(usage) {
41 if (!usage) return 0
42 return (
43 (usage.input_tokens ?? 0) +
44 (usage.output_tokens ?? 0) +
45 (usage.cache_read_input_tokens ?? 0) +
46 (usage.cache_creation_input_tokens ?? 0)
47 )
48}
49
50// 1ターン分を日の記録に足す。サブエージェントのターンはトークンだけ数える
51export function addTurn(day, { usage, isMain }) {
52 const tokens = usageTokens(usage)
53 const models = { ...day.models }
54 if (usage?.model) {
55 const m = models[usage.model] ?? { turns: 0, tokens: 0 }
56 models[usage.model] = { turns: m.turns + (isMain ? 1 : 0), tokens: m.tokens + tokens }
57 }
58 return { ...day, messages: day.messages + (isMain ? 1 : 0), tokens: day.tokens + tokens, models }
59}
60
61export function addSession(day, sessionId) {
62 return day.sessions.includes(sessionId) ? day : { ...day, sessions: [...day.sessions, sessionId] }
63}
64
65// 期間内の集計。today は Date
66export function summarize(allDays, today, rangeDays) {
67 const from = rangeDays === Infinity ? '' : dateKey(shiftDays(today, -(rangeDays - 1)))
68 const sessions = new Set()
69 const models = {}
70 let messages = 0
71 let tokens = 0
72 let activeDays = 0
73 for (const [key, day] of Object.entries(allDays)) {
74 if (key < from) continue
75 day.sessions.forEach((s) => sessions.add(s))
76 messages += day.messages
77 tokens += day.tokens
78 if (day.messages > 0 || day.sessions.length > 0) activeDays += 1
79 for (const [name, m] of Object.entries(day.models)) {
80 const cur = models[name] ?? { turns: 0, tokens: 0 }
81 models[name] = { turns: cur.turns + m.turns, tokens: cur.tokens + m.tokens }
82 }
83 }
84 const modelList = Object.entries(models)
85 .map(([name, m]) => ({ name, ...m }))
86 .sort((a, b) => b.turns - a.turns || b.tokens - a.tokens)
87 return {
88 sessions: sessions.size,
89 messages,
90 tokens,
91 activeDays,
92 streak: streak(allDays, today),
93 favoriteModel: modelList[0]?.name ?? null,
94 models: modelList,
95 }
96}
97
98function isActive(day) {
99 return !!day && (day.messages > 0 || day.sessions.length > 0)
100}
101
102// 今日(今日がまだなら昨日)から遡って連続で使った日数
103export function streak(allDays, today) {
104 let d = isActive(allDays[dateKey(today)]) ? today : shiftDays(today, -1)
105 let n = 0
106 while (isActive(allDays[dateKey(d)])) {
107 n += 1
108 d = shiftDays(d, -1)
109 }
110 return n
111}
112
113// 7行(日〜土)× weeks 列。最後の列が今日を含む週。値は 0〜4 の濃さ、未来は null
114export function heatmap(allDays, today, weeks) {
115 const start = shiftDays(today, -today.getDay() - (weeks - 1) * 7)
116 const values = []
117 for (let w = 0; w < weeks; w++) {
118 for (let d = 0; d < 7; d++) {
119 const date = shiftDays(start, w * 7 + d)
120 values.push(date > today ? null : (allDays[dateKey(date)]?.messages ?? 0))
121 }
122 }
123 const max = Math.max(1, ...values.filter((v) => v !== null))
124 const rows = []
125 for (let d = 0; d < 7; d++) {
126 const row = []
127 for (let w = 0; w < weeks; w++) {
128 const v = values[w * 7 + d]
129 row.push(v === null ? null : v === 0 ? 0 : Math.max(1, Math.ceil((v / max) * 4)))
130 }
131 rows.push(row)
132 }
133 return rows
134}
135
136async function loadDays($) {
137 const keys = (await $.store.keys()).filter((k) => k.startsWith(DAY_PREFIX))
138 const next = {}
139 for (const key of keys) next[key.slice(DAY_PREFIX.length)] = (await $.store.get(key)) ?? emptyDay()
140 days = next
141}
142
143// 今日の記録を書き換える。他のセッションの書き込みを消さないよう、直前に読み直す
144async function updateToday($, fn) {
145 const key = dateKey(new Date(await $.clock.now()))
146 const current = (await $.store.get(DAY_PREFIX + key)) ?? emptyDay()
147 const next = fn(current)
148 await $.store.set(DAY_PREFIX + key, next)
149 days = { ...days, [key]: next }
150 $.ui.invalidate('ui.render')
151}
152
153export function register(on) {
154 on('session.start', async ($, e, next) => {
155 const sessionId = await $.session.id()
156 await updateToday($, (day) => addSession(day, sessionId))
157 await $.command.register({ name: 'stats', description: '利用統計のパネルを開く', immediate: true })
158 return next(e)
159 })
160
161 on('turn.complete', async ($, e, next) => {
162 const result = await next(e)
163 if (e.usage || !e.agentId) {
164 await updateToday($, (day) => addTurn(day, { usage: e.usage, isMain: !e.agentId }))
165 }
166 return result
167 })
168
169 on('command.run', { command: 'stats' }, async ($) => {
170 await loadDays($)
171 await $.ui.open({ id: PANE, title: 'Usage stats', focus: true, closeOnEscape: true })
172 return {}
173 })
174
175 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
176 if (e.requestId !== PANE) return next(e)
177 const { Box, Text, Button } = $.ui.resolve(e)
178 const redraw = () => $.ui.invalidate('ui.render')
179 const today = new Date(await $.clock.now())
180 const width = e.props.bodyColumns ?? 80
181 const r = RANGES.find((x) => x.id === range)
182 const s = summarize(days, today, r.days)
183
184 const tabButton = (id, label, hotkey) =>
185 Button({ key: 'tab-' + id, label, hotkey, plain: true, dimColor: tab !== id, onPress: () => { tab = id; redraw() } })
186 const rangeButton = (x) =>
187 Button({ key: 'range-' + x.id, label: x.label, hotkey: x.hotkey, plain: true, dimColor: range !== x.id, onPress: () => { range = x.id; redraw() } })
188
189 const header = Box({
190 flexDirection: 'row',
191 justifyContent: 'space-between',
192 children: [
193 Box({ flexDirection: 'row', columnGap: 2, children: [tabButton('overview', 'Overview', '1'), tabButton('models', 'Models', '2')] }),
194 Box({ flexDirection: 'row', columnGap: 2, children: RANGES.map(rangeButton) }),
195 ],
196 })
197
198 const cardWidth = Math.max(18, Math.floor((width - 4) / 3))
199 const card = (label, value) =>
200 Box({
201 key: 'card-' + label,
202 flexDirection: 'column',
203 width: cardWidth,
204 borderStyle: 'round',
205 paddingX: 1,
206 children: [Text({ dimColor: true, children: [label] }), Text({ bold: true, wrap: 'truncate-end', children: [value] })],
207 })
208 const cardRow = (items) => Box({ flexDirection: 'row', children: items.map(([l, v]) => card(l, v)) })
209
210 const weeks = Math.max(4, Math.min(HEATMAP_WEEKS, Math.floor((width - 2) / 2)))
211 const grid = heatmap(days, today, weeks)
212 const cell = (level) =>
213 level === null
214 ? Text({ children: [' '] })
215 : level === 0
216 ? Text({ dimColor: true, children: ['· '] })
217 : Text({ color: LEVEL_COLORS[level - 1], children: ['■ '] })
218 const heat = Box({
219 flexDirection: 'column',
220 children: [
221 ...grid.map((row) => Text({ children: row.map(cell) })),
222 Text({ dimColor: true, children: ['Less ', ...LEVEL_COLORS.map((c) => Text({ color: c, children: ['■ '] })), 'More'] }),
223 ],
224 })
225
226 const overview = [
227 cardRow([
228 ['Sessions', formatNumber(s.sessions)],
229 ['Messages', formatNumber(s.messages)],
230 ['Total tokens', formatNumber(s.tokens)],
231 ]),
232 cardRow([
233 ['Active days', String(s.activeDays)],
234 ['Streak', s.streak + (s.streak === 1 ? ' day' : ' days')],
235 ['Favorite model', s.favoriteModel ?? '—'],
236 ]),
237 Text({ children: [' '] }),
238 heat,
239 ]
240
241 const maxTurns = Math.max(1, ...s.models.map((m) => m.turns))
242 const barWidth = Math.max(5, Math.min(30, width - 50))
243 const models = s.models.length
244 ? s.models.map((m) =>
245 Box({
246 key: 'model-' + m.name,
247 flexDirection: 'row',
248 columnGap: 2,
249 children: [
250 Text({ wrap: 'truncate-end', children: [m.name.padEnd(28).slice(0, 28)] }),
251 Text({ color: LEVEL_COLORS[2], children: ['█'.repeat(Math.max(1, Math.round((m.turns / maxTurns) * barWidth)))] }),
252 Text({ dimColor: true, children: [m.turns + ' turns · ' + formatNumber(m.tokens) + ' tokens'] }),
253 ],
254 }),
255 )
256 : [Text({ dimColor: true, children: ['この期間の記録はまだありません'] })]
257
258 return Box({
259 flexDirection: 'column',
260 children: [header, Text({ children: [' '] }), ...(tab === 'overview' ? overview : models)],
261 })
262 })
263}
264