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.
- package/README.md +33 -14
- package/dist/agents/claude-runner.js +224 -23
- package/dist/agents/codex-runner.js +2 -8
- package/dist/agents/gemini-runner.js +1 -8
- package/dist/channels/aun.js +438 -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 +69 -9
- package/dist/config.js +98 -2
- package/dist/core/command-handler.js +462 -112
- package/dist/core/message/message-bridge.js +8 -5
- package/dist/core/message/message-processor.js +146 -54
- package/dist/core/message/message-queue.js +48 -0
- package/dist/core/message/stream-flusher.js +2 -2
- 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 +734 -8
- package/dist/utils/init.js +33 -2
- package/dist/utils/media-cache.js +2 -0
- package/dist/utils/stats-collector.js +0 -8
- package/package.json +9 -3
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,141 @@ 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
|
+
const timer = setTimeout(() => {
|
|
263
|
+
permCtx?.interactionRouter?.cancel(requestId);
|
|
264
|
+
permCtx?.cancelIntercept?.(sessionId);
|
|
265
|
+
logger.info(`[AgentRunner] AskUserQuestion timeout for ${requestId}`);
|
|
266
|
+
resolve(null);
|
|
267
|
+
}, 5 * 60 * 1000);
|
|
268
|
+
permCtx?.interactionRouter?.register(requestId, sessionId, (action, values) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
if (action === 'cancel') {
|
|
271
|
+
resolve(null);
|
|
272
|
+
}
|
|
273
|
+
else if (action === '_custom_input' && permCtx.interceptNextMessage) {
|
|
274
|
+
// "手动输入":发提示,拦截下一条消息
|
|
275
|
+
const sendHint = async () => {
|
|
276
|
+
if (sendPrompt) {
|
|
277
|
+
await sendPrompt('✏️ 请输入你的想法,回复后继续……');
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
sendHint().catch(() => { });
|
|
281
|
+
permCtx.interceptNextMessage(sessionId, (msg) => {
|
|
282
|
+
resolve(msg.content || null);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else if (q.multiSelect) {
|
|
286
|
+
// multiSelect 按钮点击:包装为数组
|
|
287
|
+
resolve([action]);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
resolve(action); // action = button key = option label
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
if (answer === null) {
|
|
295
|
+
// 超时或取消,自动选第一项
|
|
296
|
+
const firstLabel = q.options[0]?.label || '';
|
|
297
|
+
answers[q.question] = q.multiSelect ? [firstLabel] : firstLabel;
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
answers[q.question] = answer;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const updatedInput = { ...input, answers };
|
|
304
|
+
return { behavior: 'allow', updatedInput, decisionClassification: 'user_temporary' };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* AskUserQuestion 纯文本 fallback:发送文本格式的问题,直接 allow
|
|
308
|
+
*/
|
|
309
|
+
async handleAskUserQuestionFallback(input, questions) {
|
|
310
|
+
// 自动选择每个问题的第一个选项
|
|
311
|
+
const answers = {};
|
|
312
|
+
if (questions?.length) {
|
|
313
|
+
const lines = questions.map(q => {
|
|
314
|
+
const firstLabel = q.options[0]?.label || '';
|
|
315
|
+
answers[q.question] = firstLabel;
|
|
316
|
+
const optText = q.options.map((o, i) => ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n');
|
|
317
|
+
return `${q.question}\n${optText}\n → 自动选择:${firstLabel}`;
|
|
318
|
+
});
|
|
319
|
+
if (this.sendPromptFn) {
|
|
320
|
+
await this.sendPromptFn(`💬 以下问题已自动选择第一项:\n\n${lines.join('\n\n')}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const updatedInput = { ...input, answers };
|
|
324
|
+
return { behavior: 'allow', updatedInput, decisionClassification: 'user_temporary' };
|
|
325
|
+
}
|
|
189
326
|
/**
|
|
190
327
|
* SDK 原始事件 → 标准 AgentEvent 转换
|
|
191
328
|
* 所有 SDK 特有的事件类型引用封装在此方法内
|
|
@@ -271,9 +408,13 @@ export class AgentRunner {
|
|
|
271
408
|
};
|
|
272
409
|
}
|
|
273
410
|
}
|
|
411
|
+
// 剥离 SDK result 中混入的 <thinking>...</thinking> 块
|
|
412
|
+
const cleanResult = typeof event.result === 'string'
|
|
413
|
+
? event.result.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim()
|
|
414
|
+
: event.result;
|
|
274
415
|
yield {
|
|
275
416
|
type: 'complete',
|
|
276
|
-
result:
|
|
417
|
+
result: cleanResult,
|
|
277
418
|
subtype: event.subtype,
|
|
278
419
|
isError: event.is_error,
|
|
279
420
|
errors: event.errors,
|
|
@@ -286,25 +427,16 @@ export class AgentRunner {
|
|
|
286
427
|
}
|
|
287
428
|
}
|
|
288
429
|
async runQuery(sessionId, prompt, projectPath, initialClaudeSessionId, images, systemPromptAppend, sessionManager) {
|
|
430
|
+
// 记录当前 evolclaw session ID,用于 Agent ctl 环境变量注入
|
|
431
|
+
this.currentEvolclawSessionId = sessionId;
|
|
289
432
|
// 同步用户级配置到内存
|
|
290
433
|
this.syncFromUserSettings();
|
|
291
434
|
ensureDir(projectPath);
|
|
292
435
|
ensureDir(path.join(projectPath, '.claude'));
|
|
293
436
|
// 优先使用传入的 agentSessionId(从数据库恢复),否则使用内存中的
|
|
294
437
|
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) {
|
|
438
|
+
// 验证会话文件是否存在且有效(仅在有 agentSessionId 时)
|
|
439
|
+
if (agentSessionId) {
|
|
308
440
|
const homeDir = os.homedir();
|
|
309
441
|
const encodedProjectPath = encodePath(projectPath);
|
|
310
442
|
const sessionFile = path.join(homeDir, '.claude', 'projects', encodedProjectPath, `${agentSessionId}.jsonl`);
|
|
@@ -401,6 +533,11 @@ export class AgentRunner {
|
|
|
401
533
|
// SDK-level canUseTool 回调:接入 PermissionGateway 的用户审批入口
|
|
402
534
|
// 只在 SDK 认为此工具需要用户确认时触发(黑名单已在 PreToolUse hook 拦截)
|
|
403
535
|
const canUseToolCallback = async (toolName, input, options) => {
|
|
536
|
+
// 特殊处理:AskUserQuestion 工具(SDK 内置的用户交互工具)
|
|
537
|
+
// 这不是权限审批,而是收集用户答案,需要构造表单卡片
|
|
538
|
+
if (toolName === 'AskUserQuestion') {
|
|
539
|
+
return await this.handleAskUserQuestion(sessionId, input, options);
|
|
540
|
+
}
|
|
404
541
|
// bypass 模式:一律 allow
|
|
405
542
|
if (this.permissionMode === 'bypass') {
|
|
406
543
|
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
@@ -429,7 +566,7 @@ export class AgentRunner {
|
|
|
429
566
|
const summary = options.title
|
|
430
567
|
|| options.description
|
|
431
568
|
|| summarizeToolInput(toolName, input);
|
|
432
|
-
const decision = await this.permissionGateway.requestPermission(sessionId, toolName, input, this.sendPromptFn, this.
|
|
569
|
+
const decision = await this.permissionGateway.requestPermission(sessionId, toolName, input, this.sendPromptFn, this.permissionContexts.get(sessionId), summary, options.decisionReason);
|
|
433
570
|
if (decision === 'deny') {
|
|
434
571
|
return { behavior: 'deny', message: '用户拒绝或审批超时', decisionClassification: 'user_reject' };
|
|
435
572
|
}
|
|
@@ -444,7 +581,7 @@ export class AgentRunner {
|
|
|
444
581
|
const excludeDynamic = this.config?.agents?.anthropic?.excludeDynamicSections === true;
|
|
445
582
|
// 公共 options(新旧模式共用)
|
|
446
583
|
const sdkPermissionMode = this.toSdkPermissionMode();
|
|
447
|
-
logger.info(`[AgentRunner] runQuery model=${this.model} effort=${this.effort ?? 'auto'} permMode=${this.permissionMode} sdkMode=${sdkPermissionMode}`);
|
|
584
|
+
logger.info(`[AgentRunner] runQuery model=${this.model} effort=${this.effort ?? 'auto'} permMode=${this.permissionMode} sdkMode=${sdkPermissionMode} systemPromptAppend=${systemPromptAppend ? systemPromptAppend.substring(0, 100) + '...' : 'none'}`);
|
|
448
585
|
const commonOptions = {
|
|
449
586
|
cwd: projectPath,
|
|
450
587
|
model: this.model,
|
|
@@ -454,6 +591,7 @@ export class AgentRunner {
|
|
|
454
591
|
canUseTool: canUseToolCallback,
|
|
455
592
|
permissionMode: sdkPermissionMode,
|
|
456
593
|
persistSession: true,
|
|
594
|
+
enableFileCheckpointing: true,
|
|
457
595
|
hooks: {
|
|
458
596
|
PreCompact: [{ matcher: '.*', hooks: [preCompactHook] }],
|
|
459
597
|
PreToolUse: [{ matcher: '.*', hooks: [preToolUseHook] }],
|
|
@@ -470,7 +608,7 @@ export class AgentRunner {
|
|
|
470
608
|
},
|
|
471
609
|
env: this.getAgentEnv()
|
|
472
610
|
};
|
|
473
|
-
const createQuery = (promptInput, resumeSessionId) => {
|
|
611
|
+
const createQuery = (promptInput, resumeSessionId, resumeAt) => {
|
|
474
612
|
if (useSettingSources) {
|
|
475
613
|
// 新方式:SDK 自动加载 CLAUDE.md 和 MCP 配置
|
|
476
614
|
return query({
|
|
@@ -485,6 +623,7 @@ export class AgentRunner {
|
|
|
485
623
|
...(systemPromptAppend ? { append: systemPromptAppend } : {})
|
|
486
624
|
},
|
|
487
625
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
626
|
+
...(resumeAt ? { resumeSessionAt: resumeAt } : {}),
|
|
488
627
|
}
|
|
489
628
|
});
|
|
490
629
|
}
|
|
@@ -528,6 +667,7 @@ export class AgentRunner {
|
|
|
528
667
|
options: {
|
|
529
668
|
...commonOptions,
|
|
530
669
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
670
|
+
...(resumeAt ? { resumeSessionAt: resumeAt } : {}),
|
|
531
671
|
...(Object.keys(globalMcpServers).length > 0 ? { mcpServers: globalMcpServers } : {}),
|
|
532
672
|
...(fullAppend ? {
|
|
533
673
|
systemPrompt: {
|
|
@@ -540,6 +680,23 @@ export class AgentRunner {
|
|
|
540
680
|
});
|
|
541
681
|
}
|
|
542
682
|
};
|
|
683
|
+
// 检查待处理的 resumeAt(由 /rewind N chat 设置)
|
|
684
|
+
let resumeAt;
|
|
685
|
+
if (sessionManager && agentSessionId) {
|
|
686
|
+
try {
|
|
687
|
+
const currentSession = await sessionManager.getSessionById?.(sessionId);
|
|
688
|
+
if (currentSession?.metadata?.resumeAt) {
|
|
689
|
+
resumeAt = currentSession.metadata.resumeAt;
|
|
690
|
+
const newMeta = { ...currentSession.metadata };
|
|
691
|
+
delete newMeta.resumeAt;
|
|
692
|
+
await sessionManager.updateSession(sessionId, { metadata: newMeta });
|
|
693
|
+
logger.info(`[AgentRunner] Consuming resumeAt: ${resumeAt}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
logger.warn('[AgentRunner] Failed to check resumeAt:', err);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
543
700
|
let sdkStream;
|
|
544
701
|
if (images && images.length > 0) {
|
|
545
702
|
logger.debug('[AgentRunner] Creating query with images, images:', images.length);
|
|
@@ -551,7 +708,7 @@ export class AgentRunner {
|
|
|
551
708
|
}
|
|
552
709
|
else {
|
|
553
710
|
logger.debug('[AgentRunner] Creating query with text only, agentSessionId:', initialClaudeSessionId);
|
|
554
|
-
sdkStream = createQuery(prompt, agentSessionId);
|
|
711
|
+
sdkStream = createQuery(prompt, agentSessionId, resumeAt);
|
|
555
712
|
}
|
|
556
713
|
// 保存 interrupt 能力(不写 activeStreams,由 registerStream 管理活跃状态)
|
|
557
714
|
if ('interrupt' in sdkStream && typeof sdkStream.interrupt === 'function') {
|
|
@@ -657,6 +814,7 @@ export class AgentRunner {
|
|
|
657
814
|
this.activeSessions.delete(sessionId);
|
|
658
815
|
this.activeStreams.delete(sessionId);
|
|
659
816
|
this.interruptFns.delete(sessionId);
|
|
817
|
+
this.permissionContexts.delete(sessionId);
|
|
660
818
|
}
|
|
661
819
|
resolveSessionFile(agentSessionId, projectPath) {
|
|
662
820
|
const encodedProjectPath = encodePath(projectPath);
|
|
@@ -667,6 +825,49 @@ export class AgentRunner {
|
|
|
667
825
|
const result = await sdkForkSession(agentSessionId, { dir: projectPath, title });
|
|
668
826
|
return result.sessionId;
|
|
669
827
|
}
|
|
828
|
+
async getSessionMessages(agentSessionId, projectPath) {
|
|
829
|
+
return sdkGetSessionMessages(agentSessionId, { dir: projectPath });
|
|
830
|
+
}
|
|
831
|
+
async rewindFiles(agentSessionId, projectPath, userMessageId) {
|
|
832
|
+
logger.info(`[RewindFiles] agentSessionId=${agentSessionId} userMessageId=${userMessageId}`);
|
|
833
|
+
const stderrChunks = [];
|
|
834
|
+
const tempQuery = query({
|
|
835
|
+
prompt: '',
|
|
836
|
+
options: {
|
|
837
|
+
cwd: projectPath,
|
|
838
|
+
resume: agentSessionId,
|
|
839
|
+
enableFileCheckpointing: true,
|
|
840
|
+
permissionMode: this.toSdkPermissionMode(),
|
|
841
|
+
stderr: (data) => { stderrChunks.push(data); },
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
try {
|
|
845
|
+
for await (const _msg of tempQuery) {
|
|
846
|
+
const dryResult = await tempQuery.rewindFiles(userMessageId, { dryRun: true });
|
|
847
|
+
logger.info('[RewindFiles] dryRun result:', JSON.stringify(dryResult));
|
|
848
|
+
if (!dryResult.canRewind)
|
|
849
|
+
return dryResult;
|
|
850
|
+
const result = await tempQuery.rewindFiles(userMessageId);
|
|
851
|
+
logger.info('[RewindFiles] rewind result:', JSON.stringify(result));
|
|
852
|
+
return {
|
|
853
|
+
...result,
|
|
854
|
+
filesChanged: dryResult.filesChanged ?? result.filesChanged,
|
|
855
|
+
insertions: dryResult.insertions ?? result.insertions,
|
|
856
|
+
deletions: dryResult.deletions ?? result.deletions,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
throw new Error('Query stream ended before rewindFiles could be called');
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
if (stderrChunks.length > 0) {
|
|
863
|
+
logger.error('[RewindFiles] subprocess stderr:', stderrChunks.join(''));
|
|
864
|
+
}
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
tempQuery.close();
|
|
869
|
+
}
|
|
870
|
+
}
|
|
670
871
|
}
|
|
671
872
|
// Plugin implementation
|
|
672
873
|
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;
|