claude-code-watch 0.0.19 → 0.0.21

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
@@ -9,8 +9,12 @@ Claude Code writes detailed JSONL logs under `~/.claude/projects/` as it works
9
9
  ## Features
10
10
 
11
11
  - **Real-time streaming** — thinking, tool calls, tool results, and text responses appear as they happen
12
- - **Multi-session** — watch all Claude Code sessions from the past 24 hours in a tree view, grouped by date
12
+ - **Multi-session** — watch all Claude Code sessions in a tree view, grouped by date; active sessions stay flat regardless of age
13
13
  - **Subagent tracking** — see subagent activity nested under their parent session
14
+ - **Color-coded session tags** — each session gets a unique colored hash prefix for easy visual distinction
15
+ - **Agent-level activity** — active dots on agent/main nodes (not just sessions) with configurable thresholds
16
+ - **Session hiding** — remove unwanted sessions; hidden state persists for 24h via localStorage
17
+ - **Code block copy** — one-click copy button on every markdown code block
14
18
  - **Token & cost visibility** — tracks input/output/cache tokens per agent, with context window utilization
15
19
  - **Filter controls** — toggle thinking, tool input, tool output, hook output, and text visibility independently
16
20
  - **Auto-discovery** — automatically picks up new sessions as they start (toggleable)
@@ -46,7 +50,7 @@ Shorter alias: `cc-watch` (equivalent to `claude-code-watch`).
46
50
 
47
51
  OPTIONS:
48
52
  -p, --port <port> HTTP port (default: 23000)
49
- -h, --host <host> Bind host (default: 127.0.0.1)
53
+ --host <host> Bind host (default: 127.0.0.1)
50
54
  -s <ID> Watch a specific session by ID
51
55
  -n Start from newest (skip history, live only)
52
56
  -l [N] List recent sessions (default 10) and exit
@@ -57,7 +61,7 @@ OPTIONS:
57
61
  -D Debug: show raw type:subtype for every JSONL line we'd drop
58
62
  --poll <ms> Polling interval in milliseconds (default: 500)
59
63
  -v Show version
60
- --help Show this help
64
+ -h, --help Show this help
61
65
  ```
62
66
 
63
67
  ### Examples
@@ -76,7 +80,7 @@ claude-code-watch -s abc123-def456
76
80
  claude-code-watch -n
77
81
 
78
82
  # Custom port and host
79
- claude-code-watch -p 8080 -h 0.0.0.0
83
+ claude-code-watch -p 8080 --host 0.0.0.0
80
84
 
81
85
  # Limit tree to 5 most recent sessions, auto-collapse after 2m of inactivity
82
86
  claude-code-watch -m 5 -c 2m
package/README.zh-CN.md CHANGED
@@ -19,8 +19,12 @@ Claude Code 在运行时会将详细的 JSONL 日志写入 `~/.claude/projects/`
19
19
  ## 主要功能
20
20
 
21
21
  - **实时流式传输** — 思考过程、工具调用/结果、文本响应实时呈现
22
- - **多会话监视** — 同时查看所有活跃的 Claude Code 会话
22
+ - **多会话监视** — 同时查看所有活跃的 Claude Code 会话,活跃会话不进历史分组
23
23
  - **子代理追踪** — 在父会话下嵌套显示子代理活动
24
+ - **会话彩色标识** — 每个会话显示独特的彩色 hash 前缀,多会话一目了然
25
+ - **代理级活跃指示** — 绿点细化到 agent/main 级别,不只看会话整体
26
+ - **会话隐藏** — 移除不关心的会话,隐藏状态 24h 内持久化
27
+ - **代码块一键复制** — 代码块 header 右侧复制按钮,点击即复制
24
28
  - **Token/成本追踪** — 每个代理的输入/输出/缓存 token 及上下文窗口利用率
25
29
  - **过滤控制** — 独立切换 thinking、工具输入/输出、hook 输出、文本的可见性
26
30
  - **自动发现** — 新会话启动时自动纳入监控
@@ -23,19 +23,19 @@ USAGE:
23
23
 
24
24
  OPTIONS:
25
25
  -p, --port <port> HTTP port (default: 23000)
26
- -h, --host <host> Bind host (default: 127.0.0.1)
26
+ --host <host> Bind host (default: 127.0.0.1)
27
27
  -s <ID> Watch a specific session by ID
28
28
  -n Start from newest (skip history, live only)
29
29
  -l [N] List recent sessions (default 10) and exit
30
30
  -a [N] List active sessions (default all) and exit
31
- -w <dur> Active window duration (default 5m, e.g. 30s, 2m, 10m)
31
+ -w <dur> Active window duration (default 24h, e.g. 30s, 2m, 10m)
32
32
  -m <N> Max sessions to show in tree (default 0=unlimited)
33
33
  -c <dur> Auto-collapse sessions inactive for this duration (e.g. 2m)
34
34
  -D Debug: show raw type:subtype for every JSONL line we'd drop
35
35
  --poll <ms> Polling interval in milliseconds (default: 500)
36
36
  --no-open Do not auto-open browser on start
37
37
  -v Show version
38
- --help Show this help
38
+ -h, --help Show this help
39
39
 
40
40
  ENVIRONMENT:
41
41
  CLAUDE_HOME Override Claude config directory (default: ~/.claude)
@@ -80,13 +80,17 @@ function checkForUpdate() {
80
80
  console.log(`\n New version available: v${latest} (current: v${VERSION})`);
81
81
  console.log(' Updating in background...\n');
82
82
  const child = cp.spawn('npm', ['install', '-g', 'claude-code-watch@latest'], {
83
- stdio: 'ignore',
83
+ stdio: ['ignore', 'ignore', 'pipe'],
84
84
  detached: true,
85
85
  });
86
+ let stderr = '';
87
+ child.stderr.on('data', (d) => { stderr += d; });
86
88
  child.unref();
87
89
  child.on('exit', (code) => {
88
90
  if (code === 0) {
89
91
  console.log(` Updated to v${latest}. Changes take effect on next start.\n`);
92
+ } else {
93
+ console.error(` Update failed (exit code ${code}): ${stderr.trim()}\n`);
90
94
  }
91
95
  });
92
96
  }
@@ -168,7 +172,6 @@ async function main() {
168
172
  options.port = pv;
169
173
  break;
170
174
  }
171
- case '-h':
172
175
  case '--host':
173
176
  if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
174
177
  console.error(`Error: ${arg} requires a host address`);
@@ -219,6 +222,7 @@ async function main() {
219
222
  case '-v':
220
223
  showVersion = true;
221
224
  break;
225
+ case '-h':
222
226
  case '--help':
223
227
  showHelp = true;
224
228
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
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
@@ -170,12 +170,14 @@ body {
170
170
  cursor: pointer; white-space: nowrap; gap: 4px;
171
171
  overflow: hidden;
172
172
  }
173
- .tree-prefix { color: var(--dim); font-size: 12px; flex-shrink: 0; letter-spacing: 0; font-family: monospace; }
173
+ .tree-prefix { color: var(--dim); font-size: 12px; flex-shrink: 0; letter-spacing: 0; font-family: monospace; white-space: pre; }
174
174
  .tree-activity {
175
- font-size: 10px; color: var(--dim); white-space: nowrap;
176
- overflow: hidden; text-overflow: ellipsis;
177
- padding: 0 2px 2px; line-height: 1.2;
178
- cursor: pointer;
175
+ font-size: 10px; color: var(--dim); white-space: pre;
176
+ overflow: hidden; line-height: 1.2;
177
+ padding: 0 2px 2px; cursor: pointer;
178
+ }
179
+ .tree-activity .act-text {
180
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
179
181
  }
180
182
  .tree-node .ctx-pct { font-size: 10px; margin-left: 4px; flex-shrink: 0; }
181
183
  .tree-node .ctx-pct.warn { color: var(--yellow); }
@@ -184,6 +186,11 @@ body {
184
186
  .tree-node .active-dot.on { color: var(--green); text-shadow: 0 0 6px var(--green); }
185
187
  .tree-node .active-dot.off { color: #555; opacity: 1; }
186
188
 
189
+ .tree-node .session-prefix {
190
+ background: rgba(255,255,255,0.08);
191
+ padding: 0 3px; border-radius: 3px; flex-shrink: 0; font-family: monospace;
192
+ letter-spacing: 0.5px; vertical-align: middle; font-weight: 600;
193
+ }
187
194
  .tree-actions { display: none; gap: 2px; padding-right: 4px; }
188
195
  .tree-row:hover .tree-actions { display: flex; }
189
196
  .tree-row.selected>.tree-actions { display: flex; }
@@ -257,6 +264,8 @@ body {
257
264
  .code-block-wrapper { margin: 8px 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
258
265
  .code-block-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: var(--bg3); font-size: 11px; color: var(--dim); }
259
266
  .code-block-header .lang-tag { color: var(--blue); font-weight: bold; }
267
+ .code-block-header .copy-btn { cursor: pointer; opacity: 0.5; transition: opacity 0.2s; font-size: 11px; }
268
+ .code-block-header .copy-btn:hover { opacity: 1; }
260
269
  .code-block-wrapper pre { margin: 0; padding: 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; }
261
270
  .code-block-wrapper pre code { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; }
262
271
 
@@ -377,12 +386,13 @@ class LRUCache {
377
386
  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; }
378
387
  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); } }
379
388
  }
380
- const seenToolIDs = new LRUCache(5000);
389
+ const seenToolIDs = new LRUCache(20000);
381
390
  const toolNameMap = new LRUCache(2000);
382
391
  const agentActivity = new Map(); // "sessionID:agentID" → { toolName, content }
383
392
  const taskDescriptions = new Map(); // toolID → description string
384
393
  const MAX_DESC_STORE = 200;
385
394
  let filters = new Map();
395
+ let visibleFilterCount = 0;
386
396
 
387
397
  let showThinking = true;
388
398
  let showToolInput = true;
@@ -393,6 +403,25 @@ let showActivity = true;
393
403
  let autoDiscovery = true;
394
404
  let appVersion = '';
395
405
 
406
+ const HIDDEN_KEY = 'claude-watch-hidden';
407
+ function loadHiddenSessions() {
408
+ try {
409
+ const data = JSON.parse(localStorage.getItem(HIDDEN_KEY) || '{}');
410
+ const now = Date.now();
411
+ for (const [id, ts] of Object.entries(data)) {
412
+ if (now - ts < 24 * 60 * 60 * 1000) hiddenSessionIDs.add(id);
413
+ }
414
+ _saveHiddenSessions();
415
+ } catch {}
416
+ }
417
+ function _saveHiddenSessions() {
418
+ const data = {};
419
+ for (const id of hiddenSessionIDs) data[id] = Date.now();
420
+ localStorage.setItem(HIDDEN_KEY, JSON.stringify(data));
421
+ }
422
+ const hiddenSessionIDs = new Set();
423
+ loadHiddenSessions();
424
+
396
425
  let renderPending = false;
397
426
 
398
427
  let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0;
@@ -416,6 +445,8 @@ const MAX_ITEMS = 9999;
416
445
  const MAX_LINES = 50;
417
446
  let renderedItemCount = 0;
418
447
  let needsFullRender = true;
448
+ let treeDirty = true;
449
+ let lastTreeCursor = -1;
419
450
 
420
451
  // ══════════════════════════════════════════════════════════════════════════════
421
452
  // Markdown renderer (marked + highlight.js)
@@ -438,12 +469,22 @@ mdRenderer.code = function (codeOrObj, langOrEsc) {
438
469
  }
439
470
  const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
440
471
  return `<div class="code-block-wrapper">
441
- <div class="code-block-header">${langTag}<span></span></div>
472
+ <div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">&#x2398;</span></div>
442
473
  <pre><code>${highlighted}</code></pre>
443
474
  </div>`;
444
475
  };
445
476
  marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
446
477
 
478
+ function copyCode(btn) {
479
+ const wrapper = btn.closest('.code-block-wrapper');
480
+ const code = wrapper ? wrapper.querySelector('code') : null;
481
+ if (!code) return;
482
+ navigator.clipboard.writeText(code.textContent).then(() => {
483
+ btn.innerHTML = '&#x2713;';
484
+ setTimeout(() => { btn.innerHTML = '&#x2398;'; }, 1500);
485
+ });
486
+ }
487
+
447
488
  function mdRender(text) {
448
489
  try {
449
490
  return DOMPurify.sanitize(marked.parse(text));
@@ -516,7 +557,7 @@ function handleMessage(msg) {
516
557
  case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
517
558
  case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
518
559
  case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
519
- case 'context': contextData = msg.payload; updateTreeDots(); break;
560
+ case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); break;
520
561
  case 'config':
521
562
  if (msg.payload.version) appVersion = msg.payload.version;
522
563
  if (msg.payload.collapseAfter > 0 && !collapseTimer) {
@@ -538,6 +579,7 @@ function sendCmd(action, extra = {}) {
538
579
  function handleSnapshot(payload) {
539
580
  autoDiscovery = payload.autoDiscovery;
540
581
  for (const s of (payload.sessions || [])) {
582
+ if (hiddenSessionIDs.has(s.id)) continue;
541
583
  let session = sessionsMap.get(s.id);
542
584
  if (!session) {
543
585
  session = {
@@ -566,21 +608,26 @@ function handleSnapshot(payload) {
566
608
  }
567
609
  }
568
610
  }
611
+ // Initialize agentActivity from server-computed lastActivities (survives history skip)
612
+ for (const [key, val] of Object.entries(payload.lastActivities || {})) {
613
+ agentActivity.set(key, val);
614
+ }
569
615
  updateFilters();
570
616
  rebuildNodes();
571
617
  needsFullRender = true;
572
618
  visibleDirty = true;
573
- scheduleTreeRender();
619
+ // Don't render tree yet — wait for itemBatch to fill title/model/taskDescriptions
574
620
  }
575
621
 
576
622
  function handleNewSession(payload) {
623
+ if (hiddenSessionIDs.has(payload.sessionID)) return;
577
624
  if (sessionsMap.has(payload.sessionID)) return;
578
625
  const session = {
579
626
  id: payload.sessionID, projectPath: payload.projectPath,
580
627
  title: '', folder: folderName(payload.projectPath), model: '',
581
628
  agents: [{ id: '', name: 'Main', type: 'main' }],
582
629
  tasks: [], collapsed: false, pinned: false,
583
- lastActivity: Date.now(),
630
+ lastActivity: payload.birthtimeMs || Date.now(),
584
631
  birthtimeMs: payload.birthtimeMs || 0,
585
632
  };
586
633
  sessions.push(session);
@@ -589,7 +636,7 @@ function handleNewSession(payload) {
589
636
  rebuildNodes();
590
637
  needsFullRender = true;
591
638
  visibleDirty = true;
592
- scheduleTreeRender();
639
+ scheduleRender();
593
640
  }
594
641
 
595
642
  function handleNewAgent(payload) {
@@ -604,7 +651,7 @@ function handleNewAgent(payload) {
604
651
  rebuildNodes();
605
652
  needsFullRender = true;
606
653
  visibleDirty = true;
607
- scheduleTreeRender();
654
+ scheduleRender();
608
655
  }
609
656
 
610
657
  function handleNewBgTask(payload) {
@@ -616,7 +663,7 @@ function handleNewBgTask(payload) {
616
663
  isComplete: payload.isComplete,
617
664
  });
618
665
  rebuildNodes();
619
- scheduleTreeRender();
666
+ scheduleRender();
620
667
  }
621
668
 
622
669
  function handleSessionRemoved(payload) {
@@ -629,7 +676,7 @@ function handleSessionRemoved(payload) {
629
676
  rebuildNodes();
630
677
  needsFullRender = true;
631
678
  visibleDirty = true;
632
- scheduleTreeRender();
679
+ scheduleRender();
633
680
  }
634
681
 
635
682
  // ══════════════════════════════════════════════════════════════════════════════
@@ -645,7 +692,7 @@ function handleItem(item) {
645
692
  }
646
693
  // Update activity
647
694
  const s = sessionsMap.get(item.sessionID);
648
- if (s) s.lastActivity = Date.now();
695
+ if (s) s.lastActivity = itemTime(item);
649
696
  pushItem(item);
650
697
  scheduleRender();
651
698
  }
@@ -658,13 +705,15 @@ function handleItemBatch(items) {
658
705
  continue;
659
706
  }
660
707
  const s = sessionsMap.get(item.sessionID);
661
- if (s) s.lastActivity = Date.now();
708
+ if (s) s.lastActivity = itemTime(item);
662
709
  pushItem(item);
663
710
  }
711
+ rebuildNodes();
664
712
  scheduleRender();
665
713
  }
666
714
 
667
715
  function pushItem(item) {
716
+ if (hiddenSessionIDs.has(item.sessionID)) return;
668
717
  // Token counts are sourced exclusively from server context messages
669
718
  // to avoid divergence between frontend accumulation and server tracking
670
719
 
@@ -678,7 +727,6 @@ function pushItem(item) {
678
727
  }
679
728
 
680
729
  if (item.type === 'tool_input') {
681
- // Main 代理不追踪工具调用,只显示用户 prompt
682
730
  if (item.agentID) {
683
731
  agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
684
732
  }
@@ -725,20 +773,26 @@ function isItemVisible(item) {
725
773
  // Tree
726
774
  // ══════════════════════════════════════════════════════════════════════════════
727
775
 
776
+ function idColor(rank) {
777
+ const hue = (rank * 137.508) % 360;
778
+ return `hsl(${hue}, 75%, 60%)`;
779
+ }
780
+
728
781
  function rebuildNodes() {
729
782
  // Sort sessions by creation time, newest first
730
783
  sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
784
+ for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
731
785
 
732
786
  const today = new Date();
733
787
  const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
734
788
 
735
- const todaySessions = [];
789
+ const flatSessions = [];
736
790
  const olderByDate = new Map(); // dateStr -> [sessions]
737
791
 
738
792
  for (const s of sessions) {
739
793
  const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
740
- if (!dateStr || dateStr === todayStr) {
741
- todaySessions.push(s);
794
+ if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
795
+ flatSessions.push(s);
742
796
  } else {
743
797
  if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
744
798
  olderByDate.get(dateStr).push(s);
@@ -764,7 +818,7 @@ function rebuildNodes() {
764
818
  const act = agentActivity.get(actKey);
765
819
  treeNodes.push({
766
820
  type: a.type, id: a.id, name: a.name, sessionID: s.id,
767
- level: 1, isLast: isLastAgent && !hasTasks,
821
+ level: 1, isLast: isLastAgent,
768
822
  activityTool: act ? act.toolName : '',
769
823
  activityDesc: act ? act.content : '',
770
824
  });
@@ -775,15 +829,16 @@ function rebuildNodes() {
775
829
  type: 'task', id: t.id, name: t.toolName,
776
830
  sessionID: s.id, parentAgentID: t.parentAgentID,
777
831
  outputPath: t.outputPath, isComplete: t.isComplete,
778
- level: 2, isLast: isLastAgent && ti === lastTaskIdx,
832
+ level: 2, isLast: ti === lastTaskIdx,
833
+ parentIsLast: isLastAgent,
779
834
  description: tDesc || '',
780
835
  });
781
836
  }
782
837
  }
783
838
  }
784
839
 
785
- // Today's sessions (expanded)
786
- for (const s of todaySessions) {
840
+ // Today's + active sessions (expanded, not in date folders)
841
+ for (const s of flatSessions) {
787
842
  addSessionWithChildren(s, false);
788
843
  }
789
844
 
@@ -807,14 +862,13 @@ function rebuildNodes() {
807
862
  }
808
863
  }
809
864
 
810
- // Mark last session among today's sessions
811
- const todaySessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
812
- if (todaySessionNodes.length > 0) todaySessionNodes[todaySessionNodes.length - 1].isLast = true;
865
+ // Mark last session among flat sessions
866
+ const flatSessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
867
+ if (flatSessionNodes.length > 0) flatSessionNodes[flatSessionNodes.length - 1].isLast = true;
813
868
 
814
869
  // Mark last session inside each folder
815
870
  for (const dateStr of sortedDates) {
816
871
  if (folderCollapsed[dateStr] !== false) continue;
817
- const folderSessionNodes = treeNodes.filter(n => n.type === 'session' && n.inFolder);
818
872
  // Find sessions belonging to this folder
819
873
  const thisFolder = [];
820
874
  let inThisFolder = false;
@@ -827,19 +881,19 @@ function rebuildNodes() {
827
881
  }
828
882
 
829
883
  if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
884
+ treeDirty = true;
830
885
  }
831
886
 
832
887
  function treePrefix(node) {
833
888
  if (node.level === 0) {
834
889
  return node.inFolder ? ' ' : '';
835
890
  }
836
- const branch = node.isLast ? '└── ' : '├── ';
837
- if (node.level === 1) return branch;
838
- // Level 2: need to check if parent agent is last
839
- const agentNode = treeNodes.find(n => n.sessionID === node.sessionID && (n.type === 'main' || n.type === 'agent') && n.id === (node.parentAgentID || ''));
840
- const parentIsLast = agentNode ? agentNode.isLast : true;
841
- const stem = parentIsLast ? ' ' : '│ ';
842
- return stem + branch;
891
+ const branch = node.isLast ? '└──' : '├──';
892
+ if (node.level === 1) return ' ' + branch;
893
+ // Level 2: use pre-computed parentIsLast from rebuildNodes
894
+ const parentIsLast = node.parentIsLast !== undefined ? node.parentIsLast : true;
895
+ const stem = parentIsLast ? ' ' : '│ ';
896
+ return ' ' + stem + branch;
843
897
  }
844
898
 
845
899
  function getNodeHTML(node, idx) {
@@ -869,7 +923,7 @@ function getNodeHTML(node, idx) {
869
923
  return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
870
924
  <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
871
925
  <div class="tree-node">
872
- <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} ${esc(displayName)}
926
+ <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} <span class="session-prefix" style="color:${idColor(node.colorRank)}">${esc(node.id.split('-')[0].toUpperCase())}</span> ${esc(displayName)}
873
927
  ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
874
928
  ${subInfo}
875
929
  ${timeHtml}
@@ -898,8 +952,10 @@ function getNodeHTML(node, idx) {
898
952
  const actText = showActivity && (node.activityTool || node.activityDesc)
899
953
  ? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
900
954
  : '';
955
+ const indent = treePrefix(node).replace(/[├└]──/, ' ');
956
+ const actPrefix = `<span class="tree-prefix">${indent}</span>`;
901
957
  const activityHTML = actText
902
- ? `<div class="tree-activity">${actIcon} ${esc(actText)}</div>`
958
+ ? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
903
959
  : '';
904
960
  return `<div class="tree-row${selClass ? ' selected' : ''}">
905
961
  <div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
@@ -917,8 +973,10 @@ function getNodeHTML(node, idx) {
917
973
 
918
974
  if (node.type === 'task') {
919
975
  const icon = node.isComplete ? '✓' : '⏳';
976
+ const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
977
+ const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
920
978
  const descHTML = showActivity && node.description
921
- ? `<div class="tree-activity">📋 ${esc(node.description)}</div>`
979
+ ? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
922
980
  : '';
923
981
  return `<div class="tree-row${selClass ? ' selected' : ''}">
924
982
  <div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
@@ -942,11 +1000,25 @@ function renderTree() {
942
1000
  treeCursorInfo.textContent = '';
943
1001
  return;
944
1002
  }
945
- let html = '';
946
- for (let i = 0; i < treeNodes.length; i++) {
947
- html += getNodeHTML(treeNodes[i], i);
1003
+
1004
+ const cursorChanged = treeCursor !== lastTreeCursor;
1005
+ if (treeDirty) {
1006
+ let html = '';
1007
+ for (let i = 0; i < treeNodes.length; i++) {
1008
+ html += getNodeHTML(treeNodes[i], i);
1009
+ }
1010
+ treeEl.innerHTML = html;
1011
+ treeDirty = false;
1012
+ } else if (cursorChanged) {
1013
+ const prevSel = treeEl.querySelector('.tree-row.selected');
1014
+ if (prevSel) prevSel.classList.remove('selected');
1015
+ const newContent = treeEl.querySelector('[data-idx="' + treeCursor + '"]');
1016
+ if (newContent) {
1017
+ const row = newContent.closest('.tree-row');
1018
+ if (row) row.classList.add('selected');
1019
+ }
948
1020
  }
949
- treeEl.innerHTML = html;
1021
+ lastTreeCursor = treeCursor;
950
1022
 
951
1023
  // Scroll selected into view
952
1024
  const sel = treeEl.querySelector('.tree-row.selected');
@@ -964,8 +1036,16 @@ function updateTreeDots() {
964
1036
  const idx = parseInt(content.getAttribute('data-idx'));
965
1037
  if (isNaN(idx)) continue;
966
1038
  const node = treeNodes[idx];
967
- if (!node || node.type !== 'session') continue;
968
- const active = isSessionActive(node);
1039
+ if (!node) continue;
1040
+ let active = false;
1041
+ if (node.type === 'session') {
1042
+ active = isSessionActive(node);
1043
+ } else if (node.type === 'main' || node.type === 'agent') {
1044
+ const ctxKey = node.sessionID + ':' + node.id;
1045
+ const ctx = contextData[ctxKey];
1046
+ const threshold = node.type === 'main' ? 600000 : 180000;
1047
+ active = ctx && (now - ctx.lastActivity < threshold);
1048
+ }
969
1049
  const newCls = active ? 'active-dot on' : 'active-dot off';
970
1050
  const newHTML = active ? '🟢' : '⚪';
971
1051
  if (dot.className !== newCls) {
@@ -977,6 +1057,14 @@ function updateTreeDots() {
977
1057
 
978
1058
  const ACTIVE_THRESHOLD = 600000; // 10 minutes
979
1059
 
1060
+ function itemTime(item) {
1061
+ if (item && item.timestamp) {
1062
+ const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
1063
+ if (!isNaN(ts.getTime())) return ts.getTime();
1064
+ }
1065
+ return Date.now();
1066
+ }
1067
+
980
1068
  function isSessionActive(session) {
981
1069
  if (!session) return false;
982
1070
  const now = Date.now();
@@ -1017,8 +1105,8 @@ function renderStream() {
1017
1105
  let html;
1018
1106
  if (lines.length > 0) {
1019
1107
  html = lines.map(l => {
1020
- if (l.html) return `<div class="${l.cls}">${l.text}</div>`;
1021
- return `<div class="${l.cls}">${esc(l.text)}</div>`;
1108
+ if (l.html) return `<div class="${esc(l.cls)}">${l.text}</div>`;
1109
+ return `<div class="${esc(l.cls)}">${esc(l.text)}</div>`;
1022
1110
  }).join('\n');
1023
1111
  } else if (streamItems.length > 0) {
1024
1112
  html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
@@ -1068,15 +1156,18 @@ function renderItem(item) {
1068
1156
  }
1069
1157
 
1070
1158
  const agentName = item.agentName || 'Main';
1159
+ const sForColor = sessionsMap.get(item.sessionID);
1160
+ const prefixTag = `<span class="session-prefix" style="color:${idColor(sForColor ? sForColor.colorRank : 0)}">[${esc(item.sessionID.split('-')[0].toUpperCase())}]</span>`;
1161
+ const agentLabel = prefixTag + ' ' + esc(agentName);
1071
1162
  const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
1072
1163
 
1073
1164
  switch (item.type) {
1074
1165
  case 'thinking':
1075
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + '🧠 Thinking')}</span>${tsHtml}`, html: true });
1166
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true });
1076
1167
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l });
1077
1168
  break;
1078
1169
  case 'tool_input':
1079
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + `🔧 ${item.toolName || ''}`)}</span>${tsHtml}`, html: true });
1170
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true });
1080
1171
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l });
1081
1172
  break;
1082
1173
  case 'tool_output': {
@@ -1086,19 +1177,19 @@ function renderItem(item) {
1086
1177
  }
1087
1178
  let label = tn ? `📤 ${tn} result` : '📤 Output';
1088
1179
  if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1089
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + label)}</span>${tsHtml}`, html: true });
1180
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1090
1181
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l });
1091
1182
  break;
1092
1183
  }
1093
1184
  case 'text':
1094
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + '💬 Response')}</span>${tsHtml}`, html: true });
1185
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true });
1095
1186
  lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true });
1096
1187
  break;
1097
1188
  case 'hook_output': {
1098
1189
  let label = '🪝 Hook';
1099
1190
  if (item.toolName) label += ' ' + item.toolName;
1100
1191
  if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1101
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + label)}</span>${tsHtml}`, html: true });
1192
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1102
1193
  if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true });
1103
1194
  if (item.hookContent) {
1104
1195
  for (const l of truncContent(item.hookContent)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">content:</span> ${esc(l)}`, html: true });
@@ -1109,14 +1200,14 @@ function renderItem(item) {
1109
1200
  case 'diagnostics': {
1110
1201
  let label = '⚠ Diagnostics';
1111
1202
  if (item.toolName) label += ' ' + item.toolName;
1112
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + label)}</span>${tsHtml}`, html: true });
1203
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1113
1204
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l });
1114
1205
  break;
1115
1206
  }
1116
1207
  case 'debug': {
1117
1208
  let label = '🔍 Debug';
1118
1209
  if (item.toolName) label += ' ' + item.toolName;
1119
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${esc(agentName + sep + label)}</span>${tsHtml}`, html: true });
1210
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1120
1211
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l });
1121
1212
  break;
1122
1213
  }
@@ -1212,7 +1303,10 @@ function toggleNodeVisibility(idx) {
1212
1303
  const node = treeNodes[idx];
1213
1304
  if (!node) return;
1214
1305
  const key = node.sessionID + ':' + node.id;
1215
- filters.set(key, !filters.get(key));
1306
+ const wasEnabled = filters.get(key);
1307
+ filters.set(key, !wasEnabled);
1308
+ if (wasEnabled) visibleFilterCount--;
1309
+ else visibleFilterCount++;
1216
1310
  renderAll();
1217
1311
  }
1218
1312
 
@@ -1256,6 +1350,7 @@ function soloSelected() {
1256
1350
  updateFilters();
1257
1351
  } else {
1258
1352
  filters.clear();
1353
+ visibleFilterCount = 0;
1259
1354
  if (node.type === 'session') {
1260
1355
  const session = sessions.find(s => s.id === node.id);
1261
1356
  if (session && session.collapsed) {
@@ -1263,9 +1358,13 @@ function soloSelected() {
1263
1358
  session.pinned = true;
1264
1359
  rebuildNodes();
1265
1360
  }
1266
- for (const a of node.agents) filters.set(node.id + ':' + a.id, true);
1361
+ for (const a of node.agents) {
1362
+ filters.set(node.id + ':' + a.id, true);
1363
+ visibleFilterCount++;
1364
+ }
1267
1365
  } else if (node.type === 'main' || node.type === 'agent') {
1268
1366
  filters.set(node.sessionID + ':' + node.id, true);
1367
+ visibleFilterCount = 1;
1269
1368
  }
1270
1369
  }
1271
1370
  renderAll();
@@ -1273,23 +1372,15 @@ function soloSelected() {
1273
1372
 
1274
1373
  function isSoloed(node) {
1275
1374
  if (node.type === 'session') {
1276
- for (const s of sessions) {
1277
- if (s.id === node.id) continue;
1278
- for (const a of s.agents) {
1279
- if (filters.get(s.id + ':' + a.id)) return false;
1280
- }
1375
+ if (visibleFilterCount !== node.agents.length) return false;
1376
+ for (const a of node.agents) {
1377
+ if (!filters.get(node.id + ':' + a.id)) return false;
1281
1378
  }
1282
1379
  return true;
1283
1380
  }
1284
1381
  if (node.type === 'main' || node.type === 'agent') {
1285
1382
  const key = node.sessionID + ':' + node.id;
1286
- if (!filters.get(key)) return false;
1287
- for (const s of sessions) {
1288
- for (const a of s.agents) {
1289
- if ((s.id + ':' + a.id) !== key && filters.get(s.id + ':' + a.id)) return false;
1290
- }
1291
- }
1292
- return true;
1383
+ return visibleFilterCount === 1 && filters.get(key);
1293
1384
  }
1294
1385
  return false;
1295
1386
  }
@@ -1307,6 +1398,8 @@ function removeSelectedSession() {
1307
1398
  else sid = node.sessionID;
1308
1399
  if (!sid) return;
1309
1400
  if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1401
+ hiddenSessionIDs.add(sid);
1402
+ _saveHiddenSessions();
1310
1403
  const idx = sessions.findIndex(s => s.id === sid);
1311
1404
  if (idx >= 0) {
1312
1405
  sessions.splice(idx, 1);
@@ -1332,7 +1425,7 @@ function toggleText() { showText = !showText; needsFullRender = true;
1332
1425
  visibleDirty = true; renderStream(); refreshButtons(); }
1333
1426
  function toggleHook() { showHook = !showHook; needsFullRender = true;
1334
1427
  visibleDirty = true; renderStream(); refreshButtons(); }
1335
- function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleTreeRender(); refreshButtons(); }
1428
+ function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
1336
1429
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1337
1430
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1338
1431
  function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
@@ -1406,10 +1499,8 @@ function applyCollapsePolicy(duration) {
1406
1499
  function startActiveRefresh() {
1407
1500
  if (activeRefreshTimer) clearInterval(activeRefreshTimer);
1408
1501
  activeRefreshTimer = setInterval(() => {
1409
- const prevHTML = treeEl.innerHTML;
1410
- rebuildNodes();
1411
- renderTree();
1412
- if (treeEl.innerHTML !== prevHTML) renderAll();
1502
+ updateTreeDots();
1503
+ refreshButtons();
1413
1504
  }, 15000);
1414
1505
  }
1415
1506
 
@@ -1430,9 +1521,11 @@ streamEl.addEventListener('scroll', () => {
1430
1521
 
1431
1522
  function updateFilters() {
1432
1523
  filters.clear();
1524
+ visibleFilterCount = 0;
1433
1525
  for (const s of sessions) {
1434
1526
  for (const a of s.agents) {
1435
1527
  filters.set(s.id + ':' + a.id, true);
1528
+ visibleFilterCount++;
1436
1529
  }
1437
1530
  }
1438
1531
  }
@@ -1496,17 +1589,6 @@ function renderAll() {
1496
1589
  }
1497
1590
 
1498
1591
  function scheduleRender() {
1499
- if (!renderPending) {
1500
- renderPending = true;
1501
- requestAnimationFrame(() => {
1502
- renderPending = false;
1503
- renderStream();
1504
- refreshButtons();
1505
- });
1506
- }
1507
- }
1508
-
1509
- function scheduleTreeRender() {
1510
1592
  if (!renderPending) {
1511
1593
  renderPending = true;
1512
1594
  requestAnimationFrame(() => {
@@ -455,15 +455,7 @@ function extractToolResultContent(content) {
455
455
 
456
456
  function stripNonUserContent(text) {
457
457
  if (!text) return '';
458
- // Remove tags that wrap non-user content
459
- let s = text;
460
- s = s.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '');
461
- s = s.replace(/<command-name>[\s\S]*?<\/command-name>/g, '');
462
- s = s.replace(/<command-message>[\s\S]*?<\/command-message>/g, '');
463
- s = s.replace(/<command-args>[\s\S]*?<\/command-args>/g, '');
464
- s = s.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, '');
465
- // Trim and return; empty string means no real user content
466
- return s.trim();
458
+ return text.replace(/<(?:local-command-caveat|command-name|command-message|command-args|local-command-stdout)>[\s\S]*?<\/(?:local-command-caveat|command-name|command-message|command-args|local-command-stdout)>/g, '').trim();
467
459
  }
468
460
 
469
461
  // ============================================================================
@@ -483,7 +475,7 @@ function formatToolInput(toolName, input) {
483
475
 
484
476
  switch (toolName) {
485
477
  case 'Bash':
486
- if (inp.description) return truncate(`${inp.command}\n # ${inp.description}`);
478
+ if (inp.description) return truncate(`${inp.command || ''}\n # ${inp.description}`);
487
479
  return truncate(inp.command || '');
488
480
  case 'Read':
489
481
  return inp.file_path || '';
@@ -41,6 +41,7 @@ class DashboardServer {
41
41
  this.server = null;
42
42
  this.wss = null;
43
43
  this._heartbeatTimer = null;
44
+ this._allowedPrefix = null;
44
45
 
45
46
  setDebugAll(options.debugAll || false);
46
47
  this.debugAll = options.debugAll || false;
@@ -50,6 +51,14 @@ class DashboardServer {
50
51
  return sessionID + ':' + (agentID || '');
51
52
  }
52
53
 
54
+ async _getAllowedPrefix() {
55
+ if (!this._allowedPrefix) {
56
+ const homeReal = await fs.promises.realpath(os.homedir());
57
+ this._allowedPrefix = path.join(homeReal, '.claude', 'projects');
58
+ }
59
+ return this._allowedPrefix;
60
+ }
61
+
53
62
  itemTime(item) {
54
63
  if (item.timestamp) {
55
64
  const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
@@ -207,8 +216,7 @@ class DashboardServer {
207
216
  let realPath;
208
217
  let allowedPrefix;
209
218
  try {
210
- const homeReal = await fs.promises.realpath(os.homedir());
211
- allowedPrefix = path.join(homeReal, '.claude', 'projects');
219
+ allowedPrefix = await this._getAllowedPrefix();
212
220
  realPath = await fs.promises.realpath(resolved);
213
221
  if (!realPath.startsWith(allowedPrefix)) {
214
222
  this.sendJSON(res, { error: 'Access denied' }, 403);
@@ -302,9 +310,20 @@ class DashboardServer {
302
310
  isComplete: t.isComplete,
303
311
  })),
304
312
  }));
313
+ // Compute last activity per agent from itemBuffer (handles skipped history)
314
+ const lastActivities = {};
315
+ for (const item of this.itemBuffer) {
316
+ const actKey = item.sessionID + ':' + (item.agentID || '');
317
+ if (item.type === 'user_text') {
318
+ lastActivities[actKey] = { toolName: '', content: (item.content || '').slice(0, 200) };
319
+ } else if (item.type === 'tool_input' && item.agentID) {
320
+ lastActivities[actKey] = { toolName: item.toolName || '', content: (item.content || '').slice(0, 200) };
321
+ }
322
+ }
305
323
  this.send(ws, 'snapshot', {
306
324
  sessions,
307
325
  autoDiscovery: this.watcher.isAutoDiscoveryEnabled(),
326
+ lastActivities,
308
327
  });
309
328
  }
310
329
 
@@ -330,6 +349,7 @@ class DashboardServer {
330
349
  }
331
350
  });
332
351
 
352
+ const FLUSH_BATCH_LIMIT = 50;
333
353
  w.on('item', (item) => {
334
354
  this.itemBuffer.push(item);
335
355
  if (this.itemBuffer.length > MAX_ITEM_BUFFER) {
@@ -337,7 +357,13 @@ class DashboardServer {
337
357
  }
338
358
  this.updateContext(item);
339
359
  this._pendingItems.push(item);
340
- if (!this._flushTimer) {
360
+ if (this._pendingItems.length >= FLUSH_BATCH_LIMIT) {
361
+ // Batch size hit limit — flush immediately
362
+ if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; }
363
+ const batch = this._pendingItems;
364
+ this._pendingItems = [];
365
+ this.broadcast('itemBatch', batch);
366
+ } else if (!this._flushTimer) {
341
367
  this._flushTimer = setTimeout(() => {
342
368
  this._flushTimer = null;
343
369
  const batch = this._pendingItems;
@@ -32,11 +32,13 @@ function getClaudeProjectsDir() {
32
32
  return path.join(os.homedir(), '.claude', 'projects');
33
33
  }
34
34
 
35
+ const _projectPathCache = new Map();
36
+
35
37
  async function resolveProjectPath(encoded) {
38
+ if (_projectPathCache.has(encoded)) return _projectPathCache.get(encoded);
36
39
  let s = encoded;
37
40
  if (s.startsWith('-')) s = s.slice(1);
38
41
  if (!s) return '';
39
-
40
42
  const parts = s.split('-');
41
43
 
42
44
  // Try progressively joining segments from the right with dashes
@@ -46,14 +48,18 @@ async function resolveProjectPath(encoded) {
46
48
  const testPath = `/${pathPart}/${dirPart}`;
47
49
  try {
48
50
  await fsp.access(testPath);
49
- return `${pathPart}/${dirPart}`;
51
+ const result = `${pathPart}/${dirPart}`;
52
+ _projectPathCache.set(encoded, result);
53
+ return result;
50
54
  } catch {
51
55
  // Path doesn't exist, try next combination
52
56
  }
53
57
  }
54
58
 
55
59
  // Fallback to naive conversion
56
- return s.replace(/-/g, '/');
60
+ const result = s.replace(/-/g, '/');
61
+ _projectPathCache.set(encoded, result);
62
+ return result;
57
63
  }
58
64
 
59
65
  function isMainSessionFile(filePath, stats) {
@@ -339,6 +345,11 @@ class Watcher extends EventEmitter {
339
345
  this.watcher.on('unlink', (p) => {
340
346
  this.filePositions.delete(p);
341
347
  this.fileContexts.delete(p);
348
+ const timer = this.debounceTimers.get(p);
349
+ if (timer) {
350
+ clearTimeout(timer);
351
+ this.debounceTimers.delete(p);
352
+ }
342
353
  });
343
354
  this.watcher.on('error', (err) => this.emit('error', err));
344
355
 
@@ -877,14 +888,14 @@ class Watcher extends EventEmitter {
877
888
  async _initializeSessionReading(sessions) {
878
889
  let shouldSkip = this.skipHistory;
879
890
  if (!shouldSkip) {
880
- let totalLines = 0;
891
+ let totalEstimate = 0;
881
892
  for (const session of sessions) {
882
- totalLines += await this._countFileLines(session.mainFile);
893
+ totalEstimate += await this._estimateFileLines(session.mainFile);
883
894
  for (const agentPath of Object.values(session.subagents)) {
884
- totalLines += await this._countFileLines(agentPath);
895
+ totalEstimate += await this._estimateFileLines(agentPath);
885
896
  }
886
897
  }
887
- shouldSkip = totalLines > AutoSkipLineThreshold;
898
+ shouldSkip = totalEstimate > AutoSkipLineThreshold;
888
899
  }
889
900
 
890
901
  if (shouldSkip) {
@@ -982,17 +993,17 @@ class Watcher extends EventEmitter {
982
993
  if (pos === stats.size) { await handle.close(); handle = null; return; }
983
994
 
984
995
  newPos = pos;
996
+ const fileSize = stats.size;
985
997
  // Read in chunks to avoid large buffer allocations for big file deltas
986
998
  let carryOver = ''; // incomplete trailing line from previous chunk
987
999
  let carryOverBytes = 0; // byte length of carryOver (to avoid re-reading it)
988
1000
  const buf = Buffer.alloc(MaxReadChunk);
989
1001
 
990
1002
  while (true) {
991
- const currentStats = await handle.stat();
992
1003
  const readFrom = newPos + carryOverBytes;
993
- if (readFrom >= currentStats.size) break;
1004
+ if (readFrom >= fileSize) break;
994
1005
 
995
- const readLen = Math.min(MaxReadChunk, currentStats.size - readFrom);
1006
+ const readLen = Math.min(MaxReadChunk, fileSize - readFrom);
996
1007
  const { bytesRead } = await handle.read(buf, 0, readLen, readFrom);
997
1008
  if (bytesRead === 0) break;
998
1009
 
@@ -1076,7 +1087,7 @@ class Watcher extends EventEmitter {
1076
1087
  }
1077
1088
 
1078
1089
  newPos += chunkBytes;
1079
- this.filePositions.set(filePath, Math.min(newPos, currentStats.size));
1090
+ this.filePositions.set(filePath, Math.min(newPos, fileSize));
1080
1091
  }
1081
1092
 
1082
1093
  // Process any remaining carryOver as a final incomplete line (no trailing \n).
@@ -1103,27 +1114,10 @@ class Watcher extends EventEmitter {
1103
1114
  }
1104
1115
  }
1105
1116
 
1106
- async _countFileLines(filePath) {
1117
+ async _estimateFileLines(filePath) {
1107
1118
  try {
1108
1119
  const stat = await fsp.stat(filePath);
1109
- if (stat.size === 0) return 0;
1110
- const handle = await fsp.open(filePath, 'r');
1111
- const buf = Buffer.alloc(8192);
1112
- let count = 0;
1113
- let pos = 0;
1114
- try {
1115
- while (pos < stat.size) {
1116
- const readLen = Math.min(8192, stat.size - pos);
1117
- const { bytesRead } = await handle.read(buf, 0, readLen, pos);
1118
- for (let i = 0; i < bytesRead; i++) {
1119
- if (buf[i] === 0x0A) count++;
1120
- }
1121
- pos += bytesRead;
1122
- }
1123
- } finally {
1124
- await handle.close();
1125
- }
1126
- return count;
1120
+ return Math.ceil(stat.size / 500);
1127
1121
  } catch {
1128
1122
  return 0;
1129
1123
  }