@axhub/genie 0.2.7 → 0.2.9

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 (131) hide show
  1. package/LICENSE +21 -675
  2. package/dist/api-docs.html +2 -2
  3. package/dist/assets/App-GBcTeeUS.js +460 -0
  4. package/dist/assets/App-qxJ8_QYu.css +32 -0
  5. package/dist/assets/ReviewApp-C9K--AQE.js +1 -0
  6. package/dist/assets/{_basePickBy-C19AekOu.js → _basePickBy-DR_8uFCo.js} +1 -1
  7. package/dist/assets/{_baseUniq-JsnevLw_.js → _baseUniq-D0njlQ_7.js} +1 -1
  8. package/dist/assets/{arc-BLpcuBlf.js → arc-CKlr_Rec.js} +1 -1
  9. package/dist/assets/architectureDiagram-2XIMDMQ5-BmO_uLUH.js +36 -0
  10. package/dist/assets/{blockDiagram-WCTKOSBZ-DQBLwsUS.js → blockDiagram-WCTKOSBZ-DhAeO-56.js} +3 -3
  11. package/dist/assets/c4Diagram-IC4MRINW-C67kFoXx.js +10 -0
  12. package/dist/assets/channel-V3MBjKys.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-De63kbgc.js → chunk-4BX2VUAB-mLLagvJi.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DtTDDdM9.js → chunk-55IACEB6-Lx-hOjlM.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-DHuwd8tw.js → chunk-FMBD7UC4-Bt-XmVUV.js} +1 -1
  16. package/dist/assets/{chunk-JSJVCQXG-BgytFtmO.js → chunk-JSJVCQXG-Cya6gaDV.js} +1 -1
  17. package/dist/assets/{chunk-KX2RTZJC-nZdp86aN.js → chunk-KX2RTZJC-Bd7Ig6tF.js} +1 -1
  18. package/dist/assets/chunk-NQ4KR5QH-5UAE0Vg-.js +220 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DvUQ3mnO.js → chunk-QZHKN3VN-BAxZ8m7w.js} +1 -1
  20. package/dist/assets/chunk-WL4C6EOR-DjDPvUUP.js +189 -0
  21. package/dist/assets/classDiagram-VBA2DB6C-C790yYiY.js +1 -0
  22. package/dist/assets/classDiagram-v2-RAHNMMFH-C790yYiY.js +1 -0
  23. package/dist/assets/clone-BbMGfZwt.js +1 -0
  24. package/dist/assets/cose-bilkent-S5V4N54A-D-60XrkJ.js +1 -0
  25. package/dist/assets/cytoscape.esm-2ZfV8NB5.js +331 -0
  26. package/dist/assets/{dagre-KLK3FWXG-CHYIvW47.js → dagre-KLK3FWXG-bqu3ZS4K.js} +1 -1
  27. package/dist/assets/diagram-E7M64L7V-BueeqoYm.js +24 -0
  28. package/dist/assets/{diagram-IFDJBPK2-Dzsiln_C.js → diagram-IFDJBPK2-D4fDv2E7.js} +1 -1
  29. package/dist/assets/{diagram-P4PSJMXO-DKnGbUpE.js → diagram-P4PSJMXO-WqipY3fN.js} +1 -1
  30. package/dist/assets/erDiagram-INFDFZHY-D0oVnO-x.js +70 -0
  31. package/dist/assets/{flowDiagram-PKNHOUZH-BAZ2-jKp.js → flowDiagram-PKNHOUZH-DzbGyxrr.js} +4 -4
  32. package/dist/assets/ganttDiagram-A5KZAMGK-BwhbbgCP.js +292 -0
  33. package/dist/assets/{gitGraphDiagram-K3NZZRJ6-BflpyjGy.js → gitGraphDiagram-K3NZZRJ6-DZgAh_KM.js} +1 -1
  34. package/dist/assets/{graph-suelaXFh.js → graph-DzKos-N0.js} +1 -1
  35. package/dist/assets/highlighted-body-TPN3WLV5-CKDMgz3X.js +1 -0
  36. package/dist/assets/index-DiQlHzGj.js +2 -0
  37. package/dist/assets/index-Drat2nB9.css +1 -0
  38. package/dist/assets/{infoDiagram-LFFYTUFH-pfD1FA3p.js → infoDiagram-LFFYTUFH-BFicZbTf.js} +1 -1
  39. package/dist/assets/ishikawaDiagram-PHBUUO56-CtihxDxl.js +70 -0
  40. package/dist/assets/journeyDiagram-4ABVD52K-Du00J8_d.js +139 -0
  41. package/dist/assets/{kanban-definition-K7BYSVSG-FWinmur1.js → kanban-definition-K7BYSVSG-BJi9S0iQ.js} +5 -5
  42. package/dist/assets/{layout-vcz43XvZ.js → layout-B80Sityu.js} +1 -1
  43. package/dist/assets/{linear-le4gc0vx.js → linear-sRQLOf5H.js} +1 -1
  44. package/dist/assets/mermaid-O7DHMXV3-CBuVs4eJ.js +1038 -0
  45. package/dist/assets/mindmap-definition-YRQLILUH-C5IL_xi-.js +68 -0
  46. package/dist/assets/{pieDiagram-SKSYHLDU-C7PKDh3b.js → pieDiagram-SKSYHLDU-CeTwlJ8z.js} +2 -2
  47. package/dist/assets/quadrantDiagram-337W2JSQ-COfUcLWt.js +7 -0
  48. package/dist/assets/requirementDiagram-Z7DCOOCP-DSb-CJ5B.js +73 -0
  49. package/dist/assets/{sankeyDiagram-WA2Y5GQK-4gulcOP4.js → sankeyDiagram-WA2Y5GQK-8jtuVb45.js} +3 -3
  50. package/dist/assets/sequenceDiagram-2WXFIKYE-C2VpkMwA.js +145 -0
  51. package/dist/assets/{stateDiagram-RAJIS63D-CB4Vl7qM.js → stateDiagram-RAJIS63D-fmwMqxxc.js} +1 -1
  52. package/dist/assets/stateDiagram-v2-FVOUBMTO-9GGXVWrR.js +1 -0
  53. package/dist/assets/timeline-definition-YZTLITO2-Dx1hP5lg.js +61 -0
  54. package/dist/assets/{treemap-KZPCXAKY-DZSEE6Hz.js → treemap-KZPCXAKY-CkLOdYCZ.js} +58 -58
  55. package/dist/assets/vendor-codemirror-BxPY6emf.js +39 -0
  56. package/dist/assets/vendor-react-xmA_f8ig.js +59 -0
  57. package/dist/assets/vendor-xterm-DfaPXD3y.js +66 -0
  58. package/dist/assets/{vennDiagram-LZ73GAT5-8E_G06fI.js → vennDiagram-LZ73GAT5-D6KWcnln.js} +4 -4
  59. package/dist/assets/xychartDiagram-JWTSCODW-6fh6qmzN.js +7 -0
  60. package/dist/index.html +5 -5
  61. package/package.json +36 -35
  62. package/server/acp-runtime/client.js +91 -17
  63. package/server/acp-runtime/index.js +5 -16
  64. package/server/acp-runtime/session-store.js +4 -4
  65. package/server/channels/runtime/AgentRuntimeAdapter.js +1 -10
  66. package/server/claude-sdk.js +1 -3
  67. package/server/cli.js +159 -2
  68. package/server/external-agent/service.js +24 -6
  69. package/server/external-agent/ws.js +63 -3
  70. package/server/gemini-cli.js +1 -3
  71. package/server/index.js +120 -19
  72. package/server/openai-codex.js +1 -3
  73. package/server/opencode-cli.js +1 -3
  74. package/server/projects.js +654 -236
  75. package/server/routes/cc-connect.js +1131 -0
  76. package/server/routes/cli-auth.js +1 -73
  77. package/server/routes/commands.js +4 -9
  78. package/server/routes/projects.js +45 -24
  79. package/server/routes/session-core.js +149 -86
  80. package/server/session-core/eventStore.js +45 -18
  81. package/server/session-core/providerAdapters.js +50 -13
  82. package/server/session-core/providerDiscovery.js +8 -3
  83. package/server/session-core/runtimeState.js +8 -0
  84. package/server/utils/ccConnectManager.js +390 -0
  85. package/server/utils/ccConnectState.js +575 -0
  86. package/server/utils/resolveCommandPath.js +71 -0
  87. package/server/utils/workspaceRoots.js +154 -0
  88. package/shared/conversationEvents.js +78 -14
  89. package/dist/assets/App-BWSqiXAT.js +0 -220
  90. package/dist/assets/App-DrlLKa8f.css +0 -1
  91. package/dist/assets/ReviewApp-nz3mbArg.js +0 -1
  92. package/dist/assets/architectureDiagram-2XIMDMQ5-CarjBOOv.js +0 -36
  93. package/dist/assets/c4Diagram-IC4MRINW-CGobwBIj.js +0 -10
  94. package/dist/assets/channel-DkFNxV_H.js +0 -1
  95. package/dist/assets/chunk-NQ4KR5QH-CMH6EDP2.js +0 -220
  96. package/dist/assets/chunk-WL4C6EOR-Dn7db_6t.js +0 -189
  97. package/dist/assets/classDiagram-VBA2DB6C-DtwCEe8S.js +0 -1
  98. package/dist/assets/classDiagram-v2-RAHNMMFH-DtwCEe8S.js +0 -1
  99. package/dist/assets/clone-C0lCEIEO.js +0 -1
  100. package/dist/assets/cose-bilkent-S5V4N54A-DD_nzqsz.js +0 -1
  101. package/dist/assets/cytoscape.esm-5J0xJHOV.js +0 -321
  102. package/dist/assets/diagram-E7M64L7V-TVdvHtGc.js +0 -24
  103. package/dist/assets/erDiagram-INFDFZHY-5Kw0bByo.js +0 -70
  104. package/dist/assets/ganttDiagram-A5KZAMGK-CsADFkcq.js +0 -292
  105. package/dist/assets/highlighted-body-OFNGDK62-CZrBMazC.js +0 -1
  106. package/dist/assets/index-B01NxbUv.css +0 -1
  107. package/dist/assets/index-DW5pGgQ_.js +0 -2
  108. package/dist/assets/ishikawaDiagram-PHBUUO56-ndm9snwO.js +0 -70
  109. package/dist/assets/journeyDiagram-4ABVD52K-HgF2t7z5.js +0 -139
  110. package/dist/assets/mermaid-GHXKKRXX-CK8m3lad.js +0 -870
  111. package/dist/assets/mindmap-definition-YRQLILUH-CNq9SKj4.js +0 -68
  112. package/dist/assets/quadrantDiagram-337W2JSQ-B7FnztNO.js +0 -7
  113. package/dist/assets/requirementDiagram-Z7DCOOCP-Bl_BM2Th.js +0 -73
  114. package/dist/assets/sequenceDiagram-2WXFIKYE-VEuJDwyJ.js +0 -145
  115. package/dist/assets/stateDiagram-v2-FVOUBMTO-C85ucl39.js +0 -1
  116. package/dist/assets/timeline-definition-YZTLITO2-BPGKhi7f.js +0 -61
  117. package/dist/assets/vendor-codemirror-CyOKkaQZ.js +0 -31
  118. package/dist/assets/vendor-react-CP4yFTs7.js +0 -8
  119. package/dist/assets/vendor-xterm-DfcmCpbH.js +0 -66
  120. package/dist/assets/xychartDiagram-JWTSCODW-CbBk50-O.js +0 -7
  121. package/server/_legacy-providers/README.md +0 -30
  122. package/server/_legacy-providers/claude-sdk.js +0 -956
  123. package/server/_legacy-providers/gemini-cli.js +0 -368
  124. package/server/_legacy-providers/openai-codex.js +0 -705
  125. package/server/_legacy-providers/opencode-cli.js +0 -674
  126. package/server/acp-runtime/client.test.js +0 -688
  127. package/server/acp-runtime/session-store.test.js +0 -89
  128. package/server/cli.test.js +0 -76
  129. package/server/external-agent/service.test.js +0 -53
  130. package/server/external-agent/ws.test.js +0 -289
  131. package/shared/conversationEvents.test.js +0 -403
@@ -51,13 +51,62 @@ import os from 'os';
51
51
  import path from 'path';
52
52
  import readline from 'readline';
53
53
  import crypto from 'crypto';
54
+ import { fileURLToPath } from 'url';
54
55
  import { parseCodexTokenCountInfo } from './utils/codexTokenUsage.js';
55
56
  import { CODEX_MODELS } from '../shared/modelConstants.js';
56
57
 
58
+ const __filename = fileURLToPath(import.meta.url);
59
+ const __dirname = path.dirname(__filename);
60
+
57
61
  const KNOWN_CODEX_MODELS = new Set(
58
62
  (CODEX_MODELS?.OPTIONS || []).map((option) => String(option?.value || '').trim().toLowerCase()).filter(Boolean)
59
63
  );
60
64
 
65
+ const PROJECT_CONFIG_PERMISSION_ERROR_CODES = new Set(['EACCES', 'EPERM', 'EROFS']);
66
+
67
+ function getPrimaryProjectConfigPath() {
68
+ return path.join(os.homedir(), '.claude', 'project-config.json');
69
+ }
70
+
71
+ function getFallbackProjectConfigPath() {
72
+ const dataFilePath = process.env.DATA_FILE_PATH || path.join(__dirname, 'database', 'state.json');
73
+ return path.join(path.dirname(dataFilePath), 'project-config.json');
74
+ }
75
+
76
+ function getProjectConfigPaths() {
77
+ return [...new Set([
78
+ getPrimaryProjectConfigPath(),
79
+ getFallbackProjectConfigPath()
80
+ ])];
81
+ }
82
+
83
+ function isProjectConfigPermissionError(error) {
84
+ return Boolean(error && PROJECT_CONFIG_PERMISSION_ERROR_CODES.has(error.code));
85
+ }
86
+
87
+ function normalizeProjectConfig(rawConfig) {
88
+ if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
89
+ return {};
90
+ }
91
+
92
+ return rawConfig;
93
+ }
94
+
95
+ async function writeProjectConfigFile(configPath, config) {
96
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
97
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
98
+ }
99
+
100
+ async function removeProjectConfigFile(configPath) {
101
+ try {
102
+ await fs.rm(configPath, { force: true });
103
+ } catch (error) {
104
+ if (error.code !== 'ENOENT') {
105
+ throw error;
106
+ }
107
+ }
108
+ }
109
+
61
110
  function isRecognizedCodexModel(value) {
62
111
  if (typeof value !== 'string') return false;
63
112
  const normalized = value.trim().toLowerCase();
@@ -97,7 +146,12 @@ function extractVisibleUserMessage(value) {
97
146
  return '';
98
147
  }
99
148
 
100
- if (text.startsWith('# AGENTS.md instructions for ') || text.includes('<environment_context>')) {
149
+ if (
150
+ text.startsWith('# AGENTS.md instructions for ') ||
151
+ text.includes('<environment_context>') ||
152
+ text.startsWith('<subagent_notification>') ||
153
+ text.startsWith('</subagent_notification>')
154
+ ) {
101
155
  return '';
102
156
  }
103
157
 
@@ -128,12 +182,16 @@ function isInjectedContextContent(value) {
128
182
 
129
183
  if (
130
184
  text.startsWith('# AGENTS.md instructions for ') ||
131
- text.startsWith('[DYNAMIC CONTEXT V1]')
185
+ text.startsWith('[DYNAMIC CONTEXT V1]') ||
186
+ text.startsWith('<subagent_notification>') ||
187
+ text.startsWith('</subagent_notification>')
132
188
  ) {
133
189
  return true;
134
190
  }
135
191
 
136
- return /^<dynamic_context(?:\s|>)/i.test(text) || text.includes('<environment_context>');
192
+ return /^<dynamic_context(?:\s|>)/i.test(text)
193
+ || text.includes('<environment_context>')
194
+ || /<subagent_notification(?:\s|>)/i.test(text);
137
195
  }
138
196
 
139
197
  // Import TaskMaster detection functions
@@ -266,7 +324,14 @@ async function detectTaskMasterFolder(projectPath) {
266
324
 
267
325
  // Cache for extracted project directories
268
326
  const projectDirectoryCache = new Map();
327
+ const PROJECT_LIST_CACHE_TTL_MS = 15000;
269
328
  const PROVIDER_SESSION_LOOKUP_CACHE_TTL_MS = 5000;
329
+ const projectListCache = {
330
+ data: null,
331
+ promise: null,
332
+ expiresAt: 0
333
+ };
334
+ const codexSessionFileCache = new Map();
270
335
  const providerSessionLookupCache = {
271
336
  codex: { key: null, data: null, promise: null, expiresAt: 0 },
272
337
  gemini: { key: null, data: null, promise: null, expiresAt: 0 },
@@ -343,9 +408,81 @@ async function findFilesRecursively(rootDir, matcher) {
343
408
  return discoveredFiles;
344
409
  }
345
410
 
411
+ function getCodexSessionsDir() {
412
+ return path.join(os.homedir(), '.codex', 'sessions');
413
+ }
414
+
415
+ function cacheCodexSessionFilePath(sessionId, filePath) {
416
+ if (!sessionId || !filePath) {
417
+ return;
418
+ }
419
+
420
+ codexSessionFileCache.set(sessionId, filePath);
421
+ }
422
+
423
+ function findCodexSessionFilePathBySessionIdHint(sessionId, filePaths = []) {
424
+ const normalizedSessionId = String(sessionId || '').trim();
425
+ if (!normalizedSessionId || !Array.isArray(filePaths) || filePaths.length === 0) {
426
+ return null;
427
+ }
428
+
429
+ return filePaths.find((filePath) => {
430
+ const basename = path.basename(filePath, '.jsonl');
431
+ return basename === normalizedSessionId || basename.includes(normalizedSessionId);
432
+ }) || null;
433
+ }
434
+
435
+ async function resolveCodexSessionFile(sessionId) {
436
+ if (!sessionId) {
437
+ return null;
438
+ }
439
+
440
+ const cachedPath = codexSessionFileCache.get(sessionId);
441
+ if (cachedPath) {
442
+ try {
443
+ await fs.access(cachedPath);
444
+ return cachedPath;
445
+ } catch (_) {
446
+ codexSessionFileCache.delete(sessionId);
447
+ }
448
+ }
449
+
450
+ const jsonlFiles = await findFilesRecursively(
451
+ getCodexSessionsDir(),
452
+ (entryName) => entryName.endsWith('.jsonl')
453
+ );
454
+
455
+ const hintedFilePath = findCodexSessionFilePathBySessionIdHint(sessionId, jsonlFiles);
456
+ if (hintedFilePath) {
457
+ cacheCodexSessionFilePath(sessionId, hintedFilePath);
458
+ return hintedFilePath;
459
+ }
460
+
461
+ for (const filePath of jsonlFiles) {
462
+ try {
463
+ const sessionData = await parseCodexSessionFile(filePath);
464
+ if (sessionData?.id === sessionId) {
465
+ cacheCodexSessionFilePath(sessionId, filePath);
466
+ return filePath;
467
+ }
468
+ } catch (error) {
469
+ console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
470
+ }
471
+ }
472
+
473
+ return null;
474
+ }
475
+
476
+ function clearProjectListCache() {
477
+ projectListCache.data = null;
478
+ projectListCache.promise = null;
479
+ projectListCache.expiresAt = 0;
480
+ }
481
+
346
482
  // Clear cache when needed (called when project files change)
347
483
  function clearProjectDirectoryCache() {
348
484
  projectDirectoryCache.clear();
485
+ clearProjectListCache();
349
486
  }
350
487
 
351
488
  function clearProviderSessionLookupCaches() {
@@ -357,6 +494,27 @@ function clearProviderSessionLookupCaches() {
357
494
  }
358
495
  }
359
496
 
497
+ function appendBoundedMessage(buffer, message, maxSize = null) {
498
+ if (!message) {
499
+ return;
500
+ }
501
+
502
+ if (maxSize === null) {
503
+ buffer.push(message);
504
+ return;
505
+ }
506
+
507
+ if (maxSize <= 0) {
508
+ return;
509
+ }
510
+
511
+ if (buffer.length === maxSize) {
512
+ buffer.shift();
513
+ }
514
+
515
+ buffer.push(message);
516
+ }
517
+
360
518
  async function getCachedProviderSessionLookup(providerName, cacheKey, buildLookup) {
361
519
  const cacheEntry = providerSessionLookupCache[providerName];
362
520
 
@@ -393,31 +551,54 @@ async function getCachedProviderSessionLookup(providerName, cacheKey, buildLooku
393
551
 
394
552
  // Load project configuration file
395
553
  async function loadProjectConfig() {
396
- const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
397
- try {
398
- const configData = await fs.readFile(configPath, 'utf8');
399
- return JSON.parse(configData);
400
- } catch (error) {
401
- // Return empty config if file doesn't exist
402
- return {};
554
+ const mergedConfig = {};
555
+
556
+ for (const configPath of getProjectConfigPaths()) {
557
+ try {
558
+ const configData = await fs.readFile(configPath, 'utf8');
559
+ Object.assign(mergedConfig, normalizeProjectConfig(JSON.parse(configData)));
560
+ } catch (error) {
561
+ // Return merged config from any readable location.
562
+ }
403
563
  }
564
+
565
+ return mergedConfig;
404
566
  }
405
567
 
406
568
  // Save project configuration file
407
569
  async function saveProjectConfig(config) {
408
- const claudeDir = path.join(os.homedir(), '.claude');
409
- const configPath = path.join(claudeDir, 'project-config.json');
410
-
411
- // Ensure the .claude directory exists
412
- try {
413
- await fs.mkdir(claudeDir, { recursive: true });
414
- } catch (error) {
415
- if (error.code !== 'EEXIST') {
570
+ const normalizedConfig = normalizeProjectConfig(config);
571
+ const [primaryConfigPath, ...fallbackConfigPaths] = getProjectConfigPaths();
572
+ let lastPermissionError = null;
573
+
574
+ for (const configPath of [primaryConfigPath, ...fallbackConfigPaths]) {
575
+ try {
576
+ await writeProjectConfigFile(configPath, normalizedConfig);
577
+
578
+ if (configPath === primaryConfigPath) {
579
+ for (const fallbackConfigPath of fallbackConfigPaths) {
580
+ await removeProjectConfigFile(fallbackConfigPath);
581
+ }
582
+ }
583
+
584
+ clearProjectDirectoryCache();
585
+ return;
586
+ } catch (error) {
587
+ if (isProjectConfigPermissionError(error)) {
588
+ lastPermissionError = error;
589
+ continue;
590
+ }
591
+
416
592
  throw error;
417
593
  }
418
594
  }
419
-
420
- await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
595
+
596
+ if (lastPermissionError) {
597
+ const saveError = new Error('Unable to save the project list because both Claude config storage and the app data directory are not writable.');
598
+ saveError.code = lastPermissionError.code;
599
+ saveError.cause = lastPermissionError;
600
+ throw saveError;
601
+ }
421
602
  }
422
603
 
423
604
  // Generate better display name from path
@@ -568,15 +749,17 @@ async function extractProjectDirectory(projectName) {
568
749
  }
569
750
  }
570
751
 
571
- async function getProjects(progressCallback = null) {
752
+ function cloneProjectList(projects = []) {
753
+ return projects.map((project) => ({ ...project }));
754
+ }
755
+
756
+ async function collectProjectDefinitions(progressCallback = null) {
572
757
  const claudeDir = path.join(os.homedir(), '.claude', 'projects');
573
758
  const config = await loadProjectConfig();
574
- const projects = [];
575
759
  const existingProjects = new Set();
576
760
  const projectDefinitions = [];
577
761
  let totalProjects = 0;
578
762
  let processedProjects = 0;
579
- let directories = [];
580
763
 
581
764
  try {
582
765
  // Check if the .claude/projects directory exists
@@ -584,7 +767,7 @@ async function getProjects(progressCallback = null) {
584
767
 
585
768
  // First, get existing Claude projects from the file system
586
769
  const entries = await fs.readdir(claudeDir, { withFileTypes: true });
587
- directories = entries.filter(e => e.isDirectory());
770
+ const directories = entries.filter(e => e.isDirectory());
588
771
 
589
772
  // Build set of existing project names for later
590
773
  directories.forEach(e => existingProjects.add(e.name));
@@ -675,69 +858,173 @@ async function getProjects(progressCallback = null) {
675
858
  }
676
859
  }
677
860
 
678
- const uniqueProjectPaths = Array.from(new Set(
679
- projectDefinitions
680
- .map((definition) => normalizeComparableProjectPath(definition.fullPath))
681
- .filter(Boolean)
682
- ));
861
+ return {
862
+ projectDefinitions,
863
+ totalProjects
864
+ };
865
+ }
683
866
 
684
- const [codexSessionsByProjectPath, geminiSessionsByProjectPath, opencodeSessionsByProjectPath] = await Promise.all([
685
- buildCodexSessionsLookup(uniqueProjectPaths, { limit: 5 }),
686
- buildGeminiSessionsLookup(uniqueProjectPaths, { limit: 5 }),
687
- buildOpencodeSessionsLookup(uniqueProjectPaths, { limit: 5 })
688
- ]);
867
+ async function getProjectsList(progressCallback = null) {
868
+ if (
869
+ projectListCache.data &&
870
+ projectListCache.expiresAt > Date.now()
871
+ ) {
872
+ const cachedProjects = cloneProjectList(projectListCache.data);
873
+ if (progressCallback) {
874
+ progressCallback({
875
+ phase: 'complete',
876
+ current: cachedProjects.length,
877
+ total: cachedProjects.length
878
+ });
879
+ }
880
+ return cachedProjects;
881
+ }
689
882
 
690
- for (const definition of projectDefinitions) {
691
- const normalizedProjectPath = normalizeComparableProjectPath(definition.fullPath);
692
- const project = {
883
+ if (!progressCallback && projectListCache.promise) {
884
+ return cloneProjectList(await projectListCache.promise);
885
+ }
886
+
887
+ const loadProjectsList = async () => {
888
+ const { projectDefinitions, totalProjects } = await collectProjectDefinitions(progressCallback);
889
+ const lightweightProjects = projectDefinitions.map((definition) => ({
693
890
  name: definition.name,
694
891
  path: definition.path,
695
892
  displayName: definition.displayName,
696
893
  fullPath: definition.fullPath,
697
894
  isCustomName: definition.isCustomName,
698
- isManuallyAdded: !!definition.isManuallyAdded,
699
- sessions: [],
700
- codexSessions: codexSessionsByProjectPath.get(normalizedProjectPath) || [],
701
- opencodeSessions: opencodeSessionsByProjectPath.get(normalizedProjectPath) || [],
702
- geminiSessions: geminiSessionsByProjectPath.get(normalizedProjectPath) || []
703
- };
895
+ isManuallyAdded: !!definition.isManuallyAdded
896
+ }));
704
897
 
705
- if (!definition.isManuallyAdded) {
706
- try {
707
- const sessionResult = await getSessions(definition.name, 5, 0);
708
- project.sessions = sessionResult.sessions || [];
709
- project.sessionMeta = {
710
- hasMore: sessionResult.hasMore,
711
- total: sessionResult.total
712
- };
713
- } catch (e) {
714
- console.warn(`Could not load sessions for project ${definition.name}:`, e.message);
715
- }
898
+ projectListCache.data = lightweightProjects;
899
+ projectListCache.expiresAt = Date.now() + PROJECT_LIST_CACHE_TTL_MS;
900
+
901
+ if (progressCallback) {
902
+ progressCallback({
903
+ phase: 'complete',
904
+ current: totalProjects,
905
+ total: totalProjects
906
+ });
716
907
  }
717
908
 
718
- try {
719
- const taskMasterResult = await detectTaskMasterFolder(definition.fullPath);
720
- const taskMasterStatus = definition.isManuallyAdded
721
- ? (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'taskmaster-only' : 'not-configured')
722
- : (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured');
723
-
724
- project.taskmaster = {
725
- status: taskMasterStatus,
726
- hasTaskmaster: taskMasterResult.hasTaskmaster,
727
- hasEssentialFiles: taskMasterResult.hasEssentialFiles,
728
- metadata: taskMasterResult.metadata
729
- };
730
- } catch (error) {
731
- console.warn(`TaskMaster detection failed for project ${definition.name}:`, error.message);
732
- project.taskmaster = {
733
- status: 'error',
734
- hasTaskmaster: false,
735
- hasEssentialFiles: false,
736
- error: error.message
909
+ return lightweightProjects;
910
+ };
911
+
912
+ if (progressCallback) {
913
+ return loadProjectsList();
914
+ }
915
+
916
+ projectListCache.promise = loadProjectsList().finally(() => {
917
+ projectListCache.promise = null;
918
+ });
919
+
920
+ return cloneProjectList(await projectListCache.promise);
921
+ }
922
+
923
+ async function buildProjectFromDefinition(definition, providerLookups = null) {
924
+ const normalizedProjectPath = normalizeComparableProjectPath(definition.fullPath);
925
+ const project = {
926
+ name: definition.name,
927
+ path: definition.path,
928
+ displayName: definition.displayName,
929
+ fullPath: definition.fullPath,
930
+ isCustomName: definition.isCustomName,
931
+ isManuallyAdded: !!definition.isManuallyAdded,
932
+ sessions: [],
933
+ codexSessions: [],
934
+ opencodeSessions: [],
935
+ geminiSessions: [],
936
+ sessionMeta: {
937
+ hasMore: false,
938
+ total: 0
939
+ }
940
+ };
941
+
942
+ try {
943
+ if (providerLookups) {
944
+ project.codexSessions = providerLookups.codexSessionsByProjectPath?.get(normalizedProjectPath) || [];
945
+ project.opencodeSessions = providerLookups.opencodeSessionsByProjectPath?.get(normalizedProjectPath) || [];
946
+ project.geminiSessions = providerLookups.geminiSessionsByProjectPath?.get(normalizedProjectPath) || [];
947
+ } else {
948
+ const [codexSessions, opencodeSessions, geminiSessions] = await Promise.all([
949
+ getCodexSessions(definition.fullPath, { limit: 5 }),
950
+ getOpencodeSessions(definition.fullPath, { limit: 5 }),
951
+ getGeminiSessions(definition.fullPath, { limit: 5 })
952
+ ]);
953
+ project.codexSessions = codexSessions;
954
+ project.opencodeSessions = opencodeSessions;
955
+ project.geminiSessions = geminiSessions;
956
+ }
957
+
958
+ if (!definition.isManuallyAdded) {
959
+ const sessionResult = await getSessions(definition.name, 5, 0);
960
+ project.sessions = sessionResult.sessions || [];
961
+ project.sessionMeta = {
962
+ hasMore: sessionResult.hasMore,
963
+ total: sessionResult.total
737
964
  };
738
965
  }
966
+ } catch (error) {
967
+ console.warn(`Could not load session details for project ${definition.name}:`, error.message);
968
+ }
969
+
970
+ try {
971
+ const taskMasterResult = await detectTaskMasterFolder(definition.fullPath);
972
+ const taskMasterStatus = definition.isManuallyAdded
973
+ ? (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'taskmaster-only' : 'not-configured')
974
+ : (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured');
975
+
976
+ project.taskmaster = {
977
+ status: taskMasterStatus,
978
+ hasTaskmaster: taskMasterResult.hasTaskmaster,
979
+ hasEssentialFiles: taskMasterResult.hasEssentialFiles,
980
+ metadata: taskMasterResult.metadata
981
+ };
982
+ } catch (error) {
983
+ console.warn(`TaskMaster detection failed for project ${definition.name}:`, error.message);
984
+ project.taskmaster = {
985
+ status: 'error',
986
+ hasTaskmaster: false,
987
+ hasEssentialFiles: false,
988
+ error: error.message
989
+ };
990
+ }
991
+
992
+ return project;
993
+ }
994
+
995
+ async function getProjectDetails(projectName) {
996
+ const projectList = await getProjectsList();
997
+ const definition = projectList.find((project) => project.name === projectName);
739
998
 
740
- projects.push(project);
999
+ if (!definition) {
1000
+ throw new Error(`Project not found: ${projectName}`);
1001
+ }
1002
+
1003
+ return buildProjectFromDefinition(definition);
1004
+ }
1005
+
1006
+ async function getProjects(progressCallback = null) {
1007
+ const projects = [];
1008
+ const { projectDefinitions, totalProjects } = await collectProjectDefinitions(progressCallback);
1009
+
1010
+ const uniqueProjectPaths = Array.from(new Set(
1011
+ projectDefinitions
1012
+ .map((definition) => normalizeComparableProjectPath(definition.fullPath))
1013
+ .filter(Boolean)
1014
+ ));
1015
+
1016
+ const [codexSessionsByProjectPath, geminiSessionsByProjectPath, opencodeSessionsByProjectPath] = await Promise.all([
1017
+ buildCodexSessionsLookup(uniqueProjectPaths, { limit: 5 }),
1018
+ buildGeminiSessionsLookup(uniqueProjectPaths, { limit: 5 }),
1019
+ buildOpencodeSessionsLookup(uniqueProjectPaths, { limit: 5 })
1020
+ ]);
1021
+
1022
+ for (const definition of projectDefinitions) {
1023
+ projects.push(await buildProjectFromDefinition(definition, {
1024
+ codexSessionsByProjectPath,
1025
+ opencodeSessionsByProjectPath,
1026
+ geminiSessionsByProjectPath
1027
+ }));
741
1028
  }
742
1029
 
743
1030
  // Emit completion after all projects (including manual) are processed
@@ -880,6 +1167,43 @@ async function getSessions(projectName, limit = 5, offset = 0) {
880
1167
  }
881
1168
  }
882
1169
 
1170
+ async function getClaudeSessionMetadata(sessionId) {
1171
+ try {
1172
+ const normalizedSessionId = String(sessionId || '').trim();
1173
+ if (!normalizedSessionId) {
1174
+ return null;
1175
+ }
1176
+
1177
+ const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
1178
+ const jsonlFiles = await findFilesRecursively(
1179
+ claudeProjectsDir,
1180
+ (entryName) => entryName.endsWith('.jsonl') && !entryName.startsWith('agent-')
1181
+ );
1182
+
1183
+ for (const filePath of jsonlFiles) {
1184
+ try {
1185
+ const result = await parseJsonlSessions(filePath);
1186
+ const matchedSession = (result?.sessions || []).find((session) => session?.id === normalizedSessionId);
1187
+ if (matchedSession) {
1188
+ return {
1189
+ ...matchedSession,
1190
+ filePath,
1191
+ projectName: path.basename(path.dirname(filePath)),
1192
+ provider: 'claude'
1193
+ };
1194
+ }
1195
+ } catch {}
1196
+ }
1197
+
1198
+ return null;
1199
+ } catch (error) {
1200
+ if (error?.code !== 'ENOENT') {
1201
+ console.error(`Error reading Claude session metadata for ${sessionId}:`, error);
1202
+ }
1203
+ return null;
1204
+ }
1205
+ }
1206
+
883
1207
  async function parseJsonlSessions(filePath) {
884
1208
  const sessions = new Map();
885
1209
  const entries = [];
@@ -1107,14 +1431,27 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
1107
1431
  // Rename a project's display name
1108
1432
  async function renameProject(projectName, newDisplayName) {
1109
1433
  const config = await loadProjectConfig();
1434
+ const existingConfig = config[projectName] && typeof config[projectName] === 'object'
1435
+ ? config[projectName]
1436
+ : {};
1437
+ const trimmedDisplayName = typeof newDisplayName === 'string'
1438
+ ? newDisplayName.trim()
1439
+ : '';
1110
1440
 
1111
- if (!newDisplayName || newDisplayName.trim() === '') {
1112
- // Remove custom name if empty, will fall back to auto-generated
1113
- delete config[projectName];
1441
+ if (!trimmedDisplayName) {
1442
+ // Remove only the custom display name while preserving other metadata such as
1443
+ // manuallyAdded/originalPath so externally added projects do not disappear.
1444
+ const { displayName: _removedDisplayName, ...remainingConfig } = existingConfig;
1445
+
1446
+ if (Object.keys(remainingConfig).length === 0) {
1447
+ delete config[projectName];
1448
+ } else {
1449
+ config[projectName] = remainingConfig;
1450
+ }
1114
1451
  } else {
1115
- // Set custom display name
1116
1452
  config[projectName] = {
1117
- displayName: newDisplayName.trim()
1453
+ ...existingConfig,
1454
+ displayName: trimmedDisplayName
1118
1455
  };
1119
1456
  }
1120
1457
 
@@ -1252,7 +1589,41 @@ async function addProjectManually(projectPath, displayName = null) {
1252
1589
  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
1253
1590
 
1254
1591
  if (config[projectName]) {
1255
- throw new Error(`Project already configured for path: ${absolutePath}`);
1592
+ const existingConfig = config[projectName] && typeof config[projectName] === 'object'
1593
+ ? config[projectName]
1594
+ : {};
1595
+ const hasManualMetadata = Boolean(existingConfig.manuallyAdded || existingConfig.originalPath || existingConfig.path);
1596
+
1597
+ if (hasManualMetadata) {
1598
+ throw new Error(`Project already configured for path: ${absolutePath}`);
1599
+ }
1600
+
1601
+ // Recover historical broken entries that lost their manual-project metadata
1602
+ // during a rename, which otherwise makes the project invisible in the sidebar
1603
+ // while still blocking re-adding the same workspace.
1604
+ config[projectName] = {
1605
+ ...existingConfig,
1606
+ manuallyAdded: true,
1607
+ originalPath: absolutePath
1608
+ };
1609
+
1610
+ if (displayName) {
1611
+ config[projectName].displayName = displayName;
1612
+ }
1613
+
1614
+ await saveProjectConfig(config);
1615
+
1616
+ return {
1617
+ name: projectName,
1618
+ path: absolutePath,
1619
+ fullPath: absolutePath,
1620
+ displayName: config[projectName].displayName || await generateDisplayName(projectName, absolutePath),
1621
+ isManuallyAdded: true,
1622
+ sessions: [],
1623
+ codexSessions: [],
1624
+ opencodeSessions: [],
1625
+ geminiSessions: []
1626
+ };
1256
1627
  }
1257
1628
 
1258
1629
  // Allow adding projects even if the directory exists - this enables tracking
@@ -1289,7 +1660,7 @@ async function getCodexSessions(projectPath, options = {}) {
1289
1660
  const sessions = [];
1290
1661
  const normalizedProjectPath = normalizeComparableProjectPath(projectPath);
1291
1662
  const jsonlFiles = await findFilesRecursively(
1292
- path.join(os.homedir(), '.codex', 'sessions'),
1663
+ getCodexSessionsDir(),
1293
1664
  (entryName) => entryName.endsWith('.jsonl')
1294
1665
  );
1295
1666
 
@@ -1299,6 +1670,7 @@ async function getCodexSessions(projectPath, options = {}) {
1299
1670
  const sessionData = await parseCodexSessionFile(filePath);
1300
1671
 
1301
1672
  if (sessionData && normalizeComparableProjectPath(sessionData.cwd) === normalizedProjectPath) {
1673
+ cacheCodexSessionFilePath(sessionData.id, filePath);
1302
1674
  sessions.push({
1303
1675
  id: sessionData.id,
1304
1676
  summary: sessionData.summary || 'Codex Session',
@@ -1327,15 +1699,17 @@ async function getCodexSessions(projectPath, options = {}) {
1327
1699
  }
1328
1700
  }
1329
1701
 
1330
- const OPENCODE_SESSION_DIR_CANDIDATES = [
1331
- path.join(os.homedir(), '.opencode', 'sessions'),
1332
- path.join(os.homedir(), '.config', 'opencode', 'sessions')
1333
- ];
1702
+ function getOpencodeSessionDirCandidates() {
1703
+ return [
1704
+ path.join(os.homedir(), '.opencode', 'sessions'),
1705
+ path.join(os.homedir(), '.config', 'opencode', 'sessions')
1706
+ ];
1707
+ }
1334
1708
 
1335
1709
  async function findOpencodeSessionFiles() {
1336
1710
  const files = [];
1337
1711
 
1338
- for (const baseDir of OPENCODE_SESSION_DIR_CANDIDATES) {
1712
+ for (const baseDir of getOpencodeSessionDirCandidates()) {
1339
1713
  const discovered = await findFilesRecursively(
1340
1714
  baseDir,
1341
1715
  (entryName, _fullPath, entry) => entry.isFile() && entryName.endsWith('.jsonl')
@@ -1600,6 +1974,34 @@ async function findOpencodeSessionFileById(sessionId) {
1600
1974
  return null;
1601
1975
  }
1602
1976
 
1977
+ async function getOpencodeSessionMetadata(sessionId) {
1978
+ try {
1979
+ const normalizedSessionId = String(sessionId || '').trim();
1980
+ if (!normalizedSessionId) {
1981
+ return null;
1982
+ }
1983
+
1984
+ const sessionFilePath = await findOpencodeSessionFileById(normalizedSessionId);
1985
+ if (!sessionFilePath) {
1986
+ return null;
1987
+ }
1988
+
1989
+ const sessionData = await parseOpencodeSessionFile(sessionFilePath);
1990
+ if (!sessionData) {
1991
+ return null;
1992
+ }
1993
+
1994
+ return {
1995
+ ...sessionData,
1996
+ filePath: sessionFilePath,
1997
+ provider: 'opencode'
1998
+ };
1999
+ } catch (error) {
2000
+ console.error(`Error reading OpenCode session metadata for ${sessionId}:`, error);
2001
+ return null;
2002
+ }
2003
+ }
2004
+
1603
2005
  async function getOpencodeSessionMessages(sessionId, limit = null, offset = 0) {
1604
2006
  try {
1605
2007
  const sessionFilePath = await findOpencodeSessionFileById(sessionId);
@@ -1730,43 +2132,42 @@ async function deleteOpencodeSession(sessionId) {
1730
2132
  }
1731
2133
  }
1732
2134
 
1733
- async function getGeminiSessions(projectPath, options = {}) {
1734
- const { limit = 5 } = options;
1735
- try {
1736
- const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
1737
- try {
1738
- await fs.access(geminiTmpDir);
1739
- } catch {
1740
- return [];
1741
- }
2135
+ async function findGeminiSessionFiles() {
2136
+ return findFilesRecursively(
2137
+ path.join(os.homedir(), '.gemini', 'tmp'),
2138
+ (entryName, fullPath) => (
2139
+ entryName.endsWith('.json') &&
2140
+ /[\\/]chats[\\/]/.test(fullPath)
2141
+ )
2142
+ );
2143
+ }
1742
2144
 
1743
- const projectHash = crypto.createHash('sha256').update(projectPath).digest('hex');
1744
- const chatsDir = path.join(geminiTmpDir, projectHash, 'chats');
2145
+ function inferGeminiProjectHash(filePath, parsedSession = null) {
2146
+ const explicitHash = typeof parsedSession?.projectHash === 'string'
2147
+ ? parsedSession.projectHash.trim()
2148
+ : '';
2149
+ if (explicitHash) {
2150
+ return explicitHash.toLowerCase();
2151
+ }
1745
2152
 
1746
- try {
1747
- await fs.access(chatsDir);
1748
- } catch {
1749
- return [];
1750
- }
2153
+ const parentDir = path.basename(path.dirname(path.dirname(filePath)));
2154
+ if (/^[a-f0-9]{64}$/i.test(parentDir)) {
2155
+ return parentDir.toLowerCase();
2156
+ }
1751
2157
 
1752
- const entries = await fs.readdir(chatsDir, { withFileTypes: true });
1753
- const sessionFiles = entries
1754
- .filter(e => e.isFile() && e.name.endsWith('.json'))
1755
- .map(e => path.join(chatsDir, e.name));
2158
+ return '';
2159
+ }
1756
2160
 
1757
- const sessions = [];
1758
- for (const filePath of sessionFiles) {
1759
- try {
1760
- const sessionData = await parseGeminiSessionFile(filePath);
1761
- if (!sessionData?.id) continue;
1762
- sessions.push(sessionData);
1763
- } catch (error) {
1764
- console.warn(`Could not parse Gemini session file ${filePath}:`, error.message);
1765
- }
1766
- }
2161
+ async function getGeminiSessions(projectPath, options = {}) {
2162
+ const { limit = 5 } = options;
2163
+ const normalizedProjectPath = normalizeComparableProjectPath(projectPath);
2164
+ if (!normalizedProjectPath) {
2165
+ return [];
2166
+ }
1767
2167
 
1768
- sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
1769
- return limit > 0 ? sessions.slice(0, limit) : sessions;
2168
+ try {
2169
+ const lookup = await buildGeminiSessionsLookup([normalizedProjectPath], { limit });
2170
+ return lookup.get(normalizedProjectPath) || [];
1770
2171
  } catch (error) {
1771
2172
  console.error('Error fetching Gemini sessions:', error);
1772
2173
  return [];
@@ -1805,11 +2206,36 @@ async function parseGeminiSessionFile(filePath) {
1805
2206
  lastActivity,
1806
2207
  createdAt: data?.startTime || lastActivity,
1807
2208
  model: messages.filter(m => m?.model).slice(-1)[0]?.model || null,
2209
+ projectHash: inferGeminiProjectHash(filePath, data),
1808
2210
  provider: 'gemini',
1809
2211
  filePath
1810
2212
  };
1811
2213
  }
1812
2214
 
2215
+ async function getGeminiSessionMetadata(sessionId) {
2216
+ try {
2217
+ const normalizedSessionId = String(sessionId || '').trim();
2218
+ if (!normalizedSessionId) {
2219
+ return null;
2220
+ }
2221
+
2222
+ const sessionFiles = await findGeminiSessionFiles();
2223
+ for (const filePath of sessionFiles) {
2224
+ try {
2225
+ const sessionData = await parseGeminiSessionFile(filePath);
2226
+ if (sessionData?.id === normalizedSessionId) {
2227
+ return sessionData;
2228
+ }
2229
+ } catch {}
2230
+ }
2231
+
2232
+ return null;
2233
+ } catch (error) {
2234
+ console.error(`Error reading Gemini session metadata for ${sessionId}:`, error);
2235
+ return null;
2236
+ }
2237
+ }
2238
+
1813
2239
  async function buildCodexSessionsLookup(projectPaths, options = {}) {
1814
2240
  const { limit = 5 } = options;
1815
2241
  const normalizedProjectPaths = Array.from(new Set(
@@ -1823,7 +2249,7 @@ async function buildCodexSessionsLookup(projectPaths, options = {}) {
1823
2249
  const cacheKey = JSON.stringify({ projectPaths: normalizedProjectPaths, limit });
1824
2250
  return getCachedProviderSessionLookup('codex', cacheKey, async () => {
1825
2251
  const jsonlFiles = await findFilesRecursively(
1826
- path.join(os.homedir(), '.codex', 'sessions'),
2252
+ getCodexSessionsDir(),
1827
2253
  (entryName) => entryName.endsWith('.jsonl')
1828
2254
  );
1829
2255
 
@@ -1832,6 +2258,7 @@ async function buildCodexSessionsLookup(projectPaths, options = {}) {
1832
2258
  try {
1833
2259
  const sessionData = await parseCodexSessionFile(filePath);
1834
2260
  if (sessionData?.id) {
2261
+ cacheCodexSessionFilePath(sessionData.id, filePath);
1835
2262
  sessions.push({
1836
2263
  id: sessionData.id,
1837
2264
  summary: sessionData.summary || 'Codex Session',
@@ -1884,73 +2311,58 @@ async function buildGeminiSessionsLookup(projectPaths, options = {}) {
1884
2311
  (projectPaths || []).map(normalizeComparableProjectPath).filter(Boolean)
1885
2312
  ));
1886
2313
 
2314
+ if (normalizedProjectPaths.length === 0) {
2315
+ return new Map();
2316
+ }
2317
+
1887
2318
  const cacheKey = JSON.stringify({ projectPaths: normalizedProjectPaths, limit });
1888
2319
  return getCachedProviderSessionLookup('gemini', cacheKey, async () => {
1889
- const sessionsByProjectPath = new Map();
1890
-
1891
- for (const normalizedProjectPath of normalizedProjectPaths) {
1892
- const projectHash = crypto.createHash('sha256').update(normalizedProjectPath).digest('hex');
1893
- const chatsDir = path.join(os.homedir(), '.gemini', 'tmp', projectHash, 'chats');
2320
+ const projectHashToPath = new Map(
2321
+ normalizedProjectPaths.map((normalizedProjectPath) => ([
2322
+ crypto.createHash('sha256').update(normalizedProjectPath).digest('hex'),
2323
+ normalizedProjectPath
2324
+ ]))
2325
+ );
2326
+ const sessionsByProjectPath = new Map(
2327
+ normalizedProjectPaths.map((normalizedProjectPath) => [normalizedProjectPath, []])
2328
+ );
2329
+ const sessionFiles = await findGeminiSessionFiles();
1894
2330
 
2331
+ for (const filePath of sessionFiles) {
1895
2332
  try {
1896
- const entries = await fs.readdir(chatsDir, { withFileTypes: true });
1897
- const sessionFiles = entries
1898
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
1899
- .map((entry) => path.join(chatsDir, entry.name));
2333
+ const sessionData = await parseGeminiSessionFile(filePath);
2334
+ if (!sessionData?.id) {
2335
+ continue;
2336
+ }
1900
2337
 
1901
- const sessions = [];
1902
- for (const filePath of sessionFiles) {
1903
- try {
1904
- const sessionData = await parseGeminiSessionFile(filePath);
1905
- if (sessionData?.id) {
1906
- sessions.push(sessionData);
1907
- }
1908
- } catch (error) {
1909
- console.warn(`Could not parse Gemini session file ${filePath}:`, error.message);
1910
- }
2338
+ const normalizedProjectPath = projectHashToPath.get(sessionData.projectHash || '');
2339
+ if (!normalizedProjectPath) {
2340
+ continue;
1911
2341
  }
1912
2342
 
1913
- sessions.sort((a, b) => new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0));
1914
- sessionsByProjectPath.set(
1915
- normalizedProjectPath,
1916
- limit > 0 ? sessions.slice(0, limit) : sessions
1917
- );
1918
- } catch (_) {
1919
- sessionsByProjectPath.set(normalizedProjectPath, []);
2343
+ sessionsByProjectPath.get(normalizedProjectPath)?.push(sessionData);
2344
+ } catch (error) {
2345
+ console.warn(`Could not parse Gemini session file ${filePath}:`, error.message);
1920
2346
  }
1921
2347
  }
1922
2348
 
2349
+ for (const [normalizedProjectPath, sessions] of sessionsByProjectPath.entries()) {
2350
+ sessions.sort((a, b) => new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0));
2351
+ sessionsByProjectPath.set(
2352
+ normalizedProjectPath,
2353
+ limit > 0 ? sessions.slice(0, limit) : sessions
2354
+ );
2355
+ }
2356
+
1923
2357
  return sessionsByProjectPath;
1924
2358
  });
1925
2359
  }
1926
2360
 
1927
2361
  async function getGeminiSessionMessages(sessionId, limit = null, offset = 0) {
1928
2362
  try {
1929
- const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
1930
- const findSessionFile = async (dir) => {
1931
- try {
1932
- const entries = await fs.readdir(dir, { withFileTypes: true });
1933
- for (const entry of entries) {
1934
- const fullPath = path.join(dir, entry.name);
1935
- if (entry.isDirectory()) {
1936
- const found = await findSessionFile(fullPath);
1937
- if (found) return found;
1938
- continue;
1939
- }
1940
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
1941
- try {
1942
- const raw = await fs.readFile(fullPath, 'utf8');
1943
- const parsed = JSON.parse(raw);
1944
- if (parsed?.sessionId === sessionId) {
1945
- return fullPath;
1946
- }
1947
- } catch {}
1948
- }
1949
- } catch {}
1950
- return null;
1951
- };
2363
+ const sessionMetadata = await getGeminiSessionMetadata(sessionId);
2364
+ const sessionFilePath = sessionMetadata?.filePath || null;
1952
2365
 
1953
- const sessionFilePath = await findSessionFile(geminiTmpDir);
1954
2366
  if (!sessionFilePath) {
1955
2367
  return { messages: [], total: 0, hasMore: false };
1956
2368
  }
@@ -2100,6 +2512,7 @@ async function parseCodexSessionFile(filePath) {
2100
2512
  }
2101
2513
 
2102
2514
  if (sessionMeta) {
2515
+ cacheCodexSessionFilePath(sessionMeta.id, filePath);
2103
2516
  return {
2104
2517
  ...sessionMeta,
2105
2518
  model: lastResolvedModel || sessionMeta.model || null,
@@ -2122,35 +2535,18 @@ async function parseCodexSessionFile(filePath) {
2122
2535
  // Get messages for a specific Codex session
2123
2536
  async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2124
2537
  try {
2125
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
2126
-
2127
- // Find the session file by searching for the session ID
2128
- const findSessionFile = async (dir) => {
2129
- try {
2130
- const entries = await fs.readdir(dir, { withFileTypes: true });
2131
- for (const entry of entries) {
2132
- const fullPath = path.join(dir, entry.name);
2133
- if (entry.isDirectory()) {
2134
- const found = await findSessionFile(fullPath);
2135
- if (found) return found;
2136
- } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
2137
- return fullPath;
2138
- }
2139
- }
2140
- } catch (error) {
2141
- // Skip directories we can't read
2142
- }
2143
- return null;
2144
- };
2145
-
2146
- const sessionFilePath = await findSessionFile(codexSessionsDir);
2538
+ const sessionFilePath = await resolveCodexSessionFile(sessionId);
2147
2539
 
2148
2540
  if (!sessionFilePath) {
2149
2541
  console.warn(`Codex session file not found for session ${sessionId}`);
2150
2542
  return { messages: [], total: 0, hasMore: false };
2151
2543
  }
2152
2544
 
2545
+ const normalizedLimit = limit === null ? null : Math.max(0, Number(limit) || 0);
2546
+ const normalizedOffset = Math.max(0, Number(offset) || 0);
2547
+ const maxBufferedMessages = normalizedLimit === null ? null : normalizedLimit + normalizedOffset;
2153
2548
  const messages = [];
2549
+ let total = 0;
2154
2550
  let tokenUsage = null;
2155
2551
  const fileStream = fsSync.createReadStream(sessionFilePath);
2156
2552
  const rl = readline.createInterface({
@@ -2175,6 +2571,11 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2175
2571
  .join('\n');
2176
2572
  };
2177
2573
 
2574
+ const appendMessage = (message) => {
2575
+ total += 1;
2576
+ appendBoundedMessage(messages, message, maxBufferedMessages);
2577
+ };
2578
+
2178
2579
  for await (const line of rl) {
2179
2580
  if (line.trim()) {
2180
2581
  try {
@@ -2200,7 +2601,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2200
2601
 
2201
2602
  // Only add if there's actual content
2202
2603
  if (visibleTextContent?.trim()) {
2203
- messages.push({
2604
+ appendMessage({
2204
2605
  type: role === 'user' ? 'user' : 'assistant',
2205
2606
  timestamp: entry.timestamp,
2206
2607
  message: {
@@ -2217,7 +2618,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2217
2618
  .filter(Boolean)
2218
2619
  .join('\n');
2219
2620
  if (summaryText?.trim()) {
2220
- messages.push({
2621
+ appendMessage({
2221
2622
  type: 'thinking',
2222
2623
  timestamp: entry.timestamp,
2223
2624
  message: {
@@ -2243,7 +2644,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2243
2644
  }
2244
2645
  }
2245
2646
 
2246
- messages.push({
2647
+ appendMessage({
2247
2648
  type: 'tool_use',
2248
2649
  timestamp: entry.timestamp,
2249
2650
  toolName: toolName,
@@ -2253,7 +2654,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2253
2654
  }
2254
2655
 
2255
2656
  if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
2256
- messages.push({
2657
+ appendMessage({
2257
2658
  type: 'tool_result',
2258
2659
  timestamp: entry.timestamp,
2259
2660
  toolCallId: entry.payload.call_id,
@@ -2283,7 +2684,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2283
2684
  }
2284
2685
  }
2285
2686
 
2286
- messages.push({
2687
+ appendMessage({
2287
2688
  type: 'tool_use',
2288
2689
  timestamp: entry.timestamp,
2289
2690
  toolName: 'Edit',
@@ -2295,7 +2696,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2295
2696
  toolCallId: entry.payload.call_id
2296
2697
  });
2297
2698
  } else {
2298
- messages.push({
2699
+ appendMessage({
2299
2700
  type: 'tool_use',
2300
2701
  timestamp: entry.timestamp,
2301
2702
  toolName: toolName,
@@ -2306,7 +2707,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2306
2707
  }
2307
2708
 
2308
2709
  if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
2309
- messages.push({
2710
+ appendMessage({
2310
2711
  type: 'tool_result',
2311
2712
  timestamp: entry.timestamp,
2312
2713
  toolCallId: entry.payload.call_id,
@@ -2320,29 +2721,31 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2320
2721
  }
2321
2722
  }
2322
2723
 
2323
- // Sort by timestamp
2324
- messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
2325
-
2326
- const total = messages.length;
2327
-
2328
2724
  // Apply pagination if limit is specified
2329
- if (limit !== null) {
2330
- const startIndex = Math.max(0, total - offset - limit);
2331
- const endIndex = total - offset;
2725
+ if (normalizedLimit !== null) {
2726
+ const endIndex = Math.max(0, messages.length - normalizedOffset);
2727
+ const startIndex = Math.max(0, endIndex - normalizedLimit);
2332
2728
  const paginatedMessages = messages.slice(startIndex, endIndex);
2333
- const hasMore = startIndex > 0;
2729
+ const hasMore = total > normalizedOffset + paginatedMessages.length;
2334
2730
 
2335
2731
  return {
2336
2732
  messages: paginatedMessages,
2337
2733
  total,
2338
2734
  hasMore,
2339
- offset,
2340
- limit,
2735
+ offset: normalizedOffset,
2736
+ limit: normalizedLimit,
2341
2737
  tokenUsage
2342
2738
  };
2343
2739
  }
2344
2740
 
2345
- return { messages, tokenUsage };
2741
+ return {
2742
+ messages,
2743
+ total,
2744
+ hasMore: false,
2745
+ offset: normalizedOffset,
2746
+ limit: normalizedLimit,
2747
+ tokenUsage
2748
+ };
2346
2749
 
2347
2750
  } catch (error) {
2348
2751
  console.error(`Error reading Codex session messages for ${sessionId}:`, error);
@@ -2350,37 +2753,45 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2350
2753
  }
2351
2754
  }
2352
2755
 
2353
- async function deleteCodexSession(sessionId) {
2354
- try {
2355
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
2756
+ async function getCodexSessionMetadata(sessionId) {
2757
+ if (!sessionId) {
2758
+ return null;
2759
+ }
2356
2760
 
2357
- const findJsonlFiles = async (dir) => {
2358
- const files = [];
2359
- try {
2360
- const entries = await fs.readdir(dir, { withFileTypes: true });
2361
- for (const entry of entries) {
2362
- const fullPath = path.join(dir, entry.name);
2363
- if (entry.isDirectory()) {
2364
- files.push(...await findJsonlFiles(fullPath));
2365
- } else if (entry.name.endsWith('.jsonl')) {
2366
- files.push(fullPath);
2367
- }
2368
- }
2369
- } catch (error) {}
2370
- return files;
2371
- };
2761
+ const sessionFilePath = await resolveCodexSessionFile(sessionId);
2762
+ if (!sessionFilePath) {
2763
+ return null;
2764
+ }
2372
2765
 
2373
- const jsonlFiles = await findJsonlFiles(codexSessionsDir);
2766
+ const sessionData = await parseCodexSessionFile(sessionFilePath);
2767
+ if (!sessionData) {
2768
+ return null;
2769
+ }
2374
2770
 
2375
- for (const filePath of jsonlFiles) {
2376
- const sessionData = await parseCodexSessionFile(filePath);
2377
- if (sessionData && sessionData.id === sessionId) {
2378
- await fs.unlink(filePath);
2379
- return true;
2380
- }
2771
+ const metadata = {
2772
+ ...sessionData,
2773
+ filePath: sessionFilePath,
2774
+ provider: 'codex'
2775
+ };
2776
+
2777
+ if (metadata.git === undefined) {
2778
+ delete metadata.git;
2779
+ }
2780
+
2781
+ return metadata;
2782
+ }
2783
+
2784
+ async function deleteCodexSession(sessionId) {
2785
+ try {
2786
+ const sessionFilePath = await resolveCodexSessionFile(sessionId);
2787
+
2788
+ if (!sessionFilePath) {
2789
+ throw new Error(`Codex session file not found for session ${sessionId}`);
2381
2790
  }
2382
2791
 
2383
- throw new Error(`Codex session file not found for session ${sessionId}`);
2792
+ await fs.unlink(sessionFilePath);
2793
+ codexSessionFileCache.delete(sessionId);
2794
+ return true;
2384
2795
  } catch (error) {
2385
2796
  console.error(`Error deleting Codex session ${sessionId}:`, error);
2386
2797
  throw error;
@@ -2389,7 +2800,10 @@ async function deleteCodexSession(sessionId) {
2389
2800
 
2390
2801
  export {
2391
2802
  getProjects,
2803
+ getProjectsList,
2804
+ getProjectDetails,
2392
2805
  getSessions,
2806
+ getClaudeSessionMetadata,
2393
2807
  getSessionMessages,
2394
2808
  parseJsonlSessions,
2395
2809
  renameProject,
@@ -2402,11 +2816,15 @@ export {
2402
2816
  extractProjectDirectory,
2403
2817
  clearProjectDirectoryCache,
2404
2818
  clearProviderSessionLookupCaches,
2819
+ findCodexSessionFilePathBySessionIdHint,
2405
2820
  getCodexSessions,
2406
2821
  getCodexSessionMessages,
2822
+ getCodexSessionMetadata,
2407
2823
  getOpencodeSessions,
2824
+ getOpencodeSessionMetadata,
2408
2825
  getOpencodeSessionMessages,
2409
2826
  getGeminiSessions,
2827
+ getGeminiSessionMetadata,
2410
2828
  getGeminiSessionMessages,
2411
2829
  deleteCodexSession,
2412
2830
  deleteOpencodeSession