clauddy 1.29.1 → 2.0.1

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/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
- }