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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > AI Agent 统一网关 —— 连接 IM、终端、Agent 网络
4
4
 
5
- EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex 等 AI Agent 提供统一接入层,支持飞书、微信、AUN Mesh 网络和终端 TUI 四种通道。人类可以通过手机 IM 随时接力开发,其他 Agent 也可以通过 AUN 网络直接调用你的 Agent —— 不只是人机交互,也是 Agent 间协作的基础设施。
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 Mesh 网络,扫码一键接入
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 WebSocket + WeChat HTTP 长轮询 + AUN Mesh 网络
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
- ### 管理员级命令(仅 Owner 可用)
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
- - `/safe` - 进入安全模式
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 Mesh 网络
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
- - [x] 手动授权支持(飞书卡片)
266
- - [ ] ACP 协议支持(接入 Codex / Gemini CLI)
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
- permissionContext;
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.permissionContext = context;
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: event.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
- let skipResume = false;
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.permissionContext, summary, options.decisionReason);
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;