You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
rate-limit-guard: detect an account switch and tell the model the new account's limits #6445
When the operator rotates to another Claude account mid-session, the model keeps acting on the
previous account's usage limits. rate-limit-guard's notices still report the old account's
windows, and nothing tells the model the account changed, so it throttles fan-out, defers work
"until the reset", or suggests handoffs that the new account does not need.
Observed 2026-10-05 in a session on feat/upstream-docs-cache (PR #6348): after the operator
switched accounts, the prompt.submit hook context still said "rate-limit-guard: 7-day window at
or above 90%, resets at 2026-10-07 03:00 UTC", and the session planned around that reset until the
operator corrected it by hand.
Ask
Detect an account switch: the signed-in account identity (or organization) differs from the one
the guard last recorded for this session or machine.
On a switch, invalidate the cached usage snapshot and re-read the limits for the new account.
Inject one notice into the session on the next turn, for example: "Account switched to
; current limits: 5-hour window N%, 7-day window N%, resets at ...; earlier limit
notices in this session no longer apply."
Stop emitting the previous account's numbers once the switch is seen.
Open questions
Which signal identifies the account reliably and locally (the auth state Claude Code exposes to
hooks or mods, a status payload, the usage endpoint's response)? Needs a check against current
Claude Code docs before design.
Which hook or mod event fires early enough after a switch (UserPromptSubmit, a status-line tick,
SessionStart on resume)?
Should context-guard take the same notice, so the model can tell a context-size warning from a
usage warning?
Acceptance
After an account switch, the next model turn carries a notice naming the switch and the new
account's current limits.
No notice after the switch reports the old account's windows.
A stay-quiet test: no switch notice when the account is unchanged
(docs/conventions/hook-precision).
Second occurrence (2026-10-10/11), with cause and fix options
Problem
It happened again. After the operator switched Claude accounts, a prompt.submit context line reached the model:
rate-limit-guard: 5-hour window at or above 95% (100% used), resets at 2026-10-11 06:10 UTC.
The model passed it on to the user as current state ("Your 5-hour usage window is at 100% and resets at 06:10 UTC"). The new account was not limited. The user's words: "I switched accounts so rate limiting is not an issue … needs to detect that so you don't think this".
A few hours later in the same session, the live pull tool mcp__rate-limit-guard__status returned five_hour: 1% used, resets_at 2026-10-11T07:50Z, seven_day: 0%, verdict: quiet. The live read reflects the new account, so the harness reading was correct. The stale value came from the module's own latched state.
Cause
The push line does not read the snapshot file. It comes from the mod's in-memory state (plugins/rate-limit-guard/hooks/register.tsx):
Source of the reading:$.session.usage().rateLimits, or e.rateLimits on session.measure (register.tsx:247, :597). This is the "last API response" reading, the same data the statusline rate_limits object carries. The StopFailure capture (hooks/record-rate-limit-stop.sh) and the snapshot file ~/.claude/rate-limit-guard/rate-limits.json (register.tsx:435) are write-only outputs for lanes. The push line never reads either one.
Levels latch per window, and only on a rise:recordCrossings skips any reading whose level is not above the recorded one (register.tsx:200). A level drops only when the recorded resetsMs passes or the window disappears from the reading (register.tsx:189). The comment at register.tsx:180-181 and README "Use only rises within a window" (README.md:44) both treat a drop as "reporting noise". An account switch looks exactly like such a drop: the new account reads 1%, and the old account's 06:10 reset has not passed yet. So the edge level and the old reading stay latched.
The line is built from the latched reading, not the current one:dueEvents restates st.levels with w.limit, "the reading that crossed into it" (register.tsx:214-217). After a compaction, /resume, /branch, /clear or a reload (:571-573, :582-583, :590), the next prompt.submit (:611-631) or tool.call (:668-684) states the old account's 100% used, resets at 06:10 as fact. The only gate is st.reading?.has(kind) (:217). The new account also has a five_hour window, so that check passes.
Second path: a pending event from the old account's last response is sent on the next carrier, even when that carrier comes after a /login (:203, :274). The mod has no freshness or identity check before it sends a line.
Snapshot keying: the snapshot is one file per machine, last writer wins, with a session_id field and an optional account.email (register.tsx:398-410). Its 10-minute staleness rule and account-switch rule (reader-contract.md:35-56) apply only to lane readers. The push line has no staleness or identity rule.
The identity is already read but never used to invalidate:readIdentity parses .oauthAccount.emailAddress from ${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, cached on mtime and size (register.tsx:366-381). recordResponse records it on each response (:384-389). It is used only to attribute or omit the snapshot's account field (:392-396), never to reset st.levels, st.pending or st.handoff.
Upstream signals (as of 2026-10-10)
Statusline rate_limits (statusline#available-data): five_hour/seven_dayused_percentage and resets_at (epoch seconds). The object appears only after the session's first API response, and "Claude Code drops a window once its resets_at time passes". The data has no account, org or email field. The only identifier is session_id.
Hooks (hooks): the common input fields (session_id, transcript_path, cwd, permission_mode, …) carry no account or org identity. SessionStart source is startup|resume|clear|compact|fork. None of these fire on /login. Notification has an auth_success matcher ("Authentication completes"), but its payload and whether /login triggers it are undocumented. StopFailure authentication_failed/oauth_org_not_allowed/account_on_hold are errors, not switch events.
Mods: the module already reads .claude.json because the docs list no $.session identity accessor (reader-contract.md:14-23 records the recheck trigger, pinned at Claude Code 2.1.288, 2026-10-03).
Local state file: ~/.claude.json.oauthAccount here is an object with string fields accountUuid, emailAddress, organizationUuid, organizationName, organizationType, organizationRateLimitTier, a number profileFetchedAt, and others. I checked the field shape only and read no values. accountUuid/organizationUuid are better switch keys than the email: they are stable, and comparing them never brings an address into scope.
Fix (how), ranked
Recommended: (a) plus (d), re-baseline on an account change. On every refresh, compare the current identity (.oauthAccount.accountUuid, falling back to emailAddress, through the existing cached readIdentity) with the identity recorded when the levels were set. When it differs, clear st.levels, st.pending, st.handoff and st.toastQueue, and drop restate/restateIfLoud for windows the new reading does not support. Independently, in recordCrossings, treat a window whose resetsAt changed before the recorded resetsMs passed as a new window: re-baseline its level instead of keeping the latched one. Within one account a window's reset time does not move before it passes, so this catches a switch even when .claude.json is unreadable or unattributed. If the model was told edge for that window, send one line saying it no longer applies (see wording below).
Basis: register.tsx:189-200 (latch rule), :366-389 (identity already read per response), statusline docs (a window is dropped only at its resets_at), and /status output this session (resets moved 06:10 to 07:50 while the old reset had not passed).
Always, alongside 1: (c) build restated lines from the current reading and mark them as readings.dueEvents should restate from st.reading, not the recorded w.limit (register.tsx:214). A restatement whose current reading is no longer at that level should be dropped. Every line should carry its source time, so the model cannot pass it on as present fact: rate-limit-guard: as of the last API response (02:41 UTC), the 5-hour window was at or above 95% (100% used), resets 06:10 UTC. A reading belongs to the account signed in at that time; after a /login it no longer applies. Re-check with mcp__rate-limit-guard__status before telling the user they are limited.
On a detected switch: rate-limit-guard: the signed-in account changed; earlier rate-limit lines in this session no longer apply. Current reading: <windows> (or: no reading yet for this account).
This follows the README's facts-only rule (README.md:46-48). It adds provenance, not instructions about what to do.
Basis: /session-flow:keep-going SKILL.md:184-199 (a captured limit is bound to its account and needs a live re-check). Live tool output above.
Not recommended alone: (b) drop the line when the reading predates the last login or session start. No documented hook or mod event fires on /login (hooks docs above). auth_success's trigger and payload are undocumented. Session start does not mark a switch: this switch happened mid-session. The only usable proxy is a .claude.json mtime or profileFetchedAt change, which is option 1 done less precisely.
Basis: hooks docs fetched 2026-10-10. judgment on the mtime proxy.
(d) on its own, without identity: a drop in used% alone is not a reliable signal. The README calls dips reporting noise (README.md:44). Use the resets_at change (part of option 1), not the percentage drop.
Basis: register.tsx:180-181, judgment.
Verification
In hooks/rate-limit-guard.test.ts, following the existing account: tests (:982-1067):
Fails before the fix: identity A, then reading five_hour 100%, resets 06:10 (level edge, line sent). Switch .claude.json to identity B, then reading five_hour 1%, resets 07:50. Then trigger a compaction (restate) and a prompt.submit. Assert that no line contains 100% used or 06:10, and that at most one switch line is sent.
Same scenario with identity unreadable: only resets_at moves early. Assert the same result, which covers (d).
Stay-quiet, per docs/conventions/hook-precision: same identity and same resets_at, the reading dips from 96% to 80%. Assert no line and no re-arm, so the current behavior holds.
Restatement uses the current reading: same identity, latched at 96%, now reading 97%. The restated line shows 97%.
Related
/session-flow:keep-going: its rule that a captured limit is account-bound and needs a live re-check (SKILL.md:184-199) is the model-side form of this fix.
Owner decision (2026-10-11): Reset rate-limit-guard state when the account changes or a reset time moves earlier, and restate from the current reading (ranked options 1 and 2 from the 2026-10-11 comment). Lands before #6321.
Recorded by the coordinating agent session with the owner's approval. Removing needs-human; implementation is queued and will link its branch here.
Question: What should rate-limit-guard do when the signed-in account changes or a window's reset time moves earlier?
Owner's answer: Reset the mod's latched state and restate from the current reading (ranked options 1 and 2 of the 2026-10-11 comment). It lands before #6321.
The owner confirmed this decision, first recorded at #6445 (comment), in an attended backlog-shaping session on 2026-10-18. The question was "Confirm the 10 decisions, #7287's kill-switch hardening included, and admit them to the paced queue?" and the owner answered "Yes, all 10 (Recommended)".
Posted by AI from the owner's answer in that session.
Type: Bug Summary: Reset rate-limit-guard's latched levels when the account changes or a window's reset time moves earlier, and restate from the current reading.
Current behavior:
The mod latches each window's level and only records rises; a level drops only when its recorded reset passes. After an account switch, the new account's lower reading looks like reporting noise, so the model keeps getting the old account's windows and reset times.
Desired behavior:
The mod keys the signed-in identity on the account UUID, falling back to the email address when no UUID can be read.
When that identity differs from the last one recorded, the mod clears all its latched state: window levels, pending events, operator handoff events and the toast queue.
When a window's reset time moves earlier than the recorded one, it clears that window's latched state the same way.
On the next turn it sends one line saying the account or window changed, earlier rate-limit lines no longer apply, and the current reading (or that there is no reading yet for this account).
With the same identity and the same reset time, a dip in used percent changes nothing, as today.
Latched state (levels, pending, handoff, toast queue): cleared on identity change; a window's entries cleared when its reset time moves earlier.
One switch line: facts only.
Conventions followed: the mod-authoring convention and ADR 0052.
Acceptance criteria:
A red-first test: identity A latched at 100% resetting 06:10, then identity B reading 1% resetting 07:50, then a compaction and a prompt: no line holds 100% used or 06:10, and at most one switch line is sent.
A test with identity unreadable where only the reset time moves earlier gives the same result.
A test: two identities with the same email but different account UUIDs count as a switch; with no UUID readable, a changed email counts as a switch.
A test: after a switch, no pending, handoff or queued toast entry from the old identity is delivered.
A stay-quiet test: same identity and reset time, reading dips from 96% to 80%: no line and no re-arm.
A test: same identity latched at 96%, now reading 97%: the restated line shows 97%.
scripts/run-test-file.sh on the rate-limit-guard mod test file passes.
A changelog entry or fragment for rate-limit-guard passes scripts/check-changelog-fragments.sh.
The work-resolve-issue lane stopped partway through this issue and needs a person.
Why, in the run's own words:
The change is written in the checkout but not committed: the commit tool failed twice with 'Failed to get branch reference: 403', so nothing reached the branch.
What the run says it did not do:
Commit failed with a 403 on the branch reference; the checkout was on a detached HEAD.
The mod test file was not run (run-test-file refuses it; the suite needs claude plugin test). Tests were traced by hand only, with no type check.
The work-resolve-issue run that agent-ready started failed before it reported, or the token broker refused it because its workflow files differ from main's. work-unblock removed and added agent-ready back to start a fresh run on main's workflow files. It does this once per version of those files, so if this run fails the same way, a person looks at it.
Activity
github-actions commentedon Oct 5, 2026
Thanks for the report. Automated triage handed this issue to a maintainer.
Automated triage. A maintainer reviews it.
kyle-sexton commentedon Oct 11, 2026
Second occurrence (2026-10-10/11), with cause and fix options
Problem
It happened again. After the operator switched Claude accounts, a
prompt.submitcontext line reached the model:The model passed it on to the user as current state ("Your 5-hour usage window is at 100% and resets at 06:10 UTC"). The new account was not limited. The user's words: "I switched accounts so rate limiting is not an issue … needs to detect that so you don't think this".
A few hours later in the same session, the live pull tool
mcp__rate-limit-guard__statusreturnedfive_hour: 1% used, resets_at 2026-10-11T07:50Z,seven_day: 0%,verdict: quiet. The live read reflects the new account, so the harness reading was correct. The stale value came from the module's own latched state.Cause
The push line does not read the snapshot file. It comes from the mod's in-memory state (
plugins/rate-limit-guard/hooks/register.tsx):$.session.usage().rateLimits, ore.rateLimitsonsession.measure(register.tsx:247,:597). This is the "last API response" reading, the same data the statuslinerate_limitsobject carries. The StopFailure capture (hooks/record-rate-limit-stop.sh) and the snapshot file~/.claude/rate-limit-guard/rate-limits.json(register.tsx:435) are write-only outputs for lanes. The push line never reads either one.recordCrossingsskips any reading whose level is not above the recorded one (register.tsx:200). A level drops only when the recordedresetsMspasses or the window disappears from the reading (register.tsx:189). The comment atregister.tsx:180-181and README "Use only rises within a window" (README.md:44) both treat a drop as "reporting noise". An account switch looks exactly like such a drop: the new account reads 1%, and the old account's 06:10 reset has not passed yet. So theedgelevel and the old reading stay latched.dueEventsrestatesst.levelswithw.limit, "the reading that crossed into it" (register.tsx:214-217). After a compaction,/resume,/branch,/clearor a reload (:571-573,:582-583,:590), the nextprompt.submit(:611-631) ortool.call(:668-684) states the old account's100% used, resets at 06:10as fact. The only gate isst.reading?.has(kind)(:217). The new account also has afive_hourwindow, so that check passes.pendingevent from the old account's last response is sent on the next carrier, even when that carrier comes after a/login(:203,:274). The mod has no freshness or identity check before it sends a line.session_idfield and an optionalaccount.email(register.tsx:398-410). Its 10-minute staleness rule and account-switch rule (reader-contract.md:35-56) apply only to lane readers. The push line has no staleness or identity rule.readIdentityparses.oauthAccount.emailAddressfrom${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, cached on mtime and size (register.tsx:366-381).recordResponserecords it on each response (:384-389). It is used only to attribute or omit the snapshot'saccountfield (:392-396), never to resetst.levels,st.pendingorst.handoff.Upstream signals (as of 2026-10-10)
rate_limits(statusline#available-data):five_hour/seven_dayused_percentageandresets_at(epoch seconds). The object appears only after the session's first API response, and "Claude Code drops a window once itsresets_attime passes". The data has no account, org or email field. The only identifier issession_id.session_id,transcript_path,cwd,permission_mode, …) carry no account or org identity. SessionStartsourceisstartup|resume|clear|compact|fork. None of these fire on/login. Notification has anauth_successmatcher ("Authentication completes"), but its payload and whether/logintriggers it are undocumented. StopFailureauthentication_failed/oauth_org_not_allowed/account_on_holdare errors, not switch events..claude.jsonbecause the docs list no$.sessionidentity accessor (reader-contract.md:14-23 records the recheck trigger, pinned at Claude Code 2.1.288, 2026-10-03).~/.claude.json.oauthAccounthere is an object with string fieldsaccountUuid,emailAddress,organizationUuid,organizationName,organizationType,organizationRateLimitTier, a numberprofileFetchedAt, and others. I checked the field shape only and read no values.accountUuid/organizationUuidare better switch keys than the email: they are stable, and comparing them never brings an address into scope.Fix (how), ranked
refresh, compare the current identity (.oauthAccount.accountUuid, falling back toemailAddress, through the existing cachedreadIdentity) with the identity recorded when the levels were set. When it differs, clearst.levels,st.pending,st.handoffandst.toastQueue, and droprestate/restateIfLoudfor windows the new reading does not support. Independently, inrecordCrossings, treat a window whoseresetsAtchanged before the recordedresetsMspassed as a new window: re-baseline its level instead of keeping the latched one. Within one account a window's reset time does not move before it passes, so this catches a switch even when.claude.jsonis unreadable or unattributed. If the model was toldedgefor that window, send one line saying it no longer applies (see wording below).Basis:
register.tsx:189-200(latch rule),:366-389(identity already read per response), statusline docs (a window is dropped only at itsresets_at), and/statusoutput this session (resets moved 06:10 to 07:50 while the old reset had not passed).dueEventsshould restate fromst.reading, not the recordedw.limit(register.tsx:214). A restatement whose current reading is no longer at that level should be dropped. Every line should carry its source time, so the model cannot pass it on as present fact:rate-limit-guard: as of the last API response (02:41 UTC), the 5-hour window was at or above 95% (100% used), resets 06:10 UTC. A reading belongs to the account signed in at that time; after a /login it no longer applies. Re-check with mcp__rate-limit-guard__status before telling the user they are limited.On a detected switch:
rate-limit-guard: the signed-in account changed; earlier rate-limit lines in this session no longer apply. Current reading: <windows> (or: no reading yet for this account).This follows the README's facts-only rule (README.md:46-48). It adds provenance, not instructions about what to do.
Basis:
/session-flow:keep-goingSKILL.md:184-199 (a captured limit is bound to its account and needs a live re-check). Live tool output above./login(hooks docs above).auth_success's trigger and payload are undocumented. Session start does not mark a switch: this switch happened mid-session. The only usable proxy is a.claude.jsonmtime orprofileFetchedAtchange, which is option 1 done less precisely.Basis: hooks docs fetched 2026-10-10. judgment on the mtime proxy.
resets_atchange (part of option 1), not the percentage drop.Basis:
register.tsx:180-181, judgment.Verification
In
hooks/rate-limit-guard.test.ts, following the existingaccount:tests (:982-1067):five_hour 100%, resets 06:10(leveledge, line sent). Switch.claude.jsonto identity B, then readingfive_hour 1%, resets 07:50. Then trigger a compaction (restate) and aprompt.submit. Assert that no line contains100% usedor06:10, and that at most one switch line is sent.resets_atmoves early. Assert the same result, which covers (d).resets_at, the reading dips from 96% to 80%. Assert no line and no re-arm, so the current behavior holds.Related
/session-flow:keep-going: its rule that a captured limit is account-bound and needs a live re-check (SKILL.md:184-199) is the model-side form of this fix.kyle-sexton commentedon Oct 11, 2026
Owner decision (2026-10-11): Reset rate-limit-guard state when the account changes or a reset time moves earlier, and restate from the current reading (ranked options 1 and 2 from the 2026-10-11 comment). Lands before #6321.
Recorded by the coordinating agent session with the owner's approval. Removing
needs-human; implementation is queued and will link its branch here.kyle-sexton commentedon Oct 18, 2026
Decision recorded.
Question: What should rate-limit-guard do when the signed-in account changes or a window's reset time moves earlier?
Owner's answer: Reset the mod's latched state and restate from the current reading (ranked options 1 and 2 of the 2026-10-11 comment). It lands before #6321.
The owner confirmed this decision, first recorded at #6445 (comment), in an attended backlog-shaping session on 2026-10-18. The question was "Confirm the 10 decisions, #7287's kill-switch hardening included, and admit them to the paced queue?" and the owner answered "Yes, all 10 (Recommended)".
Posted by AI from the owner's answer in that session.
kyle-sexton commentedon Oct 18, 2026
Agent Brief
Type: Bug
Summary: Reset rate-limit-guard's latched levels when the account changes or a window's reset time moves earlier, and restate from the current reading.
Current behavior:
The mod latches each window's level and only records rises; a level drops only when its recorded reset passes. After an account switch, the new account's lower reading looks like reporting noise, so the model keeps getting the old account's windows and reset times.
Desired behavior:
Key interfaces:
Acceptance criteria:
100% usedor06:10, and at most one switch line is sent.scripts/run-test-file.shon the rate-limit-guard mod test file passes.scripts/check-changelog-fragments.sh.Out of scope:
This was generated by AI during backlog shaping. The owner approved its admission in an attended backlog-shaping session on 2026-10-18.
melodic-automation-lanes commentedon Oct 18, 2026
The work-resolve-issue lane stopped partway through this issue and needs a person.
Why, in the run's own words:
What the run says it did not do:
claude plugin test). Tests were traced by hand only, with no type check.Run: https://github.com/melodic-software/claude-code-plugins/actions/runs/39105897350
To retry, remove
needs-humanand addagent-readyagain.melodic-automation-lanes commentedon Oct 19, 2026
The work-resolve-issue run that
agent-readystarted failed before it reported, or the token broker refused it because its workflow files differ from main's. work-unblock removed and addedagent-readyback to start a fresh run on main's workflow files. It does this once per version of those files, so if this run fails the same way, a person looks at it.