evolclaw 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,7 +78,8 @@ export class MessageBridge {
78
78
  // 3. session 解析(使用 Channel 层填充的 chatType)
79
79
  const chatType = msg.chatType || 'private';
80
80
  const metadata = {};
81
- if (msg.replyContext)
81
+ // 话题会话创建时写入 replyContext(用于 threadId 路由);主会话不写(避免群聊覆盖)
82
+ if (msg.threadId && msg.replyContext)
82
83
  metadata.replyContext = msg.replyContext;
83
84
  // 写入实例名(审计 + 精确出站路由)
84
85
  metadata.channelName = channelName;
@@ -101,6 +102,7 @@ export class MessageBridge {
101
102
  chatType,
102
103
  images: msg.images, timestamp: Date.now(),
103
104
  peerId: msg.peerId, peerName: msg.peerName,
105
+ peerType: msg.peerType,
104
106
  messageId: msg.messageId,
105
107
  mentions: msg.mentions, threadId: msg.threadId,
106
108
  replyContext: msg.replyContext,
@@ -150,8 +152,7 @@ export class MessageBridge {
150
152
  return false;
151
153
  if (parsed.type === 'menu.query') {
152
154
  const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
153
- const isAdmin = identity.role === 'owner';
154
- const items = this.cmdHandler.getMenuItems(isAdmin);
155
+ const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
155
156
  const response = JSON.stringify({ type: 'menu.response', items });
156
157
  if (adapter?.sendCustomPayload) {
157
158
  adapter.sendCustomPayload(msg.channelId, response);
@@ -195,8 +196,8 @@ export class MessageBridge {
195
196
  return true;
196
197
  }
197
198
  /**
198
- * 撤回消息:先查 debounce 窗口,再查 message queue
199
- * @returns true 如果找到并取消
199
+ * 撤回消息:先查 debounce 窗口,再查 message queue,最后查正在执行的任务。
200
+ * @returns true 如果找到并取消/中断
200
201
  */
201
202
  cancel(messageId) {
202
203
  // 阶段 1: debounce 窗口(尚未入队)
@@ -205,7 +206,10 @@ export class MessageBridge {
205
206
  return true;
206
207
  }
207
208
  // 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
208
- return this.messageQueue.cancel(messageId);
209
+ if (this.messageQueue.cancel(messageId))
210
+ return true;
211
+ // 阶段 3: 正在执行的任务 → 触发 interrupt
212
+ return this.messageQueue.cancelActive(messageId);
209
213
  }
210
214
  /** 清理资源 */
211
215
  dispose() {
@@ -7,6 +7,7 @@ import { logger } from '../../utils/logger.js';
7
7
  import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
8
8
  import { summarizeToolInput } from '../permission.js';
9
9
  import { getOwner } from '../../config.js';
10
+ import { getPackageRoot, resolveRoot } from '../../paths.js';
10
11
  /**
11
12
  * 统一消息处理器
12
13
  * 负责处理来自不同渠道的消息,协调事件流处理
@@ -25,6 +26,9 @@ export class MessageProcessor {
25
26
  defaultAgentId;
26
27
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
27
28
  interactionRouter;
29
+ messageQueue;
30
+ skillsHintDesc = undefined; // undefined=未加载, null=无模板, string=缓存描述
31
+ skillsEnsured = false; // 全局 SKILLS.md 是否已确保
28
32
  /** 按 agentId 获取 agent,回退到默认 */
29
33
  getAgent(agentId) {
30
34
  if (agentId && this.agentMap.has(agentId))
@@ -67,6 +71,9 @@ export class MessageProcessor {
67
71
  setInteractionRouter(router) {
68
72
  this.interactionRouter = router;
69
73
  }
74
+ setMessageQueue(queue) {
75
+ this.messageQueue = queue;
76
+ }
70
77
  /**
71
78
  * 注册渠道适配器
72
79
  */
@@ -149,7 +156,6 @@ export class MessageProcessor {
149
156
  // 按 session.agentId 选择 agent 后端
150
157
  const agent = this.getAgent(session.agentId);
151
158
  const monitorEnabled = this.config.idleMonitor?.enabled !== false;
152
- const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
153
159
  const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
154
160
  // 计算是否抑制中间输出(工具活动 + 流式文本)
155
161
  const shouldSuppress = () => {
@@ -181,7 +187,7 @@ export class MessageProcessor {
181
187
  const msg = showIdleMonitor
182
188
  ? result.message
183
189
  : `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
184
- channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(session)).catch(e => {
190
+ channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(message)).catch(e => {
185
191
  logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
186
192
  });
187
193
  }
@@ -196,7 +202,7 @@ export class MessageProcessor {
196
202
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
197
203
  if (channelInfo && showIdleMonitor && !shouldSuppress()) {
198
204
  if (!isBackground) {
199
- channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(session)).catch(e => {
205
+ channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(message)).catch(e => {
200
206
  logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
201
207
  });
202
208
  }
@@ -219,25 +225,24 @@ export class MessageProcessor {
219
225
  if (channelInfo) {
220
226
  try {
221
227
  const errorType = classifyError(error);
222
- // 上下文过长是可恢复错误,不累计触发安全模式
228
+ // 上下文过长是可恢复错误,不累计错误计数
223
229
  if (errorType === ErrorType.CONTEXT_TOO_LONG) {
224
- logger.info(`[MessageProcessor] Context too long error, skipping safe mode accumulation`);
225
- // 认证错误(401 / Invalid API Key)不是会话问题,安全模式无法修复,不累计
230
+ logger.info(`[MessageProcessor] Context too long error, skipping error accumulation`);
231
+ // 认证错误(401 / Invalid API Key)不是会话问题,不累计
226
232
  }
227
233
  else if (errorType === ErrorType.AUTH_ERROR) {
228
- logger.info(`[MessageProcessor] Auth error (invalid API key), skipping safe mode accumulation`);
229
- // API 错误(5xx / 算力池切换等)是平台暂时性问题,安全模式无法修复,不累计
234
+ logger.info(`[MessageProcessor] Auth error (invalid API key), skipping error accumulation`);
235
+ // API 错误(5xx / 算力池切换等)是平台暂时性问题,不累计
230
236
  }
231
237
  else if (errorType === ErrorType.API_ERROR) {
232
- logger.info(`[MessageProcessor] API error, skipping safe mode accumulation`);
238
+ logger.info(`[MessageProcessor] API error, skipping error accumulation`);
233
239
  }
234
240
  else if (!policy.accumulateErrors(chatType, identityRole)) {
235
- logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping safe mode accumulation`);
241
+ logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping error accumulation`);
236
242
  }
237
243
  else {
238
244
  const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
239
- const newCount = await this.sessionManager.recordError(session.id, prefixed, error.message);
240
- await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
245
+ await this.sessionManager.recordError(session.id, prefixed, error.message);
241
246
  }
242
247
  }
243
248
  catch (statusError) {
@@ -251,39 +256,11 @@ export class MessageProcessor {
251
256
  clearInterval(monitorInterval);
252
257
  }
253
258
  }
254
- /** session 提取渠道预构建的回复上下文 */
255
- getReplyContext(session) {
256
- return session.metadata?.replyContext;
257
- }
258
- /**
259
- * 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
260
- */
261
- async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors) {
262
- if (safeModeThreshold <= 0)
263
- return;
264
- const health = await this.sessionManager.getHealthStatus(session.id);
265
- const sendOpts = this.getReplyContext(session);
266
- const isThread = !!session.threadId;
267
- if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
268
- await this.sessionManager.setSafeMode(session.id, true);
269
- logger.warn(`[MessageProcessor] Session ${session.id} entered safe mode after ${consecutiveErrors} errors`);
270
- this.eventBus.publish({ type: 'session:safe-mode-entered', sessionId: session.id, consecutiveErrors });
271
- const suggestions = isThread
272
- ? `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /clear - 清空会话历史\n3. /status - 查看详细状态`
273
- : `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /new [名称] - 创建新会话(清空历史)\n3. /status - 查看详细状态`;
274
- await adapter.sendText(channelId, `\u26a0\ufe0f 安全模式已启用(连续 ${consecutiveErrors} 次异常)
275
-
276
- 当前限制:
277
- - 无法记住之前的对话
278
- - 每次提问需要提供完整上下文
279
-
280
- 建议操作:
281
- ${suggestions}`, sendOpts);
282
- }
283
- else if (safeModeThreshold >= 2 && consecutiveErrors === safeModeThreshold - 1) {
284
- await adapter.sendText(channelId, `\u26a0\ufe0f 检测到异常(${consecutiveErrors}/${safeModeThreshold})\n\n如果问题持续,系统将自动进入安全模式。建议使用 /status 查看状态。`, sendOpts);
285
- }
259
+ /** 获取回复上下文(跟着任务走) */
260
+ getReplyContext(message) {
261
+ return message.replyContext;
286
262
  }
263
+ /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
287
264
  async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
288
265
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
289
266
  const channelKey = session.metadata?.channelName || message.channel;
@@ -324,7 +301,7 @@ ${suggestions}`, sendOpts);
324
301
  logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
325
302
  // 记录开始处理
326
303
  this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
327
- adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(session));
304
+ adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(message));
328
305
  logger.message({
329
306
  msgId: messageId,
330
307
  sessionId: session.id,
@@ -341,8 +318,8 @@ ${suggestions}`, sendOpts);
341
318
  const opts = {};
342
319
  if (isFinal)
343
320
  opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
344
- // 话题会话:使用 Channel 预构建的 replyContext(确保消息进入话题)
345
- const replyCtx = session.metadata?.replyContext;
321
+ // replyContext 跟着任务走:优先用当前 message 的,兜底用 session 的(话题会话创建时写入)
322
+ const replyCtx = this.getReplyContext(message);
346
323
  if (replyCtx) {
347
324
  Object.assign(opts, replyCtx);
348
325
  }
@@ -362,17 +339,25 @@ ${suggestions}`, sendOpts);
362
339
  // 调用 AgentRunner(含上下文过长自动 compact 重试)
363
340
  // 设置权限审批的消息发送回调(指向当前渠道)
364
341
  agent.setSendPrompt(async (text) => {
365
- await adapter.sendText(message.channelId, text, this.getReplyContext(session));
342
+ await adapter.sendText(message.channelId, text, this.getReplyContext(message));
366
343
  });
367
344
  // 设置权限审批的交互上下文(支持交互卡片)
368
- agent.setPermissionContext?.({
345
+ agent.setPermissionContext?.(session.id, {
369
346
  adapter,
370
347
  channelId: message.channelId,
371
- replyContext: this.getReplyContext(session),
348
+ replyContext: this.getReplyContext(message),
372
349
  interactionRouter: this.interactionRouter,
350
+ interceptNextMessage: this.messageQueue
351
+ ? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
352
+ : undefined,
353
+ cancelIntercept: this.messageQueue
354
+ ? (sessionKey) => this.messageQueue.cancelIntercept(sessionKey)
355
+ : undefined,
373
356
  });
374
- // 设置 per-session 权限模式
375
- agent.setMode(session.metadata?.permissionMode ?? 'bypass');
357
+ // 设置 per-session 权限模式(动态默认值:owner → bypass,admin → auto,guest → readonly)
358
+ const role = session.identity?.role;
359
+ const defaultPermMode = role === 'owner' ? 'bypass' : role === 'admin' ? 'auto' : 'readonly';
360
+ agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
376
361
  // 标记会话为处理中(实时持久化,重启后可恢复)
377
362
  this.sessionManager.markProcessing(session.id);
378
363
  // 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
@@ -389,6 +374,7 @@ ${suggestions}`, sendOpts);
389
374
  // 1. 当前环境信息
390
375
  const peerLabel = session.identity?.role || 'unknown';
391
376
  const peerName = message.peerName || session.metadata?.peerName;
377
+ const peerType = message.peerType;
392
378
  const envParts = [
393
379
  `会话通道: ${currentChannelType}`,
394
380
  `当前项目: ${path.basename(absoluteProjectPath)}`,
@@ -398,6 +384,8 @@ ${suggestions}`, sendOpts);
398
384
  envParts.push(`对端身份: ${peerLabel}`);
399
385
  if (peerName)
400
386
  envParts.push(`对端名称: ${peerName}`);
387
+ if (peerType && peerType !== 'unknown')
388
+ envParts.push(`对端类型: ${peerType}`);
401
389
  if (session.chatType)
402
390
  envParts.push(`聊天类型: ${session.chatType}`);
403
391
  if (session.agentId && session.agentId !== 'claude')
@@ -435,6 +423,19 @@ ${suggestions}`, sendOpts);
435
423
  if (capParts.length > 0) {
436
424
  contextParts.push(`[通道能力] ${capParts.join('、')}`);
437
425
  }
426
+ // 4. 群聊 @ 规则:告知 agent 应该 @ 谁,由 agent 自行在回复中添加
427
+ if (message.chatType === 'group' && message.peerId) {
428
+ contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
429
+ }
430
+ // 5. Agent ctl 自管理指令提示 + SKILLS.md 生成
431
+ if (!this.skillsEnsured) {
432
+ this.ensureSkillsFile();
433
+ this.skillsEnsured = true;
434
+ }
435
+ const skillsHint = this.getSkillsHint();
436
+ if (skillsHint) {
437
+ contextParts.push(`[EvolClaw 自管理] ${skillsHint}`);
438
+ }
438
439
  const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
439
440
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
440
441
  const MAX_RETRIES = 3;
@@ -515,22 +516,22 @@ ${suggestions}`, sendOpts);
515
516
  && targetSpec !== currentChannelType;
516
517
  // 跨通道仅限 owner
517
518
  if (isCrossChannel && session.identity?.role !== 'owner') {
518
- await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(session));
519
+ await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(message));
519
520
  continue;
520
521
  }
521
522
  const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
522
523
  if (!fs.existsSync(resolvedPath)) {
523
524
  logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
524
- await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(session));
525
+ await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(message));
525
526
  continue;
526
527
  }
527
528
  // 找目标 adapter
528
529
  if (!targetInfo) {
529
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(session));
530
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(message));
530
531
  continue;
531
532
  }
532
533
  if (!targetInfo.adapter.sendFile) {
533
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(session));
534
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(message));
534
535
  continue;
535
536
  }
536
537
  // 找目标 channelId
@@ -541,21 +542,21 @@ ${suggestions}`, sendOpts);
541
542
  const ownerPeerId = getOwner(this.config, targetAdapterName);
542
543
  targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
543
544
  if (!targetChannelId) {
544
- await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
545
+ await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
545
546
  continue;
546
547
  }
547
548
  }
548
549
  logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
549
550
  try {
550
- await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(session));
551
+ await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(message));
551
552
  this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
552
553
  if (isCrossChannel) {
553
- await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(session));
554
+ await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(message));
554
555
  }
555
556
  }
556
557
  catch (error) {
557
558
  logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
558
- await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(session));
559
+ await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(message));
559
560
  }
560
561
  }
561
562
  // 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
@@ -574,14 +575,6 @@ ${suggestions}`, sendOpts);
574
575
  }
575
576
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
576
577
  await flusher.flush(true);
577
- // 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
578
- const healthStatus = await this.sessionManager.getHealthStatus(session.id);
579
- if (healthStatus.safeMode) {
580
- const hint = session.threadId
581
- ? '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
582
- : '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
583
- await adapter.sendText(message.channelId, hint, this.getReplyContext(session));
584
- }
585
578
  // 清理 activeStreams(正常完成)
586
579
  agent.cleanupStream(streamKey);
587
580
  // 清除处理中状态
@@ -593,7 +586,7 @@ ${suggestions}`, sendOpts);
593
586
  const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
594
587
  const rawSubtype = streamResult.subtype || 'agent_error';
595
588
  const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
596
- adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(session));
589
+ adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(message));
597
590
  this.eventBus.publish({
598
591
  type: 'message:error',
599
592
  sessionId: session.id,
@@ -601,15 +594,13 @@ ${suggestions}`, sendOpts);
601
594
  errorType,
602
595
  terminalReason: streamResult.terminalReason
603
596
  });
604
- // 仅系统级 subtype 累计安全模式(权限拒绝、max turns 等用户操作不累计)
597
+ // 系统级 subtype 仍累计错误计数,供 /status 诊断使用
605
598
  if (isInfraError(rawSubtype, streamResult.terminalReason)) {
606
599
  const chatType = message.chatType || 'private';
607
600
  const identityRole = session.identity?.role || 'anonymous';
608
- const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
609
601
  const { policy } = channelInfo;
610
602
  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);
603
+ await this.sessionManager.recordError(session.id, errorType, errorSummary);
613
604
  }
614
605
  }
615
606
  logger.message({
@@ -623,7 +614,7 @@ ${suggestions}`, sendOpts);
623
614
  }
624
615
  else {
625
616
  // 真正的成功
626
- adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
617
+ adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(message));
627
618
  await this.sessionManager.recordSuccess(session.id);
628
619
  this.eventBus.publish({
629
620
  type: 'message:completed',
@@ -676,7 +667,7 @@ ${suggestions}`, sendOpts);
676
667
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
677
668
  if (!isUserInterrupt) {
678
669
  try {
679
- adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
670
+ adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(message));
680
671
  }
681
672
  catch { }
682
673
  }
@@ -724,7 +715,7 @@ ${suggestions}`, sendOpts);
724
715
  let sendOpts;
725
716
  try {
726
717
  const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
727
- sendOpts = this.getReplyContext(session);
718
+ sendOpts = this.getReplyContext(message);
728
719
  }
729
720
  catch { }
730
721
  await adapter.sendText(message.channelId, userMessage, sendOpts);
@@ -735,11 +726,12 @@ ${suggestions}`, sendOpts);
735
726
  * 解析会话和项目路径
736
727
  */
737
728
  async resolveSession(message) {
738
- // 话题会话:使用 Channel 预构建的 replyContext
739
- const metadata = message.replyContext
729
+ // 话题会话创建时写入 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
730
+ const metadata = (message.threadId && message.replyContext)
740
731
  ? { replyContext: message.replyContext }
741
732
  : undefined;
742
733
  const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata, undefined, message.peerId);
734
+ // replyContext 不再写入 session.metadata(跟着 message 走,避免群聊多人覆盖)
743
735
  const absoluteProjectPath = path.isAbsolute(session.projectPath)
744
736
  ? session.projectPath
745
737
  : path.resolve(process.cwd(), session.projectPath);
@@ -953,9 +945,22 @@ ${suggestions}`, sendOpts);
953
945
  }
954
946
  catch (error) {
955
947
  // User interrupt (AbortError) is expected, log at info level
948
+ const catchInterruptReason = this.interruptedSessions.get(session.id);
949
+ const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop';
956
950
  if (error instanceof Error && error.name === 'AbortError') {
957
951
  logger.info('[MessageProcessor] Stream interrupted (AbortError)');
958
952
  }
953
+ else if (catchIsUserInterrupt) {
954
+ // SDK telemetry noise after user-initiated interrupt — not a real error
955
+ logger.debug('[MessageProcessor] Stream ended after user interrupt:', error?.message?.split('\n')[0]);
956
+ completeResult.isError = false;
957
+ completeResult.hasReceivedText = hasReceivedText;
958
+ return completeResult;
959
+ }
960
+ else if (isRetryableError(error)) {
961
+ // Retryable errors (network aborts, transient API failures) are noise at ERROR level
962
+ logger.warn('[MessageProcessor] Stream processing error (retryable):', error?.message?.split('\n')[0]);
963
+ }
959
964
  else {
960
965
  logger.error('[MessageProcessor] Stream processing error:', error);
961
966
  }
@@ -999,6 +1004,99 @@ ${suggestions}`, sendOpts);
999
1004
  // 都找不到,返回项目根目录路径
1000
1005
  return rootPath;
1001
1006
  }
1007
+ /**
1008
+ * 确保全局数据目录下有最新版本的 SKILLS.md
1009
+ * 目标:{EVOLCLAW_HOME}/data/SKILLS.md
1010
+ */
1011
+ ensureSkillsFile() {
1012
+ try {
1013
+ const targetDir = path.join(resolveRoot(), 'data');
1014
+ const targetPath = path.join(targetDir, 'SKILLS.md');
1015
+ const templatePath = path.join(getPackageRoot(), 'src', 'templates', 'skills.md');
1016
+ // 模板不存在则跳过(构建环境可能没有 src/)
1017
+ if (!fs.existsSync(templatePath)) {
1018
+ // 尝试 dist/templates/skills.md
1019
+ const distTemplatePath = path.join(getPackageRoot(), 'dist', 'templates', 'skills.md');
1020
+ if (!fs.existsSync(distTemplatePath))
1021
+ return;
1022
+ this.copySkillsIfNeeded(distTemplatePath, targetDir, targetPath);
1023
+ return;
1024
+ }
1025
+ this.copySkillsIfNeeded(templatePath, targetDir, targetPath);
1026
+ }
1027
+ catch {
1028
+ // 静默失败,不影响正常消息处理
1029
+ }
1030
+ }
1031
+ copySkillsIfNeeded(templatePath, targetDir, targetPath) {
1032
+ const templateContent = fs.readFileSync(templatePath, 'utf-8');
1033
+ const templateVersion = templateContent.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
1034
+ if (fs.existsSync(targetPath)) {
1035
+ const existing = fs.readFileSync(targetPath, 'utf-8');
1036
+ const existingVersion = existing.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
1037
+ if (this.compareSemver(existingVersion, templateVersion) >= 0)
1038
+ return; // 已是最新
1039
+ }
1040
+ if (!fs.existsSync(targetDir)) {
1041
+ fs.mkdirSync(targetDir, { recursive: true });
1042
+ }
1043
+ fs.writeFileSync(targetPath, templateContent, 'utf-8');
1044
+ }
1045
+ /** 简易 semver 比较:支持 "1", "1.0", "1.0.0" 等格式,返回 -1/0/1 */
1046
+ compareSemver(a, b) {
1047
+ const pa = a.split('.').map(Number);
1048
+ const pb = b.split('.').map(Number);
1049
+ const len = Math.max(pa.length, pb.length);
1050
+ for (let i = 0; i < len; i++) {
1051
+ const na = pa[i] || 0;
1052
+ const nb = pb[i] || 0;
1053
+ if (na !== nb)
1054
+ return na > nb ? 1 : -1;
1055
+ }
1056
+ return 0;
1057
+ }
1058
+ /**
1059
+ * 从模板 frontmatter 缓存提示(懒加载,整个进程只读一次模板文件)
1060
+ */
1061
+ getSkillsHint() {
1062
+ if (this.skillsHintDesc === undefined) {
1063
+ this.skillsHintDesc = this.loadSkillsHint();
1064
+ }
1065
+ return this.skillsHintDesc;
1066
+ }
1067
+ /**
1068
+ * 从包模板源读取 frontmatter 并生成提示(仅执行一次)
1069
+ */
1070
+ loadSkillsHint() {
1071
+ try {
1072
+ const candidates = [
1073
+ path.join(getPackageRoot(), 'src', 'templates', 'skills.md'),
1074
+ path.join(getPackageRoot(), 'dist', 'templates', 'skills.md'),
1075
+ ];
1076
+ for (const templatePath of candidates) {
1077
+ if (!fs.existsSync(templatePath))
1078
+ continue;
1079
+ const content = fs.readFileSync(templatePath, 'utf-8');
1080
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
1081
+ if (!frontmatterMatch)
1082
+ continue;
1083
+ const fm = frontmatterMatch[1];
1084
+ const desc = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || 'EvolClaw 运行时管理指令';
1085
+ const trigger = fm.match(/^trigger:\s*(.+)$/m)?.[1]?.trim() || '';
1086
+ const skillsPath = path.join(resolveRoot(), 'data', 'SKILLS.md');
1087
+ const parts = [
1088
+ `可通过 Bash 执行 \`evolclaw ctl <cmd>\` 管理运行时:${desc}`,
1089
+ trigger ? `触发时机:${trigger}` : '',
1090
+ `完整文档见 ${skillsPath}`,
1091
+ ];
1092
+ return parts.filter(Boolean).join('\n');
1093
+ }
1094
+ return null;
1095
+ }
1096
+ catch {
1097
+ return null;
1098
+ }
1099
+ }
1002
1100
  /**
1003
1101
  * 判断文件路径是否为占位符/示例文本
1004
1102
  * 用于过滤大模型在说明文字中误写的 [SEND_FILE:...] 标记
@@ -8,10 +8,12 @@ export class MessageQueue {
8
8
  currentSessionKey;
9
9
  currentProjectPath;
10
10
  currentAgentId;
11
+ activeMessageIds = new Set(); // 正在执行的消息 ID
11
12
  interruptCallback;
12
13
  eventBus;
13
14
  recentMessageIds = new Set();
14
15
  DEDUP_WINDOW = 60_000; // 1 分钟窗口
16
+ interceptors = new Map();
15
17
  constructor(handler) {
16
18
  this.handler = handler;
17
19
  }
@@ -21,6 +23,19 @@ export class MessageQueue {
21
23
  setEventBus(eventBus) {
22
24
  this.eventBus = eventBus;
23
25
  }
26
+ /**
27
+ * 注册一次性消息拦截器:下一条来自 sessionKey 的消息不入队、不触发 interrupt,
28
+ * 直接传给 handler。用于 AskUserQuestion 的"手动输入"场景。
29
+ */
30
+ interceptNext(sessionKey, handler) {
31
+ this.interceptors.set(sessionKey, handler);
32
+ }
33
+ /**
34
+ * 取消拦截器(超时或卡片被其他方式回答时调用)
35
+ */
36
+ cancelIntercept(sessionKey) {
37
+ this.interceptors.delete(sessionKey);
38
+ }
24
39
  /**
25
40
  * 检查消息是否应该处理(去重)
26
41
  */
@@ -53,6 +68,14 @@ export class MessageQueue {
53
68
  if (!this.shouldProcess(message)) {
54
69
  return Promise.resolve();
55
70
  }
71
+ // 拦截器检查:AskUserQuestion 等场景的一次性消息拦截
72
+ const interceptor = this.interceptors.get(sessionKey);
73
+ if (interceptor) {
74
+ this.interceptors.delete(sessionKey);
75
+ logger.debug(`[Queue] Message intercepted for ${sessionKey}`);
76
+ interceptor(message);
77
+ return Promise.resolve();
78
+ }
56
79
  const queueKey = this.getQueueKey(sessionKey, projectPath);
57
80
  logger.debug(`[Queue] Enqueuing message for ${queueKey}`);
58
81
  return new Promise((resolve, reject) => {
@@ -97,6 +120,7 @@ export class MessageQueue {
97
120
  this.processing.delete(queueKey);
98
121
  this.currentSessionKey = undefined;
99
122
  this.currentProjectPath = undefined;
123
+ this.activeMessageIds.clear();
100
124
  return;
101
125
  }
102
126
  // FIFO 贪心合并:弹出队首连续同 peerId 的消息
@@ -105,6 +129,12 @@ export class MessageQueue {
105
129
  this.currentSessionKey = queueKey;
106
130
  this.currentProjectPath = merged.projectPath;
107
131
  this.currentAgentId = merged.message.agentId;
132
+ // 记录正在执行的 messageId(用于撤回中断)
133
+ this.activeMessageIds.clear();
134
+ for (const item of items) {
135
+ if (item.message.messageId)
136
+ this.activeMessageIds.add(item.message.messageId);
137
+ }
108
138
  const resolves = items.map(i => i.resolve);
109
139
  const rejects = items.map(i => i.reject);
110
140
  logger.debug(`[Queue] Processing ${items.length} message(s) from ${merged.message.channel}:${merged.message.channelId}`);
@@ -200,6 +230,24 @@ export class MessageQueue {
200
230
  }
201
231
  return false;
202
232
  }
233
+ /**
234
+ * 撤回正在执行的消息:如果 messageId 正在处理中,触发 interrupt。
235
+ * @returns true 如果找到并触发了中断
236
+ */
237
+ cancelActive(messageId) {
238
+ if (!this.activeMessageIds.has(messageId))
239
+ return false;
240
+ if (!this.currentSessionKey)
241
+ return false;
242
+ // 从 queueKey 提取 sessionKey
243
+ const sessionKey = this.currentSessionKey.split('::')[0];
244
+ logger.info(`[Queue] Recalled active message ${messageId}, interrupting session ${sessionKey}`);
245
+ this.eventBus?.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'recalled' });
246
+ if (this.interruptCallback) {
247
+ this.interruptCallback(sessionKey, this.currentAgentId).catch(() => { });
248
+ }
249
+ return true;
250
+ }
203
251
  /**
204
252
  * 外部锁:快速命令(/compact, /clear)执行期间阻塞队列处理
205
253
  * 返回 release 函数
@@ -161,13 +161,13 @@ export class StreamFlusher {
161
161
  if (this.diagEnabled)
162
162
  diag(this.instanceId, 'flushActivitiesOnly', { outputLen: output.length });
163
163
  if (output) {
164
+ this.sentContent = true; // 同步标记,避免 timer flush 未 await 时的竞态
164
165
  const text = output;
165
166
  // chain 保持不断裂:单条失败不阻塞后续(catch → resolve)
166
167
  this.sendChain = this.sendChain
167
168
  .then(() => this.send(text, false, false))
168
169
  .catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
169
170
  await this.sendChain;
170
- this.sentContent = true;
171
171
  this.lastFlush = Date.now();
172
172
  this.flushCount++;
173
173
  }
@@ -207,6 +207,7 @@ export class StreamFlusher {
207
207
  if (this.diagEnabled)
208
208
  diag(this.instanceId, 'flush', { isFinal, outputLen: output.length, flushCount: this.flushCount, sinceLastFlush: Date.now() - this.lastFlush, preview: output.substring(0, 80) });
209
209
  if (output) {
210
+ this.sentContent = true; // 同步标记,避免 timer flush 未 await 时的竞态
210
211
  const text = output;
211
212
  const final = isFinal;
212
213
  const ht = hasText;
@@ -214,7 +215,6 @@ export class StreamFlusher {
214
215
  .then(() => this.send(text, final, ht))
215
216
  .catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
216
217
  await this.sendChain;
217
- this.sentContent = true;
218
218
  this.lastFlush = Date.now();
219
219
  this.flushCount++;
220
220
  }