@statforge/claudestat 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +36 -0
- package/dist/daemon.js +56 -8
- package/dist/db.d.ts +11 -0
- package/dist/db.js +30 -0
- package/dist/doctor.js +20 -2
- package/dist/export.d.ts +2 -1
- package/dist/export.js +41 -6
- package/dist/index.js +405 -30
- package/dist/insights.d.ts +1 -0
- package/dist/insights.js +26 -0
- package/dist/install.js +28 -1
- package/dist/intelligence.d.ts +11 -4
- package/dist/intelligence.js +43 -17
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +49 -0
- package/dist/notifier.d.ts +15 -0
- package/dist/notifier.js +26 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +34 -0
- package/dist/routes/helpers.d.ts +5 -0
- package/dist/routes/helpers.js +21 -1
- package/dist/routes/misc.js +19 -1
- package/dist/routes/projects.js +10 -1
- package/dist/service.js +11 -0
- package/hooks/event.js +44 -26
- package/package.json +1 -1
package/dist/install.js
CHANGED
|
@@ -23,6 +23,7 @@ const readline_1 = __importDefault(require("readline"));
|
|
|
23
23
|
const child_process_1 = require("child_process");
|
|
24
24
|
const paths_1 = require("./paths");
|
|
25
25
|
const config_1 = require("./config");
|
|
26
|
+
const doctor_1 = require("./doctor");
|
|
26
27
|
const CLAUDESTAT_DIR = (0, paths_1.getClaudestatDir)();
|
|
27
28
|
const CLAUDE_SETTINGS = path_1.default.join((0, paths_1.getClaudeDir)(), 'settings.json');
|
|
28
29
|
const HOOKS_DIR = path_1.default.join(CLAUDESTAT_DIR, 'hooks');
|
|
@@ -143,9 +144,29 @@ async function runWizard() {
|
|
|
143
144
|
plan = planMap[input.trim()] ?? 'pro';
|
|
144
145
|
}
|
|
145
146
|
console.log(`✓ Plan: ${plan}`);
|
|
147
|
+
// Paso 3b: puerto
|
|
148
|
+
let port = 7337;
|
|
149
|
+
if (!nonInteractive) {
|
|
150
|
+
const portInput = await new Promise(resolve => rl.question(`Port for daemon [default: 7337]: `, resolve));
|
|
151
|
+
const parsed = parseInt(portInput.trim(), 10);
|
|
152
|
+
if (!isNaN(parsed) && parsed >= 1024 && parsed <= 65535) {
|
|
153
|
+
port = parsed;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.log(`✓ Port: ${port}`);
|
|
157
|
+
// Paso 3c: kill-switch threshold
|
|
158
|
+
let threshold = 95;
|
|
159
|
+
if (!nonInteractive) {
|
|
160
|
+
const tInput = await new Promise(resolve => rl.question(`Quota threshold for kill-switch warning [default: 95%]: `, resolve));
|
|
161
|
+
const parsed = parseInt(tInput.trim(), 10);
|
|
162
|
+
if (!isNaN(parsed) && parsed >= 50 && parsed <= 100) {
|
|
163
|
+
threshold = parsed;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
console.log(`✓ Kill-switch threshold: ${threshold}%`);
|
|
146
167
|
// Paso 4: crear config inicial
|
|
147
168
|
const cfg = (0, config_1.readConfig)();
|
|
148
|
-
(0, config_1.writeConfig)({ ...cfg, plan: plan });
|
|
169
|
+
(0, config_1.writeConfig)({ ...cfg, plan: plan, port, killSwitchThreshold: threshold });
|
|
149
170
|
console.log(`✓ Config created → ${CONFIG_PATH}\n`);
|
|
150
171
|
}
|
|
151
172
|
finally {
|
|
@@ -155,6 +176,12 @@ async function runWizard() {
|
|
|
155
176
|
installHooks();
|
|
156
177
|
// Paso 6: registrar MCP server en Claude Code
|
|
157
178
|
installMcp();
|
|
179
|
+
// Paso final: doctor
|
|
180
|
+
console.log('\n🩺 Running doctor to verify installation...\n');
|
|
181
|
+
try {
|
|
182
|
+
await (0, doctor_1.runDoctor)();
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
158
185
|
}
|
|
159
186
|
function installMcp() {
|
|
160
187
|
const nodeExec = process.execPath;
|
package/dist/intelligence.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface LoopAlert {
|
|
|
11
11
|
count: number;
|
|
12
12
|
windowMs: number;
|
|
13
13
|
ts: number;
|
|
14
|
+
context: LoopContext;
|
|
15
|
+
}
|
|
16
|
+
export interface LoopContext {
|
|
17
|
+
repeatedFiles: string[];
|
|
18
|
+
repeatedCommands: string[];
|
|
19
|
+
estimatedCostUsd: number;
|
|
14
20
|
}
|
|
15
21
|
export interface IntelligenceReport {
|
|
16
22
|
loops: LoopAlert[];
|
|
@@ -22,12 +28,13 @@ export interface IntelligenceReport {
|
|
|
22
28
|
seqCycleCount: number;
|
|
23
29
|
}
|
|
24
30
|
/**
|
|
25
|
-
* Detecta loops: cuando el mismo tool se llama ≥
|
|
26
|
-
* dentro de
|
|
31
|
+
* Detecta loops: cuando el mismo tool se llama ≥ threshold veces
|
|
32
|
+
* dentro de windowMs. Evita alertas duplicadas con COOLDOWN_MS = windowMs.
|
|
27
33
|
*
|
|
28
34
|
* Algoritmo: ventana deslizante sobre eventos ordenados por timestamp.
|
|
35
|
+
* Captura contexto (archivos repetidos, comandos Bash repetidos) para cada alerta.
|
|
29
36
|
*/
|
|
30
|
-
export declare function detectLoops(events: EventRow[]): LoopAlert[];
|
|
37
|
+
export declare function detectLoops(events: EventRow[], threshold?: number, windowMs?: number): LoopAlert[];
|
|
31
38
|
/**
|
|
32
39
|
* Calcula un score de 0-100 basado en:
|
|
33
40
|
* - Loops detectados → -10 por loop, cap -25
|
|
@@ -96,5 +103,5 @@ export declare function predictSaturation(samples: Array<{
|
|
|
96
103
|
/**
|
|
97
104
|
* Genera el reporte completo de inteligencia para una sesión.
|
|
98
105
|
*/
|
|
99
|
-
export declare function analyzeSession(events: EventRow[], costUsd: number): IntelligenceReport;
|
|
106
|
+
export declare function analyzeSession(events: EventRow[], costUsd: number, threshold?: number, windowMs?: number): IntelligenceReport;
|
|
100
107
|
export {};
|
package/dist/intelligence.js
CHANGED
|
@@ -17,35 +17,62 @@ exports.analyzeSemanticLoops = analyzeSemanticLoops;
|
|
|
17
17
|
exports.predictSaturation = predictSaturation;
|
|
18
18
|
exports.analyzeSession = analyzeSession;
|
|
19
19
|
// ─── Detección de loops ───────────────────────────────────────────────────────
|
|
20
|
-
const
|
|
21
|
-
const LOOP_WINDOW_MS = 120000; // ventana de tiempo: 2 minutos (antes 60s → demasiados falsos positivos en coding)
|
|
22
|
-
const LOOP_COOLDOWN_MS = 120000; // cooldown entre alertas del mismo tool: 2 min (antes 15s → re-alertaba constantemente)
|
|
20
|
+
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
|
23
21
|
/**
|
|
24
|
-
* Detecta loops: cuando el mismo tool se llama ≥
|
|
25
|
-
* dentro de
|
|
22
|
+
* Detecta loops: cuando el mismo tool se llama ≥ threshold veces
|
|
23
|
+
* dentro de windowMs. Evita alertas duplicadas con COOLDOWN_MS = windowMs.
|
|
26
24
|
*
|
|
27
25
|
* Algoritmo: ventana deslizante sobre eventos ordenados por timestamp.
|
|
26
|
+
* Captura contexto (archivos repetidos, comandos Bash repetidos) para cada alerta.
|
|
28
27
|
*/
|
|
29
|
-
function detectLoops(events) {
|
|
28
|
+
function detectLoops(events, threshold = 8, windowMs = 120000) {
|
|
29
|
+
const COOLDOWN_MS = windowMs; // cooldown = mismo tamaño que la ventana
|
|
30
30
|
const alerts = [];
|
|
31
|
-
const windowsByTool = new Map();
|
|
31
|
+
const windowsByTool = new Map();
|
|
32
32
|
for (const ev of events) {
|
|
33
|
-
// Solo contamos tool calls (PreToolUse o Done — uno de los dos)
|
|
34
33
|
if (ev.type !== 'Done' && ev.type !== 'PreToolUse')
|
|
35
34
|
continue;
|
|
36
35
|
if (!ev.tool_name)
|
|
37
36
|
continue;
|
|
38
37
|
const toolName = ev.tool_name;
|
|
39
38
|
const ts = ev.ts;
|
|
40
|
-
|
|
41
|
-
const window = (windowsByTool.get(toolName) || []).filter(t => t >= ts - LOOP_WINDOW_MS);
|
|
39
|
+
const window = (windowsByTool.get(toolName) || []).filter(t => t >= ts - windowMs);
|
|
42
40
|
window.push(ts);
|
|
43
41
|
windowsByTool.set(toolName, window);
|
|
44
|
-
if (window.length >=
|
|
45
|
-
// Verificar cooldown: no alertar si ya alertamos recientemente para este tool
|
|
42
|
+
if (window.length >= threshold) {
|
|
46
43
|
const lastAlert = [...alerts].reverse().find(a => a.toolName === toolName);
|
|
47
|
-
if (!lastAlert || ts - lastAlert.ts >=
|
|
48
|
-
|
|
44
|
+
if (!lastAlert || ts - lastAlert.ts >= COOLDOWN_MS) {
|
|
45
|
+
const windowStart = ts - windowMs;
|
|
46
|
+
const loopEvents = events.filter(e => e.ts >= windowStart && e.ts <= ts && e.type === 'Done');
|
|
47
|
+
const repeatedFiles = new Set();
|
|
48
|
+
const repeatedCommands = new Set();
|
|
49
|
+
for (const le of loopEvents) {
|
|
50
|
+
if (!le.tool_input)
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const inp = JSON.parse(le.tool_input);
|
|
54
|
+
if (FILE_TOOLS.has(le.tool_name ?? '')) {
|
|
55
|
+
const fp = inp.file_path ?? inp.path ?? inp.pattern;
|
|
56
|
+
if (typeof fp === 'string')
|
|
57
|
+
repeatedFiles.add(fp);
|
|
58
|
+
}
|
|
59
|
+
if (le.tool_name === 'Bash' && typeof inp.command === 'string') {
|
|
60
|
+
repeatedCommands.add(inp.command.slice(0, 80));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
}
|
|
65
|
+
alerts.push({
|
|
66
|
+
toolName,
|
|
67
|
+
count: window.length,
|
|
68
|
+
windowMs,
|
|
69
|
+
ts,
|
|
70
|
+
context: {
|
|
71
|
+
repeatedFiles: [...repeatedFiles].slice(0, 10),
|
|
72
|
+
repeatedCommands: [...repeatedCommands].slice(0, 5),
|
|
73
|
+
estimatedCostUsd: 0,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
49
76
|
}
|
|
50
77
|
}
|
|
51
78
|
}
|
|
@@ -85,7 +112,6 @@ function calcEfficiencyScore(events, loops, costUsd) {
|
|
|
85
112
|
return Math.max(0, Math.min(100, score));
|
|
86
113
|
}
|
|
87
114
|
// ─── Semantic metrics ─────────────────────────────────────────────────────────
|
|
88
|
-
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
|
89
115
|
const ERROR_PATTERN = /error:|ENOENT|EACCES|EPERM|permission denied|failed|No such file/i;
|
|
90
116
|
/**
|
|
91
117
|
* Counts exact duplicate tool calls: same tool_name + same tool_input.
|
|
@@ -239,8 +265,8 @@ function predictSaturation(samples, contextWindow) {
|
|
|
239
265
|
/**
|
|
240
266
|
* Genera el reporte completo de inteligencia para una sesión.
|
|
241
267
|
*/
|
|
242
|
-
function analyzeSession(events, costUsd) {
|
|
243
|
-
const loops = detectLoops(events);
|
|
268
|
+
function analyzeSession(events, costUsd, threshold, windowMs) {
|
|
269
|
+
const loops = detectLoops(events, threshold, windowMs);
|
|
244
270
|
const efficiencyScore = calcEfficiencyScore(events, loops, costUsd);
|
|
245
271
|
const exactRetries = detectExactRetries(events);
|
|
246
272
|
const errorRate = computeErrorRate(events);
|
package/dist/logger.d.ts
ADDED
package/dist/logger.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.logger = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const paths_1 = require("./paths");
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
11
|
+
const MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
const MAX_FILES = 3;
|
|
13
|
+
function rotate(logFile) {
|
|
14
|
+
for (let i = MAX_FILES - 2; i >= 0; i--) {
|
|
15
|
+
const src = i === 0 ? logFile : `${logFile}.${i}`;
|
|
16
|
+
const dst = `${logFile}.${i + 1}`;
|
|
17
|
+
try {
|
|
18
|
+
if (fs_1.default.existsSync(src))
|
|
19
|
+
fs_1.default.renameSync(src, dst);
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function write(level, message) {
|
|
25
|
+
try {
|
|
26
|
+
const cfg = (0, config_1.readConfig)();
|
|
27
|
+
const minRank = LEVEL_RANK[cfg.logLevel] ?? 1;
|
|
28
|
+
if ((LEVEL_RANK[level] ?? 0) < minRank)
|
|
29
|
+
return;
|
|
30
|
+
const logFile = (0, paths_1.getDaemonLogFile)();
|
|
31
|
+
fs_1.default.mkdirSync((0, paths_1.getClaudestatDir)(), { recursive: true });
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs_1.default.statSync(logFile);
|
|
34
|
+
if (stat.size >= MAX_SIZE_BYTES)
|
|
35
|
+
rotate(logFile);
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
const ts = new Date().toISOString();
|
|
39
|
+
const line = `${ts} [${level.toUpperCase()}] ${message}\n`;
|
|
40
|
+
fs_1.default.appendFileSync(logFile, line);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
exports.logger = {
|
|
45
|
+
debug: (msg) => write('debug', msg),
|
|
46
|
+
info: (msg) => write('info', msg),
|
|
47
|
+
warn: (msg) => write('warn', msg),
|
|
48
|
+
error: (msg) => write('error', msg),
|
|
49
|
+
};
|
package/dist/notifier.d.ts
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
1
|
export declare function sendDesktopNotification(title: string, body: string): void;
|
|
2
|
+
export interface AlertPayload {
|
|
3
|
+
title: string;
|
|
4
|
+
body: string;
|
|
5
|
+
level: 'yellow' | 'orange' | 'red';
|
|
6
|
+
cyclePct: number;
|
|
7
|
+
weeklyPct?: number;
|
|
8
|
+
burnRate?: number;
|
|
9
|
+
resetInMins: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Sends an alert to a webhook URL (Slack, Discord, n8n, etc.).
|
|
13
|
+
* Payload format is compatible with Slack incoming webhooks and Discord webhooks.
|
|
14
|
+
* Fire-and-forget: errors are silently ignored.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sendWebhookAlert(url: string, payload: AlertPayload): void;
|
package/dist/notifier.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.sendDesktopNotification = sendDesktopNotification;
|
|
7
|
+
exports.sendWebhookAlert = sendWebhookAlert;
|
|
7
8
|
const child_process_1 = require("child_process");
|
|
8
9
|
const os_1 = __importDefault(require("os"));
|
|
9
10
|
function sendDesktopNotification(title, body) {
|
|
@@ -38,3 +39,28 @@ function sendDesktopNotification(title, body) {
|
|
|
38
39
|
// notification unavailable — silent fallback
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Sends an alert to a webhook URL (Slack, Discord, n8n, etc.).
|
|
44
|
+
* Payload format is compatible with Slack incoming webhooks and Discord webhooks.
|
|
45
|
+
* Fire-and-forget: errors are silently ignored.
|
|
46
|
+
*/
|
|
47
|
+
function sendWebhookAlert(url, payload) {
|
|
48
|
+
const emoji = payload.level === 'red' ? '🔴' : payload.level === 'orange' ? '🟠' : '🟡';
|
|
49
|
+
const body = JSON.stringify({
|
|
50
|
+
text: `${emoji} *${payload.title}*\n${payload.body}`,
|
|
51
|
+
attachments: [{
|
|
52
|
+
color: payload.level === 'red' ? '#ff0000' : payload.level === 'orange' ? '#ff8800' : '#ffcc00',
|
|
53
|
+
fields: [
|
|
54
|
+
{ title: '5h cycle', value: `${payload.cyclePct}%`, short: true },
|
|
55
|
+
{ title: 'Resets in', value: `${payload.resetInMins}m`, short: true },
|
|
56
|
+
...(payload.burnRate ? [{ title: 'Burn rate', value: `${payload.burnRate.toLocaleString()} tok/min`, short: true }] : []),
|
|
57
|
+
],
|
|
58
|
+
}],
|
|
59
|
+
});
|
|
60
|
+
fetch(url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body,
|
|
64
|
+
signal: AbortSignal.timeout(5000),
|
|
65
|
+
}).catch(() => { });
|
|
66
|
+
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -100,6 +100,24 @@ export declare function whichAllCmd(name: string): string;
|
|
|
100
100
|
* Windows: netstat -ano | findstr :<port>
|
|
101
101
|
*/
|
|
102
102
|
export declare function portCheckCmd(port: number): string;
|
|
103
|
+
/**
|
|
104
|
+
* Returns the port file path. The daemon writes the active port here on startup
|
|
105
|
+
* so the hook script (vanilla JS, no imports) can read it without parsing config.
|
|
106
|
+
*/
|
|
107
|
+
export declare function getPortFile(): string;
|
|
108
|
+
/**
|
|
109
|
+
* Writes the active port to disk. Called by the daemon after successful bind.
|
|
110
|
+
*/
|
|
111
|
+
export declare function writePortFile(port: number): void;
|
|
112
|
+
/**
|
|
113
|
+
* Returns the path of the pause signal file.
|
|
114
|
+
* When this file exists, the hook shows a warning instead of blocking.
|
|
115
|
+
*/
|
|
116
|
+
export declare function getPauseSignalFile(): string;
|
|
117
|
+
/**
|
|
118
|
+
* Returns the daemon log file path.
|
|
119
|
+
*/
|
|
120
|
+
export declare function getDaemonLogFile(): string;
|
|
103
121
|
/**
|
|
104
122
|
* Returns true if running on Windows.
|
|
105
123
|
*/
|
package/dist/paths.js
CHANGED
|
@@ -31,6 +31,10 @@ exports.getOpencodeDb = getOpencodeDb;
|
|
|
31
31
|
exports.whichCmd = whichCmd;
|
|
32
32
|
exports.whichAllCmd = whichAllCmd;
|
|
33
33
|
exports.portCheckCmd = portCheckCmd;
|
|
34
|
+
exports.getPortFile = getPortFile;
|
|
35
|
+
exports.writePortFile = writePortFile;
|
|
36
|
+
exports.getPauseSignalFile = getPauseSignalFile;
|
|
37
|
+
exports.getDaemonLogFile = getDaemonLogFile;
|
|
34
38
|
const os_1 = __importDefault(require("os"));
|
|
35
39
|
const path_1 = __importDefault(require("path"));
|
|
36
40
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -189,6 +193,36 @@ function portCheckCmd(port) {
|
|
|
189
193
|
? `netstat -ano | findstr :${port}`
|
|
190
194
|
: `lsof -i :${port}`;
|
|
191
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Returns the port file path. The daemon writes the active port here on startup
|
|
198
|
+
* so the hook script (vanilla JS, no imports) can read it without parsing config.
|
|
199
|
+
*/
|
|
200
|
+
function getPortFile() {
|
|
201
|
+
return path_1.default.join(getClaudestatDir(), 'port');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Writes the active port to disk. Called by the daemon after successful bind.
|
|
205
|
+
*/
|
|
206
|
+
function writePortFile(port) {
|
|
207
|
+
try {
|
|
208
|
+
fs_1.default.mkdirSync(getClaudestatDir(), { recursive: true });
|
|
209
|
+
fs_1.default.writeFileSync(getPortFile(), String(port));
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Returns the path of the pause signal file.
|
|
215
|
+
* When this file exists, the hook shows a warning instead of blocking.
|
|
216
|
+
*/
|
|
217
|
+
function getPauseSignalFile() {
|
|
218
|
+
return path_1.default.join(getClaudestatDir(), 'pause.signal');
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns the daemon log file path.
|
|
222
|
+
*/
|
|
223
|
+
function getDaemonLogFile() {
|
|
224
|
+
return path_1.default.join(getClaudestatDir(), 'daemon.log');
|
|
225
|
+
}
|
|
192
226
|
/**
|
|
193
227
|
* Returns true if running on Windows.
|
|
194
228
|
*/
|
package/dist/routes/helpers.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
/** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
|
|
2
2
|
export declare function findProjectCwdForFile(filePath: string): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Returns the git root directory for the given directory, or undefined.
|
|
5
|
+
* Uses 'git rev-parse --show-toplevel' — fast and reliable.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findGitRoot(fromDir: string): string | undefined;
|
package/dist/routes/helpers.js
CHANGED
|
@@ -4,8 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.findProjectCwdForFile = findProjectCwdForFile;
|
|
7
|
+
exports.findGitRoot = findGitRoot;
|
|
7
8
|
const path_1 = __importDefault(require("path"));
|
|
8
9
|
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
9
11
|
/** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
|
|
10
12
|
function findProjectCwdForFile(filePath) {
|
|
11
13
|
let dir = path_1.default.dirname(filePath);
|
|
@@ -17,5 +19,23 @@ function findProjectCwdForFile(filePath) {
|
|
|
17
19
|
break;
|
|
18
20
|
dir = parent;
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
// Fallback: git root
|
|
23
|
+
return findGitRoot(path_1.default.dirname(filePath));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns the git root directory for the given directory, or undefined.
|
|
27
|
+
* Uses 'git rev-parse --show-toplevel' — fast and reliable.
|
|
28
|
+
*/
|
|
29
|
+
function findGitRoot(fromDir) {
|
|
30
|
+
try {
|
|
31
|
+
const root = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
|
|
32
|
+
cwd: fromDir,
|
|
33
|
+
stdio: 'pipe',
|
|
34
|
+
timeout: 2000,
|
|
35
|
+
}).toString().trim();
|
|
36
|
+
return root || undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
21
41
|
}
|
package/dist/routes/misc.js
CHANGED
|
@@ -62,7 +62,8 @@ exports.miscRouter.get('/intelligence/:sessionId', (req, res) => {
|
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
const events = db_1.dbOps.getSessionEvents(sessionId);
|
|
65
|
-
const
|
|
65
|
+
const cfg = (0, config_1.readConfig)();
|
|
66
|
+
const report = (0, intelligence_1.analyzeSession)(events, session.total_cost_usd ?? 0, cfg.loopThreshold, cfg.loopWindowSecs * 1000);
|
|
66
67
|
res.json({ sessionId, ...report });
|
|
67
68
|
});
|
|
68
69
|
// ─── GET /quota — datos de cuota y burn rate ──────────────────────────────────
|
|
@@ -422,3 +423,20 @@ exports.miscRouter.post('/tool-status', (req, res) => {
|
|
|
422
423
|
(0, stream_1.broadcast)({ type: 'tool_status_changed', payload: { tool, status, last_task: last_task ?? null, finished_at, session_id: session_id ?? null, waiting_for: waiting_for ?? null } });
|
|
423
424
|
res.json({ ok: true });
|
|
424
425
|
});
|
|
426
|
+
// ─── GET /api/logs — últimos N líneas del daemon log ──────────────────────────
|
|
427
|
+
exports.miscRouter.get('/api/logs', (req, res) => {
|
|
428
|
+
const n = Math.min(parseInt(req.query.n, 10) || 50, 500);
|
|
429
|
+
const logFile = (0, paths_1.getDaemonLogFile)();
|
|
430
|
+
try {
|
|
431
|
+
if (!fs_1.default.existsSync(logFile)) {
|
|
432
|
+
res.json({ lines: [] });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const content = fs_1.default.readFileSync(logFile, 'utf8');
|
|
436
|
+
const lines = content.split('\n').filter(Boolean);
|
|
437
|
+
res.json({ lines: lines.slice(-n) });
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
res.json({ lines: [] });
|
|
441
|
+
}
|
|
442
|
+
});
|
package/dist/routes/projects.js
CHANGED
|
@@ -13,6 +13,7 @@ const db_1 = require("../db");
|
|
|
13
13
|
const projects_cache_1 = require("../cache/projects-cache");
|
|
14
14
|
const pattern_analyzer_1 = require("../pattern-analyzer");
|
|
15
15
|
const helpers_1 = require("./helpers");
|
|
16
|
+
const config_1 = require("../config");
|
|
16
17
|
exports.projectsRouter = (0, express_1.Router)();
|
|
17
18
|
/** Infiere el proyecto activo mirando los eventos de archivo de una sesión */
|
|
18
19
|
function inferProjectCwd(events) {
|
|
@@ -134,6 +135,7 @@ exports.projectsRouter.get('/projects', (_req, res) => {
|
|
|
134
135
|
cliHoursMap.get(row.project_path)[row.source] = row.total_ms / 3600000;
|
|
135
136
|
}
|
|
136
137
|
// Attach pattern insights per project (only if DB has enough data)
|
|
138
|
+
const cfg = (0, config_1.readConfig)();
|
|
137
139
|
const projects = [...projectMap.values()].map(p => {
|
|
138
140
|
const toolCounts = db_1.dbOps.getProjectToolCounts(p.path);
|
|
139
141
|
const sessionStats = db_1.dbOps.getProjectSessionStats(p.path);
|
|
@@ -141,7 +143,14 @@ exports.projectsRouter.get('/projects', (_req, res) => {
|
|
|
141
143
|
? (0, pattern_analyzer_1.analyzePatterns)(toolCounts, sessionStats)
|
|
142
144
|
: [];
|
|
143
145
|
const cli_hours = cliHoursMap.get(p.path);
|
|
144
|
-
|
|
146
|
+
const aliasName = cfg.projectAliases[p.path];
|
|
147
|
+
return {
|
|
148
|
+
...p,
|
|
149
|
+
name: aliasName ?? p.name,
|
|
150
|
+
alias: aliasName ?? null,
|
|
151
|
+
insights,
|
|
152
|
+
...(cli_hours ? { cli_hours } : {}),
|
|
153
|
+
};
|
|
145
154
|
})
|
|
146
155
|
.sort((a, b) => (b.last_active ?? 0) - (a.last_active ?? 0));
|
|
147
156
|
res.json({ projects, active_project: activeProject });
|
package/dist/service.js
CHANGED
|
@@ -8,6 +8,14 @@ exports.uninstallService = uninstallService;
|
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const child_process_1 = require("child_process");
|
|
11
|
+
function buildEnvPath() {
|
|
12
|
+
const current = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin';
|
|
13
|
+
const nvmDir = process.env.NVM_DIR;
|
|
14
|
+
if (!nvmDir)
|
|
15
|
+
return current;
|
|
16
|
+
const nvmBin = path_1.default.join(nvmDir, 'versions', 'node', `v${process.versions.node}`, 'bin');
|
|
17
|
+
return current.includes(nvmBin) ? current : `${nvmBin}:${current}`;
|
|
18
|
+
}
|
|
11
19
|
const PLIST_LABEL = 'com.statforge.claudestat';
|
|
12
20
|
const PLIST_PATH = path_1.default.join(process.env.HOME ?? '~', 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
|
|
13
21
|
const SYSTEMD_DIR = path_1.default.join(process.env.HOME ?? '~', '.config', 'systemd', 'user');
|
|
@@ -37,6 +45,8 @@ function makePlist() {
|
|
|
37
45
|
<dict>
|
|
38
46
|
<key>CLAUDESTAT_DAEMON</key>
|
|
39
47
|
<string>1</string>
|
|
48
|
+
<key>PATH</key>
|
|
49
|
+
<string>${buildEnvPath()}</string>
|
|
40
50
|
</dict>
|
|
41
51
|
<key>StandardOutPath</key>
|
|
42
52
|
<string>/tmp/claudestat-daemon.log</string>
|
|
@@ -56,6 +66,7 @@ ExecStart=${serviceCommand()} start
|
|
|
56
66
|
Restart=on-failure
|
|
57
67
|
RestartSec=5
|
|
58
68
|
Environment=CLAUDESTAT_DAEMON=1
|
|
69
|
+
Environment=PATH=${buildEnvPath()}
|
|
59
70
|
|
|
60
71
|
[Install]
|
|
61
72
|
WantedBy=default.target`;
|
package/hooks/event.js
CHANGED
|
@@ -14,8 +14,19 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const eventType = process.argv[2] || 'Unknown'
|
|
17
|
-
const
|
|
18
|
-
const
|
|
17
|
+
const fs = require('fs')
|
|
18
|
+
const os = require('os')
|
|
19
|
+
|
|
20
|
+
// Read port from ~/.claudestat/port (written by daemon on startup). Fallback: 7337.
|
|
21
|
+
let DAEMON_PORT = 7337
|
|
22
|
+
try {
|
|
23
|
+
const portFile = require('path').join(os.homedir(), '.claudestat', 'port')
|
|
24
|
+
const raw = fs.readFileSync(portFile, 'utf8').trim()
|
|
25
|
+
const parsed = parseInt(raw, 10)
|
|
26
|
+
if (!isNaN(parsed) && parsed >= 1024 && parsed <= 65535) DAEMON_PORT = parsed
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
29
|
+
const DAEMON_URL = `http://localhost:${DAEMON_PORT}/event`
|
|
19
30
|
|
|
20
31
|
let rawData = ''
|
|
21
32
|
process.stdin.on('data', chunk => { rawData += chunk })
|
|
@@ -29,36 +40,43 @@ process.stdin.on('end', () => {
|
|
|
29
40
|
...hookData
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
// Para PreToolUse: enviamos el evento Y
|
|
33
|
-
// Si
|
|
43
|
+
// Para PreToolUse: enviamos el evento Y leemos el archivo de pausa local.
|
|
44
|
+
// Si existe pause.signal, mostramos warning y salimos según killSwitchForce.
|
|
34
45
|
if (eventType === 'PreToolUse') {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
fetch(DAEMON_URL, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify(payload),
|
|
50
|
+
signal: AbortSignal.timeout(1500),
|
|
51
|
+
}).catch(() => null)
|
|
52
|
+
|
|
53
|
+
// Check pause signal file (written by daemon when quota threshold reached)
|
|
54
|
+
const signalFile = require('path').join(os.homedir(), '.claudestat', 'pause.signal')
|
|
55
|
+
let signalMsg = null
|
|
56
|
+
try { signalMsg = fs.readFileSync(signalFile, 'utf8').trim() } catch {}
|
|
57
|
+
|
|
58
|
+
if (signalMsg) {
|
|
59
|
+
process.stderr.write(`\n⚠️ claudestat — quota warning\n`)
|
|
60
|
+
process.stderr.write(` ${signalMsg}\n`)
|
|
43
61
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
process.stderr.write(
|
|
54
|
-
process.stderr.write(` ${ks.reason ?? 'Usage quota exceeded.'}\n`)
|
|
55
|
-
process.stderr.write(` To disable: claudestat config --kill-switch false\n\n`)
|
|
62
|
+
// Read config to check killSwitchForce
|
|
63
|
+
let forceBlock = false
|
|
64
|
+
try {
|
|
65
|
+
const cfgPath = require('path').join(os.homedir(), '.claudestat', 'config.json')
|
|
66
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
|
|
67
|
+
forceBlock = cfg.killSwitchForce === true
|
|
68
|
+
} catch {}
|
|
69
|
+
|
|
70
|
+
if (forceBlock) {
|
|
71
|
+
process.stderr.write(` Blocked (killSwitchForce is enabled). Run: claudestat resume\n\n`)
|
|
56
72
|
process.exit(2)
|
|
57
73
|
} else {
|
|
74
|
+
process.stderr.write(` Continuing. To pause manually: claudestat resume (removes signal)\n\n`)
|
|
58
75
|
process.exit(0)
|
|
59
76
|
}
|
|
60
|
-
}
|
|
61
|
-
|
|
77
|
+
} else {
|
|
78
|
+
process.exit(0)
|
|
79
|
+
}
|
|
62
80
|
|
|
63
81
|
} else {
|
|
64
82
|
// Para todos los demás tipos (SessionStart, PostToolUse, Stop):
|