SLOPSHOPPER

rate-limit-guard

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…

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rate-limit-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rate-limit-guard ⎿ rate-limit-guard: From the last API response: ⎿ rate-limit-guard: 5-hour window: 31% used, below 95%, resets at 2025-10-09 09:53 UTC ⎿ rate-limit-guard: 7-day window: no reading ⎿ rate-limit-guard: Line threshold 95%, approach mark 90%. ⎿ rate-limit-guard: Band row off, window-change toast on. Set the row with /rate-limit-guard band [on|off]. ⎿ rate-limit-guard: Snapshot: /Users/dev/.claude/rate-limit-guard/rate-limits.json ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

rate-limit-guard

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 module (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.
  • StopFailure hook (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.
  • Reader contract (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

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.

Lines to Claude

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:

WhenLine
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 thresholdonce; the approach and threshold lines can then fire again
After a compaction (not the precompute kind), and after /resume or /branchthe 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).

Window changes shown to you

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.

Operator mode

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.

  • Pointer: mods reference: render sites.
  • As of: 2026-10-03, Claude Code 2.1.288.
  • Recheck trigger: the 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).

The /rate-limit-guard command, band row and status tool

/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.

Snapshot writes

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.

Where mods are off

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.

Behavior

  • Atomic, last-writer-wins snapshot. Concurrent sessions write one path; readers never see torn JSON (temp file + rename, with a brief retry for the Windows rename-over-open-target case). The helper takes a short lock, refuses an older 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.
  • Fail-open capability detection. Sessions whose auth exposes no subscription windows (API-key, enterprise) write an honest snapshot without 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.
  • An unchanged reading costs no process. The module compares each reading with the last one it wrote and with the 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.
  • Multi-account operation is a narrowed gap. The snapshot names the account whose windows it carries in an 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").

Tests and their budgets

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 testReplacement
The tee suite: snapshot shape, account, no-change floor, enablementthe snapshot:, account:, floor: and switch: tests in hooks/rate-limit-guard.test.ts
The tee suite: atomic write, windowless preservation, lockslib/write-snapshot.test.mjs
The tee suite: the zero-fork render tracethe budget: test, at the budget above
The bench lanes and their recorded process countsthe budget: test, at the budget above
The tee suite: transparency, spool and drain, async writes, the disabled markernone: the plugin no longer wraps a status line or spools
The shim and compose-script suitesnone: no shim ships; the setup skill's evals cover removing an old one

Install

/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.

Requirements

  • Claude Code 2.1.287 or later, with mods on, for the module. Below that, or with mods off, only the StopFailure hook runs.
  • Node.js on 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.
  • Bash for the StopFailure hook: on native Windows, Git for Windows, which hooks/exec-bash.mjs finds.
  • Claude.ai subscription auth (Pro/Max) for proactive window data. On other auth the guard is reactive-only.

/rate-limit-guard:check reports whether node resolves and whether the module can load, read-only. It installs nothing.

Configuration

The userConfig options:

OptionWhat it controls
rate_limit_guard_enabledKill 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_enabledThe module's lines to Claude, and the operator-mode suggestions (default true).
rate_limit_report_modeautomatic (default) or operator; see Operator mode.
rate_limit_line_thresholdWindow use for the threshold line (default 95).
rate_limit_approach_pctWindow use for the one approach line (default 90).
rate_limit_line_dataWhat a line carries beside its verdict: percent, window, reset (default verdict,percent,window,reset).
rate_limit_guard_bandThe band row (default false).
rate_limit_guard_toastThe 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.

  • Pointer: the doc comment on Register in the claude-code/index.d.ts types Claude Code writes for its build (see create: get the types for your build).
  • As of: 2026-10-03, Claude Code 2.1.288.
  • Recheck trigger: that doc comment stops saying an options change reloads the plugin.

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 -->

Options reference

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.

OptionTypeDefaultEnvironment variableDescription
rate_limit_guard_enabledbooleantrueCLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLEDTurns 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_enabledbooleantrueCLAUDE_PLUGIN_OPTION_RATE_LIMIT_LINES_ENABLEDSends 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_modestring"automatic"CLAUDE_PLUGIN_OPTION_RATE_LIMIT_REPORT_MODEautomatic (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_thresholdnumber95CLAUDE_PLUGIN_OPTION_RATE_LIMIT_LINE_THRESHOLDWindow 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_pctnumber90CLAUDE_PLUGIN_OPTION_RATE_LIMIT_APPROACH_PCTWindow 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_datastring"verdict,percent,window,reset"CLAUDE_PLUGIN_OPTION_RATE_LIMIT_LINE_DATAComma 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_bandbooleanfalseCLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_BANDDraws 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_toastbooleantrueCLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_TOASTShows 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.

How to set these

Three supported routes, in the order most people want them:

  1. Interactively. Claude Code prompts for declared options when you enable the plugin. To change them later: /plugin configure rate-limit-guard@<marketplace>.
  2. Headless. Repeat --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.

  1. By hand, in settings. Add the value under 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.

Upstream documentation

  • User configuration: the userConfig schema and the CLAUDE_PLUGIN_OPTION_<KEY> export
  • [Plugin install options](https://code.claude.com/
Source 1 files
hooks/register.tsx 719 lines
1import 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