claude-code-watch 0.0.5 → 0.0.6

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.
@@ -1,22 +1,24 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const fsp = require('fs/promises');
5
- const path = require('path');
6
- const os = require('os');
7
- const chokidar = require('chokidar');
8
- const { parseLine, AgentIDDisplayLength } = require('../parser/parser');
3
+ var fs = require('fs');
4
+ var fsp = require('fs/promises');
5
+ var path = require('path');
6
+ var os = require('os');
7
+ var readline = require('readline');
8
+ var chokidar = require('chokidar');
9
+ var { EventEmitter } = require('events');
10
+ var { parseLine, AgentIDDisplayLength } = require('../parser/parser');
9
11
 
10
12
  // ============================================================================
11
13
  // Constants
12
14
  // ============================================================================
13
15
 
14
- const AutoSkipLineThreshold = 100;
15
- const KeepRecentLines = 10;
16
- const CleanupInterval = 5 * 60 * 1000;
17
- const FsnotifyDiscoveryInterval = 60 * 1000;
18
- const RecentActivityThreshold = 2 * 60 * 1000;
19
- const DebounceInterval = 50;
16
+ var AutoSkipLineThreshold = 100;
17
+ var KeepRecentLines = 10;
18
+ var CleanupInterval = 5 * 60 * 1000;
19
+ var FsnotifyDiscoveryInterval = 60 * 1000;
20
+ var RecentActivityThreshold = 2 * 60 * 1000;
21
+ var DebounceInterval = 50;
20
22
 
21
23
  // ============================================================================
22
24
  // Helpers
@@ -44,7 +46,9 @@ function resolveProjectPath(encoded) {
44
46
  try {
45
47
  fs.accessSync(testPath);
46
48
  return `${pathPart}/${dirPart}`;
47
- } catch {}
49
+ } catch {
50
+ // Path doesn't exist, try next combination
51
+ }
48
52
  }
49
53
 
50
54
  // Fallback to naive conversion
@@ -60,10 +64,10 @@ function isMainSessionFile(filePath, stats) {
60
64
  return true;
61
65
  }
62
66
 
63
- function readAgentType(jsonlPath) {
67
+ async function readAgentType(jsonlPath) {
64
68
  const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
65
69
  try {
66
- const data = fs.readFileSync(metaPath, 'utf-8');
70
+ const data = await fsp.readFile(metaPath, 'utf-8');
67
71
  const meta = JSON.parse(data);
68
72
  return meta.agentType || '';
69
73
  } catch {
@@ -102,8 +106,8 @@ class BackgroundTask {
102
106
  // Watcher class
103
107
  // ============================================================================
104
108
 
105
- class Watcher extends require('events').EventEmitter {
106
- constructor({ sessionID, pollInterval, activeWindow, maxSessions } = {}) {
109
+ class Watcher extends EventEmitter {
110
+ constructor({ sessionID, pollInterval, activeWindow, maxSessions, debugAll } = {}) {
107
111
  super();
108
112
  this.claudeDir = getClaudeProjectsDir();
109
113
  this.pollInterval = pollInterval || 500;
@@ -120,12 +124,15 @@ class Watcher extends require('events').EventEmitter {
120
124
  this.fileContexts = new Map();
121
125
  this.debounceTimers = new Map();
122
126
  this.pendingSubagents = new Map();
127
+ this._readLocks = new Map();
128
+ this._pollRunning = false;
123
129
 
124
130
  // Intervals / timers
125
131
  this._cleanupTimer = null;
126
132
  this._discoveryTimer = null;
127
133
  this._pollTimer = null;
128
134
  this._running = false;
135
+ this.debug = debugAll || false;
129
136
 
130
137
  this._sessionID = sessionID || '';
131
138
  }
@@ -201,7 +208,7 @@ class Watcher extends require('events').EventEmitter {
201
208
  return this.buildSession(mainFile);
202
209
  }
203
210
 
204
- buildSession(mainFile) {
211
+ async buildSession(mainFile) {
205
212
  const base = path.basename(mainFile);
206
213
  const id = base.replace(/\.jsonl$/, '');
207
214
  const projectDir = path.basename(path.dirname(mainFile));
@@ -212,19 +219,21 @@ class Watcher extends require('events').EventEmitter {
212
219
  // Find subagent files
213
220
  const subagentDir = path.join(path.dirname(mainFile), id, 'subagents');
214
221
  try {
215
- const entries = fs.readdirSync(subagentDir);
222
+ const entries = await fsp.readdir(subagentDir);
216
223
  for (const entry of entries) {
217
224
  if (entry.endsWith('.jsonl')) {
218
225
  const agentID = entry.replace(/^agent-/, '').replace(/\.jsonl$/, '');
219
226
  const jsonlPath = path.join(subagentDir, entry);
220
227
  session.subagents[agentID] = jsonlPath;
221
- const agentType = readAgentType(jsonlPath);
228
+ const agentType = await readAgentType(jsonlPath);
222
229
  if (agentType) {
223
230
  session.subagentTypes[agentID] = agentType;
224
231
  }
225
232
  }
226
233
  }
227
- } catch {}
234
+ } catch (err) {
235
+ if (this.debug) console.error('[watcher] buildSession subagent scan error:', err.message);
236
+ }
228
237
 
229
238
  return session;
230
239
  }
@@ -237,11 +246,11 @@ class Watcher extends require('events').EventEmitter {
237
246
  await this._walkDir(this.claudeDir, (filePath, stats) => {
238
247
  if (!isMainSessionFile(filePath, stats)) return;
239
248
  if (now - stats.mtimeMs > this.activeWindow) return;
240
-
241
- const session = this.buildSession(filePath);
242
- discovered.push({ session, modTime: stats.mtimeMs });
249
+ discovered.push({ filePath, modTime: stats.mtimeMs });
243
250
  });
244
- } catch {}
251
+ } catch (err) {
252
+ if (this.debug) console.error('[watcher] discoverActiveSessions error:', err.message);
253
+ }
245
254
 
246
255
  // Sort by most recent first
247
256
  discovered.sort((a, b) => b.modTime - a.modTime);
@@ -250,21 +259,22 @@ class Watcher extends require('events').EventEmitter {
250
259
  }
251
260
 
252
261
  for (const d of discovered) {
253
- if (!this.sessions.has(d.session.id)) {
254
- this.sessions.set(d.session.id, d.session);
262
+ const session = await this.buildSession(d.filePath);
263
+ if (!this.sessions.has(session.id)) {
264
+ this.sessions.set(session.id, session);
255
265
 
256
266
  // Broadcast so connected clients learn about the new session
257
- this.emit('broadcast', 'newSession', { sessionID: d.session.id, projectPath: d.session.projectPath });
258
- for (const [agentID, agentType] of Object.entries(d.session.subagentTypes)) {
259
- this.emit('broadcast', 'newAgent', { sessionID: d.session.id, agentID, agentType });
267
+ this.emit('broadcast', 'newSession', { sessionID: session.id, projectPath: session.projectPath });
268
+ for (const [agentID, agentType] of Object.entries(session.subagentTypes)) {
269
+ this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
260
270
  }
261
271
 
262
- const pending = this.pendingSubagents.get(d.session.id);
272
+ const pending = this.pendingSubagents.get(session.id);
263
273
  if (pending) {
264
- this.pendingSubagents.delete(d.session.id);
274
+ this.pendingSubagents.delete(session.id);
265
275
  for (const sp of pending) {
266
276
  const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
267
- this._registerSubagent(d.session, d.session.id, agentID, sp);
277
+ await this._registerSubagent(session, session.id, agentID, sp);
268
278
  }
269
279
  }
270
280
  }
@@ -279,14 +289,14 @@ class Watcher extends require('events').EventEmitter {
279
289
  // Start / Stop
280
290
  // =========================================================================
281
291
 
282
- start() {
292
+ async start() {
283
293
  if (this._running) return;
284
294
  this._running = true;
285
295
 
286
296
  if (this.useFsnotify) {
287
- this._startFsnotify();
297
+ await this._startFsnotify();
288
298
  } else {
289
- this._startPolling();
299
+ await this._startPolling();
290
300
  }
291
301
  }
292
302
 
@@ -309,7 +319,7 @@ class Watcher extends require('events').EventEmitter {
309
319
  // Chokidar (fsnotify) mode
310
320
  // =========================================================================
311
321
 
312
- _startFsnotify() {
322
+ async _startFsnotify() {
313
323
  // Set up watches
314
324
  try {
315
325
  if (fs.existsSync(this.claudeDir)) {
@@ -317,10 +327,12 @@ class Watcher extends require('events').EventEmitter {
317
327
  } else {
318
328
  this._watchAncestor(this.claudeDir);
319
329
  }
320
- } catch {}
330
+ } catch (err) {
331
+ if (this.debug) console.error('[watcher] start watch setup error:', err.message);
332
+ }
321
333
 
322
334
  const sessions = this.getSessionsSnapshot();
323
- this._initializeSessionReading(sessions);
335
+ await this._initializeSessionReading(sessions);
324
336
  for (const session of sessions) {
325
337
  this._registerSessionWatches(session);
326
338
  }
@@ -360,7 +372,7 @@ class Watcher extends require('events').EventEmitter {
360
372
  }
361
373
  }
362
374
 
363
- _addDirectoryWatches(root, maxDepth = 20) {
375
+ _addDirectoryWatches(root, maxDepth = 10) {
364
376
  const addRecursive = (dir, depth) => {
365
377
  if (depth > maxDepth) return;
366
378
  try {
@@ -405,8 +417,12 @@ class Watcher extends require('events').EventEmitter {
405
417
  try {
406
418
  fs.accessSync(this.claudeDir);
407
419
  this._addDirectoryWatches(this.claudeDir);
408
- this.discoverActiveSessions();
409
- } catch {}
420
+ this.discoverActiveSessions().catch(err => {
421
+ if (this.debug) console.error('[watcher] discoverActiveSessions error:', err.message);
422
+ });
423
+ } catch (err) {
424
+ if (this.debug) console.error('[watcher] _handleFsCreate directory scan error:', err.message);
425
+ }
410
426
  }
411
427
  return;
412
428
  }
@@ -415,7 +431,7 @@ class Watcher extends require('events').EventEmitter {
415
431
  if (p.includes('/subagents/')) {
416
432
  this._handleNewSubagentFile(p);
417
433
  } else if (this.watchActive) {
418
- this._handleNewSessionFile(p);
434
+ this._handleNewSessionFile(p); // fire-and-forget, session will be discovered on next poll
419
435
  }
420
436
  return;
421
437
  }
@@ -453,10 +469,14 @@ class Watcher extends require('events').EventEmitter {
453
469
  if (existing) {
454
470
  clearTimeout(existing);
455
471
  }
456
- const timer = setTimeout(() => {
472
+ const timer = setTimeout(async () => {
457
473
  this.debounceTimers.delete(p);
458
474
  const agentType = this._lookupAgentType(ctx.sessionID, ctx.agentID);
459
- this._readFile(p, ctx.sessionID, ctx.agentID, agentType);
475
+ try {
476
+ await this._readFile(p, ctx.sessionID, ctx.agentID, agentType);
477
+ } catch (err) {
478
+ this.emit('error', err);
479
+ }
460
480
  }, DebounceInterval);
461
481
  this.debounceTimers.set(p, timer);
462
482
  }
@@ -465,15 +485,15 @@ class Watcher extends require('events').EventEmitter {
465
485
  // New session handlers
466
486
  // =========================================================================
467
487
 
468
- _handleNewSessionFile(p) {
488
+ async _handleNewSessionFile(p) {
469
489
  let stats;
470
- try { stats = fs.statSync(p); } catch { return; }
490
+ try { stats = await fsp.stat(p); } catch { return; }
471
491
  if (!isMainSessionFile(p, stats)) return;
472
492
 
473
493
  // Only accept sessions within the active window
474
494
  if (Date.now() - stats.mtimeMs > this.activeWindow) return;
475
495
 
476
- const session = this.buildSession(p);
496
+ const session = await this.buildSession(p);
477
497
  if (this.sessions.has(session.id)) return;
478
498
 
479
499
  this.sessions.set(session.id, session);
@@ -510,11 +530,11 @@ class Watcher extends require('events').EventEmitter {
510
530
  return;
511
531
  }
512
532
 
513
- this._registerSubagent(session, sessionID, agentID, p);
533
+ this._registerSubagent(session, sessionID, agentID, p); // fire-and-forget, event handler context
514
534
  }
515
535
 
516
- _registerSubagent(session, sessionID, agentID, p) {
517
- const agentType = readAgentType(p);
536
+ async _registerSubagent(session, sessionID, agentID, p) {
537
+ const agentType = await readAgentType(p);
518
538
  if (session.subagents[agentID]) return;
519
539
 
520
540
  session.subagents[agentID] = p;
@@ -524,7 +544,7 @@ class Watcher extends require('events').EventEmitter {
524
544
  this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType });
525
545
  }
526
546
 
527
- _handleNewToolResultFile(p) {
547
+ async _handleNewToolResultFile(p) {
528
548
  const toolID = path.basename(p).replace(/\.txt$/, '');
529
549
  const toolResultsDir = path.dirname(p);
530
550
  const sessionDir = path.dirname(toolResultsDir);
@@ -534,8 +554,8 @@ class Watcher extends require('events').EventEmitter {
534
554
  if (!session) return;
535
555
  if (session.backgroundTasks[toolID]) return;
536
556
 
537
- const parentAgentID = this._findBackgroundTaskParent(session, toolID);
538
- const isComplete = this._isBackgroundTaskComplete(session, toolID);
557
+ const parentAgentID = await this._findBackgroundTaskParent(session, toolID);
558
+ const isComplete = await this._isBackgroundTaskComplete(session, toolID);
539
559
 
540
560
  const task = new BackgroundTask(toolID, parentAgentID, 'Background Task', p, isComplete);
541
561
  session.backgroundTasks[toolID] = task;
@@ -561,22 +581,29 @@ class Watcher extends require('events').EventEmitter {
561
581
  // Periodic checking (polling fallback + fsnotify discovery)
562
582
  // =========================================================================
563
583
 
564
- _checkForNewSessions() {
584
+ async _checkForNewSessions() {
565
585
  const now = Date.now();
566
- const candidates = [];
586
+ const fileCandidates = [];
567
587
 
568
588
  try {
569
- _walkDirSyncSimple(this.claudeDir, (filePath, stats) => {
589
+ await this._walkDir(this.claudeDir, (filePath, stats) => {
570
590
  if (!isMainSessionFile(filePath, stats)) return;
571
591
  if (now - stats.mtimeMs > this.activeWindow) return;
572
592
 
573
593
  const id = path.basename(filePath).replace(/\.jsonl$/, '');
574
594
  if (this.sessions.has(id)) return;
575
595
 
576
- const session = this.buildSession(filePath);
577
- candidates.push({ session, modTime: stats.mtimeMs });
596
+ fileCandidates.push({ filePath, modTime: stats.mtimeMs });
578
597
  });
579
- } catch {}
598
+ } catch (err) {
599
+ if (this.debug) console.error('[watcher] _checkForNewSessions error:', err.message);
600
+ }
601
+
602
+ const candidates = [];
603
+ for (const fc of fileCandidates) {
604
+ const session = await this.buildSession(fc.filePath);
605
+ candidates.push({ session, modTime: fc.modTime });
606
+ }
580
607
 
581
608
  candidates.sort((a, b) => b.modTime - a.modTime);
582
609
 
@@ -601,16 +628,16 @@ class Watcher extends require('events').EventEmitter {
601
628
  this.pendingSubagents.delete(c.session.id);
602
629
  for (const sp of pending) {
603
630
  const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
604
- this._registerSubagent(c.session, c.session.id, agentID, sp);
631
+ await this._registerSubagent(c.session, c.session.id, agentID, sp);
605
632
  }
606
633
  }
607
634
  }
608
635
  }
609
636
 
610
- _checkForNewSubagents(session) {
637
+ async _checkForNewSubagents(session) {
611
638
  const subagentDir = path.join(path.dirname(session.mainFile), session.id, 'subagents');
612
639
  let entries;
613
- try { entries = fs.readdirSync(subagentDir); } catch { return; }
640
+ try { entries = await fsp.readdir(subagentDir); } catch { return; }
614
641
 
615
642
  for (const entry of entries) {
616
643
  if (!entry.endsWith('.jsonl')) continue;
@@ -618,18 +645,18 @@ class Watcher extends require('events').EventEmitter {
618
645
  if (session.subagents[agentID]) continue;
619
646
 
620
647
  const agentPath = path.join(subagentDir, entry);
621
- const agentType = readAgentType(agentPath);
648
+ const agentType = await readAgentType(agentPath);
622
649
  session.subagents[agentID] = agentPath;
623
650
  if (agentType) session.subagentTypes[agentID] = agentType;
624
651
 
625
- this.emit('newAgent', { sessionID: session.id, agentID, agentType });
652
+ this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
626
653
  }
627
654
  }
628
655
 
629
- _checkForBackgroundTasks(session) {
656
+ async _checkForBackgroundTasks(session) {
630
657
  const toolResultsDir = path.join(path.dirname(session.mainFile), session.id, 'tool-results');
631
658
  let entries;
632
- try { entries = fs.readdirSync(toolResultsDir); } catch { return; }
659
+ try { entries = await fsp.readdir(toolResultsDir); } catch { return; }
633
660
 
634
661
  for (const entry of entries) {
635
662
  if (!entry.endsWith('.txt')) continue;
@@ -637,8 +664,8 @@ class Watcher extends require('events').EventEmitter {
637
664
  if (session.backgroundTasks[toolID]) continue;
638
665
 
639
666
  const outputPath = path.join(toolResultsDir, entry);
640
- const parentAgentID = this._findBackgroundTaskParent(session, toolID);
641
- const isComplete = this._isBackgroundTaskComplete(session, toolID);
667
+ const parentAgentID = await this._findBackgroundTaskParent(session, toolID);
668
+ const isComplete = await this._isBackgroundTaskComplete(session, toolID);
642
669
 
643
670
  const task = new BackgroundTask(toolID, parentAgentID, 'Background Task', outputPath, isComplete);
644
671
  session.backgroundTasks[toolID] = task;
@@ -654,27 +681,27 @@ class Watcher extends require('events').EventEmitter {
654
681
  }
655
682
  }
656
683
 
657
- _findBackgroundTaskParent(session, toolID) {
684
+ async _findBackgroundTaskParent(session, toolID) {
658
685
  const entry = session.toolIndex.get(toolID);
659
686
  if (entry) return entry.parentAgentID || '';
660
687
  if (!session.toolIndexPopulated) {
661
- this._populateToolIndex(session);
688
+ await this._populateToolIndex(session);
662
689
  }
663
690
  const cached = session.toolIndex.get(toolID);
664
691
  return cached ? (cached.parentAgentID || '') : '';
665
692
  }
666
693
 
667
- _isBackgroundTaskComplete(session, toolID) {
694
+ async _isBackgroundTaskComplete(session, toolID) {
668
695
  const entry = session.toolIndex.get(toolID);
669
696
  if (entry) return entry.hasResult;
670
697
  if (!session.toolIndexPopulated) {
671
- this._populateToolIndex(session);
698
+ await this._populateToolIndex(session);
672
699
  }
673
700
  const cached = session.toolIndex.get(toolID);
674
701
  return cached ? cached.hasResult : false;
675
702
  }
676
703
 
677
- _populateToolIndex(session) {
704
+ async _populateToolIndex(session) {
678
705
  if (session.toolIndexPopulated) return;
679
706
  session.toolIndexPopulated = true;
680
707
  const files = [
@@ -685,8 +712,10 @@ class Watcher extends require('events').EventEmitter {
685
712
  for (const { path: filePath, agentID } of files) {
686
713
  if (!filePath) continue;
687
714
  try {
688
- const content = fs.readFileSync(filePath, 'utf-8');
689
- for (const line of content.split('\n')) {
715
+ const input = fs.createReadStream(filePath, { encoding: 'utf-8' });
716
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
717
+
718
+ for await (const line of rl) {
690
719
  if (!line.includes('"tool_')) continue;
691
720
 
692
721
  if (line.includes('"tool_use"')) {
@@ -718,7 +747,9 @@ class Watcher extends require('events').EventEmitter {
718
747
  }
719
748
  }
720
749
  }
721
- } catch {}
750
+ } catch (err) {
751
+ if (this.debug) console.error('[watcher] _populateToolIndex error reading', filePath + ':', err.message);
752
+ }
722
753
  }
723
754
  }
724
755
 
@@ -726,13 +757,16 @@ class Watcher extends require('events').EventEmitter {
726
757
  // Polling mode
727
758
  // =========================================================================
728
759
 
729
- _startPolling() {
760
+ async _startPolling() {
730
761
  const sessions = this.getSessionsSnapshot();
731
- this._initializeSessionReading(sessions);
762
+ await this._initializeSessionReading(sessions);
732
763
 
733
764
  this._pollTimer = setInterval(() => {
734
- if (!this._running) return;
735
- this._handlePollTick();
765
+ if (!this._running || this._pollRunning) return;
766
+ this._pollRunning = true;
767
+ this._handlePollTick()
768
+ .then(() => { this._pollRunning = false; })
769
+ .catch(() => { this._pollRunning = false; });
736
770
  }, this.pollInterval);
737
771
 
738
772
  this._cleanupTimer = setInterval(() => {
@@ -741,29 +775,34 @@ class Watcher extends require('events').EventEmitter {
741
775
  }, CleanupInterval);
742
776
  }
743
777
 
744
- _handlePollTick() {
778
+ async _handlePollTick() {
745
779
  if (this.watchActive) {
746
- this._checkForNewSessions();
747
- }
748
- for (const session of this.getSessionsSnapshot()) {
749
- this._checkForNewSubagents(session);
750
- this._checkForBackgroundTasks(session);
751
- this._readSessionFiles(session);
780
+ await this._checkForNewSessions();
752
781
  }
782
+ const sessions = this.getSessionsSnapshot();
783
+ await Promise.all(sessions.map(s => this._processSessionTick(s)));
784
+ }
785
+
786
+ async _processSessionTick(session) {
787
+ await Promise.all([
788
+ this._checkForNewSubagents(session),
789
+ this._checkForBackgroundTasks(session),
790
+ ]);
791
+ await this._readSessionFiles(session);
753
792
  }
754
793
 
755
794
  // =========================================================================
756
795
  // File reading
757
796
  // =========================================================================
758
797
 
759
- _initializeSessionReading(sessions) {
798
+ async _initializeSessionReading(sessions) {
760
799
  let shouldSkip = this.skipHistory;
761
800
  if (!shouldSkip) {
762
801
  let totalLines = 0;
763
802
  for (const session of sessions) {
764
- totalLines += this._countFileLines(session.mainFile);
803
+ totalLines += await this._countFileLines(session.mainFile);
765
804
  for (const agentPath of Object.values(session.subagents)) {
766
- totalLines += this._countFileLines(agentPath);
805
+ totalLines += await this._countFileLines(agentPath);
767
806
  }
768
807
  }
769
808
  shouldSkip = totalLines > AutoSkipLineThreshold;
@@ -771,35 +810,34 @@ class Watcher extends require('events').EventEmitter {
771
810
 
772
811
  if (shouldSkip) {
773
812
  for (const session of sessions) {
774
- this._skipToEndOfFiles(session);
775
- this._readSessionFiles(session);
813
+ await this._skipToEndOfFiles(session);
814
+ await this._readSessionFiles(session);
776
815
  }
777
816
  } else {
778
817
  for (const session of sessions) {
779
- this._readSessionFiles(session);
818
+ await this._readSessionFiles(session);
780
819
  }
781
820
  }
782
821
  }
783
822
 
784
- _skipToEndOfFiles(session) {
785
- const mainPos = this._findPositionForLastNLines(session.mainFile, KeepRecentLines);
823
+ async _skipToEndOfFiles(session) {
824
+ const mainPos = await this._findPositionForLastNLines(session.mainFile, KeepRecentLines);
786
825
  this.filePositions.set(session.mainFile, mainPos);
787
826
 
788
827
  for (const agentPath of Object.values(session.subagents)) {
789
- const pos = this._findPositionForLastNLines(agentPath, KeepRecentLines);
828
+ const pos = await this._findPositionForLastNLines(agentPath, KeepRecentLines);
790
829
  this.filePositions.set(agentPath, pos);
791
830
  }
792
831
  }
793
832
 
794
- _findPositionForLastNLines(filePath, n) {
833
+ async _findPositionForLastNLines(filePath, n) {
795
834
  try {
796
- const stat = fs.statSync(filePath);
835
+ const stat = await fsp.stat(filePath);
797
836
  const fileSize = stat.size;
798
837
  if (fileSize === 0) return 0;
799
838
 
800
- let fd;
839
+ const handle = await fsp.open(filePath, 'r');
801
840
  try {
802
- fd = fs.openSync(filePath, 'r');
803
841
  const chunkSize = 8192;
804
842
  const buf = Buffer.alloc(chunkSize);
805
843
  let newlineCount = 0;
@@ -809,9 +847,9 @@ class Watcher extends require('events').EventEmitter {
809
847
  while (position > 0 && newlineCount <= n) {
810
848
  const readLen = Math.min(chunkSize, position);
811
849
  position -= readLen;
812
- fs.readSync(fd, buf, 0, readLen, position);
850
+ const { bytesRead } = await handle.read(buf, 0, readLen, position);
813
851
 
814
- for (let i = readLen - 1; i >= 0; i--) {
852
+ for (let i = bytesRead - 1; i >= 0; i--) {
815
853
  if (buf[i] === 0x0A) {
816
854
  newlineCount++;
817
855
  if (newlineCount === n) {
@@ -825,130 +863,165 @@ class Watcher extends require('events').EventEmitter {
825
863
  if (newlineCount < n) return 0;
826
864
  return lastNewlinePos;
827
865
  } finally {
828
- if (fd !== undefined) try { fs.closeSync(fd); } catch {}
866
+ await handle.close();
829
867
  }
830
868
  } catch {
831
869
  return 0;
832
870
  }
833
871
  }
834
872
 
835
- _readSessionFiles(session) {
836
- this._readFile(session.mainFile, session.id, '', '');
873
+ async _readSessionFiles(session) {
874
+ const reads = [this._readFile(session.mainFile, session.id, '', '')];
837
875
  for (const [agentID, agentPath] of Object.entries(session.subagents)) {
838
876
  const agentType = session.subagentTypes[agentID] || '';
839
- this._readFile(agentPath, session.id, agentID, agentType);
877
+ reads.push(this._readFile(agentPath, session.id, agentID, agentType));
840
878
  }
879
+ await Promise.all(reads);
841
880
  }
842
881
 
843
- _readFile(filePath, sessionID, agentID, agentType) {
844
- let fd;
882
+ async _readFile(filePath, sessionID, agentID, agentType) {
883
+ // Serialize reads per file to prevent concurrent access
884
+ const prev = this._readLocks.get(filePath) || Promise.resolve();
885
+ let resolveLock;
886
+ const lock = new Promise(r => { resolveLock = r; });
887
+ this._readLocks.set(filePath, lock);
888
+
845
889
  try {
846
- fd = fs.openSync(filePath, 'r');
847
- const pos = this.filePositions.get(filePath) || 0;
848
- const stats = fs.fstatSync(fd);
849
- if (pos >= stats.size) return;
850
-
851
- const readLen = stats.size - pos;
852
- const buf = Buffer.alloc(readLen);
853
- const bytesRead = fs.readSync(fd, buf, 0, readLen, pos);
854
- if (bytesRead === 0) return;
855
-
856
- let newPos = pos;
857
- const content = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
858
- const lines = content.split('\n');
859
-
860
- // Check whether the file has grown since we read it.
861
- // If yes, the last line may be incomplete (no trailing newline yet).
862
- let currentSize;
863
- try { currentSize = fs.fstatSync(fd).size; } catch { currentSize = stats.size; }
864
- const fileGrew = currentSize > pos + bytesRead;
865
-
866
- for (let i = 0; i < lines.length; i++) {
867
- const isLast = i === lines.length - 1;
868
- const rawLine = lines[i];
869
-
870
- // Trailing empty string after final newline — already processed
871
- if (isLast && rawLine === '' && content.endsWith('\n')) {
872
- continue;
873
- }
890
+ await prev;
874
891
 
875
- // File has grown: last line may be missing its newline, defer it
876
- if (isLast && fileGrew) {
877
- continue;
878
- }
892
+ let handle;
893
+ let newPos = this.filePositions.get(filePath) || 0;
894
+ try {
895
+ handle = await fsp.open(filePath, 'r');
896
+ const pos = this.filePositions.get(filePath) || 0;
897
+ const stats = await handle.stat();
898
+ if (pos >= stats.size) { await handle.close(); handle = null; return; }
899
+
900
+ const readLen = stats.size - pos;
901
+ const buf = Buffer.alloc(readLen);
902
+ const { bytesRead } = await handle.read(buf, 0, readLen, pos);
903
+ if (bytesRead === 0) { await handle.close(); handle = null; return; }
904
+
905
+ newPos = pos;
906
+ const content = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
907
+ const rawLines = content.split('\n');
908
+
909
+ // Detect Windows-style CRLF line endings
910
+ const firstNl = content.indexOf('\n');
911
+ const crlf = firstNl > 0 && content[firstNl - 1] === '\r';
912
+ const nlLen = crlf ? 2 : 1;
913
+
914
+ let currentSize;
915
+ try { currentSize = (await handle.stat()).size; } catch { currentSize = stats.size; }
916
+ const fileGrew = currentSize > pos + bytesRead;
917
+
918
+ await handle.close();
919
+ handle = null;
920
+
921
+ for (let i = 0; i < rawLines.length; i++) {
922
+ const isLast = i === rawLines.length - 1;
923
+ let rawLine = rawLines[i];
924
+
925
+ // Trailing empty line after a final newline — skip it, advance position
926
+ if (isLast && rawLine === '' && content.endsWith('\n')) {
927
+ newPos += nlLen;
928
+ continue;
929
+ }
879
930
 
880
- // Advance file position (only for lines we actually consume)
881
- newPos += Buffer.byteLength(rawLine, 'utf-8');
882
- if (!isLast) newPos += 1;
883
-
884
- if (!rawLine.trim()) continue;
885
-
886
- const items = parseLine(rawLine);
887
- for (const item of items) {
888
- // Set session ID
889
- item.sessionID = sessionID;
890
-
891
- // Set agent ID and name from context
892
- if (agentID) {
893
- if (!item.agentID) item.agentID = agentID;
894
- if (agentType) {
895
- const idx = agentType.lastIndexOf(':');
896
- if (idx >= 0 && idx < agentType.length - 1) {
897
- item.agentName = agentType.slice(idx + 1);
898
- } else {
899
- item.agentName = agentType;
900
- }
901
- } else if (!item.agentName || item.agentName.startsWith('Agent-')) {
902
- item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
903
- }
931
+ // Last line may be incomplete if file grew mid-read or lacks a trailing newline
932
+ if (isLast && !content.endsWith('\n')) {
933
+ // Don't process this line, don't advance position past it.
934
+ // Next read will re-read from the current newPos and get the complete line.
935
+ continue;
904
936
  }
905
937
 
906
- // Populate tool index for O(1) lookups
907
- if (item.toolID) {
908
- const session = this.sessions.get(sessionID);
909
- if (session) {
910
- const existing = session.toolIndex.get(item.toolID);
911
- if (item.type === 'tool_output') {
912
- if (existing) {
913
- existing.hasResult = true;
938
+ // Strip trailing \r for clean line processing (Windows CRLF)
939
+ if (crlf && rawLine.endsWith('\r')) {
940
+ rawLine = rawLine.slice(0, -1);
941
+ }
942
+
943
+ newPos += Buffer.byteLength(rawLine, 'utf-8');
944
+ newPos += nlLen;
945
+
946
+ if (!rawLine.trim()) continue;
947
+
948
+ const items = parseLine(rawLine);
949
+ for (const item of items) {
950
+ item.sessionID = sessionID;
951
+
952
+ if (agentID) {
953
+ if (!item.agentID) item.agentID = agentID;
954
+ if (agentType) {
955
+ const idx = agentType.lastIndexOf(':');
956
+ if (idx >= 0 && idx < agentType.length - 1) {
957
+ item.agentName = agentType.slice(idx + 1);
914
958
  } else {
915
- session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
959
+ item.agentName = agentType;
960
+ }
961
+ } else if (!item.agentName || item.agentName.startsWith('Agent-')) {
962
+ item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
963
+ }
964
+ }
965
+
966
+ if (item.toolID) {
967
+ const session = this.sessions.get(sessionID);
968
+ if (session) {
969
+ const existing = session.toolIndex.get(item.toolID);
970
+ if (item.type === 'tool_output') {
971
+ if (existing) {
972
+ existing.hasResult = true;
973
+ } else {
974
+ session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
975
+ }
976
+ } else if (item.type === 'tool_input' && !existing) {
977
+ session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
916
978
  }
917
- } else if (item.type === 'tool_input' && !existing) {
918
- session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
919
979
  }
920
980
  }
981
+
982
+ this.emit('item', item);
921
983
  }
984
+ }
922
985
 
923
- this.emit('item', item);
986
+ this.filePositions.set(filePath, Math.min(newPos, stats.size));
987
+ } catch (err) {
988
+ if (newPos !== undefined) {
989
+ this.filePositions.set(filePath, newPos);
990
+ }
991
+ this.emit('error', err);
992
+ } finally {
993
+ if (handle) {
994
+ try { await handle.close(); } catch {}
924
995
  }
925
996
  }
926
-
927
- this.filePositions.set(filePath, Math.min(newPos, stats.size));
928
- } catch {} finally {
929
- if (fd !== undefined) {
930
- try { fs.closeSync(fd); } catch {}
997
+ } finally {
998
+ resolveLock();
999
+ if (this._readLocks.get(filePath) === lock) {
1000
+ this._readLocks.delete(filePath);
931
1001
  }
932
1002
  }
933
1003
  }
934
1004
 
935
- _countFileLines(filePath) {
1005
+ async _countFileLines(filePath) {
936
1006
  try {
937
- const stat = fs.statSync(filePath);
1007
+ const stat = await fsp.stat(filePath);
938
1008
  if (stat.size === 0) return 0;
939
- const fd = fs.openSync(filePath, 'r');
1009
+ const handle = await fsp.open(filePath, 'r');
940
1010
  const buf = Buffer.alloc(8192);
941
1011
  let count = 0;
942
1012
  let pos = 0;
943
- while (pos < stat.size) {
944
- const readLen = Math.min(8192, stat.size - pos);
945
- const bytesRead = fs.readSync(fd, buf, 0, readLen, pos);
946
- for (let i = 0; i < bytesRead; i++) {
947
- if (buf[i] === 0x0A) count++;
1013
+ try {
1014
+ while (pos < stat.size) {
1015
+ const readLen = Math.min(8192, stat.size - pos);
1016
+ const { bytesRead } = await handle.read(buf, 0, readLen, pos);
1017
+ for (let i = 0; i < bytesRead; i++) {
1018
+ if (buf[i] === 0x0A) count++;
1019
+ }
1020
+ pos += bytesRead;
948
1021
  }
949
- pos += bytesRead;
1022
+ } finally {
1023
+ await handle.close();
950
1024
  }
951
- fs.closeSync(fd);
952
1025
  return count;
953
1026
  } catch {
954
1027
  return 0;
@@ -1021,48 +1094,34 @@ class Watcher extends require('events').EventEmitter {
1021
1094
  // Directory walking
1022
1095
  // =========================================================================
1023
1096
 
1024
- _createWalkDir(readdirFn) {
1025
- const walk = async (dir, callback) => {
1026
- try {
1027
- const entries = await readdirFn(dir, { withFileTypes: true });
1028
- for (const entry of entries) {
1029
- const fullPath = path.join(dir, entry.name);
1030
- if (entry.isDirectory()) {
1031
- await walk(fullPath, callback);
1032
- } else {
1033
- let stats;
1034
- try { stats = fs.statSync(fullPath); } catch { continue; }
1035
- callback(fullPath, stats);
1036
- }
1037
- }
1038
- } catch {}
1039
- };
1040
- return walk;
1041
- }
1042
-
1043
- _walkDir = this._createWalkDir(fsp.readdir);
1097
+ _walkDir = createWalkDir(fsp.readdir);
1044
1098
  }
1045
1099
 
1046
1100
  // ============================================================================
1047
- // Pure synchronous directory walk (no async/Promise overhead)
1101
+ // Directory walking (shared factory for sync and async)
1048
1102
  // ============================================================================
1049
1103
 
1050
- function _walkDirSyncSimple(dir, callback) {
1051
- try {
1052
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1053
- for (const entry of entries) {
1054
- const fullPath = path.join(dir, entry.name);
1055
- if (entry.isDirectory()) {
1056
- _walkDirSyncSimple(fullPath, callback);
1057
- } else {
1058
- let stats;
1059
- try { stats = fs.statSync(fullPath); } catch { continue; }
1060
- callback(fullPath, stats);
1104
+ function createWalkDir(readdirFn) {
1105
+ const walk = async (dir, callback) => {
1106
+ try {
1107
+ const entries = await readdirFn(dir, { withFileTypes: true });
1108
+ for (const entry of entries) {
1109
+ const fullPath = path.join(dir, entry.name);
1110
+ if (entry.isDirectory()) {
1111
+ await walk(fullPath, callback);
1112
+ } else {
1113
+ let stats;
1114
+ try { stats = await fsp.stat(fullPath); } catch { continue; }
1115
+ callback(fullPath, stats);
1116
+ }
1061
1117
  }
1062
- }
1063
- } catch {}
1118
+ } catch {}
1119
+ };
1120
+ return walk;
1064
1121
  }
1065
1122
 
1123
+ var _walkDirAsync = createWalkDir(fsp.readdir);
1124
+
1066
1125
  // ============================================================================
1067
1126
  // Static listing methods
1068
1127
  // ============================================================================
@@ -1106,19 +1165,7 @@ async function _listSessionsFiltered(limit, activeWithin) {
1106
1165
  }
1107
1166
 
1108
1167
  async function _walkDirStatic(dir, callback) {
1109
- try {
1110
- const entries = await fsp.readdir(dir, { withFileTypes: true });
1111
- for (const entry of entries) {
1112
- const fullPath = path.join(dir, entry.name);
1113
- if (entry.isDirectory()) {
1114
- await _walkDirStatic(fullPath, callback);
1115
- } else {
1116
- let stats;
1117
- try { stats = fs.statSync(fullPath); } catch { continue; }
1118
- callback(fullPath, stats);
1119
- }
1120
- }
1121
- } catch {}
1168
+ return _walkDirAsync(dir, callback);
1122
1169
  }
1123
1170
 
1124
1171
  module.exports = {