Skip to content

rate-limit-guard: detect an account switch and tell the model the new account's limits #6445

Open

Description

@kyle-sexton

Problem

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

  1. Detect an account switch: the signed-in account identity (or organization) differs from the one
    the guard last recorded for this session or machine.
  2. On a switch, invalidate the cached usage snapshot and re-read the limits for the new account.
  3. 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."
  4. 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).

Activity

  1. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 5, 2026
  2. github-actions commented on Oct 5, 2026

    @github-actions
    Contributor

    Thanks for the report. Automated triage handed this issue to a maintainer.

    Automated triage. A maintainer reviews it.

  3. added
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 5, 2026
  4. kyle-sexton commented on Oct 11, 2026

    @kyle-sexton
    MemberAuthor

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

    1. 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).
    2. 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.
    3. 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.
    4. (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

  5. kyle-sexton commented on Oct 11, 2026

    @kyle-sexton
    MemberAuthor

    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.

  6. removed
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 11, 2026
  7. added theissue type on Oct 15, 2026
  8. added
    priority: mediumReal value, no hard deadline; normal backlog flow.
    and removed
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 15, 2026
  9. kyle-sexton commented on Oct 18, 2026

    @kyle-sexton
    MemberAuthor

    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.

  10. kyle-sexton commented on Oct 18, 2026

    @kyle-sexton
    MemberAuthor

    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:

    • 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.
    • A later restatement uses the current reading.

    Key interfaces:

    • Account identity: account UUID first, email fallback.
    • 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.

    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.

  11. added
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.
    on Oct 18, 2026
  12. added
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 18, 2026
  13. melodic-automation-lanes commented on Oct 18, 2026

    @melodic-automation-lanes
    Contributor

    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.

    Run: https://github.com/melodic-software/claude-code-plugins/actions/runs/39105897350

    To retry, remove needs-human and add agent-ready again.

  14. removed
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    on Oct 18, 2026
  15. added
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    and removed
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 18, 2026
  16. melodic-automation-lanes commented on Oct 19, 2026

    @melodic-automation-lanes
    Contributor

    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.

  17. added
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    and removed
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    on Oct 19, 2026
  18. removed
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    on Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority: mediumReal value, no hard deadline; normal backlog flow.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions