Shows context, 5-hour, weekly and per-model weekly limit usage above the prompt

プロンプト入力欄の上の帯に、コンテキストの使用率と、5 時間制限と週間制限の使用率を常時表示する Claude Code の mod です。 Fable のようにモデル別の週間制限があるアカウントでは、その使用率も並べます。
ctx ██░░░░░░░░ 20% 5h ██████┃░░░ 62% 2h13m (23:13) 7d ┃░░░░░░░░░ 5% 4d18h Fable ┃░░░░░░░░░ 3%
公式ドキュメントでは、mod には Claude Code v2.1.287 以降が必要とされています。 実際には v2.1.286 でも動作を確認しており、v2.1.284 では mod が読み込まれませんでした。
Desktop アプリは、本体に同梱された Claude Code で動きます。 帯が表示されるのは、Claude Code v2.1.286 以降を同梱した本体(2.19675.0 以降)で新しく始めたセッションです。 本体を更新する前から続いているセッションは、再開しても帯が表示されないことがありました。 その場合は、新しいセッションを開いてください。
Claude Code のプロンプトで、次の 3 つを順に実行します。
/plugin marketplace add HolyGrail/claude-mods
/plugin install usage-meter@claude-mods
/reload-plugins
更新の手順と注意点は、リポジトリの README にまとめてあります。
clone したリポジトリのルートで、次のように起動します。
claude --plugin-dir ./plugins/usage-meter
手元のコードを常に読み込むなら、~/.claude/settings.json の env に CLAUDE_CODE_PLUGIN_DIRS としてこのディレクトリの絶対パスを書きます。
5 時間制限と週間制限はアカウント単位の値なので、読み取った値を $.store でマシン上のセッション全体に共有しています。 まだ値を受け取っていないセッションも、ほかのセッションが保存した最新の値を表示し、別のセッションで使った分も 1 分以内に反映されます。
$.store には atomic な更新がないため、各セッションは自分専用のキーにだけ書き込み、ほかのセッションの値を上書きしません。 それでも、キーの削除と書き込みがミリ秒単位で重なったときの競合は防げません。 たとえば、8 日以上測定していなかったセッションが測定した瞬間に、別のセッションがそのキーを古いものとして削除すると、その測定値は共有されません。 その場合も、各セッションの表示は次の測定で正しい値に戻ります。
mods API にはアカウントを識別する手段がないため、共有する値はアカウントを区別しません。 同じマシンで別のアカウントに切り替えると、新しいセッションが最初の応答を受け取るまで、前のアカウントの値が表示されます。
モデル別の週間制限は、/usage 画面が読むのと同じ https://api.anthropic.com/api/oauth/usage から取得しています。 mods API が渡す値は 5 時間制限と週間制限だけで、モデル別の値を含まないためです。 認証には $.session.authorize() のハンドルを使うので、トークンが mod に渡ることはありません。
$.store でセッション全体に共有する。表示は最大 5 分遅れるcd plugins/usage-meter
claude plugin testhooks/register.js 438 lines1// Shows context, 5-hour limit, weekly limit and per-model weekly limit usage in the band above
2// the prompt.
3
4// The latest figures this session has, from $.session.usage() or session.measure
5let context = null
6let rateLimits = []
7// When rateLimits was last measured, in $.clock.now() milliseconds
8let measuredAt = 0
9// The timer that refreshes the band, kept so a later session.start can stop it
10let ticker = null
11// This session's own key in the store
12let ownKey = null
13// The per-model weekly limits as last read, by this session or another: { at, limits }
14let scoped = { at: 0, limits: [] }
15// When this session last set out to ask the usage endpoint, whatever came of it
16let scopedTriedAt = 0
17// Counts session.start, so an answer to a poll an earlier start began is left unused
18let scopedGeneration = 0
19
20// Rate limits are per account, so sessions share readings through $.store, and each shows the
21// newest one. $.store has no atomic update, so each session writes only its own key, and no
22// write can overwrite another session's reading.
23// The mods API has no account id, so after switching accounts on this machine a new
24// session shows the previous account's reading until its own first response.
25const KEY_PREFIX = 'reading:'
26// Readings older than the longest window say nothing current: they are never shown, and their
27// keys are deleted
28const STALE_MS = 8 * 24 * 3_600_000
29// The session.end reasons after which this module stops; /clear, /resume (which /branch reports)
30// and logout leave it running and measuring
31const FINAL_REASONS = ['prompt_input_exit', 'other']
32// How often to pick up other sessions' readings and refresh the countdowns and time markers
33const TICK_MS = 60_000
34
35// The per-model weekly limits (Fable's), which $.session.usage() and session.measure leave out,
36// come from the endpoint /usage reads. Every session shares one key: each write is a whole fresh
37// reading, so one landing over another loses nothing.
38const SCOPED_KEY = 'scoped'
39// When any session last asked the endpoint, so that others starting or ticking meanwhile wait,
40// and a failing endpoint is asked once a poll by the machine, not once by each session. Two
41// sessions that read it in the same moment may still both ask: $.store has no atomic update.
42const SCOPED_TRIED_KEY = 'scoped-tried'
43const SCOPED_URL = 'https://api.anthropic.com/api/oauth/usage'
44// How old the shared reading gets before a session asks the endpoint again, and how long a
45// session waits after an attempt that brought nothing
46const SCOPED_POLL_MS = 5 * 60_000
47// A per-model limit that resets within this long of the weekly limit leaves its countdown out
48const SAME_RESET_MS = 60_000
49
50const HOUR_MS = 3_600_000
51const WINDOWS = {
52 // showsClock adds the reset time of day, in JST
53 five_hour: { label: '5h', ms: 5 * HOUR_MS, showsClock: true },
54 seven_day: { label: '7d', ms: 7 * 24 * HOUR_MS },
55 // Labeled with the model's name
56 weekly_scoped: { ms: 7 * 24 * HOUR_MS },
57 spend_limit: { label: '$' },
58}
59
60// Pace thresholds: margin is the elapsed share of the window minus the used share
61const GREEN_MIN_MARGIN = 10
62const RED_BELOW_MARGIN = -15
63// Usage this low stays green early in a window, when the margin is still small
64const GREEN_MAX_USED = 10
65// Usage this high is red whatever the pace
66const RED_MIN_USED = 90
67
68// JST has no daylight saving time, so a fixed offset gives its clock
69const JST_OFFSET_MS = 9 * HOUR_MS
70
71const BAR_CELLS = 10
72// Columns between two meters, and the band's last column, which the terminal may draw over
73const METER_GAP = 3
74const BAND_RESERVED_COLUMNS = 2
75const SVG_BAR = { width: 96, height: 10 }
76// With more meters than this, the Desktop app's bars narrow so the line still fits its band
77const SVG_WIDE_MAX_METERS = 3
78const SVG_NARROW_WIDTH = 72
79const SVG_COLORS = { success: '#4caf50', warning: '#e0a526', error: '#e5534b', track: 'rgba(128,128,128,0.3)', marker: '#5b9bff' }
80// The terminal draws the time marker in this color
81const MARKER_COLOR = 'cyan'
82
83export function register(on) {
84 // Fires again on an enable or a worker respawn, which may keep this module's variables
85 on('session.start', async ($, e, next) => {
86 ticker?.cancel()
87 rateLimits = []
88 measuredAt = 0
89 scoped = { at: 0, limits: [] }
90 scopedTriedAt = 0
91 scopedGeneration += 1
92 ownKey = KEY_PREFIX + (await $.session.id())
93 const usage = await $.session.usage()
94 context = usage.context
95 if (usage.rateLimits.length > 0) await publishSnapshot($, usage.rateLimits)
96 // Also clears keys ended sessions left, which short runs that never tick would not
97 await refresh($)
98 await adoptScoped($)
99 // Not awaited: the session does not wait on the network to start
100 void pollScoped($).then(() => $.ui.invalidate('ui.render'))
101 ticker = $.clock.every(TICK_MS, async () => {
102 await refresh($)
103 await adoptScoped($)
104 await pollScoped($)
105 $.ui.invalidate('ui.render')
106 })
107 $.ui.invalidate('ui.render')
108 return next(e)
109 })
110
111 // Keeps the store from growing by a key per session. A session whose reading is not the newest
112 // removes its own key. One that holds the newest marks it ended, so the others may delete it once
113 // a newer reading exists: an ended session never writes again, so that delete can't lose a write.
114 on('session.end', async ($, e, next) => {
115 if (!FINAL_REASONS.includes(e.reason) || !ownKey) return next(e)
116 ticker?.cancel()
117 await releaseKey($)
118 return next(e)
119 })
120
121 // /clear, /resume, /branch (fork) and compaction change the context, which session.measure
122 // reports only after the next turn. All but compaction also switch to another session id, so
123 // this module hands its key over as an ended session would, and writes under the new id.
124 on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
125 const key = KEY_PREFIX + (await $.session.id())
126 if (ownKey && key !== ownKey) {
127 await releaseKey($)
128 ownKey = key
129 }
130 context = (await $.session.usage()).context
131 $.ui.invalidate('ui.render')
132 return next(e)
133 })
134
135 // Fires after each turn, and when a rate-limit window moves a whole point
136 on('session.measure', async ($, e, next) => {
137 context = e.context
138 // A fresh measurement, including an empty one when the account's windows went away
139 if (e.changed.includes('rateLimits')) await remember($, e.rateLimits)
140 $.ui.invalidate('ui.render')
141 return next(e)
142 })
143
144 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145 const elements = $.ui.resolve(e)
146 const now = await $.clock.now()
147 const meters = [{ label: 'ctx', used: context?.percent, elapsed: null, resetsAt: null }]
148 for (const limit of rateLimits) {
149 meters.push(readLimit(limit, now))
150 }
151 const weekly = meters.find((m) => m.kind === 'seven_day')
152 for (const limit of scoped.limits) {
153 // Only the endpoint refreshes this reading, and it may be failing: past the reset, the
154 // meter goes until an answer comes, where 0% could stand for days over real usage
155 if (limit.resetsAt != null && Date.parse(limit.resetsAt) <= now) continue
156 const m = readLimit(limit, now)
157 // The weekly meter beside it already counts down to the same reset
158 if (weekly?.resetsAt != null && m.resetsAt != null && Math.abs(m.resetsAt - weekly.resetsAt) < SAME_RESET_MS) {
159 m.resetsAt = null
160 }
161 meters.push(m)
162 }
163 for (const m of meters) m.value = valueText(m, now)
164 const gauge = e.surface === 'desktop' ? 'svg' : barsFit(meters, e.props.bodyColumns ?? 0) ? 'text' : 'none'
165
166 const barWidth = meters.length > SVG_WIDE_MAX_METERS ? SVG_NARROW_WIDTH : SVG_BAR.width
167 const line = elements.Box({
168 flexDirection: 'row',
169 columnGap: METER_GAP,
170 // In a narrow Desktop window a whole meter moves to the next row, rather than its text
171 // breaking or running off the band
172 ...(gauge === 'svg' && { flexWrap: 'wrap' }),
173 children: meters.map((m) => meter(elements, gauge, m, barWidth)),
174 })
175 // Keep what the mods after this one draw in the band
176 const rest = await next(e)
177 if (!rest) return line
178 return elements.Box({ flexDirection: 'column', children: [line, rest] })
179 })
180}
181
182async function remember($, limits) {
183 // Take the time first, so a refresh that runs meanwhile can't pair old limits with it
184 const now = await $.clock.now()
185 rateLimits = limits
186 measuredAt = now
187 await $.store.set(ownKey, { at: now, limits })
188}
189
190// Takes the newest reading any session saved, unless this session's own is newer still, and
191// deletes the keys no session needs: ended sessions' older readings, and any reading too old
192async function refresh($) {
193 const { entries, newest } = await scan($)
194 if (newest && newest.reading.at >= measuredAt) {
195 rateLimits = newest.reading.limits
196 measuredAt = newest.reading.at
197 } else if (!newest && measuredAt < (await $.clock.now()) - STALE_MS) {
198 // This session's own reading has gone stale too, as in one left idle for days
199 rateLimits = []
200 measuredAt = 0
201 }
202 await prune($, entries, newest?.key)
203}
204
205// Stops using this session's key: keeps its reading marked ended if it is the newest, so the
206// others may delete it once a newer one exists, and removes it otherwise, since only the newest
207// reading is ever shown
208async function releaseKey($) {
209 const { entries, newest } = await scan($)
210 if (newest?.key === ownKey) await $.store.set(ownKey, { ...newest.reading, ended: true })
211 else await $.store.delete(ownKey)
212 await prune($, entries, newest?.key)
213}
214
215async function prune($, entries, newestKey) {
216 const cutoff = (await $.clock.now()) - STALE_MS
217 for (const { key, reading } of entries) {
218 if (key === ownKey || key === newestKey) continue
219 if (!isReading(reading) || reading.ended === true || reading.at < cutoff) await $.store.delete(key)
220 }
221}
222
223// usage() at startup may answer this session's last reading, which can be older than the shared
224// one in ways a merge can't tell apart (a window that went away, a spend limit that went down),
225// so a shared reading always wins and the snapshot is saved only when there is none
226async function publishSnapshot($, snapshot) {
227 const { newest } = await scan($)
228 if (newest) {
229 rateLimits = newest.reading.limits
230 measuredAt = newest.reading.at
231 return
232 }
233 await remember($, snapshot)
234}
235
236// Every session's key and what it holds, and the newest reading among them. At an equal time the
237// later key wins, so every session picks the same one.
238async function scan($) {
239 const entries = []
240 let newest = null
241 const cutoff = (await $.clock.now()) - STALE_MS
242 for (const key of await $.store.keys()) {
243 if (!key.startsWith(KEY_PREFIX)) continue
244 const reading = await $.store.get(key)
245 entries.push({ key, reading })
246 if (!isReading(reading) || reading.at < cutoff) continue
247 if (!newest || reading.at > newest.reading.at || (reading.at === newest.reading.at && key > newest.key)) {
248 newest = { key, reading }
249 }
250 }
251 return { entries, newest }
252}
253
254// Takes the per-model reading the sessions share, unless this session's own is newer, and drops
255// one too old to say anything current
256async function adoptScoped($) {
257 const shared = await $.store.get(SCOPED_KEY)
258 if (isReading(shared) && shared.at >= scoped.at) scoped = shared
259 if (scoped.at < (await $.clock.now()) - STALE_MS) scoped = { at: 0, limits: [] }
260}
261
262// Asks the usage endpoint once the shared reading is due, and shares what it answers. A session
263// with no first-party login, a refused request or an answer in another shape leaves the reading
264// as it was.
265async function pollScoped($) {
266 const now = await $.clock.now()
267 if (now - scoped.at < SCOPED_POLL_MS || now - scopedTriedAt < SCOPED_POLL_MS) return
268 scopedTriedAt = now
269 const generation = scopedGeneration
270 try {
271 const auth = await $.session.authorize()
272 if (!auth) return
273 const tried = await $.store.get(SCOPED_TRIED_KEY)
274 if (typeof tried === 'number' && tried <= now && now - tried < SCOPED_POLL_MS) {
275 // Wait out the rest of that session's five minutes, not five of this one's own
276 scopedTriedAt = tried
277 return
278 }
279 await $.store.set(SCOPED_TRIED_KEY, now)
280 const res = await $.http.fetch(SCOPED_URL, { auth: auth.handle })
281 const limits = res.ok ? scopedLimits(JSON.parse(res.text)) : null
282 if (!limits || generation !== scopedGeneration) return
283 scoped = { at: await $.clock.now(), limits }
284 await $.store.set(SCOPED_KEY, scoped)
285 } catch {
286 // The next attempt is a poll away
287 }
288}
289
290// The weekly limits the answer scopes to a model, as limits readLimit takes: an empty list for an
291// account with none, null when the answer has no list of limits at all
292function scopedLimits(usage) {
293 if (!Array.isArray(usage?.limits)) return null
294 const limits = []
295 for (const limit of usage.limits) {
296 const label = limit?.scope?.model?.display_name
297 if (limit?.kind !== 'weekly_scoped' || typeof label !== 'string' || typeof limit.percent !== 'number') continue
298 const resetsAtMs = typeof limit.resets_at === 'string' ? Date.parse(limit.resets_at) : NaN
299 limits.push({
300 kind: 'weekly_scoped',
301 label,
302 percentUsed: limit.percent,
303 ...(Number.isFinite(resetsAtMs) && { resetsAt: new Date(resetsAtMs).toISOString() }),
304 })
305 }
306 return limits
307}
308
309function isReading(value) {
310 return value != null && typeof value.at === 'number' && Array.isArray(value.limits)
311}
312
313function readLimit(limit, now) {
314 const window = WINDOWS[limit.kind]
315 const label = limit.label ?? window?.label ?? limit.kind
316 const resetsAtMs = limit.resetsAt == null ? null : Date.parse(limit.resetsAt)
317 // A window that has reset since the last reading starts again from zero
318 if (resetsAtMs != null && resetsAtMs <= now) {
319 return { kind: limit.kind, label, used: 0, elapsed: window?.ms ? 0 : null, resetsAt: null }
320 }
321 const elapsed = window?.ms && resetsAtMs != null ? clamp(100 - ((resetsAtMs - now) / window.ms) * 100) : null
322 return { kind: limit.kind, label, used: limit.percentUsed, elapsed, resetsAt: resetsAtMs, showsClock: window?.showsClock === true }
323}
324
325// Green, yellow or red by how far usage runs ahead of the time gone in its window
326function statusOf(used, elapsed) {
327 if (used >= RED_MIN_USED) return 'error'
328 if (elapsed == null) return used >= 80 ? 'error' : used >= 50 ? 'warning' : 'success'
329 const margin = elapsed - used
330 if (margin < RED_BELOW_MARGIN) return 'error'
331 if (margin < GREEN_MIN_MARGIN && used >= GREEN_MAX_USED) return 'warning'
332 return 'success'
333}
334
335function valueText({ used, resetsAt, showsClock }, now) {
336 let value = typeof used === 'number' ? Math.round(used) + '%' : '—'
337 if (resetsAt != null) value += ' ' + untilReset(resetsAt - now)
338 if (resetsAt != null && showsClock) value += ' (' + jstClock(resetsAt) + ')'
339 return value
340}
341
342// Whether every meter fits on one line with its text bar; every character drawn is one cell wide
343function barsFit(meters, columns) {
344 const width = meters.reduce((sum, m) => sum + [...m.label].length + 1 + BAR_CELLS + 1 + [...m.value].length, 0)
345 return width + METER_GAP * (meters.length - 1) <= columns - BAND_RESERVED_COLUMNS
346}
347
348function meter({ Box, Text, Svg }, gauge, { label, used, elapsed, value }, barWidth) {
349 const known = typeof used === 'number'
350 const status = known ? statusOf(used, elapsed) : null
351 const style = known ? { color: status } : { dimColor: true }
352
353 const children = [Text({ children: [label] })]
354 if (gauge === 'svg') {
355 children.push(
356 Svg({
357 source: svgBar(known ? used : 0, elapsed, status, barWidth),
358 alt: label + ' ' + value + (elapsed == null ? '' : ', ' + Math.round(elapsed) + '% of the window gone'),
359 width: barWidth,
360 height: SVG_BAR.height,
361 }),
362 )
363 } else if (gauge === 'text') {
364 children.push(...textBar(Text, known ? used : 0, elapsed, status))
365 }
366 children.push(Text({ ...style, children: [value] }))
367 return Box({
368 key: 'meter-' + label,
369 flexDirection: 'row',
370 columnGap: 1,
371 alignItems: 'center',
372 ...(gauge === 'svg' && { flexShrink: 0 }),
373 children,
374 })
375}
376
377// The bar as runs of cells: used cells in the status color, the rest dim, and the time marker
378function textBar(Text, used, elapsed, status) {
379 const filled = Math.round((clamp(used) / 100) * BAR_CELLS)
380 const marker = elapsed == null ? -1 : Math.min(BAR_CELLS - 1, Math.floor((elapsed / 100) * BAR_CELLS))
381 const markerStyle = { color: MARKER_COLOR, bold: true }
382 const usedStyle = status ? { color: status } : { dimColor: true }
383 const restStyle = { dimColor: true }
384 const cells = []
385 for (let i = 0; i < BAR_CELLS; i++) {
386 if (i === marker) cells.push({ char: '┃', style: markerStyle })
387 else if (i < filled) cells.push({ char: '█', style: usedStyle })
388 else cells.push({ char: '░', style: restStyle })
389 }
390 const runs = []
391 for (const cell of cells) {
392 const last = runs.at(-1)
393 if (last && last.style === cell.style) last.text += cell.char
394 else runs.push({ text: cell.char, style: cell.style })
395 }
396 // One Text per run, nested in a Text so the runs stay on one line with no gaps
397 return [Text({ children: runs.map((run) => Text({ ...run.style, children: [run.text] })) })]
398}
399
400function svgBar(used, elapsed, status, width) {
401 const { height } = SVG_BAR
402 const r = height / 2
403 const fill = Math.round((clamp(used) / 100) * width)
404 const parts = [
405 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
406 `<clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>`,
407 `<g clip-path="url(#c)">`,
408 `<rect width="${width}" height="${height}" fill="${SVG_COLORS.track}"/>`,
409 ]
410 if (fill > 0) parts.push(`<rect width="${fill}" height="${height}" fill="${SVG_COLORS[status ?? 'success']}"/>`)
411 parts.push('</g>')
412 if (elapsed != null) {
413 const x = Math.min(width - 2, Math.max(0, Math.round((elapsed / 100) * width) - 1))
414 parts.push(`<rect x="${x}" width="2" height="${height}" fill="${SVG_COLORS.marker}"/>`)
415 }
416 parts.push('</svg>')
417 return parts.join('')
418}
419
420function clamp(percent) {
421 return Math.min(Math.max(percent, 0), 100)
422}
423
424// The time of day as HH:MM, 24-hour, in JST
425function jstClock(ms) {
426 const jst = new Date(ms + JST_OFFSET_MS)
427 return String(jst.getUTCHours()).padStart(2, '0') + ':' + String(jst.getUTCMinutes()).padStart(2, '0')
428}
429
430function untilReset(ms) {
431 const minutes = Math.max(0, Math.ceil(ms / 60_000))
432 const days = Math.floor(minutes / 1440)
433 const hours = Math.floor((minutes % 1440) / 60)
434 if (days > 0) return days + 'd' + hours + 'h'
435 if (hours > 0) return hours + 'h' + (minutes % 60) + 'm'
436 return (minutes % 60) + 'm'
437}
438