evolclaw 2.2.0 → 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 (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 +247 -84
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +132 -50
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +750 -209
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +216 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +386 -105
  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} +57 -11
  25. package/dist/index.js +138 -54
  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
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Gemini SessionFileAdapter
3
+ *
4
+ * Reads Gemini CLI session files from ~/.gemini/tmp/{project}/chats/.
5
+ * Gemini stores sessions as JSON files with naming convention:
6
+ * session-{YYYY-MM-DDTHH-mm}-{uuid-prefix}.json
7
+ *
8
+ * Each file contains:
9
+ * { sessionId, projectHash, startTime, lastUpdated, messages, kind }
10
+ *
11
+ * Messages have type: 'user' | 'gemini' (not 'role').
12
+ */
13
+ import { logger } from '../../../utils/logger.js';
14
+ import path from 'path';
15
+ import fs from 'fs';
16
+ import os from 'os';
17
+ export class GeminiSessionFileAdapter {
18
+ agentId = 'gemini';
19
+ getGeminiHome() {
20
+ return path.join(os.homedir(), '.gemini');
21
+ }
22
+ /**
23
+ * Resolve Gemini project key for a project path.
24
+ * Priority:
25
+ * 1. ~/.gemini/projects.json explicit mapping
26
+ * 2. .project_root file content match under ~/.gemini/tmp/<project-key>/
27
+ * 3. basename(projectPath) fallback
28
+ */
29
+ resolveProjectKey(projectPath) {
30
+ const geminiHome = this.getGeminiHome();
31
+ const projectsPath = path.join(geminiHome, 'projects.json');
32
+ try {
33
+ if (fs.existsSync(projectsPath)) {
34
+ const data = JSON.parse(fs.readFileSync(projectsPath, 'utf-8'));
35
+ const mapped = data?.projects?.[projectPath];
36
+ if (typeof mapped === 'string' && mapped.trim())
37
+ return mapped;
38
+ }
39
+ }
40
+ catch (error) {
41
+ logger.debug('[GeminiAdapter] Failed to read projects.json:', error);
42
+ }
43
+ const tmpDir = path.join(geminiHome, 'tmp');
44
+ if (fs.existsSync(tmpDir)) {
45
+ try {
46
+ for (const entry of fs.readdirSync(tmpDir, { withFileTypes: true })) {
47
+ if (!entry.isDirectory())
48
+ continue;
49
+ const rootFile = path.join(tmpDir, entry.name, '.project_root');
50
+ if (!fs.existsSync(rootFile))
51
+ continue;
52
+ try {
53
+ const content = fs.readFileSync(rootFile, 'utf-8').trim();
54
+ if (content === projectPath)
55
+ return entry.name;
56
+ }
57
+ catch {
58
+ // ignore broken .project_root
59
+ }
60
+ }
61
+ }
62
+ catch (error) {
63
+ logger.debug('[GeminiAdapter] Failed to scan tmp project roots:', error);
64
+ }
65
+ }
66
+ return path.basename(projectPath);
67
+ }
68
+ /**
69
+ * Resolve the Gemini chats directory for a given project path.
70
+ */
71
+ resolveChatsDir(projectPath) {
72
+ const geminiHome = this.getGeminiHome();
73
+ const projectKey = this.resolveProjectKey(projectPath);
74
+ return path.join(geminiHome, 'tmp', projectKey, 'chats');
75
+ }
76
+ /**
77
+ * Find the session file matching a given session UUID.
78
+ * Gemini files are named session-{date}-{uuid-prefix}.json where
79
+ * uuid-prefix is the first 8 chars of the session UUID.
80
+ */
81
+ findSessionFile(projectPath, agentSessionId) {
82
+ const chatsDir = this.resolveChatsDir(projectPath);
83
+ if (!fs.existsSync(chatsDir))
84
+ return null;
85
+ const uuidPrefix = agentSessionId.substring(0, 8);
86
+ try {
87
+ const files = fs.readdirSync(chatsDir);
88
+ const match = files.find(f => f.includes(uuidPrefix) && f.endsWith('.json'));
89
+ return match ? path.join(chatsDir, match) : null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ readSessionData(projectPath, agentSessionId) {
96
+ const filePath = this.findSessionFile(projectPath, agentSessionId);
97
+ if (!filePath)
98
+ return null;
99
+ try {
100
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
101
+ }
102
+ catch (error) {
103
+ logger.warn(`[GeminiAdapter] Failed to read session file: ${filePath}`, error);
104
+ return null;
105
+ }
106
+ }
107
+ checkExists(projectPath, agentSessionId) {
108
+ return this.findSessionFile(projectPath, agentSessionId) !== null;
109
+ }
110
+ getFileInfo(projectPath, agentSessionId) {
111
+ const data = this.readSessionData(projectPath, agentSessionId);
112
+ if (!data)
113
+ return { turns: 0 };
114
+ const msgs = data.messages || [];
115
+ const userTurns = msgs.filter((m) => m.type === 'user').length;
116
+ // Extract title from first user message
117
+ let title;
118
+ const firstUser = msgs.find((m) => m.type === 'user');
119
+ if (firstUser) {
120
+ const text = Array.isArray(firstUser.content)
121
+ ? firstUser.content[0]?.text || ''
122
+ : String(firstUser.content || '');
123
+ title = text.substring(0, 50).trim() || undefined;
124
+ }
125
+ return { turns: userTurns, title };
126
+ }
127
+ readFirstMessage(projectPath, agentSessionId) {
128
+ return this.readUserMessage(projectPath, agentSessionId, 'first');
129
+ }
130
+ readLastUserMessage(projectPath, agentSessionId) {
131
+ return this.readUserMessage(projectPath, agentSessionId, 'last');
132
+ }
133
+ scanCliSessions(projectPath) {
134
+ const chatsDir = this.resolveChatsDir(projectPath);
135
+ if (!fs.existsSync(chatsDir))
136
+ return [];
137
+ try {
138
+ const files = fs.readdirSync(chatsDir)
139
+ .filter(f => f.startsWith('session-') && f.endsWith('.json'))
140
+ .map(f => {
141
+ const filePath = path.join(chatsDir, f);
142
+ const stat = fs.statSync(filePath);
143
+ // Extract UUID from filename: session-{date}-{uuid-prefix}.json
144
+ // Read the file to get full UUID
145
+ try {
146
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
147
+ return { uuid: data.sessionId, mtime: stat.mtimeMs };
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ })
153
+ .filter((e) => e !== null)
154
+ .sort((a, b) => b.mtime - a.mtime)
155
+ .slice(0, 10);
156
+ return files;
157
+ }
158
+ catch (error) {
159
+ logger.warn('[GeminiAdapter] scanCliSessions failed:', error);
160
+ return [];
161
+ }
162
+ }
163
+ readUserMessage(projectPath, agentSessionId, which) {
164
+ const data = this.readSessionData(projectPath, agentSessionId);
165
+ if (!data)
166
+ return null;
167
+ const msgs = (data.messages || []).filter((m) => m.type === 'user');
168
+ if (msgs.length === 0)
169
+ return null;
170
+ const msg = which === 'first' ? msgs[0] : msgs[msgs.length - 1];
171
+ const text = Array.isArray(msg.content)
172
+ ? msg.content[0]?.text || ''
173
+ : String(msg.content || '');
174
+ const trimmed = text.trim().replace(/\s+/g, ' ');
175
+ return trimmed.substring(0, 50) + (trimmed.length > 50 ? '...' : '');
176
+ }
177
+ }
@@ -1,5 +1,5 @@
1
1
  import fsPromises from 'fs/promises';
2
- import { logger } from './logger.js';
2
+ import { logger } from '../../utils/logger.js';
3
3
  /**
4
4
  * 检查会话文件健康度(接收完整文件路径)
5
5
  */
@@ -1,10 +1,9 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { ensureDir } from '../config.js';
3
- import { resolvePaths } from '../paths.js';
4
- import { logger } from '../utils/logger.js';
5
- import { encodePath } from '../utils/cross-platform.js';
2
+ import { ensureDir } from '../../config.js';
3
+ import { resolvePaths } from '../../paths.js';
4
+ import { logger } from '../../utils/logger.js';
5
+ import { encodePath } from '../../utils/cross-platform.js';
6
6
  import path from 'path';
7
- import fs from 'fs';
8
7
  import os from 'os';
9
8
  export class SessionManager {
10
9
  db;
@@ -93,12 +92,10 @@ export class SessionManager {
93
92
  const agentId = row.agent_id || 'claude';
94
93
  const adapter = this.getFileAdapter(agentId);
95
94
  if (!adapter) {
96
- // 无适配器时回退到 Claude 路径检查
97
- const sessionFile = this.getSessionFilePath(row.project_path, agentSessionId);
98
- if (fs.existsSync(sessionFile))
99
- return agentSessionId;
95
+ // 无适配器:无法验证文件,信任 DB 记录
96
+ return agentSessionId;
100
97
  }
101
- else if (adapter.checkExists(row.project_path, agentSessionId)) {
98
+ if (adapter.checkExists(row.project_path, agentSessionId)) {
102
99
  return agentSessionId;
103
100
  }
104
101
  logger.warn(`Session file not found for ${agentId}: ${agentSessionId}, clearing session ID`);
@@ -348,6 +345,29 @@ export class SessionManager {
348
345
  CREATE INDEX IF NOT EXISTS idx_session_health_safe_mode ON session_health(safe_mode);
349
346
  `);
350
347
  }
348
+ /**
349
+ * 启动时迁移:将 sessions.channel 从 channelType 回填为实例名(channelName)。
350
+ * 读取每行 metadata.channelName,若与 channel 列不同则更新。
351
+ */
352
+ migrateChannelToInstanceName() {
353
+ const rows = this.db.prepare(`SELECT id, channel, metadata FROM sessions WHERE metadata IS NOT NULL AND metadata != ''`).all();
354
+ let migrated = 0;
355
+ for (const row of rows) {
356
+ try {
357
+ const meta = JSON.parse(row.metadata);
358
+ if (meta.channelName && meta.channelName !== row.channel) {
359
+ this.db.prepare(`UPDATE sessions SET channel = ?, updated_at = ? WHERE id = ?`)
360
+ .run(meta.channelName, Date.now(), row.id);
361
+ migrated++;
362
+ logger.info(`[Migration] Restored channel '${row.channel}' -> '${meta.channelName}' (session ${row.id})`);
363
+ }
364
+ }
365
+ catch { /* skip invalid metadata */ }
366
+ }
367
+ if (migrated > 0) {
368
+ logger.info(`Channel instance name migration completed (${migrated} sessions updated)`);
369
+ }
370
+ }
351
371
  /**
352
372
  * 获取指定渠道所有已知的 thread_id(用于重启后预填充 seenThreads)
353
373
  */
@@ -402,6 +422,12 @@ export class SessionManager {
402
422
  if (threadId) {
403
423
  const session = this.getOrCreateThreadSession(channel, channelId, threadId, defaultProjectPath, metadata, name, agentId);
404
424
  session.identity = this.resolveIdentity(channel, userId);
425
+ // 新话题会话补写默认权限模式
426
+ if (session.metadata && !session.metadata.permissionMode) {
427
+ session.metadata.permissionMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
428
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
429
+ .run(JSON.stringify(session.metadata), Date.now(), session.id);
430
+ }
405
431
  return session;
406
432
  }
407
433
  // 主会话:查找活跃会话
@@ -413,7 +439,7 @@ export class SessionManager {
413
439
  const validSessionId = this.validateSessionFile(active);
414
440
  const session = { ...this.rowToSession(active), agentSessionId: validSessionId };
415
441
  session.identity = this.resolveIdentity(channel, userId);
416
- // 补写 peerId/peerName(私聊 session 可能在此字段引入前创建)
442
+ // 补写 peerId/peerName/channelName(旧 session 可能在这些字段引入前创建)
417
443
  if (chatType === 'private' && userId) {
418
444
  const activeMeta = active.metadata ? JSON.parse(active.metadata) : {};
419
445
  let updated = false;
@@ -425,12 +451,26 @@ export class SessionManager {
425
451
  activeMeta.peerName = metadata.peerName;
426
452
  updated = true;
427
453
  }
454
+ if (metadata?.channelName && activeMeta.channelName !== metadata.channelName) {
455
+ activeMeta.channelName = metadata.channelName;
456
+ updated = true;
457
+ }
428
458
  if (updated) {
429
459
  this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
430
460
  .run(JSON.stringify(activeMeta), Date.now(), active.id);
431
461
  session.metadata = activeMeta;
432
462
  }
433
463
  }
464
+ // 补写 channelName(非私聊时也需要)
465
+ if (metadata?.channelName && chatType !== 'private') {
466
+ const activeMeta = active.metadata ? JSON.parse(active.metadata) : {};
467
+ if (activeMeta.channelName !== metadata.channelName) {
468
+ activeMeta.channelName = metadata.channelName;
469
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
470
+ .run(JSON.stringify(activeMeta), Date.now(), active.id);
471
+ session.metadata = activeMeta;
472
+ }
473
+ }
434
474
  return session;
435
475
  }
436
476
  // 查找默认项目的主会话
@@ -474,6 +514,10 @@ export class SessionManager {
474
514
  updatedAt: Date.now()
475
515
  };
476
516
  session.identity = this.resolveIdentity(channel, userId);
517
+ // 写入默认权限模式(基于角色,只在首次创建时设置)
518
+ if (!sessionMetadata.permissionMode) {
519
+ sessionMetadata.permissionMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
520
+ }
477
521
  this.insertSession(session);
478
522
  this.eventBus.publish({
479
523
  type: 'session:created',
@@ -966,6 +1010,8 @@ export class SessionManager {
966
1010
  VALUES (?, 0, 0, ?, ?, ?)
967
1011
  ON CONFLICT(session_id) DO UPDATE SET
968
1012
  consecutive_errors = 0,
1013
+ last_error = NULL,
1014
+ last_error_type = NULL,
969
1015
  last_success_time = ?,
970
1016
  updated_at = ?
971
1017
  `).run(sessionId, now, now, now, now, now);
package/dist/index.js CHANGED
@@ -1,23 +1,26 @@
1
- import { ClaudeSessionFileAdapter } from './core/adapters/claude-session-file-adapter.js';
2
- import { CodexSessionFileAdapter } from './core/adapters/codex-session-file-adapter.js';
3
- import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, validateConfigIntegrity } from './config.js';
4
- import { SessionManager } from './core/session-manager.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';
5
6
  import { ClaudeAgentPlugin } from './agents/claude-runner.js';
6
7
  import { CodexAgentPlugin } from './agents/codex-runner.js';
8
+ import { GeminiAgentPlugin } from './agents/gemini-runner.js';
7
9
  import { FeishuChannelPlugin } from './channels/feishu.js';
8
10
  import { WechatChannelPlugin } from './channels/wechat.js';
9
11
  import { AUNChannelPlugin } from './channels/aun.js';
10
- import { MessageProcessor } from './core/message-processor.js';
11
- import { MessageQueue } from './core/message-queue.js';
12
- import { MessageBridge } from './core/message-bridge.js';
13
- import { MessageCache } from './utils/message-cache.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';
14
16
  import { CommandHandler } from './core/command-handler.js';
15
17
  import { EventBus } from './core/event-bus.js';
16
- import { StatsCollector } from './core/stats-collector.js';
18
+ import { StatsCollector } from './utils/stats-collector.js';
17
19
  import { PermissionGateway } from './core/permission.js';
20
+ import { InteractionRouter } from './core/interaction-router.js';
18
21
  import { ChannelLoader } from './core/channel-loader.js';
19
22
  import { AgentLoader } from './core/agent-loader.js';
20
- import { IpcServer } from './core/ipc-server.js';
23
+ import { IpcServer } from './ipc.js';
21
24
  import { logger } from './utils/logger.js';
22
25
  import path from 'path';
23
26
  import fs from 'fs';
@@ -54,6 +57,8 @@ async function main() {
54
57
  }
55
58
  const anthropic = resolveAnthropicConfig(config);
56
59
  logger.info('✓ Config loaded (API keys hidden)');
60
+ // Channel instance name uniqueness check
61
+ validateChannelInstanceNames(config);
57
62
  if (anthropic.baseUrl) {
58
63
  logger.info(`✓ Using custom API base URL: ${anthropic.baseUrl}`);
59
64
  }
@@ -70,10 +75,12 @@ async function main() {
70
75
  // 注册会话文件适配器(Claude / Codex 各自的会话文件操作)
71
76
  sessionManager.registerFileAdapter(new ClaudeSessionFileAdapter());
72
77
  sessionManager.registerFileAdapter(new CodexSessionFileAdapter());
78
+ sessionManager.registerFileAdapter(new GeminiSessionFileAdapter());
73
79
  // Agent 插件系统
74
80
  const agentLoader = new AgentLoader();
75
81
  agentLoader.register(new ClaudeAgentPlugin());
76
82
  agentLoader.register(new CodexAgentPlugin());
83
+ agentLoader.register(new GeminiAgentPlugin());
77
84
  const agentInstances = agentLoader.createAll(config, {
78
85
  onSessionIdUpdate: async (sessionId, agentSessionId) => {
79
86
  await sessionManager.updateAgentSessionIdBySessionId(sessionId, agentSessionId);
@@ -93,6 +100,8 @@ async function main() {
93
100
  // 权限审批网关
94
101
  const permissionGateway = new PermissionGateway();
95
102
  permissionGateway.setEventBus(eventBus);
103
+ // 交互路由器
104
+ const interactionRouter = new InteractionRouter();
96
105
  // 为所有支持权限的 agent 设置 gateway
97
106
  for (const inst of agentInstances) {
98
107
  inst.agent.setPermissionGateway?.(permissionGateway);
@@ -111,9 +120,12 @@ async function main() {
111
120
  channelLoader.register(new AUNChannelPlugin());
112
121
  const channelInstances = await channelLoader.createAll(config);
113
122
  logger.info(`✓ Created ${channelInstances.length} channel instance(s)`);
123
+ // 启动迁移:将 sessions.channel 从 channelType 回填为实例名
124
+ sessionManager.migrateChannelToInstanceName();
114
125
  // 创建命令处理器
115
126
  const cmdHandler = new CommandHandler(sessionManager, agentMap, config, messageCache, eventBus, defaultAgent);
116
127
  cmdHandler.setPermissionGateway(permissionGateway);
128
+ cmdHandler.setInteractionRouter(interactionRouter);
117
129
  cmdHandler.setStatsCollector(statsCollector);
118
130
  // 创建消息处理器
119
131
  const processor = new MessageProcessor(agentMap, sessionManager, config, messageCache, eventBus, (content, channel, channelId, userId, threadId) => {
@@ -129,6 +141,8 @@ async function main() {
129
141
  }, defaultAgent);
130
142
  // 回填 processor 和 messageQueue 的引用
131
143
  cmdHandler.setProcessor(processor);
144
+ // 设置交互路由器
145
+ processor.setInteractionRouter(interactionRouter);
132
146
  // 设置 compact 开始回调(对所有支持的 agent)
133
147
  for (const inst of agentInstances) {
134
148
  inst.agent.setCompactStartCallback?.((sessionId) => {
@@ -166,18 +180,27 @@ async function main() {
166
180
  // 设置项目路径提供器(如果需要)
167
181
  if (inst.onProjectPathRequest && inst.channel.onProjectPathRequest) {
168
182
  inst.channel.onProjectPathRequest(async (channelId) => {
169
- const session = await sessionManager.getOrCreateSession(inst.adapter.name, channelId, config.projects?.defaultPath || process.cwd(), undefined, undefined, undefined, undefined);
183
+ const session = await sessionManager.getOrCreateSession(inst.adapter.channelName, channelId, config.projects?.defaultPath || process.cwd(), undefined, undefined, undefined, undefined);
170
184
  return path.isAbsolute(session.projectPath)
171
185
  ? session.projectPath
172
186
  : path.resolve(process.cwd(), session.projectPath);
173
187
  });
174
188
  }
175
- // 注册 adapter、policy 和 options
176
- processor.registerChannel(inst.adapter, inst.policy || defaultPolicy, inst.options);
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);
177
194
  cmdHandler.registerAdapter(inst.adapter);
178
- cmdHandler.registerChannel(inst.adapter.name, inst.channel);
195
+ cmdHandler.registerChannel(inst.adapter.channelName, inst.channel, inst.channelType);
179
196
  if (inst.policy) {
180
- cmdHandler.registerPolicy(inst.adapter.name, 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
+ });
181
204
  }
182
205
  }
183
206
  // ── MessageBridge:Channel ↔ Core 消息桥梁 ──
@@ -185,86 +208,134 @@ async function main() {
185
208
  // ── 渠道消息注册 ──
186
209
  // 连接插件系统的渠道
187
210
  for (const inst of channelInstances) {
188
- if (inst.adapter.name === 'feishu') {
189
- msgBridge.register('feishu', (handler) => inst.channel.onMessage(async ({ channelId: chatId, content, images, peerId, peerName, messageId, mentions, threadId, rootId, chatType }) => {
190
- handler({
191
- channel: 'feishu', channelId: chatId, content, images, chatType,
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,
192
216
  peerId: peerId || '', peerName, messageId, mentions, threadId,
193
217
  replyContext: rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined,
194
218
  });
195
219
  }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, {
196
220
  replyToMessageId: replyContext?.replyToMessageId,
197
221
  replyInThread: true,
198
- }), inst.adapter);
222
+ }), inst.adapter, channelType);
199
223
  inst.channel.onRecall?.((messageId) => {
200
224
  msgBridge.cancel(messageId);
201
225
  });
202
226
  }
203
- if (inst.adapter.name === 'wechat') {
204
- msgBridge.register('wechat', (handler) => inst.channel.onMessage(async (channelId, content, peerId, images, chatType) => {
227
+ if (channelType === 'wechat') {
228
+ // 注入 EventBus(用于 channel:health 事件)
229
+ if (inst.channel.setEventBus) {
230
+ inst.channel.setEventBus(eventBus);
231
+ }
232
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (channelId, content, peerId, images, chatType) => {
205
233
  handler({
206
- channel: 'wechat',
234
+ channel: channelType,
207
235
  channelId,
208
236
  content,
209
237
  images,
210
238
  chatType: chatType || 'private',
211
239
  peerId: peerId || '',
212
240
  });
213
- }), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter);
241
+ }), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter, channelType);
214
242
  }
215
- if (inst.adapter.name === 'aun') {
216
- msgBridge.register('aun', (handler) => inst.channel.onMessage(async (opts) => {
243
+ if (channelType === 'aun') {
244
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (opts) => {
217
245
  handler({
218
- channel: 'aun',
246
+ channel: channelType,
219
247
  channelId: opts.channelId,
220
248
  content: opts.content,
221
249
  chatType: opts.chatType || 'private',
222
250
  peerId: opts.peerId || '',
251
+ peerName: opts.peerName,
223
252
  messageId: opts.messageId,
224
253
  mentions: opts.mentions,
225
254
  threadId: opts.threadId,
226
255
  replyContext: opts.replyContext,
227
256
  });
228
- }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, replyContext), inst.adapter);
257
+ }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, replyContext), inst.adapter, channelType);
229
258
  }
230
259
  }
231
260
  // ── 连接所有渠道 ──
232
261
  const connected = await channelLoader.connectAll(channelInstances);
233
262
  // 预填充 Feishu 已知 thread_id(重启后避免误判话题创建)
234
263
  for (const inst of channelInstances) {
235
- if (inst.adapter.name === 'feishu' && 'preloadThreads' in inst.channel) {
236
- const threadIds = sessionManager.getKnownThreadIds('feishu');
264
+ const channelType = inst.channelType || inst.adapter.channelName;
265
+ if (channelType === 'feishu' && 'preloadThreads' in inst.channel) {
266
+ const threadIds = sessionManager.getKnownThreadIds(inst.adapter.channelName);
237
267
  inst.channel.preloadThreads(threadIds);
238
268
  }
239
269
  }
240
270
  for (const name of connected) {
271
+ // 查找对应实例以获取 channelType
272
+ const inst = channelInstances.find(i => i.adapter.channelName === name);
273
+ const type = inst?.channelType || name;
241
274
  eventBus.publish({
242
275
  type: 'channel:connected',
243
- channel: name.toLowerCase(),
276
+ channel: type.toLowerCase(),
277
+ channelName: name,
244
278
  timestamp: Date.now()
245
279
  });
246
280
  }
247
- // AUN 重连失败通知:通过其他渠道给 owner 发消息
281
+ // AUN 重连失败通知:通过 channel:health 事件
248
282
  for (const inst of channelInstances) {
249
- if (inst.adapter.name === 'aun' && inst.channel.setOnChannelDown) {
283
+ const channelType = inst.channelType || inst.adapter.channelName;
284
+ if (channelType === 'aun' && inst.channel.setOnChannelDown) {
250
285
  inst.channel.setOnChannelDown(() => {
251
- logger.error('[AUN] All reconnect attempts exhausted, notifying owners');
252
- const msg = '⚠️ AUN 渠道断连,自动重试已用尽。\n使用 /check reconnect 手动重连';
253
- for (const other of channelInstances) {
254
- if (other.adapter.name === inst.adapter.name)
255
- continue;
256
- const ownerCfg = config.channels?.[other.adapter.name];
257
- const ownerId = ownerCfg?.owner;
258
- if (ownerId) {
259
- other.adapter.sendText(ownerId, msg).catch(err => {
260
- logger.error(`[AUN] Failed to notify ${other.adapter.name} owner:`, err);
261
- });
262
- }
263
- }
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);
264
320
  });
265
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);
266
333
  }
267
- logger.info(`\n🚀 EvolClaw is running with ${connected.length} channel(s): ${connected.join(', ')}\n`);
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`);
268
339
  eventBus.publish({
269
340
  type: 'system:started',
270
341
  channels: connected.map(c => c.toLowerCase()),
@@ -327,15 +398,22 @@ async function main() {
327
398
  // IPC server — 供 CLI 查询实时状态
328
399
  const ipcServer = new IpcServer(resolvePaths().socket, () => {
329
400
  const channels = {};
401
+ const channelsByType = {};
330
402
  for (const inst of channelInstances) {
331
- const name = inst.adapter.name;
332
- channels[name] = inst.channel.getStatus?.() ?? { connected: true };
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);
333
410
  }
334
411
  const snap = statsCollector.getSnapshot();
335
412
  return {
336
413
  pid: process.pid,
337
414
  uptime: snap.uptimeMs,
338
415
  channels,
416
+ channelsByType,
339
417
  queue: {
340
418
  pending: messageQueue.getGlobalQueueLength(),
341
419
  processing: messageQueue.getGlobalProcessingCount(),
@@ -378,8 +456,13 @@ async function main() {
378
456
  }
379
457
  });
380
458
  // 优雅关闭
381
- const shutdown = async () => {
382
- logger.info('\n\nShutting down gracefully...');
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})`);
383
466
  fs.unwatchFile(configPath);
384
467
  ipcServer.stop();
385
468
  eventBus.publish({
@@ -389,14 +472,15 @@ async function main() {
389
472
  // 断开插件系统的渠道
390
473
  await channelLoader.disconnectAll(channelInstances);
391
474
  for (const inst of channelInstances) {
392
- eventBus.publish({ type: 'channel:disconnected', channel: inst.adapter.name, reason: 'shutdown' });
475
+ const type = inst.channelType || inst.adapter.channelName;
476
+ eventBus.publish({ type: 'channel:disconnected', channel: type, channelName: inst.adapter.channelName, reason: 'shutdown' });
393
477
  }
394
478
  sessionManager.close();
395
479
  logger.info('✓ Shutdown complete');
396
480
  process.exit(0);
397
481
  };
398
- process.on('SIGINT', shutdown);
399
- process.on('SIGTERM', shutdown);
482
+ process.on('SIGINT', () => shutdown('SIGINT'));
483
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
400
484
  }
401
485
  main().catch((error) => {
402
486
  const msg = `Fatal error: ${error?.stack || error}`;