@cmdctrl/claude-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/adapter/claude-cli.d.ts +41 -0
  2. package/dist/adapter/claude-cli.d.ts.map +1 -0
  3. package/dist/adapter/claude-cli.js +525 -0
  4. package/dist/adapter/claude-cli.js.map +1 -0
  5. package/dist/adapter/events.d.ts +52 -0
  6. package/dist/adapter/events.d.ts.map +1 -0
  7. package/dist/adapter/events.js +134 -0
  8. package/dist/adapter/events.js.map +1 -0
  9. package/dist/client/messages.d.ts +140 -0
  10. package/dist/client/messages.d.ts.map +1 -0
  11. package/dist/client/messages.js +6 -0
  12. package/dist/client/messages.js.map +1 -0
  13. package/dist/client/websocket.d.ts +115 -0
  14. package/dist/client/websocket.d.ts.map +1 -0
  15. package/dist/client/websocket.js +434 -0
  16. package/dist/client/websocket.js.map +1 -0
  17. package/dist/commands/register.d.ts +10 -0
  18. package/dist/commands/register.d.ts.map +1 -0
  19. package/dist/commands/register.js +175 -0
  20. package/dist/commands/register.js.map +1 -0
  21. package/dist/commands/start.d.ts +9 -0
  22. package/dist/commands/start.d.ts.map +1 -0
  23. package/dist/commands/start.js +54 -0
  24. package/dist/commands/start.js.map +1 -0
  25. package/dist/commands/status.d.ts +5 -0
  26. package/dist/commands/status.d.ts.map +1 -0
  27. package/dist/commands/status.js +38 -0
  28. package/dist/commands/status.js.map +1 -0
  29. package/dist/commands/stop.d.ts +5 -0
  30. package/dist/commands/stop.d.ts.map +1 -0
  31. package/dist/commands/stop.js +59 -0
  32. package/dist/commands/stop.js.map +1 -0
  33. package/dist/commands/unregister.d.ts +5 -0
  34. package/dist/commands/unregister.d.ts.map +1 -0
  35. package/dist/commands/unregister.js +28 -0
  36. package/dist/commands/unregister.js.map +1 -0
  37. package/dist/config/config.d.ts +68 -0
  38. package/dist/config/config.d.ts.map +1 -0
  39. package/dist/config/config.js +193 -0
  40. package/dist/config/config.js.map +1 -0
  41. package/dist/handlers/context-handler.d.ts +37 -0
  42. package/dist/handlers/context-handler.d.ts.map +1 -0
  43. package/dist/handlers/context-handler.js +303 -0
  44. package/dist/handlers/context-handler.js.map +1 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +39 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/message-reader.d.ts +25 -0
  50. package/dist/message-reader.d.ts.map +1 -0
  51. package/dist/message-reader.js +454 -0
  52. package/dist/message-reader.js.map +1 -0
  53. package/dist/session-discovery.d.ts +48 -0
  54. package/dist/session-discovery.d.ts.map +1 -0
  55. package/dist/session-discovery.js +496 -0
  56. package/dist/session-discovery.js.map +1 -0
  57. package/dist/session-watcher.d.ts +92 -0
  58. package/dist/session-watcher.d.ts.map +1 -0
  59. package/dist/session-watcher.js +494 -0
  60. package/dist/session-watcher.js.map +1 -0
  61. package/dist/session-watcher.test.d.ts +9 -0
  62. package/dist/session-watcher.test.d.ts.map +1 -0
  63. package/dist/session-watcher.test.js +149 -0
  64. package/dist/session-watcher.test.js.map +1 -0
  65. package/jest.config.js +8 -0
  66. package/package.json +42 -0
  67. package/src/adapter/claude-cli.ts +591 -0
  68. package/src/adapter/events.ts +186 -0
  69. package/src/client/messages.ts +193 -0
  70. package/src/client/websocket.ts +509 -0
  71. package/src/commands/register.ts +201 -0
  72. package/src/commands/start.ts +70 -0
  73. package/src/commands/status.ts +47 -0
  74. package/src/commands/stop.ts +58 -0
  75. package/src/commands/unregister.ts +30 -0
  76. package/src/config/config.ts +163 -0
  77. package/src/handlers/context-handler.ts +337 -0
  78. package/src/index.ts +45 -0
  79. package/src/message-reader.ts +485 -0
  80. package/src/session-discovery.ts +557 -0
  81. package/src/session-watcher.test.ts +141 -0
  82. package/src/session-watcher.ts +560 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,557 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import * as readline from 'readline';
5
+
6
+ const ACTIVE_THRESHOLD_MS = 30 * 1000; // 30 seconds
7
+ const TAIL_BYTES = 65536; // 64KB - only used as fallback
8
+
9
+ // Cache for sessions parsed from disk (not in index)
10
+ // Maps session_id -> { session, fileMtime }
11
+ const parsedSessionCache = new Map<string, { session: ExternalSession; fileMtime: number }>();
12
+ let lastDiscoveryTime = 0;
13
+
14
+ export interface ExternalSession {
15
+ session_id: string;
16
+ slug: string;
17
+ title: string; // Generated from first user message or slug
18
+ project: string;
19
+ project_name: string;
20
+ file_path: string;
21
+ last_message: string;
22
+ last_activity: string; // ISO timestamp
23
+ is_active: boolean;
24
+ message_count: number;
25
+ }
26
+
27
+ export interface ExternalSessionsByProject {
28
+ project: string;
29
+ project_name: string;
30
+ sessions: ExternalSession[];
31
+ }
32
+
33
+ export interface SessionEntry {
34
+ type: string;
35
+ sessionId?: string;
36
+ slug?: string;
37
+ cwd?: string;
38
+ timestamp?: string; // ISO timestamp of the entry
39
+ message?: {
40
+ role?: string;
41
+ content?: string | any;
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Claude Code's sessions-index.json format
47
+ */
48
+ interface SessionsIndex {
49
+ version: number;
50
+ entries: SessionIndexEntry[];
51
+ }
52
+
53
+ interface SessionIndexEntry {
54
+ sessionId: string;
55
+ fullPath: string;
56
+ fileMtime: number;
57
+ firstPrompt: string;
58
+ customTitle?: string;
59
+ summary?: string;
60
+ messageCount: number;
61
+ created: string;
62
+ modified: string; // Actual last message timestamp (not file mtime!)
63
+ gitBranch?: string;
64
+ projectPath: string;
65
+ isSidechain: boolean;
66
+ }
67
+
68
+ /**
69
+ * Generate a title from message content (first line, truncated)
70
+ */
71
+ function generateTitle(message: string): string {
72
+ if (!message) return '';
73
+ const firstLine = message.split('\n')[0].trim();
74
+ if (firstLine.length === 0) return '';
75
+ if (firstLine.length <= 50) return firstLine;
76
+
77
+ // Truncate at word boundary
78
+ const truncated = firstLine.slice(0, 50);
79
+ const lastSpace = truncated.lastIndexOf(' ');
80
+ if (lastSpace > 30) {
81
+ return truncated.slice(0, lastSpace) + '...';
82
+ }
83
+ return truncated + '...';
84
+ }
85
+
86
+ /**
87
+ * Extract readable text from message content (handles string or array of content blocks)
88
+ */
89
+ function extractReadableText(content: unknown): string {
90
+ // Simple string
91
+ if (typeof content === 'string') {
92
+ return content.trim();
93
+ }
94
+
95
+ // Array of content blocks (Claude format)
96
+ if (Array.isArray(content)) {
97
+ const textParts: string[] = [];
98
+ for (const block of content) {
99
+ if (typeof block === 'string') {
100
+ textParts.push(block);
101
+ } else if (block && typeof block === 'object') {
102
+ // Text block: { type: 'text', text: '...' }
103
+ if (block.type === 'text' && typeof block.text === 'string') {
104
+ textParts.push(block.text);
105
+ }
106
+ // Skip tool_use, tool_result, image blocks etc.
107
+ }
108
+ }
109
+ return textParts.join(' ').trim();
110
+ }
111
+
112
+ // Object with text property
113
+ if (content && typeof content === 'object' && 'text' in content) {
114
+ const text = (content as { text: unknown }).text;
115
+ if (typeof text === 'string') {
116
+ return text.trim();
117
+ }
118
+ }
119
+
120
+ return '';
121
+ }
122
+
123
+ /**
124
+ * Decode a project directory name to a path
125
+ * e.g., "-Users-mrwoof-src-cmdctrl" -> "/Users/mrwoof/src/cmdctrl"
126
+ *
127
+ * The encoding is ambiguous: hyphens in directory names look the same as path separators.
128
+ * e.g., "-Users-mrwoof-src-cmdctrl-admin-interface" could be:
129
+ * /Users/mrwoof/src/cmdctrl-admin-interface (correct - worktree)
130
+ * /Users/mrwoof/src/cmdctrl/admin/interface (wrong - doesn't exist)
131
+ *
132
+ * We solve this by trying all possible decodings and returning the one that:
133
+ * 1. Actually exists on the filesystem
134
+ * 2. Has the most path components (to prefer /a/b-c over /a/b/c when both exist)
135
+ *
136
+ * If no valid path is found, fall back to replacing all hyphens with slashes.
137
+ */
138
+ function decodeProjectPath(dirName: string): string {
139
+ if (!dirName || dirName.length === 0) return '';
140
+
141
+ // Remove leading dash and split by dashes
142
+ const parts = dirName.slice(1).split('-');
143
+ if (parts.length === 0) return '/';
144
+
145
+ // Generate all possible path interpretations using recursion
146
+ const candidates: string[] = [];
147
+
148
+ function generatePaths(index: number, currentPath: string): void {
149
+ if (index >= parts.length) {
150
+ candidates.push(currentPath);
151
+ return;
152
+ }
153
+
154
+ // Try combining remaining parts with hyphens (longer combinations first)
155
+ for (let end = parts.length; end > index; end--) {
156
+ const component = parts.slice(index, end).join('-');
157
+ const newPath = currentPath + '/' + component;
158
+ generatePaths(end, newPath);
159
+ }
160
+ }
161
+
162
+ generatePaths(0, '');
163
+
164
+ // Find candidates that exist, preferring fewer path components (more hyphens preserved)
165
+ // Sort by number of slashes (ascending) to prefer paths with hyphens in names
166
+ candidates.sort((a, b) => {
167
+ const slashesA = (a.match(/\//g) || []).length;
168
+ const slashesB = (b.match(/\//g) || []).length;
169
+ return slashesA - slashesB;
170
+ });
171
+
172
+ for (const candidate of candidates) {
173
+ if (fs.existsSync(candidate)) {
174
+ return candidate;
175
+ }
176
+ }
177
+
178
+ // Fallback: replace all hyphens with slashes (original behavior)
179
+ return '/' + parts.join('/');
180
+ }
181
+
182
+ /**
183
+ * Extract project name from full path
184
+ */
185
+ function projectNameFromPath(projectPath: string): string {
186
+ return path.basename(projectPath);
187
+ }
188
+
189
+ /**
190
+ * Discover all Claude Code sessions on this device
191
+ *
192
+ * Uses sessions-index.json for efficiency when available (one file read per project
193
+ * instead of 64KB per session). Falls back to parsing individual files if index
194
+ * is missing or stale.
195
+ */
196
+ export async function discoverSessions(excludeSessionIDs: Set<string> = new Set()): Promise<ExternalSession[]> {
197
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
198
+ const sessionMap = new Map<string, ExternalSession>();
199
+
200
+ // Check if directory exists
201
+ if (!fs.existsSync(claudeDir)) {
202
+ return [];
203
+ }
204
+
205
+ // Read project directories
206
+ const entries = fs.readdirSync(claudeDir, { withFileTypes: true });
207
+
208
+ for (const entry of entries) {
209
+ if (!entry.isDirectory()) continue;
210
+
211
+ const projectDir = path.join(claudeDir, entry.name);
212
+ const projectPath = decodeProjectPath(entry.name);
213
+ const projectName = projectNameFromPath(projectPath);
214
+
215
+ // Try to use sessions-index.json first (much more efficient)
216
+ const indexPath = path.join(projectDir, 'sessions-index.json');
217
+ if (fs.existsSync(indexPath)) {
218
+ try {
219
+ const indexContent = fs.readFileSync(indexPath, 'utf-8');
220
+ const index: SessionsIndex = JSON.parse(indexContent);
221
+
222
+ // Track which sessions we need to re-parse due to stale index
223
+ const staleSessionPaths: string[] = [];
224
+
225
+ for (const indexEntry of index.entries) {
226
+ // Skip sidechains, excluded sessions, and empty sessions
227
+ if (indexEntry.isSidechain) continue;
228
+ if (excludeSessionIDs.has(indexEntry.sessionId)) continue;
229
+ if (indexEntry.messageCount === 0) continue;
230
+
231
+ // Check if the actual file has been modified since the index was updated
232
+ // If so, the index data is stale and we need to re-parse the file
233
+ try {
234
+ const stat = fs.statSync(indexEntry.fullPath);
235
+ const actualMtimeMs = stat.mtimeMs;
236
+ // Index fileMtime is in milliseconds
237
+ if (actualMtimeMs > indexEntry.fileMtime + 1000) {
238
+ // File is newer than index - mark for re-parsing
239
+ staleSessionPaths.push(indexEntry.fullPath);
240
+ continue;
241
+ }
242
+ } catch {
243
+ // File doesn't exist or can't stat, skip
244
+ continue;
245
+ }
246
+
247
+ const modifiedDate = new Date(indexEntry.modified);
248
+ const isActive = Date.now() - modifiedDate.getTime() < ACTIVE_THRESHOLD_MS;
249
+
250
+ // Use customTitle > summary > firstPrompt for title
251
+ let title = indexEntry.customTitle || indexEntry.summary || '';
252
+ if (!title && indexEntry.firstPrompt && indexEntry.firstPrompt !== 'No prompt') {
253
+ title = generateTitle(indexEntry.firstPrompt);
254
+ }
255
+ if (!title) {
256
+ title = indexEntry.sessionId.slice(0, 8);
257
+ }
258
+
259
+ const session: ExternalSession = {
260
+ session_id: indexEntry.sessionId,
261
+ slug: '', // Not in index, but we don't really use it
262
+ title,
263
+ // Always use directory-derived projectPath, not indexEntry.projectPath
264
+ // The index stores cwd at session start, which can be a subdirectory
265
+ project: projectPath,
266
+ project_name: projectNameFromPath(projectPath),
267
+ file_path: indexEntry.fullPath,
268
+ last_message: indexEntry.firstPrompt !== 'No prompt' ? generateTitle(indexEntry.firstPrompt) : '',
269
+ last_activity: indexEntry.modified, // This is the correct message timestamp!
270
+ is_active: isActive,
271
+ message_count: indexEntry.messageCount,
272
+ };
273
+
274
+ // Keep most recently active version
275
+ const existing = sessionMap.get(session.session_id);
276
+ if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
277
+ sessionMap.set(session.session_id, session);
278
+ }
279
+ }
280
+
281
+ // Re-parse stale sessions from index
282
+ for (const stalePath of staleSessionPaths) {
283
+ try {
284
+ const session = await parseSessionFile(stalePath, projectPath, projectName);
285
+ if (session.message_count === 0) continue;
286
+
287
+ const existing = sessionMap.get(session.session_id);
288
+ if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
289
+ sessionMap.set(session.session_id, session);
290
+ }
291
+ } catch {
292
+ // Failed to parse, skip
293
+ }
294
+ }
295
+
296
+ // After processing index, check for files not in index (index can be stale)
297
+ const indexedSessionIds = new Set(index.entries.map(e => e.sessionId));
298
+ let missingFiles: string[];
299
+ try {
300
+ missingFiles = fs.readdirSync(projectDir)
301
+ .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
302
+ .map(f => path.join(projectDir, f));
303
+ } catch (err) {
304
+ continue; // Can't read directory, skip
305
+ }
306
+
307
+ for (const jsonlPath of missingFiles) {
308
+ const sessionId = path.basename(jsonlPath, '.jsonl');
309
+
310
+ // Skip if already in index or excluded
311
+ if (indexedSessionIds.has(sessionId)) continue;
312
+ if (excludeSessionIDs.has(sessionId)) continue;
313
+
314
+ try {
315
+ const stat = fs.statSync(jsonlPath);
316
+ const fileMtime = stat.mtimeMs;
317
+ const cached = parsedSessionCache.get(sessionId);
318
+
319
+ let session: ExternalSession;
320
+ if (cached && cached.fileMtime === fileMtime) {
321
+ // File unchanged, use cached session (update is_active)
322
+ session = { ...cached.session };
323
+ session.is_active = Date.now() - new Date(session.last_activity).getTime() < ACTIVE_THRESHOLD_MS;
324
+ } else {
325
+ // File is new or modified, parse it
326
+ session = await parseSessionFile(jsonlPath, projectPath, projectName);
327
+ if (session.message_count === 0) continue;
328
+ parsedSessionCache.set(sessionId, { session, fileMtime });
329
+ }
330
+
331
+ const existing = sessionMap.get(session.session_id);
332
+ if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
333
+ sessionMap.set(session.session_id, session);
334
+ }
335
+ } catch (err) {
336
+ continue; // Skip unparseable files
337
+ }
338
+ }
339
+ continue; // Done with this project
340
+ } catch (err) {
341
+ // Index parsing failed, fall back to file parsing
342
+ console.warn(`Failed to parse sessions-index.json in ${projectDir}, falling back to file parsing:`, err);
343
+ }
344
+ }
345
+
346
+ // Fallback: parse individual .jsonl files (slower but always works)
347
+ let jsonlFiles: string[];
348
+ try {
349
+ jsonlFiles = fs.readdirSync(projectDir)
350
+ .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
351
+ .map(f => path.join(projectDir, f));
352
+ } catch (err) {
353
+ console.warn(`Failed to read project directory ${projectDir}:`, err);
354
+ continue;
355
+ }
356
+
357
+ for (const jsonlPath of jsonlFiles) {
358
+ try {
359
+ const session = await parseSessionFile(jsonlPath, projectPath, projectName);
360
+
361
+ // Skip if excluded or no messages
362
+ if (excludeSessionIDs.has(session.session_id)) continue;
363
+ if (session.message_count === 0) continue;
364
+
365
+ // Keep most recently active version
366
+ const existing = sessionMap.get(session.session_id);
367
+ if (!existing || new Date(session.last_activity) > new Date(existing.last_activity)) {
368
+ sessionMap.set(session.session_id, session);
369
+ }
370
+ } catch (err) {
371
+ // Skip files that can't be parsed
372
+ continue;
373
+ }
374
+ }
375
+ }
376
+
377
+ // Convert to array and sort by last activity (most recent first)
378
+ const sessions = Array.from(sessionMap.values());
379
+ sessions.sort((a, b) => new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime());
380
+
381
+ return sessions;
382
+ }
383
+
384
+ /**
385
+ * Parse a session JSONL file to extract metadata
386
+ */
387
+ async function parseSessionFile(filePath: string, projectPath: string, projectName: string): Promise<ExternalSession> {
388
+ const stat = fs.statSync(filePath);
389
+ const lastActivity = stat.mtime;
390
+ const isActive = Date.now() - lastActivity.getTime() < ACTIVE_THRESHOLD_MS;
391
+
392
+ const session: ExternalSession = {
393
+ session_id: '',
394
+ slug: '',
395
+ title: '',
396
+ project: projectPath,
397
+ project_name: projectName,
398
+ file_path: filePath,
399
+ last_message: '',
400
+ last_activity: lastActivity.toISOString(),
401
+ is_active: isActive,
402
+ message_count: 0,
403
+ };
404
+
405
+ // Read the tail of the file
406
+ const fileSize = stat.size;
407
+ const fd = fs.openSync(filePath, 'r');
408
+
409
+ try {
410
+ const seekPos = Math.max(0, fileSize - TAIL_BYTES);
411
+ const buffer = Buffer.alloc(Math.min(TAIL_BYTES, fileSize));
412
+ fs.readSync(fd, buffer, 0, buffer.length, seekPos);
413
+
414
+ let content = buffer.toString('utf-8');
415
+
416
+ // If we seeked into middle, skip first partial line
417
+ if (seekPos > 0) {
418
+ const newlineIdx = content.indexOf('\n');
419
+ if (newlineIdx >= 0) {
420
+ content = content.slice(newlineIdx + 1);
421
+ }
422
+ }
423
+
424
+ const lines = content.split('\n').filter(l => l.trim());
425
+ let firstUserMessage = '';
426
+ let lastUserMessage = '';
427
+ let messageCount = 0;
428
+ let foundSessionId = false;
429
+ let foundSlug = false;
430
+ let lastMessageTimestamp = ''; // Track actual last message timestamp
431
+
432
+ for (const line of lines) {
433
+ try {
434
+ const entry: SessionEntry = JSON.parse(line);
435
+
436
+ // Extract session ID and slug
437
+ if (!foundSessionId && entry.sessionId) {
438
+ session.session_id = entry.sessionId;
439
+ foundSessionId = true;
440
+ }
441
+ if (!foundSlug && entry.slug) {
442
+ session.slug = entry.slug;
443
+ foundSlug = true;
444
+ }
445
+ // NOTE: Do NOT override project with entry.cwd - the project path must come from
446
+ // the directory where the session file is stored, not from cwd in JSONL entries.
447
+ // The cwd can change during a session (e.g., when Claude changes to a subdirectory),
448
+ // but the session file stays in its original project directory.
449
+
450
+ // Count messages and track last message timestamp
451
+ if (entry.type === 'user' || entry.type === 'assistant') {
452
+ messageCount++;
453
+ // Track timestamp of actual user/assistant messages (not system messages)
454
+ if (entry.timestamp) {
455
+ lastMessageTimestamp = entry.timestamp;
456
+ }
457
+ }
458
+
459
+ // Track first and last user messages (extract readable text only)
460
+ if (entry.type === 'user' && entry.message?.content) {
461
+ const text = extractReadableText(entry.message.content);
462
+ if (text) {
463
+ if (!firstUserMessage) {
464
+ firstUserMessage = text;
465
+ }
466
+ lastUserMessage = text;
467
+ }
468
+ }
469
+ } catch {
470
+ continue;
471
+ }
472
+ }
473
+
474
+ // Use actual last message timestamp if available, otherwise fall back to file mtime
475
+ if (lastMessageTimestamp) {
476
+ session.last_activity = lastMessageTimestamp;
477
+ session.is_active = Date.now() - new Date(lastMessageTimestamp).getTime() < ACTIVE_THRESHOLD_MS;
478
+ }
479
+
480
+ session.message_count = messageCount;
481
+
482
+ // Fallback session ID from filename
483
+ if (!session.session_id) {
484
+ session.session_id = path.basename(filePath, '.jsonl');
485
+ }
486
+
487
+ // Generate title from first user message, falling back to slug then session ID
488
+ if (firstUserMessage) {
489
+ session.title = generateTitle(firstUserMessage);
490
+ }
491
+ if (!session.title && session.slug) {
492
+ session.title = session.slug;
493
+ }
494
+ if (!session.title) {
495
+ session.title = session.session_id.slice(0, 8);
496
+ }
497
+
498
+ // Truncate last message for preview
499
+ if (lastUserMessage.length > 100) {
500
+ session.last_message = lastUserMessage.slice(0, 100) + '...';
501
+ } else {
502
+ session.last_message = lastUserMessage;
503
+ }
504
+
505
+ } finally {
506
+ fs.closeSync(fd);
507
+ }
508
+
509
+ return session;
510
+ }
511
+
512
+ /**
513
+ * Group sessions by project
514
+ */
515
+ export function groupByProject(sessions: ExternalSession[]): ExternalSessionsByProject[] {
516
+ const projectMap = new Map<string, ExternalSessionsByProject>();
517
+ const projectOrder: string[] = [];
518
+
519
+ for (const session of sessions) {
520
+ if (!projectMap.has(session.project)) {
521
+ projectMap.set(session.project, {
522
+ project: session.project,
523
+ project_name: session.project_name,
524
+ sessions: [],
525
+ });
526
+ projectOrder.push(session.project);
527
+ }
528
+ projectMap.get(session.project)!.sessions.push(session);
529
+ }
530
+
531
+ return projectOrder.map(p => projectMap.get(p)!);
532
+ }
533
+
534
+ /**
535
+ * Discover projects (directories in ~/.claude/projects/)
536
+ */
537
+ export function discoverProjects(): { path: string; name: string }[] {
538
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
539
+
540
+ if (!fs.existsSync(claudeDir)) {
541
+ return [];
542
+ }
543
+
544
+ const entries = fs.readdirSync(claudeDir, { withFileTypes: true });
545
+ const projects: { path: string; name: string }[] = [];
546
+
547
+ for (const entry of entries) {
548
+ if (!entry.isDirectory()) continue;
549
+
550
+ const projectPath = decodeProjectPath(entry.name);
551
+ const projectName = projectNameFromPath(projectPath);
552
+
553
+ projects.push({ path: projectPath, name: projectName });
554
+ }
555
+
556
+ return projects;
557
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for SessionWatcher file change detection
3
+ *
4
+ * This test verifies that the SessionWatcher reliably detects
5
+ * file changes when an external process appends to a JSONL file
6
+ * (simulating Claude CLI writing to session files).
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+ import { execSync } from 'child_process';
13
+ import { SessionWatcher, SessionEvent } from './session-watcher';
14
+
15
+ describe('SessionWatcher', () => {
16
+ let tempDir: string;
17
+ let tempFile: string;
18
+ let watcher: SessionWatcher;
19
+ let events: SessionEvent[];
20
+
21
+ beforeEach(() => {
22
+ // Create a temp directory and file for each test
23
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-watcher-test-'));
24
+ tempFile = path.join(tempDir, 'test-session.jsonl');
25
+
26
+ // Write initial content with uuid (required for processing)
27
+ fs.writeFileSync(tempFile, '{"uuid":"init-1","type":"user","message":{"content":"initial message"}}\n');
28
+
29
+ events = [];
30
+ watcher = new SessionWatcher((event) => {
31
+ events.push(event);
32
+ });
33
+ });
34
+
35
+ afterEach(() => {
36
+ watcher.unwatchAll();
37
+ // Clean up temp files
38
+ if (fs.existsSync(tempFile)) {
39
+ fs.unlinkSync(tempFile);
40
+ }
41
+ if (fs.existsSync(tempDir)) {
42
+ fs.rmdirSync(tempDir);
43
+ }
44
+ });
45
+
46
+ it('should detect file changes when external process appends content', async () => {
47
+ // Start watching the file
48
+ watcher.watchSession('test-session-123', tempFile);
49
+
50
+ // Wait for watcher to initialize
51
+ await sleep(100);
52
+
53
+ // Simulate external process (Claude CLI) appending to file
54
+ // This mimics how the CLI incrementally writes JSONL lines
55
+ fs.appendFileSync(tempFile, '{"uuid":"resp-1","type":"assistant","message":{"content":[{"type":"text","text":"response 1"}]}}\n');
56
+
57
+ // Wait for the watcher to detect the change
58
+ // Using 2 seconds as a reasonable timeout - if fs.watch works, it should be much faster
59
+ // If using polling at 500ms, we need at least that long plus processing time
60
+ await sleep(2000);
61
+
62
+ // The watcher should have detected the change and fired the callback
63
+ expect(events.length).toBeGreaterThan(0);
64
+ expect(events[0].sessionId).toBe('test-session-123');
65
+ expect(events[0].type).toBe('AGENT_RESPONSE');
66
+ expect(events[0].content).toBe('response 1');
67
+ });
68
+
69
+ it('should detect multiple sequential appends', async () => {
70
+ watcher.watchSession('test-session-456', tempFile);
71
+ await sleep(100);
72
+
73
+ // Simulate multiple rapid appends (like Claude streaming output)
74
+ fs.appendFileSync(tempFile, '{"uuid":"line-1","type":"assistant","message":{"content":[{"type":"text","text":"line 1"}]}}\n');
75
+ await sleep(100);
76
+ fs.appendFileSync(tempFile, '{"uuid":"line-2","type":"assistant","message":{"content":[{"type":"text","text":"line 2"}]}}\n');
77
+ await sleep(100);
78
+ fs.appendFileSync(tempFile, '{"uuid":"line-3","type":"assistant","message":{"content":[{"type":"text","text":"line 3"}]}}\n');
79
+
80
+ // Wait for detection (accounting for polling interval)
81
+ await sleep(2000);
82
+
83
+ // Should have detected all events
84
+ expect(events.length).toBe(3);
85
+ expect(events.map(e => e.content)).toEqual(['line 1', 'line 2', 'line 3']);
86
+ });
87
+
88
+ it('should detect file changes from external process (simulates Claude CLI)', async () => {
89
+ // This is the critical test - external processes appending to files
90
+ // is exactly how the Claude CLI writes to session JSONL files.
91
+ // fs.watch() on macOS often fails to detect these changes.
92
+ watcher.watchSession('test-session-external', tempFile);
93
+ await sleep(100);
94
+
95
+ // Use shell to append - this is an external process, just like Claude CLI
96
+ const jsonLine = '{"uuid":"ext-1","type":"assistant","message":{"content":[{"type":"text","text":"external append"}]}}';
97
+ execSync(`echo '${jsonLine}' >> "${tempFile}"`);
98
+
99
+ // Wait for detection
100
+ await sleep(2000);
101
+
102
+ // The watcher MUST detect changes from external processes
103
+ expect(events.length).toBeGreaterThan(0);
104
+ expect(events[0].sessionId).toBe('test-session-external');
105
+ expect(events[0].type).toBe('AGENT_RESPONSE');
106
+ });
107
+
108
+ it('should emit VERBOSE for tool_use entries', async () => {
109
+ watcher.watchSession('test-session-tool', tempFile);
110
+ await sleep(100);
111
+
112
+ // Append a tool_use entry
113
+ const toolEntry = '{"uuid":"tool-1","type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"/test/file.ts"}}]}}';
114
+ fs.appendFileSync(tempFile, toolEntry + '\n');
115
+
116
+ await sleep(2000);
117
+
118
+ expect(events.length).toBe(1);
119
+ expect(events[0].type).toBe('VERBOSE');
120
+ expect(events[0].content).toContain('Reading');
121
+ });
122
+
123
+ it('should emit USER_MESSAGE for user entries', async () => {
124
+ watcher.watchSession('test-session-user', tempFile);
125
+ await sleep(100);
126
+
127
+ // Append a user message entry
128
+ const userEntry = '{"uuid":"user-1","type":"user","message":{"content":"hello agent"}}';
129
+ fs.appendFileSync(tempFile, userEntry + '\n');
130
+
131
+ await sleep(2000);
132
+
133
+ expect(events.length).toBe(1);
134
+ expect(events[0].type).toBe('USER_MESSAGE');
135
+ expect(events[0].content).toBe('hello agent');
136
+ });
137
+ });
138
+
139
+ function sleep(ms: number): Promise<void> {
140
+ return new Promise((resolve) => setTimeout(resolve, ms));
141
+ }