@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/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;
@@ -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 ≥ LOOP_THRESHOLD veces
26
- * dentro de LOOP_WINDOW_MS. Evita alertas duplicadas con LOOP_COOLDOWN_MS.
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 {};
@@ -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 LOOP_THRESHOLD = 8; // calls para considerar loop
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 ≥ LOOP_THRESHOLD veces
25
- * dentro de LOOP_WINDOW_MS. Evita alertas duplicadas con LOOP_COOLDOWN_MS.
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(); // toolName → timestamps en ventana
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
- // Mantener solo los timestamps dentro de la ventana deslizante
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 >= LOOP_THRESHOLD) {
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 >= LOOP_COOLDOWN_MS) {
48
- alerts.push({ toolName, count: window.length, windowMs: LOOP_WINDOW_MS, ts });
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);
@@ -0,0 +1,6 @@
1
+ export declare const logger: {
2
+ debug: (msg: string) => void;
3
+ info: (msg: string) => void;
4
+ warn: (msg: string) => void;
5
+ error: (msg: string) => void;
6
+ };
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
+ };
@@ -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
  */
@@ -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;
@@ -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
- return undefined;
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
  }
@@ -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 report = (0, intelligence_1.analyzeSession)(events, session.total_cost_usd ?? 0);
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
+ });
@@ -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
- return { ...p, insights, ...(cli_hours ? { cli_hours } : {}) };
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 DAEMON_URL = 'http://localhost:7337/event'
18
- const KILL_SWITCH_URL = 'http://localhost:7337/kill-switch'
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 consultamos el kill-switch en paralelo.
33
- // Si el daemon bloquea, salimos con exit(2) para cancelar la acción.
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
- Promise.all([
36
- // 1. Registrar el evento (fire-and-forget, no nos importa el resultado)
37
- fetch(DAEMON_URL, {
38
- method: 'POST',
39
- headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify(payload),
41
- signal: AbortSignal.timeout(1500),
42
- }).catch(() => null),
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
- // 2. Consultar el kill-switch con timeout corto para no retrasar a Claude
45
- fetch(KILL_SWITCH_URL, {
46
- signal: AbortSignal.timeout(1500),
47
- })
48
- .then(r => r.json())
49
- .catch(() => ({ blocked: false })), // daemon no disponible → fail-open
50
- ])
51
- .then(([_, ks]) => {
52
- if (ks && ks.blocked) {
53
- process.stderr.write(`\n🚫 claudestat kill switch active\n`)
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
- .catch(() => process.exit(0)) // cualquier error → no bloquear
77
+ } else {
78
+ process.exit(0)
79
+ }
62
80
 
63
81
  } else {
64
82
  // Para todos los demás tipos (SessionStart, PostToolUse, Stop):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statforge/claudestat",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"