@statforge/claudestat 1.2.0 → 1.2.2

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.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  "use strict";
3
3
  /**
4
4
  * mcp-server.ts — MCP (Model Context Protocol) server for claudestat
@@ -50,9 +50,10 @@ const readline = __importStar(require("readline"));
50
50
  const db_1 = require("./db");
51
51
  const quota_tracker_1 = require("./quota-tracker");
52
52
  const insights_1 = require("./insights");
53
+ const config_1 = require("./config");
53
54
  const SERVER_NAME = 'claudestat';
54
- const SERVER_VERSION = '1.2.0';
55
- const PROTOCOL_VERSION = '2025-06-18';
55
+ const SERVER_VERSION = '1.2.2';
56
+ const PROTOCOL_VERSION = '2025-03-26';
56
57
  const TOOLS = [
57
58
  {
58
59
  name: 'get_quota_status',
@@ -104,6 +105,34 @@ const TOOLS = [
104
105
  required: []
105
106
  }
106
107
  },
108
+ {
109
+ name: 'get_usage_insights',
110
+ description: 'Get unique usage insights not available in /usage: cost per project, cache savings, output/input ratio, efficiency trend, and peak hours',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ days: {
115
+ type: 'number',
116
+ description: 'Days to look back (default 7)'
117
+ }
118
+ },
119
+ required: []
120
+ }
121
+ },
122
+ {
123
+ name: 'get_model_breakdown',
124
+ description: 'Get cost and session count broken down by Claude model (Sonnet, Haiku, Opus) for the last N days',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ days: {
129
+ type: 'number',
130
+ description: 'Days to look back (default 7)'
131
+ }
132
+ },
133
+ required: []
134
+ }
135
+ },
107
136
  {
108
137
  name: 'get_weekly_insight',
109
138
  description: 'Get the weekly usage summary with an actionable tip (same as claudestat weekly command)',
@@ -141,10 +170,13 @@ function toolGetQuotaStatus() {
141
170
  : `${resetMin}m`;
142
171
  const weeklyTotalHours = q.weeklyHoursSonnet + q.weeklyHoursOpus;
143
172
  const weeklyLimitTotal = q.weeklyLimitSonnet + q.weeklyLimitOpus;
173
+ const planLabel = q.planSource === 'inferred'
174
+ ? `${q.detectedPlan.toUpperCase()} plan (unverified — checking API...)`
175
+ : `${q.detectedPlan.toUpperCase()} plan`;
144
176
  const parts = [
145
- `Quota status — ${q.detectedPlan.toUpperCase()} plan`,
177
+ `Quota status — ${planLabel}`,
146
178
  ``,
147
- `5h cycle: ${q.cyclePrompts}/${q.cycleLimit} prompts (${q.cyclePct}%) · resets in ${resetLabel}`,
179
+ `5h cycle: ${q.cyclePct}% · ${q.cyclePrompts > q.cycleLimit ? `${q.cyclePrompts}/${q.cycleLimit} prompts (OVER LIMIT)` : `${q.cyclePrompts}/${q.cycleLimit} prompts`} · resets in ${resetLabel}`,
148
180
  `Weekly: ${weeklyTotalHours}h / ${weeklyLimitTotal}h (${q.weeklyPctAll}%)`,
149
181
  ];
150
182
  if (q.weeklyLimitOpus > 0) {
@@ -154,6 +186,33 @@ function toolGetQuotaStatus() {
154
186
  if (q.burnRateTokensPerMin > 0) {
155
187
  parts.push(`Burn rate: ${q.burnRateTokensPerMin.toLocaleString()} tokens/min`);
156
188
  }
189
+ // Active alerts — only shown when thresholds are crossed
190
+ const cfg = (0, config_1.readConfig)();
191
+ if (cfg.alertsEnabled) {
192
+ const alerts = [];
193
+ const cycleLevel = (0, config_1.getWarnLevel)(q.cyclePct, cfg.warnThresholds);
194
+ const weeklyLevel = (0, config_1.getWarnLevel)(q.weeklyPctAll, cfg.weeklyWarnThresholds);
195
+ if (cycleLevel === 'red')
196
+ alerts.push(`🔴 5h cycle at ${q.cyclePct}% — critical, limit imminent`);
197
+ else if (cycleLevel)
198
+ alerts.push(`⚠️ 5h cycle at ${q.cyclePct}% — approaching limit`);
199
+ if (weeklyLevel === 'red')
200
+ alerts.push(`🔴 Weekly at ${q.weeklyPctAll}% — critical`);
201
+ else if (weeklyLevel)
202
+ alerts.push(`⚠️ Weekly at ${q.weeklyPctAll}% — approaching weekly limit`);
203
+ if (q.cyclePrompts > q.cycleLimit) {
204
+ alerts.push(`⚠️ Prompt count (${q.cyclePrompts}) exceeds plan limit (${q.cycleLimit}) — plan may be mis-detected`);
205
+ }
206
+ const reminderMins = cfg.resetReminderMins ?? 10;
207
+ if (reminderMins > 0 && resetMin <= reminderMins && resetMin > 0) {
208
+ alerts.push(`⏰ Cycle resets in ${resetMin}m — good time to wrap up or start fresh`);
209
+ }
210
+ if (alerts.length > 0) {
211
+ parts.push(``);
212
+ parts.push(`─── ACTIVE ALERTS ───────────────────────`);
213
+ parts.push(...alerts);
214
+ }
215
+ }
157
216
  return parts.join('\n');
158
217
  }
159
218
  function toolGetCurrentSession() {
@@ -221,6 +280,71 @@ function toolGetTopTools(days, sortBy) {
221
280
  }
222
281
  return lines.join('\n');
223
282
  }
283
+ function toolGetUsageInsights(days) {
284
+ const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
285
+ const i = (0, insights_1.getUsageInsights)(d);
286
+ if (i.total_sessions === 0)
287
+ return `No data for the last ${d} days.`;
288
+ const bar = (pct, width = 20) => '█'.repeat(Math.round(pct / 100 * width)) + '░'.repeat(width - Math.round(pct / 100 * width));
289
+ const lines = [];
290
+ lines.push(`💡 Usage insights — last ${d} days`);
291
+ lines.push('━'.repeat(44));
292
+ lines.push(``);
293
+ lines.push(` 💰 ${fmtDollar(i.avg_cost_per_session)}/session · ${i.total_sessions} sessions · ${fmtDollar(i.total_cost)} total`);
294
+ if (i.project_costs.length > 0) {
295
+ lines.push(``);
296
+ lines.push(` 🗂 Top projects`);
297
+ const topTotal = i.project_costs.reduce((s, p) => s + p.total_cost, 0);
298
+ for (const p of i.project_costs.slice(0, 4)) {
299
+ const pct = topTotal > 0 ? Math.round(p.total_cost / topTotal * 100) : 0;
300
+ const name = (p.project.split('/').pop() ?? p.project).slice(0, 14).padEnd(14);
301
+ lines.push(` ${name} ${bar(pct)} ${fmtDollar(p.total_cost)} ${pct}%`);
302
+ }
303
+ }
304
+ lines.push(``);
305
+ lines.push(` ⚡ Cache ~${fmtDollar(i.cache_savings_usd)} saved · ${i.cache_hit_pct}% hit rate`);
306
+ lines.push(``);
307
+ lines.push(` 📊 ${i.output_input_ratio}× output/input · ${i.ratio_label}`);
308
+ lines.push(``);
309
+ const effTrend = i.efficiency_delta !== -999
310
+ ? ` ${i.efficiency_delta > 0 ? `↑ +${i.efficiency_delta}` : i.efficiency_delta < 0 ? `↓ ${i.efficiency_delta}` : '→ same'} vs prev period`
311
+ : '';
312
+ lines.push(` 📈 Efficiency ${i.avg_efficiency}/100${effTrend} · ${i.total_loops} loops`);
313
+ if (i.hour_ranges.length > 0) {
314
+ lines.push(``);
315
+ lines.push(` ⏰ Activity by time of day`);
316
+ const maxCount = Math.max(...i.hour_ranges.map(r => r.count));
317
+ for (let j = 0; j < i.hour_ranges.length; j++) {
318
+ const r = i.hour_ranges[j];
319
+ const pct = maxCount > 0 ? Math.round(r.count / maxCount * 100) : 0;
320
+ lines.push(` ${r.emoji} ${r.from}–${r.to} ${bar(pct)} ${r.count} sessions`);
321
+ if (j < i.hour_ranges.length - 1)
322
+ lines.push('');
323
+ }
324
+ }
325
+ lines.push(``);
326
+ lines.push('━'.repeat(44));
327
+ return lines.join('\n');
328
+ }
329
+ function toolGetModelBreakdown(days) {
330
+ const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
331
+ const models = db_1.dbOps.getModelBreakdown(d);
332
+ if (models.length === 0)
333
+ return `No model data in the last ${d} days.`;
334
+ const totalCost = models.reduce((s, m) => s + m.total_cost, 0);
335
+ const lines = [
336
+ `Model breakdown — last ${d} days`,
337
+ '',
338
+ ];
339
+ for (const m of models) {
340
+ const pct = totalCost > 0 ? Math.round(m.total_cost / totalCost * 100) : 0;
341
+ const rawName = (m.model ?? 'unknown').replace(/^<|>$/g, '');
342
+ const name = rawName.padEnd(30);
343
+ const cost = fmtDollar(m.total_cost).padEnd(10);
344
+ lines.push(` ${name}${cost}${pct}% ${m.session_count} sessions`);
345
+ }
346
+ return lines.join('\n');
347
+ }
224
348
  function toolGetWeeklyInsight(days) {
225
349
  const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
226
350
  const data = (0, insights_1.getWeeklyInsightData)(d);
@@ -239,25 +363,25 @@ function toolGetWeeklyInsight(days) {
239
363
  `Tip: ${(0, insights_1.generateTip)(data)}`,
240
364
  ].join('\n');
241
365
  }
242
- function handleToolCall(name, args) {
243
- const days = typeof args.days === 'number' ? args.days : 7;
366
+ async function handleToolCall(name, args) {
244
367
  const sortBy = typeof args.sort_by === 'string' ? args.sort_by : 'cost';
245
368
  switch (name) {
246
- case 'get_quota_status': return toolGetQuotaStatus();
369
+ case 'get_quota_status':
370
+ await (0, quota_tracker_1.refreshFromApi)();
371
+ return toolGetQuotaStatus();
247
372
  case 'get_current_session': return toolGetCurrentSession();
248
- case 'get_session_stats': return toolGetSessionStats(days);
249
- case 'get_top_tools': return toolGetTopTools(days, sortBy);
250
- case 'get_weekly_insight': return toolGetWeeklyInsight(days);
373
+ case 'get_session_stats': return toolGetSessionStats(typeof args.days === 'number' ? args.days : 7);
374
+ case 'get_top_tools': return toolGetTopTools(typeof args.days === 'number' ? args.days : 30, sortBy);
375
+ case 'get_usage_insights': return toolGetUsageInsights(typeof args.days === 'number' ? args.days : 7);
376
+ case 'get_model_breakdown': return toolGetModelBreakdown(typeof args.days === 'number' ? args.days : 7);
377
+ case 'get_weekly_insight': return toolGetWeeklyInsight(typeof args.days === 'number' ? args.days : 7);
251
378
  default: return `Unknown tool: ${name}`;
252
379
  }
253
380
  }
254
- function handleRequest(msg) {
381
+ async function handleRequest(msg) {
255
382
  const { id, method, params } = msg;
256
- if (id === undefined) {
257
- if (method === 'notifications/initialized')
258
- return null;
383
+ if (id === undefined)
259
384
  return null;
260
- }
261
385
  try {
262
386
  switch (method) {
263
387
  case 'initialize':
@@ -274,7 +398,7 @@ function handleRequest(msg) {
274
398
  case 'tools/call': {
275
399
  const toolName = params?.name;
276
400
  const toolArgs = (params?.arguments ?? {});
277
- const text = handleToolCall(toolName, toolArgs);
401
+ const text = await handleToolCall(toolName, toolArgs);
278
402
  return {
279
403
  jsonrpc: '2.0', id,
280
404
  result: { content: [{ type: 'text', text }], isError: false }
@@ -301,10 +425,12 @@ rl.on('line', (line) => {
301
425
  return;
302
426
  try {
303
427
  const msg = JSON.parse(trimmed);
304
- const response = handleRequest(msg);
305
- if (response) {
306
- process.stdout.write(JSON.stringify(response) + '\n');
307
- }
428
+ handleRequest(msg).then(response => {
429
+ if (response)
430
+ process.stdout.write(JSON.stringify(response) + '\n');
431
+ }).catch((e) => {
432
+ process.stderr.write(`[claudestat-mcp] Handler error: ${e.message}\n`);
433
+ });
308
434
  }
309
435
  catch (e) {
310
436
  process.stderr.write(`[claudestat-mcp] Parse error: ${e.message}\n`);
@@ -312,4 +438,5 @@ rl.on('line', (line) => {
312
438
  });
313
439
  process.on('SIGTERM', () => process.exit(0));
314
440
  process.on('SIGINT', () => process.exit(0));
441
+ // API quota is refreshed on-demand per get_quota_status call (disk cache throttles to 1 call/5min)
315
442
  process.stderr.write(`[claudestat-mcp] Server ready (stdio, protocol ${PROTOCOL_VERSION})\n`);
package/dist/notifier.js CHANGED
@@ -8,15 +8,33 @@ const child_process_1 = require("child_process");
8
8
  const os_1 = __importDefault(require("os"));
9
9
  function sendDesktopNotification(title, body) {
10
10
  try {
11
- if (os_1.default.platform() === 'darwin') {
12
- const escaped = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
13
- (0, child_process_1.execSync)(`osascript -e 'display notification "${escaped}" with title "${title}"'`, { stdio: 'ignore' });
11
+ const platform = os_1.default.platform();
12
+ if (platform === 'darwin') {
13
+ const t = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
14
+ const b = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
15
+ (0, child_process_1.execSync)(`osascript -e 'display notification "${b}" with title "${t}"'`, { stdio: 'ignore', timeout: 5000 });
14
16
  }
15
- else if (os_1.default.platform() === 'linux') {
16
- (0, child_process_1.execSync)(`notify-send "${title}" "${body}"`, { stdio: 'ignore' });
17
+ else if (platform === 'linux') {
18
+ (0, child_process_1.execSync)(`notify-send "${title}" "${body}"`, { stdio: 'ignore', timeout: 5000 });
19
+ }
20
+ else if (platform === 'win32') {
21
+ // UTF-16LE base64-encoded PowerShell to handle any special characters safely
22
+ const script = [
23
+ `Add-Type -AssemblyName System.Windows.Forms`,
24
+ `$n = New-Object System.Windows.Forms.NotifyIcon`,
25
+ `$n.Icon = [System.Drawing.SystemIcons]::Information`,
26
+ `$n.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info`,
27
+ `$n.BalloonTipTitle = '${title.replace(/'/g, "''")}'`,
28
+ `$n.BalloonTipText = '${body.replace(/'/g, "''")}'`,
29
+ `$n.Visible = $true`,
30
+ `$n.ShowBalloonTip(5000)`,
31
+ `Start-Sleep -Milliseconds 200`,
32
+ ].join('; ');
33
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
34
+ (0, child_process_1.execSync)(`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`, { stdio: 'ignore', timeout: 8000 });
17
35
  }
18
36
  }
19
37
  catch {
20
- // notification not available already logged to console
38
+ // notification unavailablesilent fallback
21
39
  }
22
40
  }
@@ -12,6 +12,7 @@
12
12
  * - High cache ratio → positive: great cost efficiency
13
13
  * - High avg cost → consider Haiku for simpler tasks
14
14
  * - Low efficiency → linked to loops
15
+ * - Write vs Edit → suggest Edit for incremental changes
15
16
  */
16
17
  export type InsightLevel = 'tip' | 'warning' | 'positive';
17
18
  export interface PatternInsight {
@@ -13,6 +13,7 @@
13
13
  * - High cache ratio → positive: great cost efficiency
14
14
  * - High avg cost → consider Haiku for simpler tasks
15
15
  * - Low efficiency → linked to loops
16
+ * - Write vs Edit → suggest Edit for incremental changes
16
17
  */
17
18
  Object.defineProperty(exports, "__esModule", { value: true });
18
19
  exports.analyzePatterns = analyzePatterns;
@@ -52,6 +53,18 @@ function analyzePatterns(toolCounts, stats) {
52
53
  metric: `${bashPct}% Bash (${bashCount}) vs ${readGrep} Read+Grep+Glob`,
53
54
  });
54
55
  }
56
+ // ── Heavy Write vs Edit ────────────────────────────────────────────────────
57
+ const writeCount = get('Write');
58
+ const editCount = get('Edit');
59
+ const writePct = Math.round(writeCount / totalTools * 100);
60
+ if (writeCount > editCount * 3 && writeCount >= 10) {
61
+ insights.push({
62
+ level: 'tip',
63
+ title: 'Heavy Write vs Edit usage',
64
+ description: 'More than 3× Write calls vs Edit calls. Editing existing files is cheaper in tokens than writing from scratch. Consider using Edit for incremental changes instead of rewriting entire files.',
65
+ metric: `${writePct}% Write (${writeCount} writes vs ${editCount} edits)`,
66
+ });
67
+ }
55
68
  // ── High loop rate ────────────────────────────────────────────────────────
56
69
  if (stats.avg_loops >= 1.5) {
57
70
  insights.push({
@@ -38,6 +38,12 @@ export interface QuotaData {
38
38
  planSource: 'config' | 'keychain' | 'inferred';
39
39
  computedAt: number;
40
40
  }
41
+ /**
42
+ * Llama a la API de Anthropic para obtener los % de quota exactos que muestra claude.ai.
43
+ * Actualiza apiCache si la llamada tiene éxito; de lo contrario, no hace nada (silent fallback).
44
+ * Debe llamarse periódicamente desde el daemon y al inicio del MCP server.
45
+ */
46
+ export declare function refreshFromApi(): Promise<void>;
41
47
  /**
42
48
  * Calcula y retorna QuotaData.
43
49
  * Usa caché de 30s para no re-leer todos los JSONL en cada request del dashboard.
@@ -19,6 +19,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
19
19
  return (mod && mod.__esModule) ? mod : { "default": mod };
20
20
  };
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.refreshFromApi = refreshFromApi;
22
23
  exports.computeQuota = computeQuota;
23
24
  exports.invalidateQuotaCache = invalidateQuotaCache;
24
25
  const fs_1 = __importDefault(require("fs"));
@@ -35,33 +36,6 @@ const PLAN_LIMITS = {
35
36
  const CYCLE_MS = 5 * 60 * 60 * 1000; // 5 horas en ms
36
37
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
37
38
  const WINDOW_5MIN = 5 * 60 * 1000; // ventana de 5 min para agrupar actividad por modelo
38
- /**
39
- * Calcula el timestamp de reset usando ventana rolling real.
40
- *
41
- * Claude Code NO usa floor(now/5h) desde epoch UTC — usa una ventana rolling
42
- * que empieza desde el primer mensaje del ciclo actual.
43
- *
44
- * Enfoque: buscar el primer mensaje humano en los últimos 5h de actividad.
45
- * resetAt = primerMensaje.ts + 5h
46
- *
47
- * Si no hay mensajes en las últimas 5h → el ciclo ya reseteó, el próximo
48
- * reset es en 5h desde el primer mensaje futuro (mostramos ~5h).
49
- */
50
- function computeResetAt(entries, now) {
51
- const fiveHoursAgo = now - CYCLE_MS;
52
- const recentHuman = entries
53
- .filter(e => e.type === 'human' && e.ts >= fiveHoursAgo)
54
- .sort((a, b) => a.ts - b.ts);
55
- if (recentHuman.length > 0) {
56
- // Usamos el PRIMER mensaje humano (más antiguo) + 5h
57
- // = momento en que el primer prompt de la ventana actual expira → cuota empieza a liberarse
58
- // Los mensajes tool_result de sub-agentes ya están filtrados en el caller
59
- return recentHuman[0].ts + CYCLE_MS;
60
- }
61
- // Sin actividad en las últimas 5h → cuota ya libre, no hay reset pendiente
62
- // Retornar `now` hace que cycleResetMs = 0, la UI puede mostrar "Disponible"
63
- return now;
64
- }
65
39
  function getWeekStart(now) {
66
40
  // Lunes 00:00 hora local
67
41
  const d = new Date(now);
@@ -210,6 +184,63 @@ function readAllEntries(sinceTs) {
210
184
  catch { /* PROJECTS_DIR inaccesible */ }
211
185
  return all;
212
186
  }
187
+ // ─── API de uso de Anthropic (datos exactos = claude.ai/settings/usage) ──────
188
+ const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage';
189
+ const USAGE_API_BETA = 'oauth-2025-04-20';
190
+ const API_CACHE_TTL = 5 * 60000; // 5 minutes
191
+ const API_DISK_CACHE = path_1.default.join((0, paths_1.getClaudestatDir)(), 'api-cache.json');
192
+ let apiCache = null;
193
+ /**
194
+ * Llama a la API de Anthropic para obtener los % de quota exactos que muestra claude.ai.
195
+ * Actualiza apiCache si la llamada tiene éxito; de lo contrario, no hace nada (silent fallback).
196
+ * Debe llamarse periódicamente desde el daemon y al inicio del MCP server.
197
+ */
198
+ async function refreshFromApi() {
199
+ // Shared disk cache: all processes (daemon + MCP) read/write the same file.
200
+ // This ensures at most 1 API call per API_CACHE_TTL across all processes.
201
+ try {
202
+ const raw = fs_1.default.readFileSync(API_DISK_CACHE, 'utf8');
203
+ const disk = JSON.parse(raw);
204
+ if (disk && Date.now() - disk.ts < API_CACHE_TTL) {
205
+ apiCache = disk;
206
+ invalidateQuotaCache();
207
+ return;
208
+ }
209
+ }
210
+ catch { /* no disk cache yet — proceed to API */ }
211
+ const token = (0, claude_auth_1.getOAuthAccessToken)();
212
+ if (!token)
213
+ return;
214
+ try {
215
+ const ctrl = new AbortController();
216
+ const timer = setTimeout(() => ctrl.abort(), 3000);
217
+ const res = await fetch(USAGE_API_URL, {
218
+ headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': USAGE_API_BETA },
219
+ signal: ctrl.signal,
220
+ });
221
+ clearTimeout(timer);
222
+ if (!res.ok) {
223
+ process.stderr.write(`[claudestat] API quota fetch failed: HTTP ${res.status}\n`);
224
+ return;
225
+ }
226
+ const data = await res.json();
227
+ const fh = data.five_hour;
228
+ if (fh == null)
229
+ return;
230
+ apiCache = {
231
+ cyclePct: Math.round(fh.utilization),
232
+ weeklyPctAll: data.seven_day != null ? Math.round(data.seven_day.utilization) : 0,
233
+ cycleResetAt: fh.resets_at ? new Date(fh.resets_at).getTime() : 0,
234
+ ts: Date.now(),
235
+ };
236
+ try {
237
+ fs_1.default.writeFileSync(API_DISK_CACHE, JSON.stringify(apiCache), 'utf8');
238
+ }
239
+ catch { }
240
+ invalidateQuotaCache();
241
+ }
242
+ catch { /* no network or keychain — silent fallback to JSONL */ }
243
+ }
213
244
  // ─── Caché de 30 segundos ─────────────────────────────────────────────────────
214
245
  let cache = null;
215
246
  const CACHE_TTL = 30000; // 30 segundos
@@ -225,6 +256,16 @@ function computeQuota(forcePlan) {
225
256
  if (!forcePlan && cache && now - cache.ts < CACHE_TTL) {
226
257
  return cache.data;
227
258
  }
259
+ // Load API data from disk cache if in-memory is stale (shared across processes)
260
+ if (!apiCache || now - apiCache.ts >= API_CACHE_TTL) {
261
+ try {
262
+ const raw = fs_1.default.readFileSync(API_DISK_CACHE, 'utf8');
263
+ const disk = JSON.parse(raw);
264
+ if (disk && now - disk.ts < API_CACHE_TTL)
265
+ apiCache = disk;
266
+ }
267
+ catch { }
268
+ }
228
269
  const weekStart = getWeekStart(now);
229
270
  const thirtyMinAgo = now - 30 * 60 * 1000;
230
271
  // Leer entradas relevantes (última semana + un poco más para detección de plan)
@@ -251,7 +292,7 @@ function computeQuota(forcePlan) {
251
292
  const limits = PLAN_LIMITS[plan];
252
293
  // ─ Ciclo 5h: ventana deslizante [now-5h, now] ─
253
294
  const fiveHAgo = now - CYCLE_MS;
254
- const cycleResetAt = computeResetAt(entries, now);
295
+ const cycleResetAt = (Math.floor(now / CYCLE_MS) + 1) * CYCLE_MS; // epoch-aligned, matches claude.ai
255
296
  const cycleStart = fiveHAgo; // inicio real de la ventana de conteo
256
297
  // Basado en tokens (como claude.ai/settings/usage)
257
298
  const cycleEntries = entries.filter(e => e.ts >= fiveHAgo);
@@ -297,14 +338,20 @@ function computeQuota(forcePlan) {
297
338
  const weeklyPctAll = limits.weeklyHoursSonnet + limits.weeklyHoursOpus > 0
298
339
  ? Math.min(100, Math.round((weeklyHoursSonnet + weeklyHoursOpus) / (limits.weeklyHoursSonnet + limits.weeklyHoursOpus) * 100))
299
340
  : 0;
341
+ // Override with API data if fresh — matches claude.ai exactly
342
+ const apiFresh = apiCache != null && Date.now() - apiCache.ts < API_CACHE_TTL;
343
+ const resolvedCyclePct = apiFresh ? apiCache.cyclePct : cyclePct;
344
+ const resolvedWeeklyPctAll = apiFresh ? apiCache.weeklyPctAll : weeklyPctAll;
345
+ const resolvedCycleResetAt = (apiFresh && apiCache.cycleResetAt > 0) ? apiCache.cycleResetAt : cycleResetAt;
346
+ const resolvedCycleResetMs = Math.max(0, resolvedCycleResetAt - now);
300
347
  const data = {
301
348
  cyclePrompts: cycleEntries.filter(e => e.type === 'human').length,
302
349
  cycleLimit: limits.prompts5h,
303
- cyclePct,
350
+ cyclePct: resolvedCyclePct,
304
351
  cycleTokens,
305
352
  cycleLimitTokens: limits.tokens5h,
306
- cycleResetMs,
307
- cycleResetAt,
353
+ cycleResetMs: resolvedCycleResetMs,
354
+ cycleResetAt: resolvedCycleResetAt,
308
355
  cycleStartTs: cycleStart,
309
356
  weeklyHoursSonnet,
310
357
  weeklyHoursOpus,
@@ -314,7 +361,7 @@ function computeQuota(forcePlan) {
314
361
  weeklyTokensHaiku,
315
362
  weeklyLimitSonnet: limits.weeklyHoursSonnet,
316
363
  weeklyLimitOpus: limits.weeklyHoursOpus,
317
- weeklyPctAll,
364
+ weeklyPctAll: resolvedWeeklyPctAll,
318
365
  burnRateTokensPerMin,
319
366
  detectedPlan: plan,
320
367
  planSource,