clauddy 1.29.1 → 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/README.md +10 -9
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -345
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
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
|
-
}
|