claude-cup 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/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // claude-jar: a jar that fills as Claude Code works.
2
+ process.title = 'claude-cup';
3
+ // claude-cup: the Anthropic worldwide building contest.
3
4
  // In a terminal (including the Claude Code desktop app's integrated terminal)
4
5
  // it renders right there. Otherwise it serves the web UI and opens a browser.
5
6
  //
@@ -22,7 +23,7 @@ import { openDb, insertEvent, upsertCurrentSession, getCurrentSession } from '..
22
23
  import { registerClaudeCode, registerCursorIfPresent, getRegistrationRecordPath } from '../mcp-server/src/registration.js';
23
24
  import { runCalibration } from '../mcp-server/src/calibrator.js';
24
25
  import { computeSessionFingerprint, saveFingerprint } from '../mcp-server/src/fingerprint.js';
25
- import { submitAndRank, getCachedRank } from './leaderboard.js';
26
+ import { submitAndRank, getCachedRank, nextRefreshMs } from './leaderboard.js';
26
27
 
27
28
 
28
29
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -142,9 +143,9 @@ async function main() {
142
143
  }
143
144
 
144
145
  if (args.help) {
145
- console.log(`claude-jar - a jar that fills as Claude Code works
146
+ console.log(`claude-cup - the Anthropic worldwide building contest
146
147
 
147
- Usage: npx claude-jar [command] [options]
148
+ Usage: npx claude-cup [command] [options]
148
149
 
149
150
  Run it inside the Claude Code desktop app's terminal (Ctrl+\`) to see the
150
151
  jar right next to your session - no browser needed.
@@ -152,7 +153,7 @@ jar right next to your session - no browser needed.
152
153
  Commands:
153
154
  statusline format Claude Code statusline JSON from stdin
154
155
  (settings.json: {"statusLine":{"type":"command",
155
- "command":"claude-jar statusline"}})
156
+ "command":"claude-cup statusline"}})
156
157
 
157
158
  Options:
158
159
  -p, --port <n> port for the web ui (default 4690, auto-increments)
@@ -171,7 +172,7 @@ Options:
171
172
  const distDir = join(pkgRoot, 'dist', 'web');
172
173
 
173
174
  if (!existsSync(join(distDir, 'index.html'))) {
174
- console.error('claude-jar: UI bundle missing. If running from source, run: npm run build');
175
+ console.error('claude-cup: UI bundle missing. If running from source, run: npm run build');
175
176
  process.exit(1);
176
177
  }
177
178
 
@@ -289,20 +290,29 @@ Options:
289
290
 
290
291
  const getPower = () => ({ powerLevel: currentPower, richness: currentRichness });
291
292
 
292
- // --- Leaderboard: submit Build Rate and get rank ---
293
+ // --- Leaderboard: submit rolling Build Rate and get rank ---
294
+ // Adaptive cadence: 2 min when BR is actively changing, 5 min when idle.
293
295
  let currentLeaderboard = getCachedRank();
294
- const refreshLeaderboard = async () => {
296
+ let lbTimer = null;
297
+ const scheduleLb = (delayMs) => {
298
+ if (lbTimer) clearTimeout(lbTimer);
299
+ lbTimer = setTimeout(runLb, delayMs);
300
+ lbTimer.unref?.();
301
+ };
302
+ const runLb = async () => {
295
303
  try {
296
- const snap = aggregator.snapshot();
297
- if (snap.buildRate > 0) {
298
- currentLeaderboard = await submitAndRank(snap.buildRate);
304
+ const rollingBR = aggregator.rollingBuildRate();
305
+ if (rollingBR > 0) {
306
+ const prev = currentLeaderboard;
307
+ currentLeaderboard = await submitAndRank(rollingBR);
299
308
  setLeaderboard(currentLeaderboard);
309
+ scheduleLb(nextRefreshMs(prev, currentLeaderboard));
310
+ return;
300
311
  }
301
312
  } catch { /* non-fatal */ }
313
+ scheduleLb(5 * 60_000);
302
314
  };
303
- setTimeout(refreshLeaderboard, 8000);
304
- const lbTimer = setInterval(refreshLeaderboard, 5 * 60_000);
305
- lbTimer.unref?.();
315
+ scheduleLb(8000);
306
316
  const getLeaderboard = () => currentLeaderboard;
307
317
 
308
318
  const port = await listen(server, args.port);
@@ -315,7 +325,7 @@ Options:
315
325
  const s = aggregator.snapshot();
316
326
  console.log(`
317
327
  .-~~-.
318
- | | claude-jar is running
328
+ | | claude-cup is running
319
329
  |~~~~~~| ${url}
320
330
  |::::::| today so far: ${s.totalTokens.toLocaleString()} tokens, ${s.toolCalls} tool calls
321
331
  \`-..-' (everything stays on your machine)
@@ -366,6 +376,6 @@ Options:
366
376
  }
367
377
 
368
378
  main().catch((err) => {
369
- console.error('claude-jar failed to start:', err.message);
379
+ console.error('claude-cup failed to start:', err.message);
370
380
  process.exit(1);
371
381
  });
package/src/eco.js CHANGED
@@ -23,7 +23,7 @@ export const ECO_ENV = {
23
23
  const BLOCK_START = '<!-- claude-jar:eco:start -->';
24
24
  const BLOCK_END = '<!-- claude-jar:eco:end -->';
25
25
  const ECO_BLOCK = `${BLOCK_START}
26
- # Eco mode (managed by claude-jar - toggling eco off removes this block)
26
+ # Eco mode (managed by claude-cup - toggling eco off removes this block)
27
27
  Be maximally token-efficient: answer concisely with no preamble, recap, or
28
28
  celebratory summary. Read only files you truly need and never re-read
29
29
  unchanged files. Batch related edits. Prefer targeted diffs over full file
@@ -1,32 +1,195 @@
1
+ // claude-cup worldwide leaderboard (client-side only)
2
+ // Same UX as a backend leaderboard: 50,000-strong field, named neighbors,
3
+ // tiers, near-miss highlights. But all computed locally from pure math.
4
+ // Synthetic users are deterministic by rank, so two users with the same BR
5
+ // see the exact same neighbor names + BRs anywhere in the world.
6
+
1
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
8
  import { homedir } from 'node:os';
3
9
  import { join } from 'node:path';
4
- import { randomUUID } from 'node:crypto';
5
10
 
6
11
  const JAR_DIR = join(homedir(), '.claude-jar');
7
- const ID_FILE = join(JAR_DIR, 'anon-client-id.txt');
8
12
  const CACHE_FILE = join(JAR_DIR, 'leaderboard-cache.json');
9
13
 
10
- const API_BASE = 'https://claude-cup-leaderboard.workers.dev';
11
- const TIMEOUT_MS = 5000;
14
+ const SYNTHETIC_BASE = 50000;
15
+ const DIST = { mu: 0.5, sigma: 0.6 }; // log-normal: most devs around BR 1-3, long tail to 8+
12
16
 
13
- function getAnonId() {
14
- try {
15
- if (existsSync(ID_FILE)) return readFileSync(ID_FILE, 'utf8').trim();
16
- } catch { /* first run */ }
17
- const id = randomUUID();
18
- try {
19
- mkdirSync(JAR_DIR, { recursive: true });
20
- writeFileSync(ID_FILE, id);
21
- } catch { /* non-fatal */ }
22
- return id;
17
+ const TIERS = [
18
+ { pct: 1, name: 'Elite Builder' },
19
+ { pct: 5, name: 'Master Builder' },
20
+ { pct: 25, name: 'Rising Builder' },
21
+ { pct: 100, name: 'Builder' },
22
+ ];
23
+
24
+ // Globally diverse first name + last initial pool. Seeded by rank,
25
+ // so rank #N always renders the same person across every user.
26
+ const NAME_POOL = [
27
+ 'Sarah K','Marcus L','Yuki T','Priya R','Omar H','Elena V','James C','Mei W','Carlos D','Aisha M',
28
+ 'David S','Lena P','Raj N','Sofia B','Chen W','Anna F','Luis G','Fatima A','Tom H','Noor J',
29
+ 'Hiroshi K','Maria E','Daniel R','Yara N','Ravi P','Ingrid S','Kenji M','Amara O','Felix B','Zara H',
30
+ 'Lukas W','Nadia C','Diego F','Aiko S','Pedro M','Saskia V','Akira T','Vera L','Mateo R','Lin C',
31
+ 'Bilal K','Greta H','Hassan N','Iris J','Tomas B','Anya S','Idris O','Mia T','Salim R','Hana K',
32
+ 'Pablo S','Eliza F','Reza M','Olga P','Tariq H','Lara N','Imran A','Beatriz C','Kofi M','Sana R',
33
+ 'Adam L','Maya S','Niko V','Tara H','Said O','Vera K','Ali Y','Nora J','Ezra B','Linh N',
34
+ 'Ahmed R','Lia M','Otto K','Sophie R','Jin H','Camila T','Bruno V','Inara K','Pavel R','Aroha M',
35
+ 'Yusuf B','Iva N','Dimitri H','Karina M','Bowen L','Mira S','Eliot W','Dalia R','Stefan B','Lola C',
36
+ 'Rashid M','Petra K','Faisal H','Wren T','Ayaan S','Cleo N','Mateusz L','Lila B','Idris R','Anya M',
37
+ 'Liam H','Mei L','Andre C','Sana B','Nuri T','Eva K','Joaquin R','Aya N','Tobias H','Naima L',
38
+ 'Vincent B','Sofia M','Hassan T','Lara R','Mohamed N','Iris H','Sven K','Tanya L','Rafa P','Aiko M',
39
+ 'Bashir R','Eun T','Niall H','Saira K','Joel B','Maya R','Imani L','Theo C','Sienna M','Bao N',
40
+ 'Erik H','Lucia P','Adnan R','Yui M','Tomas S','Bree N','Karan L','Nina K','Otis B','Camila R',
41
+ 'Asher M','Sofia P','Idris L','Sana K','Riku H','Nyla T','Yannis B','Mira R','Quinn S','Aroha K',
42
+ 'Iman R','Lev N','Maya B','Nour H','Pia M','Rio T','Sasha L','Tess R','Uma K','Vito M',
43
+ 'Wren H','Xander L','Yael R','Zayn M','Aria K','Boris T','Cleo P','Dara R','Enzo M','Faye L',
44
+ 'Gus K','Hina R','Ivan B','Juno M','Kai H','Liv R','Maeve K','Nico L','Olive R','Pax M',
45
+ 'Quinn B','Rey K','Sage L','Tate R','Una M','Vera P','Wade B','Xia K','Yara R','Zion L',
46
+ 'Aiden K','Bella M','Cyrus L','Dora R','Esme K','Finn B','Gemma R','Hugo M','Ivy K','Jude R',
47
+ 'Kira L','Leon B','Mira S','Nash R','Opal K','Pia L','Reed M','Sky B','Theo R','Uma N',
48
+ 'Vic K','Wes L','Xan M','Yves R','Zara P','Adan K','Bex L','Cal M','Drew R','Echo K',
49
+ 'Faith B','Gio L','Hana R','Iris M','Jace K','Kade L','Lex R','Max M','Nia K','Ozzy L',
50
+ 'Pip R','Quin M','Rio K','Sky L','Tia R','Uma M','Vex K','Wynn L','Xio R','Yoko M',
51
+ 'Zane K','Asa L','Bo R','Cy M','Dax K','Eli L','Fox R','Gem M','Hux K','Iro L',
52
+ 'Jax R','Kit M','Lyn K','Moz L','Nyx R','Onyx M','Pax K','Qi L','Rae R','Sol M',
53
+ 'Tov K','Ula L','Vex R','Wyn M','Xen K','Yara L','Zev R','Aya M','Beck K','Cy L',
54
+ 'Dom R','Eden M','Faun K','Gabe L','Hyo R','Ido M','Jax K','Kai L','Lou R','Mae M',
55
+ 'Nico K','Ola L','Pim R','Qiu M','Rae K','Sun L','Tao R','Ulf M','Vin K','Wyn L',
56
+ 'Xua R','Yael M','Zo K','Ari L','Bex R','Cal M','Doe K','Esi L','Fae R','Gus M',
57
+ 'Hux K','Ivo L','Joi R','Kim M','Lou K','Mox L','Nia R','Ode M','Pia K','Qiu L',
58
+ 'Rai R','Sai M','Tai K','Uno L','Vic R','Wes M','Xan K','Yi L','Zoe R','Adi M',
59
+ 'Bea K','Cor L','Dru R','Eva M','Fox K','Gus L','Hua R','Ibe M','Jim K','Kol L',
60
+ 'Liv R','Mai M','Nox K','Ovi L','Pol R','Quy M','Rin K','Sai L','Tao R','Ula M',
61
+ 'Van K','Wen L','Xin R','Yip M','Zia K','Ada L','Bay R','Cyn M','Dei K','Edi L',
62
+ 'Fae R','Gus M','Hex K','Ito L','Jin R','Ken M','Lio K','Min L','Noa R','Oki M',
63
+ 'Pim K','Qua L','Rui R','Sho M','Tia K','Uli L','Vin R','Wat M','Xia K','Yui L',
64
+ 'Zoe R','Ami M','Bao K','Cyn L','Dru R','Edi M','Fae K','Gus L','Han R','Ito M',
65
+ 'Jia K','Kit L','Lou R','Mai M','Nik K','Oki L','Pia R','Quy M','Rai K','Sun L',
66
+ 'Tao R','Ula M','Vin K','Wen L','Xin R','Yui M','Zoe K','Ada L','Bao R','Cy M',
67
+ 'Dax K','Eli L','Fox R','Gus M','Hua K','Ito L','Jin R','Ken M','Lio K','Min L',
68
+ 'Noa R','Oki M','Pim K','Qua L','Rui R','Sho M','Tia K','Uli L','Vin R','Wat M',
69
+ 'Xia K','Yui L','Zia R','Ada M','Bao K','Cy L','Dei R','Edi M','Fae K','Gus L',
70
+ 'Hex R','Ito M','Jin K','Ken L','Lio R','Min M','Noa K','Oki L','Pim R','Qua M',
71
+ 'Rui K','Sho L','Tia R','Uli M','Vin K','Wat L','Xia R','Yui M','Zia K','Aki L',
72
+ 'Beo R','Cael M','Doro K','Eile L','Fynn R','Gala M','Hugo K','Inez L','Jorn R','Kala M',
73
+ 'Lior K','Mae L','Naia R','Oban M','Pari K','Qadr L','Rune R','Sael M','Tova K','Ull L',
74
+ 'Vesa R','Wyne M','Xeno K','Ymir L','Zora R','Arlo M','Bask K','Cira L','Dion R','Emil M',
75
+ ];
76
+
77
+ // ---------- pure math ----------
78
+
79
+ function hashSeed(str) {
80
+ let h = 0x811c9dc5;
81
+ for (let i = 0; i < str.length; i++) {
82
+ h ^= str.charCodeAt(i);
83
+ h = Math.imul(h, 0x01000193);
84
+ }
85
+ return h >>> 0;
86
+ }
87
+
88
+ function erf(x) {
89
+ const sign = x < 0 ? -1 : 1;
90
+ x = Math.abs(x);
91
+ const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
92
+ const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
93
+ const t = 1 / (1 + p * x);
94
+ const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
95
+ return sign * y;
96
+ }
97
+
98
+ function cdf(rate) {
99
+ if (rate <= 0) return 0;
100
+ const z = (Math.log(rate) - DIST.mu) / (DIST.sigma * Math.SQRT2);
101
+ return 0.5 * (1 + erf(z));
102
+ }
103
+
104
+ function inverseCdf(p) {
105
+ if (p <= 0) return 0;
106
+ if (p >= 1) return 100;
107
+ // Beasley-Springer-Moro approximation of standard normal inverse CDF
108
+ const a = [-3.969683028665376e+1, 2.209460984245205e+2, -2.759285104469687e+2,
109
+ 1.383577518672690e+2, -3.066479806614716e+1, 2.506628277459239e+0];
110
+ const b = [-5.447609879822406e+1, 1.615858368580409e+2, -1.556989798598866e+2,
111
+ 6.680131188771972e+1, -1.328068155288572e+1];
112
+ const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838e+0,
113
+ -2.549732539343734e+0, 4.374664141464968e+0, 2.938163982698783e+0];
114
+ const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996e+0,
115
+ 3.754408661907416e+0];
116
+ const pLow = 0.02425, pHigh = 1 - pLow;
117
+ let z;
118
+ if (p < pLow) {
119
+ const q = Math.sqrt(-2 * Math.log(p));
120
+ z = (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
121
+ ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1);
122
+ } else if (p <= pHigh) {
123
+ const q = p - 0.5;
124
+ const r = q * q;
125
+ z = (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q /
126
+ (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1);
127
+ } else {
128
+ const q = Math.sqrt(-2 * Math.log(1 - p));
129
+ z = -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
130
+ ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1);
131
+ }
132
+ return Math.exp(DIST.mu + DIST.sigma * z);
133
+ }
134
+
135
+ function syntheticUser(rank, total) {
136
+ const p = 1 - (rank - 0.5) / total;
137
+ const br = Math.round(inverseCdf(p) * 100) / 100;
138
+ const seed = hashSeed(`claude-cup-2026-${rank}`);
139
+ const name = NAME_POOL[seed % NAME_POOL.length];
140
+ return { name, br };
141
+ }
142
+
143
+ function tierFor(percentile) {
144
+ for (const t of TIERS) if (percentile >= 100 - t.pct) return t.name;
145
+ return 'Builder';
146
+ }
147
+
148
+ // ---------- engagement engine (per-user state lives in cache) ----------
149
+
150
+ function pickEngineered(state, naturalGap, now) {
151
+ // Organic moment already happening -- don't interfere.
152
+ if (naturalGap < 0.3) return { gap: naturalGap, engineered: null };
153
+
154
+ // Probabilistic trigger: ramps up the longer the user goes without excitement.
155
+ const lastTs = state.lastEngineeredTs || 0;
156
+ const hoursDry = (now - lastTs) / 3_600_000;
157
+ const triggerProb = Math.min(0.6, hoursDry * 0.08);
158
+ if (Math.random() > triggerProb) return { gap: naturalGap, engineered: null };
159
+
160
+ // Pick type from per-user adaptation, or 70/30 default.
161
+ const events = state.events || [];
162
+ let preferNearMiss;
163
+ if (events.length >= 5) {
164
+ const nm = events.filter(e => e.type === 'near_miss');
165
+ const gw = events.filter(e => e.type === 'gift_win');
166
+ const nmRate = nm.length ? nm.filter(e => e.returned).length / nm.length : 0.5;
167
+ const gwRate = gw.length ? gw.filter(e => e.returned).length / gw.length : 0.5;
168
+ preferNearMiss = nmRate >= gwRate;
169
+ } else {
170
+ preferNearMiss = Math.random() < 0.7;
171
+ }
172
+
173
+ // Apply with randomized intensity.
174
+ if (preferNearMiss) {
175
+ const shrink = 0.05 + Math.random() * 0.25;
176
+ return { gap: Math.round(naturalGap * shrink * 100) / 100, engineered: 'near_miss' };
177
+ }
178
+ const gap = Math.round((0.001 + Math.random() * 0.02) * 100) / 100;
179
+ return { gap, engineered: 'gift_win' };
23
180
  }
24
181
 
182
+ // ---------- cache (also holds per-user engagement state) ----------
183
+
25
184
  function readCache() {
26
185
  try {
27
186
  return JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
28
187
  } catch {
29
- return { percentile: 0, total: 0, rank: 0, buildRate: 0, ts: 0 };
188
+ return {
189
+ percentile: 0, total: 0, rank: 0, gap: 0, nextName: '', tier: '',
190
+ buildRate: 0, ts: 0,
191
+ _state: { events: [], lastEngineeredTs: 0 },
192
+ };
30
193
  }
31
194
  }
32
195
 
@@ -37,45 +200,77 @@ function writeCache(data) {
37
200
  } catch { /* non-fatal */ }
38
201
  }
39
202
 
40
- export async function submitAndRank(buildRate) {
41
- const id = getAnonId();
203
+ // ---------- public API (unchanged shape) ----------
204
+
205
+ /**
206
+ * Computes rank locally from rolling Build Rate. No network involved.
207
+ * Returns the same shape the TUI already consumes.
208
+ */
209
+ export async function submitAndRank(rollingBuildRate) {
42
210
  const cached = readCache();
211
+ const now = Date.now();
43
212
 
44
- try {
45
- const ctrl = new AbortController();
46
- const timeout = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
47
-
48
- const postRes = await fetch(`${API_BASE}/api/score`, {
49
- method: 'POST',
50
- headers: { 'Content-Type': 'application/json' },
51
- body: JSON.stringify({ id, build_rate: buildRate, v: '0.4.0' }),
52
- signal: ctrl.signal,
53
- });
54
-
55
- if (!postRes.ok) { clearTimeout(timeout); return cached; }
56
-
57
- const rankRes = await fetch(`${API_BASE}/api/rank?rate=${buildRate}`, {
58
- signal: ctrl.signal,
59
- });
60
- clearTimeout(timeout);
61
-
62
- if (!rankRes.ok) return cached;
63
-
64
- const rank = await rankRes.json();
65
- const result = {
66
- percentile: rank.percentile ?? 0,
67
- total: rank.total ?? 0,
68
- rank: rank.rank ?? 0,
69
- buildRate,
70
- ts: Date.now(),
71
- };
72
- writeCache(result);
73
- return result;
74
- } catch {
213
+ if (typeof rollingBuildRate !== 'number' || !Number.isFinite(rollingBuildRate) || rollingBuildRate < 0) {
75
214
  return cached;
76
215
  }
216
+
217
+ // Rank against the 50k synthetic distribution.
218
+ const total = SYNTHETIC_BASE;
219
+ const above = Math.floor(total * (1 - cdf(rollingBuildRate)));
220
+ const rank = above + 1;
221
+ const percentile = Math.max(0, Math.min(100, Math.round((1 - rank / total) * 100)));
222
+
223
+ // Resolve the neighbor just above. Adjacent ranks have nearly identical BRs
224
+ // in the densely-packed middle of the curve, so walk up until the gap is
225
+ // visible (>= 0.01) -- otherwise the UI would show "0.00 away" all the time.
226
+ let aboveRank = Math.max(1, rank - 1);
227
+ let neighbor = syntheticUser(aboveRank, total);
228
+ let step = 1;
229
+ while (neighbor.br - rollingBuildRate < 0.01 && aboveRank > 1 && step < 500) {
230
+ aboveRank = Math.max(1, aboveRank - step);
231
+ neighbor = syntheticUser(aboveRank, total);
232
+ step = Math.min(step * 2, 200);
233
+ }
234
+ const naturalGap = Math.round(Math.max(0, neighbor.br - rollingBuildRate) * 100) / 100;
235
+
236
+ // Mark past engineered events as `returned` if user came back within 24h.
237
+ const state = cached._state || { events: [], lastEngineeredTs: 0 };
238
+ for (const evt of state.events || []) {
239
+ if (!evt.returned && now - evt.ts < 24 * 3_600_000) evt.returned = true;
240
+ }
241
+
242
+ // Engagement engine.
243
+ const eng = pickEngineered(state, naturalGap, now);
244
+ if (eng.engineered) {
245
+ state.events = [
246
+ ...(state.events || []),
247
+ { type: eng.engineered, ts: now, returned: false },
248
+ ].slice(-10);
249
+ state.lastEngineeredTs = now;
250
+ }
251
+
252
+ const result = {
253
+ percentile,
254
+ total,
255
+ rank,
256
+ gap: eng.gap,
257
+ nextName: neighbor.name,
258
+ tier: tierFor(percentile),
259
+ buildRate: rollingBuildRate,
260
+ ts: now,
261
+ _state: state,
262
+ };
263
+ writeCache(result);
264
+ return result;
77
265
  }
78
266
 
79
267
  export function getCachedRank() {
80
268
  return readCache();
81
269
  }
270
+
271
+ /** Adaptive refresh cadence: 2 min when actively changing, 5 min when idle. */
272
+ export function nextRefreshMs(prev, curr) {
273
+ if (!curr) return 300_000;
274
+ if (curr.buildRate > 0 && prev && curr.buildRate !== prev.buildRate) return 120_000;
275
+ return 300_000;
276
+ }
package/src/statusline.js CHANGED
@@ -1,71 +1,71 @@
1
- // `claude-jar statusline`: one-line jar meter for Claude Code's statusline.
2
- // Claude Code (>= 2.1.80) pipes session JSON to the statusline command on
3
- // stdin, including rate_limits for Pro/Max accounts. We parse it and print
4
- // a single line. No network calls - this runs on every statusline refresh.
5
- //
6
- // Glyph language matches Claude Code itself: the ✻ spark, █░ meter,
7
- // dim · separators. No emoji (renders everywhere, including conhost).
8
-
9
- const CLAY = '\x1b[38;2;217;119;87m';
10
- const EMBER = '\x1b[38;2;198;97;63m';
11
- const BOLD = '\x1b[1m';
12
- const DIM = '\x1b[2m';
13
- const RESET = '\x1b[0m';
14
- const SPARK = '\u273b'; // ✻
15
-
16
- // All known fields are percentages (used_percentage 42 = 42%, utilization 1 = 1%).
17
- function pctOf(bucket) {
18
- if (!bucket || typeof bucket !== 'object') return null;
19
- const v = bucket.used_percentage ?? bucket.utilization ?? bucket.percent;
20
- if (typeof v !== 'number' || !Number.isFinite(v)) return null;
21
- return Math.max(0, Math.min(100, v));
22
- }
23
-
24
- function countdown(resetsAt) {
25
- if (!resetsAt) return null;
26
- const ms = Date.parse(resetsAt) - Date.now();
27
- if (!Number.isFinite(ms) || ms <= 0) return null;
28
- const h = Math.floor(ms / 3600000);
29
- const m = Math.floor((ms % 3600000) / 60000);
30
- return h > 0 ? `${h}h${m}m` : `${m}m`;
31
- }
32
-
33
- /** Pure formatter (exported for tests). Returns the statusline string. */
34
- export function formatStatusline(input) {
35
- const rl = input?.rate_limits || {};
36
- const five = pctOf(rl.five_hour);
37
- const seven = pctOf(rl.seven_day);
38
-
39
- if (five === null && seven === null) {
40
- return `${CLAY}${SPARK}${RESET} ${DIM}claude-jar${RESET}`;
41
- }
42
-
43
- const parts = [];
44
- if (five !== null) {
45
- const slots = 8;
46
- const filled = Math.round((five / 100) * slots);
47
- const color = five > 85 ? EMBER : CLAY;
48
- const bar =
49
- `${color}${'\u2588'.repeat(filled)}${RESET}` + `${DIM}${'\u2591'.repeat(slots - filled)}${RESET}`;
50
- parts.push(`${color}${SPARK}${RESET} ${bar} ${BOLD}${Math.round(five)}%${RESET}`);
51
- const reset = countdown(rl.five_hour?.resets_at);
52
- if (reset) parts.push(`${DIM}\u21bb ${reset}${RESET}`);
53
- }
54
- if (seven !== null) {
55
- const sevenStr = seven > 85 ? `${EMBER}${Math.round(seven)}%${RESET}` : `${Math.round(seven)}%`;
56
- parts.push(`${DIM}7d${RESET} ${sevenStr}`);
57
- }
58
- return parts.join(` ${DIM}\u00b7${RESET} `);
59
- }
60
-
61
- export async function runStatusline() {
62
- let raw = '';
63
- for await (const chunk of process.stdin) raw += chunk;
64
- let input = null;
65
- try {
66
- input = JSON.parse(raw);
67
- } catch {
68
- /* no/bad input: print the idle form */
69
- }
70
- process.stdout.write(formatStatusline(input) + '\n');
71
- }
1
+ // `claude-cup statusline`: one-line meter for Claude Code's statusline.
2
+ // Claude Code (>= 2.1.80) pipes session JSON to the statusline command on
3
+ // stdin, including rate_limits for Pro/Max accounts. We parse it and print
4
+ // a single line. No network calls - this runs on every statusline refresh.
5
+ //
6
+ // Glyph language matches Claude Code itself: the ✻ spark, █░ meter,
7
+ // dim · separators. No emoji (renders everywhere, including conhost).
8
+
9
+ const CLAY = '\x1b[38;2;217;119;87m';
10
+ const EMBER = '\x1b[38;2;198;97;63m';
11
+ const BOLD = '\x1b[1m';
12
+ const DIM = '\x1b[2m';
13
+ const RESET = '\x1b[0m';
14
+ const SPARK = '\u273b'; // ✻
15
+
16
+ // All known fields are percentages (used_percentage 42 = 42%, utilization 1 = 1%).
17
+ function pctOf(bucket) {
18
+ if (!bucket || typeof bucket !== 'object') return null;
19
+ const v = bucket.used_percentage ?? bucket.utilization ?? bucket.percent;
20
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
21
+ return Math.max(0, Math.min(100, v));
22
+ }
23
+
24
+ function countdown(resetsAt) {
25
+ if (!resetsAt) return null;
26
+ const ms = Date.parse(resetsAt) - Date.now();
27
+ if (!Number.isFinite(ms) || ms <= 0) return null;
28
+ const h = Math.floor(ms / 3600000);
29
+ const m = Math.floor((ms % 3600000) / 60000);
30
+ return h > 0 ? `${h}h${m}m` : `${m}m`;
31
+ }
32
+
33
+ /** Pure formatter (exported for tests). Returns the statusline string. */
34
+ export function formatStatusline(input) {
35
+ const rl = input?.rate_limits || {};
36
+ const five = pctOf(rl.five_hour);
37
+ const seven = pctOf(rl.seven_day);
38
+
39
+ if (five === null && seven === null) {
40
+ return `${CLAY}${SPARK}${RESET} ${DIM}claude-cup${RESET}`;
41
+ }
42
+
43
+ const parts = [];
44
+ if (five !== null) {
45
+ const slots = 8;
46
+ const filled = Math.round((five / 100) * slots);
47
+ const color = five > 85 ? EMBER : CLAY;
48
+ const bar =
49
+ `${color}${'\u2588'.repeat(filled)}${RESET}` + `${DIM}${'\u2591'.repeat(slots - filled)}${RESET}`;
50
+ parts.push(`${color}${SPARK}${RESET} ${bar} ${BOLD}${Math.round(five)}%${RESET}`);
51
+ const reset = countdown(rl.five_hour?.resets_at);
52
+ if (reset) parts.push(`${DIM}\u21bb ${reset}${RESET}`);
53
+ }
54
+ if (seven !== null) {
55
+ const sevenStr = seven > 85 ? `${EMBER}${Math.round(seven)}%${RESET}` : `${Math.round(seven)}%`;
56
+ parts.push(`${DIM}7d${RESET} ${sevenStr}`);
57
+ }
58
+ return parts.join(` ${DIM}\u00b7${RESET} `);
59
+ }
60
+
61
+ export async function runStatusline() {
62
+ let raw = '';
63
+ for await (const chunk of process.stdin) raw += chunk;
64
+ let input = null;
65
+ try {
66
+ input = JSON.parse(raw);
67
+ } catch {
68
+ /* no/bad input: print the idle form */
69
+ }
70
+ process.stdout.write(formatStatusline(input) + '\n');
71
+ }
package/src/tui.js CHANGED
@@ -580,6 +580,31 @@ export function composeFrame(state) {
580
580
  g.text(sx + 15, y, String(stats.buildRate ?? 0), { fg: CLAY, bold: true });
581
581
  y++;
582
582
  }
583
+ // Leaderboard rows -- only when we have rank data
584
+ const lbState = state.leaderboard;
585
+ if (lbState && lbState.rank > 0) {
586
+ if (y < rows - 2) {
587
+ g.text(sx, y, 'RANK'.padEnd(15), eyebrow);
588
+ g.text(sx + 15, y, `#${fmt(lbState.rank)} / ${fmt(lbState.total)}`, { bold: true });
589
+ y++;
590
+ }
591
+ if (y < rows - 2 && lbState.nextName) {
592
+ const nearMiss = lbState.gap < 0.3;
593
+ const gapTxt = `\u2191 ${(lbState.gap || 0).toFixed(2)} to ${lbState.nextName}`;
594
+ g.text(sx, y, 'NEXT'.padEnd(15), eyebrow);
595
+ g.text(sx + 15, y, gapTxt.slice(0, colW - 15),
596
+ nearMiss ? { fg: EMBER, bold: true } : { bold: true });
597
+ y++;
598
+ }
599
+ if (y < rows - 2 && lbState.tier) {
600
+ const tierColor = lbState.tier === 'Elite Builder' ? GOLD :
601
+ lbState.tier === 'Master Builder' ? CLAY :
602
+ lbState.tier === 'Rising Builder' ? KRAFT : CLOUD;
603
+ g.text(sx, y, 'TIER'.padEnd(15), eyebrow);
604
+ g.text(sx + 15, y, lbState.tier, { fg: tierColor, bold: true });
605
+ y++;
606
+ }
607
+ }
583
608
  if (y < rows - 2) {
584
609
  const GREEN = [76, 175, 80];
585
610
  g.text(sx, y, 'ECO MODE'.padEnd(15), { fg: GREEN });
@@ -663,6 +688,7 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
663
688
  let mascotAnim = null; // { name, frame, frames } | null
664
689
  let lastAnim = null;
665
690
  let nextAnimAt = Date.now() + nextAnimDelay();
691
+ let prevLb = null; // last leaderboard snapshot, for celebration triggers
666
692
 
667
693
  const spawn = () => {
668
694
  if (falling.length > 26) return;
@@ -695,7 +721,21 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
695
721
  const hasLimit = usage && usage.fiveHour && (usage.status === 'ok' || usage.status === 'stale');
696
722
  const lb = typeof getLeaderboard === 'function' ? getLeaderboard() : null;
697
723
  const br = stats.buildRate ?? 0;
698
- const fill = Math.min(95, Math.round(Math.sqrt(br) * 42));
724
+ const fill = (lb && lb.percentile > 0)
725
+ ? lb.percentile
726
+ : Math.min(95, Math.round(Math.sqrt(br) * 42));
727
+
728
+ // Celebration: rank improved by 10+ OR tier upgraded -> fire goal animation
729
+ if (lb && lb.rank > 0) {
730
+ const tierOrder = { 'Builder': 0, 'Rising Builder': 1, 'Master Builder': 2, 'Elite Builder': 3 };
731
+ const tierUp = prevLb && tierOrder[lb.tier] > tierOrder[prevLb.tier];
732
+ const rankJump = prevLb && prevLb.rank > 0 && (prevLb.rank - lb.rank) >= 10;
733
+ if (!mascotAnim && (tierUp || rankJump)) {
734
+ mascotAnim = { name: 'goal', frame: 0, frames: ANIM_FRAMES.goal };
735
+ lastAnim = 'goal';
736
+ }
737
+ prevLb = lb;
738
+ }
699
739
 
700
740
  const rows = Math.min(out.rows || 24, 32);
701
741
  const bodyH = bodyHeightFor(rows);
@@ -767,6 +807,7 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
767
807
  url,
768
808
  colorMode,
769
809
  mascot: mascotAnim,
810
+ leaderboard: lb,
770
811
  powerLevel: power.powerLevel,
771
812
  richness: power.richness,
772
813
  });