@statforge/claudestat 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/enricher.js CHANGED
@@ -1,347 +1,129 @@
1
1
  "use strict";
2
2
  /**
3
- * enricher.ts — Enriquecedor de coste desde JSONL de Claude Code
3
+ * enricher.ts — Watcher multi-CLI usando adapters
4
4
  *
5
- * Claude Code escribe los tokens de cada respuesta en:
6
- * ~/.claude/projects/{project-hash}/{session-id}.jsonl
5
+ * En lugar de hardcodear la lógica de Claude Code, usa el adapter pattern
6
+ * para soportar múltiples coding CLIs (Claude Code, Codex, OpenCode, etc.).
7
7
  *
8
- * Cada línea de tipo "assistant" contiene:
9
- * message.usage.input_tokens
10
- * message.usage.output_tokens
11
- * message.usage.cache_read_input_tokens
12
- * message.usage.cache_creation_input_tokens
13
- * message.model
14
- *
15
- * El enricher observa cambios en esos archivos (con chokidar),
16
- * calcula el coste acumulado por sesión y llama al callback
17
- * para que el daemon actualice la DB y haga broadcast via SSE.
8
+ * Cada adapter implementa WatcherAdapter (src/watchers/adapter.ts):
9
+ * - detect() → si el CLI está instalado
10
+ * - getWatchPaths() → qué archivos observar
11
+ * - parseEvent() → parsear una línea de trace
12
+ * - getSessionCost() → calcular costos acumulados
18
13
  */
19
14
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
21
16
  };
22
17
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.getContextWindow = getContextWindow;
24
- exports.getAllBlockCostsForSession = getAllBlockCostsForSession;
25
- exports.getSessionPrompts = getSessionPrompts;
18
+ exports.getSessionPrompts = exports.getContextWindow = exports.getAllBlockCostsForSession = void 0;
26
19
  exports.startEnricher = startEnricher;
27
20
  exports.stopEnricher = stopEnricher;
28
21
  exports.cleanupSession = cleanupSession;
29
22
  exports.processLatestForSession = processLatestForSession;
30
- const promises_1 = __importDefault(require("fs/promises"));
31
- const fs_1 = __importDefault(require("fs"));
32
- const path_1 = __importDefault(require("path"));
33
23
  const chokidar_1 = __importDefault(require("chokidar"));
34
- const paths_1 = require("./paths");
35
- const pricing_1 = require("./pricing");
36
- // ─── Context window dinámico ──────────────────────────────────────────────────
37
- const KNOWN_CONTEXT_WINDOWS = {
38
- 'claude-opus-4-6': 200000,
39
- 'claude-sonnet-4-6': 200000,
40
- 'claude-haiku-4-5': 200000,
41
- };
42
- function getContextWindow(model) {
43
- return KNOWN_CONTEXT_WINDOWS[model] ?? 200000;
44
- }
45
- const fileOffsets = new Map();
46
- const fileLocks = new Map(); // Lock per file
47
- const FILE_OFFSET_TTL = 30 * 60000; // 30 minutos
48
- function cleanupStaleOffsets() {
49
- const now = Date.now();
50
- for (const [key, entry] of fileOffsets) {
51
- if (now - entry.lastAccess > FILE_OFFSET_TTL)
52
- fileOffsets.delete(key);
24
+ const path_1 = __importDefault(require("path"));
25
+ const fs_1 = __importDefault(require("fs"));
26
+ const adapter_1 = require("./watchers/adapter");
27
+ require("./watchers/claude-code");
28
+ require("./watchers/codex");
29
+ require("./watchers/opencode");
30
+ require("./watchers/amp");
31
+ require("./watchers/droid");
32
+ require("./watchers/codebuff");
33
+ // Re-export Claude Code-specific utilities for routes/stream and routes/misc
34
+ var claude_code_1 = require("./watchers/claude-code");
35
+ Object.defineProperty(exports, "getAllBlockCostsForSession", { enumerable: true, get: function () { return claude_code_1.getAllBlockCostsForSession; } });
36
+ Object.defineProperty(exports, "getContextWindow", { enumerable: true, get: function () { return claude_code_1.getContextWindow; } });
37
+ Object.defineProperty(exports, "getSessionPrompts", { enumerable: true, get: function () { return claude_code_1.getSessionPrompts; } });
38
+ const prevContextBySession = new Map();
39
+ let watcher = null;
40
+ const pendingFiles = new Map();
41
+ const fileLocks = new Map();
42
+ // ─── Adapter lookup por filePath ───────────────────────────────────────────────
43
+ const adapterByDir = new Map();
44
+ function findAdapter(filePath) {
45
+ for (const [dir, adapter] of adapterByDir) {
46
+ if (filePath.startsWith(dir))
47
+ return adapter;
53
48
  }
49
+ return undefined;
54
50
  }
55
- async function processJSONL(filePath) {
56
- // Skip if already processing this file
51
+ // ─── Procesamiento de archivos ─────────────────────────────────────────────────
52
+ async function processFile(filePath) {
57
53
  if (fileLocks.has(filePath))
58
54
  return null;
59
55
  fileLocks.set(filePath, Promise.resolve());
60
- let fileContent;
61
56
  try {
62
- fileContent = await promises_1.default.readFile(filePath, 'utf8');
63
- }
64
- catch {
65
- return null;
66
- }
67
- const currentSize = Buffer.byteLength(fileContent, 'utf8');
68
- const knownEntry = fileOffsets.get(filePath);
69
- const knownOffset = knownEntry?.offset ?? 0;
70
- if (currentSize < knownOffset)
71
- fileOffsets.set(filePath, { offset: 0, lastAccess: Date.now() });
72
- const totals = {
73
- input_tokens: 0, output_tokens: 0,
74
- cache_read: 0, cache_creation: 0, cost_usd: 0,
75
- context_used: 0, context_window: 200000
76
- };
77
- let lastInputUsd = 0;
78
- let lastOutputUsd = 0;
79
- let lastInputTokens = 0;
80
- let lastOutputTokens = 0;
81
- let lastModel = undefined;
82
- let firstTs = undefined;
83
- for (const raw of fileContent.split('\n')) {
84
- const line = raw.trim();
85
- if (!line)
86
- continue;
87
- try {
88
- const obj = JSON.parse(line);
89
- if (obj.type !== 'assistant')
90
- continue;
91
- const msg = obj.message;
92
- if (!msg?.usage)
93
- continue;
94
- const usage = msg.usage;
95
- const model = msg.model ?? 'claude-sonnet-4-6';
96
- if (firstTs === undefined && obj.timestamp) {
97
- try {
98
- firstTs = new Date(obj.timestamp).getTime();
99
- }
100
- catch (e) { /* ignore invalid timestamp */ }
101
- }
102
- totals.input_tokens += usage.input_tokens ?? 0;
103
- totals.output_tokens += usage.output_tokens ?? 0;
104
- totals.cache_read += usage.cache_read_input_tokens ?? 0;
105
- totals.cache_creation += usage.cache_creation_input_tokens ?? 0;
106
- const resolvedModel = model ?? 'claude-sonnet-4-6';
107
- totals.cost_usd += (0, pricing_1.calcCost)(resolvedModel, usage);
108
- totals.context_used = (usage.input_tokens ?? 0)
109
- + (usage.cache_read_input_tokens ?? 0)
110
- + (usage.cache_creation_input_tokens ?? 0);
111
- totals.context_window = getContextWindow(resolvedModel);
112
- const price = pricing_1.PRICING[resolvedModel] ?? pricing_1.DEFAULT_PRICING;
113
- const M = 1000000;
114
- lastInputUsd = ((usage.input_tokens ?? 0) * price.input +
115
- (usage.cache_read_input_tokens ?? 0) * price.cacheRead +
116
- (usage.cache_creation_input_tokens ?? 0) * price.cacheCreate) / M;
117
- lastOutputUsd = ((usage.output_tokens ?? 0) * price.output) / M;
118
- lastInputTokens = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
119
- lastOutputTokens = usage.output_tokens ?? 0;
120
- lastModel = model ?? lastModel;
121
- }
122
- catch (e) {
123
- console.warn('[enricher] Error calculating cost:', e);
124
- }
125
- }
126
- if (lastInputUsd + lastOutputUsd > 0) {
127
- totals.lastEntry = {
128
- inputUsd: lastInputUsd,
129
- outputUsd: lastOutputUsd,
130
- totalUsd: lastInputUsd + lastOutputUsd,
131
- inputTokens: lastInputTokens,
132
- outputTokens: lastOutputTokens,
133
- };
134
- }
135
- totals.lastModel = lastModel;
136
- totals.firstTs = firstTs;
137
- fileOffsets.set(filePath, { offset: currentSize, lastAccess: Date.now() });
138
- fileLocks.delete(filePath);
139
- return totals;
140
- }
141
- const blockCostCache = new Map();
142
- const costCacheLocks = new Map(); // Simple lock flag
143
- const BLOCK_COST_TTL = 5 * 60000;
144
- async function getAllBlockCostsForSession(sessionId) {
145
- // Return cached if available and not expired
146
- const cached = blockCostCache.get(sessionId);
147
- if (cached && Date.now() - cached.ts < BLOCK_COST_TTL)
148
- return cached.data;
149
- // Skip if already calculating for this session
150
- if (costCacheLocks.get(sessionId))
151
- return cached?.data ?? [];
152
- costCacheLocks.set(sessionId, true);
153
- try {
154
- if (!fs_1.default.existsSync(PROJECTS_DIR))
155
- return [];
156
- const dirs = await promises_1.default.readdir(PROJECTS_DIR);
157
- for (const dir of dirs) {
158
- const dirPath = path_1.default.join(PROJECTS_DIR, dir);
159
- try {
160
- const stat = await promises_1.default.stat(dirPath);
161
- if (!stat.isDirectory())
162
- continue;
163
- }
164
- catch {
165
- continue;
166
- }
167
- const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
168
- try {
169
- await promises_1.default.access(filePath);
170
- }
171
- catch {
172
- continue;
173
- }
174
- const result = [];
175
- let current = null;
176
- const content = await promises_1.default.readFile(filePath, 'utf8');
177
- for (const raw of content.split('\n')) {
178
- const line = raw.trim();
179
- if (!line)
180
- continue;
181
- try {
182
- const obj = JSON.parse(line);
183
- if (obj.type === 'human' || obj.type === 'user') {
184
- const msgContent = obj.message?.content;
185
- if (Array.isArray(msgContent) && msgContent[0]?.type === 'tool_result')
186
- continue;
187
- const text = typeof msgContent === 'string' ? msgContent
188
- : Array.isArray(msgContent)
189
- ? (msgContent.find((c) => c?.type === 'text')?.text ?? '')
190
- : '';
191
- if (text.includes('<system-reminder>') || text.includes('<command-name>'))
192
- continue;
193
- current = { inputUsd: 0, outputUsd: 0, totalUsd: 0, inputTokens: 0, outputTokens: 0 };
194
- result.push(current);
195
- }
196
- if (obj.type === 'assistant' && current) {
197
- const usage = obj.message?.usage;
198
- const model = obj.message?.model ?? 'claude-sonnet-4-6';
199
- if (!usage)
200
- continue;
201
- const price = pricing_1.PRICING[model] ?? pricing_1.DEFAULT_PRICING;
202
- const M = 1000000;
203
- const inUsd = ((usage.input_tokens ?? 0) * price.input +
204
- (usage.cache_read_input_tokens ?? 0) * price.cacheRead +
205
- (usage.cache_creation_input_tokens ?? 0) * price.cacheCreate) / M;
206
- const outUsd = ((usage.output_tokens ?? 0) * price.output) / M;
207
- const inTok = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
208
- const outTok = usage.output_tokens ?? 0;
209
- current.inputUsd += inUsd;
210
- current.outputUsd += outUsd;
211
- current.totalUsd += inUsd + outUsd;
212
- current.inputTokens += inTok;
213
- current.outputTokens += outTok;
214
- }
215
- }
216
- catch (e) {
217
- console.warn('[enricher] Error reading JSONL block:', e);
218
- }
219
- }
220
- const filtered = result.filter(b => b.totalUsd > 0);
221
- blockCostCache.set(sessionId, { data: filtered, ts: Date.now() });
222
- return filtered;
223
- }
224
- }
225
- catch (e) {
226
- console.warn('[enricher] Error calculating block costs:', e);
57
+ const adapter = findAdapter(filePath);
58
+ if (!adapter)
59
+ return null;
60
+ const sessionId = path_1.default.basename(filePath, '.jsonl');
61
+ if (!sessionId.includes('-') || sessionId.length < 10)
62
+ return null;
63
+ const cost = await adapter.getSessionCost(filePath);
64
+ if (!cost || cost.cost_usd < 0)
65
+ return null;
66
+ return { sessionId, cost, source: adapter.name };
227
67
  }
228
68
  finally {
229
- costCacheLocks.delete(sessionId);
69
+ fileLocks.delete(filePath);
230
70
  }
231
- return cached?.data ?? [];
232
71
  }
233
- async function getSessionPrompts(sessionId) {
234
- try {
235
- if (!fs_1.default.existsSync(PROJECTS_DIR))
236
- return [];
237
- const dirs = await promises_1.default.readdir(PROJECTS_DIR);
238
- for (const dir of dirs) {
239
- const dirPath = path_1.default.join(PROJECTS_DIR, dir);
240
- try {
241
- const stat = await promises_1.default.stat(dirPath);
242
- if (!stat.isDirectory())
243
- continue;
244
- }
245
- catch {
246
- continue;
247
- }
248
- const candidates = [
249
- path_1.default.join(dirPath, `${sessionId}.jsonl`),
250
- ];
251
- for (const file of candidates) {
252
- try {
253
- await promises_1.default.access(file);
254
- }
255
- catch {
256
- continue;
257
- }
258
- const results = [];
259
- const content = await promises_1.default.readFile(file, 'utf8');
260
- let index = 0;
261
- for (const raw of content.split('\n')) {
262
- const line = raw.trim();
263
- if (!line)
264
- continue;
265
- try {
266
- const obj = JSON.parse(line);
267
- if (obj.type !== 'human' && obj.type !== 'user')
268
- continue;
269
- const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : 0;
270
- if (!ts || isNaN(ts))
271
- continue;
272
- const msgContent = obj.message?.content;
273
- let text = '';
274
- if (typeof msgContent === 'string') {
275
- text = msgContent;
276
- }
277
- else if (Array.isArray(msgContent)) {
278
- const textBlocks = msgContent.filter(c => c?.type === 'text');
279
- if (textBlocks.length === 0)
280
- continue;
281
- text = textBlocks.map((c) => c.text ?? '').join('\n').trim();
282
- }
283
- if (text.includes('<command-name>') ||
284
- text.includes('<local-command-stdout>') ||
285
- text.includes('<system-reminder>') ||
286
- text.length === 0)
287
- continue;
288
- index++;
289
- results.push({ index, ts, text });
290
- }
291
- catch { }
292
- }
293
- return results;
294
- }
295
- }
296
- }
297
- catch { }
298
- return [];
299
- }
300
- // ─── Watcher ─────────────────────────────────────────────────────────────────
301
- const PROJECTS_DIR = path_1.default.join((0, paths_1.getClaudeDir)(), 'projects');
302
- const prevContextBySession = new Map();
303
- let watcher = null;
304
- const pendingFiles = new Map();
72
+ // ─── Start / Stop ──────────────────────────────────────────────────────────────
305
73
  let offsetCleanupInterval = null;
306
- function startEnricher(onUpdate, onCompact, onSessionEnd) {
307
- if (!fs_1.default.existsSync(PROJECTS_DIR)) {
308
- console.warn(`[enricher] Directory not found: ${PROJECTS_DIR}`);
74
+ function startEnricher(onUpdate, onCompact) {
75
+ const adapters = (0, adapter_1.getActiveAdapters)();
76
+ if (adapters.length === 0) {
77
+ console.warn('[enricher] No supported CLI tools detected');
309
78
  return;
310
79
  }
311
- watcher = chokidar_1.default.watch(`${PROJECTS_DIR}/**/*.jsonl`, {
80
+ // Index directories for adapter lookup
81
+ for (const a of adapters) {
82
+ for (const watchPath of a.getWatchPaths()) {
83
+ // Extract base directory from glob pattern
84
+ const baseDir = watchPath.split('/**')[0];
85
+ if (baseDir)
86
+ adapterByDir.set(baseDir, a);
87
+ }
88
+ }
89
+ const watchPaths = adapters.flatMap(a => a.getWatchPaths());
90
+ console.log(`[enricher] Watching ${adapters.map(a => a.label).join(', ')}`);
91
+ watcher = chokidar_1.default.watch(watchPaths, {
312
92
  persistent: true,
313
93
  ignoreInitial: true,
314
94
  awaitWriteFinish: {
315
95
  stabilityThreshold: 200,
316
- pollInterval: 100
317
- }
96
+ pollInterval: 100,
97
+ },
318
98
  });
319
99
  const handleFile = (filePath) => {
320
- const sessionId = path_1.default.basename(filePath, '.jsonl');
321
- if (!sessionId.includes('-') || sessionId.length < 10)
322
- return;
323
100
  const existing = pendingFiles.get(filePath);
324
101
  if (existing)
325
102
  clearTimeout(existing);
326
- const timer = setTimeout(() => {
103
+ const timer = setTimeout(async () => {
327
104
  pendingFiles.delete(filePath);
328
- processJSONL(filePath).then(cost => {
329
- if (cost && cost.cost_usd >= 0) {
330
- const prev = prevContextBySession.get(sessionId);
331
- if (onCompact && prev !== undefined && prev > 140000 && cost.context_used < prev * 0.5) {
332
- onCompact(sessionId);
333
- }
334
- prevContextBySession.set(sessionId, cost.context_used);
335
- onUpdate(sessionId, cost);
336
- }
337
- }).catch(err => console.error('[enricher] Error processing JSONL:', err));
105
+ const result = await processFile(filePath);
106
+ if (!result)
107
+ return;
108
+ const { sessionId, cost, source } = result;
109
+ const prev = prevContextBySession.get(sessionId);
110
+ if (onCompact && prev !== undefined && prev > 140000 && cost.context_used < prev * 0.5) {
111
+ onCompact(sessionId);
112
+ }
113
+ prevContextBySession.set(sessionId, cost.context_used);
114
+ onUpdate(sessionId, cost, source);
338
115
  }, 100);
339
116
  pendingFiles.set(filePath, timer);
340
117
  };
341
118
  watcher.on('change', handleFile);
342
119
  watcher.on('add', handleFile);
343
- offsetCleanupInterval = setInterval(cleanupStaleOffsets, 5 * 60000);
344
- console.log(`[enricher] Watching ${PROJECTS_DIR}`);
120
+ offsetCleanupInterval = setInterval(() => {
121
+ for (const [, adapter] of adapterByDir) {
122
+ if (typeof adapter.constructor?.name === 'undefined') {
123
+ // cleanup per-adapter state if needed
124
+ }
125
+ }
126
+ }, 5 * 60000);
345
127
  }
346
128
  function stopEnricher() {
347
129
  if (watcher) {
@@ -355,46 +137,44 @@ function stopEnricher() {
355
137
  for (const [, timer] of pendingFiles)
356
138
  clearTimeout(timer);
357
139
  pendingFiles.clear();
358
- fileOffsets.clear();
359
140
  prevContextBySession.clear();
360
- blockCostCache.clear();
141
+ adapterByDir.clear();
361
142
  console.log('[enricher] Stopped');
362
143
  }
363
144
  function cleanupSession(sessionId) {
364
- blockCostCache.delete(sessionId);
365
145
  prevContextBySession.delete(sessionId);
366
- for (const [key, entry] of fileOffsets) {
367
- if (key.includes(sessionId))
368
- fileOffsets.delete(key);
369
- }
370
146
  }
371
- async function processLatestForSession(sessionId, onUpdate) {
372
- try {
373
- if (!fs_1.default.existsSync(PROJECTS_DIR))
374
- return;
375
- const dirs = await promises_1.default.readdir(PROJECTS_DIR);
376
- for (const dir of dirs) {
377
- const dirPath = path_1.default.join(PROJECTS_DIR, dir);
378
- try {
379
- const stat = await promises_1.default.stat(dirPath);
380
- if (!stat.isDirectory())
381
- continue;
382
- }
383
- catch {
147
+ // ─── Legacy: processLatestForSession (now adapter-agnostic) ─────────────────────
148
+ async function processLatestForSession(sessionId, onUpdate, source) {
149
+ const adapters = source
150
+ ? [(0, adapter_1.getAdapter)(source)].filter(Boolean)
151
+ : (0, adapter_1.getActiveAdapters)();
152
+ for (const adapter of adapters) {
153
+ for (const watchPath of adapter.getWatchPaths()) {
154
+ const baseDir = watchPath.split('/**')[0];
155
+ if (!baseDir)
384
156
  continue;
385
- }
386
- const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
387
- try {
388
- await promises_1.default.access(filePath);
389
- }
390
- catch {
157
+ if (!fs_1.default.existsSync(baseDir))
391
158
  continue;
159
+ const dirs = fs_1.default.readdirSync(baseDir);
160
+ for (const dir of dirs) {
161
+ const dirPath = path_1.default.join(baseDir, dir);
162
+ try {
163
+ if (!fs_1.default.statSync(dirPath).isDirectory())
164
+ continue;
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
170
+ if (!fs_1.default.existsSync(filePath))
171
+ continue;
172
+ const cost = await adapter.getSessionCost(filePath);
173
+ if (cost && cost.cost_usd >= 0) {
174
+ onUpdate(sessionId, cost, adapter.name);
175
+ }
176
+ return;
392
177
  }
393
- const cost = await processJSONL(filePath);
394
- if (cost && cost.cost_usd >= 0)
395
- onUpdate(sessionId, cost);
396
- return;
397
178
  }
398
179
  }
399
- catch { }
400
180
  }
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ const export_1 = require("./export");
29
29
  const config_1 = require("./config");
30
30
  const doctor_1 = require("./doctor");
31
31
  const roast_1 = require("./roast");
32
+ const cost_projector_1 = require("./cost-projector");
32
33
  const insights_1 = require("./insights");
33
34
  const paths_1 = require("./paths");
34
35
  const quota_tracker_1 = require("./quota-tracker");
@@ -200,11 +201,20 @@ program
200
201
  console.log('✅ claudestat fully removed');
201
202
  process.exit(0);
202
203
  }
203
- console.log('Setting up claudestat...');
204
- (0, install_1.installHooks)();
205
- (0, service_1.installService)();
206
- console.log('✅ claudestat is running and will start automatically on login');
207
- console.log(' Dashboard → http://localhost:7337');
204
+ // 1. Wizard: Node check + plan + config + hooks + MCP
205
+ await (0, install_1.runWizard)();
206
+ // 2. Start daemon now
207
+ const daemonRunning = await fetch('http://localhost:7337/health', {
208
+ signal: AbortSignal.timeout(2000),
209
+ }).then(r => r.ok).catch(() => false);
210
+ if (!daemonRunning) {
211
+ spawnDaemon();
212
+ }
213
+ else {
214
+ console.log('✅ Daemon already running');
215
+ console.log(' Dashboard → http://localhost:7337');
216
+ }
217
+ console.log('\n Run \x1b[36mclaudestat watch\x1b[0m to see live activity');
208
218
  process.exit(0);
209
219
  });
210
220
  program
@@ -238,14 +248,13 @@ program
238
248
  .action(async (opts) => {
239
249
  try {
240
250
  await (0, quota_tracker_1.refreshFromApi)(); // refresh disk cache on demand; daemon reads from disk
241
- const [quotaRes, healthRes] = await Promise.all([
251
+ const [quotaRes] = await Promise.all([
242
252
  fetch('http://localhost:7337/quota'),
243
253
  fetch('http://localhost:7337/health'),
244
254
  ]);
245
255
  if (!quotaRes.ok)
246
256
  throw new Error('Daemon unavailable');
247
257
  const q = await quotaRes.json();
248
- const _h = await healthRes.json().catch(() => ({}));
249
258
  if (opts.json) {
250
259
  console.log(JSON.stringify({
251
260
  cyclePrompts: q.cyclePrompts,
@@ -315,6 +324,7 @@ program
315
324
  .option('--threshold <number>', 'Quota percentage to trigger the kill switch (default: 95)')
316
325
  .option('--plan <plan>', 'Force plan detection: pro|max5|max20|auto')
317
326
  .option('--alerts <bool>', 'Enable/disable daemon rate limit alerts: true|false')
327
+ .option('--session-limit <usd>', 'Alert when a session exceeds this cost in USD (0 = disabled)')
318
328
  .action((opts) => {
319
329
  const cfg = (0, config_1.readConfig)();
320
330
  let changed = false;
@@ -344,6 +354,15 @@ program
344
354
  cfg.alertsEnabled = opts.alerts === 'true';
345
355
  changed = true;
346
356
  }
357
+ if (opts.sessionLimit !== undefined) {
358
+ const v = parseFloat(opts.sessionLimit);
359
+ if (!isNaN(v) && v >= 0) {
360
+ cfg.sessionCostLimitUsd = v;
361
+ changed = true;
362
+ }
363
+ else
364
+ console.warn(' ⚠️ session-limit must be a number >= 0 (e.g. 5 for $5)');
365
+ }
347
366
  if (changed) {
348
367
  (0, config_1.writeConfig)(cfg);
349
368
  console.log('✅ Config saved to ~/.claudestat/config.json');
@@ -373,6 +392,7 @@ program
373
392
  if (cfg.killSwitchEnabled) {
374
393
  lines.push(` ${bar(cfg.killSwitchThreshold)}`);
375
394
  }
395
+ lines.push(` Session limit ${cfg.sessionCostLimitUsd > 0 ? `${Y}$${cfg.sessionCostLimitUsd.toFixed(2)}${R}` : `${D}OFF${R}`}`);
376
396
  lines.push('');
377
397
  lines.push(` Cycle thresholds ${cfg.warnThresholds.join('%, ')}%`);
378
398
  lines.push(` ${D}yellow${R} ${bar(cfg.warnThresholds[0], 8)} ${D}orange${R} ${bar(cfg.warnThresholds[1], 8)} ${D}red${R} ${bar(cfg.warnThresholds[2], 8)}`);
@@ -536,4 +556,25 @@ program
536
556
  process.exit(1);
537
557
  }
538
558
  });
559
+ program
560
+ .command('project')
561
+ .description('Show cost projection with linear regression')
562
+ .option('--days <number>', 'Look back N days for data (default 90)')
563
+ .option('--json', 'Output raw JSON')
564
+ .action((opts) => {
565
+ try {
566
+ const days = Math.max(7, Math.min(365, parseInt(opts.days ?? '90', 10) || 90));
567
+ const p = (0, cost_projector_1.computeProjection)(days);
568
+ if (opts.json) {
569
+ console.log(JSON.stringify(p, null, 2));
570
+ process.exit(0);
571
+ }
572
+ console.log((0, cost_projector_1.formatProjection)(p));
573
+ process.exit(0);
574
+ }
575
+ catch (err) {
576
+ console.error('\n❌ Error:', err.message);
577
+ process.exit(1);
578
+ }
579
+ });
539
580
  program.parse();
package/dist/insights.js CHANGED
@@ -223,8 +223,6 @@ function renderWeeklyInsight(d) {
223
223
  const R = '\x1b[0m';
224
224
  const B = '\x1b[1m';
225
225
  const D = '\x1b[2m';
226
- const G = '\x1b[32m';
227
- const Y = '\x1b[33m';
228
226
  const C = '\x1b[36m';
229
227
  const bar = (pct, width = 20) => {
230
228
  const filled = Math.round(Math.min(pct, 100) / 100 * width);
@@ -74,7 +74,7 @@ function fmtTok(n) {
74
74
  * - {project}/AGENTS.md — instructions for agent mode
75
75
  * - {project}/.claude/CLAUDE.md — alternative project-level location
76
76
  */
77
- function resolveContextCandidates(homeDir, projectCwd) {
77
+ function resolveContextCandidates(_homeDir, projectCwd) {
78
78
  const claudeDir = (0, paths_1.getClaudeDir)();
79
79
  const candidates = [
80
80
  { label: 'CLAUDE.md (global)', filePath: path_1.default.join(claudeDir, 'CLAUDE.md') },
@@ -23,7 +23,7 @@ const rateLimitCleanupInterval = setInterval(() => {
23
23
  const now = Date.now();
24
24
  rateLimitMap.forEach((v, k) => { if (now - v.windowStart > RATE_LIMIT_WINDOW_MS)
25
25
  rateLimitMap.delete(k); });
26
- }, 5 * 60000);
26
+ }, 5 * 60000).unref();
27
27
  function stopRateLimiter() {
28
28
  clearInterval(rateLimitCleanupInterval);
29
29
  rateLimitMap.clear();