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,936 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { getCodexDir } = require('./codex-config');
4
+ const { parseSession, parseSessionMeta, extractSessionMeta, readJSONL } = require('./codex-parser');
5
+ const { globalCache, CacheKeys } = require('./enhanced-cache');
6
+
7
+ const COUNTS_CACHE_TTL_MS = 30 * 1000;
8
+ const SCAN_FILES_CACHE_TTL_MS = 15 * 1000;
9
+ const ALL_SESSIONS_CACHE_TTL_MS = 20 * 1000;
10
+ const PROJECTS_CACHE_TTL_MS = 300 * 1000;
11
+ const PROJECT_SESSIONS_CACHE_TTL_MS = 120 * 1000;
12
+ const FAST_META_READ_BYTES = 64 * 1024;
13
+ const EMPTY_COUNTS = Object.freeze({ projectCount: 0, sessionCount: 0 });
14
+
15
+ let countsCache = {
16
+ expiresAt: 0,
17
+ value: EMPTY_COUNTS
18
+ };
19
+
20
+ let scanFilesCache = {
21
+ expiresAt: 0,
22
+ value: []
23
+ };
24
+
25
+ let sessionFileIndexCache = {
26
+ expiresAt: 0,
27
+ value: new Map()
28
+ };
29
+
30
+ let allSessionsCache = {
31
+ expiresAt: 0,
32
+ value: []
33
+ };
34
+
35
+ const CODEX_PROJECTS_CACHE_KEY = `${CacheKeys.PROJECTS}codex`;
36
+ const codexSessionCacheKeys = new Set();
37
+
38
+ function getCodexSessionsCacheKey(projectName) {
39
+ return `${CacheKeys.SESSIONS}codex:${projectName}`;
40
+ }
41
+
42
+ /**
43
+ * 获取会话目录
44
+ */
45
+ function getSessionsDir() {
46
+ return path.join(getCodexDir(), 'sessions');
47
+ }
48
+
49
+ /**
50
+ * 递归扫描目录查找所有会话文件
51
+ * @param {string} dir - 目录路径
52
+ * @returns {Array} 会话文件路径数组
53
+ */
54
+ function scanDirectoryRecursive(dir) {
55
+ const results = [];
56
+
57
+ if (!fs.existsSync(dir)) {
58
+ return results;
59
+ }
60
+
61
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
62
+
63
+ for (const entry of entries) {
64
+ const fullPath = path.join(dir, entry.name);
65
+
66
+ if (entry.isDirectory()) {
67
+ // 递归扫描子目录
68
+ results.push(...scanDirectoryRecursive(fullPath));
69
+ } else if (entry.isFile() && entry.name.match(/^rollout-.*\.jsonl$/)) {
70
+ // 匹配会话文件
71
+ results.push(fullPath);
72
+ }
73
+ }
74
+
75
+ return results;
76
+ }
77
+
78
+ /**
79
+ * 扫描所有会话文件
80
+ * @returns {Array} 会话文件路径数组
81
+ */
82
+ function scanSessionFiles() {
83
+ const now = Date.now();
84
+ if (scanFilesCache.expiresAt > now) {
85
+ return scanFilesCache.value;
86
+ }
87
+
88
+ const sessionsDir = getSessionsDir();
89
+ const files = scanDirectoryRecursive(sessionsDir);
90
+
91
+ const parsed = files.map(filePath => {
92
+ const filename = path.basename(filePath);
93
+ // Codex 文件名格式:rollout-YYYY-MM-DDTHH-MM-SS-uuid.jsonl
94
+ // 时间戳:19个字符(2025-11-22T12-34-56)
95
+ const match = filename.match(/rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-([\w-]+)\.jsonl/);
96
+
97
+ if (!match) return null;
98
+
99
+ let size = 0;
100
+ let mtime = null;
101
+ let mtimeMs = 0;
102
+ try {
103
+ const stats = fs.statSync(filePath);
104
+ size = stats.size;
105
+ mtime = stats.mtime.toISOString();
106
+ mtimeMs = stats.mtime.getTime();
107
+ } catch (err) {
108
+ // ignore stat errors
109
+ }
110
+
111
+ return {
112
+ filePath,
113
+ timestamp: match[1],
114
+ sessionId: match[2],
115
+ date: match[1].split('T')[0],
116
+ size,
117
+ mtime,
118
+ mtimeMs
119
+ };
120
+ }).filter(Boolean);
121
+
122
+ const expiresAt = now + SCAN_FILES_CACHE_TTL_MS;
123
+ scanFilesCache = {
124
+ expiresAt,
125
+ value: parsed
126
+ };
127
+ sessionFileIndexCache = {
128
+ expiresAt,
129
+ value: new Map(parsed.map(file => [file.sessionId, file]))
130
+ };
131
+
132
+ return parsed;
133
+ }
134
+
135
+ /**
136
+ * 获取所有会话(轻量级,仅元数据)
137
+ * @returns {Array} 会话对象数组
138
+ */
139
+ function getAllSessions() {
140
+ const now = Date.now();
141
+ if (allSessionsCache.expiresAt > now) {
142
+ return allSessionsCache.value;
143
+ }
144
+
145
+ const files = scanSessionFiles();
146
+
147
+ const parsed = files.map(file => {
148
+ const fastSummary = readSessionMetaSummaryFast(file.filePath);
149
+ let session = null;
150
+
151
+ if (fastSummary && fastSummary.payload) {
152
+ session = {
153
+ filePath: file.filePath,
154
+ meta: normalizeSessionMetaFromPayload(fastSummary.payload, file.timestamp),
155
+ tokens: null,
156
+ messageCount: 0,
157
+ preview: fastSummary.preview || '',
158
+ size: file.size || 0,
159
+ mtime: file.mtime || null,
160
+ mtimeMs: file.mtimeMs || 0
161
+ };
162
+ } else {
163
+ // 回退完整解析,保证兼容性
164
+ session = parseSessionMeta(file.filePath);
165
+ if (session) {
166
+ session.size = file.size || 0;
167
+ session.mtime = file.mtime || null;
168
+ session.mtimeMs = file.mtimeMs || 0;
169
+ }
170
+ }
171
+
172
+ if (!session) return null;
173
+
174
+ return {
175
+ ...session,
176
+ sessionId: file.sessionId,
177
+ date: file.date
178
+ };
179
+ }).filter(Boolean);
180
+
181
+ allSessionsCache = {
182
+ expiresAt: now + ALL_SESSIONS_CACHE_TTL_MS,
183
+ value: parsed
184
+ };
185
+
186
+ return parsed;
187
+ }
188
+
189
+ /**
190
+ * 归一化会话数据为 Claude Code 格式
191
+ * @param {Object} codexSession - Codex 会话对象
192
+ * @returns {Object} 归一化后的会话对象
193
+ */
194
+ function normalizeSession(codexSession) {
195
+ const { meta, sessionId, preview, filePath } = codexSession;
196
+
197
+ // 获取文件大小和修改时间
198
+ let size = Number.isFinite(codexSession.size) ? codexSession.size : 0;
199
+ let mtime = codexSession.mtime || meta.timestamp || null;
200
+ if ((!size || !mtime) && filePath) {
201
+ try {
202
+ if (fs.existsSync(filePath)) {
203
+ const stats = fs.statSync(filePath);
204
+ size = size || stats.size;
205
+ mtime = mtime || stats.mtime.toISOString();
206
+ }
207
+ } catch (err) {
208
+ // 忽略错误
209
+ }
210
+ }
211
+
212
+ return {
213
+ sessionId,
214
+ mtime,
215
+ size,
216
+ filePath: filePath || '',
217
+ gitBranch: meta.git?.branch || null,
218
+ firstMessage: preview || null,
219
+ forkedFrom: null, // Codex 不支持 fork
220
+
221
+ // 额外的 Codex 特有字段(前端可能需要)
222
+ source: 'codex'
223
+ };
224
+ }
225
+
226
+ /**
227
+ * 聚合项目列表
228
+ * @returns {Array} 项目对象数组
229
+ */
230
+ function getProjects() {
231
+ const cached = globalCache.get(CODEX_PROJECTS_CACHE_KEY);
232
+ if (cached) {
233
+ return cached;
234
+ }
235
+
236
+ const sessions = getAllSessions();
237
+ const projectMap = new Map();
238
+
239
+ sessions.forEach(session => {
240
+ const meta = session.meta;
241
+
242
+ // 优先使用 Git 仓库名,否则使用 cwd 的最后一级目录
243
+ const projectName = extractCodexProjectNameFromMeta(meta);
244
+ const projectPath = (typeof meta.cwd === 'string' && meta.cwd.trim()) ? meta.cwd.trim() : projectName;
245
+ if (!projectName) return;
246
+
247
+ if (!projectMap.has(projectName)) {
248
+ projectMap.set(projectName, {
249
+ name: projectName,
250
+ displayName: projectName,
251
+ fullPath: projectPath,
252
+ path: projectPath,
253
+ gitRepo: meta.git?.repositoryUrl,
254
+ branch: meta.git?.branch,
255
+ sessionCount: 0,
256
+ lastUsed: null,
257
+ source: 'codex'
258
+ });
259
+ }
260
+
261
+ const project = projectMap.get(projectName);
262
+ project.sessionCount++;
263
+
264
+ // 更新最后活动时间
265
+ const sessionTime = session.mtimeMs || new Date(session.meta.timestamp || 0).getTime() || 0;
266
+ if (!project.lastUsed || sessionTime > project.lastUsed) {
267
+ project.lastUsed = sessionTime;
268
+ }
269
+ });
270
+
271
+ // 获取保存的排序
272
+ const savedOrder = getProjectOrder();
273
+ const projects = Array.from(projectMap.values());
274
+
275
+ // 应用保存的排序
276
+ if (savedOrder.length > 0) {
277
+ const ordered = [];
278
+ const projectsMap = new Map(projects.map(p => [p.name, p]));
279
+
280
+ // 按保存的顺序添加项目
281
+ for (const projectName of savedOrder) {
282
+ if (projectsMap.has(projectName)) {
283
+ ordered.push(projectsMap.get(projectName));
284
+ projectsMap.delete(projectName);
285
+ }
286
+ }
287
+
288
+ // 添加剩余的新项目(不在保存顺序中的)
289
+ ordered.push(...projectsMap.values());
290
+ globalCache.set(CODEX_PROJECTS_CACHE_KEY, ordered, PROJECTS_CACHE_TTL_MS);
291
+ return ordered;
292
+ }
293
+
294
+ // 默认按最后活动时间排序
295
+ const sorted = projects.sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0));
296
+ globalCache.set(CODEX_PROJECTS_CACHE_KEY, sorted, PROJECTS_CACHE_TTL_MS);
297
+ return sorted;
298
+ }
299
+
300
+ /**
301
+ * 根据项目名获取会话列表(归一化格式)
302
+ * @param {string} projectName - 项目名称
303
+ * @returns {Array} 归一化的会话数组
304
+ */
305
+ function getSessionsByProject(projectName) {
306
+ const cacheKey = getCodexSessionsCacheKey(projectName);
307
+ const cached = globalCache.get(cacheKey);
308
+ if (cached) {
309
+ return cached;
310
+ }
311
+
312
+ const sessions = getAllSessions();
313
+
314
+ // 获取 fork 关系
315
+ const { getForkRelations } = require('./sessions');
316
+ const forkRelations = getForkRelations();
317
+
318
+ // 获取保存的排序
319
+ const savedOrder = getSessionOrder(projectName);
320
+
321
+ // 过滤并归一化会话
322
+ const filteredSessions = sessions
323
+ .filter(session => {
324
+ const sessionProjectName = extractCodexProjectNameFromMeta(session.meta);
325
+ return sessionProjectName === projectName;
326
+ })
327
+ .map(session => {
328
+ const normalized = normalizeSession(session);
329
+ // 添加 fork 关系
330
+ normalized.forkedFrom = forkRelations[normalized.sessionId] || null;
331
+ return normalized;
332
+ });
333
+
334
+ // 应用保存的排序
335
+ let orderedSessions = filteredSessions;
336
+ if (savedOrder.length > 0) {
337
+ const orderedFromSaved = [];
338
+ const sessionMap = new Map(filteredSessions.map(s => [s.sessionId, s]));
339
+
340
+ for (const sessionId of savedOrder) {
341
+ const session = sessionMap.get(sessionId);
342
+ if (session) {
343
+ orderedFromSaved.push(session);
344
+ sessionMap.delete(sessionId);
345
+ }
346
+ }
347
+
348
+ const newSessions = [...sessionMap.values()];
349
+ newSessions.sort((a, b) => {
350
+ return new Date(b.mtime).getTime() - new Date(a.mtime).getTime();
351
+ });
352
+
353
+ // 新会话在前,旧会话在后(按保存顺序)
354
+ orderedSessions = [...newSessions, ...orderedFromSaved];
355
+ } else {
356
+ // 默认按时间倒序
357
+ orderedSessions.sort((a, b) => {
358
+ return new Date(b.mtime).getTime() - new Date(a.mtime).getTime();
359
+ });
360
+ }
361
+
362
+ globalCache.set(cacheKey, orderedSessions, PROJECT_SESSIONS_CACHE_TTL_MS);
363
+ codexSessionCacheKeys.add(cacheKey);
364
+
365
+ return orderedSessions;
366
+ }
367
+
368
+ /**
369
+ * 根据 sessionId 获取会话(归一化格式)
370
+ * @param {string} sessionId - 会话 ID
371
+ * @returns {Object|null} 归一化的会话对象
372
+ */
373
+ function getSessionById(sessionId) {
374
+ const file = findSessionFileById(sessionId);
375
+
376
+ if (!file) {
377
+ return null;
378
+ }
379
+
380
+ const session = parseSession(file.filePath);
381
+ if (!session) {
382
+ return null;
383
+ }
384
+
385
+ return {
386
+ ...normalizeSession(session),
387
+ messages: session.messages, // 包含完整消息
388
+ filePath: file.filePath
389
+ };
390
+ }
391
+
392
+ /**
393
+ * 搜索会话(全局)
394
+ * @param {string} keyword - 搜索关键词
395
+ * @returns {Array} 搜索结果
396
+ */
397
+ function searchSessions(keyword) {
398
+ const files = scanSessionFiles();
399
+ const results = [];
400
+
401
+ files.forEach(file => {
402
+ // 使用完整解析获取消息内容
403
+ const session = parseSession(file.filePath);
404
+
405
+ if (!session || !session.messages || !Array.isArray(session.messages)) {
406
+ return;
407
+ }
408
+
409
+ session.messages.forEach((message, index) => {
410
+ if (message.role !== 'user' && message.role !== 'assistant') {
411
+ return;
412
+ }
413
+
414
+ const content = (message.content || '').toLowerCase();
415
+ const keywordLower = keyword.toLowerCase();
416
+
417
+ if (content.includes(keywordLower)) {
418
+ // 提取上下文
419
+ const startIndex = Math.max(0, content.indexOf(keywordLower) - 50);
420
+ const endIndex = Math.min(content.length, content.indexOf(keywordLower) + keyword.length + 50);
421
+ const context = content.substring(startIndex, endIndex);
422
+
423
+ // 确定项目名
424
+ let projectName;
425
+ if (session.meta?.git?.repositoryUrl) {
426
+ projectName = session.meta.git.repositoryUrl.split('/').pop().replace('.git', '');
427
+ } else if (session.meta?.cwd) {
428
+ projectName = path.basename(session.meta.cwd);
429
+ } else {
430
+ projectName = 'Unknown';
431
+ }
432
+
433
+ results.push({
434
+ sessionId: file.sessionId,
435
+ projectName,
436
+ messageIndex: index,
437
+ role: message.role,
438
+ context: (startIndex > 0 ? '...' : '') + context + (endIndex < content.length ? '...' : ''),
439
+ timestamp: message.timestamp,
440
+ source: 'codex'
441
+ });
442
+ }
443
+ });
444
+ });
445
+
446
+ return results;
447
+ }
448
+
449
+ /**
450
+ * 删除项目(删除项目下所有会话)
451
+ * @param {string} projectName - 项目名称
452
+ * @returns {Object} 删除结果 { success: true, deletedCount: number }
453
+ */
454
+ function deleteProject(projectName) {
455
+ const sessions = getAllSessions();
456
+
457
+ // 找到该项目下的所有会话
458
+ const projectSessions = sessions.filter(session => {
459
+ const sessionProjectName = extractCodexProjectNameFromMeta(session.meta);
460
+ return sessionProjectName === projectName;
461
+ });
462
+
463
+ if (projectSessions.length === 0) {
464
+ throw new Error('Project not found or has no sessions');
465
+ }
466
+
467
+ // 删除所有会话文件
468
+ let deletedCount = 0;
469
+ const { getForkRelations, saveForkRelations } = require('./sessions');
470
+ const { deleteAlias } = require('./alias');
471
+ const forkRelations = getForkRelations();
472
+ let forkRelationsModified = false;
473
+
474
+ projectSessions.forEach(session => {
475
+ try {
476
+ // 删除会话文件
477
+ if (fs.existsSync(session.filePath)) {
478
+ fs.unlinkSync(session.filePath);
479
+ deletedCount++;
480
+ }
481
+
482
+ // 清理 fork 关系
483
+ if (forkRelations[session.sessionId]) {
484
+ delete forkRelations[session.sessionId];
485
+ forkRelationsModified = true;
486
+ }
487
+
488
+ // 清理指向该会话的 fork 关系
489
+ Object.keys(forkRelations).forEach(key => {
490
+ if (forkRelations[key] === session.sessionId) {
491
+ delete forkRelations[key];
492
+ forkRelationsModified = true;
493
+ }
494
+ });
495
+
496
+ // 清理别名
497
+ try {
498
+ deleteAlias(session.sessionId);
499
+ } catch (err) {
500
+ // 忽略别名不存在的错误
501
+ }
502
+ } catch (err) {
503
+ console.error(`[Codex] Failed to delete session ${session.sessionId}:`, err.message);
504
+ }
505
+ });
506
+
507
+ // 保存清理后的 fork 关系
508
+ if (forkRelationsModified) {
509
+ saveForkRelations(forkRelations);
510
+ }
511
+
512
+ // 清理项目排序配置
513
+ try {
514
+ const currentOrder = getProjectOrder();
515
+ const newOrder = currentOrder.filter(name => name !== projectName);
516
+ if (newOrder.length !== currentOrder.length) {
517
+ saveProjectOrder(newOrder);
518
+ }
519
+ } catch (err) {
520
+ console.error('[Codex] Failed to clean project order:', err.message);
521
+ }
522
+
523
+ // 清理会话排序配置
524
+ try {
525
+ saveSessionOrder(projectName, []);
526
+ } catch (err) {
527
+ console.error('[Codex] Failed to clean session order:', err.message);
528
+ }
529
+
530
+ invalidateProjectAndSessionCountsCache();
531
+ invalidateCodexSessionCaches();
532
+ return { success: true, deletedCount };
533
+ }
534
+
535
+ /**
536
+ * 获取最近的会话(跨项目)
537
+ * @param {number} limit - 返回数量限制,默认 5
538
+ * @returns {Array} 最近会话数组
539
+ */
540
+ function getRecentSessions(limit = 5) {
541
+ const sessions = getAllSessions();
542
+
543
+ // 获取 fork 关系和别名
544
+ const { getForkRelations } = require('./sessions');
545
+ const { loadAliases } = require('./alias');
546
+ const forkRelations = getForkRelations();
547
+ const aliases = loadAliases();
548
+
549
+ // 归一化所有会话
550
+ const allNormalizedSessions = sessions.map(session => {
551
+ const normalized = normalizeSession(session);
552
+
553
+ // 添加项目信息
554
+ const projectName = extractCodexProjectNameFromMeta(session.meta) || 'Unknown';
555
+ const projectPath = (typeof session.meta.cwd === 'string' && session.meta.cwd.trim())
556
+ ? session.meta.cwd
557
+ : projectName;
558
+
559
+ return {
560
+ ...normalized,
561
+ forkedFrom: forkRelations[normalized.sessionId] || null,
562
+ alias: aliases[normalized.sessionId] || null,
563
+ projectName: projectName,
564
+ projectDisplayName: projectName,
565
+ projectFullPath: projectPath
566
+ };
567
+ });
568
+
569
+ // 按 mtime 倒序排序,取前 N 个
570
+ return allNormalizedSessions
571
+ .sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime())
572
+ .slice(0, limit);
573
+ }
574
+
575
+ /**
576
+ * 删除一个会话
577
+ * @param {string} sessionId - 会话 ID
578
+ * @returns {Object} 删除结果 { success: true }
579
+ */
580
+ function deleteSession(sessionId) {
581
+ const targetFile = findSessionFileById(sessionId);
582
+
583
+ if (!targetFile) {
584
+ throw new Error('Session not found');
585
+ }
586
+
587
+ // 删除会话文件
588
+ fs.unlinkSync(targetFile.filePath);
589
+
590
+ // 清理 fork 关系
591
+ const { getForkRelations, saveForkRelations } = require('./sessions');
592
+ const forkRelations = getForkRelations();
593
+
594
+ // 删除作为源的 fork 关系
595
+ delete forkRelations[sessionId];
596
+
597
+ // 删除所有指向该会话的 fork 关系
598
+ Object.keys(forkRelations).forEach(key => {
599
+ if (forkRelations[key] === sessionId) {
600
+ delete forkRelations[key];
601
+ }
602
+ });
603
+
604
+ saveForkRelations(forkRelations);
605
+
606
+ // 清理别名
607
+ const { deleteAlias } = require('./alias');
608
+ try {
609
+ deleteAlias(sessionId);
610
+ } catch (err) {
611
+ // 忽略别名不存在的错误
612
+ }
613
+
614
+ invalidateProjectAndSessionCountsCache();
615
+ invalidateCodexSessionCaches();
616
+ return { success: true };
617
+ }
618
+
619
+ /**
620
+ * Fork 一个会话(创建副本)
621
+ * @param {string} sessionId - 原会话 ID
622
+ * @returns {Object} Fork 结果 { newSessionId, forkedFrom }
623
+ */
624
+ function forkSession(sessionId) {
625
+ const sourceFile = findSessionFileById(sessionId);
626
+
627
+ if (!sourceFile) {
628
+ throw new Error('Session not found');
629
+ }
630
+
631
+ // 读取原会话文件内容
632
+ const content = fs.readFileSync(sourceFile.filePath, 'utf8');
633
+
634
+ // 生成新的 session ID (使用 crypto.randomUUID 生成 v4 UUID)
635
+ const crypto = require('crypto');
636
+ const newSessionId = crypto.randomUUID();
637
+
638
+ // 生成新的时间戳(Codex 格式:YYYY-MM-DDTHH-MM-SS)
639
+ const now = new Date();
640
+ const timestamp = now.toISOString()
641
+ .replace(/\.\d{3}Z$/, '') // 移除毫秒和 Z
642
+ .replace(/:/g, '-'); // 将冒号替换为破折号
643
+
644
+ // 生成新文件路径(按当前日期组织)
645
+ const year = now.getFullYear();
646
+ const month = String(now.getMonth() + 1).padStart(2, '0');
647
+ const day = String(now.getDate()).padStart(2, '0');
648
+
649
+ const targetDir = path.join(getSessionsDir(), String(year), month, day);
650
+
651
+ // 确保目标目录存在
652
+ if (!fs.existsSync(targetDir)) {
653
+ fs.mkdirSync(targetDir, { recursive: true });
654
+ }
655
+
656
+ const newFileName = `rollout-${timestamp}-${newSessionId}.jsonl`;
657
+ const newFilePath = path.join(targetDir, newFileName);
658
+
659
+ // 写入新文件
660
+ fs.writeFileSync(newFilePath, content, 'utf8');
661
+
662
+ // 保存 fork 关系(复用 Claude Code 的 fork 关系存储)
663
+ const { getForkRelations, saveForkRelations } = require('./sessions');
664
+ const forkRelations = getForkRelations();
665
+ forkRelations[newSessionId] = sessionId;
666
+ saveForkRelations(forkRelations);
667
+
668
+ invalidateProjectAndSessionCountsCache();
669
+ invalidateCodexSessionCaches();
670
+ return {
671
+ newSessionId,
672
+ forkedFrom: sessionId,
673
+ newFilePath
674
+ };
675
+ }
676
+
677
+ /**
678
+ * 获取会话排序(按项目)
679
+ * @param {string} projectName - 项目名称
680
+ * @returns {Array} 会话 ID 数组
681
+ */
682
+ function getSessionOrder(projectName) {
683
+ const { getSessionOrder: getClaudeSessionOrder } = require('./sessions');
684
+ // 复用 Claude Code 的排序存储,使用 "codex-" 前缀区分
685
+ return getClaudeSessionOrder(`codex-${projectName}`);
686
+ }
687
+
688
+ /**
689
+ * 保存会话排序
690
+ * @param {string} projectName - 项目名称
691
+ * @param {Array} order - 会话 ID 数组
692
+ */
693
+ function saveSessionOrder(projectName, order) {
694
+ const { saveSessionOrder: saveClaudeSessionOrder } = require('./sessions');
695
+ // 复用 Claude Code 的排序存储,使用 "codex-" 前缀区分
696
+ saveClaudeSessionOrder(`codex-${projectName}`, order);
697
+ const cacheKey = getCodexSessionsCacheKey(projectName);
698
+ globalCache.delete(cacheKey);
699
+ codexSessionCacheKeys.delete(cacheKey);
700
+ }
701
+
702
+ /**
703
+ * 获取项目排序
704
+ * @returns {Array} 项目名称数组
705
+ */
706
+ function getProjectOrder() {
707
+ const { getProjectOrder: getClaudeProjectOrder } = require('./sessions');
708
+ const { getCodexDir } = require('./codex-config');
709
+ // 复用 Claude Code 的排序存储,使用特殊的配置对象标识 Codex
710
+ return getClaudeProjectOrder({ projectsDir: getCodexDir() });
711
+ }
712
+
713
+ /**
714
+ * 保存项目排序
715
+ * @param {Array} order - 项目名称数组
716
+ */
717
+ function saveProjectOrder(order) {
718
+ const { saveProjectOrder: saveClaudeProjectOrder } = require('./sessions');
719
+ const { getCodexDir } = require('./codex-config');
720
+ // 复用 Claude Code 的排序存储
721
+ saveClaudeProjectOrder({ projectsDir: getCodexDir() }, order);
722
+ globalCache.delete(CODEX_PROJECTS_CACHE_KEY);
723
+ }
724
+
725
+ function invalidateProjectAndSessionCountsCache() {
726
+ countsCache.expiresAt = 0;
727
+ }
728
+
729
+ function invalidateCodexSessionCaches(options = {}) {
730
+ scanFilesCache.expiresAt = 0;
731
+ sessionFileIndexCache.expiresAt = 0;
732
+ allSessionsCache.expiresAt = 0;
733
+ globalCache.delete(CODEX_PROJECTS_CACHE_KEY);
734
+
735
+ if (options.projectName) {
736
+ const cacheKey = getCodexSessionsCacheKey(options.projectName);
737
+ globalCache.delete(cacheKey);
738
+ codexSessionCacheKeys.delete(cacheKey);
739
+ return;
740
+ }
741
+
742
+ for (const key of codexSessionCacheKeys) {
743
+ globalCache.delete(key);
744
+ }
745
+ codexSessionCacheKeys.clear();
746
+ }
747
+
748
+ function extractCodexProjectNameFromMeta(metaPayload = {}) {
749
+ const repoUrl = metaPayload?.git?.repository_url || metaPayload?.git?.repositoryUrl;
750
+ if (typeof repoUrl === 'string' && repoUrl.trim()) {
751
+ const parsedName = repoUrl.split('/').pop();
752
+ if (parsedName) {
753
+ const normalized = parsedName.replace(/\.git$/i, '').trim();
754
+ if (normalized) return normalized;
755
+ }
756
+ }
757
+
758
+ const cwd = metaPayload?.cwd;
759
+ if (typeof cwd === 'string' && cwd.trim()) {
760
+ return path.basename(cwd.trim());
761
+ }
762
+
763
+ return '';
764
+ }
765
+
766
+ function normalizeSessionMetaFromPayload(payload = {}, fallbackTimestamp = null) {
767
+ return {
768
+ sessionId: payload.id,
769
+ timestamp: payload.timestamp || normalizeTimestampFromFilename(fallbackTimestamp),
770
+ cwd: payload.cwd,
771
+ cliVersion: payload.cli_version,
772
+ provider: payload.model_provider,
773
+ git: payload.git ? {
774
+ branch: payload.git.branch,
775
+ commitHash: payload.git.commit_hash || payload.git.commitHash,
776
+ repositoryUrl: payload.git.repository_url || payload.git.repositoryUrl
777
+ } : null
778
+ };
779
+ }
780
+
781
+ function normalizeTimestampFromFilename(raw = '') {
782
+ if (!raw || typeof raw !== 'string') return null;
783
+ const [date, timePart] = raw.split('T');
784
+ if (!date || !timePart) return null;
785
+ return `${date}T${timePart.replace(/-/g, ':')}Z`;
786
+ }
787
+
788
+ function extractCodexPreviewFromResponseItem(payload = {}) {
789
+ if (payload?.type !== 'message' || payload?.role !== 'user') {
790
+ return '';
791
+ }
792
+
793
+ const contentParts = Array.isArray(payload.content) ? payload.content : [];
794
+ const text = contentParts
795
+ .map(item => item?.text || item?.input_text || '')
796
+ .join('\n')
797
+ .trim();
798
+
799
+ if (!text || text === 'Warmup' || text.startsWith('<environment_context>')) {
800
+ return '';
801
+ }
802
+
803
+ return text.substring(0, 100);
804
+ }
805
+
806
+ function readSessionMetaSummaryFast(filePath) {
807
+ let fd;
808
+ try {
809
+ fd = fs.openSync(filePath, 'r');
810
+ const buffer = Buffer.alloc(FAST_META_READ_BYTES);
811
+ const bytesRead = fs.readSync(fd, buffer, 0, FAST_META_READ_BYTES, 0);
812
+ if (bytesRead <= 0) return null;
813
+
814
+ const chunk = buffer.toString('utf8', 0, bytesRead);
815
+ const lines = chunk.split('\n');
816
+
817
+ let payload = null;
818
+ let preview = '';
819
+
820
+ for (const line of lines) {
821
+ const trimmed = line.trim();
822
+ if (!trimmed) continue;
823
+ let parsed;
824
+ try {
825
+ parsed = JSON.parse(trimmed);
826
+ } catch (err) {
827
+ continue;
828
+ }
829
+
830
+ if (!payload && parsed?.type === 'session_meta' && parsed?.payload && typeof parsed.payload === 'object') {
831
+ payload = parsed.payload;
832
+ }
833
+
834
+ if (!preview && parsed?.type === 'response_item' && parsed?.payload) {
835
+ preview = extractCodexPreviewFromResponseItem(parsed.payload) || preview;
836
+ }
837
+
838
+ if (payload && preview) {
839
+ break;
840
+ }
841
+ }
842
+
843
+ if (!payload) return null;
844
+ return { payload, preview };
845
+ } catch (err) {
846
+ return null;
847
+ } finally {
848
+ if (fd !== undefined) {
849
+ try {
850
+ fs.closeSync(fd);
851
+ } catch (err) {
852
+ // ignore close errors
853
+ }
854
+ }
855
+ }
856
+ }
857
+
858
+ function readSessionMetaPayloadFast(filePath) {
859
+ const summary = readSessionMetaSummaryFast(filePath);
860
+ return summary?.payload || null;
861
+ }
862
+
863
+ function findSessionFileById(sessionId) {
864
+ const now = Date.now();
865
+ if (sessionFileIndexCache.expiresAt > now) {
866
+ return sessionFileIndexCache.value.get(sessionId) || null;
867
+ }
868
+
869
+ scanSessionFiles();
870
+ if (sessionFileIndexCache.expiresAt > Date.now()) {
871
+ return sessionFileIndexCache.value.get(sessionId) || null;
872
+ }
873
+ return null;
874
+ }
875
+
876
+ function calculateProjectAndSessionCounts() {
877
+ const sessions = scanSessionFiles();
878
+ if (sessions.length === 0) {
879
+ return EMPTY_COUNTS;
880
+ }
881
+
882
+ const projectNames = new Set();
883
+ sessions.forEach((session) => {
884
+ const payload = readSessionMetaPayloadFast(session.filePath);
885
+ const projectName = extractCodexProjectNameFromMeta(payload || {});
886
+ if (projectName) {
887
+ projectNames.add(projectName);
888
+ }
889
+ });
890
+
891
+ return {
892
+ projectCount: projectNames.size,
893
+ sessionCount: sessions.length
894
+ };
895
+ }
896
+
897
+ /**
898
+ * 获取 Codex 项目与会话数量(用于仪表盘轻量统计)
899
+ */
900
+ function getProjectAndSessionCounts() {
901
+ const now = Date.now();
902
+ if (countsCache.expiresAt > now) {
903
+ return countsCache.value;
904
+ }
905
+
906
+ try {
907
+ const counts = calculateProjectAndSessionCounts();
908
+ countsCache = {
909
+ value: counts,
910
+ expiresAt: now + COUNTS_CACHE_TTL_MS
911
+ };
912
+ return counts;
913
+ } catch (err) {
914
+ return countsCache.value || EMPTY_COUNTS;
915
+ }
916
+ }
917
+
918
+ module.exports = {
919
+ getSessionsDir,
920
+ scanSessionFiles,
921
+ getAllSessions,
922
+ getProjects,
923
+ getSessionsByProject,
924
+ getSessionById,
925
+ searchSessions,
926
+ normalizeSession,
927
+ forkSession,
928
+ deleteSession,
929
+ deleteProject,
930
+ getRecentSessions,
931
+ getSessionOrder,
932
+ saveSessionOrder,
933
+ getProjectOrder,
934
+ saveProjectOrder,
935
+ getProjectAndSessionCounts
936
+ };