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/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 };
@@ -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 };