利用上限(5時間枠と週の枠)の使った割合とリセットの時刻を入力欄の上に出し、上限が近づいたら知らせる

利用上限の5時間枠と週の枠を、どれだけ使ったかとリセットの時刻とあわせて、入力欄の上に出すmodです。
上限 5時間 █████░░░ 62%(3:30リセット) 週 █░░░░░░░ 17%(10/6 23:00リセット)
| いつ | すること |
|---|---|
| 起動したときと、Claudeが答え終わるたび | 入力欄の上の表示を新しくする。70%から黄色、90%から赤 |
| 5時間枠が80%と95%を超えたとき、週の枠が90%を超えたとき | 右上に知らせを出す(同じ枠の同じ段階では1回だけ) |
| 5時間枠が90%以上、または週の枠が95%以上のとき | 頼みごとに一文を添えて、Claudeに伝える |
Claudeに添える一文は次のとおりです。画面には出ません。
[limit-meter] 利用上限が近づいています(5時間枠は92%使用、3:30にリセット)。上限に達すると、作業は途中で止まります。この依頼で2つ以上のファイルを書き換えるなら、書き換える前に、区切り方を短く提案して、ユーザーの返事を待ってください。1つのファイルで済む作業なら、そのまま進めてかまいません。
Opus 5.5では、この一文を読んで区切り方を先に出しました。Haiku 4.5では、そのまま書き換えを始めました。モデルによって効き方が違います。
上限に近いときの見え方は、LIMIT_METER_DEMOに5時間枠と週の枠の割合を入れて起動すると確かめられます。リセットの時刻は本物のままです。
LIMIT_METER_DEMO=92,40 claude
TZ、/etc/localtime、date +%zの順に読み、リセットの時刻をその時刻で出します。どれも読めないときは、時刻のあとに「UTC」と付けますhooks/register.tsx 86 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import { bandPieces, demoLimits, noteFor, readingOf, toastsFor } from './meter'
5import { UTC_GUESS, zoneFromName, zoneFromOffset, zoneFromPath } from './zone'
6import type { Zone } from './zone'
7
8const reading = atom({ plugin: 'limit-meter', key: 'reading' } as const, null)
9const TOAST_MS = 8000
10// Toasts already shown, by window, threshold and reset: each shows once.
11const shown = new Set<string>()
12// The machine's time zone, read once per load of the module.
13let zone: Zone | null = null
14
15/** TZ when it names a zone, else where /etc/localtime points, else `date +%z`, else UTC. */
16async function readZone($: EngineInterface): Promise<Zone> {
17 const fromEnv = zoneFromName(await $.env.get('TZ'))
18 if (fromEnv !== null) return fromEnv
19 const fromFile = await $.fs.stat('/etc/localtime', { resolve: true }).then(
20 stat => zoneFromPath(stat.realPath),
21 () => null,
22 )
23 if (fromFile !== null) return fromFile
24 const fromDate = await $.process.run(['date', '+%z']).then(
25 run => (run.exitCode === 0 ? zoneFromOffset(run.stdout) : null),
26 () => null,
27 )
28
29 return fromDate ?? UTC_GUESS
30}
31
32async function measure($: EngineInterface, limits: readonly SessionRateLimit[], usd?: number) {
33 zone ??= await readZone($)
34 const latest = readingOf(demoLimits(limits, await $.env.get('LIMIT_METER_DEMO')), usd)
35 await update($, reading, () => latest)
36 for (const toast of toastsFor(latest, await $.clock.now(), zone)) {
37 if (shown.has(toast.key)) continue
38 shown.add(toast.key)
39 $.ui.toast(toast.text, { timeoutMs: TOAST_MS })
40 }
41}
42
43export const register: Register = on => {
44 on('session.start', async ($, e, next) => {
45 const usage = await $.session.usage()
46 await measure($, usage.rateLimits, usage.cost?.usd)
47
48 return next(e)
49 })
50
51 on('session.measure', async ($, e, next) => {
52 await measure($, e.rateLimits, e.cost?.usd)
53
54 return next(e)
55 })
56
57 on('prompt.submit', async ($, e, next) => {
58 const latest = await read($, reading)
59 const note = latest === null ? null : noteFor(latest, await $.clock.now(), zone ?? UTC_GUESS)
60
61 return note === null ? next(e) : next({ ...e, context: [...(e.context ?? []), note] })
62 })
63
64 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
65 const latest = await read($, reading)
66 if (e.props.hasSurvey || latest === null) return next(e)
67 const pieces = bandPieces(latest, await $.clock.now(), zone ?? UTC_GUESS, e.props.bodyColumns)
68 if (pieces.length === 0) return next(e)
69 const { Box, Text } = $.ui.resolve(e)
70
71 return (
72 <Box>
73 {pieces.map(piece =>
74 piece.color === undefined ? (
75 <Text dimColor>{piece.text}</Text>
76 ) : (
77 <Text color={piece.color} bold>
78 {piece.text}
79 </Text>
80 ),
81 )}
82 </Box>
83 )
84 })
85}
86hooks/meter.ts 152 lines1import type { SessionRateLimit } from 'claude-code'
2
3import type { LimitMeterReading as Reading, LimitMeterWindow as Window } from '../types'
4import { partsOf } from './zone'
5import type { Zone } from './zone'
6
7/** A theme color the band draws a window's use in. */
8export type Level = 'success' | 'warning' | 'error'
9
10/** One run of the band's text, colored when it is a window's use. */
11export type Piece = { text: string; color?: Level }
12
13/** A toast to show once: `key` names the window, the threshold and the reset. */
14export type Toast = { key: string; text: string }
15
16const MINUTE = 60 * 1000
17const DAY = 24 * 60 * MINUTE
18const BAR_CELLS = 8
19/** The band draws bars from this many columns, and the weekly window from NARROW. */
20const WIDE = 80
21const NARROW = 62
22
23/** The 5-hour window toasts at each of these percentages, the weekly one at its own. */
24export const FIVE_HOUR_TOASTS = [80, 95] as const
25export const WEEK_TOASTS = [90] as const
26/** From these percentages, each prompt carries a note telling Claude the limit is near. */
27export const FIVE_HOUR_NOTE_AT = 90
28export const WEEK_NOTE_AT = 95
29
30/** The window named `kind` (`five_hour`, `seven_day`), or null when none was reported. */
31export function windowOf(limits: readonly SessionRateLimit[], kind: string): Window | null {
32 const found = limits.find(limit => limit.kind === kind)
33 if (found === undefined) return null
34 const resetsAt = found.resetsAt === undefined ? NaN : Date.parse(found.resetsAt)
35
36 return { percent: found.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
37}
38
39/**
40 * `LIMIT_METER_DEMO=92,40`: the 5-hour and weekly windows read as these
41 * percentages instead of the real ones, to try how the band and the
42 * warnings look. The reset times stay real.
43 */
44export function demoLimits(limits: readonly SessionRateLimit[], value: string | undefined): readonly SessionRateLimit[] {
45 const match = value?.match(/^\s*(\d+(?:\.\d+)?)\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?$/)
46 if (match === null || match === undefined) return limits
47 const percents: Record<string, number | undefined> = {
48 five_hour: Number(match[1]),
49 seven_day: match[2] === undefined ? undefined : Number(match[2]),
50 }
51
52 return limits.map(limit => ({ ...limit, percentUsed: percents[limit.kind] ?? limit.percentUsed }))
53}
54
55export function readingOf(limits: readonly SessionRateLimit[], usd?: number): Reading {
56 return {
57 five: windowOf(limits, 'five_hour'),
58 week: windowOf(limits, 'seven_day'),
59 usd: usd ?? null,
60 }
61}
62
63/** Green below 70%, yellow below 90%, red from 90%. */
64export function levelOf(percent: number): Level {
65 if (percent >= 90) return 'error'
66 if (percent >= 70) return 'warning'
67
68 return 'success'
69}
70
71export function bar(percent: number, cells = BAR_CELLS): string {
72 const used = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
73
74 return '█'.repeat(used) + '░'.repeat(cells - used)
75}
76
77/** "3:30" for a reset within a day of `now`, "10/6 23:00" for a later one, in `zone`. */
78export function clockText(at: number, now: number, zone: Zone): string {
79 const { month, day, hour, minute } = partsOf(at, zone)
80 const time = `${hour}:${String(minute).padStart(2, '0')}`
81 const when = at - now < DAY ? time : `${month}/${day} ${time}`
82
83 return 'isGuess' in zone ? `${when} UTC` : when
84}
85
86function windowPieces(name: string, window: Window, now: number, zone: Zone, withBar: boolean): Piece[] {
87 const percent = `${Math.floor(window.percent)}%`
88 const used = withBar ? `${bar(window.percent)} ${percent}` : percent
89 const reset = window.resetsAt === null ? '' : `(${clockText(window.resetsAt, now, zone)}リセット)`
90
91 return [{ text: `${name} ` }, { text: used, color: levelOf(window.percent) }, { text: reset }]
92}
93
94/**
95 * The band's text for `columns` cells: both windows with bars when there is
96 * room, the cost when the account reports no windows (an API key), else none.
97 */
98export function bandPieces(reading: Reading, now: number, zone: Zone, columns: number): Piece[] {
99 const { five, week, usd } = reading
100 if (five === null && week === null) {
101 return usd === null || usd === 0 ? [] : [{ text: `このセッションの料金 $${usd.toFixed(2)}` }]
102 }
103 const withBar = columns >= WIDE
104 const pieces: Piece[] = [{ text: '上限 ' }]
105 if (five !== null) pieces.push(...windowPieces('5時間', five, now, zone, withBar))
106 if (week !== null && (five === null || columns >= NARROW)) {
107 if (five !== null) pieces.push({ text: ' ' })
108 pieces.push(...windowPieces('週', week, now, zone, withBar))
109 }
110
111 return pieces
112}
113
114function toastFor(name: string, id: string, window: Window, thresholds: readonly number[], now: number, zone: Zone): Toast[] {
115 const passed = thresholds.filter(at => window.percent >= at)
116 if (passed.length === 0) return []
117 const at = passed[passed.length - 1]
118 const reset = window.resetsAt === null ? '' : `。${clockText(window.resetsAt, now, zone)}にリセットされます`
119
120 return [{ key: `${id}:${at}:${window.resetsAt}`, text: `${name}の${at}%を超えました${reset}` }]
121}
122
123/** The highest threshold each window has passed, as a toast keyed to that window's reset. */
124export function toastsFor(reading: Reading, now: number, zone: Zone): Toast[] {
125 return [
126 ...(reading.five === null ? [] : toastFor('5時間枠', 'five', reading.five, FIVE_HOUR_TOASTS, now, zone)),
127 ...(reading.week === null ? [] : toastFor('週の枠', 'week', reading.week, WEEK_TOASTS, now, zone)),
128 ]
129}
130
131function usedText(name: string, window: Window, now: number, zone: Zone): string {
132 const reset = window.resetsAt === null ? '' : `、${clockText(window.resetsAt, now, zone)}にリセット`
133
134 return `${name}は${Math.floor(window.percent)}%使用${reset}`
135}
136
137/** What Claude reads beside each prompt while a window is near its limit, or null. */
138export function noteFor(reading: Reading, now: number, zone: Zone): string | null {
139 const { five, week } = reading
140 const near = [
141 five !== null && five.percent >= FIVE_HOUR_NOTE_AT ? usedText('5時間枠', five, now, zone) : null,
142 week !== null && week.percent >= WEEK_NOTE_AT ? usedText('週の枠', week, now, zone) : null,
143 ].filter((part): part is string => part !== null)
144 if (near.length === 0) return null
145
146 return [
147 `[limit-meter] 利用上限が近づいています(${near.join('/')})。上限に達すると、作業は途中で止まります。`,
148 'この依頼で2つ以上のファイルを書き換えるなら、書き換える前に、区切り方を短く提案して、ユーザーの返事を待ってください。',
149 '1つのファイルで済む作業なら、そのまま進めてかまいません。',
150 ].join('')
151}
152hooks/zone.ts 68 lines1/**
2 * The person's time zone. A mod runs on UTC whatever the machine's zone, so
3 * the band reads the machine's zone (an IANA name, or else its offset) and
4 * formats each reset time in it.
5 */
6export type Zone = { name: string } | { offsetMinutes: number; isGuess?: true }
7
8/** Where no zone could be read: UTC, and the band says so. */
9export const UTC_GUESS: Zone = { offsetMinutes: 0, isGuess: true }
10
11const MINUTE = 60 * 1000
12
13/** The zone named `name` (`Asia/Tokyo`), or null when Intl does not know it. */
14export function zoneFromName(name: string | undefined): Zone | null {
15 if (name === undefined || name.trim() === '' || name.startsWith(':')) return null
16 try {
17 new Intl.DateTimeFormat('en-US', { timeZone: name.trim() })
18 } catch {
19 return null
20 }
21
22 return { name: name.trim() }
23}
24
25/**
26 * The zone `/etc/localtime` points at: `/var/db/timezone/zoneinfo/Asia/Tokyo`
27 * on macOS, `/usr/share/zoneinfo/Asia/Tokyo` on Linux.
28 */
29export function zoneFromPath(realPath: string | undefined): Zone | null {
30 const match = realPath?.match(/zoneinfo(?:\.default)?\/(.+)$/)
31
32 return match === null || match === undefined ? null : zoneFromName(match[1])
33}
34
35/** The zone `date +%z` printed (`+0900`, `-0330`), as a fixed offset. */
36export function zoneFromOffset(text: string): Zone | null {
37 const match = text.trim().match(/^([+-])(\d{2}):?(\d{2})$/)
38 if (match === null) return null
39 const minutes = Number(match[2]) * 60 + Number(match[3])
40
41 return { offsetMinutes: match[1] === '-' ? -minutes : minutes }
42}
43
44/** Month, day, hour and minute of `at` in `zone`. */
45export function partsOf(at: number, zone: Zone) {
46 if ('name' in zone) {
47 const parts = new Intl.DateTimeFormat('en-US', {
48 timeZone: zone.name,
49 month: 'numeric',
50 day: 'numeric',
51 hour: 'numeric',
52 minute: '2-digit',
53 hourCycle: 'h23',
54 }).formatToParts(new Date(at))
55 const part = (type: string) => Number(parts.find(each => each.type === type)?.value)
56
57 return { month: part('month'), day: part('day'), hour: part('hour'), minute: part('minute') }
58 }
59 const shifted = new Date(at + zone.offsetMinutes * MINUTE)
60
61 return {
62 month: shifted.getUTCMonth() + 1,
63 day: shifted.getUTCDate(),
64 hour: shifted.getUTCHours(),
65 minute: shifted.getUTCMinutes(),
66 }
67}
68types/index.d.ts 16 lines1/** One rate-limit window: how much of it is used, and when it resets (ms). */
2export type LimitMeterWindow = { percent: number; resetsAt: number | null }
3
4/** The last reading: the 5-hour and weekly windows, or the cost off a subscription. */
5export type LimitMeterReading = {
6 five: LimitMeterWindow | null
7 week: LimitMeterWindow | null
8 usd: number | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'limit-meter': { reading: LimitMeterReading | null }
14 }
15}
16