claude-code-watch 0.0.7 → 0.0.9

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/README.md CHANGED
@@ -12,7 +12,7 @@ Claude Code writes detailed JSONL logs under `~/.claude/projects/` as it works
12
12
  - **Multi-session** — watch all active Claude Code sessions simultaneously in a tree view
13
13
  - **Subagent tracking** — see subagent activity nested under their parent session
14
14
  - **Token & cost visibility** — tracks input/output/cache tokens per agent, with context window utilization
15
- - **Filter controls** — toggle thinking, tool input, tool output, and text visibility independently
15
+ - **Filter controls** — toggle thinking, tool input, tool output, hook output, and text visibility independently
16
16
  - **Auto-discovery** — automatically picks up new sessions as they start (toggleable)
17
17
 
18
18
  ## Quick Start
@@ -51,7 +51,7 @@ OPTIONS:
51
51
  -n Start from newest (skip history, live only)
52
52
  -l [N] List recent sessions (default 10) and exit
53
53
  -a [N] List active sessions (default all) and exit
54
- -w <dur> Active window duration (default 5m, e.g. 30s, 2m, 10m)
54
+ -w <dur> Active window duration (default 30m, e.g. 30s, 2m, 10m)
55
55
  -m <N> Max sessions to show in tree (default 0=unlimited)
56
56
  -c <dur> Auto-collapse sessions inactive for this duration (e.g. 2m)
57
57
  -D Debug: show raw type:subtype for every JSONL line we'd drop
package/README.zh-CN.md CHANGED
@@ -22,7 +22,7 @@ Claude Code 在运行时会将详细的 JSONL 日志写入 `~/.claude/projects/`
22
22
  - **多会话监视** — 同时查看所有活跃的 Claude Code 会话
23
23
  - **子代理追踪** — 在父会话下嵌套显示子代理活动
24
24
  - **Token/成本追踪** — 每个代理的输入/输出/缓存 token 及上下文窗口利用率
25
- - **过滤控制** — 独立切换 thinking、工具输入/输出、文本的可见性
25
+ - **过滤控制** — 独立切换 thinking、工具输入/输出、hook 输出、文本的可见性
26
26
  - **自动发现** — 新会话启动时自动纳入监控
27
27
 
28
28
  ## 致谢
@@ -150,7 +150,7 @@ async function main() {
150
150
  sessionID: '',
151
151
  skipHistory: false,
152
152
  pollMs: 500,
153
- activeWindow: 5 * 60 * 1000,
153
+ activeWindow: 30 * 60 * 1000,
154
154
  maxSessions: 0,
155
155
  collapseAfter: 0,
156
156
  debugAll: false,
@@ -190,7 +190,7 @@ async function main() {
190
190
  break;
191
191
  case '-w':
192
192
  try {
193
- options.activeWindow = parseDuration(args[++i] || '5m');
193
+ options.activeWindow = parseDuration(args[++i] || '30m');
194
194
  } catch {
195
195
  options.activeWindow = 5 * 60 * 1000;
196
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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
@@ -264,6 +264,7 @@ body {
264
264
  <button class="btn on" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
265
265
  <button class="btn on" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
266
266
  <button class="btn on" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
267
+ <button class="btn on" id="btn-hook" onclick="toggleHook()" data-tooltip="Toggle hook output">🪝 Hook</button>
267
268
  <span class="sep">│</span>
268
269
  <button class="btn on" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
269
270
  <button class="btn" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
@@ -337,49 +338,29 @@ let lastMsgTime = 0;
337
338
  let staleCheckTimer = null;
338
339
 
339
340
  let sessions = [];
341
+ let sessionsMap = new Map(); // id -> session, for O(1) lookups
340
342
  let treeNodes = [];
341
343
  let treeCursor = 0;
342
344
  let streamItems = [];
343
- const seenToolIDsKeys = [];
344
- const seenToolIDsSet = new Set();
345
- const seenToolIDsMax = 5000;
346
-
347
- function seenToolIDsHas(key) {
348
- return seenToolIDsSet.has(key);
349
- }
350
- function seenToolIDsAdd(key) {
351
- seenToolIDsSet.add(key);
352
- seenToolIDsKeys.push(key);
353
- if (seenToolIDsKeys.length > seenToolIDsMax) {
354
- const evictCount = seenToolIDsKeys.length >> 1;
355
- for (let i = 0; i < evictCount; i++) {
356
- seenToolIDsSet.delete(seenToolIDsKeys[i]);
357
- }
358
- seenToolIDsKeys.splice(0, evictCount);
359
- }
360
- }
361
- const toolNameMapMax = 2000;
362
- let toolNameMap = new Map(); // toolID -> toolName
363
- let toolNameMapKeys = [];
364
-
365
- function toolNameMapSet(toolID, toolName) {
366
- if (toolNameMap.has(toolID)) return;
367
- toolNameMap.set(toolID, toolName);
368
- toolNameMapKeys.push(toolID);
369
- if (toolNameMapKeys.length > toolNameMapMax) {
370
- const evictCount = toolNameMapKeys.length >> 1;
371
- for (let i = 0; i < evictCount; i++) {
372
- toolNameMap.delete(toolNameMapKeys[i]);
373
- }
374
- toolNameMapKeys.splice(0, evictCount);
375
- }
376
- }
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);
377
357
  let filters = new Map();
378
358
 
379
359
  let showThinking = true;
380
360
  let showToolInput = true;
381
361
  let showToolOutput = true;
382
362
  let showText = true;
363
+ let showHook = true;
383
364
  let autoDiscovery = true;
384
365
 
385
366
  let renderPending = false;
@@ -401,10 +382,11 @@ let collapseAfter = 0;
401
382
  let collapseTimer = null;
402
383
  let activeRefreshTimer = null;
403
384
 
404
- const MAX_ITEMS = 1000;
385
+ const MAX_ITEMS = 3000;
405
386
  const MAX_LINES = 50;
406
387
  let renderedItemCount = 0;
407
388
  let needsFullRender = true;
389
+ visibleDirty = true;
408
390
 
409
391
  // ══════════════════════════════════════════════════════════════════════════════
410
392
  // Markdown renderer (marked + highlight.js)
@@ -523,7 +505,7 @@ function sendCmd(action, extra = {}) {
523
505
  function handleSnapshot(payload) {
524
506
  autoDiscovery = payload.autoDiscovery;
525
507
  for (const s of (payload.sessions || [])) {
526
- let session = sessions.find(x => x.id === s.id);
508
+ let session = sessionsMap.get(s.id);
527
509
  if (!session) {
528
510
  session = {
529
511
  id: s.id, projectPath: s.projectPath, title: '',
@@ -532,6 +514,7 @@ function handleSnapshot(payload) {
532
514
  lastActivity: Date.now(),
533
515
  };
534
516
  sessions.push(session);
517
+ sessionsMap.set(session.id, session);
535
518
  session.agents.push({ id: '', name: 'Main', type: 'main' });
536
519
  }
537
520
  session.lastActivity = Date.now();
@@ -552,25 +535,31 @@ function handleSnapshot(payload) {
552
535
  }
553
536
  updateFilters();
554
537
  rebuildNodes();
538
+ needsFullRender = true;
539
+ visibleDirty = true;
555
540
  scheduleRender();
556
541
  }
557
542
 
558
543
  function handleNewSession(payload) {
559
- if (sessions.find(s => s.id === payload.sessionID)) return;
560
- sessions.push({
544
+ if (sessionsMap.has(payload.sessionID)) return;
545
+ const session = {
561
546
  id: payload.sessionID, projectPath: payload.projectPath,
562
547
  title: '', folder: folderName(payload.projectPath), model: '',
563
548
  agents: [{ id: '', name: 'Main', type: 'main' }],
564
549
  tasks: [], collapsed: false, pinned: false,
565
550
  lastActivity: Date.now(),
566
- });
551
+ };
552
+ sessions.push(session);
553
+ sessionsMap.set(session.id, session);
567
554
  updateFilters();
568
555
  rebuildNodes();
556
+ needsFullRender = true;
557
+ visibleDirty = true;
569
558
  scheduleRender();
570
559
  }
571
560
 
572
561
  function handleNewAgent(payload) {
573
- const s = sessions.find(s => s.id === payload.sessionID);
562
+ const s = sessionsMap.get(payload.sessionID);
574
563
  if (!s || s.agents.find(a => a.id === payload.agentID)) return;
575
564
  s.agents.push({
576
565
  id: payload.agentID,
@@ -579,11 +568,13 @@ function handleNewAgent(payload) {
579
568
  });
580
569
  updateFilters();
581
570
  rebuildNodes();
571
+ needsFullRender = true;
572
+ visibleDirty = true;
582
573
  scheduleRender();
583
574
  }
584
575
 
585
576
  function handleNewBgTask(payload) {
586
- const s = sessions.find(s => s.id === payload.sessionID);
577
+ const s = sessionsMap.get(payload.sessionID);
587
578
  if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
588
579
  s.tasks.push({
589
580
  id: payload.toolID, parentAgentID: payload.parentAgentID,
@@ -596,9 +587,14 @@ function handleNewBgTask(payload) {
596
587
 
597
588
  function handleSessionRemoved(payload) {
598
589
  const idx = sessions.findIndex(s => s.id === payload.sessionID);
599
- if (idx >= 0) sessions.splice(idx, 1);
590
+ if (idx >= 0) {
591
+ const session = sessions.splice(idx, 1)[0];
592
+ sessions.push(session);
593
+ }
600
594
  updateFilters();
601
595
  rebuildNodes();
596
+ needsFullRender = true;
597
+ visibleDirty = true;
602
598
  scheduleRender();
603
599
  }
604
600
 
@@ -608,13 +604,13 @@ function handleSessionRemoved(payload) {
608
604
 
609
605
  function handleItem(item) {
610
606
  if (item.type === 'session_title') {
611
- const s = sessions.find(s => s.id === item.sessionID);
607
+ const s = sessionsMap.get(item.sessionID);
612
608
  if (s) { s.title = item.content.slice(0, 30); }
613
609
  scheduleRender();
614
610
  return;
615
611
  }
616
612
  // Update activity
617
- const s = sessions.find(s => s.id === item.sessionID);
613
+ const s = sessionsMap.get(item.sessionID);
618
614
  if (s) s.lastActivity = Date.now();
619
615
  pushItem(item);
620
616
  scheduleRender();
@@ -623,7 +619,7 @@ function handleItem(item) {
623
619
  function handleItemBatch(items) {
624
620
  for (const item of items) {
625
621
  if (item.type === 'session_title') {
626
- const s = sessions.find(s => s.id === item.sessionID);
622
+ const s = sessionsMap.get(item.sessionID);
627
623
  if (s) { s.title = item.content.slice(0, 30); }
628
624
  continue;
629
625
  }
@@ -637,24 +633,28 @@ function pushItem(item) {
637
633
  // to avoid divergence between frontend accumulation and server tracking
638
634
 
639
635
  if (item.model) {
640
- const s = sessions.find(s => s.id === item.sessionID);
636
+ const s = sessionsMap.get(item.sessionID);
641
637
  if (s) s.model = item.model;
642
638
  }
643
639
 
644
640
  if (item.type === 'tool_input' && item.toolID && item.toolName) {
645
- toolNameMapSet(item.toolID, item.toolName);
641
+ toolNameMap.set(item.toolID, item.toolName);
646
642
  }
647
643
 
648
644
  if (item.toolID) {
649
645
  const key = `${item.toolID}:${item.type}`;
650
- if (seenToolIDsHas(key)) return;
651
- seenToolIDsAdd(key);
646
+ if (seenToolIDs.has(key)) return;
647
+ seenToolIDs.set(key, true);
652
648
  }
653
649
 
654
650
  streamItems.push(item);
655
651
  if (streamItems.length > MAX_ITEMS) {
656
652
  streamItems = streamItems.slice(-MAX_ITEMS);
657
- 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);
658
658
  }
659
659
  }
660
660
 
@@ -665,6 +665,7 @@ function isItemVisible(item) {
665
665
  case 'tool_input': return showToolInput;
666
666
  case 'tool_output': return showToolOutput;
667
667
  case 'text': return showText;
668
+ case 'hook_output': return showHook;
668
669
  default: return true;
669
670
  }
670
671
  }
@@ -820,7 +821,14 @@ function isSessionActive(session) {
820
821
  // ══════════════════════════════════════════════════════════════════════════════
821
822
 
822
823
  function renderStream() {
823
- 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;
831
+ const wasAutoScroll = autoScroll;
824
832
 
825
833
  if (needsFullRender || renderedItemCount > visible.length) {
826
834
  // Full rebuild: filter changed, items trimmed, or initial render
@@ -844,10 +852,9 @@ function renderStream() {
844
852
  streamEl.innerHTML = html;
845
853
  renderedItemCount = visible.length;
846
854
  needsFullRender = false;
847
- if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
855
+ if (wasAutoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
848
856
  } else {
849
857
  // Incremental append: only add new items since last render
850
- const wasAtBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
851
858
  for (let i = renderedItemCount; i < visible.length; i++) {
852
859
  for (const l of renderItem(visible[i])) {
853
860
  const div = document.createElement('div');
@@ -857,7 +864,7 @@ function renderStream() {
857
864
  }
858
865
  }
859
866
  renderedItemCount = visible.length;
860
- if (autoScroll && wasAtBottom) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
867
+ if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
861
868
  }
862
869
 
863
870
  const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
@@ -951,6 +958,7 @@ function refreshButtons() {
951
958
  document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
952
959
  document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
953
960
  document.getElementById('btn-text').classList.toggle('on', showText);
961
+ document.getElementById('btn-hook').classList.toggle('on', showHook);
954
962
  document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
955
963
  document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
956
964
  document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
@@ -1104,8 +1112,12 @@ function removeSelectedSession() {
1104
1112
  if (node.type === 'session') sid = node.id;
1105
1113
  else sid = node.sessionID;
1106
1114
  if (!sid) return;
1107
- if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1108
- sessions = sessions.filter(s => s.id !== sid);
1115
+ if (!confirm(`Move session ${sid.slice(0, 12)}... to bottom?`)) return;
1116
+ const idx = sessions.findIndex(s => s.id === sid);
1117
+ if (idx >= 0) {
1118
+ const session = sessions.splice(idx, 1)[0];
1119
+ sessions.push(session);
1120
+ }
1109
1121
  sendCmd('removeSession', { sessionID: sid });
1110
1122
  updateFilters();
1111
1123
  rebuildNodes();
@@ -1116,17 +1128,23 @@ function removeSelectedSession() {
1116
1128
  // Toggles
1117
1129
  // ══════════════════════════════════════════════════════════════════════════════
1118
1130
 
1119
- function toggleThinking() { showThinking = !showThinking; needsFullRender = true; renderStream(); refreshButtons(); }
1120
- function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true; renderStream(); refreshButtons(); }
1121
- function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true; renderStream(); refreshButtons(); }
1122
- function toggleText() { showText = !showText; 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(); }
1123
1141
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1124
1142
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1125
1143
  function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
1126
1144
 
1127
1145
  function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
1128
1146
  function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
1129
- function scrollDown() { streamEl.scrollTop += 80; renderAll(); }
1147
+ function scrollDown() { streamEl.scrollTop += 80; if (autoScroll) autoScroll = false; renderAll(); }
1130
1148
  function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
1131
1149
 
1132
1150
  // ══════════════════════════════════════════════════════════════════════════════
@@ -1260,6 +1278,7 @@ function fmtTok(n) {
1260
1278
 
1261
1279
  function renderAll() {
1262
1280
  needsFullRender = true;
1281
+ visibleDirty = true;
1263
1282
  renderTree();
1264
1283
  renderStream();
1265
1284
  refreshButtons();
@@ -1270,7 +1289,9 @@ function scheduleRender() {
1270
1289
  renderPending = true;
1271
1290
  requestAnimationFrame(() => {
1272
1291
  renderPending = false;
1273
- renderAll();
1292
+ renderTree();
1293
+ renderStream();
1294
+ refreshButtons();
1274
1295
  });
1275
1296
  }
1276
1297
  }
@@ -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
 
@@ -21,7 +21,7 @@ var MIME = {
21
21
  };
22
22
 
23
23
  var MAX_ITEM_BUFFER = 2000;
24
- var CONTEXT_STALE_MS = 30 * 60 * 1000; // 30 minutes
24
+ var CONTEXT_STALE_MS = 60 * 60 * 1000; // 60 minutes
25
25
 
26
26
  class DashboardServer {
27
27
  constructor(options = {}) {
@@ -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;
@@ -52,7 +55,7 @@ class DashboardServer {
52
55
  ctx = { inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, model: '', contextWindow: 200000, lastActivity: Date.now() };
53
56
  this.contextMap.set(key, ctx);
54
57
  }
55
- if (item.inputTokens) ctx.inputTokens += item.inputTokens;
58
+ if (item.inputTokens) ctx.inputTokens = Math.max(ctx.inputTokens, item.inputTokens);
56
59
  if (item.outputTokens) ctx.outputTokens += item.outputTokens;
57
60
  if (item.cacheCreationTokens) ctx.cacheCreation += item.cacheCreationTokens;
58
61
  if (item.cacheReadTokens) ctx.cacheRead += item.cacheReadTokens;
@@ -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;
@@ -353,7 +389,7 @@ class DashboardServer {
353
389
  }
354
390
  const skipHistory = options.skipHistory || false;
355
391
  const pollMs = options.pollMs || 500;
356
- const activeWindow = options.activeWindow || 5 * 60 * 1000;
392
+ const activeWindow = options.activeWindow || 100 * 60 * 1000;
357
393
  const maxSessions = options.maxSessions || 0;
358
394
  const openBrowser = options.openBrowser !== false;
359
395
 
@@ -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)
@@ -400,40 +436,47 @@ class DashboardServer {
400
436
  await w.init();
401
437
  if (skipHistory) w.setSkipHistory(true);
402
438
  await w.start();
403
-
404
- // Open browser AFTER sessions are discovered, so new clients get a full snapshot
405
- if (openBrowser) {
406
- const url = `http://localhost:${this.port}`;
407
- const platform = process.platform;
408
- if (platform === 'darwin') {
409
- cp.spawn('open', [url]);
410
- } else if (platform === 'win32') {
411
- cp.spawn('cmd', ['/c', 'start', '', url]);
412
- } else {
413
- cp.spawn('xdg-open', [url]);
414
- }
415
- }
416
439
  } catch (err) {
417
440
  console.error('Watcher init error:', err.message);
418
441
  process.exit(1);
419
442
  }
420
443
 
421
444
  this._contextCleanupTimer = setInterval(() => this.cleanupContextMap(), CONTEXT_STALE_MS);
445
+ this._heartbeatTimer = setInterval(() => this.broadcast('heartbeat', null), 30000);
422
446
 
423
- this.server.listen(this.port, this.host, () => {
424
- const url = `http://localhost:${this.port}`;
425
- console.log(`\n claude-watch web server`);
426
- console.log(` ───────────────────────────`);
427
- console.log(` Local: ${url}`);
428
- console.log(` Network: http://${this.host}:${this.port}`);
429
- console.log(` Quit: Ctrl+C\n`);
447
+ // Start listening and wait for server to be ready before opening browser
448
+ await new Promise((resolve) => {
449
+ this.server.listen(this.port, this.host, () => {
450
+ const url = `http://localhost:${this.port}`;
451
+ console.log(`\n claude-watch web server`);
452
+ console.log(` ───────────────────────────`);
453
+ console.log(` Local: ${url}`);
454
+ console.log(` Network: http://${this.host}:${this.port}`);
455
+ console.log(` Quit: Ctrl+C\n`);
456
+ resolve();
457
+ });
430
458
  });
431
459
 
460
+ // Open browser AFTER server is confirmed listening and watcher is ready
461
+ if (openBrowser) {
462
+ const url = `http://localhost:${this.port}`;
463
+ const platform = process.platform;
464
+ if (platform === 'darwin') {
465
+ cp.spawn('open', [url]);
466
+ } else if (platform === 'win32') {
467
+ cp.spawn('cmd', ['/c', 'start', '', url]);
468
+ } else {
469
+ cp.spawn('xdg-open', [url]);
470
+ }
471
+ }
472
+
432
473
  return { server: this.server, watcher: w };
433
474
  }
434
475
 
435
476
  stop() {
436
477
  if (this._contextCleanupTimer) clearInterval(this._contextCleanupTimer);
478
+ if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
479
+ if (this._flushTimer) clearTimeout(this._flushTimer);
437
480
  if (this.wss) this.wss.close();
438
481
  if (this.server) this.server.close();
439
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
 
@@ -111,7 +113,7 @@ class Watcher extends EventEmitter {
111
113
  super();
112
114
  this.claudeDir = getClaudeProjectsDir();
113
115
  this.pollInterval = pollInterval || 500;
114
- this.activeWindow = activeWindow || 5 * 60 * 1000;
116
+ this.activeWindow = activeWindow || 100 * 60 * 1000;
115
117
  this.maxSessions = maxSessions || 0;
116
118
  this.sessions = new Map();
117
119
  this.filePositions = new Map();
@@ -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')) {
@@ -703,7 +698,20 @@ class Watcher extends EventEmitter {
703
698
 
704
699
  async _populateToolIndex(session) {
705
700
  if (session.toolIndexPopulated) return;
706
- session.toolIndexPopulated = true;
701
+ // If another call is already populating, wait for it
702
+ if (session._toolIndexPromise) {
703
+ await session._toolIndexPromise;
704
+ return;
705
+ }
706
+ session._toolIndexPromise = this._doPopulateToolIndex(session);
707
+ try {
708
+ await session._toolIndexPromise;
709
+ } finally {
710
+ session._toolIndexPromise = null;
711
+ }
712
+ }
713
+
714
+ async _doPopulateToolIndex(session) {
707
715
  const files = [
708
716
  { path: session.mainFile, agentID: '' },
709
717
  ...Object.entries(session.subagents).map(([id, p]) => ({ path: p, agentID: id })),
@@ -751,6 +759,7 @@ class Watcher extends EventEmitter {
751
759
  if (this.debug) console.error('[watcher] _populateToolIndex error reading', filePath + ':', err.message);
752
760
  }
753
761
  }
762
+ session.toolIndexPopulated = true;
754
763
  }
755
764
 
756
765
  // =========================================================================
@@ -771,7 +780,7 @@ class Watcher extends EventEmitter {
771
780
 
772
781
  this._cleanupTimer = setInterval(() => {
773
782
  if (!this._running) return;
774
- this._cleanupFilePositions();
783
+ this._cleanupFilePositions().catch(() => {});
775
784
  }, CleanupInterval);
776
785
  }
777
786
 
@@ -890,100 +899,119 @@ class Watcher extends EventEmitter {
890
899
  await prev;
891
900
 
892
901
  let handle;
893
- let newPos = this.filePositions.get(filePath) || 0;
902
+ const pos = this.filePositions.get(filePath) || 0;
903
+ let newPos = pos;
894
904
  try {
895
905
  handle = await fsp.open(filePath, 'r');
896
- const pos = this.filePositions.get(filePath) || 0;
897
906
  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; }
907
+ if (pos > stats.size) {
908
+ // File was truncated — reset position to 0 so we re-read from the start
909
+ this.filePositions.set(filePath, 0);
910
+ await handle.close(); handle = null; return;
911
+ }
912
+ if (pos === stats.size) { await handle.close(); handle = null; return; }
904
913
 
905
914
  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;
915
+ // Read in chunks to avoid large buffer allocations for big file deltas
916
+ let carryOver = ''; // incomplete trailing line from previous chunk
917
+ const buf = Buffer.alloc(MaxReadChunk);
918
+
919
+ while (true) {
920
+ const currentStats = await handle.stat();
921
+ const currentPos = this.filePositions.get(filePath) || 0;
922
+ if (currentPos >= currentStats.size) break;
923
+
924
+ const readLen = Math.min(MaxReadChunk, currentStats.size - currentPos);
925
+ const { bytesRead } = await handle.read(buf, 0, readLen, currentPos);
926
+ if (bytesRead === 0) break;
927
+
928
+ const chunk = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
929
+ const combined = carryOver + chunk;
930
+
931
+ // Detect CRLF from first newline in the combined text
932
+ let nlLen = 1;
933
+ const firstNl = combined.indexOf('\n');
934
+ if (firstNl > 0 && combined[firstNl - 1] === '\r') nlLen = 2;
935
+
936
+ const rawLines = combined.split('\n');
937
+
938
+ // If the chunk doesn't end with a newline, the last segment is incomplete.
939
+ // Save it as carryOver for the next chunk; don't process it yet.
940
+ if (!chunk.endsWith('\n')) {
941
+ carryOver = rawLines.pop();
942
+ } else {
943
+ // chunk ends with \n — split produces a trailing empty string; clear carryOver
944
+ carryOver = '';
945
+ }
913
946
 
914
- let currentSize;
915
- try { currentSize = (await handle.stat()).size; } catch { currentSize = stats.size; }
916
- const fileGrew = currentSize > pos + bytesRead;
947
+ let chunkBytes = 0;
917
948
 
918
- await handle.close();
919
- handle = null;
949
+ for (let i = 0; i < rawLines.length; i++) {
950
+ let rawLine = rawLines[i];
920
951
 
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
- }
930
-
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
- }
952
+ // Trailing empty after final newline just advance position
953
+ if (rawLine === '' && i === rawLines.length - 1 && combined.endsWith('\n')) {
954
+ chunkBytes += nlLen;
955
+ continue;
956
+ }
937
957
 
938
- // Strip trailing \r for clean line processing (Windows CRLF)
939
- if (crlf && rawLine.endsWith('\r')) {
940
- rawLine = rawLine.slice(0, -1);
941
- }
958
+ // Strip trailing \r for CRLF
959
+ if (nlLen === 2 && rawLine.endsWith('\r')) {
960
+ rawLine = rawLine.slice(0, -1);
961
+ }
942
962
 
943
- newPos += Buffer.byteLength(rawLine, 'utf-8');
944
- newPos += nlLen;
963
+ chunkBytes += Buffer.byteLength(rawLine, 'utf-8') + nlLen;
945
964
 
946
- if (!rawLine.trim()) continue;
965
+ if (!rawLine.trim()) continue;
947
966
 
948
- const items = parseLine(rawLine);
949
- for (const item of items) {
950
- item.sessionID = sessionID;
967
+ const items = parseLine(rawLine);
968
+ for (const item of items) {
969
+ item.sessionID = sessionID;
951
970
 
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;
971
+ if (agentID) {
972
+ if (!item.agentID) item.agentID = agentID;
973
+ if (agentType) {
974
+ const idx = agentType.lastIndexOf(':');
975
+ if (idx >= 0 && idx < agentType.length - 1) {
976
+ item.agentName = agentType.slice(idx + 1);
977
+ } else {
978
+ item.agentName = agentType;
979
+ }
980
+ } else if (!item.agentName || item.agentName.startsWith('Agent-')) {
981
+ item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
960
982
  }
961
- } else if (!item.agentName || item.agentName.startsWith('Agent-')) {
962
- item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
963
983
  }
964
- }
965
984
 
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 });
985
+ if (item.toolID) {
986
+ const session = this.sessions.get(sessionID);
987
+ if (session) {
988
+ const existing = session.toolIndex.get(item.toolID);
989
+ if (item.type === 'tool_output') {
990
+ if (existing) {
991
+ existing.hasResult = true;
992
+ } else {
993
+ session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
994
+ }
995
+ } else if (item.type === 'tool_input' && !existing) {
996
+ session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
975
997
  }
976
- } else if (item.type === 'tool_input' && !existing) {
977
- session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
978
998
  }
979
999
  }
980
- }
981
1000
 
982
- this.emit('item', item);
1001
+ this.emit('item', item);
1002
+ }
983
1003
  }
1004
+
1005
+ newPos += chunkBytes;
1006
+ this.filePositions.set(filePath, Math.min(newPos, currentStats.size));
984
1007
  }
985
1008
 
986
- this.filePositions.set(filePath, Math.min(newPos, stats.size));
1009
+ // Process any remaining carryOver as a final incomplete line (no trailing \n).
1010
+ // This line may become complete on the next read, so we don't parse it yet.
1011
+ // But we must NOT advance position past it — next read starts from newPos.
1012
+
1013
+ await handle.close();
1014
+ handle = null;
987
1015
  } catch (err) {
988
1016
  if (newPos !== undefined) {
989
1017
  this.filePositions.set(filePath, newPos);
@@ -1028,9 +1056,9 @@ class Watcher extends EventEmitter {
1028
1056
  }
1029
1057
  }
1030
1058
 
1031
- _cleanupFilePositions() {
1059
+ async _cleanupFilePositions() {
1032
1060
  for (const p of this.filePositions.keys()) {
1033
- try { fs.accessSync(p); } catch {
1061
+ try { await fsp.access(p); } catch {
1034
1062
  this.filePositions.delete(p);
1035
1063
  this.fileContexts.delete(p);
1036
1064
  }
@@ -1040,7 +1068,7 @@ class Watcher extends EventEmitter {
1040
1068
  const now = Date.now();
1041
1069
  for (const [sessionID, session] of this.sessions) {
1042
1070
  let stats;
1043
- try { stats = fs.statSync(session.mainFile); } catch {
1071
+ try { stats = await fsp.stat(session.mainFile); } catch {
1044
1072
  this.removeSession(sessionID);
1045
1073
  this.emit('broadcast', 'sessionRemoved', { sessionID });
1046
1074
  continue;
@@ -1139,25 +1167,29 @@ async function _listSessionsFiltered(limit, activeWithin) {
1139
1167
  const sessions = [];
1140
1168
  const now = Date.now();
1141
1169
 
1170
+ const candidates = [];
1142
1171
  try {
1143
1172
  await _walkDirStatic(claudeDir, (filePath, stats) => {
1144
1173
  if (!isMainSessionFile(filePath, stats)) return;
1145
1174
  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
- });
1175
+ candidates.push({ filePath, stats });
1158
1176
  });
1159
1177
  } catch {}
1160
1178
 
1179
+ for (const c of candidates) {
1180
+ const basename = path.basename(c.filePath);
1181
+ const projectDir = path.basename(path.dirname(c.filePath));
1182
+ const projectPath = await resolveProjectPath(projectDir);
1183
+
1184
+ sessions.push({
1185
+ id: basename.replace(/\.jsonl$/, ''),
1186
+ path: c.filePath,
1187
+ projectPath,
1188
+ modified: c.stats.mtime,
1189
+ isActive: (now - c.stats.mtimeMs) < RecentActivityThreshold,
1190
+ });
1191
+ }
1192
+
1161
1193
  sessions.sort((a, b) => b.modified - a.modified);
1162
1194
  if (limit > 0 && sessions.length > limit) sessions.length = limit;
1163
1195