clauddy 1.28.0 → 1.28.1

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 (4) hide show
  1. package/README.md +1 -1
  2. package/cursor.js +24 -13
  3. package/package.json +1 -1
  4. package/usage.js +97 -15
package/README.md CHANGED
@@ -286,7 +286,7 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
286
286
  "alertThresholds": [80, 95], // notify when session/week cross these % (two levels)
287
287
  "fireThreshold": 90, // session % at which the pet catches fire (maxed out stays 100)
288
288
  "pollIntervalMs": 4000, // how often local logs are re-read
289
- "activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
289
+ "activeThresholdMs": 20000, // fallback "active" window, for a log whose tail doesn't say whether a turn is open
290
290
  "sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
291
291
  "codex": { "enabled": false }, // set by Settings → Codex → Connect
292
292
  "cursor": { "enabled": false }, // set by Settings → Cursor → Connect
package/cursor.js CHANGED
@@ -8,7 +8,10 @@ const os = require('node:os')
8
8
  // comes from the agent transcripts under ~/.cursor/projects, which log each
9
9
  // tool call much like Claude Code's own logs.
10
10
  const API = 'https://api2.cursor.sh/aiserver.v1.DashboardService/'
11
- const ACTIVE_MS = 60000 // a transcript touched within the last minute = Cursor working
11
+ const ACTIVE_MS = 60000 // a transcript that says nothing counts only while it's this fresh
12
+ // an unfinished turn this long without a write was abandoned (Cursor closed mid-run)
13
+ const OPEN_TURN_MS = 30 * 60000
14
+ const RECENT_TRANSCRIPTS = 8
12
15
  const FETCH_MS = 5 * 60000 // a monthly budget moves slowly; don't hammer the API
13
16
  const RETRY_MS = 60000
14
17
  const TAIL_BYTES = 64 * 1024
@@ -169,7 +172,8 @@ const ACTIVITY = {
169
172
  AskQuestion: 'waiting',
170
173
  }
171
174
 
172
- // what the newest transcript lines say: a finished turn means nothing is running
175
+ // what the newest transcript lines say: a finished turn means nothing is
176
+ // running (null), an open one names its scene, and undefined means no telling
173
177
  function activityOf(lines) {
174
178
  for (let i = lines.length - 1; i >= 0; i--) {
175
179
  let o
@@ -186,7 +190,7 @@ function activityOf(lines) {
186
190
  }
187
191
  if (o.role === 'user') return 'working' // just asked: the agent is thinking
188
192
  }
189
- return 'working'
193
+ return undefined
190
194
  }
191
195
 
192
196
  const dirs = (dir) => {
@@ -232,22 +236,29 @@ function tailLines(file, size) {
232
236
  return lines.filter(Boolean)
233
237
  }
234
238
 
239
+ // A turn stays open while a long command runs or the model thinks, without a
240
+ // write, so an open turn in any recent transcript is Cursor at work. A
241
+ // transcript with nothing to say counts only while it's fresh.
235
242
  function readActivity(now) {
236
- let latest = null
243
+ const recent = []
244
+ let lastSeen = null
237
245
  for (const file of transcripts()) {
238
246
  try {
239
247
  const st = fs.statSync(file)
240
- if (!latest || st.mtimeMs > latest.st.mtimeMs) latest = { file, st }
248
+ if (lastSeen == null || st.mtimeMs > lastSeen) lastSeen = st.mtimeMs
249
+ if (now - st.mtimeMs < OPEN_TURN_MS) recent.push({ file, st })
241
250
  } catch {}
242
251
  }
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 }
252
+ recent.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
253
+ for (const { file, st } of recent.slice(0, RECENT_TRANSCRIPTS)) {
254
+ let said
255
+ try {
256
+ said = activityOf(tailLines(file, st.size))
257
+ } catch {}
258
+ const activity = said === undefined ? (now - st.mtimeMs < ACTIVE_MS ? 'working' : null) : said
259
+ if (activity) return { active: true, activity, lastSeen }
260
+ }
261
+ return { active: false, activity: null, lastSeen }
251
262
  }
252
263
 
253
264
  // The poll's view of Cursor: activity from disk every tick, limits from the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.28.0",
4
+ "version": "1.28.1",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
package/usage.js CHANGED
@@ -224,23 +224,30 @@ const ACTIVITY_BY_TOOL = {
224
224
  AskUserQuestion: 'waiting',
225
225
  }
226
226
 
227
- // Classify what Claude Code is doing right now by reading the tail of the most
228
- // recently touched session log: the latest tool_use → an activity, plan mode
229
- // (permission-mode) wins over everything. Returns null when there's no signal.
230
- function detectActivity(file) {
231
- let raw
227
+ // the last lines of a session log (the recent tail is plenty), or null
228
+ function tailLines(file) {
232
229
  try {
233
230
  const fd = fs.openSync(file, 'r')
234
- const size = fs.fstatSync(fd).size
235
- const len = Math.min(size, 65536) // last 64KB is plenty for the recent tail
236
- const buf = Buffer.alloc(len)
237
- fs.readSync(fd, buf, 0, len, size - len)
238
- fs.closeSync(fd)
239
- raw = buf.toString('utf8')
231
+ try {
232
+ const size = fs.fstatSync(fd).size
233
+ const len = Math.min(size, 65536)
234
+ const buf = Buffer.alloc(len)
235
+ fs.readSync(fd, buf, 0, len, size - len)
236
+ return buf.toString('utf8').split('\n').filter(Boolean)
237
+ } finally {
238
+ fs.closeSync(fd)
239
+ }
240
240
  } catch {
241
241
  return null
242
242
  }
243
- const lines = raw.split('\n').filter(Boolean)
243
+ }
244
+
245
+ // Classify what Claude Code is doing right now by reading the tail of the most
246
+ // recently touched session log: the latest tool_use → an activity, plan mode
247
+ // (permission-mode) wins over everything. Returns null when there's no signal.
248
+ function detectActivity(file) {
249
+ const lines = tailLines(file)
250
+ if (!lines) return null
244
251
  let activity = null
245
252
  let permMode = null
246
253
  for (let i = lines.length - 1; i >= 0 && i >= lines.length - 80; i--) {
@@ -265,6 +272,60 @@ function detectActivity(file) {
265
272
  return activity
266
273
  }
267
274
 
275
+ // User lines that open no turn: slash commands and their output, `!` shell
276
+ // input, background-task notices, the compaction summary, meta attachments
277
+ const NO_TURN =
278
+ /^\s*(<command-name>|<command-message>|<local-command-|<bash-|<task-notification>|This session is being continued)/
279
+ const DONE_STOPS = new Set(['end_turn', 'stop_sequence', 'refusal'])
280
+ const textOf = (content) =>
281
+ typeof content === 'string'
282
+ ? content
283
+ : Array.isArray(content)
284
+ ? content.map((b) => (b && b.type === 'text' ? b.text : '')).join(' ')
285
+ : ''
286
+
287
+ // Is a turn still open? Claude Code writes nothing while a command runs or the
288
+ // model thinks, so a quiet log proves nothing; how it ends does. A final answer
289
+ // (and the turn_duration line after it) or an interruption closes the turn; a
290
+ // tool call, a tool result or a prompt leaves it open. Returns true, false, or
291
+ // null when the tail doesn't say.
292
+ function turnOpen(lines) {
293
+ for (let i = lines.length - 1; i >= 0; i--) {
294
+ let o
295
+ try {
296
+ o = JSON.parse(lines[i])
297
+ } catch {
298
+ continue
299
+ }
300
+ if (o.isSidechain) continue
301
+ if (o.type === 'system') {
302
+ if (o.subtype === 'turn_duration') return false
303
+ continue
304
+ }
305
+ if (o.type === 'assistant') {
306
+ const stop = o.message?.stop_reason
307
+ if (!stop) continue
308
+ return !DONE_STOPS.has(stop)
309
+ }
310
+ if (o.type === 'user') {
311
+ const content = o.message?.content
312
+ if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result')) return true
313
+ if (o.isMeta || o.isCompactSummary) continue
314
+ const text = textOf(content)
315
+ if (text.includes('[Request interrupted by user')) return false
316
+ if (NO_TURN.test(text)) continue
317
+ return true
318
+ }
319
+ }
320
+ return null
321
+ }
322
+
323
+ // a turn left open this long without a write was abandoned (a closed
324
+ // terminal, a crash) — or a permission prompt nobody answered
325
+ const OPEN_TURN_MS = 30 * 60000
326
+ // parallel sessions and subagents each keep a log; a handful covers them
327
+ const RECENT_LOGS = 8
328
+
268
329
  // Plan presets (token budgets ~= 100%). Calibrated for Max 5x from the official
269
330
  // panel (5h ~24% at 152M tokens, weekly ~62% at 2.14B); Pro/Max20x scaled by the
270
331
  // plan multiplier. ESTIMATES — Anthropic doesn't publish exact numbers.
@@ -371,11 +432,31 @@ function getUsage(config) {
371
432
  const weekPct = weeklyBudget ? Math.min(100, (weekTokens / weeklyBudget) * 100) : 0
372
433
 
373
434
  const lastActivityMs = lastMtime ? now - lastMtime : Infinity
374
- const active = lastActivityMs <= (config.activeThresholdMs || 20000)
375
- const sleeping = lastActivityMs >= (config.sleepThresholdMs || 300000)
435
+ // Working means a turn is open in one of the recent logs, even when a long
436
+ // command or a slow answer has kept it quiet for minutes. A log with nothing
437
+ // to say falls back to the old rule: written in the last few seconds.
438
+ let openFile = null
439
+ const recentLogs = files
440
+ .filter((f) => now - f.st.mtimeMs <= OPEN_TURN_MS)
441
+ .sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
442
+ .slice(0, RECENT_LOGS)
443
+ let newestSays = null
444
+ for (const [i, f] of recentLogs.entries()) {
445
+ const lines = tailLines(f.full)
446
+ const open = lines ? turnOpen(lines) : null
447
+ if (i === 0) newestSays = open
448
+ if (open) {
449
+ openFile = f.full
450
+ break
451
+ }
452
+ }
453
+ const quietRule = newestSays === null && lastActivityMs <= (config.activeThresholdMs || 20000)
454
+ const active = !!openFile || quietRule
455
+ const sleeping = !active && lastActivityMs >= (config.sleepThresholdMs || 300000)
376
456
 
377
457
  // what Claude is doing right now (only meaningful while active)
378
- const activity = active && newestFile ? detectActivity(newestFile) : null
458
+ const activeFile = openFile || (quietRule ? newestFile : null)
459
+ const activity = activeFile ? detectActivity(activeFile) || (openFile ? 'working' : null) : null
379
460
 
380
461
  const byModelArr = [...byModel.entries()]
381
462
  .map(([label, tokens]) => ({ label, tokens }))
@@ -422,5 +503,6 @@ module.exports = {
422
503
  projectLabel,
423
504
  tokensOf,
424
505
  detectActivity,
506
+ turnOpen,
425
507
  PLAN_BUDGETS,
426
508
  }