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.
Files changed (57) hide show
  1. package/dist/auth/device-manager.d.ts +20 -0
  2. package/dist/auth/device-manager.d.ts.map +1 -0
  3. package/dist/auth/device-manager.js +101 -0
  4. package/dist/auth/device-manager.js.map +1 -0
  5. package/dist/auth/user-credentials.d.ts +35 -0
  6. package/dist/auth/user-credentials.d.ts.map +1 -0
  7. package/dist/auth/user-credentials.js +128 -0
  8. package/dist/auth/user-credentials.js.map +1 -0
  9. package/dist/claude/hook-handler.d.ts +27 -0
  10. package/dist/claude/hook-handler.d.ts.map +1 -0
  11. package/dist/claude/hook-handler.js +191 -0
  12. package/dist/claude/hook-handler.js.map +1 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +195 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/command/handler.d.ts +34 -0
  18. package/dist/command/handler.d.ts.map +1 -0
  19. package/dist/command/handler.js +371 -0
  20. package/dist/command/handler.js.map +1 -0
  21. package/dist/command/validator.d.ts +23 -0
  22. package/dist/command/validator.d.ts.map +1 -0
  23. package/dist/command/validator.js +295 -0
  24. package/dist/command/validator.js.map +1 -0
  25. package/dist/communication/websocket-client.d.ts +28 -0
  26. package/dist/communication/websocket-client.d.ts.map +1 -0
  27. package/dist/communication/websocket-client.js +224 -0
  28. package/dist/communication/websocket-client.js.map +1 -0
  29. package/dist/config/default.d.ts +19 -0
  30. package/dist/config/default.d.ts.map +1 -0
  31. package/dist/config/default.js +40 -0
  32. package/dist/config/default.js.map +1 -0
  33. package/dist/index.d.ts +16 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +155 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/session/claude-monitor.d.ts +66 -0
  38. package/dist/session/claude-monitor.d.ts.map +1 -0
  39. package/dist/session/claude-monitor.js +770 -0
  40. package/dist/session/claude-monitor.js.map +1 -0
  41. package/dist/session/monitor.d.ts +22 -0
  42. package/dist/session/monitor.d.ts.map +1 -0
  43. package/dist/session/monitor.js +189 -0
  44. package/dist/session/monitor.js.map +1 -0
  45. package/dist/session/parser.d.ts +51 -0
  46. package/dist/session/parser.d.ts.map +1 -0
  47. package/dist/session/parser.js +139 -0
  48. package/dist/session/parser.js.map +1 -0
  49. package/dist/utils/logger-new.d.ts +1 -0
  50. package/dist/utils/logger-new.d.ts.map +1 -0
  51. package/dist/utils/logger-new.js +2 -0
  52. package/dist/utils/logger-new.js.map +1 -0
  53. package/dist/utils/logger.d.ts +11 -0
  54. package/dist/utils/logger.d.ts.map +1 -0
  55. package/dist/utils/logger.js +37 -0
  56. package/dist/utils/logger.js.map +1 -0
  57. 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