SLOPSHOPPER

account-bars

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.

newpanecommandtoastprocessnetwork
v0.1.0no licenseupdated 2026-10-10raoofaltaher/claude-code-mods/account-bars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · account-bars
│ ┃ accounts ✕ › fix the failing auth test and add an audit log call │ ┃ S session (5-hour) · W weekly (7-day) │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No other accounts yet. In PowerShell: ⏺ Update(src/auth.ts) │ ┃ $env:CLAUDE_CONFIG_DIR="$HOME\.claude-accoun ⎿ Added 2 lines, removed 1 line │ ┃ ts\<label>"; claude auth login ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ compact ] [ refresh ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /accounts │ ⎿ account-bars: Accounts sidebar hidden. /accounts brings it back. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · accounts
S session (5-hour) · W weekly (7-day) No other accounts yet. In PowerShell: $env:CLAUDE_CONFIG_DIR="$HOME\.claude-accounts\<label>"; claude auth login [ compact ] [ refresh ]
README

Claude Code mods

Two mods for Claude Code's function-hooks plugin system:

  • account-bars: live session and weekly limit bars for every Claude account you use, in a sidebar beside the conversation.
  • tool-icons: colored icons on tool lines.

Both are tested on Claude Code 2.1.296. Mods are an early-access API that can change between releases.


account-bars

A sidebar on the right of the terminal conversation with one pair of bars per Claude account:

  • S: the session (5-hour) limit.
  • W: the weekly (7-day) limit.

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 ]

Install

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

Add your accounts

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:

  • Make sure the browser approves the right account. It is usually still signed in to claude.ai as your current account. Either switch accounts on claude.ai first, or open the sign-in link the terminal prints in a private window.
  • claude auth login --email <address> fills in the address.
  • --sso forces single sign-on.
  • Add the account you are signed into as well (for example 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.
  • Labels:
  • Don't use an e-mail address as a folder name, since it would show on screen.
  • To rename an account, close anything using that profile and rename its folder. Then update order (below) if you set it.

Use

/accountsShow or hide the sidebar.
/accounts refreshRe-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 → orderYour preferred order, e.g. work1,work2,personal. Unlisted accounts follow alphabetically.
/config → account-bars → claudePathThe 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:

  • It docks on the right in Claude Code's full-screen layout when the terminal is at least 110 columns wide (opened with /accounts).
  • It opens by itself from 144 columns.
  • Outside the full-screen layout it sits above the prompt.

Status lines under each account:

LineMeaning
as of 40s agoLive reading. Other accounts refresh every 5 minutes. The account you are signed into also updates after every reply.
reading…Not read yet.
sign-in expiredThat profile's login lapsed. Run claude auth login for it again (see above). The mod tries it again every 10 minutes.
no reading, retryingThe 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 inThe folder exists but holds no login.
other org, never suggestedTracked, but in a different organization from the account you are signed into.
pausedCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set, so other accounts are not polled.
a yellow line at the topA setup problem: claude, or the program that reads other accounts, was not found at its fixed path.

At the session limit:

  • A toast and a desktop notification name the next account, for example: "Session limit reached. Next in line: work2 (session 0%, weekly 67%). Run /login and pick it."
  • The sidebar marks that account next.
  • You get one notice per session window.
  • Only accounts in the same organization as the one you are signed into are ever suggested.

Choosing who is "next"

The policy lives in one function, pickNext() in account-bars/hooks/next.ts. Each candidate carries:

  • its place in your order;
  • its status;
  • its session and weekly percentages (already 0 once a window has reset);
  • when it was last read.

The rule:

  1. Skip accounts whose login lapsed or that have none.
  2. Skip accounts at 90 % weekly or more (WEEKLY_CEILING), since they would block soon after.
  3. Of the rest, take the one with the most session room (a window past its reset counts as empty), then your order.
  4. If none is fit, suggest nothing rather than an account about to block.

Change the function to suit how you work.

How it works

AccountWhere its numbers come fromCredential involved
The one this session is signed intoReadings Claude Code itself pushes to mods after every reply, plus the usage endpoint through Claude Code's credential handlenone: 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 processthat 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.

  • On Windows it is Windows PowerShell 5.1, at its fixed path under %SystemRoot%.
  • On Linux it is /bin/sh with /usr/bin/curl. The token reaches curl on its standard input, never on a command line.
  • The profile folder is passed to the helper in an environment variable, never pasted into a command.
  • The helper's script is in account-bars/hooks/probe.ts.

To recognise accounts and organizations, the mod runs claude auth status --json:

  • When: at start, after /login, on refresh, and every 10 minutes.
  • What it keeps: the e-mail and organization ID it returns, in memory only.

Security and privacy

  • Tokens never enter the mod.
  • The session's own account is read through Claude Code's credential handle, which keeps the secret inside Claude Code.
  • Every other account is read by the helper process above.
  • So no token passes through the mod, or through the events other installed mods can hook (file reads, web requests, process runs).
  • Tokens are only ever sent to https://api.anthropic.com.
  • Programs run only by full path, from your home folder. That is 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.
  • The Linux helper's own tools (tr, sed, tail) come only from /usr/bin and /bin. curl ignores ~/.curlrc and allows https only.
  • The Windows helper loads PowerShell's own modules only, and names every command with its module.
  • When a helper fails, it prints only error, never the error text, which could quote the login it failed to read.
  • Text from outside is cleaned: folder names have control and format characters removed (terminal escapes, bidi overrides, zero-width marks) before they are drawn or put in a notification.
  • What is saved: only labels, percentages and reset times.
  • E-mails and organization IDs: held in this mod's memory only, and never saved or shown. Other mods can read a mod's shared state, so these are kept out of it. They do pass through the claude auth status results, which another installed mod could hook.
  • Credentials files: the mod itself never reads or writes them, and never switches accounts. It never refreshes a login itself either: it asks Claude Code to (see Token renewal below). Claude Code manages every profile's sign-in.
  • Same organization only: suggestions never move a work conversation to an account outside its organization.
  • No-traffic setting: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stops everything that touches other accounts: both their usage reads and their identity checks.

Things to know

  • Undocumented endpoint: Anthropic can change or restrict the usage endpoint without notice. If it does, the affected accounts show no reading, retrying instead of breaking. The account you are signed into keeps updating from Claude Code's own readings.
  • Plain-text logins: each profile is a full Claude Code login on disk, stored like your main one (.credentials.json). Keep your disk encrypted.
  • PowerShell logging: if your organization turns on PowerShell module or script-block logging, Windows records the helper's runs. The script itself holds no token (it reads it at run time), but check what your logging captures before relying on that.
  • macOS: Claude Code keeps logins in the macOS Keychain, not in .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.
  • Token renewal: access tokens last about 8 hours. When one is close to expiry, the mod runs claude auth status for that profile so Claude Code can renew it. If a profile still lapses, it shows sign-in expired.
  • Terms of use: switching stays manual by design. Anthropic's terms restrict account sharing and getting around usage limits, so check your plan's terms before relying on several accounts.

Develop

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.


tool-icons

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.

  • Any built-in tool that runs a command (Bash, PowerShell, Monitor) shows the command itself, never the model's description of it.
  • A multi-line command is marked, e.g. (3 lines) ….
  • A long one keeps its start and its end, so what it ends with stays in sight.
  • Other tools show what they act on (a recipient, a schedule, a link) ahead of any description.
  • The red delete icon is a hint read from the command's wording, not a safety check.
  • tool-icons only redraws transcript rows, never the approval dialog.
  • Its rows show the call, not the tool's output. Run /icons off to see Claude Code's own rows, output and error text included.
  • Control and format characters are removed from every row: terminal escapes, bidi overrides and zero-width marks.
Source 5 files
hooks/register.tsx 560 lines
1import { 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}
560
hooks/next.ts 41 lines
1import 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}
41
hooks/probe.ts 73 lines
1// 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}
73
hooks/usage.ts 123 lines
1import 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}
123
types/index.d.ts 38 lines
1/** 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