clauddy 1.26.0 โ†’ 1.28.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. Use Codex too? It can track that alongside (see [Codex](#codex)).
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 or Cursor too? It can track those alongside (see [Codex](#codex) and [Cursor](#cursor)).
4
4
 
5
5
  <p align="center">
6
6
 
@@ -53,6 +53,21 @@ A few things differ from Claude:
53
53
 
54
54
  Disconnecting (the **ร—** on the Codex card) only stops monitoring in Clauddy; your Codex login and logs are untouched.
55
55
 
56
+ ## Cursor
57
+
58
+ Clauddy can follow [Cursor](https://cursor.com) too. Open **โš™ Settings โ†’ Connections โ†’ Cursor โ†’ Connect**: it needs the Cursor app signed in on this computer, and starts monitoring once it finds that login. Clauddy reads the app's session token from its local storage and asks Cursor's own dashboard endpoints for your usage, the same numbers as the Usage page on cursor.com.
59
+
60
+ With three services connected, the tabs under the pet show each logo and %, and the minimized dock gets a third line.
61
+
62
+ Cursor works differently from the other two:
63
+
64
+ - **Its budget is monthly.** The Cursor tab shows **this month ยท included** (the whole plan) and **API models** (the pool for named third-party models), both resetting on your billing date. There is no 5-hour session or weekly window.
65
+ - **Activity scenes work.** Cursor's agent writes a transcript of every tool call under `~/.cursor/projects`, so the pet reads, edits, runs commands and researches along with it, as it does for Claude.
66
+ - **No token counts.** Neither source reports tokens, so the Cursor tab has no breakdowns or 30-day map, and its status line says when the agent last ran instead.
67
+ - **The endpoints are unofficial.** They're the ones the cursor.com dashboard uses, checked every 5 minutes. If Cursor changes them, the tab shows the last reading with its age until Clauddy is updated.
68
+
69
+ Disconnecting (the **ร—** on the Cursor card) only stops monitoring in Clauddy; the Cursor app and its login are untouched.
70
+
56
71
  ## Burn rate
57
72
 
58
73
  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:
@@ -123,6 +138,7 @@ Now and then the pet says something in a speech bubble. It only speaks when some
123
138
  | 90 minutes of unbroken work | _You've been at it for 1h 35m straight. Stretch break?_ | _Stretch break?_ | |
124
139
  | A new record day (30-day best) | _New record! 45M tokens today, your biggest day in a month._ | _New record: 45M!_ | |
125
140
  | Codex crosses fire, hits 100%, or resets | _Codex is at 91% now. It resets in 1h 12m._ | _Codex at 91%!_ | โœ“ (fire, reset) |
141
+ | Cursor crosses fire, hits 100%, or starts a new month | _Cursor is at 91% now. It resets in 6d 4h._ | _Cursor at 91%!_ | โœ“ (fire, reset) |
126
142
 
127
143
  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
144
 
@@ -214,8 +230,8 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
214
230
  ## Controls
215
231
 
216
232
  - **Drag** the widget anywhere on screen
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
218
- - **Pet icon** in the compact header switches to **Just the pet** (floating mode). Drag the pet itself to move it. Hovering over the pet (or focusing it) reveals the usage glance; the small expand button in it returns to compact. Keyboard users can focus the pet and press Enter. The chosen size is remembered. In compact and pet-only modes, the pet follows whoever is working, with provider logos identifying Claude, Codex, or both. Usage selection stays manual; when neither is active the pet rests.
233
+ - **โ€“** minimizes to just the pet, ringed by the live session % (the number sits inside the ring) โ€” or, with more than one service connected, one reading per service; the **โคข** button or a double-click on the pet expands it back
234
+ - **Pet icon** in the compact header switches to **Just the pet** (floating mode). Drag the pet itself to move it. Hovering over the pet (or focusing it) reveals the usage glance; the small expand button in it returns to compact. Keyboard users can focus the pet and press Enter. The chosen size is remembered. In compact and pet-only modes, the pet follows whoever is working, with provider logos identifying Claude, Codex, Cursor, or several at once. Usage selection stays manual. When neither is active, the hottest session still sets the mood: the pet catches fire (or maxes out) with that provider's logo beside it, and otherwise rests.
219
235
  - **โš™** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
220
236
  - **โ†—** opens the official Usage page of the service on screen
221
237
  - **ร—** quits
@@ -225,7 +241,7 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
225
241
  Under **โš™ Settings โ†’ Preferences** you can pick where Clauddy lives:
226
242
 
227
243
  - **Floating pet** โ€” the always-on widget in the corner (default).
228
- - **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.
244
+ - **Menu bar** โ€” a small pet icon in the macOS menu bar showing your live session **%** (it turns ๐Ÿ”ฅ near your limit; with Codex or Cursor too it reads `C 64% ยท X 28% ยท Cu 5%`). Click it to pop open the full pet + usage panel; click away to dismiss. Right-click for a quick menu.
229
245
 
230
246
  Switching is instant โ€” no restart. (On Windows/Linux the icon lives in the system tray; the live % shows in its tooltip.)
231
247
 
@@ -243,6 +259,7 @@ Optional **macOS notifications**, toggled (with their thresholds) in **โš™ Setti
243
259
  | _Weekly usage at 84%_ โ€” `resets Fri 7:00 AM` | Same, for the weekly limit |
244
260
  | _Fable weekly at 84%_ โ€” `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
245
261
  | _Codex session at 82%_ / _Codex weekly at 84%_ | Same, for Codex, when it's connected |
262
+ | _Cursor usage at 82%_ / _Cursor API models at 84%_ โ€” `resets Oct 11` | Same, for Cursor's month, when it's connected |
246
263
  | _Session window reset_ โ€” `full budget again` | A session you had pushed past 80% rolls over |
247
264
  | _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
248
265
 
@@ -256,7 +273,7 @@ The first alert asks macOS for permission; after that the app shows up in **Syst
256
273
 
257
274
  When the selected session crosses your first alert threshold, **Notify at reset** appears beneath the session details and in the compact monitor. Click to arm it; click **Reminder on** to cancel. This is a one-shot request, independent of the automatic threshold-alert toggle.
258
275
 
259
- Reminders survive restarts, belong to the Claude account or Codex connection that created them, and are canceled when you disconnect that source. Clauddy must be running to notify; an overdue reminder is delivered on reopening or waking up. A fresh, lower reading confirms that session budget returned and the pet celebrates. If usage hasn't refreshed โ€” common with idle Codex logs โ€” the reminder only says the scheduled reset time arrived and asks you to check usage.
276
+ Reminders survive restarts, belong to the Claude account, Codex or Cursor connection that created them, and are canceled when you disconnect that source. Clauddy must be running to notify; an overdue reminder is delivered on reopening or waking up. A fresh, lower reading confirms that session budget returned and the pet celebrates. If usage hasn't refreshed โ€” common with idle Codex logs โ€” the reminder only says the scheduled reset time arrived and asks you to check usage.
260
277
 
261
278
  ## Configure (`config.json`)
262
279
 
@@ -272,6 +289,7 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
272
289
  "activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
273
290
  "sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
274
291
  "codex": { "enabled": false }, // set by Settings โ†’ Codex โ†’ Connect
292
+ "cursor": { "enabled": false }, // set by Settings โ†’ Cursor โ†’ Connect
275
293
  "talk": true, // speech bubbles on transitions
276
294
  "sound": false, // chiptune blips, opt-in
277
295
  "soundMutedUntil": 0, // set by the ๐Ÿ”Š button: muted until this epoch ms
@@ -292,7 +310,7 @@ bunx clauddy tired # ๐Ÿฅต maxed out
292
310
  bunx clauddy idle # ๐Ÿ™‚ calm
293
311
  bunx clauddy auto # โ†ฉ๏ธ back to your real usage
294
312
  bunx clauddy say # ๐Ÿ’ฌ a remark in the speech bubble
295
- bunx clauddy say fire # or a specific one: greeting, fire, reset, maxed, welcome, streak, record, codex
313
+ bunx clauddy say fire # or a specific one: greeting, fire, reset, maxed, welcome, streak, record, codex, cursor
296
314
  ```
297
315
 
298
316
  Each state is written to the data dir the running widget watches, so it reacts
@@ -316,11 +334,11 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
316
334
  - `alerts.json` โ€” which notifications are already armed, so a restart doesn't repeat them
317
335
  - `accounts.json` โ€” your list of accounts and which one is active
318
336
 
319
- With Codex connected, Clauddy also reads (never writes) `~/.codex/sessions`.
337
+ With Codex connected, Clauddy also reads (never writes) `~/.codex/sessions`. With Cursor connected, it reads the Cursor app's login from its local storage and the agent transcripts in `~/.cursor/projects`, also without writing.
320
338
  - `debug.json` โ€” scratch file for the `./pet` simulator
321
339
  - `accounts/<id>/` โ€” the same `auth.json` + `alerts.json`, for each extra account
322
340
 
323
- Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
341
+ Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints, and, with Cursor connected, the usage calls to Cursor's own API with the Cursor app's token.
324
342
 
325
343
  ## Contributing
326
344
 
package/bin/clauddy.js CHANGED
@@ -33,7 +33,17 @@ const STATES = [
33
33
  'waiting',
34
34
  ]
35
35
  // `clauddy say <kind>` previews one of the pet's remarks
36
- const REMARKS = ['greeting', 'fire', 'reset', 'maxed', 'welcome', 'streak', 'record', 'codex']
36
+ const REMARKS = [
37
+ 'greeting',
38
+ 'fire',
39
+ 'reset',
40
+ 'maxed',
41
+ 'welcome',
42
+ 'streak',
43
+ 'record',
44
+ 'codex',
45
+ 'cursor',
46
+ ]
37
47
  const arg = process.argv[2]
38
48
  const kind = arg === 'say' ? process.argv[3] || 'greeting' : undefined
39
49
 
package/cursor.js ADDED
@@ -0,0 +1,267 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const os = require('node:os')
4
+
5
+ // Cursor keeps no usage in local files. Its app stores a session token in
6
+ // `state.vscdb` (SQLite), and the same dashboard endpoints cursor.com uses
7
+ // answer with it โ€” unofficial, so every field is read defensively. Activity
8
+ // comes from the agent transcripts under ~/.cursor/projects, which log each
9
+ // tool call much like Claude Code's own logs.
10
+ const API = 'https://api2.cursor.sh/aiserver.v1.DashboardService/'
11
+ const ACTIVE_MS = 60000 // a transcript touched within the last minute = Cursor working
12
+ const FETCH_MS = 5 * 60000 // a monthly budget moves slowly; don't hammer the API
13
+ const RETRY_MS = 60000
14
+ const TAIL_BYTES = 64 * 1024
15
+
16
+ function defaultStateDb() {
17
+ const tail = ['Cursor', 'User', 'globalStorage', 'state.vscdb']
18
+ if (process.platform === 'darwin')
19
+ return path.join(os.homedir(), 'Library', 'Application Support', ...tail)
20
+ if (process.platform === 'win32')
21
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), ...tail)
22
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), ...tail)
23
+ }
24
+
25
+ let homeDir = path.join(os.homedir(), '.cursor')
26
+ let stateDb = defaultStateDb()
27
+ let fetchImpl = (...args) => globalThis.fetch(...args)
28
+ let limits = null // last good reading: { ts, plan, endMs, total, api }
29
+ let lastTry = -Infinity
30
+ let lastFailed = false
31
+ let signedOut = false
32
+ let inflight = null
33
+
34
+ // tests point these elsewhere; a change of place forgets the old reading
35
+ function configureCursor(opts = {}) {
36
+ if ('fetch' in opts) fetchImpl = opts.fetch || ((...args) => globalThis.fetch(...args))
37
+ if (!('home' in opts) && !('stateDb' in opts)) return
38
+ if ('home' in opts) homeDir = opts.home || path.join(os.homedir(), '.cursor')
39
+ if ('stateDb' in opts) stateDb = opts.stateDb || defaultStateDb()
40
+ limits = null
41
+ lastTry = -Infinity
42
+ lastFailed = false
43
+ signedOut = false
44
+ inflight = null
45
+ }
46
+
47
+ const text = (v) => (v == null ? null : typeof v === 'string' ? v : Buffer.from(v).toString('utf8'))
48
+
49
+ // Electron's Node has `node:sqlite`; Bun, which runs the tests, has its own
50
+ // with the same prepare/get/close
51
+ function openReadOnly(file) {
52
+ try {
53
+ const { DatabaseSync } = require('node:sqlite')
54
+ return new DatabaseSync(file, { readOnly: true })
55
+ } catch (err) {
56
+ if (!process.versions.bun) throw err
57
+ const { Database } = require('bun:sqlite')
58
+ return new Database(file, { readonly: true })
59
+ }
60
+ }
61
+
62
+ // the app's own session: its token, plus the plan it last cached
63
+ function readAuth() {
64
+ if (!fs.existsSync(stateDb)) return null
65
+ let db
66
+ try {
67
+ db = openReadOnly(stateDb)
68
+ const q = db.prepare('SELECT value FROM ItemTable WHERE key = ?')
69
+ const get = (k) => text(q.get(k)?.value)
70
+ return { token: get('cursorAuth/accessToken'), plan: get('cursorAuth/stripeMembershipType') }
71
+ } catch {
72
+ return null
73
+ } finally {
74
+ db?.close()
75
+ }
76
+ }
77
+
78
+ // Settings โ†’ Connect: only a signed-in app has something to read
79
+ function detectCursor() {
80
+ const auth = readAuth()
81
+ return { found: !!auth?.token, plan: auth?.plan || null }
82
+ }
83
+
84
+ async function post(method, token) {
85
+ const res = await fetchImpl(API + method, {
86
+ method: 'POST',
87
+ headers: {
88
+ Authorization: `Bearer ${token}`,
89
+ 'Content-Type': 'application/json',
90
+ 'Connect-Protocol-Version': '1',
91
+ },
92
+ body: '{}',
93
+ signal: AbortSignal.timeout(15000),
94
+ })
95
+ if (!res.ok) throw new Error(`cursor ${method}: HTTP ${res.status}`)
96
+ return res.json()
97
+ }
98
+
99
+ const pctOf = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
100
+
101
+ // One fetch at a time. The token is read fresh each time: the Cursor app
102
+ // refreshes it on its own, so there is nothing to renew here.
103
+ function refreshLimits(now = Date.now()) {
104
+ if (inflight) return inflight
105
+ lastTry = now
106
+ inflight = (async () => {
107
+ const auth = readAuth()
108
+ signedOut = !auth?.token
109
+ if (signedOut) return
110
+ const [usage, info] = await Promise.all([
111
+ post('GetCurrentPeriodUsage', auth.token),
112
+ post('GetPlanInfo', auth.token).catch(() => null),
113
+ ])
114
+ const pu = usage?.planUsage || {}
115
+ limits = {
116
+ ts: now,
117
+ plan: info?.planInfo?.planName?.toLowerCase() || auth.plan || null,
118
+ endMs: Number(usage?.billingCycleEnd) || null,
119
+ total: pctOf(pu.totalPercentUsed),
120
+ api: pctOf(pu.apiPercentUsed),
121
+ }
122
+ })()
123
+ .then(() => {
124
+ lastFailed = false
125
+ })
126
+ .catch((err) => {
127
+ lastFailed = true
128
+ console.error('cursor:', err.message)
129
+ })
130
+ .finally(() => {
131
+ inflight = null
132
+ })
133
+ return inflight
134
+ }
135
+
136
+ // the billing cycle as a window; past its end, the % belongs to the old cycle
137
+ function cycleNow(pct, endMs, now) {
138
+ const expired = endMs != null && endMs <= now
139
+ return {
140
+ pct: expired ? null : pct,
141
+ resetMs: endMs != null && !expired ? endMs - now : null,
142
+ expired,
143
+ }
144
+ }
145
+
146
+ // ---- activity ---------------------------------------------------------------
147
+ const ACTIVITY = {
148
+ Read: 'reading',
149
+ ReadFile: 'reading',
150
+ Grep: 'reading',
151
+ Glob: 'reading',
152
+ LS: 'reading',
153
+ ListDir: 'reading',
154
+ SemanticSearch: 'reading',
155
+ ReadLints: 'reading',
156
+ Write: 'editing',
157
+ StrReplace: 'editing',
158
+ Edit: 'editing',
159
+ MultiEdit: 'editing',
160
+ ApplyPatch: 'editing',
161
+ Delete: 'editing',
162
+ EditNotebook: 'editing',
163
+ Shell: 'running',
164
+ WebSearch: 'researching',
165
+ WebFetch: 'researching',
166
+ Task: 'delegating',
167
+ TodoWrite: 'planning',
168
+ CreatePlan: 'planning',
169
+ AskQuestion: 'waiting',
170
+ }
171
+
172
+ // what the newest transcript lines say: a finished turn means nothing is running
173
+ function activityOf(lines) {
174
+ for (let i = lines.length - 1; i >= 0; i--) {
175
+ let o
176
+ try {
177
+ o = JSON.parse(lines[i])
178
+ } catch {
179
+ continue
180
+ }
181
+ if (o.type === 'turn_ended') return null
182
+ if (o.role === 'assistant') {
183
+ const blocks = Array.isArray(o.message?.content) ? o.message.content : []
184
+ const tool = blocks.findLast((b) => b?.type === 'tool_use')
185
+ return (tool && ACTIVITY[tool.name]) || 'working'
186
+ }
187
+ if (o.role === 'user') return 'working' // just asked: the agent is thinking
188
+ }
189
+ return 'working'
190
+ }
191
+
192
+ const dirs = (dir) => {
193
+ try {
194
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => !d.name.startsWith('.'))
195
+ } catch {
196
+ return []
197
+ }
198
+ }
199
+
200
+ // every transcript, subagents included: projects/<p>/agent-transcripts/<id>/โ€ฆ
201
+ function transcripts() {
202
+ const out = []
203
+ for (const p of dirs(path.join(homeDir, 'projects'))) {
204
+ if (!p.isDirectory()) continue
205
+ const root = path.join(homeDir, 'projects', p.name, 'agent-transcripts')
206
+ for (const id of dirs(root)) {
207
+ if (!id.isDirectory()) continue
208
+ const dir = path.join(root, id.name)
209
+ for (const f of dirs(dir)) {
210
+ if (f.isFile() && f.name.endsWith('.jsonl')) out.push(path.join(dir, f.name))
211
+ else if (f.isDirectory() && f.name === 'subagents') {
212
+ for (const s of dirs(path.join(dir, f.name)))
213
+ if (s.isFile() && s.name.endsWith('.jsonl')) out.push(path.join(dir, f.name, s.name))
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return out
219
+ }
220
+
221
+ function tailLines(file, size) {
222
+ const len = Math.min(size, TAIL_BYTES)
223
+ const buf = Buffer.alloc(len)
224
+ const fd = fs.openSync(file, 'r')
225
+ try {
226
+ fs.readSync(fd, buf, 0, len, size - len)
227
+ } finally {
228
+ fs.closeSync(fd)
229
+ }
230
+ const lines = buf.toString('utf8').split('\n')
231
+ if (len < size) lines.shift() // cut mid-line
232
+ return lines.filter(Boolean)
233
+ }
234
+
235
+ function readActivity(now) {
236
+ let latest = null
237
+ for (const file of transcripts()) {
238
+ try {
239
+ const st = fs.statSync(file)
240
+ if (!latest || st.mtimeMs > latest.st.mtimeMs) latest = { file, st }
241
+ } catch {}
242
+ }
243
+ if (!latest) return { active: false, activity: null, lastSeen: null }
244
+ const lastSeen = latest.st.mtimeMs
245
+ if (now - lastSeen >= ACTIVE_MS) return { active: false, activity: null, lastSeen }
246
+ let activity = 'working'
247
+ try {
248
+ activity = activityOf(tailLines(latest.file, latest.st.size))
249
+ } catch {}
250
+ return { active: activity != null, activity, lastSeen }
251
+ }
252
+
253
+ // The poll's view of Cursor: activity from disk every tick, limits from the
254
+ // last fetch (a new one starts in the background when it's due).
255
+ function getCursorUsage(now = Date.now()) {
256
+ if (now - lastTry >= (limits && !lastFailed ? FETCH_MS : RETRY_MS)) refreshLimits(now)
257
+ return {
258
+ ...readActivity(now),
259
+ plan: limits?.plan ?? null,
260
+ session: limits ? cycleNow(limits.total, limits.endMs, now) : null,
261
+ api: limits ? cycleNow(limits.api, limits.endMs, now) : null,
262
+ limitsAt: limits?.ts ?? null,
263
+ signedOut,
264
+ }
265
+ }
266
+
267
+ module.exports = { getCursorUsage, refreshLimits, detectCursor, configureCursor, activityOf }
package/main.js CHANGED
@@ -19,12 +19,14 @@ const { getUsage } = usage
19
19
  const auth = require('./auth')
20
20
  const accounts = require('./accounts')
21
21
  const codex = require('./codex')
22
+ const cursor = require('./cursor')
22
23
  const { createReminders } = require('./reminders')
23
24
 
24
25
  const REPO = 'renatoaug/claude-usage-monitor'
25
26
  const USAGE_URLS = {
26
27
  claude: 'https://claude.ai/settings/usage',
27
28
  codex: 'https://chatgpt.com/usage#settings/Usage',
29
+ cursor: 'https://cursor.com/dashboard?tab=usage',
28
30
  }
29
31
 
30
32
  // data dir: kept outside the project folder so moving the repo doesn't break it.
@@ -65,6 +67,8 @@ let sessionPct = null // authoritative session % shown in the tray title
65
67
  let codexUsage = null // last Codex payload, null while it isn't enabled
66
68
  let realUsageAt = 0
67
69
  let codexReadAt = 0
70
+ let cursorUsage = null // last Cursor payload, null while it isn't enabled
71
+ let cursorReadAt = 0
68
72
  let realUsage = null // last OAuth usage payload โ€” the % alerts trust when logged in
69
73
  let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
70
74
  let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
@@ -81,6 +85,7 @@ function publicConfig(c) {
81
85
  fireThreshold: c.fireThreshold,
82
86
  zoom: c.zoom,
83
87
  codex: !!c.codex?.enabled,
88
+ cursor: !!c.cursor?.enabled,
84
89
  talk: c.talk,
85
90
  sound: c.sound,
86
91
  soundMutedUntil: c.soundMutedUntil,
@@ -99,6 +104,7 @@ function loadConfig() {
99
104
  fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
100
105
  zoom: 100, // widget scale %, 100-200
101
106
  codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
107
+ cursor: { enabled: false }, // opt-in too: it reads the Cursor app's own login
102
108
  talk: true, // speech bubbles on transitions
103
109
  sound: false, // chiptune blips: opt-in, audio must never surprise anyone
104
110
  soundMutedUntil: 0, // the one-click "mute for 1 hour", as an epoch ms
@@ -147,6 +153,8 @@ function fmtDuration(ms) {
147
153
  function fmtClock(ms) {
148
154
  const at = new Date(Date.now() + ms)
149
155
  const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
156
+ // a week or more out (Cursor's monthly cycle), a weekday would be ambiguous
157
+ if (ms >= 6 * 86400000) return at.toLocaleDateString([], { month: 'short', day: 'numeric' })
150
158
  return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
151
159
  }
152
160
 
@@ -169,12 +177,35 @@ const resetReminders = createReminders({
169
177
  fs.renameSync(tmp, REMINDERS_FILE)
170
178
  },
171
179
  })
172
- const reminderKey = (provider) => (provider === 'codex' ? 'codex' : `claude:${accounts.activeId()}`)
180
+ // Codex and Cursor have one connection each; Claude's reminders are per account
181
+ const reminderKey = (provider) =>
182
+ provider === 'claude' ? `claude:${accounts.activeId()}` : provider
183
+ const NAMES = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor' }
184
+ // each service's reset window, when it was read, and whether it's still on
185
+ function serviceState(provider) {
186
+ if (provider === 'codex')
187
+ return {
188
+ session: codexUsage?.session,
189
+ readAt: codexReadAt,
190
+ freshAt: codexUsage?.limitsAt,
191
+ on: !!config.codex?.enabled,
192
+ }
193
+ if (provider === 'cursor')
194
+ return {
195
+ session: cursorUsage?.session,
196
+ readAt: cursorReadAt,
197
+ freshAt: cursorUsage?.limitsAt,
198
+ on: !!config.cursor?.enabled,
199
+ }
200
+ return { session: realUsage?.session, readAt: realUsageAt, freshAt: realUsageAt, on: null }
201
+ }
173
202
  function sendReminders(error = null) {
174
203
  if (!win || win.isDestroyed()) return
204
+ const find = (p) => resetReminders.list().find((r) => r.key === reminderKey(p)) || null
175
205
  win.webContents.send('reminders', {
176
- claude: resetReminders.list().find((r) => r.key === reminderKey('claude')) || null,
177
- codex: resetReminders.list().find((r) => r.key === 'codex') || null,
206
+ claude: find('claude'),
207
+ codex: find('codex'),
208
+ cursor: find('cursor'),
178
209
  error,
179
210
  })
180
211
  }
@@ -193,16 +224,15 @@ function checkResetReminders() {
193
224
  try {
194
225
  for (const r of resetReminders.list()) {
195
226
  const connected =
196
- r.provider === 'codex'
197
- ? config.codex?.enabled
198
- : accounts.list().some((a) => r.key === `claude:${a.id}` && hasToken(a.id))
227
+ r.provider === 'claude'
228
+ ? accounts.list().some((a) => r.key === `claude:${a.id}` && hasToken(a.id))
229
+ : serviceState(r.provider).on
199
230
  if (!connected && !cancelReminder(r.key)) return
200
231
  }
201
232
  const due = resetReminders.takeDue()
202
233
  for (const r of due) {
203
- const isCurrent = r.provider === 'codex' || r.key === reminderKey('claude')
204
- const session = r.provider === 'codex' ? codexUsage?.session : realUsage?.session
205
- const readAt = r.provider === 'codex' ? codexUsage?.limitsAt : realUsageAt
234
+ const isCurrent = r.provider !== 'claude' || r.key === reminderKey('claude')
235
+ const { session, freshAt: readAt } = serviceState(r.provider)
206
236
  const confirmed = isCurrent && readAt >= r.at && session?.pct != null && session.pct < r.pct
207
237
  const message = confirmed
208
238
  ? `${r.label}: session budget is available again.`
@@ -220,14 +250,12 @@ function checkResetReminders() {
220
250
  }
221
251
  }
222
252
  ipcMain.on('set-reminder', (_e, provider, enabled) => {
223
- if (!['claude', 'codex'].includes(provider) || typeof enabled !== 'boolean') return
253
+ if (!Object.hasOwn(NAMES, provider) || typeof enabled !== 'boolean') return
224
254
  const key = reminderKey(provider)
225
255
  if (!enabled) return cancelReminder(key)
226
- const cx = provider === 'codex'
227
- const session = cx ? codexUsage?.session : realUsage?.session
228
- const at = (cx ? codexReadAt : realUsageAt) + (session?.resetMs || 0)
229
- const freshAt = cx ? codexUsage?.limitsAt : realUsageAt
230
- const connected = cx ? config.codex?.enabled : auth.isConnected()
256
+ const { session, readAt, freshAt, on } = serviceState(provider)
257
+ const at = readAt + (session?.resetMs || 0)
258
+ const connected = provider === 'claude' ? auth.isConnected() : on
231
259
  if (
232
260
  !connected ||
233
261
  !freshAt ||
@@ -237,7 +265,10 @@ ipcMain.on('set-reminder', (_e, provider, enabled) => {
237
265
  return sendReminders('Wait for a fresh usage reading before setting a reminder.')
238
266
  }
239
267
  try {
240
- const label = cx ? 'Codex' : `Claude ยท ${accounts.active().label || 'your account'}`
268
+ const label =
269
+ provider === 'claude'
270
+ ? `Claude ยท ${accounts.active().label || 'your account'}`
271
+ : NAMES[provider]
241
272
  if (!resetReminders.arm({ key, provider, label, at, pct: session.pct })) {
242
273
  return sendReminders('This reset time has passed. Wait for a fresh reading.')
243
274
  }
@@ -305,6 +336,14 @@ function checkCodexAlerts(config, c) {
305
336
  { label: 'Codex weekly', pct: c.weekly?.pct, resetMs: c.weekly?.resetMs },
306
337
  ])
307
338
  }
339
+ // Cursor's budget is the billing cycle; its API-model pool fills on its own
340
+ function checkCursorAlerts(config, c) {
341
+ if (!c) return
342
+ alertScopes(config, [
343
+ { label: 'Cursor usage', pct: c.session?.pct, resetMs: c.session?.resetMs },
344
+ { label: 'Cursor API models', pct: c.api?.pct, resetMs: c.api?.resetMs },
345
+ ])
346
+ }
308
347
  // per-model weekly limits only exist on the account side, so they're checked
309
348
  // off the OAuth poll rather than the local tick
310
349
  function checkScopedAlerts(config, u) {
@@ -550,6 +589,17 @@ function createWindow() {
550
589
  } catch (err) {
551
590
  console.error('codex:', err)
552
591
  }
592
+ // Cursor too: limits come from a throttled background fetch
593
+ try {
594
+ const c = config.cursor?.enabled ? cursor.getCursorUsage() : null
595
+ cursorUsage = c
596
+ cursorReadAt = Date.now()
597
+ win.webContents.send('cursor', c)
598
+ checkCursorAlerts(config, c)
599
+ updateTray()
600
+ } catch (err) {
601
+ console.error('cursor:', err)
602
+ }
553
603
  checkResetReminders()
554
604
  }
555
605
  doTick = tick
@@ -641,11 +691,13 @@ function trayMenu() {
641
691
  return Menu.buildFromTemplate([
642
692
  { label: 'Open Clauddy', click: () => showPopover() },
643
693
  ...accountsMenuItems(),
644
- ...(config.codex?.enabled
645
- ? [
646
- { label: 'Open Claude usage', click: () => shell.openExternal(USAGE_URLS.claude) },
647
- { label: 'Open Codex usage', click: () => shell.openExternal(USAGE_URLS.codex) },
648
- ]
694
+ ...(config.codex?.enabled || config.cursor?.enabled
695
+ ? ['claude', 'codex', 'cursor']
696
+ .filter((p) => p === 'claude' || config[p]?.enabled)
697
+ .map((p) => ({
698
+ label: `Open ${NAMES[p]} usage`,
699
+ click: () => shell.openExternal(USAGE_URLS[p]),
700
+ }))
649
701
  : [{ label: 'Open Usage page', click: () => shell.openExternal(USAGE_URLS.claude) }]),
650
702
  { type: 'separator' },
651
703
  { label: 'Quit Clauddy', click: () => app.quit() },
@@ -742,15 +794,19 @@ function positionFloating() {
742
794
  function updateTray() {
743
795
  if (!tray) return
744
796
  const cx = codexUsage
745
- const cxPct = cx?.session?.pct
746
- // both services: one icon, both numbers, each labelled โ€” never one blended %
747
- if (cx) {
797
+ const cu = cursorUsage
798
+ // several services: one icon, every number labelled โ€” never one blended %
799
+ if (cx || cu) {
748
800
  const fmt = (v) => (v == null ? 'โ€”' : `${Math.round(v)}%`)
749
- const title = sessionPct == null ? `X ${fmt(cxPct)}` : `C ${fmt(sessionPct)} ยท X ${fmt(cxPct)}`
801
+ const age = (u) => (u.limitsAt ? ` (as of ${new Date(u.limitsAt).toLocaleTimeString()})` : '')
802
+ const parts = [
803
+ sessionPct != null && ['C', `Claude session ${fmt(sessionPct)}`, sessionPct],
804
+ cx && ['X', `Codex session ${fmt(cx.session?.pct)}${age(cx)}`, cx.session?.pct],
805
+ cu && ['Cu', `Cursor usage ${fmt(cu.session?.pct)}${age(cu)}`, cu.session?.pct],
806
+ ].filter(Boolean)
807
+ const title = parts.map(([k, , v]) => `${k} ${fmt(v)}`).join(' ยท ')
750
808
  if (process.platform === 'darwin') tray.setTitle(` ${title}`)
751
- const age = cx.limitsAt ? ` (as of ${new Date(cx.limitsAt).toLocaleTimeString()})` : ''
752
- const claude = sessionPct == null ? '' : `Claude session ${fmt(sessionPct)} ยท `
753
- tray.setToolTip(`Clauddy โ€” ${claude}Codex session ${fmt(cxPct)}${age}`)
809
+ tray.setToolTip(`Clauddy โ€” ${parts.map(([, t]) => t).join(' ยท ')}`)
754
810
  return
755
811
  }
756
812
  if (sessionPct == null) {
@@ -836,6 +892,16 @@ ipcMain.on('codex-detect', () => {
836
892
  })
837
893
  ipcMain.on('codex-enable', (_e, on) => setCodexEnabled(!!on))
838
894
 
895
+ // Settings โ†’ Connect Cursor: it needs the Cursor app signed in on this machine
896
+ ipcMain.on('cursor-detect', () => {
897
+ let r = { found: false, plan: null }
898
+ try {
899
+ r = cursor.detectCursor()
900
+ } catch {}
901
+ if (win && !win.isDestroyed()) win.webContents.send('cursor-detected', r)
902
+ })
903
+ ipcMain.on('cursor-enable', (_e, on) => setCursorEnabled(!!on))
904
+
839
905
  // watch the debug file; forward forced states to the renderer
840
906
  function watchDebug() {
841
907
  fs.watchFile(DEBUG_FILE, { interval: 400 }, () => {
@@ -1032,6 +1098,19 @@ function setCodexEnabled(on) {
1032
1098
  updateTray()
1033
1099
  }
1034
1100
 
1101
+ // off only stops monitoring here; the Cursor app and its login are untouched
1102
+ function setCursorEnabled(on) {
1103
+ writeConfigPatch({ cursor: { ...(config.cursor || {}), enabled: on } })
1104
+ config = loadConfig()
1105
+ if (!on) {
1106
+ cursorUsage = null
1107
+ cancelReminder('cursor')
1108
+ }
1109
+ if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
1110
+ if (doTick) doTick()
1111
+ updateTray()
1112
+ }
1113
+
1035
1114
  function writeConfigPatch(patch) {
1036
1115
  let obj = {}
1037
1116
  for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.26.0",
4
+ "version": "1.28.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "auth.js",
16
16
  "accounts.js",
17
17
  "codex.js",
18
+ "cursor.js",
18
19
  "reminders.js",
19
20
  "config.json",
20
21
  "renderer"
@@ -76,6 +77,7 @@
76
77
  "auth.js",
77
78
  "accounts.js",
78
79
  "codex.js",
80
+ "cursor.js",
79
81
  "reminders.js",
80
82
  "config.json",
81
83
  "renderer/**",