SLOPSHOPPER

Plan Usage

Shows your Claude plan's session and weekly limits above the prompt, gives Claude a plan_usage tool to check them without a model call, and can pause a…

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-05potterdigital/plan-usage-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-usage
› 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 › /plan-usage ⎿ plan-usage: { ⎿ plan-usage: "limits": [ ⎿ plan-usage: { ⎿ plan-usage: "kind": "five_hour", ⎿ plan-usage: "percentUsed": 31, ⎿ plan-usage: "resetsAt": 1760003600000, Plan usage: show your limits above the prompt 1: Always 2: From 50% 3: From 80% 4: Other ● OK Session 31% as of 1:23 AM PT ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Plan usage: show your limits above the prompt 1: Always 2: From 50% 3: From 80% 4: Other ● OK Session 31% as of 1:23 AM PT ⟨Claude Code's own drawing⟩
README

Plan Usage

A Claude Code mod that keeps your Claude plan's limits in view and lets Claude check them itself.

The line above the prompt: a blue "OK Session 11%" with its reset time, then an orange "HIGH Weekly 98%" with its reset time. Below it, Claude Code's own weekly-limit notice, which this mod leaves in place.

  • A line above the prompt with your session (5-hour) and weekly limits, how much of each you have used, and when each resets, in your time zone.
  • A plan_usage tool Claude can call before long or unattended work, and a /plan-usage command for you. Both are answered locally from figures Claude Code already has, with no model call.
  • An optional pause that stops a long-running session just before the session limit and continues it after the limit resets.

Requirements

  • Claude Code 2.1.289 or later. This is the version it is tested on; mods are early access and the API moves between releases.
  • A claude.ai Pro or Max plan. Claude Code reports plan limits only on a subscription. On an API key the line says there is no reading, unless another session on the same Claude profile is signed in to a plan.

Tested on macOS. It uses no platform-specific code, but Linux and Windows are untested.

Install

claude plugin marketplace add potterdigital/plan-usage-mod
claude plugin install plan-usage@potterdigital

If the install mentions userConfig options not yet set, that is fine: every setting has a default.

From inside a session, /plugin install plan-usage --marketplace potterdigital/plan-usage-mod asks you to confirm the marketplace, then opens the plugin so you can choose where to install it.

To try it without installing, clone the repository and start a session with claude --plugin-dir ./plan-usage-mod.

What you see

Line showsMeans
● OK in blueUnder the warning level (80% unless you change it)
▲ HIGH in orangeAt or above the warning level
■ LIMIT in orangeAt or past 100%
⏸ PAUSED at 96% · continues 4:12 AM CTThe optional pause is holding the session
as of 2:31 AM CTThe newest figures are more than 5 minutes old
○ pause off until 4:10 AM CTYou overrode the optional pause for this session window

Every level has its own icon and word as well as its color, so it reads the same for colorblind users.

To keep the line out of the way until it matters, set show_at: with 50, a limit appears only once it reaches 50%, and the line is empty while neither has.

Until someone on a computer answers it, the line asks: Plan usage: show your limits above the prompt 1: Always 2: From 50% 3: From 80% 4: Other. Type the digit at an empty prompt, or click; Other asks for a whole number from 0 to 100. As with Claude Code's own survey in the same place, a message you start with 1 to 4 at an empty prompt picks that answer while the question is up. The choice is saved as show_at, so /config shows it and can change it, and a choice made while Claude works is saved when the turn ends. The question is not asked in a claude -p run.

In a narrow terminal (under about 105 columns) the line shortens to the time left, as in Session 9% · 3h 55m. What mods that run after this one draw in the same place, Claude Code's own notices included, stays visible below this line.

The plan_usage tool

Claude sees it as mcp__plan-usage__plan_usage. It takes no input and answers JSON:

{
  "limits": [
    {
      "kind": "five_hour",
      "percentUsed": 8,
      "resetsAt": "2026-10-04T11:40:00.000Z",
      "label": "Session",
      "level": "ok",
      "resetsAtLocal": "6:40 AM CT",
      "resetsIn": "3h 57m"
    },
    {
      "kind": "seven_day",
      "percentUsed": 98,
      "resetsAt": "2026-10-04T10:00:00.000Z",
      "label": "Weekly",
      "level": "high",
      "resetsAtLocal": "5:00 AM CT",
      "resetsIn": "2h 17m"
    }
  ],
  "readAtLocal": "2:43 AM CT",
  "timeZone": "America/Chicago",
  "autoPause": { "isOn": false, "threshold": 95, "pause": null }
}
  • kind, percentUsed and resetsAt are exactly what Claude Code reports. Other kinds appear when your plan has them, such as seven_day_opus.
  • A reset that is not today includes the day, as in Thu 9:00 PM CT.
  • Before the first reading, limits is empty and a note explains why. If the time_zone setting is not a valid zone, a warning says so.
  • /plan-usage prints the same JSON, after the plan-usage: label Claude Code puts on every plugin command's output.

Answering costs no model call. Like any tool, its name and description are part of what Claude Code sends with each request, and Claude reads its answer.

To have Claude check before big jobs, add a line like this to your CLAUDE.md: Before starting long or unattended work, call plan_usage and stop if the weekly limit is above 90%.

Where the numbers come from

Claude Code reads your plan limits from each model reply; they are the figures its status line receives. This mod takes them from there (the session.measure event) and makes no network requests of its own.

  • While Claude works, the line updates with every reply.
  • While a session is idle, it checks once a minute for a newer reading that another of your open sessions stored. That is a local file read. Sessions share readings when they use the same Claude profile and load the mod the same way (installed, or from the same --plugin-dir folder).
  • When every session is idle, nothing new arrives, so the line keeps the last figures and shows how old they are.

The figures are Claude Code's, so they are as current as its last reply. A limit can move from usage elsewhere, such as claude.ai in a browser, before the next reply shows it.

The optional pause

Off by default, and it runs only where a person is at the prompt: in a claude -p run or an SDK host it stays off, because the run ends with its turn and could never continue.

When it is on and the session limit reaches the threshold (95% unless you change it):

  1. Tool calls that are already running finish. New ones are refused with a message telling Claude to stop. A subagent counts as one running call, so the pause waits for it while refusing its new tool calls.
  2. The turn ends.
  3. Two minutes after the session limit resets, the mod sends the session a prompt to continue: Plan usage reset; resume where you left off from the files on disk. Claude reads it with a note that this mod sent it, not you.

The check runs whenever new figures arrive during a turn and when each turn starts, so a turn that starts past the threshold, including one you start, a /loop iteration or a queued prompt, is stopped before Claude answers, and the line shows PAUSED. If a turn finishes on its own before anything was refused, nothing is continued later.

You stay in charge. While a pause is in place, a prompt you send yourself cancels it, and the mod does not pause again until that session window resets; the line shows ○ pause off until and the reset time. That counts prompts typed at the terminal, sent through Remote Control, or sent from Slack as the session's owner. A message you type while a turn is still running is queued into that turn and does not cancel anything. /clear, /resume, /branch and closing the session also cancel a waiting continue.

The pause watches the session limit only. If the weekly limit is what runs out, the continuing prompt meets that limit instead.

How this differs from Claude Code's built-in waiting. Claude Code already waits and continues on its own when a usage limit stops a session (autoContinueAtUsageLimit, on by default). That starts once the limit has been hit, wherever the work was. This pause starts a little earlier, at a threshold you pick, so the turn ends between tool calls rather than partway through one, and the session keeps some headroom for you.

Settings

Set these in /config, where each one is a row, or in settings.json:

{
  "pluginConfigs": {
    "plan-usage@potterdigital": {
      "options": { "auto_pause": true, "pause_threshold": 90 }
    }
  }
}

With --plugin-dir, the key is plan-usage@inline.

SettingDefaultWhat it does
warn_at80Percent at which the line turns orange and reads HIGH
show_at0Show a limit on the line only from this percent. 0 shows it always.
time_zoneemptyIANA time zone for reset times, such as America/New_York. Empty uses your computer's. An invalid name falls back to your computer's, with a note in the session and a warning in the tool's answer.
auto_pausefalseTurns the pause on
pause_threshold95Session-limit percent that starts the pause
resume_promptsee aboveWhat the session is sent when it continues

Zones without a short name show as an offset, such as 9:00 AM GMT+1 for London.

What it can access

claude plugin validate . lists everything the mod hooks and calls. In short:

  • Reads: the plan figures Claude Code already has, and the CLAUDE_CONFIG_DIR environment variable, which keeps readings from different Claude profiles apart. It is told about each prompt you send, like every prompt hook, and uses only who sent it; it does not read, store or forward the text.
  • Writes: your first-run choice, once, as this mod's own show_at setting; the last reading and any pause, to the session's own state; and the last reading and whether the first-run question was answered, to this mod's file in Claude Code's plugin store (plugins/store/ in your Claude configuration directory, ~/.claude by default).
  • Acts: when the pause is on, it refuses tool calls, ends a turn, and sends one prompt. Nothing else.
  • Never: network requests, other files, other environment variables, model calls.

Development

claude plugin test .                 # 44 tests, under 3 seconds
claude plugin validate --strict .    # what Claude Code will load, warnings as errors
npx prettier --check .

To type-check, load the mod once (claude -p "/plan-usage" --plugin-dir . is enough) so Claude Code writes the type declarations for your version into .claude-plugin/types/, then run npx -p typescript tsc -p .. CI runs the first three; it cannot type-check, because Anthropic's published copy of the declarations predates APIs this mod uses.

See CONTRIBUTING.md and SECURITY.md.

License

MIT. Not affiliated with Anthropic. Claude and Claude Code are trademarks of Anthropic.

Source 3 files
hooks/register.tsx 494 lines
1import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
2
3import type { PlanUsageLimit, PlanUsageOnboarding, PlanUsagePause, PlanUsageReading } from '../types'
4import {
5  ORANGE,
6  RESUME_DELAY_MS,
7  describeLimits,
8  formatIn,
9  isReading,
10  labelOf,
11  lookOf,
12  pauseTrigger,
13  resetsAtMs,
14  resolveZone,
15  timeFormat,
16} from './usage'
17import type { TimeFormat } from './usage'
18
19// $.state carries the figures to the band and across a hot reload. Every $.state read within one event
20// sees a single moment, so a timer or a long tool call would read stale values: the module variables
21// below are what the logic trusts, and each change is written through to $.state.
22const READING = { plugin: 'plan-usage', key: 'reading' } as const
23const PAUSE = { plugin: 'plan-usage', key: 'pause' } as const
24const OVERRIDE = { plugin: 'plan-usage', key: 'overrideUntil' } as const
25const ONBOARDING = { plugin: 'plan-usage', key: 'onboarding' } as const
26// Set in $.store once someone has chosen show_at on this machine, so the first-run line shows once.
27const ONBOARDED_KEY = 'onboarded'
28
29const TOOL = 'mcp__plan-usage__plan_usage'
30// How often an idle session looks for a newer reading another session stored. A local file read.
31const SYNC_MS = 60_000
32// Redraws the band so "resets in" counts down.
33const TICK_MS = 30_000
34const STALE_MS = 5 * 60_000
35const BAND_KINDS = ['five_hour', 'seven_day']
36
37let warnAt = 80
38let showAt = 0
39let autoPause = false
40let threshold = 95
41let resumePrompt = ''
42let time: TimeFormat = timeFormat('UTC')
43let zoneWarning = ''
44
45let latest: PlanUsageReading | null = null
46let held: PlanUsagePause | null = null
47// The main turn now running, kept for $.turn.abort; null between turns.
48let turnId: string | null = null
49let toolsRunning = 0
50// Set when the pause refused a tool call: the turn was cut short even if it then ended by itself.
51let wasRefused = false
52// A person sent a prompt during a pause: no pausing again until this session window resets.
53let overrideUntil = 0
54let resumeTimer: Timer | null = null
55
56async function setReading($: EngineInterface, next: PlanUsageReading | null): Promise<void> {
57  latest = next
58  await $.state.set(READING, next)
59}
60
61async function setPause($: EngineInterface, next: PlanUsagePause | null): Promise<void> {
62  held = next
63  await $.state.set(PAUSE, next)
64}
65
66async function setOverride($: EngineInterface, until: number): Promise<void> {
67  overrideUntil = until
68  await $.state.set(OVERRIDE, until)
69}
70
71// Plan limits are per login. The store file is per configuration directory, but a machine can point several
72// CLAUDE_CONFIG_DIRs at one plugins folder, so the key keeps their readings apart.
73async function sharedKey($: EngineInterface): Promise<string> {
74  return `reading:${(await $.env.get('CLAUDE_CONFIG_DIR')) ?? 'default'}`
75}
76
77// Keeps whichever reading is newer, then checks the pause threshold.
78async function adopt($: EngineInterface, next: PlanUsageReading): Promise<void> {
79  if (next.limits.length === 0) return
80  if (latest !== null && latest.readAt > next.readAt) return
81  await setReading($, next)
82  await checkPause($)
83}
84
85function copyLimits(limits: readonly SessionRateLimit[]): PlanUsageLimit[] {
86  return limits.map(({ kind, percentUsed, resetsAt }) =>
87    resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
88  )
89}
90
91// A model reply's figures, as session.measure delivers them: stamped now, and stored so idle sessions on the
92// same login see them.
93async function fromReply($: EngineInterface, limits: readonly SessionRateLimit[]): Promise<void> {
94  const reply: PlanUsageReading = { limits: copyLimits(limits), readAt: await $.clock.now() }
95  await adopt($, reply)
96  await $.store.set(await sharedKey($), reply)
97}
98
99async function fromShared($: EngineInterface): Promise<void> {
100  const stored = await $.store.get(await sharedKey($))
101  if (isReading(stored)) await adopt($, stored)
102}
103
104// The figures of this session's last reply when nothing better is known. They may be old, so they are never
105// stored for other sessions and are dated as far back as they could be: the start of this session.
106async function fromLastReply($: EngineInterface): Promise<void> {
107  if (latest !== null) return
108  const usage = await $.session.usage()
109  if (usage.rateLimits.length > 0) await adopt($, { limits: copyLimits(usage.rateLimits), readAt: usage.startedAt })
110}
111
112async function checkPause($: EngineInterface): Promise<void> {
113  const now = await $.clock.now()
114  if (!autoPause || turnId === null || held !== null || latest === null || now < overrideUntil) return
115  const hit = pauseTrigger(latest.limits, threshold)
116  if (hit === undefined) return
117  const resetsAt = resetsAtMs(hit)
118  if (resetsAt === undefined) {
119    $.ui.log(`session limit at ${hit.percentUsed}% but Claude Code reported no reset time, so no pause`)
120    return
121  }
122  // A reading from a window that has since reset says nothing about the current one.
123  if (resetsAt <= now) return
124  await setPause($, { phase: 'pending', percentUsed: hit.percentUsed, resumeAt: resetsAt + RESUME_DELAY_MS })
125  if (toolsRunning === 0) {
126    await stopTurn($)
127    return
128  }
129  $.ui.toast(`Session limit at ${hit.percentUsed}%: pausing once the running tool call finishes`)
130}
131
132// Ends the running turn and waits for the reset.
133async function stopTurn($: EngineInterface): Promise<void> {
134  if (held === null || held.phase !== 'pending') return
135  // A prompt can cancel the pause while the abort is awaited, so work from this copy.
136  const pause: PlanUsagePause = { ...held, phase: 'waiting' }
137  const stopping = turnId
138  turnId = null
139  await setPause($, pause)
140  if (stopping !== null) {
141    try {
142      await $.turn.abort({ turnId: stopping })
143    } catch (error) {
144      $.ui.log(`could not stop the turn: ${String(error)}`, { to: 'debug' })
145    }
146  }
147  const now = await $.clock.now()
148  $.ui.log(
149    `paused at ${pause.percentUsed}% of the session limit; continuing at ${time.at(pause.resumeAt, now)}. Send a prompt yourself to cancel.`,
150  )
151  await armResume($)
152  // The stopped turn's turn.complete no longer matches turnId, so a first-run choice it held is saved here.
153  if (pendingShowAt !== null) await saveShowAt($, pendingShowAt)
154}
155
156async function armResume($: EngineInterface): Promise<void> {
157  if (held === null || held.phase !== 'waiting') return
158  resumeTimer?.cancel()
159  const wait = Math.max(0, held.resumeAt - (await $.clock.now()))
160  resumeTimer = $.clock.after(wait, () => {
161    void resume($)
162  })
163}
164
165// At the reset plus 2 minutes: if a newer reading moved the reset later, wait again; else continue.
166async function resume($: EngineInterface): Promise<void> {
167  resumeTimer = null
168  await fromShared($)
169  const still = latest === null ? undefined : pauseTrigger(latest.limits, threshold)
170  const movedTo = still === undefined ? undefined : resetsAtMs(still)
171  if (held !== null && movedTo !== undefined && movedTo + RESUME_DELAY_MS > held.resumeAt) {
172    await setPause($, { ...held, resumeAt: movedTo + RESUME_DELAY_MS })
173    await armResume($)
174    return
175  }
176  await setPause($, null)
177  wasRefused = false
178  await $.prompt.submit({ text: resumePrompt })
179}
180
181// Drops a pending or waiting pause without continuing anything.
182async function cancelPause($: EngineInterface): Promise<void> {
183  resumeTimer?.cancel()
184  resumeTimer = null
185  wasRefused = false
186  if (held !== null) await setPause($, null)
187}
188
189// The first-run choice: shown until someone on this computer has saved one.
190let needsChoice = false
191// A choice made while a turn runs, saved when the main turn ends: saving reloads this module.
192let pendingShowAt: number | null = null
193
194async function chooseShowAt($: EngineInterface, percent: number): Promise<void> {
195  showAt = percent
196  await $.state.set(ONBOARDING, null)
197  if (turnId !== null) {
198    pendingShowAt = percent
199    $.ui.toast(`Plan usage: ${shownFrom(percent)}. Saved when this turn ends.`)
200    return
201  }
202  await saveShowAt($, percent)
203}
204
205function shownFrom(percent: number): string {
206  return percent === 0 ? 'always shown' : `shown from ${percent}%`
207}
208
209// Saves show_at where /config shows it. The key is "<plugin name>.<field>", read live under a marketplace
210// install (plan-usage@potterdigital) on Claude Code 2.1.289. Saving reloads the module, so the done flag is
211// written first and taken back if the save is refused.
212async function saveShowAt($: EngineInterface, percent: number): Promise<void> {
213  pendingShowAt = null
214  await $.store.set(ONBOARDED_KEY, true)
215  let refusal: string | undefined
216  try {
217    refusal = (await $.config.set({ key: 'plan-usage.show_at', value: percent })).deny
218  } catch (error) {
219    refusal = String(error)
220  }
221  if (refusal !== undefined) {
222    await $.store.set(ONBOARDED_KEY, false)
223    const retry: PlanUsageOnboarding = { step: 'choose', note: 'Could not save that. Choose again, or set show_at in /config.' }
224    await $.state.set(ONBOARDING, retry)
225    $.ui.log(`show_at was not saved: ${refusal}`)
226    return
227  }
228  needsChoice = false
229  $.ui.toast(`Plan usage: ${shownFrom(percent)}. Change it in /config.`)
230}
231
232async function submitOtherPercent($: EngineInterface, text: string): Promise<void> {
233  const entry = text.trim()
234  const percent = Number(entry)
235  if (!/^\d{1,3}$/.test(entry) || percent > 100) {
236    const retry: PlanUsageOnboarding = { step: 'other', note: 'Enter a whole number from 0 to 100.' }
237    await $.state.set(ONBOARDING, retry)
238    return
239  }
240  await chooseShowAt($, percent)
241}
242
243async function askOtherPercent($: EngineInterface): Promise<void> {
244  const other: PlanUsageOnboarding = { step: 'other' }
245  await $.state.set(ONBOARDING, other)
246}
247
248async function report($: EngineInterface): Promise<string> {
249  await fromShared($)
250  await fromLastReply($)
251  const now = await $.clock.now()
252  const pause = held === null ? null : { ...held, resumeAtLocal: time.at(held.resumeAt, now) }
253  return JSON.stringify(
254    {
255      limits: latest === null ? [] : describeLimits(latest.limits, now, time, warnAt),
256      readAtLocal: latest === null ? null : time.at(latest.readAt, now),
257      timeZone: time.zone,
258      autoPause: { isOn: autoPause, threshold, pause },
259      ...(latest === null
260        ? { note: 'No reading yet: Claude Code reports plan limits with model replies on a claude.ai plan.' }
261        : {}),
262      ...(zoneWarning === '' ? {} : { warning: zoneWarning }),
263    },
264    null,
265    2,
266  )
267}
268
269export const register: Register = (on, options) => {
270  warnAt = typeof options.warn_at === 'number' ? options.warn_at : 80
271  showAt = typeof options.show_at === 'number' ? options.show_at : 0
272  autoPause = options.auto_pause === true
273  threshold = typeof options.pause_threshold === 'number' ? options.pause_threshold : 95
274  resumePrompt =
275    typeof options.resume_prompt === 'string' && options.resume_prompt.trim() !== ''
276      ? options.resume_prompt
277      : 'Plan usage reset; resume where you left off from the files on disk.'
278  const configured = typeof options.time_zone === 'string' ? options.time_zone : ''
279  const resolved = resolveZone(configured)
280  time = timeFormat(resolved.zone)
281  zoneWarning = resolved.isFallback ? `time_zone "${configured}" is not a time zone; showing ${resolved.zone}` : ''
282
283  on('session.start', async ($, e, next) => {
284    // After a hot reload, pick up what the previous copy of this module held.
285    latest = (await $.state.get(READING)).value ?? null
286    held = (await $.state.get(PAUSE)).value ?? null
287    overrideUntil = (await $.state.get(OVERRIDE)).value ?? 0
288    if (zoneWarning !== '') $.ui.log(zoneWarning)
289    if (autoPause && !e.isInteractive) {
290      // A claude -p run or SDK host ends with its turn, so a paused run could never continue.
291      autoPause = false
292      $.ui.log('auto_pause is off in this session: it runs only where a person is at the prompt')
293    }
294    needsChoice = e.isInteractive && (await $.store.get(ONBOARDED_KEY)) !== true
295    if (needsChoice) {
296      const choose: PlanUsageOnboarding = { step: 'choose' }
297      await $.state.set(ONBOARDING, choose)
298    }
299    await fromShared($)
300    await fromLastReply($)
301    $.clock.every(SYNC_MS, () => {
302      void fromShared($)
303    })
304    $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
305    await armResume($)
306    await $.tool.register({
307      name: 'plan_usage',
308      description:
309        "The Claude plan's usage limits right now, answered locally without a model call: one entry per window, with kind (five_hour is the session limit, seven_day the weekly one), percentUsed from 0 to 100, resetsAt as ISO 8601 and resetsAtLocal in the user's time zone. Check it before starting long or unattended work.",
310      inputSchema: { type: 'object', properties: {} },
311    })
312    try {
313      await $.command.register({ name: 'plan-usage', description: 'Show plan usage limits and reset times', immediate: true })
314    } catch (error) {
315      $.ui.log(`/plan-usage not registered: ${String(error)}`, { to: 'debug' })
316    }
317    return next(e)
318  })
319
320  // /clear, /resume and /branch reset $.state and start a different conversation: drop any pending continue.
321  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
322    turnId = null
323    await cancelPause($)
324    if (latest !== null) await $.state.set(READING, latest)
325    if (overrideUntil > 0) await $.state.set(OVERRIDE, overrideUntil)
326    if (pendingShowAt !== null) {
327      await saveShowAt($, pendingShowAt)
328    } else if (needsChoice) {
329      const choose: PlanUsageOnboarding = { step: 'choose' }
330      await $.state.set(ONBOARDING, choose)
331    }
332    return next(e)
333  })
334
335  // A person outranks the pause. A prompt they send that starts its own turn (one typed while a turn runs is
336  // queued into it, and leaves the pause alone) cancels a pause, and pausing stays off until the session window
337  // resets. A prompt sent past the threshold with no pause yet is paused at turn.start like any other, so the
338  // override is always a deliberate second prompt. Origins: the terminal, Remote Control, and the owner on Slack.
339  on('prompt.submit', { origin: { kind: /^(composer|bridge|slack-ping)$/ } }, async ($, e, next) => {
340    if (held !== null && e.turnId === undefined) {
341      await setOverride($, Math.max(overrideUntil, held.resumeAt - RESUME_DELAY_MS))
342      $.ui.toast(`Pause cancelled: you sent a prompt. No pausing until ${time.at(overrideUntil, await $.clock.now())}.`)
343      await cancelPause($)
344    }
345    return next(e)
346  })
347
348  on('session.measure', async ($, e, next) => {
349    if (e.changed.includes('rateLimits')) await fromReply($, e.rateLimits)
350    return next(e)
351  })
352
353  on('turn.start', async ($, e, next) => {
354    // A main turn that never reported turn.complete leaves its id behind; a new turn replaces it.
355    turnId = e.turnId
356    // A reading that crossed the threshold between turns applies to this one.
357    await checkPause($)
358    return next(e)
359  })
360
361  on('turn.complete', async ($, e, next) => {
362    // Subagent turns complete with agentId set and do not end the main turn.
363    if (e.agentId === undefined && e.turnId === turnId) {
364      turnId = null
365      if (held !== null && held.phase === 'pending') {
366        if (wasRefused) {
367          // The pause refused a tool call, so the work stopped early: continue it after the reset.
368          await setPause($, { ...held, phase: 'waiting' })
369          await armResume($)
370        } else {
371          // The turn finished on its own before anything was cut: nothing to continue.
372          await setPause($, null)
373        }
374      }
375    }
376    // Last: saving the first-run choice reloads the module, so the pause above is settled first.
377    if (e.agentId === undefined && pendingShowAt !== null && turnId === null) await saveShowAt($, pendingShowAt)
378    return next(e)
379  })
380
381  on('tool.call', async ($, e, next) => {
382    if (e.tool !== TOOL && held !== null) {
383      wasRefused = true
384      return {
385        deny: `Paused: the plan's session limit is at ${held.percentUsed}%. Stop here; the session continues after the limit resets.`,
386      }
387    }
388    toolsRunning += 1
389    try {
390      return e.tool === TOOL ? { result: await report($) } : await next(e)
391    } finally {
392      toolsRunning -= 1
393      if (toolsRunning === 0) await stopTurn($)
394    }
395  })
396
397  on('command.run', { command: 'plan-usage' }, async $ => ({ text: await report($) }))
398
399  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
400    if (e.props.hasSurvey) return next(e)
401    const current = (await $.state.get(READING)).value ?? null
402    const pausing = (await $.state.get(PAUSE)).value ?? null
403    const pauseOffUntil = (await $.state.get(OVERRIDE)).value ?? 0
404    const onboarding = (await $.state.get(ONBOARDING)).value ?? null
405    const now = await $.clock.now()
406    const { Box, Text } = $.ui.resolve(e)
407    const isNarrow = e.props.bodyColumns < 100
408    // The band is shared: keep what the mods after this one draw (Claude Code's own notes included) under these lines.
409    const theirs = await next(e)
410
411    // The first-run choice, above the usage line. It needs a text field, which the terminal and the desktop app have.
412    let choice = null
413    if (onboarding !== null && (e.surface === 'terminal' || e.surface === 'desktop')) {
414      const { Button, Input } = $.ui.resolve(e)
415      choice =
416        onboarding.step === 'choose' ? (
417          <Box flexDirection="row" columnGap={2}>
418            <Text>Plan usage: show your limits above the prompt</Text>
419            <Button key="show-always" label="Always" hotkey="1" plain onPress={() => chooseShowAt($, 0)} />
420            <Button key="show-50" label="From 50%" hotkey="2" plain onPress={() => chooseShowAt($, 50)} />
421            <Button key="show-80" label="From 80%" hotkey="3" plain onPress={() => chooseShowAt($, 80)} />
422            <Button key="show-other" label="Other" hotkey="4" plain onPress={() => askOtherPercent($)} />
423            {onboarding.note === undefined ? null : <Text>{onboarding.note}</Text>}
424          </Box>
425        ) : (
426          <Box flexDirection="row" columnGap={2}>
427            <Input
428              key="show-at"
429              label="Plan usage: show limits from what percent"
430              placeholder="0 to 100"
431              value=""
432              submitLabel="save"
433              autoFocus
434              onSubmit={(text: string) => submitOtherPercent($, text)}
435            />
436            {onboarding.note === undefined ? null : <Text>{onboarding.note}</Text>}
437          </Box>
438        )
439    }
440
441    let usage = null
442    if (current === null) {
443      if (showAt === 0) usage = <Text wrap="truncate-end">◌ Plan usage: no reading yet (it appears after the first reply)</Text>
444    } else {
445      const shown = BAND_KINDS.flatMap(kind => current.limits.filter(limit => limit.kind === kind && limit.percentUsed >= showAt))
446      const isStale = now - current.readAt > STALE_MS
447      const isOverridden = autoPause && pausing === null && now < pauseOffUntil
448      // Below show_at, with nothing paused or overridden, the usage line steps aside.
449      if (shown.length > 0 || pausing !== null || isOverridden) {
450        usage = (
451          <Box flexDirection="row" columnGap={3}>
452            {pausing === null ? null : (
453              <Text color={ORANGE} bold wrap="truncate-end">
454                ⏸ PAUSED at {pausing.percentUsed}% · continues {time.at(pausing.resumeAt, now)}
455              </Text>
456            )}
457            {shown.map(limit => {
458              const look = lookOf(limit.percentUsed, warnAt)
459              const at = resetsAtMs(limit)
460              const resets =
461                at === undefined
462                  ? ''
463                  : isNarrow
464                    ? ` · ${formatIn(at - now)}`
465                    : ` · resets ${time.at(at, now)} (${formatIn(at - now)})`
466              return (
467                <Box key={limit.kind} flexDirection="row" columnGap={1}>
468                  <Text color={look.color} bold>
469                    {look.icon} {look.word}
470                  </Text>
471                  <Text wrap="truncate-end">
472                    {labelOf(limit.kind)} {limit.percentUsed}%{resets}
473                  </Text>
474                </Box>
475              )
476            })}
477            {isOverridden ? <Text wrap="truncate-end">○ pause off until {time.at(pauseOffUntil, now)}</Text> : null}
478            {isStale && shown.length > 0 ? <Text wrap="truncate-end">as of {time.at(current.readAt, now)}</Text> : null}
479          </Box>
480        )
481      }
482    }
483
484    if (choice === null && usage === null) return theirs
485    return (
486      <Box flexDirection="column">
487        {choice}
488        {usage}
489        {theirs}
490      </Box>
491    )
492  })
493}
494
hooks/usage.ts 151 lines
1// Pure helpers: no `$`, so tests call them directly.
2import type { PlanUsageLimit } from '../types'
3
4export const RESUME_DELAY_MS = 2 * 60_000
5
6// Blue below the warning level, orange at or above it. The icon and the word carry the level too,
7// so it never rests on color alone (red/green colorblind readers included).
8export const BLUE = '#4ea1ff'
9export const ORANGE = '#ff9f1a'
10
11const LABELS: Record<string, string> = {
12  five_hour: 'Session',
13  seven_day: 'Weekly',
14  seven_day_opus: 'Weekly Opus',
15  seven_day_sonnet: 'Weekly Sonnet',
16  spend_limit: 'Spend',
17}
18
19export type Level = 'ok' | 'high' | 'limit'
20
21export interface LevelLook {
22  icon: string
23  word: string
24  color: string
25}
26
27const LOOKS: Record<Level, LevelLook> = {
28  ok: { icon: '●', word: 'OK', color: BLUE },
29  high: { icon: '▲', word: 'HIGH', color: ORANGE },
30  limit: { icon: '■', word: 'LIMIT', color: ORANGE },
31}
32
33export function labelOf(kind: string): string {
34  return LABELS[kind] ?? kind
35}
36
37export function levelOf(percentUsed: number, warnAt: number): Level {
38  if (percentUsed >= 100) return 'limit'
39  return percentUsed >= warnAt ? 'high' : 'ok'
40}
41
42export function lookOf(percentUsed: number, warnAt: number): LevelLook {
43  return LOOKS[levelOf(percentUsed, warnAt)]
44}
45
46// The zone to show times in: the configured IANA name when it is valid, else this computer's.
47export function resolveZone(configured: string): { zone: string; isFallback: boolean } {
48  const system = new Intl.DateTimeFormat().resolvedOptions().timeZone
49  if (configured.trim() === '') return { zone: system, isFallback: false }
50  try {
51    new Intl.DateTimeFormat('en-US', { timeZone: configured })
52    return { zone: configured, isFallback: false }
53  } catch {
54    return { zone: system, isFallback: true }
55  }
56}
57
58export interface TimeFormat {
59  zone: string
60  // "4:10 AM CT" today, "Thu 9:00 PM CT" within six days, "Oct 12 9:00 PM CT" beyond.
61  at: (time: number, now: number) => string
62}
63
64export function timeFormat(zone: string): TimeFormat {
65  const clock = new Intl.DateTimeFormat('en-US', { timeZone: zone, hour: 'numeric', minute: '2-digit' })
66  const weekday = new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short' })
67  const monthDay = new Intl.DateTimeFormat('en-US', { timeZone: zone, month: 'short', day: 'numeric' })
68  const calendarDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' })
69  return {
70    zone,
71    at: (time, now) => {
72      const label = `${clock.format(time)} ${zoneLabel(zone, time)}`
73      if (calendarDay.format(time) === calendarDay.format(now)) return label
74      return Math.abs(time - now) / 86_400_000 < 6 ? `${weekday.format(time)} ${label}` : `${monthDay.format(time)} ${label}`
75    },
76  }
77}
78
79// "CT" or "ET" where the zone has a short generic name; "GMT+1" style where it does not.
80function zoneLabel(zone: string, time: number): string {
81  const part = (style: 'shortGeneric' | 'short'): string =>
82    new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: style })
83      .formatToParts(time)
84      .find(p => p.type === 'timeZoneName')?.value ?? ''
85  const generic = part('shortGeneric')
86  return /^[A-Z]{2,4}$/.test(generic) ? generic : part('short')
87}
88
89// "1h 12m", "45m", "2d 3h"; "now" once due.
90export function formatIn(ms: number): string {
91  const minutes = Math.round(ms / 60_000)
92  if (minutes <= 0) return 'now'
93  if (minutes < 60) return `${minutes}m`
94  const hours = Math.floor(minutes / 60)
95  if (hours < 24) return `${hours}h ${minutes % 60}m`
96  return `${Math.floor(hours / 24)}d ${hours % 24}h`
97}
98
99export function resetsAtMs(limit: PlanUsageLimit): number | undefined {
100  if (limit.resetsAt === undefined) return undefined
101  const at = Date.parse(limit.resetsAt)
102  return Number.isNaN(at) ? undefined : at
103}
104
105function isRecord(value: unknown): value is Record<string, unknown> {
106  return typeof value === 'object' && value !== null && !Array.isArray(value)
107}
108
109function isLimit(value: unknown): value is PlanUsageLimit {
110  return (
111    isRecord(value) &&
112    typeof value.kind === 'string' &&
113    typeof value.percentUsed === 'number' &&
114    (value.resetsAt === undefined || typeof value.resetsAt === 'string')
115  )
116}
117
118// A reading another session stored; $.store is shared and outlives versions of this mod, so check its shape.
119export function isReading(value: unknown): value is { limits: PlanUsageLimit[]; readAt: number } {
120  return isRecord(value) && Array.isArray(value.limits) && value.limits.every(isLimit) && typeof value.readAt === 'number'
121}
122
123// The session window at or past the threshold.
124export function pauseTrigger(limits: readonly PlanUsageLimit[], threshold: number): PlanUsageLimit | undefined {
125  return limits.find(limit => limit.kind === 'five_hour' && limit.percentUsed >= threshold)
126}
127
128export interface DescribedLimit extends PlanUsageLimit {
129  label: string
130  level: Level
131  resetsAtLocal?: string
132  resetsIn?: string
133}
134
135export function describeLimits(
136  limits: readonly PlanUsageLimit[],
137  now: number,
138  time: TimeFormat,
139  warnAt: number,
140): DescribedLimit[] {
141  return limits.map(limit => {
142    const at = resetsAtMs(limit)
143    const described: DescribedLimit = { ...limit, label: labelOf(limit.kind), level: levelOf(limit.percentUsed, warnAt) }
144    if (at !== undefined) {
145      described.resetsAtLocal = time.at(at, now)
146      described.resetsIn = formatIn(at - now)
147    }
148    return described
149  })
150}
151
types/index.d.ts 40 lines
1// One plan-limit window as Claude Code reports it: `five_hour` (the session limit), `seven_day` (weekly),
2// the per-model weekly windows, or a gateway's `spend_limit`.
3export interface PlanUsageLimit {
4  kind: string
5  percentUsed: number
6  resetsAt?: string
7}
8
9// The newest figures, from the last model reply this session or another session on the same login saw.
10export interface PlanUsageReading {
11  limits: PlanUsageLimit[]
12  readAt: number
13}
14
15// An auto-pause in progress: `pending` waits for running tool calls, `waiting` waits for the reset.
16export interface PlanUsagePause {
17  phase: 'pending' | 'waiting'
18  percentUsed: number
19  resumeAt: number
20}
21
22// The first-run line that sets show_at; `note` explains a rejected entry.
23export interface PlanUsageOnboarding {
24  step: 'choose' | 'other'
25  note?: string
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'plan-usage': {
31      reading: PlanUsageReading | null
32      pause: PlanUsagePause | null
33      // When a person cancelled a pause: no pausing again before this time (ms since the epoch); 0 when none.
34      overrideUntil: number
35      // The first-run choice of show_at: 'choose' offers the presets, 'other' asks for a percent.
36      onboarding: PlanUsageOnboarding | null
37    }
38  }
39}
40