SLOPSHOPPER

switchboard-profile-band

A bar above the prompt in the Switchboard profile's colour, with its name, plan and rate limits left

newbandprocesstimer
★ 3v0.2.0MITupdated 2026-10-08shayaansultan/switchboard/plugins/profile-band
A shopper browsing a rack in a slop shop
README

Switchboard

Run multiple Claude accounts at the same time on one Mac, and multiple ChatGPT or Codex accounts too. Two, or as many as you have memory for. Keep a personal account and a work account both signed in, in their own desktop windows and their own terminals, instead of logging out and back in every time you switch. Each account also gets a live rate-limit bar, so you can see which one has headroom left before you start.

Switchboard showing four accounts, each with its own usage bars

It is for accounts you own. Native profiles use their own account. Codex desktop profiles can also use a shared proxy bucket, which selects accounts and handles inference quota failover. See Proxy buckets.

Can you run two Claude accounts at once? Or more?

Yes to both, and the same goes for Codex. There is no limit built in. Both desktop apps are Chromium-based, so each account gets its own user-data directory and runs as a genuinely separate window with its own session. The command-line tools are separated the same way, with an environment variable per account.

Each profile is isolated twice over, because the two halves hold different state. Both desktop apps embed an agent that reads the same config-home variable the command-line tool does, so the flag alone is not enough.

AppSigned-in sessionAgent home: sessions, plugins, config
Claude--user-data-dir=<…>/desktopCLAUDE_CONFIG_DIR=<…>/home
Codex--user-data-dir=<…>/desktopCODEX_HOME=<…>/home

Every account is a profile owning an isolated directory tree under ~/.switchboard/<vendor>/<profile>/. Profiles never share cookies, tokens, chat history or session state, and you can run as many at once as you have memory for.

The "Default" profiles point at the normal locations, ~/.claude, ~/.codex and ~/Library/Application Support/{Claude,Codex}. Switchboard never writes to them itself.

Which Claude app is which?

Two Claude apps on different profiles look the same, so Switchboard marks every Claude profile but the Default one. When it launches that profile's desktop app it passes a small Claude Code plugin (plugins/profile-band), and each Code session shows the profile band: a bar in the profile's colour just above the prompt. On the left are its name in bold, its plan, how full this session's context window is and what the session has cost. On the right, each of the 5-hour and 7-day limits shows a bar of what is left, when it resets (a countdown for the 5-hour window, a local day and time for the 7-day one), and its pace: "on track" for the 7-day window when less of it is used than of the week, or "runs out Fri 14:00" for either window when use so far would empty it before the reset. A narrow window gives up the cost, the "on track" note, the 7-day reset, the context, the 5-hour reset, the bars and the plan, in that order, then whole windows, keeping a window with a pace warning longest; the name always stays. The limits refresh every five minutes from Switchboard's cache, fetched live when that is over 15 minutes old, and need the switchboard command installed; context and cost update after every turn, and a compaction hides the context until the next one. The Default profile's app has no band. A rename or recolour shows after the profile's next launch.

Does it work with Claude Code and the Codex CLI?

Yes. Each profile gets its own CLI login, and the Terminal button opens a shell already inside that account, so claude or codex in that window uses it. The copy icon on each profile copies the one-line command, which can go into an alias.

Profiles show as a list or as cards; pick either with the toggle above them. Drag a profile to reorder it within its app; the Default profile stays first.

Signing a profile's CLI in is also what enables its usage bars.

What are the rate-limit bars?

Every window the account has: the 5-hour and 7-day windows for Claude, including model-scoped ones such as a separate Opus pool, and the weekly and model-specific pools for Codex. Each shows how much is used, or how much is left if you prefer, with the time until it resets. They turn amber and then red as a window runs out.

You can read them in the menu bar without opening the window.

Can I use a saved usage reset?

Choose Usage resets… in a signed-in profile’s menu or a bucket account’s menu. A dialog opens in the window with the account’s current usage bars and each saved reset: how many are left, when it expires, which windows it clears, and why it can’t be used yet when it can’t. Pick one and choose Use reset… to see a confirmation that names the account and what will be spent; only Use reset on that screen spends it. Closing at any earlier point spends nothing. The last screen shows the provider’s answer beside the refreshed bars, with the old percentages struck through.

Claude’s next eligible grant and ChatGPT’s Codex reset credits are supported. A routed profile labels its own action Native account resets…. To reset a proxy account, use that account’s menu in Proxy buckets. The proxy supplies the selected account’s token; no native profile is required. After the vendor confirms a reset, Switchboard clears that account’s proxy cooldown, rechecks usage and updates its routing weight. Paused accounts stay paused. Other accounts keep their existing cooldowns. It never buys credits or changes a plan. A recent Claude CLI must be installed for Claude reset checks.

Existing bucket workers need a restart after installing this feature. Until a worker supports the targeted refresh, its reset dialog shows grants but does not allow redemption. Switchboard does not restart an active bucket for you.

These are private vendor interfaces and can change. Unknown responses stop the action; failed requests are never retried automatically. Pending request IDs are stored locally so an uncertain reply can be retried without spending a second reset. Check the vendor’s Usage page if a result is uncertain.

The CLI uses the same checks and confirmation flow. Listing never spends a reset; select an exact grant ID from the output to redeem it. resets PROFILE always targets the native account, even when that profile routes through a bucket. Bucket accounts can be selected by their exact name or an unambiguous email.

switchboard resets claude
switchboard resets codex --redeem GRANT_ID --yes
switchboard bucket resets BUCKET ACCOUNT
switchboard bucket resets BUCKET ACCOUNT --redeem GRANT_ID --yes

Results are JSON by default; add --human for readable output. Each offer carries remaining, expiresAt, clears and, when it can’t be used, reason. Without --yes, redemption prompts in a terminal and refuses in noninteractive calls. Check the JSON outcome for the vendor result and proxyRecovery for not-needed, refreshed, deferred or unconfirmed. An exit of zero alone does not mean quota was reset. Run switchboard usage PROFILE --refresh for fresh native usage. bun run test:usage-resets runs the isolated app and proxy worker integration checks.

Agent instructions

skills/switchboard/SKILL.md is the canonical agent guide. The CLI tab's Copy agent prompt button copies that file, and switchboard guide prints the same Markdown from the installed app. Packaging includes the guide, so instructions and executable commands update together.

Agentfiles and other skill managers can use a short entrypoint that tells the agent to run switchboard guide before operating Switchboard, rather than maintaining a separate copy of the operating instructions. For older installations without guide, update Switchboard; switchboard --help can identify the available commands.

Is the app running?

Each profile shows whether its desktop app is running: a green dot in the list, a pill on a card, and "(not running)" after its line in the menu bar. It keeps up on its own. Launching or quitting an app from anywhere, the Dock, Cmd+Q or the switchboard command, shows up within about a second, so there is no need to press Refresh, which is for the usage numbers.

Launch is the black play button and Quit the red power button. While one is on its way the button spins and the profile reads Starting… or Quitting…. An app still running ten seconds after Quit reads Won't quit, usually because it is asking you to confirm; switch to the app and answer it, or use Force quit, which loses anything unsaved in it.

Closing an app's window with its red button leaves the app running. Turn on Notice closed windows in Settings and such a profile reads No window, with a hollow dot, instead of Running. This needs Accessibility permission: turning the switch on asks macOS for it, and the switch stays off until you allow Switchboard in System Settings, then turns on by itself. Because the app is ad-hoc signed, macOS forgets the permission after each rebuild; the switch then reads off again, with a link to the settings pane, where you turn Switchboard off and on. A window that is minimised, hidden or on another desktop still counts as open. Whether or not the setting is on, Show window (a button on a No window row, and in each running profile's menu) brings that profile's window back, as clicking its Dock icon would.

Keep the Mac awake

Use Keep awake beside Refresh to turn macOS's system sleep setting on or off. macOS handles administrator authorization. The control reads the actual setting, including after you reopen Switchboard; closing or crashing the app leaves the setting as you chose it.

For implementation details and closed-lid hardware verification, read Keep awake.

Requirements

  • A macOS whose open supports --env, which is how a profile's config home reaches the app. Confirmed on macOS 26; I have not established the earliest version that carries the flag, so check with man open if you are on something older.
  • Claude Desktop, the ChatGPT app, or both, installed in /Applications. Other locations are not detected yet.
  • The claude or codex CLI for the usage bars. Switchboard asks your login shell for its environment, so a version manager such as nvm, fnm, volta or mise is fine, and so is fish or bash rather than zsh.
  • Apple Silicon has been tested. Intel should build, but has not been tried.

What it reads, and what leaves your Mac

Worth knowing before you run an unsigned app that touches your accounts.

  • Your keychain. To show Claude usage, Switchboard runs `security

find-generic-password` for the entry the Claude Code CLI already created. macOS will ask for your password the first time. It asks again after each rebuild, because ad-hoc signing produces a new code hash and the keychain entry's permission no longer recognises the app. Codex tokens are read from a file instead, so they produce no prompt.

  • App launches and quits. A small helper bundled with the app listens for macOS's notice that some app started or stopped, so the window can show a profile's app opening or closing at once. It reports only the process and its path, to Switchboard alone, and exits with it.
  • Other apps' windows, only if you turn on Notice closed windows. The helper then counts the Claude and ChatGPT windows each profile has open, through the Accessibility API and an undocumented macOS call that says which desktop a window is on. It reads counts, not window contents.
  • Usage requests per signed-in profile, on a timer. Normally one call to the same endpoints the two CLIs use for their own usage screens, authorised with that profile's existing CLI token. The interval in Settings is the rate while you are using Switchboard; left alone it slows to every 15 and then 30 minutes, never polls faster than the setting on battery, and stops while the Mac is asleep or locked. Who is signed in is only re-checked hourly, on a manual refresh, or when a usage call says the token is gone. Codex tries a second URL only if the first answers 404. Neither endpoint is documented by its vendor, so both can change without notice and the bars can go blank. Claude usage may retry once after a token change. Switchboard has no telemetry; the Claude CLI has its own network behaviour when started for session renewal.
  • Tokens never leave the main process. They are read, used for that one request, and dropped. What the window receives is the profile's own name and colour, its directory paths and the CLI command to enter it, whether the app is running, and from the account: the email address, plan name, organisation name, and the usage percentages with their reset times.
  • Never credentials. Switchboard does not write logins, and it will not copy API keys or credential helpers between profiles. When a Claude subscription token expires, Switchboard briefly starts Claude Code in an isolated empty directory so the CLI can renew that profile's OAuth session, then rereads the credential and fetches usage. The hidden terminal uses macOS /usr/bin/expect, safe mode, no tools or MCP servers, and no model prompt. Only the trust menu for that empty directory is accepted. Recovery has a 20-second deadline plus bounded process cleanup; failed attempts have a one-minute cooldown. If Claude requires interactive sign-in, renewal stops and the profile must be opened in Terminal. A rejected token that has not expired also requires sign-in, unless another CLI has already replaced it.

Install

bun install
bun run install-app

That builds Switchboard.app, copies it to /Applications, ad-hoc signs it and opens it. Launch it like any other app afterwards. Re-run the same command after changing the source. For a dev run without installing, use bun start.

The app is not signed with a Developer ID and not notarised, so macOS may warn about it. Building it yourself, as above, is the intended path.

It also lives in the menu bar with launch, quit and usage per profile, so you can close the window and leave it running. Settings has an "Open at login" switch; started that way it stays in the menu bar until you click it.

Adding an account

  1. Click + Profile in the Claude or Codex panel and name it, then choose which existing profile to start from and what to bring over.
  2. Click the launch button (the filled play icon on the row). A fresh window opens with no session. Quit the other windows of that app first, using the "Quit others now" link on the card. The sign-in link is delivered to whichever window macOS picks.
  3. Click Sign in CLI on the card's warning line (or in its ⋯ menu). A terminal opens running claude auth login or codex login inside that profile. This is what enables the usage bars.
  4. The terminal icon opens a shell already inside the profile, in whichever terminal you choose in Settings. Terminal.app, iTerm2, Ghostty, Warp, kitty, Alacritty and WezTerm are supported, and only the installed ones are listed.

Remove on a card deletes the profile and everything under its directory: the CLI login, the desktop session, history and settings. There is no way to remove a profile and keep its data. The Default profiles cannot be removed.

Keep in sync, or copy once

Items you bring over can be linked or copied, and the difference matters.

Keep in sync makes the new profile's file a symlink to the source profile's. One edit then applies to both, which is the point for skills you maintain in one place. It also means the reverse: if the second account's agent rewrites a file, for example when it updates CLAUDE.md or AGENTS.md from a memory shortcut, that write lands in the source profile too. Choose Copy once for anything you want the two accounts to be able to change independently.

Chat history is always copied, never linked, because two accounts writing into one session folder would corrupt each other's resume lists. For Codex it also carries the sidebar's project list and which project each thread sits in, since the app keeps that separately from the sessions and would otherwise show the threads unplaced or, for ones run in worktrees, not at all.

Logins, memories and session state are never brought over at all. Connectors and plugins are offered but off by default, because they reach the source account's Slack, Notion and so on.

Driving Switchboard from a terminal or an agent

Everything the window does is also a switchboard command, with results as JSON so an agent can read them and a person can pipe them into jq. The common questions have one-line answers:

switchboard list                      # profiles, running state, signed-in account
switchboard usage --max-age 15m       # rate-limit windows, refreshed if older than 15 minutes
switchboard pick claude               # the Claude profile with the most quota left
switchboard exec work -- claude -p "…"   # run anything inside a profile's account
eval "$(switchboard env work)"        # put the current shell into a profile
switchboard launch work               # open its desktop window
switchboard add codex Client --from codex   # a new profile, set up like the Default
switchboard routed work app-server    # Codex through work's proxy bucket, as its window runs

A Codex profile assigned to a proxy bucket reaches the pool through a local port that changes whenever the bucket's worker restarts. A client Switchboard does not launch, such as T3 Code or a script, should run switchboard routed PROFILE as its Codex binary; it starts the worker if needed and finds the current port on every call.

Install it from the CLI tab in the window, or once with switchboard install-cli (or node out/cli.js install-cli from this checkout) after bun run install-app. The command runs on the installed app's own runtime and code, so it is always the same version as the app, and bun run install-app updates both. bun run cli -- list runs it from source without installing.

Profiles are addressed by id (claude-work), by vendor (claude means that vendor's Default), by vendor/name, or by a name that only one profile has. switchboard --help lists every command.

The contract, for anything that parses the output: a successful command prints its result as JSON on stdout and exits 0. A failed one prints nothing on stdout, one JSON object on stderr with a stable error code, a message and usually a hint, and exits 1 (it ran and failed), 2 (usage), 3 (not found) or 4 (refused by a safety rule, such as removing a profile whose window is open, or --yes missing). exec, cli and routed exit with the child's own status. Per-profile usage errors are data inside a successful result, with a status of ok, stale, not-signed-in, error or none.

The CLI reads the app's cached usage numbers by default and fetches live only with --max-age or --refresh; it never polls. It writes profiles.json directly, and a running app notices and reloads within about a second, so the window and the command line never disagree about which profiles exist.

Caveats

  • Neither vendor supports any of this. An update to either app could break the --user-data-dir flag or the usage endpoints. The Default profiles keep working regardless.
  • Launch extra profiles from Switchboard rather than from the Dock. A Dock launch carries neither the flag nor the variable, so it opens the Default profile regardless of which window you meant.
  • Codex and ChatGPT are the same ChatGPT.app bundle, and every profile launches that one bundle, so an update to it applies to all of them. macOS registers each instance separately, which means one Dock icon per running profile rather than one for the app.
  • Claude token renewal depends on the installed CLI's interactive startup behaviour and may break after a CLI update. If automatic renewal reports that sign-in is required, open that profile in Terminal and sign in there. Organisation-managed Claude policies can still affect this isolated startup.
  • ANTHROPIC_API_KEY in your shell makes Claude Code bill the API instead of your subscription. Switchboard never sets it; check your own shell config.

Development

The source is TypeScript. The main process is compiled by tsc into out/, which is what Electron runs. The window is a small Preact app under src/renderer/, bundled by bun build into out/renderer/main.js; its styling is one stylesheet of tokens and primitives modelled on Wispr Flow, with Figtree and EB Garamond bundled. Tests import the .ts files directly.

bun test          # profile isolation, setup logic and the usage parsers
bun run test      # the same, after oxlint and a type check
bun run test:claude-pty # compiled PTY integration checks under Node, on macOS
bun run typecheck # tsc --noEmit
bun run lint      # oxlint
bun format        # oxfmt; bun run fmt is also supported (CSS/HTML excluded by config)
bun start         # build, then run without installing
bun run demo      # regenerate the screenshot and social card from invented accounts
bun run icons     # re-render the icon PNGs from build/icon.svg

The screenshot is produced by rendering the app's own UI against demo/fixture.js, so it never contains anyone's real accounts and stays current when the interface changes.

OpenCode terminal profiles

The terminal-first oc CLI adds isolated OpenCode profiles with separate service connections and per-profile ChatGPT account pools. It runs without the Electron app. See OpenCode profiles for installation, selective imports, the six service adapters, routing behaviour, and verification.

MIT licensed. See LICENSE.

Source 2 files
hooks/register.tsx 319 lines
1// A Claude Code mod that Switchboard passes to every Claude profile it
2// launches (CLAUDE_CODE_PLUGIN_DIRS, see src/launch.ts). It fills the band
3// above the prompt with the profile's colour, so which profile an app belongs
4// to, and whether it has room for more work, can be spotted from across the
5// screen. On the left: the profile's name and plan, then how full this
6// session's context window is and what the session has cost. On the right:
7// the 5-hour and 7-day rate limits, each as a bar of what is left, when it
8// resets, and whether use so far would run it out before then.
9//
10// The limits come from `switchboard usage` every five minutes: Switchboard's
11// cache, fetched live when that is over 15 minutes old, and never renewing the
12// sign-in this session is using. Context and cost come from the session itself
13// when it starts and after every turn; a compaction hides the context until
14// the next turn. A narrow band drops
15// detail in a fixed order (DROPS), then whole windows, a window with a pace
16// warning last, and never the name. The Default profile is launched without
17// the variables and draws nothing.
18
19import { atom, read, update } from 'claude-code';
20import type { EngineInterface, Register } from 'claude-code';
21
22import type { BandSession, BandUsage, BandWindow } from '../types';
23
24const REFRESH_MS = 5 * 60 * 1000;
25const usage = atom({ plugin: 'switchboard-profile-band', key: 'usage' } as const, null);
26const session = atom({ plugin: 'switchboard-profile-band', key: 'session' } as const, null);
27
28const HOUR = 60 * 60 * 1000;
29const WINDOW_MS: Record<string, number> = { '5h': 5 * HOUR, '7d': 7 * 24 * HOUR };
30const BAR_CELLS = 10;
31
32type Profile = { id: string | undefined; name: string; color: string; command: string | undefined };
33
34// Text on the profile's colour: dark on a light colour, light on a dark one.
35// `undefined` for anything but #rrggbb, which then colours the name instead.
36export function labelColor(hex: string): string | undefined {
37  const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
38  if (!m) return undefined;
39  const [r, g, b] = m.slice(1).map((c) => {
40    const v = parseInt(c, 16) / 255;
41    return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
42  });
43  const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
44  return luminance > 0.4 ? '#1a1a1a' : '#ffffff';
45}
46
47// The profile's plan and its 5-hour and 7-day windows from `switchboard usage
48// PROFILE` output; null when there are no numbers to show.
49export function parseUsage(stdout: string): BandUsage | null {
50  try {
51    const row = JSON.parse(stdout).profiles?.[0]?.usage;
52    if (!row || (row.status !== 'ok' && row.status !== 'stale')) return null;
53    const windows: BandWindow[] = (row.windows ?? [])
54      .filter((w: BandWindow) => w.label === '5h' || w.label === '7d')
55      .map((w: BandWindow) => ({
56        label: w.label,
57        remaining: w.remaining,
58        resetsAt: w.resetsAt ?? null,
59        severity: w.severity,
60      }));
61    return { plan: row.plan ?? null, windows };
62  } catch {
63    return null;
64  }
65}
66
67// "2h 5m", "40m", "3d": how long until a window resets.
68export function until(resetsAt: string | null, now: number): string | null {
69  const at = resetsAt ? Date.parse(resetsAt) : NaN;
70  if (Number.isNaN(at) || at <= now) return null;
71  const minutes = Math.round((at - now) / 60000);
72  if (minutes < 60) return `${minutes}m`;
73  if (minutes < 48 * 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
74  return `${Math.round(minutes / 1440)}d`;
75}
76
77// "Sat 02:00", or "16:20" without the day: a local time, for the 7-day reset,
78// whose reset is days off and easier to plan around as a date than a count.
79// Rounded to the minute: resets land a moment before the hour (01:59:59.9).
80function clockTime(at: number, withDay: boolean, timeZone?: string): string {
81  return new Date(Math.round(at / 60000) * 60000).toLocaleString('en-GB', {
82    ...(withDay ? { weekday: 'short' } : {}),
83    hour: '2-digit',
84    minute: '2-digit',
85    timeZone,
86  });
87}
88
89export function resetsOn(resetsAt: string | null, now: number, timeZone?: string): string | null {
90  const at = resetsAt ? Date.parse(resetsAt) : NaN;
91  if (Number.isNaN(at) || at <= now) return null;
92  return clockTime(at, true, timeZone);
93}
94
95// Whether the window lasts to its reset at the rate it has been used so far:
96// 'ok' when it does, the moment it would run out when it does not, null when
97// there is too little of the window behind us to say (its first tenth) or no
98// reset time, or nothing left, which the bar already says. The two answers are
99// exact complements: a window runs out early precisely when less of it is left
100// than of the time.
101export function pace(w: BandWindow, now: number): 'ok' | { runsOutAt: number } | null {
102  const windowMs = WINDOW_MS[w.label];
103  const at = w.resetsAt ? Date.parse(w.resetsAt) : NaN;
104  if (!windowMs || Number.isNaN(at) || at <= now || w.remaining <= 0) return null;
105  const timeLeft = at - now;
106  const elapsed = windowMs - timeLeft;
107  if (elapsed < windowMs / 10) return null;
108  const used = 100 - w.remaining;
109  if (used <= 0 || w.remaining * elapsed >= used * timeLeft) return 'ok';
110  return { runsOutAt: now + (w.remaining * elapsed) / used };
111}
112
113// "███████░░░": what is left of a window, in tenths.
114export function bar(remaining: number): string {
115  const full = Math.max(0, Math.min(BAR_CELLS, Math.round(remaining / 10)));
116  return '█'.repeat(full) + '░'.repeat(BAR_CELLS - full);
117}
118
119// What the band can say, before it is fitted to the width it has.
120export type BandFacts = {
121  name: string;
122  plan: string | null;
123  context: number | null;
124  costUsd: number | null;
125  limits: {
126    label: string;
127    remaining: number;
128    // "resets in 1h 47m" or "resets Sat 02:00".
129    resets: string | null;
130    // "on track", "runs out Fri 14:00", or nothing to say.
131    pace: { text: string; isWarning: boolean } | null;
132    isLow: boolean;
133  }[];
134};
135
136// What a narrow band gives up, first to last, before it drops whole windows.
137const DROPS = ['cost', 'onTrack', 'reset7d', 'context', 'reset5h', 'bars', 'plan'] as const;
138type Drop = (typeof DROPS)[number];
139
140export type BandLayout = { detail: string; limits: { key: string; text: string; isBold: boolean }[] };
141
142function layout(f: BandFacts, dropped: Set<Drop>, hidden: Set<string>): BandLayout {
143  const detail = [
144    !dropped.has('plan') && f.plan,
145    !dropped.has('context') && f.context !== null && `context ${f.context}%`,
146    // Less than a cent is not worth the room.
147    !dropped.has('cost') && f.costUsd !== null && f.costUsd >= 0.005 && `$${f.costUsd.toFixed(2)}`,
148  ].filter(Boolean);
149  const limits = f.limits
150    .filter((l) => !hidden.has(l.label))
151    .map((l) => {
152      const parts = [
153        `${l.label} ${dropped.has('bars') ? '' : `${bar(l.remaining)} `}${l.remaining}%`,
154        !dropped.has(`reset${l.label}` as Drop) && l.resets,
155        l.pace && (l.pace.isWarning || !dropped.has('onTrack')) && l.pace.text,
156      ].filter(Boolean);
157      return { key: l.label, text: parts.join(' · '), isBold: l.isLow || !!l.pace?.isWarning };
158    });
159  return { detail: detail.length ? `  ${detail.join(' · ')}` : '', limits };
160}
161
162// The most of the band that fits in `columns` cells, the padding included.
163// Windows go from the right, those with a pace warning after the rest.
164export function fitBand(f: BandFacts, columns: number): BandLayout {
165  const windows = [...f.limits]
166    .reverse()
167    .sort((a, b) => Number(!!a.pace?.isWarning) - Number(!!b.pace?.isWarning))
168    .map((l) => l.label);
169  const steps = DROPS.length + windows.length;
170  for (let n = 0; ; n++) {
171    const dropped = new Set(DROPS.slice(0, n));
172    const shown = layout(f, dropped, new Set(windows.slice(0, Math.max(0, n - DROPS.length))));
173    const width = 2 + f.name.length + shown.detail.length + shown.limits.reduce((w, l) => w + l.text.length + 4, 0);
174    if (width <= columns || n === steps) return shown;
175  }
176}
177
178export function bandFacts(
179  p: { name: string },
180  u: BandUsage | null,
181  s: BandSession | null,
182  now: number,
183  timeZone?: string,
184): BandFacts {
185  return {
186    name: p.name,
187    plan: u?.plan ?? null,
188    context: s?.context ?? null,
189    costUsd: s?.costUsd ?? null,
190    limits: (u?.windows ?? []).map((w) => {
191      const is5h = w.label === '5h';
192      const reset = is5h ? until(w.resetsAt, now) : resetsOn(w.resetsAt, now, timeZone);
193      const paced = pace(w, now);
194      return {
195        label: w.label,
196        remaining: w.remaining,
197        resets: reset && `resets ${is5h ? 'in ' : ''}${reset}`,
198        // The 5-hour window speaks up only when it is running out.
199        pace:
200          paced === 'ok'
201            ? is5h
202              ? null
203              : { text: 'on track', isWarning: false }
204            : paced && { text: `runs out ${clockTime(paced.runsOutAt, !is5h, timeZone)}`, isWarning: true },
205        // Low as Switchboard's own window colours it: warned, or 70% used.
206        isLow: w.severity === 'warning' || w.severity === 'critical' || w.remaining <= 30,
207      };
208    }),
209  };
210}
211
212// The launch environment does not change while the session runs.
213let profile: Promise<Profile | null> | undefined;
214
215function loadProfile($: EngineInterface): Promise<Profile | null> {
216  profile ??= (async () => {
217    const name = await $.env.get('SWITCHBOARD_PROFILE_NAME');
218    if (!name) return null;
219    return {
220      id: await $.env.get('SWITCHBOARD_PROFILE'),
221      name,
222      color: (await $.env.get('SWITCHBOARD_PROFILE_COLOR')) ?? '',
223      command: await $.env.get('SWITCHBOARD_COMMAND'),
224    };
225  })();
226  return profile;
227}
228
229async function readSession($: EngineInterface): Promise<void> {
230  try {
231    const s = await $.session.usage();
232    await update($, session, () => ({ context: s.context.percent ?? null, costUsd: s.cost?.usd ?? null }));
233  } catch {
234    // The band keeps the last figures.
235  }
236}
237
238export const register: Register = (on) => {
239  on('session.start', async ($, e, next) => {
240    const started = await next(e);
241    const p = await loadProfile($);
242    if (!p) return started;
243    void readSession($);
244    const id = p.id;
245    const command = p.command;
246    if (!id || !command) return started;
247
248    const refresh = async () => {
249      try {
250        const { exitCode, stdout } = await $.process.run([command, 'usage', id, '--max-age', '15m', '--no-renew'], {
251          timeoutMs: 20000,
252        });
253        if (exitCode !== 0) return;
254        const latest = parseUsage(stdout);
255        if (latest) await update($, usage, () => latest);
256      } catch {
257        // No numbers this round; the band keeps the last ones.
258      }
259    };
260    void refresh();
261    $.clock.every(REFRESH_MS, () => void refresh());
262    return started;
263  });
264
265  // The main conversation's, not a subagent's: the band shows this session.
266  on('turn.complete', async ($, e, next) => {
267    const done = await next(e);
268    if (!e.agentId && (await loadProfile($))) void readSession($);
269    return done;
270  });
271
272  // The session reports the last response's fill until the next one, which a
273  // compaction makes wrong: show none until the next turn reads it again.
274  on('session.compact', async ($, e, next) => {
275    const done = await next(e);
276    if (!e.agentId) await update($, session, (s) => s && { ...s, context: null });
277    return done;
278  });
279
280  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
281    if (e.props.hasSurvey) return next(e);
282    const p = await loadProfile($);
283    if (!p) return next(e);
284
285    const { Box, Text } = $.ui.resolve(e);
286    const label = labelColor(p.color);
287    if (!label) {
288      return (
289        <Text color={p.color || undefined} bold>
290          {`● ${p.name}`}
291        </Text>
292      );
293    }
294
295    const u = await read($, usage);
296    const s = await read($, session);
297    const now = u?.windows.length ? await $.clock.now() : 0;
298    const shown = fitBand(bandFacts(p, u, s, now), e.props.bodyColumns);
299
300    return (
301      <Box width="100%" backgroundColor={p.color} paddingX={1} flexDirection="row" justifyContent="space-between">
302        <Box flexDirection="row">
303          <Text color={label} bold>
304            {p.name}
305          </Text>
306          {shown.detail ? <Text color={label}>{shown.detail}</Text> : null}
307        </Box>
308        <Box flexDirection="row">
309          {shown.limits.map((l, i) => (
310            <Text key={l.key} color={label} bold={l.isBold}>
311              {`${i ? '    ' : ''}${l.text}`}
312            </Text>
313          ))}
314        </Box>
315      </Box>
316    );
317  });
318};
319
types/index.d.ts 13 lines
1// What the band keeps between draws: the profile's plan and rate-limit
2// windows, as `switchboard usage` last reported them, and this session's
3// context fill (a whole percentage) and cost, as the session last reported them.
4export type BandWindow = { label: string; remaining: number; resetsAt: string | null; severity: string | null };
5export type BandUsage = { plan: string | null; windows: BandWindow[] };
6export type BandSession = { context: number | null; costUsd: number | null };
7
8declare module 'claude-code' {
9  interface PluginState {
10    'switchboard-profile-band': { usage: BandUsage | null; session: BandSession | null };
11  }
12}
13