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.
- package/README.md +49 -27
- package/data/evolclaw.sample.json +6 -3
- package/dist/agents/claude-runner.js +125 -52
- package/dist/agents/codex-runner.js +10 -5
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +247 -84
- package/dist/channels/feishu.js +556 -96
- package/dist/channels/wechat.js +98 -74
- package/dist/cli.js +132 -50
- package/dist/config.js +185 -31
- package/dist/core/channel-loader.js +11 -4
- package/dist/core/command-handler.js +750 -209
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +216 -0
- package/dist/core/{message-processor.js → message/message-processor.js} +386 -105
- package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
- package/dist/{utils → core/message}/stream-debouncer.js +1 -1
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/core/permission.js +212 -11
- package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
- package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/{utils → core/session}/session-file-health.js +1 -1
- package/dist/core/{session-manager.js → session/session-manager.js} +57 -11
- package/dist/index.js +138 -54
- package/dist/{core/ipc-server.js → ipc.js} +36 -1
- package/dist/types.js +3 -0
- package/dist/utils/cross-platform.js +38 -1
- package/dist/utils/error-utils.js +130 -5
- package/dist/utils/init-channel.js +649 -0
- package/dist/utils/init.js +55 -150
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/{core → utils}/stats-collector.js +16 -0
- package/package.json +3 -3
- package/dist/core/message-bridge.js +0 -187
- package/dist/utils/init-feishu.js +0 -263
- package/dist/utils/init-wechat.js +0 -172
- package/dist/utils/ipc-client.js +0 -36
- package/dist/utils/permission-utils.js +0 -71
- /package/dist/{utils → core/message}/message-cache.js +0 -0
- /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
- /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
|
@@ -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,10 +1,9 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { ensureDir } from '
|
|
3
|
-
import { resolvePaths } from '
|
|
4
|
-
import { logger } from '
|
|
5
|
-
import { encodePath } from '
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
if (fs.existsSync(sessionFile))
|
|
99
|
-
return agentSessionId;
|
|
95
|
+
// 无适配器:无法验证文件,信任 DB 记录
|
|
96
|
+
return agentSessionId;
|
|
100
97
|
}
|
|
101
|
-
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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 './
|
|
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 './
|
|
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 './
|
|
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.
|
|
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
|
-
|
|
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.
|
|
195
|
+
cmdHandler.registerChannel(inst.adapter.channelName, inst.channel, inst.channelType);
|
|
179
196
|
if (inst.policy) {
|
|
180
|
-
cmdHandler.registerPolicy(inst.adapter.
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 (
|
|
204
|
-
|
|
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:
|
|
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 (
|
|
216
|
-
msgBridge.register(
|
|
243
|
+
if (channelType === 'aun') {
|
|
244
|
+
msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (opts) => {
|
|
217
245
|
handler({
|
|
218
|
-
channel:
|
|
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
|
-
|
|
236
|
-
|
|
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:
|
|
276
|
+
channel: type.toLowerCase(),
|
|
277
|
+
channelName: name,
|
|
244
278
|
timestamp: Date.now()
|
|
245
279
|
});
|
|
246
280
|
}
|
|
247
|
-
// AUN
|
|
281
|
+
// AUN 重连失败通知:通过 channel:health 事件
|
|
248
282
|
for (const inst of channelInstances) {
|
|
249
|
-
|
|
283
|
+
const channelType = inst.channelType || inst.adapter.channelName;
|
|
284
|
+
if (channelType === 'aun' && inst.channel.setOnChannelDown) {
|
|
250
285
|
inst.channel.setOnChannelDown(() => {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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.
|
|
332
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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}`;
|