claude-code-watch 0.0.1
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/LICENSE +22 -0
- package/README.md +110 -0
- package/README.zh-CN.md +30 -0
- package/bin/claude-watch.js +187 -0
- package/package.json +38 -0
- package/public/index.html +1206 -0
- package/src/parser/parser.js +528 -0
- package/src/server/server.js +375 -0
- package/src/watcher/watcher.js +1130 -0
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const fsp = require('fs/promises');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const chokidar = require('chokidar');
|
|
8
|
+
const { parseLine, AgentIDDisplayLength } = require('../parser/parser');
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Constants
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
const AutoSkipLineThreshold = 100;
|
|
15
|
+
const KeepRecentLines = 10;
|
|
16
|
+
const CleanupInterval = 5 * 60 * 1000;
|
|
17
|
+
const FsnotifyDiscoveryInterval = 60 * 1000;
|
|
18
|
+
const RecentActivityThreshold = 2 * 60 * 1000;
|
|
19
|
+
const DebounceInterval = 50;
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
function getClaudeProjectsDir() {
|
|
26
|
+
if (process.env.CLAUDE_HOME) {
|
|
27
|
+
return path.join(process.env.CLAUDE_HOME, 'projects');
|
|
28
|
+
}
|
|
29
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveProjectPath(encoded) {
|
|
33
|
+
let s = encoded;
|
|
34
|
+
if (s.startsWith('-')) s = s.slice(1);
|
|
35
|
+
if (!s) return '';
|
|
36
|
+
|
|
37
|
+
const parts = s.split('-');
|
|
38
|
+
|
|
39
|
+
// Try progressively joining segments from the right with dashes
|
|
40
|
+
for (let joinFrom = parts.length - 1; joinFrom >= 1; joinFrom--) {
|
|
41
|
+
const pathPart = parts.slice(0, joinFrom).join('/');
|
|
42
|
+
const dirPart = parts.slice(joinFrom).join('-');
|
|
43
|
+
const testPath = `/${pathPart}/${dirPart}`;
|
|
44
|
+
try {
|
|
45
|
+
fs.accessSync(testPath);
|
|
46
|
+
return `${pathPart}/${dirPart}`;
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fallback to naive conversion
|
|
51
|
+
return s.replace(/-/g, '/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isMainSessionFile(filePath, stats) {
|
|
55
|
+
if (stats && stats.isDirectory()) return false;
|
|
56
|
+
if (!filePath.endsWith('.jsonl')) return false;
|
|
57
|
+
if (filePath.includes('/subagents/')) return false;
|
|
58
|
+
const basename = path.basename(filePath);
|
|
59
|
+
if (basename.startsWith('agent-')) return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readAgentType(jsonlPath) {
|
|
64
|
+
const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
|
|
65
|
+
try {
|
|
66
|
+
const data = fs.readFileSync(metaPath, 'utf-8');
|
|
67
|
+
const meta = JSON.parse(data);
|
|
68
|
+
return meta.agentType || '';
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Session class
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
class Session {
|
|
79
|
+
constructor(id, projectPath, mainFile) {
|
|
80
|
+
this.id = id;
|
|
81
|
+
this.projectPath = projectPath;
|
|
82
|
+
this.mainFile = mainFile;
|
|
83
|
+
this.subagents = {}; // agentID -> file path
|
|
84
|
+
this.subagentTypes = {}; // agentID -> agentType
|
|
85
|
+
this.backgroundTasks = {}; // toolID -> BackgroundTask
|
|
86
|
+
this.toolIndex = new Map(); // toolID -> { toolName, parentAgentID, hasResult }
|
|
87
|
+
this.toolIndexPopulated = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
class BackgroundTask {
|
|
92
|
+
constructor(toolID, parentAgentID, toolName, outputPath, isComplete) {
|
|
93
|
+
this.toolID = toolID;
|
|
94
|
+
this.parentAgentID = parentAgentID;
|
|
95
|
+
this.toolName = toolName;
|
|
96
|
+
this.outputPath = outputPath;
|
|
97
|
+
this.isComplete = isComplete;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Watcher class
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
class Watcher extends require('events').EventEmitter {
|
|
106
|
+
constructor({ sessionID, pollInterval, activeWindow, maxSessions } = {}) {
|
|
107
|
+
super();
|
|
108
|
+
this.claudeDir = getClaudeProjectsDir();
|
|
109
|
+
this.pollInterval = pollInterval || 500;
|
|
110
|
+
this.activeWindow = activeWindow || 5 * 60 * 1000;
|
|
111
|
+
this.maxSessions = maxSessions || 0;
|
|
112
|
+
this.sessions = new Map();
|
|
113
|
+
this.filePositions = new Map();
|
|
114
|
+
this.watchActive = !sessionID; // watch all active if no specific session
|
|
115
|
+
this.skipHistory = false;
|
|
116
|
+
|
|
117
|
+
// chokidar fields
|
|
118
|
+
this.watcher = null;
|
|
119
|
+
this.useFsnotify = false;
|
|
120
|
+
this.fileContexts = new Map();
|
|
121
|
+
this.debounceTimers = new Map();
|
|
122
|
+
this.pendingSubagents = new Map();
|
|
123
|
+
|
|
124
|
+
// Intervals / timers
|
|
125
|
+
this._cleanupTimer = null;
|
|
126
|
+
this._discoveryTimer = null;
|
|
127
|
+
this._pollTimer = null;
|
|
128
|
+
this._running = false;
|
|
129
|
+
|
|
130
|
+
this._sessionID = sessionID || '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =========================================================================
|
|
134
|
+
// Initialization
|
|
135
|
+
// =========================================================================
|
|
136
|
+
|
|
137
|
+
async init() {
|
|
138
|
+
// Try to set up chokidar
|
|
139
|
+
try {
|
|
140
|
+
this.watcher = chokidar.watch([], {
|
|
141
|
+
persistent: true,
|
|
142
|
+
ignoreInitial: true,
|
|
143
|
+
awaitWriteFinish: false,
|
|
144
|
+
usePolling: false,
|
|
145
|
+
interval: 100,
|
|
146
|
+
});
|
|
147
|
+
this.useFsnotify = true;
|
|
148
|
+
} catch {
|
|
149
|
+
this.useFsnotify = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (this._sessionID) {
|
|
153
|
+
const session = await this.findSession(this._sessionID);
|
|
154
|
+
if (session) {
|
|
155
|
+
this.sessions.set(session.id, session);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
await this.discoverActiveSessions();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =========================================================================
|
|
165
|
+
// Session discovery
|
|
166
|
+
// =========================================================================
|
|
167
|
+
|
|
168
|
+
async findSession(sessionID) {
|
|
169
|
+
const jsonlFiles = [];
|
|
170
|
+
try {
|
|
171
|
+
await this._walkDir(this.claudeDir, (filePath, stats) => {
|
|
172
|
+
if (isMainSessionFile(filePath, stats)) {
|
|
173
|
+
jsonlFiles.push(filePath);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
// Dir may not exist
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (jsonlFiles.length === 0) return null;
|
|
181
|
+
|
|
182
|
+
// Sort by mtime (most recent first)
|
|
183
|
+
jsonlFiles.sort((a, b) => {
|
|
184
|
+
try {
|
|
185
|
+
const sa = fs.statSync(a);
|
|
186
|
+
const sb = fs.statSync(b);
|
|
187
|
+
return sb.mtimeMs - sa.mtimeMs;
|
|
188
|
+
} catch {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let mainFile;
|
|
194
|
+
if (sessionID) {
|
|
195
|
+
mainFile = jsonlFiles.find(f => f.includes(sessionID));
|
|
196
|
+
if (!mainFile) return null;
|
|
197
|
+
} else {
|
|
198
|
+
mainFile = jsonlFiles[0];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return this.buildSession(mainFile);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
buildSession(mainFile) {
|
|
205
|
+
const base = path.basename(mainFile);
|
|
206
|
+
const id = base.replace(/\.jsonl$/, '');
|
|
207
|
+
const projectDir = path.basename(path.dirname(mainFile));
|
|
208
|
+
const projectPath = resolveProjectPath(projectDir);
|
|
209
|
+
|
|
210
|
+
const session = new Session(id, projectPath, mainFile);
|
|
211
|
+
|
|
212
|
+
// Find subagent files
|
|
213
|
+
const subagentDir = path.join(path.dirname(mainFile), id, 'subagents');
|
|
214
|
+
try {
|
|
215
|
+
const entries = fs.readdirSync(subagentDir);
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (entry.endsWith('.jsonl')) {
|
|
218
|
+
const agentID = entry.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
219
|
+
const jsonlPath = path.join(subagentDir, entry);
|
|
220
|
+
session.subagents[agentID] = jsonlPath;
|
|
221
|
+
const agentType = readAgentType(jsonlPath);
|
|
222
|
+
if (agentType) {
|
|
223
|
+
session.subagentTypes[agentID] = agentType;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
|
|
229
|
+
return session;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async discoverActiveSessions() {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const discovered = [];
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await this._walkDir(this.claudeDir, (filePath, stats) => {
|
|
238
|
+
if (!isMainSessionFile(filePath, stats)) return;
|
|
239
|
+
if (now - stats.mtimeMs > this.activeWindow) return;
|
|
240
|
+
|
|
241
|
+
const session = this.buildSession(filePath);
|
|
242
|
+
discovered.push({ session, modTime: stats.mtimeMs });
|
|
243
|
+
});
|
|
244
|
+
} catch {}
|
|
245
|
+
|
|
246
|
+
// Sort by most recent first
|
|
247
|
+
discovered.sort((a, b) => b.modTime - a.modTime);
|
|
248
|
+
if (this.maxSessions > 0 && discovered.length > this.maxSessions) {
|
|
249
|
+
discovered.length = this.maxSessions;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const d of discovered) {
|
|
253
|
+
if (!this.sessions.has(d.session.id)) {
|
|
254
|
+
this.sessions.set(d.session.id, d.session);
|
|
255
|
+
|
|
256
|
+
// Broadcast so connected clients learn about the new session
|
|
257
|
+
this.emit('broadcast', 'newSession', { sessionID: d.session.id, projectPath: d.session.projectPath });
|
|
258
|
+
for (const [agentID, agentType] of Object.entries(d.session.subagentTypes)) {
|
|
259
|
+
this.emit('broadcast', 'newAgent', { sessionID: d.session.id, agentID, agentType });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const pending = this.pendingSubagents.get(d.session.id);
|
|
263
|
+
if (pending) {
|
|
264
|
+
this.pendingSubagents.delete(d.session.id);
|
|
265
|
+
for (const sp of pending) {
|
|
266
|
+
const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
267
|
+
this._registerSubagent(d.session, d.session.id, agentID, sp);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getSessionsSnapshot() {
|
|
275
|
+
return Array.from(this.sessions.values());
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// =========================================================================
|
|
279
|
+
// Start / Stop
|
|
280
|
+
// =========================================================================
|
|
281
|
+
|
|
282
|
+
start() {
|
|
283
|
+
if (this._running) return;
|
|
284
|
+
this._running = true;
|
|
285
|
+
|
|
286
|
+
if (this.useFsnotify) {
|
|
287
|
+
this._startFsnotify();
|
|
288
|
+
} else {
|
|
289
|
+
this._startPolling();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
stop() {
|
|
294
|
+
this._running = false;
|
|
295
|
+
if (this.watcher) {
|
|
296
|
+
this.watcher.close().catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
if (this._cleanupTimer) clearInterval(this._cleanupTimer);
|
|
299
|
+
if (this._discoveryTimer) clearInterval(this._discoveryTimer);
|
|
300
|
+
if (this._pollTimer) clearInterval(this._pollTimer);
|
|
301
|
+
// Cancel all debounce timers
|
|
302
|
+
for (const timer of this.debounceTimers.values()) {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
this.debounceTimers.clear();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// =========================================================================
|
|
309
|
+
// Chokidar (fsnotify) mode
|
|
310
|
+
// =========================================================================
|
|
311
|
+
|
|
312
|
+
_startFsnotify() {
|
|
313
|
+
// Set up watches
|
|
314
|
+
try {
|
|
315
|
+
if (fs.existsSync(this.claudeDir)) {
|
|
316
|
+
this._addDirectoryWatches(this.claudeDir);
|
|
317
|
+
} else {
|
|
318
|
+
this._watchAncestor(this.claudeDir);
|
|
319
|
+
}
|
|
320
|
+
} catch {}
|
|
321
|
+
|
|
322
|
+
const sessions = this.getSessionsSnapshot();
|
|
323
|
+
this._initializeSessionReading(sessions);
|
|
324
|
+
for (const session of sessions) {
|
|
325
|
+
this._registerSessionWatches(session);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// chokidar events
|
|
329
|
+
this.watcher.on('add', (p) => this._handleFsCreate(p));
|
|
330
|
+
this.watcher.on('change', (p) => this._handleFsWrite(p));
|
|
331
|
+
this.watcher.on('unlink', (p) => {
|
|
332
|
+
this.filePositions.delete(p);
|
|
333
|
+
this.fileContexts.delete(p);
|
|
334
|
+
});
|
|
335
|
+
this.watcher.on('error', (err) => this.emit('error', err));
|
|
336
|
+
|
|
337
|
+
// Periodic cleanup and discovery
|
|
338
|
+
this._cleanupTimer = setInterval(() => {
|
|
339
|
+
if (!this._running) return;
|
|
340
|
+
this._cleanupFilePositions();
|
|
341
|
+
}, CleanupInterval);
|
|
342
|
+
|
|
343
|
+
this._discoveryTimer = setInterval(() => {
|
|
344
|
+
if (!this._running) return;
|
|
345
|
+
if (this.watchActive) this._checkForNewSessions();
|
|
346
|
+
}, FsnotifyDiscoveryInterval);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_watchAncestor(target) {
|
|
350
|
+
let dir = target;
|
|
351
|
+
while (true) {
|
|
352
|
+
const parent = path.dirname(dir);
|
|
353
|
+
if (parent === dir) break;
|
|
354
|
+
try {
|
|
355
|
+
fs.accessSync(parent);
|
|
356
|
+
this.watcher.add(parent);
|
|
357
|
+
return;
|
|
358
|
+
} catch {}
|
|
359
|
+
dir = parent;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
_addDirectoryWatches(root, maxDepth = 20) {
|
|
364
|
+
const addRecursive = (dir, depth) => {
|
|
365
|
+
if (depth > maxDepth) return;
|
|
366
|
+
try {
|
|
367
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
368
|
+
this.watcher.add(dir);
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
if (entry.isDirectory()) {
|
|
371
|
+
addRecursive(path.join(dir, entry.name), depth + 1);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {}
|
|
375
|
+
};
|
|
376
|
+
addRecursive(root, 0);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
_registerSessionWatches(session) {
|
|
380
|
+
this._addFileWatch(session.mainFile, session.id, '');
|
|
381
|
+
for (const [agentID, agentPath] of Object.entries(session.subagents)) {
|
|
382
|
+
this._addFileWatch(agentPath, session.id, agentID);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_addFileWatch(filePath, sessionID, agentID) {
|
|
387
|
+
try {
|
|
388
|
+
this.watcher.add(filePath);
|
|
389
|
+
this.fileContexts.set(filePath, { sessionID, agentID });
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// =========================================================================
|
|
394
|
+
// chokidar event handlers
|
|
395
|
+
// =========================================================================
|
|
396
|
+
|
|
397
|
+
_handleFsCreate(p) {
|
|
398
|
+
let stats;
|
|
399
|
+
try { stats = fs.statSync(p); } catch { return; }
|
|
400
|
+
|
|
401
|
+
if (stats.isDirectory()) {
|
|
402
|
+
this.watcher.add(p);
|
|
403
|
+
this._scanNewDirectory(p);
|
|
404
|
+
if (p === this.claudeDir || this.claudeDir.startsWith(p)) {
|
|
405
|
+
try {
|
|
406
|
+
fs.accessSync(this.claudeDir);
|
|
407
|
+
this._addDirectoryWatches(this.claudeDir);
|
|
408
|
+
this.discoverActiveSessions();
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (p.endsWith('.jsonl')) {
|
|
415
|
+
if (p.includes('/subagents/')) {
|
|
416
|
+
this._handleNewSubagentFile(p);
|
|
417
|
+
} else if (this.watchActive) {
|
|
418
|
+
this._handleNewSessionFile(p);
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (p.endsWith('.txt') && p.includes('/tool-results/')) {
|
|
424
|
+
this._handleNewToolResultFile(p);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_scanNewDirectory(dirPath) {
|
|
429
|
+
let entries;
|
|
430
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
|
|
431
|
+
const base = path.basename(dirPath);
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
434
|
+
if (entry.isDirectory()) {
|
|
435
|
+
this.watcher.add(fullPath);
|
|
436
|
+
this._scanNewDirectory(fullPath);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (base === 'subagents' && entry.name.endsWith('.jsonl')) {
|
|
440
|
+
this._handleNewSubagentFile(fullPath);
|
|
441
|
+
} else if (base === 'tool-results' && entry.name.endsWith('.txt')) {
|
|
442
|
+
this._handleNewToolResultFile(fullPath);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_handleFsWrite(p) {
|
|
448
|
+
const ctx = this.fileContexts.get(p);
|
|
449
|
+
if (!ctx) return;
|
|
450
|
+
|
|
451
|
+
// Debounce
|
|
452
|
+
const existing = this.debounceTimers.get(p);
|
|
453
|
+
if (existing) {
|
|
454
|
+
clearTimeout(existing);
|
|
455
|
+
}
|
|
456
|
+
const timer = setTimeout(() => {
|
|
457
|
+
this.debounceTimers.delete(p);
|
|
458
|
+
const agentType = this._lookupAgentType(ctx.sessionID, ctx.agentID);
|
|
459
|
+
this._readFile(p, ctx.sessionID, ctx.agentID, agentType);
|
|
460
|
+
}, DebounceInterval);
|
|
461
|
+
this.debounceTimers.set(p, timer);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// =========================================================================
|
|
465
|
+
// New session handlers
|
|
466
|
+
// =========================================================================
|
|
467
|
+
|
|
468
|
+
_handleNewSessionFile(p) {
|
|
469
|
+
let stats;
|
|
470
|
+
try { stats = fs.statSync(p); } catch { return; }
|
|
471
|
+
if (!isMainSessionFile(p, stats)) return;
|
|
472
|
+
|
|
473
|
+
// Only accept sessions within the active window
|
|
474
|
+
if (Date.now() - stats.mtimeMs > this.activeWindow) return;
|
|
475
|
+
|
|
476
|
+
const session = this.buildSession(p);
|
|
477
|
+
if (this.sessions.has(session.id)) return;
|
|
478
|
+
|
|
479
|
+
this.sessions.set(session.id, session);
|
|
480
|
+
this._registerSessionWatches(session);
|
|
481
|
+
this.emit('broadcast', 'newSession', { sessionID: session.id, projectPath: session.projectPath });
|
|
482
|
+
|
|
483
|
+
// Broadcast pre-existing subagents to frontend
|
|
484
|
+
for (const [agentID, agentType] of Object.entries(session.subagentTypes)) {
|
|
485
|
+
this.emit('broadcast', 'newAgent', { sessionID: session.id, agentID, agentType });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Process any subagent files that arrived before the session was discovered
|
|
489
|
+
const pending = this.pendingSubagents.get(session.id);
|
|
490
|
+
if (pending) {
|
|
491
|
+
this.pendingSubagents.delete(session.id);
|
|
492
|
+
for (const sp of pending) {
|
|
493
|
+
const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
494
|
+
this._registerSubagent(session, session.id, agentID, sp);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
_handleNewSubagentFile(p) {
|
|
500
|
+
const agentID = path.basename(p).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
501
|
+
const subagentsDir = path.dirname(p);
|
|
502
|
+
const sessionDir = path.dirname(subagentsDir);
|
|
503
|
+
const sessionID = path.basename(sessionDir);
|
|
504
|
+
|
|
505
|
+
const session = this.sessions.get(sessionID);
|
|
506
|
+
if (!session) {
|
|
507
|
+
const pending = this.pendingSubagents.get(sessionID) || [];
|
|
508
|
+
if (!pending.includes(p)) pending.push(p);
|
|
509
|
+
this.pendingSubagents.set(sessionID, pending);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
this._registerSubagent(session, sessionID, agentID, p);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
_registerSubagent(session, sessionID, agentID, p) {
|
|
517
|
+
const agentType = readAgentType(p);
|
|
518
|
+
if (session.subagents[agentID]) return;
|
|
519
|
+
|
|
520
|
+
session.subagents[agentID] = p;
|
|
521
|
+
if (agentType) session.subagentTypes[agentID] = agentType;
|
|
522
|
+
|
|
523
|
+
this._addFileWatch(p, sessionID, agentID);
|
|
524
|
+
this.emit('broadcast', 'newAgent', { sessionID, agentID, agentType });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_handleNewToolResultFile(p) {
|
|
528
|
+
const toolID = path.basename(p).replace(/\.txt$/, '');
|
|
529
|
+
const toolResultsDir = path.dirname(p);
|
|
530
|
+
const sessionDir = path.dirname(toolResultsDir);
|
|
531
|
+
const sessionID = path.basename(sessionDir);
|
|
532
|
+
|
|
533
|
+
const session = this.sessions.get(sessionID);
|
|
534
|
+
if (!session) return;
|
|
535
|
+
if (session.backgroundTasks[toolID]) return;
|
|
536
|
+
|
|
537
|
+
const parentAgentID = this._findBackgroundTaskParent(session, toolID);
|
|
538
|
+
const isComplete = this._isBackgroundTaskComplete(session, toolID);
|
|
539
|
+
|
|
540
|
+
const task = new BackgroundTask(toolID, parentAgentID, 'Background Task', p, isComplete);
|
|
541
|
+
session.backgroundTasks[toolID] = task;
|
|
542
|
+
|
|
543
|
+
this.emit('broadcast', 'newBackgroundTask', {
|
|
544
|
+
sessionID,
|
|
545
|
+
parentAgentID,
|
|
546
|
+
toolID,
|
|
547
|
+
toolName: 'Background Task',
|
|
548
|
+
outputPath: p,
|
|
549
|
+
isComplete,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
_lookupAgentType(sessionID, agentID) {
|
|
554
|
+
if (!agentID) return '';
|
|
555
|
+
const session = this.sessions.get(sessionID);
|
|
556
|
+
if (!session) return '';
|
|
557
|
+
return session.subagentTypes[agentID] || '';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// =========================================================================
|
|
561
|
+
// Periodic checking (polling fallback + fsnotify discovery)
|
|
562
|
+
// =========================================================================
|
|
563
|
+
|
|
564
|
+
_checkForNewSessions() {
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const candidates = [];
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
_walkDirSyncSimple(this.claudeDir, (filePath, stats) => {
|
|
570
|
+
if (!isMainSessionFile(filePath, stats)) return;
|
|
571
|
+
if (now - stats.mtimeMs > this.activeWindow) return;
|
|
572
|
+
|
|
573
|
+
const id = path.basename(filePath).replace(/\.jsonl$/, '');
|
|
574
|
+
if (this.sessions.has(id)) return;
|
|
575
|
+
|
|
576
|
+
const session = this.buildSession(filePath);
|
|
577
|
+
candidates.push({ session, modTime: stats.mtimeMs });
|
|
578
|
+
});
|
|
579
|
+
} catch {}
|
|
580
|
+
|
|
581
|
+
candidates.sort((a, b) => b.modTime - a.modTime);
|
|
582
|
+
|
|
583
|
+
for (const c of candidates) {
|
|
584
|
+
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) break;
|
|
585
|
+
if (this.sessions.has(c.session.id)) continue;
|
|
586
|
+
|
|
587
|
+
this.sessions.set(c.session.id, c.session);
|
|
588
|
+
|
|
589
|
+
if (this.useFsnotify) {
|
|
590
|
+
this._registerSessionWatches(c.session);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
this.emit('broadcast', 'newSession', { sessionID: c.session.id, projectPath: c.session.projectPath });
|
|
594
|
+
|
|
595
|
+
for (const [agentID, agentType] of Object.entries(c.session.subagentTypes)) {
|
|
596
|
+
this.emit('broadcast', 'newAgent', { sessionID: c.session.id, agentID, agentType });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const pending = this.pendingSubagents.get(c.session.id);
|
|
600
|
+
if (pending) {
|
|
601
|
+
this.pendingSubagents.delete(c.session.id);
|
|
602
|
+
for (const sp of pending) {
|
|
603
|
+
const agentID = path.basename(sp).replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
604
|
+
this._registerSubagent(c.session, c.session.id, agentID, sp);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
_checkForNewSubagents(session) {
|
|
611
|
+
const subagentDir = path.join(path.dirname(session.mainFile), session.id, 'subagents');
|
|
612
|
+
let entries;
|
|
613
|
+
try { entries = fs.readdirSync(subagentDir); } catch { return; }
|
|
614
|
+
|
|
615
|
+
for (const entry of entries) {
|
|
616
|
+
if (!entry.endsWith('.jsonl')) continue;
|
|
617
|
+
const agentID = entry.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
618
|
+
if (session.subagents[agentID]) continue;
|
|
619
|
+
|
|
620
|
+
const agentPath = path.join(subagentDir, entry);
|
|
621
|
+
const agentType = readAgentType(agentPath);
|
|
622
|
+
session.subagents[agentID] = agentPath;
|
|
623
|
+
if (agentType) session.subagentTypes[agentID] = agentType;
|
|
624
|
+
|
|
625
|
+
this.emit('newAgent', { sessionID: session.id, agentID, agentType });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_checkForBackgroundTasks(session) {
|
|
630
|
+
const toolResultsDir = path.join(path.dirname(session.mainFile), session.id, 'tool-results');
|
|
631
|
+
let entries;
|
|
632
|
+
try { entries = fs.readdirSync(toolResultsDir); } catch { return; }
|
|
633
|
+
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
if (!entry.endsWith('.txt')) continue;
|
|
636
|
+
const toolID = entry.replace(/\.txt$/, '');
|
|
637
|
+
if (session.backgroundTasks[toolID]) continue;
|
|
638
|
+
|
|
639
|
+
const outputPath = path.join(toolResultsDir, entry);
|
|
640
|
+
const parentAgentID = this._findBackgroundTaskParent(session, toolID);
|
|
641
|
+
const isComplete = this._isBackgroundTaskComplete(session, toolID);
|
|
642
|
+
|
|
643
|
+
const task = new BackgroundTask(toolID, parentAgentID, 'Background Task', outputPath, isComplete);
|
|
644
|
+
session.backgroundTasks[toolID] = task;
|
|
645
|
+
|
|
646
|
+
this.emit('broadcast', 'newBackgroundTask', {
|
|
647
|
+
sessionID: session.id,
|
|
648
|
+
parentAgentID,
|
|
649
|
+
toolID,
|
|
650
|
+
toolName: 'Background Task',
|
|
651
|
+
outputPath,
|
|
652
|
+
isComplete,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_findBackgroundTaskParent(session, toolID) {
|
|
658
|
+
const entry = session.toolIndex.get(toolID);
|
|
659
|
+
if (entry) return entry.parentAgentID || '';
|
|
660
|
+
if (!session.toolIndexPopulated) {
|
|
661
|
+
this._populateToolIndex(session);
|
|
662
|
+
}
|
|
663
|
+
const cached = session.toolIndex.get(toolID);
|
|
664
|
+
return cached ? (cached.parentAgentID || '') : '';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
_isBackgroundTaskComplete(session, toolID) {
|
|
668
|
+
const entry = session.toolIndex.get(toolID);
|
|
669
|
+
if (entry) return entry.hasResult;
|
|
670
|
+
if (!session.toolIndexPopulated) {
|
|
671
|
+
this._populateToolIndex(session);
|
|
672
|
+
}
|
|
673
|
+
const cached = session.toolIndex.get(toolID);
|
|
674
|
+
return cached ? cached.hasResult : false;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_populateToolIndex(session) {
|
|
678
|
+
if (session.toolIndexPopulated) return;
|
|
679
|
+
session.toolIndexPopulated = true;
|
|
680
|
+
const files = [
|
|
681
|
+
{ path: session.mainFile, agentID: '' },
|
|
682
|
+
...Object.entries(session.subagents).map(([id, p]) => ({ path: p, agentID: id })),
|
|
683
|
+
];
|
|
684
|
+
|
|
685
|
+
for (const { path: filePath, agentID } of files) {
|
|
686
|
+
if (!filePath) continue;
|
|
687
|
+
try {
|
|
688
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
689
|
+
for (const line of content.split('\n')) {
|
|
690
|
+
if (!line.includes('"tool_')) continue;
|
|
691
|
+
|
|
692
|
+
if (line.includes('"tool_use"')) {
|
|
693
|
+
const idMatch = line.match(/"id"\s*:\s*"([^"]+)"/);
|
|
694
|
+
if (!idMatch) continue;
|
|
695
|
+
const tid = idMatch[1];
|
|
696
|
+
if (session.toolIndex.has(tid)) continue;
|
|
697
|
+
const nameMatch = line.match(/"name"\s*:\s*"([^"]+)"/);
|
|
698
|
+
session.toolIndex.set(tid, {
|
|
699
|
+
toolName: nameMatch ? nameMatch[1] : '',
|
|
700
|
+
parentAgentID: agentID,
|
|
701
|
+
hasResult: false,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (line.includes('"tool_result"')) {
|
|
706
|
+
const useIdMatch = line.match(/"tool_use_id"\s*:\s*"([^"]+)"/);
|
|
707
|
+
if (!useIdMatch) continue;
|
|
708
|
+
const tid = useIdMatch[1];
|
|
709
|
+
const existing = session.toolIndex.get(tid);
|
|
710
|
+
if (existing) {
|
|
711
|
+
existing.hasResult = true;
|
|
712
|
+
} else {
|
|
713
|
+
session.toolIndex.set(tid, {
|
|
714
|
+
toolName: '',
|
|
715
|
+
parentAgentID: '',
|
|
716
|
+
hasResult: true,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} catch {}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// =========================================================================
|
|
726
|
+
// Polling mode
|
|
727
|
+
// =========================================================================
|
|
728
|
+
|
|
729
|
+
_startPolling() {
|
|
730
|
+
const sessions = this.getSessionsSnapshot();
|
|
731
|
+
this._initializeSessionReading(sessions);
|
|
732
|
+
|
|
733
|
+
this._pollTimer = setInterval(() => {
|
|
734
|
+
if (!this._running) return;
|
|
735
|
+
this._handlePollTick();
|
|
736
|
+
}, this.pollInterval);
|
|
737
|
+
|
|
738
|
+
this._cleanupTimer = setInterval(() => {
|
|
739
|
+
if (!this._running) return;
|
|
740
|
+
this._cleanupFilePositions();
|
|
741
|
+
}, CleanupInterval);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
_handlePollTick() {
|
|
745
|
+
if (this.watchActive) {
|
|
746
|
+
this._checkForNewSessions();
|
|
747
|
+
}
|
|
748
|
+
for (const session of this.getSessionsSnapshot()) {
|
|
749
|
+
this._checkForNewSubagents(session);
|
|
750
|
+
this._checkForBackgroundTasks(session);
|
|
751
|
+
this._readSessionFiles(session);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// =========================================================================
|
|
756
|
+
// File reading
|
|
757
|
+
// =========================================================================
|
|
758
|
+
|
|
759
|
+
_initializeSessionReading(sessions) {
|
|
760
|
+
let shouldSkip = this.skipHistory;
|
|
761
|
+
if (!shouldSkip) {
|
|
762
|
+
let totalLines = 0;
|
|
763
|
+
for (const session of sessions) {
|
|
764
|
+
totalLines += this._countFileLines(session.mainFile);
|
|
765
|
+
for (const agentPath of Object.values(session.subagents)) {
|
|
766
|
+
totalLines += this._countFileLines(agentPath);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
shouldSkip = totalLines > AutoSkipLineThreshold;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (shouldSkip) {
|
|
773
|
+
for (const session of sessions) {
|
|
774
|
+
this._skipToEndOfFiles(session);
|
|
775
|
+
this._readSessionFiles(session);
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
for (const session of sessions) {
|
|
779
|
+
this._readSessionFiles(session);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
_skipToEndOfFiles(session) {
|
|
785
|
+
const mainPos = this._findPositionForLastNLines(session.mainFile, KeepRecentLines);
|
|
786
|
+
this.filePositions.set(session.mainFile, mainPos);
|
|
787
|
+
|
|
788
|
+
for (const agentPath of Object.values(session.subagents)) {
|
|
789
|
+
const pos = this._findPositionForLastNLines(agentPath, KeepRecentLines);
|
|
790
|
+
this.filePositions.set(agentPath, pos);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
_findPositionForLastNLines(filePath, n) {
|
|
795
|
+
try {
|
|
796
|
+
const stat = fs.statSync(filePath);
|
|
797
|
+
const fileSize = stat.size;
|
|
798
|
+
if (fileSize === 0) return 0;
|
|
799
|
+
|
|
800
|
+
let fd;
|
|
801
|
+
try {
|
|
802
|
+
fd = fs.openSync(filePath, 'r');
|
|
803
|
+
const chunkSize = 8192;
|
|
804
|
+
const buf = Buffer.alloc(chunkSize);
|
|
805
|
+
let newlineCount = 0;
|
|
806
|
+
let position = fileSize;
|
|
807
|
+
let lastNewlinePos = fileSize;
|
|
808
|
+
|
|
809
|
+
while (position > 0 && newlineCount <= n) {
|
|
810
|
+
const readLen = Math.min(chunkSize, position);
|
|
811
|
+
position -= readLen;
|
|
812
|
+
fs.readSync(fd, buf, 0, readLen, position);
|
|
813
|
+
|
|
814
|
+
for (let i = readLen - 1; i >= 0; i--) {
|
|
815
|
+
if (buf[i] === 0x0A) {
|
|
816
|
+
newlineCount++;
|
|
817
|
+
if (newlineCount === n) {
|
|
818
|
+
lastNewlinePos = position + i + 1;
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (newlineCount < n) return 0;
|
|
826
|
+
return lastNewlinePos;
|
|
827
|
+
} finally {
|
|
828
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch {}
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
_readSessionFiles(session) {
|
|
836
|
+
this._readFile(session.mainFile, session.id, '', '');
|
|
837
|
+
for (const [agentID, agentPath] of Object.entries(session.subagents)) {
|
|
838
|
+
const agentType = session.subagentTypes[agentID] || '';
|
|
839
|
+
this._readFile(agentPath, session.id, agentID, agentType);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
_readFile(filePath, sessionID, agentID, agentType) {
|
|
844
|
+
let fd;
|
|
845
|
+
try {
|
|
846
|
+
fd = fs.openSync(filePath, 'r');
|
|
847
|
+
const pos = this.filePositions.get(filePath) || 0;
|
|
848
|
+
const stats = fs.fstatSync(fd);
|
|
849
|
+
if (pos >= stats.size) return;
|
|
850
|
+
|
|
851
|
+
const readLen = stats.size - pos;
|
|
852
|
+
const buf = Buffer.alloc(readLen);
|
|
853
|
+
const bytesRead = fs.readSync(fd, buf, 0, readLen, pos);
|
|
854
|
+
if (bytesRead === 0) return;
|
|
855
|
+
|
|
856
|
+
let newPos = pos;
|
|
857
|
+
const content = bytesRead < readLen ? buf.toString('utf-8', 0, bytesRead) : buf.toString('utf-8');
|
|
858
|
+
const lines = content.split('\n');
|
|
859
|
+
|
|
860
|
+
// Check whether the file has grown since we read it.
|
|
861
|
+
// If yes, the last line may be incomplete (no trailing newline yet).
|
|
862
|
+
let currentSize;
|
|
863
|
+
try { currentSize = fs.fstatSync(fd).size; } catch { currentSize = stats.size; }
|
|
864
|
+
const fileGrew = currentSize > pos + bytesRead;
|
|
865
|
+
|
|
866
|
+
for (let i = 0; i < lines.length; i++) {
|
|
867
|
+
const isLast = i === lines.length - 1;
|
|
868
|
+
const rawLine = lines[i];
|
|
869
|
+
|
|
870
|
+
// Trailing empty string after final newline — already processed
|
|
871
|
+
if (isLast && rawLine === '' && content.endsWith('\n')) {
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// File has grown: last line may be missing its newline, defer it
|
|
876
|
+
if (isLast && fileGrew) {
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Advance file position (only for lines we actually consume)
|
|
881
|
+
newPos += Buffer.byteLength(rawLine, 'utf-8');
|
|
882
|
+
if (!isLast) newPos += 1;
|
|
883
|
+
|
|
884
|
+
if (!rawLine.trim()) continue;
|
|
885
|
+
|
|
886
|
+
const items = parseLine(rawLine);
|
|
887
|
+
for (const item of items) {
|
|
888
|
+
// Set session ID
|
|
889
|
+
item.sessionID = sessionID;
|
|
890
|
+
|
|
891
|
+
// Set agent ID and name from context
|
|
892
|
+
if (agentID) {
|
|
893
|
+
if (!item.agentID) item.agentID = agentID;
|
|
894
|
+
if (agentType) {
|
|
895
|
+
const idx = agentType.lastIndexOf(':');
|
|
896
|
+
if (idx >= 0 && idx < agentType.length - 1) {
|
|
897
|
+
item.agentName = agentType.slice(idx + 1);
|
|
898
|
+
} else {
|
|
899
|
+
item.agentName = agentType;
|
|
900
|
+
}
|
|
901
|
+
} else if (!item.agentName || item.agentName.startsWith('Agent-')) {
|
|
902
|
+
item.agentName = `Agent-${agentID.slice(0, Math.min(AgentIDDisplayLength, agentID.length))}`;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Populate tool index for O(1) lookups
|
|
907
|
+
if (item.toolID) {
|
|
908
|
+
const session = this.sessions.get(sessionID);
|
|
909
|
+
if (session) {
|
|
910
|
+
const existing = session.toolIndex.get(item.toolID);
|
|
911
|
+
if (item.type === 'tool_output') {
|
|
912
|
+
if (existing) {
|
|
913
|
+
existing.hasResult = true;
|
|
914
|
+
} else {
|
|
915
|
+
session.toolIndex.set(item.toolID, { toolName: '', parentAgentID: agentID || '', hasResult: true });
|
|
916
|
+
}
|
|
917
|
+
} else if (item.type === 'tool_input' && !existing) {
|
|
918
|
+
session.toolIndex.set(item.toolID, { toolName: item.toolName || '', parentAgentID: agentID || '', hasResult: false });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
this.emit('item', item);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
this.filePositions.set(filePath, Math.min(newPos, stats.size));
|
|
928
|
+
} catch {} finally {
|
|
929
|
+
if (fd !== undefined) {
|
|
930
|
+
try { fs.closeSync(fd); } catch {}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
_countFileLines(filePath) {
|
|
936
|
+
try {
|
|
937
|
+
const stat = fs.statSync(filePath);
|
|
938
|
+
if (stat.size === 0) return 0;
|
|
939
|
+
const fd = fs.openSync(filePath, 'r');
|
|
940
|
+
const buf = Buffer.alloc(8192);
|
|
941
|
+
let count = 0;
|
|
942
|
+
let pos = 0;
|
|
943
|
+
while (pos < stat.size) {
|
|
944
|
+
const readLen = Math.min(8192, stat.size - pos);
|
|
945
|
+
const bytesRead = fs.readSync(fd, buf, 0, readLen, pos);
|
|
946
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
947
|
+
if (buf[i] === 0x0A) count++;
|
|
948
|
+
}
|
|
949
|
+
pos += bytesRead;
|
|
950
|
+
}
|
|
951
|
+
fs.closeSync(fd);
|
|
952
|
+
return count;
|
|
953
|
+
} catch {
|
|
954
|
+
return 0;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
_cleanupFilePositions() {
|
|
959
|
+
for (const p of this.filePositions.keys()) {
|
|
960
|
+
try { fs.accessSync(p); } catch {
|
|
961
|
+
this.filePositions.delete(p);
|
|
962
|
+
this.fileContexts.delete(p);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Remove stale sessions whose main file is no longer active
|
|
967
|
+
const now = Date.now();
|
|
968
|
+
for (const [sessionID, session] of this.sessions) {
|
|
969
|
+
let stats;
|
|
970
|
+
try { stats = fs.statSync(session.mainFile); } catch {
|
|
971
|
+
this.removeSession(sessionID);
|
|
972
|
+
this.emit('broadcast', 'sessionRemoved', { sessionID });
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (now - stats.mtimeMs > this.activeWindow) {
|
|
976
|
+
this.removeSession(sessionID);
|
|
977
|
+
this.emit('broadcast', 'sessionRemoved', { sessionID });
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// =========================================================================
|
|
983
|
+
// Public API
|
|
984
|
+
// =========================================================================
|
|
985
|
+
|
|
986
|
+
setSkipHistory(skip) {
|
|
987
|
+
this.skipHistory = skip;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
removeSession(sessionID) {
|
|
991
|
+
const session = this.sessions.get(sessionID);
|
|
992
|
+
if (session) {
|
|
993
|
+
const paths = [session.mainFile, ...Object.values(session.subagents)];
|
|
994
|
+
for (const p of paths) {
|
|
995
|
+
if (p) {
|
|
996
|
+
this.fileContexts.delete(p);
|
|
997
|
+
this.filePositions.delete(p);
|
|
998
|
+
const timer = this.debounceTimers.get(p);
|
|
999
|
+
if (timer) {
|
|
1000
|
+
clearTimeout(timer);
|
|
1001
|
+
this.debounceTimers.delete(p);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
this.sessions.delete(sessionID);
|
|
1007
|
+
if (session) {
|
|
1008
|
+
this.emit('sessionRemoved', { sessionID });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
toggleAutoDiscovery() {
|
|
1013
|
+
this.watchActive = !this.watchActive;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
isAutoDiscoveryEnabled() {
|
|
1017
|
+
return this.watchActive;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// =========================================================================
|
|
1021
|
+
// Directory walking
|
|
1022
|
+
// =========================================================================
|
|
1023
|
+
|
|
1024
|
+
_createWalkDir(readdirFn) {
|
|
1025
|
+
const walk = async (dir, callback) => {
|
|
1026
|
+
try {
|
|
1027
|
+
const entries = await readdirFn(dir, { withFileTypes: true });
|
|
1028
|
+
for (const entry of entries) {
|
|
1029
|
+
const fullPath = path.join(dir, entry.name);
|
|
1030
|
+
if (entry.isDirectory()) {
|
|
1031
|
+
await walk(fullPath, callback);
|
|
1032
|
+
} else {
|
|
1033
|
+
let stats;
|
|
1034
|
+
try { stats = fs.statSync(fullPath); } catch { continue; }
|
|
1035
|
+
callback(fullPath, stats);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
} catch {}
|
|
1039
|
+
};
|
|
1040
|
+
return walk;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
_walkDir = this._createWalkDir(fsp.readdir);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ============================================================================
|
|
1047
|
+
// Pure synchronous directory walk (no async/Promise overhead)
|
|
1048
|
+
// ============================================================================
|
|
1049
|
+
|
|
1050
|
+
function _walkDirSyncSimple(dir, callback) {
|
|
1051
|
+
try {
|
|
1052
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1053
|
+
for (const entry of entries) {
|
|
1054
|
+
const fullPath = path.join(dir, entry.name);
|
|
1055
|
+
if (entry.isDirectory()) {
|
|
1056
|
+
_walkDirSyncSimple(fullPath, callback);
|
|
1057
|
+
} else {
|
|
1058
|
+
let stats;
|
|
1059
|
+
try { stats = fs.statSync(fullPath); } catch { continue; }
|
|
1060
|
+
callback(fullPath, stats);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
} catch {}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ============================================================================
|
|
1067
|
+
// Static listing methods
|
|
1068
|
+
// ============================================================================
|
|
1069
|
+
|
|
1070
|
+
async function listSessions(limit = 10) {
|
|
1071
|
+
return _listSessionsFiltered(limit, 0);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function listActiveSessions(withinMs) {
|
|
1075
|
+
return _listSessionsFiltered(0, withinMs);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function _listSessionsFiltered(limit, activeWithin) {
|
|
1079
|
+
const claudeDir = getClaudeProjectsDir();
|
|
1080
|
+
const sessions = [];
|
|
1081
|
+
const now = Date.now();
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
await _walkDirStatic(claudeDir, (filePath, stats) => {
|
|
1085
|
+
if (!isMainSessionFile(filePath, stats)) return;
|
|
1086
|
+
if (activeWithin > 0 && (now - stats.mtimeMs) > activeWithin) return;
|
|
1087
|
+
|
|
1088
|
+
const basename = path.basename(filePath);
|
|
1089
|
+
const projectDir = path.basename(path.dirname(filePath));
|
|
1090
|
+
const projectPath = resolveProjectPath(projectDir);
|
|
1091
|
+
|
|
1092
|
+
sessions.push({
|
|
1093
|
+
id: basename.replace(/\.jsonl$/, ''),
|
|
1094
|
+
path: filePath,
|
|
1095
|
+
projectPath,
|
|
1096
|
+
modified: stats.mtime,
|
|
1097
|
+
isActive: (now - stats.mtimeMs) < RecentActivityThreshold,
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
} catch {}
|
|
1101
|
+
|
|
1102
|
+
sessions.sort((a, b) => b.modified - a.modified);
|
|
1103
|
+
if (limit > 0 && sessions.length > limit) sessions.length = limit;
|
|
1104
|
+
|
|
1105
|
+
return sessions;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function _walkDirStatic(dir, callback) {
|
|
1109
|
+
try {
|
|
1110
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
1111
|
+
for (const entry of entries) {
|
|
1112
|
+
const fullPath = path.join(dir, entry.name);
|
|
1113
|
+
if (entry.isDirectory()) {
|
|
1114
|
+
await _walkDirStatic(fullPath, callback);
|
|
1115
|
+
} else {
|
|
1116
|
+
let stats;
|
|
1117
|
+
try { stats = fs.statSync(fullPath); } catch { continue; }
|
|
1118
|
+
callback(fullPath, stats);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
} catch {}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
module.exports = {
|
|
1125
|
+
Watcher,
|
|
1126
|
+
Session,
|
|
1127
|
+
BackgroundTask,
|
|
1128
|
+
listSessions,
|
|
1129
|
+
listActiveSessions,
|
|
1130
|
+
};
|