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.
Files changed (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +283 -95
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +232 -57
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +803 -247
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +217 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
  25. package/dist/index.js +140 -57
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  import path from 'path';
2
- import { logger } from '../utils/logger.js';
2
+ import { logger } from '../../utils/logger.js';
3
3
  export class MessageQueue {
4
4
  queues = new Map();
5
5
  processing = new Set();
@@ -1,4 +1,4 @@
1
- import { logger } from './logger.js';
1
+ import { logger } from '../../utils/logger.js';
2
2
  /**
3
3
  * 入站消息去抖器
4
4
  *
@@ -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 '../paths.js';
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
- activities = [];
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, actCount: this.activities.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.activities.push(desc);
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), actCount: this.activities.length });
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.activities.length > 0;
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 = 0;
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
- if (this.activities.length > 0) {
143
- output += this.activities.join('\n') + '\n\n';
144
- this.activities = [];
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
- if (this.buffer) {
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
- await this.send(output, isFinal);
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++;
@@ -1,38 +1,239 @@
1
- import { summarizeToolInput } from '../utils/permission-utils.js';
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
- * 请求人工审批。调用方负责模式判断(仅 approve 模式调用此方法)。
11
- * 黑名单检查由调用方(preToolUseHook)在调用此方法前完成。
136
+ * 检查工具是否已被标记为"始终允许"
137
+ */
138
+ isAlwaysAllowed(toolName) {
139
+ return this.alwaysAllow.has(toolName);
140
+ }
141
+ /**
142
+ * 将工具标记为"始终允许"
12
143
  */
13
- async requestPermission(sessionId, toolName, toolInput, sendPrompt, summary, reason) {
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
- await sendPrompt(`🔐 权限请求\n工具:${toolName}\n操作:${displaySummary}${reasonLine}\n\n回复 /perm allow 批准 或 /perm deny 拒绝`);
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(false);
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, approved) {
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
- pending.resolve(approved);
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(false);
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 '../../utils/cross-platform.js';
9
- import { logger } from '../../utils/logger.js';
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 '../../utils/logger.js';
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 (!db)
58
- return false;
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 row = db.prepare('SELECT 1 FROM threads WHERE id = ? AND archived = 0').get(agentSessionId);
61
- return !!row;
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 (error) {
64
- logger.warn(`[CodexAdapter] checkExists failed:`, error);
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 (!db)
71
- return { turns: 0 };
72
- try {
73
- const row = db.prepare('SELECT title, rollout_path FROM threads WHERE id = ?').get(agentSessionId);
74
- if (!row)
75
- return { turns: 0 };
76
- const title = row.title || undefined;
77
- const turns = this.countTurnsFromRollout(row.rollout_path);
78
- return { turns, title };
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
- catch (error) {
81
- logger.warn(`[CodexAdapter] getFileInfo failed:`, error);
82
- return { turns: 0 };
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 (!db)
88
- return null;
89
- try {
90
- const row = db.prepare('SELECT first_user_message FROM threads WHERE id = ?').get(agentSessionId);
91
- if (!row?.first_user_message)
92
- return null;
93
- const text = row.first_user_message.trim().replace(/\s+/g, ' ');
94
- return text.substring(0, 50) + (text.length > 50 ? '...' : '');
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
- catch (error) {
97
- logger.warn(`[CodexAdapter] readFirstMessage failed:`, error);
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
- if (!db)
104
- return null;
105
- try {
106
- const row = db.prepare('SELECT rollout_path FROM threads WHERE id = ?').get(agentSessionId);
107
- if (!row?.rollout_path || !fs.existsSync(row.rollout_path))
108
- return null;
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
- return lastMessage;
155
+ catch (error) {
156
+ logger.warn(`[CodexAdapter] readLastUserMessage DB query failed:`, error);
157
+ }
123
158
  }
124
- catch (error) {
125
- logger.warn(`[CodexAdapter] readLastUserMessage failed:`, error);
126
- return null;
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
  */