evolclaw 2.2.0 → 2.4.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 +49 -27
- package/data/evolclaw.sample.json +6 -3
- package/dist/agents/claude-runner.js +125 -52
- package/dist/agents/codex-runner.js +10 -5
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +283 -95
- package/dist/channels/feishu.js +556 -96
- package/dist/channels/wechat.js +98 -74
- package/dist/cli.js +232 -57
- package/dist/config.js +185 -31
- package/dist/core/channel-loader.js +11 -4
- package/dist/core/command-handler.js +803 -247
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +217 -0
- package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
- package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
- package/dist/{utils → core/message}/stream-debouncer.js +1 -1
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/core/permission.js +212 -11
- package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
- package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/{utils → core/session}/session-file-health.js +1 -1
- package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
- package/dist/index.js +140 -57
- package/dist/{core/ipc-server.js → ipc.js} +36 -1
- package/dist/types.js +3 -0
- package/dist/utils/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 +55 -150
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/{core → utils}/stats-collector.js +16 -0
- package/package.json +3 -3
- package/dist/core/message-bridge.js +0 -187
- package/dist/utils/init-feishu.js +0 -263
- package/dist/utils/init-wechat.js +0 -172
- package/dist/utils/ipc-client.js +0 -36
- package/dist/utils/permission-utils.js +0 -71
- /package/dist/{utils → core/message}/message-cache.js +0 -0
- /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
- /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { logger } from '../../utils/logger.js';
|
|
1
2
|
import fs from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
|
-
import { resolvePaths } from '
|
|
4
|
+
import { resolvePaths } from '../../paths.js';
|
|
4
5
|
// 诊断日志(按需启用,通过 config.debug.flusherDiag 控制)
|
|
5
6
|
let diagStream = null;
|
|
6
7
|
function getDiagStream() {
|
|
@@ -32,7 +33,7 @@ export class StreamFlusher {
|
|
|
32
33
|
send;
|
|
33
34
|
interval;
|
|
34
35
|
buffer = '';
|
|
35
|
-
|
|
36
|
+
queue = []; // 按入队顺序记录 activity 和 text 段
|
|
36
37
|
timer;
|
|
37
38
|
lastFlush = Date.now();
|
|
38
39
|
allText = '';
|
|
@@ -43,6 +44,7 @@ export class StreamFlusher {
|
|
|
43
44
|
instanceId;
|
|
44
45
|
createTime = Date.now();
|
|
45
46
|
diagEnabled;
|
|
47
|
+
sendChain = Promise.resolve(); // 串行发送队列,保证消息按序到达
|
|
46
48
|
constructor(send, interval = 4000, fileMarkerPattern, diagEnabled = false) {
|
|
47
49
|
this.send = send;
|
|
48
50
|
this.interval = interval;
|
|
@@ -53,11 +55,14 @@ export class StreamFlusher {
|
|
|
53
55
|
diag(this.instanceId, 'created', { interval });
|
|
54
56
|
}
|
|
55
57
|
addText(text) {
|
|
58
|
+
if (this.buffer.length === 0 && text.length > 0) {
|
|
59
|
+
this.queue.push({ kind: 'text' });
|
|
60
|
+
}
|
|
56
61
|
this.buffer += text;
|
|
57
62
|
this.allText += text;
|
|
58
63
|
this.messageTimestamps.push(Date.now());
|
|
59
64
|
if (this.diagEnabled)
|
|
60
|
-
diag(this.instanceId, 'addText', { len: text.length, preview: text.substring(0, 60), bufLen: this.buffer.length,
|
|
65
|
+
diag(this.instanceId, 'addText', { len: text.length, preview: text.substring(0, 60), bufLen: this.buffer.length, queueLen: this.queue.length });
|
|
61
66
|
this.scheduleFlush();
|
|
62
67
|
}
|
|
63
68
|
addTextBlock(text) {
|
|
@@ -67,20 +72,21 @@ export class StreamFlusher {
|
|
|
67
72
|
}
|
|
68
73
|
this.buffer += text;
|
|
69
74
|
this.allText += text;
|
|
75
|
+
this.queue.push({ kind: 'text' });
|
|
70
76
|
this.messageTimestamps.push(Date.now());
|
|
71
77
|
if (this.diagEnabled)
|
|
72
78
|
diag(this.instanceId, 'addTextBlock', { len: text.length, preview: text.substring(0, 60), bufLen: this.buffer.length });
|
|
73
79
|
this.scheduleFlush();
|
|
74
80
|
}
|
|
75
81
|
addActivity(desc) {
|
|
76
|
-
this.
|
|
82
|
+
this.queue.push({ kind: 'activity', text: desc });
|
|
77
83
|
this.messageTimestamps.push(Date.now());
|
|
78
84
|
if (this.diagEnabled)
|
|
79
|
-
diag(this.instanceId, 'addActivity', { desc: desc.substring(0, 80),
|
|
85
|
+
diag(this.instanceId, 'addActivity', { desc: desc.substring(0, 80), queueLen: this.queue.length });
|
|
80
86
|
this.scheduleFlush();
|
|
81
87
|
}
|
|
82
88
|
hasContent() {
|
|
83
|
-
return this.buffer.length > 0 || this.
|
|
89
|
+
return this.buffer.length > 0 || this.queue.some(e => e.kind === 'activity');
|
|
84
90
|
}
|
|
85
91
|
hasSentContent() {
|
|
86
92
|
return this.sentContent;
|
|
@@ -102,7 +108,7 @@ export class StreamFlusher {
|
|
|
102
108
|
}
|
|
103
109
|
let targetDelay;
|
|
104
110
|
if (this.flushCount === 0) {
|
|
105
|
-
targetDelay =
|
|
111
|
+
targetDelay = 500;
|
|
106
112
|
}
|
|
107
113
|
else if (this.flushCount <= 3) {
|
|
108
114
|
targetDelay = Math.ceil(this.interval / 2);
|
|
@@ -133,27 +139,81 @@ export class StreamFlusher {
|
|
|
133
139
|
const maxDelay = this.interval * 2.5;
|
|
134
140
|
return Math.max(minDelay, Math.min(maxDelay, dynamicDelay));
|
|
135
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* 只 flush activities,保留 text buffer 不动
|
|
144
|
+
* 用于 complete 事件前清空 pending activities,让最终文本留给 flush(true) 发送
|
|
145
|
+
*/
|
|
146
|
+
async flushActivitiesOnly() {
|
|
147
|
+
const hasActivities = this.queue.some(e => e.kind === 'activity');
|
|
148
|
+
if (!hasActivities)
|
|
149
|
+
return;
|
|
150
|
+
if (this.timer) {
|
|
151
|
+
clearTimeout(this.timer);
|
|
152
|
+
this.timer = undefined;
|
|
153
|
+
}
|
|
154
|
+
// 只取 activity 条目,保留 text 条目在 queue 中
|
|
155
|
+
const activities = this.queue.filter(e => e.kind === 'activity');
|
|
156
|
+
this.queue = this.queue.filter(e => e.kind === 'text');
|
|
157
|
+
let output = activities.map(e => e.text).join('\n') + '\n\n';
|
|
158
|
+
if (output && this.fileMarkerPattern) {
|
|
159
|
+
output = output.replace(this.fileMarkerPattern, '').trim();
|
|
160
|
+
}
|
|
161
|
+
if (this.diagEnabled)
|
|
162
|
+
diag(this.instanceId, 'flushActivitiesOnly', { outputLen: output.length });
|
|
163
|
+
if (output) {
|
|
164
|
+
const text = output;
|
|
165
|
+
// chain 保持不断裂:单条失败不阻塞后续(catch → resolve)
|
|
166
|
+
this.sendChain = this.sendChain
|
|
167
|
+
.then(() => this.send(text, false, false))
|
|
168
|
+
.catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
|
|
169
|
+
await this.sendChain;
|
|
170
|
+
this.sentContent = true;
|
|
171
|
+
this.lastFlush = Date.now();
|
|
172
|
+
this.flushCount++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
136
175
|
async flush(isFinal) {
|
|
137
176
|
if (this.timer) {
|
|
138
177
|
clearTimeout(this.timer);
|
|
139
178
|
this.timer = undefined;
|
|
140
179
|
}
|
|
141
180
|
let output = '';
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
181
|
+
const hasText = this.buffer.length > 0;
|
|
182
|
+
// 按入队顺序合并:遇到 text 条目时插入 buffer 内容,遇到 activity 直接追加
|
|
183
|
+
let textInserted = false;
|
|
184
|
+
for (const entry of this.queue) {
|
|
185
|
+
if (entry.kind === 'activity') {
|
|
186
|
+
// 确保 activity 前有换行分隔(text 末尾可能没有换行)
|
|
187
|
+
if (output && !output.endsWith('\n'))
|
|
188
|
+
output += '\n';
|
|
189
|
+
output += entry.text + '\n';
|
|
190
|
+
}
|
|
191
|
+
else if (!textInserted) {
|
|
192
|
+
if (output)
|
|
193
|
+
output += output.endsWith('\n') ? '\n' : '\n\n';
|
|
194
|
+
output += this.buffer;
|
|
195
|
+
textInserted = true;
|
|
196
|
+
}
|
|
145
197
|
}
|
|
146
|
-
|
|
198
|
+
// 如果 queue 为空但有 buffer(纯文本情况)
|
|
199
|
+
if (!textInserted && hasText) {
|
|
147
200
|
output += this.buffer;
|
|
148
|
-
this.buffer = '';
|
|
149
201
|
}
|
|
202
|
+
this.queue = [];
|
|
203
|
+
this.buffer = '';
|
|
150
204
|
if (output && this.fileMarkerPattern) {
|
|
151
205
|
output = output.replace(this.fileMarkerPattern, '').trim();
|
|
152
206
|
}
|
|
153
207
|
if (this.diagEnabled)
|
|
154
208
|
diag(this.instanceId, 'flush', { isFinal, outputLen: output.length, flushCount: this.flushCount, sinceLastFlush: Date.now() - this.lastFlush, preview: output.substring(0, 80) });
|
|
155
209
|
if (output) {
|
|
156
|
-
|
|
210
|
+
const text = output;
|
|
211
|
+
const final = isFinal;
|
|
212
|
+
const ht = hasText;
|
|
213
|
+
this.sendChain = this.sendChain
|
|
214
|
+
.then(() => this.send(text, final, ht))
|
|
215
|
+
.catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
|
|
216
|
+
await this.sendChain;
|
|
157
217
|
this.sentContent = true;
|
|
158
218
|
this.lastFlush = Date.now();
|
|
159
219
|
this.flushCount++;
|
package/dist/core/permission.js
CHANGED
|
@@ -1,38 +1,239 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'path';
|
|
2
|
+
// 危险命令黑名单(正则表达式)
|
|
3
|
+
const DANGEROUS_PATTERNS = [
|
|
4
|
+
// Unix
|
|
5
|
+
/\brm\s+-\w*r\w*f/, // rm -rf
|
|
6
|
+
/\bsudo\b/, // sudo
|
|
7
|
+
/\bmkfs\b/, // mkfs (格式化文件系统)
|
|
8
|
+
/\bdd\s+if=/, // dd (磁盘操作)
|
|
9
|
+
/\bchmod\s+777/, // chmod 777 (危险权限)
|
|
10
|
+
/>\s*\/dev\/(?!null\b)/, // 重定向到设备文件(排除 /dev/null)
|
|
11
|
+
/\bshutdown\b/, // 关机
|
|
12
|
+
/\breboot\b/, // 重启
|
|
13
|
+
// Windows
|
|
14
|
+
/\bformat\s+[a-zA-Z]:/i, // format C: (格式化磁盘)
|
|
15
|
+
/\brd\s+\/s/i, // rd /s (递归删除目录)
|
|
16
|
+
/\bdel\s+\/[sfq]/i, // del /f, /s, /q (强制删除)
|
|
17
|
+
/\breg\s+delete/i, // reg delete (删除注册表)
|
|
18
|
+
/\bnet\s+stop/i, // net stop (停止服务)
|
|
19
|
+
];
|
|
20
|
+
// 只读模式写入命令黑名单
|
|
21
|
+
const READONLY_WRITE_PATTERNS = [
|
|
22
|
+
/\bmkdir\b/, /\btouch\b/, /\btee\b/, /\bcp\b/, /\bmv\b/,
|
|
23
|
+
/\brm\b/, /\brmdir\b/, /\bchmod\b/, /\bchown\b/, /\bln\b/,
|
|
24
|
+
/>>?\s/,
|
|
25
|
+
/\bgit\s+(commit|push|merge|rebase|reset|stash|checkout|cherry-pick|revert|tag|branch\s+-[dDmM])/,
|
|
26
|
+
/\bgit\s+am\b/,
|
|
27
|
+
/\bnpm\s+(install|ci|uninstall|update|link|publish|run|exec|init)\b/,
|
|
28
|
+
/\bnpx\b/, /\byarn\b/, /\bpnpm\b/, /\bpip\s+install\b/,
|
|
29
|
+
/\bsed\s+-i\b/, /\bawk\s+-i\b/, /\bpatch\b/,
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* 只读模式检查(用于 PreToolUse hook 和 canUseTool callback)
|
|
33
|
+
* Write/Edit/NotebookEdit 仅允许写入 {projectPath}/.evolclaw/tmp/
|
|
34
|
+
* Bash 拦截所有写入意图命令
|
|
35
|
+
*/
|
|
36
|
+
export function checkReadonly(toolName, input, projectPath) {
|
|
37
|
+
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit') {
|
|
38
|
+
const filePath = (input.file_path || input.notebook_path);
|
|
39
|
+
if (!filePath)
|
|
40
|
+
return { behavior: 'allow' };
|
|
41
|
+
const tmpDir = path.join(projectPath, '.evolclaw', 'tmp') + path.sep;
|
|
42
|
+
const resolved = path.resolve(projectPath, filePath) + (filePath.endsWith(path.sep) ? path.sep : '');
|
|
43
|
+
if (!resolved.startsWith(tmpDir) && resolved !== tmpDir.slice(0, -1)) {
|
|
44
|
+
return { behavior: 'deny', message: '🔒 只读模式:禁止修改项目文件。如需生成文件请写入 .evolclaw/tmp/ 目录' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (toolName === 'Bash') {
|
|
48
|
+
const cmd = input.command || '';
|
|
49
|
+
for (const pattern of READONLY_WRITE_PATTERNS) {
|
|
50
|
+
if (pattern.test(cmd)) {
|
|
51
|
+
return { behavior: 'deny', message: '🔒 只读模式:禁止执行写入操作' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { behavior: 'allow' };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 黑名单检查(用于 PreToolUse hook)
|
|
59
|
+
* 检查危险命令模式,非黑名单一律放行
|
|
60
|
+
*/
|
|
61
|
+
export async function checkBlacklist(toolName, input) {
|
|
62
|
+
// 只检查 Bash 工具,其余工具全部放行
|
|
63
|
+
if (toolName === 'Bash') {
|
|
64
|
+
const cmd = input.command || '';
|
|
65
|
+
// 空命令直接放行
|
|
66
|
+
if (!cmd || cmd.trim() === '') {
|
|
67
|
+
return { behavior: 'allow', updatedInput: input };
|
|
68
|
+
}
|
|
69
|
+
// 检查黑名单
|
|
70
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
71
|
+
if (pattern.test(cmd)) {
|
|
72
|
+
return {
|
|
73
|
+
behavior: 'deny',
|
|
74
|
+
message: `⛔ 危险命令被拦截: ${cmd.substring(0, 80)}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 默认允许
|
|
80
|
+
return { behavior: 'allow', updatedInput: input };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 工具输入摘要(提取工具调用的可读描述,供权限审批和消息展示使用)
|
|
84
|
+
*/
|
|
85
|
+
export function summarizeToolInput(toolName, input) {
|
|
86
|
+
if (!input)
|
|
87
|
+
return '';
|
|
88
|
+
const extractors = {
|
|
89
|
+
'Read': (i) => i.file_path,
|
|
90
|
+
'Edit': (i) => i.file_path,
|
|
91
|
+
'Write': (i) => i.file_path,
|
|
92
|
+
'Bash': (i) => i.command?.substring(0, 80),
|
|
93
|
+
'Grep': (i) => `pattern: ${i.pattern}`,
|
|
94
|
+
'Glob': (i) => `pattern: ${i.pattern}`,
|
|
95
|
+
'Agent': (i) => i.description || i.prompt?.substring(0, 80),
|
|
96
|
+
'Skill': (i) => i.skill ? `${i.skill}${i.args ? ' ' + i.args : ''}` : undefined,
|
|
97
|
+
'TodoWrite': (i) => {
|
|
98
|
+
if (Array.isArray(i.todos)) {
|
|
99
|
+
return i.todos.map((t) => t.content || t.task || t.text).filter(Boolean).join(', ').substring(0, 80);
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
},
|
|
103
|
+
'TaskCreate': (i) => i.subject || i.description?.substring(0, 80),
|
|
104
|
+
'TaskUpdate': (i) => i.status ? `${i.taskId} → ${i.status}` : i.taskId,
|
|
105
|
+
'NotebookEdit': (i) => i.notebook_path,
|
|
106
|
+
'WebFetch': (i) => i.url,
|
|
107
|
+
'WebSearch': (i) => i.query?.substring(0, 80),
|
|
108
|
+
};
|
|
109
|
+
const extractor = extractors[toolName];
|
|
110
|
+
if (extractor) {
|
|
111
|
+
const result = extractor(input);
|
|
112
|
+
if (result)
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
return input.description
|
|
116
|
+
|| input.subject
|
|
117
|
+
|| input.file_path
|
|
118
|
+
|| input.pattern
|
|
119
|
+
|| input.command?.substring(0, 80)
|
|
120
|
+
|| input.prompt?.substring(0, 80)
|
|
121
|
+
|| input.query?.substring(0, 80)
|
|
122
|
+
|| input.skill
|
|
123
|
+
|| input.url
|
|
124
|
+
|| '';
|
|
125
|
+
}
|
|
2
126
|
export class PermissionGateway {
|
|
3
127
|
pending = new Map();
|
|
4
128
|
timeout = 5 * 60 * 1000;
|
|
5
129
|
eventBus;
|
|
130
|
+
/** 始终允许的工具缓存:toolName → Set<pattern> */
|
|
131
|
+
alwaysAllow = new Map();
|
|
6
132
|
setEventBus(eventBus) {
|
|
7
133
|
this.eventBus = eventBus;
|
|
8
134
|
}
|
|
9
135
|
/**
|
|
10
|
-
*
|
|
11
|
-
|
|
136
|
+
* 检查工具是否已被标记为"始终允许"
|
|
137
|
+
*/
|
|
138
|
+
isAlwaysAllowed(toolName) {
|
|
139
|
+
return this.alwaysAllow.has(toolName);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 将工具标记为"始终允许"
|
|
12
143
|
*/
|
|
13
|
-
|
|
144
|
+
addAlwaysAllow(toolName) {
|
|
145
|
+
if (!this.alwaysAllow.has(toolName)) {
|
|
146
|
+
this.alwaysAllow.set(toolName, new Set());
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 清除所有"始终允许"缓存(用于切换权限模式时重置)
|
|
151
|
+
*/
|
|
152
|
+
clearAlwaysAllow() {
|
|
153
|
+
this.alwaysAllow.clear();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 获取所有"始终允许"的工具列表
|
|
157
|
+
*/
|
|
158
|
+
getAlwaysAllowList() {
|
|
159
|
+
return [...this.alwaysAllow.keys()];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 请求人工审批。返回三态决策。
|
|
163
|
+
*/
|
|
164
|
+
async requestPermission(sessionId, toolName, toolInput, sendPrompt, context, summary, reason) {
|
|
165
|
+
// 如果已标记为始终允许,直接放行
|
|
166
|
+
if (this.isAlwaysAllowed(toolName)) {
|
|
167
|
+
return 'always';
|
|
168
|
+
}
|
|
14
169
|
const requestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15
170
|
const displaySummary = summary || summarizeToolInput(toolName, toolInput);
|
|
16
171
|
const reasonLine = reason ? `\n原因:${reason}` : '';
|
|
17
172
|
this.eventBus?.publish({ type: 'permission:requested', sessionId, requestId, toolName, input: displaySummary });
|
|
18
|
-
|
|
173
|
+
// 构造 ActionInteraction
|
|
174
|
+
const interaction = {
|
|
175
|
+
type: 'interaction',
|
|
176
|
+
id: requestId,
|
|
177
|
+
kind: {
|
|
178
|
+
kind: 'action',
|
|
179
|
+
title: '🔐 权限请求',
|
|
180
|
+
body: `工具:${toolName}\n操作:${displaySummary}${reasonLine}`,
|
|
181
|
+
buttons: [
|
|
182
|
+
{ key: 'allow', label: '✅ 允许', style: 'primary' },
|
|
183
|
+
{ key: 'always', label: '🔓 始终允许', style: 'default' },
|
|
184
|
+
{ key: 'deny', label: '❌ 拒绝', style: 'danger' },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
channelId: context?.channelId || '',
|
|
188
|
+
sessionId,
|
|
189
|
+
expiresAt: Date.now() + this.timeout,
|
|
190
|
+
};
|
|
191
|
+
// 尝试富交互
|
|
192
|
+
let interactionSent = false;
|
|
193
|
+
if (context?.adapter?.sendInteraction && context.channelId) {
|
|
194
|
+
try {
|
|
195
|
+
const result = await context.adapter.sendInteraction(context.channelId, interaction, context.replyContext);
|
|
196
|
+
interactionSent = !!result;
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
// sendInteraction 失败,fallback 到文本
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// fallback 到文本
|
|
203
|
+
if (!interactionSent) {
|
|
204
|
+
await sendPrompt(`🔐 权限请求\n工具:${toolName}\n操作:${displaySummary}${reasonLine}\n\n回复 /perm allow 本次允许 | always 始终允许 | deny 拒绝`);
|
|
205
|
+
}
|
|
19
206
|
return new Promise((resolve) => {
|
|
20
207
|
const timer = setTimeout(() => {
|
|
21
208
|
this.pending.delete(requestId);
|
|
209
|
+
// 清理 router 注册(仅删除本次请求,不影响其他交互)
|
|
210
|
+
if (interactionSent && context?.interactionRouter) {
|
|
211
|
+
context.interactionRouter.cancel(requestId);
|
|
212
|
+
}
|
|
22
213
|
this.eventBus?.publish({ type: 'permission:timeout', sessionId, requestId });
|
|
23
|
-
resolve(
|
|
214
|
+
resolve('deny');
|
|
24
215
|
}, this.timeout);
|
|
25
|
-
this.pending.set(requestId, { sessionId, resolve, timer });
|
|
216
|
+
this.pending.set(requestId, { sessionId, toolName, resolve, timer });
|
|
217
|
+
// 如果发了交互卡片,同时注册到 InteractionRouter
|
|
218
|
+
if (interactionSent && context?.interactionRouter) {
|
|
219
|
+
context.interactionRouter.register(requestId, sessionId, (action) => {
|
|
220
|
+
this.resolvePermission(sessionId, requestId, action);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
26
223
|
});
|
|
27
224
|
}
|
|
28
|
-
resolvePermission(sessionId, requestId,
|
|
225
|
+
resolvePermission(sessionId, requestId, decision) {
|
|
29
226
|
const pending = this.pending.get(requestId);
|
|
30
227
|
if (!pending || pending.sessionId !== sessionId)
|
|
31
228
|
return false;
|
|
32
229
|
clearTimeout(pending.timer);
|
|
33
|
-
|
|
230
|
+
// 如果是 always,缓存该工具
|
|
231
|
+
if (decision === 'always') {
|
|
232
|
+
this.addAlwaysAllow(pending.toolName);
|
|
233
|
+
}
|
|
234
|
+
pending.resolve(decision);
|
|
34
235
|
this.pending.delete(requestId);
|
|
35
|
-
this.eventBus?.publish({ type: 'permission:resolved', sessionId, requestId, approved });
|
|
236
|
+
this.eventBus?.publish({ type: 'permission:resolved', sessionId, requestId, approved: decision !== 'deny' });
|
|
36
237
|
return true;
|
|
37
238
|
}
|
|
38
239
|
/** 中断时取消指定会话的所有 pending 权限请求 */
|
|
@@ -40,7 +241,7 @@ export class PermissionGateway {
|
|
|
40
241
|
for (const [requestId, pending] of this.pending.entries()) {
|
|
41
242
|
if (pending.sessionId === sessionId) {
|
|
42
243
|
clearTimeout(pending.timer);
|
|
43
|
-
pending.resolve(
|
|
244
|
+
pending.resolve('deny');
|
|
44
245
|
this.pending.delete(requestId);
|
|
45
246
|
}
|
|
46
247
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* and wraps sdkListSessions for name synchronization.
|
|
6
6
|
*/
|
|
7
7
|
import { listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
-
import { encodePath } from '
|
|
9
|
-
import { logger } from '
|
|
8
|
+
import { encodePath } from '../../../utils/cross-platform.js';
|
|
9
|
+
import { logger } from '../../../utils/logger.js';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import os from 'os';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and Codex rollout JSONL files for detailed session info.
|
|
6
6
|
*/
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
-
import { logger } from '
|
|
8
|
+
import { logger } from '../../../utils/logger.js';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import os from 'os';
|
|
@@ -53,78 +53,116 @@ export class CodexSessionFileAdapter {
|
|
|
53
53
|
return this.db;
|
|
54
54
|
}
|
|
55
55
|
checkExists(projectPath, agentSessionId) {
|
|
56
|
+
// 1. 优先查 state_*.sqlite(覆盖 CLI 创建的线程)
|
|
56
57
|
const db = this.getDb();
|
|
57
|
-
if (
|
|
58
|
-
|
|
58
|
+
if (db) {
|
|
59
|
+
try {
|
|
60
|
+
const row = db.prepare('SELECT 1 FROM threads WHERE id = ? AND archived = 0').get(agentSessionId);
|
|
61
|
+
if (row)
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
logger.warn(`[CodexAdapter] checkExists DB query failed:`, error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 2. Fallback: 扫 ~/.codex/sessions/ 下的 rollout JSONL
|
|
69
|
+
// SDK 创建的 thread 不写 state_*.sqlite,但会持久化到 sessions 目录
|
|
70
|
+
return !!this.findRolloutFile(agentSessionId);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 在 ~/.codex/sessions/ 下递归查找含 agentSessionId 的 rollout JSONL 文件
|
|
74
|
+
*/
|
|
75
|
+
findRolloutFile(agentSessionId) {
|
|
76
|
+
const sessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
|
77
|
+
if (!fs.existsSync(sessionsDir))
|
|
78
|
+
return null;
|
|
59
79
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
80
|
+
const search = (dir) => {
|
|
81
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
const found = search(path.join(dir, entry.name));
|
|
84
|
+
if (found)
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
else if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
|
|
88
|
+
return path.join(dir, entry.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
return search(sessionsDir);
|
|
62
94
|
}
|
|
63
|
-
catch
|
|
64
|
-
|
|
65
|
-
return false;
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
66
97
|
}
|
|
67
98
|
}
|
|
68
99
|
getFileInfo(projectPath, agentSessionId) {
|
|
100
|
+
// 1. 优先查 state DB
|
|
69
101
|
const db = this.getDb();
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
102
|
+
if (db) {
|
|
103
|
+
try {
|
|
104
|
+
const row = db.prepare('SELECT title, rollout_path FROM threads WHERE id = ?').get(agentSessionId);
|
|
105
|
+
if (row) {
|
|
106
|
+
return {
|
|
107
|
+
turns: this.countTurnsFromRollout(row.rollout_path),
|
|
108
|
+
title: row.title || undefined,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
logger.warn(`[CodexAdapter] getFileInfo DB query failed:`, error);
|
|
114
|
+
}
|
|
79
115
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
116
|
+
// 2. Fallback: 从 sessions 目录查找 rollout 文件
|
|
117
|
+
const rolloutPath = this.findRolloutFile(agentSessionId);
|
|
118
|
+
if (rolloutPath) {
|
|
119
|
+
return { turns: this.countTurnsFromRollout(rolloutPath) };
|
|
83
120
|
}
|
|
121
|
+
return { turns: 0 };
|
|
84
122
|
}
|
|
85
123
|
readFirstMessage(projectPath, agentSessionId) {
|
|
124
|
+
// 1. 优先查 state DB
|
|
86
125
|
const db = this.getDb();
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
if (db) {
|
|
127
|
+
try {
|
|
128
|
+
const row = db.prepare('SELECT first_user_message FROM threads WHERE id = ?').get(agentSessionId);
|
|
129
|
+
if (row?.first_user_message) {
|
|
130
|
+
const text = row.first_user_message.trim().replace(/\s+/g, ' ');
|
|
131
|
+
return text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
logger.warn(`[CodexAdapter] readFirstMessage DB query failed:`, error);
|
|
136
|
+
}
|
|
95
137
|
}
|
|
96
|
-
|
|
97
|
-
|
|
138
|
+
// 2. Fallback: 从 rollout JSONL 读取第一条 user_message
|
|
139
|
+
const rolloutPath = this.findRolloutFile(agentSessionId);
|
|
140
|
+
if (!rolloutPath)
|
|
98
141
|
return null;
|
|
99
|
-
|
|
142
|
+
return this.readUserMessageFromRollout(rolloutPath, 'first');
|
|
100
143
|
}
|
|
101
144
|
readLastUserMessage(projectPath, agentSessionId) {
|
|
145
|
+
// 1. 优先查 state DB 获取 rollout_path
|
|
102
146
|
const db = this.getDb();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const content = fs.readFileSync(row.rollout_path, 'utf-8');
|
|
110
|
-
const lines = content.split('\n').filter(l => l.trim());
|
|
111
|
-
let lastMessage = null;
|
|
112
|
-
for (const line of lines) {
|
|
113
|
-
try {
|
|
114
|
-
const event = JSON.parse(line);
|
|
115
|
-
if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
|
|
116
|
-
const text = event.payload.message.trim().replace(/\s+/g, ' ');
|
|
117
|
-
lastMessage = text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
118
|
-
}
|
|
147
|
+
let rolloutPath = null;
|
|
148
|
+
if (db) {
|
|
149
|
+
try {
|
|
150
|
+
const row = db.prepare('SELECT rollout_path FROM threads WHERE id = ?').get(agentSessionId);
|
|
151
|
+
if (row?.rollout_path && fs.existsSync(row.rollout_path)) {
|
|
152
|
+
rolloutPath = row.rollout_path;
|
|
119
153
|
}
|
|
120
|
-
catch { /* skip malformed line */ }
|
|
121
154
|
}
|
|
122
|
-
|
|
155
|
+
catch (error) {
|
|
156
|
+
logger.warn(`[CodexAdapter] readLastUserMessage DB query failed:`, error);
|
|
157
|
+
}
|
|
123
158
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
// 2. Fallback: 从 sessions 目录查找 rollout 文件
|
|
160
|
+
if (!rolloutPath) {
|
|
161
|
+
rolloutPath = this.findRolloutFile(agentSessionId);
|
|
127
162
|
}
|
|
163
|
+
if (!rolloutPath)
|
|
164
|
+
return null;
|
|
165
|
+
return this.readUserMessageFromRollout(rolloutPath, 'last');
|
|
128
166
|
}
|
|
129
167
|
scanCliSessions(projectPath) {
|
|
130
168
|
const db = this.getDb();
|
|
@@ -168,6 +206,33 @@ export class CodexSessionFileAdapter {
|
|
|
168
206
|
}
|
|
169
207
|
this.dbInitialized = false;
|
|
170
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* 从 rollout JSONL 读取第一条或最后一条 user_message
|
|
211
|
+
*/
|
|
212
|
+
readUserMessageFromRollout(rolloutPath, which) {
|
|
213
|
+
try {
|
|
214
|
+
const content = fs.readFileSync(rolloutPath, 'utf-8');
|
|
215
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
216
|
+
let result = null;
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
try {
|
|
219
|
+
const event = JSON.parse(line);
|
|
220
|
+
if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
|
|
221
|
+
const text = event.payload.message.trim().replace(/\s+/g, ' ');
|
|
222
|
+
const truncated = text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
223
|
+
if (which === 'first')
|
|
224
|
+
return truncated;
|
|
225
|
+
result = truncated;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch { /* skip malformed line */ }
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
171
236
|
/**
|
|
172
237
|
* 从 rollout JSONL 文件计算轮数(数 turn_context 行)
|
|
173
238
|
*/
|