evolclaw 2.4.0 → 2.5.1
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.
- package/README.md +33 -14
- package/dist/agents/claude-runner.js +269 -23
- package/dist/agents/codex-runner.js +2 -8
- package/dist/agents/gemini-runner.js +1 -8
- package/dist/channels/aun.js +525 -53
- package/dist/channels/dingtalk.js +506 -0
- package/dist/channels/feishu.js +31 -231
- package/dist/channels/qqbot.js +391 -0
- package/dist/channels/wechat.js +36 -38
- package/dist/channels/wecom.js +549 -0
- package/dist/cli.js +86 -10
- package/dist/config.js +98 -2
- package/dist/core/command-handler.js +554 -130
- package/dist/core/message/message-bridge.js +26 -9
- package/dist/core/message/message-processor.js +152 -57
- package/dist/core/message/message-queue.js +48 -0
- package/dist/core/message/stream-flusher.js +2 -2
- package/dist/core/permission.js +7 -11
- package/dist/core/session/session-manager.js +21 -3
- package/dist/index.js +48 -13
- package/dist/ipc.js +14 -4
- package/dist/templates/skills.md +64 -0
- package/dist/utils/error-dict.js +63 -0
- package/dist/utils/error-utils.js +156 -56
- package/dist/utils/format.js +32 -0
- package/dist/utils/init-channel.js +752 -8
- package/dist/utils/init.js +85 -3
- package/dist/utils/media-cache.js +2 -0
- package/dist/utils/stats-collector.js +0 -8
- package/evolclaw-install.md +54 -0
- package/package.json +11 -4
|
@@ -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,14 +152,27 @@ 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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
adapter
|
|
155
|
+
if (parsed.cmd) {
|
|
156
|
+
// 动态子菜单查询
|
|
157
|
+
const items = await this.cmdHandler.getSubMenuItems(parsed.cmd, channel, msg.channelId, msg.peerId);
|
|
158
|
+
const response = JSON.stringify({ type: 'menu.response', cmd: parsed.cmd, items: items ?? [] });
|
|
159
|
+
if (adapter?.sendCustomPayload) {
|
|
160
|
+
adapter.sendCustomPayload(msg.channelId, response);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
await sendReply(msg.channelId, response);
|
|
164
|
+
}
|
|
159
165
|
}
|
|
160
166
|
else {
|
|
161
|
-
|
|
167
|
+
// 全量菜单
|
|
168
|
+
const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
|
|
169
|
+
const response = JSON.stringify({ type: 'menu.response', items });
|
|
170
|
+
if (adapter?.sendCustomPayload) {
|
|
171
|
+
adapter.sendCustomPayload(msg.channelId, response);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
await sendReply(msg.channelId, response);
|
|
175
|
+
}
|
|
162
176
|
}
|
|
163
177
|
return true;
|
|
164
178
|
}
|
|
@@ -196,8 +210,8 @@ export class MessageBridge {
|
|
|
196
210
|
return true;
|
|
197
211
|
}
|
|
198
212
|
/**
|
|
199
|
-
* 撤回消息:先查 debounce 窗口,再查 message queue
|
|
200
|
-
* @returns true
|
|
213
|
+
* 撤回消息:先查 debounce 窗口,再查 message queue,最后查正在执行的任务。
|
|
214
|
+
* @returns true 如果找到并取消/中断
|
|
201
215
|
*/
|
|
202
216
|
cancel(messageId) {
|
|
203
217
|
// 阶段 1: debounce 窗口(尚未入队)
|
|
@@ -206,7 +220,10 @@ export class MessageBridge {
|
|
|
206
220
|
return true;
|
|
207
221
|
}
|
|
208
222
|
// 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
|
|
209
|
-
|
|
223
|
+
if (this.messageQueue.cancel(messageId))
|
|
224
|
+
return true;
|
|
225
|
+
// 阶段 3: 正在执行的任务 → 触发 interrupt
|
|
226
|
+
return this.messageQueue.cancelActive(messageId);
|
|
210
227
|
}
|
|
211
228
|
/** 清理资源 */
|
|
212
229
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -360,19 +337,29 @@ ${suggestions}`, sendOpts);
|
|
|
360
337
|
// 保存当前 flusher,用于 compact 事件
|
|
361
338
|
this.currentFlusher = flusher;
|
|
362
339
|
// 调用 AgentRunner(含上下文过长自动 compact 重试)
|
|
340
|
+
// 捕获当前消息的上下文(闭包),避免后续消息处理时串台
|
|
341
|
+
const capturedChannelId = message.channelId;
|
|
342
|
+
const capturedReplyContext = this.getReplyContext(message);
|
|
363
343
|
// 设置权限审批的消息发送回调(指向当前渠道)
|
|
364
344
|
agent.setSendPrompt(async (text) => {
|
|
365
|
-
await adapter.sendText(
|
|
345
|
+
await adapter.sendText(capturedChannelId, text, capturedReplyContext);
|
|
366
346
|
});
|
|
367
347
|
// 设置权限审批的交互上下文(支持交互卡片)
|
|
368
|
-
agent.setPermissionContext?.({
|
|
348
|
+
agent.setPermissionContext?.(session.id, {
|
|
369
349
|
adapter,
|
|
370
|
-
channelId:
|
|
371
|
-
replyContext:
|
|
350
|
+
channelId: capturedChannelId,
|
|
351
|
+
replyContext: capturedReplyContext,
|
|
372
352
|
interactionRouter: this.interactionRouter,
|
|
353
|
+
interceptNextMessage: this.messageQueue
|
|
354
|
+
? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
|
|
355
|
+
: undefined,
|
|
356
|
+
cancelIntercept: this.messageQueue
|
|
357
|
+
? (sessionKey) => this.messageQueue.cancelIntercept(sessionKey)
|
|
358
|
+
: undefined,
|
|
373
359
|
});
|
|
374
|
-
// 设置 per-session 权限模式(动态默认值:owner → bypass,guest → readonly)
|
|
375
|
-
const
|
|
360
|
+
// 设置 per-session 权限模式(动态默认值:owner → bypass,admin → auto,guest → readonly)
|
|
361
|
+
const role = session.identity?.role;
|
|
362
|
+
const defaultPermMode = role === 'owner' ? 'bypass' : role === 'admin' ? 'auto' : 'readonly';
|
|
376
363
|
agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
|
|
377
364
|
// 标记会话为处理中(实时持久化,重启后可恢复)
|
|
378
365
|
this.sessionManager.markProcessing(session.id);
|
|
@@ -390,6 +377,7 @@ ${suggestions}`, sendOpts);
|
|
|
390
377
|
// 1. 当前环境信息
|
|
391
378
|
const peerLabel = session.identity?.role || 'unknown';
|
|
392
379
|
const peerName = message.peerName || session.metadata?.peerName;
|
|
380
|
+
const peerType = message.peerType;
|
|
393
381
|
const envParts = [
|
|
394
382
|
`会话通道: ${currentChannelType}`,
|
|
395
383
|
`当前项目: ${path.basename(absoluteProjectPath)}`,
|
|
@@ -399,6 +387,8 @@ ${suggestions}`, sendOpts);
|
|
|
399
387
|
envParts.push(`对端身份: ${peerLabel}`);
|
|
400
388
|
if (peerName)
|
|
401
389
|
envParts.push(`对端名称: ${peerName}`);
|
|
390
|
+
if (peerType && peerType !== 'unknown')
|
|
391
|
+
envParts.push(`对端类型: ${peerType}`);
|
|
402
392
|
if (session.chatType)
|
|
403
393
|
envParts.push(`聊天类型: ${session.chatType}`);
|
|
404
394
|
if (session.agentId && session.agentId !== 'claude')
|
|
@@ -440,6 +430,15 @@ ${suggestions}`, sendOpts);
|
|
|
440
430
|
if (message.chatType === 'group' && message.peerId) {
|
|
441
431
|
contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
|
|
442
432
|
}
|
|
433
|
+
// 5. Agent ctl 自管理指令提示 + SKILLS.md 生成
|
|
434
|
+
if (!this.skillsEnsured) {
|
|
435
|
+
this.ensureSkillsFile();
|
|
436
|
+
this.skillsEnsured = true;
|
|
437
|
+
}
|
|
438
|
+
const skillsHint = this.getSkillsHint();
|
|
439
|
+
if (skillsHint) {
|
|
440
|
+
contextParts.push(`[EvolClaw 自管理] ${skillsHint}`);
|
|
441
|
+
}
|
|
443
442
|
const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
|
|
444
443
|
// 可重试错误(403/429/5xx)指数退避重试,最多 3 次
|
|
445
444
|
const MAX_RETRIES = 3;
|
|
@@ -579,14 +578,6 @@ ${suggestions}`, sendOpts);
|
|
|
579
578
|
}
|
|
580
579
|
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
581
580
|
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
581
|
// 清理 activeStreams(正常完成)
|
|
591
582
|
agent.cleanupStream(streamKey);
|
|
592
583
|
// 清除处理中状态
|
|
@@ -606,15 +597,13 @@ ${suggestions}`, sendOpts);
|
|
|
606
597
|
errorType,
|
|
607
598
|
terminalReason: streamResult.terminalReason
|
|
608
599
|
});
|
|
609
|
-
//
|
|
600
|
+
// 系统级 subtype 仍累计错误计数,供 /status 诊断使用
|
|
610
601
|
if (isInfraError(rawSubtype, streamResult.terminalReason)) {
|
|
611
602
|
const chatType = message.chatType || 'private';
|
|
612
603
|
const identityRole = session.identity?.role || 'anonymous';
|
|
613
|
-
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
614
604
|
const { policy } = channelInfo;
|
|
615
605
|
if (policy.accumulateErrors(chatType, identityRole)) {
|
|
616
|
-
|
|
617
|
-
await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount, message);
|
|
606
|
+
await this.sessionManager.recordError(session.id, errorType, errorSummary);
|
|
618
607
|
}
|
|
619
608
|
}
|
|
620
609
|
logger.message({
|
|
@@ -959,9 +948,22 @@ ${suggestions}`, sendOpts);
|
|
|
959
948
|
}
|
|
960
949
|
catch (error) {
|
|
961
950
|
// User interrupt (AbortError) is expected, log at info level
|
|
951
|
+
const catchInterruptReason = this.interruptedSessions.get(session.id);
|
|
952
|
+
const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop';
|
|
962
953
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
963
954
|
logger.info('[MessageProcessor] Stream interrupted (AbortError)');
|
|
964
955
|
}
|
|
956
|
+
else if (catchIsUserInterrupt) {
|
|
957
|
+
// SDK telemetry noise after user-initiated interrupt — not a real error
|
|
958
|
+
logger.debug('[MessageProcessor] Stream ended after user interrupt:', error?.message?.split('\n')[0]);
|
|
959
|
+
completeResult.isError = false;
|
|
960
|
+
completeResult.hasReceivedText = hasReceivedText;
|
|
961
|
+
return completeResult;
|
|
962
|
+
}
|
|
963
|
+
else if (isRetryableError(error)) {
|
|
964
|
+
// Retryable errors (network aborts, transient API failures) are noise at ERROR level
|
|
965
|
+
logger.warn('[MessageProcessor] Stream processing error (retryable):', error?.message?.split('\n')[0]);
|
|
966
|
+
}
|
|
965
967
|
else {
|
|
966
968
|
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
967
969
|
}
|
|
@@ -1005,6 +1007,99 @@ ${suggestions}`, sendOpts);
|
|
|
1005
1007
|
// 都找不到,返回项目根目录路径
|
|
1006
1008
|
return rootPath;
|
|
1007
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* 确保全局数据目录下有最新版本的 SKILLS.md
|
|
1012
|
+
* 目标:{EVOLCLAW_HOME}/data/SKILLS.md
|
|
1013
|
+
*/
|
|
1014
|
+
ensureSkillsFile() {
|
|
1015
|
+
try {
|
|
1016
|
+
const targetDir = path.join(resolveRoot(), 'data');
|
|
1017
|
+
const targetPath = path.join(targetDir, 'SKILLS.md');
|
|
1018
|
+
const templatePath = path.join(getPackageRoot(), 'src', 'templates', 'skills.md');
|
|
1019
|
+
// 模板不存在则跳过(构建环境可能没有 src/)
|
|
1020
|
+
if (!fs.existsSync(templatePath)) {
|
|
1021
|
+
// 尝试 dist/templates/skills.md
|
|
1022
|
+
const distTemplatePath = path.join(getPackageRoot(), 'dist', 'templates', 'skills.md');
|
|
1023
|
+
if (!fs.existsSync(distTemplatePath))
|
|
1024
|
+
return;
|
|
1025
|
+
this.copySkillsIfNeeded(distTemplatePath, targetDir, targetPath);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this.copySkillsIfNeeded(templatePath, targetDir, targetPath);
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// 静默失败,不影响正常消息处理
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
copySkillsIfNeeded(templatePath, targetDir, targetPath) {
|
|
1035
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
1036
|
+
const templateVersion = templateContent.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
|
|
1037
|
+
if (fs.existsSync(targetPath)) {
|
|
1038
|
+
const existing = fs.readFileSync(targetPath, 'utf-8');
|
|
1039
|
+
const existingVersion = existing.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
|
|
1040
|
+
if (this.compareSemver(existingVersion, templateVersion) >= 0)
|
|
1041
|
+
return; // 已是最新
|
|
1042
|
+
}
|
|
1043
|
+
if (!fs.existsSync(targetDir)) {
|
|
1044
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1045
|
+
}
|
|
1046
|
+
fs.writeFileSync(targetPath, templateContent, 'utf-8');
|
|
1047
|
+
}
|
|
1048
|
+
/** 简易 semver 比较:支持 "1", "1.0", "1.0.0" 等格式,返回 -1/0/1 */
|
|
1049
|
+
compareSemver(a, b) {
|
|
1050
|
+
const pa = a.split('.').map(Number);
|
|
1051
|
+
const pb = b.split('.').map(Number);
|
|
1052
|
+
const len = Math.max(pa.length, pb.length);
|
|
1053
|
+
for (let i = 0; i < len; i++) {
|
|
1054
|
+
const na = pa[i] || 0;
|
|
1055
|
+
const nb = pb[i] || 0;
|
|
1056
|
+
if (na !== nb)
|
|
1057
|
+
return na > nb ? 1 : -1;
|
|
1058
|
+
}
|
|
1059
|
+
return 0;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* 从模板 frontmatter 缓存提示(懒加载,整个进程只读一次模板文件)
|
|
1063
|
+
*/
|
|
1064
|
+
getSkillsHint() {
|
|
1065
|
+
if (this.skillsHintDesc === undefined) {
|
|
1066
|
+
this.skillsHintDesc = this.loadSkillsHint();
|
|
1067
|
+
}
|
|
1068
|
+
return this.skillsHintDesc;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* 从包模板源读取 frontmatter 并生成提示(仅执行一次)
|
|
1072
|
+
*/
|
|
1073
|
+
loadSkillsHint() {
|
|
1074
|
+
try {
|
|
1075
|
+
const candidates = [
|
|
1076
|
+
path.join(getPackageRoot(), 'src', 'templates', 'skills.md'),
|
|
1077
|
+
path.join(getPackageRoot(), 'dist', 'templates', 'skills.md'),
|
|
1078
|
+
];
|
|
1079
|
+
for (const templatePath of candidates) {
|
|
1080
|
+
if (!fs.existsSync(templatePath))
|
|
1081
|
+
continue;
|
|
1082
|
+
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
1083
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1084
|
+
if (!frontmatterMatch)
|
|
1085
|
+
continue;
|
|
1086
|
+
const fm = frontmatterMatch[1];
|
|
1087
|
+
const desc = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || 'EvolClaw 运行时管理指令';
|
|
1088
|
+
const trigger = fm.match(/^trigger:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
1089
|
+
const skillsPath = path.join(resolveRoot(), 'data', 'SKILLS.md');
|
|
1090
|
+
const parts = [
|
|
1091
|
+
`可通过 Bash 执行 \`evolclaw ctl <cmd>\` 管理运行时:${desc}`,
|
|
1092
|
+
trigger ? `触发时机:${trigger}` : '',
|
|
1093
|
+
`完整文档见 ${skillsPath}`,
|
|
1094
|
+
];
|
|
1095
|
+
return parts.filter(Boolean).join('\n');
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
catch {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1008
1103
|
/**
|
|
1009
1104
|
* 判断文件路径是否为占位符/示例文本
|
|
1010
1105
|
* 用于过滤大模型在说明文字中误写的 [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
|
}
|
package/dist/core/permission.js
CHANGED
|
@@ -94,6 +94,12 @@ export function summarizeToolInput(toolName, input) {
|
|
|
94
94
|
'Glob': (i) => `pattern: ${i.pattern}`,
|
|
95
95
|
'Agent': (i) => i.description || i.prompt?.substring(0, 80),
|
|
96
96
|
'Skill': (i) => i.skill ? `${i.skill}${i.args ? ' ' + i.args : ''}` : undefined,
|
|
97
|
+
'ExitPlanMode': (i) => {
|
|
98
|
+
if (i.allowedPrompts?.length) {
|
|
99
|
+
return `计划包含 ${i.allowedPrompts.length} 项操作权限`;
|
|
100
|
+
}
|
|
101
|
+
return '计划审批';
|
|
102
|
+
},
|
|
97
103
|
'TodoWrite': (i) => {
|
|
98
104
|
if (Array.isArray(i.todos)) {
|
|
99
105
|
return i.todos.map((t) => t.content || t.task || t.text).filter(Boolean).join(', ').substring(0, 80);
|
|
@@ -186,7 +192,6 @@ export class PermissionGateway {
|
|
|
186
192
|
},
|
|
187
193
|
channelId: context?.channelId || '',
|
|
188
194
|
sessionId,
|
|
189
|
-
expiresAt: Date.now() + this.timeout,
|
|
190
195
|
};
|
|
191
196
|
// 尝试富交互
|
|
192
197
|
let interactionSent = false;
|
|
@@ -204,16 +209,7 @@ export class PermissionGateway {
|
|
|
204
209
|
await sendPrompt(`🔐 权限请求\n工具:${toolName}\n操作:${displaySummary}${reasonLine}\n\n回复 /perm allow 本次允许 | always 始终允许 | deny 拒绝`);
|
|
205
210
|
}
|
|
206
211
|
return new Promise((resolve) => {
|
|
207
|
-
|
|
208
|
-
this.pending.delete(requestId);
|
|
209
|
-
// 清理 router 注册(仅删除本次请求,不影响其他交互)
|
|
210
|
-
if (interactionSent && context?.interactionRouter) {
|
|
211
|
-
context.interactionRouter.cancel(requestId);
|
|
212
|
-
}
|
|
213
|
-
this.eventBus?.publish({ type: 'permission:timeout', sessionId, requestId });
|
|
214
|
-
resolve('deny');
|
|
215
|
-
}, this.timeout);
|
|
216
|
-
this.pending.set(requestId, { sessionId, toolName, resolve, timer });
|
|
212
|
+
this.pending.set(requestId, { sessionId, toolName, resolve, timer: setTimeout(() => { }, 0) });
|
|
217
213
|
// 如果发了交互卡片,同时注册到 InteractionRouter
|
|
218
214
|
if (interactionSent && context?.interactionRouter) {
|
|
219
215
|
context.interactionRouter.register(requestId, sessionId, (action) => {
|
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|