cc-control-agent 2.0.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.
- package/dist/auth/device-manager.d.ts +20 -0
- package/dist/auth/device-manager.d.ts.map +1 -0
- package/dist/auth/device-manager.js +101 -0
- package/dist/auth/device-manager.js.map +1 -0
- package/dist/auth/user-credentials.d.ts +35 -0
- package/dist/auth/user-credentials.d.ts.map +1 -0
- package/dist/auth/user-credentials.js +128 -0
- package/dist/auth/user-credentials.js.map +1 -0
- package/dist/claude/hook-handler.d.ts +27 -0
- package/dist/claude/hook-handler.d.ts.map +1 -0
- package/dist/claude/hook-handler.js +191 -0
- package/dist/claude/hook-handler.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +195 -0
- package/dist/cli.js.map +1 -0
- package/dist/command/handler.d.ts +34 -0
- package/dist/command/handler.d.ts.map +1 -0
- package/dist/command/handler.js +371 -0
- package/dist/command/handler.js.map +1 -0
- package/dist/command/validator.d.ts +23 -0
- package/dist/command/validator.d.ts.map +1 -0
- package/dist/command/validator.js +295 -0
- package/dist/command/validator.js.map +1 -0
- package/dist/communication/websocket-client.d.ts +28 -0
- package/dist/communication/websocket-client.d.ts.map +1 -0
- package/dist/communication/websocket-client.js +224 -0
- package/dist/communication/websocket-client.js.map +1 -0
- package/dist/config/default.d.ts +19 -0
- package/dist/config/default.d.ts.map +1 -0
- package/dist/config/default.js +40 -0
- package/dist/config/default.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +155 -0
- package/dist/index.js.map +1 -0
- package/dist/session/claude-monitor.d.ts +66 -0
- package/dist/session/claude-monitor.d.ts.map +1 -0
- package/dist/session/claude-monitor.js +770 -0
- package/dist/session/claude-monitor.js.map +1 -0
- package/dist/session/monitor.d.ts +22 -0
- package/dist/session/monitor.d.ts.map +1 -0
- package/dist/session/monitor.js +189 -0
- package/dist/session/monitor.js.map +1 -0
- package/dist/session/parser.d.ts +51 -0
- package/dist/session/parser.d.ts.map +1 -0
- package/dist/session/parser.js +139 -0
- package/dist/session/parser.js.map +1 -0
- package/dist/utils/logger-new.d.ts +1 -0
- package/dist/utils/logger-new.d.ts.map +1 -0
- package/dist/utils/logger-new.js +2 -0
- package/dist/utils/logger-new.js.map +1 -0
- package/dist/utils/logger.d.ts +11 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +37 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Claude Code Session Monitor - Monitors Real Claude Code Sessions
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { watch } from "chokidar";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { logger } from "../utils/logger.js";
|
|
8
|
+
export class ClaudeSessionMonitor {
|
|
9
|
+
wsClient;
|
|
10
|
+
watcher = null;
|
|
11
|
+
sessionCache = new Map();
|
|
12
|
+
debounceTimer = new Map();
|
|
13
|
+
periodicSyncTimer = null;
|
|
14
|
+
claudePath;
|
|
15
|
+
projectsPath;
|
|
16
|
+
deviceId;
|
|
17
|
+
constructor(wsClient, deviceId, claudePath = `${process.env.HOME}/.claude`) {
|
|
18
|
+
this.wsClient = wsClient;
|
|
19
|
+
this.deviceId = deviceId;
|
|
20
|
+
this.claudePath = claudePath;
|
|
21
|
+
this.projectsPath = join(claudePath, "projects");
|
|
22
|
+
}
|
|
23
|
+
async start() {
|
|
24
|
+
try {
|
|
25
|
+
logger.info(`Starting Claude Code session monitor for: ${this.projectsPath}`);
|
|
26
|
+
// Ensure projects directory exists
|
|
27
|
+
await fs.mkdir(this.projectsPath, { recursive: true });
|
|
28
|
+
// Initialize session cache from existing projects
|
|
29
|
+
await this.initializeSessionCache();
|
|
30
|
+
// Watch all project directories
|
|
31
|
+
this.watcher = watch(this.projectsPath, {
|
|
32
|
+
ignored: /(^|[\/\\])\../,
|
|
33
|
+
persistent: true,
|
|
34
|
+
ignoreInitial: true, // Don't fire for existing files (we already loaded them)
|
|
35
|
+
depth: 2,
|
|
36
|
+
// Use polling on macOS for reliability with frequently-written files
|
|
37
|
+
usePolling: true,
|
|
38
|
+
interval: 2000, // Check every 2 seconds
|
|
39
|
+
});
|
|
40
|
+
// Watch for changes
|
|
41
|
+
this.watcher
|
|
42
|
+
.on("add", (path) => {
|
|
43
|
+
logger.info(`[Watcher] File added: ${path.split("/").pop()}`);
|
|
44
|
+
this.handleFileChange("add", path);
|
|
45
|
+
})
|
|
46
|
+
.on("change", (path) => {
|
|
47
|
+
logger.info(`[Watcher] File changed: ${path.split("/").pop()}`);
|
|
48
|
+
this.handleFileChange("change", path);
|
|
49
|
+
})
|
|
50
|
+
.on("unlink", (path) => this.handleFileChange("unlink", path))
|
|
51
|
+
.on("error", (error) => logger.error("Watcher error", error))
|
|
52
|
+
.on("ready", () => {
|
|
53
|
+
logger.info("Claude Code session watcher ready");
|
|
54
|
+
logger.info(`Monitoring ${this.sessionCache.size} sessions`);
|
|
55
|
+
});
|
|
56
|
+
// Periodic re-sync: scan for recently modified sessions every 30 seconds
|
|
57
|
+
this.periodicSyncTimer = setInterval(() => this.periodicSync(), 30000);
|
|
58
|
+
logger.info("Claude Code session monitor started");
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
logger.error("Failed to start Claude Code session monitor", error);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async initializeSessionCache() {
|
|
66
|
+
try {
|
|
67
|
+
// List all project directories
|
|
68
|
+
const projects = await fs.readdir(this.projectsPath);
|
|
69
|
+
for (const project of projects) {
|
|
70
|
+
const projectDir = join(this.projectsPath, project);
|
|
71
|
+
// Check if it's a directory
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fs.stat(projectDir);
|
|
74
|
+
if (!stat.isDirectory())
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// First, try to read sessions-index.json
|
|
81
|
+
const indexPath = join(projectDir, "sessions-index.json");
|
|
82
|
+
try {
|
|
83
|
+
await fs.access(indexPath);
|
|
84
|
+
await this.processSessionIndex(indexPath, false);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// No index, that's OK - we'll find JSONL files directly
|
|
88
|
+
}
|
|
89
|
+
// Then scan for .jsonl files NOT already in cache
|
|
90
|
+
await this.scanForJsonlFiles(projectDir, false);
|
|
91
|
+
}
|
|
92
|
+
logger.info(`Initialized session cache with ${this.sessionCache.size} sessions`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error("Failed to initialize session cache", error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Scan a project directory for .jsonl session files not in cache
|
|
100
|
+
*/
|
|
101
|
+
async scanForJsonlFiles(projectDir, sendUpdates = true) {
|
|
102
|
+
try {
|
|
103
|
+
const files = await fs.readdir(projectDir);
|
|
104
|
+
// Derive projectPath from the directory name
|
|
105
|
+
// Directory name is the encoded path like "-Users-Krunal-Desktop-Projects-..."
|
|
106
|
+
const dirName = projectDir.split("/").pop() || "";
|
|
107
|
+
const projectPath = dirName.startsWith("-")
|
|
108
|
+
? "/" + dirName.slice(1).replace(/-/g, "/")
|
|
109
|
+
: dirName;
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
if (!file.endsWith(".jsonl"))
|
|
112
|
+
continue;
|
|
113
|
+
const sessionId = file.replace(".jsonl", "");
|
|
114
|
+
// Only process UUID-format session IDs
|
|
115
|
+
if (!/^[a-f0-9-]{36}$/.test(sessionId))
|
|
116
|
+
continue;
|
|
117
|
+
// Skip if already cached
|
|
118
|
+
if (this.sessionCache.has(sessionId))
|
|
119
|
+
continue;
|
|
120
|
+
const filePath = join(projectDir, file);
|
|
121
|
+
try {
|
|
122
|
+
const session = await this.parseSessionFromFile(sessionId, filePath, projectPath);
|
|
123
|
+
if (session) {
|
|
124
|
+
this.sessionCache.set(sessionId, session);
|
|
125
|
+
logger.debug(`Discovered session from JSONL: ${sessionId}`, {
|
|
126
|
+
messageCount: session.messages.length,
|
|
127
|
+
project: projectPath,
|
|
128
|
+
});
|
|
129
|
+
if (sendUpdates) {
|
|
130
|
+
this.sendSessionUpdate(sessionId, session);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
logger.error(`Failed to parse JSONL session ${sessionId}`, error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
logger.error(`Failed to scan for JSONL files in ${projectDir}`, error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Look up customTitle from JSONL lines and sessions-index.json.
|
|
145
|
+
* JSONL has {"type":"custom-title","customTitle":"..."} entries written by /rename.
|
|
146
|
+
*/
|
|
147
|
+
lookupCustomTitleFromLines(lines) {
|
|
148
|
+
// Scan backwards (most recent rename wins)
|
|
149
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(lines[i]);
|
|
152
|
+
if (parsed.type === "custom-title" && parsed.customTitle) {
|
|
153
|
+
// Clean up: take first line only, trim whitespace
|
|
154
|
+
return parsed.customTitle.split("\n")[0].trim();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore */ }
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
async lookupCustomTitleFromIndex(sessionId, filePath) {
|
|
162
|
+
try {
|
|
163
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
164
|
+
const indexPath = join(dir, "sessions-index.json");
|
|
165
|
+
const content = await fs.readFile(indexPath, "utf-8");
|
|
166
|
+
const index = JSON.parse(content);
|
|
167
|
+
const entry = index.entries.find(e => e.sessionId === sessionId);
|
|
168
|
+
return entry?.customTitle;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Parse a session directly from a JSONL file (no index entry needed)
|
|
176
|
+
*/
|
|
177
|
+
async parseSessionFromFile(sessionId, filePath, projectPath) {
|
|
178
|
+
try {
|
|
179
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
180
|
+
const lines = content.split("\n").filter(line => line.trim().length > 0);
|
|
181
|
+
if (lines.length === 0)
|
|
182
|
+
return null;
|
|
183
|
+
const messages = await this.parseMessages(lines, sessionId);
|
|
184
|
+
// Get file stat for timestamps
|
|
185
|
+
const fileStat = await fs.stat(filePath);
|
|
186
|
+
// Extract first prompt and summary from messages
|
|
187
|
+
const firstUserMsg = messages.find(m => m.role === "user");
|
|
188
|
+
const firstPrompt = firstUserMsg?.content?.slice(0, 200) || "";
|
|
189
|
+
// Parse first few messages for cwd info
|
|
190
|
+
let cwd = projectPath;
|
|
191
|
+
for (let i = 0; i < Math.min(5, lines.length); i++) {
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(lines[i]);
|
|
194
|
+
if (parsed.cwd) {
|
|
195
|
+
cwd = parsed.cwd;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch { /* ignore */ }
|
|
200
|
+
}
|
|
201
|
+
// Look up customTitle: first from JSONL (type: "custom-title"), then from index
|
|
202
|
+
const customTitle = this.lookupCustomTitleFromLines(lines)
|
|
203
|
+
|| await this.lookupCustomTitleFromIndex(sessionId, filePath);
|
|
204
|
+
// Determine status based on file modification time and message history
|
|
205
|
+
const status = this.calculateStatusFromMessages(messages, fileStat.mtime);
|
|
206
|
+
const session = {
|
|
207
|
+
id: sessionId,
|
|
208
|
+
deviceId: this.deviceId,
|
|
209
|
+
status,
|
|
210
|
+
messages,
|
|
211
|
+
filesAccessed: [],
|
|
212
|
+
lastActivity: fileStat.mtime,
|
|
213
|
+
createdAt: fileStat.birthtime || fileStat.mtime,
|
|
214
|
+
metadata: {
|
|
215
|
+
workingDirectory: cwd,
|
|
216
|
+
claudeVersion: "unknown",
|
|
217
|
+
summary: firstPrompt.slice(0, 100),
|
|
218
|
+
firstPrompt,
|
|
219
|
+
customTitle,
|
|
220
|
+
messageCount: messages.length,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
return session;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
logger.error(`Failed to parse session from file ${filePath}`, error);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async sendAllSessionsToRelay() {
|
|
231
|
+
if (!this.wsClient.isConnected()) {
|
|
232
|
+
logger.warn("WebSocket not connected, cannot send sessions");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Sort sessions by lastActivity descending (latest first)
|
|
236
|
+
const sortedSessions = Array.from(this.sessionCache.entries())
|
|
237
|
+
.sort(([, a], [, b]) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
238
|
+
const totalSessions = sortedSessions.length;
|
|
239
|
+
logger.info(`Syncing ${totalSessions} sessions to relay server in batches...`);
|
|
240
|
+
// Send in batches of 10 with delay between batches
|
|
241
|
+
const BATCH_SIZE = 10;
|
|
242
|
+
const BATCH_DELAY_MS = 500;
|
|
243
|
+
let successCount = 0;
|
|
244
|
+
for (let i = 0; i < sortedSessions.length; i += BATCH_SIZE) {
|
|
245
|
+
if (!this.wsClient.isConnected()) {
|
|
246
|
+
logger.warn("WebSocket disconnected during sync, stopping");
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
const batch = sortedSessions.slice(i, i + BATCH_SIZE);
|
|
250
|
+
const lightweightSessions = batch.map(([, session]) => {
|
|
251
|
+
const msgs = session.messages || [];
|
|
252
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
253
|
+
const lastMessage = lastMsg
|
|
254
|
+
? {
|
|
255
|
+
role: lastMsg.role,
|
|
256
|
+
content: lastMsg.content?.slice(0, 200) || "",
|
|
257
|
+
timestamp: lastMsg.timestamp,
|
|
258
|
+
}
|
|
259
|
+
: undefined;
|
|
260
|
+
// Last 30 messages with content truncated for DB storage
|
|
261
|
+
const recentMessages = msgs.slice(-30).map(m => ({
|
|
262
|
+
id: m.id,
|
|
263
|
+
sessionId: m.sessionId,
|
|
264
|
+
role: m.role,
|
|
265
|
+
content: m.content?.slice(0, 2000) || "",
|
|
266
|
+
timestamp: m.timestamp,
|
|
267
|
+
metadata: m.metadata,
|
|
268
|
+
toolCalls: m.toolCalls?.map(tc => ({
|
|
269
|
+
...tc,
|
|
270
|
+
result: typeof tc.result === "string" ? tc.result.slice(0, 1500) : tc.result,
|
|
271
|
+
})),
|
|
272
|
+
}));
|
|
273
|
+
return {
|
|
274
|
+
id: session.id,
|
|
275
|
+
sessionId: session.id,
|
|
276
|
+
deviceId: this.deviceId,
|
|
277
|
+
status: this.calculateStatusFromMessages(msgs, session.lastActivity),
|
|
278
|
+
metadata: session.metadata,
|
|
279
|
+
lastActivity: session.lastActivity,
|
|
280
|
+
createdAt: session.createdAt,
|
|
281
|
+
messageCount: msgs.length || session.metadata?.messageCount || 0,
|
|
282
|
+
workingDirectory: session.metadata?.workingDirectory,
|
|
283
|
+
projectName: session.metadata?.customTitle || session.metadata?.project,
|
|
284
|
+
summary: session.metadata?.summary,
|
|
285
|
+
firstPrompt: session.metadata?.firstPrompt,
|
|
286
|
+
lastMessage,
|
|
287
|
+
recentMessages,
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
this.wsClient.send({
|
|
292
|
+
type: "sessions:sync",
|
|
293
|
+
payload: { sessions: lightweightSessions },
|
|
294
|
+
});
|
|
295
|
+
successCount += batch.length;
|
|
296
|
+
logger.info(`Batch ${Math.floor(i / BATCH_SIZE) + 1}: sent ${batch.length} sessions (${successCount}/${totalSessions})`);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
logger.error(`Batch sync failed at offset ${i}`, error);
|
|
300
|
+
}
|
|
301
|
+
// Wait between batches to avoid overwhelming the socket
|
|
302
|
+
if (i + BATCH_SIZE < sortedSessions.length) {
|
|
303
|
+
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
logger.info(`Session sync complete: ${successCount}/${totalSessions} sent`);
|
|
307
|
+
}
|
|
308
|
+
async sendInitialSessions(limit = 10) {
|
|
309
|
+
if (!this.wsClient.isConnected()) {
|
|
310
|
+
logger.warn("WebSocket not connected, cannot send sessions");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const sessions = Array.from(this.sessionCache.entries()).slice(0, limit);
|
|
314
|
+
logger.info(`Sending initial ${sessions.length} sessions to relay server...`);
|
|
315
|
+
let sentCount = 0;
|
|
316
|
+
for (const [sessionId, session] of sessions) {
|
|
317
|
+
this.sendSessionUpdate(sessionId, session);
|
|
318
|
+
sentCount++;
|
|
319
|
+
// Longer delay to avoid overwhelming the socket
|
|
320
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
321
|
+
}
|
|
322
|
+
logger.info(`Sent ${sentCount} initial sessions to relay server`);
|
|
323
|
+
}
|
|
324
|
+
handleFileChange(event, path) {
|
|
325
|
+
// Process sessions-index.json files
|
|
326
|
+
if (path.endsWith("sessions-index.json")) {
|
|
327
|
+
this.debounce("index", path, async () => {
|
|
328
|
+
await this.processSessionIndex(path);
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Process .jsonl session files
|
|
333
|
+
if (path.endsWith(".jsonl")) {
|
|
334
|
+
this.debounce("session", path, async () => {
|
|
335
|
+
await this.processSessionFile(path);
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
debounce(type, key, fn) {
|
|
341
|
+
const debounceKey = `${type}:${key}`;
|
|
342
|
+
const existingTimer = this.debounceTimer.get(debounceKey);
|
|
343
|
+
if (existingTimer) {
|
|
344
|
+
clearTimeout(existingTimer);
|
|
345
|
+
}
|
|
346
|
+
const timer = setTimeout(async () => {
|
|
347
|
+
try {
|
|
348
|
+
await fn();
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
logger.error(`Debounced function failed for ${debounceKey}`, error);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
this.debounceTimer.delete(debounceKey);
|
|
355
|
+
}
|
|
356
|
+
}, 500); // 500ms debounce
|
|
357
|
+
this.debounceTimer.set(debounceKey, timer);
|
|
358
|
+
}
|
|
359
|
+
async processSessionIndex(indexPath, sendUpdates = true) {
|
|
360
|
+
try {
|
|
361
|
+
const content = await fs.readFile(indexPath, "utf-8");
|
|
362
|
+
const index = JSON.parse(content);
|
|
363
|
+
logger.debug(`Processing session index: ${indexPath}`, {
|
|
364
|
+
entries: index.entries.length,
|
|
365
|
+
});
|
|
366
|
+
// Process each session in the index
|
|
367
|
+
for (const entry of index.entries) {
|
|
368
|
+
try {
|
|
369
|
+
// Read the session JSONL file
|
|
370
|
+
const sessionContent = await fs.readFile(entry.fullPath, "utf-8");
|
|
371
|
+
const session = await this.parseSession(entry, sessionContent);
|
|
372
|
+
if (session) {
|
|
373
|
+
// Check if session changed
|
|
374
|
+
const cached = this.sessionCache.get(session.id);
|
|
375
|
+
const hasChanged = !cached ||
|
|
376
|
+
JSON.stringify(cached.messages) !== JSON.stringify(session.messages);
|
|
377
|
+
if (hasChanged) {
|
|
378
|
+
this.sessionCache.set(session.id, session);
|
|
379
|
+
logger.debug(`Session updated: ${session.id}`, {
|
|
380
|
+
messageCount: session.messages.length,
|
|
381
|
+
summary: session.metadata?.summary,
|
|
382
|
+
});
|
|
383
|
+
// Send update to relay server only if requested
|
|
384
|
+
if (sendUpdates) {
|
|
385
|
+
this.sendSessionUpdate(session.id, session);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
logger.error(`Failed to process session ${entry.sessionId}`, error);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
logger.error(`Failed to process session index: ${indexPath}`, error);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async processSessionFile(sessionPath) {
|
|
400
|
+
try {
|
|
401
|
+
const content = await fs.readFile(sessionPath, "utf-8");
|
|
402
|
+
const lines = content.split("\n").filter(line => line.trim().length > 0);
|
|
403
|
+
// Extract session ID from path
|
|
404
|
+
const sessionId = this.extractSessionId(sessionPath);
|
|
405
|
+
if (!sessionId)
|
|
406
|
+
return;
|
|
407
|
+
// Get cached session - if not cached, create from file
|
|
408
|
+
let cached = this.sessionCache.get(sessionId);
|
|
409
|
+
if (!cached) {
|
|
410
|
+
// Derive project path from the parent directory name
|
|
411
|
+
const dirName = sessionPath.split("/").slice(-2, -1)[0] || "";
|
|
412
|
+
const projectPath = dirName.startsWith("-")
|
|
413
|
+
? "/" + dirName.slice(1).replace(/-/g, "/")
|
|
414
|
+
: dirName;
|
|
415
|
+
const parsed = await this.parseSessionFromFile(sessionId, sessionPath, projectPath);
|
|
416
|
+
if (!parsed)
|
|
417
|
+
return;
|
|
418
|
+
cached = parsed;
|
|
419
|
+
this.sessionCache.set(sessionId, cached);
|
|
420
|
+
logger.info(`New session discovered: ${sessionId}`);
|
|
421
|
+
this.sendSessionUpdate(sessionId, cached);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Parse messages from JSONL
|
|
425
|
+
const messages = await this.parseMessages(lines, sessionId);
|
|
426
|
+
// Check if messages changed (compare count for performance)
|
|
427
|
+
const hasChanged = cached.messages.length !== messages.length ||
|
|
428
|
+
(messages.length > 0 && cached.messages.length > 0 &&
|
|
429
|
+
messages[messages.length - 1]?.id !== cached.messages[cached.messages.length - 1]?.id);
|
|
430
|
+
// Also refresh customTitle from JSONL lines or index (user may have renamed)
|
|
431
|
+
const customTitle = this.lookupCustomTitleFromLines(lines)
|
|
432
|
+
|| await this.lookupCustomTitleFromIndex(sessionId, sessionPath);
|
|
433
|
+
const titleChanged = !!customTitle && cached.metadata?.customTitle !== customTitle;
|
|
434
|
+
if (titleChanged && cached.metadata) {
|
|
435
|
+
cached.metadata.customTitle = customTitle;
|
|
436
|
+
}
|
|
437
|
+
if (hasChanged || titleChanged) {
|
|
438
|
+
cached.messages = messages;
|
|
439
|
+
cached.lastActivity = new Date();
|
|
440
|
+
cached.status = this.calculateStatusFromMessages(messages, new Date());
|
|
441
|
+
cached.deviceId = this.deviceId;
|
|
442
|
+
if (cached.metadata) {
|
|
443
|
+
cached.metadata.messageCount = messages.length;
|
|
444
|
+
}
|
|
445
|
+
this.sessionCache.set(sessionId, cached);
|
|
446
|
+
this.sendSessionUpdate(sessionId, cached);
|
|
447
|
+
logger.debug(`Session file updated: ${sessionId}`, {
|
|
448
|
+
messageCount: messages.length,
|
|
449
|
+
status: cached.status,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
logger.error(`Failed to process session file: ${sessionPath}`, error);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async parseSession(entry, content) {
|
|
458
|
+
try {
|
|
459
|
+
const lines = content.split("\n").filter(line => line.trim().length > 0);
|
|
460
|
+
const messages = await this.parseMessages(lines, entry.sessionId);
|
|
461
|
+
// Use actual file modification time for accurate status (index timestamps can be stale)
|
|
462
|
+
let lastModified;
|
|
463
|
+
try {
|
|
464
|
+
const fileStat = await fs.stat(entry.fullPath);
|
|
465
|
+
lastModified = fileStat.mtime;
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
lastModified = new Date(entry.modified);
|
|
469
|
+
}
|
|
470
|
+
const status = this.calculateStatusFromMessages(messages, lastModified);
|
|
471
|
+
const session = {
|
|
472
|
+
id: entry.sessionId,
|
|
473
|
+
deviceId: this.deviceId,
|
|
474
|
+
status,
|
|
475
|
+
messages,
|
|
476
|
+
filesAccessed: [],
|
|
477
|
+
lastActivity: lastModified,
|
|
478
|
+
createdAt: new Date(entry.created),
|
|
479
|
+
metadata: {
|
|
480
|
+
workingDirectory: entry.projectPath,
|
|
481
|
+
claudeVersion: "unknown",
|
|
482
|
+
summary: entry.summary,
|
|
483
|
+
firstPrompt: entry.firstPrompt,
|
|
484
|
+
customTitle: entry.customTitle,
|
|
485
|
+
messageCount: entry.messageCount || messages.length,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
return session;
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
logger.error(`Failed to parse session ${entry.sessionId}`, error);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Calculate session status based on last modification time and message history.
|
|
497
|
+
* - If file not modified in >5 minutes -> "idle"
|
|
498
|
+
* - If last message role is "assistant" -> "waiting_input" (Claude finished, waiting for user)
|
|
499
|
+
* - If last message role is "user" -> "processing" (Claude is working)
|
|
500
|
+
* - Default -> "processing"
|
|
501
|
+
*/
|
|
502
|
+
calculateStatusFromMessages(messages, lastModified) {
|
|
503
|
+
const IDLE_THRESHOLD = 5 * 60 * 1000;
|
|
504
|
+
const timeSinceModified = Date.now() - lastModified.getTime();
|
|
505
|
+
if (timeSinceModified > IDLE_THRESHOLD)
|
|
506
|
+
return "idle";
|
|
507
|
+
const lastMsg = messages[messages.length - 1];
|
|
508
|
+
if (!lastMsg)
|
|
509
|
+
return "processing";
|
|
510
|
+
// Assistant spoke last = waiting for user input
|
|
511
|
+
if (lastMsg.role === "assistant")
|
|
512
|
+
return "waiting_input";
|
|
513
|
+
// User spoke last = Claude is processing
|
|
514
|
+
return "processing";
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Backward-compatible status calculation when messages are not available.
|
|
518
|
+
*/
|
|
519
|
+
calculateStatus(lastModified) {
|
|
520
|
+
return this.calculateStatusFromMessages([], lastModified);
|
|
521
|
+
}
|
|
522
|
+
async parseMessages(lines, sessionId) {
|
|
523
|
+
const messages = [];
|
|
524
|
+
// Pass 1: Collect tool_result content keyed by tool_use_id
|
|
525
|
+
const toolResultMap = new Map();
|
|
526
|
+
for (const line of lines) {
|
|
527
|
+
try {
|
|
528
|
+
const parsed = JSON.parse(line);
|
|
529
|
+
if (!parsed.message || parsed.type === "file-history-snapshot")
|
|
530
|
+
continue;
|
|
531
|
+
if (!Array.isArray(parsed.message.content))
|
|
532
|
+
continue;
|
|
533
|
+
for (const block of parsed.message.content) {
|
|
534
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
535
|
+
let resultText = "";
|
|
536
|
+
if (typeof block.content === "string") {
|
|
537
|
+
resultText = block.content;
|
|
538
|
+
}
|
|
539
|
+
else if (Array.isArray(block.content)) {
|
|
540
|
+
resultText = block.content
|
|
541
|
+
.filter((c) => c.type === "text")
|
|
542
|
+
.map((c) => c.text)
|
|
543
|
+
.join("\n");
|
|
544
|
+
}
|
|
545
|
+
toolResultMap.set(block.tool_use_id, resultText);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Pass 2: Build messages with structured toolCalls
|
|
554
|
+
for (const line of lines) {
|
|
555
|
+
try {
|
|
556
|
+
const parsed = JSON.parse(line);
|
|
557
|
+
if (!parsed.message || parsed.type === "file-history-snapshot") {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const role = parsed.message.role;
|
|
561
|
+
let textContent = "";
|
|
562
|
+
let toolCalls = [];
|
|
563
|
+
let isToolResult = false;
|
|
564
|
+
if (typeof parsed.message.content === "string") {
|
|
565
|
+
textContent = parsed.message.content;
|
|
566
|
+
}
|
|
567
|
+
else if (Array.isArray(parsed.message.content)) {
|
|
568
|
+
textContent = parsed.message.content
|
|
569
|
+
.filter(c => c.type === "text")
|
|
570
|
+
.map(c => c.text)
|
|
571
|
+
.join("\n");
|
|
572
|
+
for (const block of parsed.message.content) {
|
|
573
|
+
if (block.type === "tool_use") {
|
|
574
|
+
const toolId = block.id || block.tool_use_id || "";
|
|
575
|
+
const result = toolResultMap.get(toolId) || "";
|
|
576
|
+
toolCalls.push({
|
|
577
|
+
id: toolId,
|
|
578
|
+
name: block.name || "",
|
|
579
|
+
arguments: block.input || {},
|
|
580
|
+
result: result.slice(0, 1500),
|
|
581
|
+
status: "completed",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (block.type === "tool_result") {
|
|
585
|
+
isToolResult = true;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Skip tool_result "user" messages - they're system-generated responses
|
|
590
|
+
if (isToolResult)
|
|
591
|
+
continue;
|
|
592
|
+
// Skip if nothing to show
|
|
593
|
+
if (!textContent.trim() && toolCalls.length === 0)
|
|
594
|
+
continue;
|
|
595
|
+
const message = {
|
|
596
|
+
id: parsed.message.id || parsed.uuid,
|
|
597
|
+
sessionId,
|
|
598
|
+
role,
|
|
599
|
+
content: textContent.trim(),
|
|
600
|
+
timestamp: new Date(parsed.timestamp),
|
|
601
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
602
|
+
metadata: {
|
|
603
|
+
model: parsed.message.model,
|
|
604
|
+
gitBranch: parsed.gitBranch,
|
|
605
|
+
cwd: parsed.cwd,
|
|
606
|
+
usage: parsed.message.usage,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
messages.push(message);
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return messages;
|
|
616
|
+
}
|
|
617
|
+
extractSessionId(path) {
|
|
618
|
+
// Extract session ID from path
|
|
619
|
+
// Format: /path/to/session-{id}.jsonl
|
|
620
|
+
const match = path.match(/([a-f0-9-]{36})\.jsonl$/);
|
|
621
|
+
return match ? match[1] : null;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Send a lightweight session update (no messages array - just metadata)
|
|
625
|
+
*/
|
|
626
|
+
sendSessionUpdate(sessionId, session) {
|
|
627
|
+
if (!this.wsClient.isConnected()) {
|
|
628
|
+
logger.warn("WebSocket not connected, skipping session update");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
// Recalculate status dynamically based on current time and messages
|
|
633
|
+
const msgs = session.messages || [];
|
|
634
|
+
const currentStatus = this.calculateStatusFromMessages(msgs, session.lastActivity);
|
|
635
|
+
// Extract last message info for the lightweight payload
|
|
636
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
637
|
+
const lastMessage = lastMsg
|
|
638
|
+
? {
|
|
639
|
+
role: lastMsg.role,
|
|
640
|
+
content: lastMsg.content?.slice(0, 200) || "",
|
|
641
|
+
timestamp: lastMsg.timestamp,
|
|
642
|
+
}
|
|
643
|
+
: undefined;
|
|
644
|
+
// Include recent messages so the relay can update session_messages table
|
|
645
|
+
const recentMessages = msgs.slice(-30).map(m => ({
|
|
646
|
+
id: m.id,
|
|
647
|
+
sessionId: m.sessionId,
|
|
648
|
+
role: m.role,
|
|
649
|
+
content: m.content?.slice(0, 2000) || "",
|
|
650
|
+
timestamp: m.timestamp,
|
|
651
|
+
metadata: m.metadata,
|
|
652
|
+
toolCalls: m.toolCalls?.map(tc => ({
|
|
653
|
+
...tc,
|
|
654
|
+
result: typeof tc.result === "string" ? tc.result.slice(0, 1500) : tc.result,
|
|
655
|
+
})),
|
|
656
|
+
}));
|
|
657
|
+
const payload = {
|
|
658
|
+
session: {
|
|
659
|
+
id: session.id,
|
|
660
|
+
sessionId: session.id,
|
|
661
|
+
deviceId: this.deviceId,
|
|
662
|
+
status: currentStatus,
|
|
663
|
+
metadata: session.metadata,
|
|
664
|
+
lastActivity: session.lastActivity,
|
|
665
|
+
createdAt: session.createdAt,
|
|
666
|
+
messageCount: msgs.length || session.metadata?.messageCount || 0,
|
|
667
|
+
lastMessage,
|
|
668
|
+
recentMessages,
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
this.wsClient.send({
|
|
672
|
+
type: "state:session_update",
|
|
673
|
+
payload,
|
|
674
|
+
});
|
|
675
|
+
logger.info(`Session update sent: ${sessionId}`, {
|
|
676
|
+
messageCount: msgs.length,
|
|
677
|
+
recentMessages: recentMessages.length,
|
|
678
|
+
status: currentStatus,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
logger.error(`Error sending session update for ${sessionId}`, error);
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Periodic sync: scan for recently modified JSONL files and push updates.
|
|
688
|
+
* Safety net in case the file watcher misses changes.
|
|
689
|
+
*/
|
|
690
|
+
async periodicSync() {
|
|
691
|
+
if (!this.wsClient.isConnected())
|
|
692
|
+
return;
|
|
693
|
+
try {
|
|
694
|
+
const projects = await fs.readdir(this.projectsPath);
|
|
695
|
+
const now = Date.now();
|
|
696
|
+
const RECENT_THRESHOLD = 60000; // Files modified in last 60 seconds
|
|
697
|
+
for (const project of projects) {
|
|
698
|
+
const projectDir = join(this.projectsPath, project);
|
|
699
|
+
try {
|
|
700
|
+
const stat = await fs.stat(projectDir);
|
|
701
|
+
if (!stat.isDirectory())
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const files = await fs.readdir(projectDir);
|
|
708
|
+
for (const file of files) {
|
|
709
|
+
if (!file.endsWith(".jsonl"))
|
|
710
|
+
continue;
|
|
711
|
+
const sessionId = file.replace(".jsonl", "");
|
|
712
|
+
if (!/^[a-f0-9-]{36}$/.test(sessionId))
|
|
713
|
+
continue;
|
|
714
|
+
const filePath = join(projectDir, file);
|
|
715
|
+
try {
|
|
716
|
+
const fileStat = await fs.stat(filePath);
|
|
717
|
+
if (now - fileStat.mtime.getTime() > RECENT_THRESHOLD)
|
|
718
|
+
continue;
|
|
719
|
+
// File was recently modified - process it
|
|
720
|
+
await this.processSessionFile(filePath);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
logger.error("Periodic sync failed", error);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async stop() {
|
|
733
|
+
logger.info("Stopping Claude Code session monitor...");
|
|
734
|
+
// Clear periodic sync timer
|
|
735
|
+
if (this.periodicSyncTimer) {
|
|
736
|
+
clearInterval(this.periodicSyncTimer);
|
|
737
|
+
this.periodicSyncTimer = null;
|
|
738
|
+
}
|
|
739
|
+
// Clear debounce timers
|
|
740
|
+
for (const timer of this.debounceTimer.values()) {
|
|
741
|
+
clearTimeout(timer);
|
|
742
|
+
}
|
|
743
|
+
this.debounceTimer.clear();
|
|
744
|
+
// Close file watcher
|
|
745
|
+
if (this.watcher) {
|
|
746
|
+
await this.watcher.close();
|
|
747
|
+
this.watcher = null;
|
|
748
|
+
}
|
|
749
|
+
// Clear session cache
|
|
750
|
+
this.sessionCache.clear();
|
|
751
|
+
logger.info("Claude Code session monitor stopped");
|
|
752
|
+
}
|
|
753
|
+
getSessionCache() {
|
|
754
|
+
return new Map(this.sessionCache);
|
|
755
|
+
}
|
|
756
|
+
getSession(sessionId) {
|
|
757
|
+
return this.sessionCache.get(sessionId);
|
|
758
|
+
}
|
|
759
|
+
getAllSessions() {
|
|
760
|
+
return Array.from(this.sessionCache.values());
|
|
761
|
+
}
|
|
762
|
+
getSessionsByProject(projectPath) {
|
|
763
|
+
return this.getAllSessions().filter(session => session.metadata?.workingDirectory === projectPath);
|
|
764
|
+
}
|
|
765
|
+
getActiveSessions(minutes = 5) {
|
|
766
|
+
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
|
|
767
|
+
return this.getAllSessions().filter(session => session.lastActivity > cutoff);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
//# sourceMappingURL=claude-monitor.js.map
|