SLOPSHOPPER

usage-stats

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

newpanecommand
v?no licenseupdated 2026-10-02japan-da-man/claude-code/plugins/usage-stats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-stats
│ ┃ Usage stats ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Overview 2: Models a: All m: 30d w: 7d │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭────────────────╮╭────────────────╮╭─────── ⎿ Read 6 lines │ ┃ │ Sessions ││ Messages ││ Total ⏺ Update(src/auth.ts) │ ┃ │ 1 ││ 1 ││ 98.9k ⎿ Added 2 lines, removed 1 line │ ┃ ╰────────────────╯╰────────────────╯╰─────── ⏺ Bash(bun test) │ ┃ ╭────────────────╮╭────────────────╮╭─────── ⎿ 3 pass, 1 fail │ ┃ │ Active days ││ Streak ││ Favori │ ┃ │ 1 ││ 1 day ││ claude ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰────────────────╯╰────────────────╯╰─────── │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ · · · · · · · · · · · · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · · · · · › /stats │ ┃ · · · · · · · · · · · · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · · · · ■ │ ┃ · · · · · · · · · · · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · · · · │ ┃ Less ■ ■ ■ ■ More │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage stats
1: Overview 2: Models a: All m: 30d w: 7d ╭────────────────╮╭────────────────╮╭────────────────╮ │ Sessions ││ Messages ││ Total tokens │ │ 1 ││ 1 ││ 98.9k │ ╰────────────────╯╰────────────────╯╰────────────────╯ ╭────────────────╮╭────────────────╮╭────────────────╮ │ Active days ││ Streak ││ Favorite model │ │ 1 ││ 1 day ││ claude-opus-5… │ ╰────────────────╯╰────────────────╯╰────────────────╯ · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ■ · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · Less ■ ■ ■ ■ More
README

claude-code

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)をまとめてやる。手でやる場合:

  1. plugins/<name>/ に .claude-plugin/plugin.json と中身を置く
  2. .claude-plugin/marketplace.json の plugins にエントリを追加する(name は plugin.json と同じにする)
  3. まとめて入るようにするなら plugins/japan-da-man-mods/.claude-plugin/plugin.json の dependencies にも名前を足す

plugin.json に version を書いていないので、コミットごとに新しいバージョンとして扱われる。

mods は Claude Code v2.1.287 以降が必要。

公式 mod をもとにしたもの

token-weather・blast-radius・replay-theater は、Anthropic の claude-code-playground にある同名の mod(Apache-2.0)をもとに手を加えたもの。各プラグインに LICENSE を同梱し、各 hooks/register.js の先頭に元のコードの著作権表示と、こちらで変えた点を書いている。

Source 1 files
hooks/register.js 264 lines
1// 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