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 +2 -2
- package/README.zh-CN.md +1 -1
- package/bin/claude-watch.js +2 -2
- package/package.json +1 -1
- package/public/index.html +83 -62
- package/src/parser/parser.js +26 -9
- package/src/server/server.js +70 -27
- package/src/watcher/watcher.js +157 -125
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
|
|
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
|
## 致谢
|
package/bin/claude-watch.js
CHANGED
|
@@ -150,7 +150,7 @@ async function main() {
|
|
|
150
150
|
sessionID: '',
|
|
151
151
|
skipHistory: false,
|
|
152
152
|
pollMs: 500,
|
|
153
|
-
activeWindow:
|
|
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] || '
|
|
193
|
+
options.activeWindow = parseDuration(args[++i] || '30m');
|
|
194
194
|
} catch {
|
|
195
195
|
options.activeWindow = 5 * 60 * 1000;
|
|
196
196
|
}
|
package/package.json
CHANGED
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
560
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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(`
|
|
1108
|
-
|
|
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;
|
|
1120
|
-
|
|
1121
|
-
function
|
|
1122
|
-
|
|
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
|
-
|
|
1292
|
+
renderTree();
|
|
1293
|
+
renderStream();
|
|
1294
|
+
refreshButtons();
|
|
1274
1295
|
});
|
|
1275
1296
|
}
|
|
1276
1297
|
}
|
package/src/parser/parser.js
CHANGED
|
@@ -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
|
|
package/src/server/server.js
CHANGED
|
@@ -21,7 +21,7 @@ var MIME = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
var MAX_ITEM_BUFFER = 2000;
|
|
24
|
-
var CONTEXT_STALE_MS =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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, '
|
|
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 ||
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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();
|
package/src/watcher/watcher.js
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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.
|
|
191
|
-
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
915
|
-
try { currentSize = (await handle.stat()).size; } catch { currentSize = stats.size; }
|
|
916
|
-
const fileGrew = currentSize > pos + bytesRead;
|
|
947
|
+
let chunkBytes = 0;
|
|
917
948
|
|
|
918
|
-
|
|
919
|
-
|
|
949
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
950
|
+
let rawLine = rawLines[i];
|
|
920
951
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
958
|
+
// Strip trailing \r for CRLF
|
|
959
|
+
if (nlLen === 2 && rawLine.endsWith('\r')) {
|
|
960
|
+
rawLine = rawLine.slice(0, -1);
|
|
961
|
+
}
|
|
942
962
|
|
|
943
|
-
|
|
944
|
-
newPos += nlLen;
|
|
963
|
+
chunkBytes += Buffer.byteLength(rawLine, 'utf-8') + nlLen;
|
|
945
964
|
|
|
946
|
-
|
|
965
|
+
if (!rawLine.trim()) continue;
|
|
947
966
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
967
|
+
const items = parseLine(rawLine);
|
|
968
|
+
for (const item of items) {
|
|
969
|
+
item.sessionID = sessionID;
|
|
951
970
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|