evolclaw 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +247 -84
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +132 -50
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +750 -209
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +216 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +386 -105
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +57 -11
  25. package/dist/index.js +138 -54
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -1,12 +1,12 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs';
3
- import { hasCompact } from '../agents/claude-runner.js';
4
- import { StreamFlusher } from '../utils/stream-flusher.js';
5
- import { StreamIdleMonitor } from '../utils/stream-idle-monitor.js';
6
- import { logger } from '../utils/logger.js';
7
- import { getErrorMessage, classifyError, ErrorType } from '../utils/error-utils.js';
8
- import { summarizeToolInput } from '../utils/permission-utils.js';
9
- import { getOwner } from '../config.js';
3
+ import { hasCompact } from '../../agents/claude-runner.js';
4
+ import { StreamFlusher } from './stream-flusher.js';
5
+ import { StreamIdleMonitor } from './stream-idle-monitor.js';
6
+ import { logger } from '../../utils/logger.js';
7
+ import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
8
+ import { summarizeToolInput } from '../permission.js';
9
+ import { getOwner } from '../../config.js';
10
10
  /**
11
11
  * 统一消息处理器
12
12
  * 负责处理来自不同渠道的消息,协调事件流处理
@@ -18,11 +18,13 @@ export class MessageProcessor {
18
18
  eventBus;
19
19
  commandHandler;
20
20
  channels = new Map();
21
+ channelTypeMap = new Map(); // channelType → channelName(首个实例)
21
22
  currentFlusher;
22
23
  shouldSuppressActivities = false;
23
24
  agentMap;
24
25
  defaultAgentId;
25
26
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
27
+ interactionRouter;
26
28
  /** 按 agentId 获取 agent,回退到默认 */
27
29
  getAgent(agentId) {
28
30
  if (agentId && this.agentMap.has(agentId))
@@ -62,23 +64,31 @@ export class MessageProcessor {
62
64
  }
63
65
  });
64
66
  }
67
+ setInteractionRouter(router) {
68
+ this.interactionRouter = router;
69
+ }
65
70
  /**
66
71
  * 注册渠道适配器
67
72
  */
68
73
  registerChannel(adapter, policy, options) {
69
- this.channels.set(adapter.name, { adapter, options, policy });
74
+ this.channels.set(adapter.channelName, { adapter, options, policy });
75
+ // 维护 channelType → channelName 映射(首个实例优先)
76
+ const type = options?.channelType || adapter.channelName;
77
+ if (!this.channelTypeMap.has(type)) {
78
+ this.channelTypeMap.set(type, adapter.channelName);
79
+ }
70
80
  }
71
81
  /**
72
- * 获取渠道适配器
82
+ * 获取渠道适配器(支持实例名和 channelType)
73
83
  */
74
84
  getAdapter(channelName) {
75
- return this.channels.get(channelName)?.adapter;
85
+ return this.resolveChannelInfo(channelName)?.adapter;
76
86
  }
77
87
  /**
78
- * 获取渠道信息(含 policy)
88
+ * 获取渠道信息(含 policy,支持实例名和 channelType
79
89
  */
80
90
  getChannelInfo(channelName) {
81
- return this.channels.get(channelName);
91
+ return this.resolveChannelInfo(channelName);
82
92
  }
83
93
  /**
84
94
  * 处理 compact 开始事件
@@ -91,19 +101,48 @@ export class MessageProcessor {
91
101
  this.currentFlusher.addActivity('\u23f3 会话压缩中...');
92
102
  }
93
103
  }
104
+ /**
105
+ * 根据 channel 标识查找渠道信息
106
+ * 先按实例名精确匹配,再按 channelType 映射到实例名
107
+ */
108
+ resolveChannelInfo(channel) {
109
+ // 1. 精确匹配实例名
110
+ let info = this.channels.get(channel);
111
+ if (info)
112
+ return info;
113
+ // 2. 按 channelType 查找(兼容按类型名路由)
114
+ const instanceName = this.channelTypeMap.get(channel);
115
+ if (instanceName)
116
+ info = this.channels.get(instanceName);
117
+ return info;
118
+ }
119
+ // 命令前缀列表(与 CommandHandler.quickCommandPrefixes 保持同步)
120
+ static COMMAND_PREFIXES = [
121
+ '/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart',
122
+ '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork',
123
+ '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/send', '/check',
124
+ '/p ', '/s ', '/name ',
125
+ ];
126
+ /** 判断消息内容是否为已知命令 */
127
+ isKnownCommand(content) {
128
+ return content === '/p' || content === '/s' ||
129
+ MessageProcessor.COMMAND_PREFIXES.some(cmd => content.startsWith(cmd));
130
+ }
94
131
  /**
95
132
  * 处理消息(主入口)
96
133
  */
97
134
  async processMessage(message) {
98
135
  const idleMs = (this.config.idleMonitor?.timeout ?? 120) * 1000;
99
- const channelInfo = this.channels.get(message.channel);
136
+ // 先解析会话,再优先用 session.metadata.channelName 精确定位实例级 adapter
137
+ // message.channel 现在存实例名(channelName),可直接用于精确路由
138
+ const { session, absoluteProjectPath } = await this.resolveSession(message);
139
+ const channelKey = session.metadata?.channelName || message.channel;
140
+ const channelInfo = this.resolveChannelInfo(channelKey);
100
141
  if (!channelInfo) {
101
- logger.error(`[MessageProcessor] Unknown channel: ${message.channel}`);
142
+ logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
102
143
  return;
103
144
  }
104
145
  const { policy } = channelInfo;
105
- // 解析会话(唯一的 getOrCreateSession 调用点)
106
- const { session, absoluteProjectPath } = await this.resolveSession(message);
107
146
  const streamKey = session.id;
108
147
  const chatType = message.chatType || 'private';
109
148
  const identityRole = session.identity?.role || 'anonymous';
@@ -142,7 +181,7 @@ export class MessageProcessor {
142
181
  const msg = showIdleMonitor
143
182
  ? result.message
144
183
  : `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
145
- channelInfo.adapter.sendText(message.channelId, msg).catch(e => {
184
+ channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(session)).catch(e => {
146
185
  logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
147
186
  });
148
187
  }
@@ -157,7 +196,7 @@ export class MessageProcessor {
157
196
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
158
197
  if (channelInfo && showIdleMonitor && !shouldSuppress()) {
159
198
  if (!isBackground) {
160
- channelInfo.adapter.sendText(message.channelId, result.message).catch(e => {
199
+ channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(session)).catch(e => {
161
200
  logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
162
201
  });
163
202
  }
@@ -183,12 +222,21 @@ export class MessageProcessor {
183
222
  // 上下文过长是可恢复错误,不累计触发安全模式
184
223
  if (errorType === ErrorType.CONTEXT_TOO_LONG) {
185
224
  logger.info(`[MessageProcessor] Context too long error, skipping safe mode accumulation`);
225
+ // 认证错误(401 / Invalid API Key)不是会话问题,安全模式无法修复,不累计
226
+ }
227
+ else if (errorType === ErrorType.AUTH_ERROR) {
228
+ logger.info(`[MessageProcessor] Auth error (invalid API key), skipping safe mode accumulation`);
229
+ // API 错误(5xx / 算力池切换等)是平台暂时性问题,安全模式无法修复,不累计
230
+ }
231
+ else if (errorType === ErrorType.API_ERROR) {
232
+ logger.info(`[MessageProcessor] API error, skipping safe mode accumulation`);
186
233
  }
187
234
  else if (!policy.accumulateErrors(chatType, identityRole)) {
188
235
  logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping safe mode accumulation`);
189
236
  }
190
237
  else {
191
- const newCount = await this.sessionManager.recordError(session.id, errorType, error.message);
238
+ const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
239
+ const newCount = await this.sessionManager.recordError(session.id, prefixed, error.message);
192
240
  await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
193
241
  }
194
242
  }
@@ -238,9 +286,17 @@ ${suggestions}`, sendOpts);
238
286
  }
239
287
  async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
240
288
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
241
- const channelInfo = this.channels.get(message.channel);
289
+ const channelKey = session.metadata?.channelName || message.channel;
290
+ const channelInfo = this.resolveChannelInfo(channelKey);
242
291
  if (!channelInfo) {
243
- logger.error(`[MessageProcessor] Unknown channel: ${message.channel}`);
292
+ logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
293
+ return;
294
+ }
295
+ // 二次拦截:如果命令消息绕过 MessageBridge 的 handleCommand 泄漏到这里,
296
+ // 静默丢弃而不是发送给 Agent(命令已在 MessageBridge 层处理过)
297
+ const rawContent = message.content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
298
+ if (rawContent.startsWith('/') && this.isKnownCommand(rawContent)) {
299
+ logger.warn(`[MessageProcessor] Command leaked past MessageBridge, dropped: "${rawContent.substring(0, 40)}"`);
244
300
  return;
245
301
  }
246
302
  const { adapter, options } = channelInfo;
@@ -279,21 +335,23 @@ ${suggestions}`, sendOpts);
279
335
  // 创建 StreamFlusher,传入文件标记模式用于自动过滤
280
336
  // 使用动态判断,确保切换项目后不会继续输出
281
337
  let firstReply = true;
282
- const flusher = new StreamFlusher(async (text, isFinal) => {
338
+ const flusher = new StreamFlusher(async (text, isFinal, hasText) => {
283
339
  const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
284
340
  if (!isCurrentlyBackground) {
285
341
  const opts = {};
286
342
  if (isFinal)
287
- opts.title = '\u6700\u7ec8\u56de\u590d:';
343
+ opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
288
344
  // 话题会话:使用 Channel 预构建的 replyContext(确保消息进入话题)
289
345
  const replyCtx = session.metadata?.replyContext;
290
346
  if (replyCtx) {
291
347
  Object.assign(opts, replyCtx);
292
348
  }
293
349
  else if (firstReply && message.messageId) {
294
- // 主会话:首条消息引用回复用户原消息
295
- opts.replyToMessageId = message.messageId;
296
- firstReply = false;
350
+ // 主会话:首条消息引用回复用户原消息(只在含真实文字时消费)
351
+ if (hasText) {
352
+ opts.replyToMessageId = message.messageId;
353
+ firstReply = false;
354
+ }
297
355
  }
298
356
  await adapter.sendText(message.channelId, text, Object.keys(opts).length ? opts : undefined);
299
357
  }
@@ -306,9 +364,15 @@ ${suggestions}`, sendOpts);
306
364
  agent.setSendPrompt(async (text) => {
307
365
  await adapter.sendText(message.channelId, text, this.getReplyContext(session));
308
366
  });
367
+ // 设置权限审批的交互上下文(支持交互卡片)
368
+ agent.setPermissionContext?.({
369
+ adapter,
370
+ channelId: message.channelId,
371
+ replyContext: this.getReplyContext(session),
372
+ interactionRouter: this.interactionRouter,
373
+ });
309
374
  // 设置 per-session 权限模式
310
- const permissionMode = session.metadata?.permissionMode || 'default';
311
- agent.setMode(permissionMode);
375
+ agent.setMode(session.metadata?.permissionMode ?? 'bypass');
312
376
  // 标记会话为处理中(实时持久化,重启后可恢复)
313
377
  this.sessionManager.markProcessing(session.id);
314
378
  // 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
@@ -317,15 +381,16 @@ ${suggestions}`, sendOpts);
317
381
  const effectivePrompt = prevInterruptReason === 'new_message' && session.agentSessionId
318
382
  ? `【新消息插入】\n\n${message.content}\n\n【请无视之前中断继续处理】`
319
383
  : message.content;
384
+ let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
320
385
  try {
321
386
  // 动态构建运行时上下文提示
322
387
  const contextParts = [];
388
+ const currentChannelType = options?.channelType || message.channel;
323
389
  // 1. 当前环境信息
324
390
  const peerLabel = session.identity?.role || 'unknown';
325
- const sessionName = session.name || '默认会话';
326
391
  const peerName = message.peerName || session.metadata?.peerName;
327
392
  const envParts = [
328
- `会话通道: ${message.channel}`,
393
+ `会话通道: ${currentChannelType}`,
329
394
  `当前项目: ${path.basename(absoluteProjectPath)}`,
330
395
  ];
331
396
  if (session.name)
@@ -333,25 +398,70 @@ ${suggestions}`, sendOpts);
333
398
  envParts.push(`对端身份: ${peerLabel}`);
334
399
  if (peerName)
335
400
  envParts.push(`对端名称: ${peerName}`);
401
+ if (session.chatType)
402
+ envParts.push(`聊天类型: ${session.chatType}`);
403
+ if (session.agentId && session.agentId !== 'claude')
404
+ envParts.push(`当前Agent: ${session.agentId}`);
336
405
  contextParts.push(`[当前环境] ${envParts.join(' | ')}`);
337
- // 2. 文件发送能力
338
- const fileChannels = [...this.channels.entries()]
339
- .filter(([, info]) => info.adapter.sendFile)
340
- .map(([name]) => name);
341
- const currentCanSend = fileChannels.includes(message.channel);
342
- const crossChannels = fileChannels.filter(n => n !== message.channel);
343
- if (currentCanSend || crossChannels.length > 0) {
406
+ // 只读模式提示
407
+ if (session.metadata?.permissionMode === 'readonly') {
408
+ contextParts.push('[只读模式] 禁止修改项目文件。如需生成文件供用户下载,请写入 .evolclaw/tmp/ 目录后使用 [SEND_FILE:] 发送');
409
+ }
410
+ // 2. 文件发送能力(按 channelType 去重,提示词只展示第一级通道名)
411
+ const fileChannelTypes = new Set();
412
+ const currentCanSend = !!channelInfo.adapter.sendFile;
413
+ for (const [, info] of this.channels) {
414
+ if (info.adapter.sendFile) {
415
+ fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
416
+ }
417
+ }
418
+ const crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
419
+ if (currentCanSend || crossChannelTypes.length > 0) {
344
420
  const hints = [];
345
421
  if (currentCanSend)
346
422
  hints.push(`[SEND_FILE:路径] 发送文件到当前通道`);
347
- if (crossChannels.length > 0)
348
- hints.push(`[SEND_FILE:${crossChannels[0]}:路径] 发送文件到指定通道(可用: ${crossChannels.join('/')})`);
423
+ if (crossChannelTypes.length > 0)
424
+ hints.push(`[SEND_FILE:${crossChannelTypes[0]}:路径] 发送文件到指定通道(可用: ${crossChannelTypes.join('/')})`);
349
425
  contextParts.push(hints.join(','));
350
426
  }
427
+ // 3. 当前通道能力
428
+ const capParts = [];
429
+ if (options?.supportsImages)
430
+ capParts.push('图片输入');
431
+ if (channelInfo.adapter.sendImage)
432
+ capParts.push('图片输出');
433
+ if (channelInfo.adapter.sendFile)
434
+ capParts.push('文件发送');
435
+ if (capParts.length > 0) {
436
+ contextParts.push(`[通道能力] ${capParts.join('、')}`);
437
+ }
351
438
  const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
352
- const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
353
- agent.registerStream(streamKey, stream);
354
- await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress);
439
+ // 可重试错误(403/429/5xx)指数退避重试,最多 3
440
+ const MAX_RETRIES = 3;
441
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
442
+ let streamRegistered = false;
443
+ try {
444
+ const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
445
+ agent.registerStream(streamKey, stream);
446
+ streamRegistered = true;
447
+ streamResult = await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress);
448
+ break; // 成功,跳出重试循环
449
+ }
450
+ catch (retryError) {
451
+ if (streamRegistered) {
452
+ agent.cleanupStream(streamKey);
453
+ }
454
+ if (attempt < MAX_RETRIES && isRetryableError(retryError)) {
455
+ const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
456
+ logger.warn(`[MessageProcessor] Retryable error (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
457
+ flusher.addActivity(`⚠️ API 暂时不可用,${delay / 1000}秒后重试 (${attempt}/${MAX_RETRIES})...`);
458
+ await flusher.flush();
459
+ await new Promise(resolve => setTimeout(resolve, delay));
460
+ continue;
461
+ }
462
+ throw retryError; // 不可重试或已耗尽重试次数
463
+ }
464
+ }
355
465
  }
356
466
  catch (error) {
357
467
  if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.agentSessionId && hasCompact(agent)) {
@@ -364,7 +474,7 @@ ${suggestions}`, sendOpts);
364
474
  flusher.addActivity('\u2705 压缩完成,正在重试...');
365
475
  const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, options?.systemPromptAppend, this.sessionManager);
366
476
  agent.registerStream(streamKey, retryStream);
367
- await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
477
+ streamResult = await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
368
478
  }
369
479
  else {
370
480
  throw new Error('CONTEXT_COMPACT_FAILED');
@@ -375,63 +485,93 @@ ${suggestions}`, sendOpts);
375
485
  }
376
486
  }
377
487
  // 处理文件标记 - 支持 [SEND_FILE:path] 和 [SEND_FILE:channel:path]
488
+ // 注意:始终扫描全部文本(含中间轮),因为文件标记可能出现在任意轮次
489
+ // suppressed 模式下 flusher 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
378
490
  const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
379
491
  const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
380
- const fullText = flusher.getFinalText();
492
+ const flusherText = flusher.getFinalText();
493
+ const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
381
494
  const fileMatches = [...fullText.matchAll(markerPattern)];
382
495
  for (const match of fileMatches) {
383
496
  // 兼容旧格式 (1组) 和新格式 (2组)
384
497
  const hasChannelGroup = match.length >= 3;
385
- const targetChannelName = hasChannelGroup ? (match[1] ?? message.channel) : message.channel;
498
+ const targetSpec = hasChannelGroup ? (match[1] ?? undefined) : undefined;
386
499
  const filePath = (hasChannelGroup ? match[2] : match[1]).trim();
387
500
  if (this.isPlaceholderPath(filePath)) {
388
- logger.info(`[${adapter.name}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
501
+ logger.info(`[${adapter.channelName}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
389
502
  continue;
390
503
  }
504
+ // 解析目标:按实例名匹配,再按 channelType 映射
505
+ let targetInfo = targetSpec ? this.channels.get(targetSpec) : channelInfo;
506
+ let targetLabel = targetSpec || message.channel;
507
+ if (targetSpec && !targetInfo) {
508
+ // 按 channelType 查找首个匹配的实例
509
+ const instanceName = this.channelTypeMap.get(targetSpec);
510
+ if (instanceName)
511
+ targetInfo = this.channels.get(instanceName);
512
+ }
513
+ const currentChannelType = channelInfo.options?.channelType || adapter.channelName;
514
+ const isCrossChannel = targetSpec && targetSpec !== message.channel
515
+ && targetSpec !== currentChannelType;
391
516
  // 跨通道仅限 owner
392
- if (targetChannelName !== message.channel && session.identity?.role !== 'owner') {
517
+ if (isCrossChannel && session.identity?.role !== 'owner') {
393
518
  await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(session));
394
519
  continue;
395
520
  }
396
521
  const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
397
522
  if (!fs.existsSync(resolvedPath)) {
398
- logger.warn(`[${adapter.name}] File not found: ${resolvedPath}`);
523
+ logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
399
524
  await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(session));
400
525
  continue;
401
526
  }
402
527
  // 找目标 adapter
403
- const targetInfo = this.channels.get(targetChannelName);
404
528
  if (!targetInfo) {
405
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetChannelName} 未启用或不存在`, this.getReplyContext(session));
529
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(session));
406
530
  continue;
407
531
  }
408
532
  if (!targetInfo.adapter.sendFile) {
409
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetChannelName} 不支持文件发送`, this.getReplyContext(session));
533
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(session));
410
534
  continue;
411
535
  }
412
536
  // 找目标 channelId
413
537
  let targetChannelId = message.channelId;
414
- if (targetChannelName !== message.channel) {
415
- const ownerPeerId = getOwner(this.config, targetChannelName);
416
- targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelName, ownerPeerId) ?? '') : '';
538
+ if (isCrossChannel) {
539
+ const targetAdapterName = targetInfo.adapter.channelName;
540
+ const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
541
+ const ownerPeerId = getOwner(this.config, targetAdapterName);
542
+ targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
417
543
  if (!targetChannelId) {
418
- await adapter.sendText(message.channelId, `\u274c 未找到 ${targetChannelName} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
544
+ await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
419
545
  continue;
420
546
  }
421
547
  }
422
- logger.info(`[${adapter.name}] Sending file via ${targetChannelName}: ${resolvedPath}`);
548
+ logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
423
549
  try {
424
550
  await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(session));
425
- this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetChannelName });
426
- if (targetChannelName !== message.channel) {
427
- await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetChannelName} 发送`, this.getReplyContext(session));
551
+ this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
552
+ if (isCrossChannel) {
553
+ await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(session));
428
554
  }
429
555
  }
430
556
  catch (error) {
431
- logger.error(`[${adapter.name}] Failed to send file: ${resolvedPath}`, error);
557
+ logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
432
558
  await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(session));
433
559
  }
434
560
  }
561
+ // 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
562
+ // suppressed 模式:中间流式文本未推送,使用最后一轮回复(回退到全文)
563
+ // 非 suppressed 且无流式文本:同上
564
+ // 非 suppressed 且有流式文本:已经逐步推送过了,不重复添加
565
+ // 但如果 flusher 既未发送过内容也没有 pending 内容(如 text 事件全为空),仍需兜底
566
+ const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
567
+ if (finalReplyText) {
568
+ if (shouldSuppress()) {
569
+ flusher.addText(finalReplyText);
570
+ }
571
+ else if (!streamResult.hasReceivedText || (!flusher.hasSentContent() && !flusher.hasContent())) {
572
+ flusher.addText(finalReplyText);
573
+ }
574
+ }
435
575
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
436
576
  await flusher.flush(true);
437
577
  // 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
@@ -444,35 +584,72 @@ ${suggestions}`, sendOpts);
444
584
  }
445
585
  // 清理 activeStreams(正常完成)
446
586
  agent.cleanupStream(streamKey);
447
- // 清除处理中状态 + 记录成功响应
587
+ // 清除处理中状态
448
588
  this.sessionManager.clearProcessing(session.id);
449
589
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
450
590
  const interruptReason = this.interruptedSessions.get(session.id);
451
- adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
452
- await this.sessionManager.recordSuccess(session.id);
453
- this.eventBus.publish({
454
- type: 'message:completed',
455
- sessionId: session.id,
456
- channel: message.channel,
457
- channelId: message.channelId,
458
- durationMs: Date.now() - startTime,
459
- timestamp: Date.now()
460
- });
591
+ if (streamResult.isError) {
592
+ // Agent 流正常结束但任务结果失败(权限被拒、max turns、工具链失败等)
593
+ const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
594
+ const rawSubtype = streamResult.subtype || 'agent_error';
595
+ const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
596
+ adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(session));
597
+ this.eventBus.publish({
598
+ type: 'message:error',
599
+ sessionId: session.id,
600
+ error: errorSummary,
601
+ errorType,
602
+ terminalReason: streamResult.terminalReason
603
+ });
604
+ // 仅系统级 subtype 累计安全模式(权限拒绝、max turns 等用户操作不累计)
605
+ if (isInfraError(rawSubtype, streamResult.terminalReason)) {
606
+ const chatType = message.chatType || 'private';
607
+ const identityRole = session.identity?.role || 'anonymous';
608
+ const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
609
+ const { policy } = channelInfo;
610
+ if (policy.accumulateErrors(chatType, identityRole)) {
611
+ const newCount = await this.sessionManager.recordError(session.id, errorType, errorSummary);
612
+ await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount);
613
+ }
614
+ }
615
+ logger.message({
616
+ msgId: messageId,
617
+ sessionId: session.id,
618
+ dir: 'inbound',
619
+ status: 'failed',
620
+ error: errorSummary,
621
+ terminalReason: streamResult.terminalReason
622
+ });
623
+ }
624
+ else {
625
+ // 真正的成功
626
+ adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
627
+ await this.sessionManager.recordSuccess(session.id);
628
+ this.eventBus.publish({
629
+ type: 'message:completed',
630
+ sessionId: session.id,
631
+ channel: message.channel,
632
+ channelId: message.channelId,
633
+ terminalReason: streamResult.terminalReason,
634
+ finalText: streamResult.lastReplyText || undefined,
635
+ durationMs: Date.now() - startTime,
636
+ timestamp: Date.now()
637
+ });
638
+ // 记录处理完成
639
+ logger.message({
640
+ msgId: messageId,
641
+ sessionId: session.id,
642
+ dir: 'inbound',
643
+ status: 'completed',
644
+ duration: Date.now() - startTime
645
+ });
646
+ }
461
647
  const isFinallyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
462
648
  if (isFinallyBackground) {
463
649
  const projectName = path.basename(session.projectPath);
464
650
  const count = this.messageCache.getCount(session.id);
465
651
  await adapter.sendText(message.channelId, `[\u540e\u53f0-${projectName}] \u2713 任务完成 (${count}条消息已缓存)`);
466
652
  }
467
- const duration = Date.now() - startTime;
468
- // 记录处理完成
469
- logger.message({
470
- msgId: messageId,
471
- sessionId: session.id,
472
- dir: 'inbound',
473
- status: 'completed',
474
- duration
475
- });
476
653
  // 记录发送响应
477
654
  logger.message({
478
655
  msgId: `${messageId}_reply`,
@@ -491,39 +668,58 @@ ${suggestions}`, sendOpts);
491
668
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
492
669
  // 区分超时 / 中断 / 错误
493
670
  const errType = classifyError(error);
671
+ const interruptReason = this.interruptedSessions.get(session.id);
672
+ const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
494
673
  const procStatus = errType === ErrorType.SDK_TIMEOUT ? 'timeout'
495
674
  : errType === ErrorType.STREAM_ERROR ? 'interrupted'
496
675
  : 'error';
497
- try {
498
- adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
676
+ // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
677
+ if (!isUserInterrupt) {
678
+ try {
679
+ adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
680
+ }
681
+ catch { }
682
+ }
683
+ // 用户主动中断时降级日志;其余仍按 error 记录
684
+ if (isUserInterrupt) {
685
+ logger.info(`[${message.channel}] Interrupted by user (${interruptReason})`);
686
+ }
687
+ else {
688
+ logger.error(`[${message.channel}] Error:`, error);
499
689
  }
500
- catch { }
501
- logger.error(`[${message.channel}] Error:`, error);
502
690
  const errorMsg = error instanceof Error ? error.message : String(error);
503
- const errorType = errType;
691
+ const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
504
692
  this.eventBus.publish({
505
693
  type: 'message:error',
506
- sessionId: message.channelId,
694
+ sessionId: session.id,
507
695
  error: errorMsg,
508
- errorType: String(errorType)
696
+ errorType
509
697
  });
510
698
  // 记录处理失败
511
699
  logger.message({
512
700
  msgId: messageId,
513
- sessionId: message.channelId,
701
+ sessionId: session.id,
514
702
  dir: 'inbound',
515
703
  status: 'failed',
516
704
  error: error instanceof Error ? error.message : String(error)
517
705
  });
518
- if (error instanceof Error) {
706
+ if (error instanceof Error && !isUserInterrupt) {
519
707
  logger.error(`[${message.channel}] Error stack:`, error.stack);
520
708
  }
521
709
  // 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
710
+ // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
711
+ // processEventStream 已通过 flusher 发过错误时也跳过
522
712
  if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
523
713
  logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
524
714
  }
715
+ else if (isUserInterrupt) {
716
+ logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
717
+ }
718
+ else if (error?._errorAlreadySent) {
719
+ logger.info(`[MessageProcessor] Error already sent via flusher, skip sending duplicate message`);
720
+ }
525
721
  else {
526
- const userMessage = getErrorMessage(error);
722
+ const userMessage = getErrorMessage(error, undefined);
527
723
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
528
724
  let sendOpts;
529
725
  try {
@@ -558,6 +754,9 @@ ${suggestions}`, sendOpts);
558
754
  async processEventStream(stream, session, flusher, resetTimer, shouldSuppress) {
559
755
  let hasReceivedText = false;
560
756
  let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
757
+ let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
758
+ // 追踪最后一轮 assistant 回复文本(tool_use 之后的纯文本)
759
+ let lastReplyText = '';
561
760
  try {
562
761
  for await (const event of stream) {
563
762
  // 每收到事件重置空闲超时
@@ -570,12 +769,31 @@ ${suggestions}`, sendOpts);
570
769
  logger.info(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
571
770
  continue;
572
771
  }
772
+ // session 状态变更(idle/running/requires_action)
773
+ if (event.type === 'state_changed') {
774
+ logger.info(`[MessageProcessor] Session state: ${event.state} for session: ${session.id}`);
775
+ this.eventBus.publish({ type: 'agent:state-changed', sessionId: session.id, state: event.state });
776
+ continue;
777
+ }
778
+ // agent 状态通知(仅事件,不直出给用户)
779
+ if (event.type === 'status') {
780
+ logger.info(`[MessageProcessor] Agent status: ${event.subtype}: ${event.message}`);
781
+ this.eventBus.publish({
782
+ type: 'agent:status',
783
+ sessionId: session.id,
784
+ subtype: event.subtype,
785
+ message: event.message,
786
+ timestamp: Date.now()
787
+ });
788
+ continue;
789
+ }
573
790
  const isCurrentlyBackground = await this.isBackgroundSession(session, session.channel, session.channelId);
574
791
  // === 前台任务:正常处理所有事件 ===
575
792
  if (!isCurrentlyBackground) {
576
793
  // 流式文本
577
794
  if (event.type === 'text') {
578
795
  hasReceivedText = true;
796
+ lastReplyText += event.text;
579
797
  this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
580
798
  if (!shouldSuppress()) {
581
799
  flusher.addText(event.text);
@@ -602,6 +820,8 @@ ${suggestions}`, sendOpts);
602
820
  }
603
821
  // 工具调用
604
822
  if (event.type === 'tool_use') {
823
+ // 工具调用意味着当前文本是中间轮,重置最后回复追踪
824
+ lastReplyText = '';
605
825
  this.eventBus.publish({
606
826
  type: 'tool:use',
607
827
  sessionId: session.id,
@@ -638,46 +858,81 @@ ${suggestions}`, sendOpts);
638
858
  logger.warn(`[MessageProcessor] error event: ${event.errorType}: ${event.error}`);
639
859
  if (!hasErrorResult && !shouldSuppress()) {
640
860
  hasErrorResult = true;
641
- flusher.addActivity(`\u26a0\ufe0f ${event.error}`);
861
+ flusher.addActivity(`\u274c ${event.error}`);
642
862
  }
643
863
  }
644
864
  // 完成事件
865
+ // SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
866
+ // 仅记录状态,最终 flush(true) 在流结束后统一执行
645
867
  if (event.type === 'complete') {
646
868
  logger.debug(`[MessageProcessor] complete event: hasReceivedText=${hasReceivedText}, isError=${event.isError}, shouldSuppress=${shouldSuppress()}`);
869
+ // 自动回填会话名称
870
+ if (event.sessionTitle && session.name === '默认会话') {
871
+ await this.sessionManager.renameSession(session.id, event.sessionTitle);
872
+ logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
873
+ }
874
+ // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
875
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
647
876
  // 失败且无前置错误输出:显示 errors 摘要
648
- if (event.isError && !hasErrorResult && !shouldSuppress()) {
877
+ // 但用户主动中断(新消息打断 /stop 命令)时不显示错误提示
878
+ const interruptReason = this.interruptedSessions.get(session.id);
879
+ const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
880
+ if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt) {
649
881
  const errorSummary = event.errors?.join('; ') || '\u4efb\u52a1\u6267\u884c\u5931\u8d25';
650
- flusher.addActivity(`\u26a0\ufe0f ${errorSummary}`);
882
+ // 使用 terminalReason 提供更友好的错误提示
883
+ const userFriendlyMessage = event.terminalReason
884
+ ? getErrorMessage(null, event.terminalReason)
885
+ : `\u274c ${errorSummary}`;
886
+ flusher.addActivity(userFriendlyMessage);
651
887
  }
652
- // 成功结果文本:suppressed 模式下总是添加,否则仅在无流式文本时添加
653
- if (event.result) {
654
- if (shouldSuppress()) {
655
- flusher.addText(event.result);
656
- }
657
- else if (!hasReceivedText) {
658
- flusher.addText(event.result);
659
- }
888
+ // 中间 complete:flush 掉已有 activities(不带 isFinal),让中间结果及时显示
889
+ // 最终文本留给流结束后的统一 flush(true)
890
+ if (flusher.hasContent()) {
891
+ await flusher.flushActivitiesOnly();
660
892
  }
661
- await flusher.flush(true);
662
893
  }
663
894
  continue;
664
895
  }
665
- // === 后台任务:只处理 complete 事件,仅缓存不发送 ===
896
+ // === 后台任务:追踪最后回复文本,但只处理 complete 事件 ===
897
+ if (event.type === 'text') {
898
+ lastReplyText += event.text;
899
+ }
900
+ else if (event.type === 'tool_use') {
901
+ lastReplyText = '';
902
+ }
666
903
  if (event.type !== 'complete') {
667
904
  continue;
668
905
  }
906
+ // 自动回填会话名称
907
+ if (event.sessionTitle && session.name === '默认会话') {
908
+ await this.sessionManager.renameSession(session.id, event.sessionTitle);
909
+ logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
910
+ }
911
+ // 记录完成状态
912
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
669
913
  if (event.subtype === 'success') {
670
914
  this.messageCache.addEvent(session.id, {
671
915
  type: 'completed',
672
- message: event.result || '',
916
+ message: lastReplyText || event.result || '',
673
917
  timestamp: Date.now(),
674
918
  metadata: {
675
919
  duration: event.durationMs,
676
920
  cost: event.costUsd
677
921
  }
678
922
  });
923
+ // 后台任务完成也纳入统计
924
+ this.eventBus.publish({
925
+ type: 'message:completed',
926
+ sessionId: session.id,
927
+ channel: session.channel,
928
+ channelId: session.channelId,
929
+ finalText: lastReplyText || event.result || undefined,
930
+ durationMs: event.durationMs,
931
+ timestamp: Date.now()
932
+ });
679
933
  }
680
934
  else if (event.isError === true) {
935
+ const bgErrorType = prefixErrorType(ERROR_PREFIX.AGENT, event.subtype || 'agent_error');
681
936
  this.messageCache.addEvent(session.id, {
682
937
  type: 'error',
683
938
  message: event.errors?.join('\n') || '\u672a\u77e5\u9519\u8bef',
@@ -686,16 +941,42 @@ ${suggestions}`, sendOpts);
686
941
  errorType: event.subtype
687
942
  }
688
943
  });
944
+ // 后台任务失败也纳入统计
945
+ this.eventBus.publish({
946
+ type: 'message:error',
947
+ sessionId: session.id,
948
+ error: event.errors?.join('; ') || '\u672a\u77e5\u9519\u8bef',
949
+ errorType: bgErrorType
950
+ });
689
951
  }
690
952
  }
691
953
  }
692
954
  catch (error) {
693
- logger.error('[MessageProcessor] Stream processing error:', error);
955
+ // User interrupt (AbortError) is expected, log at info level
956
+ if (error instanceof Error && error.name === 'AbortError') {
957
+ logger.info('[MessageProcessor] Stream interrupted (AbortError)');
958
+ }
959
+ else {
960
+ logger.error('[MessageProcessor] Stream processing error:', error);
961
+ }
694
962
  if (error instanceof Error && error.message.includes('process exited')) {
695
963
  flusher.addActivity('\u274c Claude Code \u8fdb\u7a0b\u5f02\u5e38\u9000\u51fa\uff0c\u8bf7\u91cd\u8bd5');
696
964
  }
965
+ // Flush any pending error activities before re-throwing,
966
+ // and mark the error so outer catch won't send a duplicate message
967
+ if (hasErrorResult || flusher.hasContent()) {
968
+ try {
969
+ await flusher.flush(true);
970
+ }
971
+ catch { }
972
+ if (error instanceof Error) {
973
+ error._errorAlreadySent = true;
974
+ }
975
+ }
697
976
  throw error;
698
977
  }
978
+ completeResult.hasReceivedText = hasReceivedText;
979
+ return completeResult;
699
980
  }
700
981
  /**
701
982
  * 解析文件路径,支持相对路径和绝对路径