@axhub/genie 0.2.8 → 0.2.10

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 (106) hide show
  1. package/LICENSE +21 -675
  2. package/dist/api-docs.html +2 -2
  3. package/dist/assets/App-CYCCsgwf.js +264 -0
  4. package/dist/assets/ReviewApp-0srHIXwb.js +1 -0
  5. package/dist/assets/{_basePickBy-CqJbRZ9y.js → _basePickBy-DVVb07UV.js} +1 -1
  6. package/dist/assets/{_baseUniq-BS8YH8jO.js → _baseUniq-BtbziL5G.js} +1 -1
  7. package/dist/assets/{arc-BBmKEN-S.js → arc-BsCC8yBD.js} +1 -1
  8. package/dist/assets/{architectureDiagram-2XIMDMQ5-N5lcb82R.js → architectureDiagram-2XIMDMQ5-woFp6eNI.js} +1 -1
  9. package/dist/assets/{blockDiagram-WCTKOSBZ-DTMwHuLn.js → blockDiagram-WCTKOSBZ-ya8VAc2k.js} +1 -1
  10. package/dist/assets/{c4Diagram-IC4MRINW-BTKlkXI9.js → c4Diagram-IC4MRINW-CY1dZmIZ.js} +1 -1
  11. package/dist/assets/channel-BMhScXFe.js +1 -0
  12. package/dist/assets/{chunk-4BX2VUAB-DUdoTxAc.js → chunk-4BX2VUAB-CR1lAd74.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-Bm_92xe4.js → chunk-55IACEB6-CP98WcFC.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-CGW0g62g.js → chunk-FMBD7UC4-D9c7ijAB.js} +1 -1
  15. package/dist/assets/{chunk-JSJVCQXG-DYkTH3w1.js → chunk-JSJVCQXG-DQAGYOn-.js} +1 -1
  16. package/dist/assets/{chunk-KX2RTZJC-C9oTlISU.js → chunk-KX2RTZJC-BbTXiDq7.js} +1 -1
  17. package/dist/assets/{chunk-NQ4KR5QH-CM50ygWP.js → chunk-NQ4KR5QH-BI6AX0dr.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-7dzpYeNJ.js → chunk-QZHKN3VN-DB3V2Ifo.js} +1 -1
  19. package/dist/assets/{chunk-WL4C6EOR-Cm9nQrsr.js → chunk-WL4C6EOR-DhzTthv6.js} +1 -1
  20. package/dist/assets/classDiagram-VBA2DB6C-CMIxlWcT.js +1 -0
  21. package/dist/assets/classDiagram-v2-RAHNMMFH-CMIxlWcT.js +1 -0
  22. package/dist/assets/clone-BPqOt4r3.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-Ccp_p0JZ.js → cose-bilkent-S5V4N54A-BQ09ZE2j.js} +1 -1
  24. package/dist/assets/{dagre-KLK3FWXG-fBwTLUp9.js → dagre-KLK3FWXG-Dc2ueD_R.js} +1 -1
  25. package/dist/assets/{diagram-E7M64L7V-CeNVmFUp.js → diagram-E7M64L7V-DP-LsQoL.js} +1 -1
  26. package/dist/assets/{diagram-IFDJBPK2-CtavyLGa.js → diagram-IFDJBPK2-Cg6r42cB.js} +1 -1
  27. package/dist/assets/{diagram-P4PSJMXO-CpQTjQwc.js → diagram-P4PSJMXO-aHsfoUZE.js} +1 -1
  28. package/dist/assets/{erDiagram-INFDFZHY-B8R5vwhd.js → erDiagram-INFDFZHY-qBXJ4aAz.js} +1 -1
  29. package/dist/assets/{flowDiagram-PKNHOUZH-BvkVVwIQ.js → flowDiagram-PKNHOUZH-D_13emJM.js} +1 -1
  30. package/dist/assets/{ganttDiagram-A5KZAMGK-DOu3hSNa.js → ganttDiagram-A5KZAMGK-BvIcOLwz.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-K3NZZRJ6-C7zT67YE.js → gitGraphDiagram-K3NZZRJ6-ad0vvNcU.js} +1 -1
  32. package/dist/assets/{graph-D11wiwHo.js → graph-CeJCMjan.js} +1 -1
  33. package/dist/assets/{highlighted-body-TPN3WLV5-Babpthg-.js → highlighted-body-TPN3WLV5-B_novwSz.js} +1 -1
  34. package/dist/assets/index-C514cLyb.js +2 -0
  35. package/dist/assets/index-h1DBl_g3.css +1 -0
  36. package/dist/assets/{infoDiagram-LFFYTUFH-BmA7IpQG.js → infoDiagram-LFFYTUFH-lOxAqb3m.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-PHBUUO56-BEquZd3E.js → ishikawaDiagram-PHBUUO56-DIr-51gj.js} +1 -1
  38. package/dist/assets/{journeyDiagram-4ABVD52K-BfemGz7f.js → journeyDiagram-4ABVD52K-CYcIW0ZU.js} +1 -1
  39. package/dist/assets/{kanban-definition-K7BYSVSG-CWja3mln.js → kanban-definition-K7BYSVSG-C1ZK616a.js} +1 -1
  40. package/dist/assets/{layout-BLUNf-PJ.js → layout-CI2RM-v6.js} +1 -1
  41. package/dist/assets/{linear-DukIV_Xv.js → linear-DE7bISck.js} +1 -1
  42. package/dist/assets/{mermaid-O7DHMXV3-SgtM28qI.js → mermaid-O7DHMXV3-XxAJo8EK.js} +6 -6
  43. package/dist/assets/{mindmap-definition-YRQLILUH-4UjqXITU.js → mindmap-definition-YRQLILUH-Dz6EFjmn.js} +1 -1
  44. package/dist/assets/{pieDiagram-SKSYHLDU-8AxqJd0M.js → pieDiagram-SKSYHLDU-DPpEzUed.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-337W2JSQ-D60m8V8r.js → quadrantDiagram-337W2JSQ-xdoXNet7.js} +1 -1
  46. package/dist/assets/{requirementDiagram-Z7DCOOCP-zqh9jBVf.js → requirementDiagram-Z7DCOOCP-DUq8H3CL.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-WA2Y5GQK-CDZILTLI.js → sankeyDiagram-WA2Y5GQK-CmqEUxRu.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-2WXFIKYE-7BReFd0L.js → sequenceDiagram-2WXFIKYE-DhtXRNiH.js} +1 -1
  49. package/dist/assets/{stateDiagram-RAJIS63D-HPTVdIG4.js → stateDiagram-RAJIS63D-Dj0HOlbN.js} +1 -1
  50. package/dist/assets/stateDiagram-v2-FVOUBMTO-C9utf5gv.js +1 -0
  51. package/dist/assets/{timeline-definition-YZTLITO2-CTVllFgr.js → timeline-definition-YZTLITO2-DUuJzZB5.js} +1 -1
  52. package/dist/assets/{treemap-KZPCXAKY-BtyxboJZ.js → treemap-KZPCXAKY-DpYBQ0qr.js} +1 -1
  53. package/dist/assets/vendor-codemirror-CMHSJ_9p.js +9 -0
  54. package/dist/assets/{vendor-react-Cpt6D04s.js → vendor-react-xmA_f8ig.js} +1 -1
  55. package/dist/assets/{vennDiagram-LZ73GAT5-D96ZI6Mg.js → vennDiagram-LZ73GAT5-DpePUyOd.js} +1 -1
  56. package/dist/assets/{xychartDiagram-JWTSCODW-eRk-39YO.js → xychartDiagram-JWTSCODW-Cfp1I4_U.js} +1 -1
  57. package/dist/index.html +5 -5
  58. package/package.json +8 -7
  59. package/server/acp-runtime/client.js +129 -16
  60. package/server/acp-runtime/index.js +54 -0
  61. package/server/acp-runtime/registry.js +2 -2
  62. package/server/acp-runtime/session-store.js +79 -5
  63. package/server/cli.js +55 -10
  64. package/server/database/db.js +20 -0
  65. package/server/external-agent/service.js +24 -6
  66. package/server/external-agent/ws.js +540 -27
  67. package/server/index.js +112 -151
  68. package/server/lan-access/core.js +79 -0
  69. package/server/lan-access/state.js +102 -0
  70. package/server/middleware/auth.js +57 -14
  71. package/server/projects.js +930 -667
  72. package/server/routes/auth.js +24 -4
  73. package/server/routes/cli-auth.js +21 -25
  74. package/server/routes/codex.js +84 -298
  75. package/server/routes/commands.js +322 -407
  76. package/server/routes/lan-access.js +231 -0
  77. package/server/routes/projects.js +154 -158
  78. package/server/routes/session-core.js +160 -91
  79. package/server/routes/settings.js +113 -99
  80. package/server/session-core/eventStore.js +60 -20
  81. package/server/session-core/providerAdapters.js +75 -38
  82. package/server/session-core/runtimeState.js +8 -0
  83. package/server/session-core/sessionListMerge.js +47 -0
  84. package/shared/conversationEvents.js +174 -15
  85. package/shared/modelConstants.js +79 -99
  86. package/dist/assets/App-CTKZtqB1.js +0 -460
  87. package/dist/assets/ReviewApp-DM6BNAzR.js +0 -1
  88. package/dist/assets/channel-1oJBvF-0.js +0 -1
  89. package/dist/assets/classDiagram-VBA2DB6C-d5TeKFM4.js +0 -1
  90. package/dist/assets/classDiagram-v2-RAHNMMFH-d5TeKFM4.js +0 -1
  91. package/dist/assets/clone-CinxIlEu.js +0 -1
  92. package/dist/assets/index-DFxzgWoO.js +0 -2
  93. package/dist/assets/index-YCFGDVKw.css +0 -1
  94. package/dist/assets/stateDiagram-v2-FVOUBMTO-DTUf5_gC.js +0 -1
  95. package/dist/assets/vendor-codemirror-Dz7_EqNA.js +0 -39
  96. package/server/_legacy-providers/README.md +0 -30
  97. package/server/_legacy-providers/claude-sdk.js +0 -956
  98. package/server/_legacy-providers/gemini-cli.js +0 -368
  99. package/server/_legacy-providers/openai-codex.js +0 -705
  100. package/server/_legacy-providers/opencode-cli.js +0 -674
  101. package/server/routes/git.js +0 -1110
  102. package/server/routes/mcp-utils.js +0 -48
  103. package/server/routes/mcp.js +0 -536
  104. package/server/routes/taskmaster.js +0 -1963
  105. package/server/utils/mcp-detector.js +0 -198
  106. package/server/utils/taskmaster-websocket.js +0 -129
@@ -51,13 +51,68 @@ 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';
57
+ import { listAcpSessions } from './acp-runtime/session-store.js';
58
+ import { mergeSessionLists } from './session-core/sessionListMerge.js';
59
+
60
+ const __filename = fileURLToPath(import.meta.url);
61
+ const __dirname = path.dirname(__filename);
56
62
 
57
63
  const KNOWN_CODEX_MODELS = new Set(
58
64
  (CODEX_MODELS?.OPTIONS || []).map((option) => String(option?.value || '').trim().toLowerCase()).filter(Boolean)
59
65
  );
60
66
 
67
+ const PROJECT_CONFIG_PERMISSION_ERROR_CODES = new Set(['EACCES', 'EPERM', 'EROFS']);
68
+
69
+ function trimText(value) {
70
+ return typeof value === 'string' ? value.trim() : '';
71
+ }
72
+
73
+ function getPrimaryProjectConfigPath() {
74
+ return path.join(os.homedir(), '.claude', 'project-config.json');
75
+ }
76
+
77
+ function getFallbackProjectConfigPath() {
78
+ const dataFilePath = process.env.DATA_FILE_PATH || path.join(__dirname, 'database', 'state.json');
79
+ return path.join(path.dirname(dataFilePath), 'project-config.json');
80
+ }
81
+
82
+ function getProjectConfigPaths() {
83
+ return [...new Set([
84
+ getPrimaryProjectConfigPath(),
85
+ getFallbackProjectConfigPath()
86
+ ])];
87
+ }
88
+
89
+ function isProjectConfigPermissionError(error) {
90
+ return Boolean(error && PROJECT_CONFIG_PERMISSION_ERROR_CODES.has(error.code));
91
+ }
92
+
93
+ function normalizeProjectConfig(rawConfig) {
94
+ if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
95
+ return {};
96
+ }
97
+
98
+ return rawConfig;
99
+ }
100
+
101
+ async function writeProjectConfigFile(configPath, config) {
102
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
103
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
104
+ }
105
+
106
+ async function removeProjectConfigFile(configPath) {
107
+ try {
108
+ await fs.rm(configPath, { force: true });
109
+ } catch (error) {
110
+ if (error.code !== 'ENOENT') {
111
+ throw error;
112
+ }
113
+ }
114
+ }
115
+
61
116
  function isRecognizedCodexModel(value) {
62
117
  if (typeof value !== 'string') return false;
63
118
  const normalized = value.trim().toLowerCase();
@@ -97,7 +152,12 @@ function extractVisibleUserMessage(value) {
97
152
  return '';
98
153
  }
99
154
 
100
- if (text.startsWith('# AGENTS.md instructions for ') || text.includes('<environment_context>')) {
155
+ if (
156
+ text.startsWith('# AGENTS.md instructions for ') ||
157
+ text.includes('<environment_context>') ||
158
+ text.startsWith('<subagent_notification>') ||
159
+ text.startsWith('</subagent_notification>')
160
+ ) {
101
161
  return '';
102
162
  }
103
163
 
@@ -120,6 +180,40 @@ function extractVisibleUserMessage(value) {
120
180
  return text;
121
181
  }
122
182
 
183
+ function extractCodexMessageText(content) {
184
+ if (!Array.isArray(content)) {
185
+ return typeof content === 'string' ? content : '';
186
+ }
187
+
188
+ return content
189
+ .map((item) => {
190
+ if (item?.type === 'input_text' || item?.type === 'output_text' || item?.type === 'text') {
191
+ return item.text;
192
+ }
193
+ return '';
194
+ })
195
+ .filter(Boolean)
196
+ .join('\n');
197
+ }
198
+
199
+ function shouldIncludeCodexTranscriptMessage(entry) {
200
+ const payload = entry?.payload;
201
+ if (entry?.type !== 'response_item' || payload?.type !== 'message') {
202
+ return false;
203
+ }
204
+
205
+ const role = String(payload.role || '').trim().toLowerCase();
206
+ if (role !== 'user' && role !== 'assistant') {
207
+ return false;
208
+ }
209
+
210
+ if (role === 'assistant' && String(payload.phase || '').trim().toLowerCase() === 'commentary') {
211
+ return false;
212
+ }
213
+
214
+ return true;
215
+ }
216
+
123
217
  function isInjectedContextContent(value) {
124
218
  const text = String(value || '').trim();
125
219
  if (!text) {
@@ -128,145 +222,28 @@ function isInjectedContextContent(value) {
128
222
 
129
223
  if (
130
224
  text.startsWith('# AGENTS.md instructions for ') ||
131
- text.startsWith('[DYNAMIC CONTEXT V1]')
225
+ text.startsWith('[DYNAMIC CONTEXT V1]') ||
226
+ text.startsWith('<subagent_notification>') ||
227
+ text.startsWith('</subagent_notification>')
132
228
  ) {
133
229
  return true;
134
230
  }
135
231
 
136
- return /^<dynamic_context(?:\s|>)/i.test(text) || text.includes('<environment_context>');
137
- }
138
-
139
- // Import TaskMaster detection functions
140
- async function detectTaskMasterFolder(projectPath) {
141
- try {
142
- const taskMasterPath = path.join(projectPath, '.taskmaster');
143
-
144
- // Check if .taskmaster directory exists
145
- try {
146
- const stats = await fs.stat(taskMasterPath);
147
- if (!stats.isDirectory()) {
148
- return {
149
- hasTaskmaster: false,
150
- reason: '.taskmaster exists but is not a directory'
151
- };
152
- }
153
- } catch (error) {
154
- if (error.code === 'ENOENT') {
155
- return {
156
- hasTaskmaster: false,
157
- reason: '.taskmaster directory not found'
158
- };
159
- }
160
- throw error;
161
- }
162
-
163
- // Check for key TaskMaster files
164
- const keyFiles = [
165
- 'tasks/tasks.json',
166
- 'config.json'
167
- ];
168
-
169
- const fileStatus = {};
170
- let hasEssentialFiles = true;
171
-
172
- for (const file of keyFiles) {
173
- const filePath = path.join(taskMasterPath, file);
174
- try {
175
- await fs.access(filePath);
176
- fileStatus[file] = true;
177
- } catch (error) {
178
- fileStatus[file] = false;
179
- if (file === 'tasks/tasks.json') {
180
- hasEssentialFiles = false;
181
- }
182
- }
183
- }
184
-
185
- // Parse tasks.json if it exists for metadata
186
- let taskMetadata = null;
187
- if (fileStatus['tasks/tasks.json']) {
188
- try {
189
- const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
190
- const tasksContent = await fs.readFile(tasksPath, 'utf8');
191
- const tasksData = JSON.parse(tasksContent);
192
-
193
- // Handle both tagged and legacy formats
194
- let tasks = [];
195
- if (tasksData.tasks) {
196
- // Legacy format
197
- tasks = tasksData.tasks;
198
- } else {
199
- // Tagged format - get tasks from all tags
200
- Object.values(tasksData).forEach(tagData => {
201
- if (tagData.tasks) {
202
- tasks = tasks.concat(tagData.tasks);
203
- }
204
- });
205
- }
206
-
207
- // Calculate task statistics
208
- const stats = tasks.reduce((acc, task) => {
209
- acc.total++;
210
- acc[task.status] = (acc[task.status] || 0) + 1;
211
-
212
- // Count subtasks
213
- if (task.subtasks) {
214
- task.subtasks.forEach(subtask => {
215
- acc.subtotalTasks++;
216
- acc.subtasks = acc.subtasks || {};
217
- acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
218
- });
219
- }
220
-
221
- return acc;
222
- }, {
223
- total: 0,
224
- subtotalTasks: 0,
225
- pending: 0,
226
- 'in-progress': 0,
227
- done: 0,
228
- review: 0,
229
- deferred: 0,
230
- cancelled: 0,
231
- subtasks: {}
232
- });
233
-
234
- taskMetadata = {
235
- taskCount: stats.total,
236
- subtaskCount: stats.subtotalTasks,
237
- completed: stats.done || 0,
238
- pending: stats.pending || 0,
239
- inProgress: stats['in-progress'] || 0,
240
- review: stats.review || 0,
241
- completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
242
- lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
243
- };
244
- } catch (parseError) {
245
- console.warn('Failed to parse tasks.json:', parseError.message);
246
- taskMetadata = { error: 'Failed to parse tasks.json' };
247
- }
248
- }
249
-
250
- return {
251
- hasTaskmaster: true,
252
- hasEssentialFiles,
253
- files: fileStatus,
254
- metadata: taskMetadata,
255
- path: taskMasterPath
256
- };
257
-
258
- } catch (error) {
259
- console.error('Error detecting TaskMaster folder:', error);
260
- return {
261
- hasTaskmaster: false,
262
- reason: `Error checking directory: ${error.message}`
263
- };
264
- }
232
+ return /^<dynamic_context(?:\s|>)/i.test(text)
233
+ || text.includes('<environment_context>')
234
+ || /<subagent_notification(?:\s|>)/i.test(text);
265
235
  }
266
236
 
267
237
  // Cache for extracted project directories
268
238
  const projectDirectoryCache = new Map();
239
+ const PROJECT_LIST_CACHE_TTL_MS = 15000;
269
240
  const PROVIDER_SESSION_LOOKUP_CACHE_TTL_MS = 5000;
241
+ const projectListCache = {
242
+ data: null,
243
+ promise: null,
244
+ expiresAt: 0
245
+ };
246
+ const codexSessionFileCache = new Map();
270
247
  const providerSessionLookupCache = {
271
248
  codex: { key: null, data: null, promise: null, expiresAt: 0 },
272
249
  gemini: { key: null, data: null, promise: null, expiresAt: 0 },
@@ -343,9 +320,81 @@ async function findFilesRecursively(rootDir, matcher) {
343
320
  return discoveredFiles;
344
321
  }
345
322
 
323
+ function getCodexSessionsDir() {
324
+ return path.join(os.homedir(), '.codex', 'sessions');
325
+ }
326
+
327
+ function cacheCodexSessionFilePath(sessionId, filePath) {
328
+ if (!sessionId || !filePath) {
329
+ return;
330
+ }
331
+
332
+ codexSessionFileCache.set(sessionId, filePath);
333
+ }
334
+
335
+ function findCodexSessionFilePathBySessionIdHint(sessionId, filePaths = []) {
336
+ const normalizedSessionId = String(sessionId || '').trim();
337
+ if (!normalizedSessionId || !Array.isArray(filePaths) || filePaths.length === 0) {
338
+ return null;
339
+ }
340
+
341
+ return filePaths.find((filePath) => {
342
+ const basename = path.basename(filePath, '.jsonl');
343
+ return basename === normalizedSessionId || basename.includes(normalizedSessionId);
344
+ }) || null;
345
+ }
346
+
347
+ async function resolveCodexSessionFile(sessionId) {
348
+ if (!sessionId) {
349
+ return null;
350
+ }
351
+
352
+ const cachedPath = codexSessionFileCache.get(sessionId);
353
+ if (cachedPath) {
354
+ try {
355
+ await fs.access(cachedPath);
356
+ return cachedPath;
357
+ } catch (_) {
358
+ codexSessionFileCache.delete(sessionId);
359
+ }
360
+ }
361
+
362
+ const jsonlFiles = await findFilesRecursively(
363
+ getCodexSessionsDir(),
364
+ (entryName) => entryName.endsWith('.jsonl')
365
+ );
366
+
367
+ const hintedFilePath = findCodexSessionFilePathBySessionIdHint(sessionId, jsonlFiles);
368
+ if (hintedFilePath) {
369
+ cacheCodexSessionFilePath(sessionId, hintedFilePath);
370
+ return hintedFilePath;
371
+ }
372
+
373
+ for (const filePath of jsonlFiles) {
374
+ try {
375
+ const sessionData = await parseCodexSessionFile(filePath);
376
+ if (sessionData?.id === sessionId) {
377
+ cacheCodexSessionFilePath(sessionId, filePath);
378
+ return filePath;
379
+ }
380
+ } catch (error) {
381
+ console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
382
+ }
383
+ }
384
+
385
+ return null;
386
+ }
387
+
388
+ function clearProjectListCache() {
389
+ projectListCache.data = null;
390
+ projectListCache.promise = null;
391
+ projectListCache.expiresAt = 0;
392
+ }
393
+
346
394
  // Clear cache when needed (called when project files change)
347
395
  function clearProjectDirectoryCache() {
348
396
  projectDirectoryCache.clear();
397
+ clearProjectListCache();
349
398
  }
350
399
 
351
400
  function clearProviderSessionLookupCaches() {
@@ -357,6 +406,27 @@ function clearProviderSessionLookupCaches() {
357
406
  }
358
407
  }
359
408
 
409
+ function appendBoundedMessage(buffer, message, maxSize = null) {
410
+ if (!message) {
411
+ return;
412
+ }
413
+
414
+ if (maxSize === null) {
415
+ buffer.push(message);
416
+ return;
417
+ }
418
+
419
+ if (maxSize <= 0) {
420
+ return;
421
+ }
422
+
423
+ if (buffer.length === maxSize) {
424
+ buffer.shift();
425
+ }
426
+
427
+ buffer.push(message);
428
+ }
429
+
360
430
  async function getCachedProviderSessionLookup(providerName, cacheKey, buildLookup) {
361
431
  const cacheEntry = providerSessionLookupCache[providerName];
362
432
 
@@ -393,190 +463,182 @@ async function getCachedProviderSessionLookup(providerName, cacheKey, buildLooku
393
463
 
394
464
  // Load project configuration file
395
465
  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 {};
466
+ const mergedConfig = {};
467
+
468
+ for (const configPath of getProjectConfigPaths()) {
469
+ try {
470
+ const configData = await fs.readFile(configPath, 'utf8');
471
+ Object.assign(mergedConfig, normalizeProjectConfig(JSON.parse(configData)));
472
+ } catch (error) {
473
+ // Return merged config from any readable location.
474
+ }
403
475
  }
476
+
477
+ return mergedConfig;
404
478
  }
405
479
 
406
480
  // Save project configuration file
407
481
  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') {
482
+ const normalizedConfig = normalizeProjectConfig(config);
483
+ const [primaryConfigPath, ...fallbackConfigPaths] = getProjectConfigPaths();
484
+ let lastPermissionError = null;
485
+
486
+ for (const configPath of [primaryConfigPath, ...fallbackConfigPaths]) {
487
+ try {
488
+ await writeProjectConfigFile(configPath, normalizedConfig);
489
+
490
+ if (configPath === primaryConfigPath) {
491
+ for (const fallbackConfigPath of fallbackConfigPaths) {
492
+ await removeProjectConfigFile(fallbackConfigPath);
493
+ }
494
+ }
495
+
496
+ clearProjectDirectoryCache();
497
+ return;
498
+ } catch (error) {
499
+ if (isProjectConfigPermissionError(error)) {
500
+ lastPermissionError = error;
501
+ continue;
502
+ }
503
+
416
504
  throw error;
417
505
  }
418
506
  }
419
-
420
- await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
507
+
508
+ if (lastPermissionError) {
509
+ const saveError = new Error('Unable to save the project list because both Claude config storage and the app data directory are not writable.');
510
+ saveError.code = lastPermissionError.code;
511
+ saveError.cause = lastPermissionError;
512
+ throw saveError;
513
+ }
421
514
  }
422
515
 
423
- // Generate better display name from path
424
- async function generateDisplayName(projectName, actualProjectDir = null) {
425
- // Use actual project directory if provided, otherwise decode from project name
426
- let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
427
-
428
- // Try to read package.json from the project path
516
+ function decodeProjectNameFallback(projectName) {
517
+ return String(projectName || '').replace(/-/g, '/');
518
+ }
519
+
520
+ async function readPackageDisplayName(projectPath) {
429
521
  try {
430
- const packageJsonPath = path.join(projectPath, 'package.json');
431
- const packageData = await fs.readFile(packageJsonPath, 'utf8');
432
- const packageJson = JSON.parse(packageData);
433
-
434
- // Return the name from package.json if it exists
435
- if (packageJson.name) {
436
- return packageJson.name;
522
+ const raw = await fs.readFile(path.join(projectPath, 'package.json'), 'utf8');
523
+ const parsed = JSON.parse(raw);
524
+ return trimText(parsed?.name) || null;
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+
530
+ function lastPathSegment(projectPath) {
531
+ const normalized = path.normalize(projectPath);
532
+ const segment = path.basename(normalized);
533
+ return segment || normalized;
534
+ }
535
+
536
+ async function generateDisplayName(projectName, actualProjectDir = null) {
537
+ const preferredPath = actualProjectDir || decodeProjectNameFallback(projectName);
538
+ return (await readPackageDisplayName(preferredPath)) || lastPathSegment(preferredPath);
539
+ }
540
+
541
+ async function summarizeProjectDirectoryHistory(projectDirectoryPath) {
542
+ const cwdCounts = new Map();
543
+ let latestSeen = { cwd: null, timestamp: 0 };
544
+ const files = (await fs.readdir(projectDirectoryPath))
545
+ .filter((fileName) => fileName.endsWith('.jsonl') && !fileName.startsWith('agent-'));
546
+
547
+ for (const fileName of files) {
548
+ const stream = fsSync.createReadStream(path.join(projectDirectoryPath, fileName));
549
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
550
+
551
+ for await (const line of lines) {
552
+ if (!line.trim()) {
553
+ continue;
554
+ }
555
+
556
+ try {
557
+ const entry = JSON.parse(line);
558
+ const cwd = trimText(entry?.cwd);
559
+ if (!cwd) {
560
+ continue;
561
+ }
562
+
563
+ cwdCounts.set(cwd, (cwdCounts.get(cwd) || 0) + 1);
564
+ const timestamp = Date.parse(entry?.timestamp || '') || 0;
565
+ if (timestamp >= latestSeen.timestamp) {
566
+ latestSeen = { cwd, timestamp };
567
+ }
568
+ } catch {
569
+ }
437
570
  }
438
- } catch (error) {
439
- // Fall back to path-based naming if package.json doesn't exist or can't be read
440
571
  }
441
-
442
- // If it starts with /, it's an absolute path
443
- if (projectPath.startsWith('/')) {
444
- const parts = projectPath.split('/').filter(Boolean);
445
- // Return only the last folder name
446
- return parts[parts.length - 1] || projectPath;
572
+
573
+ return { cwdCounts, latestSeen };
574
+ }
575
+
576
+ function chooseProjectDirectory(projectName, cwdCounts, latestSeen) {
577
+ if (cwdCounts.size === 0) {
578
+ return decodeProjectNameFallback(projectName);
447
579
  }
448
-
449
- return projectPath;
580
+
581
+ if (cwdCounts.size === 1) {
582
+ return Array.from(cwdCounts.keys())[0];
583
+ }
584
+
585
+ let topPath = null;
586
+ let topCount = -1;
587
+ for (const [cwd, count] of cwdCounts.entries()) {
588
+ if (count > topCount) {
589
+ topPath = cwd;
590
+ topCount = count;
591
+ }
592
+ }
593
+
594
+ if (latestSeen.cwd && (cwdCounts.get(latestSeen.cwd) || 0) >= Math.max(1, Math.floor(topCount / 4))) {
595
+ return latestSeen.cwd;
596
+ }
597
+
598
+ return topPath || decodeProjectNameFallback(projectName);
450
599
  }
451
600
 
452
- // Extract the actual project directory from JSONL sessions (with caching)
453
601
  async function extractProjectDirectory(projectName) {
454
- // Check cache first
455
602
  if (projectDirectoryCache.has(projectName)) {
456
603
  return projectDirectoryCache.get(projectName);
457
604
  }
458
605
 
459
- // Check project config for originalPath (manually added projects via UI or platform)
460
- // This handles projects with dashes in their directory names correctly
461
606
  const config = await loadProjectConfig();
462
- if (config[projectName]?.originalPath) {
463
- const originalPath = config[projectName].originalPath;
464
- projectDirectoryCache.set(projectName, originalPath);
465
- return originalPath;
607
+ const configuredPath = trimText(config?.[projectName]?.originalPath);
608
+ if (configuredPath) {
609
+ projectDirectoryCache.set(projectName, configuredPath);
610
+ return configuredPath;
466
611
  }
467
612
 
468
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
469
- const cwdCounts = new Map();
470
- let latestTimestamp = 0;
471
- let latestCwd = null;
472
- let extractedPath;
473
-
613
+ const projectDirectoryPath = path.join(os.homedir(), '.claude', 'projects', projectName);
614
+
474
615
  try {
475
- // Check if the project directory exists
476
- await fs.access(projectDir);
477
-
478
- const files = await fs.readdir(projectDir);
479
- const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
480
-
481
- if (jsonlFiles.length === 0) {
482
- // Fall back to decoded project name if no sessions
483
- extractedPath = projectName.replace(/-/g, '/');
484
- } else {
485
- // Process all JSONL files to collect cwd values
486
- for (const file of jsonlFiles) {
487
- const jsonlFile = path.join(projectDir, file);
488
- const fileStream = fsSync.createReadStream(jsonlFile);
489
- const rl = readline.createInterface({
490
- input: fileStream,
491
- crlfDelay: Infinity
492
- });
493
-
494
- for await (const line of rl) {
495
- if (line.trim()) {
496
- try {
497
- const entry = JSON.parse(line);
498
-
499
- if (entry.cwd) {
500
- // Count occurrences of each cwd
501
- cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
502
-
503
- // Track the most recent cwd
504
- const timestamp = new Date(entry.timestamp || 0).getTime();
505
- if (timestamp > latestTimestamp) {
506
- latestTimestamp = timestamp;
507
- latestCwd = entry.cwd;
508
- }
509
- }
510
- } catch (parseError) {
511
- // Skip malformed lines
512
- }
513
- }
514
- }
515
- }
516
-
517
- // Determine the best cwd to use
518
- if (cwdCounts.size === 0) {
519
- // No cwd found, fall back to decoded project name
520
- extractedPath = projectName.replace(/-/g, '/');
521
- } else if (cwdCounts.size === 1) {
522
- // Only one cwd, use it
523
- extractedPath = Array.from(cwdCounts.keys())[0];
524
- } else {
525
- // Multiple cwd values - prefer the most recent one if it has reasonable usage
526
- const mostRecentCount = cwdCounts.get(latestCwd) || 0;
527
- const maxCount = Math.max(...cwdCounts.values());
528
-
529
- // Use most recent if it has at least 25% of the max count
530
- if (mostRecentCount >= maxCount * 0.25) {
531
- extractedPath = latestCwd;
532
- } else {
533
- // Otherwise use the most frequently used cwd
534
- for (const [cwd, count] of cwdCounts.entries()) {
535
- if (count === maxCount) {
536
- extractedPath = cwd;
537
- break;
538
- }
539
- }
540
- }
541
-
542
- // Fallback (shouldn't reach here)
543
- if (!extractedPath) {
544
- extractedPath = latestCwd || projectName.replace(/-/g, '/');
545
- }
546
- }
547
- }
548
-
549
- // Cache the result
550
- projectDirectoryCache.set(projectName, extractedPath);
551
-
552
- return extractedPath;
553
-
616
+ const { cwdCounts, latestSeen } = await summarizeProjectDirectoryHistory(projectDirectoryPath);
617
+ const resolvedPath = chooseProjectDirectory(projectName, cwdCounts, latestSeen);
618
+ projectDirectoryCache.set(projectName, resolvedPath);
619
+ return resolvedPath;
554
620
  } catch (error) {
555
- // If the directory doesn't exist, just use the decoded project name
556
- if (error.code === 'ENOENT') {
557
- extractedPath = projectName.replace(/-/g, '/');
558
- } else {
621
+ if (error.code !== 'ENOENT') {
559
622
  console.error(`Error extracting project directory for ${projectName}:`, error);
560
- // Fall back to decoded project name for other errors
561
- extractedPath = projectName.replace(/-/g, '/');
562
623
  }
563
-
564
- // Cache the fallback result too
565
- projectDirectoryCache.set(projectName, extractedPath);
566
-
567
- return extractedPath;
624
+
625
+ const fallbackPath = decodeProjectNameFallback(projectName);
626
+ projectDirectoryCache.set(projectName, fallbackPath);
627
+ return fallbackPath;
568
628
  }
569
629
  }
570
630
 
571
- async function getProjects(progressCallback = null) {
631
+ function cloneProjectList(projects = []) {
632
+ return projects.map((project) => ({ ...project }));
633
+ }
634
+
635
+ async function collectProjectDefinitions(progressCallback = null) {
572
636
  const claudeDir = path.join(os.homedir(), '.claude', 'projects');
573
637
  const config = await loadProjectConfig();
574
- const projects = [];
575
638
  const existingProjects = new Set();
576
639
  const projectDefinitions = [];
577
640
  let totalProjects = 0;
578
641
  let processedProjects = 0;
579
- let directories = [];
580
642
 
581
643
  try {
582
644
  // Check if the .claude/projects directory exists
@@ -584,7 +646,7 @@ async function getProjects(progressCallback = null) {
584
646
 
585
647
  // First, get existing Claude projects from the file system
586
648
  const entries = await fs.readdir(claudeDir, { withFileTypes: true });
587
- directories = entries.filter(e => e.isDirectory());
649
+ const directories = entries.filter(e => e.isDirectory());
588
650
 
589
651
  // Build set of existing project names for later
590
652
  directories.forEach(e => existingProjects.add(e.name));
@@ -675,367 +737,507 @@ async function getProjects(progressCallback = null) {
675
737
  }
676
738
  }
677
739
 
740
+ return {
741
+ projectDefinitions,
742
+ totalProjects
743
+ };
744
+ }
745
+
746
+ async function getProjectsList(progressCallback = null) {
747
+ if (
748
+ projectListCache.data &&
749
+ projectListCache.expiresAt > Date.now()
750
+ ) {
751
+ const cachedProjects = cloneProjectList(projectListCache.data);
752
+ if (progressCallback) {
753
+ progressCallback({
754
+ phase: 'complete',
755
+ current: cachedProjects.length,
756
+ total: cachedProjects.length
757
+ });
758
+ }
759
+ return cachedProjects;
760
+ }
761
+
762
+ if (!progressCallback && projectListCache.promise) {
763
+ return cloneProjectList(await projectListCache.promise);
764
+ }
765
+
766
+ const loadProjectsList = async () => {
767
+ const { projectDefinitions, totalProjects } = await collectProjectDefinitions(progressCallback);
768
+ const lightweightProjects = projectDefinitions.map((definition) => ({
769
+ name: definition.name,
770
+ path: definition.path,
771
+ displayName: definition.displayName,
772
+ fullPath: definition.fullPath,
773
+ isCustomName: definition.isCustomName,
774
+ isManuallyAdded: !!definition.isManuallyAdded
775
+ }));
776
+
777
+ projectListCache.data = lightweightProjects;
778
+ projectListCache.expiresAt = Date.now() + PROJECT_LIST_CACHE_TTL_MS;
779
+
780
+ if (progressCallback) {
781
+ progressCallback({
782
+ phase: 'complete',
783
+ current: totalProjects,
784
+ total: totalProjects
785
+ });
786
+ }
787
+
788
+ return lightweightProjects;
789
+ };
790
+
791
+ if (progressCallback) {
792
+ return loadProjectsList();
793
+ }
794
+
795
+ projectListCache.promise = loadProjectsList().finally(() => {
796
+ projectListCache.promise = null;
797
+ });
798
+
799
+ return cloneProjectList(await projectListCache.promise);
800
+ }
801
+
802
+ async function buildProjectFromDefinition(definition, providerLookups = null) {
803
+ const normalizedProjectPath = normalizeComparableProjectPath(definition.fullPath);
804
+ const project = {
805
+ name: definition.name,
806
+ path: definition.path,
807
+ displayName: definition.displayName,
808
+ fullPath: definition.fullPath,
809
+ isCustomName: definition.isCustomName,
810
+ isManuallyAdded: !!definition.isManuallyAdded,
811
+ sessions: [],
812
+ codexSessions: [],
813
+ opencodeSessions: [],
814
+ geminiSessions: [],
815
+ sessionMeta: {
816
+ hasMore: false,
817
+ total: 0
818
+ }
819
+ };
820
+
821
+ try {
822
+ let claudeAcpSessions = [];
823
+ let codexAcpSessions = [];
824
+ let opencodeAcpSessions = [];
825
+ let geminiAcpSessions = [];
826
+
827
+ if (providerLookups) {
828
+ claudeAcpSessions = providerLookups.claudeAcpSessionsByProjectPath?.get(normalizedProjectPath) || [];
829
+ codexAcpSessions = providerLookups.codexAcpSessionsByProjectPath?.get(normalizedProjectPath) || [];
830
+ opencodeAcpSessions = providerLookups.opencodeAcpSessionsByProjectPath?.get(normalizedProjectPath) || [];
831
+ geminiAcpSessions = providerLookups.geminiAcpSessionsByProjectPath?.get(normalizedProjectPath) || [];
832
+
833
+ project.codexSessions = mergeSessionLists(
834
+ providerLookups.codexSessionsByProjectPath?.get(normalizedProjectPath) || [],
835
+ codexAcpSessions,
836
+ { fallbackProvider: 'codex' }
837
+ );
838
+ project.opencodeSessions = mergeSessionLists(
839
+ providerLookups.opencodeSessionsByProjectPath?.get(normalizedProjectPath) || [],
840
+ opencodeAcpSessions,
841
+ { fallbackProvider: 'opencode' }
842
+ );
843
+ project.geminiSessions = mergeSessionLists(
844
+ providerLookups.geminiSessionsByProjectPath?.get(normalizedProjectPath) || [],
845
+ geminiAcpSessions,
846
+ { fallbackProvider: 'gemini' }
847
+ );
848
+ } else {
849
+ const [codexSessions, opencodeSessions, geminiSessions, acpSessions] = await Promise.all([
850
+ getCodexSessions(definition.fullPath, { limit: 5 }),
851
+ getOpencodeSessions(definition.fullPath, { limit: 5 }),
852
+ getGeminiSessions(definition.fullPath, { limit: 5 }),
853
+ listAcpSessions({ projectPath: definition.fullPath })
854
+ ]);
855
+ claudeAcpSessions = acpSessions.filter((session) => session.provider === 'claude');
856
+ codexAcpSessions = acpSessions.filter((session) => session.provider === 'codex');
857
+ opencodeAcpSessions = acpSessions.filter((session) => session.provider === 'opencode');
858
+ geminiAcpSessions = acpSessions.filter((session) => session.provider === 'gemini');
859
+ project.codexSessions = mergeSessionLists(codexSessions, codexAcpSessions, { fallbackProvider: 'codex' });
860
+ project.opencodeSessions = mergeSessionLists(opencodeSessions, opencodeAcpSessions, { fallbackProvider: 'opencode' });
861
+ project.geminiSessions = mergeSessionLists(geminiSessions, geminiAcpSessions, { fallbackProvider: 'gemini' });
862
+ }
863
+
864
+ if (!definition.isManuallyAdded) {
865
+ const sessionResult = await getSessions(definition.name, 5, 0);
866
+ project.sessions = mergeSessionLists(
867
+ (sessionResult.sessions || []).map((session) => ({
868
+ ...session,
869
+ provider: 'claude',
870
+ source: session?.source || 'legacy'
871
+ })),
872
+ claudeAcpSessions,
873
+ { fallbackProvider: 'claude' }
874
+ );
875
+ project.sessionMeta = {
876
+ hasMore: sessionResult.hasMore,
877
+ total: sessionResult.total
878
+ };
879
+ } else {
880
+ project.sessions = mergeSessionLists([], claudeAcpSessions, { fallbackProvider: 'claude' });
881
+ }
882
+ } catch (error) {
883
+ console.warn(`Could not load session details for project ${definition.name}:`, error.message);
884
+ }
885
+
886
+ return project;
887
+ }
888
+
889
+ async function getProjectDetails(projectName) {
890
+ const projectList = await getProjectsList();
891
+ const definition = projectList.find((project) => project.name === projectName);
892
+
893
+ if (!definition) {
894
+ throw new Error(`Project not found: ${projectName}`);
895
+ }
896
+
897
+ return buildProjectFromDefinition(definition);
898
+ }
899
+
900
+ async function getProjects(progressCallback = null) {
901
+ const projects = [];
902
+ const { projectDefinitions, totalProjects } = await collectProjectDefinitions(progressCallback);
903
+
678
904
  const uniqueProjectPaths = Array.from(new Set(
679
905
  projectDefinitions
680
906
  .map((definition) => normalizeComparableProjectPath(definition.fullPath))
681
907
  .filter(Boolean)
682
908
  ));
683
909
 
684
- const [codexSessionsByProjectPath, geminiSessionsByProjectPath, opencodeSessionsByProjectPath] = await Promise.all([
910
+ const [codexSessionsByProjectPath, geminiSessionsByProjectPath, opencodeSessionsByProjectPath, claudeAcpSessionsByProjectPath, codexAcpSessionsByProjectPath, geminiAcpSessionsByProjectPath, opencodeAcpSessionsByProjectPath] = await Promise.all([
685
911
  buildCodexSessionsLookup(uniqueProjectPaths, { limit: 5 }),
686
912
  buildGeminiSessionsLookup(uniqueProjectPaths, { limit: 5 }),
687
- buildOpencodeSessionsLookup(uniqueProjectPaths, { limit: 5 })
913
+ buildOpencodeSessionsLookup(uniqueProjectPaths, { limit: 5 }),
914
+ buildAcpProviderSessionsLookup('claude', uniqueProjectPaths, { limit: 5 }),
915
+ buildAcpProviderSessionsLookup('codex', uniqueProjectPaths, { limit: 5 }),
916
+ buildAcpProviderSessionsLookup('gemini', uniqueProjectPaths, { limit: 5 }),
917
+ buildAcpProviderSessionsLookup('opencode', uniqueProjectPaths, { limit: 5 })
688
918
  ]);
689
919
 
690
920
  for (const definition of projectDefinitions) {
691
- const normalizedProjectPath = normalizeComparableProjectPath(definition.fullPath);
692
- const project = {
693
- name: definition.name,
694
- path: definition.path,
695
- displayName: definition.displayName,
696
- fullPath: definition.fullPath,
697
- 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
- };
921
+ projects.push(await buildProjectFromDefinition(definition, {
922
+ claudeAcpSessionsByProjectPath,
923
+ codexSessionsByProjectPath,
924
+ codexAcpSessionsByProjectPath,
925
+ geminiSessionsByProjectPath,
926
+ geminiAcpSessionsByProjectPath,
927
+ opencodeSessionsByProjectPath,
928
+ opencodeAcpSessionsByProjectPath
929
+ }));
930
+ }
704
931
 
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
- }
716
- }
932
+ // Emit completion after all projects (including manual) are processed
933
+ if (progressCallback) {
934
+ progressCallback({
935
+ phase: 'complete',
936
+ current: totalProjects,
937
+ total: totalProjects
938
+ });
939
+ }
940
+
941
+ return projects;
942
+ }
943
+
944
+ function createClaudeSessionRecord(sessionId) {
945
+ return {
946
+ id: sessionId,
947
+ summary: 'New Session',
948
+ messageCount: 0,
949
+ lastActivity: new Date(0),
950
+ cwd: '',
951
+ lastUserMessage: null,
952
+ lastAssistantMessage: null,
953
+ rootMessageId: null
954
+ };
955
+ }
956
+
957
+ function truncateSummary(text, maxLength = 50) {
958
+ const normalized = trimText(text);
959
+ if (!normalized) {
960
+ return 'New Session';
961
+ }
962
+
963
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
964
+ }
965
+
966
+ function extractClaudeTextContent(content) {
967
+ if (typeof content === 'string') {
968
+ return content;
969
+ }
970
+
971
+ if (Array.isArray(content)) {
972
+ return content
973
+ .filter((part) => part?.type === 'text' && typeof part?.text === 'string')
974
+ .map((part) => part.text)
975
+ .join('\n')
976
+ .trim();
977
+ }
978
+
979
+ return '';
980
+ }
981
+
982
+ function isIgnoredClaudePrompt(text) {
983
+ return text.startsWith('<command-name>')
984
+ || text.startsWith('<command-message>')
985
+ || text.startsWith('<command-args>')
986
+ || text.startsWith('<local-command-stdout>')
987
+ || text.startsWith('<system-reminder>')
988
+ || text.startsWith('Caveat:')
989
+ || text.startsWith('This session is being continued from a previous')
990
+ || text.startsWith('Invalid API key')
991
+ || text.includes('{"subtasks":')
992
+ || text.includes('CRITICAL: You MUST respond with ONLY a JSON')
993
+ || text === 'Warmup';
994
+ }
717
995
 
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
737
- };
738
- }
996
+ function mergeClaudeSession(target, incoming) {
997
+ if (incoming.messageCount > target.messageCount) {
998
+ target.messageCount = incoming.messageCount;
999
+ }
739
1000
 
740
- projects.push(project);
1001
+ if (incoming.summary !== 'New Session' && (target.summary === 'New Session' || incoming.lastActivity > target.lastActivity)) {
1002
+ target.summary = incoming.summary;
741
1003
  }
742
1004
 
743
- // Emit completion after all projects (including manual) are processed
744
- if (progressCallback) {
745
- progressCallback({
746
- phase: 'complete',
747
- current: totalProjects,
748
- total: totalProjects
749
- });
1005
+ if (incoming.lastActivity > target.lastActivity) {
1006
+ target.lastActivity = incoming.lastActivity;
1007
+ target.lastUserMessage = incoming.lastUserMessage || target.lastUserMessage;
1008
+ target.lastAssistantMessage = incoming.lastAssistantMessage || target.lastAssistantMessage;
750
1009
  }
751
1010
 
752
- return projects;
1011
+ if (!target.cwd && incoming.cwd) {
1012
+ target.cwd = incoming.cwd;
1013
+ }
1014
+
1015
+ if (!target.rootMessageId && incoming.rootMessageId) {
1016
+ target.rootMessageId = incoming.rootMessageId;
1017
+ }
753
1018
  }
754
1019
 
755
1020
  async function getSessions(projectName, limit = 5, offset = 0) {
756
1021
  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
757
1022
 
758
1023
  try {
759
- const files = await fs.readdir(projectDir);
760
- // agent-*.jsonl files contain session start data at this point. This needs to be revisited
761
- // periodically to make sure only accurate data is there and no new functionality is added there
762
- const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
763
-
764
- if (jsonlFiles.length === 0) {
765
- return { sessions: [], hasMore: false, total: 0 };
1024
+ const files = (await fs.readdir(projectDir))
1025
+ .filter((fileName) => fileName.endsWith('.jsonl') && !fileName.startsWith('agent-'));
1026
+
1027
+ if (files.length === 0) {
1028
+ return { sessions: [], hasMore: false, total: 0, offset, limit };
766
1029
  }
767
-
768
- // Sort files by modification time (newest first)
769
- const filesWithStats = await Promise.all(
770
- jsonlFiles.map(async (file) => {
771
- const filePath = path.join(projectDir, file);
772
- const stats = await fs.stat(filePath);
773
- return { file, mtime: stats.mtime };
774
- })
775
- );
776
- filesWithStats.sort((a, b) => b.mtime - a.mtime);
777
-
778
- const allSessions = new Map();
779
- const allEntries = [];
780
- const uuidToSessionMap = new Map();
781
-
782
- // Collect all sessions and entries from all files
783
- for (const { file } of filesWithStats) {
784
- const jsonlFile = path.join(projectDir, file);
785
- const result = await parseJsonlSessions(jsonlFile);
786
-
787
- result.sessions.forEach(session => {
788
- if (!allSessions.has(session.id)) {
789
- allSessions.set(session.id, session);
1030
+
1031
+ const filesByRecency = await Promise.all(files.map(async (fileName) => {
1032
+ const filePath = path.join(projectDir, fileName);
1033
+ const stat = await fs.stat(filePath);
1034
+ return { filePath, modifiedAt: stat.mtime.getTime() };
1035
+ }));
1036
+ filesByRecency.sort((left, right) => right.modifiedAt - left.modifiedAt);
1037
+
1038
+ const sessionsById = new Map();
1039
+
1040
+ for (const { filePath } of filesByRecency) {
1041
+ const parsed = await parseJsonlSessions(filePath);
1042
+ for (const session of parsed.sessions) {
1043
+ const existing = sessionsById.get(session.id);
1044
+ if (!existing) {
1045
+ sessionsById.set(session.id, session);
1046
+ continue;
790
1047
  }
791
- });
792
-
793
- allEntries.push(...result.entries);
794
-
795
- // Early exit optimization for large projects
796
- if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
797
- break;
1048
+ mergeClaudeSession(existing, session);
798
1049
  }
799
1050
  }
800
-
801
- // Build UUID-to-session mapping for timeline detection
802
- allEntries.forEach(entry => {
803
- if (entry.uuid && entry.sessionId) {
804
- uuidToSessionMap.set(entry.uuid, entry.sessionId);
805
- }
806
- });
807
-
808
- // Group sessions by first user message ID
809
- const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
810
- const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
811
-
812
- // Find the first user message for each session
813
- allEntries.forEach(entry => {
814
- if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
815
- // This is a first user message in a session (parentUuid is null)
816
- const firstUserMsgId = entry.uuid;
817
-
818
- if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
819
- sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
820
-
821
- const session = allSessions.get(entry.sessionId);
822
- if (session) {
823
- if (!sessionGroups.has(firstUserMsgId)) {
824
- sessionGroups.set(firstUserMsgId, {
825
- latestSession: session,
826
- allSessions: [session]
827
- });
828
- } else {
829
- const group = sessionGroups.get(firstUserMsgId);
830
- group.allSessions.push(session);
831
1051
 
832
- // Update latest session if this one is more recent
833
- if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
834
- group.latestSession = session;
835
- }
836
- }
837
- }
838
- }
839
- }
840
- });
1052
+ const groupedSessions = new Map();
841
1053
 
842
- // Collect all sessions that don't belong to any group (standalone sessions)
843
- const groupedSessionIds = new Set();
844
- sessionGroups.forEach(group => {
845
- group.allSessions.forEach(session => groupedSessionIds.add(session.id));
846
- });
1054
+ for (const session of sessionsById.values()) {
1055
+ const groupKey = session.rootMessageId || `session:${session.id}`;
1056
+ const currentGroup = groupedSessions.get(groupKey) || [];
1057
+ currentGroup.push(session);
1058
+ groupedSessions.set(groupKey, currentGroup);
1059
+ }
1060
+
1061
+ const visibleSessions = Array.from(groupedSessions.values()).map((group) => {
1062
+ const ordered = [...group].sort((left, right) => right.lastActivity - left.lastActivity);
1063
+ const primary = { ...ordered[0] };
847
1064
 
848
- const standaloneSessionsArray = Array.from(allSessions.values())
849
- .filter(session => !groupedSessionIds.has(session.id));
850
-
851
- // Combine grouped sessions (only show latest from each group) + standalone sessions
852
- const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
853
- const session = { ...group.latestSession };
854
- // Add metadata about grouping
855
- if (group.allSessions.length > 1) {
856
- session.isGrouped = true;
857
- session.groupSize = group.allSessions.length;
858
- session.groupSessions = group.allSessions.map(s => s.id);
1065
+ if (ordered.length > 1) {
1066
+ primary.isGrouped = true;
1067
+ primary.groupSize = ordered.length;
1068
+ primary.groupSessions = ordered.map((session) => session.id);
859
1069
  }
860
- return session;
861
- });
862
- const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
863
- .filter(session => !session.summary.startsWith('{ "'))
864
- .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
1070
+
1071
+ return primary;
1072
+ }).sort((left, right) => right.lastActivity - left.lastActivity);
865
1073
 
866
1074
  const total = visibleSessions.length;
867
- const paginatedSessions = visibleSessions.slice(offset, offset + limit);
868
- const hasMore = offset + limit < total;
869
-
1075
+ const sessions = visibleSessions.slice(offset, offset + limit);
1076
+
870
1077
  return {
871
- sessions: paginatedSessions,
872
- hasMore,
1078
+ sessions,
1079
+ hasMore: offset + limit < total,
873
1080
  total,
874
1081
  offset,
875
1082
  limit
876
1083
  };
877
1084
  } catch (error) {
878
1085
  console.error(`Error reading sessions for project ${projectName}:`, error);
879
- return { sessions: [], hasMore: false, total: 0 };
1086
+ return { sessions: [], hasMore: false, total: 0, offset, limit };
1087
+ }
1088
+ }
1089
+
1090
+ async function getClaudeSessionMetadata(sessionId) {
1091
+ try {
1092
+ const normalizedSessionId = String(sessionId || '').trim();
1093
+ if (!normalizedSessionId) {
1094
+ return null;
1095
+ }
1096
+
1097
+ const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
1098
+ const jsonlFiles = await findFilesRecursively(
1099
+ claudeProjectsDir,
1100
+ (entryName) => entryName.endsWith('.jsonl') && !entryName.startsWith('agent-')
1101
+ );
1102
+
1103
+ for (const filePath of jsonlFiles) {
1104
+ try {
1105
+ const result = await parseJsonlSessions(filePath);
1106
+ const matchedSession = (result?.sessions || []).find((session) => session?.id === normalizedSessionId);
1107
+ if (matchedSession) {
1108
+ return {
1109
+ ...matchedSession,
1110
+ filePath,
1111
+ projectName: path.basename(path.dirname(filePath)),
1112
+ provider: 'claude'
1113
+ };
1114
+ }
1115
+ } catch {}
1116
+ }
1117
+
1118
+ return null;
1119
+ } catch (error) {
1120
+ if (error?.code !== 'ENOENT') {
1121
+ console.error(`Error reading Claude session metadata for ${sessionId}:`, error);
1122
+ }
1123
+ return null;
880
1124
  }
881
1125
  }
882
1126
 
883
1127
  async function parseJsonlSessions(filePath) {
884
1128
  const sessions = new Map();
885
1129
  const entries = [];
886
- const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId
1130
+ const pendingSummaries = new Map();
887
1131
 
888
1132
  try {
889
- const fileStream = fsSync.createReadStream(filePath);
890
- const rl = readline.createInterface({
891
- input: fileStream,
892
- crlfDelay: Infinity
893
- });
894
-
895
- for await (const line of rl) {
896
- if (line.trim()) {
897
- try {
898
- const entry = JSON.parse(line);
899
- entries.push(entry);
1133
+ const stream = fsSync.createReadStream(filePath);
1134
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
900
1135
 
901
- // Handle summary entries that don't have sessionId yet
902
- if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) {
903
- pendingSummaries.set(entry.leafUuid, entry.summary);
904
- }
905
-
906
- if (entry.sessionId) {
907
- if (!sessions.has(entry.sessionId)) {
908
- sessions.set(entry.sessionId, {
909
- id: entry.sessionId,
910
- summary: 'New Session',
911
- messageCount: 0,
912
- lastActivity: new Date(),
913
- cwd: entry.cwd || '',
914
- lastUserMessage: null,
915
- lastAssistantMessage: null
916
- });
917
- }
918
-
919
- const session = sessions.get(entry.sessionId);
1136
+ for await (const line of lines) {
1137
+ if (!line.trim()) {
1138
+ continue;
1139
+ }
920
1140
 
921
- // Apply pending summary if this entry has a parentUuid that matches a pending summary
922
- if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) {
923
- session.summary = pendingSummaries.get(entry.parentUuid);
924
- }
1141
+ try {
1142
+ const entry = JSON.parse(line);
1143
+ entries.push(entry);
925
1144
 
926
- // Update summary from summary entries with sessionId
927
- if (entry.type === 'summary' && entry.summary) {
928
- session.summary = entry.summary;
929
- }
1145
+ if (entry.type === 'summary' && trimText(entry.summary) && !entry.sessionId && trimText(entry.leafUuid)) {
1146
+ pendingSummaries.set(entry.leafUuid, trimText(entry.summary));
1147
+ }
930
1148
 
931
- // Track last user and assistant messages (skip system messages)
932
- if (entry.message?.role === 'user' && entry.message?.content) {
933
- const content = entry.message.content;
1149
+ const sessionId = trimText(entry.sessionId);
1150
+ if (!sessionId) {
1151
+ continue;
1152
+ }
934
1153
 
935
- // Extract text from array format if needed
936
- let textContent = content;
937
- if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {
938
- textContent = content[0].text;
939
- }
1154
+ const session = sessions.get(sessionId) || createClaudeSessionRecord(sessionId);
1155
+ sessions.set(sessionId, session);
940
1156
 
941
- const isSystemMessage = typeof textContent === 'string' && (
942
- textContent.startsWith('<command-name>') ||
943
- textContent.startsWith('<command-message>') ||
944
- textContent.startsWith('<command-args>') ||
945
- textContent.startsWith('<local-command-stdout>') ||
946
- textContent.startsWith('<system-reminder>') ||
947
- textContent.startsWith('Caveat:') ||
948
- textContent.startsWith('This session is being continued from a previous') ||
949
- textContent.startsWith('Invalid API key') ||
950
- textContent.includes('{"subtasks":') || // Filter Task Master prompts
951
- textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts
952
- textContent === 'Warmup' // Explicitly filter out "Warmup"
953
- );
954
-
955
- if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) {
956
- session.lastUserMessage = textContent;
957
- }
958
- } else if (entry.message?.role === 'assistant' && entry.message?.content) {
959
- // Skip API error messages using the isApiErrorMessage flag
960
- if (entry.isApiErrorMessage === true) {
961
- // Skip this message entirely
962
- } else {
963
- // Track last assistant text message
964
- let assistantText = null;
965
-
966
- if (Array.isArray(entry.message.content)) {
967
- for (const part of entry.message.content) {
968
- if (part.type === 'text' && part.text) {
969
- assistantText = part.text;
970
- }
971
- }
972
- } else if (typeof entry.message.content === 'string') {
973
- assistantText = entry.message.content;
974
- }
1157
+ if (!session.cwd && trimText(entry.cwd)) {
1158
+ session.cwd = trimText(entry.cwd);
1159
+ }
975
1160
 
976
- // Additional filter for assistant messages with system content
977
- const isSystemAssistantMessage = typeof assistantText === 'string' && (
978
- assistantText.startsWith('Invalid API key') ||
979
- assistantText.includes('{"subtasks":') ||
980
- assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON')
981
- );
1161
+ if (trimText(entry.timestamp)) {
1162
+ session.lastActivity = new Date(entry.timestamp);
1163
+ }
982
1164
 
983
- if (assistantText && !isSystemAssistantMessage) {
984
- session.lastAssistantMessage = assistantText;
985
- }
986
- }
987
- }
1165
+ if (entry.type === 'summary' && trimText(entry.summary)) {
1166
+ session.summary = trimText(entry.summary);
1167
+ } else if (session.summary === 'New Session' && trimText(entry.parentUuid) && pendingSummaries.has(entry.parentUuid)) {
1168
+ session.summary = pendingSummaries.get(entry.parentUuid);
1169
+ }
988
1170
 
989
- session.messageCount++;
1171
+ if (!session.rootMessageId && entry.type === 'user' && entry.parentUuid === null && trimText(entry.uuid)) {
1172
+ session.rootMessageId = trimText(entry.uuid);
1173
+ }
990
1174
 
991
- if (entry.timestamp) {
992
- session.lastActivity = new Date(entry.timestamp);
993
- }
1175
+ if (entry.message?.role === 'user') {
1176
+ const text = extractClaudeTextContent(entry.message.content);
1177
+ if (text && !isIgnoredClaudePrompt(text)) {
1178
+ session.lastUserMessage = text;
1179
+ }
1180
+ } else if (entry.message?.role === 'assistant' && entry.isApiErrorMessage !== true) {
1181
+ const text = extractClaudeTextContent(entry.message.content);
1182
+ if (text && !isIgnoredClaudePrompt(text)) {
1183
+ session.lastAssistantMessage = text;
994
1184
  }
995
- } catch (parseError) {
996
- // Skip malformed lines silently
997
1185
  }
998
- }
999
- }
1000
1186
 
1001
- // After processing all entries, set final summary based on last message if no summary exists
1002
- for (const session of sessions.values()) {
1003
- if (session.summary === 'New Session') {
1004
- // Prefer last user message, fall back to last assistant message
1005
- const lastMessage = session.lastUserMessage || session.lastAssistantMessage;
1006
- if (lastMessage) {
1007
- session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage;
1008
- }
1187
+ session.messageCount += 1;
1188
+ } catch {
1009
1189
  }
1010
1190
  }
1011
1191
 
1012
- // Filter out sessions that contain JSON responses (Task Master errors)
1013
- const allSessions = Array.from(sessions.values());
1014
- const filteredSessions = allSessions.filter(session => {
1015
- const shouldFilter = session.summary.startsWith('{ "');
1016
- if (shouldFilter) {
1017
- }
1018
- // Log a sample of summaries to debug
1019
- if (Math.random() < 0.01) { // Log 1% of sessions
1020
- }
1021
- return !shouldFilter;
1022
- });
1192
+ const normalizedSessions = Array.from(sessions.values())
1193
+ .map((session) => {
1194
+ if (session.summary === 'New Session') {
1195
+ session.summary = truncateSummary(session.lastUserMessage || session.lastAssistantMessage || 'New Session');
1196
+ } else {
1197
+ session.summary = truncateSummary(session.summary, 80);
1198
+ }
1023
1199
 
1200
+ return session;
1201
+ })
1202
+ .filter((session) => !session.summary.startsWith('{ "'));
1024
1203
 
1025
1204
  return {
1026
- sessions: filteredSessions,
1027
- entries: entries
1205
+ sessions: normalizedSessions,
1206
+ entries
1028
1207
  };
1029
-
1030
1208
  } catch (error) {
1031
1209
  console.error('Error reading JSONL file:', error);
1032
1210
  return { sessions: [], entries: [] };
1033
1211
  }
1034
1212
  }
1035
1213
 
1214
+ async function resolveClaudeProjectDir(projectName, sessionId = null) {
1215
+ const defaultProjectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
1216
+
1217
+ try {
1218
+ await fs.access(defaultProjectDir);
1219
+ return defaultProjectDir;
1220
+ } catch (error) {
1221
+ if (error?.code !== 'ENOENT') {
1222
+ throw error;
1223
+ }
1224
+ }
1225
+
1226
+ if (!sessionId) {
1227
+ return defaultProjectDir;
1228
+ }
1229
+
1230
+ const sessionMetadata = await getClaudeSessionMetadata(sessionId);
1231
+ if (sessionMetadata?.filePath) {
1232
+ return path.dirname(sessionMetadata.filePath);
1233
+ }
1234
+
1235
+ return defaultProjectDir;
1236
+ }
1237
+
1036
1238
  // Get messages for a specific session with pagination support
1037
1239
  async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
1038
- const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
1240
+ const projectDir = await resolveClaudeProjectDir(projectName, sessionId);
1039
1241
 
1040
1242
  try {
1041
1243
  const files = await fs.readdir(projectDir);
@@ -1336,7 +1538,7 @@ async function getCodexSessions(projectPath, options = {}) {
1336
1538
  const sessions = [];
1337
1539
  const normalizedProjectPath = normalizeComparableProjectPath(projectPath);
1338
1540
  const jsonlFiles = await findFilesRecursively(
1339
- path.join(os.homedir(), '.codex', 'sessions'),
1541
+ getCodexSessionsDir(),
1340
1542
  (entryName) => entryName.endsWith('.jsonl')
1341
1543
  );
1342
1544
 
@@ -1346,6 +1548,7 @@ async function getCodexSessions(projectPath, options = {}) {
1346
1548
  const sessionData = await parseCodexSessionFile(filePath);
1347
1549
 
1348
1550
  if (sessionData && normalizeComparableProjectPath(sessionData.cwd) === normalizedProjectPath) {
1551
+ cacheCodexSessionFilePath(sessionData.id, filePath);
1349
1552
  sessions.push({
1350
1553
  id: sessionData.id,
1351
1554
  summary: sessionData.summary || 'Codex Session',
@@ -1374,15 +1577,17 @@ async function getCodexSessions(projectPath, options = {}) {
1374
1577
  }
1375
1578
  }
1376
1579
 
1377
- const OPENCODE_SESSION_DIR_CANDIDATES = [
1378
- path.join(os.homedir(), '.opencode', 'sessions'),
1379
- path.join(os.homedir(), '.config', 'opencode', 'sessions')
1380
- ];
1580
+ function getOpencodeSessionDirCandidates() {
1581
+ return [
1582
+ path.join(os.homedir(), '.opencode', 'sessions'),
1583
+ path.join(os.homedir(), '.config', 'opencode', 'sessions')
1584
+ ];
1585
+ }
1381
1586
 
1382
1587
  async function findOpencodeSessionFiles() {
1383
1588
  const files = [];
1384
1589
 
1385
- for (const baseDir of OPENCODE_SESSION_DIR_CANDIDATES) {
1590
+ for (const baseDir of getOpencodeSessionDirCandidates()) {
1386
1591
  const discovered = await findFilesRecursively(
1387
1592
  baseDir,
1388
1593
  (entryName, _fullPath, entry) => entry.isFile() && entryName.endsWith('.jsonl')
@@ -1647,6 +1852,34 @@ async function findOpencodeSessionFileById(sessionId) {
1647
1852
  return null;
1648
1853
  }
1649
1854
 
1855
+ async function getOpencodeSessionMetadata(sessionId) {
1856
+ try {
1857
+ const normalizedSessionId = String(sessionId || '').trim();
1858
+ if (!normalizedSessionId) {
1859
+ return null;
1860
+ }
1861
+
1862
+ const sessionFilePath = await findOpencodeSessionFileById(normalizedSessionId);
1863
+ if (!sessionFilePath) {
1864
+ return null;
1865
+ }
1866
+
1867
+ const sessionData = await parseOpencodeSessionFile(sessionFilePath);
1868
+ if (!sessionData) {
1869
+ return null;
1870
+ }
1871
+
1872
+ return {
1873
+ ...sessionData,
1874
+ filePath: sessionFilePath,
1875
+ provider: 'opencode'
1876
+ };
1877
+ } catch (error) {
1878
+ console.error(`Error reading OpenCode session metadata for ${sessionId}:`, error);
1879
+ return null;
1880
+ }
1881
+ }
1882
+
1650
1883
  async function getOpencodeSessionMessages(sessionId, limit = null, offset = 0) {
1651
1884
  try {
1652
1885
  const sessionFilePath = await findOpencodeSessionFileById(sessionId);
@@ -1857,6 +2090,30 @@ async function parseGeminiSessionFile(filePath) {
1857
2090
  };
1858
2091
  }
1859
2092
 
2093
+ async function getGeminiSessionMetadata(sessionId) {
2094
+ try {
2095
+ const normalizedSessionId = String(sessionId || '').trim();
2096
+ if (!normalizedSessionId) {
2097
+ return null;
2098
+ }
2099
+
2100
+ const sessionFiles = await findGeminiSessionFiles();
2101
+ for (const filePath of sessionFiles) {
2102
+ try {
2103
+ const sessionData = await parseGeminiSessionFile(filePath);
2104
+ if (sessionData?.id === normalizedSessionId) {
2105
+ return sessionData;
2106
+ }
2107
+ } catch {}
2108
+ }
2109
+
2110
+ return null;
2111
+ } catch (error) {
2112
+ console.error(`Error reading Gemini session metadata for ${sessionId}:`, error);
2113
+ return null;
2114
+ }
2115
+ }
2116
+
1860
2117
  async function buildCodexSessionsLookup(projectPaths, options = {}) {
1861
2118
  const { limit = 5 } = options;
1862
2119
  const normalizedProjectPaths = Array.from(new Set(
@@ -1870,7 +2127,7 @@ async function buildCodexSessionsLookup(projectPaths, options = {}) {
1870
2127
  const cacheKey = JSON.stringify({ projectPaths: normalizedProjectPaths, limit });
1871
2128
  return getCachedProviderSessionLookup('codex', cacheKey, async () => {
1872
2129
  const jsonlFiles = await findFilesRecursively(
1873
- path.join(os.homedir(), '.codex', 'sessions'),
2130
+ getCodexSessionsDir(),
1874
2131
  (entryName) => entryName.endsWith('.jsonl')
1875
2132
  );
1876
2133
 
@@ -1879,6 +2136,7 @@ async function buildCodexSessionsLookup(projectPaths, options = {}) {
1879
2136
  try {
1880
2137
  const sessionData = await parseCodexSessionFile(filePath);
1881
2138
  if (sessionData?.id) {
2139
+ cacheCodexSessionFilePath(sessionData.id, filePath);
1882
2140
  sessions.push({
1883
2141
  id: sessionData.id,
1884
2142
  summary: sessionData.summary || 'Codex Session',
@@ -1899,6 +2157,24 @@ async function buildCodexSessionsLookup(projectPaths, options = {}) {
1899
2157
  });
1900
2158
  }
1901
2159
 
2160
+ async function buildAcpProviderSessionsLookup(provider, projectPaths, options = {}) {
2161
+ const { limit = 5 } = options;
2162
+ const normalizedProjectPaths = Array.from(new Set(
2163
+ (projectPaths || []).map(normalizeComparableProjectPath).filter(Boolean)
2164
+ ));
2165
+
2166
+ if (normalizedProjectPaths.length === 0) {
2167
+ return new Map();
2168
+ }
2169
+
2170
+ const sessions = await listAcpSessions({ provider });
2171
+ return groupSessionsByNormalizedProjectPath(
2172
+ normalizedProjectPaths,
2173
+ sessions.map((session) => ({ ...session, provider, source: 'acp' })),
2174
+ limit
2175
+ );
2176
+ }
2177
+
1902
2178
  async function buildOpencodeSessionsLookup(projectPaths, options = {}) {
1903
2179
  const { limit = 5 } = options;
1904
2180
  const normalizedProjectPaths = Array.from(new Set(
@@ -1980,19 +2256,8 @@ async function buildGeminiSessionsLookup(projectPaths, options = {}) {
1980
2256
 
1981
2257
  async function getGeminiSessionMessages(sessionId, limit = null, offset = 0) {
1982
2258
  try {
1983
- const sessionFiles = await findGeminiSessionFiles();
1984
- let sessionFilePath = null;
1985
-
1986
- for (const filePath of sessionFiles) {
1987
- try {
1988
- const raw = await fs.readFile(filePath, 'utf8');
1989
- const parsed = JSON.parse(raw);
1990
- if (parsed?.sessionId === sessionId) {
1991
- sessionFilePath = filePath;
1992
- break;
1993
- }
1994
- } catch {}
1995
- }
2259
+ const sessionMetadata = await getGeminiSessionMetadata(sessionId);
2260
+ const sessionFilePath = sessionMetadata?.filePath || null;
1996
2261
 
1997
2262
  if (!sessionFilePath) {
1998
2263
  return { messages: [], total: 0, hasMore: false };
@@ -2132,6 +2397,15 @@ async function parseCodexSessionFile(filePath) {
2132
2397
  }
2133
2398
  }
2134
2399
 
2400
+ if (shouldIncludeCodexTranscriptMessage(entry) && String(entry.payload.role).trim().toLowerCase() === 'user') {
2401
+ const visibleUserMessage = extractVisibleUserMessage(extractCodexMessageText(entry.payload.content));
2402
+ if (!visibleUserMessage) {
2403
+ continue;
2404
+ }
2405
+ messageCount++;
2406
+ lastUserMessage = visibleUserMessage;
2407
+ }
2408
+
2135
2409
  if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
2136
2410
  messageCount++;
2137
2411
  }
@@ -2143,6 +2417,7 @@ async function parseCodexSessionFile(filePath) {
2143
2417
  }
2144
2418
 
2145
2419
  if (sessionMeta) {
2420
+ cacheCodexSessionFilePath(sessionMeta.id, filePath);
2146
2421
  return {
2147
2422
  ...sessionMeta,
2148
2423
  model: lastResolvedModel || sessionMeta.model || null,
@@ -2165,35 +2440,18 @@ async function parseCodexSessionFile(filePath) {
2165
2440
  // Get messages for a specific Codex session
2166
2441
  async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2167
2442
  try {
2168
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
2169
-
2170
- // Find the session file by searching for the session ID
2171
- const findSessionFile = async (dir) => {
2172
- try {
2173
- const entries = await fs.readdir(dir, { withFileTypes: true });
2174
- for (const entry of entries) {
2175
- const fullPath = path.join(dir, entry.name);
2176
- if (entry.isDirectory()) {
2177
- const found = await findSessionFile(fullPath);
2178
- if (found) return found;
2179
- } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
2180
- return fullPath;
2181
- }
2182
- }
2183
- } catch (error) {
2184
- // Skip directories we can't read
2185
- }
2186
- return null;
2187
- };
2188
-
2189
- const sessionFilePath = await findSessionFile(codexSessionsDir);
2443
+ const sessionFilePath = await resolveCodexSessionFile(sessionId);
2190
2444
 
2191
2445
  if (!sessionFilePath) {
2192
2446
  console.warn(`Codex session file not found for session ${sessionId}`);
2193
2447
  return { messages: [], total: 0, hasMore: false };
2194
2448
  }
2195
2449
 
2450
+ const normalizedLimit = limit === null ? null : Math.max(0, Number(limit) || 0);
2451
+ const normalizedOffset = Math.max(0, Number(offset) || 0);
2452
+ const maxBufferedMessages = normalizedLimit === null ? null : normalizedLimit + normalizedOffset;
2196
2453
  const messages = [];
2454
+ let total = 0;
2197
2455
  let tokenUsage = null;
2198
2456
  const fileStream = fsSync.createReadStream(sessionFilePath);
2199
2457
  const rl = readline.createInterface({
@@ -2201,21 +2459,9 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2201
2459
  crlfDelay: Infinity
2202
2460
  });
2203
2461
 
2204
- // Helper to extract text from Codex content array
2205
- const extractText = (content) => {
2206
- if (!Array.isArray(content)) return content;
2207
- return content
2208
- .map(item => {
2209
- if (item.type === 'input_text' || item.type === 'output_text') {
2210
- return item.text;
2211
- }
2212
- if (item.type === 'text') {
2213
- return item.text;
2214
- }
2215
- return '';
2216
- })
2217
- .filter(Boolean)
2218
- .join('\n');
2462
+ const appendMessage = (message) => {
2463
+ total += 1;
2464
+ appendBoundedMessage(messages, message, maxBufferedMessages);
2219
2465
  };
2220
2466
 
2221
2467
  for await (const line of rl) {
@@ -2229,10 +2475,10 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2229
2475
  }
2230
2476
 
2231
2477
  // Extract messages from response_item
2232
- if (entry.type === 'response_item' && entry.payload?.type === 'message') {
2478
+ if (shouldIncludeCodexTranscriptMessage(entry)) {
2233
2479
  const content = entry.payload.content;
2234
2480
  const role = entry.payload.role || 'assistant';
2235
- const textContent = extractText(content);
2481
+ const textContent = extractCodexMessageText(content);
2236
2482
  const visibleTextContent = role === 'user'
2237
2483
  ? extractVisibleUserMessage(textContent)
2238
2484
  : textContent;
@@ -2243,7 +2489,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2243
2489
 
2244
2490
  // Only add if there's actual content
2245
2491
  if (visibleTextContent?.trim()) {
2246
- messages.push({
2492
+ appendMessage({
2247
2493
  type: role === 'user' ? 'user' : 'assistant',
2248
2494
  timestamp: entry.timestamp,
2249
2495
  message: {
@@ -2260,7 +2506,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2260
2506
  .filter(Boolean)
2261
2507
  .join('\n');
2262
2508
  if (summaryText?.trim()) {
2263
- messages.push({
2509
+ appendMessage({
2264
2510
  type: 'thinking',
2265
2511
  timestamp: entry.timestamp,
2266
2512
  message: {
@@ -2286,7 +2532,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2286
2532
  }
2287
2533
  }
2288
2534
 
2289
- messages.push({
2535
+ appendMessage({
2290
2536
  type: 'tool_use',
2291
2537
  timestamp: entry.timestamp,
2292
2538
  toolName: toolName,
@@ -2296,7 +2542,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2296
2542
  }
2297
2543
 
2298
2544
  if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
2299
- messages.push({
2545
+ appendMessage({
2300
2546
  type: 'tool_result',
2301
2547
  timestamp: entry.timestamp,
2302
2548
  toolCallId: entry.payload.call_id,
@@ -2326,7 +2572,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2326
2572
  }
2327
2573
  }
2328
2574
 
2329
- messages.push({
2575
+ appendMessage({
2330
2576
  type: 'tool_use',
2331
2577
  timestamp: entry.timestamp,
2332
2578
  toolName: 'Edit',
@@ -2338,7 +2584,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2338
2584
  toolCallId: entry.payload.call_id
2339
2585
  });
2340
2586
  } else {
2341
- messages.push({
2587
+ appendMessage({
2342
2588
  type: 'tool_use',
2343
2589
  timestamp: entry.timestamp,
2344
2590
  toolName: toolName,
@@ -2349,7 +2595,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2349
2595
  }
2350
2596
 
2351
2597
  if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
2352
- messages.push({
2598
+ appendMessage({
2353
2599
  type: 'tool_result',
2354
2600
  timestamp: entry.timestamp,
2355
2601
  toolCallId: entry.payload.call_id,
@@ -2363,29 +2609,31 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2363
2609
  }
2364
2610
  }
2365
2611
 
2366
- // Sort by timestamp
2367
- messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
2368
-
2369
- const total = messages.length;
2370
-
2371
2612
  // Apply pagination if limit is specified
2372
- if (limit !== null) {
2373
- const startIndex = Math.max(0, total - offset - limit);
2374
- const endIndex = total - offset;
2613
+ if (normalizedLimit !== null) {
2614
+ const endIndex = Math.max(0, messages.length - normalizedOffset);
2615
+ const startIndex = Math.max(0, endIndex - normalizedLimit);
2375
2616
  const paginatedMessages = messages.slice(startIndex, endIndex);
2376
- const hasMore = startIndex > 0;
2617
+ const hasMore = total > normalizedOffset + paginatedMessages.length;
2377
2618
 
2378
2619
  return {
2379
2620
  messages: paginatedMessages,
2380
2621
  total,
2381
2622
  hasMore,
2382
- offset,
2383
- limit,
2623
+ offset: normalizedOffset,
2624
+ limit: normalizedLimit,
2384
2625
  tokenUsage
2385
2626
  };
2386
2627
  }
2387
2628
 
2388
- return { messages, tokenUsage };
2629
+ return {
2630
+ messages,
2631
+ total,
2632
+ hasMore: false,
2633
+ offset: normalizedOffset,
2634
+ limit: normalizedLimit,
2635
+ tokenUsage
2636
+ };
2389
2637
 
2390
2638
  } catch (error) {
2391
2639
  console.error(`Error reading Codex session messages for ${sessionId}:`, error);
@@ -2393,37 +2641,45 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
2393
2641
  }
2394
2642
  }
2395
2643
 
2396
- async function deleteCodexSession(sessionId) {
2397
- try {
2398
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
2644
+ async function getCodexSessionMetadata(sessionId) {
2645
+ if (!sessionId) {
2646
+ return null;
2647
+ }
2399
2648
 
2400
- const findJsonlFiles = async (dir) => {
2401
- const files = [];
2402
- try {
2403
- const entries = await fs.readdir(dir, { withFileTypes: true });
2404
- for (const entry of entries) {
2405
- const fullPath = path.join(dir, entry.name);
2406
- if (entry.isDirectory()) {
2407
- files.push(...await findJsonlFiles(fullPath));
2408
- } else if (entry.name.endsWith('.jsonl')) {
2409
- files.push(fullPath);
2410
- }
2411
- }
2412
- } catch (error) {}
2413
- return files;
2414
- };
2649
+ const sessionFilePath = await resolveCodexSessionFile(sessionId);
2650
+ if (!sessionFilePath) {
2651
+ return null;
2652
+ }
2653
+
2654
+ const sessionData = await parseCodexSessionFile(sessionFilePath);
2655
+ if (!sessionData) {
2656
+ return null;
2657
+ }
2415
2658
 
2416
- const jsonlFiles = await findJsonlFiles(codexSessionsDir);
2659
+ const metadata = {
2660
+ ...sessionData,
2661
+ filePath: sessionFilePath,
2662
+ provider: 'codex'
2663
+ };
2417
2664
 
2418
- for (const filePath of jsonlFiles) {
2419
- const sessionData = await parseCodexSessionFile(filePath);
2420
- if (sessionData && sessionData.id === sessionId) {
2421
- await fs.unlink(filePath);
2422
- return true;
2423
- }
2665
+ if (metadata.git === undefined) {
2666
+ delete metadata.git;
2667
+ }
2668
+
2669
+ return metadata;
2670
+ }
2671
+
2672
+ async function deleteCodexSession(sessionId) {
2673
+ try {
2674
+ const sessionFilePath = await resolveCodexSessionFile(sessionId);
2675
+
2676
+ if (!sessionFilePath) {
2677
+ throw new Error(`Codex session file not found for session ${sessionId}`);
2424
2678
  }
2425
2679
 
2426
- throw new Error(`Codex session file not found for session ${sessionId}`);
2680
+ await fs.unlink(sessionFilePath);
2681
+ codexSessionFileCache.delete(sessionId);
2682
+ return true;
2427
2683
  } catch (error) {
2428
2684
  console.error(`Error deleting Codex session ${sessionId}:`, error);
2429
2685
  throw error;
@@ -2432,7 +2688,10 @@ async function deleteCodexSession(sessionId) {
2432
2688
 
2433
2689
  export {
2434
2690
  getProjects,
2691
+ getProjectsList,
2692
+ getProjectDetails,
2435
2693
  getSessions,
2694
+ getClaudeSessionMetadata,
2436
2695
  getSessionMessages,
2437
2696
  parseJsonlSessions,
2438
2697
  renameProject,
@@ -2445,11 +2704,15 @@ export {
2445
2704
  extractProjectDirectory,
2446
2705
  clearProjectDirectoryCache,
2447
2706
  clearProviderSessionLookupCaches,
2707
+ findCodexSessionFilePathBySessionIdHint,
2448
2708
  getCodexSessions,
2449
2709
  getCodexSessionMessages,
2710
+ getCodexSessionMetadata,
2450
2711
  getOpencodeSessions,
2712
+ getOpencodeSessionMetadata,
2451
2713
  getOpencodeSessionMessages,
2452
2714
  getGeminiSessions,
2715
+ getGeminiSessionMetadata,
2453
2716
  getGeminiSessionMessages,
2454
2717
  deleteCodexSession,
2455
2718
  deleteOpencodeSession