@statforge/claudestat 1.6.1 → 1.8.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.
Files changed (63) hide show
  1. package/README.md +36 -3
  2. package/dashboard/dist/assets/AnalyticsView-DDGLDoCN.js +7 -0
  3. package/dashboard/dist/assets/HistoryView-DkPfrNrv.js +1 -0
  4. package/dashboard/dist/assets/LineChart-BOWYkkEW.js +2 -0
  5. package/dashboard/dist/assets/ProjectsView-VRoRiEL4.js +6 -0
  6. package/dashboard/dist/assets/SystemView-B2zbIxhY.js +1 -0
  7. package/dashboard/dist/assets/TopView-C2qdsy0Y.js +1 -0
  8. package/dashboard/dist/assets/index-CMhe3KaT.js +84 -0
  9. package/dashboard/dist/assets/shared-BbBtsdh1.js +1 -0
  10. package/dashboard/dist/assets/{vendor-lucide-Cym0q5l_.js → vendor-lucide-ClCW-axQ.js} +79 -64
  11. package/dashboard/dist/assets/{vendor-react-B_Jzs0gY.js → vendor-react-gHSHIE2L.js} +1 -1
  12. package/dashboard/dist/index.html +3 -3
  13. package/dist/config.d.ts +7 -0
  14. package/dist/config.js +36 -0
  15. package/dist/daemon.js +113 -9
  16. package/dist/db.d.ts +87 -2
  17. package/dist/db.js +325 -65
  18. package/dist/doctor.js +21 -3
  19. package/dist/enricher.d.ts +3 -2
  20. package/dist/enricher.js +10 -5
  21. package/dist/export.d.ts +2 -1
  22. package/dist/export.js +41 -6
  23. package/dist/index.js +406 -20
  24. package/dist/insights.d.ts +1 -0
  25. package/dist/insights.js +26 -0
  26. package/dist/install.js +28 -1
  27. package/dist/intelligence.d.ts +66 -4
  28. package/dist/intelligence.js +205 -17
  29. package/dist/logger.d.ts +6 -0
  30. package/dist/logger.js +49 -0
  31. package/dist/notifier.d.ts +15 -0
  32. package/dist/notifier.js +26 -0
  33. package/dist/paths.d.ts +23 -0
  34. package/dist/paths.js +42 -0
  35. package/dist/pricing.d.ts +2 -0
  36. package/dist/pricing.js +12 -1
  37. package/dist/routes/events.js +136 -5
  38. package/dist/routes/helpers.d.ts +5 -0
  39. package/dist/routes/helpers.js +21 -1
  40. package/dist/routes/history.js +6 -2
  41. package/dist/routes/intents.d.ts +1 -0
  42. package/dist/routes/intents.js +155 -0
  43. package/dist/routes/misc.js +150 -4
  44. package/dist/routes/opencode-reader.js +39 -3
  45. package/dist/routes/projects.js +19 -1
  46. package/dist/routes/replay.d.ts +1 -0
  47. package/dist/routes/replay.js +29 -0
  48. package/dist/routes/reports.js +7 -0
  49. package/dist/routes/top.js +8 -1
  50. package/dist/service.js +11 -0
  51. package/dist/watchers/adapter.d.ts +1 -0
  52. package/dist/watchers/claude-code.d.ts +16 -1
  53. package/dist/watchers/claude-code.js +201 -76
  54. package/dist/watchers/opencode.d.ts +1 -0
  55. package/dist/watchers/opencode.js +152 -14
  56. package/hooks/event.js +44 -26
  57. package/package.json +1 -1
  58. package/dashboard/dist/assets/AnalyticsView-5bUM3UHp.js +0 -8
  59. package/dashboard/dist/assets/HistoryView-C-AsEqos.js +0 -1
  60. package/dashboard/dist/assets/ProjectsView-D9bZBdY2.js +0 -6
  61. package/dashboard/dist/assets/SystemView-DIYDCCF3.js +0 -1
  62. package/dashboard/dist/assets/TopView-DhdLpsiA.js +0 -1
  63. package/dashboard/dist/assets/index-DgbWvj42.js +0 -84
package/dist/index.js CHANGED
@@ -6,6 +6,39 @@
6
6
  * Suprimimos el ExperimentalWarning de node:sqlite antes de importar nada.
7
7
  * El módulo funciona perfectamente — el warning es solo informativo.
8
8
  */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
9
42
  var __importDefault = (this && this.__importDefault) || function (mod) {
10
43
  return (mod && mod.__esModule) ? mod : { "default": mod };
11
44
  };
@@ -20,8 +53,6 @@ const commander_1 = require("commander");
20
53
  const fs_1 = __importDefault(require("fs"));
21
54
  const path_1 = __importDefault(require("path"));
22
55
  const child_process_1 = require("child_process");
23
- const daemon_1 = require("./daemon");
24
- const watchdog_1 = require("./watchdog");
25
56
  const watch_1 = require("./watch");
26
57
  const install_1 = require("./install");
27
58
  const service_1 = require("./service");
@@ -36,6 +67,18 @@ const quota_tracker_1 = require("./quota-tracker");
36
67
  const program = new commander_1.Command();
37
68
  const PKG_VERSION = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', 'package.json'), 'utf8')).version;
38
69
  const PID_FILE = (0, paths_1.getPidFile)();
70
+ const PORT = (0, config_1.readConfig)().port;
71
+ function semverGt(a, b) {
72
+ const [pa, pb] = [a.split('.').map(Number), b.split('.').map(Number)];
73
+ for (let i = 0; i < 3; i++) {
74
+ const aVal = pa[i] ?? 0, bVal = pb[i] ?? 0;
75
+ if (aVal > bVal)
76
+ return true;
77
+ if (aVal < bVal)
78
+ return false;
79
+ }
80
+ return false;
81
+ }
39
82
  // ── Update notifier ────────────────────────────────────────────
40
83
  const SKIP_UPDATE_NOTICE = new Set(['start', 'stop', 'restart', 'watch']);
41
84
  const subcommand = process.argv[2];
@@ -64,7 +107,7 @@ if (!SKIP_UPDATE_NOTICE.has(subcommand)) {
64
107
  }
65
108
  const _exit = process.exit.bind(process);
66
109
  process.exit = ((code) => {
67
- if ((code ?? 0) === 0 && cachedLatest && cachedLatest !== PKG_VERSION) {
110
+ if ((code ?? 0) === 0 && cachedLatest && semverGt(cachedLatest, PKG_VERSION)) {
68
111
  console.log(`\n ✦ Update available: ${PKG_VERSION} → ${cachedLatest}`);
69
112
  console.log(` Run: npm install -g @statforge/claudestat\n`);
70
113
  }
@@ -79,7 +122,7 @@ function spawnDaemon() {
79
122
  });
80
123
  child.unref();
81
124
  console.log(`✅ claudestat daemon started (pid ${child.pid})`);
82
- console.log(` Dashboard → http://localhost:7337`);
125
+ console.log(` Dashboard → http://localhost:${PORT}`);
83
126
  }
84
127
  function removePidFile() {
85
128
  try {
@@ -89,7 +132,7 @@ function removePidFile() {
89
132
  }
90
133
  async function stopDaemon() {
91
134
  try {
92
- const res = await fetch('http://localhost:7337/shutdown', {
135
+ const res = await fetch(`http://localhost:${PORT}/shutdown`, {
93
136
  method: 'POST',
94
137
  signal: AbortSignal.timeout(2000),
95
138
  });
@@ -166,18 +209,96 @@ program
166
209
  }
167
210
  process.exit(0);
168
211
  });
212
+ program
213
+ .command('update')
214
+ .description('Check for updates and install the latest version from npm')
215
+ .option('--dry-run', 'Only check for updates, do not install')
216
+ .action(async (opts) => {
217
+ console.log('\n🔍 Checking for updates...');
218
+ const latest = await checkLatestVersion();
219
+ if (!latest) {
220
+ console.log(' ❌ Could not reach npm registry. Check your internet connection.\n');
221
+ process.exit(1);
222
+ }
223
+ if (!semverGt(latest, PKG_VERSION)) {
224
+ console.log(` ✅ Already on latest version (${PKG_VERSION})\n`);
225
+ process.exit(0);
226
+ }
227
+ console.log(` ✦ Update available: ${PKG_VERSION} → ${latest}`);
228
+ try {
229
+ const changelogPath = path_1.default.join(__dirname, '..', 'CHANGELOG.md');
230
+ const changelog = fs_1.default.readFileSync(changelogPath, 'utf8');
231
+ const sections = changelog.split(/^## /m).filter(Boolean);
232
+ const relevant = sections.filter(s => s.startsWith(`[${latest}]`) ||
233
+ (semverGt(s.split(']')[0].replace('[', ''), PKG_VERSION) &&
234
+ semverGt(latest, s.split(']')[0].replace('[', ''))));
235
+ if (relevant.length > 0) {
236
+ console.log('\n 📋 What\'s new:');
237
+ relevant.slice(0, 3).forEach(s => {
238
+ const lines = s.split('\n').slice(0, 6);
239
+ lines.forEach(l => console.log(` ${l}`));
240
+ });
241
+ }
242
+ }
243
+ catch { }
244
+ if (opts.dryRun) {
245
+ console.log(`\n Run \x1b[36mclaudestat update\x1b[0m to install.\n`);
246
+ process.exit(0);
247
+ }
248
+ console.log(`\n📦 Installing @statforge/claudestat@${latest}...`);
249
+ try {
250
+ (0, child_process_1.execSync)('npm install -g @statforge/claudestat', { stdio: 'inherit' });
251
+ }
252
+ catch {
253
+ console.error('\n❌ Installation failed. Try manually: npm install -g @statforge/claudestat\n');
254
+ process.exit(1);
255
+ }
256
+ console.log('\n🔗 Re-registering hooks...');
257
+ try {
258
+ const { installHooks } = await Promise.resolve().then(() => __importStar(require('./install')));
259
+ installHooks();
260
+ }
261
+ catch { }
262
+ console.log('\n🔄 Restarting daemon...');
263
+ try {
264
+ await stopDaemon().catch(() => { });
265
+ await new Promise(r => setTimeout(r, 500));
266
+ spawnDaemon();
267
+ }
268
+ catch { }
269
+ console.log(`\n✅ Updated to ${latest}. Run \x1b[36mclaudestat doctor\x1b[0m to verify.\n`);
270
+ process.exit(0);
271
+ });
169
272
  program
170
273
  .command('start')
171
274
  .description('Start the background daemon (receives Claude Code hook events)')
172
275
  .option('--watchdog', 'Auto-restart daemon if it crashes')
173
- .action((opts) => {
276
+ .option('--wait', 'Wait until daemon responds on /health before returning (max 10s)')
277
+ .action(async (opts) => {
174
278
  if (process.env.CLAUDESTAT_DAEMON) {
175
- (0, daemon_1.startDaemon)();
176
- if (opts.watchdog)
177
- (0, watchdog_1.startWatchdog)();
279
+ const { startDaemon } = require('./daemon');
280
+ startDaemon();
281
+ if (opts.watchdog) {
282
+ const { startWatchdog } = require('./watchdog');
283
+ startWatchdog();
284
+ }
178
285
  }
179
286
  else {
180
287
  spawnDaemon();
288
+ if (opts.wait) {
289
+ const deadline = Date.now() + 10000;
290
+ while (Date.now() < deadline) {
291
+ await new Promise(r => setTimeout(r, 200));
292
+ try {
293
+ const res = await fetch(`http://localhost:${PORT}/health`, { signal: AbortSignal.timeout(500) });
294
+ if (res.ok) {
295
+ console.log('✅ Daemon is ready');
296
+ break;
297
+ }
298
+ }
299
+ catch { }
300
+ }
301
+ }
181
302
  process.exit(0);
182
303
  }
183
304
  });
@@ -192,7 +313,21 @@ program
192
313
  .command('setup')
193
314
  .description('One-command setup: install hooks + register daemon as system service (auto-starts on login)')
194
315
  .option('--uninstall', 'Remove hooks and system service')
316
+ .option('--port <number>', 'Custom daemon port (default: 7337)')
317
+ .option('--reset', 'Reinstall from scratch (keeps SQLite history)')
195
318
  .action(async (opts) => {
319
+ if (opts.port) {
320
+ const p = parseInt(opts.port, 10);
321
+ if (!isNaN(p) && p >= 1024 && p <= 65535) {
322
+ const cfg = (0, config_1.readConfig)();
323
+ (0, config_1.writeConfig)({ ...cfg, port: p });
324
+ console.log(`✓ Port set to ${p}`);
325
+ }
326
+ else {
327
+ console.error('❌ Invalid port. Must be between 1024 and 65535.');
328
+ process.exit(1);
329
+ }
330
+ }
196
331
  if (opts.uninstall) {
197
332
  console.log('Uninstalling claudestat...');
198
333
  (0, service_1.uninstallService)();
@@ -201,10 +336,23 @@ program
201
336
  console.log('✅ claudestat fully removed');
202
337
  process.exit(0);
203
338
  }
339
+ if (opts.reset) {
340
+ console.log('\n🔄 Resetting claudestat installation...');
341
+ (0, service_1.uninstallService)();
342
+ (0, install_1.uninstallHooks)();
343
+ await stopDaemon().catch(() => { });
344
+ const cfgPath = path_1.default.join((0, paths_1.getClaudestatDir)(), 'config.json');
345
+ try {
346
+ fs_1.default.unlinkSync(cfgPath);
347
+ }
348
+ catch { }
349
+ console.log(' ✅ Hooks, service, and config removed (history preserved)');
350
+ console.log(' 🔁 Starting fresh install...\n');
351
+ }
204
352
  // 1. Wizard: Node check + plan + config + hooks + MCP
205
353
  await (0, install_1.runWizard)();
206
354
  // 2. Start daemon now
207
- const daemonRunning = await fetch('http://localhost:7337/health', {
355
+ const daemonRunning = await fetch(`http://localhost:${PORT}/health`, {
208
356
  signal: AbortSignal.timeout(2000),
209
357
  }).then(r => r.ok).catch(() => false);
210
358
  if (!daemonRunning) {
@@ -212,7 +360,7 @@ program
212
360
  }
213
361
  else {
214
362
  console.log('✅ Daemon already running');
215
- console.log(' Dashboard → http://localhost:7337');
363
+ console.log(` Dashboard → http://localhost:${PORT}`);
216
364
  }
217
365
  console.log('\n Run \x1b[36mclaudestat watch\x1b[0m to see live activity');
218
366
  process.exit(0);
@@ -227,15 +375,16 @@ program
227
375
  .action(() => { (0, install_1.uninstallHooks)(); process.exit(0); });
228
376
  program
229
377
  .command('export [format]')
230
- .description('Export session data (json | csv, default: json). Max 500 sessions.')
378
+ .description('Export session data (json | csv | markdown, default: json). Max 500 sessions.')
231
379
  .option('--from <date>', 'Start date YYYY-MM-DD (inclusive)')
232
380
  .option('--to <date>', 'End date YYYY-MM-DD (inclusive)')
381
+ .option('--since <period>', 'Shorthand: 7d, 30d, 90d (overrides --from)')
233
382
  .option('--project <name>', 'Filter by project path (case-insensitive substring)')
234
- .option('--output <path>', 'Write to file instead of stdout')
383
+ .option('--output <path>', 'Write to file (default: stdout)')
235
384
  .action((format, opts) => {
236
385
  const fmt = (format ?? 'json').toLowerCase();
237
- if (fmt !== 'json' && fmt !== 'csv') {
238
- console.error('Error: format must be "json" or "csv"');
386
+ if (!['json', 'csv', 'markdown'].includes(fmt)) {
387
+ console.error('Error: format must be "json", "csv", or "markdown"');
239
388
  process.exit(1);
240
389
  }
241
390
  (0, export_1.runExport)({ format: fmt, ...opts });
@@ -249,8 +398,8 @@ program
249
398
  try {
250
399
  await (0, quota_tracker_1.refreshFromApi)(); // refresh disk cache on demand; daemon reads from disk
251
400
  const [quotaRes] = await Promise.all([
252
- fetch('http://localhost:7337/quota'),
253
- fetch('http://localhost:7337/health'),
401
+ fetch(`http://localhost:${PORT}/quota`),
402
+ fetch(`http://localhost:${PORT}/health`),
254
403
  ]);
255
404
  if (!quotaRes.ok)
256
405
  throw new Error('Daemon unavailable');
@@ -325,6 +474,13 @@ program
325
474
  .option('--plan <plan>', 'Force plan detection: pro|max5|max20|auto')
326
475
  .option('--alerts <bool>', 'Enable/disable daemon rate limit alerts: true|false')
327
476
  .option('--session-limit <usd>', 'Alert when a session exceeds this cost in USD (0 = disabled)')
477
+ .option('--kill-switch-force <bool>', 'Hard-block on kill switch instead of warning: true|false')
478
+ .option('--log-level <level>', 'Set log level: debug|info|warn|error (default: info)')
479
+ .option('--loop-threshold <number>', 'Tool calls in window to trigger loop detection (default: 8)')
480
+ .option('--loop-window <seconds>', 'Detection window in seconds (default: 120)')
481
+ .option('--alias <path=name>', 'Set a project alias: --alias "/path/to/repo=MyApp"')
482
+ .option('--remove-alias <path>', 'Remove a project alias')
483
+ .option('--webhook <url>', 'Set webhook URL for external alerts (Slack, Discord). Use "off" to disable.')
328
484
  .action((opts) => {
329
485
  const cfg = (0, config_1.readConfig)();
330
486
  let changed = false;
@@ -363,6 +519,63 @@ program
363
519
  else
364
520
  console.warn(' ⚠️ session-limit must be a number >= 0 (e.g. 5 for $5)');
365
521
  }
522
+ if (opts.killSwitchForce !== undefined) {
523
+ cfg.killSwitchForce = opts.killSwitchForce === 'true';
524
+ changed = true;
525
+ }
526
+ if (opts.logLevel !== undefined) {
527
+ if (['debug', 'info', 'warn', 'error'].includes(opts.logLevel)) {
528
+ cfg.logLevel = opts.logLevel;
529
+ changed = true;
530
+ }
531
+ else {
532
+ console.warn(' ⚠️ log-level must be: debug | info | warn | error');
533
+ }
534
+ if (opts.loopThreshold !== undefined) {
535
+ const v = parseInt(opts.loopThreshold, 10);
536
+ if (!isNaN(v) && v >= 2 && v <= 50) {
537
+ cfg.loopThreshold = v;
538
+ changed = true;
539
+ }
540
+ else
541
+ console.warn(' ⚠️ loopThreshold must be between 2 and 50');
542
+ }
543
+ if (opts.loopWindow !== undefined) {
544
+ const v = parseInt(opts.loopWindow, 10);
545
+ if (!isNaN(v) && v >= 10 && v <= 600) {
546
+ cfg.loopWindowSecs = v;
547
+ changed = true;
548
+ }
549
+ else
550
+ console.warn(' ⚠️ loopWindow must be between 10 and 600 seconds');
551
+ }
552
+ if (opts.alias !== undefined) {
553
+ const eqIdx = opts.alias.indexOf('=');
554
+ if (eqIdx < 1) {
555
+ console.warn(' ⚠️ format: --alias "/absolute/path=Alias Name"');
556
+ }
557
+ else {
558
+ const projectPath = opts.alias.slice(0, eqIdx).trim();
559
+ const aliasName = opts.alias.slice(eqIdx + 1).trim();
560
+ if (!projectPath.startsWith('/') && !projectPath.match(/^[A-Z]:\\/)) {
561
+ console.warn(' ⚠️ project path must be absolute');
562
+ }
563
+ else {
564
+ cfg.projectAliases = { ...cfg.projectAliases, [projectPath]: aliasName };
565
+ changed = true;
566
+ }
567
+ }
568
+ }
569
+ if (opts.removeAlias !== undefined) {
570
+ const { [opts.removeAlias]: _, ...rest } = cfg.projectAliases;
571
+ cfg.projectAliases = rest;
572
+ changed = true;
573
+ }
574
+ if (opts.webhook !== undefined) {
575
+ cfg.webhookUrl = opts.webhook === 'off' ? null : opts.webhook;
576
+ changed = true;
577
+ }
578
+ }
366
579
  if (changed) {
367
580
  (0, config_1.writeConfig)(cfg);
368
581
  console.log('✅ Config saved to ~/.claudestat/config.json');
@@ -386,6 +599,7 @@ program
386
599
  lines.push('━'.repeat(42));
387
600
  lines.push('');
388
601
  lines.push(` Plan ${planColor}${planLabel.toUpperCase()}${R}`);
602
+ lines.push(` Port ${C}${cfg.port}${R}`);
389
603
  lines.push(` Alerts ${alertsIcon}`);
390
604
  lines.push('');
391
605
  lines.push(` Kill switch ${cfg.killSwitchEnabled ? `${Y}ON${R} at ${cfg.killSwitchThreshold}%` : `${D}OFF${R}`}`);
@@ -393,6 +607,17 @@ program
393
607
  lines.push(` ${bar(cfg.killSwitchThreshold)}`);
394
608
  }
395
609
  lines.push(` Session limit ${cfg.sessionCostLimitUsd > 0 ? `${Y}$${cfg.sessionCostLimitUsd.toFixed(2)}${R}` : `${D}OFF${R}`}`);
610
+ lines.push(` Kill switch mode ${cfg.killSwitchForce ? `${R}force-block${R}` : `${G}warn-only${R} ${D}(use --kill-switch-force true to hard-block)${R}`}`);
611
+ const logColors = { debug: D, info: '', warn: Y, error: '\x1b[31m' };
612
+ lines.push(` Log level ${logColors[cfg.logLevel] ?? ''}${cfg.logLevel.toUpperCase()}${R}`);
613
+ lines.push(` Loop detection ${C}${cfg.loopThreshold} calls${R} in ${C}${cfg.loopWindowSecs}s${R}`);
614
+ lines.push(` Webhook ${cfg.webhookUrl ? `${C}${cfg.webhookUrl}${R}` : `${D}off${R}`}`);
615
+ if (Object.keys(cfg.projectAliases).length > 0) {
616
+ lines.push(` Project aliases`);
617
+ for (const [p, a] of Object.entries(cfg.projectAliases)) {
618
+ lines.push(` ${D}${p}${R} → ${C}${a}${R}`);
619
+ }
620
+ }
396
621
  lines.push('');
397
622
  lines.push(` Cycle thresholds ${cfg.warnThresholds.join('%, ')}%`);
398
623
  lines.push(` ${D}yellow${R} ${bar(cfg.warnThresholds[0], 8)} ${D}orange${R} ${bar(cfg.warnThresholds[1], 8)} ${D}red${R} ${bar(cfg.warnThresholds[2], 8)}`);
@@ -409,6 +634,20 @@ program
409
634
  await stopDaemon().catch((e) => { console.error(`❌ ${e.message}`); process.exit(1); });
410
635
  process.exit(0);
411
636
  });
637
+ program
638
+ .command('resume')
639
+ .description('Remove the pause signal — allows Claude Code to continue after a quota warning')
640
+ .action(() => {
641
+ const signalFile = (0, paths_1.getPauseSignalFile)();
642
+ try {
643
+ fs_1.default.unlinkSync(signalFile);
644
+ console.log('✅ Pause signal removed — claudestat will no longer warn on tool calls');
645
+ }
646
+ catch {
647
+ console.log(' No pause signal active.');
648
+ }
649
+ process.exit(0);
650
+ });
412
651
  program
413
652
  .command('restart')
414
653
  .description('Restart the claudestat daemon')
@@ -425,16 +664,21 @@ program
425
664
  .option('--by <metric>', 'Sort by: cost, count, duration (default: cost)')
426
665
  .option('--limit <number>', 'Number of tools to show (default: 10)')
427
666
  .option('--days <number>', 'Look back N days (default: 30)')
667
+ .option('--json', 'Output as JSON')
428
668
  .action(async (opts) => {
429
669
  try {
430
670
  const by = opts.by ?? 'cost';
431
671
  const limit = opts.limit ?? 10;
432
672
  const days = opts.days ?? 30;
433
- const url = `http://localhost:7337/api/top?by=${by}&limit=${limit}&days=${days}`;
673
+ const url = `http://localhost:${PORT}/api/top?by=${by}&limit=${limit}&days=${days}`;
434
674
  const res = await fetch(url);
435
675
  if (!res.ok)
436
676
  throw new Error('Daemon unavailable');
437
677
  const data = await res.json();
678
+ if (opts.json) {
679
+ console.log(JSON.stringify(data, null, 2));
680
+ process.exit(0);
681
+ }
438
682
  const R = '\x1b[0m';
439
683
  const B = '\x1b[1m';
440
684
  const D = '\x1b[2m';
@@ -465,13 +709,16 @@ program
465
709
  const dur = isOther ? '—' : fmtDur(t.totalDurationMs);
466
710
  const cost = isOther ? fmtCost(t.estimatedCostUsd) : fmtCost(t.estimatedCostUsd);
467
711
  const countStr = isOther ? '—' : String(t.count);
712
+ const avgPerCall = t.count > 0 && !isOther
713
+ ? `$${(t.estimatedCostUsd / t.count).toFixed(4)}`
714
+ : '—';
468
715
  const toolName = (t.tool.length > 18 ? t.tool.slice(0, 16) + '…' : t.tool).padEnd(18);
469
716
  if (isOther) {
470
717
  lines.push(` ${D}Other${R} ${'—'.padStart(20)} ${cost.padStart(10)} ${fmtPct(pct)}`);
471
718
  }
472
719
  else {
473
720
  lines.push(` ${B}${(i + 1).toString().padStart(2)}${R} ${toolName} ${bar(val, maxVal)} ${cost.padStart(10)} ${fmtPct(pct)}`);
474
- lines.push(` ${D}${countStr} calls · ${dur}${R}`);
721
+ lines.push(` ${D}${countStr} calls · ${dur} · avg/call ${avgPerCall}${R}`);
475
722
  }
476
723
  }
477
724
  lines.push('');
@@ -485,6 +732,63 @@ program
485
732
  process.exit(1);
486
733
  }
487
734
  });
735
+ program
736
+ .command('loops')
737
+ .description('List sessions with detected loops')
738
+ .option('--days <number>', 'Look back N days (default: 30)', '30')
739
+ .option('--limit <number>', 'Max sessions to show (default: 10)', '10')
740
+ .option('--json', 'Output raw JSON')
741
+ .action(async (opts) => {
742
+ try {
743
+ const days = Math.min(parseInt(opts.days, 10) || 30, 365);
744
+ const limit = Math.min(parseInt(opts.limit, 10) || 10, 50);
745
+ const since = Date.now() - days * 86400000;
746
+ const res = await fetch(`http://localhost:${PORT}/sessions`);
747
+ if (!res.ok)
748
+ throw new Error('Daemon unavailable');
749
+ const sessions = await res.json();
750
+ const withLoops = sessions
751
+ .filter(s => (s.loops_detected ?? 0) > 0 && s.started_at >= since)
752
+ .slice(0, limit);
753
+ if (opts.json) {
754
+ console.log(JSON.stringify(withLoops, null, 2));
755
+ process.exit(0);
756
+ }
757
+ const R = '\x1b[0m', B = '\x1b[1m', D = '\x1b[2m', Y = '\x1b[33m';
758
+ if (withLoops.length === 0) {
759
+ console.log(`\n No loops detected in the last ${days} days.\n`);
760
+ process.exit(0);
761
+ }
762
+ console.log(`\n${B}🔁 claudestat loops${R} ${D}last ${days} days${R}`);
763
+ console.log('━'.repeat(52));
764
+ for (const s of withLoops) {
765
+ const date = new Date(s.started_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
766
+ const project = s.project_path ? path_1.default.basename(s.project_path) : 'unknown';
767
+ const cost = s.total_cost_usd ? `$${s.total_cost_usd.toFixed(3)}` : '$0.000';
768
+ console.log(`\n ${Y}${s.loops_detected} loop(s)${R} ${B}${project}${R} ${D}${date} ${cost}${R}`);
769
+ try {
770
+ const iRes = await fetch(`http://localhost:${PORT}/intelligence/${s.id}`);
771
+ const intel = await iRes.json();
772
+ for (const loop of (intel.loops ?? []).slice(0, 3)) {
773
+ console.log(` ${D}↳ ${loop.toolName} ×${loop.count} in ${loop.windowMs / 1000}s${R}`);
774
+ if (loop.context?.repeatedFiles?.length > 0) {
775
+ loop.context.repeatedFiles.slice(0, 3).forEach((f) => console.log(` ${D}file: ${path_1.default.basename(f)}${R}`));
776
+ }
777
+ if (loop.context?.repeatedCommands?.length > 0) {
778
+ loop.context.repeatedCommands.slice(0, 2).forEach((c) => console.log(` ${D}cmd: ${c.slice(0, 60)}${R}`));
779
+ }
780
+ }
781
+ }
782
+ catch { }
783
+ }
784
+ console.log('\n' + '━'.repeat(52) + '\n');
785
+ process.exit(0);
786
+ }
787
+ catch {
788
+ console.error('\n❌ Daemon is not running. Start it with: claudestat start\n');
789
+ process.exit(1);
790
+ }
791
+ });
488
792
  program
489
793
  .command('doctor')
490
794
  .description('Check installation health and diagnose common issues')
@@ -492,6 +796,70 @@ program
492
796
  console.error('\n❌ Error:', err.message);
493
797
  process.exit(1);
494
798
  }));
799
+ program
800
+ .command('logs')
801
+ .description('Show daemon log (~/.claudestat/daemon.log)')
802
+ .option('-n <number>', 'Number of lines to show (default: 50)', '50')
803
+ .option('--follow', 'Tail the log in real time')
804
+ .option('--level <level>', 'Filter by minimum level: debug|info|warn|error')
805
+ .action((opts) => {
806
+ const logFile = (0, paths_1.getDaemonLogFile)();
807
+ if (!fs_1.default.existsSync(logFile)) {
808
+ console.log('\n No daemon log found. Start the daemon first: claudestat start\n');
809
+ process.exit(0);
810
+ }
811
+ const minRank = { debug: 0, info: 1, warn: 2, error: 3 };
812
+ const levelFilter = opts.level ? (minRank[opts.level] ?? 0) : 0;
813
+ function filterLine(line) {
814
+ if (!opts.level)
815
+ return true;
816
+ const match = line.match(/\[(DEBUG|INFO|WARN|ERROR)\]/);
817
+ if (!match)
818
+ return true;
819
+ return (minRank[match[1].toLowerCase()] ?? 0) >= levelFilter;
820
+ }
821
+ const levelColor = {
822
+ DEBUG: '\x1b[2m', INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m'
823
+ };
824
+ function colorize(line) {
825
+ return line.replace(/\[(DEBUG|INFO|WARN|ERROR)\]/, (_, l) => `${levelColor[l] ?? ''}[${l}]\x1b[0m`);
826
+ }
827
+ if (opts.follow) {
828
+ const content = fs_1.default.readFileSync(logFile, 'utf8');
829
+ content.split('\n').filter(Boolean).filter(filterLine).slice(-20)
830
+ .forEach(l => console.log(colorize(l)));
831
+ let size = fs_1.default.statSync(logFile).size;
832
+ setInterval(() => {
833
+ try {
834
+ const newSize = fs_1.default.statSync(logFile).size;
835
+ if (newSize <= size)
836
+ return;
837
+ const buf = Buffer.alloc(newSize - size);
838
+ const fd = fs_1.default.openSync(logFile, 'r');
839
+ fs_1.default.readSync(fd, buf, 0, buf.length, size);
840
+ fs_1.default.closeSync(fd);
841
+ size = newSize;
842
+ buf.toString('utf8').split('\n').filter(Boolean).filter(filterLine)
843
+ .forEach(l => console.log(colorize(l)));
844
+ }
845
+ catch { }
846
+ }, 300);
847
+ }
848
+ else {
849
+ const n = Math.min(parseInt(opts.n, 10) || 50, 500);
850
+ const content = fs_1.default.readFileSync(logFile, 'utf8');
851
+ const lines = content.split('\n').filter(Boolean).filter(filterLine).slice(-n);
852
+ if (lines.length === 0) {
853
+ console.log(`\n No log entries${opts.level ? ` at level ≥ ${opts.level}` : ''}.\n`);
854
+ }
855
+ else {
856
+ console.log('');
857
+ lines.forEach(l => console.log(colorize(l)));
858
+ console.log('');
859
+ }
860
+ process.exit(0);
861
+ }
862
+ });
495
863
  program
496
864
  .command('roast')
497
865
  .description('Roast your Claude Code usage habits')
@@ -519,11 +887,29 @@ program
519
887
  console.log('\n📊 No usage data yet — start using Claude Code and claudestat will track it.\n');
520
888
  process.exit(0);
521
889
  }
890
+ const prev = (0, insights_1.getPrevWeekInsightData)();
891
+ const deltaCost = prev.total_cost > 0
892
+ ? Math.round((data.total_cost - prev.total_cost) / prev.total_cost * 100)
893
+ : null;
894
+ const deltaSessions = prev.total_sessions > 0
895
+ ? Math.round((data.total_sessions - prev.total_sessions) / prev.total_sessions * 100)
896
+ : null;
522
897
  if (opts.json) {
523
- console.log(JSON.stringify(data, null, 2));
898
+ console.log(JSON.stringify({ current: data, prev, deltaCostPct: deltaCost, deltaSessionsPct: deltaSessions }, null, 2));
524
899
  process.exit(0);
525
900
  }
526
901
  console.log((0, insights_1.renderWeeklyInsight)(data));
902
+ if (deltaCost !== null || deltaSessions !== null) {
903
+ const R = '\x1b[0m', D = '\x1b[2m', G = '\x1b[32m', Y = '\x1b[33m';
904
+ const sign = (n) => n >= 0 ? `+${n}` : `${n}`;
905
+ const color = (n) => n > 0 ? Y : G;
906
+ console.log(` ${D}vs last week:${R}`);
907
+ if (deltaCost !== null)
908
+ console.log(` cost ${color(deltaCost)}${sign(deltaCost)}%${R}`);
909
+ if (deltaSessions !== null)
910
+ console.log(` sessions ${sign(deltaSessions)}%`);
911
+ console.log('');
912
+ }
527
913
  process.exit(0);
528
914
  }
529
915
  catch (err) {
@@ -43,3 +43,4 @@ export declare function renderInsights(d: UsageInsightsData): string;
43
43
  export declare function shouldShowInsight(): boolean;
44
44
  export declare function markInsightShown(): void;
45
45
  export declare function renderWeeklyInsight(d: WeeklyInsightData): string;
46
+ export declare function getPrevWeekInsightData(): WeeklyInsightData;
package/dist/insights.js CHANGED
@@ -10,6 +10,7 @@ exports.renderInsights = renderInsights;
10
10
  exports.shouldShowInsight = shouldShowInsight;
11
11
  exports.markInsightShown = markInsightShown;
12
12
  exports.renderWeeklyInsight = renderWeeklyInsight;
13
+ exports.getPrevWeekInsightData = getPrevWeekInsightData;
13
14
  const path_1 = __importDefault(require("path"));
14
15
  const db_1 = require("./db");
15
16
  const WEEK_MS = 7 * 86400000;
@@ -253,3 +254,28 @@ function renderWeeklyInsight(d) {
253
254
  lines.push('');
254
255
  return lines.join('\n');
255
256
  }
257
+ function getPrevWeekInsightData() {
258
+ const agg = db_1.dbOps.getPrevWeekInsight();
259
+ const topTools = db_1.dbOps.getTopTools(14, 'cost', 1);
260
+ const topTool = topTools[0];
261
+ const totalInputWithCache = agg.input_tokens + agg.cache_read;
262
+ const cacheHitPct = totalInputWithCache > 0
263
+ ? Math.min(100, Math.round(agg.cache_read / totalInputWithCache * 100))
264
+ : 0;
265
+ return {
266
+ total_sessions: agg.total_sessions,
267
+ total_cost: agg.total_cost,
268
+ input_tokens: agg.input_tokens,
269
+ output_tokens: agg.output_tokens,
270
+ cache_read: agg.cache_read,
271
+ cache_hit_pct: cacheHitPct,
272
+ total_loops: agg.total_loops,
273
+ avg_efficiency: Math.round(agg.avg_efficiency),
274
+ top_tool: topTool?.tool_name ?? 'Unknown',
275
+ top_tool_cost_pct: agg.total_cost > 0
276
+ ? Math.round((topTool?.total_cost_usd ?? 0) / agg.total_cost * 100)
277
+ : 0,
278
+ week_start: agg.week_start,
279
+ week_end: agg.week_end ?? agg.week_start,
280
+ };
281
+ }