@adversity/coding-tool-x 2.4.0 → 2.4.2

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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  const os = require('os');
7
7
  const path = require('path');
8
+ const fs = require('fs');
8
9
 
9
10
  // 尝试加载 node-pty,如果失败则提示
10
11
  let pty = null;
@@ -40,7 +41,22 @@ class PtyManager {
40
41
  if (process.platform === 'win32') {
41
42
  return process.env.COMSPEC || 'cmd.exe';
42
43
  }
43
- return process.env.SHELL || '/bin/zsh';
44
+
45
+ // 优先使用环境变量指定的 shell
46
+ if (process.env.SHELL && fs.existsSync(process.env.SHELL)) {
47
+ return process.env.SHELL;
48
+ }
49
+
50
+ // 回退到常见的 shell,按优先级检查
51
+ const commonShells = ['/bin/bash', '/bin/sh', '/usr/bin/bash', '/usr/bin/sh'];
52
+ for (const shell of commonShells) {
53
+ if (fs.existsSync(shell)) {
54
+ return shell;
55
+ }
56
+ }
57
+
58
+ // 最后回退
59
+ return '/bin/sh';
44
60
  }
45
61
 
46
62
  /**
@@ -78,6 +94,9 @@ class PtyManager {
78
94
  // 检查 PTY 是否可用
79
95
  if (!pty) {
80
96
  const errMsg = this.getPtyError() || 'node-pty is not available';
97
+ console.error('[PTY] Cannot create terminal:', errMsg);
98
+ console.error('[PTY] Node version:', process.version);
99
+ console.error('[PTY] Platform:', process.platform);
81
100
  throw new Error(`Cannot create terminal: ${errMsg}`);
82
101
  }
83
102
 
@@ -94,6 +113,21 @@ class PtyManager {
94
113
  startCommand = null
95
114
  } = options;
96
115
 
116
+ // 验证 shell 和 cwd 存在
117
+ if (!fs.existsSync(shell)) {
118
+ const error = `Shell not found: ${shell}`;
119
+ console.error('[PTY]', error);
120
+ throw new Error(error);
121
+ }
122
+ if (!fs.existsSync(cwd)) {
123
+ const error = `Working directory not found: ${cwd}`;
124
+ console.error('[PTY]', error);
125
+ throw new Error(error);
126
+ }
127
+
128
+ console.log(`[PTY] Creating terminal: shell=${shell}, cwd=${cwd}`);
129
+
130
+
97
131
  const terminalId = `term_${this.nextId++}_${Date.now()}`;
98
132
 
99
133
  // 合并环境变量
@@ -11,6 +11,7 @@ const {
11
11
  checkHasMessagesCache,
12
12
  rememberHasMessages
13
13
  } = require('./session-cache');
14
+ const { globalCache, CacheKeys } = require('./enhanced-cache');
14
15
  const { PATHS } = require('../../config/paths');
15
16
 
16
17
  // Base directory for cc-tool data
@@ -82,15 +83,15 @@ function saveForkRelations(relations) {
82
83
  fs.writeFileSync(relationsFile, JSON.stringify(relations, null, 2), 'utf8');
83
84
  }
84
85
 
85
- // Get all projects with stats
86
- function getProjects(config) {
86
+ // Get all projects with stats (async version)
87
+ async function getProjects(config) {
87
88
  const projectsDir = config.projectsDir;
88
89
 
89
90
  if (!fs.existsSync(projectsDir)) {
90
91
  return [];
91
92
  }
92
93
 
93
- const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
94
+ const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
94
95
  return entries
95
96
  .filter(entry => entry.isDirectory())
96
97
  .map(entry => entry.name);
@@ -283,32 +284,52 @@ function extractCwdFromSessionHeader(sessionFile) {
283
284
  return null;
284
285
  }
285
286
 
286
- // Get projects with detailed stats (with caching)
287
- function getProjectsWithStats(config, options = {}) {
287
+ // Get projects with detailed stats (with caching) - async version
288
+ async function getProjectsWithStats(config, options = {}) {
288
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
289
298
  const cached = getCachedProjects(config);
290
299
  if (cached) {
300
+ globalCache.set(cacheKey, cached, 300000); // 5分钟
291
301
  return cached;
292
302
  }
293
303
  }
294
304
 
295
- const data = buildProjectsWithStats(config);
296
- setCachedProjects(config, data);
297
- return data;
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
+ }
298
318
  }
299
319
 
300
- function buildProjectsWithStats(config) {
320
+ async function buildProjectsWithStats(config) {
301
321
  const projectsDir = config.projectsDir;
302
322
 
303
323
  if (!fs.existsSync(projectsDir)) {
304
324
  return [];
305
325
  }
306
326
 
307
- const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
327
+ const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
308
328
 
309
- return entries
329
+ // Process all projects concurrently
330
+ const projectPromises = entries
310
331
  .filter(entry => entry.isDirectory())
311
- .map(entry => {
332
+ .map(async (entry) => {
312
333
  const projectName = entry.name;
313
334
  const projectPath = path.join(projectsDir, projectName);
314
335
 
@@ -320,24 +341,29 @@ function buildProjectsWithStats(config) {
320
341
  let lastUsed = null;
321
342
 
322
343
  try {
323
- const files = fs.readdirSync(projectPath);
344
+ const files = await fs.promises.readdir(projectPath);
324
345
  const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
325
346
 
326
- // Filter: only count sessions that have actual messages (not just file-history-snapshots)
327
- const sessionFilesWithMessages = jsonlFiles.filter(f => {
328
- const filePath = path.join(projectPath, f);
329
- return hasActualMessages(filePath);
330
- });
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
+ );
331
355
 
356
+ const sessionFilesWithMessages = sessionChecks.filter(f => f !== null);
332
357
  sessionCount = sessionFilesWithMessages.length;
333
358
 
334
359
  // Find most recent session (only from sessions with messages)
335
360
  if (sessionFilesWithMessages.length > 0) {
336
- const stats = sessionFilesWithMessages.map(f => {
361
+ const statPromises = sessionFilesWithMessages.map(async (f) => {
337
362
  const filePath = path.join(projectPath, f);
338
- const stat = fs.statSync(filePath);
363
+ const stat = await fs.promises.stat(filePath);
339
364
  return stat.mtime.getTime();
340
365
  });
366
+ const stats = await Promise.all(statPromises);
341
367
  lastUsed = Math.max(...stats);
342
368
  }
343
369
  } catch (err) {
@@ -351,8 +377,10 @@ function buildProjectsWithStats(config) {
351
377
  sessionCount,
352
378
  lastUsed
353
379
  };
354
- })
355
- .sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)); // Sort by last used
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
356
384
  }
357
385
 
358
386
  // 获取 Claude 项目/会话数量(轻量统计)
@@ -383,16 +411,27 @@ function getProjectAndSessionCounts(config) {
383
411
  return { projectCount, sessionCount };
384
412
  }
385
413
 
386
- // Check if a session file has actual messages (not just file-history-snapshots)
387
- function hasActualMessages(filePath) {
414
+ // Check if a session file has actual messages (async with enhanced caching)
415
+ async function hasActualMessages(filePath) {
388
416
  try {
389
- const stats = fs.statSync(filePath);
390
- const cached = checkHasMessagesCache(filePath, stats);
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);
391
422
  if (typeof cached === 'boolean') {
392
423
  return cached;
393
424
  }
394
425
 
395
- const result = scanSessionFileForMessages(filePath);
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);
396
435
  rememberHasMessages(filePath, stats, result);
397
436
  return result;
398
437
  } catch (err) {
@@ -434,30 +473,74 @@ function scanSessionFileForMessages(filePath) {
434
473
  }
435
474
  }
436
475
 
437
- // Get sessions for a project
438
- function getSessionsForProject(config, projectName) {
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
+
439
514
  const projectConfig = { ...config, currentProject: projectName };
440
515
  const sessions = getAllSessions(projectConfig);
441
516
  const forkRelations = getForkRelations();
442
517
  const savedOrder = getSessionOrder(projectName);
443
518
 
444
- // Parse session info and calculate total size, filter out sessions with no messages
519
+ // Parse session info and calculate total size, filter out sessions with no messages (in parallel)
445
520
  let totalSize = 0;
446
- const sessionsWithInfo = sessions
447
- .filter(session => hasActualMessages(session.filePath))
448
- .map(session => {
449
- const info = parseSessionInfoFast(session.filePath);
450
- totalSize += session.size || 0;
451
- return {
452
- sessionId: session.sessionId,
453
- mtime: session.mtime,
454
- size: session.size,
455
- filePath: session.filePath,
456
- gitBranch: info.gitBranch || null,
457
- firstMessage: info.firstMessage || null,
458
- forkedFrom: forkRelations[session.sessionId] || null
459
- };
460
- });
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
+ });
461
544
 
462
545
  // Apply saved order if exists
463
546
  let orderedSessions = sessionsWithInfo;
@@ -478,10 +561,14 @@ function getSessionsForProject(config, projectName) {
478
561
  orderedSessions = ordered;
479
562
  }
480
563
 
481
- return {
564
+ const result = {
482
565
  sessions: orderedSessions,
483
566
  totalSize
484
567
  };
568
+
569
+ // Cache for 2 minutes
570
+ globalCache.set(cacheKey, result, 120000);
571
+ return result;
485
572
  }
486
573
 
487
574
  // Delete a session
@@ -847,14 +847,14 @@ class SkillService {
847
847
 
848
848
  request.on('error', (err) => {
849
849
  file.close();
850
- fs.unlink(dest, () => {});
850
+ fs.unlink(dest, () => { });
851
851
  reject(err);
852
852
  });
853
853
 
854
854
  request.on('timeout', () => {
855
855
  request.destroy();
856
856
  file.close();
857
- fs.unlink(dest, () => {});
857
+ fs.unlink(dest, () => { });
858
858
  reject(new Error('Download timeout'));
859
859
  });
860
860
  });
@@ -912,6 +912,256 @@ ${content}
912
912
  return { success: true, message: '技能创建成功', directory };
913
913
  }
914
914
 
915
+ /**
916
+ * 创建带多文件的技能
917
+ * @param {string} directory - 技能目录名
918
+ * @param {Array<{path: string, content: string}>} files - 文件数组
919
+ * @returns {Object} 创建结果
920
+ */
921
+ createSkillWithFiles({ directory, files }) {
922
+ const dest = path.join(this.installDir, directory);
923
+
924
+ // 检查是否已存在
925
+ if (fs.existsSync(dest)) {
926
+ throw new Error(`技能目录 "${directory}" 已存在`);
927
+ }
928
+
929
+ // 验证必须包含 SKILL.md
930
+ const hasSkillMd = files.some(f =>
931
+ f.path === 'SKILL.md' || f.path.endsWith('/SKILL.md')
932
+ );
933
+ if (!hasSkillMd) {
934
+ throw new Error('技能必须包含 SKILL.md 文件');
935
+ }
936
+
937
+ // 创建目录
938
+ fs.mkdirSync(dest, { recursive: true });
939
+
940
+ // 写入所有文件
941
+ for (const file of files) {
942
+ const filePath = path.join(dest, file.path);
943
+ const fileDir = path.dirname(filePath);
944
+
945
+ // 确保父目录存在
946
+ if (!fs.existsSync(fileDir)) {
947
+ fs.mkdirSync(fileDir, { recursive: true });
948
+ }
949
+
950
+ // 写入文件内容
951
+ if (file.isBase64) {
952
+ // 二进制文件使用 base64 编码
953
+ fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
954
+ } else {
955
+ fs.writeFileSync(filePath, file.content, 'utf-8');
956
+ }
957
+ }
958
+
959
+ // 清除缓存
960
+ this.skillsCache = null;
961
+ this.cacheTime = 0;
962
+
963
+ return {
964
+ success: true,
965
+ message: '技能创建成功',
966
+ directory,
967
+ fileCount: files.length
968
+ };
969
+ }
970
+
971
+ /**
972
+ * 获取技能目录下所有文件列表
973
+ * @param {string} directory - 技能目录名
974
+ * @returns {Array<{path: string, size: number, isDirectory: boolean}>}
975
+ */
976
+ getSkillFiles(directory) {
977
+ const skillPath = path.join(this.installDir, directory);
978
+
979
+ if (!fs.existsSync(skillPath)) {
980
+ throw new Error(`技能 "${directory}" 不存在`);
981
+ }
982
+
983
+ const files = [];
984
+ this._scanFilesRecursive(skillPath, skillPath, files);
985
+ return files;
986
+ }
987
+
988
+ /**
989
+ * 递归扫描目录获取文件列表
990
+ */
991
+ _scanFilesRecursive(currentDir, baseDir, files) {
992
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
993
+
994
+ for (const entry of entries) {
995
+ const fullPath = path.join(currentDir, entry.name);
996
+ const relativePath = path.relative(baseDir, fullPath);
997
+
998
+ if (entry.isDirectory()) {
999
+ files.push({
1000
+ path: relativePath,
1001
+ size: 0,
1002
+ isDirectory: true
1003
+ });
1004
+ this._scanFilesRecursive(fullPath, baseDir, files);
1005
+ } else {
1006
+ const stats = fs.statSync(fullPath);
1007
+ files.push({
1008
+ path: relativePath,
1009
+ size: stats.size,
1010
+ isDirectory: false
1011
+ });
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * 获取技能文件内容
1018
+ * @param {string} directory - 技能目录名
1019
+ * @param {string} filePath - 文件相对路径
1020
+ * @returns {Object} 文件内容
1021
+ */
1022
+ getSkillFileContent(directory, filePath) {
1023
+ const fullPath = path.join(this.installDir, directory, filePath);
1024
+
1025
+ if (!fs.existsSync(fullPath)) {
1026
+ throw new Error(`文件 "${filePath}" 不存在`);
1027
+ }
1028
+
1029
+ const stats = fs.statSync(fullPath);
1030
+ if (stats.isDirectory()) {
1031
+ throw new Error(`"${filePath}" 是目录,不是文件`);
1032
+ }
1033
+
1034
+ // 判断是否是文本文件
1035
+ const textExtensions = ['.md', '.txt', '.json', '.js', '.ts', '.py', '.sh', '.yaml', '.yml', '.toml', '.xml', '.html', '.css'];
1036
+ const ext = path.extname(filePath).toLowerCase();
1037
+ const isText = textExtensions.includes(ext);
1038
+
1039
+ if (isText) {
1040
+ return {
1041
+ path: filePath,
1042
+ content: fs.readFileSync(fullPath, 'utf-8'),
1043
+ isBase64: false,
1044
+ size: stats.size
1045
+ };
1046
+ } else {
1047
+ return {
1048
+ path: filePath,
1049
+ content: fs.readFileSync(fullPath).toString('base64'),
1050
+ isBase64: true,
1051
+ size: stats.size
1052
+ };
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * 添加文件到现有技能
1058
+ * @param {string} directory - 技能目录名
1059
+ * @param {Array<{path: string, content: string, isBase64?: boolean}>} files - 文件数组
1060
+ */
1061
+ addSkillFiles(directory, files) {
1062
+ const skillPath = path.join(this.installDir, directory);
1063
+
1064
+ if (!fs.existsSync(skillPath)) {
1065
+ throw new Error(`技能 "${directory}" 不存在`);
1066
+ }
1067
+
1068
+ const added = [];
1069
+ for (const file of files) {
1070
+ const filePath = path.join(skillPath, file.path);
1071
+ const fileDir = path.dirname(filePath);
1072
+
1073
+ // 确保父目录存在
1074
+ if (!fs.existsSync(fileDir)) {
1075
+ fs.mkdirSync(fileDir, { recursive: true });
1076
+ }
1077
+
1078
+ // 写入文件
1079
+ if (file.isBase64) {
1080
+ fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
1081
+ } else {
1082
+ fs.writeFileSync(filePath, file.content, 'utf-8');
1083
+ }
1084
+ added.push(file.path);
1085
+ }
1086
+
1087
+ // 清除缓存
1088
+ this.skillsCache = null;
1089
+ this.cacheTime = 0;
1090
+
1091
+ return { success: true, added };
1092
+ }
1093
+
1094
+ /**
1095
+ * 删除技能中的文件
1096
+ * @param {string} directory - 技能目录名
1097
+ * @param {string} filePath - 文件相对路径
1098
+ */
1099
+ deleteSkillFile(directory, filePath) {
1100
+ const skillPath = path.join(this.installDir, directory);
1101
+
1102
+ if (!fs.existsSync(skillPath)) {
1103
+ throw new Error(`技能 "${directory}" 不存在`);
1104
+ }
1105
+
1106
+ // 不允许删除 SKILL.md
1107
+ if (filePath === 'SKILL.md') {
1108
+ throw new Error('不能删除 SKILL.md 文件');
1109
+ }
1110
+
1111
+ const fullPath = path.join(skillPath, filePath);
1112
+
1113
+ if (!fs.existsSync(fullPath)) {
1114
+ throw new Error(`文件 "${filePath}" 不存在`);
1115
+ }
1116
+
1117
+ const stats = fs.statSync(fullPath);
1118
+ if (stats.isDirectory()) {
1119
+ fs.rmSync(fullPath, { recursive: true, force: true });
1120
+ } else {
1121
+ fs.unlinkSync(fullPath);
1122
+ }
1123
+
1124
+ // 清除缓存
1125
+ this.skillsCache = null;
1126
+ this.cacheTime = 0;
1127
+
1128
+ return { success: true, deleted: filePath };
1129
+ }
1130
+
1131
+ /**
1132
+ * 更新技能文件内容
1133
+ * @param {string} directory - 技能目录名
1134
+ * @param {string} filePath - 文件相对路径
1135
+ * @param {string} content - 新内容
1136
+ * @param {boolean} isBase64 - 是否为 base64 编码
1137
+ */
1138
+ updateSkillFile(directory, filePath, content, isBase64 = false) {
1139
+ const skillPath = path.join(this.installDir, directory);
1140
+
1141
+ if (!fs.existsSync(skillPath)) {
1142
+ throw new Error(`技能 "${directory}" 不存在`);
1143
+ }
1144
+
1145
+ const fullPath = path.join(skillPath, filePath);
1146
+
1147
+ if (!fs.existsSync(fullPath)) {
1148
+ throw new Error(`文件 "${filePath}" 不存在`);
1149
+ }
1150
+
1151
+ if (isBase64) {
1152
+ fs.writeFileSync(fullPath, Buffer.from(content, 'base64'));
1153
+ } else {
1154
+ fs.writeFileSync(fullPath, content, 'utf-8');
1155
+ }
1156
+
1157
+ // 清除缓存
1158
+ this.skillsCache = null;
1159
+ this.cacheTime = 0;
1160
+
1161
+ return { success: true, updated: filePath };
1162
+ }
1163
+
1164
+
915
1165
  /**
916
1166
  * 卸载技能
917
1167
  */
@@ -108,6 +108,11 @@ function saveTerminalCommands(commands) {
108
108
  * @returns {string} 启动命令
109
109
  */
110
110
  function getCommandForChannel(channel, sessionId = null, cwd = null) {
111
+ // Add support for plain shell (do not auto-run any command)
112
+ if (channel === 'shell') {
113
+ return null;
114
+ }
115
+
111
116
  const commands = loadTerminalCommands();
112
117
  const channelConfig = commands[channel] || commands.claude;
113
118