claude-code-watch 0.0.8 → 0.0.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Web-based real-time monitor for Claude Code.",
5
5
  "main": "./src/server/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -338,43 +338,22 @@ let lastMsgTime = 0;
338
338
  let staleCheckTimer = null;
339
339
 
340
340
  let sessions = [];
341
+ let sessionsMap = new Map(); // id -> session, for O(1) lookups
341
342
  let treeNodes = [];
342
343
  let treeCursor = 0;
343
344
  let streamItems = [];
344
- const seenToolIDsKeys = [];
345
- const seenToolIDsSet = new Set();
346
- const seenToolIDsMax = 5000;
347
-
348
- function seenToolIDsHas(key) {
349
- return seenToolIDsSet.has(key);
350
- }
351
- function seenToolIDsAdd(key) {
352
- seenToolIDsSet.add(key);
353
- seenToolIDsKeys.push(key);
354
- if (seenToolIDsKeys.length > seenToolIDsMax) {
355
- const evictCount = seenToolIDsKeys.length >> 1;
356
- for (let i = 0; i < evictCount; i++) {
357
- seenToolIDsSet.delete(seenToolIDsKeys[i]);
358
- }
359
- seenToolIDsKeys.splice(0, evictCount);
360
- }
361
- }
362
- const toolNameMapMax = 2000;
363
- let toolNameMap = new Map(); // toolID -> toolName
364
- let toolNameMapKeys = [];
365
-
366
- function toolNameMapSet(toolID, toolName) {
367
- if (toolNameMap.has(toolID)) return;
368
- toolNameMap.set(toolID, toolName);
369
- toolNameMapKeys.push(toolID);
370
- if (toolNameMapKeys.length > toolNameMapMax) {
371
- const evictCount = toolNameMapKeys.length >> 1;
372
- for (let i = 0; i < evictCount; i++) {
373
- toolNameMap.delete(toolNameMapKeys[i]);
374
- }
375
- toolNameMapKeys.splice(0, evictCount);
376
- }
377
- }
345
+ let visibleItems = [];
346
+ let visibleDirty = true;
347
+ // LRU cache: recently accessed keys survive eviction, so a tool_input's ID
348
+ // stays alive long enough for its matching tool_output to arrive and merge.
349
+ class LRUCache {
350
+ constructor(max) { this.max = max; this.map = new Map(); }
351
+ has(key) { if (!this.map.has(key)) return false; const v = this.map.get(key); this.map.delete(key); this.map.set(key, v); return true; }
352
+ get(key) { if (!this.map.has(key)) return undefined; const v = this.map.get(key); this.map.delete(key); this.map.set(key, v); return v; }
353
+ set(key, val) { if (this.map.has(key)) this.map.delete(key); this.map.set(key, val); if (this.map.size > this.max) { const oldest = this.map.keys().next().value; this.map.delete(oldest); } }
354
+ }
355
+ const seenToolIDs = new LRUCache(5000);
356
+ const toolNameMap = new LRUCache(2000);
378
357
  let filters = new Map();
379
358
 
380
359
  let showThinking = true;
@@ -407,6 +386,7 @@ const MAX_ITEMS = 3000;
407
386
  const MAX_LINES = 50;
408
387
  let renderedItemCount = 0;
409
388
  let needsFullRender = true;
389
+ visibleDirty = true;
410
390
 
411
391
  // ══════════════════════════════════════════════════════════════════════════════
412
392
  // Markdown renderer (marked + highlight.js)
@@ -525,7 +505,7 @@ function sendCmd(action, extra = {}) {
525
505
  function handleSnapshot(payload) {
526
506
  autoDiscovery = payload.autoDiscovery;
527
507
  for (const s of (payload.sessions || [])) {
528
- let session = sessions.find(x => x.id === s.id);
508
+ let session = sessionsMap.get(s.id);
529
509
  if (!session) {
530
510
  session = {
531
511
  id: s.id, projectPath: s.projectPath, title: '',
@@ -534,6 +514,7 @@ function handleSnapshot(payload) {
534
514
  lastActivity: Date.now(),
535
515
  };
536
516
  sessions.push(session);
517
+ sessionsMap.set(session.id, session);
537
518
  session.agents.push({ id: '', name: 'Main', type: 'main' });
538
519
  }
539
520
  session.lastActivity = Date.now();
@@ -555,26 +536,30 @@ function handleSnapshot(payload) {
555
536
  updateFilters();
556
537
  rebuildNodes();
557
538
  needsFullRender = true;
539
+ visibleDirty = true;
558
540
  scheduleRender();
559
541
  }
560
542
 
561
543
  function handleNewSession(payload) {
562
- if (sessions.find(s => s.id === payload.sessionID)) return;
563
- sessions.push({
544
+ if (sessionsMap.has(payload.sessionID)) return;
545
+ const session = {
564
546
  id: payload.sessionID, projectPath: payload.projectPath,
565
547
  title: '', folder: folderName(payload.projectPath), model: '',
566
548
  agents: [{ id: '', name: 'Main', type: 'main' }],
567
549
  tasks: [], collapsed: false, pinned: false,
568
550
  lastActivity: Date.now(),
569
- });
551
+ };
552
+ sessions.push(session);
553
+ sessionsMap.set(session.id, session);
570
554
  updateFilters();
571
555
  rebuildNodes();
572
556
  needsFullRender = true;
557
+ visibleDirty = true;
573
558
  scheduleRender();
574
559
  }
575
560
 
576
561
  function handleNewAgent(payload) {
577
- const s = sessions.find(s => s.id === payload.sessionID);
562
+ const s = sessionsMap.get(payload.sessionID);
578
563
  if (!s || s.agents.find(a => a.id === payload.agentID)) return;
579
564
  s.agents.push({
580
565
  id: payload.agentID,
@@ -584,11 +569,12 @@ function handleNewAgent(payload) {
584
569
  updateFilters();
585
570
  rebuildNodes();
586
571
  needsFullRender = true;
572
+ visibleDirty = true;
587
573
  scheduleRender();
588
574
  }
589
575
 
590
576
  function handleNewBgTask(payload) {
591
- const s = sessions.find(s => s.id === payload.sessionID);
577
+ const s = sessionsMap.get(payload.sessionID);
592
578
  if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
593
579
  s.tasks.push({
594
580
  id: payload.toolID, parentAgentID: payload.parentAgentID,
@@ -608,6 +594,7 @@ function handleSessionRemoved(payload) {
608
594
  updateFilters();
609
595
  rebuildNodes();
610
596
  needsFullRender = true;
597
+ visibleDirty = true;
611
598
  scheduleRender();
612
599
  }
613
600
 
@@ -617,13 +604,13 @@ function handleSessionRemoved(payload) {
617
604
 
618
605
  function handleItem(item) {
619
606
  if (item.type === 'session_title') {
620
- const s = sessions.find(s => s.id === item.sessionID);
607
+ const s = sessionsMap.get(item.sessionID);
621
608
  if (s) { s.title = item.content.slice(0, 30); }
622
609
  scheduleRender();
623
610
  return;
624
611
  }
625
612
  // Update activity
626
- const s = sessions.find(s => s.id === item.sessionID);
613
+ const s = sessionsMap.get(item.sessionID);
627
614
  if (s) s.lastActivity = Date.now();
628
615
  pushItem(item);
629
616
  scheduleRender();
@@ -632,7 +619,7 @@ function handleItem(item) {
632
619
  function handleItemBatch(items) {
633
620
  for (const item of items) {
634
621
  if (item.type === 'session_title') {
635
- const s = sessions.find(s => s.id === item.sessionID);
622
+ const s = sessionsMap.get(item.sessionID);
636
623
  if (s) { s.title = item.content.slice(0, 30); }
637
624
  continue;
638
625
  }
@@ -646,24 +633,28 @@ function pushItem(item) {
646
633
  // to avoid divergence between frontend accumulation and server tracking
647
634
 
648
635
  if (item.model) {
649
- const s = sessions.find(s => s.id === item.sessionID);
636
+ const s = sessionsMap.get(item.sessionID);
650
637
  if (s) s.model = item.model;
651
638
  }
652
639
 
653
640
  if (item.type === 'tool_input' && item.toolID && item.toolName) {
654
- toolNameMapSet(item.toolID, item.toolName);
641
+ toolNameMap.set(item.toolID, item.toolName);
655
642
  }
656
643
 
657
644
  if (item.toolID) {
658
645
  const key = `${item.toolID}:${item.type}`;
659
- if (seenToolIDsHas(key)) return;
660
- seenToolIDsAdd(key);
646
+ if (seenToolIDs.has(key)) return;
647
+ seenToolIDs.set(key, true);
661
648
  }
662
649
 
663
650
  streamItems.push(item);
664
651
  if (streamItems.length > MAX_ITEMS) {
665
652
  streamItems = streamItems.slice(-MAX_ITEMS);
666
- needsFullRender = true;
653
+ visibleDirty = true;
654
+ }
655
+ // Incrementally update visibleItems — no need to re-filter on every item
656
+ if (!visibleDirty && isItemVisible(item)) {
657
+ visibleItems.push(item);
667
658
  }
668
659
  }
669
660
 
@@ -830,7 +821,13 @@ function isSessionActive(session) {
830
821
  // ══════════════════════════════════════════════════════════════════════════════
831
822
 
832
823
  function renderStream() {
833
- const visible = streamItems.filter(isItemVisible);
824
+ // Rebuild visibleItems from scratch only when filters/toggles changed
825
+ if (visibleDirty) {
826
+ visibleItems = streamItems.filter(isItemVisible);
827
+ visibleDirty = false;
828
+ }
829
+
830
+ const visible = visibleItems;
834
831
  const wasAutoScroll = autoScroll;
835
832
 
836
833
  if (needsFullRender || renderedItemCount > visible.length) {
@@ -1131,11 +1128,16 @@ function removeSelectedSession() {
1131
1128
  // Toggles
1132
1129
  // ══════════════════════════════════════════════════════════════════════════════
1133
1130
 
1134
- function toggleThinking() { showThinking = !showThinking; needsFullRender = true; renderStream(); refreshButtons(); }
1135
- function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true; renderStream(); refreshButtons(); }
1136
- function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true; renderStream(); refreshButtons(); }
1137
- function toggleText() { showText = !showText; needsFullRender = true; renderStream(); refreshButtons(); }
1138
- function toggleHook() { showHook = !showHook; needsFullRender = true; renderStream(); refreshButtons(); }
1131
+ function toggleThinking() { showThinking = !showThinking; needsFullRender = true;
1132
+ visibleDirty = true; renderStream(); refreshButtons(); }
1133
+ function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true;
1134
+ visibleDirty = true; renderStream(); refreshButtons(); }
1135
+ function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true;
1136
+ visibleDirty = true; renderStream(); refreshButtons(); }
1137
+ function toggleText() { showText = !showText; needsFullRender = true;
1138
+ visibleDirty = true; renderStream(); refreshButtons(); }
1139
+ function toggleHook() { showHook = !showHook; needsFullRender = true;
1140
+ visibleDirty = true; renderStream(); refreshButtons(); }
1139
1141
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1140
1142
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1141
1143
  function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
@@ -1276,6 +1278,7 @@ function fmtTok(n) {
1276
1278
 
1277
1279
  function renderAll() {
1278
1280
  needsFullRender = true;
1281
+ visibleDirty = true;
1279
1282
  renderTree();
1280
1283
  renderStream();
1281
1284
  refreshButtons();
@@ -145,6 +145,7 @@ function parseSessionTitle(raw, timestamp, title) {
145
145
  type: StreamItemType.SESSION_TITLE,
146
146
  sessionID: raw.sessionId,
147
147
  content: title,
148
+ timestamp,
148
149
  })];
149
150
  }
150
151
 
@@ -162,6 +163,7 @@ function parseSystemMessage(raw, timestamp) {
162
163
  agentID: raw.agentId || '',
163
164
  agentName: name,
164
165
  durationMs: raw.durationMs || 0,
166
+ timestamp,
165
167
  })];
166
168
  case 'compact_boundary':
167
169
  return [makeItem({
@@ -170,6 +172,7 @@ function parseSystemMessage(raw, timestamp) {
170
172
  agentID: raw.agentId || '',
171
173
  agentName: name,
172
174
  content: formatCompactSummary(raw.compactMetadata),
175
+ timestamp,
173
176
  })];
174
177
  default:
175
178
  return [];
@@ -216,6 +219,7 @@ function parseAttachment(raw, timestamp) {
216
219
  toolName: raw.attachment.hookName || '',
217
220
  content: body,
218
221
  durationMs: raw.attachment.durationMs || 0,
222
+ timestamp,
219
223
  })];
220
224
  }
221
225
  case 'diagnostics':
@@ -237,6 +241,7 @@ function diagnosticsItems(raw, timestamp, agentName) {
237
241
  agentName,
238
242
  toolName: diagnosticsHeader(f),
239
243
  content: diagnosticsBody(f.diagnostics),
244
+ timestamp,
240
245
  }));
241
246
  }
242
247
  return items;
@@ -289,6 +294,7 @@ function parsePRLink(raw, timestamp) {
289
294
  type: StreamItemType.PR_LINK,
290
295
  sessionID: raw.sessionId,
291
296
  content,
297
+ timestamp,
292
298
  })];
293
299
  }
294
300
 
@@ -312,6 +318,7 @@ function parseAssistantMessage(raw, timestamp) {
312
318
  agentID: raw.agentId || '',
313
319
  agentName: name,
314
320
  content: block.thinking,
321
+ timestamp,
315
322
  }));
316
323
  }
317
324
  break;
@@ -322,6 +329,7 @@ function parseAssistantMessage(raw, timestamp) {
322
329
  agentID: raw.agentId || '',
323
330
  agentName: name,
324
331
  content: block.text,
332
+ timestamp,
325
333
  }));
326
334
  }
327
335
  break;
@@ -333,6 +341,7 @@ function parseAssistantMessage(raw, timestamp) {
333
341
  content: formatToolInput(block.name, block.input),
334
342
  toolName: prettyToolName(block.name),
335
343
  toolID: block.id || '',
344
+ timestamp,
336
345
  }));
337
346
  break;
338
347
  }
@@ -378,6 +387,7 @@ function parseUserMessage(raw, timestamp) {
378
387
  content: extractToolResultContent(result.content),
379
388
  toolID: result.tool_use_id || '',
380
389
  durationMs,
390
+ timestamp,
381
391
  }));
382
392
  }
383
393
  }
@@ -406,14 +416,21 @@ function extractToolResultContent(content) {
406
416
  // Tool Input Formatting
407
417
  // ============================================================================
408
418
 
419
+ var MAX_TOOL_INPUT_LENGTH = 5000;
420
+
421
+ function truncate(s) {
422
+ if (!s || s.length <= MAX_TOOL_INPUT_LENGTH) return s;
423
+ return s.slice(0, MAX_TOOL_INPUT_LENGTH) + '...truncated';
424
+ }
425
+
409
426
  function formatToolInput(toolName, input) {
410
427
  if (!input) return '';
411
428
  const inp = input;
412
429
 
413
430
  switch (toolName) {
414
431
  case 'Bash':
415
- if (inp.description) return `${inp.command}\n # ${inp.description}`;
416
- return inp.command || '';
432
+ if (inp.description) return truncate(`${inp.command}\n # ${inp.description}`);
433
+ return truncate(inp.command || '');
417
434
  case 'Read':
418
435
  return inp.file_path || '';
419
436
  case 'Write':
@@ -432,22 +449,22 @@ function formatToolInput(toolName, input) {
432
449
  return inp.query || '';
433
450
  case 'Task':
434
451
  case 'Agent':
435
- if (inp.description) return inp.description;
436
- return inp.prompt || '';
452
+ if (inp.description) return truncate(inp.description);
453
+ return truncate(inp.prompt || '');
437
454
  case 'Skill':
438
- if (inp.args) return `${inp.skill} \u2014 ${inp.args}`;
455
+ if (inp.args) return truncate(`${inp.skill} \u2014 ${inp.args}`);
439
456
  return inp.skill || '';
440
457
  case 'ToolSearch':
441
458
  return inp.query || '';
442
459
  case 'ScheduleWakeup':
443
460
  if (inp.reason) return inp.reason;
444
461
  if (inp.delaySeconds > 0) return `delay ${inp.delaySeconds}s`;
445
- return JSON.stringify(input);
462
+ return truncate(JSON.stringify(input));
446
463
  case 'TaskCreate':
447
464
  return inp.subject || '';
448
465
  case 'TaskUpdate':
449
466
  if (inp.taskId) return `task ${inp.taskId}`;
450
- return JSON.stringify(input);
467
+ return truncate(JSON.stringify(input));
451
468
  case 'TaskStop':
452
469
  return inp.task_id || '';
453
470
  case 'EnterPlanMode':
@@ -456,9 +473,9 @@ function formatToolInput(toolName, input) {
456
473
  return '(exit plan mode)';
457
474
  case 'CronCreate':
458
475
  if (inp.cron && inp.prompt) return `${inp.cron}: ${inp.prompt}`;
459
- return JSON.stringify(input);
476
+ return truncate(JSON.stringify(input));
460
477
  default:
461
- return JSON.stringify(input);
478
+ return truncate(JSON.stringify(input));
462
479
  }
463
480
  }
464
481
 
@@ -33,9 +33,12 @@ class DashboardServer {
33
33
  this.itemBuffer = [];
34
34
  this.contextMap = new Map();
35
35
  this._contextCleanupTimer = null;
36
+ this._pendingItems = [];
37
+ this._flushTimer = null;
36
38
 
37
39
  this.server = null;
38
40
  this.wss = null;
41
+ this._heartbeatTimer = null;
39
42
 
40
43
  setDebugAll(options.debugAll || false);
41
44
  this.debugAll = options.debugAll || false;
@@ -180,7 +183,16 @@ class DashboardServer {
180
183
  const filePath = params.get('path');
181
184
  if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
182
185
  const resolved = path.resolve(filePath);
183
- if (!resolved.startsWith(path.resolve(os.homedir(), '.claude', 'projects'))) {
186
+ const allowedPrefix = path.resolve(os.homedir(), '.claude', 'projects');
187
+ // Resolve symlinks before prefix check to prevent symlink-based path traversal
188
+ try {
189
+ const realPath = await fs.promises.realpath(resolved);
190
+ if (!realPath.startsWith(allowedPrefix)) {
191
+ this.sendJSON(res, { error: 'Access denied' }, 403);
192
+ return;
193
+ }
194
+ } catch {
195
+ // realpath fails for non-existent files — block them
184
196
  this.sendJSON(res, { error: 'Access denied' }, 403);
185
197
  return;
186
198
  }
@@ -296,7 +308,19 @@ class DashboardServer {
296
308
  this.itemBuffer = this.itemBuffer.slice(-MAX_ITEM_BUFFER);
297
309
  }
298
310
  this.updateContext(item);
299
- this.broadcast('item', item);
311
+ this._pendingItems.push(item);
312
+ if (!this._flushTimer) {
313
+ this._flushTimer = setTimeout(() => {
314
+ this._flushTimer = null;
315
+ const batch = this._pendingItems;
316
+ this._pendingItems = [];
317
+ if (batch.length === 1) {
318
+ this.broadcast('item', batch[0]);
319
+ } else if (batch.length > 1) {
320
+ this.broadcast('itemBatch', batch);
321
+ }
322
+ }, 50);
323
+ }
300
324
  });
301
325
  w.on('broadcast', (type, payload) => {
302
326
  this.broadcast(type, payload);
@@ -334,11 +358,23 @@ class DashboardServer {
334
358
  if (process.platform === 'win32') {
335
359
  cp.execSync(`taskkill /PID ${parsedPid} /F`, { encoding: 'utf-8' });
336
360
  } else {
337
- process.kill(parsedPid, 'SIGKILL');
361
+ process.kill(parsedPid, 'SIGTERM');
338
362
  }
339
363
  } catch {}
340
364
  }
341
365
  }
366
+
367
+ // Wait for graceful shutdown, then escalate to SIGKILL if still alive
368
+ if (process.platform !== 'win32') {
369
+ await new Promise(r => setTimeout(r, 3000));
370
+ for (const pid of pids) {
371
+ const parsedPid = parseInt(pid, 10);
372
+ if (Number.isInteger(parsedPid) && parsedPid > 0) {
373
+ try { process.kill(parsedPid, 0); process.kill(parsedPid, 'SIGKILL'); } catch {}
374
+ }
375
+ }
376
+ }
377
+
342
378
  // Wait briefly for the port to be released
343
379
  await new Promise(r => setTimeout(r, 500));
344
380
  return true;
@@ -380,7 +416,7 @@ class DashboardServer {
380
416
  });
381
417
  });
382
418
 
383
- this.wss = new WebSocketServer({ server: this.server });
419
+ this.wss = new WebSocketServer({ server: this.server, maxPayload: 1024 * 1024 });
384
420
  this.wss.on('connection', (ws) => this.onWsConnection(ws));
385
421
 
386
422
  // Register error handler once (not inside doListen to avoid accumulation)
@@ -406,6 +442,7 @@ class DashboardServer {
406
442
  }
407
443
 
408
444
  this._contextCleanupTimer = setInterval(() => this.cleanupContextMap(), CONTEXT_STALE_MS);
445
+ this._heartbeatTimer = setInterval(() => this.broadcast('heartbeat', null), 30000);
409
446
 
410
447
  // Start listening and wait for server to be ready before opening browser
411
448
  await new Promise((resolve) => {
@@ -438,6 +475,8 @@ class DashboardServer {
438
475
 
439
476
  stop() {
440
477
  if (this._contextCleanupTimer) clearInterval(this._contextCleanupTimer);
478
+ if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
479
+ if (this._flushTimer) clearTimeout(this._flushTimer);
441
480
  if (this.wss) this.wss.close();
442
481
  if (this.server) this.server.close();
443
482
  if (this.watcher) this.watcher.stop();
@@ -17,6 +17,7 @@ var AutoSkipLineThreshold = 100;
17
17
  var KeepRecentLines = 10;
18
18
  var CleanupInterval = 5 * 60 * 1000;
19
19
  var FsnotifyDiscoveryInterval = 60 * 1000;
20
+ var MaxReadChunk = 64 * 1024;
20
21
  var RecentActivityThreshold = 2 * 60 * 1000;
21
22
  var DebounceInterval = 50;
22
23
 
@@ -31,7 +32,7 @@ function getClaudeProjectsDir() {
31
32
  return path.join(os.homedir(), '.claude', 'projects');
32
33
  }
33
34
 
34
- function resolveProjectPath(encoded) {
35
+ async function resolveProjectPath(encoded) {
35
36
  let s = encoded;
36
37
  if (s.startsWith('-')) s = s.slice(1);
37
38
  if (!s) return '';
@@ -44,7 +45,7 @@ function resolveProjectPath(encoded) {
44
45
  const dirPart = parts.slice(joinFrom).join('-');
45
46
  const testPath = `/${pathPart}/${dirPart}`;
46
47
  try {
47
- fs.accessSync(testPath);
48
+ await fsp.access(testPath);
48
49
  return `${pathPart}/${dirPart}`;
49
50
  } catch {
50
51
  // Path doesn't exist, try next combination
@@ -89,6 +90,7 @@ class Session {
89
90
  this.backgroundTasks = {}; // toolID -> BackgroundTask
90
91
  this.toolIndex = new Map(); // toolID -> { toolName, parentAgentID, hasResult }
91
92
  this.toolIndexPopulated = false;
93
+ this._toolIndexPromise = null;
92
94
  }
93
95
  }
94
96
 
@@ -187,22 +189,19 @@ class Watcher extends EventEmitter {
187
189
  if (jsonlFiles.length === 0) return null;
188
190
 
189
191
  // Sort by mtime (most recent first)
190
- jsonlFiles.sort((a, b) => {
191
- try {
192
- const sa = fs.statSync(a);
193
- const sb = fs.statSync(b);
194
- return sb.mtimeMs - sa.mtimeMs;
195
- } catch {
196
- return 0;
197
- }
198
- });
192
+ const statResults = await Promise.all(jsonlFiles.map(async f => {
193
+ try { return { path: f, mtime: (await fsp.stat(f)).mtimeMs }; } catch { return null; }
194
+ }));
195
+ const validStats = statResults.filter(s => s !== null);
196
+ validStats.sort((a, b) => b.mtime - a.mtime);
199
197
 
200
198
  let mainFile;
201
199
  if (sessionID) {
202
- mainFile = jsonlFiles.find(f => f.includes(sessionID));
200
+ mainFile = validStats.find(s => s.path.includes(sessionID));
203
201
  if (!mainFile) return null;
202
+ mainFile = mainFile.path;
204
203
  } else {
205
- mainFile = jsonlFiles[0];
204
+ mainFile = validStats.length > 0 ? validStats[0].path : jsonlFiles[0];
206
205
  }
207
206
 
208
207
  return this.buildSession(mainFile);
@@ -212,7 +211,7 @@ class Watcher extends EventEmitter {
212
211
  const base = path.basename(mainFile);
213
212
  const id = base.replace(/\.jsonl$/, '');
214
213
  const projectDir = path.basename(path.dirname(mainFile));
215
- const projectPath = resolveProjectPath(projectDir);
214
+ const projectPath = await resolveProjectPath(projectDir);
216
215
 
217
216
  const session = new Session(id, projectPath, mainFile);
218
217
 
@@ -322,11 +321,7 @@ class Watcher extends EventEmitter {
322
321
  async _startFsnotify() {
323
322
  // Set up watches
324
323
  try {
325
- if (fs.existsSync(this.claudeDir)) {
326
- this._addDirectoryWatches(this.claudeDir);
327
- } else {
328
- this._watchAncestor(this.claudeDir);
329
- }
324
+ try { await fsp.stat(this.claudeDir); await this._addDirectoryWatches(this.claudeDir); } catch { await this._watchAncestor(this.claudeDir); }
330
325
  } catch (err) {
331
326
  if (this.debug) console.error('[watcher] start watch setup error:', err.message);
332
327
  }
@@ -338,7 +333,7 @@ class Watcher extends EventEmitter {
338
333
  }
339
334
 
340
335
  // chokidar events
341
- this.watcher.on('add', (p) => this._handleFsCreate(p));
336
+ this.watcher.on('add', (p) => this._handleFsCreate(p).catch(() => {}));
342
337
  this.watcher.on('change', (p) => this._handleFsWrite(p));
343
338
  this.watcher.on('unlink', (p) => {
344
339
  this.filePositions.delete(p);
@@ -349,7 +344,7 @@ class Watcher extends EventEmitter {
349
344
  // Periodic cleanup and discovery
350
345
  this._cleanupTimer = setInterval(() => {
351
346
  if (!this._running) return;
352
- this._cleanupFilePositions();
347
+ this._cleanupFilePositions().catch(() => {});
353
348
  }, CleanupInterval);
354
349
 
355
350
  this._discoveryTimer = setInterval(() => {
@@ -358,13 +353,13 @@ class Watcher extends EventEmitter {
358
353
  }, FsnotifyDiscoveryInterval);
359
354
  }
360
355
 
361
- _watchAncestor(target) {
356
+ async _watchAncestor(target) {
362
357
  let dir = target;
363
358
  while (true) {
364
359
  const parent = path.dirname(dir);
365
360
  if (parent === dir) break;
366
361
  try {
367
- fs.accessSync(parent);
362
+ await fsp.access(parent);
368
363
  this.watcher.add(parent);
369
364
  return;
370
365
  } catch {}
@@ -372,20 +367,20 @@ class Watcher extends EventEmitter {
372
367
  }
373
368
  }
374
369
 
375
- _addDirectoryWatches(root, maxDepth = 10) {
376
- const addRecursive = (dir, depth) => {
370
+ async _addDirectoryWatches(root, maxDepth = 10) {
371
+ const addRecursive = async (dir, depth) => {
377
372
  if (depth > maxDepth) return;
378
373
  try {
379
- const entries = fs.readdirSync(dir, { withFileTypes: true });
374
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
380
375
  this.watcher.add(dir);
381
376
  for (const entry of entries) {
382
377
  if (entry.isDirectory()) {
383
- addRecursive(path.join(dir, entry.name), depth + 1);
378
+ await addRecursive(path.join(dir, entry.name), depth + 1);
384
379
  }
385
380
  }
386
381
  } catch {}
387
382
  };
388
- addRecursive(root, 0);
383
+ await addRecursive(root, 0);
389
384
  }
390
385
 
391
386
  _registerSessionWatches(session) {
@@ -406,17 +401,17 @@ class Watcher extends EventEmitter {
406
401
  // chokidar event handlers
407
402
  // =========================================================================
408
403
 
409
- _handleFsCreate(p) {
404
+ async _handleFsCreate(p) {
410
405
  let stats;
411
- try { stats = fs.statSync(p); } catch { return; }
406
+ try { stats = await fsp.stat(p); } catch { return; }
412
407
 
413
408
  if (stats.isDirectory()) {
414
409
  this.watcher.add(p);
415
- this._scanNewDirectory(p);
410
+ await this._scanNewDirectory(p);
416
411
  if (p === this.claudeDir || this.claudeDir.startsWith(p)) {
417
412
  try {
418
- fs.accessSync(this.claudeDir);
419
- this._addDirectoryWatches(this.claudeDir);
413
+ await fsp.access(this.claudeDir);
414
+ await this._addDirectoryWatches(this.claudeDir);
420
415
  this.discoverActiveSessions().catch(err => {
421
416
  if (this.debug) console.error('[watcher] discoverActiveSessions error:', err.message);
422
417
  });
@@ -441,15 +436,15 @@ class Watcher extends EventEmitter {
441
436
  }
442
437
  }
443
438
 
444
- _scanNewDirectory(dirPath) {
439
+ async _scanNewDirectory(dirPath) {
445
440
  let entries;
446
- try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
441
+ try { entries = await fsp.readdir(dirPath, { withFileTypes: true }); } catch { return; }
447
442
  const base = path.basename(dirPath);
448
443
  for (const entry of entries) {
449
444
  const fullPath = path.join(dirPath, entry.name);
450
445
  if (entry.isDirectory()) {
451
446
  this.watcher.add(fullPath);
452
- this._scanNewDirectory(fullPath);
447
+ await this._scanNewDirectory(fullPath);
453
448
  continue;
454
449
  }
455
450
  if (base === 'subagents' && entry.name.endsWith('.jsonl')) {
@@ -461,8 +456,16 @@ class Watcher extends EventEmitter {
461
456
  }
462
457
 
463
458
  _handleFsWrite(p) {
464
- const ctx = this.fileContexts.get(p);
465
- if (!ctx) return;
459
+ let ctx = this.fileContexts.get(p);
460
+
461
+ // If fileContexts is missing (race condition during async session registration),
462
+ // try to infer the session context from the path
463
+ if (!ctx) {
464
+ ctx = this._inferFileContext(p);
465
+ if (!ctx) return;
466
+ // Register it so future events are found directly
467
+ this.fileContexts.set(p, ctx);
468
+ }
466
469
 
467
470
  // Debounce
468
471
  const existing = this.debounceTimers.get(p);
@@ -481,6 +484,28 @@ class Watcher extends EventEmitter {
481
484
  this.debounceTimers.set(p, timer);
482
485
  }
483
486
 
487
+ _inferFileContext(p) {
488
+ if (!p.endsWith('.jsonl')) return null;
489
+
490
+ // Subagent file: infer sessionID and agentID from path structure
491
+ if (p.includes('/subagents/')) {
492
+ const subagentsDir = path.dirname(p);
493
+ const sessionDir = path.dirname(subagentsDir);
494
+ const sessionID = path.basename(sessionDir);
495
+ const agentID = path.basename(p).replace(/^agent-/, '').replace(/\.jsonl$/, '');
496
+ const session = this.sessions.get(sessionID);
497
+ if (!session) return null;
498
+ return { sessionID, agentID };
499
+ }
500
+
501
+ // Main session file: infer sessionID from filename
502
+ const basename = path.basename(p);
503
+ const sessionID = basename.replace(/\.jsonl$/, '');
504
+ const session = this.sessions.get(sessionID);
505
+ if (!session) return null;
506
+ return { sessionID, agentID: '' };
507
+ }
508
+
484
509
  // =========================================================================
485
510
  // New session handlers
486
511
  // =========================================================================
@@ -505,6 +530,12 @@ class Watcher extends EventEmitter {
505
530
  this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
506
531
  }
507
532
 
533
+ // Read initial data from the new session's files
534
+ if (this.useFsnotify) {
535
+ await this._skipToEndOfFiles(session);
536
+ await this._readSessionFiles(session);
537
+ }
538
+
508
539
  // Process any subagent files that arrived before the session was discovered
509
540
  const pending = this.pendingSubagents.get(session.id);
510
541
  if (pending) {
@@ -542,6 +573,13 @@ class Watcher extends EventEmitter {
542
573
 
543
574
  this._addFileWatch(p, sessionID, agentID);
544
575
  this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType });
576
+
577
+ // Read initial data from the new subagent file
578
+ if (this.useFsnotify) {
579
+ const pos = await this._findPositionForLastNLines(p, KeepRecentLines);
580
+ this.filePositions.set(p, pos);
581
+ await this._readFile(p, sessionID, agentID, agentType);
582
+ }
545
583
  }
546
584
 
547
585
  async _handleNewToolResultFile(p) {
@@ -615,6 +653,8 @@ class Watcher extends EventEmitter {
615
653
 
616
654
  if (this.useFsnotify) {
617
655
  this._registerSessionWatches(c.session);
656
+ await this._skipToEndOfFiles(c.session);
657
+ await this._readSessionFiles(c.session);
618
658
  }
619
659
 
620
660
  this.emit('broadcast', 'newSession', { sessionID: c.session.id, projectPath: c.session.projectPath });
@@ -703,7 +743,20 @@ class Watcher extends EventEmitter {
703
743
 
704
744
  async _populateToolIndex(session) {
705
745
  if (session.toolIndexPopulated) return;
706
- session.toolIndexPopulated = true;
746
+ // If another call is already populating, wait for it
747
+ if (session._toolIndexPromise) {
748
+ await session._toolIndexPromise;
749
+ return;
750
+ }
751
+ session._toolIndexPromise = this._doPopulateToolIndex(session);
752
+ try {
753
+ await session._toolIndexPromise;
754
+ } finally {
755
+ session._toolIndexPromise = null;
756
+ }
757
+ }
758
+
759
+ async _doPopulateToolIndex(session) {
707
760
  const files = [
708
761
  { path: session.mainFile, agentID: '' },
709
762
  ...Object.entries(session.subagents).map(([id, p]) => ({ path: p, agentID: id })),
@@ -751,6 +804,7 @@ class Watcher extends EventEmitter {
751
804
  if (this.debug) console.error('[watcher] _populateToolIndex error reading', filePath + ':', err.message);
752
805
  }
753
806
  }
807
+ session.toolIndexPopulated = true;
754
808
  }
755
809
 
756
810
  // =========================================================================
@@ -771,7 +825,7 @@ class Watcher extends EventEmitter {
771
825
 
772
826
  this._cleanupTimer = setInterval(() => {
773
827
  if (!this._running) return;
774
- this._cleanupFilePositions();
828
+ this._cleanupFilePositions().catch(() => {});
775
829
  }, CleanupInterval);
776
830
  }
777
831
 
@@ -890,100 +944,122 @@ class Watcher extends EventEmitter {
890
944
  await prev;
891
945
 
892
946
  let handle;
893
- let newPos = this.filePositions.get(filePath) || 0;
947
+ const pos = this.filePositions.get(filePath) || 0;
948
+ let newPos = pos;
894
949
  try {
895
950
  handle = await fsp.open(filePath, 'r');
896
- const pos = this.filePositions.get(filePath) || 0;
897
951
  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; }
952
+ if (pos > stats.size) {
953
+ // File was truncated — reset position to 0 so we re-read from the start
954
+ this.filePositions.set(filePath, 0);
955
+ await handle.close(); handle = null; return;
956
+ }
957
+ if (pos === stats.size) { await handle.close(); handle = null; return; }
904
958
 
905
959
  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;
960
+ // Read in chunks to avoid large buffer allocations for big file deltas
961
+ let carryOver = ''; // incomplete trailing line from previous chunk
962
+ let carryOverBytes = 0; // byte length of carryOver (to avoid re-reading it)
963
+ const buf = Buffer.alloc(MaxReadChunk);
964
+
965
+ while (true) {
966
+ const currentStats = await handle.stat();
967
+ const readFrom = newPos + carryOverBytes;
968
+ if (readFrom >= currentStats.size) break;
969
+
970
+ const readLen = Math.min(MaxReadChunk, currentStats.size - readFrom);
971
+ const { bytesRead } = await handle.read(buf, 0, readLen, readFrom);
972
+ if (bytesRead === 0) break;
973
+
974
+ const chunk = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
975
+ const combined = carryOver + chunk;
976
+
977
+ // Detect CRLF from first newline in the combined text
978
+ let nlLen = 1;
979
+ const firstNl = combined.indexOf('\n');
980
+ if (firstNl > 0 && combined[firstNl - 1] === '\r') nlLen = 2;
981
+
982
+ const rawLines = combined.split('\n');
983
+
984
+ // If the chunk doesn't end with a newline, the last segment is incomplete.
985
+ // Save it as carryOver for the next chunk; don't process it yet.
986
+ if (!chunk.endsWith('\n')) {
987
+ carryOver = rawLines.pop();
988
+ carryOverBytes = Buffer.byteLength(carryOver, 'utf-8');
989
+ } else {
990
+ // chunk ends with \n — split produces a trailing empty string; clear carryOver
991
+ carryOver = '';
992
+ carryOverBytes = 0;
993
+ }
920
994
 
921
- for (let i = 0; i < rawLines.length; i++) {
922
- const isLast = i === rawLines.length - 1;
923
- let rawLine = rawLines[i];
995
+ let chunkBytes = 0;
924
996
 
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
- }
997
+ for (let i = 0; i < rawLines.length; i++) {
998
+ let rawLine = rawLines[i];
930
999
 
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;
936
- }
1000
+ // Trailing empty after final newline just advance position
1001
+ if (rawLine === '' && i === rawLines.length - 1 && combined.endsWith('\n')) {
1002
+ chunkBytes += nlLen;
1003
+ continue;
1004
+ }
937
1005
 
938
- // Strip trailing \r for clean line processing (Windows CRLF)
939
- if (crlf && rawLine.endsWith('\r')) {
940
- rawLine = rawLine.slice(0, -1);
941
- }
1006
+ // Strip trailing \r for CRLF
1007
+ if (nlLen === 2 && rawLine.endsWith('\r')) {
1008
+ rawLine = rawLine.slice(0, -1);
1009
+ }
942
1010
 
943
- newPos += Buffer.byteLength(rawLine, 'utf-8');
944
- newPos += nlLen;
1011
+ chunkBytes += Buffer.byteLength(rawLine, 'utf-8') + nlLen;
945
1012
 
946
- if (!rawLine.trim()) continue;
1013
+ if (!rawLine.trim()) continue;
947
1014
 
948
- const items = parseLine(rawLine);
949
- for (const item of items) {
950
- item.sessionID = sessionID;
1015
+ const items = parseLine(rawLine);
1016
+ for (const item of items) {
1017
+ item.sessionID = sessionID;
951
1018
 
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);
958
- } else {
959
- item.agentName = agentType;
1019
+ if (agentID) {
1020
+ if (!item.agentID) item.agentID = agentID;
1021
+ if (agentType) {
1022
+ const idx = agentType.lastIndexOf(':');
1023
+ if (idx >= 0 && idx < agentType.length - 1) {
1024
+ item.agentName = agentType.slice(idx + 1);
1025
+ } else {
1026
+ item.agentName = agentType;
1027
+ }
1028
+ } else if (!item.agentName || item.agentName.startsWith('Agent-')) {
1029
+ item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
960
1030
  }
961
- } else if (!item.agentName || item.agentName.startsWith('Agent-')) {
962
- item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
963
1031
  }
964
- }
965
1032
 
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 });
1033
+ if (item.toolID) {
1034
+ const session = this.sessions.get(sessionID);
1035
+ if (session) {
1036
+ const existing = session.toolIndex.get(item.toolID);
1037
+ if (item.type === 'tool_output') {
1038
+ if (existing) {
1039
+ existing.hasResult = true;
1040
+ } else {
1041
+ session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
1042
+ }
1043
+ } else if (item.type === 'tool_input' && !existing) {
1044
+ session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
975
1045
  }
976
- } else if (item.type === 'tool_input' && !existing) {
977
- session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
978
1046
  }
979
1047
  }
980
- }
981
1048
 
982
- this.emit('item', item);
1049
+ this.emit('item', item);
1050
+ }
983
1051
  }
1052
+
1053
+ newPos += chunkBytes;
1054
+ this.filePositions.set(filePath, Math.min(newPos, currentStats.size));
984
1055
  }
985
1056
 
986
- this.filePositions.set(filePath, Math.min(newPos, stats.size));
1057
+ // Process any remaining carryOver as a final incomplete line (no trailing \n).
1058
+ // This line may become complete on the next read, so we don't parse it yet.
1059
+ // But we must NOT advance position past it — next read starts from newPos.
1060
+
1061
+ await handle.close();
1062
+ handle = null;
987
1063
  } catch (err) {
988
1064
  if (newPos !== undefined) {
989
1065
  this.filePositions.set(filePath, newPos);
@@ -1028,9 +1104,9 @@ class Watcher extends EventEmitter {
1028
1104
  }
1029
1105
  }
1030
1106
 
1031
- _cleanupFilePositions() {
1107
+ async _cleanupFilePositions() {
1032
1108
  for (const p of this.filePositions.keys()) {
1033
- try { fs.accessSync(p); } catch {
1109
+ try { await fsp.access(p); } catch {
1034
1110
  this.filePositions.delete(p);
1035
1111
  this.fileContexts.delete(p);
1036
1112
  }
@@ -1040,7 +1116,7 @@ class Watcher extends EventEmitter {
1040
1116
  const now = Date.now();
1041
1117
  for (const [sessionID, session] of this.sessions) {
1042
1118
  let stats;
1043
- try { stats = fs.statSync(session.mainFile); } catch {
1119
+ try { stats = await fsp.stat(session.mainFile); } catch {
1044
1120
  this.removeSession(sessionID);
1045
1121
  this.emit('broadcast', 'sessionRemoved', { sessionID });
1046
1122
  continue;
@@ -1139,25 +1215,29 @@ async function _listSessionsFiltered(limit, activeWithin) {
1139
1215
  const sessions = [];
1140
1216
  const now = Date.now();
1141
1217
 
1218
+ const candidates = [];
1142
1219
  try {
1143
1220
  await _walkDirStatic(claudeDir, (filePath, stats) => {
1144
1221
  if (!isMainSessionFile(filePath, stats)) return;
1145
1222
  if (activeWithin > 0 && (now - stats.mtimeMs) > activeWithin) return;
1146
-
1147
- const basename = path.basename(filePath);
1148
- const projectDir = path.basename(path.dirname(filePath));
1149
- const projectPath = resolveProjectPath(projectDir);
1150
-
1151
- sessions.push({
1152
- id: basename.replace(/\.jsonl$/, ''),
1153
- path: filePath,
1154
- projectPath,
1155
- modified: stats.mtime,
1156
- isActive: (now - stats.mtimeMs) < RecentActivityThreshold,
1157
- });
1223
+ candidates.push({ filePath, stats });
1158
1224
  });
1159
1225
  } catch {}
1160
1226
 
1227
+ for (const c of candidates) {
1228
+ const basename = path.basename(c.filePath);
1229
+ const projectDir = path.basename(path.dirname(c.filePath));
1230
+ const projectPath = await resolveProjectPath(projectDir);
1231
+
1232
+ sessions.push({
1233
+ id: basename.replace(/\.jsonl$/, ''),
1234
+ path: c.filePath,
1235
+ projectPath,
1236
+ modified: c.stats.mtime,
1237
+ isActive: (now - c.stats.mtimeMs) < RecentActivityThreshold,
1238
+ });
1239
+ }
1240
+
1161
1241
  sessions.sort((a, b) => b.modified - a.modified);
1162
1242
  if (limit > 0 && sessions.length > limit) sessions.length = limit;
1163
1243