claude-code-watch 0.0.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.
@@ -0,0 +1,1130 @@
1
+ 'use strict';
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');
9
+
10
+ // ============================================================================
11
+ // Constants
12
+ // ============================================================================
13
+
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;
20
+
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+
25
+ function getClaudeProjectsDir() {
26
+ if (process.env.CLAUDE_HOME) {
27
+ return path.join(process.env.CLAUDE_HOME, 'projects');
28
+ }
29
+ return path.join(os.homedir(), '.claude', 'projects');
30
+ }
31
+
32
+ function resolveProjectPath(encoded) {
33
+ let s = encoded;
34
+ if (s.startsWith('-')) s = s.slice(1);
35
+ if (!s) return '';
36
+
37
+ const parts = s.split('-');
38
+
39
+ // Try progressively joining segments from the right with dashes
40
+ for (let joinFrom = parts.length - 1; joinFrom >= 1; joinFrom--) {
41
+ const pathPart = parts.slice(0, joinFrom).join('/');
42
+ const dirPart = parts.slice(joinFrom).join('-');
43
+ const testPath = `/${pathPart}/${dirPart}`;
44
+ try {
45
+ fs.accessSync(testPath);
46
+ return `${pathPart}/${dirPart}`;
47
+ } catch {}
48
+ }
49
+
50
+ // Fallback to naive conversion
51
+ return s.replace(/-/g, '/');
52
+ }
53
+
54
+ function isMainSessionFile(filePath, stats) {
55
+ if (stats && stats.isDirectory()) return false;
56
+ if (!filePath.endsWith('.jsonl')) return false;
57
+ if (filePath.includes('/subagents/')) return false;
58
+ const basename = path.basename(filePath);
59
+ if (basename.startsWith('agent-')) return false;
60
+ return true;
61
+ }
62
+
63
+ function readAgentType(jsonlPath) {
64
+ const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
65
+ try {
66
+ const data = fs.readFileSync(metaPath, 'utf-8');
67
+ const meta = JSON.parse(data);
68
+ return meta.agentType || '';
69
+ } catch {
70
+ return '';
71
+ }
72
+ }
73
+
74
+ // ============================================================================
75
+ // Session class
76
+ // ============================================================================
77
+
78
+ class Session {
79
+ constructor(id, projectPath, mainFile) {
80
+ this.id = id;
81
+ this.projectPath = projectPath;
82
+ this.mainFile = mainFile;
83
+ this.subagents = {}; // agentID -> file path
84
+ this.subagentTypes = {}; // agentID -> agentType
85
+ this.backgroundTasks = {}; // toolID -> BackgroundTask
86
+ this.toolIndex = new Map(); // toolID -> { toolName, parentAgentID, hasResult }
87
+ this.toolIndexPopulated = false;
88
+ }
89
+ }
90
+
91
+ class BackgroundTask {
92
+ constructor(toolID, parentAgentID, toolName, outputPath, isComplete) {
93
+ this.toolID = toolID;
94
+ this.parentAgentID = parentAgentID;
95
+ this.toolName = toolName;
96
+ this.outputPath = outputPath;
97
+ this.isComplete = isComplete;
98
+ }
99
+ }
100
+
101
+ // ============================================================================
102
+ // Watcher class
103
+ // ============================================================================
104
+
105
+ class Watcher extends require('events').EventEmitter {
106
+ constructor({ sessionID, pollInterval, activeWindow, maxSessions } = {}) {
107
+ super();
108
+ this.claudeDir = getClaudeProjectsDir();
109
+ this.pollInterval = pollInterval || 500;
110
+ this.activeWindow = activeWindow || 5 * 60 * 1000;
111
+ this.maxSessions = maxSessions || 0;
112
+ this.sessions = new Map();
113
+ this.filePositions = new Map();
114
+ this.watchActive = !sessionID; // watch all active if no specific session
115
+ this.skipHistory = false;
116
+
117
+ // chokidar fields
118
+ this.watcher = null;
119
+ this.useFsnotify = false;
120
+ this.fileContexts = new Map();
121
+ this.debounceTimers = new Map();
122
+ this.pendingSubagents = new Map();
123
+
124
+ // Intervals / timers
125
+ this._cleanupTimer = null;
126
+ this._discoveryTimer = null;
127
+ this._pollTimer = null;
128
+ this._running = false;
129
+
130
+ this._sessionID = sessionID || '';
131
+ }
132
+
133
+ // =========================================================================
134
+ // Initialization
135
+ // =========================================================================
136
+
137
+ async init() {
138
+ // Try to set up chokidar
139
+ try {
140
+ this.watcher = chokidar.watch([], {
141
+ persistent: true,
142
+ ignoreInitial: true,
143
+ awaitWriteFinish: false,
144
+ usePolling: false,
145
+ interval: 100,
146
+ });
147
+ this.useFsnotify = true;
148
+ } catch {
149
+ this.useFsnotify = false;
150
+ }
151
+
152
+ if (this._sessionID) {
153
+ const session = await this.findSession(this._sessionID);
154
+ if (session) {
155
+ this.sessions.set(session.id, session);
156
+ }
157
+ } else {
158
+ await this.discoverActiveSessions();
159
+ }
160
+
161
+ return this;
162
+ }
163
+
164
+ // =========================================================================
165
+ // Session discovery
166
+ // =========================================================================
167
+
168
+ async findSession(sessionID) {
169
+ const jsonlFiles = [];
170
+ try {
171
+ await this._walkDir(this.claudeDir, (filePath, stats) => {
172
+ if (isMainSessionFile(filePath, stats)) {
173
+ jsonlFiles.push(filePath);
174
+ }
175
+ });
176
+ } catch {
177
+ // Dir may not exist
178
+ }
179
+
180
+ if (jsonlFiles.length === 0) return null;
181
+
182
+ // Sort by mtime (most recent first)
183
+ jsonlFiles.sort((a, b) => {
184
+ try {
185
+ const sa = fs.statSync(a);
186
+ const sb = fs.statSync(b);
187
+ return sb.mtimeMs - sa.mtimeMs;
188
+ } catch {
189
+ return 0;
190
+ }
191
+ });
192
+
193
+ let mainFile;
194
+ if (sessionID) {
195
+ mainFile = jsonlFiles.find(f => f.includes(sessionID));
196
+ if (!mainFile) return null;
197
+ } else {
198
+ mainFile = jsonlFiles[0];
199
+ }
200
+
201
+ return this.buildSession(mainFile);
202
+ }
203
+
204
+ buildSession(mainFile) {
205
+ const base = path.basename(mainFile);
206
+ const id = base.replace(/\.jsonl$/, '');
207
+ const projectDir = path.basename(path.dirname(mainFile));
208
+ const projectPath = resolveProjectPath(projectDir);
209
+
210
+ const session = new Session(id, projectPath, mainFile);
211
+
212
+ // Find subagent files
213
+ const subagentDir = path.join(path.dirname(mainFile), id, 'subagents');
214
+ try {
215
+ const entries = fs.readdirSync(subagentDir);
216
+ for (const entry of entries) {
217
+ if (entry.endsWith('.jsonl')) {
218
+ const agentID = entry.replace(/^agent-/, '').replace(/\.jsonl$/, '');
219
+ const jsonlPath = path.join(subagentDir, entry);
220
+ session.subagents[agentID] = jsonlPath;
221
+ const agentType = readAgentType(jsonlPath);
222
+ if (agentType) {
223
+ session.subagentTypes[agentID] = agentType;
224
+ }
225
+ }
226
+ }
227
+ } catch {}
228
+
229
+ return session;
230
+ }
231
+
232
+ async discoverActiveSessions() {
233
+ const now = Date.now();
234
+ const discovered = [];
235
+
236
+ try {
237
+ await this._walkDir(this.claudeDir, (filePath, stats) => {
238
+ if (!isMainSessionFile(filePath, stats)) return;
239
+ if (now - stats.mtimeMs > this.activeWindow) return;
240
+
241
+ const session = this.buildSession(filePath);
242
+ discovered.push({ session, modTime: stats.mtimeMs });
243
+ });
244
+ } catch {}
245
+
246
+ // Sort by most recent first
247
+ discovered.sort((a, b) => b.modTime - a.modTime);
248
+ if (this.maxSessions > 0 && discovered.length > this.maxSessions) {
249
+ discovered.length = this.maxSessions;
250
+ }
251
+
252
+ for (const d of discovered) {
253
+ if (!this.sessions.has(d.session.id)) {
254
+ this.sessions.set(d.session.id, d.session);
255
+
256
+ // 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 });
260
+ }
261
+
262
+ const pending = this.pendingSubagents.get(d.session.id);
263
+ if (pending) {
264
+ this.pendingSubagents.delete(d.session.id);
265
+ for (const sp of pending) {
266
+ const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
267
+ this._registerSubagent(d.session, d.session.id, agentID, sp);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ getSessionsSnapshot() {
275
+ return Array.from(this.sessions.values());
276
+ }
277
+
278
+ // =========================================================================
279
+ // Start / Stop
280
+ // =========================================================================
281
+
282
+ start() {
283
+ if (this._running) return;
284
+ this._running = true;
285
+
286
+ if (this.useFsnotify) {
287
+ this._startFsnotify();
288
+ } else {
289
+ this._startPolling();
290
+ }
291
+ }
292
+
293
+ stop() {
294
+ this._running = false;
295
+ if (this.watcher) {
296
+ this.watcher.close().catch(() => {});
297
+ }
298
+ if (this._cleanupTimer) clearInterval(this._cleanupTimer);
299
+ if (this._discoveryTimer) clearInterval(this._discoveryTimer);
300
+ if (this._pollTimer) clearInterval(this._pollTimer);
301
+ // Cancel all debounce timers
302
+ for (const timer of this.debounceTimers.values()) {
303
+ clearTimeout(timer);
304
+ }
305
+ this.debounceTimers.clear();
306
+ }
307
+
308
+ // =========================================================================
309
+ // Chokidar (fsnotify) mode
310
+ // =========================================================================
311
+
312
+ _startFsnotify() {
313
+ // Set up watches
314
+ try {
315
+ if (fs.existsSync(this.claudeDir)) {
316
+ this._addDirectoryWatches(this.claudeDir);
317
+ } else {
318
+ this._watchAncestor(this.claudeDir);
319
+ }
320
+ } catch {}
321
+
322
+ const sessions = this.getSessionsSnapshot();
323
+ this._initializeSessionReading(sessions);
324
+ for (const session of sessions) {
325
+ this._registerSessionWatches(session);
326
+ }
327
+
328
+ // chokidar events
329
+ this.watcher.on('add', (p) => this._handleFsCreate(p));
330
+ this.watcher.on('change', (p) => this._handleFsWrite(p));
331
+ this.watcher.on('unlink', (p) => {
332
+ this.filePositions.delete(p);
333
+ this.fileContexts.delete(p);
334
+ });
335
+ this.watcher.on('error', (err) => this.emit('error', err));
336
+
337
+ // Periodic cleanup and discovery
338
+ this._cleanupTimer = setInterval(() => {
339
+ if (!this._running) return;
340
+ this._cleanupFilePositions();
341
+ }, CleanupInterval);
342
+
343
+ this._discoveryTimer = setInterval(() => {
344
+ if (!this._running) return;
345
+ if (this.watchActive) this._checkForNewSessions();
346
+ }, FsnotifyDiscoveryInterval);
347
+ }
348
+
349
+ _watchAncestor(target) {
350
+ let dir = target;
351
+ while (true) {
352
+ const parent = path.dirname(dir);
353
+ if (parent === dir) break;
354
+ try {
355
+ fs.accessSync(parent);
356
+ this.watcher.add(parent);
357
+ return;
358
+ } catch {}
359
+ dir = parent;
360
+ }
361
+ }
362
+
363
+ _addDirectoryWatches(root, maxDepth = 20) {
364
+ const addRecursive = (dir, depth) => {
365
+ if (depth > maxDepth) return;
366
+ try {
367
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
368
+ this.watcher.add(dir);
369
+ for (const entry of entries) {
370
+ if (entry.isDirectory()) {
371
+ addRecursive(path.join(dir, entry.name), depth + 1);
372
+ }
373
+ }
374
+ } catch {}
375
+ };
376
+ addRecursive(root, 0);
377
+ }
378
+
379
+ _registerSessionWatches(session) {
380
+ this._addFileWatch(session.mainFile, session.id, '');
381
+ for (const [agentID, agentPath] of Object.entries(session.subagents)) {
382
+ this._addFileWatch(agentPath, session.id, agentID);
383
+ }
384
+ }
385
+
386
+ _addFileWatch(filePath, sessionID, agentID) {
387
+ try {
388
+ this.watcher.add(filePath);
389
+ this.fileContexts.set(filePath, { sessionID, agentID });
390
+ } catch {}
391
+ }
392
+
393
+ // =========================================================================
394
+ // chokidar event handlers
395
+ // =========================================================================
396
+
397
+ _handleFsCreate(p) {
398
+ let stats;
399
+ try { stats = fs.statSync(p); } catch { return; }
400
+
401
+ if (stats.isDirectory()) {
402
+ this.watcher.add(p);
403
+ this._scanNewDirectory(p);
404
+ if (p === this.claudeDir || this.claudeDir.startsWith(p)) {
405
+ try {
406
+ fs.accessSync(this.claudeDir);
407
+ this._addDirectoryWatches(this.claudeDir);
408
+ this.discoverActiveSessions();
409
+ } catch {}
410
+ }
411
+ return;
412
+ }
413
+
414
+ if (p.endsWith('.jsonl')) {
415
+ if (p.includes('/subagents/')) {
416
+ this._handleNewSubagentFile(p);
417
+ } else if (this.watchActive) {
418
+ this._handleNewSessionFile(p);
419
+ }
420
+ return;
421
+ }
422
+
423
+ if (p.endsWith('.txt') && p.includes('/tool-results/')) {
424
+ this._handleNewToolResultFile(p);
425
+ }
426
+ }
427
+
428
+ _scanNewDirectory(dirPath) {
429
+ let entries;
430
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
431
+ const base = path.basename(dirPath);
432
+ for (const entry of entries) {
433
+ const fullPath = path.join(dirPath, entry.name);
434
+ if (entry.isDirectory()) {
435
+ this.watcher.add(fullPath);
436
+ this._scanNewDirectory(fullPath);
437
+ continue;
438
+ }
439
+ if (base === 'subagents' && entry.name.endsWith('.jsonl')) {
440
+ this._handleNewSubagentFile(fullPath);
441
+ } else if (base === 'tool-results' && entry.name.endsWith('.txt')) {
442
+ this._handleNewToolResultFile(fullPath);
443
+ }
444
+ }
445
+ }
446
+
447
+ _handleFsWrite(p) {
448
+ const ctx = this.fileContexts.get(p);
449
+ if (!ctx) return;
450
+
451
+ // Debounce
452
+ const existing = this.debounceTimers.get(p);
453
+ if (existing) {
454
+ clearTimeout(existing);
455
+ }
456
+ const timer = setTimeout(() => {
457
+ this.debounceTimers.delete(p);
458
+ const agentType = this._lookupAgentType(ctx.sessionID, ctx.agentID);
459
+ this._readFile(p, ctx.sessionID, ctx.agentID, agentType);
460
+ }, DebounceInterval);
461
+ this.debounceTimers.set(p, timer);
462
+ }
463
+
464
+ // =========================================================================
465
+ // New session handlers
466
+ // =========================================================================
467
+
468
+ _handleNewSessionFile(p) {
469
+ let stats;
470
+ try { stats = fs.statSync(p); } catch { return; }
471
+ if (!isMainSessionFile(p, stats)) return;
472
+
473
+ // Only accept sessions within the active window
474
+ if (Date.now() - stats.mtimeMs > this.activeWindow) return;
475
+
476
+ const session = this.buildSession(p);
477
+ if (this.sessions.has(session.id)) return;
478
+
479
+ this.sessions.set(session.id, session);
480
+ this._registerSessionWatches(session);
481
+ this.emit('broadcast', 'newSession', { sessionID: session.id, projectPath: session.projectPath });
482
+
483
+ // Broadcast pre-existing subagents to frontend
484
+ for (const [agentID, agentType] of Object.entries(session.subagentTypes)) {
485
+ this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
486
+ }
487
+
488
+ // Process any subagent files that arrived before the session was discovered
489
+ const pending = this.pendingSubagents.get(session.id);
490
+ if (pending) {
491
+ this.pendingSubagents.delete(session.id);
492
+ for (const sp of pending) {
493
+ const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
494
+ this._registerSubagent(session, session.id, agentID, sp);
495
+ }
496
+ }
497
+ }
498
+
499
+ _handleNewSubagentFile(p) {
500
+ const agentID = path.basename(p).replace(/^agent-/, '').replace(/\.jsonl$/, '');
501
+ const subagentsDir = path.dirname(p);
502
+ const sessionDir = path.dirname(subagentsDir);
503
+ const sessionID = path.basename(sessionDir);
504
+
505
+ const session = this.sessions.get(sessionID);
506
+ if (!session) {
507
+ const pending = this.pendingSubagents.get(sessionID) || [];
508
+ if (!pending.includes(p)) pending.push(p);
509
+ this.pendingSubagents.set(sessionID, pending);
510
+ return;
511
+ }
512
+
513
+ this._registerSubagent(session, sessionID, agentID, p);
514
+ }
515
+
516
+ _registerSubagent(session, sessionID, agentID, p) {
517
+ const agentType = readAgentType(p);
518
+ if (session.subagents[agentID]) return;
519
+
520
+ session.subagents[agentID] = p;
521
+ if (agentType) session.subagentTypes[agentID] = agentType;
522
+
523
+ this._addFileWatch(p, sessionID, agentID);
524
+ this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType });
525
+ }
526
+
527
+ _handleNewToolResultFile(p) {
528
+ const toolID = path.basename(p).replace(/\.txt$/, '');
529
+ const toolResultsDir = path.dirname(p);
530
+ const sessionDir = path.dirname(toolResultsDir);
531
+ const sessionID = path.basename(sessionDir);
532
+
533
+ const session = this.sessions.get(sessionID);
534
+ if (!session) return;
535
+ if (session.backgroundTasks[toolID]) return;
536
+
537
+ const parentAgentID = this._findBackgroundTaskParent(session, toolID);
538
+ const isComplete = this._isBackgroundTaskComplete(session, toolID);
539
+
540
+ const task = new BackgroundTask(toolID, parentAgentID, 'Background Task', p, isComplete);
541
+ session.backgroundTasks[toolID] = task;
542
+
543
+ this.emit('broadcast', 'newBackgroundTask', {
544
+ sessionID,
545
+ parentAgentID,
546
+ toolID,
547
+ toolName: 'Background Task',
548
+ outputPath: p,
549
+ isComplete,
550
+ });
551
+ }
552
+
553
+ _lookupAgentType(sessionID, agentID) {
554
+ if (!agentID) return '';
555
+ const session = this.sessions.get(sessionID);
556
+ if (!session) return '';
557
+ return session.subagentTypes[agentID] || '';
558
+ }
559
+
560
+ // =========================================================================
561
+ // Periodic checking (polling fallback + fsnotify discovery)
562
+ // =========================================================================
563
+
564
+ _checkForNewSessions() {
565
+ const now = Date.now();
566
+ const candidates = [];
567
+
568
+ try {
569
+ _walkDirSyncSimple(this.claudeDir, (filePath, stats) => {
570
+ if (!isMainSessionFile(filePath, stats)) return;
571
+ if (now - stats.mtimeMs > this.activeWindow) return;
572
+
573
+ const id = path.basename(filePath).replace(/\.jsonl$/, '');
574
+ if (this.sessions.has(id)) return;
575
+
576
+ const session = this.buildSession(filePath);
577
+ candidates.push({ session, modTime: stats.mtimeMs });
578
+ });
579
+ } catch {}
580
+
581
+ candidates.sort((a, b) => b.modTime - a.modTime);
582
+
583
+ for (const c of candidates) {
584
+ if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) break;
585
+ if (this.sessions.has(c.session.id)) continue;
586
+
587
+ this.sessions.set(c.session.id, c.session);
588
+
589
+ if (this.useFsnotify) {
590
+ this._registerSessionWatches(c.session);
591
+ }
592
+
593
+ this.emit('broadcast', 'newSession', { sessionID: c.session.id, projectPath: c.session.projectPath });
594
+
595
+ for (const [agentID, agentType] of Object.entries(c.session.subagentTypes)) {
596
+ this.emit('broadcast', 'newAgent', { sessionID: c.session.id, agentID, agentType });
597
+ }
598
+
599
+ const pending = this.pendingSubagents.get(c.session.id);
600
+ if (pending) {
601
+ this.pendingSubagents.delete(c.session.id);
602
+ for (const sp of pending) {
603
+ const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
604
+ this._registerSubagent(c.session, c.session.id, agentID, sp);
605
+ }
606
+ }
607
+ }
608
+ }
609
+
610
+ _checkForNewSubagents(session) {
611
+ const subagentDir = path.join(path.dirname(session.mainFile), session.id, 'subagents');
612
+ let entries;
613
+ try { entries = fs.readdirSync(subagentDir); } catch { return; }
614
+
615
+ for (const entry of entries) {
616
+ if (!entry.endsWith('.jsonl')) continue;
617
+ const agentID = entry.replace(/^agent-/, '').replace(/\.jsonl$/, '');
618
+ if (session.subagents[agentID]) continue;
619
+
620
+ const agentPath = path.join(subagentDir, entry);
621
+ const agentType = readAgentType(agentPath);
622
+ session.subagents[agentID] = agentPath;
623
+ if (agentType) session.subagentTypes[agentID] = agentType;
624
+
625
+ this.emit('newAgent', { sessionID: session.id, agentID, agentType });
626
+ }
627
+ }
628
+
629
+ _checkForBackgroundTasks(session) {
630
+ const toolResultsDir = path.join(path.dirname(session.mainFile), session.id, 'tool-results');
631
+ let entries;
632
+ try { entries = fs.readdirSync(toolResultsDir); } catch { return; }
633
+
634
+ for (const entry of entries) {
635
+ if (!entry.endsWith('.txt')) continue;
636
+ const toolID = entry.replace(/\.txt$/, '');
637
+ if (session.backgroundTasks[toolID]) continue;
638
+
639
+ const outputPath = path.join(toolResultsDir, entry);
640
+ const parentAgentID = this._findBackgroundTaskParent(session, toolID);
641
+ const isComplete = this._isBackgroundTaskComplete(session, toolID);
642
+
643
+ const task = new BackgroundTask(toolID, parentAgentID, 'Background Task', outputPath, isComplete);
644
+ session.backgroundTasks[toolID] = task;
645
+
646
+ this.emit('broadcast', 'newBackgroundTask', {
647
+ sessionID: session.id,
648
+ parentAgentID,
649
+ toolID,
650
+ toolName: 'Background Task',
651
+ outputPath,
652
+ isComplete,
653
+ });
654
+ }
655
+ }
656
+
657
+ _findBackgroundTaskParent(session, toolID) {
658
+ const entry = session.toolIndex.get(toolID);
659
+ if (entry) return entry.parentAgentID || '';
660
+ if (!session.toolIndexPopulated) {
661
+ this._populateToolIndex(session);
662
+ }
663
+ const cached = session.toolIndex.get(toolID);
664
+ return cached ? (cached.parentAgentID || '') : '';
665
+ }
666
+
667
+ _isBackgroundTaskComplete(session, toolID) {
668
+ const entry = session.toolIndex.get(toolID);
669
+ if (entry) return entry.hasResult;
670
+ if (!session.toolIndexPopulated) {
671
+ this._populateToolIndex(session);
672
+ }
673
+ const cached = session.toolIndex.get(toolID);
674
+ return cached ? cached.hasResult : false;
675
+ }
676
+
677
+ _populateToolIndex(session) {
678
+ if (session.toolIndexPopulated) return;
679
+ session.toolIndexPopulated = true;
680
+ const files = [
681
+ { path: session.mainFile, agentID: '' },
682
+ ...Object.entries(session.subagents).map(([id, p]) => ({ path: p, agentID: id })),
683
+ ];
684
+
685
+ for (const { path: filePath, agentID } of files) {
686
+ if (!filePath) continue;
687
+ try {
688
+ const content = fs.readFileSync(filePath, 'utf-8');
689
+ for (const line of content.split('\n')) {
690
+ if (!line.includes('"tool_')) continue;
691
+
692
+ if (line.includes('"tool_use"')) {
693
+ const idMatch = line.match(/"id"\s*:\s*"([^"]+)"/);
694
+ if (!idMatch) continue;
695
+ const tid = idMatch[1];
696
+ if (session.toolIndex.has(tid)) continue;
697
+ const nameMatch = line.match(/"name"\s*:\s*"([^"]+)"/);
698
+ session.toolIndex.set(tid, {
699
+ toolName: nameMatch ? nameMatch[1] : '',
700
+ parentAgentID: agentID,
701
+ hasResult: false,
702
+ });
703
+ }
704
+
705
+ if (line.includes('"tool_result"')) {
706
+ const useIdMatch = line.match(/"tool_use_id"\s*:\s*"([^"]+)"/);
707
+ if (!useIdMatch) continue;
708
+ const tid = useIdMatch[1];
709
+ const existing = session.toolIndex.get(tid);
710
+ if (existing) {
711
+ existing.hasResult = true;
712
+ } else {
713
+ session.toolIndex.set(tid, {
714
+ toolName: '',
715
+ parentAgentID: '',
716
+ hasResult: true,
717
+ });
718
+ }
719
+ }
720
+ }
721
+ } catch {}
722
+ }
723
+ }
724
+
725
+ // =========================================================================
726
+ // Polling mode
727
+ // =========================================================================
728
+
729
+ _startPolling() {
730
+ const sessions = this.getSessionsSnapshot();
731
+ this._initializeSessionReading(sessions);
732
+
733
+ this._pollTimer = setInterval(() => {
734
+ if (!this._running) return;
735
+ this._handlePollTick();
736
+ }, this.pollInterval);
737
+
738
+ this._cleanupTimer = setInterval(() => {
739
+ if (!this._running) return;
740
+ this._cleanupFilePositions();
741
+ }, CleanupInterval);
742
+ }
743
+
744
+ _handlePollTick() {
745
+ 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);
752
+ }
753
+ }
754
+
755
+ // =========================================================================
756
+ // File reading
757
+ // =========================================================================
758
+
759
+ _initializeSessionReading(sessions) {
760
+ let shouldSkip = this.skipHistory;
761
+ if (!shouldSkip) {
762
+ let totalLines = 0;
763
+ for (const session of sessions) {
764
+ totalLines += this._countFileLines(session.mainFile);
765
+ for (const agentPath of Object.values(session.subagents)) {
766
+ totalLines += this._countFileLines(agentPath);
767
+ }
768
+ }
769
+ shouldSkip = totalLines > AutoSkipLineThreshold;
770
+ }
771
+
772
+ if (shouldSkip) {
773
+ for (const session of sessions) {
774
+ this._skipToEndOfFiles(session);
775
+ this._readSessionFiles(session);
776
+ }
777
+ } else {
778
+ for (const session of sessions) {
779
+ this._readSessionFiles(session);
780
+ }
781
+ }
782
+ }
783
+
784
+ _skipToEndOfFiles(session) {
785
+ const mainPos = this._findPositionForLastNLines(session.mainFile, KeepRecentLines);
786
+ this.filePositions.set(session.mainFile, mainPos);
787
+
788
+ for (const agentPath of Object.values(session.subagents)) {
789
+ const pos = this._findPositionForLastNLines(agentPath, KeepRecentLines);
790
+ this.filePositions.set(agentPath, pos);
791
+ }
792
+ }
793
+
794
+ _findPositionForLastNLines(filePath, n) {
795
+ try {
796
+ const stat = fs.statSync(filePath);
797
+ const fileSize = stat.size;
798
+ if (fileSize === 0) return 0;
799
+
800
+ let fd;
801
+ try {
802
+ fd = fs.openSync(filePath, 'r');
803
+ const chunkSize = 8192;
804
+ const buf = Buffer.alloc(chunkSize);
805
+ let newlineCount = 0;
806
+ let position = fileSize;
807
+ let lastNewlinePos = fileSize;
808
+
809
+ while (position > 0 && newlineCount <= n) {
810
+ const readLen = Math.min(chunkSize, position);
811
+ position -= readLen;
812
+ fs.readSync(fd, buf, 0, readLen, position);
813
+
814
+ for (let i = readLen - 1; i >= 0; i--) {
815
+ if (buf[i] === 0x0A) {
816
+ newlineCount++;
817
+ if (newlineCount === n) {
818
+ lastNewlinePos = position + i + 1;
819
+ break;
820
+ }
821
+ }
822
+ }
823
+ }
824
+
825
+ if (newlineCount < n) return 0;
826
+ return lastNewlinePos;
827
+ } finally {
828
+ if (fd !== undefined) try { fs.closeSync(fd); } catch {}
829
+ }
830
+ } catch {
831
+ return 0;
832
+ }
833
+ }
834
+
835
+ _readSessionFiles(session) {
836
+ this._readFile(session.mainFile, session.id, '', '');
837
+ for (const [agentID, agentPath] of Object.entries(session.subagents)) {
838
+ const agentType = session.subagentTypes[agentID] || '';
839
+ this._readFile(agentPath, session.id, agentID, agentType);
840
+ }
841
+ }
842
+
843
+ _readFile(filePath, sessionID, agentID, agentType) {
844
+ let fd;
845
+ 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
+ }
874
+
875
+ // File has grown: last line may be missing its newline, defer it
876
+ if (isLast && fileGrew) {
877
+ continue;
878
+ }
879
+
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
+ }
904
+ }
905
+
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;
914
+ } else {
915
+ session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
916
+ }
917
+ } else if (item.type === 'tool_input' && !existing) {
918
+ session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
919
+ }
920
+ }
921
+ }
922
+
923
+ this.emit('item', item);
924
+ }
925
+ }
926
+
927
+ this.filePositions.set(filePath, Math.min(newPos, stats.size));
928
+ } catch {} finally {
929
+ if (fd !== undefined) {
930
+ try { fs.closeSync(fd); } catch {}
931
+ }
932
+ }
933
+ }
934
+
935
+ _countFileLines(filePath) {
936
+ try {
937
+ const stat = fs.statSync(filePath);
938
+ if (stat.size === 0) return 0;
939
+ const fd = fs.openSync(filePath, 'r');
940
+ const buf = Buffer.alloc(8192);
941
+ let count = 0;
942
+ 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++;
948
+ }
949
+ pos += bytesRead;
950
+ }
951
+ fs.closeSync(fd);
952
+ return count;
953
+ } catch {
954
+ return 0;
955
+ }
956
+ }
957
+
958
+ _cleanupFilePositions() {
959
+ for (const p of this.filePositions.keys()) {
960
+ try { fs.accessSync(p); } catch {
961
+ this.filePositions.delete(p);
962
+ this.fileContexts.delete(p);
963
+ }
964
+ }
965
+
966
+ // Remove stale sessions whose main file is no longer active
967
+ const now = Date.now();
968
+ for (const [sessionID, session] of this.sessions) {
969
+ let stats;
970
+ try { stats = fs.statSync(session.mainFile); } catch {
971
+ this.removeSession(sessionID);
972
+ this.emit('broadcast', 'sessionRemoved', { sessionID });
973
+ continue;
974
+ }
975
+ if (now - stats.mtimeMs > this.activeWindow) {
976
+ this.removeSession(sessionID);
977
+ this.emit('broadcast', 'sessionRemoved', { sessionID });
978
+ }
979
+ }
980
+ }
981
+
982
+ // =========================================================================
983
+ // Public API
984
+ // =========================================================================
985
+
986
+ setSkipHistory(skip) {
987
+ this.skipHistory = skip;
988
+ }
989
+
990
+ removeSession(sessionID) {
991
+ const session = this.sessions.get(sessionID);
992
+ if (session) {
993
+ const paths = [session.mainFile, ...Object.values(session.subagents)];
994
+ for (const p of paths) {
995
+ if (p) {
996
+ this.fileContexts.delete(p);
997
+ this.filePositions.delete(p);
998
+ const timer = this.debounceTimers.get(p);
999
+ if (timer) {
1000
+ clearTimeout(timer);
1001
+ this.debounceTimers.delete(p);
1002
+ }
1003
+ }
1004
+ }
1005
+ }
1006
+ this.sessions.delete(sessionID);
1007
+ if (session) {
1008
+ this.emit('sessionRemoved', { sessionID });
1009
+ }
1010
+ }
1011
+
1012
+ toggleAutoDiscovery() {
1013
+ this.watchActive = !this.watchActive;
1014
+ }
1015
+
1016
+ isAutoDiscoveryEnabled() {
1017
+ return this.watchActive;
1018
+ }
1019
+
1020
+ // =========================================================================
1021
+ // Directory walking
1022
+ // =========================================================================
1023
+
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);
1044
+ }
1045
+
1046
+ // ============================================================================
1047
+ // Pure synchronous directory walk (no async/Promise overhead)
1048
+ // ============================================================================
1049
+
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);
1061
+ }
1062
+ }
1063
+ } catch {}
1064
+ }
1065
+
1066
+ // ============================================================================
1067
+ // Static listing methods
1068
+ // ============================================================================
1069
+
1070
+ async function listSessions(limit = 10) {
1071
+ return _listSessionsFiltered(limit, 0);
1072
+ }
1073
+
1074
+ async function listActiveSessions(withinMs) {
1075
+ return _listSessionsFiltered(0, withinMs);
1076
+ }
1077
+
1078
+ async function _listSessionsFiltered(limit, activeWithin) {
1079
+ const claudeDir = getClaudeProjectsDir();
1080
+ const sessions = [];
1081
+ const now = Date.now();
1082
+
1083
+ try {
1084
+ await _walkDirStatic(claudeDir, (filePath, stats) => {
1085
+ if (!isMainSessionFile(filePath, stats)) return;
1086
+ if (activeWithin > 0 && (now - stats.mtimeMs) > activeWithin) return;
1087
+
1088
+ const basename = path.basename(filePath);
1089
+ const projectDir = path.basename(path.dirname(filePath));
1090
+ const projectPath = resolveProjectPath(projectDir);
1091
+
1092
+ sessions.push({
1093
+ id: basename.replace(/\.jsonl$/, ''),
1094
+ path: filePath,
1095
+ projectPath,
1096
+ modified: stats.mtime,
1097
+ isActive: (now - stats.mtimeMs) < RecentActivityThreshold,
1098
+ });
1099
+ });
1100
+ } catch {}
1101
+
1102
+ sessions.sort((a, b) => b.modified - a.modified);
1103
+ if (limit > 0 && sessions.length > limit) sessions.length = limit;
1104
+
1105
+ return sessions;
1106
+ }
1107
+
1108
+ 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 {}
1122
+ }
1123
+
1124
+ module.exports = {
1125
+ Watcher,
1126
+ Session,
1127
+ BackgroundTask,
1128
+ listSessions,
1129
+ listActiveSessions,
1130
+ };