feat(providers): switch accounts mid-thread and track usage limits - #9181
feat(providers): switch accounts mid-thread and track usage limits#9181tamimbinhakim wants to merge 3 commits into
Conversation
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.
There was a problem hiding this comment.
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
| payload: Record<string, unknown>, | ||
| observedAt: string, | ||
| ): ServerProviderRateLimit | undefined { | ||
| const snapshot = asRecord(payload.rateLimits) ?? payload; |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
There was a problem hiding this comment.
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
| const rateLimitSuggestion = useMemo( | ||
| () => | ||
| activeThread | ||
| ? resolveProviderRateLimitSuggestion({ | ||
| providers: providerStatuses, |
There was a problem hiding this comment.
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()returnssnapshot.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".selectRateLimitFallbackProviderfilters on the snapshot'senabled, which lags a settings write and stays set for a just-deleted custom instance — so the banner can offer, andhandleSwitchRateLimitedAccountcan 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
There was a problem hiding this comment.
Fixed in f31fb6c: labels come from deriveProviderInstanceEntries so two unlabeled Claude accounts read as their resolved display names.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| return Effect.void; | ||
| } | ||
| return worker.enqueue(event); | ||
| }), |
There was a problem hiding this comment.
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.
Triggered by learned rule: PubSub subscribe-before-snapshot to avoid change-stream gaps
Reviewed by Cursor Bugbot for commit 487da17. Configure here.
There was a problem hiding this comment.
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.
ApprovabilityVerdict: 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:
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.
There was a problem hiding this comment.
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
| // The tinted glyph is the account marker; initials only | ||
| // disambiguate siblings that have no colour. | ||
| showBadge={showInstanceBadge && entry.accentColor === undefined} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Fixed in ea8ee31: the composer trigger applies the same rule.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
There was a problem hiding this comment.
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).
❌ 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, |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit f31fb6c. Configure here.
There was a problem hiding this comment.
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.
- 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.
|
This limitation is actually costing Anthropic money because it's the only thing that stopped me from getting another $200 sub today 😆 |
|
exactly. hope theo's team sees this |
|
@juliusmarminge can this be merged? |
|
I would love this! |


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>.jsonland its sidecar dir) into the target config dir first, so--resumeworks there with full history. Nothing else in the config dirs is shared.ProviderServicealso 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
ProviderRateLimitReactornormalizes the Claude and Codex rate limit events ontoServerProvider.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 aprovider.instance.switchedactivity 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
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.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.rateLimiton the registry; a newProviderRateLimitReactorprojects that state and, whenautoSwitchProviderOnRateLimitis on, retries a failed limited turn once on a sibling instance (same driver + continuation group) while logging aprovider.instance.switchedactivity.ProviderCommandReactoralso 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, andcarryOverClaudeSessionTranscriptcopies session files before--resume.ProviderServicereuses 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
ServerProviderRateLimitand 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
ProviderRateLimitReactorto parse Claude and Codex rate-limit payloads and turn-error text into a normalizedServerProviderRateLimit, store it in-memory inProviderRegistry, and project it to the clientautoSwitchProviderOnRateLimitis enabled,ProviderCommandReactorreroutes the turn to the lowest-utilization sibling sharing the same driver and continuation group, appending a switch activity to the threadProviderService.startSessionnow 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 andcarryOverClaudeSessionTranscriptcopies the session transcript between config directories on resumeClaudeDrivernow 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;ProviderRegistryexcludes rate-limit fields from status-cache persistence, so rate-limit state is volatile and lost on restartMacroscope summarized ea8ee31.