evolclaw 2.2.0 → 2.4.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 (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +283 -95
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +232 -57
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +803 -247
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +217 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
  25. package/dist/index.js +140 -57
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -1,15 +1,34 @@
1
1
  import { hasModelSwitcher, hasPermissionController } from '../agents/claude-runner.js';
2
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 availableEfforts = ['low', 'medium', 'high', 'max'];
8
- function effortBar(level) {
9
- const levels = {
10
- low: '◆◇◇◇', medium: '◆◆◇◇', high: '◆◆◆◇', max: '◆◆◆◆'
11
- };
12
- 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');
13
32
  }
14
33
  /**
15
34
  * 写入用户级 ~/.claude/settings.json(与 Claude CLI 行为一致)
@@ -84,7 +103,7 @@ function formatIdleTime(ms) {
84
103
  return '刚刚';
85
104
  }
86
105
  // 支持的命令列表
87
- const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/agent', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork', '/del', '/perm', '/send', '/check'];
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'];
88
107
  // 命令别名映射
89
108
  const aliases = {
90
109
  '/p': '/project',
@@ -92,7 +111,7 @@ const aliases = {
92
111
  '/name': '/rename'
93
112
  };
94
113
  // 命令快速路径前缀(所有命令都不进入消息队列)
95
- const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/agent', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/send', '/check', '/p ', '/s ', '/name '];
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 '];
96
115
  export class CommandHandler {
97
116
  sessionManager;
98
117
  config;
@@ -101,9 +120,11 @@ export class CommandHandler {
101
120
  adapters = new Map();
102
121
  policies = new Map();
103
122
  channelObjects = new Map(); // name → actual channel instance (for /check)
123
+ channelTypeMap = new Map(); // name → channelType (for grouping)
104
124
  processor;
105
125
  messageQueue;
106
126
  permissionGateway;
127
+ interactionRouter;
107
128
  statsCollector;
108
129
  agentMap;
109
130
  defaultAgentId;
@@ -145,12 +166,16 @@ export class CommandHandler {
145
166
  const d = Math.floor(sec / 86400);
146
167
  const h = Math.floor((sec % 86400) / 3600);
147
168
  const m = Math.floor((sec % 3600) / 60);
169
+ const s = sec % 60;
148
170
  const parts = [];
149
171
  if (d > 0)
150
172
  parts.push(`${d}天`);
151
173
  if (h > 0)
152
174
  parts.push(`${h}时`);
153
- parts.push(`${m}分`);
175
+ if (m > 0)
176
+ parts.push(`${m}分`);
177
+ if (parts.length === 0)
178
+ parts.push(`${s}秒`);
154
179
  return parts.join('');
155
180
  }
156
181
  /** 获取消息队列 key:话题用 session.id,主会话用 channel-channelId */
@@ -162,6 +187,69 @@ export class CommandHandler {
162
187
  getReplyContext(session) {
163
188
  return session.metadata?.replyContext;
164
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
+ }
226
+ }
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;
252
+ }
165
253
  /** 获取活跃会话,无会话时返回统一错误提示 */
166
254
  async ensureSession(channel, channelId, threadId) {
167
255
  if (threadId) {
@@ -187,20 +275,37 @@ export class CommandHandler {
187
275
  setPermissionGateway(gateway) {
188
276
  this.permissionGateway = gateway;
189
277
  }
278
+ setInteractionRouter(router) {
279
+ this.interactionRouter = router;
280
+ }
190
281
  setStatsCollector(collector) {
191
282
  this.statsCollector = collector;
192
283
  }
193
284
  registerAdapter(adapter) {
194
- this.adapters.set(adapter.name, adapter);
285
+ this.adapters.set(adapter.channelName, adapter);
195
286
  }
196
- registerChannel(name, channel) {
287
+ registerChannel(name, channel, channelType) {
197
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;
198
295
  }
199
296
  registerPolicy(channelName, policy) {
200
297
  this.policies.set(channelName, policy);
201
298
  }
202
299
  getAdapter(channelName) {
203
- 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;
204
309
  }
205
310
  getPolicy(channel) {
206
311
  return this.policies.get(channel) || {
@@ -219,15 +324,25 @@ export class CommandHandler {
219
324
  * 返回结构化命令菜单(供 menu.query 使用)
220
325
  * admin 看到全部命令分组,guest 仅看到用户级命令
221
326
  */
222
- getMenuItems(isAdmin) {
327
+ getMenuItems(isAdmin, chatType = 'private') {
223
328
  const items = [];
329
+ if (!isAdmin && chatType === 'group') {
330
+ return [
331
+ {
332
+ group: '其他',
333
+ commands: [
334
+ { cmd: '/status', label: '显示会话状态' },
335
+ { cmd: '/help', label: '显示帮助信息' },
336
+ ]
337
+ }
338
+ ];
339
+ }
224
340
  if (isAdmin) {
225
341
  items.push({
226
342
  group: '项目管理',
227
343
  commands: [
228
344
  { cmd: '/pwd', label: '显示当前项目路径' },
229
- { cmd: '/plist', label: '列出所有配置的项目' },
230
- { cmd: '/p', args: '<name|path>', label: '切换项目' },
345
+ { cmd: '/p', args: '[name|path]', label: '列出或切换项目' },
231
346
  { cmd: '/bind', args: '<path>', label: '绑定新项目目录' },
232
347
  ]
233
348
  });
@@ -235,14 +350,12 @@ export class CommandHandler {
235
350
  items.push({
236
351
  group: '会话管理',
237
352
  commands: [
238
- { cmd: '/new', args: '[name]', label: '创建新会话' },
239
- { cmd: '/slist', label: '列出当前项目的所有会话' },
240
- { cmd: '/s', args: '<name|index|uuid>', label: '切换到指定会话' },
353
+ { cmd: '/new', args: '[name]', label: '创建新会话(清空历史请用此命令)' },
354
+ { cmd: '/s', args: '[cli|name|index|uuid]', label: '列出或切换会话(cli 查看未导入的 CLI 会话)' },
241
355
  { cmd: '/name', args: '<name>', label: '重命名当前会话' },
242
356
  { cmd: '/del', args: '<name>', label: '删除指定会话' },
243
357
  ...(isAdmin ? [
244
358
  { cmd: '/fork', args: '[name]', label: '分支当前会话' },
245
- { cmd: '/clear', label: '清空会话对话历史' },
246
359
  { cmd: '/compact', label: '压缩会话上下文' },
247
360
  ] : []),
248
361
  ]
@@ -252,13 +365,14 @@ export class CommandHandler {
252
365
  group: 'Agent 与模型',
253
366
  commands: [
254
367
  { cmd: '/agent', args: '[name]', label: '查看或切换 Agent 后端' },
255
- { cmd: '/model', args: '[model] [effort]', label: '查看或切换模型' },
368
+ { cmd: '/model', args: '[model]', label: '查看或切换模型' },
369
+ { cmd: '/effort', args: '[level]', label: '查看或切换推理强度' },
256
370
  ]
257
371
  });
258
372
  items.push({
259
373
  group: '权限管理',
260
374
  commands: [
261
- { cmd: '/perm', args: '[mode|allow|deny]', label: '权限模式管理' },
375
+ { cmd: '/perm', args: '[mode|allow|always|deny]', label: '权限模式管理' },
262
376
  ]
263
377
  });
264
378
  items.push({
@@ -267,9 +381,7 @@ export class CommandHandler {
267
381
  { cmd: '/status', label: '显示会话状态' },
268
382
  { cmd: '/stop', label: '中断当前任务' },
269
383
  { cmd: '/restart', label: '重启服务' },
270
- { cmd: '/repair', label: '检查并修复会话' },
271
- { cmd: '/safe', label: '进入安全模式' },
272
- { cmd: '/send', args: '[渠道] <path>', label: '发送项目内文件' },
384
+ { cmd: '/send', args: '[channel] <path>', label: '发送项目内文件' },
273
385
  { cmd: '/check', args: '[rty <channel>]', label: '检查渠道状态或重连指定渠道' },
274
386
  ]
275
387
  });
@@ -300,7 +412,7 @@ export class CommandHandler {
300
412
  * 主命令处理入口
301
413
  */
302
414
  async handle(content, channel, channelId, sendMessage, userId, threadId) {
303
- // 解析身份
415
+ // 解析身份(按实例名)
304
416
  const identity = this.sessionManager.resolveIdentity(channel, userId);
305
417
  const policy = this.getPolicy(channel);
306
418
  // 按当前会话选择 agent 后端
@@ -327,15 +439,19 @@ export class CommandHandler {
327
439
  }
328
440
  // 权限检查:区分用户级命令和管理级命令
329
441
  const isAdmin = identity.role === 'owner';
442
+ const activeChatType = activeSession?.chatType || 'private';
330
443
  if (normalizedContent.startsWith('/')) {
331
- const userCommands = ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
444
+ const guestGroupCommands = ['/status', '/help'];
445
+ const userCommands = activeChatType === 'group' && !isAdmin
446
+ ? guestGroupCommands
447
+ : ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
332
448
  const isUserCommand = userCommands.some(cmd => normalizedContent === cmd.trimEnd() || normalizedContent.startsWith(cmd));
333
449
  if (!isUserCommand && !isAdmin) {
334
- return '❌ 无权限:此命令仅限管理员使用';
450
+ return '❌ 无权限:当前群聊仅支持 /status 和 /help';
335
451
  }
336
452
  }
337
453
  // 空闲检查:某些命令需要等待当前会话空闲
338
- const requiresIdle = ['/new', '/clear', '/compact', '/safe', '/repair', '/fork', '/bind'];
454
+ const requiresIdle = ['/new', '/session', '/clear', '/compact', '/safe', '/repair', '/fork', '/bind', '/project', '/agent'];
339
455
  if (requiresIdle.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' '))) {
340
456
  if (threadId) {
341
457
  // 话题中:检查话题 session 是否在处理(不创建)
@@ -370,18 +486,27 @@ export class CommandHandler {
370
486
  }
371
487
  const isCmd = commands.some(cmd => normalizedContent.startsWith(cmd));
372
488
  if (!isCmd)
373
- return null;
489
+ return undefined;
374
490
  // /help 命令不需要会话
375
491
  if (normalizedContent === '/help') {
492
+ if (!isAdmin && activeChatType === 'group') {
493
+ const lines = [
494
+ '可用命令:',
495
+ '',
496
+ '其他:',
497
+ ' /status - 显示会话状态',
498
+ ' /help - 显示此帮助信息',
499
+ ];
500
+ return lines.join('\n');
501
+ }
376
502
  if (!isAdmin) {
377
503
  const lines = [
378
504
  '可用命令:',
379
505
  '',
380
506
  '🔄 会话管理:',
381
- ' /new [名称] - 创建新会话(可选命名)',
382
- ' /slist - 列出当前项目的所有会话',
383
- ' /s, /session <名称|序号|uuid> - 切换到指定会话',
384
- ' /name, /rename <新名称> - 重命名当前会话',
507
+ ' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
508
+ ' /s [cli|名称|序号|uuid] - 列出或切换会话(cli 查看未导入的 CLI 会话)',
509
+ ' /name <新名称> - 重命名当前会话',
385
510
  ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
386
511
  ' /status - 显示会话状态',
387
512
  '',
@@ -395,36 +520,32 @@ export class CommandHandler {
395
520
  '',
396
521
  '📁 项目管理:',
397
522
  ' /pwd - 显示当前项目路径',
398
- ' /plist - 列出所有配置的项目',
399
- ' /p, /project <name|path> - 切换项目',
523
+ ' /p [name|path] - 列出或切换项目',
400
524
  ' /bind <path> - 绑定新项目目录',
401
525
  '',
402
526
  '🔄 会话管理:',
403
- ' /new [名称] - 创建新会话(可选命名)',
404
- ' /slist - 列出当前项目的所有会话',
405
- ' /s, /session <名称> - 切换到指定会话',
406
- ' /name, /rename <新名称> - 重命名当前会话',
527
+ ' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
528
+ ' /s [cli|名称|序号|uuid] - 列出或切换会话(cli 查看未导入的 CLI 会话)',
529
+ ' /name <新名称> - 重命名当前会话',
407
530
  ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
408
531
  ' /fork [名称] - 分支当前会话(从当前对话点创建分支)',
409
- ' /clear - 清空当前会话的对话历史',
410
532
  ' /compact - 压缩会话上下文(减少 token 用量)',
411
533
  '',
412
534
  '🤖 Agent 与模型:',
413
535
  ' /agent [name] - 查看或切换 Agent 后端',
414
- ' /model [model] [effort] - 查看或切换模型/推理强度',
536
+ ' /model [model] - 查看或切换模型',
537
+ ' /effort [level] - 查看或切换推理强度',
415
538
  '',
416
539
  '🔐 权限管理:',
417
540
  ' /perm - 查看当前权限模式',
418
- ' /perm <default|request|edit|plan|noask> - 切换权限模式',
419
- ' /perm allow|deny - 审批权限请求',
541
+ ' /perm <auto|bypass|request|edit|plan|noask> - 切换权限模式',
542
+ ' /perm allow|always|deny - 审批权限请求',
420
543
  '',
421
544
  '🛠️ 运维:',
422
545
  ' /status - 显示会话状态',
423
546
  ' /stop - 中断当前任务',
424
547
  ' /restart - 重启服务',
425
- ' /repair - 检查并修复会话',
426
- ' /safe - 进入安全模式',
427
- ' /send [渠道] <路径> - 发送项目内文件',
548
+ ' /send [channel] <path> - 发送项目内文件',
428
549
  '',
429
550
  '❓ 帮助:',
430
551
  ' /help - 显示此帮助信息',
@@ -445,21 +566,61 @@ export class CommandHandler {
445
566
  if (!hasPermissionController(permAgent)) {
446
567
  return '❌ 权限控制不可用';
447
568
  }
448
- const currentMode = permSession.metadata?.permissionMode || 'default';
569
+ const defaultPermMode = identity.role === 'owner' ? 'bypass' : 'readonly';
570
+ const currentMode = permSession.metadata?.permissionMode ?? defaultPermMode;
449
571
  const modes = permAgent.listModes();
572
+ // 尝试发送交互卡片
573
+ if (this.interactionRouter) {
574
+ const requestId = `perm-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
575
+ const availableModes = modes.filter(m => m.available);
576
+ const interaction = {
577
+ type: 'interaction',
578
+ id: requestId,
579
+ channelId,
580
+ sessionId: permSession.id,
581
+ kind: {
582
+ kind: 'action',
583
+ title: '🔐 权限模式',
584
+ body: availableModes.map(m => `${m.key === currentMode ? '▶' : '•'} **${m.key}** (${m.nameZh}) - ${m.description}`).join('\n'),
585
+ buttons: availableModes.map(m => ({
586
+ key: m.key,
587
+ label: m.key === currentMode ? `✓ ${m.key}` : m.key,
588
+ style: m.key === currentMode ? 'primary' : 'default',
589
+ })),
590
+ },
591
+ };
592
+ const replyCtx = this.getReplyContext(permSession);
593
+ const cardSent = await this.sendInteractionCard({
594
+ channel, channelId, sessionId: permSession.id, requestId, interaction, replyCtx,
595
+ callback: async (action, _values, operatorId) => {
596
+ if (action !== currentMode) {
597
+ if (userId && operatorId && operatorId !== userId)
598
+ return;
599
+ const result = await this.handle(`/perm ${action}`, channel, channelId, undefined, userId, threadId);
600
+ if (result) {
601
+ const adapter = this.adapters.get(channel);
602
+ adapter?.sendText(channelId, result, replyCtx);
603
+ }
604
+ }
605
+ },
606
+ });
607
+ if (cardSent)
608
+ return null;
609
+ }
610
+ // 降级:文本
450
611
  const modeList = modes.map(m => {
451
612
  const prefix = m.key === currentMode ? '▶' : ' ';
452
613
  const suffix = m.available ? '' : ' ⚠️ 不可用';
453
614
  return ` ${prefix} ${m.key} (${m.nameZh}) - ${m.description}${suffix}`;
454
615
  }).join('\n');
455
- return `🔐 当前权限模式: ${currentMode}\n\n${modeList}\n\n用法:\n /perm <模式> 切换权限模式\n /perm allow|deny 审批权限请求`;
616
+ return `🔐 当前权限模式: ${currentMode}\n\n${modeList}\n\n用法:\n /perm <模式> 切换权限模式\n /perm allow|always|deny 审批权限请求`;
456
617
  }
457
618
  const parts = args.split(/\s+/);
458
- // /perm <mode> 或 /perm allow|deny:切换模式 / 快捷审批
619
+ // /perm <mode> 或 /perm allow|always|deny:切换模式 / 快捷审批
459
620
  if (parts.length === 1) {
460
621
  const arg = parts[0];
461
- // /perm allow|deny:快捷审批(自动找当前 session 唯一的 pending 请求)
462
- if (arg === 'allow' || arg === 'deny') {
622
+ // /perm allow|always|deny:快捷审批(自动找当前 session 唯一的 pending 请求)
623
+ if (arg === 'allow' || arg === 'always' || arg === 'deny') {
463
624
  if (!this.permissionGateway) {
464
625
  return '❌ 权限审批未启用';
465
626
  }
@@ -471,8 +632,14 @@ export class CommandHandler {
471
632
  return `❌ 当前有 ${pendingIds.length} 个待审批请求,请指定 requestId:\n${pendingIds.map(id => ` /perm ${id} ${arg}`).join('\n')}`;
472
633
  }
473
634
  const requestId = pendingIds[0];
474
- this.permissionGateway.resolvePermission(permSession.id, requestId, arg === 'allow');
475
- return arg === 'allow' ? `✓ 已授权,继续执行……` : `✓ 已拒绝`;
635
+ const decision = arg;
636
+ this.permissionGateway.resolvePermission(permSession.id, requestId, decision);
637
+ const labels = {
638
+ allow: '✓ 已授权(本次),继续执行……',
639
+ always: '✓ 已授权(始终允许该工具),继续执行……',
640
+ deny: '✓ 已拒绝'
641
+ };
642
+ return labels[decision];
476
643
  }
477
644
  // /perm <mode>:切换权限模式
478
645
  if (hasPermissionController(permAgent)) {
@@ -482,6 +649,10 @@ export class CommandHandler {
482
649
  if (!matched.available) {
483
650
  return `❌ ${matched.key} 模式当前不可用:${matched.unavailableReason}`;
484
651
  }
652
+ // guest 用户只能保持 readonly 模式
653
+ if (identity.role !== 'owner' && arg !== 'readonly') {
654
+ return '❌ 当前身份无法切换权限模式';
655
+ }
485
656
  const metadata = permSession.metadata || {};
486
657
  metadata.permissionMode = arg;
487
658
  await this.sessionManager.updateSession(permSession.id, { metadata });
@@ -489,12 +660,12 @@ export class CommandHandler {
489
660
  }
490
661
  }
491
662
  // 不是已知模式名也不是 allow/deny
492
- const modeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'default|request|edit|plan|noask';
493
- return `❌ 未知参数: ${arg}\n用法: /perm <${modeKeys}> 或 /perm allow|deny`;
663
+ const modeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'auto|bypass|request|edit|plan|noask';
664
+ return `❌ 未知参数: ${arg}\n用法: /perm <${modeKeys}> 或 /perm allow|always|deny`;
494
665
  }
495
666
  // 双参数不再支持,提示正确用法
496
- const allModeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'default|request|edit|plan|noask';
497
- return `❌ 未知参数: ${args}\n用法: /perm <${allModeKeys}> 或 /perm allow|deny`;
667
+ const allModeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : 'auto|bypass|request|edit|plan|noask';
668
+ return `❌ 未知参数: ${args}\n用法: /perm <${allModeKeys}> 或 /perm allow|always|deny`;
498
669
  }
499
670
  // /agent 命令:查看或切换 Agent 后端
500
671
  if (normalizedContent.startsWith('/agent')) {
@@ -504,7 +675,44 @@ export class CommandHandler {
504
675
  const available = [...this.agentMap.keys()];
505
676
  if (!args) {
506
677
  const currentAgent = activeSession?.agentId || this.defaultAgentId;
507
- const list = available.map(a => `${a === currentAgent ? '✓' : '-'} ${a}`).join('\n');
678
+ // 尝试发送交互卡片
679
+ if (this.interactionRouter && available.length > 1) {
680
+ const requestId = `agent-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
681
+ const interaction = {
682
+ type: 'interaction',
683
+ id: requestId,
684
+ channelId,
685
+ sessionId: activeSession?.id || requestId,
686
+ kind: {
687
+ kind: 'action',
688
+ title: '🔌 切换 Agent',
689
+ buttons: available.map(a => ({
690
+ key: a,
691
+ label: a === currentAgent ? `✓ ${a}` : a,
692
+ style: a === currentAgent ? 'primary' : 'default',
693
+ })),
694
+ },
695
+ };
696
+ const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
697
+ const cardSent = await this.sendInteractionCard({
698
+ channel, channelId, sessionId: activeSession?.id || requestId, requestId, interaction, replyCtx,
699
+ callback: async (action, _values, operatorId) => {
700
+ if (action !== currentAgent) {
701
+ if (userId && operatorId && operatorId !== userId)
702
+ return;
703
+ const result = await this.handle(`/agent ${action}`, channel, channelId, undefined, userId, threadId);
704
+ if (result) {
705
+ const adapter = this.adapters.get(channel);
706
+ adapter?.sendText(channelId, result, replyCtx);
707
+ }
708
+ }
709
+ },
710
+ });
711
+ if (cardSent)
712
+ return null;
713
+ }
714
+ // 降级:文本
715
+ const list = available.map(a => `${a === currentAgent ? ' ✓' : ' '} ${a}`).join('\n');
508
716
  return `当前 Agent: ${currentAgent}\n\n可用:\n${list}\n\n用法: /agent <name>`;
509
717
  }
510
718
  if (!this.agentMap.has(args)) {
@@ -514,15 +722,19 @@ export class CommandHandler {
514
722
  if ('error' in result)
515
723
  return result.error;
516
724
  const { session } = result;
517
- // 取消原会话的 pending 权限请求
725
+ // 取消原会话的 pending 权限请求和交互卡片
518
726
  if (this.permissionGateway) {
519
727
  this.permissionGateway.cancelAll(session.id);
520
728
  }
729
+ if (this.interactionRouter) {
730
+ this.interactionRouter.cancelAll(session.id);
731
+ }
521
732
  // 切换到目标 agent(恢复已有会话或创建新会话)
522
733
  const newSession = await this.sessionManager.switchAgent(channel, channelId, session.projectPath, args);
523
734
  const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
524
735
  const projectName = this.getProjectName(session.projectPath);
525
- return `✓ 已切换 Agent: ${args}\n 项目: ${projectName}\n 会话: ${newSession.name || '(未命名)'}\n ${hasExistingSession}`;
736
+ let agentSwitchResponse = `✓ 已切换 Agent: ${args}\n 项目: ${projectName}\n 会话: ${newSession.name || '(未命名)'}\n ${hasExistingSession}`;
737
+ return agentSwitchResponse;
526
738
  }
527
739
  // /model 命令:查看或切换模型/推理强度
528
740
  if (normalizedContent.startsWith('/model')) {
@@ -536,54 +748,72 @@ export class CommandHandler {
536
748
  const models = hasModelSwitcher(modelAgent) ? modelAgent.listModels() : [];
537
749
  if (!args) {
538
750
  const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
751
+ const efforts = getAvailableEfforts(modelAgent, currentModel);
539
752
  const currentEffort = modelAgent.getEffort?.() || 'auto';
540
- const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : `${currentEffort} ${effortBar(currentEffort)}`;
753
+ // 尝试发送交互卡片
754
+ if (this.interactionRouter && models.length > 0) {
755
+ const requestId = `model-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
756
+ const interaction = {
757
+ type: 'interaction',
758
+ id: requestId,
759
+ channelId,
760
+ sessionId: modelSession.id,
761
+ kind: {
762
+ kind: 'action',
763
+ title: '🤖 切换模型',
764
+ buttons: models.map((m) => ({
765
+ key: m,
766
+ label: m === currentModel ? `✓ ${m}` : m,
767
+ style: m === currentModel ? 'primary' : 'default',
768
+ })),
769
+ },
770
+ };
771
+ const replyCtx = this.getReplyContext(modelSession);
772
+ const cardSent = await this.sendInteractionCard({
773
+ channel, channelId, sessionId: modelSession.id, requestId, interaction, replyCtx,
774
+ callback: async (action, _values, operatorId) => {
775
+ if (action !== currentModel) {
776
+ if (userId && operatorId && operatorId !== userId)
777
+ return;
778
+ const result = await this.handle(`/model ${action}`, channel, channelId, undefined, userId, threadId);
779
+ if (result) {
780
+ const adapter = this.adapters.get(channel);
781
+ adapter?.sendText(channelId, result, replyCtx);
782
+ }
783
+ }
784
+ },
785
+ });
786
+ if (cardSent)
787
+ return null;
788
+ }
789
+ // 降级:文本
541
790
  const modelList = models.map((m) => `- ${m}`).join('\n');
542
- 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默认`;
791
+ const effortHint = efforts.length > 0
792
+ ? `\n推理强度: ${currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort} (使用 /effort 调整)`
793
+ : '';
794
+ return `当前模型: ${currentModel}${effortHint}\n\n可用模型:\n${modelList}\n\n${formatModelUsage(modelAgent, currentModel)}`;
543
795
  }
544
796
  const parts = args.split(/\s+/);
545
797
  let newModel;
546
798
  let newEffort;
547
799
  if (parts.length === 1) {
548
800
  const arg = parts[0];
549
- if (arg === 'auto') {
550
- modelAgent.setEffort?.(undefined);
551
- // 写回来源
552
- const isCodex = modelAgent.name === 'codex';
553
- if (isCodex) {
554
- if (this.config.agents?.openai?.reasoning) {
555
- delete this.config.agents.openai.reasoning;
556
- try {
557
- saveConfig(this.config);
558
- }
559
- catch { }
560
- }
561
- }
562
- else {
563
- const configuredInEvolclaw = !!this.config.agents?.anthropic?.effort;
564
- if (configuredInEvolclaw) {
565
- delete this.config.agents.anthropic.effort;
566
- try {
567
- saveConfig(this.config);
568
- }
569
- catch { }
570
- }
571
- else {
572
- writeUserSettings({ effortLevel: null });
573
- }
574
- }
575
- return '✓ 推理强度已恢复为 auto (SDK默认)';
801
+ const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
802
+ const efforts = getAvailableEfforts(modelAgent, currentModel);
803
+ // effort 相关参数统一转发到 /effort
804
+ if (efforts.includes(arg) || arg === 'auto') {
805
+ return this.handle(`/effort ${arg}`, channel, channelId, undefined, userId, threadId);
576
806
  }
577
- // 单参数:模型 effort
578
- if (availableEfforts.includes(arg)) {
579
- newEffort = arg;
807
+ else if (allEfforts.includes(arg)) {
808
+ return `⚠️ 请使用 /effort ${arg} 调整推理强度`;
580
809
  }
581
810
  else if (models.includes(arg)) {
582
811
  newModel = arg;
583
812
  }
584
813
  else {
585
814
  const modelList = models.map((m) => `- ${m}`).join('\n');
586
- return `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}\n\n推理强度: ${availableEfforts.join(' / ')}`;
815
+ const effortHint = efforts.length > 0 ? `\n\n推理强度请使用 /effort 命令` : '';
816
+ return `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}${effortHint}`;
587
817
  }
588
818
  }
589
819
  else {
@@ -592,8 +822,13 @@ export class CommandHandler {
592
822
  if (!models.includes(modelArg)) {
593
823
  return `❌ 无效的模型ID: ${modelArg}`;
594
824
  }
595
- if (!availableEfforts.includes(effortArg)) {
596
- return `❌ 无效的推理强度: ${effortArg}\n可选: ${availableEfforts.join(' / ')}`;
825
+ const targetEfforts = getAvailableEfforts(modelAgent, modelArg);
826
+ if (targetEfforts.length === 0) {
827
+ return `⚠️ ${modelArg} 不支持推理强度设置`;
828
+ }
829
+ if (!targetEfforts.includes(effortArg)) {
830
+ const errorLabel = allEfforts.includes(effortArg) ? '⚠️' : '❌';
831
+ return `${errorLabel} ${modelArg} 不支持 ${effortArg} 推理强度\n可选: ${targetEfforts.join(' / ')}`;
597
832
  }
598
833
  newModel = modelArg;
599
834
  newEffort = effortArg;
@@ -613,12 +848,8 @@ export class CommandHandler {
613
848
  changes.push(`模型: ${newModel}`);
614
849
  }
615
850
  if (newEffort) {
616
- const modelAfterSwitch = newModel ?? (hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name);
617
- if (newEffort === 'max' && !modelAfterSwitch.includes('opus')) {
618
- return '⚠️ max 推理强度仅 Opus 模型支持(opus / claude-opus-4-6)';
619
- }
620
851
  modelAgent.setEffort?.(newEffort);
621
- changes.push(`推理强度: ${newEffort} ${effortBar(newEffort)}`);
852
+ changes.push(`推理强度: ${newEffort}`);
622
853
  }
623
854
  // 持久化:写回来源(就近原则)
624
855
  // evolclaw.json 配了 → 写 evolclaw.json
@@ -685,6 +916,137 @@ export class CommandHandler {
685
916
  }
686
917
  return `✓ 已切换\n ${changes.join('\n ')}`;
687
918
  }
919
+ // /effort 命令:查看或切换推理强度
920
+ if (normalizedContent.startsWith('/effort')) {
921
+ const args = normalizedContent.slice(7).trim();
922
+ const effortResult = await this.ensureSession(channel, channelId, threadId);
923
+ if ('error' in effortResult)
924
+ return effortResult.error;
925
+ const { session: effortSession } = effortResult;
926
+ const effortAgent = this.getAgent(effortSession.agentId);
927
+ const currentModel = hasModelSwitcher(effortAgent) ? effortAgent.getModel() : effortAgent.name;
928
+ const efforts = getAvailableEfforts(effortAgent, currentModel);
929
+ const currentEffort = effortAgent.getEffort?.() || 'auto';
930
+ if (efforts.length === 0) {
931
+ return '⚠️ 当前模型不支持推理强度设置';
932
+ }
933
+ if (!args) {
934
+ // /effort(无参数):显示当前推理强度 + 发送 Action 卡片
935
+ if (this.interactionRouter) {
936
+ const requestId = `effort-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
937
+ const buttons = [
938
+ ...efforts.map(e => ({
939
+ key: e,
940
+ label: e === currentEffort ? `✓ ${e}` : e,
941
+ style: e === currentEffort ? 'primary' : 'default',
942
+ })),
943
+ {
944
+ key: 'auto',
945
+ label: currentEffort === 'auto' ? '✓ auto' : 'auto',
946
+ style: currentEffort === 'auto' ? 'primary' : 'default',
947
+ },
948
+ ];
949
+ const interaction = {
950
+ type: 'interaction',
951
+ id: requestId,
952
+ channelId,
953
+ sessionId: effortSession.id,
954
+ kind: {
955
+ kind: 'action',
956
+ title: '⚡ 推理强度',
957
+ buttons,
958
+ },
959
+ };
960
+ const replyCtx = this.getReplyContext(effortSession);
961
+ const cardSent = await this.sendInteractionCard({
962
+ channel, channelId, sessionId: effortSession.id, requestId, interaction, replyCtx,
963
+ callback: async (action, _values, operatorId) => {
964
+ if (action !== currentEffort) {
965
+ if (userId && operatorId && operatorId !== userId)
966
+ return;
967
+ const result = await this.handle(`/effort ${action}`, channel, channelId, undefined, userId, threadId);
968
+ if (result) {
969
+ const adapter = this.adapters.get(channel);
970
+ adapter?.sendText(channelId, result, replyCtx);
971
+ }
972
+ }
973
+ },
974
+ });
975
+ if (cardSent)
976
+ return null;
977
+ }
978
+ // 降级:文本
979
+ const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort;
980
+ const effortList = efforts.map(e => `${e === currentEffort ? ' ✓' : ' '} ${e}`).join('\n');
981
+ return `⚡ 推理强度: ${effortDisplay}\n\n可选:\n${effortList}\n ${currentEffort === 'auto' ? ' ✓' : ' '} auto\n\n用法: /effort <level>`;
982
+ }
983
+ // /effort auto:恢复 SDK 默认
984
+ if (args === 'auto') {
985
+ effortAgent.setEffort?.(undefined);
986
+ const isCodex = effortAgent.name === 'codex';
987
+ if (isCodex) {
988
+ if (this.config.agents?.openai?.reasoning) {
989
+ delete this.config.agents.openai.reasoning;
990
+ try {
991
+ saveConfig(this.config);
992
+ }
993
+ catch { }
994
+ }
995
+ }
996
+ else {
997
+ const configuredInEvolclaw = !!this.config.agents?.anthropic?.effort;
998
+ if (configuredInEvolclaw) {
999
+ delete this.config.agents.anthropic.effort;
1000
+ try {
1001
+ saveConfig(this.config);
1002
+ }
1003
+ catch { }
1004
+ }
1005
+ else {
1006
+ writeUserSettings({ effortLevel: null });
1007
+ }
1008
+ }
1009
+ return '✓ 推理强度已恢复为 auto (SDK默认)';
1010
+ }
1011
+ // /effort <level>:切换推理强度
1012
+ if (!efforts.includes(args)) {
1013
+ if (allEfforts.includes(args)) {
1014
+ return `⚠️ ${currentModel} 不支持 ${args} 推理强度\n可选: ${efforts.join(' / ')}`;
1015
+ }
1016
+ return `❌ 无效参数: ${args}\n可选: ${efforts.join(' / ')} / auto`;
1017
+ }
1018
+ const newEffort = args;
1019
+ effortAgent.setEffort?.(newEffort);
1020
+ // 持久化
1021
+ if (!this.config.agents)
1022
+ this.config.agents = {};
1023
+ const isCodex = effortAgent.name === 'codex';
1024
+ if (isCodex) {
1025
+ if (!this.config.agents.openai)
1026
+ this.config.agents.openai = {};
1027
+ this.config.agents.openai.reasoning = newEffort;
1028
+ try {
1029
+ saveConfig(this.config);
1030
+ }
1031
+ catch { }
1032
+ }
1033
+ else {
1034
+ const configuredInEvolclaw = !!(this.config.agents?.anthropic?.model || this.config.agents?.anthropic?.effort);
1035
+ if (configuredInEvolclaw) {
1036
+ if (!this.config.agents.anthropic)
1037
+ this.config.agents.anthropic = {};
1038
+ this.config.agents.anthropic.effort = newEffort;
1039
+ try {
1040
+ saveConfig(this.config);
1041
+ }
1042
+ catch { }
1043
+ }
1044
+ else {
1045
+ writeUserSettings({ effortLevel: newEffort });
1046
+ }
1047
+ }
1048
+ return `✓ 推理强度: ${newEffort}`;
1049
+ }
688
1050
  // /stop 命令:中断当前任务
689
1051
  if (normalizedContent === '/stop') {
690
1052
  const stopResult = await this.ensureSession(channel, channelId, threadId);
@@ -827,7 +1189,7 @@ export class CommandHandler {
827
1189
  }
828
1190
  const lines = [];
829
1191
  if (isAdmin) {
830
- lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`);
1192
+ lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${this.resolveChannelType(channel)} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`);
831
1193
  if (health.consecutiveErrors > 0) {
832
1194
  lines.push(`异常计数: ${health.consecutiveErrors}`);
833
1195
  }
@@ -844,8 +1206,8 @@ export class CommandHandler {
844
1206
  lines.push('⚠️ 当前处于安全模式(历史上下文已禁用)');
845
1207
  lines.push('');
846
1208
  lines.push('退出方式:');
847
- lines.push('1. /repair - 检查并修复会话(推荐,保留历史)');
848
- lines.push('2. /new [名称] - 创建新会话(清空历史)');
1209
+ lines.push('1. /new [名称] - 创建新会话(清空历史)');
1210
+ lines.push('2. 联系管理员使用 /repair 检查并修复会话');
849
1211
  }
850
1212
  if (health.lastError) {
851
1213
  lines.push('');
@@ -875,9 +1237,12 @@ export class CommandHandler {
875
1237
  timestamp: Date.now()
876
1238
  });
877
1239
  if (session) {
1240
+ // Reset agent backend state so the new
1241
+ // session starts with a fresh conversation history
1242
+ await agent.clearSession(session.id, session.agentSessionId || '', session.projectPath);
878
1243
  await agent.closeSession(session.id);
879
1244
  }
880
- return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /slist 查看`;
1245
+ return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /s 查看`;
881
1246
  }
882
1247
  // /check 命令:检查渠道状态 / 手动重连指定渠道
883
1248
  if (normalizedContent === '/check' || normalizedContent.startsWith('/check ')) {
@@ -903,15 +1268,30 @@ export class CommandHandler {
903
1268
  }
904
1269
  // Default: show full system health check
905
1270
  const lines = ['📡 渠道状态:'];
1271
+ // Group by channelType
1272
+ const groups = new Map();
906
1273
  for (const [name] of this.adapters) {
1274
+ const type = this.channelTypeMap.get(name) || name;
907
1275
  const ch = this.channelObjects.get(name);
1276
+ let status;
908
1277
  if (ch?.getStatus) {
909
1278
  const s = ch.getStatus();
910
- const status = s.connected ? '✓ 已连接' : s.reconnectAttempt > 0 ? `⏳ 重连中 (${s.reconnectAttempt}/${s.maxAttempts})` : '✗ 断开';
911
- lines.push(` ${name}: ${status}`);
1279
+ status = s.connected ? '✓ 已连接' : s.reconnectAttempt > 0 ? `⏳ 重连中 (${s.reconnectAttempt}/${s.maxAttempts})` : '✗ 断开';
1280
+ }
1281
+ else {
1282
+ status = '✓ 已注册';
1283
+ }
1284
+ if (!groups.has(type))
1285
+ groups.set(type, []);
1286
+ groups.get(type).push({ name, status });
1287
+ }
1288
+ for (const [type, instances] of groups) {
1289
+ if (instances.length === 1) {
1290
+ lines.push(` ${instances[0].name}: ${instances[0].status}`);
912
1291
  }
913
1292
  else {
914
- lines.push(` ${name}: ✓ 已注册`);
1293
+ const parts = instances.map(i => `${i.name} ${i.status}`);
1294
+ lines.push(` ${type}: [${parts.join(', ')}]`);
915
1295
  }
916
1296
  }
917
1297
  // 队列状态
@@ -924,7 +1304,6 @@ export class CommandHandler {
924
1304
  ? this.statsCollector.getSnapshot().uptimeMs
925
1305
  : process.uptime() * 1000;
926
1306
  lines.push(` 运行时间: ${this.formatUptime(uptimeMs)}`);
927
- lines.push(` 当前安全模式会话: ${this.sessionManager.getSafeModeSessionCount()}`);
928
1307
  // 近 1 小时统计
929
1308
  if (this.statsCollector) {
930
1309
  const snap = this.statsCollector.getSnapshot();
@@ -939,8 +1318,11 @@ export class CommandHandler {
939
1318
  else {
940
1319
  lines.push(` 处理出错: 0`);
941
1320
  }
1321
+ if (h.toolErrors > 0) {
1322
+ const toolBreakdown = Object.entries(h.toolErrorsByName).map(([t, c]) => `${t}: ${c}`).join(', ');
1323
+ lines.push(` 工具失败: ${h.toolErrors} (${toolBreakdown})`);
1324
+ }
942
1325
  lines.push(` 被中断: ${h.interrupts}`);
943
- lines.push(` 进入安全模式: ${h.safeModeEntries}`);
944
1326
  if (h.completed > 0) {
945
1327
  lines.push(` 平均响应耗时: ${(h.avgResponseMs / 1000).toFixed(1)}s`);
946
1328
  }
@@ -957,6 +1339,33 @@ export class CommandHandler {
957
1339
  const count = this.messageCache.getCount(s.id);
958
1340
  return `${s.projectPath} 有 ${count} 条新消息`;
959
1341
  });
1342
+ // 执行重启逻辑(共用于卡片回调和文本确认)
1343
+ const executeRestart = async () => {
1344
+ let replyContext;
1345
+ if (threadId) {
1346
+ const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
1347
+ replyContext = this.getReplyContext(threadSession);
1348
+ }
1349
+ const restartInfo = {
1350
+ channel,
1351
+ channelId,
1352
+ timestamp: Date.now(),
1353
+ ...(replyContext?.replyToMessageId ? { rootId: replyContext.replyToMessageId } : {}),
1354
+ };
1355
+ fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
1356
+ const { spawn } = await import('child_process');
1357
+ spawn('node', [path.join(getPackageRoot(), 'dist', 'cli.js'), 'restart-monitor'], {
1358
+ detached: true,
1359
+ stdio: 'ignore',
1360
+ env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
1361
+ }).unref();
1362
+ this.eventBus.publish({ type: 'system:restart', channel, channelId });
1363
+ setTimeout(() => {
1364
+ logger.info('[System] Restarting by user command...');
1365
+ process.exit(0);
1366
+ }, 1000);
1367
+ };
1368
+ // 文本确认流程
960
1369
  if (sessionsWithMessages.length > 0) {
961
1370
  const restartKey = `${channel}-${channelId}`;
962
1371
  const restartConfirmFile = path.join(resolvePaths().dataDir, `restart-confirm-${restartKey}.json`);
@@ -976,30 +1385,7 @@ export class CommandHandler {
976
1385
  return sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。';
977
1386
  }
978
1387
  }
979
- // 话题中 restart 时保存 replyContext 用于重启后回复到话题
980
- let replyContext;
981
- if (threadId) {
982
- const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
983
- replyContext = this.getReplyContext(threadSession);
984
- }
985
- const restartInfo = {
986
- channel,
987
- channelId,
988
- timestamp: Date.now(),
989
- ...(replyContext?.replyToMessageId ? { rootId: replyContext.replyToMessageId } : {}),
990
- };
991
- fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
992
- const { spawn } = await import('child_process');
993
- spawn('node', [path.join(getPackageRoot(), 'dist', 'cli.js'), 'restart-monitor'], {
994
- detached: true,
995
- stdio: 'ignore',
996
- env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
997
- }).unref();
998
- this.eventBus.publish({ type: 'system:restart', channel, channelId });
999
- setTimeout(() => {
1000
- logger.info('[System] Restarting by user command...');
1001
- process.exit(0);
1002
- }, 1000);
1388
+ await executeRestart();
1003
1389
  return '🔄 服务正在重启,请稍候...(约 5 秒后恢复)';
1004
1390
  }
1005
1391
  // /pwd 命令:显示当前项目路径
@@ -1022,26 +1408,43 @@ export class CommandHandler {
1022
1408
  if (!rawArg) {
1023
1409
  return '用法: /send <相对路径> 或 /send <渠道> <相对路径>\n示例: /send src/index.ts\n示例: /send feishu report.md';
1024
1410
  }
1025
- // 解析目标通道:第一个 token 若匹配已注册通道名则为目标通道
1411
+ // 解析目标通道:第一个 token 按实例名匹配,再按 channelType 匹配
1026
1412
  const tokens = rawArg.split(/\s+/);
1027
- const knownChannels = [...this.adapters.keys()];
1028
1413
  let targetChannel = channel;
1414
+ let targetLabel = channel;
1029
1415
  let filePath = rawArg;
1030
- if (tokens.length >= 2 && knownChannels.includes(tokens[0])) {
1031
- targetChannel = tokens[0];
1032
- filePath = tokens.slice(1).join(' ');
1416
+ if (tokens.length >= 2) {
1417
+ const spec = tokens[0];
1418
+ if (this.adapters.has(spec)) {
1419
+ // 精确实例名
1420
+ targetChannel = spec;
1421
+ targetLabel = spec;
1422
+ filePath = tokens.slice(1).join(' ');
1423
+ }
1424
+ else {
1425
+ // 按 channelType 查找第一个匹配的实例
1426
+ for (const [name] of this.adapters) {
1427
+ if ((this.channelTypeMap.get(name) || name) === spec) {
1428
+ targetChannel = name;
1429
+ targetLabel = spec;
1430
+ filePath = tokens.slice(1).join(' ');
1431
+ break;
1432
+ }
1433
+ }
1434
+ }
1033
1435
  }
1436
+ const isCrossChannel = targetChannel !== channel;
1034
1437
  // 跨通道仅限 owner
1035
- if (targetChannel !== channel && identity.role !== 'owner') {
1438
+ if (isCrossChannel && identity.role !== 'owner') {
1036
1439
  return '❌ 跨通道发送仅限管理员';
1037
1440
  }
1038
1441
  // 找目标 adapter
1039
1442
  const targetAdapter = this.adapters.get(targetChannel);
1040
1443
  if (!targetAdapter) {
1041
- return `❌ 通道 ${targetChannel} 未启用或不存在`;
1444
+ return `❌ 通道 ${targetLabel} 未启用或不存在`;
1042
1445
  }
1043
1446
  if (!targetAdapter.sendFile) {
1044
- return `❌ 通道 ${targetChannel} 不支持文件发送`;
1447
+ return `❌ 通道 ${targetLabel} 不支持文件发送`;
1045
1448
  }
1046
1449
  // 获取 session(需要 projectPath)
1047
1450
  const sendResult = await this.ensureSession(channel, channelId, threadId);
@@ -1076,22 +1479,22 @@ export class CommandHandler {
1076
1479
  }
1077
1480
  // 找目标 channelId
1078
1481
  let targetChannelId = channelId;
1079
- if (targetChannel !== channel) {
1482
+ if (isCrossChannel) {
1080
1483
  const ownerPeerId = getOwner(this.config, targetChannel);
1081
1484
  targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannel, ownerPeerId) ?? '') : '';
1082
1485
  if (!targetChannelId) {
1083
- return `❌ 未找到 ${targetChannel} 的私聊会话,请先在该通道发送一条消息`;
1486
+ return `❌ 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`;
1084
1487
  }
1085
1488
  }
1086
1489
  // 发送文件
1087
1490
  try {
1088
- const replyCtx = targetChannel === channel ? this.getReplyContext(sendSession) : undefined;
1491
+ const replyCtx = isCrossChannel ? undefined : this.getReplyContext(sendSession);
1089
1492
  await targetAdapter.sendFile(targetChannelId, realPath, replyCtx);
1090
1493
  const sizeStr = stat.size < 1024 ? `${stat.size} B`
1091
1494
  : stat.size < 1024 * 1024 ? `${(stat.size / 1024).toFixed(1)} KB`
1092
1495
  : `${(stat.size / 1024 / 1024).toFixed(1)} MB`;
1093
- return targetChannel !== channel
1094
- ? `📎 文件已通过 ${targetChannel} 发送: ${filePath} (${sizeStr})`
1496
+ return isCrossChannel
1497
+ ? `📎 文件已通过 ${targetLabel} 发送: ${filePath} (${sizeStr})`
1095
1498
  : `✅ 已发送: ${filePath} (${sizeStr})`;
1096
1499
  }
1097
1500
  catch (error) {
@@ -1115,73 +1518,110 @@ export class CommandHandler {
1115
1518
 
1116
1519
  提示:群聊不支持切换项目`;
1117
1520
  }
1118
- const lines = ['可用项目:'];
1119
- // 收集项目信息并按最近活跃排序
1521
+ // 收集项目信息并按最近活跃排序(唯一来源:evolclaw.json projects.list)
1120
1522
  const entries = [];
1121
- const configuredPaths = new Set();
1122
1523
  for (const [name, projectPath] of Object.entries(this.projects)) {
1123
- configuredPaths.add(projectPath);
1124
- const isCurrent = session?.projectPath === projectPath;
1524
+ // 跳过不存在的路径
1525
+ if (!fs.existsSync(projectPath))
1526
+ continue;
1527
+ const isCurrent = session ? path.resolve(session.projectPath) === path.resolve(projectPath) : false;
1125
1528
  const projectSession = await this.sessionManager.getSessionByProjectPath(channel, channelId, projectPath);
1126
1529
  entries.push({
1127
1530
  name, projectPath, projectSession, isCurrent,
1128
1531
  updatedAt: projectSession?.updatedAt ?? 0,
1129
1532
  });
1130
1533
  }
1131
- // Include bound projects not in config (created via /bind)
1132
- const allSessions = await this.sessionManager.listSessions(channel, channelId);
1133
- for (const s of allSessions) {
1134
- if (!configuredPaths.has(s.projectPath)) {
1135
- configuredPaths.add(s.projectPath);
1136
- const isCurrent = session?.projectPath === s.projectPath;
1137
- entries.push({
1138
- name: path.basename(s.projectPath), projectPath: s.projectPath, projectSession: s, isCurrent,
1139
- updatedAt: s.updatedAt ?? 0,
1140
- });
1141
- }
1142
- }
1143
1534
  // 当前活跃项目置顶,其余按 updatedAt 降序
1144
1535
  entries.sort((a, b) => {
1145
1536
  if (a.isCurrent !== b.isCurrent)
1146
1537
  return a.isCurrent ? -1 : 1;
1147
1538
  return b.updatedAt - a.updatedAt;
1148
1539
  });
1149
- for (const { name, projectPath, projectSession, isCurrent } of entries) {
1150
- const prefix = isCurrent ? ' ✓' : ' ';
1151
- if (!projectSession) {
1152
- lines.push(`${prefix} ${name} (${projectPath}) - 无会话`);
1153
- continue;
1154
- }
1155
- const statusParts = [];
1540
+ // 构建项目状态文本的辅助函数
1541
+ const buildStatusText = (entry) => {
1542
+ const { projectSession, isCurrent } = entry;
1543
+ if (!projectSession)
1544
+ return '无会话';
1545
+ const parts = [];
1156
1546
  if (isCurrent) {
1157
- statusParts.push('活跃');
1547
+ parts.push('活跃');
1158
1548
  }
1159
1549
  else {
1160
- const idleMs = Date.now() - projectSession.updatedAt;
1161
- statusParts.push(formatIdleTime(idleMs));
1550
+ parts.push(formatIdleTime(Date.now() - projectSession.updatedAt));
1162
1551
  }
1163
- // 用 DB processingState 判断处理状态
1164
1552
  const isProcessing = !!projectSession.processingState;
1165
1553
  if (isProcessing) {
1166
- const queueLength = this.messageQueue.getQueueLength(projectSession.id);
1167
- if (queueLength > 0) {
1168
- statusParts.push(`[处理中,队列${queueLength}条]`);
1169
- }
1170
- else {
1171
- statusParts.push('[处理中]');
1172
- }
1554
+ const qLen = this.messageQueue.getQueueLength(projectSession.id);
1555
+ parts.push(qLen > 0 ? `[处理中,队列${qLen}条]` : '[处理中]');
1173
1556
  }
1174
- const unreadCount = this.messageCache.getCount(projectSession.id);
1175
- if (unreadCount > 0) {
1176
- statusParts.push(`[${unreadCount}条新消息]`);
1557
+ const unread = this.messageCache.getCount(projectSession.id);
1558
+ if (unread > 0) {
1559
+ parts.push(`[${unread}条新消息]`);
1177
1560
  }
1178
1561
  else if (!isProcessing && !isCurrent) {
1179
- statusParts.push('[空闲]');
1562
+ parts.push('[空闲]');
1180
1563
  }
1181
- lines.push(`${prefix} ${name} (${projectPath}) - ${statusParts.join(' ')}`);
1564
+ return parts.join(' ');
1565
+ };
1566
+ // 尝试发送 ActionInteraction 卡片(每个项目一个按钮,一键切换)
1567
+ if (this.interactionRouter && entries.length > 0) {
1568
+ const requestId = `plist-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1569
+ const buttons = entries.map(e => ({
1570
+ key: e.name,
1571
+ label: e.isCurrent ? `✓ ${e.name}` : e.name,
1572
+ style: e.isCurrent ? 'primary' : 'default',
1573
+ }));
1574
+ const bodyLines = entries.map(e => {
1575
+ const status = buildStatusText(e);
1576
+ const prefix = e.isCurrent ? '▶' : '•';
1577
+ return `${prefix} **${e.name}** (${e.projectPath}) ${status}`;
1578
+ });
1579
+ const interaction = {
1580
+ type: 'interaction',
1581
+ id: requestId,
1582
+ channelId,
1583
+ sessionId: activeSession?.id || requestId,
1584
+ kind: {
1585
+ kind: 'action',
1586
+ title: '📂 项目列表',
1587
+ body: bodyLines.join('\n'),
1588
+ buttons,
1589
+ },
1590
+ };
1591
+ const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
1592
+ const cardSent = await this.sendInteractionCard({
1593
+ channel, channelId, sessionId: activeSession?.id || requestId, requestId, interaction, replyCtx,
1594
+ callback: async (action, _values, operatorId) => {
1595
+ if (userId && operatorId && operatorId !== userId)
1596
+ return;
1597
+ const selectedEntry = entries.find(e => e.name === action);
1598
+ if (selectedEntry && !selectedEntry.isCurrent) {
1599
+ const result = await this.handle(`/project ${action}`, channel, channelId, undefined, userId, threadId);
1600
+ if (result) {
1601
+ const adapter = this.adapters.get(channel);
1602
+ adapter?.sendText(channelId, result, replyCtx);
1603
+ }
1604
+ }
1605
+ },
1606
+ });
1607
+ if (cardSent)
1608
+ return null;
1609
+ }
1610
+ // 降级:文本列表
1611
+ const lines = ['可用项目:'];
1612
+ for (const entry of entries) {
1613
+ const prefix = entry.isCurrent ? ' ✓' : ' ';
1614
+ lines.push(`${prefix} ${entry.name} (${entry.projectPath}) - ${buildStatusText(entry)}`);
1182
1615
  }
1183
1616
  return lines.join('\n');
1184
1617
  }
1618
+ // /project(无参数):直接复用 /plist 逻辑(含卡片交互)
1619
+ if (normalizedContent === '/project') {
1620
+ if (!policy.canSwitchProject(session?.chatType || 'private', identity.role)) {
1621
+ // 群聊不能切换项目,交由 /plist 逻辑处理
1622
+ }
1623
+ return this.handle('/plist', channel, channelId, undefined, userId, threadId);
1624
+ }
1185
1625
  // /project 命令:切换项目(支持名称或路径)
1186
1626
  if (normalizedContent.startsWith('/project ')) {
1187
1627
  if (!policy.canSwitchProject(session?.chatType || 'private', identity.role)) {
@@ -1212,7 +1652,7 @@ export class CommandHandler {
1212
1652
  else {
1213
1653
  projectPath = this.projects[arg];
1214
1654
  if (!projectPath) {
1215
- return `❌ 项目 "${arg}" 不存在\n提示: 使用 /plist 查看可用项目`;
1655
+ return `❌ 项目 "${arg}" 不存在\n提示: 使用 /p 查看可用项目`;
1216
1656
  }
1217
1657
  projectName = arg;
1218
1658
  }
@@ -1307,16 +1747,87 @@ export class CommandHandler {
1307
1747
  this.projects[projectName] = projectPath;
1308
1748
  return `✓ 已添加项目: ${projectName}\n 路径: ${projectPath}\n\n使用 /p ${projectName} 切换到该项目`;
1309
1749
  }
1310
- // /slist 命令:列出当前项目的所有会话
1311
- if (normalizedContent === '/slist') {
1750
+ // /slist 命令:列出当前项目的会话
1751
+ // /slist 仅 EvolClaw 会话
1752
+ // /slist cli — 仅 CLI 会话(未导入的)
1753
+ if (normalizedContent === '/slist' || normalizedContent === '/slist cli') {
1312
1754
  if (!session) {
1313
1755
  return `❌ 当前没有活跃会话
1314
1756
 
1315
1757
  请先执行以下操作之一:
1316
1758
  1. 发送任意消息 - 自动创建新会话
1317
1759
  2. /new [名称] - 创建命名会话
1318
- 3. /project <项目> - 切换到指定项目`;
1760
+ 3. /p <项目> - 切换到指定项目`;
1761
+ }
1762
+ const showCliOnly = normalizedContent === '/slist cli';
1763
+ // /slist cli — 仅显示 CLI 会话
1764
+ if (showCliOnly) {
1765
+ const canImportCli = policy.canImportCliSession(session.chatType || 'private', identity.role);
1766
+ if (!canImportCli) {
1767
+ return '❌ 当前无权查看 CLI 会话';
1768
+ }
1769
+ const cliSessions = await this.sessionManager.scanCliSessions(session.projectPath, session.agentId);
1770
+ const sessions = await this.sessionManager.listSessions(channel, channelId);
1771
+ const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
1772
+ const dbSessionIds = new Set(currentProjectSessions.map(s => s.agentSessionId).filter(Boolean));
1773
+ const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid));
1774
+ if (orphanCliSessions.length === 0) {
1775
+ return `当前项目 ${path.basename(session.projectPath)} 没有未导入的 CLI 会话`;
1776
+ }
1777
+ // 构建显示数据(复用于卡片和文本)
1778
+ const cliDisplayItems = orphanCliSessions.map(c => {
1779
+ const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
1780
+ const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid, session.agentId) || '(无消息)';
1781
+ const uuid = c.uuid.substring(0, 8);
1782
+ return { uuid, fullUuid: c.uuid, time, message };
1783
+ });
1784
+ // 尝试发送 ActionInteraction 卡片
1785
+ if (this.interactionRouter && cliDisplayItems.length > 0) {
1786
+ const requestId = `slist-cli-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1787
+ const buttons = cliDisplayItems.map(item => ({
1788
+ key: item.uuid,
1789
+ label: item.uuid,
1790
+ style: 'default',
1791
+ }));
1792
+ const bodyLines = cliDisplayItems.map(item => `• ${item.time} (${item.uuid}) "${item.message}"`);
1793
+ const interaction = {
1794
+ type: 'interaction',
1795
+ id: requestId,
1796
+ channelId,
1797
+ sessionId: session.id,
1798
+ kind: {
1799
+ kind: 'action',
1800
+ title: `📋 ${path.basename(session.projectPath)} CLI 会话 (${cliDisplayItems.length})`,
1801
+ body: bodyLines.join('\n'),
1802
+ buttons,
1803
+ },
1804
+ };
1805
+ const replyCtx = this.getReplyContext(session);
1806
+ const cardSent = await this.sendInteractionCard({
1807
+ channel, channelId, sessionId: session.id, requestId, interaction, replyCtx,
1808
+ callback: async (action, _values, operatorId) => {
1809
+ if (userId && operatorId && operatorId !== userId)
1810
+ return;
1811
+ const result = await this.handle(`/session ${action}`, channel, channelId, undefined, userId, threadId);
1812
+ if (result) {
1813
+ const adapter = this.adapters.get(channel);
1814
+ adapter?.sendText(channelId, result, replyCtx);
1815
+ }
1816
+ },
1817
+ });
1818
+ if (cardSent)
1819
+ return null;
1820
+ }
1821
+ // 降级:文本列表
1822
+ const lines = [`当前项目 ${path.basename(session.projectPath)} 的 CLI 会话 (共 ${orphanCliSessions.length} 个):`, ''];
1823
+ for (const item of cliDisplayItems) {
1824
+ lines.push(` ${item.time} (${item.uuid}) "${item.message}"`);
1825
+ }
1826
+ lines.push('');
1827
+ lines.push('使用 /s <8位uuid> 导入并切换到 CLI 会话');
1828
+ return lines.join('\n');
1319
1829
  }
1830
+ // /slist — 仅显示 EvolClaw 会话
1320
1831
  const sessions = await this.sessionManager.listSessions(channel, channelId);
1321
1832
  const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
1322
1833
  // 从 SDK 同步会话名称(发现 CLI 改名)
@@ -1335,46 +1846,97 @@ export class CommandHandler {
1335
1846
  catch (error) {
1336
1847
  logger.debug('[CommandHandler] SDK listSessions sync failed (non-critical):', error);
1337
1848
  }
1338
- const canImportCli = policy.canImportCliSession(session.chatType || 'private', identity.role);
1339
- const cliSessions = canImportCli
1340
- ? await this.sessionManager.scanCliSessions(session.projectPath, session.agentId)
1341
- : [];
1342
- const dbSessionIds = new Set(currentProjectSessions.map(s => s.agentSessionId).filter(Boolean));
1849
+ // 构建可显示会话列表(复用于卡片和文本)
1850
+ const hideTopics = currentProjectSessions.length > 10;
1851
+ const topicCount = hideTopics ? currentProjectSessions.filter(s => s.threadId).length : 0;
1852
+ const maxDisplay = 10;
1853
+ const displaySessions = [];
1854
+ let displayIndex = 0;
1855
+ for (let i = 0; i < currentProjectSessions.length; i++) {
1856
+ const s = currentProjectSessions[i];
1857
+ if (hideTopics && s.threadId)
1858
+ continue;
1859
+ if (displayIndex >= maxDisplay)
1860
+ break;
1861
+ const isActive = s.metadata?.isActive === true;
1862
+ displayIndex++;
1863
+ const name = s.name || '(未命名)';
1864
+ const idleTime = formatIdleTime(Date.now() - s.updatedAt);
1865
+ const fileMissing = !!(s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId, s.agentId));
1866
+ let status = '[空闲]';
1867
+ if (fileMissing) {
1868
+ status = '[会话文件缺失]';
1869
+ }
1870
+ else if (!!s.processingState) {
1871
+ status = '[处理中]';
1872
+ }
1873
+ else if (isActive) {
1874
+ status = '[活跃]';
1875
+ }
1876
+ displaySessions.push({ session: s, index: displayIndex, isActive, name, status, idleTime, fileMissing });
1877
+ }
1878
+ // 尝试发送 ActionInteraction 卡片(每个会话一个按钮,一键切换)
1879
+ if (this.interactionRouter && displaySessions.length >= 1) {
1880
+ const requestId = `slist-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1881
+ const buttons = displaySessions.map(ds => {
1882
+ const shortId = ds.session.agentSessionId ? ds.session.agentSessionId.substring(0, 8) : ds.name;
1883
+ return {
1884
+ key: String(ds.index),
1885
+ label: ds.isActive ? `✓ ${ds.index}. ${shortId}` : `${ds.index}. ${shortId}`,
1886
+ style: ds.isActive ? 'primary' : 'default',
1887
+ };
1888
+ });
1889
+ const bodyLines = displaySessions.map(ds => {
1890
+ const prefix = ds.isActive ? '▶' : '•';
1891
+ const threadTag = ds.session.threadId ? '[话题] ' : '';
1892
+ const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
1893
+ const fileMark = ds.fileMissing ? '❌ ' : '';
1894
+ return `${prefix} ${ds.index}. ${threadTag}${fileMark}**${ds.name}** ${uuid} ${ds.idleTime} ${ds.status}`;
1895
+ });
1896
+ const interaction = {
1897
+ type: 'interaction',
1898
+ id: requestId,
1899
+ channelId,
1900
+ sessionId: session.id,
1901
+ kind: {
1902
+ kind: 'action',
1903
+ title: `📋 ${path.basename(session.projectPath)} 会话列表`,
1904
+ body: bodyLines.join('\n'),
1905
+ buttons,
1906
+ },
1907
+ };
1908
+ const replyCtx = this.getReplyContext(session);
1909
+ const cardSent = await this.sendInteractionCard({
1910
+ channel, channelId, sessionId: session.id, requestId, interaction, replyCtx,
1911
+ callback: async (action, _values, operatorId) => {
1912
+ if (userId && operatorId && operatorId !== userId)
1913
+ return;
1914
+ const target = displaySessions.find(ds => String(ds.index) === action);
1915
+ if (target && !target.isActive) {
1916
+ const result = await this.handle(`/session ${action}`, channel, channelId, undefined, userId, threadId);
1917
+ if (result) {
1918
+ const adapter = this.adapters.get(channel);
1919
+ adapter?.sendText(channelId, result, replyCtx);
1920
+ }
1921
+ }
1922
+ },
1923
+ });
1924
+ if (cardSent)
1925
+ return null;
1926
+ }
1927
+ // 降级:文本列表
1343
1928
  const lines = [`当前项目 ${path.basename(session.projectPath)} 的 [${session.agentId}] 会话列表:`, ''];
1344
1929
  if (currentProjectSessions.length > 0) {
1345
- // 超过10个会话时隐藏话题会话(/slist 只能在主会话调用,话题内已禁用)
1346
- const hideTopics = currentProjectSessions.length > 10;
1347
- const topicCount = hideTopics ? currentProjectSessions.filter(s => s.threadId).length : 0;
1348
- const maxDisplay = 10;
1349
- lines.push('【EvolClaw 会话】');
1350
- let displayIndex = 0;
1351
- for (let i = 0; i < currentProjectSessions.length; i++) {
1352
- const s = currentProjectSessions[i];
1353
- if (hideTopics && s.threadId)
1354
- continue;
1355
- if (displayIndex >= maxDisplay)
1356
- break;
1357
- const isActive = s.metadata?.isActive === true;
1358
- displayIndex++;
1359
- const prefix = isActive ? ' ✓' : ' ';
1360
- const num = `${displayIndex}.`;
1361
- const threadTag = s.threadId ? '[话题] ' : '';
1362
- const name = s.name || '(未命名)';
1363
- const uuid = s.agentSessionId ? `(${s.agentSessionId.substring(0, 8)})` : '';
1364
- const idleTime = formatIdleTime(Date.now() - s.updatedAt);
1365
- if (s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId, s.agentId)) {
1366
- lines.push(`${prefix} ${num} ${threadTag}❌ ${name} ${uuid} - ${idleTime} [会话文件缺失]`);
1930
+ for (const ds of displaySessions) {
1931
+ const prefix = ds.isActive ? ' ✓' : ' ';
1932
+ const num = `${ds.index}.`;
1933
+ const threadTag = ds.session.threadId ? '[话题] ' : '';
1934
+ const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
1935
+ if (ds.fileMissing) {
1936
+ lines.push(`${prefix} ${num} ${threadTag}❌ ${ds.name} ${uuid} - ${ds.idleTime} ${ds.status}`);
1367
1937
  }
1368
1938
  else {
1369
- const sIsProcessing = !!s.processingState;
1370
- let status = '[空闲]';
1371
- if (sIsProcessing) {
1372
- status = '[处理中]';
1373
- }
1374
- else if (isActive) {
1375
- status = '[活跃]';
1376
- }
1377
- lines.push(`${prefix} ${num} ${threadTag}${name} ${uuid} - ${idleTime} ${status}`);
1939
+ lines.push(`${prefix} ${num} ${threadTag}${ds.name} ${uuid} - ${ds.idleTime} ${ds.status}`);
1378
1940
  }
1379
1941
  }
1380
1942
  const hiddenCount = currentProjectSessions.length - displayIndex - topicCount;
@@ -1388,29 +1950,23 @@ export class CommandHandler {
1388
1950
  }
1389
1951
  lines.push('');
1390
1952
  }
1391
- const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid)).slice(0, 5);
1392
- if (orphanCliSessions.length > 0) {
1393
- lines.push('【CLI 会话】(最新5个)');
1394
- for (const c of orphanCliSessions) {
1395
- const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
1396
- const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid, session.agentId) || '(无消息)';
1397
- const uuid = c.uuid.substring(0, 8);
1398
- lines.push(` ${time} (${uuid}) "${message}"`);
1399
- }
1400
- lines.push('');
1401
- }
1402
1953
  lines.push('使用 /s <序号、name或8位uuid> 切换会话');
1954
+ lines.push('使用 /s cli 查看 CLI 会话');
1403
1955
  return lines.join('\n');
1404
1956
  }
1957
+ // /session(无参数):直接复用 /slist 逻辑(含卡片交互)
1958
+ if (normalizedContent === '/session') {
1959
+ return this.handle('/slist', channel, channelId, undefined, userId, threadId);
1960
+ }
1961
+ // /session cli(= /s cli):列出未导入的 CLI 会话
1962
+ if (normalizedContent === '/session cli') {
1963
+ return this.handle('/slist cli', channel, channelId, undefined, userId, threadId);
1964
+ }
1405
1965
  // /session 或 /s 命令:切换会话
1406
1966
  if (normalizedContent.startsWith('/session ')) {
1407
1967
  const sessionName = normalizedContent.slice(9).trim();
1408
1968
  if (!sessionName)
1409
1969
  return '用法: /s <序号、会话名称或前8位UUID>';
1410
- const isProcessing = !!session?.processingState;
1411
- if (isProcessing) {
1412
- return `⚠️ 当前正在处理消息,无法切换会话\n请等待当前任务完成后再试`;
1413
- }
1414
1970
  let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
1415
1971
  // 序号切换:纯数字时按 /slist 显示的序号匹配(超过10个时隐藏非活跃话题会话)
1416
1972
  if (!targetSession && /^\d+$/.test(sessionName) && session) {
@@ -1426,7 +1982,7 @@ export class CommandHandler {
1426
1982
  targetSession = visibleSessions[idx - 1];
1427
1983
  }
1428
1984
  else {
1429
- return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
1985
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /s 查看可用会话`;
1430
1986
  }
1431
1987
  }
1432
1988
  if (!targetSession && sessionName.length === 8) {
@@ -1451,7 +2007,7 @@ export class CommandHandler {
1451
2007
  }
1452
2008
  }
1453
2009
  if (!targetSession) {
1454
- return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
2010
+ return `❌ 会话不存在: ${sessionName}\n使用 /s 查看可用会话`;
1455
2011
  }
1456
2012
  const lastInput = targetSession.agentSessionId
1457
2013
  ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId, targetSession.agentId)
@@ -1530,14 +2086,14 @@ export class CommandHandler {
1530
2086
  targetSession = visibleSessions[idx - 1];
1531
2087
  }
1532
2088
  else {
1533
- return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
2089
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /s 查看可用会话`;
1534
2090
  }
1535
2091
  }
1536
2092
  if (!targetSession && sessionName.length === 8) {
1537
2093
  targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
1538
2094
  }
1539
2095
  if (!targetSession) {
1540
- return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
2096
+ return `❌ 会话不存在: ${sessionName}\n使用 /s 查看可用会话`;
1541
2097
  }
1542
2098
  if (targetSession.id === session.id) {
1543
2099
  return `❌ 无法删除当前活跃会话\n请先切换到其他会话`;
@@ -1568,7 +2124,7 @@ export class CommandHandler {
1568
2124
  const forkedSessionId = await forkAgent.forkSession(session.agentSessionId, session.projectPath, forkName);
1569
2125
  const newSession = await this.sessionManager.createForkedSession(session, forkedSessionId, forkName);
1570
2126
  this.eventBus.publish({ type: 'session:forked', sessionId: newSession.id, sourceSessionId: session.id, name: forkName });
1571
- return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /slist 查看所有会话,/s <名称> 切换回原会话`;
2127
+ return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /s 查看所有会话,/s <名称> 切换回原会话`;
1572
2128
  }
1573
2129
  catch (error) {
1574
2130
  logger.error('[CommandHandler] Fork session failed:', error);
@@ -1586,7 +2142,7 @@ export class CommandHandler {
1586
2142
  return `当前不在安全模式,无需修复\n\n如需进入安全模式,请使用 /safe`;
1587
2143
  }
1588
2144
  const repairAgent = this.getAgent(repairSession.agentId);
1589
- const { checkSessionFile, backupSessionFile } = await import('../utils/session-file-health.js');
2145
+ const { checkSessionFile, backupSessionFile } = await import('./session/session-file-health.js');
1590
2146
  try {
1591
2147
  if (!repairSession.agentSessionId) {
1592
2148
  await this.sessionManager.resetHealthStatus(repairSession.id);