週の利用上限を、どのセッションが使ったかをグラフで表示(起動すると画面の右に自動で開く)

Claude Code の mod(Claude Code の動きや画面を変えられるプラグイン) 週の利用上限を、どのセッションがどれだけ使ったかを、セッションをまたいで画面の右にグラフで表示
週の利用上限に近づいても、どのセッションが使ったのかが分からない 作者の直近7日の記録を料金の比率で数えると、93セッションのうち上位5セッションで52%、下位半分の46セッションは合わせて0.4% 長く続けた数本のセッションが、気づかないうちに大半を使っていた
Claude Code 2.1.287 以降が必要(claude --version で確認) 早期公開のときに CLAUDE_CODE_ENABLE_FUNCTION_HOOKS を設定していたら削除(2.1.287 以降は無視される、公式の mods の概要のページ)
/plugin marketplace add shumatsumonobu/claude-mods-bench
/plugin install session-usage@claude-mods-bench
/reload-plugins でも読み込める(公式の mods の概要のページ)起動すると、画面の右に今週の使用量をセッション別に表示 上は曜日×時間帯の使用量で、使った時間帯ほど明るい緑 下はセッションごとの棒グラフで、作業フォルダ名・最初のプロンプト・割合・料金

プロンプトの上の1行には、このセッションの割合と、7日の利用上限の消費率を表示 閉じたあとは、u(1行の右の u: Open)か /session-usage で開き直せる
棒グラフと割合の範囲は、7日の利用上限が前回リセットされてから今まで 上の色の表は、暦の上の直近7日 セッション同士は料金(/cost と同じ集計)で比べる
mod は Claude Code の中で動くため、shell hook(settings.json に定義する従来のフック)では書けない次のことができる
$.session.usage() で、セッションの料金と、5時間・7日の利用上限の消費率を読める モデルへの問い合わせは起きない shell hook の入力には、料金も利用上限の消費率も入っていない(公式の hooks のページで読んだ範囲)ui.render で、画面の右に色の表と棒グラフを描き、プロンプトの上の1行にも出せる$.store)は、同じマシンで動く全セッションで共有される(公式の interface のページ) 各セッションが返事のたびに自分の料金を書き込み、どのセッションからでも全部を並べて見られる実測(2026-10-07、Claude Code 2.1.289)と、公式ドキュメントを読んだ範囲
/session-usage は画面の右を開くだけで、会話には何も返さない(ヘッドレスで実行して料金 0、1回)/session-usage で開けば、どの幅でも表示hooks/register.ts 453 lines1import type { On } from 'claude-code'
2
3/** 画面の右に開く表示の ID、コマンド名と `ui.render` での見分けにも使う */
4const PANE_ID = 'session-usage'
5/** 時間の長さ(ミリ秒) */
6const HOUR = 3600 * 1000
7const DAY = 24 * HOUR
8const WEEK = 7 * DAY
9/** 記録を残す期間、7日の窓より少し長く持ち、それより古いセッションの記録は消す */
10const KEEP_MS = 8 * DAY
11/** この時間内に返事があったセッションに、active の印を付ける */
12const ACTIVE_MS = 5 * 60 * 1000
13/** 棒グラフに出すセッションの数、残りは1行にまとめる */
14const TOP = 6
15
16/** セッション1つの記録、保存領域にセッションごとのキー(`s:<id>`)で置き、セッション同士の書き込みがぶつからないようにする */
17export type Rec = {
18 /** セッションの ID(`$.session.id()`) */
19 id: string
20 /** 作業フォルダの最後のディレクトリ名 */
21 project: string
22 /** 最初のプロンプトの先頭 */
23 title: string
24 /** 最後に返事が終わった時刻(ミリ秒) */
25 lastAt: number
26 /** `$.session.usage()` の料金(ドル)、セッションの累計 */
27 usd: number
28 /** 時間ごとの料金の増分、キーはその時間の始まり(ミリ秒)の文字列 */
29 hours: Record<string, number>
30}
31
32/** 利用上限1つの読み取り(`$.session.usage()` の `rateLimits` の1件) */
33export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
34
35/** このセッションの記録 */
36let me: Rec | null = null
37/** 保存領域から読んだ、全セッションの記録 */
38let all: Rec[] = []
39/** 最後に読んだ利用上限 */
40let limits: Limit[] = []
41/** 画面の右を開いているか */
42let isOpen = false
43/** ユーザーが自分で閉じたか、閉じたらこのセッションでは自動で開き直さない */
44let closedByUser = false
45/** ほかのセッションの記録を定期的に読み直すタイマーを、もう始めたか */
46let ticking = false
47
48/** パスの最後のディレクトリ名 */
49export function baseName(path: string): string {
50 const parts = path.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
51 return parts[parts.length - 1] || path
52}
53
54/** 時刻が属する1時間の始まり(ミリ秒)を、記録のキーの文字列にする */
55export function hourKey(ms: number): string {
56 return String(Math.floor(ms / HOUR) * HOUR)
57}
58
59/**
60 * 料金の新しい累計を記録に足す
61 * 前の累計から増えた分だけを、今の時間に積み、記録を残す期間より古い時間は捨てる
62 */
63export function addCost(rec: Rec, usd: number, now: number): Rec {
64 const delta = usd - rec.usd
65 const hours: Record<string, number> = {}
66 for (const [k, v] of Object.entries(rec.hours)) if (Number(k) >= now - KEEP_MS) hours[k] = v
67 if (delta > 0) {
68 const k = hourKey(now)
69 hours[k] = (hours[k] ?? 0) + delta
70 }
71 return { ...rec, usd: Math.max(rec.usd, usd), lastAt: now, hours }
72}
73
74/**
75 * 数える範囲の始まり
76 * 7日の利用上限の次のリセットから7日さかのぼる、上限が読めなければ直近7日
77 */
78export function windowStart(lims: Limit[], now: number): number {
79 const week = lims.find((l) => l.kind === 'seven_day')
80 const reset = week?.resetsAt ? Date.parse(week.resetsAt) : NaN
81 return Number.isFinite(reset) && reset > now ? reset - WEEK : now - WEEK
82}
83
84/** 数える範囲の中で、そのセッションが使った料金 */
85export function costIn(rec: Rec, from: number): number {
86 let sum = 0
87 for (const [k, v] of Object.entries(rec.hours)) if (Number(k) + HOUR > from) sum += v
88 return sum
89}
90
91/**
92 * セッションを、数える範囲の中の料金が多い順に並べ、全体に占める割合を付ける
93 * 範囲の中で料金が無いセッションは外す
94 */
95export function ranking(recs: Rec[], from: number) {
96 const rows = recs.map((r) => ({ rec: r, usd: costIn(r, from) })).filter((x) => x.usd > 0)
97 const total = rows.reduce((s, x) => s + x.usd, 0)
98 rows.sort((a, b) => b.usd - a.usd)
99 return { total, rows: rows.map((x) => ({ ...x, share: total > 0 ? x.usd / total : 0 })) }
100}
101
102/** 1文字を8段階に分けて描くための、端数の文字 */
103const BLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
104
105/** 割合を、幅 width 文字の棒にする(1文字を8段階に分ける) */
106export function bar(share: number, width: number): string {
107 const eighths = Math.round(Math.max(0, Math.min(1, share)) * width * 8)
108 return '█'.repeat(Math.floor(eighths / 8)) + BLOCKS[eighths % 8]
109}
110
111/** 割合を百分率の文字にする、10% 未満は小数1桁まで */
112export function pct(share: number): string {
113 return `${(share * 100).toFixed(share < 0.1 ? 1 : 0)}%`
114}
115
116/**
117 * 曜日×時間帯の料金
118 * 行は6日前から今日までの7日、列は0時から23時(端末の時刻)
119 */
120export function heat(recs: Rec[], now: number): number[][] {
121 const grid = Array.from({ length: 7 }, () => new Array<number>(24).fill(0))
122 const today = new Date(now)
123 today.setHours(0, 0, 0, 0)
124 const first = today.getTime() - 6 * DAY
125 for (const r of recs) {
126 for (const [k, v] of Object.entries(r.hours)) {
127 const t = Number(k)
128 if (t < first) continue
129 const d = new Date(t)
130 const day = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - first) / DAY)
131 if (day >= 0 && day < 7) grid[day][d.getHours()] += v
132 }
133 }
134 return grid
135}
136
137/** Raster で、端末の既定の色を表す値 */
138const DEFAULT_COLOR = 0x01000000
139/** マス目の色、使っていない時間は暗い灰色、多く使った時間ほど明るい緑 */
140const RAMP = [0x2b2b2b, 0x0e4429, 0x006d32, 0x26a641, 0x39d353]
141
142/** 料金を、色の段階(0 は使っていない、1〜4 は多いほど明るい)にする */
143export function level(v: number, max: number): number {
144 if (v <= 0 || max <= 0) return 0
145 return Math.min(4, 1 + Math.floor((v / max) * 3.999))
146}
147
148/** バイト列を base64 にする、`toBase64` が無い環境(テスト)では `btoa` で代わりにする */
149function toBase64(bytes: Uint8Array): string {
150 const u = bytes as any
151 if (typeof u.toBase64 === 'function') return u.toBase64()
152 let s = ''
153 for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
154 return btoa(s)
155}
156
157/**
158 * Raster に渡すマス目
159 * 1マスは文字・文字の色・背景の色の3つの数で、1時間を2文字の幅にする
160 */
161export function heatCells(grid: number[][]): { columns: number; rows: number; cells: string } {
162 const max = Math.max(0, ...grid.flat())
163 const nums: number[] = []
164 for (const row of grid) {
165 for (const v of row) {
166 const color = RAMP[level(v, max)]
167 for (let i = 0; i < 2; i++) nums.push('█'.codePointAt(0)!, color, DEFAULT_COLOR)
168 }
169 }
170 return { columns: 48, rows: grid.length, cells: toBase64(new Uint8Array(Uint32Array.from(nums).buffer)) }
171}
172
173/** 曜日の表示 */
174const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
175
176/** マス目の左に並べる曜日、6日前から今日まで */
177function dayLabels(now: number): string[] {
178 return Array.from({ length: 7 }, (_, i) => WEEKDAY[new Date(now - (6 - i) * DAY).getDay()])
179}
180
181/** 7日の利用上限の消費率と、次のリセットの曜日と時刻 */
182function limitText(lims: Limit[]): string {
183 const week = lims.find((l) => l.kind === 'seven_day')
184 if (!week) return ''
185 const reset = week.resetsAt ? new Date(week.resetsAt) : null
186 const when = reset ? ` · resets ${WEEKDAY[reset.getDay()]} ${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}` : ''
187 return `7-day limit ${week.percentUsed}% used${when}`
188}
189
190/** 棒の上に出すセッションの名前 ── 作業フォルダ名、印(this session か active)、最初のプロンプト */
191function rowLabel(rec: Rec, isMe: boolean, now: number): string {
192 const tags: string[] = []
193 if (isMe) tags.push('this session')
194 else if (now - rec.lastAt < ACTIVE_MS) tags.push('active')
195 const title = rec.title ? ` · ${rec.title}` : ''
196 // 印は題より前に置く、題が長いと行の末尾が切れるため
197 return `${rec.project}${tags.length ? ` (${tags.join(', ')})` : ''}${title}`
198}
199
200/**
201 * 画面の右に出す中身
202 * 上に曜日×時間帯の使用量(端末だけ)、下にセッション別の棒グラフ、最後に7日の利用上限
203 */
204export function drawPane(t: any, surface: string, recs: Rec[], meId: string, lims: Limit[], now: number, columns: number) {
205 const { Box, Text, Raster } = t
206 const from = windowStart(lims, now)
207 const r = ranking(recs, from)
208 const barWidth = Math.max(8, Math.min(28, columns - 16))
209 const children: any[] = []
210
211 if (surface === 'terminal' && Raster) {
212 const h = heatCells(heat(recs, now))
213 children.push(
214 Box({
215 key: 'heat',
216 flexDirection: 'row',
217 gap: 1,
218 children: [
219 Box({ key: 'days', flexDirection: 'column', children: dayLabels(now).map((d, i) => Text({ key: `d${i}`, dimColor: true, children: d })) }),
220 Raster({ key: 'grid', ...h }),
221 ],
222 }),
223 Text({ key: 'hours', dimColor: true, children: ' 0h 6h 12h 18h' }),
224 )
225 }
226
227 if (r.rows.length === 0) {
228 children.push(Text({ key: 'empty', dimColor: true, children: 'No usage recorded yet. Each session adds its own after every reply.' }))
229 } else {
230 const top = r.rows.slice(0, TOP)
231 top.forEach((x, i) => {
232 const isMe = x.rec.id === meId
233 children.push(
234 Box({
235 key: `s${i}`,
236 flexDirection: 'column',
237 marginTop: i === 0 ? 1 : 0,
238 children: [
239 Text({ key: 'name', ...(isMe ? { bold: true } : {}), wrap: 'truncate-end', children: rowLabel(x.rec, isMe, now) }),
240 Box({
241 key: 'bar',
242 flexDirection: 'row',
243 gap: 1,
244 children: [
245 Text({ key: 'b', color: isMe ? 'cyan' : 'green', children: bar(x.share, barWidth).padEnd(barWidth, ' ') }),
246 Text({ key: 'p', children: `${pct(x.share).padStart(5)} $${x.usd.toFixed(2)}` }),
247 ],
248 }),
249 ],
250 }),
251 )
252 })
253 const rest = r.rows.slice(TOP)
254 if (rest.length > 0) {
255 const share = rest.reduce((s, x) => s + x.share, 0)
256 children.push(Text({ key: 'rest', dimColor: true, children: `+ ${rest.length} more session${rest.length === 1 ? '' : 's'} ${pct(share)}` }))
257 }
258 }
259
260 const limit = limitText(lims)
261 children.push(
262 Box({
263 key: 'foot',
264 flexDirection: 'column',
265 marginTop: 1,
266 children: [
267 ...(limit ? [Text({ key: 'limit', children: limit })] : []),
268 Text({ key: 'note', dimColor: true, children: 'Shares are by cost, as /cost counts it. Sessions without this mod are not counted.' }),
269 ],
270 }),
271 )
272
273 return Box({
274 flexDirection: 'column',
275 borderStyle: 'round',
276 borderColor: 'green',
277 paddingX: 1,
278 children: [Text({ key: 'title', bold: true, color: 'green', children: 'Session usage · this week' }), Box({ key: 'body', flexDirection: 'column', marginTop: 1, children })],
279 })
280}
281
282/** プロンプトの上の1行 ── このセッションの割合と、7日の利用上限、このセッションに料金がまだ無ければ出さない */
283export function drawLine(t: any, recs: Rec[], meId: string, lims: Limit[], now: number, onOpen: () => void) {
284 const r = ranking(recs, windowStart(lims, now))
285 const mine = r.rows.find((x) => x.rec.id === meId)
286 if (!mine) return null
287 const { Box, Text, Button } = t
288 const limit = lims.find((l) => l.kind === 'seven_day')
289 const parts = [`this session ${pct(mine.share)} of the week`, ...(limit ? [`7-day limit ${limit.percentUsed}%`] : [])]
290 return Box({
291 key: 'session-usage',
292 gap: 2,
293 paddingX: 1,
294 children: [
295 Text({ key: 'label', bold: true, color: 'green', children: `Session usage · ${parts.join(' · ')}` }),
296 Button({ key: 'open', label: 'Open', hotkey: 'u', plain: true, onPress: onOpen }),
297 ],
298 })
299}
300
301/** このセッションの記録を、保存領域の自分のキーに書く */
302async function save($: any) {
303 if (me) await $.store.set(`s:${me.id}`, me)
304}
305
306/** 保存領域から全セッションの記録と利用上限を読み直し、記録を残す期間より古いセッションは消す */
307async function load($: any) {
308 const now = await $.clock.now()
309 const keys: string[] = await $.store.keys()
310 const recs: Rec[] = []
311 for (const k of keys) {
312 if (k === 'limits') {
313 const v = await $.store.get(k)
314 if (Array.isArray(v)) limits = v
315 continue
316 }
317 if (!k.startsWith('s:')) continue
318 const v = (await $.store.get(k)) as Rec | undefined
319 if (!v || now - v.lastAt > KEEP_MS) {
320 await $.store.delete(k)
321 continue
322 }
323 recs.push(v)
324 }
325 all = recs
326}
327
328/**
329 * 画面の右を開く
330 * コマンドやボタンで開くときだけキーボードを渡し、自動で開くときはプロンプトへの入力を奪わない
331 */
332async function openPane($: any, asked: boolean) {
333 isOpen = true
334 await load($)
335 $.ui.invalidate('ui.render')
336 const pane = { id: PANE_ID, title: 'Session usage', closeOnEscape: true, columns: 56 }
337 await $.ui.open(asked ? { ...pane, focus: true } : pane)
338 $.ui.invalidate('ui.render')
339}
340
341/**
342 * セッションの始まり
343 * このセッションの記録を用意し、ほかのセッションの記録を読み、コマンドを登録して、画面の右を自動で開く
344 */
345export async function onStart($: any, e: any, next: any) {
346 const r = await next(e)
347 try {
348 const id = await $.session.id()
349 const now = await $.clock.now()
350 const u = await $.session.usage()
351 const saved = (await $.store.get(`s:${id}`)) as Rec | undefined
352 me = saved ?? { id, project: baseName(String(e.cwd ?? (await $.session.cwd()))), title: '', lastAt: now, usd: u?.cost?.usd ?? 0, hours: {} }
353 if (Array.isArray(u?.rateLimits) && u.rateLimits.length > 0) limits = u.rateLimits
354 await load($)
355 if (!ticking) {
356 ticking = true
357 // ほかのセッションの記録を取り込む、読むのは手元の保存領域だけで、モデルは呼ばない
358 $.clock.every(30_000, async () => {
359 await load($)
360 $.ui.invalidate('ui.render')
361 })
362 }
363 } catch {
364 // 記録の準備に失敗しても、セッションは止めない
365 }
366 await $.command.register({ name: PANE_ID, description: 'Session usage: which sessions used the most this week' })
367 // コマンドを待たずに開く、ほかのセッションの記録があるので、開いた時点で中身がある
368 if (!closedByUser) await openPane($, false).catch(() => {})
369 return r
370}
371
372/** 最初のプロンプトの先頭を、このセッションの題として記録する(プロンプトは書き換えない) */
373export async function onPrompt($: any, e: any, next: any) {
374 if (me && me.title === '' && typeof e.text === 'string' && e.text.trim() !== '') {
375 me = { ...me, title: e.text.trim().split('\n')[0].slice(0, 40) }
376 await save($).catch(() => {})
377 }
378 return next(e)
379}
380
381/** 返事が終わるたびに、料金の累計と利用上限を読み、増えた分を記録して描き直す */
382export async function onTurn($: any, e: any, next: any) {
383 const answer = await next(e)
384 try {
385 if (me) {
386 const u = await $.session.usage()
387 const now = await $.clock.now()
388 if (typeof u?.cost?.usd === 'number') me = addCost(me, u.cost.usd, now)
389 await save($)
390 if (Array.isArray(u?.rateLimits) && u.rateLimits.length > 0) {
391 limits = u.rateLimits
392 await $.store.set('limits', limits)
393 }
394 await load($)
395 $.ui.invalidate('ui.render')
396 }
397 } catch {
398 // 記録の失敗でターンは止めない
399 }
400 return answer
401}
402
403/** `/session-usage` ── 画面の右を開く */
404export async function onCommand($: any, e: any) {
405 closedByUser = false
406 await openPane($, true)
407 // 文字を返すと会話に入り Claude が読むので、何も返さず画面に描くだけにする
408 return {}
409}
410
411/** 画面の右に、自分の表示を描く */
412export async function onPane($: any, e: any, next: any) {
413 if (e.requestId !== PANE_ID) return next(e)
414 const now = await $.clock.now()
415 return drawPane($.ui.resolve(e), String(e.surface ?? 'terminal'), all, me?.id ?? '', limits, now, Number(e.props?.bodyColumns ?? 56))
416}
417
418/** プロンプトの上の1行を描く、ほかの mod の表示も消さずに並べる */
419export async function onBand($: any, e: any, next: any) {
420 const now = await $.clock.now()
421 const ours = drawLine($.ui.resolve(e), all, me?.id ?? '', limits, now, () => {
422 closedByUser = false
423 void openPane($, true)
424 })
425 const theirs = await next(e)
426 if (ours === null) return theirs
427 if (!theirs) return ours
428 // ほかの mod の表示を消さないよう、自分の1行の下に並べる
429 const { Box } = $.ui.resolve(e)
430 return Box({ flexDirection: 'column', children: [ours, theirs] })
431}
432
433/** 画面の右が閉じられたとき、ユーザーが閉じたなら、このセッションでは自動で開き直さない */
434export async function onClose($: any, e: any, next: any) {
435 if (e.id === PANE_ID) {
436 isOpen = false
437 if (e.origin?.kind === 'person') closedByUser = true
438 }
439 $.ui.invalidate('ui.render')
440 return next(e)
441}
442
443/** mod の入口 ── 各セッションの料金を記録し、セッション別の使用量を画面の右とプロンプトの上の1行に出す */
444export function register(on: On) {
445 on('session.start', onStart)
446 on('prompt.submit', onPrompt)
447 on('turn.complete', onTurn)
448 on('command.run', { command: 'session-usage' }, onCommand)
449 on('ui.render', { component: 'Pane' }, onPane)
450 on('ui.render', { component: 'AbovePrompt' }, onBand)
451 on('ui.close', onClose)
452}
453