ENZH

I Taught My Claude Accounts to Rotate Themselves

The wall

If you live in Claude Code, you know the wall. You're deep in a session, the plan is landing, and the 5-hour usage window quietly fills up. Then everything stops until the window resets. If you pay for more than one account — a personal Max, a work Max — the fix is obvious: switch to the one with headroom. Except switching, on macOS, means logging out. You re-authenticate, your running session loses its login, you lose your place.

I wanted the boring version of this: the active account should rotate itself to the next one with headroom, before the wall, while I keep typing. No prompt, no logout, no me noticing. A little like how your phone hops from Wi-Fi to cellular without dropping the call.

The interesting part isn't that I wanted it. It's the one fact that made it possible.

The fact: a running Claude re-reads its login every request

Here's the thing I had to verify by hand, because it isn't written down anywhere: on macOS, Claude Code doesn't hold its login token in memory for the life of the process. It reads it from the macOS Keychain — one specific item, Claude Code-credentials — and it re-reads it per request. The ~/.claude/.credentials.json file you might expect to matter is just a mirror; editing it does nothing to a running session.

Sit with that for a second, because the whole design falls out of it. If a live claude process reads one Keychain item on every call, then rewriting that one item hot-swaps the account underneath a running session. No restart. No re-login. The next request just quietly authenticates as a different account. The session never knows.

The same thing as a precise sequence:

That's the entire trick. Everything else — the daemon, the menu-bar app, a few hundred lines of Rust and Swift — is just plumbing around this one primitive. The hard part was never the code. It was knowing that the primitive existed, and that it was safe: an "Always Allow" grant on the Keychain item is bound to the calling binary's code signature, so if you route every write through Apple's own stable /usr/bin/security, the grant survives your own rebuilds. You authorize it once.

Building the plumbing with Claude Code

The tool is a fork of clauth, a terminal account-switcher, plus a menu-bar companion I called ccsbar (Claude Code Switcher Bar). Both were built almost entirely inside Claude Code, and the division of what it did versus what I did turned out to be the real lesson (more on that at the end).

The fork added three things. First, the Keychain hot-swap above — the switch that actually reaches a running session. Second, a headless daemon: it watches each account's usage, and when the active one crosses a threshold (95% of the 5-hour window, by default), it walks an ordered fallback chain to the next account with headroom and rewrites the Keychain. Unattended. Terminal closed. You come back from lunch and you're three accounts down the chain, still working. Third, a self-contained browser OAuth login, because Claude Code's own /login on macOS writes to a per-directory Keychain item that the fork can't see — so the fork mints its own tokens through the same PKCE flow and writes them where they belong.

The daemon publishes a small status.json every second and listens on a Unix socket. Which is where ccsbar comes in: a native SwiftUI menu-bar app that reads the status file and drives the socket. The usage bars it draws aren't the part I care about — the part I care about is that it forecasts the daemon's own next move: "Watching work — would switch to personal at 95%." That sentence is computed by a pure function that mirrors the daemon's chain-walk logic, line for line, so the prediction can never quietly drift from what the daemon will actually do.

The real design problem: a daemon's worst bug is the silent one

Here's what I didn't appreciate until I was living with it. A background daemon that switches your account has a specific failure mode that a normal app doesn't: it can fail silently and invisibly. The daemon dies and your usage numbers freeze at whatever they were — but they still look like live numbers. An auto-switch fires at 2am and you have no idea which account you're on. A login quietly expires and the daemon just... keeps serving the stale cached number, cheerfully, forever.

Every one of those is a case where the system knows something you don't. So the entire menu-bar redesign became one discipline: every silent state the daemon can have gets a loud, fixed home. A dead daemon turns the whole panel's data grey and stamps it "frozen 4m ago" — a frozen number must never impersonate a live one. An unattended rotation flashes "⇄ rotated to personal" in the menu bar for eight seconds, so the hero feature finally has visible proof it fired. A dropped login gets a red badge and a one-click "Log in again."

That last one is where the story gets honest.

The bug you only find by running it

I shipped the dropped-login detection, tested it against fixtures, all green. Then I actually used it, and a running Claude session started failing with "Not logged in · Please run /login" — on the exact account the daemon was cheerfully reporting as fine.

Three bugs, stacked, each hiding the next.

The first: the daemon's usage poller only tried to refresh a token when the API returned a 401. But a dead account was coming back 429 — rate-limited — and the poller treated every 429 as "endpoint is busy, try later" and fell back to the cached number. So a genuinely dead login hid behind a rate-limit code, forever. The fix was small but only obvious in hindsight: a 429 on a token that's also past its clock expiry isn't a rate limit, it's a dead token wearing a rate-limit costume — chase the refresh, and let it surface as broken.

The second: the "Log in again" button ran clauth login <account>, which refused with "a profile named X already exists" — the login command was built to create accounts, not re-authenticate existing ones. It failed before the browser ever opened.

The third was the one that actually caused the screenshot. Re-authenticating wrote fresh tokens into the account's profile and its .credentials.json file — but not the Keychain. And we just established that on macOS the running session reads the Keychain. So the reauth "succeeded," the file was pristine, and the live session kept reading the dead token from the one place I'd forgotten to rewrite. The fix was to force the Keychain write whenever you re-authenticate the account that's currently active — the same primitive from the very top of this post, applied in the one place I'd missed it.

None of the three showed up in a test. All three showed up in the first real run. The cert saga in an earlier post taught me the same thing from a different angle: some bugs live entirely in the gap between "the code is correct" and "the system behaves."

Where the line actually is

Looking back at the whole build, the interesting boundary wasn't between hard code and easy code. Claude Code wrote the Rust daemon, the concurrency-safe state handling, the pure forecast engine, the entire SwiftUI app — thousands of lines, most of it to completion, because all of it has clear success criteria. A chain-walk either matches the fixture or it doesn't. A socket command either round-trips or it doesn't.

The line was around truth it couldn't observe. The one fact this entire project rests on — that a running claude re-reads the Keychain per request — is not in any documentation. I had to verify it empirically: switch the Keychain item under a live session and watch the next request pick up the new account. No amount of reasoning gets you there; you have to run the experiment. And the two things I checked by hand instead of in an automated test stayed that way by design. One is a real switch against my live accounts: every claude on the machine reads that same one Keychain item, so a switch moves all of them at once — including whatever session I'm mid-task in — which makes it a thing to run deliberately, not fire in a test loop. The other is a browser OAuth sign-in you physically click through. Not because Claude Code couldn't drive them, but because their success criteria live outside the code, in a world it can't see.

That's the shape of it. Anything with a machine-checkable definition of done, the agent drives to completion. Anything whose truth lives in the running system — an empirical fact about macOS, a browser you have to click, a session that either recovers or doesn't — is where I stay in the loop.

The daemon's been rotating my accounts for days now. I don't notice it, which is exactly the point. The one time I did notice was when it flashed "⇄ rotated to personal" while I was mid-sentence — and kept going.


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0