@statforge/claudestat 1.4.0 → 1.5.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +130 -547
  3. package/dist/cost-projector.d.ts +24 -0
  4. package/dist/cost-projector.js +133 -0
  5. package/dist/daemon.js +2 -2
  6. package/dist/db.d.ts +4 -0
  7. package/dist/db.js +14 -6
  8. package/dist/enricher.d.ts +18 -26
  9. package/dist/enricher.js +113 -333
  10. package/dist/index.js +23 -2
  11. package/dist/insights.js +0 -2
  12. package/dist/meta-stats.js +1 -1
  13. package/dist/middleware/rate-limiter.js +1 -1
  14. package/dist/paths.d.ts +17 -0
  15. package/dist/paths.js +45 -1
  16. package/dist/quota-tracker.js +0 -1
  17. package/dist/roast.js +0 -2
  18. package/dist/routes/events.d.ts +0 -2
  19. package/dist/routes/events.js +7 -21
  20. package/dist/routes/helpers.d.ts +2 -0
  21. package/dist/routes/helpers.js +21 -0
  22. package/dist/routes/misc.js +3 -21
  23. package/dist/routes/projects.d.ts +0 -2
  24. package/dist/routes/projects.js +3 -17
  25. package/dist/routes/stream.d.ts +1 -1
  26. package/dist/routes/stream.js +3 -3
  27. package/dist/service.js +11 -7
  28. package/dist/watch.js +0 -1
  29. package/dist/watchdog.d.ts +5 -0
  30. package/dist/watchdog.js +6 -1
  31. package/dist/watchers/adapter.d.ts +37 -0
  32. package/dist/watchers/adapter.js +31 -0
  33. package/dist/watchers/amp.d.ts +8 -0
  34. package/dist/watchers/amp.js +42 -0
  35. package/dist/watchers/claude-code.d.ts +17 -0
  36. package/dist/watchers/claude-code.js +300 -0
  37. package/dist/watchers/codebuff.d.ts +8 -0
  38. package/dist/watchers/codebuff.js +42 -0
  39. package/dist/watchers/codex.d.ts +9 -0
  40. package/dist/watchers/codex.js +43 -0
  41. package/dist/watchers/droid.d.ts +8 -0
  42. package/dist/watchers/droid.js +42 -0
  43. package/dist/watchers/opencode.d.ts +9 -0
  44. package/dist/watchers/opencode.js +43 -0
  45. package/package.json +12 -3
package/dist/paths.js CHANGED
@@ -16,6 +16,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.isWindows = void 0;
19
+ exports.isBinary = isBinary;
20
+ exports.getBinaryDir = getBinaryDir;
21
+ exports.getDashboardDir = getDashboardDir;
19
22
  exports.getClaudeDir = getClaudeDir;
20
23
  exports.getClaudestatDir = getClaudestatDir;
21
24
  exports.getPidFile = getPidFile;
@@ -28,14 +31,55 @@ exports.whichAllCmd = whichAllCmd;
28
31
  exports.portCheckCmd = portCheckCmd;
29
32
  const os_1 = __importDefault(require("os"));
30
33
  const path_1 = __importDefault(require("path"));
34
+ const fs_1 = __importDefault(require("fs"));
31
35
  const isWin = process.platform === 'win32';
36
+ // ─── Binary detection ──────────────────────────────────────────────────────────
37
+ /**
38
+ * Detecta si claudestat está corriendo como binario standalone (Bun compile)
39
+ * vs desde npm (node dist/index.js).
40
+ */
41
+ function isBinary() {
42
+ const arg1 = process.argv[1] ?? '';
43
+ return !arg1.includes('node_modules') && !arg1.includes('dist/index.js')
44
+ && !arg1.includes('tsx') && arg1.includes('claudestat');
45
+ }
46
+ /**
47
+ * Retorna el directorio base del binario o del proyecto.
48
+ * En binario: directorio donde está el ejecutable.
49
+ * En npm: root del proyecto (unimos dist/..).
50
+ */
51
+ function getBinaryDir() {
52
+ if (isBinary()) {
53
+ return path_1.default.dirname(process.argv[1]);
54
+ }
55
+ return path_1.default.join(__dirname, '..');
56
+ }
57
+ /**
58
+ * Retorna el directorio del dashboard build (dashboard/dist/).
59
+ * En binario: busca relativo al binario o en CLAUDESTAT_DATA_DIR.
60
+ * En npm: usa __dirname para encontrar dist/.
61
+ */
62
+ function getDashboardDir() {
63
+ if (isBinary()) {
64
+ const candidates = [
65
+ path_1.default.join(getBinaryDir(), 'dashboard'),
66
+ path_1.default.join(getClaudestatDir(), 'dashboard'),
67
+ path_1.default.join(getBinaryDir(), '..', 'dashboard', 'dist'),
68
+ ];
69
+ for (const c of candidates) {
70
+ if (fs_1.default.existsSync(path_1.default.join(c, 'index.html')))
71
+ return c;
72
+ }
73
+ }
74
+ return path_1.default.join(__dirname, '..', 'dashboard', 'dist');
75
+ }
32
76
  // ─── Claude Code data directory ────────────────────────────────────────────────
33
77
  /**
34
78
  * Returns the Claude Code data directory (~/.claude on all platforms).
35
79
  * Empirically verified: Claude Code CLI stores settings at ~/.claude on macOS, Linux, and Windows.
36
80
  */
37
81
  function getClaudeDir() {
38
- return path_1.default.join(os_1.default.homedir(), '.claude');
82
+ return process.env.CLAUDE_DIR ?? path_1.default.join(os_1.default.homedir(), '.claude');
39
83
  }
40
84
  // ─── ClaudeStat data directory ─────────────────────────────────────────────────
41
85
  /**
@@ -298,7 +298,6 @@ function computeQuota(forcePlan) {
298
298
  const cycleEntries = entries.filter(e => e.ts >= fiveHAgo);
299
299
  const cycleTokens = cycleEntries.reduce((sum, e) => sum + (e.inputTokens ?? 0) + (e.outputTokens ?? 0), 0);
300
300
  const cyclePct = Math.min(100, Math.round(cycleTokens / limits.tokens5h * 100));
301
- const cycleResetMs = Math.max(0, cycleResetAt - now);
302
301
  // ─ Semanal por modelo: ventanas de 5 min con actividad ─
303
302
  // Contamos ventanas de 5 min distintas con al menos 1 respuesta por modelo
304
303
  const sonnetWindows = new Set();
package/dist/roast.js CHANGED
@@ -79,8 +79,6 @@ async function runRoast(opts) {
79
79
  const D = '\x1b[2m';
80
80
  const G = '\x1b[32m';
81
81
  const Y = '\x1b[33m';
82
- const C = '\x1b[36m';
83
- const M = '\x1b[35m';
84
82
  const bar = (pct, width = 20) => {
85
83
  const filled = Math.round(Math.min(pct, 100) / 100 * width);
86
84
  const color = pct >= 90 ? '\x1b[31m' : pct >= 70 ? '\x1b[33m' : '\x1b[32m';
@@ -5,8 +5,6 @@ export declare const lastAgentByCwd: Map<string, {
5
5
  session_id: string;
6
6
  }>;
7
7
  export declare const taggedSessionParents: Set<string>;
8
- /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
9
- export declare function findProjectCwdForFile(filePath: string): string | undefined;
10
8
  /**
11
9
  * Cuando el enricher detecta nuevos tokens en un JSONL:
12
10
  * 1. Corre el análisis de inteligencia
@@ -5,9 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.processLatestForSession = exports.onCompactDetected = exports.onCostUpdate = exports.taggedSessionParents = exports.lastAgentByCwd = exports.eventsRouter = void 0;
8
- exports.findProjectCwdForFile = findProjectCwdForFile;
9
8
  const path_1 = __importDefault(require("path"));
10
- const fs_1 = __importDefault(require("fs"));
11
9
  const express_1 = require("express");
12
10
  const db_1 = require("../db");
13
11
  const intelligence_1 = require("../intelligence");
@@ -18,6 +16,7 @@ const config_1 = require("../config");
18
16
  const rate_limiter_1 = require("../middleware/rate-limiter");
19
17
  const stream_1 = require("./stream");
20
18
  const notifier_1 = require("../notifier");
19
+ const helpers_1 = require("./helpers");
21
20
  const enricher_1 = require("../enricher");
22
21
  Object.defineProperty(exports, "processLatestForSession", { enumerable: true, get: function () { return enricher_1.processLatestForSession; } });
23
22
  // ─── Loop alert cooldown (toolName:sessionId → last alert ts) ─────────────────
@@ -56,33 +55,20 @@ function shouldFireAlert(level, pct) {
56
55
  alertCooldown.set(level, Date.now());
57
56
  return true;
58
57
  }
59
- /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
60
- function findProjectCwdForFile(filePath) {
61
- let dir = path_1.default.dirname(filePath);
62
- for (let i = 0; i < 6; i++) {
63
- if (fs_1.default.existsSync(path_1.default.join(dir, 'HANDOFF.md')))
64
- return dir;
65
- const parent = path_1.default.dirname(dir);
66
- if (parent === dir)
67
- break;
68
- dir = parent;
69
- }
70
- return undefined;
71
- }
72
58
  exports.eventsRouter.post('/event', (req, res) => {
73
59
  const ip = req.ip ?? '127.0.0.1';
74
60
  if ((0, rate_limiter_1.isRateLimited)(ip)) {
75
61
  res.status(429).json({ error: 'Too many requests — wait 1 minute' });
76
62
  return;
77
63
  }
78
- const { type, session_id, tool_name, tool_input, tool_response, ts, cwd, transcript_path } = req.body;
64
+ const { type, session_id, tool_name, tool_input, tool_response, ts, cwd, transcript_path, source } = req.body;
79
65
  if (!session_id || !type) {
80
66
  res.status(400).json({ error: 'Missing session_id or type' });
81
67
  return;
82
68
  }
83
69
  const resolvedCwd = cwd
84
70
  ?? (transcript_path ? path_1.default.dirname(transcript_path) || undefined : undefined);
85
- db_1.dbOps.upsertSession({ id: session_id, cwd: resolvedCwd, started_at: ts, last_event_at: ts });
71
+ db_1.dbOps.upsertSession({ id: session_id, cwd: resolvedCwd, started_at: ts, last_event_at: ts, source });
86
72
  // Skill grouping: get current parent BEFORE processing this event
87
73
  // (the Skill Done event itself is NOT tagged — only its subsequent sub-calls are)
88
74
  const skillParent = (tool_name !== 'Skill' && type !== 'Stop')
@@ -112,7 +98,7 @@ exports.eventsRouter.post('/event', (req, res) => {
112
98
  session_id, type,
113
99
  tool_name: tool_name ?? undefined,
114
100
  tool_input: tool_input ? JSON.stringify(tool_input) : undefined,
115
- ts, cwd: resolvedCwd, skill_parent: skillParent
101
+ ts, cwd: resolvedCwd, skill_parent: skillParent, source
116
102
  });
117
103
  (0, stream_1.broadcast)({ type: 'event', payload: { ...req.body, skill_parent: skillParent } });
118
104
  // Stop limpia el skill activo para esta sesión
@@ -132,7 +118,7 @@ exports.eventsRouter.post('/event', (req, res) => {
132
118
  const inp = typeof tool_input === 'string' ? JSON.parse(tool_input) : (tool_input ?? {});
133
119
  const filePath = inp?.file_path ?? inp?.path;
134
120
  if (typeof filePath === 'string' && path_1.default.isAbsolute(filePath)) {
135
- const projectCwd = findProjectCwdForFile(filePath);
121
+ const projectCwd = (0, helpers_1.findProjectCwdForFile)(filePath);
136
122
  if (projectCwd)
137
123
  db_1.dbOps.updateSessionProject(session_id, projectCwd);
138
124
  }
@@ -208,12 +194,12 @@ exports.eventsRouter.post('/event', (req, res) => {
208
194
  * 2. Guarda el coste + score en DB
209
195
  * 3. Hace broadcast vía SSE para que el watch muestre el coste actualizado
210
196
  */
211
- const onCostUpdate = (sessionId, cost) => {
197
+ const onCostUpdate = (sessionId, cost, source) => {
212
198
  // Ensure session row exists — sub-agent JSONLs arrive from the enricher without a
213
199
  // prior hook event (Claude Code does not fire hooks for sub-agent sessions).
214
200
  let sessionRow = db_1.dbOps.getSession(sessionId);
215
201
  if (!sessionRow) {
216
- db_1.dbOps.upsertSession({ id: sessionId, cwd: undefined, started_at: cost.firstTs ?? Date.now(), last_event_at: cost.firstTs ?? Date.now() });
202
+ db_1.dbOps.upsertSession({ id: sessionId, cwd: undefined, started_at: cost.firstTs ?? Date.now(), last_event_at: cost.firstTs ?? Date.now(), source });
217
203
  sessionRow = db_1.dbOps.getSession(sessionId);
218
204
  }
219
205
  // Sub-agent detection: first time we see a session, check if its firstTs falls after
@@ -0,0 +1,2 @@
1
+ /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
2
+ export declare function findProjectCwdForFile(filePath: string): string | undefined;
@@ -0,0 +1,21 @@
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.findProjectCwdForFile = findProjectCwdForFile;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
10
+ function findProjectCwdForFile(filePath) {
11
+ let dir = path_1.default.dirname(filePath);
12
+ for (let i = 0; i < 6; i++) {
13
+ if (fs_1.default.existsSync(path_1.default.join(dir, 'HANDOFF.md')))
14
+ return dir;
15
+ const parent = path_1.default.dirname(dir);
16
+ if (parent === dir)
17
+ break;
18
+ dir = parent;
19
+ }
20
+ return undefined;
21
+ }
@@ -9,7 +9,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.miscRouter = void 0;
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const fs_1 = __importDefault(require("fs"));
12
- const os_1 = __importDefault(require("os"));
13
12
  const express_1 = require("express");
14
13
  const db_1 = require("../db");
15
14
  const intelligence_1 = require("../intelligence");
@@ -23,6 +22,7 @@ const projects_1 = require("./projects");
23
22
  const session_state_1 = require("../session-state");
24
23
  const stream_1 = require("./stream");
25
24
  const paths_1 = require("../paths");
25
+ const cost_projector_1 = require("../cost-projector");
26
26
  exports.miscRouter = (0, express_1.Router)();
27
27
  // ─── GET /git?path=... — git info para un proyecto ────────────────────────────
28
28
  exports.miscRouter.get('/git', (req, res) => {
@@ -135,7 +135,6 @@ exports.miscRouter.get('/system-config', (_req, res) => {
135
135
  return;
136
136
  }
137
137
  try {
138
- const home = os_1.default.homedir();
139
138
  const claudeDir = (0, paths_1.getClaudeDir)();
140
139
  let hooks = {};
141
140
  try {
@@ -257,24 +256,7 @@ exports.miscRouter.put('/config', (req, res) => {
257
256
  res.status(500).json({ error: String(e) });
258
257
  }
259
258
  });
260
- // ─── GET /cost-projection — weekly/monthly cost projection ──────────────────
259
+ // ─── GET /cost-projection — linear regression cost projection ───────────────
261
260
  exports.miscRouter.get('/cost-projection', (_req, res) => {
262
- const week = db_1.dbOps.getCostProjection(7);
263
- const month = db_1.dbOps.getCostProjection(30);
264
- const weekSpan = week.latest && week.earliest ? (week.latest - week.earliest) / 86400000 : 0;
265
- const monthSpan = month.latest && month.earliest ? (month.latest - month.earliest) / 86400000 : 0;
266
- const weeklyProjected = weekSpan > 0.5 ? (week.total_cost_usd ?? 0) / weekSpan * 7 : 0;
267
- const monthlyProjected = monthSpan > 1 ? (month.total_cost_usd ?? 0) / monthSpan * 30 : 0;
268
- res.json({
269
- weekly: {
270
- daysWithData: Math.round(weekSpan * 10) / 10,
271
- costSoFar: week.total_cost_usd ?? 0,
272
- projected: Math.round(weeklyProjected * 100) / 100,
273
- },
274
- monthly: {
275
- daysWithData: Math.round(monthSpan * 10) / 10,
276
- costSoFar: month.total_cost_usd ?? 0,
277
- projected: Math.round(monthlyProjected * 100) / 100,
278
- },
279
- });
261
+ res.json((0, cost_projector_1.computeProjection)(90));
280
262
  });
@@ -1,7 +1,5 @@
1
1
  import { type EventRow } from '../db';
2
2
  export declare const projectsRouter: import("express-serve-static-core").Router;
3
- /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
4
- export declare function findProjectCwdForFile(filePath: string): string | undefined;
5
3
  /** Infiere el proyecto activo mirando los eventos de archivo de una sesión */
6
4
  export declare function inferProjectCwd(events: EventRow[]): string | undefined;
7
5
  /**
@@ -5,29 +5,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.projectsRouter = void 0;
8
- exports.findProjectCwdForFile = findProjectCwdForFile;
9
8
  exports.inferProjectCwd = inferProjectCwd;
10
9
  exports.inferActiveProjectByMajority = inferActiveProjectByMajority;
11
10
  const path_1 = __importDefault(require("path"));
12
- const fs_1 = __importDefault(require("fs"));
13
11
  const express_1 = require("express");
14
12
  const db_1 = require("../db");
15
13
  const projects_cache_1 = require("../cache/projects-cache");
16
14
  const pattern_analyzer_1 = require("../pattern-analyzer");
15
+ const helpers_1 = require("./helpers");
17
16
  exports.projectsRouter = (0, express_1.Router)();
18
- /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
19
- function findProjectCwdForFile(filePath) {
20
- let dir = path_1.default.dirname(filePath);
21
- for (let i = 0; i < 6; i++) {
22
- if (fs_1.default.existsSync(path_1.default.join(dir, 'HANDOFF.md')))
23
- return dir;
24
- const parent = path_1.default.dirname(dir);
25
- if (parent === dir)
26
- break;
27
- dir = parent;
28
- }
29
- return undefined;
30
- }
31
17
  /** Infiere el proyecto activo mirando los eventos de archivo de una sesión */
32
18
  function inferProjectCwd(events) {
33
19
  const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
@@ -41,7 +27,7 @@ function inferProjectCwd(events) {
41
27
  const filePath = (inp.file_path || inp.path);
42
28
  if (!filePath || !path_1.default.isAbsolute(filePath))
43
29
  continue;
44
- const cwd = findProjectCwdForFile(filePath);
30
+ const cwd = (0, helpers_1.findProjectCwdForFile)(filePath);
45
31
  if (cwd)
46
32
  return cwd;
47
33
  }
@@ -73,7 +59,7 @@ function inferActiveProjectByMajority(events, windowMs) {
73
59
  const filePath = (inp.file_path || inp.path);
74
60
  if (!filePath || !path_1.default.isAbsolute(filePath))
75
61
  continue;
76
- const project = findProjectCwdForFile(filePath);
62
+ const project = (0, helpers_1.findProjectCwdForFile)(filePath);
77
63
  if (!project)
78
64
  continue;
79
65
  const entry = hits.get(project) ?? { count: 0, lastTs: 0 };
@@ -5,4 +5,4 @@ export declare const sessionLastEvent: Map<string, {
5
5
  }>;
6
6
  export declare function broadcast(msg: object): void;
7
7
  export declare function getSseClientsSize(): number;
8
- export declare function setOnCostUpdateRef(cb: (sessionId: string, cost: any) => void): void;
8
+ export declare function setOnCostUpdateRef(cb: (sessionId: string, cost: any, source?: string) => void): void;
@@ -54,13 +54,13 @@ exports.streamRouter.get('/stream', (req, res) => {
54
54
  : allEvents;
55
55
  const lastEvt = exports.sessionLastEvent.get(latestSession.id);
56
56
  const state = (0, session_state_1.deriveSessionState)(lastEvt?.type, lastEvt?.ts ?? latestSession.last_event_at ?? latestSession.started_at);
57
- (0, enricher_1.getAllBlockCostsForSession)(latestSession.id).then(blockCosts => {
57
+ (0, enricher_1.getAllBlockCostsForSession)(latestSession.id).then((blockCosts) => {
58
58
  const subAgentSessions = db_1.dbOps.getChildSessions(latestSession.id);
59
59
  res.write(`data: ${JSON.stringify({ type: 'init', session: { ...latestSession, state }, events, blockCosts, subAgentSessions })}\n\n`);
60
60
  if (_onCostUpdateRef) {
61
- (0, enricher_1.processLatestForSession)(latestSession.id, _onCostUpdateRef).catch(err => console.error('[stream] Error processing latest session:', err));
61
+ (0, enricher_1.processLatestForSession)(latestSession.id, _onCostUpdateRef).catch((err) => console.error('[stream] Error processing latest session:', err));
62
62
  }
63
- }).catch(err => {
63
+ }).catch((err) => {
64
64
  console.error('[stream] Error loading block costs:', err);
65
65
  const subAgentSessions = db_1.dbOps.getChildSessions(latestSession.id);
66
66
  res.write(`data: ${JSON.stringify({ type: 'init', session: { ...latestSession, state }, events, blockCosts: [], subAgentSessions })}\n\n`);
package/dist/service.js CHANGED
@@ -12,9 +12,16 @@ const PLIST_LABEL = 'com.statforge.claudestat';
12
12
  const PLIST_PATH = path_1.default.join(process.env.HOME ?? '~', 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
13
13
  const SYSTEMD_DIR = path_1.default.join(process.env.HOME ?? '~', '.config', 'systemd', 'user');
14
14
  const SYSTEMD_PATH = path_1.default.join(SYSTEMD_DIR, 'claudestat.service');
15
+ function isBinary() {
16
+ return process.argv[1]?.includes('claudestat') && !process.argv[1]?.includes('node_modules')
17
+ && !process.argv[1]?.includes('dist/index.js');
18
+ }
19
+ function serviceCommand() {
20
+ if (isBinary())
21
+ return process.argv[1];
22
+ return `${process.execPath} ${process.argv[1]}`;
23
+ }
15
24
  function makePlist() {
16
- const node = process.execPath;
17
- const script = process.argv[1];
18
25
  return `<?xml version="1.0" encoding="UTF-8"?>
19
26
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
20
27
  <plist version="1.0">
@@ -23,8 +30,7 @@ function makePlist() {
23
30
  <string>${PLIST_LABEL}</string>
24
31
  <key>ProgramArguments</key>
25
32
  <array>
26
- <string>${node}</string>
27
- <string>${script}</string>
33
+ <string>${serviceCommand()}</string>
28
34
  <string>start</string>
29
35
  </array>
30
36
  <key>EnvironmentVariables</key>
@@ -40,15 +46,13 @@ function makePlist() {
40
46
  </plist>`;
41
47
  }
42
48
  function makeUnit() {
43
- const node = process.execPath;
44
- const script = process.argv[1];
45
49
  return `[Unit]
46
50
  Description=ClaudeStat daemon — real-time Claude Code monitor
47
51
  After=default.target
48
52
 
49
53
  [Service]
50
54
  Type=simple
51
- ExecStart=${node} ${script} start
55
+ ExecStart=${serviceCommand()} start
52
56
  Restart=on-failure
53
57
  RestartSec=5
54
58
  Environment=CLAUDESTAT_DAEMON=1
package/dist/watch.js CHANGED
@@ -80,7 +80,6 @@ async function startWatch() {
80
80
  }
81
81
  fetchQuota().then(pct => { state.cyclePct = pct; });
82
82
  setInterval(async () => { state.cyclePct = await fetchQuota(); }, 30000);
83
- // Refrescar stats semanales cada 5 minutos
84
83
  setInterval(() => { state.weekly = (0, weekly_1.readWeeklyStats)(); }, 5 * 60 * 1000);
85
84
  function draw() {
86
85
  if (state.sessionId) {
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * watchdog.ts — Daemon auto-restart mechanism
3
3
  *
4
+ * NOTE: The watchdog currently runs in the same process as the daemon.
5
+ * This means it cannot restart the daemon if the process crashes.
6
+ * For true resilience, the watchdog should be spawned as a separate
7
+ * process via `child_process.spawn()` with `detached: true`.
8
+ *
4
9
  * If the daemon process crashes or is killed unexpectedly, the watchdog
5
10
  * detects the stale PID file and relaunches the daemon automatically.
6
11
  *
package/dist/watchdog.js CHANGED
@@ -2,6 +2,11 @@
2
2
  /**
3
3
  * watchdog.ts — Daemon auto-restart mechanism
4
4
  *
5
+ * NOTE: The watchdog currently runs in the same process as the daemon.
6
+ * This means it cannot restart the daemon if the process crashes.
7
+ * For true resilience, the watchdog should be spawned as a separate
8
+ * process via `child_process.spawn()` with `detached: true`.
9
+ *
5
10
  * If the daemon process crashes or is killed unexpectedly, the watchdog
6
11
  * detects the stale PID file and relaunches the daemon automatically.
7
12
  *
@@ -69,7 +74,7 @@ function startWatchdog() {
69
74
  catch { }
70
75
  restartDaemon();
71
76
  }
72
- }, CHECK_INTERVAL_MS);
77
+ }, CHECK_INTERVAL_MS).unref();
73
78
  process.on('SIGTERM', () => { clearInterval(interval); process.exit(0); });
74
79
  process.on('SIGINT', () => { clearInterval(interval); process.exit(0); });
75
80
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * adapter.ts — WatcherAdapter interface + factory para Multi-CLI support
3
+ *
4
+ * Cada CLI coding (Claude Code, Codex, OpenCode, etc.) tiene su propio
5
+ * formato de trazas y ubicación de archivos. Este adapter pattern permite
6
+ * que claudestat las soporte todas con una interfaz común.
7
+ */
8
+ import type { CostUpdate } from '../db';
9
+ export interface ParsedEvent {
10
+ sessionId: string;
11
+ type: string;
12
+ toolName?: string;
13
+ toolInput?: string;
14
+ ts: number;
15
+ cwd?: string;
16
+ }
17
+ export interface WatcherAdapter {
18
+ /** Nombre único del CLI (claude-code, codex, opencode, etc.) */
19
+ readonly name: string;
20
+ /** Label legible mostrado en UI */
21
+ readonly label: string;
22
+ /** Detecta si este CLI está instalado y tiene trazas disponibles */
23
+ detect(): boolean;
24
+ /** Directorios/glob a observar con chokidar */
25
+ getWatchPaths(): string[];
26
+ /** Parsea una línea de traza en un ParsedEvent */
27
+ parseEvent(raw: string, filePath: string): ParsedEvent | null;
28
+ /** Lee y calcula costos acumulados de un archivo de sesión */
29
+ getSessionCost(filePath: string): Promise<CostUpdate | null>;
30
+ /** Nombre amigable para el badge de la CLI (ej: "CC", "Codex") */
31
+ get shortName(): string;
32
+ }
33
+ export declare function registerAdapter(adapter: WatcherAdapter): void;
34
+ export declare function getAdapter(name: string): WatcherAdapter | undefined;
35
+ export declare function getAllAdapters(): WatcherAdapter[];
36
+ export declare function getActiveAdapters(): WatcherAdapter[];
37
+ export declare function getAdapterNames(): string[];
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * adapter.ts — WatcherAdapter interface + factory para Multi-CLI support
4
+ *
5
+ * Cada CLI coding (Claude Code, Codex, OpenCode, etc.) tiene su propio
6
+ * formato de trazas y ubicación de archivos. Este adapter pattern permite
7
+ * que claudestat las soporte todas con una interfaz común.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.registerAdapter = registerAdapter;
11
+ exports.getAdapter = getAdapter;
12
+ exports.getAllAdapters = getAllAdapters;
13
+ exports.getActiveAdapters = getActiveAdapters;
14
+ exports.getAdapterNames = getAdapterNames;
15
+ // ─── Factory ───────────────────────────────────────────────────────────────────
16
+ const registry = new Map();
17
+ function registerAdapter(adapter) {
18
+ registry.set(adapter.name, adapter);
19
+ }
20
+ function getAdapter(name) {
21
+ return registry.get(name);
22
+ }
23
+ function getAllAdapters() {
24
+ return Array.from(registry.values());
25
+ }
26
+ function getActiveAdapters() {
27
+ return getAllAdapters().filter(a => a.detect());
28
+ }
29
+ function getAdapterNames() {
30
+ return Array.from(registry.keys());
31
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * amp.ts — WatcherAdapter para Amp (coding CLI agent)
3
+ *
4
+ * Amp stores traces in ~/.amp/logs/ as JSONL files.
5
+ * This is a scaffold — fill in parseEvent/getSessionCost with sample traces.
6
+ */
7
+ import { type WatcherAdapter } from './adapter';
8
+ export declare const ampAdapter: WatcherAdapter;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /**
3
+ * amp.ts — WatcherAdapter para Amp (coding CLI agent)
4
+ *
5
+ * Amp stores traces in ~/.amp/logs/ as JSONL files.
6
+ * This is a scaffold — fill in parseEvent/getSessionCost with sample traces.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.ampAdapter = void 0;
13
+ const path_1 = __importDefault(require("path"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const adapter_1 = require("./adapter");
17
+ const AMP_DIR = path_1.default.join(os_1.default.homedir(), '.amp', 'logs');
18
+ exports.ampAdapter = {
19
+ name: 'amp',
20
+ label: 'Amp',
21
+ get shortName() { return 'Amp'; },
22
+ detect() {
23
+ try {
24
+ return fs_1.default.existsSync(AMP_DIR);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ },
30
+ getWatchPaths() {
31
+ return [`${AMP_DIR}/**/*.jsonl`];
32
+ },
33
+ parseEvent(_raw, _filePath) {
34
+ // TODO: implement when Amp trace format is known
35
+ return null;
36
+ },
37
+ async getSessionCost(_filePath) {
38
+ // TODO: implement when Amp trace format is known
39
+ return null;
40
+ },
41
+ };
42
+ (0, adapter_1.registerAdapter)(exports.ampAdapter);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * claude-code.ts — WatcherAdapter para Claude Code
3
+ *
4
+ * Claude Code escribe trazas JSONL en ~/.claude/projects/{hash}/{session-id}.jsonl
5
+ * Cada línea "assistant" contiene usage tokens y modelo.
6
+ */
7
+ import { type WatcherAdapter } from './adapter';
8
+ import type { BlockCostEntry } from '../db';
9
+ export declare function getContextWindow(model: string): number;
10
+ export declare const claudeCodeAdapter: WatcherAdapter;
11
+ export declare function getAllBlockCostsForSession(sessionId: string): Promise<BlockCostEntry[]>;
12
+ export interface SessionPrompt {
13
+ index: number;
14
+ ts: number;
15
+ text: string;
16
+ }
17
+ export declare function getSessionPrompts(sessionId: string): Promise<SessionPrompt[]>;