coding-tool-x 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,900 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const { getAllSessions, parseSessionInfoFast } = require('../../utils/session');
6
+ const { loadAliases } = require('./alias');
7
+ const {
8
+ getCachedProjects,
9
+ setCachedProjects,
10
+ invalidateProjectsCache,
11
+ checkHasMessagesCache,
12
+ rememberHasMessages
13
+ } = require('./session-cache');
14
+ const { globalCache, CacheKeys } = require('./enhanced-cache');
15
+ const { PATHS } = require('../../config/paths');
16
+
17
+ // Base directory for cc-tool data
18
+ function getCcToolDir() {
19
+ return PATHS.base;
20
+ }
21
+
22
+ // Get path for storing project order
23
+ function getOrderFilePath() {
24
+ return PATHS.projectOrder;
25
+ }
26
+
27
+ // Get path for storing fork relations
28
+ function getForkRelationsFilePath() {
29
+ return path.join(PATHS.base, 'fork-relations.json');
30
+ }
31
+
32
+ // Get path for storing session order
33
+ function getSessionOrderFilePath() {
34
+ return path.join(getCcToolDir(), 'session-order.json');
35
+ }
36
+
37
+ // Get saved project order
38
+ function getProjectOrder(config) {
39
+ const orderFile = getOrderFilePath();
40
+ try {
41
+ if (fs.existsSync(orderFile)) {
42
+ const data = fs.readFileSync(orderFile, 'utf8');
43
+ return JSON.parse(data);
44
+ }
45
+ } catch (err) {
46
+ // Ignore errors
47
+ }
48
+ return [];
49
+ }
50
+
51
+ // Save project order
52
+ function saveProjectOrder(config, order) {
53
+ const orderFile = getOrderFilePath();
54
+ const dir = path.dirname(orderFile);
55
+ if (!fs.existsSync(dir)) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ }
58
+ fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), 'utf8');
59
+ invalidateProjectsCache(config);
60
+ }
61
+
62
+ // Get fork relations
63
+ function getForkRelations() {
64
+ const relationsFile = getForkRelationsFilePath();
65
+ try {
66
+ if (fs.existsSync(relationsFile)) {
67
+ const data = fs.readFileSync(relationsFile, 'utf8');
68
+ return JSON.parse(data);
69
+ }
70
+ } catch (err) {
71
+ // Ignore errors
72
+ }
73
+ return {};
74
+ }
75
+
76
+ // Save fork relations
77
+ function saveForkRelations(relations) {
78
+ const relationsFile = getForkRelationsFilePath();
79
+ const dir = path.dirname(relationsFile);
80
+ if (!fs.existsSync(dir)) {
81
+ fs.mkdirSync(dir, { recursive: true });
82
+ }
83
+ fs.writeFileSync(relationsFile, JSON.stringify(relations, null, 2), 'utf8');
84
+ }
85
+
86
+ // Get all projects with stats (async version)
87
+ async function getProjects(config) {
88
+ const projectsDir = config.projectsDir;
89
+
90
+ if (!fs.existsSync(projectsDir)) {
91
+ return [];
92
+ }
93
+
94
+ const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
95
+ return entries
96
+ .filter(entry => entry.isDirectory())
97
+ .map(entry => entry.name);
98
+ }
99
+
100
+ // Parse real project path from encoded name
101
+ // macOS/Linux: "-Users-lilithgames-work-project" -> "/Users/lilithgames/work/project"
102
+ // Windows: "C--Users-admin-Desktop-project" -> "C:\Users\admin\Desktop\project"
103
+ function parseRealProjectPath(encodedName) {
104
+ const isWindows = process.platform === 'win32';
105
+ const fallbackFromSessions = tryResolvePathFromSessions(encodedName);
106
+
107
+ // Detect Windows drive letter (e.g., "C--Users-admin")
108
+ const windowsDriveMatch = encodedName.match(/^([A-Z])--(.+)$/);
109
+
110
+ if (isWindows && windowsDriveMatch) {
111
+ // Windows path with drive letter
112
+ const driveLetter = windowsDriveMatch[1];
113
+ const restPath = windowsDriveMatch[2];
114
+
115
+ // Split by '-' to get segments
116
+ const segments = restPath.split('-').filter(s => s);
117
+
118
+ // Build path from left to right, checking existence
119
+ let realSegments = [];
120
+ let accumulated = '';
121
+ let currentPath = '';
122
+
123
+ for (let i = 0; i < segments.length; i++) {
124
+ if (accumulated) {
125
+ accumulated += '-' + segments[i];
126
+ } else {
127
+ accumulated = segments[i];
128
+ }
129
+
130
+ const testPath = driveLetter + ':\\' + realSegments.concat(accumulated).join('\\');
131
+
132
+ // Check if this path exists
133
+ let found = fs.existsSync(testPath);
134
+ let finalAccumulated = accumulated;
135
+
136
+ // If not found with dash, try with underscore
137
+ if (!found && accumulated.includes('-')) {
138
+ const withUnderscore = accumulated.replace(/-/g, '_');
139
+ const testPathUnderscore = driveLetter + ':\\' + realSegments.concat(withUnderscore).join('\\');
140
+ if (fs.existsSync(testPathUnderscore)) {
141
+ finalAccumulated = withUnderscore;
142
+ found = true;
143
+ }
144
+ }
145
+
146
+ if (found) {
147
+ realSegments.push(finalAccumulated);
148
+ accumulated = '';
149
+ currentPath = driveLetter + ':\\' + realSegments.join('\\');
150
+ }
151
+ }
152
+
153
+ // If there's remaining accumulated segment, try underscore variant
154
+ if (accumulated) {
155
+ let finalAccumulated = accumulated;
156
+ if (accumulated.includes('-')) {
157
+ const withUnderscore = accumulated.replace(/-/g, '_');
158
+ const testPath = driveLetter + ':\\' + realSegments.concat(withUnderscore).join('\\');
159
+ if (fs.existsSync(testPath)) {
160
+ finalAccumulated = withUnderscore;
161
+ }
162
+ }
163
+ realSegments.push(finalAccumulated);
164
+ currentPath = driveLetter + ':\\' + realSegments.join('\\');
165
+ }
166
+
167
+ return {
168
+ fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || (driveLetter + ':\\' + restPath.replace(/-/g, '\\')),
169
+ projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
170
+ };
171
+ } else {
172
+ // Unix-like path (macOS/Linux) or fallback
173
+ const pathStr = encodedName.replace(/^-/, '/').replace(/-/g, '/');
174
+ const segments = pathStr.split('/').filter(s => s);
175
+
176
+ // Build path from left to right, checking existence
177
+ let currentPath = '';
178
+ const realSegments = [];
179
+ let accumulated = '';
180
+
181
+ for (let i = 0; i < segments.length; i++) {
182
+ if (accumulated) {
183
+ accumulated += '-' + segments[i];
184
+ } else {
185
+ accumulated = segments[i];
186
+ }
187
+
188
+ const testPath = '/' + realSegments.concat(accumulated).join('/');
189
+
190
+ // Check if this path exists
191
+ let found = fs.existsSync(testPath);
192
+ let finalAccumulated = accumulated;
193
+
194
+ // If not found with dash, try with underscore
195
+ if (!found && accumulated.includes('-')) {
196
+ const withUnderscore = accumulated.replace(/-/g, '_');
197
+ const testPathUnderscore = '/' + realSegments.concat(withUnderscore).join('/');
198
+ if (fs.existsSync(testPathUnderscore)) {
199
+ finalAccumulated = withUnderscore;
200
+ found = true;
201
+ }
202
+ }
203
+
204
+ if (found) {
205
+ realSegments.push(finalAccumulated);
206
+ accumulated = '';
207
+ currentPath = '/' + realSegments.join('/');
208
+ }
209
+ }
210
+
211
+ // If there's remaining accumulated segment, try underscore variant
212
+ if (accumulated) {
213
+ let finalAccumulated = accumulated;
214
+ if (accumulated.includes('-')) {
215
+ const withUnderscore = accumulated.replace(/-/g, '_');
216
+ const testPath = '/' + realSegments.concat(withUnderscore).join('/');
217
+ if (fs.existsSync(testPath)) {
218
+ finalAccumulated = withUnderscore;
219
+ }
220
+ }
221
+ realSegments.push(finalAccumulated);
222
+ currentPath = '/' + realSegments.join('/');
223
+ }
224
+
225
+ return {
226
+ fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || pathStr,
227
+ projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
228
+ };
229
+ }
230
+ }
231
+
232
+ function validateProjectPath(candidatePath) {
233
+ if (candidatePath && fs.existsSync(candidatePath)) {
234
+ return candidatePath;
235
+ }
236
+ return null;
237
+ }
238
+
239
+ function tryResolvePathFromSessions(encodedName) {
240
+ try {
241
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedName);
242
+ if (!fs.existsSync(projectDir)) {
243
+ return null;
244
+ }
245
+ const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
246
+ for (const file of files) {
247
+ const sessionFile = path.join(projectDir, file);
248
+ const cwd = extractCwdFromSessionHeader(sessionFile);
249
+ if (cwd && fs.existsSync(cwd)) {
250
+ return {
251
+ fullPath: cwd,
252
+ projectName: path.basename(cwd)
253
+ };
254
+ }
255
+ }
256
+ } catch (err) {
257
+ // ignore fallback errors
258
+ }
259
+ return null;
260
+ }
261
+
262
+ function extractCwdFromSessionHeader(sessionFile) {
263
+ try {
264
+ const fd = fs.openSync(sessionFile, 'r');
265
+ const buffer = Buffer.alloc(4096);
266
+ const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
267
+ fs.closeSync(fd);
268
+ const content = buffer.slice(0, bytesRead).toString('utf8');
269
+ const lines = content.split('\n');
270
+ for (const line of lines) {
271
+ if (!line.trim()) continue;
272
+ try {
273
+ const json = JSON.parse(line);
274
+ if (json.cwd && typeof json.cwd === 'string') {
275
+ return json.cwd;
276
+ }
277
+ } catch (e) {
278
+ // ignore
279
+ }
280
+ }
281
+ } catch (err) {
282
+ // ignore
283
+ }
284
+ return null;
285
+ }
286
+
287
+ // Get projects with detailed stats (with caching) - async version
288
+ async function getProjectsWithStats(config, options = {}) {
289
+ if (!options.force) {
290
+ // Check enhanced cache first
291
+ const cacheKey = `${CacheKeys.PROJECTS}${config.projectsDir}`;
292
+ const enhancedCached = globalCache.get(cacheKey);
293
+ if (enhancedCached) {
294
+ return enhancedCached;
295
+ }
296
+
297
+ // Check old cache
298
+ const cached = getCachedProjects(config);
299
+ if (cached) {
300
+ globalCache.set(cacheKey, cached, 300000); // 5分钟
301
+ return cached;
302
+ }
303
+ }
304
+
305
+ try {
306
+ const data = await buildProjectsWithStats(config);
307
+ if (!Array.isArray(data)) {
308
+ console.warn(`[getProjectsWithStats] Unexpected non-array result for ${config.projectsDir}, returning empty array.`);
309
+ return [];
310
+ }
311
+ setCachedProjects(config, data);
312
+ globalCache.set(`${CacheKeys.PROJECTS}${config.projectsDir}`, data, 300000);
313
+ return data;
314
+ } catch (err) {
315
+ console.error(`[getProjectsWithStats] Failed to build projects for ${config.projectsDir}:`, err);
316
+ return [];
317
+ }
318
+ }
319
+
320
+ async function buildProjectsWithStats(config) {
321
+ const projectsDir = config.projectsDir;
322
+
323
+ if (!fs.existsSync(projectsDir)) {
324
+ return [];
325
+ }
326
+
327
+ const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
328
+
329
+ // Process all projects concurrently
330
+ const projectPromises = entries
331
+ .filter(entry => entry.isDirectory())
332
+ .map(async (entry) => {
333
+ const projectName = entry.name;
334
+ const projectPath = path.join(projectsDir, projectName);
335
+
336
+ // Parse real project path
337
+ const { fullPath, projectName: displayName } = parseRealProjectPath(projectName);
338
+
339
+ // Get session files (only count sessions with actual messages)
340
+ let sessionCount = 0;
341
+ let lastUsed = null;
342
+
343
+ try {
344
+ const files = await fs.promises.readdir(projectPath);
345
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
346
+
347
+ // Filter: only count sessions that have actual messages (in parallel)
348
+ const sessionChecks = await Promise.all(
349
+ jsonlFiles.map(async (f) => {
350
+ const filePath = path.join(projectPath, f);
351
+ const hasMessages = await hasActualMessages(filePath);
352
+ return hasMessages ? f : null;
353
+ })
354
+ );
355
+
356
+ const sessionFilesWithMessages = sessionChecks.filter(f => f !== null);
357
+ sessionCount = sessionFilesWithMessages.length;
358
+
359
+ // Find most recent session (only from sessions with messages)
360
+ if (sessionFilesWithMessages.length > 0) {
361
+ const statPromises = sessionFilesWithMessages.map(async (f) => {
362
+ const filePath = path.join(projectPath, f);
363
+ const stat = await fs.promises.stat(filePath);
364
+ return stat.mtime.getTime();
365
+ });
366
+ const stats = await Promise.all(statPromises);
367
+ lastUsed = Math.max(...stats);
368
+ }
369
+ } catch (err) {
370
+ // Ignore errors
371
+ }
372
+
373
+ return {
374
+ name: projectName, // Keep encoded name for API operations
375
+ displayName, // Project name for display
376
+ fullPath, // Real full path for display
377
+ sessionCount,
378
+ lastUsed
379
+ };
380
+ });
381
+
382
+ const projects = await Promise.all(projectPromises);
383
+ return projects.sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)); // Sort by last used
384
+ }
385
+
386
+ // 获取 Claude 项目/会话数量(轻量统计)
387
+ function getProjectAndSessionCounts(config) {
388
+ const projectsDir = config.projectsDir;
389
+ if (!fs.existsSync(projectsDir)) {
390
+ return { projectCount: 0, sessionCount: 0 };
391
+ }
392
+
393
+ let projectCount = 0;
394
+ let sessionCount = 0;
395
+
396
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
397
+ entries.forEach((entry) => {
398
+ if (!entry.isDirectory()) {
399
+ return;
400
+ }
401
+ projectCount += 1;
402
+ const projectPath = path.join(projectsDir, entry.name);
403
+ try {
404
+ const files = fs.readdirSync(projectPath);
405
+ sessionCount += files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')).length;
406
+ } catch (err) {
407
+ // 忽略单个项目的读取错误
408
+ }
409
+ });
410
+
411
+ return { projectCount, sessionCount };
412
+ }
413
+
414
+ // Check if a session file has actual messages (async with enhanced caching)
415
+ async function hasActualMessages(filePath) {
416
+ try {
417
+ const stats = await fs.promises.stat(filePath);
418
+
419
+ // Check enhanced cache first
420
+ const cacheKey = `${CacheKeys.HAS_MESSAGES}${filePath}:${stats.mtime.getTime()}`;
421
+ const cached = globalCache.get(cacheKey);
422
+ if (typeof cached === 'boolean') {
423
+ return cached;
424
+ }
425
+
426
+ // Check old cache mechanism
427
+ const oldCached = checkHasMessagesCache(filePath, stats);
428
+ if (typeof oldCached === 'boolean') {
429
+ globalCache.set(cacheKey, oldCached, 600000); // 10分钟
430
+ return oldCached;
431
+ }
432
+
433
+ const result = await scanSessionFileForMessagesAsync(filePath);
434
+ globalCache.set(cacheKey, result, 600000);
435
+ rememberHasMessages(filePath, stats, result);
436
+ return result;
437
+ } catch (err) {
438
+ return false;
439
+ }
440
+ }
441
+
442
+ function scanSessionFileForMessages(filePath) {
443
+ let fd = null;
444
+ try {
445
+ fd = fs.openSync(filePath, 'r');
446
+ const bufferSize = 64 * 1024;
447
+ const buffer = Buffer.alloc(bufferSize);
448
+ const pattern = /"type"\s*:\s*"(user|assistant|summary)"/;
449
+ let leftover = '';
450
+ let bytesRead;
451
+
452
+ while ((bytesRead = fs.readSync(fd, buffer, 0, bufferSize, null)) > 0) {
453
+ const chunk = buffer.toString('utf8', 0, bytesRead);
454
+ const combined = leftover + chunk;
455
+ if (pattern.test(combined)) {
456
+ fs.closeSync(fd);
457
+ return true;
458
+ }
459
+ leftover = combined.slice(-64);
460
+ }
461
+
462
+ fs.closeSync(fd);
463
+ return false;
464
+ } catch (err) {
465
+ if (fd) {
466
+ try {
467
+ fs.closeSync(fd);
468
+ } catch (e) {
469
+ // ignore
470
+ }
471
+ }
472
+ return false;
473
+ }
474
+ }
475
+
476
+ // Async version using streams for better performance
477
+ function scanSessionFileForMessagesAsync(filePath) {
478
+ return new Promise((resolve) => {
479
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
480
+ const pattern = /"type"\s*:\s*"(user|assistant|summary)"/;
481
+ let found = false;
482
+ let leftover = '';
483
+
484
+ stream.on('data', (chunk) => {
485
+ if (found) return;
486
+ const combined = leftover + chunk;
487
+ if (pattern.test(combined)) {
488
+ found = true;
489
+ stream.destroy();
490
+ resolve(true);
491
+ }
492
+ leftover = combined.slice(-64);
493
+ });
494
+
495
+ stream.on('end', () => {
496
+ if (!found) resolve(false);
497
+ });
498
+
499
+ stream.on('error', () => {
500
+ resolve(false);
501
+ });
502
+ });
503
+ }
504
+
505
+ // Get sessions for a project - async version
506
+ async function getSessionsForProject(config, projectName) {
507
+ // Check cache first
508
+ const cacheKey = `${CacheKeys.SESSIONS}${projectName}`;
509
+ const cached = globalCache.get(cacheKey);
510
+ if (cached) {
511
+ return cached;
512
+ }
513
+
514
+ const projectConfig = { ...config, currentProject: projectName };
515
+ const sessions = getAllSessions(projectConfig);
516
+ const forkRelations = getForkRelations();
517
+ const savedOrder = getSessionOrder(projectName);
518
+
519
+ // Parse session info and calculate total size, filter out sessions with no messages (in parallel)
520
+ let totalSize = 0;
521
+
522
+ const sessionChecks = await Promise.all(
523
+ sessions.map(async (session) => {
524
+ const hasMessages = await hasActualMessages(session.filePath);
525
+ return hasMessages ? session : null;
526
+ })
527
+ );
528
+
529
+ const validSessions = sessionChecks.filter(s => s !== null);
530
+
531
+ const sessionsWithInfo = validSessions.map(session => {
532
+ const info = parseSessionInfoFast(session.filePath);
533
+ totalSize += session.size || 0;
534
+ return {
535
+ sessionId: session.sessionId,
536
+ mtime: session.mtime,
537
+ size: session.size,
538
+ filePath: session.filePath,
539
+ gitBranch: info.gitBranch || null,
540
+ firstMessage: info.firstMessage || null,
541
+ forkedFrom: forkRelations[session.sessionId] || null
542
+ };
543
+ });
544
+
545
+ // Apply saved order if exists
546
+ let orderedSessions = sessionsWithInfo;
547
+ if (savedOrder.length > 0) {
548
+ const ordered = [];
549
+ const sessionMap = new Map(sessionsWithInfo.map(s => [s.sessionId, s]));
550
+
551
+ // Add sessions in saved order
552
+ for (const sessionId of savedOrder) {
553
+ if (sessionMap.has(sessionId)) {
554
+ ordered.push(sessionMap.get(sessionId));
555
+ sessionMap.delete(sessionId);
556
+ }
557
+ }
558
+
559
+ // Add remaining sessions (new ones not in saved order)
560
+ ordered.push(...sessionMap.values());
561
+ orderedSessions = ordered;
562
+ }
563
+
564
+ const result = {
565
+ sessions: orderedSessions,
566
+ totalSize
567
+ };
568
+
569
+ // Cache for 2 minutes
570
+ globalCache.set(cacheKey, result, 120000);
571
+ return result;
572
+ }
573
+
574
+ // Delete a session
575
+ function deleteSession(config, projectName, sessionId) {
576
+ const projectDir = path.join(config.projectsDir, projectName);
577
+ const sessionFile = path.join(projectDir, sessionId + '.jsonl');
578
+
579
+ if (!fs.existsSync(sessionFile)) {
580
+ throw new Error('Session not found');
581
+ }
582
+
583
+ fs.unlinkSync(sessionFile);
584
+ invalidateProjectsCache(config);
585
+ return { success: true };
586
+ }
587
+
588
+ // Fork a session
589
+ function forkSession(config, projectName, sessionId) {
590
+ const projectDir = path.join(config.projectsDir, projectName);
591
+ const sessionFile = path.join(projectDir, sessionId + '.jsonl');
592
+
593
+ if (!fs.existsSync(sessionFile)) {
594
+ throw new Error('Session not found');
595
+ }
596
+
597
+ // Read the original session
598
+ const content = fs.readFileSync(sessionFile, 'utf8');
599
+
600
+ // Generate new session ID (UUID v4)
601
+ const newSessionId = crypto.randomUUID();
602
+ const newSessionFile = path.join(projectDir, newSessionId + '.jsonl');
603
+
604
+ // Write to new file
605
+ fs.writeFileSync(newSessionFile, content, 'utf8');
606
+
607
+ // Save fork relation
608
+ const forkRelations = getForkRelations();
609
+ forkRelations[newSessionId] = sessionId;
610
+ saveForkRelations(forkRelations);
611
+ invalidateProjectsCache(config);
612
+
613
+ return { newSessionId, forkedFrom: sessionId };
614
+ }
615
+
616
+ // Get session order for a project
617
+ function getSessionOrder(projectName) {
618
+ const orderFile = getSessionOrderFilePath();
619
+ try {
620
+ if (fs.existsSync(orderFile)) {
621
+ const data = fs.readFileSync(orderFile, 'utf8');
622
+ const allOrders = JSON.parse(data);
623
+ return allOrders[projectName] || [];
624
+ }
625
+ } catch (err) {
626
+ // Ignore errors
627
+ }
628
+ return [];
629
+ }
630
+
631
+ // Save session order for a project
632
+ function saveSessionOrder(projectName, order) {
633
+ const orderFile = getSessionOrderFilePath();
634
+ const dir = path.dirname(orderFile);
635
+ if (!fs.existsSync(dir)) {
636
+ fs.mkdirSync(dir, { recursive: true });
637
+ }
638
+
639
+ // Read existing orders
640
+ let allOrders = {};
641
+ try {
642
+ if (fs.existsSync(orderFile)) {
643
+ const data = fs.readFileSync(orderFile, 'utf8');
644
+ allOrders = JSON.parse(data);
645
+ }
646
+ } catch (err) {
647
+ // Ignore errors
648
+ }
649
+
650
+ // Update order for this project
651
+ allOrders[projectName] = order;
652
+ fs.writeFileSync(orderFile, JSON.stringify(allOrders, null, 2), 'utf8');
653
+ }
654
+
655
+ // Delete a project (remove the entire project directory)
656
+ function deleteProject(config, projectName) {
657
+ const projectDir = path.join(config.projectsDir, projectName);
658
+
659
+ if (!fs.existsSync(projectDir)) {
660
+ throw new Error('Project not found');
661
+ }
662
+
663
+ // Recursively delete the directory
664
+ fs.rmSync(projectDir, { recursive: true, force: true });
665
+
666
+ // Remove from order file if exists
667
+ const order = getProjectOrder(config);
668
+ const newOrder = order.filter(name => name !== projectName);
669
+ if (newOrder.length !== order.length) {
670
+ saveProjectOrder(config, newOrder);
671
+ }
672
+
673
+ invalidateProjectsCache(config);
674
+ return { success: true };
675
+ }
676
+
677
+ // Search sessions for keyword
678
+ function searchSessions(config, projectName, keyword, contextLength = 15) {
679
+ const projectDir = path.join(config.projectsDir, projectName);
680
+
681
+ if (!fs.existsSync(projectDir)) {
682
+ return [];
683
+ }
684
+
685
+ const results = [];
686
+ const files = fs.readdirSync(projectDir);
687
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
688
+ const aliases = loadAliases();
689
+
690
+ for (const file of jsonlFiles) {
691
+ const sessionId = file.replace('.jsonl', '');
692
+ const filePath = path.join(projectDir, file);
693
+
694
+ // Skip sessions with no actual messages
695
+ if (!hasActualMessages(filePath)) {
696
+ continue;
697
+ }
698
+
699
+ try {
700
+ const content = fs.readFileSync(filePath, 'utf8');
701
+ const lines = content.split('\n');
702
+ const matches = [];
703
+
704
+ for (const line of lines) {
705
+ if (!line.trim()) continue;
706
+
707
+ try {
708
+ const json = JSON.parse(line);
709
+
710
+ // Search in message content
711
+ if (json.message && json.message.content) {
712
+ const text = json.message.content;
713
+ const lowerText = text.toLowerCase();
714
+ const lowerKeyword = keyword.toLowerCase();
715
+ let index = 0;
716
+
717
+ while ((index = lowerText.indexOf(lowerKeyword, index)) !== -1) {
718
+ // Extract context
719
+ const start = Math.max(0, index - contextLength);
720
+ const end = Math.min(text.length, index + keyword.length + contextLength);
721
+ const context = text.substring(start, end);
722
+
723
+ matches.push({
724
+ role: json.message.role || 'unknown',
725
+ context: (start > 0 ? '...' : '') + context + (end < text.length ? '...' : ''),
726
+ position: index
727
+ });
728
+
729
+ index += keyword.length;
730
+ }
731
+ }
732
+ } catch (e) {
733
+ // Skip invalid JSON lines
734
+ }
735
+ }
736
+
737
+ if (matches.length > 0) {
738
+ results.push({
739
+ sessionId,
740
+ alias: aliases[sessionId] || null,
741
+ matchCount: matches.length,
742
+ matches: matches.slice(0, 5) // Limit to 5 matches per session
743
+ });
744
+ }
745
+ } catch (e) {
746
+ // Skip files that can't be read
747
+ }
748
+ }
749
+
750
+ // Sort by match count
751
+ results.sort((a, b) => b.matchCount - a.matchCount);
752
+
753
+ return results;
754
+ }
755
+
756
+ // Get recent sessions across all projects
757
+ async function getRecentSessions(config, limit = 5) {
758
+ const projects = await getProjects(config);
759
+ const allSessions = [];
760
+ const forkRelations = getForkRelations();
761
+ const aliases = loadAliases();
762
+
763
+ // Collect all sessions from all projects
764
+ projects.forEach(projectName => {
765
+ const projectConfig = { ...config, currentProject: projectName };
766
+ const sessions = getAllSessions(projectConfig);
767
+ const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
768
+
769
+ sessions.forEach(session => {
770
+ // Skip sessions with no actual messages
771
+ if (!hasActualMessages(session.filePath)) {
772
+ return;
773
+ }
774
+
775
+ const info = parseSessionInfoFast(session.filePath);
776
+ allSessions.push({
777
+ sessionId: session.sessionId,
778
+ projectName: projectName,
779
+ projectDisplayName: displayName,
780
+ projectFullPath: fullPath,
781
+ mtime: session.mtime,
782
+ size: session.size,
783
+ filePath: session.filePath,
784
+ gitBranch: info.gitBranch || null,
785
+ firstMessage: info.firstMessage || null,
786
+ forkedFrom: forkRelations[session.sessionId] || null,
787
+ alias: aliases[session.sessionId] || null
788
+ });
789
+ });
790
+ });
791
+
792
+ // Sort by mtime descending (most recent first)
793
+ allSessions.sort((a, b) => b.mtime - a.mtime);
794
+
795
+ // Return top N sessions
796
+ return allSessions.slice(0, limit);
797
+ }
798
+
799
+ // Search sessions across all projects
800
+ async function searchSessionsAcrossProjects(config, keyword, contextLength = 35) {
801
+ const allResults = [];
802
+
803
+ try {
804
+ // Search in Claude projects
805
+ const claudeProjects = await getProjects(config);
806
+ claudeProjects.forEach(projectName => {
807
+ const projectResults = searchSessions(config, projectName, keyword, contextLength);
808
+ const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
809
+
810
+ // Add project info to each result
811
+ projectResults.forEach(result => {
812
+ allResults.push({
813
+ ...result,
814
+ projectName: projectName,
815
+ projectDisplayName: displayName,
816
+ projectFullPath: fullPath,
817
+ channel: 'claude'
818
+ });
819
+ });
820
+ });
821
+ } catch (error) {
822
+ console.error('Error searching Claude projects:', error);
823
+ }
824
+
825
+ try {
826
+ // Search in Codex projects
827
+ const codexProjectsDir = path.join(os.homedir(), '.codex', 'projects');
828
+ if (fs.existsSync(codexProjectsDir)) {
829
+ const codexConfig = { ...config, projectsDir: codexProjectsDir };
830
+ const codexProjects = await getProjects(codexConfig);
831
+ codexProjects.forEach(projectName => {
832
+ const projectResults = searchSessions(codexConfig, projectName, keyword, contextLength);
833
+ const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
834
+
835
+ projectResults.forEach(result => {
836
+ allResults.push({
837
+ ...result,
838
+ projectName: projectName,
839
+ projectDisplayName: displayName,
840
+ projectFullPath: fullPath,
841
+ channel: 'codex'
842
+ });
843
+ });
844
+ });
845
+ }
846
+ } catch (error) {
847
+ console.error('Error searching Codex projects:', error);
848
+ }
849
+
850
+ try {
851
+ // Search in Gemini projects
852
+ const geminiProjectsDir = path.join(os.homedir(), '.gemini', 'projects');
853
+ if (fs.existsSync(geminiProjectsDir)) {
854
+ const geminiConfig = { ...config, projectsDir: geminiProjectsDir };
855
+ const geminiProjects = await getProjects(geminiConfig);
856
+ geminiProjects.forEach(projectName => {
857
+ const projectResults = searchSessions(geminiConfig, projectName, keyword, contextLength);
858
+ const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
859
+
860
+ projectResults.forEach(result => {
861
+ allResults.push({
862
+ ...result,
863
+ projectName: projectName,
864
+ projectDisplayName: displayName,
865
+ projectFullPath: fullPath,
866
+ channel: 'gemini'
867
+ });
868
+ });
869
+ });
870
+ }
871
+ } catch (error) {
872
+ console.error('Error searching Gemini projects:', error);
873
+ }
874
+
875
+ // Sort by match count
876
+ allResults.sort((a, b) => b.matchCount - a.matchCount);
877
+
878
+ return allResults;
879
+ }
880
+
881
+ module.exports = {
882
+ getProjects,
883
+ getProjectsWithStats,
884
+ getSessionsForProject,
885
+ deleteSession,
886
+ forkSession,
887
+ getRecentSessions,
888
+ getProjectOrder,
889
+ saveProjectOrder,
890
+ getSessionOrder,
891
+ saveSessionOrder,
892
+ deleteProject,
893
+ parseRealProjectPath,
894
+ searchSessions,
895
+ searchSessionsAcrossProjects,
896
+ getForkRelations,
897
+ saveForkRelations,
898
+ hasActualMessages,
899
+ getProjectAndSessionCounts
900
+ };