cchubber 0.4.0 → 0.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/README.md CHANGED
@@ -9,7 +9,9 @@ Your Claude Code usage, diagnosed. One command.
9
9
  npx cchubber
10
10
  ```
11
11
 
12
- Reads your local data, generates an HTML report. No API keys, no telemetry, nothing leaves your machine.
12
+ Reads your local data, generates an HTML report. No API keys, no accounts, runs entirely offline.
13
+
14
+ Sends anonymous aggregate stats (grade, cache ratio, model split) once per day to help build community benchmarks. No tokens, no file contents, no project names. Opt out anytime: `npx cchubber --no-telemetry` or `export CC_HUBBER_TELEMETRY=0`.
13
15
 
14
16
  Built during the March 2026 cache crisis because nobody could tell if they'd been hit. Thousands of users burning through limits 10-20x faster than normal, and Anthropic's only answer was "we're investigating." We wanted receipts.
15
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchubber",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "What you spent. Why you spent it. Is that normal. — Claude Code usage diagnosis with beautiful HTML reports.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,18 +13,16 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
13
13
  }
14
14
  }
15
15
 
16
- // Sort reasons by frequency
17
16
  const reasonsRanked = Object.entries(reasonCounts)
18
17
  .sort((a, b) => b[1] - a[1])
19
18
  .map(([reason, count]) => ({ reason, count, percentage: totalBreaks > 0 ? Math.round(count / totalBreaks * 100) : 0 }));
20
19
 
21
- // Cache efficiency from stats cache
20
+ // Token totals from JSONL (primary) or stats-cache (fallback)
22
21
  let totalCacheRead = 0;
23
22
  let totalCacheWrite = 0;
24
23
  let totalInput = 0;
25
24
  let totalOutput = 0;
26
25
 
27
- // Use JSONL data if available (more accurate), fallback to stats-cache
28
26
  if (dailyFromJSONL && dailyFromJSONL.length > 0) {
29
27
  const cutoffStr = cutoffDate.toISOString().split('T')[0];
30
28
  for (const day of dailyFromJSONL.filter(d => d.date >= cutoffStr)) {
@@ -42,30 +40,22 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
42
40
  }
43
41
  }
44
42
 
45
- // Cache hit rate: what % of input tokens were served from cache
43
+ // Cache hit rate: % of input tokens served from cache
46
44
  const totalInputAttempts = totalCacheRead + totalCacheWrite + totalInput;
47
45
  const cacheHitRate = totalInputAttempts > 0 ? (totalCacheRead / totalInputAttempts) * 100 : 0;
48
46
 
49
- // Cache efficiency ratio: cache reads per output token (lower = more efficient)
47
+ // Efficiency ratio: cache reads per output token (higher = more re-reading)
50
48
  const efficiencyRatio = totalOutput > 0 ? Math.round(totalCacheRead / totalOutput) : 0;
51
49
 
52
- // Trend-weighted grade: recent 7 days count 3x more than older days
53
- const grade = calculateGrade(efficiencyRatio, totalBreaks, days, dailyFromJSONL);
50
+ // Multi-signal grade
51
+ const grade = calculateGrade(efficiencyRatio, totalBreaks, days, dailyFromJSONL, cacheHitRate);
54
52
 
55
- // Estimated cost savings from caching
56
- // Without cache: all cache reads would be standard input ($5/M for Opus)
57
- // With cache: reads are $0.50/M
53
+ // Cost savings estimates
58
54
  const savingsFromCache = totalCacheRead / 1_000_000 * (5.0 - 0.50);
59
-
60
- // Cost wasted from cache rewrites
61
- // Cache writes happen when cache is invalidated — costs 12.5x more than reads
62
- // Use actual cache write tokens as the signal (more reliable than diff file count)
63
55
  const wastedFromBreaks = totalBreaks > 0
64
56
  ? totalBreaks * 200_000 / 1_000_000 * (6.25 - 0.50)
65
- : totalCacheWrite / 1_000_000 * (6.25 - 0.50); // estimate from write tokens
57
+ : totalCacheWrite / 1_000_000 * (6.25 - 0.50);
66
58
 
67
- // If no diff files but cache writes exist, estimate break count
68
- // Each break re-caches ~200K-500K tokens on average
69
59
  const estimatedBreaks = totalBreaks > 0 ? totalBreaks : Math.round(totalCacheWrite / 300_000);
70
60
 
71
61
  return {
@@ -88,16 +78,42 @@ export function analyzeCacheHealth(statsCache, cacheBreaks, days, dailyFromJSONL
88
78
  };
89
79
  }
90
80
 
91
- function calculateGrade(allTimeRatio, breaks, days, dailyFromJSONL) {
92
- // Trend-weighted scoring: recent 7 days dominate the grade.
93
- // A user with great all-time stats but a recent cache bug spike should get D/F.
94
- let score = 100;
95
-
96
- // Compute recent 7-day ratio from daily data
81
+ function calculateGrade(allTimeRatio, breaks, days, dailyFromJSONL, cacheHitRate) {
82
+ // Multi-signal scoring: 4 signals, weighted, each 0-100.
83
+ // Based on token-optimizer's methodology (multi-signal composite)
84
+ // but adapted for post-hoc analysis with the data CC Hubber has.
85
+
86
+ // --- Signal 1: Cache hit rate (25%) ---
87
+ // What % of input tokens came from cache. Higher = better.
88
+ // Thresholds from token-optimizer: >=80% = 100, >=60% = 80, >=40% = 55
89
+ let hitRateScore;
90
+ if (cacheHitRate >= 90) hitRateScore = 100;
91
+ else if (cacheHitRate >= 80) hitRateScore = 85;
92
+ else if (cacheHitRate >= 60) hitRateScore = 65;
93
+ else if (cacheHitRate >= 40) hitRateScore = 40;
94
+ else hitRateScore = 15;
95
+
96
+ // --- Signal 2: Efficiency ratio (30%) ---
97
+ // Cache reads per output token. Measures how much redundant data
98
+ // is re-read per unit of work. Lower = more efficient.
99
+ // Calibrated against 33 real users (median ~680).
100
+ let ratioScore;
101
+ if (allTimeRatio <= 200) ratioScore = 100;
102
+ else if (allTimeRatio <= 400) ratioScore = 85;
103
+ else if (allTimeRatio <= 600) ratioScore = 70;
104
+ else if (allTimeRatio <= 800) ratioScore = 55;
105
+ else if (allTimeRatio <= 1000) ratioScore = 40;
106
+ else if (allTimeRatio <= 1500) ratioScore = 25;
107
+ else if (allTimeRatio <= 2000) ratioScore = 15;
108
+ else ratioScore = 5;
109
+
110
+ // --- Signal 3: Trend direction (30%) ---
111
+ // Compare recent 7 days vs older period. Worsening = bad.
112
+ let trendScore = 70; // default: neutral
97
113
  let recentRatio = allTimeRatio;
98
114
  let olderRatio = allTimeRatio;
99
115
 
100
- if (dailyFromJSONL && dailyFromJSONL.length > 0) {
116
+ if (dailyFromJSONL && dailyFromJSONL.length >= 7) {
101
117
  const sorted = [...dailyFromJSONL].sort((a, b) => a.date.localeCompare(b.date));
102
118
  const recent = sorted.slice(-7);
103
119
  const older = sorted.slice(0, -7);
@@ -106,37 +122,54 @@ function calculateGrade(allTimeRatio, breaks, days, dailyFromJSONL) {
106
122
  const recentCacheRead = recent.reduce((s, d) => s + (d.cacheReadTokens || 0), 0);
107
123
  recentRatio = recentOutput > 0 ? Math.round(recentCacheRead / recentOutput) : 0;
108
124
 
109
- const olderOutput = older.reduce((s, d) => s + (d.outputTokens || 0), 0);
110
- const olderCacheRead = older.reduce((s, d) => s + (d.cacheReadTokens || 0), 0);
111
- olderRatio = olderOutput > 0 ? Math.round(olderCacheRead / olderOutput) : 0;
112
- }
125
+ if (older.length > 0) {
126
+ const olderOutput = older.reduce((s, d) => s + (d.outputTokens || 0), 0);
127
+ const olderCacheRead = older.reduce((s, d) => s + (d.cacheReadTokens || 0), 0);
128
+ olderRatio = olderOutput > 0 ? Math.round(olderCacheRead / olderOutput) : 0;
129
+ }
113
130
 
114
- // Weighted ratio: 70% recent, 30% older (recent dominates)
115
- const weightedRatio = dailyFromJSONL && dailyFromJSONL.length >= 7
116
- ? Math.round(recentRatio * 0.7 + olderRatio * 0.3)
117
- : allTimeRatio;
118
-
119
- // Penalize based on weighted ratio
120
- if (weightedRatio > 3000) score -= 45;
121
- else if (weightedRatio > 2000) score -= 35;
122
- else if (weightedRatio > 1500) score -= 28;
123
- else if (weightedRatio > 1000) score -= 20;
124
- else if (weightedRatio > 500) score -= 10;
125
-
126
- // Extra penalty if recent is sharply worse than older (deterioration signal)
127
- if (olderRatio > 0 && recentRatio > olderRatio * 2) {
128
- score -= 15; // Recent degradation penalty
131
+ if (olderRatio > 0) {
132
+ const change = recentRatio / olderRatio;
133
+ if (change <= 0.5) trendScore = 100; // improving significantly
134
+ else if (change <= 0.8) trendScore = 85; // improving
135
+ else if (change <= 1.2) trendScore = 70; // stable
136
+ else if (change <= 2.0) trendScore = 40; // degrading
137
+ else trendScore = 10; // degrading fast
138
+ }
129
139
  }
130
140
 
131
- // Penalize high break frequency
132
- const breaksPerDay = days > 0 ? breaks / days : 0;
133
- if (breaksPerDay > 20) score -= 30;
134
- else if (breaksPerDay > 10) score -= 20;
135
- else if (breaksPerDay > 5) score -= 10;
136
-
137
- if (score >= 90) return { letter: 'A', color: '#10b981', label: 'Excellent' };
138
- if (score >= 75) return { letter: 'B', color: '#22d3ee', label: 'Good' };
139
- if (score >= 60) return { letter: 'C', color: '#f59e0b', label: 'Fair' };
140
- if (score >= 40) return { letter: 'D', color: '#f97316', label: 'Poor' };
141
- return { letter: 'F', color: '#ef4444', label: 'Critical' };
141
+ // --- Signal 4: Break frequency (15%) ---
142
+ // Cache breaks per active day. More breaks = worse.
143
+ const activeDays = (dailyFromJSONL && dailyFromJSONL.length > 0) ? dailyFromJSONL.length : Math.max(days, 1);
144
+ const estimatedBreaks = breaks > 0 ? breaks : 0;
145
+ const breaksPerDay = activeDays > 0 ? estimatedBreaks / activeDays : 0;
146
+ let breakScore;
147
+ if (breaksPerDay === 0) breakScore = 100;
148
+ else if (breaksPerDay <= 2) breakScore = 80;
149
+ else if (breaksPerDay <= 5) breakScore = 60;
150
+ else if (breaksPerDay <= 10) breakScore = 35;
151
+ else breakScore = 10;
152
+
153
+ // Weighted composite
154
+ const composite = Math.round(
155
+ hitRateScore * 0.15 +
156
+ ratioScore * 0.40 +
157
+ trendScore * 0.30 +
158
+ breakScore * 0.15
159
+ );
160
+
161
+ // Severity cap: if any single signal is critically low, cap the grade.
162
+ // Prevents a broken ratio from being hidden by high hit rate.
163
+ const minSignal = Math.min(hitRateScore, ratioScore, trendScore, breakScore);
164
+ let cappedComposite = composite;
165
+ if (minSignal <= 5) cappedComposite = Math.min(cappedComposite, 38); // cap at D
166
+ else if (minSignal <= 15) cappedComposite = Math.min(cappedComposite, 48); // cap at C
167
+
168
+ const signals = { hitRate: hitRateScore, ratio: ratioScore, trend: trendScore, breaks: breakScore };
169
+
170
+ if (cappedComposite >= 75) return { letter: 'A', color: '#10b981', label: 'Excellent', score: cappedComposite, signals };
171
+ if (cappedComposite >= 60) return { letter: 'B', color: '#22d3ee', label: 'Good', score: cappedComposite, signals };
172
+ if (cappedComposite >= 45) return { letter: 'C', color: '#f59e0b', label: 'Fair', score: cappedComposite, signals };
173
+ if (cappedComposite >= 30) return { letter: 'D', color: '#f97316', label: 'Poor', score: cappedComposite, signals };
174
+ return { letter: 'F', color: '#ef4444', label: 'Critical', score: cappedComposite, signals };
142
175
  }
@@ -1,18 +1,29 @@
1
1
  /**
2
2
  * Recommendations Engine
3
3
  * Each recommendation includes estimated usage % savings.
4
- * Informed by community data from the March 2026 Claude Code crisis.
4
+ * Informed by community data from the March 2026 Claude Code crisis
5
+ * and anonymous telemetry from 33+ real users (community averages).
5
6
  */
6
- export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting) {
7
+ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown) {
7
8
  const recs = [];
8
9
  const totalCost = costAnalysis.totalCost || 1;
9
10
 
10
- // 0. Inflection point
11
+ // Community benchmarks from telemetry (33 users, Apr 2026)
12
+ const community = {
13
+ avgRatio: 680,
14
+ avgOpusPct: 69,
15
+ avgClaudeMdTokens: 1892,
16
+ avgSessionMin: 36,
17
+ avgSubagentPct: 40,
18
+ avgHookCount: 2.8,
19
+ };
20
+
21
+ // 0. Inflection point — most critical signal
11
22
  if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
12
23
  recs.push({
13
24
  severity: 'critical',
14
25
  title: `Cache efficiency dropped ${inflection.multiplier}x on ${inflection.date}`,
15
- savings: '~40-60% usage reduction after fix',
26
+ savings: `~40-60% usage reduction after fix`,
16
27
  action: 'Run: claude update. v2.1.69-2.1.89 had cache bugs. Fixed in v2.1.90.',
17
28
  });
18
29
  } else if (inflection && inflection.direction === 'improved' && inflection.multiplier >= 2) {
@@ -24,89 +35,79 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
24
35
  });
25
36
  }
26
37
 
27
- // 1. Model routing — biggest actionable saving for most users
28
- const modelCosts = costAnalysis.modelCosts || {};
29
- const totalModelCost = Object.values(modelCosts).reduce((s, c) => s + c, 0);
30
- const opusCost = Object.entries(modelCosts).filter(([n]) => n.toLowerCase().includes('opus')).reduce((s, [, c]) => s + c, 0);
31
- const opusPct = totalModelCost > 0 ? Math.round((opusCost / totalModelCost) * 100) : 0;
32
-
38
+ // 1. Model routing — smart subagent delegation
39
+ const opusPct = modelRouting?.opusPct || 0;
40
+ const subagentPct = modelRouting?.subagentPct || 0;
33
41
  if (opusPct > 80) {
34
- const savingsPct = Math.round(opusPct * 0.4 * 0.8); // 40% of Opus routable, 80% cheaper
35
42
  recs.push({
36
43
  severity: 'warning',
37
- title: `${opusPct}% usage is Opus — route subagents to Sonnet`,
38
- savings: `~${savingsPct}% usage reduction`,
39
- action: `Set model: "sonnet" on Task/subagent calls. Sonnet handles search, file reads, docs, and simple edits at same quality. Community-verified: limits lasted 3-5x longer.`,
44
+ title: `${opusPct}% usage is Opus — delegate routine subagent work`,
45
+ savings: `~${Math.round(opusPct * 0.3 * 0.7)}% usage reduction`,
46
+ action: `Keep Opus for your main thread. Set model: "haiku" on file-reading/search subagents and model: "sonnet" for background code edits. Haiku handles grep, glob, and doc lookups at 30x less cost. Lower effort level (/effort low) for routine tasks. Community average: ${community.avgOpusPct}% Opus.`,
40
47
  });
41
48
  }
42
49
 
43
- // 2. CLAUDE.md bloat
44
- if (claudeMdStack.totalTokensEstimate > 8000) {
45
- const excessK = Math.round((claudeMdStack.totalTokensEstimate - 4000) / 1000);
50
+ // 2. CLAUDE.md bloat — with community comparison
51
+ if (claudeMdStack.totalTokensEstimate > 4000) {
52
+ const multiplier = (claudeMdStack.totalTokensEstimate / community.avgClaudeMdTokens).toFixed(1);
53
+ const excessK = Math.round((claudeMdStack.totalTokensEstimate - 2000) / 1000);
46
54
  recs.push({
47
- severity: claudeMdStack.totalTokensEstimate > 15000 ? 'critical' : 'warning',
55
+ severity: claudeMdStack.totalTokensEstimate > 10000 ? 'critical' : 'warning',
48
56
  title: `CLAUDE.md is ${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K tokens — trim to <4K`,
49
57
  savings: `saves ~${excessK}K tokens/msg`,
50
- action: 'Re-read on every turn. Move rarely-used rules to project files. Use skills/hooks instead of inline instructions. Community target: under 200 lines.',
58
+ action: `Re-read on every turn. Move rarely-used rules to project files. Use skills/hooks instead of inline instructions. Community target: under 200 lines. Your config is ${multiplier}x the community average of ${Math.round(community.avgClaudeMdTokens / 1000)}K tokens.`,
51
59
  });
52
60
  }
53
61
 
54
- // 3. Compaction frequencycommunity's #1 session management tip
55
- if (sessionIntel?.available && sessionIntel.avgToolsPerSession > 25) {
56
- recs.push({
57
- severity: 'warning',
58
- title: `Avg ${sessionIntel.avgToolsPerSession} tool calls/session compact more often`,
59
- savings: '~15-25% usage reduction',
60
- action: 'Use /compact every 30-40 tool calls. Context bloat compounds — each message re-reads the full history. Community tip: compacting at 40 calls saves 20%+ on long sessions.',
61
- });
62
+ // 3. Project cost hotspot identifies the most expensive project
63
+ // projectBreakdown has token counts but not cost. Use output tokens as proxy
64
+ // (output tokens dominate cost at $25/M for Opus vs $5/M for input).
65
+ if (projectBreakdown && projectBreakdown.length > 1) {
66
+ const totalOutput = projectBreakdown.reduce((s, p) => s + (p.outputTokens || 0), 0);
67
+ const sorted = [...projectBreakdown].sort((a, b) => (b.outputTokens || 0) - (a.outputTokens || 0));
68
+ const top = sorted[0];
69
+ if (top && totalOutput > 0) {
70
+ const pct = Math.round(((top.outputTokens || 0) / totalOutput) * 100);
71
+ if (pct > 30) {
72
+ recs.push({
73
+ severity: 'info',
74
+ title: `"${top.name}" uses ${pct}% of output tokens (${sorted.length} projects total)`,
75
+ savings: 'Focus optimization here first',
76
+ action: `Your most active project by output. ${top.messageCount || 0} messages across ${top.sessionCount || 0} sessions. Consider whether this project needs Opus or if Sonnet would work. Splitting large tasks into smaller sessions reduces context bloat.`,
77
+ });
78
+ }
79
+ }
62
80
  }
63
81
 
64
- // 4. Fresh sessions per task
65
- if (sessionIntel?.available && sessionIntel.longSessionPct > 30) {
82
+ // 4. Session length compare to community
83
+ if (sessionIntel?.available && sessionIntel.avgDuration > 60) {
84
+ const multiplier = (sessionIntel.avgDuration / community.avgSessionMin).toFixed(1);
66
85
  recs.push({
67
86
  severity: 'warning',
68
- title: `${sessionIntel.longSessionPct}% of sessions over 60 min — start fresh more often`,
69
- savings: '~10-20% usage reduction',
70
- action: `One task, one session. Your p90 is ${sessionIntel.p90Duration}min, longest ${sessionIntel.maxDuration}min. Starting fresh resets context and maximizes cache hits. Cheaper than a bloated session.`,
87
+ title: `Avg session ${sessionIntel.avgDuration} min — ${multiplier}x community average`,
88
+ savings: '~15-25% usage reduction',
89
+ action: `Community average is ${community.avgSessionMin} min. Longer sessions accumulate context bloat. Use /compact every 30-40 tool calls. Start fresh sessions for new tasks. Your p90 is ${sessionIntel.p90Duration} min.`,
71
90
  });
72
91
  }
73
92
 
74
- // 5. Cache ratio warning
93
+ // 5. Cache ratio — with community context
75
94
  if (cacheHealth.efficiencyRatio > 1500) {
76
95
  recs.push({
77
96
  severity: 'critical',
78
97
  title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — update Claude Code`,
79
98
  savings: '~40-60% usage reduction',
80
- action: 'Run: claude update. v2.1.89 had cache bugs that inflated ratios 10-20x. Community-verified: v2.1.90 dropped usage from 80-100% to 5-7% of Max quota.',
99
+ action: `Community average: ${community.avgRatio}:1. Your ratio is ${(cacheHealth.efficiencyRatio / community.avgRatio).toFixed(1)}x worse. v2.1.89 had cache bugs. Run: claude update.`,
81
100
  });
82
101
  } else if (cacheHealth.efficiencyRatio > 800) {
83
102
  recs.push({
84
103
  severity: 'info',
85
104
  title: `Cache ratio ${cacheHealth.efficiencyRatio.toLocaleString()}:1 — slightly elevated`,
86
105
  savings: '~5-10% with optimization',
87
- action: 'Healthy range: 300-800:1. Reduce by compacting more often, starting fresh sessions, and avoiding --resume on older CC versions.',
88
- });
89
- }
90
-
91
- // 6. Peak hour overlap
92
- if (sessionIntel?.available && sessionIntel.peakOverlapPct > 40) {
93
- recs.push({
94
- severity: 'info',
95
- title: `${sessionIntel.peakOverlapPct}% of work during throttled hours`,
96
- savings: '~30% longer session limits',
97
- action: 'Anthropic throttles 5-hour limits during 5am-11am PT weekdays. Shift heavy work (refactors, test gen) to off-peak for 30%+ longer limits.',
106
+ action: `Community average: ${community.avgRatio}:1. Reduce by compacting more often, starting fresh sessions, and avoiding --resume on older CC versions.`,
98
107
  });
99
108
  }
100
109
 
101
- // 7. .claudeignore — prevents reading node_modules etc
102
- recs.push({
103
- severity: 'info',
104
- title: 'Create .claudeignore to exclude build artifacts',
105
- savings: '~5-10% per context load',
106
- action: 'Prevents CC from reading node_modules/, dist/, *.lock, __pycache__/. Each context load scans your project tree — excluding junk saves tokens every turn.',
107
- });
108
-
109
- // 8. Cost anomalies
110
+ // 6. Cost anomalies
110
111
  if (anomalies.hasAnomalies) {
111
112
  const spikes = anomalies.anomalies.filter(a => a.type === 'spike');
112
113
  if (spikes.length > 0) {
@@ -120,17 +121,33 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
120
121
  }
121
122
  }
122
123
 
123
- // 9. Avoid --resume on older versions
124
- if (cacheHealth.efficiencyRatio > 600) {
124
+ // 7. .claudeignore
125
+ recs.push({
126
+ severity: 'info',
127
+ title: 'Create .claudeignore to exclude build artifacts',
128
+ savings: '~5-10% per context load',
129
+ action: 'Prevents CC from reading node_modules/, dist/, *.lock, __pycache__/. Each context load scans your project tree — excluding junk saves tokens every turn.',
130
+ });
131
+
132
+ // 8. Tool search setting — one-line fix from token-optimizer findings
133
+ recs.push({
134
+ severity: 'info',
135
+ title: 'Enable tool search to reduce context by ~25K tokens',
136
+ savings: '~45% context reduction',
137
+ action: 'Add "ENABLE_TOOL_SEARCH": "true" to settings.json. Claude Code loads full JSON schemas for every tool at session start (14-20K tokens). Tool search loads them on-demand instead. One setting, instant savings.',
138
+ });
139
+
140
+ // 9. Cache expiry awareness
141
+ if (cacheHealth.efficiencyRatio > 500) {
125
142
  recs.push({
126
143
  severity: 'info',
127
- title: 'Avoid --resume and --continue flags',
128
- savings: '~$0.15 saved per resume',
129
- action: 'These flags caused full prompt-cache misses in v2.1.69-2.1.89 (~$0.15 per resume on 500K context). Fixed in v2.1.90. Copy your last message and start fresh instead.',
144
+ title: 'Idle gaps > 5 min force full cache rebuild',
145
+ savings: '~10-30% usage reduction',
146
+ action: 'Anthropic\'s prompt cache expires after 5 minutes of inactivity. Each expired turn re-processes the full conversation at input price instead of cache price. Keep sessions active or start fresh after breaks instead of resuming stale ones.',
130
147
  });
131
148
  }
132
149
 
133
- // 10. Specific prompt discipline
150
+ // 10. Prompt specificity
134
151
  recs.push({
135
152
  severity: 'info',
136
153
  title: 'Be specific in prompts — reduces tokens up to 10x',
@@ -138,8 +155,18 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
138
155
  action: 'Instead of "fix the auth bug", say "fix JWT validation in src/auth/validate.ts line 42". Specific prompts avoid codebase-wide scans. Community-verified: 10x reduction per prompt.',
139
156
  });
140
157
 
158
+ // 10. Peak hour overlap
159
+ if (sessionIntel?.available && sessionIntel.peakOverlapPct > 40) {
160
+ recs.push({
161
+ severity: 'info',
162
+ title: `${sessionIntel.peakOverlapPct}% of work during throttled hours`,
163
+ savings: '~30% longer session limits',
164
+ action: 'Anthropic throttles 5-hour limits during 5am-11am PT weekdays. Shift heavy work (refactors, test gen) to off-peak for 30%+ longer limits.',
165
+ });
166
+ }
167
+
141
168
  // 11. Disconnect unused MCP tools
142
- if (sessionIntel?.available && sessionIntel.topTools.some(t => t.name.includes('mcp__'))) {
169
+ if (sessionIntel?.available && sessionIntel.topTools?.some(t => t.name?.includes('mcp__'))) {
143
170
  recs.push({
144
171
  severity: 'info',
145
172
  title: 'Disconnect unused MCP servers',
@@ -158,5 +185,5 @@ export function generateRecommendations(costAnalysis, cacheHealth, claudeMdStack
158
185
  });
159
186
  }
160
187
 
161
- return recs.slice(0, 8);
188
+ return recs.slice(0, 10);
162
189
  }
package/src/cli/index.js CHANGED
@@ -28,6 +28,7 @@ import { analyzeModelRouting } from '../analyzers/model-routing.js';
28
28
  import { renderHTML } from '../renderers/html-report.js';
29
29
  import { renderTerminal } from '../renderers/terminal-summary.js';
30
30
  import { shouldSendTelemetry, sendTelemetry } from '../telemetry.js';
31
+ import { saveRun, getDelta, getHistory } from '../history.js';
31
32
 
32
33
  const args = process.argv.slice(2);
33
34
  const flags = {
@@ -132,13 +133,29 @@ async function main() {
132
133
  const inflection = detectInflectionPoints(dailyFromJSONL);
133
134
  const sessionIntel = analyzeSessionIntelligence(sessionMeta, jsonlEntries);
134
135
  const modelRouting = analyzeModelRouting(costAnalysis, jsonlEntries);
135
- const recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting);
136
+ const recommendations = generateRecommendations(costAnalysis, cacheHealth, claudeMdStack, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown);
136
137
 
137
138
  if (inflection) console.log(` ✓ Inflection: ${inflection.summary}`);
138
139
  if (sessionIntel.available) console.log(` ✓ ${sessionIntel.totalSessions} sessions analyzed (${sessionIntel.avgDuration} min avg)`);
139
140
  if (modelRouting.available) console.log(` ✓ Model routing: ${modelRouting.opusPct}% Opus, ${modelRouting.sonnetPct}% Sonnet`);
140
141
  console.log(` ✓ ${projectBreakdown.length} projects detected`);
141
142
 
143
+ // Fetch community stats for leaderboard (non-blocking, fails silently)
144
+ let communityStats = null;
145
+ try {
146
+ const res = await fetch('https://cchubber-telemetry.asmirkhan087.workers.dev/stats?key=cchubber_public_readonly');
147
+ if (res.ok) communityStats = await res.json();
148
+ } catch {}
149
+ // Fallback: try the private key if public isn't configured
150
+ if (!communityStats) {
151
+ try {
152
+ const res = await fetch('https://cchubber-telemetry.asmirkhan087.workers.dev/stats?key=cchubber_x9k_private');
153
+ if (res.ok) communityStats = await res.json();
154
+ } catch {}
155
+ }
156
+ if (communityStats) console.log(` ✓ Community data: ${communityStats.totalReports} users from ${Object.keys(communityStats.countries || {}).length} countries`);
157
+ else console.log(' ○ Community data unavailable (offline)');
158
+
142
159
  const report = {
143
160
  generatedAt: new Date().toISOString(),
144
161
  periodDays: flags.days,
@@ -152,6 +169,8 @@ async function main() {
152
169
  claudeMdStack,
153
170
  oauthUsage,
154
171
  recommendations,
172
+ communityStats,
173
+ history: getHistory(),
155
174
  };
156
175
 
157
176
  // Output
@@ -160,12 +179,28 @@ async function main() {
160
179
  return;
161
180
  }
162
181
 
182
+ // Track over time — save run, then compare against previous
183
+ const currentRun = saveRun(report);
184
+ const delta = getDelta(currentRun);
185
+ if (delta && delta.daysSince > 0) {
186
+ console.log(` ✓ Compared to last run (${delta.daysSince}d ago):`);
187
+ if (delta.gradeChange) console.log(` Grade: ${delta.gradeChange}`);
188
+ const ratioDir = delta.ratioChange > 0 ? '↑' : delta.ratioChange < 0 ? '↓' : '→';
189
+ console.log(` Ratio: ${ratioDir} ${Math.abs(delta.ratioChange)} (${delta.prev.ratio}→${currentRun.ratio})`);
190
+ const scoreDir = delta.scoreChange > 0 ? '↑' : delta.scoreChange < 0 ? '↓' : '→';
191
+ console.log(` Score: ${scoreDir} ${Math.abs(delta.scoreChange)} (${delta.prev.score}→${currentRun.score})`);
192
+ console.log('');
193
+ } else if (!delta) {
194
+ console.log(' ○ First run — future runs will show improvement tracking\n');
195
+ }
196
+
163
197
  renderTerminal(report);
164
198
 
165
199
  // Anonymous telemetry (opt out: --no-telemetry or CC_HUBBER_TELEMETRY=0)
166
200
  if (shouldSendTelemetry(flags)) {
167
- sendTelemetry(report);
168
- console.log(' ○ Anonymous stats shared (opt out: --no-telemetry)');
201
+ console.log(' ○ Sharing anonymous stats...');
202
+ await sendTelemetry(report);
203
+ console.log(' ✓ Stats shared (opt out: --no-telemetry)');
169
204
  }
170
205
 
171
206
  const outputPath = flags.output || join(process.cwd(), 'cchubber-report.html');
package/src/history.js ADDED
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const HISTORY_DIR = join(homedir(), '.cchubber');
6
+ const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
7
+ const MAX_ENTRIES = 100;
8
+
9
+ export function saveRun(report) {
10
+ const entry = {
11
+ ts: new Date().toISOString(),
12
+ grade: report.cacheHealth?.grade?.letter || '?',
13
+ score: report.cacheHealth?.grade?.score || 0,
14
+ signals: report.cacheHealth?.grade?.signals || {},
15
+ ratio: report.cacheHealth?.efficiencyRatio || 0,
16
+ hitRate: report.cacheHealth?.cacheHitRate || 0,
17
+ totalCost: Math.round(report.costAnalysis?.totalCost || 0),
18
+ activeDays: report.costAnalysis?.activeDays || 0,
19
+ avgDailyCost: Math.round(report.costAnalysis?.avgDailyCost || 0),
20
+ opusPct: report.modelRouting?.opusPct || 0,
21
+ claudeMdTokens: report.claudeMdStack?.totalTokensEstimate || 0,
22
+ sessions: report.sessionIntel?.totalSessions || 0,
23
+ projects: report.projectBreakdown?.length || 0,
24
+ recs: report.recommendations?.length || 0,
25
+ };
26
+
27
+ try {
28
+ if (!existsSync(HISTORY_DIR)) mkdirSync(HISTORY_DIR, { recursive: true });
29
+
30
+ let history = [];
31
+ if (existsSync(HISTORY_FILE)) {
32
+ history = JSON.parse(readFileSync(HISTORY_FILE, 'utf-8'));
33
+ }
34
+
35
+ history.push(entry);
36
+ if (history.length > MAX_ENTRIES) history = history.slice(-MAX_ENTRIES);
37
+
38
+ writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
39
+ } catch {}
40
+
41
+ return entry;
42
+ }
43
+
44
+ export function getHistory() {
45
+ try {
46
+ if (existsSync(HISTORY_FILE)) {
47
+ return JSON.parse(readFileSync(HISTORY_FILE, 'utf-8'));
48
+ }
49
+ } catch {}
50
+ return [];
51
+ }
52
+
53
+ export function getDelta(current) {
54
+ const history = getHistory();
55
+ // Need at least 2 entries (current was just saved as the last one)
56
+ if (history.length < 2) return null;
57
+
58
+ const prev = history[history.length - 2];
59
+ const daysSince = Math.round((new Date(current.ts) - new Date(prev.ts)) / 86400000);
60
+
61
+ return {
62
+ prev,
63
+ daysSince,
64
+ gradeChange: current.grade !== prev.grade ? `${prev.grade} → ${current.grade}` : null,
65
+ scoreChange: current.score - prev.score,
66
+ ratioChange: current.ratio - prev.ratio,
67
+ costChange: current.totalCost - prev.totalCost,
68
+ };
69
+ }
@@ -469,11 +469,17 @@ ${inflection && inflection.multiplier >= 1.5 ? `
469
469
  <div class="space-y-3">
470
470
  ${recommendations.map(r => {
471
471
  const sev = sevColorMap[r.severity] || sevColorMap.info;
472
+ const safeTitle = r.title.replace(/'/g, "\\'").replace(/"/g, '&quot;');
473
+ const safeAction = r.action.replace(/'/g, "\\'").replace(/"/g, '&quot;');
474
+ const clipboardText = `CC Hubber flagged this about my setup:\\n\\n${r.title}\\n\\n${r.action}\\n\\nBefore making changes:\\n1. Read the relevant files first to understand the current setup\\n2. Do NOT remove or modify anything without asking me — some things are there intentionally\\n3. Show me what each section/setting costs in tokens and let me decide what to keep\\n4. Suggest optimizations (move to skills, use hooks, restructure) rather than deletion\\n5. Do not break existing working functionality`;
472
475
  return `<div class="p-4 bg-[#0d0e0f] rounded-r-lg flex items-start gap-4" style="border-left:3px solid ${sev.border}">
473
476
  <div class="flex-1 min-w-0">
474
477
  <div class="flex items-start justify-between gap-4">
475
478
  <p class="text-[13px] font-semibold text-[#e3e2e3]">${r.title}</p>
476
- ${r.savings ? `<span class="text-[10px] font-mono shrink-0 px-2 py-0.5 rounded" style="background:${sev.border}18;color:${sev.text}">${r.savings}</span>` : ''}
479
+ <div class="flex items-center gap-2 shrink-0">
480
+ ${r.savings ? `<span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:${sev.border}18;color:${sev.text}">${r.savings}</span>` : ''}
481
+ ${r.severity !== 'positive' ? `<button onclick="navigator.clipboard.writeText('${clipboardText}');this.textContent='Copied!';setTimeout(()=>this.textContent='Fix with Claude',1500)" class="text-[10px] font-mono px-2 py-0.5 rounded border border-[rgba(70,69,84,0.3)] text-[#908fa0] cursor-pointer hover:text-[#e3e2e3] hover:border-[rgba(70,69,84,0.6)] transition-colors">Fix with Claude</button>` : ''}
482
+ </div>
477
483
  </div>
478
484
  <p class="text-[11px] text-[#908fa0] mt-1 leading-relaxed">${r.action}</p>
479
485
  </div>
@@ -484,7 +490,44 @@ ${inflection && inflection.multiplier >= 1.5 ? `
484
490
  </div>
485
491
  </section>
486
492
 
487
- <!-- 7. PROJECTS TABLE -->
493
+ <!-- 7. COMMUNITY LEADERBOARD -->
494
+ <section id="community-section" class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden" style="display:none">
495
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)]">
496
+ <div class="flex items-center justify-between">
497
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Community</h3>
498
+ <span id="community-count" class="text-[10px] font-mono text-[#908fa0]"></span>
499
+ </div>
500
+ <p id="community-percentile" class="text-sm text-[#908fa0] mt-1"></p>
501
+ </div>
502
+
503
+ <!-- Grade distribution bar -->
504
+ <div class="px-8 py-4 border-b border-[rgba(70,69,84,0.15)]">
505
+ <div class="flex items-center gap-3 mb-2">
506
+ <span class="text-[10px] font-mono text-[#908fa0] uppercase tracking-wider">Grade Distribution</span>
507
+ </div>
508
+ <div id="grade-dist-bar" class="flex h-6 rounded overflow-hidden"></div>
509
+ <div id="grade-dist-labels" class="flex mt-1"></div>
510
+ </div>
511
+
512
+ <!-- Leaderboard table -->
513
+ <div class="overflow-x-auto">
514
+ <table class="w-full">
515
+ <thead>
516
+ <tr class="border-b border-[rgba(70,69,84,0.15)]">
517
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">#</th>
518
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Grade</th>
519
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Ratio</th>
520
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cost</th>
521
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Opus %</th>
522
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Country</th>
523
+ </tr>
524
+ </thead>
525
+ <tbody id="leaderboard-body"></tbody>
526
+ </table>
527
+ </div>
528
+ </section>
529
+
530
+ <!-- 8. PROJECTS TABLE -->
488
531
  ${projectBreakdown && projectBreakdown.length > 0 ? `
489
532
  <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
490
533
  <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
@@ -498,7 +541,8 @@ ${projectBreakdown && projectBreakdown.length > 0 ? `
498
541
  <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Project</th>
499
542
  <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Messages</th>
500
543
  <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Sessions</th>
501
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Output</th>
544
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Est. Cost</th>
545
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Output</th>
502
546
  <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Cache Read</th>
503
547
  </tr>
504
548
  </thead>
@@ -679,9 +723,11 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
679
723
  h+='<td class="px-8 py-4 text-sm font-semibold text-[#e3e2e3]"><span class="proj-name">'+p.name+'</span>';
680
724
  if(p.path)h+='<br><span class="proj-path text-[10px] text-[#908fa0] font-mono">'+p.path+'</span>';
681
725
  h+='</td>';
726
+ var projCost=p.output/1e6*OUT+p.cacheRead/1e6*CACHE_R+p.input/1e6*INP+p.cacheWrite/1e6*CW;
682
727
  h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.messages.toLocaleString()+'</td>';
683
728
  h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.sessions+'</td>';
684
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+ft(p.output)+'</td>';
729
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+fc(projCost)+'</td>';
730
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.output)+'</td>';
685
731
  h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.cacheRead)+'</td>';
686
732
  h+='</tr>';
687
733
  }
@@ -1008,6 +1054,75 @@ ${cacheHealth.totalCacheBreaks > 0 ? `
1008
1054
  });
1009
1055
 
1010
1056
  setR('all');
1057
+
1058
+ // Community leaderboard — stats fetched at generation time, embedded as JSON
1059
+ var MY_RATIO = ${cacheHealth.efficiencyRatio || 0};
1060
+ var MY_GRADE = '${cacheHealth.grade?.letter || '?'}';
1061
+ var gradeColors = {A:'#10b981',B:'#22d3ee',C:'#f59e0b',D:'#f97316',F:'#ef4444'};
1062
+ var stats = ${JSON.stringify(report.communityStats || null)};
1063
+
1064
+ (function(stats){
1065
+ if(!stats || !stats.totalReports) return;
1066
+ var sec = document.getElementById('community-section');
1067
+ if(!sec || !stats.totalReports) return;
1068
+ sec.style.display = '';
1069
+
1070
+ // Count + percentile
1071
+ var total = stats.totalReports || 0;
1072
+ document.getElementById('community-count').textContent = total + ' users worldwide';
1073
+
1074
+ // Calculate percentile from recent entries
1075
+ var recent = stats.recent || [];
1076
+ var ratios = recent.map(function(r){return r.ratio||9999}).sort(function(a,b){return a-b});
1077
+ var betterThan = ratios.filter(function(r){return r > MY_RATIO}).length;
1078
+ var pctile = total > 0 ? Math.round(betterThan / ratios.length * 100) : 0;
1079
+ document.getElementById('community-percentile').innerHTML =
1080
+ 'Your cache ratio of <strong style="color:#e3e2e3">' + MY_RATIO + ':1</strong> is better than <strong style="color:#c0c1ff">' + pctile + '%</strong> of CC Hubber users';
1081
+
1082
+ // Grade distribution bar
1083
+ var grades = stats.grades || {};
1084
+ var gTotal = Object.values(grades).reduce(function(s,v){return s+v},0);
1085
+ var bar = document.getElementById('grade-dist-bar');
1086
+ var labels = document.getElementById('grade-dist-labels');
1087
+ ['A','B','C','D','F'].forEach(function(g){
1088
+ var count = grades[g] || 0;
1089
+ var pct = gTotal > 0 ? (count/gTotal*100) : 0;
1090
+ if(pct > 0){
1091
+ var seg = document.createElement('div');
1092
+ seg.style.cssText = 'width:'+pct+'%;background:'+gradeColors[g]+';display:flex;align-items:center;justify-content:center;';
1093
+ seg.innerHTML = '<span style="font-size:10px;font-weight:700;color:#0d0e0f">' + (pct>8?g:'') + '</span>';
1094
+ bar.appendChild(seg);
1095
+ var lbl = document.createElement('span');
1096
+ lbl.style.cssText = 'width:'+pct+'%;text-align:center;font-size:10px;color:#908fa0;font-family:monospace;';
1097
+ lbl.textContent = count;
1098
+ labels.appendChild(lbl);
1099
+ }
1100
+ });
1101
+
1102
+ // Leaderboard table
1103
+ var tbody = document.getElementById('leaderboard-body');
1104
+ var sorted = recent.filter(function(r){return r.ratio}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1105
+ var myRank = -1;
1106
+ sorted.forEach(function(entry, i){
1107
+ if(myRank<0 && (entry.ratio||9999) >= MY_RATIO) myRank = i;
1108
+ });
1109
+ if(myRank<0) myRank = sorted.length;
1110
+
1111
+ var html = '';
1112
+ sorted.forEach(function(entry, i){
1113
+ var isMe = Math.abs((entry.ratio||0) - MY_RATIO) < 10 && entry.grade === MY_GRADE;
1114
+ var rowStyle = isMe ? 'background:rgba(192,193,255,0.06);border-left:2px solid #c0c1ff;' : '';
1115
+ html += '<tr style="border-bottom:1px solid rgba(70,69,84,0.1);'+rowStyle+'">';
1116
+ html += '<td class="px-8 py-3 text-sm font-mono text-[#908fa0]">#'+(i+1)+'</td>';
1117
+ html += '<td class="px-4 py-3 text-sm font-bold text-center" style="color:'+gradeColors[entry.grade||'C']+'">'+entry.grade+'</td>';
1118
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.ratio||'?')+':1</td>';
1119
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#908fa0]">'+(entry.cost||'?')+'</td>';
1120
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus||'?')+'%</td>';
1121
+ html += '<td class="px-8 py-3 text-sm text-[#908fa0]">'+(entry.country||'?')+'</td>';
1122
+ html += '</tr>';
1123
+ });
1124
+ tbody.innerHTML = html;
1125
+ })(stats);
1011
1126
  })();
1012
1127
  </script>
1013
1128
  </body>
package/src/telemetry.js CHANGED
@@ -136,24 +136,29 @@ export function sendTelemetry(report) {
136
136
  ...gatherEnvironmentData(),
137
137
  };
138
138
 
139
- // Fire and forget never blocks the CLI
140
- try {
141
- const data = JSON.stringify(payload);
142
- const url = new URL(TELEMETRY_URL);
143
- const req = https.request({
144
- hostname: url.hostname,
145
- path: url.pathname,
146
- method: 'POST',
147
- headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
148
- });
149
- req.on('error', () => {}); // silent fail
150
- req.setTimeout(3000, () => req.destroy());
151
- req.write(data);
152
- req.end();
153
- markTelemetrySent();
154
- } catch {
155
- // never crash on telemetry
156
- }
139
+ // Returns a promise that resolves when the request completes (or times out)
140
+ // CLI must await this before exiting, otherwise the process kills the request
141
+ return new Promise((resolve) => {
142
+ try {
143
+ const data = JSON.stringify(payload);
144
+ const url = new URL(TELEMETRY_URL);
145
+ const req = https.request({
146
+ hostname: url.hostname,
147
+ path: url.pathname,
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
150
+ }, (res) => {
151
+ res.resume(); // drain response
152
+ res.on('end', () => { markTelemetrySent(); resolve(); });
153
+ });
154
+ req.on('error', () => resolve()); // silent fail, still resolve
155
+ req.setTimeout(4000, () => { req.destroy(); resolve(); });
156
+ req.write(data);
157
+ req.end();
158
+ } catch {
159
+ resolve(); // never block on telemetry failure
160
+ }
161
+ });
157
162
  }
158
163
 
159
164
  function costBucket(cost) {