SLOPSHOPPER

ccseats

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

newpanebandcommandtoastmodel
★ 2v0.1.0MITupdated 2026-10-03paddo/ccseats/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccseats
│ ┃ Seats ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ elsewhere c: codex g: grok ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ q: close ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /seats │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Seats
elsewhere c: codex g: grok q: close
README

ccseats

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.

Install

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.

Quick start

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.

Commands

commandwhat it does
ccseats initCreate 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 statusUsage table for every seat. Alias: usage
ccseats pickShow 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 statuslineSeat name and remaining 5h, weekly and model budget, for a statusline script
ccseats relinkRecreate 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.

How it works

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:

  • Directories: projects (transcripts, --resume, auto-memory), plugins, skills, commands, agents, hooks, output-styles, scripts, file-history, plans, paste-cache, backups, downloads, uploads, teams, tasks, feedback, chrome
  • Files: CLAUDE.md, settings.json, settings.local.json, keybindings.json

It 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.

Usage data

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.

The picker

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:

  1. Lowest tier first. A higher tier is used only when every seat in the lower tiers is at or over a threshold.
  2. Inside a tier: most weekly headroom, counting whichever of the all-models week and the model bucket is tighter, then most 5-hour headroom, then fewest live sessions.
  3. A seat is skipped when it is locked, when its 5-hour, weekly or model usage is at or over its threshold, or when its weekly usage is at or over its own maxWeekly.
  4. When every seat is at or over a threshold, the picker takes the one with the lowest 5-hour usage. A locked or capped seat is never picked.

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

~/.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.

Statusline

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)"

Claude Code mod

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.
  • A band above the prompt when this seat is over a picker threshold.
  • Handoff. A digit in the pane resumes this conversation on that seat in a new tab, as ccseats <seat> --resume <id> --fork-session. The seats share projects/, so the transcript is already there.
  • Handoff to Codex or Grok (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.

Claude in Chrome

One Chrome profile per seat keeps every account signed in with no re-pairing.

  1. 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.
  2. In a session on that seat, run /chrome and pick that browser.
  3. 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:

  • Every Chrome window with the extension shows up in every seat's /chrome list. Nothing checks the account at selection time. The pinned id is what keeps seats apart.
  • Windows are called "Browser 1" by default, and two of them look the same in the /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.
  • To move a seat to a different window, run /chrome in a session, pick the new one, and run ccseats pair <seat> again.

Caveats

  • This rests on Claude Code internals that are not documented: where the login is stored, the usage endpoint, and the shape of its response. A Claude Code release can change them. ccseats reports a clear note in the table when the response shape or the stored login no longer match.
  • Developed and used daily on macOS with Claude Code 2.1.278. Linux support is tested in a container with a synthetic login, not yet with a real one. Reports welcome.
  • No native Windows support. Use WSL.
  • Use it for accounts you are entitled to use, such as a personal and a work subscription. Check your plan terms before using the picker to spread one person's work across several subscriptions.

License

MIT

Source 2 files
hooks/register.tsx 306 lines
1import { 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}
306
types/index.d.ts 23 lines
1export 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