SLOPSHOPPER

SimpleCORE Accounts

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

newpanebandguardtoaststatus
v0.6.4MITupdated 2026-10-08simplecore-inc/claude-mods/mods/accounts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sc-accounts
│ ┃ account-switch ✕ › fix the failing auth test and add an audit log call │ ┃ [SC] Accounts ✕ Close │ ┃ 1: Accounts ● sc-accounts: account-switch: cannot read the release: ENOENT: no su │ ┃ ● sc-accounts: account-switch: ENOENT: no such file /Users/dev/.claud │ ┃ No saved accounts yet. Reading the ⏺ Read(src/auth.ts) │ ┃ current login. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Refresh Add account W ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── ▐Opus5.5▌ ▐app▌ ▐⚒ Toolbox▌ ▐ctx ━━━━━━ 61%▌ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── ▐Opus5.5▌ ▐app▌ ▐⚒ Toolbox▌ ▐ctx ━━━━━━ 61%▌
Pane · account-switch
[SC] Accounts ✕ Close 1: Accounts No saved accounts yet. Reading the current login. Refresh Add account Webhook
README

SimpleCORE Claude Mods

macOS: tested Linux: untested WSL: tested Windows: untested License: MIT

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.

What's inside

Accounts · /sc:accounts

The accounts pane

The status band above the prompt

  • Keep several Claude logins and switch every running session to another in one step.
  • See each account's five-hour, weekly and per-model usage, with reset times, and what it spent past its plan when Claude reports it.
  • Usage: the tokens used on this machine by day, model and project, counted from Claude Code's transcripts.
  • Storage: what ~/.claude holds by kind and project, and a cleanup of idle sessions confirmed by typing a word.
  • A status band above the prompt: account, model and effort, task, context and usage, branch and PR, lines changed. Press the account's name to open the accounts pane, or the branch or the lines changed to open the workspace.
  • An optional webhook sends the status to an external system. It is off by default; leave it off if you have nothing to send to.

Workspace · /sc:workspace

The workspace pane

  • Agents: the session's subagents, what each did last and the answers of those that finished, and the repository's worktrees; stop an agent, open a worktree's changes, remove a merged one.
  • Checkpoints: a snapshot of the working tree before every prompt; compare with one, see one turn's changes, name and pin one, or restore it.
  • Notes: numbered notes kept per project, optionally sent to Claude with every prompt; Claude can mark one done for you to confirm.
  • Diff: the files changed since the session started or since a checkpoint, up to now or a later checkpoint; each file's diff, restoring one file, and a request for a commit message.
  • Memory: the memory files Claude Code reads, global and project apart, to read as Markdown and search line by line.

Toolbox · /sc:toolbox

The toolbox buttons

  • The project's commands as buttons of one size, from ⚒ Toolbox on the status band.
  • Run and stop shell commands, and watch their log live in a dialog.
  • Add tasks found in npm, Vite, Maven, Gradle, Cargo, make, just, Compose, Go and Python projects, or Claude's own commands such as /clear and /compact.
  • Values fixed or asked at each run, with paths completed as you type.
  • Kept in .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.

Clicking and the keyboard

RequirementWhy
Claude Code in fullscreen mode: /tui fullscreen, or "tui": "fullscreen" in ~/.claude/settings.jsonOnly 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 onA 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.
  • A digit picks a tab; Esc goes back from a dialog and closes a pane.
  • Every pane also opens from its command: /sc:accounts, /sc:workspace, /sc:toolbox.

The first pane a command opens outside fullscreen mode says so in its reply.

Install

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

Update

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.

Uninstall

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:

WhatWhere
Saved loginsmacOS: keychain items of the service account-switch. Elsewhere: ~/.claude/account-switch/
Webhook tokenmacOS: 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 valuesin each project: .toolbox/
Notes and checkpoint listsin each repository's git folder: .git/sc-workspace/, a pair of files per worktree
Checkpointsin 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.

Language

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

Developing a mod covers the layout, the rules the engine enforces, the checks and how to release.

License

MIT

Source 45 files
hooks/register.tsx 518 lines
1import { 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}
518
hooks/accounts.ts 422 lines
1import 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}
422
hooks/anthropic.ts 254 lines
1import 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}
254
hooks/cleanup.ts 208 lines
1import 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}
208
hooks/count.ts 259 lines
1import 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}
259
hooks/credentials.ts 401 lines
1import 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}
401
hooks/feed.ts 221 lines
1import 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}
221
hooks/format.ts 51 lines
1import 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
51
hooks/i18n.ts 342 lines
1import 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}
342
hooks/io.ts 74 lines
1import 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}
74
hooks/lookups.ts 250 lines
1import 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}
250
hooks/orcaCopies.ts 57 lines
1import { 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