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,259 @@
|
|
|
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
|
+
}
|
|
126
|
+
export class PermissionGateway {
|
|
127
|
+
pending = new Map();
|
|
128
|
+
timeout = 5 * 60 * 1000;
|
|
129
|
+
eventBus;
|
|
130
|
+
/** 始终允许的工具缓存:toolName → Set<pattern> */
|
|
131
|
+
alwaysAllow = new Map();
|
|
132
|
+
setEventBus(eventBus) {
|
|
133
|
+
this.eventBus = eventBus;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 检查工具是否已被标记为"始终允许"
|
|
137
|
+
*/
|
|
138
|
+
isAlwaysAllowed(toolName) {
|
|
139
|
+
return this.alwaysAllow.has(toolName);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 将工具标记为"始终允许"
|
|
143
|
+
*/
|
|
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
|
+
}
|
|
169
|
+
const requestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
170
|
+
const displaySummary = summary || summarizeToolInput(toolName, toolInput);
|
|
171
|
+
const reasonLine = reason ? `\n原因:${reason}` : '';
|
|
172
|
+
this.eventBus?.publish({ type: 'permission:requested', sessionId, requestId, toolName, input: displaySummary });
|
|
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
|
+
}
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
const timer = setTimeout(() => {
|
|
208
|
+
this.pending.delete(requestId);
|
|
209
|
+
// 清理 router 注册(仅删除本次请求,不影响其他交互)
|
|
210
|
+
if (interactionSent && context?.interactionRouter) {
|
|
211
|
+
context.interactionRouter.cancel(requestId);
|
|
212
|
+
}
|
|
213
|
+
this.eventBus?.publish({ type: 'permission:timeout', sessionId, requestId });
|
|
214
|
+
resolve('deny');
|
|
215
|
+
}, this.timeout);
|
|
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
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
resolvePermission(sessionId, requestId, decision) {
|
|
226
|
+
const pending = this.pending.get(requestId);
|
|
227
|
+
if (!pending || pending.sessionId !== sessionId)
|
|
228
|
+
return false;
|
|
229
|
+
clearTimeout(pending.timer);
|
|
230
|
+
// 如果是 always,缓存该工具
|
|
231
|
+
if (decision === 'always') {
|
|
232
|
+
this.addAlwaysAllow(pending.toolName);
|
|
233
|
+
}
|
|
234
|
+
pending.resolve(decision);
|
|
235
|
+
this.pending.delete(requestId);
|
|
236
|
+
this.eventBus?.publish({ type: 'permission:resolved', sessionId, requestId, approved: decision !== 'deny' });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
/** 中断时取消指定会话的所有 pending 权限请求 */
|
|
240
|
+
cancelAll(sessionId) {
|
|
241
|
+
for (const [requestId, pending] of this.pending.entries()) {
|
|
242
|
+
if (pending.sessionId === sessionId) {
|
|
243
|
+
clearTimeout(pending.timer);
|
|
244
|
+
pending.resolve('deny');
|
|
245
|
+
this.pending.delete(requestId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** 获取指定会话的所有 pending requestId */
|
|
250
|
+
getPendingRequests(sessionId) {
|
|
251
|
+
const ids = [];
|
|
252
|
+
for (const [requestId, pending] of this.pending.entries()) {
|
|
253
|
+
if (pending.sessionId === sessionId) {
|
|
254
|
+
ids.push(requestId);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return ids;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude SessionFileAdapter
|
|
3
|
+
*
|
|
4
|
+
* Reads Claude Agent SDK session files from ~/.claude/projects/{encodedPath}/{sessionId}.jsonl
|
|
5
|
+
* and wraps sdkListSessions for name synchronization.
|
|
6
|
+
*/
|
|
7
|
+
import { listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
+
import { encodePath } from '../../../utils/cross-platform.js';
|
|
9
|
+
import { logger } from '../../../utils/logger.js';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
export class ClaudeSessionFileAdapter {
|
|
14
|
+
agentId = 'claude';
|
|
15
|
+
getSessionFilePath(projectPath, agentSessionId) {
|
|
16
|
+
const homeDir = os.homedir();
|
|
17
|
+
const encodedPath = encodePath(projectPath);
|
|
18
|
+
return path.join(homeDir, '.claude', 'projects', encodedPath, `${agentSessionId}.jsonl`);
|
|
19
|
+
}
|
|
20
|
+
checkExists(projectPath, agentSessionId) {
|
|
21
|
+
const sessionFile = this.getSessionFilePath(projectPath, agentSessionId);
|
|
22
|
+
return fs.existsSync(sessionFile);
|
|
23
|
+
}
|
|
24
|
+
getFileInfo(projectPath, agentSessionId) {
|
|
25
|
+
const sessionFile = this.getSessionFilePath(projectPath, agentSessionId);
|
|
26
|
+
if (!fs.existsSync(sessionFile))
|
|
27
|
+
return { turns: 0 };
|
|
28
|
+
try {
|
|
29
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
30
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
31
|
+
let turns = 0;
|
|
32
|
+
let title;
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const event = JSON.parse(line);
|
|
35
|
+
if (event.type === 'user' && event.message?.role === 'user') {
|
|
36
|
+
const msgContent = event.message.content;
|
|
37
|
+
const isToolResult = Array.isArray(msgContent) && msgContent.every((c) => c.type === 'tool_result');
|
|
38
|
+
if (!isToolResult) {
|
|
39
|
+
turns++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (event.title && !title) {
|
|
43
|
+
title = event.title;
|
|
44
|
+
}
|
|
45
|
+
if (event.sessionTitle && !title) {
|
|
46
|
+
title = event.sessionTitle;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { turns, title };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
logger.warn(`[ClaudeAdapter] Failed to read session file info: ${sessionFile}`, error);
|
|
53
|
+
return { turns: 0 };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
readFirstMessage(projectPath, agentSessionId) {
|
|
57
|
+
const sessionFile = this.getSessionFilePath(projectPath, agentSessionId);
|
|
58
|
+
if (!fs.existsSync(sessionFile))
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
62
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const event = JSON.parse(line);
|
|
65
|
+
if (event.type === 'user' && event.message?.role === 'user') {
|
|
66
|
+
const text = this.extractUserMessageText(event.message.content);
|
|
67
|
+
if (text)
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
logger.warn(`[ClaudeAdapter] Failed to read session file: ${sessionFile}`, error);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
readLastUserMessage(projectPath, agentSessionId) {
|
|
78
|
+
const sessionFile = this.getSessionFilePath(projectPath, agentSessionId);
|
|
79
|
+
if (!fs.existsSync(sessionFile))
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
const content = fs.readFileSync(sessionFile, 'utf-8');
|
|
83
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
84
|
+
let lastMessage = null;
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const event = JSON.parse(line);
|
|
87
|
+
if (event.type === 'user' && event.message?.role === 'user') {
|
|
88
|
+
lastMessage = this.extractUserMessageText(event.message.content) ?? lastMessage;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lastMessage;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
logger.warn(`[ClaudeAdapter] Failed to read last message from session file: ${sessionFile}`, error);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
scanCliSessions(projectPath) {
|
|
99
|
+
const homeDir = os.homedir();
|
|
100
|
+
const encodedPath = encodePath(projectPath);
|
|
101
|
+
const sessionDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
102
|
+
if (!fs.existsSync(sessionDir))
|
|
103
|
+
return [];
|
|
104
|
+
const files = fs.readdirSync(sessionDir)
|
|
105
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
106
|
+
.filter(f => !f.startsWith('agent-'))
|
|
107
|
+
.map(f => {
|
|
108
|
+
const filePath = path.join(sessionDir, f);
|
|
109
|
+
const stat = fs.statSync(filePath);
|
|
110
|
+
return { uuid: f.replace('.jsonl', ''), mtime: stat.mtimeMs, size: stat.size };
|
|
111
|
+
})
|
|
112
|
+
.filter(f => f.size > 0)
|
|
113
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
114
|
+
.slice(0, 10);
|
|
115
|
+
return files.map(f => ({ uuid: f.uuid, mtime: f.mtime }));
|
|
116
|
+
}
|
|
117
|
+
async listSdkSessions(projectPath) {
|
|
118
|
+
try {
|
|
119
|
+
const sessions = await sdkListSessions({ dir: projectPath });
|
|
120
|
+
return sessions.map(s => ({
|
|
121
|
+
sessionId: s.sessionId,
|
|
122
|
+
title: s.customTitle || undefined,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
logger.debug('[ClaudeAdapter] SDK listSessions failed (non-critical):', error);
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
extractUserMessageText(messageContent) {
|
|
131
|
+
if (typeof messageContent === 'string') {
|
|
132
|
+
const text = messageContent.trim().replace(/\s+/g, ' ');
|
|
133
|
+
return text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
134
|
+
}
|
|
135
|
+
else if (Array.isArray(messageContent)) {
|
|
136
|
+
const textContent = messageContent.find((c) => c.type === 'text');
|
|
137
|
+
if (textContent?.text) {
|
|
138
|
+
const text = textContent.text.trim().replace(/\s+/g, ' ');
|
|
139
|
+
return text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex SessionFileAdapter
|
|
3
|
+
*
|
|
4
|
+
* Reads Codex thread data from ~/.codex/state_*.sqlite (read-only)
|
|
5
|
+
* and Codex rollout JSONL files for detailed session info.
|
|
6
|
+
*/
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { logger } from '../../../utils/logger.js';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
export class CodexSessionFileAdapter {
|
|
13
|
+
agentId = 'codex';
|
|
14
|
+
db = null;
|
|
15
|
+
dbInitialized = false;
|
|
16
|
+
/**
|
|
17
|
+
* 动态发现最新的 state_*.sqlite 文件
|
|
18
|
+
* Codex 使用 sqlx 迁移,DB 文件名含版本号(state_5, state_6, ...)
|
|
19
|
+
*/
|
|
20
|
+
resolveStateDbPath() {
|
|
21
|
+
const codexHome = path.join(os.homedir(), '.codex');
|
|
22
|
+
if (!fs.existsSync(codexHome))
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const files = fs.readdirSync(codexHome)
|
|
26
|
+
.filter(f => /^state_\d+\.sqlite$/.test(f))
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
const va = parseInt(a.match(/state_(\d+)/)?.[1] || '0');
|
|
29
|
+
const vb = parseInt(b.match(/state_(\d+)/)?.[1] || '0');
|
|
30
|
+
return vb - va;
|
|
31
|
+
});
|
|
32
|
+
return files.length > 0 ? path.join(codexHome, files[0]) : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
getDb() {
|
|
39
|
+
if (this.dbInitialized)
|
|
40
|
+
return this.db;
|
|
41
|
+
this.dbInitialized = true;
|
|
42
|
+
const dbPath = this.resolveStateDbPath();
|
|
43
|
+
if (!dbPath)
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
this.db = new DatabaseSync(dbPath, { readOnly: true });
|
|
47
|
+
logger.debug(`[CodexAdapter] Opened state DB: ${dbPath}`);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
logger.warn(`[CodexAdapter] Failed to open state DB: ${dbPath}`, error);
|
|
51
|
+
this.db = null;
|
|
52
|
+
}
|
|
53
|
+
return this.db;
|
|
54
|
+
}
|
|
55
|
+
checkExists(projectPath, agentSessionId) {
|
|
56
|
+
// 1. 优先查 state_*.sqlite(覆盖 CLI 创建的线程)
|
|
57
|
+
const db = this.getDb();
|
|
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;
|
|
79
|
+
try {
|
|
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);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
getFileInfo(projectPath, agentSessionId) {
|
|
100
|
+
// 1. 优先查 state DB
|
|
101
|
+
const db = this.getDb();
|
|
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
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 2. Fallback: 从 sessions 目录查找 rollout 文件
|
|
117
|
+
const rolloutPath = this.findRolloutFile(agentSessionId);
|
|
118
|
+
if (rolloutPath) {
|
|
119
|
+
return { turns: this.countTurnsFromRollout(rolloutPath) };
|
|
120
|
+
}
|
|
121
|
+
return { turns: 0 };
|
|
122
|
+
}
|
|
123
|
+
readFirstMessage(projectPath, agentSessionId) {
|
|
124
|
+
// 1. 优先查 state DB
|
|
125
|
+
const db = this.getDb();
|
|
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
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// 2. Fallback: 从 rollout JSONL 读取第一条 user_message
|
|
139
|
+
const rolloutPath = this.findRolloutFile(agentSessionId);
|
|
140
|
+
if (!rolloutPath)
|
|
141
|
+
return null;
|
|
142
|
+
return this.readUserMessageFromRollout(rolloutPath, 'first');
|
|
143
|
+
}
|
|
144
|
+
readLastUserMessage(projectPath, agentSessionId) {
|
|
145
|
+
// 1. 优先查 state DB 获取 rollout_path
|
|
146
|
+
const db = this.getDb();
|
|
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;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
logger.warn(`[CodexAdapter] readLastUserMessage DB query failed:`, error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// 2. Fallback: 从 sessions 目录查找 rollout 文件
|
|
160
|
+
if (!rolloutPath) {
|
|
161
|
+
rolloutPath = this.findRolloutFile(agentSessionId);
|
|
162
|
+
}
|
|
163
|
+
if (!rolloutPath)
|
|
164
|
+
return null;
|
|
165
|
+
return this.readUserMessageFromRollout(rolloutPath, 'last');
|
|
166
|
+
}
|
|
167
|
+
scanCliSessions(projectPath) {
|
|
168
|
+
const db = this.getDb();
|
|
169
|
+
if (!db)
|
|
170
|
+
return [];
|
|
171
|
+
try {
|
|
172
|
+
const rows = db.prepare('SELECT id, updated_at FROM threads WHERE cwd = ? AND archived = 0 ORDER BY updated_at DESC LIMIT 10').all(projectPath);
|
|
173
|
+
return rows.map(r => ({
|
|
174
|
+
uuid: r.id,
|
|
175
|
+
mtime: r.updated_at, // Codex uses Unix timestamp (seconds)
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
logger.warn(`[CodexAdapter] scanCliSessions failed:`, error);
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async listSdkSessions(projectPath) {
|
|
184
|
+
const db = this.getDb();
|
|
185
|
+
if (!db)
|
|
186
|
+
return [];
|
|
187
|
+
try {
|
|
188
|
+
const rows = db.prepare('SELECT id, title FROM threads WHERE cwd = ? AND archived = 0 ORDER BY updated_at DESC').all(projectPath);
|
|
189
|
+
return rows.map(r => ({
|
|
190
|
+
sessionId: r.id,
|
|
191
|
+
title: r.title || undefined,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
logger.warn(`[CodexAdapter] listSdkSessions failed:`, error);
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
close() {
|
|
200
|
+
if (this.db) {
|
|
201
|
+
try {
|
|
202
|
+
this.db.close();
|
|
203
|
+
}
|
|
204
|
+
catch { /* ignore close errors */ }
|
|
205
|
+
this.db = null;
|
|
206
|
+
}
|
|
207
|
+
this.dbInitialized = false;
|
|
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
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 从 rollout JSONL 文件计算轮数(数 turn_context 行)
|
|
238
|
+
*/
|
|
239
|
+
countTurnsFromRollout(rolloutPath) {
|
|
240
|
+
if (!rolloutPath || !fs.existsSync(rolloutPath))
|
|
241
|
+
return 0;
|
|
242
|
+
try {
|
|
243
|
+
const content = fs.readFileSync(rolloutPath, 'utf-8');
|
|
244
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
245
|
+
let turns = 0;
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
try {
|
|
248
|
+
const event = JSON.parse(line);
|
|
249
|
+
if (event.type === 'turn_context') {
|
|
250
|
+
turns++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch { /* skip malformed line */ }
|
|
254
|
+
}
|
|
255
|
+
return turns;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|