@statforge/claudestat 1.0.1
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 +437 -0
- package/dashboard/dist/assets/AnalyticsView-BApcOGsD.js +8 -0
- package/dashboard/dist/assets/HistoryView-B331k5oL.js +1 -0
- package/dashboard/dist/assets/ProjectsView-DUleaXsP.js +6 -0
- package/dashboard/dist/assets/SystemView-BGe__vl1.js +1 -0
- package/dashboard/dist/assets/TopView-CXggyydU.js +1 -0
- package/dashboard/dist/assets/index-CB01c5lb.js +84 -0
- package/dashboard/dist/assets/vendor-lucide-Cym0q5l_.js +344 -0
- package/dashboard/dist/assets/vendor-react-B_Jzs0gY.js +24 -0
- package/dashboard/dist/index.html +21 -0
- package/dist/cache/projects-cache.d.ts +9 -0
- package/dist/cache/projects-cache.js +51 -0
- package/dist/claude-auth.d.ts +38 -0
- package/dist/claude-auth.js +133 -0
- package/dist/claude-stats.d.ts +32 -0
- package/dist/claude-stats.js +98 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +110 -0
- package/dist/daemon.d.ts +15 -0
- package/dist/daemon.js +247 -0
- package/dist/db.d.ts +134 -0
- package/dist/db.js +546 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +191 -0
- package/dist/enricher.d.ts +34 -0
- package/dist/enricher.js +394 -0
- package/dist/export.d.ts +8 -0
- package/dist/export.js +82 -0
- package/dist/git.d.ts +22 -0
- package/dist/git.js +57 -0
- package/dist/github.d.ts +27 -0
- package/dist/github.js +62 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +319 -0
- package/dist/install.d.ts +14 -0
- package/dist/install.js +202 -0
- package/dist/intelligence.d.ts +45 -0
- package/dist/intelligence.js +105 -0
- package/dist/meta-stats.d.ts +28 -0
- package/dist/meta-stats.js +137 -0
- package/dist/middleware/rate-limiter.d.ts +2 -0
- package/dist/middleware/rate-limiter.js +30 -0
- package/dist/notifier.d.ts +1 -0
- package/dist/notifier.js +22 -0
- package/dist/paths.d.ts +79 -0
- package/dist/paths.js +134 -0
- package/dist/pattern-analyzer.d.ts +35 -0
- package/dist/pattern-analyzer.js +123 -0
- package/dist/project-scanner.d.ts +71 -0
- package/dist/project-scanner.js +619 -0
- package/dist/quota-tracker.d.ts +45 -0
- package/dist/quota-tracker.js +320 -0
- package/dist/render.d.ts +55 -0
- package/dist/render.js +229 -0
- package/dist/routes/events.d.ts +18 -0
- package/dist/routes/events.js +272 -0
- package/dist/routes/history.d.ts +1 -0
- package/dist/routes/history.js +65 -0
- package/dist/routes/misc.d.ts +1 -0
- package/dist/routes/misc.js +280 -0
- package/dist/routes/projects.d.ts +15 -0
- package/dist/routes/projects.js +153 -0
- package/dist/routes/reports.d.ts +11 -0
- package/dist/routes/reports.js +205 -0
- package/dist/routes/stream.d.ts +8 -0
- package/dist/routes/stream.js +70 -0
- package/dist/routes/top.d.ts +1 -0
- package/dist/routes/top.js +30 -0
- package/dist/session-state.d.ts +35 -0
- package/dist/session-state.js +50 -0
- package/dist/summarizer.d.ts +18 -0
- package/dist/summarizer.js +137 -0
- package/dist/watch.d.ts +8 -0
- package/dist/watch.js +157 -0
- package/dist/watchdog.d.ts +11 -0
- package/dist/watchdog.js +75 -0
- package/dist/weekly.d.ts +13 -0
- package/dist/weekly.js +39 -0
- package/hooks/event.js +80 -0
- package/package.json +78 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* claude-auth.ts — Lee las credenciales OAuth de Claude Code desde el sistema.
|
|
4
|
+
*
|
|
5
|
+
* macOS: las credenciales se guardan en el keychain del sistema con el servicio
|
|
6
|
+
* "Claude Code-credentials". El campo "claudeAiOauth" contiene el token
|
|
7
|
+
* OAuth, su expiración, scopes y el tipo de suscripción del plan.
|
|
8
|
+
*
|
|
9
|
+
* Linux/Windows: Claude Code puede guardar las credenciales en
|
|
10
|
+
* ~/.config/Claude/credentials.json (Electron userData path)
|
|
11
|
+
* o en el keyring del sistema.
|
|
12
|
+
*
|
|
13
|
+
* Propósito: obtener el `subscriptionType` y `rateLimitTier` del plan real del
|
|
14
|
+
* usuario, sin inferirlo del máximo histórico de prompts en los JSONL.
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.readClaudeAuth = readClaudeAuth;
|
|
21
|
+
exports.subscriptionTypeToPlan = subscriptionTypeToPlan;
|
|
22
|
+
const child_process_1 = require("child_process");
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const os_1 = __importDefault(require("os"));
|
|
26
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
27
|
+
/** Caché en memoria de 5 minutos */
|
|
28
|
+
let cache = null;
|
|
29
|
+
const CACHE_TTL = 5 * 60000;
|
|
30
|
+
// ─── Leer desde macOS Keychain ────────────────────────────────────────────────
|
|
31
|
+
function readFromKeychain() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = (0, child_process_1.execSync)(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, { stdio: ['pipe', 'pipe', 'pipe'], timeout: parseInt(process.env.CLAUDESTAT_KEYCHAIN_TIMEOUT ?? '3000', 10) }).toString().trim();
|
|
34
|
+
if (!raw)
|
|
35
|
+
return null;
|
|
36
|
+
// El valor puede ser JSON directo o base64
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(Buffer.from(raw, 'base64').toString());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
const oa = parsed?.claudeAiOauth;
|
|
45
|
+
if (!oa)
|
|
46
|
+
return null;
|
|
47
|
+
return {
|
|
48
|
+
subscriptionType: (oa.subscriptionType ?? 'unknown').toLowerCase(),
|
|
49
|
+
rateLimitTier: oa.rateLimitTier ?? 'unknown',
|
|
50
|
+
expiresAt: oa.expiresAt ?? 0,
|
|
51
|
+
tokenValid: Date.now() < (oa.expiresAt ?? 0),
|
|
52
|
+
source: 'keychain',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ─── Leer desde archivo (Linux / Electron userData) ──────────────────────────
|
|
60
|
+
function readFromFile() {
|
|
61
|
+
const homeDir = os_1.default.homedir();
|
|
62
|
+
const candidates = [
|
|
63
|
+
// Windows Credential Manager / Electron userData
|
|
64
|
+
path_1.default.join(process.env.APPDATA ?? path_1.default.join(homeDir, 'AppData', 'Roaming'), 'Claude', 'credentials.json'),
|
|
65
|
+
path_1.default.join(process.env.LOCALAPPDATA ?? path_1.default.join(homeDir, 'AppData', 'Local'), 'Claude', 'credentials.json'),
|
|
66
|
+
// Linux Electron userData
|
|
67
|
+
path_1.default.join(homeDir, '.config', 'Claude', 'credentials.json'),
|
|
68
|
+
path_1.default.join(homeDir, '.config', 'Claude', '.credentials.json'),
|
|
69
|
+
// macOS Electron userData fallback
|
|
70
|
+
path_1.default.join(homeDir, 'Library', 'Application Support', 'Claude', 'credentials.json'),
|
|
71
|
+
];
|
|
72
|
+
for (const p of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
const raw = fs_1.default.readFileSync(p, 'utf8');
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
const oa = parsed?.claudeAiOauth ?? parsed;
|
|
77
|
+
if (!oa?.subscriptionType)
|
|
78
|
+
continue;
|
|
79
|
+
return {
|
|
80
|
+
subscriptionType: (oa.subscriptionType ?? 'unknown').toLowerCase(),
|
|
81
|
+
rateLimitTier: oa.rateLimitTier ?? 'unknown',
|
|
82
|
+
expiresAt: oa.expiresAt ?? 0,
|
|
83
|
+
tokenValid: Date.now() < (oa.expiresAt ?? 0),
|
|
84
|
+
source: 'file',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
// ─── API pública ──────────────────────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Lee las credenciales de autenticación de Claude Code.
|
|
96
|
+
* Intenta: keychain (macOS) → archivo → unknown.
|
|
97
|
+
* Resultado cacheado 5 minutos para no golpear el keychain en cada request.
|
|
98
|
+
*/
|
|
99
|
+
function readClaudeAuth() {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (cache && now - cache.ts < CACHE_TTL)
|
|
102
|
+
return cache.data;
|
|
103
|
+
const info = (os_1.default.platform() === 'darwin' ? readFromKeychain() : null)
|
|
104
|
+
?? readFromFile()
|
|
105
|
+
?? { subscriptionType: 'unknown', rateLimitTier: 'unknown', expiresAt: 0, tokenValid: false, source: 'unknown' };
|
|
106
|
+
cache = { data: info, ts: now };
|
|
107
|
+
return info;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Mapea el subscriptionType de las credenciales al ClaudePlan usado por quota-tracker.
|
|
111
|
+
*
|
|
112
|
+
* Valores conocidos de Claude Code:
|
|
113
|
+
* "free" → Free plan (10 prompts/5h)
|
|
114
|
+
* "pro" → Pro plan (45 prompts/5h)
|
|
115
|
+
* "max" → Max plan — puede ser max5 o max20, diferenciado por rateLimitTier
|
|
116
|
+
* "max_5" → Max 5× (225 prompts/5h)
|
|
117
|
+
* "max_20" → Max 20× (900 prompts/5h)
|
|
118
|
+
*/
|
|
119
|
+
function subscriptionTypeToPlan(subscriptionType, rateLimitTier) {
|
|
120
|
+
const sub = subscriptionType.toLowerCase();
|
|
121
|
+
const tier = rateLimitTier.toLowerCase();
|
|
122
|
+
if (sub.includes('max_20') || tier.includes('max_20'))
|
|
123
|
+
return 'max20';
|
|
124
|
+
if (sub.includes('max_5') || tier.includes('max_5'))
|
|
125
|
+
return 'max5';
|
|
126
|
+
if (sub === 'max' || tier.includes('max'))
|
|
127
|
+
return 'max5'; // conservador
|
|
128
|
+
if (sub === 'pro')
|
|
129
|
+
return 'pro';
|
|
130
|
+
if (sub === 'free')
|
|
131
|
+
return 'free';
|
|
132
|
+
return 'pro'; // fallback conservador
|
|
133
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-stats.ts — Lee ~/.claude/stats-cache.json
|
|
3
|
+
*
|
|
4
|
+
* Claude Code mantiene este archivo con actividad agregada por día:
|
|
5
|
+
* - messageCount: total de mensajes (human + assistant) — ≈ 2× prompts reales
|
|
6
|
+
* - sessionCount: sesiones abiertas
|
|
7
|
+
* - toolCallCount: llamadas a herramientas
|
|
8
|
+
* - tokensByModel: tokens de OUTPUT por modelo (lo que genera Claude)
|
|
9
|
+
*
|
|
10
|
+
* NOTA: lastComputedDate indica hasta qué fecha están los datos. El día actual
|
|
11
|
+
* puede no aparecer todavía si Claude Code aún no actualizó el cache.
|
|
12
|
+
*/
|
|
13
|
+
export interface DayActivity {
|
|
14
|
+
date: string;
|
|
15
|
+
messages: number;
|
|
16
|
+
sessions: number;
|
|
17
|
+
tools: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ClaudeStatsData {
|
|
21
|
+
today: DayActivity | null;
|
|
22
|
+
yesterday: DayActivity | null;
|
|
23
|
+
last7: DayActivity;
|
|
24
|
+
allTime: {
|
|
25
|
+
sessions: number;
|
|
26
|
+
messages: number;
|
|
27
|
+
};
|
|
28
|
+
cacheDate: string | null;
|
|
29
|
+
todayLabel: string | null;
|
|
30
|
+
cacheIsStale: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare function readClaudeStats(): ClaudeStatsData;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* claude-stats.ts — Lee ~/.claude/stats-cache.json
|
|
4
|
+
*
|
|
5
|
+
* Claude Code mantiene este archivo con actividad agregada por día:
|
|
6
|
+
* - messageCount: total de mensajes (human + assistant) — ≈ 2× prompts reales
|
|
7
|
+
* - sessionCount: sesiones abiertas
|
|
8
|
+
* - toolCallCount: llamadas a herramientas
|
|
9
|
+
* - tokensByModel: tokens de OUTPUT por modelo (lo que genera Claude)
|
|
10
|
+
*
|
|
11
|
+
* NOTA: lastComputedDate indica hasta qué fecha están los datos. El día actual
|
|
12
|
+
* puede no aparecer todavía si Claude Code aún no actualizó el cache.
|
|
13
|
+
*/
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.readClaudeStats = readClaudeStats;
|
|
19
|
+
const fs_1 = __importDefault(require("fs"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const paths_1 = require("./paths");
|
|
22
|
+
const STATS_PATH = path_1.default.join((0, paths_1.getClaudeDir)(), 'stats-cache.json');
|
|
23
|
+
// ─── Lectura ──────────────────────────────────────────────────────────────────
|
|
24
|
+
function readClaudeStats() {
|
|
25
|
+
const empty = {
|
|
26
|
+
today: null, yesterday: null,
|
|
27
|
+
last7: { date: 'last7', messages: 0, sessions: 0, tools: 0, outputTokens: 0 },
|
|
28
|
+
allTime: { sessions: 0, messages: 0 },
|
|
29
|
+
cacheDate: null, todayLabel: null, cacheIsStale: false,
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs_1.default.readFileSync(STATS_PATH, 'utf8');
|
|
33
|
+
const data = JSON.parse(raw);
|
|
34
|
+
// Fechas de referencia
|
|
35
|
+
const now = new Date();
|
|
36
|
+
const todayStr = now.toISOString().slice(0, 10);
|
|
37
|
+
const yest = new Date(now.getTime() - 86400000);
|
|
38
|
+
const yestStr = yest.toISOString().slice(0, 10);
|
|
39
|
+
const cutoffStr = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10);
|
|
40
|
+
// Índice de tokens por fecha
|
|
41
|
+
const tokensByDate = {};
|
|
42
|
+
for (const entry of (data.dailyModelTokens ?? [])) {
|
|
43
|
+
tokensByDate[entry.date] = entry.tokensByModel ?? {};
|
|
44
|
+
}
|
|
45
|
+
// Suma de tokens por fecha
|
|
46
|
+
function tokensForDate(d) {
|
|
47
|
+
const byModel = tokensByDate[d] ?? {};
|
|
48
|
+
return Object.values(byModel).reduce((a, b) => a + b, 0);
|
|
49
|
+
}
|
|
50
|
+
// Construir DayActivity por fecha
|
|
51
|
+
const actByDate = {};
|
|
52
|
+
for (const a of (data.dailyActivity ?? [])) {
|
|
53
|
+
actByDate[a.date] = {
|
|
54
|
+
date: a.date, messages: a.messageCount ?? 0,
|
|
55
|
+
sessions: a.sessionCount ?? 0, tools: a.toolCallCount ?? 0,
|
|
56
|
+
outputTokens: tokensForDate(a.date),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Last 7 days aggregate
|
|
60
|
+
const last7 = { date: 'last7', messages: 0, sessions: 0, tools: 0, outputTokens: 0 };
|
|
61
|
+
for (const [date, act] of Object.entries(actByDate)) {
|
|
62
|
+
if (date >= cutoffStr) {
|
|
63
|
+
last7.messages += act.messages;
|
|
64
|
+
last7.sessions += act.sessions;
|
|
65
|
+
last7.tools += act.tools;
|
|
66
|
+
last7.outputTokens += act.outputTokens;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Si hoy/ayer no están en el cache, usar el día más reciente disponible
|
|
70
|
+
const todayData = actByDate[todayStr] ?? null;
|
|
71
|
+
const yesterdayData = actByDate[yestStr] ?? null;
|
|
72
|
+
let mostRecentDay = todayData ?? yesterdayData;
|
|
73
|
+
let mostRecentLabel = todayData ? 'Hoy' : yesterdayData ? 'Ayer' : null;
|
|
74
|
+
if (!mostRecentDay) {
|
|
75
|
+
// Cache antiguo — buscar el día más reciente disponible
|
|
76
|
+
const sortedDates = Object.keys(actByDate).sort().reverse();
|
|
77
|
+
if (sortedDates.length > 0) {
|
|
78
|
+
mostRecentDay = actByDate[sortedDates[0]];
|
|
79
|
+
mostRecentLabel = sortedDates[0]; // ej. "2026-04-16"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
today: mostRecentDay,
|
|
84
|
+
yesterday: yesterdayData,
|
|
85
|
+
last7,
|
|
86
|
+
allTime: {
|
|
87
|
+
sessions: data.totalSessions ?? 0,
|
|
88
|
+
messages: data.totalMessages ?? 0,
|
|
89
|
+
},
|
|
90
|
+
cacheDate: data.lastComputedDate ?? null,
|
|
91
|
+
todayLabel: mostRecentLabel,
|
|
92
|
+
cacheIsStale: !todayData && !yesterdayData,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return empty;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — Configuración de claudestat
|
|
3
|
+
*
|
|
4
|
+
* Lee/escribe ~/.claudestat/config.json con valores por defecto.
|
|
5
|
+
* Los thresholds de warning controlan cuándo se emite una alerta SSE
|
|
6
|
+
* y cuándo se activa el kill switch (hook bloqueante PreToolUse).
|
|
7
|
+
*
|
|
8
|
+
* Estructura del archivo:
|
|
9
|
+
* {
|
|
10
|
+
* "killSwitchEnabled": true, // activar/desactivar el hook bloqueante
|
|
11
|
+
* "killSwitchThreshold": 95, // % de cuota para bloquear (1-100)
|
|
12
|
+
* "warnThresholds": [70, 85, 95],// niveles de aviso: amarillo, naranja, rojo
|
|
13
|
+
* "plan": null, // forzar plan: "pro"|"max5"|"max20"|null (auto)
|
|
14
|
+
* "reportsEnabled": false, // activar/desactivar informes automáticos
|
|
15
|
+
* "reportFrequency": "weekly", // "weekly"|"biweekly"|"monthly"
|
|
16
|
+
* "reportDay": 1, // día de la semana: 0=Dom, 1=Lun … 6=Sáb
|
|
17
|
+
* "reportTime": "09:00" // hora HH:MM en que se genera el informe
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
import type { ClaudePlan } from './quota-tracker';
|
|
21
|
+
export type ReportFrequency = 'weekly' | 'biweekly' | 'monthly';
|
|
22
|
+
export interface ClaudestatConfig {
|
|
23
|
+
killSwitchEnabled: boolean;
|
|
24
|
+
killSwitchThreshold: number;
|
|
25
|
+
warnThresholds: number[];
|
|
26
|
+
plan: ClaudePlan | null;
|
|
27
|
+
reportsEnabled: boolean;
|
|
28
|
+
reportFrequency: ReportFrequency;
|
|
29
|
+
reportDay: number;
|
|
30
|
+
reportTime: string;
|
|
31
|
+
alertsEnabled: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** Lee la config del disco. Valores ausentes se rellenan con defaults. */
|
|
34
|
+
export declare function readConfig(): ClaudestatConfig;
|
|
35
|
+
/** Escribe la config en disco. Crea el directorio si no existe. */
|
|
36
|
+
export declare function writeConfig(cfg: ClaudestatConfig): void;
|
|
37
|
+
/**
|
|
38
|
+
* Valida y sanitiza los campos de una config recibida por la API.
|
|
39
|
+
* Devuelve un string de error si algo es inválido, o null si está bien.
|
|
40
|
+
*/
|
|
41
|
+
export declare function validateConfig(raw: unknown): string | null;
|
|
42
|
+
/** Devuelve el nivel de warning para un % dado, o null si no alcanza ningún threshold. */
|
|
43
|
+
export declare function getWarnLevel(pct: number, thresholds: number[]): 'yellow' | 'orange' | 'red' | null;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* config.ts — Configuración de claudestat
|
|
4
|
+
*
|
|
5
|
+
* Lee/escribe ~/.claudestat/config.json con valores por defecto.
|
|
6
|
+
* Los thresholds de warning controlan cuándo se emite una alerta SSE
|
|
7
|
+
* y cuándo se activa el kill switch (hook bloqueante PreToolUse).
|
|
8
|
+
*
|
|
9
|
+
* Estructura del archivo:
|
|
10
|
+
* {
|
|
11
|
+
* "killSwitchEnabled": true, // activar/desactivar el hook bloqueante
|
|
12
|
+
* "killSwitchThreshold": 95, // % de cuota para bloquear (1-100)
|
|
13
|
+
* "warnThresholds": [70, 85, 95],// niveles de aviso: amarillo, naranja, rojo
|
|
14
|
+
* "plan": null, // forzar plan: "pro"|"max5"|"max20"|null (auto)
|
|
15
|
+
* "reportsEnabled": false, // activar/desactivar informes automáticos
|
|
16
|
+
* "reportFrequency": "weekly", // "weekly"|"biweekly"|"monthly"
|
|
17
|
+
* "reportDay": 1, // día de la semana: 0=Dom, 1=Lun … 6=Sáb
|
|
18
|
+
* "reportTime": "09:00" // hora HH:MM en que se genera el informe
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
22
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
23
|
+
};
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.readConfig = readConfig;
|
|
26
|
+
exports.writeConfig = writeConfig;
|
|
27
|
+
exports.validateConfig = validateConfig;
|
|
28
|
+
exports.getWarnLevel = getWarnLevel;
|
|
29
|
+
const fs_1 = __importDefault(require("fs"));
|
|
30
|
+
const path_1 = __importDefault(require("path"));
|
|
31
|
+
const paths_1 = require("./paths");
|
|
32
|
+
const CONFIG_PATH = path_1.default.join((0, paths_1.getClaudestatDir)(), 'config.json');
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
killSwitchEnabled: false,
|
|
35
|
+
killSwitchThreshold: 95,
|
|
36
|
+
warnThresholds: [70, 85, 95],
|
|
37
|
+
plan: null,
|
|
38
|
+
reportsEnabled: false,
|
|
39
|
+
reportFrequency: 'weekly',
|
|
40
|
+
reportDay: 1,
|
|
41
|
+
reportTime: '09:00',
|
|
42
|
+
alertsEnabled: true,
|
|
43
|
+
};
|
|
44
|
+
/** Lee la config del disco. Valores ausentes se rellenan con defaults. */
|
|
45
|
+
function readConfig() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = fs_1.default.readFileSync(CONFIG_PATH, 'utf8');
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
return { ...DEFAULTS, ...parsed };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { ...DEFAULTS };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Escribe la config en disco. Crea el directorio si no existe. */
|
|
56
|
+
function writeConfig(cfg) {
|
|
57
|
+
fs_1.default.mkdirSync(path_1.default.dirname(CONFIG_PATH), { recursive: true });
|
|
58
|
+
fs_1.default.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
59
|
+
}
|
|
60
|
+
const VALID_PLANS = new Set(['free', 'pro', 'max5', 'max20', null]);
|
|
61
|
+
/**
|
|
62
|
+
* Valida y sanitiza los campos de una config recibida por la API.
|
|
63
|
+
* Devuelve un string de error si algo es inválido, o null si está bien.
|
|
64
|
+
*/
|
|
65
|
+
function validateConfig(raw) {
|
|
66
|
+
if (typeof raw !== 'object' || raw === null)
|
|
67
|
+
return 'Body debe ser un objeto JSON';
|
|
68
|
+
const cfg = raw;
|
|
69
|
+
if ('killSwitchEnabled' in cfg && typeof cfg.killSwitchEnabled !== 'boolean')
|
|
70
|
+
return 'killSwitchEnabled debe ser boolean';
|
|
71
|
+
if ('killSwitchThreshold' in cfg) {
|
|
72
|
+
const v = cfg.killSwitchThreshold;
|
|
73
|
+
if (typeof v !== 'number' || isNaN(v) || v < 1 || v > 100)
|
|
74
|
+
return 'killSwitchThreshold debe ser un número entre 1 y 100';
|
|
75
|
+
}
|
|
76
|
+
if ('warnThresholds' in cfg) {
|
|
77
|
+
const v = cfg.warnThresholds;
|
|
78
|
+
if (!Array.isArray(v) || v.length !== 3 || v.some(n => typeof n !== 'number' || isNaN(n) || n < 1 || n > 100))
|
|
79
|
+
return 'warnThresholds debe ser un array de 3 números entre 1 y 100';
|
|
80
|
+
}
|
|
81
|
+
if ('plan' in cfg && !VALID_PLANS.has(cfg.plan))
|
|
82
|
+
return `plan debe ser uno de: free, pro, max5, max20 o null`;
|
|
83
|
+
if ('alertsEnabled' in cfg && typeof cfg.alertsEnabled !== 'boolean')
|
|
84
|
+
return 'alertsEnabled debe ser boolean';
|
|
85
|
+
if ('reportsEnabled' in cfg && typeof cfg.reportsEnabled !== 'boolean')
|
|
86
|
+
return 'reportsEnabled debe ser boolean';
|
|
87
|
+
if ('reportFrequency' in cfg && !['weekly', 'biweekly', 'monthly'].includes(cfg.reportFrequency))
|
|
88
|
+
return 'reportFrequency debe ser weekly, biweekly o monthly';
|
|
89
|
+
if ('reportDay' in cfg) {
|
|
90
|
+
const v = cfg.reportDay;
|
|
91
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 6)
|
|
92
|
+
return 'reportDay debe ser un entero entre 0 y 6';
|
|
93
|
+
}
|
|
94
|
+
if ('reportTime' in cfg) {
|
|
95
|
+
if (typeof cfg.reportTime !== 'string' || !/^\d{2}:\d{2}$/.test(cfg.reportTime))
|
|
96
|
+
return 'reportTime debe tener formato HH:MM';
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** Devuelve el nivel de warning para un % dado, o null si no alcanza ningún threshold. */
|
|
101
|
+
function getWarnLevel(pct, thresholds) {
|
|
102
|
+
const sorted = [...thresholds].sort((a, b) => a - b); // [70, 85, 95]
|
|
103
|
+
if (sorted.length >= 3 && pct >= sorted[2])
|
|
104
|
+
return 'red';
|
|
105
|
+
if (sorted.length >= 2 && pct >= sorted[1])
|
|
106
|
+
return 'orange';
|
|
107
|
+
if (sorted.length >= 1 && pct >= sorted[0])
|
|
108
|
+
return 'yellow';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon.ts — Servidor HTTP + SSE con inteligencia integrada
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 agrega:
|
|
5
|
+
* - Enriquecimiento de coste desde JSONL (enricher)
|
|
6
|
+
* - Análisis de inteligencia (loops + eficiencia) al recibir cada cost update
|
|
7
|
+
* - Endpoint GET /intelligence/:sessionId
|
|
8
|
+
* - Endpoint GET /sessions para el dashboard futuro
|
|
9
|
+
*
|
|
10
|
+
* Phase 3 agrega:
|
|
11
|
+
* - Sirve el dashboard React desde dashboard/dist
|
|
12
|
+
* - Endpoint GET /meta-stats: KPIs de HANDOFF, Engram, config y alertas
|
|
13
|
+
* - Procesa JSONL al conectar nuevo cliente SSE (contexto inmediato)
|
|
14
|
+
*/
|
|
15
|
+
export declare function startDaemon(): void;
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* daemon.ts — Servidor HTTP + SSE con inteligencia integrada
|
|
4
|
+
*
|
|
5
|
+
* Phase 2 agrega:
|
|
6
|
+
* - Enriquecimiento de coste desde JSONL (enricher)
|
|
7
|
+
* - Análisis de inteligencia (loops + eficiencia) al recibir cada cost update
|
|
8
|
+
* - Endpoint GET /intelligence/:sessionId
|
|
9
|
+
* - Endpoint GET /sessions para el dashboard futuro
|
|
10
|
+
*
|
|
11
|
+
* Phase 3 agrega:
|
|
12
|
+
* - Sirve el dashboard React desde dashboard/dist
|
|
13
|
+
* - Endpoint GET /meta-stats: KPIs de HANDOFF, Engram, config y alertas
|
|
14
|
+
* - Procesa JSONL al conectar nuevo cliente SSE (contexto inmediato)
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.startDaemon = startDaemon;
|
|
21
|
+
const express_1 = __importDefault(require("express"));
|
|
22
|
+
const path_1 = __importDefault(require("path"));
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const db_1 = require("./db");
|
|
25
|
+
const enricher_1 = require("./enricher");
|
|
26
|
+
const config_1 = require("./config");
|
|
27
|
+
const quota_tracker_1 = require("./quota-tracker");
|
|
28
|
+
const notifier_1 = require("./notifier");
|
|
29
|
+
const events_1 = require("./routes/events");
|
|
30
|
+
const stream_1 = require("./routes/stream");
|
|
31
|
+
const projects_1 = require("./routes/projects");
|
|
32
|
+
const history_1 = require("./routes/history");
|
|
33
|
+
const misc_1 = require("./routes/misc");
|
|
34
|
+
const reports_1 = require("./routes/reports");
|
|
35
|
+
const top_1 = require("./routes/top");
|
|
36
|
+
const projects_cache_1 = require("./cache/projects-cache");
|
|
37
|
+
const rate_limiter_1 = require("./middleware/rate-limiter");
|
|
38
|
+
const summarizer_1 = require("./summarizer");
|
|
39
|
+
const paths_1 = require("./paths");
|
|
40
|
+
const PORT = 7337;
|
|
41
|
+
const app = (0, express_1.default)();
|
|
42
|
+
app.use(express_1.default.json());
|
|
43
|
+
// ─── Shutdown graceful (cross-platform, no depende de SIGTERM) ────────────────
|
|
44
|
+
let _server = null;
|
|
45
|
+
app.post('/shutdown', (_req, res) => {
|
|
46
|
+
res.json({ ok: true });
|
|
47
|
+
if (_server)
|
|
48
|
+
shutdown(_server);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
});
|
|
51
|
+
// ─── Montar rutas ─────────────────────────────────────────────────────────────
|
|
52
|
+
app.use(events_1.eventsRouter);
|
|
53
|
+
app.use(stream_1.streamRouter);
|
|
54
|
+
app.use(projects_1.projectsRouter);
|
|
55
|
+
app.use(history_1.historyRouter);
|
|
56
|
+
app.use(misc_1.miscRouter);
|
|
57
|
+
app.use(reports_1.reportsRouter);
|
|
58
|
+
app.use(top_1.topRouter);
|
|
59
|
+
// ─── GET /health — necesita acceso al tamaño del pool SSE ─────────────────────
|
|
60
|
+
app.get('/health', (_req, res) => {
|
|
61
|
+
res.json({ status: 'ok', port: PORT, clients: (0, stream_1.getSseClientsSize)() });
|
|
62
|
+
});
|
|
63
|
+
// ─── Dashboard React (servir estáticos del build de Vite) ────────────────────
|
|
64
|
+
const DASHBOARD_DIST = path_1.default.join(__dirname, '..', 'dashboard', 'dist');
|
|
65
|
+
app.use(express_1.default.static(DASHBOARD_DIST, {
|
|
66
|
+
setHeaders(res, filePath) {
|
|
67
|
+
if (filePath.endsWith('.html')) {
|
|
68
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
// SPA fallback: cualquier ruta no capturada sirve index.html
|
|
73
|
+
app.get('*', (_req, res) => {
|
|
74
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
75
|
+
res.sendFile(path_1.default.join(DASHBOARD_DIST, 'index.html'));
|
|
76
|
+
});
|
|
77
|
+
// ─── Migración de arranque: etiquetar sesiones históricas ────────────────────
|
|
78
|
+
function migrateSessionProjects() {
|
|
79
|
+
const sessions = db_1.dbOps.getAllSessions();
|
|
80
|
+
let tagged = 0;
|
|
81
|
+
for (const session of sessions) {
|
|
82
|
+
if (session.project_path)
|
|
83
|
+
continue;
|
|
84
|
+
const events = db_1.dbOps.getSessionEvents(session.id);
|
|
85
|
+
const projectCwd = (0, projects_1.inferProjectCwd)(events);
|
|
86
|
+
if (projectCwd) {
|
|
87
|
+
db_1.dbOps.updateSessionProject(session.id, projectCwd);
|
|
88
|
+
tagged++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (tagged > 0)
|
|
92
|
+
console.log(`[daemon] ${tagged} sessions tagged with project`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Genera summaries IA para las últimas N sesiones que no tienen uno.
|
|
96
|
+
* Se ejecuta en background al arrancar el daemon — no bloquea el inicio.
|
|
97
|
+
*/
|
|
98
|
+
async function migrateSessionSummaries(limit = 5) {
|
|
99
|
+
const sessions = db_1.dbOps.getAllSessions()
|
|
100
|
+
.filter(s => !s.ai_summary)
|
|
101
|
+
.slice(0, limit);
|
|
102
|
+
for (const s of sessions) {
|
|
103
|
+
try {
|
|
104
|
+
const events = db_1.dbOps.getSessionEvents(s.id);
|
|
105
|
+
const projectName = s.project_path ? path_1.default.basename(s.project_path) : undefined;
|
|
106
|
+
const summary = await (0, summarizer_1.summarizeSession)(events, s.total_cost_usd ?? 0, projectName);
|
|
107
|
+
if (summary) {
|
|
108
|
+
db_1.dbOps.updateSessionSummary(s.id, summary);
|
|
109
|
+
console.log(`[daemon] Summary generado para sesión ${s.id.slice(0, 8)}: "${summary}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error('[daemon] Error generating summary:', err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ─── Interval refs for cleanup ────────────────────────────────────────────────
|
|
118
|
+
let projectCacheInterval = null;
|
|
119
|
+
let reportInterval = null;
|
|
120
|
+
let alertInterval = null;
|
|
121
|
+
function shutdown(server) {
|
|
122
|
+
(0, enricher_1.stopEnricher)();
|
|
123
|
+
(0, rate_limiter_1.stopRateLimiter)();
|
|
124
|
+
if (projectCacheInterval) {
|
|
125
|
+
clearInterval(projectCacheInterval);
|
|
126
|
+
projectCacheInterval = null;
|
|
127
|
+
}
|
|
128
|
+
if (reportInterval) {
|
|
129
|
+
clearInterval(reportInterval);
|
|
130
|
+
reportInterval = null;
|
|
131
|
+
}
|
|
132
|
+
if (alertInterval) {
|
|
133
|
+
clearInterval(alertInterval);
|
|
134
|
+
alertInterval = null;
|
|
135
|
+
}
|
|
136
|
+
cleanPid();
|
|
137
|
+
server.close();
|
|
138
|
+
}
|
|
139
|
+
const LEVEL_RANK = { yellow: 1, orange: 2, red: 3 };
|
|
140
|
+
const LEVEL_COLOR = {
|
|
141
|
+
yellow: '\x1b[33m',
|
|
142
|
+
orange: '\x1b[33m',
|
|
143
|
+
red: '\x1b[31m',
|
|
144
|
+
};
|
|
145
|
+
let _lastAlertLevel = null;
|
|
146
|
+
function startAlertPolling() {
|
|
147
|
+
alertInterval = setInterval(() => {
|
|
148
|
+
try {
|
|
149
|
+
const cfg = (0, config_1.readConfig)();
|
|
150
|
+
if (!cfg.alertsEnabled)
|
|
151
|
+
return;
|
|
152
|
+
const data = (0, quota_tracker_1.computeQuota)(cfg.plan ?? undefined);
|
|
153
|
+
const level = (0, config_1.getWarnLevel)(data.cyclePct, cfg.warnThresholds);
|
|
154
|
+
if (!level) {
|
|
155
|
+
_lastAlertLevel = null;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const prevRank = _lastAlertLevel ? LEVEL_RANK[_lastAlertLevel] ?? 0 : 0;
|
|
159
|
+
const currRank = LEVEL_RANK[level] ?? 0;
|
|
160
|
+
if (currRank > prevRank) {
|
|
161
|
+
const color = LEVEL_COLOR[level];
|
|
162
|
+
process.stderr.write(`${color}[claudestat] ⚠️ Rate limit alert: ${data.cyclePct}% of quota used (${data.cyclePrompts}/${data.cycleLimit} prompts)\x1b[0m\n`);
|
|
163
|
+
if (data.cyclePct >= cfg.killSwitchThreshold) {
|
|
164
|
+
(0, notifier_1.sendDesktopNotification)('claudestat — Rate limit warning', `${data.cyclePct}% of 5h quota used (${data.cyclePrompts}/${data.cycleLimit} prompts)`);
|
|
165
|
+
}
|
|
166
|
+
_lastAlertLevel = level;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// quota read failed — ignore
|
|
171
|
+
}
|
|
172
|
+
}, 60000);
|
|
173
|
+
}
|
|
174
|
+
// ─── Report scheduler ─────────────────────────────────────────────────────────
|
|
175
|
+
const PROJECTS_CACHE_TTL = 2 * 60000; // 2 minutos
|
|
176
|
+
const PID_FILE = (0, paths_1.getPidFile)();
|
|
177
|
+
function writePid() {
|
|
178
|
+
try {
|
|
179
|
+
const dir = (0, paths_1.getClaudestatDir)();
|
|
180
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
181
|
+
fs_1.default.writeFileSync(PID_FILE, String(process.pid));
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
}
|
|
185
|
+
function cleanPid() {
|
|
186
|
+
try {
|
|
187
|
+
fs_1.default.unlinkSync(PID_FILE);
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
191
|
+
function startDaemon() {
|
|
192
|
+
_server = app.listen(PORT, '127.0.0.1', () => {
|
|
193
|
+
writePid();
|
|
194
|
+
process.on('exit', cleanPid);
|
|
195
|
+
process.on('SIGTERM', () => { shutdown(_server); process.exit(0); });
|
|
196
|
+
process.on('SIGINT', () => { shutdown(_server); process.exit(0); });
|
|
197
|
+
console.log(`\n● claudestat daemon → http://localhost:${PORT}`);
|
|
198
|
+
console.log(` Waiting for Claude Code events...\n`);
|
|
199
|
+
console.log(` In another terminal: \x1b[36mclaudestat watch\x1b[0m\n`);
|
|
200
|
+
// Etiquetar sesiones históricas que no tienen proyecto asignado
|
|
201
|
+
migrateSessionProjects();
|
|
202
|
+
// Pre-scan de proyectos al arrancar — garantiza respuesta inmediata en el tab
|
|
203
|
+
// Se ejecuta en background para no retrasar el inicio del servidor
|
|
204
|
+
setImmediate(() => {
|
|
205
|
+
const projects = (0, projects_cache_1.getProjectsCached)();
|
|
206
|
+
console.log(`[daemon] ${projects?.length ?? 0} projects scanned`);
|
|
207
|
+
});
|
|
208
|
+
// Refresh automático del cache de proyectos cada 2 minutos
|
|
209
|
+
// Recoge cambios en HANDOFF.md aunque el daemon lleve horas corriendo
|
|
210
|
+
projectCacheInterval = setInterval(() => {
|
|
211
|
+
(0, projects_cache_1.invalidateProjectsCache)();
|
|
212
|
+
(0, projects_cache_1.getProjectsCached)();
|
|
213
|
+
}, PROJECTS_CACHE_TTL);
|
|
214
|
+
// Iniciar el watcher de JSONL para enriquecimiento de coste
|
|
215
|
+
(0, enricher_1.startEnricher)(events_1.onCostUpdate, events_1.onCompactDetected);
|
|
216
|
+
// Scheduler de informes automáticos — corre cada minuto
|
|
217
|
+
reportInterval = setInterval(() => {
|
|
218
|
+
const cfg = (0, config_1.readConfig)();
|
|
219
|
+
if (!cfg.reportsEnabled)
|
|
220
|
+
return;
|
|
221
|
+
const dateLabel = (0, reports_1.getReportDateLabel)(new Date(), cfg);
|
|
222
|
+
if (!dateLabel)
|
|
223
|
+
return;
|
|
224
|
+
if (db_1.dbOps.getWeeklyReportByDate(dateLabel))
|
|
225
|
+
return; // ya existe
|
|
226
|
+
const markdown = (0, reports_1.generateReport)(dateLabel, cfg);
|
|
227
|
+
db_1.dbOps.insertWeeklyReport(dateLabel, markdown);
|
|
228
|
+
console.log(`[daemon] Report auto-generated: ${dateLabel}`);
|
|
229
|
+
}, 60000);
|
|
230
|
+
// Summaries IA solo si opt-in explícito (CLAUDESTAT_AI_SUMMARY=true)
|
|
231
|
+
if (process.env.CLAUDESTAT_AI_SUMMARY === 'true') {
|
|
232
|
+
migrateSessionSummaries(5).catch(() => { });
|
|
233
|
+
}
|
|
234
|
+
// Polling de alertas de rate limit cada 60s
|
|
235
|
+
startAlertPolling();
|
|
236
|
+
});
|
|
237
|
+
// Manejo de error de puerto ocupado — fuera del callback para capturar EADDRINUSE
|
|
238
|
+
_server.on('error', (err) => {
|
|
239
|
+
if (err.code === 'EADDRINUSE') {
|
|
240
|
+
console.error(`\n❌ Error: Port ${PORT} is already in use.`);
|
|
241
|
+
console.error(` Is claudestat already running? Check with: ${(0, paths_1.portCheckCmd)(PORT)}`);
|
|
242
|
+
console.error(` If so, you don't need to start it again.\n`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
});
|
|
247
|
+
}
|