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

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.

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.
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.
| App | Signed-in session | Agent home: sessions, plugins, config |
|---|---|---|
| Claude | --user-data-dir=<…>/desktop | CLAUDE_CONFIG_DIR=<…>/home |
| Codex | --user-data-dir=<…>/desktop | CODEX_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.
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.
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.
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.
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.
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.
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.
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.
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./Applications. Other locations are not detected yet.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.Worth knowing before you run an unsigned app that touches your accounts.
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.
/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.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.
claude auth login or codex login inside that profile. This is what enables the usage bars.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.
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.
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.
--user-data-dir flag or the usage endpoints. The Default profiles keep working regardless.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.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.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.
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.
hooks/register.tsx 319 lines1// 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};
319types/index.d.ts 13 lines1// 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