evolclaw 2.0.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.
@@ -0,0 +1,823 @@
1
+ import { renameSession as sdkRenameSession, forkSession as sdkForkSession, listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk';
2
+ import { saveConfig, resolvePaths, getPackageRoot } from '../config.js';
3
+ import { logger } from '../utils/logger.js';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ const availableModels = ['opus', 'sonnet', 'haiku', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'];
7
+ /**
8
+ * 计算两个字符串的 Levenshtein 距离(编辑距离)
9
+ */
10
+ function levenshteinDistance(str1, str2) {
11
+ const len1 = str1.length;
12
+ const len2 = str2.length;
13
+ const matrix = [];
14
+ for (let i = 0; i <= len1; i++) {
15
+ matrix[i] = [i];
16
+ }
17
+ for (let j = 0; j <= len2; j++) {
18
+ matrix[0][j] = j;
19
+ }
20
+ for (let i = 1; i <= len1; i++) {
21
+ for (let j = 1; j <= len2; j++) {
22
+ if (str1[i - 1] === str2[j - 1]) {
23
+ matrix[i][j] = matrix[i - 1][j - 1];
24
+ }
25
+ else {
26
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, // 替换
27
+ matrix[i][j - 1] + 1, // 插入
28
+ matrix[i - 1][j] + 1 // 删除
29
+ );
30
+ }
31
+ }
32
+ }
33
+ return matrix[len1][len2];
34
+ }
35
+ function formatIdleTime(ms) {
36
+ const seconds = Math.floor(ms / 1000);
37
+ const minutes = Math.floor(seconds / 60);
38
+ const hours = Math.floor(minutes / 60);
39
+ const days = Math.floor(hours / 24);
40
+ if (days > 0)
41
+ return `${days}天前`;
42
+ if (hours > 0)
43
+ return `${hours}小时前`;
44
+ if (minutes > 0)
45
+ return `${minutes}分钟前`;
46
+ return '刚刚';
47
+ }
48
+ // 支持的命令列表
49
+ const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork'];
50
+ // 命令别名映射
51
+ const aliases = {
52
+ '/p': '/project',
53
+ '/s': '/session',
54
+ '/name': '/rename'
55
+ };
56
+ // 命令快速路径前缀(不进入消息队列的命令)
57
+ // 注意:/stop, /clear, /compact, /safe 故意不在此列表中,它们需要进入队列触发中断机制
58
+ const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/slist', '/session', '/rename', '/repair', '/fork', '/p ', '/s ', '/name '];
59
+ export class CommandHandler {
60
+ sessionManager;
61
+ agentRunner;
62
+ config;
63
+ messageCache;
64
+ adapters = new Map();
65
+ processor;
66
+ messageQueue;
67
+ constructor(sessionManager, agentRunner, config, messageCache) {
68
+ this.sessionManager = sessionManager;
69
+ this.agentRunner = agentRunner;
70
+ this.config = config;
71
+ this.messageCache = messageCache;
72
+ }
73
+ setProcessor(processor) {
74
+ this.processor = processor;
75
+ }
76
+ setMessageQueue(messageQueue) {
77
+ this.messageQueue = messageQueue;
78
+ }
79
+ registerAdapter(adapter) {
80
+ this.adapters.set(adapter.name, adapter);
81
+ }
82
+ /**
83
+ * 快速判断是否为命令(不进队列的命令)
84
+ */
85
+ isCommand(content) {
86
+ return content === '/p' || content === '/s' || quickCommandPrefixes.some(cmd => content.startsWith(cmd));
87
+ }
88
+ /**
89
+ * 主命令处理入口
90
+ */
91
+ async handle(content, channel, channelId, sendMessage, userId) {
92
+ // 规范化命令(将别名转换为完整命令)
93
+ let normalizedContent = content;
94
+ for (const [alias, full] of Object.entries(aliases)) {
95
+ if (content === alias || content.startsWith(alias + ' ')) {
96
+ normalizedContent = content.replace(alias, full);
97
+ break;
98
+ }
99
+ }
100
+ // 权限检查:只有主人可以执行斜杠命令
101
+ if (normalizedContent.startsWith('/')) {
102
+ const { isOwner } = await import('../config.js');
103
+ if (userId && !isOwner(this.config, channel, userId)) {
104
+ return '❌ 无权限:只有主人可以执行命令';
105
+ }
106
+ }
107
+ // 检查是否以 / 开头(可能是命令)
108
+ if (normalizedContent.startsWith('/')) {
109
+ const inputCmd = normalizedContent.split(' ')[0];
110
+ const isValidCommand = commands.some(cmd => normalizedContent.startsWith(cmd));
111
+ if (!isValidCommand) {
112
+ const similar = commands.find(cmd => {
113
+ const distance = levenshteinDistance(inputCmd, cmd);
114
+ return distance <= 2;
115
+ });
116
+ if (similar) {
117
+ return `❌ 未知命令: ${inputCmd}\n💡 你是不是想输入: ${similar}\n\n输入 /help 查看所有可用命令`;
118
+ }
119
+ else {
120
+ return `❌ 未知命令: ${inputCmd}\n\n输入 /help 查看所有可用命令`;
121
+ }
122
+ }
123
+ }
124
+ const isCmd = commands.some(cmd => normalizedContent.startsWith(cmd));
125
+ if (!isCmd)
126
+ return null;
127
+ // /help 命令不需要会话
128
+ if (normalizedContent === '/help') {
129
+ return `可用命令:
130
+ 📁 项目管理:
131
+ /pwd - 显示当前项目路径
132
+ /plist - 列出所有配置的项目
133
+ /p, /project <name|path> - 切换项目
134
+ /bind <path> - 绑定新项目目录
135
+
136
+ 🔄 会话管理:
137
+ /new [名称] - 创建新会话(可选命名)
138
+ /slist - 列出当前项目的所有会话
139
+ /s, /session <名称> - 切换到指定会话
140
+ /name, /rename <新名称> - 重命名当前会话
141
+ /fork [名称] - 分支当前会话(从当前对话点创建分支)
142
+ /status - 显示会话状态
143
+ /clear - 清空当前会话的对话历史
144
+ /compact - 压缩会话上下文(减少 token 用量)
145
+ /stop - 中断当前任务
146
+ /restart - 重启服务
147
+
148
+ 🛠️ 会话修复:
149
+ /repair - 检查并修复会话
150
+ /safe - 进入安全模式
151
+
152
+ 🤖 模型管理:
153
+ /model [model-id] - 查看或切换模型
154
+
155
+ ❓ 帮助:
156
+ /help - 显示此帮助信息`;
157
+ }
158
+ // /model 命令:查看或切换模型
159
+ if (normalizedContent.startsWith('/model')) {
160
+ const args = normalizedContent.slice(6).trim();
161
+ if (!args) {
162
+ const currentModel = this.agentRunner.getModel();
163
+ const modelList = availableModels.map(m => `- ${m}`).join('\n');
164
+ return `当前模型: ${currentModel}\n\n可用模型:\n${modelList}\n\n用法: /model <model-id>`;
165
+ }
166
+ if (!availableModels.includes(args)) {
167
+ const modelList = availableModels.map(m => `- ${m}`).join('\n');
168
+ return `❌ 无效的模型ID: ${args}\n\n可用模型:\n${modelList}`;
169
+ }
170
+ if (!this.config.anthropic)
171
+ this.config.anthropic = {};
172
+ this.config.anthropic.model = args;
173
+ saveConfig(this.config);
174
+ this.agentRunner.setModel(args);
175
+ return `✓ 已切换到模型: ${args}`;
176
+ }
177
+ // /stop 命令:中断当前任务
178
+ if (normalizedContent === '/stop') {
179
+ const sessionKey = `${channel}-${channelId}`;
180
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
181
+ if (queueLength === 0) {
182
+ return '当前没有正在处理的任务';
183
+ }
184
+ await this.agentRunner.interrupt(sessionKey);
185
+ return '✓ 已发送中断信号,任务将尽快停止';
186
+ }
187
+ // /clear 命令:通过 SDK /clear 清空会话历史
188
+ if (normalizedContent === '/clear') {
189
+ const session = await this.sessionManager.getSession(channel, channelId);
190
+ if (!session) {
191
+ return '❌ 当前没有活跃会话\n使用 /new 创建新会话';
192
+ }
193
+ if (!session.claudeSessionId) {
194
+ return '❌ 当前会话没有历史记录,无需清空';
195
+ }
196
+ const projectPath = path.isAbsolute(session.projectPath)
197
+ ? session.projectPath
198
+ : path.resolve(process.cwd(), session.projectPath);
199
+ const cleared = await this.agentRunner.clearSession(session.claudeSessionId, projectPath);
200
+ if (cleared) {
201
+ await this.sessionManager.updateClaudeSessionIdBySessionId(session.id, '');
202
+ this.agentRunner.updateSessionId(session.id, '');
203
+ return '✅ 已清空当前会话的对话历史';
204
+ }
205
+ else {
206
+ return '❌ 清空会话失败,请稍后重试';
207
+ }
208
+ }
209
+ // /compact 命令:手动压缩会话上下文
210
+ if (normalizedContent === '/compact') {
211
+ const session = await this.sessionManager.getSession(channel, channelId);
212
+ if (!session) {
213
+ return '❌ 当前没有活跃会话\n使用 /new 创建新会话';
214
+ }
215
+ if (!session.claudeSessionId) {
216
+ return '❌ 当前会话没有历史记录,无需压缩';
217
+ }
218
+ const projectPath = path.isAbsolute(session.projectPath)
219
+ ? session.projectPath
220
+ : path.resolve(process.cwd(), session.projectPath);
221
+ if (sendMessage) {
222
+ await sendMessage(channelId, '⏳ 正在压缩会话上下文...');
223
+ }
224
+ const compacted = await this.agentRunner.compactSession(session.id, session.claudeSessionId, projectPath);
225
+ if (compacted) {
226
+ return '✅ 会话上下文已压缩';
227
+ }
228
+ else {
229
+ return '❌ 会话压缩失败,请稍后重试';
230
+ }
231
+ }
232
+ // 尝试获取活跃会话(所有命令都尝试获取,但不强制)
233
+ let session = await this.sessionManager.getActiveSession(channel, channelId);
234
+ // 对于需要创建会话的命令,如果没有会话则创建
235
+ if (!session && (normalizedContent.startsWith('/new') ||
236
+ normalizedContent.startsWith('/bind') ||
237
+ normalizedContent.startsWith('/project'))) {
238
+ session = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd());
239
+ }
240
+ // /status 命令:显示会话状态
241
+ if (normalizedContent === '/status') {
242
+ if (!session) {
243
+ return `📊 会话状态:
244
+
245
+ ❌ 当前未创建会话
246
+
247
+ 提示:发送任意消息或使用 /new 命令创建会话`;
248
+ }
249
+ const sessionKey = `${channel}-${channelId}`;
250
+ const isCurrentlyProcessing = this.messageQueue.isProcessing(sessionKey);
251
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
252
+ let activeStatus = session.isActive ? '✓ 活跃' : '休眠';
253
+ if (session.isActive && isCurrentlyProcessing) {
254
+ if (queueLength > 0) {
255
+ activeStatus += ` [处理中,队列${queueLength}条]`;
256
+ }
257
+ else {
258
+ activeStatus += ' [处理中]';
259
+ }
260
+ }
261
+ const projects = this.config.projects?.list || {};
262
+ const projectName = Object.entries(projects)
263
+ .find(([_, p]) => p === session.projectPath)?.[0] || path.basename(session.projectPath);
264
+ const health = await this.sessionManager.getHealthStatus(session.id);
265
+ const timeSinceSuccess = Date.now() - health.lastSuccessTime;
266
+ const timeStr = timeSinceSuccess < 60000 ? '刚刚' :
267
+ timeSinceSuccess < 3600000 ? `${Math.floor(timeSinceSuccess / 60000)}分钟前` :
268
+ `${Math.floor(timeSinceSuccess / 3600000)}小时前`;
269
+ // 获取会话文件信息并同步 name
270
+ let sessionTurns = 0;
271
+ if (session.claudeSessionId) {
272
+ const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.claudeSessionId);
273
+ sessionTurns = fileInfo.turns;
274
+ if (fileInfo.title && fileInfo.title !== session.name) {
275
+ await this.sessionManager.renameSession(session.id, fileInfo.title);
276
+ session.name = fileInfo.title;
277
+ }
278
+ }
279
+ const lines = [
280
+ '📊 会话状态:',
281
+ `渠道: ${channel} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`,
282
+ `会话ID: ${session.id}`,
283
+ `项目路径: ${session.projectPath}`,
284
+ `活跃状态: ${activeStatus}`,
285
+ `会话轮数: ${sessionTurns}`,
286
+ `异常计数: ${health.consecutiveErrors}`,
287
+ `安全模式: ${health.safeMode ? '是 ⚠️' : '否 ✓'}`,
288
+ `最后成功: ${timeStr}`,
289
+ `Claude会话: ${session.claudeSessionId || '(未初始化)'}`,
290
+ `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`,
291
+ `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`
292
+ ];
293
+ if (health.safeMode) {
294
+ lines.push('');
295
+ lines.push('⚠️ 当前处于安全模式(历史上下文已禁用)');
296
+ lines.push('');
297
+ lines.push('退出方式:');
298
+ lines.push('1. /repair - 检查并修复会话(推荐,保留历史)');
299
+ lines.push('2. /new [名称] - 创建新会话(清空历史)');
300
+ }
301
+ if (health.lastError) {
302
+ lines.push('');
303
+ lines.push(`最后错误: ${health.lastErrorType || 'unknown'}`);
304
+ lines.push(`错误信息: ${health.lastError.substring(0, 100)}`);
305
+ }
306
+ return lines.join('\n');
307
+ }
308
+ // /new 命令:创建新会话(支持命名)
309
+ if (normalizedContent.startsWith('/new')) {
310
+ const sessionName = normalizedContent.slice(4).trim() || undefined;
311
+ if (sessionName) {
312
+ const existing = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
313
+ if (existing) {
314
+ return `❌ 会话名称 "${sessionName}" 已存在,请使用其他名称`;
315
+ }
316
+ }
317
+ const projectPath = session?.projectPath || this.config.projects?.defaultPath || process.cwd();
318
+ const newSession = await this.sessionManager.createNewSession(channel, channelId, projectPath, sessionName);
319
+ if (session) {
320
+ await this.agentRunner.closeSession(session.id);
321
+ }
322
+ return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /slist 查看`;
323
+ }
324
+ // /restart 命令:重启服务
325
+ if (normalizedContent === '/restart') {
326
+ const allSessions = await this.sessionManager.listSessions(channel, channelId);
327
+ const sessionsWithMessages = allSessions
328
+ .filter(s => this.messageCache.hasMessages(s.id))
329
+ .map(s => {
330
+ const count = this.messageCache.getCount(s.id);
331
+ return `${s.projectPath} 有 ${count} 条新消息`;
332
+ });
333
+ if (sessionsWithMessages.length > 0) {
334
+ const restartKey = `${channel}-${channelId}`;
335
+ const restartConfirmFile = path.join(resolvePaths().dataDir, `restart-confirm-${restartKey}.json`);
336
+ if (fs.existsSync(restartConfirmFile)) {
337
+ const confirmInfo = JSON.parse(fs.readFileSync(restartConfirmFile, 'utf-8'));
338
+ const now = Date.now();
339
+ if (now - confirmInfo.timestamp < 10000) {
340
+ fs.unlinkSync(restartConfirmFile);
341
+ }
342
+ else {
343
+ fs.writeFileSync(restartConfirmFile, JSON.stringify({ timestamp: now }));
344
+ return sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。';
345
+ }
346
+ }
347
+ else {
348
+ fs.writeFileSync(restartConfirmFile, JSON.stringify({ timestamp: Date.now() }));
349
+ return sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。';
350
+ }
351
+ }
352
+ const restartInfo = {
353
+ channel,
354
+ channelId,
355
+ timestamp: Date.now()
356
+ };
357
+ fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
358
+ const { spawn } = await import('child_process');
359
+ spawn('node', [path.join(getPackageRoot(), 'dist', 'cli.js'), 'restart-monitor'], {
360
+ detached: true,
361
+ stdio: 'ignore',
362
+ env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
363
+ }).unref();
364
+ setTimeout(() => {
365
+ logger.info('[System] Restarting by user command...');
366
+ process.exit(0);
367
+ }, 1000);
368
+ return '🔄 服务正在重启,请稍候...(约 5 秒后恢复)';
369
+ }
370
+ // /pwd 命令:显示当前项目路径
371
+ if (normalizedContent === '/pwd') {
372
+ if (!session) {
373
+ return `❌ 当前没有活跃会话
374
+
375
+ 提示:发送任意消息或使用 /new 命令创建会话`;
376
+ }
377
+ const projects = this.config.projects?.list || {};
378
+ let projectName = '';
379
+ for (const [name, projectPath] of Object.entries(projects)) {
380
+ if (projectPath === session.projectPath) {
381
+ projectName = name;
382
+ break;
383
+ }
384
+ }
385
+ if (projectName) {
386
+ return `当前项目: ${projectName}\n路径: ${session.projectPath}`;
387
+ }
388
+ else {
389
+ return `当前项目: ${session.projectPath}`;
390
+ }
391
+ }
392
+ // /plist 命令:列出所有项目
393
+ if (normalizedContent === '/plist') {
394
+ const projects = this.config.projects?.list || {};
395
+ const isGroup = await this.isGroupChat(channel, channelId);
396
+ if (isGroup) {
397
+ if (!session) {
398
+ return `❌ 当前群聊未绑定项目
399
+
400
+ 请使用 /bind <项目路径> 绑定项目`;
401
+ }
402
+ const projectName = Object.entries(projects)
403
+ .find(([_, p]) => p === session.projectPath)?.[0] || path.basename(session.projectPath);
404
+ const sessionKey = `${channel}-${channelId}`;
405
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
406
+ const status = queueLength > 0 ? '[处理中]' : '[空闲]';
407
+ return `当前群聊绑定的项目:
408
+ ${projectName} (${session.projectPath}) - ${status}
409
+
410
+ 提示:群聊不支持切换项目`;
411
+ }
412
+ const lines = ['可用项目:'];
413
+ const sessionKey = `${channel}-${channelId}`;
414
+ const processingProject = this.messageQueue.getProcessingProject(sessionKey);
415
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
416
+ const normalizePath = (p) => p.replace(/\/+$/, '');
417
+ for (const [name, projectPath] of Object.entries(projects)) {
418
+ const isCurrent = session?.projectPath === projectPath;
419
+ const prefix = isCurrent ? ' ✓' : ' ';
420
+ const projectSession = await this.sessionManager.getSessionByProjectPath(channel, channelId, projectPath);
421
+ if (!projectSession) {
422
+ lines.push(`${prefix} ${name} (${projectPath}) - 无会话`);
423
+ continue;
424
+ }
425
+ const statusParts = [];
426
+ if (isCurrent) {
427
+ statusParts.push('活跃');
428
+ }
429
+ else {
430
+ const idleMs = Date.now() - projectSession.updatedAt;
431
+ statusParts.push(formatIdleTime(idleMs));
432
+ }
433
+ if (processingProject && normalizePath(processingProject) === normalizePath(projectPath)) {
434
+ if (queueLength > 1) {
435
+ statusParts.push(`[处理中,队列${queueLength - 1}条]`);
436
+ }
437
+ else {
438
+ statusParts.push('[处理中]');
439
+ }
440
+ }
441
+ const unreadCount = this.messageCache.getCount(projectSession.id);
442
+ if (unreadCount > 0) {
443
+ statusParts.push(`[${unreadCount}条新消息]`);
444
+ }
445
+ else if (!processingProject || normalizePath(processingProject) !== normalizePath(projectPath)) {
446
+ statusParts.push('[空闲]');
447
+ }
448
+ lines.push(`${prefix} ${name} (${projectPath}) - ${statusParts.join(' ')}`);
449
+ }
450
+ return lines.join('\n');
451
+ }
452
+ // /project 命令:切换项目(支持名称或路径)
453
+ if (normalizedContent.startsWith('/project ')) {
454
+ const isGroup = await this.isGroupChat(channel, channelId);
455
+ if (isGroup) {
456
+ return `❌ 群聊不支持切换项目
457
+
458
+ 群聊只能绑定一个项目。如需更换项目,请联系管理员重新配置。`;
459
+ }
460
+ const arg = normalizedContent.slice(9).trim();
461
+ if (!arg)
462
+ return '用法: /p <name|path> 或 /project <name|path>';
463
+ let projectPath;
464
+ let projectName;
465
+ if (arg.includes('/')) {
466
+ if (!path.isAbsolute(arg)) {
467
+ return '❌ 项目路径必须是绝对路径';
468
+ }
469
+ if (!fs.existsSync(arg)) {
470
+ return `❌ 路径不存在: ${arg}`;
471
+ }
472
+ projectPath = arg;
473
+ projectName = path.basename(arg);
474
+ }
475
+ else {
476
+ const projects = this.config.projects?.list || {};
477
+ projectPath = projects[arg];
478
+ if (!projectPath) {
479
+ return `❌ 项目 "${arg}" 不存在\n提示: 使用 /plist 查看可用项目`;
480
+ }
481
+ projectName = arg;
482
+ }
483
+ if (session) {
484
+ const normalizedSessionPath = path.resolve(session.projectPath);
485
+ const normalizedProjectPath = path.resolve(projectPath);
486
+ if (normalizedSessionPath === normalizedProjectPath) {
487
+ return `当前已在项目: ${projectName}\n 路径: ${projectPath}`;
488
+ }
489
+ }
490
+ const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath);
491
+ const cachedEvents = this.messageCache.getEvents(newSession.id);
492
+ const hasExistingSession = newSession.claudeSessionId ? '(恢复已有会话)' : '(新建会话)';
493
+ let response = `✓ 已切换到项目: ${projectName}\n 路径: ${projectPath}\n ${hasExistingSession}`;
494
+ if (cachedEvents.length > 0 && sendMessage) {
495
+ for (const event of cachedEvents) {
496
+ if (event.type === 'completed') {
497
+ response += `\n\n后台任务完成`;
498
+ if (event.metadata?.duration) {
499
+ response += ` (耗时: ${Math.round(event.metadata.duration / 1000)}s)`;
500
+ }
501
+ }
502
+ else if (event.type === 'error') {
503
+ response += `\n\n后台任务失败: ${event.metadata?.errorType || '未知错误'}`;
504
+ }
505
+ }
506
+ await sendMessage(channelId, response);
507
+ for (const event of cachedEvents) {
508
+ await sendMessage(channelId, event.message);
509
+ }
510
+ this.messageCache.clearEvents(newSession.id);
511
+ return '';
512
+ }
513
+ return response;
514
+ }
515
+ // /bind 命令:绑定新项目目录
516
+ if (normalizedContent.startsWith('/bind ')) {
517
+ const projectPath = normalizedContent.slice(6).trim();
518
+ if (!projectPath)
519
+ return '用法: /bind <path>';
520
+ if (!path.isAbsolute(projectPath)) {
521
+ return '❌ 项目路径必须是绝对路径';
522
+ }
523
+ if (!fs.existsSync(projectPath)) {
524
+ return `❌ 路径不存在: ${projectPath}`;
525
+ }
526
+ const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath);
527
+ const cachedEvents = this.messageCache.getEvents(newSession.id);
528
+ const hasExistingSession = newSession.claudeSessionId ? '(恢复已有会话)' : '(新建会话)';
529
+ let response = `✓ 已绑定项目目录: ${projectPath}\n ${hasExistingSession}`;
530
+ if (cachedEvents.length > 0) {
531
+ response += `\n\n后台任务结果:`;
532
+ for (const event of cachedEvents) {
533
+ if (event.type === 'completed') {
534
+ response += `\n✓ 任务完成`;
535
+ if (event.metadata?.duration) {
536
+ response += ` (耗时: ${Math.round(event.metadata.duration / 1000)}s)`;
537
+ }
538
+ const summary = event.message.substring(0, 200);
539
+ response += `\n${summary}${event.message.length > 200 ? '...' : ''}`;
540
+ }
541
+ else if (event.type === 'error') {
542
+ response += `\n❌ 任务失败: ${event.metadata?.errorType || '未知错误'}`;
543
+ response += `\n${event.message}`;
544
+ }
545
+ }
546
+ this.messageCache.clearEvents(newSession.id);
547
+ }
548
+ return response;
549
+ }
550
+ // /slist 命令:列出当前项目的所有会话
551
+ if (normalizedContent === '/slist') {
552
+ if (!session) {
553
+ return `❌ 当前没有活跃会话
554
+
555
+ 请先执行以下操作之一:
556
+ 1. 发送任意消息 - 自动创建新会话
557
+ 2. /new [名称] - 创建命名会话
558
+ 3. /project <项目> - 切换到指定项目`;
559
+ }
560
+ const sessions = await this.sessionManager.listSessions(channel, channelId);
561
+ const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath);
562
+ // 从 SDK 同步会话名称(发现 CLI 改名)
563
+ try {
564
+ const sdkSessions = await sdkListSessions({ dir: session.projectPath });
565
+ for (const sdkSession of sdkSessions) {
566
+ const sdkName = sdkSession.customTitle || undefined;
567
+ if (!sdkName)
568
+ continue;
569
+ const dbSession = currentProjectSessions.find(s => s.claudeSessionId === sdkSession.sessionId);
570
+ if (dbSession && sdkName !== dbSession.name) {
571
+ await this.sessionManager.renameSession(dbSession.id, sdkName);
572
+ dbSession.name = sdkName;
573
+ }
574
+ }
575
+ }
576
+ catch (error) {
577
+ logger.debug('[CommandHandler] SDK listSessions sync failed (non-critical):', error);
578
+ }
579
+ const isGroup = await this.isGroupChat(channel, channelId);
580
+ const cliSessions = isGroup
581
+ ? []
582
+ : await this.sessionManager.scanCliSessions(session.projectPath);
583
+ const dbSessionIds = new Set(currentProjectSessions.map(s => s.claudeSessionId).filter(Boolean));
584
+ const lines = [`当前项目 ${path.basename(session.projectPath)} 的会话列表:\n`];
585
+ const sessionKey = `${channel}-${channelId}`;
586
+ const isProcessing = this.messageQueue.isProcessing(sessionKey);
587
+ if (currentProjectSessions.length > 0) {
588
+ lines.push('【EvolClaw 会话】');
589
+ for (const s of currentProjectSessions) {
590
+ const prefix = s.isActive ? ' ✓' : ' ';
591
+ const name = s.name || '(未命名)';
592
+ const uuid = s.claudeSessionId ? `(${s.claudeSessionId.substring(0, 8)})` : '';
593
+ const idleTime = formatIdleTime(Date.now() - s.updatedAt);
594
+ if (s.claudeSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.claudeSessionId)) {
595
+ lines.push(`${prefix} ❌ ${name} ${uuid} - ${idleTime} [会话文件缺失]`);
596
+ }
597
+ else {
598
+ let status = '[空闲]';
599
+ if (s.isActive && isProcessing) {
600
+ status = '[处理中]';
601
+ }
602
+ else if (s.isActive) {
603
+ status = '[活跃]';
604
+ }
605
+ lines.push(`${prefix} ${name} ${uuid} - ${idleTime} ${status}`);
606
+ }
607
+ }
608
+ lines.push('');
609
+ }
610
+ const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid)).slice(0, 5);
611
+ if (orphanCliSessions.length > 0) {
612
+ lines.push('【CLI 会话】(最新5个)');
613
+ for (const c of orphanCliSessions) {
614
+ const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
615
+ const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid) || '(无消息)';
616
+ const uuid = c.uuid.substring(0, 8);
617
+ lines.push(` ${time} (${uuid}) "${message}"`);
618
+ }
619
+ lines.push('');
620
+ }
621
+ lines.push('使用 /s <name或8位uuid> 切换会话');
622
+ return lines.join('\n');
623
+ }
624
+ // /session 或 /s 命令:切换会话
625
+ if (normalizedContent.startsWith('/session ')) {
626
+ const sessionName = normalizedContent.slice(9).trim();
627
+ if (!sessionName)
628
+ return '用法: /s <会话名称或前8位UUID>';
629
+ const sessionKey = `${channel}-${channelId}`;
630
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
631
+ if (queueLength > 0) {
632
+ return `⚠️ 当前正在处理消息,无法切换会话\n请等待当前任务完成后再试`;
633
+ }
634
+ let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
635
+ if (!targetSession && sessionName.length === 8) {
636
+ targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
637
+ }
638
+ const isGroup = await this.isGroupChat(channel, channelId);
639
+ if (!targetSession && sessionName.length === 8 && !isGroup) {
640
+ const projects = this.config.projects?.list || {};
641
+ const projectPaths = Object.values(projects);
642
+ if (session) {
643
+ projectPaths.unshift(session.projectPath);
644
+ }
645
+ for (const projectPath of projectPaths) {
646
+ const cliSessions = await this.sessionManager.scanCliSessions(projectPath);
647
+ const cliSession = cliSessions.find(c => c.uuid.startsWith(sessionName));
648
+ if (cliSession) {
649
+ const imported = await this.sessionManager.importCliSession(channel, channelId, projectPath, cliSession.uuid);
650
+ const projectName = Object.entries(projects).find(([_, p]) => p === projectPath)?.[0] || path.basename(projectPath);
651
+ return `✓ 已导入 CLI 会话: ${imported.name}\n 项目: ${projectName}\n 将继续之前的对话历史`;
652
+ }
653
+ }
654
+ }
655
+ if (!targetSession) {
656
+ return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
657
+ }
658
+ const lastInput = targetSession.claudeSessionId
659
+ ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.claudeSessionId)
660
+ : null;
661
+ const lastInputLine = lastInput ? `\n 最后输入: "${lastInput}"` : '';
662
+ if (!session) {
663
+ const switched = await this.sessionManager.switchToSession(channel, channelId, targetSession.id);
664
+ if (!switched) {
665
+ return `❌ 切换会话失败`;
666
+ }
667
+ return `✓ 已切换到会话: ${targetSession.name || sessionName}\n 项目: ${path.basename(targetSession.projectPath)}${lastInputLine}`;
668
+ }
669
+ if (targetSession.id === session.id) {
670
+ return `当前已在会话: ${targetSession.name || sessionName}`;
671
+ }
672
+ const switched = await this.sessionManager.switchToSession(channel, channelId, targetSession.id);
673
+ if (!switched) {
674
+ return `❌ 切换会话失败`;
675
+ }
676
+ return `✓ 已切换到会话: ${targetSession.name || sessionName}\n 将继续之前的对话历史${lastInputLine}`;
677
+ }
678
+ // /rename 或 /name 命令:重命名当前会话
679
+ if (normalizedContent.startsWith('/rename ')) {
680
+ const newName = normalizedContent.slice(8).trim();
681
+ if (!newName)
682
+ return '用法: /name <新名称> 或 /rename <新名称>';
683
+ if (!session) {
684
+ return `❌ 当前没有活跃会话
685
+
686
+ 请先执行以下操作之一:
687
+ 1. 发送任意消息 - 自动创建新会话
688
+ 2. /new [名称] - 创建命名会话
689
+ 3. /session <名称> - 切换到已有会话`;
690
+ }
691
+ const existing = await this.sessionManager.getSessionByName(channel, channelId, newName);
692
+ if (existing && existing.id !== session.id) {
693
+ return `❌ 会话名称 "${newName}" 已存在,请使用其他名称`;
694
+ }
695
+ // 双写:SDK + 数据库
696
+ if (session.claudeSessionId) {
697
+ try {
698
+ await sdkRenameSession(session.claudeSessionId, newName, { dir: session.projectPath });
699
+ }
700
+ catch (error) {
701
+ logger.warn(`[CommandHandler] SDK renameSession failed (continuing with db update):`, error);
702
+ }
703
+ }
704
+ const success = await this.sessionManager.renameSession(session.id, newName);
705
+ if (!success) {
706
+ return `❌ 重命名失败`;
707
+ }
708
+ return `✓ 已将当前会话重命名为: ${newName}`;
709
+ }
710
+ // /fork 命令:分支当前会话
711
+ if (normalizedContent === '/fork' || normalizedContent.startsWith('/fork ')) {
712
+ const forkName = normalizedContent.slice(5).trim() || undefined;
713
+ if (!session) {
714
+ return `❌ 当前没有活跃会话,无法分支`;
715
+ }
716
+ if (!session.claudeSessionId) {
717
+ return `❌ 当前会话尚未初始化 Claude 对话,无法分支\n\n请先发送一条消息,然后再使用 /fork`;
718
+ }
719
+ try {
720
+ const forkResult = await sdkForkSession(session.claudeSessionId, { dir: session.projectPath, title: forkName });
721
+ const newSession = await this.sessionManager.createForkedSession(session, forkResult.sessionId, forkName);
722
+ return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /slist 查看所有会话,/s <名称> 切换回原会话`;
723
+ }
724
+ catch (error) {
725
+ logger.error('[CommandHandler] Fork session failed:', error);
726
+ return `❌ 会话分支失败: ${error instanceof Error ? error.message : '未知错误'}`;
727
+ }
728
+ }
729
+ // /repair 命令:检查并修复会话
730
+ if (normalizedContent === '/repair') {
731
+ if (!session) {
732
+ return `❌ 当前未创建会话,无需修复`;
733
+ }
734
+ const health = await this.sessionManager.getHealthStatus(session.id);
735
+ if (!health.safeMode) {
736
+ return `当前不在安全模式,无需修复\n\n如需进入安全模式,请使用 /safe`;
737
+ }
738
+ const { checkSessionFileHealth, backupClaudeDir } = await import('../utils/session-file-health.js');
739
+ const fsPromises = await import('fs/promises');
740
+ try {
741
+ const backupDir = await backupClaudeDir(session.projectPath);
742
+ if (!session.claudeSessionId) {
743
+ await this.sessionManager.resetHealthStatus(session.id);
744
+ return `✓ 修复完成,已退出安全模式
745
+
746
+ 修复内容:
747
+ - 未发现问题(新会话)
748
+ - 已重置异常计数器
749
+ - 已恢复正常会话模式
750
+
751
+ 备份位置:${backupDir}`;
752
+ }
753
+ const healthCheck = await checkSessionFileHealth(session.projectPath, session.claudeSessionId);
754
+ if (healthCheck.corrupt) {
755
+ const sessionFile = path.join(session.projectPath, '.claude', `${session.claudeSessionId}.jsonl`);
756
+ await fsPromises.unlink(sessionFile);
757
+ await this.sessionManager.updateClaudeSessionId(session.channel, session.channelId, '');
758
+ await this.sessionManager.resetHealthStatus(session.id);
759
+ return `✓ 修复完成,已退出安全模式
760
+
761
+ 检测到问题:
762
+ ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
763
+
764
+ 修复操作:
765
+ - 已删除损坏文件
766
+ - 已创建新会话
767
+ - 已重置异常计数器
768
+
769
+ 备份位置:${backupDir}`;
770
+ }
771
+ if (healthCheck.issues.length > 0) {
772
+ await this.sessionManager.resetHealthStatus(session.id);
773
+ return `⚠️ 检测到问题:
774
+ ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
775
+
776
+ 建议:
777
+ 1. 使用 /new 创建新会话
778
+ 2. 旧会话已备份到:${backupDir}
779
+
780
+ 已重置异常计数器,可继续使用当前会话。`;
781
+ }
782
+ await this.sessionManager.resetHealthStatus(session.id);
783
+ return `✓ 修复完成,已退出安全模式
784
+
785
+ 修复内容:
786
+ - 未发现问题
787
+ - 已重置异常计数器
788
+ - 已恢复正常会话模式
789
+
790
+ 备份位置:${backupDir}`;
791
+ }
792
+ catch (error) {
793
+ logger.error('[Repair] Failed:', error);
794
+ return `❌ 修复失败: ${error.message}`;
795
+ }
796
+ }
797
+ // /safe 命令:手动进入安全模式
798
+ if (normalizedContent === '/safe') {
799
+ if (!session) {
800
+ return `❌ 当前未创建会话`;
801
+ }
802
+ await this.sessionManager.setSafeMode(session.id, true);
803
+ return `✓ 已进入安全模式
804
+
805
+ 当前行为:
806
+ - 暂时不加载会话历史(每次对话独立)
807
+ - 所有功能正常可用(读写文件、执行命令等)
808
+ - 不会丢失历史数据(仍保存在 .claude/ 目录)
809
+
810
+ 退出安全模式:
811
+ - 使用 /repair 检查并修复会话
812
+ - 使用 /new 创建全新会话`;
813
+ }
814
+ return null;
815
+ }
816
+ /**
817
+ * 通过 adapter 查询是否为群聊
818
+ */
819
+ async isGroupChat(channel, channelId) {
820
+ const adapter = this.adapters.get(channel);
821
+ return await adapter?.isGroupChat?.(channelId) ?? false;
822
+ }
823
+ }