evolclaw 2.1.2 → 2.3.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 +59 -30
- package/data/evolclaw.sample.json +15 -4
- package/dist/agents/claude-runner.js +685 -0
- package/dist/agents/codex-runner.js +315 -0
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +580 -10
- package/dist/channels/feishu.js +888 -135
- package/dist/channels/wechat.js +127 -21
- package/dist/cli.js +519 -136
- package/dist/config.js +277 -25
- package/dist/core/agent-loader.js +39 -0
- package/dist/core/channel-loader.js +67 -0
- package/dist/core/command-handler.js +1537 -392
- package/dist/core/event-bus.js +32 -0
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +216 -0
- package/dist/core/message/message-processor.js +1028 -0
- package/dist/core/message/message-queue.js +240 -0
- package/dist/core/message/stream-debouncer.js +122 -0
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
- package/dist/core/permission.js +259 -0
- package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
- package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/core/session/session-file-adapter.js +7 -0
- package/dist/core/session/session-file-health.js +45 -0
- package/dist/core/session/session-manager.js +1072 -0
- package/dist/index.js +402 -252
- package/dist/ipc.js +106 -0
- package/dist/paths.js +1 -0
- package/dist/types.js +3 -0
- package/dist/utils/{platform.js → cross-platform.js} +38 -1
- package/dist/utils/error-utils.js +130 -5
- package/dist/utils/init-channel.js +649 -0
- package/dist/utils/init.js +190 -53
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/utils/migrate-project.js +122 -0
- package/dist/utils/rich-content-renderer.js +228 -0
- package/dist/utils/stats-collector.js +102 -0
- package/package.json +4 -2
- package/dist/core/agent-runner.js +0 -348
- package/dist/core/message-processor.js +0 -604
- package/dist/core/message-queue.js +0 -116
- package/dist/core/message-stream.js +0 -59
- package/dist/core/session-manager.js +0 -664
- package/dist/index.js.bak +0 -340
- package/dist/utils/init-feishu.js +0 -261
- package/dist/utils/init-wechat.js +0 -170
- package/dist/utils/markdown-to-feishu.js +0 -94
- package/dist/utils/permission.js +0 -43
- package/dist/utils/session-file-health.js +0 -68
- /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { query, forkSession as sdkForkSession } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { ensureDir, resolveAnthropicConfig } from '../config.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
import { checkBlacklist, checkReadonly, summarizeToolInput } from '../core/permission.js';
|
|
8
|
+
import { encodePath } from '../utils/cross-platform.js';
|
|
9
|
+
class MessageStream {
|
|
10
|
+
queue = [];
|
|
11
|
+
waiting = null;
|
|
12
|
+
done = false;
|
|
13
|
+
push(text, images) {
|
|
14
|
+
let content;
|
|
15
|
+
if (images && images.length > 0) {
|
|
16
|
+
logger.debug('[MessageStream] Creating multimodal message with', images.length, 'images');
|
|
17
|
+
content = [
|
|
18
|
+
{ type: 'text', text },
|
|
19
|
+
...images.map((img) => ({
|
|
20
|
+
type: 'image',
|
|
21
|
+
source: {
|
|
22
|
+
type: 'base64',
|
|
23
|
+
media_type: img.mimeType || 'image/png',
|
|
24
|
+
data: img.data,
|
|
25
|
+
},
|
|
26
|
+
})),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
content = text;
|
|
31
|
+
}
|
|
32
|
+
const message = {
|
|
33
|
+
type: 'user',
|
|
34
|
+
message: { role: 'user', content },
|
|
35
|
+
parent_tool_use_id: null,
|
|
36
|
+
session_id: '',
|
|
37
|
+
};
|
|
38
|
+
this.queue.push(message);
|
|
39
|
+
this.waiting?.();
|
|
40
|
+
}
|
|
41
|
+
end() {
|
|
42
|
+
this.done = true;
|
|
43
|
+
this.waiting?.();
|
|
44
|
+
}
|
|
45
|
+
async *[Symbol.asyncIterator]() {
|
|
46
|
+
while (true) {
|
|
47
|
+
while (this.queue.length > 0) {
|
|
48
|
+
yield this.queue.shift();
|
|
49
|
+
}
|
|
50
|
+
if (this.done)
|
|
51
|
+
return;
|
|
52
|
+
await new Promise((r) => {
|
|
53
|
+
this.waiting = r;
|
|
54
|
+
});
|
|
55
|
+
this.waiting = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ── 类型守卫 ──
|
|
60
|
+
export function hasModelSwitcher(agent) {
|
|
61
|
+
return typeof agent.setModel === 'function' && typeof agent.listModels === 'function';
|
|
62
|
+
}
|
|
63
|
+
export function hasPermissionController(agent) {
|
|
64
|
+
return typeof agent.setMode === 'function' && typeof agent.listModes === 'function';
|
|
65
|
+
}
|
|
66
|
+
export function hasCompact(agent) {
|
|
67
|
+
return typeof agent.compact === 'function';
|
|
68
|
+
}
|
|
69
|
+
export class AgentRunner {
|
|
70
|
+
name = 'claude';
|
|
71
|
+
capabilities = { clear: true, compact: true, fork: true };
|
|
72
|
+
apiKey;
|
|
73
|
+
model;
|
|
74
|
+
effort;
|
|
75
|
+
permissionMode = 'auto';
|
|
76
|
+
baseUrl;
|
|
77
|
+
config;
|
|
78
|
+
activeSessions = new Map();
|
|
79
|
+
activeStreams = new Map();
|
|
80
|
+
interruptFns = new Map();
|
|
81
|
+
onSessionIdUpdate;
|
|
82
|
+
onCompactStart;
|
|
83
|
+
permissionGateway;
|
|
84
|
+
sendPromptFn;
|
|
85
|
+
permissionContext;
|
|
86
|
+
constructor(apiKey, model, onSessionIdUpdate, baseUrl, config) {
|
|
87
|
+
this.apiKey = apiKey;
|
|
88
|
+
this.model = model || 'sonnet';
|
|
89
|
+
this.effort = undefined;
|
|
90
|
+
this.baseUrl = baseUrl;
|
|
91
|
+
this.config = config;
|
|
92
|
+
this.onSessionIdUpdate = onSessionIdUpdate;
|
|
93
|
+
}
|
|
94
|
+
getAgentEnv() {
|
|
95
|
+
return {
|
|
96
|
+
...process.env,
|
|
97
|
+
ANTHROPIC_AUTH_TOKEN: this.apiKey,
|
|
98
|
+
PATH: process.env.PATH,
|
|
99
|
+
DISABLE_AUTOUPDATER: '1',
|
|
100
|
+
...(this.baseUrl ? { ANTHROPIC_BASE_URL: this.baseUrl } : {})
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
setModel(model) {
|
|
104
|
+
this.model = model;
|
|
105
|
+
}
|
|
106
|
+
getModel() {
|
|
107
|
+
return this.model;
|
|
108
|
+
}
|
|
109
|
+
listModels() {
|
|
110
|
+
return ['opus', 'sonnet', 'haiku'];
|
|
111
|
+
}
|
|
112
|
+
setEffort(effort) {
|
|
113
|
+
this.effort = effort;
|
|
114
|
+
}
|
|
115
|
+
getEffort() {
|
|
116
|
+
return this.effort;
|
|
117
|
+
}
|
|
118
|
+
// ── PermissionController 接口 ──
|
|
119
|
+
setMode(mode) {
|
|
120
|
+
this.permissionMode = mode;
|
|
121
|
+
}
|
|
122
|
+
getMode() {
|
|
123
|
+
return this.permissionMode;
|
|
124
|
+
}
|
|
125
|
+
listModes() {
|
|
126
|
+
return [
|
|
127
|
+
{ key: 'auto', nameZh: '自动', description: 'AI 分类器自动判断', available: true },
|
|
128
|
+
{ key: 'bypass', nameZh: '放行', description: '全部自动放行', available: true },
|
|
129
|
+
{ key: 'request', nameZh: '审批', description: '部分自动,部分询问', available: true },
|
|
130
|
+
{ key: 'edit', nameZh: '编辑', description: '自动接受编辑,其他询问', available: true },
|
|
131
|
+
{ key: 'plan', nameZh: '规划', description: '只规划不执行', available: true },
|
|
132
|
+
{ key: 'noask', nameZh: '静默', description: '未批准则拒绝', available: true },
|
|
133
|
+
{ key: 'readonly', nameZh: '只读', description: '禁止修改项目文件,可在临时目录生成文件', available: true },
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
setPermissionGateway(gateway) {
|
|
137
|
+
this.permissionGateway = gateway;
|
|
138
|
+
}
|
|
139
|
+
setSendPrompt(fn) {
|
|
140
|
+
this.sendPromptFn = fn;
|
|
141
|
+
}
|
|
142
|
+
setPermissionContext(context) {
|
|
143
|
+
this.permissionContext = context;
|
|
144
|
+
}
|
|
145
|
+
toSdkPermissionMode() {
|
|
146
|
+
const map = {
|
|
147
|
+
'auto': 'auto', // AI 分类器自动判断
|
|
148
|
+
'bypass': 'default', // 全部自动放行(通过 canUseTool 一律 allow,保留 hook 安全检查)
|
|
149
|
+
'request': 'default', // 部分自动,部分询问
|
|
150
|
+
'edit': 'acceptEdits',
|
|
151
|
+
'plan': 'plan',
|
|
152
|
+
'noask': 'dontAsk',
|
|
153
|
+
'readonly': 'default',
|
|
154
|
+
};
|
|
155
|
+
return map[this.permissionMode] || 'auto';
|
|
156
|
+
}
|
|
157
|
+
// ── Compactable 接口 ──
|
|
158
|
+
async compact(sessionId, agentSessionId, projectPath) {
|
|
159
|
+
return this.compactSession(sessionId, agentSessionId, projectPath);
|
|
160
|
+
}
|
|
161
|
+
syncFromUserSettings() {
|
|
162
|
+
try {
|
|
163
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
164
|
+
if (!fs.existsSync(settingsPath))
|
|
165
|
+
return;
|
|
166
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
167
|
+
// evolclaw.json 显式配置优先,不被 settings.json 覆盖
|
|
168
|
+
const configModel = this.config?.agents?.anthropic?.model;
|
|
169
|
+
if (!configModel && settings.model && settings.model !== this.model) {
|
|
170
|
+
logger.info(`[AgentRunner] Synced model from ~/.claude/settings.json: ${settings.model}`);
|
|
171
|
+
this.model = settings.model;
|
|
172
|
+
}
|
|
173
|
+
const configEffort = this.config?.agents?.anthropic?.effort;
|
|
174
|
+
if (!configEffort) {
|
|
175
|
+
const newEffort = settings.effortLevel || undefined;
|
|
176
|
+
if (newEffort !== this.effort) {
|
|
177
|
+
logger.info(`[AgentRunner] Synced effort from ~/.claude/settings.json: ${newEffort ?? 'auto'}`);
|
|
178
|
+
this.effort = newEffort;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger.debug(`[AgentRunner] Failed to sync from ~/.claude/settings.json:`, error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
setCompactStartCallback(callback) {
|
|
187
|
+
this.onCompactStart = callback;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* SDK 原始事件 → 标准 AgentEvent 转换
|
|
191
|
+
* 所有 SDK 特有的事件类型引用封装在此方法内
|
|
192
|
+
*/
|
|
193
|
+
async *transformStream(sdkStream, sessionId) {
|
|
194
|
+
let hasTextDelta = false;
|
|
195
|
+
let lastSessionId;
|
|
196
|
+
// tool_use_id → tool_name 映射,用于从 SDKUserMessage 的 tool_result 块中还原工具名
|
|
197
|
+
const toolUseNames = new Map();
|
|
198
|
+
for await (const event of sdkStream) {
|
|
199
|
+
// 提取 session_id(任意 SDK 事件都可能携带)
|
|
200
|
+
if (event.session_id && event.session_id !== lastSessionId) {
|
|
201
|
+
lastSessionId = event.session_id;
|
|
202
|
+
this.updateSessionId(sessionId, event.session_id);
|
|
203
|
+
yield { type: 'session_id', sessionId: event.session_id };
|
|
204
|
+
}
|
|
205
|
+
// text_delta → text
|
|
206
|
+
if (event.type === 'text_delta' && event.text) {
|
|
207
|
+
hasTextDelta = true;
|
|
208
|
+
yield { type: 'text', text: event.text };
|
|
209
|
+
}
|
|
210
|
+
// system: compact_boundary → compact
|
|
211
|
+
if (event.type === 'system' && event.subtype === 'compact_boundary') {
|
|
212
|
+
yield { type: 'compact', preTokens: event.compact_metadata?.pre_tokens || 0 };
|
|
213
|
+
}
|
|
214
|
+
// system: task_progress → task_progress
|
|
215
|
+
if (event.type === 'system' && event.subtype === 'task_progress') {
|
|
216
|
+
yield {
|
|
217
|
+
type: 'task_progress',
|
|
218
|
+
summary: event.summary,
|
|
219
|
+
toolUses: event.tool_uses,
|
|
220
|
+
durationMs: event.duration_ms,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// system: session_state_changed → state_changed
|
|
224
|
+
if (event.type === 'system' && event.subtype === 'session_state_changed') {
|
|
225
|
+
yield { type: 'state_changed', state: event.state };
|
|
226
|
+
}
|
|
227
|
+
// assistant: 提取 tool_use 和文本(仅无 text_delta 时提取文本)
|
|
228
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
229
|
+
for (const content of event.message.content) {
|
|
230
|
+
if (content.type === 'tool_use') {
|
|
231
|
+
// 记录 id → name 映射,供后续 tool_result 使用
|
|
232
|
+
if (content.id)
|
|
233
|
+
toolUseNames.set(content.id, content.name);
|
|
234
|
+
yield { type: 'tool_use', name: content.name, input: content.input };
|
|
235
|
+
}
|
|
236
|
+
else if (content.type === 'text' && content.text && !hasTextDelta) {
|
|
237
|
+
yield { type: 'text', text: content.text };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// user: 提取 tool_result 块(SDK 将工具结果嵌套在 SDKUserMessage 中)
|
|
242
|
+
if (event.type === 'user' && event.message?.content) {
|
|
243
|
+
const contentArray = Array.isArray(event.message.content) ? event.message.content : [];
|
|
244
|
+
for (const block of contentArray) {
|
|
245
|
+
if (typeof block === 'object' && block !== null && block.type === 'tool_result') {
|
|
246
|
+
const toolName = toolUseNames.get(block.tool_use_id) || '';
|
|
247
|
+
const resultContent = typeof block.content === 'string'
|
|
248
|
+
? block.content
|
|
249
|
+
: block.content != null ? JSON.stringify(block.content) : '';
|
|
250
|
+
yield {
|
|
251
|
+
type: 'tool_result',
|
|
252
|
+
name: toolName,
|
|
253
|
+
result: resultContent,
|
|
254
|
+
isError: block.is_error === true,
|
|
255
|
+
error: block.is_error === true ? resultContent : undefined,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// result → complete(含 permission_denials 提取)
|
|
261
|
+
if (event.type === 'result') {
|
|
262
|
+
// 先发出被拒绝的权限事件
|
|
263
|
+
if (Array.isArray(event.permission_denials)) {
|
|
264
|
+
for (const denial of event.permission_denials) {
|
|
265
|
+
yield {
|
|
266
|
+
type: 'tool_result',
|
|
267
|
+
name: denial.tool_name || '',
|
|
268
|
+
result: '',
|
|
269
|
+
isError: true,
|
|
270
|
+
error: `权限被拒绝: ${denial.tool_name}`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
yield {
|
|
275
|
+
type: 'complete',
|
|
276
|
+
result: event.result,
|
|
277
|
+
subtype: event.subtype,
|
|
278
|
+
isError: event.is_error,
|
|
279
|
+
errors: event.errors,
|
|
280
|
+
durationMs: event.duration_ms,
|
|
281
|
+
costUsd: event.total_cost_usd,
|
|
282
|
+
terminalReason: event.terminal_reason,
|
|
283
|
+
sessionTitle: event.session_title,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async runQuery(sessionId, prompt, projectPath, initialClaudeSessionId, images, systemPromptAppend, sessionManager) {
|
|
289
|
+
// 同步用户级配置到内存
|
|
290
|
+
this.syncFromUserSettings();
|
|
291
|
+
ensureDir(projectPath);
|
|
292
|
+
ensureDir(path.join(projectPath, '.claude'));
|
|
293
|
+
// 优先使用传入的 agentSessionId(从数据库恢复),否则使用内存中的
|
|
294
|
+
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) {
|
|
308
|
+
const homeDir = os.homedir();
|
|
309
|
+
const encodedProjectPath = encodePath(projectPath);
|
|
310
|
+
const sessionFile = path.join(homeDir, '.claude', 'projects', encodedProjectPath, `${agentSessionId}.jsonl`);
|
|
311
|
+
let isValid = false;
|
|
312
|
+
if (fs.existsSync(sessionFile)) {
|
|
313
|
+
try {
|
|
314
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
315
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
316
|
+
// 查找第一个包含 sessionId 和 version 的行(跳过 queue-operation)
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
try {
|
|
319
|
+
const data = JSON.parse(line);
|
|
320
|
+
if (data.sessionId && data.version) {
|
|
321
|
+
isValid = true;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch { }
|
|
326
|
+
}
|
|
327
|
+
if (!isValid) {
|
|
328
|
+
logger.warn(`[AgentRunner] Session file missing session data: ${sessionFile}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
logger.warn(`[AgentRunner] Session file corrupted: ${sessionFile}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!isValid) {
|
|
336
|
+
logger.warn(`[AgentRunner] Invalid session file, starting new session`);
|
|
337
|
+
agentSessionId = undefined;
|
|
338
|
+
this.activeSessions.delete(sessionId);
|
|
339
|
+
if (this.onSessionIdUpdate) {
|
|
340
|
+
this.onSessionIdUpdate(sessionId, '');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// PreCompact Hook - 在压缩开始时触发
|
|
345
|
+
const preCompactHook = async () => {
|
|
346
|
+
if (this.onCompactStart) {
|
|
347
|
+
this.onCompactStart(sessionId);
|
|
348
|
+
}
|
|
349
|
+
return {};
|
|
350
|
+
};
|
|
351
|
+
// PreToolUse Hook - 黑名单检查 + input 修正(不可绕过,所有模式都走)
|
|
352
|
+
const preToolUseHook = async (input) => {
|
|
353
|
+
const result = await checkBlacklist(input.tool_name, input.tool_input || {});
|
|
354
|
+
if (result.behavior === 'deny') {
|
|
355
|
+
return { decision: 'block', reason: result.message };
|
|
356
|
+
}
|
|
357
|
+
if (this.permissionMode === 'readonly') {
|
|
358
|
+
const roResult = checkReadonly(input.tool_name, input.tool_input || {}, projectPath);
|
|
359
|
+
if (roResult.behavior === 'deny') {
|
|
360
|
+
return { decision: 'block', reason: roResult.message };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 修正 SDK schema 不兼容问题:部分工具被 system prompt 或 skills 指示传入
|
|
364
|
+
// SDK 未定义的参数(如 EnterPlanMode 的 reason),导致 InputValidationError
|
|
365
|
+
const toolInput = input.tool_input || {};
|
|
366
|
+
const sanitizeRules = {
|
|
367
|
+
'EnterPlanMode': ['reason'],
|
|
368
|
+
'ExitPlanMode': ['reason'],
|
|
369
|
+
'ExitWorktree': ['reason'],
|
|
370
|
+
};
|
|
371
|
+
const fieldsToRemove = sanitizeRules[input.tool_name];
|
|
372
|
+
if (fieldsToRemove && fieldsToRemove.some((f) => f in toolInput)) {
|
|
373
|
+
const cleaned = { ...toolInput };
|
|
374
|
+
for (const f of fieldsToRemove)
|
|
375
|
+
delete cleaned[f];
|
|
376
|
+
return {
|
|
377
|
+
hookSpecificOutput: {
|
|
378
|
+
hookEventName: 'PreToolUse',
|
|
379
|
+
permissionDecision: 'allow',
|
|
380
|
+
updatedInput: cleaned
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {};
|
|
385
|
+
};
|
|
386
|
+
// PermissionDenied Hook - auto 模式下 SDK 拒绝操作时通知用户
|
|
387
|
+
const permissionDeniedHook = async (input) => {
|
|
388
|
+
if (this.permissionMode === 'auto' && this.sendPromptFn) {
|
|
389
|
+
const toolName = input.tool_name || '未知工具';
|
|
390
|
+
const reason = input.reason || 'AI 判断此操作有风险';
|
|
391
|
+
const message = `⚠️ 操作已自动拦截\n工具: ${toolName}\n原因: ${reason}`;
|
|
392
|
+
try {
|
|
393
|
+
await this.sendPromptFn(message);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
logger.error('[PermissionDenied] Failed to send notification:', err);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {};
|
|
400
|
+
};
|
|
401
|
+
// SDK-level canUseTool 回调:接入 PermissionGateway 的用户审批入口
|
|
402
|
+
// 只在 SDK 认为此工具需要用户确认时触发(黑名单已在 PreToolUse hook 拦截)
|
|
403
|
+
const canUseToolCallback = async (toolName, input, options) => {
|
|
404
|
+
// bypass 模式:一律 allow
|
|
405
|
+
if (this.permissionMode === 'bypass') {
|
|
406
|
+
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
407
|
+
}
|
|
408
|
+
// readonly 模式:二次拦截(belt-and-suspenders)
|
|
409
|
+
if (this.permissionMode === 'readonly') {
|
|
410
|
+
const roResult = checkReadonly(toolName, input, projectPath);
|
|
411
|
+
if (roResult.behavior === 'deny') {
|
|
412
|
+
return { behavior: 'deny', message: roResult.message, decisionClassification: 'user_reject' };
|
|
413
|
+
}
|
|
414
|
+
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
415
|
+
}
|
|
416
|
+
// auto 模式:SDK 内置分类器自动判断,正常情况下不会触发 canUseTool 回调。
|
|
417
|
+
// 防御性兜底:确保即使 SDK 边界场景或版本变化意外调用了此回调,也不会阻塞流程。
|
|
418
|
+
if (this.permissionMode === 'auto') {
|
|
419
|
+
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
420
|
+
}
|
|
421
|
+
// 如果 PermissionGateway 未设置(如测试环境),回退到一律 allow
|
|
422
|
+
if (!this.permissionGateway || !this.sendPromptFn) {
|
|
423
|
+
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
424
|
+
}
|
|
425
|
+
// always-allow 缓存命中:直接放行
|
|
426
|
+
if (this.permissionGateway.isAlwaysAllowed(toolName)) {
|
|
427
|
+
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
428
|
+
}
|
|
429
|
+
const summary = options.title
|
|
430
|
+
|| options.description
|
|
431
|
+
|| summarizeToolInput(toolName, input);
|
|
432
|
+
const decision = await this.permissionGateway.requestPermission(sessionId, toolName, input, this.sendPromptFn, this.permissionContext, summary, options.decisionReason);
|
|
433
|
+
if (decision === 'deny') {
|
|
434
|
+
return { behavior: 'deny', message: '用户拒绝或审批超时', decisionClassification: 'user_reject' };
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
behavior: 'allow',
|
|
438
|
+
updatedInput: input,
|
|
439
|
+
decisionClassification: decision === 'always' ? 'user_permanent' : 'user_temporary'
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
const useSettingSources = this.config?.agents?.anthropic?.useSettingSources !== false;
|
|
443
|
+
const enableSummaries = this.config?.agents?.anthropic?.agentProgressSummaries !== false;
|
|
444
|
+
const excludeDynamic = this.config?.agents?.anthropic?.excludeDynamicSections === true;
|
|
445
|
+
// 公共 options(新旧模式共用)
|
|
446
|
+
const sdkPermissionMode = this.toSdkPermissionMode();
|
|
447
|
+
logger.info(`[AgentRunner] runQuery model=${this.model} effort=${this.effort ?? 'auto'} permMode=${this.permissionMode} sdkMode=${sdkPermissionMode}`);
|
|
448
|
+
const commonOptions = {
|
|
449
|
+
cwd: projectPath,
|
|
450
|
+
model: this.model,
|
|
451
|
+
...(this.effort ? { effort: this.effort } : {}),
|
|
452
|
+
autoCompactWindow: 200000,
|
|
453
|
+
advisorModel: 'haiku',
|
|
454
|
+
canUseTool: canUseToolCallback,
|
|
455
|
+
permissionMode: sdkPermissionMode,
|
|
456
|
+
persistSession: true,
|
|
457
|
+
hooks: {
|
|
458
|
+
PreCompact: [{ matcher: '.*', hooks: [preCompactHook] }],
|
|
459
|
+
PreToolUse: [{ matcher: '.*', hooks: [preToolUseHook] }],
|
|
460
|
+
PermissionDenied: [{ matcher: '.*', hooks: [permissionDeniedHook] }]
|
|
461
|
+
},
|
|
462
|
+
...(enableSummaries ? { agentProgressSummaries: true } : {}),
|
|
463
|
+
stderr: (msg) => {
|
|
464
|
+
if (msg.includes('[ERROR]') || msg.includes('[WARN]') || msg.includes('Stream started')) {
|
|
465
|
+
logger.info(`[Claude-stderr] ${msg.trim()}`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
logger.debug(`[Claude-stderr] ${msg.trim()}`);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
env: this.getAgentEnv()
|
|
472
|
+
};
|
|
473
|
+
const createQuery = (promptInput, resumeSessionId) => {
|
|
474
|
+
if (useSettingSources) {
|
|
475
|
+
// 新方式:SDK 自动加载 CLAUDE.md 和 MCP 配置
|
|
476
|
+
return query({
|
|
477
|
+
prompt: promptInput,
|
|
478
|
+
options: {
|
|
479
|
+
...commonOptions,
|
|
480
|
+
settingSources: ['project', 'user'],
|
|
481
|
+
systemPrompt: {
|
|
482
|
+
type: 'preset',
|
|
483
|
+
preset: 'claude_code',
|
|
484
|
+
...(excludeDynamic ? { excludeDynamicSections: true } : {}),
|
|
485
|
+
...(systemPromptAppend ? { append: systemPromptAppend } : {})
|
|
486
|
+
},
|
|
487
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
// 旧方式:手动加载 CLAUDE.md 和 MCP 配置(保留用于回滚)
|
|
493
|
+
const globalClaudeMd = (() => {
|
|
494
|
+
try {
|
|
495
|
+
const globalPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
496
|
+
if (fs.existsSync(globalPath)) {
|
|
497
|
+
return fs.readFileSync(globalPath, 'utf-8').trim();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch { }
|
|
501
|
+
return '';
|
|
502
|
+
})();
|
|
503
|
+
const projectClaudeMds = [
|
|
504
|
+
path.join(projectPath, 'CLAUDE.md'),
|
|
505
|
+
path.join(projectPath, '.claude', 'CLAUDE.md'),
|
|
506
|
+
].map(p => {
|
|
507
|
+
try {
|
|
508
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8').trim() : '';
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return '';
|
|
512
|
+
}
|
|
513
|
+
}).filter(Boolean);
|
|
514
|
+
const globalMcpServers = (() => {
|
|
515
|
+
try {
|
|
516
|
+
const mcpPath = path.join(os.homedir(), '.claude', 'mcp.json');
|
|
517
|
+
if (fs.existsSync(mcpPath)) {
|
|
518
|
+
const config = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
|
|
519
|
+
return config.mcpServers || {};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch { }
|
|
523
|
+
return {};
|
|
524
|
+
})();
|
|
525
|
+
const fullAppend = [...projectClaudeMds, globalClaudeMd, systemPromptAppend].filter(Boolean).join('\n\n');
|
|
526
|
+
return query({
|
|
527
|
+
prompt: promptInput,
|
|
528
|
+
options: {
|
|
529
|
+
...commonOptions,
|
|
530
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
531
|
+
...(Object.keys(globalMcpServers).length > 0 ? { mcpServers: globalMcpServers } : {}),
|
|
532
|
+
...(fullAppend ? {
|
|
533
|
+
systemPrompt: {
|
|
534
|
+
type: 'preset',
|
|
535
|
+
preset: 'claude_code',
|
|
536
|
+
append: fullAppend
|
|
537
|
+
}
|
|
538
|
+
} : {}),
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
let sdkStream;
|
|
544
|
+
if (images && images.length > 0) {
|
|
545
|
+
logger.debug('[AgentRunner] Creating query with images, images:', images.length);
|
|
546
|
+
logger.debug('[AgentRunner] Skipping resume for image message to avoid history conflict');
|
|
547
|
+
const stream = new MessageStream();
|
|
548
|
+
stream.push(prompt, images);
|
|
549
|
+
stream.end();
|
|
550
|
+
sdkStream = createQuery(stream);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
logger.debug('[AgentRunner] Creating query with text only, agentSessionId:', initialClaudeSessionId);
|
|
554
|
+
sdkStream = createQuery(prompt, agentSessionId);
|
|
555
|
+
}
|
|
556
|
+
// 保存 interrupt 能力(不写 activeStreams,由 registerStream 管理活跃状态)
|
|
557
|
+
if ('interrupt' in sdkStream && typeof sdkStream.interrupt === 'function') {
|
|
558
|
+
this.interruptFns.set(sessionId, () => sdkStream.interrupt());
|
|
559
|
+
}
|
|
560
|
+
// 返回标准 AgentEvent 流(重试由 MessageProcessor 层负责)
|
|
561
|
+
return this.transformStream(sdkStream, sessionId);
|
|
562
|
+
}
|
|
563
|
+
async interrupt(sessionId) {
|
|
564
|
+
const fn = this.interruptFns.get(sessionId);
|
|
565
|
+
if (fn) {
|
|
566
|
+
try {
|
|
567
|
+
await fn();
|
|
568
|
+
logger.info(`[AgentRunner] Interrupted session: ${sessionId}`);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
logger.warn(`[AgentRunner] Interrupt failed (transport closed): ${sessionId}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
this.interruptFns.delete(sessionId);
|
|
575
|
+
this.activeStreams.delete(sessionId);
|
|
576
|
+
}
|
|
577
|
+
hasActiveStream(sessionId) {
|
|
578
|
+
return this.activeStreams.has(sessionId);
|
|
579
|
+
}
|
|
580
|
+
registerStream(key, stream) {
|
|
581
|
+
this.activeStreams.set(key, stream);
|
|
582
|
+
}
|
|
583
|
+
cleanupStream(sessionId) {
|
|
584
|
+
this.activeStreams.delete(sessionId);
|
|
585
|
+
this.interruptFns.delete(sessionId);
|
|
586
|
+
}
|
|
587
|
+
updateSessionId(sessionId, agentSessionId) {
|
|
588
|
+
logger.info(`[AgentRunner] updateSessionId called: sessionId=${sessionId}, agentSessionId=${agentSessionId}`);
|
|
589
|
+
this.activeSessions.set(sessionId, agentSessionId);
|
|
590
|
+
if (this.onSessionIdUpdate) {
|
|
591
|
+
this.onSessionIdUpdate(sessionId, agentSessionId);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
runSessionCommand(prompt, agentSessionId, projectPath) {
|
|
595
|
+
return query({
|
|
596
|
+
prompt,
|
|
597
|
+
options: {
|
|
598
|
+
cwd: projectPath,
|
|
599
|
+
model: this.model,
|
|
600
|
+
resume: agentSessionId,
|
|
601
|
+
maxTurns: 1,
|
|
602
|
+
permissionMode: this.toSdkPermissionMode(),
|
|
603
|
+
env: this.getAgentEnv()
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* 主动压缩会话上下文
|
|
609
|
+
*/
|
|
610
|
+
async compactSession(sessionId, agentSessionId, projectPath) {
|
|
611
|
+
try {
|
|
612
|
+
logger.info(`[AgentRunner] Compacting session: ${agentSessionId}`);
|
|
613
|
+
const stream = this.runSessionCommand('/compact', agentSessionId, projectPath);
|
|
614
|
+
this.activeStreams.set(sessionId, stream);
|
|
615
|
+
try {
|
|
616
|
+
for await (const event of stream) {
|
|
617
|
+
if (event.type === 'system' && event.subtype === 'compact_boundary') {
|
|
618
|
+
logger.info(`[AgentRunner] Compact completed, pre_tokens: ${event.compact_metadata?.pre_tokens}`);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
this.activeStreams.delete(sessionId);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
logger.error('[AgentRunner] Compact failed:', error);
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* 通过 SDK /clear 命令清空会话历史
|
|
635
|
+
*/
|
|
636
|
+
async clearSession(sessionId, agentSessionId, projectPath) {
|
|
637
|
+
try {
|
|
638
|
+
logger.info(`[AgentRunner] Clearing session via SDK: ${agentSessionId}`);
|
|
639
|
+
const stream = this.runSessionCommand('/clear', agentSessionId, projectPath);
|
|
640
|
+
this.activeStreams.set(sessionId, stream);
|
|
641
|
+
try {
|
|
642
|
+
for await (const event of stream) {
|
|
643
|
+
logger.debug(`[AgentRunner] Clear event: type=${event.type}, subtype=${event.subtype || 'none'}`);
|
|
644
|
+
}
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
this.activeStreams.delete(sessionId);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
logger.error('[AgentRunner] Clear session failed:', error);
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async closeSession(sessionId) {
|
|
657
|
+
this.activeSessions.delete(sessionId);
|
|
658
|
+
this.activeStreams.delete(sessionId);
|
|
659
|
+
this.interruptFns.delete(sessionId);
|
|
660
|
+
}
|
|
661
|
+
resolveSessionFile(agentSessionId, projectPath) {
|
|
662
|
+
const encodedProjectPath = encodePath(projectPath);
|
|
663
|
+
const sessionFile = path.join(os.homedir(), '.claude', 'projects', encodedProjectPath, `${agentSessionId}.jsonl`);
|
|
664
|
+
return fs.existsSync(sessionFile) ? sessionFile : null;
|
|
665
|
+
}
|
|
666
|
+
async forkSession(agentSessionId, projectPath, title) {
|
|
667
|
+
const result = await sdkForkSession(agentSessionId, { dir: projectPath, title });
|
|
668
|
+
return result.sessionId;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// Plugin implementation
|
|
672
|
+
export class ClaudeAgentPlugin {
|
|
673
|
+
name = 'claude';
|
|
674
|
+
isEnabled(config) {
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
createAgent(config, callbacks) {
|
|
678
|
+
const anthropic = resolveAnthropicConfig(config);
|
|
679
|
+
const agentRunner = new AgentRunner(anthropic.apiKey, anthropic.model, callbacks.onSessionIdUpdate, anthropic.baseUrl, config);
|
|
680
|
+
if (anthropic.effort) {
|
|
681
|
+
agentRunner.setEffort(anthropic.effort);
|
|
682
|
+
}
|
|
683
|
+
return { agent: agentRunner };
|
|
684
|
+
}
|
|
685
|
+
}
|