clauddy 1.21.1 → 1.22.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.
Files changed (4) hide show
  1. package/README.md +1 -1
  2. package/auth.js +54 -4
  3. package/main.js +50 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -46,7 +46,7 @@ Knowing you're at **82%** with **1h 12m** left on the window still leaves you do
46
46
  - **`~35m left at this pace`** (in coral) — you'd run out before the window resets. Ease off, or wrap up.
47
47
  - **`resets before you run out`** — the reset gets there first. Carry on.
48
48
 
49
- The slope is fitted over your **session tokens** rather than the account %. The % is the number you care about, but it arrives as a whole number every ~5 minutes — over a short window the whole signal is a single `16 → 17` step, which throws the fitted pace off by multiples. Local-log tokens step too — one jump per assistant turn — but in increments some 10–20× finer, so the slope is far steadier; the account % then anchors it, converting tokens into % and re-calibrating on every poll.
49
+ The slope is fitted over your **session tokens** rather than the account %. The % is the number you care about, but it arrives as a whole number every few minutes — over a short window the whole signal is a single `16 → 17` step, which throws the fitted pace off by multiples. Local-log tokens step too — one jump per assistant turn — but in increments some 10–20× finer, so the slope is far steadier; the account % then anchors it, converting tokens into % and re-calibrating on every poll.
50
50
 
51
51
  It reads your **recent** pace, not the session average: go quiet for a few minutes and the projection eases off, which is the point.
52
52
 
package/auth.js CHANGED
@@ -35,12 +35,25 @@ function setDataDir(dir) {
35
35
  tokens = null
36
36
  profile = null
37
37
  pending = null
38
+ unreadable = false
38
39
  }
39
40
 
41
+ // Set when the file itself could not be read for a reason other than "it is not
42
+ // there" — a disk still waking up, say. That must not read as a logout, or the
43
+ // widget asks for a login it doesn't need and every account looks gone. A file
44
+ // we did read but cannot parse is a different matter: that one really is dead.
45
+ let unreadable = false
40
46
  function load() {
41
47
  if (tokens) return tokens
48
+ let raw = null
42
49
  try {
43
- tokens = JSON.parse(fs.readFileSync(tokenPath, 'utf8'))
50
+ raw = fs.readFileSync(tokenPath, 'utf8')
51
+ unreadable = false
52
+ } catch (e) {
53
+ unreadable = e?.code !== 'ENOENT'
54
+ }
55
+ try {
56
+ tokens = raw == null ? null : JSON.parse(raw)
44
57
  } catch {
45
58
  tokens = null
46
59
  }
@@ -56,12 +69,15 @@ function save(t) {
56
69
  function clear() {
57
70
  tokens = null
58
71
  profile = null
72
+ unreadable = false
59
73
  try {
60
74
  fs.unlinkSync(tokenPath)
61
75
  } catch {}
62
76
  }
63
77
  function isConnected() {
64
- return !!load()
78
+ // a token we merely failed to read still counts: the account is connected,
79
+ // we just couldn't see it this instant
80
+ return !!load() || unreadable
65
81
  }
66
82
 
67
83
  // Step 1: build the authorize URL (opens in the browser)
@@ -152,7 +168,13 @@ async function refresh() {
152
168
 
153
169
  async function validToken() {
154
170
  const t = load()
155
- if (!t) throw Object.assign(new Error('not connected'), { status: 401 })
171
+ // status 0 is "try again later", not "log in again": only a token that is
172
+ // genuinely absent should send the user back through the browser
173
+ if (!t) {
174
+ throw Object.assign(new Error(unreadable ? 'token unreadable' : 'not connected'), {
175
+ status: unreadable ? 0 : 401,
176
+ })
177
+ }
156
178
  if (!t.expires_at || t.expires_at - Date.now() < 60000) await refresh()
157
179
  return load().access_token
158
180
  }
@@ -189,6 +211,24 @@ function scopedWeeks(j) {
189
211
  return out
190
212
  }
191
213
 
214
+ // What the endpoint says its own budget is. Undocumented, so we only ever read
215
+ // it — nothing depends on it yet. Knowing the real numbers is what tells us how
216
+ // close the activity poll's floor can get.
217
+ let rateLimit = null
218
+ function readRateLimit(res) {
219
+ const out = {}
220
+ try {
221
+ for (const [k, v] of res.headers) {
222
+ if (k.startsWith('anthropic-ratelimit-')) out[k.slice(20)] = v
223
+ }
224
+ } catch {
225
+ return // no headers to read — nothing here is worth failing a fetch over
226
+ }
227
+ if (!Object.keys(out).length) return
228
+ if (JSON.stringify(out) !== JSON.stringify(rateLimit)) console.log('usage rate limit', out)
229
+ rateLimit = out
230
+ }
231
+
192
232
  // Step 3: fetch the authoritative usage
193
233
  async function fetchUsage() {
194
234
  const token = await validToken()
@@ -206,6 +246,7 @@ async function fetchUsage() {
206
246
  status: res.status,
207
247
  })
208
248
  }
249
+ readRateLimit(res)
209
250
  const j = await res.json()
210
251
  return {
211
252
  session: win(j.five_hour) || { pct: 0, resetMs: null },
@@ -239,4 +280,13 @@ async function fetchProfile() {
239
280
  return profile
240
281
  }
241
282
 
242
- module.exports = { begin, complete, fetchUsage, fetchProfile, clear, isConnected, setDataDir }
283
+ module.exports = {
284
+ rateLimit: () => rateLimit,
285
+ begin,
286
+ complete,
287
+ fetchUsage,
288
+ fetchProfile,
289
+ clear,
290
+ isConnected,
291
+ setDataDir,
292
+ }
package/main.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  Tray,
9
9
  Menu,
10
10
  nativeImage,
11
+ powerMonitor,
11
12
  } = require('electron')
12
13
  const path = require('node:path')
13
14
  const fs = require('node:fs')
@@ -233,6 +234,7 @@ function applyAccount(acc) {
233
234
  usage.setClaudeDir(acc.claudeDir)
234
235
  armed = new Set()
235
236
  lastSessionPct = null
237
+ lastSeenTokens = null // the new account's first read is a baseline, not a spend
236
238
  loadAlertState()
237
239
  }
238
240
 
@@ -336,6 +338,7 @@ function createWindow() {
336
338
  for (const a of accounts.list()) pruneEmpty(a.id)
337
339
  accounts.pruneOrphans() // folders those slots left behind
338
340
  applyAccount(accounts.active())
341
+ powerMonitor?.on?.('resume', onResume)
339
342
  const { workAreaSize } = screen.getPrimaryDisplay()
340
343
  const H = 480
341
344
 
@@ -400,6 +403,7 @@ function createWindow() {
400
403
  const data = getUsage(config)
401
404
  win.webContents.send('usage', data)
402
405
  checkAlerts(config, data)
406
+ onLocalActivity(data)
403
407
  } catch (err) {
404
408
  win.webContents.send('usage-error', String(err))
405
409
  }
@@ -648,19 +652,51 @@ function watchDebug() {
648
652
  // ---- real usage via OAuth (authoritative %), polled slowly with 429 backoff ----
649
653
  let usageTimer = null
650
654
  let usageBackoff = 5 * 60 * 1000
655
+ let authFails = 0 // consecutive 401s — see pollUsage
656
+ let lastPollAt = 0
657
+
658
+ // The authoritative % only moves when tokens are actually spent, and the local
659
+ // logs show that within a tick. Polling off that beats a faster clock: it
660
+ // answers while the user is working and asks for nothing while they are not.
661
+ // The floor is what bounds the cost — never more than one call per 90s, well
662
+ // under what a fixed one-minute poll would spend.
663
+ const POLL_FLOOR_MS = 90 * 1000
664
+ let lastSeenTokens = null
665
+ function onLocalActivity(data) {
666
+ const t = data?.session?.tokens
667
+ if (typeof t !== 'number') return
668
+ const grew = lastSeenTokens != null && t > lastSeenTokens
669
+ lastSeenTokens = t
670
+ if (!grew || !auth.isConnected()) return
671
+ if (Date.now() - lastPollAt < POLL_FLOOR_MS) return
672
+ clearTimeout(usageTimer) // pollUsage schedules the next heartbeat itself
673
+ pollUsage()
674
+ }
675
+
651
676
  function scheduleUsagePoll() {
652
677
  clearTimeout(usageTimer)
653
678
  if (auth.isConnected()) usageTimer = setTimeout(pollUsage, usageBackoff)
654
679
  }
655
680
  async function pollUsage() {
681
+ lastPollAt = Date.now()
656
682
  try {
657
683
  const u = await auth.fetchUsage()
658
684
  usageBackoff = 5 * 60 * 1000
685
+ authFails = 0
659
686
  pushRealUsage(u)
660
687
  } catch (e) {
661
688
  if (e && e.status === 429) {
662
689
  usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
663
690
  } else if (e && e.status === 401) {
691
+ // Refreshing rotates the refresh token, so a request that raced the one
692
+ // that rotated it comes back rejected while the session is perfectly
693
+ // alive. Clearing on that first answer throws away a good login, so the
694
+ // second rejection in a row is what counts.
695
+ if (++authFails < 2) {
696
+ usageBackoff = 60 * 1000
697
+ scheduleUsagePoll()
698
+ return
699
+ }
664
700
  auth.clear()
665
701
  pushRealUsage(null)
666
702
  sendAccounts()
@@ -674,9 +710,23 @@ async function pollUsage() {
674
710
  scheduleUsagePoll()
675
711
  }
676
712
  function startUsagePoll() {
713
+ authFails = 0
677
714
  if (auth.isConnected()) pollUsage()
678
715
  }
679
716
 
717
+ // Waking up: the poll timer was frozen through the sleep, so the numbers on
718
+ // screen are as old as the nap. Re-state what we know — a read that failed on
719
+ // the way down would otherwise leave a stale "log in" panel up — and poll
720
+ // again, after a beat, since the network is rarely back the instant we are.
721
+ function onResume() {
722
+ if (!auth.isConnected()) return
723
+ if (win && !win.isDestroyed()) win.webContents.send('auth-state', { connected: true })
724
+ clearTimeout(usageTimer)
725
+ usageBackoff = 5 * 60 * 1000
726
+ usageTimer = setTimeout(pollUsage, 5000)
727
+ sendProfile()
728
+ }
729
+
680
730
  // push the logged-in account's identity (email + plan) to the renderer
681
731
  let profileTimer = null
682
732
  async function sendProfile(tries = 0) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.21.1",
4
+ "version": "1.22.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {