evolclaw 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,6 +102,7 @@ export class MessageBridge {
102
102
  chatType,
103
103
  images: msg.images, timestamp: Date.now(),
104
104
  peerId: msg.peerId, peerName: msg.peerName,
105
+ peerType: msg.peerType,
105
106
  messageId: msg.messageId,
106
107
  mentions: msg.mentions, threadId: msg.threadId,
107
108
  replyContext: msg.replyContext,
@@ -151,8 +152,7 @@ export class MessageBridge {
151
152
  return false;
152
153
  if (parsed.type === 'menu.query') {
153
154
  const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
154
- const isAdmin = identity.role === 'owner';
155
- const items = this.cmdHandler.getMenuItems(isAdmin, msg.chatType || 'private');
155
+ const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
156
156
  const response = JSON.stringify({ type: 'menu.response', items });
157
157
  if (adapter?.sendCustomPayload) {
158
158
  adapter.sendCustomPayload(msg.channelId, response);
@@ -196,8 +196,8 @@ export class MessageBridge {
196
196
  return true;
197
197
  }
198
198
  /**
199
- * 撤回消息:先查 debounce 窗口,再查 message queue
200
- * @returns true 如果找到并取消
199
+ * 撤回消息:先查 debounce 窗口,再查 message queue,最后查正在执行的任务。
200
+ * @returns true 如果找到并取消/中断
201
201
  */
202
202
  cancel(messageId) {
203
203
  // 阶段 1: debounce 窗口(尚未入队)
@@ -206,7 +206,10 @@ export class MessageBridge {
206
206
  return true;
207
207
  }
208
208
  // 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
209
- return this.messageQueue.cancel(messageId);
209
+ if (this.messageQueue.cancel(messageId))
210
+ return true;
211
+ // 阶段 3: 正在执行的任务 → 触发 interrupt
212
+ return this.messageQueue.cancelActive(messageId);
210
213
  }
211
214
  /** 清理资源 */
212
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 = () => {
@@ -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, message);
245
+ await this.sessionManager.recordError(session.id, prefixed, error.message);
241
246
  }
242
247
  }
243
248
  catch (statusError) {
@@ -255,35 +260,7 @@ export class MessageProcessor {
255
260
  getReplyContext(message) {
256
261
  return message.replyContext;
257
262
  }
258
- /**
259
- * 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
260
- */
261
- async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors, message) {
262
- if (safeModeThreshold <= 0)
263
- return;
264
- const health = await this.sessionManager.getHealthStatus(session.id);
265
- const sendOpts = this.getReplyContext(message);
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
- }
286
- }
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;
@@ -365,14 +342,21 @@ ${suggestions}`, sendOpts);
365
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
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 权限模式(动态默认值:owner → bypass,guest → readonly)
375
- const defaultPermMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
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';
376
360
  agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
377
361
  // 标记会话为处理中(实时持久化,重启后可恢复)
378
362
  this.sessionManager.markProcessing(session.id);
@@ -390,6 +374,7 @@ ${suggestions}`, sendOpts);
390
374
  // 1. 当前环境信息
391
375
  const peerLabel = session.identity?.role || 'unknown';
392
376
  const peerName = message.peerName || session.metadata?.peerName;
377
+ const peerType = message.peerType;
393
378
  const envParts = [
394
379
  `会话通道: ${currentChannelType}`,
395
380
  `当前项目: ${path.basename(absoluteProjectPath)}`,
@@ -399,6 +384,8 @@ ${suggestions}`, sendOpts);
399
384
  envParts.push(`对端身份: ${peerLabel}`);
400
385
  if (peerName)
401
386
  envParts.push(`对端名称: ${peerName}`);
387
+ if (peerType && peerType !== 'unknown')
388
+ envParts.push(`对端类型: ${peerType}`);
402
389
  if (session.chatType)
403
390
  envParts.push(`聊天类型: ${session.chatType}`);
404
391
  if (session.agentId && session.agentId !== 'claude')
@@ -440,6 +427,15 @@ ${suggestions}`, sendOpts);
440
427
  if (message.chatType === 'group' && message.peerId) {
441
428
  contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
442
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
+ }
443
439
  const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
444
440
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
445
441
  const MAX_RETRIES = 3;
@@ -579,14 +575,6 @@ ${suggestions}`, sendOpts);
579
575
  }
580
576
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
581
577
  await flusher.flush(true);
582
- // 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
583
- const healthStatus = await this.sessionManager.getHealthStatus(session.id);
584
- if (healthStatus.safeMode) {
585
- const hint = session.threadId
586
- ? '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
587
- : '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
588
- await adapter.sendText(message.channelId, hint, this.getReplyContext(message));
589
- }
590
578
  // 清理 activeStreams(正常完成)
591
579
  agent.cleanupStream(streamKey);
592
580
  // 清除处理中状态
@@ -606,15 +594,13 @@ ${suggestions}`, sendOpts);
606
594
  errorType,
607
595
  terminalReason: streamResult.terminalReason
608
596
  });
609
- // 仅系统级 subtype 累计安全模式(权限拒绝、max turns 等用户操作不累计)
597
+ // 系统级 subtype 仍累计错误计数,供 /status 诊断使用
610
598
  if (isInfraError(rawSubtype, streamResult.terminalReason)) {
611
599
  const chatType = message.chatType || 'private';
612
600
  const identityRole = session.identity?.role || 'anonymous';
613
- const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
614
601
  const { policy } = channelInfo;
615
602
  if (policy.accumulateErrors(chatType, identityRole)) {
616
- const newCount = await this.sessionManager.recordError(session.id, errorType, errorSummary);
617
- await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount, message);
603
+ await this.sessionManager.recordError(session.id, errorType, errorSummary);
618
604
  }
619
605
  }
620
606
  logger.message({
@@ -959,9 +945,22 @@ ${suggestions}`, sendOpts);
959
945
  }
960
946
  catch (error) {
961
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';
962
950
  if (error instanceof Error && error.name === 'AbortError') {
963
951
  logger.info('[MessageProcessor] Stream interrupted (AbortError)');
964
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
+ }
965
964
  else {
966
965
  logger.error('[MessageProcessor] Stream processing error:', error);
967
966
  }
@@ -1005,6 +1004,99 @@ ${suggestions}`, sendOpts);
1005
1004
  // 都找不到,返回项目根目录路径
1006
1005
  return rootPath;
1007
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
+ }
1008
1100
  /**
1009
1101
  * 判断文件路径是否为占位符/示例文本
1010
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
  }
@@ -9,17 +9,22 @@ export class SessionManager {
9
9
  db;
10
10
  eventBus;
11
11
  ownerResolver;
12
+ adminResolver;
12
13
  fileAdapters = new Map();
13
- constructor(dbPath = resolvePaths().db, eventBus, ownerResolver) {
14
+ constructor(dbPath = resolvePaths().db, eventBus, ownerResolver, adminResolver) {
14
15
  ensureDir(path.dirname(dbPath));
15
16
  this.db = new DatabaseSync(dbPath);
16
17
  this.eventBus = eventBus;
17
18
  this.ownerResolver = ownerResolver;
19
+ this.adminResolver = adminResolver;
18
20
  this.initDatabase();
19
21
  }
20
22
  setOwnerResolver(resolver) {
21
23
  this.ownerResolver = resolver;
22
24
  }
25
+ setAdminResolver(resolver) {
26
+ this.adminResolver = resolver;
27
+ }
23
28
  registerFileAdapter(adapter) {
24
29
  this.fileAdapters.set(adapter.agentId, adapter);
25
30
  logger.debug(`[SessionManager] Registered file adapter: ${adapter.agentId}`);
@@ -62,8 +67,11 @@ export class SessionManager {
62
67
  resolveIdentity(channel, userId) {
63
68
  if (!userId)
64
69
  return { role: 'anonymous', mode: 'interactive' };
65
- const isOwner = this.ownerResolver?.(channel, userId) ?? false;
66
- return { role: isOwner ? 'owner' : 'guest', mode: 'interactive' };
70
+ if (this.ownerResolver?.(channel, userId))
71
+ return { role: 'owner', mode: 'interactive' };
72
+ if (this.adminResolver?.(channel, userId))
73
+ return { role: 'admin', mode: 'interactive' };
74
+ return { role: 'guest', mode: 'interactive' };
67
75
  }
68
76
  /** 更新 session 的 identity(owner 绑定后调用) */
69
77
  async updateIdentity(sessionId, identity) {
@@ -546,6 +554,10 @@ export class SessionManager {
546
554
  sets.push('metadata = ?');
547
555
  values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
548
556
  }
557
+ if ('agentSessionId' in updates) {
558
+ sets.push('claude_session_id = ?');
559
+ values.push(updates.agentSessionId ?? null);
560
+ }
549
561
  if (sets.length === 0)
550
562
  return;
551
563
  sets.push('updated_at = ?');
@@ -731,6 +743,12 @@ export class SessionManager {
731
743
  `).get(targetChannel, ownerPeerId);
732
744
  return row?.channel_id;
733
745
  }
746
+ async getSessionById(sessionId) {
747
+ const row = this.db.prepare('SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL').get(sessionId);
748
+ if (!row)
749
+ return undefined;
750
+ return this.rowToSession(row);
751
+ }
734
752
  async getActiveSession(channel, channelId) {
735
753
  const row = this.db.prepare(`
736
754
  SELECT * FROM sessions