@epiphytic/claudecodeui 1.0.1 → 1.2.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.
@@ -1,197 +1,198 @@
1
1
  /**
2
2
  * PROJECT DISCOVERY AND MANAGEMENT SYSTEM
3
3
  * ========================================
4
- *
4
+ *
5
5
  * This module manages project discovery for both Claude CLI and Cursor CLI sessions.
6
- *
6
+ *
7
7
  * ## Architecture Overview
8
- *
8
+ *
9
9
  * 1. **Claude Projects** (stored in ~/.claude/projects/)
10
10
  * - Each project is a directory named with the project path encoded (/ replaced with -)
11
11
  * - Contains .jsonl files with conversation history including 'cwd' field
12
12
  * - Project metadata stored in ~/.claude/project-config.json
13
- *
13
+ *
14
14
  * 2. **Cursor Projects** (stored in ~/.cursor/chats/)
15
15
  * - Each project directory is named with MD5 hash of the absolute project path
16
16
  * - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...
17
17
  * - Contains session directories with SQLite databases (store.db)
18
18
  * - Project path is NOT stored in the database - only in the MD5 hash
19
- *
19
+ *
20
20
  * ## Project Discovery Strategy
21
- *
21
+ *
22
22
  * 1. **Claude Projects Discovery**:
23
23
  * - Scan ~/.claude/projects/ directory for Claude project folders
24
24
  * - Extract actual project path from .jsonl files (cwd field)
25
25
  * - Fall back to decoded directory name if no sessions exist
26
- *
26
+ *
27
27
  * 2. **Cursor Sessions Discovery**:
28
28
  * - For each KNOWN project (from Claude or manually added)
29
29
  * - Compute MD5 hash of the project's absolute path
30
30
  * - Check if ~/.cursor/chats/{md5_hash}/ directory exists
31
31
  * - Read session metadata from SQLite store.db files
32
- *
32
+ *
33
33
  * 3. **Manual Project Addition**:
34
34
  * - Users can manually add project paths via UI
35
35
  * - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
36
36
  * - Allows discovering Cursor sessions for projects without Claude sessions
37
- *
37
+ *
38
38
  * ## Critical Limitations
39
- *
39
+ *
40
40
  * - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of
41
41
  * the cwd of each project. if someone has the time, you can try to reverse engineer it.
42
- *
42
+ *
43
43
  * - **Project relocation breaks history**: If a project directory is moved or renamed,
44
44
  * the MD5 hash changes, making old Cursor sessions inaccessible unless the old
45
45
  * path is known and manually added.
46
- *
46
+ *
47
47
  * ## Error Handling
48
- *
48
+ *
49
49
  * - Missing ~/.claude directory is handled gracefully with automatic creation
50
50
  * - ENOENT errors are caught and handled without crashing
51
51
  * - Empty arrays returned when no projects/sessions exist
52
- *
52
+ *
53
53
  * ## Caching Strategy
54
- *
54
+ *
55
55
  * - Project directory extraction is cached to minimize file I/O
56
56
  * - Cache is cleared when project configuration changes
57
57
  * - Session data is fetched on-demand, not cached
58
58
  */
59
59
 
60
- import { promises as fs } from 'fs';
61
- import fsSync from 'fs';
62
- import path from 'path';
63
- import readline from 'readline';
64
- import crypto from 'crypto';
65
- import sqlite3 from 'sqlite3';
66
- import { open } from 'sqlite';
67
- import os from 'os';
60
+ import { promises as fs } from "fs";
61
+ import fsSync from "fs";
62
+ import path from "path";
63
+ import readline from "readline";
64
+ import crypto from "crypto";
65
+ import sqlite3 from "sqlite3";
66
+ import { open } from "sqlite";
67
+ import os from "os";
68
68
 
69
69
  // Import TaskMaster detection functions
70
70
  async function detectTaskMasterFolder(projectPath) {
71
+ try {
72
+ const taskMasterPath = path.join(projectPath, ".taskmaster");
73
+
74
+ // Check if .taskmaster directory exists
71
75
  try {
72
- const taskMasterPath = path.join(projectPath, '.taskmaster');
73
-
74
- // Check if .taskmaster directory exists
75
- try {
76
- const stats = await fs.stat(taskMasterPath);
77
- if (!stats.isDirectory()) {
78
- return {
79
- hasTaskmaster: false,
80
- reason: '.taskmaster exists but is not a directory'
81
- };
82
- }
83
- } catch (error) {
84
- if (error.code === 'ENOENT') {
85
- return {
86
- hasTaskmaster: false,
87
- reason: '.taskmaster directory not found'
88
- };
89
- }
90
- throw error;
91
- }
76
+ const stats = await fs.stat(taskMasterPath);
77
+ if (!stats.isDirectory()) {
78
+ return {
79
+ hasTaskmaster: false,
80
+ reason: ".taskmaster exists but is not a directory",
81
+ };
82
+ }
83
+ } catch (error) {
84
+ if (error.code === "ENOENT") {
85
+ return {
86
+ hasTaskmaster: false,
87
+ reason: ".taskmaster directory not found",
88
+ };
89
+ }
90
+ throw error;
91
+ }
92
92
 
93
- // Check for key TaskMaster files
94
- const keyFiles = [
95
- 'tasks/tasks.json',
96
- 'config.json'
97
- ];
98
-
99
- const fileStatus = {};
100
- let hasEssentialFiles = true;
101
-
102
- for (const file of keyFiles) {
103
- const filePath = path.join(taskMasterPath, file);
104
- try {
105
- await fs.access(filePath);
106
- fileStatus[file] = true;
107
- } catch (error) {
108
- fileStatus[file] = false;
109
- if (file === 'tasks/tasks.json') {
110
- hasEssentialFiles = false;
111
- }
112
- }
113
- }
93
+ // Check for key TaskMaster files
94
+ const keyFiles = ["tasks/tasks.json", "config.json"];
114
95
 
115
- // Parse tasks.json if it exists for metadata
116
- let taskMetadata = null;
117
- if (fileStatus['tasks/tasks.json']) {
118
- try {
119
- const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
120
- const tasksContent = await fs.readFile(tasksPath, 'utf8');
121
- const tasksData = JSON.parse(tasksContent);
122
-
123
- // Handle both tagged and legacy formats
124
- let tasks = [];
125
- if (tasksData.tasks) {
126
- // Legacy format
127
- tasks = tasksData.tasks;
128
- } else {
129
- // Tagged format - get tasks from all tags
130
- Object.values(tasksData).forEach(tagData => {
131
- if (tagData.tasks) {
132
- tasks = tasks.concat(tagData.tasks);
133
- }
134
- });
135
- }
96
+ const fileStatus = {};
97
+ let hasEssentialFiles = true;
136
98
 
137
- // Calculate task statistics
138
- const stats = tasks.reduce((acc, task) => {
139
- acc.total++;
140
- acc[task.status] = (acc[task.status] || 0) + 1;
141
-
142
- // Count subtasks
143
- if (task.subtasks) {
144
- task.subtasks.forEach(subtask => {
145
- acc.subtotalTasks++;
146
- acc.subtasks = acc.subtasks || {};
147
- acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
148
- });
149
- }
150
-
151
- return acc;
152
- }, {
153
- total: 0,
154
- subtotalTasks: 0,
155
- pending: 0,
156
- 'in-progress': 0,
157
- done: 0,
158
- review: 0,
159
- deferred: 0,
160
- cancelled: 0,
161
- subtasks: {}
162
- });
163
-
164
- taskMetadata = {
165
- taskCount: stats.total,
166
- subtaskCount: stats.subtotalTasks,
167
- completed: stats.done || 0,
168
- pending: stats.pending || 0,
169
- inProgress: stats['in-progress'] || 0,
170
- review: stats.review || 0,
171
- completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
172
- lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
173
- };
174
- } catch (parseError) {
175
- console.warn('Failed to parse tasks.json:', parseError.message);
176
- taskMetadata = { error: 'Failed to parse tasks.json' };
99
+ for (const file of keyFiles) {
100
+ const filePath = path.join(taskMasterPath, file);
101
+ try {
102
+ await fs.access(filePath);
103
+ fileStatus[file] = true;
104
+ } catch (error) {
105
+ fileStatus[file] = false;
106
+ if (file === "tasks/tasks.json") {
107
+ hasEssentialFiles = false;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Parse tasks.json if it exists for metadata
113
+ let taskMetadata = null;
114
+ if (fileStatus["tasks/tasks.json"]) {
115
+ try {
116
+ const tasksPath = path.join(taskMasterPath, "tasks/tasks.json");
117
+ const tasksContent = await fs.readFile(tasksPath, "utf8");
118
+ const tasksData = JSON.parse(tasksContent);
119
+
120
+ // Handle both tagged and legacy formats
121
+ let tasks = [];
122
+ if (tasksData.tasks) {
123
+ // Legacy format
124
+ tasks = tasksData.tasks;
125
+ } else {
126
+ // Tagged format - get tasks from all tags
127
+ Object.values(tasksData).forEach((tagData) => {
128
+ if (tagData.tasks) {
129
+ tasks = tasks.concat(tagData.tasks);
177
130
  }
131
+ });
178
132
  }
179
133
 
180
- return {
181
- hasTaskmaster: true,
182
- hasEssentialFiles,
183
- files: fileStatus,
184
- metadata: taskMetadata,
185
- path: taskMasterPath
186
- };
134
+ // Calculate task statistics
135
+ const stats = tasks.reduce(
136
+ (acc, task) => {
137
+ acc.total++;
138
+ acc[task.status] = (acc[task.status] || 0) + 1;
139
+
140
+ // Count subtasks
141
+ if (task.subtasks) {
142
+ task.subtasks.forEach((subtask) => {
143
+ acc.subtotalTasks++;
144
+ acc.subtasks = acc.subtasks || {};
145
+ acc.subtasks[subtask.status] =
146
+ (acc.subtasks[subtask.status] || 0) + 1;
147
+ });
148
+ }
187
149
 
188
- } catch (error) {
189
- console.error('Error detecting TaskMaster folder:', error);
190
- return {
191
- hasTaskmaster: false,
192
- reason: `Error checking directory: ${error.message}`
150
+ return acc;
151
+ },
152
+ {
153
+ total: 0,
154
+ subtotalTasks: 0,
155
+ pending: 0,
156
+ "in-progress": 0,
157
+ done: 0,
158
+ review: 0,
159
+ deferred: 0,
160
+ cancelled: 0,
161
+ subtasks: {},
162
+ },
163
+ );
164
+
165
+ taskMetadata = {
166
+ taskCount: stats.total,
167
+ subtaskCount: stats.subtotalTasks,
168
+ completed: stats.done || 0,
169
+ pending: stats.pending || 0,
170
+ inProgress: stats["in-progress"] || 0,
171
+ review: stats.review || 0,
172
+ completionPercentage:
173
+ stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
174
+ lastModified: (await fs.stat(tasksPath)).mtime.toISOString(),
193
175
  };
176
+ } catch (parseError) {
177
+ console.warn("Failed to parse tasks.json:", parseError.message);
178
+ taskMetadata = { error: "Failed to parse tasks.json" };
179
+ }
194
180
  }
181
+
182
+ return {
183
+ hasTaskmaster: true,
184
+ hasEssentialFiles,
185
+ files: fileStatus,
186
+ metadata: taskMetadata,
187
+ path: taskMasterPath,
188
+ };
189
+ } catch (error) {
190
+ console.error("Error detecting TaskMaster folder:", error);
191
+ return {
192
+ hasTaskmaster: false,
193
+ reason: `Error checking directory: ${error.message}`,
194
+ };
195
+ }
195
196
  }
196
197
 
197
198
  // Cache for extracted project directories
@@ -204,9 +205,9 @@ function clearProjectDirectoryCache() {
204
205
 
205
206
  // Load project configuration file
206
207
  async function loadProjectConfig() {
207
- const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
208
+ const configPath = path.join(os.homedir(), ".claude", "project-config.json");
208
209
  try {
209
- const configData = await fs.readFile(configPath, 'utf8');
210
+ const configData = await fs.readFile(configPath, "utf8");
210
211
  return JSON.parse(configData);
211
212
  } catch (error) {
212
213
  // Return empty config if file doesn't exist
@@ -216,32 +217,32 @@ async function loadProjectConfig() {
216
217
 
217
218
  // Save project configuration file
218
219
  async function saveProjectConfig(config) {
219
- const claudeDir = path.join(os.homedir(), '.claude');
220
- const configPath = path.join(claudeDir, 'project-config.json');
221
-
220
+ const claudeDir = path.join(os.homedir(), ".claude");
221
+ const configPath = path.join(claudeDir, "project-config.json");
222
+
222
223
  // Ensure the .claude directory exists
223
224
  try {
224
225
  await fs.mkdir(claudeDir, { recursive: true });
225
226
  } catch (error) {
226
- if (error.code !== 'EEXIST') {
227
+ if (error.code !== "EEXIST") {
227
228
  throw error;
228
229
  }
229
230
  }
230
-
231
- await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
231
+
232
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
232
233
  }
233
234
 
234
235
  // Generate better display name from path
235
236
  async function generateDisplayName(projectName, actualProjectDir = null) {
236
237
  // Use actual project directory if provided, otherwise decode from project name
237
- let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
238
-
238
+ let projectPath = actualProjectDir || projectName.replace(/-/g, "/");
239
+
239
240
  // Try to read package.json from the project path
240
241
  try {
241
- const packageJsonPath = path.join(projectPath, 'package.json');
242
- const packageData = await fs.readFile(packageJsonPath, 'utf8');
242
+ const packageJsonPath = path.join(projectPath, "package.json");
243
+ const packageData = await fs.readFile(packageJsonPath, "utf8");
243
244
  const packageJson = JSON.parse(packageData);
244
-
245
+
245
246
  // Return the name from package.json if it exists
246
247
  if (packageJson.name) {
247
248
  return packageJson.name;
@@ -249,14 +250,14 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
249
250
  } catch (error) {
250
251
  // Fall back to path-based naming if package.json doesn't exist or can't be read
251
252
  }
252
-
253
+
253
254
  // If it starts with /, it's an absolute path
254
- if (projectPath.startsWith('/')) {
255
- const parts = projectPath.split('/').filter(Boolean);
255
+ if (projectPath.startsWith("/")) {
256
+ const parts = projectPath.split("/").filter(Boolean);
256
257
  // Return only the last folder name
257
258
  return parts[parts.length - 1] || projectPath;
258
259
  }
259
-
260
+
260
261
  return projectPath;
261
262
  }
262
263
 
@@ -276,22 +277,27 @@ async function extractProjectDirectory(projectName) {
276
277
  return originalPath;
277
278
  }
278
279
 
279
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
280
+ const projectDir = path.join(
281
+ os.homedir(),
282
+ ".claude",
283
+ "projects",
284
+ projectName,
285
+ );
280
286
  const cwdCounts = new Map();
281
287
  let latestTimestamp = 0;
282
288
  let latestCwd = null;
283
289
  let extractedPath;
284
-
290
+
285
291
  try {
286
292
  // Check if the project directory exists
287
293
  await fs.access(projectDir);
288
-
294
+
289
295
  const files = await fs.readdir(projectDir);
290
- const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
291
-
296
+ const jsonlFiles = files.filter((file) => file.endsWith(".jsonl"));
297
+
292
298
  if (jsonlFiles.length === 0) {
293
299
  // Fall back to decoded project name if no sessions
294
- extractedPath = projectName.replace(/-/g, '/');
300
+ extractedPath = projectName.replace(/-/g, "/");
295
301
  } else {
296
302
  // Process all JSONL files to collect cwd values
297
303
  for (const file of jsonlFiles) {
@@ -299,18 +305,18 @@ async function extractProjectDirectory(projectName) {
299
305
  const fileStream = fsSync.createReadStream(jsonlFile);
300
306
  const rl = readline.createInterface({
301
307
  input: fileStream,
302
- crlfDelay: Infinity
308
+ crlfDelay: Infinity,
303
309
  });
304
-
310
+
305
311
  for await (const line of rl) {
306
312
  if (line.trim()) {
307
313
  try {
308
314
  const entry = JSON.parse(line);
309
-
315
+
310
316
  if (entry.cwd) {
311
317
  // Count occurrences of each cwd
312
318
  cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
313
-
319
+
314
320
  // Track the most recent cwd
315
321
  const timestamp = new Date(entry.timestamp || 0).getTime();
316
322
  if (timestamp > latestTimestamp) {
@@ -324,11 +330,11 @@ async function extractProjectDirectory(projectName) {
324
330
  }
325
331
  }
326
332
  }
327
-
333
+
328
334
  // Determine the best cwd to use
329
335
  if (cwdCounts.size === 0) {
330
336
  // No cwd found, fall back to decoded project name
331
- extractedPath = projectName.replace(/-/g, '/');
337
+ extractedPath = projectName.replace(/-/g, "/");
332
338
  } else if (cwdCounts.size === 1) {
333
339
  // Only one cwd, use it
334
340
  extractedPath = Array.from(cwdCounts.keys())[0];
@@ -336,7 +342,7 @@ async function extractProjectDirectory(projectName) {
336
342
  // Multiple cwd values - prefer the most recent one if it has reasonable usage
337
343
  const mostRecentCount = cwdCounts.get(latestCwd) || 0;
338
344
  const maxCount = Math.max(...cwdCounts.values());
339
-
345
+
340
346
  // Use most recent if it has at least 25% of the max count
341
347
  if (mostRecentCount >= maxCount * 0.25) {
342
348
  extractedPath = latestCwd;
@@ -349,88 +355,99 @@ async function extractProjectDirectory(projectName) {
349
355
  }
350
356
  }
351
357
  }
352
-
358
+
353
359
  // Fallback (shouldn't reach here)
354
360
  if (!extractedPath) {
355
- extractedPath = latestCwd || projectName.replace(/-/g, '/');
361
+ extractedPath = latestCwd || projectName.replace(/-/g, "/");
356
362
  }
357
363
  }
358
364
  }
359
-
365
+
360
366
  // Cache the result
361
367
  projectDirectoryCache.set(projectName, extractedPath);
362
-
368
+
363
369
  return extractedPath;
364
-
365
370
  } catch (error) {
366
371
  // If the directory doesn't exist, just use the decoded project name
367
- if (error.code === 'ENOENT') {
368
- extractedPath = projectName.replace(/-/g, '/');
372
+ if (error.code === "ENOENT") {
373
+ extractedPath = projectName.replace(/-/g, "/");
369
374
  } else {
370
- console.error(`Error extracting project directory for ${projectName}:`, error);
375
+ console.error(
376
+ `Error extracting project directory for ${projectName}:`,
377
+ error,
378
+ );
371
379
  // Fall back to decoded project name for other errors
372
- extractedPath = projectName.replace(/-/g, '/');
380
+ extractedPath = projectName.replace(/-/g, "/");
373
381
  }
374
-
382
+
375
383
  // Cache the fallback result too
376
384
  projectDirectoryCache.set(projectName, extractedPath);
377
-
385
+
378
386
  return extractedPath;
379
387
  }
380
388
  }
381
389
 
382
390
  async function getProjects() {
383
- const claudeDir = path.join(os.homedir(), '.claude', 'projects');
391
+ const claudeDir = path.join(os.homedir(), ".claude", "projects");
384
392
  const config = await loadProjectConfig();
385
393
  const projects = [];
386
394
  const existingProjects = new Set();
387
-
395
+
388
396
  try {
389
397
  // Check if the .claude/projects directory exists
390
398
  await fs.access(claudeDir);
391
-
399
+
392
400
  // First, get existing Claude projects from the file system
393
401
  const entries = await fs.readdir(claudeDir, { withFileTypes: true });
394
-
402
+
395
403
  for (const entry of entries) {
396
404
  if (entry.isDirectory()) {
397
405
  existingProjects.add(entry.name);
398
406
  const projectPath = path.join(claudeDir, entry.name);
399
-
407
+
400
408
  // Extract actual project directory from JSONL sessions
401
409
  const actualProjectDir = await extractProjectDirectory(entry.name);
402
-
410
+
403
411
  // Get display name from config or generate one
404
412
  const customName = config[entry.name]?.displayName;
405
- const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
413
+ const autoDisplayName = await generateDisplayName(
414
+ entry.name,
415
+ actualProjectDir,
416
+ );
406
417
  const fullPath = actualProjectDir;
407
-
418
+
408
419
  const project = {
409
420
  name: entry.name,
410
421
  path: actualProjectDir,
411
422
  displayName: customName || autoDisplayName,
412
423
  fullPath: fullPath,
413
424
  isCustomName: !!customName,
414
- sessions: []
425
+ sessions: [],
415
426
  };
416
-
427
+
417
428
  // Try to get sessions for this project (just first 5 for performance)
418
429
  try {
419
430
  const sessionResult = await getSessions(entry.name, 5, 0);
420
431
  project.sessions = sessionResult.sessions || [];
421
432
  project.sessionMeta = {
422
433
  hasMore: sessionResult.hasMore,
423
- total: sessionResult.total
434
+ total: sessionResult.total,
424
435
  };
425
436
  } catch (e) {
426
- console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
437
+ console.warn(
438
+ `Could not load sessions for project ${entry.name}:`,
439
+ e.message,
440
+ );
427
441
  }
428
-
442
+
429
443
  // Also fetch Cursor sessions for this project
430
444
  try {
431
445
  project.cursorSessions = await getCursorSessions(actualProjectDir);
432
446
  } catch (e) {
433
- console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
447
+ console.warn(
448
+ `Could not load Cursor sessions for project ${entry.name}:`,
449
+ e.message,
450
+ );
434
451
  project.cursorSessions = [];
435
452
  }
436
453
 
@@ -438,173 +455,213 @@ async function getProjects() {
438
455
  try {
439
456
  project.codexSessions = await getCodexSessions(actualProjectDir);
440
457
  } catch (e) {
441
- console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
458
+ console.warn(
459
+ `Could not load Codex sessions for project ${entry.name}:`,
460
+ e.message,
461
+ );
442
462
  project.codexSessions = [];
443
463
  }
444
464
 
445
465
  // Add TaskMaster detection
446
466
  try {
447
- const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
467
+ const taskMasterResult =
468
+ await detectTaskMasterFolder(actualProjectDir);
448
469
  project.taskmaster = {
449
470
  hasTaskmaster: taskMasterResult.hasTaskmaster,
450
471
  hasEssentialFiles: taskMasterResult.hasEssentialFiles,
451
472
  metadata: taskMasterResult.metadata,
452
- status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'
473
+ status:
474
+ taskMasterResult.hasTaskmaster &&
475
+ taskMasterResult.hasEssentialFiles
476
+ ? "configured"
477
+ : "not-configured",
453
478
  };
454
479
  } catch (e) {
455
- console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);
480
+ console.warn(
481
+ `Could not detect TaskMaster for project ${entry.name}:`,
482
+ e.message,
483
+ );
456
484
  project.taskmaster = {
457
485
  hasTaskmaster: false,
458
486
  hasEssentialFiles: false,
459
487
  metadata: null,
460
- status: 'error'
488
+ status: "error",
461
489
  };
462
490
  }
463
-
491
+
464
492
  projects.push(project);
465
493
  }
466
494
  }
467
495
  } catch (error) {
468
496
  // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
469
- if (error.code !== 'ENOENT') {
470
- console.error('Error reading projects directory:', error);
497
+ if (error.code !== "ENOENT") {
498
+ console.error("Error reading projects directory:", error);
471
499
  }
472
500
  }
473
-
501
+
474
502
  // Add manually configured projects that don't exist as folders yet
475
503
  for (const [projectName, projectConfig] of Object.entries(config)) {
476
504
  if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
477
505
  // Use the original path if available, otherwise extract from potential sessions
478
506
  let actualProjectDir = projectConfig.originalPath;
479
-
507
+
480
508
  if (!actualProjectDir) {
481
509
  try {
482
510
  actualProjectDir = await extractProjectDirectory(projectName);
483
511
  } catch (error) {
484
512
  // Fall back to decoded project name
485
- actualProjectDir = projectName.replace(/-/g, '/');
513
+ actualProjectDir = projectName.replace(/-/g, "/");
486
514
  }
487
515
  }
488
-
489
- const project = {
490
- name: projectName,
491
- path: actualProjectDir,
492
- displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
493
- fullPath: actualProjectDir,
494
- isCustomName: !!projectConfig.displayName,
495
- isManuallyAdded: true,
496
- sessions: [],
497
- cursorSessions: [],
498
- codexSessions: []
499
- };
516
+
517
+ const project = {
518
+ name: projectName,
519
+ path: actualProjectDir,
520
+ displayName:
521
+ projectConfig.displayName ||
522
+ (await generateDisplayName(projectName, actualProjectDir)),
523
+ fullPath: actualProjectDir,
524
+ isCustomName: !!projectConfig.displayName,
525
+ isManuallyAdded: true,
526
+ sessions: [],
527
+ cursorSessions: [],
528
+ codexSessions: [],
529
+ };
500
530
 
501
531
  // Try to fetch Cursor sessions for manual projects too
502
532
  try {
503
533
  project.cursorSessions = await getCursorSessions(actualProjectDir);
504
534
  } catch (e) {
505
- console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
535
+ console.warn(
536
+ `Could not load Cursor sessions for manual project ${projectName}:`,
537
+ e.message,
538
+ );
506
539
  }
507
540
 
508
541
  // Try to fetch Codex sessions for manual projects too
509
542
  try {
510
543
  project.codexSessions = await getCodexSessions(actualProjectDir);
511
544
  } catch (e) {
512
- console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
545
+ console.warn(
546
+ `Could not load Codex sessions for manual project ${projectName}:`,
547
+ e.message,
548
+ );
513
549
  }
514
550
 
515
551
  // Add TaskMaster detection for manual projects
516
552
  try {
517
553
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
518
-
554
+
519
555
  // Determine TaskMaster status
520
- let taskMasterStatus = 'not-configured';
521
- if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
522
- taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
556
+ let taskMasterStatus = "not-configured";
557
+ if (
558
+ taskMasterResult.hasTaskmaster &&
559
+ taskMasterResult.hasEssentialFiles
560
+ ) {
561
+ taskMasterStatus = "taskmaster-only"; // We don't check MCP for manual projects in bulk
523
562
  }
524
-
563
+
525
564
  project.taskmaster = {
526
565
  status: taskMasterStatus,
527
566
  hasTaskmaster: taskMasterResult.hasTaskmaster,
528
567
  hasEssentialFiles: taskMasterResult.hasEssentialFiles,
529
- metadata: taskMasterResult.metadata
568
+ metadata: taskMasterResult.metadata,
530
569
  };
531
570
  } catch (error) {
532
- console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message);
571
+ console.warn(
572
+ `TaskMaster detection failed for manual project ${projectName}:`,
573
+ error.message,
574
+ );
533
575
  project.taskmaster = {
534
- status: 'error',
576
+ status: "error",
535
577
  hasTaskmaster: false,
536
578
  hasEssentialFiles: false,
537
- error: error.message
579
+ error: error.message,
538
580
  };
539
581
  }
540
-
582
+
541
583
  projects.push(project);
542
584
  }
543
585
  }
544
-
586
+
545
587
  return projects;
546
588
  }
547
589
 
548
590
  async function getSessions(projectName, limit = 5, offset = 0) {
549
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
591
+ const projectDir = path.join(
592
+ os.homedir(),
593
+ ".claude",
594
+ "projects",
595
+ projectName,
596
+ );
550
597
 
551
598
  try {
552
599
  const files = await fs.readdir(projectDir);
553
600
  // agent-*.jsonl files contain session start data at this point. This needs to be revisited
554
601
  // periodically to make sure only accurate data is there and no new functionality is added there
555
- const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
556
-
602
+ const jsonlFiles = files.filter(
603
+ (file) => file.endsWith(".jsonl") && !file.startsWith("agent-"),
604
+ );
605
+
557
606
  if (jsonlFiles.length === 0) {
558
607
  return { sessions: [], hasMore: false, total: 0 };
559
608
  }
560
-
609
+
561
610
  // Sort files by modification time (newest first)
562
611
  const filesWithStats = await Promise.all(
563
612
  jsonlFiles.map(async (file) => {
564
613
  const filePath = path.join(projectDir, file);
565
614
  const stats = await fs.stat(filePath);
566
615
  return { file, mtime: stats.mtime };
567
- })
616
+ }),
568
617
  );
569
618
  filesWithStats.sort((a, b) => b.mtime - a.mtime);
570
-
619
+
571
620
  const allSessions = new Map();
572
621
  const allEntries = [];
573
622
  const uuidToSessionMap = new Map();
574
-
623
+
575
624
  // Collect all sessions and entries from all files
576
625
  for (const { file } of filesWithStats) {
577
626
  const jsonlFile = path.join(projectDir, file);
578
627
  const result = await parseJsonlSessions(jsonlFile);
579
-
580
- result.sessions.forEach(session => {
628
+
629
+ result.sessions.forEach((session) => {
581
630
  if (!allSessions.has(session.id)) {
582
631
  allSessions.set(session.id, session);
583
632
  }
584
633
  });
585
-
634
+
586
635
  allEntries.push(...result.entries);
587
-
636
+
588
637
  // Early exit optimization for large projects
589
- if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
638
+ if (
639
+ allSessions.size >= (limit + offset) * 2 &&
640
+ allEntries.length >= Math.min(3, filesWithStats.length)
641
+ ) {
590
642
  break;
591
643
  }
592
644
  }
593
-
645
+
594
646
  // Build UUID-to-session mapping for timeline detection
595
- allEntries.forEach(entry => {
647
+ allEntries.forEach((entry) => {
596
648
  if (entry.uuid && entry.sessionId) {
597
649
  uuidToSessionMap.set(entry.uuid, entry.sessionId);
598
650
  }
599
651
  });
600
-
652
+
601
653
  // Group sessions by first user message ID
602
654
  const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
603
655
  const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
604
656
 
605
657
  // Find the first user message for each session
606
- allEntries.forEach(entry => {
607
- if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
658
+ allEntries.forEach((entry) => {
659
+ if (
660
+ entry.sessionId &&
661
+ entry.type === "user" &&
662
+ entry.parentUuid === null &&
663
+ entry.uuid
664
+ ) {
608
665
  // This is a first user message in a session (parentUuid is null)
609
666
  const firstUserMsgId = entry.uuid;
610
667
 
@@ -616,14 +673,17 @@ async function getSessions(projectName, limit = 5, offset = 0) {
616
673
  if (!sessionGroups.has(firstUserMsgId)) {
617
674
  sessionGroups.set(firstUserMsgId, {
618
675
  latestSession: session,
619
- allSessions: [session]
676
+ allSessions: [session],
620
677
  });
621
678
  } else {
622
679
  const group = sessionGroups.get(firstUserMsgId);
623
680
  group.allSessions.push(session);
624
681
 
625
682
  // Update latest session if this one is more recent
626
- if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
683
+ if (
684
+ new Date(session.lastActivity) >
685
+ new Date(group.latestSession.lastActivity)
686
+ ) {
627
687
  group.latestSession = session;
628
688
  }
629
689
  }
@@ -634,38 +694,39 @@ async function getSessions(projectName, limit = 5, offset = 0) {
634
694
 
635
695
  // Collect all sessions that don't belong to any group (standalone sessions)
636
696
  const groupedSessionIds = new Set();
637
- sessionGroups.forEach(group => {
638
- group.allSessions.forEach(session => groupedSessionIds.add(session.id));
697
+ sessionGroups.forEach((group) => {
698
+ group.allSessions.forEach((session) => groupedSessionIds.add(session.id));
639
699
  });
640
700
 
641
- const standaloneSessionsArray = Array.from(allSessions.values())
642
- .filter(session => !groupedSessionIds.has(session.id));
701
+ const standaloneSessionsArray = Array.from(allSessions.values()).filter(
702
+ (session) => !groupedSessionIds.has(session.id),
703
+ );
643
704
 
644
705
  // Combine grouped sessions (only show latest from each group) + standalone sessions
645
- const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
706
+ const latestFromGroups = Array.from(sessionGroups.values()).map((group) => {
646
707
  const session = { ...group.latestSession };
647
708
  // Add metadata about grouping
648
709
  if (group.allSessions.length > 1) {
649
710
  session.isGrouped = true;
650
711
  session.groupSize = group.allSessions.length;
651
- session.groupSessions = group.allSessions.map(s => s.id);
712
+ session.groupSessions = group.allSessions.map((s) => s.id);
652
713
  }
653
714
  return session;
654
715
  });
655
716
  const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
656
- .filter(session => !session.summary.startsWith('{ "'))
717
+ .filter((session) => !session.summary.startsWith('{ "'))
657
718
  .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
658
719
 
659
720
  const total = visibleSessions.length;
660
721
  const paginatedSessions = visibleSessions.slice(offset, offset + limit);
661
722
  const hasMore = offset + limit < total;
662
-
723
+
663
724
  return {
664
725
  sessions: paginatedSessions,
665
726
  hasMore,
666
727
  total,
667
728
  offset,
668
- limit
729
+ limit,
669
730
  };
670
731
  } catch (error) {
671
732
  console.error(`Error reading sessions for project ${projectName}:`, error);
@@ -682,7 +743,7 @@ async function parseJsonlSessions(filePath) {
682
743
  const fileStream = fsSync.createReadStream(filePath);
683
744
  const rl = readline.createInterface({
684
745
  input: fileStream,
685
- crlfDelay: Infinity
746
+ crlfDelay: Infinity,
686
747
  });
687
748
 
688
749
  for await (const line of rl) {
@@ -692,7 +753,12 @@ async function parseJsonlSessions(filePath) {
692
753
  entries.push(entry);
693
754
 
694
755
  // Handle summary entries that don't have sessionId yet
695
- if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) {
756
+ if (
757
+ entry.type === "summary" &&
758
+ entry.summary &&
759
+ !entry.sessionId &&
760
+ entry.leafUuid
761
+ ) {
696
762
  pendingSummaries.set(entry.leafUuid, entry.summary);
697
763
  }
698
764
 
@@ -700,55 +766,74 @@ async function parseJsonlSessions(filePath) {
700
766
  if (!sessions.has(entry.sessionId)) {
701
767
  sessions.set(entry.sessionId, {
702
768
  id: entry.sessionId,
703
- summary: 'New Session',
769
+ summary: "New Session",
704
770
  messageCount: 0,
705
771
  lastActivity: new Date(),
706
- cwd: entry.cwd || '',
772
+ cwd: entry.cwd || "",
707
773
  lastUserMessage: null,
708
- lastAssistantMessage: null
774
+ lastAssistantMessage: null,
709
775
  });
710
776
  }
711
777
 
712
778
  const session = sessions.get(entry.sessionId);
713
779
 
714
780
  // Apply pending summary if this entry has a parentUuid that matches a pending summary
715
- if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) {
781
+ if (
782
+ session.summary === "New Session" &&
783
+ entry.parentUuid &&
784
+ pendingSummaries.has(entry.parentUuid)
785
+ ) {
716
786
  session.summary = pendingSummaries.get(entry.parentUuid);
717
787
  }
718
788
 
719
789
  // Update summary from summary entries with sessionId
720
- if (entry.type === 'summary' && entry.summary) {
790
+ if (entry.type === "summary" && entry.summary) {
721
791
  session.summary = entry.summary;
722
792
  }
723
793
 
724
794
  // Track last user and assistant messages (skip system messages)
725
- if (entry.message?.role === 'user' && entry.message?.content) {
795
+ if (entry.message?.role === "user" && entry.message?.content) {
726
796
  const content = entry.message.content;
727
797
 
728
798
  // Extract text from array format if needed
729
799
  let textContent = content;
730
- if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
800
+ if (
801
+ Array.isArray(content) &&
802
+ content.length > 0 &&
803
+ content[0].type === "text"
804
+ ) {
731
805
  textContent = content[0].text;
732
806
  }
733
807
 
734
- const isSystemMessage = typeof textContent === 'string' && (
735
- textContent.startsWith('<command-name>') ||
736
- textContent.startsWith('<command-message>') ||
737
- textContent.startsWith('<command-args>') ||
738
- textContent.startsWith('<local-command-stdout>') ||
739
- textContent.startsWith('<system-reminder>') ||
740
- textContent.startsWith('Caveat:') ||
741
- textContent.startsWith('This session is being continued from a previous') ||
742
- textContent.startsWith('Invalid API key') ||
743
- textContent.includes('{"subtasks":') || // Filter Task Master prompts
744
- textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts
745
- textContent === 'Warmup' // Explicitly filter out "Warmup"
746
- );
747
-
748
- if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) {
808
+ const isSystemMessage =
809
+ typeof textContent === "string" &&
810
+ (textContent.startsWith("<command-name>") ||
811
+ textContent.startsWith("<command-message>") ||
812
+ textContent.startsWith("<command-args>") ||
813
+ textContent.startsWith("<local-command-stdout>") ||
814
+ textContent.startsWith("<system-reminder>") ||
815
+ textContent.startsWith("Caveat:") ||
816
+ textContent.startsWith(
817
+ "This session is being continued from a previous",
818
+ ) ||
819
+ textContent.startsWith("Invalid API key") ||
820
+ textContent.includes('{"subtasks":') || // Filter Task Master prompts
821
+ textContent.includes(
822
+ "CRITICAL: You MUST respond with ONLY a JSON",
823
+ ) || // Filter Task Master system prompts
824
+ textContent === "Warmup"); // Explicitly filter out "Warmup"
825
+
826
+ if (
827
+ typeof textContent === "string" &&
828
+ textContent.length > 0 &&
829
+ !isSystemMessage
830
+ ) {
749
831
  session.lastUserMessage = textContent;
750
832
  }
751
- } else if (entry.message?.role === 'assistant' && entry.message?.content) {
833
+ } else if (
834
+ entry.message?.role === "assistant" &&
835
+ entry.message?.content
836
+ ) {
752
837
  // Skip API error messages using the isApiErrorMessage flag
753
838
  if (entry.isApiErrorMessage === true) {
754
839
  // Skip this message entirely
@@ -758,20 +843,22 @@ async function parseJsonlSessions(filePath) {
758
843
 
759
844
  if (Array.isArray(entry.message.content)) {
760
845
  for (const part of entry.message.content) {
761
- if (part.type === 'text' && part.text) {
846
+ if (part.type === "text" && part.text) {
762
847
  assistantText = part.text;
763
848
  }
764
849
  }
765
- } else if (typeof entry.message.content === 'string') {
850
+ } else if (typeof entry.message.content === "string") {
766
851
  assistantText = entry.message.content;
767
852
  }
768
853
 
769
854
  // Additional filter for assistant messages with system content
770
- const isSystemAssistantMessage = typeof assistantText === 'string' && (
771
- assistantText.startsWith('Invalid API key') ||
772
- assistantText.includes('{"subtasks":') ||
773
- assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON')
774
- );
855
+ const isSystemAssistantMessage =
856
+ typeof assistantText === "string" &&
857
+ (assistantText.startsWith("Invalid API key") ||
858
+ assistantText.includes('{"subtasks":') ||
859
+ assistantText.includes(
860
+ "CRITICAL: You MUST respond with ONLY a JSON",
861
+ ));
775
862
 
776
863
  if (assistantText && !isSystemAssistantMessage) {
777
864
  session.lastAssistantMessage = assistantText;
@@ -793,64 +880,79 @@ async function parseJsonlSessions(filePath) {
793
880
 
794
881
  // After processing all entries, set final summary based on last message if no summary exists
795
882
  for (const session of sessions.values()) {
796
- if (session.summary === 'New Session') {
883
+ if (session.summary === "New Session") {
797
884
  // Prefer last user message, fall back to last assistant message
798
- const lastMessage = session.lastUserMessage || session.lastAssistantMessage;
885
+ const lastMessage =
886
+ session.lastUserMessage || session.lastAssistantMessage;
799
887
  if (lastMessage) {
800
- session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage;
888
+ session.summary =
889
+ lastMessage.length > 50
890
+ ? lastMessage.substring(0, 50) + "..."
891
+ : lastMessage;
801
892
  }
802
893
  }
803
894
  }
804
895
 
805
896
  // Filter out sessions that contain JSON responses (Task Master errors)
806
897
  const allSessions = Array.from(sessions.values());
807
- const filteredSessions = allSessions.filter(session => {
898
+ const filteredSessions = allSessions.filter((session) => {
808
899
  const shouldFilter = session.summary.startsWith('{ "');
809
900
  if (shouldFilter) {
810
901
  }
811
902
  // Log a sample of summaries to debug
812
- if (Math.random() < 0.01) { // Log 1% of sessions
903
+ if (Math.random() < 0.01) {
904
+ // Log 1% of sessions
813
905
  }
814
906
  return !shouldFilter;
815
907
  });
816
908
 
817
-
818
909
  return {
819
910
  sessions: filteredSessions,
820
- entries: entries
911
+ entries: entries,
821
912
  };
822
-
823
913
  } catch (error) {
824
- console.error('Error reading JSONL file:', error);
914
+ console.error("Error reading JSONL file:", error);
825
915
  return { sessions: [], entries: [] };
826
916
  }
827
917
  }
828
918
 
829
919
  // Get messages for a specific session with pagination support
830
- async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
831
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
920
+ async function getSessionMessages(
921
+ projectName,
922
+ sessionId,
923
+ limit = null,
924
+ offset = 0,
925
+ ) {
926
+ const projectDir = path.join(
927
+ os.homedir(),
928
+ ".claude",
929
+ "projects",
930
+ projectName,
931
+ );
832
932
 
833
933
  try {
834
934
  const files = await fs.readdir(projectDir);
835
935
  // agent-*.jsonl files contain session start data at this point. This needs to be revisited
836
936
  // periodically to make sure only accurate data is there and no new functionality is added there
837
- const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
838
-
937
+ const jsonlFiles = files.filter(
938
+ (file) => file.endsWith(".jsonl") && !file.startsWith("agent-"),
939
+ );
940
+
839
941
  if (jsonlFiles.length === 0) {
840
942
  return { messages: [], total: 0, hasMore: false };
841
943
  }
842
-
944
+
843
945
  const messages = [];
844
-
946
+
845
947
  // Process all JSONL files to find messages for this session
846
948
  for (const file of jsonlFiles) {
847
949
  const jsonlFile = path.join(projectDir, file);
848
950
  const fileStream = fsSync.createReadStream(jsonlFile);
849
951
  const rl = readline.createInterface({
850
952
  input: fileStream,
851
- crlfDelay: Infinity
953
+ crlfDelay: Infinity,
852
954
  });
853
-
955
+
854
956
  for await (const line of rl) {
855
957
  if (line.trim()) {
856
958
  try {
@@ -859,37 +961,37 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
859
961
  messages.push(entry);
860
962
  }
861
963
  } catch (parseError) {
862
- console.warn('Error parsing line:', parseError.message);
964
+ console.warn("Error parsing line:", parseError.message);
863
965
  }
864
966
  }
865
967
  }
866
968
  }
867
-
969
+
868
970
  // Sort messages by timestamp
869
- const sortedMessages = messages.sort((a, b) =>
870
- new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
971
+ const sortedMessages = messages.sort(
972
+ (a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0),
871
973
  );
872
-
974
+
873
975
  const total = sortedMessages.length;
874
-
976
+
875
977
  // If no limit is specified, return all messages (backward compatibility)
876
978
  if (limit === null) {
877
979
  return sortedMessages;
878
980
  }
879
-
981
+
880
982
  // Apply pagination - for recent messages, we need to slice from the end
881
983
  // offset 0 should give us the most recent messages
882
984
  const startIndex = Math.max(0, total - offset - limit);
883
985
  const endIndex = total - offset;
884
986
  const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
885
987
  const hasMore = startIndex > 0;
886
-
988
+
887
989
  return {
888
990
  messages: paginatedMessages,
889
991
  total,
890
992
  hasMore,
891
993
  offset,
892
- limit
994
+ limit,
893
995
  };
894
996
  } catch (error) {
895
997
  console.error(`Error reading messages for session ${sessionId}:`, error);
@@ -900,41 +1002,46 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
900
1002
  // Rename a project's display name
901
1003
  async function renameProject(projectName, newDisplayName) {
902
1004
  const config = await loadProjectConfig();
903
-
904
- if (!newDisplayName || newDisplayName.trim() === '') {
1005
+
1006
+ if (!newDisplayName || newDisplayName.trim() === "") {
905
1007
  // Remove custom name if empty, will fall back to auto-generated
906
1008
  delete config[projectName];
907
1009
  } else {
908
1010
  // Set custom display name
909
1011
  config[projectName] = {
910
- displayName: newDisplayName.trim()
1012
+ displayName: newDisplayName.trim(),
911
1013
  };
912
1014
  }
913
-
1015
+
914
1016
  await saveProjectConfig(config);
915
1017
  return true;
916
1018
  }
917
1019
 
918
1020
  // Delete a session from a project
919
1021
  async function deleteSession(projectName, sessionId) {
920
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
921
-
1022
+ const projectDir = path.join(
1023
+ os.homedir(),
1024
+ ".claude",
1025
+ "projects",
1026
+ projectName,
1027
+ );
1028
+
922
1029
  try {
923
1030
  const files = await fs.readdir(projectDir);
924
- const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
925
-
1031
+ const jsonlFiles = files.filter((file) => file.endsWith(".jsonl"));
1032
+
926
1033
  if (jsonlFiles.length === 0) {
927
- throw new Error('No session files found for this project');
1034
+ throw new Error("No session files found for this project");
928
1035
  }
929
-
1036
+
930
1037
  // Check all JSONL files to find which one contains the session
931
1038
  for (const file of jsonlFiles) {
932
1039
  const jsonlFile = path.join(projectDir, file);
933
- const content = await fs.readFile(jsonlFile, 'utf8');
934
- const lines = content.split('\n').filter(line => line.trim());
935
-
1040
+ const content = await fs.readFile(jsonlFile, "utf8");
1041
+ const lines = content.split("\n").filter((line) => line.trim());
1042
+
936
1043
  // Check if this file contains the session
937
- const hasSession = lines.some(line => {
1044
+ const hasSession = lines.some((line) => {
938
1045
  try {
939
1046
  const data = JSON.parse(line);
940
1047
  return data.sessionId === sessionId;
@@ -942,10 +1049,10 @@ async function deleteSession(projectName, sessionId) {
942
1049
  return false;
943
1050
  }
944
1051
  });
945
-
1052
+
946
1053
  if (hasSession) {
947
1054
  // Filter out all entries for this session
948
- const filteredLines = lines.filter(line => {
1055
+ const filteredLines = lines.filter((line) => {
949
1056
  try {
950
1057
  const data = JSON.parse(line);
951
1058
  return data.sessionId !== sessionId;
@@ -953,16 +1060,22 @@ async function deleteSession(projectName, sessionId) {
953
1060
  return true; // Keep malformed lines
954
1061
  }
955
1062
  });
956
-
1063
+
957
1064
  // Write back the filtered content
958
- await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
1065
+ await fs.writeFile(
1066
+ jsonlFile,
1067
+ filteredLines.join("\n") + (filteredLines.length > 0 ? "\n" : ""),
1068
+ );
959
1069
  return true;
960
1070
  }
961
1071
  }
962
-
1072
+
963
1073
  throw new Error(`Session ${sessionId} not found in any files`);
964
1074
  } catch (error) {
965
- console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
1075
+ console.error(
1076
+ `Error deleting session ${sessionId} from project ${projectName}:`,
1077
+ error,
1078
+ );
966
1079
  throw error;
967
1080
  }
968
1081
  }
@@ -980,23 +1093,28 @@ async function isProjectEmpty(projectName) {
980
1093
 
981
1094
  // Delete an empty project
982
1095
  async function deleteProject(projectName) {
983
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
984
-
1096
+ const projectDir = path.join(
1097
+ os.homedir(),
1098
+ ".claude",
1099
+ "projects",
1100
+ projectName,
1101
+ );
1102
+
985
1103
  try {
986
1104
  // First check if the project is empty
987
1105
  const isEmpty = await isProjectEmpty(projectName);
988
1106
  if (!isEmpty) {
989
- throw new Error('Cannot delete project with existing sessions');
1107
+ throw new Error("Cannot delete project with existing sessions");
990
1108
  }
991
-
1109
+
992
1110
  // Remove the project directory
993
1111
  await fs.rm(projectDir, { recursive: true, force: true });
994
-
1112
+
995
1113
  // Remove from project config
996
1114
  const config = await loadProjectConfig();
997
1115
  delete config[projectName];
998
1116
  await saveProjectConfig(config);
999
-
1117
+
1000
1118
  return true;
1001
1119
  } catch (error) {
1002
1120
  console.error(`Error deleting project ${projectName}:`, error);
@@ -1007,20 +1125,26 @@ async function deleteProject(projectName) {
1007
1125
  // Add a project manually to the config (without creating folders)
1008
1126
  async function addProjectManually(projectPath, displayName = null) {
1009
1127
  const absolutePath = path.resolve(projectPath);
1010
-
1128
+
1011
1129
  try {
1012
1130
  // Check if the path exists
1013
1131
  await fs.access(absolutePath);
1014
1132
  } catch (error) {
1015
1133
  throw new Error(`Path does not exist: ${absolutePath}`);
1016
1134
  }
1017
-
1135
+
1018
1136
  // Generate project name (encode path for use as directory name)
1019
- const projectName = absolutePath.replace(/\//g, '-');
1020
-
1137
+ // Claude's encoding replaces /, :, spaces, ~, _, and . with -
1138
+ const projectName = absolutePath.replace(/[\\/:\s~_.]/g, "-");
1139
+
1021
1140
  // Check if project already exists in config
1022
1141
  const config = await loadProjectConfig();
1023
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
1142
+ const projectDir = path.join(
1143
+ os.homedir(),
1144
+ ".claude",
1145
+ "projects",
1146
+ projectName,
1147
+ );
1024
1148
 
1025
1149
  if (config[projectName]) {
1026
1150
  throw new Error(`Project already configured for path: ${absolutePath}`);
@@ -1028,28 +1152,28 @@ async function addProjectManually(projectPath, displayName = null) {
1028
1152
 
1029
1153
  // Allow adding projects even if the directory exists - this enables tracking
1030
1154
  // existing Claude Code or Cursor projects in the UI
1031
-
1155
+
1032
1156
  // Add to config as manually added project
1033
1157
  config[projectName] = {
1034
1158
  manuallyAdded: true,
1035
- originalPath: absolutePath
1159
+ originalPath: absolutePath,
1036
1160
  };
1037
-
1161
+
1038
1162
  if (displayName) {
1039
1163
  config[projectName].displayName = displayName;
1040
1164
  }
1041
-
1165
+
1042
1166
  await saveProjectConfig(config);
1043
-
1044
-
1167
+
1045
1168
  return {
1046
1169
  name: projectName,
1047
1170
  path: absolutePath,
1048
1171
  fullPath: absolutePath,
1049
- displayName: displayName || await generateDisplayName(projectName, absolutePath),
1172
+ displayName:
1173
+ displayName || (await generateDisplayName(projectName, absolutePath)),
1050
1174
  isManuallyAdded: true,
1051
1175
  sessions: [],
1052
- cursorSessions: []
1176
+ cursorSessions: [],
1053
1177
  };
1054
1178
  }
1055
1179
 
@@ -1057,9 +1181,9 @@ async function addProjectManually(projectPath, displayName = null) {
1057
1181
  async function getCursorSessions(projectPath) {
1058
1182
  try {
1059
1183
  // Calculate cwdID hash for the project path (Cursor uses MD5 hash)
1060
- const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
1061
- const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
1062
-
1184
+ const cwdId = crypto.createHash("md5").update(projectPath).digest("hex");
1185
+ const cursorChatsPath = path.join(os.homedir(), ".cursor", "chats", cwdId);
1186
+
1063
1187
  // Check if the directory exists
1064
1188
  try {
1065
1189
  await fs.access(cursorChatsPath);
@@ -1067,19 +1191,19 @@ async function getCursorSessions(projectPath) {
1067
1191
  // No sessions for this project
1068
1192
  return [];
1069
1193
  }
1070
-
1194
+
1071
1195
  // List all session directories
1072
1196
  const sessionDirs = await fs.readdir(cursorChatsPath);
1073
1197
  const sessions = [];
1074
-
1198
+
1075
1199
  for (const sessionId of sessionDirs) {
1076
1200
  const sessionPath = path.join(cursorChatsPath, sessionId);
1077
- const storeDbPath = path.join(sessionPath, 'store.db');
1078
-
1201
+ const storeDbPath = path.join(sessionPath, "store.db");
1202
+
1079
1203
  try {
1080
1204
  // Check if store.db exists
1081
1205
  await fs.access(storeDbPath);
1082
-
1206
+
1083
1207
  // Capture store.db mtime as a reliable fallback timestamp
1084
1208
  let dbStatMtimeMs = null;
1085
1209
  try {
@@ -1091,14 +1215,14 @@ async function getCursorSessions(projectPath) {
1091
1215
  const db = await open({
1092
1216
  filename: storeDbPath,
1093
1217
  driver: sqlite3.Database,
1094
- mode: sqlite3.OPEN_READONLY
1218
+ mode: sqlite3.OPEN_READONLY,
1095
1219
  });
1096
-
1220
+
1097
1221
  // Get metadata from meta table
1098
1222
  const metaRows = await db.all(`
1099
1223
  SELECT key, value FROM meta
1100
1224
  `);
1101
-
1225
+
1102
1226
  // Parse metadata
1103
1227
  let metadata = {};
1104
1228
  for (const row of metaRows) {
@@ -1107,7 +1231,7 @@ async function getCursorSessions(projectPath) {
1107
1231
  // Try to decode as hex-encoded JSON
1108
1232
  const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
1109
1233
  if (hexMatch) {
1110
- const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
1234
+ const jsonStr = Buffer.from(row.value, "hex").toString("utf8");
1111
1235
  metadata[row.key] = JSON.parse(jsonStr);
1112
1236
  } else {
1113
1237
  metadata[row.key] = row.value.toString();
@@ -1117,17 +1241,18 @@ async function getCursorSessions(projectPath) {
1117
1241
  }
1118
1242
  }
1119
1243
  }
1120
-
1244
+
1121
1245
  // Get message count
1122
1246
  const messageCountResult = await db.get(`
1123
1247
  SELECT COUNT(*) as count FROM blobs
1124
1248
  `);
1125
-
1249
+
1126
1250
  await db.close();
1127
-
1251
+
1128
1252
  // Extract session info
1129
- const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
1130
-
1253
+ const sessionName =
1254
+ metadata.title || metadata.sessionTitle || "Untitled Session";
1255
+
1131
1256
  // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
1132
1257
  let createdAt = null;
1133
1258
  if (metadata.createdAt) {
@@ -1137,38 +1262,38 @@ async function getCursorSessions(projectPath) {
1137
1262
  } else {
1138
1263
  createdAt = new Date().toISOString();
1139
1264
  }
1140
-
1265
+
1141
1266
  sessions.push({
1142
1267
  id: sessionId,
1143
1268
  name: sessionName,
1144
1269
  createdAt: createdAt,
1145
1270
  lastActivity: createdAt, // For compatibility with Claude sessions
1146
1271
  messageCount: messageCountResult.count || 0,
1147
- projectPath: projectPath
1272
+ projectPath: projectPath,
1148
1273
  });
1149
-
1150
1274
  } catch (error) {
1151
- console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
1275
+ console.warn(
1276
+ `Could not read Cursor session ${sessionId}:`,
1277
+ error.message,
1278
+ );
1152
1279
  }
1153
1280
  }
1154
-
1281
+
1155
1282
  // Sort sessions by creation time (newest first)
1156
1283
  sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
1157
-
1284
+
1158
1285
  // Return only the first 5 sessions for performance
1159
1286
  return sessions.slice(0, 5);
1160
-
1161
1287
  } catch (error) {
1162
- console.error('Error fetching Cursor sessions:', error);
1288
+ console.error("Error fetching Cursor sessions:", error);
1163
1289
  return [];
1164
1290
  }
1165
1291
  }
1166
1292
 
1167
-
1168
1293
  // Fetch Codex sessions for a given project path
1169
1294
  async function getCodexSessions(projectPath) {
1170
1295
  try {
1171
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1296
+ const codexSessionsDir = path.join(os.homedir(), ".codex", "sessions");
1172
1297
  const sessions = [];
1173
1298
 
1174
1299
  // Check if the directory exists
@@ -1187,8 +1312,8 @@ async function getCodexSessions(projectPath) {
1187
1312
  for (const entry of entries) {
1188
1313
  const fullPath = path.join(dir, entry.name);
1189
1314
  if (entry.isDirectory()) {
1190
- files.push(...await findJsonlFiles(fullPath));
1191
- } else if (entry.name.endsWith('.jsonl')) {
1315
+ files.push(...(await findJsonlFiles(fullPath)));
1316
+ } else if (entry.name.endsWith(".jsonl")) {
1192
1317
  files.push(fullPath);
1193
1318
  }
1194
1319
  }
@@ -1207,35 +1332,50 @@ async function getCodexSessions(projectPath) {
1207
1332
 
1208
1333
  // Check if this session matches the project path
1209
1334
  // Handle Windows long paths with \\?\ prefix
1210
- const sessionCwd = sessionData?.cwd || '';
1211
- const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
1212
- const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
1213
-
1214
- if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
1335
+ const sessionCwd = sessionData?.cwd || "";
1336
+ const cleanSessionCwd = sessionCwd.startsWith("\\\\?\\")
1337
+ ? sessionCwd.slice(4)
1338
+ : sessionCwd;
1339
+ const cleanProjectPath = projectPath.startsWith("\\\\?\\")
1340
+ ? projectPath.slice(4)
1341
+ : projectPath;
1342
+
1343
+ if (
1344
+ sessionData &&
1345
+ (sessionData.cwd === projectPath ||
1346
+ cleanSessionCwd === cleanProjectPath ||
1347
+ path.relative(cleanSessionCwd, cleanProjectPath) === "")
1348
+ ) {
1215
1349
  sessions.push({
1216
1350
  id: sessionData.id,
1217
- summary: sessionData.summary || 'Codex Session',
1351
+ summary: sessionData.summary || "Codex Session",
1218
1352
  messageCount: sessionData.messageCount || 0,
1219
- lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
1353
+ lastActivity: sessionData.timestamp
1354
+ ? new Date(sessionData.timestamp)
1355
+ : new Date(),
1220
1356
  cwd: sessionData.cwd,
1221
1357
  model: sessionData.model,
1222
1358
  filePath: filePath,
1223
- provider: 'codex'
1359
+ provider: "codex",
1224
1360
  });
1225
1361
  }
1226
1362
  } catch (error) {
1227
- console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
1363
+ console.warn(
1364
+ `Could not parse Codex session file ${filePath}:`,
1365
+ error.message,
1366
+ );
1228
1367
  }
1229
1368
  }
1230
1369
 
1231
1370
  // Sort sessions by last activity (newest first)
1232
- sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
1371
+ sessions.sort(
1372
+ (a, b) => new Date(b.lastActivity) - new Date(a.lastActivity),
1373
+ );
1233
1374
 
1234
1375
  // Return only the first 5 sessions for performance
1235
1376
  return sessions.slice(0, 5);
1236
-
1237
1377
  } catch (error) {
1238
- console.error('Error fetching Codex sessions:', error);
1378
+ console.error("Error fetching Codex sessions:", error);
1239
1379
  return [];
1240
1380
  }
1241
1381
  }
@@ -1246,7 +1386,7 @@ async function parseCodexSessionFile(filePath) {
1246
1386
  const fileStream = fsSync.createReadStream(filePath);
1247
1387
  const rl = readline.createInterface({
1248
1388
  input: fileStream,
1249
- crlfDelay: Infinity
1389
+ crlfDelay: Infinity,
1250
1390
  });
1251
1391
 
1252
1392
  let sessionMeta = null;
@@ -1265,28 +1405,34 @@ async function parseCodexSessionFile(filePath) {
1265
1405
  }
1266
1406
 
1267
1407
  // Extract session metadata
1268
- if (entry.type === 'session_meta' && entry.payload) {
1408
+ if (entry.type === "session_meta" && entry.payload) {
1269
1409
  sessionMeta = {
1270
1410
  id: entry.payload.id,
1271
1411
  cwd: entry.payload.cwd,
1272
1412
  model: entry.payload.model || entry.payload.model_provider,
1273
1413
  timestamp: entry.timestamp,
1274
- git: entry.payload.git
1414
+ git: entry.payload.git,
1275
1415
  };
1276
1416
  }
1277
1417
 
1278
1418
  // Count messages and extract user messages for summary
1279
- if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
1419
+ if (
1420
+ entry.type === "event_msg" &&
1421
+ entry.payload?.type === "user_message"
1422
+ ) {
1280
1423
  messageCount++;
1281
1424
  if (entry.payload.message) {
1282
1425
  lastUserMessage = entry.payload.message;
1283
1426
  }
1284
1427
  }
1285
1428
 
1286
- if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
1429
+ if (
1430
+ entry.type === "response_item" &&
1431
+ entry.payload?.type === "message" &&
1432
+ entry.payload.role === "assistant"
1433
+ ) {
1287
1434
  messageCount++;
1288
1435
  }
1289
-
1290
1436
  } catch (parseError) {
1291
1437
  // Skip malformed lines
1292
1438
  }
@@ -1297,17 +1443,18 @@ async function parseCodexSessionFile(filePath) {
1297
1443
  return {
1298
1444
  ...sessionMeta,
1299
1445
  timestamp: lastTimestamp || sessionMeta.timestamp,
1300
- summary: lastUserMessage ?
1301
- (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :
1302
- 'Codex Session',
1303
- messageCount
1446
+ summary: lastUserMessage
1447
+ ? lastUserMessage.length > 50
1448
+ ? lastUserMessage.substring(0, 50) + "..."
1449
+ : lastUserMessage
1450
+ : "Codex Session",
1451
+ messageCount,
1304
1452
  };
1305
1453
  }
1306
1454
 
1307
1455
  return null;
1308
-
1309
1456
  } catch (error) {
1310
- console.error('Error parsing Codex session file:', error);
1457
+ console.error("Error parsing Codex session file:", error);
1311
1458
  return null;
1312
1459
  }
1313
1460
  }
@@ -1315,7 +1462,7 @@ async function parseCodexSessionFile(filePath) {
1315
1462
  // Get messages for a specific Codex session
1316
1463
  async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1317
1464
  try {
1318
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1465
+ const codexSessionsDir = path.join(os.homedir(), ".codex", "sessions");
1319
1466
 
1320
1467
  // Find the session file by searching for the session ID
1321
1468
  const findSessionFile = async (dir) => {
@@ -1326,7 +1473,10 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1326
1473
  if (entry.isDirectory()) {
1327
1474
  const found = await findSessionFile(fullPath);
1328
1475
  if (found) return found;
1329
- } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
1476
+ } else if (
1477
+ entry.name.includes(sessionId) &&
1478
+ entry.name.endsWith(".jsonl")
1479
+ ) {
1330
1480
  return fullPath;
1331
1481
  }
1332
1482
  }
@@ -1348,24 +1498,24 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1348
1498
  const fileStream = fsSync.createReadStream(sessionFilePath);
1349
1499
  const rl = readline.createInterface({
1350
1500
  input: fileStream,
1351
- crlfDelay: Infinity
1501
+ crlfDelay: Infinity,
1352
1502
  });
1353
1503
 
1354
1504
  // Helper to extract text from Codex content array
1355
1505
  const extractText = (content) => {
1356
1506
  if (!Array.isArray(content)) return content;
1357
1507
  return content
1358
- .map(item => {
1359
- if (item.type === 'input_text' || item.type === 'output_text') {
1508
+ .map((item) => {
1509
+ if (item.type === "input_text" || item.type === "output_text") {
1360
1510
  return item.text;
1361
1511
  }
1362
- if (item.type === 'text') {
1512
+ if (item.type === "text") {
1363
1513
  return item.text;
1364
1514
  }
1365
- return '';
1515
+ return "";
1366
1516
  })
1367
1517
  .filter(Boolean)
1368
- .join('\n');
1518
+ .join("\n");
1369
1519
  };
1370
1520
 
1371
1521
  for await (const line of rl) {
@@ -1374,64 +1524,77 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1374
1524
  const entry = JSON.parse(line);
1375
1525
 
1376
1526
  // Extract token usage from token_count events (keep latest)
1377
- if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
1527
+ if (
1528
+ entry.type === "event_msg" &&
1529
+ entry.payload?.type === "token_count" &&
1530
+ entry.payload?.info
1531
+ ) {
1378
1532
  const info = entry.payload.info;
1379
1533
  if (info.total_token_usage) {
1380
1534
  tokenUsage = {
1381
1535
  used: info.total_token_usage.total_tokens || 0,
1382
- total: info.model_context_window || 200000
1536
+ total: info.model_context_window || 200000,
1383
1537
  };
1384
1538
  }
1385
1539
  }
1386
1540
 
1387
1541
  // Extract messages from response_item
1388
- if (entry.type === 'response_item' && entry.payload?.type === 'message') {
1542
+ if (
1543
+ entry.type === "response_item" &&
1544
+ entry.payload?.type === "message"
1545
+ ) {
1389
1546
  const content = entry.payload.content;
1390
- const role = entry.payload.role || 'assistant';
1547
+ const role = entry.payload.role || "assistant";
1391
1548
  const textContent = extractText(content);
1392
1549
 
1393
1550
  // Skip system context messages (environment_context)
1394
- if (textContent?.includes('<environment_context>')) {
1551
+ if (textContent?.includes("<environment_context>")) {
1395
1552
  continue;
1396
1553
  }
1397
1554
 
1398
1555
  // Only add if there's actual content
1399
1556
  if (textContent?.trim()) {
1400
1557
  messages.push({
1401
- type: role === 'user' ? 'user' : 'assistant',
1558
+ type: role === "user" ? "user" : "assistant",
1402
1559
  timestamp: entry.timestamp,
1403
1560
  message: {
1404
1561
  role: role,
1405
- content: textContent
1406
- }
1562
+ content: textContent,
1563
+ },
1407
1564
  });
1408
1565
  }
1409
1566
  }
1410
1567
 
1411
- if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
1568
+ if (
1569
+ entry.type === "response_item" &&
1570
+ entry.payload?.type === "reasoning"
1571
+ ) {
1412
1572
  const summaryText = entry.payload.summary
1413
- ?.map(s => s.text)
1573
+ ?.map((s) => s.text)
1414
1574
  .filter(Boolean)
1415
- .join('\n');
1575
+ .join("\n");
1416
1576
  if (summaryText?.trim()) {
1417
1577
  messages.push({
1418
- type: 'thinking',
1578
+ type: "thinking",
1419
1579
  timestamp: entry.timestamp,
1420
1580
  message: {
1421
- role: 'assistant',
1422
- content: summaryText
1423
- }
1581
+ role: "assistant",
1582
+ content: summaryText,
1583
+ },
1424
1584
  });
1425
1585
  }
1426
1586
  }
1427
1587
 
1428
- if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
1588
+ if (
1589
+ entry.type === "response_item" &&
1590
+ entry.payload?.type === "function_call"
1591
+ ) {
1429
1592
  let toolName = entry.payload.name;
1430
1593
  let toolInput = entry.payload.arguments;
1431
1594
 
1432
1595
  // Map Codex tool names to Claude equivalents
1433
- if (toolName === 'shell_command') {
1434
- toolName = 'Bash';
1596
+ if (toolName === "shell_command") {
1597
+ toolName = "Bash";
1435
1598
  try {
1436
1599
  const args = JSON.parse(entry.payload.arguments);
1437
1600
  toolInput = JSON.stringify({ command: args.command });
@@ -1441,76 +1604,84 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1441
1604
  }
1442
1605
 
1443
1606
  messages.push({
1444
- type: 'tool_use',
1607
+ type: "tool_use",
1445
1608
  timestamp: entry.timestamp,
1446
1609
  toolName: toolName,
1447
1610
  toolInput: toolInput,
1448
- toolCallId: entry.payload.call_id
1611
+ toolCallId: entry.payload.call_id,
1449
1612
  });
1450
1613
  }
1451
1614
 
1452
- if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
1615
+ if (
1616
+ entry.type === "response_item" &&
1617
+ entry.payload?.type === "function_call_output"
1618
+ ) {
1453
1619
  messages.push({
1454
- type: 'tool_result',
1620
+ type: "tool_result",
1455
1621
  timestamp: entry.timestamp,
1456
1622
  toolCallId: entry.payload.call_id,
1457
- output: entry.payload.output
1623
+ output: entry.payload.output,
1458
1624
  });
1459
1625
  }
1460
1626
 
1461
- if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
1462
- const toolName = entry.payload.name || 'custom_tool';
1463
- const input = entry.payload.input || '';
1627
+ if (
1628
+ entry.type === "response_item" &&
1629
+ entry.payload?.type === "custom_tool_call"
1630
+ ) {
1631
+ const toolName = entry.payload.name || "custom_tool";
1632
+ const input = entry.payload.input || "";
1464
1633
 
1465
- if (toolName === 'apply_patch') {
1634
+ if (toolName === "apply_patch") {
1466
1635
  // Parse Codex patch format and convert to Claude Edit format
1467
1636
  const fileMatch = input.match(/\*\*\* Update File: (.+)/);
1468
- const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
1637
+ const filePath = fileMatch ? fileMatch[1].trim() : "unknown";
1469
1638
 
1470
1639
  // Extract old and new content from patch
1471
- const lines = input.split('\n');
1640
+ const lines = input.split("\n");
1472
1641
  const oldLines = [];
1473
1642
  const newLines = [];
1474
1643
 
1475
1644
  for (const line of lines) {
1476
- if (line.startsWith('-') && !line.startsWith('---')) {
1645
+ if (line.startsWith("-") && !line.startsWith("---")) {
1477
1646
  oldLines.push(line.substring(1));
1478
- } else if (line.startsWith('+') && !line.startsWith('+++')) {
1647
+ } else if (line.startsWith("+") && !line.startsWith("+++")) {
1479
1648
  newLines.push(line.substring(1));
1480
1649
  }
1481
1650
  }
1482
1651
 
1483
1652
  messages.push({
1484
- type: 'tool_use',
1653
+ type: "tool_use",
1485
1654
  timestamp: entry.timestamp,
1486
- toolName: 'Edit',
1655
+ toolName: "Edit",
1487
1656
  toolInput: JSON.stringify({
1488
1657
  file_path: filePath,
1489
- old_string: oldLines.join('\n'),
1490
- new_string: newLines.join('\n')
1658
+ old_string: oldLines.join("\n"),
1659
+ new_string: newLines.join("\n"),
1491
1660
  }),
1492
- toolCallId: entry.payload.call_id
1661
+ toolCallId: entry.payload.call_id,
1493
1662
  });
1494
1663
  } else {
1495
1664
  messages.push({
1496
- type: 'tool_use',
1665
+ type: "tool_use",
1497
1666
  timestamp: entry.timestamp,
1498
1667
  toolName: toolName,
1499
1668
  toolInput: input,
1500
- toolCallId: entry.payload.call_id
1669
+ toolCallId: entry.payload.call_id,
1501
1670
  });
1502
1671
  }
1503
1672
  }
1504
1673
 
1505
- if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
1674
+ if (
1675
+ entry.type === "response_item" &&
1676
+ entry.payload?.type === "custom_tool_call_output"
1677
+ ) {
1506
1678
  messages.push({
1507
- type: 'tool_result',
1679
+ type: "tool_result",
1508
1680
  timestamp: entry.timestamp,
1509
1681
  toolCallId: entry.payload.call_id,
1510
- output: entry.payload.output || ''
1682
+ output: entry.payload.output || "",
1511
1683
  });
1512
1684
  }
1513
-
1514
1685
  } catch (parseError) {
1515
1686
  // Skip malformed lines
1516
1687
  }
@@ -1518,7 +1689,9 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1518
1689
  }
1519
1690
 
1520
1691
  // Sort by timestamp
1521
- messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
1692
+ messages.sort(
1693
+ (a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0),
1694
+ );
1522
1695
 
1523
1696
  const total = messages.length;
1524
1697
 
@@ -1535,21 +1708,23 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1535
1708
  hasMore,
1536
1709
  offset,
1537
1710
  limit,
1538
- tokenUsage
1711
+ tokenUsage,
1539
1712
  };
1540
1713
  }
1541
1714
 
1542
1715
  return { messages, tokenUsage };
1543
-
1544
1716
  } catch (error) {
1545
- console.error(`Error reading Codex session messages for ${sessionId}:`, error);
1717
+ console.error(
1718
+ `Error reading Codex session messages for ${sessionId}:`,
1719
+ error,
1720
+ );
1546
1721
  return { messages: [], total: 0, hasMore: false };
1547
1722
  }
1548
1723
  }
1549
1724
 
1550
1725
  async function deleteCodexSession(sessionId) {
1551
1726
  try {
1552
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1727
+ const codexSessionsDir = path.join(os.homedir(), ".codex", "sessions");
1553
1728
 
1554
1729
  const findJsonlFiles = async (dir) => {
1555
1730
  const files = [];
@@ -1558,8 +1733,8 @@ async function deleteCodexSession(sessionId) {
1558
1733
  for (const entry of entries) {
1559
1734
  const fullPath = path.join(dir, entry.name);
1560
1735
  if (entry.isDirectory()) {
1561
- files.push(...await findJsonlFiles(fullPath));
1562
- } else if (entry.name.endsWith('.jsonl')) {
1736
+ files.push(...(await findJsonlFiles(fullPath)));
1737
+ } else if (entry.name.endsWith(".jsonl")) {
1563
1738
  files.push(fullPath);
1564
1739
  }
1565
1740
  }
@@ -1584,6 +1759,126 @@ async function deleteCodexSession(sessionId) {
1584
1759
  }
1585
1760
  }
1586
1761
 
1762
+ /**
1763
+ * Get full project details for a specific project by name
1764
+ * Used for on-demand loading when a project is expanded in the sidebar
1765
+ */
1766
+ async function getProjectDetailFull(projectName) {
1767
+ const claudeDir = path.join(os.homedir(), ".claude", "projects");
1768
+ const config = await loadProjectConfig();
1769
+
1770
+ // Check if it's a manually added project
1771
+ const isManuallyAdded = config[projectName]?.manuallyAdded || false;
1772
+
1773
+ // Determine the project directory
1774
+ let projectPath;
1775
+ let actualProjectDir;
1776
+
1777
+ if (isManuallyAdded) {
1778
+ actualProjectDir = config[projectName]?.originalPath;
1779
+ if (!actualProjectDir) {
1780
+ actualProjectDir = await extractProjectDirectory(projectName);
1781
+ }
1782
+ } else {
1783
+ projectPath = path.join(claudeDir, projectName);
1784
+
1785
+ // Check if project directory exists
1786
+ try {
1787
+ await fs.access(projectPath);
1788
+ } catch (error) {
1789
+ if (error.code === "ENOENT") {
1790
+ return null; // Project not found
1791
+ }
1792
+ throw error;
1793
+ }
1794
+
1795
+ actualProjectDir = await extractProjectDirectory(projectName);
1796
+ }
1797
+
1798
+ // Build the project object
1799
+ const customName = config[projectName]?.displayName;
1800
+ const autoDisplayName = await generateDisplayName(
1801
+ projectName,
1802
+ actualProjectDir,
1803
+ );
1804
+
1805
+ const project = {
1806
+ name: projectName,
1807
+ path: actualProjectDir,
1808
+ displayName: customName || autoDisplayName,
1809
+ fullPath: actualProjectDir,
1810
+ isCustomName: !!customName,
1811
+ isManuallyAdded,
1812
+ sessions: [],
1813
+ cursorSessions: [],
1814
+ codexSessions: [],
1815
+ };
1816
+
1817
+ // Fetch all Claude sessions (no limit)
1818
+ if (!isManuallyAdded) {
1819
+ try {
1820
+ const sessionResult = await getSessions(projectName, 100, 0); // Higher limit for detail view
1821
+ project.sessions = sessionResult.sessions || [];
1822
+ project.sessionMeta = {
1823
+ hasMore: sessionResult.hasMore,
1824
+ total: sessionResult.total,
1825
+ };
1826
+ } catch (e) {
1827
+ console.warn(
1828
+ `Could not load sessions for project ${projectName}:`,
1829
+ e.message,
1830
+ );
1831
+ }
1832
+ }
1833
+
1834
+ // Fetch Cursor sessions
1835
+ try {
1836
+ project.cursorSessions = await getCursorSessions(actualProjectDir);
1837
+ } catch (e) {
1838
+ console.warn(
1839
+ `Could not load Cursor sessions for project ${projectName}:`,
1840
+ e.message,
1841
+ );
1842
+ }
1843
+
1844
+ // Fetch Codex sessions
1845
+ try {
1846
+ project.codexSessions = await getCodexSessions(actualProjectDir);
1847
+ } catch (e) {
1848
+ console.warn(
1849
+ `Could not load Codex sessions for project ${projectName}:`,
1850
+ e.message,
1851
+ );
1852
+ }
1853
+
1854
+ // Fetch TaskMaster data
1855
+ try {
1856
+ const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
1857
+ project.taskmaster = {
1858
+ hasTaskmaster: taskMasterResult.hasTaskmaster,
1859
+ hasEssentialFiles: taskMasterResult.hasEssentialFiles,
1860
+ metadata: taskMasterResult.metadata,
1861
+ status:
1862
+ taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles
1863
+ ? "configured"
1864
+ : "not-configured",
1865
+ };
1866
+ } catch (e) {
1867
+ console.warn(
1868
+ `Could not detect TaskMaster for project ${projectName}:`,
1869
+ e.message,
1870
+ );
1871
+ project.taskmaster = {
1872
+ hasTaskmaster: false,
1873
+ hasEssentialFiles: false,
1874
+ metadata: null,
1875
+ status: "error",
1876
+ };
1877
+ }
1878
+
1879
+ return project;
1880
+ }
1881
+
1587
1882
  export {
1588
1883
  getProjects,
1589
1884
  getSessions,
@@ -1600,5 +1895,6 @@ export {
1600
1895
  clearProjectDirectoryCache,
1601
1896
  getCodexSessions,
1602
1897
  getCodexSessionMessages,
1603
- deleteCodexSession
1898
+ deleteCodexSession,
1899
+ getProjectDetailFull,
1604
1900
  };