SLOPSHOPPER

limit-meter

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

newbandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 上限 5時間 ██░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
上限 5時間 ██░░░░░░ 31%
README

limit-meter

利用上限の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

気をつけること

  • 数字が出るのは、Pro、Max、Teamなどのプランで使っているときだけです。APIキーで使っているときは、代わりにそのセッションの料金を出します
  • modの中の時計はUTCで動きます。そのため、手元のタイムゾーンをTZ、/etc/localtime、date +%zの順に読み、リセットの時刻をその時刻で出します。どれも読めないときは、時刻のあとに「UTC」と付けます
  • 数字は、Claudeの応答と一緒に届いたものです。Claudeが何も答えていない間は、表示は変わりません
Source 4 files
hooks/register.tsx 86 lines
1import { 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}
86
hooks/meter.ts 152 lines
1import 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}
152
hooks/zone.ts 68 lines
1/**
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}
68
types/index.d.ts 16 lines
1/** 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