@adversity/coding-tool-x 2.4.2 → 2.5.1

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.
@@ -10,6 +10,8 @@ const fs = require('fs');
10
10
  // 尝试加载 node-pty,如果失败则提示
11
11
  let pty = null;
12
12
  let ptyError = null;
13
+ let HeadlessTerminal = null;
14
+ let headlessError = null;
13
15
 
14
16
  try {
15
17
  // 优先使用 @lydell/node-pty (支持更新的 Node.js 版本)
@@ -24,6 +26,14 @@ try {
24
26
  }
25
27
  }
26
28
 
29
+ try {
30
+ const headless = require('@xterm/headless');
31
+ HeadlessTerminal = headless.Terminal || headless.default?.Terminal || headless.default || null;
32
+ } catch (err) {
33
+ headlessError = err.message;
34
+ console.warn('Warning: @xterm/headless failed to load:', err.message);
35
+ }
36
+
27
37
  class PtyManager {
28
38
  constructor() {
29
39
  // 终端进程池: terminalId -> { pty, ws, metadata }
@@ -32,6 +42,89 @@ class PtyManager {
32
42
 
33
43
  // 清理已退出的进程
34
44
  this.cleanupInterval = setInterval(() => this.cleanupDeadTerminals(), 30000);
45
+ // 避免在未启用 Web 终端时阻止进程退出
46
+ if (typeof this.cleanupInterval.unref === 'function') {
47
+ this.cleanupInterval.unref();
48
+ }
49
+ }
50
+
51
+ createScreen(cols, rows) {
52
+ if (!HeadlessTerminal) {
53
+ return null;
54
+ }
55
+ try {
56
+ return new HeadlessTerminal({
57
+ cols: Math.max(cols, 80),
58
+ rows: Math.max(rows, 24),
59
+ scrollback: 10000,
60
+ convertEol: true
61
+ });
62
+ } catch (err) {
63
+ console.warn('Failed to create headless terminal:', err.message);
64
+ return null;
65
+ }
66
+ }
67
+
68
+ buildScreenSnapshot(terminal) {
69
+ if (!terminal?.screen || !terminal.screen.buffer?.active) {
70
+ return { data: '' };
71
+ }
72
+ const screen = terminal.screen;
73
+ const buffer = screen.buffer.active;
74
+ const end = buffer.length;
75
+ const cols = screen.cols || terminal.metadata?.cols || 80;
76
+ const rows = screen.rows || terminal.metadata?.rows || 24;
77
+ const cursorLineIndex = Math.max(0, (buffer.baseY || 0) + (buffer.cursorY || 0));
78
+ const cursorCol = Math.max(0, buffer.cursorX || 0);
79
+ const lastLineIndex = end > 0 ? end - 1 : -1;
80
+ let output = '';
81
+ let hasOutput = false;
82
+ for (let i = 0; i <= lastLineIndex; i++) {
83
+ const line = buffer.getLine(i);
84
+ if (!line) {
85
+ if (hasOutput) {
86
+ output += '\r\n';
87
+ } else {
88
+ output = '';
89
+ hasOutput = true;
90
+ }
91
+ continue;
92
+ }
93
+ let text = '';
94
+ try {
95
+ text = line.translateToString(true);
96
+ } catch (err) {
97
+ try {
98
+ text = line.translateToString(false);
99
+ } catch (err2) {
100
+ text = '';
101
+ }
102
+ }
103
+ const isWrapped = Boolean(line.isWrapped);
104
+ if (!hasOutput) {
105
+ output = text;
106
+ hasOutput = true;
107
+ } else if (isWrapped) {
108
+ output += text;
109
+ } else {
110
+ output += '\r\n' + text;
111
+ }
112
+ }
113
+ const data = hasOutput ? output : '';
114
+ const topIndex = Math.max(0, lastLineIndex - rows + 1);
115
+ let cursorRow = cursorLineIndex - topIndex + 1;
116
+ if (!Number.isFinite(cursorRow)) {
117
+ cursorRow = rows;
118
+ }
119
+ cursorRow = Math.min(Math.max(cursorRow, 1), rows);
120
+ const cursorColSafe = Math.min(Math.max(cursorCol + 1, 1), cols);
121
+ return {
122
+ data,
123
+ cursorRow,
124
+ cursorCol: cursorColSafe,
125
+ rows,
126
+ cols
127
+ };
35
128
  }
36
129
 
37
130
  /**
@@ -164,9 +257,14 @@ class PtyManager {
164
257
  pty: ptyProcess,
165
258
  ws: null,
166
259
  buffer: [], // 缓存未发送的输出(用于 WebSocket 断开期间)
167
- history: [], // 持久历史记录(用于重连时恢复)
260
+ history: [], // 断线期间的输出缓冲(仅用于补发)
168
261
  historySize: 0, // 历史记录字节大小
169
262
  maxHistorySize: 100 * 1024, // 最大历史记录大小 100KB
263
+ screen: this.createScreen(cols, rows),
264
+ pendingOutput: [],
265
+ snapshotInProgress: false,
266
+ outputSeq: 0,
267
+ snapshotSeq: 0,
170
268
  metadata: {
171
269
  cwd,
172
270
  shell,
@@ -183,23 +281,36 @@ class PtyManager {
183
281
 
184
282
  // 监听 PTY 输出
185
283
  ptyProcess.onData((data) => {
186
- // 始终保存到历史记录(用于重连时恢复)
187
- terminal.history.push(data);
188
- terminal.historySize += data.length;
189
-
190
- // 如果历史记录超出限制,裁剪前面的内容
191
- while (terminal.historySize > terminal.maxHistorySize && terminal.history.length > 1) {
192
- const removed = terminal.history.shift();
193
- terminal.historySize -= removed.length;
284
+ terminal.outputSeq += 1;
285
+ const outputEntry = { seq: terminal.outputSeq, data };
286
+ if (terminal.screen) {
287
+ terminal.screen.write(data);
194
288
  }
195
289
 
196
290
  if (terminal.ws && terminal.ws.readyState === 1) { // WebSocket.OPEN
197
- terminal.ws.send(JSON.stringify({
198
- type: 'terminal:output',
199
- terminalId,
200
- data
201
- }));
202
- } else {
291
+ if (terminal.snapshotInProgress) {
292
+ terminal.pendingOutput.push(outputEntry);
293
+ } else {
294
+ terminal.ws.send(JSON.stringify({
295
+ type: 'terminal:output',
296
+ terminalId,
297
+ data
298
+ }));
299
+ }
300
+ return;
301
+ }
302
+
303
+ if (!terminal.screen) {
304
+ // 仅在断开期间缓存输出,避免重连时重复回放
305
+ terminal.history.push(data);
306
+ terminal.historySize += data.length;
307
+
308
+ // 如果历史记录超出限制,裁剪前面的内容
309
+ while (terminal.historySize > terminal.maxHistorySize && terminal.history.length > 1) {
310
+ const removed = terminal.history.shift();
311
+ terminal.historySize -= removed.length;
312
+ }
313
+
203
314
  // 缓存输出,等待 WebSocket 连接
204
315
  terminal.buffer.push(data);
205
316
  // 限制缓存大小
@@ -225,6 +336,7 @@ class PtyManager {
225
336
  // 标记为已退出,稍后清理
226
337
  terminal.exited = true;
227
338
  terminal.exitCode = exitCode;
339
+ terminal.exitedAt = Date.now();
228
340
  });
229
341
 
230
342
  this.terminals.set(terminalId, terminal);
@@ -253,7 +365,7 @@ class PtyManager {
253
365
  * @param {WebSocket} ws - WebSocket 连接
254
366
  * @returns {boolean} 是否成功
255
367
  */
256
- attachWebSocket(terminalId, ws) {
368
+ attachWebSocket(terminalId, ws, options = {}) {
257
369
  const terminal = this.terminals.get(terminalId);
258
370
  if (!terminal) {
259
371
  console.warn(`Terminal ${terminalId} not found`);
@@ -261,20 +373,114 @@ class PtyManager {
261
373
  }
262
374
 
263
375
  terminal.ws = ws;
376
+ terminal.snapshotInProgress = false;
377
+ terminal.pendingOutput = [];
378
+ terminal.snapshotSeq = 0;
264
379
 
265
- // 发送历史记录(用于重连时恢复之前的输出)
266
- if (terminal.history.length > 0) {
267
- const historyData = terminal.history.join('');
268
- ws.send(JSON.stringify({
269
- type: 'terminal:output',
270
- terminalId,
271
- data: historyData
272
- }));
273
- console.log(`Sent ${terminal.history.length} history chunks (${terminal.historySize} bytes) to terminal ${terminalId}`);
274
- }
380
+ const { includeHistory = true, trimLastLine = false } = options;
381
+
382
+ const trimHistoryLastLine = (data) => {
383
+ if (!data) return '';
384
+ if (data.endsWith('\r\n')) {
385
+ return data.slice(0, -2);
386
+ }
387
+ if (data.endsWith('\n')) {
388
+ return data.slice(0, -1);
389
+ }
390
+ return data;
391
+ };
392
+
393
+ if (terminal.screen && includeHistory) {
394
+ terminal.snapshotInProgress = true;
395
+ terminal.pendingOutput = [];
396
+ terminal.snapshotSeq = terminal.outputSeq;
397
+ terminal.buffer = [];
398
+ terminal.history = [];
399
+ terminal.historySize = 0;
400
+
401
+ const sendSnapshot = () => {
402
+ try {
403
+ const snapshot = this.buildScreenSnapshot(terminal);
404
+ if (snapshot?.data && ws.readyState === 1) {
405
+ ws.send(JSON.stringify({
406
+ type: 'terminal:output',
407
+ terminalId,
408
+ data: snapshot.data
409
+ }));
410
+ }
411
+ if (snapshot?.cursorRow && snapshot?.cursorCol && ws.readyState === 1) {
412
+ ws.send(JSON.stringify({
413
+ type: 'terminal:output',
414
+ terminalId,
415
+ data: `\x1b[${snapshot.cursorRow};${snapshot.cursorCol}H`
416
+ }));
417
+ }
418
+ const pendingTail = terminal.pendingOutput
419
+ .filter(item => item && item.seq > terminal.snapshotSeq)
420
+ .map(item => item.data);
421
+ if (pendingTail.length > 0 && ws.readyState === 1) {
422
+ ws.send(JSON.stringify({
423
+ type: 'terminal:output',
424
+ terminalId,
425
+ data: pendingTail.join('')
426
+ }));
427
+ }
428
+ } catch (err) {
429
+ console.warn(`Failed to send terminal snapshot ${terminalId}:`, err.message);
430
+ } finally {
431
+ terminal.pendingOutput = [];
432
+ terminal.snapshotInProgress = false;
433
+ terminal.snapshotSeq = 0;
434
+ }
435
+ };
275
436
 
276
- // 清空临时缓冲区(已经包含在历史记录中了)
277
- terminal.buffer = [];
437
+ if (typeof terminal.screen.write === 'function') {
438
+ terminal.screen.write('', sendSnapshot);
439
+ } else {
440
+ sendSnapshot();
441
+ }
442
+ } else {
443
+ // 发送历史记录(用于重连时恢复之前的输出)
444
+ let sentHistory = false;
445
+ if (includeHistory) {
446
+ if (terminal.history.length > 0) {
447
+ let historyData = terminal.history.join('');
448
+ if (trimLastLine) {
449
+ historyData = trimHistoryLastLine(historyData);
450
+ }
451
+ if (historyData) {
452
+ ws.send(JSON.stringify({
453
+ type: 'terminal:output',
454
+ terminalId,
455
+ data: historyData
456
+ }));
457
+ console.log(`Sent ${terminal.history.length} history chunks (${terminal.historySize} bytes) to terminal ${terminalId}`);
458
+ sentHistory = true;
459
+ }
460
+ } else if (terminal.buffer.length > 0) {
461
+ let bufferData = terminal.buffer.join('');
462
+ if (trimLastLine) {
463
+ bufferData = trimHistoryLastLine(bufferData);
464
+ }
465
+ if (bufferData) {
466
+ ws.send(JSON.stringify({
467
+ type: 'terminal:output',
468
+ terminalId,
469
+ data: bufferData
470
+ }));
471
+ console.log(`Sent ${terminal.buffer.length} buffered chunks to terminal ${terminalId}`);
472
+ sentHistory = true;
473
+ }
474
+ }
475
+ }
476
+
477
+ // 清空临时缓冲区(已经包含在历史记录中了)
478
+ terminal.buffer = [];
479
+ if (sentHistory) {
480
+ terminal.history = [];
481
+ terminal.historySize = 0;
482
+ }
483
+ }
278
484
 
279
485
  // 如果终端已退出,通知客户端
280
486
  if (terminal.exited) {
@@ -297,6 +503,9 @@ class PtyManager {
297
503
  const terminal = this.terminals.get(terminalId);
298
504
  if (terminal) {
299
505
  terminal.ws = null;
506
+ terminal.snapshotInProgress = false;
507
+ terminal.pendingOutput = [];
508
+ terminal.snapshotSeq = 0;
300
509
  }
301
510
  }
302
511
 
@@ -331,10 +540,16 @@ class PtyManager {
331
540
 
332
541
  const newCols = Math.max(cols, 80);
333
542
  const newRows = Math.max(rows, 24);
543
+ if (terminal.metadata.cols === newCols && terminal.metadata.rows === newRows) {
544
+ return true;
545
+ }
334
546
 
335
547
  terminal.pty.resize(newCols, newRows);
336
548
  terminal.metadata.cols = newCols;
337
549
  terminal.metadata.rows = newRows;
550
+ if (terminal.screen && typeof terminal.screen.resize === 'function') {
551
+ terminal.screen.resize(newCols, newRows);
552
+ }
338
553
 
339
554
  return true;
340
555
  }
@@ -369,6 +584,10 @@ class PtyManager {
369
584
  }
370
585
  }
371
586
 
587
+ if (terminal.screen && typeof terminal.screen.dispose === 'function') {
588
+ terminal.screen.dispose();
589
+ }
590
+
372
591
  this.terminals.delete(terminalId);
373
592
  console.log(`Destroyed terminal ${terminalId}`);
374
593
 
@@ -423,6 +642,9 @@ class PtyManager {
423
642
  if (terminal.exited && !terminal.ws) {
424
643
  const exitedTime = terminal.exitedAt || now;
425
644
  if (now - exitedTime > 5 * 60 * 1000) {
645
+ if (terminal.screen && typeof terminal.screen.dispose === 'function') {
646
+ terminal.screen.dispose();
647
+ }
426
648
  this.terminals.delete(id);
427
649
  console.log(`Cleaned up dead terminal ${id}`);
428
650
  }
@@ -0,0 +1,131 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+
6
+ const SECURITY_DIR = path.join(os.homedir(), '.claude', 'cc-tool');
7
+ const SECURITY_FILE = path.join(SECURITY_DIR, 'security.json');
8
+
9
+ const DEFAULT_SECURITY_CONFIG = {
10
+ passwordHash: '',
11
+ salt: '',
12
+ updatedAt: null
13
+ };
14
+
15
+ const PBKDF2_ITERATIONS = 120000;
16
+ const PBKDF2_KEYLEN = 64;
17
+ const PBKDF2_DIGEST = 'sha512';
18
+
19
+ function ensureSecurityDir() {
20
+ if (!fs.existsSync(SECURITY_DIR)) {
21
+ fs.mkdirSync(SECURITY_DIR, { recursive: true });
22
+ }
23
+ }
24
+
25
+ function readSecurityConfig() {
26
+ ensureSecurityDir();
27
+
28
+ if (!fs.existsSync(SECURITY_FILE)) {
29
+ return { ...DEFAULT_SECURITY_CONFIG };
30
+ }
31
+
32
+ try {
33
+ const content = fs.readFileSync(SECURITY_FILE, 'utf8');
34
+ const data = JSON.parse(content);
35
+ return {
36
+ ...DEFAULT_SECURITY_CONFIG,
37
+ ...data
38
+ };
39
+ } catch (error) {
40
+ console.error('Error loading security config:', error);
41
+ return { ...DEFAULT_SECURITY_CONFIG };
42
+ }
43
+ }
44
+
45
+ function writeSecurityConfig(config) {
46
+ ensureSecurityDir();
47
+ fs.writeFileSync(SECURITY_FILE, JSON.stringify(config, null, 2), 'utf8');
48
+ try {
49
+ fs.chmodSync(SECURITY_FILE, 0o600);
50
+ } catch (error) {
51
+ console.warn('Failed to set security config permissions:', error);
52
+ }
53
+ }
54
+
55
+ function hasPassword(config) {
56
+ return Boolean(config.passwordHash && config.salt);
57
+ }
58
+
59
+ function hashPassword(password, salt) {
60
+ return crypto
61
+ .pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST)
62
+ .toString('hex');
63
+ }
64
+
65
+ function safeEqualHash(a, b) {
66
+ try {
67
+ const bufferA = Buffer.from(a, 'hex');
68
+ const bufferB = Buffer.from(b, 'hex');
69
+ if (bufferA.length !== bufferB.length) {
70
+ return false;
71
+ }
72
+ return crypto.timingSafeEqual(bufferA, bufferB);
73
+ } catch (error) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function getSecurityStatus() {
79
+ const config = readSecurityConfig();
80
+ return { hasPassword: hasPassword(config) };
81
+ }
82
+
83
+ function verifySecurityPassword(password) {
84
+ const config = readSecurityConfig();
85
+ if (!hasPassword(config)) {
86
+ return { ok: false, reason: 'not_set' };
87
+ }
88
+ const hash = hashPassword(password, config.salt);
89
+ return { ok: safeEqualHash(hash, config.passwordHash) };
90
+ }
91
+
92
+ function setSecurityPassword({ currentPassword, newPassword }) {
93
+ if (typeof newPassword !== 'string' || newPassword.length < 4) {
94
+ const error = new Error('新密码至少 4 位');
95
+ error.code = 'WEAK_PASSWORD';
96
+ throw error;
97
+ }
98
+
99
+ const config = readSecurityConfig();
100
+ if (hasPassword(config)) {
101
+ if (typeof currentPassword !== 'string' || !currentPassword) {
102
+ const error = new Error('请输入当前密码');
103
+ error.code = 'CURRENT_REQUIRED';
104
+ throw error;
105
+ }
106
+ const verifyResult = verifySecurityPassword(currentPassword);
107
+ if (!verifyResult.ok) {
108
+ const error = new Error('当前密码错误');
109
+ error.code = 'INVALID_PASSWORD';
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ const salt = crypto.randomBytes(16).toString('hex');
115
+ const passwordHash = hashPassword(newPassword, salt);
116
+ const nextConfig = {
117
+ ...config,
118
+ passwordHash,
119
+ salt,
120
+ updatedAt: new Date().toISOString()
121
+ };
122
+
123
+ writeSecurityConfig(nextConfig);
124
+ return { hasPassword: true };
125
+ }
126
+
127
+ module.exports = {
128
+ getSecurityStatus,
129
+ verifySecurityPassword,
130
+ setSecurityPassword
131
+ };
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
- const { detectAvailableTerminals, getDefaultTerminal } = require('./terminal-detector');
4
+ const { detectAvailableTerminals, getDefaultTerminal, getSystemShell } = require('./terminal-detector');
5
5
 
6
6
  /**
7
7
  * 获取配置文件路径
@@ -77,6 +77,78 @@ function getSelectedTerminal() {
77
77
  return selectedTerminal || getDefaultTerminal();
78
78
  }
79
79
 
80
+ function getSystemRoot() {
81
+ return process.env.SystemRoot || process.env.windir || null;
82
+ }
83
+
84
+ function resolveWindowsShellPath(selectedTerminalId) {
85
+ const systemRoot = getSystemRoot();
86
+
87
+ if (selectedTerminalId === 'cmd') {
88
+ const candidates = [];
89
+ if (process.env.COMSPEC) {
90
+ candidates.push(process.env.COMSPEC);
91
+ }
92
+ if (systemRoot) {
93
+ candidates.push(path.join(systemRoot, 'System32', 'cmd.exe'));
94
+ }
95
+ return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
96
+ }
97
+
98
+ if (selectedTerminalId === 'powershell') {
99
+ const candidates = [];
100
+ if (systemRoot) {
101
+ candidates.push(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'));
102
+ }
103
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
104
+ candidates.push(path.join(programFiles, 'PowerShell', '7', 'pwsh.exe'));
105
+ return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
106
+ }
107
+
108
+ if (selectedTerminalId === 'git-bash') {
109
+ const terminals = detectAvailableTerminals();
110
+ const gitBash = terminals.find(t => t.id === 'git-bash');
111
+ if (gitBash && gitBash.executablePath && fs.existsSync(gitBash.executablePath)) {
112
+ return gitBash.executablePath;
113
+ }
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ function getWebTerminalShellConfig() {
120
+ const config = loadTerminalConfig();
121
+ const selectedTerminalId = config.selectedTerminal;
122
+
123
+ if (!selectedTerminalId) {
124
+ return {};
125
+ }
126
+
127
+ if (selectedTerminalId === 'system-shell') {
128
+ const shell = getSystemShell();
129
+ if (shell) {
130
+ return { shell };
131
+ }
132
+ return {};
133
+ }
134
+
135
+ if (process.platform !== 'win32') {
136
+ return {};
137
+ }
138
+
139
+ const shell = resolveWindowsShellPath(selectedTerminalId);
140
+ if (!shell) {
141
+ return {};
142
+ }
143
+
144
+ const args = [];
145
+ if (selectedTerminalId === 'git-bash') {
146
+ args.push('--login', '-i');
147
+ }
148
+
149
+ return { shell, args };
150
+ }
151
+
80
152
  /**
81
153
  * 获取终端启动命令(填充参数后)
82
154
  * @param {string} cwd - 工作目录
@@ -91,7 +163,14 @@ function getTerminalLaunchCommand(cwd, sessionId, toolType, customCliCommand) {
91
163
  throw new Error('No terminal available');
92
164
  }
93
165
 
166
+ if (terminal.supportsLocalLaunch === false || terminal.id === 'system-shell') {
167
+ throw new Error('系统 Shell 仅用于 Web 终端,请使用 Web 终端启动会话');
168
+ }
169
+
94
170
  let command = terminal.command;
171
+ if (!command) {
172
+ throw new Error('未配置终端启动命令');
173
+ }
95
174
 
96
175
  // 根据工具类型构建 CLI 命令
97
176
  let cliCommand;
@@ -136,5 +215,6 @@ module.exports = {
136
215
  loadTerminalConfig,
137
216
  saveTerminalConfig,
138
217
  getSelectedTerminal,
218
+ getWebTerminalShellConfig,
139
219
  getTerminalLaunchCommand
140
220
  };