evolclaw 2.1.2 → 2.2.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 (42) hide show
  1. package/README.md +10 -3
  2. package/data/evolclaw.sample.json +9 -1
  3. package/dist/agents/claude-runner.js +612 -0
  4. package/dist/agents/codex-runner.js +310 -0
  5. package/dist/channels/aun.js +416 -9
  6. package/dist/channels/feishu.js +397 -104
  7. package/dist/channels/wechat.js +84 -2
  8. package/dist/cli.js +427 -126
  9. package/dist/config.js +102 -4
  10. package/dist/core/adapters/claude-session-file-adapter.js +144 -0
  11. package/dist/core/adapters/codex-session-file-adapter.js +196 -0
  12. package/dist/core/agent-loader.js +39 -0
  13. package/dist/core/channel-loader.js +60 -0
  14. package/dist/core/command-handler.js +908 -304
  15. package/dist/core/event-bus.js +32 -0
  16. package/dist/core/ipc-server.js +71 -0
  17. package/dist/core/message-bridge.js +187 -0
  18. package/dist/core/message-processor.js +370 -227
  19. package/dist/core/message-queue.js +153 -29
  20. package/dist/core/permission.js +58 -0
  21. package/dist/core/session-file-adapter.js +7 -0
  22. package/dist/core/session-manager.js +567 -205
  23. package/dist/core/stats-collector.js +86 -0
  24. package/dist/index.js +309 -243
  25. package/dist/paths.js +1 -0
  26. package/dist/utils/init-feishu.js +2 -0
  27. package/dist/utils/init-wechat.js +2 -0
  28. package/dist/utils/init.js +285 -53
  29. package/dist/utils/ipc-client.js +36 -0
  30. package/dist/utils/migrate-project.js +122 -0
  31. package/dist/utils/{permission.js → permission-utils.js} +31 -3
  32. package/dist/utils/rich-content-renderer.js +228 -0
  33. package/dist/utils/session-file-health.js +11 -34
  34. package/dist/utils/stream-debouncer.js +122 -0
  35. package/dist/utils/stream-idle-monitor.js +1 -1
  36. package/package.json +3 -1
  37. package/dist/core/agent-runner.js +0 -348
  38. package/dist/core/message-stream.js +0 -59
  39. package/dist/index.js.bak +0 -340
  40. package/dist/utils/markdown-to-feishu.js +0 -94
  41. /package/dist/utils/{platform.js → cross-platform.js} +0 -0
  42. /package/dist/{core → utils}/message-cache.js +0 -0
@@ -1,10 +1,9 @@
1
- import { renameSession as sdkRenameSession, forkSession as sdkForkSession, listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk';
2
- import { resolvePaths, getPackageRoot } from '../config.js';
1
+ import { hasModelSwitcher, hasPermissionController } from '../agents/claude-runner.js';
2
+ import { saveConfig, resolvePaths, getPackageRoot, getOwner } from '../config.js';
3
3
  import { logger } from '../utils/logger.js';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
6
6
  import os from 'os';
7
- const availableModels = ['opus', 'sonnet', 'haiku', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'];
8
7
  const availableEfforts = ['low', 'medium', 'high', 'max'];
9
8
  function effortBar(level) {
10
9
  const levels = {
@@ -85,30 +84,48 @@ function formatIdleTime(ms) {
85
84
  return '刚刚';
86
85
  }
87
86
  // 支持的命令列表
88
- const commands = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/slist', '/session', '/rename', '/stop', '/clear', '/compact', '/repair', '/safe', '/fork', '/del'];
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'];
89
88
  // 命令别名映射
90
89
  const aliases = {
91
90
  '/p': '/project',
92
91
  '/s': '/session',
93
92
  '/name': '/rename'
94
93
  };
95
- // 命令快速路径前缀(不进入消息队列的命令)
96
- // 注意:/clear, /compact, /safe 故意不在此列表中,它们需要进入队列触发中断机制
97
- // /stop 是快速命令:直接调用 agentRunner.interrupt(),不走队列(否则队列自动中断后 /stop 检测不到活跃任务)
98
- const quickCommandPrefixes = ['/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart', '/model', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/del', '/p ', '/s ', '/name '];
94
+ // 命令快速路径前缀(所有命令都不进入消息队列)
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 '];
99
96
  export class CommandHandler {
100
97
  sessionManager;
101
- agentRunner;
102
98
  config;
103
99
  messageCache;
100
+ eventBus;
104
101
  adapters = new Map();
102
+ policies = new Map();
103
+ channelObjects = new Map(); // name → actual channel instance (for /check)
105
104
  processor;
106
105
  messageQueue;
107
- constructor(sessionManager, agentRunner, config, messageCache) {
106
+ permissionGateway;
107
+ statsCollector;
108
+ agentMap;
109
+ defaultAgentId;
110
+ /** 按 agentId 获取 agent,回退到默认 */
111
+ getAgent(agentId) {
112
+ if (agentId && this.agentMap.has(agentId))
113
+ return this.agentMap.get(agentId);
114
+ return this.agentMap.get(this.defaultAgentId) || this.agentMap.values().next().value;
115
+ }
116
+ constructor(sessionManager, agentRunnerOrMap, config, messageCache, eventBus, defaultAgentId) {
108
117
  this.sessionManager = sessionManager;
109
- this.agentRunner = agentRunner;
110
118
  this.config = config;
111
119
  this.messageCache = messageCache;
120
+ this.eventBus = eventBus;
121
+ if (agentRunnerOrMap instanceof Map) {
122
+ this.agentMap = agentRunnerOrMap;
123
+ this.defaultAgentId = defaultAgentId || 'claude';
124
+ }
125
+ else {
126
+ this.agentMap = new Map([[agentRunnerOrMap.name, agentRunnerOrMap]]);
127
+ this.defaultAgentId = agentRunnerOrMap.name;
128
+ }
112
129
  }
113
130
  /** 项目列表快捷访问 */
114
131
  get projects() {
@@ -122,22 +139,37 @@ export class CommandHandler {
122
139
  getProjectName(projectPath) {
123
140
  return this.getConfiguredProjectName(projectPath) || path.basename(projectPath);
124
141
  }
142
+ /** 格式化运行时间 */
143
+ formatUptime(ms) {
144
+ const sec = Math.floor(ms / 1000);
145
+ const d = Math.floor(sec / 86400);
146
+ const h = Math.floor((sec % 86400) / 3600);
147
+ const m = Math.floor((sec % 3600) / 60);
148
+ const parts = [];
149
+ if (d > 0)
150
+ parts.push(`${d}天`);
151
+ if (h > 0)
152
+ parts.push(`${h}时`);
153
+ parts.push(`${m}分`);
154
+ return parts.join('');
155
+ }
125
156
  /** 获取消息队列 key:话题用 session.id,主会话用 channel-channelId */
126
- getQueueKey(session, channel, channelId) {
127
- if (session?.threadId)
128
- return session.id;
129
- return `${channel}-${channelId}`;
157
+ getQueueKey(session, _channel, _channelId) {
158
+ // 队列和 agent 均使用 session.id 作为 key
159
+ return session?.id || '';
130
160
  }
131
- /** 从 session 提取话题回复选项 */
132
- getThreadSendOpts(session) {
133
- const rootId = session.metadata?.feishu?.rootId;
134
- return rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined;
161
+ /** 从 session 提取渠道预构建的回复上下文 */
162
+ getReplyContext(session) {
163
+ return session.metadata?.replyContext;
135
164
  }
136
165
  /** 获取活跃会话,无会话时返回统一错误提示 */
137
166
  async ensureSession(channel, channelId, threadId) {
138
167
  if (threadId) {
139
- // 话题会话:按 thread_id 查找
140
- const session = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
168
+ // 话题会话:仅查询,不创建
169
+ const session = await this.sessionManager.getThreadSession(channel, channelId, threadId);
170
+ if (!session) {
171
+ return { error: '❌ 话题中尚未创建会话\n发送消息后自动创建' };
172
+ }
141
173
  return { session };
142
174
  }
143
175
  const session = await this.sessionManager.getActiveSession(channel, channelId);
@@ -152,12 +184,112 @@ export class CommandHandler {
152
184
  setMessageQueue(messageQueue) {
153
185
  this.messageQueue = messageQueue;
154
186
  }
187
+ setPermissionGateway(gateway) {
188
+ this.permissionGateway = gateway;
189
+ }
190
+ setStatsCollector(collector) {
191
+ this.statsCollector = collector;
192
+ }
155
193
  registerAdapter(adapter) {
156
194
  this.adapters.set(adapter.name, adapter);
157
195
  }
196
+ registerChannel(name, channel) {
197
+ this.channelObjects.set(name, channel);
198
+ }
199
+ registerPolicy(channelName, policy) {
200
+ this.policies.set(channelName, policy);
201
+ }
158
202
  getAdapter(channelName) {
159
203
  return this.adapters.get(channelName);
160
204
  }
205
+ getPolicy(channel) {
206
+ return this.policies.get(channel) || {
207
+ canSwitchProject: () => true,
208
+ canListProjects: () => true,
209
+ canCreateSession: () => true,
210
+ canDeleteSession: () => true,
211
+ canImportCliSession: () => true,
212
+ messagePrefix: () => '',
213
+ showMiddleResult: () => true,
214
+ showIdleMonitor: () => true,
215
+ accumulateErrors: () => true,
216
+ };
217
+ }
218
+ /**
219
+ * 返回结构化命令菜单(供 menu.query 使用)
220
+ * admin 看到全部命令分组,guest 仅看到用户级命令
221
+ */
222
+ getMenuItems(isAdmin) {
223
+ const items = [];
224
+ if (isAdmin) {
225
+ items.push({
226
+ group: '项目管理',
227
+ commands: [
228
+ { cmd: '/pwd', label: '显示当前项目路径' },
229
+ { cmd: '/plist', label: '列出所有配置的项目' },
230
+ { cmd: '/p', args: '<name|path>', label: '切换项目' },
231
+ { cmd: '/bind', args: '<path>', label: '绑定新项目目录' },
232
+ ]
233
+ });
234
+ }
235
+ items.push({
236
+ group: '会话管理',
237
+ commands: [
238
+ { cmd: '/new', args: '[name]', label: '创建新会话' },
239
+ { cmd: '/slist', label: '列出当前项目的所有会话' },
240
+ { cmd: '/s', args: '<name|index|uuid>', label: '切换到指定会话' },
241
+ { cmd: '/name', args: '<name>', label: '重命名当前会话' },
242
+ { cmd: '/del', args: '<name>', label: '删除指定会话' },
243
+ ...(isAdmin ? [
244
+ { cmd: '/fork', args: '[name]', label: '分支当前会话' },
245
+ { cmd: '/clear', label: '清空会话对话历史' },
246
+ { cmd: '/compact', label: '压缩会话上下文' },
247
+ ] : []),
248
+ ]
249
+ });
250
+ if (isAdmin) {
251
+ items.push({
252
+ group: 'Agent 与模型',
253
+ commands: [
254
+ { cmd: '/agent', args: '[name]', label: '查看或切换 Agent 后端' },
255
+ { cmd: '/model', args: '[model] [effort]', label: '查看或切换模型' },
256
+ ]
257
+ });
258
+ items.push({
259
+ group: '权限管理',
260
+ commands: [
261
+ { cmd: '/perm', args: '[mode|allow|deny]', label: '权限模式管理' },
262
+ ]
263
+ });
264
+ items.push({
265
+ group: '运维',
266
+ commands: [
267
+ { cmd: '/status', label: '显示会话状态' },
268
+ { cmd: '/stop', label: '中断当前任务' },
269
+ { cmd: '/restart', label: '重启服务' },
270
+ { cmd: '/repair', label: '检查并修复会话' },
271
+ { cmd: '/safe', label: '进入安全模式' },
272
+ { cmd: '/send', args: '[渠道] <path>', label: '发送项目内文件' },
273
+ { cmd: '/check', args: '[rty <channel>]', label: '检查渠道状态或重连指定渠道' },
274
+ ]
275
+ });
276
+ }
277
+ else {
278
+ items.push({
279
+ group: '其他',
280
+ commands: [
281
+ { cmd: '/status', label: '显示会话状态' },
282
+ ]
283
+ });
284
+ }
285
+ items.push({
286
+ group: '帮助',
287
+ commands: [
288
+ { cmd: '/help', label: '显示帮助信息' },
289
+ ]
290
+ });
291
+ return items;
292
+ }
161
293
  /**
162
294
  * 快速判断是否为命令(不进队列的命令)
163
295
  */
@@ -168,6 +300,12 @@ export class CommandHandler {
168
300
  * 主命令处理入口
169
301
  */
170
302
  async handle(content, channel, channelId, sendMessage, userId, threadId) {
303
+ // 解析身份
304
+ const identity = this.sessionManager.resolveIdentity(channel, userId);
305
+ const policy = this.getPolicy(channel);
306
+ // 按当前会话选择 agent 后端
307
+ const activeSession = await this.sessionManager.getActiveSession(channel, channelId);
308
+ const agent = this.getAgent(activeSession?.agentId);
171
309
  // 规范化命令(将别名转换为完整命令)
172
310
  let normalizedContent = content;
173
311
  for (const [alias, full] of Object.entries(aliases)) {
@@ -176,16 +314,19 @@ export class CommandHandler {
176
314
  break;
177
315
  }
178
316
  }
179
- // 权限检查:区分用户级命令和管理级命令
180
- const { isOwner: checkOwner } = await import('../config.js');
317
+ if (normalizedContent !== content) {
318
+ logger.debug(`[CommandHandler] normalized: "${content}" -> "${normalizedContent}"`);
319
+ }
181
320
  // 话题内禁用部分命令
182
321
  if (threadId) {
183
- const threadBlocked = ['/new', '/slist', '/plist', '/bind', '/s', '/session', '/project', '/p', '/fork', '/del'];
322
+ const threadBlocked = ['/new', '/slist', '/plist', '/bind', '/s', '/session', '/project', '/p', '/fork', '/del', '/agent'];
184
323
  const isBlocked = threadBlocked.some(c => normalizedContent === c || normalizedContent.startsWith(c + ' '));
185
- if (isBlocked)
324
+ if (isBlocked) {
186
325
  return '⚠️ 话题中不支持此命令';
326
+ }
187
327
  }
188
- const isAdmin = !userId || checkOwner(this.config, channel, userId);
328
+ // 权限检查:区分用户级命令和管理级命令
329
+ const isAdmin = identity.role === 'owner';
189
330
  if (normalizedContent.startsWith('/')) {
190
331
  const userCommands = ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
191
332
  const isUserCommand = userCommands.some(cmd => normalizedContent === cmd.trimEnd() || normalizedContent.startsWith(cmd));
@@ -193,6 +334,23 @@ export class CommandHandler {
193
334
  return '❌ 无权限:此命令仅限管理员使用';
194
335
  }
195
336
  }
337
+ // 空闲检查:某些命令需要等待当前会话空闲
338
+ const requiresIdle = ['/new', '/clear', '/compact', '/safe', '/repair', '/fork', '/bind'];
339
+ if (requiresIdle.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' '))) {
340
+ if (threadId) {
341
+ // 话题中:检查话题 session 是否在处理(不创建)
342
+ const threadSession = await this.sessionManager.getThreadSession(channel, channelId, threadId);
343
+ if (threadSession) {
344
+ const threadAgent = this.getAgent(threadSession.agentId);
345
+ if (threadAgent.hasActiveStream(threadSession.id)) {
346
+ return '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试';
347
+ }
348
+ }
349
+ }
350
+ else if (activeSession && agent.hasActiveStream(activeSession.id)) {
351
+ return '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试';
352
+ }
353
+ }
196
354
  // 检查是否以 / 开头(可能是命令)
197
355
  if (normalizedContent.startsWith('/')) {
198
356
  const inputCmd = normalizedContent.split(' ')[0];
@@ -216,56 +374,171 @@ export class CommandHandler {
216
374
  // /help 命令不需要会话
217
375
  if (normalizedContent === '/help') {
218
376
  if (!isAdmin) {
219
- return `可用命令:
220
- 🔄 会话管理:
221
- /new [名称] - 创建新会话(可选命名)
222
- /slist - 列出当前项目的所有会话
223
- /s, /session <名称> - 切换到指定会话
224
- /name, /rename <新名称> - 重命名当前会话
225
- /del <名称> - 删除指定会话(仅解绑,不删除文件)
226
- /status - 显示会话状态
227
-
228
- 帮助:
229
- /help - 显示此帮助信息`;
230
- }
231
- return `可用命令:
232
- 📁 项目管理:
233
- /pwd - 显示当前项目路径
234
- /plist - 列出所有配置的项目
235
- /p, /project <name|path> - 切换项目
236
- /bind <path> - 绑定新项目目录
237
-
238
- 🔄 会话管理:
239
- /new [名称] - 创建新会话(可选命名)
240
- /slist - 列出当前项目的所有会话
241
- /s, /session <名称> - 切换到指定会话
242
- /name, /rename <新名称> - 重命名当前会话
243
- /del <名称> - 删除指定会话(仅解绑,不删除文件)
244
- /fork [名称] - 分支当前会话(从当前对话点创建分支)
245
- /status - 显示会话状态
246
- /clear - 清空当前会话的对话历史
247
- /compact - 压缩会话上下文(减少 token 用量)
248
- /stop - 中断当前任务
249
- /restart - 重启服务
250
-
251
- 🛠️ 会话修复:
252
- /repair - 检查并修复会话
253
- /safe - 进入安全模式
254
-
255
- 🤖 模型管理:
256
- /model [model] [effort] - 查看或切换模型/推理强度
257
-
258
- 帮助:
259
- /help - 显示此帮助信息`;
377
+ const lines = [
378
+ '可用命令:',
379
+ '',
380
+ '🔄 会话管理:',
381
+ ' /new [名称] - 创建新会话(可选命名)',
382
+ ' /slist - 列出当前项目的所有会话',
383
+ ' /s, /session <名称|序号|uuid> - 切换到指定会话',
384
+ ' /name, /rename <新名称> - 重命名当前会话',
385
+ ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
386
+ ' /status - 显示会话状态',
387
+ '',
388
+ '❓ 帮助:',
389
+ ' /help - 显示此帮助信息',
390
+ ];
391
+ return lines.join('\n');
392
+ }
393
+ const lines = [
394
+ '可用命令:',
395
+ '',
396
+ '📁 项目管理:',
397
+ ' /pwd - 显示当前项目路径',
398
+ ' /plist - 列出所有配置的项目',
399
+ ' /p, /project <name|path> - 切换项目',
400
+ ' /bind <path> - 绑定新项目目录',
401
+ '',
402
+ '🔄 会话管理:',
403
+ ' /new [名称] - 创建新会话(可选命名)',
404
+ ' /slist - 列出当前项目的所有会话',
405
+ ' /s, /session <名称> - 切换到指定会话',
406
+ ' /name, /rename <新名称> - 重命名当前会话',
407
+ ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
408
+ ' /fork [名称] - 分支当前会话(从当前对话点创建分支)',
409
+ ' /clear - 清空当前会话的对话历史',
410
+ ' /compact - 压缩会话上下文(减少 token 用量)',
411
+ '',
412
+ '🤖 Agent 与模型:',
413
+ ' /agent [name] - 查看或切换 Agent 后端',
414
+ ' /model [model] [effort] - 查看或切换模型/推理强度',
415
+ '',
416
+ '🔐 权限管理:',
417
+ ' /perm - 查看当前权限模式',
418
+ ' /perm <default|request|edit|plan|noask> - 切换权限模式',
419
+ ' /perm allow|deny - 审批权限请求',
420
+ '',
421
+ '🛠️ 运维:',
422
+ ' /status - 显示会话状态',
423
+ ' /stop - 中断当前任务',
424
+ ' /restart - 重启服务',
425
+ ' /repair - 检查并修复会话',
426
+ ' /safe - 进入安全模式',
427
+ ' /send [渠道] <路径> - 发送项目内文件',
428
+ '',
429
+ '❓ 帮助:',
430
+ ' /help - 显示此帮助信息',
431
+ ];
432
+ return lines.join('\n');
433
+ }
434
+ // /perm 命令:权限模式切换 + 权限审批(快速路径,不进入消息队列)
435
+ if (normalizedContent.startsWith('/perm')) {
436
+ const args = normalizedContent.slice(5).trim();
437
+ // 先获取正确的 session 和 agent(话题可能用不同 agent)
438
+ const permResult = await this.ensureSession(channel, channelId, threadId);
439
+ if ('error' in permResult)
440
+ return permResult.error;
441
+ const { session: permSession } = permResult;
442
+ const permAgent = this.getAgent(permSession.agentId);
443
+ // /perm(无参数):显示当前模式和可选模式
444
+ if (!args) {
445
+ if (!hasPermissionController(permAgent)) {
446
+ return '❌ 权限控制不可用';
447
+ }
448
+ const currentMode = permSession.metadata?.permissionMode || 'default';
449
+ const modes = permAgent.listModes();
450
+ const modeList = modes.map(m => {
451
+ const prefix = m.key === currentMode ? '▶' : ' ';
452
+ const suffix = m.available ? '' : ' ⚠️ 不可用';
453
+ return ` ${prefix} ${m.key} (${m.nameZh}) - ${m.description}${suffix}`;
454
+ }).join('\n');
455
+ return `🔐 当前权限模式: ${currentMode}\n\n${modeList}\n\n用法:\n /perm <模式> 切换权限模式\n /perm allow|deny 审批权限请求`;
456
+ }
457
+ const parts = args.split(/\s+/);
458
+ // /perm <mode> 或 /perm allow|deny:切换模式 / 快捷审批
459
+ if (parts.length === 1) {
460
+ const arg = parts[0];
461
+ // /perm allow|deny:快捷审批(自动找当前 session 唯一的 pending 请求)
462
+ if (arg === 'allow' || arg === 'deny') {
463
+ if (!this.permissionGateway) {
464
+ return '❌ 权限审批未启用';
465
+ }
466
+ const pendingIds = this.permissionGateway.getPendingRequests(permSession.id);
467
+ if (pendingIds.length === 0) {
468
+ return '❌ 当前没有待审批的权限请求';
469
+ }
470
+ if (pendingIds.length > 1) {
471
+ return `❌ 当前有 ${pendingIds.length} 个待审批请求,请指定 requestId:\n${pendingIds.map(id => ` /perm ${id} ${arg}`).join('\n')}`;
472
+ }
473
+ const requestId = pendingIds[0];
474
+ this.permissionGateway.resolvePermission(permSession.id, requestId, arg === 'allow');
475
+ return arg === 'allow' ? `✓ 已授权,继续执行……` : `✓ 已拒绝`;
476
+ }
477
+ // /perm <mode>:切换权限模式
478
+ if (hasPermissionController(permAgent)) {
479
+ const modes = permAgent.listModes();
480
+ const matched = modes.find(m => m.key === arg);
481
+ if (matched) {
482
+ if (!matched.available) {
483
+ return `❌ ${matched.key} 模式当前不可用:${matched.unavailableReason}`;
484
+ }
485
+ const metadata = permSession.metadata || {};
486
+ metadata.permissionMode = arg;
487
+ await this.sessionManager.updateSession(permSession.id, { metadata });
488
+ return `✓ 权限模式已切换为: ${matched.key} (${matched.nameZh})\n${matched.description}`;
489
+ }
490
+ }
491
+ // 不是已知模式名也不是 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`;
494
+ }
495
+ // 双参数不再支持,提示正确用法
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`;
498
+ }
499
+ // /agent 命令:查看或切换 Agent 后端
500
+ if (normalizedContent.startsWith('/agent')) {
501
+ if (!isAdmin)
502
+ return '❌ 无权限:此命令仅限管理员使用';
503
+ const args = normalizedContent.slice(6).trim();
504
+ const available = [...this.agentMap.keys()];
505
+ if (!args) {
506
+ const currentAgent = activeSession?.agentId || this.defaultAgentId;
507
+ const list = available.map(a => `${a === currentAgent ? '✓' : '-'} ${a}`).join('\n');
508
+ return `当前 Agent: ${currentAgent}\n\n可用:\n${list}\n\n用法: /agent <name>`;
509
+ }
510
+ if (!this.agentMap.has(args)) {
511
+ return `❌ 未知 Agent: ${args}\n可用: ${available.join(', ')}`;
512
+ }
513
+ const result = await this.ensureSession(channel, channelId, threadId);
514
+ if ('error' in result)
515
+ return result.error;
516
+ const { session } = result;
517
+ // 取消原会话的 pending 权限请求
518
+ if (this.permissionGateway) {
519
+ this.permissionGateway.cancelAll(session.id);
520
+ }
521
+ // 切换到目标 agent(恢复已有会话或创建新会话)
522
+ const newSession = await this.sessionManager.switchAgent(channel, channelId, session.projectPath, args);
523
+ const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
524
+ const projectName = this.getProjectName(session.projectPath);
525
+ return `✓ 已切换 Agent: ${args}\n 项目: ${projectName}\n 会话: ${newSession.name || '(未命名)'}\n ${hasExistingSession}`;
260
526
  }
261
527
  // /model 命令:查看或切换模型/推理强度
262
528
  if (normalizedContent.startsWith('/model')) {
263
529
  const args = normalizedContent.slice(6).trim();
530
+ // 获取当前会话(话题会话可能绑定不同 agent)
531
+ const modelResult = await this.ensureSession(channel, channelId, threadId);
532
+ if ('error' in modelResult)
533
+ return modelResult.error;
534
+ const { session: modelSession } = modelResult;
535
+ const modelAgent = this.getAgent(modelSession.agentId);
536
+ const models = hasModelSwitcher(modelAgent) ? modelAgent.listModels() : [];
264
537
  if (!args) {
265
- const currentModel = this.agentRunner.getModel();
266
- const currentEffort = this.agentRunner.getEffort() || 'auto';
538
+ const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
539
+ const currentEffort = modelAgent.getEffort?.() || 'auto';
267
540
  const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : `${currentEffort} ${effortBar(currentEffort)}`;
268
- const modelList = availableModels.map(m => `- ${m}`).join('\n');
541
+ const modelList = models.map((m) => `- ${m}`).join('\n');
269
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默认`;
270
543
  }
271
544
  const parts = args.split(/\s+/);
@@ -274,34 +547,49 @@ export class CommandHandler {
274
547
  if (parts.length === 1) {
275
548
  const arg = parts[0];
276
549
  if (arg === 'auto') {
277
- // 清除 effort,恢复 SDK 默认
278
- const result = await this.ensureSession(channel, channelId, threadId);
279
- if ('error' in result)
280
- return result.error;
281
- const { session } = result;
282
- const writeResult = writeUserSettings({ effortLevel: null });
283
- if (!writeResult.success) {
284
- return `⚠️ 写入用户配置失败: ${writeResult.error}\n已更新运行时配置,但未持久化到 ~/.claude/settings.json`;
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
+ }
285
574
  }
286
- this.agentRunner.setEffort(undefined);
287
575
  return '✓ 推理强度已恢复为 auto (SDK默认)';
288
576
  }
289
577
  // 单参数:模型 或 effort
290
578
  if (availableEfforts.includes(arg)) {
291
579
  newEffort = arg;
292
580
  }
293
- else if (availableModels.includes(arg)) {
581
+ else if (models.includes(arg)) {
294
582
  newModel = arg;
295
583
  }
296
584
  else {
297
- const modelList = availableModels.map(m => `- ${m}`).join('\n');
585
+ const modelList = models.map((m) => `- ${m}`).join('\n');
298
586
  return `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}\n\n推理强度: ${availableEfforts.join(' / ')}`;
299
587
  }
300
588
  }
301
589
  else {
302
590
  // 双参数:model effort
303
591
  const [modelArg, effortArg] = parts;
304
- if (!availableModels.includes(modelArg)) {
592
+ if (!models.includes(modelArg)) {
305
593
  return `❌ 无效的模型ID: ${modelArg}`;
306
594
  }
307
595
  if (!availableEfforts.includes(effortArg)) {
@@ -312,53 +600,109 @@ export class CommandHandler {
312
600
  }
313
601
  if (!this.config.agents)
314
602
  this.config.agents = {};
315
- if (!this.config.agents.anthropic)
316
- this.config.agents.anthropic = {};
317
- // 获取当前会话的项目路径
318
- const result = await this.ensureSession(channel, channelId, threadId);
319
- if ('error' in result)
320
- return result.error;
321
- const { session } = result;
603
+ const isCodexAgent = modelAgent.name === 'codex';
322
604
  const changes = [];
323
- const updates = {};
324
605
  if (newModel) {
325
- updates.model = newModel;
326
- this.agentRunner.setModel(newModel);
606
+ modelAgent.setModel?.(newModel);
607
+ this.eventBus.publish({
608
+ type: 'agent:model-changed',
609
+ sessionId: modelSession.id,
610
+ model: newModel,
611
+ timestamp: Date.now()
612
+ });
327
613
  changes.push(`模型: ${newModel}`);
328
614
  }
329
615
  if (newEffort) {
330
- const modelAfterSwitch = newModel ?? this.agentRunner.getModel();
616
+ const modelAfterSwitch = newModel ?? (hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name);
331
617
  if (newEffort === 'max' && !modelAfterSwitch.includes('opus')) {
332
618
  return '⚠️ max 推理强度仅 Opus 模型支持(opus / claude-opus-4-6)';
333
619
  }
334
- updates.effortLevel = newEffort;
335
- this.agentRunner.setEffort(newEffort);
620
+ modelAgent.setEffort?.(newEffort);
336
621
  changes.push(`推理强度: ${newEffort} ${effortBar(newEffort)}`);
337
622
  }
338
- // 写入用户级 ~/.claude/settings.json(与 Claude CLI 行为一致)
339
- const writeResult = writeUserSettings(updates);
340
- if (!writeResult.success) {
341
- return `⚠️ 写入用户配置失败: ${writeResult.error}\n已更新运行时配置,但未持久化到 ~/.claude/settings.json`;
623
+ // 持久化:写回来源(就近原则)
624
+ // evolclaw.json 配了 → 写 evolclaw.json
625
+ // evolclaw.json 没配 → 写 agent 全局配置
626
+ if (isCodexAgent) {
627
+ const configuredInEvolclaw = !!(this.config.agents?.openai?.model || this.config.agents?.openai?.reasoning);
628
+ if (configuredInEvolclaw) {
629
+ if (!this.config.agents.openai)
630
+ this.config.agents.openai = {};
631
+ if (newModel)
632
+ this.config.agents.openai.model = newModel;
633
+ if (newEffort)
634
+ this.config.agents.openai.reasoning = newEffort;
635
+ try {
636
+ saveConfig(this.config);
637
+ }
638
+ catch (error) {
639
+ return `⚠️ 写入 evolclaw.json 失败: ${error.message}\n已更新运行时配置,但未持久化`;
640
+ }
641
+ }
642
+ else {
643
+ // Codex 全局配置(~/.codex/config.toml)目前不支持写入,回退到 evolclaw.json
644
+ if (!this.config.agents.openai)
645
+ this.config.agents.openai = {};
646
+ if (newModel)
647
+ this.config.agents.openai.model = newModel;
648
+ if (newEffort)
649
+ this.config.agents.openai.reasoning = newEffort;
650
+ try {
651
+ saveConfig(this.config);
652
+ }
653
+ catch (error) {
654
+ return `⚠️ 写入 evolclaw.json 失败: ${error.message}\n已更新运行时配置,但未持久化`;
655
+ }
656
+ }
657
+ }
658
+ else {
659
+ const configuredInEvolclaw = !!(this.config.agents?.anthropic?.model || this.config.agents?.anthropic?.effort);
660
+ if (configuredInEvolclaw) {
661
+ if (!this.config.agents.anthropic)
662
+ this.config.agents.anthropic = {};
663
+ if (newModel)
664
+ this.config.agents.anthropic.model = newModel;
665
+ if (newEffort)
666
+ this.config.agents.anthropic.effort = newEffort;
667
+ try {
668
+ saveConfig(this.config);
669
+ }
670
+ catch (error) {
671
+ return `⚠️ 写入 evolclaw.json 失败: ${error.message}\n已更新运行时配置,但未持久化`;
672
+ }
673
+ }
674
+ else {
675
+ const updates = {};
676
+ if (newModel)
677
+ updates.model = newModel;
678
+ if (newEffort)
679
+ updates.effortLevel = newEffort;
680
+ const writeResult = writeUserSettings(updates);
681
+ if (!writeResult.success) {
682
+ return `⚠️ 写入用户配置失败: ${writeResult.error}\n已更新运行时配置,但未持久化到 ~/.claude/settings.json`;
683
+ }
684
+ }
342
685
  }
343
686
  return `✓ 已切换\n ${changes.join('\n ')}`;
344
687
  }
345
688
  // /stop 命令:中断当前任务
346
689
  if (normalizedContent === '/stop') {
347
- // 话题使用 session.id 作为队列 key,主会话使用 channel-channelId
348
- let sessionKey;
349
- if (threadId) {
350
- const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
351
- sessionKey = threadSession.id;
352
- }
353
- else {
354
- sessionKey = `${channel}-${channelId}`;
355
- }
690
+ const stopResult = await this.ensureSession(channel, channelId, threadId);
691
+ if ('error' in stopResult)
692
+ return '当前没有正在处理的任务';
693
+ const { session: stopSession } = stopResult;
694
+ const stopAgent = this.getAgent(stopSession.agentId);
695
+ const sessionKey = stopSession.id;
356
696
  const queueLength = this.messageQueue.getQueueLength(sessionKey);
357
- const hasActive = this.agentRunner.hasActiveStream(sessionKey);
697
+ const hasActive = stopAgent.hasActiveStream(sessionKey);
358
698
  if (queueLength === 0 && !hasActive) {
359
699
  return '当前没有正在处理的任务';
360
700
  }
361
- await this.agentRunner.interrupt(sessionKey);
701
+ await stopAgent.interrupt(sessionKey);
702
+ // 发布中断事件,让 MessageProcessor 标记为 interrupted(而非 done)
703
+ this.eventBus.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'stop' });
704
+ // 强制清除 processing_state
705
+ this.sessionManager.clearProcessing(sessionKey);
362
706
  return '✓ 已发送中断信号,任务将尽快停止';
363
707
  }
364
708
  // /clear 命令:通过 SDK /clear 清空会话历史
@@ -367,20 +711,30 @@ export class CommandHandler {
367
711
  if ('error' in result)
368
712
  return result.error;
369
713
  const { session } = result;
714
+ const sessionAgent = this.getAgent(session.agentId);
715
+ if (!sessionAgent.capabilities?.clear) {
716
+ return `❌ 当前 Agent (${sessionAgent.name}) 不支持 /clear\n\n可使用 /new 创建新会话替代`;
717
+ }
370
718
  if (!session.agentSessionId) {
371
719
  return '❌ 当前会话没有历史记录,无需清空';
372
720
  }
373
721
  const projectPath = path.isAbsolute(session.projectPath)
374
722
  ? session.projectPath
375
723
  : path.resolve(process.cwd(), session.projectPath);
376
- const cleared = await this.agentRunner.clearSession(session.agentSessionId, projectPath);
377
- if (cleared) {
378
- await this.sessionManager.updateAgentSessionIdBySessionId(session.id, '');
379
- this.agentRunner.updateSessionId(session.id, '');
380
- return '✅ 已清空当前会话的对话历史';
724
+ const releaseLock = this.messageQueue.acquireLock(session.id);
725
+ try {
726
+ const cleared = await sessionAgent.clearSession(session.id, session.agentSessionId, projectPath);
727
+ if (cleared) {
728
+ await this.sessionManager.updateAgentSessionIdBySessionId(session.id, '');
729
+ sessionAgent.updateSessionId(session.id, '');
730
+ return '✅ 已清空当前会话的对话历史';
731
+ }
732
+ else {
733
+ return '❌ 清空会话失败,请稍后重试';
734
+ }
381
735
  }
382
- else {
383
- return '❌ 清空会话失败,请稍后重试';
736
+ finally {
737
+ releaseLock();
384
738
  }
385
739
  }
386
740
  // /compact 命令:手动压缩会话上下文
@@ -389,21 +743,31 @@ export class CommandHandler {
389
743
  if ('error' in result)
390
744
  return result.error;
391
745
  const { session } = result;
746
+ const sessionAgent = this.getAgent(session.agentId);
747
+ if (!sessionAgent.capabilities?.compact) {
748
+ return `❌ 当前 Agent (${sessionAgent.name}) 不支持 /compact`;
749
+ }
392
750
  if (!session.agentSessionId) {
393
751
  return '❌ 当前会话没有历史记录,无需压缩';
394
752
  }
395
753
  const projectPath = path.isAbsolute(session.projectPath)
396
754
  ? session.projectPath
397
755
  : path.resolve(process.cwd(), session.projectPath);
398
- if (sendMessage) {
399
- await sendMessage(channelId, '⏳ 正在压缩会话上下文...', this.getThreadSendOpts(session));
400
- }
401
- const compacted = await this.agentRunner.compactSession(session.id, session.agentSessionId, projectPath);
402
- if (compacted) {
403
- return '✅ 会话上下文已压缩';
756
+ const releaseLock = this.messageQueue.acquireLock(session.id);
757
+ try {
758
+ if (sendMessage) {
759
+ await sendMessage(channelId, '⏳ 正在压缩会话上下文...', this.getReplyContext(session));
760
+ }
761
+ const compacted = await sessionAgent.compactSession(session.id, session.agentSessionId, projectPath);
762
+ if (compacted) {
763
+ return '✅ 会话上下文已压缩';
764
+ }
765
+ else {
766
+ return '❌ 会话压缩失败,请稍后重试';
767
+ }
404
768
  }
405
- else {
406
- return '❌ 会话压缩失败,请稍后重试';
769
+ finally {
770
+ releaseLock();
407
771
  }
408
772
  }
409
773
  // 尝试获取活跃会话(话题时直接查找话题 session)
@@ -414,26 +778,37 @@ export class CommandHandler {
414
778
  else {
415
779
  session = await this.sessionManager.getActiveSession(channel, channelId);
416
780
  }
417
- // 对于需要创建会话的命令,如果没有会话则创建
781
+ // 对于需要会话的命令,如果没有会话则使用默认项目创建临时会话
782
+ // 这样 /pwd、/status 等命令可以在没有活跃会话时返回默认项目信息
418
783
  if (!session && (normalizedContent.startsWith('/new') ||
419
784
  normalizedContent.startsWith('/bind') ||
420
- normalizedContent.startsWith('/project'))) {
785
+ normalizedContent.startsWith('/project') ||
786
+ normalizedContent === '/pwd' ||
787
+ normalizedContent === '/status')) {
421
788
  session = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd());
422
789
  }
423
790
  // /status 命令:显示会话状态
424
791
  if (normalizedContent === '/status') {
792
+ // session 现在总是存在(上面已自动创建)
425
793
  if (!session) {
426
- return `📊 会话状态:
427
-
428
- ❌ 当前未创建会话
429
-
430
- 提示:发送任意消息或使用 /new 命令创建会话`;
794
+ return `❌ 无法创建会话,请检查配置`;
431
795
  }
432
796
  const sessionKey = this.getQueueKey(session, channel, channelId);
433
- const isCurrentlyProcessing = this.messageQueue.isProcessing(sessionKey);
797
+ const sessionAgent = this.getAgent(session.agentId);
798
+ const isCurrentlyProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
434
799
  const queueLength = this.messageQueue.getQueueLength(sessionKey);
435
800
  const isThread = !!session.threadId;
436
- const sessionStatus = isCurrentlyProcessing ? '处理中' : '空闲';
801
+ let sessionStatus = isCurrentlyProcessing ? '处理中' : '空闲';
802
+ // 处理中时显示时长
803
+ if (isCurrentlyProcessing) {
804
+ const elapsed = Date.now() - parseInt(session.processingState, 10);
805
+ if (!isNaN(elapsed) && elapsed > 0) {
806
+ const sec = Math.floor(elapsed / 1000);
807
+ sessionStatus = sec < 60 ? `处理中 (${sec}秒)` :
808
+ sec < 3600 ? `处理中 (${Math.floor(sec / 60)}分钟)` :
809
+ `处理中 (${Math.floor(sec / 3600)}小时)`;
810
+ }
811
+ }
437
812
  const projectName = this.getProjectName(session.projectPath);
438
813
  const health = await this.sessionManager.getHealthStatus(session.id);
439
814
  const timeSinceSuccess = Date.now() - health.lastSuccessTime;
@@ -443,7 +818,7 @@ export class CommandHandler {
443
818
  // 获取会话文件信息并同步 name
444
819
  let sessionTurns = 0;
445
820
  if (session.agentSessionId) {
446
- const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId);
821
+ const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId, session.agentId);
447
822
  sessionTurns = fileInfo.turns;
448
823
  if (fileInfo.title && fileInfo.title !== session.name) {
449
824
  await this.sessionManager.renameSession(session.id, fileInfo.title);
@@ -452,10 +827,17 @@ export class CommandHandler {
452
827
  }
453
828
  const lines = [];
454
829
  if (isAdmin) {
455
- lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `异常计数: ${health.consecutiveErrors}`, `安全模式: ${health.safeMode ? '是 ⚠️' : '否 ✓'}`, `最后成功: ${timeStr}`, `Claude会话: ${session.agentSessionId || '(未初始化)'}`, `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`, `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`);
830
+ lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / 会话: ${session.name || '(未命名)'}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`);
831
+ if (health.consecutiveErrors > 0) {
832
+ lines.push(`异常计数: ${health.consecutiveErrors}`);
833
+ }
834
+ if (health.safeMode) {
835
+ lines.push(`安全模式: 是 ⚠️`);
836
+ }
837
+ lines.push(`最后成功: ${timeStr}`, `${session.agentId}会话: ${session.agentSessionId || '(未初始化)'}`, `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`, `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`);
456
838
  }
457
839
  else {
458
- lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `会话: ${session.name || '(未命名)'}`, `状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `最后活跃: ${timeStr}`);
840
+ lines.push(`📊 ${isThread ? '话题' : '会话'}状态:`, `渠道: ${channel} / 项目: ${projectName} / ${session.agentId}会话`, `状态: ${sessionStatus}`, `会话轮数: ${sessionTurns}`, `最后活跃: ${timeStr}`);
459
841
  }
460
842
  if (health.safeMode) {
461
843
  lines.push('');
@@ -482,12 +864,90 @@ export class CommandHandler {
482
864
  }
483
865
  }
484
866
  const projectPath = session?.projectPath || this.config.projects?.defaultPath || process.cwd();
485
- const newSession = await this.sessionManager.createNewSession(channel, channelId, projectPath, sessionName);
867
+ const newSession = await this.sessionManager.createNewSession(channel, channelId, projectPath, sessionName, session?.agentId || this.defaultAgentId);
868
+ this.eventBus.publish({
869
+ type: 'session:created',
870
+ sessionId: newSession.id,
871
+ channel,
872
+ channelId,
873
+ projectPath,
874
+ name: sessionName,
875
+ timestamp: Date.now()
876
+ });
486
877
  if (session) {
487
- await this.agentRunner.closeSession(session.id);
878
+ await agent.closeSession(session.id);
488
879
  }
489
880
  return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /slist 查看`;
490
881
  }
882
+ // /check 命令:检查渠道状态 / 手动重连指定渠道
883
+ if (normalizedContent === '/check' || normalizedContent.startsWith('/check ')) {
884
+ if (!isAdmin)
885
+ return '❌ 无权限:此命令仅限管理员使用';
886
+ const subCmd = normalizedContent.slice('/check'.length).trim();
887
+ // /check rty <channel> — 重连指定渠道
888
+ if (subCmd.startsWith('rty')) {
889
+ const target = subCmd.slice('rty'.length).trim();
890
+ if (!target) {
891
+ return '❌ 请指定渠道名称,例如:/check rty feishu';
892
+ }
893
+ const ch = this.channelObjects.get(target);
894
+ if (!ch) {
895
+ const available = [...this.channelObjects.keys()].join(', ') || '无';
896
+ return `❌ 未找到渠道 "${target}",可用渠道:${available}`;
897
+ }
898
+ if (!ch.reconnect) {
899
+ return `❌ 渠道 "${target}" 不支持重连`;
900
+ }
901
+ const result = await ch.reconnect();
902
+ return `🔄 ${target} 重连: ${result}`;
903
+ }
904
+ // Default: show full system health check
905
+ const lines = ['📡 渠道状态:'];
906
+ for (const [name] of this.adapters) {
907
+ const ch = this.channelObjects.get(name);
908
+ if (ch?.getStatus) {
909
+ const s = ch.getStatus();
910
+ const status = s.connected ? '✓ 已连接' : s.reconnectAttempt > 0 ? `⏳ 重连中 (${s.reconnectAttempt}/${s.maxAttempts})` : '✗ 断开';
911
+ lines.push(` ${name}: ${status}`);
912
+ }
913
+ else {
914
+ lines.push(` ${name}: ✓ 已注册`);
915
+ }
916
+ }
917
+ // 队列状态
918
+ lines.push('', '📬 队列状态:');
919
+ lines.push(` 待处理消息: ${this.messageQueue.getGlobalQueueLength()}`);
920
+ lines.push(` 处理中队列: ${this.messageQueue.getGlobalProcessingCount()}`);
921
+ // 运行概况
922
+ lines.push('', '🖥️ 运行概况:');
923
+ const uptimeMs = this.statsCollector
924
+ ? this.statsCollector.getSnapshot().uptimeMs
925
+ : process.uptime() * 1000;
926
+ lines.push(` 运行时间: ${this.formatUptime(uptimeMs)}`);
927
+ lines.push(` 当前安全模式会话: ${this.sessionManager.getSafeModeSessionCount()}`);
928
+ // 近 1 小时统计
929
+ if (this.statsCollector) {
930
+ const snap = this.statsCollector.getSnapshot();
931
+ const h = snap.lastHour;
932
+ lines.push('', '📊 近 1 小时统计:');
933
+ lines.push(` 收到消息: ${h.received}`);
934
+ lines.push(` 完成处理: ${h.completed}`);
935
+ if (h.errors > 0) {
936
+ const breakdown = Object.entries(h.errorsByType).map(([t, c]) => `${t}: ${c}`).join(', ');
937
+ lines.push(` 处理出错: ${h.errors} (${breakdown})`);
938
+ }
939
+ else {
940
+ lines.push(` 处理出错: 0`);
941
+ }
942
+ lines.push(` 被中断: ${h.interrupts}`);
943
+ lines.push(` 进入安全模式: ${h.safeModeEntries}`);
944
+ if (h.completed > 0) {
945
+ lines.push(` 平均响应耗时: ${(h.avgResponseMs / 1000).toFixed(1)}s`);
946
+ }
947
+ }
948
+ lines.push('', '💡 /check rty <channel> — 重连指定渠道');
949
+ return lines.join('\n');
950
+ }
491
951
  // /restart 命令:重启服务
492
952
  if (normalizedContent === '/restart') {
493
953
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
@@ -516,17 +976,17 @@ export class CommandHandler {
516
976
  return sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。';
517
977
  }
518
978
  }
519
- // 话题中 restart 时保存 rootId 用于重启后回复到话题
520
- let rootId;
979
+ // 话题中 restart 时保存 replyContext 用于重启后回复到话题
980
+ let replyContext;
521
981
  if (threadId) {
522
982
  const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.config.projects?.defaultPath || process.cwd(), threadId);
523
- rootId = threadSession.metadata?.feishu?.rootId;
983
+ replyContext = this.getReplyContext(threadSession);
524
984
  }
525
985
  const restartInfo = {
526
986
  channel,
527
987
  channelId,
528
988
  timestamp: Date.now(),
529
- ...(rootId ? { rootId } : {})
989
+ ...(replyContext?.replyToMessageId ? { rootId: replyContext.replyToMessageId } : {}),
530
990
  };
531
991
  fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
532
992
  const { spawn } = await import('child_process');
@@ -535,6 +995,7 @@ export class CommandHandler {
535
995
  stdio: 'ignore',
536
996
  env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
537
997
  }).unref();
998
+ this.eventBus.publish({ type: 'system:restart', channel, channelId });
538
999
  setTimeout(() => {
539
1000
  logger.info('[System] Restarting by user command...');
540
1001
  process.exit(0);
@@ -543,10 +1004,9 @@ export class CommandHandler {
543
1004
  }
544
1005
  // /pwd 命令:显示当前项目路径
545
1006
  if (normalizedContent === '/pwd') {
1007
+ // session 现在总是存在(上面已自动创建)
546
1008
  if (!session) {
547
- return `❌ 当前没有活跃会话
548
-
549
- 提示:发送任意消息或使用 /new 命令创建会话`;
1009
+ return `❌ 无法创建会话,请检查配置`;
550
1010
  }
551
1011
  const configName = this.getConfiguredProjectName(session.projectPath);
552
1012
  if (configName) {
@@ -554,33 +1014,140 @@ export class CommandHandler {
554
1014
  }
555
1015
  return `当前项目: ${session.projectPath}`;
556
1016
  }
1017
+ // /send 命令:发送项目内文件,支持 /send path 和 /send channel path
1018
+ if (normalizedContent.startsWith('/send')) {
1019
+ // 飞书会将 .md 等后缀自动转为 Markdown 链接: foo.md → [foo.md](http://foo.md/)
1020
+ // 还原: 将 [text](url) 替换为 text
1021
+ const rawArg = normalizedContent.slice(5).trim().replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
1022
+ if (!rawArg) {
1023
+ return '用法: /send <相对路径> 或 /send <渠道> <相对路径>\n示例: /send src/index.ts\n示例: /send feishu report.md';
1024
+ }
1025
+ // 解析目标通道:第一个 token 若匹配已注册通道名则为目标通道
1026
+ const tokens = rawArg.split(/\s+/);
1027
+ const knownChannels = [...this.adapters.keys()];
1028
+ let targetChannel = channel;
1029
+ let filePath = rawArg;
1030
+ if (tokens.length >= 2 && knownChannels.includes(tokens[0])) {
1031
+ targetChannel = tokens[0];
1032
+ filePath = tokens.slice(1).join(' ');
1033
+ }
1034
+ // 跨通道仅限 owner
1035
+ if (targetChannel !== channel && identity.role !== 'owner') {
1036
+ return '❌ 跨通道发送仅限管理员';
1037
+ }
1038
+ // 找目标 adapter
1039
+ const targetAdapter = this.adapters.get(targetChannel);
1040
+ if (!targetAdapter) {
1041
+ return `❌ 通道 ${targetChannel} 未启用或不存在`;
1042
+ }
1043
+ if (!targetAdapter.sendFile) {
1044
+ return `❌ 通道 ${targetChannel} 不支持文件发送`;
1045
+ }
1046
+ // 获取 session(需要 projectPath)
1047
+ const sendResult = await this.ensureSession(channel, channelId, threadId);
1048
+ if ('error' in sendResult)
1049
+ return sendResult.error;
1050
+ const sendSession = sendResult.session;
1051
+ // 路径安全校验
1052
+ if (path.isAbsolute(filePath)) {
1053
+ return '❌ 不支持绝对路径\n请使用项目内的相对路径';
1054
+ }
1055
+ if (filePath.split(path.sep).includes('..') || filePath.split('/').includes('..')) {
1056
+ return '❌ 不支持 .. 路径穿越';
1057
+ }
1058
+ const resolvedPath = path.resolve(sendSession.projectPath, filePath);
1059
+ // 存在性检查
1060
+ if (!fs.existsSync(resolvedPath)) {
1061
+ return `❌ 文件不存在: ${filePath}`;
1062
+ }
1063
+ // 符号链接安全:realpath 后验证仍在项目目录内
1064
+ const realPath = fs.realpathSync(resolvedPath);
1065
+ const realProjectPath = fs.realpathSync(sendSession.projectPath);
1066
+ if (!realPath.startsWith(realProjectPath + path.sep) && realPath !== realProjectPath) {
1067
+ return '❌ 路径不允许: 文件不在项目目录内';
1068
+ }
1069
+ const stat = fs.statSync(resolvedPath);
1070
+ if (stat.isDirectory()) {
1071
+ return '❌ 暂不支持发送目录\n目录打包发送将在后续版本支持';
1072
+ }
1073
+ const MAX_SIZE = 10 * 1024 * 1024;
1074
+ if (stat.size > MAX_SIZE) {
1075
+ return `❌ 文件过大: ${(stat.size / 1024 / 1024).toFixed(1)} MB (限制 10 MB)`;
1076
+ }
1077
+ // 找目标 channelId
1078
+ let targetChannelId = channelId;
1079
+ if (targetChannel !== channel) {
1080
+ const ownerPeerId = getOwner(this.config, targetChannel);
1081
+ targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannel, ownerPeerId) ?? '') : '';
1082
+ if (!targetChannelId) {
1083
+ return `❌ 未找到 ${targetChannel} 的私聊会话,请先在该通道发送一条消息`;
1084
+ }
1085
+ }
1086
+ // 发送文件
1087
+ try {
1088
+ const replyCtx = targetChannel === channel ? this.getReplyContext(sendSession) : undefined;
1089
+ await targetAdapter.sendFile(targetChannelId, realPath, replyCtx);
1090
+ const sizeStr = stat.size < 1024 ? `${stat.size} B`
1091
+ : stat.size < 1024 * 1024 ? `${(stat.size / 1024).toFixed(1)} KB`
1092
+ : `${(stat.size / 1024 / 1024).toFixed(1)} MB`;
1093
+ return targetChannel !== channel
1094
+ ? `📎 文件已通过 ${targetChannel} 发送: ${filePath} (${sizeStr})`
1095
+ : `✅ 已发送: ${filePath} (${sizeStr})`;
1096
+ }
1097
+ catch (error) {
1098
+ logger.error('[CommandHandler] /send failed:', error);
1099
+ return `❌ 文件发送失败: ${error.message || error}`;
1100
+ }
1101
+ }
557
1102
  // /plist 命令:列出所有项目
558
1103
  if (normalizedContent === '/plist') {
559
- const isGroup = await this.isGroupChat(channel, channelId);
560
- if (isGroup) {
1104
+ if (!policy.canListProjects(session?.chatType || 'private', identity.role)) {
561
1105
  if (!session) {
562
1106
  return `❌ 当前群聊未绑定项目
563
1107
 
564
1108
  请使用 /bind <项目路径> 绑定项目`;
565
1109
  }
566
1110
  const projectName = this.getProjectName(session.projectPath);
567
- const sessionKey = `${channel}-${channelId}`;
568
- const queueLength = this.messageQueue.getQueueLength(sessionKey);
569
- const status = queueLength > 0 ? '[处理中]' : '[空闲]';
1111
+ const isProcessing = !!session.processingState;
1112
+ const status = isProcessing ? '[处理中]' : '[空闲]';
570
1113
  return `当前群聊绑定的项目:
571
1114
  ${projectName} (${session.projectPath}) - ${status}
572
1115
 
573
1116
  提示:群聊不支持切换项目`;
574
1117
  }
575
1118
  const lines = ['可用项目:'];
576
- const sessionKey = `${channel}-${channelId}`;
577
- const processingProject = this.messageQueue.getProcessingProject(sessionKey);
578
- const queueLength = this.messageQueue.getQueueLength(sessionKey);
579
- const normalizePath = (p) => p.replace(/[/\\]+$/, '');
1119
+ // 收集项目信息并按最近活跃排序
1120
+ const entries = [];
1121
+ const configuredPaths = new Set();
580
1122
  for (const [name, projectPath] of Object.entries(this.projects)) {
1123
+ configuredPaths.add(projectPath);
581
1124
  const isCurrent = session?.projectPath === projectPath;
582
- const prefix = isCurrent ? ' ✓' : ' ';
583
1125
  const projectSession = await this.sessionManager.getSessionByProjectPath(channel, channelId, projectPath);
1126
+ entries.push({
1127
+ name, projectPath, projectSession, isCurrent,
1128
+ updatedAt: projectSession?.updatedAt ?? 0,
1129
+ });
1130
+ }
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
+ // 当前活跃项目置顶,其余按 updatedAt 降序
1144
+ entries.sort((a, b) => {
1145
+ if (a.isCurrent !== b.isCurrent)
1146
+ return a.isCurrent ? -1 : 1;
1147
+ return b.updatedAt - a.updatedAt;
1148
+ });
1149
+ for (const { name, projectPath, projectSession, isCurrent } of entries) {
1150
+ const prefix = isCurrent ? ' ✓' : ' ';
584
1151
  if (!projectSession) {
585
1152
  lines.push(`${prefix} ${name} (${projectPath}) - 无会话`);
586
1153
  continue;
@@ -593,9 +1160,12 @@ export class CommandHandler {
593
1160
  const idleMs = Date.now() - projectSession.updatedAt;
594
1161
  statusParts.push(formatIdleTime(idleMs));
595
1162
  }
596
- if (processingProject && normalizePath(processingProject) === normalizePath(projectPath)) {
597
- if (queueLength > 1) {
598
- statusParts.push(`[处理中,队列${queueLength - 1}条]`);
1163
+ // DB processingState 判断处理状态
1164
+ const isProcessing = !!projectSession.processingState;
1165
+ if (isProcessing) {
1166
+ const queueLength = this.messageQueue.getQueueLength(projectSession.id);
1167
+ if (queueLength > 0) {
1168
+ statusParts.push(`[处理中,队列${queueLength}条]`);
599
1169
  }
600
1170
  else {
601
1171
  statusParts.push('[处理中]');
@@ -605,7 +1175,7 @@ export class CommandHandler {
605
1175
  if (unreadCount > 0) {
606
1176
  statusParts.push(`[${unreadCount}条新消息]`);
607
1177
  }
608
- else if (!processingProject || normalizePath(processingProject) !== normalizePath(projectPath)) {
1178
+ else if (!isProcessing && !isCurrent) {
609
1179
  statusParts.push('[空闲]');
610
1180
  }
611
1181
  lines.push(`${prefix} ${name} (${projectPath}) - ${statusParts.join(' ')}`);
@@ -614,15 +1184,19 @@ export class CommandHandler {
614
1184
  }
615
1185
  // /project 命令:切换项目(支持名称或路径)
616
1186
  if (normalizedContent.startsWith('/project ')) {
617
- const isGroup = await this.isGroupChat(channel, channelId);
618
- if (isGroup) {
1187
+ if (!policy.canSwitchProject(session?.chatType || 'private', identity.role)) {
619
1188
  return `❌ 群聊不支持切换项目
620
1189
 
621
1190
  群聊只能绑定一个项目。如需更换项目,请联系管理员重新配置。`;
622
1191
  }
623
- const arg = normalizedContent.slice(9).trim();
1192
+ let arg = normalizedContent.slice(9).trim();
624
1193
  if (!arg)
625
1194
  return '用法: /p <name|path> 或 /project <name|path>';
1195
+ // 检查确认标志
1196
+ const hasConfirm = arg.endsWith(' --confirm');
1197
+ if (hasConfirm) {
1198
+ arg = arg.slice(0, -10).trim();
1199
+ }
626
1200
  let projectPath;
627
1201
  let projectName;
628
1202
  if (arg.includes('/')) {
@@ -649,10 +1223,33 @@ export class CommandHandler {
649
1223
  return `当前已在项目: ${projectName}\n 路径: ${projectPath}`;
650
1224
  }
651
1225
  }
652
- const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath);
1226
+ // 群聊切换项目需要确认
1227
+ const isGroupChat = session?.chatType === 'group';
1228
+ if (isGroupChat && !hasConfirm) {
1229
+ return `⚠️ 群聊切换项目风险提示:
1230
+
1231
+ 切换项目将影响所有群成员的对话上下文,可能导致:
1232
+ • 当前项目的会话历史被切换
1233
+ • 正在处理的任务被中断
1234
+ • 其他成员的工作受到影响
1235
+
1236
+ 确认切换请执行:
1237
+ /p ${projectName} --confirm`;
1238
+ }
1239
+ const currentAgentId = activeSession?.agentId || this.defaultAgentId;
1240
+ const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath, currentAgentId);
1241
+ this.eventBus.publish({
1242
+ type: 'project:switched',
1243
+ sessionId: newSession.id,
1244
+ channel,
1245
+ channelId,
1246
+ projectPath,
1247
+ timestamp: Date.now()
1248
+ });
653
1249
  const cachedEvents = this.messageCache.getEvents(newSession.id);
654
1250
  const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
655
- let response = `✓ 已切换到项目: ${projectName}\n 路径: ${projectPath}\n ${hasExistingSession}`;
1251
+ const currentAgent = newSession.agentId || this.defaultAgentId;
1252
+ let response = `✓ 已切换到项目: ${projectName}\n 路径: ${projectPath}\n Agent: ${currentAgent}\n ${hasExistingSession}`;
656
1253
  if (cachedEvents.length > 0 && sendMessage) {
657
1254
  for (const event of cachedEvents) {
658
1255
  if (event.type === 'completed') {
@@ -674,40 +1271,41 @@ export class CommandHandler {
674
1271
  }
675
1272
  return response;
676
1273
  }
677
- // /bind 命令:绑定新项目目录
1274
+ // /bind 命令:持久化项目到配置(不切换)
678
1275
  if (normalizedContent.startsWith('/bind ')) {
679
1276
  const projectPath = normalizedContent.slice(6).trim();
680
1277
  if (!projectPath)
681
- return '用法: /bind <path>';
1278
+ return '用法: /bind <路径>';
682
1279
  if (!path.isAbsolute(projectPath)) {
683
1280
  return '❌ 项目路径必须是绝对路径';
684
1281
  }
685
1282
  if (!fs.existsSync(projectPath)) {
686
1283
  return `❌ 路径不存在: ${projectPath}`;
687
1284
  }
688
- const newSession = await this.sessionManager.switchProject(channel, channelId, projectPath);
689
- const cachedEvents = this.messageCache.getEvents(newSession.id);
690
- const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
691
- let response = `✓ 已绑定项目目录: ${projectPath}\n ${hasExistingSession}`;
692
- if (cachedEvents.length > 0) {
693
- response += `\n\n后台任务结果:`;
694
- for (const event of cachedEvents) {
695
- if (event.type === 'completed') {
696
- response += `\n✓ 任务完成`;
697
- if (event.metadata?.duration) {
698
- response += ` (耗时: ${Math.round(event.metadata.duration / 1000)}s)`;
699
- }
700
- const summary = event.message.substring(0, 200);
701
- response += `\n${summary}${event.message.length > 200 ? '...' : ''}`;
702
- }
703
- else if (event.type === 'error') {
704
- response += `\n❌ 任务失败: ${event.metadata?.errorType || '未知错误'}`;
705
- response += `\n${event.message}`;
706
- }
1285
+ // 生成项目名称(使用目录名)
1286
+ const projectName = path.basename(projectPath);
1287
+ // 检查是否已存在
1288
+ if (this.projects[projectName]) {
1289
+ const existingPath = this.projects[projectName];
1290
+ if (existingPath === projectPath) {
1291
+ return `项目 "${projectName}" 已存在\n 路径: ${projectPath}\n\n使用 /p ${projectName} 切换到该项目`;
707
1292
  }
708
- this.messageCache.clearEvents(newSession.id);
709
- }
710
- return response;
1293
+ return `❌ 项目名称 "${projectName}" 已被占用\n 现有路径: ${existingPath}\n 新路径: ${projectPath}\n\n请重命名目录或手动编辑配置文件`;
1294
+ }
1295
+ // 添加到配置
1296
+ if (!this.config.projects) {
1297
+ this.config.projects = { defaultPath: process.cwd(), autoCreate: false, list: {} };
1298
+ }
1299
+ if (!this.config.projects.list) {
1300
+ this.config.projects.list = {};
1301
+ }
1302
+ this.config.projects.list[projectName] = projectPath;
1303
+ // 保存配置
1304
+ const { saveConfig } = await import('../config.js');
1305
+ saveConfig(this.config);
1306
+ // 更新内存中的项目列表
1307
+ this.projects[projectName] = projectPath;
1308
+ return `✓ 已添加项目: ${projectName}\n 路径: ${projectPath}\n\n使用 /p ${projectName} 切换到该项目`;
711
1309
  }
712
1310
  // /slist 命令:列出当前项目的所有会话
713
1311
  if (normalizedContent === '/slist') {
@@ -720,56 +1318,74 @@ export class CommandHandler {
720
1318
  3. /project <项目> - 切换到指定项目`;
721
1319
  }
722
1320
  const sessions = await this.sessionManager.listSessions(channel, channelId);
723
- const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath);
1321
+ const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
724
1322
  // 从 SDK 同步会话名称(发现 CLI 改名)
725
1323
  try {
726
- const sdkSessions = await sdkListSessions({ dir: session.projectPath });
1324
+ const sdkSessions = await this.sessionManager.listSdkSessions(session.projectPath, session.agentId);
727
1325
  for (const sdkSession of sdkSessions) {
728
- const sdkName = sdkSession.customTitle || undefined;
729
- if (!sdkName)
1326
+ if (!sdkSession.title)
730
1327
  continue;
731
1328
  const dbSession = currentProjectSessions.find(s => s.agentSessionId === sdkSession.sessionId);
732
- if (dbSession && sdkName !== dbSession.name) {
733
- await this.sessionManager.renameSession(dbSession.id, sdkName);
734
- dbSession.name = sdkName;
1329
+ if (dbSession && sdkSession.title !== dbSession.name) {
1330
+ await this.sessionManager.renameSession(dbSession.id, sdkSession.title);
1331
+ dbSession.name = sdkSession.title;
735
1332
  }
736
1333
  }
737
1334
  }
738
1335
  catch (error) {
739
1336
  logger.debug('[CommandHandler] SDK listSessions sync failed (non-critical):', error);
740
1337
  }
741
- const isGroup = await this.isGroupChat(channel, channelId);
742
- const cliSessions = (isGroup || !isAdmin)
743
- ? []
744
- : await this.sessionManager.scanCliSessions(session.projectPath);
1338
+ const canImportCli = policy.canImportCliSession(session.chatType || 'private', identity.role);
1339
+ const cliSessions = canImportCli
1340
+ ? await this.sessionManager.scanCliSessions(session.projectPath, session.agentId)
1341
+ : [];
745
1342
  const dbSessionIds = new Set(currentProjectSessions.map(s => s.agentSessionId).filter(Boolean));
746
- const lines = [`当前项目 ${path.basename(session.projectPath)} 的会话列表:\n`];
747
- const sessionKey = `${channel}-${channelId}`;
748
- const isProcessing = this.messageQueue.isProcessing(sessionKey);
1343
+ const lines = [`当前项目 ${path.basename(session.projectPath)} 的 [${session.agentId}] 会话列表:`, ''];
749
1344
  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;
750
1349
  lines.push('【EvolClaw 会话】');
1350
+ let displayIndex = 0;
751
1351
  for (let i = 0; i < currentProjectSessions.length; i++) {
752
1352
  const s = currentProjectSessions[i];
753
- const prefix = s.isActive ? ' ✓' : ' ';
754
- const num = `${i + 1}.`;
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}.`;
755
1361
  const threadTag = s.threadId ? '[话题] ' : '';
756
1362
  const name = s.name || '(未命名)';
757
1363
  const uuid = s.agentSessionId ? `(${s.agentSessionId.substring(0, 8)})` : '';
758
1364
  const idleTime = formatIdleTime(Date.now() - s.updatedAt);
759
- if (s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId)) {
1365
+ if (s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId, s.agentId)) {
760
1366
  lines.push(`${prefix} ${num} ${threadTag}❌ ${name} ${uuid} - ${idleTime} [会话文件缺失]`);
761
1367
  }
762
1368
  else {
1369
+ const sIsProcessing = !!s.processingState;
763
1370
  let status = '[空闲]';
764
- if (s.isActive && isProcessing) {
1371
+ if (sIsProcessing) {
765
1372
  status = '[处理中]';
766
1373
  }
767
- else if (s.isActive) {
1374
+ else if (isActive) {
768
1375
  status = '[活跃]';
769
1376
  }
770
1377
  lines.push(`${prefix} ${num} ${threadTag}${name} ${uuid} - ${idleTime} ${status}`);
771
1378
  }
772
1379
  }
1380
+ const hiddenCount = currentProjectSessions.length - displayIndex - topicCount;
1381
+ if (topicCount > 0 || hiddenCount > 0) {
1382
+ const parts = [];
1383
+ if (hiddenCount > 0)
1384
+ parts.push(`${hiddenCount} 个更早的会话`);
1385
+ if (topicCount > 0)
1386
+ parts.push(`${topicCount} 个话题会话`);
1387
+ lines.push(`\n (已隐藏 ${parts.join('、')})`);
1388
+ }
773
1389
  lines.push('');
774
1390
  }
775
1391
  const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid)).slice(0, 5);
@@ -777,7 +1393,7 @@ export class CommandHandler {
777
1393
  lines.push('【CLI 会话】(最新5个)');
778
1394
  for (const c of orphanCliSessions) {
779
1395
  const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
780
- const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid) || '(无消息)';
1396
+ const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid, session.agentId) || '(无消息)';
781
1397
  const uuid = c.uuid.substring(0, 8);
782
1398
  lines.push(` ${time} (${uuid}) "${message}"`);
783
1399
  }
@@ -791,38 +1407,44 @@ export class CommandHandler {
791
1407
  const sessionName = normalizedContent.slice(9).trim();
792
1408
  if (!sessionName)
793
1409
  return '用法: /s <序号、会话名称或前8位UUID>';
794
- const sessionKey = `${channel}-${channelId}`;
795
- const queueLength = this.messageQueue.getQueueLength(sessionKey);
796
- if (queueLength > 0) {
1410
+ const isProcessing = !!session?.processingState;
1411
+ if (isProcessing) {
797
1412
  return `⚠️ 当前正在处理消息,无法切换会话\n请等待当前任务完成后再试`;
798
1413
  }
799
1414
  let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
800
- // 序号切换:纯数字时按当前项目会话列表序号匹配
1415
+ // 序号切换:纯数字时按 /slist 显示的序号匹配(超过10个时隐藏非活跃话题会话)
801
1416
  if (!targetSession && /^\d+$/.test(sessionName) && session) {
802
1417
  const idx = parseInt(sessionName, 10);
803
1418
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
804
- const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath);
805
- if (idx >= 1 && idx <= projectSessions.length) {
806
- targetSession = projectSessions[idx - 1];
1419
+ const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
1420
+ // /slist 显示逻辑一致:超过10个时隐藏非活跃话题会话
1421
+ const hideTopics = projectSessions.length > 10;
1422
+ const visibleSessions = hideTopics
1423
+ ? projectSessions.filter(s => !s.threadId)
1424
+ : projectSessions;
1425
+ if (idx >= 1 && idx <= visibleSessions.length) {
1426
+ targetSession = visibleSessions[idx - 1];
807
1427
  }
808
1428
  else {
809
- return `❌ 序号超出范围 (1-${projectSessions.length})\n使用 /slist 查看可用会话`;
1429
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
810
1430
  }
811
1431
  }
812
1432
  if (!targetSession && sessionName.length === 8) {
813
1433
  targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
814
1434
  }
815
- const isGroup = await this.isGroupChat(channel, channelId);
816
- if (!targetSession && sessionName.length === 8 && !isGroup && isAdmin) {
1435
+ const canImport = policy.canImportCliSession(session?.chatType || 'private', identity.role);
1436
+ if (!targetSession && sessionName.length === 8 && canImport) {
817
1437
  const projectPaths = Object.values(this.projects);
818
1438
  if (session) {
819
1439
  projectPaths.unshift(session.projectPath);
820
1440
  }
821
1441
  for (const projectPath of projectPaths) {
822
- const cliSessions = await this.sessionManager.scanCliSessions(projectPath);
1442
+ const currentAgentId = session?.agentId || this.defaultAgentId;
1443
+ const cliSessions = await this.sessionManager.scanCliSessions(projectPath, currentAgentId);
823
1444
  const cliSession = cliSessions.find(c => c.uuid.startsWith(sessionName));
824
1445
  if (cliSession) {
825
- const imported = await this.sessionManager.importCliSession(channel, channelId, projectPath, cliSession.uuid);
1446
+ const imported = await this.sessionManager.importCliSession(channel, channelId, projectPath, cliSession.uuid, currentAgentId);
1447
+ this.eventBus.publish({ type: 'session:imported', sessionId: imported.id, agentSessionId: cliSession.uuid, projectPath });
826
1448
  const projectName = this.getProjectName(projectPath);
827
1449
  return `✓ 已导入 CLI 会话: ${imported.name}\n 项目: ${projectName}\n 将继续之前的对话历史`;
828
1450
  }
@@ -832,7 +1454,7 @@ export class CommandHandler {
832
1454
  return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
833
1455
  }
834
1456
  const lastInput = targetSession.agentSessionId
835
- ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId)
1457
+ ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId, targetSession.agentId)
836
1458
  : null;
837
1459
  const lastInputLine = lastInput ? `\n 最后输入: "${lastInput}"` : '';
838
1460
  if (!session) {
@@ -853,6 +1475,7 @@ export class CommandHandler {
853
1475
  if (!switched) {
854
1476
  return `❌ 切换会话失败`;
855
1477
  }
1478
+ this.eventBus.publish({ type: 'session:switched', sessionId: targetSession.id, fromSessionId: session.id, toSessionId: targetSession.id });
856
1479
  const continueHint = lastInput ? '\n 将继续之前的对话历史' : '\n 当前会话未有发言';
857
1480
  return `✓ 已切换到会话: ${targetSession.name || sessionName}${continueHint}${lastInputLine}`;
858
1481
  }
@@ -873,19 +1496,12 @@ export class CommandHandler {
873
1496
  if (existing && existing.id !== session.id) {
874
1497
  return `❌ 会话名称 "${newName}" 已存在,请使用其他名称`;
875
1498
  }
876
- // 双写:SDK + 数据库
877
- if (session.agentSessionId) {
878
- try {
879
- await sdkRenameSession(session.agentSessionId, newName, { dir: session.projectPath });
880
- }
881
- catch (error) {
882
- logger.warn(`[CommandHandler] SDK renameSession failed (continuing with db update):`, error);
883
- }
884
- }
1499
+ const oldName = session.name || '(未命名)';
885
1500
  const success = await this.sessionManager.renameSession(session.id, newName);
886
1501
  if (!success) {
887
1502
  return `❌ 重命名失败`;
888
1503
  }
1504
+ this.eventBus.publish({ type: 'session:renamed', sessionId: session.id, oldName, newName });
889
1505
  return `✓ 已将当前会话重命名为: ${newName}`;
890
1506
  }
891
1507
  // /del 命令:删除指定会话(仅解绑,不删除文件)
@@ -896,22 +1512,25 @@ export class CommandHandler {
896
1512
  if (!session) {
897
1513
  return `❌ 当前没有活跃会话`;
898
1514
  }
899
- // 群聊权限检查:只有管理员可以删除
900
- const isGroup = await this.isGroupChat(channel, channelId);
901
- if (isGroup && !isAdmin) {
1515
+ // 权限检查:policy 控制谁可以删除会话
1516
+ if (!policy.canDeleteSession(session.chatType || 'private', identity.role)) {
902
1517
  return `❌ 无权限:群聊中仅管理员可删除会话`;
903
1518
  }
904
1519
  let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
905
- // 序号删除
1520
+ // 序号删除(与 /slist 显示序号一致)
906
1521
  if (!targetSession && /^\d+$/.test(sessionName)) {
907
1522
  const idx = parseInt(sessionName, 10);
908
1523
  const allSessions = await this.sessionManager.listSessions(channel, channelId);
909
- const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath);
910
- if (idx >= 1 && idx <= projectSessions.length) {
911
- targetSession = projectSessions[idx - 1];
1524
+ const projectSessions = allSessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId);
1525
+ const hideTopics = projectSessions.length > 10;
1526
+ const visibleSessions = hideTopics
1527
+ ? projectSessions.filter(s => !s.threadId)
1528
+ : projectSessions;
1529
+ if (idx >= 1 && idx <= visibleSessions.length) {
1530
+ targetSession = visibleSessions[idx - 1];
912
1531
  }
913
1532
  else {
914
- return `❌ 序号超出范围 (1-${projectSessions.length})\n使用 /slist 查看可用会话`;
1533
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
915
1534
  }
916
1535
  }
917
1536
  if (!targetSession && sessionName.length === 8) {
@@ -927,7 +1546,9 @@ export class CommandHandler {
927
1546
  if (!success) {
928
1547
  return `❌ 删除失败`;
929
1548
  }
930
- await this.agentRunner.closeSession(targetSession.id);
1549
+ this.eventBus.publish({ type: 'session:deleted', sessionId: targetSession.id });
1550
+ const targetAgent = this.getAgent(targetSession.agentId);
1551
+ await targetAgent.closeSession(targetSession.id);
931
1552
  return `✓ 已删除会话: ${targetSession.name || sessionName}\n会话文件已保留,可通过 CLI 访问`;
932
1553
  }
933
1554
  // /fork 命令:分支当前会话
@@ -937,11 +1558,16 @@ export class CommandHandler {
937
1558
  return `❌ 当前没有活跃会话,无法分支`;
938
1559
  }
939
1560
  if (!session.agentSessionId) {
940
- return `❌ 当前会话尚未初始化 Claude 对话,无法分支\n\n请先发送一条消息,然后再使用 /fork`;
1561
+ return `❌ 当前会话尚未初始化对话,无法分支\n\n请先发送一条消息,然后再使用 /fork`;
1562
+ }
1563
+ const forkAgent = this.getAgent(session.agentId);
1564
+ if (!forkAgent.capabilities?.fork) {
1565
+ return `❌ 当前 Agent (${forkAgent.name}) 不支持 /fork\n\n可使用 /new 创建新会话替代`;
941
1566
  }
942
1567
  try {
943
- const forkResult = await sdkForkSession(session.agentSessionId, { dir: session.projectPath, title: forkName });
944
- const newSession = await this.sessionManager.createForkedSession(session, forkResult.sessionId, forkName);
1568
+ const forkedSessionId = await forkAgent.forkSession(session.agentSessionId, session.projectPath, forkName);
1569
+ const newSession = await this.sessionManager.createForkedSession(session, forkedSessionId, forkName);
1570
+ this.eventBus.publish({ type: 'session:forked', sessionId: newSession.id, sourceSessionId: session.id, name: forkName });
945
1571
  return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /slist 查看所有会话,/s <名称> 切换回原会话`;
946
1572
  }
947
1573
  catch (error) {
@@ -951,66 +1577,49 @@ export class CommandHandler {
951
1577
  }
952
1578
  // /repair 命令:检查并修复会话
953
1579
  if (normalizedContent === '/repair') {
954
- if (!session) {
955
- return `❌ 当前未创建会话,无需修复`;
956
- }
957
- const health = await this.sessionManager.getHealthStatus(session.id);
1580
+ const repairResult = await this.ensureSession(channel, channelId, threadId);
1581
+ if ('error' in repairResult)
1582
+ return repairResult.error;
1583
+ const { session: repairSession } = repairResult;
1584
+ const health = await this.sessionManager.getHealthStatus(repairSession.id);
958
1585
  if (!health.safeMode) {
959
1586
  return `当前不在安全模式,无需修复\n\n如需进入安全模式,请使用 /safe`;
960
1587
  }
961
- const { checkSessionFileHealth, backupClaudeDir } = await import('../utils/session-file-health.js');
962
- const fsPromises = await import('fs/promises');
1588
+ const repairAgent = this.getAgent(repairSession.agentId);
1589
+ const { checkSessionFile, backupSessionFile } = await import('../utils/session-file-health.js');
963
1590
  try {
964
- const backupDir = await backupClaudeDir(session.projectPath);
965
- if (!session.agentSessionId) {
966
- await this.sessionManager.resetHealthStatus(session.id);
967
- return `✓ 修复完成,已退出安全模式
968
-
969
- 修复内容:
970
- - 未发现问题(新会话)
971
- - 已重置异常计数器
972
- - 已恢复正常会话模式
973
-
974
- 备份位置:${backupDir}`;
1591
+ if (!repairSession.agentSessionId) {
1592
+ await this.sessionManager.resetHealthStatus(repairSession.id);
1593
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
1594
+ return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 未发现问题(新会话)\n- 已重置异常计数器\n- 已恢复正常会话模式`;
1595
+ }
1596
+ // 通过 agent 定位 session 文件
1597
+ const sessionFile = repairAgent.resolveSessionFile?.(repairSession.agentSessionId, repairSession.projectPath) ?? null;
1598
+ if (!sessionFile) {
1599
+ // 文件不存在(已被删除或从未创建),直接重置
1600
+ await this.sessionManager.resetHealthStatus(repairSession.id);
1601
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
1602
+ return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 会话文件不存在(可能已被清理)\n- 已重置异常计数器\n- 已恢复正常会话模式`;
975
1603
  }
976
- const healthCheck = await checkSessionFileHealth(session.projectPath, session.agentSessionId);
1604
+ const healthCheck = await checkSessionFile(sessionFile);
977
1605
  if (healthCheck.corrupt) {
978
- const sessionFile = path.join(session.projectPath, '.claude', `${session.agentSessionId}.jsonl`);
1606
+ const backupPath = await backupSessionFile(sessionFile);
1607
+ const fsPromises = await import('fs/promises');
979
1608
  await fsPromises.unlink(sessionFile);
980
- await this.sessionManager.updateAgentSessionId(session.channel, session.channelId, '');
981
- await this.sessionManager.resetHealthStatus(session.id);
982
- return `✓ 修复完成,已退出安全模式
983
-
984
- 检测到问题:
985
- ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
986
-
987
- 修复操作:
988
- - 已删除损坏文件
989
- - 已创建新会话
990
- - 已重置异常计数器
991
-
992
- 备份位置:${backupDir}`;
1609
+ await this.sessionManager.updateAgentSessionIdBySessionId(repairSession.id, '');
1610
+ repairAgent.updateSessionId(repairSession.id, '');
1611
+ await this.sessionManager.resetHealthStatus(repairSession.id);
1612
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
1613
+ return `✓ 修复完成,已退出安全模式\n\n检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n修复操作:\n- 已备份损坏文件\n- 已删除损坏文件\n- 已重置异常计数器\n\n备份位置:${backupPath}`;
993
1614
  }
994
1615
  if (healthCheck.issues.length > 0) {
995
- await this.sessionManager.resetHealthStatus(session.id);
996
- return `⚠️ 检测到问题:
997
- ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
998
-
999
- 建议:
1000
- 1. 使用 /new 创建新会话
1001
- 2. 旧会话已备份到:${backupDir}
1002
-
1003
- 已重置异常计数器,可继续使用当前会话。`;
1616
+ await this.sessionManager.resetHealthStatus(repairSession.id);
1617
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
1618
+ return `⚠️ 检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n建议使用 /new 创建新会话\n\n已重置异常计数器,可继续使用当前会话。`;
1004
1619
  }
1005
- await this.sessionManager.resetHealthStatus(session.id);
1006
- return `✓ 修复完成,已退出安全模式
1007
-
1008
- 修复内容:
1009
- - 未发现问题
1010
- - 已重置异常计数器
1011
- - 已恢复正常会话模式
1012
-
1013
- 备份位置:${backupDir}`;
1620
+ await this.sessionManager.resetHealthStatus(repairSession.id);
1621
+ this.eventBus.publish({ type: 'session:safe-mode-exited', sessionId: repairSession.id, method: 'repair' });
1622
+ return `✓ 修复完成,已退出安全模式\n\n修复内容:\n- 未发现问题\n- 已重置异常计数器\n- 已恢复正常会话模式`;
1014
1623
  }
1015
1624
  catch (error) {
1016
1625
  logger.error('[Repair] Failed:', error);
@@ -1019,10 +1628,12 @@ ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
1019
1628
  }
1020
1629
  // /safe 命令:手动进入安全模式
1021
1630
  if (normalizedContent === '/safe') {
1022
- if (!session) {
1023
- return `❌ 当前未创建会话`;
1024
- }
1025
- await this.sessionManager.setSafeMode(session.id, true);
1631
+ const safeResult = await this.ensureSession(channel, channelId, threadId);
1632
+ if ('error' in safeResult)
1633
+ return safeResult.error;
1634
+ const { session: safeSession } = safeResult;
1635
+ await this.sessionManager.setSafeMode(safeSession.id, true);
1636
+ this.eventBus.publish({ type: 'session:safe-mode-entered', sessionId: safeSession.id, reason: 'manual' });
1026
1637
  return `✓ 已进入安全模式
1027
1638
 
1028
1639
  当前行为:
@@ -1036,11 +1647,4 @@ ${healthCheck.issues.map((i) => `- ${i}`).join('\n')}
1036
1647
  }
1037
1648
  return null;
1038
1649
  }
1039
- /**
1040
- * 通过 adapter 查询是否为群聊
1041
- */
1042
- async isGroupChat(channel, channelId) {
1043
- const adapter = this.adapters.get(channel);
1044
- return await adapter?.isGroupChat?.(channelId) ?? false;
1045
- }
1046
1650
  }