@wemind/cli 1.0.0 → 1.1.1
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 +22 -0
- package/README.md +114 -4
- package/dist/cli.js +561 -144
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import crypto, { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
8
|
//#region ../wechat-ilink-core/dist/config.js
|
|
9
9
|
/**
|
|
10
10
|
* iLink 应用标识。
|
|
@@ -13,6 +13,54 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
*/
|
|
14
14
|
const ILINK_APP_ID = process.env.ILINK_APP_ID?.trim() || "bot";
|
|
15
15
|
//#endregion
|
|
16
|
+
//#region ../wechat-ilink-core/dist/pkg-version.js
|
|
17
|
+
/**
|
|
18
|
+
* 读取「调用方所在包」自己的版本号(从它的 package.json)。
|
|
19
|
+
*
|
|
20
|
+
* ## 为什么放在 core
|
|
21
|
+
* `@wemind/wechat-mcp` 与 `@wemind/cli` 都需要上报自己的版本,两边的「向上查找 package.json」
|
|
22
|
+
* 逻辑完全一样。放 core 里只维护一份;两个消费方各留一个 3 行的薄适配层。
|
|
23
|
+
*
|
|
24
|
+
* ## 为什么必须由调用方传入位置(`callerUrl`)
|
|
25
|
+
* `import.meta.url` 指向**当前文件**,而当前文件在 core 里——用它去向上找,命中的会是 core
|
|
26
|
+
* 自己的 `package.json`(或更上层的仓库根),**永远拿不到调用方的版本**。
|
|
27
|
+
* 实测过:在 daemon 语境里直接复用 mcp 的同类实现,得到的是 mcp 的版本(1.0.4)而不是
|
|
28
|
+
* daemon 的(1.1.0),且不会报错——静默给错值是最坏的一类 bug。
|
|
29
|
+
* 所以这里强制由调用方传 `import.meta.url`,函数从**那个位置**向上找。
|
|
30
|
+
*
|
|
31
|
+
* ## 为什么还要 expectedName
|
|
32
|
+
* 只按「最近的 package.json」查找并不可靠:在 monorepo 里向上找,可能先撞到 core 的、
|
|
33
|
+
* 也可能撞到仓库根的。用 `name` 精确匹配,命中不了就返回 `undefined`(安全失败),
|
|
34
|
+
* 由调用方决定兜底值,绝不静默返回别人的版本。
|
|
35
|
+
*
|
|
36
|
+
* ## 打包后仍然有效
|
|
37
|
+
* 两个消费方都用 tsdown 把 core **内联**进单文件产物。由于查找起点是调用方的
|
|
38
|
+
* `import.meta.url`(打包后是 `dist/cli.js` / `dist/index.js`),向上第一个带
|
|
39
|
+
* `name` 匹配的 `package.json` 正是该消费方自己的——dev(`src/`)与产物(`dist/`)两种布局都成立。
|
|
40
|
+
*
|
|
41
|
+
* @param callerUrl 调用方模块的 `import.meta.url`
|
|
42
|
+
* @param expectedName 期望的包名(即调用方自己 package.json 的 `name`)
|
|
43
|
+
* @returns 匹配到的 `version`;找不到返回 `undefined`
|
|
44
|
+
*/
|
|
45
|
+
function readOwnVersion(callerUrl, expectedName) {
|
|
46
|
+
if (!callerUrl || !expectedName) return void 0;
|
|
47
|
+
let dir;
|
|
48
|
+
try {
|
|
49
|
+
dir = path.dirname(fileURLToPath(callerUrl));
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { root } = path.parse(dir);
|
|
54
|
+
while (dir && dir !== root) {
|
|
55
|
+
const candidate = path.join(dir, "package.json");
|
|
56
|
+
try {
|
|
57
|
+
const p = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
58
|
+
if (p.name === expectedName && p.version) return p.version;
|
|
59
|
+
} catch {}
|
|
60
|
+
dir = path.dirname(dir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
16
64
|
//#region ../wechat-ilink-core/dist/api/types.js
|
|
17
65
|
/**
|
|
18
66
|
* iLink Bot API 协议类型(镜像服务端 proto:GetUpdatesReq/Resp、WeixinMessage、SendMessageReq 等)。
|
|
@@ -144,8 +192,11 @@ var ILinkError = class extends Error {
|
|
|
144
192
|
};
|
|
145
193
|
//#endregion
|
|
146
194
|
//#region ../wechat-ilink-core/dist/api/client.js
|
|
147
|
-
|
|
195
|
+
/** 普通 RPC 的超时(send/typing/config/upload/notify)。 */
|
|
148
196
|
const DEFAULT_API_TIMEOUT_MS = 15e3;
|
|
197
|
+
/** 消息长轮询(getupdates)超时。 */
|
|
198
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
|
|
199
|
+
/** 扫码状态长轮询(get_qrcode_status)超时。与消息长轮询语义不同,故分开命名。 */
|
|
149
200
|
const QR_LONG_POLL_TIMEOUT_MS = 35e3;
|
|
150
201
|
const ILINK_CLIENT_VERSION = process.env.ILINK_CLIENT_VERSION?.trim() || "2.4.3";
|
|
151
202
|
const CHANNEL_VERSION = ILINK_CLIENT_VERSION;
|
|
@@ -183,6 +234,7 @@ function buildHeaders(opts) {
|
|
|
183
234
|
if (opts.token?.trim()) headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
184
235
|
return headers;
|
|
185
236
|
}
|
|
237
|
+
/** POST 一个 iLink CGI,返回原始响应体。HTTP 非 2xx 抛错;响应体是否是 JSON 由调用方决定。 */
|
|
186
238
|
async function apiFetch(params) {
|
|
187
239
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
188
240
|
const url = new URL(params.endpoint, base);
|
|
@@ -191,7 +243,7 @@ async function apiFetch(params) {
|
|
|
191
243
|
body: params.body
|
|
192
244
|
});
|
|
193
245
|
const controller = new AbortController();
|
|
194
|
-
const
|
|
246
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
195
247
|
try {
|
|
196
248
|
const res = await fetch(url.toString(), {
|
|
197
249
|
method: "POST",
|
|
@@ -200,12 +252,19 @@ async function apiFetch(params) {
|
|
|
200
252
|
signal: controller.signal
|
|
201
253
|
});
|
|
202
254
|
const rawText = await res.text();
|
|
203
|
-
|
|
204
|
-
if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
255
|
+
if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText.slice(0, 500)}`);
|
|
205
256
|
return rawText;
|
|
206
|
-
}
|
|
207
|
-
clearTimeout(
|
|
208
|
-
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** 宽松解析:空响应体或非 JSON 返回 null(部分接口成功时无响应体)。 */
|
|
262
|
+
function tryParseJson(raw) {
|
|
263
|
+
if (!raw) return null;
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(raw);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
209
268
|
}
|
|
210
269
|
}
|
|
211
270
|
/** 防御性解析 API JSON 响应:失败抛带 label 与响应片段的清晰错误,而非裸 SyntaxError(畸形/代理 HTML/截断响应不致以晦涩错误打断调用方)。 */
|
|
@@ -216,6 +275,15 @@ function parseJsonResp(raw, label) {
|
|
|
216
275
|
throw new Error(`${label}: response not JSON (${raw.slice(0, 120)})`);
|
|
217
276
|
}
|
|
218
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* iLink 即使 HTTP 200 也可能在响应体返回非 0 ret/errcode 表示失败。语义见 error-codes.ts:
|
|
280
|
+
* ret=-2 参数错误(最常见 context_token 失效/不匹配);errcode=-14 会话过期。
|
|
281
|
+
* 不校验会导致「假成功」——消息其实没送达。仅在明确拿到非 0 错误码时抛 ILinkError(携带码+分类,供上层按类型处理)。
|
|
282
|
+
*/
|
|
283
|
+
function throwIfErrorCode(op, resp) {
|
|
284
|
+
const code = resp?.ret ?? resp?.errcode;
|
|
285
|
+
if (code !== void 0 && code !== 0) throw new ILinkError(op, code, resp?.errmsg);
|
|
286
|
+
}
|
|
219
287
|
function buildBaseInfo() {
|
|
220
288
|
return { channel_version: CHANNEL_VERSION };
|
|
221
289
|
}
|
|
@@ -243,7 +311,7 @@ async function getUpdates(params) {
|
|
|
243
311
|
}
|
|
244
312
|
}
|
|
245
313
|
async function sendMessage(params) {
|
|
246
|
-
|
|
314
|
+
throwIfErrorCode("sendMessage", tryParseJson(await apiFetch({
|
|
247
315
|
baseUrl: params.baseUrl,
|
|
248
316
|
endpoint: "ilink/bot/sendmessage",
|
|
249
317
|
body: JSON.stringify({
|
|
@@ -253,19 +321,11 @@ async function sendMessage(params) {
|
|
|
253
321
|
token: params.token,
|
|
254
322
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
255
323
|
label: "sendMessage"
|
|
256
|
-
});
|
|
257
|
-
let code;
|
|
258
|
-
let errmsg;
|
|
259
|
-
try {
|
|
260
|
-
const resp = raw ? JSON.parse(raw) : null;
|
|
261
|
-
code = resp?.ret ?? resp?.errcode;
|
|
262
|
-
errmsg = resp?.errmsg;
|
|
263
|
-
} catch {}
|
|
264
|
-
if (code !== void 0 && code !== 0) throw new ILinkError("sendMessage", code, errmsg);
|
|
324
|
+
})));
|
|
265
325
|
}
|
|
266
326
|
/** 取 CDN 上传地址(发图/发文件用)。返回 upload_full_url 或 upload_param。 */
|
|
267
327
|
async function getUploadUrl(params) {
|
|
268
|
-
|
|
328
|
+
const resp = parseJsonResp(await apiFetch({
|
|
269
329
|
baseUrl: params.baseUrl,
|
|
270
330
|
endpoint: "ilink/bot/getuploadurl",
|
|
271
331
|
body: JSON.stringify({
|
|
@@ -283,6 +343,8 @@ async function getUploadUrl(params) {
|
|
|
283
343
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
284
344
|
label: "getUploadUrl"
|
|
285
345
|
}), "getUploadUrl");
|
|
346
|
+
throwIfErrorCode("getUploadUrl", resp);
|
|
347
|
+
return resp;
|
|
286
348
|
}
|
|
287
349
|
async function fetchQRCode(apiBaseUrl) {
|
|
288
350
|
return parseJsonResp(await apiFetch({
|
|
@@ -303,26 +365,21 @@ async function pollQRStatus(apiBaseUrl, qrcode) {
|
|
|
303
365
|
headers: buildCommonHeaders(),
|
|
304
366
|
signal: controller.signal
|
|
305
367
|
});
|
|
306
|
-
if (!response.ok) {
|
|
307
|
-
const body = await response.text().catch(() => "(unreadable)");
|
|
308
|
-
clearTimeout(timer);
|
|
309
|
-
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText} ${body}`);
|
|
310
|
-
}
|
|
311
368
|
const text = await response.text();
|
|
312
|
-
|
|
369
|
+
if (!response.ok) throw new Error(`pollQRStatus ${response.status} ${response.statusText} ${text.slice(0, 500)}`);
|
|
313
370
|
return parseJsonResp(text, "pollQRStatus");
|
|
314
371
|
} catch (err) {
|
|
315
|
-
clearTimeout(timer);
|
|
316
372
|
if (err instanceof Error && err.name === "AbortError") return { status: "wait" };
|
|
317
373
|
throw err;
|
|
374
|
+
} finally {
|
|
375
|
+
clearTimeout(timer);
|
|
318
376
|
}
|
|
319
377
|
}
|
|
320
378
|
function generateId(prefix) {
|
|
321
379
|
return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
322
380
|
}
|
|
323
|
-
const DEFAULT_CONFIG_TIMEOUT_MS = 15e3;
|
|
324
381
|
async function getConfig(params) {
|
|
325
|
-
|
|
382
|
+
const resp = parseJsonResp(await apiFetch({
|
|
326
383
|
baseUrl: params.baseUrl,
|
|
327
384
|
endpoint: "ilink/bot/getconfig",
|
|
328
385
|
body: JSON.stringify({
|
|
@@ -331,12 +388,14 @@ async function getConfig(params) {
|
|
|
331
388
|
base_info: buildBaseInfo()
|
|
332
389
|
}),
|
|
333
390
|
token: params.token,
|
|
334
|
-
timeoutMs:
|
|
391
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
335
392
|
label: "getConfig"
|
|
336
393
|
}), "getConfig");
|
|
394
|
+
throwIfErrorCode("getConfig", resp);
|
|
395
|
+
return resp;
|
|
337
396
|
}
|
|
338
397
|
async function sendTyping(params) {
|
|
339
|
-
await apiFetch({
|
|
398
|
+
throwIfErrorCode("sendTyping", tryParseJson(await apiFetch({
|
|
340
399
|
baseUrl: params.baseUrl,
|
|
341
400
|
endpoint: "ilink/bot/sendtyping",
|
|
342
401
|
body: JSON.stringify({
|
|
@@ -344,39 +403,36 @@ async function sendTyping(params) {
|
|
|
344
403
|
base_info: buildBaseInfo()
|
|
345
404
|
}),
|
|
346
405
|
token: params.token,
|
|
347
|
-
timeoutMs:
|
|
406
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
348
407
|
label: "sendTyping"
|
|
349
|
-
});
|
|
408
|
+
})));
|
|
350
409
|
}
|
|
351
|
-
/**
|
|
352
|
-
|
|
410
|
+
/**
|
|
411
|
+
* 上线/下线通知的共同实现。
|
|
412
|
+
* best-effort:失败只记日志、绝不抛出——通知失败不应阻断登录/登出主流程。
|
|
413
|
+
* 也正因为要吞错,这里刻意**不校验**响应体的 ret/errcode。
|
|
414
|
+
*/
|
|
415
|
+
async function notifyLifecycle(op, endpoint, params) {
|
|
353
416
|
try {
|
|
354
417
|
await apiFetch({
|
|
355
418
|
baseUrl: params.baseUrl,
|
|
356
|
-
endpoint
|
|
419
|
+
endpoint,
|
|
357
420
|
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
358
421
|
token: params.token,
|
|
359
|
-
timeoutMs:
|
|
360
|
-
label:
|
|
422
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
423
|
+
label: op
|
|
361
424
|
});
|
|
362
425
|
} catch (err) {
|
|
363
|
-
console.error(`[wemind/wechat-ilink-core/ilink]
|
|
426
|
+
console.error(`[wemind/wechat-ilink-core/ilink] ${op} failed (ignored, best-effort): ${String(err)}`);
|
|
364
427
|
}
|
|
365
428
|
}
|
|
429
|
+
/** 通知服务端该 bot 客户端上线(best-effort,失败不阻断)。 */
|
|
430
|
+
async function notifyStart(params) {
|
|
431
|
+
await notifyLifecycle("notifyStart", "ilink/bot/msg/notifystart", params);
|
|
432
|
+
}
|
|
366
433
|
/** 通知服务端该 bot 客户端下线(best-effort,失败不阻断)。 */
|
|
367
434
|
async function notifyStop(params) {
|
|
368
|
-
|
|
369
|
-
await apiFetch({
|
|
370
|
-
baseUrl: params.baseUrl,
|
|
371
|
-
endpoint: "ilink/bot/msg/notifystop",
|
|
372
|
-
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
373
|
-
token: params.token,
|
|
374
|
-
timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
|
|
375
|
-
label: "notifyStop"
|
|
376
|
-
});
|
|
377
|
-
} catch (err) {
|
|
378
|
-
console.error(`[wemind/wechat-ilink-core/ilink] notifyStop failed (ignored, best-effort): ${String(err)}`);
|
|
379
|
-
}
|
|
435
|
+
await notifyLifecycle("notifyStop", "ilink/bot/msg/notifystop", params);
|
|
380
436
|
}
|
|
381
437
|
//#endregion
|
|
382
438
|
//#region ../wechat-ilink-core/dist/api/transport-errors.js
|
|
@@ -559,6 +615,12 @@ function setLabel(key, label) {
|
|
|
559
615
|
*/
|
|
560
616
|
const ACCOUNT_KEY = process.env.WECHAT_ACCOUNT?.trim() || "default";
|
|
561
617
|
const DATA_DIR = process.env.WECHAT_DATA_DIR?.trim() || path.join(WEMIND_ROOT, ACCOUNT_KEY);
|
|
618
|
+
/** 持久化文件名:集中在此,避免各处拼字符串写错(load/save/clear 必须指向同一份)。 */
|
|
619
|
+
const FILES = {
|
|
620
|
+
account: "account.json",
|
|
621
|
+
state: "state.json",
|
|
622
|
+
qrState: "qr_login.json"
|
|
623
|
+
};
|
|
562
624
|
function ensureDir(dir) {
|
|
563
625
|
fs.mkdirSync(dir, { recursive: true });
|
|
564
626
|
}
|
|
@@ -566,8 +628,9 @@ function readJson$2(filePath) {
|
|
|
566
628
|
let raw;
|
|
567
629
|
try {
|
|
568
630
|
raw = fs.readFileSync(filePath, "utf-8");
|
|
569
|
-
} catch {
|
|
570
|
-
return null;
|
|
631
|
+
} catch (e) {
|
|
632
|
+
if (isNotFound(e)) return null;
|
|
633
|
+
throw e;
|
|
571
634
|
}
|
|
572
635
|
try {
|
|
573
636
|
return JSON.parse(raw);
|
|
@@ -579,28 +642,63 @@ function readJson$2(filePath) {
|
|
|
579
642
|
return null;
|
|
580
643
|
}
|
|
581
644
|
}
|
|
645
|
+
function isNotFound(err) {
|
|
646
|
+
return err?.code === "ENOENT";
|
|
647
|
+
}
|
|
582
648
|
/** 原子写:先写临时文件再 rename 覆盖(同盘 rename 原子),避免写途崩溃/并发产生半截损坏文件。 */
|
|
583
649
|
function writeJson(filePath, data) {
|
|
584
650
|
ensureDir(path.dirname(filePath));
|
|
585
651
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
586
|
-
|
|
587
|
-
|
|
652
|
+
try {
|
|
653
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
654
|
+
fs.renameSync(tmp, filePath);
|
|
655
|
+
} catch (e) {
|
|
656
|
+
try {
|
|
657
|
+
fs.rmSync(tmp, { force: true });
|
|
658
|
+
} catch {}
|
|
659
|
+
throw e;
|
|
660
|
+
}
|
|
588
661
|
}
|
|
589
|
-
function loadAccount() {
|
|
590
|
-
return readJson$2(path.join(
|
|
662
|
+
function loadAccount(dir = DATA_DIR) {
|
|
663
|
+
return readJson$2(path.join(dir, FILES.account));
|
|
664
|
+
}
|
|
665
|
+
let warnedWinPerms = false;
|
|
666
|
+
/**
|
|
667
|
+
* 把登录凭证写进**指定**目录的 `account.json`(原子写 + POSIX 600 + Windows 一次性明文告警)。
|
|
668
|
+
*
|
|
669
|
+
* 为什么要有 dir 参数:`wemind login` 是先写到暂存目录 `.staging/`、扫码确认后才 rename 成账号目录。
|
|
670
|
+
* 那条路径以前直接 `fs.writeFileSync`,于是「POSIX 600 / Windows 告警」这两条安全承诺在最推荐
|
|
671
|
+
* 的登录入口上不成立(chmod 也漏了)。改成复用本函数,两条路径行为一致。
|
|
672
|
+
* rename 同盘保留权限位,所以先写好再归档不会丢 600。
|
|
673
|
+
*/
|
|
674
|
+
function saveAccountTo(dir, account) {
|
|
675
|
+
const file = path.join(dir, FILES.account);
|
|
676
|
+
writeJson(file, account);
|
|
677
|
+
if (process.platform === "win32") {
|
|
678
|
+
if (!warnedWinPerms) {
|
|
679
|
+
warnedWinPerms = true;
|
|
680
|
+
console.error(`[wemind/wechat-ilink-core] warning: ${file} stores the bot token in plaintext; Windows cannot restrict it via chmod. Protect ${dir} with folder permissions or disk encryption.`);
|
|
681
|
+
}
|
|
682
|
+
} else try {
|
|
683
|
+
fs.chmodSync(file, 384);
|
|
684
|
+
} catch {}
|
|
591
685
|
}
|
|
592
|
-
|
|
593
|
-
|
|
686
|
+
/** 空状态:唯一来源,避免 loadState/resetState 各写一份、将来加字段时漏改。 */
|
|
687
|
+
function emptyState() {
|
|
688
|
+
return {
|
|
594
689
|
updatesBuf: "",
|
|
595
690
|
contextTokens: {},
|
|
596
691
|
lastMessageId: 0
|
|
597
692
|
};
|
|
598
693
|
}
|
|
599
|
-
function
|
|
600
|
-
|
|
694
|
+
function loadState(dir = DATA_DIR) {
|
|
695
|
+
return readJson$2(path.join(dir, FILES.state)) ?? emptyState();
|
|
601
696
|
}
|
|
602
|
-
function
|
|
603
|
-
writeJson(path.join(
|
|
697
|
+
function saveState(state, dir = DATA_DIR) {
|
|
698
|
+
writeJson(path.join(dir, FILES.state), state);
|
|
699
|
+
}
|
|
700
|
+
function saveQrState(state, dir = DATA_DIR) {
|
|
701
|
+
writeJson(path.join(dir, FILES.qrState), state);
|
|
604
702
|
}
|
|
605
703
|
//#endregion
|
|
606
704
|
//#region ../wechat-ilink-core/dist/store/allow-from.js
|
|
@@ -741,8 +839,6 @@ async function uploadMediaBytes(params) {
|
|
|
741
839
|
no_need_thumb: true,
|
|
742
840
|
aeskey: aeskey.toString("hex")
|
|
743
841
|
});
|
|
744
|
-
const code = up.ret ?? up.errcode;
|
|
745
|
-
if (code !== void 0 && code !== 0) throw new ILinkError("getUploadUrl", code, up.errmsg);
|
|
746
842
|
return {
|
|
747
843
|
downloadParam: await uploadBufferToCdn({
|
|
748
844
|
buf: data,
|
|
@@ -940,7 +1036,7 @@ const ACP_AGENT_PRESETS = {
|
|
|
940
1036
|
realHome: path.join(os.homedir(), ".dsh"),
|
|
941
1037
|
inheritDirs: ["skills"],
|
|
942
1038
|
inheritFiles: ["AGENTS.md"],
|
|
943
|
-
|
|
1039
|
+
credentialFiles: [".credentials.yaml"],
|
|
944
1040
|
restore: "resume",
|
|
945
1041
|
firstFrameMs: 6e4,
|
|
946
1042
|
heartbeatMs: 6e4,
|
|
@@ -948,6 +1044,29 @@ const ACP_AGENT_PRESETS = {
|
|
|
948
1044
|
model: "[\"deepseek-account\",\"deepseek-flash\"]",
|
|
949
1045
|
mcpViaAcp: true
|
|
950
1046
|
},
|
|
1047
|
+
qoder: {
|
|
1048
|
+
name: "qoder",
|
|
1049
|
+
bin: "qodercli",
|
|
1050
|
+
binWin: "qodercli.exe",
|
|
1051
|
+
fallbackBinPaths: process.platform === "win32" ? [path.join(os.homedir(), ".qoder", "bin", "qodercli", "qodercli.exe")] : [],
|
|
1052
|
+
args: ["--acp", "--yolo"],
|
|
1053
|
+
env: { QODER_CONFIG_DIR: `%HOME%${path.sep}qoder` },
|
|
1054
|
+
realHome: path.join(os.homedir(), ".qoder"),
|
|
1055
|
+
inheritDirs: [
|
|
1056
|
+
"memory",
|
|
1057
|
+
"skills",
|
|
1058
|
+
"agents",
|
|
1059
|
+
"commands"
|
|
1060
|
+
],
|
|
1061
|
+
inheritFiles: ["AGENTS.md"],
|
|
1062
|
+
configSubdir: "qoder",
|
|
1063
|
+
mcpFormat: "kiro",
|
|
1064
|
+
mcpConfigFile: "settings.json",
|
|
1065
|
+
catalogFile: "settings.json",
|
|
1066
|
+
settingsBase: "settings.json",
|
|
1067
|
+
credentialFiles: [".auth/user"],
|
|
1068
|
+
identityFiles: [".auth/machine_id"]
|
|
1069
|
+
},
|
|
951
1070
|
"claude-code": {
|
|
952
1071
|
name: "claude-code",
|
|
953
1072
|
bin: "npx",
|
|
@@ -975,7 +1094,8 @@ const AUTO_PROBE_ORDER = [
|
|
|
975
1094
|
"kiro",
|
|
976
1095
|
"opencode",
|
|
977
1096
|
"gemini",
|
|
978
|
-
"deepseek"
|
|
1097
|
+
"deepseek",
|
|
1098
|
+
"qoder"
|
|
979
1099
|
];
|
|
980
1100
|
/** 预设在该平台的「可执行文件名」(用于 PATH 探测)。 */
|
|
981
1101
|
function presetExe(preset) {
|
|
@@ -1015,16 +1135,17 @@ function resolveAgent(cfg) {
|
|
|
1015
1135
|
key = "kiro";
|
|
1016
1136
|
}
|
|
1017
1137
|
const preset = ACP_AGENT_PRESETS[key];
|
|
1138
|
+
const overrideCfg = envKey && cfg?.agent?.trim() && cfg.agent.trim().toLowerCase() !== requestedKey ? null : cfg;
|
|
1018
1139
|
const shellEnv = process.env.WEMIND_BRAIN_SHELL?.trim();
|
|
1019
|
-
const shell = shellEnv !== void 0 && shellEnv !== "" ? shellEnv === "1" || shellEnv.toLowerCase() === "true" :
|
|
1020
|
-
const explicitBin = process.env.WEMIND_BRAIN_BIN?.trim() || (preset.name === "kiro" ? process.env.KIRO_CLI_BIN?.trim() : void 0) ||
|
|
1140
|
+
const shell = shellEnv !== void 0 && shellEnv !== "" ? shellEnv === "1" || shellEnv.toLowerCase() === "true" : overrideCfg?.shell ?? preset.shell ?? false;
|
|
1141
|
+
const explicitBin = process.env.WEMIND_BRAIN_BIN?.trim() || (preset.name === "kiro" ? process.env.KIRO_CLI_BIN?.trim() : void 0) || overrideCfg?.bin?.trim() || "";
|
|
1021
1142
|
const exe = presetExe(preset);
|
|
1022
|
-
const binPath = explicitBin ? locateBin(explicitBin) : onPath(exe);
|
|
1143
|
+
const binPath = explicitBin ? locateBin(explicitBin) : onPath(exe) ?? (preset.fallbackBinPaths ?? []).find((p) => fs.existsSync(p)) ?? null;
|
|
1023
1144
|
const bin = explicitBin || (shell ? process.platform === "win32" ? exe : preset.bin : binPath || preset.bin);
|
|
1024
1145
|
const available = known && binPath !== null;
|
|
1025
1146
|
const argsOverride = process.env.WEMIND_BRAIN_ARGS?.trim();
|
|
1026
|
-
const args = argsOverride ? argsOverride.split(/\s+/) :
|
|
1027
|
-
const homeEnvVar = process.env.WEMIND_BRAIN_HOME_ENV?.trim() ||
|
|
1147
|
+
const args = argsOverride ? argsOverride.split(/\s+/) : overrideCfg?.args && overrideCfg.args.length ? overrideCfg.args : preset.args;
|
|
1148
|
+
const homeEnvVar = process.env.WEMIND_BRAIN_HOME_ENV?.trim() || overrideCfg?.homeEnvVar?.trim() || preset.homeEnvVar;
|
|
1028
1149
|
const inherit = (process.env.WEMIND_BRAIN_INHERIT ?? process.env.WECHAT_BRAIN_INHERIT ?? "1") !== "0";
|
|
1029
1150
|
const realHome = process.env.WEMIND_BRAIN_REAL_HOME?.trim() || process.env.WECHAT_REAL_KIRO_HOME?.trim() || preset.realHome;
|
|
1030
1151
|
const dirsEnv = process.env.WEMIND_BRAIN_INHERIT_DIRS?.trim();
|
|
@@ -1048,7 +1169,9 @@ function resolveAgent(cfg) {
|
|
|
1048
1169
|
firstFrameMs: preset.firstFrameMs,
|
|
1049
1170
|
heartbeatMs: preset.heartbeatMs,
|
|
1050
1171
|
images: preset.images ?? true,
|
|
1051
|
-
|
|
1172
|
+
credentialFiles: preset.credentialFiles ?? [],
|
|
1173
|
+
identityFiles: preset.identityFiles ?? [],
|
|
1174
|
+
settingsBase: preset.settingsBase,
|
|
1052
1175
|
model: preset.model,
|
|
1053
1176
|
mcpViaAcp: preset.mcpViaAcp ?? false,
|
|
1054
1177
|
requestedKey,
|
|
@@ -1065,7 +1188,7 @@ function resolveAgent(cfg) {
|
|
|
1065
1188
|
function agentProblem(ag) {
|
|
1066
1189
|
if (!ag.known) return `未知大脑预设「${ag.requestedKey}」。可选:${Object.keys(ACP_AGENT_PRESETS).join("、")}(自定义二进制可用 WEMIND_BRAIN_BIN 或 brain set --bin 指定)`;
|
|
1067
1190
|
if (ag.requestedKey === "auto" && !ag.available) return `自动探测未找到可用的大脑(已试:${AUTO_PROBE_ORDER.join("、")})。请安装其中之一,或运行 \`wemind brain set <预设>\``;
|
|
1068
|
-
if (!ag.available) return ag.name === "deepseek" ? `找不到 DeepSeek 的「${ag.bin}」。桌面端通常有 dsh.cmd;把它的绝对路径传给 \`wemind brain set deepseek --bin "<...>\\dsh.cmd"\`` : `找不到大脑「${ag.name}」的二进制「${ag.bin}」。安装它,或运行 \`wemind brain set <预设>\` 切换`;
|
|
1191
|
+
if (!ag.available) return ag.name === "deepseek" ? `找不到 DeepSeek 的「${ag.bin}」。桌面端通常有 dsh.cmd;把它的绝对路径传给 \`wemind brain set deepseek --bin "<...>\\dsh.cmd"\`` : ag.name === "qoder" ? `找不到 Qoder CLI 的「${ag.bin}」。官方安装会放在 ~/.qoder/bin/qodercli/ 并加入 PATH;确认后重开终端,或 \`wemind brain set qoder --bin "<...>\\qodercli.exe"\`` : `找不到大脑「${ag.name}」的二进制「${ag.bin}」。安装它,或运行 \`wemind brain set <预设>\` 切换`;
|
|
1069
1192
|
return null;
|
|
1070
1193
|
}
|
|
1071
1194
|
//#endregion
|
|
@@ -1306,22 +1429,32 @@ function readJson$1(file) {
|
|
|
1306
1429
|
}
|
|
1307
1430
|
}
|
|
1308
1431
|
/**
|
|
1309
|
-
* 把 agent 的凭据文件同步进沙箱(如 DSH 的 `.credentials.yaml`)。
|
|
1310
|
-
*
|
|
1432
|
+
* 把 agent 的凭据文件同步进沙箱(如 DSH 的 `.credentials.yaml`、Qoder 的 `.auth/user`)。
|
|
1433
|
+
* - `newer`:首次复制;真实 home 的 token 刷新后覆盖沙箱;沙箱自己刷新过则保留沙箱版本。
|
|
1434
|
+
* - `always`:内容不同即覆盖(标识类文件,如 Qoder 的 `.auth/machine_id`——agent 在缺凭据时会自造一个,
|
|
1435
|
+
* 它比真实文件新,「新者胜」会把错的留下,导致永远认证失败)。
|
|
1311
1436
|
* 这是敏感文件,仅放在账号沙箱内;Windows 无 POSIX 600,建议专用账号 + 目录 ACL。
|
|
1312
1437
|
*/
|
|
1313
|
-
function syncCredentialFile$1() {
|
|
1314
|
-
if (!AG$1.
|
|
1315
|
-
const src = path.join(AG$1.realHome,
|
|
1316
|
-
const dst = path.join(BRAIN_CONFIG_DIR,
|
|
1438
|
+
function syncCredentialFile$1(file, mode) {
|
|
1439
|
+
if (!AG$1.realHome) return;
|
|
1440
|
+
const src = path.join(AG$1.realHome, file);
|
|
1441
|
+
const dst = path.join(BRAIN_CONFIG_DIR, file);
|
|
1317
1442
|
try {
|
|
1318
1443
|
if (!fs.existsSync(src)) return;
|
|
1319
1444
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
1445
|
+
if (mode === "always") {
|
|
1446
|
+
if (!(fs.existsSync(dst) && fs.readFileSync(src).equals(fs.readFileSync(dst)))) fs.copyFileSync(src, dst);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1320
1449
|
const s = fs.statSync(src);
|
|
1321
1450
|
const d = fs.existsSync(dst) ? fs.statSync(dst) : null;
|
|
1322
1451
|
if (!d || s.mtimeMs > d.mtimeMs + 1e3) fs.copyFileSync(src, dst);
|
|
1323
1452
|
} catch {}
|
|
1324
1453
|
}
|
|
1454
|
+
function syncCredentials$1() {
|
|
1455
|
+
for (const f of AG$1.credentialFiles) syncCredentialFile$1(f, "newer");
|
|
1456
|
+
for (const f of AG$1.identityFiles) syncCredentialFile$1(f, "always");
|
|
1457
|
+
}
|
|
1325
1458
|
function loadCatalog$1() {
|
|
1326
1459
|
return readMcpServers(readJson$1(CATALOG$1), AG$1.mcpFormat);
|
|
1327
1460
|
}
|
|
@@ -1398,10 +1531,41 @@ function syncOpencodeBase$1() {
|
|
|
1398
1531
|
fs.mkdirSync(BRAIN_CONFIG_DIR, { recursive: true });
|
|
1399
1532
|
fs.writeFileSync(BRAIN_MCP_JSON, JSON.stringify(parsed, null, 2), "utf-8");
|
|
1400
1533
|
}
|
|
1534
|
+
/** Qoder 这类「设置与 MCP 同文件」的 agent:以真实 settings.json 为基底生成沙箱设置。
|
|
1535
|
+
* 剥离必须由沙箱权威决定的键:
|
|
1536
|
+
* - `mcpServers`:MCP 只能来自沙箱 curated(防 wechat 回环);
|
|
1537
|
+
* - `enabledPlugins`:插件可自带 MCP,且沙箱不继承 `plugins/`;
|
|
1538
|
+
* - `providers` / `model`:自定义供应商(BYOK)含明文密钥,`model` 又可能指向已剥离的供应商。
|
|
1539
|
+
* 并强制 `general.enableAutoUpdate=false`:否则每个沙箱都会各下一份约 210 MB 的 CLI。 */
|
|
1540
|
+
function settingsBase$1() {
|
|
1541
|
+
let parsed = null;
|
|
1542
|
+
try {
|
|
1543
|
+
parsed = JSON.parse(fs.readFileSync(path.join(AG$1.realHome, AG$1.settingsBase), "utf-8"));
|
|
1544
|
+
} catch {}
|
|
1545
|
+
const base = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
|
|
1546
|
+
delete base.mcpServers;
|
|
1547
|
+
delete base.enabledPlugins;
|
|
1548
|
+
delete base.providers;
|
|
1549
|
+
delete base.model;
|
|
1550
|
+
const general = base.general;
|
|
1551
|
+
base.general = {
|
|
1552
|
+
...general && typeof general === "object" && !Array.isArray(general) ? general : {},
|
|
1553
|
+
enableAutoUpdate: false
|
|
1554
|
+
};
|
|
1555
|
+
return base;
|
|
1556
|
+
}
|
|
1557
|
+
function syncSettingsBase$1() {
|
|
1558
|
+
fs.mkdirSync(BRAIN_CONFIG_DIR, { recursive: true });
|
|
1559
|
+
fs.writeFileSync(BRAIN_MCP_JSON, JSON.stringify(settingsBase$1(), null, 2), "utf-8");
|
|
1560
|
+
}
|
|
1401
1561
|
/** 继承真实配置目录的「非 MCP」设置(模型/免确认等),让大脑与日常使用同款配置;mcp 段保持沙箱权威。
|
|
1402
1562
|
* `mcpViaAcp` 预设没有文件式 MCP/settings 可继承,跳过(模型走 preset.model + set_config_option)。 */
|
|
1403
1563
|
function syncSettings$1() {
|
|
1404
1564
|
if (AG$1.mcpViaAcp) return;
|
|
1565
|
+
if (AG$1.settingsBase && AG$1.realHome) {
|
|
1566
|
+
syncSettingsBase$1();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1405
1569
|
if (!AG$1.inherit || !AG$1.realHome) return;
|
|
1406
1570
|
if (AG$1.mcpFormat === "opencode") syncOpencodeBase$1();
|
|
1407
1571
|
else syncKiroSettings$1();
|
|
@@ -1411,7 +1575,7 @@ function ensureBrainHome() {
|
|
|
1411
1575
|
syncSettings$1();
|
|
1412
1576
|
applySandbox();
|
|
1413
1577
|
mirrorRealKiro$1();
|
|
1414
|
-
|
|
1578
|
+
syncCredentials$1();
|
|
1415
1579
|
}
|
|
1416
1580
|
function addMcp(name) {
|
|
1417
1581
|
if (FORBIDDEN.has(name)) return `不允许加载「${name}」(防回环)。`;
|
|
@@ -1480,6 +1644,43 @@ function spawnAgent(agent, opts = {}) {
|
|
|
1480
1644
|
});
|
|
1481
1645
|
}
|
|
1482
1646
|
//#endregion
|
|
1647
|
+
//#region src/session-map.ts
|
|
1648
|
+
/**
|
|
1649
|
+
* brainstorm/worker 共用的 session 映射键规则:`<agent>:<key>`。
|
|
1650
|
+
* 换大脑后各 agent 的会话互不覆盖,切回原大脑仍能 session/load 回原上下文。
|
|
1651
|
+
*/
|
|
1652
|
+
function sessionKey(agent, key) {
|
|
1653
|
+
return `${agent}:${key}`;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* 把磁盘上的映射归一成 `<agent>:<key>` 形式。
|
|
1657
|
+
* - 已带前缀的条目原样保留(其它 agent 的会话不动);
|
|
1658
|
+
* - 旧格式(裸 key)由「当时那个 agent」写入、已无从得知是哪个 → 归给当前 agent;
|
|
1659
|
+
* 若同名的新格式条目已存在,则保留新格式(它的归属确定),只丢掉裸条目。
|
|
1660
|
+
* migrated=true 时调用方应立即回写,避免下次换 agent 时旧条目再被继承一次。
|
|
1661
|
+
*/
|
|
1662
|
+
function normalizeSessionMap(raw, agent) {
|
|
1663
|
+
const map = {};
|
|
1664
|
+
const legacy = [];
|
|
1665
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {
|
|
1666
|
+
map,
|
|
1667
|
+
migrated: false
|
|
1668
|
+
};
|
|
1669
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1670
|
+
if (typeof v !== "string") continue;
|
|
1671
|
+
if (k.includes(":")) map[k] = v;
|
|
1672
|
+
else legacy.push([k, v]);
|
|
1673
|
+
}
|
|
1674
|
+
for (const [k, v] of legacy) {
|
|
1675
|
+
const nk = sessionKey(agent, k);
|
|
1676
|
+
if (!map[nk]) map[nk] = v;
|
|
1677
|
+
}
|
|
1678
|
+
return {
|
|
1679
|
+
map,
|
|
1680
|
+
migrated: legacy.length > 0
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
//#endregion
|
|
1483
1684
|
//#region src/acp-brain.ts
|
|
1484
1685
|
/**
|
|
1485
1686
|
* ACP 大脑:维护一个常驻的 ACP agent 进程(JSON-RPC over stdio,换行分隔)。
|
|
@@ -1546,13 +1747,17 @@ function reset$1() {
|
|
|
1546
1747
|
assembling$1.clear();
|
|
1547
1748
|
stdoutBuf$1 = "";
|
|
1548
1749
|
}
|
|
1750
|
+
const keyOf$1 = (userId) => sessionKey(AGENT$1.name, userId);
|
|
1549
1751
|
function loadSessionMap$1() {
|
|
1752
|
+
let raw;
|
|
1550
1753
|
try {
|
|
1551
|
-
|
|
1552
|
-
return j && typeof j === "object" ? j : {};
|
|
1754
|
+
raw = JSON.parse(fs.readFileSync(SESSION_MAP_FILE$1, "utf-8"));
|
|
1553
1755
|
} catch {
|
|
1554
1756
|
return {};
|
|
1555
1757
|
}
|
|
1758
|
+
const { map, migrated } = normalizeSessionMap(raw, AGENT$1.name);
|
|
1759
|
+
if (migrated) saveSessionMap$1(map);
|
|
1760
|
+
return map;
|
|
1556
1761
|
}
|
|
1557
1762
|
function saveSessionMap$1(m) {
|
|
1558
1763
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
@@ -1776,7 +1981,7 @@ async function ensureSession$1(userId) {
|
|
|
1776
1981
|
if (existing) return existing;
|
|
1777
1982
|
const mcpServers = AGENT$1.mcpViaAcp ? activeMcpServers() : [];
|
|
1778
1983
|
const map = loadSessionMap$1();
|
|
1779
|
-
const saved = map[userId];
|
|
1984
|
+
const saved = map[keyOf$1(userId)];
|
|
1780
1985
|
if (saved) {
|
|
1781
1986
|
const methods = AGENT$1.restore === "resume" ? ["resume"] : ["load", "resume"];
|
|
1782
1987
|
for (const method of methods) try {
|
|
@@ -1800,7 +2005,7 @@ async function ensureSession$1(userId) {
|
|
|
1800
2005
|
if (!res?.sessionId) throw new Error("session/new returned no sessionId");
|
|
1801
2006
|
await applyModel$1(res.sessionId);
|
|
1802
2007
|
userSessions.set(userId, res.sessionId);
|
|
1803
|
-
map[userId] = res.sessionId;
|
|
2008
|
+
map[keyOf$1(userId)] = res.sessionId;
|
|
1804
2009
|
saveSessionMap$1(map);
|
|
1805
2010
|
dlog$1(`session/new ok ${res.sessionId} mcp=[${mcpServers.map((s) => s.name).join(",")}]`);
|
|
1806
2011
|
return res.sessionId;
|
|
@@ -1859,7 +2064,7 @@ async function askBrain(userId, message, onProgress, images) {
|
|
|
1859
2064
|
/** 用户主动开新会话:丢弃旧 session(含磁盘映射),重建一个全新 session。 */
|
|
1860
2065
|
async function newSession(userId) {
|
|
1861
2066
|
const map = loadSessionMap$1();
|
|
1862
|
-
delete map[userId];
|
|
2067
|
+
delete map[keyOf$1(userId)];
|
|
1863
2068
|
saveSessionMap$1(map);
|
|
1864
2069
|
userSessions.delete(userId);
|
|
1865
2070
|
await ensureProcess$1();
|
|
@@ -2007,19 +2212,28 @@ function readJson(file) {
|
|
|
2007
2212
|
return null;
|
|
2008
2213
|
}
|
|
2009
2214
|
}
|
|
2010
|
-
/** 同 brain:把凭据文件(如 DSH 的
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
const
|
|
2215
|
+
/** 同 brain:把凭据文件(如 DSH 的 `.credentials.yaml`、Qoder 的 `.auth/user`)同步进 worker 沙箱。
|
|
2216
|
+
* `newer`=新者胜;`always`=内容不同即覆盖(标识类文件必须与真实目录一致,见 mcp-sandbox.ts)。 */
|
|
2217
|
+
function syncCredentialFile(file, mode) {
|
|
2218
|
+
if (!AG.realHome) return;
|
|
2219
|
+
const src = path.join(AG.realHome, file);
|
|
2220
|
+
const dst = path.join(WORKER_CONFIG_DIR, file);
|
|
2015
2221
|
try {
|
|
2016
2222
|
if (!fs.existsSync(src)) return;
|
|
2017
2223
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
2224
|
+
if (mode === "always") {
|
|
2225
|
+
if (!(fs.existsSync(dst) && fs.readFileSync(src).equals(fs.readFileSync(dst)))) fs.copyFileSync(src, dst);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2018
2228
|
const s = fs.statSync(src);
|
|
2019
2229
|
const d = fs.existsSync(dst) ? fs.statSync(dst) : null;
|
|
2020
2230
|
if (!d || s.mtimeMs > d.mtimeMs + 1e3) fs.copyFileSync(src, dst);
|
|
2021
2231
|
} catch {}
|
|
2022
2232
|
}
|
|
2233
|
+
function syncCredentials() {
|
|
2234
|
+
for (const f of AG.credentialFiles) syncCredentialFile(f, "newer");
|
|
2235
|
+
for (const f of AG.identityFiles) syncCredentialFile(f, "always");
|
|
2236
|
+
}
|
|
2023
2237
|
function loadCatalog() {
|
|
2024
2238
|
return readMcpServers(readJson(CATALOG), AG.mcpFormat);
|
|
2025
2239
|
}
|
|
@@ -2091,8 +2305,34 @@ function syncOpencodeBase() {
|
|
|
2091
2305
|
fs.mkdirSync(WORKER_CONFIG_DIR, { recursive: true });
|
|
2092
2306
|
fs.writeFileSync(WORKER_MCP_JSON, JSON.stringify(parsed, null, 2), "utf-8");
|
|
2093
2307
|
}
|
|
2308
|
+
/** Qoder 这类「设置与 MCP 同文件」的 agent:与 brain 相同的基底疗法(剥离 MCP/插件/自定义供应商 + 关自动更新)。 */
|
|
2309
|
+
function settingsBase() {
|
|
2310
|
+
let parsed = null;
|
|
2311
|
+
try {
|
|
2312
|
+
parsed = JSON.parse(fs.readFileSync(path.join(AG.realHome, AG.settingsBase), "utf-8"));
|
|
2313
|
+
} catch {}
|
|
2314
|
+
const base = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
|
|
2315
|
+
delete base.mcpServers;
|
|
2316
|
+
delete base.enabledPlugins;
|
|
2317
|
+
delete base.providers;
|
|
2318
|
+
delete base.model;
|
|
2319
|
+
const general = base.general;
|
|
2320
|
+
base.general = {
|
|
2321
|
+
...general && typeof general === "object" && !Array.isArray(general) ? general : {},
|
|
2322
|
+
enableAutoUpdate: false
|
|
2323
|
+
};
|
|
2324
|
+
return base;
|
|
2325
|
+
}
|
|
2326
|
+
function syncSettingsBase() {
|
|
2327
|
+
fs.mkdirSync(WORKER_CONFIG_DIR, { recursive: true });
|
|
2328
|
+
fs.writeFileSync(WORKER_MCP_JSON, JSON.stringify(settingsBase(), null, 2), "utf-8");
|
|
2329
|
+
}
|
|
2094
2330
|
function syncSettings() {
|
|
2095
2331
|
if (AG.mcpViaAcp) return;
|
|
2332
|
+
if (AG.settingsBase && AG.realHome) {
|
|
2333
|
+
syncSettingsBase();
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2096
2336
|
if (!AG.inherit || !AG.realHome) return;
|
|
2097
2337
|
if (AG.mcpFormat === "opencode") syncOpencodeBase();
|
|
2098
2338
|
else syncKiroSettings();
|
|
@@ -2148,7 +2388,7 @@ function ensureWorkerHome() {
|
|
|
2148
2388
|
syncSettings();
|
|
2149
2389
|
applyWorkerSandbox();
|
|
2150
2390
|
mirrorRealKiro();
|
|
2151
|
-
|
|
2391
|
+
syncCredentials();
|
|
2152
2392
|
}
|
|
2153
2393
|
//#endregion
|
|
2154
2394
|
//#region src/worker-acp.ts
|
|
@@ -2229,13 +2469,17 @@ function reset() {
|
|
|
2229
2469
|
assembling.clear();
|
|
2230
2470
|
stdoutBuf = "";
|
|
2231
2471
|
}
|
|
2472
|
+
const keyOf = (key) => sessionKey(AGENT.name, key);
|
|
2232
2473
|
function loadSessionMap() {
|
|
2474
|
+
let raw;
|
|
2233
2475
|
try {
|
|
2234
|
-
|
|
2235
|
-
return j && typeof j === "object" ? j : {};
|
|
2476
|
+
raw = JSON.parse(fs.readFileSync(SESSION_MAP_FILE, "utf-8"));
|
|
2236
2477
|
} catch {
|
|
2237
2478
|
return {};
|
|
2238
2479
|
}
|
|
2480
|
+
const { map, migrated } = normalizeSessionMap(raw, AGENT.name);
|
|
2481
|
+
if (migrated) saveSessionMap(map);
|
|
2482
|
+
return map;
|
|
2239
2483
|
}
|
|
2240
2484
|
function saveSessionMap(m) {
|
|
2241
2485
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
@@ -2456,7 +2700,7 @@ async function ensureSession(key) {
|
|
|
2456
2700
|
if (existing) return existing;
|
|
2457
2701
|
const mcpServers = AGENT.mcpViaAcp ? workerMcpServers() : [];
|
|
2458
2702
|
const map = loadSessionMap();
|
|
2459
|
-
const saved = map[key];
|
|
2703
|
+
const saved = map[keyOf(key)];
|
|
2460
2704
|
if (saved) {
|
|
2461
2705
|
const methods = AGENT.restore === "resume" ? ["resume"] : ["load", "resume"];
|
|
2462
2706
|
for (const method of methods) try {
|
|
@@ -2480,7 +2724,7 @@ async function ensureSession(key) {
|
|
|
2480
2724
|
if (!res?.sessionId) throw new Error("session/new returned no sessionId");
|
|
2481
2725
|
await applyModel(res.sessionId);
|
|
2482
2726
|
taskSessions.set(key, res.sessionId);
|
|
2483
|
-
map[key] = res.sessionId;
|
|
2727
|
+
map[keyOf(key)] = res.sessionId;
|
|
2484
2728
|
saveSessionMap(map);
|
|
2485
2729
|
dlog(`session/new ok ${res.sessionId} mcp=[${mcpServers.map((s) => s.name).join(",")}]`);
|
|
2486
2730
|
return res.sessionId;
|
|
@@ -2525,7 +2769,7 @@ async function askWorker(instruction, onProgress) {
|
|
|
2525
2769
|
/** 开新任务会话:丢弃旧 session(含磁盘映射),下次 askWorker 全新开始。 */
|
|
2526
2770
|
function newWorkerSession() {
|
|
2527
2771
|
const map = loadSessionMap();
|
|
2528
|
-
delete map[TASK_KEY];
|
|
2772
|
+
delete map[keyOf(TASK_KEY)];
|
|
2529
2773
|
saveSessionMap(map);
|
|
2530
2774
|
taskSessions.delete(TASK_KEY);
|
|
2531
2775
|
}
|
|
@@ -2875,6 +3119,18 @@ function sendOrdered(account, to, ctx, text, state = MessageState.FINISH, fallba
|
|
|
2875
3119
|
});
|
|
2876
3120
|
return replyChain;
|
|
2877
3121
|
}
|
|
3122
|
+
/**
|
|
3123
|
+
* 发一条「必须送达」的答复(控制指令回执、任务回执、任务结束确认、出错提示)。
|
|
3124
|
+
*
|
|
3125
|
+
* 与 sendOrdered 的唯一差别:**先计入连发额度**。服务端按「距用户上一条消息」计连续发送数,
|
|
3126
|
+
* 这类答复确实占额度;只发不计数会让 brain 回合的 budgetOf 低估真实值 → 误判「还能发」→
|
|
3127
|
+
* 撞 ret=-2 限流、结果丢失。计数后仍走同一条 replyChain,保证与进度/答案不乱序、遵守最小间隔。
|
|
3128
|
+
* 与 `setTaskPush` 同一套语义:task 推送也是「必须送达、只计数不被 awaiting 暂停」。
|
|
3129
|
+
*/
|
|
3130
|
+
function sendNotice(account, to, ctx, text, fallbackToken) {
|
|
3131
|
+
budgetOf(to).count += 1;
|
|
3132
|
+
return sendOrdered(account, to, ctx, text, MessageState.FINISH, fallbackToken);
|
|
3133
|
+
}
|
|
2878
3134
|
/** 把长回复作为 .md 文件发送(走同一串行链+节流);发文件失败则兜底回退为文本。 */
|
|
2879
3135
|
function sendMdOrdered(account, to, ctx, finalText, fallbackToken) {
|
|
2880
3136
|
replyChain = replyChain.then(async () => {
|
|
@@ -3174,21 +3430,21 @@ async function workerLoop(account, state) {
|
|
|
3174
3430
|
r = "处理指令出错:" + String(e);
|
|
3175
3431
|
}
|
|
3176
3432
|
log("control:", JSON.stringify(ctrl), "->", r.replace(/\n/g, " | "));
|
|
3177
|
-
await
|
|
3433
|
+
await sendNotice(account, from, token, r, latestToken());
|
|
3178
3434
|
continue;
|
|
3179
3435
|
}
|
|
3180
3436
|
const tcmd = item.text ? parseTaskControl(item.text) : null;
|
|
3181
3437
|
if (tcmd?.kind === "stop") {
|
|
3182
3438
|
const r = stopTask(from);
|
|
3183
3439
|
log("task: stop ->", r);
|
|
3184
|
-
await
|
|
3440
|
+
await sendNotice(account, from, token, r, latestToken());
|
|
3185
3441
|
continue;
|
|
3186
3442
|
}
|
|
3187
3443
|
if (tcmd?.kind === "toWorker") {
|
|
3188
3444
|
if (hasActiveTask(from)) {
|
|
3189
3445
|
log("task: inject(explicit):", tcmd.text.slice(0, 40));
|
|
3190
3446
|
injectTask(from, tcmd.text);
|
|
3191
|
-
} else await
|
|
3447
|
+
} else await sendNotice(account, from, token, dispatchTask(from, tcmd.text), latestToken());
|
|
3192
3448
|
continue;
|
|
3193
3449
|
}
|
|
3194
3450
|
const brainEscape = item.text ? parseBrainEscape(item.text) : null;
|
|
@@ -3202,7 +3458,7 @@ async function workerLoop(account, state) {
|
|
|
3202
3458
|
await handleMessage(account, token, from, brainText, item.imageItems, item.createTimeMs, latestToken);
|
|
3203
3459
|
} catch (e) {
|
|
3204
3460
|
log("handleMessage error:", e instanceof Error ? e.stack || e.message : String(e));
|
|
3205
|
-
await
|
|
3461
|
+
await sendNotice(account, from, token, "处理这条消息时出错了,请再发一次试试。", latestToken());
|
|
3206
3462
|
}
|
|
3207
3463
|
}
|
|
3208
3464
|
}
|
|
@@ -3340,8 +3596,50 @@ function pm2Available() {
|
|
|
3340
3596
|
}
|
|
3341
3597
|
/** 账号的 daemon 日志路径,与 run-daemon.ps1 / ecosystem 方式一致:`~/.wemind/<key>/daemon.log`。 */
|
|
3342
3598
|
function daemonLogPath(key) {
|
|
3343
|
-
|
|
3344
|
-
|
|
3599
|
+
return path.join(accountDir(key), "daemon.log");
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* 生成 pm2 的 app 声明(`{ apps: [decl] }`,写进账号目录的 `pm2.app.json`)。
|
|
3603
|
+
*
|
|
3604
|
+
* **为什么用 JSON 声明而不是把参数堆在 `pm2 start` 命令行上**:
|
|
3605
|
+
* - `restart_delay` / `stop_exit_codes` / `max_restarts` 在命令行上是 `--restart-delay` /
|
|
3606
|
+
* `--stop-exit-codes`(**变参**)/ `--max-restarts`,还要和 `-- daemon` 这个「脚本参数分隔符」
|
|
3607
|
+
* 挤在同一条命令里——解析顺序、变参吞掉后续 token 的风险都只能靠真机试;
|
|
3608
|
+
* - JSON 声明没有解析层:键值原样进 `pm2_env`(`stop_exit_codes` 在 `God.handleExit` 里既接受
|
|
3609
|
+
* 数字数组也接受数字字符串,这里给数字数组),与 `ecosystem.config.cjs` 同源同义;
|
|
3610
|
+
* - 路径全走 JSON 转义,不再依赖 shell 引号。
|
|
3611
|
+
*
|
|
3612
|
+
* 与 `ecosystem.config.cjs` 保持一致的项:app 名 `wemind-<key>`、崩溃 5s 重拉、最多 50 次、
|
|
3613
|
+
* 退出码 78(大脑配置错误)不重启、日志合并到账号 `daemon.log`。导出供单测直接断言(不需要装 pm2)。
|
|
3614
|
+
*/
|
|
3615
|
+
function buildAppDeclaration(key, cliPath, logPath) {
|
|
3616
|
+
return {
|
|
3617
|
+
name: `wemind-${key}`,
|
|
3618
|
+
script: cliPath,
|
|
3619
|
+
args: "daemon",
|
|
3620
|
+
interpreter: "node",
|
|
3621
|
+
cwd: os.homedir(),
|
|
3622
|
+
autorestart: true,
|
|
3623
|
+
restart_delay: 5e3,
|
|
3624
|
+
max_restarts: 50,
|
|
3625
|
+
stop_exit_codes: [78],
|
|
3626
|
+
out_file: logPath,
|
|
3627
|
+
error_file: logPath,
|
|
3628
|
+
merge_logs: true,
|
|
3629
|
+
watch: false,
|
|
3630
|
+
env: { WECHAT_ACCOUNT: key }
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* 把声明写进账号目录,返回文件路径。单独导出便于测试(不触碰 pm2);
|
|
3635
|
+
* `dir` 可覆盖目标目录——单测传临时目录,避免动真实 `~/.wemind`。
|
|
3636
|
+
*/
|
|
3637
|
+
function writeAppDeclaration(key, cliPath, logPath, dir = accountDir(key)) {
|
|
3638
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3639
|
+
const file = path.join(dir, "pm2.app.json");
|
|
3640
|
+
const apps = [buildAppDeclaration(key, cliPath, logPath)];
|
|
3641
|
+
fs.writeFileSync(file, JSON.stringify({ apps }, null, 2) + "\n", "utf-8");
|
|
3642
|
+
return file;
|
|
3345
3643
|
}
|
|
3346
3644
|
/** 用 pm2 启动某账号的 daemon:app 名 `wemind-<key>`,注入 WECHAT_ACCOUNT,日志归一到账号 daemon.log。 */
|
|
3347
3645
|
function pm2Start(key, cliPath) {
|
|
@@ -3349,21 +3647,23 @@ function pm2Start(key, cliPath) {
|
|
|
3349
3647
|
ok: false,
|
|
3350
3648
|
msg: "未检测到 pm2。请先安装:npm i -g pm2,再重试 `wemind start`。"
|
|
3351
3649
|
};
|
|
3352
|
-
const name = `wemind-${key}`;
|
|
3353
3650
|
const log = daemonLogPath(key);
|
|
3651
|
+
const name = `wemind-${key}`;
|
|
3354
3652
|
pm2Run(`delete ${name}`, { quiet: true });
|
|
3355
|
-
const
|
|
3653
|
+
const decl = writeAppDeclaration(key, cliPath, log);
|
|
3654
|
+
const r = pm2Run(`start "${decl}"`, { env: {
|
|
3356
3655
|
...process.env,
|
|
3357
3656
|
WECHAT_ACCOUNT: key
|
|
3358
3657
|
} });
|
|
3359
3658
|
if (r.status !== 0) return {
|
|
3360
3659
|
ok: false,
|
|
3361
|
-
msg: `pm2 启动失败(exit=${r.status}
|
|
3660
|
+
msg: `pm2 启动失败(exit=${r.status}),声明文件:${decl}`
|
|
3362
3661
|
};
|
|
3363
3662
|
pm2Run("save", { quiet: true });
|
|
3364
3663
|
return {
|
|
3365
3664
|
ok: true,
|
|
3366
|
-
msg: `已用 pm2 启动 ${name}(日志:${log})。\n
|
|
3665
|
+
msg: `已用 pm2 启动 ${name}(日志:${log})。\n 崩溃 5s 重拉、退出码 78(大脑配置错误)不再重启,与自带看门狗一致。
|
|
3666
|
+
开机自启(首次):执行 \`pm2 startup\`——Linux/macOS 按提示操作;Windows 用计划任务跑 \`pm2 resurrect\`。`
|
|
3367
3667
|
};
|
|
3368
3668
|
}
|
|
3369
3669
|
/** 停止某账号的 daemon(pm2 delete)。 */
|
|
@@ -3585,6 +3885,13 @@ async function checkAgent(ag, timeoutMs = 6e4) {
|
|
|
3585
3885
|
const env = { ...process.env };
|
|
3586
3886
|
if (ag.homeEnvVar) env[ag.homeEnvVar] = home;
|
|
3587
3887
|
for (const [k, v] of Object.entries(ag.env ?? {})) env[k] = v.replaceAll("%HOME%", home);
|
|
3888
|
+
const cfgDir = path.join(home, ag.configSubdir);
|
|
3889
|
+
for (const f of [...ag.credentialFiles, ...ag.identityFiles]) try {
|
|
3890
|
+
const src = path.join(ag.realHome ?? "", f);
|
|
3891
|
+
if (!ag.realHome || !fs.existsSync(src)) continue;
|
|
3892
|
+
fs.mkdirSync(path.dirname(path.join(cfgDir, f)), { recursive: true });
|
|
3893
|
+
fs.copyFileSync(src, path.join(cfgDir, f));
|
|
3894
|
+
} catch {}
|
|
3588
3895
|
const proc = spawnAgent(ag, {
|
|
3589
3896
|
cwd: home,
|
|
3590
3897
|
env
|
|
@@ -3690,17 +3997,23 @@ async function checkAgent(ag, timeoutMs = 6e4) {
|
|
|
3690
3997
|
//#endregion
|
|
3691
3998
|
//#region src/version.ts
|
|
3692
3999
|
/**
|
|
3693
|
-
* CLI
|
|
3694
|
-
*
|
|
3695
|
-
*
|
|
4000
|
+
* CLI 版本号与包身份(`wemind version` / `wemind --version` / help 头部)。
|
|
4001
|
+
*
|
|
4002
|
+
* **版本号唯一来源是 `package.json`**,这里在运行时读它,不再手写常量。
|
|
4003
|
+
* 为什么:以前版本号散落在 package.json 和本文件两处,`npm version` 只会改前者,
|
|
4004
|
+
* 漏改本文件会导致「发了 1.2.0、`wemind version` 还报 1.1.0」这种极难排查的漂移。
|
|
4005
|
+
* 现在 `npm run release:patch|minor|major` 一步到位,本文件自动跟着变。
|
|
4006
|
+
*
|
|
4007
|
+
* 查找逻辑在 `@wemind/wechat-ilink-core` 的 `readOwnVersion()`(与 MCP 包共用一份):
|
|
4008
|
+
* 必须把**本文件的** `import.meta.url` 传进去,否则会命中的是 core 自己的 package.json。
|
|
3696
4009
|
*/
|
|
3697
|
-
|
|
4010
|
+
/** 期望的 npm 包名。这是**独立常量**,故意不从 package.json 读——它是校验基准。 */
|
|
4011
|
+
const CLI_PKG_NAME = "@wemind/cli";
|
|
3698
4012
|
/**
|
|
3699
|
-
*
|
|
3700
|
-
*
|
|
3701
|
-
* 必须写全 `npx @wemind/cli`(见 README 与 docs/发布-wemind-npm.md)。
|
|
4013
|
+
* 包版本。正常情况下等于 `package.json` 的 `version`;
|
|
4014
|
+
* 若查找失败(不该发生),退化为可识别的占位值,单测会把它当失败信号。
|
|
3702
4015
|
*/
|
|
3703
|
-
const
|
|
4016
|
+
const CLI_VERSION = readOwnVersion(import.meta.url, "@wemind/cli") ?? "0.0.0-unknown";
|
|
3704
4017
|
/** 单行版本串(供 --version 输出):命令名 + 版本,包名放括号里便于反馈问题时对上 npm 页面。 */
|
|
3705
4018
|
function versionLine() {
|
|
3706
4019
|
return `wemind ${CLI_VERSION} (${CLI_PKG_NAME}, node ${process.versions.node}, ${process.platform}-${process.arch})`;
|
|
@@ -3716,13 +4029,6 @@ function versionLine() {
|
|
|
3716
4029
|
const BASE = process.env.WEMIND_ILINK_BASE?.trim() || "https://ilinkai.weixin.qq.com";
|
|
3717
4030
|
const STAGING = path.join(WEMIND_ROOT, ".staging");
|
|
3718
4031
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3719
|
-
function readAccount(dir) {
|
|
3720
|
-
try {
|
|
3721
|
-
return JSON.parse(fs.readFileSync(path.join(dir, "account.json"), "utf-8"));
|
|
3722
|
-
} catch {
|
|
3723
|
-
return null;
|
|
3724
|
-
}
|
|
3725
|
-
}
|
|
3726
4032
|
function readPid(dir, name) {
|
|
3727
4033
|
try {
|
|
3728
4034
|
const n = parseInt(fs.readFileSync(path.join(dir, name), "utf-8").trim(), 10);
|
|
@@ -3738,19 +4044,99 @@ function readPid(dir, name) {
|
|
|
3738
4044
|
function aliveOurs(pid, markers = CLI_PID_MARKERS) {
|
|
3739
4045
|
return isOurProcess(pid, markers);
|
|
3740
4046
|
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
4047
|
+
/** 阻塞式 sleep(CLI 是同步流程;Atomics.wait 不占 CPU、不比 spawn 一个进程贵)。 */
|
|
4048
|
+
function sleepSync(ms) {
|
|
4049
|
+
try {
|
|
4050
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
4051
|
+
} catch {}
|
|
4052
|
+
}
|
|
4053
|
+
/**
|
|
4054
|
+
* 某 pid 的全部后代(POSIX)。Windows 不需要——`taskkill /T` 自己会走进程树。
|
|
4055
|
+
* 用 `ps -A -o pid=,ppid=` 一次取全表再在内存里建树,比逐层 `pgrep -P` 少起进程。
|
|
4056
|
+
* (导出仅供单测;CLI 内部不对外。)
|
|
4057
|
+
*/
|
|
4058
|
+
function descendantPids(pid) {
|
|
4059
|
+
try {
|
|
4060
|
+
const psArgs = [[
|
|
4061
|
+
"-A",
|
|
4062
|
+
"-o",
|
|
4063
|
+
"pid=,ppid="
|
|
4064
|
+
], [
|
|
4065
|
+
"-e",
|
|
4066
|
+
"-o",
|
|
4067
|
+
"pid=,ppid="
|
|
4068
|
+
]];
|
|
4069
|
+
let stdout = "";
|
|
4070
|
+
for (const args of psArgs) {
|
|
4071
|
+
const r = spawnSync("ps", args, {
|
|
4072
|
+
encoding: "utf8",
|
|
4073
|
+
timeout: 5e3
|
|
4074
|
+
});
|
|
4075
|
+
if (r.status === 0 && r.stdout) {
|
|
4076
|
+
stdout = r.stdout;
|
|
4077
|
+
break;
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
4081
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
4082
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
4083
|
+
if (!m) continue;
|
|
4084
|
+
const child = Number(m[1]);
|
|
4085
|
+
const parent = Number(m[2]);
|
|
4086
|
+
const bucket = childrenOf.get(parent);
|
|
4087
|
+
if (bucket) bucket.push(child);
|
|
4088
|
+
else childrenOf.set(parent, [child]);
|
|
4089
|
+
}
|
|
4090
|
+
const out = [];
|
|
4091
|
+
const walk = (p) => {
|
|
4092
|
+
for (const c of childrenOf.get(p) ?? []) {
|
|
4093
|
+
out.push(c);
|
|
4094
|
+
walk(c);
|
|
4095
|
+
}
|
|
4096
|
+
};
|
|
4097
|
+
walk(pid);
|
|
4098
|
+
return out;
|
|
4099
|
+
} catch {
|
|
4100
|
+
return [];
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
/**
|
|
4104
|
+
* 杀「pid 及其整棵进程树」。
|
|
4105
|
+
*
|
|
4106
|
+
* Windows:`taskkill /T /F` 本来就连带子树。
|
|
4107
|
+
* POSIX:`process.kill(pid)` **只**结束该进程,不动它的子进程——daemon 被停后,它拉起的
|
|
4108
|
+
* ACP 大脑 / worker 会留下来继续占着会话与文件句柄(Windows 的 `taskkill /T` 会连带子树,所以这个缺陷只在 POSIX 暴露)。
|
|
4109
|
+
* 所以这里先收集后代、由叶子往根 SIGTERM(daemon 的 SIGTERM 处理器会自己清 pid 文件),
|
|
4110
|
+
* 等一小会儿再对仍存活的补 SIGKILL。
|
|
4111
|
+
*/
|
|
4112
|
+
function killProcessTrees(pids) {
|
|
4113
|
+
const unique = [...new Set(pids.filter((p) => isAlive(p)))];
|
|
4114
|
+
if (!unique.length) return;
|
|
4115
|
+
if (process.platform === "win32") {
|
|
4116
|
+
for (const pid of unique) spawnSync("taskkill", [
|
|
4117
|
+
"/PID",
|
|
4118
|
+
String(pid),
|
|
4119
|
+
"/T",
|
|
4120
|
+
"/F"
|
|
4121
|
+
], {
|
|
4122
|
+
stdio: "ignore",
|
|
4123
|
+
windowsHide: true
|
|
4124
|
+
});
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4127
|
+
const targets = [];
|
|
4128
|
+
for (const pid of unique) targets.push(...descendantPids(pid).reverse(), pid);
|
|
4129
|
+
const all = [...new Set(targets)].filter((p) => isAlive(p));
|
|
4130
|
+
for (const pid of all) try {
|
|
4131
|
+
process.kill(pid, "SIGTERM");
|
|
3753
4132
|
} catch {}
|
|
4133
|
+
sleepSync(800);
|
|
4134
|
+
for (const pid of all) {
|
|
4135
|
+
if (!isAlive(pid)) continue;
|
|
4136
|
+
try {
|
|
4137
|
+
process.kill(pid, "SIGKILL");
|
|
4138
|
+
} catch {}
|
|
4139
|
+
}
|
|
3754
4140
|
}
|
|
3755
4141
|
function openFile(p) {
|
|
3756
4142
|
try {
|
|
@@ -3787,7 +4173,10 @@ function startDaemon(key, runner) {
|
|
|
3787
4173
|
const useRunner = (runner || process.env.WEMIND_RUNNER || "builtin").toLowerCase();
|
|
3788
4174
|
console.log((useRunner === "pm2" ? pm2Start(key, cliPath) : watchdogStart(key, cliPath)).msg);
|
|
3789
4175
|
}
|
|
3790
|
-
/**
|
|
4176
|
+
/**
|
|
4177
|
+
* 停止某账号的常驻:先 pm2 delete(方案 a),再按 pid 兜底杀 wrapper/daemon/brain/worker
|
|
4178
|
+
* (兼容直接启动的)。停的是**整条链**:除列出的进程外,还连带它们的子进程树。
|
|
4179
|
+
*/
|
|
3791
4180
|
function stopDaemon(key) {
|
|
3792
4181
|
pm2Stop(key);
|
|
3793
4182
|
const dir = accountDir(key);
|
|
@@ -3807,22 +4196,30 @@ function stopDaemon(key) {
|
|
|
3807
4196
|
pid: readPid(dir, "brain.pid"),
|
|
3808
4197
|
name: "brain",
|
|
3809
4198
|
markers: brainPidMarkers(ag.bin, ag.args)
|
|
4199
|
+
},
|
|
4200
|
+
{
|
|
4201
|
+
pid: readPid(dir, "worker.pid"),
|
|
4202
|
+
name: "worker",
|
|
4203
|
+
markers: brainPidMarkers(ag.bin, ag.args)
|
|
3810
4204
|
}
|
|
3811
4205
|
];
|
|
3812
4206
|
const lines = commandLines(cand.map((c) => c.pid));
|
|
3813
4207
|
const stopped = [];
|
|
3814
4208
|
const skipped = [];
|
|
4209
|
+
const toKill = [];
|
|
3815
4210
|
for (const c of cand) {
|
|
3816
4211
|
if (!c.pid || !isAlive(c.pid)) continue;
|
|
3817
4212
|
if (matchesMarkers(lines.get(c.pid), c.markers)) {
|
|
3818
|
-
|
|
4213
|
+
toKill.push(c.pid);
|
|
3819
4214
|
stopped.push(`${c.name} ${c.pid}`);
|
|
3820
4215
|
} else skipped.push(`${c.name} ${c.pid}`);
|
|
3821
4216
|
}
|
|
4217
|
+
killProcessTrees(toKill);
|
|
3822
4218
|
for (const f of [
|
|
3823
4219
|
"wrapper.pid",
|
|
3824
4220
|
"daemon.pid",
|
|
3825
|
-
"brain.pid"
|
|
4221
|
+
"brain.pid",
|
|
4222
|
+
"worker.pid"
|
|
3826
4223
|
]) try {
|
|
3827
4224
|
fs.unlinkSync(path.join(dir, f));
|
|
3828
4225
|
} catch {}
|
|
@@ -3833,7 +4230,7 @@ function stopDaemon(key) {
|
|
|
3833
4230
|
}
|
|
3834
4231
|
/** 把 STAGING 的登录按 botId 归档 + 登记。 */
|
|
3835
4232
|
function finalizeStaging(label) {
|
|
3836
|
-
const acc =
|
|
4233
|
+
const acc = loadAccount(STAGING);
|
|
3837
4234
|
if (!acc?.botId) throw new Error(`staging 无有效登录(${STAGING})`);
|
|
3838
4235
|
const key = deriveAccountKey(acc.botId);
|
|
3839
4236
|
const dest = accountDir(key);
|
|
@@ -3933,13 +4330,13 @@ async function cmdLogin(label, autoStart, runner) {
|
|
|
3933
4330
|
}
|
|
3934
4331
|
if (s.status === "confirmed") {
|
|
3935
4332
|
if (!s.bot_token || !s.ilink_bot_id || !s.ilink_user_id) throw new Error("confirmed 但数据不全");
|
|
3936
|
-
|
|
4333
|
+
saveAccountTo(STAGING, {
|
|
3937
4334
|
botToken: s.bot_token,
|
|
3938
4335
|
botId: s.ilink_bot_id,
|
|
3939
4336
|
userId: s.ilink_user_id,
|
|
3940
4337
|
baseUrl: s.baseurl ?? BASE,
|
|
3941
4338
|
savedAt: Date.now()
|
|
3942
|
-
}
|
|
4339
|
+
});
|
|
3943
4340
|
clearSessionPause(s.ilink_bot_id);
|
|
3944
4341
|
await notifyStart({
|
|
3945
4342
|
baseUrl: s.baseurl ?? BASE,
|
|
@@ -4050,10 +4447,18 @@ async function cmdBrainCheck(key) {
|
|
|
4050
4447
|
/** brain 子命令派发:show(默认)/ set / unset / check。 */
|
|
4051
4448
|
async function cmdBrain(rest) {
|
|
4052
4449
|
const argv = [...rest];
|
|
4450
|
+
const FLAGS = /* @__PURE__ */ new Set([
|
|
4451
|
+
"--bin",
|
|
4452
|
+
"--args",
|
|
4453
|
+
"--shell",
|
|
4454
|
+
"--no-shell",
|
|
4455
|
+
"--restart"
|
|
4456
|
+
]);
|
|
4053
4457
|
const takeFlag = (name) => {
|
|
4054
4458
|
const i = argv.indexOf(name);
|
|
4055
4459
|
if (i < 0) return void 0;
|
|
4056
4460
|
const v = argv[i + 1];
|
|
4461
|
+
if (v === void 0 || FLAGS.has(v)) throw new Error(`${name} 缺少参数值`);
|
|
4057
4462
|
argv.splice(i, 2);
|
|
4058
4463
|
return v;
|
|
4059
4464
|
};
|
|
@@ -4092,7 +4497,7 @@ async function cmdBrain(rest) {
|
|
|
4092
4497
|
}
|
|
4093
4498
|
function cmdAdopt(key, label) {
|
|
4094
4499
|
if (!key) throw new Error("用法: adopt <key> [--label 名]");
|
|
4095
|
-
const acc =
|
|
4500
|
+
const acc = loadAccount(accountDir(key));
|
|
4096
4501
|
if (!acc?.botId) throw new Error(`${accountDir(key)} 无有效 account.json`);
|
|
4097
4502
|
const meta = registerAccount({
|
|
4098
4503
|
key,
|
|
@@ -4107,7 +4512,7 @@ function cmdAdopt(key, label) {
|
|
|
4107
4512
|
async function cmdLogout(sel) {
|
|
4108
4513
|
const key = resolveKey(sel);
|
|
4109
4514
|
const dir = accountDir(key);
|
|
4110
|
-
const acc =
|
|
4515
|
+
const acc = loadAccount(dir);
|
|
4111
4516
|
const stopMsg = stopDaemon(key);
|
|
4112
4517
|
if (acc?.botToken) try {
|
|
4113
4518
|
await notifyStop({
|
|
@@ -4330,7 +4735,20 @@ async function runManage(argv) {
|
|
|
4330
4735
|
console.log(`${k} 授权用户(${list.length}):\n${list.map((u) => " - " + u).join("\n") || " (空)"}`);
|
|
4331
4736
|
break;
|
|
4332
4737
|
}
|
|
4333
|
-
|
|
4738
|
+
case "help":
|
|
4739
|
+
case "--help":
|
|
4740
|
+
case "-h":
|
|
4741
|
+
case void 0:
|
|
4742
|
+
console.log(helpText());
|
|
4743
|
+
break;
|
|
4744
|
+
default:
|
|
4745
|
+
console.error(`未知命令:${cmd}`);
|
|
4746
|
+
console.error(helpText());
|
|
4747
|
+
process.exitCode = 2;
|
|
4748
|
+
}
|
|
4749
|
+
}
|
|
4750
|
+
function helpText() {
|
|
4751
|
+
return `WeMind 多账号管理(wemind v${CLI_VERSION})
|
|
4334
4752
|
用法: wemind <command> 其余:wemind version | help
|
|
4335
4753
|
|
|
4336
4754
|
login [--label 名] [--start] 一条命令完成扫码登录:自刷新二维码 + 自动归档(--start 顺带启动 daemon)
|
|
@@ -4353,8 +4771,7 @@ async function runManage(argv) {
|
|
|
4353
4771
|
allow <key|label> <userId> 授权某用户可驱动该账号 bot
|
|
4354
4772
|
revoke <key|label> <userId> 撤销某用户的授权
|
|
4355
4773
|
|
|
4356
|
-
说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label
|
|
4357
|
-
}
|
|
4774
|
+
说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label。`;
|
|
4358
4775
|
}
|
|
4359
4776
|
//#endregion
|
|
4360
4777
|
//#region src/cli.ts
|