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 +8 -4
- package/README.zh-CN.md +5 -1
- package/bin/claude-watch.js +9 -5
- package/package.json +1 -1
- package/public/index.html +168 -82
- package/src/parser/parser.js +2 -10
- package/src/server/server.js +39 -5
- package/src/watcher/watcher.js +24 -30
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
- **自动发现** — 新会话启动时自动纳入监控
|
package/bin/claude-watch.js
CHANGED
|
@@ -23,19 +23,19 @@ USAGE:
|
|
|
23
23
|
|
|
24
24
|
OPTIONS:
|
|
25
25
|
-p, --port <port> HTTP port (default: 23000)
|
|
26
|
-
|
|
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
|
|
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
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:
|
|
176
|
-
overflow: hidden;
|
|
177
|
-
padding: 0 2px 2px;
|
|
178
|
-
|
|
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(
|
|
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
|
|
472
|
+
<div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">⎘</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 = '✓';
|
|
484
|
+
setTimeout(() => { btn.innerHTML = '⎘'; }, 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
809
|
-
const
|
|
810
|
-
if (
|
|
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:
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
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
|
|
966
|
-
|
|
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">${
|
|
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">${
|
|
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(
|
|
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">${
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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();
|
|
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
|
-
|
|
1408
|
-
|
|
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(() => {
|
package/src/parser/parser.js
CHANGED
|
@@ -455,15 +455,7 @@ function extractToolResultContent(content) {
|
|
|
455
455
|
|
|
456
456
|
function stripNonUserContent(text) {
|
|
457
457
|
if (!text) return '';
|
|
458
|
-
|
|
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 || '';
|
package/src/server/server.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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;
|
package/src/watcher/watcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
891
|
+
let totalEstimate = 0;
|
|
881
892
|
for (const session of sessions) {
|
|
882
|
-
|
|
893
|
+
totalEstimate += await this._estimateFileLines(session.mainFile);
|
|
883
894
|
for (const agentPath of Object.values(session.subagents)) {
|
|
884
|
-
|
|
895
|
+
totalEstimate += await this._estimateFileLines(agentPath);
|
|
885
896
|
}
|
|
886
897
|
}
|
|
887
|
-
shouldSkip =
|
|
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 >=
|
|
1004
|
+
if (readFrom >= fileSize) break;
|
|
994
1005
|
|
|
995
|
-
const readLen = Math.min(MaxReadChunk,
|
|
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,
|
|
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
|
|
1117
|
+
async _estimateFileLines(filePath) {
|
|
1107
1118
|
try {
|
|
1108
1119
|
const stat = await fsp.stat(filePath);
|
|
1109
|
-
|
|
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
|
}
|