coding-tool-x 3.5.5 → 3.5.7

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 (40) hide show
  1. package/README.md +25 -4
  2. package/bin/ctx.js +6 -1
  3. package/dist/web/assets/{Analytics-gvYu5sCM.js → Analytics-C6DEmD3D.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-CPlH8Ehd.js → ConfigTemplates-Cf_iTpC4.js} +1 -1
  5. package/dist/web/assets/{Home-B-qbu3uk.js → Home-BtBmYLJ1.js} +1 -1
  6. package/dist/web/assets/{PluginManager-B2tQ_YUq.js → PluginManager-DEk8vSw5.js} +1 -1
  7. package/dist/web/assets/{ProjectList-kDadoXXs.js → ProjectList-BMVhA_Kh.js} +1 -1
  8. package/dist/web/assets/{SessionList-eLgITwTV.js → SessionList-B5ioAXxg.js} +1 -1
  9. package/dist/web/assets/{SkillManager-B7zEB5Op.js → SkillManager-DcZOiiSf.js} +1 -1
  10. package/dist/web/assets/{WorkspaceManager-C-RzB3ud.js → WorkspaceManager-BHqI8aGV.js} +1 -1
  11. package/dist/web/assets/{icons-DlxD2wZJ.js → icons-CQuif85v.js} +1 -1
  12. package/dist/web/assets/index-CtByKdkA.js +2 -0
  13. package/dist/web/assets/{index-BHeh2z0i.css → index-VGAxnLqi.css} +1 -1
  14. package/dist/web/index.html +3 -3
  15. package/docs/Caddyfile.example +19 -0
  16. package/docs/reverse-proxy-https.md +57 -0
  17. package/package.json +2 -1
  18. package/src/commands/daemon.js +33 -5
  19. package/src/commands/stats.js +41 -4
  20. package/src/commands/ui.js +12 -3
  21. package/src/config/paths.js +6 -0
  22. package/src/index.js +125 -34
  23. package/src/server/api/codex-sessions.js +6 -3
  24. package/src/server/api/dashboard.js +25 -1
  25. package/src/server/api/gemini-sessions.js +6 -3
  26. package/src/server/api/hooks.js +17 -1
  27. package/src/server/api/opencode-sessions.js +6 -3
  28. package/src/server/api/plugins.js +24 -33
  29. package/src/server/api/sessions.js +6 -3
  30. package/src/server/index.js +31 -9
  31. package/src/server/services/codex-sessions.js +107 -9
  32. package/src/server/services/https-cert.js +171 -0
  33. package/src/server/services/network-access.js +61 -2
  34. package/src/server/services/notification-hooks.js +181 -16
  35. package/src/server/services/plugins-service.js +502 -44
  36. package/src/server/services/session-launch-command.js +81 -0
  37. package/src/server/services/sessions.js +103 -33
  38. package/src/server/services/web-ui-runtime.js +54 -0
  39. package/src/server/websocket-server.js +35 -4
  40. package/dist/web/assets/index-DG00t-zy.js +0 -2
@@ -26,6 +26,30 @@ const { getTodayStatistics: getCodexTodayStatistics } = require('../services/cod
26
26
  const { getTodayStatistics: getGeminiTodayStatistics } = require('../services/gemini-statistics-service');
27
27
  const { getTodayStatistics: getOpenCodeTodayStatistics } = require('../services/opencode-statistics-service');
28
28
 
29
+ function filterEntriesByToolType(entries = {}, toolType) {
30
+ return Object.fromEntries(
31
+ Object.entries(entries || {}).filter(([, value]) => !toolType || !value?.toolType || value.toolType === toolType)
32
+ );
33
+ }
34
+
35
+ function extractToolScopedStats(stats, toolType) {
36
+ if (!stats || !toolType || !stats.byToolType) {
37
+ return stats;
38
+ }
39
+
40
+ const toolScope = stats.byToolType[toolType] || {};
41
+ return {
42
+ ...stats,
43
+ summary: {
44
+ requests: toolScope.requests || 0,
45
+ tokens: toolScope.tokens?.total || 0,
46
+ cost: toolScope.cost || 0
47
+ },
48
+ byChannel: filterEntriesByToolType(stats.byChannel, toolType),
49
+ byModel: filterEntriesByToolType(stats.byModel, toolType)
50
+ };
51
+ }
52
+
29
53
  /**
30
54
  * GET /api/dashboard/init
31
55
  * 聚合首页所需的所有数据,一次请求返回
@@ -123,7 +147,7 @@ router.get('/init', async (req, res) => {
123
147
  opencode: opencodeCounts || { projectCount: 0, sessionCount: 0 }
124
148
  },
125
149
  todayStats: {
126
- claude: formatStats(claudeTodayStats),
150
+ claude: formatStats(extractToolScopedStats(claudeTodayStats, 'claude-code')),
127
151
  codex: formatStats(codexTodayStats),
128
152
  gemini: formatStats(geminiTodayStats),
129
153
  opencode: formatStats(opencodeTodayStats)
@@ -13,6 +13,7 @@ const {
13
13
  } = require('../services/gemini-sessions');
14
14
  const { isGeminiInstalled } = require('../services/gemini-config');
15
15
  const { loadAliases } = require('../services/alias');
16
+ const { buildLaunchCommand } = require('../services/session-launch-command');
16
17
 
17
18
  module.exports = (config) => {
18
19
  /**
@@ -407,9 +408,11 @@ module.exports = (config) => {
407
408
  const resumeIndex = sessionIndex + 1;
408
409
 
409
410
  // 构建 Gemini CLI 命令(使用 --resume <index> 恢复特定会话)
410
- const command = `gemini --resume ${resumeIndex}`;
411
- const quotedCwd = `"${String(projectPath).replace(/"/g, '\\"')}"`;
412
- const copyCommand = `cd ${quotedCwd} && ${command}`;
411
+ const { command, copyCommand } = buildLaunchCommand({
412
+ cwd: projectPath,
413
+ executable: 'gemini',
414
+ args: ['--resume', String(resumeIndex)]
415
+ });
413
416
 
414
417
  res.json({
415
418
  success: true,
@@ -1,7 +1,7 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
3
  const notificationHooks = require('../services/notification-hooks');
4
- const { createSameOriginGuard } = require('../services/network-access');
4
+ const { createSameOriginGuard, isLoopbackRequest } = require('../services/network-access');
5
5
 
6
6
  router.use(createSameOriginGuard({
7
7
  message: '禁止跨站访问通知配置接口'
@@ -42,4 +42,20 @@ router.post('/test', async (req, res) => {
42
42
  }
43
43
  });
44
44
 
45
+ router.post('/browser-event', (req, res) => {
46
+ try {
47
+ if (!isLoopbackRequest(req)) {
48
+ return res.status(403).json({
49
+ error: '浏览器提醒事件仅允许本机触发'
50
+ });
51
+ }
52
+
53
+ notificationHooks.emitBrowserNotificationEvent(req.body || {});
54
+ res.json({ success: true });
55
+ } catch (error) {
56
+ console.error('Error emitting browser notification event:', error);
57
+ res.status(error.statusCode || 500).json({ error: error.message });
58
+ }
59
+ });
60
+
45
61
  module.exports = router;
@@ -14,6 +14,7 @@ const {
14
14
  } = require('../services/opencode-sessions');
15
15
  const { loadAliases } = require('../services/alias');
16
16
  const { broadcastLog } = require('../websocket-server');
17
+ const { buildLaunchCommand } = require('../services/session-launch-command');
17
18
  const { HOME_DIR } = require('../../config/paths');
18
19
 
19
20
  function isNotFoundError(error) {
@@ -350,9 +351,11 @@ module.exports = (config) => {
350
351
  const projects = getProjects();
351
352
  const project = projects.find(p => p.name === projectName);
352
353
  const cwd = session.directory || project?.fullPath || HOME_DIR;
353
- const command = `opencode -r ${sessionId}`;
354
- const quotedCwd = `"${String(cwd).replace(/"/g, '\\"')}"`;
355
- const copyCommand = `cd ${quotedCwd} && ${command}`;
354
+ const { command, copyCommand } = buildLaunchCommand({
355
+ cwd,
356
+ executable: 'opencode',
357
+ args: ['-r', sessionId]
358
+ });
356
359
 
357
360
  broadcastLog({
358
361
  type: 'action',
@@ -45,6 +45,24 @@ function extractRepoPayload(source = {}) {
45
45
  };
46
46
  }
47
47
 
48
+ function extractPluginQueryInfo(name, query = {}) {
49
+ return {
50
+ name,
51
+ repoId: query.repoId || '',
52
+ repoProvider: query.repoProvider || '',
53
+ repoHost: query.repoHost || '',
54
+ repoOwner: query.repoOwner || '',
55
+ repoName: query.repoName || '',
56
+ repoBranch: query.repoBranch || '',
57
+ directory: query.directory || '',
58
+ source: query.source || '',
59
+ repoUrl: query.repoUrl || '',
60
+ repoProjectPath: query.repoProjectPath || '',
61
+ repoLocalPath: query.repoLocalPath || '',
62
+ installPath: query.installPath || ''
63
+ };
64
+ }
65
+
48
66
  function sanitizeRepo(repo = {}) {
49
67
  const token = String(repo.token || '').trim();
50
68
  const sanitized = {
@@ -431,36 +449,7 @@ router.get('/:name/readme', async (req, res) => {
431
449
  try {
432
450
  const { platform, service } = getPluginsService(req);
433
451
  const { name } = req.params;
434
- const {
435
- repoId,
436
- repoProvider,
437
- repoHost,
438
- repoOwner,
439
- repoName,
440
- repoBranch,
441
- directory,
442
- source,
443
- repoUrl,
444
- repoProjectPath,
445
- repoLocalPath,
446
- installPath
447
- } = req.query;
448
-
449
- const pluginInfo = {
450
- name,
451
- repoId,
452
- repoProvider,
453
- repoHost,
454
- repoOwner,
455
- repoName,
456
- repoBranch,
457
- directory,
458
- source,
459
- repoUrl,
460
- repoProjectPath,
461
- repoLocalPath,
462
- installPath
463
- };
452
+ const pluginInfo = extractPluginQueryInfo(name, req.query);
464
453
 
465
454
  const readme = await service.getPluginReadme(pluginInfo);
466
455
 
@@ -483,12 +472,14 @@ router.get('/:name/readme', async (req, res) => {
483
472
  * 获取单个插件详情
484
473
  * GET /api/plugins/:name
485
474
  */
486
- router.get('/:name', (req, res) => {
475
+ router.get('/:name', async (req, res) => {
487
476
  try {
488
477
  const { platform, service } = getPluginsService(req);
489
478
  const { name } = req.params;
490
-
491
- const plugin = service.getPlugin(name);
479
+ const pluginInfo = extractPluginQueryInfo(name, req.query);
480
+ const plugin = typeof service.getPluginDetail === 'function'
481
+ ? await service.getPluginDetail(pluginInfo)
482
+ : service.getPlugin(name);
492
483
 
493
484
  if (!plugin) {
494
485
  return res.status(404).json({
@@ -6,6 +6,7 @@ const readline = require('readline');
6
6
  const { getSessionsForProject, deleteSession, forkSession, saveSessionOrder, parseRealProjectPath, searchSessions, getRecentSessions, searchSessionsAcrossProjects, hasActualMessages } = require('../services/sessions');
7
7
  const { loadAliases } = require('../services/alias');
8
8
  const { broadcastLog } = require('../websocket-server');
9
+ const { buildLaunchCommand } = require('../services/session-launch-command');
9
10
  const { NATIVE_PATHS } = require('../../config/paths');
10
11
  const CLAUDE_PROJECTS_DIR = NATIVE_PATHS.claude.projects;
11
12
 
@@ -570,9 +571,11 @@ module.exports = (config) => {
570
571
  timestamp: Date.now()
571
572
  });
572
573
 
573
- const command = `claude -r ${sessionId}`;
574
- const quotedCwd = `"${String(cwd).replace(/"/g, '\\"')}"`;
575
- const copyCommand = `cd ${quotedCwd} && ${command}`;
574
+ const { command, copyCommand } = buildLaunchCommand({
575
+ cwd,
576
+ executable: 'claude',
577
+ args: ['-r', sessionId]
578
+ });
576
579
 
577
580
  res.json({
578
581
  success: true,
@@ -1,4 +1,5 @@
1
1
  const express = require('express');
2
+ const https = require('https');
2
3
  const path = require('path');
3
4
  const chalk = require('chalk');
4
5
  const { loadConfig } = require('../config/loader');
@@ -21,8 +22,10 @@ const { startProxyServer } = require('./proxy-server');
21
22
  const { startCodexProxyServer } = require('./codex-proxy-server');
22
23
  const { startGeminiProxyServer } = require('./gemini-proxy-server');
23
24
  const { startOpenCodeProxyServer, collectProxyModelList } = require('./opencode-proxy-server');
24
- const { createRemoteMutationGuard } = require('./services/network-access');
25
+ const { createRemoteMutationGuard, isRemoteMutationAllowed } = require('./services/network-access');
25
26
  const { createApiRequestLogger } = require('./services/request-logger');
27
+ const { getLocalHttpsCredentials } = require('./services/https-cert');
28
+ const { getWebUiProtocol, isHttpsEnabled, getWebUiBaseUrl, getWebSocketBaseUrl } = require('./services/web-ui-runtime');
26
29
 
27
30
  function getInquirer() {
28
31
  return require('inquirer');
@@ -59,6 +62,8 @@ function printPortToolIssue(issue = getPortToolIssue()) {
59
62
  async function startServer(port, host = '127.0.0.1', options = {}) {
60
63
  ensureStorageDirMigrated();
61
64
  const config = loadConfig();
65
+ const webUiProtocol = getWebUiProtocol({ https: options.https });
66
+ const httpsEnabled = isHttpsEnabled({ protocol: webUiProtocol });
62
67
  // 使用配置的端口,如果没有传入参数
63
68
  if (!port) {
64
69
  port = config.ports?.webUI || 19999;
@@ -132,7 +137,9 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
132
137
 
133
138
  const app = express();
134
139
  const lanMode = host === '0.0.0.0';
135
- const allowRemoteMutation = process.env.CC_TOOL_ALLOW_REMOTE_WRITE === 'true';
140
+ const allowRemoteMutation = lanMode
141
+ ? isRemoteMutationAllowed(process.env.CC_TOOL_ALLOW_REMOTE_WRITE)
142
+ : true;
136
143
 
137
144
  // Middleware
138
145
  app.use(express.json({ limit: '100mb' }));
@@ -156,7 +163,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
156
163
  app.use('/api', createRemoteMutationGuard({
157
164
  enabled: true,
158
165
  allowRemoteMutation,
159
- message: '出于安全考虑,LAN 模式默认仅允许本机执行写操作。可设置 CC_TOOL_ALLOW_REMOTE_WRITE=true 覆盖。'
166
+ message: '当前已禁用 LAN 远程写操作,可设置 CC_TOOL_ALLOW_REMOTE_WRITE=true 重新开启。'
160
167
  }));
161
168
 
162
169
  }
@@ -245,7 +252,18 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
245
252
  }
246
253
 
247
254
  // Start server(确保监听成功后才返回,避免命令误报“已启动”)
248
- const server = app.listen(port, host);
255
+ let httpsCertificate = null;
256
+ const server = httpsEnabled
257
+ ? (() => {
258
+ httpsCertificate = getLocalHttpsCredentials();
259
+ const httpsServer = https.createServer({
260
+ key: httpsCertificate.key,
261
+ cert: httpsCertificate.cert
262
+ }, app);
263
+ httpsServer.listen(port, host);
264
+ return httpsServer;
265
+ })()
266
+ : app.listen(port, host);
249
267
  await new Promise((resolve) => {
250
268
  const onListening = () => {
251
269
  server.off('error', onError);
@@ -272,18 +290,22 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
272
290
  console.log(`\n[START] Coding-Tool Web UI running at:`);
273
291
  if (host === '0.0.0.0') {
274
292
  console.log(chalk.yellow(` [WARN] 警告: 服务正在监听所有网络接口 (LAN 可访问)`));
275
- console.log(` http://localhost:${port}`);
276
- console.log(chalk.gray(` http://<your-ip>:${port} (LAN 访问)`));
293
+ console.log(` ${getWebUiBaseUrl(port, { protocol: webUiProtocol })}`);
294
+ console.log(chalk.gray(` ${webUiProtocol}://<your-ip>:${port} (LAN 访问)`));
277
295
  } else {
278
- console.log(` http://localhost:${port}`);
296
+ console.log(` ${getWebUiBaseUrl(port, { protocol: webUiProtocol })}`);
279
297
  }
280
298
 
281
299
  // 附加 WebSocket 服务器到同一个端口
282
300
  attachWebSocketServer(server, { host });
283
- console.log(` ws://localhost:${port}/ws\n`);
301
+ console.log(` ${getWebSocketBaseUrl(port, { protocol: webUiProtocol })}/ws\n`);
302
+
303
+ if (httpsEnabled && httpsCertificate?.generated) {
304
+ console.log(chalk.gray(` [LOCK] 已生成本地 HTTPS 证书: ${httpsCertificate.certPath}`));
305
+ }
284
306
 
285
307
  if (host === '0.0.0.0' && !allowRemoteMutation) {
286
- console.log(chalk.yellow(' [LOCK] 已启用 LAN 安全保护:远程写操作默认禁用'));
308
+ console.log(chalk.yellow(' [LOCK] 已禁用 LAN 远程写操作 (CC_TOOL_ALLOW_REMOTE_WRITE=false)'));
287
309
  }
288
310
  // 自动恢复代理状态
289
311
  autoRestoreProxies();
@@ -10,6 +10,7 @@ const ALL_SESSIONS_CACHE_TTL_MS = 20 * 1000;
10
10
  const PROJECTS_CACHE_TTL_MS = 300 * 1000;
11
11
  const PROJECT_SESSIONS_CACHE_TTL_MS = 120 * 1000;
12
12
  const FAST_META_READ_BYTES = 64 * 1024;
13
+ const MAX_SESSION_META_SUMMARY_CACHE_ENTRIES = 5000;
13
14
  const EMPTY_COUNTS = Object.freeze({ projectCount: 0, sessionCount: 0 });
14
15
 
15
16
  let countsCache = {
@@ -32,6 +33,8 @@ let allSessionsCache = {
32
33
  value: []
33
34
  };
34
35
 
36
+ let sessionMetaSummaryCache = new Map();
37
+
35
38
  const CODEX_PROJECTS_CACHE_KEY = `${CacheKeys.PROJECTS}codex`;
36
39
  const codexSessionCacheKeys = new Set();
37
40
 
@@ -39,6 +42,47 @@ function getCodexSessionsCacheKey(projectName) {
39
42
  return `${CacheKeys.SESSIONS}codex:${projectName}`;
40
43
  }
41
44
 
45
+ function getSessionMetaSummaryCacheKey(filePath, fileMeta = {}) {
46
+ return `${filePath}:${fileMeta.mtimeMs || 0}:${fileMeta.size || 0}`;
47
+ }
48
+
49
+ function getCachedSessionMetaSummary(filePath, fileMeta) {
50
+ if (!fileMeta) {
51
+ return null;
52
+ }
53
+ return sessionMetaSummaryCache.get(getSessionMetaSummaryCacheKey(filePath, fileMeta)) || null;
54
+ }
55
+
56
+ function setCachedSessionMetaSummary(filePath, fileMeta, summary) {
57
+ if (!fileMeta || !summary?.payload) {
58
+ return;
59
+ }
60
+
61
+ const cacheKey = getSessionMetaSummaryCacheKey(filePath, fileMeta);
62
+ sessionMetaSummaryCache.delete(cacheKey);
63
+ sessionMetaSummaryCache.set(cacheKey, summary);
64
+
65
+ while (sessionMetaSummaryCache.size > MAX_SESSION_META_SUMMARY_CACHE_ENTRIES) {
66
+ const oldestKey = sessionMetaSummaryCache.keys().next().value;
67
+ if (!oldestKey) {
68
+ break;
69
+ }
70
+ sessionMetaSummaryCache.delete(oldestKey);
71
+ }
72
+ }
73
+
74
+ function pruneSessionMetaSummaryCache(files = []) {
75
+ const activeKeys = new Set(
76
+ files.map(file => getSessionMetaSummaryCacheKey(file.filePath, file))
77
+ );
78
+
79
+ for (const cacheKey of sessionMetaSummaryCache.keys()) {
80
+ if (!activeKeys.has(cacheKey)) {
81
+ sessionMetaSummaryCache.delete(cacheKey);
82
+ }
83
+ }
84
+ }
85
+
42
86
  /**
43
87
  * 获取会话目录
44
88
  */
@@ -128,6 +172,7 @@ function scanSessionFiles() {
128
172
  expiresAt,
129
173
  value: new Map(parsed.map(file => [file.sessionId, file]))
130
174
  };
175
+ pruneSessionMetaSummaryCache(parsed);
131
176
 
132
177
  return parsed;
133
178
  }
@@ -145,7 +190,7 @@ function getAllSessions() {
145
190
  const files = scanSessionFiles();
146
191
 
147
192
  const parsed = files.map(file => {
148
- const fastSummary = readSessionMetaSummaryFast(file.filePath);
193
+ const fastSummary = readSessionMetaSummaryFast(file.filePath, file);
149
194
  let session = null;
150
195
 
151
196
  if (fastSummary && fastSummary.payload) {
@@ -216,7 +261,7 @@ function normalizeSession(codexSession) {
216
261
  filePath: filePath || '',
217
262
  gitBranch: meta.git?.branch || null,
218
263
  firstMessage: preview || null,
219
- forkedFrom: null, // Codex 不支持 fork
264
+ forkedFrom: null,
220
265
 
221
266
  // 额外的 Codex 特有字段(前端可能需要)
222
267
  source: 'codex'
@@ -655,9 +700,10 @@ function forkSession(sessionId) {
655
700
 
656
701
  const newFileName = `rollout-${timestamp}-${newSessionId}.jsonl`;
657
702
  const newFilePath = path.join(targetDir, newFileName);
703
+ const rewrittenContent = rewriteForkedCodexSessionContent(content, newSessionId, now.toISOString());
658
704
 
659
705
  // 写入新文件
660
- fs.writeFileSync(newFilePath, content, 'utf8');
706
+ fs.writeFileSync(newFilePath, rewrittenContent, 'utf8');
661
707
 
662
708
  // 保存 fork 关系(复用 Claude Code 的 fork 关系存储)
663
709
  const { getForkRelations, saveForkRelations } = require('./sessions');
@@ -674,6 +720,50 @@ function forkSession(sessionId) {
674
720
  };
675
721
  }
676
722
 
723
+ function rewriteForkedCodexSessionContent(content, newSessionId, nowIsoTimestamp) {
724
+ const lines = String(content || '').split('\n');
725
+
726
+ return lines.map((line) => {
727
+ if (!line.trim()) {
728
+ return line;
729
+ }
730
+
731
+ let parsed;
732
+ try {
733
+ parsed = JSON.parse(line);
734
+ } catch (err) {
735
+ return line;
736
+ }
737
+
738
+ if (parsed.type === 'session_meta' && parsed.payload && typeof parsed.payload === 'object') {
739
+ return JSON.stringify({
740
+ ...parsed,
741
+ timestamp: nowIsoTimestamp,
742
+ payload: {
743
+ ...parsed.payload,
744
+ id: newSessionId,
745
+ timestamp: nowIsoTimestamp
746
+ }
747
+ });
748
+ }
749
+
750
+ if (parsed.type === 'event' && parsed.event && typeof parsed.event === 'object' && parsed.event.type === 'session_start') {
751
+ const nextEvent = { ...parsed.event };
752
+ if (typeof nextEvent.session_id === 'string' && nextEvent.session_id.trim()) {
753
+ nextEvent.session_id = newSessionId;
754
+ }
755
+
756
+ return JSON.stringify({
757
+ ...parsed,
758
+ timestamp: nowIsoTimestamp,
759
+ event: nextEvent
760
+ });
761
+ }
762
+
763
+ return line;
764
+ }).join('\n');
765
+ }
766
+
677
767
  /**
678
768
  * 获取会话排序(按项目)
679
769
  * @param {string} projectName - 项目名称
@@ -803,7 +893,12 @@ function extractCodexPreviewFromResponseItem(payload = {}) {
803
893
  return text.substring(0, 100);
804
894
  }
805
895
 
806
- function readSessionMetaSummaryFast(filePath) {
896
+ function readSessionMetaSummaryFast(filePath, fileMeta = null) {
897
+ const cached = getCachedSessionMetaSummary(filePath, fileMeta);
898
+ if (cached) {
899
+ return cached;
900
+ }
901
+
807
902
  let fd;
808
903
  try {
809
904
  fd = fs.openSync(filePath, 'r');
@@ -841,7 +936,9 @@ function readSessionMetaSummaryFast(filePath) {
841
936
  }
842
937
 
843
938
  if (!payload) return null;
844
- return { payload, preview };
939
+ const summary = { payload, preview };
940
+ setCachedSessionMetaSummary(filePath, fileMeta, summary);
941
+ return summary;
845
942
  } catch (err) {
846
943
  return null;
847
944
  } finally {
@@ -855,8 +952,8 @@ function readSessionMetaSummaryFast(filePath) {
855
952
  }
856
953
  }
857
954
 
858
- function readSessionMetaPayloadFast(filePath) {
859
- const summary = readSessionMetaSummaryFast(filePath);
955
+ function readSessionMetaPayloadFast(filePath, fileMeta = null) {
956
+ const summary = readSessionMetaSummaryFast(filePath, fileMeta);
860
957
  return summary?.payload || null;
861
958
  }
862
959
 
@@ -881,7 +978,7 @@ function calculateProjectAndSessionCounts() {
881
978
 
882
979
  const projectNames = new Set();
883
980
  sessions.forEach((session) => {
884
- const payload = readSessionMetaPayloadFast(session.filePath);
981
+ const payload = readSessionMetaPayloadFast(session.filePath, session);
885
982
  const projectName = extractCodexProjectNameFromMeta(payload || {});
886
983
  if (projectName) {
887
984
  projectNames.add(projectName);
@@ -932,5 +1029,6 @@ module.exports = {
932
1029
  saveSessionOrder,
933
1030
  getProjectOrder,
934
1031
  saveProjectOrder,
935
- getProjectAndSessionCounts
1032
+ getProjectAndSessionCounts,
1033
+ rewriteForkedCodexSessionContent
936
1034
  };
@@ -0,0 +1,171 @@
1
+ const fs = require('fs');
2
+ const net = require('net');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const selfsigned = require('selfsigned');
6
+ const { PATHS } = require('../../config/paths');
7
+
8
+ function dedupeSorted(values = []) {
9
+ return [...new Set(values.filter(Boolean).map((value) => String(value).trim()).filter(Boolean))].sort();
10
+ }
11
+
12
+ function isIpAddress(value) {
13
+ return net.isIP(String(value || '').trim()) !== 0;
14
+ }
15
+
16
+ function isValidDnsLabel(value) {
17
+ return /^[A-Za-z0-9.-]+$/.test(String(value || '').trim());
18
+ }
19
+
20
+ function collectLocalCertificateHosts() {
21
+ const hosts = ['localhost', '127.0.0.1', '::1'];
22
+ const hostname = os.hostname();
23
+ if (isValidDnsLabel(hostname)) {
24
+ hosts.push(hostname);
25
+ }
26
+
27
+ const interfaces = os.networkInterfaces();
28
+ Object.values(interfaces).forEach((entries) => {
29
+ (entries || []).forEach((entry) => {
30
+ const address = String(entry?.address || '').trim();
31
+ if (!address) {
32
+ return;
33
+ }
34
+
35
+ if (entry?.family === 'IPv4' || entry?.family === 4) {
36
+ hosts.push(address);
37
+ return;
38
+ }
39
+
40
+ if ((entry?.family === 'IPv6' || entry?.family === 6) && address !== '::1' && !address.startsWith('fe80::')) {
41
+ hosts.push(address);
42
+ }
43
+ });
44
+ });
45
+
46
+ return dedupeSorted(hosts);
47
+ }
48
+
49
+ function buildSubjectAltNames(hosts = collectLocalCertificateHosts()) {
50
+ return dedupeSorted(hosts).map((value) => (
51
+ isIpAddress(value)
52
+ ? { type: 7, ip: value }
53
+ : { type: 2, value }
54
+ ));
55
+ }
56
+
57
+ function ensureParentDir(filePath) {
58
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
59
+ }
60
+
61
+ function readMeta(metaPath) {
62
+ if (!fs.existsSync(metaPath)) {
63
+ return null;
64
+ }
65
+
66
+ try {
67
+ return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function needsRegeneration(hosts, meta) {
74
+ if (!meta || !Array.isArray(meta.hosts)) {
75
+ return true;
76
+ }
77
+ const currentHosts = dedupeSorted(hosts);
78
+ const storedHosts = dedupeSorted(meta.hosts);
79
+ return currentHosts.length !== storedHosts.length || currentHosts.some((value, index) => value !== storedHosts[index]);
80
+ }
81
+
82
+ function generateSelfSignedCertificate(hosts) {
83
+ const primaryHost = hosts.find((value) => value === '127.0.0.1')
84
+ || hosts.find((value) => !isIpAddress(value))
85
+ || hosts[0]
86
+ || '127.0.0.1';
87
+
88
+ const attrs = [{ name: 'commonName', value: primaryHost }];
89
+ return selfsigned.generate(attrs, {
90
+ algorithm: 'sha256',
91
+ days: 3650,
92
+ keySize: 2048,
93
+ extensions: [
94
+ { name: 'basicConstraints', cA: false },
95
+ {
96
+ name: 'keyUsage',
97
+ digitalSignature: true,
98
+ keyEncipherment: true
99
+ },
100
+ {
101
+ name: 'extKeyUsage',
102
+ serverAuth: true
103
+ },
104
+ {
105
+ name: 'subjectAltName',
106
+ altNames: buildSubjectAltNames(hosts)
107
+ }
108
+ ]
109
+ });
110
+ }
111
+
112
+ function ensureLocalHttpsCertificate(options = {}) {
113
+ const keyPath = options.keyPath || PATHS.https.key;
114
+ const certPath = options.certPath || PATHS.https.cert;
115
+ const metaPath = options.metaPath || PATHS.https.meta;
116
+ const hosts = dedupeSorted(options.hosts || collectLocalCertificateHosts());
117
+ const existingMeta = readMeta(metaPath);
118
+ const hasFiles = fs.existsSync(keyPath) && fs.existsSync(certPath);
119
+
120
+ if (hasFiles && !needsRegeneration(hosts, existingMeta)) {
121
+ return {
122
+ keyPath,
123
+ certPath,
124
+ metaPath,
125
+ hosts,
126
+ generated: false
127
+ };
128
+ }
129
+
130
+ ensureParentDir(keyPath);
131
+ ensureParentDir(certPath);
132
+ ensureParentDir(metaPath);
133
+
134
+ const certificate = generateSelfSignedCertificate(hosts);
135
+ fs.writeFileSync(keyPath, certificate.private, 'utf8');
136
+ fs.writeFileSync(certPath, certificate.cert, 'utf8');
137
+ fs.writeFileSync(metaPath, JSON.stringify({
138
+ hosts,
139
+ generatedAt: new Date().toISOString()
140
+ }, null, 2), 'utf8');
141
+
142
+ return {
143
+ keyPath,
144
+ certPath,
145
+ metaPath,
146
+ hosts,
147
+ generated: true
148
+ };
149
+ }
150
+
151
+ function getLocalHttpsCredentials(options = {}) {
152
+ const certificate = ensureLocalHttpsCertificate(options);
153
+ return {
154
+ ...certificate,
155
+ key: fs.readFileSync(certificate.keyPath),
156
+ cert: fs.readFileSync(certificate.certPath)
157
+ };
158
+ }
159
+
160
+ module.exports = {
161
+ ensureLocalHttpsCertificate,
162
+ getLocalHttpsCredentials,
163
+ _test: {
164
+ dedupeSorted,
165
+ isIpAddress,
166
+ isValidDnsLabel,
167
+ collectLocalCertificateHosts,
168
+ buildSubjectAltNames,
169
+ needsRegeneration
170
+ }
171
+ };