A Claude Code plugin that makes the machine's shared subscription rate-limit windows observable to every session that needs them, so autonomous loop lanes can pause before hitting a limit and resume on their own after the reset. Four parts:
- Statusline shim (
scripts/statusline-shim.sh), the durable wiring target. Installed once to~/.claude/rate-limit-guard/bin/, it resolves whichever tee version is installed at run time, so a plugin update never requires re-wiring and an uninstall degrades to your statusline running alone. Pure Bash builtins: it adds no measurable time to a refresh. - Statusline tee (
scripts/statusline-tee.sh), a transparent wrapper around your statusline command. It atomically writes the session'srate_limits(both the 5-hour and 7-day windows), acaptured_attimestamp, and the session-distinguishing fields to the fixed machine-scope contract path~/.claude/rate-limit-guard/rate-limits.json, once per drain cadence and only when the payload changed or the no-change floor expired, then passes your statusline through byte-for-byte. With no statusline configured it doubles as a minimal standalone statusline. - 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 tee path, the 90%-of-either-window pause threshold, the staleness rule, pause-end semantics, capability-detect fail-open, and drain-then-pause.
- Transparent by contract. No tee outcome ever changes the wrapped statusline's output or
exit code, including missing
jq, an unwritable path, or a rename blocked by a concurrent reader. Missingjqis surfaced as a visible one-line notice, never a silent skip. - 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).
- Fail-open capability detection. Sessions whose auth exposes no
rate_limits(API-key, enterprise) tee an honest snapshot without the key; consumers treat that as unknown and run reactive-only rather than throttling on fabricated data. Cloud / remote sessions with no statusline producer typically have no tee file at all. That is the same unknown → reactive-only classification, documented as the expected degraded mode inreference/reader-contract.md("Cloud / remote sessions"), with a documented residual that a live cloud producer is out of scope until one exists. - An unchanged payload costs no rename. When a refresh's snapshot body matches what is already
on disk,
captured_ataside, it takes no lock, stages no temp file and performs no rename. So an unchanged payload can leave the file,captured_atincluded, untouched for up to the skip floor: 300 seconds by default (RLG_TEE_NOCHANGE_FLOOR), measured from the last real write by a.last-writestamp in the contract directory. That is half the reader contract's 10-minute staleness budget, so a session whose windows sit still still refreshescaptured_atwell before a reader could call it stale, and a changed payload is written on the next refresh regardless. The spool's 15-minute record sweep runs on the same 5-minute cadence rather than on every drain, tracked byspool/.last-sweep. Both stamps are writer-private: they hold epoch seconds, carry no session data, and readers ignore them. Every tuning knob the tee reads from the environment is validated as a plain integer before it reaches bash arithmetic and falls back to its default otherwise. The render path itself has been fork-free since the spool landed, which the suite asserts directly by tracing a non-elected render (statusline-tee.test.sh, "the zero-fork render path"). - Multi-account operation is a narrowed gap, not yet a supported mode. The snapshot names the
account whose windows it carries in an
account.emailfield, so a machine switching accounts mid-drain is now visible to a reader that checks it. Two things keep it a gap. The field is absent whenever the writer could not attribute the observation. That covers four cases: no state file, no email-shaped value, a stdinaccount*key that wins instead, and a state file that is not strictly older than the chosen record's spool file, which means a switch may have happened in between. No consuming lane acts on the field yet either. An equal timestamp counts as "not strictly older": mtime resolution is coarse on several filesystems the writer runs on, so a same-tick login is indistinguishable there from a later one, and the writer omits rather than guess. The loop-lane convention §6 owns that framing; the reader contract states the four absence cases and the untrusted-value rule (reference/reader-contract.md, "Tee file shape"). The wrapper still forwards a harness-supplied identity automatically when its own top-level key name containsaccount, and that value always wins over the writer's.
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install rate-limit-guard@<marketplace>The StopFailure hook is active immediately. The statusline tee needs two operator steps, both one-time:
/rate-limit-guard:setup applyinstalls the statusline shim to~/.claude/rate-limit-guard/bin/statusline-shim.sh. The shim is inert until step 2./rate-limit-guard:setup checkverifies prerequisites and prints the exactsettings.jsonstatusline edit (wrapping your existing command, or standalone) for you to apply. The plugin never edits your settings itself.
You wire the shim, not the tee, and that wiring is permanent: ${CLAUDE_PLUGIN_ROOT} is
version-pinned and the old version directory is pruned ~14 days after an update, so a statusline
wired straight to <plugin-root>/scripts/statusline-tee.sh silently stops teeing on the next
version bump and then takes the whole statusline down when the path disappears. The shim resolves
the newest installed tee at run time, so plugin updates need no re-wiring, and it passes your
statusline through unchanged when no tee is installed (including after uninstall).
Running alongside context-guard? The tees are transparent wrappers, so they nest, each through
its own shim, with the innermost command still owning stdout and the exit code. Both setup skills
print that combined form.
The scripts run on Bash (Git Bash on native Windows; install
Git for Windows; the statusline wiring
invokes bash explicitly) and need jq on PATH for the tee and
the standalone statusline. Proactive window data requires Claude.ai subscription auth (Pro/Max).
rate_limits appears only there, per the
statusline reference; on other auth the guard is
reactive-only. The tee updates only while an interactive session refreshes the statusline.
Cost: the tee adds roughly 0.6–0.9 s per statusline refresh on Windows/Git Bash (process-spawn
bound, jq and date), and correspondingly less on native POSIX shells. The statusline is not on
the input path, so this is display latency, not typing latency; refreshInterval in your settings
governs how often it runs.
One userConfig option:
| Option | What it controls |
|---|---|
rate_limit_guard_enabled |
Kill switch for the StopFailure detection hook and the statusline tee's snapshot write (default true). |
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.
Where the switch is read from. The StopFailure hook receives it the ordinary way, as
CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED. The statusline tee cannot: it is invoked by
absolute path from the operator's own statusLine setting, and Claude Code exports those variables
to hook processes only, so the tee reads pluginConfigs from the settings files directly. Its
precedence is managed settings → user settings → environment, highest first. A managed
rate_limit_guard_enabled: false is an organization's policy and wins over any user value. Every
degraded read (no settings file, no jq, malformed JSON) leaves the tee enabled, and no outcome of
that gate ever changes what your statusline prints.
The tee path and the 90% 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 the tee's write while leaving the wrapper
transparent; removing the wrapper itself is the operator's edit to their statusLine; disabling
everything is enabledPlugins / uninstall.
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 |
Master switch for the StopFailure detection hook and the statusline tee's snapshot write; read from managed settings first, then user settings |
Three supported routes, in the order most people want them:
-
Interactively. Claude Code prompts for declared options when you enable the plugin. To change them later:
/plugin configure rate-limit-guard@<marketplace>. -
Headless. Repeat
--configfor 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 installedand still writes the value. The short-circuit message is about the install, not the config write. Do notclaude plugin uninstallto reconfigure: uninstalling drops this plugin's whole storedpluginConfigsentry, resetting every option in the table above to its default.-sdefaults touser, so pass the scopeclaude plugin listreports 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. -
By hand, in settings. Add the value under
pluginConfigsin 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'senabledPluginsinstead 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.
- User configuration: the
userConfigschema and theCLAUDE_PLUGIN_OPTION_<KEY>export - Plugin install options: the
--configflag's reference entry - Plugins and skills settings:
enabledPlugins,extraKnownMarketplaces,pluginConfigs - Settings files and who they affect: user vs project vs local precedence
- Manage installed plugins: enabling, disabling,
/plugin list
Written for the loop-lane convention's three lanes (work-items work-loop and attend-queue,
source-control babysit-loop), which inline the reader contract's operable floor. Any session or
tool on the machine may read the same files under the same contract.
Measured 2026-09-02 on Windows/MSYS with Git Bash, both tees interleaved in one loop so each meets
the same machine load. Two lanes matter and they cost very different amounts: the steady render,
which every refresh pays, and the drain, which one elected refresh per cadence pays for the whole
machine. The floor is one bash -c : spawn, 45 ms and 57 ms on the two runs below, and a
spawn-equivalent is the tee's cost over the bare render divided by that floor.
| Lane | Before | After |
|---|---|---|
| Drain, external commands | 7 | 3 |
| Drain, total process creations | 10 | 5 |
| Drain, spawn-equivalents | 8 to 10 | 5 to 6 |
| Steady render, spawn-equivalents | 1.2 to 1.3 | 1.1 to 1.5 |
The drain loses the rename, the snapshot lock's mkdir and rmdir, and the sweep's find. The
process-creation row counts the pure-bash forks the external-command row does not: the managed-scope
probe's two, unchanged, and the atomic write's umask subshell, which disappears along with the
write it wraps. The steady render is unchanged within measurement noise and sits well inside the
2 spawn-equivalent bar, because the spool had already reduced it to zero external processes.
Not a plugin option. A plain environment variable the statusline tee reads directly, so it sits outside the generated block above: Claude Code does not prompt for it and no settings scope carries it.
| Variable | Default | Effect |
|---|---|---|
RLG_TEE_ASYNC |
0 (off) |
1 detaches the snapshot write from the render, so the status line does not wait for it |
The snapshot is a side effect: nothing the status line prints depends on it, and the reader contract budgets ten minutes of staleness. Detaching skips no work of its own. A refresh still takes the lock and writes unless the bounded no-change skip applies. Detaching only stops the render waiting.
Whether that helps depends on how many sessions you keep open, not on your refresh interval.
MSYS has no native fork(), so forking a bash subshell holding the payload costs 75–200 ms on
Windows, against ~24 ms to exec a small binary. Detaching does not make the work cheaper; it buys
back the render's critical path by paying a fork more expensive than the execs it steps around,
and it lets successive refreshes overlap instead of serialise. Measured on Windows, 24 cores,
status line + tee:
| sync (default) | async | |
|---|---|---|
| One session, refreshes 1 s apart | 660 ms | 222 ms |
| Ten sessions at 1 Hz, median | 2265 ms | 4476 ms |
Ten sessions, peak bash processes |
50 | 71 |
Turn it on if you work in one or two windows. Leave it off if you run many. At ten sessions it
roughly doubles both latency and process count. On Linux and macOS, where fork() is cheap, the
crossover sits much further out; the numbers above are the Windows worst case.
MIT (SPDX-License-Identifier: MIT).