SLOPSHOPPER

multi-account

Run each project on its own Claude account: shows the account, cost and limits above the prompt, switches the account per folder, and hands the next session…

newbandcommandtoaststatusprompt
v0.2.2MITupdated 2026-10-26roych28/billable/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · multi-account
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /account ⎿ multi-account: Account: default (not signed in) ⎿ multi-account: Other accounts: none signed in ⎿ multi-account: Session cost $0.42 · 5-hour limit 31% ⎿ multi-account: Switch with /account <name>, or /account auto for the default. default not signed in Session cost $0.42 · 5-hour limit 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
default not signed in Session cost $0.42 · 5-hour limit 31%
README

Billable

Claude Code accounts and cost per client: run every project on the right Claude account, and always know what it costs.

Formerly Claude Multi-Account. Not affiliated with Anthropic; Claude and Claude Code are trademarks of Anthropic.

Freelancers and consultants often hold a Claude account per client, plus a personal one. Claude Code signs in once per config directory, so it is easy to spend a client's quota on your own project, or lose track of what a session costs. Billable puts the account, the usage and the switch where you can see them.

Switching a folder's Claude account from the status bar, then the cost report by account

Status bar

Features

  • The account in the status bar. The account this folder runs on, the session's tokens and cost, and the highest rate-limit usage. The item turns amber at 90% of a limit.
  • Switch in one click. Hover the item: the account and its email come first, then a button for every other account. The choice is saved per folder, so each project keeps its own account and you can work on several at once.
  • Add accounts from VS Code. Add account creates a config directory for the new account, installs the companion plugin into it, and opens a terminal to sign in. An account you added earlier, or by hand, gets the plugin the next time VS Code starts, once the plugin runs on any of your accounts. An account where you turned the plugin off is left alone.
  • Pick up where you stopped. After a switch, start a new conversation right where you are: it receives the last request, the last answer and the path of the previous transcript, so the work continues on the new account without repeating yourself. A new conversation on the same account starts fresh.
  • Sign in where it belongs. Signing in prints a link and waits for a code, so you open it in the browser signed in to that account (or a private window), not the default one. Switching to an account that is not signed in offers the sign-in first.
  • Real token counts. Tokens are read from the session transcript: input, output, cache reads and cache writes, for the whole session, resumed sessions included.
  • Cost report by account. What each account, and each folder under it, cost in a month. One account per client means one line per client on your invoice. Open it from the hover or with Billable: Cost report by account.
  • Share memory between accounts, or keep them apart. Each account is its own config directory, so by default it has its own conversations, memory and settings. Share conversations, memory and settings with the default account links an account to the default one: conversations and memory, or everything but the sign-in. Each account keeps its own sign-in, plan and limits.
  • Idle sessions. Every Claude Code session you leave open keeps a few hundred MB of RAM. The hover shows how many sessions are running, how many are idle, and the RAM they hold together. Stop idle sessions stops the ones you pick and gives you the command to resume each one, and a setting can stop long-idle sessions on its own.

Idle sessions

Claude Code sessions stay in memory until you close them, and a dozen forgotten ones can hold several GB. Billable reads the session files Claude Code keeps in each account's config directory (sessions/<pid>.json) and the process list.

  • In the hover: Claude sessions: 12 running · 8 idle · 3.5 GB RAM, with a Stop idle sessions button.
  • Billable: Stop idle sessions lists the idle sessions with their folder, how long they have been quiet and their RAM. Sessions quiet longer than the threshold are already selected (the auto-stop setting, or 6 hours when it is off). The chosen ones get SIGTERM, and you get cd <folder> && claude --resume <session id> for each, with a copy action. The conversation itself stays on disk.
  • Automatic: set claudeMultiAccount.idleSessions.autoStopHours to a number of hours (default 0, off). Every 30 minutes, sessions idle longer than that are stopped, and a notification lists them with their resume commands.

A session is stopped only when all of these hold, checked again right before the signal:

  • Claude Code marks it idle. A busy session is never stopped, not even when you pick it.
  • Its transcript has not been written for the threshold (when no transcript is found, the process must be that old), and the process is at least that old too.
  • The process is alive and is a Claude Code binary, started no later than the session it claims (so a reused pid is left alone).
  • It uses under 2.5% CPU over a 5-second sample.

It covers the accounts Billable knows (the accounts file, or ~/.claude), on macOS and Linux.

Sharing between accounts

Claude Code keeps conversations, memory and settings in the account's config directory, so switching a folder to another account starts it without the memory it built up. Billable: Share conversations, memory and settings with the default account (or Change on the sharing line of the hover) offers three levels per account:

  • Nothing (the default): the account has its own of everything. Best for a client you keep apart, since what an account shares goes into the context of its conversations.
  • Conversations and memory: projects (transcripts and each project's memory), file-history, todos, history.jsonl and your CLAUDE.md.
  • Everything but the sign-in: also settings.json, keybindings.json, plugins, commands, agents, skills and output-styles.

Each shared item becomes a link to the default account's copy. What the account had is merged in where the default account lacks it, and the rest is moved to a billable-backup-<time> folder in the account's directory. .claude.json and the keychain entry are never shared. Sharing less copies the settings back, and leaves the conversations with the default account. The choice is kept in ~/.claude-multi-account/sync and applied again when VS Code starts, in case Claude Code replaced a link with a file of its own.

Pro

Pro adds the full history of the cost report, beyond the current and the previous month, and CSV export for invoices. Every Pro feature is free during the launch. When Pro goes on sale, Billable: Enter Pro license key unlocks it on your machine.

Getting started

  1. Install this extension and the companion Claude Code plugin. The extension offers to install the plugin the first time it starts; to do it yourself, at the prompt of a Claude Code session: `` /plugin install multi-account --marketplace roych28/claude-multi-account ` or from a shell: ` claude plugin install multi-account --marketplace roych28/claude-multi-account ``
  2. When the extension asks, let it start Claude Code tabs on each folder's account. It sets claudeCode.claudeProcessWrapper to a small launcher in ~/.claude-multi-account/bin.
  3. Run Billable: Add account for each client and sign in. The plugin is installed into each new account for you.
  4. Open a project, click the account in the status bar, and pick the account it should run on.

How it works

Every Claude account lives in its own config directory, which Claude Code reads from CLAUDE_CONFIG_DIR. Two plain-text files in ~/.claude-multi-account hold the setup:

  • accounts: one account per line, name<TAB>config dir
  • folders: one rule per line, folder<TAB>account. The longest matching folder wins, and a * line is the default.
  • sync: one line per account that shares with the default one, account<TAB>memory|all.

The launcher reads these files and starts Claude Code on the right account. The companion plugin reports the account, cost and limits of each session, records each session's cost in ~/.claude-multi-account/ledger, and writes the handoff note the next session reads. The cost is Claude Code's own figure at API prices; on a Pro or Max plan it measures the work done, not what the plan charged.

In the terminal

The same rules work for claude in any terminal. Add this to ~/.zshrc or ~/.bashrc:

claude() { ~/.claude-multi-account/bin/cma exec claude "$@"; }

On Windows, add this to your PowerShell profile (notepad $PROFILE):

function claude { & "$HOME\.claude-multi-account\bin\cma.exe" exec claude.exe @args }

Inside a session, /account shows the account, cost and limits, and /account <name> switches the folder. The plugin also draws the account and its switch buttons above the prompt.

Testing

  • cd vscode && npm test: unit tests for the rules, tokens, cost report and license.
  • cd vscode && npm run test:integration: runs the extension inside a real VS Code against a throwaway setup.
  • claude plugin test ./plugin: the Claude Code plugin's tests.
  • scripts/check-accounts: a live check on the accounts of this machine. For each signed-in account it runs one short session through the launcher and checks that it ran on that account, was reported by the plugin and landed in the cost ledger. Then it moves a folder between two accounts and checks the second one picks up where the first stopped. It uses a temporary setup, never your own rules, and costs a few cents per account.

Repository layout

  • plugin/: the Claude Code plugin multi-account
  • vscode/: the VS Code extension, with the launcher in vscode/bin
  • jetbrains/: the same extension for IntelliJ IDEA, PyCharm, WebStorm and the other JetBrains IDEs (./gradlew buildPlugin)

Commands

  • Billable: Switch account for this folder
  • Billable: Cost report by account
  • Billable: Add account
  • Billable: Sign in to an account again
  • Billable: Remove account
  • Billable: Share conversations, memory and settings with the default account
  • Billable: Stop idle sessions
  • Billable: Start Claude Code tabs on the folder's account
  • Billable: Refresh
  • Billable: Enter Pro license key

Settings

  • claudeMultiAccount.idleSessions.autoStopHours: stop sessions idle longer than this many hours, checked every 30 minutes. 0 (the default) turns it off; any other value counts as at least 1 hour.

Requirements

macOS, Linux or Windows, with Claude Code installed. The launcher is a shell script on macOS and Linux, and a small .exe on Windows (built from launcher/, Go), because the Claude Code extension starts its wrapper without a shell there.

Privacy

Everything stays on your machine. The extension reads Claude Code's local config, session files and transcripts, lists processes with ps, and writes only to ~/.claude-multi-account. It signals a Claude Code process only when you stop a session, or when you turn on automatic stopping. The one network call is to the license server, with your key, when you enter a Pro license key.

Support

Found a bug or want a feature? Open an issue. If the extension saves you time, you can sponsor its development.

License

MIT. Not affiliated with Anthropic. Made by Roy Chemo at Kashuv.

Source 2 files
hooks/register.tsx 474 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ClaudeMultiAccountAccount, ClaudeMultiAccountInfo, ClaudeMultiAccountUsage } from '../types'
5
6const info = atom({ plugin: 'multi-account', key: 'info' } as const, null)
7const usage = atom({ plugin: 'multi-account', key: 'usage' } as const, { limits: [] })
8const nextAccount = atom({ plugin: 'multi-account', key: 'next' } as const, null)
9const handoff = atom({ plugin: 'multi-account', key: 'handoff' } as const, null)
10const run = atom({ plugin: 'multi-account', key: 'run' } as const, null)
11
12const HANDOFF_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000
13// How often the folder's rule and the other accounts are read again: both change outside this session.
14const REFRESH_MS = 5_000
15const NOT_SIGNED_IN = 'not signed in'
16const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit' }
17
18type Handoff = {
19  sessionId: string
20  account: string
21  at: number
22  transcript: string
23  lastRequest: string
24  lastAnswer: string
25  summary?: string
26}
27
28type Rule = { path: string; account: string }
29
30async function home($: EngineInterface): Promise<string> {
31  return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
32}
33
34async function base($: EngineInterface): Promise<string> {
35  return (await $.env.get('CLAUDE_MULTI_ACCOUNT_HOME')) || `${await home($)}/.claude-multi-account`
36}
37
38function expand(p: string, h: string): string {
39  return (p === '~' || p.startsWith('~/') ? h + p.slice(1) : p).replace(/\/+$/, '')
40}
41
42async function readLines($: EngineInterface, path: string): Promise<string[][]> {
43  try {
44    return (await $.fs.read(path))
45      .split('\n')
46      .map(l => l.trim())
47      .filter(l => l !== '' && !l.startsWith('#'))
48      .map(l => l.split('\t').map(f => f.trim()))
49  } catch {
50    return []
51  }
52}
53
54async function emailOf($: EngineInterface, dir: string, h: string): Promise<string> {
55  const file = pathKey(dir) === pathKey(`${h}/.claude`) ? `${h}/.claude.json` : `${dir}/.claude.json`
56  try {
57    const parsed = JSON.parse(await $.fs.read(file)) as { oauthAccount?: { emailAddress?: string } }
58    return parsed.oauthAccount?.emailAddress ?? NOT_SIGNED_IN
59  } catch {
60    return NOT_SIGNED_IN
61  }
62}
63
64async function accounts($: EngineInterface): Promise<ClaudeMultiAccountAccount[]> {
65  const h = await home($)
66  const rows = await readLines($, `${await base($)}/accounts`)
67  const list = rows.filter(r => r[0] && r[1]).map(r => ({ name: r[0]!, dir: expand(r[1]!, h) }))
68  const all = list.length > 0 ? list : [{ name: 'default', dir: `${h}/.claude` }]
69  const out: ClaudeMultiAccountAccount[] = []
70  for (const a of all) out.push({ ...a, email: await emailOf($, a.dir, h) })
71  return out
72}
73
74async function rules($: EngineInterface): Promise<Rule[]> {
75  const h = await home($)
76  const rows = await readLines($, `${await base($)}/folders`)
77  return rows.filter(r => r[0] && r[1]).map(r => ({ path: r[0] === '*' ? '*' : expand(r[0]!, h), account: r[1]! }))
78}
79
80// How two paths are compared: forward slashes, no trailing slash, and case-insensitive for Windows drive paths.
81export function pathKey(p: string): string {
82  const k = p.replace(/\\/g, '/').replace(/\/+$/, '')
83  return /^[a-zA-Z]:\//.test(k) ? k.toLowerCase() : k
84}
85
86export function accountFor(folder: string, list: Rule[]): string | null {
87  const f = pathKey(folder)
88  let best: Rule | null = null
89  let bestLen = -1
90  for (const r of list) {
91    if (r.path === '*') {
92      if (bestLen < 0) {
93        best = r
94        bestLen = 0
95      }
96      continue
97    }
98    const p = pathKey(r.path)
99    if ((f === p || f.startsWith(`${p}/`)) && p.length > bestLen) {
100      best = r
101      bestLen = p.length
102    }
103  }
104  return best?.account ?? null
105}
106
107// The folder a switch pins: the repository root, unless a rule deeper than it already covers this session's folder
108// (the VS Code status bar pins the workspace folder, which can sit inside a repository). Pinning the root then
109// would change nothing, since the longest matching rule wins.
110export function pinTarget(root: string, cwd: string, list: Rule[]): string {
111  const r = pathKey(root)
112  const c = pathKey(cwd)
113  let best = root
114  for (const rule of list) {
115    if (rule.path === '*') continue
116    const p = pathKey(rule.path)
117    const coversCwd = c === p || c.startsWith(`${p}/`)
118    if (coversCwd && p.startsWith(`${r}/`) && p.length > pathKey(best).length) best = rule.path
119  }
120  return best
121}
122
123async function folderRoot($: EngineInterface, cwd: string): Promise<string> {
124  try {
125    const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
126    return ran.exitCode === 0 && ran.stdout.trim() ? ran.stdout.trim() : cwd
127  } catch {
128    return cwd
129  }
130}
131
132function keyOf(path: string): string {
133  return path.replace(/[^a-zA-Z0-9]/g, '-')
134}
135
136async function loadInfo($: EngineInterface, cwd: string): Promise<ClaudeMultiAccountInfo> {
137  const h = await home($)
138  const list = await accounts($)
139  const dir = expand((await $.env.get('CLAUDE_CONFIG_DIR')) || `${h}/.claude`, h)
140  const current = list.find(a => pathKey(a.dir) === pathKey(dir)) ?? {
141    name: dir.split('/').pop() ?? dir,
142    dir,
143    email: await emailOf($, dir, h),
144  }
145  const sessionId = await $.session.id()
146  return {
147    current,
148    others: list.filter(a => pathKey(a.dir) !== pathKey(current.dir) && a.email !== NOT_SIGNED_IN),
149    folder: await folderRoot($, cwd),
150    transcript: `${dir}/projects/${keyOf(cwd)}/${sessionId}.jsonl`,
151  }
152}
153
154function clip(text: string, max: number): string {
155  return text.length > max ? `${text.slice(0, max)} [...]` : text
156}
157
158async function handoffPath($: EngineInterface, folder: string): Promise<string> {
159  return `${await base($)}/handoff/${keyOf(folder)}.json`
160}
161
162async function readHandoff($: EngineInterface, folder: string): Promise<Handoff | null> {
163  try {
164    return JSON.parse(await $.fs.read(await handoffPath($, folder))) as Handoff
165  } catch {
166    return null
167  }
168}
169
170async function writeHandoff($: EngineInterface, summary?: string): Promise<void> {
171  const i = await read($, info)
172  if (!i) return
173  const sessionId = await $.session.id()
174  const messages = await $.session.messages()
175  const lastRequest = [...messages].reverse().find(m => m.role === 'user' && m.text.trim() !== '')
176  const lastAnswer = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim() !== '')
177  const previous = await readHandoff($, i.folder)
178  const keptSummary = summary ?? (previous?.sessionId === sessionId ? previous.summary : undefined)
179  const note: Handoff = {
180    sessionId,
181    account: i.current.name,
182    at: await $.clock.now(),
183    transcript: i.transcript,
184    lastRequest: clip(lastRequest?.text ?? '', 2000),
185    lastAnswer: clip(lastAnswer?.text ?? '', 3000),
186    ...(keptSummary ? { summary: keptSummary } : {}),
187  }
188  await $.fs.write(await handoffPath($, i.folder), JSON.stringify(note, null, 2))
189}
190
191async function writeStatus($: EngineInterface): Promise<void> {
192  const i = await read($, info)
193  if (!i) return
194  const sessionId = await $.session.id()
195  const status = {
196    sessionId,
197    cwd: await $.session.cwd(),
198    folder: i.folder,
199    transcript: i.transcript,
200    account: { name: i.current.name, email: i.current.email },
201    others: i.others.map(o => ({ name: o.name, email: o.email })),
202    usage: await read($, usage),
203    next: await read($, nextAccount),
204    updatedAt: await $.clock.now(),
205  }
206  await $.fs.write(`${await base($)}/sessions/${sessionId}.json`, JSON.stringify(status))
207  await writeLedger($, sessionId, i, status.usage, status.updatedAt)
208}
209
210// One ledger entry per run of a session, so a session resumed on another account is billed to each account
211// for its own part. The cost counts from the run's start, whether or not Claude Code restores it on resume.
212async function writeLedger(
213  $: EngineInterface,
214  sessionId: string,
215  i: ClaudeMultiAccountInfo,
216  u: ClaudeMultiAccountUsage,
217  now: number,
218): Promise<void> {
219  const r = await read($, run)
220  if (!r || u.usd === undefined) return
221  const entry = {
222    sessionId,
223    account: i.current.name,
224    email: i.current.email,
225    folder: i.folder,
226    startedAt: r.startedAt,
227    updatedAt: now,
228    usd: Math.max(0, u.usd - r.startUsd),
229  }
230  await $.fs.write(`${await base($)}/ledger/${sessionId}-${r.startedAt}.json`, JSON.stringify(entry))
231}
232
233function handoffPrompt(note: Handoff): string {
234  return [
235    '# Where the previous session in this folder stopped',
236    `Session ${note.sessionId} on the ${note.account} account, last active ${new Date(note.at).toISOString()}.`,
237    'If the person continues earlier work, pick up from here without asking them to repeat it; if they start something new, ignore this.',
238    `Full transcript (JSONL, read its tail for more detail): ${note.transcript}`,
239    '',
240    'Last request from the person:',
241    note.lastRequest || '(none)',
242    '',
243    'Last answer:',
244    note.lastAnswer || '(none)',
245    ...(note.summary ? ['', 'Handoff summary written when the account was switched:', note.summary] : []),
246  ].join('\n')
247}
248
249const SUMMARY_PROMPT =
250  'The person is moving this work to another Claude account; a fresh session will continue from a note. ' +
251  'Write that handoff note in the language of the conversation, under 300 words: the goal, what is done ' +
252  '(with full file paths and commits), what is in progress, the exact next step, and open questions. ' +
253  'No secrets, keys or tokens. Reply with the note only.'
254
255async function pin($: EngineInterface, folder: string, name: string): Promise<void> {
256  const path = `${await base($)}/folders`
257  let lines: string[] = []
258  try {
259    lines = (await $.fs.read(path)).split('\n').filter(l => l.trim() !== '')
260  } catch {}
261  const h = await home($)
262  const kept = lines.filter(l => {
263    const first = l.split('\t')[0]?.trim() ?? ''
264    return l.trim().startsWith('#') || first === '*' || pathKey(expand(first, h)) !== pathKey(folder)
265  })
266  if (name !== 'auto') kept.push(`${folder}\t${name}`)
267  await $.fs.write(path, kept.join('\n') + '\n')
268}
269
270async function switchTo($: EngineInterface, name: string): Promise<string> {
271  const i = await read($, info)
272  if (!i) return 'Not ready yet.'
273  const list = await accounts($)
274  if (name !== 'auto' && !list.some(a => a.name === name)) {
275    return `Unknown account: ${name}. Known: ${list.map(a => a.name).join(', ')}, or auto.`
276  }
277  // The launcher picks the account from the session's own folder, so that is what the switch has to reach.
278  const cwd = await $.session.cwd()
279  const target = pinTarget(i.folder, cwd, await rules($))
280  await pin($, target, name)
281  const next = accountFor(cwd, await rules($))
282  await update($, nextAccount, () => next)
283
284  const fork = await $.model.fork({ prompt: SUMMARY_PROMPT })
285  let note = ''
286  if (fork.isAnswered) {
287    await writeHandoff($, fork.text.trim())
288  } else {
289    await writeHandoff($)
290    note = `\nNo handoff summary (${fork.reason}): the next session gets the last request, the last answer and the transcript path.`
291    $.ui.toast(`Handoff summary not written (${fork.reason})`)
292  }
293  await writeStatus($)
294  return (
295    `${target} now runs on ${next ?? 'the default account'}.${note}\n` +
296    'To continue from here: open a new Claude tab, or exit and run claude --continue in this folder.'
297  )
298}
299
300function usageLine(u: ClaudeMultiAccountUsage): string {
301  const cost = `Session cost ${u.usd === undefined ? 'unknown' : `$${u.usd.toFixed(2)}`}`
302  return [cost, ...u.limits.map(l => `${LIMIT_NAMES[l.kind] ?? l.kind} ${l.percentUsed}%`)].join('  ·  ')
303}
304
305function statusText(i: ClaudeMultiAccountInfo, u: ClaudeMultiAccountUsage, next: string | null): string {
306  const lines = [
307    `Account: ${i.current.name} (${i.current.email})`,
308    `Other accounts: ${i.others.map(o => `${o.name} (${o.email})`).join(', ') || 'none signed in'}`,
309    usageLine(u),
310  ]
311  if (next && next !== i.current.name) lines.push(`The next session in ${i.folder} runs on ${next}.`)
312  lines.push('Switch with /account <name>, or /account auto for the default.')
313  return lines.join('\n')
314}
315
316// A running session keeps its account. After the folder is switched (from the VS Code status bar, /account or
317// another session) the line says where a new conversation runs, so it never reads as if nothing changed.
318function vscodeStatus(i: ClaudeMultiAccountInfo, next: string | null): string {
319  const line = `Claude account: ${i.current.name} (${i.current.email})`
320  return next && next !== i.current.name ? `${line}. New conversations in this folder run on ${next}.` : line
321}
322
323async function refresh($: EngineInterface): Promise<void> {
324  const i = await read($, info)
325  if (!i) return
326  const list = await accounts($)
327  const current = list.find(a => pathKey(a.dir) === pathKey(i.current.dir)) ?? i.current
328  const others = list.filter(a => pathKey(a.dir) !== pathKey(current.dir) && a.email !== NOT_SIGNED_IN)
329  await update($, info, () => ({ ...i, current, others }))
330  const ruled = accountFor(await $.session.cwd(), await rules($))
331  await update($, nextAccount, () => ruled)
332}
333
334let timer: Timer | undefined
335// The status line as last set, so an unchanged one is not set again every few seconds.
336let shown: string | undefined
337
338async function refreshStatus($: EngineInterface): Promise<void> {
339  await refresh($)
340  const i = await read($, info)
341  if (!i || (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) !== 'claude-vscode') return
342  const text = vscodeStatus(i, await read($, nextAccount))
343  if (text === shown) return
344  shown = text
345  $.ui.status(text)
346}
347
348export const register: Register = on => {
349  on('session.start', async ($, e, next) => {
350    const loaded = await loadInfo($, e.cwd)
351    await update($, info, () => loaded)
352    await update($, nextAccount, () => null)
353    shown = undefined
354    timer?.cancel()
355    timer = $.clock.every(REFRESH_MS, () => {
356      refreshStatus($).catch(() => {})
357    })
358
359    const now = await $.session.usage()
360    await update($, usage, () => ({
361      usd: now.cost?.usd,
362      limits: now.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed })),
363    }))
364    const startedAt = await $.clock.now()
365    await update($, run, () => ({ startedAt, startUsd: now.cost?.usd ?? 0 }))
366
367    const names = (await accounts($)).map(a => a.name)
368    await $.command.register({
369      name: 'account',
370      description: 'Show the Claude account, cost and limits; /account <name> switches this folder',
371      argumentHint: `[${[...names, 'auto'].join('|')}]`,
372    })
373
374    const isFresh = (await $.session.messages()).length === 0
375    const note = await readHandoff($, loaded.folder)
376    const sessionId = await $.session.id()
377    const isRecent = note !== null && (await $.clock.now()) - note.at < HANDOFF_MAX_AGE_MS
378    // Only across a switch: on the same account a new conversation is a fresh start, and the old one is a resume away.
379    const isSwitch = note !== null && note.account !== loaded.current.name
380    if (isFresh && note && isRecent && isSwitch && note.sessionId !== sessionId) {
381      await update($, handoff, () => handoffPrompt(note))
382    }
383    await writeStatus($)
384
385    if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-vscode') {
386      const text = `Claude account: ${loaded.current.name} (${loaded.current.email})`
387      shown = text
388      $.ui.status(text)
389      try {
390        await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
391      } catch (err) {
392        $.ui.toast(`${text}. The notice could not be added: ${String(err)}`)
393      }
394    }
395
396    return next(e)
397  })
398
399  on('prompt.submit', async ($, e, next) => {
400    if (e.command || e.isMeta) return next(e)
401    const text = await read($, handoff)
402    if (!text) return next(e)
403    await update($, handoff, () => null)
404    return next({ ...e, context: [...(e.context ?? []), text] })
405  })
406
407  on('session.measure', async ($, e, next) => {
408    await update($, usage, () => ({
409      usd: e.cost?.usd,
410      limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed })),
411    }))
412    await writeStatus($)
413    return next(e)
414  })
415
416  on('turn.complete', async ($, e, next) => {
417    const done = await next(e)
418    if (e.agentId === undefined) {
419      try {
420        await writeHandoff($)
421      } catch (err) {
422        $.ui.toast(`Handoff note not saved: ${String(err)}`)
423      }
424    }
425    await writeStatus($)
426    return done
427  })
428
429  on('command.run', { command: 'account' }, async ($, e) => {
430    const name = e.args.trim()
431    if (name) return { text: await switchTo($, name) }
432    const i = (await read($, info)) ?? (await loadInfo($, await $.session.cwd()))
433    return { text: statusText(i, await read($, usage), await read($, nextAccount)) }
434  })
435
436  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
437    if (e.props.hasSurvey) return next(e)
438    if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-vscode') return next(e)
439    const i = await read($, info)
440    if (!i) return next(e)
441    const { Box, Text, Button } = $.ui.resolve(e)
442    const u = await read($, usage)
443    const pending = await read($, nextAccount)
444
445    return (
446      <Box flexDirection="column">
447        <Box flexDirection="row">
448          <Text bold>{i.current.name}</Text>
449          <Text dimColor>  {i.current.email}</Text>
450        </Box>
451        {i.others.length > 0 && (
452          <Box flexDirection="row">
453            <Text dimColor>Switch to  </Text>
454            {i.others.map(o => (
455              <Button
456                key={`switch-${o.name}`}
457                label={o.name}
458                onPress={async () => {
459                  const result = await switchTo($, o.name)
460                  $.ui.toast(result.split('\n')[0] ?? result)
461                }}
462              />
463            ))}
464          </Box>
465        )}
466        {pending && pending !== i.current.name && (
467          <Text color="yellow">The next session in this folder runs on {pending}</Text>
468        )}
469        <Text dimColor>{usageLine(u)}</Text>
470      </Box>
471    )
472  })
473}
474
types/index.d.ts 26 lines
1export type ClaudeMultiAccountAccount = { name: string; dir: string; email: string }
2
3export type ClaudeMultiAccountInfo = {
4  current: ClaudeMultiAccountAccount
5  others: ClaudeMultiAccountAccount[]
6  folder: string
7  transcript: string
8}
9
10export type ClaudeMultiAccountUsage = {
11  usd?: number
12  limits: { kind: string; percentUsed: number }[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'multi-account': {
18      info: ClaudeMultiAccountInfo | null
19      usage: ClaudeMultiAccountUsage
20      next: string | null
21      handoff: string | null
22      run: { startedAt: number; startUsd: number } | null
23    }
24  }
25}
26