claude-code-watch 0.0.24 → 0.1.0

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.
@@ -286,7 +286,7 @@ async function main() {
286
286
  }
287
287
 
288
288
  checkForUpdate();
289
- startServer(options);
289
+ await startServer(options);
290
290
  }
291
291
 
292
292
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.0.24",
3
+ "version": "0.1.0",
4
4
  "description": "Web-based real-time monitor for Claude Code.",
5
5
  "main": "./src/server/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -190,7 +190,20 @@ body {
190
190
  background: rgba(255,255,255,0.08);
191
191
  padding: 0 3px; border-radius: 3px; flex-shrink: 0; font-family: monospace;
192
192
  letter-spacing: 0.5px; vertical-align: middle; font-weight: 600;
193
+ position: relative; cursor: pointer;
193
194
  }
195
+ .session-id-tip {
196
+ position: fixed; z-index: 10000;
197
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 4px;
198
+ padding: 4px 8px; font-size: 11px; white-space: nowrap; color: var(--text);
199
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: flex; align-items: center; gap: 6px;
200
+ }
201
+ .session-id-tip code { font-family: monospace; user-select: all; color: var(--white); }
202
+ .session-id-tip .tip-copy-btn {
203
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 3px;
204
+ color: var(--text); font-size: 10px; padding: 1px 5px; cursor: pointer;
205
+ }
206
+ .session-id-tip .tip-copy-btn:hover { background: var(--accent); color: #fff; }
194
207
  .tree-actions { display: none; gap: 2px; padding-right: 4px; }
195
208
  .tree-row:hover .tree-actions { display: flex; }
196
209
  .tree-row.selected>.tree-actions { display: flex; }
@@ -226,6 +239,15 @@ body {
226
239
  .stream-line.agent-tag .tag-label { flex-shrink: 0; }
227
240
  .stream-line.agent-tag .timestamp { font-weight: normal; font-size: 0.85em; color: var(--dim); white-space: nowrap; }
228
241
  .stream-line.separator { color: var(--dim); }
242
+ .stream-line.user-prompt-block {
243
+ background: rgba(59,130,246,0.08);
244
+ border-left: 8px solid var(--blue);
245
+ border-radius: 4px;
246
+ padding: 6px 10px;
247
+ margin: 4px 0;
248
+ color: var(--white);
249
+ line-height: 1.5;
250
+ }
229
251
 
230
252
  /* ── Footer ── */
231
253
  #footer {
@@ -286,6 +308,73 @@ body {
286
308
 
287
309
  /* Theme toggle button */
288
310
  #btn-theme { font-size: 14px; }
311
+
312
+ /* ── Export modal ── */
313
+ .modal-overlay {
314
+ position: fixed; inset: 0;
315
+ background: rgba(0, 0, 0, 0.6);
316
+ z-index: 10000;
317
+ display: flex; align-items: center; justify-content: center;
318
+ }
319
+ :root[data-theme="light"] .modal-overlay { background: rgba(0, 0, 0, 0.3); }
320
+ :root[data-theme="light"] .modal-session-row.selected { background: rgba(124, 58, 237, 0.2); }
321
+
322
+ .modal-box {
323
+ background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
324
+ width: 480px; max-width: 90vw; max-height: 80vh;
325
+ display: flex; flex-direction: column; overflow: hidden;
326
+ }
327
+
328
+ .modal-header {
329
+ display: flex; align-items: center; justify-content: space-between;
330
+ padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
331
+ }
332
+ .modal-title { font-size: 13px; font-weight: 600; color: var(--white); }
333
+
334
+ .modal-toolbar {
335
+ display: flex; align-items: center; gap: 4px;
336
+ padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
337
+ }
338
+ .modal-count { margin-left: auto; font-size: 11px; color: var(--dim); }
339
+
340
+ .modal-body { flex: 1; overflow-y: auto; padding: 6px 0; }
341
+
342
+ .modal-session-row {
343
+ display: flex; align-items: center; gap: 8px;
344
+ padding: 6px 12px; cursor: pointer; transition: background 0.1s; user-select: none;
345
+ }
346
+ .modal-session-row:hover { background: var(--bg2); }
347
+ .modal-session-row.selected { background: rgba(124, 58, 237, 0.15); }
348
+
349
+ .modal-checkbox {
350
+ appearance: none; width: 16px; height: 16px;
351
+ border: 1px solid var(--border); border-radius: 3px; background: var(--bg2);
352
+ cursor: pointer; position: relative; flex-shrink: 0; transition: all 0.15s;
353
+ }
354
+ .modal-checkbox:checked { background: var(--purple); border-color: var(--purple); }
355
+ .modal-checkbox:checked::after {
356
+ content: '✓'; position: absolute; inset: 0;
357
+ display: flex; align-items: center; justify-content: center;
358
+ color: var(--white); font-size: 11px; font-weight: bold;
359
+ }
360
+
361
+ .modal-session-prefix {
362
+ font-family: monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0;
363
+ }
364
+ .modal-session-info {
365
+ flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px; overflow: hidden;
366
+ }
367
+ .modal-session-project {
368
+ font-size: 12px; font-weight: 500; color: var(--text);
369
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
370
+ }
371
+ .modal-session-model { font-size: 10px; color: var(--dim); flex-shrink: 0; }
372
+ .modal-session-time { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: auto; }
373
+
374
+ .modal-footer {
375
+ display: flex; align-items: center; justify-content: flex-end; gap: 6px;
376
+ padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
377
+ }
289
378
  </style>
290
379
  </head>
291
380
  <body>
@@ -296,13 +385,14 @@ body {
296
385
  <button class="btn on" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
297
386
  <button class="btn on" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
298
387
  <button class="btn on" id="btn-hook" onclick="toggleHook()" data-tooltip="Toggle hook output">🪝 Hook</button>
388
+ <button class="btn on" id="btn-user-prompt" onclick="toggleUserPrompt()" data-tooltip="Toggle user prompt">👤 Prompt</button>
299
389
  <span class="sep">│</span>
300
390
  <button class="btn on" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
301
391
  <button class="btn" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
302
392
  <span class="sep">│</span>
303
393
  <span id="session-info">Connecting...</span>
304
394
  <div class="auto">
305
- <button class="btn btn-icon" id="btn-export" onclick="exportHTML()" data-tooltip="导出 HTML">💾</button>
395
+ <button class="btn btn-icon" id="btn-export" onclick="openExportModal()" data-tooltip="导出 HTML">💾</button>
306
396
  <button class="btn btn-icon" id="btn-theme" onclick="toggleTheme()" data-tooltip="Toggle theme">🌙</button>
307
397
  <button class="btn on" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
308
398
  <span class="sep">│</span>
@@ -341,6 +431,25 @@ body {
341
431
  <span id="footer-version" style="margin-left:auto;font-size:10px;color:var(--dim)"></span>
342
432
  </div>
343
433
 
434
+ <div id="export-modal" class="modal-overlay" style="display:none">
435
+ <div class="modal-box">
436
+ <div class="modal-header">
437
+ <span class="modal-title">选择要导出的会话</span>
438
+ <button class="btn btn-icon" onclick="closeExportModal()" data-tooltip="关闭">✕</button>
439
+ </div>
440
+ <div class="modal-toolbar">
441
+ <button class="btn" onclick="exportModalToggleAll(true)">全选</button>
442
+ <button class="btn" onclick="exportModalToggleAll(false)">取消全选</button>
443
+ <span class="modal-count" id="modal-selected-count">已选 0 / 0</span>
444
+ </div>
445
+ <div class="modal-body" id="modal-session-list"></div>
446
+ <div class="modal-footer">
447
+ <button class="btn" onclick="closeExportModal()">取消</button>
448
+ <button class="btn on" id="modal-export-btn" onclick="confirmExport()" disabled>导出</button>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
344
453
  <script src="vendor/highlight.min.js"></script>
345
454
  <script src="vendor/marked.min.js"></script>
346
455
  <script src="vendor/purify.min.js"></script>
@@ -385,11 +494,13 @@ class LRUCache {
385
494
  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; }
386
495
  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; }
387
496
  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); } }
497
+ delete(key) { return this.map.delete(key); }
498
+ keys() { return this.map.keys(); }
388
499
  }
389
500
  const seenToolIDs = new LRUCache(20000);
390
501
  const toolNameMap = new LRUCache(2000);
391
- const agentActivity = new Map(); // "sessionID:agentID" → { toolName, content }
392
- const taskDescriptions = new Map(); // toolID → description string
502
+ const agentActivity = new LRUCache(500); // "sessionID:agentID" → { toolName, content }
503
+ const taskDescriptions = new LRUCache(2000); // toolID → description string
393
504
  const MAX_DESC_STORE = 200;
394
505
  let filters = new Map();
395
506
  let visibleFilterCount = 0;
@@ -399,6 +510,7 @@ let showToolInput = true;
399
510
  let showToolOutput = true;
400
511
  let showText = true;
401
512
  let showHook = true;
513
+ let showUserPrompt = true;
402
514
  let showActivity = true;
403
515
  let autoDiscovery = true;
404
516
  let appVersion = '';
@@ -526,7 +638,7 @@ function connect() {
526
638
  reconnectTimer = setTimeout(connect, reconnectDelay);
527
639
  reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
528
640
  };
529
- ws.onerror = () => {};
641
+ ws.onerror = (e) => { console.warn('[ws] connection error', e); };
530
642
 
531
643
  ws.onmessage = (e) => {
532
644
  lastMsgTime = Date.now();
@@ -583,6 +695,14 @@ function sendCmd(action, extra = {}) {
583
695
 
584
696
  function handleSnapshot(payload) {
585
697
  autoDiscovery = payload.autoDiscovery;
698
+ const incomingIDs = new Set((payload.sessions || []).map(s => s.id));
699
+ for (let i = sessions.length - 1; i >= 0; i--) {
700
+ const s = sessions[i];
701
+ if (!incomingIDs.has(s.id) && !s.pinned) {
702
+ sessions.splice(i, 1);
703
+ sessionsMap.delete(s.id);
704
+ }
705
+ }
586
706
  for (const s of (payload.sessions || [])) {
587
707
  if (hiddenSessionIDs.has(s.id)) continue;
588
708
  let session = sessionsMap.get(s.id);
@@ -672,10 +792,16 @@ function handleNewBgTask(payload) {
672
792
  }
673
793
 
674
794
  function handleSessionRemoved(payload) {
675
- const idx = sessions.findIndex(s => s.id === payload.sessionID);
795
+ const sid = payload.sessionID;
796
+ const s = sessionsMap.get(sid);
797
+ if (s) {
798
+ for (const a of s.agents) agentActivity.delete(sid + ':' + a.id);
799
+ for (const t of s.tasks) taskDescriptions.delete(t.id);
800
+ }
801
+ const idx = sessions.findIndex(s => s.id === sid);
676
802
  if (idx >= 0) {
677
803
  sessions.splice(idx, 1);
678
- sessionsMap.delete(payload.sessionID);
804
+ sessionsMap.delete(sid);
679
805
  }
680
806
  updateFilters();
681
807
  rebuildNodes();
@@ -769,7 +895,7 @@ function isItemVisible(item) {
769
895
  case 'tool_output': return showToolOutput;
770
896
  case 'text': return showText;
771
897
  case 'hook_output': return showHook;
772
- case 'user_text': return false;
898
+ case 'user_text': return showUserPrompt;
773
899
  default: return true;
774
900
  }
775
901
  }
@@ -919,7 +1045,7 @@ function getNodeHTML(node, idx) {
919
1045
  }
920
1046
 
921
1047
  if (node.type === 'session') {
922
- const displayName = node.title || folderName(node.projectPath) || node.id.slice(0, 14);
1048
+ const displayName = folderName(node.projectPath) || node.title || node.id.slice(0, 14);
923
1049
  const parts = [];
924
1050
  if (node.model) parts.push(`🧠 ${esc(node.model)}`);
925
1051
  const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
@@ -930,7 +1056,7 @@ function getNodeHTML(node, idx) {
930
1056
  return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
931
1057
  <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
932
1058
  <div class="tree-node">
933
- <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)}
1059
+ <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} <span class="session-prefix" style="color:${idColor(node.colorRank)}" data-sid="${esc(node.id)}" onmouseenter="showSessionIdTip(this)" onmouseleave="hideSessionIdTip(this)">${esc(node.id.split('-')[0].toUpperCase())}</span> ${esc(displayName)}
934
1060
  ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
935
1061
  ${subInfo}
936
1062
  ${timeHtml}
@@ -1222,6 +1348,10 @@ function renderItem(item) {
1222
1348
  for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
1223
1349
  break;
1224
1350
  }
1351
+ case 'user_text':
1352
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}👤 User Prompt</span>${tsHtml}`, html: true, sessionID: sid });
1353
+ lines.push({ cls: 'stream-line user-prompt-block md-content', text: mdRender(item.content), html: true, sessionID: sid });
1354
+ break;
1225
1355
  }
1226
1356
 
1227
1357
  lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
@@ -1243,6 +1373,7 @@ function refreshButtons() {
1243
1373
  document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
1244
1374
  document.getElementById('btn-text').classList.toggle('on', showText);
1245
1375
  document.getElementById('btn-hook').classList.toggle('on', showHook);
1376
+ document.getElementById('btn-user-prompt').classList.toggle('on', showUserPrompt);
1246
1377
  document.getElementById('btn-activity').classList.toggle('on', showActivity);
1247
1378
  document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
1248
1379
  document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
@@ -1253,7 +1384,7 @@ function refreshButtons() {
1253
1384
  if (sessions.length === 0) info = 'Waiting...';
1254
1385
  else if (sessions.length === 1) {
1255
1386
  const s = sessions[0];
1256
- info = (s.title || folderName(s.projectPath) || s.id.slice(0, 14));
1387
+ info = (folderName(s.projectPath) || s.title || s.id.slice(0, 14));
1257
1388
  } else info = sessions.length + ' sessions';
1258
1389
  if (!autoDiscovery) info += ' [paused]';
1259
1390
  sessionInfo.textContent = info;
@@ -1277,6 +1408,50 @@ function refreshButtons() {
1277
1408
  }
1278
1409
  }
1279
1410
 
1411
+ // ══════════════════════════════════════════════════════════════════════════════
1412
+ // Session ID tooltip
1413
+ // ══════════════════════════════════════════════════════════════════════════════
1414
+
1415
+ let sessionIdTipTimer = null;
1416
+ let sessionIdTipEl = null;
1417
+ function showSessionIdTip(el) {
1418
+ hideAllSessionIdTips();
1419
+ const sid = el.getAttribute('data-sid');
1420
+ if (!sid) return;
1421
+ sessionIdTipTimer = setTimeout(() => {
1422
+ const rect = el.getBoundingClientRect();
1423
+ const tip = document.createElement('div');
1424
+ tip.className = 'session-id-tip';
1425
+ tip.style.top = (rect.bottom + 4) + 'px';
1426
+ tip.style.left = rect.left + 'px';
1427
+ tip.innerHTML = `<code>${esc(sid)}</code><button class="tip-copy-btn" onclick="event.stopPropagation();copySessionId(this)">Copy</button>`;
1428
+ tip.onmouseenter = () => clearTimeout(sessionIdTipTimer);
1429
+ tip.onmouseleave = () => { hideAllSessionIdTips(); };
1430
+ document.body.appendChild(tip);
1431
+ sessionIdTipEl = tip;
1432
+ el._tip = tip;
1433
+ }, 300);
1434
+ }
1435
+ function hideSessionIdTip(el) {
1436
+ sessionIdTipTimer = setTimeout(() => {
1437
+ if (el._tip) { el._tip.remove(); el._tip = null; }
1438
+ sessionIdTipEl = null;
1439
+ }, 200);
1440
+ }
1441
+ function hideAllSessionIdTips() {
1442
+ clearTimeout(sessionIdTipTimer);
1443
+ document.querySelectorAll('.session-id-tip').forEach(t => t.remove());
1444
+ sessionIdTipEl = null;
1445
+ }
1446
+ function copySessionId(btn) {
1447
+ const code = btn.parentElement.querySelector('code');
1448
+ if (!code) return;
1449
+ navigator.clipboard.writeText(code.textContent).then(() => {
1450
+ btn.textContent = 'Copied!';
1451
+ setTimeout(() => { btn.closest('.session-id-tip')?.remove(); }, 800);
1452
+ });
1453
+ }
1454
+
1280
1455
  // ══════════════════════════════════════════════════════════════════════════════
1281
1456
  // Actions
1282
1457
  // ══════════════════════════════════════════════════════════════════════════════
@@ -1436,6 +1611,8 @@ function toggleText() { showText = !showText; needsFullRender = true;
1436
1611
  visibleDirty = true; renderStream(); refreshButtons(); }
1437
1612
  function toggleHook() { showHook = !showHook; needsFullRender = true;
1438
1613
  visibleDirty = true; renderStream(); refreshButtons(); }
1614
+ function toggleUserPrompt() { showUserPrompt = !showUserPrompt; needsFullRender = true;
1615
+ visibleDirty = true; renderStream(); refreshButtons(); }
1439
1616
  function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
1440
1617
  function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1441
1618
  function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
@@ -1580,7 +1757,7 @@ function folderName(projectPath) {
1580
1757
  }
1581
1758
 
1582
1759
  function esc(s) {
1583
- return (s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;');
1760
+ return (s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;').replace(/\\/g, '&#x5C;');
1584
1761
  }
1585
1762
 
1586
1763
  function fmtDur(ms) {
@@ -1633,17 +1810,122 @@ function scheduleRender() {
1633
1810
  }
1634
1811
  }
1635
1812
 
1813
+ // ══════════════════════════════════════════════════════════════════════════════
1814
+ // Export modal — session selection
1815
+ // ══════════════════════════════════════════════════════════════════════════════
1816
+
1817
+ let exportModalSelected = new Set();
1818
+
1819
+ function openExportModal() {
1820
+ if (sessions.length === 0) {
1821
+ const btn = document.getElementById('btn-export');
1822
+ const orig = btn.textContent;
1823
+ btn.textContent = '✕ 无会话';
1824
+ setTimeout(() => { btn.textContent = orig; }, 2000);
1825
+ return;
1826
+ }
1827
+ exportModalSelected = new Set(sessions.map(s => s.id));
1828
+ renderModalSessionList();
1829
+ updateModalCount();
1830
+ document.getElementById('export-modal').style.display = 'flex';
1831
+ }
1832
+
1833
+ function renderModalSessionList() {
1834
+ const listEl = document.getElementById('modal-session-list');
1835
+ const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
1836
+ listEl.innerHTML = sorted.map(s => {
1837
+ const color = idColor(s.colorRank || 0);
1838
+ const project = folderName(s.projectPath) || s.projectPath || '';
1839
+ const prefix = s.id.split('-')[0].toUpperCase();
1840
+ const model = s.model || '';
1841
+ const time = formatTime(s.birthtimeMs);
1842
+ const checked = exportModalSelected.has(s.id) ? 'checked' : '';
1843
+ const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
1844
+ return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
1845
+ <input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
1846
+ <span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
1847
+ <div class="modal-session-info">
1848
+ <span class="modal-session-project">${esc(project)}</span>
1849
+ ${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
1850
+ </div>
1851
+ ${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
1852
+ </div>`;
1853
+ }).join('\n');
1854
+ }
1855
+
1856
+ function toggleModalSession(sid, rowEl) {
1857
+ if (exportModalSelected.has(sid)) {
1858
+ exportModalSelected.delete(sid);
1859
+ } else {
1860
+ exportModalSelected.add(sid);
1861
+ }
1862
+ const checkbox = rowEl.querySelector('.modal-checkbox');
1863
+ checkbox.checked = exportModalSelected.has(sid);
1864
+ rowEl.classList.toggle('selected', exportModalSelected.has(sid));
1865
+ updateModalCount();
1866
+ }
1867
+
1868
+ function exportModalToggleAll(selectAll) {
1869
+ if (selectAll) {
1870
+ exportModalSelected = new Set(sessions.map(s => s.id));
1871
+ } else {
1872
+ exportModalSelected.clear();
1873
+ }
1874
+ document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
1875
+ const sid = row.dataset.sid;
1876
+ const checkbox = row.querySelector('.modal-checkbox');
1877
+ checkbox.checked = exportModalSelected.has(sid);
1878
+ row.classList.toggle('selected', exportModalSelected.has(sid));
1879
+ });
1880
+ updateModalCount();
1881
+ }
1882
+
1883
+ function updateModalCount() {
1884
+ const total = sessions.length;
1885
+ const selected = exportModalSelected.size;
1886
+ document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
1887
+ document.getElementById('modal-export-btn').disabled = selected === 0;
1888
+ }
1889
+
1890
+ function closeExportModal() {
1891
+ document.getElementById('export-modal').style.display = 'none';
1892
+ exportModalSelected.clear();
1893
+ }
1894
+
1895
+ // Esc key closes modal
1896
+ document.addEventListener('keydown', (e) => {
1897
+ if (e.key === 'Escape') {
1898
+ const modal = document.getElementById('export-modal');
1899
+ if (modal.style.display !== 'none') {
1900
+ closeExportModal();
1901
+ e.stopPropagation();
1902
+ }
1903
+ }
1904
+ });
1905
+
1906
+ function confirmExport() {
1907
+ if (exportModalSelected.size === 0) return;
1908
+ const selectedIds = new Set(exportModalSelected);
1909
+ closeExportModal();
1910
+ exportHTML(selectedIds);
1911
+ }
1912
+
1636
1913
  // ══════════════════════════════════════════════════════════════════════════════
1637
1914
  // Export HTML
1638
1915
  // ══════════════════════════════════════════════════════════════════════════════
1639
1916
 
1640
- function exportHTML() {
1917
+ function exportHTML(selectedIds = null) {
1641
1918
  const theme = document.documentElement.getAttribute('data-theme') || 'dark';
1642
1919
 
1643
- // Collect unique sessions from visible items
1644
- const sidsInExport = new Set();
1645
- for (const item of visibleItems) {
1646
- if (item.sessionID) sidsInExport.add(item.sessionID);
1920
+ // Collect sessions to export
1921
+ let sidsInExport;
1922
+ if (selectedIds) {
1923
+ sidsInExport = selectedIds;
1924
+ } else {
1925
+ sidsInExport = new Set();
1926
+ for (const item of visibleItems) {
1927
+ if (item.sessionID) sidsInExport.add(item.sessionID);
1928
+ }
1647
1929
  }
1648
1930
  const exportSessions = [];
1649
1931
  for (const sid of sidsInExport) {
@@ -1697,6 +1979,13 @@ ${items}
1697
1979
  clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
1698
1980
  clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
1699
1981
 
1982
+ // Filter out stream lines from non-selected sessions
1983
+ if (selectedIds) {
1984
+ clone.querySelectorAll('[data-session-id]').forEach(el => {
1985
+ if (!selectedIds.has(el.dataset.sessionId)) el.remove();
1986
+ });
1987
+ }
1988
+
1700
1989
  // Get the cleaned innerHTML
1701
1990
  const streamHTML = clone.innerHTML;
1702
1991
 
@@ -196,11 +196,21 @@ function formatTokenCount(n) {
196
196
  return String(n);
197
197
  }
198
198
 
199
+ const CONTEXT_WINDOWS = {
200
+ 'claude-opus-4-7': 1000000,
201
+ 'claude-opus-4-6': 200000,
202
+ 'claude-sonnet-4-6': 1000000,
203
+ 'claude-sonnet-4-5': 200000,
204
+ 'claude-haiku-4-5': 200000,
205
+ 'claude-haiku-4': 200000,
206
+ };
207
+
199
208
  function contextWindowFor(model) {
200
209
  if (!model) return 200000;
201
- if (model.startsWith('claude-opus-4-7') || model.startsWith('claude-sonnet-4-6')) return 1000000;
202
- if (model.startsWith('claude-haiku-4-5') || model.startsWith('claude-opus-4-6') ||
203
- model.startsWith('claude-sonnet-4-5') || model.startsWith('claude-haiku-4')) return 200000;
210
+ for (const [prefix, win] of Object.entries(CONTEXT_WINDOWS)) {
211
+ if (model.startsWith(prefix)) return win;
212
+ }
213
+ if (/claude-(opus|sonnet)/.test(model)) return 1000000;
204
214
  return 200000;
205
215
  }
206
216
 
@@ -255,7 +255,9 @@ class DashboardServer {
255
255
  this.clients.delete(ws);
256
256
  });
257
257
 
258
- ws.on('error', () => {});
258
+ ws.on('error', (err) => {
259
+ if (this.debugAll) console.error('[server] WS client error:', err.message);
260
+ });
259
261
 
260
262
  this.sendSnapshot(ws);
261
263
  this.sendItemBatch(ws);
@@ -396,7 +398,10 @@ class DashboardServer {
396
398
  try {
397
399
  const result = cp.execSync(cmd, { encoding: 'utf-8' }).trim();
398
400
  if (!result) return false;
399
- const pids = result.split('\n').map(s => s.trim()).filter(Boolean);
401
+ let pids = result.split('\n').map(s => s.trim()).filter(Boolean);
402
+ if (process.platform === 'win32') {
403
+ pids = pids.map(line => line.split(/\s+/).pop());
404
+ }
400
405
 
401
406
  // Ask user for confirmation before killing
402
407
  const confirmed = await askYesNo(`Port ${port} is occupied by process(es) ${pids.join(', ')}. Kill them? [y/N] `);
@@ -539,7 +544,14 @@ class DashboardServer {
539
544
  stop() {
540
545
  if (this._contextCleanupTimer) clearInterval(this._contextCleanupTimer);
541
546
  if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
542
- if (this._flushTimer) clearTimeout(this._flushTimer);
547
+ if (this._flushTimer) {
548
+ clearTimeout(this._flushTimer);
549
+ this._flushTimer = null;
550
+ }
551
+ if (this._pendingItems.length > 0) {
552
+ this.broadcast('itemBatch', this._pendingItems);
553
+ this._pendingItems = [];
554
+ }
543
555
  if (this.wss) this.wss.close();
544
556
  if (this.server) this.server.close();
545
557
  if (this.watcher) this.watcher.stop();
@@ -549,6 +561,12 @@ class DashboardServer {
549
561
 
550
562
  async function startServer(options = {}) {
551
563
  const ds = new DashboardServer(options);
564
+ const shutdown = () => {
565
+ ds.stop();
566
+ process.exit(0);
567
+ };
568
+ process.on('SIGINT', shutdown);
569
+ process.on('SIGTERM', shutdown);
552
570
  return ds.start(options);
553
571
  }
554
572
 
@@ -33,9 +33,22 @@ function getClaudeProjectsDir() {
33
33
  }
34
34
 
35
35
  const _projectPathCache = new Map();
36
+ const _PROJECT_PATH_CACHE_MAX = 500;
37
+
38
+ function _projectPathCacheSet(key, value) {
39
+ _projectPathCache.set(key, value);
40
+ if (_projectPathCache.size > _PROJECT_PATH_CACHE_MAX) {
41
+ _projectPathCache.delete(_projectPathCache.keys().next().value);
42
+ }
43
+ }
36
44
 
37
45
  async function resolveProjectPath(encoded) {
38
- if (_projectPathCache.has(encoded)) return _projectPathCache.get(encoded);
46
+ if (_projectPathCache.has(encoded)) {
47
+ const v = _projectPathCache.get(encoded);
48
+ _projectPathCache.delete(encoded);
49
+ _projectPathCacheSet(encoded, v);
50
+ return v;
51
+ }
39
52
  let s = encoded;
40
53
  if (s.startsWith('-')) s = s.slice(1);
41
54
  if (!s) return '';
@@ -48,7 +61,7 @@ async function resolveProjectPath(encoded) {
48
61
  // Strategy 1: try direct decoded path on disk (handles dots correctly)
49
62
  try {
50
63
  await fsp.access('/' + directDecoded);
51
- _projectPathCache.set(encoded, directDecoded);
64
+ _projectPathCacheSet(encoded, directDecoded);
52
65
  return directDecoded;
53
66
  } catch {}
54
67
 
@@ -77,7 +90,7 @@ async function resolveProjectPath(encoded) {
77
90
  try {
78
91
  await fsp.access(testPath);
79
92
  const result = `${pathPart}/${dirPart}`;
80
- _projectPathCache.set(encoded, result);
93
+ _projectPathCacheSet(encoded, result);
81
94
  return result;
82
95
  } catch {
83
96
  // Path doesn't exist, try next combination
@@ -85,7 +98,7 @@ async function resolveProjectPath(encoded) {
85
98
  }
86
99
 
87
100
  // Fallback: return direct decoded path (correct even if path no longer exists on disk)
88
- _projectPathCache.set(encoded, directDecoded);
101
+ _projectPathCacheSet(encoded, directDecoded);
89
102
  return directDecoded;
90
103
  }
91
104
 
@@ -609,10 +622,10 @@ class Watcher extends EventEmitter {
609
622
  const pending = this.pendingSubagents.get(sessionID) || [];
610
623
  if (!pending.includes(p)) pending.push(p);
611
624
  this.pendingSubagents.set(sessionID, pending);
612
- return;
625
+ return Promise.resolve();
613
626
  }
614
627
 
615
- this._registerSubagent(session, sessionID, agentID, p).catch(err => {
628
+ return this._registerSubagent(session, sessionID, agentID, p).catch(err => {
616
629
  if (this.debug) console.error('[watcher] _registerSubagent error:', err.message);
617
630
  });
618
631
  }
@@ -914,7 +927,7 @@ class Watcher extends EventEmitter {
914
927
 
915
928
  async _initializeSessionReading(sessions) {
916
929
  let shouldSkip = this.skipHistory;
917
- if (!shouldSkip) {
930
+ if (!shouldSkip && !this._sessionID) {
918
931
  let totalEstimate = 0;
919
932
  for (const session of sessions) {
920
933
  totalEstimate += await this._estimateFileLines(session.mainFile);
@@ -1007,15 +1020,15 @@ class Watcher extends EventEmitter {
1007
1020
  await prev;
1008
1021
 
1009
1022
  let handle;
1010
- const pos = this.filePositions.get(filePath) || 0;
1023
+ let pos = this.filePositions.get(filePath) || 0;
1011
1024
  let newPos = pos;
1012
1025
  try {
1013
1026
  handle = await fsp.open(filePath, 'r');
1014
1027
  const stats = await handle.stat();
1015
1028
  if (pos > stats.size) {
1016
- // File was truncated — reset position to 0 so we re-read from the start
1029
+ // File was truncated — reset position to 0 and re-read from the start
1030
+ pos = 0;
1017
1031
  this.filePositions.set(filePath, 0);
1018
- await handle.close(); handle = null; return;
1019
1032
  }
1020
1033
  if (pos === stats.size) { await handle.close(); handle = null; return; }
1021
1034
 
@@ -1027,7 +1040,8 @@ class Watcher extends EventEmitter {
1027
1040
  const buf = Buffer.alloc(MaxReadChunk);
1028
1041
 
1029
1042
  while (true) {
1030
- const readFrom = newPos + carryOverBytes;
1043
+ const prevCarryOverBytes = carryOverBytes;
1044
+ const readFrom = newPos + prevCarryOverBytes;
1031
1045
  if (readFrom >= fileSize) break;
1032
1046
 
1033
1047
  const readLen = Math.min(MaxReadChunk, fileSize - readFrom);
@@ -1055,7 +1069,9 @@ class Watcher extends EventEmitter {
1055
1069
  carryOverBytes = 0;
1056
1070
  }
1057
1071
 
1058
- let chunkBytes = 0;
1072
+ // Start from prevCarryOverBytes so carryOver bytes from the previous
1073
+ // iteration are counted exactly once toward newPos advancement.
1074
+ let chunkBytes = prevCarryOverBytes;
1059
1075
 
1060
1076
  for (let i = 0; i < rawLines.length; i++) {
1061
1077
  let rawLine = rawLines[i];
@@ -1155,6 +1171,7 @@ class Watcher extends EventEmitter {
1155
1171
  try { await fsp.access(p); } catch {
1156
1172
  this.filePositions.delete(p);
1157
1173
  this.fileContexts.delete(p);
1174
+ this._readLocks.delete(p);
1158
1175
  }
1159
1176
  }
1160
1177
 
@@ -1172,6 +1189,10 @@ class Watcher extends EventEmitter {
1172
1189
  this.emit('broadcast', 'sessionRemoved', { sessionID });
1173
1190
  }
1174
1191
  }
1192
+
1193
+ for (const sid of this.pendingSubagents.keys()) {
1194
+ if (!this.sessions.has(sid)) this.pendingSubagents.delete(sid);
1195
+ }
1175
1196
  }
1176
1197
 
1177
1198
  // =========================================================================
@@ -1190,15 +1211,20 @@ class Watcher extends EventEmitter {
1190
1211
  if (p) {
1191
1212
  this.fileContexts.delete(p);
1192
1213
  this.filePositions.delete(p);
1214
+ this._readLocks.delete(p);
1193
1215
  const timer = this.debounceTimers.get(p);
1194
1216
  if (timer) {
1195
1217
  clearTimeout(timer);
1196
1218
  this.debounceTimers.delete(p);
1197
1219
  }
1220
+ if (this.watcher) {
1221
+ this.watcher.unwatch(p);
1222
+ }
1198
1223
  }
1199
1224
  }
1200
1225
  }
1201
1226
  this.sessions.delete(sessionID);
1227
+ this.pendingSubagents.delete(sessionID);
1202
1228
  if (session) {
1203
1229
  this.emit('sessionRemoved', { sessionID });
1204
1230
  }
@@ -1237,7 +1263,11 @@ function createWalkDir(readdirFn) {
1237
1263
  callback(fullPath, stats);
1238
1264
  }
1239
1265
  }
1240
- } catch {}
1266
+ } catch (err) {
1267
+ if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
1268
+ console.error(`[watcher] _walkDir error on ${dir}: ${err.message}`);
1269
+ }
1270
+ }
1241
1271
  };
1242
1272
  return walk;
1243
1273
  }