clauddy 1.17.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,16 @@ The session/weekly **%** comes straight from your Anthropic account, so it match
29
29
 
30
30
  The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed automatically. **Until you connect**, the limits area shows a _"Connect your account"_ prompt instead of percentages.
31
31
 
32
+ ### Several subscriptions
33
+
34
+ Got more than one Claude account — say a personal Pro and a Max from work? **⚙ Settings → "+ Add another account"** opens the same browser login, and the token it brings back lands in a new slot. Once two accounts exist, a list appears in Settings (and an **Account** submenu on the tray icon) to switch between them. Give up halfway and the empty slot disappears on its own — the list only ever holds accounts you actually logged into.
35
+
36
+ The widget follows **one account at a time**: the one you pick is the one whose % is shown, whose logs are counted, and the only one that can notify you. Each account keeps its own token and its own armed alerts, so switching never replays a notification you already dismissed elsewhere. Everything else — window position, display mode, zoom, thresholds — is shared.
37
+
38
+ Removing an account deletes its token from disk. The first account can't be removed, and neither can the one you're currently on — switch away first.
39
+
40
+ > Prefer one widget per account instead? Setting `CLAUDE_CONFIG_DIR` still isolates a whole instance — token, settings and logs — so you can run two Clauddys side by side.
41
+
32
42
  ## Burn rate
33
43
 
34
44
  Knowing you're at **82%** with **1h 12m** left on the window still leaves you doing arithmetic in your head. So Clauddy does it for you: it fits the slope of your recent usage and projects when you'd hit 100% — showing one extra line under the session bar:
@@ -177,7 +187,19 @@ Switching is instant — no restart. (On Windows/Linux the icon lives in the sys
177
187
 
178
188
  ## Alerts
179
189
 
180
- Optional **macOS notifications** when your session or weekly usage crosses the thresholds you set (default **80%** and **95%**) — e.g. _"Your session is over 80% — now at 82%"_. They re-arm automatically once usage drops back below a threshold (after a reset). Toggle them and edit the thresholds in **⚙ Settings**.
190
+ Optional **macOS notifications**, toggled (with their thresholds) in **⚙ Settings**:
191
+
192
+ | Notification | When |
193
+ | --- | --- |
194
+ | _Session at 82%_ — `2h 39m left · resets 6:50 PM` | Your session crosses a threshold (default **80%** and **95%**) |
195
+ | _Weekly usage at 84%_ — `resets Fri 7:00 AM` | Same, for the weekly limit |
196
+ | _Fable weekly at 84%_ — `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
197
+ | _Session window reset_ — `full budget again` | A session you had pushed past 80% rolls over |
198
+ | _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
199
+
200
+ The last threshold you set is the only one that makes a sound; the earlier ones arrive silently. Clicking any of them brings the widget to the front. Each fires once and re-arms when usage drops back below — remembered across restarts, so relaunching at 85% doesn't repeat an alert you already dismissed.
201
+
202
+ Percentages come from your account when you're logged in, and fall back to the local token estimate when you're not.
181
203
 
182
204
  The first alert asks macOS for permission; after that the app shows up in **System Settings > Notifications** like any other.
183
205
 
@@ -230,7 +252,10 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
230
252
 
231
253
  - `auth.json` — your OAuth token (file mode `600`, never committed)
232
254
  - `config.json` — your alert settings
255
+ - `alerts.json` — which notifications are already armed, so a restart doesn't repeat them
256
+ - `accounts.json` — your list of accounts and which one is active
233
257
  - `debug.json` — scratch file for the `./pet` simulator
258
+ - `accounts/<id>/` — the same `auth.json` + `alerts.json`, for each extra account
234
259
 
235
260
  Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
236
261
 
package/accounts.js ADDED
@@ -0,0 +1,145 @@
1
+ // Multiple Claude subscriptions in one widget.
2
+ //
3
+ // An account is a token plus, optionally, the Claude config dir whose logs it
4
+ // reads. The first one ("default") keeps the paths the app has always used, so
5
+ // an existing install carries over untouched; every extra account gets its own
6
+ // folder under `accounts/` for auth.json and alerts.json.
7
+ //
8
+ // Only the active account is polled — and therefore only it can notify.
9
+ const fs = require('node:fs')
10
+ const path = require('node:path')
11
+ const os = require('node:os')
12
+
13
+ const BASE_DIR = process.env.CLAUDE_CONFIG_DIR
14
+ ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
15
+ : path.join(os.homedir(), '.claude-usage-monitor')
16
+ const FILE = path.join(BASE_DIR, 'accounts.json')
17
+ const DEFAULT_ID = 'default'
18
+
19
+ function defaults() {
20
+ return { active: DEFAULT_ID, accounts: [{ id: DEFAULT_ID, label: null, claudeDir: null }] }
21
+ }
22
+
23
+ function sane(j) {
24
+ const list = (Array.isArray(j?.accounts) ? j.accounts : [])
25
+ .filter((a) => a && typeof a.id === 'string' && a.id)
26
+ .map((a) => ({
27
+ id: a.id,
28
+ label: typeof a.label === 'string' && a.label ? a.label : null,
29
+ claudeDir: typeof a.claudeDir === 'string' && a.claudeDir ? a.claudeDir : null,
30
+ }))
31
+ if (!list.some((a) => a.id === DEFAULT_ID)) list.unshift(defaults().accounts[0])
32
+ const active = list.some((a) => a.id === j?.active) ? j.active : DEFAULT_ID
33
+ return { active, accounts: list }
34
+ }
35
+
36
+ // re-read on every call instead of caching: the file is a handful of rows, it
37
+ // is read only when the account list is shown or changed, and a stale cache
38
+ // here would mean the widget polling a token it thinks it still has
39
+ function load() {
40
+ try {
41
+ return sane(JSON.parse(fs.readFileSync(FILE, 'utf8')))
42
+ } catch {
43
+ return defaults() // first run, or a corrupted file
44
+ }
45
+ }
46
+
47
+ function save(state) {
48
+ try {
49
+ fs.mkdirSync(BASE_DIR, { recursive: true })
50
+ fs.writeFileSync(FILE, JSON.stringify(state, null, 2))
51
+ } catch {}
52
+ }
53
+
54
+ // where an account's own files (auth.json, alerts.json) live
55
+ function dataDirOf(id) {
56
+ return id === DEFAULT_ID ? BASE_DIR : path.join(BASE_DIR, 'accounts', id)
57
+ }
58
+
59
+ function list() {
60
+ return load().accounts.map((a) => ({ ...a }))
61
+ }
62
+
63
+ function activeId() {
64
+ return load().active
65
+ }
66
+
67
+ function active() {
68
+ const s = load()
69
+ return s.accounts.find((a) => a.id === s.active) || s.accounts[0]
70
+ }
71
+
72
+ function find(s, id) {
73
+ return s.accounts.find((a) => a.id === id)
74
+ }
75
+
76
+ function setActive(id) {
77
+ const s = load()
78
+ const hit = find(s, id)
79
+ if (!hit) return null
80
+ s.active = id
81
+ save(s)
82
+ return hit
83
+ }
84
+
85
+ // a fresh, logged-out account — the caller switches to it and runs the usual
86
+ // browser login, which is what gives it an identity
87
+ function add(claudeDir) {
88
+ const s = load()
89
+ // two clicks inside the same millisecond used to mint the same id, and the
90
+ // duplicate then looked like the account we were already on
91
+ let id = `acct-${Date.now().toString(36)}`
92
+ while (s.accounts.some((a) => a.id === id)) {
93
+ id = `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
94
+ }
95
+ s.accounts.push({ id, label: null, claudeDir: claudeDir || null })
96
+ save(s)
97
+ return id
98
+ }
99
+
100
+ // the login flow knows the email; that's a better name than "Account 2"
101
+ function label(id, text) {
102
+ const s = load()
103
+ const a = find(s, id)
104
+ if (!a || a.label === (text || null)) return
105
+ a.label = text || null
106
+ save(s)
107
+ }
108
+
109
+ function setClaudeDir(id, dir) {
110
+ const s = load()
111
+ const a = find(s, id)
112
+ if (!a) return
113
+ a.claudeDir = dir || null
114
+ save(s)
115
+ }
116
+
117
+ // removing an account takes its token with it — leaving a stray auth.json
118
+ // behind would be a credential nobody can see or revoke from the UI
119
+ function remove(id) {
120
+ const s = load()
121
+ if (id === DEFAULT_ID) return false // the original install's data: never dropped
122
+ if (id === s.active) return false // switch away first: the widget is showing it
123
+ const i = s.accounts.findIndex((a) => a.id === id)
124
+ if (i < 0) return false
125
+ s.accounts.splice(i, 1)
126
+ save(s)
127
+ try {
128
+ fs.rmSync(dataDirOf(id), { recursive: true, force: true })
129
+ } catch {}
130
+ return true
131
+ }
132
+
133
+ module.exports = {
134
+ BASE_DIR,
135
+ DEFAULT_ID,
136
+ dataDirOf,
137
+ list,
138
+ active,
139
+ activeId,
140
+ setActive,
141
+ add,
142
+ label,
143
+ setClaudeDir,
144
+ remove,
145
+ }
package/auth.js CHANGED
@@ -17,7 +17,9 @@ const UA = 'claude-cli/2.1.181 (external, cli)'
17
17
  const DATA_DIR = process.env.CLAUDE_CONFIG_DIR
18
18
  ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
19
19
  : path.join(os.homedir(), '.claude-usage-monitor')
20
- const TOKEN_PATH = path.join(DATA_DIR, 'auth.json')
20
+ // mutable: with several accounts configured, each one keeps its token in its
21
+ // own dir and the widget switches between them at runtime
22
+ let tokenPath = path.join(DATA_DIR, 'auth.json')
21
23
 
22
24
  const b64url = (buf) =>
23
25
  buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
@@ -26,10 +28,19 @@ let tokens = null // { access_token, refresh_token, expires_at }
26
28
  let pending = null // { verifier, state }
27
29
  let profile = null // { email, name, plan } — cached account identity
28
30
 
31
+ // point the module at another account's dir — the cached token and profile
32
+ // belong to the previous one, so both are dropped
33
+ function setDataDir(dir) {
34
+ tokenPath = path.join(dir || DATA_DIR, 'auth.json')
35
+ tokens = null
36
+ profile = null
37
+ pending = null
38
+ }
39
+
29
40
  function load() {
30
41
  if (tokens) return tokens
31
42
  try {
32
- tokens = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'))
43
+ tokens = JSON.parse(fs.readFileSync(tokenPath, 'utf8'))
33
44
  } catch {
34
45
  tokens = null
35
46
  }
@@ -38,15 +49,15 @@ function load() {
38
49
  function save(t) {
39
50
  tokens = t
40
51
  try {
41
- fs.mkdirSync(path.dirname(TOKEN_PATH), { recursive: true })
42
- fs.writeFileSync(TOKEN_PATH, JSON.stringify(t, null, 2), { mode: 0o600 })
52
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true })
53
+ fs.writeFileSync(tokenPath, JSON.stringify(t, null, 2), { mode: 0o600 })
43
54
  } catch {}
44
55
  }
45
56
  function clear() {
46
57
  tokens = null
47
58
  profile = null
48
59
  try {
49
- fs.unlinkSync(TOKEN_PATH)
60
+ fs.unlinkSync(tokenPath)
50
61
  } catch {}
51
62
  }
52
63
  function isConnected() {
@@ -228,4 +239,4 @@ async function fetchProfile() {
228
239
  return profile
229
240
  }
230
241
 
231
- module.exports = { begin, complete, fetchUsage, fetchProfile, clear, isConnected }
242
+ module.exports = { begin, complete, fetchUsage, fetchProfile, clear, isConnected, setDataDir }
package/main.js CHANGED
@@ -13,8 +13,10 @@ const path = require('node:path')
13
13
  const fs = require('node:fs')
14
14
  const os = require('node:os')
15
15
  const { spawn } = require('node:child_process')
16
- const { getUsage } = require('./usage')
16
+ const usage = require('./usage')
17
+ const { getUsage } = usage
17
18
  const auth = require('./auth')
19
+ const accounts = require('./accounts')
18
20
 
19
21
  const REPO = 'renatoaug/claude-usage-monitor'
20
22
 
@@ -52,6 +54,7 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
52
54
  let trayBounds = null // last known tray icon rect, to anchor the popover
53
55
  let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
54
56
  let sessionPct = null // authoritative session % shown in the tray title
57
+ let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
55
58
  let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
56
59
  let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
57
60
 
@@ -92,46 +95,233 @@ function loadConfig() {
92
95
  return defaults
93
96
  }
94
97
 
95
- // native notification when usage crosses a threshold
96
- const armed = new Set()
97
- function checkAlerts(config, d) {
98
- alertScopes(config, [
99
- ['session', d.session.pct],
100
- ['weekly usage', d.week.pct],
101
- ])
98
+ // ---- alerts ------------------------------------------------------------------
99
+ // One notification per (scope × threshold), re-armed when usage drops back below.
100
+ // `armed` is persisted so relaunching at 85% doesn't repeat the 80% alert you
101
+ // already dismissed; it also carries the last session % so a reset is detectable
102
+ // across restarts.
103
+ // per account: two subscriptions have separate windows, so an alert armed on
104
+ // one says nothing about the other
105
+ let alertsFile = path.join(DATA_DIR, 'alerts.json')
106
+ let armed = new Set()
107
+ let lastSessionPct = null
108
+ function loadAlertState() {
109
+ try {
110
+ const j = JSON.parse(fs.readFileSync(alertsFile, 'utf8'))
111
+ armed = new Set(Array.isArray(j.armed) ? j.armed : [])
112
+ lastSessionPct = typeof j.lastSessionPct === 'number' ? j.lastSessionPct : null
113
+ } catch {} // first run, or a corrupted file: start from a clean slate
102
114
  }
103
- // per-model weekly limits only exist on the account side, so they're checked
104
- // off the OAuth poll rather than the local tick
105
- function checkScopedAlerts(config, u) {
106
- alertScopes(
107
- config,
108
- (u?.scoped || []).map((s) => [`${s.label} weekly usage`, s.pct]),
109
- )
115
+ function saveAlertState() {
116
+ try {
117
+ fs.mkdirSync(path.dirname(alertsFile), { recursive: true })
118
+ fs.writeFileSync(alertsFile, JSON.stringify({ armed: [...armed], lastSessionPct }))
119
+ } catch {}
120
+ }
121
+
122
+ function fmtDuration(ms) {
123
+ const h = Math.floor(ms / 3600000)
124
+ const m = Math.floor((ms % 3600000) / 60000)
125
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
126
+ }
127
+ // wall-clock time the window flips, in the machine's own locale + timezone
128
+ function fmtClock(ms) {
129
+ const at = new Date(Date.now() + ms)
130
+ const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
131
+ return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
132
+ }
133
+
134
+ function notify(title, body, { silent = true } = {}) {
135
+ if (!Notification.isSupported()) return
136
+ const n = new Notification({ title, body, silent })
137
+ n.on('click', showWidget) // the obvious gesture: bring the pet to the front
138
+ n.show()
139
+ }
140
+
141
+ // the alert lines carry the reset, since "what do I do about it" is a question
142
+ // about time left, not about the threshold that happened to fire
143
+ function resetLine(resetMs, withRemaining) {
144
+ if (resetMs == null) return ''
145
+ const at = `resets ${fmtClock(resetMs)}`
146
+ return withRemaining && resetMs > 0 ? `${fmtDuration(resetMs)} left · ${at}` : at
110
147
  }
148
+
149
+ // usage crossing a threshold. `scopes` are {label, pct, resetMs, session} rows;
150
+ // the real (OAuth) numbers are preferred over the local token estimate, which is
151
+ // only a budget guess and can be off by an order of magnitude.
111
152
  function alertScopes(config, scopes) {
112
- if (!config.alerts || !Notification.isSupported()) return
153
+ if (!config.alerts) return
113
154
  const ths = config.alertThresholds || [80, 95]
114
- for (const [name, pct] of scopes) {
155
+ const top = Math.max(...ths)
156
+ let dirty = false
157
+ for (const { label, pct, resetMs, session } of scopes) {
158
+ if (pct == null) continue
115
159
  for (const t of ths) {
116
- const key = `${name}:${t}`
160
+ const key = `${label}:${t}`
117
161
  if (pct >= t) {
118
162
  if (!armed.has(key)) {
119
163
  armed.add(key)
120
- new Notification({
121
- title: 'Clauddy',
122
- body: `Your ${name} is over ${t}% — now at ${Math.round(pct)}%`,
123
- silent: false,
124
- }).show()
164
+ dirty = true
165
+ const urgent = t === top
166
+ notify(
167
+ `${label} at ${Math.round(pct)}%${urgent ? ' — almost out' : ''}`,
168
+ resetLine(resetMs, session),
169
+ { silent: !urgent }, // 80% is a heads-up; the last threshold earns a sound
170
+ )
125
171
  }
126
- } else {
127
- armed.delete(key) // re-arm when it drops below
172
+ } else if (armed.delete(key)) {
173
+ dirty = true // re-arm when it drops below
128
174
  }
129
175
  }
130
176
  }
177
+ if (dirty) saveAlertState()
178
+ }
179
+
180
+ function checkAlerts(config, d) {
181
+ const r = realUsage // authoritative when logged in; the estimate is the fallback
182
+ const session = r?.session ?? d.session
183
+ const week = r?.week ?? d.week
184
+ alertScopes(config, [
185
+ { label: 'Session', pct: session.pct, resetMs: session.resetMs, session: true },
186
+ { label: 'Weekly usage', pct: week.pct, resetMs: week.resetMs },
187
+ ])
188
+ checkWindowReset(config, session.pct)
189
+ }
190
+ // per-model weekly limits only exist on the account side, so they're checked
191
+ // off the OAuth poll rather than the local tick
192
+ function checkScopedAlerts(config, u) {
193
+ alertScopes(
194
+ config,
195
+ (u?.scoped || []).map((s) => ({
196
+ label: `${s.label} weekly`,
197
+ pct: s.pct,
198
+ resetMs: s.resetMs,
199
+ })),
200
+ )
201
+ }
202
+
203
+ // "you can work again" — only worth saying to someone who was actually near the
204
+ // ceiling, so a window flipping at 20% stays silent
205
+ const RESET_FROM = 80
206
+ const RESET_TO = 5
207
+ function checkWindowReset(config, pct) {
208
+ if (pct == null) return
209
+ const was = lastSessionPct
210
+ lastSessionPct = pct
211
+ if (config.alerts && was != null && was >= RESET_FROM && pct <= RESET_TO) {
212
+ notify('Session window reset', 'full budget again')
213
+ }
214
+ if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
215
+ }
216
+
217
+ // the token going dead is a silent failure otherwise: the widget quietly falls
218
+ // back to the local estimate and keeps showing a number the user trusts
219
+ function alertAuthLost(config) {
220
+ if (!config.alerts) return
221
+ notify('Clauddy lost access to your usage', 'open Settings to reconnect')
222
+ }
223
+
224
+ // ---- accounts ----------------------------------------------------------------
225
+ // One widget, several Claude subscriptions. Switching rebinds the token store
226
+ // (auth), the log dir (usage) and the alert state — everything that is "whose
227
+ // usage is this" — while the window itself (position, mode, zoom, settings)
228
+ // stays global, because there is only one pet.
229
+ function applyAccount(acc) {
230
+ const dir = accounts.dataDirOf(acc.id)
231
+ alertsFile = path.join(dir, 'alerts.json')
232
+ auth.setDataDir(dir)
233
+ usage.setClaudeDir(acc.claudeDir)
234
+ armed = new Set()
235
+ lastSessionPct = null
236
+ loadAlertState()
237
+ }
238
+
239
+ // `connected` is read off disk rather than through auth, which only ever knows
240
+ // about the active account
241
+ function accountsPayload() {
242
+ return {
243
+ active: accounts.activeId(),
244
+ accounts: accounts.list().map((a) => ({
245
+ id: a.id,
246
+ label: a.label,
247
+ connected: hasToken(a.id),
248
+ })),
249
+ }
250
+ }
251
+
252
+ // `busy` is the account being switched to, while its usage is still loading
253
+ function sendAccounts(busy = null) {
254
+ if (win && !win.isDestroyed()) win.webContents.send('accounts', { ...accountsPayload(), busy })
255
+ }
256
+
257
+ // the numbers on screen belong to the old account: drop them before the new
258
+ // account's first poll lands, so nothing stale is ever attributed to it
259
+ function switchAccount(id) {
260
+ const leaving = accounts.activeId()
261
+ if (id === leaving) return
262
+ const acc = accounts.setActive(id)
263
+ if (!acc) return
264
+ saveAlertState() // the account we're leaving keeps what it had armed
265
+ pruneEmpty(leaving)
266
+ applyAccount(acc)
267
+ clearTimeout(usageTimer)
268
+ usageBackoff = 5 * 60 * 1000
269
+ pushRealUsage(null)
270
+ if (win && !win.isDestroyed()) {
271
+ win.webContents.send('auth-state', { connected: auth.isConnected() })
272
+ win.webContents.send('profile', null)
273
+ }
274
+ sendAccounts(id) // the row goes pending right away…
275
+
276
+ // …because the rest is synchronous and slow: re-reading the new account's
277
+ // logs walks every .jsonl under its projects dir, which blocks this process
278
+ // for a beat on a busy machine. Yielding first lets the renderer paint the
279
+ // pending state instead of freezing mid-click.
280
+ setTimeout(() => {
281
+ if (doTick) doTick()
282
+ startUsagePoll()
283
+ sendProfile()
284
+ updateTray()
285
+ sendAccounts()
286
+ }, 0)
131
287
  }
132
288
 
289
+ // a slot only earns its place by holding a login. One that was never connected
290
+ // — the browser flow abandoned, or "add" clicked twice — is dropped the moment
291
+ // we leave it, so the list can't fill up with "Not connected yet".
292
+ // asked of any account, not just the active one — auth only knows about the
293
+ // dir it is currently pointed at
294
+ function hasToken(id) {
295
+ return fs.existsSync(path.join(accounts.dataDirOf(id), 'auth.json'))
296
+ }
297
+
298
+ function pruneEmpty(id) {
299
+ if (id === accounts.DEFAULT_ID || id === accounts.activeId()) return
300
+ const acc = accounts.list().find((a) => a.id === id)
301
+ if (!acc || acc.label || hasToken(id)) return
302
+ accounts.remove(id)
303
+ }
304
+
305
+ ipcMain.on('accounts-switch', (_e, id) => switchAccount(String(id)))
306
+ // "add an account" *is* "log in": the browser opens on the new, empty slot, so
307
+ // the token lands in it and not in the account we just left
308
+ ipcMain.on('accounts-add', () => {
309
+ switchAccount(accounts.add())
310
+ startLogin()
311
+ })
312
+ ipcMain.on('accounts-remove', (_e, id) => {
313
+ if (accounts.remove(String(id))) sendAccounts()
314
+ })
315
+
133
316
  function createWindow() {
134
317
  config = loadConfig()
318
+ // a login abandoned in an earlier run: nothing stays pending across a restart,
319
+ // so fall back to the first account and let the empty slots go
320
+ const boot = accounts.active()
321
+ if (boot.id !== accounts.DEFAULT_ID && !boot.label && !hasToken(boot.id))
322
+ accounts.setActive(accounts.DEFAULT_ID)
323
+ for (const a of accounts.list()) pruneEmpty(a.id)
324
+ applyAccount(accounts.active())
135
325
  const { workAreaSize } = screen.getPrimaryDisplay()
136
326
  const H = 480
137
327
 
@@ -207,6 +397,7 @@ function createWindow() {
207
397
  win.webContents.send('config', publicConfig(config))
208
398
  win.webContents.send('version', app.getVersion())
209
399
  win.webContents.send('auth-state', { connected: auth.isConnected() })
400
+ sendAccounts()
210
401
  pollTimer = setInterval(tick, config.pollIntervalMs)
211
402
  startUsagePoll()
212
403
  sendProfile()
@@ -265,9 +456,28 @@ function destroyTray() {
265
456
  }
266
457
  }
267
458
 
459
+ // the accounts entry only earns its place once there's more than one
460
+ function accountsMenuItems() {
461
+ const list = accounts.list()
462
+ if (list.length < 2) return []
463
+ const activeId = accounts.activeId()
464
+ return [
465
+ {
466
+ label: 'Account',
467
+ submenu: list.map((a) => ({
468
+ label: a.label || 'Not connected',
469
+ type: 'radio',
470
+ checked: a.id === activeId,
471
+ click: () => switchAccount(a.id),
472
+ })),
473
+ },
474
+ ]
475
+ }
476
+
268
477
  function trayMenu() {
269
478
  return Menu.buildFromTemplate([
270
479
  { label: 'Open Clauddy', click: () => showPopover() },
480
+ ...accountsMenuItems(),
271
481
  {
272
482
  label: 'Open Usage page',
273
483
  click: () => shell.openExternal('https://claude.ai/settings/usage'),
@@ -289,6 +499,16 @@ function showPopover() {
289
499
  win.focus()
290
500
  }
291
501
 
502
+ // bring the widget to the front from wherever it is, in either mode
503
+ function showWidget() {
504
+ if (!win || win.isDestroyed()) return
505
+ if (currentMode === 'menubar') showPopover()
506
+ else {
507
+ win.show()
508
+ win.focus()
509
+ }
510
+ }
511
+
292
512
  // route every programmatic move through here so the 'moved' handler can tell
293
513
  // our own repositioning apart from a genuine user drag (and skip saving it)
294
514
  function moveWindow(x, y) {
@@ -369,6 +589,7 @@ function updateTray() {
369
589
 
370
590
  // send real usage to the renderer and refresh the tray title in one place
371
591
  function pushRealUsage(u) {
592
+ realUsage = u
372
593
  sessionPct = u?.session ? u.session.pct : null
373
594
  updateTray()
374
595
  checkScopedAlerts(config, u)
@@ -428,6 +649,8 @@ async function pollUsage() {
428
649
  } else if (e && e.status === 401) {
429
650
  auth.clear()
430
651
  pushRealUsage(null)
652
+ sendAccounts()
653
+ alertAuthLost(config)
431
654
  if (win && !win.isDestroyed()) {
432
655
  win.webContents.send('auth-state', { connected: false })
433
656
  win.webContents.send('profile', null)
@@ -445,17 +668,28 @@ async function sendProfile() {
445
668
  if (!auth.isConnected()) return
446
669
  try {
447
670
  const p = await auth.fetchProfile()
671
+ if (p?.email) {
672
+ accounts.label(accounts.activeId(), p.email) // a better name than "acct-xyz"
673
+ sendAccounts()
674
+ }
448
675
  if (win && !win.isDestroyed()) win.webContents.send('profile', p)
449
676
  } catch {} // non-fatal: the chip just stays hidden
450
677
  }
451
678
 
452
- ipcMain.on('auth-start', () => shell.openExternal(auth.begin()))
679
+ // begin() has to run *after* any account switch: switching resets the pending
680
+ // PKCE verifier, which would strand a login started before it
681
+ function startLogin() {
682
+ shell.openExternal(auth.begin())
683
+ if (win && !win.isDestroyed()) win.webContents.send('auth-pending')
684
+ }
685
+ ipcMain.on('auth-start', startLogin)
453
686
  ipcMain.on('auth-code', async (_e, code) => {
454
687
  const ok = () => {
455
688
  if (win && !win.isDestroyed()) {
456
689
  win.webContents.send('auth-state', { connected: true })
457
690
  win.webContents.send('auth-result', { ok: true })
458
691
  }
692
+ sendAccounts()
459
693
  sendProfile()
460
694
  }
461
695
  try {
@@ -484,6 +718,7 @@ ipcMain.on('auth-logout', () => {
484
718
  auth.clear()
485
719
  clearTimeout(usageTimer)
486
720
  pushRealUsage(null)
721
+ sendAccounts()
487
722
  if (win && !win.isDestroyed()) {
488
723
  win.webContents.send('auth-state', { connected: false })
489
724
  win.webContents.send('profile', null)
@@ -503,8 +738,14 @@ ipcMain.on('save-config', (_e, patch) => {
503
738
  fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
504
739
  fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
505
740
  } catch {}
741
+ const prevThresholds = String(config.alertThresholds)
506
742
  config = loadConfig()
507
- armed.clear() // re-arm alerts with new thresholds
743
+ // re-arm only when the thresholds actually moved: saving an unrelated setting
744
+ // (zoom, mode…) shouldn't replay an alert the user already dismissed
745
+ if (String(config.alertThresholds) !== prevThresholds) {
746
+ armed.clear()
747
+ saveAlertState()
748
+ }
508
749
  if (doTick) doTick()
509
750
  if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
510
751
  applyMode(config.mode) // switch between floating widget and menu-bar popover live
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.17.1",
4
+ "version": "1.19.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "preload.js",
14
14
  "usage.js",
15
15
  "auth.js",
16
+ "accounts.js",
16
17
  "config.json",
17
18
  "renderer"
18
19
  ],
@@ -71,6 +72,7 @@
71
72
  "preload.js",
72
73
  "usage.js",
73
74
  "auth.js",
75
+ "accounts.js",
74
76
  "config.json",
75
77
  "renderer/**",
76
78
  "build/trayTemplate.png",
package/preload.js CHANGED
@@ -10,10 +10,15 @@ contextBridge.exposeInMainWorld('api', {
10
10
  onRealUsage: (cb) => ipcRenderer.on('real-usage', (_e, u) => cb(u)),
11
11
  onAuthState: (cb) => ipcRenderer.on('auth-state', (_e, s) => cb(s)),
12
12
  onProfile: (cb) => ipcRenderer.on('profile', (_e, p) => cb(p)),
13
+ onAuthPending: (cb) => ipcRenderer.on('auth-pending', () => cb()),
13
14
  onAuthResult: (cb) => ipcRenderer.on('auth-result', (_e, r) => cb(r)),
14
15
  authStart: () => ipcRenderer.send('auth-start'),
15
16
  authCode: (code) => ipcRenderer.send('auth-code', code),
16
17
  authLogout: () => ipcRenderer.send('auth-logout'),
18
+ onAccounts: (cb) => ipcRenderer.on('accounts', (_e, a) => cb(a)),
19
+ accountSwitch: (id) => ipcRenderer.send('accounts-switch', id),
20
+ accountAdd: () => ipcRenderer.send('accounts-add'),
21
+ accountRemove: (id) => ipcRenderer.send('accounts-remove', id),
17
22
  onDebugState: (cb) => ipcRenderer.on('debug-state', (_e, s) => cb(s)),
18
23
  onVersion: (cb) => ipcRenderer.on('version', (_e, v) => cb(v)),
19
24
  onUpdateStatus: (cb) => ipcRenderer.on('update-status', (_e, s) => cb(s)),
@@ -305,8 +305,22 @@
305
305
  <!-- settings panel -->
306
306
  <div id="settings">
307
307
  <div id="account">
308
+ <!-- who you are comes first: the list, then the login for whichever
309
+ account is active — so "add" and the code field sit together -->
310
+ <div id="acc-connected">
311
+ <span class="acc-ok" id="acc-ok">● Connected</span>
312
+ <button id="acc-logout" class="acc-link">Disconnect</button>
313
+ </div>
314
+ <!-- one row per subscription; only the active one is polled -->
315
+ <div id="acc-switch" hidden>
316
+ <div class="set-hint set-hint-left">Accounts</div>
317
+ <div id="acc-list"></div>
318
+ </div>
319
+ <button id="acc-add" class="acc-link">+ Add another account</button>
308
320
  <div id="acc-disconnected">
309
- <div class="set-hint set-hint-left">Connect your Claude account to show your real usage.</div>
321
+ <div class="set-hint set-hint-left" id="acc-intro">
322
+ Connect your Claude account to show your real usage.
323
+ </div>
310
324
  <button id="acc-connect" class="acc-btn">Log in with browser</button>
311
325
  <div id="acc-paste">
312
326
  <div class="set-hint set-hint-left">Paste the code from the browser page:</div>
@@ -315,10 +329,6 @@
315
329
  <div id="acc-msg"></div>
316
330
  </div>
317
331
  </div>
318
- <div id="acc-connected">
319
- <span class="acc-ok" id="acc-ok">● Connected</span>
320
- <button id="acc-logout" class="acc-link">Disconnect</button>
321
- </div>
322
332
  </div>
323
333
 
324
334
  <div id="set-rows">
package/renderer/pet.js CHANGED
@@ -682,7 +682,9 @@ window.api.onAuthState((s) => {
682
682
  if (!on) {
683
683
  realUsage = null
684
684
  document.body.classList.remove('live')
685
- el('acc-paste').classList.remove('show')
685
+ // main sends this before it opens the browser for a new login, so the
686
+ // pending state that comes next is not undone by it
687
+ endLogin()
686
688
  showProfile(null)
687
689
  }
688
690
  if (document.body.classList.contains('settings-open')) fitSize()
@@ -720,12 +722,94 @@ function setPlan(node, plan) {
720
722
  }
721
723
  }
722
724
  window.api.onProfile(showProfile)
725
+
726
+ // ---- accounts ---------------------------------------------------------------
727
+ // The list is only worth showing once there are two; with a single account the
728
+ // panel keeps the shape it has always had, plus the "add" link.
729
+ let pendingRemove = null // id whose × is armed, so removal takes two clicks
730
+ let lastAccounts = null
731
+
732
+ function renderAccounts(a) {
733
+ lastAccounts = a
734
+ const list = a?.accounts || []
735
+ const box = el('acc-list')
736
+ el('acc-switch').hidden = list.length < 2
737
+ box.textContent = ''
738
+ box.classList.toggle('busy', !!a?.busy)
739
+ for (const acc of list) {
740
+ const active = acc.id === a.active
741
+ const row = document.createElement('div')
742
+ row.className = 'acc-item'
743
+ if (active) row.classList.add('active')
744
+ if (acc.connected) row.classList.add('connected')
745
+ // the account being switched to owns the spinner: everything on screen
746
+ // still belongs to the one we're leaving
747
+ if (a.busy === acc.id) row.classList.add('loading')
748
+
749
+ const dot = document.createElement('span')
750
+ dot.className = 'dot'
751
+ const who = document.createElement('span')
752
+ who.className = 'who'
753
+ who.textContent =
754
+ acc.label || (acc.connected ? 'Connected' : active ? 'Waiting for login…' : 'Not connected')
755
+ row.append(dot, who)
756
+
757
+ // the first account holds the original install's data, and the active one
758
+ // is what the widget is showing — neither can be removed from under you
759
+ if (acc.id !== 'default' && !active) {
760
+ const x = document.createElement('button')
761
+ const armed = pendingRemove === acc.id
762
+ x.className = armed ? 'drop armed' : 'drop'
763
+ x.textContent = armed ? 'remove?' : '\u00d7'
764
+ x.title = 'Remove this account'
765
+ x.addEventListener('click', (e) => {
766
+ e.stopPropagation() // the row itself switches accounts
767
+ if (armed) {
768
+ pendingRemove = null
769
+ window.api.accountRemove(acc.id)
770
+ } else {
771
+ pendingRemove = acc.id // a click deletes a token: ask once
772
+ renderAccounts(a)
773
+ }
774
+ })
775
+ row.appendChild(x)
776
+ }
777
+
778
+ if (!active) row.addEventListener('click', () => switchAccount(acc.id))
779
+ box.appendChild(row)
780
+ }
781
+ fitSize()
782
+ }
783
+
784
+ // paint the pending state from the click itself rather than waiting for main to
785
+ // answer — the answer is exactly what takes a moment
786
+ function switchAccount(id) {
787
+ if (lastAccounts?.busy) return
788
+ endLogin() // the code from the account we're leaving is no good here
789
+ pendingRemove = null
790
+ renderAccounts({ ...lastAccounts, busy: id })
791
+ window.api.accountSwitch(id)
792
+ }
793
+
794
+ window.api.onAccounts((a) => {
795
+ pendingRemove = null
796
+ renderAccounts(a)
797
+ })
798
+
799
+ function endLogin() {
800
+ document.body.classList.remove('awaiting')
801
+ el('acc-paste').classList.remove('show')
802
+ el('acc-code').value = ''
803
+ el('acc-msg').textContent = ''
804
+ }
805
+ el('acc-add').addEventListener('click', () => {
806
+ el('acc-msg').textContent = ''
807
+ window.api.accountAdd() // switches to a fresh slot and opens the browser
808
+ })
723
809
  let successTimer = null
724
810
  window.api.onAuthResult((r) => {
725
811
  if (r?.ok) {
726
- el('acc-msg').textContent = ''
727
- el('acc-code').value = ''
728
- el('acc-paste').classList.remove('show')
812
+ endLogin()
729
813
  // logging in is done: land back on the home panel, where the fresh live
730
814
  // meters show up under a short-lived cheer — and the pet throws confetti
731
815
  document.body.classList.remove('settings-open')
@@ -738,6 +822,8 @@ window.api.onAuthResult((r) => {
738
822
  }, 6000)
739
823
  celebrate()
740
824
  } else {
825
+ // failed: put "Log in with browser" back, so a retry is one click away
826
+ document.body.classList.remove('awaiting')
741
827
  const e = r?.error || ''
742
828
  el('acc-msg').textContent = /429|rate_limit/i.test(e)
743
829
  ? 'Rate limited by Anthropic — wait a few minutes, then try once with a fresh code.'
@@ -794,9 +880,13 @@ el('close').addEventListener('click', () => window.api.quit())
794
880
  el('usage').addEventListener('click', () => window.api.openUsage())
795
881
 
796
882
  // account login (browser flow)
797
- el('acc-connect').addEventListener('click', () => {
798
- window.api.authStart() // opens the browser to log in
799
- el('acc-paste').classList.add('show') // reveal the code field
883
+ el('acc-connect').addEventListener('click', () => window.api.authStart())
884
+ // main opened the browser — the only thing left to do is paste the code back,
885
+ // so the panel narrows to exactly that
886
+ window.api.onAuthPending(() => {
887
+ document.body.classList.add('awaiting')
888
+ el('acc-paste').classList.add('show')
889
+ el('acc-code').focus()
800
890
  fitSize()
801
891
  })
802
892
  // the Connect button only lights up once there's a code to submit
@@ -1025,6 +1115,7 @@ if (typeof module === 'object' && module.exports) {
1025
1115
  renderProjects,
1026
1116
  renderHeat,
1027
1117
  showProfile,
1118
+ renderAccounts,
1028
1119
  burn,
1029
1120
  }
1030
1121
  }
@@ -683,6 +683,109 @@ body.live .live-only {
683
683
  gap: 10px; /* the email ellipsizes instead of touching Disconnect */
684
684
  flex-wrap: wrap; /* the success line drops to its own row */
685
685
  }
686
+ #acc-switch {
687
+ margin-top: 10px;
688
+ }
689
+ #acc-switch[hidden] {
690
+ display: none;
691
+ }
692
+ #acc-list {
693
+ display: flex;
694
+ flex-direction: column;
695
+ margin-top: 2px;
696
+ }
697
+ #acc-list.busy {
698
+ pointer-events: none; /* one switch at a time */
699
+ }
700
+ /* a plain list, not a stack of cards: the panel already has enough boxes */
701
+ .acc-item {
702
+ display: flex;
703
+ align-items: center;
704
+ gap: 7px;
705
+ padding: 5px 6px;
706
+ border-radius: 6px;
707
+ cursor: pointer;
708
+ color: var(--muted);
709
+ transition:
710
+ background 0.15s,
711
+ color 0.15s;
712
+ }
713
+ .acc-item:hover {
714
+ background: rgba(255, 255, 255, 0.06);
715
+ color: var(--text);
716
+ }
717
+ .acc-item.active {
718
+ background: rgba(217, 119, 87, 0.14);
719
+ color: var(--text);
720
+ cursor: default;
721
+ }
722
+ .acc-item .dot {
723
+ width: 5px;
724
+ height: 5px;
725
+ border-radius: 50%;
726
+ background: rgba(255, 255, 255, 0.22); /* logged out: a row with no usage yet */
727
+ flex: none;
728
+ }
729
+ .acc-item.connected .dot {
730
+ background: var(--green);
731
+ }
732
+ .acc-item .who {
733
+ flex: 1;
734
+ min-width: 0;
735
+ font-size: 11px;
736
+ overflow: hidden;
737
+ text-overflow: ellipsis;
738
+ white-space: nowrap;
739
+ }
740
+ /* switching re-reads that account's logs, which takes a beat — say so */
741
+ .acc-item.loading .dot {
742
+ background: var(--coral);
743
+ animation: acc-pulse 0.9s ease-in-out infinite;
744
+ }
745
+ .acc-item.loading .who {
746
+ opacity: 0.6;
747
+ }
748
+ @keyframes acc-pulse {
749
+ 0%,
750
+ 100% {
751
+ opacity: 1;
752
+ }
753
+ 50% {
754
+ opacity: 0.2;
755
+ }
756
+ }
757
+ .acc-item .drop {
758
+ border: none;
759
+ background: none;
760
+ color: var(--muted);
761
+ font-size: 12px;
762
+ line-height: 1;
763
+ padding: 0 2px;
764
+ cursor: pointer;
765
+ opacity: 0; /* one row at a time, on hover: this deletes a token */
766
+ transition: opacity 0.15s;
767
+ }
768
+ .acc-item:hover .drop {
769
+ opacity: 0.6;
770
+ }
771
+ .acc-item .drop:hover {
772
+ opacity: 1;
773
+ color: var(--text);
774
+ }
775
+ /* armed: the confirm has to be legible whether or not the cursor is on the row */
776
+ .acc-item .drop.armed,
777
+ .acc-item:hover .drop.armed {
778
+ opacity: 1;
779
+ font-size: 10px;
780
+ color: var(--coral);
781
+ }
782
+ #acc-add {
783
+ margin-top: 6px;
784
+ }
785
+ /* while a login is pending, "add another" would start a second one */
786
+ body.awaiting #acc-add {
787
+ display: none;
788
+ }
686
789
  #acc-paste {
687
790
  display: none;
688
791
  margin-top: 8px;
@@ -712,6 +815,15 @@ body.auth-on #acc-disconnected {
712
815
  body.auth-on #acc-connected {
713
816
  display: flex;
714
817
  }
818
+ /* the browser is already open on the login page: the button that opens it and
819
+ the pitch for connecting are both noise now — only the code field matters */
820
+ body.awaiting #acc-connect,
821
+ body.awaiting #acc-intro {
822
+ display: none;
823
+ }
824
+ #acc-disconnected {
825
+ margin-top: 10px;
826
+ }
715
827
 
716
828
  /* just logged in: a short-lived green cheer at the top of the home panel */
717
829
  #home-success {
package/usage.js CHANGED
@@ -2,8 +2,26 @@ const fs = require('node:fs')
2
2
  const path = require('node:path')
3
3
  const os = require('node:os')
4
4
 
5
- const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
6
- const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects')
5
+ // the Claude config dir whose logs we read. mutable because the widget can
6
+ // switch between accounts at runtime, each one pointing at its own dir.
7
+ const DEFAULT_CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
8
+ let claudeDir = DEFAULT_CLAUDE_DIR
9
+ let projectsDir = path.join(claudeDir, 'projects')
10
+
11
+ // point the reader at another account's logs. fileCache survives: it is keyed
12
+ // by absolute path, so entries from another dir can't collide — and keeping it
13
+ // is what makes switching back to an account instant instead of a full re-scan.
14
+ // projectLabelCache is keyed by a directory *name*, which can repeat across
15
+ // dirs, so that one goes.
16
+ function setClaudeDir(dir) {
17
+ // an account with no dir of its own reads the default one — which is still
18
+ // CLAUDE_CONFIG_DIR when the whole instance is isolated that way
19
+ const next = dir || DEFAULT_CLAUDE_DIR
20
+ if (next === claudeDir) return
21
+ claudeDir = next
22
+ projectsDir = path.join(claudeDir, 'projects')
23
+ projectLabelCache.clear()
24
+ }
7
25
 
8
26
  function labelFor(model) {
9
27
  if (!model) return 'desconhecido'
@@ -103,7 +121,7 @@ function projectLabel(dirName) {
103
121
  }
104
122
 
105
123
  function projectDirOf(file) {
106
- const rel = path.relative(PROJECTS_DIR, file)
124
+ const rel = path.relative(projectsDir, file)
107
125
  const first = rel.split(path.sep)[0]
108
126
  return first && first !== '..' ? first : null
109
127
  }
@@ -279,7 +297,7 @@ function getUsage(config) {
279
297
  const scanCutoff = start30 - dayMs
280
298
 
281
299
  const files = []
282
- walkJsonl(PROJECTS_DIR, files, scanCutoff)
300
+ walkJsonl(projectsDir, files, scanCutoff)
283
301
 
284
302
  let lastMtime = 0
285
303
  let newestFile = null
@@ -397,4 +415,12 @@ function getUsage(config) {
397
415
  // labelFor/tokensOf/detectActivity are exported for the tests — they're the
398
416
  // parts that decode Claude Code's log format, which is the thing most likely
399
417
  // to change out from under us.
400
- module.exports = { getUsage, labelFor, projectLabel, tokensOf, detectActivity, PLAN_BUDGETS }
418
+ module.exports = {
419
+ getUsage,
420
+ setClaudeDir,
421
+ labelFor,
422
+ projectLabel,
423
+ tokensOf,
424
+ detectActivity,
425
+ PLAN_BUDGETS,
426
+ }