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
@@ -16,6 +16,9 @@ const { PATHS, NATIVE_PATHS } = require('../../config/paths');
16
16
  const CLAUDE_PROJECTS_DIR = NATIVE_PATHS.claude.projects;
17
17
  const CODEX_PROJECTS_DIR = path.join(path.dirname(NATIVE_PATHS.codex.config), 'projects');
18
18
  const GEMINI_PROJECTS_DIR = path.join(path.dirname(NATIVE_PATHS.gemini.env), 'projects');
19
+ const PROJECT_PATH_CACHE_TTL_MS = 5 * 60 * 1000;
20
+ const MAX_PROJECT_PATH_CACHE_ENTRIES = 500;
21
+ let projectPathResolutionCache = new Map();
19
22
 
20
23
  // Base directory for cc-tool data
21
24
  function getCcToolDir() {
@@ -100,15 +103,82 @@ async function getProjects(config) {
100
103
  .map(entry => entry.name);
101
104
  }
102
105
 
106
+ function getProjectPathCacheKey(encodedName) {
107
+ return `${process.platform}:${encodedName}`;
108
+ }
109
+
110
+ function cloneProjectPathResolution(value) {
111
+ return {
112
+ fullPath: value?.fullPath || '',
113
+ projectName: value?.projectName || ''
114
+ };
115
+ }
116
+
117
+ function getCachedProjectPathResolution(encodedName) {
118
+ const cacheKey = getProjectPathCacheKey(encodedName);
119
+ const cached = projectPathResolutionCache.get(cacheKey);
120
+ if (!cached) {
121
+ return null;
122
+ }
123
+
124
+ if (cached.expiresAt <= Date.now()) {
125
+ projectPathResolutionCache.delete(cacheKey);
126
+ return null;
127
+ }
128
+
129
+ return cloneProjectPathResolution(cached.value);
130
+ }
131
+
132
+ function setCachedProjectPathResolution(encodedName, value) {
133
+ const cacheKey = getProjectPathCacheKey(encodedName);
134
+ projectPathResolutionCache.delete(cacheKey);
135
+ projectPathResolutionCache.set(cacheKey, {
136
+ expiresAt: Date.now() + PROJECT_PATH_CACHE_TTL_MS,
137
+ value: cloneProjectPathResolution(value)
138
+ });
139
+
140
+ while (projectPathResolutionCache.size > MAX_PROJECT_PATH_CACHE_ENTRIES) {
141
+ const oldestKey = projectPathResolutionCache.keys().next().value;
142
+ if (!oldestKey) {
143
+ break;
144
+ }
145
+ projectPathResolutionCache.delete(oldestKey);
146
+ }
147
+ }
148
+
149
+ function clearProjectPathResolutionCache(encodedName) {
150
+ if (!encodedName) {
151
+ projectPathResolutionCache.clear();
152
+ return;
153
+ }
154
+
155
+ projectPathResolutionCache.delete(getProjectPathCacheKey(encodedName));
156
+ }
157
+
103
158
  // Parse real project path from encoded name
104
159
  // macOS/Linux: "-Users-lilithgames-work-project" -> "/Users/lilithgames/work/project"
105
160
  // Windows: "C--Users-admin-Desktop-project" -> "C:\Users\admin\Desktop\project"
106
161
  function parseRealProjectPath(encodedName) {
162
+ const normalizedEncodedName = String(encodedName || '').trim();
163
+ const cached = getCachedProjectPathResolution(normalizedEncodedName);
164
+ if (cached) {
165
+ return cached;
166
+ }
167
+
107
168
  const isWindows = process.platform === 'win32';
108
- const fallbackFromSessions = tryResolvePathFromSessions(encodedName);
169
+ const fallbackFromSessions = tryResolvePathFromSessions(normalizedEncodedName);
170
+
171
+ if (fallbackFromSessions?.fullPath) {
172
+ const resolved = {
173
+ fullPath: fallbackFromSessions.fullPath,
174
+ projectName: fallbackFromSessions.projectName || path.basename(fallbackFromSessions.fullPath) || normalizedEncodedName
175
+ };
176
+ setCachedProjectPathResolution(normalizedEncodedName, resolved);
177
+ return resolved;
178
+ }
109
179
 
110
180
  // Detect Windows drive letter (e.g., "C--Users-admin")
111
- const windowsDriveMatch = encodedName.match(/^([A-Z])--(.+)$/);
181
+ const windowsDriveMatch = normalizedEncodedName.match(/^([A-Z])--(.+)$/);
112
182
 
113
183
  if (isWindows && windowsDriveMatch) {
114
184
  // Windows path with drive letter
@@ -167,13 +237,15 @@ function parseRealProjectPath(encodedName) {
167
237
  currentPath = driveLetter + ':\\' + realSegments.join('\\');
168
238
  }
169
239
 
170
- return {
171
- fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || (driveLetter + ':\\' + restPath.replace(/-/g, '\\')),
172
- projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
240
+ const resolved = {
241
+ fullPath: validateProjectPath(currentPath) || (driveLetter + ':\\' + restPath.replace(/-/g, '\\')),
242
+ projectName: realSegments[realSegments.length - 1] || normalizedEncodedName
173
243
  };
244
+ setCachedProjectPathResolution(normalizedEncodedName, resolved);
245
+ return resolved;
174
246
  } else {
175
247
  // Unix-like path (macOS/Linux) or fallback
176
- const pathStr = encodedName.replace(/^-/, '/').replace(/-/g, '/');
248
+ const pathStr = normalizedEncodedName.replace(/^-/, '/').replace(/-/g, '/');
177
249
  const segments = pathStr.split('/').filter(s => s);
178
250
 
179
251
  // Build path from left to right, checking existence
@@ -225,10 +297,12 @@ function parseRealProjectPath(encodedName) {
225
297
  currentPath = '/' + realSegments.join('/');
226
298
  }
227
299
 
228
- return {
229
- fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || pathStr,
230
- projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
300
+ const resolved = {
301
+ fullPath: validateProjectPath(currentPath) || pathStr,
302
+ projectName: realSegments[realSegments.length - 1] || normalizedEncodedName
231
303
  };
304
+ setCachedProjectPathResolution(normalizedEncodedName, resolved);
305
+ return resolved;
232
306
  }
233
307
  }
234
308
 
@@ -347,27 +421,20 @@ async function buildProjectsWithStats(config) {
347
421
  const files = await fs.promises.readdir(projectPath);
348
422
  const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
349
423
 
350
- // Filter: only count sessions that have actual messages (in parallel)
351
424
  const sessionChecks = await Promise.all(
352
- jsonlFiles.map(async (f) => {
353
- const filePath = path.join(projectPath, f);
354
- const hasMessages = await hasActualMessages(filePath);
355
- return hasMessages ? f : null;
425
+ jsonlFiles.map(async (fileName) => {
426
+ const filePath = path.join(projectPath, fileName);
427
+ const stats = await fs.promises.stat(filePath);
428
+ const hasMessages = await hasActualMessages(filePath, stats);
429
+ return hasMessages ? stats.mtime.getTime() : null;
356
430
  })
357
431
  );
358
432
 
359
- const sessionFilesWithMessages = sessionChecks.filter(f => f !== null);
360
- sessionCount = sessionFilesWithMessages.length;
433
+ const activeSessionTimes = sessionChecks.filter((mtimeMs) => Number.isFinite(mtimeMs));
434
+ sessionCount = activeSessionTimes.length;
361
435
 
362
- // Find most recent session (only from sessions with messages)
363
- if (sessionFilesWithMessages.length > 0) {
364
- const statPromises = sessionFilesWithMessages.map(async (f) => {
365
- const filePath = path.join(projectPath, f);
366
- const stat = await fs.promises.stat(filePath);
367
- return stat.mtime.getTime();
368
- });
369
- const stats = await Promise.all(statPromises);
370
- lastUsed = Math.max(...stats);
436
+ if (activeSessionTimes.length > 0) {
437
+ lastUsed = Math.max(...activeSessionTimes);
371
438
  }
372
439
  } catch (err) {
373
440
  // Ignore errors
@@ -415,9 +482,9 @@ function getProjectAndSessionCounts(config) {
415
482
  }
416
483
 
417
484
  // Check if a session file has actual messages (async with enhanced caching)
418
- async function hasActualMessages(filePath) {
485
+ async function hasActualMessages(filePath, statsOverride = null) {
419
486
  try {
420
- const stats = await fs.promises.stat(filePath);
487
+ const stats = statsOverride || await fs.promises.stat(filePath);
421
488
 
422
489
  // Check enhanced cache first
423
490
  const cacheKey = `${CacheKeys.HAS_MESSAGES}${filePath}:${stats.mtime.getTime()}`;
@@ -658,14 +725,13 @@ function saveSessionOrder(projectName, order) {
658
725
  // Delete a project (remove the entire project directory)
659
726
  function deleteProject(config, projectName) {
660
727
  const projectDir = path.join(config.projectsDir, projectName);
728
+ const existed = fs.existsSync(projectDir);
661
729
 
662
- if (!fs.existsSync(projectDir)) {
663
- throw new Error('Project not found');
730
+ if (existed) {
731
+ // Recursively delete the directory
732
+ fs.rmSync(projectDir, { recursive: true, force: true });
664
733
  }
665
734
 
666
- // Recursively delete the directory
667
- fs.rmSync(projectDir, { recursive: true, force: true });
668
-
669
735
  // Remove from order file if exists
670
736
  const order = getProjectOrder(config);
671
737
  const newOrder = order.filter(name => name !== projectName);
@@ -674,7 +740,11 @@ function deleteProject(config, projectName) {
674
740
  }
675
741
 
676
742
  invalidateProjectsCache(config);
677
- return { success: true };
743
+ clearProjectPathResolutionCache(projectName);
744
+ return {
745
+ success: true,
746
+ alreadyDeleted: !existed
747
+ };
678
748
  }
679
749
 
680
750
  // Search sessions for keyword
@@ -0,0 +1,54 @@
1
+ function normalizeWebUiProtocol(value) {
2
+ return String(value || '').trim().toLowerCase() === 'https' ? 'https' : 'http';
3
+ }
4
+
5
+ function hasHttpsFlag(argv = process.argv) {
6
+ return Array.isArray(argv) && argv.includes('--https');
7
+ }
8
+
9
+ function getWebUiProtocol(options = {}) {
10
+ if (typeof options.https === 'boolean') {
11
+ return options.https ? 'https' : 'http';
12
+ }
13
+
14
+ if (typeof options.protocol === 'string' && options.protocol.trim()) {
15
+ return normalizeWebUiProtocol(options.protocol);
16
+ }
17
+
18
+ const env = options.env || process.env;
19
+ if (env && typeof env.CC_TOOL_WEB_UI_PROTOCOL === 'string' && env.CC_TOOL_WEB_UI_PROTOCOL.trim()) {
20
+ return normalizeWebUiProtocol(env.CC_TOOL_WEB_UI_PROTOCOL);
21
+ }
22
+
23
+ return hasHttpsFlag(options.argv || process.argv) ? 'https' : 'http';
24
+ }
25
+
26
+ function isHttpsEnabled(options = {}) {
27
+ return getWebUiProtocol(options) === 'https';
28
+ }
29
+
30
+ function getDefaultLoopbackHost(protocol = 'http') {
31
+ return normalizeWebUiProtocol(protocol) === 'https' ? '127.0.0.1' : 'localhost';
32
+ }
33
+
34
+ function getWebUiBaseUrl(port, options = {}) {
35
+ const protocol = getWebUiProtocol(options);
36
+ const hostname = String(options.hostname || '').trim() || getDefaultLoopbackHost(protocol);
37
+ return `${protocol}://${hostname}:${port}`;
38
+ }
39
+
40
+ function getWebSocketBaseUrl(port, options = {}) {
41
+ const protocol = getWebUiProtocol(options) === 'https' ? 'wss' : 'ws';
42
+ const hostname = String(options.hostname || '').trim() || getDefaultLoopbackHost(protocol === 'wss' ? 'https' : 'http');
43
+ return `${protocol}://${hostname}:${port}`;
44
+ }
45
+
46
+ module.exports = {
47
+ normalizeWebUiProtocol,
48
+ hasHttpsFlag,
49
+ getWebUiProtocol,
50
+ isHttpsEnabled,
51
+ getDefaultLoopbackHost,
52
+ getWebUiBaseUrl,
53
+ getWebSocketBaseUrl
54
+ };
@@ -7,7 +7,9 @@ const { PATHS } = require('../config/paths');
7
7
  const {
8
8
  normalizeAddress,
9
9
  isLoopbackAddress,
10
- isLoopbackRequest
10
+ isLoopbackRequest,
11
+ getRequestProtocol,
12
+ getRequestHost
11
13
  } = require('./services/network-access');
12
14
 
13
15
  const MAX_PERSISTED_LOGS = 500;
@@ -111,8 +113,8 @@ function isAllowedWebSocketOrigin(req) {
111
113
  return false;
112
114
  }
113
115
 
114
- const requestHost = parseHostHeader(req.headers.host);
115
- const requestProtocol = req.socket && req.socket.encrypted ? 'https:' : 'http:';
116
+ const requestHost = parseHostHeader(getRequestHost(req));
117
+ const requestProtocol = getRequestProtocol(req);
116
118
  const requestHostname = normalizeAddress(requestHost.hostname).toLowerCase();
117
119
  const requestPort = requestHost.port || defaultPortForProtocol(requestProtocol);
118
120
 
@@ -504,11 +506,40 @@ function broadcastSchedulerState(source, schedulerState) {
504
506
  }
505
507
  }
506
508
 
509
+ function broadcastBrowserNotification(event = {}) {
510
+ const notification = {
511
+ type: 'browser-notification',
512
+ title: String(event.title || '').trim() || 'Coding Tool',
513
+ source: String(event.source || '').trim() || 'claude',
514
+ sourceLabel: String(event.sourceLabel || '').trim() || 'Claude Code',
515
+ mode: String(event.mode || '').trim() || 'notification',
516
+ eventType: String(event.eventType || '').trim(),
517
+ message: String(event.message || '').trim() || '任务已完成 | 等待交互',
518
+ timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now()
519
+ };
520
+
521
+ if (wss && wsClients.size > 0) {
522
+ const message = JSON.stringify(notification);
523
+
524
+ wsClients.forEach(client => {
525
+ if (client.readyState === WebSocket.OPEN) {
526
+ client.send(message);
527
+ }
528
+ });
529
+ }
530
+ }
531
+
507
532
  module.exports = {
508
533
  startWebSocketServer,
509
534
  stopWebSocketServer,
510
535
  broadcastLog,
511
536
  clearAllLogs,
512
537
  broadcastProxyState,
513
- broadcastSchedulerState
538
+ broadcastSchedulerState,
539
+ broadcastBrowserNotification,
540
+ _test: {
541
+ parseHostHeader,
542
+ defaultPortForProtocol,
543
+ isAllowedWebSocketOrigin
544
+ }
514
545
  };