SLOPSHOPPER

account-rotator

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…

newpanebandspinnertoastprocess
★ 15v0.1.0MITupdated 2026-10-15ivokund/claude-account-rotator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · account-rotator
│ ┃ claude-accounts ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Once the account in use has 10% of its │ account-rotator │ │ ┃ 5-hour window or 5% of its week left, moves ● account-rotator: acc│ Claude account 1 needs a sign-in (not │ │ ┃ to the account whose 5-hour window resets ⏺ Read(src/auth.ts) │ signed in). Click ⚠1 under the prompt. │ │ ┃ soonest. ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ Claude Code's login: account 1. New sessions ⎿ Added 2 lines, removed 1 line │ ┃ start on it. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 1 your normal account ⚠ not signed in ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 5h ·········· no reading yet │ ┃ │ week ·········· no reading yet ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ [ + Add an account ] │ ┃ ╰─────────────────────────────────────────── │ ┃ Accounts │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Move to rotate-to-reset rotate-to-room │ ┃ ╰─────────────────────────────────────────── │ ┃ Rotation │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Session footer-session-bars footer-sessi │ ┃ │ Week footer-week-bars footer-week-pie ⚠ Claude account 1 needs a sign-in (not signed in). [ Sign in ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits Claude accounts

Draws

Band
⚠ Claude account 1 needs a sign-in (not signed in). [ Sign in ]
Pane · claude-accounts
Once the account in use has 10% of its 5-hour window or 5% of its week left, moves to the account whose 5-hour window resets soonest. Claude Code's login: account 1. New sessions start on it. ╭─────────────────────────────────────────────────────────── │ 1 your normal account ⚠ not signed in │ 5h ·········· no reading yet │ week ·········· no reading yet │ │ [ + Add an account ] ╰─────────────────────────────────────────────────────────── Accounts ╭─────────────────────────────────────────────────────────── │ Move to rotate-to-reset rotate-to-room rotate-to-order ╰─────────────────────────────────────────────────────────── Rotation ╭─────────────────────────────────────────────────────────── │ Session footer-session-bars footer-session-pie footer-s │ Week footer-week-bars footer-week-pie footer-week-br │ Percent footer-percent-session footer-percent-week │ Show footer-fill-left footer-fill-used │ In use footer-active-lines footer-active-tile footer-a │ Preview footer-preview-real footer-preview-demo ╰─────────────────────────────────────────────────────────── Display
README

Claude account rotator

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.

The footer up close as the account in use runs low; the session moves to another account and a notice says so; then the panel switches the footer's top row between bars, pies and braille

<sub>A real session fed imitated accounts (demo/harness), so no real account appears.</sub>

Install

/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.

The footer

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:

The footer's looks. Glyphs: bars, pies or braille. The account in use: between lines, on a tile, or with the rest dimmed. Counting what is used instead of the room left, without percents, or without glyphs. An account that needs a sign-in, one signing in, and the rows' names under the pointer.

What it does

  • Rotates. Once the account in use is down to 10% of its 5-hour window or 5% of its week, the session moves to another account, and a notice says which and why. Rotation in the panel picks which: by default the one whose 5-hour window resets soonest, so room about to refill is used before it does; else the one with the most room, or the next in the list; or disabled, which leaves switching to you.
  • Recovers. A turn that still hits a usage limit or a rejected sign-in switches accounts and asks Claude to carry on.
  • Shows what is really in use. If a switch doesn't take, the footer and panel go back to the old account, and the rotation stops trying the other one for that session.
  • Follows /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.
  • Names Claude Code's login. The panel's second line says which account new sessions start on, and whether it is personal or a Team or Enterprise seat, whose sessions can't switch.
  • Warns. When every account is near its limit, a line above the prompt says when the next one frees up. When an account needs signing in again, the same line says so and signs it in with one click.

Requirements

  • macOS, where Claude Code keeps sign-ins in the keychain, or Linux, where it keeps them in files.
  • Claude Code 2.1.296 or later, in its fullscreen renderer (/tui fullscreen), the only one that passes clicks on: the panel opens with a click.
  • Any Mac terminal: Terminal.app, iTerm2, Ghostty, Warp, VS Code; on Linux, one with mouse reporting. In iTerm2 the profile's Enable mouse reporting must be on, in tmux set -g mouse on, and iTerm2's tmux -CC mode can't run fullscreen.
  • Claude subscription accounts (tested with Max and Team seats). Your /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.

Team and Enterprise accounts

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:

  • the session started on a Team or Enterprise account, or
  • plugins reloaded while one was in use: /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.
  • Add Team seats from the panel, not /login: + Add an account, and Sign in to renew.
  • Restart Claude when blocked: quit, and run 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.

Turning the security default off

[!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.

How it works

  • Each account you add gets a folder, ~/.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.
  • Its only network traffic: each account's usage every 10 minutes, from the endpoint /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.

Uninstall

Remove each account in the panel, which signs it out, then run claude plugin uninstall account-rotator@claude-account-rotator and delete ~/.claude/accounts.

Terms

As of October 2026:

  • Neither Anthropic's Consumer Terms (October 8, 2025) nor its Commercial Terms (June 17, 2025) forbid one person from holding more than one Claude account. The Consumer Terms forbid sharing an account's login details, so add only your own. A Team or Enterprise seat is also under its organization's rules.
  • Claude Code's legal page says the advertised usage limits of Pro and Max assume ordinary, individual usage. It also says Anthropic may enforce its rules on sign-ins and credentials without notice. The mod signs accounts in only through Claude Code's own claude auth login and keeps no tokens of its own.
  • The Consumer Terms forbid reaching the Services "through automated or non-human means" except with an API key. The mod's usage check every 10 minutes and its "carry on" prompt after a switch run inside Claude Code, as part of your own session, but judge that for yourself.
  • The Usage Policy forbids intentionally bypassing restrictions or guardrails built into Anthropic's products: read the warning in Turning the security default off first.

Terms change; this is not legal advice. Not affiliated with or endorsed by Anthropic.

Develop

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.

License

MIT

Source 4 files
hooks/register.tsx 790 lines
1import { 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}
790
hooks/model.ts 461 lines
1import 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
461
hooks/view.tsx 573 lines
1import 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}
573
types/index.d.ts 94 lines
1/** 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