clauddy 1.29.1 → 2.0.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 +10 -9
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -345
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
package/auth.js
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
// Claude OAuth (same flow as Claude Code) + the authoritative usage endpoint.
|
|
2
|
-
const crypto = require('node:crypto')
|
|
3
|
-
const fs = require('node:fs')
|
|
4
|
-
const path = require('node:path')
|
|
5
|
-
const os = require('node:os')
|
|
6
|
-
|
|
7
|
-
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
8
|
-
const REDIRECT = 'https://platform.claude.com/oauth/code/callback'
|
|
9
|
-
const AUTHORIZE = 'https://claude.ai/oauth/authorize'
|
|
10
|
-
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
11
|
-
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
12
|
-
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
|
|
13
|
-
const SCOPE = 'org:create_api_key user:profile user:inference'
|
|
14
|
-
const UA = 'claude-cli/2.1.181 (external, cli)'
|
|
15
|
-
// when CLAUDE_CONFIG_DIR is set (e.g. via direnv for multi-account setups),
|
|
16
|
-
// keep the widget's data alongside that account's Claude config
|
|
17
|
-
const DATA_DIR = process.env.CLAUDE_CONFIG_DIR
|
|
18
|
-
? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
|
|
19
|
-
: path.join(os.homedir(), '.claude-usage-monitor')
|
|
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')
|
|
23
|
-
|
|
24
|
-
const b64url = (buf) =>
|
|
25
|
-
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
26
|
-
|
|
27
|
-
let tokens = null // { access_token, refresh_token, expires_at }
|
|
28
|
-
let pending = null // { verifier, state }
|
|
29
|
-
let profile = null // { email, name, plan } — cached account identity
|
|
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
|
-
unreadable = false
|
|
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
|
|
46
|
-
function load() {
|
|
47
|
-
if (tokens) return tokens
|
|
48
|
-
let raw = null
|
|
49
|
-
try {
|
|
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)
|
|
57
|
-
} catch {
|
|
58
|
-
tokens = null
|
|
59
|
-
}
|
|
60
|
-
return tokens
|
|
61
|
-
}
|
|
62
|
-
function save(t) {
|
|
63
|
-
tokens = t
|
|
64
|
-
try {
|
|
65
|
-
fs.mkdirSync(path.dirname(tokenPath), { recursive: true })
|
|
66
|
-
fs.writeFileSync(tokenPath, JSON.stringify(t, null, 2), { mode: 0o600 })
|
|
67
|
-
} catch {}
|
|
68
|
-
}
|
|
69
|
-
function clear() {
|
|
70
|
-
tokens = null
|
|
71
|
-
profile = null
|
|
72
|
-
unreadable = false
|
|
73
|
-
try {
|
|
74
|
-
fs.unlinkSync(tokenPath)
|
|
75
|
-
} catch {}
|
|
76
|
-
}
|
|
77
|
-
function isConnected() {
|
|
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
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Step 1: build the authorize URL (opens in the browser)
|
|
84
|
-
function begin() {
|
|
85
|
-
const verifier = b64url(crypto.randomBytes(32))
|
|
86
|
-
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
|
|
87
|
-
const state = b64url(crypto.randomBytes(32))
|
|
88
|
-
pending = { verifier, state }
|
|
89
|
-
const params = {
|
|
90
|
-
code: 'true',
|
|
91
|
-
client_id: CLIENT_ID,
|
|
92
|
-
response_type: 'code',
|
|
93
|
-
redirect_uri: REDIRECT,
|
|
94
|
-
scope: SCOPE,
|
|
95
|
-
code_challenge: challenge,
|
|
96
|
-
code_challenge_method: 'S256',
|
|
97
|
-
state,
|
|
98
|
-
}
|
|
99
|
-
const query = Object.entries(params)
|
|
100
|
-
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
101
|
-
.join('&')
|
|
102
|
-
return `${AUTHORIZE}?${query}`
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Step 2: exchange the pasted "code#state" for tokens
|
|
106
|
-
async function complete(pasted) {
|
|
107
|
-
const raw = String(pasted).trim()
|
|
108
|
-
// A directly-pasted long-lived token (e.g. from `claude setup-token`) skips
|
|
109
|
-
// the rate-limited code exchange entirely.
|
|
110
|
-
if (!raw.includes('#')) {
|
|
111
|
-
save({ access_token: raw, refresh_token: null, expires_at: Date.now() + 365 * 864e5 })
|
|
112
|
-
pending = null
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
if (!pending) throw new Error('no pending auth')
|
|
116
|
-
const [code, returnedState] = raw.split('#')
|
|
117
|
-
const res = await fetch(TOKEN_URL, {
|
|
118
|
-
method: 'POST',
|
|
119
|
-
headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
|
|
120
|
-
body: JSON.stringify({
|
|
121
|
-
grant_type: 'authorization_code',
|
|
122
|
-
code,
|
|
123
|
-
state: returnedState || pending.state,
|
|
124
|
-
code_verifier: pending.verifier,
|
|
125
|
-
client_id: CLIENT_ID,
|
|
126
|
-
redirect_uri: REDIRECT,
|
|
127
|
-
}),
|
|
128
|
-
})
|
|
129
|
-
if (!res.ok) {
|
|
130
|
-
const body = await res.text().catch(() => '')
|
|
131
|
-
throw new Error(`exchange ${res.status}: ${body.slice(0, 150)}`)
|
|
132
|
-
}
|
|
133
|
-
const j = await res.json()
|
|
134
|
-
save({
|
|
135
|
-
access_token: j.access_token,
|
|
136
|
-
refresh_token: j.refresh_token,
|
|
137
|
-
expires_at: Date.now() + (j.expires_in || 3600) * 1000,
|
|
138
|
-
})
|
|
139
|
-
pending = null
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Usage and profile both ask for a valid token at once. Two refreshes would
|
|
143
|
-
// rotate the refresh token twice, and the loser's grant comes back rejected —
|
|
144
|
-
// so callers share the one in flight.
|
|
145
|
-
let refreshing = null
|
|
146
|
-
function refresh() {
|
|
147
|
-
if (!refreshing) refreshing = doRefresh().finally(() => (refreshing = null))
|
|
148
|
-
return refreshing
|
|
149
|
-
}
|
|
150
|
-
async function doRefresh() {
|
|
151
|
-
const t = load()
|
|
152
|
-
if (!t?.refresh_token) throw Object.assign(new Error('no refresh token'), { status: 401 })
|
|
153
|
-
const res = await fetch(TOKEN_URL, {
|
|
154
|
-
method: 'POST',
|
|
155
|
-
headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
|
|
156
|
-
body: JSON.stringify({
|
|
157
|
-
grant_type: 'refresh_token',
|
|
158
|
-
refresh_token: t.refresh_token,
|
|
159
|
-
client_id: CLIENT_ID,
|
|
160
|
-
}),
|
|
161
|
-
})
|
|
162
|
-
if (!res.ok) {
|
|
163
|
-
// a rejected grant (expired/revoked refresh token) means the session is
|
|
164
|
-
// dead — surface it as 401 so callers clear the token and prompt login.
|
|
165
|
-
// transient failures (429, 5xx) keep their status so we retry, not logout.
|
|
166
|
-
const dead = res.status === 400 || res.status === 403
|
|
167
|
-
throw Object.assign(new Error('refresh failed'), { status: dead ? 401 : res.status })
|
|
168
|
-
}
|
|
169
|
-
const j = await res.json()
|
|
170
|
-
save({
|
|
171
|
-
access_token: j.access_token,
|
|
172
|
-
refresh_token: j.refresh_token || t.refresh_token,
|
|
173
|
-
expires_at: Date.now() + (j.expires_in || 3600) * 1000,
|
|
174
|
-
})
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function validToken() {
|
|
178
|
-
const t = load()
|
|
179
|
-
// status 0 is "try again later", not "log in again": only a token that is
|
|
180
|
-
// genuinely absent should send the user back through the browser
|
|
181
|
-
if (!t) {
|
|
182
|
-
throw Object.assign(new Error(unreadable ? 'token unreadable' : 'not connected'), {
|
|
183
|
-
status: unreadable ? 0 : 401,
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
if (!t.expires_at || t.expires_at - Date.now() < 60000) await refresh()
|
|
187
|
-
return load().access_token
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function win(o) {
|
|
191
|
-
return o && typeof o.utilization === 'number'
|
|
192
|
-
? { pct: o.utilization, resetMs: o.resets_at ? Date.parse(o.resets_at) - Date.now() : null }
|
|
193
|
-
: null
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Weekly limits scoped to one model (e.g. "Fable" on Max) live in the `limits`
|
|
197
|
-
// array. The older seven_day_<model> windows are kept as a fallback for plans
|
|
198
|
-
// that still report them that way.
|
|
199
|
-
function scopedWeeks(j) {
|
|
200
|
-
const out = []
|
|
201
|
-
for (const l of Array.isArray(j.limits) ? j.limits : []) {
|
|
202
|
-
if (l?.kind !== 'weekly_scoped' || typeof l.percent !== 'number') continue
|
|
203
|
-
const label = l.scope?.model?.display_name
|
|
204
|
-
if (!label) continue
|
|
205
|
-
out.push({
|
|
206
|
-
label,
|
|
207
|
-
pct: l.percent,
|
|
208
|
-
resetMs: l.resets_at ? Date.parse(l.resets_at) - Date.now() : null,
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
if (out.length) return out
|
|
212
|
-
for (const [key, label] of [
|
|
213
|
-
['seven_day_opus', 'Opus'],
|
|
214
|
-
['seven_day_sonnet', 'Sonnet'],
|
|
215
|
-
]) {
|
|
216
|
-
const w = win(j[key])
|
|
217
|
-
if (w) out.push({ label, ...w })
|
|
218
|
-
}
|
|
219
|
-
return out
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// What the endpoint says its own budget is. Undocumented, so we only ever read
|
|
223
|
-
// it — nothing depends on it yet. Knowing the real numbers is what tells us how
|
|
224
|
-
// close the activity poll's floor can get.
|
|
225
|
-
let rateLimit = null
|
|
226
|
-
function readRateLimit(res) {
|
|
227
|
-
const out = {}
|
|
228
|
-
try {
|
|
229
|
-
for (const [k, v] of res.headers) {
|
|
230
|
-
if (k.startsWith('anthropic-ratelimit-')) out[k.slice(20)] = v
|
|
231
|
-
}
|
|
232
|
-
} catch {
|
|
233
|
-
return // no headers to read — nothing here is worth failing a fetch over
|
|
234
|
-
}
|
|
235
|
-
if (!Object.keys(out).length) return
|
|
236
|
-
if (JSON.stringify(out) !== JSON.stringify(rateLimit)) console.log('usage rate limit', out)
|
|
237
|
-
rateLimit = out
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Step 3: fetch the authoritative usage
|
|
241
|
-
async function fetchUsage() {
|
|
242
|
-
const token = await validToken()
|
|
243
|
-
const res = await fetch(USAGE_URL, {
|
|
244
|
-
headers: {
|
|
245
|
-
Authorization: `Bearer ${token}`,
|
|
246
|
-
'anthropic-beta': 'oauth-2025-04-20',
|
|
247
|
-
'anthropic-version': '2023-06-01',
|
|
248
|
-
'User-Agent': UA,
|
|
249
|
-
},
|
|
250
|
-
})
|
|
251
|
-
if (!res.ok) {
|
|
252
|
-
const body = await res.text().catch(() => '')
|
|
253
|
-
throw Object.assign(new Error(`usage ${res.status}: ${body.slice(0, 150)}`), {
|
|
254
|
-
status: res.status,
|
|
255
|
-
})
|
|
256
|
-
}
|
|
257
|
-
readRateLimit(res)
|
|
258
|
-
const j = await res.json()
|
|
259
|
-
return {
|
|
260
|
-
session: win(j.five_hour) || { pct: 0, resetMs: null },
|
|
261
|
-
week: win(j.seven_day) || { pct: 0, resetMs: null },
|
|
262
|
-
scoped: scopedWeeks(j),
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// The logged-in account's identity (email + plan tier). Cached for the session;
|
|
267
|
-
// only changes on logout/login, so we don't re-fetch on every usage poll.
|
|
268
|
-
async function fetchProfile() {
|
|
269
|
-
if (profile) return profile
|
|
270
|
-
const token = await validToken()
|
|
271
|
-
const res = await fetch(PROFILE_URL, {
|
|
272
|
-
headers: {
|
|
273
|
-
Authorization: `Bearer ${token}`,
|
|
274
|
-
'anthropic-beta': 'oauth-2025-04-20',
|
|
275
|
-
'anthropic-version': '2023-06-01',
|
|
276
|
-
'User-Agent': UA,
|
|
277
|
-
},
|
|
278
|
-
})
|
|
279
|
-
if (!res.ok) {
|
|
280
|
-
throw Object.assign(new Error(`profile ${res.status}`), { status: res.status })
|
|
281
|
-
}
|
|
282
|
-
const a = (await res.json()).account || {}
|
|
283
|
-
profile = {
|
|
284
|
-
email: a.email || null,
|
|
285
|
-
name: a.display_name || a.full_name || null,
|
|
286
|
-
plan: a.has_claude_max ? 'Max' : a.has_claude_pro ? 'Pro' : null,
|
|
287
|
-
}
|
|
288
|
-
return profile
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
module.exports = {
|
|
292
|
-
rateLimit: () => rateLimit,
|
|
293
|
-
begin,
|
|
294
|
-
complete,
|
|
295
|
-
fetchUsage,
|
|
296
|
-
fetchProfile,
|
|
297
|
-
clear,
|
|
298
|
-
isConnected,
|
|
299
|
-
setDataDir,
|
|
300
|
-
}
|
package/codex.js
DELETED
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
const { spawn } = require('node:child_process')
|
|
2
|
-
const fs = require('node:fs')
|
|
3
|
-
const path = require('node:path')
|
|
4
|
-
const os = require('node:os')
|
|
5
|
-
|
|
6
|
-
// Codex (CLI and the desktop app alike) writes one `rollout-*.jsonl` per
|
|
7
|
-
// session under ~/.codex/sessions/YYYY/MM/DD. Its `token_count` events carry
|
|
8
|
-
// both the token totals and the account's rate limits, so the tokens and history
|
|
9
|
-
// come from local files. Other ChatGPT products share those limits without
|
|
10
|
-
// writing rollouts, so the % also comes from Codex's own app-server.
|
|
11
|
-
let codexDir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
12
|
-
|
|
13
|
-
function setCodexDir(dir) {
|
|
14
|
-
codexDir = dir || process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
15
|
-
fileCache.clear()
|
|
16
|
-
liveLimits = null
|
|
17
|
-
lastLiveTry = 0
|
|
18
|
-
lastLiveFailed = false
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// tests point this at a stub; false turns the live read off
|
|
22
|
-
function setCodexBin(bin) {
|
|
23
|
-
codexBin = bin
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const DAY = 86400000
|
|
27
|
-
const DAYS = 30
|
|
28
|
-
const WEEK = 7 * DAY
|
|
29
|
-
const ACTIVE_MS = 60000 // a rollout touched within the last minute = Codex working
|
|
30
|
-
const DAY_MIN = 24 * 60
|
|
31
|
-
const TOP_PROJECTS = 5 // same fold as Claude's panel
|
|
32
|
-
const LIVE_MS = 2 * 60000 // how often to ask the app-server for the limits
|
|
33
|
-
const LIVE_RETRY_MS = 5 * 60000 // a process spawn per try: back off when it fails
|
|
34
|
-
const LIVE_TIMEOUT_MS = 15000
|
|
35
|
-
|
|
36
|
-
let codexBin
|
|
37
|
-
let liveLimits = null
|
|
38
|
-
let lastLiveTry = 0
|
|
39
|
-
let lastLiveFailed = false
|
|
40
|
-
|
|
41
|
-
// per file: how far we've read, plus what we got out of it. Rollouts only ever
|
|
42
|
-
// grow, so a poll reads just the bytes appended since the last one.
|
|
43
|
-
const fileCache = new Map()
|
|
44
|
-
|
|
45
|
-
function blankSummary() {
|
|
46
|
-
return { offset: 0, rest: '', lastTotal: 0, events: [], limits: null, model: null, cwd: null }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// a token_count's rate limits. Codex reports several buckets; some (the
|
|
50
|
-
// `premium` one on Plus, say) come with null windows — only a bucket with a
|
|
51
|
-
// real window is worth showing.
|
|
52
|
-
function limitsOf(rl, ts) {
|
|
53
|
-
if (!rl || (!rl.primary && !rl.secondary)) return null
|
|
54
|
-
const win = (w) =>
|
|
55
|
-
w && typeof w.used_percent === 'number'
|
|
56
|
-
? { pct: w.used_percent, resetsAt: w.resets_at ? w.resets_at * 1000 : null }
|
|
57
|
-
: null
|
|
58
|
-
// Plus reports a 5-hour window and a weekly one; some plans (Pro Lite, say)
|
|
59
|
-
// only the weekly one, in the `primary` slot. Sort by length, not by slot.
|
|
60
|
-
let session = null
|
|
61
|
-
let weekly = null
|
|
62
|
-
for (const w of [rl.primary, rl.secondary]) {
|
|
63
|
-
const v = win(w)
|
|
64
|
-
if (!v) continue
|
|
65
|
-
const long = w.window_minutes != null ? w.window_minutes >= DAY_MIN : w === rl.secondary
|
|
66
|
-
if (long) weekly = v
|
|
67
|
-
else session = v
|
|
68
|
-
}
|
|
69
|
-
return { ts, plan: rl.plan_type || null, session, weekly }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// the app-server reports the same windows as the rollouts, in camelCase
|
|
73
|
-
function limitsFromLive(result, ts) {
|
|
74
|
-
const rl = result?.rateLimitsByLimitId?.codex || result?.rateLimits
|
|
75
|
-
if (!rl) return null
|
|
76
|
-
const win = (w) =>
|
|
77
|
-
w && {
|
|
78
|
-
used_percent: w.usedPercent,
|
|
79
|
-
window_minutes: w.windowDurationMins,
|
|
80
|
-
resets_at: w.resetsAt,
|
|
81
|
-
}
|
|
82
|
-
return limitsOf(
|
|
83
|
-
{ primary: win(rl.primary), secondary: win(rl.secondary), plan_type: rl.planType },
|
|
84
|
-
ts,
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// the desktop app's bundled CLI first: a GUI app doesn't get the shell's PATH
|
|
89
|
-
function findCodexBin() {
|
|
90
|
-
if (codexBin !== undefined) return codexBin
|
|
91
|
-
const home = os.homedir()
|
|
92
|
-
const bundled = ['/Applications', path.join(home, 'Applications')].flatMap((dir) => [
|
|
93
|
-
path.join(dir, 'ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex'),
|
|
94
|
-
path.join(dir, 'Codex.app/Contents/Resources/codex'),
|
|
95
|
-
])
|
|
96
|
-
return bundled.find((f) => fs.existsSync(f)) || 'codex'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// One `account/rateLimits/read` over the app-server's stdio JSON-RPC. Codex keeps
|
|
100
|
-
// its own login and token refresh: Clauddy never touches the token.
|
|
101
|
-
function readLiveLimits(bin, ts) {
|
|
102
|
-
return new Promise((resolve) => {
|
|
103
|
-
let child
|
|
104
|
-
try {
|
|
105
|
-
child = spawn(bin, ['app-server'], {
|
|
106
|
-
env: { ...process.env, CODEX_HOME: codexDir },
|
|
107
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
108
|
-
})
|
|
109
|
-
} catch {
|
|
110
|
-
resolve(null)
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
const finish = (limits) => {
|
|
114
|
-
clearTimeout(timer)
|
|
115
|
-
child.kill()
|
|
116
|
-
resolve(limits)
|
|
117
|
-
}
|
|
118
|
-
const timer = setTimeout(() => finish(null), LIVE_TIMEOUT_MS)
|
|
119
|
-
const send = (msg) => child.stdin.write(`${JSON.stringify(msg)}\n`)
|
|
120
|
-
child.on('error', () => finish(null))
|
|
121
|
-
child.on('exit', () => finish(null))
|
|
122
|
-
child.stdin.on('error', () => finish(null))
|
|
123
|
-
let rest = ''
|
|
124
|
-
child.stdout.on('data', (chunk) => {
|
|
125
|
-
const lines = (rest + chunk).split('\n')
|
|
126
|
-
rest = lines.pop()
|
|
127
|
-
for (const line of lines) {
|
|
128
|
-
let msg
|
|
129
|
-
try {
|
|
130
|
-
msg = JSON.parse(line)
|
|
131
|
-
} catch {
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
134
|
-
if (msg.id === 1) {
|
|
135
|
-
send({ method: 'initialized' })
|
|
136
|
-
send({ id: 2, method: 'account/rateLimits/read' })
|
|
137
|
-
} else if (msg.id === 2) finish(limitsFromLive(msg.result, ts))
|
|
138
|
-
}
|
|
139
|
-
})
|
|
140
|
-
send({
|
|
141
|
-
id: 1,
|
|
142
|
-
method: 'initialize',
|
|
143
|
-
params: { clientInfo: { name: 'clauddy', version: '0' } },
|
|
144
|
-
})
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// A failed read keeps the last good one: it ages into stale, never into zero.
|
|
149
|
-
async function refreshLimits(now = Date.now()) {
|
|
150
|
-
const bin = findCodexBin()
|
|
151
|
-
if (!bin) return
|
|
152
|
-
lastLiveTry = now
|
|
153
|
-
const dir = codexDir
|
|
154
|
-
const limits = await readLiveLimits(bin, now)
|
|
155
|
-
if (dir !== codexDir) return // the source changed while this one was running
|
|
156
|
-
lastLiveFailed = !limits
|
|
157
|
-
if (limits) liveLimits = limits
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function ingestLine(sum, line) {
|
|
161
|
-
// cheap filter before JSON.parse — rollouts are mostly message bodies
|
|
162
|
-
if (!line.includes('"token_count"') && !line.includes('"turn_context"')) return
|
|
163
|
-
let o
|
|
164
|
-
try {
|
|
165
|
-
o = JSON.parse(line)
|
|
166
|
-
} catch {
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
const p = o.payload || {}
|
|
170
|
-
const ts = Date.parse(o.timestamp) || 0
|
|
171
|
-
if (o.type === 'turn_context') {
|
|
172
|
-
if (p.model) sum.model = p.model
|
|
173
|
-
if (p.cwd) sum.cwd = p.cwd
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
if (p.type !== 'token_count') return
|
|
177
|
-
// totals are cumulative per session, and the same count is often emitted
|
|
178
|
-
// twice — the delta is what this event actually spent. The model and project
|
|
179
|
-
// are the ones in effect *now*, so a mid-session switch is attributed right.
|
|
180
|
-
const total = p.info?.total_token_usage?.total_tokens
|
|
181
|
-
if (typeof total === 'number' && total > sum.lastTotal) {
|
|
182
|
-
sum.events.push({ ts, tokens: total - sum.lastTotal, model: sum.model, cwd: sum.cwd })
|
|
183
|
-
sum.lastTotal = total
|
|
184
|
-
}
|
|
185
|
-
const lim = limitsOf(p.rate_limits, ts)
|
|
186
|
-
if (lim && (!sum.limits || lim.ts >= sum.limits.ts)) sum.limits = lim
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function readFile(file, st) {
|
|
190
|
-
let sum = fileCache.get(file)
|
|
191
|
-
// shrunk or replaced: start over
|
|
192
|
-
if (!sum || st.size < sum.offset) sum = blankSummary()
|
|
193
|
-
if (st.size > sum.offset) {
|
|
194
|
-
const fd = fs.openSync(file, 'r')
|
|
195
|
-
try {
|
|
196
|
-
const buf = Buffer.alloc(st.size - sum.offset)
|
|
197
|
-
fs.readSync(fd, buf, 0, buf.length, sum.offset)
|
|
198
|
-
sum.offset = st.size
|
|
199
|
-
const lines = (sum.rest + buf.toString('utf8')).split('\n')
|
|
200
|
-
// the last piece may be a line still being written
|
|
201
|
-
sum.rest = lines.pop()
|
|
202
|
-
for (const line of lines) ingestLine(sum, line)
|
|
203
|
-
} finally {
|
|
204
|
-
fs.closeSync(fd)
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
sum.mtime = st.mtimeMs
|
|
208
|
-
fileCache.set(file, sum)
|
|
209
|
-
return sum
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// every rollout, with its stat; `since` drops the ones untouched since then
|
|
213
|
-
function rollouts(since) {
|
|
214
|
-
const out = []
|
|
215
|
-
const walk = (dir, depth) => {
|
|
216
|
-
let kids
|
|
217
|
-
try {
|
|
218
|
-
kids = fs.readdirSync(dir, { withFileTypes: true })
|
|
219
|
-
} catch {
|
|
220
|
-
return
|
|
221
|
-
}
|
|
222
|
-
for (const k of kids) {
|
|
223
|
-
const full = path.join(dir, k.name)
|
|
224
|
-
if (k.isDirectory() && depth < 3) walk(full, depth + 1)
|
|
225
|
-
else if (k.isFile() && k.name.endsWith('.jsonl')) {
|
|
226
|
-
try {
|
|
227
|
-
const st = fs.statSync(full)
|
|
228
|
-
if (st.mtimeMs >= since) out.push({ file: full, st })
|
|
229
|
-
} catch {}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
walk(path.join(codexDir, 'sessions'), 0)
|
|
234
|
-
return out
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Is there a Codex session on this machine? The folder alone is not enough.
|
|
238
|
-
function detectCodex() {
|
|
239
|
-
const files = rollouts(0)
|
|
240
|
-
if (!files.length) return { found: false, plan: null }
|
|
241
|
-
files.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
|
|
242
|
-
let plan = null
|
|
243
|
-
for (const { file, st } of files.slice(0, 5)) {
|
|
244
|
-
const lim = readFile(file, st).limits
|
|
245
|
-
if (lim?.plan) {
|
|
246
|
-
plan = lim.plan
|
|
247
|
-
break
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return { found: true, plan }
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// A window whose reset has passed tells us nothing about today: the log is
|
|
254
|
-
// older than the window, so the % is unknown until Codex writes a fresh one.
|
|
255
|
-
function windowNow(w, now) {
|
|
256
|
-
if (!w) return null
|
|
257
|
-
const expired = w.resetsAt != null && w.resetsAt <= now
|
|
258
|
-
return {
|
|
259
|
-
pct: expired ? null : w.pct,
|
|
260
|
-
resetMs: w.resetsAt != null && !expired ? w.resetsAt - now : null,
|
|
261
|
-
expired,
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function ranked(map) {
|
|
266
|
-
return [...map.entries()]
|
|
267
|
-
.map(([label, tokens]) => ({ label, tokens }))
|
|
268
|
-
.sort((a, b) => b.tokens - a.tokens)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function getCodexUsage(now = Date.now()) {
|
|
272
|
-
if (!fs.existsSync(path.join(codexDir, 'sessions'))) return null
|
|
273
|
-
if (now - lastLiveTry >= (lastLiveFailed ? LIVE_RETRY_MS : LIVE_MS)) refreshLimits(now)
|
|
274
|
-
const startOfDay = new Date(now).setHours(0, 0, 0, 0)
|
|
275
|
-
const start30 = startOfDay - (DAYS - 1) * DAY
|
|
276
|
-
const files = rollouts(start30)
|
|
277
|
-
const seen = new Set()
|
|
278
|
-
let limits = null
|
|
279
|
-
let latest = null
|
|
280
|
-
let tokensToday = 0
|
|
281
|
-
let tokens5h = 0
|
|
282
|
-
let tokensWeek = 0
|
|
283
|
-
let monthTokens = 0
|
|
284
|
-
const days30 = new Array(DAYS).fill(0)
|
|
285
|
-
const byModel = new Map()
|
|
286
|
-
const byProject = new Map()
|
|
287
|
-
for (const { file, st } of files) {
|
|
288
|
-
seen.add(file)
|
|
289
|
-
const sum = readFile(file, st)
|
|
290
|
-
if (sum.limits && (!limits || sum.limits.ts > limits.ts)) limits = sum.limits
|
|
291
|
-
if (!latest || sum.mtime > latest.mtime) latest = sum
|
|
292
|
-
for (const e of sum.events) {
|
|
293
|
-
if (e.ts < start30) continue
|
|
294
|
-
monthTokens += e.tokens
|
|
295
|
-
days30[Math.min(DAYS - 1, Math.floor((e.ts - start30) / DAY))] += e.tokens
|
|
296
|
-
if (e.ts >= startOfDay) tokensToday += e.tokens
|
|
297
|
-
if (now - e.ts < 5 * 3600000) tokens5h += e.tokens
|
|
298
|
-
if (now - e.ts < WEEK) {
|
|
299
|
-
tokensWeek += e.tokens
|
|
300
|
-
const m = e.model || 'unknown'
|
|
301
|
-
byModel.set(m, (byModel.get(m) || 0) + e.tokens)
|
|
302
|
-
if (e.cwd) {
|
|
303
|
-
const p = path.basename(e.cwd)
|
|
304
|
-
byProject.set(p, (byProject.get(p) || 0) + e.tokens)
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
// whichever is newer: a rollout written after the last live read beats it
|
|
310
|
-
if (liveLimits && (!limits || liveLimits.ts >= limits.ts)) limits = liveLimits
|
|
311
|
-
// forget files that aged out of the window
|
|
312
|
-
for (const f of fileCache.keys()) if (!seen.has(f)) fileCache.delete(f)
|
|
313
|
-
|
|
314
|
-
const projects = ranked(byProject)
|
|
315
|
-
const byProjectArr = projects.slice(0, TOP_PROJECTS)
|
|
316
|
-
const rest = projects.slice(TOP_PROJECTS)
|
|
317
|
-
if (rest.length) {
|
|
318
|
-
byProjectArr.push({
|
|
319
|
-
label: `other · ${rest.length}`,
|
|
320
|
-
tokens: rest.reduce((n, p) => n + p.tokens, 0),
|
|
321
|
-
})
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const weeklyOnly = !!limits?.weekly && !limits.session
|
|
325
|
-
return {
|
|
326
|
-
active: !!latest && now - latest.mtime < ACTIVE_MS,
|
|
327
|
-
lastSeen: latest ? latest.mtime : null,
|
|
328
|
-
plan: limits?.plan || null,
|
|
329
|
-
// with no 5-hour window, the week is the budget that binds: it takes the
|
|
330
|
-
// session's place, the way Cursor's month does
|
|
331
|
-
session: limits ? windowNow(weeklyOnly ? limits.weekly : limits.session, now) : null,
|
|
332
|
-
weekly: limits && !weeklyOnly ? windowNow(limits.weekly, now) : null,
|
|
333
|
-
weeklyOnly,
|
|
334
|
-
limitsAt: limits?.ts || null,
|
|
335
|
-
tokensToday,
|
|
336
|
-
tokens5h,
|
|
337
|
-
tokensWeek,
|
|
338
|
-
byModel: ranked(byModel),
|
|
339
|
-
byProject: byProjectArr,
|
|
340
|
-
days30,
|
|
341
|
-
monthTokens,
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
module.exports = { getCodexUsage, refreshLimits, detectCodex, setCodexDir, setCodexBin }
|
package/config.json
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_note": "Defaults bundled with the app. User settings are saved to ~/.claude-usage-monitor/config.json. Session/weekly % come from your account (log in via Settings).",
|
|
3
|
-
"mode": "floating",
|
|
4
|
-
"alerts": true,
|
|
5
|
-
"alertThresholds": [80, 95],
|
|
6
|
-
"fireThreshold": 90,
|
|
7
|
-
"zoom": 100,
|
|
8
|
-
"pollIntervalMs": 4000,
|
|
9
|
-
"activeThresholdMs": 20000,
|
|
10
|
-
"sleepThresholdMs": 300000
|
|
11
|
-
}
|