clauddy 1.24.0 → 1.26.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
@@ -109,6 +109,35 @@ Other activities — **planning**, **researching**, **delegating**, **waiting**
109
109
  show up in the status line as they happen. When Claude goes quiet, the pet drops
110
110
  back to plain **working** / **idle**.
111
111
 
112
+ ## The pet talks
113
+
114
+ Now and then the pet says something in a speech bubble. It only speaks when something changes, never on a timer, and it keeps quiet when it has nothing specific to say. Remarks are at least 10 minutes apart; the big moments can cut that line.
115
+
116
+ | Moment | What it says (expanded) | Minimized | Blip |
117
+ | --- | --- | --- | --- |
118
+ | First launch of the day | _Good morning! Yesterday was your heaviest day this week, 12M tokens._ | _Morning! Big day yesterday._ | ✓ |
119
+ | Session crosses your fire threshold | _Getting warm, 91% already. At this pace you'll run out in about 40m._ | _91%, getting warm!_ | ✓ |
120
+ | The session window resets | _Fresh window! That was 34M tokens over 4h 12m, mostly editing code._ | _Fresh window! 34M last time._ | ✓ |
121
+ | The session hits 100% | _That's the limit. I'll be back at 2:35 PM._ | _Maxed out till 2:35 PM._ | |
122
+ | You're back after 2+ hours away | _Welcome back! You were gone 3h 12m. The session's at 12%._ | _Welcome back!_ | |
123
+ | 90 minutes of unbroken work | _You've been at it for 1h 35m straight. Stretch break?_ | _Stretch break?_ | |
124
+ | A new record day (30-day best) | _New record! 45M tokens today, your biggest day in a month._ | _New record: 45M!_ | |
125
+ | Codex crosses fire, hits 100%, or resets | _Codex is at 91% now. It resets in 1h 12m._ | _Codex at 91%!_ | ✓ (fire, reset) |
126
+
127
+ The greeting follows your clock (_Good morning_, _Good afternoon_, _Good evening_, or _Still up?_ before 5 AM) and appears once a day, and so does the record. The reset recap covers only what the pet actually saw, so if you launch it mid-session it says less. Click the bubble to dismiss it.
128
+
129
+ ### The voice
130
+
131
+ Optionally the pet also **blips** as it talks: short chiptune notes, one per letter, generated live with Web Audio (square waves, no sound files). It blips faster and higher when it's on fire, slower and lower when it's sleepy. Only the headline moments blip (the ✓ rows above), because a sound on every remark wears out fast.
132
+
133
+ Audio should never catch you off guard in a meeting:
134
+
135
+ - **Off by default.** Turn it on under **⚙ Settings → voice → Chiptune voice** (you'll hear a sample).
136
+ - **One-click mute.** While the voice is on, a 🔊 button sits in the title bar. Click it to mute for an hour, and again to unmute.
137
+ - **Always silent** in menu-bar mode and while the pet is minimized.
138
+
139
+ **Speech bubbles** can be switched off entirely in the same place. Try any remark from the terminal with `bunx clauddy say <kind>` ([see below](#play-with-the-pet)).
140
+
112
141
  ## Install
113
142
 
114
143
  **macOS (Apple Silicon)** is the first-class build. **Windows (x64)** and **Linux (x64)** work too. The `bunx`/`npx` route below runs on all of them today.
@@ -186,6 +215,7 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
186
215
 
187
216
  - **Drag** the widget anywhere on screen
188
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.
189
219
  - **⚙** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
190
220
  - **↗** opens the official Usage page of the service on screen
191
221
  - **×** quits
@@ -199,6 +229,10 @@ Under **⚙ Settings → Preferences** you can pick where Clauddy lives:
199
229
 
200
230
  Switching is instant — no restart. (On Windows/Linux the icon lives in the system tray; the live % shows in its tooltip.)
201
231
 
232
+ ### Provider reactions
233
+
234
+ When a provider's activity changes, the pet briefly glances toward it, with no caption. Both can be working at once. A glance never changes your selected provider, and glances share a 30-second cooldown. Inactivity is treated as a pause, not a completed task.
235
+
202
236
  ## Alerts
203
237
 
204
238
  Optional **macOS notifications**, toggled (with their thresholds) in **⚙ Settings**:
@@ -218,6 +252,12 @@ Percentages come from your account when you're logged in, and fall back to the l
218
252
 
219
253
  The first alert asks macOS for permission; after that the app shows up in **System Settings > Notifications** like any other.
220
254
 
255
+ ### Remind me at the session reset
256
+
257
+ 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
+
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.
260
+
221
261
  ## Configure (`config.json`)
222
262
 
223
263
  Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you can tweak them without rebuilding:
@@ -232,6 +272,9 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
232
272
  "activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
233
273
  "sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
234
274
  "codex": { "enabled": false }, // set by Settings → Codex → Connect
275
+ "talk": true, // speech bubbles on transitions
276
+ "sound": false, // chiptune blips, opt-in
277
+ "soundMutedUntil": 0, // set by the 🔊 button: muted until this epoch ms
235
278
  }
236
279
  ```
237
280
 
@@ -248,6 +291,8 @@ bunx clauddy working # 🍴 eats token coins
248
291
  bunx clauddy tired # 🥵 maxed out
249
292
  bunx clauddy idle # 🙂 calm
250
293
  bunx clauddy auto # ↩️ back to your real usage
294
+ 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
251
296
  ```
252
297
 
253
298
  Each state is written to the data dir the running widget watches, so it reacts
@@ -259,7 +304,7 @@ live. (Installed globally? Drop the `bunx`: `clauddy poke`. Working on the repo?
259
304
  - **`main.js`** — Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
260
305
  - **`usage.js`** — reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/project/day, detects the rolling 5-hour session window, the working/sleeping status, and which activity (reading/editing/running/…) Claude is on from its latest tool use.
261
306
  - **`auth.js`** — OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
262
- - **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
307
+ - **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles. `voice.js` writes its remarks and synthesizes the blips.
263
308
  - **`make-icon.js`** — generates the app icon from the pixel sprite (`build/icon.icns`).
264
309
 
265
310
  ## Data & privacy
package/bin/clauddy.js CHANGED
@@ -20,6 +20,7 @@ const STATES = [
20
20
  'idle',
21
21
  'poke',
22
22
  'celebrate',
23
+ 'say',
23
24
  'auto',
24
25
  'clear',
25
26
  // activity states (what Claude Code is doing)
@@ -31,7 +32,10 @@ const STATES = [
31
32
  'delegating',
32
33
  'waiting',
33
34
  ]
35
+ // `clauddy say <kind>` previews one of the pet's remarks
36
+ const REMARKS = ['greeting', 'fire', 'reset', 'maxed', 'welcome', 'streak', 'record', 'codex']
34
37
  const arg = process.argv[2]
38
+ const kind = arg === 'say' ? process.argv[3] || 'greeting' : undefined
35
39
 
36
40
  if (!arg) {
37
41
  // no argument → launch the app
@@ -47,19 +51,25 @@ if (!arg) {
47
51
  'Usage:',
48
52
  ' clauddy launch the widget',
49
53
  ' clauddy <state> poke the running widget (just for fun)',
54
+ ' clauddy say [kind] make the pet say one of its remarks',
50
55
  '',
51
56
  'States: fire, sleeping, working, tired, idle, poke, celebrate, auto',
57
+ `Remarks: ${REMARKS.join(', ')}`,
52
58
  '(the widget must be running for a state to show)',
53
59
  ].join('\n'),
54
60
  )
61
+ } else if (kind && !REMARKS.includes(kind)) {
62
+ console.error(`clauddy: unknown remark "${kind}"`)
63
+ console.error(`try: ${REMARKS.join(', ')}`)
64
+ process.exit(1)
55
65
  } else if (STATES.includes(arg)) {
56
66
  // write the state for the running app to pick up
57
67
  fs.mkdirSync(dataDir, { recursive: true })
58
68
  fs.writeFileSync(
59
69
  path.join(dataDir, 'debug.json'),
60
- `${JSON.stringify({ state: arg, t: Date.now() })}\n`,
70
+ `${JSON.stringify({ state: arg, kind, t: Date.now() })}\n`,
61
71
  )
62
- console.log(`clauddy → ${arg} (the running widget will react)`)
72
+ console.log(`clauddy → ${arg}${kind ? ` ${kind}` : ''} (the running widget will react)`)
63
73
  } else {
64
74
  console.error(`clauddy: unknown command "${arg}"`)
65
75
  console.error('try: fire, sleeping, working, tired, idle, poke, celebrate, auto — or --help')
package/main.js CHANGED
@@ -19,6 +19,7 @@ const { getUsage } = usage
19
19
  const auth = require('./auth')
20
20
  const accounts = require('./accounts')
21
21
  const codex = require('./codex')
22
+ const { createReminders } = require('./reminders')
22
23
 
23
24
  const REPO = 'renatoaug/claude-usage-monitor'
24
25
  const USAGE_URLS = {
@@ -50,6 +51,7 @@ const WINDOW_STATE = path.join(DATA_DIR, 'window.json')
50
51
 
51
52
  let win
52
53
  let pollTimer
54
+ let petPointerTimer
53
55
  let config
54
56
  let doTick = null
55
57
  const W = 304
@@ -61,6 +63,8 @@ let trayBounds = null // last known tray icon rect, to anchor the popover
61
63
  let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
62
64
  let sessionPct = null // authoritative session % shown in the tray title
63
65
  let codexUsage = null // last Codex payload, null while it isn't enabled
66
+ let realUsageAt = 0
67
+ let codexReadAt = 0
64
68
  let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
65
69
  let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
66
70
  let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
@@ -77,6 +81,9 @@ function publicConfig(c) {
77
81
  fireThreshold: c.fireThreshold,
78
82
  zoom: c.zoom,
79
83
  codex: !!c.codex?.enabled,
84
+ talk: c.talk,
85
+ sound: c.sound,
86
+ soundMutedUntil: c.soundMutedUntil,
80
87
  }
81
88
  }
82
89
 
@@ -92,6 +99,9 @@ function loadConfig() {
92
99
  fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
93
100
  zoom: 100, // widget scale %, 100-200
94
101
  codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
102
+ talk: true, // speech bubbles on transitions
103
+ sound: false, // chiptune blips: opt-in, audio must never surprise anyone
104
+ soundMutedUntil: 0, // the one-click "mute for 1 hour", as an epoch ms
95
105
  pollIntervalMs: 4000,
96
106
  activeThresholdMs: 20000,
97
107
  sleepThresholdMs: 300000,
@@ -147,6 +157,96 @@ function notify(title, body, { silent = true } = {}) {
147
157
  n.show()
148
158
  }
149
159
 
160
+ // Explicit reminders survive restarts and belong to the account that armed them.
161
+ const REMINDERS_FILE = path.join(DATA_DIR, 'reset-reminders.json')
162
+ const resetReminders = createReminders({
163
+ now: () => Date.now(),
164
+ load: () => JSON.parse(fs.readFileSync(REMINDERS_FILE, 'utf8')),
165
+ save: (data) => {
166
+ fs.mkdirSync(DATA_DIR, { recursive: true })
167
+ const tmp = `${REMINDERS_FILE}.tmp`
168
+ fs.writeFileSync(tmp, JSON.stringify(data))
169
+ fs.renameSync(tmp, REMINDERS_FILE)
170
+ },
171
+ })
172
+ const reminderKey = (provider) => (provider === 'codex' ? 'codex' : `claude:${accounts.activeId()}`)
173
+ function sendReminders(error = null) {
174
+ if (!win || win.isDestroyed()) return
175
+ 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,
178
+ error,
179
+ })
180
+ }
181
+ function cancelReminder(key) {
182
+ try {
183
+ resetReminders.cancel(key)
184
+ sendReminders()
185
+ return true
186
+ } catch (err) {
187
+ console.error('reset reminder:', err)
188
+ sendReminders('Could not cancel the reminder. Please try again.')
189
+ return false
190
+ }
191
+ }
192
+ function checkResetReminders() {
193
+ try {
194
+ for (const r of resetReminders.list()) {
195
+ const connected =
196
+ r.provider === 'codex'
197
+ ? config.codex?.enabled
198
+ : accounts.list().some((a) => r.key === `claude:${a.id}` && hasToken(a.id))
199
+ if (!connected && !cancelReminder(r.key)) return
200
+ }
201
+ const due = resetReminders.takeDue()
202
+ 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
206
+ const confirmed = isCurrent && readAt >= r.at && session?.pct != null && session.pct < r.pct
207
+ const message = confirmed
208
+ ? `${r.label}: session budget is available again.`
209
+ : `${r.label}: the scheduled session reset time has arrived. Open usage to confirm the new budget.`
210
+ notify(
211
+ confirmed ? 'Your session budget is back' : 'Time to check your session budget',
212
+ message,
213
+ )
214
+ if (win && !win.isDestroyed())
215
+ win.webContents.send('reminder-due', { ...r, confirmed, isCurrent })
216
+ }
217
+ if (due.length) sendReminders()
218
+ } catch (err) {
219
+ console.error('reset reminder:', err)
220
+ }
221
+ }
222
+ ipcMain.on('set-reminder', (_e, provider, enabled) => {
223
+ if (!['claude', 'codex'].includes(provider) || typeof enabled !== 'boolean') return
224
+ const key = reminderKey(provider)
225
+ 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()
231
+ if (
232
+ !connected ||
233
+ !freshAt ||
234
+ Date.now() - freshAt > 15 * 60000 ||
235
+ !Number.isFinite(session?.pct)
236
+ ) {
237
+ return sendReminders('Wait for a fresh usage reading before setting a reminder.')
238
+ }
239
+ try {
240
+ const label = cx ? 'Codex' : `Claude · ${accounts.active().label || 'your account'}`
241
+ if (!resetReminders.arm({ key, provider, label, at, pct: session.pct })) {
242
+ return sendReminders('This reset time has passed. Wait for a fresh reading.')
243
+ }
244
+ sendReminders()
245
+ } catch {
246
+ sendReminders('Could not save the reminder. Please try again.')
247
+ }
248
+ })
249
+
150
250
  // the alert lines carry the reset, since "what do I do about it" is a question
151
251
  // about time left, not about the threshold that happened to fire
152
252
  function resetLine(resetMs, withRemaining) {
@@ -226,7 +326,14 @@ function checkWindowReset(config, pct) {
226
326
  if (pct == null) return
227
327
  const was = lastSessionPct
228
328
  lastSessionPct = pct
229
- if (config.alerts && was != null && was >= RESET_FROM && pct <= RESET_TO) {
329
+ if (
330
+ config.alerts &&
331
+ was != null &&
332
+ was >= RESET_FROM &&
333
+ pct <= RESET_TO &&
334
+ !resetReminders.list().some((r) => r.key === reminderKey('claude')) &&
335
+ !resetReminders.recentlyDelivered(reminderKey('claude'))
336
+ ) {
230
337
  notify('Session window reset', 'full budget again')
231
338
  }
232
339
  if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
@@ -253,6 +360,7 @@ function applyAccount(acc) {
253
360
  lastSessionPct = null
254
361
  lastSeenTokens = null // the new account's first read is a baseline, not a spend
255
362
  loadAlertState()
363
+ sendReminders()
256
364
  }
257
365
 
258
366
  // `connected` is read off disk rather than through auth, which only ever knows
@@ -340,7 +448,10 @@ ipcMain.on('accounts-cancel-add', () => {
340
448
  switchAccount(from) // the empty slot is pruned on the way out
341
449
  })
342
450
  ipcMain.on('accounts-remove', (_e, id) => {
343
- if (accounts.remove(String(id))) sendAccounts()
451
+ if (accounts.remove(String(id))) {
452
+ cancelReminder(`claude:${id}`)
453
+ sendAccounts()
454
+ }
344
455
  })
345
456
 
346
457
  function createWindow() {
@@ -379,6 +490,9 @@ function createWindow() {
379
490
  preload: path.join(__dirname, 'preload.js'),
380
491
  contextIsolation: true,
381
492
  nodeIntegration: false,
493
+ // the pet's blips start from a usage change, never from a click, so
494
+ // Chromium would otherwise hold the AudioContext suspended
495
+ autoplayPolicy: 'no-user-gesture-required',
382
496
  },
383
497
  })
384
498
 
@@ -429,12 +543,14 @@ function createWindow() {
429
543
  try {
430
544
  const c = config.codex?.enabled ? codex.getCodexUsage() : null
431
545
  codexUsage = c
546
+ codexReadAt = Date.now()
432
547
  win.webContents.send('codex', c)
433
548
  checkCodexAlerts(config, c)
434
549
  updateTray()
435
550
  } catch (err) {
436
551
  console.error('codex:', err)
437
552
  }
553
+ checkResetReminders()
438
554
  }
439
555
  doTick = tick
440
556
 
@@ -442,6 +558,7 @@ function createWindow() {
442
558
  tick()
443
559
  win.webContents.send('config', publicConfig(config))
444
560
  win.webContents.send('version', app.getVersion())
561
+ sendReminders()
445
562
  win.webContents.send('auth-state', { connected: auth.isConnected() })
446
563
  sendAccounts()
447
564
  pollTimer = setInterval(tick, config.pollIntervalMs)
@@ -650,12 +767,38 @@ function updateTray() {
650
767
  // send real usage to the renderer and refresh the tray title in one place
651
768
  function pushRealUsage(u) {
652
769
  realUsage = u
770
+ realUsageAt = u ? Date.now() : 0
653
771
  sessionPct = u?.session ? u.session.pct : null
654
772
  updateTray()
655
773
  checkScopedAlerts(config, u)
656
774
  if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
657
775
  }
658
776
 
777
+ // Native drag regions swallow renderer pointer events. Poll only in pet-only
778
+ // mode so its hover still works while the sprite remains a native drag handle.
779
+ ipcMain.on('pet-pointer-watch', (_e, enabled) => {
780
+ if (typeof enabled !== 'boolean') return
781
+ clearInterval(petPointerTimer)
782
+ petPointerTimer = null
783
+ if (!enabled) return
784
+ let last // only a moved cursor crosses IPC, not ten idle messages a second
785
+ petPointerTimer = setInterval(() => {
786
+ if (!win || win.isDestroyed()) return
787
+ let point = null
788
+ if (win.isVisible()) {
789
+ const cursor = screen.getCursorScreenPoint()
790
+ const bounds = win.getContentBounds()
791
+ const x = cursor.x - bounds.x
792
+ const y = cursor.y - bounds.y
793
+ if (x >= 0 && y >= 0 && x < bounds.width && y < bounds.height) point = { x, y }
794
+ }
795
+ const key = point ? `${point.x},${point.y}` : ''
796
+ if (key === last) return
797
+ last = key
798
+ win.webContents.send('pet-pointer', point)
799
+ }, 100)
800
+ })
801
+
659
802
  // resize the window to fit the content
660
803
  ipcMain.on('resize', (_e, w, h) => {
661
804
  if (!win || win.isDestroyed()) return
@@ -733,9 +876,11 @@ function scheduleUsagePoll() {
733
876
  if (auth.isConnected()) usageTimer = setTimeout(pollUsage, usageBackoff)
734
877
  }
735
878
  async function pollUsage() {
879
+ const accountId = accounts.activeId()
736
880
  lastPollAt = Date.now()
737
881
  try {
738
882
  const u = await auth.fetchUsage()
883
+ if (accountId !== accounts.activeId()) return
739
884
  usageBackoff = 5 * 60 * 1000
740
885
  authFails = 0
741
886
  pushRealUsage(u)
@@ -747,6 +892,7 @@ async function pollUsage() {
747
892
  sendProfile()
748
893
  }
749
894
  } catch (e) {
895
+ if (accountId !== accounts.activeId()) return
750
896
  if (e && e.status === 429) {
751
897
  usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
752
898
  } else if (e && e.status === 401) {
@@ -861,6 +1007,7 @@ ipcMain.on('auth-code', async (_e, code) => {
861
1007
  }
862
1008
  })
863
1009
  ipcMain.on('auth-logout', () => {
1010
+ cancelReminder(reminderKey('claude'))
864
1011
  auth.clear()
865
1012
  clearTimeout(usageTimer)
866
1013
  pushRealUsage(null)
@@ -876,7 +1023,10 @@ ipcMain.on('auth-logout', () => {
876
1023
  function setCodexEnabled(on) {
877
1024
  writeConfigPatch({ codex: { ...(config.codex || {}), enabled: on } })
878
1025
  config = loadConfig()
879
- if (!on) codexUsage = null
1026
+ if (!on) {
1027
+ codexUsage = null
1028
+ cancelReminder('codex')
1029
+ }
880
1030
  if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
881
1031
  if (doTick) doTick()
882
1032
  updateTray()
@@ -1020,6 +1170,7 @@ app.whenReady().then(() => {
1020
1170
 
1021
1171
  app.on('window-all-closed', () => {
1022
1172
  if (pollTimer) clearInterval(pollTimer)
1173
+ clearInterval(petPointerTimer)
1023
1174
  fs.unwatchFile(DEBUG_FILE)
1024
1175
  destroyTray()
1025
1176
  app.quit()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.24.0",
4
+ "version": "1.26.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
+ "reminders.js",
18
19
  "config.json",
19
20
  "renderer"
20
21
  ],
@@ -75,6 +76,7 @@
75
76
  "auth.js",
76
77
  "accounts.js",
77
78
  "codex.js",
79
+ "reminders.js",
78
80
  "config.json",
79
81
  "renderer/**",
80
82
  "build/trayTemplate.png",
package/preload.js CHANGED
@@ -1,6 +1,11 @@
1
1
  const { contextBridge, ipcRenderer } = require('electron')
2
2
 
3
3
  contextBridge.exposeInMainWorld('api', {
4
+ watchPetPointer: (enabled) => ipcRenderer.send('pet-pointer-watch', enabled),
5
+ onPetPointer: (cb) => ipcRenderer.on('pet-pointer', (_e, point) => cb(point)),
6
+ setReminder: (provider, enabled) => ipcRenderer.send('set-reminder', provider, enabled),
7
+ onReminders: (cb) => ipcRenderer.on('reminders', (_e, state) => cb(state)),
8
+ onReminderDue: (cb) => ipcRenderer.on('reminder-due', (_e, event) => cb(event)),
4
9
  onUsage: (cb) => ipcRenderer.on('usage', (_e, data) => cb(data)),
5
10
  onError: (cb) => ipcRenderer.on('usage-error', (_e, msg) => cb(msg)),
6
11
  onConfig: (cb) => ipcRenderer.on('config', (_e, cfg) => cb(cfg)),
package/reminders.js ADDED
@@ -0,0 +1,53 @@
1
+ // One-shot reset reminders, independent of automatic threshold alerts.
2
+ function createReminders({ load, save, now = Date.now }) {
3
+ let pending = []
4
+ let delivered = {}
5
+ const valid = (r) =>
6
+ r &&
7
+ ['claude', 'codex'].includes(r.provider) &&
8
+ typeof r.key === 'string' &&
9
+ typeof r.label === 'string' &&
10
+ Number.isFinite(r.at) &&
11
+ Number.isFinite(r.pct)
12
+ try {
13
+ const data = load()
14
+ pending = Array.isArray(data?.pending) ? data.pending.filter(valid) : []
15
+ delivered = data?.delivered || {}
16
+ } catch {}
17
+ function persist(nextPending, nextDelivered = delivered) {
18
+ const recent = Object.fromEntries(
19
+ Object.entries(nextDelivered).filter(([, at]) => Number.isFinite(at) && now() - at < 3600000),
20
+ )
21
+ save({ pending: nextPending, delivered: recent })
22
+ pending = nextPending
23
+ delivered = recent
24
+ }
25
+ return {
26
+ list: () => pending.map((r) => ({ ...r })),
27
+ arm(reminder) {
28
+ if (!valid(reminder) || reminder.at <= now() || reminder.at > now() + 7 * 86400000)
29
+ return false
30
+ persist([...pending.filter((r) => r.key !== reminder.key), { ...reminder }])
31
+ return true
32
+ },
33
+ cancel(key) {
34
+ const found = pending.some((r) => r.key === key)
35
+ if (found) persist(pending.filter((r) => r.key !== key))
36
+ },
37
+ takeDue() {
38
+ const due = pending.filter((r) => r.at <= now())
39
+ if (!due.length) return []
40
+ const nextDelivered = { ...delivered }
41
+ for (const r of due) nextDelivered[r.key] = now()
42
+ // Consume before delivering: restart must not replay it.
43
+ persist(
44
+ pending.filter((r) => r.at > now()),
45
+ nextDelivered,
46
+ )
47
+ return due
48
+ },
49
+ recentlyDelivered: (key) => Number.isFinite(delivered[key]) && now() - delivered[key] < 3600000,
50
+ }
51
+ }
52
+
53
+ module.exports = { createReminders }
@@ -0,0 +1,48 @@
1
+ // A quiet transition detector. Startup and reconnects establish a baseline;
2
+ // inactivity is a pause, never proof that an agent finished its task.
3
+ ;((root) => {
4
+ function createActivityTracker({ now = Date.now, cooldown = 30000 } = {}) {
5
+ const seen = new Map()
6
+ let lastCue = -Infinity
7
+ return {
8
+ forget(provider) {
9
+ seen.delete(provider)
10
+ },
11
+ observe(provider, data) {
12
+ if (!data) {
13
+ seen.delete(provider)
14
+ return null
15
+ }
16
+ const next = { active: !!data.active, activity: data.activity || 'working' }
17
+ const previous = seen.get(provider)
18
+ seen.set(provider, next)
19
+ if (!previous) return null
20
+ const changed =
21
+ next.active !== previous.active || (next.active && next.activity !== previous.activity)
22
+ if (!changed || now() - lastCue < cooldown) return null
23
+ lastCue = now()
24
+ return { provider, activity: next.active ? next.activity : 'paused' }
25
+ },
26
+ }
27
+ }
28
+ // Activity freshness is separate from quota freshness: Codex can work while
29
+ // its last rate-limit reading is old. Ignore abandoned activity snapshots.
30
+ const FRESH_MS = 60000
31
+ const SLEEP_MS = 300000 // main's default sleepThresholdMs; Codex has no flag of its own
32
+ function currentActivity(claude, codex, now = Date.now()) {
33
+ const fresh = (at) => Number.isFinite(at) && now - at <= FRESH_MS
34
+ const providers = []
35
+ if (claude?.active && fresh(claude.ts)) providers.push('claude')
36
+ if (codex?.active && fresh(codex.lastSeen)) providers.push('codex')
37
+ // only Claude's logs say *what* it's doing; two workers share one scene
38
+ const activity = providers.join() === 'claude' ? claude.activity || 'working' : 'working'
39
+ const asleep = [
40
+ claude && !!claude.sleeping,
41
+ codex && !!codex.lastSeen && now - codex.lastSeen >= SLEEP_MS,
42
+ ].filter((v) => v !== null && v !== undefined)
43
+ return { providers, activity, sleeping: asleep.length > 0 && asleep.every(Boolean) }
44
+ }
45
+ const api = { createActivityTracker, currentActivity }
46
+ if (typeof module === 'object' && module.exports) module.exports = api
47
+ else root.Companion = api
48
+ })(globalThis)