claude-code-watch 0.1.4 → 0.2.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.
@@ -9,6 +9,7 @@ var readline = require('readline');
9
9
  var { WebSocketServer } = require('ws');
10
10
  var { Watcher, listSessions, listActiveSessions } = require('../watcher/watcher');
11
11
  var { setDebugAll, contextWindowFor } = require('../parser/parser');
12
+ var { fullScanTokenUsage } = require('../scanner/scanner');
12
13
 
13
14
  var PACKAGE_VERSION = require('../../package.json').version;
14
15
 
@@ -37,6 +38,17 @@ class DashboardServer {
37
38
  this._contextCleanupTimer = null;
38
39
  this._pendingItems = [];
39
40
  this._flushTimer = null;
41
+ this._tokenStatsDirty = false;
42
+
43
+ // Incremental last-activity tracking: "sessionID:agentID" → { toolName, content }
44
+ this.lastActivities = new Map();
45
+
46
+ // Time-series token stats: daily aggregation (never cleaned up)
47
+ // Key: "YYYY-MM-DD", value: { messages, input, output, cacheCreation, cacheRead, models: { modelName: { input, output, cacheCreation, cacheRead } } }
48
+ this.dailyStats = new Map();
49
+
50
+ // Hourly distribution: 24-hour array of API call counts (local timezone)
51
+ this.hourlyStats = new Array(24).fill(0);
40
52
 
41
53
  this.server = null;
42
54
  this.wss = null;
@@ -67,6 +79,12 @@ class DashboardServer {
67
79
  return Date.now();
68
80
  }
69
81
 
82
+ _getDateKey(ts) {
83
+ let d = new Date(ts);
84
+ if (isNaN(d.getTime())) d = new Date();
85
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
86
+ }
87
+
70
88
  updateContext(item) {
71
89
  const key = this.getCtxKey(item.sessionID, item.agentID);
72
90
  let ctx = this.contextMap.get(key);
@@ -85,6 +103,42 @@ class DashboardServer {
85
103
  ctx.contextWindow = contextWindowFor(item.model);
86
104
  }
87
105
  ctx.lastActivity = Math.max(ctx.lastActivity || 0, this.itemTime(item));
106
+
107
+ // ── Time-series aggregation for token stats ──
108
+ // All 4 token fields are summed (incremental for billing/consumption perspective)
109
+ const hasTokens = item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens;
110
+ if (hasTokens) {
111
+ const dateKey = this._getDateKey(this.itemTime(item));
112
+ let day = this.dailyStats.get(dateKey);
113
+ if (!day) {
114
+ day = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {} };
115
+ this.dailyStats.set(dateKey, day);
116
+ }
117
+ day.messages++;
118
+ if (item.inputTokens) day.input += item.inputTokens;
119
+ if (item.outputTokens) day.output += item.outputTokens;
120
+ if (item.cacheCreationTokens) day.cacheCreation += item.cacheCreationTokens;
121
+ if (item.cacheReadTokens) day.cacheRead += item.cacheReadTokens;
122
+
123
+ // Hourly distribution: increment the hour bucket
124
+ const tsDate = new Date(this.itemTime(item));
125
+ if (!isNaN(tsDate.getTime())) {
126
+ this.hourlyStats[tsDate.getHours()]++;
127
+ }
128
+
129
+ // Per-model breakdown within this day
130
+ if (item.model) {
131
+ let m = day.models[item.model];
132
+ if (!m) {
133
+ m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
134
+ day.models[item.model] = m;
135
+ }
136
+ if (item.inputTokens) m.input += item.inputTokens;
137
+ if (item.outputTokens) m.output += item.outputTokens;
138
+ if (item.cacheCreationTokens) m.cacheCreation += item.cacheCreationTokens;
139
+ if (item.cacheReadTokens) m.cacheRead += item.cacheReadTokens;
140
+ }
141
+ }
88
142
  }
89
143
 
90
144
  cleanupContextMap() {
@@ -112,6 +166,48 @@ class DashboardServer {
112
166
  return result;
113
167
  }
114
168
 
169
+ getTokenStatsSnapshot() {
170
+ // Convert dailyStats Map to plain object, sorted by date descending
171
+ const daily = {};
172
+ const sortedKeys = [...this.dailyStats.keys()].sort().reverse();
173
+ for (const k of sortedKeys) {
174
+ const d = this.dailyStats.get(k);
175
+ daily[k] = {
176
+ messages: d.messages,
177
+ input: d.input,
178
+ output: d.output,
179
+ cacheCreation: d.cacheCreation,
180
+ cacheRead: d.cacheRead,
181
+ models: d.models,
182
+ };
183
+ }
184
+
185
+ // Compute global totals
186
+ let totalMessages = 0, totalInput = 0, totalOutput = 0, totalCacheCreation = 0, totalCacheRead = 0;
187
+ const modelTotals = {};
188
+ for (const [, d] of this.dailyStats) {
189
+ totalMessages += d.messages;
190
+ totalInput += d.input;
191
+ totalOutput += d.output;
192
+ totalCacheCreation += d.cacheCreation;
193
+ totalCacheRead += d.cacheRead;
194
+ for (const [modelName, m] of Object.entries(d.models)) {
195
+ if (!modelTotals[modelName]) modelTotals[modelName] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
196
+ modelTotals[modelName].input += m.input;
197
+ modelTotals[modelName].output += m.output;
198
+ modelTotals[modelName].cacheCreation += m.cacheCreation;
199
+ modelTotals[modelName].cacheRead += m.cacheRead;
200
+ }
201
+ }
202
+
203
+ return {
204
+ totals: { messages: totalMessages, input: totalInput, output: totalOutput, cacheCreation: totalCacheCreation, cacheRead: totalCacheRead, days: this.dailyStats.size },
205
+ modelTotals,
206
+ daily,
207
+ hourly: this.hourlyStats,
208
+ };
209
+ }
210
+
115
211
  broadcast(type, payload) {
116
212
  const msg = JSON.stringify({ type, payload });
117
213
  const toRemove = [];
@@ -137,9 +233,16 @@ class DashboardServer {
137
233
  const ext = path.extname(filePath).toLowerCase();
138
234
  try {
139
235
  const data = await fs.promises.readFile(filePath);
236
+ // Vendor files (highlight.js, marked, DOMPurify, CSS) are versioned with the
237
+ // package and rarely change — cache for 1 year. Everything else (index.html,
238
+ // favicon) stays no-cache to ensure users always get the latest.
239
+ const isVendor = filePath.includes('/vendor/');
240
+ const cacheControl = isVendor
241
+ ? 'public, max-age=31536000, immutable'
242
+ : 'no-cache, no-store, must-revalidate';
140
243
  res.writeHead(200, {
141
244
  'Content-Type': MIME[ext] || 'application/octet-stream',
142
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
245
+ 'Cache-Control': cacheControl,
143
246
  });
144
247
  res.end(data);
145
248
  } catch {
@@ -207,6 +310,11 @@ class DashboardServer {
207
310
  return;
208
311
  }
209
312
 
313
+ if (route === '/token-stats') {
314
+ this.sendJSON(res, this.getTokenStatsSnapshot());
315
+ return;
316
+ }
317
+
210
318
  if (route === '/task-output') {
211
319
  const filePath = params.get('path');
212
320
  if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
@@ -262,6 +370,7 @@ class DashboardServer {
262
370
  this.sendSnapshot(ws);
263
371
  this.sendItemBatch(ws);
264
372
  this.sendContext(ws);
373
+ this.sendTokenStats(ws);
265
374
  this.sendConfig(ws);
266
375
  }
267
376
 
@@ -294,6 +403,10 @@ class DashboardServer {
294
403
  try { ws.send(JSON.stringify({ type, payload })); } catch {}
295
404
  }
296
405
 
406
+ sendTokenStats(ws) {
407
+ this.send(ws, 'tokenStats', this.getTokenStatsSnapshot());
408
+ }
409
+
297
410
  sendSnapshot(ws) {
298
411
  if (!this.watcher) return;
299
412
  const sessions = this.watcher.getSessionsSnapshot().map(s => ({
@@ -301,7 +414,7 @@ class DashboardServer {
301
414
  projectPath: s.projectPath,
302
415
  birthtimeMs: s.birthtimeMs || 0,
303
416
  subagents: Object.entries(s.subagentTypes || s.subagents || {}).reduce((acc, [id, type]) => {
304
- acc[id] = typeof type === 'string' ? type : '';
417
+ acc[id] = { type: typeof type === 'string' ? type : '', birthtimeMs: (s.subagentBirthtimes && s.subagentBirthtimes[id]) || 0 };
305
418
  return acc;
306
419
  }, {}),
307
420
  backgroundTasks: Object.entries(s.backgroundTasks || {}).map(([id, t]) => ({
@@ -312,15 +425,10 @@ class DashboardServer {
312
425
  isComplete: t.isComplete,
313
426
  })),
314
427
  }));
315
- // Compute last activity per agent from itemBuffer (handles skipped history)
428
+ // Use incrementally maintained lastActivities map (O(1) instead of O(itemBuffer))
316
429
  const lastActivities = {};
317
- for (const item of this.itemBuffer) {
318
- const actKey = item.sessionID + ':' + (item.agentID || '');
319
- if (item.type === 'user_text') {
320
- lastActivities[actKey] = { toolName: '', content: (item.content || '').slice(0, 200) };
321
- } else if (item.type === 'tool_input' && item.agentID) {
322
- lastActivities[actKey] = { toolName: item.toolName || '', content: (item.content || '').slice(0, 200) };
323
- }
430
+ for (const [key, val] of this.lastActivities) {
431
+ lastActivities[key] = val;
324
432
  }
325
433
  this.send(ws, 'snapshot', {
326
434
  sessions,
@@ -349,6 +457,9 @@ class DashboardServer {
349
457
  for (const key of this.contextMap.keys()) {
350
458
  if (key.startsWith(sessionID + ':')) this.contextMap.delete(key);
351
459
  }
460
+ for (const key of this.lastActivities.keys()) {
461
+ if (key.startsWith(sessionID + ':')) this.lastActivities.delete(key);
462
+ }
352
463
  });
353
464
 
354
465
  const FLUSH_BATCH_LIMIT = 50;
@@ -358,13 +469,32 @@ class DashboardServer {
358
469
  this.itemBuffer = this.itemBuffer.slice(-MAX_ITEM_BUFFER);
359
470
  }
360
471
  this.updateContext(item);
472
+
473
+ // Incrementally track last activity per agent
474
+ if (item.type === 'user_text') {
475
+ const actKey = item.sessionID + ':' + (item.agentID || '');
476
+ this.lastActivities.set(actKey, { toolName: '', content: (item.content || '').slice(0, 200) });
477
+ } else if (item.type === 'tool_input' && item.agentID) {
478
+ const actKey = item.sessionID + ':' + item.agentID;
479
+ this.lastActivities.set(actKey, { toolName: item.toolName || '', content: (item.content || '').slice(0, 200) });
480
+ }
481
+
361
482
  this._pendingItems.push(item);
483
+ // Track if any item in this batch has token data (for tokenStats broadcast)
484
+ if (item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens) {
485
+ this._tokenStatsDirty = true;
486
+ }
362
487
  if (this._pendingItems.length >= FLUSH_BATCH_LIMIT) {
363
488
  // Batch size hit limit — flush immediately
364
489
  if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; }
365
490
  const batch = this._pendingItems;
366
491
  this._pendingItems = [];
367
492
  this.broadcast('itemBatch', batch);
493
+ this.broadcast('context', this.getContextSnapshot());
494
+ if (this._tokenStatsDirty) {
495
+ this._tokenStatsDirty = false;
496
+ this.broadcast('tokenStats', this.getTokenStatsSnapshot());
497
+ }
368
498
  } else if (!this._flushTimer) {
369
499
  this._flushTimer = setTimeout(() => {
370
500
  this._flushTimer = null;
@@ -375,6 +505,11 @@ class DashboardServer {
375
505
  } else if (batch.length > 1) {
376
506
  this.broadcast('itemBatch', batch);
377
507
  }
508
+ this.broadcast('context', this.getContextSnapshot());
509
+ if (this._tokenStatsDirty) {
510
+ this._tokenStatsDirty = false;
511
+ this.broadcast('tokenStats', this.getTokenStatsSnapshot());
512
+ }
378
513
  }, 50);
379
514
  }
380
515
  });
@@ -497,6 +632,29 @@ class DashboardServer {
497
632
  }
498
633
  });
499
634
 
635
+ // ── Full-scan historical JSONL files for token stats ──
636
+ // This runs BEFORE watcher starts, scanning ALL files regardless of age
637
+ console.log(' Scanning historical token data...');
638
+ try {
639
+ const scanned = await fullScanTokenUsage((done, total) => {
640
+ if (total > 0 && (done % 100 === 0 || done === total)) {
641
+ console.log(` Scanned ${done}/${total} files...`);
642
+ }
643
+ });
644
+ // Merge scanned data into this.dailyStats and this.hourlyStats
645
+ for (const [dateStr, day] of scanned.dailyStats) {
646
+ this.dailyStats.set(dateStr, day);
647
+ }
648
+ for (let h = 0; h < 24; h++) {
649
+ this.hourlyStats[h] += scanned.hourlyStats[h];
650
+ }
651
+ const totalDays = this.dailyStats.size;
652
+ const totalMsgs = [...this.dailyStats.values()].reduce((s, d) => s + d.messages, 0);
653
+ console.log(` Token scan complete: ${totalDays} days, ${totalMsgs.toLocaleString()} messages`);
654
+ } catch (err) {
655
+ console.error(' Token scan error (non-critical, continuing):', err.message);
656
+ }
657
+
500
658
  const w = this.setupWatcher(watcherOpts);
501
659
 
502
660
  try {
@@ -581,4 +739,4 @@ function askYesNo(prompt) {
581
739
  });
582
740
  }
583
741
 
584
- module.exports = { DashboardServer, startServer };
742
+ module.exports = { DashboardServer, startServer };
@@ -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;