Shared rate-limit guard for loop lanes and Claude: a hooks module writes the subscription rate-limit windows to a fixed machine-scope file and tells Claude…

A Claude Code plugin that tells Claude, and every session on the machine that reads its file, where the account's shared subscription rate-limit windows stand, so Claude can plan around a limit before it hits one and autonomous loop lanes can pause before a limit and resume on their own after the reset. Three parts:
hooks/register.tsx), a mod that Claude Code runs in its own process. It tells Claude at boundaries when a window approaches or reaches the pause edge and when it resets, shows you a toast when that happens, can draw the figures in a band row (off by default), answers a status tool, and writes the windows to the fixed machine-scope file ~/.claude/rate-limit-guard/rate-limits.json in interactive and headless sessions alike. It needs Claude Code 2.1.287 or later; older builds are unsupported.hooks/record-rate-limit-stop.sh), the reactive fallback. When a turn ends on a rate-limit API error, it appends a detection record to ~/.claude/rate-limit-guard/stop-events.jsonl. StopFailure output and exit codes are ignored by the harness, so the hook is side-effect-only by design.reference/reader-contract.md), the authoritative consumer contract: the fixed file path, the 95%-of-either-window pause threshold, the staleness rule, pause-end semantics, capability-detect fail-open, and drain-then-pause.The module tells Claude where the account stands against its rate limits, tells you when a window changes, can draw the figures in a band row, answers a status tool, and writes the contract file, in interactive, -p, --bg and /loop sessions alike. Tested with claude plugin test and live on Claude Code 2.1.288: interactive terminal sessions, -p runs, --bg and /loop lanes, side by side with the retired tee, a mods-off session, and native Windows. The Desktop app and VS Code were not run.
A line goes to Claude only at a boundary, appended to the context of a main-thread tool result or of a prompt; a crossing seen when a turn ends reaches Claude with the next prompt. Per window (5-hour and 7-day), with the default options:
| When | Line |
|---|---|
| The window reaches the approach mark (90%) | once per window, "at or above 90%", with the reset time |
| The window reaches the line threshold (95%) | once per window, "at or above 95%", with the reset time |
| The window resets (its reset time passes, or it leaves the reading) after reaching the threshold | once; the approach and threshold lines can then fire again |
After a compaction (not the precompute kind), and after /resume or /branch | the verdict for each window at or above the approach mark, once; nothing when none is |
After /clear, and when the module loads into a session that already has turns (a --resume launch, a reload after an options change, a hooks-worker restart) | the verdict for each window at or above the threshold, once; nothing when none is |
Use only rises within a window, so a reading that dips below a mark sends nothing and does not re-arm that mark's line. No other tool call or prompt carries a line. Each window gets its own line, for example rate-limit-guard: 5-hour window at or above 95% (96% used), resets at 2026-10-03 21:00 UTC. A line states facts only (the window, the threshold crossed, and the use and reset time the line data selects) and never tells Claude what to do; Claude decides. Every line carries its verdict; rate_limit_line_data chooses what goes with it: the window's name (window), its use (percent) and its reset time (reset). Without window the line says "a rate-limit window". A line never carries the account email or the session name. Subagents get no line. Each line sent to Claude is also written as sent to the debug log (claude --debug). The line threshold is a line setting only: the loop lanes' pause edge stays 95% (see the reader contract).
When a window rises from an earlier reading to the approach mark or the line threshold, or resets after reaching the threshold, the module shows a toast for 4 seconds, such as 5h at the 95% pause edge · resets 21:00 UTC, and writes one transcript line Claude does not read, ending · more: /rate-limit-guard. A window's first reading since the module loaded or the window reset is never toasted, and neither is a restatement after a compaction, a resume, /clear or a reload: those only restate the verdict to Claude. Windows belong to the account, so a rise across /clear or a resume is a change and is toasted. The toast does not depend on rate_limit_lines_enabled. rate_limit_guard_toast set to false drops the toast and keeps the transcript line. Outside the terminal (the Desktop app, VS Code, mobile), where toast drawing is unverified, the change also shows as a single row above the prompt, covering every window that changed, until your next prompt.
With rate_limit_report_mode set to operator, a turn a person started by typing (or through the Remote Control bridge) gets no line. When that turn ends, the line is offered as the prompt box's suggestion (Tab takes it) and shown as a notice row above the prompt, on every surface, wrapped rather than cut off. With text in the box, only the notice row shows, and the suggestion is offered again once the box is empty. Where nobody can take a suggestion, the line goes to Claude as in automatic mode: -p and SDK turns, /loop and scheduled turns, task notifications and other non-typed turns, a session with no drawing surface (such as the VS Code panel), and a suggestion the session reports it cannot show. The row and a shown suggestion are your channel for a held line, so it gets a toast and a transcript line only when you never had either: its first offer could not show, or a survey hid the row until the line went to Claude. A suggestion that showed but was not taken goes to Claude as the automatic line at the next turn no person started; a turn a person starts drops it unsent.
Upstream's render-sites table lists the band's site, AbovePrompt, as drawn in the terminal and the Desktop app. No probe of this plugin ran in the Desktop app or VS Code, so the band, the notice rows, the toast and the suggestion there are untested.
AbovePrompt row changes the apps it lists, or a Desktop run of this plugin is made.A --bg session reports its first prompt as typed, so in operator mode a --bg lane gets no line from that turn; its line reaches Claude at the lane's next turn no person started. A lane that wants the lines at once may start its session with its own options through --settings, which can set any key user settings can, including the plugin's pluginConfigs entry: {"pluginConfigs": {"rate-limit-guard@<marketplace>": {"options": {"rate_limit_report_mode": "automatic"}}}} (a --plugin-dir copy is keyed <name>@inline).
pluginConfigs entry, and the pluginConfigs row of mods reference: settings and environment variables.--settings set user-scope keys, or the pluginConfigs scope changes./rate-limit-guard with no argument prints each window's use, verdict and reset time, the line threshold and approach mark, whether the band row and the toast are on, the snapshot path and a link to this README.
The band row above the prompt is off by default. When on, it shows 5h <x>% | 7d <y>%, with - for a window that has no reading yet. Context use is context-guard's row, not this one. rate_limit_guard_band set to true turns it on for every session; /rate-limit-guard band on, band off, or a bare band (toggle) changes it for the current session. Claude can call mcp__rate-limit-guard__status for the exact figures from the last API response: every window the response reported, with its verdict, and, behind a Claude gateway, the spend_limit window, which is never written to the contract file.
The module writes ~/.claude/rate-limit-guard/rate-limits.json through lib/write-snapshot.mjs, run with node, so the file is replaced atomically on Linux, macOS and native Windows. It writes at once when a window moves a whole point, appears, leaves or resets, and otherwise at most once every 300 seconds across the machine, checked from main-thread tool results, each measurement after a turn, a 60-second timer that runs only while a turn runs, and the session's end. It never writes from a turn a task notification started (a paused lane's own Monitor tick), and it follows the plugin's on/off switch and rate_limit_guard_enabled. The body carries captured_at, session_id, the windows, and account.email only when the account in the state file at the write is the one it held at the last API response (a startup quota check counts); it never carries session_name or spend_limit. A session with no windows writes a windowless body, which never replaces a file that has windows.
Process cost: an event that writes nothing starts no process; each write starts one node process. Mods can start host processes in the CLI only; where they cannot, the module writes nothing and logs that once to the debug log.
Below Claude Code 2.1.287, under disableAllHooks, with --bare, when mods are switched off remotely, or after the hooks worker crashes, the module does not run: no lines, no band, no status tool, no module writes. The StopFailure hook still records, and readers fall back to reactive-only as the reader contract says. /rate-limit-guard:check and /rate-limit-guard:setup check report "mods off" in that case. A hook that throws passes its event through unchanged.
captured_at over a newer one, and keeps a windowless body from replacing windows. Last-writer-wins still applies between sessions: a session whose last API response is minutes old can write its older reading over a newer one, until the next write from a session with a fresher response.rate_limits; consumers treat that as unknown and run reactive-only rather than throttling on fabricated data. Cloud / remote sessions typically have no file a consumer can read. That is the same unknown → reactive-only classification, documented as the expected degraded mode in reference/reader-contract.md ("Cloud / remote sessions"), with a documented residual that a live cloud producer is out of scope until one exists.captured_at on disk, in process, so an unchanged reading inside the 300-second floor starts nothing. That floor is half the reader contract's 10-minute staleness budget, so a session whose windows sit still refreshes captured_at well before a reader could call it stale.account.email field, so a machine switching accounts is visible to a reader that checks it. Lanes drop a latched pause on an account change: while paused they read .oauthAccount.emailAddress from .claude.json directly and re-evaluate against the new account's windows. The gap that remains is attribution: the field is absent whenever the module could not attribute the observation, which covers an unreadable state file, no email-shaped value, and an account that changed between the last API response and the write. A lane that cannot attribute keeps its latch. The loop-lane convention §6 owns that framing; the reader contract states the absence cases and the untrusted-value rule (reference/reader-contract.md, "Snapshot file shape").claude plugin test plugins/rate-limit-guard runs the module's suite (hooks/rate-limit-guard.test.ts) with stubbed Claude Code calls; lib/write-snapshot.test.mjs covers the helper. The module's process budget, counted in the hook-budget convention's unit (one process start): 0 processes on a tool result, prompt, measurement or timer tick that writes nothing, and 1 (the node helper) per write, with writes at most once per changed reading and no more often than every 300 seconds when unchanged. The budget: test pins it: 50 unchanged tool results start no process, and one change starts exactly one.
The tests retired with the statusline tee, and what holds their property now:
| Retired test | Replacement |
|---|---|
| The tee suite: snapshot shape, account, no-change floor, enablement | the snapshot:, account:, floor: and switch: tests in hooks/rate-limit-guard.test.ts |
| The tee suite: atomic write, windowless preservation, locks | lib/write-snapshot.test.mjs |
| The tee suite: the zero-fork render trace | the budget: test, at the budget above |
| The bench lanes and their recorded process counts | the budget: test, at the budget above |
| The tee suite: transparency, spool and drain, async writes, the disabled marker | none: the plugin no longer wraps a status line or spools |
| The shim and compose-script suites | none: no shim ships; the setup skill's evals cover removing an old one |
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install rate-limit-guard@<marketplace>
The module and the StopFailure hook are active once the plugin loads (the next session, or /reload-plugins in an open one); nothing needs wiring. /rate-limit-guard:setup check verifies the result.
PATH. The StopFailure hook launches through node hooks/exec-bash.mjs, and the module writes the file by running node; Claude Code's native binary does not ship or use Node. Without it the hook records nothing and the module writes nothing, while its lines and band row still work.hooks/exec-bash.mjs finds./rate-limit-guard:check reports whether node resolves and whether the module can load, read-only. It installs nothing.
The userConfig options:
| Option | What it controls |
|---|---|
rate_limit_guard_enabled | Kill switch for the StopFailure detection hook and the module's snapshot writes (default true). It does not stop the module's lines. |
rate_limit_lines_enabled | The module's lines to Claude, and the operator-mode suggestions (default true). |
rate_limit_report_mode | automatic (default) or operator; see Operator mode. |
rate_limit_line_threshold | Window use for the threshold line (default 95). |
rate_limit_approach_pct | Window use for the one approach line (default 90). |
rate_limit_line_data | What a line carries beside its verdict: percent, window, reset (default verdict,percent,window,reset). |
rate_limit_guard_band | The band row (default false). |
rate_limit_guard_toast | The toast when a window nears or reaches the threshold or resets (default true); the transcript line stays either way. |
A threshold or approach mark outside 1 to 100, or line data with an unknown item, reads as that option's default with one transcript line naming it; the thresholds declare no range because Claude Code refuses the whole module for a value outside one. A value of the wrong type (text for a number, a number for a switch) still stops the module loading.
The module reads its options when it loads. Claude Code reloads a module when its options change, so a switch turned off takes effect from the next event, with no restart. The StopFailure hook receives rate_limit_guard_enabled as CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED at session start.
Register in the claude-code/index.d.ts types Claude Code writes for its build (see create: get the types for your build).Set it with /plugin configure rate-limit-guard@<marketplace>, or headless via claude plugin install rate-limit-guard@<marketplace> -s <scope> --config rate_limit_guard_enabled=false, against an already-installed plugin that prints already installed and still writes the value. Never uninstall to reconfigure: that drops the whole stored pluginConfigs entry and resets every option to its manifest default. The verified-version record lives in the plugin-reconfiguration convention.
The file path and the 95% pause threshold are deliberately not configurable: they are contract constants that cross-plugin consumers inline from the reader contract; a per-user override would silently split the writer from its readers. The kill switch stops this plugin's writes and records; turning the plugin off for a project is enabledPlugins, and removing it is uninstall.
<!-- BEGIN GENERATED: plugin options. Edit plugin.json, then run scripts/sync-plugin-options-docs.py -->
Generated from this plugin's .claude-plugin/plugin.json. Every option Claude Code will prompt for when the plugin is enabled, with the environment variable each hook reads it from.
| Option | Type | Default | Environment variable | Description |
|---|---|---|---|---|
rate_limit_guard_enabled | boolean | true | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED | Turns on the StopFailure detection hook and the module's snapshot writes to the machine-scope rate-limit file. Lines to Claude have their own option. On by default. Read from managed settings first, then user settings. |
rate_limit_lines_enabled | boolean | true | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_LINES_ENABLED | Sends Claude one line when a rate-limit window approaches or reaches the line threshold, when it resets, and after a compaction, a resume or a /clear at the threshold. On by default. |
rate_limit_report_mode | string | "automatic" | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_REPORT_MODE | automatic (default) sends the lines to Claude; operator holds them in a turn a person typed and offers the person a ready-made prompt and a notice row above the prompt when the turn ends. Headless, loop and schedule turns get automatic lines either way. |
rate_limit_line_threshold | number | 95 | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_LINE_THRESHOLD | Window use at which Claude gets the threshold line, 1 to 100; any other value reads as the default. Default 95, the loop lanes' pause edge, which stays 95 whatever this is set to. |
rate_limit_approach_pct | number | 90 | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_APPROACH_PCT | Window use at which Claude gets one approach line before the threshold, 1 to 100; any other value reads as the default. Default 90; at or above the threshold, no approach line is sent. |
rate_limit_line_data | string | "verdict,percent,window,reset" | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_LINE_DATA | Comma list of what a line carries beside its verdict, which every line has: percent, window and reset. Default verdict,percent,window,reset; a list with an unknown item reads as the default. Never the account email or the session name. |
rate_limit_guard_band | boolean | false | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_BAND | Draws the 5-hour and 7-day window figures in a row above the prompt. Off by default. Turn it on in /config, or for one session with /rate-limit-guard band on. |
rate_limit_guard_toast | boolean | true | CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_TOAST | Shows a toast and writes one transcript line when a rate-limit window nears or reaches the line threshold, or resets from it. Off keeps the transcript line. On by default. |
Three supported routes, in the order most people want them:
/plugin configure rate-limit-guard@<marketplace>.--config for each option. Replace <marketplace> with the marketplace you installed this plugin from: claude plugin install rate-limit-guard@<marketplace> -s <scope> --config rate_limit_guard_enabled=<value>
The same command reconfigures a plugin that is already installed: it prints already installed and still writes the value. The short-circuit message is about the install, not the config write. Do not claude plugin uninstall to reconfigure: uninstalling drops this plugin's whole stored pluginConfigs entry, resetting every option in the table above to its default. -s defaults to user, so pass the scope claude plugin list reports for this plugin. The verified-version record lives in the plugin-reconfiguration convention.
The value is stored immediately; the session you are in does not change. Hooks are handed their CLAUDE_PLUGIN_OPTION_* when the session starts, so start a fresh Claude Code session before expecting new behavior. A check run in the old session still reports the old value, and that is not a failed write.
pluginConfigs in your user settings (~/.claude/settings.json): {
"pluginConfigs": {
"rate-limit-guard@<marketplace>": {
"options": {
"rate_limit_guard_enabled": <value>
}
}
}
}
Plugin option values are read from user, --settings, and managed settings only, not from a project's .claude/settings.json. To vary behavior per repository, enable or disable the plugin in that project's enabledPlugins instead of setting an option there.
Do not set the CLAUDE_PLUGIN_OPTION_* variables yourself. They are how Claude Code hands a configured value to a hook process; the value comes from the routes above.
userConfig schema and the CLAUDE_PLUGIN_OPTION_<KEY> exporthooks/register.tsx 719 lines1import type { EngineInterface, PromptOrigin, Register, SessionRateLimit, Timer } from 'claude-code'
2
3// The contract directory under the home directory. make-probe-copy.sh rewrites this one line.
4const CONTRACT_DIR = 'rate-limit-guard'
5const SNAPSHOT_FILE = 'rate-limits.json'
6const HELPER = 'lib/write-snapshot.mjs'
7const PAUSE_EDGE = 95
8const FLOOR_MS = 300_000
9const WRITE_TIMER_MS = 60_000
10const REOFFER_MS = 5_000
11const WINDOWS = [
12 { kind: 'five_hour', name: '5-hour', short: '5h' },
13 { kind: 'seven_day', name: '7-day', short: '7d' },
14] as const
15// The verdict is always in a line; these add to it.
16const DATA_ITEMS = ['verdict', 'percent', 'window', 'reset']
17const DEFAULT_DATA = ['verdict', 'percent', 'window', 'reset']
18const RANK = { quiet: 0, approach: 1, edge: 2 } as const
19const PERSON_ORIGINS = ['composer', 'bridge']
20const COMMAND = 'rate-limit-guard'
21// Command replies carry no plugin prefix: Claude Code shows each under the plugin's name.
22const USAGE = `Usage: /${COMMAND} [band [on|off]]`
23const README = 'https://github.com/melodic-software/claude-code-plugins/blob/main/plugins/rate-limit-guard/README.md'
24
25type Level = 'quiet' | 'approach' | 'edge'
26type Event = Level | 'reset'
27type Reading = Map<string, SessionRateLimit>
28// An event and the reading that produced it, so a late line states both from one reading.
29type Due = { event: Event; limit: SessionRateLimit | undefined }
30type Config = {
31 bad: string[]
32 writes: boolean
33 lines: boolean
34 operator: boolean
35 threshold: number
36 approach: number
37 data: Set<string>
38 band: boolean
39 toast: boolean
40}
41// An operator notice offers held lines and shows on every surface; a crossing notice stands in for
42// the toast where toasts may not draw, so it shows only off the terminal.
43type Notice = { text: string; kind: 'crossing' | 'operator' }
44type Body = {
45 captured_at: string
46 session_id: string
47 rate_limits?: Record<string, { used_percentage: number; resets_at?: number }>
48 account?: { email: string }
49}
50type State = {
51 reading: Reading | undefined
52 spend: SessionRateLimit | undefined
53 levels: Map<string, { level: Level; resetsMs: number; limit: SessionRateLimit }>
54 limits: readonly SessionRateLimit[]
55 pending: Map<string, Due>
56 // Rises from a known level and resets not yet shown to the person.
57 toastQueue: [string, Event][]
58 // Operator mode: the changes the operator notice offers, and whether its row has been drawn.
59 // The row and a shown suggestion are the person's channel, so a change either reached is never toasted.
60 heldToasts: [string, Event][]
61 rowSeen: boolean
62 // Operator mode: events a shown suggestion offered, handed to Claude if no person takes them.
63 handoff: Map<string, Due>
64 restate: boolean
65 restateIfLoud: boolean
66 // An in-process /resume or /branch ended the last session; cleared by the first decided write.
67 branched: boolean
68 forceAutomatic: boolean
69 origin: PromptOrigin | undefined
70 notice: Notice | undefined
71 bandShown: boolean
72 lastResponseAtMs: number | undefined
73 responseEmail: string | undefined
74 identityCache: { key: string; email: string | undefined } | undefined
75 lastAttempt: { sig: string; at: number } | undefined
76 loggedOnce: Set<string>
77 writing: Promise<void>
78 writeTimer: Timer | undefined
79 reofferTimer: Timer | undefined
80}
81
82// A bad value reads as the option's default and adds one line to `bad`: the engine refuses the whole
83// module for a value outside a declared range, so the ranges are checked here instead.
84export const parseConfig = (options: Record<string, unknown>): Config => {
85 const bad: string[] = []
86 const reject = (key: string, kind: string, fallback: string) =>
87 bad.push(`option ${key} is ${kind}; using the default, ${fallback}`)
88 const number = (key: string, fallback: number) => {
89 const value = options[key]
90 if (value === undefined) return fallback
91 if (typeof value === 'number' && value >= 1 && value <= 100) return value
92 reject(key, typeof value === 'number' ? `${value}, outside 1 to 100` : `a ${typeof value}, not a number`, String(fallback))
93 return fallback
94 }
95 const raw = options.rate_limit_line_data
96 const items = String(raw ?? '')
97 .split(',')
98 .map(s => s.trim().toLowerCase())
99 .filter(s => s !== '')
100 const known = items.length > 0 && items.every(s => DATA_ITEMS.includes(s))
101 // An empty value reads as the default silently, as an unset one does.
102 if (raw !== undefined && String(raw).trim() !== '' && !known) {
103 const shown = typeof raw === 'string' ? JSON.stringify(raw.slice(0, 40)) : `a ${typeof raw}`
104 reject('rate_limit_line_data', `${shown}, not a list of ${DATA_ITEMS.join(', ')}`, DEFAULT_DATA.join(','))
105 }
106 const data = new Set(known ? items : DEFAULT_DATA)
107 return {
108 bad,
109 writes: options.rate_limit_guard_enabled !== false,
110 lines: options.rate_limit_lines_enabled !== false,
111 operator: options.rate_limit_report_mode === 'operator',
112 threshold: number('rate_limit_line_threshold', PAUSE_EDGE),
113 approach: number('rate_limit_approach_pct', 90),
114 data,
115 band: options.rate_limit_guard_band === true,
116 toast: options.rate_limit_guard_toast !== false,
117 }
118}
119
120// The status line drops a window once its reset time has passed; so does this reading.
121export const liveWindows = (limits: readonly SessionRateLimit[], nowMs: number): Reading => {
122 const reading: Reading = new Map()
123 for (const { kind } of WINDOWS) {
124 const limit = limits.find(l => l.kind === kind)
125 if (limit === undefined) continue
126 const resetsMs = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
127 if (Number.isFinite(resetsMs) && resetsMs <= nowMs) continue
128 reading.set(kind, limit)
129 }
130 return reading
131}
132
133const levelOf = (percent: number, cfg: Config): Level =>
134 percent >= cfg.threshold ? 'edge' : percent >= cfg.approach ? 'approach' : 'quiet'
135
136const edgeName = (cfg: Config) =>
137 cfg.threshold === PAUSE_EDGE ? `${PAUSE_EDGE}% pause edge` : `${cfg.threshold}% line threshold`
138
139const resetLabel = (iso: string) => {
140 const at = new Date(iso)
141 return Number.isNaN(at.getTime()) ? iso : `${at.toISOString().slice(0, 16).replace('T', ' ')} UTC`
142}
143
144// The person's wording; Claude's names the threshold crossed and nothing else.
145const verdictText = (event: Event, cfg: Config) =>
146 ({ edge: 'at', approach: 'nearing', quiet: 'below', reset: 'reset and below' })[event] + ` the ${edgeName(cfg)}`
147
148const modelVerdict = (event: Event, cfg: Config) =>
149 ({
150 edge: `at or above ${cfg.threshold}%`,
151 approach: `at or above ${cfg.approach}%`,
152 quiet: `below ${cfg.threshold}%`,
153 reset: `reset, now below ${cfg.threshold}%`,
154 })[event]
155
156const windowOf = (kind: string) => WINDOWS.find(w => w.kind === kind)
157
158const clause = (kind: string, event: Event, limit: SessionRateLimit | undefined, cfg: Config) => {
159 const subject = cfg.data.has('window') ? `${windowOf(kind)?.name ?? kind} window` : 'a rate-limit window'
160 const percent = limit !== undefined && cfg.data.has('percent') ? `${limit.percentUsed}% used` : undefined
161 let text = `${subject} ${modelVerdict(event, cfg)}${percent ? ` (${percent})` : ''}`
162 if (event !== 'reset' && cfg.data.has('reset') && limit?.resetsAt !== undefined) {
163 text += `, resets at ${resetLabel(limit.resetsAt)}`
164 }
165 return text
166}
167
168// The person's short form: the 5-hour window resets within the day, so its time alone is enough.
169const toastBody = (kind: string, event: Event, limit: SessionRateLimit | undefined, cfg: Config) => {
170 if (event === 'reset') return `${windowOf(kind)?.short ?? kind} reset, below the ${edgeName(cfg)}`
171 const text = `${windowOf(kind)?.short ?? kind} ${verdictText(event, cfg)}`
172 if (limit?.resetsAt === undefined) return text
173 const label = resetLabel(limit.resetsAt)
174 return `${text} · resets ${kind === 'five_hour' ? label.replace(/^\d{4}-\d{2}-\d{2} /, '') : label}`
175}
176
177const order = (kind: string) => WINDOWS.findIndex(w => w.kind === kind)
178
179// Records crossings since the last check as pending events, one per window, the newest kept.
180// Use only rises within a window, so a dip is reporting noise: a window's level falls, and its
181// lines re-arm, only when the window resets (its reset time passes) or leaves the reading.
182// Returns the events the person is told of: a rise from a known level, or a reset from the edge.
183// A window's first reading is never one, so a fresh load or the reading after a reset stays quiet.
184export const recordCrossings = (st: State, reading: Reading, cfg: Config, nowMs: number) => {
185 const changes: [string, Event][] = []
186 for (const { kind } of WINDOWS) {
187 const limit = reading.get(kind)
188 let prev = st.levels.get(kind)
189 if (prev !== undefined && (limit === undefined || prev.resetsMs <= nowMs)) {
190 if (prev.level === 'edge') {
191 st.pending.set(kind, { event: 'reset', limit })
192 changes.push([kind, 'reset'])
193 } else st.pending.delete(kind)
194 st.levels.delete(kind)
195 prev = undefined
196 }
197 if (limit === undefined) continue
198 const level = levelOf(limit.percentUsed, cfg)
199 const resetsMs = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
200 if (prev !== undefined && RANK[level] <= RANK[prev.level]) continue
201 st.levels.set(kind, { level, resetsMs: Number.isFinite(resetsMs) ? resetsMs : Infinity, limit })
202 if (level === 'quiet') continue
203 st.pending.set(kind, { event: level, limit })
204 if (prev !== undefined) changes.push([kind, level])
205 }
206 return changes
207}
208
209// The events due now, one per window, without consuming them.
210const dueEvents = (st: State): [string, Due][] => {
211 // After /clear or a fresh load mid-session, only a window at the edge is restated; after a
212 // compaction, a resume or a /branch, each window past quiet. A quiet window is never restated.
213 // A restated level carries the reading that crossed into it.
214 const recorded = ([kind, w]: [string, { level: Level; limit: SessionRateLimit }]): [string, Due] => [kind, { event: w.level, limit: w.limit }]
215 const atEdge = st.restateIfLoud ? [...st.levels].filter(([, w]) => w.level === 'edge').map(recorded) : []
216 return st.restate
217 ? [...st.levels].filter(([kind, w]) => w.level !== 'quiet' && st.reading?.has(kind)).map(recorded)
218 : [...new Map<string, Due>([...st.pending.entries(), ...atEdge])]
219}
220
221const dueLines = (st: State, cfg: Config): string[] =>
222 dueEvents(st)
223 .sort(([a], [b]) => order(a) - order(b))
224 .map(([kind, due]) => `rate-limit-guard: ${clause(kind, due.event, due.limit, cfg)}.`)
225
226// Appends lines to what Claude reads and writes each to the debug log, so the log holds what Claude was told.
227const withLines = <T extends { context?: readonly string[] }>($: EngineInterface, e: T, lines: readonly string[]): T => {
228 for (const line of lines) $.ui.log(line, { to: 'debug' })
229 return lines.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...lines] }
230}
231
232const consume = (st: State) => {
233 st.pending.clear()
234 st.restate = false
235 st.restateIfLoud = false
236 st.forceAutomatic = false
237}
238
239const isPersonTurn = (st: State) => st.origin !== undefined && PERSON_ORIGINS.includes(st.origin.kind)
240
241async function operatorHolds($: EngineInterface, st: State, cfg: Config) {
242 if (!cfg.operator || st.forceAutomatic || !isPersonTurn(st)) return false
243 return (await $.session.surfaces()).length > 0
244}
245
246async function refresh($: EngineInterface, st: State, cfg: Config, limits?: readonly SessionRateLimit[]) {
247 const [now, rateLimits] = await Promise.all([$.clock.now(), limits ?? $.session.usage().then(u => u.rateLimits)])
248 const reading = liveWindows(rateLimits, now)
249 const changed = bandText(reading) !== bandText(st.reading)
250 st.reading = reading
251 st.limits = rateLimits
252 st.spend = rateLimits.find(l => l.kind === 'spend_limit')
253 if (changed) $.ui.invalidate('ui.render')
254 // No window reported is no reading, never a reset: the levels wait for the next reading.
255 if (rateLimits.some(l => WINDOWS.some(w => w.kind === l.kind))) st.toastQueue.push(...recordCrossings(st, reading, cfg, now))
256 return { now, reading }
257}
258
259function clearNotice($: EngineInterface, st: State, kind?: Notice['kind']) {
260 if (st.notice === undefined || (kind !== undefined && st.notice.kind !== kind)) return
261 st.notice = undefined
262 st.reofferTimer = stopTimer(st.reofferTimer)
263 $.ui.invalidate('ui.render')
264}
265
266// The lines a carrier attaches now, consumed; none when none is due or operator mode holds them.
267// Lines sent to Claude supersede any notice still offering them to the person.
268async function takeLines($: EngineInterface, st: State, cfg: Config): Promise<string[]> {
269 if (!cfg.lines) {
270 consume(st)
271 return []
272 }
273 if (await operatorHolds($, st, cfg)) return []
274 const lines = dueLines(st, cfg)
275 // A restatement with no reading to restate waits for the first carrier that has one.
276 if (st.restate && (st.reading?.size ?? 0) === 0) return []
277 consume(st)
278 if (lines.length > 0 && st.notice?.kind === 'operator') {
279 releaseHeld(st, st.rowSeen)
280 clearNotice($, st)
281 }
282 return lines
283}
284
285// Ends the operator notice's hold on its changes: dropped when the person saw them, otherwise
286// queued for the next flush.
287function releaseHeld(st: State, seen: boolean) {
288 if (!seen) st.toastQueue.unshift(...st.heldToasts)
289 st.heldToasts = []
290}
291
292// Tells the person of each queued window change: a transcript line always, a toast when the option
293// allows. Run after a carrier's lines are built and never throws, so a failing toast drops no line.
294// While operator mode holds the lines the queue waits: a shown suggestion or the person's next
295// prompt drops it, and a suggestion that cannot show leaves it for the carrier that sends the line.
296// `held` is the carrier's answer from before its lines were taken, which may end a forced turn.
297async function flushToasts($: EngineInterface, st: State, cfg: Config, held?: boolean) {
298 try {
299 if (st.toastQueue.length === 0) return
300 if (held ?? (cfg.lines && (await operatorHolds($, st, cfg)))) return
301 const changes = st.toastQueue.splice(0).sort(([a], [b]) => order(a) - order(b))
302 const bodies: string[] = []
303 for (const [kind, event] of changes) {
304 const body = toastBody(kind, event, st.reading?.get(kind), cfg)
305 bodies.push(body)
306 $.ui.log(`rate-limit-guard: ${body} · more: /${COMMAND}`, { to: 'transcript' })
307 if (cfg.toast) $.ui.toast(body)
308 }
309 if (st.notice?.kind !== 'operator') {
310 st.notice = { text: `rate-limit-guard: ${bodies.join('; ')} · more: /${COMMAND}`, kind: 'crossing' }
311 $.ui.invalidate('ui.render')
312 }
313 } catch (error) {
314 logOnce($, st, 'flush-failed', `window change not shown: ${error instanceof Error ? error.message : String(error)}`)
315 }
316}
317
318const noticeShows = (st: State, surface: string) =>
319 st.notice !== undefined && (st.notice.kind === 'operator' || surface !== 'terminal')
320
321// The module's band row: 5h <x>% | 7d <y>%.
322export const bandText = (reading: Reading | undefined) =>
323 WINDOWS.map(({ kind, short }) => {
324 const limit = reading?.get(kind)
325 return `${short} ${limit === undefined ? '-' : `${limit.percentUsed}%`}`
326 }).join(' | ')
327
328// What /rate-limit-guard with no argument prints.
329async function statusText($: EngineInterface, st: State, cfg: Config) {
330 await refresh($, st, cfg)
331 const windows = WINDOWS.map(({ kind, name }) => {
332 const limit = st.reading?.get(kind)
333 if (limit === undefined) return `${name} window: no reading`
334 const reset = limit.resetsAt === undefined ? '' : `, resets at ${resetLabel(limit.resetsAt)}`
335 return `${name} window: ${limit.percentUsed}% used, ${modelVerdict(levelOf(limit.percentUsed, cfg), cfg)}${reset}`
336 })
337 const home = await homeDir($)
338 const snapshot = !cfg.writes
339 ? 'off (rate_limit_guard_enabled is false)'
340 : home
341 ? `${home}/.claude/${CONTRACT_DIR}/${SNAPSHOT_FILE}`
342 : 'no home directory to write under'
343 return [
344 'From the last API response:',
345 ...windows,
346 ...(st.spend ? [`Spend limit: ${st.spend.percentUsed}% used`] : []),
347 `Line threshold ${cfg.threshold}%, approach mark ${cfg.approach}%.`,
348 `Band row ${st.bandShown ? 'on' : 'off'}, window-change toast ${cfg.toast ? 'on' : 'off'}. Set the row with /${COMMAND} band [on|off].`,
349 `Snapshot: ${snapshot}`,
350 `README: ${README}`,
351 ].join('\n')
352}
353
354const isoSeconds = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
355
356export const isEmailShaped = (value: unknown): value is string => {
357 if (typeof value !== 'string') return false
358 const points = [...value].map(c => c.codePointAt(0) ?? 0)
359 return points.length >= 3 && points.length <= 254 && value.includes('@') && !points.some(p => p < 32 || p === 34 || p === 92 || p === 127)
360}
361
362const homeDir = async ($: EngineInterface) => (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
363
364// The account in the state file, undefined when unreadable or malformed. The file is large and
365// rewritten often, so a parse is kept until the file's mtime or size changes.
366async function readIdentity($: EngineInterface, st: State, home: string) {
367 const path = `${(await $.env.get('CLAUDE_CONFIG_DIR')) || home}/.claude.json`
368 const stat = await $.fs.stat(path).catch(() => undefined)
369 if (stat === undefined || stat.kind !== 'file') return undefined
370 const key = `${path}|${stat.mtimeMs}|${stat.size}`
371 if (st.identityCache?.key === key) return st.identityCache.email
372 const text = await $.fs.read(path).catch(() => undefined)
373 if (typeof text !== 'string') return undefined
374 let email: string | undefined
375 try {
376 const value: unknown = JSON.parse(text)?.oauthAccount?.emailAddress
377 email = isEmailShaped(value) ? value : undefined
378 } catch {}
379 st.identityCache = { key, email }
380 return email
381}
382
383// An API response: its time, and the account it was for.
384async function recordResponse($: EngineInterface, st: State, cfg: Config) {
385 st.lastResponseAtMs = await $.clock.now()
386 st.responseEmail = undefined
387 const home = await homeDir($)
388 if (cfg.writes && home) st.responseEmail = await readIdentity($, st, home)
389}
390
391// Omit rather than guess: attribute only when the account now is the one read at the last response.
392async function accountEmail($: EngineInterface, st: State, home: string) {
393 if (st.responseEmail === undefined) return undefined
394 const email = await readIdentity($, st, home)
395 return email !== undefined && email === st.responseEmail ? email : undefined
396}
397
398export const snapshotBody = (capturedAt: string, sessionId: string, reading: Reading, email?: string): Body => {
399 const windows = [...reading.entries()].map(([kind, l]) => {
400 const resetsMs = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
401 const resets = Number.isFinite(resetsMs) ? { resets_at: Math.floor(resetsMs / 1000) } : {}
402 return [kind, { used_percentage: l.percentUsed, ...resets }] as const
403 })
404 return {
405 captured_at: capturedAt,
406 session_id: sessionId,
407 ...(windows.length > 0 ? { rate_limits: Object.fromEntries(windows) } : {}),
408 ...(email ? { account: { email } } : {}),
409 }
410}
411
412const wholePoints = (body: Partial<Body> | undefined) =>
413 JSON.stringify(
414 Object.entries(body?.rate_limits ?? {})
415 .sort(([a], [b]) => a.localeCompare(b))
416 .map(([kind, w]) => [kind, Math.floor(Number(w?.used_percentage)), w?.resets_at ?? null]),
417 )
418
419const logOnce = ($: EngineInterface, st: State, key: string, text: string, to: 'debug' | 'transcript' = 'debug') => {
420 if (st.loggedOnce.has(key)) return
421 st.loggedOnce.add(key)
422 $.ui.log(`rate-limit-guard: ${text}`, { to })
423}
424
425// A bad option is the person's to fix, so its line goes to the transcript, once per load.
426const reportOptions = ($: EngineInterface, st: State, cfg: Config) => {
427 for (const line of cfg.bad) logOnce($, st, line, line, 'transcript')
428}
429
430// Decides in memory whether to write, so an event that writes nothing starts no process.
431async function writeSnapshot($: EngineInterface, st: State, cfg: Config, trigger: 'event' | 'timer') {
432 if (!cfg.writes || st.origin?.kind === 'task-notification' || st.reading === undefined) return
433 const home = await homeDir($)
434 if (!home) return
435 const target = `${home}/.claude/${CONTRACT_DIR}/${SNAPSHOT_FILE}`
436 const [now, sessionId] = await Promise.all([$.clock.now(), $.session.id()])
437 const email = await accountEmail($, st, home)
438 const body = snapshotBody(isoSeconds(now), sessionId, st.reading, email)
439 const onDisk = await $.fs
440 .read(target)
441 .then(text => JSON.parse(String(text)) as Partial<Body>)
442 .catch(() => undefined)
443 const diskAt = onDisk?.captured_at === undefined ? NaN : Date.parse(onDisk.captured_at)
444 if (trigger === 'timer' && onDisk !== undefined && onDisk.session_id !== sessionId && !(diskAt < (st.lastResponseAtMs ?? 0))) {
445 return
446 }
447 // After a branch the file takes the new session id at once: the id is part of what a reader trusts.
448 const moved =
449 onDisk === undefined || wholePoints(onDisk) !== wholePoints(body) || (st.branched && onDisk.session_id !== sessionId)
450 if (!moved && Number.isFinite(diskAt) && now - diskAt < FLOOR_MS) return
451 const sig = JSON.stringify({ ...body, captured_at: undefined })
452 if (st.lastAttempt !== undefined && st.lastAttempt.sig === sig && now - st.lastAttempt.at < FLOOR_MS) return
453 const argv = ['node', `${$.plugin.root}/${HELPER}`, target, '--preserve-key', 'rate_limits', ...(moved ? [] : ['--floor', '300'])]
454 // Only a write the helper decided (written, or skipped by rule) dedupes; a failed one is tried at the next carrier.
455 try {
456 const run = await $.process.run(argv, { stdin: JSON.stringify(body), timeoutMs: 10_000 })
457 if (run.exitCode === 0 || run.exitCode === 3) {
458 st.lastAttempt = { sig, at: now }
459 st.branched = false
460 }
461 else logOnce($, st, 'write-failed', `snapshot write failed (exit ${run.exitCode}): ${run.stderr.trim()}`)
462 } catch (error) {
463 logOnce($, st, 'write-threw', `snapshot write did not run: ${error instanceof Error ? error.message : String(error)}`)
464 }
465}
466
467function queueWrite($: EngineInterface, st: State, cfg: Config, trigger: 'event' | 'timer') {
468 st.writing = st.writing.then(() => writeSnapshot($, st, cfg, trigger)).catch(() => undefined)
469 return st.writing
470}
471
472async function statusJson($: EngineInterface, st: State, cfg: Config) {
473 await refresh($, st, cfg)
474 // Every window the response reported except a gateway's spend limit, which has its own entry.
475 const now = await $.clock.now()
476 const live = st.limits.filter(l => l.kind !== 'spend_limit' && !(Date.parse(l.resetsAt ?? '') <= now))
477 const windows = Object.fromEntries(
478 live.map(l => [l.kind, { used_percentage: l.percentUsed, resets_at: l.resetsAt ?? null, verdict: levelOf(l.percentUsed, cfg) }]),
479 )
480 const levels = live.map(l => levelOf(l.percentUsed, cfg))
481 return JSON.stringify({
482 source: 'the last API response',
483 windows,
484 verdict: levels.length === 0 ? 'unknown' : levels.includes('edge') ? 'edge' : levels.includes('approach') ? 'approach' : 'quiet',
485 line_threshold: cfg.threshold,
486 approach_pct: cfg.approach,
487 lanes_pause_edge: PAUSE_EDGE,
488 ...(st.spend ? { spend_limit: { used_percentage: st.spend.percentUsed, resets_at: st.spend.resetsAt ?? null } } : {}),
489 })
490}
491
492// Operator mode: offer the line as the prompt box's suggestion; with text in the box, show the
493// notice row and offer again once the box is empty; where it cannot show, the line goes to Claude.
494// A first offer that cannot show leaves the changes to be toasted with the automatic line; a
495// re-offer comes after the row was up, so the person has seen them unless a survey hid the row.
496async function offer($: EngineInterface, st: State, first = false) {
497 if (st.notice?.kind !== 'operator') return true
498 const { text } = st.notice
499 const box = await $.prompt.read()
500 if (box.text.trim() !== '') return false
501 const { isShown } = await $.prompt.suggest({ text })
502 if (isShown) {
503 st.handoff = new Map([...st.handoff, ...dueEvents(st)])
504 consume(st)
505 releaseHeld(st, true)
506 } else {
507 releaseHeld(st, !first && st.rowSeen)
508 st.notice = undefined
509 st.forceAutomatic = true
510 }
511 $.ui.invalidate('ui.render')
512 return true
513}
514
515function stopTimer(timer: Timer | undefined) {
516 timer?.cancel()
517 return undefined
518}
519
520export const register: Register = (on, options) => {
521 const cfg = parseConfig(options)
522 const st: State = {
523 reading: undefined,
524 spend: undefined,
525 levels: new Map(),
526 limits: [],
527 pending: new Map(),
528 toastQueue: [],
529 heldToasts: [],
530 rowSeen: false,
531 handoff: new Map(),
532 restate: false,
533 restateIfLoud: false,
534 branched: false,
535 forceAutomatic: false,
536 origin: undefined,
537 notice: undefined,
538 bandShown: cfg.band,
539 lastResponseAtMs: undefined,
540 responseEmail: undefined,
541 identityCache: undefined,
542 lastAttempt: undefined,
543 loggedOnce: new Set(),
544 writing: Promise.resolve(),
545 writeTimer: undefined,
546 reofferTimer: undefined,
547 }
548
549 on('session.start', async ($, e, next) => {
550 reportOptions($, st, cfg)
551 const [tool] = await Promise.allSettled([
552 $.tool.register({
553 name: 'status',
554 description:
555 "Returns this session's plan rate-limit usage as JSON, from the last API response: `windows` keyed by kind (five_hour, seven_day, and any other window reported except the spend limit), each with `used_percentage`, `resets_at` and `verdict`; an overall `verdict`, the worst window's (`quiet`, `approach` at or above `approach_pct`, `edge` at or above `line_threshold`, or `unknown` when no window is reported); those two thresholds and `lanes_pause_edge`; and `spend_limit` when a gateway reports one. A window whose reset time has passed is left out. The figures change only when an API response arrives. By default rate-limit-guard also adds a line to the next prompt or tool result when the 5-hour or 7-day window rises to approach or edge, or resets from edge. Read-only.",
556 inputSchema: { type: 'object', properties: {}, additionalProperties: false },
557 }),
558 $.command.register({
559 name: 'rate-limit-guard',
560 description: 'Rate-limit windows and verdicts; band on or off sets the band row for this session',
561 argumentHint: '[band [on|off]]',
562 }),
563 ])
564 if (tool.status === 'rejected') {
565 const reason = tool.reason instanceof Error ? tool.reason.message : String(tool.reason)
566 logOnce($, st, 'tool-register', `the status pull tool could not register: ${reason}`)
567 }
568 await refresh($, st, cfg)
569 // A fresh load mid-session (a reload, a worker respawn, an enable, a --resume launch): the
570 // earlier lines already reached Claude, so only a window at the edge is restated.
571 if ((await $.session.turns()) > 0) {
572 st.pending.clear()
573 st.restateIfLoud = true
574 }
575 return next(e)
576 }).catch(($, e, next) => next(e))
577
578 on('session.end', async ($, e, next) => {
579 st.writeTimer = stopTimer(st.writeTimer)
580 st.reofferTimer = stopTimer(st.reofferTimer)
581 await queueWrite($, st, cfg, 'event')
582 if (e.reason === 'resume') st.restate = st.branched = true
583 if (e.reason === 'clear') st.restateIfLoud = true
584 st.origin = undefined
585 return next(e)
586 }).catch(($, e, next) => next(e))
587
588 on('session.compact', async ($, e, next) => {
589 const result = await next(e)
590 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result && result.skip)) st.restate = true
591 return result
592 }).catch(($, e, next) => next(e))
593
594 on('session.measure', async ($, e, next) => {
595 reportOptions($, st, cfg)
596 await recordResponse($, st, cfg).catch(() => undefined)
597 await refresh($, st, cfg, e.rateLimits)
598 await queueWrite($, st, cfg, 'event')
599 await flushToasts($, st, cfg)
600 return next(e)
601 }).catch(($, e, next) => next(e))
602
603 on('turn.step', async function* ($, e, next) {
604 const result = yield* next(e)
605 await recordResponse($, st, cfg).catch(() => undefined)
606 return result
607 }).catch(async function* ($, e, next) {
608 return yield* next(e)
609 })
610
611 on('prompt.submit', async ($, e, next) => {
612 reportOptions($, st, cfg)
613 if (e.turnId === undefined) {
614 st.origin = e.origin
615 // An untaken suggestion goes to Claude at the next turn no person started; a person's turn drops it.
616 const handingOff = !isPersonTurn(st) && st.handoff.size > 0
617 if (handingOff) {
618 for (const [kind, due] of st.handoff) {
619 if (!st.pending.has(kind) && (due.event === 'reset' || st.levels.has(kind))) st.pending.set(kind, due)
620 }
621 }
622 st.handoff.clear()
623 if (isPersonTurn(st) && st.notice?.kind === 'operator') releaseHeld(st, st.rowSeen)
624 if (isPersonTurn(st) || handingOff) clearNotice($, st)
625 }
626 await refresh($, st, cfg)
627 const held = cfg.lines && (await operatorHolds($, st, cfg))
628 const lines = await takeLines($, st, cfg)
629 await flushToasts($, st, cfg, held)
630 return next(withLines($, e, lines))
631 }).catch(($, e, next) => next(e))
632
633 on('turn.start', async ($, e, next) => {
634 st.reofferTimer = stopTimer(st.reofferTimer)
635 st.writeTimer ??= $.clock.every(WRITE_TIMER_MS, () => {
636 void queueWrite($, st, cfg, 'timer')
637 })
638 return next(e)
639 }).catch(($, e, next) => next(e))
640
641 on('turn.complete', async ($, e, next) => {
642 if (e.agentId !== undefined) return next(e)
643 st.writeTimer = stopTimer(st.writeTimer)
644 if (cfg.lines && (await operatorHolds($, st, cfg))) {
645 await refresh($, st, cfg)
646 await flushToasts($, st, cfg)
647 const lines = dueLines(st, cfg)
648 if (lines.length > 0) {
649 // One row, one prefix, however many windows it offers.
650 st.notice = { text: `FYI, rate-limit-guard: ${lines.map(l => l.replace(/^rate-limit-guard: /, '')).join(' ')}`, kind: 'operator' }
651 st.heldToasts.push(...st.toastQueue.splice(0))
652 st.rowSeen = false
653 $.ui.invalidate('ui.render')
654 if (!(await offer($, st, true))) {
655 st.reofferTimer ??= $.clock.every(REOFFER_MS, () => {
656 void offer($, st)
657 .then(done => {
658 if (done) st.reofferTimer = stopTimer(st.reofferTimer)
659 })
660 .catch(() => undefined)
661 })
662 }
663 }
664 }
665 return next(e)
666 }).catch(($, e, next) => next(e))
667
668 on('tool.call', async ($, e, next) => {
669 if (e.tool === `mcp__${$.plugin.name}__status`) {
670 const json = await statusJson($, st, cfg)
671 await flushToasts($, st, cfg)
672 return { result: json }
673 }
674 reportOptions($, st, cfg)
675 const result = await next(e)
676 if (e.agentId !== undefined) return result
677 await refresh($, st, cfg)
678 await queueWrite($, st, cfg, 'event')
679 if (result.deny !== undefined || result.isError) return result
680 const held = cfg.lines && (await operatorHolds($, st, cfg))
681 const lines = await takeLines($, st, cfg)
682 await flushToasts($, st, cfg, held)
683 return withLines($, result, lines)
684 }).catch(($, e, next) => next(e))
685
686 on('command.run', { command: 'rate-limit-guard' }, async ($, e, next) => {
687 const words = (e.args ?? '').trim().toLowerCase().split(/\s+/).filter(w => w !== '')
688 if (words.length === 0) {
689 const text = await statusText($, st, cfg)
690 await flushToasts($, st, cfg)
691 return { text }
692 }
693 const [word, arg, ...rest] = words
694 if (word !== 'band' || rest.length > 0 || (arg !== undefined && arg !== 'on' && arg !== 'off')) return { text: USAGE }
695 st.bandShown = arg === undefined ? !st.bandShown : arg === 'on'
696 $.ui.invalidate('ui.render')
697 return { text: `Band row ${st.bandShown ? 'on' : 'off'} for this session` }
698 }).catch(($, e, next) => next(e))
699
700 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
701 const theirs = await next(e)
702 const notice = noticeShows(st, e.surface) ? st.notice : undefined
703 if (e.props.hasSurvey || (!st.bandShown && notice === undefined)) return theirs
704 if (notice?.kind === 'operator') st.rowSeen = true
705 const { Box, Text } = $.ui.resolve(e)
706 return (
707 <Box flexDirection="column">
708 {st.bandShown ? (
709 <Text dimColor wrap="truncate">
710 {bandText(st.reading)}
711 </Text>
712 ) : null}
713 {notice !== undefined ? <Text wrap="wrap">{notice.text}</Text> : null}
714 {theirs}
715 </Box>
716 )
717 }).catch(($, e, next) => next(e))
718}
719