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 +26 -8
- package/bin/clauddy.js +11 -1
- package/cursor.js +267 -0
- package/main.js +107 -28
- package/package.json +3 -1
- package/preload.js +4 -0
- package/reminders.js +3 -2
- package/renderer/companion.js +12 -8
- package/renderer/index.html +56 -8
- package/renderer/motion.css +0 -3
- package/renderer/pet.js +315 -148
- package/renderer/style.css +206 -6
- package/renderer/voice.js +12 -11
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
|
|
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
|
|
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
|
|
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
|
|
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 = [
|
|
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
|
-
|
|
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:
|
|
177
|
-
codex:
|
|
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 === '
|
|
197
|
-
?
|
|
198
|
-
:
|
|
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
|
|
204
|
-
const session = r.provider
|
|
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 (!
|
|
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
|
|
227
|
-
const
|
|
228
|
-
const
|
|
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 =
|
|
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
|
-
|
|
647
|
-
|
|
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
|
|
746
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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/**",
|