pulse-for-claude-code 0.1.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 +292 -0
- package/bin/cli.js +169 -0
- package/bin/export.js +64 -0
- package/hooks/notify-hook.js +109 -0
- package/hooks/permission-hook.js +177 -0
- package/hooks/stop-hook.js +83 -0
- package/package.json +36 -0
- package/public/app.js +1024 -0
- package/public/assets/ClaudeCourchevel.png +0 -0
- package/public/assets/ClaudeCourchevelWork.png +0 -0
- package/public/assets/ClaudeGarage.png +0 -0
- package/public/assets/ClaudeGarageWork.png +0 -0
- package/public/assets/ClaudeOffice.png +0 -0
- package/public/assets/ClaudeOfficeWork.png +0 -0
- package/public/assets/ClaudeParis.png +0 -0
- package/public/assets/ClaudeParisWork.png +0 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +216 -0
- package/public/manifest.webmanifest +11 -0
- package/public/style.css +577 -0
- package/src/approvals.js +77 -0
- package/src/config.js +83 -0
- package/src/daemon.js +148 -0
- package/src/engine.js +573 -0
- package/src/hooksetup.js +60 -0
- package/src/notify.js +56 -0
- package/src/ntfy.js +82 -0
- package/src/phonepage.js +81 -0
- package/src/search.js +45 -0
- package/src/server.js +322 -0
- package/src/snapshots.js +33 -0
- package/src/transcript.js +206 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { priceFor } = require('./config');
|
|
7
|
+
|
|
8
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
9
|
+
|
|
10
|
+
// Per-file parse cache so unchanged sessions are never re-read.
|
|
11
|
+
// path -> { mtimeMs, size, data }
|
|
12
|
+
const fileCache = new Map();
|
|
13
|
+
|
|
14
|
+
function listJsonl() {
|
|
15
|
+
const out = [];
|
|
16
|
+
let dirs;
|
|
17
|
+
try {
|
|
18
|
+
dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
|
|
19
|
+
} catch (e) {
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
for (const d of dirs) {
|
|
23
|
+
if (!d.isDirectory()) continue;
|
|
24
|
+
const p = path.join(PROJECTS_DIR, d.name);
|
|
25
|
+
let files;
|
|
26
|
+
try { files = fs.readdirSync(p); } catch (e) { continue; }
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
if (f.endsWith('.jsonl')) out.push(path.join(p, f));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function projectName(cwd) {
|
|
35
|
+
if (!cwd) return 'unknown';
|
|
36
|
+
return path.basename(cwd) || cwd;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toolHint(input) {
|
|
40
|
+
if (!input || typeof input !== 'object') return '';
|
|
41
|
+
const h = input.file_path || input.command || input.pattern ||
|
|
42
|
+
input.description || input.url || input.path || input.query || '';
|
|
43
|
+
return String(h).replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emptyFileData() {
|
|
47
|
+
return { tokens: [], tools: [], sessions: {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function blankSession(sid) {
|
|
51
|
+
return {
|
|
52
|
+
sid,
|
|
53
|
+
title: null,
|
|
54
|
+
lastPrompt: null,
|
|
55
|
+
cwd: null,
|
|
56
|
+
project: null,
|
|
57
|
+
model: null,
|
|
58
|
+
firstT: null,
|
|
59
|
+
lastT: null,
|
|
60
|
+
userMsgs: 0,
|
|
61
|
+
assistantMsgs: 0,
|
|
62
|
+
toolCalls: 0,
|
|
63
|
+
errors: 0,
|
|
64
|
+
promptTimes: [],
|
|
65
|
+
lastStopReason: null,
|
|
66
|
+
lastAssistantT: null,
|
|
67
|
+
lastWasError: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseFile(fp) {
|
|
72
|
+
const data = emptyFileData();
|
|
73
|
+
let raw;
|
|
74
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch (e) { return data; }
|
|
75
|
+
|
|
76
|
+
const lines = raw.split('\n');
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (!line) continue;
|
|
79
|
+
let o;
|
|
80
|
+
try { o = JSON.parse(line); } catch (e) { continue; }
|
|
81
|
+
|
|
82
|
+
const type = o.type;
|
|
83
|
+
const sid = o.sessionId;
|
|
84
|
+
const tsMs = o.timestamp ? Date.parse(o.timestamp) : null;
|
|
85
|
+
|
|
86
|
+
let s = null;
|
|
87
|
+
if (sid) {
|
|
88
|
+
if (!data.sessions[sid]) data.sessions[sid] = blankSession(sid);
|
|
89
|
+
s = data.sessions[sid];
|
|
90
|
+
if (tsMs) {
|
|
91
|
+
if (!s.firstT || tsMs < s.firstT) s.firstT = tsMs;
|
|
92
|
+
if (!s.lastT || tsMs > s.lastT) s.lastT = tsMs;
|
|
93
|
+
}
|
|
94
|
+
if (o.cwd) { s.cwd = o.cwd; s.project = projectName(o.cwd); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (type === 'ai-title' && s) s.title = o.aiTitle;
|
|
98
|
+
if (type === 'last-prompt' && s) s.lastPrompt = o.lastPrompt;
|
|
99
|
+
if (type === 'user' && s && !o.isSidechain) {
|
|
100
|
+
s.userMsgs++;
|
|
101
|
+
if (o.promptSource === 'typed' && tsMs) s.promptTimes.push(tsMs);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (type === 'assistant') {
|
|
105
|
+
const msg = o.message || {};
|
|
106
|
+
if (s) {
|
|
107
|
+
s.assistantMsgs++;
|
|
108
|
+
if (msg.model && msg.model !== '<synthetic>') s.model = msg.model;
|
|
109
|
+
if (o.isApiErrorMessage) s.errors++;
|
|
110
|
+
if (tsMs && (!s.lastAssistantT || tsMs > s.lastAssistantT)) {
|
|
111
|
+
s.lastAssistantT = tsMs;
|
|
112
|
+
s.lastStopReason = msg.stop_reason || null;
|
|
113
|
+
s.lastWasError = !!(o.isApiErrorMessage || o.apiErrorStatus);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const u = msg.usage;
|
|
117
|
+
if (u && tsMs) {
|
|
118
|
+
data.tokens.push({
|
|
119
|
+
t: tsMs,
|
|
120
|
+
sid: sid || null,
|
|
121
|
+
model: msg.model || 'unknown',
|
|
122
|
+
inp: u.input_tokens || 0,
|
|
123
|
+
cwr: u.cache_creation_input_tokens || 0,
|
|
124
|
+
crd: u.cache_read_input_tokens || 0,
|
|
125
|
+
out: u.output_tokens || 0,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
129
|
+
for (const c of content) {
|
|
130
|
+
if (c && c.type === 'tool_use') {
|
|
131
|
+
if (s) s.toolCalls++;
|
|
132
|
+
data.tools.push({ t: tsMs, sid: sid || null, name: c.name, hint: toolHint(c.input) });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return data;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getFileData(fp) {
|
|
141
|
+
let st;
|
|
142
|
+
try { st = fs.statSync(fp); } catch (e) { return null; }
|
|
143
|
+
const cached = fileCache.get(fp);
|
|
144
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.data;
|
|
145
|
+
const data = parseFile(fp);
|
|
146
|
+
fileCache.set(fp, { mtimeMs: st.mtimeMs, size: st.size, data });
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- aggregation helpers ----
|
|
151
|
+
|
|
152
|
+
function entryTokens(e) {
|
|
153
|
+
return e.inp + e.cwr + e.crd + e.out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function entryCost(e, pricing) {
|
|
157
|
+
const p = priceFor(e.model, pricing);
|
|
158
|
+
return (e.inp * p.in + e.out * p.out + e.cwr * p.cacheWrite + e.crd * p.cacheRead) / 1e6;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function startOfLocalDay(now) {
|
|
162
|
+
const d = new Date(now);
|
|
163
|
+
d.setHours(0, 0, 0, 0);
|
|
164
|
+
return d.getTime();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function startOfLocalWeek(now) {
|
|
168
|
+
const d = new Date(now);
|
|
169
|
+
const day = (d.getDay() + 6) % 7; // Monday = 0
|
|
170
|
+
d.setDate(d.getDate() - day);
|
|
171
|
+
d.setHours(0, 0, 0, 0);
|
|
172
|
+
return d.getTime();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function newBucket() {
|
|
176
|
+
return { tokens: 0, cost: 0, inp: 0, out: 0, cwr: 0, crd: 0, count: 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function addToBucket(b, e, pricing) {
|
|
180
|
+
b.tokens += entryTokens(e);
|
|
181
|
+
b.cost += entryCost(e, pricing);
|
|
182
|
+
b.inp += e.inp; b.out += e.out; b.cwr += e.cwr; b.crd += e.crd;
|
|
183
|
+
b.count++;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scan(config, nowMs) {
|
|
187
|
+
const now = nowMs || Date.now();
|
|
188
|
+
const pricing = config.pricing;
|
|
189
|
+
|
|
190
|
+
const files = listJsonl();
|
|
191
|
+
const allTokens = [];
|
|
192
|
+
const allTools = [];
|
|
193
|
+
const sessions = {};
|
|
194
|
+
|
|
195
|
+
for (const fp of files) {
|
|
196
|
+
const d = getFileData(fp);
|
|
197
|
+
if (!d) continue;
|
|
198
|
+
for (const e of d.tokens) allTokens.push(e);
|
|
199
|
+
for (const t of d.tools) allTools.push(t);
|
|
200
|
+
for (const sid of Object.keys(d.sessions)) {
|
|
201
|
+
const fs0 = d.sessions[sid];
|
|
202
|
+
const cur = sessions[sid];
|
|
203
|
+
if (!cur) { sessions[sid] = Object.assign({}, fs0); continue; }
|
|
204
|
+
// merge a session that spans multiple files
|
|
205
|
+
cur.firstT = Math.min(cur.firstT || fs0.firstT, fs0.firstT || cur.firstT);
|
|
206
|
+
cur.lastT = Math.max(cur.lastT || fs0.lastT, fs0.lastT || cur.lastT);
|
|
207
|
+
cur.userMsgs += fs0.userMsgs;
|
|
208
|
+
cur.assistantMsgs += fs0.assistantMsgs;
|
|
209
|
+
cur.toolCalls += fs0.toolCalls;
|
|
210
|
+
cur.errors += fs0.errors;
|
|
211
|
+
cur.title = fs0.title || cur.title;
|
|
212
|
+
cur.lastPrompt = fs0.lastPrompt || cur.lastPrompt;
|
|
213
|
+
cur.cwd = fs0.cwd || cur.cwd;
|
|
214
|
+
cur.project = fs0.project || cur.project;
|
|
215
|
+
cur.model = fs0.model || cur.model;
|
|
216
|
+
cur.promptTimes = (cur.promptTimes || []).concat(fs0.promptTimes || []);
|
|
217
|
+
if ((fs0.lastAssistantT || 0) > (cur.lastAssistantT || 0)) {
|
|
218
|
+
cur.lastAssistantT = fs0.lastAssistantT;
|
|
219
|
+
cur.lastStopReason = fs0.lastStopReason;
|
|
220
|
+
cur.lastWasError = fs0.lastWasError;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const dayStart = startOfLocalDay(now);
|
|
226
|
+
const weekStart = startOfLocalWeek(now);
|
|
227
|
+
const hourStart = now - 3600 * 1000;
|
|
228
|
+
const fiveHourStart = now - 5 * 3600 * 1000;
|
|
229
|
+
|
|
230
|
+
const windows = {
|
|
231
|
+
hour: newBucket(),
|
|
232
|
+
fiveHour: newBucket(),
|
|
233
|
+
today: newBucket(),
|
|
234
|
+
week: newBucket(),
|
|
235
|
+
total: newBucket(),
|
|
236
|
+
};
|
|
237
|
+
const byModel = {};
|
|
238
|
+
const byProject = {};
|
|
239
|
+
|
|
240
|
+
// sparkline: last 24 hours, hourly
|
|
241
|
+
const hourly = [];
|
|
242
|
+
for (let i = 23; i >= 0; i--) {
|
|
243
|
+
const hStart = now - i * 3600 * 1000;
|
|
244
|
+
hourly.push({ t: hStart, tokens: 0, cost: 0 });
|
|
245
|
+
}
|
|
246
|
+
const hourlyBase = now - 24 * 3600 * 1000;
|
|
247
|
+
|
|
248
|
+
// daily: last 30 days
|
|
249
|
+
const daily = {};
|
|
250
|
+
for (let i = 29; i >= 0; i--) {
|
|
251
|
+
const d = new Date(now - i * 86400 * 1000);
|
|
252
|
+
d.setHours(0, 0, 0, 0);
|
|
253
|
+
const key = dateKey(d.getTime());
|
|
254
|
+
daily[key] = { date: key, tokens: 0, cost: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// sid -> project for attributing tokens to projects
|
|
258
|
+
const sidProject = {};
|
|
259
|
+
for (const sid of Object.keys(sessions)) sidProject[sid] = sessions[sid].project || 'unknown';
|
|
260
|
+
|
|
261
|
+
for (const e of allTokens) {
|
|
262
|
+
addToBucket(windows.total, e, pricing);
|
|
263
|
+
if (e.t >= hourStart) addToBucket(windows.hour, e, pricing);
|
|
264
|
+
if (e.t >= fiveHourStart) addToBucket(windows.fiveHour, e, pricing);
|
|
265
|
+
if (e.t >= dayStart) addToBucket(windows.today, e, pricing);
|
|
266
|
+
if (e.t >= weekStart) addToBucket(windows.week, e, pricing);
|
|
267
|
+
|
|
268
|
+
const mk = modelKey(e.model);
|
|
269
|
+
(byModel[mk] = byModel[mk] || newBucket());
|
|
270
|
+
addToBucket(byModel[mk], e, pricing);
|
|
271
|
+
|
|
272
|
+
const pk = sidProject[e.sid] || 'unknown';
|
|
273
|
+
(byProject[pk] = byProject[pk] || newBucket());
|
|
274
|
+
addToBucket(byProject[pk], e, pricing);
|
|
275
|
+
|
|
276
|
+
if (e.t >= hourlyBase) {
|
|
277
|
+
const idx = Math.min(23, Math.floor((e.t - hourlyBase) / (3600 * 1000)));
|
|
278
|
+
if (hourly[idx]) { hourly[idx].tokens += entryTokens(e); hourly[idx].cost += entryCost(e, pricing); }
|
|
279
|
+
}
|
|
280
|
+
const dk = dateKey(e.t);
|
|
281
|
+
if (daily[dk]) { daily[dk].tokens += entryTokens(e); daily[dk].cost += entryCost(e, pricing); }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// per session: the latest call defines current context, the largest call ever
|
|
285
|
+
// seen defines which window (200k or 1M) that session runs on. One assistant
|
|
286
|
+
// message's top-level usage is a single API call, so this is never a sum.
|
|
287
|
+
const latestUsageBySid = {};
|
|
288
|
+
const maxCtxBySid = {};
|
|
289
|
+
for (const e of allTokens) {
|
|
290
|
+
const ctx = e.inp + e.cwr + e.crd;
|
|
291
|
+
// current context = latest call that actually had a prompt; synthetic /
|
|
292
|
+
// injected messages report ~0 cache and would otherwise zero it out
|
|
293
|
+
if (ctx > 0) {
|
|
294
|
+
const cur = latestUsageBySid[e.sid];
|
|
295
|
+
if (!cur || e.t > cur.t) latestUsageBySid[e.sid] = e;
|
|
296
|
+
}
|
|
297
|
+
if (!maxCtxBySid[e.sid] || ctx > maxCtxBySid[e.sid]) maxCtxBySid[e.sid] = ctx;
|
|
298
|
+
}
|
|
299
|
+
function contextFor(sid) {
|
|
300
|
+
const e = latestUsageBySid[sid];
|
|
301
|
+
const used = e ? (e.inp + e.cwr + e.crd) : 0;
|
|
302
|
+
const peak = maxCtxBySid[sid] || used;
|
|
303
|
+
const limit = limitFor(peak, config.contextLimit, config.contextLimitExplicit);
|
|
304
|
+
return { used, limit, percent: limit ? Math.min(100, Math.round((used / limit) * 100)) : 0 };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// most recently active session, and every session still inside the idle window
|
|
308
|
+
const sessionList = Object.values(sessions)
|
|
309
|
+
.filter(s => s.lastT)
|
|
310
|
+
.sort((a, b) => b.lastT - a.lastT);
|
|
311
|
+
const active = sessionList.length ? sessionList[0] : null;
|
|
312
|
+
const context = active ? contextFor(active.sid) : { used: 0, limit: config.contextLimit || 200000, percent: 0 };
|
|
313
|
+
|
|
314
|
+
const idleMs = (config.idleMinutes || 10) * 60 * 1000;
|
|
315
|
+
const sessionsOut = sessionList.slice(0, 50).map(s => {
|
|
316
|
+
const cf = contextFor(s.sid);
|
|
317
|
+
return {
|
|
318
|
+
sid: s.sid,
|
|
319
|
+
title: s.title || '(untitled session)',
|
|
320
|
+
project: s.project || 'unknown',
|
|
321
|
+
cwd: s.cwd,
|
|
322
|
+
model: modelKey(s.model),
|
|
323
|
+
lastPrompt: s.lastPrompt,
|
|
324
|
+
firstT: s.firstT,
|
|
325
|
+
lastT: s.lastT,
|
|
326
|
+
userMsgs: s.userMsgs,
|
|
327
|
+
assistantMsgs: s.assistantMsgs,
|
|
328
|
+
toolCalls: s.toolCalls,
|
|
329
|
+
errors: s.errors,
|
|
330
|
+
tokens: sessionTokens(allTokens, s.sid),
|
|
331
|
+
cost: sessionCost(allTokens, s.sid, pricing),
|
|
332
|
+
contextUsed: cf.used,
|
|
333
|
+
contextLimit: cf.limit,
|
|
334
|
+
contextPercent: cf.percent,
|
|
335
|
+
active: now - s.lastT <= idleMs,
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const activity = allTools
|
|
340
|
+
.filter(t => t.t)
|
|
341
|
+
.sort((a, b) => b.t - a.t)
|
|
342
|
+
.slice(0, 60)
|
|
343
|
+
.map(t => ({
|
|
344
|
+
t: t.t,
|
|
345
|
+
name: t.name,
|
|
346
|
+
hint: t.hint,
|
|
347
|
+
project: sidProject[t.sid] || 'unknown',
|
|
348
|
+
}));
|
|
349
|
+
|
|
350
|
+
// rough ETA for the active session: how long past turns took vs how long the
|
|
351
|
+
// current one has been running. Inherently a guess, labelled as such in the UI.
|
|
352
|
+
function computeEta(sid) {
|
|
353
|
+
if (!sid || !sessions[sid]) return null;
|
|
354
|
+
const sess = sessions[sid];
|
|
355
|
+
const prompts = (sess.promptTimes || []).slice().sort((a, b) => a - b);
|
|
356
|
+
const ats = allTokens.filter(e => e.sid === sid).map(e => e.t).sort((a, b) => a - b);
|
|
357
|
+
if (!prompts.length && !ats.length) return null;
|
|
358
|
+
const durs = [];
|
|
359
|
+
for (let i = 0; i + 1 < prompts.length; i++) {
|
|
360
|
+
let last = null;
|
|
361
|
+
for (const t of ats) { if (t >= prompts[i] && t < prompts[i + 1]) last = t; }
|
|
362
|
+
if (last !== null) durs.push(last - prompts[i]);
|
|
363
|
+
}
|
|
364
|
+
durs.sort((a, b) => a - b);
|
|
365
|
+
const median = durs.length ? durs[Math.floor(durs.length / 2)] : null;
|
|
366
|
+
const lastPrompt = prompts.length ? prompts[prompts.length - 1] : 0;
|
|
367
|
+
const lastAsst = sess.lastAssistantT || (ats.length ? ats[ats.length - 1] : 0);
|
|
368
|
+
const stop = sess.lastStopReason;
|
|
369
|
+
const RECENT = 120 * 1000;
|
|
370
|
+
|
|
371
|
+
// phase from stop_reason: error trumps all, tool_use = working, end_turn = done
|
|
372
|
+
const recentError = sess.lastWasError && (now - lastAsst) < RECENT;
|
|
373
|
+
let phase;
|
|
374
|
+
if (recentError) phase = 'error';
|
|
375
|
+
else if (lastPrompt > lastAsst && (now - lastPrompt) < 5 * 60 * 1000) phase = 'working';
|
|
376
|
+
else if (stop === 'tool_use' && (now - lastAsst) < RECENT) phase = 'working';
|
|
377
|
+
else if (stop === 'end_turn' && (now - lastAsst) < RECENT) phase = 'done';
|
|
378
|
+
else phase = 'idle';
|
|
379
|
+
|
|
380
|
+
const elapsed = now - lastPrompt;
|
|
381
|
+
const remaining = (phase === 'working' && median != null) ? Math.max(0, median - elapsed) : null;
|
|
382
|
+
return { phase, working: phase === 'working', elapsedMs: elapsed, medianMs: median, remainingMs: remaining, turns: durs.length };
|
|
383
|
+
}
|
|
384
|
+
const eta = active ? computeEta(active.sid) : null;
|
|
385
|
+
|
|
386
|
+
// window reset estimates. Claude uses fixed windows that open at the first
|
|
387
|
+
// message of a block and last a fixed length; a message past the window opens
|
|
388
|
+
// a new block. We find the current block start and add the window length.
|
|
389
|
+
const sortedTs = allTokens.map(e => e.t).sort((a, b) => a - b);
|
|
390
|
+
function blockReset(windowMs) {
|
|
391
|
+
if (!sortedTs.length) return null;
|
|
392
|
+
let start = sortedTs[0];
|
|
393
|
+
for (const t of sortedTs) { if (t > start + windowMs) start = t; }
|
|
394
|
+
return Math.max(0, start + windowMs - now);
|
|
395
|
+
}
|
|
396
|
+
const resets = {
|
|
397
|
+
fiveHourMs: blockReset(5 * 3600 * 1000),
|
|
398
|
+
weekMs: blockReset(7 * 86400 * 1000),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// peak spend per window type = best honest proxy for the real ceiling, since
|
|
402
|
+
// Anthropic does not publish limits. Percent is shown against this.
|
|
403
|
+
const blockB = {}, weekB = {}, dayB = {};
|
|
404
|
+
for (const e of allTokens) {
|
|
405
|
+
const c = entryCost(e, pricing);
|
|
406
|
+
const bk = Math.floor(e.t / (5 * 3600 * 1000));
|
|
407
|
+
const wk = Math.floor(e.t / (7 * 86400 * 1000));
|
|
408
|
+
const dk = dateKey(e.t);
|
|
409
|
+
blockB[bk] = (blockB[bk] || 0) + c;
|
|
410
|
+
weekB[wk] = (weekB[wk] || 0) + c;
|
|
411
|
+
dayB[dk] = (dayB[dk] || 0) + c;
|
|
412
|
+
}
|
|
413
|
+
const maxOf = (o) => { let m = 0; for (const k in o) if (o[k] > m) m = o[k]; return m; };
|
|
414
|
+
const peaks = { fiveHour: maxOf(blockB), day: maxOf(dayB), week: maxOf(weekB) };
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
generatedAt: now,
|
|
418
|
+
plan: config.plan,
|
|
419
|
+
rank: rankFor(windows.total.tokens),
|
|
420
|
+
eta,
|
|
421
|
+
resets,
|
|
422
|
+
peaks,
|
|
423
|
+
budgets: config.budgets,
|
|
424
|
+
context,
|
|
425
|
+
active: active ? sessionsOut.find(s => s.sid === active.sid) || null : null,
|
|
426
|
+
activeSessions: sessionsOut.filter(s => s.active),
|
|
427
|
+
windows,
|
|
428
|
+
byModel,
|
|
429
|
+
byProject,
|
|
430
|
+
hourly,
|
|
431
|
+
daily: Object.values(daily),
|
|
432
|
+
sessions: sessionsOut,
|
|
433
|
+
activity,
|
|
434
|
+
totals: {
|
|
435
|
+
sessions: sessionList.length,
|
|
436
|
+
files: files.length,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function sessionTokens(all, sid) {
|
|
442
|
+
let n = 0;
|
|
443
|
+
for (const e of all) if (e.sid === sid) n += entryTokens(e);
|
|
444
|
+
return n;
|
|
445
|
+
}
|
|
446
|
+
function sessionCost(all, sid, pricing) {
|
|
447
|
+
let c = 0;
|
|
448
|
+
for (const e of all) if (e.sid === sid) c += entryCost(e, pricing);
|
|
449
|
+
return c;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Effective context window for a session. Claude Code uses 200k by default and
|
|
453
|
+
// 1M with the long-context beta; we infer which from the largest call the
|
|
454
|
+
// session ever made. If the user pinned contextLimit, that value is hard.
|
|
455
|
+
function limitFor(peak, base, explicit) {
|
|
456
|
+
const floor = base || 200000;
|
|
457
|
+
if (explicit) return floor;
|
|
458
|
+
if (peak <= floor) return floor;
|
|
459
|
+
if (peak <= 1000000) return 1000000;
|
|
460
|
+
return Math.ceil(peak / 100000) * 100000;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A tongue-in-cheek rank based on all-time token throughput.
|
|
464
|
+
function rankFor(totalTokens) {
|
|
465
|
+
const tiers = [
|
|
466
|
+
[1e6, 'Lurker'],
|
|
467
|
+
[1e7, 'Coder'],
|
|
468
|
+
[1e8, 'Vibe Coder'],
|
|
469
|
+
[5e8, 'Power Coder'],
|
|
470
|
+
[2e9, 'God Coder'],
|
|
471
|
+
[Infinity, 'Genius'],
|
|
472
|
+
];
|
|
473
|
+
for (const [lim, name] of tiers) if (totalTokens < lim) return name;
|
|
474
|
+
return 'Genius';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function modelKey(model) {
|
|
478
|
+
const m = String(model || '').toLowerCase();
|
|
479
|
+
if (m.includes('opus')) return 'opus';
|
|
480
|
+
if (m.includes('sonnet')) return 'sonnet';
|
|
481
|
+
if (m.includes('haiku')) return 'haiku';
|
|
482
|
+
return model || 'unknown';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function dateKey(ms) {
|
|
486
|
+
const d = new Date(ms);
|
|
487
|
+
const y = d.getFullYear();
|
|
488
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
489
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
490
|
+
return `${y}-${m}-${day}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---- session digest: a clean, no-noise timeline of one session ----
|
|
494
|
+
|
|
495
|
+
function userText(content) {
|
|
496
|
+
if (typeof content === 'string') return content.trim();
|
|
497
|
+
if (Array.isArray(content)) {
|
|
498
|
+
return content
|
|
499
|
+
.filter(c => c && c.type === 'text' && c.text)
|
|
500
|
+
.map(c => c.text)
|
|
501
|
+
.join(' ')
|
|
502
|
+
.trim();
|
|
503
|
+
}
|
|
504
|
+
return '';
|
|
505
|
+
}
|
|
506
|
+
function cap(s, n) {
|
|
507
|
+
s = String(s || '');
|
|
508
|
+
return s.length > n ? s.slice(0, n).trim() + '…' : s;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function sessionDigest(sid, config) {
|
|
512
|
+
const pricing = config && config.pricing;
|
|
513
|
+
const all = listJsonl();
|
|
514
|
+
let files = all.filter(fp => path.basename(fp) === sid + '.jsonl');
|
|
515
|
+
if (!files.length) files = all; // fallback: scan everything for this sid
|
|
516
|
+
|
|
517
|
+
let records = [];
|
|
518
|
+
for (const fp of files) {
|
|
519
|
+
let raw;
|
|
520
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; }
|
|
521
|
+
for (const line of raw.split('\n')) {
|
|
522
|
+
if (!line) continue;
|
|
523
|
+
let o; try { o = JSON.parse(line); } catch (e) { continue; }
|
|
524
|
+
if (o.sessionId !== sid) continue;
|
|
525
|
+
records.push(o);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
records = records
|
|
529
|
+
.map((o, i) => ({ o, i, t: o.timestamp ? Date.parse(o.timestamp) : 0 }))
|
|
530
|
+
.sort((a, b) => (a.t - b.t) || (a.i - b.i))
|
|
531
|
+
.map(x => x.o);
|
|
532
|
+
|
|
533
|
+
const meta = { sid, title: null, project: null, cwd: null, model: null, firstT: null, lastT: null };
|
|
534
|
+
const turns = [];
|
|
535
|
+
let cur = null;
|
|
536
|
+
|
|
537
|
+
for (const o of records) {
|
|
538
|
+
const t = o.timestamp ? Date.parse(o.timestamp) : null;
|
|
539
|
+
if (t) { if (!meta.firstT) meta.firstT = t; meta.lastT = t; }
|
|
540
|
+
if (o.type === 'ai-title') meta.title = o.aiTitle || meta.title;
|
|
541
|
+
if (o.cwd) { meta.cwd = o.cwd; meta.project = projectName(o.cwd); }
|
|
542
|
+
|
|
543
|
+
if (o.type === 'user' && !o.isSidechain && o.promptSource === 'typed') {
|
|
544
|
+
const txt = userText((o.message || {}).content);
|
|
545
|
+
cur = { index: turns.length + 1, t: t, prompt: cap(txt, 2000), text: '', actions: [], tokens: 0, cost: 0, context: 0 };
|
|
546
|
+
turns.push(cur);
|
|
547
|
+
} else if (o.type === 'assistant') {
|
|
548
|
+
const m = o.message || {};
|
|
549
|
+
if (m.model) meta.model = m.model;
|
|
550
|
+
if (!cur) { cur = { index: turns.length + 1, t: t, prompt: '(session start)', text: '', actions: [], tokens: 0, cost: 0, context: 0 }; turns.push(cur); }
|
|
551
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
552
|
+
for (const c of content) {
|
|
553
|
+
if (c && c.type === 'text' && c.text && c.text.trim()) cur.text += (cur.text ? '\n' : '') + c.text.trim();
|
|
554
|
+
else if (c && c.type === 'tool_use') cur.actions.push({ name: c.name, hint: toolHint(c.input) });
|
|
555
|
+
}
|
|
556
|
+
const u = m.usage;
|
|
557
|
+
if (u) {
|
|
558
|
+
const e = { inp: u.input_tokens || 0, cwr: u.cache_creation_input_tokens || 0, crd: u.cache_read_input_tokens || 0, out: u.output_tokens || 0, model: m.model };
|
|
559
|
+
cur.tokens += entryTokens(e);
|
|
560
|
+
cur.cost += entryCost(e, pricing);
|
|
561
|
+
cur.context = e.inp + e.cwr + e.crd;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let cum = 0, cumCost = 0;
|
|
567
|
+
for (const tn of turns) { tn.text = cap(tn.text, 1600); cum += tn.tokens; cumCost += tn.cost; tn.cumTokens = cum; tn.cumCost = cumCost; }
|
|
568
|
+
|
|
569
|
+
meta.model = modelKey(meta.model);
|
|
570
|
+
return { meta, turns: turns.slice(-120) };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = { scan, sessionDigest, listJsonl, PROJECTS_DIR };
|
package/src/hooksetup.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Wire (or unwire) the Pulse hooks in ~/.claude/settings.json so users do not
|
|
4
|
+
// have to hand edit JSON. Idempotent: it never adds a hook that is already
|
|
5
|
+
// there, merges next to any hooks you already have, and keeps a one time backup.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
|
|
12
|
+
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
13
|
+
const EVENTS = { Notification: 'notify-hook.js', Stop: 'stop-hook.js', PreToolUse: 'permission-hook.js' };
|
|
14
|
+
|
|
15
|
+
function load() {
|
|
16
|
+
try { return JSON.parse(fs.readFileSync(SETTINGS, 'utf8')); } catch (e) { return {}; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function save(s) {
|
|
20
|
+
try { fs.mkdirSync(path.dirname(SETTINGS), { recursive: true }); } catch (e) {}
|
|
21
|
+
try { if (fs.existsSync(SETTINGS) && !fs.existsSync(SETTINGS + '.pulsebak')) fs.copyFileSync(SETTINGS, SETTINGS + '.pulsebak'); } catch (e) {}
|
|
22
|
+
fs.writeFileSync(SETTINGS, JSON.stringify(s, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function installHooks() {
|
|
26
|
+
const s = load();
|
|
27
|
+
s.hooks = s.hooks || {};
|
|
28
|
+
let added = 0, already = 0;
|
|
29
|
+
for (const ev of Object.keys(EVENTS)) {
|
|
30
|
+
s.hooks[ev] = s.hooks[ev] || [];
|
|
31
|
+
const has = s.hooks[ev].some((g) => (g.hooks || []).some((h) => (h.command || '').indexOf(EVENTS[ev]) !== -1));
|
|
32
|
+
if (has) { already++; continue; }
|
|
33
|
+
const hookObj = { type: 'command', command: 'node ' + path.join(HOOKS_DIR, EVENTS[ev]) };
|
|
34
|
+
// give the approval hook room to wait for your click before Claude Code kills it
|
|
35
|
+
if (ev === 'PreToolUse') hookObj.timeout = 120;
|
|
36
|
+
s.hooks[ev].push({ matcher: '', hooks: [hookObj] });
|
|
37
|
+
added++;
|
|
38
|
+
}
|
|
39
|
+
save(s);
|
|
40
|
+
return { added, already };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function uninstallHooks() {
|
|
44
|
+
const s = load();
|
|
45
|
+
if (!s.hooks) return { removed: 0 };
|
|
46
|
+
let removed = 0;
|
|
47
|
+
for (const ev of Object.keys(EVENTS)) {
|
|
48
|
+
if (!s.hooks[ev]) continue;
|
|
49
|
+
const before = JSON.stringify(s.hooks[ev]);
|
|
50
|
+
s.hooks[ev] = s.hooks[ev]
|
|
51
|
+
.map((g) => Object.assign({}, g, { hooks: (g.hooks || []).filter((h) => (h.command || '').indexOf(EVENTS[ev]) === -1) }))
|
|
52
|
+
.filter((g) => (g.hooks || []).length);
|
|
53
|
+
if (JSON.stringify(s.hooks[ev]) !== before) removed++;
|
|
54
|
+
if (!s.hooks[ev].length) delete s.hooks[ev];
|
|
55
|
+
}
|
|
56
|
+
save(s);
|
|
57
|
+
return { removed };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { installHooks, uninstallHooks, SETTINGS };
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// Runtime dir shared with the Claude Code hook. The hook appends one JSON
|
|
8
|
+
// object per line here when Claude needs attention (a permission prompt,
|
|
9
|
+
// "waiting for input", or a finished run).
|
|
10
|
+
const RUNTIME_DIR = path.join(os.homedir(), '.claude-pulse');
|
|
11
|
+
const EVENTS_FILE = path.join(RUNTIME_DIR, 'events.jsonl');
|
|
12
|
+
|
|
13
|
+
function eventsPath() { return EVENTS_FILE; }
|
|
14
|
+
function runtimeDir() { return RUNTIME_DIR; }
|
|
15
|
+
|
|
16
|
+
function ensureRuntimeDir() {
|
|
17
|
+
try { fs.mkdirSync(RUNTIME_DIR, { recursive: true }); } catch (e) {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readEvents(limit) {
|
|
21
|
+
let raw;
|
|
22
|
+
try { raw = fs.readFileSync(EVENTS_FILE, 'utf8'); } catch (e) { return []; }
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const line of raw.split('\n')) {
|
|
25
|
+
if (!line.trim()) continue;
|
|
26
|
+
try { out.push(JSON.parse(line)); } catch (e) {}
|
|
27
|
+
}
|
|
28
|
+
out.sort((a, b) => (b.time || 0) - (a.time || 0));
|
|
29
|
+
return limit ? out.slice(0, limit) : out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A notification is "waiting" if it is recent and the session it belongs to
|
|
33
|
+
// has not produced newer activity (which would mean the prompt was answered).
|
|
34
|
+
function computeWaiting(events, sessions, now) {
|
|
35
|
+
const t = now || Date.now();
|
|
36
|
+
const recentMs = 30 * 60 * 1000; // ignore stale prompts older than 30 min
|
|
37
|
+
const bySid = {};
|
|
38
|
+
for (const s of sessions || []) bySid[s.sid] = s;
|
|
39
|
+
|
|
40
|
+
for (const ev of events) {
|
|
41
|
+
if (ev.type !== 'permission' && ev.type !== 'notification') continue;
|
|
42
|
+
if (!ev.time || t - ev.time > recentMs) continue;
|
|
43
|
+
const s = ev.sessionId ? bySid[ev.sessionId] : null;
|
|
44
|
+
// resolved if the session moved after the prompt fired
|
|
45
|
+
if (s && s.lastT && s.lastT > ev.time + 1500) continue;
|
|
46
|
+
return {
|
|
47
|
+
time: ev.time,
|
|
48
|
+
message: ev.message || 'Claude needs your attention',
|
|
49
|
+
sessionId: ev.sessionId || null,
|
|
50
|
+
project: s ? s.project : (ev.cwd ? path.basename(ev.cwd) : null),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { readEvents, computeWaiting, eventsPath, runtimeDir, ensureRuntimeDir };
|