clauddy 1.23.1 โ 1.25.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 +59 -7
- package/bin/clauddy.js +12 -2
- package/codex.js +220 -0
- package/main.js +84 -7
- package/package.json +3 -1
- package/preload.js +5 -1
- package/renderer/index.html +152 -68
- package/renderer/motion.css +120 -2
- package/renderer/pet.js +612 -44
- package/renderer/style.css +814 -53
- package/renderer/voice.js +243 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ๐ซ Clauddy
|
|
2
2
|
|
|
3
|
-
A cute pixel-art desktop pet for macOS that tracks your Claude Code usage โ mirroring the official **Settings โ Usage** panel (current session + weekly limits, in tokens & %), with animations.
|
|
3
|
+
A cute pixel-art desktop pet for macOS that tracks your Claude Code usage โ mirroring the official **Settings โ Usage** panel (current session + weekly limits, in tokens & %), with animations. Use Codex too? It can track that alongside (see [Codex](#codex)).
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
|
|
@@ -24,7 +24,7 @@ The **percentages are real**, pulled from your account (you log in once โ see
|
|
|
24
24
|
|
|
25
25
|
The session/weekly **%** comes straight from your Anthropic account, so it matches the official panel exactly. You connect once via a browser login:
|
|
26
26
|
|
|
27
|
-
1. Open **โ Settings โ
|
|
27
|
+
1. Open **โ Settings โ Connections โ Claude โ Connect** โ your browser opens an Anthropic auth page.
|
|
28
28
|
2. Log in, copy the **authentication code** shown, and paste it back into the app โ **Connect**.
|
|
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.
|
|
@@ -39,6 +39,20 @@ Removing an account (the **ร** on its row) deletes its token from disk. The one
|
|
|
39
39
|
|
|
40
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
41
|
|
|
42
|
+
## Codex
|
|
43
|
+
|
|
44
|
+
Clauddy can follow [Codex](https://openai.com/codex) (the CLI and the desktop app) next to Claude. Open **โ Settings โ Connections โ Codex โ Connect**: it looks for a Codex session on this computer and starts monitoring if it finds one. There's nothing to log into โ Codex writes its token counts and rate limits to `~/.codex/sessions` (or `$CODEX_HOME`), and Clauddy only reads those files.
|
|
45
|
+
|
|
46
|
+
With both connected, **tabs under the pet** pick which service the whole panel shows โ account chip, pet mood, session and weekly %, the breakdowns, the 30-day map and the **โ** Usage link. The other tab keeps its % in view and gets a **!** when it crosses an alert threshold. Minimized, the ring becomes one reading per service; click one to switch.
|
|
47
|
+
|
|
48
|
+
A few things differ from Claude:
|
|
49
|
+
|
|
50
|
+
- **The % updates when Codex runs.** Its logs are only written during a session, so an idle Codex shows the last reading with its age (`read 1h 30m ago`). A window that has already reset since then shows **โ** rather than a made-up 0%.
|
|
51
|
+
- **It can lag the account page.** Codex limits are shared with other ChatGPT products (Work, Agents, Excel) that don't write to these logs.
|
|
52
|
+
- **No burn-rate projection and no activity scenes** โ the logs don't carry enough to draw them honestly.
|
|
53
|
+
|
|
54
|
+
Disconnecting (the **ร** on the Codex card) only stops monitoring in Clauddy; your Codex login and logs are untouched.
|
|
55
|
+
|
|
42
56
|
## Burn rate
|
|
43
57
|
|
|
44
58
|
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:
|
|
@@ -95,6 +109,35 @@ Other activities โ **planning**, **researching**, **delegating**, **waiting**
|
|
|
95
109
|
show up in the status line as they happen. When Claude goes quiet, the pet drops
|
|
96
110
|
back to plain **working** / **idle**.
|
|
97
111
|
|
|
112
|
+
## The pet talks
|
|
113
|
+
|
|
114
|
+
Now and then the pet says something in a speech bubble. It only speaks when something changes, never on a timer, and it keeps quiet when it has nothing specific to say. Remarks are at least 10 minutes apart; the big moments can cut that line.
|
|
115
|
+
|
|
116
|
+
| Moment | What it says (expanded) | Minimized | Blip |
|
|
117
|
+
| --- | --- | --- | --- |
|
|
118
|
+
| First launch of the day | _Good morning! Yesterday was your heaviest day this week, 12M tokens._ | _Morning! Big day yesterday._ | โ |
|
|
119
|
+
| Session crosses your fire threshold | _Getting warm, 91% already. At this pace you'll run out in about 40m._ | _91%, getting warm!_ | โ |
|
|
120
|
+
| The session window resets | _Fresh window! That was 34M tokens over 4h 12m, mostly editing code._ | _Fresh window! 34M last time._ | โ |
|
|
121
|
+
| The session hits 100% | _That's the limit. I'll be back at 2:35 PM._ | _Maxed out till 2:35 PM._ | |
|
|
122
|
+
| You're back after 2+ hours away | _Welcome back! You were gone 3h 12m. The session's at 12%._ | _Welcome back!_ | |
|
|
123
|
+
| 90 minutes of unbroken work | _You've been at it for 1h 35m straight. Stretch break?_ | _Stretch break?_ | |
|
|
124
|
+
| A new record day (30-day best) | _New record! 45M tokens today, your biggest day in a month._ | _New record: 45M!_ | |
|
|
125
|
+
| Codex crosses fire, hits 100%, or resets | _Codex is at 91% now. It resets in 1h 12m._ | _Codex at 91%!_ | โ (fire, reset) |
|
|
126
|
+
|
|
127
|
+
The greeting follows your clock (_Good morning_, _Good afternoon_, _Good evening_, or _Still up?_ before 5 AM) and appears once a day, and so does the record. The reset recap covers only what the pet actually saw, so if you launch it mid-session it says less. Click the bubble to dismiss it.
|
|
128
|
+
|
|
129
|
+
### The voice
|
|
130
|
+
|
|
131
|
+
Optionally the pet also **blips** as it talks: short chiptune notes, one per letter, generated live with Web Audio (square waves, no sound files). It blips faster and higher when it's on fire, slower and lower when it's sleepy. Only the headline moments blip (the โ rows above), because a sound on every remark wears out fast.
|
|
132
|
+
|
|
133
|
+
Audio should never catch you off guard in a meeting:
|
|
134
|
+
|
|
135
|
+
- **Off by default.** Turn it on under **โ Settings โ voice โ Chiptune voice** (you'll hear a sample).
|
|
136
|
+
- **One-click mute.** While the voice is on, a ๐ button sits in the title bar. Click it to mute for an hour, and again to unmute.
|
|
137
|
+
- **Always silent** in menu-bar mode and while the pet is minimized.
|
|
138
|
+
|
|
139
|
+
**Speech bubbles** can be switched off entirely in the same place. Try any remark from the terminal with `bunx clauddy say <kind>` ([see below](#play-with-the-pet)).
|
|
140
|
+
|
|
98
141
|
## Install
|
|
99
142
|
|
|
100
143
|
**macOS (Apple Silicon)** is the first-class build. **Windows (x64)** and **Linux (x64)** work too. The `bunx`/`npx` route below runs on all of them today.
|
|
@@ -171,17 +214,17 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
|
|
|
171
214
|
## Controls
|
|
172
215
|
|
|
173
216
|
- **Drag** the widget anywhere on screen
|
|
174
|
-
- **โ** minimizes to just the pet, ringed by the live session % (the number sits inside the ring); the **โคข** button or a double-click on the pet expands it back
|
|
217
|
+
- **โ** minimizes to just the pet, ringed by the live session % (the number sits inside the ring) โ or, with Claude and Codex both connected, one reading per service; the **โคข** button or a double-click on the pet expands it back
|
|
175
218
|
- **โ** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
|
|
176
|
-
- **โ** opens the official Usage page
|
|
219
|
+
- **โ** opens the official Usage page of the service on screen
|
|
177
220
|
- **ร** quits
|
|
178
221
|
|
|
179
222
|
### Floating or menu bar
|
|
180
223
|
|
|
181
|
-
Under **โ Settings โ
|
|
224
|
+
Under **โ Settings โ Preferences** you can pick where Clauddy lives:
|
|
182
225
|
|
|
183
226
|
- **Floating pet** โ the always-on widget in the corner (default).
|
|
184
|
-
- **Menu bar** โ a small pet icon in the macOS menu bar showing your live session **%** (it turns ๐ฅ near your limit). Click it to pop open the full pet + usage panel; click away to dismiss. Right-click for a quick menu.
|
|
227
|
+
- **Menu bar** โ a small pet icon in the macOS menu bar showing your live session **%** (it turns ๐ฅ near your limit; with Codex too it reads `C 64% ยท X 28%`). Click it to pop open the full pet + usage panel; click away to dismiss. Right-click for a quick menu.
|
|
185
228
|
|
|
186
229
|
Switching is instant โ no restart. (On Windows/Linux the icon lives in the system tray; the live % shows in its tooltip.)
|
|
187
230
|
|
|
@@ -194,6 +237,7 @@ Optional **macOS notifications**, toggled (with their thresholds) in **โ Setti
|
|
|
194
237
|
| _Session at 82%_ โ `2h 39m left ยท resets 6:50 PM` | Your session crosses a threshold (default **80%** and **95%**) |
|
|
195
238
|
| _Weekly usage at 84%_ โ `resets Fri 7:00 AM` | Same, for the weekly limit |
|
|
196
239
|
| _Fable weekly at 84%_ โ `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
|
|
240
|
+
| _Codex session at 82%_ / _Codex weekly at 84%_ | Same, for Codex, when it's connected |
|
|
197
241
|
| _Session window reset_ โ `full budget again` | A session you had pushed past 80% rolls over |
|
|
198
242
|
| _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
|
|
199
243
|
|
|
@@ -216,6 +260,10 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
|
|
|
216
260
|
"pollIntervalMs": 4000, // how often local logs are re-read
|
|
217
261
|
"activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
|
|
218
262
|
"sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
|
|
263
|
+
"codex": { "enabled": false }, // set by Settings โ Codex โ Connect
|
|
264
|
+
"talk": true, // speech bubbles on transitions
|
|
265
|
+
"sound": false, // chiptune blips, opt-in
|
|
266
|
+
"soundMutedUntil": 0, // set by the ๐ button: muted until this epoch ms
|
|
219
267
|
}
|
|
220
268
|
```
|
|
221
269
|
|
|
@@ -232,6 +280,8 @@ bunx clauddy working # ๐ด eats token coins
|
|
|
232
280
|
bunx clauddy tired # ๐ฅต maxed out
|
|
233
281
|
bunx clauddy idle # ๐ calm
|
|
234
282
|
bunx clauddy auto # โฉ๏ธ back to your real usage
|
|
283
|
+
bunx clauddy say # ๐ฌ a remark in the speech bubble
|
|
284
|
+
bunx clauddy say fire # or a specific one: greeting, fire, reset, maxed, welcome, streak, record, codex
|
|
235
285
|
```
|
|
236
286
|
|
|
237
287
|
Each state is written to the data dir the running widget watches, so it reacts
|
|
@@ -243,7 +293,7 @@ live. (Installed globally? Drop the `bunx`: `clauddy poke`. Working on the repo?
|
|
|
243
293
|
- **`main.js`** โ Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
|
|
244
294
|
- **`usage.js`** โ reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/project/day, detects the rolling 5-hour session window, the working/sleeping status, and which activity (reading/editing/running/โฆ) Claude is on from its latest tool use.
|
|
245
295
|
- **`auth.js`** โ OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
|
|
246
|
-
- **`renderer/`** โ the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
|
|
296
|
+
- **`renderer/`** โ the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles. `voice.js` writes its remarks and synthesizes the blips.
|
|
247
297
|
- **`make-icon.js`** โ generates the app icon from the pixel sprite (`build/icon.icns`).
|
|
248
298
|
|
|
249
299
|
## Data & privacy
|
|
@@ -254,6 +304,8 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
|
|
|
254
304
|
- `config.json` โ your alert settings
|
|
255
305
|
- `alerts.json` โ which notifications are already armed, so a restart doesn't repeat them
|
|
256
306
|
- `accounts.json` โ your list of accounts and which one is active
|
|
307
|
+
|
|
308
|
+
With Codex connected, Clauddy also reads (never writes) `~/.codex/sessions`.
|
|
257
309
|
- `debug.json` โ scratch file for the `./pet` simulator
|
|
258
310
|
- `accounts/<id>/` โ the same `auth.json` + `alerts.json`, for each extra account
|
|
259
311
|
|
package/bin/clauddy.js
CHANGED
|
@@ -20,6 +20,7 @@ const STATES = [
|
|
|
20
20
|
'idle',
|
|
21
21
|
'poke',
|
|
22
22
|
'celebrate',
|
|
23
|
+
'say',
|
|
23
24
|
'auto',
|
|
24
25
|
'clear',
|
|
25
26
|
// activity states (what Claude Code is doing)
|
|
@@ -31,7 +32,10 @@ const STATES = [
|
|
|
31
32
|
'delegating',
|
|
32
33
|
'waiting',
|
|
33
34
|
]
|
|
35
|
+
// `clauddy say <kind>` previews one of the pet's remarks
|
|
36
|
+
const REMARKS = ['greeting', 'fire', 'reset', 'maxed', 'welcome', 'streak', 'record', 'codex']
|
|
34
37
|
const arg = process.argv[2]
|
|
38
|
+
const kind = arg === 'say' ? process.argv[3] || 'greeting' : undefined
|
|
35
39
|
|
|
36
40
|
if (!arg) {
|
|
37
41
|
// no argument โ launch the app
|
|
@@ -47,19 +51,25 @@ if (!arg) {
|
|
|
47
51
|
'Usage:',
|
|
48
52
|
' clauddy launch the widget',
|
|
49
53
|
' clauddy <state> poke the running widget (just for fun)',
|
|
54
|
+
' clauddy say [kind] make the pet say one of its remarks',
|
|
50
55
|
'',
|
|
51
56
|
'States: fire, sleeping, working, tired, idle, poke, celebrate, auto',
|
|
57
|
+
`Remarks: ${REMARKS.join(', ')}`,
|
|
52
58
|
'(the widget must be running for a state to show)',
|
|
53
59
|
].join('\n'),
|
|
54
60
|
)
|
|
61
|
+
} else if (kind && !REMARKS.includes(kind)) {
|
|
62
|
+
console.error(`clauddy: unknown remark "${kind}"`)
|
|
63
|
+
console.error(`try: ${REMARKS.join(', ')}`)
|
|
64
|
+
process.exit(1)
|
|
55
65
|
} else if (STATES.includes(arg)) {
|
|
56
66
|
// write the state for the running app to pick up
|
|
57
67
|
fs.mkdirSync(dataDir, { recursive: true })
|
|
58
68
|
fs.writeFileSync(
|
|
59
69
|
path.join(dataDir, 'debug.json'),
|
|
60
|
-
`${JSON.stringify({ state: arg, t: Date.now() })}\n`,
|
|
70
|
+
`${JSON.stringify({ state: arg, kind, t: Date.now() })}\n`,
|
|
61
71
|
)
|
|
62
|
-
console.log(`clauddy โ ${arg} (the running widget will react)`)
|
|
72
|
+
console.log(`clauddy โ ${arg}${kind ? ` ${kind}` : ''} (the running widget will react)`)
|
|
63
73
|
} else {
|
|
64
74
|
console.error(`clauddy: unknown command "${arg}"`)
|
|
65
75
|
console.error('try: fire, sleeping, working, tired, idle, poke, celebrate, auto โ or --help')
|
package/codex.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
|
|
5
|
+
// Codex (CLI and the desktop app alike) writes one `rollout-*.jsonl` per
|
|
6
|
+
// session under ~/.codex/sessions/YYYY/MM/DD. Its `token_count` events carry
|
|
7
|
+
// both the token totals and the account's rate limits, so everything here comes
|
|
8
|
+
// from local files โ no login needed.
|
|
9
|
+
let codexDir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
10
|
+
|
|
11
|
+
function setCodexDir(dir) {
|
|
12
|
+
codexDir = dir || process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
13
|
+
fileCache.clear()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DAY = 86400000
|
|
17
|
+
const DAYS = 30
|
|
18
|
+
const WEEK = 7 * DAY
|
|
19
|
+
const ACTIVE_MS = 60000 // a rollout touched within the last minute = Codex working
|
|
20
|
+
const TOP_PROJECTS = 5 // same fold as Claude's panel
|
|
21
|
+
|
|
22
|
+
// per file: how far we've read, plus what we got out of it. Rollouts only ever
|
|
23
|
+
// grow, so a poll reads just the bytes appended since the last one.
|
|
24
|
+
const fileCache = new Map()
|
|
25
|
+
|
|
26
|
+
function blankSummary() {
|
|
27
|
+
return { offset: 0, rest: '', lastTotal: 0, events: [], limits: null, model: null, cwd: null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// a token_count's rate limits. Codex reports several buckets; some (the
|
|
31
|
+
// `premium` one on Plus, say) come with null windows โ only a bucket with a
|
|
32
|
+
// real window is worth showing.
|
|
33
|
+
function limitsOf(rl, ts) {
|
|
34
|
+
if (!rl || (!rl.primary && !rl.secondary)) return null
|
|
35
|
+
const win = (w) =>
|
|
36
|
+
w && typeof w.used_percent === 'number'
|
|
37
|
+
? { pct: w.used_percent, resetsAt: w.resets_at ? w.resets_at * 1000 : null }
|
|
38
|
+
: null
|
|
39
|
+
return { ts, plan: rl.plan_type || null, primary: win(rl.primary), secondary: win(rl.secondary) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ingestLine(sum, line) {
|
|
43
|
+
// cheap filter before JSON.parse โ rollouts are mostly message bodies
|
|
44
|
+
if (!line.includes('"token_count"') && !line.includes('"turn_context"')) return
|
|
45
|
+
let o
|
|
46
|
+
try {
|
|
47
|
+
o = JSON.parse(line)
|
|
48
|
+
} catch {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const p = o.payload || {}
|
|
52
|
+
const ts = Date.parse(o.timestamp) || 0
|
|
53
|
+
if (o.type === 'turn_context') {
|
|
54
|
+
if (p.model) sum.model = p.model
|
|
55
|
+
if (p.cwd) sum.cwd = p.cwd
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (p.type !== 'token_count') return
|
|
59
|
+
// totals are cumulative per session, and the same count is often emitted
|
|
60
|
+
// twice โ the delta is what this event actually spent. The model and project
|
|
61
|
+
// are the ones in effect *now*, so a mid-session switch is attributed right.
|
|
62
|
+
const total = p.info?.total_token_usage?.total_tokens
|
|
63
|
+
if (typeof total === 'number' && total > sum.lastTotal) {
|
|
64
|
+
sum.events.push({ ts, tokens: total - sum.lastTotal, model: sum.model, cwd: sum.cwd })
|
|
65
|
+
sum.lastTotal = total
|
|
66
|
+
}
|
|
67
|
+
const lim = limitsOf(p.rate_limits, ts)
|
|
68
|
+
if (lim && (!sum.limits || lim.ts >= sum.limits.ts)) sum.limits = lim
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readFile(file, st) {
|
|
72
|
+
let sum = fileCache.get(file)
|
|
73
|
+
// shrunk or replaced: start over
|
|
74
|
+
if (!sum || st.size < sum.offset) sum = blankSummary()
|
|
75
|
+
if (st.size > sum.offset) {
|
|
76
|
+
const fd = fs.openSync(file, 'r')
|
|
77
|
+
try {
|
|
78
|
+
const buf = Buffer.alloc(st.size - sum.offset)
|
|
79
|
+
fs.readSync(fd, buf, 0, buf.length, sum.offset)
|
|
80
|
+
sum.offset = st.size
|
|
81
|
+
const lines = (sum.rest + buf.toString('utf8')).split('\n')
|
|
82
|
+
// the last piece may be a line still being written
|
|
83
|
+
sum.rest = lines.pop()
|
|
84
|
+
for (const line of lines) ingestLine(sum, line)
|
|
85
|
+
} finally {
|
|
86
|
+
fs.closeSync(fd)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
sum.mtime = st.mtimeMs
|
|
90
|
+
fileCache.set(file, sum)
|
|
91
|
+
return sum
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// every rollout, with its stat; `since` drops the ones untouched since then
|
|
95
|
+
function rollouts(since) {
|
|
96
|
+
const out = []
|
|
97
|
+
const walk = (dir, depth) => {
|
|
98
|
+
let kids
|
|
99
|
+
try {
|
|
100
|
+
kids = fs.readdirSync(dir, { withFileTypes: true })
|
|
101
|
+
} catch {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
for (const k of kids) {
|
|
105
|
+
const full = path.join(dir, k.name)
|
|
106
|
+
if (k.isDirectory() && depth < 3) walk(full, depth + 1)
|
|
107
|
+
else if (k.isFile() && k.name.endsWith('.jsonl')) {
|
|
108
|
+
try {
|
|
109
|
+
const st = fs.statSync(full)
|
|
110
|
+
if (st.mtimeMs >= since) out.push({ file: full, st })
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
walk(path.join(codexDir, 'sessions'), 0)
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Is there a Codex session on this machine? The folder alone is not enough.
|
|
120
|
+
function detectCodex() {
|
|
121
|
+
const files = rollouts(0)
|
|
122
|
+
if (!files.length) return { found: false, plan: null }
|
|
123
|
+
files.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
|
|
124
|
+
let plan = null
|
|
125
|
+
for (const { file, st } of files.slice(0, 5)) {
|
|
126
|
+
const lim = readFile(file, st).limits
|
|
127
|
+
if (lim?.plan) {
|
|
128
|
+
plan = lim.plan
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { found: true, plan }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A window whose reset has passed tells us nothing about today: the log is
|
|
136
|
+
// older than the window, so the % is unknown until Codex writes a fresh one.
|
|
137
|
+
function windowNow(w, now) {
|
|
138
|
+
if (!w) return null
|
|
139
|
+
const expired = w.resetsAt != null && w.resetsAt <= now
|
|
140
|
+
return {
|
|
141
|
+
pct: expired ? null : w.pct,
|
|
142
|
+
resetMs: w.resetsAt != null && !expired ? w.resetsAt - now : null,
|
|
143
|
+
expired,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ranked(map) {
|
|
148
|
+
return [...map.entries()]
|
|
149
|
+
.map(([label, tokens]) => ({ label, tokens }))
|
|
150
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getCodexUsage(now = Date.now()) {
|
|
154
|
+
if (!fs.existsSync(path.join(codexDir, 'sessions'))) return null
|
|
155
|
+
const startOfDay = new Date(now).setHours(0, 0, 0, 0)
|
|
156
|
+
const start30 = startOfDay - (DAYS - 1) * DAY
|
|
157
|
+
const files = rollouts(start30)
|
|
158
|
+
const seen = new Set()
|
|
159
|
+
let limits = null
|
|
160
|
+
let latest = null
|
|
161
|
+
let tokensToday = 0
|
|
162
|
+
let tokens5h = 0
|
|
163
|
+
let tokensWeek = 0
|
|
164
|
+
let monthTokens = 0
|
|
165
|
+
const days30 = new Array(DAYS).fill(0)
|
|
166
|
+
const byModel = new Map()
|
|
167
|
+
const byProject = new Map()
|
|
168
|
+
for (const { file, st } of files) {
|
|
169
|
+
seen.add(file)
|
|
170
|
+
const sum = readFile(file, st)
|
|
171
|
+
if (sum.limits && (!limits || sum.limits.ts > limits.ts)) limits = sum.limits
|
|
172
|
+
if (!latest || sum.mtime > latest.mtime) latest = sum
|
|
173
|
+
for (const e of sum.events) {
|
|
174
|
+
if (e.ts < start30) continue
|
|
175
|
+
monthTokens += e.tokens
|
|
176
|
+
days30[Math.min(DAYS - 1, Math.floor((e.ts - start30) / DAY))] += e.tokens
|
|
177
|
+
if (e.ts >= startOfDay) tokensToday += e.tokens
|
|
178
|
+
if (now - e.ts < 5 * 3600000) tokens5h += e.tokens
|
|
179
|
+
if (now - e.ts < WEEK) {
|
|
180
|
+
tokensWeek += e.tokens
|
|
181
|
+
const m = e.model || 'unknown'
|
|
182
|
+
byModel.set(m, (byModel.get(m) || 0) + e.tokens)
|
|
183
|
+
if (e.cwd) {
|
|
184
|
+
const p = path.basename(e.cwd)
|
|
185
|
+
byProject.set(p, (byProject.get(p) || 0) + e.tokens)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// forget files that aged out of the window
|
|
191
|
+
for (const f of fileCache.keys()) if (!seen.has(f)) fileCache.delete(f)
|
|
192
|
+
|
|
193
|
+
const projects = ranked(byProject)
|
|
194
|
+
const byProjectArr = projects.slice(0, TOP_PROJECTS)
|
|
195
|
+
const rest = projects.slice(TOP_PROJECTS)
|
|
196
|
+
if (rest.length) {
|
|
197
|
+
byProjectArr.push({
|
|
198
|
+
label: `other ยท ${rest.length}`,
|
|
199
|
+
tokens: rest.reduce((n, p) => n + p.tokens, 0),
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
active: !!latest && now - latest.mtime < ACTIVE_MS,
|
|
205
|
+
lastSeen: latest ? latest.mtime : null,
|
|
206
|
+
plan: limits?.plan || null,
|
|
207
|
+
session: limits ? windowNow(limits.primary, now) : null,
|
|
208
|
+
weekly: limits ? windowNow(limits.secondary, now) : null,
|
|
209
|
+
limitsAt: limits?.ts || null,
|
|
210
|
+
tokensToday,
|
|
211
|
+
tokens5h,
|
|
212
|
+
tokensWeek,
|
|
213
|
+
byModel: ranked(byModel),
|
|
214
|
+
byProject: byProjectArr,
|
|
215
|
+
days30,
|
|
216
|
+
monthTokens,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { getCodexUsage, detectCodex, setCodexDir }
|
package/main.js
CHANGED
|
@@ -18,8 +18,13 @@ const usage = require('./usage')
|
|
|
18
18
|
const { getUsage } = usage
|
|
19
19
|
const auth = require('./auth')
|
|
20
20
|
const accounts = require('./accounts')
|
|
21
|
+
const codex = require('./codex')
|
|
21
22
|
|
|
22
23
|
const REPO = 'renatoaug/claude-usage-monitor'
|
|
24
|
+
const USAGE_URLS = {
|
|
25
|
+
claude: 'https://claude.ai/settings/usage',
|
|
26
|
+
codex: 'https://chatgpt.com/usage#settings/Usage',
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
// data dir: kept outside the project folder so moving the repo doesn't break it.
|
|
25
30
|
// when CLAUDE_CONFIG_DIR is set (e.g. via direnv for multi-account setups), nest
|
|
@@ -47,7 +52,7 @@ let win
|
|
|
47
52
|
let pollTimer
|
|
48
53
|
let config
|
|
49
54
|
let doTick = null
|
|
50
|
-
const W =
|
|
55
|
+
const W = 304
|
|
51
56
|
|
|
52
57
|
// menu-bar (tray) mode state
|
|
53
58
|
let tray = null
|
|
@@ -55,6 +60,7 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
|
|
|
55
60
|
let trayBounds = null // last known tray icon rect, to anchor the popover
|
|
56
61
|
let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
|
|
57
62
|
let sessionPct = null // authoritative session % shown in the tray title
|
|
63
|
+
let codexUsage = null // last Codex payload, null while it isn't enabled
|
|
58
64
|
let realUsage = null // last OAuth usage payload โ the % alerts trust when logged in
|
|
59
65
|
let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
|
|
60
66
|
let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
|
|
@@ -70,6 +76,10 @@ function publicConfig(c) {
|
|
|
70
76
|
alertThresholds: c.alertThresholds,
|
|
71
77
|
fireThreshold: c.fireThreshold,
|
|
72
78
|
zoom: c.zoom,
|
|
79
|
+
codex: !!c.codex?.enabled,
|
|
80
|
+
talk: c.talk,
|
|
81
|
+
sound: c.sound,
|
|
82
|
+
soundMutedUntil: c.soundMutedUntil,
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
|
|
@@ -84,6 +94,10 @@ function loadConfig() {
|
|
|
84
94
|
alertThresholds: [80, 95],
|
|
85
95
|
fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
|
|
86
96
|
zoom: 100, // widget scale %, 100-200
|
|
97
|
+
codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
|
|
98
|
+
talk: true, // speech bubbles on transitions
|
|
99
|
+
sound: false, // chiptune blips: opt-in, audio must never surprise anyone
|
|
100
|
+
soundMutedUntil: 0, // the one-click "mute for 1 hour", as an epoch ms
|
|
87
101
|
pollIntervalMs: 4000,
|
|
88
102
|
activeThresholdMs: 20000,
|
|
89
103
|
sleepThresholdMs: 300000,
|
|
@@ -188,6 +202,15 @@ function checkAlerts(config, d) {
|
|
|
188
202
|
])
|
|
189
203
|
checkWindowReset(config, session.pct)
|
|
190
204
|
}
|
|
205
|
+
// Codex has its own windows, so its own keys: `Codex session:80` never
|
|
206
|
+
// collides with Claude's `Session:80`
|
|
207
|
+
function checkCodexAlerts(config, c) {
|
|
208
|
+
if (!c) return
|
|
209
|
+
alertScopes(config, [
|
|
210
|
+
{ label: 'Codex session', pct: c.session?.pct, resetMs: c.session?.resetMs, session: true },
|
|
211
|
+
{ label: 'Codex weekly', pct: c.weekly?.pct, resetMs: c.weekly?.resetMs },
|
|
212
|
+
])
|
|
213
|
+
}
|
|
191
214
|
// per-model weekly limits only exist on the account side, so they're checked
|
|
192
215
|
// off the OAuth poll rather than the local tick
|
|
193
216
|
function checkScopedAlerts(config, u) {
|
|
@@ -362,6 +385,9 @@ function createWindow() {
|
|
|
362
385
|
preload: path.join(__dirname, 'preload.js'),
|
|
363
386
|
contextIsolation: true,
|
|
364
387
|
nodeIntegration: false,
|
|
388
|
+
// the pet's blips start from a usage change, never from a click, so
|
|
389
|
+
// Chromium would otherwise hold the AudioContext suspended
|
|
390
|
+
autoplayPolicy: 'no-user-gesture-required',
|
|
365
391
|
},
|
|
366
392
|
})
|
|
367
393
|
|
|
@@ -408,6 +434,16 @@ function createWindow() {
|
|
|
408
434
|
} catch (err) {
|
|
409
435
|
win.webContents.send('usage-error', String(err))
|
|
410
436
|
}
|
|
437
|
+
// Codex is a separate, optional source: its failure must not blank Claude's
|
|
438
|
+
try {
|
|
439
|
+
const c = config.codex?.enabled ? codex.getCodexUsage() : null
|
|
440
|
+
codexUsage = c
|
|
441
|
+
win.webContents.send('codex', c)
|
|
442
|
+
checkCodexAlerts(config, c)
|
|
443
|
+
updateTray()
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error('codex:', err)
|
|
446
|
+
}
|
|
411
447
|
}
|
|
412
448
|
doTick = tick
|
|
413
449
|
|
|
@@ -497,10 +533,12 @@ function trayMenu() {
|
|
|
497
533
|
return Menu.buildFromTemplate([
|
|
498
534
|
{ label: 'Open Clauddy', click: () => showPopover() },
|
|
499
535
|
...accountsMenuItems(),
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
536
|
+
...(config.codex?.enabled
|
|
537
|
+
? [
|
|
538
|
+
{ label: 'Open Claude usage', click: () => shell.openExternal(USAGE_URLS.claude) },
|
|
539
|
+
{ label: 'Open Codex usage', click: () => shell.openExternal(USAGE_URLS.codex) },
|
|
540
|
+
]
|
|
541
|
+
: [{ label: 'Open Usage page', click: () => shell.openExternal(USAGE_URLS.claude) }]),
|
|
504
542
|
{ type: 'separator' },
|
|
505
543
|
{ label: 'Quit Clauddy', click: () => app.quit() },
|
|
506
544
|
])
|
|
@@ -595,6 +633,18 @@ function positionFloating() {
|
|
|
595
633
|
// the tray shows the live session % (macOS title), turning ๐ฅ near the limit
|
|
596
634
|
function updateTray() {
|
|
597
635
|
if (!tray) return
|
|
636
|
+
const cx = codexUsage
|
|
637
|
+
const cxPct = cx?.session?.pct
|
|
638
|
+
// both services: one icon, both numbers, each labelled โ never one blended %
|
|
639
|
+
if (cx) {
|
|
640
|
+
const fmt = (v) => (v == null ? 'โ' : `${Math.round(v)}%`)
|
|
641
|
+
const title = sessionPct == null ? `X ${fmt(cxPct)}` : `C ${fmt(sessionPct)} ยท X ${fmt(cxPct)}`
|
|
642
|
+
if (process.platform === 'darwin') tray.setTitle(` ${title}`)
|
|
643
|
+
const age = cx.limitsAt ? ` (as of ${new Date(cx.limitsAt).toLocaleTimeString()})` : ''
|
|
644
|
+
const claude = sessionPct == null ? '' : `Claude session ${fmt(sessionPct)} ยท `
|
|
645
|
+
tray.setToolTip(`Clauddy โ ${claude}Codex session ${fmt(cxPct)}${age}`)
|
|
646
|
+
return
|
|
647
|
+
}
|
|
598
648
|
if (sessionPct == null) {
|
|
599
649
|
if (process.platform === 'darwin') tray.setTitle('')
|
|
600
650
|
tray.setToolTip('Clauddy โ connect your account for live %')
|
|
@@ -637,7 +687,20 @@ ipcMain.on('resize', (_e, w, h) => {
|
|
|
637
687
|
}
|
|
638
688
|
})
|
|
639
689
|
|
|
640
|
-
|
|
690
|
+
// the arrow opens the page of whichever service the panel is showing
|
|
691
|
+
ipcMain.on('open-usage', (_e, provider) =>
|
|
692
|
+
shell.openExternal(USAGE_URLS[provider] || USAGE_URLS.claude),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
// Settings โ Connect Codex: look first; the renderer enables it when found
|
|
696
|
+
ipcMain.on('codex-detect', () => {
|
|
697
|
+
let r = { found: false, plan: null }
|
|
698
|
+
try {
|
|
699
|
+
r = codex.detectCodex()
|
|
700
|
+
} catch {}
|
|
701
|
+
if (win && !win.isDestroyed()) win.webContents.send('codex-detected', r)
|
|
702
|
+
})
|
|
703
|
+
ipcMain.on('codex-enable', (_e, on) => setCodexEnabled(!!on))
|
|
641
704
|
|
|
642
705
|
// watch the debug file; forward forced states to the renderer
|
|
643
706
|
function watchDebug() {
|
|
@@ -818,7 +881,17 @@ ipcMain.on('auth-logout', () => {
|
|
|
818
881
|
profileShown = false
|
|
819
882
|
})
|
|
820
883
|
|
|
821
|
-
|
|
884
|
+
// off only stops monitoring here; Codex's own logs and login are untouched
|
|
885
|
+
function setCodexEnabled(on) {
|
|
886
|
+
writeConfigPatch({ codex: { ...(config.codex || {}), enabled: on } })
|
|
887
|
+
config = loadConfig()
|
|
888
|
+
if (!on) codexUsage = null
|
|
889
|
+
if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
|
|
890
|
+
if (doTick) doTick()
|
|
891
|
+
updateTray()
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function writeConfigPatch(patch) {
|
|
822
895
|
let obj = {}
|
|
823
896
|
for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
|
|
824
897
|
try {
|
|
@@ -831,6 +904,10 @@ ipcMain.on('save-config', (_e, patch) => {
|
|
|
831
904
|
fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
|
|
832
905
|
fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
|
|
833
906
|
} catch {}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
ipcMain.on('save-config', (_e, patch) => {
|
|
910
|
+
writeConfigPatch(patch)
|
|
834
911
|
const prevThresholds = String(config.alertThresholds)
|
|
835
912
|
config = loadConfig()
|
|
836
913
|
// re-arm only when the thresholds actually moved: saving an unrelated setting
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauddy",
|
|
3
3
|
"desktopName": "clauddy.desktop",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.25.0",
|
|
5
5
|
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
6
6
|
"main": "main.js",
|
|
7
7
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"usage.js",
|
|
15
15
|
"auth.js",
|
|
16
16
|
"accounts.js",
|
|
17
|
+
"codex.js",
|
|
17
18
|
"config.json",
|
|
18
19
|
"renderer"
|
|
19
20
|
],
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
"usage.js",
|
|
74
75
|
"auth.js",
|
|
75
76
|
"accounts.js",
|
|
77
|
+
"codex.js",
|
|
76
78
|
"config.json",
|
|
77
79
|
"renderer/**",
|
|
78
80
|
"build/trayTemplate.png",
|
package/preload.js
CHANGED
|
@@ -6,7 +6,11 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
6
6
|
onConfig: (cb) => ipcRenderer.on('config', (_e, cfg) => cb(cfg)),
|
|
7
7
|
saveConfig: (patch) => ipcRenderer.send('save-config', patch),
|
|
8
8
|
resize: (w, h) => ipcRenderer.send('resize', w, h),
|
|
9
|
-
openUsage: () => ipcRenderer.send('open-usage'),
|
|
9
|
+
openUsage: (provider) => ipcRenderer.send('open-usage', provider),
|
|
10
|
+
codexDetect: () => ipcRenderer.send('codex-detect'),
|
|
11
|
+
onCodexDetected: (cb) => ipcRenderer.on('codex-detected', (_e, r) => cb(r)),
|
|
12
|
+
codexEnable: (on) => ipcRenderer.send('codex-enable', on),
|
|
13
|
+
onCodex: (cb) => ipcRenderer.on('codex', (_e, c) => cb(c)),
|
|
10
14
|
onRealUsage: (cb) => ipcRenderer.on('real-usage', (_e, u) => cb(u)),
|
|
11
15
|
onAuthState: (cb) => ipcRenderer.on('auth-state', (_e, s) => cb(s)),
|
|
12
16
|
onProfile: (cb) => ipcRenderer.on('profile', (_e, p) => cb(p)),
|