Switch between saved Claude logins and watch every account's rate limits

Plugins for Claude Code that add panes, a status band and slash commands to the terminal: switch between several Claude accounts, watch their usage limits and token use, see and clean up what Claude Code keeps on the machine, and keep agents, worktrees, checkpoints, diffs and memory files in one pane, and run a project's builds and commands from buttons. Install one plugin, sc, and you get them all.
[!IMPORTANT] Clicking needs Claude Code's fullscreen mode. The buttons, tabs, tiles and status band cells answer a mouse click only while Claude Code draws in fullscreen mode. Turn it on once with
/tui fullscreen, which is kept as"tui": "fullscreen"in~/.claude/settings.json. In the default mode Claude Code passes no click to the plugins, on every platform, Windows and WSL included. See Clicking and the keyboard.
/sc:accounts~/.claude holds by kind and project, and a cleanup of idle sessions confirmed by typing a word./sc:workspace/sc:toolbox/clear and /compact..toolbox/toolbox.json in the project; share it or ignore it in git. A skill lets Claude write it for you.Anything that cannot be taken back asks in a dialog first; Esc cancels.
| Requirement | Why |
|---|---|
Claude Code in fullscreen mode: /tui fullscreen, or "tui": "fullscreen" in ~/.claude/settings.json | Only the fullscreen mode reads the mouse; in the default mode no click reaches a plugin |
A terminal that passes mouse events to the program running in it; inside tmux, set -g mouse on | A terminal that keeps the mouse for its own text selection sends Claude Code no click to pass on |
Without a click, every control is reached from the keyboard:
ctrl+x then Tab gives the open pane, or the status band, the keyboard.Tab and the arrow keys move between buttons; Enter presses the one with the ring.Esc goes back from a dialog and closes a pane./sc:accounts, /sc:workspace, /sc:toolbox.The first pane a command opens outside fullscreen mode says so in its reply.
claude plugin marketplace add simplecore-inc/claude-mods
claude plugin install sc@simplecore-mods
sc installs sc-accounts, sc-workspace and sc-toolbox with it. The plugins are installed for your user, so they run in every session. Run /reload-plugins in a session that was already open.
To use a local clone instead, add its folder as the marketplace:
git clone https://github.com/simplecore-inc/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin install sc@simplecore-mods
claude plugin marketplace update simplecore-mods
claude plugin update sc@simplecore-mods
claude plugin update sc-accounts@simplecore-mods
claude plugin update sc-workspace@simplecore-mods
claude plugin update sc-toolbox@simplecore-mods
Then run /reload-plugins in each open session. With a local clone, git pull in the clone and /reload-plugins are enough: a plugin from a folder marketplace is read from that folder.
claude plugin uninstall sc@simplecore-mods
claude plugin prune
claude plugin marketplace remove simplecore-mods
prune removes sc-accounts, sc-workspace and sc-toolbox, which were installed as dependencies of sc; uninstall them by name instead if you installed them yourself. marketplace remove is only needed when you will not install from it again.
The mods leave some data behind, which you can delete by hand:
| What | Where |
|---|---|
| Saved logins | macOS: keychain items of the service account-switch. Elsewhere: ~/.claude/account-switch/ |
| Webhook token | macOS: the keychain item of the service sc-webhook. Elsewhere: ~/.claude/sc-accounts/webhook-token |
| Webhook template | ~/.claude/sc-accounts/ |
| Settings, account index | ~/.claude/plugins/store/sc-*.json |
| Tools, their logs and remembered values | in each project: .toolbox/ |
| Notes and checkpoint lists | in each repository's git folder: .git/sc-workspace/, a pair of files per worktree |
| Checkpoints | in each repository: the refs under refs/sc/, and the files sc-snapshot-*.index in the git folder and in each worktree's folder under .git/worktrees/ |
~/.claude is CLAUDE_CONFIG_DIR when that is set. To delete a repository's checkpoints, and its notes with them, run in the repository:
git for-each-ref --format='%(refname)' refs/sc/ | xargs -n 1 git update-ref -d
common="$(git rev-parse --git-common-dir)"
rm -f "$common"/sc-snapshot-*.index "$common"/worktrees/*/sc-snapshot-*.index
rm -rf "$common/sc-workspace"
The account you are logged in with stays logged in; only the saved copies go.
The mods show English, and Korean where Claude Code's language setting is Korean; any other language falls back to English. Without that setting, LC_ALL, LC_MESSAGES and LANG decide, in that order.
Developing a mod covers the layout, the rules the engine enforces, the checks and how to release.
hooks/register.tsx 518 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { USAGE_KEY, syncLive, tickOrca } from './accounts'
5import type { AccountsContext } from './accounts'
6import { lookedUpOnly } from './anthropic'
7import type { CleanupContext } from './cleanup'
8import type { CountContext } from './count'
9import { claudeDirectory, homeDirectory } from './credentials'
10import type { FeedContext } from './feed'
11import { releaseDateOf } from './format'
12import { messagesFor, resolveLocale } from './i18n'
13import type { Locale, Messages } from './i18n'
14import type { Cell, EnvName, Io } from './io'
15import { message } from './io'
16import { adoptMeasured, pollLive } from './lookups'
17import { keepOrcaCopiesFresh } from './orcaCopies'
18import { adoptSession, answerCommand, openPane, paneActions, paneModel, refresh, syncPaneOpen, toggle } from './paneControl'
19import type { PaneContext } from './paneControl'
20import { collectStatus, countLines, noteEffortCommand, noteRequestEffort, startStatus } from './sessionStatus'
21import type { StatusContext } from './sessionStatus'
22import { LATEST_RELEASE_KEY, RELEASE_CHECK_MS, latestReleaseUrl, latestVersion } from './shared/release'
23import type { RunningRelease } from './shared/release'
24import { isBesideOtherPanes } from './shared/panes'
25import { editedPath, lineChanges } from './status'
26import type { EffortSettings } from './status'
27import { StatusBand, toolboxCell } from './views/band'
28import { AccountsPane } from './views/pane'
29
30const accounts = atom({ plugin: 'sc-accounts', key: 'accounts' } as const, [])
31const usage = atom({ plugin: 'sc-accounts', key: 'usage' } as const, {})
32const live = atom({ plugin: 'sc-accounts', key: 'live' } as const, null)
33const dialog = atom({ plugin: 'sc-accounts', key: 'dialog' } as const, null)
34const focused = atom({ plugin: 'sc-accounts', key: 'focused' } as const, null)
35const isRefreshing = atom({ plugin: 'sc-accounts', key: 'isRefreshing' } as const, false)
36const isGuideOpen = atom({ plugin: 'sc-accounts', key: 'isGuideOpen' } as const, false)
37const statusInfo = atom({ plugin: 'sc-accounts', key: 'status' } as const, null)
38const sessionEffort = atom({ plugin: 'sc-accounts', key: 'sessionEffort' } as const, null)
39const tick = atom({ plugin: 'sc-accounts', key: 'tick' } as const, 0)
40const webhookDraft = atom({ plugin: 'sc-accounts', key: 'webhookDraft' } as const, null)
41const webhookLast = atom({ plugin: 'sc-accounts', key: 'webhookLast' } as const, null)
42const paneOpen = atom({ plugin: 'sc-accounts', key: 'paneOpen' } as const, false)
43const tab = atom({ plugin: 'sc-accounts', key: 'tab' } as const, 'accounts')
44const usagePeriod = atom({ plugin: 'sc-accounts', key: 'usagePeriod' } as const, 30)
45const usageSummary = atom({ plugin: 'sc-accounts', key: 'usageSummary' } as const, null)
46const usageScan = atom({ plugin: 'sc-accounts', key: 'usageScan' } as const, null)
47const usageError = atom({ plugin: 'sc-accounts', key: 'usageError' } as const, null)
48const storage = atom({ plugin: 'sc-accounts', key: 'storage' } as const, null)
49const cleanupDays = atom({ plugin: 'sc-accounts', key: 'cleanupDays' } as const, 30)
50/** The toolbox's counts for its band cell; all zero when the toolbox is not installed. */
51const toolboxSummary = atom({ plugin: 'sc-toolbox', key: 'summary' } as const, { running: 0, waiting: 0, failed: 0 })
52
53const PANE = 'account-switch'
54/** The pane's label on the engine's tab row, shown while another pane is open beside it. */
55const TAB_LABEL = 'Accounts'
56/** The plugin's name, as `ui.press` names the plugin that drew a pressed Button. */
57const PLUGIN = 'sc-accounts'
58/** The plugin `sc` declares /sc:accounts in `commands/accounts.md`; this hook answers it. */
59const COMMAND = 'sc:accounts'
60/** The band cell that toggles the accounts pane, the live account's name: `band-account`, then one Button per further coloured run. */
61const BAND_ACCOUNT = 'band-account'
62/** The `/config` row of the plugin's `showStatusBand` setting. */
63const BAND_SETTING = 'sc-accounts.showStatusBand'
64/** Every session wakes this often: to adopt a new login, and to share or take the automatic lookup. */
65const TICK_MS = 60 * 1000
66/** How often the session's status is read: the model, effort, branch, task and the rest the band shows. */
67const STATUS_POLL_MS = 2000
68/** How often the open pane redraws, so each account's "updated N min ago" is at most this late. */
69const PANE_TICK_MS = 10 * 1000
70/** How long after a /clear ends the old session the new one is taken up, once the engine has switched ids. */
71const CLEAR_SETTLE_MS = 300
72/** The largest file whose edit is counted; `$.fs.read` refuses bigger ones. */
73const MAX_COUNTED_FILE = 4 * 1024 * 1024
74
75/** The display language, settled at every session start (a reload starts one). */
76let locale: Locale = 'en'
77let m: Messages = messagesFor(locale)
78/** This build's version and release date, read from the plugin's own files at session start. */
79let release: RunningRelease = {}
80/** When this session's current model turn began; 0 before the first. */
81let turnStartedAt = 0
82let sessionId = ''
83let homePath = ''
84let hostname: string | null = null
85let engineVersion: string | null = null
86
87function isAccountCell(element: string): boolean {
88 return element === BAND_ACCOUNT || element.startsWith(`${BAND_ACCOUNT}-`)
89}
90
91function debugLog($: EngineInterface, error: unknown): void {
92 $.ui.log(`account-switch: ${message(error)}`, { to: 'debug' })
93}
94
95// ── what the modules reach the machine and the session through ─────────────
96
97/** One environment variable the modules read, each by its literal name, as the engine lists them. */
98function variable($: EngineInterface, name: EnvName): Promise<string | undefined> {
99 switch (name) {
100 case 'OS':
101 return $.env.get('OS')
102 case 'HOME':
103 return $.env.get('HOME')
104 case 'USERPROFILE':
105 return $.env.get('USERPROFILE')
106 case 'USER':
107 return $.env.get('USER')
108 case 'CLAUDE_CONFIG_DIR':
109 return $.env.get('CLAUDE_CONFIG_DIR')
110 case 'CLAUDE_SECURESTORAGE_CONFIG_DIR':
111 return $.env.get('CLAUDE_SECURESTORAGE_CONFIG_DIR')
112 case 'ORCA_USER_DATA_PATH':
113 return $.env.get('ORCA_USER_DATA_PATH')
114 case 'XDG_CONFIG_HOME':
115 return $.env.get('XDG_CONFIG_HOME')
116 case 'APPDATA':
117 return $.env.get('APPDATA')
118 }
119}
120
121function machine($: EngineInterface): Io {
122 return {
123 run: (argv, init) => $.process.run(argv, init),
124 read: path => $.fs.read(path),
125 write: (path, text) => $.fs.write(path, text),
126 exists: path => $.fs.exists(path),
127 stat: path => $.fs.stat(path),
128 list: path => $.fs.list(path),
129 env: name => variable($, name),
130 now: () => $.clock.now(),
131 sleep: ms => $.clock.sleep(ms),
132 after: (ms, fn) => {
133 const timer = $.clock.after(ms, fn)
134
135 return () => timer.cancel()
136 },
137 fetch: (url, init) => $.http.fetch(url, init),
138 store: {
139 get: key => $.store.get(key),
140 set: async (key, value) => {
141 await $.store.set(key, value)
142 },
143 delete: async key => {
144 await $.store.delete(key)
145 },
146 keys: () => $.store.keys(),
147 },
148 log: text => $.ui.log(`account-switch: ${text}`, { to: 'debug' }),
149 }
150}
151
152function accountsContext($: EngineInterface): AccountsContext {
153 return {
154 io: machine($),
155 accounts: { get: () => read($, accounts), set: async list => void (await update($, accounts, () => list)) },
156 live: { get: () => read($, live), set: async uuid => void (await update($, live, () => uuid)) },
157 usage: { get: () => read($, usage), update: async change => void (await update($, usage, change)) },
158 isRefreshing: { get: () => read($, isRefreshing), set: async value => void (await update($, isRefreshing, () => value)) },
159 toast: text => $.ui.toast(text),
160 messages: () => m,
161 session: { id: () => sessionId, cwd: () => $.session.cwd(), version: () => release.version ?? '' },
162 }
163}
164
165function countContext($: EngineInterface): CountContext {
166 return {
167 io: machine($),
168 messages: () => m,
169 usagePeriod: () => read($, usagePeriod),
170 usageSummary: async summary => void (await update($, usageSummary, () => summary)),
171 usageScan: async scan => void (await update($, usageScan, () => scan)),
172 usageError: { get: () => read($, usageError), set: async error => void (await update($, usageError, () => error)) },
173 isUsageShown: async () => (await $.ui.panes()).some(pane => pane.id === PANE) && (await read($, tab)) === 'usage',
174 }
175}
176
177function cleanupContext($: EngineInterface): CleanupContext {
178 return {
179 io: machine($),
180 count: countContext($),
181 storage: async view => void (await update($, storage, () => view)),
182 cleanupDays: () => read($, cleanupDays),
183 sessionId: () => $.session.id(),
184 autoCleanupDays: async () => {
185 const { cleanupPeriodDays } = (await $.settings.read()) as { cleanupPeriodDays?: unknown }
186
187 return typeof cleanupPeriodDays === 'number' ? cleanupPeriodDays : undefined
188 },
189 homePath: () => homePath,
190 }
191}
192
193function feedContext($: EngineInterface): FeedContext {
194 return {
195 io: machine($),
196 messages: () => m,
197 webhookLast: async last => void (await update($, webhookLast, () => last)),
198 liveFigures: async () => {
199 const liveUuid = await read($, live)
200
201 return {
202 email: (await read($, accounts)).find(one => one.uuid === liveUuid)?.email ?? null,
203 limits: liveUuid ? ((await read($, usage))[liveUuid]?.limits ?? []) : [],
204 }
205 },
206 session: { id: () => sessionId, hostname: () => hostname, version: () => engineVersion },
207 }
208}
209
210function statusContext($: EngineInterface): StatusContext {
211 return {
212 accounts: accountsContext($),
213 feed: feedContext($),
214 cwd: () => $.session.cwd(),
215 model: () => $.session.model(),
216 usage: () => $.session.usage(),
217 settings: async () => (await $.settings.read()) as { fastMode?: unknown },
218 settingsOf: async source => (await $.settings.read({ source })) as EffortSettings,
219 status: { get: () => read($, statusInfo), set: async next => void (await update($, statusInfo, () => next)) },
220 sessionEffort: { get: () => read($, sessionEffort), set: async next => void (await update($, sessionEffort, () => next)) },
221 turnStartedAt: () => turnStartedAt,
222 }
223}
224
225function paneContext($: EngineInterface): PaneContext {
226 const cell = <T,>(get: () => Promise<T>, set: (value: T) => Promise<unknown>): Cell<T> => ({ get, set: async value => void (await set(value)) })
227
228 return {
229 accounts: accountsContext($),
230 count: countContext($),
231 cleanup: cleanupContext($),
232 feed: feedContext($),
233 status: statusContext($),
234 cells: {
235 dialog: cell(() => read($, dialog), value => update($, dialog, () => value)),
236 focused: cell(() => read($, focused), value => update($, focused, () => value)),
237 tab: cell(() => read($, tab), value => update($, tab, () => value)),
238 paneOpen: cell(() => read($, paneOpen), value => update($, paneOpen, () => value)),
239 isGuideOpen: cell(() => read($, isGuideOpen), value => update($, isGuideOpen, () => value)),
240 webhookDraft: cell(() => read($, webhookDraft), value => update($, webhookDraft, () => value)),
241 webhookLast: cell(() => read($, webhookLast), value => update($, webhookLast, () => value)),
242 usagePeriod: cell(() => read($, usagePeriod), value => update($, usagePeriod, () => value)),
243 usageSummary: cell(() => read($, usageSummary), value => update($, usageSummary, () => value)),
244 usageScan: cell(() => read($, usageScan), value => update($, usageScan, () => value)),
245 usageError: cell(() => read($, usageError), value => update($, usageError, () => value)),
246 storage: cell(() => read($, storage), value => update($, storage, () => value)),
247 cleanupDays: cell(() => read($, cleanupDays), value => update($, cleanupDays, () => value)),
248 tick: cell(() => read($, tick), value => update($, tick, () => value)),
249 statusInfo: cell(() => read($, statusInfo), value => update($, statusInfo, () => value)),
250 },
251 ui: {
252 open: async (rows, focus) => {
253 await $.ui.open({ id: PANE, title: TAB_LABEL, rows, ...(focus ? { focus: true, closeOnEscape: true } : {}) })
254 },
255 close: async () => {
256 await $.ui.close({ id: PANE })
257 },
258 pane: async () => (await $.ui.panes()).find(pane => pane.id === PANE),
259 toast: text => $.ui.toast(text),
260 runCommand: (command, args) => $.command.run({ command, args }),
261 isUnderTabs: async () =>
262 isBesideOtherPanes('sc-accounts', {
263 'sc-accounts': (await $.state.get({ plugin: 'sc-accounts', key: 'paneOpen' })).value === true,
264 'sc-workspace': (await $.state.get({ plugin: 'sc-workspace', key: 'paneOpen' })).value === true,
265 'sc-toolbox': (await $.state.get({ plugin: 'sc-toolbox', key: 'paneOpen' })).value === true,
266 }),
267 },
268 session: { model: () => $.session.model(), cwd: () => $.session.cwd(), cost: async () => (await $.session.usage()).cost?.usd ?? null },
269 view: { locale: () => locale, messages: () => m, release: () => release, homePath: () => homePath },
270 }
271}
272
273// ── the session ────────────────────────────────────────────────────────────
274
275/** The version `plugin.json` states and the date `CHANGELOG.md` gives that version. */
276async function readRelease($: EngineInterface): Promise<RunningRelease> {
277 try {
278 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string; repository?: string }
279 if (typeof manifest.version !== 'string') return {}
280 const changelogPath = `${$.plugin.root}/CHANGELOG.md`
281 const changelog = (await $.fs.exists(changelogPath)) ? await $.fs.read(changelogPath) : ''
282
283 return { version: manifest.version, date: releaseDateOf(changelog, manifest.version), repository: manifest.repository }
284 } catch (error) {
285 $.ui.log(`account-switch: cannot read the release: ${message(error)}`, { to: 'debug' })
286
287 return {}
288 }
289}
290
291/** Hears of the latest published release, so the pane header says when an update is due. */
292async function followLatestRelease($: EngineInterface): Promise<void> {
293 const url = latestReleaseUrl(release.repository)
294 if (url === null) return
295 const latest = await latestVersion(
296 {
297 now: () => $.clock.now(),
298 get: () => $.store.get(LATEST_RELEASE_KEY),
299 set: value => $.store.set(LATEST_RELEASE_KEY, value),
300 fetch: (target, init) => $.http.fetch(target, init),
301 },
302 url,
303 )
304 release = { ...release, latest }
305}
306
307/** A file's text, or null when it is missing or too big to read. */
308async function readSmallFile($: EngineInterface, path: string): Promise<string | null> {
309 if (!(await $.fs.exists(path))) return null
310 const { size } = await $.fs.stat(path)
311
312 return size > MAX_COUNTED_FILE ? null : $.fs.read(path)
313}
314
315// ── hooks ─────────────────────────────────────────────────────────────────
316
317export const register: Register = (on, options) => {
318 // The `showStatusBand` setting; a change in /config reloads the module with the new value.
319 const isBandShown = options.showStatusBand !== false
320
321 on('session.start', async ($, e, next) => {
322 const { language } = await $.settings.read()
323 const localeVariables = [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')]
324 locale = resolveLocale(language, localeVariables)
325 m = messagesFor(locale)
326 release = await readRelease($)
327 // A later published release turns the header's date into Update Required: asked now and every few hours.
328 void followLatestRelease($).catch((error: unknown) => debugLog($, error))
329 $.clock.every(RELEASE_CHECK_MS, () => {
330 void followLatestRelease($).catch((error: unknown) => debugLog($, error))
331 })
332 $.ui.status(undefined)
333 // Readings no lookup produced (a figure copied from another login, a message an older build kept) go.
334 await $.store.set(USAGE_KEY, lookedUpOnly(await $.store.get(USAGE_KEY)))
335 const io = machine($)
336 homePath = await homeDirectory(io)
337 engineVersion = (await $.session.version()).version
338 const host = await $.process.run(['hostname'], { timeoutMs: 2000 })
339 hostname = host.exitCode === 0 ? host.stdout.trim() : null
340 // The session's status: read every two seconds from the engine, git, gh and the transcript.
341 startStatus(
342 {
343 run: (argv, init) => $.process.run(argv, init),
344 read: async path => ((await $.fs.exists(path)) ? $.fs.read(path) : null),
345 list: async path => ((await $.fs.exists(path)) ? $.fs.list(path) : []),
346 size: async path => ((await $.fs.exists(path)) ? (await $.fs.stat(path)).size : null),
347 },
348 { configPath: await claudeDirectory(io), sessionRoot: await $.session.root() },
349 )
350 sessionId = await $.session.id()
351 await adoptSession(paneContext($), sessionId, false)
352 $.clock.every(STATUS_POLL_MS, () => {
353 void collectStatus(statusContext($)).catch((error: unknown) => debugLog($, error))
354 })
355 $.clock.every(PANE_TICK_MS, () => {
356 void (async () => {
357 if (!(await syncPaneOpen(paneContext($)))) return
358 const now = await $.clock.now()
359 await update($, tick, () => now)
360 })().catch((error: unknown) => debugLog($, error))
361 })
362 $.clock.every(TICK_MS, () => {
363 const pane = paneContext($)
364 const ctx = pane.accounts
365 void syncLive(ctx)
366 .catch((error: unknown) => debugLog($, error))
367 .then(() => tickOrca(ctx))
368 // Orca writes a copy as it is while a Claude terminal runs in it: each is kept from expiring.
369 .then(claude => (claude ? keepOrcaCopiesFresh(ctx, claude) : undefined))
370 .catch((error: unknown) => debugLog($, error))
371 .then(() => refresh(pane, false))
372 // The live account is kept current between Claude Code's own readings too.
373 .then(() => pollLive(ctx))
374 .catch((error: unknown) => debugLog($, error))
375 })
376
377 return next(e)
378 })
379
380 // A /clear goes on in this process under a new session id, and no session.start fires for
381 // it: the new session's state is filled here, once the old one has ended.
382 on('session.end', async ($, e, next) => {
383 const result = await next(e)
384 if (e.reason === 'clear') {
385 $.clock.after(CLEAR_SETTLE_MS, () => {
386 void (async () => {
387 sessionId = await $.session.id()
388 await adoptSession(paneContext($), sessionId, true)
389 })().catch((error: unknown) => debugLog($, error))
390 })
391 }
392
393 return result
394 })
395
396 on('turn.start', async ($, e, next) => {
397 turnStartedAt = await $.clock.now()
398
399 return next(e)
400 })
401
402 // The effort each main-loop request asks for, as the engine settled it (a subagent's are its own).
403 on('turn.step', async function* ($, e, next) {
404 if (!e.agentId) await noteRequestEffort(statusContext($), e.effort).catch((error: unknown) => debugLog($, error))
405
406 return yield* next(e)
407 })
408
409 // This session's `/effort`: the level it names shows at once, a default picked from its list once saved.
410 on('command.run', { command: 'effort' }, async ($, e, next) => {
411 const userBefore = (await $.settings.read({ source: 'user' })) as EffortSettings
412 const result = await next(e)
413 await noteEffortCommand(statusContext($), e.args, userBefore).catch((error: unknown) => debugLog($, error))
414
415 return result
416 })
417
418 // Lines a file-changing tool call adds and removes: the file before and after, compared.
419 // A failure here never stands in the tool call's way: the call goes on, run once.
420 on('tool.call', async ($, e, next) => {
421 const path = editedPath(String(e.tool), e)
422 if (path === null) return next(e)
423 const before = await readSmallFile($, path).catch(() => undefined)
424 const result = await next(e)
425 if (before !== undefined && !('deny' in result) && !(result as { isError?: boolean }).isError) {
426 const after = await readSmallFile($, path).catch(() => undefined)
427 if (after !== undefined) await countLines(statusContext($), lineChanges(before, after)).catch((error: unknown) => debugLog($, error))
428 }
429
430 return result
431 }).catch(($, e, next) => next(e))
432
433 // Claude Code's own response reported its windows: filed under the live account when they are its.
434 on('session.measure', async ($, e, next) => {
435 await adoptMeasured(accountsContext($), e.rateLimits, turnStartedAt).catch((error: unknown) => debugLog($, error))
436
437 return next(e)
438 })
439
440 on('command.run', { command: COMMAND }, async ($, e) => ({
441 text: await answerCommand(paneContext($), e.args, {
442 isFullscreen: e.presentation?.isFullscreen,
443 setBand: async isShown => (await $.config.set({ key: BAND_SETTING, value: isShown })).deny,
444 }),
445 }))
446
447 // The status on the band above the prompt, left-aligned under a dim rule: the
448 // model, effort, fast mode and task, the live account, the context and usage
449 // gauges, the place and PR, and the lines changed. Cells move to a new row
450 // when the band is too narrow.
451 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
452 if (e.props.hasSurvey || !isBandShown) return next(e)
453 const status = await read($, statusInfo)
454 const liveUuid = await read($, live)
455 const account = (await read($, accounts)).find(one => one.uuid === liveUuid)
456 const reading = liveUuid ? (await read($, usage))[liveUuid] : undefined
457 const windows = (reading?.limits ?? []).filter(limit => limit.label === '5h' || limit.label === 'wk')
458 const contextUsed = (await $.session.usage()).context.percent ?? status?.contextUsed ?? null
459 if (!status && contextUsed === null && (!account || windows.length === 0)) return next(e)
460
461 return StatusBand($.ui.resolve(e), {
462 status,
463 account,
464 reading,
465 windows,
466 contextUsed,
467 now: await $.clock.now(),
468 locale,
469 room: Math.max(20, e.props.bodyColumns),
470 toolbox: toolboxCell(await read($, toolboxSummary)),
471 // The ui.press hook below takes the account, and the workspace's the place and the lines;
472 // this runs for a press neither took (the workspace without its hook).
473 onPress: target => {
474 void toggle(paneContext($), target).catch((error: unknown) => $.ui.toast(message(error)))
475 },
476 })
477 })
478
479 // A band press is taken here, inside the person's press, and toggled before the chain
480 // settles: a pane opened there counts as asked for and is placed at any width.
481 on('ui.press', async ($, e, next) => {
482 if (e.plugin !== PLUGIN || e.component !== 'AbovePrompt' || !isAccountCell(e.element)) return next(e)
483 await toggle(paneContext($), 'accounts')
484
485 return { element: e.element }
486 })
487
488 // Where the keyboard is in the pane, so the dialog's outlined tiles can show it.
489 on('ui.focus', async ($, e, next) => {
490 const result = await next(e)
491 if (e.requestId === PANE) await update($, focused, () => e.element ?? null)
492
493 return result
494 }).catch(($, e, next) => next(e))
495
496 // Esc (or the close mark) while the dialog asks cancels the dialog and keeps the pane.
497 on('ui.close', async ($, e, next) => {
498 if (e.id === PANE && e.origin.kind === 'person' && (await read($, dialog)) !== null) {
499 const wasWebhook = (await read($, dialog))?.kind === 'webhook'
500 await update($, dialog, () => null)
501 await update($, focused, () => null)
502 if (wasWebhook) await openPane(paneContext($), true)
503
504 return { value: undefined }
505 }
506 const result = await next(e)
507 if (e.id === PANE && (await read($, paneOpen))) await update($, paneOpen, () => false)
508
509 return result
510 }).catch(($, e, next) => next(e))
511
512 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
513 const pane = paneContext($)
514
515 return AccountsPane($.ui.resolve(e), await paneModel(pane, e.props.bodyColumns ?? 60, e.surface !== 'mobile'), paneActions(pane))
516 })
517}
518hooks/accounts.ts 422 lines1import type { AccountView, UsageView } from '../types'
2import { PROFILE_URL, mayHeal, parseProfile, usageInit } from './anthropic'
3import {
4 claudeDirectory,
5 keepCredentialFileInStep,
6 liveCredentialPath,
7 liveOauthAccount,
8 platformOf,
9 readLiveCredential,
10 readVault,
11 writeLiveFile,
12 writeLiveKeychain,
13 writeVault,
14} from './credentials'
15import type { OauthAccount } from './credentials'
16import type { Messages } from './i18n'
17import type { Cell, Io } from './io'
18import { message, within } from './io'
19import { isOlderGrant } from './keychain'
20import type { Credential } from './keychain'
21import { withLock } from './lock'
22import { pendingStep, planFollow } from './orca'
23import type { OrcaClaude } from './orca'
24import { isOrcaCheckDue, orcaClaude, orcaSelect, returnPending, takePending } from './orcaClient'
25import { serialQueue } from './queue'
26import { isOwnSwitch, parseChanges, withChange } from './switchlog'
27import type { LoginChange } from './switchlog'
28import { writeAtomic } from './writes'
29
30/**
31 * The saved accounts and the login Claude Code uses: reading the live login
32 * and filing it, putting a rejected login back, and saying who changed the
33 * login, Orca included; switching and removing are in `switching.ts`. What the
34 * session's state holds
35 * is reached through the context the hooks module builds.
36 */
37
38/** What the account work reads and writes beyond the machine: the session's state, its toasts and its words. */
39export type AccountsContext = {
40 io: Io
41 accounts: Cell<AccountView[]>
42 live: Cell<string | null>
43 usage: { get: () => Promise<Record<string, UsageView>>; update: (change: (map: Record<string, UsageView>) => Record<string, UsageView>) => Promise<void> }
44 isRefreshing: Cell<boolean>
45 toast: (text: string) => void
46 messages: () => Messages
47 session: { id: () => string; cwd: () => Promise<string>; version: () => string }
48}
49
50export const USAGE_KEY = 'usage'
51const INDEX_KEY = 'accounts'
52export const oauthAccountKey = (uuid: string) => `oauthAccount:${uuid}`
53/** The `$.store` key of the session selecting in Orca a login changed outside it, so the others leave it to that one. */
54const ORCA_FOLLOW_KEY = 'orcaFollow'
55const ORCA_FOLLOW_CLAIM_MS = 60 * 1000
56/** How long a change of the login stands before it is explained: Orca writes a login in steps over a few seconds, passing through another. */
57const SETTLE_MS = 10_000
58/** How long the profile endpoint may take to say whose a token is. */
59const PROFILE_TIMEOUT_MS = 15_000
60/** How long after putting a login back this session leaves a login rejected again alone, so two writers never loop. */
61const HEAL_GAP_MS = 60 * 1000
62/** How long Claude Code keeps a keychain login cached when no credentials file tells it of a change. */
63const KEYCHAIN_CACHE_MS = 35 * 1000
64/** This mod's lock over the change record, which every session writes. */
65const CHANGES_LOCK = { staleMs: 10_000, tries: 12 }
66
67/**
68 * A switch and each account's token work run one at a time in this session:
69 * a lookup refreshing an account's token while a switch makes it the live
70 * login would refresh it twice with one refresh token.
71 */
72export const exclusive = serialQueue()
73
74/** When this session last saw the live account change, by a switch or a login. */
75let liveChangedAt = 0
76/** Whose the live token is, as the last read found: a lookup of the live account checks the token it sends is still this one. */
77let liveOwner: { accessToken: string; uuid: string } | null = null
78/** The live account this session last said or took up: a change that settles back to it was a passing write, said to nobody. */
79let announcedLive: string | null = null
80/** The changes of the login this session has found, counted, so only the latest one's settling is explained. */
81let changeCount = 0
82let healedAt = 0
83/** The read of the live login in progress, which every caller meanwhile shares: two at once would file one token twice. */
84let syncing: Promise<string | null> | null = null
85
86/** Takes up a login this session wrote itself: a later change away from it, Orca's included, is one to explain. */
87export function noteInstalled(uuid: string, accessToken: string, at: number): void {
88 liveChangedAt = at
89 liveOwner = { accessToken, uuid }
90 announcedLive = uuid
91}
92
93export function lastLiveChange(): number {
94 return liveChangedAt
95}
96
97/** Whether the live login is still the token the last read filed under `uuid`. */
98export function isLiveTokenOf(credential: Credential, uuid: string): boolean {
99 return liveOwner?.uuid === uuid && liveOwner.accessToken === credential.claudeAiOauth.accessToken
100}
101
102/** The organization of a login's details, as Orca matches accounts by it too; null when the details name none. */
103export function organizationOf(account: OauthAccount | undefined): string | null {
104 return typeof account?.organizationUuid === 'string' && account.organizationUuid !== '' ? account.organizationUuid : null
105}
106
107/**
108 * The saved accounts: the list the store holds, with every account it lost
109 * put back. An account is saved when both its details (`oauthAccount:<id>`)
110 * and its credential are kept, so a session running an older build that wrote
111 * back a shorter list cannot drop one; removing an account deletes both.
112 */
113async function storedIndex(ctx: AccountsContext): Promise<AccountView[]> {
114 const { io } = ctx
115 const stored = await io.store.get(INDEX_KEY)
116 const list = Array.isArray(stored) ? (stored as AccountView[]) : []
117 const listed = new Set(list.map(one => one.uuid))
118 const lost: AccountView[] = []
119 for (const key of await io.store.keys()) {
120 if (!key.startsWith('oauthAccount:')) continue
121 const uuid = key.slice('oauthAccount:'.length)
122 if (listed.has(uuid)) continue
123 const account = (await io.store.get(key)) as OauthAccount | undefined
124 const credential = account ? await readVault(io, uuid).catch(() => null) : null
125 if (!account || !credential) continue
126 lost.push({ uuid, email: account.emailAddress, organizationName: account.organizationName, subscriptionType: credential.claudeAiOauth.subscriptionType, savedAt: 0 })
127 }
128
129 return [...list, ...lost.sort((a, b) => a.email.localeCompare(b.email))]
130}
131
132/**
133 * Changes the saved accounts. The change is applied to the list the store
134 * holds now, never to this session's copy: a session that has not loaded the
135 * list yet, or holds an older one, would otherwise write back only the
136 * accounts it knows and drop the rest.
137 */
138export async function changeIndex(ctx: AccountsContext, change: (list: AccountView[]) => AccountView[]): Promise<AccountView[]> {
139 const list = change(await storedIndex(ctx))
140 await ctx.io.store.set(INDEX_KEY, list)
141 await ctx.accounts.set(list)
142
143 return list
144}
145
146export async function loadIndex(ctx: AccountsContext): Promise<void> {
147 await ctx.accounts.set(await storedIndex(ctx))
148}
149
150/** The ids of the accounts saved now, as the list every session shares holds them: one another session removed is not among them, whatever this session still shows. */
151export async function savedIds(ctx: AccountsContext): Promise<Set<string>> {
152 return new Set((await storedIndex(ctx)).map(one => one.uuid))
153}
154
155/**
156 * Whose login a token is, as the profile endpoint answers: the account;
157 * `rejected` when the token is empty or the endpoint refuses it (401, 403),
158 * so it can never work again; `unknown` when no answer came (offline, 5xx,
159 * no answer within its time).
160 */
161async function tokenCheck(ctx: AccountsContext, credential: Credential): Promise<OauthAccount | 'rejected' | 'unknown'> {
162 if (credential.claudeAiOauth.accessToken === '') return 'rejected'
163 try {
164 const response = await within(ctx.io, ctx.io.fetch(PROFILE_URL, usageInit({ token: credential.claudeAiOauth.accessToken })), PROFILE_TIMEOUT_MS, ctx.messages().profileNoAnswer(PROFILE_TIMEOUT_MS / 1000))
165 if (response.status === 401 || response.status === 403) return 'rejected'
166 if (!response.ok) return 'unknown'
167
168 return parseProfile(response.text)
169 } catch (error) {
170 ctx.io.log(message(error))
171
172 return 'unknown'
173 }
174}
175
176/**
177 * Reads the login Claude Code uses now and files it in the vault: a new
178 * account is added, a known one gets the credential Claude Code last
179 * refreshed. A copy older than the one saved for its account (another grant
180 * that expires sooner) is used but never filed over the newer one.
181 *
182 * @returns the live account's uuid, or null with no Claude login
183 */
184export async function syncLive(ctx: AccountsContext): Promise<string | null> {
185 syncing ??= syncLiveOnce(ctx).finally(() => {
186 syncing = null
187 })
188
189 return syncing
190}
191
192async function syncLiveOnce(ctx: AccountsContext): Promise<string | null> {
193 const { io } = ctx
194 const [configured, credential] = await Promise.all([liveOauthAccount(io), readLiveCredential(io)])
195 if (configured === null || credential === null) {
196 liveOwner = null
197 await ctx.live.set(null)
198
199 return null
200 }
201 await keepCredentialFileInStep(io, credential)
202
203 let account = configured
204 const stored = await readVault(io, configured.accountUuid)
205 if (JSON.stringify(stored) !== JSON.stringify(credential)) {
206 // The token changed: ask whose it is. During a login the config and the
207 // keychain can name different accounts for a moment, and filing a token
208 // under the wrong account would show one account's usage as another's.
209 const check = await tokenCheck(ctx, credential)
210 // Rejected: a login written into Claude Code that can never work again, its refresh token spent elsewhere.
211 if (check === 'rejected') return healLogin(ctx, configured, credential)
212 if (check === 'unknown') return ctx.live.get()
213 account = check.accountUuid === configured.accountUuid ? configured : (((await io.store.get(oauthAccountKey(check.accountUuid))) as OauthAccount | undefined) ?? check)
214 const saved = account.accountUuid === configured.accountUuid ? stored : await readVault(io, account.accountUuid)
215 if (!isOlderGrant(credential, saved)) await writeVault(io, account.accountUuid, credential)
216 }
217 liveOwner = { accessToken: credential.claudeAiOauth.accessToken, uuid: account.accountUuid }
218 const previous = await ctx.live.get()
219 if (previous !== account.accountUuid) {
220 liveChangedAt = await io.now()
221 if (previous !== null) await noteChange(ctx, previous, account)
222 else announcedLive ??= account.accountUuid
223 }
224 await ctx.live.set(account.accountUuid)
225 await io.store.set(oauthAccountKey(account.accountUuid), account)
226
227 const now = await io.now()
228 let isNew = false
229 await changeIndex(ctx, list => {
230 const known = list.find(one => one.uuid === account.accountUuid)
231 const view: AccountView = {
232 uuid: account.accountUuid,
233 email: account.emailAddress,
234 organizationName: account.organizationName,
235 subscriptionType: credential.claudeAiOauth.subscriptionType,
236 savedAt: known?.savedAt ?? now,
237 }
238 isNew = known === undefined
239
240 return known ? list.map(one => (one.uuid === view.uuid ? view : one)) : [...list, view]
241 })
242 if (isNew) ctx.toast(ctx.messages().saved(account.emailAddress))
243
244 return account.accountUuid
245}
246
247/**
248 * Puts the configured account's saved login back when the one Claude Code
249 * holds was rejected: a login written into Claude Code whose refresh token was
250 * already spent elsewhere. Only a saved login that works as it is: one that
251 * needs refreshing is left to /login, as several sessions refreshing it at
252 * once would spend it too.
253 *
254 * @returns the live account's uuid, or the one shown when nothing was put back
255 */
256async function healLogin(ctx: AccountsContext, configured: OauthAccount, rejected: Credential): Promise<string | null> {
257 const { io } = ctx
258 const now = await io.now()
259 const saved = await readVault(io, configured.accountUuid)
260 if (!mayHeal(saved, rejected, now, healedAt, HEAL_GAP_MS)) return ctx.live.get()
261 const check = await tokenCheck(ctx, saved)
262 if (typeof check === 'string' || check.accountUuid !== configured.accountUuid) return ctx.live.get()
263 healedAt = now
264 await recordChange(ctx, { kind: 'heal', from: null, to: configured.emailAddress })
265 await writeLiveKeychain(io, saved)
266 await writeLiveFile(io, saved)
267 liveChangedAt = now
268 liveOwner = { accessToken: saved.claudeAiOauth.accessToken, uuid: configured.accountUuid }
269 await ctx.live.set(configured.accountUuid)
270 ctx.toast(ctx.messages().loginHealed(configured.emailAddress))
271
272 return configured.accountUuid
273}
274
275/** The file that records every change of the live login, this mod's switches and the ones from outside it. */
276async function changesPath(io: Io): Promise<string> {
277 return `${await claudeDirectory(io)}/sc-accounts/login-changes.jsonl`
278}
279
280async function readChanges(io: Io): Promise<string> {
281 const path = await changesPath(io)
282
283 return (await io.exists(path)) ? io.read(path) : ''
284}
285
286/**
287 * Adds a change to the record. Every session writes it, so the record is read
288 * and written whole under a lock of its own, and replaced at once: a line
289 * written by another session at the same moment is never lost. A record that
290 * cannot be written never stops a switch.
291 */
292export async function recordChange(ctx: AccountsContext, change: Omit<LoginChange, 'at' | 'session' | 'cwd' | 'version'>): Promise<void> {
293 const { io } = ctx
294 try {
295 const full: LoginChange = { at: await io.now(), session: ctx.session.id(), cwd: await ctx.session.cwd(), version: ctx.session.version(), ...change }
296 const path = await changesPath(io)
297 const { isWindows } = await platformOf(io)
298 await withLock(io, `${path}.lock`, { ...CHANGES_LOCK, isWindows }, async () => {
299 await writeAtomic(io, path, withChange(await readChanges(io), full), { isPrivate: false, isWindows })
300 })
301 } catch (error) {
302 io.log(message(error))
303 }
304}
305
306/**
307 * A change of the live login this session found. A switch, heal or Orca
308 * selection of this mod's, in any session, says nothing; any other change is
309 * explained once the login has stood still for `SETTLE_MS`, so writes that pass
310 * through another login on their way back say nothing either.
311 */
312async function noteChange(ctx: AccountsContext, previousUuid: string, account: OauthAccount): Promise<void> {
313 announcedLive ??= previousUuid
314 if (isOwnSwitch(parseChanges(await readChanges(ctx.io)), account.emailAddress, await ctx.io.now())) {
315 announcedLive = account.accountUuid
316
317 return
318 }
319 changeCount += 1
320 const count = changeCount
321 ctx.io.after(SETTLE_MS, () => {
322 if (count === changeCount) void settleChange(ctx).catch((error: unknown) => ctx.io.log(message(error)))
323 })
324}
325
326/** Explains a change of the login once it has stood still: who made it, and what Orca does about it. */
327async function settleChange(ctx: AccountsContext): Promise<void> {
328 const count = changeCount
329 const liveUuid = await syncLive(ctx)
330 // Another change came meanwhile, and its own settling explains both; or the login is back where it was.
331 if (count !== changeCount || liveUuid === null || liveUuid === announcedLive) return
332 const from = (await ctx.accounts.get()).find(one => one.uuid === announcedLive)?.email ?? null
333 announcedLive = liveUuid
334 const account = (await ctx.io.store.get(oauthAccountKey(liveUuid))) as OauthAccount | undefined
335 if (!account || isOwnSwitch(parseChanges(await readChanges(ctx.io)), account.emailAddress, await ctx.io.now())) return
336 await explainChange(ctx, from, account)
337}
338
339/** Records and says who changed the login, and has Orca keep a login changed outside it rather than put its own back. */
340async function explainChange(ctx: AccountsContext, from: string | null, to: OauthAccount): Promise<void> {
341 const m = ctx.messages()
342 const reach = await orcaClaude(ctx.io, m)
343 const claude = reach.kind === 'ok' ? reach.claude : null
344 // Orca has just started and put back the login it wrote before, over the one a switch chose while it was closed.
345 if (claude && (await applyPendingOrca(ctx, claude))) return
346 const plan = planFollow(claude, to.emailAddress, organizationOf(to))
347 if (plan.kind === 'orca') {
348 await recordChange(ctx, { kind: 'orca', from, to: to.emailAddress })
349 ctx.toast(m.loginChangedByOrca(from ?? '?', to.emailAddress))
350
351 return
352 }
353 if (plan.kind === 'follow') {
354 // Another session found the same change a moment ago and is selecting it in Orca.
355 if (!(await claimFollow(ctx.io, to.emailAddress))) return
356 await recordChange(ctx, { kind: 'follow', from, to: to.emailAddress })
357 try {
358 await orcaSelect(ctx.io, m, plan.account.id)
359 ctx.toast(m.orcaFollowed(to.emailAddress))
360 } catch (error) {
361 ctx.toast(m.orcaFollowFailed(to.emailAddress, message(error)))
362 }
363
364 return
365 }
366 await recordChange(ctx, { kind: 'outside', from, to: to.emailAddress })
367 ctx.toast(plan.kind === 'revert' ? m.orcaWillRevert(to.emailAddress, plan.activeEmail ?? '?') : m.loginChangedOutside(from ?? '?', to.emailAddress))
368}
369
370/** Takes, for this session, selecting a login in Orca, unless another session took the same one within a minute. */
371async function claimFollow(io: Io, email: string): Promise<boolean> {
372 const now = await io.now()
373 const claim = (await io.store.get(ORCA_FOLLOW_KEY)) as { email?: unknown; at?: unknown } | undefined
374 if (claim?.email === email && typeof claim.at === 'number' && now - claim.at < ORCA_FOLLOW_CLAIM_MS) return false
375 await io.store.set(ORCA_FOLLOW_KEY, { email, at: now })
376
377 return true
378}
379
380/** Selects in Orca the account a switch chose while Orca was not running; true when it was asked to. */
381async function applyPendingOrca(ctx: AccountsContext, claude: OrcaClaude): Promise<boolean> {
382 const { io } = ctx
383 const pending = await takePending(io)
384 if (!pending || pendingStep(pending, claude, await io.now()) !== 'apply') return false
385 await recordChange(ctx, { kind: 'follow', from: claude.accounts.find(account => account.id === claude.activeId)?.email ?? null, to: pending.email })
386 try {
387 await orcaSelect(io, ctx.messages(), pending.accountId)
388 ctx.toast(ctx.messages().orcaAppliedPending(pending.email))
389 } catch (error) {
390 await returnPending(io, pending)
391 io.log(message(error))
392 }
393
394 return true
395}
396
397/**
398 * Keeps what Orca says current, machine-wide every few minutes, and makes a
399 * selection that waits for Orca once it answers.
400 *
401 * @returns what Orca said, when it was asked now and answered; null otherwise
402 */
403export async function tickOrca(ctx: AccountsContext): Promise<OrcaClaude | null> {
404 if (!(await isOrcaCheckDue(ctx.io))) return null
405 const reach = await orcaClaude(ctx.io, ctx.messages())
406 if (reach.kind !== 'ok') return null
407 await applyPendingOrca(ctx, reach.claude)
408
409 return reach.claude
410}
411
412/**
413 * From when a response's figures are the live account's: the moment it last
414 * changed, and on macOS without a credentials file, the keychain cache after
415 * it, during which a running session may still ask with the previous login.
416 */
417export async function figuresTrustedFrom(io: Io): Promise<number> {
418 const cached = (await platformOf(io)).backend === 'keychain' && !(await io.exists(await liveCredentialPath(io)))
419
420 return liveChangedAt + (cached ? KEYCHAIN_CACHE_MS : 0)
421}
422hooks/anthropic.ts 254 lines1import type { HttpInit } from 'claude-code'
2
3import type { LimitView, Money, SpendView, UsageView } from '../types'
4import type { Messages } from './i18n'
5import { isAccountId } from './keychain'
6import type { Credential } from './keychain'
7
8export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
9export const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
10export const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
11const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
12const OAUTH_BETA = 'oauth-2025-04-20'
13/** Refresh this long before the access token expires. */
14const EXPIRY_MARGIN_MS = 5 * 60 * 1000
15
16export class AnthropicError extends Error {
17 constructor(
18 message: string,
19 readonly status?: number,
20 /** The response's `Retry-After` header, when it sent one. */
21 readonly retryAfter?: string,
22 ) {
23 super(message)
24 }
25}
26
27type RawLimit = {
28 kind?: string
29 percent?: number
30 resets_at?: string | null
31 scope?: { model?: { display_name?: string | null } | null } | null
32}
33
34type RawWindow = { utilization?: number | null; resets_at?: string | null } | null
35
36type RawUsage = {
37 limits?: RawLimit[]
38 five_hour?: RawWindow
39 seven_day?: RawWindow
40}
41
42function labelOf(limit: RawLimit): string {
43 if (limit.kind === 'session') return '5h'
44 if (limit.kind === 'weekly_all') return 'wk'
45
46 return limit.scope?.model?.display_name ?? limit.kind ?? '?'
47}
48
49/** Turns the usage endpoint's body into the windows the pane draws. */
50export function parseUsage(body: unknown): LimitView[] {
51 const raw = (body ?? {}) as RawUsage
52 if (Array.isArray(raw.limits) && raw.limits.length > 0) {
53 return raw.limits
54 .filter(limit => typeof limit.percent === 'number')
55 .map(limit => ({ label: labelOf(limit), percent: limit.percent as number, resetsAt: limit.resets_at ?? undefined }))
56 }
57 const windows: [string, RawWindow | undefined][] = [
58 ['5h', raw.five_hour],
59 ['wk', raw.seven_day],
60 ]
61
62 return windows.flatMap(([label, window]) =>
63 typeof window?.utilization === 'number'
64 ? [{ label, percent: window.utilization, resetsAt: window.resets_at ?? undefined }]
65 : [],
66 )
67}
68
69type RawMoney = { amount_minor?: unknown; currency?: unknown; exponent?: unknown } | null
70
71/** An amount as the endpoint writes it, or undefined when any part is missing. */
72function moneyOf(raw: RawMoney | undefined): Money | undefined {
73 if (!raw || typeof raw.amount_minor !== 'number' || typeof raw.currency !== 'string' || typeof raw.exponent !== 'number') return undefined
74
75 return { minor: raw.amount_minor, currency: raw.currency, exponent: raw.exponent }
76}
77
78/**
79 * What the account spent past its plan, from the usage endpoint's `spend`:
80 * shown only when spending is on or something was spent, and only with every
81 * part of the amount given. Nothing is estimated.
82 */
83export function parseSpend(body: unknown): SpendView | undefined {
84 const spend = (body as { spend?: { used?: RawMoney; limit?: RawMoney; enabled?: unknown } | null } | null)?.spend
85 const used = moneyOf(spend?.used)
86 if (!spend || !used || (spend.enabled !== true && used.minor === 0)) return undefined
87 const limit = moneyOf(spend.limit)
88
89 return limit ? { used, limit } : { used }
90}
91
92/** An amount in its currency's units, as many decimals as the endpoint says: `12.34 USD`. */
93export function moneyText(money: Money): string {
94 return `${(money.minor / 10 ** money.exponent).toFixed(money.exponent)} ${money.currency}`
95}
96
97/** The usage request: with a bearer token, or with the session's own credential handle. */
98export function usageInit(auth: { token: string } | { handle: string }): HttpInit {
99 if ('handle' in auth) return { headers: { 'anthropic-beta': OAUTH_BETA }, auth: auth.handle }
100
101 return { headers: { 'anthropic-beta': OAUTH_BETA, Authorization: `Bearer ${auth.token}` } }
102}
103
104/** Whether a token expires within `marginMs` (five minutes unless said): refreshed before it is used. */
105export function needsRefresh(credential: Credential, now: number, marginMs = EXPIRY_MARGIN_MS): boolean {
106 return credential.claudeAiOauth.expiresAt - marginMs <= now
107}
108
109export function refreshInit(credential: Credential): HttpInit {
110 const oauth = credential.claudeAiOauth
111
112 return {
113 method: 'POST',
114 headers: { 'Content-Type': 'application/json' },
115 body: JSON.stringify({
116 grant_type: 'refresh_token',
117 refresh_token: oauth.refreshToken,
118 client_id: CLIENT_ID,
119 scope: (oauth.scopes ?? []).join(' '),
120 }),
121 }
122}
123
124/** The credential after a refresh; the server rotates the refresh token, so it must be saved. */
125export function applyRefresh(credential: Credential, responseText: string, now: number): Credential {
126 const body = JSON.parse(responseText) as { access_token?: string; refresh_token?: string; expires_in?: number }
127 if (typeof body.access_token !== 'string' || typeof body.expires_in !== 'number') {
128 throw new AnthropicError('token refresh returned no access token')
129 }
130 const oauth = credential.claudeAiOauth
131
132 return {
133 ...credential,
134 claudeAiOauth: {
135 ...oauth,
136 accessToken: body.access_token,
137 refreshToken: body.refresh_token ?? oauth.refreshToken,
138 expiresAt: now + body.expires_in * 1000,
139 },
140 }
141}
142
143/** Whose a token is, as the profile endpoint answers. */
144export type TokenOwner = {
145 accountUuid: string
146 emailAddress: string
147 organizationUuid?: string
148 organizationName?: string
149}
150
151export function parseProfile(text: string): TokenOwner {
152 const body = JSON.parse(text) as {
153 account?: { uuid?: string; email?: string }
154 organization?: { uuid?: string; name?: string }
155 }
156 if (typeof body.account?.uuid !== 'string' || typeof body.account.email !== 'string') {
157 throw new AnthropicError('profile endpoint named no account')
158 }
159 // The id names a keychain item and a vault file: one that could reach elsewhere is no account.
160 if (!isAccountId(body.account.uuid)) throw new AnthropicError(`profile endpoint named an account id this mod cannot file: ${JSON.stringify(body.account.uuid)}`)
161
162 return {
163 accountUuid: body.account.uuid,
164 emailAddress: body.account.email,
165 organizationUuid: body.organization?.uuid,
166 organizationName: body.organization?.name,
167 }
168}
169
170/**
171 * The reading after a failed lookup. A 429 keeps the previous reading as it
172 * was, only marked stale: the next lookup recovers by itself, so it is no
173 * message the person must read. Any other failure is said in words.
174 */
175export function failedReading(previous: UsageView | undefined, error: unknown, now: number, m: Messages): UsageView {
176 const kept = isLookedUp(previous) ? previous : undefined
177 // The spend kept is the last lookup's too, as the limits are.
178 const spend = kept?.spend ? { spend: kept.spend } : {}
179 if (error instanceof AnthropicError && error.status === 429) {
180 return { limits: kept?.limits ?? [], fetchedAt: kept?.fetchedAt ?? now, isStale: true, source: 'lookup', ...spend }
181 }
182
183 // The limits kept are the last lookup's, and so is the time they were looked up.
184 return { limits: kept?.limits ?? [], fetchedAt: kept?.fetchedAt ?? now, error: describeFailure(error, m), source: 'lookup', ...spend }
185}
186
187/**
188 * The reading of an inactive account whose token Orca keeps and this mod does
189 * not refresh: the last lookup's figures and time stay, marked held, and no
190 * error is said, as nothing failed.
191 */
192export function heldReading(previous: UsageView | undefined, now: number): UsageView {
193 const kept = isLookedUp(previous) ? previous : undefined
194
195 return { limits: kept?.limits ?? [], fetchedAt: kept?.fetchedAt ?? now, isHeld: true, source: 'lookup', ...(kept?.spend ? { spend: kept.spend } : {}) }
196}
197
198/** Whether a reading came from its own account's lookup, the one source trusted for its figures. */
199export function isLookedUp(reading: UsageView | undefined): reading is UsageView {
200 return reading?.source === 'lookup'
201}
202
203/** The readings that came from lookups; whatever else a store or a session held is dropped. */
204export function lookedUpOnly(readings: unknown): Record<string, UsageView> {
205 if (!readings || typeof readings !== 'object') return {}
206
207 return Object.fromEntries(Object.entries(readings as Record<string, UsageView>).filter(([, reading]) => isLookedUp(reading)))
208}
209
210/** One window as Claude Code's own response reported it. */
211export type MeasuredWindow = { kind: string; percentUsed: number; resetsAt?: string }
212
213/**
214 * The reading after Claude Code's own response reported its windows: the
215 * five-hour and weekly figures replaced, every other window (a model's
216 * weekly limit, which responses do not carry) kept from the last lookup.
217 * Only for a response the account in question answered.
218 */
219export function withMeasured(previous: UsageView | undefined, windows: MeasuredWindow[], now: number): UsageView {
220 const measured: LimitView[] = windows.flatMap(window => {
221 if (window.kind === 'five_hour') return [{ label: '5h', percent: window.percentUsed, resetsAt: window.resetsAt }]
222 if (window.kind === 'seven_day') return [{ label: 'wk', percent: window.percentUsed, resetsAt: window.resetsAt }]
223
224 return []
225 })
226 const labels = new Set(measured.map(limit => limit.label))
227 const kept = (isLookedUp(previous) ? previous.limits : []).filter(limit => !labels.has(limit.label))
228
229 // A measured window says nothing of spending: the last lookup's spend stays.
230 const spend = isLookedUp(previous) && previous.spend ? { spend: previous.spend } : {}
231
232 return { limits: [...measured, ...kept], fetchedAt: now, source: 'lookup', ...spend }
233}
234
235/** A failure as the pane shows it. */
236export function describeFailure(error: unknown, m: Messages): string {
237 if (error instanceof AnthropicError && (error.status === 400 || error.status === 401)) return m.authExpired
238 if (error instanceof AnthropicError && error.status !== undefined) return m.serverError(error.status)
239
240 return error instanceof Error ? error.message : String(error)
241}
242
243/**
244 * Whether the configured account's saved login may be put back in place of a
245 * rejected one: there is one, it is not the token rejected, it works without a
246 * refresh (several sessions refreshing it at once would spend it), and this
247 * session did not put one back within `gapMs`.
248 */
249export function mayHeal(saved: Credential | null, rejected: Credential, now: number, healedAt: number, gapMs: number): saved is Credential {
250 if (saved === null || now - healedAt < gapMs) return false
251
252 return saved.claudeAiOauth.accessToken !== rejected.claudeAiOauth.accessToken && !needsRefresh(saved, now)
253}
254hooks/cleanup.ts 208 lines1import type { StorageView } from '../types'
2import { countUsage, forgetTranscripts, loadUsageIndex } from './count'
3import type { CountContext } from './count'
4import { claudeDirectory, platformOf } from './credentials'
5import type { Io } from './io'
6import { message } from './io'
7import { removeTreeArgv } from './shared/files'
8import { byteSize, cleanupPlan, cleanupTargets, confirmedSessions, sessionIdsInCommands, sessionsOf, summarizeStorage } from './storage'
9import type { StorageFile, StorageSession } from './storage'
10import { projectOf } from './usage'
11
12/**
13 * What Claude Code keeps under its config directory, and the cleanup of idle
14 * sessions. A cleanup deletes only the sessions its dialog named when the
15 * person confirmed it, never this session nor one a running Claude Code
16 * process resumed, and counts every transcript first so their tokens stay.
17 */
18
19/** What the Storage tab reads and shows beyond the machine. */
20export type CleanupContext = {
21 io: Io
22 count: CountContext
23 storage: (view: StorageView) => Promise<void>
24 cleanupDays: () => Promise<number>
25 sessionId: () => Promise<string>
26 /** `cleanupPeriodDays` from Claude Code's settings, when set. */
27 autoCleanupDays: () => Promise<number | undefined>
28 homePath: () => string
29}
30
31/** Claude Code's own default for `cleanupPeriodDays`. */
32const AUTO_CLEANUP_DEFAULT_DAYS = 30
33/** How deep the Storage tab walks the projects folder: tool output sits in `<project>/<session>/tool-results/`. */
34const STORAGE_DEPTH = 6
35/** Project folders the Storage tab names: as many as it lists. */
36const STORAGE_PROJECTS_NAMED = 8
37/** Other folders the Storage tab lists, the largest first, and the smallest it lists. */
38const STORAGE_FOLDERS_SHOWN = 8
39const STORAGE_FOLDER_MIN_BYTES = 1024 * 1024
40/** Paths one deletion command takes at a time. */
41const DELETE_BATCH = 40
42
43/** The files under the projects folder as last measured, for the cleanup to choose from. */
44let storageFiles: StorageFile[] = []
45/** The sessions the cleanup dialog named, as `<folder>/<session>`: all a cleanup may delete. */
46let confirmed = new Set<string>()
47
48/** Every file under the projects folder, with its size and when it last changed. */
49async function projectFiles(io: Io, root: string): Promise<StorageFile[]> {
50 if (!(await io.exists(root))) return []
51 const files: StorageFile[] = []
52 const walk = async (relative: string, depth: number): Promise<void> => {
53 for (const entry of await io.list(relative === '' ? root : `${root}/${relative}`)) {
54 const child = relative === '' ? entry.name : `${relative}/${entry.name}`
55 if (entry.kind === 'dir' && !entry.isLink && depth < STORAGE_DEPTH) await walk(child, depth + 1)
56 else if (entry.kind === 'file') files.push({ relative: child, size: entry.size, mtimeMs: entry.mtimeMs })
57 }
58 }
59 await walk('', 1)
60
61 return files
62}
63
64/** The sizes of the config directory's other folders, by `du`; none where there is no POSIX shell. */
65async function otherFolders(io: Io, directory: string): Promise<{ name: string; bytes: number }[]> {
66 const names = (await io.list(directory)).filter(entry => entry.kind === 'dir' && entry.name !== 'projects').map(entry => entry.name)
67 if (names.length === 0) return []
68 const { exitCode, stdout } = await io.run(['du', '-sk', '--', ...names.map(name => `${directory}/${name}`)], { timeoutMs: 60_000 })
69 if (exitCode !== 0 && stdout.trim() === '') return []
70
71 return stdout
72 .split('\n')
73 .map(line => line.split('\t'))
74 .filter(parts => parts.length === 2 && /^\d+$/.test(parts[0] ?? ''))
75 .map(([kb = '0', path = '']) => ({ name: path.slice(directory.length + 1), bytes: Number(kb) * 1024 }))
76 .filter(folder => folder.bytes >= STORAGE_FOLDER_MIN_BYTES)
77 .sort((a, b) => b.bytes - a.bytes)
78 .slice(0, STORAGE_FOLDERS_SHOWN)
79}
80
81/** The project a folder's newest session transcript worked in, read from its first `cwd`; undefined when none is found. */
82async function recordedProject(io: Io, root: string, folder: string, files: StorageFile[]): Promise<string | undefined> {
83 const newest = files
84 // A session's own transcript: a subagent's may have worked in a worktree of its own.
85 .filter(file => file.relative.startsWith(`${folder}/`) && file.relative.endsWith('.jsonl') && file.relative.split('/').length === 2)
86 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
87 if (!newest) return undefined
88 const { exitCode, stdout } = await io.run(['sh', '-c', 'head -c 262144 "$1" | grep -a -o -m 1 \'"cwd":"[^"]*"\'', 'sh', `${root}/${newest.relative}`], { timeoutMs: 10_000 })
89 const cwd = exitCode === 0 ? /"cwd":"([^"]*)"/.exec(stdout)?.[1] : undefined
90
91 return cwd ? projectOf(cwd.replace(/\\\\/g, '\\')) : undefined
92}
93
94/** Measures what the config directory holds, for the Storage tab. */
95export async function measureStorage(ctx: CleanupContext): Promise<void> {
96 const { io } = ctx
97 const directory = await claudeDirectory(io)
98 const files = await projectFiles(io, `${directory}/projects`)
99 storageFiles = files
100 const summary = summarizeStorage(files)
101 // A folder is named after the project its sessions worked in, as the usage count recorded it.
102 const { index } = await loadUsageIndex(ctx.count)
103 const votes = new Map<string, Map<string, number>>()
104 for (const session of sessionsOf(files)) {
105 const project = index.sessions[session.session]?.projects[0]
106 if (!project) continue
107 const tally = votes.get(session.folder) ?? new Map<string, number>()
108 tally.set(project, (tally.get(project) ?? 0) + 1)
109 votes.set(session.folder, tally)
110 }
111 const voted = (folder: string) => [...(votes.get(folder)?.entries() ?? [])].sort((a, b) => b[1] - a[1])[0]?.[0]
112 // A folder the count knows nothing of is named from the directory its newest transcript records.
113 const names = new Map<string, string>()
114 for (const project of summary.projects.slice(0, STORAGE_PROJECTS_NAMED)) {
115 const name = voted(project.folder) ?? (await recordedProject(io, `${directory}/projects`, project.folder, files))
116 if (name) names.set(project.folder, name)
117 }
118 const configured = await ctx.autoCleanupDays()
119 let folders: { name: string; bytes: number }[] = []
120 try {
121 folders = await otherFolders(io, directory)
122 } catch (error) {
123 io.log(message(error))
124 }
125 await ctx.storage({
126 ...summary,
127 projects: summary.projects.map(project => ({ ...project, name: names.get(project.folder) ?? project.folder })),
128 folders,
129 autoDays: configured ?? AUTO_CLEANUP_DEFAULT_DAYS,
130 isAutoDefault: configured === undefined,
131 root: directory.replace(ctx.homePath(), '~'),
132 })
133}
134
135/** The sessions running Claude Code processes resumed by id; none where the processes cannot be listed. */
136async function runningSessions(io: Io): Promise<string[]> {
137 const { isWindows } = await platformOf(io)
138 const argv = isWindows
139 ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | ForEach-Object { $_.CommandLine }']
140 : ['ps', '-axo', 'command=']
141 try {
142 const listed = await io.run(argv, { timeoutMs: 10_000 })
143
144 return listed.exitCode === 0 ? sessionIdsInCommands(listed.stdout) : []
145 } catch (error) {
146 io.log(message(error))
147
148 return []
149 }
150}
151
152/** The sessions a cleanup at the chosen age would delete, as last measured: never this one, nor one a running process resumed. */
153export async function plannedCleanup(ctx: CleanupContext): Promise<{ sessions: StorageSession[]; bytes: number }> {
154 const keep = [await ctx.sessionId(), ...(await runningSessions(ctx.io))]
155
156 return cleanupPlan(sessionsOf(storageFiles), await ctx.cleanupDays(), await ctx.io.now(), keep)
157}
158
159/** What the cleanup dialog names: the sessions and their bytes as measured when it opened. */
160let asked: { count: number; bytes: number } = { count: 0, bytes: 0 }
161
162/** Names what the dialog shows, measured now, as all a cleanup confirmed from it may delete. */
163export async function askCleanup(ctx: CleanupContext): Promise<{ count: number; bytes: number }> {
164 await measureStorage(ctx)
165 const plan = await plannedCleanup(ctx)
166 confirmed = new Set(plan.sessions.map(session => `${session.folder}/${session.session}`))
167 asked = { count: plan.sessions.length, bytes: plan.bytes }
168
169 return asked
170}
171
172/** What the open cleanup dialog names. */
173export function askedCleanup(): { count: number; bytes: number } {
174 return asked
175}
176
177/**
178 * Deletes the sessions the confirmed dialog named that are still idle now:
179 * each transcript and the session's folder. Every path is checked to lie
180 * under the projects folder first; one that does not stops the whole cleanup
181 * before anything is deleted. The usage figures already counted stay.
182 */
183export async function cleanUp(ctx: CleanupContext, words: { nothing: (days: number) => string; uncounted: (files: number) => string; needsShell: string; done: (count: number, size: string) => string }): Promise<string> {
184 const { io } = ctx
185 // Measured again now: a session resumed since the dialog opened is no longer idle.
186 await measureStorage(ctx)
187 const sessions = confirmedSessions((await plannedCleanup(ctx)).sessions, confirmed)
188 confirmed = new Set()
189 if (sessions.length === 0) return words.nothing(await ctx.cleanupDays())
190 // Every transcript is counted before any is deleted, so the usage figures keep their tokens;
191 // one that could not be counted stops the cleanup.
192 const uncounted = await countUsage(ctx.count, true)
193 if ((await ctx.count.usageError.get()) !== null) throw new Error(words.needsShell)
194 if (uncounted.length > 0) throw new Error(words.uncounted(uncounted.length))
195 const root = `${await claudeDirectory(io)}/projects`
196 const paths = cleanupTargets(root, sessions.flatMap(session => session.paths))
197 const { isWindows } = await platformOf(io)
198 for (let start = 0; start < paths.length; start += DELETE_BATCH) {
199 const { exitCode, stderr } = await io.run(removeTreeArgv(paths.slice(start, start + DELETE_BATCH), isWindows), { timeoutMs: 120_000 })
200 if (exitCode !== 0) throw new Error(`deleting sessions exited ${exitCode}: ${stderr.trim()}`)
201 }
202 // The count forgets the deleted transcripts' offsets; their tokens stay counted.
203 await forgetTranscripts(ctx.count, paths)
204 await measureStorage(ctx)
205
206 return words.done(sessions.length, byteSize(sessions.reduce((sum, session) => sum + session.bytes, 0)))
207}
208hooks/count.ts 259 lines1import type { UsageSummary } from '../types'
2import { claudeDirectory, platformOf } from './credentials'
3import type { Messages } from './i18n'
4import type { Cell, Io } from './io'
5import { message } from './io'
6import { LockBusyError, acquire } from './lock'
7import type { Lock } from './lock'
8import { addRecords, asIndex, emptyIndex, indexToStore, localDay, parseScan, scannedBytes, SCAN_SCRIPT, sessionOf, summarize } from './usage'
9import type { UsageIndex } from './usage'
10import { writeAtomic } from './writes'
11
12/**
13 * Tokens counted from this machine's transcripts. The count is the machine's,
14 * kept in files under the mod's folder, and every session may add to it, so
15 * one session counts at a time, under a lock, starting from the count as the
16 * files hold it: a session that counted from a copy it read earlier would
17 * write back a count that lacks what another session added meanwhile.
18 */
19
20/** What counting reads and shows beyond the machine. */
21export type CountContext = {
22 io: Io
23 messages: () => Messages
24 usagePeriod: () => Promise<number>
25 usageSummary: (summary: UsageSummary) => Promise<void>
26 usageScan: (scan: { done: number; total: number } | null) => Promise<void>
27 usageError: Cell<string | null>
28 /** Whether the Usage tab is on screen, so a long first count goes on chunk after chunk. */
29 isUsageShown: () => Promise<boolean>
30}
31
32/** Bytes of transcripts one count reads before it lets the pane draw and goes on. */
33const USAGE_SCAN_BYTES = 256 * 1024 * 1024
34/** How deep transcripts sit under the projects folder: `<project>/<session>/subagents/<agent>.jsonl`. */
35const TRANSCRIPT_DEPTH = 4
36/** The count's lock, touched after each chunk; a chunk takes two minutes at most. */
37const COUNT_LOCK = { staleMs: 4 * 60 * 1000, tries: 1 }
38/** Waiting for another session's count to finish before a cleanup counts to the end. */
39const COUNT_LOCK_WAITING = { staleMs: 4 * 60 * 1000, tries: 150 }
40
41/** The count as last read or written by this session, and the files' time and size then. */
42let cached: { index: UsageIndex; seen: Set<string>; stamp: string } | null = null
43/** Whether a count is running in this session. */
44let isCounting = false
45
46async function folder(io: Io): Promise<string> {
47 return `${await claudeDirectory(io)}/sc-accounts`
48}
49
50async function indexPath(io: Io): Promise<string> {
51 return `${await folder(io)}/usage-index.json`
52}
53
54/** The file holding the `n`th part of the counted message ids. */
55async function idsPath(io: Io, n: number): Promise<string> {
56 return `${await folder(io)}/usage-ids-${n}.json`
57}
58
59/** The index file's time and size, which change with every write by any session. */
60async function stampOf(io: Io): Promise<string> {
61 const path = await indexPath(io)
62 if (!(await io.exists(path))) return 'none'
63 const { mtimeMs, size } = await io.stat(path)
64
65 return `${mtimeMs}:${size}`
66}
67
68/** The count as the files hold it, read again only when another session wrote it since. */
69export async function loadUsageIndex(ctx: CountContext): Promise<{ index: UsageIndex; seen: Set<string> }> {
70 const { io } = ctx
71 const stamp = await stampOf(io)
72 if (cached?.stamp === stamp) return cached
73 let index = emptyIndex()
74 try {
75 if (stamp !== 'none') {
76 const raw = JSON.parse(await io.read(await indexPath(io))) as { idFiles?: unknown }
77 const read = asIndex(raw)
78 // The ids live in files of their own; an index written before that holds them itself.
79 const ids = [...read.ids]
80 const parts = typeof raw.idFiles === 'number' ? raw.idFiles : 0
81 for (let n = 0; n < parts; n += 1) ids.push(...(JSON.parse(await io.read(await idsPath(io, n))) as string[]))
82 index = { ...read, ids }
83 }
84 } catch (error) {
85 // A count that does not read is counted again from the start.
86 io.log(message(error))
87 index = emptyIndex()
88 }
89 cached = { index, seen: new Set(index.ids), stamp }
90
91 return cached
92}
93
94/** Writes the count, each file replaced whole: its ids in files of their own, then the index naming how many. */
95async function saveUsageIndex(ctx: CountContext, index: UsageIndex, seen: Set<string>): Promise<void> {
96 const { io } = ctx
97 const { isWindows } = await platformOf(io)
98 const stored = indexToStore(index, [...seen])
99 for (const [n, part] of stored.idFiles.entries()) await writeAtomic(io, await idsPath(io, n), JSON.stringify(part), { isPrivate: false, isWindows })
100 await writeAtomic(io, await indexPath(io), JSON.stringify(stored.index), { isPrivate: false, isWindows })
101 cached = { index, seen, stamp: await stampOf(io) }
102}
103
104/** Takes the count's lock: once, or waiting for another session's count when `isWaiting`; null when it stays held. */
105async function takeCountLock(ctx: CountContext, isWaiting: boolean): Promise<Lock | null> {
106 const { isWindows } = await platformOf(ctx.io)
107 try {
108 return await acquire(ctx.io, `${await folder(ctx.io)}/locks/usage-count.lock`, { ...(isWaiting ? COUNT_LOCK_WAITING : COUNT_LOCK), isWindows })
109 } catch (error) {
110 if (error instanceof LockBusyError) return null
111 throw error
112 }
113}
114
115/**
116 * Every transcript under the config directory with its size: each session's,
117 * and each subagent's, which counts toward the session that started it.
118 */
119async function transcriptFiles(io: Io): Promise<{ path: string; session: string; size: number }[]> {
120 const root = `${await claudeDirectory(io)}/projects`
121 if (!(await io.exists(root))) return []
122 const files: { path: string; session: string; size: number }[] = []
123 const walk = async (relative: string, depth: number): Promise<void> => {
124 for (const entry of await io.list(`${root}/${relative}`)) {
125 const child = relative === '' ? entry.name : `${relative}/${entry.name}`
126 if (entry.kind === 'dir' && depth < TRANSCRIPT_DEPTH) await walk(child, depth + 1)
127 else if (entry.kind === 'file' && entry.name.endsWith('.jsonl') && depth >= 2) files.push({ path: `${root}/${child}`, session: sessionOf(child), size: entry.size })
128 }
129 }
130 await walk('', 1)
131
132 return files
133}
134
135/** The Usage tab's figures for its period, from the count as the files hold it. */
136export async function refreshUsageSummary(ctx: CountContext): Promise<void> {
137 const { index } = await loadUsageIndex(ctx)
138 const period = await ctx.usagePeriod()
139 const today = localDay(new Date(await ctx.io.now()).toISOString())
140 await ctx.usageSummary(summarize(index, period === 0 ? null : period, today))
141}
142
143/**
144 * Counts what the transcripts gained since the last count, up to
145 * `USAGE_SCAN_BYTES` at a time, saying how far it has got; while the Usage tab
146 * is on screen it goes on until every transcript is counted. With
147 * `isToTheEnd` it counts everything in this call, waiting for another
148 * session's count first (before a cleanup deletes transcripts, so their tokens
149 * are counted first).
150 *
151 * @returns the transcripts that could not be counted; with `isToTheEnd`, a
152 * count another session held past the wait is all of them
153 */
154export async function countUsage(ctx: CountContext, isToTheEnd = false): Promise<string[]> {
155 const { io } = ctx
156 if (isCounting && !isToTheEnd) return []
157 // A count to the end waits for one already running here, then reads what it left.
158 while (isCounting) await io.sleep(200)
159 isCounting = true
160 try {
161 const lock = await takeCountLock(ctx, isToTheEnd)
162 if (lock === null) {
163 // Another session counts now: its figures show once it has written them.
164 if (!isToTheEnd) {
165 await refreshUsageSummary(ctx)
166
167 return []
168 }
169
170 return (await transcriptFiles(io)).map(file => file.path)
171 }
172 try {
173 return await countHolding(ctx, lock, isToTheEnd)
174 } finally {
175 await lock.release().catch((error: unknown) => io.log(message(error)))
176 }
177 } finally {
178 isCounting = false
179 }
180}
181
182async function countHolding(ctx: CountContext, lock: Lock, isToTheEnd: boolean): Promise<string[]> {
183 const { io } = ctx
184 await ctx.usageError.set(null)
185 const loaded = await loadUsageIndex(ctx)
186 let index = loaded.index
187 const seen = new Set(loaded.seen)
188 const all = await transcriptFiles(io)
189 const pending = all.filter(file => (index.files[file.path]?.offset ?? 0) < file.size)
190 if (pending.length === 0) {
191 await ctx.usageScan(null)
192 await refreshUsageSummary(ctx)
193
194 return []
195 }
196 // The progress is of every transcript: those counted before, in an earlier session too, are done.
197 const total = all.length
198 await ctx.usageScan({ done: total - pending.length, total })
199 let budget = isToTheEnd ? Number.POSITIVE_INFINITY : USAGE_SCAN_BYTES
200 let done = 0
201 let hasMoved = false
202 const failed: string[] = []
203 for (const file of pending) {
204 if (budget <= 0) break
205 // A transcript is read a chunk at a time, so one of gigabytes never meets the time limit whole.
206 let offset = index.files[file.path]?.offset ?? 0
207 let isFailed = false
208 while (offset < file.size && budget > 0) {
209 const to = Math.min(file.size, offset + USAGE_SCAN_BYTES, offset + budget)
210 const { exitCode, stdout, stderr } = await io.run(['sh', '-c', SCAN_SCRIPT, 'sh', file.path, String(to), String(offset + 1), String(to - offset)], { timeoutMs: 120_000 })
211 await lock.touch()
212 if (exitCode === -1 && /failed to start|ENOENT/.test(stderr)) {
213 await ctx.usageError.set(ctx.messages().usageNeedsShell)
214
215 return [file.path]
216 }
217 if (exitCode !== 0) {
218 io.log(`usage count of ${file.path} exited ${exitCode}: ${stderr.trim()}`)
219 isFailed = true
220 break
221 }
222 const next = offset + scannedBytes(stdout)
223 index = addRecords(index, seen, file.path, file.session, parseScan(stdout), next)
224 budget -= to - offset
225 // Only a line still being written is left: the file is done for this pass.
226 if (next === offset) break
227 hasMoved = true
228 offset = next
229 }
230 if (isFailed) failed.push(file.path)
231 // A failed file counts as done for this pass, so the count never retries it without end.
232 if (isFailed || budget > 0 || offset >= file.size) done += 1
233 }
234 await saveUsageIndex(ctx, index, seen)
235 await refreshUsageSummary(ctx)
236 const left = pending.length - done
237 if (left > 0 && hasMoved && (await ctx.isUsageShown())) {
238 await ctx.usageScan({ done: total - left, total })
239 io.after(100, () => void countUsage(ctx).catch((error: unknown) => io.log(message(error))))
240 } else {
241 await ctx.usageScan(null)
242 }
243
244 return failed
245}
246
247/** Forgets the offsets of transcripts a cleanup deleted, under the count's lock; their tokens stay counted. */
248export async function forgetTranscripts(ctx: CountContext, deleted: readonly string[]): Promise<void> {
249 const lock = await takeCountLock(ctx, true)
250 if (lock === null) throw new Error(`the usage count is held by another session`)
251 try {
252 const { index, seen } = await loadUsageIndex(ctx)
253 const gone = (file: string) => deleted.some(path => file === path || file.startsWith(`${path}/`))
254 await saveUsageIndex(ctx, { ...index, files: Object.fromEntries(Object.entries(index.files).filter(([file]) => !gone(file))) }, seen)
255 } finally {
256 await lock.release().catch((error: unknown) => ctx.io.log(message(error)))
257 }
258}
259hooks/credentials.ts 401 lines1import type { HttpResponse } from 'claude-code'
2
3import { AnthropicError, TOKEN_URL, applyRefresh, needsRefresh, refreshInit } from './anthropic'
4import type { Io } from './io'
5import { message, within } from './io'
6import {
7 ITEM_NOT_FOUND,
8 KeychainError,
9 ORCA_COPY_SERVICE,
10 VAULT_SERVICE,
11 addLine,
12 credentialsDirectory,
13 deleteArgv,
14 fileLagsKeychain,
15 findArgv,
16 isAccountId,
17 isSameGrant,
18 keychainAccountName,
19 liveServiceName,
20 parseCredential,
21} from './keychain'
22import type { Credential, StorageVariables } from './keychain'
23import { withLock } from './lock'
24import { deleteFileArgv, detectPlatform, vaultFilePath } from './platform'
25import type { Platform } from './platform'
26import { writeAtomic } from './writes'
27
28/**
29 * Where the logins live: the one Claude Code uses (its keychain item on macOS,
30 * `.credentials.json` elsewhere, and `oauthAccount` in its global config), the
31 * saved ones (the vault: keychain items, or owner-only files), and the
32 * webhook's token. Every write to a file or item Claude Code also writes takes
33 * Claude Code's own lock for it first, and every file is replaced whole.
34 */
35
36/** The `oauthAccount` object Claude Code keeps in its global config, kept whole. */
37export type OauthAccount = {
38 accountUuid: string
39 emailAddress: string
40 organizationName?: string
41 [field: string]: unknown
42}
43
44/** Directory under Claude Code's config directory holding saved logins on the file backend. */
45const VAULT_DIRECTORY = 'account-switch'
46/** Where the webhook's bearer token is kept: its own keychain service, or an owner-only file. */
47const WEBHOOK_SERVICE = 'sc-webhook'
48const WEBHOOK_TOKEN_ACCOUNT = 'token'
49/** Claude Code's lock over its login store (`.storage-write`), stale as its own is after 15 s. */
50const STORAGE_LOCK = { staleMs: 15_000, tries: 12 }
51/** Claude Code's lock over its global config (`.claude.json.lock`), stale as proper-lockfile's default after 10 s. */
52const CONFIG_LOCK = { staleMs: 10_000, tries: 12 }
53/** This mod's lock over one saved account's refresh, held across sessions. */
54const REFRESH_LOCK = { staleMs: 60_000, tries: 20 }
55/** How long a token refresh may take, as Claude Code allows its own. */
56export const REFRESH_TIMEOUT_MS = 30_000
57
58let platformCache: Platform | undefined
59
60export async function platformOf(io: Io): Promise<Platform> {
61 if (platformCache) return platformCache
62 const osVariable = await io.env('OS')
63 let kernelName: string | undefined
64 if (osVariable !== 'Windows_NT') {
65 try {
66 const uname = await io.run(['uname', '-s'], { timeoutMs: 5000 })
67 kernelName = uname.exitCode === 0 ? uname.stdout : undefined
68 } catch (error) {
69 io.log(`uname: ${message(error)}`)
70 }
71 }
72 platformCache = detectPlatform(osVariable, kernelName)
73
74 return platformCache
75}
76
77/** `HOME`, or `USERPROFILE` on Windows. */
78export async function homeDirectory(io: Io): Promise<string> {
79 const home = (await io.env('HOME')) || (await io.env('USERPROFILE'))
80 if (!home) throw new Error('neither HOME nor USERPROFILE is set')
81
82 return home.replaceAll('\\', '/')
83}
84
85/** Claude Code's config directory: `CLAUDE_CONFIG_DIR`, else `~/.claude`. */
86export async function claudeDirectory(io: Io): Promise<string> {
87 return (await io.env('CLAUDE_CONFIG_DIR')) || `${await homeDirectory(io)}/.claude`
88}
89
90export async function globalConfigPath(io: Io): Promise<string> {
91 const configDir = await io.env('CLAUDE_CONFIG_DIR')
92
93 return configDir ? `${configDir}/.claude.json` : `${await homeDirectory(io)}/.claude.json`
94}
95
96/** The environment variables that decide where Claude Code keeps its login. */
97async function storageVariables(io: Io): Promise<StorageVariables> {
98 return { configDir: await io.env('CLAUDE_CONFIG_DIR'), secureStorageDir: await io.env('CLAUDE_SECURESTORAGE_CONFIG_DIR') }
99}
100
101/** The folder Claude Code keeps `.credentials.json` and its login store's lock in. */
102async function storageDirectory(io: Io): Promise<string> {
103 return credentialsDirectory(await storageVariables(io), `${await homeDirectory(io)}/.claude`)
104}
105
106/** `.credentials.json`: Claude Code's login on the file backend, and its plaintext copy on macOS. */
107export async function liveCredentialPath(io: Io): Promise<string> {
108 return `${await storageDirectory(io)}/.credentials.json`
109}
110
111/** The keychain item Claude Code reads its login from, as it names it for this session's environment; read once. */
112let liveItemCache: { service: string; account: string } | undefined
113
114async function liveItem(io: Io): Promise<{ service: string; account: string }> {
115 liveItemCache ??= {
116 service: await liveServiceName(await storageVariables(io), `${await homeDirectory(io)}/.claude`),
117 account: keychainAccountName(await io.env('USER')),
118 }
119
120 return liveItemCache
121}
122
123async function readIfPresent(io: Io, path: string): Promise<string | null> {
124 return (await io.exists(path)) ? io.read(path) : null
125}
126
127async function findSecret(io: Io, service: string, account?: string): Promise<string | null> {
128 const { exitCode, stdout, stderr } = await io.run(findArgv(service, account), { timeoutMs: 5000 })
129 if (exitCode === ITEM_NOT_FOUND) return null
130 if (exitCode !== 0) throw new KeychainError(`security exited ${exitCode}: ${stderr.trim()}`)
131
132 return stdout
133}
134
135async function storeSecret(io: Io, service: string, account: string, text: string): Promise<void> {
136 const { exitCode, stderr } = await io.run(['security', '-i'], { stdin: addLine(service, account, text), timeoutMs: 5000 })
137 if (exitCode !== 0) throw new KeychainError(`security exited ${exitCode}: ${stderr.trim()}`)
138}
139
140/** Runs `work` holding Claude Code's own lock over its login store, as its writes do. */
141async function underStorageLock<T>(io: Io, work: () => Promise<T>): Promise<T> {
142 const { isWindows } = await platformOf(io)
143
144 return withLock(io, `${await storageDirectory(io)}/.storage-write.lock`, { ...STORAGE_LOCK, isWindows }, work)
145}
146
147/** The login Claude Code uses now. */
148export async function readLiveCredential(io: Io): Promise<Credential | null> {
149 let text: string | null
150 if ((await platformOf(io)).backend === 'keychain') {
151 const item = await liveItem(io)
152 text = await findSecret(io, item.service, item.account)
153 } else {
154 text = await readIfPresent(io, await liveCredentialPath(io))
155 }
156
157 return text === null ? null : parseCredential(text)
158}
159
160/** Writes Claude Code's keychain item on macOS; the file backend keeps its login in the file alone. */
161export async function writeLiveKeychain(io: Io, credential: Credential): Promise<void> {
162 if ((await platformOf(io)).backend === 'file') return
163 const item = await liveItem(io)
164 await underStorageLock(io, () => storeSecret(io, item.service, item.account, JSON.stringify(credential)))
165}
166
167/**
168 * Writes `.credentials.json`: the login itself on the file backend, and on
169 * macOS the plaintext copy, only when that file exists. Claude Code compares
170 * the file's modification time before each token check, so the write is what
171 * makes a running session drop its cached login at once.
172 */
173export async function writeLiveFile(io: Io, credential: Credential): Promise<void> {
174 const path = await liveCredentialPath(io)
175 const { backend, isWindows } = await platformOf(io)
176 if (backend === 'keychain' && !(await io.exists(path))) return
177 await underStorageLock(io, () => writeAtomic(io, path, JSON.stringify(credential), { isPrivate: true, isWindows }))
178}
179
180/**
181 * On macOS, brings `.credentials.json` up to the keychain's login when Claude
182 * Code refreshed into the keychain alone: the file's change is what makes the
183 * other sessions drop the token whose refresh token that refresh spent.
184 */
185export async function keepCredentialFileInStep(io: Io, keychain: Credential): Promise<void> {
186 if ((await platformOf(io)).backend !== 'keychain') return
187 const text = await readIfPresent(io, await liveCredentialPath(io))
188 const file = text === null ? null : parseCredential(text)
189 if (fileLagsKeychain(file, keychain)) await writeLiveFile(io, keychain)
190}
191
192async function vaultPath(io: Io, uuid: string): Promise<string> {
193 return vaultFilePath(await claudeDirectory(io), VAULT_DIRECTORY, uuid)
194}
195
196function checkedId(uuid: string): string {
197 if (!isAccountId(uuid)) throw new KeychainError(`refused an account id this mod cannot file: ${JSON.stringify(uuid)}`)
198
199 return uuid
200}
201
202/** One saved account's login. */
203export async function readVault(io: Io, uuid: string): Promise<Credential | null> {
204 const text = (await platformOf(io)).backend === 'keychain' ? await findSecret(io, VAULT_SERVICE, checkedId(uuid)) : await readIfPresent(io, await vaultPath(io, uuid))
205
206 return text === null ? null : parseCredential(text)
207}
208
209export async function writeVault(io: Io, uuid: string, credential: Credential): Promise<void> {
210 const text = JSON.stringify(credential)
211 const { backend, isWindows } = await platformOf(io)
212 if (backend === 'keychain') await storeSecret(io, VAULT_SERVICE, checkedId(uuid), text)
213 else await writeAtomic(io, await vaultPath(io, uuid), text, { isPrivate: true, isWindows })
214}
215
216export async function deleteVault(io: Io, uuid: string): Promise<void> {
217 const platform = await platformOf(io)
218 if (platform.backend === 'file') {
219 const path = await vaultPath(io, uuid)
220 const { exitCode, stderr } = await io.run(deleteFileArgv(path, platform.isWindows), { timeoutMs: 5000 })
221 if (exitCode !== 0) throw new Error(`cannot delete ${path}: ${stderr.trim()}`)
222
223 return
224 }
225 const { exitCode, stderr } = await io.run(deleteArgv(VAULT_SERVICE, checkedId(uuid)), { timeoutMs: 5000 })
226 if (exitCode !== 0 && exitCode !== ITEM_NOT_FOUND) throw new KeychainError(`security exited ${exitCode}: ${stderr.trim()}`)
227}
228
229async function webhookTokenPath(io: Io): Promise<string> {
230 return `${await claudeDirectory(io)}/sc-accounts/webhook-token`
231}
232
233/** The webhook's bearer token, or null when none is kept. */
234export async function readWebhookToken(io: Io): Promise<string | null> {
235 const token = (await platformOf(io)).backend === 'keychain' ? await findSecret(io, WEBHOOK_SERVICE, WEBHOOK_TOKEN_ACCOUNT) : await readIfPresent(io, await webhookTokenPath(io))
236
237 return token === null || token.trim() === '' ? null : token.trim()
238}
239
240/** Keeps the webhook's bearer token, through stdin as every secret here; never in a command line. */
241export async function writeWebhookToken(io: Io, token: string): Promise<void> {
242 const { backend, isWindows } = await platformOf(io)
243 if (backend === 'keychain') await storeSecret(io, WEBHOOK_SERVICE, WEBHOOK_TOKEN_ACCOUNT, token)
244 else await writeAtomic(io, await webhookTokenPath(io), token, { isPrivate: true, isWindows })
245}
246
247export async function deleteWebhookToken(io: Io): Promise<void> {
248 const platform = await platformOf(io)
249 if (platform.backend === 'file') {
250 const path = await webhookTokenPath(io)
251 if (!(await io.exists(path))) return
252 const { exitCode, stderr } = await io.run(deleteFileArgv(path, platform.isWindows), { timeoutMs: 5000 })
253 if (exitCode !== 0) throw new Error(`cannot delete ${path}: ${stderr.trim()}`)
254
255 return
256 }
257 const { exitCode, stderr } = await io.run(deleteArgv(WEBHOOK_SERVICE, WEBHOOK_TOKEN_ACCOUNT), { timeoutMs: 5000 })
258 if (exitCode !== 0 && exitCode !== ITEM_NOT_FOUND) throw new KeychainError(`security exited ${exitCode}: ${stderr.trim()}`)
259}
260
261/** The account Claude Code's global config names, as last read, by the file's time and size: the file is read again only when it changed. */
262let configCache: { mtimeMs: number; size: number; account: OauthAccount | null } | undefined
263
264/** The login Claude Code's global config names, or null with none (or one whose id this mod cannot file). */
265export async function liveOauthAccount(io: Io): Promise<OauthAccount | null> {
266 const path = await globalConfigPath(io)
267 // Without the file's time and size the file is read as it is, and nothing is kept.
268 const stamp = await io.stat(path).catch(() => null)
269 if (stamp !== null && configCache?.mtimeMs === stamp.mtimeMs && configCache.size === stamp.size) return configCache.account
270 const config = JSON.parse(await io.read(path)) as { oauthAccount?: OauthAccount }
271 const account = isAccountId(config.oauthAccount?.accountUuid) && typeof config.oauthAccount.emailAddress === 'string' ? config.oauthAccount : null
272 configCache = stamp === null ? undefined : { mtimeMs: stamp.mtimeMs, size: stamp.size, account }
273
274 return account
275}
276
277/**
278 * Sets `oauthAccount` in Claude Code's global config, or takes it out with
279 * null, under Claude Code's own lock over that file and reading it again
280 * there, so a write of Claude Code's own between the read and the write is
281 * never lost; the file is replaced whole, owner-only as Claude Code keeps it.
282 */
283export async function writeLiveOauthAccount(io: Io, account: OauthAccount | null): Promise<void> {
284 const path = await globalConfigPath(io)
285 const { isWindows } = await platformOf(io)
286 await withLock(io, `${path}.lock`, { ...CONFIG_LOCK, isWindows }, async () => {
287 const { oauthAccount: _was, ...rest } = JSON.parse(await io.read(path)) as Record<string, unknown>
288 const next = account === null ? rest : { ...rest, oauthAccount: account }
289 await writeAtomic(io, path, `${JSON.stringify(next, null, 2)}\n`, { isPrivate: true, isWindows })
290 })
291 configCache = undefined
292}
293
294/**
295 * Where Orca keeps its copy of one account's login: on macOS a keychain item
296 * named by Orca's account id, elsewhere `.credentials.json` in the account's
297 * folder, which Orca marks as its own.
298 */
299export type OrcaCopyPlace = { kind: 'keychain'; orcaId: string } | { kind: 'file'; path: string }
300
301async function readOrcaCopy(io: Io, place: OrcaCopyPlace): Promise<string | null> {
302 return place.kind === 'keychain' ? findSecret(io, ORCA_COPY_SERVICE, place.orcaId) : readIfPresent(io, place.path)
303}
304
305async function writeOrcaCopy(io: Io, place: OrcaCopyPlace, text: string): Promise<void> {
306 if (place.kind === 'keychain') await storeSecret(io, ORCA_COPY_SERVICE, place.orcaId, text)
307 else await writeAtomic(io, place.path, text, { isPrivate: true, isWindows: (await platformOf(io)).isWindows })
308}
309
310/** What keeping Orca's copy of a saved login fresh came to: refreshed, fresh enough, or no copy to refresh. */
311export type OrcaCopyStep = 'refreshed' | 'fresh' | 'absent'
312
313/**
314 * Refreshes a saved login Orca keeps a copy of once either copy expires within
315 * `marginMs`, and writes the new grant to this mod's copy and to Orca's. Orca
316 * applies its copy as it is while a Claude terminal runs in it, so an expired
317 * or spent copy has every running session refresh a dead token at once, and
318 * all of them are signed out.
319 *
320 * Two copies of different grants are usually one grant rotated in one place
321 * and not the other, the older refresh token already spent; or two logins
322 * made apart, both alive. So the grant that expires later is refreshed first,
323 * the other only when the server refuses it, and whichever refreshes is
324 * written to both. It runs under the account's refresh lock, the copies read
325 * again inside it.
326 */
327export async function refreshOrcaCopy(io: Io, uuid: string, place: OrcaCopyPlace, marginMs: number, noAnswer: string): Promise<OrcaCopyStep> {
328 const { isWindows } = await platformOf(io)
329 const lockPath = `${await claudeDirectory(io)}/sc-accounts/locks/refresh-${checkedId(uuid)}.lock`
330
331 return withLock(io, lockPath, { ...REFRESH_LOCK, isWindows }, async () => {
332 const saved = await readVault(io, uuid)
333 const text = await readOrcaCopy(io, place)
334 if (saved === null || text === null) return 'absent'
335 const copy = parseCredential(text)
336 const now = await io.now()
337 const isDue = needsRefresh(saved, now, marginMs) || needsRefresh(copy, now, marginMs)
338 // Two fresh copies are left as they are, apart or not: the later grant reaches both once one is due.
339 if (!isDue) return 'fresh'
340 const candidates = isSameGrant(saved, copy) || saved.claudeAiOauth.expiresAt >= copy.claudeAiOauth.expiresAt ? [saved, copy] : [copy, saved]
341 const save = async (from: Credential, response: HttpResponse): Promise<OrcaCopyStep> => {
342 if (!response.ok) throw new AnthropicError(`token refresh answered ${response.status}`, response.status)
343 const fresh = applyRefresh(from, response.text, now)
344 const { accessToken, refreshToken, expiresAt } = fresh.claudeAiOauth
345 await writeVault(io, uuid, { ...saved, claudeAiOauth: { ...saved.claudeAiOauth, accessToken, refreshToken, expiresAt } })
346 await writeOrcaCopy(io, place, JSON.stringify({ ...copy, claudeAiOauth: { ...copy.claudeAiOauth, accessToken, refreshToken, expiresAt } }))
347
348 return 'refreshed'
349 }
350 let refused: unknown
351 for (const candidate of isSameGrant(saved, copy) ? [saved] : candidates) {
352 const response = await within(io, io.fetch(TOKEN_URL, refreshInit(candidate)), REFRESH_TIMEOUT_MS, noAnswer, late => {
353 void save(candidate, late).catch((error: unknown) => io.log(`a late token refresh could not be saved: ${message(error)}`))
354 })
355 try {
356 return await save(candidate, response)
357 } catch (error) {
358 // Refused: this grant is spent or revoked, and the other copy's may still be alive.
359 if (!(error instanceof AnthropicError) || error.status === undefined || error.status >= 500) throw error
360 refused = error
361 }
362 }
363 throw refused
364 })
365}
366
367/**
368 * A saved account's login, its access token refreshed first when it is near
369 * expiry. The grant Claude Code holds (`live`) is never refreshed here: a
370 * refresh rotates the refresh token under Claude Code, so it is handed back as
371 * it is. The refresh runs under this account's lock across sessions, after
372 * reading the saved login again, as another session may have refreshed it
373 * meanwhile and refreshing one refresh token twice spends it. A refresh that
374 * answers after its time has still rotated the token: its answer is saved
375 * when it comes. `noAnswer` is what the person reads when it does not answer
376 * in time.
377 */
378export async function ensureFresh(io: Io, uuid: string, credential: Credential, live: Credential | null, noAnswer: string): Promise<Credential> {
379 if (!needsRefresh(credential, await io.now()) || isSameGrant(credential, live)) return credential
380 const { isWindows } = await platformOf(io)
381 const lockPath = `${await claudeDirectory(io)}/sc-accounts/locks/refresh-${checkedId(uuid)}.lock`
382
383 return withLock(io, lockPath, { ...REFRESH_LOCK, isWindows }, async () => {
384 const current = (await readVault(io, uuid)) ?? credential
385 const now = await io.now()
386 if (!needsRefresh(current, now) || isSameGrant(current, live)) return current
387 const apply = async (response: HttpResponse): Promise<Credential> => {
388 if (!response.ok) throw new AnthropicError(`token refresh answered ${response.status}`, response.status)
389 const fresh = applyRefresh(current, response.text, now)
390 await writeVault(io, uuid, fresh)
391
392 return fresh
393 }
394 const response = await within(io, io.fetch(TOKEN_URL, refreshInit(current)), REFRESH_TIMEOUT_MS, noAnswer, late => {
395 void apply(late).catch((error: unknown) => io.log(`a late token refresh could not be saved: ${message(error)}`))
396 })
397
398 return apply(response)
399 })
400}
401hooks/feed.ts 221 lines1import type { HttpResponse } from 'claude-code'
2
3import type { LimitView, StatusInfo, WebhookSend } from '../types'
4import { claudeDirectory, deleteWebhookToken, platformOf, readWebhookToken, writeWebhookToken } from './credentials'
5import type { Messages } from './i18n'
6import type { Io } from './io'
7import { message, within } from './io'
8import type { WebhookDraft } from './views/webhook'
9import { changeKey, DEFAULT_TEMPLATE_TEXT, FORMER_DEFAULT_TEXTS, parseConfig, renderTemplate, urlProblem, webhookRequest, webhookValues, WEBHOOK_HEARTBEAT_MS, WEBHOOK_MIN_GAP_MS } from './webhook'
10import type { WebhookConfig } from './webhook'
11import { writeAtomic } from './writes'
12
13/**
14 * The webhook: the session's status filled into the template file and sent
15 * to the URL set, when it changes and as a heartbeat. One send waits at a
16 * time, so a receiver that does not answer never gathers a queue.
17 */
18
19/** What the feed reads and shows beyond the machine. */
20export type FeedContext = {
21 io: Io
22 messages: () => Messages
23 webhookLast: (last: WebhookSend) => Promise<void>
24 /** The live account's email and windows, for the template's variables. */
25 liveFigures: () => Promise<{ email: string | null; limits: LimitView[] }>
26 session: { id: () => string; hostname: () => string | null; version: () => string | null }
27}
28
29/** The `$.store` key of the webhook feed's settings, shared by every session on the machine. */
30export const WEBHOOK_KEY = 'webhook'
31/** How long a receiver has to answer before the dialog says it did not. */
32const WEBHOOK_TIMEOUT_MS = 10_000
33/** How long a send with no answer at all holds the feed before it is let go. */
34const WEBHOOK_ABANDON_MS = 5 * 60 * 1000
35/** The most of a receiver's answer the dialog keeps: a receiver can answer HTTP 200 and still say it stored nothing. */
36const REPLY_KEPT = 200
37
38/** What the feed last sent and when, so an unchanged status is not sent again until the heartbeat. */
39let lastFeed = { key: '', at: 0 }
40/** The send waiting for its answer, numbered, and since when. */
41let sending: { number: number; since: number } | null = null
42let sends = 0
43
44/** Starts the feed afresh, as a new session does. */
45export function resetFeed(): void {
46 lastFeed = { key: '', at: 0 }
47}
48
49/** The template file of the webhook feed, under Claude Code's config directory. */
50export async function templatePath(io: Io): Promise<string> {
51 return `${await claudeDirectory(io)}/sc-accounts/webhook.json`
52}
53
54/** The template's text: the file's, or the default while there is none. */
55async function readTemplate(io: Io): Promise<string> {
56 const path = await templatePath(io)
57
58 return (await io.exists(path)) ? io.read(path) : DEFAULT_TEMPLATE_TEXT
59}
60
61/** Writes the template file from the default when it is missing, or brings a former default up to date, as the dialog does each time it opens. */
62export async function ensureTemplate(io: Io): Promise<void> {
63 const path = await templatePath(io)
64 if (await io.exists(path)) {
65 await upgradeTemplate(io)
66
67 return
68 }
69 const { isWindows } = await platformOf(io)
70 await writeAtomic(io, path, DEFAULT_TEMPLATE_TEXT, { isPrivate: false, isWindows })
71}
72
73/**
74 * Brings a template file that still holds, byte for byte, a default an
75 * earlier release wrote to the current default; a file the person edited is
76 * theirs and stays as it is. Run as a session starts and as the dialog opens;
77 * a send never writes the template. Whether it wrote.
78 */
79export async function upgradeTemplate(io: Io): Promise<boolean> {
80 const path = await templatePath(io)
81 if (!(await io.exists(path))) return false
82 // Only a file the size of a former default is read: the defaults are ASCII, so characters are bytes.
83 const { size } = await io.stat(path)
84 if (!FORMER_DEFAULT_TEXTS.some(text => text.length === size) || !FORMER_DEFAULT_TEXTS.includes(await io.read(path))) return false
85 const { isWindows } = await platformOf(io)
86 await writeAtomic(io, path, DEFAULT_TEMPLATE_TEXT, { isPrivate: false, isWindows })
87
88 return true
89}
90
91/** The start of a receiver's answer on one line, or null when it said nothing. */
92export function replyExcerpt(text: string): string | null {
93 const line = text.replace(/\s+/g, ' ').trim()
94
95 return line === '' ? null : line.slice(0, REPLY_KEPT)
96}
97
98/** The template filled with the session's status now, or why it cannot be. */
99async function webhookBody(ctx: FeedContext, status: StatusInfo, modelId: string, cwd: string, cost: number | null) {
100 const figures = await ctx.liveFigures()
101 const values = webhookValues({
102 session: ctx.session.id(),
103 now: await ctx.io.now(),
104 hostname: ctx.session.hostname(),
105 version: ctx.session.version(),
106 cwd,
107 modelId,
108 status,
109 cost,
110 account: figures.email,
111 limits: figures.limits,
112 })
113
114 return { values, rendered: renderTemplate(await readTemplate(ctx.io), values) }
115}
116
117/** Sends the filled template, and keeps how it went for the dialog: the answer and what it said, or why none came. */
118async function send(ctx: FeedContext, config: WebhookConfig, rendered: { body: string; value: unknown }, token: string | null, onSettled?: () => void): Promise<void> {
119 const at = await ctx.io.now()
120 let request: Promise<HttpResponse> | null = null
121 try {
122 const { url, init } = webhookRequest(config, rendered, token)
123 request = ctx.io.fetch(url, init)
124 void request.finally(() => onSettled?.()).catch(() => undefined)
125 const response = await within(ctx.io, request, WEBHOOK_TIMEOUT_MS, ctx.messages().webhookNoAnswer(WEBHOOK_TIMEOUT_MS / 1000))
126 await ctx.webhookLast({ at, status: response.status, error: null, reply: replyExcerpt(response.text) })
127 } catch (error) {
128 // Failed before the request went out: nothing is left waiting.
129 if (request === null) onSettled?.()
130 await ctx.webhookLast({ at, status: null, error: message(error), reply: null })
131 }
132}
133
134/** Sends the status when the feed is on and something changed, or the heartbeat is due; never twice in two seconds. */
135export async function feedWebhook(ctx: FeedContext, status: StatusInfo, modelId: string, cwd: string, cost: number | null): Promise<void> {
136 const { io } = ctx
137 // Read each time, so a save in any session applies to every session.
138 const config = parseConfig(await io.store.get(WEBHOOK_KEY))
139 if (!config.enabled || urlProblem(config.url) !== null) return
140 const now = await io.now()
141 if (now - lastFeed.at < WEBHOOK_MIN_GAP_MS) return
142 // One send waits at a time; one that never answers is let go after a while, so the feed goes on.
143 if (sending !== null && now - sending.since < WEBHOOK_ABANDON_MS) return
144 const { values, rendered } = await webhookBody(ctx, status, modelId, cwd, cost)
145 if (!('body' in rendered)) return
146 const key = changeKey(values)
147 if (key === lastFeed.key && now - lastFeed.at < WEBHOOK_HEARTBEAT_MS) return
148 lastFeed = { key, at: now }
149 sends += 1
150 const number = sends
151 sending = { number, since: now }
152 const release = () => {
153 if (sending?.number === number) sending = null
154 }
155 // Not awaited: the status reading never waits on the receiver.
156 void send(ctx, config, rendered, await readWebhookToken(io).catch(() => null), release).catch((error: unknown) => io.log(message(error)))
157}
158
159/** The draft as a config, its URL trimmed. */
160export function draftConfig(draft: WebhookDraft): WebhookConfig {
161 return { enabled: draft.enabled, url: draft.url.trim(), method: draft.method }
162}
163
164/** The words for a URL that cannot be sent to, or null. */
165export function urlMessage(m: Messages, problem: ReturnType<typeof urlProblem>): string | null {
166 if (problem === 'empty') return m.webhookUrlEmpty
167 if (problem === 'scheme') return m.webhookUrlScheme
168 if (problem === 'invalid') return m.webhookUrlInvalid
169
170 return null
171}
172
173/** The settings as stored, whether a token is kept, the template file written first when missing: what the dialog opens with. */
174export async function openWebhook(io: Io): Promise<{ config: WebhookConfig; hasToken: boolean }> {
175 const config = parseConfig(await io.store.get(WEBHOOK_KEY))
176 const hasToken = (await readWebhookToken(io)) !== null
177 await ensureTemplate(io)
178
179 return { config, hasToken }
180}
181
182/** What the dialog previews: the request the draft would make now, or why it makes none. */
183export async function webhookPreview(ctx: FeedContext, draft: WebhookDraft, status: StatusInfo | null, modelId: string, cwd: string, cost: number | null): Promise<{ body: string } | { problem: string }> {
184 const m = ctx.messages()
185 if (!status) return { problem: m.webhookTemplateMissing }
186 const { rendered } = await webhookBody(ctx, status, modelId, cwd, cost)
187 if ('invalid' in rendered) return { problem: m.webhookTemplateInvalid(rendered.invalid) }
188 if ('unknown' in rendered) return { problem: m.webhookTemplateUnknown(rendered.unknown.join(', ')) }
189 const config = draftConfig(draft)
190 if (config.method === 'GET' && urlProblem(config.url) === null) return { body: `GET ${webhookRequest(config, rendered, null).url}` }
191
192 return { body: JSON.stringify(rendered.value, null, 2) }
193}
194
195/** Saves the draft: the settings to the store, the token to its secret store; refused with a reason when the URL cannot be sent to. */
196export async function saveWebhook(ctx: FeedContext, draft: WebhookDraft): Promise<string> {
197 const m = ctx.messages()
198 const config = draftConfig(draft)
199 if (config.enabled && urlProblem(config.url) !== null) throw new Error(urlMessage(m, urlProblem(config.url)) ?? '')
200 if (draft.clearToken) await deleteWebhookToken(ctx.io)
201 if (draft.token.trim() !== '') await writeWebhookToken(ctx.io, draft.token.trim())
202 await ctx.io.store.set(WEBHOOK_KEY, config)
203 resetFeed()
204
205 return config.enabled ? m.webhookOn : m.webhookOff
206}
207
208/** Sends the draft once, the token typed or kept, and keeps how it went. */
209export async function testWebhook(ctx: FeedContext, draft: WebhookDraft, status: StatusInfo | null, modelId: string, cwd: string, cost: number | null): Promise<void> {
210 const m = ctx.messages()
211 const config = draftConfig(draft)
212 const problem = urlProblem(config.url)
213 if (problem !== null) throw new Error(urlMessage(m, problem) ?? '')
214 if (!status) throw new Error(m.webhookTemplateMissing)
215 const { rendered } = await webhookBody(ctx, status, modelId, cwd, cost)
216 if (!('body' in rendered)) throw new Error('invalid' in rendered ? m.webhookTemplateInvalid(rendered.invalid) : m.webhookTemplateUnknown(rendered.unknown.join(', ')))
217 const typed = draft.token.trim()
218 const token = draft.clearToken ? null : typed !== '' ? typed : await readWebhookToken(ctx.io)
219 await send(ctx, config, rendered, token)
220}
221hooks/format.ts 51 lines1import type { AccountView, LimitView } from '../types'
2import type { Locale } from './shared/locale'
3import { resetText } from './shared/time'
4
5export { bar, barParts, displayWidth, packRows, severityColor } from './shared/layout'
6export { releaseDateOf } from './shared/locale'
7export { formatDuration, resetClock, resetCountdown, resetText, untilReset } from './shared/time'
8
9/** `5h 26% → 17:00(2h 56m) · wk 45% → Wed 10/7 11:00(2d 20h)`. */
10export function describeLimits(limits: LimitView[], now: number, nowLabel = 'now', locale: Locale = 'en'): string {
11 return limits
12 .map(limit => {
13 const reset = resetText(limit.resetsAt, now, locale, nowLabel)
14
15 return `${limit.label} ${Math.round(limit.percent)}%${reset ? ` → ${reset}` : ''}`
16 })
17 .join(' · ')
18}
19
20/** Finds a saved account by 1-based position, uuid, email, or an email prefix only one matches. */
21export function pick(list: AccountView[], query: string): AccountView | undefined {
22 if (query === '') return undefined
23 const index = Number(query)
24 if (Number.isInteger(index) && index >= 1) return list[index - 1]
25 const exact = list.find(one => one.uuid === query || one.email === query)
26 if (exact) return exact
27 const prefixed = list.filter(one => one.email.startsWith(query))
28
29 return prefixed.length === 1 ? prefixed[0] : undefined
30}
31
32/**
33 * Finds a saved account named whole: its email, in any case, or its uuid.
34 * What cannot be undone without a new /login (removing an account) takes
35 * this, never a position in a list or a prefix, which a typo can match.
36 */
37export function pickExact(list: AccountView[], query: string): AccountView | undefined {
38 const wanted = query.trim().toLowerCase()
39 if (wanted === '') return undefined
40
41 return list.find(one => one.uuid.toLowerCase() === wanted || one.email.toLowerCase() === wanted)
42}
43
44/** Whether two reset times name the same moment, allowing the server's sub-minute jitter. */
45export function isSameReset(a: string | undefined, b: string | undefined): boolean {
46 if (!a || !b) return false
47
48 return Math.abs(Date.parse(a) - Date.parse(b)) <= 2 * 60 * 1000
49}
50
51hooks/i18n.ts 342 lines1import type { Locale } from './shared/locale'
2
3export { resolveLocale, WEEKDAYS } from './shared/locale'
4export type { Locale } from './shared/locale'
5
6const en = {
7 addGuide: [
8 'To add an account:',
9 '1. The account you use now is already saved.',
10 '2. Log in to the other account with `/login`.',
11 '3. Within a minute of logging in, the new account is saved automatically.',
12 ].join('\n'),
13 paneOpened: 'Opened the accounts pane.',
14 noAccounts: 'No saved accounts.',
15 noAccountsYet: 'No saved accounts yet. Reading the current login.',
16 noMatch: (query: string) => `No saved account matches "${query}".`,
17 unknownVerb: (verb: string) => `Unknown subcommand: ${verb}`,
18 saved: (email: string) => `Saved the ${email} account`,
19 switched: (email: string) => `Switched to ${email}. Running sessions use it from their next request.`,
20 removed: (email: string) => `Removed the ${email} account.`,
21 cannotRemoveLive: 'The account in use cannot be removed.',
22 removeNeedsEmail: (query: string) => `Removing deletes the saved login, so name the account whole: /sc:accounts remove <email>. "${query}" names no account exactly.`,
23 noStoredLogin: 'No saved login for this account. Log in to it again.',
24 authExpired: 'Login expired. Log in to this account again.',
25 serverError: (status: number) => `The server returned an error (HTTP ${status}).`,
26 usageNoAnswer: (seconds: number) => `The usage lookup did not answer within ${seconds} seconds.`,
27 profileNoAnswer: (seconds: number) => `The account lookup did not answer within ${seconds} seconds.`,
28 refreshNoAnswer: (seconds: number) => `The token refresh did not answer within ${seconds} seconds.`,
29 active: 'active',
30 activeTag: ' [active]',
31 updatedAgo: (age: string) => `updated ${age} ago`,
32 loading: 'loading',
33 now: 'now',
34 refreshButton: 'Refresh',
35 switchButton: 'Switch',
36 refreshingButton: 'Refreshing',
37 addButton: 'Add account',
38 closeButton: 'Close',
39 clickHint: 'Clicks reach the panes only in fullscreen mode (/tui fullscreen); here, ctrl+x tab focuses the pane, then Tab or the arrows move and Enter presses.',
40 backButton: '← Back',
41 switchTitle: (email: string) => `Switch to ${email}?`,
42 loginChangedOutside: (from: string, to: string) => `The login changed from ${from} to ${to} with no switch of sc-accounts. It is recorded in ~/.claude/sc-accounts/login-changes.jsonl.`,
43 switchHint: 'Every Claude Code session on this machine uses this account from its next request.',
44 loginHealed: (email: string) => `The login Claude Code held was rejected, so ${email}'s saved login was put back.`,
45 switchOrcaHint: 'Orca manages the Claude logins on this machine, so the account is selected in Orca too.',
46 switchedWithOrca: (email: string) => `Switched to ${email}, in Orca too. Running sessions use it from their next request.`,
47 switchedOrcaPending: (email: string) => `Switched to ${email}. Orca is not running; once it runs, ${email} is selected in Orca too, so Orca does not put its own login back.`,
48 switchedOrcaWillRevert: (email: string, active: string) =>
49 `Switched to ${email}. Orca, not running now, holds no login for it and puts ${active} back when it starts; add ${email} in Orca to keep it.`,
50 switchedOrcaFailed: (email: string, reason: string) =>
51 `Switched to ${email}, but it could not be selected in Orca (${reason}). It is tried again every minute; until then Orca may put its own login back.`,
52 orcaLacksAccount: (email: string, active: string) =>
53 `Orca writes ${active}'s login into Claude Code and holds none for ${email}, so a switch would be put back to ${active}. Add ${email} in Orca (Manage accounts, or orca account add), then switch again.`,
54 orcaNotRunning: 'Orca is not running',
55 orcaNeedsPerl: 'reaching Orca needs perl, which is not installed',
56 orcaNeedsPowerShell: 'reaching Orca needs PowerShell, which did not start',
57 orcaNoAnswer: 'Orca did not answer',
58 loginChangedByOrca: (from: string, to: string) => `Orca switched the login from ${from} to ${to}.`,
59 orcaFollowed: (to: string) => `The login changed to ${to} outside sc-accounts and Orca, so ${to} was selected in Orca too and stays.`,
60 orcaFollowFailed: (to: string, reason: string) => `The login changed to ${to}, but it could not be selected in Orca (${reason}); Orca may put its own login back.`,
61 orcaCopyRefused: (email: string) => `The login Orca keeps for ${email} was refused when refreshed. Run /login as ${email} before selecting it in Orca, or every session signs out.`,
62 orcaWillRevert: (to: string, active: string) =>
63 `The login changed to ${to}, which Orca holds no login for; Orca puts ${active} back the next time it writes the login. Add ${to} in Orca to keep it.`,
64 orcaAppliedPending: (email: string) => `${email}, chosen while Orca was not running, is now selected in Orca too.`,
65 heldByOrca: 'Orca keeps this login where sc-accounts cannot refresh it, so the figures are looked up again once the account is in use.',
66 removeTitle: (email: string) => `Remove ${email}?`,
67 removeHint: 'Its saved login is deleted from this machine. Using the account again takes a new /login.',
68 removeConfirm: 'Remove',
69 cancel: 'Cancel',
70 saveButton: 'Save',
71 switchOn: 'On',
72 switchOff: 'Off',
73 webhookButton: 'Webhook',
74 webhookTitle: 'Webhook',
75 webhookSend: 'Send the status',
76 webhookWhen: (heartbeat: number, gap: number) =>
77 `Sent when the status changes (model, effort, context, usage, branch, lines changed and the rest), and every ${heartbeat} seconds while nothing changes. Never twice within ${gap} seconds.`,
78 webhookUrl: 'URL',
79 webhookUrlPlaceholder: 'https://example.com/hook',
80 webhookUrlEmpty: 'Enter the URL to send to.',
81 webhookUrlScheme: 'The URL starts with http:// or https://.',
82 webhookUrlInvalid: 'This is not a URL.',
83 webhookTemplateFile: 'Template',
84 webhookTemplateHint: 'Edit this file to change what is sent. It is JSON; a value written as "{{name}}" is replaced by that variable. Each send reads the file anew.',
85 webhookVariables: 'Variables',
86 webhookTimes: 'Times: now, the 5-hour reset and the weekly reset',
87 webhookTimeIso: (example: string) => `Sent as ISO 8601 text in UTC, such as "${example}".`,
88 webhookTimeEpoch: (example: number) =>
89 `Sent as Unix time, a number of seconds, such as ${example}. Claude Code gives a status line script its reset times in this format.`,
90 webhookTimeEpochMs: (example: number) => `Sent as Unix time, a number of milliseconds, such as ${example}. {{timestamp}} is the same value as {{timeEpochMs}}.`,
91 webhookTimeLocal: (example: string) => `Sent as this computer's local time with its offset from UTC, such as "${example}".`,
92 webhookPreview: 'What is sent now',
93 webhookMoreLines: (count: number) => `… ${count} more lines`,
94 tabAccounts: 'Accounts',
95 tabUsage: 'Usage',
96 spendLine: (used: string, limit?: string) => (limit ? `Spent past the plan: ${used} of ${limit}` : `Spent past the plan: ${used}`),
97 usageTitle: 'Usage on this machine',
98 usageDetail: 'every account · from Claude Code transcripts',
99 usagePeriodLabel: 'period',
100 usagePeriod: (days: number) => (days === 0 ? 'Everything counted' : `Last ${days} days`),
101 usagePeriodTitle: 'Show usage for',
102 tokensInput: 'Input',
103 tokensOutput: 'Output',
104 tokensCacheRead: 'Cache read',
105 tokensCacheWrite: 'Cache write',
106 cacheReuse: 'Cache reuse',
107 cacheReuseHint: 'Cache reuse is cache reads over input plus cache reads.',
108 sessionsResponses: (sessions: number, responses: number) => `${sessions} sessions · ${responses} responses`,
109 dailyTitle: 'Tokens by day',
110 byModel: 'By model',
111 byProject: 'By project',
112 rankSessions: (sessions: number) => (sessions === 1 ? '1 session' : `${sessions} sessions`),
113 usageScanning: (done: number, total: number) => `Counting transcripts… ${done} of ${total} files`,
114 usageEmpty: 'No responses counted in this period.',
115 usageNeedsShell: 'Counting needs a POSIX shell (sh, head, tail, awk), which Windows has only with Git Bash or WSL.',
116 tabStorage: 'Storage',
117 storageTitle: 'Session records on this machine',
118 storageDetail: (path: string) => path,
119 storageKinds: 'Transcripts',
120 storageSubagents: 'Subagent transcripts',
121 storageOther: 'Tool output and images',
122 storageSessions: (count: number) => (count === 1 ? '1 session' : `${count} sessions`),
123 storageAuto: (days: number, isDefault: boolean) =>
124 `Claude Code deletes sessions idle over ${days} days on its own${isDefault ? ' (the default)' : ''}. Set "cleanupPeriodDays" in ~/.claude/settings.json to change it.`,
125 storageProjects: 'By project',
126 storageProjectDetail: (sessions: string, lastActive: string) => `${sessions} · last ${lastActive}`,
127 storageFolders: 'Other folders under ~/.claude',
128 storageScanning: 'Measuring…',
129 cleanupLabel: 'delete sessions idle over',
130 cleanupDays: (days: number) => `${days} days`,
131 cleanupDaysTitle: 'Delete the sessions idle over',
132 cleanupButton: 'Clean up',
133 cleanupTitle: (count: number, size: string) => `Delete ${count === 1 ? '1 session' : `${count} sessions`} (${size})?`,
134 cleanupWhat: (days: number) => `Every session with no change in ${days} days: its transcript, its subagents' transcripts and its tool output.`,
135 cleanupKeeps: 'The session running here is kept, and so are the usage figures already counted.',
136 cleanupNoResume: 'A deleted session can no longer be resumed. This cannot be undone.',
137 cleanupNothing: (days: number) => `No session has been idle over ${days} days.`,
138 cleanupConfirm: 'Delete',
139 cleanupWord: 'delete',
140 cleanupTypeHint: (word: string) => `Type ${word} and press Enter to delete them.`,
141 cleanupPlaceholder: (word: string) => `Type ${word}`,
142 cleanupMismatch: (word: string) => `Nothing was deleted: type ${word} exactly to confirm.`,
143 cleanupNoField: 'Deleting needs a text field: clean up from the terminal or the desktop app.',
144 cleanupUncounted: (files: number) => `Nothing was deleted: ${files === 1 ? '1 transcript' : `${files} transcripts`} could not be counted for the Usage tab first.`,
145 cleanedUp: (count: number, size: string) => `Deleted ${count === 1 ? '1 session' : `${count} sessions`}, ${size}.`,
146 webhookTemplateInvalid: (reason: string) => `The template is not JSON: ${reason}`,
147 webhookTemplateUnknown: (names: string) => `The template names variables there are none of: ${names}`,
148 webhookTemplateMissing: 'The template file is missing.',
149 webhookNeverSent: 'Nothing sent yet.',
150 webhookSent: (clock: string, status: number) => `Sent at ${clock}: HTTP ${status}`,
151 webhookFailed: (clock: string, reason: string) => `Sending at ${clock} failed: ${reason}`,
152 webhookReply: (reply: string) => `The receiver answered: ${reply}`,
153 webhookNoAnswer: (seconds: number) => `The receiver did not answer within ${seconds} seconds.`,
154 webhookTest: 'Send a test',
155 webhookOn: 'The webhook is on.',
156 webhookOff: 'The webhook is off.',
157 webhookMethod: 'Method',
158 webhookGetHint: 'GET sends the template\'s top-level fields as query parameters.',
159 webhookToken: 'Bearer token',
160 webhookTokenSet: 'set',
161 webhookTokenNone: 'none',
162 webhookTokenPlaceholder: 'a new token, or leave empty',
163 webhookTokenClear: 'Clear token',
164 webhookTokenKept: 'Kept in the keychain on macOS, in an owner-only file elsewhere; never shown.',
165 bandShown: 'The status band above the prompt is shown.',
166 bandHidden: 'The status band above the prompt is hidden.',
167 bandUsage: 'Usage: /sc:accounts band on|off',
168 release: (version: string, date?: string) => (date ? `v${version} (${date})` : `v${version}`),
169 updateRequired: 'Update Required',
170}
171
172export type Messages = typeof en
173
174const ko: Messages = {
175 addGuide: [
176 '계정 추가 방법:',
177 '1. 지금 계정은 이미 저장되어 있습니다.',
178 '2. `/login`으로 추가할 계정에 로그인해 주세요.',
179 '3. 로그인을 마치면 1분 안에 새 계정을 자동으로 저장합니다.',
180 ].join('\n'),
181 paneOpened: '계정 창을 열었습니다.',
182 noAccounts: '저장된 계정이 없습니다.',
183 noAccountsYet: '저장된 계정이 없습니다. 지금 로그인 정보를 읽는 중입니다.',
184 noMatch: query => `"${query}"에 해당하는 저장된 계정이 없습니다.`,
185 unknownVerb: verb => `알 수 없는 명령입니다: ${verb}`,
186 saved: email => `${email} 계정을 저장했습니다`,
187 switched: email => `${email} 계정으로 전환했습니다. 실행 중인 세션은 다음 요청부터 이 계정을 사용합니다.`,
188 removed: email => `${email} 계정을 제거했습니다.`,
189 cannotRemoveLive: '사용 중인 계정은 제거할 수 없습니다.',
190 removeNeedsEmail: query => `계정을 제거하면 저장된 로그인이 삭제되므로 이메일 전체를 적어 주세요: /sc:accounts remove <이메일>. "${query}"와 정확히 일치하는 계정이 없습니다.`,
191 noStoredLogin: '저장된 로그인이 없습니다. 이 계정으로 다시 로그인해 주세요.',
192 authExpired: '인증이 만료됐습니다. 이 계정으로 다시 로그인해 주세요.',
193 serverError: status => `서버가 오류를 반환했습니다(HTTP ${status}).`,
194 usageNoAnswer: seconds => `사용량 조회 서버가 ${seconds}초 안에 응답하지 않았습니다.`,
195 profileNoAnswer: seconds => `계정 정보 조회 서버가 ${seconds}초 안에 응답하지 않았습니다.`,
196 refreshNoAnswer: seconds => `토큰 갱신 서버가 ${seconds}초 안에 응답하지 않았습니다.`,
197 active: '활성',
198 activeTag: ' [활성]',
199 updatedAgo: age => `${age} 전 갱신`,
200 loading: '조회 중',
201 now: '곧',
202 refreshButton: '새로고침',
203 switchButton: '전환',
204 refreshingButton: '조회 중',
205 addButton: '계정 추가',
206 closeButton: '닫기',
207 clickHint: '클릭은 전체 화면 모드(/tui fullscreen)에서만 창에 전달됩니다. 지금은 ctrl+x tab으로 창에 포커스를 준 뒤 Tab이나 화살표로 이동하고 Enter로 누르세요.',
208 backButton: '← 돌아가기',
209 switchTitle: email => `${email} 계정으로 전환할까요?`,
210 loginChangedOutside: (from, to) => `sc-accounts의 전환 없이 로그인이 ${from}에서 ${to}(으)로 바뀌었습니다. ~/.claude/sc-accounts/login-changes.jsonl에 기록했습니다.`,
211 switchHint: '이 기기의 모든 Claude Code 세션이 다음 요청부터 이 계정을 씁니다.',
212 loginHealed: email => `Claude Code가 들고 있던 로그인이 거부되어 ${email}의 저장된 로그인으로 되돌렸습니다.`,
213 switchOrcaHint: '이 기기의 Claude 로그인은 Orca가 관리하므로 Orca에서도 이 계정을 선택합니다.',
214 switchedWithOrca: email => `${email} 계정으로 전환하고 Orca에서도 이 계정을 선택했습니다. 실행 중인 세션은 다음 요청부터 이 계정을 사용합니다.`,
215 switchedOrcaPending: email => `${email} 계정으로 전환했습니다. Orca가 실행 중이 아니어서, Orca가 실행되면 이 계정을 Orca에서도 선택해 Orca가 이전 로그인으로 되돌리지 않게 합니다.`,
216 switchedOrcaWillRevert: (email, active) =>
217 `${email} 계정으로 전환했습니다. 지금 실행 중이 아닌 Orca에는 이 계정의 로그인이 없어서, Orca를 시작하면 ${active} 계정으로 되돌립니다. 이 계정을 계속 쓰려면 Orca에 추가해 주세요.`,
218 switchedOrcaFailed: (email, reason) =>
219 `${email} 계정으로 전환했지만 Orca에서는 이 계정을 선택하지 못했습니다(${reason}). 1분마다 다시 시도하며, 그 전에는 Orca가 이전 로그인으로 되돌릴 수 있습니다.`,
220 orcaLacksAccount: (email, active) =>
221 `Orca는 Claude Code에 ${active} 계정의 로그인을 기록하고 ${email} 계정의 로그인은 보관하고 있지 않아서, 전환해도 ${active} 계정으로 되돌립니다. Orca의 계정 관리에서 ${email} 계정을 추가하거나 orca account add를 실행한 뒤 다시 전환해 주세요.`,
222 orcaNotRunning: 'Orca가 실행되지 않음',
223 orcaNeedsPerl: 'perl이 없어 Orca에 연결할 수 없음',
224 orcaNeedsPowerShell: 'PowerShell을 실행하지 못해 Orca에 연결할 수 없음',
225 orcaNoAnswer: 'Orca가 응답하지 않음',
226 loginChangedByOrca: (from, to) => `Orca가 로그인을 ${from}에서 ${to}(으)로 바꿨습니다.`,
227 orcaFollowed: to => `sc-accounts와 Orca 밖에서 로그인이 ${to}(으)로 바뀌어, Orca에서도 ${to} 계정을 선택해 이 로그인을 유지합니다.`,
228 orcaFollowFailed: (to, reason) => `로그인이 ${to}(으)로 바뀌었지만 Orca에서 이 계정을 선택하지 못했습니다(${reason}). Orca가 이전 로그인으로 되돌릴 수 있습니다.`,
229 orcaCopyRefused: email => `Orca에 저장된 ${email} 로그인을 갱신하려 했지만 거부됐습니다. Orca에서 이 계정을 선택하기 전에 ${email} 계정으로 /login을 실행해 주세요. 그대로 선택하면 모든 세션이 로그아웃됩니다.`,
230 orcaWillRevert: (to, active) =>
231 `로그인이 ${to}(으)로 바뀌었지만 Orca에는 이 계정의 로그인이 없어서, Orca가 다음에 로그인을 기록할 때 ${active} 계정으로 되돌립니다. 이 계정을 계속 쓰려면 Orca에 추가해 주세요.`,
232 orcaAppliedPending: email => `Orca가 실행되지 않던 동안 고른 ${email} 계정을 Orca에서도 선택했습니다.`,
233 heldByOrca: 'Orca가 이 로그인을 sc-accounts가 갱신할 수 없는 곳에 보관해서, 이 계정을 다시 사용하면 수치를 다시 조회합니다.',
234 removeTitle: email => `${email} 계정을 제거할까요?`,
235 removeHint: '이 기기에 저장한 로그인을 삭제합니다. 이 계정을 다시 쓰려면 /login으로 다시 로그인해야 합니다.',
236 removeConfirm: '제거',
237 cancel: '취소',
238 saveButton: '저장',
239 switchOn: '켬',
240 switchOff: '끔',
241 webhookButton: '웹훅',
242 webhookTitle: '웹훅',
243 webhookSend: '상태 보내기',
244 webhookWhen: (heartbeat, gap) =>
245 `상태(모델, effort, 컨텍스트, 사용량, 브랜치, 변경 줄 수 등)가 바뀔 때 보내고, 바뀌지 않아도 ${heartbeat}초마다 보냅니다. ${gap}초 안에 두 번 보내지는 않습니다.`,
246 webhookUrl: '주소',
247 webhookUrlPlaceholder: 'https://example.com/hook',
248 webhookUrlEmpty: '보낼 주소를 입력해 주세요.',
249 webhookUrlScheme: '주소는 http:// 또는 https://로 시작해야 합니다.',
250 webhookUrlInvalid: '주소 형식이 올바르지 않습니다.',
251 webhookTemplateFile: '템플릿',
252 webhookTemplateHint: '보낼 내용을 바꾸려면 이 파일을 수정해 주세요. 파일은 JSON이고, 값을 "{{이름}}"으로 쓰면 그 변수의 값으로 바뀝니다. 보낼 때마다 파일을 새로 읽습니다.',
253 webhookVariables: '변수',
254 webhookTimes: '시각 변수: 현재 시각, 5시간 한도 리셋 시각, 주간 한도 리셋 시각',
255 webhookTimeIso: example => `UTC 기준 ISO 8601 문자열로 보냅니다(예: "${example}").`,
256 webhookTimeEpoch: example => `유닉스 시간을 초 단위 숫자로 보냅니다(예: ${example}). Claude Code가 상태 표시줄 스크립트에 전달하는 리셋 시각도 이 형식입니다.`,
257 webhookTimeEpochMs: example => `유닉스 시간을 밀리초 단위 숫자로 보냅니다(예: ${example}). {{timestamp}} 변수의 값은 {{timeEpochMs}} 변수와 같습니다.`,
258 webhookTimeLocal: example => `이 컴퓨터의 현지 시각을 UTC와의 시차를 포함한 문자열로 보냅니다(예: "${example}").`,
259 webhookPreview: '지금 보낼 내용',
260 webhookMoreLines: count => `… ${count}줄 더 있음`,
261 tabAccounts: '계정',
262 tabUsage: '사용량',
263 spendLine: (used, limit) => (limit ? `플랜 외 사용액: ${used} (한도 ${limit})` : `플랜 외 사용액: ${used}`),
264 usageTitle: '이 기기의 사용량',
265 usageDetail: '모든 계정 · Claude Code 세션 기록 기준',
266 usagePeriodLabel: '기간',
267 usagePeriod: days => (days === 0 ? '센 기간 전체' : `최근 ${days}일`),
268 usagePeriodTitle: '사용량을 볼 기간',
269 tokensInput: '입력',
270 tokensOutput: '출력',
271 tokensCacheRead: '캐시 읽기',
272 tokensCacheWrite: '캐시 쓰기',
273 cacheReuse: '캐시 재사용률',
274 cacheReuseHint: '캐시 재사용률은 캐시 읽기를 입력과 캐시 읽기의 합으로 나눈 값입니다.',
275 sessionsResponses: (sessions, responses) => `세션 ${sessions}개 · 응답 ${responses}개`,
276 dailyTitle: '일별 토큰',
277 byModel: '모델별',
278 byProject: '프로젝트별',
279 rankSessions: sessions => `세션 ${sessions}개`,
280 usageScanning: (done, total) => `세션 기록을 세는 중… 파일 ${total}개 중 ${done}개`,
281 usageEmpty: '이 기간에 센 응답이 없습니다.',
282 usageNeedsShell: '사용량을 세려면 POSIX 셸(sh, head, tail, awk)이 필요합니다. Windows에서는 Git Bash나 WSL에만 있습니다.',
283 tabStorage: '저장 공간',
284 storageTitle: '이 기기의 세션 기록',
285 storageDetail: path => path,
286 storageKinds: '세션 기록',
287 storageSubagents: '하위 에이전트 기록',
288 storageOther: '도구 출력과 이미지',
289 storageSessions: count => `세션 ${count}개`,
290 storageAuto: (days, isDefault) =>
291 `Claude Code는 ${days}일 넘게 변경이 없는 세션을 자동으로 삭제합니다${isDefault ? '(기본값)' : ''}. 기간을 바꾸려면 ~/.claude/settings.json에 "cleanupPeriodDays"를 지정해 주세요.`,
292 storageProjects: '프로젝트별',
293 storageProjectDetail: (sessions, lastActive) => `${sessions} · 마지막 ${lastActive}`,
294 storageFolders: '~/.claude의 다른 폴더',
295 storageScanning: '용량을 재는 중…',
296 cleanupLabel: '삭제할 세션: 변경 없이',
297 cleanupDays: days => `${days}일 지난 것`,
298 cleanupDaysTitle: '삭제할 세션의 기준',
299 cleanupButton: '정리',
300 cleanupTitle: (count, size) => `세션 ${count}개(${size})를 삭제할까요?`,
301 cleanupWhat: days => `${days}일 동안 변경이 없는 모든 세션의 기록, 하위 에이전트 기록, 도구 출력을 삭제합니다.`,
302 cleanupKeeps: '지금 이 세션과 이미 센 사용량 수치는 남깁니다.',
303 cleanupNoResume: '삭제한 세션은 다시 이어서 할 수 없고, 되돌릴 수 없습니다.',
304 cleanupNothing: days => `${days}일 넘게 변경이 없는 세션이 없습니다.`,
305 cleanupConfirm: '삭제',
306 cleanupWord: '삭제',
307 cleanupTypeHint: word => `삭제하려면 「${word}」를 입력하고 Enter를 눌러 주세요.`,
308 cleanupPlaceholder: word => `${word} 입력`,
309 cleanupMismatch: word => `아무것도 삭제하지 않았습니다. 확인하려면 「${word}」를 그대로 입력해 주세요.`,
310 cleanupNoField: '삭제하려면 입력란이 필요합니다. 터미널이나 데스크톱 앱에서 정리해 주세요.',
311 cleanupUncounted: files => `아무것도 삭제하지 않았습니다. 세션 기록 ${files}개를 사용량에 먼저 셀 수 없었습니다.`,
312 cleanedUp: (count, size) => `세션 ${count}개, ${size}를 삭제했습니다.`,
313 webhookTemplateInvalid: reason => `템플릿이 올바른 JSON이 아닙니다: ${reason}`,
314 webhookTemplateUnknown: names => `템플릿에 없는 변수가 있습니다: ${names}`,
315 webhookTemplateMissing: '템플릿 파일이 없습니다.',
316 webhookNeverSent: '아직 보낸 적이 없습니다.',
317 webhookSent: (clock, status) => `${clock}에 보냈습니다: HTTP ${status}`,
318 webhookFailed: (clock, reason) => `${clock}에 보내지 못했습니다: ${reason}`,
319 webhookReply: reply => `수신 서버 응답: ${reply}`,
320 webhookNoAnswer: seconds => `수신 서버가 ${seconds}초 안에 응답하지 않았습니다.`,
321 webhookTest: '시험 전송',
322 webhookOn: '웹훅 전송을 켰습니다.',
323 webhookOff: '웹훅 전송을 껐습니다.',
324 webhookMethod: '방식',
325 webhookGetHint: 'GET은 템플릿의 최상위 필드를 쿼리 매개변수로 보냅니다.',
326 webhookToken: 'Bearer 토큰',
327 webhookTokenSet: '설정됨',
328 webhookTokenNone: '없음',
329 webhookTokenPlaceholder: '새 토큰을 입력하거나 비워 두세요',
330 webhookTokenClear: '토큰 지우기',
331 webhookTokenKept: 'macOS에서는 키체인에, 그 밖의 OS에서는 소유자만 읽을 수 있는 파일에 저장하며 화면에 표시하지 않습니다.',
332 bandShown: '입력란 위 상태 표시를 켰습니다.',
333 bandHidden: '입력란 위 상태 표시를 껐습니다.',
334 bandUsage: '사용법: /sc:accounts band on|off',
335 release: (version, date) => (date ? `v${version} (${date})` : `v${version}`),
336 updateRequired: '업데이트 필요',
337}
338
339export function messagesFor(locale: Locale): Messages {
340 return locale === 'ko' ? ko : en
341}
342hooks/io.ts 74 lines1import type { FsEntry, FsStat, HttpInit, HttpResponse, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3/**
4 * The environment variables the modules read. The engine lists what a module
5 * reads from the literal names at its `$.env.get` calls, so each is read by
6 * name in the hooks module and no other may be asked for.
7 */
8export type EnvName = 'OS' | 'HOME' | 'USERPROFILE' | 'USER' | 'CLAUDE_CONFIG_DIR' | 'CLAUDE_SECURESTORAGE_CONFIG_DIR' | 'ORCA_USER_DATA_PATH' | 'XDG_CONFIG_HOME' | 'APPDATA'
9
10/**
11 * What the modules reach the machine through. The hooks module builds each
12 * member over `$`, which no other file may hold, so a module takes this and
13 * plain data, and a test hands it a machine of its own.
14 */
15export type Io = {
16 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
17 read: (path: string) => Promise<string>
18 write: (path: string, text: string) => Promise<void>
19 exists: (path: string) => Promise<boolean>
20 stat: (path: string) => Promise<FsStat>
21 list: (path: string) => Promise<readonly FsEntry[]>
22 env: (name: EnvName) => Promise<string | undefined>
23 now: () => Promise<number>
24 sleep: (ms: number) => Promise<void>
25 /** Runs `fn` once after `ms`; the returned function cancels it. */
26 after: (ms: number, fn: () => void) => () => void
27 fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
28 store: {
29 get: (key: string) => Promise<unknown>
30 set: (key: string, value: unknown) => Promise<void>
31 delete: (key: string) => Promise<void>
32 keys: () => Promise<readonly string[]>
33 }
34 /** A line for the debug log. */
35 log: (text: string) => void
36}
37
38/** A value of the session's state, read and written whole. */
39export type Cell<T> = { get: () => Promise<T>; set: (value: T) => Promise<void> }
40
41export function message(error: unknown): string {
42 return error instanceof Error ? error.message : String(error)
43}
44
45/** A request or command that did not answer within its time. */
46export class TimeoutError extends Error {}
47
48/**
49 * The promise's value, or a `TimeoutError` saying `noAnswer` once `ms` pass:
50 * the words the person reads, in their language. The promise runs on either
51 * way; `onLate` gets its value if it settles after the deadline.
52 */
53export async function within<T>(io: Io, promise: Promise<T>, ms: number, noAnswer: string, onLate?: (value: T) => void): Promise<T> {
54 let isLate = false
55 let cancel = () => {}
56 const deadline = new Promise<never>((_, reject) => {
57 cancel = io.after(ms, () => {
58 isLate = true
59 reject(new TimeoutError(noAnswer))
60 })
61 })
62 promise.then(
63 value => {
64 if (isLate) onLate?.(value)
65 },
66 () => undefined,
67 )
68 try {
69 return await Promise.race([promise, deadline])
70 } finally {
71 cancel()
72 }
73}
74hooks/lookups.ts 250 lines1import type { UsageView } from '../types'
2import { AnthropicError, USAGE_URL, failedReading, heldReading, lookedUpOnly, needsRefresh, parseSpend, parseUsage, usageInit, withMeasured } from './anthropic'
3import type { MeasuredWindow } from './anthropic'
4import { USAGE_KEY, exclusive, figuresTrustedFrom, isLiveTokenOf, oauthAccountKey, organizationOf, savedIds, syncLive } from './accounts'
5import type { AccountsContext } from './accounts'
6import { REFRESH_TIMEOUT_MS, ensureFresh, liveOauthAccount, readLiveCredential, readVault, refreshOrcaCopy } from './credentials'
7import type { OauthAccount } from './credentials'
8import { message, within } from './io'
9import { isSameGrant } from './keychain'
10import type { Credential } from './keychain'
11import { LockBusyError } from './lock'
12import { orcaAccountFor } from './orca'
13import type { OrcaAccount } from './orca'
14import { ORCA_COPY_MARGIN_MS } from './orcaCopies'
15import { orcaCopyPlace, rememberedOrca } from './orcaClient'
16import { LIVE_POLL_MS, afterRateLimit, afterSuccess, isAutomaticLookupDue, readShared } from './schedule'
17
18/**
19 * Every saved account's rate limits: looked up with each account's own
20 * token, shared with the other sessions through the store, and the live
21 * account's kept equal to what Claude Code's own responses report.
22 */
23
24const LOOKUP_KEY = 'lookup'
25/** The `$.store` key holding when any session last looked the live account up. */
26export const LIVE_LOOKUP_KEY = 'liveLookupAt'
27/** How long the usage endpoint may take to answer. */
28const USAGE_TIMEOUT_MS = 15_000
29
30/** The login changed between reading whose it is and looking it up: the answer would be another account's. */
31class LoginChangedError extends Error {}
32
33/**
34 * An inactive account's token needs refreshing, and Orca keeps a copy of its
35 * login where this mod cannot reach it (no keychain item, or no folder Orca
36 * marks as that account's): refreshing only this mod's copy would leave Orca's
37 * spent, so it is left alone.
38 */
39class HeldByOrcaError extends Error {}
40
41/** The Orca account that keeps a copy of a saved account's login, as Orca last said; undefined when it keeps none. */
42async function orcaKeeperOf(ctx: AccountsContext, uuid: string): Promise<OrcaAccount | undefined> {
43 const email = (await ctx.accounts.get()).find(one => one.uuid === uuid)?.email
44 const remembered = await rememberedOrca(ctx.io)
45 if (email === undefined || remembered === null) return undefined
46 const details = (await ctx.io.store.get(oauthAccountKey(uuid))) as OauthAccount | undefined
47
48 return orcaAccountFor(remembered, email, organizationOf(details))
49}
50
51/**
52 * A saved account's token, refreshed first when it nears expiry. Where Orca
53 * keeps a copy of the same grant, both copies are refreshed together, so Orca
54 * never writes a spent login later.
55 */
56async function savedToken(ctx: AccountsContext, uuid: string, saved: Credential, live: Credential | null): Promise<string> {
57 const { io } = ctx
58 const noAnswer = ctx.messages().refreshNoAnswer(REFRESH_TIMEOUT_MS / 1000)
59 const keeper = needsRefresh(saved, await io.now()) ? await orcaKeeperOf(ctx, uuid) : undefined
60 if (!keeper) return (await ensureFresh(io, uuid, saved, live, noAnswer)).claudeAiOauth.accessToken
61 const place = await orcaCopyPlace(io, keeper.id)
62 const step = place === null ? 'absent' : await refreshOrcaCopy(io, uuid, place, ORCA_COPY_MARGIN_MS, noAnswer)
63 const fresh = step === 'refreshed' || step === 'fresh' ? await readVault(io, uuid) : null
64 if (fresh === null) throw new HeldByOrcaError(uuid)
65
66 return fresh.claudeAiOauth.accessToken
67}
68
69/**
70 * One account's reading. The live login is Claude Code's to refresh, never
71 * this mod's, so it is looked up with the token Claude Code holds, once that
72 * token is known to be this account's; a saved login that is the live grant
73 * (the config naming another account for a moment) is the live account's too.
74 */
75async function readingFor(ctx: AccountsContext, uuid: string, liveUuid: string | null, live: Credential | null): Promise<UsageView> {
76 const { io } = ctx
77 const m = ctx.messages()
78 const saved = uuid === liveUuid ? null : await readVault(io, uuid)
79 let token: string
80 if (uuid === liveUuid || isSameGrant(saved, live)) {
81 if (live === null) throw new Error(m.noStoredLogin)
82 if (!isLiveTokenOf(live, uuid)) throw new LoginChangedError(uuid)
83 token = live.claudeAiOauth.accessToken
84 } else {
85 if (saved === null) throw new Error(m.noStoredLogin)
86 token = await savedToken(ctx, uuid, saved, live)
87 }
88 const response = await within(io, io.fetch(USAGE_URL, usageInit({ token })), USAGE_TIMEOUT_MS, m.usageNoAnswer(USAGE_TIMEOUT_MS / 1000))
89 if (!response.ok) throw new AnthropicError(`usage endpoint answered ${response.status}`, response.status, response.headers['retry-after'])
90 const body: unknown = JSON.parse(response.text)
91 const spend = parseSpend(body)
92
93 return { limits: parseUsage(body), fetchedAt: await io.now(), source: 'lookup', ...(spend ? { spend } : {}) }
94}
95
96/** Reads the live account alone and shares the reading; what a switch and Claude Code's own readings ask for. */
97export async function refreshLive(ctx: AccountsContext): Promise<void> {
98 const { io } = ctx
99 const liveUuid = await syncLive(ctx)
100 if (liveUuid === null) return
101 await io.store.set(LIVE_LOOKUP_KEY, await io.now())
102 let reading: UsageView
103 try {
104 reading = await readingFor(ctx, liveUuid, liveUuid, await readLiveCredential(io))
105 } catch (error) {
106 if (error instanceof LoginChangedError) return
107 reading = failedReading((await ctx.usage.get())[liveUuid], error, await io.now(), ctx.messages())
108 }
109 await ctx.usage.update(map => ({ ...map, [liveUuid]: reading }))
110 await io.store.set(USAGE_KEY, { ...lookedUpOnly(await io.store.get(USAGE_KEY)), [liveUuid]: reading })
111}
112
113/** Looks the live account up when no session has for `LIVE_POLL_MS` and no 429 holds lookups back: it stays current between Claude Code's own readings. */
114export async function pollLive(ctx: AccountsContext): Promise<void> {
115 const { io } = ctx
116 const lastLiveLookup = Number((await io.store.get(LIVE_LOOKUP_KEY)) ?? 0)
117 const { backoffUntil } = readShared(await io.store.get(LOOKUP_KEY))
118 const now = await io.now()
119 if (now >= backoffUntil && now - lastLiveLookup >= LIVE_POLL_MS) await refreshLive(ctx)
120}
121
122/** Shows the readings the last lookup, by any session on this machine, stored. */
123export async function adoptSharedUsage(ctx: AccountsContext): Promise<void> {
124 const stored = lookedUpOnly(await ctx.io.store.get(USAGE_KEY))
125 await ctx.usage.update(map => ({ ...lookedUpOnly(map), ...stored }))
126}
127
128/**
129 * Reads every saved account's rate limits, one after another, and shares
130 * them with the other sessions. `isAsked` is a lookup the person asked for:
131 * it runs at once, past the shared schedule and any 429 wait.
132 */
133export async function refreshAll(ctx: AccountsContext, isAsked: boolean): Promise<void> {
134 const { io } = ctx
135 if (await ctx.isRefreshing.get()) return
136 const startedAt = await io.now()
137 let shared = readShared(await io.store.get(LOOKUP_KEY))
138 if (!isAsked && !isAutomaticLookupDue(shared, startedAt)) {
139 // Reusing another session's lookup still needs this session to know which account is live.
140 await syncLive(ctx)
141 await adoptSharedUsage(ctx)
142
143 return
144 }
145 // Claim the slot before the requests go out, so a session ticking meanwhile reuses this lookup.
146 shared = { ...shared, startedAt }
147 await io.store.set(LOOKUP_KEY, shared)
148 await ctx.isRefreshing.set(true)
149 let retryAfter: string | undefined
150 let isRateLimited = false
151 try {
152 const liveUuid = await syncLive(ctx)
153 for (const account of await ctx.accounts.get()) {
154 try {
155 const reading = await exclusive(async () => {
156 // Whose login Claude Code uses now, read again for each account: a switch here or in another
157 // session may have made this one live since the lookup began, and the live login is never refreshed here.
158 const liveNow = (await liveOauthAccount(io).catch(() => null))?.accountUuid ?? liveUuid
159
160 return readingFor(ctx, account.uuid, liveNow, await readLiveCredential(io).catch(() => null))
161 })
162 await ctx.usage.update(map => ({ ...map, [account.uuid]: reading }))
163 } catch (error) {
164 // Another session switched meanwhile, or is refreshing this account: nothing is filed this time.
165 if (error instanceof LoginChangedError || error instanceof LockBusyError) continue
166 // Left to Orca: the figures last looked up stay, and the card says why they age.
167 if (error instanceof HeldByOrcaError) {
168 const now = await io.now()
169 await ctx.usage.update(map => ({ ...map, [account.uuid]: heldReading(map[account.uuid], now) }))
170 continue
171 }
172 if (error instanceof AnthropicError && error.status === 429) {
173 isRateLimited = true
174 retryAfter = error.retryAfter ?? retryAfter
175 }
176 const now = await io.now()
177 await ctx.usage.update(map => ({ ...map, [account.uuid]: failedReading(map[account.uuid], error, now, ctx.messages()) }))
178 }
179 }
180 } finally {
181 // Only the accounts saved now: the reading of one removed meanwhile, in any session, is not written back.
182 const known = await savedIds(ctx)
183 const readings = lookedUpOnly(await ctx.usage.get())
184 await io.store.set(USAGE_KEY, Object.fromEntries(Object.entries(readings).filter(([uuid]) => known.has(uuid))))
185 const now = await io.now()
186 await io.store.set(LOOKUP_KEY, isRateLimited ? afterRateLimit(shared, now, retryAfter) : afterSuccess(shared))
187 await ctx.isRefreshing.set(false)
188 }
189}
190
191/**
192 * Keeps the live account's five-hour and weekly figures equal to the ones
193 * this session's latest response reported, which need no lookup and no rate
194 * limit. Only once a turn has begun since the live account last changed:
195 * before that, the session's figures may be the previous login's. Written
196 * when they differ from what is shown, or when the shown reading is a minute
197 * old, so a figure filed wrongly is put right by the next response.
198 */
199export async function adoptSessionFigures(ctx: AccountsContext, windows: readonly MeasuredWindow[], turnStartedAt: number): Promise<void> {
200 const { io } = ctx
201 const liveUuid = await ctx.live.get()
202 if (liveUuid === null || turnStartedAt === 0 || turnStartedAt <= (await figuresTrustedFrom(io))) return
203 const measured = windows.filter(window => window.kind === 'five_hour' || window.kind === 'seven_day')
204 if (measured.length === 0) return
205 const now = await io.now()
206 const current = (await ctx.usage.get())[liveUuid]
207 const next = withMeasured(current, measured, now)
208 const shown = (reading: UsageView | undefined) => JSON.stringify((reading?.limits ?? []).filter(limit => limit.label === '5h' || limit.label === 'wk'))
209 if (shown(current) === shown(next) && current?.isStale !== true && now - (current?.fetchedAt ?? 0) < 60_000) return
210 await ctx.usage.update(map => ({ ...map, [liveUuid]: next }))
211 await io.store.set(USAGE_KEY, { ...lookedUpOnly(await io.store.get(USAGE_KEY)), [liveUuid]: next })
212}
213
214/**
215 * Claude Code's own response reported its windows. They are the live
216 * account's only when this turn began after the live account last changed,
217 * and when the login Claude Code is configured with is still the one this
218 * session knows: a switch made in another session reaches every session's
219 * requests at once but this session's `live` only at its next read, and the
220 * new login's figures must never be filed under the account it replaced.
221 * Otherwise the account is read again and looked up instead.
222 */
223export async function adoptMeasured(ctx: AccountsContext, windows: readonly MeasuredWindow[], turnStartedAt: number): Promise<void> {
224 const { io } = ctx
225 const liveUuid = await ctx.live.get()
226 if (liveUuid === null || windows.length === 0) return
227 const configured = await liveOauthAccount(io).catch((error: unknown) => {
228 // An unreadable config names no account: the figures are not filed.
229 io.log(message(error))
230
231 return null
232 })
233 if (configured?.accountUuid !== liveUuid) {
234 void syncLive(ctx)
235 .then(() => refreshLive(ctx))
236 .catch((error: unknown) => io.log(message(error)))
237
238 return
239 }
240 if (turnStartedAt > (await figuresTrustedFrom(io))) {
241 const now = await io.now()
242 const reading = withMeasured((await ctx.usage.get())[liveUuid], [...windows], now)
243 await ctx.usage.update(map => ({ ...map, [liveUuid]: reading }))
244 await io.store.set(USAGE_KEY, { ...lookedUpOnly(await io.store.get(USAGE_KEY)), [liveUuid]: reading })
245
246 return
247 }
248 void refreshLive(ctx).catch((error: unknown) => io.log(message(error)))
249}
250hooks/orcaCopies.ts 57 lines1import { oauthAccountKey, organizationOf } from './accounts'
2import type { AccountsContext } from './accounts'
3import { AnthropicError } from './anthropic'
4import { REFRESH_TIMEOUT_MS, readVault, refreshOrcaCopy } from './credentials'
5import type { OauthAccount } from './credentials'
6import { message } from './io'
7import { orcaAccountFor } from './orca'
8import type { OrcaClaude } from './orca'
9import { orcaCopyPlace } from './orcaClient'
10
11/**
12 * Keeps Orca's copies of the saved logins from expiring. Orca writes the copy
13 * of the account selected in it as it is while a Claude terminal runs there,
14 * expired or not, and every running session then refreshes the same refresh
15 * token at once: all but the first are signed out. So each copy is refreshed
16 * here before it expires, this mod's copy with it.
17 */
18
19/** How long before it expires a copy Orca keeps is refreshed: more than the few minutes between two checks of Orca. */
20export const ORCA_COPY_MARGIN_MS = 60 * 60 * 1000
21/** The `$.store` key of the copies that could not be refreshed, by account: the expiry of the grant that failed. */
22const FAILED_KEY = 'orcaCopyFailed'
23
24/**
25 * Refreshes Orca's copy of each saved account that expires soon. The live
26 * login is Claude Code's to refresh and the one Orca has selected is Orca's,
27 * so both are left alone. A copy the server refused is not tried again until
28 * it changes, and is said once; a refresh that could not be asked is tried at
29 * the next check.
30 */
31export async function keepOrcaCopiesFresh(ctx: AccountsContext, claude: OrcaClaude): Promise<void> {
32 const { io } = ctx
33 const m = ctx.messages()
34 const liveUuid = await ctx.live.get()
35 const failed = ((await io.store.get(FAILED_KEY)) ?? {}) as Record<string, number>
36 for (const account of await ctx.accounts.get()) {
37 if (account.uuid === liveUuid) continue
38 const details = (await io.store.get(oauthAccountKey(account.uuid))) as OauthAccount | undefined
39 const kept = orcaAccountFor(claude, account.email, organizationOf(details))
40 if (!kept || kept.id === claude.activeId) continue
41 const saved = await readVault(io, account.uuid).catch(() => null)
42 if (saved === null || failed[account.uuid] === saved.claudeAiOauth.expiresAt) continue
43 const place = await orcaCopyPlace(io, kept.id).catch(() => null)
44 if (place === null) continue
45 try {
46 if ((await refreshOrcaCopy(io, account.uuid, place, ORCA_COPY_MARGIN_MS, m.refreshNoAnswer(REFRESH_TIMEOUT_MS / 1000))) === 'refreshed') {
47 io.log(`refreshed the login Orca keeps for ${account.email}`)
48 }
49 } catch (error) {
50 io.log(`the login Orca keeps for ${account.email} could not be refreshed: ${message(error)}`)
51 if (!(error instanceof AnthropicError) || error.status === undefined || error.status >= 500) continue
52 await io.store.set(FAILED_KEY, { ...failed, [account.uuid]: saved.claudeAiOauth.expiresAt })
53 ctx.toast(m.orcaCopyRefused(account.email))
54 }
55 }
56}
57