A machine-wide meter of the plan's rolling 5-hour usage: every session's turns summed as effective tokens, the cap learned from each session-limit hit, the…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, vm_stat, ioreg, ps).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them — and a tool so Claude doesn't have to run lsof to find out what's listening.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfileclaude CLI, which also listen on localhost from the project's folder) are never listed as servers nor counted as other listeners.~/.claude/dev-servers/<project>/.dev_servers tool: this project's running servers (name, port, pid, uptime, and the log when it was started from the pane), the start commands that aren't running, and with all: true every other listener on the machine (port, process), ending with how many Claude Code sessions it left out, e.g. (7 Claude Code sessions not listed), so nothing is hidden silently. Its description tells Claude to use it instead of lsof/netstat/ps. It never starts or stops anything.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.netstat -anv -p tcp (macOS) or ss -ltnp (Linux), never a bare lsof; ps and lsof -d cwd run once per new pid, one pid at a time. One open session scans per interval (15s by default) and writes ~/.claude/dev-servers/scan-cache.json for the others; a slow scan doubles the interval, up to 5 minutes.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
~/.claude/usage-meter/summary.json, at most every 30 s) and, once pct reaches budgetCapAt (80%), caps every turn at budgetCapEffort (high), deep asks included, so you stop hitting the 5-hour wall. A turn already at or below the cap is left alone; lowering over a warm cache waits like any other lowering and never happens mid-turn. A missing or stale summary (over 10 minutes old), or one with no percentage yet (no fresh API reading and no limit learned), is no budget information. The debug log gets one line when the cap engages and one when it releases. budgetCapAt: 0 turns it off.subagentEffort (empty by default) to run every subagent request at that effort; the budget cap applies to them too (the lower of the two). Each subagent turn's effort is decided at its first request and kept for the turn.avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision, the session's counts, the budget state (percent used, the cap, whether it is engaged) and the subagent effort. /route off and /route on toggle it all; /route deep and /route routine force the next turn.effort: low (routine); while the budget cap lowers it: effort: high (budget 84%).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60), budgetCapAt (80; 0 = off), budgetCapEffort (high), subagentEffort (empty = the session's own).
A Jobs pane for long-running work: training runs, downloads, extractions — and three tools so Claude follows them instead of polling.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled. A timer Claude sets for itself — sleep 600 && echo done, a loop of sleeps, or a task described as a wait, timer, pause or delay that only polls and reports with read-only programs (rg, tail, pgrep…) — is still listed by jobs_list and shown as a dim timer row, but never toasts, never counts in the status line, does not open the pane, is judged by its exit marker alone (no ps), and leaves a minute after it ends. Anything that runs a real program is a job, however it is described./jobs opens the pane, /unwatch <label|id|done|all> removes jobs.jobs_list (every watched job's state, pid, progress, ETA, last-output age, exit code and log path), jobs_watch ({ pid?, log?, name?, quietMinutes? }: follow a process or log started elsewhere; it never starts commands) and jobs_unwatch ({ name }). The first message of each conversation says jobs are watched and names them, so Claude stops writing until … sleep loops and running ps. They need no permission prompt: they only read the mod's own list.tail, checks processes with ps (one listing, or a per-pid lookup when only pids are watched), and runs df. A probe that times out or takes over 2 s is retried at a doubling interval, up to 5 minutes, until it is fast again. The debug log names a probe's pace only once it has held for two probes in a row, and at most once per five minutes per probe, so a pace that flips with the Mac's load leaves no trail.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min; a timer leaves after a minute), auto-open (on), which disks to show.
Memory, swap and GPU on the status line, a machine_status tool for Claude, and a guard that refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%, and CPU 4/16 cores whenever macOS has parked the performance cores. "Free" is unused memory (vm_stat's free + inactive + speculative pages): what a new job can take without pushing anyone out. macOS's own "memory free percentage" counts active pages too, which is how a Mac can be "55% free" with the swap nearly full.sysctl calls and vm_stat), every 15 s by default; the GPU (ioreg) on every third sample, never right after a slow one. One session samples and writes ~/.claude/machine-guard/sample.json (with a lock.json while it probes); every other session shows the same numbers instead of probing too. When probes are slow or time out (10–40 s while the Mac swaps) on two samples in a row, the interval doubles, and doubles again with each further slow sample, up to 2 min; a single slow sample is a blip and changes nothing. The configured pace returns once two samples in a row are fast again. The other sessions follow the sampler's pace, so a blip stays quiet in every session.kern.sched_recommended_cores, the bitmask of CPUs the scheduler may use. When macOS's performance controller parks the performance cores (after a thermal event, or stuck until a sleep/wake or reboot), the status line shows CPU 4/16 cores, machine_status and /guard report it with the parked count, you get one toast once at least half the cores have been parked for a minute, or any parking has lasted five minutes (masks blip for seconds under GPU load), and one when they come back, and Claude gets a note when it starts heavy work so it keeps worker counts small. The guard never blocks on it.ps, the slowest probe) is taken only on demand (/guard, machine_status) or when a sample shows pressure, at most once a minute; "biggest" means the apps from a table taken in the last 5 minutes./busy.When memory is only tight (under 20% free, or kernel level 2), the job runs and Claude gets a note to start one heavy job at a time.
machine_status is a tool Claude can call (no input, allowed without asking): the sample's age, RAM free, swap, GPU, the pressure level, the biggest apps (or how to get a table), the guard's verdict and its rules. Claude is told to use it instead of ps, top, vm_stat, memory_pressure or project scripts before launching heavy work./busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees (and takes a process table when one is due). /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast.rg, grep, pgrep or pkill exiting 1 with nothing on stderr (no match, no such process), are logged as expected (proc-expected, with why), not as failures; an exit of 2 or more, or anything on stderr, still counts. It also records model calls (the only usage the mods cost: /second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits — in the projects that use Modal, with one CLI run per minute for the whole machine.
.py file with import modal / from modal import … (one rg run, 5 s at most), or modal named in pyproject.toml, requirements*.txt, uv.lock or poetry.lock. The answer is remembered per project for a day. Running /modal, or any modal … command in Bash, switches it on for the session; alwaysPoll (off) watches every project.~/.claude/modal-meter/lock.json runs modal app list --json every pollSeconds (60) and writes cache.json; the other open sessions read the cache and refresh their own status line and pane from it. Once one session learns the CLI has no billing report, none tries it again for a day.app list over 2 s or a timeout doubles the interval (up to 10 minutes); a fast answer brings it back.Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, never retries one that isn't there, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗, for every watched PR.gh pr create) or you added with /prs watch, and for any PR whose branch this worktree has checked out. PRs adopted from the repository at start stay on the status line, in /prs and in prs_status, and still get the merge ritual, but their CI results don't toast — so with seven worktree sessions of one repo a result toasts once, where it matters, not seven times. toastAdoptedCi (off) brings back toasts for every watched PR.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.git worktree add, such as Claude Code's .claude/worktrees/<name> sessions) it only fetches: it never switches or pulls there, because the default branch is checked out in the primary worktree and the switch could only fail ('main' is already used by worktree …). When the merged branch is the one the worktree has checked out, the toast says #N merged → this worktree's branch; remove the worktree when you're done (git worktree remove <path>) and the Tab suggestion tells Claude the branch and worktree stay until you remove them. In the primary worktree the ritual is unchanged.gh pr checks and the tail of the failed log attached, so you don't paste it.prs_status (number, title, state, branch, CI, URL, and how old the data is), instead of running gh pr view or gh pr checks itself: no API call, and it refreshes first when the data is older than a poll.Resource not accessible by personal access token), it tells you once with the fix (gh auth login --web, or grant the token "Checks: read"), keeps polling the PRs without their checks so merges are still handled, shows them as #219 CI?, and tries the checks again after 30 minutes or when you run /prs. If gh can't read PRs at all (not logged in), the status line says PRs: gh can't read them and nothing is polled until then. A GitHub rate limit pauses polling for 5 minutes; any other failure doubles the wait up to 10 minutes, back to normal on the next success. Nothing retries every minute forever; the debug log gets one line per change./prs lists watched PRs and asks gh again at once. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)authRetryMinutes (30): how long before the checks are tried again after gh refused them over auth.attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.toastAdoptedCi (off): toast the CI results of PRs adopted from the repository too, as every PR's used to; with several worktree sessions of one repo, each would toast.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./goal (or the Stop hook's condition), open tasks with a count of the done ones, the questions you asked with the first line of each answer, the files touched, the commits made and PRs opened. After a compaction it goes back into Claude's context as Ledger (this session, before compaction) (about 40 lines at most), so you don't have to say "don't lose track of where we were". /recall ledger prints it any time; /clear starts a fresh one. It is written to ~/.claude/recall/ledger/<session>.json (readable only by you, secrets masked) so a reload keeps it./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall ledger prints this session's ledger (above)./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo. The ledgers beside it are kept the same way.~/.zshrc, ~/.zprofile, ~/.bashrc and `~/.bash_profilehooks/register.ts 389 lines1import type { EngineInterface, Register, TurnUsage } from 'claude-code'
2
3import type { Hit, Summary, TurnLine, UsageAlerts, UsageSettings } from '../types'
4import {
5 HOUR_MS,
6 KEPT_HITS,
7 buildSummary,
8 describeUsage,
9 hitMessage,
10 isSameSummary,
11 localZone,
12 nextClockTime,
13 parseHits,
14 parseLimit,
15 parseTurns,
16 pruneTurns,
17 shareOf,
18 statusLine,
19 sumWindow,
20 turnLine,
21 warnMessage,
22 withHit,
23} from './meter'
24import type { Window } from './meter'
25
26type Engine = EngineInterface
27
28const ALERTS = { plugin: 'usage-meter', key: 'alerts' } as const
29const FRESH_ALERTS: UsageAlerts = { isWarned: false, toastedResets: [] }
30const TOOL = 'mcp__usage-meter__usage_status'
31const COMMAND = 'meter'
32/** How often this session's turns file is rewritten without the lines older than the window. */
33const PRUNE_EVERY_MS = HOUR_MS
34/** How many reset times this session remembers having toasted. */
35const KEPT_RESETS = 20
36/** The API's limit error as its own row of the conversation reads, and nothing but it. */
37const LIMIT_ROW = /^\s*You(?:'|’)ve hit your (?:session|usage) limit\b/i
38const LIMIT_ROW_MAX = 240
39
40const DEFAULTS: UsageSettings = { windowHours: 5, refreshSeconds: 30, warnAt: 80 }
41
42const TOOL_DESCRIPTION =
43 "The plan's rolling five-hour usage across every Claude Code session on this Mac: effective tokens in the window, " +
44 "the share of the limit used (the API's own reading when fresh, else the estimate against the last limit hit), when the window resets, " +
45 'each session’s share and the limit hits seen. ' +
46 'Check it before fanning out subagents or starting optional work when the budget is tight; it reads the shared files and makes no model call.'
47
48const COMMAND_DESCRIPTION =
49 "The plan's five-hour usage across every session: effective tokens, the share of the limit, the reset, each session's share, the limit hits, the weights"
50
51type Paths = { turns: string; hits: string; summary: string }
52
53/**
54 * One activation's memory, made in `register` and handed to every function
55 * beside `$`: a reload starts it over, while the files and `$.state` carry on.
56 */
57type Memory = {
58 settings: UsageSettings
59 windowMs: number
60 refreshMs: number
61 /** The zone this process runs in, for the clock times shown. */
62 zone: string | null
63 /** Where the files are; undefined until HOME is read, null when it is unset. */
64 paths: Paths | null | undefined
65 sessionId: string | null
66 /** This session's turns as its file holds them; null until read back. */
67 own: TurnLine[] | null
68 prunedAt: number
69 ticker: { cancel: () => void } | null
70 shownStatus: string | undefined
71 lastSummary: Summary | null
72 /** Serializes the writes of this session's file: subagents end turns side by side. */
73 writing: Promise<void>
74 refreshing: Promise<void> | null
75}
76
77/** A number option: finite and at least `least`, else the default. */
78const numberOption = (value: unknown, fallback: number, least: number): number =>
79 typeof value === 'number' && Number.isFinite(value) && value >= least ? value : fallback
80
81/** One turn's line as the API counted it, a subagent's flagged. */
82const turnOf = (usage: TurnUsage, isSub: boolean, now: number): TurnLine => ({
83 ts: now,
84 in: usage.input_tokens,
85 cr: usage.cache_read_input_tokens,
86 cw: usage.cache_creation_input_tokens,
87 out: usage.output_tokens,
88 sub: isSub,
89})
90
91/** One reading of the window: what the status line, the summary and the warning are drawn from. */
92type Figures = { summary: Summary; window: Window; now: number }
93
94async function debug($: Engine, line: string) {
95 try {
96 await $.ui.log(`usage-meter: ${line}`, { to: 'debug' })
97 } catch {
98 // The debug log is best effort.
99 }
100}
101
102async function where($: Engine, m: Memory): Promise<Paths | null> {
103 if (m.paths === undefined) {
104 const home = await $.env.get('HOME')
105 if (home) {
106 const dir = `${home}/.claude/usage-meter`
107 m.paths = { turns: `${dir}/turns`, hits: `${dir}/hits.json`, summary: `${dir}/summary.json` }
108 } else {
109 m.paths = null
110 await debug($, 'HOME is not set: nothing is counted')
111 }
112 }
113 return m.paths
114}
115
116async function whoAmI($: Engine, m: Memory): Promise<string> {
117 m.sessionId ??= await $.session.id().catch(() => `session-${Math.random().toString(36).slice(2, 10)}`)
118 return m.sessionId
119}
120
121const readText = ($: Engine, path: string): Promise<string | null> => $.fs.read(path).catch(() => null)
122
123/** Every session's turns inside the window: a file idle for longer than the window holds none and is not read. */
124async function readWindow($: Engine, m: Memory, p: Paths, now: number): Promise<Window> {
125 const entries = await $.fs.list(p.turns).catch(() => [])
126 const since = now - m.windowMs
127 const files = await Promise.all(
128 entries
129 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl') && (entry.mtimeMs === 0 || entry.mtimeMs > since))
130 .map(async entry => ({ id: entry.name.slice(0, -'.jsonl'.length), turns: parseTurns((await readText($, `${p.turns}/${entry.name}`)) ?? '') })),
131 )
132 return sumWindow(files, now, m.windowMs)
133}
134
135const readHits = async ($: Engine, p: Paths): Promise<Hit[]> => parseHits(await readText($, p.hits))
136
137function showStatus($: Engine, m: Memory, line: string | undefined) {
138 if (line !== m.shownStatus) {
139 m.shownStatus = line
140 $.ui.status(line)
141 }
142}
143
144async function alerts($: Engine): Promise<UsageAlerts> {
145 const { value } = await $.state.get(ALERTS)
146 return value ?? FRESH_ALERTS
147}
148
149/** One toast when the share first crosses the threshold; armed again once it is back below. */
150async function warn($: Engine, m: Memory, { summary, window, now }: Figures) {
151 const share = shareOf(summary, window.api, now)
152 if (share === null) {
153 return
154 }
155 const known = await alerts($)
156 if (share.pct >= m.settings.warnAt) {
157 if (!known.isWarned) {
158 await $.state.set(ALERTS, { ...known, isWarned: true })
159 $.ui.toast(warnMessage(share, summary, window.api, now, m.windowMs, m.zone), { timeoutMs: 12_000 })
160 }
161 } else if (known.isWarned) {
162 await $.state.set(ALERTS, { ...known, isWarned: false })
163 }
164}
165
166/** Sums the window again: summary.json when the figures changed, the status line, the warning. */
167async function doRefresh($: Engine, m: Memory) {
168 const p = await where($, m)
169 if (p === null) {
170 return
171 }
172 const now = await $.clock.now()
173 const window = await readWindow($, m, p, now)
174 const hits = await readHits($, p)
175 const summary = buildSummary(window, hits, now, m.windowMs)
176 if (!isSameSummary(m.lastSummary, summary)) {
177 m.lastSummary = summary
178 await $.fs.write(p.summary, `${JSON.stringify(summary)}\n`)
179 }
180 showStatus($, m, statusLine(summary, window.api, now, m.windowMs, m.zone))
181 await warn($, m, { summary, window, now })
182}
183
184/** One refresh at a time: a caller while one runs gets that one. */
185function refresh($: Engine, m: Memory): Promise<void> {
186 m.refreshing ??= doRefresh($, m)
187 .catch(error => debug($, `could not sum the window: ${String(error)}`))
188 .finally(() => {
189 m.refreshing = null
190 })
191 return m.refreshing
192}
193
194function ensureTicker($: Engine, m: Memory) {
195 if (m.paths && !m.ticker) {
196 m.ticker = $.clock.every(m.refreshMs, () => void refresh($, m))
197 }
198}
199
200function stopTicker(m: Memory) {
201 m.ticker?.cancel()
202 m.ticker = null
203}
204
205/** The API's reading of its own five-hour window, when the last response carried one. */
206async function apiReading($: Engine): Promise<{ u: number; r?: number } | null> {
207 try {
208 const usage = await $.session.usage()
209 const five = usage.rateLimits.find(limit => limit.kind === 'five_hour')
210 if (!five || !Number.isFinite(five.percentUsed)) {
211 return null
212 }
213 const resetsAt = five.resetsAt === undefined ? NaN : Date.parse(five.resetsAt)
214 return Number.isFinite(resetsAt) ? { u: five.percentUsed, r: resetsAt } : { u: five.percentUsed }
215 } catch {
216 return null
217 }
218}
219
220/** Adds one turn to this session's file: read back once after a load, written whole, pruned about hourly. */
221async function record($: Engine, m: Memory, turn: TurnLine) {
222 const p = await where($, m)
223 if (p === null) {
224 return
225 }
226 const api = await apiReading($)
227 if (api !== null) {
228 turn.u = api.u
229 if (api.r !== undefined) {
230 turn.r = api.r
231 }
232 }
233 const job = m.writing.then(async () => {
234 const path = `${p.turns}/${await whoAmI($, m)}.jsonl`
235 const loaded = m.own ?? parseTurns((await readText($, path)) ?? '')
236 loaded.push(turn)
237 const isPruneDue = turn.ts - m.prunedAt >= PRUNE_EVERY_MS
238 const kept = isPruneDue ? pruneTurns(loaded, turn.ts - m.windowMs) : loaded
239 if (isPruneDue) {
240 m.prunedAt = turn.ts
241 }
242 m.own = kept
243 await $.fs.write(path, `${kept.map(turnLine).join('\n')}\n`)
244 })
245 m.writing = job.catch(() => undefined)
246 await job
247}
248
249/** A session-limit error: one hit per reset time, counted machine-wide once, told to this session once. */
250async function onLimit($: Engine, m: Memory, text: string, now: number) {
251 const parsed = parseLimit(text)
252 if (parsed === null) {
253 return
254 }
255 const p = await where($, m)
256 if (p === null) {
257 return
258 }
259 const resetAt = nextClockTime(now, parsed.hour, parsed.minute, parsed.zone)
260 const known = await alerts($)
261 if (known.toastedResets.includes(resetAt)) {
262 return
263 }
264 // The hit is this window's warning: no warn toast beside it.
265 await $.state.set(ALERTS, { isWarned: true, toastedResets: [...known.toastedResets, resetAt].slice(-KEPT_RESETS) })
266 const window = await readWindow($, m, p, now)
267 const hit: Hit = { at: now, resetAt, effectiveAtHit: window.effective, zone: parsed.zone }
268 const { hits, isNew } = withHit(await readHits($, p), hit, KEPT_HITS)
269 if (isNew) {
270 await $.fs.write(p.hits, `${JSON.stringify(hits)}\n`)
271 }
272 $.ui.toast(hitMessage(resetAt, window.effective, m.windowMs, parsed.zone ?? m.zone), { timeoutMs: 15_000 })
273 await refresh($, m)
274}
275
276/** What /meter and the usage_status tool print. */
277async function describe($: Engine, m: Memory): Promise<string> {
278 const p = await where($, m)
279 if (p === null) {
280 return 'usage-meter: HOME is not set, so there is nowhere to keep the turns.'
281 }
282 const now = await $.clock.now()
283 const window = await readWindow($, m, p, now)
284 const hits = await readHits($, p)
285 return describeUsage(window, buildSummary(window, hits, now, m.windowMs), hits, m.settings, now, await whoAmI($, m), m.zone)
286}
287
288export const register: Register = (on, options) => {
289 const settings: UsageSettings = {
290 windowHours: numberOption(options.windowHours, DEFAULTS.windowHours, 0.1),
291 refreshSeconds: numberOption(options.refreshSeconds, DEFAULTS.refreshSeconds, 5),
292 warnAt: numberOption(options.warnAt, DEFAULTS.warnAt, 1),
293 }
294 const m: Memory = {
295 settings,
296 windowMs: Math.round(settings.windowHours * HOUR_MS),
297 refreshMs: Math.round(settings.refreshSeconds * 1000),
298 zone: localZone(),
299 paths: undefined,
300 sessionId: null,
301 own: null,
302 prunedAt: -Infinity,
303 ticker: null,
304 shownStatus: undefined,
305 lastSummary: null,
306 writing: Promise.resolve(),
307 refreshing: null,
308 }
309
310 on('session.start', async ($, e, next) => {
311 await $.command
312 .register({ name: COMMAND, description: COMMAND_DESCRIPTION, immediate: true })
313 .catch(error => debug($, `the /${COMMAND} command did not register: ${String(error)}`))
314 await $.tool
315 .register({ name: 'usage_status', description: TOOL_DESCRIPTION, inputSchema: { type: 'object', properties: {} }, isDeferred: false })
316 .catch(error => debug($, `the usage_status tool did not register: ${String(error)}`))
317 await refresh($, m)
318 ensureTicker($, m)
319 return next(e)
320 })
321
322 // Every turn's cost, a subagent's too; a turn that died on the limit is a hit.
323 on('turn.complete', async ($, e, next) => {
324 const done = await next(e)
325 try {
326 const now = await $.clock.now()
327 if (e.usage !== undefined) {
328 await record($, m, turnOf(e.usage, e.agentId !== undefined, now))
329 }
330 if (e.reason === 'error') {
331 await onLimit($, m, e.answer, now)
332 }
333 ensureTicker($, m)
334 await refresh($, m)
335 } catch (error) {
336 await debug($, `could not count the turn: ${String(error)}`)
337 }
338 return done
339 })
340
341 // The API's limit error is stored as an assistant row holding nothing but its message.
342 on('session.append', async ($, e, next) => {
343 const kept = await next(e)
344 try {
345 const block = e.message.content[0]
346 const text = e.message.type === 'assistant' && e.message.content.length === 1 && block?.type === 'text' ? block.text : undefined
347 if (typeof text === 'string' && text.length <= LIMIT_ROW_MAX && LIMIT_ROW.test(text)) {
348 await onLimit($, m, text, await $.clock.now())
349 }
350 } catch (error) {
351 await debug($, `could not read the row: ${String(error)}`)
352 }
353 return kept
354 })
355
356 // The same error as the classic hook classifies it.
357 on('classic.StopFailure', async ($, e, next) => {
358 try {
359 if (e.error === 'rate_limit') {
360 await onLimit($, m, e.error_details ?? e.last_assistant_message ?? '', await $.clock.now())
361 }
362 } catch (error) {
363 await debug($, `could not read the failure: ${String(error)}`)
364 }
365 return next(e)
366 })
367
368 on('tool.check', { tool: TOOL }, async ($, e, next) => {
369 const verdict = await next(e)
370 // The tool only reads the shared files: it never needs to ask, though a deny in settings stands.
371 return verdict.decision === 'deny' ? verdict : { decision: 'allow', reason: 'usage-meter: reads the shared usage files' }
372 })
373
374 on('tool.call', { tool: TOOL }, async $ => ({ result: await describe($, m) }))
375
376 on('command.run', { command: COMMAND }, async $ => ({ text: await describe($, m) }))
377
378 on('session.end', async ($, e, next) => {
379 stopTicker(m)
380 showStatus($, m, undefined)
381 // A /clear goes on in this process as another conversation: its id and its file are read afresh.
382 m.sessionId = null
383 m.own = null
384 m.prunedAt = -Infinity
385 m.lastSummary = null
386 return next(e)
387 })
388}
389hooks/meter.ts 451 lines1import type { ApiReading, Hit, SessionUsageRow, Summary, TurnLine, UsageSettings, WindowTotals } from '../types'
2
3export const HOUR_MS = 3_600_000
4/** A reading of the API's own five-hour window counts as current for this long. */
5export const API_FRESH_MS = 15 * 60_000
6/** How many hits hits.json keeps. */
7export const KEPT_HITS = 20
8
9/**
10 * What a token of each kind counts toward the session limit, as near as
11 * Anthropic's usage explainer puts it: cache reads are cheap, cache writes
12 * and output dear. An estimate of what the limit meters, not its formula.
13 */
14export const WEIGHTS = { input: 1, cacheRead: 0.1, cacheWrite: 2, output: 5 } as const
15
16type Counts = { in: number; cr: number; cw: number; out: number }
17
18/** The effective tokens of one turn or one total, whole. */
19export const effectiveOf = (t: Counts): number =>
20 Math.round(t.in * WEIGHTS.input + t.cr * WEIGHTS.cacheRead + t.cw * WEIGHTS.cacheWrite + t.out * WEIGHTS.output)
21
22/** One turn as its JSON line. */
23export const turnLine = (turn: TurnLine): string => JSON.stringify(turn)
24
25const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
26
27/** The turns a file holds, one JSON object per line; a line that is not one is skipped. */
28export const parseTurns = (text: string): TurnLine[] => {
29 const turns: TurnLine[] = []
30 for (const raw of text.split('\n')) {
31 const line = raw.trim()
32 if (line === '') {
33 continue
34 }
35 let value: unknown
36 try {
37 value = JSON.parse(line)
38 } catch {
39 continue
40 }
41 if (typeof value !== 'object' || value === null || Array.isArray(value)) {
42 continue
43 }
44 const o = value as Record<string, unknown>
45 if (!isCount(o.ts) || !isCount(o.in) || !isCount(o.cr) || !isCount(o.cw) || !isCount(o.out)) {
46 continue
47 }
48 const turn: TurnLine = { ts: o.ts, in: o.in, cr: o.cr, cw: o.cw, out: o.out, sub: o.sub === true }
49 if (isCount(o.u)) {
50 turn.u = o.u
51 }
52 if (isCount(o.r)) {
53 turn.r = o.r
54 }
55 turns.push(turn)
56 }
57 return turns
58}
59
60/** The turns after `since`; one at the boundary is out. */
61export const pruneTurns = (turns: readonly TurnLine[], since: number): TurnLine[] => turns.filter(turn => turn.ts > since)
62
63/** Every session's turns inside the window, summed: the figures the status line, summary.json and /meter draw on. */
64export type Window = {
65 totals: WindowTotals
66 effective: number
67 /** Each session with a turn in the window, the heaviest first. */
68 sessions: SessionUsageRow[]
69 /** The newest reading of the API's own five-hour window among them; null when none carried one. */
70 api: ApiReading | null
71}
72
73/** Adds up the turns inside the window ending at `now`, file by file, and finds the newest API reading. */
74export const sumWindow = (files: readonly { id: string; turns: readonly TurnLine[] }[], now: number, windowMs: number): Window => {
75 const since = now - windowMs
76 const totals: WindowTotals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
77 const sessions: SessionUsageRow[] = []
78 let api: ApiReading | null = null
79 for (const file of files) {
80 const inside = pruneTurns(file.turns, since)
81 if (inside.length === 0) {
82 continue
83 }
84 const own: Counts = { in: 0, cr: 0, cw: 0, out: 0 }
85 let subTurns = 0
86 for (const turn of inside) {
87 own.in += turn.in
88 own.cr += turn.cr
89 own.cw += turn.cw
90 own.out += turn.out
91 if (turn.sub) {
92 subTurns += 1
93 }
94 if (turn.u !== undefined && (api === null || turn.ts > api.ts)) {
95 api = { ts: turn.ts, percentUsed: turn.u, resetsAt: turn.r ?? null, sessionId: file.id }
96 }
97 }
98 totals.input += own.in
99 totals.cacheRead += own.cr
100 totals.cacheWrite += own.cw
101 totals.output += own.out
102 sessions.push({ id: file.id, turns: inside.length, subTurns, effective: effectiveOf(own) })
103 }
104 sessions.sort((a, b) => b.effective - a.effective || a.id.localeCompare(b.id))
105 const effective = effectiveOf({ in: totals.input, cr: totals.cacheRead, cw: totals.cacheWrite, out: totals.output })
106 return { totals, effective, sessions, api }
107}
108
109/**
110 * The limit message as the API's error row reads it:
111 * `You've hit your session limit · resets 10:30pm (America/Los_Angeles)`,
112 * also with a bullet, with `Resets at`, or with no zone.
113 */
114const LIMIT = /hit your (?:session|usage) limit.*?\bresets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*\(([^)]+)\))?/i
115
116/** The clock time and zone a limit message names; null when the text is not one, or names no time. */
117export const parseLimit = (text: string): { hour: number; minute: number; zone: string | null } | null => {
118 const match = LIMIT.exec(text)
119 if (!match) {
120 return null
121 }
122 const h12 = Number(match[1])
123 const minute = Number(match[2] ?? '0')
124 const half = (match[3] ?? '').toLowerCase()
125 if (h12 < 1 || h12 > 12 || minute > 59) {
126 return null
127 }
128 const zone = match[4]?.trim() || null
129 return { hour: (h12 % 12) + (half === 'pm' ? 12 : 0), minute, zone }
130}
131
132type Wall = { year: number; month: number; day: number; hour: number; minute: number; second: number }
133
134/** A time's parts in a zone, by part type; null when Intl refuses the zone. */
135const partsIn = (ms: number, zone: string, options: Intl.DateTimeFormatOptions): Map<string, string> | null => {
136 try {
137 const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, ...options }).formatToParts(new Date(ms))
138 return new Map(parts.map(part => [part.type, part.value]))
139 } catch {
140 return null
141 }
142}
143
144/** The wall clock in a zone at `ms`. */
145const wallIn = (ms: number, zone: string): Wall | null => {
146 const parts = partsIn(ms, zone, { hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
147 if (parts === null) {
148 return null
149 }
150 const n = (type: string): number => Number(parts.get(type))
151 const wall: Wall = { year: n('year'), month: n('month'), day: n('day'), hour: n('hour') % 24, minute: n('minute'), second: n('second') }
152 return Object.values(wall).every(Number.isFinite) ? wall : null
153}
154
155/** A wall clock read as if it were UTC. */
156const asUtc = (wall: Wall): number => Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second)
157
158/** The zone's offset from UTC at `ms`, in milliseconds east. */
159const offsetIn = (ms: number, zone: string): number | null => {
160 const wall = wallIn(ms, zone)
161 return wall === null ? null : asUtc(wall) - Math.floor(ms / 1000) * 1000
162}
163
164/** The zone this process runs in, as Intl resolves it; null when it cannot say. */
165export const localZone = (): string | null => {
166 try {
167 return Intl.DateTimeFormat().resolvedOptions().timeZone || null
168 } catch {
169 return null
170 }
171}
172
173const zonesToTry = (zone: string | null): string[] => [zone, localZone()].filter((z): z is string => z !== null)
174
175/** The next occurrence of a clock time by the process's own clock, when Intl knows no zone at all. */
176const nextClockTimeLocal = (now: number, hour: number, minute: number): number => {
177 const d = new Date(now)
178 const today = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute, 0, 0).getTime()
179 return today > now ? today : new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, hour, minute, 0, 0).getTime()
180}
181
182/**
183 * The next occurrence after `now` of a clock time in a zone: today's if it is
184 * still ahead, else tomorrow's, the offset re-read at the target so a change of
185 * daylight time between now and then is honoured. The local zone stands in
186 * for a zone Intl does not know, or none.
187 */
188export const nextClockTime = (now: number, hour: number, minute: number, zone: string | null): number => {
189 for (const z of zonesToTry(zone)) {
190 const wall = wallIn(now, z)
191 const offset = offsetIn(now, z)
192 if (wall === null || offset === null) {
193 continue
194 }
195 let target = Date.UTC(wall.year, wall.month - 1, wall.day, hour, minute, 0)
196 if (target <= asUtc(wall)) {
197 target += 24 * HOUR_MS
198 }
199 let at = target - offset
200 const later = offsetIn(at, z)
201 if (later !== null && later !== offset) {
202 at = target - later
203 }
204 return at
205 }
206 return nextClockTimeLocal(now, hour, minute)
207}
208
209/** `5:30pm`, the way the limit message says it, in the zone (the local zone when it is unknown). */
210export const formatClock = (ms: number, zone: string | null): string => {
211 for (const z of zonesToTry(zone)) {
212 const parts = partsIn(ms, z, { hour: 'numeric', minute: '2-digit', hour12: true })
213 if (parts !== null) {
214 return `${parts.get('hour')}:${parts.get('minute')}${(parts.get('dayPeriod') ?? '').toLowerCase()}`
215 }
216 }
217 const d = new Date(ms)
218 const hour = d.getHours()
219 return `${hour % 12 === 0 ? 12 : hour % 12}:${String(d.getMinutes()).padStart(2, '0')}${hour < 12 ? 'am' : 'pm'}`
220}
221
222/** `Sep 10 3:58pm`. */
223export const formatDay = (ms: number, zone: string | null): string => {
224 for (const z of zonesToTry(zone)) {
225 const parts = partsIn(ms, z, { month: 'short', day: 'numeric' })
226 if (parts !== null) {
227 return `${parts.get('month')} ${parts.get('day')} ${formatClock(ms, z)}`
228 }
229 }
230 return `${new Date(ms).toDateString().slice(4, 10).trim()} ${formatClock(ms, zone)}`
231}
232
233/** `5s ago`, `2m ago`, `1h05m ago`. */
234export const formatAgo = (ms: number): string => {
235 const seconds = Math.max(0, Math.floor(ms / 1000))
236 if (seconds < 60) {
237 return `${seconds}s ago`
238 }
239 const minutes = Math.floor(seconds / 60)
240 if (minutes < 60) {
241 return `${minutes}m ago`
242 }
243 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m ago`
244}
245
246/** A token count at a glance: `12.4M`, `812K`, `950`. */
247export const formatTokens = (n: number): string =>
248 n >= 999_500 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}K` : `${Math.round(n)}`
249
250/** A window's length: `5h`, `2.5h`, `30m`. */
251export const formatHours = (ms: number): string => {
252 const hours = ms / HOUR_MS
253 if (hours < 1) {
254 return `${Math.round(ms / 60_000)}m`
255 }
256 return `${Number.isInteger(hours) ? hours : Number(hours.toFixed(1))}h`
257}
258
259/** The median effective total of the last three hits: the cap as the hits have shown it. Null without a hit. */
260export const capEstimate = (hits: readonly Hit[]): number | null => {
261 const last = hits
262 .slice(-3)
263 .map(hit => hit.effectiveAtHit)
264 .sort((a, b) => a - b)
265 if (last.length === 0) {
266 return null
267 }
268 const mid = Math.floor(last.length / 2)
269 return last.length % 2 === 1 ? (last[mid] ?? null) : Math.round(((last[mid - 1] ?? 0) + (last[mid] ?? 0)) / 2)
270}
271
272/** The hits with one more, unless one with the same reset time is already there (the same limit, seen again); the last `keep` kept. */
273export const withHit = (hits: readonly Hit[], hit: Hit, keep: number): { hits: Hit[]; isNew: boolean } =>
274 hits.some(known => known.resetAt === hit.resetAt) ? { hits: [...hits], isNew: false } : { hits: [...hits, hit].slice(-keep), isNew: true }
275
276/** The hits hits.json holds; nothing for a missing or unreadable file. */
277export const parseHits = (text: string | null): Hit[] => {
278 if (text === null) {
279 return []
280 }
281 let value: unknown
282 try {
283 value = JSON.parse(text)
284 } catch {
285 return []
286 }
287 if (!Array.isArray(value)) {
288 return []
289 }
290 const hits: Hit[] = []
291 for (const item of value as unknown[]) {
292 if (typeof item !== 'object' || item === null) {
293 continue
294 }
295 const o = item as Record<string, unknown>
296 if (!isCount(o.at) || !isCount(o.resetAt) || !isCount(o.effectiveAtHit)) {
297 continue
298 }
299 hits.push({ at: o.at, resetAt: o.resetAt, effectiveAtHit: o.effectiveAtHit, zone: typeof o.zone === 'string' && o.zone !== '' ? o.zone : null })
300 }
301 return hits
302}
303
304/**
305 * summary.json, field for field in the contract's order. `pct` and `nextResetAt` come from the API's own five-hour
306 * reading while it is fresh (the limit's real figure, known before any hit), else from the last learned limit.
307 */
308export const buildSummary = (window: Window, hits: readonly Hit[], now: number, windowMs: number): Summary => {
309 const cap = capEstimate(hits)
310 const api = isFreshReading(window.api, now) ? window.api : null
311 let last: Hit | null = null
312 for (const hit of hits) {
313 if (last === null || hit.at >= last.at) {
314 last = hit
315 }
316 }
317 return {
318 ts: now,
319 windowMs,
320 sessions: window.sessions.length,
321 tokens: { input: window.totals.input, cacheRead: window.totals.cacheRead, cacheWrite: window.totals.cacheWrite, output: window.totals.output },
322 effective: window.effective,
323 capEstimate: cap,
324 pct: api !== null ? Math.round(api.percentUsed) : cap !== null && cap > 0 ? Math.round((100 * window.effective) / cap) : null,
325 lastHitAt: last === null ? null : last.at,
326 nextResetAt:
327 api !== null && api.resetsAt !== null && api.resetsAt > now ? api.resetsAt : last !== null && last.resetAt > now ? last.resetAt : null,
328 }
329}
330
331/** The estimate alone: the window's effective tokens against the last learned limit, whatever pct carries. */
332export const estimatePct = (summary: Summary): number | null =>
333 summary.capEstimate !== null && summary.capEstimate > 0 ? Math.round((100 * summary.effective) / summary.capEstimate) : null
334
335/** Whether two summaries say the same thing, the timestamp aside. */
336export const isSameSummary = (a: Summary | null, b: Summary): boolean =>
337 a !== null && JSON.stringify({ ...a, ts: 0 }) === JSON.stringify({ ...b, ts: 0 })
338
339/** The share of the limit used, and whether the API said so or the estimate did. */
340export type Share = { pct: number; source: 'api' | 'estimate' }
341
342/** Whether a reading of the API's window is still current: recent, and its window not yet reset. */
343export const isFreshReading = (api: ApiReading | null, now: number): api is ApiReading =>
344 api !== null && now - api.ts <= API_FRESH_MS && (api.resetsAt === null || api.resetsAt > now)
345
346/** The best share known: the API's own reading while fresh, else the estimate against the last limit, else nothing. */
347export const shareOf = (summary: Summary, api: ApiReading | null, now: number): Share | null =>
348 isFreshReading(api, now)
349 ? { pct: Math.round(api.percentUsed), source: 'api' }
350 : summary.pct !== null
351 ? { pct: summary.pct, source: 'estimate' }
352 : null
353
354/** The next reset ahead: the last hit's, else the API's; null when neither is ahead. */
355export const resetOf = (summary: Summary, api: ApiReading | null, now: number): number | null =>
356 summary.nextResetAt !== null && summary.nextResetAt > now
357 ? summary.nextResetAt
358 : api !== null && api.resetsAt !== null && api.resetsAt > now
359 ? api.resetsAt
360 : null
361
362/** `usage 5h: 12.4M eff · 61% of last limit · resets 10:30pm`: the share and the reset only when known. */
363export const statusLine = (summary: Summary, api: ApiReading | null, now: number, windowMs: number, zone: string | null): string => {
364 const parts = [`usage ${formatHours(windowMs)}: ${formatTokens(summary.effective)} eff`]
365 const share = shareOf(summary, api, now)
366 if (share !== null) {
367 parts.push(share.source === 'api' ? `${share.pct}% used` : `${share.pct}% of last limit`)
368 }
369 const reset = resetOf(summary, api, now)
370 if (reset !== null) {
371 parts.push(`resets ${formatClock(reset, zone)}`)
372 }
373 return parts.join(' · ')
374}
375
376/** The toast when the share first crosses the threshold. */
377export const warnMessage = (share: Share, summary: Summary, api: ApiReading | null, now: number, windowMs: number, zone: string | null): string => {
378 const window = `in the last ${formatHours(windowMs)}`
379 const head =
380 share.source === 'api'
381 ? `Usage at ${share.pct}% of the five-hour limit · ${formatTokens(summary.effective)} eff ${window}`
382 : `Usage at ${share.pct}% of the last session limit · ${formatTokens(summary.effective)} of ${formatTokens(summary.capEstimate ?? 0)} eff ${window}`
383 const reset = resetOf(summary, api, now)
384 return reset === null ? head : `${head} · resets ${formatClock(reset, zone)}`
385}
386
387/** The toast at the hit itself. */
388export const hitMessage = (resetAt: number, effective: number, windowMs: number, zone: string | null): string =>
389 `Session limit hit · resets ${formatClock(resetAt, zone)} · ${formatTokens(effective)} eff in the last ${formatHours(windowMs)}, cap estimate updated`
390
391const WEIGHTS_LINE =
392 'Weights: eff = input + 0.1·cache read + 2·cache write + 5·output, an estimate of what the session limit meters; the API line is the limit’s own reading.'
393
394/** What /meter and the usage_status tool print. */
395export const describeUsage = (
396 window: Window,
397 summary: Summary,
398 hits: readonly Hit[],
399 settings: UsageSettings,
400 now: number,
401 ownId: string,
402 zone: string | null,
403): string => {
404 const hours = formatHours(summary.windowMs)
405 const t = window.totals
406 const lines: string[] = [
407 window.sessions.length === 0
408 ? `Usage in the last ${hours}, all sessions: 0 eff (no turns counted yet)`
409 : `Usage in the last ${hours}, all sessions: ${formatTokens(summary.effective)} eff · input ${formatTokens(t.input)} · cache read ${formatTokens(t.cacheRead)} · cache write ${formatTokens(t.cacheWrite)} · output ${formatTokens(t.output)}`,
410 ]
411 if (summary.capEstimate === null || estimatePct(summary) === null) {
412 lines.push('Estimate: no limit hit seen yet, so the cap is unknown until the first')
413 } else {
414 const n = Math.min(3, hits.length)
415 const from = n === 1 ? '1 hit' : `the median of the last ${n} hits`
416 const reset = summary.nextResetAt !== null && summary.nextResetAt > now ? ` · resets ${formatClock(summary.nextResetAt, zone)}` : ''
417 lines.push(`Estimate: ${estimatePct(summary)}% of the last session limit (cap ${formatTokens(summary.capEstimate)} eff from ${from})${reset}`)
418 }
419 const api = window.api
420 if (api === null) {
421 lines.push('API five-hour window: no reading yet')
422 } else {
423 const reset = api.resetsAt !== null && api.resetsAt > now ? ` · resets ${formatClock(api.resetsAt, zone)}` : ''
424 lines.push(`API five-hour window: ${Math.round(api.percentUsed)}% used${reset} · read ${formatAgo(now - api.ts)} by session ${api.sessionId.slice(0, 8)}`)
425 }
426 lines.push(`Sessions in the window: ${window.sessions.length}`)
427 const rows = window.sessions.map(session => ({
428 id: session.id.slice(0, 8).padEnd(8),
429 turns: `${session.turns} ${session.turns === 1 ? 'turn' : 'turns'}${session.subTurns > 0 ? ` (${session.subTurns} by subagents)` : ''}`,
430 eff: `${formatTokens(session.effective)} eff`,
431 share: `${summary.effective > 0 ? Math.round((100 * session.effective) / summary.effective) : 0}%`,
432 isOwn: session.id === ownId,
433 }))
434 const turnsWidth = Math.max(0, ...rows.map(row => row.turns.length))
435 const effWidth = Math.max(0, ...rows.map(row => row.eff.length))
436 const shareWidth = Math.max(0, ...rows.map(row => row.share.length))
437 for (const row of rows) {
438 lines.push(` ${row.id} ${row.turns.padEnd(turnsWidth)} ${row.eff.padStart(effWidth)} ${row.share.padStart(shareWidth)}${row.isOwn ? ' ← this session' : ''}`)
439 }
440 if (hits.length === 0) {
441 lines.push('Limit hits: none yet')
442 } else {
443 lines.push(`Limit hits, newest first (${hits.length} kept, ${KEPT_HITS} at most):`)
444 for (const hit of [...hits].sort((a, b) => b.at - a.at)) {
445 lines.push(` ${formatDay(hit.at, zone)} · resets ${formatClock(hit.resetAt, hit.zone ?? zone)} · ${formatTokens(hit.effectiveAtHit)} eff in the window then`)
446 }
447 }
448 lines.push(WEIGHTS_LINE, `Settings: window ${formatHours(settings.windowHours * HOUR_MS)} · refresh every ${settings.refreshSeconds}s · warn at ${settings.warnAt}%`)
449 return lines.join('\n')
450}
451types/index.d.ts 99 lines1/**
2 * One turn as this session's turns file keeps it, one JSON line each:
3 * when it ended and the four token counts the API reported.
4 */
5export type TurnLine = {
6 /** When the turn ended, in `$.clock.now()` milliseconds. */
7 ts: number
8 /** Uncached input tokens. */
9 in: number
10 /** Input tokens the prompt cache served. */
11 cr: number
12 /** Input tokens written to the prompt cache. */
13 cw: number
14 /** Output tokens. */
15 out: number
16 /** True for a subagent's turn. */
17 sub: boolean
18 /** The API's five-hour window reading when the turn ended, percent used; absent when it reported none. */
19 u?: number
20 /** When that window resets, in milliseconds; absent when the API said nothing. */
21 r?: number
22}
23
24/** The window's token counts by category, summed over every session. */
25export type WindowTotals = {
26 input: number
27 cacheRead: number
28 cacheWrite: number
29 output: number
30}
31
32/** One session's share of the window, as /meter tables it. */
33export type SessionUsageRow = {
34 /** The session id (the turns file's name). */
35 id: string
36 turns: number
37 /** How many of the turns were subagents'. */
38 subTurns: number
39 effective: number
40}
41
42/** The newest reading of the API's own five-hour window found in the window's turns. */
43export type ApiReading = {
44 /** When it was read, in milliseconds. */
45 ts: number
46 /** Percent of the five-hour limit used, as the API reported it. */
47 percentUsed: number
48 /** When the API said the window resets, in milliseconds; null when it did not say. */
49 resetsAt: number | null
50 /** Which session read it. */
51 sessionId: string
52}
53
54/** One session-limit hit, as hits.json keeps it. */
55export type Hit = {
56 /** When the limit message was seen, in milliseconds. */
57 at: number
58 /** The next occurrence of the clock time the message named, in milliseconds. */
59 resetAt: number
60 /** The window's effective tokens when the limit was hit: a sample of the cap. */
61 effectiveAtHit: number
62 /** The zone the message named (`America/Los_Angeles`), or null when it named none. */
63 zone: string | null
64}
65
66/** What summary.json holds for other mods: the contract, field for field. */
67export type Summary = {
68 ts: number
69 windowMs: number
70 sessions: number
71 tokens: WindowTotals
72 effective: number
73 capEstimate: number | null
74 pct: number | null
75 lastHitAt: number | null
76 nextResetAt: number | null
77}
78
79/** The plugin's settings, as /meter describes them. */
80export type UsageSettings = {
81 windowHours: number
82 refreshSeconds: number
83 warnAt: number
84}
85
86/** What this session has already said, so a reload does not say it again. */
87export type UsageAlerts = {
88 /** Whether the warn toast has fired for the share now above the threshold. */
89 isWarned: boolean
90 /** The reset times of the hits this session has toasted; at most 20. */
91 toastedResets: number[]
92}
93
94declare module 'claude-code' {
95 interface PluginState {
96 'usage-meter': { alerts: UsageAlerts }
97 }
98}
99