clauddy 1.29.0 → 2.0.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/usage.js DELETED
@@ -1,539 +0,0 @@
1
- const fs = require('node:fs')
2
- const path = require('node:path')
3
- const os = require('node:os')
4
-
5
- // the Claude config dir whose logs we read. mutable because the widget can
6
- // switch between accounts at runtime, each one pointing at its own dir.
7
- const DEFAULT_CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
8
- let claudeDir = DEFAULT_CLAUDE_DIR
9
- let projectsDir = path.join(claudeDir, 'projects')
10
-
11
- // point the reader at another account's logs. fileCache survives: it is keyed
12
- // by absolute path, so entries from another dir can't collide — and keeping it
13
- // is what makes switching back to an account instant instead of a full re-scan.
14
- // projectLabelCache is keyed by a directory *name*, which can repeat across
15
- // dirs, so that one goes.
16
- function setClaudeDir(dir) {
17
- // an account with no dir of its own reads the default one — which is still
18
- // CLAUDE_CONFIG_DIR when the whole instance is isolated that way
19
- const next = dir || DEFAULT_CLAUDE_DIR
20
- if (next === claudeDir) return
21
- claudeDir = next
22
- projectsDir = path.join(claudeDir, 'projects')
23
- projectLabelCache.clear()
24
- }
25
-
26
- function labelFor(model) {
27
- if (!model) return 'desconhecido'
28
- const m = model.toLowerCase()
29
- const fam = m.includes('opus')
30
- ? 'Opus'
31
- : m.includes('sonnet')
32
- ? 'Sonnet'
33
- : m.includes('haiku')
34
- ? 'Haiku'
35
- : m.includes('fable')
36
- ? 'Fable'
37
- : m.includes('mythos')
38
- ? 'Mythos'
39
- : null
40
- if (!fam) return model
41
- // version after the family: opus-5, opus-4-8, haiku-4-5-<date>
42
- let ver = m.match(/(?:opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/)
43
- // legacy ids put the version before the family: claude-3-5-sonnet
44
- if (!ver) ver = m.match(/-(\d)-(\d+)/)
45
- if (ver) return ver[2] ? `${fam} ${ver[1]}.${ver[2]}` : `${fam} ${ver[1]}`
46
- return fam
47
- }
48
-
49
- // Claude Code names each project directory after the working directory with
50
- // every `/` and `.` flattened to `-`, so `-Users-me-my-projects-clauddy` cannot
51
- // be reversed by string surgery alone: the separator and the names use the same
52
- // character. Instead of guessing, we walk the real filesystem and let it be the
53
- // dictionary — at each level we ask which actual child directory, once encoded
54
- // the same way, starts the remaining string. Longest match first, because `my`
55
- // and `my-projects` can both be candidates and only the longer one is right.
56
- const projectLabelCache = new Map()
57
-
58
- function encodePathSegment(name) {
59
- return name.replace(/[/.]/g, '-')
60
- }
61
-
62
- // Walks as far as the filesystem allows and reports where it stopped. A project
63
- // that has since been moved or deleted still resolves its surviving ancestors,
64
- // which is what keeps the fallback label readable instead of a raw blob.
65
- //
66
- // `consumed` is the remainder as it stood *before* the last successful step.
67
- // That step is the only one that can have eaten into the name of a directory
68
- // that no longer exists — matching `qulture-identity` out of the encoded
69
- // `qulture-identity-web` leaves a bare `web`, which reads like a real project
70
- // and is not one. When the walk ends short, the caller rewinds to `consumed`
71
- // and shows a longer label rather than a confidently wrong one.
72
- function resolveProjectPath(dirName) {
73
- let cur = path.sep
74
- let rest = dirName
75
- let consumed = null
76
- while (rest) {
77
- if (!rest.startsWith('-')) break
78
- const tail = rest.slice(1)
79
- let kids
80
- try {
81
- kids = fs.readdirSync(cur, { withFileTypes: true })
82
- } catch {
83
- break
84
- }
85
- let match = null
86
- for (const k of kids) {
87
- // symlinks count: /var is one on macOS, and people do symlink project trees
88
- if (!k.isDirectory() && !k.isSymbolicLink()) continue
89
- const enc = encodePathSegment(k.name)
90
- // `my` and `my-projects` can both start the remainder — the longer one wins
91
- if (tail !== enc && !tail.startsWith(`${enc}-`)) continue
92
- if (!match || enc.length > encodePathSegment(match).length) match = k.name
93
- }
94
- if (!match) break
95
- consumed = tail
96
- cur = path.join(cur, match)
97
- rest = tail.slice(encodePathSegment(match).length)
98
- }
99
- return { full: rest ? null : cur, consumed }
100
- }
101
-
102
- const LABEL_MAX = 26
103
-
104
- // The readable name for a project directory: its basename when the path still
105
- // exists, otherwise the raw tail. A moved or deleted project is shown as-is
106
- // rather than guessed at — a wrong name is worse than an ugly one.
107
- function projectLabel(dirName) {
108
- const hit = projectLabelCache.get(dirName)
109
- if (hit !== undefined) return hit
110
- const { full, consumed } = resolveProjectPath(dirName)
111
- let label
112
- if (full) {
113
- label = path.basename(full) || full
114
- } else {
115
- // whatever the filesystem could not account for, shown as-is
116
- label = (consumed || dirName).replace(/^-+/, '')
117
- if (label.length > LABEL_MAX) label = `…${label.slice(-(LABEL_MAX - 1))}`
118
- }
119
- projectLabelCache.set(dirName, label)
120
- return label
121
- }
122
-
123
- function projectDirOf(file) {
124
- const rel = path.relative(projectsDir, file)
125
- const first = rel.split(path.sep)[0]
126
- return first && first !== '..' ? first : null
127
- }
128
-
129
- function tokensOf(entry) {
130
- const u = entry.usage || {}
131
- return (
132
- (u.input_tokens || 0) +
133
- (u.output_tokens || 0) +
134
- (u.cache_read_input_tokens || 0) +
135
- (u.cache_creation_input_tokens || 0)
136
- )
137
- }
138
-
139
- const fileCache = new Map()
140
-
141
- function walkJsonl(dir, out, cutoffMs) {
142
- let items
143
- try {
144
- items = fs.readdirSync(dir, { withFileTypes: true })
145
- } catch {
146
- return
147
- }
148
- for (const it of items) {
149
- const full = path.join(dir, it.name)
150
- if (it.isDirectory()) {
151
- walkJsonl(full, out, cutoffMs)
152
- } else if (it.isFile() && it.name.endsWith('.jsonl')) {
153
- let st
154
- try {
155
- st = fs.statSync(full)
156
- } catch {
157
- continue
158
- }
159
- if (st.mtimeMs < cutoffMs) continue
160
- out.push({ full, st })
161
- }
162
- }
163
- }
164
-
165
- function ingestLine(entries, line) {
166
- // most lines are tool results and prompts: skip them without a parse
167
- if (!line.includes('"usage"')) return
168
- let obj
169
- try {
170
- obj = JSON.parse(line)
171
- } catch {
172
- return
173
- }
174
- if (obj.type !== 'assistant' || !obj.message || !obj.message.usage) return
175
- if (obj.message.model === '<synthetic>') return // Claude Code internal messages
176
- const ts = Date.parse(obj.timestamp || obj.message?.timestamp || 0)
177
- if (!ts) return
178
- entries.push({
179
- ts,
180
- model: obj.message.model,
181
- tokens: tokensOf({ usage: obj.message.usage }),
182
- key: `${obj.message.id || ''}:${obj.requestId || obj.uuid || ''}`,
183
- })
184
- }
185
-
186
- const CHUNK = 8 * 1024 * 1024
187
-
188
- // Session logs only ever grow, and a long one passes 500 MB: a poll reads just
189
- // the bytes appended since the last one, in chunks, so the main process never
190
- // stalls on a full re-read. A line still being written waits in `rest`.
191
- function parseFile(full, st) {
192
- let cached = fileCache.get(full)
193
- // shrunk or replaced: start over
194
- if (!cached || st.size < cached.offset) {
195
- cached = { offset: 0, rest: Buffer.alloc(0), entries: [] }
196
- fileCache.set(full, cached)
197
- }
198
- if (st.size <= cached.offset) return cached.entries
199
- let fd
200
- try {
201
- fd = fs.openSync(full, 'r')
202
- } catch {
203
- return cached.entries
204
- }
205
- try {
206
- while (cached.offset < st.size) {
207
- const chunk = Buffer.alloc(Math.min(CHUNK, st.size - cached.offset))
208
- const n = fs.readSync(fd, chunk, 0, chunk.length, cached.offset)
209
- if (n <= 0) break
210
- cached.offset += n
211
- const buf = Buffer.concat([cached.rest, chunk.subarray(0, n)])
212
- // cut at the last newline in bytes, so a split UTF-8 character survives
213
- const end = buf.lastIndexOf(0x0a)
214
- if (end < 0) {
215
- cached.rest = buf
216
- continue
217
- }
218
- cached.rest = Buffer.from(buf.subarray(end + 1))
219
- for (const line of buf.toString('utf8', 0, end).split('\n')) {
220
- if (line) ingestLine(cached.entries, line)
221
- }
222
- }
223
- } catch {
224
- // unreadable mid-way: keep what we have, retry from the offset next poll
225
- } finally {
226
- fs.closeSync(fd)
227
- }
228
- return cached.entries
229
- }
230
-
231
- const DAYS = 30
232
- // how many projects the panel ranks before folding the rest into `other`
233
- const TOP_PROJECTS = 5
234
- const SESSION_MS = 5 * 3600 * 1000
235
-
236
- // map a Claude Code tool name to what the pet is "doing"
237
- const ACTIVITY_BY_TOOL = {
238
- Edit: 'editing',
239
- MultiEdit: 'editing',
240
- Write: 'editing',
241
- NotebookEdit: 'editing',
242
- Read: 'reading',
243
- Grep: 'reading',
244
- Glob: 'reading',
245
- LS: 'reading',
246
- Bash: 'running',
247
- BashOutput: 'running',
248
- KillShell: 'running',
249
- WebSearch: 'researching',
250
- WebFetch: 'researching',
251
- Task: 'delegating',
252
- Agent: 'delegating',
253
- TodoWrite: 'planning',
254
- ExitPlanMode: 'planning',
255
- AskUserQuestion: 'waiting',
256
- }
257
-
258
- // the last lines of a session log (the recent tail is plenty), or null
259
- function tailLines(file) {
260
- try {
261
- const fd = fs.openSync(file, 'r')
262
- try {
263
- const size = fs.fstatSync(fd).size
264
- const len = Math.min(size, 65536)
265
- const buf = Buffer.alloc(len)
266
- fs.readSync(fd, buf, 0, len, size - len)
267
- return buf.toString('utf8').split('\n').filter(Boolean)
268
- } finally {
269
- fs.closeSync(fd)
270
- }
271
- } catch {
272
- return null
273
- }
274
- }
275
-
276
- // Classify what Claude Code is doing right now by reading the tail of the most
277
- // recently touched session log: the latest tool_use → an activity, plan mode
278
- // (permission-mode) wins over everything. Returns null when there's no signal.
279
- function detectActivity(file) {
280
- const lines = tailLines(file)
281
- if (!lines) return null
282
- let activity = null
283
- let permMode = null
284
- for (let i = lines.length - 1; i >= 0 && i >= lines.length - 80; i--) {
285
- let o
286
- try {
287
- o = JSON.parse(lines[i])
288
- } catch {
289
- continue
290
- }
291
- if (permMode === null && (o.type === 'permission-mode' || o.type === 'mode')) {
292
- const v = o.mode || o.permissionMode || o.value
293
- if (typeof v === 'string') permMode = v
294
- }
295
- if (activity === null && o.type === 'assistant' && Array.isArray(o.message?.content)) {
296
- const blocks = o.message.content
297
- const tools = blocks.filter((b) => b && b.type === 'tool_use')
298
- if (tools.length) activity = ACTIVITY_BY_TOOL[tools[tools.length - 1].name] || 'working'
299
- }
300
- if (activity !== null && permMode !== null) break
301
- }
302
- if (permMode === 'plan') return 'planning'
303
- return activity
304
- }
305
-
306
- // User lines that open no turn: slash commands and their output, `!` shell
307
- // input, background-task notices, the compaction summary, meta attachments
308
- const NO_TURN =
309
- /^\s*(<command-name>|<command-message>|<local-command-|<bash-|<task-notification>|This session is being continued)/
310
- const DONE_STOPS = new Set(['end_turn', 'stop_sequence', 'refusal'])
311
- const textOf = (content) =>
312
- typeof content === 'string'
313
- ? content
314
- : Array.isArray(content)
315
- ? content.map((b) => (b && b.type === 'text' ? b.text : '')).join(' ')
316
- : ''
317
-
318
- // Is a turn still open? Claude Code writes nothing while a command runs or the
319
- // model thinks, so a quiet log proves nothing; how it ends does. A final answer
320
- // (and the turn_duration line after it) or an interruption closes the turn; a
321
- // tool call, a tool result or a prompt leaves it open. Returns true, false, or
322
- // null when the tail doesn't say.
323
- function turnOpen(lines) {
324
- for (let i = lines.length - 1; i >= 0; i--) {
325
- let o
326
- try {
327
- o = JSON.parse(lines[i])
328
- } catch {
329
- continue
330
- }
331
- if (o.isSidechain) continue
332
- if (o.type === 'system') {
333
- if (o.subtype === 'turn_duration') return false
334
- continue
335
- }
336
- if (o.type === 'assistant') {
337
- const stop = o.message?.stop_reason
338
- if (!stop) continue
339
- return !DONE_STOPS.has(stop)
340
- }
341
- if (o.type === 'user') {
342
- const content = o.message?.content
343
- if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result')) return true
344
- if (o.isMeta || o.isCompactSummary) continue
345
- const text = textOf(content)
346
- if (text.includes('[Request interrupted by user')) return false
347
- if (NO_TURN.test(text)) continue
348
- return true
349
- }
350
- }
351
- return null
352
- }
353
-
354
- // a turn left open this long without a write was abandoned (a closed
355
- // terminal, a crash) — or a permission prompt nobody answered
356
- const OPEN_TURN_MS = 30 * 60000
357
- // parallel sessions and subagents each keep a log; a handful covers them
358
- const RECENT_LOGS = 8
359
-
360
- // Plan presets (token budgets ~= 100%). Calibrated for Max 5x from the official
361
- // panel (5h ~24% at 152M tokens, weekly ~62% at 2.14B); Pro/Max20x scaled by the
362
- // plan multiplier. ESTIMATES — Anthropic doesn't publish exact numbers.
363
- const PLAN_BUDGETS = {
364
- pro: { session: 126e6, week: 690e6 },
365
- max5x: { session: 630e6, week: 3450e6 },
366
- max20x: { session: 2520e6, week: 13800e6 },
367
- }
368
-
369
- function getUsage(config) {
370
- const now = Date.now()
371
- const dayMs = 24 * 3600 * 1000
372
- const startOfToday = new Date()
373
- startOfToday.setHours(0, 0, 0, 0)
374
- const todayMs = startOfToday.getTime()
375
- let weekStart = now - 7 * dayMs
376
- let weekResetMs = null
377
- if (config.weeklyAnchorIso) {
378
- const anchor = Date.parse(config.weeklyAnchorIso)
379
- if (!Number.isNaN(anchor)) {
380
- const period = 7 * dayMs
381
- const lastReset = anchor + Math.floor((now - anchor) / period) * period
382
- weekStart = lastReset
383
- weekResetMs = lastReset + period - now
384
- }
385
- }
386
- const start30 = todayMs - (DAYS - 1) * dayMs
387
- const fiveMinAgo = now - 5 * 60000
388
- const recentCutoff = now - 12 * 3600 * 1000
389
- const scanCutoff = start30 - dayMs
390
-
391
- const files = []
392
- walkJsonl(projectsDir, files, scanCutoff)
393
-
394
- let lastMtime = 0
395
- let newestFile = null
396
- for (const f of files) {
397
- if (f.st.mtimeMs > lastMtime) {
398
- lastMtime = f.st.mtimeMs
399
- newestFile = f.full
400
- }
401
- }
402
-
403
- const seen = new Set()
404
- let todayTokens = 0
405
- let weekTokens = 0
406
- let monthTokens = 0
407
- const byModel = new Map() // tokens per model, 7 days
408
- const byProject = new Map() // tokens per project, 7 days
409
- const days30 = new Array(DAYS).fill(0) // tokens per day
410
- const recent = [] // last 12h, to detect the 5h session
411
- let last5mTokens = 0
412
-
413
- for (const f of files) {
414
- const entries = parseFile(f.full, f.st)
415
- const dir = projectDirOf(f.full)
416
- const proj = dir ? projectLabel(dir) : null
417
- for (const e of entries) {
418
- if (e.ts < start30) continue
419
- if (e.key && e.key !== ':' && seen.has(e.key)) continue
420
- if (e.key && e.key !== ':') seen.add(e.key)
421
- const t = e.tokens
422
- monthTokens += t
423
-
424
- const dayIdx = Math.min(DAYS - 1, Math.max(0, Math.floor((e.ts - start30) / dayMs)))
425
- days30[dayIdx] += t
426
-
427
- if (e.ts >= weekStart) {
428
- weekTokens += t
429
- const lbl = labelFor(e.model)
430
- byModel.set(lbl, (byModel.get(lbl) || 0) + t)
431
- if (proj) byProject.set(proj, (byProject.get(proj) || 0) + t)
432
- }
433
- if (e.ts >= todayMs) todayTokens += t
434
- if (e.ts >= recentCutoff) recent.push({ ts: e.ts, tokens: t })
435
- if (e.ts >= fiveMinAgo) last5mTokens += t
436
- }
437
- }
438
-
439
- // 5h session window (like /usage "Current session")
440
- recent.sort((a, b) => a.ts - b.ts)
441
- let sStart = null
442
- let sEnd = null
443
- for (const e of recent) {
444
- if (sStart === null || e.ts >= sEnd) {
445
- sStart = e.ts
446
- sEnd = sStart + SESSION_MS
447
- }
448
- }
449
- const planB = PLAN_BUDGETS[config.plan]
450
- const sessionBudget = planB ? planB.session : config.sessionTokenBudget
451
- const weeklyBudget = planB ? planB.week : config.weeklyTokenBudget
452
-
453
- const session = { tokens: 0, pct: 0, resetMs: 0, active: false }
454
- if (sStart !== null && now < sEnd) {
455
- let tk = 0
456
- for (const e of recent) if (e.ts >= sStart && e.ts < sEnd) tk += e.tokens
457
- session.tokens = tk
458
- session.active = true
459
- session.resetMs = sEnd - now
460
- session.pct = sessionBudget ? Math.min(100, (tk / sessionBudget) * 100) : 0
461
- }
462
-
463
- const weekPct = weeklyBudget ? Math.min(100, (weekTokens / weeklyBudget) * 100) : 0
464
-
465
- const lastActivityMs = lastMtime ? now - lastMtime : Infinity
466
- // Working means a turn is open in one of the recent logs, even when a long
467
- // command or a slow answer has kept it quiet for minutes. A log with nothing
468
- // to say falls back to the old rule: written in the last few seconds.
469
- let openFile = null
470
- const recentLogs = files
471
- .filter((f) => now - f.st.mtimeMs <= OPEN_TURN_MS)
472
- .sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
473
- .slice(0, RECENT_LOGS)
474
- let newestSays = null
475
- for (const [i, f] of recentLogs.entries()) {
476
- const lines = tailLines(f.full)
477
- const open = lines ? turnOpen(lines) : null
478
- if (i === 0) newestSays = open
479
- if (open) {
480
- openFile = f.full
481
- break
482
- }
483
- }
484
- const quietRule = newestSays === null && lastActivityMs <= (config.activeThresholdMs || 20000)
485
- const active = !!openFile || quietRule
486
- const sleeping = !active && lastActivityMs >= (config.sleepThresholdMs || 300000)
487
-
488
- // what Claude is doing right now (only meaningful while active)
489
- const activeFile = openFile || (quietRule ? newestFile : null)
490
- const activity = activeFile ? detectActivity(activeFile) || (openFile ? 'working' : null) : null
491
-
492
- const byModelArr = [...byModel.entries()]
493
- .map(([label, tokens]) => ({ label, tokens }))
494
- .sort((a, b) => b.tokens - a.tokens)
495
-
496
- // ranked, then everything past the top N folded into one row — a machine with
497
- // 40 project directories must not turn the panel into a 40-row list.
498
- const ranked = [...byProject.entries()]
499
- .map(([label, tokens]) => ({ label, tokens }))
500
- .sort((a, b) => b.tokens - a.tokens)
501
- const byProjectArr = ranked.slice(0, TOP_PROJECTS)
502
- const rest = ranked.slice(TOP_PROJECTS)
503
- if (rest.length) {
504
- byProjectArr.push({
505
- label: `other · ${rest.length}`,
506
- tokens: rest.reduce((n, p) => n + p.tokens, 0),
507
- })
508
- }
509
-
510
- return {
511
- session,
512
- week: { tokens: weekTokens, pct: weekPct, resetMs: weekResetMs },
513
- today: { tokens: todayTokens },
514
- byModel: byModelArr,
515
- byProject: byProjectArr,
516
- days30,
517
- monthTokens,
518
- tokensPerMin: Math.round(last5mTokens / 5),
519
- active,
520
- sleeping,
521
- activity,
522
- lastActivityMs,
523
- ts: now,
524
- }
525
- }
526
-
527
- // labelFor/tokensOf/detectActivity are exported for the tests — they're the
528
- // parts that decode Claude Code's log format, which is the thing most likely
529
- // to change out from under us.
530
- module.exports = {
531
- getUsage,
532
- setClaudeDir,
533
- labelFor,
534
- projectLabel,
535
- tokensOf,
536
- detectActivity,
537
- turnOpen,
538
- PLAN_BUDGETS,
539
- }