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
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> AI Agent 统一网关 —— 连接 IM、终端、Agent 网络
|
|
4
4
|
|
|
5
|
-
EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex 等
|
|
5
|
+
EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex 等 Coding Agent 提供统一接入层,使其作为通用基座 Agent,接入到飞书、微信、钉钉、QQ频道、企业微信等多种 IM 通道,以及 AUN 多智能体网络。人类可以通过手机 IM 随时接力开发,其他 Agent 也可以通过 AUN 网络直接调用你的 Agent —— 不只是人机交互,也是 Agent 间协作的基础设施。
|
|
6
6
|
|
|
7
7
|
## 核心特性
|
|
8
8
|
|
|
@@ -11,10 +11,11 @@ EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex
|
|
|
11
11
|
- 🚀 **轻量化设计**:进程模式运行,CLI 命令行管理,无端口开放,无容器依赖,无 UI 界面
|
|
12
12
|
- 📁 **多项目支持**:每个项目独立会话,支持动态切换
|
|
13
13
|
- 👥 **双模式会话**:多用户私聊会话隔离,群聊会话共享,满足不同协作场景
|
|
14
|
-
- 🌐 **多渠道接入**:Channel Adapter 模式,飞书 + 微信 + AUN
|
|
14
|
+
- 🌐 **多渠道接入**:Channel Adapter 模式,飞书 + 微信 + 钉钉 + QQ频道 + 企业微信 + AUN 网络
|
|
15
15
|
- 🤖 **Agent 间互联**:通过 AUN 网络,你的 Agent 可被其他 Agent 发现和调用
|
|
16
16
|
- 🖥️ **终端 TUI 客户端**:`evolclaw tui` 直接在终端与远程 Agent 对话,无需 IM
|
|
17
|
-
- 🔐
|
|
17
|
+
- 🔐 **分层权限**:三级权限体系(user/admin/owner),多用户安全隔离
|
|
18
|
+
- 🛠️ **Agent 自管理**:Agent 可通过 CLI 命令自主管理运行时(查看状态、切换模型、调整配置等)
|
|
18
19
|
- 📦 **项目搬家**:`evolclaw mv` 一键迁移项目目录,保留 Claude/Codex/EvolClaw 全部会话历史
|
|
19
20
|
- 💾 **会话持久化**:会话数据与 CLI 工具共享,不额外存储,服务重启不丢失
|
|
20
21
|
- ⚡ **执行中插入**:任务执行中可发送新消息,自动中断当前任务并处理新请求
|
|
@@ -38,7 +39,7 @@ EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex
|
|
|
38
39
|
|
|
39
40
|
### 核心组件
|
|
40
41
|
|
|
41
|
-
1. **消息渠道层** (`src/channels/`) - Feishu
|
|
42
|
+
1. **消息渠道层** (`src/channels/`) - Feishu + WeChat + DingTalk + QQBot + WeCom + AUN 网络
|
|
42
43
|
2. **消息队列层** (`src/core/message/message-queue.ts`) - 会话级串行处理 + 中断支持
|
|
43
44
|
3. **命令处理层** (`src/core/command-handler.ts`) - 斜杠命令处理(CommandHandler 类)
|
|
44
45
|
4. **消息处理层** (`src/core/message/message-processor.ts`) - 统一事件处理引擎
|
|
@@ -111,6 +112,15 @@ evolclaw init feishu
|
|
|
111
112
|
# 单独配置微信(扫码登录)
|
|
112
113
|
evolclaw init wechat
|
|
113
114
|
|
|
115
|
+
# 单独配置钉钉(扫码登录)
|
|
116
|
+
evolclaw init dingtalk
|
|
117
|
+
|
|
118
|
+
# 单独配置 QQ 频道(扫码登录)
|
|
119
|
+
evolclaw init qqbot
|
|
120
|
+
|
|
121
|
+
# 单独配置企业微信(手动输入 Bot ID + Secret)
|
|
122
|
+
evolclaw init wecom
|
|
123
|
+
|
|
114
124
|
# 单独配置 AUN(Mesh 网络通道)
|
|
115
125
|
evolclaw init aun
|
|
116
126
|
```
|
|
@@ -193,6 +203,9 @@ evolclaw/
|
|
|
193
203
|
│ ├── channels/
|
|
194
204
|
│ │ ├── feishu.ts # 飞书 WebSocket 渠道
|
|
195
205
|
│ │ ├── wechat.ts # 微信 ClawBot 渠道
|
|
206
|
+
│ │ ├── dingtalk.ts # 钉钉 Stream 渠道
|
|
207
|
+
│ │ ├── qqbot.ts # QQ 频道渠道
|
|
208
|
+
│ │ ├── wecom.ts # 企业微信 AI Bot 渠道
|
|
196
209
|
│ │ └── aun.ts # AUN Mesh 网络渠道
|
|
197
210
|
│ ├── utils/ # 工具函数
|
|
198
211
|
│ ├── types.ts # 类型定义
|
|
@@ -219,9 +232,10 @@ evolclaw/
|
|
|
219
232
|
- `/name <新名称>` - 重命名当前会话
|
|
220
233
|
- `/del <名称>` - 删除指定会话(仅解绑,不删除文件)
|
|
221
234
|
- `/status` - 显示会话状态
|
|
235
|
+
- `/check` - 系统健康检查(摘要)
|
|
222
236
|
- `/help` - 显示所有命令
|
|
223
237
|
|
|
224
|
-
###
|
|
238
|
+
### 管理员级命令(Admin+ 可用)
|
|
225
239
|
|
|
226
240
|
**项目管理**:
|
|
227
241
|
- `/pwd` - 显示当前项目路径
|
|
@@ -238,32 +252,37 @@ evolclaw/
|
|
|
238
252
|
**系统管理**:
|
|
239
253
|
- `/clear` - 清空对话历史
|
|
240
254
|
- `/compact` - 压缩会话上下文
|
|
255
|
+
- `/rewind <turn>` - 回退会话到指定轮次
|
|
241
256
|
- `/stop` - 中断当前任务
|
|
257
|
+
- `/check` - 系统健康检查(详情)
|
|
258
|
+
- `/activity [all|dm|owner|none]` - 查看/控制中间输出显示模式
|
|
259
|
+
- `/restart <channel>` - 重连指定渠道
|
|
260
|
+
|
|
261
|
+
### Owner 专属命令
|
|
262
|
+
|
|
242
263
|
- `/send <文件路径>` - 发送文件给用户
|
|
243
|
-
- `/check` - 系统健康检查面板
|
|
244
264
|
- `/restart` - 重启服务(自愈机制)
|
|
245
265
|
- `/repair` - 检查并修复会话
|
|
246
|
-
- `/
|
|
266
|
+
- `/agentmd [put|set]` - 管理 AUN agent.md(仅 AUN 渠道)
|
|
247
267
|
|
|
248
268
|
## 技术栈
|
|
249
269
|
|
|
250
270
|
- **运行时**:Node.js >= 22 + TypeScript(ES modules)
|
|
251
271
|
- **AI SDK**:@anthropic-ai/claude-agent-sdk >= 0.2.75、@openai/codex-sdk、Gemini CLI
|
|
252
|
-
- **消息渠道**:飞书(@larksuiteoapi/node-sdk)、微信(ClawBot ilink API)、AUN
|
|
272
|
+
- **消息渠道**:飞书(@larksuiteoapi/node-sdk)、微信(ClawBot ilink API)、钉钉(dingtalk-stream)、QQ频道(pure-qqbot)、企业微信(AI Bot API)、AUN 网络
|
|
253
273
|
- **数据存储**:node:sqlite(内置模块)+ JSONL(CLI 共用)
|
|
254
274
|
- **测试框架**:Vitest
|
|
255
275
|
|
|
256
276
|
## TODO
|
|
257
277
|
|
|
258
|
-
- [x] Windows 系统 CLI 命令支持
|
|
259
|
-
- [x] 微信插件支持图片/文件的收发
|
|
260
278
|
- [x] AUN Mesh 网络通道接入
|
|
261
279
|
- [x] TUI 终端客户端(`evolclaw tui`)
|
|
262
280
|
- [x] 项目搬家工具(`evolclaw mv`)
|
|
263
|
-
- [
|
|
264
|
-
- [x]
|
|
265
|
-
- [
|
|
266
|
-
- [ ]
|
|
281
|
+
- [x] 手动授权支持(文本回复 + 飞书卡片)
|
|
282
|
+
- [x] 自动授权可配置(自动放行/自动拒绝)
|
|
283
|
+
- [ ] AUN 群组扩展功能支持
|
|
284
|
+
- [ ] 触发器支持
|
|
285
|
+
- [ ] 统计/状态监控 WebHook
|
|
267
286
|
|
|
268
287
|
|
|
269
288
|
## 许可证
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { query, forkSession as sdkForkSession } from '@anthropic-ai/claude-agent-sdk';
|
|
1
|
+
import { query, forkSession as sdkForkSession, getSessionMessages as sdkGetSessionMessages } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { ensureDir, resolveAnthropicConfig } from '../config.js';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
@@ -82,7 +82,8 @@ export class AgentRunner {
|
|
|
82
82
|
onCompactStart;
|
|
83
83
|
permissionGateway;
|
|
84
84
|
sendPromptFn;
|
|
85
|
-
|
|
85
|
+
permissionContexts = new Map();
|
|
86
|
+
currentEvolclawSessionId;
|
|
86
87
|
constructor(apiKey, model, onSessionIdUpdate, baseUrl, config) {
|
|
87
88
|
this.apiKey = apiKey;
|
|
88
89
|
this.model = model || 'sonnet';
|
|
@@ -97,7 +98,8 @@ export class AgentRunner {
|
|
|
97
98
|
ANTHROPIC_AUTH_TOKEN: this.apiKey,
|
|
98
99
|
PATH: process.env.PATH,
|
|
99
100
|
DISABLE_AUTOUPDATER: '1',
|
|
100
|
-
...(this.baseUrl ? { ANTHROPIC_BASE_URL: this.baseUrl } : {})
|
|
101
|
+
...(this.baseUrl ? { ANTHROPIC_BASE_URL: this.baseUrl } : {}),
|
|
102
|
+
...(this.currentEvolclawSessionId ? { EVOLCLAW_SESSION_ID: this.currentEvolclawSessionId } : {}),
|
|
101
103
|
};
|
|
102
104
|
}
|
|
103
105
|
setModel(model) {
|
|
@@ -139,8 +141,8 @@ export class AgentRunner {
|
|
|
139
141
|
setSendPrompt(fn) {
|
|
140
142
|
this.sendPromptFn = fn;
|
|
141
143
|
}
|
|
142
|
-
setPermissionContext(context) {
|
|
143
|
-
this.
|
|
144
|
+
setPermissionContext(sessionId, context) {
|
|
145
|
+
this.permissionContexts.set(sessionId, context);
|
|
144
146
|
}
|
|
145
147
|
toSdkPermissionMode() {
|
|
146
148
|
const map = {
|
|
@@ -186,6 +188,182 @@ export class AgentRunner {
|
|
|
186
188
|
setCompactStartCallback(callback) {
|
|
187
189
|
this.onCompactStart = callback;
|
|
188
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* 处理 AskUserQuestion 工具调用:将 SDK 问题转换为飞书 action 卡片,逐个收集用户答案
|
|
193
|
+
* SDK 期望返回 updatedInput 中包含 answers 字段:{ [questionText]: selectedLabel | selectedLabel[] }
|
|
194
|
+
*/
|
|
195
|
+
async handleAskUserQuestion(sessionId, input, options) {
|
|
196
|
+
const questions = input.questions;
|
|
197
|
+
// 没有交互上下文(无渠道适配器),回退到纯文本
|
|
198
|
+
const permCtx = this.permissionContexts.get(sessionId);
|
|
199
|
+
if (!permCtx?.adapter?.sendInteraction || !permCtx?.channelId) {
|
|
200
|
+
return this.handleAskUserQuestionFallback(input, questions);
|
|
201
|
+
}
|
|
202
|
+
const answers = {};
|
|
203
|
+
// 闭包捕获当前 sendPromptFn,避免异步等待期间被其他会话覆盖
|
|
204
|
+
const sendPrompt = this.sendPromptFn;
|
|
205
|
+
// 逐个 question 发送卡片并等待用户选择
|
|
206
|
+
for (let i = 0; i < questions.length; i++) {
|
|
207
|
+
const q = questions[i];
|
|
208
|
+
const requestId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
209
|
+
const cardTitle = q.header ? `💬 ${q.header}` : `💬 问题 ${i + 1}/${questions.length}`;
|
|
210
|
+
// 统一使用 action 按钮卡片(单选 / 多选均用按钮)
|
|
211
|
+
const bodyLines = [q.question];
|
|
212
|
+
if (q.options.some(opt => opt.description)) {
|
|
213
|
+
bodyLines.push('');
|
|
214
|
+
q.options.forEach((opt, idx) => {
|
|
215
|
+
bodyLines.push(`${idx + 1}. **${opt.label}**${opt.description ? ` — ${opt.description}` : ''}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const interaction = {
|
|
219
|
+
type: 'interaction',
|
|
220
|
+
id: requestId,
|
|
221
|
+
kind: {
|
|
222
|
+
kind: 'action',
|
|
223
|
+
title: cardTitle,
|
|
224
|
+
body: bodyLines.join('\n'),
|
|
225
|
+
buttons: [
|
|
226
|
+
...q.options.map(opt => ({
|
|
227
|
+
key: opt.label,
|
|
228
|
+
label: opt.label,
|
|
229
|
+
style: 'default',
|
|
230
|
+
})),
|
|
231
|
+
...(permCtx.interceptNextMessage ? [{
|
|
232
|
+
key: '_custom_input',
|
|
233
|
+
label: '✏️ 手动输入',
|
|
234
|
+
style: 'default',
|
|
235
|
+
}] : []),
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
channelId: permCtx.channelId,
|
|
239
|
+
sessionId,
|
|
240
|
+
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
241
|
+
};
|
|
242
|
+
let cardSent = false;
|
|
243
|
+
try {
|
|
244
|
+
const result = await permCtx.adapter.sendInteraction(permCtx.channelId, interaction, permCtx.replyContext);
|
|
245
|
+
cardSent = !!result;
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
logger.warn(`[AgentRunner] AskUserQuestion card send failed for q${i}:`, err);
|
|
249
|
+
}
|
|
250
|
+
if (!cardSent) {
|
|
251
|
+
// 卡片发送失败,以纯文本展示选项并自动选推荐项
|
|
252
|
+
const firstLabel = q.options[0]?.label || '';
|
|
253
|
+
answers[q.question] = q.multiSelect ? [firstLabel] : firstLabel;
|
|
254
|
+
if (sendPrompt) {
|
|
255
|
+
const optText = q.options.map((o, idx) => ` ${idx + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n');
|
|
256
|
+
await sendPrompt(`💬 ${q.header || q.question}\n${q.header ? q.question + '\n' : ''}${optText}\n → 自动选择:${firstLabel}`);
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// 等待用户交互
|
|
261
|
+
const answer = await new Promise((resolve) => {
|
|
262
|
+
permCtx?.interactionRouter?.register(requestId, sessionId, (action, values) => {
|
|
263
|
+
if (action === 'cancel') {
|
|
264
|
+
resolve(null);
|
|
265
|
+
}
|
|
266
|
+
else if (action === '_custom_input' && permCtx.interceptNextMessage) {
|
|
267
|
+
// "手动输入":发提示,拦截下一条消息
|
|
268
|
+
const sendHint = async () => {
|
|
269
|
+
if (sendPrompt) {
|
|
270
|
+
await sendPrompt('✏️ 请输入你的想法,回复后继续……');
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
sendHint().catch(() => { });
|
|
274
|
+
permCtx.interceptNextMessage(sessionId, (msg) => {
|
|
275
|
+
resolve(msg.content || null);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
else if (q.multiSelect) {
|
|
279
|
+
// multiSelect 按钮点击:包装为数组
|
|
280
|
+
resolve([action]);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
resolve(action); // action = button key = option label
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
if (answer === null) {
|
|
288
|
+
// 取消,自动选第一项
|
|
289
|
+
const firstLabel = q.options[0]?.label || '';
|
|
290
|
+
answers[q.question] = q.multiSelect ? [firstLabel] : firstLabel;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
answers[q.question] = answer;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const updatedInput = { ...input, answers };
|
|
297
|
+
return { behavior: 'allow', updatedInput, decisionClassification: 'user_temporary' };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* AskUserQuestion 纯文本 fallback:发送文本格式的问题,直接 allow
|
|
301
|
+
*/
|
|
302
|
+
async handleAskUserQuestionFallback(input, questions) {
|
|
303
|
+
// 自动选择每个问题的第一个选项
|
|
304
|
+
const answers = {};
|
|
305
|
+
if (questions?.length) {
|
|
306
|
+
const lines = questions.map(q => {
|
|
307
|
+
const firstLabel = q.options[0]?.label || '';
|
|
308
|
+
answers[q.question] = firstLabel;
|
|
309
|
+
const optText = q.options.map((o, i) => ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n');
|
|
310
|
+
return `${q.question}\n${optText}\n → 自动选择:${firstLabel}`;
|
|
311
|
+
});
|
|
312
|
+
if (this.sendPromptFn) {
|
|
313
|
+
await this.sendPromptFn(`💬 以下问题已自动选择第一项:\n\n${lines.join('\n\n')}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const updatedInput = { ...input, answers };
|
|
317
|
+
return { behavior: 'allow', updatedInput, decisionClassification: 'user_temporary' };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* 处理 ExitPlanMode 工具调用:plan mode 审批,等待用户批准后才继续执行
|
|
321
|
+
*/
|
|
322
|
+
async handleExitPlanMode(sessionId, input, options) {
|
|
323
|
+
const permCtx = this.permissionContexts.get(sessionId);
|
|
324
|
+
const sendPrompt = this.sendPromptFn;
|
|
325
|
+
// 无交互上下文,直接 allow(防御性兜底)
|
|
326
|
+
if (!permCtx?.adapter?.sendInteraction || !permCtx?.channelId || !sendPrompt) {
|
|
327
|
+
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' };
|
|
328
|
+
}
|
|
329
|
+
const requestId = `plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
330
|
+
const interaction = {
|
|
331
|
+
type: 'interaction',
|
|
332
|
+
id: requestId,
|
|
333
|
+
kind: {
|
|
334
|
+
kind: 'action',
|
|
335
|
+
title: '📋 计划审批',
|
|
336
|
+
body: 'AI 已完成规划,等待审批。\n请查看以上计划内容后决定。',
|
|
337
|
+
buttons: [
|
|
338
|
+
{ key: 'approve', label: '✅ 批准执行', style: 'primary' },
|
|
339
|
+
{ key: 'reject', label: '❌ 拒绝', style: 'danger' },
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
channelId: permCtx.channelId,
|
|
343
|
+
sessionId,
|
|
344
|
+
};
|
|
345
|
+
let cardSent = false;
|
|
346
|
+
try {
|
|
347
|
+
const result = await permCtx.adapter.sendInteraction(permCtx.channelId, interaction, permCtx.replyContext);
|
|
348
|
+
cardSent = !!result;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
logger.warn('[AgentRunner] ExitPlanMode card send failed:', err);
|
|
352
|
+
}
|
|
353
|
+
if (!cardSent) {
|
|
354
|
+
await sendPrompt('📋 计划审批\nAI 已完成规划,等待审批。\n回复 /plan approve 批准执行 | /plan reject 拒绝');
|
|
355
|
+
}
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
permCtx?.interactionRouter?.register(requestId, sessionId, (action) => {
|
|
358
|
+
if (action === 'approve') {
|
|
359
|
+
resolve({ behavior: 'allow', updatedInput: input, decisionClassification: 'user_temporary' });
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
resolve({ behavior: 'deny', message: '用户拒绝了计划', decisionClassification: 'user_reject' });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
189
367
|
/**
|
|
190
368
|
* SDK 原始事件 → 标准 AgentEvent 转换
|
|
191
369
|
* 所有 SDK 特有的事件类型引用封装在此方法内
|
|
@@ -271,9 +449,13 @@ export class AgentRunner {
|
|
|
271
449
|
};
|
|
272
450
|
}
|
|
273
451
|
}
|
|
452
|
+
// 剥离 SDK result 中混入的 <thinking>...</thinking> 块
|
|
453
|
+
const cleanResult = typeof event.result === 'string'
|
|
454
|
+
? event.result.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim()
|
|
455
|
+
: event.result;
|
|
274
456
|
yield {
|
|
275
457
|
type: 'complete',
|
|
276
|
-
result:
|
|
458
|
+
result: cleanResult,
|
|
277
459
|
subtype: event.subtype,
|
|
278
460
|
isError: event.is_error,
|
|
279
461
|
errors: event.errors,
|
|
@@ -286,25 +468,16 @@ export class AgentRunner {
|
|
|
286
468
|
}
|
|
287
469
|
}
|
|
288
470
|
async runQuery(sessionId, prompt, projectPath, initialClaudeSessionId, images, systemPromptAppend, sessionManager) {
|
|
471
|
+
// 记录当前 evolclaw session ID,用于 Agent ctl 环境变量注入
|
|
472
|
+
this.currentEvolclawSessionId = sessionId;
|
|
289
473
|
// 同步用户级配置到内存
|
|
290
474
|
this.syncFromUserSettings();
|
|
291
475
|
ensureDir(projectPath);
|
|
292
476
|
ensureDir(path.join(projectPath, '.claude'));
|
|
293
477
|
// 优先使用传入的 agentSessionId(从数据库恢复),否则使用内存中的
|
|
294
478
|
let agentSessionId = initialClaudeSessionId || this.activeSessions.get(sessionId);
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
if (sessionManager) {
|
|
298
|
-
const health = await sessionManager.getHealthStatus(sessionId);
|
|
299
|
-
if (health.safeMode) {
|
|
300
|
-
// 安全模式:不使用 resume,每次都是新对话
|
|
301
|
-
agentSessionId = undefined;
|
|
302
|
-
skipResume = true;
|
|
303
|
-
logger.warn(`[AgentRunner] Safe mode enabled for ${sessionId}, not resuming session`);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
// 验证会话文件是否存在且有效(仅在非安全模式且有 agentSessionId 时)
|
|
307
|
-
if (agentSessionId && !skipResume) {
|
|
479
|
+
// 验证会话文件是否存在且有效(仅在有 agentSessionId 时)
|
|
480
|
+
if (agentSessionId) {
|
|
308
481
|
const homeDir = os.homedir();
|
|
309
482
|
const encodedProjectPath = encodePath(projectPath);
|
|
310
483
|
const sessionFile = path.join(homeDir, '.claude', 'projects', encodedProjectPath, `${agentSessionId}.jsonl`);
|
|
@@ -401,6 +574,15 @@ export class AgentRunner {
|
|
|
401
574
|
// SDK-level canUseTool 回调:接入 PermissionGateway 的用户审批入口
|
|
402
575
|
// 只在 SDK 认为此工具需要用户确认时触发(黑名单已在 PreToolUse hook 拦截)
|
|
403
576
|
const canUseToolCallback = async (toolName, input, options) => {
|
|
577
|
+
// 特殊处理:AskUserQuestion 工具(SDK 内置的用户交互工具)
|
|
578
|
+
// 这不是权限审批,而是收集用户答案,需要构造表单卡片
|
|
579
|
+
if (toolName === 'AskUserQuestion') {
|
|
580
|
+
return await this.handleAskUserQuestion(sessionId, input, options);
|
|
581
|
+
}
|
|
582
|
+
// 特殊处理:ExitPlanMode 工具(plan mode 审批)
|
|
583
|
+
if (toolName === 'ExitPlanMode') {
|
|
584
|
+
return await this.handleExitPlanMode(sessionId, input, options);
|
|
585
|
+
}
|
|
404
586
|
// bypass 模式:一律 allow
|
|
405
587
|
if (this.permissionMode === 'bypass') {
|
|
406
588
|
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
@@ -429,7 +611,7 @@ export class AgentRunner {
|
|
|
429
611
|
const summary = options.title
|
|
430
612
|
|| options.description
|
|
431
613
|
|| summarizeToolInput(toolName, input);
|
|
432
|
-
const decision = await this.permissionGateway.requestPermission(sessionId, toolName, input, this.sendPromptFn, this.
|
|
614
|
+
const decision = await this.permissionGateway.requestPermission(sessionId, toolName, input, this.sendPromptFn, this.permissionContexts.get(sessionId), summary, options.decisionReason);
|
|
433
615
|
if (decision === 'deny') {
|
|
434
616
|
return { behavior: 'deny', message: '用户拒绝或审批超时', decisionClassification: 'user_reject' };
|
|
435
617
|
}
|
|
@@ -444,7 +626,7 @@ export class AgentRunner {
|
|
|
444
626
|
const excludeDynamic = this.config?.agents?.anthropic?.excludeDynamicSections === true;
|
|
445
627
|
// 公共 options(新旧模式共用)
|
|
446
628
|
const sdkPermissionMode = this.toSdkPermissionMode();
|
|
447
|
-
logger.info(`[AgentRunner] runQuery model=${this.model} effort=${this.effort ?? 'auto'} permMode=${this.permissionMode} sdkMode=${sdkPermissionMode}`);
|
|
629
|
+
logger.info(`[AgentRunner] runQuery model=${this.model} effort=${this.effort ?? 'auto'} permMode=${this.permissionMode} sdkMode=${sdkPermissionMode} systemPromptAppend=${systemPromptAppend ? systemPromptAppend.substring(0, 100) + '...' : 'none'}`);
|
|
448
630
|
const commonOptions = {
|
|
449
631
|
cwd: projectPath,
|
|
450
632
|
model: this.model,
|
|
@@ -454,6 +636,7 @@ export class AgentRunner {
|
|
|
454
636
|
canUseTool: canUseToolCallback,
|
|
455
637
|
permissionMode: sdkPermissionMode,
|
|
456
638
|
persistSession: true,
|
|
639
|
+
enableFileCheckpointing: true,
|
|
457
640
|
hooks: {
|
|
458
641
|
PreCompact: [{ matcher: '.*', hooks: [preCompactHook] }],
|
|
459
642
|
PreToolUse: [{ matcher: '.*', hooks: [preToolUseHook] }],
|
|
@@ -470,7 +653,7 @@ export class AgentRunner {
|
|
|
470
653
|
},
|
|
471
654
|
env: this.getAgentEnv()
|
|
472
655
|
};
|
|
473
|
-
const createQuery = (promptInput, resumeSessionId) => {
|
|
656
|
+
const createQuery = (promptInput, resumeSessionId, resumeAt) => {
|
|
474
657
|
if (useSettingSources) {
|
|
475
658
|
// 新方式:SDK 自动加载 CLAUDE.md 和 MCP 配置
|
|
476
659
|
return query({
|
|
@@ -485,6 +668,7 @@ export class AgentRunner {
|
|
|
485
668
|
...(systemPromptAppend ? { append: systemPromptAppend } : {})
|
|
486
669
|
},
|
|
487
670
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
671
|
+
...(resumeAt ? { resumeSessionAt: resumeAt } : {}),
|
|
488
672
|
}
|
|
489
673
|
});
|
|
490
674
|
}
|
|
@@ -528,6 +712,7 @@ export class AgentRunner {
|
|
|
528
712
|
options: {
|
|
529
713
|
...commonOptions,
|
|
530
714
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
715
|
+
...(resumeAt ? { resumeSessionAt: resumeAt } : {}),
|
|
531
716
|
...(Object.keys(globalMcpServers).length > 0 ? { mcpServers: globalMcpServers } : {}),
|
|
532
717
|
...(fullAppend ? {
|
|
533
718
|
systemPrompt: {
|
|
@@ -540,6 +725,23 @@ export class AgentRunner {
|
|
|
540
725
|
});
|
|
541
726
|
}
|
|
542
727
|
};
|
|
728
|
+
// 检查待处理的 resumeAt(由 /rewind N chat 设置)
|
|
729
|
+
let resumeAt;
|
|
730
|
+
if (sessionManager && agentSessionId) {
|
|
731
|
+
try {
|
|
732
|
+
const currentSession = await sessionManager.getSessionById?.(sessionId);
|
|
733
|
+
if (currentSession?.metadata?.resumeAt) {
|
|
734
|
+
resumeAt = currentSession.metadata.resumeAt;
|
|
735
|
+
const newMeta = { ...currentSession.metadata };
|
|
736
|
+
delete newMeta.resumeAt;
|
|
737
|
+
await sessionManager.updateSession(sessionId, { metadata: newMeta });
|
|
738
|
+
logger.info(`[AgentRunner] Consuming resumeAt: ${resumeAt}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
logger.warn('[AgentRunner] Failed to check resumeAt:', err);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
543
745
|
let sdkStream;
|
|
544
746
|
if (images && images.length > 0) {
|
|
545
747
|
logger.debug('[AgentRunner] Creating query with images, images:', images.length);
|
|
@@ -551,7 +753,7 @@ export class AgentRunner {
|
|
|
551
753
|
}
|
|
552
754
|
else {
|
|
553
755
|
logger.debug('[AgentRunner] Creating query with text only, agentSessionId:', initialClaudeSessionId);
|
|
554
|
-
sdkStream = createQuery(prompt, agentSessionId);
|
|
756
|
+
sdkStream = createQuery(prompt, agentSessionId, resumeAt);
|
|
555
757
|
}
|
|
556
758
|
// 保存 interrupt 能力(不写 activeStreams,由 registerStream 管理活跃状态)
|
|
557
759
|
if ('interrupt' in sdkStream && typeof sdkStream.interrupt === 'function') {
|
|
@@ -657,6 +859,7 @@ export class AgentRunner {
|
|
|
657
859
|
this.activeSessions.delete(sessionId);
|
|
658
860
|
this.activeStreams.delete(sessionId);
|
|
659
861
|
this.interruptFns.delete(sessionId);
|
|
862
|
+
this.permissionContexts.delete(sessionId);
|
|
660
863
|
}
|
|
661
864
|
resolveSessionFile(agentSessionId, projectPath) {
|
|
662
865
|
const encodedProjectPath = encodePath(projectPath);
|
|
@@ -667,6 +870,49 @@ export class AgentRunner {
|
|
|
667
870
|
const result = await sdkForkSession(agentSessionId, { dir: projectPath, title });
|
|
668
871
|
return result.sessionId;
|
|
669
872
|
}
|
|
873
|
+
async getSessionMessages(agentSessionId, projectPath) {
|
|
874
|
+
return sdkGetSessionMessages(agentSessionId, { dir: projectPath });
|
|
875
|
+
}
|
|
876
|
+
async rewindFiles(agentSessionId, projectPath, userMessageId) {
|
|
877
|
+
logger.info(`[RewindFiles] agentSessionId=${agentSessionId} userMessageId=${userMessageId}`);
|
|
878
|
+
const stderrChunks = [];
|
|
879
|
+
const tempQuery = query({
|
|
880
|
+
prompt: '',
|
|
881
|
+
options: {
|
|
882
|
+
cwd: projectPath,
|
|
883
|
+
resume: agentSessionId,
|
|
884
|
+
enableFileCheckpointing: true,
|
|
885
|
+
permissionMode: this.toSdkPermissionMode(),
|
|
886
|
+
stderr: (data) => { stderrChunks.push(data); },
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
try {
|
|
890
|
+
for await (const _msg of tempQuery) {
|
|
891
|
+
const dryResult = await tempQuery.rewindFiles(userMessageId, { dryRun: true });
|
|
892
|
+
logger.info('[RewindFiles] dryRun result:', JSON.stringify(dryResult));
|
|
893
|
+
if (!dryResult.canRewind)
|
|
894
|
+
return dryResult;
|
|
895
|
+
const result = await tempQuery.rewindFiles(userMessageId);
|
|
896
|
+
logger.info('[RewindFiles] rewind result:', JSON.stringify(result));
|
|
897
|
+
return {
|
|
898
|
+
...result,
|
|
899
|
+
filesChanged: dryResult.filesChanged ?? result.filesChanged,
|
|
900
|
+
insertions: dryResult.insertions ?? result.insertions,
|
|
901
|
+
deletions: dryResult.deletions ?? result.deletions,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
throw new Error('Query stream ended before rewindFiles could be called');
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
if (stderrChunks.length > 0) {
|
|
908
|
+
logger.error('[RewindFiles] subprocess stderr:', stderrChunks.join(''));
|
|
909
|
+
}
|
|
910
|
+
throw error;
|
|
911
|
+
}
|
|
912
|
+
finally {
|
|
913
|
+
tempQuery.close();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
670
916
|
}
|
|
671
917
|
// Plugin implementation
|
|
672
918
|
export class ClaudeAgentPlugin {
|
|
@@ -87,15 +87,9 @@ export class CodexRunner {
|
|
|
87
87
|
}
|
|
88
88
|
// ── Core: runQuery ──
|
|
89
89
|
async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager) {
|
|
90
|
+
// Agent ctl: 注入 EVOLCLAW_SESSION_ID 供子进程使用
|
|
91
|
+
process.env.EVOLCLAW_SESSION_ID = sessionId;
|
|
90
92
|
let agentSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
|
|
91
|
-
// 安全模式:跳过 resume,创建新 thread
|
|
92
|
-
if (agentSessionId && sessionManager) {
|
|
93
|
-
const health = await sessionManager.getHealthStatus(sessionId);
|
|
94
|
-
if (health.safeMode) {
|
|
95
|
-
agentSessionId = undefined;
|
|
96
|
-
logger.warn(`[CodexRunner] Safe mode enabled for ${sessionId}, not resuming thread`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
93
|
const threadOptions = {
|
|
100
94
|
workingDirectory: projectPath,
|
|
101
95
|
model: this.model,
|
|
@@ -89,14 +89,6 @@ export class GeminiRunner {
|
|
|
89
89
|
// ── Core: runQuery ──
|
|
90
90
|
async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager) {
|
|
91
91
|
let geminiSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
|
|
92
|
-
// Safe mode: skip resume
|
|
93
|
-
if (geminiSessionId && sessionManager) {
|
|
94
|
-
const health = await sessionManager.getHealthStatus(sessionId);
|
|
95
|
-
if (health.safeMode) {
|
|
96
|
-
geminiSessionId = undefined;
|
|
97
|
-
logger.warn(`[GeminiRunner] Safe mode enabled for ${sessionId}, not resuming session`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
92
|
// Build CLI args
|
|
101
93
|
const args = [];
|
|
102
94
|
// Only inject system context on first turn (no resume).
|
|
@@ -139,6 +131,7 @@ export class GeminiRunner {
|
|
|
139
131
|
// Spawn subprocess
|
|
140
132
|
const env = {
|
|
141
133
|
...process.env,
|
|
134
|
+
EVOLCLAW_SESSION_ID: sessionId,
|
|
142
135
|
};
|
|
143
136
|
if (this.resolved.apiKey) {
|
|
144
137
|
env.GOOGLE_API_KEY = this.resolved.apiKey;
|