clauddy 1.23.0 โ†’ 1.24.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 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 โ†’ "Log in with browser"** โ€” your browser opens an Anthropic auth page.
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:
@@ -56,19 +70,19 @@ It only appears once there's enough to say honestly โ€” roughly 5 minutes into a
56
70
 
57
71
  <table>
58
72
  <tr>
59
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/idle.gif" width="280" alt="idle" /><br /><b>idle</b><br /><sub>breathes &amp; blinks</sub></td>
60
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/working.gif" width="280" alt="working" /><br /><b>working</b><br /><sub>hops &amp; eats token coins</sub></td>
73
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/idle.gif" width="280" alt="idle" /><br /><b>idle</b><br /><sub>breathes &amp; blinks</sub></td>
74
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/working.gif" width="280" alt="working" /><br /><b>working</b><br /><sub>hops &amp; eats token coins</sub></td>
61
75
  </tr>
62
76
  <tr>
63
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/on-fire.gif" width="280" alt="on fire" /><br /><b>on fire</b><br /><sub>session โ‰ฅ 90% โ†’ red, shivers, flames</sub></td>
64
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/tired.gif" width="280" alt="maxed out" /><br /><b>maxed out</b><br /><sub>session at 100% โ†’ drained, slumped, sweating</sub></td>
77
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/on-fire.gif" width="280" alt="on fire" /><br /><b>on fire</b><br /><sub>session โ‰ฅ 90% โ†’ red, shivers, flames</sub></td>
78
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/tired.gif" width="280" alt="maxed out" /><br /><b>maxed out</b><br /><sub>session at 100% โ†’ drained, slumped, sweating</sub></td>
65
79
  </tr>
66
80
  <tr>
67
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/sleeping.gif" width="280" alt="sleeping" /><br /><b>sleeping</b><br /><sub>idle 5+ min โ†’ blue zzz &amp; moonlight</sub></td>
68
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/poke.gif" width="280" alt="poke" /><br /><b>poke</b><br /><sub>click the pet โ†’ squish &amp; hearts</sub></td>
81
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/sleeping.gif" width="280" alt="sleeping" /><br /><b>sleeping</b><br /><sub>idle 5+ min โ†’ blue zzz &amp; moonlight</sub></td>
82
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/poke.gif" width="280" alt="poke" /><br /><b>poke</b><br /><sub>click the pet โ†’ squish &amp; hearts</sub></td>
69
83
  </tr>
70
84
  <tr>
71
- <td colspan="2" align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/celebrate.gif" width="280" alt="celebrate" /><br /><b>celebrate</b><br /><sub>session resets โ†’ jump &amp; confetti</sub></td>
85
+ <td colspan="2" align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/celebrate.gif" width="280" alt="celebrate" /><br /><b>celebrate</b><br /><sub>session resets โ†’ jump &amp; confetti</sub></td>
72
86
  </tr>
73
87
  </table>
74
88
 
@@ -83,11 +97,11 @@ own animated scene:
83
97
 
84
98
  <table>
85
99
  <tr>
86
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/reading.gif" width="280" alt="reading" /><br /><b>reading</b><br /><sub>puts on glasses &amp; flips through docs</sub></td>
87
- <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/editing.gif" width="280" alt="editing" /><br /><b>editing</b><br /><sub>types at the laptop, coffee in reach</sub></td>
100
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/reading.gif" width="280" alt="reading" /><br /><b>reading</b><br /><sub>an open book under the reading lamp</sub></td>
101
+ <td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/editing.gif" width="280" alt="editing" /><br /><b>editing</b><br /><sub>types at the desk, coffee in reach</sub></td>
88
102
  </tr>
89
103
  <tr>
90
- <td colspan="2" align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/running.gif" width="280" alt="running" /><br /><b>running</b><br /><sub>watches a task log tick through its checks</sub></td>
104
+ <td colspan="2" align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/a8491cea34c4a8547965eadc3b24618ba50218e1/docs/media/running.gif" width="280" alt="running" /><br /><b>running</b><br /><sub>watches a task log tick through its checks</sub></td>
91
105
  </tr>
92
106
  </table>
93
107
 
@@ -171,17 +185,17 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
171
185
  ## Controls
172
186
 
173
187
  - **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
188
+ - **โ€“** 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
189
  - **โš™** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
176
- - **โ†—** opens the official Usage page
190
+ - **โ†—** opens the official Usage page of the service on screen
177
191
  - **ร—** quits
178
192
 
179
193
  ### Floating or menu bar
180
194
 
181
- Under **โš™ Settings โ†’ Display** you can pick where Clauddy lives:
195
+ Under **โš™ Settings โ†’ Preferences** you can pick where Clauddy lives:
182
196
 
183
197
  - **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.
198
+ - **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
199
 
186
200
  Switching is instant โ€” no restart. (On Windows/Linux the icon lives in the system tray; the live % shows in its tooltip.)
187
201
 
@@ -194,6 +208,7 @@ Optional **macOS notifications**, toggled (with their thresholds) in **โš™ Setti
194
208
  | _Session at 82%_ โ€” `2h 39m left ยท resets 6:50 PM` | Your session crosses a threshold (default **80%** and **95%**) |
195
209
  | _Weekly usage at 84%_ โ€” `resets Fri 7:00 AM` | Same, for the weekly limit |
196
210
  | _Fable weekly at 84%_ โ€” `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
211
+ | _Codex session at 82%_ / _Codex weekly at 84%_ | Same, for Codex, when it's connected |
197
212
  | _Session window reset_ โ€” `full budget again` | A session you had pushed past 80% rolls over |
198
213
  | _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
199
214
 
@@ -216,6 +231,7 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
216
231
  "pollIntervalMs": 4000, // how often local logs are re-read
217
232
  "activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
218
233
  "sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
234
+ "codex": { "enabled": false }, // set by Settings โ†’ Codex โ†’ Connect
219
235
  }
220
236
  ```
221
237
 
@@ -254,6 +270,8 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
254
270
  - `config.json` โ€” your alert settings
255
271
  - `alerts.json` โ€” which notifications are already armed, so a restart doesn't repeat them
256
272
  - `accounts.json` โ€” your list of accounts and which one is active
273
+
274
+ With Codex connected, Clauddy also reads (never writes) `~/.codex/sessions`.
257
275
  - `debug.json` โ€” scratch file for the `./pet` simulator
258
276
  - `accounts/<id>/` โ€” the same `auth.json` + `alerts.json`, for each extra account
259
277
 
package/auth.js CHANGED
@@ -139,7 +139,15 @@ async function complete(pasted) {
139
139
  pending = null
140
140
  }
141
141
 
142
- async function refresh() {
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() {
143
151
  const t = load()
144
152
  if (!t?.refresh_token) throw Object.assign(new Error('no refresh token'), { status: 401 })
145
153
  const res = await fetch(TOKEN_URL, {
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 = 276
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,7 @@ function publicConfig(c) {
70
76
  alertThresholds: c.alertThresholds,
71
77
  fireThreshold: c.fireThreshold,
72
78
  zoom: c.zoom,
79
+ codex: !!c.codex?.enabled,
73
80
  }
74
81
  }
75
82
 
@@ -84,6 +91,7 @@ function loadConfig() {
84
91
  alertThresholds: [80, 95],
85
92
  fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
86
93
  zoom: 100, // widget scale %, 100-200
94
+ codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
87
95
  pollIntervalMs: 4000,
88
96
  activeThresholdMs: 20000,
89
97
  sleepThresholdMs: 300000,
@@ -188,6 +196,15 @@ function checkAlerts(config, d) {
188
196
  ])
189
197
  checkWindowReset(config, session.pct)
190
198
  }
199
+ // Codex has its own windows, so its own keys: `Codex session:80` never
200
+ // collides with Claude's `Session:80`
201
+ function checkCodexAlerts(config, c) {
202
+ if (!c) return
203
+ alertScopes(config, [
204
+ { label: 'Codex session', pct: c.session?.pct, resetMs: c.session?.resetMs, session: true },
205
+ { label: 'Codex weekly', pct: c.weekly?.pct, resetMs: c.weekly?.resetMs },
206
+ ])
207
+ }
191
208
  // per-model weekly limits only exist on the account side, so they're checked
192
209
  // off the OAuth poll rather than the local tick
193
210
  function checkScopedAlerts(config, u) {
@@ -273,6 +290,7 @@ function switchAccount(id) {
273
290
  win.webContents.send('auth-state', { connected: auth.isConnected() })
274
291
  win.webContents.send('profile', null)
275
292
  }
293
+ profileShown = false
276
294
  sendAccounts(id) // the row goes pending right awayโ€ฆ
277
295
 
278
296
  // โ€ฆbecause the rest is synchronous and slow: re-reading the new account's
@@ -407,6 +425,16 @@ function createWindow() {
407
425
  } catch (err) {
408
426
  win.webContents.send('usage-error', String(err))
409
427
  }
428
+ // Codex is a separate, optional source: its failure must not blank Claude's
429
+ try {
430
+ const c = config.codex?.enabled ? codex.getCodexUsage() : null
431
+ codexUsage = c
432
+ win.webContents.send('codex', c)
433
+ checkCodexAlerts(config, c)
434
+ updateTray()
435
+ } catch (err) {
436
+ console.error('codex:', err)
437
+ }
410
438
  }
411
439
  doTick = tick
412
440
 
@@ -496,10 +524,12 @@ function trayMenu() {
496
524
  return Menu.buildFromTemplate([
497
525
  { label: 'Open Clauddy', click: () => showPopover() },
498
526
  ...accountsMenuItems(),
499
- {
500
- label: 'Open Usage page',
501
- click: () => shell.openExternal('https://claude.ai/settings/usage'),
502
- },
527
+ ...(config.codex?.enabled
528
+ ? [
529
+ { label: 'Open Claude usage', click: () => shell.openExternal(USAGE_URLS.claude) },
530
+ { label: 'Open Codex usage', click: () => shell.openExternal(USAGE_URLS.codex) },
531
+ ]
532
+ : [{ label: 'Open Usage page', click: () => shell.openExternal(USAGE_URLS.claude) }]),
503
533
  { type: 'separator' },
504
534
  { label: 'Quit Clauddy', click: () => app.quit() },
505
535
  ])
@@ -594,6 +624,18 @@ function positionFloating() {
594
624
  // the tray shows the live session % (macOS title), turning ๐Ÿ”ฅ near the limit
595
625
  function updateTray() {
596
626
  if (!tray) return
627
+ const cx = codexUsage
628
+ const cxPct = cx?.session?.pct
629
+ // both services: one icon, both numbers, each labelled โ€” never one blended %
630
+ if (cx) {
631
+ const fmt = (v) => (v == null ? 'โ€”' : `${Math.round(v)}%`)
632
+ const title = sessionPct == null ? `X ${fmt(cxPct)}` : `C ${fmt(sessionPct)} ยท X ${fmt(cxPct)}`
633
+ if (process.platform === 'darwin') tray.setTitle(` ${title}`)
634
+ const age = cx.limitsAt ? ` (as of ${new Date(cx.limitsAt).toLocaleTimeString()})` : ''
635
+ const claude = sessionPct == null ? '' : `Claude session ${fmt(sessionPct)} ยท `
636
+ tray.setToolTip(`Clauddy โ€” ${claude}Codex session ${fmt(cxPct)}${age}`)
637
+ return
638
+ }
597
639
  if (sessionPct == null) {
598
640
  if (process.platform === 'darwin') tray.setTitle('')
599
641
  tray.setToolTip('Clauddy โ€” connect your account for live %')
@@ -636,7 +678,20 @@ ipcMain.on('resize', (_e, w, h) => {
636
678
  }
637
679
  })
638
680
 
639
- ipcMain.on('open-usage', () => shell.openExternal('https://claude.ai/settings/usage'))
681
+ // the arrow opens the page of whichever service the panel is showing
682
+ ipcMain.on('open-usage', (_e, provider) =>
683
+ shell.openExternal(USAGE_URLS[provider] || USAGE_URLS.claude),
684
+ )
685
+
686
+ // Settings โ†’ Connect Codex: look first; the renderer enables it when found
687
+ ipcMain.on('codex-detect', () => {
688
+ let r = { found: false, plan: null }
689
+ try {
690
+ r = codex.detectCodex()
691
+ } catch {}
692
+ if (win && !win.isDestroyed()) win.webContents.send('codex-detected', r)
693
+ })
694
+ ipcMain.on('codex-enable', (_e, on) => setCodexEnabled(!!on))
640
695
 
641
696
  // watch the debug file; forward forced states to the renderer
642
697
  function watchDebug() {
@@ -684,6 +739,13 @@ async function pollUsage() {
684
739
  usageBackoff = 5 * 60 * 1000
685
740
  authFails = 0
686
741
  pushRealUsage(u)
742
+ // a token that came back after being cleared (a refresh that landed late)
743
+ // brings the % back, but nothing re-sent the chip: restate who this is
744
+ if (!profileShown) {
745
+ if (win && !win.isDestroyed()) win.webContents.send('auth-state', { connected: true })
746
+ sendAccounts()
747
+ sendProfile()
748
+ }
687
749
  } catch (e) {
688
750
  if (e && e.status === 429) {
689
751
  usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
@@ -705,6 +767,7 @@ async function pollUsage() {
705
767
  win.webContents.send('auth-state', { connected: false })
706
768
  win.webContents.send('profile', null)
707
769
  }
770
+ profileShown = false
708
771
  }
709
772
  }
710
773
  scheduleUsagePoll()
@@ -729,6 +792,7 @@ function onResume() {
729
792
 
730
793
  // push the logged-in account's identity (email + plan) to the renderer
731
794
  let profileTimer = null
795
+ let profileShown = false // the renderer holds this account's profile
732
796
  async function sendProfile(tries = 0) {
733
797
  clearTimeout(profileTimer)
734
798
  if (!auth.isConnected()) return
@@ -743,6 +807,7 @@ async function sendProfile(tries = 0) {
743
807
  sendAccounts()
744
808
  }
745
809
  if (win && !win.isDestroyed()) win.webContents.send('profile', p)
810
+ profileShown = true
746
811
  } catch {
747
812
  // a rate limit or a blip would otherwise hide the chip โ€” and with it the
748
813
  // account switcher โ€” until the app is restarted, so keep trying for a while
@@ -804,9 +869,20 @@ ipcMain.on('auth-logout', () => {
804
869
  win.webContents.send('auth-state', { connected: false })
805
870
  win.webContents.send('profile', null)
806
871
  }
872
+ profileShown = false
807
873
  })
808
874
 
809
- ipcMain.on('save-config', (_e, patch) => {
875
+ // off only stops monitoring here; Codex's own logs and login are untouched
876
+ function setCodexEnabled(on) {
877
+ writeConfigPatch({ codex: { ...(config.codex || {}), enabled: on } })
878
+ config = loadConfig()
879
+ if (!on) codexUsage = null
880
+ if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
881
+ if (doTick) doTick()
882
+ updateTray()
883
+ }
884
+
885
+ function writeConfigPatch(patch) {
810
886
  let obj = {}
811
887
  for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
812
888
  try {
@@ -819,6 +895,10 @@ ipcMain.on('save-config', (_e, patch) => {
819
895
  fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
820
896
  fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
821
897
  } catch {}
898
+ }
899
+
900
+ ipcMain.on('save-config', (_e, patch) => {
901
+ writeConfigPatch(patch)
822
902
  const prevThresholds = String(config.alertThresholds)
823
903
  config = loadConfig()
824
904
  // 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.23.0",
4
+ "version": "1.24.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)),