Right-side sidebar with session and weekly limit bars for each of your Claude accounts; suggests the next same-org account at the session limit.

Two mods for Claude Code's function-hooks plugin system:
Both are tested on Claude Code 2.1.296. Mods are an early-access API that can change between releases.
A sidebar on the right of the terminal conversation with one pair of bars per Claude account:
Bars are green below 50 %, yellow from 50 %, orange from 75 % and red from 90 %. Each bar shows when its window resets. When the account you are signed into reaches its session limit, the mod tells you which account is next in line. It never switches for you: you switch with /login.
S session (5-hour) · W weekly (7-day)
▶ work1
S ███████████████████████░ 96% 3h 30m
W █████░░░░░░░░░░░░░░░░░░░ 22% 2d 6h
as of just now
work2 next
S ░░░░░░░░░░░░░░░░░░░░░░░░ 0%
W ████████████████░░░░░░░░ 67% 11h 10m
as of just now
personal
S ████░░░░░░░░░░░░░░░░░░░░ 15% 3h 00m
W ███░░░░░░░░░░░░░░░░░░░░░ 11% 4d 18h
as of just now · other org, never suggested
[ compact ] [ refresh ]
You need Claude Code with mods (function hooks), a Claude subscription login (Pro, Max, Team or Enterprise), and git able to reach GitHub.
From GitHub, typed at the prompt of a Claude Code terminal session:
/plugin install account-bars --marketplace raoofaltaher/claude-code-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a local copy of this repository:
claude plugin marketplace add <path-to-this-repo>
claude plugin install account-bars@my-mods
A marketplace added from a folder is read from that folder itself. After you edit the mod there, run /reload-plugins.
To try it for one session without installing:
claude --plugin-dir <path-to-this-repo>/account-bars
The account you are signed into works with no setup. Every other account is signed in once into its own Claude Code profile folder under ~/.claude-accounts/. The folder name is the label the sidebar shows.
Use a separate terminal for this, not a Claude Code session, because the sign-in opens a browser.
PowerShell:
$env:CLAUDE_CONFIG_DIR = "$HOME\.claude-accounts\work2"
claude auth login
Remove-Item Env:CLAUDE_CONFIG_DIR
bash / zsh:
CLAUDE_CONFIG_DIR="$HOME/.claude-accounts/work2" claude auth login
Then press r in the sidebar, or run /accounts refresh.
Tips:
claude auth login --email <address> fills in the address.--sso forces single sign-on.work1). The top row then shows its name instead of "this session". After you /login to another account, that profile is what keeps the first account tracked.order (below) if you set it./accounts | Show or hide the sidebar. |
/accounts refresh | Re-read every account now. |
c or [ compact ] / [ expand ] | One row per account, or the full view with reset times and status. |
r or [ refresh ] | Same as /accounts refresh. |
/config → account-bars → order | Your preferred order, e.g. work1,work2,personal. Unlisted accounts follow alphabetically. |
/config → account-bars → claudePath | The full path to claude, if it isn't at ~/.local/bin/claude (claude.exe on Windows), the native installer's place. It must be a full path outside the project folder; the mod never looks claude up by name. |
Where the sidebar sits:
/accounts).Status lines under each account:
| Line | Meaning |
|---|---|
as of 40s ago | Live reading. Other accounts refresh every 5 minutes. The account you are signed into also updates after every reply. |
reading… | Not read yet. |
sign-in expired | That profile's login lapsed. Run claude auth login for it again (see above). The mod tries it again every 10 minutes. |
no reading, retrying | The last read failed. The mod retries with back-off (up to 40 minutes). If Anthropic answers "too many requests" (HTTP 429), that account waits 15 minutes. |
not signed in | The folder exists but holds no login. |
other org, never suggested | Tracked, but in a different organization from the account you are signed into. |
paused | CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set, so other accounts are not polled. |
| a yellow line at the top | A setup problem: claude, or the program that reads other accounts, was not found at its fixed path. |
At the session limit:
The policy lives in one function, pickNext() in account-bars/hooks/next.ts. Each candidate carries:
order;The rule:
WEEKLY_CEILING), since they would block soon after.order.Change the function to suit how you work.
| Account | Where its numbers come from | Credential involved |
|---|---|---|
| The one this session is signed into | Readings Claude Code itself pushes to mods after every reply, plus the usage endpoint through Claude Code's credential handle | none: the mod never sees the secret |
Every profile under ~/.claude-accounts/ | GET https://api.anthropic.com/api/oauth/usage (the endpoint Claude Code's own /usage reads), every 5 minutes, made by a short-lived helper process | that profile's access token, which only the helper ever reads |
The helper reads the profile's .credentials.json, makes the request, and prints only three things: when the login expires, the HTTP status, and the usage numbers.
%SystemRoot%./bin/sh with /usr/bin/curl. The token reaches curl on its standard input, never on a command line.account-bars/hooks/probe.ts.To recognise accounts and organizations, the mod runs claude auth status --json:
/login, on refresh, and every 10 minutes.https://api.anthropic.com.claude (or your claudePath) and the helper. A program looked up by name could resolve to a file planted in a cloned repository's folder.tr, sed, tail) come only from /usr/bin and /bin. curl ignores ~/.curlrc and allows https only.error, never the error text, which could quote the login it failed to read.claude auth status results, which another installed mod could hook.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stops everything that touches other accounts: both their usage reads and their identity checks.no reading, retrying instead of breaking. The account you are signed into keeps updating from Claude Code's own readings..credentials.json). Keep your disk encrypted..credentials.json, so extra profiles show not signed in there. The account you are signed into still works. Tested on Windows. The Linux helper was tested under Git Bash with curl.claude auth status for that profile so Claude Code can renew it. If a profile still lapses, it shows sign-in expired.account-bars/
.claude-plugin/plugin.json manifest, the `order` option
hooks/hooks.json names the hooks module
hooks/register.tsx sidebar, /accounts, polling, limit notice
hooks/usage.ts parsing, bands, bars, time text
hooks/probe.ts the helper that reads other accounts (Windows PowerShell, sh + curl)
hooks/next.ts pickNext(): who is suggested
types/index.d.ts the mod's state contract
tests/account-bars.test.ts colour bands, parsing, drawing, credential handling, limit notice
claude plugin validate account-bars
claude plugin test account-bars
Claude Code writes the API's type declarations into account-bars/.claude-plugin/types/ whenever it loads the mod; tsconfig.json extends them. That folder is git-ignored: it is regenerated per build and lists the MCP tools connected on the machine that loaded it.
Colored icons at the start of each tool line, such as read, edit, write, delete, shell, search, web, MCP and agents. It has switchable styles, color themes, and two looks: a badge chip and a gradient label.
/plugin install tool-icons --marketplace raoofaltaher/claude-code-mods
/icons previews everything. /icons <style>, /icons <theme>, /icons badge | gradient | plain and /icons off | on change it. Some styles need a Nerd Font, such as Cascadia Mono NF.
(3 lines) ….tool-icons only redraws transcript rows, never the approval dialog./icons off to see Claude Code's own rows, output and error text included.hooks/register.tsx 560 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AccountStatus, AccountView, Reading } from '../types'
5import { pickNext } from './next'
6import type { Candidate } from './next'
7import { POWERSHELL_PROBE, PROFILE_VAR, SH_PROBE, parseProbe } from './probe'
8import type { Probe } from './probe'
9import {
10 BAND_COLOR,
11 USAGE_BETA,
12 USAGE_URL,
13 agoText,
14 band,
15 bar,
16 effectivePercent,
17 fromRateLimits,
18 isRecord,
19 parseUsage,
20 printable,
21 scrub,
22 untilText,
23} from './usage'
24import type { Windows } from './usage'
25
26const PANE = 'accounts'
27const PROFILE_ROOT = '.claude-accounts'
28const POLL_MS = 5 * 60_000
29/** How long an account waits after the endpoint answers 429 (too many requests). */
30const RATE_LIMITED_MS = 15 * 60_000
31const IDENTIFY_MS = 10 * 60_000
32const CLOCK_MS = 30_000
33const RENEW_BEFORE_MS = 30 * 60_000
34
35const accounts = atom({ plugin: 'account-bars', key: 'accounts' } as const, [])
36const isCompact = atom({ plugin: 'account-bars', key: 'isCompact' } as const, false)
37const nextUp = atom({ plugin: 'account-bars', key: 'nextUp' } as const, null)
38const clock = atom({ plugin: 'account-bars', key: 'now' } as const, 0)
39const notice = atom({ plugin: 'account-bars', key: 'notice' } as const, null)
40
41/** An absolute path on Windows (`C:\…`, `\\server\…`) or elsewhere (`/…`). */
42const isAbsolute = (path: string) => /^([A-Za-z]:[\\/]|\\\\|\/)/.test(path)
43
44type Readings = Windows & { asOf?: number }
45
46/**
47 * One account signed in under ~/.claude-accounts/<label>. `email` and `orgId`
48 * stay in this module's memory to recognise the active account and its
49 * organization; they are never written to state, store or screen.
50 */
51type Profile = {
52 label: string
53 dir: string
54 email?: string
55 orgId?: string
56 status: AccountStatus
57 readings: Readings
58 failures: number
59 skipUntil: number
60 renewTriedAt: number
61 /** Why the last read failed (scrubbed, short); undefined after a good read. */
62 detail?: string
63}
64
65export const register: Register = (on, options) => {
66 const order =
67 typeof options.order === 'string'
68 ? options.order.split(',').map(label => label.trim()).filter(Boolean)
69 : []
70 const claudePath = typeof options.claudePath === 'string' ? options.claudePath.trim() : ''
71
72 let profiles: Profile[] = []
73 let active: { email?: string; orgId?: string; readings: Readings } = { readings: {} }
74 let lastIdentified = 0
75 let isBusy = false
76 /** The background work session.start sets up; other hooks only nudge it. */
77 let background: { refresh: (withIdentify: boolean) => void; republish: () => void } | undefined
78
79 const rank = (label: string) => {
80 const at = order.indexOf(label)
81 return at === -1 ? order.length : at
82 }
83 const activeProfile = () =>
84 active.email === undefined ? undefined : profiles.find(p => p.email === active.email)
85 const sameOrg = (p: Profile) =>
86 p.orgId === undefined || active.orgId === undefined ? null : p.orgId === active.orgId
87 const backOff = (p: Profile, now: number, detail?: string) => {
88 p.failures += 1
89 p.status = 'error'
90 p.detail = detail
91 p.skipUntil = now + POLL_MS * Math.min(8, 2 ** (p.failures - 1))
92 }
93
94 on('session.start', async ($, e, next) => {
95 // Paths from the environment are used only when absolute: an empty or
96 // relative home would put every run in the session's (a repo's) folder.
97 const systemRoot = await $.env.get('SystemRoot')
98 const isWindows = systemRoot !== undefined && /^[A-Za-z]:\\/.test(systemRoot)
99 const home = (isWindows ? await $.env.get('USERPROFILE') : await $.env.get('HOME')) ?? ''
100 const isHomeUsable = isAbsolute(home)
101 const sep = isWindows ? '\\' : '/'
102 const join = (...parts: string[]) => parts.join(sep)
103 const shown = (path: string) => (isHomeUsable && path.startsWith(home) ? `~${path.slice(home.length)}` : path)
104 const isFile = async (path: string) => {
105 try {
106 return (await $.fs.stat(path)).kind === 'file'
107 } catch {
108 return false
109 }
110 }
111 /** Inside the session's folder, when that folder is not the home folder itself. */
112 const norm = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
113 const isInProject = (path: string) =>
114 norm(e.cwd) !== norm(home) && norm(path).startsWith(`${norm(e.cwd)}/`)
115
116 // Programs run only by full path, from the home folder: a name looked up
117 // on PATH can resolve to a file a cloned repository planted in its folder,
118 // and a claudePath pointing into the project is refused for the same reason.
119 const isClaudePathUsable = isAbsolute(claudePath) && !isInProject(claudePath)
120 const claudeExe = isClaudePathUsable ? claudePath : join(home, '.local', 'bin', isWindows ? 'claude.exe' : 'claude')
121 const probeArgv = isWindows
122 ? [join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), '-NoProfile', '-NonInteractive', '-Command', POWERSHELL_PROBE]
123 : ['/bin/sh', '-c', SH_PROBE]
124 // what the helper runs in: its own module folder, or the system's own bin folders
125 const probeEnv: Record<string, string> = isWindows
126 ? { PSModulePath: join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'Modules') }
127 : { PATH: '/usr/bin:/bin' }
128 const helpers = isWindows ? [probeArgv[0]!] : [probeArgv[0]!, '/usr/bin/curl']
129 const hasClaude = isHomeUsable && (await isFile(claudeExe))
130 const missingHelper = (await Promise.all(helpers.map(async path => ((await isFile(path)) ? null : path)))).find(
131 path => path !== null,
132 )
133 const hasProbe = isHomeUsable && missingHelper === undefined
134 const problems = !isHomeUsable
135 ? ['no usable home folder in the environment: other accounts are not read']
136 : [
137 ...(claudePath !== '' && !isClaudePathUsable
138 ? ['claudePath is ignored: it must be a full path outside the project folder']
139 : []),
140 ...(hasClaude ? [] : [`claude not found at ${shown(claudeExe)}: set claudePath in /config`]),
141 ...(missingHelper === undefined ? [] : [`${missingHelper} not found: other accounts cannot be read`]),
142 ]
143
144 /** `claude auth status --json` for the session's login, or a profile's: identity only. */
145 const authStatus = async (dir?: string) => {
146 if (!hasClaude) return undefined
147 try {
148 const { stdout } = await $.process.run([claudeExe, 'auth', 'status', '--json'], {
149 cwd: home,
150 timeoutMs: 30_000,
151 ...(dir === undefined ? {} : { env: { CLAUDE_CONFIG_DIR: dir } }),
152 })
153 const status: unknown = JSON.parse(stdout)
154 if (!isRecord(status) || status.loggedIn !== true) return undefined
155 return {
156 email: typeof status.email === 'string' ? status.email : undefined,
157 orgId: typeof status.orgId === 'string' ? status.orgId : undefined,
158 }
159 } catch {
160 return undefined
161 }
162 }
163
164 /** The profile's usage, read by the helper process; its token never reaches this mod. */
165 const runProbe = async (dir: string): Promise<{ probe: Probe | undefined; reason?: string }> => {
166 if (!hasProbe) return { probe: undefined, reason: 'helper not available' }
167 try {
168 const { stdout, stderr, exitCode } = await $.process.run(probeArgv, {
169 cwd: home,
170 env: { ...probeEnv, [PROFILE_VAR]: dir },
171 stdin: '',
172 timeoutMs: 30_000,
173 })
174 const probe = parseProbe(stdout)
175 if (probe !== undefined) return { probe }
176 const said = stdout.trim().split(/\r?\n/)[0] || stderr.trim() || 'no output'
177 return { probe: undefined, reason: scrub(`helper exit ${exitCode}: ${said}`) }
178 } catch (error) {
179 return { probe: undefined, reason: scrub(`helper did not run: ${error instanceof Error ? error.message : String(error)}`) }
180 }
181 }
182
183 const identify = async () => {
184 const root = join(home, PROFILE_ROOT)
185 let names: string[] = []
186 try {
187 names = (await $.fs.list(root)).filter(entry => entry.kind === 'dir').map(entry => entry.name)
188 } catch {
189 names = []
190 }
191 const cached = await $.store.get('readings')
192 const known = new Map(profiles.map(p => [p.dir, p]))
193 profiles = names.map(name => {
194 const dir = join(root, name)
195 const label = printable(name)
196 return (
197 known.get(dir) ?? {
198 label,
199 dir,
200 status: 'pending',
201 readings: isRecord(cached) && isRecord(cached[label]) ? (cached[label] as Readings) : {},
202 failures: 0,
203 skipUntil: 0,
204 renewTriedAt: 0,
205 }
206 )
207 })
208 const me = await authStatus()
209 active = { ...active, email: me?.email, orgId: me?.orgId }
210 // with non-essential traffic off, other accounts are not touched at all
211 const isQuiet = Boolean(await $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'))
212 for (const p of profiles) {
213 if (isQuiet) {
214 p.status = 'paused'
215 continue
216 }
217 const who = await authStatus(p.dir)
218 p.email = who?.email
219 p.orgId = who?.orgId
220 if (who === undefined) p.status = 'not-signed-in'
221 else if (p.status === 'not-signed-in') p.status = 'pending'
222 }
223 lastIdentified = await $.clock.now()
224 }
225
226 /** The session's own account: through the engine's credential handle, never its secret. */
227 const pollActive = async (now: number) => {
228 try {
229 const auth = await $.session.authorize()
230 if (auth?.kind === 'bearer') {
231 const res = await $.http.fetch(USAGE_URL, {
232 auth: auth.handle,
233 headers: { 'anthropic-beta': USAGE_BETA },
234 })
235 const windows = res.ok ? parseUsage(res.text) : {}
236 if (windows.session || windows.weekly) {
237 active.readings = { ...windows, asOf: now }
238 return
239 }
240 }
241 } catch {
242 // fall back to the figures the engine already holds
243 }
244 const windows = fromRateLimits((await $.session.usage()).rateLimits)
245 if (windows.session || windows.weekly) active.readings = { ...windows, asOf: now }
246 }
247
248 const pollProfile = async (p: Profile, now: number) => {
249 let { probe, reason } = await runProbe(p.dir)
250 const expiresAt = probe?.isSignedIn ? probe.expiresAt : undefined
251 if (expiresAt !== undefined && expiresAt - now < RENEW_BEFORE_MS && now - p.renewTriedAt > IDENTIFY_MS) {
252 p.renewTriedAt = now
253 await authStatus(p.dir) // Claude Code renews its own login here, if it does so at all
254 ;({ probe, reason } = await runProbe(p.dir))
255 }
256 if (probe === undefined) {
257 backOff(p, now, reason)
258 return
259 }
260 if (!probe.isSignedIn) {
261 p.status = 'not-signed-in'
262 return
263 }
264 if (probe.status === 401 || probe.status === 403) {
265 // a lapsed login is tried again with the next identity check, not every poll
266 p.status = 'expired'
267 p.skipUntil = now + IDENTIFY_MS
268 return
269 }
270 if (probe.status === 429) {
271 // the endpoint asked us to slow down: keep the last numbers and wait
272 p.status = 'error'
273 p.detail = 'rate-limited, waiting 15m'
274 p.skipUntil = now + RATE_LIMITED_MS
275 return
276 }
277 const windows: Windows = probe.status === 200 ? probe.windows : {}
278 if (!windows.session && !windows.weekly) {
279 const why = probe.status === 200 ? 'unexpected reply' : probe.status === 0 ? 'no connection' : `HTTP ${probe.status}`
280 backOff(p, now, why)
281 return
282 }
283 p.readings = { ...windows, asOf: now }
284 p.status = 'ok'
285 p.detail = undefined
286 p.failures = 0
287 p.skipUntil = 0
288 }
289
290 const poll = async () => {
291 const now = await $.clock.now()
292 await pollActive(now)
293 const isQuiet = Boolean(await $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'))
294 const mine = activeProfile()
295 for (const p of profiles) {
296 if (p === mine || p.status === 'not-signed-in' || now < p.skipUntil) continue
297 if (isQuiet) p.status = 'paused'
298 else await pollProfile(p, now)
299 }
300 }
301
302 const publish = async () => {
303 const now = await $.clock.now()
304 const mine = activeProfile()
305 if (mine) mine.readings = active.readings
306 const view = (p: Profile): AccountView => ({
307 label: p.label,
308 isActive: false,
309 isSameOrg: sameOrg(p),
310 status: p.status,
311 ...p.readings,
312 ...(p.detail === undefined ? {} : { detail: p.detail }),
313 })
314 const others = profiles
315 .filter(p => p !== mine)
316 .map(view)
317 const self: AccountView = {
318 label: mine?.label ?? 'this session',
319 isActive: true,
320 isSameOrg: true,
321 status: active.readings.asOf === undefined ? 'pending' : 'ok',
322 ...active.readings,
323 }
324 const byOrder = (a: AccountView, b: AccountView) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label)
325 // an active account with no profile has no place in the order: it leads
326 const views = mine === undefined ? [self, ...others.sort(byOrder)] : [self, ...others].sort(byOrder)
327
328 const candidates: Candidate[] = views
329 .filter(v => !v.isActive && v.isSameOrg === true && v.status !== 'not-signed-in')
330 .map(v => ({
331 label: v.label,
332 rank: rank(v.label),
333 status: v.status,
334 sessionPercent: effectivePercent(v.session, now),
335 weeklyPercent: effectivePercent(v.weekly, now),
336 ...(v.session ? { session: v.session } : {}),
337 ...(v.weekly ? { weekly: v.weekly } : {}),
338 asOf: v.asOf,
339 }))
340 const picked = pickNext(candidates, now)
341 const suggestion = candidates.some(c => c.label === picked) ? picked : null
342
343 await update($, accounts, () => views)
344 await update($, nextUp, () => suggestion)
345 await update($, clock, () => now)
346 await update($, notice, () => (problems.length === 0 ? null : problems.join(' · ')))
347 await $.store.set(
348 'readings',
349 Object.fromEntries(profiles.filter(p => p.readings.asOf !== undefined).map(p => [p.label, p.readings])),
350 )
351 }
352
353 /** At the session limit: say who is next, once per window. Switching stays the person's. */
354 const checkLimit = async () => {
355 const now = await $.clock.now()
356 const session = active.readings.session
357 if ((effectivePercent(session, now) ?? 0) < 100) return
358 const windowId = session?.resetsAt ?? 'unknown'
359 if ((await $.store.get('notifiedFor')) === windowId) return
360 await $.store.set('notifiedFor', windowId)
361 const label = await read($, nextUp)
362 const suggested = (await read($, accounts)).find(v => v.label === label)
363 const percent = (r: Reading | undefined) => {
364 const p = effectivePercent(r, now)
365 return p === undefined ? '?' : `${Math.round(p)}%`
366 }
367 const text = suggested
368 ? `Session limit reached. Next in line: ${suggested.label} (session ${percent(suggested.session)}, weekly ${percent(suggested.weekly)}). Run /login and pick it.`
369 : 'Session limit reached. No other same-org account is signed in to suggest.'
370 $.ui.toast(text, { timeoutMs: 15_000 })
371 try {
372 await $.ui.notify(text, { title: 'Claude account limit' })
373 } catch {
374 // a hook above refused the notification; the toast stands
375 }
376 }
377
378 const refresh = async (withIdentify: boolean) => {
379 if (isBusy) return
380 isBusy = true
381 try {
382 if (withIdentify || (await $.clock.now()) - lastIdentified >= IDENTIFY_MS) await identify()
383 await poll()
384 await publish()
385 await checkLimit()
386 } finally {
387 isBusy = false
388 }
389 }
390
391 background = {
392 refresh: withIdentify => void $.clock.after(0, () => void refresh(withIdentify)),
393 republish: () => void $.clock.after(0, () => void publish().then(checkLimit)),
394 }
395
396 await $.command.register({
397 name: 'accounts',
398 description: 'Show or hide the account limits sidebar',
399 argumentHint: '[refresh]',
400 })
401 if ((await $.store.get('paneOpen')) !== false) void $.ui.open({ id: PANE, title: 'Accounts' })
402 $.clock.every(POLL_MS, () => void refresh(false))
403 $.clock.every(CLOCK_MS, () => void $.clock.now().then(now => update($, clock, () => now)))
404 background.refresh(true)
405 return next(e)
406 })
407
408 on('session.measure', async ($, e, next) => {
409 if (e.changed.includes('rateLimits')) {
410 const windows = fromRateLimits(e.rateLimits)
411 if (windows.session || windows.weekly) {
412 active.readings = { ...windows, asOf: await $.clock.now() }
413 background?.republish()
414 }
415 }
416 return next(e)
417 })
418
419 // after /login the session may be on another account: find out who
420 on('command.run', { command: 'login' }, async ($, e, next) => {
421 const result = await next(e)
422 background?.refresh(true)
423 return result
424 }).catch(($, e, next) => next(e))
425
426 on('command.run', { command: 'accounts' }, async ($, e) => {
427 if (e.args.trim() === 'refresh') {
428 background?.refresh(true)
429 return { text: 'Refreshing account limits.' }
430 }
431 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
432 await $.ui.close({ id: PANE })
433 await $.store.set('paneOpen', false)
434 return { text: 'Accounts sidebar hidden. /accounts brings it back.' }
435 }
436 await $.store.set('paneOpen', true)
437 const opened = await $.ui.open({ id: PANE, title: 'Accounts' })
438 return { text: opened.isPlaced ? 'Accounts sidebar shown.' : `Accounts sidebar waits: ${opened.reason}` }
439 })
440
441 on('ui.close', { id: PANE }, async ($, e, next) => {
442 if (e.origin.kind === 'person') await $.store.set('paneOpen', false)
443 return next(e)
444 }).catch(($, e, next) => next(e))
445
446 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
447 const { Box, Text, Button } = $.ui.resolve(e)
448 const list = await read($, accounts)
449 const compact = await read($, isCompact)
450 const suggested = await read($, nextUp)
451 const now = await read($, clock)
452 const problem = await read($, notice)
453 const cols = Math.max(20, e.props.bodyColumns)
454 const labelWidth = Math.min(12, Math.max(4, ...list.map(v => v.label.length)))
455
456 const meter = (reading: Reading | undefined, width: number) => {
457 const percent = effectivePercent(reading, now)
458 if (percent === undefined) return <Text dimColor>{`${'·'.repeat(width)} --`}</Text>
459 const { filled, empty } = bar(percent, width)
460 const color = BAND_COLOR[band(percent)]
461 return (
462 <Box flexDirection="row">
463 <Text color={color}>{filled}</Text>
464 <Text dimColor>{empty}</Text>
465 <Text color={color}>{` ${String(Math.round(percent)).padStart(3)}%`}</Text>
466 </Box>
467 )
468 }
469
470 const note = (v: AccountView) => {
471 const org = v.isSameOrg === false ? ' · other org, never suggested' : ''
472 switch (v.status) {
473 case 'ok':
474 return `as of ${agoText(v.asOf, now)}${org}`
475 case 'pending':
476 return 'reading…'
477 case 'expired':
478 return 'sign-in expired: run claude auth login for this profile'
479 case 'error':
480 return `no reading${v.detail ? ` (${v.detail})` : ''}, retrying · last ${agoText(v.asOf, now)}`
481 case 'not-signed-in':
482 return 'not signed in'
483 case 'paused':
484 return 'paused: non-essential traffic is off'
485 }
486 }
487
488 const mark = (v: AccountView) => (v.isActive ? '▶ ' : ' ')
489
490 const rows = compact
491 ? list.map(v => {
492 const width = Math.max(3, Math.floor((cols - labelWidth - 2 - 14) / 2))
493 return (
494 <Box key={`row-${v.label}`} flexDirection="row" gap={1}>
495 <Text bold={v.isActive} dimColor={v.isSameOrg === false} wrap="truncate">
496 {mark(v) + v.label.slice(0, labelWidth).padEnd(labelWidth)}
497 </Text>
498 {meter(v.session, width)}
499 {meter(v.weekly, width)}
500 </Box>
501 )
502 })
503 : list.map(v => {
504 const width = Math.max(6, Math.min(28, cols - 20))
505 return (
506 <Box key={`row-${v.label}`} flexDirection="column" marginBottom={1}>
507 <Box flexDirection="row" justifyContent="space-between">
508 <Text bold={v.isActive} dimColor={v.isSameOrg === false} wrap="truncate">
509 {mark(v) + v.label}
510 </Text>
511 <Text color="success" bold>
512 {v.label === suggested ? 'next' : ''}
513 </Text>
514 </Box>
515 <Box flexDirection="row" gap={1}>
516 <Text dimColor>{' S'}</Text>
517 {meter(v.session, width)}
518 <Text dimColor>{untilText(v.session?.resetsAt, now)}</Text>
519 </Box>
520 <Box flexDirection="row" gap={1}>
521 <Text dimColor>{' W'}</Text>
522 {meter(v.weekly, width)}
523 <Text dimColor>{untilText(v.weekly?.resetsAt, now)}</Text>
524 </Box>
525 <Text dimColor wrap="wrap">{` ${note(v)}`}</Text>
526 </Box>
527 )
528 })
529
530 return (
531 <Box flexDirection="column">
532 {problem !== null && (
533 <Text color="warning" wrap="wrap">
534 {problem}
535 </Text>
536 )}
537 <Text dimColor>{compact ? 'S 5-hour · W weekly' : 'S session (5-hour) · W weekly (7-day)'}</Text>
538 <Box flexDirection="column" marginTop={1}>
539 {rows}
540 </Box>
541 {list.length <= 1 && (
542 <Box flexDirection="column" marginTop={1}>
543 <Text dimColor wrap="wrap">No other accounts yet. In PowerShell:</Text>
544 <Text dimColor wrap="wrap">{'$env:CLAUDE_CONFIG_DIR="$HOME\\.claude-accounts\\<label>"; claude auth login'}</Text>
545 </Box>
546 )}
547 <Box flexDirection="row" gap={1} marginTop={1}>
548 <Button
549 key="mode"
550 hotkey="c"
551 label={compact ? 'expand' : 'compact'}
552 onPress={() => void update($, isCompact, value => !value)}
553 />
554 <Button key="refresh" hotkey="r" label="refresh" onPress={() => background?.refresh(true)} />
555 </Box>
556 </Box>
557 )
558 })
559}
560hooks/next.ts 41 lines1import type { AccountStatus, Reading } from '../types'
2
3/**
4 * One account the sidebar may suggest: same organization as the active one,
5 * not the active one. Its readings are what the last poll saw.
6 */
7export type Candidate = {
8 label: string
9 /** Its place in your `order` setting: 0 is first in line. */
10 rank: number
11 status: AccountStatus
12 /** 0-100, already 0 where the window's reset time has passed; undefined if never read. */
13 sessionPercent: number | undefined
14 weeklyPercent: number | undefined
15 session?: Reading
16 weekly?: Reading
17 /** When the readings were taken (ms); undefined if never. */
18 asOf: number | undefined
19}
20
21/** An account at or above this weekly use is never suggested: it would block soon after. */
22export const WEEKLY_CEILING = 90
23
24/**
25 * Chooses the account to suggest when the active one reaches its session
26 * limit. Returns its label, or null to suggest none.
27 *
28 * Skips lapsed logins and accounts at WEEKLY_CEILING or more; of the rest,
29 * the most session room wins (a window past its reset counts as empty), then
30 * your `order`. An account never read sorts after every read one.
31 */
32export function pickNext(candidates: readonly Candidate[], now: number): string | null {
33 void now
34 const usable = candidates.filter(
35 c => c.status !== 'expired' && c.status !== 'not-signed-in' && (c.weeklyPercent ?? 0) < WEEKLY_CEILING,
36 )
37 const session = (c: Candidate) => c.sessionPercent ?? Number.POSITIVE_INFINITY
38 usable.sort((a, b) => session(a) - session(b) || a.rank - b.rank)
39 return usable[0]?.label ?? null
40}
41hooks/probe.ts 73 lines1// A short-lived helper process reads a profile's login and asks the usage
2// endpoint itself, so no token ever enters this mod, nor any hook's event
3// (another mod hooking file reads or web requests would see it there).
4// It prints `expires <ms>`, `status <code>` and the response body, or `none`.
5
6import { USAGE_BETA, USAGE_URL, parseUsage } from './usage'
7import type { Windows } from './usage'
8
9/** The profile folder reaches the helper through this variable, never its command line. */
10export const PROFILE_VAR = 'ACCOUNT_BARS_PROFILE'
11
12/**
13 * Windows PowerShell 5.1, which every Windows has at a fixed path. One line
14 * with no double quotes, so it crosses a Windows command line unescaped.
15 * Every command is named with its module, so nothing earlier on the module
16 * path can stand in for it. Any failure prints `error` and nothing more: an
17 * error message could quote the login it failed to read.
18 */
19export const POWERSHELL_PROBE =
20 "try{$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';" +
21 `$f=Microsoft.PowerShell.Management\\Join-Path $env:${PROFILE_VAR} '.credentials.json';` +
22 "if(-not(Microsoft.PowerShell.Management\\Test-Path -LiteralPath $f)){'none';exit};" +
23 '$raw=Microsoft.PowerShell.Management\\Get-Content -Raw -LiteralPath $f;' +
24 "if($raw -notmatch '.claudeAiOauth.\\s*:\\s*(\\{[^{}]*\\})'){'none';exit};" +
25 '$o=$Matches[1]|Microsoft.PowerShell.Utility\\ConvertFrom-Json;' +
26 "'expires '+$o.expiresAt;" +
27 `try{$r=Microsoft.PowerShell.Utility\\Invoke-WebRequest -UseBasicParsing -TimeoutSec 20 -Uri '${USAGE_URL}' ` +
28 `-Headers @{Authorization='Bearer '+$o.accessToken;'anthropic-beta'='${USAGE_BETA}'};` +
29 "'status '+[int]$r.StatusCode;$r.Content}" +
30 "catch{$c=0;if($_.Exception.Response){$c=[int]$_.Exception.Response.StatusCode};'status '+$c}" +
31 "}catch{'error'}"
32
33/**
34 * POSIX sh and curl. PATH is pinned, so `tr`, `sed` and `tail` come from the
35 * system's own folders. The token goes to curl on its standard input
36 * (`-H @-`), never on a command line; `-q` keeps a ~/.curlrc from adding
37 * options (verbose output would print the header), and only https is allowed.
38 */
39export const SH_PROBE = [
40 'PATH=/usr/bin:/bin; export PATH',
41 `f="$${PROFILE_VAR}/.credentials.json"`,
42 '[ -f "$f" ] || { echo none; exit 0; }',
43 `o=$(tr -d '\\r\\n' < "$f" | sed -n 's/.*"claudeAiOauth"[[:space:]]*:[[:space:]]*{\\([^{}]*\\)}.*/\\1/p')`,
44 '[ -n "$o" ] || { echo none; exit 0; }',
45 `t=$(printf '%s' "$o" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')`,
46 `x=$(printf '%s' "$o" | sed -n 's/.*"expiresAt"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p')`,
47 '{ [ -n "$t" ] && [ -n "$x" ]; } || { echo error; exit 0; }',
48 'echo "expires $x"',
49 `r=$(printf 'Authorization: Bearer %s\\n' "$t" | /usr/bin/curl -q -s --proto =https -m 20 -H @- -H 'anthropic-beta: ${USAGE_BETA}' -w '\\n%{http_code}' '${USAGE_URL}')`,
50 `echo "status $(printf '%s\\n' "$r" | tail -n 1)"`,
51 `printf '%s\\n' "$r" | sed '$d'`,
52].join('\n')
53
54export type Probe =
55 | { isSignedIn: false }
56 | { isSignedIn: true; expiresAt: number | undefined; status: number; windows: Windows }
57
58/** Reads what a probe printed; anything else is "no reading", never a guess. */
59export function parseProbe(stdout: string): Probe | undefined {
60 const text = stdout.replace(/\r\n/g, '\n')
61 if (text.trim() === 'none') return { isSignedIn: false }
62 const expires = /^expires (\d+)\s*$/m.exec(text)
63 const status = /^status (\d+)\s*$/m.exec(text)
64 if (status === null) return undefined
65 const bodyAt = text.indexOf('\n', status.index)
66 return {
67 isSignedIn: true,
68 expiresAt: expires ? Number(expires[1]) : undefined,
69 status: Number(status[1]),
70 windows: bodyAt === -1 ? {} : parseUsage(text.slice(bodyAt + 1)),
71 }
72}
73hooks/usage.ts 123 lines1import type { SessionRateLimit } from 'claude-code'
2
3import type { Reading } from '../types'
4
5/** The endpoint Claude Code's own /usage reads (undocumented). */
6export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
7export const USAGE_BETA = 'oauth-2025-04-20'
8
9export type Band = 'green' | 'yellow' | 'orange' | 'red'
10
11export const BAND_COLOR: Record<Band, string> = {
12 green: '#22c55e',
13 yellow: '#eab308',
14 orange: '#f97316',
15 red: '#ef4444',
16}
17
18export function band(percent: number): Band {
19 if (percent >= 90) return 'red'
20 if (percent >= 75) return 'orange'
21 if (percent >= 50) return 'yellow'
22 return 'green'
23}
24
25/** A window whose reset time has passed is empty again, whatever it last read. */
26export function effectivePercent(reading: Reading | undefined, now: number): number | undefined {
27 if (reading === undefined) return undefined
28 if (reading.resetsAt !== undefined) {
29 const at = Date.parse(reading.resetsAt)
30 if (Number.isFinite(at) && at <= now) return 0
31 }
32 return reading.percent
33}
34
35export type Windows = { session?: Reading; weekly?: Reading }
36
37/** Reads the usage endpoint's body; a field of another shape is left out, never guessed. */
38export function parseUsage(text: string): Windows {
39 let body: unknown
40 try {
41 body = JSON.parse(text)
42 } catch {
43 return {}
44 }
45 return withWindows(readWindow(body, 'five_hour'), readWindow(body, 'seven_day'))
46}
47
48function readWindow(body: unknown, key: string): Reading | undefined {
49 if (!isRecord(body)) return undefined
50 const window = body[key]
51 if (!isRecord(window)) return undefined
52 const { utilization, resets_at } = window
53 if (typeof utilization !== 'number' || !Number.isFinite(utilization)) return undefined
54 return typeof resets_at === 'string'
55 ? { percent: Math.max(0, utilization), resetsAt: resets_at }
56 : { percent: Math.max(0, utilization) }
57}
58
59/** The windows the engine pushes for the session's own account. */
60export function fromRateLimits(limits: readonly SessionRateLimit[]): Windows {
61 const pick = (kind: string): Reading | undefined => {
62 const window = limits.find(limit => limit.kind === kind)
63 if (window === undefined) return undefined
64 return window.resetsAt === undefined
65 ? { percent: window.percentUsed }
66 : { percent: window.percentUsed, resetsAt: window.resetsAt }
67 }
68 return withWindows(pick('five_hour'), pick('seven_day'))
69}
70
71function withWindows(session: Reading | undefined, weekly: Reading | undefined): Windows {
72 return { ...(session ? { session } : {}), ...(weekly ? { weekly } : {}) }
73}
74
75export function bar(percent: number, width: number): { filled: string; empty: string } {
76 const cells = Math.max(1, width)
77 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
78 return { filled: '█'.repeat(filled), empty: '░'.repeat(cells - filled) }
79}
80
81/** "42m", "2h 05m", "3d 4h"; "reset" once due; empty when unknown. */
82export function untilText(iso: string | undefined, now: number): string {
83 if (iso === undefined) return ''
84 const at = Date.parse(iso)
85 if (!Number.isFinite(at)) return ''
86 const minutes = Math.round((at - now) / 60_000)
87 if (minutes <= 0) return 'reset'
88 if (minutes < 60) return `${minutes}m`
89 const hours = Math.floor(minutes / 60)
90 if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
91 return `${Math.floor(hours / 24)}d ${hours % 24}h`
92}
93
94export function agoText(asOf: number | undefined, now: number): string {
95 if (asOf === undefined) return 'never'
96 const seconds = Math.max(0, Math.round((now - asOf) / 1000))
97 if (seconds < 10) return 'just now'
98 if (seconds < 60) return `${seconds}s ago`
99 const minutes = Math.round(seconds / 60)
100 if (minutes < 60) return `${minutes}m ago`
101 const hours = Math.round(minutes / 60)
102 return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`
103}
104
105/**
106 * Text from outside the mod (a folder name) with control and format
107 * characters removed: terminal escapes, bidi overrides, zero-width marks.
108 */
109export function printable(text: string): string {
110 return text.replace(/[\p{Cc}\p{Cf}]/gu, '')
111}
112
113/** A short reason fit to show: token-shaped text replaced, one line, at most 80 characters. */
114export function scrub(text: string): string {
115 const line = printable(text.replace(/\s+/g, ' ')).trim()
116 const safe = line.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[token]').replace(/(Bearer)\s+\S+/gi, '$1 [token]')
117 return safe.length > 80 ? `${safe.slice(0, 79)}…` : safe
118}
119
120export function isRecord(value: unknown): value is Record<string, unknown> {
121 return typeof value === 'object' && value !== null && !Array.isArray(value)
122}
123types/index.d.ts 38 lines1/** One usage window: how full it is (0-100) and when it empties again. */
2export type Reading = { percent: number; resetsAt?: string }
3
4/**
5 * Where an account's numbers stand: read (`ok`), not yet read (`pending`),
6 * its profile's login lapsed (`expired`), the last read failed (`error`),
7 * its profile holds no login (`not-signed-in`), or polling is off (`paused`).
8 */
9export type AccountStatus = 'ok' | 'pending' | 'expired' | 'error' | 'not-signed-in' | 'paused'
10
11/** One row of the sidebar. Labels and numbers only: no e-mail, no token. */
12export type AccountView = {
13 label: string
14 isActive: boolean
15 /** Same organization as the active account; null while unknown. */
16 isSameOrg: boolean | null
17 status: AccountStatus
18 session?: Reading
19 weekly?: Reading
20 /** When the readings were taken, in `$.clock.now()` milliseconds. */
21 asOf?: number
22 /** Why the last read failed, in a few words (no token, no address); absent when it did not. */
23 detail?: string
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'account-bars': {
29 accounts: AccountView[]
30 isCompact: boolean
31 nextUp: string | null
32 now: number
33 /** A setup problem to show above the bars (claude or the helper not found); null when none. */
34 notice: string | null
35 }
36 }
37}
38