evolclaw 2.4.0 → 2.5.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.
@@ -1,5 +1,5 @@
1
1
  import { hasModelSwitcher, hasPermissionController } from '../agents/claude-runner.js';
2
- import { saveConfig, resolvePaths, getPackageRoot, getOwner } from '../config.js';
2
+ import { saveConfig, resolvePaths, getPackageRoot, getOwner, getChannelShowActivities, setChannelShowActivities } from '../config.js';
3
3
  import { logger } from '../utils/logger.js';
4
4
  import crypto from 'crypto';
5
5
  import path from 'path';
@@ -22,10 +22,10 @@ function formatModelUsage(agent, model) {
22
22
  const efforts = getAvailableEfforts(agent, model);
23
23
  const lines = [
24
24
  '用法:',
25
- ' /model <model> 切换模型',
25
+ ' /model <模型> 切换模型',
26
26
  ];
27
27
  if (efforts.length > 0) {
28
- lines.push(' /model <model> <effort> 切换模型+推理强度');
28
+ lines.push(' /model <模型> <强度> 切换模型+推理强度');
29
29
  lines.push(' /effort [level] 查看或切换推理强度');
30
30
  }
31
31
  return lines.join('\n');
@@ -103,15 +103,16 @@ function formatIdleTime(ms) {
103
103
  return '刚刚';
104
104
  }
105
105
  // 支持的命令列表
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'];
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', '/rewind', '/activity', '/agentmd'];
107
107
  // 命令别名映射
108
108
  const aliases = {
109
109
  '/p': '/project',
110
110
  '/s': '/session',
111
- '/name': '/rename'
111
+ '/name': '/rename',
112
+ '/rw': '/rewind'
112
113
  };
113
114
  // 命令快速路径前缀(所有命令都不进入消息队列)
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 '];
115
+ 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 ', '/rewind', '/rw', '/rw ', '/activity'];
115
116
  export class CommandHandler {
116
117
  sessionManager;
117
118
  config;
@@ -237,15 +238,8 @@ export class CommandHandler {
237
238
  return false;
238
239
  const wrappedCallback = async (action, values, operatorId) => {
239
240
  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
- }
241
+ // 已完成交互的卡片:保留原始内容,仅禁用按钮(不标记为"已过期")
242
+ // "已过期"仅用于被新卡片替代的旧卡片(invalidateOldCards)
249
243
  };
250
244
  this.interactionRouter.register(opts.requestId, opts.sessionId, wrappedCallback, { timeoutMs: 120_000, messageId });
251
245
  return true;
@@ -322,9 +316,11 @@ export class CommandHandler {
322
316
  }
323
317
  /**
324
318
  * 返回结构化命令菜单(供 menu.query 使用)
325
- * admin 看到全部命令分组,guest 仅看到用户级命令
319
+ * owner 看到全部命令,admin 看到管理级命令(不含 owner-only),guest 仅看到用户级命令
326
320
  */
327
- getMenuItems(isAdmin, chatType = 'private') {
321
+ getMenuItems(role, chatType = 'private') {
322
+ const isOwner = role === 'owner';
323
+ const isAdmin = role === 'owner' || role === 'admin';
328
324
  const items = [];
329
325
  if (!isAdmin && chatType === 'group') {
330
326
  return [
@@ -332,6 +328,7 @@ export class CommandHandler {
332
328
  group: '其他',
333
329
  commands: [
334
330
  { cmd: '/status', label: '显示会话状态' },
331
+ { cmd: '/check', label: '检查渠道健康' },
335
332
  { cmd: '/help', label: '显示帮助信息' },
336
333
  ]
337
334
  }
@@ -343,7 +340,7 @@ export class CommandHandler {
343
340
  commands: [
344
341
  { cmd: '/pwd', label: '显示当前项目路径' },
345
342
  { cmd: '/p', args: '[name|path]', label: '列出或切换项目' },
346
- { cmd: '/bind', args: '<path>', label: '绑定新项目目录' },
343
+ ...(isOwner ? [{ cmd: '/bind', args: '<path>', label: '绑定新项目目录' }] : []),
347
344
  ]
348
345
  });
349
346
  }
@@ -356,6 +353,7 @@ export class CommandHandler {
356
353
  { cmd: '/del', args: '<name>', label: '删除指定会话' },
357
354
  ...(isAdmin ? [
358
355
  { cmd: '/fork', args: '[name]', label: '分支当前会话' },
356
+ { cmd: '/rewind', args: '[N] [chat|file|all]', label: '查看历史/撤销指定轮次 (别名: /rw)' },
359
357
  { cmd: '/compact', label: '压缩会话上下文' },
360
358
  ] : []),
361
359
  ]
@@ -372,7 +370,7 @@ export class CommandHandler {
372
370
  items.push({
373
371
  group: '权限管理',
374
372
  commands: [
375
- { cmd: '/perm', args: '[mode|allow|always|deny]', label: '权限模式管理' },
373
+ { cmd: '/perm', args: isOwner ? '[mode|allow|always|deny]' : '[allow|always|deny]', label: '权限模式管理' },
376
374
  ]
377
375
  });
378
376
  items.push({
@@ -380,9 +378,16 @@ export class CommandHandler {
380
378
  commands: [
381
379
  { cmd: '/status', label: '显示会话状态' },
382
380
  { cmd: '/stop', label: '中断当前任务' },
383
- { cmd: '/restart', label: '重启服务' },
384
- { cmd: '/send', args: '[channel] <path>', label: '发送项目内文件' },
385
- { cmd: '/check', args: '[rty <channel>]', label: '检查渠道状态或重连指定渠道' },
381
+ { cmd: '/check', label: '检查渠道状态' },
382
+ { cmd: '/activity', args: '[all|dm|owner|none]', label: '查看/控制中间输出显示模式' },
383
+ ...(isAdmin ? [
384
+ { cmd: '/restart', args: '<channel>', label: '重连指定渠道' },
385
+ ] : []),
386
+ ...(isOwner ? [
387
+ { cmd: '/restart', label: '重启服务' },
388
+ { cmd: '/send', args: '[channel] <path>', label: '发送项目内文件' },
389
+ { cmd: '/agentmd', args: '[put|set <内容>]', label: '管理 agent.md' },
390
+ ] : []),
386
391
  ]
387
392
  });
388
393
  }
@@ -391,6 +396,7 @@ export class CommandHandler {
391
396
  group: '其他',
392
397
  commands: [
393
398
  { cmd: '/status', label: '显示会话状态' },
399
+ { cmd: '/check', label: '检查渠道健康' },
394
400
  ]
395
401
  });
396
402
  }
@@ -438,20 +444,23 @@ export class CommandHandler {
438
444
  }
439
445
  }
440
446
  // 权限检查:区分用户级命令和管理级命令
441
- const isAdmin = identity.role === 'owner';
447
+ const isOwner = identity.role === 'owner';
448
+ const isAdmin = identity.role === 'owner' || identity.role === 'admin';
442
449
  const activeChatType = activeSession?.chatType || 'private';
443
450
  if (normalizedContent.startsWith('/')) {
444
- const guestGroupCommands = ['/status', '/help'];
451
+ const guestGroupCommands = ['/status', '/help', '/check'];
445
452
  const userCommands = activeChatType === 'group' && !isAdmin
446
453
  ? guestGroupCommands
447
- : ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
454
+ : ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s ', '/check'];
448
455
  const isUserCommand = userCommands.some(cmd => normalizedContent === cmd.trimEnd() || normalizedContent.startsWith(cmd));
449
456
  if (!isUserCommand && !isAdmin) {
450
- return '❌ 无权限:当前群聊仅支持 /status 和 /help';
457
+ return activeChatType === 'group'
458
+ ? '❌ 无权限:当前群聊仅支持 /status 和 /help'
459
+ : '❌ 无权限:此命令仅限管理员使用';
451
460
  }
452
461
  }
453
462
  // 空闲检查:某些命令需要等待当前会话空闲
454
- const requiresIdle = ['/new', '/session', '/clear', '/compact', '/safe', '/repair', '/fork', '/bind', '/project', '/agent'];
463
+ const requiresIdle = ['/new', '/session', '/clear', '/compact', '/safe', '/repair', '/fork', '/bind', '/project', '/agent', '/rewind'];
455
464
  if (requiresIdle.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' '))) {
456
465
  if (threadId) {
457
466
  // 话题中:检查话题 session 是否在处理(不创建)
@@ -495,6 +504,7 @@ export class CommandHandler {
495
504
  '',
496
505
  '其他:',
497
506
  ' /status - 显示会话状态',
507
+ ' /check - 检查渠道健康',
498
508
  ' /help - 显示此帮助信息',
499
509
  ];
500
510
  return lines.join('\n');
@@ -509,19 +519,21 @@ export class CommandHandler {
509
519
  ' /name <新名称> - 重命名当前会话',
510
520
  ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
511
521
  ' /status - 显示会话状态',
522
+ ' /check - 检查渠道健康',
512
523
  '',
513
524
  '❓ 帮助:',
514
525
  ' /help - 显示此帮助信息',
515
526
  ];
516
527
  return lines.join('\n');
517
528
  }
529
+ // admin+ 基础命令
518
530
  const lines = [
519
531
  '可用命令:',
520
532
  '',
521
533
  '📁 项目管理:',
522
534
  ' /pwd - 显示当前项目路径',
523
535
  ' /p [name|path] - 列出或切换项目',
524
- ' /bind <path> - 绑定新项目目录',
536
+ ...(isOwner ? [' /bind <path> - 绑定新项目目录'] : []),
525
537
  '',
526
538
  '🔄 会话管理:',
527
539
  ' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
@@ -529,6 +541,7 @@ export class CommandHandler {
529
541
  ' /name <新名称> - 重命名当前会话',
530
542
  ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
531
543
  ' /fork [名称] - 分支当前会话(从当前对话点创建分支)',
544
+ ' /rewind [N] [chat|file|all] - 查看历史/撤销指定轮次(别名: /rw)',
532
545
  ' /compact - 压缩会话上下文(减少 token 用量)',
533
546
  '',
534
547
  '🤖 Agent 与模型:',
@@ -538,14 +551,22 @@ export class CommandHandler {
538
551
  '',
539
552
  '🔐 权限管理:',
540
553
  ' /perm - 查看当前权限模式',
541
- ' /perm <auto|bypass|request|edit|plan|noask> - 切换权限模式',
554
+ ...(isOwner ? [' /perm <auto|bypass|request|edit|plan|noask> - 切换权限模式'] : []),
542
555
  ' /perm allow|always|deny - 审批权限请求',
543
556
  '',
544
557
  '🛠️ 运维:',
545
558
  ' /status - 显示会话状态',
546
559
  ' /stop - 中断当前任务',
547
- ' /restart - 重启服务',
548
- ' /send [channel] <path> - 发送项目内文件',
560
+ ' /check - 检查渠道状态',
561
+ ' /activity [all|dm|owner|none] - 查看/控制中间输出显示模式',
562
+ ...(isAdmin ? [
563
+ ' /restart <channel> - 重连指定渠道',
564
+ ] : []),
565
+ ...(isOwner ? [
566
+ ' /restart - 重启服务',
567
+ ' /send [channel] <path> - 发送项目内文件',
568
+ ' /agentmd [put|set <内容>] - 管理 agent.md',
569
+ ] : []),
549
570
  '',
550
571
  '❓ 帮助:',
551
572
  ' /help - 显示此帮助信息',
@@ -581,7 +602,7 @@ export class CommandHandler {
581
602
  kind: {
582
603
  kind: 'action',
583
604
  title: '🔐 权限模式',
584
- body: availableModes.map(m => `${m.key === currentMode ? '' : '•'} **${m.key}** (${m.nameZh}) - ${m.description}`).join('\n'),
605
+ body: availableModes.map(m => `${m.key === currentMode ? '' : '•'} **${m.key}** (${m.nameZh}) - ${m.description}`).join('\n'),
585
606
  buttons: availableModes.map(m => ({
586
607
  key: m.key,
587
608
  label: m.key === currentMode ? `✓ ${m.key}` : m.key,
@@ -609,7 +630,7 @@ export class CommandHandler {
609
630
  }
610
631
  // 降级:文本
611
632
  const modeList = modes.map(m => {
612
- const prefix = m.key === currentMode ? '' : ' ';
633
+ const prefix = m.key === currentMode ? '' : ' ';
613
634
  const suffix = m.available ? '' : ' ⚠️ 不可用';
614
635
  return ` ${prefix} ${m.key} (${m.nameZh}) - ${m.description}${suffix}`;
615
636
  }).join('\n');
@@ -649,9 +670,9 @@ export class CommandHandler {
649
670
  if (!matched.available) {
650
671
  return `❌ ${matched.key} 模式当前不可用:${matched.unavailableReason}`;
651
672
  }
652
- // guest 用户只能保持 readonly 模式
653
- if (identity.role !== 'owner' && arg !== 'readonly') {
654
- return '❌ 当前身份无法切换权限模式';
673
+ // guest admin 用户不能切换权限模式(仅 owner)
674
+ if (!isOwner) {
675
+ return '❌ 权限模式切换仅限 owner';
655
676
  }
656
677
  const metadata = permSession.metadata || {};
657
678
  metadata.permissionMode = arg;
@@ -668,8 +689,9 @@ export class CommandHandler {
668
689
  return `❌ 未知参数: ${args}\n用法: /perm <${allModeKeys}> 或 /perm allow|always|deny`;
669
690
  }
670
691
  // /agent 命令:查看或切换 Agent 后端
671
- if (normalizedContent.startsWith('/agent')) {
672
- if (!isAdmin)
692
+ if (normalizedContent === '/agent' || normalizedContent.startsWith('/agent ')) {
693
+ // 群聊中 owner only,私聊中 admin+
694
+ if (activeChatType === 'group' ? !isOwner : !isAdmin)
673
695
  return '❌ 无权限:此命令仅限管理员使用';
674
696
  const args = normalizedContent.slice(6).trim();
675
697
  const available = [...this.agentMap.keys()];
@@ -787,7 +809,7 @@ export class CommandHandler {
787
809
  return null;
788
810
  }
789
811
  // 降级:文本
790
- const modelList = models.map((m) => `- ${m}`).join('\n');
812
+ const modelList = models.map((m) => ` ${m === currentModel ? '✓' : ' '} ${m}`).join('\n');
791
813
  const effortHint = efforts.length > 0
792
814
  ? `\n推理强度: ${currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort} (使用 /effort 调整)`
793
815
  : '';
@@ -811,7 +833,7 @@ export class CommandHandler {
811
833
  newModel = arg;
812
834
  }
813
835
  else {
814
- const modelList = models.map((m) => `- ${m}`).join('\n');
836
+ const modelList = models.map((m) => ` ${m === currentModel ? '✓' : ' '} ${m}`).join('\n');
815
837
  const effortHint = efforts.length > 0 ? `\n\n推理强度请使用 /effort 命令` : '';
816
838
  return `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}${effortHint}`;
817
839
  }
@@ -977,8 +999,9 @@ export class CommandHandler {
977
999
  }
978
1000
  // 降级:文本
979
1001
  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>`;
1002
+ const allItems = [...efforts, 'auto'];
1003
+ const effortList = allItems.map(e => ` ${e === currentEffort ? '✓' : ' '} ${e}${e === 'auto' ? ' (SDK默认)' : ''}`).join('\n');
1004
+ return `⚡ 推理强度: ${effortDisplay}\n\n可选:\n${effortList}\n\n用法: /effort <level>`;
982
1005
  }
983
1006
  // /effort auto:恢复 SDK 默认
984
1007
  if (args === 'auto') {
@@ -1047,6 +1070,151 @@ export class CommandHandler {
1047
1070
  }
1048
1071
  return `✓ 推理强度: ${newEffort}`;
1049
1072
  }
1073
+ // /activity 命令:控制中间输出显示模式
1074
+ if (normalizedContent === '/agentmd' || normalizedContent.startsWith('/agentmd ')) {
1075
+ if (!isOwner)
1076
+ return '❌ 无权限:此命令仅限 owner 使用';
1077
+ const adapter = this.adapters.get(channel);
1078
+ if (!adapter?.uploadAgentMd)
1079
+ return '❌ 当前通道不支持 agent.md 操作';
1080
+ const selfAid = typeof adapter._selfAid === 'function' ? adapter._selfAid() : '';
1081
+ const arg = normalizedContent.slice(9).trim();
1082
+ // put — read local ~/.aun/AIDs/{aid}/agent.md and upload
1083
+ if (arg === 'put') {
1084
+ if (!selfAid)
1085
+ return '❌ 未连接,无法确定本地 AID';
1086
+ try {
1087
+ const { readFileSync } = await import('node:fs');
1088
+ const { join } = await import('node:path');
1089
+ const { homedir } = await import('node:os');
1090
+ const localPath = join(homedir(), '.aun', 'AIDs', selfAid, 'agent.md');
1091
+ const content = readFileSync(localPath, 'utf-8');
1092
+ await adapter.uploadAgentMd(content);
1093
+ return '✅ agent.md 已发布';
1094
+ }
1095
+ catch (e) {
1096
+ return `❌ 发布失败: ${String(e.message || e).slice(0, 100)}`;
1097
+ }
1098
+ }
1099
+ // set <content> — upload inline content and sync to local
1100
+ if (arg.startsWith('set ')) {
1101
+ const content = arg.slice(4).trim();
1102
+ if (!content)
1103
+ return '用法:/agentmd set <内容>';
1104
+ if (!selfAid)
1105
+ return '❌ 未连接,无法确定本地 AID';
1106
+ try {
1107
+ await adapter.uploadAgentMd(content);
1108
+ const { writeFileSync, mkdirSync } = await import('node:fs');
1109
+ const { join } = await import('node:path');
1110
+ const { homedir } = await import('node:os');
1111
+ const localDir = join(homedir(), '.aun', 'AIDs', selfAid);
1112
+ mkdirSync(localDir, { recursive: true });
1113
+ writeFileSync(join(localDir, 'agent.md'), content, 'utf-8');
1114
+ return '✅ agent.md 已更新并发布到AUN网络';
1115
+ }
1116
+ catch (e) {
1117
+ return `❌ 发布失败: ${String(e.message || e).slice(0, 100)}`;
1118
+ }
1119
+ }
1120
+ // view — /agentmd or /agentmd <aid>
1121
+ const aidToView = arg || selfAid;
1122
+ if (!aidToView)
1123
+ return '用法:/agentmd [<aid>] | put | set <内容>';
1124
+ try {
1125
+ const md = await adapter.downloadAgentMd(aidToView);
1126
+ if (!md || !md.trim())
1127
+ return `ℹ️ ${aidToView} 尚未设置 agent.md`;
1128
+ return `\`\`\`\n${md.slice(0, 1500)}\n\`\`\``;
1129
+ }
1130
+ catch (e) {
1131
+ const msg = String(e.message || e);
1132
+ if (msg.includes('not found') || msg.includes('404')) {
1133
+ return `ℹ️ ${aidToView} 尚未设置 agent.md`;
1134
+ }
1135
+ return `❌ 获取失败: ${msg.slice(0, 100)}`;
1136
+ }
1137
+ }
1138
+ if (normalizedContent === '/activity' || normalizedContent.startsWith('/activity ')) {
1139
+ if (!isAdmin)
1140
+ return '❌ 无权限:此命令仅限管理员使用';
1141
+ const activityArg = normalizedContent.slice(9).trim();
1142
+ const modeMap = {
1143
+ all: 'all',
1144
+ dm: 'dm-only',
1145
+ owner: 'owner-dm-only',
1146
+ none: 'none',
1147
+ };
1148
+ const currentMode = getChannelShowActivities(this.config, channel);
1149
+ // 模式描述列表(用于 body 和文本降级)
1150
+ const modeDescriptions = [
1151
+ { key: 'all', configVal: 'all', label: '全部显示' },
1152
+ { key: 'dm', configVal: 'dm-only', label: '仅私聊显示' },
1153
+ { key: 'owner', configVal: 'owner-dm-only', label: '仅 owner 私聊显示' },
1154
+ { key: 'none', configVal: 'none', label: '全部静默' },
1155
+ ];
1156
+ if (!activityArg) {
1157
+ // 无参数:显示当前模式 + Action 卡片
1158
+ if (this.interactionRouter) {
1159
+ const requestId = `activity-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
1160
+ const body = modeDescriptions.map(m => `${m.configVal === currentMode ? '✓' : '•'} **${m.key}** (${m.label})`).join('\n');
1161
+ const buttons = modeDescriptions.map(m => ({
1162
+ key: m.key,
1163
+ label: m.configVal === currentMode ? `✓ ${m.key}` : m.key,
1164
+ style: m.configVal === currentMode ? 'primary' : 'default',
1165
+ }));
1166
+ const interaction = {
1167
+ type: 'interaction',
1168
+ id: requestId,
1169
+ channelId,
1170
+ sessionId: activeSession?.id || requestId,
1171
+ kind: {
1172
+ kind: 'action',
1173
+ title: '📋 中间输出模式',
1174
+ body,
1175
+ buttons,
1176
+ },
1177
+ };
1178
+ const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
1179
+ const cardSent = await this.sendInteractionCard({
1180
+ channel, channelId, sessionId: activeSession?.id || requestId, requestId, interaction, replyCtx,
1181
+ callback: async (action, _values, operatorId) => {
1182
+ const newMode = modeMap[action];
1183
+ if (newMode && newMode !== currentMode) {
1184
+ if (userId && operatorId && operatorId !== userId)
1185
+ return;
1186
+ const result = await this.handle(`/activity ${action}`, channel, channelId, undefined, userId, threadId);
1187
+ if (result) {
1188
+ const adapter = this.adapters.get(channel);
1189
+ adapter?.sendText(channelId, result, replyCtx);
1190
+ }
1191
+ }
1192
+ },
1193
+ });
1194
+ if (cardSent)
1195
+ return null;
1196
+ }
1197
+ // 降级:文本
1198
+ const modeList = modeDescriptions.map(m => {
1199
+ const prefix = m.configVal === currentMode ? '✓' : ' ';
1200
+ return ` ${prefix} ${m.key} (${m.label})`;
1201
+ }).join('\n');
1202
+ return `📋 中间输出模式: ${currentMode}\n\n${modeList}\n\n用法:\n /activity <模式> 切换中间输出显示模式`;
1203
+ }
1204
+ const newMode = modeMap[activityArg];
1205
+ if (!newMode) {
1206
+ return `❌ 无效参数: ${activityArg}\n可选: all / dm / owner / none`;
1207
+ }
1208
+ const label = modeDescriptions.find(m => m.configVal === newMode)?.label || newMode;
1209
+ if (newMode === currentMode) {
1210
+ return `📋 中间输出模式已是 ${activityArg}(${label})`;
1211
+ }
1212
+ // 切换操作仅 owner
1213
+ if (!isOwner)
1214
+ return '❌ 中间输出模式切换仅限 owner';
1215
+ setChannelShowActivities(this.config, channel, newMode);
1216
+ return `✅ 中间输出模式: ${activityArg}(${label})`;
1217
+ }
1050
1218
  // /stop 命令:中断当前任务
1051
1219
  if (normalizedContent === '/stop') {
1052
1220
  const stopResult = await this.ensureSession(channel, channelId, threadId);
@@ -1193,22 +1361,11 @@ export class CommandHandler {
1193
1361
  if (health.consecutiveErrors > 0) {
1194
1362
  lines.push(`异常计数: ${health.consecutiveErrors}`);
1195
1363
  }
1196
- if (health.safeMode) {
1197
- lines.push(`安全模式: 是 ⚠️`);
1198
- }
1199
1364
  lines.push(`最后成功: ${timeStr}`, `${session.agentId}会话: ${session.agentSessionId || '(未初始化)'}`, `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`, `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`);
1200
1365
  }
1201
1366
  else {
1202
1367
  lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / ${session.agentId}会话`, `状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `最后活跃: ${timeStr}`);
1203
1368
  }
1204
- if (health.safeMode) {
1205
- lines.push('');
1206
- lines.push('⚠️ 当前处于安全模式(历史上下文已禁用)');
1207
- lines.push('');
1208
- lines.push('退出方式:');
1209
- lines.push('1. /new [名称] - 创建新会话(清空历史)');
1210
- lines.push('2. 联系管理员使用 /repair 检查并修复会话');
1211
- }
1212
1369
  if (health.lastError) {
1213
1370
  lines.push('');
1214
1371
  lines.push(`最后错误: ${health.lastErrorType || 'unknown'}`);
@@ -1244,29 +1401,10 @@ export class CommandHandler {
1244
1401
  }
1245
1402
  return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /s 查看`;
1246
1403
  }
1247
- // /check 命令:检查渠道状态 / 手动重连指定渠道
1404
+ // /check 命令:检查渠道状态(guest 可用,详情仅 admin)/ 重连指定渠道(admin only)
1248
1405
  if (normalizedContent === '/check' || normalizedContent.startsWith('/check ')) {
1249
- if (!isAdmin)
1250
- return '❌ 无权限:此命令仅限管理员使用';
1251
1406
  const subCmd = normalizedContent.slice('/check'.length).trim();
1252
- // /check rty <channel> 重连指定渠道
1253
- if (subCmd.startsWith('rty')) {
1254
- const target = subCmd.slice('rty'.length).trim();
1255
- if (!target) {
1256
- return '❌ 请指定渠道名称,例如:/check rty feishu';
1257
- }
1258
- const ch = this.channelObjects.get(target);
1259
- if (!ch) {
1260
- const available = [...this.channelObjects.keys()].join(', ') || '无';
1261
- return `❌ 未找到渠道 "${target}",可用渠道:${available}`;
1262
- }
1263
- if (!ch.reconnect) {
1264
- return `❌ 渠道 "${target}" 不支持重连`;
1265
- }
1266
- const result = await ch.reconnect();
1267
- return `🔄 ${target} 重连: ${result}`;
1268
- }
1269
- // Default: show full system health check
1407
+ // Default: show system health check (non-admin 仅看摘要)
1270
1408
  const lines = ['📡 渠道状态:'];
1271
1409
  // Group by channelType
1272
1410
  const groups = new Map();
@@ -1285,6 +1423,13 @@ export class CommandHandler {
1285
1423
  groups.set(type, []);
1286
1424
  groups.get(type).push({ name, status });
1287
1425
  }
1426
+ if (!isAdmin) {
1427
+ // guest/user: 仅显示渠道健康摘要
1428
+ const total = [...groups.values()].flat().length;
1429
+ const healthy = [...groups.values()].flat().filter(i => i.status.includes('✓')).length;
1430
+ lines.push(` ${healthy}/${total} 渠道正常`);
1431
+ return lines.join('\n');
1432
+ }
1288
1433
  for (const [type, instances] of groups) {
1289
1434
  if (instances.length === 1) {
1290
1435
  lines.push(` ${instances[0].name}: ${instances[0].status}`);
@@ -1327,11 +1472,30 @@ export class CommandHandler {
1327
1472
  lines.push(` 平均响应耗时: ${(h.avgResponseMs / 1000).toFixed(1)}s`);
1328
1473
  }
1329
1474
  }
1330
- lines.push('', '💡 /check rty <channel> — 重连指定渠道');
1331
1475
  return lines.join('\n');
1332
1476
  }
1333
- // /restart 命令:重启服务
1334
- if (normalizedContent === '/restart') {
1477
+ // /restart 命令:重启服务(owner only) / 重连指定渠道(admin+)
1478
+ if (normalizedContent === '/restart' || normalizedContent.startsWith('/restart ')) {
1479
+ const restartArg = normalizedContent.slice('/restart'.length).trim();
1480
+ // /restart <channel> — 重连指定渠道(admin only)
1481
+ if (restartArg) {
1482
+ if (!isAdmin)
1483
+ return '❌ 无权限:渠道重连仅限管理员使用';
1484
+ const target = restartArg;
1485
+ const ch = this.channelObjects.get(target);
1486
+ if (!ch) {
1487
+ const available = [...this.channelObjects.keys()].join(', ') || '无';
1488
+ return `❌ 未找到渠道 "${target}",可用渠道:${available}`;
1489
+ }
1490
+ if (!ch.reconnect) {
1491
+ return `❌ 渠道 "${target}" 不支持重连`;
1492
+ }
1493
+ const result = await ch.reconnect();
1494
+ return `🔄 ${target} 重连: ${result}`;
1495
+ }
1496
+ // /restart(无参数)— 重启整个服务(owner only)
1497
+ if (!isOwner)
1498
+ return '❌ 无权限:服务重启仅限 owner 使用';
1335
1499
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
1336
1500
  const sessionsWithMessages = allSessions
1337
1501
  .filter(s => this.messageCache.hasMessages(s.id))
@@ -1400,8 +1564,10 @@ export class CommandHandler {
1400
1564
  }
1401
1565
  return `当前项目: ${session.projectPath}`;
1402
1566
  }
1403
- // /send 命令:发送项目内文件,支持 /send path 和 /send channel path
1567
+ // /send 命令:发送项目内文件,支持 /send path 和 /send channel path(owner only)
1404
1568
  if (normalizedContent.startsWith('/send')) {
1569
+ if (!isOwner)
1570
+ return '❌ 无权限:此命令仅限 owner 使用';
1405
1571
  // 飞书会将 .md 等后缀自动转为 Markdown 链接: foo.md → [foo.md](http://foo.md/)
1406
1572
  // 还原: 将 [text](url) 替换为 text
1407
1573
  const rawArg = normalizedContent.slice(5).trim().replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
@@ -1573,7 +1739,7 @@ export class CommandHandler {
1573
1739
  }));
1574
1740
  const bodyLines = entries.map(e => {
1575
1741
  const status = buildStatusText(e);
1576
- const prefix = e.isCurrent ? '' : '•';
1742
+ const prefix = e.isCurrent ? '' : '•';
1577
1743
  return `${prefix} **${e.name}** (${e.projectPath}) ${status}`;
1578
1744
  });
1579
1745
  const interaction = {
@@ -1613,6 +1779,7 @@ export class CommandHandler {
1613
1779
  const prefix = entry.isCurrent ? ' ✓' : ' ';
1614
1780
  lines.push(`${prefix} ${entry.name} (${entry.projectPath}) - ${buildStatusText(entry)}`);
1615
1781
  }
1782
+ lines.push('', '提示: 使用 /p <名称> 切换项目');
1616
1783
  return lines.join('\n');
1617
1784
  }
1618
1785
  // /project(无参数):直接复用 /plist 逻辑(含卡片交互)
@@ -1711,8 +1878,10 @@ export class CommandHandler {
1711
1878
  }
1712
1879
  return response;
1713
1880
  }
1714
- // /bind 命令:持久化项目到配置(不切换)
1881
+ // /bind 命令:持久化项目到配置(不切换)(owner only)
1715
1882
  if (normalizedContent.startsWith('/bind ')) {
1883
+ if (!isOwner)
1884
+ return '❌ 无权限:此命令仅限 owner 使用';
1716
1885
  const projectPath = normalizedContent.slice(6).trim();
1717
1886
  if (!projectPath)
1718
1887
  return '用法: /bind <路径>';
@@ -1887,7 +2056,7 @@ export class CommandHandler {
1887
2056
  };
1888
2057
  });
1889
2058
  const bodyLines = displaySessions.map(ds => {
1890
- const prefix = ds.isActive ? '' : '•';
2059
+ const prefix = ds.isActive ? '' : '•';
1891
2060
  const threadTag = ds.session.threadId ? '[话题] ' : '';
1892
2061
  const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
1893
2062
  const fileMark = ds.fileMissing ? '❌ ' : '';
@@ -2131,31 +2300,59 @@ export class CommandHandler {
2131
2300
  return `❌ 会话分支失败: ${error instanceof Error ? error.message : '未知错误'}`;
2132
2301
  }
2133
2302
  }
2134
- // /repair 命令:检查并修复会话
2303
+ // /rewind 命令:查看历史 / 回退会话
2304
+ if (normalizedContent === '/rewind' || normalizedContent.startsWith('/rewind ')) {
2305
+ const result = await this.ensureSession(channel, channelId, threadId);
2306
+ if ('error' in result)
2307
+ return result.error;
2308
+ const { session } = result;
2309
+ const rewindAgent = this.getAgent(session.agentId);
2310
+ if (rewindAgent.name !== 'claude') {
2311
+ return '❌ /rewind 仅支持 Claude 后端';
2312
+ }
2313
+ if (!session.agentSessionId) {
2314
+ return '❌ 当前会话无历史记录\n\n请先发送一条消息,然后再使用 /rewind';
2315
+ }
2316
+ if (!rewindAgent.getSessionMessages) {
2317
+ return '❌ 当前 Agent 不支持 /rewind';
2318
+ }
2319
+ const args = normalizedContent.slice('/rewind'.length).trim();
2320
+ if (!args) {
2321
+ return await this.handleRewindList(session, rewindAgent);
2322
+ }
2323
+ const parts = args.split(/\s+/);
2324
+ const turnNum = parseInt(parts[0], 10);
2325
+ if (isNaN(turnNum) || turnNum < 1) {
2326
+ return '❌ 无效轮次,用法:/rewind <N> chat|file|all(撤销第N轮)';
2327
+ }
2328
+ const mode = parts[1]?.toLowerCase();
2329
+ if (!mode) {
2330
+ return `❌ 请指定回退模式:/rewind ${turnNum} chat | file | all(撤销第${turnNum}轮)`;
2331
+ }
2332
+ if (!['chat', 'file', 'all'].includes(mode)) {
2333
+ return `❌ 无效模式 "${mode}",可选:chat | file | all`;
2334
+ }
2335
+ return await this.handleRewind(session, rewindAgent, turnNum, mode);
2336
+ }
2337
+ // /repair 命令:检查并修复会话文件
2135
2338
  if (normalizedContent === '/repair') {
2136
2339
  const repairResult = await this.ensureSession(channel, channelId, threadId);
2137
2340
  if ('error' in repairResult)
2138
2341
  return repairResult.error;
2139
2342
  const { session: repairSession } = repairResult;
2140
- const health = await this.sessionManager.getHealthStatus(repairSession.id);
2141
- if (!health.safeMode) {
2142
- return `当前不在安全模式,无需修复\n\n如需进入安全模式,请使用 /safe`;
2143
- }
2144
2343
  const repairAgent = this.getAgent(repairSession.agentId);
2145
2344
  const { checkSessionFile, backupSessionFile } = await import('./session/session-file-health.js');
2146
2345
  try {
2147
2346
  if (!repairSession.agentSessionId) {
2148
2347
  await this.sessionManager.resetHealthStatus(repairSession.id);
2149
- this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2150
- return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 未发现问题(新会话)\n- 已重置异常计数器\n- 已恢复正常会话模式`;
2348
+ return `✓ 修复完成\n\n修复内容:\n- 未发现问题(新会话)\n- 已重置异常计数器`;
2151
2349
  }
2152
2350
  // 通过 agent 定位 session 文件
2153
2351
  const sessionFile = repairAgent.resolveSessionFile?.(repairSession.agentSessionId, repairSession.projectPath) ?? null;
2154
2352
  if (!sessionFile) {
2155
2353
  // 文件不存在(已被删除或从未创建),直接重置
2156
2354
  await this.sessionManager.resetHealthStatus(repairSession.id);
2157
- this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2158
- return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 会话文件不存在(可能已被清理)\n- 已重置异常计数器\n- 已恢复正常会话模式`;
2355
+ return `✓ 修复完成\n\n修复内容:\n- 会话文件不存在(可能已被清理)\n- 已重置异常计数器`;
2159
2356
  }
2160
2357
  const healthCheck = await checkSessionFile(sessionFile);
2161
2358
  if (healthCheck.corrupt) {
@@ -2165,42 +2362,195 @@ export class CommandHandler {
2165
2362
  await this.sessionManager.updateAgentSessionIdBySessionId(repairSession.id, '');
2166
2363
  repairAgent.updateSessionId(repairSession.id, '');
2167
2364
  await this.sessionManager.resetHealthStatus(repairSession.id);
2168
- this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2169
- return `✓ 修复完成,已退出安全模式\n\n检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n修复操作:\n- 已备份损坏文件\n- 已删除损坏文件\n- 已重置异常计数器\n\n备份位置:${backupPath}`;
2365
+ return `✓ 修复完成\n\n检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n修复操作:\n- 已备份损坏文件\n- 已删除损坏文件\n- 已重置异常计数器\n\n备份位置:${backupPath}`;
2170
2366
  }
2171
2367
  if (healthCheck.issues.length > 0) {
2172
2368
  await this.sessionManager.resetHealthStatus(repairSession.id);
2173
- this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2174
2369
  return `⚠️ 检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n建议使用 /new 创建新会话\n\n已重置异常计数器,可继续使用当前会话。`;
2175
2370
  }
2176
2371
  await this.sessionManager.resetHealthStatus(repairSession.id);
2177
- this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
2178
- return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 未发现问题\n- 已重置异常计数器\n- 已恢复正常会话模式`;
2372
+ return `✓ 修复完成\n\n修复内容:\n- 未发现问题\n- 已重置异常计数器`;
2179
2373
  }
2180
2374
  catch (error) {
2181
2375
  logger.error('[Repair] Failed:', error);
2182
2376
  return `❌ 修复失败: ${error.message}`;
2183
2377
  }
2184
2378
  }
2185
- // /safe 命令:手动进入安全模式
2379
+ // /safe 命令:安全模式已禁用
2186
2380
  if (normalizedContent === '/safe') {
2187
- const safeResult = await this.ensureSession(channel, channelId, threadId);
2188
- if ('error' in safeResult)
2189
- return safeResult.error;
2190
- const { session: safeSession } = safeResult;
2191
- await this.sessionManager.setSafeMode(safeSession.id, true);
2192
- this.eventBus.publish({ type: 'session:safe-mode-entered', sessionId: safeSession.id, reason: 'manual' });
2193
- return `✓ 已进入安全模式
2194
-
2195
- 当前行为:
2196
- - 暂时不加载会话历史(每次对话独立)
2197
- - 所有功能正常可用(读写文件、执行命令等)
2198
- - 不会丢失历史数据(仍保存在 .claude/ 目录)
2199
-
2200
- 退出安全模式:
2201
- - 使用 /repair 检查并修复会话
2202
- - 使用 /new 创建全新会话`;
2381
+ return `ℹ️ 安全模式已禁用\n\n如需重置会话,请使用 /new 创建新会话。`;
2203
2382
  }
2204
2383
  return null;
2205
2384
  }
2385
+ // ── /rewind helpers ──
2386
+ async handleRewindList(session, agent) {
2387
+ try {
2388
+ const messages = await agent.getSessionMessages(session.agentSessionId, session.projectPath);
2389
+ const turns = this.buildTurnList(messages);
2390
+ if (turns.length === 0) {
2391
+ return '📋 当前会话暂无对话记录';
2392
+ }
2393
+ const lines = turns.map(t => `#${t.index} ${t.userContent}`);
2394
+ return [
2395
+ `📋 会话历史 (共 ${turns.length} 轮)`,
2396
+ '',
2397
+ ...lines,
2398
+ '',
2399
+ '💡 /rewind <N> chat|file|all — 撤销第N轮',
2400
+ ].join('\n');
2401
+ }
2402
+ catch (error) {
2403
+ logger.error('[CommandHandler] Failed to read session messages:', error);
2404
+ return `❌ 读取会话历史失败: ${error instanceof Error ? error.message : '未知错误'}`;
2405
+ }
2406
+ }
2407
+ async handleRewind(session, agent, turnNum, mode) {
2408
+ try {
2409
+ const messages = await agent.getSessionMessages(session.agentSessionId, session.projectPath);
2410
+ const turns = this.buildTurnList(messages);
2411
+ if (turnNum < 1 || turnNum > turns.length) {
2412
+ return `❌ 轮次超出范围,当前共 ${turns.length} 轮`;
2413
+ }
2414
+ // /rewind N = 撤销第N轮(及之后),保留 1..N-1
2415
+ const rewindTarget = turns[turnNum - 1]; // 被撤销的轮次(用于文件回退)
2416
+ const keepTarget = turnNum >= 2 ? turns[turnNum - 2] : null; // 保留到的轮次(用于对话回退)
2417
+ const results = [];
2418
+ // 文件回退(立即执行)
2419
+ if (mode === 'file' || mode === 'all') {
2420
+ if (!agent.rewindFiles) {
2421
+ return '❌ 当前 Agent 不支持文件回退';
2422
+ }
2423
+ const fileResult = await agent.rewindFiles(session.agentSessionId, session.projectPath, rewindTarget.userUuid);
2424
+ if (!fileResult.canRewind) {
2425
+ if (mode === 'file') {
2426
+ return `❌ 当前会话无文件快照,无法回退文件${fileResult.error ? `\n原因: ${fileResult.error}` : ''}`;
2427
+ }
2428
+ results.push(`⚠️ 文件回退失败${fileResult.error ? `: ${fileResult.error}` : '(无文件快照)'}`);
2429
+ }
2430
+ else {
2431
+ const detail = fileResult.filesChanged
2432
+ ? `(恢复了 ${fileResult.filesChanged.length} 个文件)`
2433
+ : '';
2434
+ results.push(`✅ 已恢复文件到第 ${turnNum} 轮之前的状态${detail}`);
2435
+ }
2436
+ }
2437
+ // 对话回退(延迟执行 — 下次发消息时生效)
2438
+ if (mode === 'chat' || mode === 'all') {
2439
+ if (keepTarget) {
2440
+ const meta = { ...(session.metadata || {}), resumeAt: keepTarget.assistantUuid };
2441
+ await this.sessionManager.updateSession(session.id, { metadata: meta });
2442
+ }
2443
+ else {
2444
+ // N=1:撤销全部对话,清空 session 从头开始
2445
+ const meta = { ...(session.metadata || {}) };
2446
+ delete meta.resumeAt;
2447
+ await this.sessionManager.updateSession(session.id, {
2448
+ metadata: meta,
2449
+ agentSessionId: null,
2450
+ });
2451
+ }
2452
+ const discarded = turns.length - turnNum + 1;
2453
+ const keepDesc = keepTarget
2454
+ ? `回退到第 ${turnNum - 1} 轮:"${keepTarget.userContent}"`
2455
+ : '已清空全部对话历史';
2456
+ results.push(`✅ 已撤销第 ${turnNum} 轮${discarded > 1 ? `及后续共 ${discarded} 轮` : ''}`, keepTarget ? `下次发言将从第 ${turnNum - 1} 轮继续` : '下次发言将开始全新对话');
2457
+ }
2458
+ this.eventBus.publish({
2459
+ type: 'session:rewind',
2460
+ sessionId: session.id,
2461
+ turnNum,
2462
+ mode,
2463
+ });
2464
+ return results.join('\n');
2465
+ }
2466
+ catch (error) {
2467
+ logger.error('[CommandHandler] Rewind failed:', error);
2468
+ return `❌ 回退失败: ${error instanceof Error ? error.message : '未知错误'}`;
2469
+ }
2470
+ }
2471
+ buildTurnList(messages) {
2472
+ const turns = [];
2473
+ let pendingUser = null;
2474
+ for (const msg of messages) {
2475
+ if (msg.type === 'user') {
2476
+ const m = msg.message;
2477
+ if (Array.isArray(m?.content) && m.content.every((c) => c.type === 'tool_result')) {
2478
+ continue;
2479
+ }
2480
+ const content = this.extractUserContent(msg.message);
2481
+ if (content) {
2482
+ pendingUser = { content, uuid: msg.uuid };
2483
+ }
2484
+ }
2485
+ else if (msg.type === 'assistant' && pendingUser) {
2486
+ turns.push({
2487
+ index: turns.length + 1,
2488
+ userContent: pendingUser.content,
2489
+ userUuid: pendingUser.uuid,
2490
+ assistantUuid: msg.uuid,
2491
+ });
2492
+ pendingUser = null;
2493
+ }
2494
+ }
2495
+ return turns;
2496
+ }
2497
+ // ── Agent Ctl ──
2498
+ static CTL_COMMANDS = [
2499
+ '/help', '/status', '/check',
2500
+ '/model', '/effort', '/perm',
2501
+ '/compact', '/activity', '/send', '/restart', '/agentmd',
2502
+ ];
2503
+ /**
2504
+ * Agent ctl 入口:通过 IPC 接收 Agent 自主管理指令
2505
+ * 复用现有 slash cmd 逻辑,权限继承 session 用户角色
2506
+ */
2507
+ async handleCtl(cmd, sessionId) {
2508
+ // 1. 白名单检查
2509
+ const inputCmd = cmd.split(' ')[0];
2510
+ if (!CommandHandler.CTL_COMMANDS.includes(inputCmd)) {
2511
+ return { ok: false, error: `不允许的指令: ${inputCmd}` };
2512
+ }
2513
+ // 2. 通过 sessionId 查 session
2514
+ const session = await this.sessionManager.getSessionById(sessionId);
2515
+ if (!session) {
2516
+ return { ok: false, error: '无效的 session' };
2517
+ }
2518
+ // 3. 从 session.metadata.peerId 获取 userId(用于权限判断)
2519
+ const userId = session.metadata?.peerId;
2520
+ // 4. send 路径限制:只允许 projectPath 下的文件
2521
+ if (cmd.startsWith('/send')) {
2522
+ const sendArgs = cmd.slice(5).trim();
2523
+ const parts = sendArgs.split(/\s+/);
2524
+ const filePath = parts[parts.length - 1];
2525
+ if (filePath) {
2526
+ const resolved = path.resolve(session.projectPath, filePath);
2527
+ if (!resolved.startsWith(session.projectPath)) {
2528
+ return { ok: false, error: '路径越界:只能发送项目目录下的文件' };
2529
+ }
2530
+ }
2531
+ }
2532
+ // 5. 调用现有 handle(),不传 sendMessage 回调(结果直接返回)
2533
+ try {
2534
+ const result = await this.handle(cmd, session.channel, session.channelId, undefined, // 不发送消息
2535
+ userId);
2536
+ return { ok: true, result: result ?? '(无输出)' };
2537
+ }
2538
+ catch (err) {
2539
+ return { ok: false, error: err.message };
2540
+ }
2541
+ }
2542
+ extractUserContent(message) {
2543
+ const m = message;
2544
+ let text = '';
2545
+ if (typeof m?.content === 'string') {
2546
+ text = m.content;
2547
+ }
2548
+ else if (Array.isArray(m?.content)) {
2549
+ text = m.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ');
2550
+ }
2551
+ text = text.trim().replace(/\s+/g, ' ');
2552
+ if (!text)
2553
+ return '';
2554
+ return text.length > 50 ? text.substring(0, 50) + '…' : text;
2555
+ }
2206
2556
  }