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 +46 -1
- package/bin/clauddy.js +12 -2
- package/main.js +154 -3
- package/package.json +3 -1
- package/preload.js +5 -0
- package/reminders.js +53 -0
- package/renderer/companion.js +48 -0
- package/renderer/index.html +37 -3
- package/renderer/pet.js +549 -12
- package/renderer/style.css +533 -31
- package/renderer/voice.js +243 -0
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 (
|
|
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)))
|
|
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)
|
|
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.
|
|
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)
|