claude-code-watch 0.1.5 → 0.2.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.
@@ -7,12 +7,35 @@ var os = require('os');
7
7
  var cp = require('child_process');
8
8
  var readline = require('readline');
9
9
  var { WebSocketServer } = require('ws');
10
+ var { compareVersions } = require('../cli-helpers');
10
11
  var { Watcher, listSessions, listActiveSessions } = require('../watcher/watcher');
11
12
  var { setDebugAll, contextWindowFor } = require('../parser/parser');
12
13
  var { fullScanTokenUsage } = require('../scanner/scanner');
13
14
 
14
15
  var PACKAGE_VERSION = require('../../package.json').version;
15
16
 
17
+ function fetchLatestVersion() {
18
+ return new Promise(function(resolve, reject) {
19
+ var opts = {
20
+ hostname: 'registry.npmjs.org',
21
+ path: '/claude-code-watch/latest',
22
+ timeout: 5000,
23
+ };
24
+ var req = require('https').get(opts, function(res) {
25
+ if (res.statusCode !== 200) { reject(new Error('HTTP ' + res.statusCode)); return; }
26
+ var data = '';
27
+ res.on('data', function(chunk) { data += chunk; });
28
+ res.on('end', function() {
29
+ try { resolve(JSON.parse(data).version); }
30
+ catch (err) { reject(err); }
31
+ });
32
+ });
33
+ req.on('error', reject);
34
+ req.on('timeout', function() { req.destroy(); reject(new Error('timeout')); });
35
+ req.end();
36
+ });
37
+ }
38
+
16
39
  var MIME = {
17
40
  '.html': 'text/html; charset=utf-8',
18
41
  '.css': 'text/css; charset=utf-8',
@@ -40,14 +63,22 @@ class DashboardServer {
40
63
  this._flushTimer = null;
41
64
  this._tokenStatsDirty = false;
42
65
 
66
+ // Incremental last-activity tracking: "sessionID:agentID" → { toolName, content }
67
+ this.lastActivities = new Map();
68
+
43
69
  // Time-series token stats: daily aggregation (never cleaned up)
44
70
  // Key: "YYYY-MM-DD", value: { messages, input, output, cacheCreation, cacheRead, models: { modelName: { input, output, cacheCreation, cacheRead } } }
45
71
  this.dailyStats = new Map();
46
72
 
73
+ // Hourly distribution: 24-hour array of API call counts (local timezone)
74
+ this.hourlyStats = new Array(24).fill(0);
75
+
47
76
  this.server = null;
48
77
  this.wss = null;
49
78
  this._heartbeatTimer = null;
50
79
  this._allowedPrefix = null;
80
+ this.latestVersion = null;
81
+ this._versionCheckTimer = null;
51
82
 
52
83
  setDebugAll(options.debugAll || false);
53
84
  this.debugAll = options.debugAll || false;
@@ -114,6 +145,12 @@ class DashboardServer {
114
145
  if (item.cacheCreationTokens) day.cacheCreation += item.cacheCreationTokens;
115
146
  if (item.cacheReadTokens) day.cacheRead += item.cacheReadTokens;
116
147
 
148
+ // Hourly distribution: increment the hour bucket
149
+ const tsDate = new Date(this.itemTime(item));
150
+ if (!isNaN(tsDate.getTime())) {
151
+ this.hourlyStats[tsDate.getHours()]++;
152
+ }
153
+
117
154
  // Per-model breakdown within this day
118
155
  if (item.model) {
119
156
  let m = day.models[item.model];
@@ -192,6 +229,7 @@ class DashboardServer {
192
229
  totals: { messages: totalMessages, input: totalInput, output: totalOutput, cacheCreation: totalCacheCreation, cacheRead: totalCacheRead, days: this.dailyStats.size },
193
230
  modelTotals,
194
231
  daily,
232
+ hourly: this.hourlyStats,
195
233
  };
196
234
  }
197
235
 
@@ -220,9 +258,16 @@ class DashboardServer {
220
258
  const ext = path.extname(filePath).toLowerCase();
221
259
  try {
222
260
  const data = await fs.promises.readFile(filePath);
261
+ // Vendor files (highlight.js, marked, DOMPurify, CSS) are versioned with the
262
+ // package and rarely change — cache for 1 year. Everything else (index.html,
263
+ // favicon) stays no-cache to ensure users always get the latest.
264
+ const isVendor = filePath.includes('/vendor/');
265
+ const cacheControl = isVendor
266
+ ? 'public, max-age=31536000, immutable'
267
+ : 'no-cache, no-store, must-revalidate';
223
268
  res.writeHead(200, {
224
269
  'Content-Type': MIME[ext] || 'application/octet-stream',
225
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
270
+ 'Cache-Control': cacheControl,
226
271
  });
227
272
  res.end(data);
228
273
  } catch {
@@ -394,7 +439,7 @@ class DashboardServer {
394
439
  projectPath: s.projectPath,
395
440
  birthtimeMs: s.birthtimeMs || 0,
396
441
  subagents: Object.entries(s.subagentTypes || s.subagents || {}).reduce((acc, [id, type]) => {
397
- acc[id] = typeof type === 'string' ? type : '';
442
+ acc[id] = { type: typeof type === 'string' ? type : '', birthtimeMs: (s.subagentBirthtimes && s.subagentBirthtimes[id]) || 0 };
398
443
  return acc;
399
444
  }, {}),
400
445
  backgroundTasks: Object.entries(s.backgroundTasks || {}).map(([id, t]) => ({
@@ -405,15 +450,10 @@ class DashboardServer {
405
450
  isComplete: t.isComplete,
406
451
  })),
407
452
  }));
408
- // Compute last activity per agent from itemBuffer (handles skipped history)
453
+ // Use incrementally maintained lastActivities map (O(1) instead of O(itemBuffer))
409
454
  const lastActivities = {};
410
- for (const item of this.itemBuffer) {
411
- const actKey = item.sessionID + ':' + (item.agentID || '');
412
- if (item.type === 'user_text') {
413
- lastActivities[actKey] = { toolName: '', content: (item.content || '').slice(0, 200) };
414
- } else if (item.type === 'tool_input' && item.agentID) {
415
- lastActivities[actKey] = { toolName: item.toolName || '', content: (item.content || '').slice(0, 200) };
416
- }
455
+ for (const [key, val] of this.lastActivities) {
456
+ lastActivities[key] = val;
417
457
  }
418
458
  this.send(ws, 'snapshot', {
419
459
  sessions,
@@ -431,7 +471,17 @@ class DashboardServer {
431
471
  }
432
472
 
433
473
  sendConfig(ws) {
434
- this.send(ws, 'config', { collapseAfter: this.collapseAfterMs, version: PACKAGE_VERSION });
474
+ this.send(ws, 'config', { collapseAfter: this.collapseAfterMs, version: PACKAGE_VERSION, latestVersion: this.latestVersion });
475
+ }
476
+
477
+ _checkLatestVersion() {
478
+ fetchLatestVersion().then((latest) => {
479
+ if (compareVersions(latest, PACKAGE_VERSION) > 0) {
480
+ this.latestVersion = latest;
481
+ // Notify all connected clients
482
+ this.broadcast('config', { collapseAfter: this.collapseAfterMs, version: PACKAGE_VERSION, latestVersion: latest });
483
+ }
484
+ }).catch(() => { /* network unavailable, skip */ });
435
485
  }
436
486
 
437
487
  setupWatcher(watcherOpts) {
@@ -442,6 +492,9 @@ class DashboardServer {
442
492
  for (const key of this.contextMap.keys()) {
443
493
  if (key.startsWith(sessionID + ':')) this.contextMap.delete(key);
444
494
  }
495
+ for (const key of this.lastActivities.keys()) {
496
+ if (key.startsWith(sessionID + ':')) this.lastActivities.delete(key);
497
+ }
445
498
  });
446
499
 
447
500
  const FLUSH_BATCH_LIMIT = 50;
@@ -451,6 +504,16 @@ class DashboardServer {
451
504
  this.itemBuffer = this.itemBuffer.slice(-MAX_ITEM_BUFFER);
452
505
  }
453
506
  this.updateContext(item);
507
+
508
+ // Incrementally track last activity per agent
509
+ if (item.type === 'user_text') {
510
+ const actKey = item.sessionID + ':' + (item.agentID || '');
511
+ this.lastActivities.set(actKey, { toolName: '', content: (item.content || '').slice(0, 200) });
512
+ } else if (item.type === 'tool_input' && item.agentID) {
513
+ const actKey = item.sessionID + ':' + item.agentID;
514
+ this.lastActivities.set(actKey, { toolName: item.toolName || '', content: (item.content || '').slice(0, 200) });
515
+ }
516
+
454
517
  this._pendingItems.push(item);
455
518
  // Track if any item in this batch has token data (for tokenStats broadcast)
456
519
  if (item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens) {
@@ -462,6 +525,7 @@ class DashboardServer {
462
525
  const batch = this._pendingItems;
463
526
  this._pendingItems = [];
464
527
  this.broadcast('itemBatch', batch);
528
+ this.broadcast('context', this.getContextSnapshot());
465
529
  if (this._tokenStatsDirty) {
466
530
  this._tokenStatsDirty = false;
467
531
  this.broadcast('tokenStats', this.getTokenStatsSnapshot());
@@ -476,6 +540,7 @@ class DashboardServer {
476
540
  } else if (batch.length > 1) {
477
541
  this.broadcast('itemBatch', batch);
478
542
  }
543
+ this.broadcast('context', this.getContextSnapshot());
479
544
  if (this._tokenStatsDirty) {
480
545
  this._tokenStatsDirty = false;
481
546
  this.broadcast('tokenStats', this.getTokenStatsSnapshot());
@@ -606,15 +671,18 @@ class DashboardServer {
606
671
  // This runs BEFORE watcher starts, scanning ALL files regardless of age
607
672
  console.log(' Scanning historical token data...');
608
673
  try {
609
- const scannedDaily = await fullScanTokenUsage((done, total) => {
674
+ const scanned = await fullScanTokenUsage((done, total) => {
610
675
  if (total > 0 && (done % 100 === 0 || done === total)) {
611
676
  console.log(` Scanned ${done}/${total} files...`);
612
677
  }
613
678
  });
614
- // Merge scanned data into this.dailyStats
615
- for (const [dateStr, day] of scannedDaily) {
679
+ // Merge scanned data into this.dailyStats and this.hourlyStats
680
+ for (const [dateStr, day] of scanned.dailyStats) {
616
681
  this.dailyStats.set(dateStr, day);
617
682
  }
683
+ for (let h = 0; h < 24; h++) {
684
+ this.hourlyStats[h] += scanned.hourlyStats[h];
685
+ }
618
686
  const totalDays = this.dailyStats.size;
619
687
  const totalMsgs = [...this.dailyStats.values()].reduce((s, d) => s + d.messages, 0);
620
688
  console.log(` Token scan complete: ${totalDays} days, ${totalMsgs.toLocaleString()} messages`);
@@ -637,6 +705,10 @@ class DashboardServer {
637
705
  this._contextCleanupTimer = setInterval(() => this.cleanupContextMap(), CONTEXT_STALE_MS);
638
706
  this._heartbeatTimer = setInterval(() => this.broadcast('heartbeat', null), 30000);
639
707
 
708
+ // Check for latest version on startup and periodically (every hour)
709
+ this._checkLatestVersion();
710
+ this._versionCheckTimer = setInterval(() => this._checkLatestVersion(), 60 * 60 * 1000);
711
+
640
712
  // Start listening and wait for server to be ready before opening browser
641
713
  await new Promise((resolve) => {
642
714
  this.server.listen(this.port, this.host, () => {
@@ -669,6 +741,7 @@ class DashboardServer {
669
741
  stop() {
670
742
  if (this._contextCleanupTimer) clearInterval(this._contextCleanupTimer);
671
743
  if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
744
+ if (this._versionCheckTimer) clearInterval(this._versionCheckTimer);
672
745
  if (this._flushTimer) {
673
746
  clearTimeout(this._flushTimer);
674
747
  this._flushTimer = null;
@@ -134,6 +134,7 @@ class Session {
134
134
  this.birthtimeMs = birthtimeMs || 0;
135
135
  this.subagents = {}; // agentID -> file path
136
136
  this.subagentTypes = {}; // agentID -> agentType
137
+ this.subagentBirthtimes = {}; // agentID -> birthtimeMs
137
138
  this.backgroundTasks = {}; // toolID -> BackgroundTask
138
139
  this.toolIndex = new Map(); // toolID -> { toolName, parentAgentID, hasResult }
139
140
  this.toolIndexPopulated = false;
@@ -275,6 +276,10 @@ class Watcher extends EventEmitter {
275
276
  if (agentType) {
276
277
  session.subagentTypes[agentID] = agentType;
277
278
  }
279
+ try {
280
+ const agentStats = await fsp.stat(jsonlPath);
281
+ session.subagentBirthtimes[agentID] = agentStats.birthtimeMs || agentStats.mtimeMs || 0;
282
+ } catch {}
278
283
  }
279
284
  }
280
285
  } catch (err) {
@@ -312,7 +317,7 @@ class Watcher extends EventEmitter {
312
317
  // Broadcast so connected clients learn about the new session
313
318
  this.emit('broadcast', 'newSession', { sessionID: session.id, projectPath: session.projectPath, birthtimeMs: session.birthtimeMs });
314
319
  for (const [agentID, agentType] of Object.entries(session.subagentTypes)) {
315
- this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
320
+ this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType, birthtimeMs: session.subagentBirthtimes[agentID] || 0 });
316
321
  }
317
322
 
318
323
  const pending = this.pendingSubagents.get(session.id);
@@ -589,7 +594,7 @@ class Watcher extends EventEmitter {
589
594
 
590
595
  // Broadcast pre-existing subagents to frontend
591
596
  for (const [agentID, agentType] of Object.entries(session.subagentTypes)) {
592
- this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
597
+ this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType, birthtimeMs: session.subagentBirthtimes[agentID] || 0 });
593
598
  }
594
599
 
595
600
  // Read initial data from the new session's files
@@ -636,9 +641,13 @@ class Watcher extends EventEmitter {
636
641
 
637
642
  session.subagents[agentID] = p;
638
643
  if (agentType) session.subagentTypes[agentID] = agentType;
644
+ try {
645
+ const agentStats = await fsp.stat(p);
646
+ session.subagentBirthtimes[agentID] = agentStats.birthtimeMs || agentStats.mtimeMs || 0;
647
+ } catch {}
639
648
 
640
649
  this._addFileWatch(p, sessionID, agentID);
641
- this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType });
650
+ this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType, birthtimeMs: session.subagentBirthtimes[agentID] || 0 });
642
651
 
643
652
  // Read initial data from the new subagent file
644
653
  if (this.useFsnotify) {
@@ -726,7 +735,7 @@ class Watcher extends EventEmitter {
726
735
  this.emit('broadcast', 'newSession', { sessionID: c.session.id, projectPath: c.session.projectPath, birthtimeMs: c.session.birthtimeMs });
727
736
 
728
737
  for (const [agentID, agentType] of Object.entries(c.session.subagentTypes)) {
729
- this.emit('broadcast', 'newAgent', { sessionID: c.session.id, agentID, agentType });
738
+ this.emit('broadcast', 'newAgent', { sessionID: c.session.id, agentID, agentType, birthtimeMs: c.session.subagentBirthtimes[agentID] || 0 });
730
739
  }
731
740
 
732
741
  const pending = this.pendingSubagents.get(c.session.id);
@@ -754,8 +763,12 @@ class Watcher extends EventEmitter {
754
763
  const agentType = await readAgentType(agentPath);
755
764
  session.subagents[agentID] = agentPath;
756
765
  if (agentType) session.subagentTypes[agentID] = agentType;
766
+ try {
767
+ const agentStats = await fsp.stat(agentPath);
768
+ session.subagentBirthtimes[agentID] = agentStats.birthtimeMs || agentStats.mtimeMs || 0;
769
+ } catch {}
757
770
 
758
- this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
771
+ this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType, birthtimeMs: session.subagentBirthtimes[agentID] || 0 });
759
772
  }
760
773
  }
761
774
 
@@ -828,59 +841,66 @@ class Watcher extends EventEmitter {
828
841
  ...Object.entries(session.subagents).map(([id, p]) => ({ path: p, agentID: id })),
829
842
  ];
830
843
 
831
- for (const { path: filePath, agentID } of files) {
832
- if (!filePath) continue;
833
- try {
834
- const input = fs.createReadStream(filePath, { encoding: 'utf-8' });
835
- const rl = readline.createInterface({ input, crlfDelay: Infinity });
836
-
837
- for await (const line of rl) {
838
- if (!line.includes('"tool_')) continue;
839
-
840
- if (line.includes('"tool_use"')) {
841
- try {
842
- var raw = JSON.parse(line);
843
- var content = raw.message && raw.message.content;
844
- if (!Array.isArray(content)) continue;
845
- for (var block of content) {
846
- if (block.type !== 'tool_use' || !block.id) continue;
847
- if (session.toolIndex.has(block.id)) continue;
848
- session.toolIndex.set(block.id, {
849
- toolName: block.name || '',
850
- parentAgentID: agentID,
851
- hasResult: false,
852
- });
853
- }
854
- } catch { continue; }
855
- }
844
+ // Read all files in parallel each scans its file independently and writes
845
+ // to session.toolIndex (safe under Node single-threaded event loop).
846
+ await Promise.all(files.map(({ path: filePath, agentID }) =>
847
+ this._scanFileForTools(session, filePath, agentID)
848
+ ));
856
849
 
857
- if (line.includes('"tool_result"')) {
858
- try {
859
- var raw2 = JSON.parse(line);
860
- var content2 = raw2.message && raw2.message.content;
861
- if (!Array.isArray(content2)) continue;
862
- for (var block2 of content2) {
863
- if (block2.type !== 'tool_result' || !block2.tool_use_id) continue;
864
- var tid = block2.tool_use_id;
865
- var existing = session.toolIndex.get(tid);
866
- if (existing) {
867
- existing.hasResult = true;
868
- } else {
869
- session.toolIndex.set(tid, {
870
- toolName: '',
871
- parentAgentID: '',
872
- hasResult: true,
873
- });
874
- }
850
+ session.toolIndexPopulated = true;
851
+ }
852
+
853
+ async _scanFileForTools(session, filePath, agentID) {
854
+ if (!filePath) return;
855
+ try {
856
+ const input = fs.createReadStream(filePath, { encoding: 'utf-8' });
857
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
858
+
859
+ for await (const line of rl) {
860
+ if (!line.includes('"tool_')) continue;
861
+
862
+ if (line.includes('"tool_use"')) {
863
+ try {
864
+ var raw = JSON.parse(line);
865
+ var content = raw.message && raw.message.content;
866
+ if (!Array.isArray(content)) continue;
867
+ for (var block of content) {
868
+ if (block.type !== 'tool_use' || !block.id) continue;
869
+ if (session.toolIndex.has(block.id)) continue;
870
+ session.toolIndex.set(block.id, {
871
+ toolName: block.name || '',
872
+ parentAgentID: agentID,
873
+ hasResult: false,
874
+ });
875
+ }
876
+ } catch { continue; }
877
+ }
878
+
879
+ if (line.includes('"tool_result"')) {
880
+ try {
881
+ var raw2 = JSON.parse(line);
882
+ var content2 = raw2.message && raw2.message.content;
883
+ if (!Array.isArray(content2)) continue;
884
+ for (var block2 of content2) {
885
+ if (block2.type !== 'tool_result' || !block2.tool_use_id) continue;
886
+ var tid = block2.tool_use_id;
887
+ var existing = session.toolIndex.get(tid);
888
+ if (existing) {
889
+ existing.hasResult = true;
890
+ } else {
891
+ session.toolIndex.set(tid, {
892
+ toolName: '',
893
+ parentAgentID: '',
894
+ hasResult: true,
895
+ });
875
896
  }
876
- } catch { continue; }
877
- }
897
+ }
898
+ } catch { continue; }
878
899
  }
879
- } catch (err) {
880
- if (this.debug) console.error('[watcher] _populateToolIndex error reading', filePath + ':', err.message);
881
900
  }
901
+ } catch (err) {
902
+ if (this.debug) console.error('[watcher] _populateToolIndex error reading', filePath + ':', err.message);
882
903
  }
883
- session.toolIndexPopulated = true;
884
904
  }
885
905
 
886
906
  // =========================================================================
@@ -1087,7 +1107,9 @@ class Watcher extends EventEmitter {
1087
1107
  rawLine = rawLine.slice(0, -1);
1088
1108
  }
1089
1109
 
1090
- chunkBytes += Buffer.byteLength(rawLine, 'utf-8') + nlLen;
1110
+ // Fast path: use rawLine.length for ASCII-only lines (1 byte per char).
1111
+ // Only call Buffer.byteLength when multi-byte characters are detected.
1112
+ chunkBytes += (/[^\x00-\x7F]/.test(rawLine) ? Buffer.byteLength(rawLine, 'utf-8') : rawLine.length) + nlLen;
1091
1113
 
1092
1114
  if (!rawLine.trim()) continue;
1093
1115
 
@@ -1251,22 +1273,38 @@ class Watcher extends EventEmitter {
1251
1273
 
1252
1274
  function createWalkDir(readdirFn) {
1253
1275
  const walk = async (dir, callback) => {
1276
+ let entries;
1254
1277
  try {
1255
- const entries = await readdirFn(dir, { withFileTypes: true });
1256
- for (const entry of entries) {
1257
- const fullPath = path.join(dir, entry.name);
1258
- if (entry.isDirectory()) {
1259
- await walk(fullPath, callback);
1260
- } else {
1261
- let stats;
1262
- try { stats = await fsp.stat(fullPath); } catch { continue; }
1263
- callback(fullPath, stats);
1264
- }
1265
- }
1278
+ entries = await readdirFn(dir, { withFileTypes: true });
1266
1279
  } catch (err) {
1267
1280
  if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
1268
1281
  console.error(`[watcher] _walkDir error on ${dir}: ${err.message}`);
1269
1282
  }
1283
+ return;
1284
+ }
1285
+
1286
+ // Separate directories and files for parallel processing
1287
+ const dirs = [];
1288
+ const files = [];
1289
+ for (const entry of entries) {
1290
+ if (entry.isDirectory()) dirs.push(entry);
1291
+ else files.push(entry);
1292
+ }
1293
+
1294
+ // Stat all files in parallel
1295
+ if (files.length > 0) {
1296
+ const results = await Promise.all(files.map(async (entry) => {
1297
+ const fullPath = path.join(dir, entry.name);
1298
+ try { return { path: fullPath, stats: await fsp.stat(fullPath) }; } catch { return null; }
1299
+ }));
1300
+ for (const r of results) {
1301
+ if (r) callback(r.path, r.stats);
1302
+ }
1303
+ }
1304
+
1305
+ // Recurse into subdirectories
1306
+ for (const entry of dirs) {
1307
+ await walk(path.join(dir, entry.name), callback);
1270
1308
  }
1271
1309
  };
1272
1310
  return walk;