@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/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
- const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
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 t = setTimeout(() => controller.abort(), params.timeoutMs);
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
- clearTimeout(t);
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
- } catch (err) {
207
- clearTimeout(t);
208
- throw err;
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
- const raw = await apiFetch({
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
- return parseJsonResp(await apiFetch({
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
- clearTimeout(timer);
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
- return parseJsonResp(await apiFetch({
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: DEFAULT_CONFIG_TIMEOUT_MS,
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: DEFAULT_CONFIG_TIMEOUT_MS,
406
+ timeoutMs: DEFAULT_API_TIMEOUT_MS,
348
407
  label: "sendTyping"
349
- });
408
+ })));
350
409
  }
351
- /** 通知服务端该 bot 客户端上线(best-effort,失败不阻断)。 */
352
- async function notifyStart(params) {
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: "ilink/bot/msg/notifystart",
419
+ endpoint,
357
420
  body: JSON.stringify({ base_info: buildBaseInfo() }),
358
421
  token: params.token,
359
- timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
360
- label: "notifyStart"
422
+ timeoutMs: DEFAULT_API_TIMEOUT_MS,
423
+ label: op
361
424
  });
362
425
  } catch (err) {
363
- console.error(`[wemind/wechat-ilink-core/ilink] notifyStart failed (ignored, best-effort): ${String(err)}`);
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
- try {
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
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
587
- fs.renameSync(tmp, filePath);
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(DATA_DIR, "account.json"));
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
- function loadState() {
593
- return readJson$2(path.join(DATA_DIR, "state.json")) ?? {
686
+ /** 空状态:唯一来源,避免 loadState/resetState 各写一份、将来加字段时漏改。 */
687
+ function emptyState() {
688
+ return {
594
689
  updatesBuf: "",
595
690
  contextTokens: {},
596
691
  lastMessageId: 0
597
692
  };
598
693
  }
599
- function saveState(state) {
600
- writeJson(path.join(DATA_DIR, "state.json"), state);
694
+ function loadState(dir = DATA_DIR) {
695
+ return readJson$2(path.join(dir, FILES.state)) ?? emptyState();
601
696
  }
602
- function saveQrState(state) {
603
- writeJson(path.join(DATA_DIR, "qr_login.json"), state);
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
- credentialFile: ".credentials.yaml",
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" : cfg?.shell ?? preset.shell ?? false;
1020
- const explicitBin = process.env.WEMIND_BRAIN_BIN?.trim() || (preset.name === "kiro" ? process.env.KIRO_CLI_BIN?.trim() : void 0) || cfg?.bin?.trim() || "";
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+/) : cfg?.args && cfg.args.length ? cfg.args : preset.args;
1027
- const homeEnvVar = process.env.WEMIND_BRAIN_HOME_ENV?.trim() || cfg?.homeEnvVar?.trim() || preset.homeEnvVar;
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
- credentialFile: preset.credentialFile,
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
- * 新者胜:首次复制;真实 home 的 token 刷新后覆盖沙箱;沙箱自己刷新过则保留沙箱版本。
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.credentialFile || !AG$1.realHome) return;
1315
- const src = path.join(AG$1.realHome, AG$1.credentialFile);
1316
- const dst = path.join(BRAIN_CONFIG_DIR, AG$1.credentialFile);
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
- syncCredentialFile$1();
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
- const j = JSON.parse(fs.readFileSync(SESSION_MAP_FILE$1, "utf-8"));
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 的 .credentials.yaml)新者胜地同步进 worker 沙箱。 */
2011
- function syncCredentialFile() {
2012
- if (!AG.credentialFile || !AG.realHome) return;
2013
- const src = path.join(AG.realHome, AG.credentialFile);
2014
- const dst = path.join(WORKER_CONFIG_DIR, AG.credentialFile);
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
- syncCredentialFile();
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
- const j = JSON.parse(fs.readFileSync(SESSION_MAP_FILE, "utf-8"));
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 reply(account, from, token, r, MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
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 reply(account, from, token, r, MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
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 reply(account, from, token, dispatchTask(from, tcmd.text), MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
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 reply(account, from, token, "处理这条消息时出错了,请再发一次试试。", MessageState.FINISH, latestToken()).catch((e2) => handleSendError(e2, account));
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
- const root = process.env.WEMIND_HOME?.trim() || path.join(os.homedir(), ".wemind");
3344
- return path.join(root, key, "daemon.log");
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 r = pm2Run(`start "${cliPath}" --name ${name} --interpreter node --output "${log}" --error "${log}" --merge-logs -- daemon`, { env: {
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 开机自启(首次):执行 \`pm2 startup\`——Linux/macOS 按提示操作;Windows 用计划任务跑 \`pm2 resurrect\`。`
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 版本号(`wemind version` / `wemind --version` / help 头部)。
3694
- * 与 package.json 的 version 必须一致——单测 version.test.ts 会读 package.json 校验,
3695
- * 避免「发了新版本、CLI 还报旧版本号」这种排查成本极高的小漂移。
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
- const CLI_VERSION = "1.0.0";
4010
+ /** 期望的 npm 包名。这是**独立常量**,故意不从 package.json 读——它是校验基准。 */
4011
+ const CLI_PKG_NAME = "@wemind/cli";
3698
4012
  /**
3699
- * npm 包名(与 package.json 的 name 一致,单测校验)。
3700
- * 命令名是 `wemind`,但**包名**是 `@wemind/cli`——两者不同,所以 `npx` 不能靠「唯一 bin」自动解析,
3701
- * 必须写全 `npx @wemind/cli`(见 README 与 docs/发布-wemind-npm.md)。
4013
+ * 包版本。正常情况下等于 `package.json` 的 `version`;
4014
+ * 若查找失败(不该发生),退化为可识别的占位值,单测会把它当失败信号。
3702
4015
  */
3703
- const CLI_PKG_NAME = "@wemind/cli";
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
- function killPid(pid) {
3742
- if (process.platform === "win32") spawnSync("taskkill", [
3743
- "/PID",
3744
- String(pid),
3745
- "/T",
3746
- "/F"
3747
- ], {
3748
- stdio: "ignore",
3749
- windowsHide: true
3750
- });
3751
- else try {
3752
- process.kill(pid);
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
- /** 停止某账号的常驻:先 pm2 delete(方案 a),再按 pid 兜底杀 wrapper/daemon/brain(兼容直接启动的)。 */
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
- killPid(c.pid);
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 = readAccount(STAGING);
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
- fs.writeFileSync(path.join(STAGING, "account.json"), JSON.stringify({
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
- }, null, 2), "utf-8");
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 = readAccount(accountDir(key));
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 = readAccount(dir);
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
- default: console.log(`WeMind 多账号管理(wemind v${CLI_VERSION})
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