evolclaw 2.0.3 → 2.0.5
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/LICENSE +21 -0
- package/README.md +143 -89
- package/data/evolclaw.sample.json +8 -17
- package/dist/channels/feishu.js +68 -10
- package/dist/channels/wechat.js +238 -5
- package/dist/cli.js +108 -78
- package/dist/core/agent-runner.js +3 -2
- package/dist/core/command-handler.js +3 -3
- package/dist/core/message-processor.js +6 -2
- package/dist/core/session-manager.js +4 -3
- package/dist/index.js +32 -9
- package/dist/paths.js +3 -1
- package/dist/utils/init.js +66 -50
- package/dist/utils/markdown-to-feishu.js +58 -2
- package/dist/utils/permission.js +7 -0
- package/dist/utils/platform.js +175 -0
- package/package.json +3 -4
- package/bin/evolclaw +0 -10
package/dist/channels/wechat.js
CHANGED
|
@@ -17,8 +17,58 @@ const SESSION_PAUSE_DURATION_MS = 10 * 60 * 1000; // 长暂停:10 分钟
|
|
|
17
17
|
const MSG_TYPE_USER = 1;
|
|
18
18
|
const MSG_TYPE_BOT = 2;
|
|
19
19
|
const MSG_ITEM_TEXT = 1;
|
|
20
|
+
const MSG_ITEM_IMAGE = 2;
|
|
20
21
|
const MSG_ITEM_VOICE = 3;
|
|
22
|
+
const MSG_ITEM_FILE = 4;
|
|
23
|
+
const MSG_ITEM_VIDEO = 5;
|
|
21
24
|
const MSG_STATE_FINISH = 2;
|
|
25
|
+
// ── CDN + AES ───────────────────────────────────────────────────────────────
|
|
26
|
+
const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
27
|
+
const MIME_MAP = {
|
|
28
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
29
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
|
|
30
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo',
|
|
31
|
+
'.pdf': 'application/pdf', '.doc': 'application/msword',
|
|
32
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
33
|
+
'.xls': 'application/vnd.ms-excel',
|
|
34
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
35
|
+
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
|
|
36
|
+
'.txt': 'text/plain', '.csv': 'text/csv', '.md': 'text/markdown',
|
|
37
|
+
};
|
|
38
|
+
// Exported for unit testing
|
|
39
|
+
export function parseAesKey(aesKeyBase64) {
|
|
40
|
+
const decoded = Buffer.from(aesKeyBase64, 'base64');
|
|
41
|
+
if (decoded.length === 16)
|
|
42
|
+
return decoded;
|
|
43
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')))
|
|
44
|
+
return Buffer.from(decoded.toString('ascii'), 'hex');
|
|
45
|
+
throw new Error(`Invalid aes_key length: ${decoded.length}`);
|
|
46
|
+
}
|
|
47
|
+
// Exported for unit testing
|
|
48
|
+
export function decryptAesEcb(ciphertext, key) {
|
|
49
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
50
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
51
|
+
}
|
|
52
|
+
// Exported for unit testing
|
|
53
|
+
export function encryptAesEcb(plaintext, key) {
|
|
54
|
+
const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
|
|
55
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
56
|
+
}
|
|
57
|
+
async function downloadMedia(cdnMedia, hexKey) {
|
|
58
|
+
const aesKeyBase64 = hexKey
|
|
59
|
+
? Buffer.from(hexKey, 'hex').toString('base64')
|
|
60
|
+
: cdnMedia.aes_key;
|
|
61
|
+
if (!cdnMedia.encrypt_query_param)
|
|
62
|
+
throw new Error('No encrypt_query_param');
|
|
63
|
+
const url = `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(cdnMedia.encrypt_query_param)}`;
|
|
64
|
+
const res = await fetch(url);
|
|
65
|
+
if (!res.ok)
|
|
66
|
+
throw new Error(`CDN download failed: ${res.status}`);
|
|
67
|
+
const encrypted = Buffer.from(await res.arrayBuffer());
|
|
68
|
+
if (!aesKeyBase64)
|
|
69
|
+
return encrypted; // 无 key = 明文
|
|
70
|
+
return decryptAesEcb(encrypted, parseAesKey(aesKeyBase64));
|
|
71
|
+
}
|
|
22
72
|
// ── Markdown → Plain Text ───────────────────────────────────────────────────
|
|
23
73
|
function markdownToPlainText(text) {
|
|
24
74
|
let result = text;
|
|
@@ -91,6 +141,8 @@ export class WechatChannel {
|
|
|
91
141
|
// Session expired 状态
|
|
92
142
|
sessionPausedUntil = 0;
|
|
93
143
|
onSessionExpired;
|
|
144
|
+
// Project path resolver(用于保存文件到 uploads 目录)
|
|
145
|
+
projectPathResolver;
|
|
94
146
|
constructor(config) {
|
|
95
147
|
this.config = config;
|
|
96
148
|
const dataDir = resolvePaths().dataDir;
|
|
@@ -179,6 +231,64 @@ export class WechatChannel {
|
|
|
179
231
|
throw err;
|
|
180
232
|
}
|
|
181
233
|
}
|
|
234
|
+
/** 注册 projectPath 解析器,用于保存接收的文件 */
|
|
235
|
+
onProjectPathRequest(resolver) {
|
|
236
|
+
this.projectPathResolver = resolver;
|
|
237
|
+
}
|
|
238
|
+
/** 发送文件(图片/视频/文件)给用户,通过 CDN 上传 */
|
|
239
|
+
async sendFile(to, filePath) {
|
|
240
|
+
// Session 暂停期间拒绝发送
|
|
241
|
+
if (this.isSessionPaused()) {
|
|
242
|
+
logger.warn(`[WeChat] Session paused, dropping file send to ${to}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const contextToken = this.contextTokenCache.get(to);
|
|
246
|
+
if (!contextToken) {
|
|
247
|
+
logger.error(`[WeChat] No context_token for ${to}, cannot send file`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!fs.existsSync(filePath)) {
|
|
251
|
+
logger.error(`[WeChat] File not found: ${filePath}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const plaintext = Buffer.from(fs.readFileSync(filePath));
|
|
256
|
+
const rawsize = plaintext.length;
|
|
257
|
+
const rawfilemd5 = crypto.createHash('md5').update(plaintext).digest('hex');
|
|
258
|
+
const aeskey = crypto.randomBytes(16);
|
|
259
|
+
const filekey = crypto.randomBytes(16).toString('hex');
|
|
260
|
+
const filesize = Math.ceil((rawsize + 1) / 16) * 16;
|
|
261
|
+
// MIME → UploadMediaType
|
|
262
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
263
|
+
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
|
264
|
+
const uploadMediaType = mime.startsWith('image/') ? 1
|
|
265
|
+
: mime.startsWith('video/') ? 2 : 3;
|
|
266
|
+
// Step 1: getuploadurl
|
|
267
|
+
const uploadResp = await this.getUploadUrl({
|
|
268
|
+
filekey, media_type: uploadMediaType, to_user_id: to,
|
|
269
|
+
rawsize, rawfilemd5, filesize,
|
|
270
|
+
aeskey: aeskey.toString('hex'),
|
|
271
|
+
no_need_thumb: true,
|
|
272
|
+
});
|
|
273
|
+
// Step 2: encrypt + upload to CDN
|
|
274
|
+
const ciphertext = encryptAesEcb(plaintext, aeskey);
|
|
275
|
+
const downloadParam = await this.cdnUpload(uploadResp.upload_param, filekey, ciphertext);
|
|
276
|
+
// Step 3: sendmessage with CDN reference
|
|
277
|
+
const cdnMedia = {
|
|
278
|
+
encrypt_query_param: downloadParam,
|
|
279
|
+
aes_key: Buffer.from(aeskey.toString('hex')).toString('base64'),
|
|
280
|
+
encrypt_type: 1,
|
|
281
|
+
};
|
|
282
|
+
const itemType = mime.startsWith('image/') ? MSG_ITEM_IMAGE
|
|
283
|
+
: mime.startsWith('video/') ? MSG_ITEM_VIDEO : MSG_ITEM_FILE;
|
|
284
|
+
const item = this.buildMediaItem(itemType, cdnMedia, filePath, filesize, rawsize);
|
|
285
|
+
await this.sendMediaMessage(to, item, contextToken);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
logger.error(`[WeChat] Failed to send file ${filePath} to ${to}:`, err);
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
182
292
|
// ── Long-Poll Loop ────────────────────────────────────────────────────
|
|
183
293
|
async pollLoop(signal) {
|
|
184
294
|
let consecutiveFailures = 0;
|
|
@@ -315,22 +425,29 @@ export class WechatChannel {
|
|
|
315
425
|
async handleInboundMessage(msg) {
|
|
316
426
|
if (msg.message_type !== MSG_TYPE_USER)
|
|
317
427
|
return;
|
|
318
|
-
const text = extractTextFromMessage(msg);
|
|
319
|
-
if (!text)
|
|
320
|
-
return;
|
|
321
428
|
const fromUserId = msg.from_user_id ?? '';
|
|
322
429
|
// 缓存 context_token
|
|
323
430
|
if (msg.context_token) {
|
|
324
431
|
this.contextTokenCache.set(fromUserId, msg.context_token);
|
|
325
432
|
this.persistContextTokens();
|
|
326
433
|
}
|
|
327
|
-
|
|
434
|
+
// 提取文本(原有逻辑)
|
|
435
|
+
const text = extractTextFromMessage(msg);
|
|
436
|
+
// 提取媒体 → 下载
|
|
437
|
+
const media = await this.extractMedia(msg, fromUserId);
|
|
438
|
+
// 合成最终内容
|
|
439
|
+
const finalContent = media.prompt
|
|
440
|
+
? (text ? `${text}\n\n${media.prompt}` : media.prompt)
|
|
441
|
+
: text;
|
|
442
|
+
if (!finalContent && !media.images.length)
|
|
443
|
+
return;
|
|
444
|
+
logger.info(`[WeChat] Received: from=${fromUserId} text=${(finalContent || '').slice(0, 50)} images=${media.images.length}...`);
|
|
328
445
|
// 发送 typing 指示器(异步,不阻塞)
|
|
329
446
|
this.acknowledgeMessage(fromUserId, msg.context_token).catch(() => { });
|
|
330
447
|
// 回调主流程
|
|
331
448
|
if (this.messageHandler) {
|
|
332
449
|
try {
|
|
333
|
-
await this.messageHandler(fromUserId,
|
|
450
|
+
await this.messageHandler(fromUserId, finalContent || '', fromUserId, media.images.length ? media.images : undefined);
|
|
334
451
|
}
|
|
335
452
|
catch (err) {
|
|
336
453
|
logger.error('[WeChat] Message handler error:', err);
|
|
@@ -379,6 +496,122 @@ export class WechatChannel {
|
|
|
379
496
|
}
|
|
380
497
|
return undefined;
|
|
381
498
|
}
|
|
499
|
+
// ── Media Extraction (Inbound) ────────────────────────────────────────
|
|
500
|
+
async extractMedia(msg, channelId) {
|
|
501
|
+
const images = [];
|
|
502
|
+
const prompts = [];
|
|
503
|
+
for (const item of msg.item_list ?? []) {
|
|
504
|
+
try {
|
|
505
|
+
if (item.type === MSG_ITEM_IMAGE && item.image_item?.media) {
|
|
506
|
+
const buf = await downloadMedia(item.image_item.media, item.image_item.aeskey);
|
|
507
|
+
images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg' });
|
|
508
|
+
}
|
|
509
|
+
else if (item.type === MSG_ITEM_FILE && item.file_item?.media) {
|
|
510
|
+
const buf = await downloadMedia(item.file_item.media);
|
|
511
|
+
const fileName = this.sanitizeFileName(item.file_item.file_name || `file_${Date.now()}`);
|
|
512
|
+
const savePath = await this.saveToUploads(buf, fileName, channelId);
|
|
513
|
+
prompts.push(`用户发送了文件:${fileName}\n文件已保存到:${savePath}\n请使用 Read 工具读取并分析文件内容。`);
|
|
514
|
+
}
|
|
515
|
+
else if (item.type === MSG_ITEM_VIDEO && item.video_item?.media) {
|
|
516
|
+
const buf = await downloadMedia(item.video_item.media);
|
|
517
|
+
const fileName = `video_${Date.now()}.mp4`;
|
|
518
|
+
const savePath = await this.saveToUploads(buf, fileName, channelId);
|
|
519
|
+
prompts.push(`用户发送了视频:${fileName}\n文件已保存到:${savePath}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
logger.error(`[WeChat] Failed to download media type=${item.type}:`, err);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { prompt: prompts.join('\n\n'), images };
|
|
527
|
+
}
|
|
528
|
+
async saveToUploads(buf, fileName, channelId) {
|
|
529
|
+
const projectPath = this.projectPathResolver
|
|
530
|
+
? await this.projectPathResolver(channelId)
|
|
531
|
+
: process.cwd();
|
|
532
|
+
const uploadsDir = path.join(projectPath, '.claude', 'uploads');
|
|
533
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
534
|
+
const savePath = path.join(uploadsDir, fileName);
|
|
535
|
+
fs.writeFileSync(savePath, buf);
|
|
536
|
+
return savePath;
|
|
537
|
+
}
|
|
538
|
+
/** 清理文件名:移除路径穿越字符,只保留 basename */
|
|
539
|
+
sanitizeFileName(name) {
|
|
540
|
+
return path.basename(name).replace(/[<>:"|?*\x00-\x1f]/g, '_') || `file_${Date.now()}`;
|
|
541
|
+
}
|
|
542
|
+
// ── Media Upload (Outbound) ──────────────────────────────────────────
|
|
543
|
+
async getUploadUrl(params) {
|
|
544
|
+
const body = JSON.stringify({
|
|
545
|
+
...params,
|
|
546
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
547
|
+
});
|
|
548
|
+
const raw = await this.apiFetch('ilink/bot/getuploadurl', body, DEFAULT_API_TIMEOUT_MS);
|
|
549
|
+
const resp = JSON.parse(raw);
|
|
550
|
+
if (!resp.upload_param)
|
|
551
|
+
throw new Error('getuploadurl: no upload_param');
|
|
552
|
+
return resp;
|
|
553
|
+
}
|
|
554
|
+
async cdnUpload(uploadParam, filekey, ciphertext) {
|
|
555
|
+
const url = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${filekey}`;
|
|
556
|
+
let lastError;
|
|
557
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
558
|
+
try {
|
|
559
|
+
const res = await fetch(url, {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
562
|
+
body: new Uint8Array(ciphertext),
|
|
563
|
+
});
|
|
564
|
+
if (res.status >= 400 && res.status < 500) {
|
|
565
|
+
throw new Error(`CDN upload client error: ${res.status}`);
|
|
566
|
+
}
|
|
567
|
+
if (!res.ok)
|
|
568
|
+
throw new Error(`CDN upload failed: ${res.status}`);
|
|
569
|
+
const downloadParam = res.headers.get('x-encrypted-param');
|
|
570
|
+
if (!downloadParam)
|
|
571
|
+
throw new Error('Missing x-encrypted-param header');
|
|
572
|
+
return downloadParam;
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
lastError = err;
|
|
576
|
+
if (err.message?.includes('client error'))
|
|
577
|
+
throw err; // 4xx 不重试
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
throw lastError;
|
|
581
|
+
}
|
|
582
|
+
buildMediaItem(itemType, cdnMedia, filePath, ciphertextSize, plaintextSize) {
|
|
583
|
+
if (itemType === MSG_ITEM_IMAGE) {
|
|
584
|
+
return { type: MSG_ITEM_IMAGE, image_item: { media: cdnMedia, mid_size: ciphertextSize } };
|
|
585
|
+
}
|
|
586
|
+
if (itemType === MSG_ITEM_VIDEO) {
|
|
587
|
+
return { type: MSG_ITEM_VIDEO, video_item: { media: cdnMedia, video_size: ciphertextSize } };
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
type: MSG_ITEM_FILE,
|
|
591
|
+
file_item: {
|
|
592
|
+
media: cdnMedia,
|
|
593
|
+
file_name: path.basename(filePath),
|
|
594
|
+
len: String(plaintextSize),
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async sendMediaMessage(to, item, contextToken) {
|
|
599
|
+
const clientId = `evolclaw-wechat:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
600
|
+
const body = {
|
|
601
|
+
msg: {
|
|
602
|
+
from_user_id: '',
|
|
603
|
+
to_user_id: to,
|
|
604
|
+
client_id: clientId,
|
|
605
|
+
message_type: MSG_TYPE_BOT,
|
|
606
|
+
message_state: MSG_STATE_FINISH,
|
|
607
|
+
item_list: [item],
|
|
608
|
+
context_token: contextToken,
|
|
609
|
+
},
|
|
610
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
611
|
+
};
|
|
612
|
+
await this.apiFetch('ilink/bot/sendmessage', JSON.stringify(body), DEFAULT_API_TIMEOUT_MS);
|
|
613
|
+
logger.info(`[WeChat] Sent media to ${to}, type=${item.type}`);
|
|
614
|
+
}
|
|
382
615
|
// ── ilink API Helpers ─────────────────────────────────────────────────
|
|
383
616
|
async apiFetch(endpoint, body, timeoutMs, externalSignal) {
|
|
384
617
|
const base = this.config.baseUrl.endsWith('/') ? this.config.baseUrl : `${this.config.baseUrl}/`;
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
import fs from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
|
-
import { spawn,
|
|
4
|
+
import { spawn, execFile } from 'child_process';
|
|
4
5
|
import { promisify } from 'util';
|
|
5
6
|
import { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from './paths.js';
|
|
6
7
|
import { cmdInit } from './utils/init.js';
|
|
7
8
|
import { cmdInitWechat } from './utils/init-wechat.js';
|
|
8
9
|
import { cmdInitFeishu } from './utils/init-feishu.js';
|
|
10
|
+
import * as platform from './utils/platform.js';
|
|
11
|
+
// Suppress Node.js ExperimentalWarning (e.g. SQLite) from cluttering CLI output
|
|
12
|
+
process.removeAllListeners('warning');
|
|
13
|
+
process.on('warning', (w) => { if (w.name === 'ExperimentalWarning')
|
|
14
|
+
return; process.stderr.write((w.stack ?? String(w)) + '\n'); });
|
|
9
15
|
const execFileAsync = promisify(execFile);
|
|
10
16
|
// 清理 Claude Code 环境变量,防止 SDK 认为是嵌套会话
|
|
11
17
|
function cleanEnv() {
|
|
12
18
|
for (const key of [
|
|
13
19
|
'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT',
|
|
14
20
|
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
|
|
15
|
-
'CLAUDE_CONFIG_DIR',
|
|
21
|
+
'CLAUDE_CONFIG_DIR',
|
|
16
22
|
]) {
|
|
17
23
|
delete process.env[key];
|
|
18
24
|
}
|
|
@@ -21,14 +27,11 @@ function isRunning(pidFile) {
|
|
|
21
27
|
if (!fs.existsSync(pidFile))
|
|
22
28
|
return null;
|
|
23
29
|
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
24
|
-
|
|
25
|
-
process.kill(pid, 0);
|
|
30
|
+
if (platform.isProcessRunning(pid)) {
|
|
26
31
|
return pid;
|
|
27
32
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
33
|
+
fs.unlinkSync(pidFile);
|
|
34
|
+
return null;
|
|
32
35
|
}
|
|
33
36
|
function rotateLogs(logDir) {
|
|
34
37
|
if (!fs.existsSync(logDir))
|
|
@@ -148,7 +151,7 @@ function showHistory(statsFile) {
|
|
|
148
151
|
console.log('==================================================');
|
|
149
152
|
}
|
|
150
153
|
// ==================== Commands ====================
|
|
151
|
-
function cmdStart() {
|
|
154
|
+
async function cmdStart() {
|
|
152
155
|
const p = resolvePaths();
|
|
153
156
|
ensureDataDirs();
|
|
154
157
|
// 检查配置文件
|
|
@@ -165,24 +168,17 @@ function cmdStart() {
|
|
|
165
168
|
}
|
|
166
169
|
// 检查是否有残留进程(PID 文件已丢失但进程还在)
|
|
167
170
|
let hasOrphan = false;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const p of pids) {
|
|
174
|
-
try {
|
|
175
|
-
process.kill(parseInt(p, 10));
|
|
176
|
-
}
|
|
177
|
-
catch { }
|
|
178
|
-
}
|
|
179
|
-
hasOrphan = true;
|
|
171
|
+
const orphanPids = platform.findProcesses('node.*dist/index.js');
|
|
172
|
+
if (orphanPids.length > 0) {
|
|
173
|
+
console.log(`⚠ 发现 ${orphanPids.length} 个残留进程,正在清理...`);
|
|
174
|
+
for (const p of orphanPids) {
|
|
175
|
+
platform.killProcess(p);
|
|
180
176
|
}
|
|
177
|
+
hasOrphan = true;
|
|
181
178
|
}
|
|
182
|
-
catch { }
|
|
183
179
|
// 如果清理了残留进程,等待它们退出
|
|
184
180
|
if (hasOrphan) {
|
|
185
|
-
|
|
181
|
+
await sleep(2000);
|
|
186
182
|
}
|
|
187
183
|
console.log('🚀 Starting EvolClaw...');
|
|
188
184
|
rotateLogs(p.logs);
|
|
@@ -196,7 +192,7 @@ function cmdStart() {
|
|
|
196
192
|
const out = fs.openSync(stdoutLog, 'a');
|
|
197
193
|
const err = fs.openSync(stdoutLog, 'a');
|
|
198
194
|
const appMain = path.join(getPackageRoot(), 'dist', 'index.js');
|
|
199
|
-
const child = spawn('node', [appMain], {
|
|
195
|
+
const child = spawn('node', ['--no-warnings=ExperimentalWarning', appMain], {
|
|
200
196
|
detached: true,
|
|
201
197
|
stdio: ['ignore', out, err],
|
|
202
198
|
env: {
|
|
@@ -211,19 +207,7 @@ function cmdStart() {
|
|
|
211
207
|
// 等待 ready signal(最多 15 秒)
|
|
212
208
|
const startTime = Date.now();
|
|
213
209
|
const checkReady = () => {
|
|
214
|
-
//
|
|
215
|
-
if (!isRunning(p.pid)) {
|
|
216
|
-
console.log('❌ Failed to start EvolClaw');
|
|
217
|
-
console.log('');
|
|
218
|
-
console.log('📝 Error details (last 10 lines of stdout):');
|
|
219
|
-
if (fs.existsSync(stdoutLog)) {
|
|
220
|
-
const content = fs.readFileSync(stdoutLog, 'utf-8').trim().split('\n');
|
|
221
|
-
console.log(content.slice(-10).map(l => ` ${l}`).join('\n'));
|
|
222
|
-
}
|
|
223
|
-
process.exit(1);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
// ready signal 出现
|
|
210
|
+
// ready signal 出现(优先检查,避免 Windows 上 isRunning 误判)
|
|
227
211
|
if (fs.existsSync(p.readySignal)) {
|
|
228
212
|
const pid = isRunning(p.pid);
|
|
229
213
|
console.log(`✓ EvolClaw started successfully (PID: ${pid})`);
|
|
@@ -248,6 +232,21 @@ function cmdStart() {
|
|
|
248
232
|
process.exit(1);
|
|
249
233
|
return;
|
|
250
234
|
}
|
|
235
|
+
// 进程已退出且无 ready signal
|
|
236
|
+
if (!isRunning(p.pid)) {
|
|
237
|
+
// 给进程一点时间写 ready signal(可能刚好在写入中)
|
|
238
|
+
if (Date.now() - startTime > 3000) {
|
|
239
|
+
console.log('❌ Failed to start EvolClaw');
|
|
240
|
+
console.log('');
|
|
241
|
+
console.log('📝 Error details (last 10 lines of stdout):');
|
|
242
|
+
if (fs.existsSync(stdoutLog)) {
|
|
243
|
+
const content = fs.readFileSync(stdoutLog, 'utf-8').trim().split('\n');
|
|
244
|
+
console.log(content.slice(-10).map(l => ` ${l}`).join('\n'));
|
|
245
|
+
}
|
|
246
|
+
process.exit(1);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
251
250
|
setTimeout(checkReady, 500);
|
|
252
251
|
};
|
|
253
252
|
setTimeout(checkReady, 1000);
|
|
@@ -260,15 +259,12 @@ async function stopAndWait(pidFile) {
|
|
|
260
259
|
if (!pid)
|
|
261
260
|
return;
|
|
262
261
|
console.log(`🛑 Stopping EvolClaw (PID: ${pid})...`);
|
|
263
|
-
|
|
262
|
+
platform.killProcess(pid);
|
|
264
263
|
await new Promise((resolve) => {
|
|
265
264
|
let waited = 0;
|
|
266
265
|
const check = setInterval(() => {
|
|
267
266
|
waited++;
|
|
268
|
-
|
|
269
|
-
process.kill(pid, 0);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
267
|
+
if (!platform.isProcessRunning(pid)) {
|
|
272
268
|
clearInterval(check);
|
|
273
269
|
try {
|
|
274
270
|
fs.unlinkSync(pidFile);
|
|
@@ -280,10 +276,7 @@ async function stopAndWait(pidFile) {
|
|
|
280
276
|
}
|
|
281
277
|
if (waited >= 10) {
|
|
282
278
|
clearInterval(check);
|
|
283
|
-
|
|
284
|
-
process.kill(pid, 9);
|
|
285
|
-
}
|
|
286
|
-
catch { }
|
|
279
|
+
platform.killProcess(pid, true);
|
|
287
280
|
try {
|
|
288
281
|
fs.unlinkSync(pidFile);
|
|
289
282
|
}
|
|
@@ -317,12 +310,13 @@ async function cmdStatus() {
|
|
|
317
310
|
console.log('');
|
|
318
311
|
console.log('📊 Process Info:');
|
|
319
312
|
try {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
313
|
+
const info = platform.getProcessInfo(pid);
|
|
314
|
+
if (info.uptime)
|
|
315
|
+
console.log(` Uptime: ${info.uptime}`);
|
|
316
|
+
if (info.cpu)
|
|
317
|
+
console.log(` CPU: ${info.cpu}%`);
|
|
318
|
+
if (info.memory)
|
|
319
|
+
console.log(` Memory: ${info.memory} KB`);
|
|
326
320
|
}
|
|
327
321
|
catch { }
|
|
328
322
|
console.log(` EVOLCLAW_HOME: ${resolveRoot()}`);
|
|
@@ -337,14 +331,16 @@ async function cmdStatus() {
|
|
|
337
331
|
console.log('');
|
|
338
332
|
console.log('📦 Sessions & Projects:');
|
|
339
333
|
try {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
334
|
+
const Database = await import('node:sqlite');
|
|
335
|
+
const db = new Database.DatabaseSync(p.db);
|
|
336
|
+
const totalSessions = db.prepare('SELECT count(*) as cnt FROM sessions').get();
|
|
337
|
+
const activeSessions = db.prepare('SELECT count(*) as cnt FROM sessions WHERE is_active=1').get();
|
|
338
|
+
const uniqueChats = db.prepare('SELECT count(DISTINCT channel_id) as cnt FROM sessions').get();
|
|
339
|
+
const projects = db.prepare('SELECT count(DISTINCT project_path) as cnt FROM sessions').get();
|
|
340
|
+
db.close();
|
|
341
|
+
console.log(` Total sessions: ${totalSessions.cnt} (active: ${activeSessions.cnt})`);
|
|
342
|
+
console.log(` Unique chats: ${uniqueChats.cnt}`);
|
|
343
|
+
console.log(` Projects: ${projects.cnt}`);
|
|
348
344
|
}
|
|
349
345
|
catch { }
|
|
350
346
|
}
|
|
@@ -384,7 +380,42 @@ async function cmdStatus() {
|
|
|
384
380
|
}
|
|
385
381
|
if (config.channels?.wechat?.token) {
|
|
386
382
|
const tokenPreview = config.channels.wechat.token.slice(0, 20);
|
|
387
|
-
|
|
383
|
+
// Validate token by calling getconfig API
|
|
384
|
+
try {
|
|
385
|
+
const baseUrl = (config.channels.wechat.baseUrl || 'https://ilinkai.weixin.qq.com').replace(/\/$/, '');
|
|
386
|
+
const body = JSON.stringify({ base_info: { channel_version: '1.0.0' } });
|
|
387
|
+
const uint32 = (await import('node:crypto')).default.randomBytes(4).readUInt32BE(0);
|
|
388
|
+
const wechatUin = Buffer.from(String(uint32), 'utf-8').toString('base64');
|
|
389
|
+
const res = await fetch(`${baseUrl}/ilink/bot/getconfig`, {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: {
|
|
392
|
+
'Content-Type': 'application/json',
|
|
393
|
+
'AuthorizationType': 'ilink_bot_token',
|
|
394
|
+
'Authorization': `Bearer ${config.channels.wechat.token.trim()}`,
|
|
395
|
+
'X-WECHAT-UIN': wechatUin,
|
|
396
|
+
},
|
|
397
|
+
body,
|
|
398
|
+
signal: AbortSignal.timeout(10_000),
|
|
399
|
+
});
|
|
400
|
+
const resp = JSON.parse(await res.text());
|
|
401
|
+
const isExpired = resp.errcode === -14 || resp.ret === -14;
|
|
402
|
+
if (isExpired) {
|
|
403
|
+
console.log(` WeChat: ✗ Token expired (Token: ${tokenPreview}...)`);
|
|
404
|
+
console.log(' Run: evolclaw init wechat && evolclaw restart');
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
console.log(` WeChat: ✓ Connected (Token: ${tokenPreview}...)`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
const msg = e.message || '';
|
|
412
|
+
if (msg.includes('ETIMEDOUT') || msg.includes('ENETUNREACH') || msg.includes('ENOTFOUND')) {
|
|
413
|
+
console.log(` WeChat: ✗ Connection timeout (Token: ${tokenPreview}...)`);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
console.log(` WeChat: ✓ Configured (Token: ${tokenPreview}...)`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
388
419
|
}
|
|
389
420
|
else {
|
|
390
421
|
console.log(' WeChat: - Not configured');
|
|
@@ -433,8 +464,16 @@ function cmdLogs() {
|
|
|
433
464
|
console.log(`❌ Log file not found: ${mainLog}`);
|
|
434
465
|
process.exit(1);
|
|
435
466
|
}
|
|
436
|
-
|
|
437
|
-
|
|
467
|
+
if (platform.isWindows) {
|
|
468
|
+
// Windows: use fs.watch for live tail
|
|
469
|
+
const tail = platform.tailFile(mainLog);
|
|
470
|
+
platform.onShutdown(() => tail.abort());
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
// Unix: use tail -f
|
|
474
|
+
const child = spawn('tail', ['-f', mainLog], { stdio: 'inherit' });
|
|
475
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
476
|
+
}
|
|
438
477
|
}
|
|
439
478
|
/**
|
|
440
479
|
* restart-monitor: 内部命令,由 /restart 命令调用
|
|
@@ -467,10 +506,7 @@ async function cmdRestartMonitor() {
|
|
|
467
506
|
let waited = 0;
|
|
468
507
|
const interval = setInterval(() => {
|
|
469
508
|
waited++;
|
|
470
|
-
|
|
471
|
-
process.kill(oldPid, 0);
|
|
472
|
-
}
|
|
473
|
-
catch {
|
|
509
|
+
if (!platform.isProcessRunning(oldPid)) {
|
|
474
510
|
clearInterval(interval);
|
|
475
511
|
log(`Process ${oldPid} has exited`);
|
|
476
512
|
resolve();
|
|
@@ -479,10 +515,7 @@ async function cmdRestartMonitor() {
|
|
|
479
515
|
if (waited >= 30) {
|
|
480
516
|
clearInterval(interval);
|
|
481
517
|
log('ERROR: Process still running after 30s, force killing');
|
|
482
|
-
|
|
483
|
-
process.kill(oldPid, 9);
|
|
484
|
-
}
|
|
485
|
-
catch { }
|
|
518
|
+
platform.killProcess(oldPid, true);
|
|
486
519
|
resolve();
|
|
487
520
|
}
|
|
488
521
|
}, 1000);
|
|
@@ -558,7 +591,7 @@ async function spawnAndWaitReady(p, log, timeout) {
|
|
|
558
591
|
const out = fs.openSync(stdoutLog, 'a');
|
|
559
592
|
const err = fs.openSync(stdoutLog, 'a');
|
|
560
593
|
const appMain = path.join(getPackageRoot(), 'dist', 'index.js');
|
|
561
|
-
const child = spawn('node', [appMain], {
|
|
594
|
+
const child = spawn('node', ['--no-warnings=ExperimentalWarning', appMain], {
|
|
562
595
|
detached: true,
|
|
563
596
|
stdio: ['ignore', out, err],
|
|
564
597
|
env: {
|
|
@@ -589,10 +622,7 @@ async function spawnAndWaitReady(p, log, timeout) {
|
|
|
589
622
|
// 超时后杀掉进程
|
|
590
623
|
const pid = isRunning(p.pid);
|
|
591
624
|
if (pid) {
|
|
592
|
-
|
|
593
|
-
process.kill(pid);
|
|
594
|
-
}
|
|
595
|
-
catch { }
|
|
625
|
+
platform.killProcess(pid);
|
|
596
626
|
try {
|
|
597
627
|
fs.unlinkSync(p.pid);
|
|
598
628
|
}
|
|
@@ -771,7 +801,7 @@ export async function main(args) {
|
|
|
771
801
|
}
|
|
772
802
|
break;
|
|
773
803
|
case 'start':
|
|
774
|
-
cmdStart();
|
|
804
|
+
await cmdStart();
|
|
775
805
|
break;
|
|
776
806
|
case 'stop':
|
|
777
807
|
await cmdStop();
|
|
@@ -810,6 +840,6 @@ Environment:
|
|
|
810
840
|
}
|
|
811
841
|
}
|
|
812
842
|
// 直接运行时自动执行(node dist/cli.js ...)
|
|
813
|
-
if (import.meta.url
|
|
843
|
+
if (platform.isMainScript(import.meta.url)) {
|
|
814
844
|
main(process.argv.slice(2));
|
|
815
845
|
}
|
|
@@ -6,6 +6,7 @@ import os from 'os';
|
|
|
6
6
|
import { MessageStream } from './message-stream.js';
|
|
7
7
|
import { logger } from '../utils/logger.js';
|
|
8
8
|
import { canUseTool } from '../utils/permission.js';
|
|
9
|
+
import { encodePath } from '../utils/platform.js';
|
|
9
10
|
export class AgentRunner {
|
|
10
11
|
apiKey;
|
|
11
12
|
model;
|
|
@@ -59,8 +60,8 @@ export class AgentRunner {
|
|
|
59
60
|
// 验证会话文件是否存在且有效(仅在非安全模式且有 claudeSessionId 时)
|
|
60
61
|
if (claudeSessionId && !skipResume) {
|
|
61
62
|
const homeDir = os.homedir();
|
|
62
|
-
const
|
|
63
|
-
const sessionFile = path.join(homeDir, '.claude', 'projects',
|
|
63
|
+
const encodedProjectPath = encodePath(projectPath);
|
|
64
|
+
const sessionFile = path.join(homeDir, '.claude', 'projects', encodedProjectPath, `${claudeSessionId}.jsonl`);
|
|
64
65
|
let isValid = false;
|
|
65
66
|
if (fs.existsSync(sessionFile)) {
|
|
66
67
|
try {
|
|
@@ -433,7 +433,7 @@ export class CommandHandler {
|
|
|
433
433
|
const sessionKey = `${channel}-${channelId}`;
|
|
434
434
|
const processingProject = this.messageQueue.getProcessingProject(sessionKey);
|
|
435
435
|
const queueLength = this.messageQueue.getQueueLength(sessionKey);
|
|
436
|
-
const normalizePath = (p) => p.replace(
|
|
436
|
+
const normalizePath = (p) => p.replace(/[/\\]+$/, '');
|
|
437
437
|
for (const [name, projectPath] of Object.entries(this.projects)) {
|
|
438
438
|
const isCurrent = session?.projectPath === projectPath;
|
|
439
439
|
const prefix = isCurrent ? ' ✓' : ' ';
|
|
@@ -596,7 +596,7 @@ export class CommandHandler {
|
|
|
596
596
|
logger.debug('[CommandHandler] SDK listSessions sync failed (non-critical):', error);
|
|
597
597
|
}
|
|
598
598
|
const isGroup = await this.isGroupChat(channel, channelId);
|
|
599
|
-
const cliSessions = isGroup
|
|
599
|
+
const cliSessions = (isGroup || !isAdmin)
|
|
600
600
|
? []
|
|
601
601
|
: await this.sessionManager.scanCliSessions(session.projectPath);
|
|
602
602
|
const dbSessionIds = new Set(currentProjectSessions.map(s => s.claudeSessionId).filter(Boolean));
|
|
@@ -655,7 +655,7 @@ export class CommandHandler {
|
|
|
655
655
|
targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
|
|
656
656
|
}
|
|
657
657
|
const isGroup = await this.isGroupChat(channel, channelId);
|
|
658
|
-
if (!targetSession && sessionName.length === 8 && !isGroup) {
|
|
658
|
+
if (!targetSession && sessionName.length === 8 && !isGroup && isAdmin) {
|
|
659
659
|
const projectPaths = Object.values(this.projects);
|
|
660
660
|
if (session) {
|
|
661
661
|
projectPaths.unshift(session.projectPath);
|