Skip to content

feat(providers): switch accounts mid-thread and track usage limits - #9181

Open
tamimbinhakim wants to merge 3 commits into
pingdotgg:mainfrom
tamimbinhakim:feat/claude-account-switching
Open

feat(providers): switch accounts mid-thread and track usage limits#9181
tamimbinhakim wants to merge 3 commits into
pingdotgg:mainfrom
tamimbinhakim:feat/claude-account-switching

Conversation

@tamimbinhakim

@tamimbinhakim tamimbinhakim commented Sep 2, 2026

Copy link
Copy Markdown

Problem

I run two Claude accounts in T3 Code (work + personal). Once a thread starts on one of them it is stuck there: the other account is greyed out in the model picker and the server rejects the switch because the two config dirs have different continuation keys. So when the work account hits its 5-hour limit mid-task, the only option is to start a new thread on the personal account and lose the context.

On top of that, nothing in the app knows an account is rate limited. The adapters already emit account.rate-limits.updated (with reset time and utilization) but nothing consumes it, so you only find out when a turn fails with "You've hit your limit".

What this does

Switch Claude accounts inside an existing thread. All Claude instances now share one continuation group. The resume cursor records which config dir it ran under, and when a thread resumes on a different Claude instance the adapter copies the session transcript (projects/<cwd>/<session>.jsonl and its sidecar dir) into the target config dir first, so --resume works there with full history. Nothing else in the config dirs is shared. ProviderService also reuses the persisted cursor + cwd when a stopped thread comes back on a sibling instance with the same continuation key (that was already broken for same-home siblings, it just started a fresh session).

Track usage limits per account. New ProviderRateLimitReactor normalizes the Claude and Codex rate limit events onto ServerProvider.rateLimit (status, resetsAt, window, utilization). The model picker tooltip shows "Usage limit reached, resets at 3:00 PM" on the limited account.

Auto switch (new setting, off by default). autoSwitchProviderOnRateLimit: when a turn fails on an account that is currently limited, the reactor re-sends that turn on a sibling account (same driver + continuation group, not limited, lowest utilization wins), appends a provider.instance.switched activity to the thread, and the command reactor routes later turns of that thread the same way until the limit resets. Each failed turn is retried at most once.

Suggestion when the setting is off. A composer banner (same stack as "Resume with less context") says which account hit its limit and when it resets, with a "Switch to Claude Personal" button. Switching sets the thread's model selection and, if the last turn failed, puts that message back in the composer so you can just hit send.

Logo tint. Accounts with an accent colour now paint the provider logo in that colour instead of adding the little initials badge, so two Claude accounts read as a blue Claude and a green Claude in the rail, the sidebar and the composer.

Per provider

  • Claude: full support (transcript carry-over, limits, auto switch).
  • Codex: limits + auto switch between instances that share a home (shadow home setup). No transcript carry-over needed since sessions are already shared.
  • Cursor / Grok / OpenCode: unchanged; no rate limit signal is emitted for them so nothing is shown.
  • Mobile: gets the server-side behaviour (auto switch, activity) for free. No banner or settings toggle added there yet.

Testing

  • ClaudeHome.test.ts: transcript carry-over (direct project dir, moved cwd, missing source).
  • ProviderService.test.ts: cursor/cwd reuse across sibling instances, and not across different continuation keys.
  • providerRateLimits.test.ts / providerRateLimits.test.ts (shared): Claude + Codex normalization, error-text fallback, fallback selection.
  • ProviderRateLimitReactor.test.ts: snapshot projection, retry on sibling with the setting on, no-op with it off, no double retry.
  • providerRateLimitBanner.logic.test.ts: banner decision + reset formatting.
  • Targeted typecheck for server, web, shared, contracts is clean. Existing adapter/service/reactor suites pass.

I know this is bigger than the "small fixes only" guidance and touches three things (mid-thread switch, limit tracking + auto switch, logo tint). They came out of the same afternoon of hitting limits and I kept them in one branch so the feature is usable end to end, but I'm happy to split it into three PRs if you'd rather review them separately.

Related: #2111, #1444, #1607, #2471.


Note

Medium Risk
Changes orchestration turn routing, provider session continuation (including filesystem transcript copy), and multi-instance Claude identity; incorrect fallback or carry-over could send turns to the wrong account or break resume.

Overview
Adds usage-limit awareness and account fallback across server and web. Runtime events are normalized (Claude/Codex) into volatile ServerProvider.rateLimit on the registry; a new ProviderRateLimitReactor projects that state and, when autoSwitchProviderOnRateLimit is on, retries a failed limited turn once on a sibling instance (same driver + continuation group) while logging a provider.instance.switched activity. ProviderCommandReactor also steers subsequent turn starts to the fallback while the limit is active.

Claude threads can move between accounts without starting over: all Claude instances share one continuation group, the resume cursor records configDir, and carryOverClaudeSessionTranscript copies session files before --resume. ProviderService reuses persisted resume cursor/cwd when restarting on a sibling with the same continuation key.

On the client, a composer banner and model-picker tooltips surface limits and manual “switch account” when auto-switch is off; provider icons use accent tinting instead of corner badges where configured. Contracts add ServerProviderRateLimit and the new server setting (default off).

Reviewed by Cursor Bugbot for commit ea8ee31. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Switch provider accounts mid-thread and track usage limits

  • Adds ProviderRateLimitReactor to parse Claude and Codex rate-limit payloads and turn-error text into a normalized ServerProviderRateLimit, store it in-memory in ProviderRegistry, and project it to the client
  • When a provider is rate-limited and autoSwitchProviderOnRateLimit is enabled, ProviderCommandReactor reroutes the turn to the lowest-utilization sibling sharing the same driver and continuation group, appending a switch activity to the thread
  • ProviderService.startSession now forwards the persisted resume cursor and working directory to a sibling instance with the same driver and continuation key; Claude uses a fixed continuation-group constant and carryOverClaudeSessionTranscript copies the session transcript between config directories on resume
  • The web composer shows a usage-limit banner with reset time, fallback provider name, and a switch action; provider icons render accent-colored glyphs instead of initials badges; a General settings toggle controls automatic switching
  • Risk: ClaudeDriver now advertises one fixed continuation group regardless of config-dir path, so Claude instances that previously had distinct per-home continuation keys are now eligible siblings for each other; ProviderRegistry excludes rate-limit fields from status-cache persistence, so rate-limit state is volatile and lost on restart

Macroscope summarized ea8ee31.

Two Claude providers with different config directories could not share a
thread: the continuation key was tied to the config dir, so the model
picker greyed out the other account and the server rejected the switch.

Claude instances now share one continuation group. The resume cursor
records the config dir it ran under, and the adapter copies the session
transcript (plus its sidecar directory) into the target config dir before
resuming, so the thread continues with full history on the other account.
ProviderService also reuses a stopped thread's persisted cursor and cwd
when a sibling instance shares the same continuation key.

Rate-limit events the adapters already emitted were dropped on the floor.
A new ProviderRateLimitReactor normalizes them (Claude and Codex) onto
ServerProvider.rateLimit with status, reset time, window and utilization.
With the new autoSwitchProviderOnRateLimit setting the reactor re-sends a
turn that failed on a limited account using a sibling account and records
the switch as a thread activity; the command reactor routes later turns
the same way. With the setting off the composer shows a notice with the
reset time and a one-click switch that also restores the failed message.

Accounts with an accent colour now tint the provider logo itself instead
of adding a corner badge, so two Claude accounts read as two colours.
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Sep 2, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ProviderRateLimitReactor service is defined in the legacy Services/ + Layers/ split with a standalone Shape interface and a ...Live layer export. New services should follow the canonical single-module layout already used by e.g. apps/server/src/orchestration/ThreadSettlementReactor.ts (tag with inline interface, exported make, export const layer). Individual findings are inline.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts Outdated
Comment thread apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts Outdated
Comment thread apps/server/src/orchestration/Services/ProviderRateLimitReactor.ts Outdated
Comment thread apps/server/src/orchestration/Layers/ProviderRateLimitReactor.ts Outdated
Comment thread apps/web/src/components/chat/ProviderInstanceIcon.tsx Outdated
payload: Record<string, unknown>,
observedAt: string,
): ServerProviderRateLimit | undefined {
const snapshot = asRecord(payload.rateLimits) ?? payload;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High provider/providerRateLimits.ts:98

A sparse Codex update containing only primary: { usedPercent: 40 } is normalized to allowed, so replacing the provider state drops any still-active rejected secondary window and subsequent turns can be sent while Codex continues rejecting the account. readCodexRateLimit selects only the windows in the current payload; preserve and merge the previous Codex snapshot (or otherwise retain omitted windows) before deriving the replacement status.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/providerRateLimits.ts around line 98:

A sparse Codex update containing only `primary: { usedPercent: 40 }` is normalized to `allowed`, so replacing the provider state drops any still-active rejected `secondary` window and subsequent turns can be sent while Codex continues rejecting the account. `readCodexRateLimit` selects only the windows in the current payload; preserve and merge the previous Codex snapshot (or otherwise retain omitted windows) before deriving the replacement status.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f31fb6c with mergeCodexRateLimit: an active rejection is kept when a Codex update omits its window (different or missing resetsAt) and is replaced once that window's reset passes or an update names it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/provider/Layers/ProviderRegistry.ts
Comment thread apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Comment thread apps/server/src/provider/providerRateLimits.ts
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts Outdated
Comment thread apps/web/src/components/ChatView.tsx
Comment thread apps/server/src/provider/Layers/ProviderRegistry.ts
Comment thread packages/shared/src/providerRateLimits.ts

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four consistency findings, all inline: the rate-limit banner bypasses the instance-entry projection this file already uses for provider identity/eligibility, ProviderInstanceIcon now decides badge rendering and icon filter inside the primitive (silently voiding props and classes three call sites pass on purpose), and the new picker usage-limit text is not mirrored into aria-label.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/chat/ModelPickerSidebar.tsx
Comment thread apps/web/src/components/chat/ProviderInstanceIcon.tsx Outdated
Comment on lines +5161 to +5165
const rateLimitSuggestion = useMemo(
() =>
activeThread
? resolveProviderRateLimitSuggestion({
providers: providerStatuses,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The banner reads raw ServerProvider snapshots, bypassing the instance-entry projection this file already uses for provider identity and availability (applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), line 2929). Two concrete effects:

  • providerLabel() returns snapshot.displayName, which is the driver label ("Claude") for any instance created without an optional label. With two Claude accounts the banner reads "Claude hit its usage limit. Claude can continue this thread." and the action is "Switch to Claude". resolveInstanceDisplayName (used by the picker, sidebar and composer trigger) exists to disambiguate this into e.g. "Claude Personal".
  • selectRateLimitFallbackProvider filters on the snapshot's enabled, which lags a settings write and stays set for a just-deleted custom instance — so the banner can offer, and handleSwitchRateLimitedAccount can then select, an account the model picker no longer lists.

Suggest deriving entries once (applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings)), restricting the candidates passed in to the entries that overlay reports as enabled, and taking the banner/button copy from entry.displayName instead of providerLabel.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f31fb6c: labels come from deriveProviderInstanceEntries so two unlabeled Claude accounts read as their resolved display names.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/web/src/components/chat/ProviderInstanceIcon.tsx Outdated
Comment thread apps/server/src/orchestration/ProviderRateLimitReactor.ts
Comment thread apps/server/src/provider/Drivers/ClaudeHome.ts
Comment thread apps/server/src/provider/providerRateLimits.ts
return Effect.void;
}
return worker.enqueue(event);
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate-limit reactor subscribes after park

Medium Severity

start parks first and only then runs Stream.runForEach on providerService.streamEvents, so the PubSub subscription is not attached until server activation. ProviderCommandReactor.start already subscribes before forkParked so events during pending activation are not dropped. Rate-limit updates and failed-turn retries that fire in that window never reach the reactor.

Fix in Cursor Fix in Web

Triggered by learned rule: PubSub subscribe-before-snapshot to avoid change-stream gaps

Reviewed by Cursor Bugbot for commit 487da17. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. The reactor consumes providerService.streamEvents exactly the way ProviderRuntimeIngestion does (forkParked), so events before activation are not observed by either consumer; nothing can have failed a turn before activation. The turn-start domain subscription is taken eagerly, matching ProviderCommandReactor.

@macroscopeapp

macroscopeapp Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a large cross-cutting feature that changes production orchestration, provider session continuation, filesystem transcript handling, retry behavior, and user-facing account switching. The new defaulted setting and unresolved concerns around event timing, retry side effects, and fallback selection require human validation.

Not approved because:

  • 3 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

- Retry the exact user message that started the failed turn (observed
  from thread.turn-start-requested) and reuse its message id, so a retry
  never appends a duplicate bubble or picks a newer message.
- Keep an active Codex rejection when a sparse update omits its window.
- Record the switch activity without letting a dispatch failure drop the
  user's turn.
- Error-text limit detection applies only to Claude and Codex and expires
  after 15 minutes instead of sticking until a restart.
- Clear rate-limit state for removed instances and never persist it.
- Stamp the resume cursor with Claude's real config dir (~/.claude when
  none is set) and copy carried-over transcripts into the current cwd's
  project dir so --resume finds them after a path change.
- Fallback selection requires a known continuation group and an installed
  CLI.
- Icon primitive no longer overrides caller dimming or hides badges; call
  sites decide, and OpenCode's per-path fills take the accent too.
- Rate-limit banner uses instance display names and does not restore a
  message that carried attachments; picker mirrors the limit into the
  aria-label.
- Reactor moved to the single-module service layout.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service module itself now follows the canonical layout (single module at orchestration/ProviderRateLimitReactor.ts, inline interface on the Context.Service tag, exported make, export const layer), and src/server.ts / the integration harness consume it via a namespace import. One import-convention violation remains, inline.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/orchestration/ProviderRateLimitReactor.test.ts Outdated
Comment thread apps/server/src/orchestration/ProviderRateLimitReactor.ts Outdated
Comment thread apps/web/src/components/ChatView.tsx Outdated
Comment on lines +194 to +196
// The tinted glyph is the account marker; initials only
// disambiguate siblings that have no colour.
showBadge={showInstanceBadge && entry.accentColor === undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new "tinted glyph is the account marker" policy is applied by ANDing accentColor === undefined at individual call sites (here, Sidebar.tsx:1608, Sidebar.tsx:356, ProviderInstanceCard.tsx:548), but ProviderModelPicker.tsx:167 — the composer trigger — still passes showBadge={showInstanceBadge} unchanged. Since shouldShowInstanceBadge short-circuits true on accentColor, an accented account now renders as accent glyph plus an accent initials badge in the composer trigger, and as accent glyph only in the rail that trigger opens and in sidebar rows — the same account reads two different ways in adjacent surfaces.

Since the badge rule is shared (providerInstances.ts:116-130 documents it as the contract for "the composer trigger, the picker rail, and sidebar rows"), the smallest durable fix is to encode the new rule there — drop the if (entry.accentColor) return true short-circuit, update the doc comment — and remove the per-call-site && entry.accentColor === undefined. That also clears the badge props that are now unreachable at ProviderInstanceCard.tsx:551 and Sidebar.tsx:355-357.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea8ee31: the composer trigger applies the same rule.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/web/src/components/ChatView.tsx Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

There are 5 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f31fb6c. Configure here.

message: {
// Same id as the original: the projection upserts instead of
// appending, so the transcript keeps a single bubble.
messageId: input.userMessage.id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry re-runs first-turn side effects

Medium Severity

Reusing the original messageId on auto-switch retry keeps a single user row, so isFirstUserMessageTurn stays true. The follow-up thread.turn.start then forks worktree rename and title generation again for a turn that already started and failed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f31fb6c. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving as is. Both first-turn side effects are already guarded: the title is only generated while canReplaceThreadTitle still holds and the branch is only renamed while it is still a temporary worktree branch, so a retry after a failed first turn at most finishes work the failed turn did not, and is a no-op otherwise.

Comment thread apps/server/src/orchestration/ProviderRateLimitReactor.ts Outdated
Comment thread apps/server/src/orchestration/ProviderRateLimitReactor.ts
Comment thread apps/server/src/orchestration/Layers/OrchestrationReactor.ts Outdated
- Error-text detections carry a turn-error window so the Codex merge never
  mistakes them for a real window.
- Turn-start records are bounded and a missing record is logged instead of
  silently skipping the retry.
- Rate-limit banner dismissal is per thread and stable when no reset time
  is known; the composer trigger follows the tinted-glyph rule.
- Namespace imports for the reactor module.
@tlmader

tlmader commented Sep 4, 2026

Copy link
Copy Markdown

This limitation is actually costing Anthropic money because it's the only thing that stopped me from getting another $200 sub today 😆

@tamimbinhakim

Copy link
Copy Markdown
Author

exactly. hope theo's team sees this

@tamimbinhakim

Copy link
Copy Markdown
Author

@juliusmarminge can this be merged?

@bryceeppler

Copy link
Copy Markdown

I would love this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants