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.
@@ -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
- logger.info(`[WeChat] Received: from=${fromUserId} text=${text.slice(0, 50)}...`);
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, text, 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, execFileSync, execFile } from 'child_process';
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', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'
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
- try {
25
- process.kill(pid, 0);
30
+ if (platform.isProcessRunning(pid)) {
26
31
  return pid;
27
32
  }
28
- catch {
29
- fs.unlinkSync(pidFile);
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
- try {
169
- const output = execFileSync('pgrep', ['-f', 'node.*dist/index.js'], { encoding: 'utf-8' }).trim();
170
- if (output) {
171
- const pids = output.split('\n');
172
- console.log(`⚠ 发现 ${pids.length} 个残留进程,正在清理...`);
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
- execFileSync('sleep', ['2']);
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
- process.kill(pid);
262
+ platform.killProcess(pid);
264
263
  await new Promise((resolve) => {
265
264
  let waited = 0;
266
265
  const check = setInterval(() => {
267
266
  waited++;
268
- try {
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
- try {
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 uptime = execFileSync('ps', ['-p', String(pid), '-o', 'etime='], { encoding: 'utf-8' }).trim();
321
- const cpu = execFileSync('ps', ['-p', String(pid), '-o', '%cpu='], { encoding: 'utf-8' }).trim();
322
- const mem = execFileSync('ps', ['-p', String(pid), '-o', 'rss='], { encoding: 'utf-8' }).trim();
323
- console.log(` Uptime: ${uptime}`);
324
- console.log(` CPU: ${cpu}%`);
325
- console.log(` Memory: ${mem} KB`);
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 output = execFileSync('sqlite3', [p.db,
341
- 'SELECT count(*) FROM sessions; SELECT count(*) FROM sessions WHERE is_active=1; SELECT count(DISTINCT channel_id) FROM sessions; SELECT count(DISTINCT project_path) FROM sessions;'
342
- ], { encoding: 'utf-8' }).trim().split('\n');
343
- if (output.length >= 4) {
344
- console.log(` Total sessions: ${output[0]} (active: ${output[1]})`);
345
- console.log(` Unique chats: ${output[2]}`);
346
- console.log(` Projects: ${output[3]}`);
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
- console.log(` WeChat: Configured (Token: ${tokenPreview}...)`);
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
- const child = spawn('tail', ['-f', mainLog], { stdio: 'inherit' });
437
- child.on('exit', (code) => process.exit(code || 0));
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
- try {
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
- try {
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
- try {
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 === `file://${process.argv[1]}`) {
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 encodedPath = projectPath.replace(/\//g, '-');
63
- const sessionFile = path.join(homeDir, '.claude', 'projects', encodedPath, `${claudeSessionId}.jsonl`);
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);