evolclaw 2.1.2 → 2.3.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.
Files changed (54) hide show
  1. package/README.md +59 -30
  2. package/data/evolclaw.sample.json +15 -4
  3. package/dist/agents/claude-runner.js +685 -0
  4. package/dist/agents/codex-runner.js +315 -0
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +580 -10
  7. package/dist/channels/feishu.js +888 -135
  8. package/dist/channels/wechat.js +127 -21
  9. package/dist/cli.js +519 -136
  10. package/dist/config.js +277 -25
  11. package/dist/core/agent-loader.js +39 -0
  12. package/dist/core/channel-loader.js +67 -0
  13. package/dist/core/command-handler.js +1537 -392
  14. package/dist/core/event-bus.js +32 -0
  15. package/dist/core/interaction-router.js +68 -0
  16. package/dist/core/message/message-bridge.js +216 -0
  17. package/dist/core/message/message-processor.js +1028 -0
  18. package/dist/core/message/message-queue.js +240 -0
  19. package/dist/core/message/stream-debouncer.js +122 -0
  20. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  21. package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
  22. package/dist/core/permission.js +259 -0
  23. package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
  24. package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
  25. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  26. package/dist/core/session/session-file-adapter.js +7 -0
  27. package/dist/core/session/session-file-health.js +45 -0
  28. package/dist/core/session/session-manager.js +1072 -0
  29. package/dist/index.js +402 -252
  30. package/dist/ipc.js +106 -0
  31. package/dist/paths.js +1 -0
  32. package/dist/types.js +3 -0
  33. package/dist/utils/{platform.js → cross-platform.js} +38 -1
  34. package/dist/utils/error-utils.js +130 -5
  35. package/dist/utils/init-channel.js +649 -0
  36. package/dist/utils/init.js +190 -53
  37. package/dist/utils/logger.js +8 -3
  38. package/dist/utils/media-cache.js +207 -0
  39. package/dist/utils/migrate-project.js +122 -0
  40. package/dist/utils/rich-content-renderer.js +228 -0
  41. package/dist/utils/stats-collector.js +102 -0
  42. package/package.json +4 -2
  43. package/dist/core/agent-runner.js +0 -348
  44. package/dist/core/message-processor.js +0 -604
  45. package/dist/core/message-queue.js +0 -116
  46. package/dist/core/message-stream.js +0 -59
  47. package/dist/core/session-manager.js +0 -664
  48. package/dist/index.js.bak +0 -340
  49. package/dist/utils/init-feishu.js +0 -261
  50. package/dist/utils/init-wechat.js +0 -170
  51. package/dist/utils/markdown-to-feishu.js +0 -94
  52. package/dist/utils/permission.js +0 -43
  53. package/dist/utils/session-file-health.js +0 -68
  54. /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
@@ -1,16 +1,34 @@
1
- import { renameSession as sdkRenameSession, forkSession as sdkForkSession, listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk';
2
- import { resolvePaths, getPackageRoot } from '../config.js';
1
+ import { hasModelSwitcher, hasPermissionController } from '../agents/claude-runner.js';
2
+ import { saveConfig, resolvePaths, getPackageRoot, getOwner } from '../config.js';
3
3
  import { logger } from '../utils/logger.js';
4
+ import crypto from 'crypto';
4
5
  import path from 'path';
5
6
  import fs from 'fs';
6
7
  import os from 'os';
7
- const availableModels = ['opus', 'sonnet', 'haiku', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'];
8
- const availableEfforts = ['low', 'medium', 'high', 'max'];
9
- function effortBar(level) {
10
- const levels = {
11
- low: '◆◇◇◇', medium: '◆◆◇◇', high: '◆◆◆◇', max: '◆◆◆◆'
12
- };
13
- return levels[level] || '◆◆◇◇';
8
+ const allEfforts = ['low', 'medium', 'high', 'max'];
9
+ const nonMaxEfforts = allEfforts.filter(e => e !== 'max');
10
+ function getAvailableEfforts(agent, model) {
11
+ if (agent.name === 'claude') {
12
+ if (model.includes('opus'))
13
+ return allEfforts;
14
+ return nonMaxEfforts;
15
+ }
16
+ if (agent.name === 'codex') {
17
+ return nonMaxEfforts;
18
+ }
19
+ return [];
20
+ }
21
+ function formatModelUsage(agent, model) {
22
+ const efforts = getAvailableEfforts(agent, model);
23
+ const lines = [
24
+ '用法:',
25
+ ' /model <model> 切换模型',
26
+ ];
27
+ if (efforts.length > 0) {
28
+ lines.push(' /model <model> <effort> 切换模型+推理强度');
29
+ lines.push(' /effort [level] 查看或切换推理强度');
30
+ }
31
+ return lines.join('\n');
14
32
  }
15
33
  /**
16
34
  * 写入用户级 ~/.claude/settings.json(与 Claude CLI 行为一致)
@@ -85,30 +103,50 @@ function formatIdleTime(ms) {
85
103
  return '刚刚';
86
104
  }
87
105
  // 支持的命令列表
88
- const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork', '/del'];
106
+ const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork', '/del', '/perm', '/send', '/check'];
89
107
  // 命令别名映射
90
108
  const aliases = {
91
109
  '/p': '/project',
92
110
  '/s': '/session',
93
111
  '/name': '/rename'
94
112
  };
95
- // 命令快速路径前缀(不进入消息队列的命令)
96
- // 注意:/clear, /compact, /safe 故意不在此列表中,它们需要进入队列触发中断机制
97
- // /stop 是快速命令:直接调用 agentRunner.interrupt(),不走队列(否则队列自动中断后 /stop 检测不到活跃任务)
98
- const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/del', '/p ', '/s ', '/name '];
113
+ // 命令快速路径前缀(所有命令都不进入消息队列)
114
+ const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/send', '/check', '/p ', '/s ', '/name '];
99
115
  export class CommandHandler {
100
116
  sessionManager;
101
- agentRunner;
102
117
  config;
103
118
  messageCache;
119
+ eventBus;
104
120
  adapters = new Map();
121
+ policies = new Map();
122
+ channelObjects = new Map(); // name → actual channel instance (for /check)
123
+ channelTypeMap = new Map(); // name → channelType (for grouping)
105
124
  processor;
106
125
  messageQueue;
107
- constructor(sessionManager, agentRunner, config, messageCache) {
126
+ permissionGateway;
127
+ interactionRouter;
128
+ statsCollector;
129
+ agentMap;
130
+ defaultAgentId;
131
+ /** 按 agentId 获取 agent,回退到默认 */
132
+ getAgent(agentId) {
133
+ if (agentId && this.agentMap.has(agentId))
134
+ return this.agentMap.get(agentId);
135
+ return this.agentMap.get(this.defaultAgentId) || this.agentMap.values().next().value;
136
+ }
137
+ constructor(sessionManager, agentRunnerOrMap, config, messageCache, eventBus, defaultAgentId) {
108
138
  this.sessionManager = sessionManager;
109
- this.agentRunner = agentRunner;
110
139
  this.config = config;
111
140
  this.messageCache = messageCache;
141
+ this.eventBus = eventBus;
142
+ if (agentRunnerOrMap instanceof Map) {
143
+ this.agentMap = agentRunnerOrMap;
144
+ this.defaultAgentId = defaultAgentId || 'claude';
145
+ }
146
+ else {
147
+ this.agentMap = new Map([[agentRunnerOrMap.name, agentRunnerOrMap]]);
148
+ this.defaultAgentId = agentRunnerOrMap.name;
149
+ }
112
150
  }
113
151
  /** 项目列表快捷访问 */
114
152
  get projects() {
@@ -122,22 +160,104 @@ export class CommandHandler {
122
160
  getProjectName(projectPath) {
123
161
  return this.getConfiguredProjectName(projectPath) || path.basename(projectPath);
124
162
  }
163
+ /** 格式化运行时间 */
164
+ formatUptime(ms) {
165
+ const sec = Math.floor(ms / 1000);
166
+ const d = Math.floor(sec / 86400);
167
+ const h = Math.floor((sec % 86400) / 3600);
168
+ const m = Math.floor((sec % 3600) / 60);
169
+ const s = sec % 60;
170
+ const parts = [];
171
+ if (d > 0)
172
+ parts.push(`${d}天`);
173
+ if (h > 0)
174
+ parts.push(`${h}时`);
175
+ if (m > 0)
176
+ parts.push(`${m}分`);
177
+ if (parts.length === 0)
178
+ parts.push(`${s}秒`);
179
+ return parts.join('');
180
+ }
125
181
  /** 获取消息队列 key:话题用 session.id,主会话用 channel-channelId */
126
- getQueueKey(session, channel, channelId) {
127
- if (session?.threadId)
128
- return session.id;
129
- return `${channel}-${channelId}`;
182
+ getQueueKey(session, _channel, _channelId) {
183
+ // 队列和 agent 均使用 session.id 作为 key
184
+ return session?.id || '';
185
+ }
186
+ /** 从 session 提取渠道预构建的回复上下文 */
187
+ getReplyContext(session) {
188
+ return session.metadata?.replyContext;
189
+ }
190
+ /**
191
+ * 尝试通过渠道适配器发送交互卡片。
192
+ * 返回 message_id 表示卡片已发送,false 表示降级为文本。
193
+ */
194
+ async trySendInteraction(channel, channelId, interaction, replyContext) {
195
+ const adapter = this.adapters.get(channel);
196
+ if (!adapter?.sendInteraction)
197
+ return false;
198
+ try {
199
+ return await adapter.sendInteraction(channelId, interaction, replyContext);
200
+ }
201
+ catch (e) {
202
+ logger.warn(`[CommandHandler] sendInteraction failed: ${e}`);
203
+ return false;
204
+ }
205
+ }
206
+ /** 作废某 session 下所有 pending 交互卡片(PATCH 禁用 + cancel) */
207
+ async invalidateOldCards(channel, sessionId) {
208
+ if (!this.interactionRouter)
209
+ return;
210
+ const adapter = this.adapters.get(channel);
211
+ const pending = this.interactionRouter.getPending(sessionId);
212
+ if (pending.length === 0)
213
+ return;
214
+ const disabledCard = {
215
+ config: { wide_screen_mode: true },
216
+ header: { template: 'grey', title: { tag: 'plain_text', content: '已过期' } },
217
+ elements: [{ tag: 'markdown', content: '此卡片已过期,请查看最新卡片。' }],
218
+ };
219
+ for (const id of pending) {
220
+ const msgId = this.interactionRouter.getMessageId(id);
221
+ if (msgId && adapter?.patchInteractionCard) {
222
+ adapter.patchInteractionCard(msgId, disabledCard).catch(() => { });
223
+ }
224
+ this.interactionRouter.cancel(id);
225
+ }
130
226
  }
131
- /** 从 session 提取话题回复选项 */
132
- getThreadSendOpts(session) {
133
- const rootId = session.metadata?.feishu?.rootId;
134
- return rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined;
227
+ /**
228
+ * 发送交互卡片并注册回调。作废旧卡片 → 发送新卡片 → 注册到 interactionRouter。
229
+ * 返回 true 表示卡片已发送(调用方应 return null),false 表示降级到文本。
230
+ */
231
+ async sendInteractionCard(opts) {
232
+ if (!this.interactionRouter)
233
+ return false;
234
+ await this.invalidateOldCards(opts.channel, opts.sessionId);
235
+ const messageId = await this.trySendInteraction(opts.channel, opts.channelId, opts.interaction, opts.replyCtx);
236
+ if (!messageId)
237
+ return false;
238
+ const wrappedCallback = async (action, values, operatorId) => {
239
+ await opts.callback(action, values, operatorId);
240
+ const adapter = this.adapters.get(opts.channel);
241
+ if (adapter?.patchInteractionCard) {
242
+ const disabledCard = {
243
+ config: { wide_screen_mode: true },
244
+ header: { template: 'grey', title: { tag: 'plain_text', content: '已过期' } },
245
+ elements: [{ tag: 'markdown', content: '此卡片已过期,请查看最新卡片。' }],
246
+ };
247
+ adapter.patchInteractionCard(messageId, disabledCard).catch(() => { });
248
+ }
249
+ };
250
+ this.interactionRouter.register(opts.requestId, opts.sessionId, wrappedCallback, { timeoutMs: 120_000, messageId });
251
+ return true;
135
252
  }
136
253
  /** 获取活跃会话,无会话时返回统一错误提示 */
137
254
  async ensureSession(channel, channelId, threadId) {
138
255
  if (threadId) {
139
- // 话题会话:按 thread_id 查找
140
- const session = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
256
+ // 话题会话:仅查询,不创建
257
+ const session = await this.sessionManager.getThreadSession(channel, channelId, threadId);
258
+ if (!session) {
259
+ return { error: '❌ 话题中尚未创建会话\n发送消息后自动创建' };
260
+ }
141
261
  return { session };
142
262
  }
143
263
  const session = await this.sessionManager.getActiveSession(channel, channelId);
@@ -152,11 +272,130 @@ export class CommandHandler {
152
272
  setMessageQueue(messageQueue) {
153
273
  this.messageQueue = messageQueue;
154
274
  }
275
+ setPermissionGateway(gateway) {
276
+ this.permissionGateway = gateway;
277
+ }
278
+ setInteractionRouter(router) {
279
+ this.interactionRouter = router;
280
+ }
281
+ setStatsCollector(collector) {
282
+ this.statsCollector = collector;
283
+ }
155
284
  registerAdapter(adapter) {
156
- this.adapters.set(adapter.name, adapter);
285
+ this.adapters.set(adapter.channelName, adapter);
286
+ }
287
+ registerChannel(name, channel, channelType) {
288
+ this.channelObjects.set(name, channel);
289
+ if (channelType)
290
+ this.channelTypeMap.set(name, channelType);
291
+ }
292
+ /** 将实例名解析为渠道类型(用于 session 查询) */
293
+ resolveChannelType(channelName) {
294
+ return this.channelTypeMap.get(channelName) || channelName;
295
+ }
296
+ registerPolicy(channelName, policy) {
297
+ this.policies.set(channelName, policy);
157
298
  }
158
299
  getAdapter(channelName) {
159
- return this.adapters.get(channelName);
300
+ // 先按实例名查找,再按 channelType 查找
301
+ let adapter = this.adapters.get(channelName);
302
+ if (adapter)
303
+ return adapter;
304
+ for (const [name, a] of this.adapters) {
305
+ if ((this.channelTypeMap.get(name) || name) === channelName)
306
+ return a;
307
+ }
308
+ return undefined;
309
+ }
310
+ getPolicy(channel) {
311
+ return this.policies.get(channel) || {
312
+ canSwitchProject: () => true,
313
+ canListProjects: () => true,
314
+ canCreateSession: () => true,
315
+ canDeleteSession: () => true,
316
+ canImportCliSession: () => true,
317
+ messagePrefix: () => '',
318
+ showMiddleResult: () => true,
319
+ showIdleMonitor: () => true,
320
+ accumulateErrors: () => true,
321
+ };
322
+ }
323
+ /**
324
+ * 返回结构化命令菜单(供 menu.query 使用)
325
+ * admin 看到全部命令分组,guest 仅看到用户级命令
326
+ */
327
+ getMenuItems(isAdmin) {
328
+ const items = [];
329
+ if (isAdmin) {
330
+ items.push({
331
+ group: '项目管理',
332
+ commands: [
333
+ { cmd: '/pwd', label: '显示当前项目路径' },
334
+ { cmd: '/plist', label: '列出所有配置的项目' },
335
+ { cmd: '/p', args: '<name|path>', label: '切换项目' },
336
+ { cmd: '/bind', args: '<path>', label: '绑定新项目目录' },
337
+ ]
338
+ });
339
+ }
340
+ items.push({
341
+ group: '会话管理',
342
+ commands: [
343
+ { cmd: '/new', args: '[name]', label: '创建新会话' },
344
+ { cmd: '/slist', label: '列出当前项目的所有会话' },
345
+ { cmd: '/slist', args: 'cli', label: '列出 CLI 会话(未导入的)' },
346
+ { cmd: '/s', args: '<name|index|uuid>', label: '切换到指定会话' },
347
+ { cmd: '/name', args: '<name>', label: '重命名当前会话' },
348
+ { cmd: '/del', args: '<name>', label: '删除指定会话' },
349
+ ...(isAdmin ? [
350
+ { cmd: '/fork', args: '[name]', label: '分支当前会话' },
351
+ { cmd: '/clear', label: '清空会话对话历史' },
352
+ { cmd: '/compact', label: '压缩会话上下文' },
353
+ ] : []),
354
+ ]
355
+ });
356
+ if (isAdmin) {
357
+ items.push({
358
+ group: 'Agent 与模型',
359
+ commands: [
360
+ { cmd: '/agent', args: '[name]', label: '查看或切换 Agent 后端' },
361
+ { cmd: '/model', args: '[model]', label: '查看或切换模型' },
362
+ { cmd: '/effort', args: '[level]', label: '查看或切换推理强度' },
363
+ ]
364
+ });
365
+ items.push({
366
+ group: '权限管理',
367
+ commands: [
368
+ { cmd: '/perm', args: '[mode|allow|always|deny]', label: '权限模式管理' },
369
+ ]
370
+ });
371
+ items.push({
372
+ group: '运维',
373
+ commands: [
374
+ { cmd: '/status', label: '显示会话状态' },
375
+ { cmd: '/stop', label: '中断当前任务' },
376
+ { cmd: '/restart', label: '重启服务' },
377
+ { cmd: '/repair', label: '检查并修复会话' },
378
+ { cmd: '/safe', label: '进入安全模式' },
379
+ { cmd: '/send', args: '[渠道] <path>', label: '发送项目内文件' },
380
+ { cmd: '/check', args: '[rty <channel>]', label: '检查渠道状态或重连指定渠道' },
381
+ ]
382
+ });
383
+ }
384
+ else {
385
+ items.push({
386
+ group: '其他',
387
+ commands: [
388
+ { cmd: '/status', label: '显示会话状态' },
389
+ ]
390
+ });
391
+ }
392
+ items.push({
393
+ group: '帮助',
394
+ commands: [
395
+ { cmd: '/help', label: '显示帮助信息' },
396
+ ]
397
+ });
398
+ return items;
160
399
  }
161
400
  /**
162
401
  * 快速判断是否为命令(不进队列的命令)
@@ -168,6 +407,12 @@ export class CommandHandler {
168
407
  * 主命令处理入口
169
408
  */
170
409
  async handle(content, channel, channelId, sendMessage, userId, threadId) {
410
+ // 解析身份(按实例名)
411
+ const identity = this.sessionManager.resolveIdentity(channel, userId);
412
+ const policy = this.getPolicy(channel);
413
+ // 按当前会话选择 agent 后端
414
+ const activeSession = await this.sessionManager.getActiveSession(channel, channelId);
415
+ const agent = this.getAgent(activeSession?.agentId);
171
416
  // 规范化命令(将别名转换为完整命令)
172
417
  let normalizedContent = content;
173
418
  for (const [alias, full] of Object.entries(aliases)) {
@@ -176,16 +421,19 @@ export class CommandHandler {
176
421
  break;
177
422
  }
178
423
  }
179
- // 权限检查:区分用户级命令和管理级命令
180
- const { isOwner: checkOwner } = await import('../config.js');
424
+ if (normalizedContent !== content) {
425
+ logger.debug(`[CommandHandler] normalized: "${content}" -> "${normalizedContent}"`);
426
+ }
181
427
  // 话题内禁用部分命令
182
428
  if (threadId) {
183
- const threadBlocked = ['/new', '/slist', '/plist', '/bind', '/s', '/session', '/project', '/p', '/fork', '/del'];
429
+ const threadBlocked = ['/new', '/slist', '/plist', '/bind', '/s', '/session', '/project', '/p', '/fork', '/del', '/agent'];
184
430
  const isBlocked = threadBlocked.some(c => normalizedContent === c || normalizedContent.startsWith(c + ' '));
185
- if (isBlocked)
431
+ if (isBlocked) {
186
432
  return '⚠️ 话题中不支持此命令';
433
+ }
187
434
  }
188
- const isAdmin = !userId || checkOwner(this.config, channel, userId);
435
+ // 权限检查:区分用户级命令和管理级命令
436
+ const isAdmin = identity.role === 'owner';
189
437
  if (normalizedContent.startsWith('/')) {
190
438
  const userCommands = ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
191
439
  const isUserCommand = userCommands.some(cmd => normalizedContent === cmd.trimEnd() || normalizedContent.startsWith(cmd));
@@ -193,6 +441,23 @@ export class CommandHandler {
193
441
  return '❌ 无权限:此命令仅限管理员使用';
194
442
  }
195
443
  }
444
+ // 空闲检查:某些命令需要等待当前会话空闲
445
+ const requiresIdle = ['/new', '/session', '/clear', '/compact', '/safe', '/repair', '/fork', '/bind', '/project', '/agent'];
446
+ if (requiresIdle.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' '))) {
447
+ if (threadId) {
448
+ // 话题中:检查话题 session 是否在处理(不创建)
449
+ const threadSession = await this.sessionManager.getThreadSession(channel, channelId, threadId);
450
+ if (threadSession) {
451
+ const threadAgent = this.getAgent(threadSession.agentId);
452
+ if (threadAgent.hasActiveStream(threadSession.id)) {
453
+ return '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试';
454
+ }
455
+ }
456
+ }
457
+ else if (activeSession && agent.hasActiveStream(activeSession.id)) {
458
+ return '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试';
459
+ }
460
+ }
196
461
  // 检查是否以 / 开头(可能是命令)
197
462
  if (normalizedContent.startsWith('/')) {
198
463
  const inputCmd = normalizedContent.split(' ')[0];
@@ -212,153 +477,581 @@ export class CommandHandler {
212
477
  }
213
478
  const isCmd = commands.some(cmd => normalizedContent.startsWith(cmd));
214
479
  if (!isCmd)
215
- return null;
480
+ return undefined;
216
481
  // /help 命令不需要会话
217
482
  if (normalizedContent === '/help') {
218
483
  if (!isAdmin) {
219
- return `可用命令:
220
- 🔄 会话管理:
221
- /new [名称] - 创建新会话(可选命名)
222
- /slist - 列出当前项目的所有会话
223
- /s, /session <名称> - 切换到指定会话
224
- /name, /rename <新名称> - 重命名当前会话
225
- /del <名称> - 删除指定会话(仅解绑,不删除文件)
226
- /status - 显示会话状态
227
-
228
- 帮助:
229
- /help - 显示此帮助信息`;
230
- }
231
- return `可用命令:
232
- 📁 项目管理:
233
- /pwd - 显示当前项目路径
234
- /plist - 列出所有配置的项目
235
- /p, /project <name|path> - 切换项目
236
- /bind <path> - 绑定新项目目录
237
-
238
- 🔄 会话管理:
239
- /new [名称] - 创建新会话(可选命名)
240
- /slist - 列出当前项目的所有会话
241
- /s, /session <名称> - 切换到指定会话
242
- /name, /rename <新名称> - 重命名当前会话
243
- /del <名称> - 删除指定会话(仅解绑,不删除文件)
244
- /fork [名称] - 分支当前会话(从当前对话点创建分支)
245
- /status - 显示会话状态
246
- /clear - 清空当前会话的对话历史
247
- /compact - 压缩会话上下文(减少 token 用量)
248
- /stop - 中断当前任务
249
- /restart - 重启服务
250
-
251
- 🛠️ 会话修复:
252
- /repair - 检查并修复会话
253
- /safe - 进入安全模式
254
-
255
- 🤖 模型管理:
256
- /model [model] [effort] - 查看或切换模型/推理强度
257
-
258
- 帮助:
259
- /help - 显示此帮助信息`;
484
+ const lines = [
485
+ '可用命令:',
486
+ '',
487
+ '🔄 会话管理:',
488
+ ' /new [名称] - 创建新会话(可选命名)',
489
+ ' /slist - 列出当前项目的所有会话',
490
+ ' /slist cli - 列出 CLI 会话(未导入的)',
491
+ ' /s, /session <名称|序号|uuid> - 切换到指定会话',
492
+ ' /name, /rename <新名称> - 重命名当前会话',
493
+ ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
494
+ ' /status - 显示会话状态',
495
+ '',
496
+ '❓ 帮助:',
497
+ ' /help - 显示此帮助信息',
498
+ ];
499
+ return lines.join('\n');
500
+ }
501
+ const lines = [
502
+ '可用命令:',
503
+ '',
504
+ '📁 项目管理:',
505
+ ' /pwd - 显示当前项目路径',
506
+ ' /plist - 列出所有配置的项目',
507
+ ' /p, /project <name|path> - 切换项目',
508
+ ' /bind <path> - 绑定新项目目录',
509
+ '',
510
+ '🔄 会话管理:',
511
+ ' /new [名称] - 创建新会话(可选命名)',
512
+ ' /slist - 列出当前项目的所有会话',
513
+ ' /s, /session <名称> - 切换到指定会话',
514
+ ' /name, /rename <新名称> - 重命名当前会话',
515
+ ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
516
+ ' /fork [名称] - 分支当前会话(从当前对话点创建分支)',
517
+ ' /clear - 清空当前会话的对话历史',
518
+ ' /compact - 压缩会话上下文(减少 token 用量)',
519
+ '',
520
+ '🤖 Agent 与模型:',
521
+ ' /agent [name] - 查看或切换 Agent 后端',
522
+ ' /model [model] - 查看或切换模型',
523
+ ' /effort [level] - 查看或切换推理强度',
524
+ '',
525
+ '🔐 权限管理:',
526
+ ' /perm - 查看当前权限模式',
527
+ ' /perm <auto|bypass|request|edit|plan|noask> - 切换权限模式',
528
+ ' /perm allow|always|deny - 审批权限请求',
529
+ '',
530
+ '🛠️ 运维:',
531
+ ' /status - 显示会话状态',
532
+ ' /stop - 中断当前任务',
533
+ ' /restart - 重启服务',
534
+ ' /repair - 检查并修复会话',
535
+ ' /safe - 进入安全模式',
536
+ ' /send [渠道] <路径> - 发送项目内文件',
537
+ '',
538
+ '❓ 帮助:',
539
+ ' /help - 显示此帮助信息',
540
+ ];
541
+ return lines.join('\n');
542
+ }
543
+ // /perm 命令:权限模式切换 + 权限审批(快速路径,不进入消息队列)
544
+ if (normalizedContent.startsWith('/perm')) {
545
+ const args = normalizedContent.slice(5).trim();
546
+ // 先获取正确的 session 和 agent(话题可能用不同 agent)
547
+ const permResult = await this.ensureSession(channel, channelId, threadId);
548
+ if ('error' in permResult)
549
+ return permResult.error;
550
+ const { session: permSession } = permResult;
551
+ const permAgent = this.getAgent(permSession.agentId);
552
+ // /perm(无参数):显示当前模式和可选模式
553
+ if (!args) {
554
+ if (!hasPermissionController(permAgent)) {
555
+ return '❌ 权限控制不可用';
556
+ }
557
+ const currentMode = permSession.metadata?.permissionMode ?? 'bypass';
558
+ const modes = permAgent.listModes();
559
+ // 尝试发送交互卡片
560
+ if (this.interactionRouter) {
561
+ const requestId = `perm-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
562
+ const availableModes = modes.filter(m => m.available);
563
+ const interaction = {
564
+ type: 'interaction',
565
+ id: requestId,
566
+ channelId,
567
+ sessionId: permSession.id,
568
+ kind: {
569
+ kind: 'action',
570
+ title: '🔐 权限模式',
571
+ body: availableModes.map(m => `${m.key === currentMode ? '▶' : '•'} **${m.key}** (${m.nameZh}) - ${m.description}`).join('\n'),
572
+ buttons: availableModes.map(m => ({
573
+ key: m.key,
574
+ label: m.key === currentMode ? `✓ ${m.key}` : m.key,
575
+ style: m.key === currentMode ? 'primary' : 'default',
576
+ })),
577
+ },
578
+ };
579
+ const replyCtx = this.getReplyContext(permSession);
580
+ const cardSent = await this.sendInteractionCard({
581
+ channel, channelId, sessionId: permSession.id, requestId, interaction, replyCtx,
582
+ callback: async (action, _values, operatorId) => {
583
+ if (action !== currentMode) {
584
+ if (userId && operatorId && operatorId !== userId)
585
+ return;
586
+ const result = await this.handle(`/perm ${action}`, channel, channelId, undefined, userId, threadId);
587
+ if (result) {
588
+ const adapter = this.adapters.get(channel);
589
+ adapter?.sendText(channelId, result, replyCtx);
590
+ }
591
+ }
592
+ },
593
+ });
594
+ if (cardSent)
595
+ return null;
596
+ }
597
+ // 降级:文本
598
+ const modeList = modes.map(m => {
599
+ const prefix = m.key === currentMode ? '▶' : ' ';
600
+ const suffix = m.available ? '' : ' ⚠️ 不可用';
601
+ return ` ${prefix} ${m.key} (${m.nameZh}) - ${m.description}${suffix}`;
602
+ }).join('\n');
603
+ return `🔐 当前权限模式: ${currentMode}\n\n${modeList}\n\n用法:\n /perm <模式> 切换权限模式\n /perm allow|always|deny 审批权限请求`;
604
+ }
605
+ const parts = args.split(/\s+/);
606
+ // /perm <mode> 或 /perm allow|always|deny:切换模式 / 快捷审批
607
+ if (parts.length === 1) {
608
+ const arg = parts[0];
609
+ // /perm allow|always|deny:快捷审批(自动找当前 session 唯一的 pending 请求)
610
+ if (arg === 'allow' || arg === 'always' || arg === 'deny') {
611
+ if (!this.permissionGateway) {
612
+ return '❌ 权限审批未启用';
613
+ }
614
+ const pendingIds = this.permissionGateway.getPendingRequests(permSession.id);
615
+ if (pendingIds.length === 0) {
616
+ return '❌ 当前没有待审批的权限请求';
617
+ }
618
+ if (pendingIds.length > 1) {
619
+ return `❌ 当前有 ${pendingIds.length} 个待审批请求,请指定 requestId:\n${pendingIds.map(id => ` /perm ${id} ${arg}`).join('\n')}`;
620
+ }
621
+ const requestId = pendingIds[0];
622
+ const decision = arg;
623
+ this.permissionGateway.resolvePermission(permSession.id, requestId, decision);
624
+ const labels = {
625
+ allow: '✓ 已授权(本次),继续执行……',
626
+ always: '✓ 已授权(始终允许该工具),继续执行……',
627
+ deny: '✓ 已拒绝'
628
+ };
629
+ return labels[decision];
630
+ }
631
+ // /perm <mode>:切换权限模式
632
+ if (hasPermissionController(permAgent)) {
633
+ const modes = permAgent.listModes();
634
+ const matched = modes.find(m => m.key === arg);
635
+ if (matched) {
636
+ if (!matched.available) {
637
+ return `❌ ${matched.key} 模式当前不可用:${matched.unavailableReason}`;
638
+ }
639
+ // guest 用户只能保持 readonly 模式
640
+ if (identity.role !== 'owner' && arg !== 'readonly') {
641
+ return '❌ 当前身份无法切换权限模式';
642
+ }
643
+ const metadata = permSession.metadata || {};
644
+ metadata.permissionMode = arg;
645
+ await this.sessionManager.updateSession(permSession.id, { metadata });
646
+ return `✓ 权限模式已切换为: ${matched.key} (${matched.nameZh})\n${matched.description}`;
647
+ }
648
+ }
649
+ // 不是已知模式名也不是 allow/deny
650
+ const modeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'auto|bypass|request|edit|plan|noask';
651
+ return `❌ 未知参数: ${arg}\n用法: /perm <${modeKeys}> 或 /perm allow|always|deny`;
652
+ }
653
+ // 双参数不再支持,提示正确用法
654
+ const allModeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'auto|bypass|request|edit|plan|noask';
655
+ return `❌ 未知参数: ${args}\n用法: /perm <${allModeKeys}> 或 /perm allow|always|deny`;
656
+ }
657
+ // /agent 命令:查看或切换 Agent 后端
658
+ if (normalizedContent.startsWith('/agent')) {
659
+ if (!isAdmin)
660
+ return '❌ 无权限:此命令仅限管理员使用';
661
+ const args = normalizedContent.slice(6).trim();
662
+ const available = [...this.agentMap.keys()];
663
+ if (!args) {
664
+ const currentAgent = activeSession?.agentId || this.defaultAgentId;
665
+ // 尝试发送交互卡片
666
+ if (this.interactionRouter && available.length > 1) {
667
+ const requestId = `agent-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
668
+ const interaction = {
669
+ type: 'interaction',
670
+ id: requestId,
671
+ channelId,
672
+ sessionId: activeSession?.id || requestId,
673
+ kind: {
674
+ kind: 'action',
675
+ title: '🔌 切换 Agent',
676
+ buttons: available.map(a => ({
677
+ key: a,
678
+ label: a === currentAgent ? `✓ ${a}` : a,
679
+ style: a === currentAgent ? 'primary' : 'default',
680
+ })),
681
+ },
682
+ };
683
+ const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
684
+ const cardSent = await this.sendInteractionCard({
685
+ channel, channelId, sessionId: activeSession?.id || requestId, requestId, interaction, replyCtx,
686
+ callback: async (action, _values, operatorId) => {
687
+ if (action !== currentAgent) {
688
+ if (userId && operatorId && operatorId !== userId)
689
+ return;
690
+ const result = await this.handle(`/agent ${action}`, channel, channelId, undefined, userId, threadId);
691
+ if (result) {
692
+ const adapter = this.adapters.get(channel);
693
+ adapter?.sendText(channelId, result, replyCtx);
694
+ }
695
+ }
696
+ },
697
+ });
698
+ if (cardSent)
699
+ return null;
700
+ }
701
+ // 降级:文本
702
+ const list = available.map(a => `${a === currentAgent ? ' ✓' : ' '} ${a}`).join('\n');
703
+ return `当前 Agent: ${currentAgent}\n\n可用:\n${list}\n\n用法: /agent <name>`;
704
+ }
705
+ if (!this.agentMap.has(args)) {
706
+ return `❌ 未知 Agent: ${args}\n可用: ${available.join(', ')}`;
707
+ }
708
+ const result = await this.ensureSession(channel, channelId, threadId);
709
+ if ('error' in result)
710
+ return result.error;
711
+ const { session } = result;
712
+ // 取消原会话的 pending 权限请求和交互卡片
713
+ if (this.permissionGateway) {
714
+ this.permissionGateway.cancelAll(session.id);
715
+ }
716
+ if (this.interactionRouter) {
717
+ this.interactionRouter.cancelAll(session.id);
718
+ }
719
+ // 切换到目标 agent(恢复已有会话或创建新会话)
720
+ const newSession = await this.sessionManager.switchAgent(channel, channelId, session.projectPath, args);
721
+ const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
722
+ const projectName = this.getProjectName(session.projectPath);
723
+ let agentSwitchResponse = `✓ 已切换 Agent: ${args}\n 项目: ${projectName}\n 会话: ${newSession.name || '(未命名)'}\n ${hasExistingSession}`;
724
+ return agentSwitchResponse;
260
725
  }
261
726
  // /model 命令:查看或切换模型/推理强度
262
727
  if (normalizedContent.startsWith('/model')) {
263
728
  const args = normalizedContent.slice(6).trim();
729
+ // 获取当前会话(话题会话可能绑定不同 agent)
730
+ const modelResult = await this.ensureSession(channel, channelId, threadId);
731
+ if ('error' in modelResult)
732
+ return modelResult.error;
733
+ const { session: modelSession } = modelResult;
734
+ const modelAgent = this.getAgent(modelSession.agentId);
735
+ const models = hasModelSwitcher(modelAgent) ? modelAgent.listModels() : [];
264
736
  if (!args) {
265
- const currentModel = this.agentRunner.getModel();
266
- const currentEffort = this.agentRunner.getEffort() || 'auto';
267
- const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : `${currentEffort} ${effortBar(currentEffort)}`;
268
- const modelList = availableModels.map(m => `- ${m}`).join('\n');
269
- return `当前模型: ${currentModel}\n推理强度: ${effortDisplay}\n\n可用模型:\n${modelList}\n\n推理强度: ${availableEfforts.join(' / ')} / auto\n\n用法:\n /model <model> 切换模型\n /model <model> <effort> 切换模型+推理强度\n /model <effort> 仅切换推理强度\n /model auto 恢复SDK默认`;
737
+ const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
738
+ const efforts = getAvailableEfforts(modelAgent, currentModel);
739
+ const currentEffort = modelAgent.getEffort?.() || 'auto';
740
+ // 尝试发送交互卡片
741
+ if (this.interactionRouter && models.length > 0) {
742
+ const requestId = `model-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
743
+ const interaction = {
744
+ type: 'interaction',
745
+ id: requestId,
746
+ channelId,
747
+ sessionId: modelSession.id,
748
+ kind: {
749
+ kind: 'action',
750
+ title: '🤖 切换模型',
751
+ buttons: models.map((m) => ({
752
+ key: m,
753
+ label: m === currentModel ? `✓ ${m}` : m,
754
+ style: m === currentModel ? 'primary' : 'default',
755
+ })),
756
+ },
757
+ };
758
+ const replyCtx = this.getReplyContext(modelSession);
759
+ const cardSent = await this.sendInteractionCard({
760
+ channel, channelId, sessionId: modelSession.id, requestId, interaction, replyCtx,
761
+ callback: async (action, _values, operatorId) => {
762
+ if (action !== currentModel) {
763
+ if (userId && operatorId && operatorId !== userId)
764
+ return;
765
+ const result = await this.handle(`/model ${action}`, channel, channelId, undefined, userId, threadId);
766
+ if (result) {
767
+ const adapter = this.adapters.get(channel);
768
+ adapter?.sendText(channelId, result, replyCtx);
769
+ }
770
+ }
771
+ },
772
+ });
773
+ if (cardSent)
774
+ return null;
775
+ }
776
+ // 降级:文本
777
+ const modelList = models.map((m) => `- ${m}`).join('\n');
778
+ const effortHint = efforts.length > 0
779
+ ? `\n推理强度: ${currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort} (使用 /effort 调整)`
780
+ : '';
781
+ return `当前模型: ${currentModel}${effortHint}\n\n可用模型:\n${modelList}\n\n${formatModelUsage(modelAgent, currentModel)}`;
270
782
  }
271
783
  const parts = args.split(/\s+/);
272
784
  let newModel;
273
785
  let newEffort;
274
786
  if (parts.length === 1) {
275
787
  const arg = parts[0];
276
- if (arg === 'auto') {
277
- // 清除 effort,恢复 SDK 默认
278
- const result = await this.ensureSession(channel, channelId, threadId);
279
- if ('error' in result)
280
- return result.error;
281
- const { session } = result;
282
- const writeResult = writeUserSettings({ effortLevel: null });
283
- if (!writeResult.success) {
284
- return `⚠️ 写入用户配置失败: ${writeResult.error}\n已更新运行时配置,但未持久化到 ~/.claude/settings.json`;
285
- }
286
- this.agentRunner.setEffort(undefined);
287
- return '✓ 推理强度已恢复为 auto (SDK默认)';
788
+ const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
789
+ const efforts = getAvailableEfforts(modelAgent, currentModel);
790
+ // effort 相关参数统一转发到 /effort
791
+ if (efforts.includes(arg) || arg === 'auto') {
792
+ return this.handle(`/effort ${arg}`, channel, channelId, undefined, userId, threadId);
288
793
  }
289
- // 单参数:模型 effort
290
- if (availableEfforts.includes(arg)) {
291
- newEffort = arg;
794
+ else if (allEfforts.includes(arg)) {
795
+ return `⚠️ 请使用 /effort ${arg} 调整推理强度`;
292
796
  }
293
- else if (availableModels.includes(arg)) {
797
+ else if (models.includes(arg)) {
294
798
  newModel = arg;
295
799
  }
296
800
  else {
297
- const modelList = availableModels.map(m => `- ${m}`).join('\n');
298
- return `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}\n\n推理强度: ${availableEfforts.join(' / ')}`;
801
+ const modelList = models.map((m) => `- ${m}`).join('\n');
802
+ const effortHint = efforts.length > 0 ? `\n\n推理强度请使用 /effort 命令` : '';
803
+ return `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}${effortHint}`;
299
804
  }
300
805
  }
301
806
  else {
302
807
  // 双参数:model effort
303
808
  const [modelArg, effortArg] = parts;
304
- if (!availableModels.includes(modelArg)) {
809
+ if (!models.includes(modelArg)) {
305
810
  return `❌ 无效的模型ID: ${modelArg}`;
306
811
  }
307
- if (!availableEfforts.includes(effortArg)) {
308
- return `❌ 无效的推理强度: ${effortArg}\n可选: ${availableEfforts.join(' / ')}`;
812
+ const targetEfforts = getAvailableEfforts(modelAgent, modelArg);
813
+ if (targetEfforts.length === 0) {
814
+ return `⚠️ ${modelArg} 不支持推理强度设置`;
815
+ }
816
+ if (!targetEfforts.includes(effortArg)) {
817
+ const errorLabel = allEfforts.includes(effortArg) ? '⚠️' : '❌';
818
+ return `${errorLabel} ${modelArg} 不支持 ${effortArg} 推理强度\n可选: ${targetEfforts.join(' / ')}`;
309
819
  }
310
820
  newModel = modelArg;
311
821
  newEffort = effortArg;
312
822
  }
313
823
  if (!this.config.agents)
314
824
  this.config.agents = {};
315
- if (!this.config.agents.anthropic)
316
- this.config.agents.anthropic = {};
317
- // 获取当前会话的项目路径
318
- const result = await this.ensureSession(channel, channelId, threadId);
319
- if ('error' in result)
320
- return result.error;
321
- const { session } = result;
825
+ const isCodexAgent = modelAgent.name === 'codex';
322
826
  const changes = [];
323
- const updates = {};
324
827
  if (newModel) {
325
- updates.model = newModel;
326
- this.agentRunner.setModel(newModel);
828
+ modelAgent.setModel?.(newModel);
829
+ this.eventBus.publish({
830
+ type: 'agent:model-changed',
831
+ sessionId: modelSession.id,
832
+ model: newModel,
833
+ timestamp: Date.now()
834
+ });
327
835
  changes.push(`模型: ${newModel}`);
328
836
  }
329
837
  if (newEffort) {
330
- const modelAfterSwitch = newModel ?? this.agentRunner.getModel();
331
- if (newEffort === 'max' && !modelAfterSwitch.includes('opus')) {
332
- return '⚠️ max 推理强度仅 Opus 模型支持(opus / claude-opus-4-6)';
838
+ modelAgent.setEffort?.(newEffort);
839
+ changes.push(`推理强度: ${newEffort}`);
840
+ }
841
+ // 持久化:写回来源(就近原则)
842
+ // evolclaw.json 配了 → 写 evolclaw.json
843
+ // evolclaw.json 没配 → 写 agent 全局配置
844
+ if (isCodexAgent) {
845
+ const configuredInEvolclaw = !!(this.config.agents?.openai?.model || this.config.agents?.openai?.reasoning);
846
+ if (configuredInEvolclaw) {
847
+ if (!this.config.agents.openai)
848
+ this.config.agents.openai = {};
849
+ if (newModel)
850
+ this.config.agents.openai.model = newModel;
851
+ if (newEffort)
852
+ this.config.agents.openai.reasoning = newEffort;
853
+ try {
854
+ saveConfig(this.config);
855
+ }
856
+ catch (error) {
857
+ return `⚠️ 写入 evolclaw.json 失败: ${error.message}\n已更新运行时配置,但未持久化`;
858
+ }
859
+ }
860
+ else {
861
+ // Codex 全局配置(~/.codex/config.toml)目前不支持写入,回退到 evolclaw.json
862
+ if (!this.config.agents.openai)
863
+ this.config.agents.openai = {};
864
+ if (newModel)
865
+ this.config.agents.openai.model = newModel;
866
+ if (newEffort)
867
+ this.config.agents.openai.reasoning = newEffort;
868
+ try {
869
+ saveConfig(this.config);
870
+ }
871
+ catch (error) {
872
+ return `⚠️ 写入 evolclaw.json 失败: ${error.message}\n已更新运行时配置,但未持久化`;
873
+ }
333
874
  }
334
- updates.effortLevel = newEffort;
335
- this.agentRunner.setEffort(newEffort);
336
- changes.push(`推理强度: ${newEffort} ${effortBar(newEffort)}`);
337
875
  }
338
- // 写入用户级 ~/.claude/settings.json(与 Claude CLI 行为一致)
339
- const writeResult = writeUserSettings(updates);
340
- if (!writeResult.success) {
341
- return `⚠️ 写入用户配置失败: ${writeResult.error}\n已更新运行时配置,但未持久化到 ~/.claude/settings.json`;
876
+ else {
877
+ const configuredInEvolclaw = !!(this.config.agents?.anthropic?.model || this.config.agents?.anthropic?.effort);
878
+ if (configuredInEvolclaw) {
879
+ if (!this.config.agents.anthropic)
880
+ this.config.agents.anthropic = {};
881
+ if (newModel)
882
+ this.config.agents.anthropic.model = newModel;
883
+ if (newEffort)
884
+ this.config.agents.anthropic.effort = newEffort;
885
+ try {
886
+ saveConfig(this.config);
887
+ }
888
+ catch (error) {
889
+ return `⚠️ 写入 evolclaw.json 失败: ${error.message}\n已更新运行时配置,但未持久化`;
890
+ }
891
+ }
892
+ else {
893
+ const updates = {};
894
+ if (newModel)
895
+ updates.model = newModel;
896
+ if (newEffort)
897
+ updates.effortLevel = newEffort;
898
+ const writeResult = writeUserSettings(updates);
899
+ if (!writeResult.success) {
900
+ return `⚠️ 写入用户配置失败: ${writeResult.error}\n已更新运行时配置,但未持久化到 ~/.claude/settings.json`;
901
+ }
902
+ }
342
903
  }
343
904
  return `✓ 已切换\n ${changes.join('\n ')}`;
344
905
  }
345
- // /stop 命令:中断当前任务
346
- if (normalizedContent === '/stop') {
347
- // 话题使用 session.id 作为队列 key,主会话使用 channel-channelId
348
- let sessionKey;
349
- if (threadId) {
350
- const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
351
- sessionKey = threadSession.id;
906
+ // /effort 命令:查看或切换推理强度
907
+ if (normalizedContent.startsWith('/effort')) {
908
+ const args = normalizedContent.slice(7).trim();
909
+ const effortResult = await this.ensureSession(channel, channelId, threadId);
910
+ if ('error' in effortResult)
911
+ return effortResult.error;
912
+ const { session: effortSession } = effortResult;
913
+ const effortAgent = this.getAgent(effortSession.agentId);
914
+ const currentModel = hasModelSwitcher(effortAgent) ? effortAgent.getModel() : effortAgent.name;
915
+ const efforts = getAvailableEfforts(effortAgent, currentModel);
916
+ const currentEffort = effortAgent.getEffort?.() || 'auto';
917
+ if (efforts.length === 0) {
918
+ return '⚠️ 当前模型不支持推理强度设置';
919
+ }
920
+ if (!args) {
921
+ // /effort(无参数):显示当前推理强度 + 发送 Action 卡片
922
+ if (this.interactionRouter) {
923
+ const requestId = `effort-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
924
+ const buttons = [
925
+ ...efforts.map(e => ({
926
+ key: e,
927
+ label: e === currentEffort ? `✓ ${e}` : e,
928
+ style: e === currentEffort ? 'primary' : 'default',
929
+ })),
930
+ {
931
+ key: 'auto',
932
+ label: currentEffort === 'auto' ? '✓ auto' : 'auto',
933
+ style: currentEffort === 'auto' ? 'primary' : 'default',
934
+ },
935
+ ];
936
+ const interaction = {
937
+ type: 'interaction',
938
+ id: requestId,
939
+ channelId,
940
+ sessionId: effortSession.id,
941
+ kind: {
942
+ kind: 'action',
943
+ title: '⚡ 推理强度',
944
+ buttons,
945
+ },
946
+ };
947
+ const replyCtx = this.getReplyContext(effortSession);
948
+ const cardSent = await this.sendInteractionCard({
949
+ channel, channelId, sessionId: effortSession.id, requestId, interaction, replyCtx,
950
+ callback: async (action, _values, operatorId) => {
951
+ if (action !== currentEffort) {
952
+ if (userId && operatorId && operatorId !== userId)
953
+ return;
954
+ const result = await this.handle(`/effort ${action}`, channel, channelId, undefined, userId, threadId);
955
+ if (result) {
956
+ const adapter = this.adapters.get(channel);
957
+ adapter?.sendText(channelId, result, replyCtx);
958
+ }
959
+ }
960
+ },
961
+ });
962
+ if (cardSent)
963
+ return null;
964
+ }
965
+ // 降级:文本
966
+ const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort;
967
+ const effortList = efforts.map(e => `${e === currentEffort ? ' ✓' : ' '} ${e}`).join('\n');
968
+ return `⚡ 推理强度: ${effortDisplay}\n\n可选:\n${effortList}\n ${currentEffort === 'auto' ? ' ✓' : ' '} auto\n\n用法: /effort <level>`;
969
+ }
970
+ // /effort auto:恢复 SDK 默认
971
+ if (args === 'auto') {
972
+ effortAgent.setEffort?.(undefined);
973
+ const isCodex = effortAgent.name === 'codex';
974
+ if (isCodex) {
975
+ if (this.config.agents?.openai?.reasoning) {
976
+ delete this.config.agents.openai.reasoning;
977
+ try {
978
+ saveConfig(this.config);
979
+ }
980
+ catch { }
981
+ }
982
+ }
983
+ else {
984
+ const configuredInEvolclaw = !!this.config.agents?.anthropic?.effort;
985
+ if (configuredInEvolclaw) {
986
+ delete this.config.agents.anthropic.effort;
987
+ try {
988
+ saveConfig(this.config);
989
+ }
990
+ catch { }
991
+ }
992
+ else {
993
+ writeUserSettings({ effortLevel: null });
994
+ }
995
+ }
996
+ return '✓ 推理强度已恢复为 auto (SDK默认)';
997
+ }
998
+ // /effort <level>:切换推理强度
999
+ if (!efforts.includes(args)) {
1000
+ if (allEfforts.includes(args)) {
1001
+ return `⚠️ ${currentModel} 不支持 ${args} 推理强度\n可选: ${efforts.join(' / ')}`;
1002
+ }
1003
+ return `❌ 无效参数: ${args}\n可选: ${efforts.join(' / ')} / auto`;
1004
+ }
1005
+ const newEffort = args;
1006
+ effortAgent.setEffort?.(newEffort);
1007
+ // 持久化
1008
+ if (!this.config.agents)
1009
+ this.config.agents = {};
1010
+ const isCodex = effortAgent.name === 'codex';
1011
+ if (isCodex) {
1012
+ if (!this.config.agents.openai)
1013
+ this.config.agents.openai = {};
1014
+ this.config.agents.openai.reasoning = newEffort;
1015
+ try {
1016
+ saveConfig(this.config);
1017
+ }
1018
+ catch { }
352
1019
  }
353
1020
  else {
354
- sessionKey = `${channel}-${channelId}`;
1021
+ const configuredInEvolclaw = !!(this.config.agents?.anthropic?.model || this.config.agents?.anthropic?.effort);
1022
+ if (configuredInEvolclaw) {
1023
+ if (!this.config.agents.anthropic)
1024
+ this.config.agents.anthropic = {};
1025
+ this.config.agents.anthropic.effort = newEffort;
1026
+ try {
1027
+ saveConfig(this.config);
1028
+ }
1029
+ catch { }
1030
+ }
1031
+ else {
1032
+ writeUserSettings({ effortLevel: newEffort });
1033
+ }
355
1034
  }
1035
+ return `✓ 推理强度: ${newEffort}`;
1036
+ }
1037
+ // /stop 命令:中断当前任务
1038
+ if (normalizedContent === '/stop') {
1039
+ const stopResult = await this.ensureSession(channel, channelId, threadId);
1040
+ if ('error' in stopResult)
1041
+ return '当前没有正在处理的任务';
1042
+ const { session: stopSession } = stopResult;
1043
+ const stopAgent = this.getAgent(stopSession.agentId);
1044
+ const sessionKey = stopSession.id;
356
1045
  const queueLength = this.messageQueue.getQueueLength(sessionKey);
357
- const hasActive = this.agentRunner.hasActiveStream(sessionKey);
1046
+ const hasActive = stopAgent.hasActiveStream(sessionKey);
358
1047
  if (queueLength === 0 && !hasActive) {
359
1048
  return '当前没有正在处理的任务';
360
1049
  }
361
- await this.agentRunner.interrupt(sessionKey);
1050
+ await stopAgent.interrupt(sessionKey);
1051
+ // 发布中断事件,让 MessageProcessor 标记为 interrupted(而非 done)
1052
+ this.eventBus.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'stop' });
1053
+ // 强制清除 processing_state
1054
+ this.sessionManager.clearProcessing(sessionKey);
362
1055
  return '✓ 已发送中断信号,任务将尽快停止';
363
1056
  }
364
1057
  // /clear 命令:通过 SDK /clear 清空会话历史
@@ -367,20 +1060,30 @@ export class CommandHandler {
367
1060
  if ('error' in result)
368
1061
  return result.error;
369
1062
  const { session } = result;
1063
+ const sessionAgent = this.getAgent(session.agentId);
1064
+ if (!sessionAgent.capabilities?.clear) {
1065
+ return `❌ 当前 Agent (${sessionAgent.name}) 不支持 /clear\n\n可使用 /new 创建新会话替代`;
1066
+ }
370
1067
  if (!session.agentSessionId) {
371
1068
  return '❌ 当前会话没有历史记录,无需清空';
372
1069
  }
373
1070
  const projectPath = path.isAbsolute(session.projectPath)
374
1071
  ? session.projectPath
375
1072
  : path.resolve(process.cwd(), session.projectPath);
376
- const cleared = await this.agentRunner.clearSession(session.agentSessionId, projectPath);
377
- if (cleared) {
378
- await this.sessionManager.updateAgentSessionIdBySessionId(session.id, '');
379
- this.agentRunner.updateSessionId(session.id, '');
380
- return '✅ 已清空当前会话的对话历史';
1073
+ const releaseLock = this.messageQueue.acquireLock(session.id);
1074
+ try {
1075
+ const cleared = await sessionAgent.clearSession(session.id, session.agentSessionId, projectPath);
1076
+ if (cleared) {
1077
+ await this.sessionManager.updateAgentSessionIdBySessionId(session.id, '');
1078
+ sessionAgent.updateSessionId(session.id, '');
1079
+ return '✅ 已清空当前会话的对话历史';
1080
+ }
1081
+ else {
1082
+ return '❌ 清空会话失败,请稍后重试';
1083
+ }
381
1084
  }
382
- else {
383
- return '❌ 清空会话失败,请稍后重试';
1085
+ finally {
1086
+ releaseLock();
384
1087
  }
385
1088
  }
386
1089
  // /compact 命令:手动压缩会话上下文
@@ -389,21 +1092,31 @@ export class CommandHandler {
389
1092
  if ('error' in result)
390
1093
  return result.error;
391
1094
  const { session } = result;
1095
+ const sessionAgent = this.getAgent(session.agentId);
1096
+ if (!sessionAgent.capabilities?.compact) {
1097
+ return `❌ 当前 Agent (${sessionAgent.name}) 不支持 /compact`;
1098
+ }
392
1099
  if (!session.agentSessionId) {
393
1100
  return '❌ 当前会话没有历史记录,无需压缩';
394
1101
  }
395
1102
  const projectPath = path.isAbsolute(session.projectPath)
396
1103
  ? session.projectPath
397
1104
  : path.resolve(process.cwd(), session.projectPath);
398
- if (sendMessage) {
399
- await sendMessage(channelId, '⏳ 正在压缩会话上下文...', this.getThreadSendOpts(session));
400
- }
401
- const compacted = await this.agentRunner.compactSession(session.id, session.agentSessionId, projectPath);
402
- if (compacted) {
403
- return '✅ 会话上下文已压缩';
1105
+ const releaseLock = this.messageQueue.acquireLock(session.id);
1106
+ try {
1107
+ if (sendMessage) {
1108
+ await sendMessage(channelId, '⏳ 正在压缩会话上下文...', this.getReplyContext(session));
1109
+ }
1110
+ const compacted = await sessionAgent.compactSession(session.id, session.agentSessionId, projectPath);
1111
+ if (compacted) {
1112
+ return '✅ 会话上下文已压缩';
1113
+ }
1114
+ else {
1115
+ return '❌ 会话压缩失败,请稍后重试';
1116
+ }
404
1117
  }
405
- else {
406
- return '❌ 会话压缩失败,请稍后重试';
1118
+ finally {
1119
+ releaseLock();
407
1120
  }
408
1121
  }
409
1122
  // 尝试获取活跃会话(话题时直接查找话题 session)
@@ -414,26 +1127,37 @@ export class CommandHandler {
414
1127
  else {
415
1128
  session = await this.sessionManager.getActiveSession(channel, channelId);
416
1129
  }
417
- // 对于需要创建会话的命令,如果没有会话则创建
1130
+ // 对于需要会话的命令,如果没有会话则使用默认项目创建临时会话
1131
+ // 这样 /pwd、/status 等命令可以在没有活跃会话时返回默认项目信息
418
1132
  if (!session && (normalizedContent.startsWith('/new') ||
419
1133
  normalizedContent.startsWith('/bind') ||
420
- normalizedContent.startsWith('/project'))) {
1134
+ normalizedContent.startsWith('/project') ||
1135
+ normalizedContent === '/pwd' ||
1136
+ normalizedContent === '/status')) {
421
1137
  session = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd());
422
1138
  }
423
1139
  // /status 命令:显示会话状态
424
1140
  if (normalizedContent === '/status') {
1141
+ // session 现在总是存在(上面已自动创建)
425
1142
  if (!session) {
426
- return `📊 会话状态:
427
-
428
- ❌ 当前未创建会话
429
-
430
- 提示:发送任意消息或使用 /new 命令创建会话`;
1143
+ return `❌ 无法创建会话,请检查配置`;
431
1144
  }
432
1145
  const sessionKey = this.getQueueKey(session, channel, channelId);
433
- const isCurrentlyProcessing = this.messageQueue.isProcessing(sessionKey);
1146
+ const sessionAgent = this.getAgent(session.agentId);
1147
+ const isCurrentlyProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
434
1148
  const queueLength = this.messageQueue.getQueueLength(sessionKey);
435
1149
  const isThread = !!session.threadId;
436
- const sessionStatus = isCurrentlyProcessing ? '处理中' : '空闲';
1150
+ let sessionStatus = isCurrentlyProcessing ? '处理中' : '空闲';
1151
+ // 处理中时显示时长
1152
+ if (isCurrentlyProcessing) {
1153
+ const elapsed = Date.now() - parseInt(session.processingState, 10);
1154
+ if (!isNaN(elapsed) && elapsed > 0) {
1155
+ const sec = Math.floor(elapsed / 1000);
1156
+ sessionStatus = sec < 60 ? `处理中 (${sec}秒)` :
1157
+ sec < 3600 ? `处理中 (${Math.floor(sec / 60)}分钟)` :
1158
+ `处理中 (${Math.floor(sec / 3600)}小时)`;
1159
+ }
1160
+ }
437
1161
  const projectName = this.getProjectName(session.projectPath);
438
1162
  const health = await this.sessionManager.getHealthStatus(session.id);
439
1163
  const timeSinceSuccess = Date.now() - health.lastSuccessTime;
@@ -443,7 +1167,7 @@ export class CommandHandler {
443
1167
  // 获取会话文件信息并同步 name
444
1168
  let sessionTurns = 0;
445
1169
  if (session.agentSessionId) {
446
- const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId);
1170
+ const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId, session.agentId);
447
1171
  sessionTurns = fileInfo.turns;
448
1172
  if (fileInfo.title && fileInfo.title !== session.name) {
449
1173
  await this.sessionManager.renameSession(session.id, fileInfo.title);
@@ -452,10 +1176,17 @@ export class CommandHandler {
452
1176
  }
453
1177
  const lines = [];
454
1178
  if (isAdmin) {
455
- lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `异常计数: ${health.consecutiveErrors}`, `安全模式: ${health.safeMode ? '是 ⚠️' : '否 ✓'}`, `最后成功: ${timeStr}`, `Claude会话: ${session.agentSessionId || '(未初始化)'}`, `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`, `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`);
1179
+ lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${this.resolveChannelType(channel)} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`);
1180
+ if (health.consecutiveErrors > 0) {
1181
+ lines.push(`异常计数: ${health.consecutiveErrors}`);
1182
+ }
1183
+ if (health.safeMode) {
1184
+ lines.push(`安全模式: 是 ⚠️`);
1185
+ }
1186
+ lines.push(`最后成功: ${timeStr}`, `${session.agentId}会话: ${session.agentSessionId || '(未初始化)'}`, `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`, `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`);
456
1187
  }
457
1188
  else {
458
- lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `会话: ${session.name || '(未命名)'}`, `状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `最后活跃: ${timeStr}`);
1189
+ lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / ${session.agentId}会话`, `状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `最后活跃: ${timeStr}`);
459
1190
  }
460
1191
  if (health.safeMode) {
461
1192
  lines.push('');
@@ -482,12 +1213,112 @@ export class CommandHandler {
482
1213
  }
483
1214
  }
484
1215
  const projectPath = session?.projectPath || this.config.projects?.defaultPath || process.cwd();
485
- const newSession = await this.sessionManager.createNewSession(channel, channelId, projectPath, sessionName);
1216
+ const newSession = await this.sessionManager.createNewSession(channel, channelId, projectPath, sessionName, session?.agentId || this.defaultAgentId);
1217
+ this.eventBus.publish({
1218
+ type: 'session:created',
1219
+ sessionId: newSession.id,
1220
+ channel,
1221
+ channelId,
1222
+ projectPath,
1223
+ name: sessionName,
1224
+ timestamp: Date.now()
1225
+ });
486
1226
  if (session) {
487
- await this.agentRunner.closeSession(session.id);
1227
+ // Reset agent backend state so the new
1228
+ // session starts with a fresh conversation history
1229
+ await agent.clearSession(session.id, session.agentSessionId || '', session.projectPath);
1230
+ await agent.closeSession(session.id);
488
1231
  }
489
1232
  return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /slist 查看`;
490
1233
  }
1234
+ // /check 命令:检查渠道状态 / 手动重连指定渠道
1235
+ if (normalizedContent === '/check' || normalizedContent.startsWith('/check ')) {
1236
+ if (!isAdmin)
1237
+ return '❌ 无权限:此命令仅限管理员使用';
1238
+ const subCmd = normalizedContent.slice('/check'.length).trim();
1239
+ // /check rty <channel> — 重连指定渠道
1240
+ if (subCmd.startsWith('rty')) {
1241
+ const target = subCmd.slice('rty'.length).trim();
1242
+ if (!target) {
1243
+ return '❌ 请指定渠道名称,例如:/check rty feishu';
1244
+ }
1245
+ const ch = this.channelObjects.get(target);
1246
+ if (!ch) {
1247
+ const available = [...this.channelObjects.keys()].join(', ') || '无';
1248
+ return `❌ 未找到渠道 "${target}",可用渠道:${available}`;
1249
+ }
1250
+ if (!ch.reconnect) {
1251
+ return `❌ 渠道 "${target}" 不支持重连`;
1252
+ }
1253
+ const result = await ch.reconnect();
1254
+ return `🔄 ${target} 重连: ${result}`;
1255
+ }
1256
+ // Default: show full system health check
1257
+ const lines = ['📡 渠道状态:'];
1258
+ // Group by channelType
1259
+ const groups = new Map();
1260
+ for (const [name] of this.adapters) {
1261
+ const type = this.channelTypeMap.get(name) || name;
1262
+ const ch = this.channelObjects.get(name);
1263
+ let status;
1264
+ if (ch?.getStatus) {
1265
+ const s = ch.getStatus();
1266
+ status = s.connected ? '✓ 已连接' : s.reconnectAttempt > 0 ? `⏳ 重连中 (${s.reconnectAttempt}/${s.maxAttempts})` : '✗ 断开';
1267
+ }
1268
+ else {
1269
+ status = '✓ 已注册';
1270
+ }
1271
+ if (!groups.has(type))
1272
+ groups.set(type, []);
1273
+ groups.get(type).push({ name, status });
1274
+ }
1275
+ for (const [type, instances] of groups) {
1276
+ if (instances.length === 1) {
1277
+ lines.push(` ${instances[0].name}: ${instances[0].status}`);
1278
+ }
1279
+ else {
1280
+ const parts = instances.map(i => `${i.name} ${i.status}`);
1281
+ lines.push(` ${type}: [${parts.join(', ')}]`);
1282
+ }
1283
+ }
1284
+ // 队列状态
1285
+ lines.push('', '📬 队列状态:');
1286
+ lines.push(` 待处理消息: ${this.messageQueue.getGlobalQueueLength()}`);
1287
+ lines.push(` 处理中队列: ${this.messageQueue.getGlobalProcessingCount()}`);
1288
+ // 运行概况
1289
+ lines.push('', '🖥️ 运行概况:');
1290
+ const uptimeMs = this.statsCollector
1291
+ ? this.statsCollector.getSnapshot().uptimeMs
1292
+ : process.uptime() * 1000;
1293
+ lines.push(` 运行时间: ${this.formatUptime(uptimeMs)}`);
1294
+ lines.push(` 当前安全模式会话: ${this.sessionManager.getSafeModeSessionCount()}`);
1295
+ // 近 1 小时统计
1296
+ if (this.statsCollector) {
1297
+ const snap = this.statsCollector.getSnapshot();
1298
+ const h = snap.lastHour;
1299
+ lines.push('', '📊 近 1 小时统计:');
1300
+ lines.push(` 收到消息: ${h.received}`);
1301
+ lines.push(` 完成处理: ${h.completed}`);
1302
+ if (h.errors > 0) {
1303
+ const breakdown = Object.entries(h.errorsByType).map(([t, c]) => `${t}: ${c}`).join(', ');
1304
+ lines.push(` 处理出错: ${h.errors} (${breakdown})`);
1305
+ }
1306
+ else {
1307
+ lines.push(` 处理出错: 0`);
1308
+ }
1309
+ if (h.toolErrors > 0) {
1310
+ const toolBreakdown = Object.entries(h.toolErrorsByName).map(([t, c]) => `${t}: ${c}`).join(', ');
1311
+ lines.push(` 工具失败: ${h.toolErrors} (${toolBreakdown})`);
1312
+ }
1313
+ lines.push(` 被中断: ${h.interrupts}`);
1314
+ lines.push(` 进入安全模式: ${h.safeModeEntries}`);
1315
+ if (h.completed > 0) {
1316
+ lines.push(` 平均响应耗时: ${(h.avgResponseMs / 1000).toFixed(1)}s`);
1317
+ }
1318
+ }
1319
+ lines.push('', '💡 /check rty <channel> — 重连指定渠道');
1320
+ return lines.join('\n');
1321
+ }
491
1322
  // /restart 命令:重启服务
492
1323
  if (normalizedContent === '/restart') {
493
1324
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
@@ -497,6 +1328,33 @@ export class CommandHandler {
497
1328
  const count = this.messageCache.getCount(s.id);
498
1329
  return `${s.projectPath} 有 ${count} 条新消息`;
499
1330
  });
1331
+ // 执行重启逻辑(共用于卡片回调和文本确认)
1332
+ const executeRestart = async () => {
1333
+ let replyContext;
1334
+ if (threadId) {
1335
+ const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
1336
+ replyContext = this.getReplyContext(threadSession);
1337
+ }
1338
+ const restartInfo = {
1339
+ channel,
1340
+ channelId,
1341
+ timestamp: Date.now(),
1342
+ ...(replyContext?.replyToMessageId ? { rootId: replyContext.replyToMessageId } : {}),
1343
+ };
1344
+ fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
1345
+ const { spawn } = await import('child_process');
1346
+ spawn('node', [path.join(getPackageRoot(), 'dist', 'cli.js'), 'restart-monitor'], {
1347
+ detached: true,
1348
+ stdio: 'ignore',
1349
+ env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
1350
+ }).unref();
1351
+ this.eventBus.publish({ type: 'system:restart', channel, channelId });
1352
+ setTimeout(() => {
1353
+ logger.info('[System] Restarting by user command...');
1354
+ process.exit(0);
1355
+ }, 1000);
1356
+ };
1357
+ // 文本确认流程
500
1358
  if (sessionsWithMessages.length > 0) {
501
1359
  const restartKey = `${channel}-${channelId}`;
502
1360
  const restartConfirmFile = path.join(resolvePaths().dataDir, `restart-confirm-${restartKey}.json`);
@@ -516,37 +1374,14 @@ export class CommandHandler {
516
1374
  return sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。';
517
1375
  }
518
1376
  }
519
- // 话题中 restart 时保存 rootId 用于重启后回复到话题
520
- let rootId;
521
- if (threadId) {
522
- const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
523
- rootId = threadSession.metadata?.feishu?.rootId;
524
- }
525
- const restartInfo = {
526
- channel,
527
- channelId,
528
- timestamp: Date.now(),
529
- ...(rootId ? { rootId } : {})
530
- };
531
- fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
532
- const { spawn } = await import('child_process');
533
- spawn('node', [path.join(getPackageRoot(), 'dist', 'cli.js'), 'restart-monitor'], {
534
- detached: true,
535
- stdio: 'ignore',
536
- env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
537
- }).unref();
538
- setTimeout(() => {
539
- logger.info('[System] Restarting by user command...');
540
- process.exit(0);
541
- }, 1000);
1377
+ await executeRestart();
542
1378
  return '🔄 服务正在重启,请稍候...(约 5 秒后恢复)';
543
1379
  }
544
1380
  // /pwd 命令:显示当前项目路径
545
1381
  if (normalizedContent === '/pwd') {
1382
+ // session 现在总是存在(上面已自动创建)
546
1383
  if (!session) {
547
- return `❌ 当前没有活跃会话
548
-
549
- 提示:发送任意消息或使用 /new 命令创建会话`;
1384
+ return `❌ 无法创建会话,请检查配置`;
550
1385
  }
551
1386
  const configName = this.getConfiguredProjectName(session.projectPath);
552
1387
  if (configName) {
@@ -554,75 +1389,243 @@ export class CommandHandler {
554
1389
  }
555
1390
  return `当前项目: ${session.projectPath}`;
556
1391
  }
1392
+ // /send 命令:发送项目内文件,支持 /send path 和 /send channel path
1393
+ if (normalizedContent.startsWith('/send')) {
1394
+ // 飞书会将 .md 等后缀自动转为 Markdown 链接: foo.md → [foo.md](http://foo.md/)
1395
+ // 还原: 将 [text](url) 替换为 text
1396
+ const rawArg = normalizedContent.slice(5).trim().replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
1397
+ if (!rawArg) {
1398
+ return '用法: /send <相对路径> 或 /send <渠道> <相对路径>\n示例: /send src/index.ts\n示例: /send feishu report.md';
1399
+ }
1400
+ // 解析目标通道:第一个 token 按实例名匹配,再按 channelType 匹配
1401
+ const tokens = rawArg.split(/\s+/);
1402
+ let targetChannel = channel;
1403
+ let targetLabel = channel;
1404
+ let filePath = rawArg;
1405
+ if (tokens.length >= 2) {
1406
+ const spec = tokens[0];
1407
+ if (this.adapters.has(spec)) {
1408
+ // 精确实例名
1409
+ targetChannel = spec;
1410
+ targetLabel = spec;
1411
+ filePath = tokens.slice(1).join(' ');
1412
+ }
1413
+ else {
1414
+ // 按 channelType 查找第一个匹配的实例
1415
+ for (const [name] of this.adapters) {
1416
+ if ((this.channelTypeMap.get(name) || name) === spec) {
1417
+ targetChannel = name;
1418
+ targetLabel = spec;
1419
+ filePath = tokens.slice(1).join(' ');
1420
+ break;
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ const isCrossChannel = targetChannel !== channel;
1426
+ // 跨通道仅限 owner
1427
+ if (isCrossChannel && identity.role !== 'owner') {
1428
+ return '❌ 跨通道发送仅限管理员';
1429
+ }
1430
+ // 找目标 adapter
1431
+ const targetAdapter = this.adapters.get(targetChannel);
1432
+ if (!targetAdapter) {
1433
+ return `❌ 通道 ${targetLabel} 未启用或不存在`;
1434
+ }
1435
+ if (!targetAdapter.sendFile) {
1436
+ return `❌ 通道 ${targetLabel} 不支持文件发送`;
1437
+ }
1438
+ // 获取 session(需要 projectPath)
1439
+ const sendResult = await this.ensureSession(channel, channelId, threadId);
1440
+ if ('error' in sendResult)
1441
+ return sendResult.error;
1442
+ const sendSession = sendResult.session;
1443
+ // 路径安全校验
1444
+ if (path.isAbsolute(filePath)) {
1445
+ return '❌ 不支持绝对路径\n请使用项目内的相对路径';
1446
+ }
1447
+ if (filePath.split(path.sep).includes('..') || filePath.split('/').includes('..')) {
1448
+ return '❌ 不支持 .. 路径穿越';
1449
+ }
1450
+ const resolvedPath = path.resolve(sendSession.projectPath, filePath);
1451
+ // 存在性检查
1452
+ if (!fs.existsSync(resolvedPath)) {
1453
+ return `❌ 文件不存在: ${filePath}`;
1454
+ }
1455
+ // 符号链接安全:realpath 后验证仍在项目目录内
1456
+ const realPath = fs.realpathSync(resolvedPath);
1457
+ const realProjectPath = fs.realpathSync(sendSession.projectPath);
1458
+ if (!realPath.startsWith(realProjectPath + path.sep) && realPath !== realProjectPath) {
1459
+ return '❌ 路径不允许: 文件不在项目目录内';
1460
+ }
1461
+ const stat = fs.statSync(resolvedPath);
1462
+ if (stat.isDirectory()) {
1463
+ return '❌ 暂不支持发送目录\n目录打包发送将在后续版本支持';
1464
+ }
1465
+ const MAX_SIZE = 10 * 1024 * 1024;
1466
+ if (stat.size > MAX_SIZE) {
1467
+ return `❌ 文件过大: ${(stat.size / 1024 / 1024).toFixed(1)} MB (限制 10 MB)`;
1468
+ }
1469
+ // 找目标 channelId
1470
+ let targetChannelId = channelId;
1471
+ if (isCrossChannel) {
1472
+ const ownerPeerId = getOwner(this.config, targetChannel);
1473
+ targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannel, ownerPeerId) ?? '') : '';
1474
+ if (!targetChannelId) {
1475
+ return `❌ 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`;
1476
+ }
1477
+ }
1478
+ // 发送文件
1479
+ try {
1480
+ const replyCtx = isCrossChannel ? undefined : this.getReplyContext(sendSession);
1481
+ await targetAdapter.sendFile(targetChannelId, realPath, replyCtx);
1482
+ const sizeStr = stat.size < 1024 ? `${stat.size} B`
1483
+ : stat.size < 1024 * 1024 ? `${(stat.size / 1024).toFixed(1)} KB`
1484
+ : `${(stat.size / 1024 / 1024).toFixed(1)} MB`;
1485
+ return isCrossChannel
1486
+ ? `📎 文件已通过 ${targetLabel} 发送: ${filePath} (${sizeStr})`
1487
+ : `✅ 已发送: ${filePath} (${sizeStr})`;
1488
+ }
1489
+ catch (error) {
1490
+ logger.error('[CommandHandler] /send failed:', error);
1491
+ return `❌ 文件发送失败: ${error.message || error}`;
1492
+ }
1493
+ }
557
1494
  // /plist 命令:列出所有项目
558
1495
  if (normalizedContent === '/plist') {
559
- const isGroup = await this.isGroupChat(channel, channelId);
560
- if (isGroup) {
1496
+ if (!policy.canListProjects(session?.chatType || 'private', identity.role)) {
561
1497
  if (!session) {
562
1498
  return `❌ 当前群聊未绑定项目
563
1499
 
564
1500
  请使用 /bind <项目路径> 绑定项目`;
565
1501
  }
566
1502
  const projectName = this.getProjectName(session.projectPath);
567
- const sessionKey = `${channel}-${channelId}`;
568
- const queueLength = this.messageQueue.getQueueLength(sessionKey);
569
- const status = queueLength > 0 ? '[处理中]' : '[空闲]';
1503
+ const isProcessing = !!session.processingState;
1504
+ const status = isProcessing ? '[处理中]' : '[空闲]';
570
1505
  return `当前群聊绑定的项目:
571
1506
  ${projectName} (${session.projectPath}) - ${status}
572
1507
 
573
1508
  提示:群聊不支持切换项目`;
574
1509
  }
575
- const lines = ['可用项目:'];
576
- const sessionKey = `${channel}-${channelId}`;
577
- const processingProject = this.messageQueue.getProcessingProject(sessionKey);
578
- const queueLength = this.messageQueue.getQueueLength(sessionKey);
579
- const normalizePath = (p) => p.replace(/[/\\]+$/, '');
1510
+ // 收集项目信息并按最近活跃排序(唯一来源:evolclaw.json projects.list)
1511
+ const entries = [];
580
1512
  for (const [name, projectPath] of Object.entries(this.projects)) {
581
- const isCurrent = session?.projectPath === projectPath;
582
- const prefix = isCurrent ? ' ✓' : ' ';
583
- const projectSession = await this.sessionManager.getSessionByProjectPath(channel, channelId, projectPath);
584
- if (!projectSession) {
585
- lines.push(`${prefix} ${name} (${projectPath}) - 无会话`);
1513
+ // 跳过不存在的路径
1514
+ if (!fs.existsSync(projectPath))
586
1515
  continue;
587
- }
588
- const statusParts = [];
1516
+ const isCurrent = session ? path.resolve(session.projectPath) === path.resolve(projectPath) : false;
1517
+ const projectSession = await this.sessionManager.getSessionByProjectPath(channel, channelId, projectPath);
1518
+ entries.push({
1519
+ name, projectPath, projectSession, isCurrent,
1520
+ updatedAt: projectSession?.updatedAt ?? 0,
1521
+ });
1522
+ }
1523
+ // 当前活跃项目置顶,其余按 updatedAt 降序
1524
+ entries.sort((a, b) => {
1525
+ if (a.isCurrent !== b.isCurrent)
1526
+ return a.isCurrent ? -1 : 1;
1527
+ return b.updatedAt - a.updatedAt;
1528
+ });
1529
+ // 构建项目状态文本的辅助函数
1530
+ const buildStatusText = (entry) => {
1531
+ const { projectSession, isCurrent } = entry;
1532
+ if (!projectSession)
1533
+ return '无会话';
1534
+ const parts = [];
589
1535
  if (isCurrent) {
590
- statusParts.push('活跃');
1536
+ parts.push('活跃');
591
1537
  }
592
1538
  else {
593
- const idleMs = Date.now() - projectSession.updatedAt;
594
- statusParts.push(formatIdleTime(idleMs));
1539
+ parts.push(formatIdleTime(Date.now() - projectSession.updatedAt));
595
1540
  }
596
- if (processingProject && normalizePath(processingProject) === normalizePath(projectPath)) {
597
- if (queueLength > 1) {
598
- statusParts.push(`[处理中,队列${queueLength - 1}条]`);
599
- }
600
- else {
601
- statusParts.push('[处理中]');
602
- }
1541
+ const isProcessing = !!projectSession.processingState;
1542
+ if (isProcessing) {
1543
+ const qLen = this.messageQueue.getQueueLength(projectSession.id);
1544
+ parts.push(qLen > 0 ? `[处理中,队列${qLen}条]` : '[处理中]');
603
1545
  }
604
- const unreadCount = this.messageCache.getCount(projectSession.id);
605
- if (unreadCount > 0) {
606
- statusParts.push(`[${unreadCount}条新消息]`);
1546
+ const unread = this.messageCache.getCount(projectSession.id);
1547
+ if (unread > 0) {
1548
+ parts.push(`[${unread}条新消息]`);
607
1549
  }
608
- else if (!processingProject || normalizePath(processingProject) !== normalizePath(projectPath)) {
609
- statusParts.push('[空闲]');
1550
+ else if (!isProcessing && !isCurrent) {
1551
+ parts.push('[空闲]');
610
1552
  }
611
- lines.push(`${prefix} ${name} (${projectPath}) - ${statusParts.join(' ')}`);
1553
+ return parts.join(' ');
1554
+ };
1555
+ // 尝试发送 ActionInteraction 卡片(每个项目一个按钮,一键切换)
1556
+ if (this.interactionRouter && entries.length > 0) {
1557
+ const requestId = `plist-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1558
+ const buttons = entries.map(e => ({
1559
+ key: e.name,
1560
+ label: e.isCurrent ? `✓ ${e.name}` : e.name,
1561
+ style: e.isCurrent ? 'primary' : 'default',
1562
+ }));
1563
+ const bodyLines = entries.map(e => {
1564
+ const status = buildStatusText(e);
1565
+ const prefix = e.isCurrent ? '▶' : '•';
1566
+ return `${prefix} **${e.name}** (${e.projectPath}) ${status}`;
1567
+ });
1568
+ const interaction = {
1569
+ type: 'interaction',
1570
+ id: requestId,
1571
+ channelId,
1572
+ sessionId: activeSession?.id || requestId,
1573
+ kind: {
1574
+ kind: 'action',
1575
+ title: '📂 项目列表',
1576
+ body: bodyLines.join('\n'),
1577
+ buttons,
1578
+ },
1579
+ };
1580
+ const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
1581
+ const cardSent = await this.sendInteractionCard({
1582
+ channel, channelId, sessionId: activeSession?.id || requestId, requestId, interaction, replyCtx,
1583
+ callback: async (action, _values, operatorId) => {
1584
+ if (userId && operatorId && operatorId !== userId)
1585
+ return;
1586
+ const selectedEntry = entries.find(e => e.name === action);
1587
+ if (selectedEntry && !selectedEntry.isCurrent) {
1588
+ const result = await this.handle(`/project ${action}`, channel, channelId, undefined, userId, threadId);
1589
+ if (result) {
1590
+ const adapter = this.adapters.get(channel);
1591
+ adapter?.sendText(channelId, result, replyCtx);
1592
+ }
1593
+ }
1594
+ },
1595
+ });
1596
+ if (cardSent)
1597
+ return null;
1598
+ }
1599
+ // 降级:文本列表
1600
+ const lines = ['可用项目:'];
1601
+ for (const entry of entries) {
1602
+ const prefix = entry.isCurrent ? ' ✓' : ' ';
1603
+ lines.push(`${prefix} ${entry.name} (${entry.projectPath}) - ${buildStatusText(entry)}`);
612
1604
  }
613
1605
  return lines.join('\n');
614
1606
  }
1607
+ // /project(无参数):直接复用 /plist 逻辑(含卡片交互)
1608
+ if (normalizedContent === '/project') {
1609
+ if (!policy.canSwitchProject(session?.chatType || 'private', identity.role)) {
1610
+ // 群聊不能切换项目,交由 /plist 逻辑处理
1611
+ }
1612
+ return this.handle('/plist', channel, channelId, undefined, userId, threadId);
1613
+ }
615
1614
  // /project 命令:切换项目(支持名称或路径)
616
1615
  if (normalizedContent.startsWith('/project ')) {
617
- const isGroup = await this.isGroupChat(channel, channelId);
618
- if (isGroup) {
1616
+ if (!policy.canSwitchProject(session?.chatType || 'private', identity.role)) {
619
1617
  return `❌ 群聊不支持切换项目
620
1618
 
621
1619
  群聊只能绑定一个项目。如需更换项目,请联系管理员重新配置。`;
622
1620
  }
623
- const arg = normalizedContent.slice(9).trim();
1621
+ let arg = normalizedContent.slice(9).trim();
624
1622
  if (!arg)
625
1623
  return '用法: /p <name|path> 或 /project <name|path>';
1624
+ // 检查确认标志
1625
+ const hasConfirm = arg.endsWith(' --confirm');
1626
+ if (hasConfirm) {
1627
+ arg = arg.slice(0, -10).trim();
1628
+ }
626
1629
  let projectPath;
627
1630
  let projectName;
628
1631
  if (arg.includes('/')) {
@@ -649,10 +1652,33 @@ export class CommandHandler {
649
1652
  return `当前已在项目: ${projectName}\n 路径: ${projectPath}`;
650
1653
  }
651
1654
  }
652
- const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath);
1655
+ // 群聊切换项目需要确认
1656
+ const isGroupChat = session?.chatType === 'group';
1657
+ if (isGroupChat && !hasConfirm) {
1658
+ return `⚠️ 群聊切换项目风险提示:
1659
+
1660
+ 切换项目将影响所有群成员的对话上下文,可能导致:
1661
+ • 当前项目的会话历史被切换
1662
+ • 正在处理的任务被中断
1663
+ • 其他成员的工作受到影响
1664
+
1665
+ 确认切换请执行:
1666
+ /p ${projectName} --confirm`;
1667
+ }
1668
+ const currentAgentId = activeSession?.agentId || this.defaultAgentId;
1669
+ const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath, currentAgentId);
1670
+ this.eventBus.publish({
1671
+ type: 'project:switched',
1672
+ sessionId: newSession.id,
1673
+ channel,
1674
+ channelId,
1675
+ projectPath,
1676
+ timestamp: Date.now()
1677
+ });
653
1678
  const cachedEvents = this.messageCache.getEvents(newSession.id);
654
1679
  const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
655
- let response = `✓ 已切换到项目: ${projectName}\n 路径: ${projectPath}\n ${hasExistingSession}`;
1680
+ const currentAgent = newSession.agentId || this.defaultAgentId;
1681
+ let response = `✓ 已切换到项目: ${projectName}\n 路径: ${projectPath}\n Agent: ${currentAgent}\n ${hasExistingSession}`;
656
1682
  if (cachedEvents.length > 0 && sendMessage) {
657
1683
  for (const event of cachedEvents) {
658
1684
  if (event.type === 'completed') {
@@ -674,43 +1700,46 @@ export class CommandHandler {
674
1700
  }
675
1701
  return response;
676
1702
  }
677
- // /bind 命令:绑定新项目目录
1703
+ // /bind 命令:持久化项目到配置(不切换)
678
1704
  if (normalizedContent.startsWith('/bind ')) {
679
1705
  const projectPath = normalizedContent.slice(6).trim();
680
1706
  if (!projectPath)
681
- return '用法: /bind <path>';
1707
+ return '用法: /bind <路径>';
682
1708
  if (!path.isAbsolute(projectPath)) {
683
1709
  return '❌ 项目路径必须是绝对路径';
684
1710
  }
685
1711
  if (!fs.existsSync(projectPath)) {
686
1712
  return `❌ 路径不存在: ${projectPath}`;
687
1713
  }
688
- const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath);
689
- const cachedEvents = this.messageCache.getEvents(newSession.id);
690
- const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
691
- let response = `✓ 已绑定项目目录: ${projectPath}\n ${hasExistingSession}`;
692
- if (cachedEvents.length > 0) {
693
- response += `\n\n后台任务结果:`;
694
- for (const event of cachedEvents) {
695
- if (event.type === 'completed') {
696
- response += `\n✓ 任务完成`;
697
- if (event.metadata?.duration) {
698
- response += ` (耗时: ${Math.round(event.metadata.duration / 1000)}s)`;
699
- }
700
- const summary = event.message.substring(0, 200);
701
- response += `\n${summary}${event.message.length > 200 ? '...' : ''}`;
702
- }
703
- else if (event.type === 'error') {
704
- response += `\n❌ 任务失败: ${event.metadata?.errorType || '未知错误'}`;
705
- response += `\n${event.message}`;
706
- }
1714
+ // 生成项目名称(使用目录名)
1715
+ const projectName = path.basename(projectPath);
1716
+ // 检查是否已存在
1717
+ if (this.projects[projectName]) {
1718
+ const existingPath = this.projects[projectName];
1719
+ if (existingPath === projectPath) {
1720
+ return `项目 "${projectName}" 已存在\n 路径: ${projectPath}\n\n使用 /p ${projectName} 切换到该项目`;
707
1721
  }
708
- this.messageCache.clearEvents(newSession.id);
709
- }
710
- return response;
1722
+ return `❌ 项目名称 "${projectName}" 已被占用\n 现有路径: ${existingPath}\n 新路径: ${projectPath}\n\n请重命名目录或手动编辑配置文件`;
1723
+ }
1724
+ // 添加到配置
1725
+ if (!this.config.projects) {
1726
+ this.config.projects = { defaultPath: process.cwd(), autoCreate: false, list: {} };
1727
+ }
1728
+ if (!this.config.projects.list) {
1729
+ this.config.projects.list = {};
1730
+ }
1731
+ this.config.projects.list[projectName] = projectPath;
1732
+ // 保存配置
1733
+ const { saveConfig } = await import('../config.js');
1734
+ saveConfig(this.config);
1735
+ // 更新内存中的项目列表
1736
+ this.projects[projectName] = projectPath;
1737
+ return `✓ 已添加项目: ${projectName}\n 路径: ${projectPath}\n\n使用 /p ${projectName} 切换到该项目`;
711
1738
  }
712
- // /slist 命令:列出当前项目的所有会话
713
- if (normalizedContent === '/slist') {
1739
+ // /slist 命令:列出当前项目的会话
1740
+ // /slist 仅 EvolClaw 会话
1741
+ // /slist cli — 仅 CLI 会话(未导入的)
1742
+ if (normalizedContent === '/slist' || normalizedContent === '/slist cli') {
714
1743
  if (!session) {
715
1744
  return `❌ 当前没有活跃会话
716
1745
 
@@ -719,110 +1748,244 @@ export class CommandHandler {
719
1748
  2. /new [名称] - 创建命名会话
720
1749
  3. /project <项目> - 切换到指定项目`;
721
1750
  }
1751
+ const showCliOnly = normalizedContent === '/slist cli';
1752
+ // /slist cli — 仅显示 CLI 会话
1753
+ if (showCliOnly) {
1754
+ const canImportCli = policy.canImportCliSession(session.chatType || 'private', identity.role);
1755
+ if (!canImportCli) {
1756
+ return '❌ 当前无权查看 CLI 会话';
1757
+ }
1758
+ const cliSessions = await this.sessionManager.scanCliSessions(session.projectPath, session.agentId);
1759
+ const sessions = await this.sessionManager.listSessions(channel, channelId);
1760
+ const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
1761
+ const dbSessionIds = new Set(currentProjectSessions.map(s => s.agentSessionId).filter(Boolean));
1762
+ const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid));
1763
+ if (orphanCliSessions.length === 0) {
1764
+ return `当前项目 ${path.basename(session.projectPath)} 没有未导入的 CLI 会话`;
1765
+ }
1766
+ // 构建显示数据(复用于卡片和文本)
1767
+ const cliDisplayItems = orphanCliSessions.map(c => {
1768
+ const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
1769
+ const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid, session.agentId) || '(无消息)';
1770
+ const uuid = c.uuid.substring(0, 8);
1771
+ return { uuid, fullUuid: c.uuid, time, message };
1772
+ });
1773
+ // 尝试发送 ActionInteraction 卡片
1774
+ if (this.interactionRouter && cliDisplayItems.length > 0) {
1775
+ const requestId = `slist-cli-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1776
+ const buttons = cliDisplayItems.map(item => ({
1777
+ key: item.uuid,
1778
+ label: item.uuid,
1779
+ style: 'default',
1780
+ }));
1781
+ const bodyLines = cliDisplayItems.map(item => `• ${item.time} (${item.uuid}) "${item.message}"`);
1782
+ const interaction = {
1783
+ type: 'interaction',
1784
+ id: requestId,
1785
+ channelId,
1786
+ sessionId: session.id,
1787
+ kind: {
1788
+ kind: 'action',
1789
+ title: `📋 ${path.basename(session.projectPath)} CLI 会话 (${cliDisplayItems.length})`,
1790
+ body: bodyLines.join('\n'),
1791
+ buttons,
1792
+ },
1793
+ };
1794
+ const replyCtx = this.getReplyContext(session);
1795
+ const cardSent = await this.sendInteractionCard({
1796
+ channel, channelId, sessionId: session.id, requestId, interaction, replyCtx,
1797
+ callback: async (action, _values, operatorId) => {
1798
+ if (userId && operatorId && operatorId !== userId)
1799
+ return;
1800
+ const result = await this.handle(`/session ${action}`, channel, channelId, undefined, userId, threadId);
1801
+ if (result) {
1802
+ const adapter = this.adapters.get(channel);
1803
+ adapter?.sendText(channelId, result, replyCtx);
1804
+ }
1805
+ },
1806
+ });
1807
+ if (cardSent)
1808
+ return null;
1809
+ }
1810
+ // 降级:文本列表
1811
+ const lines = [`当前项目 ${path.basename(session.projectPath)} 的 CLI 会话 (共 ${orphanCliSessions.length} 个):`, ''];
1812
+ for (const item of cliDisplayItems) {
1813
+ lines.push(` ${item.time} (${item.uuid}) "${item.message}"`);
1814
+ }
1815
+ lines.push('');
1816
+ lines.push('使用 /s <8位uuid> 导入并切换到 CLI 会话');
1817
+ return lines.join('\n');
1818
+ }
1819
+ // /slist — 仅显示 EvolClaw 会话
722
1820
  const sessions = await this.sessionManager.listSessions(channel, channelId);
723
- const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath);
1821
+ const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
724
1822
  // 从 SDK 同步会话名称(发现 CLI 改名)
725
1823
  try {
726
- const sdkSessions = await sdkListSessions({ dir: session.projectPath });
1824
+ const sdkSessions = await this.sessionManager.listSdkSessions(session.projectPath, session.agentId);
727
1825
  for (const sdkSession of sdkSessions) {
728
- const sdkName = sdkSession.customTitle || undefined;
729
- if (!sdkName)
1826
+ if (!sdkSession.title)
730
1827
  continue;
731
1828
  const dbSession = currentProjectSessions.find(s => s.agentSessionId === sdkSession.sessionId);
732
- if (dbSession && sdkName !== dbSession.name) {
733
- await this.sessionManager.renameSession(dbSession.id, sdkName);
734
- dbSession.name = sdkName;
1829
+ if (dbSession && sdkSession.title !== dbSession.name) {
1830
+ await this.sessionManager.renameSession(dbSession.id, sdkSession.title);
1831
+ dbSession.name = sdkSession.title;
735
1832
  }
736
1833
  }
737
1834
  }
738
1835
  catch (error) {
739
1836
  logger.debug('[CommandHandler] SDK listSessions sync failed (non-critical):', error);
740
1837
  }
741
- const isGroup = await this.isGroupChat(channel, channelId);
742
- const cliSessions = (isGroup || !isAdmin)
743
- ? []
744
- : await this.sessionManager.scanCliSessions(session.projectPath);
745
- const dbSessionIds = new Set(currentProjectSessions.map(s => s.agentSessionId).filter(Boolean));
746
- const lines = [`当前项目 ${path.basename(session.projectPath)} 的会话列表:\n`];
747
- const sessionKey = `${channel}-${channelId}`;
748
- const isProcessing = this.messageQueue.isProcessing(sessionKey);
1838
+ // 构建可显示会话列表(复用于卡片和文本)
1839
+ const hideTopics = currentProjectSessions.length > 10;
1840
+ const topicCount = hideTopics ? currentProjectSessions.filter(s => s.threadId).length : 0;
1841
+ const maxDisplay = 10;
1842
+ const displaySessions = [];
1843
+ let displayIndex = 0;
1844
+ for (let i = 0; i < currentProjectSessions.length; i++) {
1845
+ const s = currentProjectSessions[i];
1846
+ if (hideTopics && s.threadId)
1847
+ continue;
1848
+ if (displayIndex >= maxDisplay)
1849
+ break;
1850
+ const isActive = s.metadata?.isActive === true;
1851
+ displayIndex++;
1852
+ const name = s.name || '(未命名)';
1853
+ const idleTime = formatIdleTime(Date.now() - s.updatedAt);
1854
+ const fileMissing = !!(s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId, s.agentId));
1855
+ let status = '[空闲]';
1856
+ if (fileMissing) {
1857
+ status = '[会话文件缺失]';
1858
+ }
1859
+ else if (!!s.processingState) {
1860
+ status = '[处理中]';
1861
+ }
1862
+ else if (isActive) {
1863
+ status = '[活跃]';
1864
+ }
1865
+ displaySessions.push({ session: s, index: displayIndex, isActive, name, status, idleTime, fileMissing });
1866
+ }
1867
+ // 尝试发送 ActionInteraction 卡片(每个会话一个按钮,一键切换)
1868
+ if (this.interactionRouter && displaySessions.length >= 1) {
1869
+ const requestId = `slist-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1870
+ const buttons = displaySessions.map(ds => {
1871
+ const shortId = ds.session.agentSessionId ? ds.session.agentSessionId.substring(0, 8) : ds.name;
1872
+ return {
1873
+ key: String(ds.index),
1874
+ label: ds.isActive ? `✓ ${ds.index}. ${shortId}` : `${ds.index}. ${shortId}`,
1875
+ style: ds.isActive ? 'primary' : 'default',
1876
+ };
1877
+ });
1878
+ const bodyLines = displaySessions.map(ds => {
1879
+ const prefix = ds.isActive ? '▶' : '•';
1880
+ const threadTag = ds.session.threadId ? '[话题] ' : '';
1881
+ const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
1882
+ const fileMark = ds.fileMissing ? '❌ ' : '';
1883
+ return `${prefix} ${ds.index}. ${threadTag}${fileMark}**${ds.name}** ${uuid} ${ds.idleTime} ${ds.status}`;
1884
+ });
1885
+ const interaction = {
1886
+ type: 'interaction',
1887
+ id: requestId,
1888
+ channelId,
1889
+ sessionId: session.id,
1890
+ kind: {
1891
+ kind: 'action',
1892
+ title: `📋 ${path.basename(session.projectPath)} 会话列表`,
1893
+ body: bodyLines.join('\n'),
1894
+ buttons,
1895
+ },
1896
+ };
1897
+ const replyCtx = this.getReplyContext(session);
1898
+ const cardSent = await this.sendInteractionCard({
1899
+ channel, channelId, sessionId: session.id, requestId, interaction, replyCtx,
1900
+ callback: async (action, _values, operatorId) => {
1901
+ if (userId && operatorId && operatorId !== userId)
1902
+ return;
1903
+ const target = displaySessions.find(ds => String(ds.index) === action);
1904
+ if (target && !target.isActive) {
1905
+ const result = await this.handle(`/session ${action}`, channel, channelId, undefined, userId, threadId);
1906
+ if (result) {
1907
+ const adapter = this.adapters.get(channel);
1908
+ adapter?.sendText(channelId, result, replyCtx);
1909
+ }
1910
+ }
1911
+ },
1912
+ });
1913
+ if (cardSent)
1914
+ return null;
1915
+ }
1916
+ // 降级:文本列表
1917
+ const lines = [`当前项目 ${path.basename(session.projectPath)} 的 [${session.agentId}] 会话列表:`, ''];
749
1918
  if (currentProjectSessions.length > 0) {
750
- lines.push('【EvolClaw 会话】');
751
- for (let i = 0; i < currentProjectSessions.length; i++) {
752
- const s = currentProjectSessions[i];
753
- const prefix = s.isActive ? '' : ' ';
754
- const num = `${i + 1}.`;
755
- const threadTag = s.threadId ? '[话题] ' : '';
756
- const name = s.name || '(未命名)';
757
- const uuid = s.agentSessionId ? `(${s.agentSessionId.substring(0, 8)})` : '';
758
- const idleTime = formatIdleTime(Date.now() - s.updatedAt);
759
- if (s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId)) {
760
- lines.push(`${prefix} ${num} ${threadTag}❌ ${name} ${uuid} - ${idleTime} [会话文件缺失]`);
1919
+ for (const ds of displaySessions) {
1920
+ const prefix = ds.isActive ? ' ✓' : ' ';
1921
+ const num = `${ds.index}.`;
1922
+ const threadTag = ds.session.threadId ? '[话题] ' : '';
1923
+ const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
1924
+ if (ds.fileMissing) {
1925
+ lines.push(`${prefix} ${num} ${threadTag}❌ ${ds.name} ${uuid} - ${ds.idleTime} ${ds.status}`);
761
1926
  }
762
1927
  else {
763
- let status = '[空闲]';
764
- if (s.isActive && isProcessing) {
765
- status = '[处理中]';
766
- }
767
- else if (s.isActive) {
768
- status = '[活跃]';
769
- }
770
- lines.push(`${prefix} ${num} ${threadTag}${name} ${uuid} - ${idleTime} ${status}`);
1928
+ lines.push(`${prefix} ${num} ${threadTag}${ds.name} ${uuid} - ${ds.idleTime} ${ds.status}`);
771
1929
  }
772
1930
  }
773
- lines.push('');
774
- }
775
- const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid)).slice(0, 5);
776
- if (orphanCliSessions.length > 0) {
777
- lines.push('【CLI 会话】(最新5个)');
778
- for (const c of orphanCliSessions) {
779
- const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
780
- const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid) || '(无消息)';
781
- const uuid = c.uuid.substring(0, 8);
782
- lines.push(` ${time} (${uuid}) "${message}"`);
1931
+ const hiddenCount = currentProjectSessions.length - displayIndex - topicCount;
1932
+ if (topicCount > 0 || hiddenCount > 0) {
1933
+ const parts = [];
1934
+ if (hiddenCount > 0)
1935
+ parts.push(`${hiddenCount} 个更早的会话`);
1936
+ if (topicCount > 0)
1937
+ parts.push(`${topicCount} 个话题会话`);
1938
+ lines.push(`\n (已隐藏 ${parts.join('、')})`);
783
1939
  }
784
1940
  lines.push('');
785
1941
  }
786
1942
  lines.push('使用 /s <序号、name或8位uuid> 切换会话');
1943
+ lines.push('使用 /slist cli 查看 CLI 会话');
787
1944
  return lines.join('\n');
788
1945
  }
1946
+ // /session(无参数):直接复用 /slist 逻辑(含卡片交互)
1947
+ if (normalizedContent === '/session') {
1948
+ return this.handle('/slist', channel, channelId, undefined, userId, threadId);
1949
+ }
789
1950
  // /session 或 /s 命令:切换会话
790
1951
  if (normalizedContent.startsWith('/session ')) {
791
1952
  const sessionName = normalizedContent.slice(9).trim();
792
1953
  if (!sessionName)
793
1954
  return '用法: /s <序号、会话名称或前8位UUID>';
794
- const sessionKey = `${channel}-${channelId}`;
795
- const queueLength = this.messageQueue.getQueueLength(sessionKey);
796
- if (queueLength > 0) {
797
- return `⚠️ 当前正在处理消息,无法切换会话\n请等待当前任务完成后再试`;
798
- }
799
1955
  let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
800
- // 序号切换:纯数字时按当前项目会话列表序号匹配
1956
+ // 序号切换:纯数字时按 /slist 显示的序号匹配(超过10个时隐藏非活跃话题会话)
801
1957
  if (!targetSession && /^\d+$/.test(sessionName) && session) {
802
1958
  const idx = parseInt(sessionName, 10);
803
1959
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
804
- const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath);
805
- if (idx >= 1 && idx <= projectSessions.length) {
806
- targetSession = projectSessions[idx - 1];
1960
+ const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
1961
+ // /slist 显示逻辑一致:超过10个时隐藏非活跃话题会话
1962
+ const hideTopics = projectSessions.length > 10;
1963
+ const visibleSessions = hideTopics
1964
+ ? projectSessions.filter(s => !s.threadId)
1965
+ : projectSessions;
1966
+ if (idx >= 1 && idx <= visibleSessions.length) {
1967
+ targetSession = visibleSessions[idx - 1];
807
1968
  }
808
1969
  else {
809
- return `❌ 序号超出范围 (1-${projectSessions.length})\n使用 /slist 查看可用会话`;
1970
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
810
1971
  }
811
1972
  }
812
1973
  if (!targetSession && sessionName.length === 8) {
813
1974
  targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
814
1975
  }
815
- const isGroup = await this.isGroupChat(channel, channelId);
816
- if (!targetSession && sessionName.length === 8 && !isGroup && isAdmin) {
1976
+ const canImport = policy.canImportCliSession(session?.chatType || 'private', identity.role);
1977
+ if (!targetSession && sessionName.length === 8 && canImport) {
817
1978
  const projectPaths = Object.values(this.projects);
818
1979
  if (session) {
819
1980
  projectPaths.unshift(session.projectPath);
820
1981
  }
821
1982
  for (const projectPath of projectPaths) {
822
- const cliSessions = await this.sessionManager.scanCliSessions(projectPath);
1983
+ const currentAgentId = session?.agentId || this.defaultAgentId;
1984
+ const cliSessions = await this.sessionManager.scanCliSessions(projectPath, currentAgentId);
823
1985
  const cliSession = cliSessions.find(c => c.uuid.startsWith(sessionName));
824
1986
  if (cliSession) {
825
- const imported = await this.sessionManager.importCliSession(channel, channelId, projectPath, cliSession.uuid);
1987
+ const imported = await this.sessionManager.importCliSession(channel, channelId, projectPath, cliSession.uuid, currentAgentId);
1988
+ this.eventBus.publish({ type: 'session:imported', sessionId: imported.id, agentSessionId: cliSession.uuid, projectPath });
826
1989
  const projectName = this.getProjectName(projectPath);
827
1990
  return `✓ 已导入 CLI 会话: ${imported.name}\n 项目: ${projectName}\n 将继续之前的对话历史`;
828
1991
  }
@@ -832,7 +1995,7 @@ export class CommandHandler {
832
1995
  return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
833
1996
  }
834
1997
  const lastInput = targetSession.agentSessionId
835
- ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId)
1998
+ ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId, targetSession.agentId)
836
1999
  : null;
837
2000
  const lastInputLine = lastInput ? `\n 最后输入: "${lastInput}"` : '';
838
2001
  if (!session) {
@@ -853,6 +2016,7 @@ export class CommandHandler {
853
2016
  if (!switched) {
854
2017
  return `❌ 切换会话失败`;
855
2018
  }
2019
+ this.eventBus.publish({ type: 'session:switched', sessionId: targetSession.id, fromSessionId: session.id, toSessionId: targetSession.id });
856
2020
  const continueHint = lastInput ? '\n 将继续之前的对话历史' : '\n 当前会话未有发言';
857
2021
  return `✓ 已切换到会话: ${targetSession.name || sessionName}${continueHint}${lastInputLine}`;
858
2022
  }
@@ -873,19 +2037,12 @@ export class CommandHandler {
873
2037
  if (existing && existing.id !== session.id) {
874
2038
  return `❌ 会话名称 "${newName}" 已存在,请使用其他名称`;
875
2039
  }
876
- // 双写:SDK + 数据库
877
- if (session.agentSessionId) {
878
- try {
879
- await sdkRenameSession(session.agentSessionId, newName, { dir: session.projectPath });
880
- }
881
- catch (error) {
882
- logger.warn(`[CommandHandler] SDK renameSession failed (continuing with db update):`, error);
883
- }
884
- }
2040
+ const oldName = session.name || '(未命名)';
885
2041
  const success = await this.sessionManager.renameSession(session.id, newName);
886
2042
  if (!success) {
887
2043
  return `❌ 重命名失败`;
888
2044
  }
2045
+ this.eventBus.publish({ type: 'session:renamed', sessionId: session.id, oldName, newName });
889
2046
  return `✓ 已将当前会话重命名为: ${newName}`;
890
2047
  }
891
2048
  // /del 命令:删除指定会话(仅解绑,不删除文件)
@@ -896,22 +2053,25 @@ export class CommandHandler {
896
2053
  if (!session) {
897
2054
  return `❌ 当前没有活跃会话`;
898
2055
  }
899
- // 群聊权限检查:只有管理员可以删除
900
- const isGroup = await this.isGroupChat(channel, channelId);
901
- if (isGroup && !isAdmin) {
2056
+ // 权限检查:policy 控制谁可以删除会话
2057
+ if (!policy.canDeleteSession(session.chatType || 'private', identity.role)) {
902
2058
  return `❌ 无权限:群聊中仅管理员可删除会话`;
903
2059
  }
904
2060
  let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
905
- // 序号删除
2061
+ // 序号删除(与 /slist 显示序号一致)
906
2062
  if (!targetSession && /^\d+$/.test(sessionName)) {
907
2063
  const idx = parseInt(sessionName, 10);
908
2064
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
909
- const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath);
910
- if (idx >= 1 && idx <= projectSessions.length) {
911
- targetSession = projectSessions[idx - 1];
2065
+ const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
2066
+ const hideTopics = projectSessions.length > 10;
2067
+ const visibleSessions = hideTopics
2068
+ ? projectSessions.filter(s => !s.threadId)
2069
+ : projectSessions;
2070
+ if (idx >= 1 && idx <= visibleSessions.length) {
2071
+ targetSession = visibleSessions[idx - 1];
912
2072
  }
913
2073
  else {
914
- return `❌ 序号超出范围 (1-${projectSessions.length})\n使用 /slist 查看可用会话`;
2074
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
915
2075
  }
916
2076
  }
917
2077
  if (!targetSession && sessionName.length === 8) {
@@ -927,7 +2087,9 @@ export class CommandHandler {
927
2087
  if (!success) {
928
2088
  return `❌ 删除失败`;
929
2089
  }
930
- await this.agentRunner.closeSession(targetSession.id);
2090
+ this.eventBus.publish({ type: 'session:deleted', sessionId: targetSession.id });
2091
+ const targetAgent = this.getAgent(targetSession.agentId);
2092
+ await targetAgent.closeSession(targetSession.id);
931
2093
  return `✓ 已删除会话: ${targetSession.name || sessionName}\n会话文件已保留,可通过 CLI 访问`;
932
2094
  }
933
2095
  // /fork 命令:分支当前会话
@@ -937,11 +2099,16 @@ export class CommandHandler {
937
2099
  return `❌ 当前没有活跃会话,无法分支`;
938
2100
  }
939
2101
  if (!session.agentSessionId) {
940
- return `❌ 当前会话尚未初始化 Claude 对话,无法分支\n\n请先发送一条消息,然后再使用 /fork`;
2102
+ return `❌ 当前会话尚未初始化对话,无法分支\n\n请先发送一条消息,然后再使用 /fork`;
2103
+ }
2104
+ const forkAgent = this.getAgent(session.agentId);
2105
+ if (!forkAgent.capabilities?.fork) {
2106
+ return `❌ 当前 Agent (${forkAgent.name}) 不支持 /fork\n\n可使用 /new 创建新会话替代`;
941
2107
  }
942
2108
  try {
943
- const forkResult = await sdkForkSession(session.agentSessionId, { dir: session.projectPath, title: forkName });
944
- const newSession = await this.sessionManager.createForkedSession(session, forkResult.sessionId, forkName);
2109
+ const forkedSessionId = await forkAgent.forkSession(session.agentSessionId, session.projectPath, forkName);
2110
+ const newSession = await this.sessionManager.createForkedSession(session, forkedSessionId, forkName);
2111
+ this.eventBus.publish({ type: 'session:forked', sessionId: newSession.id, sourceSessionId: session.id, name: forkName });
945
2112
  return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /slist 查看所有会话,/s <名称> 切换回原会话`;
946
2113
  }
947
2114
  catch (error) {
@@ -951,66 +2118,49 @@ export class CommandHandler {
951
2118
  }
952
2119
  // /repair 命令:检查并修复会话
953
2120
  if (normalizedContent === '/repair') {
954
- if (!session) {
955
- return `❌ 当前未创建会话,无需修复`;
956
- }
957
- const health = await this.sessionManager.getHealthStatus(session.id);
2121
+ const repairResult = await this.ensureSession(channel, channelId, threadId);
2122
+ if ('error' in repairResult)
2123
+ return repairResult.error;
2124
+ const { session: repairSession } = repairResult;
2125
+ const health = await this.sessionManager.getHealthStatus(repairSession.id);
958
2126
  if (!health.safeMode) {
959
2127
  return `当前不在安全模式,无需修复\n\n如需进入安全模式,请使用 /safe`;
960
2128
  }
961
- const { checkSessionFileHealth, backupClaudeDir } = await import('../utils/session-file-health.js');
962
- const fsPromises = await import('fs/promises');
2129
+ const repairAgent = this.getAgent(repairSession.agentId);
2130
+ const { checkSessionFile, backupSessionFile } = await import('./session/session-file-health.js');
963
2131
  try {
964
- const backupDir = await backupClaudeDir(session.projectPath);
965
- if (!session.agentSessionId) {
966
- await this.sessionManager.resetHealthStatus(session.id);
967
- return `✓ 修复完成,已退出安全模式
968
-
969
- 修复内容:
970
- - 未发现问题(新会话)
971
- - 已重置异常计数器
972
- - 已恢复正常会话模式
973
-
974
- 备份位置:${backupDir}`;
2132
+ if (!repairSession.agentSessionId) {
2133
+ await this.sessionManager.resetHealthStatus(repairSession.id);
2134
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2135
+ return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 未发现问题(新会话)\n- 已重置异常计数器\n- 已恢复正常会话模式`;
2136
+ }
2137
+ // 通过 agent 定位 session 文件
2138
+ const sessionFile = repairAgent.resolveSessionFile?.(repairSession.agentSessionId, repairSession.projectPath) ?? null;
2139
+ if (!sessionFile) {
2140
+ // 文件不存在(已被删除或从未创建),直接重置
2141
+ await this.sessionManager.resetHealthStatus(repairSession.id);
2142
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2143
+ return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 会话文件不存在(可能已被清理)\n- 已重置异常计数器\n- 已恢复正常会话模式`;
975
2144
  }
976
- const healthCheck = await checkSessionFileHealth(session.projectPath, session.agentSessionId);
2145
+ const healthCheck = await checkSessionFile(sessionFile);
977
2146
  if (healthCheck.corrupt) {
978
- const sessionFile = path.join(session.projectPath, '.claude', `${session.agentSessionId}.jsonl`);
2147
+ const backupPath = await backupSessionFile(sessionFile);
2148
+ const fsPromises = await import('fs/promises');
979
2149
  await fsPromises.unlink(sessionFile);
980
- await this.sessionManager.updateAgentSessionId(session.channel, session.channelId, '');
981
- await this.sessionManager.resetHealthStatus(session.id);
982
- return `✓ 修复完成,已退出安全模式
983
-
984
- 检测到问题:
985
- ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
986
-
987
- 修复操作:
988
- - 已删除损坏文件
989
- - 已创建新会话
990
- - 已重置异常计数器
991
-
992
- 备份位置:${backupDir}`;
2150
+ await this.sessionManager.updateAgentSessionIdBySessionId(repairSession.id, '');
2151
+ repairAgent.updateSessionId(repairSession.id, '');
2152
+ await this.sessionManager.resetHealthStatus(repairSession.id);
2153
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2154
+ return `✓ 修复完成,已退出安全模式\n\n检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n修复操作:\n- 已备份损坏文件\n- 已删除损坏文件\n- 已重置异常计数器\n\n备份位置:${backupPath}`;
993
2155
  }
994
2156
  if (healthCheck.issues.length > 0) {
995
- await this.sessionManager.resetHealthStatus(session.id);
996
- return `⚠️ 检测到问题:
997
- ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
998
-
999
- 建议:
1000
- 1. 使用 /new 创建新会话
1001
- 2. 旧会话已备份到:${backupDir}
1002
-
1003
- 已重置异常计数器,可继续使用当前会话。`;
2157
+ await this.sessionManager.resetHealthStatus(repairSession.id);
2158
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2159
+ return `⚠️ 检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n建议使用 /new 创建新会话\n\n已重置异常计数器,可继续使用当前会话。`;
1004
2160
  }
1005
- await this.sessionManager.resetHealthStatus(session.id);
1006
- return `✓ 修复完成,已退出安全模式
1007
-
1008
- 修复内容:
1009
- - 未发现问题
1010
- - 已重置异常计数器
1011
- - 已恢复正常会话模式
1012
-
1013
- 备份位置:${backupDir}`;
2161
+ await this.sessionManager.resetHealthStatus(repairSession.id);
2162
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2163
+ return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 未发现问题\n- 已重置异常计数器\n- 已恢复正常会话模式`;
1014
2164
  }
1015
2165
  catch (error) {
1016
2166
  logger.error('[Repair] Failed:', error);
@@ -1019,10 +2169,12 @@ ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
1019
2169
  }
1020
2170
  // /safe 命令:手动进入安全模式
1021
2171
  if (normalizedContent === '/safe') {
1022
- if (!session) {
1023
- return `❌ 当前未创建会话`;
1024
- }
1025
- await this.sessionManager.setSafeMode(session.id, true);
2172
+ const safeResult = await this.ensureSession(channel, channelId, threadId);
2173
+ if ('error' in safeResult)
2174
+ return safeResult.error;
2175
+ const { session: safeSession } = safeResult;
2176
+ await this.sessionManager.setSafeMode(safeSession.id, true);
2177
+ this.eventBus.publish({ type: 'session:safe-mode-entered', sessionId: safeSession.id, reason: 'manual' });
1026
2178
  return `✓ 已进入安全模式
1027
2179
 
1028
2180
  当前行为:
@@ -1036,11 +2188,4 @@ ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
1036
2188
  }
1037
2189
  return null;
1038
2190
  }
1039
- /**
1040
- * 通过 adapter 查询是否为群聊
1041
- */
1042
- async isGroupChat(channel, channelId) {
1043
- const adapter = this.adapters.get(channel);
1044
- return await adapter?.isGroupChat?.(channelId) ?? false;
1045
- }
1046
2191
  }