claude-mission-control 1.5.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/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/claude-dashboard.js +5 -0
- package/claude-dashboard.service +11 -0
- package/com.claude-dashboard.plist +18 -0
- package/config.example.json +4 -0
- package/ignore.example.json +3 -0
- package/install.ps1 +46 -0
- package/install.sh +66 -0
- package/lib/collector.js +530 -0
- package/lib/config.js +203 -0
- package/lib/detail.js +134 -0
- package/lib/gitstatus.js +84 -0
- package/lib/history.js +103 -0
- package/lib/ignore.js +42 -0
- package/lib/names.js +54 -0
- package/lib/notify.js +62 -0
- package/lib/opener.js +104 -0
- package/lib/paths.js +39 -0
- package/lib/plan.js +58 -0
- package/lib/pricing.js +72 -0
- package/lib/quota.js +43 -0
- package/lib/registry.js +69 -0
- package/lib/search.js +170 -0
- package/lib/sessions.js +61 -0
- package/lib/tasks.js +54 -0
- package/lib/transcript-view.js +117 -0
- package/lib/transcripts.js +390 -0
- package/lib/usage.js +116 -0
- package/menubar/claude-dash.15s.sh +82 -0
- package/names.example.json +4 -0
- package/package.json +38 -0
- package/public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/public/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/Oswald-Variable.woff2 +0 -0
- package/public/icon.svg +6 -0
- package/public/index.html +2045 -0
- package/public/manifest.webmanifest +12 -0
- package/server.js +397 -0
- package/uninstall.ps1 +8 -0
- package/uninstall.sh +18 -0
package/lib/collector.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Merges every source into one in-memory state object and notifies on change.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { readLiveSessions } = require('./sessions');
|
|
5
|
+
const { readTasks } = require('./tasks');
|
|
6
|
+
const { readQuota } = require('./quota');
|
|
7
|
+
const { fetchOauthUsage } = require('./usage');
|
|
8
|
+
const { readRegistry } = require('./registry');
|
|
9
|
+
const { refreshHistory, activityBuckets, weekHourHeat } = require('./history');
|
|
10
|
+
const { scanAllTranscripts, groupByProject, sessionTitle, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, dayKey } = require('./transcripts');
|
|
11
|
+
const { collectGitStatus } = require('./gitstatus');
|
|
12
|
+
const { friendlyName } = require('./names');
|
|
13
|
+
const { isIgnored } = require('./ignore');
|
|
14
|
+
const { worktreeRoot } = require('./paths');
|
|
15
|
+
const { sendNotification, newlyWaiting, isProjectMuted } = require('./notify');
|
|
16
|
+
const { readConfig } = require('./config');
|
|
17
|
+
const { estimateCost, budgetLevel, typicalWait } = require('./pricing');
|
|
18
|
+
const { readPlan } = require('./plan');
|
|
19
|
+
|
|
20
|
+
// A busy session whose transcript hasn't grown for this long may be stalled.
|
|
21
|
+
const QUIET_FLAG_MS = 10 * 60 * 1000;
|
|
22
|
+
const QUIET_NOTIFY_MS = 20 * 60 * 1000;
|
|
23
|
+
const SPEND_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
const MISSING_GRACE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
26
|
+
const SESSIONS_PER_PROJECT = 5;
|
|
27
|
+
|
|
28
|
+
const CADENCE = {
|
|
29
|
+
active: { live: 2_000, scan: 15_000, git: 30_000, quota: 60_000 },
|
|
30
|
+
idle: { live: 15_000, scan: 60_000, git: 300_000, quota: 300_000 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
class Collector {
|
|
34
|
+
constructor() {
|
|
35
|
+
this.state = { generatedAt: 0, quota: null, liveSessions: [], projects: [], errors: [] };
|
|
36
|
+
this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null };
|
|
37
|
+
this.listeners = new Set();
|
|
38
|
+
this.clientCount = 0;
|
|
39
|
+
this.timers = [];
|
|
40
|
+
this.fingerprint = '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onChange(fn) {
|
|
44
|
+
this.listeners.add(fn);
|
|
45
|
+
return () => this.listeners.delete(fn);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setClientCount(n) {
|
|
49
|
+
const wasIdle = this.clientCount === 0;
|
|
50
|
+
this.clientCount = n;
|
|
51
|
+
if (wasIdle && n > 0) this.kick(); // wake from idle cadence immediately
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
cadence() {
|
|
55
|
+
return this.clientCount > 0 ? CADENCE.active : CADENCE.idle;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async start() {
|
|
59
|
+
await Promise.all([this.refreshLive(), this.refreshQuota(), this.refreshScan()]);
|
|
60
|
+
await this.refreshGit(); // needs project list from scan
|
|
61
|
+
this.assemble();
|
|
62
|
+
this.loop('live', () => this.refreshLive());
|
|
63
|
+
this.loop('scan', () => this.refreshScan());
|
|
64
|
+
this.loop('git', () => this.refreshGit());
|
|
65
|
+
this.loop('quota', () => this.refreshQuota());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
loop(name, fn) {
|
|
69
|
+
const tick = async () => {
|
|
70
|
+
try {
|
|
71
|
+
await fn();
|
|
72
|
+
this.assemble();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
this.noteError(name, e);
|
|
75
|
+
}
|
|
76
|
+
const t = setTimeout(tick, this.cadence()[name]);
|
|
77
|
+
t.unref();
|
|
78
|
+
this.timers.push(t);
|
|
79
|
+
};
|
|
80
|
+
const t = setTimeout(tick, this.cadence()[name]);
|
|
81
|
+
t.unref();
|
|
82
|
+
this.timers.push(t);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
kick() {
|
|
86
|
+
// On first client connect, refresh the fast-moving data right away.
|
|
87
|
+
Promise.all([this.refreshLive(), this.refreshQuota()])
|
|
88
|
+
.then(() => this.assemble())
|
|
89
|
+
.catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async refreshLive() {
|
|
93
|
+
const live = await readLiveSessions();
|
|
94
|
+
for (const s of live) {
|
|
95
|
+
const t = await readTasks(s.sessionId);
|
|
96
|
+
s.currentTask = t ? t.currentTask : null;
|
|
97
|
+
s.tasksSummary = t ? t.tasksSummary : null;
|
|
98
|
+
}
|
|
99
|
+
this.raw.live = live;
|
|
100
|
+
this.notifyTransitions(live);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
notifyTransitions(live) {
|
|
104
|
+
const next = new Map(live.map((s) => [s.sessionId, s.status]));
|
|
105
|
+
const prev = this.prevLiveStatus ?? null;
|
|
106
|
+
for (const id of newlyWaiting(prev, next)) {
|
|
107
|
+
const s = live.find((x) => x.sessionId === id);
|
|
108
|
+
const root = worktreeRoot(s.cwd).root;
|
|
109
|
+
if (isProjectMuted(root, readConfig().mutedProjects)) continue;
|
|
110
|
+
const project = friendlyName(root);
|
|
111
|
+
sendNotification({
|
|
112
|
+
title: `${project} needs you`,
|
|
113
|
+
body: s.waitingFor || 'Claude is waiting for your input',
|
|
114
|
+
sound: 'Glass',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
this.prevLiveStatus = next;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async refreshScan() {
|
|
121
|
+
const sessions = await scanAllTranscripts();
|
|
122
|
+
this.raw.transcriptGroups = groupByProject(sessions);
|
|
123
|
+
this.raw.history = await refreshHistory();
|
|
124
|
+
// Task summaries for recent sessions (readTasks caches by dir mtime).
|
|
125
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
126
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
127
|
+
for (const m of g.sessions) {
|
|
128
|
+
if (m.lastActivityAt >= cutoff) {
|
|
129
|
+
const t = await readTasks(m.sessionId);
|
|
130
|
+
m.tasksSummary = t ? t.tasksSummary : null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async refreshGit() {
|
|
137
|
+
const paths = this.projectPaths().filter((p) => existsDir(p));
|
|
138
|
+
this.raw.git = await collectGitStatus(paths);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async refreshQuota() {
|
|
142
|
+
// Live OAuth usage first (Keychain-authed, macOS); statusline cache as
|
|
143
|
+
// the fallback for other platforms or when the toggle is off.
|
|
144
|
+
this.raw.quota = (await fetchOauthUsage()) || (await readQuota());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
projectPaths() {
|
|
148
|
+
const set = new Map(); // lowercase -> display path
|
|
149
|
+
for (const [key, g] of this.raw.transcriptGroups) set.set(key, g.path);
|
|
150
|
+
for (const [key, e] of readRegistry()) if (!set.has(key)) set.set(key, e.path);
|
|
151
|
+
for (const [key, e] of this.raw.history) if (!set.has(key)) set.set(key, e.path);
|
|
152
|
+
for (const s of this.raw.live) {
|
|
153
|
+
const { root } = worktreeRoot(s.cwd);
|
|
154
|
+
if (!set.has(root.toLowerCase())) set.set(root.toLowerCase(), root);
|
|
155
|
+
}
|
|
156
|
+
return [...set.values()].filter((p) => !isIgnored(p));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
assemble() {
|
|
160
|
+
const registry = readRegistry();
|
|
161
|
+
const liveByProject = new Set(
|
|
162
|
+
this.raw.live.map((s) => worktreeRoot(s.cwd).root.toLowerCase())
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const projects = [];
|
|
166
|
+
for (const p of this.projectPaths()) {
|
|
167
|
+
const key = p.toLowerCase();
|
|
168
|
+
const group = this.raw.transcriptGroups.get(key);
|
|
169
|
+
const hist = this.raw.history.get(key);
|
|
170
|
+
const reg = registry.get(key);
|
|
171
|
+
const git = this.raw.git.get(p) || null;
|
|
172
|
+
|
|
173
|
+
const sessionMetas = group ? group.sessions : [];
|
|
174
|
+
const lastActivityAt = Math.max(
|
|
175
|
+
sessionMetas[0] ? sessionMetas[0].lastActivityAt : 0,
|
|
176
|
+
hist ? hist.lastTimestamp : 0,
|
|
177
|
+
reg ? reg.lastSessionModified || 0 : 0
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const missing = !existsDir(p);
|
|
181
|
+
if (missing && (!lastActivityAt || Date.now() - lastActivityAt > MISSING_GRACE_MS)) continue;
|
|
182
|
+
if (!sessionMetas.length && !hist && !lastActivityAt) continue;
|
|
183
|
+
|
|
184
|
+
projects.push({
|
|
185
|
+
path: p,
|
|
186
|
+
name: friendlyName(p),
|
|
187
|
+
missing,
|
|
188
|
+
lastActivityAt: lastActivityAt || null,
|
|
189
|
+
isLive: liveByProject.has(key),
|
|
190
|
+
git,
|
|
191
|
+
spend7d: sessionMetas.reduce(
|
|
192
|
+
(sum, m) =>
|
|
193
|
+
Date.now() - m.lastActivityAt < SPEND_WINDOW_MS ? sum + estimateCost(combinedUsage(m)) : sum,
|
|
194
|
+
0
|
|
195
|
+
),
|
|
196
|
+
sessions: sessionMetas.slice(0, SESSIONS_PER_PROJECT).map((m) => {
|
|
197
|
+
const { title, source } = sessionTitle(m);
|
|
198
|
+
return {
|
|
199
|
+
sessionId: m.sessionId,
|
|
200
|
+
estCost: estimateCost(combinedUsage(m)),
|
|
201
|
+
model: m.model,
|
|
202
|
+
title,
|
|
203
|
+
titleSource: source,
|
|
204
|
+
lastPrompt: m.lastPrompt,
|
|
205
|
+
awaySummary: m.awaySummary,
|
|
206
|
+
awaySummaryAt: m.awaySummaryAt || null,
|
|
207
|
+
tasksSummary: m.tasksSummary || null,
|
|
208
|
+
startedAt: m.startedAt,
|
|
209
|
+
lastActivityAt: m.lastActivityAt,
|
|
210
|
+
worktree: m.worktree,
|
|
211
|
+
resumeCommand: resumeCommand(m.cwd, m.sessionId),
|
|
212
|
+
};
|
|
213
|
+
}),
|
|
214
|
+
activity: { days: 14, counts: activityBuckets(hist ? hist.timestamps : []) },
|
|
215
|
+
stats: {
|
|
216
|
+
sessionCount: sessionMetas.length,
|
|
217
|
+
promptCount: hist ? hist.promptCount : 0,
|
|
218
|
+
lastCost: reg && typeof reg.lastCost === 'number' ? reg.lastCost : null,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
projects.sort((a, b) => Number(b.isLive) - Number(a.isLive) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
223
|
+
|
|
224
|
+
const titleBySession = new Map();
|
|
225
|
+
const modelBySession = new Map();
|
|
226
|
+
const subsBySession = new Map();
|
|
227
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
228
|
+
for (const m of g.sessions) {
|
|
229
|
+
titleBySession.set(m.sessionId, sessionTitle(m).title);
|
|
230
|
+
if (m.model) modelBySession.set(m.sessionId, m.model);
|
|
231
|
+
const subs = subagentSummary(m);
|
|
232
|
+
if (subs) subsBySession.set(m.sessionId, subs);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Transcript freshness per session, for stuck detection.
|
|
237
|
+
const activityBySession = new Map();
|
|
238
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
239
|
+
for (const m of g.sessions) activityBySession.set(m.sessionId, m.lastActivityAt);
|
|
240
|
+
}
|
|
241
|
+
this.stuckNotified = this.stuckNotified || new Set();
|
|
242
|
+
|
|
243
|
+
const liveSessions = this.raw.live.map((s) => {
|
|
244
|
+
const { root, worktree } = worktreeRoot(s.cwd);
|
|
245
|
+
// "Quiet" = busy but the transcript hasn't grown. Long quiet spells
|
|
246
|
+
// usually mean the session is stalled (weak signal: it may just be
|
|
247
|
+
// waiting on slow background work).
|
|
248
|
+
let quietMin = null; // whole minutes so the state fingerprint stays stable
|
|
249
|
+
if (s.status === 'busy') {
|
|
250
|
+
const lastWrite = Math.max(
|
|
251
|
+
activityBySession.get(s.sessionId) || 0,
|
|
252
|
+
s.statusUpdatedAt || 0
|
|
253
|
+
);
|
|
254
|
+
if (lastWrite) {
|
|
255
|
+
const q = Date.now() - lastWrite;
|
|
256
|
+
if (q >= QUIET_FLAG_MS) quietMin = Math.floor(q / 60000);
|
|
257
|
+
if (q >= QUIET_NOTIFY_MS && !this.stuckNotified.has(s.sessionId)) {
|
|
258
|
+
this.stuckNotified.add(s.sessionId);
|
|
259
|
+
if (!isProjectMuted(root, readConfig().mutedProjects)) sendNotification({
|
|
260
|
+
title: `${friendlyName(root)} may be stuck`,
|
|
261
|
+
body: `Busy with no output for ${quietMin} minutes — worth a look.`,
|
|
262
|
+
sound: 'Basso',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (quietMin === null) this.stuckNotified.delete(s.sessionId);
|
|
268
|
+
return {
|
|
269
|
+
quietMin,
|
|
270
|
+
pid: s.pid,
|
|
271
|
+
sessionId: s.sessionId,
|
|
272
|
+
cwd: s.cwd,
|
|
273
|
+
projectPath: root,
|
|
274
|
+
projectName: friendlyName(root),
|
|
275
|
+
isWorktree: worktree !== null,
|
|
276
|
+
name: s.name,
|
|
277
|
+
model: modelBySession.get(s.sessionId) || null,
|
|
278
|
+
title: titleBySession.get(s.sessionId) || s.name,
|
|
279
|
+
status: s.status,
|
|
280
|
+
waitingFor: s.waitingFor,
|
|
281
|
+
startedAt: s.startedAt,
|
|
282
|
+
statusUpdatedAt: s.statusUpdatedAt,
|
|
283
|
+
currentTask: s.currentTask,
|
|
284
|
+
tasksSummary: s.tasksSummary,
|
|
285
|
+
subagents: subsBySession.get(s.sessionId) || null,
|
|
286
|
+
resumeCommand: resumeCommand(s.cwd, s.sessionId),
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Pinned sessions, newest first, resolved to display data.
|
|
291
|
+
const pinnedIds = new Set(readConfig().pinnedSessions || []);
|
|
292
|
+
const pinned = [];
|
|
293
|
+
if (pinnedIds.size) {
|
|
294
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
295
|
+
for (const m of g.sessions) {
|
|
296
|
+
if (pinnedIds.has(m.sessionId)) {
|
|
297
|
+
pinned.push({
|
|
298
|
+
sessionId: m.sessionId,
|
|
299
|
+
title: sessionTitle(m).title,
|
|
300
|
+
projectName: friendlyName(g.path),
|
|
301
|
+
model: m.model || null,
|
|
302
|
+
lastActivityAt: m.lastActivityAt,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
pinned.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Trailing 8 weeks of estimated cost, oldest bucket first. Whole sessions
|
|
311
|
+
// land in the bucket of their last activity — an approximation, but the
|
|
312
|
+
// trend is what matters. Rounded to cents so the fingerprint stays stable.
|
|
313
|
+
const DAY = 86400000;
|
|
314
|
+
const midnight = new Date();
|
|
315
|
+
midnight.setHours(0, 0, 0, 0);
|
|
316
|
+
const windowEnd = midnight.getTime() + DAY;
|
|
317
|
+
const weeklyCost = new Array(8).fill(0);
|
|
318
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
319
|
+
if (isIgnored(g.path)) continue;
|
|
320
|
+
for (const m of g.sessions) {
|
|
321
|
+
const idx = 7 - Math.floor((windowEnd - 1 - m.lastActivityAt) / (7 * DAY));
|
|
322
|
+
if (idx >= 0 && idx < 8) weeklyCost[idx] += estimateCost(combinedUsage(m));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (let i = 0; i < 8; i++) weeklyCost[i] = Math.round(weeklyCost[i] * 100) / 100;
|
|
326
|
+
|
|
327
|
+
// Weekly budget: compare the trailing-7-day estimate, alert once per
|
|
328
|
+
// threshold crossing (75 / 90 / 100%), re-arm when spend drops back.
|
|
329
|
+
const budgetLimit = readConfig().weeklyBudget || 0;
|
|
330
|
+
const level = budgetLevel(weeklyCost[7], budgetLimit);
|
|
331
|
+
if (level > (this.budgetAlerted || 0)) {
|
|
332
|
+
this.budgetAlerted = level;
|
|
333
|
+
sendNotification({
|
|
334
|
+
title: 'Claude weekly budget',
|
|
335
|
+
body: `7-day estimated spend ≈$${weeklyCost[7].toFixed(0)} — ${level >= 100 ? 'over' : `${level}% of`} your $${budgetLimit} budget.`,
|
|
336
|
+
sound: 'Basso',
|
|
337
|
+
});
|
|
338
|
+
} else if (level < (this.budgetAlerted || 0)) {
|
|
339
|
+
this.budgetAlerted = level;
|
|
340
|
+
}
|
|
341
|
+
const budget = budgetLimit ? { limit: budgetLimit, spent: weeklyCost[7], level } : null;
|
|
342
|
+
|
|
343
|
+
const next = { quota: this.raw.quota, plan: readPlan(), liveSessions, projects, weeklyCost, budget, pinned, errors: this.state.errors.slice(-5) };
|
|
344
|
+
const fp = JSON.stringify(next);
|
|
345
|
+
if (fp !== this.fingerprint) {
|
|
346
|
+
this.fingerprint = fp;
|
|
347
|
+
this.state = { generatedAt: Date.now(), ...next };
|
|
348
|
+
for (const fn of this.listeners) fn(this.state);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Every session for one project, uncapped — for the detail view.
|
|
353
|
+
allSessions(projectPath) {
|
|
354
|
+
const g = this.raw.transcriptGroups.get(projectPath.toLowerCase());
|
|
355
|
+
if (!g) return [];
|
|
356
|
+
return g.sessions.map((m) => {
|
|
357
|
+
const { title, source } = sessionTitle(m);
|
|
358
|
+
return {
|
|
359
|
+
sessionId: m.sessionId,
|
|
360
|
+
title,
|
|
361
|
+
titleSource: source,
|
|
362
|
+
estCost: estimateCost(combinedUsage(m)),
|
|
363
|
+
awaySummary: m.awaySummary,
|
|
364
|
+
startedAt: m.startedAt,
|
|
365
|
+
lastActivityAt: m.lastActivityAt,
|
|
366
|
+
worktree: m.worktree,
|
|
367
|
+
resumeCommand: resumeCommand(m.cwd, m.sessionId),
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Aggregates for the stats view: daily activity, hour histogram,
|
|
373
|
+
// per-model usage, totals. Ignored projects excluded throughout.
|
|
374
|
+
statsSummary() {
|
|
375
|
+
const DAY = 86400000;
|
|
376
|
+
const perDay = new Map();
|
|
377
|
+
const hours = new Array(24).fill(0);
|
|
378
|
+
const allTimestamps = [];
|
|
379
|
+
let promptTotal = 0;
|
|
380
|
+
const weekCut = Date.now() - 7 * 86400000;
|
|
381
|
+
let weekPrompts = 0;
|
|
382
|
+
const weekDayNames = new Map(); // 'Monday' -> count, last 7 days
|
|
383
|
+
const weekHours = new Array(24).fill(0);
|
|
384
|
+
for (const e of this.raw.history.values()) {
|
|
385
|
+
if (isIgnored(e.path)) continue;
|
|
386
|
+
promptTotal += e.promptCount;
|
|
387
|
+
allTimestamps.push(...e.timestamps);
|
|
388
|
+
for (const t of e.timestamps) {
|
|
389
|
+
const d = new Date(t);
|
|
390
|
+
d.setHours(0, 0, 0, 0);
|
|
391
|
+
perDay.set(d.getTime(), (perDay.get(d.getTime()) || 0) + 1);
|
|
392
|
+
hours[new Date(t).getHours()]++;
|
|
393
|
+
if (t >= weekCut) {
|
|
394
|
+
weekPrompts++;
|
|
395
|
+
const name = new Date(t).toLocaleDateString(undefined, { weekday: 'long' });
|
|
396
|
+
weekDayNames.set(name, (weekDayNames.get(name) || 0) + 1);
|
|
397
|
+
weekHours[new Date(t).getHours()]++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const days = [];
|
|
402
|
+
const today = new Date();
|
|
403
|
+
today.setHours(0, 0, 0, 0);
|
|
404
|
+
for (let i = 181; i >= 0; i--) {
|
|
405
|
+
const t = today.getTime() - i * DAY;
|
|
406
|
+
days.push({ t, c: perDay.get(t) || 0 });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const byModel = {};
|
|
410
|
+
const perProject = [];
|
|
411
|
+
const sessionDays = [];
|
|
412
|
+
let sessionTotal = 0;
|
|
413
|
+
let costTotal = 0;
|
|
414
|
+
const weekDayKey = dayKey(weekCut);
|
|
415
|
+
let weekSessions = 0;
|
|
416
|
+
let weekCost = 0;
|
|
417
|
+
const weekProjects = new Map();
|
|
418
|
+
const weekModels = new Set();
|
|
419
|
+
const weekWaits = [0, 0, 0, 0];
|
|
420
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
421
|
+
if (isIgnored(g.path)) continue;
|
|
422
|
+
sessionTotal += g.sessions.length;
|
|
423
|
+
const projModels = {};
|
|
424
|
+
for (const m of g.sessions) {
|
|
425
|
+
const mDays = combinedDays(m);
|
|
426
|
+
sessionDays.push(mDays);
|
|
427
|
+
if (m.lastActivityAt >= weekCut) {
|
|
428
|
+
weekSessions++;
|
|
429
|
+
if (m.waits) for (let i = 0; i < 4; i++) weekWaits[i] += m.waits[i];
|
|
430
|
+
let mWeekCost = 0;
|
|
431
|
+
for (const [day, byM] of Object.entries(mDays)) {
|
|
432
|
+
if (day >= weekDayKey) {
|
|
433
|
+
mWeekCost += estimateCost(byM);
|
|
434
|
+
for (const model of Object.keys(byM)) weekModels.add(model);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
weekCost += mWeekCost;
|
|
438
|
+
const pn = friendlyName(g.path);
|
|
439
|
+
weekProjects.set(pn, (weekProjects.get(pn) || 0) + mWeekCost);
|
|
440
|
+
}
|
|
441
|
+
for (const [model, u] of Object.entries(combinedUsage(m))) {
|
|
442
|
+
const e = byModel[model] || (byModel[model] = { tokens: 0, cost: 0 });
|
|
443
|
+
const cost = estimateCost({ [model]: u });
|
|
444
|
+
e.tokens += (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheCreation || 0);
|
|
445
|
+
e.cost += cost;
|
|
446
|
+
projModels[model] = (projModels[model] || 0) + cost;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const projTotal = Object.values(projModels).reduce((a, b) => a + b, 0);
|
|
450
|
+
if (projTotal >= 0.01) {
|
|
451
|
+
perProject.push({ name: friendlyName(g.path), total: projTotal, models: projModels });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
perProject.sort((a, b) => b.total - a.total);
|
|
455
|
+
for (const e of Object.values(byModel)) costTotal += e.cost;
|
|
456
|
+
return {
|
|
457
|
+
days,
|
|
458
|
+
hours,
|
|
459
|
+
heat: weekHourHeat(allTimestamps),
|
|
460
|
+
costDays: dailyCostSeries(sessionDays, 90),
|
|
461
|
+
week: {
|
|
462
|
+
sessions: weekSessions,
|
|
463
|
+
prompts: weekPrompts,
|
|
464
|
+
cost: Math.round(weekCost * 100) / 100,
|
|
465
|
+
projects: [...weekProjects.entries()]
|
|
466
|
+
.filter(([, c]) => c >= 0.01)
|
|
467
|
+
.sort((a, b) => b[1] - a[1])
|
|
468
|
+
.slice(0, 5)
|
|
469
|
+
.map(([name, cost]) => ({ name, cost: Math.round(cost * 100) / 100 })),
|
|
470
|
+
models: [...weekModels],
|
|
471
|
+
busiestDay: [...weekDayNames.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n)[0] || null,
|
|
472
|
+
busiestHour: weekHours.some((c) => c) ? weekHours.indexOf(Math.max(...weekHours)) : null,
|
|
473
|
+
waits: weekWaits,
|
|
474
|
+
typicalWait: typicalWait(weekWaits),
|
|
475
|
+
},
|
|
476
|
+
byModel,
|
|
477
|
+
perProject: perProject.slice(0, 15),
|
|
478
|
+
totals: { sessions: sessionTotal, prompts: promptTotal, cost: Math.round(costTotal * 100) / 100 },
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Transcript file + title for one session, for the transcript viewer.
|
|
483
|
+
findSessionFile(sessionId) {
|
|
484
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
485
|
+
for (const m of g.sessions) {
|
|
486
|
+
if (m.sessionId === sessionId) {
|
|
487
|
+
return { file: m.file, title: sessionTitle(m).title, projectName: friendlyName(g.path) };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Real cwd for a session (worktree sessions differ from their project path).
|
|
495
|
+
findSession(sessionId) {
|
|
496
|
+
for (const s of this.raw.live) {
|
|
497
|
+
if (s.sessionId === sessionId) return { cwd: s.cwd, live: true };
|
|
498
|
+
}
|
|
499
|
+
for (const g of this.raw.transcriptGroups.values()) {
|
|
500
|
+
for (const m of g.sessions) {
|
|
501
|
+
if (m.sessionId === sessionId) return { cwd: m.cwd, live: false };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
noteError(source, err) {
|
|
508
|
+
const msg = `${source}: ${String(err && err.message ? err.message : err).slice(0, 200)}`;
|
|
509
|
+
this.state.errors.push({ at: Date.now(), message: msg });
|
|
510
|
+
if (this.state.errors.length > 20) this.state.errors = this.state.errors.slice(-20);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function resumeCommand(cwd, sessionId) {
|
|
515
|
+
if (process.platform === 'win32') {
|
|
516
|
+
return `cd /d "${cwd}" && claude --resume ${sessionId}`;
|
|
517
|
+
}
|
|
518
|
+
const safe = String(cwd).replace(/'/g, `'\\''`);
|
|
519
|
+
return `cd '${safe}' && claude --resume ${sessionId}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function existsDir(p) {
|
|
523
|
+
try {
|
|
524
|
+
return fs.statSync(p).isDirectory();
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
module.exports = { Collector };
|