SLOPSHOPPER

usage-rail

Adds a usage card to the session pane: session limit, weekly limit and cloud session credit, as the app's usage card shows them.

panecommandpromptnetworktimer
v0.3.3no licenseupdated 2026-10-19ImTotem/claude-code-mods/plugins/usage-rail
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-rail
│ ┃ 세션 ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ 사용량 │ ⏺ Read(src/auth.ts) │ ┃ │ 세션 한도 31% 사용됨 │ ⎿ 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 │ ┃ ⟨Claude Code's own drawing⟩ │ › /usage-rail │ ⎿ usage-rail: 사용량 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 세션
╭──────────────────────────────────────────────────────────╮ │ 사용량 │ │ 세션 한도 31% 사용됨 │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 클라우드 세션 크레딧 — │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 불러오지 못했습니다 │ ╰──────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

usage-rail

세션 패널(session-rail)에 사용량 카드를 넣는 모드입니다. Claude 앱의 사용량 카드와 같은 항목·모양으로 보여줍니다.

항목오른쪽막대 아래
세션 한도29% 사용됨3시간 54분 후 재설정 (하루가 넘으면 재설정: (일) 오후 9:00)
매주4% 사용됨재설정: (일) 오후 9:00
클라우드 세션 크레딧US$100 중 US$77 남음만료: 11월 5일 오후 4:59 GMT+9
  • 값은 사용량 화면이 읽는 /api/oauth/usage에서 가져옵니다. 세션의 인증은 엔진이 붙여 주고($.session.authorize), 모드는 토큰을 보지 않습니다.
  • 그 주소를 읽을 수 없으면(API 키, 게이트웨이 등) 마지막 응답의 한도 헤더 값으로 세션 한도·매주만 보여줍니다. 둘 다 없으면 이유를 한 줄로 보여줍니다.
  • 클라우드 세션(claude.ai/code, 데스크톱 앱의 클라우드 세션)에서는 표시되지 않습니다. 그 세션의 인증에는 이 주소가 요구하는 user:profile 권한이 없어 403으로 거절되고, 엔진도 같은 이유로 한도 헤더 값을 넘겨주지 않습니다. 세션 한도·매주는 헤더 값이 들어오면 그 값으로 보이고, 클라우드 세션 크레딧 줄은 그 자리에 남아 금액을 받을 수 없는 이유를 보여줍니다.
  • 세션 시작 때, 프롬프트를 보낼 때, 응답이 끝날 때, 패널이 열릴 때 다시 읽되, 주소는 5분에 한 번까지만 읽습니다(Claude Code의 사용량 화면과 같은 간격). 요청 제한(429)을 받으면 10분, 권한 거절(4xx)을 받으면 30분 뒤에 다시 시도합니다. 그 사이에도 헤더 값은 갱신됩니다. 남은 시간은 30초마다 다시 계산됩니다.
  • 막대는 데스크톱·모바일에서 둥근 막대(SVG), 터미널에서 ━ 줄로 그립니다.
  • /usage-rail: 세션 패널을 열고 바로 다시 읽습니다.
  • 카드는 세션 패널 맨 위에 놓입니다. 패널을 열고 닫는 방법은 session-rail의 README에 있습니다.

설정

설정기본값뜻
timeZoneAsia/Seoul재설정·만료 시각을 표시할 IANA 시간대. 클라우드 세션은 UTC로 실행되므로 여기서 정합니다.

설치할 때 묻고, 나중에는 /config에서 바꿀 수 있습니다.

설치

/plugin install usage-rail --marketplace ImTotem/claude-code-mods

session-rail이 함께 설치됩니다. 마켓플레이스는 이 저장소의 main 브랜치에 있습니다.

개발

이 브랜치의 루트가 모드 폴더입니다.

claude plugin validate .
claude plugin test .
claude --plugin-dir .

tsconfig.json은 엔진이 모드를 불러올 때 .claude-plugin/types/에 깔아 주는 타입 설정을 이어받습니다. 그 폴더는 커밋하지 않습니다.

Source 2 files
hooks/register.tsx 472 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { UsageCredit, UsageFetch, UsageSnapshot, UsageWindow } from '../types'
5
6// The pane session-rail opens; this mod adds its card to it, and opens it
7// too: a pane id is one pane whichever plugin opens it.
8const RAIL = 'session-rail'
9const RAIL_TITLE = '세션'
10// The endpoint the Usage screen reads, called with the session's own
11// credential through $.session.authorize; never more than once a minute.
12const USAGE_PATH = '/api/oauth/usage'
13// The endpoint limits how often it is read: five minutes after an answer,
14// as Claude Code's own Usage screen waits, longer after a refusal.
15const FETCH_GAP_MS = 5 * 60_000
16const RATE_LIMITED_GAP_MS = 10 * 60_000
17const REFUSED_GAP_MS = 30 * 60_000
18const FAILED_GAP_MS = 60_000
19const READ_TIMEOUT_MS = 15_000
20const POLL_MS = 5 * 60_000
21const TICK_MS = 30_000
22const BAR_COLOR = '#3d7eea'
23const TRACK_COLOR = 'rgba(128,128,128,0.3)'
24const BAR_HEIGHT = 8
25
26// The windows the app's usage card shows, in its order and words.
27const LABELS: Record<string, string> = {
28  five_hour: '세션 한도',
29  seven_day: '매주',
30}
31const KINDS = Object.keys(LABELS)
32const WEEKDAYS: Record<string, string> = {
33  Sun: '일',
34  Mon: '월',
35  Tue: '화',
36  Wed: '수',
37  Thu: '목',
38  Fri: '금',
39  Sat: '토',
40}
41
42const snapshot = atom({ plugin: 'usage-rail', key: 'snapshot' } as const, null)
43const now = atom({ plugin: 'usage-rail', key: 'now' } as const, 0)
44const lastFetch = atom({ plugin: 'usage-rail', key: 'lastFetch' } as const, null)
45
46type Json = Record<string, unknown>
47
48const isRecord = (value: unknown): value is Json =>
49  typeof value === 'object' && value !== null && !Array.isArray(value)
50const numberOf = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
51const stringOf = (value: unknown) => (typeof value === 'string' && value !== '' ? value : undefined)
52
53// The usage endpoint's credit with dollars and periods: the cloud session
54// credit. Its current period's end is when the credit expires.
55function creditOf(body: Json, at: number): UsageCredit | undefined {
56  const raw = body.wattle_ember
57  if (!isRecord(raw)) return undefined
58
59  const limit = numberOf(raw.limit_dollars)
60  const used = numberOf(raw.used_dollars)
61  const remaining =
62    numberOf(raw.remaining_dollars) ?? (limit !== undefined && used !== undefined ? limit - used : undefined)
63  if (limit === undefined || remaining === undefined) return undefined
64
65  const periods = Array.isArray(raw.periods) ? raw.periods.filter(isRecord) : []
66  const current = periods.find(period => {
67    const starts = Date.parse(stringOf(period.starts_at) ?? '')
68    const ends = Date.parse(stringOf(period.ends_at) ?? '')
69    return starts <= at && at < ends
70  })
71  const endsAt = stringOf(current?.ends_at) ?? stringOf(raw.resets_at)
72  const label = stringOf(raw.label)
73
74  const credit: UsageCredit = {
75    label: label === undefined || /cloud session/i.test(label) ? '클라우드 세션 크레딧' : label,
76    currency: 'USD',
77    limit,
78    remaining,
79    percent: numberOf(raw.utilization) ?? (limit > 0 ? ((limit - remaining) / limit) * 100 : 0),
80  }
81  if (endsAt !== undefined) credit.endsAt = endsAt
82
83  return credit
84}
85
86function fromEndpoint(body: Json, at: number): UsageSnapshot {
87  const windows: UsageWindow[] = []
88  for (const kind of KINDS) {
89    const raw = body[kind]
90    const percent = isRecord(raw) ? numberOf(raw.utilization) : undefined
91    if (!isRecord(raw) || percent === undefined) continue
92
93    const resetsAt = stringOf(raw.resets_at)
94    windows.push(resetsAt === undefined ? { kind, percent } : { kind, percent, resetsAt })
95  }
96
97  const shot: UsageSnapshot = { windows, measuredAt: at }
98  const credit = creditOf(body, at)
99  if (credit !== undefined) shot.credit = credit
100
101  return shot
102}
103
104// What the last response's rate-limit headers said, where the endpoint
105// cannot be read (no first-party credential, a gateway).
106function fromHeaders(limits: readonly SessionRateLimit[], at: number): UsageSnapshot {
107  const windows = limits
108    .filter(limit => KINDS.includes(limit.kind))
109    .map(({ kind, percentUsed, resetsAt }) =>
110      resetsAt === undefined ? { kind, percent: percentUsed } : { kind, percent: percentUsed, resetsAt },
111    )
112    .sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind))
113
114  return { windows, measuredAt: at }
115}
116
117const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 200)
118
119// What the last read came to, in state for the card and in the store, so a
120// read that fails in a session nobody can watch can be looked at afterwards.
121async function record($: EngineInterface, fetched: UsageFetch, sample?: unknown) {
122  await update($, lastFetch, () => fetched)
123  await $.store.set('lastFetch', sample === undefined ? fetched : { ...fetched, sample }).catch(() => undefined)
124}
125
126async function readEndpoint($: EngineInterface, at: number): Promise<UsageSnapshot | undefined> {
127  const authorization = await $.session.authorize()
128  if (authorization === null) {
129    await record($, { at, status: null, error: 'no first-party credential' })
130    return undefined
131  }
132
133  const base = ((await $.env.get('ANTHROPIC_BASE_URL')) ?? 'https://api.anthropic.com').replace(/\/+$/, '')
134  const response = await $.http.fetch(`${base}${USAGE_PATH}`, {
135    auth: authorization.handle,
136    headers: { accept: 'application/json', 'anthropic-beta': 'oauth-2025-04-20' },
137  })
138  if (!response.ok) {
139    await record($, { at, status: response.status, error: response.text.slice(0, 200) })
140    return undefined
141  }
142
143  const body: unknown = JSON.parse(response.text)
144  const shot = isRecord(body) ? fromEndpoint(body, at) : undefined
145  if (shot === undefined || (shot.windows.length === 0 && shot.credit === undefined)) {
146    const keys = isRecord(body) ? Object.keys(body).join(', ') : typeof body
147    await record($, { at, status: response.status, error: `no usage rows in the answer (${keys})` }, body)
148    return undefined
149  }
150
151  await record($, { at, status: response.status })
152
153  return shot
154}
155
156// A read that never answers ends as a failure rather than holding the card.
157function withTimeout<T>($: EngineInterface, work: Promise<T>): Promise<T> {
158  const timeout = $.clock.sleep(READ_TIMEOUT_MS).then(() => {
159    throw new Error(`no answer in ${READ_TIMEOUT_MS / 1000}s`)
160  })
161
162  return Promise.race([work, timeout])
163}
164
165function gapAfter(isAnswered: boolean, status: number | null): number {
166  if (isAnswered) return FETCH_GAP_MS
167  if (status === 429) return RATE_LIMITED_GAP_MS
168  if (status !== null && status >= 400 && status < 500) return REFUSED_GAP_MS
169
170  return FAILED_GAP_MS
171}
172
173let nextReadAt = 0
174let reading: Promise<void> | undefined
175
176async function readNow($: EngineInterface, limits: readonly SessionRateLimit[] | undefined, force: boolean) {
177  const at = await $.clock.now()
178  await update($, now, () => at)
179
180  const previous = await read($, snapshot)
181  const headers = fromHeaders(limits ?? (await $.session.usage()).rateLimits, at)
182  if (!force && at < nextReadAt) {
183    // Between reads of the endpoint, a fresher header reading still lands.
184    if (previous !== null && headers.windows.length > 0 && previous.measuredAt < at) {
185      await update($, snapshot, () => ({ ...previous, windows: headers.windows, measuredAt: at }))
186    }
187    return
188  }
189
190  const fetched = await withTimeout($, readEndpoint($, at)).catch(async (error: unknown) => {
191    await record($, { at, status: null, error: messageOf(error) })
192    return undefined
193  })
194  const outcome = await read($, lastFetch)
195  nextReadAt = at + gapAfter(fetched !== undefined, outcome?.at === at ? outcome.status : null)
196
197  // Where the endpoint did not answer, the headers' windows stand in and the
198  // credit last read stays.
199  const shot: UsageSnapshot = fetched ?? {
200    windows: headers.windows.length > 0 ? headers.windows : (previous?.windows ?? []),
201    measuredAt: at,
202    ...(previous?.credit === undefined ? {} : { credit: previous.credit }),
203  }
204  await update($, snapshot, () => shot)
205}
206
207function refresh($: EngineInterface, limits?: readonly SessionRateLimit[], force = false): Promise<void> {
208  reading ??= readNow($, limits, force).finally(() => {
209    reading = undefined
210  })
211
212  return reading
213}
214
215// Reads started by an event run on a timer of their own, so the event (a
216// session start, a prompt) never waits on the network.
217function refreshLater($: EngineInterface, limits?: readonly SessionRateLimit[]) {
218  $.clock.after(1, () => {
219    void refresh($, limits).catch(() => undefined)
220  })
221}
222
223// Wall-clock parts in the configured zone; UTC where the zone is unknown.
224function clockAt(ms: number, timeZone: string) {
225  const date = new Date(ms)
226  try {
227    const parts = new Intl.DateTimeFormat('en-US', {
228      timeZone,
229      month: 'numeric',
230      day: 'numeric',
231      weekday: 'short',
232      hour: 'numeric',
233      minute: '2-digit',
234      hourCycle: 'h23',
235      timeZoneName: 'shortOffset',
236    }).formatToParts(date)
237    const part = (type: string) => parts.find(one => one.type === type)?.value ?? ''
238
239    return {
240      month: Number(part('month')),
241      day: Number(part('day')),
242      weekday: WEEKDAYS[part('weekday')] ?? part('weekday'),
243      hour: Number(part('hour')),
244      minute: part('minute'),
245      zone: part('timeZoneName'),
246    }
247  } catch {
248    return {
249      month: date.getUTCMonth() + 1,
250      day: date.getUTCDate(),
251      weekday: Object.values(WEEKDAYS)[date.getUTCDay()] ?? '',
252      hour: date.getUTCHours(),
253      minute: String(date.getUTCMinutes()).padStart(2, '0'),
254      zone: 'UTC',
255    }
256  }
257}
258
259function time(hour: number, minute: string): string {
260  return `${hour < 12 ? '오전' : '오후'} ${hour % 12 === 0 ? 12 : hour % 12}:${minute}`
261}
262
263// "3시간 54분 후 재설정" within a day, "재설정: (일) 오후 9:00" past it.
264function resetText(resetsAt: string | undefined, at: number, timeZone: string): string | undefined {
265  const target = Date.parse(resetsAt ?? '')
266  if (Number.isNaN(target)) return undefined
267
268  const minutes = Math.max(0, Math.round((target - at) / 60_000))
269  if (minutes < 24 * 60) {
270    const hours = Math.floor(minutes / 60)
271    const rest = minutes % 60
272    if (hours === 0) return `${rest}분 후 재설정`
273
274    return rest === 0 ? `${hours}시간 후 재설정` : `${hours}시간 ${rest}분 후 재설정`
275  }
276
277  const clock = clockAt(target, timeZone)
278
279  return `재설정: (${clock.weekday}) ${time(clock.hour, clock.minute)}`
280}
281
282function expiryText(endsAt: string | undefined, timeZone: string): string | undefined {
283  const target = Date.parse(endsAt ?? '')
284  if (Number.isNaN(target)) return undefined
285
286  const clock = clockAt(target, timeZone)
287
288  return `만료: ${clock.month}월 ${clock.day}일 ${time(clock.hour, clock.minute)} ${clock.zone}`
289}
290
291function dollars(amount: number): string {
292  return `US$${Number.isInteger(amount) ? amount : amount.toFixed(2)}`
293}
294
295export const register: Register = (on, options) => {
296  const timeZone = typeof options.timeZone === 'string' && options.timeZone !== '' ? options.timeZone : 'UTC'
297
298  on('session.start', async ($, e, next) => {
299    await $.command.register({
300      name: 'usage-rail',
301      description: '세션 패널을 열어 사용량(세션 한도, 매주, 클라우드 세션 크레딧)을 보여줍니다',
302    })
303
304    // A session the app hosts reads as not interactive, so nothing waits on it.
305    refreshLater($)
306    $.clock.every(TICK_MS, () => {
307      void $.clock.now().then(at => update($, now, () => at))
308    })
309    $.clock.every(POLL_MS, () => {
310      void refresh($).catch(() => undefined)
311    })
312
313    return next(e)
314  })
315
316  on('command.run', { command: 'usage-rail' }, async $ => {
317    await refresh($, undefined, true).catch(() => undefined)
318    const opened = await $.ui.open({ id: RAIL, title: RAIL_TITLE })
319
320    return {
321      text: opened.isPlaced
322        ? '사용량 패널을 열었습니다.'
323        : `사용량 패널을 열지 못했습니다: ${opened.reason}`,
324    }
325  })
326
327  // After each turn, on each prompt and whenever the pane opens: read again,
328  // at most once a minute after a read that answered.
329  on('session.measure', ($, e, next) => {
330    refreshLater($, e.rateLimits)
331
332    return next(e)
333  })
334
335  on('prompt.submit', ($, e, next) => {
336    refreshLater($)
337
338    return next(e)
339  }).catch(($, e, next) => next(e))
340
341  on('ui.open', { id: RAIL }, async ($, e, next) => {
342    const opened = await next(e)
343    if (opened.value?.isPlaced === true) refreshLater($)
344
345    return opened
346  }).catch(($, e, next) => next(e))
347
348  // The card goes above whatever the other mods add, so it leads the pane.
349  on('ui.render', { component: 'Pane', requestId: RAIL }, async ($, e, next) => {
350    const table = $.ui.resolve(e)
351    const { Box, Text } = table
352    // The terminal's table names an Svg it draws as nothing; it gets cells.
353    const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
354    const below = await next(e)
355    const shot = await read($, snapshot)
356    const fetched = await read($, lastFetch)
357    const at = Math.max(await read($, now), shot?.measuredAt ?? 0)
358    const width = Math.max(10, e.props.bodyColumns - 4)
359
360    // A rounded bar where the surface draws SVG: widths in percent and radii
361    // in pixels, never a stretched viewBox, so the ends stay round at any
362    // width. A line of cells on the terminal.
363    const bar = (percent: number) => {
364      const used = Math.max(0, Math.min(100, percent))
365      if (Svg !== undefined) {
366        const radius = BAR_HEIGHT / 2
367        const source =
368          `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${BAR_HEIGHT}">` +
369          `<rect x="0" y="0" width="100%" height="${BAR_HEIGHT}" rx="${radius}" ry="${radius}" fill="${TRACK_COLOR}"/>` +
370          (used > 0
371            ? `<rect x="0" y="0" width="${used.toFixed(2)}%" height="${BAR_HEIGHT}" rx="${radius}" ry="${radius}" fill="${BAR_COLOR}"/>`
372            : '') +
373          `</svg>`
374        return <Svg source={source} alt={`${Math.round(used)}% 사용됨`} height={BAR_HEIGHT} />
375      }
376
377      const filled = Math.round((used / 100) * width)
378      return (
379        <Text>
380          <Text color={BAR_COLOR}>{'━'.repeat(filled)}</Text>
381          <Text dimColor>{'━'.repeat(width - filled)}</Text>
382        </Text>
383      )
384    }
385
386    const item = (name: string, value: string, percent: number, note: string | undefined) => (
387      <Box flexDirection="column" gap="sm">
388        <Box flexDirection="row" justifyContent="space-between" gap={1}>
389          <Text weight="semibold" wrap="truncate-end">
390            {name}
391          </Text>
392          <Text dimColor size="footnote" tabular>
393            {value}
394          </Text>
395        </Box>
396        {bar(percent)}
397        {note !== undefined && (
398          <Text dimColor size="footnote">
399            {note}
400          </Text>
401        )}
402      </Box>
403    )
404
405    // A cloud session's credential lacks the scope the endpoint asks for, and
406    // the credit comes from the endpoint alone: its row says why it is empty.
407    const isScopeDenied = fetched?.status === 403 && /scope/i.test(fetched.error ?? '')
408    const creditGap =
409      fetched === null
410        ? '불러오는 중입니다'
411        : isScopeDenied
412          ? '이 세션에서는 조회할 수 없습니다 (사용량 조회 권한 없음)'
413          : fetched.status === 429
414            ? '요청이 많아 잠시 뒤에 다시 불러옵니다'
415            : fetched.error !== undefined
416              ? '불러오지 못했습니다'
417              : undefined
418
419    const items = [
420      ...(shot?.windows ?? []).map(window =>
421        item(
422          LABELS[window.kind] ?? window.kind,
423          `${Math.round(window.percent)}% 사용됨`,
424          window.percent,
425          resetText(window.resetsAt, at, timeZone),
426        ),
427      ),
428      ...(shot?.credit !== undefined
429        ? [
430            item(
431              shot.credit.label,
432              `${dollars(shot.credit.limit)} 중 ${dollars(shot.credit.remaining)} 남음`,
433              shot.credit.percent,
434              expiryText(shot.credit.endsAt, timeZone),
435            ),
436          ]
437        : creditGap === undefined
438          ? []
439          : [item('클라우드 세션 크레딧', '—', 0, creditGap)]),
440    ]
441
442    const why = [fetched?.status ?? undefined, fetched?.error].filter(part => part !== undefined).join(' ')
443    const empty = isScopeDenied
444      ? '이 세션의 인증에는 사용량 조회 권한(user:profile)이 없어 사용량을 볼 수 없습니다. 클라우드 세션의 제한입니다.'
445      : fetched?.error !== undefined
446        ? `사용량을 불러오지 못했습니다 (${why})`
447        : '사용량을 불러오는 중입니다'
448
449    const card = (
450      <Box key="usage-card" flexDirection="column" gap="md" borderStyle="round" borderDimColor paddingX={1}>
451        <Text weight="bold" size="heading">
452          사용량
453        </Text>
454        {(shot?.windows.length ?? 0) === 0 && shot?.credit === undefined ? (
455          <Text dimColor wrap="wrap">
456            {empty}
457          </Text>
458        ) : (
459          items
460        )}
461      </Box>
462    )
463
464    return (
465      <Box flexDirection="column" gap={1}>
466        {card}
467        {below}
468      </Box>
469    )
470  })
471}
472
types/index.d.ts 32 lines
1// One plan window: `five_hour` (세션 한도) or `seven_day` (매주).
2export type UsageWindow = { kind: string; percent: number; resetsAt?: string }
3
4// A dollar credit with an expiry, such as the cloud session credit.
5export type UsageCredit = {
6  label: string
7  currency: string
8  limit: number
9  remaining: number
10  percent: number
11  endsAt?: string
12}
13
14export type UsageSnapshot = {
15  windows: UsageWindow[]
16  credit?: UsageCredit
17  measuredAt: number
18}
19
20// The last read of the usage endpoint: its status, or why there was none.
21export type UsageFetch = { at: number; status: number | null; error?: string }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'usage-rail': {
26      snapshot: UsageSnapshot | null
27      now: number
28      lastFetch: UsageFetch | null
29    }
30  }
31}
32