Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/rate-limit-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "rate-limit-guard",
"version": "0.6.3",
"version": "0.7.0",
"description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.",
"author": {
"name": "Melodic Software",
Expand Down
82 changes: 77 additions & 5 deletions plugins/rate-limit-guard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,78 @@
All notable changes to the `rate-limit-guard` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.7.0]

### Changed

- **The statusline refresh no longer writes the snapshot; it records to a spool and one
elected refresh per 30 seconds flushes the batch.** Measured same-window here
(Windows/MSYS, n=9): `render.sh` alone 234.4 ms, `render.sh` behind this wrapper
1047.1 ms. The wrapper dominated, and the dominant term inside it was process
creation — a cost MSYS has no cheap primitive for, on a path that fires on every
assistant message AND every `refreshInterval` tick, once per open session. 0.6.x
made that work cheaper (nine spawns to four); this release takes it off the render
path instead. The common refresh now runs **zero external processes and zero
subshells**, asserted by an `xtrace` of a non-elected render rather than by reading
the code.

What a refresh does now: extract `session_id` by parameter expansion, strip CR/LF
(a JSON string cannot contain either, so this is lossless), stamp the epoch with
`printf -v '%(%s)T'`, and overwrite `~/.claude/rate-limit-guard/spool/<session>.json`
with one line. All builtins.

**Per-session files, not a shared append spool.** POSIX specifies atomicity for
concurrent writes to pipes up to `PIPE_BUF` and explicitly leaves regular-file
behaviour unspecified; through Cygwin/MSYS the observed no-interleave bound on
appends is around a kilobyte while statusline payloads are multiple kilobytes, and
bash's buffered builtin output can split one large record across syscalls anyway.
Atomicity therefore comes from **file disjointness** — no two writers ever share a
file, each record is one line written with a truncating `>` — instead of from an
argument about write sizes. A record torn by a kill mid-write fails `fromjson` in
the drain and is dropped, which is covered by a test.

**The filename is a shard key, never trusted data.** `session_id` arrives in the
harness payload; it must match `^[A-Za-z0-9._-]{1,64}$` and not begin with a dot, or
it shards to the literal name `misc`. Traversal attempts, embedded quotes,
200-character values and JSON nulls are all covered.

**Election is stamp-based, and the elected refresh drains in-process.** There is no
timer to hang this on: Claude Code hooks are strictly event-driven and none fires on
a schedule (<https://code.claude.com/docs/en/hooks.md>), an OS scheduler would mean
three mechanisms across three platforms, and a resident lock-holder would have to be
forked off a render — the exact cost being removed — and would be killed with it,
since Claude Code cancels in-flight statusline scripts. So the renders are the clock:
whichever finds `spool/.last-drain` older than the cadence takes `spool/.drain.lock`,
re-reads the stamp under it (a herd collapses for one failed `mkdir`), and flushes.
The drain uses its OWN lock rather than the snapshot lock, so `tee_snapshot` keeps
the concurrent-writer lock, the atomic temp-then-rename and the windowless-writer
preservation check exactly as they were.

**The snapshot body is byte-identical apart from `captured_at`**, proven by
`diff <(jq -S 'del(.captured_at)' pristine) <(jq -S 'del(.captured_at)' patched)`.
The body projection is now one shared jq function called by both the live probe and
the drain, so the two cannot drift. `captured_at` is the **observation time of the
chosen record**, never the flush time — which is what lets a windowless refresh
flush a window-bearing sibling's record without faking freshness.

**Reader-visible change, inside the existing contract:** the contract file now trails
the newest refresh on the machine by up to 30 seconds instead of being rewritten on
every refresh. The reader contract budgets ten minutes of staleness and its operable
floor values are unchanged; `reference/reader-contract.md` documents the cadence,
the `spool/` inventory and the `.tee-disabled` marker.

**The enablement gate still gates the write**, but it cannot be evaluated on the
render path — reading settings costs a `jq`. A drain that reads
`rate_limit_guard_enabled: false` writes an epoch-stamped `.tee-disabled` marker and
drops the spool; refreshes then stop recording on one builtin test. The marker
expires, so a re-enabled plugin recovers on its own without a restart.

Bash 4.2 is the floor (`%(%s)T` is a 4.2 builtin). Below it — macOS bash 3.2, where
`fork` is cheap and this problem does not arise — the previous synchronous path runs
untouched, and `RLG_TEE_ASYNC=1` keeps its current behaviour on every version.

All 75 pre-existing assertions pass unmodified; the suite is now 96.

## [0.6.3]

### Changed
Expand Down Expand Up @@ -92,11 +164,11 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format
by paying a fork more expensive than the execs it steps around, and it lets successive refreshes
overlap instead of serialise. Measured (Windows, 24 cores, statusline + 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 |
| | 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 |

Sessions, not refresh rate, is the variable that decides. Turn it on if you run one or two
windows; leave it off if you run many. The durable fix removes the cost instead of moving it —
Expand Down
47 changes: 37 additions & 10 deletions plugins/rate-limit-guard/reference/reader-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,15 @@ loop-lane convention (`docs/conventions/loop-lane/README.md` §6 in the marketpl

## Tee file shape

One JSON object, rewritten atomically on every statusline refresh (temp file + rename — a reader
never sees torn JSON; the file is **last-writer-wins** across all sessions on the machine):
One JSON object, rewritten atomically on a **drain cadence** rather than on every refresh (temp file

- rename — a reader never sees torn JSON; the file is **last-writer-wins** across all sessions on the
machine). Each refresh records its observation to a private per-session spool file with no external
process at all, and one elected refresh per cadence flushes the batch into this file, so the snapshot
trails the newest refresh on the machine by **at most 30 seconds**. That is well inside the
10-minute staleness budget below, and the operable floor values are unchanged. `captured_at` is the
**observation time of the record the drain chose** — when those windows were seen — not the time the
file was written:

```json
{
Expand All @@ -42,7 +49,9 @@ never sees torn JSON; the file is **last-writer-wins** across all sessions on th
(The example is internally consistent: `1784841300` is 2026-07-23T21:15:00Z — within five hours of
`captured_at` — and `1785142800` is 2026-07-27T09:00:00Z, within the seven-day window.)

- `captured_at` — ISO-8601 UTC write time; always present. Drives the staleness rule.
- `captured_at` — ISO-8601 UTC **observation** time of the chosen record; always present. Drives the
staleness rule. It can trail the file's mtime by up to the drain cadence (30 s), which is why the
rule is written against this field and never against the file's modification time.
- `rate_limits` — copied verbatim from the statusline stdin schema
(<https://code.claude.com/docs/en/statusline>, verified 2026-08-10): `used_percentage` is 0–100,
`resets_at` is Unix epoch seconds. The key is present **only** when the session observes
Expand All @@ -65,12 +74,12 @@ never sees torn JSON; the file is **last-writer-wins** across all sessions on th
Windows may be unobservable (API-key and enterprise auth carry limits but expose no
`rate_limits`). A consumer classifies its guard mode before every pause decision:

| Observation | Scope | Mode |
|---|---|---|
| Fresh snapshot with plausible `rate_limits` | whole guard | **proactive** — apply the operable floor |
| Tee file absent, stale, or missing `rate_limits` | whole guard | **unknown → reactive-only** |
| Absurd `used_percentage` or `resets_at` | that window | that window **unknown**; the floor still applies to every window still plausible |
| No window plausible | whole guard | **unknown → reactive-only** |
| Observation | Scope | Mode |
| ------------------------------------------------ | ----------- | -------------------------------------------------------------------------------- |
| Fresh snapshot with plausible `rate_limits` | whole guard | **proactive** — apply the operable floor |
| Tee file absent, stale, or missing `rate_limits` | whole guard | **unknown → reactive-only** |
| Absurd `used_percentage` or `resets_at` | that window | that window **unknown**; the floor still applies to every window still plausible |
| No window plausible | whole guard | **unknown → reactive-only** |

The scope column is load-bearing: only the whole-guard rows drop the guard to reactive-only. Absurd
values fail open, never closed: a `used_percentage` outside 0–100 or non-numeric, or a `resets_at`
Expand All @@ -92,7 +101,12 @@ proactive.
appended by the hook:

```json
{"detected_at":"2026-07-23T17:41:02Z","hook_event_name":"StopFailure","matcher":"rate_limit","session_id":"abc123"}
{
"detected_at": "2026-07-23T17:41:02Z",
"hook_event_name": "StopFailure",
"matcher": "rate_limit",
"session_id": "abc123"
}
```

The hook is side-effect-only (the harness ignores StopFailure output and exit codes) and the
Expand All @@ -111,6 +125,19 @@ sweeping the directory expects them:

- `stop-events.jsonl.lock` — the advisory-lock sibling the hook's serialized append and rotation use
(present wherever `flock` exists).
- `spool/` — the tee's per-session write-ahead spool, owner-only by inheritance from the contract
directory. `spool/<session>.json` holds ONE line: the newest observation that session recorded,
overwritten in place each refresh (never appended, so no two writers ever share a file). The name
is a shard key derived from `session_id` and reduced to `misc` unless it matches
`^[A-Za-z0-9._-]{1,64}$` without a leading dot — it is **never** trusted as a path. `spool/.last-drain`
holds the epoch seconds of the last flush and is what elects the next draining refresh; a stale
`spool/.drain.lock` directory can appear if a drain is killed and is stolen after two minutes.
Records older than 15 minutes are swept by the next drain. Readers consume none of this: the
contract file above is still the only proactive surface.
- `.tee-disabled` — written by a drain that read `rate_limit_guard_enabled: false`, holding the epoch
seconds at which it was written. While it is present and younger than the recheck interval the
refreshes stop recording entirely; when it ages out the next drain re-reads the real setting and
removes the marker, so re-enabling the plugin recovers without a restart.
- `.rate-limits.json.tmp.<pid>.<random>` — the tee's atomic-write staging file. Normally it exists
for well under a second between write and rename. It can outlive its writer: Claude Code
[cancels an in-flight statusline script](https://code.claude.com/docs/en/statusline) when a new
Expand Down
Loading