claude-code-watch 0.0.8 → 0.0.10
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/package.json +1 -1
- package/public/index.html +57 -54
- package/src/parser/parser.js +26 -9
- package/src/server/server.js +43 -4
- package/src/watcher/watcher.js +206 -126
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -338,43 +338,22 @@ let lastMsgTime = 0;
|
|
|
338
338
|
let staleCheckTimer = null;
|
|
339
339
|
|
|
340
340
|
let sessions = [];
|
|
341
|
+
let sessionsMap = new Map(); // id -> session, for O(1) lookups
|
|
341
342
|
let treeNodes = [];
|
|
342
343
|
let treeCursor = 0;
|
|
343
344
|
let streamItems = [];
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
for (let i = 0; i < evictCount; i++) {
|
|
357
|
-
seenToolIDsSet.delete(seenToolIDsKeys[i]);
|
|
358
|
-
}
|
|
359
|
-
seenToolIDsKeys.splice(0, evictCount);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
const toolNameMapMax = 2000;
|
|
363
|
-
let toolNameMap = new Map(); // toolID -> toolName
|
|
364
|
-
let toolNameMapKeys = [];
|
|
365
|
-
|
|
366
|
-
function toolNameMapSet(toolID, toolName) {
|
|
367
|
-
if (toolNameMap.has(toolID)) return;
|
|
368
|
-
toolNameMap.set(toolID, toolName);
|
|
369
|
-
toolNameMapKeys.push(toolID);
|
|
370
|
-
if (toolNameMapKeys.length > toolNameMapMax) {
|
|
371
|
-
const evictCount = toolNameMapKeys.length >> 1;
|
|
372
|
-
for (let i = 0; i < evictCount; i++) {
|
|
373
|
-
toolNameMap.delete(toolNameMapKeys[i]);
|
|
374
|
-
}
|
|
375
|
-
toolNameMapKeys.splice(0, evictCount);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
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);
|
|
378
357
|
let filters = new Map();
|
|
379
358
|
|
|
380
359
|
let showThinking = true;
|
|
@@ -407,6 +386,7 @@ const MAX_ITEMS = 3000;
|
|
|
407
386
|
const MAX_LINES = 50;
|
|
408
387
|
let renderedItemCount = 0;
|
|
409
388
|
let needsFullRender = true;
|
|
389
|
+
visibleDirty = true;
|
|
410
390
|
|
|
411
391
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
412
392
|
// Markdown renderer (marked + highlight.js)
|
|
@@ -525,7 +505,7 @@ function sendCmd(action, extra = {}) {
|
|
|
525
505
|
function handleSnapshot(payload) {
|
|
526
506
|
autoDiscovery = payload.autoDiscovery;
|
|
527
507
|
for (const s of (payload.sessions || [])) {
|
|
528
|
-
let session =
|
|
508
|
+
let session = sessionsMap.get(s.id);
|
|
529
509
|
if (!session) {
|
|
530
510
|
session = {
|
|
531
511
|
id: s.id, projectPath: s.projectPath, title: '',
|
|
@@ -534,6 +514,7 @@ function handleSnapshot(payload) {
|
|
|
534
514
|
lastActivity: Date.now(),
|
|
535
515
|
};
|
|
536
516
|
sessions.push(session);
|
|
517
|
+
sessionsMap.set(session.id, session);
|
|
537
518
|
session.agents.push({ id: '', name: 'Main', type: 'main' });
|
|
538
519
|
}
|
|
539
520
|
session.lastActivity = Date.now();
|
|
@@ -555,26 +536,30 @@ function handleSnapshot(payload) {
|
|
|
555
536
|
updateFilters();
|
|
556
537
|
rebuildNodes();
|
|
557
538
|
needsFullRender = true;
|
|
539
|
+
visibleDirty = true;
|
|
558
540
|
scheduleRender();
|
|
559
541
|
}
|
|
560
542
|
|
|
561
543
|
function handleNewSession(payload) {
|
|
562
|
-
if (
|
|
563
|
-
|
|
544
|
+
if (sessionsMap.has(payload.sessionID)) return;
|
|
545
|
+
const session = {
|
|
564
546
|
id: payload.sessionID, projectPath: payload.projectPath,
|
|
565
547
|
title: '', folder: folderName(payload.projectPath), model: '',
|
|
566
548
|
agents: [{ id: '', name: 'Main', type: 'main' }],
|
|
567
549
|
tasks: [], collapsed: false, pinned: false,
|
|
568
550
|
lastActivity: Date.now(),
|
|
569
|
-
}
|
|
551
|
+
};
|
|
552
|
+
sessions.push(session);
|
|
553
|
+
sessionsMap.set(session.id, session);
|
|
570
554
|
updateFilters();
|
|
571
555
|
rebuildNodes();
|
|
572
556
|
needsFullRender = true;
|
|
557
|
+
visibleDirty = true;
|
|
573
558
|
scheduleRender();
|
|
574
559
|
}
|
|
575
560
|
|
|
576
561
|
function handleNewAgent(payload) {
|
|
577
|
-
const s =
|
|
562
|
+
const s = sessionsMap.get(payload.sessionID);
|
|
578
563
|
if (!s || s.agents.find(a => a.id === payload.agentID)) return;
|
|
579
564
|
s.agents.push({
|
|
580
565
|
id: payload.agentID,
|
|
@@ -584,11 +569,12 @@ function handleNewAgent(payload) {
|
|
|
584
569
|
updateFilters();
|
|
585
570
|
rebuildNodes();
|
|
586
571
|
needsFullRender = true;
|
|
572
|
+
visibleDirty = true;
|
|
587
573
|
scheduleRender();
|
|
588
574
|
}
|
|
589
575
|
|
|
590
576
|
function handleNewBgTask(payload) {
|
|
591
|
-
const s =
|
|
577
|
+
const s = sessionsMap.get(payload.sessionID);
|
|
592
578
|
if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
|
|
593
579
|
s.tasks.push({
|
|
594
580
|
id: payload.toolID, parentAgentID: payload.parentAgentID,
|
|
@@ -608,6 +594,7 @@ function handleSessionRemoved(payload) {
|
|
|
608
594
|
updateFilters();
|
|
609
595
|
rebuildNodes();
|
|
610
596
|
needsFullRender = true;
|
|
597
|
+
visibleDirty = true;
|
|
611
598
|
scheduleRender();
|
|
612
599
|
}
|
|
613
600
|
|
|
@@ -617,13 +604,13 @@ function handleSessionRemoved(payload) {
|
|
|
617
604
|
|
|
618
605
|
function handleItem(item) {
|
|
619
606
|
if (item.type === 'session_title') {
|
|
620
|
-
const s =
|
|
607
|
+
const s = sessionsMap.get(item.sessionID);
|
|
621
608
|
if (s) { s.title = item.content.slice(0, 30); }
|
|
622
609
|
scheduleRender();
|
|
623
610
|
return;
|
|
624
611
|
}
|
|
625
612
|
// Update activity
|
|
626
|
-
const s =
|
|
613
|
+
const s = sessionsMap.get(item.sessionID);
|
|
627
614
|
if (s) s.lastActivity = Date.now();
|
|
628
615
|
pushItem(item);
|
|
629
616
|
scheduleRender();
|
|
@@ -632,7 +619,7 @@ function handleItem(item) {
|
|
|
632
619
|
function handleItemBatch(items) {
|
|
633
620
|
for (const item of items) {
|
|
634
621
|
if (item.type === 'session_title') {
|
|
635
|
-
const s =
|
|
622
|
+
const s = sessionsMap.get(item.sessionID);
|
|
636
623
|
if (s) { s.title = item.content.slice(0, 30); }
|
|
637
624
|
continue;
|
|
638
625
|
}
|
|
@@ -646,24 +633,28 @@ function pushItem(item) {
|
|
|
646
633
|
// to avoid divergence between frontend accumulation and server tracking
|
|
647
634
|
|
|
648
635
|
if (item.model) {
|
|
649
|
-
const s =
|
|
636
|
+
const s = sessionsMap.get(item.sessionID);
|
|
650
637
|
if (s) s.model = item.model;
|
|
651
638
|
}
|
|
652
639
|
|
|
653
640
|
if (item.type === 'tool_input' && item.toolID && item.toolName) {
|
|
654
|
-
|
|
641
|
+
toolNameMap.set(item.toolID, item.toolName);
|
|
655
642
|
}
|
|
656
643
|
|
|
657
644
|
if (item.toolID) {
|
|
658
645
|
const key = `${item.toolID}:${item.type}`;
|
|
659
|
-
if (
|
|
660
|
-
|
|
646
|
+
if (seenToolIDs.has(key)) return;
|
|
647
|
+
seenToolIDs.set(key, true);
|
|
661
648
|
}
|
|
662
649
|
|
|
663
650
|
streamItems.push(item);
|
|
664
651
|
if (streamItems.length > MAX_ITEMS) {
|
|
665
652
|
streamItems = streamItems.slice(-MAX_ITEMS);
|
|
666
|
-
|
|
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);
|
|
667
658
|
}
|
|
668
659
|
}
|
|
669
660
|
|
|
@@ -830,7 +821,13 @@ function isSessionActive(session) {
|
|
|
830
821
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
831
822
|
|
|
832
823
|
function renderStream() {
|
|
833
|
-
|
|
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;
|
|
834
831
|
const wasAutoScroll = autoScroll;
|
|
835
832
|
|
|
836
833
|
if (needsFullRender || renderedItemCount > visible.length) {
|
|
@@ -1131,11 +1128,16 @@ function removeSelectedSession() {
|
|
|
1131
1128
|
// Toggles
|
|
1132
1129
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
1133
1130
|
|
|
1134
|
-
function toggleThinking() { showThinking = !showThinking; needsFullRender = true;
|
|
1135
|
-
|
|
1136
|
-
function
|
|
1137
|
-
|
|
1138
|
-
function
|
|
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(); }
|
|
1139
1141
|
function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
|
|
1140
1142
|
function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
|
|
1141
1143
|
function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
|
|
@@ -1276,6 +1278,7 @@ function fmtTok(n) {
|
|
|
1276
1278
|
|
|
1277
1279
|
function renderAll() {
|
|
1278
1280
|
needsFullRender = true;
|
|
1281
|
+
visibleDirty = true;
|
|
1279
1282
|
renderTree();
|
|
1280
1283
|
renderStream();
|
|
1281
1284
|
refreshButtons();
|
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
|
@@ -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;
|
|
@@ -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;
|
|
@@ -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)
|
|
@@ -406,6 +442,7 @@ class DashboardServer {
|
|
|
406
442
|
}
|
|
407
443
|
|
|
408
444
|
this._contextCleanupTimer = setInterval(() => this.cleanupContextMap(), CONTEXT_STALE_MS);
|
|
445
|
+
this._heartbeatTimer = setInterval(() => this.broadcast('heartbeat', null), 30000);
|
|
409
446
|
|
|
410
447
|
// Start listening and wait for server to be ready before opening browser
|
|
411
448
|
await new Promise((resolve) => {
|
|
@@ -438,6 +475,8 @@ class DashboardServer {
|
|
|
438
475
|
|
|
439
476
|
stop() {
|
|
440
477
|
if (this._contextCleanupTimer) clearInterval(this._contextCleanupTimer);
|
|
478
|
+
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
479
|
+
if (this._flushTimer) clearTimeout(this._flushTimer);
|
|
441
480
|
if (this.wss) this.wss.close();
|
|
442
481
|
if (this.server) this.server.close();
|
|
443
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
|
|
|
@@ -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')) {
|
|
@@ -461,8 +456,16 @@ class Watcher extends EventEmitter {
|
|
|
461
456
|
}
|
|
462
457
|
|
|
463
458
|
_handleFsWrite(p) {
|
|
464
|
-
|
|
465
|
-
|
|
459
|
+
let ctx = this.fileContexts.get(p);
|
|
460
|
+
|
|
461
|
+
// If fileContexts is missing (race condition during async session registration),
|
|
462
|
+
// try to infer the session context from the path
|
|
463
|
+
if (!ctx) {
|
|
464
|
+
ctx = this._inferFileContext(p);
|
|
465
|
+
if (!ctx) return;
|
|
466
|
+
// Register it so future events are found directly
|
|
467
|
+
this.fileContexts.set(p, ctx);
|
|
468
|
+
}
|
|
466
469
|
|
|
467
470
|
// Debounce
|
|
468
471
|
const existing = this.debounceTimers.get(p);
|
|
@@ -481,6 +484,28 @@ class Watcher extends EventEmitter {
|
|
|
481
484
|
this.debounceTimers.set(p, timer);
|
|
482
485
|
}
|
|
483
486
|
|
|
487
|
+
_inferFileContext(p) {
|
|
488
|
+
if (!p.endsWith('.jsonl')) return null;
|
|
489
|
+
|
|
490
|
+
// Subagent file: infer sessionID and agentID from path structure
|
|
491
|
+
if (p.includes('/subagents/')) {
|
|
492
|
+
const subagentsDir = path.dirname(p);
|
|
493
|
+
const sessionDir = path.dirname(subagentsDir);
|
|
494
|
+
const sessionID = path.basename(sessionDir);
|
|
495
|
+
const agentID = path.basename(p).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
496
|
+
const session = this.sessions.get(sessionID);
|
|
497
|
+
if (!session) return null;
|
|
498
|
+
return { sessionID, agentID };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Main session file: infer sessionID from filename
|
|
502
|
+
const basename = path.basename(p);
|
|
503
|
+
const sessionID = basename.replace(/\.jsonl$/, '');
|
|
504
|
+
const session = this.sessions.get(sessionID);
|
|
505
|
+
if (!session) return null;
|
|
506
|
+
return { sessionID, agentID: '' };
|
|
507
|
+
}
|
|
508
|
+
|
|
484
509
|
// =========================================================================
|
|
485
510
|
// New session handlers
|
|
486
511
|
// =========================================================================
|
|
@@ -505,6 +530,12 @@ class Watcher extends EventEmitter {
|
|
|
505
530
|
this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
|
|
506
531
|
}
|
|
507
532
|
|
|
533
|
+
// Read initial data from the new session's files
|
|
534
|
+
if (this.useFsnotify) {
|
|
535
|
+
await this._skipToEndOfFiles(session);
|
|
536
|
+
await this._readSessionFiles(session);
|
|
537
|
+
}
|
|
538
|
+
|
|
508
539
|
// Process any subagent files that arrived before the session was discovered
|
|
509
540
|
const pending = this.pendingSubagents.get(session.id);
|
|
510
541
|
if (pending) {
|
|
@@ -542,6 +573,13 @@ class Watcher extends EventEmitter {
|
|
|
542
573
|
|
|
543
574
|
this._addFileWatch(p, sessionID, agentID);
|
|
544
575
|
this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType });
|
|
576
|
+
|
|
577
|
+
// Read initial data from the new subagent file
|
|
578
|
+
if (this.useFsnotify) {
|
|
579
|
+
const pos = await this._findPositionForLastNLines(p, KeepRecentLines);
|
|
580
|
+
this.filePositions.set(p, pos);
|
|
581
|
+
await this._readFile(p, sessionID, agentID, agentType);
|
|
582
|
+
}
|
|
545
583
|
}
|
|
546
584
|
|
|
547
585
|
async _handleNewToolResultFile(p) {
|
|
@@ -615,6 +653,8 @@ class Watcher extends EventEmitter {
|
|
|
615
653
|
|
|
616
654
|
if (this.useFsnotify) {
|
|
617
655
|
this._registerSessionWatches(c.session);
|
|
656
|
+
await this._skipToEndOfFiles(c.session);
|
|
657
|
+
await this._readSessionFiles(c.session);
|
|
618
658
|
}
|
|
619
659
|
|
|
620
660
|
this.emit('broadcast', 'newSession', { sessionID: c.session.id, projectPath: c.session.projectPath });
|
|
@@ -703,7 +743,20 @@ class Watcher extends EventEmitter {
|
|
|
703
743
|
|
|
704
744
|
async _populateToolIndex(session) {
|
|
705
745
|
if (session.toolIndexPopulated) return;
|
|
706
|
-
|
|
746
|
+
// If another call is already populating, wait for it
|
|
747
|
+
if (session._toolIndexPromise) {
|
|
748
|
+
await session._toolIndexPromise;
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
session._toolIndexPromise = this._doPopulateToolIndex(session);
|
|
752
|
+
try {
|
|
753
|
+
await session._toolIndexPromise;
|
|
754
|
+
} finally {
|
|
755
|
+
session._toolIndexPromise = null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async _doPopulateToolIndex(session) {
|
|
707
760
|
const files = [
|
|
708
761
|
{ path: session.mainFile, agentID: '' },
|
|
709
762
|
...Object.entries(session.subagents).map(([id, p]) => ({ path: p, agentID: id })),
|
|
@@ -751,6 +804,7 @@ class Watcher extends EventEmitter {
|
|
|
751
804
|
if (this.debug) console.error('[watcher] _populateToolIndex error reading', filePath + ':', err.message);
|
|
752
805
|
}
|
|
753
806
|
}
|
|
807
|
+
session.toolIndexPopulated = true;
|
|
754
808
|
}
|
|
755
809
|
|
|
756
810
|
// =========================================================================
|
|
@@ -771,7 +825,7 @@ class Watcher extends EventEmitter {
|
|
|
771
825
|
|
|
772
826
|
this._cleanupTimer = setInterval(() => {
|
|
773
827
|
if (!this._running) return;
|
|
774
|
-
this._cleanupFilePositions();
|
|
828
|
+
this._cleanupFilePositions().catch(() => {});
|
|
775
829
|
}, CleanupInterval);
|
|
776
830
|
}
|
|
777
831
|
|
|
@@ -890,100 +944,122 @@ class Watcher extends EventEmitter {
|
|
|
890
944
|
await prev;
|
|
891
945
|
|
|
892
946
|
let handle;
|
|
893
|
-
|
|
947
|
+
const pos = this.filePositions.get(filePath) || 0;
|
|
948
|
+
let newPos = pos;
|
|
894
949
|
try {
|
|
895
950
|
handle = await fsp.open(filePath, 'r');
|
|
896
|
-
const pos = this.filePositions.get(filePath) || 0;
|
|
897
951
|
const stats = await handle.stat();
|
|
898
|
-
if (pos
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (
|
|
952
|
+
if (pos > stats.size) {
|
|
953
|
+
// File was truncated — reset position to 0 so we re-read from the start
|
|
954
|
+
this.filePositions.set(filePath, 0);
|
|
955
|
+
await handle.close(); handle = null; return;
|
|
956
|
+
}
|
|
957
|
+
if (pos === stats.size) { await handle.close(); handle = null; return; }
|
|
904
958
|
|
|
905
959
|
newPos = pos;
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
960
|
+
// Read in chunks to avoid large buffer allocations for big file deltas
|
|
961
|
+
let carryOver = ''; // incomplete trailing line from previous chunk
|
|
962
|
+
let carryOverBytes = 0; // byte length of carryOver (to avoid re-reading it)
|
|
963
|
+
const buf = Buffer.alloc(MaxReadChunk);
|
|
964
|
+
|
|
965
|
+
while (true) {
|
|
966
|
+
const currentStats = await handle.stat();
|
|
967
|
+
const readFrom = newPos + carryOverBytes;
|
|
968
|
+
if (readFrom >= currentStats.size) break;
|
|
969
|
+
|
|
970
|
+
const readLen = Math.min(MaxReadChunk, currentStats.size - readFrom);
|
|
971
|
+
const { bytesRead } = await handle.read(buf, 0, readLen, readFrom);
|
|
972
|
+
if (bytesRead === 0) break;
|
|
973
|
+
|
|
974
|
+
const chunk = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
|
|
975
|
+
const combined = carryOver + chunk;
|
|
976
|
+
|
|
977
|
+
// Detect CRLF from first newline in the combined text
|
|
978
|
+
let nlLen = 1;
|
|
979
|
+
const firstNl = combined.indexOf('\n');
|
|
980
|
+
if (firstNl > 0 && combined[firstNl - 1] === '\r') nlLen = 2;
|
|
981
|
+
|
|
982
|
+
const rawLines = combined.split('\n');
|
|
983
|
+
|
|
984
|
+
// If the chunk doesn't end with a newline, the last segment is incomplete.
|
|
985
|
+
// Save it as carryOver for the next chunk; don't process it yet.
|
|
986
|
+
if (!chunk.endsWith('\n')) {
|
|
987
|
+
carryOver = rawLines.pop();
|
|
988
|
+
carryOverBytes = Buffer.byteLength(carryOver, 'utf-8');
|
|
989
|
+
} else {
|
|
990
|
+
// chunk ends with \n — split produces a trailing empty string; clear carryOver
|
|
991
|
+
carryOver = '';
|
|
992
|
+
carryOverBytes = 0;
|
|
993
|
+
}
|
|
920
994
|
|
|
921
|
-
|
|
922
|
-
const isLast = i === rawLines.length - 1;
|
|
923
|
-
let rawLine = rawLines[i];
|
|
995
|
+
let chunkBytes = 0;
|
|
924
996
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
newPos += nlLen;
|
|
928
|
-
continue;
|
|
929
|
-
}
|
|
997
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
998
|
+
let rawLine = rawLines[i];
|
|
930
999
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
1000
|
+
// Trailing empty after final newline — just advance position
|
|
1001
|
+
if (rawLine === '' && i === rawLines.length - 1 && combined.endsWith('\n')) {
|
|
1002
|
+
chunkBytes += nlLen;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
937
1005
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1006
|
+
// Strip trailing \r for CRLF
|
|
1007
|
+
if (nlLen === 2 && rawLine.endsWith('\r')) {
|
|
1008
|
+
rawLine = rawLine.slice(0, -1);
|
|
1009
|
+
}
|
|
942
1010
|
|
|
943
|
-
|
|
944
|
-
newPos += nlLen;
|
|
1011
|
+
chunkBytes += Buffer.byteLength(rawLine, 'utf-8') + nlLen;
|
|
945
1012
|
|
|
946
|
-
|
|
1013
|
+
if (!rawLine.trim()) continue;
|
|
947
1014
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1015
|
+
const items = parseLine(rawLine);
|
|
1016
|
+
for (const item of items) {
|
|
1017
|
+
item.sessionID = sessionID;
|
|
951
1018
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1019
|
+
if (agentID) {
|
|
1020
|
+
if (!item.agentID) item.agentID = agentID;
|
|
1021
|
+
if (agentType) {
|
|
1022
|
+
const idx = agentType.lastIndexOf(':');
|
|
1023
|
+
if (idx >= 0 && idx < agentType.length - 1) {
|
|
1024
|
+
item.agentName = agentType.slice(idx + 1);
|
|
1025
|
+
} else {
|
|
1026
|
+
item.agentName = agentType;
|
|
1027
|
+
}
|
|
1028
|
+
} else if (!item.agentName || item.agentName.startsWith('Agent-')) {
|
|
1029
|
+
item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
|
|
960
1030
|
}
|
|
961
|
-
} else if (!item.agentName || item.agentName.startsWith('Agent-')) {
|
|
962
|
-
item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
|
|
963
1031
|
}
|
|
964
|
-
}
|
|
965
1032
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1033
|
+
if (item.toolID) {
|
|
1034
|
+
const session = this.sessions.get(sessionID);
|
|
1035
|
+
if (session) {
|
|
1036
|
+
const existing = session.toolIndex.get(item.toolID);
|
|
1037
|
+
if (item.type === 'tool_output') {
|
|
1038
|
+
if (existing) {
|
|
1039
|
+
existing.hasResult = true;
|
|
1040
|
+
} else {
|
|
1041
|
+
session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
|
|
1042
|
+
}
|
|
1043
|
+
} else if (item.type === 'tool_input' && !existing) {
|
|
1044
|
+
session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
|
|
975
1045
|
}
|
|
976
|
-
} else if (item.type === 'tool_input' && !existing) {
|
|
977
|
-
session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
|
|
978
1046
|
}
|
|
979
1047
|
}
|
|
980
|
-
}
|
|
981
1048
|
|
|
982
|
-
|
|
1049
|
+
this.emit('item', item);
|
|
1050
|
+
}
|
|
983
1051
|
}
|
|
1052
|
+
|
|
1053
|
+
newPos += chunkBytes;
|
|
1054
|
+
this.filePositions.set(filePath, Math.min(newPos, currentStats.size));
|
|
984
1055
|
}
|
|
985
1056
|
|
|
986
|
-
|
|
1057
|
+
// Process any remaining carryOver as a final incomplete line (no trailing \n).
|
|
1058
|
+
// This line may become complete on the next read, so we don't parse it yet.
|
|
1059
|
+
// But we must NOT advance position past it — next read starts from newPos.
|
|
1060
|
+
|
|
1061
|
+
await handle.close();
|
|
1062
|
+
handle = null;
|
|
987
1063
|
} catch (err) {
|
|
988
1064
|
if (newPos !== undefined) {
|
|
989
1065
|
this.filePositions.set(filePath, newPos);
|
|
@@ -1028,9 +1104,9 @@ class Watcher extends EventEmitter {
|
|
|
1028
1104
|
}
|
|
1029
1105
|
}
|
|
1030
1106
|
|
|
1031
|
-
_cleanupFilePositions() {
|
|
1107
|
+
async _cleanupFilePositions() {
|
|
1032
1108
|
for (const p of this.filePositions.keys()) {
|
|
1033
|
-
try {
|
|
1109
|
+
try { await fsp.access(p); } catch {
|
|
1034
1110
|
this.filePositions.delete(p);
|
|
1035
1111
|
this.fileContexts.delete(p);
|
|
1036
1112
|
}
|
|
@@ -1040,7 +1116,7 @@ class Watcher extends EventEmitter {
|
|
|
1040
1116
|
const now = Date.now();
|
|
1041
1117
|
for (const [sessionID, session] of this.sessions) {
|
|
1042
1118
|
let stats;
|
|
1043
|
-
try { stats =
|
|
1119
|
+
try { stats = await fsp.stat(session.mainFile); } catch {
|
|
1044
1120
|
this.removeSession(sessionID);
|
|
1045
1121
|
this.emit('broadcast', 'sessionRemoved', { sessionID });
|
|
1046
1122
|
continue;
|
|
@@ -1139,25 +1215,29 @@ async function _listSessionsFiltered(limit, activeWithin) {
|
|
|
1139
1215
|
const sessions = [];
|
|
1140
1216
|
const now = Date.now();
|
|
1141
1217
|
|
|
1218
|
+
const candidates = [];
|
|
1142
1219
|
try {
|
|
1143
1220
|
await _walkDirStatic(claudeDir, (filePath, stats) => {
|
|
1144
1221
|
if (!isMainSessionFile(filePath, stats)) return;
|
|
1145
1222
|
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
|
-
});
|
|
1223
|
+
candidates.push({ filePath, stats });
|
|
1158
1224
|
});
|
|
1159
1225
|
} catch {}
|
|
1160
1226
|
|
|
1227
|
+
for (const c of candidates) {
|
|
1228
|
+
const basename = path.basename(c.filePath);
|
|
1229
|
+
const projectDir = path.basename(path.dirname(c.filePath));
|
|
1230
|
+
const projectPath = await resolveProjectPath(projectDir);
|
|
1231
|
+
|
|
1232
|
+
sessions.push({
|
|
1233
|
+
id: basename.replace(/\.jsonl$/, ''),
|
|
1234
|
+
path: c.filePath,
|
|
1235
|
+
projectPath,
|
|
1236
|
+
modified: c.stats.mtime,
|
|
1237
|
+
isActive: (now - c.stats.mtimeMs) < RecentActivityThreshold,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1161
1241
|
sessions.sort((a, b) => b.modified - a.modified);
|
|
1162
1242
|
if (limit > 0 && sessions.length > limit) sessions.length = limit;
|
|
1163
1243
|
|