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.
- package/README.md +1 -1
- package/auth.js +54 -4
- package/main.js +50 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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) {
|