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.
Files changed (54) hide show
  1. package/README.md +59 -30
  2. package/data/evolclaw.sample.json +15 -4
  3. package/dist/agents/claude-runner.js +685 -0
  4. package/dist/agents/codex-runner.js +315 -0
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +580 -10
  7. package/dist/channels/feishu.js +888 -135
  8. package/dist/channels/wechat.js +127 -21
  9. package/dist/cli.js +519 -136
  10. package/dist/config.js +277 -25
  11. package/dist/core/agent-loader.js +39 -0
  12. package/dist/core/channel-loader.js +67 -0
  13. package/dist/core/command-handler.js +1537 -392
  14. package/dist/core/event-bus.js +32 -0
  15. package/dist/core/interaction-router.js +68 -0
  16. package/dist/core/message/message-bridge.js +216 -0
  17. package/dist/core/message/message-processor.js +1028 -0
  18. package/dist/core/message/message-queue.js +240 -0
  19. package/dist/core/message/stream-debouncer.js +122 -0
  20. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  21. package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
  22. package/dist/core/permission.js +259 -0
  23. package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
  24. package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
  25. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  26. package/dist/core/session/session-file-adapter.js +7 -0
  27. package/dist/core/session/session-file-health.js +45 -0
  28. package/dist/core/session/session-manager.js +1072 -0
  29. package/dist/index.js +402 -252
  30. package/dist/ipc.js +106 -0
  31. package/dist/paths.js +1 -0
  32. package/dist/types.js +3 -0
  33. package/dist/utils/{platform.js → cross-platform.js} +38 -1
  34. package/dist/utils/error-utils.js +130 -5
  35. package/dist/utils/init-channel.js +649 -0
  36. package/dist/utils/init.js +190 -53
  37. package/dist/utils/logger.js +8 -3
  38. package/dist/utils/media-cache.js +207 -0
  39. package/dist/utils/migrate-project.js +122 -0
  40. package/dist/utils/rich-content-renderer.js +228 -0
  41. package/dist/utils/stats-collector.js +102 -0
  42. package/package.json +4 -2
  43. package/dist/core/agent-runner.js +0 -348
  44. package/dist/core/message-processor.js +0 -604
  45. package/dist/core/message-queue.js +0 -116
  46. package/dist/core/message-stream.js +0 -59
  47. package/dist/core/session-manager.js +0 -664
  48. package/dist/index.js.bak +0 -340
  49. package/dist/utils/init-feishu.js +0 -261
  50. package/dist/utils/init-wechat.js +0 -170
  51. package/dist/utils/markdown-to-feishu.js +0 -94
  52. package/dist/utils/permission.js +0 -43
  53. package/dist/utils/session-file-health.js +0 -68
  54. /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
package/dist/index.js CHANGED
@@ -1,13 +1,26 @@
1
- import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig } from './config.js';
2
- import { SessionManager } from './core/session-manager.js';
3
- import { AgentRunner } from './core/agent-runner.js';
4
- import { FeishuChannel } from './channels/feishu.js';
5
- import { AUNChannel } from './channels/aun.js';
6
- import { WechatChannel } from './channels/wechat.js';
7
- import { MessageProcessor } from './core/message-processor.js';
8
- import { MessageQueue } from './core/message-queue.js';
9
- import { MessageCache } from './core/message-cache.js';
1
+ import { ClaudeSessionFileAdapter } from './core/session/adapters/claude-session-file-adapter.js';
2
+ import { CodexSessionFileAdapter } from './core/session/adapters/codex-session-file-adapter.js';
3
+ import { GeminiSessionFileAdapter } from './core/session/adapters/gemini-session-file-adapter.js';
4
+ import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, validateConfigIntegrity, validateChannelInstanceNames, getOwner } from './config.js';
5
+ import { SessionManager } from './core/session/session-manager.js';
6
+ import { ClaudeAgentPlugin } from './agents/claude-runner.js';
7
+ import { CodexAgentPlugin } from './agents/codex-runner.js';
8
+ import { GeminiAgentPlugin } from './agents/gemini-runner.js';
9
+ import { FeishuChannelPlugin } from './channels/feishu.js';
10
+ import { WechatChannelPlugin } from './channels/wechat.js';
11
+ import { AUNChannelPlugin } from './channels/aun.js';
12
+ import { MessageProcessor } from './core/message/message-processor.js';
13
+ import { MessageQueue } from './core/message/message-queue.js';
14
+ import { MessageBridge } from './core/message/message-bridge.js';
15
+ import { MessageCache } from './core/message/message-cache.js';
10
16
  import { CommandHandler } from './core/command-handler.js';
17
+ import { EventBus } from './core/event-bus.js';
18
+ import { StatsCollector } from './utils/stats-collector.js';
19
+ import { PermissionGateway } from './core/permission.js';
20
+ import { InteractionRouter } from './core/interaction-router.js';
21
+ import { ChannelLoader } from './core/channel-loader.js';
22
+ import { AgentLoader } from './core/agent-loader.js';
23
+ import { IpcServer } from './ipc.js';
11
24
  import { logger } from './utils/logger.js';
12
25
  import path from 'path';
13
26
  import fs from 'fs';
@@ -34,22 +47,65 @@ async function main() {
34
47
  ensureDataDirs();
35
48
  // 加载配置
36
49
  const config = loadConfig();
50
+ // 配置完整性校验
51
+ const integrity = validateConfigIntegrity(config);
52
+ if (!integrity.valid) {
53
+ const msg = `❌ Config integrity check failed:\n ${integrity.reasons.join('\n ')}`;
54
+ logger.error(msg);
55
+ console.error(msg); // ensure it lands in stdout.log for self-heal diagnostics
56
+ process.exit(1);
57
+ }
37
58
  const anthropic = resolveAnthropicConfig(config);
38
59
  logger.info('✓ Config loaded (API keys hidden)');
60
+ // Channel instance name uniqueness check
61
+ validateChannelInstanceNames(config);
39
62
  if (anthropic.baseUrl) {
40
63
  logger.info(`✓ Using custom API base URL: ${anthropic.baseUrl}`);
41
64
  }
42
- // 初始化数据库
43
- const sessionManager = new SessionManager();
65
+ // 创建事件总线
66
+ const eventBus = new EventBus();
67
+ logger.info('✓ Event bus initialized');
68
+ // 统计收集器(近 1 小时滚动统计)
69
+ const statsCollector = new StatsCollector(eventBus);
70
+ // 初始化数据库(带 ownerResolver)
71
+ const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => {
72
+ return isOwner(config, channel, userId);
73
+ });
44
74
  logger.info('✓ Database initialized');
45
- // 初始化 Agent Runner(带持久化回调)
46
- const agentRunner = new AgentRunner(anthropic.apiKey, anthropic.model, async (sessionId, agentSessionId) => {
47
- await sessionManager.updateAgentSessionIdBySessionId(sessionId, agentSessionId);
48
- }, anthropic.baseUrl, config);
49
- if (anthropic.effort) {
50
- agentRunner.setEffort(anthropic.effort);
75
+ // 注册会话文件适配器(Claude / Codex 各自的会话文件操作)
76
+ sessionManager.registerFileAdapter(new ClaudeSessionFileAdapter());
77
+ sessionManager.registerFileAdapter(new CodexSessionFileAdapter());
78
+ sessionManager.registerFileAdapter(new GeminiSessionFileAdapter());
79
+ // Agent 插件系统
80
+ const agentLoader = new AgentLoader();
81
+ agentLoader.register(new ClaudeAgentPlugin());
82
+ agentLoader.register(new CodexAgentPlugin());
83
+ agentLoader.register(new GeminiAgentPlugin());
84
+ const agentInstances = agentLoader.createAll(config, {
85
+ onSessionIdUpdate: async (sessionId, agentSessionId) => {
86
+ await sessionManager.updateAgentSessionIdBySessionId(sessionId, agentSessionId);
87
+ },
88
+ });
89
+ // 构建 agent map,支持按 agentId 路由(当前默认使用第一个 agent)
90
+ const agentMap = new Map();
91
+ for (const inst of agentInstances) {
92
+ agentMap.set(inst.agent.name, inst.agent);
93
+ }
94
+ const defaultAgent = config.agents?.defaultAgent || 'claude';
95
+ const agentRunner = agentMap.get(defaultAgent) || agentInstances[0]?.agent;
96
+ if (!agentRunner) {
97
+ throw new Error('No agent backend available. Check agents config.');
98
+ }
99
+ logger.info(`✓ Agent runner ready (default: ${agentRunner.name}, available: ${[...agentMap.keys()].join(', ')})`);
100
+ // 权限审批网关
101
+ const permissionGateway = new PermissionGateway();
102
+ permissionGateway.setEventBus(eventBus);
103
+ // 交互路由器
104
+ const interactionRouter = new InteractionRouter();
105
+ // 为所有支持权限的 agent 设置 gateway
106
+ for (const inst of agentInstances) {
107
+ inst.agent.setPermissionGateway?.(permissionGateway);
51
108
  }
52
- logger.info('✓ Agent runner ready');
53
109
  // 创建消息缓存
54
110
  const messageCache = new MessageCache();
55
111
  logger.info('✓ Message cache initialized');
@@ -57,284 +113,378 @@ async function main() {
57
113
  setInterval(() => {
58
114
  messageCache.cleanupExpired();
59
115
  }, 60 * 60 * 1000);
60
- // 飞书渠道(条件初始化)
61
- let feishu = null;
62
- if (config.channels?.feishu?.enabled !== false && config.channels?.feishu?.appId) {
63
- feishu = new FeishuChannel({
64
- appId: config.channels.feishu.appId,
65
- appSecret: config.channels.feishu.appSecret,
66
- db: sessionManager.getDatabase()
67
- });
68
- // 设置项目路径提供器
69
- feishu.onProjectPathRequest(async (chatId) => {
70
- const session = await sessionManager.getOrCreateSession('feishu', chatId, config.projects?.defaultPath || process.cwd());
71
- return path.isAbsolute(session.projectPath)
72
- ? session.projectPath
73
- : path.resolve(process.cwd(), session.projectPath);
74
- });
75
- }
76
- // AUN 渠道(条件初始化)
77
- let aun = null;
78
- if (config.channels?.aun?.enabled !== false && config.channels?.aun?.domain) {
79
- aun = new AUNChannel({ domain: config.channels.aun.domain, agentName: config.channels.aun.agentName });
80
- }
116
+ // 渠道插件系统
117
+ const channelLoader = new ChannelLoader();
118
+ channelLoader.register(new FeishuChannelPlugin());
119
+ channelLoader.register(new WechatChannelPlugin());
120
+ channelLoader.register(new AUNChannelPlugin());
121
+ const channelInstances = await channelLoader.createAll(config);
122
+ logger.info(`✓ Created ${channelInstances.length} channel instance(s)`);
123
+ // 启动迁移:将 sessions.channel 从 channelType 回填为实例名
124
+ sessionManager.migrateChannelToInstanceName();
81
125
  // 创建命令处理器
82
- const cmdHandler = new CommandHandler(sessionManager, agentRunner, config, messageCache);
126
+ const cmdHandler = new CommandHandler(sessionManager, agentMap, config, messageCache, eventBus, defaultAgent);
127
+ cmdHandler.setPermissionGateway(permissionGateway);
128
+ cmdHandler.setInteractionRouter(interactionRouter);
129
+ cmdHandler.setStatsCollector(statsCollector);
83
130
  // 创建消息处理器
84
- const processor = new MessageProcessor(agentRunner, sessionManager, config, messageCache, (content, channel, channelId, userId, threadId) => {
131
+ const processor = new MessageProcessor(agentMap, sessionManager, config, messageCache, eventBus, (content, channel, channelId, userId, threadId) => {
85
132
  const sendFn = async (id, text, opts) => {
86
133
  const adapter = cmdHandler.getAdapter(channel);
87
134
  if (!adapter)
88
135
  return;
89
- // 文件标记处理(通过 adapter.sendFile 能力判断,不按渠道名分支)
90
- if (adapter.sendFile) {
91
- const fileMarkerPattern = /\[SEND_FILE:([^\]]+)\]/g;
92
- const fileMatches = [...text.matchAll(fileMarkerPattern)];
93
- for (const match of fileMatches) {
94
- const filePath = match[1].trim();
95
- // 跳过占位符/代码片段中的伪路径
96
- if (!filePath || /[\\[\]{}*+?|^$]/.test(filePath))
97
- continue;
98
- const session = await sessionManager.getActiveSession(channel, channelId);
99
- const projectPath = session?.projectPath || process.cwd();
100
- const absoluteFilePath = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath);
101
- try {
102
- await adapter.sendFile(id, absoluteFilePath);
103
- }
104
- catch (error) {
105
- logger.error(`[${channel}] Failed to send file: ${absoluteFilePath}`, error);
106
- }
107
- }
108
- text = text.replace(fileMarkerPattern, '').trim();
109
- }
110
136
  if (text) {
111
137
  await adapter.sendText(id, text, opts);
112
138
  }
113
139
  };
114
140
  return cmdHandler.handle(content, channel, channelId, sendFn, userId, threadId);
115
- });
141
+ }, defaultAgent);
116
142
  // 回填 processor 和 messageQueue 的引用
117
143
  cmdHandler.setProcessor(processor);
118
- // 设置 compact 开始回调
119
- agentRunner.setCompactStartCallback((sessionId) => {
120
- processor.handleCompactStart();
121
- });
144
+ // 设置交互路由器
145
+ processor.setInteractionRouter(interactionRouter);
146
+ // 设置 compact 开始回调(对所有支持的 agent)
147
+ for (const inst of agentInstances) {
148
+ inst.agent.setCompactStartCallback?.((sessionId) => {
149
+ processor.handleCompactStart(sessionId);
150
+ });
151
+ }
122
152
  // 创建消息队列
123
153
  const messageQueue = new MessageQueue(async (message) => {
124
154
  await processor.processMessage(message);
125
155
  });
126
- // 设置中断回调
127
- messageQueue.setInterruptCallback(async (sessionKey) => {
128
- await agentRunner.interrupt(sessionKey);
156
+ // 设置中断回调(精确中断正在处理的 agent)
157
+ messageQueue.setInterruptCallback(async (sessionKey, agentId) => {
158
+ const agent = agentMap.get(agentId || defaultAgent);
159
+ if (agent?.hasActiveStream(sessionKey)) {
160
+ await agent.interrupt(sessionKey);
161
+ }
129
162
  });
163
+ messageQueue.setEventBus(eventBus);
130
164
  // 回填 messageQueue 引用
131
165
  cmdHandler.setMessageQueue(messageQueue);
132
- // 注册 Feishu 适配器(如果已初始化)
133
- if (feishu) {
134
- const feishuAdapter = {
135
- name: 'feishu',
136
- sendText: (channelId, text, options) => feishu.sendMessage(channelId, text, options),
137
- sendFile: (channelId, filePath) => feishu.sendFile(channelId, filePath),
138
- isGroupChat: (channelId) => feishu.getChatMode(channelId).then(m => m === 'group'),
139
- };
140
- const feishuOptions = {
141
- systemPromptAppend: '[重要系统功能] 你可以通过飞书发送文件给用户。方法:在响应中使用 [SEND_FILE:文件路径] 标记。示例:文件已准备好![SEND_FILE:./report.txt] 路径支持相对路径(相对项目目录)或绝对路径。系统会自动上传并发送。',
142
- fileMarkerPattern: /\[SEND_FILE:([^\]]+)\]/g,
143
- supportsImages: true,
144
- };
145
- processor.registerChannel(feishuAdapter, feishuOptions);
146
- cmdHandler.registerAdapter(feishuAdapter);
147
- }
148
- // 注册 AUN 适配器(如果已初始化)
149
- if (aun) {
150
- const aunAdapter = {
151
- name: 'aun',
152
- sendText: (channelId, text) => aun.sendMessage(channelId, text),
153
- };
154
- processor.registerChannel(aunAdapter);
155
- cmdHandler.registerAdapter(aunAdapter);
166
+ // 默认策略
167
+ const defaultPolicy = {
168
+ canSwitchProject: (chatType, role) => chatType === 'private' || role === 'owner',
169
+ canListProjects: (chatType, role) => chatType === 'private' || role === 'owner',
170
+ canCreateSession: () => true,
171
+ canDeleteSession: (chatType, role) => chatType === 'private' || role === 'owner',
172
+ canImportCliSession: (chatType, role) => chatType === 'private' || role === 'owner',
173
+ messagePrefix: () => '',
174
+ showMiddleResult: () => true,
175
+ showIdleMonitor: () => true,
176
+ accumulateErrors: () => true,
177
+ };
178
+ // 注册渠道插件的 adapter 和 policy
179
+ for (const inst of channelInstances) {
180
+ // 设置项目路径提供器(如果需要)
181
+ if (inst.onProjectPathRequest && inst.channel.onProjectPathRequest) {
182
+ inst.channel.onProjectPathRequest(async (channelId) => {
183
+ const session = await sessionManager.getOrCreateSession(inst.adapter.channelName, channelId, config.projects?.defaultPath || process.cwd(), undefined, undefined, undefined, undefined);
184
+ return path.isAbsolute(session.projectPath)
185
+ ? session.projectPath
186
+ : path.resolve(process.cwd(), session.projectPath);
187
+ });
188
+ }
189
+ // 注册 adapter、policy 和 options(注入 channelType)
190
+ const opts = inst.channelType
191
+ ? { ...inst.options, channelType: inst.channelType }
192
+ : inst.options;
193
+ processor.registerChannel(inst.adapter, inst.policy || defaultPolicy, opts);
194
+ cmdHandler.registerAdapter(inst.adapter);
195
+ cmdHandler.registerChannel(inst.adapter.channelName, inst.channel, inst.channelType);
196
+ if (inst.policy) {
197
+ cmdHandler.registerPolicy(inst.adapter.channelName, inst.policy);
198
+ }
199
+ // 注册交互回调:渠道收到用户操作后路由到 InteractionRouter
200
+ if (inst.adapter.onInteraction) {
201
+ inst.adapter.onInteraction((response) => {
202
+ interactionRouter.handle(response);
203
+ });
204
+ }
156
205
  }
157
- // ── WeChat 渠道(条件初始化)──
158
- let wechat = null;
159
- if (config.channels?.wechat?.enabled && config.channels?.wechat?.token) {
160
- wechat = new WechatChannel({
161
- baseUrl: config.channels.wechat.baseUrl || 'https://ilinkai.weixin.qq.com',
162
- token: config.channels.wechat.token,
163
- });
164
- // 设置项目路径提供器(用于接收文件保存)
165
- wechat.onProjectPathRequest(async (channelId) => {
166
- const session = await sessionManager.getOrCreateSession('wechat', channelId, config.projects?.defaultPath || process.cwd());
167
- return path.isAbsolute(session.projectPath)
168
- ? session.projectPath
169
- : path.resolve(process.cwd(), session.projectPath);
170
- });
171
- const wechatAdapter = {
172
- name: 'wechat',
173
- sendText: (channelId, text) => wechat.sendMessage(channelId, text),
174
- sendFile: (channelId, filePath) => wechat.sendFile(channelId, filePath),
175
- };
176
- const wechatOptions = {
177
- systemPromptAppend: '[系统功能] 你可以发送文件给用户。方法:在响应中使用 [SEND_FILE:文件路径] 标记。示例:文件已准备好![SEND_FILE:./report.txt]',
178
- fileMarkerPattern: /\[SEND_FILE:([^\]]+)\]/g,
179
- };
180
- processor.registerChannel(wechatAdapter, wechatOptions);
181
- cmdHandler.registerAdapter(wechatAdapter);
182
- // Session 过期通知(通过 Feishu 等其他渠道告知用户)
183
- wechat.onSessionExpiredNotify(async (message) => {
184
- // 尝试通过已注册的 Feishu owner 通知
185
- const feishuOwner = config.channels?.feishu?.owner;
186
- if (feishuOwner) {
187
- try {
188
- // Feishu owner ID 是 open_id,但 sendMessage 需要 chat_id
189
- // 这里只记日志,因为 owner 的 chat_id 需要从 session 中获取
190
- logger.warn(`[WeChat] ${message}`);
191
- }
192
- catch { }
193
- }
194
- else {
195
- logger.warn(`[WeChat] ${message}`);
196
- }
197
- });
198
- wechat.onMessage(async (channelId, content, userId, images) => {
199
- content = content.trim();
200
- // 首次交互自动绑定主人
201
- if (userId && !config.channels?.wechat?.owner) {
202
- const { setOwner } = await import('./config.js');
203
- setOwner(config, 'wechat', userId);
204
- logger.info(`[Owner] Auto-bound WeChat owner: ${userId}`);
205
- }
206
- // 命令快速路径
207
- if (cmdHandler.isCommand(content)) {
208
- const cmdResult = await cmdHandler.handle(content, 'wechat', channelId, undefined, userId);
209
- if (cmdResult !== null) {
210
- if (cmdResult) {
211
- try {
212
- await wechat.sendMessage(channelId, cmdResult);
213
- }
214
- catch (error) {
215
- logger.error('[WeChat] Failed to send command response:', error);
216
- }
217
- }
218
- return;
219
- }
206
+ // ── MessageBridge:Channel ↔ Core 消息桥梁 ──
207
+ const msgBridge = new MessageBridge(config, sessionManager, processor, messageQueue, cmdHandler, eventBus);
208
+ // ── 渠道消息注册 ──
209
+ // 连接插件系统的渠道
210
+ for (const inst of channelInstances) {
211
+ const channelType = inst.channelType || inst.adapter.channelName;
212
+ if (channelType === 'feishu') {
213
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async ({ channelId: chatId, content, images, peerId, peerName, messageId, mentions, threadId, rootId, chatType }) => {
214
+ await handler({
215
+ channel: channelType, channelId: chatId, content, images, chatType,
216
+ peerId: peerId || '', peerName, messageId, mentions, threadId,
217
+ replyContext: rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined,
218
+ });
219
+ }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, {
220
+ replyToMessageId: replyContext?.replyToMessageId,
221
+ replyInThread: true,
222
+ }), inst.adapter, channelType);
223
+ inst.channel.onRecall?.((messageId) => {
224
+ msgBridge.cancel(messageId);
225
+ });
226
+ }
227
+ if (channelType === 'wechat') {
228
+ // 注入 EventBus(用于 channel:health 事件)
229
+ if (inst.channel.setEventBus) {
230
+ inst.channel.setEventBus(eventBus);
220
231
  }
221
- // 获取当前项目路径
222
- const session = await sessionManager.getOrCreateSession('wechat', channelId, config.projects?.defaultPath || process.cwd());
223
- // 普通消息进入队列
224
- await messageQueue.enqueue(`wechat-${channelId}`, { channel: 'wechat', channelId, content, images, timestamp: Date.now(), userId }, session.projectPath);
225
- });
232
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (channelId, content, peerId, images, chatType) => {
233
+ handler({
234
+ channel: channelType,
235
+ channelId,
236
+ content,
237
+ images,
238
+ chatType: chatType || 'private',
239
+ peerId: peerId || '',
240
+ });
241
+ }), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter, channelType);
242
+ }
243
+ if (channelType === 'aun') {
244
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (opts) => {
245
+ handler({
246
+ channel: channelType,
247
+ channelId: opts.channelId,
248
+ content: opts.content,
249
+ chatType: opts.chatType || 'private',
250
+ peerId: opts.peerId || '',
251
+ peerName: opts.peerName,
252
+ messageId: opts.messageId,
253
+ mentions: opts.mentions,
254
+ threadId: opts.threadId,
255
+ replyContext: opts.replyContext,
256
+ });
257
+ }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, replyContext), inst.adapter, channelType);
258
+ }
226
259
  }
227
- // Feishu 消息处理
228
- if (feishu) {
229
- feishu.onMessage(async ({ channelId: chatId, content: rawContent, images, userId, userName, messageId, mentions, threadId, rootId }) => {
230
- let content = rawContent.trim();
231
- // 首次交互自动绑定主人
232
- if (userId && !config.channels?.feishu?.owner) {
233
- const { setOwner } = await import('./config.js');
234
- setOwner(config, 'feishu', userId);
235
- logger.info(`[Owner] Auto-bound owner: ${userName} (${userId})`);
236
- }
237
- // 命令立即处理,不进入队列
238
- if (cmdHandler.isCommand(content)) {
239
- const cmdResult = await cmdHandler.handle(content, 'feishu', chatId, undefined, userId, threadId);
240
- if (cmdResult !== null) {
241
- if (cmdResult) {
242
- try {
243
- await feishu.sendMessage(chatId, cmdResult, { forceText: true, replyToMessageId: rootId, replyInThread: true });
244
- }
245
- catch (error) {
246
- logger.error('[Feishu] Failed to send command response:', error);
247
- }
248
- }
249
- return;
250
- }
251
- }
252
- // 获取当前项目路径(话题会话自动创建,携带 metadata)
253
- const metadata = rootId ? { feishu: { rootId } } : undefined;
254
- const session = await sessionManager.getOrCreateSession('feishu', chatId, config.projects?.defaultPath || process.cwd(), threadId, metadata);
255
- // 群聊消息添加用户名前缀
256
- const chatMode = await feishu.getChatMode(chatId);
257
- if (chatMode === 'group' && userName) {
258
- content = `[${userName}] ${content}`;
259
- }
260
- // 普通消息进入队列(使用 session.id 作为 key,话题间可并行)
261
- await messageQueue.enqueue(session.id, { channel: 'feishu', channelId: chatId, content, images, timestamp: Date.now(), userId, userName, messageId, isGroup: chatMode === 'group', mentions, threadId }, session.projectPath);
260
+ // ── 连接所有渠道 ──
261
+ const connected = await channelLoader.connectAll(channelInstances);
262
+ // 预填充 Feishu 已知 thread_id(重启后避免误判话题创建)
263
+ for (const inst of channelInstances) {
264
+ const channelType = inst.channelType || inst.adapter.channelName;
265
+ if (channelType === 'feishu' && 'preloadThreads' in inst.channel) {
266
+ const threadIds = sessionManager.getKnownThreadIds(inst.adapter.channelName);
267
+ inst.channel.preloadThreads(threadIds);
268
+ }
269
+ }
270
+ for (const name of connected) {
271
+ // 查找对应实例以获取 channelType
272
+ const inst = channelInstances.find(i => i.adapter.channelName === name);
273
+ const type = inst?.channelType || name;
274
+ eventBus.publish({
275
+ type: 'channel:connected',
276
+ channel: type.toLowerCase(),
277
+ channelName: name,
278
+ timestamp: Date.now()
262
279
  });
263
280
  }
264
- // AUN 消息处理
265
- if (aun) {
266
- aun.onMessage(async (sessionId, content) => {
267
- content = content.trim();
268
- // 首次交互自动绑定主人
269
- if (!config.channels?.aun?.owner) {
270
- const { setOwner } = await import('./config.js');
271
- setOwner(config, 'aun', sessionId);
272
- logger.info(`[Owner] Auto-bound AUN owner: ${sessionId}`);
281
+ // AUN 重连失败通知:通过 channel:health 事件
282
+ for (const inst of channelInstances) {
283
+ const channelType = inst.channelType || inst.adapter.channelName;
284
+ if (channelType === 'aun' && inst.channel.setOnChannelDown) {
285
+ inst.channel.setOnChannelDown(() => {
286
+ eventBus.publish({
287
+ type: 'channel:health',
288
+ channel: channelType,
289
+ channelName: inst.adapter.channelName,
290
+ status: 'auth_error',
291
+ message: `⚠️ AUN 渠道 ${inst.adapter.channelName} 断连,自动重试已用尽。\n使用 /check rty aun 手动重连`,
292
+ timestamp: Date.now(),
293
+ });
294
+ });
295
+ }
296
+ }
297
+ // 统一 channel:health 跨通道通知(仅 auth_error)
298
+ // 按 (channelType, ownerId) 去重,避免同类型多实例重复通知
299
+ eventBus.subscribe('channel:health', (event) => {
300
+ if (event.type !== 'channel:health' || event.status !== 'auth_error')
301
+ return;
302
+ const sourceChannelType = event.channel;
303
+ const sourceChannelName = event.channelName || sourceChannelType;
304
+ const msg = event.message;
305
+ logger.error(`[ChannelHealth] ${sourceChannelName} auth_error: ${msg}`);
306
+ const notified = new Set(); // channelType:ownerId 去重
307
+ for (const other of channelInstances) {
308
+ const otherType = other.channelType || other.adapter.channelName;
309
+ if (otherType === sourceChannelType)
310
+ continue; // 跳过同类型通道
311
+ const ownerId = getOwner(config, other.adapter.channelName);
312
+ if (!ownerId)
313
+ continue;
314
+ const key = `${otherType}:${ownerId}`;
315
+ if (notified.has(key))
316
+ continue; // 同类型已通知过此 owner
317
+ notified.add(key);
318
+ other.adapter.sendText(ownerId, msg).catch(err => {
319
+ logger.error(`[ChannelHealth] Failed to notify ${other.adapter.channelName} owner:`, err);
320
+ });
321
+ }
322
+ });
323
+ // 按 channelType 归组显示连接摘要
324
+ const connectedGroups = new Map();
325
+ for (const inst of channelInstances) {
326
+ const name = inst.adapter.channelName;
327
+ if (!connected.includes(name))
328
+ continue;
329
+ const type = inst.channelType || name;
330
+ if (!connectedGroups.has(type))
331
+ connectedGroups.set(type, []);
332
+ connectedGroups.get(type).push(name);
333
+ }
334
+ const channelSummary = Array.from(connectedGroups.entries())
335
+ .map(([type, names]) => names.length === 1 ? names[0] : `${type}[${names.join(', ')}]`)
336
+ .join(', ');
337
+ const totalCount = connected.length;
338
+ logger.info(`\n🚀 EvolClaw is running with ${totalCount} channel(s): ${channelSummary}\n`);
339
+ eventBus.publish({
340
+ type: 'system:started',
341
+ channels: connected.map(c => c.toLowerCase()),
342
+ timestamp: Date.now()
343
+ });
344
+ // 恢复重启前未完成的会话
345
+ const pendingSessions = sessionManager.getPendingProcessingSessions();
346
+ if (pendingSessions.length > 0) {
347
+ logger.info(`[Resume] Found ${pendingSessions.length} pending session(s) from before restart`);
348
+ for (const session of pendingSessions) {
349
+ if (!session.agentSessionId) {
350
+ sessionManager.clearProcessing(session.id);
351
+ continue;
273
352
  }
274
- // 命令立即处理,不进入队列
275
- if (cmdHandler.isCommand(content)) {
276
- const cmdResult = await cmdHandler.handle(content, 'aun', sessionId, undefined, sessionId);
277
- if (cmdResult) {
278
- await aun.sendMessage(sessionId, cmdResult);
279
- return;
280
- }
353
+ const agent = agentMap.get(session.agentId) || agentMap.get(defaultAgent);
354
+ if (!agent) {
355
+ sessionManager.clearProcessing(session.id);
356
+ continue;
281
357
  }
282
- // 获取当前项目路径
283
- const session = await sessionManager.getOrCreateSession('aun', sessionId, config.projects?.defaultPath || process.cwd());
284
- // 普通消息进入队列
285
- await messageQueue.enqueue(`aun-${sessionId}`, { channel: 'aun', channelId: sessionId, content, timestamp: Date.now(), userId: sessionId }, session.projectPath);
286
- });
358
+ logger.info(`[Resume] Resuming session: ${session.id} (agent: ${session.agentId})`);
359
+ const resumeMessage = {
360
+ channel: session.channel,
361
+ channelId: session.channelId,
362
+ content: '服务已重启,请继续之前未完成的任务。',
363
+ timestamp: Date.now(),
364
+ peerId: '',
365
+ threadId: session.threadId || undefined,
366
+ replyContext: session.metadata?.replyContext,
367
+ };
368
+ // 清除状态后入队(processMessage 会重新标记)
369
+ sessionManager.clearProcessing(session.id);
370
+ messageQueue.enqueue(session.id, resumeMessage, session.projectPath).catch(err => {
371
+ logger.error(`[Resume] Failed to resume session ${session.id}:`, err);
372
+ });
373
+ }
287
374
  }
288
- // 连接渠道
289
- const channels = [];
290
- const channelInstances = [
291
- ...(feishu ? [{ name: 'Feishu', instance: feishu, timeout: 5000 }] : []),
292
- ...(aun ? [{ name: 'AUN', instance: aun }] : []),
293
- ...(wechat ? [{ name: 'WeChat', instance: wechat }] : []),
294
- ];
295
- for (const { name, instance, timeout } of channelInstances) {
375
+ // 重启通知:通过渠道 adapter 发送(channel-agnostic)
376
+ const pendingFile = path.join(resolvePaths().dataDir, 'restart-pending.json');
377
+ if (fs.existsSync(pendingFile)) {
296
378
  try {
297
- if (timeout) {
298
- await Promise.race([
299
- instance.connect(),
300
- new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout')), timeout))
301
- ]);
302
- }
303
- else {
304
- await instance.connect();
379
+ const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
380
+ const adapter = cmdHandler.getAdapter(pending.channel);
381
+ if (adapter) {
382
+ const replyContext = pending.rootId
383
+ ? { replyToMessageId: pending.rootId, replyInThread: true }
384
+ : undefined;
385
+ await adapter.sendText(pending.channelId, '✅ 服务重启成功!', replyContext);
386
+ logger.info(`[Restart] Notification sent via ${pending.channel}`);
305
387
  }
306
- logger.info(`✓ ${name} connected`);
307
- channels.push(name);
388
+ fs.unlinkSync(pendingFile);
308
389
  }
309
- catch (error) {
310
- logger.warn(`⚠ ${name} connection failed (will continue without it)`);
311
- if (error instanceof Error) {
312
- logger.warn(` Reason: ${error.message}`);
313
- }
390
+ catch (e) {
391
+ logger.error('[Restart] Failed to send restart notification:', e);
314
392
  }
315
393
  }
316
- logger.info(`\n🚀 EvolClaw is running with ${channels.length} channel(s): ${channels.join(', ')}\n`);
317
394
  // 写入 ready 信号,供 restart-monitor 检测启动成功
318
395
  const readySignalPath = resolvePaths().readySignal;
319
396
  fs.writeFileSync(readySignalPath, String(Date.now()));
320
397
  logger.info(`✓ Ready signal written: ${readySignalPath}`);
398
+ // IPC server — 供 CLI 查询实时状态
399
+ const ipcServer = new IpcServer(resolvePaths().socket, () => {
400
+ const channels = {};
401
+ const channelsByType = {};
402
+ for (const inst of channelInstances) {
403
+ const name = inst.adapter.channelName;
404
+ const status = inst.channel.getStatus?.() ?? { connected: true };
405
+ const channelType = inst.channelType || name;
406
+ channels[name] = { ...status, channelType };
407
+ if (!channelsByType[channelType])
408
+ channelsByType[channelType] = [];
409
+ channelsByType[channelType].push(name);
410
+ }
411
+ const snap = statsCollector.getSnapshot();
412
+ return {
413
+ pid: process.pid,
414
+ uptime: snap.uptimeMs,
415
+ channels,
416
+ channelsByType,
417
+ queue: {
418
+ pending: messageQueue.getGlobalQueueLength(),
419
+ processing: messageQueue.getGlobalProcessingCount(),
420
+ },
421
+ stats: {
422
+ received: snap.lastHour.received,
423
+ completed: snap.lastHour.completed,
424
+ errors: snap.lastHour.errors,
425
+ avgResponseMs: snap.lastHour.avgResponseMs,
426
+ },
427
+ };
428
+ });
429
+ ipcServer.start();
430
+ // 运行时配置文件监控
431
+ const configPath = resolvePaths().config;
432
+ fs.watchFile(configPath, { interval: 5000 }, (_curr, _prev) => {
433
+ let newConfig;
434
+ try {
435
+ newConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
436
+ }
437
+ catch {
438
+ // JSON 解析失败 → 视为坏文件,备份内存中的好副本
439
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
440
+ const backupPath = path.join(resolvePaths().dataDir, `evolclaw-${ts}.json`);
441
+ fs.writeFileSync(backupPath, JSON.stringify(config, null, 2));
442
+ logger.warn(`[Config Watch] Config file is not valid JSON. In-memory snapshot saved to ${backupPath}`);
443
+ eventBus.publish({ type: 'config:corrupted', backupPath, reasons: ['Invalid JSON'] });
444
+ return;
445
+ }
446
+ const result = validateConfigIntegrity(newConfig);
447
+ if (!result.valid) {
448
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
449
+ const backupPath = path.join(resolvePaths().dataDir, `evolclaw-${ts}.json`);
450
+ fs.writeFileSync(backupPath, JSON.stringify(config, null, 2));
451
+ logger.warn(`[Config Watch] Bad config write detected. Reasons: ${result.reasons.join('; ')}. In-memory snapshot saved to ${backupPath}`);
452
+ eventBus.publish({ type: 'config:corrupted', backupPath, reasons: result.reasons });
453
+ }
454
+ else {
455
+ logger.debug(`[Config Watch] Config file modified, passes integrity check`);
456
+ }
457
+ });
321
458
  // 优雅关闭
322
- const shutdown = async () => {
323
- logger.info('\n\nShutting down gracefully...');
324
- if (feishu)
325
- await feishu.disconnect();
326
- if (aun)
327
- await aun.disconnect();
328
- if (wechat)
329
- await wechat.disconnect();
459
+ let shutdownSignal = 'unknown';
460
+ const shutdown = async (signal) => {
461
+ if (signal)
462
+ shutdownSignal = signal;
463
+ const pid = process.pid;
464
+ const ppid = process.ppid;
465
+ logger.info(`\n\nShutting down gracefully... (signal=${shutdownSignal}, pid=${pid}, ppid=${ppid})`);
466
+ fs.unwatchFile(configPath);
467
+ ipcServer.stop();
468
+ eventBus.publish({
469
+ type: 'system:shutdown',
470
+ timestamp: Date.now()
471
+ });
472
+ // 断开插件系统的渠道
473
+ await channelLoader.disconnectAll(channelInstances);
474
+ for (const inst of channelInstances) {
475
+ const type = inst.channelType || inst.adapter.channelName;
476
+ eventBus.publish({ type: 'channel:disconnected', channel: type, channelName: inst.adapter.channelName, reason: 'shutdown' });
477
+ }
330
478
  sessionManager.close();
331
479
  logger.info('✓ Shutdown complete');
332
480
  process.exit(0);
333
481
  };
334
- process.on('SIGINT', shutdown);
335
- process.on('SIGTERM', shutdown);
482
+ process.on('SIGINT', () => shutdown('SIGINT'));
483
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
336
484
  }
337
485
  main().catch((error) => {
486
+ const msg = `Fatal error: ${error?.stack || error}`;
338
487
  logger.error('Fatal error:', error);
488
+ console.error(msg); // ensure it lands in stdout.log for self-heal diagnostics
339
489
  process.exit(1);
340
490
  });