claude-code-watch 0.0.18 → 0.0.20

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.18",
3
+ "version": "0.0.20",
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
  }
@@ -657,12 +704,16 @@ function handleItemBatch(items) {
657
704
  if (s) { s.title = item.content.slice(0, 30); }
658
705
  continue;
659
706
  }
707
+ const s = sessionsMap.get(item.sessionID);
708
+ if (s) s.lastActivity = itemTime(item);
660
709
  pushItem(item);
661
710
  }
711
+ rebuildNodes();
662
712
  scheduleRender();
663
713
  }
664
714
 
665
715
  function pushItem(item) {
716
+ if (hiddenSessionIDs.has(item.sessionID)) return;
666
717
  // Token counts are sourced exclusively from server context messages
667
718
  // to avoid divergence between frontend accumulation and server tracking
668
719
 
@@ -676,7 +727,6 @@ function pushItem(item) {
676
727
  }
677
728
 
678
729
  if (item.type === 'tool_input') {
679
- // Main 代理不追踪工具调用,只显示用户 prompt
680
730
  if (item.agentID) {
681
731
  agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
682
732
  }
@@ -723,20 +773,26 @@ function isItemVisible(item) {
723
773
  // Tree
724
774
  // ══════════════════════════════════════════════════════════════════════════════
725
775
 
776
+ function idColor(rank) {
777
+ const hue = (rank * 137.508) % 360;
778
+ return `hsl(${hue}, 75%, 60%)`;
779
+ }
780
+
726
781
  function rebuildNodes() {
727
782
  // Sort sessions by creation time, newest first
728
783
  sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
784
+ for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
729
785
 
730
786
  const today = new Date();
731
787
  const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
732
788
 
733
- const todaySessions = [];
789
+ const flatSessions = [];
734
790
  const olderByDate = new Map(); // dateStr -> [sessions]
735
791
 
736
792
  for (const s of sessions) {
737
793
  const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
738
- if (!dateStr || dateStr === todayStr) {
739
- todaySessions.push(s);
794
+ if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
795
+ flatSessions.push(s);
740
796
  } else {
741
797
  if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
742
798
  olderByDate.get(dateStr).push(s);
@@ -762,7 +818,7 @@ function rebuildNodes() {
762
818
  const act = agentActivity.get(actKey);
763
819
  treeNodes.push({
764
820
  type: a.type, id: a.id, name: a.name, sessionID: s.id,
765
- level: 1, isLast: isLastAgent && !hasTasks,
821
+ level: 1, isLast: isLastAgent,
766
822
  activityTool: act ? act.toolName : '',
767
823
  activityDesc: act ? act.content : '',
768
824
  });
@@ -773,15 +829,16 @@ function rebuildNodes() {
773
829
  type: 'task', id: t.id, name: t.toolName,
774
830
  sessionID: s.id, parentAgentID: t.parentAgentID,
775
831
  outputPath: t.outputPath, isComplete: t.isComplete,
776
- level: 2, isLast: isLastAgent && ti === lastTaskIdx,
832
+ level: 2, isLast: ti === lastTaskIdx,
833
+ parentIsLast: isLastAgent,
777
834
  description: tDesc || '',
778
835
  });
779
836
  }
780
837
  }
781
838
  }
782
839
 
783
- // Today's sessions (expanded)
784
- for (const s of todaySessions) {
840
+ // Today's + active sessions (expanded, not in date folders)
841
+ for (const s of flatSessions) {
785
842
  addSessionWithChildren(s, false);
786
843
  }
787
844
 
@@ -805,14 +862,13 @@ function rebuildNodes() {
805
862
  }
806
863
  }
807
864
 
808
- // Mark last session among today's sessions
809
- const todaySessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
810
- 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;
811
868
 
812
869
  // Mark last session inside each folder
813
870
  for (const dateStr of sortedDates) {
814
871
  if (folderCollapsed[dateStr] !== false) continue;
815
- const folderSessionNodes = treeNodes.filter(n => n.type === 'session' && n.inFolder);
816
872
  // Find sessions belonging to this folder
817
873
  const thisFolder = [];
818
874
  let inThisFolder = false;
@@ -825,19 +881,19 @@ function rebuildNodes() {
825
881
  }
826
882
 
827
883
  if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
884
+ treeDirty = true;
828
885
  }
829
886
 
830
887
  function treePrefix(node) {
831
888
  if (node.level === 0) {
832
889
  return node.inFolder ? ' ' : '';
833
890
  }
834
- const branch = node.isLast ? '└── ' : '├── ';
835
- if (node.level === 1) return branch;
836
- // Level 2: need to check if parent agent is last
837
- const agentNode = treeNodes.find(n => n.sessionID === node.sessionID && (n.type === 'main' || n.type === 'agent') && n.id === (node.parentAgentID || ''));
838
- const parentIsLast = agentNode ? agentNode.isLast : true;
839
- const stem = parentIsLast ? ' ' : '│ ';
840
- 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;
841
897
  }
842
898
 
843
899
  function getNodeHTML(node, idx) {
@@ -867,7 +923,7 @@ function getNodeHTML(node, idx) {
867
923
  return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
868
924
  <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
869
925
  <div class="tree-node">
870
- <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)}
871
927
  ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
872
928
  ${subInfo}
873
929
  ${timeHtml}
@@ -896,8 +952,10 @@ function getNodeHTML(node, idx) {
896
952
  const actText = showActivity && (node.activityTool || node.activityDesc)
897
953
  ? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
898
954
  : '';
955
+ const indent = treePrefix(node).replace(/[├└]──/, ' ');
956
+ const actPrefix = `<span class="tree-prefix">${indent}</span>`;
899
957
  const activityHTML = actText
900
- ? `<div class="tree-activity">${actIcon} ${esc(actText)}</div>`
958
+ ? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
901
959
  : '';
902
960
  return `<div class="tree-row${selClass ? ' selected' : ''}">
903
961
  <div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
@@ -915,8 +973,10 @@ function getNodeHTML(node, idx) {
915
973
 
916
974
  if (node.type === 'task') {
917
975
  const icon = node.isComplete ? '✓' : '⏳';
976
+ const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
977
+ const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
918
978
  const descHTML = showActivity && node.description
919
- ? `<div class="tree-activity">📋 ${esc(node.description)}</div>`
979
+ ? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
920
980
  : '';
921
981
  return `<div class="tree-row${selClass ? ' selected' : ''}">
922
982
  <div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
@@ -940,11 +1000,25 @@ function renderTree() {
940
1000
  treeCursorInfo.textContent = '';
941
1001
  return;
942
1002
  }
943
- let html = '';
944
- for (let i = 0; i < treeNodes.length; i++) {
945
- 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
+ }
946
1020
  }
947
- treeEl.innerHTML = html;
1021
+ lastTreeCursor = treeCursor;
948
1022
 
949
1023
  // Scroll selected into view
950
1024
  const sel = treeEl.querySelector('.tree-row.selected');
@@ -962,8 +1036,16 @@ function updateTreeDots() {
962
1036
  const idx = parseInt(content.getAttribute('data-idx'));
963
1037
  if (isNaN(idx)) continue;
964
1038
  const node = treeNodes[idx];
965
- if (!node || node.type !== 'session') continue;
966
- 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
+ }
967
1049
  const newCls = active ? 'active-dot on' : 'active-dot off';
968
1050
  const newHTML = active ? '🟢' : '⚪';
969
1051
  if (dot.className !== newCls) {
@@ -975,6 +1057,14 @@ function updateTreeDots() {
975
1057
 
976
1058
  const ACTIVE_THRESHOLD = 600000; // 10 minutes
977
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
+
978
1068
  function isSessionActive(session) {
979
1069
  if (!session) return false;
980
1070
  const now = Date.now();
@@ -1015,8 +1105,8 @@ function renderStream() {
1015
1105
  let html;
1016
1106
  if (lines.length > 0) {
1017
1107
  html = lines.map(l => {
1018
- if (l.html) return `<div class="${l.cls}">${l.text}</div>`;
1019
- 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>`;
1020
1110
  }).join('\n');
1021
1111
  } else if (streamItems.length > 0) {
1022
1112
  html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
@@ -1066,15 +1156,18 @@ function renderItem(item) {
1066
1156
  }
1067
1157
 
1068
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);
1069
1162
  const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
1070
1163
 
1071
1164
  switch (item.type) {
1072
1165
  case 'thinking':
1073
- 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 });
1074
1167
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l });
1075
1168
  break;
1076
1169
  case 'tool_input':
1077
- 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 });
1078
1171
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l });
1079
1172
  break;
1080
1173
  case 'tool_output': {
@@ -1084,19 +1177,19 @@ function renderItem(item) {
1084
1177
  }
1085
1178
  let label = tn ? `📤 ${tn} result` : '📤 Output';
1086
1179
  if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1087
- 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 });
1088
1181
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l });
1089
1182
  break;
1090
1183
  }
1091
1184
  case 'text':
1092
- 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 });
1093
1186
  lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true });
1094
1187
  break;
1095
1188
  case 'hook_output': {
1096
1189
  let label = '🪝 Hook';
1097
1190
  if (item.toolName) label += ' ' + item.toolName;
1098
1191
  if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1099
- 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 });
1100
1193
  if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true });
1101
1194
  if (item.hookContent) {
1102
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 });
@@ -1107,14 +1200,14 @@ function renderItem(item) {
1107
1200
  case 'diagnostics': {
1108
1201
  let label = '⚠ Diagnostics';
1109
1202
  if (item.toolName) label += ' ' + item.toolName;
1110
- 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 });
1111
1204
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l });
1112
1205
  break;
1113
1206
  }
1114
1207
  case 'debug': {
1115
1208
  let label = '🔍 Debug';
1116
1209
  if (item.toolName) label += ' ' + item.toolName;
1117
- 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 });
1118
1211
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l });
1119
1212
  break;
1120
1213
  }
@@ -1210,7 +1303,10 @@ function toggleNodeVisibility(idx) {
1210
1303
  const node = treeNodes[idx];
1211
1304
  if (!node) return;
1212
1305
  const key = node.sessionID + ':' + node.id;
1213
- filters.set(key, !filters.get(key));
1306
+ const wasEnabled = filters.get(key);
1307
+ filters.set(key, !wasEnabled);
1308
+ if (wasEnabled) visibleFilterCount--;
1309
+ else visibleFilterCount++;
1214
1310
  renderAll();
1215
1311
  }
1216
1312
 
@@ -1254,6 +1350,7 @@ function soloSelected() {
1254
1350
  updateFilters();
1255
1351
  } else {
1256
1352
  filters.clear();
1353
+ visibleFilterCount = 0;
1257
1354
  if (node.type === 'session') {
1258
1355
  const session = sessions.find(s => s.id === node.id);
1259
1356
  if (session && session.collapsed) {
@@ -1261,9 +1358,13 @@ function soloSelected() {
1261
1358
  session.pinned = true;
1262
1359
  rebuildNodes();
1263
1360
  }
1264
- 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
+ }
1265
1365
  } else if (node.type === 'main' || node.type === 'agent') {
1266
1366
  filters.set(node.sessionID + ':' + node.id, true);
1367
+ visibleFilterCount = 1;
1267
1368
  }
1268
1369
  }
1269
1370
  renderAll();
@@ -1271,23 +1372,17 @@ function soloSelected() {
1271
1372
 
1272
1373
  function isSoloed(node) {
1273
1374
  if (node.type === 'session') {
1274
- for (const s of sessions) {
1275
- if (s.id === node.id) continue;
1276
- for (const a of s.agents) {
1277
- if (filters.get(s.id + ':' + a.id)) return false;
1278
- }
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;
1279
1378
  }
1280
1379
  return true;
1281
1380
  }
1282
1381
  if (node.type === 'main' || node.type === 'agent') {
1283
1382
  const key = node.sessionID + ':' + node.id;
1284
- if (!filters.get(key)) return false;
1285
- for (const s of sessions) {
1286
- for (const a of s.agents) {
1287
- if ((s.id + ':' + a.id) !== key && filters.get(s.id + ':' + a.id)) return false;
1288
- }
1289
- }
1290
- return true;
1383
+ return visibleFilterCount === 1 && filters.get(key);
1384
+ }
1385
+ return false;
1291
1386
  }
1292
1387
  return false;
1293
1388
  }
@@ -1305,6 +1400,8 @@ function removeSelectedSession() {
1305
1400
  else sid = node.sessionID;
1306
1401
  if (!sid) return;
1307
1402
  if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1403
+ hiddenSessionIDs.add(sid);
1404
+ _saveHiddenSessions();
1308
1405
  const idx = sessions.findIndex(s => s.id === sid);
1309
1406
  if (idx >= 0) {
1310
1407
  sessions.splice(idx, 1);
@@ -1330,7 +1427,7 @@ function toggleText() { showText = !showText; needsFullRender = true;
1330
1427
  visibleDirty = true; renderStream(); refreshButtons(); }
1331
1428
  function toggleHook() { showHook = !showHook; needsFullRender = true;
1332
1429
  visibleDirty = true; renderStream(); refreshButtons(); }
1333
- function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleTreeRender(); refreshButtons(); }
1430
+ function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
1334
1431
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1335
1432
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1336
1433
  function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
@@ -1404,10 +1501,8 @@ function applyCollapsePolicy(duration) {
1404
1501
  function startActiveRefresh() {
1405
1502
  if (activeRefreshTimer) clearInterval(activeRefreshTimer);
1406
1503
  activeRefreshTimer = setInterval(() => {
1407
- const prevHTML = treeEl.innerHTML;
1408
- rebuildNodes();
1409
- renderTree();
1410
- if (treeEl.innerHTML !== prevHTML) renderAll();
1504
+ updateTreeDots();
1505
+ refreshButtons();
1411
1506
  }, 15000);
1412
1507
  }
1413
1508
 
@@ -1428,9 +1523,11 @@ streamEl.addEventListener('scroll', () => {
1428
1523
 
1429
1524
  function updateFilters() {
1430
1525
  filters.clear();
1526
+ visibleFilterCount = 0;
1431
1527
  for (const s of sessions) {
1432
1528
  for (const a of s.agents) {
1433
1529
  filters.set(s.id + ':' + a.id, true);
1530
+ visibleFilterCount++;
1434
1531
  }
1435
1532
  }
1436
1533
  }
@@ -1494,17 +1591,6 @@ function renderAll() {
1494
1591
  }
1495
1592
 
1496
1593
  function scheduleRender() {
1497
- if (!renderPending) {
1498
- renderPending = true;
1499
- requestAnimationFrame(() => {
1500
- renderPending = false;
1501
- renderStream();
1502
- refreshButtons();
1503
- });
1504
- }
1505
- }
1506
-
1507
- function scheduleTreeRender() {
1508
1594
  if (!renderPending) {
1509
1595
  renderPending = true;
1510
1596
  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,11 +51,27 @@ 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
+
62
+ itemTime(item) {
63
+ if (item.timestamp) {
64
+ const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
65
+ if (!isNaN(ts.getTime())) return ts.getTime();
66
+ }
67
+ return Date.now();
68
+ }
69
+
53
70
  updateContext(item) {
54
71
  const key = this.getCtxKey(item.sessionID, item.agentID);
55
72
  let ctx = this.contextMap.get(key);
56
73
  if (!ctx) {
57
- ctx = { inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, model: '', contextWindow: 200000, lastActivity: Date.now() };
74
+ ctx = { inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, model: '', contextWindow: 200000, lastActivity: this.itemTime(item) };
58
75
  this.contextMap.set(key, ctx);
59
76
  }
60
77
  // inputTokens: Claude API returns cumulative total per call, not incremental — use Math.max
@@ -67,7 +84,7 @@ class DashboardServer {
67
84
  ctx.model = item.model;
68
85
  ctx.contextWindow = contextWindowFor(item.model);
69
86
  }
70
- ctx.lastActivity = Date.now();
87
+ ctx.lastActivity = Math.max(ctx.lastActivity || 0, this.itemTime(item));
71
88
  }
72
89
 
73
90
  cleanupContextMap() {
@@ -199,8 +216,7 @@ class DashboardServer {
199
216
  let realPath;
200
217
  let allowedPrefix;
201
218
  try {
202
- const homeReal = await fs.promises.realpath(os.homedir());
203
- allowedPrefix = path.join(homeReal, '.claude', 'projects');
219
+ allowedPrefix = await this._getAllowedPrefix();
204
220
  realPath = await fs.promises.realpath(resolved);
205
221
  if (!realPath.startsWith(allowedPrefix)) {
206
222
  this.sendJSON(res, { error: 'Access denied' }, 403);
@@ -294,9 +310,20 @@ class DashboardServer {
294
310
  isComplete: t.isComplete,
295
311
  })),
296
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
+ }
297
323
  this.send(ws, 'snapshot', {
298
324
  sessions,
299
325
  autoDiscovery: this.watcher.isAutoDiscoveryEnabled(),
326
+ lastActivities,
300
327
  });
301
328
  }
302
329
 
@@ -322,6 +349,7 @@ class DashboardServer {
322
349
  }
323
350
  });
324
351
 
352
+ const FLUSH_BATCH_LIMIT = 50;
325
353
  w.on('item', (item) => {
326
354
  this.itemBuffer.push(item);
327
355
  if (this.itemBuffer.length > MAX_ITEM_BUFFER) {
@@ -329,7 +357,13 @@ class DashboardServer {
329
357
  }
330
358
  this.updateContext(item);
331
359
  this._pendingItems.push(item);
332
- 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) {
333
367
  this._flushTimer = setTimeout(() => {
334
368
  this._flushTimer = null;
335
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
  }