Live usage meters for every ccseats seat in Claude Code, and a one-key handoff of the conversation to the seat with the most headroom, or to Codex or Grok

Run several Claude Code accounts side by side on one machine. macOS and Linux.
Each account is a "seat". Seats stay signed in at the same time, share one set of plugins, skills, settings and session transcripts, and show their remaining quota in one table. An optional picker starts each new session on the seat with the most headroom.
seat tier email 5h 7d fable 5h reset 7d reset live note
───────────────────────────────────────────────────────────────────────────────
main 1 you@example.com 7% 39% 54% Tue 04:30 Fri 18:00 3
work 1 work@example.com 0% 62% 92% - Thu 10:00 0
side 2 side@example.com 0% 0% 0% - Sun 15:00 0
One zsh script. On macOS it needs only what the system ships. On Linux it needs zsh, jq and curl.
curl -fsSL https://raw.githubusercontent.com/paddo/ccseats/main/install.sh | sh
or
npm install -g ccseats
jq ships with macOS 15 and later. Elsewhere install it with brew, apt or dnf. Windows works through WSL.
ccseats init # seat "main" on your existing ~/.claude
ccseats add work --chrome "Profile 1" # creates ~/.claude-work, linked to ~/.claude
ccseats work # starts claude on that seat. Run /login once.
ccseats status
Your existing ~/.claude is not moved or changed. It becomes the first seat and the source of everything the other seats share. Add as many seats as you have accounts, and ccseats remove <seat> --purge takes one away again.
| command | what it does |
|---|---|
ccseats init | Create the config with seat main on ~/.claude |
ccseats add <name> [--chrome "Profile 1"] [--tier N] [--max-weekly N] | Create ~/.claude-<name> and link the shared items |
ccseats remove <seat> [--purge] | Forget a seat. --purge also deletes its directory and stored login |
ccseats status | Usage table for every seat. Alias: usage |
ccseats pick | Show the table and print the seat the picker would choose |
ccseats <seat> [claude args] | Run claude on that seat |
ccseats [claude args] | Run claude on the picked seat when autoPick is on, otherwise on the first seat |
ccseats chrome <seat> | Open the Chrome profile mapped to that seat |
ccseats pair <seat> | Pin the browser the seat is paired with now, labelled with the seat name |
ccseats statusline | Seat name and remaining 5h, weekly and model budget, for a statusline script |
ccseats relink | Recreate the shared symlinks in every seat |
Everything after the seat name goes to claude unchanged: ccseats work --resume.
A short alias is handy: alias cl=ccseats.
A seat is a config directory. Claude Code reads CLAUDE_CONFIG_DIR. When it is set, the account file, settings, transcripts and credentials all live under that directory. On macOS the login goes into the Keychain under Claude Code-credentials-<first 8 hex of sha256(dir)>. On Linux it goes into .credentials.json inside the directory. Either way two directories hold two logins and both work at once. ccseats <seat> sets the variable and runs claude.
Seats share almost everything. ccseats add fills the new directory with symlinks into ~/.claude:
projects (transcripts, --resume, auto-memory), plugins, skills, commands, agents, hooks, output-styles, scripts, file-history, plans, paste-cache, backups, downloads, uploads, teams, tasks, feedback, chromeCLAUDE.md, settings.json, settings.local.json, keybindings.jsonIt also copies your per-project trust and tool approvals into the new seat, so you are not re-prompted for every repository.
What stays per seat, and why:
.claude.json: the signed-in account and the paired Chrome browser.history.jsonl: Claude Code refuses a symlinked or hard-linked history file, so prompt history is per seat.sessions, session-env, security state, telemetry, caches: per process or per account.Because settings.json is shared, a /model or /config change applies to every seat.
Do not run /login inside a seat to switch accounts. It rewrites that directory's stored login and the seat now belongs to the other account. Start a session on the other seat instead.
ccseats status reads the same endpoint the /usage command reads, with each seat's own stored token. The limits array in the response holds the 5-hour session limit, the weekly limit for all models, and any weekly limit scoped to one model.
The endpoint answers 429 with a retry-after of several minutes when polled often. ccseats therefore keeps one cache file per seat, makes at most one request per seat every two minutes, and backs off for the full retry-after on a 429.
A seat that has been idle for hours has an expired access token. ccseats does not refresh tokens itself, because racing Claude Code on a rotating refresh token can sign a seat out. It shows the cached numbers instead. Those stay a safe upper bound, since an idle seat cannot use more quota, and a window whose reset time has passed counts as empty. The next session on that seat refreshes the token.
Off by default. Set "autoPick": true in the config and a bare ccseats starts on the chosen seat and prints the table with the choice marked.
Rules:
tier first. A higher tier is used only when every seat in the lower tiers is at or over a threshold.maxWeekly.tier and maxWeekly are optional. A higher tier means the seat is used later. maxWeekly takes a seat out of the running once its week reaches that percentage.
~/.config/ccseats/config.json
{
"seats": {
"main": { "dir": "~/.claude", "chrome": "Default", "tier": 1 },
"work": { "dir": "~/.claude-work", "chrome": "Profile 1", "tier": 1, "device": "d102d8b0-763b-46a5-b4d9-6412db83484f" },
"side": { "dir": "~/.claude-side", "chrome": "Profile 2", "tier": 2, "maxWeekly": 70 }
},
"model": "Fable",
"autoPick": false,
"thresholds": { "session": 90, "weekly": 90, "model": 85 }
}
device is set by ccseats pair. model is the display name of the model whose weekly bucket the picker also checks, as it appears in /usage. Seat order in the file is the display order.
ccseats statusline prints the seat name and the remaining budget: 5-hour session, weekly across all models, and the weekly limit of the configured model (f for Fable):
work 5h 93% w 61% f 46% left
It never waits on the network. A stale cache starts a background refresh and the previous values show. Call it from your statusline script:
printf " | %s" "$(ccseats statusline)"
The plugin/ folder is a Claude Code mod (Claude Code 2.1.287 or later). It runs inside the session and adds:
/seats: a pane with 5h, weekly and model meters for every seat, and the time to each reset. ● marks this session's seat, ★ the seat the picker would choose.ccseats <seat> --resume <id> --fork-session. The seats share projects/, so the transcript is already there.c, g). Those tools cannot read a Claude transcript, so Claude first writes a handoff brief over its own cached transcript to ~/.cache/ccseats/handoffs/. The other agent starts with that brief.Install it once. The seats share plugins/ and settings.json, so every seat gets it:
claude plugin marketplace add paddo/ccseats
claude plugin install ccseats@ccseats
New tabs open in herdr, tmux, WezTerm and kitty. kitty needs allow_remote_control in kitty.conf. In any other terminal the command goes to the clipboard (pbcopy, wl-copy or xclip), or into the transcript when no clipboard tool exists.
The mod reads only the local usage caches, so it adds no requests to the usage endpoint. The first turn on a new seat is not cached, because the prompt cache belongs to one account.
One Chrome profile per seat keeps every account signed in with no re-pairing.
ccseats chrome <seat> opens the mapped Chrome profile (google-chrome or chromium on Linux). Install the Claude extension in it and sign in to claude.ai with that seat's account./chrome and pick that browser.ccseats pair <seat> saves that browser's id in the config. From then on every launch of the seat writes it back before Claude Code starts, labelled with the seat name, so the pairing cannot drift and /chrome shows work instead of "Browser 1".Things worth knowing:
/chrome list. Nothing checks the account at selection time. The pinned id is what keeps seats apart./chrome list. If you cannot tell them apart, ask Claude in that session to "switch browser and let me pick it in Chrome". That sends a Connect prompt to every window, and the one you accept in is the one that gets paired. Then run ccseats pair./chrome in a session, pick the new one, and run ccseats pair <seat> again.MIT
hooks/register.tsx 306 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Seat, Seats } from '../types'
5
6const PANE = 'ccseats'
7const BAR = 16
8const TRACK = 0x2a2a36
9const EIGHTHS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']
10
11const seats = atom({ plugin: 'ccseats', key: 'seats' } as const, {
12 list: [],
13 thresholds: { session: 90, weekly: 90, model: 85 },
14 model: 'Fable',
15} as Seats)
16const isDismissed = atom({ plugin: 'ccseats', key: 'isDismissed' } as const, false)
17
18type Limit = { kind: string; percent: number; resets_at: string | null; scope: { model?: { display_name?: string } } | null }
19
20const epoch = (iso: string | null) => (iso ? Date.parse(iso) / 1000 : null)
21
22// Mirrors ccseats usage_fields: a window whose reset has passed counts as empty.
23function usage(limit: Limit | undefined, now: number) {
24 if (!limit) return { used: null, reset: null }
25 const reset = epoch(limit.resets_at)
26 if (reset !== null && reset < now) return { used: 0, reset: null }
27 return { used: limit.percent, reset }
28}
29
30function overThreshold(seat: Seat, t: Seats['thresholds']) {
31 return (seat.session ?? 0) >= t.session || (seat.weekly ?? 0) >= t.weekly || (seat.model ?? 0) >= t.model
32}
33
34// Same order as the ccseats picker: lowest tier, then the tighter weekly figure, then 5h.
35function pickTarget({ list, thresholds }: Seats) {
36 return list
37 .filter(s => !s.isCurrent && !s.locked && s.session !== null && !overThreshold(s, thresholds))
38 .filter(s => s.maxWeekly === null || (s.weekly ?? 0) < s.maxWeekly)
39 .sort(
40 (a, b) =>
41 a.tier - b.tier ||
42 Math.max(a.weekly ?? 0, a.model ?? 0) - Math.max(b.weekly ?? 0, b.model ?? 0) ||
43 (a.session ?? 0) - (b.session ?? 0),
44 )[0]
45}
46
47function usedColor(used: number) {
48 return used < 50 ? 0x22c55e : used < 80 ? 0xeab308 : 0xef4444
49}
50
51// Each filled cell takes the colour its own position would have, so a bar shades green to red.
52// Eighth blocks on a track background give the bar sub-cell precision.
53function barCells(used: number | null) {
54 const eighths = Math.round(((used ?? 0) / 100) * BAR * 8)
55 const words: number[] = []
56 for (let i = 0; i < BAR; i++) {
57 const left = eighths - i * 8
58 const char = left >= 8 ? '█' : left > 0 ? EIGHTHS[left - 1]! : ' '
59 words.push(char.codePointAt(0)!, usedColor(((i + 1) / BAR) * 100), TRACK)
60 }
61 return new Uint8Array(Uint32Array.from(words).buffer).toBase64()
62}
63
64function textBar(used: number | null) {
65 const eighths = Math.round(((used ?? 0) / 100) * BAR * 8)
66 const tail = eighths % 8 ? EIGHTHS[(eighths % 8) - 1] : ''
67 return ('█'.repeat(Math.floor(eighths / 8)) + tail).padEnd(BAR, ' ')
68}
69
70function until(reset: number | null, now: number) {
71 if (reset === null) return ''
72 const mins = Math.max(0, Math.round((reset - now) / 60))
73 if (mins >= 1440) return `${Math.floor(mins / 1440)}d${Math.floor((mins % 1440) / 60)}h`
74 return mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${mins}m`
75}
76
77const pct = (n: number | null) => (n === null ? ' -' : `${String(Math.round(n)).padStart(3)}%`)
78const pctColor = (n: number | null) => (!n ? undefined : n < 50 ? 'green' : n < 80 ? 'yellow' : 'red')
79
80async function load($: EngineInterface) {
81 const home = (await $.env.get('HOME')) ?? ''
82 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
83 const expand = (p: string) => p.replace(/^~/, home).replace(/\/$/, '')
84 const configHome = (await $.env.get('XDG_CONFIG_HOME')) || `${home}/.config`
85 const configPath = (await $.env.get('CCSEATS_CONFIG')) || `${configHome}/ccseats/config.json`
86 // Before `ccseats init` there is no config; the meters stay empty and the timer retries.
87 const text = await $.fs.read(configPath).catch(() => null)
88 if (typeof text !== 'string') return
89 const config = JSON.parse(text)
90 const model: string = config.model ?? 'Fable'
91 const now = (await $.clock.now()) / 1000
92 const list: Seat[] = []
93 for (const [name, seat] of Object.entries<{ dir: string; tier?: number; maxWeekly?: number }>(config.seats)) {
94 const dir = expand(seat.dir)
95 let cache: { limits?: Limit[]; five_hour?: { locked_reason: string | null }; seven_day?: { locked_reason: string | null } } = {}
96 try {
97 cache = JSON.parse(await $.fs.read(`${dir}/usage-cache.json`))
98 } catch {}
99 const limits = cache.limits ?? []
100 const session = usage(limits.find(l => l.kind === 'session'), now)
101 const weekly = usage(limits.find(l => l.kind === 'weekly_all'), now)
102 const scoped = usage(limits.find(l => l.kind === 'weekly_scoped' && l.scope?.model?.display_name === model), now)
103 list.push({
104 name,
105 dir,
106 tier: seat.tier ?? 1,
107 maxWeekly: seat.maxWeekly ?? null,
108 session: session.used,
109 weekly: weekly.used,
110 model: scoped.used,
111 resetSession: session.reset,
112 resetWeekly: weekly.reset,
113 locked: Boolean(cache.five_hour?.locked_reason || cache.seven_day?.locked_reason),
114 isCurrent: dir === expand(configDir),
115 })
116 }
117 const thresholds = { session: 90, weekly: 90, model: 85, ...config.thresholds }
118 await update($, seats, () => ({ list, thresholds, model }))
119 // A dismissal lasts for one low spell: once this seat is back under every threshold, the band may show again.
120 const current = list.find(s => s.isCurrent)
121 if (current && !overThreshold(current, thresholds)) {
122 await update($, isDismissed, () => false)
123 }
124}
125
126const AGENTS = [
127 { name: 'codex', hotkey: 'c' },
128 { name: 'grok', hotkey: 'g' },
129] as const
130
131const BRIEF_PROMPT = `Write a handoff brief for another coding agent that takes over this work in the same directory. It cannot see this conversation. Cover: the goal, what is done, the files touched (paths), decisions and constraints the user set, open problems, and the exact next steps. Markdown. Output only the brief.`
132
133// Opens a tab in the first terminal multiplexer this session runs inside. WezTerm and kitty run the
134// command in a login shell so ~/.local/bin, where ccseats installs, is on PATH.
135async function openTab($: EngineInterface, label: string, command: string) {
136 const cwd = await $.session.cwd()
137 const shell = (await $.env.get('SHELL')) ?? 'sh'
138 const workspace = await $.env.get('HERDR_WORKSPACE_ID')
139 if (workspace) {
140 const created = await $.process.run(['herdr', 'tab', 'create', '--workspace', workspace, '--cwd', cwd, '--label', label, '--focus'])
141 const pane = JSON.parse(created.stdout).result.root_pane.pane_id
142 return (await $.process.run(['herdr', 'pane', 'run', pane, command])).exitCode === 0
143 }
144 if (await $.env.get('TMUX')) {
145 return (await $.process.run(['tmux', 'new-window', '-c', cwd, '-n', label, command])).exitCode === 0
146 }
147 if (await $.env.get('WEZTERM_PANE')) {
148 return (await $.process.run(['wezterm', 'cli', 'spawn', '--cwd', cwd, '--', shell, '-lc', command])).exitCode === 0
149 }
150 // kitty refuses unless the user's kitty.conf sets allow_remote_control; a refusal falls through to the clipboard.
151 if (await $.env.get('KITTY_WINDOW_ID')) {
152 return (await $.process.run(['kitty', '@', 'launch', '--type=tab', `--cwd=${cwd}`, `--tab-title=${label}`, shell, '-lc', command])).exitCode === 0
153 }
154 return false
155}
156
157const CLIPBOARDS = [['pbcopy'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]
158
159// Quotes one argument for the shell the terminal runs the command in; plain words stay bare so the command reads cleanly.
160const shq = (arg: string) => (/^[\w./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
161
162async function launch($: EngineInterface, label: string, command: string) {
163 // A multiplexer CLI missing from PATH, or a herdr error, falls through to the clipboard too.
164 if (await openTab($, label, command).catch(() => false)) {
165 $.ui.toast(`Handed off to ${label} in a new tab. Exit this one when ready.`)
166 return
167 }
168 for (const argv of CLIPBOARDS) {
169 try {
170 if ((await $.process.run(argv, { stdin: command })).exitCode === 0) {
171 $.ui.toast(`Copied: ${command}`)
172 return
173 }
174 } catch {}
175 }
176 // A toast fades, so the command goes in the transcript on a line of its own for a clean copy.
177 $.ui.log('ccseats: no clipboard tool found. Run this in a new terminal:')
178 $.ui.log(command)
179 $.ui.toast('Handoff command is in the transcript')
180}
181
182// Resumes this conversation on another seat. The seats share projects/, so the transcript is
183// there; --fork-session gives the new seat its own session id so two processes never write one file.
184async function handoff($: EngineInterface, seat: Seat) {
185 await $.ui.close({ id: PANE })
186 await update($, isDismissed, () => true)
187 await launch($, seat.name, `ccseats ${shq(seat.name)} --resume ${shq(await $.session.id())} --fork-session`)
188}
189
190// Other CLIs cannot read a Claude transcript, so the model writes a brief over its own cached
191// transcript. The brief lives outside the repo so the handoff leaves git status clean.
192async function handoffAgent($: EngineInterface, agent: string) {
193 // Pane closed and band dismissed before the brief is written, so a second press cannot start a second brief.
194 await $.ui.close({ id: PANE })
195 await update($, isDismissed, () => true)
196 $.ui.toast(`Writing the handoff brief for ${agent}…`)
197 const brief = await $.model.fork({ prompt: BRIEF_PROMPT })
198 if (!brief.isAnswered) {
199 $.ui.toast(`No brief: ${brief.reason}`)
200 return
201 }
202 const path = `${await $.env.get('HOME')}/.cache/ccseats/handoffs/${await $.session.id()}.md`
203 await $.fs.write(path, brief.text)
204 await launch($, agent, `${agent} ${shq(`Read ${path} and continue the work it describes.`)}`)
205}
206
207export const register: Register = on => {
208 on('session.start', async ($, e, next) => {
209 await $.command.register({ name: 'seats', description: 'Usage meters for every ccseats seat, with handoff' })
210 // Reads local cache files only; the ccseats statusline already refreshes them from the API.
211 $.clock.every(30_000, () => load($))
212 await load($)
213 return next(e)
214 })
215
216 on('turn.complete', async ($, e, next) => {
217 await load($)
218 return next(e)
219 })
220
221 on('command.run', { command: 'seats' }, async $ => {
222 await load($)
223 await $.ui.open({ id: PANE, title: 'Seats', focus: true, closeOnEscape: true, columns: 44 })
224 return {}
225 })
226
227 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
228 const ui = $.ui.resolve(e)
229 const { Box, Text, Button } = ui
230 const state = await read($, seats)
231 const now = (await $.clock.now()) / 1000
232 const target = pickTarget(state)
233
234 const label = state.model.toLowerCase()
235 const meter = (seat: Seat, name: string, used: number | null, reset: number | null) => (
236 <Box flexDirection="row" columnGap={1} paddingLeft={2}>
237 <Text dimColor>{name.padEnd(5)}</Text>
238 {'Raster' in ui ? (
239 <ui.Raster key={`${seat.name}-${name}`} columns={BAR} rows={1} cells={barCells(used)} />
240 ) : (
241 <Text color={pctColor(used)}>{textBar(used)}</Text>
242 )}
243 <Text color={pctColor(used)} dimColor={!used}>
244 {pct(used)}
245 </Text>
246 <Text dimColor>{until(reset, now)}</Text>
247 </Box>
248 )
249
250 return (
251 <Box flexDirection="column" rowGap={1} paddingLeft={1} paddingTop={1}>
252 {state.list.map((seat, i) => (
253 <Box flexDirection="column">
254 <Box flexDirection="row" columnGap={1}>
255 <Text bold color={seat.isCurrent ? 'magenta' : undefined}>
256 {seat.isCurrent ? '●' : '○'} {seat.name}
257 </Text>
258 <Text dimColor>tier {seat.tier}</Text>
259 {seat.locked && <Text color="red">locked</Text>}
260 {seat === target && <Text color="cyan">★</Text>}
261 {seat.isCurrent && <Text dimColor>this session</Text>}
262 {!seat.isCurrent && !seat.locked && (
263 <Button key={`go-${seat.name}`} label="hand off" hotkey={i < 9 ? String(i + 1) : undefined} plain onPress={() => handoff($, seat)} />
264 )}
265 </Box>
266 {meter(seat, '5h', seat.session, seat.resetSession)}
267 {meter(seat, '7d', seat.weekly, seat.resetWeekly)}
268 {seat.model !== null && meter(seat, label, seat.model, null)}
269 </Box>
270 ))}
271 <Box flexDirection="row" columnGap={2}>
272 <Text dimColor>elsewhere</Text>
273 {AGENTS.map(a => (
274 <Button key={`agent-${a.name}`} label={a.name} hotkey={a.hotkey} plain onPress={() => handoffAgent($, a.name)} />
275 ))}
276 </Box>
277 <Button key="close" label="close" hotkey="q" plain onPress={() => $.ui.close({ id: PANE })} />
278 </Box>
279 )
280 })
281
282 // Shows only while this seat is over a picker threshold. With every seat low, only the other agents are offered.
283 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284 const state = await read($, seats)
285 const current = state.list.find(s => s.isCurrent)
286 const target = pickTarget(state)
287 const label = state.model.toLowerCase()
288 if (!current || !overThreshold(current, state.thresholds) || (await read($, isDismissed))) return next(e)
289 const { Box, Text, Button } = $.ui.resolve(e)
290 return (
291 <Box flexDirection="row" columnGap={2}>
292 <Text color="yellow">
293 {current.name} is low: 5h {pct(current.session).trim()}, 7d {pct(current.weekly).trim()}
294 {current.model !== null ? `, ${label} ${pct(current.model).trim()}` : ''} used.
295 {target ? ` ${target.name} has 5h ${pct(target.session).trim()}, 7d ${pct(target.weekly).trim()}.` : ' No seat has room.'}
296 </Text>
297 {target && <Button key="handoff" label={`hand off to ${target.name}`} plain onPress={() => handoff($, target)} />}
298 {AGENTS.map(a => (
299 <Button key={`agent-${a.name}`} label={a.name} hotkey={a.hotkey} plain onPress={() => handoffAgent($, a.name)} />
300 ))}
301 <Button key="dismiss" label="dismiss" hotkey="x" plain onPress={() => update($, isDismissed, () => true)} />
302 </Box>
303 )
304 })
305}
306types/index.d.ts 23 lines1export type Seat = {
2 name: string
3 dir: string
4 tier: number
5 maxWeekly: number | null
6 session: number | null
7 weekly: number | null
8 model: number | null
9 resetSession: number | null
10 resetWeekly: number | null
11 locked: boolean
12 isCurrent: boolean
13}
14
15export type Seats = { list: Seat[]; thresholds: { session: number; weekly: number; model: number }; model: string }
16
17declare module 'claude-code' {
18 interface PluginState {
19 ccseats: { seats: Seats; isDismissed: boolean }
20 }
21}
22
23