SLOPSHOPPER

rate-limits

Writes the account's rate-limit windows (five_hour, seven_day) to ~/.claude/local/rate-limits.json on every measurement, for the weekly readout fleet-launch.sh…

new
★ 1v0.1.0MITupdated 2026-10-13alienfast/claude/mods/rate-limits
A shopper browsing a rack in a slop shop
README

rate-limits

A hooks-only mod that writes the account's rate-limit windows to ~/.claude/local/rate-limits.json on every measurement, so scripts/fleet-launch.sh can print the active account's burst (five_hour) and weekly (seven_day) readings before it dispatches, and scripts/fleet-headroom.sh can park a fleet session on the same meter. Nothing at launch reported weekly use before it: the 2026-10-10 fleet (3 sessions, 12h) was launched on an account 5.75h from its weekly limit and lost 18.75 of its 36 session-hours.

What it writes

session.measure fires after each main-thread turn and whenever a window moves a whole point. When its rateLimits carries a reading the whole file is rewritten — last writer wins, so the file names whichever account the latest session on this machine ran on, and measured_at dates it. A measurement with no windows (off a subscription, or before the first response) writes nothing, so an empty reading never overwrites a dated one.

{
  "measured_at": "2026-10-10T20:15:03.000Z",
  "session": "8c16860d-d02b-4364-af52-2d5a27d9dd9c",
  "windows": [
    { "kind": "five_hour", "percentUsed": 12.5, "resetsAt": "2026-10-11T00:00:00.000Z" },
    { "kind": "seven_day", "percentUsed": 71, "resetsAt": "2026-10-13T22:00:00.000Z" }
  ]
}

~/.claude/local/ is the directory ~/.claude/CLAUDE.md reserves for machine-local state kept across sessions; the write creates it. The home directory comes from HOME, or USERPROFILE where only that is set.

Each session also keeps a history, ~/.claude/local/rate-limits/<session-id>.jsonl, one line per measurement in which a window moved ({"measured_at", "windows"}, the same windows as above). The snapshot cannot say how full the 5-hour window got while a fleet ran — the 2026-10-12 retro had only a reading from two days earlier to estimate headroom from — and one file per session means concurrent sessions never race a shared file. scripts/fleet-metrics.py reads a fleet's files for each session's peak five_hour reading, and scripts/fleet-headroom.sh --session <id> reads a session's last line as its account's current 5h reading at pick time (its own account by construction), falling back to the snapshot. A reload resumes the session's file rather than starting it over. Nothing prunes these files (the mod API has no delete); each holds at most a few hundred short lines.

Measured, 2026-10-10

A headless claude -p --plugin-dir ~/.claude/mods/rate-limits --model haiku run on a subscription account wrote the file on its one turn, carrying both windows: five_hour at 28% resetting in 3.5h and seven_day at 7% resetting Oct 13 22:00Z, the same reset the morning's weekly-limit message had named. The file did not exist before the run. Not yet measured: that the reading follows an account switch — the next session on the other account rewrites the file, and measured_at should move with it.

Configuration

userConfig: enabled (kill switch).

Loading

Installed user-wide by update.sh from the repo's marketplace and loaded in place from this folder. fleet-launch.sh never requires it: without a reading it prints Weekly: no reading (<why>) and launches.

Checks

claude plugin validate mods/rate-limits && claude plugin test mods/rate-limits && mods/typecheck.sh mods/rate-limits
Source 2 files
hooks/register.ts 53 lines
1import type { Register } from 'claude-code'
2import { historyLine, historyPath, readOptions, readingOf, readingPath } from './limits'
3
4let sessionId = ''
5let path: string | null = null
6let historyFile: string | null = null
7let history: string[] = []
8
9export const register: Register = (on, options) => {
10  const opts = readOptions(options)
11
12  on('session.start', async ($, e, next) => {
13    sessionId = await $.session.id()
14    const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
15    path = readingPath(home)
16    historyFile = historyPath(home, sessionId)
17    // A reload starts the module over; the session's file is where its earlier readings live.
18    history = []
19    if (historyFile !== null && (await $.fs.exists(historyFile))) {
20      history = (await $.fs.read(historyFile)).split('\n').filter((line) => line !== '')
21    }
22    return next(e)
23  })
24
25  // Fires after each main-thread turn and whenever a window moves a whole point. The whole file is rewritten each time, last writer
26  // wins, so it names whichever account the latest session on this machine ran on and `measured_at` dates it. The session's history
27  // gains a line only when a window moved. A failed write is dropped: a readout never touches the session, and the launch line says
28  // when the file is stale or missing.
29  on('session.measure', async ($, e, next) => {
30    if (opts.enabled && path !== null) {
31      const now = await $.clock.now()
32      const text = readingOf(e.rateLimits, sessionId, now)
33      if (text !== null) {
34        try {
35          await $.fs.write(path, text)
36        } catch {
37          // see above
38        }
39      }
40      const line = e.changed.includes('rateLimits') ? historyLine(e.rateLimits, now) : null
41      if (line !== null && historyFile !== null) {
42        history.push(line)
43        try {
44          await $.fs.write(historyFile, `${history.join('\n')}\n`)
45        } catch {
46          // see above
47        }
48      }
49    }
50    return next(e)
51  })
52}
53
hooks/limits.ts 38 lines
1export type RateLimit = { readonly kind: string; readonly percentUsed: number; readonly resetsAt?: string }
2export type Options = { readonly enabled: boolean }
3
4export function readOptions(raw: Readonly<Record<string, unknown>>): Options {
5  return { enabled: raw.enabled !== false }
6}
7
8// One machine-wide file under ~/.claude/local/, the directory ~/.claude/CLAUDE.md reserves for state kept across sessions, read by
9// scripts/fleet-launch.sh. Null without a home directory, and then nothing is written.
10export function readingPath(home: string | undefined): string | null {
11  const h = (home ?? '').trim().replace(/[\\/]+$/, '')
12  return h === '' ? null : `${h}/.claude/local/rate-limits.json`
13}
14
15function windowsOf(windows: readonly RateLimit[]) {
16  return windows.map((w) => (w.resetsAt === undefined ? { kind: w.kind, percentUsed: w.percentUsed } : { kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
17}
18
19// The file's text: the windows as the engine reported them, dated; null when the measurement carried none (off a subscription, or
20// before the first response), so an empty reading never overwrites a dated one.
21export function readingOf(windows: readonly RateLimit[], session: string, nowMs: number): string | null {
22  if (windows.length === 0) return null
23  return `${JSON.stringify({ measured_at: new Date(nowMs).toISOString(), session, windows: windowsOf(windows) }, null, 2)}\n`
24}
25
26// The snapshot above keeps only the latest writer, so it cannot say how full a window got while a fleet ran. Each session also keeps
27// its own history, one file per session so concurrent sessions never race a shared one; fleet-metrics.py reads a fleet's files for
28// the peak five_hour reading. Null without a home directory or a session id.
29export function historyPath(home: string | undefined, session: string): string | null {
30  const h = (home ?? '').trim().replace(/[\\/]+$/, '')
31  return h === '' || session === '' ? null : `${h}/.claude/local/rate-limits/${session}.jsonl`
32}
33
34export function historyLine(windows: readonly RateLimit[], nowMs: number): string | null {
35  if (windows.length === 0) return null
36  return JSON.stringify({ measured_at: new Date(nowMs).toISOString(), windows: windowsOf(windows) })
37}
38