Your Claude plan usage right above the message box: the 5-hour and weekly limits with reset times, plus this session's context fill

Mods that load in every Claude Code session: the terminal, the desktop app's Code tab, and cloud sessions.
| Mod | What it shows |
|---|---|
| repo-sentry | Business, repo, branch, behind or ahead of origin, dirty count. Warns on work sitting on main, a stale repo, or a staged secret. |
| progress-pulse | A live gradient progress band for the session's task list, a cheer on every finished task, /progress for the full view, /progress demo to see it run. |
| usage-meter | The 5-hour and weekly plan limits with reset times, context fill and session cost, right above the message box. /usage-meter hides or shows it. |
setup-mac.js (run once) installs every mod as a Claude Code plugin read straight from this folder, and adds a session-start hook that runs sync-mods.js --quiet. A mod added to the marketplace installs itself at the next session start; an edit to a mod takes effect at the next session start or /reload-plugins.cloud-setup.sh goes into each cloud environment's setup script. Every new container clones this repo and runs sync-mods.js.Put it in its own folder with .claude-plugin/plugin.json, add it to .claude-plugin/marketplace.json, check it with claude plugin validate <folder> and claude plugin test <folder>, commit and push.
mcp-diagnose.js, mcp-where.js, move-mcp-to-repo.js, mcp-set-secret.js, mcp-env-from-dotenv.js: MCP server helpers that never print secret values. servicetrade-mcp/: the read-only ServiceTrade MCP server.
hooks/register.tsx 111 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Reading } from '../types'
5import { crossed, k, label, meter, money, ordered, resetText, textReading, tone } from './logic'
6
7const readingA = atom({ plugin: 'usage-meter', key: 'reading' } as const, null as Reading | null)
8const hiddenA = atom({ plugin: 'usage-meter', key: 'hidden' } as const, false)
9
10function toReading(u: SessionUsage | SessionMeasureInput, at: number): Reading {
11 return {
12 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) })),
13 contextPercent: u.context.percent ?? null,
14 contextWindow: u.context.window,
15 costUsd: u.cost ? u.cost.usd : null,
16 at,
17 }
18}
19
20async function store($: EngineInterface, next: Reading): Promise<void> {
21 const prev = await read($, readingA)
22 await update($, readingA, () => next)
23 for (const l of next.limits) {
24 const before = prev?.limits.find(p => p.kind === l.kind)?.percentUsed
25 const m = crossed(before, l.percentUsed)
26 if (m) $.ui.toast(`${label(l.kind)} limit at ${l.percentUsed}%. ${resetText(l.resetsAt, next.at)}`.trim(), { timeoutMs: 8000 })
27 }
28}
29
30export const register: Register = on => {
31 // A first reading at start (free: no API call), then every change the engine measures.
32 on('session.start', async ($, e, next) => {
33 const r = await next(e)
34 try {
35 const u = await $.session.usage()
36 await store($, toReading(u, await $.clock.now()))
37 } catch { /* the band waits for the first measurement instead */ }
38 await $.command.register({ name: 'usage-meter', description: 'Show plan usage now; add hide or show to toggle the meter above the message box' })
39 return r
40 })
41
42 on('session.measure', async ($, e, next) => {
43 const r = await next(e)
44 await store($, toReading(e, await $.clock.now()))
45 return r
46 })
47
48 // /usage-meter prints the reading as text (screens that draw no bands still see it);
49 // /usage-meter hide and /usage-meter show toggle the band.
50 on('command.run', { command: 'usage-meter' }, async ($, e) => {
51 const arg = e.args.trim().toLowerCase()
52 if (arg === 'hide' || arg === 'show') {
53 await update($, hiddenA, () => arg === 'hide')
54 return { text: arg === 'hide' ? 'Usage meter hidden. /usage-meter show brings it back.' : 'Usage meter shown.' }
55 }
56 let r = await read($, readingA)
57 try { r = toReading(await $.session.usage(), await $.clock.now()) } catch { /* keep the last reading */ }
58 return { text: textReading(r, await $.clock.now()) }
59 })
60
61 // Drawn below any other band, so it sits directly on top of the message box.
62 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63 const below = await next(e)
64 const r = await read($, readingA)
65 if (e.props.hasSurvey || (await read($, hiddenA))) return below
66 const { Box, Text } = $.ui.resolve(e)
67 const now = await $.clock.now()
68 const cols = e.props.bodyColumns
69 const barW = cols >= 110 ? 12 : cols >= 80 ? 8 : 5
70
71 const limits = r ? ordered(r.limits) : []
72 const pieces = limits.map(l => {
73 const m = meter(l.percentUsed, barW)
74 const c = tone(l.percentUsed)
75 return (
76 <Text>
77 <Text bold>{label(l.kind) + ' '}</Text>
78 <Text color={c}>{m.filled}</Text><Text dimColor>{m.empty}</Text>
79 <Text bold color={c}>{` ${l.percentUsed}%`}</Text>
80 <Text dimColor>{cols >= 70 && l.resetsAt ? ` ${resetText(l.resetsAt, now)}` : ''}</Text>
81 </Text>
82 )
83 })
84 const ctx = r && r.contextPercent !== null
85 ? <Text>
86 <Text bold>{'Context '}</Text>
87 <Text bold color={tone(r.contextPercent)}>{`${r.contextPercent}%`}</Text>
88 <Text dimColor>{` of ${k(r.contextWindow)}`}</Text>
89 </Text>
90 : null
91 const cost = r && r.costUsd !== null && cols >= 100
92 ? <Text dimColor>{`${money(r.costUsd)} this session at API rates`}</Text>
93 : null
94 const empty = !limits.length
95 ? <Text dimColor>{r ? 'Plan limits appear after the first reply in this session.' : 'Usage appears after the first reply in this session.'}</Text>
96 : null
97
98 return (
99 <Box flexDirection="column">
100 {below}
101 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
102 {empty}
103 {pieces}
104 {ctx}
105 {cost}
106 </Box>
107 </Box>
108 )
109 })
110}
111hooks/logic.ts 62 lines1import type { Limit, Reading } from '../types'
2
3export const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'Week', spend_limit: 'Spend' }
4export const label = (kind: string) => LABEL[kind] ?? kind.replace(/_/g, ' ')
5
6// Calm green while there is room, gold past half, orange from 80, red from 95.
7export function tone(pct: number): string {
8 if (pct >= 95) return '#ff4d4f'
9 if (pct >= 80) return '#ff6b35'
10 if (pct >= 50) return '#ffc14d'
11 return '#2ed573'
12}
13
14export function meter(pct: number, width: number): { filled: string; empty: string } {
15 const w = Math.max(3, width)
16 const n = Math.max(0, Math.min(w, Math.round((Math.min(100, Math.max(0, pct)) / 100) * w)))
17 return { filled: '█'.repeat(n), empty: '░'.repeat(w - n) }
18}
19
20// "resets 2:40 PM" when it is today, "resets Mon 9 AM" within a week, otherwise the date.
21export function resetText(iso: string | undefined, now: number, timeZone?: string): string {
22 if (!iso) return ''
23 const t = Date.parse(iso)
24 if (Number.isNaN(t)) return ''
25 if (t <= now) return 'resetting now'
26 const opts = timeZone ? { timeZone } : {}
27 const day = (ms: number) => new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms)
28 const time = new Intl.DateTimeFormat('en-US', { ...opts, hour: 'numeric', minute: '2-digit' }).format(t).replace(':00', '')
29 if (day(t) === day(now)) return `resets ${time}`
30 if (t - now < 6.5 * 864e5) return `resets ${new Intl.DateTimeFormat('en-US', { ...opts, weekday: 'short' }).format(t)} ${time}`
31 return `resets ${new Intl.DateTimeFormat('en-US', { ...opts, month: 'short', day: 'numeric' }).format(t)}`
32}
33
34// The order people read them in: the short window first, the week second, anything else after.
35export function ordered(limits: Limit[]): Limit[] {
36 const rank = (k: string) => (k === 'five_hour' ? 0 : k === 'seven_day' ? 1 : 2)
37 return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
38}
39
40// The thresholds worth a toast, each crossed once per climb.
41export function crossed(before: number | undefined, after: number): number | null {
42 for (const m of [95, 90, 80]) if ((before ?? 0) < m && after >= m) return m
43 return null
44}
45
46export const money = (usd: number) => (usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`)
47export const k = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : `${Math.round(n / 1000)}k`)
48
49// The reading as plain text, for /usage-meter and for screens that draw no bands.
50export function textReading(r: Reading | null, now: number): string {
51 if (!r) return 'No usage reading yet. It arrives with the first reply in this session.'
52 const out: string[] = []
53 for (const l of ordered(r.limits)) {
54 const m = meter(l.percentUsed, 20)
55 out.push(`${label(l.kind).padEnd(7)} ${m.filled}${m.empty} ${l.percentUsed}% ${resetText(l.resetsAt, now)}`.trimEnd())
56 }
57 if (!r.limits.length) out.push('Plan limits: no reading from Claude Code yet in this session.')
58 if (r.contextPercent !== null) out.push(`Context ${r.contextPercent}% of ${k(r.contextWindow)}`)
59 if (r.costUsd !== null) out.push(`${money(r.costUsd)} this session at API rates`)
60 return out.join('\n')
61}
62types/index.d.ts 16 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Reading = {
4 limits: Limit[]
5 contextPercent: number | null
6 contextWindow: number
7 costUsd: number | null
8 at: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'usage-meter': { reading: Reading | null; hidden: boolean }
14 }
15}
16