Moves Claude Code between your Claude accounts as their usage limits fill, shows each account's usage under the prompt, and signs an account back in with one…

A Claude Code mod for people with more than one Claude account. It shows each account's usage under the prompt, and moves your session to another account before the one in use runs out.

<sub>A real session fed imitated accounts (demo/harness), so no real account appears.</sub>
/plugin install account-rotator --marketplace ivokund/claude-account-rotator
Then click the usage in the footer, press + Add an account and approve the sign-in in your browser. The account you are signed in with now is account 1.
A column per account. The top row is the room it has right now (its 5-hour window, or none once its week is spent), the bottom row its week: green with plenty left, red with little. The percents are the account in use's.
Click it for the panel: each account's usage and reset times, Sign in, Use now and Remove, + Add an account, Rotate now, Rotation, which picks the account it moves to, and Display, which picks the footer's look:

/login. /login signs in the account the session is on, which after a switch isn't Claude Code's login. The mod checks who each account's sign-in belongs to: one that now holds another account's asks for its own again, and the session moves off it; one signed in as an account not on the list becomes that account./tui fullscreen), the only one that passes clicks on: the panel opens with a click.set -g mouse on, and iTerm2's tmux -CC mode can't run fullscreen./login must be a personal one, Pro or Max: see Team and Enterprise accounts.Claude Code's own "You've used N% of your session limit" warning can still show for a minute after a switch: the switch lands within about 35 seconds, and warnings Claude Code queued before it still play. No mod can hide it.
Mods are early access in Claude Code, and this one relies on internals a release can change: where sign-ins are stored, and the usage endpoint behind /usage.
The mod moves between personal accounts and Team or Enterprise seats. But Claude Code stops plugins from switching accounts, for the rest of a session, when:
/reload-plugins, or installing, enabling or disabling a plugin (since 2.1.299).The mod tells you when this happens. To keep switching:
/login with your personal account (Pro or Max) in a new session. New sessions start on Claude Code's login, which the panel's second line names; /login changes it only in a session on it, and in a session the mod moved, signs in that account instead. It doesn't unblock the session it runs in: restart Claude after./login: + Add an account, and Sign in to renew.claude --continue or claude --resume to keep your conversation (or plain claude). Each starts on your /login. Not /restart: it stays on the account in use.The block comes from a built-in security default that stops plugins you install from changing where Claude Code reads its sign-in. It also runs on any machine with managed settings.
[!WARNING] This removes a security protection for every plugin and every session on the machine. With it off, a plugin you install can override what an organization's policy sets for Claude Code (its hooks, prompt content, settings, tool and MCP server rules) and change where Claude Code reads sign-ins and settings from. Anthropic's Usage Policy forbids intentionally bypassing restrictions or guardrails built into its products. This is a setting Claude Code offers administrators, but using it so a Team session can switch accounts may count as bypassing. Do it only on a machine you alone control, with plugins you trust. Never do it where an organization manages Claude Code, or where your Team or Enterprise organization's rules forbid it.
Managed settings decide whether the default runs: once they set prependPlugins, it runs only if listed there. The file must be owned by an administrator, since Claude Code ignores this key in a file your account can write. If the file below already exists, it holds your organization's policy: leave it alone. Otherwise:
# macOS
sudo mkdir -p "/Library/Application Support/ClaudeCode"
echo '{ "prependPlugins": [] }' | sudo tee "/Library/Application Support/ClaudeCode/managed-settings.json"
# Linux
sudo mkdir -p /etc/claude-code
echo '{ "prependPlugins": [] }' | sudo tee /etc/claude-code/managed-settings.json
It applies to sessions started after. Delete the file to turn the default back on. Managed settings an organization sends from Anthropic's servers can override it.
Untested: this follows how Claude Code 2.1.299 describes and reads the setting, but it has not been tried end to end, and a release can change it. You use it at your own risk.
~/.claude/accounts/<n>, and Claude Code keeps its sign-in in the keychain under that folder's name (on Linux, in that folder's .credentials.json), as for any config folder. A switch points the session at another account's sign-in./usage reads, and once per sign-in who it belongs to, from the one Claude Code asks at a sign-in, each with that account's own token. Tokens stay where Claude Code keeps them; the mod stores each account's email, organization, plan and last usage, and a digest of its token, to tell a new sign-in.Remove each account in the panel, which signs it out, then run claude plugin uninstall account-rotator@claude-account-rotator and delete ~/.claude/accounts.
As of October 2026:
claude auth login and keeps no tokens of its own.Terms change; this is not legal advice. Not affiliated with or endorsed by Anthropic.
claude --plugin-dir . # a session with the mod loaded from this folder
claude plugin validate .
claude plugin test .
demo/record.mjs records the GIF and demo/looks.mjs draws the footer's looks, both from a real session fed imitated accounts; their headers say what they need.
hooks/register.tsx 790 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 HookStream,
5 ProcessSpawnChunk,
6 ProcessSpawnResult,
7 Register,
8 SessionRateLimit,
9 Timer,
10} from 'claude-code'
11
12import type { Account, FooterStyle, Health, Holder, Reading, RotateTo, View } from '../types'
13import {
14 EMPTY_VIEW,
15 LEAVE_FIVE_HOUR,
16 blockedText,
17 credentialFrom,
18 credentialsFile,
19 fingerprint,
20 footerStyleFrom,
21 freeId,
22 healthOf,
23 identityFromProfile,
24 identityFromStatus,
25 isLoginOf,
26 isOnNormalLogin,
27 isPolicyRefusal,
28 keychainItem,
29 keychainUser,
30 label,
31 pickTarget,
32 rotateToFrom,
33 readingFromLimits,
34 readingFromUsage,
35 sameLogin,
36 sameUsage,
37 shouldLeave,
38 slotEnv,
39 windowPct,
40} from './model'
41import type { Credential, Identity } from './model'
42import { Band, Footer, Pane } from './view'
43import type { Actions } from './view'
44
45type Host = EngineInterface
46
47const PANE = 'claude-accounts'
48const TITLE = 'Claude accounts'
49/** The dock width the pane asks for: a login's line, its buttons and its usage rows fit on one line each. */
50const PANE_COLUMNS = 64
51/** Claude Code caches a keychain read for 30 s, so a switch lands within this. */
52const SETTLE_MS = 35_000
53/** A switch no response has shown after this long is reported as not taken. */
54const CONFIRM_GIVE_UP_MS = 4 * 60_000
55/**
56 * Claude Code refreshes a token 5 minutes before it expires. A refresh that
57 * runs while the old login's read is still cached is saved under the new
58 * slot, overwriting that login: so no switch this close to the expiry.
59 */
60const REFRESH_GUARD_MS = 8 * 60_000
61/** A file only macOS has: there Claude Code keeps logins in the keychain, elsewhere (Linux) in a file each. */
62const MACOS_MARKER = '/System/Library/CoreServices/SystemVersion.plist'
63const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
64const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
65const CONTINUE =
66 'Claude Code switched to another of your Claude accounts because the previous one hit its usage limit or lost its sign-in. Continue where you left off.'
67
68const viewAtom = atom({ plugin: 'account-rotator', key: 'view' } as const, EMPTY_VIEW)
69
70/**
71 * Whether this session's pane shows. A press opens or closes it in its own
72 * turn, with no read before: the engine counts an open as the person's only
73 * there, and places it at any width; one made after an await waits undrawn
74 * below 144 columns. Kept in step with closes by the person and with reloads.
75 */
76let isPaneShown = false
77/** The `claude auth login` child of the sign-in under way, so Cancel can end it. */
78let running: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | undefined
79/** The login this session is signing in; a stored 'logging-in' from a session that died is checked again. */
80let signingIn: string | undefined
81let signInQueue: Promise<void> = Promise.resolve()
82/** A switch the refresh guard holds back: the latest one asked for, tried again each minute until it goes. */
83let held: { target: Account; why: string; timer: Timer } | undefined
84/** Logins a switch to did not take this session: the rotation leaves them be; Use now still tries. */
85const untaken = new Set<string>()
86
87// ---------------------------------------------------------------- state
88// The view is this session's; the store is one JSON file every session of the
89// mod shares: the list of logins, each login's last reading and health under a
90// key of its own, so sessions writing different logins never collide, the
91// footer's look, and which login the rotation moves to.
92
93/** The view survives a hot reload as the code before wrote it: what this code added starts as in a new session. */
94const current = (v: View): View => ({ ...EMPTY_VIEW, ...v })
95const getView = async ($: Host): Promise<View> => current(await read($, viewAtom))
96const setView = ($: Host, fn: (v: View) => View): Promise<View> => update($, viewAtom, v => fn(current(v)))
97/** Something the person should know that nothing on screen says: a toast, on screen for a while. */
98const tell = ($: Host, text: string) => $.ui.toast(text, { timeoutMs: 8000 })
99const debug = ($: Host, text: string) => $.ui.log(`account-rotator: ${text}`, { to: 'debug' })
100
101function isAccount(value: unknown): value is Account {
102 if (typeof value !== 'object' || value === null) return false
103 const { id, slot } = value as Record<string, unknown>
104 return typeof id === 'string' && typeof slot === 'string'
105}
106
107async function loadAccounts($: Host): Promise<Account[]> {
108 const stored = await $.store.get('accounts')
109 return Array.isArray(stored) ? stored.filter(isAccount) : []
110}
111
112async function saveAccounts($: Host, accounts: Account[]): Promise<void> {
113 await $.store.set('accounts', accounts)
114 await setView($, v => ({ ...v, accounts }))
115}
116
117async function saveReading($: Host, id: string, reading: Reading): Promise<void> {
118 await $.store.set(`reading:${id}`, reading)
119 await setView($, v => ({ ...v, readings: { ...v.readings, [id]: reading } }))
120}
121
122/** Stores a login's health; answers what it was before. */
123async function saveHealth($: Host, id: string, health: Health): Promise<Health | undefined> {
124 const before = (await getView($)).health[id]
125 await $.store.set(`health:${id}`, health)
126 await setView($, v => ({ ...v, health: { ...v.health, [id]: health } }))
127 return before
128}
129
130async function saveHolder($: Host, id: string, holder: Holder): Promise<void> {
131 await $.store.set(`holder:${id}`, holder)
132 await setView($, v => ({ ...v, holders: { ...v.holders, [id]: holder } }))
133}
134
135/** Folds what other sessions stored into this session's view. */
136async function mergeStore($: Host): Promise<void> {
137 const accounts = await loadAccounts($)
138 const readings: Record<string, Reading> = {}
139 const health: Record<string, Health> = {}
140 const holders: Record<string, Holder> = {}
141 for (const a of accounts) {
142 const r = await $.store.get(`reading:${a.id}`)
143 if (typeof r === 'object' && r !== null && typeof (r as Reading).at === 'number') readings[a.id] = r as Reading
144 const h = await $.store.get(`health:${a.id}`)
145 if (typeof h === 'object' && h !== null && typeof (h as Health).kind === 'string') health[a.id] = h as Health
146 const s = await $.store.get(`holder:${a.id}`)
147 if (typeof s === 'object' && s !== null && typeof (s as Holder).token === 'string' && typeof (s as Holder).who?.email === 'string') {
148 holders[a.id] = s as Holder
149 }
150 }
151 const footer = footerStyleFrom(await $.store.get('footer'))
152 const rotateTo = rotateToFrom(await $.store.get('rotate-to'))
153 await setView($, v => {
154 const nextReadings: Record<string, Reading> = {}
155 const nextHealth: Record<string, Health> = {}
156 const nextHolders: Record<string, Holder> = {}
157 for (const a of accounts) {
158 const mine = v.readings[a.id]
159 const theirs = readings[a.id]
160 const reading = mine !== undefined && (theirs === undefined || mine.at >= theirs.at) ? mine : theirs
161 if (reading !== undefined) nextReadings[a.id] = reading
162 const own = v.health[a.id]
163 const h = signingIn === a.id ? own : (health[a.id] ?? own)
164 if (h !== undefined) nextHealth[a.id] = h
165 const holder = holders[a.id] ?? v.holders[a.id]
166 if (holder !== undefined) nextHolders[a.id] = holder
167 }
168 return { ...v, accounts, readings: nextReadings, health: nextHealth, holders: nextHolders, footer, rotateTo }
169 })
170}
171
172/** Changes the footer's look for every session. */
173async function setFooter($: Host, change: Partial<FooterStyle>): Promise<void> {
174 const footer = { ...(await getView($)).footer, ...change }
175 await $.store.set('footer', footer)
176 await setView($, v => ({ ...v, footer }))
177}
178
179/** Changes which login the rotation moves to, for every session. */
180async function setRotateTo($: Host, rotateTo: RotateTo): Promise<void> {
181 await $.store.set('rotate-to', rotateTo)
182 await setView($, v => ({ ...v, rotateTo }))
183}
184
185// ---------------------------------------------------------------- host
186
187type Stored = { state: 'present'; credential: Credential | undefined } | { state: 'absent' } | { state: 'unreadable'; code: number }
188
189/** A slot's stored login, from the keychain on macOS or its file elsewhere (Linux); the secret stays in this module. */
190async function readStored($: Host, slot: string): Promise<Stored> {
191 return (await $.fs.exists(MACOS_MARKER)) ? readKeychain($, slot) : readCredentialsFile($, slot)
192}
193
194async function readKeychain($: Host, slot: string): Promise<Stored> {
195 const user = keychainUser(await $.env.get('USER'))
196 const argv = ['security', 'find-generic-password', '-a', user, '-w', '-s', await keychainItem(slot)]
197 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 10_000 })
198 if (exitCode === 44) return { state: 'absent' }
199 if (exitCode !== 0) return { state: 'unreadable', code: exitCode }
200 return { state: 'present', credential: credentialFrom(stdout.trim()) }
201}
202
203/**
204 * A file that cannot be read holds no login, as Claude Code counts it: a
205 * missing or locked file, and one caught mid-write (Claude Code renames a new
206 * one into place, rewriting it in place only where that fails).
207 */
208async function readCredentialsFile($: Host, slot: string): Promise<Stored> {
209 const path = credentialsFile(slot, await $.env.get('HOME'))
210 const text = path === undefined ? undefined : await $.fs.read(path).catch(() => undefined)
211 return text === undefined ? { state: 'absent' } : { state: 'present', credential: credentialFrom(text) }
212}
213
214type UsageAnswer = { kind: 'ok'; body: unknown } | { kind: 'rejected' } | { kind: 'failed'; status: number }
215
216const oauthHeaders = (accessToken: string) => ({
217 Authorization: `Bearer ${accessToken}`,
218 'anthropic-beta': 'oauth-2025-04-20',
219 'Content-Type': 'application/json',
220})
221
222/** The usage of the login a token belongs to, as `/usage` reads it. */
223async function fetchUsage($: Host, accessToken: string): Promise<UsageAnswer> {
224 const res = await $.http.fetch(USAGE_URL, { headers: oauthHeaders(accessToken) })
225 if (res.status === 401 || res.status === 403) return { kind: 'rejected' }
226 if (!res.ok) return { kind: 'failed', status: res.status }
227 try {
228 return { kind: 'ok', body: JSON.parse(res.text) }
229 } catch {
230 // A 2xx that is not JSON counts as a failed call; the next check asks again.
231 return { kind: 'failed', status: res.status }
232 }
233}
234
235/** Who a token signs in, as Claude Code asks at a sign-in: email, organization and plan; undefined when the call fails. */
236async function fetchIdentity($: Host, accessToken: string): Promise<Identity | undefined> {
237 const res = await $.http.fetch(PROFILE_URL, { headers: oauthHeaders(accessToken) })
238 if (!res.ok) return undefined
239 try {
240 return identityFromProfile(JSON.parse(res.text))
241 } catch {
242 // A body that is not JSON says nothing of who; the next check asks again.
243 return undefined
244 }
245}
246
247/** Who a slot is signed in as: email, organization and plan. */
248async function signedInAs($: Host, slot: string): Promise<Identity | undefined> {
249 const { exitCode, stdout } = await $.process.run(['claude', 'auth', 'status', '--json'], {
250 env: slotEnv(slot),
251 timeoutMs: 20_000,
252 })
253 return exitCode === 0 ? identityFromStatus(stdout) : undefined
254}
255
256/** Points this session's requests, and every child it starts after, at a slot's login. */
257async function useSlot($: Host, slot: string): Promise<void> {
258 if (slot === '') await $.env.set('CLAUDE_SECURESTORAGE_CONFIG_DIR', undefined)
259 else await $.env.set('CLAUDE_SECURESTORAGE_CONFIG_DIR', slot)
260}
261
262/**
263 * Claude Code's security policy refuses switching in this session: says so
264 * once, as a toast and a notification, and the pane says so while it lasts.
265 * False when it was said already.
266 */
267async function blocked($: Host): Promise<boolean> {
268 const view = await getView($)
269 if (view.isSwitchBlocked) return false
270 await setView($, v => ({ ...v, isSwitchBlocked: true }))
271 const text = blockedText(isOnNormalLogin(view))
272 $.ui.toast(text, { timeoutMs: 15_000 })
273 await $.ui.notify(text, { title: TITLE })
274 return true
275}
276
277/**
278 * Sets the slot already in use, which changes nothing: the policy that refuses
279 * a switch refuses this too, so a session that cannot switch says so at its start.
280 */
281async function checkSwitching($: Host, slot: string): Promise<void> {
282 try {
283 await useSlot($, slot)
284 } catch (error) {
285 if (isPolicyRefusal(error)) await blocked($)
286 else await debug($, `switch check failed: ${String(error)}`)
287 }
288}
289
290// ---------------------------------------------------------------- checks and switching
291
292async function announceLockout($: Host, account: Account, why: string): Promise<void> {
293 const text = `Claude account ${label(account)} needs a sign-in (${why}). Click ⚠${account.id} under the prompt.`
294 $.ui.toast(text, { timeoutMs: 8000 })
295 await $.ui.notify(text, { title: TITLE })
296}
297
298/**
299 * Who a login's sign-in belongs to: as answered for its token before, else
300 * asked while the token is live. A /login in a session on a login signs in
301 * there, and a new sign-in brings a new token, so it is asked about again.
302 */
303async function holderOf($: Host, account: Account, credential: Credential, isLive: boolean): Promise<Holder | undefined> {
304 const token = await fingerprint(credential.refreshToken ?? credential.accessToken)
305 const known = (await getView($)).holders[account.id]
306 if (known?.token === token) return known
307 if (!isLive) return undefined
308 const who = await fetchIdentity($, credential.accessToken)
309 if (who?.email === undefined) return undefined
310 const holder = { token, who: { ...who, email: who.email, ...(credential.plan === undefined ? {} : { plan: credential.plan }) } }
311 await saveHolder($, account.id, holder)
312 return holder
313}
314
315/**
316 * A login's sign-in belongs to `who`. Another login on the list: this one
317 * holds that one's sign-in, answered so it asks for its own again. Else the
318 * login is who signed in, its name and plan kept current, and told when that
319 * is another account than before: a /login in a session on it did that.
320 */
321async function followSignIn($: Host, account: Account, who: Identity): Promise<Account | undefined> {
322 const accounts = await loadAccounts($)
323 const own = accounts.find(a => a.id === account.id)
324 if (own === undefined) return undefined
325 const isSame = own.email === undefined || isLoginOf(own, who)
326 const twin = isSame ? undefined : accounts.find(a => a.id !== own.id && isLoginOf(a, who))
327 if (twin !== undefined) return twin
328 const followed = { ...own, ...who }
329 if (followed.email !== own.email || followed.orgId !== own.orgId || followed.orgName !== own.orgName || followed.plan !== own.plan) {
330 await saveAccounts($, accounts.map(a => (a.id === own.id ? followed : a)))
331 }
332 if (!isSame) {
333 const name = `${followed.email}${followed.orgName ? ` · ${followed.orgName}` : ''}`
334 tell(
335 $,
336 own.slot === ''
337 ? `Claude Code's login, account ${own.id}, is ${name} now: new sessions start on it.`
338 : `Claude account ${own.id} is ${name} now: a /login in a session on account ${own.id} signs in there, not Claude Code's login.`,
339 )
340 }
341 return undefined
342}
343
344/**
345 * Re-reads one login's stored sign-in: who it belongs to and, while its token
346 * is live, its usage. One that is another login's on the list leaves this
347 * login needing its own, its usage that other's.
348 */
349async function checkAccount($: Host, account: Account): Promise<void> {
350 const now = await $.clock.now()
351 if (signingIn === account.id) return
352 const stored = await readStored($, account.slot)
353 if (stored.state === 'unreadable') {
354 tell($, `Could not read Claude account ${account.id} from the keychain (exit ${stored.code}); is it locked?`)
355 return
356 }
357 const credential = stored.state === 'present' ? stored.credential : undefined
358 let health = healthOf(credential, now)
359 if (health.kind === 'ok' && credential !== undefined) {
360 const isLive = credential.expiresAt > now + 60_000
361 const holder = await holderOf($, account, credential, isLive)
362 const twin = holder === undefined ? undefined : await followSignIn($, account, holder.who)
363 if (twin !== undefined) health = { kind: 'login-needed', why: `signed in as account ${twin.id} now` }
364 else if (isLive) {
365 const usage = await fetchUsage($, credential.accessToken)
366 if (usage.kind === 'rejected') health = { kind: 'login-needed', why: 'sign-in rejected' }
367 if (usage.kind === 'failed') await debug($, `usage for account ${account.id}: HTTP ${usage.status}`)
368 if (usage.kind === 'ok') {
369 const reading = readingFromUsage(usage.body, now)
370 if (reading !== undefined) await saveReading($, account.id, reading)
371 }
372 }
373 }
374 const before = await saveHealth($, account.id, health)
375 if (health.kind === 'login-needed' && before?.kind !== 'login-needed') {
376 await announceLockout($, account, health.why)
377 }
378}
379
380async function checkAll($: Host): Promise<void> {
381 for (const account of (await getView($)).accounts) {
382 try {
383 await checkAccount($, account)
384 } catch (error) {
385 await debug($, `checking account ${account.id} failed: ${String(error)}`)
386 }
387 }
388 await maybeRotate($)
389}
390
391/** Why the login in use is left, counted the way the footer shows it: room left, or what is used. */
392function whyLeave(view: View, now: number): string {
393 if (view.health[view.activeId]?.kind === 'login-needed') return `account ${view.activeId} needs a sign-in`
394 const r = view.readings[view.activeId]
395 const five = windowPct(r?.fiveHour, now) ?? 0
396 const [window, used] = five >= LEAVE_FIVE_HOUR ? ['5-hour window', five] : ['week', windowPct(r?.sevenDay, now) ?? 0]
397 return view.footer.fill === 'left'
398 ? `account ${view.activeId} has ${Math.round(100 - used)}% of its ${window} left`
399 : `account ${view.activeId} has used ${Math.round(used)}% of its ${window}`
400}
401
402/**
403 * Once the active login is spent or locked out, moves to the one the pane's
404 * Rotation setting picks; never while it is off, or Claude Code's policy refuses it.
405 */
406async function maybeRotate($: Host): Promise<boolean> {
407 const now = await $.clock.now()
408 const view = await getView($)
409 if (view.rotateTo === 'off' || view.isSwitchBlocked || view.pending !== undefined || !shouldLeave(view, now)) return false
410 // With none to move to, the band above the prompt says when the next login frees up.
411 const target = pickTarget({ ...view, accounts: view.accounts.filter(a => !untaken.has(a.id)) }, now)
412 return target === undefined ? false : switchTo($, target, whyLeave(view, now))
413}
414
415/** Points this session at `target`; false while the refresh guard holds it back, or Claude Code's policy refuses it. */
416async function switchTo($: Host, target: Account, why: string): Promise<boolean> {
417 const now = await $.clock.now()
418 const view = await getView($)
419 const active = view.accounts.find(a => a.id === view.activeId)
420 if (active !== undefined && active.id !== target.id) {
421 const stored = await readStored($, active.slot)
422 const credential = stored.state === 'present' ? stored.credential : undefined
423 if (credential !== undefined && credential.expiresAt > now && credential.expiresAt - now < REFRESH_GUARD_MS) {
424 if (held === undefined) {
425 tell($, `Switching to Claude account ${target.id} in a few minutes, once account ${active.id}'s token has refreshed.`)
426 const retry = () => {
427 if (held !== undefined) void switchTo($, held.target, held.why)
428 }
429 held = { target, why, timer: $.clock.every(60_000, retry) }
430 } else held = { ...held, target, why }
431 return false
432 }
433 }
434 held?.timer.cancel()
435 held = undefined
436 try {
437 await useSlot($, target.slot)
438 } catch (error) {
439 if (!isPolicyRefusal(error)) throw error
440 // A press of Use now or Rotate now gets an answer, even once the notice has shown.
441 if (!(await blocked($))) tell($, blockedText(isOnNormalLogin(await getView($))))
442 return false
443 }
444 await setView($, v => ({
445 ...v,
446 activeId: target.id,
447 pending: { id: target.id, from: v.activeId, since: now },
448 removing: undefined,
449 }))
450 $.ui.toast(`Switched to Claude account ${label(target)}: ${why}.`, { timeoutMs: 6000 })
451 return true
452}
453
454/**
455 * After a switch: which login answered the latest response? The new one
456 * (confirmed), or too soon to say. Only a response still answered by the old
457 * login minutes later is reported: an idle session has no new response to tell.
458 * Then the session is pointed back at the old login, and the footer and pane
459 * mark it in use again, as it is the one spending.
460 */
461async function settlePending($: Host, isResponse: boolean): Promise<void> {
462 const { pending, accounts } = await getView($)
463 if (pending === undefined) return
464 const now = await $.clock.now()
465 if (now - pending.since < SETTLE_MS) return
466 const reading = readingFromLimits((await $.session.usage()).rateLimits, now)
467 const target = accounts.find(a => a.id === pending.id)
468 if (target !== undefined) await checkAccount($, target)
469 if (sameUsage(reading, (await getView($)).readings[pending.id], now)) {
470 untaken.delete(pending.id)
471 await setView($, v => ({ ...v, pending: undefined }))
472 } else if (isResponse && now - pending.since > CONFIRM_GIVE_UP_MS) {
473 const from = accounts.find(a => a.id === pending.from)
474 if (from !== undefined) await checkAccount($, from)
475 if (from === undefined || !sameUsage(reading, (await getView($)).readings[from.id], now)) {
476 await setView($, v => ({ ...v, pending: undefined }))
477 tell($, `The switch to Claude account ${pending.id} has not shown in responses.`)
478 return
479 }
480 untaken.add(pending.id)
481 try {
482 await useSlot($, from.slot)
483 } catch (error) {
484 await debug($, `switch back failed: ${String(error)}`)
485 }
486 await setView($, v => ({ ...v, activeId: from.id, pending: undefined }))
487 tell(
488 $,
489 `The switch to Claude account ${pending.id} did not take: responses still use account ${label(from)}, so it shows in use again. ` +
490 `The rotation leaves account ${pending.id} be in this session; Use now tries it again.`,
491 )
492 }
493}
494
495/** A response's own usage: the active login's, unless a switch is still settling. */
496async function onMeasure($: Host, limits: readonly SessionRateLimit[]): Promise<void> {
497 const view = await getView($)
498 if (view.pending !== undefined) {
499 await settlePending($, true)
500 return
501 }
502 const reading = readingFromLimits(limits, await $.clock.now())
503 if (reading === undefined) return
504 await saveReading($, view.activeId, reading)
505 await maybeRotate($)
506}
507
508/** A turn that died on a usage limit or a rejected sign-in: switch, then carry on. */
509async function onStopFailure($: Host, error: string): Promise<void> {
510 if (error !== 'rate_limit' && error !== 'authentication_failed') return
511 const view = await getView($)
512 const active = view.accounts.find(a => a.id === view.activeId)
513 if (active === undefined) return
514 if (error === 'authentication_failed') {
515 const before = await saveHealth($, active.id, { kind: 'login-needed', why: 'sign-in rejected' })
516 if (before?.kind !== 'login-needed') await announceLockout($, active, 'sign-in rejected')
517 } else {
518 await checkAccount($, active)
519 const now = await $.clock.now()
520 const after = await getView($)
521 if (!shouldLeave(after, now)) {
522 // The limit was hit before the usage call shows it: count the 5-hour window as full.
523 const r = after.readings[active.id]
524 const resetsAt = r?.fiveHour?.resetsAt
525 await saveReading($, active.id, { ...r, fiveHour: { pct: 100, ...(resetsAt ? { resetsAt } : {}) }, at: now })
526 }
527 }
528 await setView($, v => ({ ...v, pending: undefined }))
529 if (await maybeRotate($)) {
530 $.clock.after(SETTLE_MS, () => void $.prompt.submit({ text: CONTINUE }))
531 }
532}
533
534// ---------------------------------------------------------------- sign-in
535
536/** Signs one login in through the browser; its slot gets the new credential. */
537async function signIn($: Host, account: Account): Promise<void> {
538 const since = await $.clock.now()
539 await saveHealth($, account.id, { kind: 'logging-in', since })
540 const argv = ['claude', 'auth', 'login', '--claudeai', ...(account.email ? ['--email', account.email] : [])]
541 // The child opens the browser itself. The link it prints is the paste-a-code
542 // flow, which a background child cannot finish, so stdout goes unread.
543 const child = $.process.spawn({ argv, env: slotEnv(account.slot) })
544 running = child
545 signingIn = account.id
546 let errors = ''
547 try {
548 for await (const { stream, text } of child) {
549 if (stream === 'stderr') errors += text
550 }
551 } catch (error) {
552 errors += String(error)
553 } finally {
554 running = undefined
555 signingIn = undefined
556 }
557 const code = await child.result.then(r => r.code, () => null)
558 await saveHealth($, account.id, { kind: 'login-needed', why: 'sign-in not finished' })
559 if (code !== 0) {
560 tell($, `Sign-in for Claude account ${account.id} did not finish${errors.trim() ? `: ${errors.trim().slice(0, 160)}` : '.'}`)
561 return
562 }
563 await adoptSignIn($, account)
564}
565
566/** After a browser sign-in: who signed in, and is it a login already on the list? */
567async function adoptSignIn($: Host, account: Account): Promise<void> {
568 const who = await signedInAs($, account.slot)
569 const accounts = await loadAccounts($)
570 const twin = who === undefined ? undefined : accounts.find(a => a.id !== account.id && sameLogin(a, who))
571 if (twin !== undefined) {
572 await saveHealth($, account.id, { kind: 'login-needed', why: `that was account ${label(twin)} again` })
573 tell(
574 $,
575 `Claude account ${account.id} signed in as account ${label(twin)}. Sign out of claude.ai in your browser (or pick another organization), then sign in again.`,
576 )
577 return
578 }
579 const updated = accounts.map(a => (a.id === account.id && who !== undefined ? { ...a, ...who } : a))
580 await saveAccounts($, updated)
581 await saveHealth($, account.id, { kind: 'ok' })
582 const fresh = updated.find(a => a.id === account.id) ?? account
583 await checkAccount($, fresh)
584 await maybeRotate($)
585}
586
587function queueSignIn(work: () => Promise<void>): Promise<void> {
588 signInQueue = signInQueue.then(work, work)
589 return signInQueue
590}
591
592/** Signs in, one after another, every login that needs it. */
593const signInAll = ($: Host) =>
594 queueSignIn(async () => {
595 const view = await getView($)
596 for (const account of view.accounts.filter(a => view.health[a.id]?.kind === 'login-needed')) {
597 await signIn($, account)
598 }
599 })
600
601const signInOne = ($: Host, id: string) =>
602 queueSignIn(async () => {
603 const account = (await getView($)).accounts.find(a => a.id === id)
604 if (account !== undefined) await signIn($, account)
605 })
606
607/** Adds a login in a new slot under ~/.claude/accounts and signs it in. */
608async function addAccount($: Host): Promise<void> {
609 const accounts = await loadAccounts($)
610 const id = freeId(accounts)
611 const slot = `${(await $.env.get('HOME')) ?? ''}/.claude/accounts/${id}`
612 await $.fs.write(`${slot}/README`, `Claude account ${id}, kept by the account-rotator mod.\n`)
613 await saveAccounts($, [...accounts, { id, slot }])
614 await saveHealth($, id, { kind: 'login-needed', why: 'not signed in yet' })
615 await signInOne($, id)
616}
617
618/** The second press of Remove: signs the slot out, then drops it from the list. */
619async function removeAccount($: Host, id: string): Promise<void> {
620 const view = await getView($)
621 const account = view.accounts.find(a => a.id === id)
622 if (account === undefined || account.slot === '') return
623 if (view.activeId === id) {
624 const other = view.accounts.find(a => a.id !== id && view.health[a.id]?.kind === 'ok')
625 if (other === undefined || !(await switchTo($, other, `account ${id} is being removed`))) {
626 tell($, `Claude account ${id} is in use and no other account can take over yet; not removed.`)
627 return
628 }
629 }
630 await $.process.run(['claude', 'auth', 'logout'], { env: slotEnv(account.slot), timeoutMs: 20_000 })
631 await saveAccounts($, view.accounts.filter(a => a.id !== id))
632 await $.store.delete(`reading:${id}`)
633 await $.store.delete(`health:${id}`)
634 await $.store.delete(`holder:${id}`)
635 await setView($, v => {
636 const readings = { ...v.readings }
637 const health = { ...v.health }
638 const holders = { ...v.holders }
639 delete readings[id]
640 delete health[id]
641 delete holders[id]
642 return { ...v, readings, health, holders, removing: undefined }
643 })
644}
645
646// ---------------------------------------------------------------- wiring
647
648/** The footer's one press: opens the pane, or closes it while it shows. */
649function togglePane($: Host): Promise<unknown> {
650 isPaneShown = !isPaneShown
651 return isPaneShown ? $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS }) : $.ui.close({ id: PANE })
652}
653
654/** After a /login, which signs in the login this session is on: who it is now, and a move off it when that is another login's account. */
655async function checkActive($: Host): Promise<void> {
656 const view = await getView($)
657 const active = view.accounts.find(a => a.id === view.activeId)
658 if (active !== undefined) await checkAccount($, active)
659 await maybeRotate($)
660}
661
662/** Rotate now: moves to the login the pane's Rotation setting picks, as the rotation would once this one runs low; with it off, the most room. */
663async function rotate($: Host): Promise<void> {
664 const view = await getView($)
665 const target = pickTarget(view, await $.clock.now())
666 if (target === undefined) tell($, 'No other Claude account can take over right now.')
667 else await switchTo($, target, 'you pressed Rotate now')
668}
669
670/** Every press starts its work in the background: a sign-in outlives the press. */
671function actions($: Host): Actions {
672 const background = (work: Promise<unknown>) => {
673 work.catch(error => tell($, `Something went wrong: ${String(error)}`))
674 }
675 return {
676 togglePane: () => background(togglePane($)),
677 rotate: () => background(rotate($)),
678 signInAll: () => background(signInAll($)),
679 signInOne: id => background(signInOne($, id)),
680 cancelSignIn: () => background(Promise.resolve(running?.return({ code: null, signal: 'SIGTERM' }))),
681 add: () => background(addAccount($)),
682 use: id =>
683 background(
684 getView($).then(view => {
685 const account = view.accounts.find(a => a.id === id)
686 return account === undefined ? false : switchTo($, account, 'you picked it')
687 }),
688 ),
689 askRemove: id => background(setView($, v => ({ ...v, removing: id }))),
690 remove: id => background(removeAccount($, id)),
691 setFooter: change => background(setFooter($, change)),
692 setRotateTo: rotateTo => background(setRotateTo($, rotateTo)),
693 setDemo: on => background(setView($, v => ({ ...v, demo: on }))),
694 }
695}
696
697/** First run makes the normal login number 1; then: which login this session uses. */
698async function boot($: Host): Promise<void> {
699 // Terminal.app is the terminal that names itself so and draws block characters from its font.
700 const hasFontBlocks = (await $.env.get('TERM_PROGRAM')) === 'Apple_Terminal'
701 await setView($, v => ({ ...v, hasFontBlocks }))
702 let accounts = await loadAccounts($)
703 if (accounts.length === 0) {
704 accounts = [{ id: '1', slot: '' }]
705 await saveAccounts($, accounts)
706 }
707 const slot = (await $.env.get('CLAUDE_SECURESTORAGE_CONFIG_DIR')) ?? ''
708 let active = accounts.find(a => a.slot === slot)
709 if (active === undefined) {
710 // Started on a slot nobody registered (set by hand, or by another tool): adopt it.
711 active = { id: freeId(accounts), slot }
712 accounts = [...accounts, active]
713 await saveAccounts($, accounts)
714 }
715 const activeId = active.id
716 await mergeStore($)
717 await setView($, v => ({ ...v, activeId }))
718 await checkSwitching($, slot)
719 // A live sign-in names its login as it checks; one whose token has run out, from what `claude auth login` left in its folder.
720 await checkAll($)
721 for (const account of (await loadAccounts($)).filter(a => a.email === undefined || a.orgId === undefined)) {
722 const who = await signedInAs($, account.slot)
723 if (who === undefined) continue
724 const latest = await loadAccounts($)
725 await saveAccounts($, latest.map(a => (a.id === account.id ? { ...a, ...who } : a)))
726 }
727}
728
729export const register: Register = on => {
730 on('session.start', async ($, e, next) => {
731 const started = await next(e)
732 void boot($).catch(error => debug($, `start failed: ${String(error)}`))
733 void $.ui.panes().then(panes => {
734 isPaneShown = panes.some(p => p.id === PANE && p.isShown)
735 })
736 $.clock.every(60_000, () => {
737 void mergeStore($)
738 .then(() => settlePending($, false))
739 .then(() => maybeRotate($))
740 .catch(error => debug($, `sync failed: ${String(error)}`))
741 })
742 $.clock.every(10 * 60_000, () => {
743 void checkAll($).catch(error => debug($, `check failed: ${String(error)}`))
744 })
745 return started
746 })
747
748 on('session.measure', async ($, e, next) => {
749 if (e.changed.includes('rateLimits')) {
750 void onMeasure($, e.rateLimits).catch(error => debug($, `measure failed: ${String(error)}`))
751 }
752 return next(e)
753 })
754
755 on('classic.StopFailure', async ($, e, next) => {
756 void onStopFailure($, e.error).catch(error => debug($, `stop failure handling failed: ${String(error)}`))
757 return next(e)
758 }).catch(($, e, next) => next(e))
759
760 // Typed at the prompt, /login answers once its sign-in is done; run mid-turn, at once, and the checks find it later.
761 on('command.run', { command: 'login' }, async ($, e, next) => {
762 const ran = await next(e)
763 void checkActive($).catch(error => debug($, `check after /login failed: ${String(error)}`))
764 return ran
765 }).catch(($, e, next) => next(e))
766
767 on('ui.close', async ($, e, next) => {
768 if (e.id === PANE) isPaneShown = false
769 return next(e)
770 })
771
772
773 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
774 const view = await getView($)
775 if (view.accounts.length === 0) return next(e)
776 return Footer($.ui.resolve(e), view, e.props.modes, await $.clock.now(), actions($))
777 })
778
779 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
780 if (e.props.hasSurvey) return next(e)
781 const view = await getView($)
782 return Band($.ui.resolve(e), view, await $.clock.now(), actions($)) ?? next(e)
783 })
784
785 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
786 const view = await getView($)
787 return Pane($.ui.resolve(e), view, await $.clock.now(), actions($))
788 })
789}
790hooks/model.ts 461 lines1import type { Account, Fill, FooterStyle, Glyphs, Health, Highlight, Reading, RotateTo, UsageWindow, View } from '../types'
2
3/** Leave a login once its 5-hour window is this full (percent). */
4export const LEAVE_FIVE_HOUR = 90
5/** Leave a login once its weekly window is this full (percent). */
6export const LEAVE_SEVEN_DAY = 95
7/** Warn this long before a login's refresh token runs out. */
8export const SIGN_IN_WARN_MS = 3 * 24 * 3600_000
9
10export const DEFAULT_FOOTER: FooterStyle = { session: 'braille', week: 'bars', sessionPercent: true, weekPercent: true, active: 'lines', fill: 'left' }
11
12export const DEFAULT_ROTATE_TO: RotateTo = 'reset'
13
14export const EMPTY_VIEW: View = {
15 accounts: [],
16 readings: {},
17 health: {},
18 holders: {},
19 activeId: '1',
20 footer: DEFAULT_FOOTER,
21 rotateTo: DEFAULT_ROTATE_TO,
22 hasFontBlocks: false,
23}
24
25export const GLYPHS: readonly Glyphs[] = ['bars', 'pie', 'braille', 'none']
26export const HIGHLIGHTS: readonly Highlight[] = ['lines', 'tile', 'dim']
27export const FILLS: readonly Fill[] = ['left', 'used']
28export const ROTATE_TOS: readonly RotateTo[] = ['reset', 'room', 'order', 'off']
29
30const isOneOf = <T extends string>(value: unknown, allowed: readonly T[]): value is T =>
31 typeof value === 'string' && (allowed as readonly string[]).includes(value)
32
33/** Which login the rotation moves to, as stored; unknown is the default's. */
34export function rotateToFrom(value: unknown): RotateTo {
35 return isOneOf(value, ROTATE_TOS) ? value : DEFAULT_ROTATE_TO
36}
37
38/** The footer's look as stored; any part missing or unknown is the default's. */
39export function footerStyleFrom(value: unknown): FooterStyle {
40 const v = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
41 return {
42 session: isOneOf(v.session, GLYPHS) ? v.session : DEFAULT_FOOTER.session,
43 week: isOneOf(v.week, GLYPHS) ? v.week : DEFAULT_FOOTER.week,
44 sessionPercent: typeof v.sessionPercent === 'boolean' ? v.sessionPercent : DEFAULT_FOOTER.sessionPercent,
45 weekPercent: typeof v.weekPercent === 'boolean' ? v.weekPercent : DEFAULT_FOOTER.weekPercent,
46 active: isOneOf(v.active, HIGHLIGHTS) ? v.active : DEFAULT_FOOTER.active,
47 fill: isOneOf(v.fill, FILLS) ? v.fill : DEFAULT_FOOTER.fill,
48 }
49}
50
51/** A window's percent now: 0 once its reset time has passed. */
52export function windowPct(w: UsageWindow | undefined, now: number): number | undefined {
53 if (w === undefined) return undefined
54 if (w.resetsAt !== undefined && Date.parse(w.resetsAt) <= now) return 0
55 return w.pct
56}
57
58/**
59 * What a login can serve right now, percent of its 5-hour window: a week's
60 * percent is a far bigger budget, so the session decides until the week is
61 * spent, and then nothing is. Undefined before any 5-hour reading.
62 */
63export function availableNow(r: Reading | undefined, now: number): number | undefined {
64 const five = roomLeft(r?.fiveHour, now)
65 if (five === undefined) return undefined
66 return (windowPct(r?.sevenDay, now) ?? 0) >= LEAVE_SEVEN_DAY ? 0 : five
67}
68
69export function isSpent(r: Reading | undefined, now: number): boolean {
70 return (
71 (windowPct(r?.fiveHour, now) ?? 0) >= LEAVE_FIVE_HOUR ||
72 (windowPct(r?.sevenDay, now) ?? 0) >= LEAVE_SEVEN_DAY
73 )
74}
75
76export function shouldLeave(view: View, now: number): boolean {
77 return view.health[view.activeId]?.kind === 'login-needed' || isSpent(view.readings[view.activeId], now)
78}
79
80/** When a login's 5-hour window resets, epoch ms; Infinity while none is running. */
81function fiveHourReset(r: Reading | undefined, now: number): number {
82 const w = r?.fiveHour
83 if (w?.resetsAt === undefined || (windowPct(w, now) ?? 0) === 0) return Infinity
84 return Date.parse(w.resetsAt)
85}
86
87/**
88 * The signed-in login below both thresholds to move to, as `rotateTo` picks:
89 * the one that can serve the most right now (unread counts as fresh); the one
90 * whose 5-hour window resets soonest, so room about to refill is used first,
91 * the most room breaking ties; or the next in the list after the one in use.
92 * With the rotation off, Rotate now takes the one with the most room.
93 */
94export function pickTarget(view: View, now: number): Account | undefined {
95 const ready = view.accounts.filter(
96 a => a.id !== view.activeId && view.health[a.id]?.kind === 'ok' && !isSpent(view.readings[a.id], now),
97 )
98 const available = (a: Account) => availableNow(view.readings[a.id], now) ?? 100
99 const byRoom = (a: Account, b: Account) => available(b) - available(a)
100 switch (view.rotateTo) {
101 case 'room':
102 case 'off':
103 return ready.sort(byRoom)[0]
104 case 'reset': {
105 const reset = (a: Account) => fiveHourReset(view.readings[a.id], now)
106 return ready.sort((a, b) => (reset(a) === reset(b) ? byRoom(a, b) : reset(a) < reset(b) ? -1 : 1))[0]
107 }
108 case 'order': {
109 const at = view.accounts.findIndex(a => a.id === view.activeId)
110 const after = [...view.accounts.slice(at + 1), ...view.accounts.slice(0, at + 1)]
111 return after.find(a => ready.includes(a))
112 }
113 }
114}
115
116/** When the soonest spent window among all logins resets, epoch ms. */
117export function nextReset(view: View, now: number): number | undefined {
118 const times = view.accounts.flatMap(a => {
119 const r = view.readings[a.id]
120 return [r?.fiveHour, r?.sevenDay]
121 .filter((w): w is UsageWindow => w !== undefined && w.resetsAt !== undefined && (windowPct(w, now) ?? 0) > 0)
122 .map(w => Date.parse(w.resetsAt ?? ''))
123 })
124 return times.length === 0 ? undefined : Math.min(...times)
125}
126
127function windowFrom(value: unknown): UsageWindow | undefined {
128 if (typeof value !== 'object' || value === null) return undefined
129 const { utilization, resets_at } = value as { utilization?: unknown; resets_at?: unknown }
130 if (typeof utilization !== 'number') return undefined
131 return { pct: utilization, ...(typeof resets_at === 'string' ? { resetsAt: resets_at } : {}) }
132}
133
134/** A reading from the body of GET /api/oauth/usage. */
135export function readingFromUsage(body: unknown, now: number): Reading | undefined {
136 if (typeof body !== 'object' || body === null) return undefined
137 const { five_hour, seven_day } = body as { five_hour?: unknown; seven_day?: unknown }
138 const fiveHour = windowFrom(five_hour)
139 const sevenDay = windowFrom(seven_day)
140 if (fiveHour === undefined && sevenDay === undefined) return undefined
141 return { ...(fiveHour ? { fiveHour } : {}), ...(sevenDay ? { sevenDay } : {}), at: now }
142}
143
144/** A reading from the rate-limit windows the last response reported. */
145export function readingFromLimits(
146 limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[],
147 now: number,
148): Reading | undefined {
149 const pick = (kind: string): UsageWindow | undefined => {
150 const w = limits.find(l => l.kind === kind)
151 return w && { pct: w.percentUsed, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) }
152 }
153 const fiveHour = pick('five_hour')
154 const sevenDay = pick('seven_day')
155 if (fiveHour === undefined && sevenDay === undefined) return undefined
156 return { ...(fiveHour ? { fiveHour } : {}), ...(sevenDay ? { sevenDay } : {}), at: now }
157}
158
159/** True when two readings agree within a few points on every shared window. */
160export function sameUsage(a: Reading | undefined, b: Reading | undefined, now: number): boolean {
161 if (a === undefined || b === undefined) return false
162 const pairs: [UsageWindow | undefined, UsageWindow | undefined][] = [
163 [a.fiveHour, b.fiveHour],
164 [a.sevenDay, b.sevenDay],
165 ]
166 const shared = pairs.filter(([x, y]) => x !== undefined && y !== undefined)
167 return (
168 shared.length > 0 &&
169 shared.every(([x, y]) => Math.abs((windowPct(x, now) ?? 0) - (windowPct(y, now) ?? 0)) <= 3)
170 )
171}
172
173/** The part of a stored login Claude Code keeps in the keychain (on Linux, a file) that matters here. */
174export type Credential = {
175 accessToken: string
176 expiresAt: number
177 refreshTokenExpiresAt?: number
178 /** A new sign-in brings a new one. */
179 refreshToken?: string
180 /** `subscriptionType`, the plan Claude Code reads from the sign-in: on a Team or Enterprise one it keeps plugins from switching. */
181 plan?: string
182}
183
184export function credentialFrom(text: string): Credential | undefined {
185 let parsed: unknown
186 try {
187 parsed = JSON.parse(text)
188 } catch {
189 // An entry that is not JSON is no usable login: the caller asks for a sign-in.
190 return undefined
191 }
192 const oauth = (parsed as { claudeAiOauth?: unknown } | null)?.claudeAiOauth
193 if (typeof oauth !== 'object' || oauth === null) return undefined
194 const { accessToken, expiresAt, refreshTokenExpiresAt, refreshToken, subscriptionType } = oauth as Record<string, unknown>
195 if (typeof accessToken !== 'string' || typeof expiresAt !== 'number') return undefined
196 return {
197 accessToken,
198 expiresAt,
199 ...(typeof refreshTokenExpiresAt === 'number' ? { refreshTokenExpiresAt } : {}),
200 ...(typeof refreshToken === 'string' ? { refreshToken } : {}),
201 ...(typeof subscriptionType === 'string' ? { plan: subscriptionType } : {}),
202 }
203}
204
205/** A login's health from its stored credential alone. */
206export function healthOf(cred: Credential | undefined, now: number): Health {
207 if (cred === undefined) return { kind: 'login-needed', why: 'not signed in' }
208 if (cred.refreshTokenExpiresAt !== undefined && cred.refreshTokenExpiresAt <= now) {
209 return { kind: 'login-needed', why: 'sign-in expired' }
210 }
211 return { kind: 'ok', ...(cred.refreshTokenExpiresAt ? { refreshExpiresAt: cred.refreshTokenExpiresAt } : {}) }
212}
213
214/** The percent a glyph or bar fills to and the footer prints: the room left, or what is used. */
215export function shown(fill: Fill, leftPct: number): number {
216 return fill === 'left' ? leftPct : 100 - leftPct
217}
218
219/** Percent of a window still free; undefined when the window has no reading. */
220export function roomLeft(w: UsageWindow | undefined, now: number): number | undefined {
221 const pct = windowPct(w, now)
222 return pct === undefined ? undefined : Math.round(100 - pct)
223}
224
225/**
226 * The color for a percent of room left: green with all of it, through yellow,
227 * to red with none (hue 120 down to 0); lighter while the pointer is over it.
228 */
229export function roomColor(leftPct: number, isLit = false): string {
230 const hue = Math.max(0, Math.min(100, leftPct)) * 1.2
231 const [s, l] = [0.75, isLit ? 0.72 : 0.55]
232 const a = s * Math.min(l, 1 - l)
233 const channel = (n: number) => {
234 const k = (n + hue / 30) % 12
235 const v = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
236 return Math.round(v * 255).toString(16).padStart(2, '0')
237 }
238 return `#${channel(0)}${channel(8)}${channel(4)}`
239}
240
241const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
242
243/** A vertical bar for a percentage, an eighth to full height. */
244export function bar(pct: number): string {
245 return BARS[Math.min(7, Math.max(0, Math.round((pct / 100) * 7)))] ?? '█'
246}
247
248const BRAILLE = ['⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿']
249
250/** A braille cell filled from the bottom for a percentage, one dot to all eight. */
251export function braille(pct: number): string {
252 return BRAILLE[Math.min(7, Math.max(0, Math.round((pct / 100) * 7)))] ?? '⣿'
253}
254
255const PIES = ['○', '◔', '◑', '◕', '●']
256
257/** A pie for a percentage, empty to full in quarters; a dot when unknown. */
258export function pie(pct: number | undefined): string {
259 if (pct === undefined) return '·'
260 return PIES[Math.min(4, Math.max(0, Math.round(pct / 25)))] ?? '●'
261}
262
263/** One login's glyph for a percentage in a footer row's style. */
264export function glyph(style: Glyphs, pct: number): string {
265 switch (style) {
266 case 'bars':
267 return bar(pct)
268 case 'pie':
269 return pie(pct)
270 case 'braille':
271 return braille(pct)
272 case 'none':
273 return ''
274 }
275}
276
277/** A ten-cell bar for the pane, filled by a percentage. */
278export function longBar(pct: number | undefined): string {
279 if (pct === undefined) return '··········'
280 const full = Math.min(10, Math.max(0, Math.round(pct / 10)))
281 return '▰'.repeat(full) + '▱'.repeat(10 - full)
282}
283
284/** "in 1h 20m", "in 3d 4h": relative, so it needs no time zone. */
285export function untilText(at: number | string | undefined, now: number): string {
286 if (at === undefined) return ''
287 const ms = (typeof at === 'string' ? Date.parse(at) : at) - now
288 if (!Number.isFinite(ms)) return ''
289 if (ms <= 0) return 'now'
290 const minutes = Math.round(ms / 60_000)
291 if (minutes < 60) return `in ${minutes}m`
292 const hours = Math.floor(minutes / 60)
293 if (hours < 24) return `in ${hours}h ${minutes % 60}m`
294 return `in ${Math.floor(hours / 24)}d ${hours % 24}h`
295}
296
297export function label(account: Account | undefined): string {
298 if (account === undefined) return '?'
299 if (account.email === undefined) return account.id
300 return `${account.id} (${account.email}${account.orgName ? ` · ${account.orgName}` : ''})`
301}
302
303/**
304 * The keychain entry Claude Code keeps a slot's login in. Mirrors Claude Code
305 * 2.1.296: the normal login has no suffix; any other slot is suffixed with
306 * the first 8 hex digits of the SHA-256 of its directory, NFC-normalized.
307 */
308export async function keychainItem(slot: string): Promise<string> {
309 if (slot === '') return 'Claude Code-credentials'
310 return `Claude Code-credentials-${(await sha256(slot.normalize('NFC'))).slice(0, 8)}`
311}
312
313async function sha256(text: string): Promise<string> {
314 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
315 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
316}
317
318/** A digest of a sign-in's token: tells a new sign-in from the one asked about, without keeping the token. */
319export async function fingerprint(token: string): Promise<string> {
320 return (await sha256(token)).slice(0, 16)
321}
322
323/**
324 * The file Claude Code keeps a slot's login in where there is no keychain
325 * (Linux): where 2.1.296 puts it for a `claude` child run with `slotEnv`, the
326 * normal login's in ~/.claude and any other slot's in its own directory,
327 * NFC-normalized. Undefined for the normal login with no home. A session
328 * started with CLAUDE_CONFIG_DIR set keeps its normal login in that directory
329 * instead, which this misses as `keychainItem` does on macOS.
330 */
331export function credentialsFile(slot: string, home: string | undefined): string | undefined {
332 const dir = slot === '' ? home && `${home}/.claude` : slot
333 return dir ? `${dir.normalize('NFC')}/.credentials.json` : undefined
334}
335
336/** The environment that points a `claude` child at a slot's login. */
337export function slotEnv(slot: string): Record<string, string> {
338 return slot === ''
339 ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: '' }
340 : { CLAUDE_SECURESTORAGE_CONFIG_DIR: slot, CLAUDE_CONFIG_DIR: slot }
341}
342
343/**
344 * Whether an error is Claude Code's security default refusing the switch: in
345 * a session on a Team or Enterprise account, or on a machine with managed
346 * settings, it keeps a plugin you install from changing where sign-ins are read.
347 */
348export function isPolicyRefusal(error: unknown): boolean {
349 return String(error).includes('is not for plugins outside policy to change')
350}
351
352/** Whether this session reads the normal login, the one `/login` signs in, rather than a slot the mod moved it to. */
353export function isOnNormalLogin(view: View): boolean {
354 return (view.accounts.find(a => a.id === view.activeId)?.slot ?? '') === ''
355}
356
357/**
358 * Why switching is blocked, and how to get a session that can switch. One on
359 * the normal login started on a Team seat: signing that login in to a personal
360 * account fixes the next session. One on a seat reloaded plugins there, or
361 * started there, as `/restart` keeps the slot in use; a new session from the
362 * terminal starts on the normal login already. `/login` there would sign in
363 * the seat instead, as sign-ins follow the slot in use.
364 */
365export function blockedAdvice(isNormalLogin: boolean): string {
366 return isNormalLogin
367 ? "Claude Code doesn't let plugins switch accounts in a session that starts on a Team or Enterprise account. " +
368 'Fix: /login with your personal (Pro or Max) account, then restart Claude.'
369 : "Claude Code doesn't let plugins switch accounts once a session on a Team or Enterprise account starts or reloads plugins. " +
370 'Fix: quit Claude and run `claude --continue` or `claude --resume`. Not /restart, which keeps this account.'
371}
372
373/** What the mod says once Claude Code's security default refuses switching. */
374export function blockedText(isNormalLogin: boolean): string {
375 return (
376 `Can't switch accounts in this session. ${blockedAdvice(isNormalLogin)} ` +
377 'More: https://github.com/ivokund/claude-account-rotator#team-and-enterprise-accounts'
378 )
379}
380
381/** The keychain account name Claude Code files its entries under. */
382export function keychainUser(user: string | undefined): string {
383 return user !== undefined && /^[a-zA-Z0-9._-]+$/.test(user) ? user : 'claude-code-user'
384}
385
386/** The first number from 2 up that no login uses yet. */
387export function freeId(accounts: readonly Account[]): string {
388 const taken = new Set(accounts.map(a => a.id))
389 let n = 2
390 while (taken.has(String(n))) n += 1
391 return String(n)
392}
393
394export type Identity = Pick<Account, 'email' | 'orgId' | 'orgName' | 'plan'>
395
396/** Who a slot is signed in as, from `claude auth status --json`; undefined when signed out. */
397export function identityFromStatus(stdout: string): Identity | undefined {
398 let status: Record<string, unknown>
399 try {
400 const parsed: unknown = JSON.parse(stdout)
401 if (typeof parsed !== 'object' || parsed === null) return undefined
402 status = parsed as Record<string, unknown>
403 } catch {
404 // Output that is not JSON leaves who it is unknown; the label falls back to the number.
405 return undefined
406 }
407 if (status.loggedIn !== true || typeof status.email !== 'string') return undefined
408 return {
409 email: status.email,
410 ...(typeof status.orgId === 'string' ? { orgId: status.orgId } : {}),
411 ...(typeof status.orgName === 'string' ? { orgName: status.orgName } : {}),
412 ...(typeof status.subscriptionType === 'string' ? { plan: status.subscriptionType } : {}),
413 }
414}
415
416/** The same login: one email in one organization. */
417export function sameLogin(a: Identity, b: Identity): boolean {
418 return a.email !== undefined && a.email === b.email && (a.orgId ?? '') === (b.orgId ?? '')
419}
420
421/** A listed login is the account `who` signed in as, as far as it is known: by its email, and its organization once known. */
422export function isLoginOf(a: Identity, who: Identity): boolean {
423 return a.email !== undefined && a.email === who.email && (a.orgId === undefined || a.orgId === who.orgId)
424}
425
426/**
427 * Who a token signs in, from the body of GET /api/oauth/profile, which Claude
428 * Code asks at a sign-in: email and organization. The plan is the sign-in's own.
429 */
430export function identityFromProfile(body: unknown): Identity | undefined {
431 const field = (value: unknown, key: string) => (typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined)
432 const { account, organization } = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
433 const [email, orgId, orgName] = [field(account, 'email'), field(organization, 'uuid'), field(organization, 'name')]
434 if (typeof email !== 'string' || email === '') return undefined
435 return {
436 email,
437 ...(typeof orgId === 'string' ? { orgId } : {}),
438 ...(typeof orgName === 'string' ? { orgName } : {}),
439 }
440}
441
442/** A Team or Enterprise plan: Claude Code keeps plugins from switching accounts in a session that starts on one. */
443export const isSeatPlan = (plan: string | undefined): boolean => plan === 'team' || plan === 'enterprise'
444
445/** A plan as the pane names it: personal Max or Pro, or a Team or Enterprise seat. */
446export function planText(plan: string): string {
447 switch (plan) {
448 case 'max':
449 return 'personal Max'
450 case 'pro':
451 return 'personal Pro'
452 case 'team':
453 return 'a Team seat'
454 case 'enterprise':
455 return 'an Enterprise seat'
456 default:
457 return plan
458 }
459}
460
461hooks/view.tsx 573 lines1import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
2
3import type { Account, Fill, FooterRun, FooterStyle, Glyphs, Highlight, RotateTo, UsageWindow, View } from '../types'
4import {
5 GLYPHS,
6 LEAVE_FIVE_HOUR,
7 LEAVE_SEVEN_DAY,
8 SIGN_IN_WARN_MS,
9 availableNow,
10 blockedAdvice,
11 glyph,
12 isOnNormalLogin,
13 isSeatPlan,
14 longBar,
15 nextReset,
16 planText,
17 roomColor,
18 roomLeft,
19 shown,
20 untilText,
21 windowPct,
22} from './model'
23
24export type Ui = Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
25
26export type Actions = {
27 togglePane: () => void
28 rotate: () => void
29 signInAll: () => void
30 signInOne: (id: string) => void
31 cancelSignIn: () => void
32 add: () => void
33 use: (id: string) => void
34 askRemove: (id: string) => void
35 remove: (id: string) => void
36 setFooter: (change: Partial<FooterStyle>) => void
37 setRotateTo: (rotateTo: RotateTo) => void
38 setDemo: (on: boolean) => void
39}
40
41const needsSignIn = (view: View) => view.accounts.filter(a => view.health[a.id]?.kind === 'login-needed')
42
43/** Imitated room left per login, [right now, week], for the pane's demo preview: every color shows. */
44const DEMO: readonly (readonly [number, number])[] = [[85, 70], [60, 40], [35, 90], [15, 20], [95, 55]]
45
46/** A login's room left right now and in its week, or the demo's; undefined where it has none. */
47function rooms(view: View, a: Account, i: number, now: number): readonly [number | undefined, number | undefined] {
48 if (view.demo) return DEMO[i % DEMO.length] ?? [undefined, undefined]
49 const r = view.readings[a.id]
50 return [availableNow(r, now), roomLeft(r?.sevenDay, now)]
51}
52
53/** A percent's room color, lighter while the pointer is over the footer. */
54const roomRun = (leftPct: number) => ({ color: roomColor(leftPct), lit: roomColor(leftPct, true) })
55
56/**
57 * A login's glyph in a footer row, green with all of its room left to red with
58 * none. The session row says when a login needs a sign-in; a login without the
59 * room (a plan with no weekly limit) leaves its place blank.
60 */
61function glyphOf(view: View, a: Account, style: Glyphs, row: 0 | 1, room: number | undefined): FooterRun {
62 const health = view.health[a.id]?.kind
63 if (health === 'login-needed') return row === 0 ? { text: '⚠', color: 'error' } : { text: ' ' }
64 if (health === 'logging-in') return row === 0 ? { text: '◌', color: 'warning' } : { text: ' ' }
65 if (room === undefined) return { text: row === 0 ? '·' : ' ' }
66 return { text: glyph(style, shown(view.footer.fill, room)), ...roomRun(room) }
67}
68
69/** The tile's color: the theme's dim gray, the lines' own, which stands apart from the screen and from the shade under the pointer. */
70const TILE = 'subtle'
71/** The lines' color: the theme's dim gray, so they frame the column without outshining it; its lighter gray under the pointer. */
72const LINE = { color: 'subtle', lit: 'inactive' }
73
74/** How a login's glyph shows it is the one in use, as the pane's setting picks. */
75function marked(highlight: Highlight, isActive: boolean): Partial<FooterRun> {
76 switch (highlight) {
77 case 'tile':
78 return isActive ? { backgroundColor: TILE } : {}
79 case 'dim':
80 return isActive ? {} : { dimColor: true }
81 case 'lines':
82 return {}
83 }
84}
85
86/** The highlights drawn in the cells either side of a column: every row keeps those cells. */
87const IN_GAPS: readonly Highlight[] = ['lines', 'tile']
88
89/** A row's room left for the account in use, or undefined where it has none. */
90function activeRoom(view: View, now: number, row: 0 | 1): number | undefined {
91 const i = view.accounts.findIndex(a => a.id === view.activeId)
92 const active = view.accounts[i]
93 return active === undefined ? undefined : rooms(view, active, i, now)[row]
94}
95
96/**
97 * The cell before column `i`, or after the last one at `i` = the number of
98 * accounts: a space between columns, nothing at the ends, unless the highlight
99 * draws there. The lines stand mid-cell either side of the column in use, half
100 * a cell clear of its glyphs. The tile pads it with half of each of those
101 * cells, in half blocks, which most terminals draw to fill their cells; where
102 * the terminal draws them from its font, short of the line, they would notch
103 * the shade, so there it takes the whole cells.
104 */
105function edge(view: View, i: number, inGaps: boolean): FooterRun[] {
106 if (!inGaps) return i > 0 && i < view.accounts.length ? [{ text: ' ' }] : []
107 const active = view.accounts.findIndex(a => a.id === view.activeId)
108 if (i !== active && i - 1 !== active) return [{ text: ' ' }]
109 if (view.footer.active === 'lines') return [{ text: '│', ...LINE }]
110 if (view.hasFontBlocks) return [{ text: ' ', backgroundColor: TILE }]
111 return [{ text: i === active ? '▐' : '▌', color: TILE }]
112}
113
114/**
115 * One footer row as runs of text: a glyph per account, a space apart, then the
116 * account in use's percent at the right end. While another row draws glyphs, a
117 * row with its own off keeps their columns blank, so its percent lines up
118 * under the other's.
119 */
120function footerRow(view: View, now: number, row: 0 | 1, style: Glyphs, showPercent: boolean, hasColumns: boolean): FooterRun[] {
121 const pct = activeRoom(view, now, row)
122 const inGaps = hasColumns && IN_GAPS.includes(view.footer.active)
123 const columns = hasColumns
124 ? [
125 ...view.accounts.flatMap((a, i) => [
126 ...edge(view, i, inGaps),
127 {
128 ...(style === 'none' ? { text: ' ' } : glyphOf(view, a, style, row, rooms(view, a, i, now)[row])),
129 ...marked(view.footer.active, a.id === view.activeId),
130 },
131 ]),
132 ...edge(view, view.accounts.length, inGaps),
133 ]
134 : []
135 // A cell of the hover shade left of the glyphs where no highlight keeps one before the first column.
136 const lead: FooterRun[] = inGaps ? [] : [{ text: ' ' }]
137 if (!showPercent) return [...lead, ...columns]
138 const text = `${hasColumns ? (inGaps ? ' ' : ' ') : ''}${(pct === undefined ? '' : `${shown(view.footer.fill, pct)}%`).padStart(4)}`
139 return [...lead, ...columns, { text, ...(pct === undefined ? {} : roomRun(pct)) }]
140}
141
142/** The shade the footer's glyphs stand on under the pointer: the theme's own behind a message the person sent. */
143const HOVER_SHADE = 'userMessageBackground'
144
145const widthOf = (row: readonly FooterRun[]) => row.reduce((n, run) => n + run.text.length, 0)
146
147/**
148 * The rows' runs as Text, rows a line apart and padded a cell past the widest,
149 * so the hover shade is one even block with room right of the percents, as the
150 * cell before the glyphs gives it at the left. Under the pointer a Button is
151 * drawn inverted, its colors swapped, so each run's hover is given swapped: its
152 * lighter color as the background, the shade as the color. Inverted, that is
153 * the glyphs lit on the shade, and the tile's cells on their own color.
154 */
155function runsText({ Text }: Ui, rows: readonly FooterRun[][]) {
156 const width = Math.max(...rows.map(widthOf)) + 1
157 return rows.flatMap((row, i) => [
158 ...(i > 0 ? ['\n'] : []),
159 <Text>
160 {[...row, ...(widthOf(row) < width ? [{ text: ' '.repeat(width - widthOf(row)) }] : [])].map(run => (
161 <Text
162 color={run.color}
163 backgroundColor={run.backgroundColor}
164 dimColor={run.dimColor}
165 hover={{ color: run.backgroundColor ?? HOVER_SHADE, backgroundColor: run.lit ?? run.color ?? 'text', dimColor: false }}
166 >
167 {run.text}
168 </Text>
169 ))}
170 </Text>,
171 ])
172}
173
174/** What each footer row counts, said left of it while the pointer is over the footer. */
175const ROW_NAMES = ['Session:', 'Weekly:'] as const
176
177/**
178 * The footer, right of the prompt: the engine's mode labels, then a row per
179 * row picked in the pane (right now, the week), glyphs and percent each on or
180 * off, as one control that opens the pane, or closes it while it shows. Under
181 * the pointer the glyphs light up on a shade, and each row's name shows at its
182 * left. A week row with nothing to show is left out; with both rows off, a
183 * word keeps the control.
184 */
185export function Footer(ui: Ui, view: View, modes: readonly string[], now: number, act: Actions) {
186 const { Box, Text, Button } = ui
187 const { session, week, sessionPercent, weekPercent } = view.footer
188 const hasWeek = view.accounts.some((a, i) => rooms(view, a, i, now)[1] !== undefined)
189 const sessionGlyphs = session !== 'none'
190 const weekGlyphs = week !== 'none' && hasWeek
191 const hasColumns = sessionGlyphs || weekGlyphs
192 const weekShown = weekGlyphs || (weekPercent && activeRoom(view, now, 1) !== undefined)
193 const shownRows = [...(sessionGlyphs || sessionPercent ? [0 as const] : []), ...(weekShown ? [1 as const] : [])]
194 const rows = shownRows.map(row =>
195 row === 0 ? footerRow(view, now, 0, session, sessionPercent, hasColumns) : footerRow(view, now, 1, week, weekPercent, hasColumns),
196 )
197 return (
198 <Box flexDirection="row" columnGap={1}>
199 {modes.length > 0 && <Text dimColor>{modes.join(' & ')}</Text>}
200 <Box key="accounts-area" flexDirection="row" columnGap={1}>
201 {rows.length > 0 && (
202 <Box display="none" hover={{ display: 'flex' }} flexDirection="column" alignItems="flex-end">
203 {shownRows.map(row => (
204 <Text dimColor>{ROW_NAMES[row]}</Text>
205 ))}
206 </Box>
207 )}
208 {/* Switching is blocked: a press opens the pane, whose first line says why. */}
209 {view.isSwitchBlocked && (
210 <Button key="switch-blocked" label="Switching blocked" plain onPress={act.togglePane}>
211 <Text color="warning">⚠</Text>
212 </Button>
213 )}
214 {/* A scope of its own: the glyphs light only while the pointer is on them, as the control inverts. */}
215 <Box key="accounts-glyphs">
216 <Button key="accounts" label="Claude accounts" plain onPress={act.togglePane}>
217 {rows.length === 0 ? <Text dimColor>accounts</Text> : runsText(ui, rows)}
218 </Button>
219 </Box>
220 </Box>
221 </Box>
222 )
223}
224
225/** The pane's mark for what is in use or picked: the theme's yellow. */
226const ACCENT = 'warning'
227
228/** A setting that is on or off, as a press that flips it. */
229function toggle(ui: Ui, key: string, label: string, on: boolean, set: (on: boolean) => void) {
230 const { Text, Button } = ui
231 return (
232 <Button key={key} plain onPress={() => set(!on)}>
233 <Text color={on ? ACCENT : undefined} dimColor={!on}>
234 {on ? '■' : '□'} {label}
235 </Text>
236 </Button>
237 )
238}
239
240/** Settings' names take a column of their own; their presses wrap under each other on a narrow pane. */
241const NAME_WIDTH = 9
242
243/** One setting: its name, then its options as presses, the picked one marked. */
244function choice<T extends string>(
245 ui: Ui,
246 key: string,
247 name: string,
248 options: readonly (readonly [T, string])[],
249 picked: T,
250 pick: (value: T) => void,
251) {
252 const { Box, Text, Button } = ui
253 return (
254 <Box key={key} flexDirection="row">
255 <Box width={NAME_WIDTH} flexShrink={0}>
256 <Text dimColor>{name}</Text>
257 </Box>
258 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
259 {options.map(([value, label]) => (
260 <Button key={`${key}-${value}`} plain onPress={() => pick(value)}>
261 <Text color={value === picked ? ACCENT : undefined} dimColor={value !== picked}>
262 {value === picked ? '●' : '○'} {label}
263 </Text>
264 </Button>
265 ))}
266 </Box>
267 </Box>
268 )
269}
270
271const GLYPH_CHOICES = GLYPHS.map(g => [g, g] as const)
272const HIGHLIGHT_CHOICES = [
273 ['lines', 'lines'],
274 ['tile', 'tile'],
275 ['dim', 'dim the rest'],
276] as const
277const FILL_CHOICES = [
278 ['left', 'room left'],
279 ['used', 'used'],
280] as const satisfies readonly (readonly [Fill, string])[]
281const ROTATE_CHOICES = [
282 ['reset', 'soonest reset'],
283 ['room', 'most room'],
284 ['order', 'next in list'],
285 ['off', 'disabled'],
286] as const satisfies readonly (readonly [RotateTo, string])[]
287const PREVIEW_CHOICES = [
288 ['real', 'your usage'],
289 ['demo', 'demo values'],
290] as const
291
292/**
293 * A group of the pane in a box of its own, its name set in the top border: the
294 * name is painted over the border as the wrapper's last child.
295 */
296function section(ui: Ui, key: string, title: string, children: RenderChildren) {
297 const { Box, Text } = ui
298 return (
299 <Box key={key} flexDirection="column">
300 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
301 {children}
302 </Box>
303 <Box position="absolute" top={0} left={2}>
304 <Text bold> {title} </Text>
305 </Box>
306 </Box>
307 )
308}
309
310/** The footer's settings: a style and a percent per row, what glyphs fill with, how the login in use stands out, and a demo preview. */
311function FooterSettings(ui: Ui, view: View, act: Actions) {
312 const { Box, Text } = ui
313 return section(ui, 'display', 'Display', [
314 choice(ui, 'footer-session', 'Session', GLYPH_CHOICES, view.footer.session, session => act.setFooter({ session })),
315 choice(ui, 'footer-week', 'Week', GLYPH_CHOICES, view.footer.week, week => act.setFooter({ week })),
316 <Box key="footer-percent" flexDirection="row">
317 <Box width={NAME_WIDTH} flexShrink={0}>
318 <Text dimColor>Percent</Text>
319 </Box>
320 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
321 {toggle(ui, 'footer-percent-session', 'session', view.footer.sessionPercent, on => act.setFooter({ sessionPercent: on }))}
322 {toggle(ui, 'footer-percent-week', 'week', view.footer.weekPercent, on => act.setFooter({ weekPercent: on }))}
323 </Box>
324 </Box>,
325 choice(ui, 'footer-fill', 'Show', FILL_CHOICES, view.footer.fill, fill => act.setFooter({ fill })),
326 choice(ui, 'footer-active', 'In use', HIGHLIGHT_CHOICES, view.footer.active, active => act.setFooter({ active })),
327 choice(ui, 'footer-preview', 'Preview', PREVIEW_CHOICES, view.demo ? 'demo' : 'real', v => act.setDemo(v === 'demo')),
328 ])
329}
330
331/** Which login the rotation moves to. */
332function RotationSettings(ui: Ui, view: View, act: Actions) {
333 return section(ui, 'rotation', 'Rotation', [
334 choice(ui, 'rotate-to', 'Move to', ROTATE_CHOICES, view.rotateTo, act.setRotateTo),
335 ])
336}
337
338/** The band above the prompt, drawn only while a login needs the person. */
339export function Band(ui: Ui, view: View, now: number, act: Actions) {
340 const { Box, Text, Button } = ui
341 const signingIn = view.accounts.find(a => view.health[a.id]?.kind === 'logging-in')
342 if (signingIn !== undefined) {
343 return (
344 <Box flexDirection="row" columnGap={2}>
345 <Text color="warning">
346 ◌ Account {signingIn.id}: in your browser, approve as {signingIn.email ?? 'the account to add'} and pick the
347 organization. Wrong account there? Sign out of claude.ai in another tab, then reload the sign-in page.
348 </Text>
349 <Button key="cancel-sign-in" onPress={act.cancelSignIn}>Cancel</Button>
350 </Box>
351 )
352 }
353 const locked = needsSignIn(view)
354 if (locked.length > 0) {
355 const names = locked.map(a => (a.email ? `${a.id} (${a.email})` : a.id)).join(', ')
356 const why = locked.map(a => view.health[a.id]).find(h => h?.kind === 'login-needed')
357 return (
358 <Box flexDirection="row" columnGap={2}>
359 <Text color="error">
360 ⚠ Claude account {names} needs a sign-in{why?.kind === 'login-needed' ? ` (${why.why})` : ''}.
361 </Text>
362 <Button key="sign-in-all" variant="primary" hotkey="s" onPress={act.signInAll}>
363 {locked.length > 1 ? 'Sign in all' : 'Sign in'}
364 </Button>
365 </Box>
366 )
367 }
368 const spent = view.accounts.every(a => {
369 const r = view.readings[a.id]
370 return (windowPct(r?.fiveHour, now) ?? 0) >= LEAVE_FIVE_HOUR || (windowPct(r?.sevenDay, now) ?? 0) >= LEAVE_SEVEN_DAY
371 })
372 if (view.accounts.length > 0 && spent) {
373 const at = nextReset(view, now)
374 return (
375 <Text color="warning">
376 Every Claude account is near its limit{at === undefined ? '' : `; the next frees up ${untilText(at, now)}`}.
377 </Text>
378 )
379 }
380 return undefined
381}
382
383/** A window's bar and percent at their widest ("▰▰▰▰▰▰▰▰▰▰ 100% left"), so the reset times line up. */
384const BAR_WIDTH = 20
385
386/**
387 * One usage window of a login, filled and colored as the footer draws it, and
388 * when it resets: beside it, or under the bar on a narrow pane. A plan that
389 * reports no such window (a Team seat's week) says so.
390 */
391function windowRow(ui: Ui, fill: Fill, isActive: boolean, name: string, hasReading: boolean, w: UsageWindow | undefined, now: number) {
392 const { Box, Text } = ui
393 const left = roomLeft(w, now)
394 return (
395 <Box flexDirection="row" marginLeft={3}>
396 <Box width={5} flexShrink={0}>
397 <Text dimColor={!isActive}>{name}</Text>
398 </Box>
399 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
400 <Box minWidth={BAR_WIDTH}>
401 {left === undefined ? (
402 <Text dimColor>
403 {longBar(undefined)}
404 {hasReading ? ' none reported' : ' no reading yet'}
405 </Text>
406 ) : (
407 <Text color={roomColor(left)}>{`${longBar(shown(fill, left))} ${shown(fill, left)}% ${fill === 'left' ? 'left' : 'used'}`}</Text>
408 )}
409 </Box>
410 {w?.resetsAt !== undefined && left !== undefined && left < 100 && <Text dimColor={!isActive}>resets {untilText(w.resetsAt, now)}</Text>}
411 </Box>
412 </Box>
413 )
414}
415
416/** The slot at the right end of a login's line: as wide as a button the terminal draws, `[ Use now ]`, so the Removes beside it line up. */
417const USE_WIDTH = '[ Use now ]'.length
418
419/**
420 * The right end of a login's line: what it needs, then Remove, then rightmost
421 * always its use, in a slot of its own: in use, Use now, Sign in while it
422 * needs one, or signing in.
423 */
424function lineEnd(ui: Ui, view: View, a: Account, act: Actions) {
425 const { Box, Text, Button } = ui
426 const health = view.health[a.id]
427 const remove =
428 a.slot !== '' &&
429 health?.kind !== 'logging-in' &&
430 (view.removing === a.id ? (
431 <Button key={`remove-${a.id}`} onPress={() => act.remove(a.id)}>Press again to remove</Button>
432 ) : (
433 <Button key={`ask-remove-${a.id}`} dimColor onPress={() => act.askRemove(a.id)}>Remove</Button>
434 ))
435 const use = (end: RenderChildren) => (
436 <Box minWidth={USE_WIDTH} justifyContent="flex-end">
437 {end}
438 </Box>
439 )
440 switch (health?.kind) {
441 case 'login-needed':
442 return [
443 <Text color="error">⚠ {health.why}</Text>,
444 remove,
445 use(<Button key={`sign-in-${a.id}`} variant="primary" onPress={() => act.signInOne(a.id)}>Sign in</Button>),
446 ]
447 case 'logging-in':
448 return [use(<Text color="warning">◌ signing in…</Text>)]
449 default:
450 return [
451 remove,
452 use(
453 a.id === view.activeId ? (
454 <Text color={ACCENT}>● in use</Text>
455 ) : (
456 health?.kind === 'ok' && <Button key={`use-${a.id}`} onPress={() => act.use(a.id)}>Use now</Button>
457 ),
458 ),
459 ]
460 }
461}
462
463/**
464 * One login: who it is at the left of its first line, its state and presses
465 * at the right (under it, still at the right, on a pane too narrow for both),
466 * then its two windows. The login in use is drawn bright, the rest dim.
467 */
468function accountRows(ui: Ui, view: View, a: Account, now: number, act: Actions) {
469 const { Box, Text } = ui
470 const health = view.health[a.id]
471 const r = view.readings[a.id]
472 const isActive = a.id === view.activeId
473 const expiry = health?.kind === 'ok' ? health.refreshExpiresAt : undefined
474 return (
475 <Box key={`account-${a.id}`} flexDirection="column" marginBottom={1}>
476 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
477 <Box flexDirection="row" flexShrink={1}>
478 <Box width={3} flexShrink={0}>
479 <Text bold={isActive} dimColor={!isActive}>{a.id}</Text>
480 </Box>
481 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
482 <Text bold={isActive} dimColor={!isActive}>{a.email ?? (a.slot === '' ? 'your normal account' : 'not signed in yet')}</Text>
483 {a.orgName !== undefined && <Text dimColor>{a.orgName}{a.plan ? ` · ${a.plan}` : ''}</Text>}
484 </Box>
485 </Box>
486 <Box flexDirection="row" flexGrow={1} flexWrap="wrap" justifyContent="flex-end" columnGap={2}>
487 {lineEnd(ui, view, a, act)}
488 </Box>
489 </Box>
490 {windowRow(ui, view.footer.fill, isActive, '5h', r !== undefined, r?.fiveHour, now)}
491 {windowRow(ui, view.footer.fill, isActive, 'week', r !== undefined, r?.sevenDay, now)}
492 {expiry !== undefined && expiry - now < SIGN_IN_WARN_MS && (
493 <Text color="warning"> sign-in runs out {untilText(expiry, now)}; signing in again renews it</Text>
494 )}
495 </Box>
496 )
497}
498
499/** Where the rotation moves to, as its setting picks. */
500const RULE_TARGETS: Record<Exclude<RotateTo, 'off'>, string> = {
501 reset: 'the account whose 5-hour window resets soonest',
502 room: 'the account with the most room right now',
503 order: 'the next account in the list with room',
504}
505
506/** When the rotation moves on, counted the way the footer shows it (room left, or what is used), and where to. */
507function rule(fill: Fill, rotateTo: RotateTo): string {
508 if (rotateTo === 'off') return 'Rotation is disabled: the session stays on the account in use until you pick another.'
509 const when =
510 fill === 'left'
511 ? `Once the account in use has ${100 - LEAVE_FIVE_HOUR}% of its 5-hour window or ${100 - LEAVE_SEVEN_DAY}% of its week left`
512 : `Once the account in use has used ${LEAVE_FIVE_HOUR}% of its 5-hour window or ${LEAVE_SEVEN_DAY}% of its week`
513 return `${when}, moves to ${RULE_TARGETS[rotateTo]}.`
514}
515
516/** The pane's first line while Claude Code's security policy refuses every switch. */
517function blockedRule(view: View): string {
518 return (
519 `Can't switch accounts in this session. ${blockedAdvice(isOnNormalLogin(view))} More in the README.`
520 )
521}
522
523/**
524 * Claude Code's own login, the one new sessions start on, as its sign-in
525 * says: whose account it is, and its plan, a Team or Enterprise one keeping
526 * those sessions from switching. In a session on another login, /login signs
527 * in that one instead.
528 */
529function LoginLine({ Text }: Ui, view: View) {
530 const normal = view.accounts.find(a => a.slot === '')
531 if (normal === undefined) return undefined
532 const who = view.holders[normal.id]?.who ?? normal
533 const isSeat = isSeatPlan(who.plan)
534 const text = [
535 `Claude Code's login: account ${normal.id}${who.email === undefined ? '' : ` (${who.email})`}${who.plan === undefined ? '' : `, ${planText(who.plan)}`}.`,
536 isSeat
537 ? "New sessions start on it, and can't switch accounts: /login with your personal (Pro or Max) account in a session on it, then restart Claude."
538 : 'New sessions start on it.',
539 ...(view.activeId === normal.id ? [] : [`/login in this session signs in account ${view.activeId} instead.`]),
540 ]
541 return (
542 <Text key="login" color={isSeat ? 'warning' : undefined} dimColor={!isSeat}>
543 {text.join(' ')}
544 </Text>
545 )
546}
547
548/**
549 * The pane: when the rotation moves on (or that it can't), and Claude Code's
550 * own login, then every account, its usage and what can be done with it, then
551 * where the rotation moves to and the footer's settings, each group in its box.
552 */
553export function Pane(ui: Ui, view: View, now: number, act: Actions) {
554 const { Box, Text, Button } = ui
555 const accounts = [
556 ...view.accounts.map(a => accountRows(ui, view, a, now, act)),
557 <Box key="add-row" flexDirection="row" columnGap={2}>
558 <Button key="add" onPress={act.add}>+ Add an account</Button>
559 {view.accounts.length > 1 && <Button key="rotate" onPress={act.rotate}>Rotate now</Button>}
560 {needsSignIn(view).length > 1 && <Button key="pane-sign-in-all" variant="primary" onPress={act.signInAll}>Sign in all</Button>}
561 </Box>,
562 ]
563 return (
564 <Box flexDirection="column" rowGap={1}>
565 {view.isSwitchBlocked ? <Text color="warning">{blockedRule(view)}</Text> : <Text dimColor>{rule(view.footer.fill, view.rotateTo)}</Text>}
566 {LoginLine(ui, view)}
567 {section(ui, 'accounts', 'Accounts', accounts)}
568 {RotationSettings(ui, view, act)}
569 {FooterSettings(ui, view, act)}
570 </Box>
571 )
572}
573types/index.d.ts 94 lines1/** One usage window of a login: percent used, and when it resets (ISO 8601). */
2export type UsageWindow = { pct: number; resetsAt?: string }
3
4/** A login's usage as last read, `at` in epoch milliseconds. */
5export type Reading = { fiveHour?: UsageWindow; sevenDay?: UsageWindow; at: number }
6
7/** Whether a login can serve requests, or why not. */
8export type Health =
9 | { kind: 'ok'; refreshExpiresAt?: number }
10 | { kind: 'login-needed'; why: string }
11 | { kind: 'logging-in'; since: number }
12
13/**
14 * One Claude login. `slot` is the credentials directory Claude Code reads it
15 * from: '' is the normal login, anything else an absolute directory.
16 */
17export type Account = {
18 id: string
19 slot: string
20 email?: string
21 /** One email can hold logins in several organizations (your Max plan, a Team seat). */
22 orgId?: string
23 orgName?: string
24 /** `subscriptionType` as `claude auth status` reports it: max, team, pro. */
25 plan?: string
26}
27
28/**
29 * Who a login's sign-in belongs to, as the profile endpoint answered for the
30 * token `token` digests; the plan as Claude Code reads it from the sign-in.
31 */
32export type Holder = { token: string; who: Pick<Account, 'email' | 'orgId' | 'orgName' | 'plan'> & { email: string } }
33
34/** How the footer draws one of its rows: a glyph per login, or not at all. */
35export type Glyphs = 'bars' | 'pie' | 'braille' | 'none'
36
37/** How the footer marks the login in use: a dark line down each side of its column, a tile behind it, or the rest dimmed. */
38export type Highlight = 'lines' | 'tile' | 'dim'
39
40/** What glyphs and bars fill with: the room a login has left, or what it has used. */
41export type Fill = 'left' | 'used'
42
43/**
44 * Which login the rotation moves to: the one whose 5-hour window resets
45 * soonest, the one with the most room right now, or the next in the list; or
46 * none, the rotation off: the session moves only when the person picks.
47 */
48export type RotateTo = 'reset' | 'room' | 'order' | 'off'
49
50/** The footer's look, picked in the pane and kept across sessions. */
51export type FooterStyle = {
52 session: Glyphs
53 week: Glyphs
54 /** Whether a row ends with the login in use's percent. */
55 sessionPercent: boolean
56 weekPercent: boolean
57 active: Highlight
58 /** Either way the colors go from green with all the room left to red with none. */
59 fill: Fill
60}
61
62/** A run of the footer's text: its color, and the lighter one it takes while the pointer is over the footer. */
63export type FooterRun = { text: string; color?: string; lit?: string; backgroundColor?: string; dimColor?: boolean }
64
65/** Everything the footer, band and pane draw from. */
66export type View = {
67 accounts: Account[]
68 readings: Record<string, Reading>
69 health: Record<string, Health>
70 /** Who each login's sign-in belongs to: a /login in a session on a login signs in there, whoever it signs in. */
71 holders: Record<string, Holder>
72 /** The login this session sends its requests with. */
73 activeId: string
74 /** Set after a switch until a response shows the new login's usage. */
75 pending?: { id: string; from: string; since: number }
76 /** Account id whose removal waits for a second press. */
77 removing?: string
78 footer: FooterStyle
79 /** Which login the rotation moves to, picked in the pane and kept across sessions. */
80 rotateTo: RotateTo
81 /** This session only: Claude Code's security default refuses switching, as on a Team or Enterprise account. */
82 isSwitchBlocked?: boolean
83 /** This session only: the footer draws imitated usage, to judge its look. */
84 demo?: boolean
85 /** This session's terminal draws block characters from its font, short of the line, as Terminal.app does. */
86 hasFontBlocks: boolean
87}
88
89declare module 'claude-code' {
90 interface PluginState {
91 'account-rotator': { view: View }
92 }
93}
94