clauddy 1.17.1 → 1.18.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.
Files changed (3) hide show
  1. package/README.md +13 -1
  2. package/main.js +131 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -177,7 +177,19 @@ Switching is instant — no restart. (On Windows/Linux the icon lives in the sys
177
177
 
178
178
  ## Alerts
179
179
 
180
- Optional **macOS notifications** when your session or weekly usage crosses the thresholds you set (default **80%** and **95%**) — e.g. _"Your session is over 80% — now at 82%"_. They re-arm automatically once usage drops back below a threshold (after a reset). Toggle them and edit the thresholds in **⚙ Settings**.
180
+ Optional **macOS notifications**, toggled (with their thresholds) in **⚙ Settings**:
181
+
182
+ | Notification | When |
183
+ | --- | --- |
184
+ | _Session at 82%_ — `2h 39m left · resets 6:50 PM` | Your session crosses a threshold (default **80%** and **95%**) |
185
+ | _Weekly usage at 84%_ — `resets Fri 7:00 AM` | Same, for the weekly limit |
186
+ | _Fable weekly at 84%_ — `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
187
+ | _Session window reset_ — `full budget again` | A session you had pushed past 80% rolls over |
188
+ | _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
189
+
190
+ The last threshold you set is the only one that makes a sound; the earlier ones arrive silently. Clicking any of them brings the widget to the front. Each fires once and re-arms when usage drops back below — remembered across restarts, so relaunching at 85% doesn't repeat an alert you already dismissed.
191
+
192
+ Percentages come from your account when you're logged in, and fall back to the local token estimate when you're not.
181
193
 
182
194
  The first alert asks macOS for permission; after that the app shows up in **System Settings > Notifications** like any other.
183
195
 
package/main.js CHANGED
@@ -52,6 +52,7 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
52
52
  let trayBounds = null // last known tray icon rect, to anchor the popover
53
53
  let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
54
54
  let sessionPct = null // authoritative session % shown in the tray title
55
+ let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
55
56
  let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
56
57
  let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
57
58
 
@@ -92,46 +93,133 @@ function loadConfig() {
92
93
  return defaults
93
94
  }
94
95
 
95
- // native notification when usage crosses a threshold
96
- const armed = new Set()
97
- function checkAlerts(config, d) {
98
- alertScopes(config, [
99
- ['session', d.session.pct],
100
- ['weekly usage', d.week.pct],
101
- ])
96
+ // ---- alerts ------------------------------------------------------------------
97
+ // One notification per (scope × threshold), re-armed when usage drops back below.
98
+ // `armed` is persisted so relaunching at 85% doesn't repeat the 80% alert you
99
+ // already dismissed; it also carries the last session % so a reset is detectable
100
+ // across restarts.
101
+ const ALERTS_FILE = path.join(DATA_DIR, 'alerts.json')
102
+ let armed = new Set()
103
+ let lastSessionPct = null
104
+ function loadAlertState() {
105
+ try {
106
+ const j = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'))
107
+ armed = new Set(Array.isArray(j.armed) ? j.armed : [])
108
+ lastSessionPct = typeof j.lastSessionPct === 'number' ? j.lastSessionPct : null
109
+ } catch {} // first run, or a corrupted file: start from a clean slate
102
110
  }
103
- // per-model weekly limits only exist on the account side, so they're checked
104
- // off the OAuth poll rather than the local tick
105
- function checkScopedAlerts(config, u) {
106
- alertScopes(
107
- config,
108
- (u?.scoped || []).map((s) => [`${s.label} weekly usage`, s.pct]),
109
- )
111
+ function saveAlertState() {
112
+ try {
113
+ fs.mkdirSync(DATA_DIR, { recursive: true })
114
+ fs.writeFileSync(ALERTS_FILE, JSON.stringify({ armed: [...armed], lastSessionPct }))
115
+ } catch {}
116
+ }
117
+
118
+ function fmtDuration(ms) {
119
+ const h = Math.floor(ms / 3600000)
120
+ const m = Math.floor((ms % 3600000) / 60000)
121
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
122
+ }
123
+ // wall-clock time the window flips, in the machine's own locale + timezone
124
+ function fmtClock(ms) {
125
+ const at = new Date(Date.now() + ms)
126
+ const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
127
+ return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
110
128
  }
129
+
130
+ function notify(title, body, { silent = true } = {}) {
131
+ if (!Notification.isSupported()) return
132
+ const n = new Notification({ title, body, silent })
133
+ n.on('click', showWidget) // the obvious gesture: bring the pet to the front
134
+ n.show()
135
+ }
136
+
137
+ // the alert lines carry the reset, since "what do I do about it" is a question
138
+ // about time left, not about the threshold that happened to fire
139
+ function resetLine(resetMs, withRemaining) {
140
+ if (resetMs == null) return ''
141
+ const at = `resets ${fmtClock(resetMs)}`
142
+ return withRemaining && resetMs > 0 ? `${fmtDuration(resetMs)} left · ${at}` : at
143
+ }
144
+
145
+ // usage crossing a threshold. `scopes` are {label, pct, resetMs, session} rows;
146
+ // the real (OAuth) numbers are preferred over the local token estimate, which is
147
+ // only a budget guess and can be off by an order of magnitude.
111
148
  function alertScopes(config, scopes) {
112
- if (!config.alerts || !Notification.isSupported()) return
149
+ if (!config.alerts) return
113
150
  const ths = config.alertThresholds || [80, 95]
114
- for (const [name, pct] of scopes) {
151
+ const top = Math.max(...ths)
152
+ let dirty = false
153
+ for (const { label, pct, resetMs, session } of scopes) {
154
+ if (pct == null) continue
115
155
  for (const t of ths) {
116
- const key = `${name}:${t}`
156
+ const key = `${label}:${t}`
117
157
  if (pct >= t) {
118
158
  if (!armed.has(key)) {
119
159
  armed.add(key)
120
- new Notification({
121
- title: 'Clauddy',
122
- body: `Your ${name} is over ${t}% — now at ${Math.round(pct)}%`,
123
- silent: false,
124
- }).show()
160
+ dirty = true
161
+ const urgent = t === top
162
+ notify(
163
+ `${label} at ${Math.round(pct)}%${urgent ? ' — almost out' : ''}`,
164
+ resetLine(resetMs, session),
165
+ { silent: !urgent }, // 80% is a heads-up; the last threshold earns a sound
166
+ )
125
167
  }
126
- } else {
127
- armed.delete(key) // re-arm when it drops below
168
+ } else if (armed.delete(key)) {
169
+ dirty = true // re-arm when it drops below
128
170
  }
129
171
  }
130
172
  }
173
+ if (dirty) saveAlertState()
174
+ }
175
+
176
+ function checkAlerts(config, d) {
177
+ const r = realUsage // authoritative when logged in; the estimate is the fallback
178
+ const session = r?.session ?? d.session
179
+ const week = r?.week ?? d.week
180
+ alertScopes(config, [
181
+ { label: 'Session', pct: session.pct, resetMs: session.resetMs, session: true },
182
+ { label: 'Weekly usage', pct: week.pct, resetMs: week.resetMs },
183
+ ])
184
+ checkWindowReset(config, session.pct)
185
+ }
186
+ // per-model weekly limits only exist on the account side, so they're checked
187
+ // off the OAuth poll rather than the local tick
188
+ function checkScopedAlerts(config, u) {
189
+ alertScopes(
190
+ config,
191
+ (u?.scoped || []).map((s) => ({
192
+ label: `${s.label} weekly`,
193
+ pct: s.pct,
194
+ resetMs: s.resetMs,
195
+ })),
196
+ )
197
+ }
198
+
199
+ // "you can work again" — only worth saying to someone who was actually near the
200
+ // ceiling, so a window flipping at 20% stays silent
201
+ const RESET_FROM = 80
202
+ const RESET_TO = 5
203
+ function checkWindowReset(config, pct) {
204
+ if (pct == null) return
205
+ const was = lastSessionPct
206
+ lastSessionPct = pct
207
+ if (config.alerts && was != null && was >= RESET_FROM && pct <= RESET_TO) {
208
+ notify('Session window reset', 'full budget again')
209
+ }
210
+ if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
211
+ }
212
+
213
+ // the token going dead is a silent failure otherwise: the widget quietly falls
214
+ // back to the local estimate and keeps showing a number the user trusts
215
+ function alertAuthLost(config) {
216
+ if (!config.alerts) return
217
+ notify('Clauddy lost access to your usage', 'open Settings to reconnect')
131
218
  }
132
219
 
133
220
  function createWindow() {
134
221
  config = loadConfig()
222
+ loadAlertState()
135
223
  const { workAreaSize } = screen.getPrimaryDisplay()
136
224
  const H = 480
137
225
 
@@ -289,6 +377,16 @@ function showPopover() {
289
377
  win.focus()
290
378
  }
291
379
 
380
+ // bring the widget to the front from wherever it is, in either mode
381
+ function showWidget() {
382
+ if (!win || win.isDestroyed()) return
383
+ if (currentMode === 'menubar') showPopover()
384
+ else {
385
+ win.show()
386
+ win.focus()
387
+ }
388
+ }
389
+
292
390
  // route every programmatic move through here so the 'moved' handler can tell
293
391
  // our own repositioning apart from a genuine user drag (and skip saving it)
294
392
  function moveWindow(x, y) {
@@ -369,6 +467,7 @@ function updateTray() {
369
467
 
370
468
  // send real usage to the renderer and refresh the tray title in one place
371
469
  function pushRealUsage(u) {
470
+ realUsage = u
372
471
  sessionPct = u?.session ? u.session.pct : null
373
472
  updateTray()
374
473
  checkScopedAlerts(config, u)
@@ -428,6 +527,7 @@ async function pollUsage() {
428
527
  } else if (e && e.status === 401) {
429
528
  auth.clear()
430
529
  pushRealUsage(null)
530
+ alertAuthLost(config)
431
531
  if (win && !win.isDestroyed()) {
432
532
  win.webContents.send('auth-state', { connected: false })
433
533
  win.webContents.send('profile', null)
@@ -503,8 +603,14 @@ ipcMain.on('save-config', (_e, patch) => {
503
603
  fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
504
604
  fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
505
605
  } catch {}
606
+ const prevThresholds = String(config.alertThresholds)
506
607
  config = loadConfig()
507
- armed.clear() // re-arm alerts with new thresholds
608
+ // re-arm only when the thresholds actually moved: saving an unrelated setting
609
+ // (zoom, mode…) shouldn't replay an alert the user already dismissed
610
+ if (String(config.alertThresholds) !== prevThresholds) {
611
+ armed.clear()
612
+ saveAlertState()
613
+ }
508
614
  if (doTick) doTick()
509
615
  if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
510
616
  applyMode(config.mode) // switch between floating widget and menu-bar popover live
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.17.1",
4
+ "version": "1.18.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {