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

세션 패널(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), 모드는 토큰을 보지 않습니다.user:profile 권한이 없어 403으로 거절되고, 엔진도 같은 이유로 한도 헤더 값을 넘겨주지 않습니다. 세션 한도·매주는 헤더 값이 들어오면 그 값으로 보이고, 클라우드 세션 크레딧 줄은 그 자리에 남아 금액을 받을 수 없는 이유를 보여줍니다.━ 줄로 그립니다./usage-rail: 세션 패널을 열고 바로 다시 읽습니다.세션 패널 맨 위에 놓입니다. 패널을 열고 닫는 방법은 session-rail의 README에 있습니다.| 설정 | 기본값 | 뜻 |
|---|---|---|
timeZone | Asia/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/에 깔아 주는 타입 설정을 이어받습니다. 그 폴더는 커밋하지 않습니다.
hooks/register.tsx 472 lines1import { 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}
472types/index.d.ts 32 lines1// 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