@wemind/cli 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,4382 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import crypto, { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
5
+ import os from "node:os";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ //#region ../wechat-ilink-core/dist/config.js
9
+ /**
10
+ * iLink 应用标识。
11
+ * 原 fork 从 package.json 的 ilink_appid 字段读取(向上查找);拆为 workspace 多包后,
12
+ * 该耦合不再可靠(mcp-server 打包后会找到错误的 package.json),固化为常量,并允许 env 覆盖。
13
+ */
14
+ const ILINK_APP_ID = process.env.ILINK_APP_ID?.trim() || "bot";
15
+ //#endregion
16
+ //#region ../wechat-ilink-core/dist/api/types.js
17
+ /**
18
+ * iLink Bot API 协议类型(镜像服务端 proto:GetUpdatesReq/Resp、WeixinMessage、SendMessageReq 等)。
19
+ *
20
+ * 来源与依据:
21
+ * - 字段与 proto 含义对照官方 Tencent/openclaw-weixin `src/api/types.ts` 整理(社区/官方最权威的公开实现)。
22
+ * - 错误码(ret / errcode)语义见 `error-codes.ts`。
23
+ * - 标 `[实测]` 的注释为 WeMind 在真机使用中验证/纠正的结论(详见笔记「迭代记录与待办」)。
24
+ *
25
+ * 约定:
26
+ * - 全部走 JSON over HTTP;proto 里的 bytes 字段在 JSON 中是 **base64 字符串**。
27
+ * - 协议字段绝大多数可选(服务端按场景下发),故统一用 `?`;本地自有类型(AccountData/ServerState/QrLoginState)按实际必填。
28
+ */
29
+ /** 消息方向(proto: MessageType)。USER=入站(用户发来),BOT=出站(机器人发出)。 */
30
+ const MessageType = {
31
+ NONE: 0,
32
+ USER: 1,
33
+ BOT: 2
34
+ };
35
+ /** 消息项类型(proto: MessageItemType)。一条消息的 item_list 里每项是其中一种。 */
36
+ const MessageItemType = {
37
+ NONE: 0,
38
+ TEXT: 1,
39
+ IMAGE: 2,
40
+ VOICE: 3,
41
+ FILE: 4,
42
+ VIDEO: 5
43
+ };
44
+ /**
45
+ * 消息状态(proto: MessageState)。
46
+ * - NEW=0:新消息。
47
+ * - GENERATING=1:生成中(流式/进行中)。
48
+ * - FINISH=2:本回合结束。
49
+ * [实测] 关键:发送进度/中间消息用 **GENERATING**,仅最终答案用 **FINISH**——
50
+ * FINISH 会结束当前回合(关闭该 context 的发送窗口),若进度也用 FINISH 会提前关掉会话。
51
+ * 详见笔记里程碑 21/23 与 README「进度提示与连续发送上限」。
52
+ */
53
+ const MessageState = {
54
+ NEW: 0,
55
+ GENERATING: 1,
56
+ FINISH: 2
57
+ };
58
+ /** 上传媒体类型(proto: UploadMediaType)。getUploadUrl 的 media_type 取值。 */
59
+ const UploadMediaType = {
60
+ IMAGE: 1,
61
+ VIDEO: 2,
62
+ FILE: 3,
63
+ VOICE: 4
64
+ };
65
+ //#endregion
66
+ //#region ../wechat-ilink-core/dist/api/error-codes.js
67
+ /**
68
+ * iLink Bot API 错误码定义与分类。
69
+ *
70
+ * 权威来源:逆向自官方 Tencent/openclaw-weixin 的「iLink Bot API 协议参考」
71
+ * (corespeed-io/wechatbot `docs/protocol.md` 的 Error Codes 表,社区逆向、目前最权威的公开文档)。
72
+ *
73
+ * 两个字段,语义不同:
74
+ * - `ret` :发送/上传等 RPC 的返回码(sendmessage / getuploadurl 等响应体)。
75
+ * - `errcode`:长轮询/会话类接口的错误码(getupdates 等)。
76
+ *
77
+ * 已知错误码(截至文档):
78
+ * | 码 | 字段 | 含义 | 处理 |
79
+ * |---------------|----------|------------------------------|----------------------------------------|
80
+ * | 0 | ret | 成功 | — |
81
+ * | -2 | ret | 参数错误 (Parameter error) | 检查请求参数 |
82
+ * | -14 | errcode | 会话过期 (Session expired) | 重新登录(本项目对该账号冷却 1 小时) |
83
+ *
84
+ * ⚠️ 重要澄清(避免重蹈覆辙):
85
+ * - `ret = -2` 官方定义是**参数错误**,**不是**"context_token 按时间过期"。
86
+ * 常见诱因:context_token 失效/与当前会话不匹配、必填字段缺失/格式不对。
87
+ * - context_token 官方说明:按 `(accountId, userId)` **缓存、持久化、重用**,
88
+ * **仅在会话过期(-14)/重新登录时清除**——**没有**文档化的时间 TTL。
89
+ * - 真正"按时间/会话过期"的是 `errcode = -14`(会话级),与 -2 无关。
90
+ */
91
+ /** 发送/上传 RPC 的 `ret` 码。 */
92
+ const ILINK_RET = {
93
+ OK: 0,
94
+ /** 参数错误:检查请求(最常见为 context_token 失效/不匹配;也可能被复用为发送限流——短时连续发送过多,见 README「连续发送上限」)。 */
95
+ PARAM_ERROR: -2
96
+ };
97
+ /** 长轮询/会话类接口的 `errcode` 码。 */
98
+ const ILINK_ERRCODE = {
99
+ /** 会话过期,需重新登录。 */
100
+ SESSION_EXPIRED: -14 };
101
+ /** 把一个 ret/errcode 数值分类。 */
102
+ function classifyILinkCode(code) {
103
+ if (code === void 0 || code === null || code === 0) return "ok";
104
+ if (code === ILINK_ERRCODE.SESSION_EXPIRED) return "session_expired";
105
+ if (code === ILINK_RET.PARAM_ERROR) return "param_error";
106
+ return "unknown";
107
+ }
108
+ /** 返回可读标签 + 处理建议,用于日志与提示。 */
109
+ function describeILinkCode(code) {
110
+ switch (classifyILinkCode(code)) {
111
+ case "ok": return {
112
+ kind: "ok",
113
+ label: `ok(${code ?? 0})`,
114
+ hint: ""
115
+ };
116
+ case "session_expired": return {
117
+ kind: "session_expired",
118
+ label: "会话过期(-14)",
119
+ hint: "需重新登录"
120
+ };
121
+ case "param_error": return {
122
+ kind: "param_error",
123
+ label: "参数错误(-2)",
124
+ hint: "检查请求参数(最常见:context_token 失效/不匹配;或发送限流——短时连续发送过多)"
125
+ };
126
+ default: return {
127
+ kind: "unknown",
128
+ label: `未知错误码(${code})`,
129
+ hint: "对照 iLink 协议文档排查"
130
+ };
131
+ }
132
+ }
133
+ /** iLink 接口返回非 0 ret/errcode 时抛出的结构化错误,携带原始码与分类,便于上层按类型处理。 */
134
+ var ILinkError = class extends Error {
135
+ code;
136
+ kind;
137
+ constructor(op, code, errmsg) {
138
+ const { kind, label, hint } = describeILinkCode(code);
139
+ super(`${op} failed: ${label}${hint ? ` — ${hint}` : ""}${errmsg ? ` (${errmsg})` : ""}`);
140
+ this.name = "ILinkError";
141
+ this.code = code;
142
+ this.kind = kind;
143
+ }
144
+ };
145
+ //#endregion
146
+ //#region ../wechat-ilink-core/dist/api/client.js
147
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
148
+ const DEFAULT_API_TIMEOUT_MS = 15e3;
149
+ const QR_LONG_POLL_TIMEOUT_MS = 35e3;
150
+ const ILINK_CLIENT_VERSION = process.env.ILINK_CLIENT_VERSION?.trim() || "2.4.3";
151
+ const CHANNEL_VERSION = ILINK_CLIENT_VERSION;
152
+ /**
153
+ * iLink-App-ClientVersion: uint32 编码 0x00MMNNPP(major<<16 | minor<<8 | patch)。
154
+ * 编码方式借鉴自官方 Tencent/openclaw-weixin(MIT)。
155
+ */
156
+ function buildClientVersion(version) {
157
+ const [major = 0, minor = 0, patch = 0] = version.split(".").map((p) => parseInt(p, 10) || 0);
158
+ return (major & 255) << 16 | (minor & 255) << 8 | patch & 255;
159
+ }
160
+ const ILINK_APP_CLIENT_VERSION = String(buildClientVersion(ILINK_CLIENT_VERSION));
161
+ /** iLink 公共请求头:所有 GET/POST 都需携带,缺失会导致服务端拒绝连接。 */
162
+ function buildCommonHeaders() {
163
+ return {
164
+ "iLink-App-Id": ILINK_APP_ID,
165
+ "iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION
166
+ };
167
+ }
168
+ function ensureTrailingSlash(url) {
169
+ return url.endsWith("/") ? url : `${url}/`;
170
+ }
171
+ function randomWechatUin() {
172
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
173
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
174
+ }
175
+ function buildHeaders(opts) {
176
+ const headers = {
177
+ "Content-Type": "application/json",
178
+ AuthorizationType: "ilink_bot_token",
179
+ "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
180
+ "X-WECHAT-UIN": randomWechatUin(),
181
+ ...buildCommonHeaders()
182
+ };
183
+ if (opts.token?.trim()) headers.Authorization = `Bearer ${opts.token.trim()}`;
184
+ return headers;
185
+ }
186
+ async function apiFetch(params) {
187
+ const base = ensureTrailingSlash(params.baseUrl);
188
+ const url = new URL(params.endpoint, base);
189
+ const hdrs = buildHeaders({
190
+ token: params.token,
191
+ body: params.body
192
+ });
193
+ const controller = new AbortController();
194
+ const t = setTimeout(() => controller.abort(), params.timeoutMs);
195
+ try {
196
+ const res = await fetch(url.toString(), {
197
+ method: "POST",
198
+ headers: hdrs,
199
+ body: params.body,
200
+ signal: controller.signal
201
+ });
202
+ const rawText = await res.text();
203
+ clearTimeout(t);
204
+ if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`);
205
+ return rawText;
206
+ } catch (err) {
207
+ clearTimeout(t);
208
+ throw err;
209
+ }
210
+ }
211
+ /** 防御性解析 API JSON 响应:失败抛带 label 与响应片段的清晰错误,而非裸 SyntaxError(畸形/代理 HTML/截断响应不致以晦涩错误打断调用方)。 */
212
+ function parseJsonResp(raw, label) {
213
+ try {
214
+ return JSON.parse(raw);
215
+ } catch {
216
+ throw new Error(`${label}: response not JSON (${raw.slice(0, 120)})`);
217
+ }
218
+ }
219
+ function buildBaseInfo() {
220
+ return { channel_version: CHANNEL_VERSION };
221
+ }
222
+ async function getUpdates(params) {
223
+ const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
224
+ try {
225
+ return parseJsonResp(await apiFetch({
226
+ baseUrl: params.baseUrl,
227
+ endpoint: "ilink/bot/getupdates",
228
+ body: JSON.stringify({
229
+ get_updates_buf: params.updatesBuf ?? "",
230
+ base_info: buildBaseInfo()
231
+ }),
232
+ token: params.token,
233
+ timeoutMs: timeout,
234
+ label: "getUpdates"
235
+ }), "getUpdates");
236
+ } catch (err) {
237
+ if (err instanceof Error && err.name === "AbortError") return {
238
+ ret: 0,
239
+ msgs: [],
240
+ get_updates_buf: params.updatesBuf
241
+ };
242
+ throw err;
243
+ }
244
+ }
245
+ async function sendMessage(params) {
246
+ const raw = await apiFetch({
247
+ baseUrl: params.baseUrl,
248
+ endpoint: "ilink/bot/sendmessage",
249
+ body: JSON.stringify({
250
+ ...params.body,
251
+ base_info: buildBaseInfo()
252
+ }),
253
+ token: params.token,
254
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
255
+ 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);
265
+ }
266
+ /** 取 CDN 上传地址(发图/发文件用)。返回 upload_full_url 或 upload_param。 */
267
+ async function getUploadUrl(params) {
268
+ return parseJsonResp(await apiFetch({
269
+ baseUrl: params.baseUrl,
270
+ endpoint: "ilink/bot/getuploadurl",
271
+ body: JSON.stringify({
272
+ filekey: params.filekey,
273
+ media_type: params.media_type,
274
+ to_user_id: params.to_user_id,
275
+ rawsize: params.rawsize,
276
+ rawfilemd5: params.rawfilemd5,
277
+ filesize: params.filesize,
278
+ no_need_thumb: params.no_need_thumb,
279
+ aeskey: params.aeskey,
280
+ base_info: buildBaseInfo()
281
+ }),
282
+ token: params.token,
283
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
284
+ label: "getUploadUrl"
285
+ }), "getUploadUrl");
286
+ }
287
+ async function fetchQRCode(apiBaseUrl) {
288
+ return parseJsonResp(await apiFetch({
289
+ baseUrl: apiBaseUrl,
290
+ endpoint: "ilink/bot/get_bot_qrcode?bot_type=3",
291
+ body: JSON.stringify({ local_token_list: [] }),
292
+ timeoutMs: DEFAULT_API_TIMEOUT_MS,
293
+ label: "fetchQRCode"
294
+ }), "fetchQRCode");
295
+ }
296
+ async function pollQRStatus(apiBaseUrl, qrcode) {
297
+ const base = ensureTrailingSlash(apiBaseUrl);
298
+ const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
299
+ const controller = new AbortController();
300
+ const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
301
+ try {
302
+ const response = await fetch(url.toString(), {
303
+ headers: buildCommonHeaders(),
304
+ signal: controller.signal
305
+ });
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
+ const text = await response.text();
312
+ clearTimeout(timer);
313
+ return parseJsonResp(text, "pollQRStatus");
314
+ } catch (err) {
315
+ clearTimeout(timer);
316
+ if (err instanceof Error && err.name === "AbortError") return { status: "wait" };
317
+ throw err;
318
+ }
319
+ }
320
+ function generateId(prefix) {
321
+ return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
322
+ }
323
+ const DEFAULT_CONFIG_TIMEOUT_MS = 15e3;
324
+ async function getConfig(params) {
325
+ return parseJsonResp(await apiFetch({
326
+ baseUrl: params.baseUrl,
327
+ endpoint: "ilink/bot/getconfig",
328
+ body: JSON.stringify({
329
+ ilink_user_id: params.ilinkUserId,
330
+ context_token: params.contextToken,
331
+ base_info: buildBaseInfo()
332
+ }),
333
+ token: params.token,
334
+ timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
335
+ label: "getConfig"
336
+ }), "getConfig");
337
+ }
338
+ async function sendTyping(params) {
339
+ await apiFetch({
340
+ baseUrl: params.baseUrl,
341
+ endpoint: "ilink/bot/sendtyping",
342
+ body: JSON.stringify({
343
+ ...params.body,
344
+ base_info: buildBaseInfo()
345
+ }),
346
+ token: params.token,
347
+ timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
348
+ label: "sendTyping"
349
+ });
350
+ }
351
+ /** 通知服务端该 bot 客户端上线(best-effort,失败不阻断)。 */
352
+ async function notifyStart(params) {
353
+ try {
354
+ await apiFetch({
355
+ baseUrl: params.baseUrl,
356
+ endpoint: "ilink/bot/msg/notifystart",
357
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
358
+ token: params.token,
359
+ timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
360
+ label: "notifyStart"
361
+ });
362
+ } catch (err) {
363
+ console.error(`[wemind/wechat-ilink-core/ilink] notifyStart failed (ignored, best-effort): ${String(err)}`);
364
+ }
365
+ }
366
+ /** 通知服务端该 bot 客户端下线(best-effort,失败不阻断)。 */
367
+ 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
+ }
380
+ }
381
+ //#endregion
382
+ //#region ../wechat-ilink-core/dist/api/transport-errors.js
383
+ /**
384
+ * 传输层错误分类:把 fetch/网络层抛出的错误归类,判定是否可重试。
385
+ * 与 error-codes.ts(iLink 业务 ret/errcode)正交——这里只管「网络传输」层面的失败。
386
+ * 借鉴 wxclawbot-cli(MIT)client.ts 的 classifyTransportError 思路。
387
+ */
388
+ /** 归类一个传输层错误。timeout(AbortError)一律可重试。 */
389
+ function classifyTransportError(err) {
390
+ if (err instanceof Error && err.name === "AbortError") return {
391
+ kind: "timeout",
392
+ retryable: true,
393
+ message: err.message || "request timeout"
394
+ };
395
+ const message = err instanceof Error ? err.message : String(err);
396
+ const code = err && typeof err === "object" && "code" in err && typeof err.code === "string" ? err.code : void 0;
397
+ const cause = err instanceof Error && err.cause != null ? String(err.cause) : void 0;
398
+ const s = `${message} ${code ?? ""} ${cause ?? ""}`.toLowerCase();
399
+ if (s.includes("enotfound") || s.includes("dns")) return {
400
+ kind: "dns",
401
+ retryable: true,
402
+ message,
403
+ code
404
+ };
405
+ if (s.includes("econnreset") || s.includes("socket hang up")) return {
406
+ kind: "connection",
407
+ retryable: true,
408
+ message,
409
+ code
410
+ };
411
+ if (s.includes("cert") || s.includes("tls") || s.includes("ssl")) return {
412
+ kind: "tls",
413
+ retryable: false,
414
+ message,
415
+ code
416
+ };
417
+ if (s.includes("econnrefused") || s.includes("ehostunreach") || s.includes("enetunreach") || s.includes("etimedout") || s.includes("network") || s.includes("fetch failed")) return {
418
+ kind: "network",
419
+ retryable: true,
420
+ message,
421
+ code
422
+ };
423
+ return {
424
+ kind: "unknown",
425
+ retryable: false,
426
+ message,
427
+ code
428
+ };
429
+ }
430
+ //#endregion
431
+ //#region ../wechat-ilink-core/dist/api/session-guard.js
432
+ const PAUSE_MS = 3600 * 1e3;
433
+ const pauseUntil = /* @__PURE__ */ new Map();
434
+ /** 命中 -14 后暂停该账号一小时。 */
435
+ function pauseSession(accountId) {
436
+ pauseUntil.set(accountId, Date.now() + PAUSE_MS);
437
+ }
438
+ /** 距离冷却结束的剩余毫秒数(未暂停时为 0)。 */
439
+ function getRemainingPauseMs(accountId) {
440
+ const until = pauseUntil.get(accountId);
441
+ if (until === void 0) return 0;
442
+ const remaining = until - Date.now();
443
+ if (remaining <= 0) {
444
+ pauseUntil.delete(accountId);
445
+ return 0;
446
+ }
447
+ return remaining;
448
+ }
449
+ function isSessionPaused(accountId) {
450
+ return getRemainingPauseMs(accountId) > 0;
451
+ }
452
+ /** 重新登录成功后清除冷却。 */
453
+ function clearSessionPause(accountId) {
454
+ pauseUntil.delete(accountId);
455
+ }
456
+ //#endregion
457
+ //#region ../wechat-ilink-core/dist/store/accounts.js
458
+ /**
459
+ * 账号注册表:管理 ~/.wemind 下的多账号清单。
460
+ * - 每个账号一个独立子目录(key = 由 botId 派生的唯一标识,见 deriveAccountKey)。
461
+ * - accounts.json 记录 key → 元数据(botId/userId/baseUrl/label/addedAt)与默认账号。
462
+ * label 为人类可读别名(默认等于 key,可改),folder/key 始终唯一且由微信 botId 派生。
463
+ */
464
+ /** WeMind 根目录(所有账号子目录与注册表的父目录)。可用 WEMIND_HOME 覆盖。 */
465
+ const WEMIND_ROOT = process.env.WEMIND_HOME?.trim() || path.join(os.homedir(), ".wemind");
466
+ const REGISTRY_FILE = path.join(WEMIND_ROOT, "accounts.json");
467
+ /** 把 botId(如 feead781eb44@im.bot)净化成可作目录名的唯一 key(feead781eb44)。 */
468
+ function deriveAccountKey(botId) {
469
+ return ((botId || "").split("@")[0] || botId || "").replace(/[^a-zA-Z0-9_-]/g, "").trim() || "account";
470
+ }
471
+ /** 某账号的状态目录:~/.wemind/<key>。 */
472
+ function accountDir(key) {
473
+ return path.join(WEMIND_ROOT, key);
474
+ }
475
+ function loadRegistry() {
476
+ let raw;
477
+ try {
478
+ raw = fs.readFileSync(REGISTRY_FILE, "utf-8");
479
+ } catch {
480
+ return { accounts: {} };
481
+ }
482
+ try {
483
+ const j = JSON.parse(raw);
484
+ if (j && typeof j === "object" && j.accounts) return j;
485
+ throw new Error("registry shape invalid");
486
+ } catch (e) {
487
+ console.error(`[wemind/wechat-ilink-core/accounts] ${REGISTRY_FILE} parse failed, backing up: ${String(e)}`);
488
+ try {
489
+ fs.renameSync(REGISTRY_FILE, `${REGISTRY_FILE}.corrupt-${Date.now()}`);
490
+ } catch {}
491
+ return { accounts: {} };
492
+ }
493
+ }
494
+ /** 原子写注册表:临时文件 + rename,避免写途崩溃/并发损坏。 */
495
+ function saveRegistry(reg) {
496
+ fs.mkdirSync(WEMIND_ROOT, { recursive: true });
497
+ const tmp = `${REGISTRY_FILE}.tmp-${process.pid}-${Date.now()}`;
498
+ fs.writeFileSync(tmp, JSON.stringify(reg, null, 2), "utf-8");
499
+ fs.renameSync(tmp, REGISTRY_FILE);
500
+ }
501
+ function listAccounts() {
502
+ const reg = loadRegistry();
503
+ return Object.values(reg.accounts).sort((a, b) => a.addedAt - b.addedAt);
504
+ }
505
+ function getDefaultKey() {
506
+ const reg = loadRegistry();
507
+ if (reg.default && reg.accounts[reg.default]) return reg.default;
508
+ const keys = Object.keys(reg.accounts);
509
+ return keys.length ? keys[0] : void 0;
510
+ }
511
+ function setDefaultKey(key) {
512
+ const reg = loadRegistry();
513
+ if (!reg.accounts[key]) throw new Error(`account not found: ${key}`);
514
+ reg.default = key;
515
+ saveRegistry(reg);
516
+ }
517
+ /** 登记/更新一个账号;首个账号自动设为默认。 */
518
+ function registerAccount(meta) {
519
+ const reg = loadRegistry();
520
+ const existing = reg.accounts[meta.key];
521
+ const full = {
522
+ key: meta.key,
523
+ botId: meta.botId,
524
+ userId: meta.userId,
525
+ baseUrl: meta.baseUrl,
526
+ label: meta.label || existing?.label || meta.key,
527
+ addedAt: existing?.addedAt ?? meta.addedAt ?? Date.now()
528
+ };
529
+ reg.accounts[meta.key] = full;
530
+ if (!reg.default) reg.default = meta.key;
531
+ saveRegistry(reg);
532
+ return full;
533
+ }
534
+ /** 移除注册表中的账号条目(不删除磁盘目录,由调用方决定)。 */
535
+ function unregisterAccount(key) {
536
+ const reg = loadRegistry();
537
+ delete reg.accounts[key];
538
+ if (reg.default === key) {
539
+ const rest = Object.keys(reg.accounts);
540
+ reg.default = rest.length ? rest[0] : void 0;
541
+ }
542
+ saveRegistry(reg);
543
+ }
544
+ /** 设置/修改某账号的人类可读别名。 */
545
+ function setLabel(key, label) {
546
+ const reg = loadRegistry();
547
+ if (!reg.accounts[key]) throw new Error(`account not found: ${key}`);
548
+ reg.accounts[key].label = label;
549
+ saveRegistry(reg);
550
+ }
551
+ //#endregion
552
+ //#region ../wechat-ilink-core/dist/store/account.js
553
+ /**
554
+ * 数据目录解析(按优先级):
555
+ * 1. WECHAT_DATA_DIR:显式指定完整路径(最高优先级,便于任意自定义/多账号)。
556
+ * 2. 否则 ~/.wemind/<account>,其中 <account> 取自 WECHAT_ACCOUNT,默认 "default"。
557
+ * account.json / state.json / brain-sessions.json / brain-mcps.json / brain-home 等都落在此目录下。
558
+ * 多账号即「不同 WECHAT_ACCOUNT(或 WECHAT_DATA_DIR)→ 不同目录 → 互相隔离」。
559
+ */
560
+ const ACCOUNT_KEY = process.env.WECHAT_ACCOUNT?.trim() || "default";
561
+ const DATA_DIR = process.env.WECHAT_DATA_DIR?.trim() || path.join(WEMIND_ROOT, ACCOUNT_KEY);
562
+ function ensureDir(dir) {
563
+ fs.mkdirSync(dir, { recursive: true });
564
+ }
565
+ function readJson$2(filePath) {
566
+ let raw;
567
+ try {
568
+ raw = fs.readFileSync(filePath, "utf-8");
569
+ } catch {
570
+ return null;
571
+ }
572
+ try {
573
+ return JSON.parse(raw);
574
+ } catch (e) {
575
+ console.error(`[wemind/wechat-ilink-core/store] ${filePath} parse failed, backing up and treating as empty: ${String(e)}`);
576
+ try {
577
+ fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
578
+ } catch {}
579
+ return null;
580
+ }
581
+ }
582
+ /** 原子写:先写临时文件再 rename 覆盖(同盘 rename 原子),避免写途崩溃/并发产生半截损坏文件。 */
583
+ function writeJson(filePath, data) {
584
+ ensureDir(path.dirname(filePath));
585
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
586
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
587
+ fs.renameSync(tmp, filePath);
588
+ }
589
+ function loadAccount() {
590
+ return readJson$2(path.join(DATA_DIR, "account.json"));
591
+ }
592
+ function loadState() {
593
+ return readJson$2(path.join(DATA_DIR, "state.json")) ?? {
594
+ updatesBuf: "",
595
+ contextTokens: {},
596
+ lastMessageId: 0
597
+ };
598
+ }
599
+ function saveState(state) {
600
+ writeJson(path.join(DATA_DIR, "state.json"), state);
601
+ }
602
+ function saveQrState(state) {
603
+ writeJson(path.join(DATA_DIR, "qr_login.json"), state);
604
+ }
605
+ //#endregion
606
+ //#region ../wechat-ilink-core/dist/store/allow-from.js
607
+ function filePath(dir = DATA_DIR) {
608
+ return path.join(dir, "allowFrom.json");
609
+ }
610
+ function readAllowFrom(dir = DATA_DIR) {
611
+ let raw;
612
+ try {
613
+ raw = fs.readFileSync(filePath(dir), "utf-8");
614
+ } catch {
615
+ return [];
616
+ }
617
+ try {
618
+ const j = JSON.parse(raw);
619
+ return Array.isArray(j.allowFrom) ? j.allowFrom.filter((x) => typeof x === "string" && !!x) : [];
620
+ } catch (e) {
621
+ console.error(`[wemind/wechat-ilink-core/allow-from] ${filePath(dir)} parse failed, backing up: ${String(e)}`);
622
+ try {
623
+ fs.renameSync(filePath(dir), `${filePath(dir)}.corrupt-${Date.now()}`);
624
+ } catch {}
625
+ return [];
626
+ }
627
+ }
628
+ /** 原子写:临时文件 + rename,避免写途崩溃/并发损坏这个安全闸文件。 */
629
+ function writeAllowFrom(list, dir = DATA_DIR) {
630
+ fs.mkdirSync(dir, { recursive: true });
631
+ const uniq = [...new Set(list.filter((x) => typeof x === "string" && !!x))];
632
+ const fp = filePath(dir);
633
+ const tmp = `${fp}.tmp-${process.pid}-${Date.now()}`;
634
+ fs.writeFileSync(tmp, JSON.stringify({
635
+ version: 1,
636
+ allowFrom: uniq
637
+ }, null, 2), "utf-8");
638
+ fs.renameSync(tmp, fp);
639
+ }
640
+ /** 加入授权用户(去重)。返回更新后的清单。 */
641
+ function addAllowFrom(userId, dir = DATA_DIR) {
642
+ const list = readAllowFrom(dir);
643
+ if (userId && !list.includes(userId)) list.push(userId);
644
+ writeAllowFrom(list, dir);
645
+ return list;
646
+ }
647
+ /** 移除授权用户。返回更新后的清单。 */
648
+ function removeAllowFrom(userId, dir = DATA_DIR) {
649
+ const list = readAllowFrom(dir).filter((x) => x !== userId);
650
+ writeAllowFrom(list, dir);
651
+ return list;
652
+ }
653
+ /** 该 userId 是否被授权。 */
654
+ function isAllowed(userId, dir = DATA_DIR) {
655
+ return !!userId && readAllowFrom(dir).includes(userId);
656
+ }
657
+ //#endregion
658
+ //#region ../wechat-ilink-core/dist/cdn/image.js
659
+ /**
660
+ * 收图:从 iLink CDN 下载并 AES-128-ECB 解密图片。
661
+ * 机制借鉴官方 Tencent/openclaw-weixin(MIT)的 cdn/pic-decrypt + cdn-url。
662
+ */
663
+ const CDN_BASE_URL = process.env.WECHAT_CDN_BASE_URL?.trim() || "https://novac2c.cdn.weixin.qq.com/c2c";
664
+ function decryptAesEcb(ciphertext, key) {
665
+ const d = createDecipheriv("aes-128-ecb", key, null);
666
+ return Buffer.concat([d.update(ciphertext), d.final()]);
667
+ }
668
+ /** 解析图片 AES key → 16 字节。aeskey 为 hex;media.aes_key 为 base64(可能是 base64(hex 字符串))。 */
669
+ function resolveKey$1(img) {
670
+ if (img.aeskey && /^[0-9a-fA-F]{32}$/.test(img.aeskey)) return Buffer.from(img.aeskey, "hex");
671
+ const b64 = img.media?.aes_key;
672
+ if (b64) {
673
+ const decoded = Buffer.from(b64, "base64");
674
+ if (decoded.length === 16) return decoded;
675
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) return Buffer.from(decoded.toString("ascii"), "hex");
676
+ }
677
+ return null;
678
+ }
679
+ /** 下载并解密一张图片,返回明文 Buffer(失败抛错)。 */
680
+ async function downloadImage(img) {
681
+ const url = img.media?.full_url || (img.media?.encrypt_query_param ? `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(img.media.encrypt_query_param)}` : "");
682
+ if (!url) throw new Error("image: 无 full_url/encrypt_query_param");
683
+ const key = resolveKey$1(img);
684
+ if (!key) throw new Error("image: 无法解析 aes key");
685
+ const res = await fetch(url, { signal: AbortSignal.timeout(2e4) });
686
+ if (!res.ok) throw new Error(`image: CDN 下载失败 ${res.status}`);
687
+ return decryptAesEcb(Buffer.from(await res.arrayBuffer()), key);
688
+ }
689
+ /** AES-128-ECB 加密(PKCS7 默认)。 */
690
+ function encryptAesEcb(plaintext, key) {
691
+ const c = createCipheriv("aes-128-ecb", key, null);
692
+ return Buffer.concat([c.update(plaintext), c.final()]);
693
+ }
694
+ /** AES-128-ECB 密文大小(PKCS7 补齐到 16 字节边界)。 */
695
+ function aesEcbPaddedSize(plaintextSize) {
696
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
697
+ }
698
+ /** 加密并上传一段 buffer 到 CDN,返回下载用的 encrypt_query_param(响应头 x-encrypted-param)。 */
699
+ async function uploadBufferToCdn(params) {
700
+ const { buf, aeskey, filekey, uploadFullUrl, uploadParam } = params;
701
+ const ciphertext = encryptAesEcb(buf, aeskey);
702
+ const url = uploadFullUrl?.trim() || (uploadParam ? `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}` : "");
703
+ if (!url) throw new Error("upload: 无 upload_full_url/upload_param");
704
+ let lastErr;
705
+ for (let attempt = 1; attempt <= 3; attempt++) try {
706
+ const res = await fetch(url, {
707
+ method: "POST",
708
+ headers: { "Content-Type": "application/octet-stream" },
709
+ body: new Uint8Array(ciphertext),
710
+ signal: AbortSignal.timeout(3e4)
711
+ });
712
+ if (res.status >= 400 && res.status < 500) throw new Error(`CDN 上传客户端错误 ${res.status}`);
713
+ if (res.status !== 200) throw new Error(`CDN 上传服务端错误 ${res.status}`);
714
+ const dp = res.headers.get("x-encrypted-param");
715
+ if (!dp) throw new Error("CDN 上传响应缺 x-encrypted-param");
716
+ return dp;
717
+ } catch (e) {
718
+ lastErr = e;
719
+ if (e instanceof Error && e.message.includes("客户端错误")) throw e;
720
+ if (attempt < 3) continue;
721
+ }
722
+ throw lastErr instanceof Error ? lastErr : /* @__PURE__ */ new Error("CDN 上传失败");
723
+ }
724
+ /** getUploadUrl + 加密上传,返回媒体引用所需信息。media_type 见 UploadMediaType。 */
725
+ async function uploadMediaBytes(params) {
726
+ const { baseUrl, token, to, data, mediaType } = params;
727
+ const rawsize = data.length;
728
+ const rawfilemd5 = createHash("md5").update(data).digest("hex");
729
+ const filesize = aesEcbPaddedSize(rawsize);
730
+ const filekey = randomBytes(16).toString("hex");
731
+ const aeskey = randomBytes(16);
732
+ const up = await getUploadUrl({
733
+ baseUrl,
734
+ token,
735
+ filekey,
736
+ media_type: mediaType,
737
+ to_user_id: to,
738
+ rawsize,
739
+ rawfilemd5,
740
+ filesize,
741
+ no_need_thumb: true,
742
+ aeskey: aeskey.toString("hex")
743
+ });
744
+ const code = up.ret ?? up.errcode;
745
+ if (code !== void 0 && code !== 0) throw new ILinkError("getUploadUrl", code, up.errmsg);
746
+ return {
747
+ downloadParam: await uploadBufferToCdn({
748
+ buf: data,
749
+ aeskey,
750
+ filekey,
751
+ uploadFullUrl: up.upload_full_url,
752
+ uploadParam: up.upload_param
753
+ }),
754
+ aeskeyHex: aeskey.toString("hex"),
755
+ filesize,
756
+ rawsize
757
+ };
758
+ }
759
+ /** 拼 CDN media 引用(消息里给接收方下载+解密用)。 */
760
+ function buildMediaRef(downloadParam, aeskeyHex) {
761
+ return {
762
+ encrypt_query_param: downloadParam,
763
+ aes_key: Buffer.from(aeskeyHex).toString("base64"),
764
+ encrypt_type: 1
765
+ };
766
+ }
767
+ const IMAGE_EXTS = /* @__PURE__ */ new Set([
768
+ ".png",
769
+ ".jpg",
770
+ ".jpeg",
771
+ ".gif",
772
+ ".webp",
773
+ ".bmp"
774
+ ]);
775
+ const VIDEO_EXTS = /* @__PURE__ */ new Set([
776
+ ".mp4",
777
+ ".mov",
778
+ ".webm",
779
+ ".mkv",
780
+ ".avi"
781
+ ]);
782
+ /** 按文件名扩展名判定媒体类别(借鉴 wxclawbot media.ts classifyMedia)。其余一律按文件发送。 */
783
+ function classifyMediaByName(name) {
784
+ const i = name.lastIndexOf(".");
785
+ const ext = i >= 0 ? name.slice(i).toLowerCase() : "";
786
+ if (IMAGE_EXTS.has(ext)) return "image";
787
+ if (VIDEO_EXTS.has(ext)) return "video";
788
+ return "file";
789
+ }
790
+ const UPLOAD_TYPE_BY_KIND = {
791
+ image: UploadMediaType.IMAGE,
792
+ video: UploadMediaType.VIDEO,
793
+ file: UploadMediaType.FILE
794
+ };
795
+ /** 主动发一张图片给用户(需 context_token,与发文本相同)。image 为图片明文 buffer。 */
796
+ async function sendImage(params) {
797
+ const { baseUrl, token, to, image, contextToken } = params;
798
+ const { downloadParam, aeskeyHex, filesize } = await uploadMediaBytes({
799
+ baseUrl,
800
+ token,
801
+ to,
802
+ data: image,
803
+ mediaType: UploadMediaType.IMAGE
804
+ });
805
+ await sendMessage({
806
+ baseUrl,
807
+ token,
808
+ body: { msg: {
809
+ from_user_id: "",
810
+ to_user_id: to,
811
+ client_id: generateId("img"),
812
+ message_type: MessageType.BOT,
813
+ message_state: MessageState.FINISH,
814
+ item_list: [{
815
+ type: MessageItemType.IMAGE,
816
+ image_item: {
817
+ media: buildMediaRef(downloadParam, aeskeyHex),
818
+ mid_size: filesize
819
+ }
820
+ }],
821
+ context_token: contextToken
822
+ } }
823
+ });
824
+ }
825
+ /**
826
+ * 主动发一个文件/视频/图片给用户(需 context_token)。
827
+ * 类别按 fileName 扩展名自动判定(image/video/file),也可用 kind 显式指定。
828
+ * 借鉴 wxclawbot media.ts 的类型识别与 item 组装;上传链路复用 WeMind 自有实现。
829
+ */
830
+ async function sendFile(params) {
831
+ const { baseUrl, token, to, data, fileName, contextToken } = params;
832
+ const kind = params.kind ?? classifyMediaByName(fileName);
833
+ const { downloadParam, aeskeyHex, filesize, rawsize } = await uploadMediaBytes({
834
+ baseUrl,
835
+ token,
836
+ to,
837
+ data,
838
+ mediaType: UPLOAD_TYPE_BY_KIND[kind]
839
+ });
840
+ const media = buildMediaRef(downloadParam, aeskeyHex);
841
+ let item;
842
+ if (kind === "image") item = {
843
+ type: MessageItemType.IMAGE,
844
+ image_item: {
845
+ media,
846
+ mid_size: filesize
847
+ }
848
+ };
849
+ else if (kind === "video") item = {
850
+ type: MessageItemType.VIDEO,
851
+ video_item: {
852
+ media,
853
+ video_size: filesize
854
+ }
855
+ };
856
+ else item = {
857
+ type: MessageItemType.FILE,
858
+ file_item: {
859
+ media,
860
+ file_name: fileName,
861
+ len: String(rawsize)
862
+ }
863
+ };
864
+ await sendMessage({
865
+ baseUrl,
866
+ token,
867
+ body: { msg: {
868
+ from_user_id: "",
869
+ to_user_id: to,
870
+ client_id: generateId("media"),
871
+ message_type: MessageType.BOT,
872
+ message_state: MessageState.FINISH,
873
+ item_list: [item],
874
+ context_token: contextToken
875
+ } }
876
+ });
877
+ }
878
+ //#endregion
879
+ //#region src/brain-agents.ts
880
+ /**
881
+ * 已知 ACP agent 预设。
882
+ * - `kiro` 为默认且**已实测**。
883
+ * - 其余均为**示例(未实测)**:参数/包名以各自官方为准,接入前请核对(见 docs/接入其它-ACP-Agent.md)。
884
+ * - 区别两类:① **原生 ACP**(CLI 自带 ACP 模式,直接起,如 kiro、gemini);
885
+ * ② **需适配器**(CLI 本身不说 ACP,要起一个适配器进程,如 Claude Code、Codex)。
886
+ */
887
+ const ACP_AGENT_PRESETS = {
888
+ kiro: {
889
+ name: "kiro",
890
+ bin: "kiro-cli",
891
+ binWin: "kiro-cli.exe",
892
+ args: ["acp", "--trust-all-tools"],
893
+ homeEnvVar: "KIRO_HOME",
894
+ realHome: path.join(os.homedir(), ".kiro"),
895
+ inheritDirs: [
896
+ "steering",
897
+ "memories",
898
+ "skills",
899
+ "agents",
900
+ "powers",
901
+ "tasks",
902
+ "extensions"
903
+ ]
904
+ },
905
+ gemini: {
906
+ name: "gemini",
907
+ bin: "gemini",
908
+ binWin: "gemini.cmd",
909
+ args: ["--experimental-acp"],
910
+ homeEnvVar: "GEMINI_CLI_HOME",
911
+ shell: true
912
+ },
913
+ opencode: {
914
+ name: "opencode",
915
+ bin: "opencode",
916
+ binWin: "opencode",
917
+ args: ["acp"],
918
+ shell: true,
919
+ homeEnvVar: "XDG_CONFIG_HOME",
920
+ realHome: path.join(os.homedir(), ".config", "opencode"),
921
+ inheritDirs: [
922
+ "skills",
923
+ "agents",
924
+ "commands"
925
+ ],
926
+ inheritFiles: ["AGENTS.md"],
927
+ configSubdir: "opencode",
928
+ mcpFormat: "opencode",
929
+ mcpConfigFile: "opencode.json",
930
+ catalogFile: "opencode.json",
931
+ env: { OPENCODE_DISABLE_PROJECT_CONFIG: "1" }
932
+ },
933
+ deepseek: {
934
+ name: "deepseek",
935
+ bin: "dsh",
936
+ binWin: "dsh.cmd",
937
+ args: ["--profile", "acp"],
938
+ shell: true,
939
+ homeEnvVar: "DSH_HOME",
940
+ realHome: path.join(os.homedir(), ".dsh"),
941
+ inheritDirs: ["skills"],
942
+ inheritFiles: ["AGENTS.md"],
943
+ credentialFile: ".credentials.yaml",
944
+ restore: "resume",
945
+ firstFrameMs: 6e4,
946
+ heartbeatMs: 6e4,
947
+ images: false,
948
+ model: "[\"deepseek-account\",\"deepseek-flash\"]",
949
+ mcpViaAcp: true
950
+ },
951
+ "claude-code": {
952
+ name: "claude-code",
953
+ bin: "npx",
954
+ binWin: "npx.cmd",
955
+ args: ["-y", "@zed-industries/claude-code-acp"],
956
+ shell: true
957
+ },
958
+ codex: {
959
+ name: "codex",
960
+ bin: "codex-acp",
961
+ binWin: "codex-acp.exe",
962
+ args: []
963
+ }
964
+ };
965
+ function onPath(exe) {
966
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) if (dir && fs.existsSync(path.join(dir, exe))) return path.join(dir, exe);
967
+ return null;
968
+ }
969
+ /**
970
+ * `auto` 的探测顺序:按序取第一个「二进制能在 PATH 上解析到」的预设。
971
+ * 只收纳**原生 ACP 且二进制可直接判定**的预设——适配器类(`claude-code` 走 `npx`、
972
+ * `codex` 走 `codex-acp`)不参与自动探测(`npx` 几乎总在 PATH 上,会把它们误选出来)。
973
+ */
974
+ const AUTO_PROBE_ORDER = [
975
+ "kiro",
976
+ "opencode",
977
+ "gemini",
978
+ "deepseek"
979
+ ];
980
+ /** 预设在该平台的「可执行文件名」(用于 PATH 探测)。 */
981
+ function presetExe(preset) {
982
+ return process.platform === "win32" ? preset.binWin ?? `${preset.bin}.exe` : preset.bin;
983
+ }
984
+ /** 预设的二进制是否能在 PATH 上解析到(只判定、不启动)。 */
985
+ function presetAvailable(key) {
986
+ const preset = ACP_AGENT_PRESETS[key];
987
+ return !!preset && onPath(presetExe(preset)) !== null;
988
+ }
989
+ /**
990
+ * 解析一个 bin 说明(绝对/相对路径,或 PATH 上的裸名)到实际文件;解析不到返回 null。
991
+ * 裸名在 Windows 上会补试 `.exe`/`.cmd`,覆盖 npm shim 的常见布局。
992
+ */
993
+ function locateBin(bin) {
994
+ if (!bin) return null;
995
+ if (bin.includes("/") || bin.includes(path.sep)) return fs.existsSync(bin) ? bin : null;
996
+ const candidates = [bin];
997
+ if (process.platform === "win32" && !/\.[A-Za-z0-9]+$/.test(bin)) candidates.push(`${bin}.exe`, `${bin}.cmd`);
998
+ for (const c of candidates) {
999
+ const hit = onPath(c);
1000
+ if (hit) return hit;
1001
+ }
1002
+ return null;
1003
+ }
1004
+ /** 解析当前要用的 ACP agent:环境变量 > 账号 brain.json > auto。 */
1005
+ function resolveAgent(cfg) {
1006
+ const envKey = process.env.WEMIND_BRAIN?.trim();
1007
+ const fileKey = cfg?.agent?.trim();
1008
+ const requestedKey = (envKey || fileKey || "auto").toLowerCase();
1009
+ const source = envKey ? "env" : fileKey ? "file" : "auto";
1010
+ let known = true;
1011
+ let key = requestedKey;
1012
+ if (key === "auto") key = AUTO_PROBE_ORDER.find((k) => presetAvailable(k)) ?? "kiro";
1013
+ else if (!ACP_AGENT_PRESETS[key]) {
1014
+ known = false;
1015
+ key = "kiro";
1016
+ }
1017
+ const preset = ACP_AGENT_PRESETS[key];
1018
+ 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() || "";
1021
+ const exe = presetExe(preset);
1022
+ const binPath = explicitBin ? locateBin(explicitBin) : onPath(exe);
1023
+ const bin = explicitBin || (shell ? process.platform === "win32" ? exe : preset.bin : binPath || preset.bin);
1024
+ const available = known && binPath !== null;
1025
+ 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;
1028
+ const inherit = (process.env.WEMIND_BRAIN_INHERIT ?? process.env.WECHAT_BRAIN_INHERIT ?? "1") !== "0";
1029
+ const realHome = process.env.WEMIND_BRAIN_REAL_HOME?.trim() || process.env.WECHAT_REAL_KIRO_HOME?.trim() || preset.realHome;
1030
+ const dirsEnv = process.env.WEMIND_BRAIN_INHERIT_DIRS?.trim();
1031
+ const inheritDirs = dirsEnv ? dirsEnv.split(/[,\s]+/).filter(Boolean) : preset.inheritDirs ?? [];
1032
+ return {
1033
+ name: preset.name,
1034
+ bin,
1035
+ args,
1036
+ homeEnvVar,
1037
+ shell,
1038
+ inherit,
1039
+ realHome,
1040
+ inheritDirs,
1041
+ inheritFiles: preset.inheritFiles ?? [],
1042
+ configSubdir: preset.configSubdir ?? "",
1043
+ mcpFormat: preset.mcpFormat ?? "kiro",
1044
+ mcpConfigFile: preset.mcpConfigFile ?? "settings/mcp.json",
1045
+ catalogFile: preset.catalogFile ?? "settings/mcp.json",
1046
+ env: preset.env ?? {},
1047
+ restore: preset.restore,
1048
+ firstFrameMs: preset.firstFrameMs,
1049
+ heartbeatMs: preset.heartbeatMs,
1050
+ images: preset.images ?? true,
1051
+ credentialFile: preset.credentialFile,
1052
+ model: preset.model,
1053
+ mcpViaAcp: preset.mcpViaAcp ?? false,
1054
+ requestedKey,
1055
+ source,
1056
+ known,
1057
+ available,
1058
+ binPath
1059
+ };
1060
+ }
1061
+ /**
1062
+ * 校验解析后的大脑是否可用:可用返回 null,否则返回一句面向用户的说明。
1063
+ * 供 daemon 启动校验(早失败、说人话)与 `wemind brain` / `wemind brain check` 复用。
1064
+ */
1065
+ function agentProblem(ag) {
1066
+ if (!ag.known) return `未知大脑预设「${ag.requestedKey}」。可选:${Object.keys(ACP_AGENT_PRESETS).join("、")}(自定义二进制可用 WEMIND_BRAIN_BIN 或 brain set --bin 指定)`;
1067
+ 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 <预设>\` 切换`;
1069
+ return null;
1070
+ }
1071
+ //#endregion
1072
+ //#region src/brain-config.ts
1073
+ /** brain.json 的完整路径(默认当前账号数据目录)。 */
1074
+ function brainConfigPath(dir = DATA_DIR) {
1075
+ return path.join(dir, "brain.json");
1076
+ }
1077
+ /**
1078
+ * 读取账号的大脑配置;缺失/损坏/无有效 agent 一律返回 null(幂等、不抛)。
1079
+ * 字段做了宽松归一化:args 允许字符串(空格分隔)或数组;未知字段忽略。
1080
+ */
1081
+ function loadBrainConfig(dir = DATA_DIR) {
1082
+ let j;
1083
+ try {
1084
+ j = JSON.parse(fs.readFileSync(brainConfigPath(dir), "utf-8"));
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ if (!j || typeof j !== "object" || Array.isArray(j)) return null;
1089
+ const agent = typeof j.agent === "string" ? j.agent.trim() : "";
1090
+ if (!agent) return null;
1091
+ const cfg = { agent };
1092
+ if (typeof j.bin === "string" && j.bin.trim()) cfg.bin = j.bin.trim();
1093
+ if (Array.isArray(j.args)) cfg.args = j.args.filter((x) => typeof x === "string");
1094
+ else if (typeof j.args === "string" && j.args.trim()) cfg.args = j.args.trim().split(/\s+/);
1095
+ if (typeof j.shell === "boolean") cfg.shell = j.shell;
1096
+ if (typeof j.homeEnvVar === "string" && j.homeEnvVar.trim()) cfg.homeEnvVar = j.homeEnvVar.trim();
1097
+ return cfg;
1098
+ }
1099
+ /** 原子写入账号的大脑配置(临时文件 + rename,避免读到半截)。 */
1100
+ function saveBrainConfig(cfg, dir = DATA_DIR) {
1101
+ fs.mkdirSync(dir, { recursive: true });
1102
+ const fp = brainConfigPath(dir);
1103
+ const tmp = `${fp}.tmp`;
1104
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1105
+ fs.renameSync(tmp, fp);
1106
+ }
1107
+ /** 清除账号的大脑配置(回到自动探测/默认);返回是否确有文件被删。 */
1108
+ function clearBrainConfig(dir = DATA_DIR) {
1109
+ try {
1110
+ fs.unlinkSync(brainConfigPath(dir));
1111
+ return true;
1112
+ } catch {
1113
+ return false;
1114
+ }
1115
+ }
1116
+ //#endregion
1117
+ //#region src/agent-mcp.ts
1118
+ function isObj(v) {
1119
+ return !!v && typeof v === "object" && !Array.isArray(v);
1120
+ }
1121
+ /** 从配置对象读出 server 定义表(按格式);缺失/异常一律返回空表。 */
1122
+ function readMcpServers(obj, format) {
1123
+ if (!isObj(obj)) return {};
1124
+ const servers = format === "opencode" ? isObj(obj.mcp) ? obj.mcp.servers : void 0 : obj.mcpServers;
1125
+ return isObj(servers) ? servers : {};
1126
+ }
1127
+ /** 把 server 定义表写回配置对象(按格式),返回**新对象**(不改原对象),并保留其它键(如 opencode 的 model)。 */
1128
+ function withMcpServers(obj, servers, format) {
1129
+ const base = isObj(obj) ? { ...obj } : {};
1130
+ if (format === "opencode") {
1131
+ const mcp = isObj(base.mcp) ? { ...base.mcp } : {};
1132
+ mcp.servers = servers;
1133
+ base.mcp = mcp;
1134
+ } else base.mcpServers = servers;
1135
+ return base;
1136
+ }
1137
+ /**
1138
+ * 宽松解析 JSONC(去 // 与块注释、去尾随逗号)。仅用于把用户可能带注释的
1139
+ * opencode.jsonc 读成对象;失败返回 null(调用方按「无配置」处理,不抛)。
1140
+ */
1141
+ function parseJsonc(text) {
1142
+ try {
1143
+ return JSON.parse(text);
1144
+ } catch {}
1145
+ try {
1146
+ const cleaned = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
1147
+ const parsed = JSON.parse(cleaned);
1148
+ return isObj(parsed) ? parsed : null;
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ }
1153
+ function envEntries(map) {
1154
+ if (!isObj(map)) return [];
1155
+ return Object.entries(map).filter(([, v]) => v !== void 0 && v !== null).map(([name, v]) => ({
1156
+ name,
1157
+ value: String(v)
1158
+ }));
1159
+ }
1160
+ /**
1161
+ * 把 catalog 的 server 定义表转成 ACP `mcpServers`(stdio / HTTP)。
1162
+ * - kiro `{ command, args, env }` → stdio;
1163
+ * - opencode `{ type:"local", command:[...], environment }` → stdio(拆 command[0]/args);
1164
+ * - `{ url }`(kiro 远程 / opencode remote)→ HTTP(SSE 不被 ACP 支持,无法从定义识别,由服务端拒绝)。
1165
+ * 返回转换结果与**被跳过的名字**(结构不认识/明显残缺)——调用方记日志,避免坏条目把整个 session 拖挂。
1166
+ */
1167
+ function toAcpMcpServers(servers, opts = {}) {
1168
+ const exclude = new Set(opts.exclude ?? []);
1169
+ const out = [];
1170
+ const skipped = [];
1171
+ for (const [name, entry] of Object.entries(servers)) {
1172
+ if (exclude.has(name)) continue;
1173
+ if (!isObj(entry)) {
1174
+ skipped.push(name);
1175
+ continue;
1176
+ }
1177
+ if (Array.isArray(entry.command)) {
1178
+ const cmd = entry.command[0];
1179
+ if (typeof cmd !== "string" || !cmd) {
1180
+ skipped.push(name);
1181
+ continue;
1182
+ }
1183
+ out.push({
1184
+ name,
1185
+ command: cmd,
1186
+ args: entry.command.slice(1).map(String),
1187
+ env: envEntries(entry.environment ?? entry.env)
1188
+ });
1189
+ continue;
1190
+ }
1191
+ if (typeof entry.command === "string" && entry.command) {
1192
+ out.push({
1193
+ name,
1194
+ command: entry.command,
1195
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
1196
+ env: envEntries(entry.env)
1197
+ });
1198
+ continue;
1199
+ }
1200
+ if (typeof entry.url === "string" && entry.url) {
1201
+ const rawHeaders = entry.headers;
1202
+ const headers = isObj(rawHeaders) ? envEntries(rawHeaders) : Array.isArray(rawHeaders) ? rawHeaders.filter((h) => isObj(h) && typeof h.name === "string").map((h) => ({
1203
+ name: String(h.name),
1204
+ value: String(h.value ?? "")
1205
+ })) : [];
1206
+ out.push({
1207
+ name,
1208
+ type: "http",
1209
+ url: entry.url,
1210
+ headers
1211
+ });
1212
+ continue;
1213
+ }
1214
+ skipped.push(name);
1215
+ }
1216
+ return {
1217
+ servers: out,
1218
+ skipped
1219
+ };
1220
+ }
1221
+ //#endregion
1222
+ //#region src/mcp-sandbox.ts
1223
+ /**
1224
+ * brain 的 MCP 沙箱:brain 跑在独立配置目录下,默认不加载任何 MCP。
1225
+ * 通过对话指令(加载/卸载/列出)动态调整「激活集」,从全局 MCP 目录取定义写入沙箱。
1226
+ * DATA_DIR 从 core 导入(支持 WECHAT_DATA_DIR 覆盖,便于多账号隔离)。
1227
+ *
1228
+ * 按 agent 适配(见 brain-agents.ts 预设与 docs/接入-OpenCode.md):
1229
+ * - 配置目录:kiro = 沙箱根(KIRO_HOME);opencode = <沙箱>/opencode(XDG_CONFIG_HOME)。
1230
+ * - MCP 文件:kiro = settings/mcp.json({mcpServers});opencode = opencode.json(mcp.servers)。
1231
+ * kiro 行为与改造前完全一致(默认值即 kiro 约定)。
1232
+ */
1233
+ const AG$1 = resolveAgent(loadBrainConfig());
1234
+ /** brain 专用沙箱根目录(由 agent 的 homeEnvVar 指向它)。 */
1235
+ const BRAIN_HOME = process.env.WECHAT_BRAIN_HOME?.trim() || path.join(DATA_DIR, "brain-home");
1236
+ /** 该 agent 在沙箱里的「配置目录」(kiro=沙箱根;opencode=<沙箱>/opencode)。 */
1237
+ const BRAIN_CONFIG_DIR = path.join(BRAIN_HOME, AG$1.configSubdir);
1238
+ /** MCP 配置文件(相对配置目录,按 agent 的 mcpConfigFile)。 */
1239
+ const BRAIN_MCP_JSON = path.join(BRAIN_CONFIG_DIR, AG$1.mcpConfigFile);
1240
+ /** 激活集持久化文件。 */
1241
+ const ACTIVE_FILE = path.join(DATA_DIR, "brain-mcps.json");
1242
+ /** 「目录」:可选 MCP 的定义来源。默认取所用 agent 真实配置目录(realHome)下的
1243
+ * catalogFile(kiro: settings/mcp.json / opencode: opencode.json);可用
1244
+ * `WEMIND_MCP_CATALOG` / `WECHAT_MCP_CATALOG` 覆盖为任意路径。 */
1245
+ const CATALOG$1 = process.env.WEMIND_MCP_CATALOG?.trim() || process.env.WECHAT_MCP_CATALOG?.trim() || path.join(AG$1.realHome || path.join(os.homedir(), ".kiro"), AG$1.catalogFile);
1246
+ /** 永不允许 brain 加载(防回环)。 */
1247
+ const FORBIDDEN = /* @__PURE__ */ new Set(["wechat"]);
1248
+ /**
1249
+ * 让 brain 继承所用 agent 真实配置目录的「知识/记忆/技能」:
1250
+ * - 目录式(inheritDirs,如 kiro 的 steering/memories/skills、opencode 的 skills/agents/commands)用
1251
+ * 目录 junction(Windows)/ symlink 指向真实目录;
1252
+ * - 单文件式(inheritFiles,如 opencode 的 AGENTS.md)直接拷入配置目录。
1253
+ * 使微信里的大脑和你日常使用读到同样的记忆、规则、技能。
1254
+ * **MCP(settings/opencode.json 的 mcp 段)与 brain 自有会话(sessions)不镜像**:MCP 必须走沙箱(排除 wechat 防回环),会话不能混。
1255
+ */
1256
+ function mirrorRealKiro$1() {
1257
+ if (!AG$1.inherit || !AG$1.realHome) return;
1258
+ if (path.resolve(BRAIN_HOME) === path.resolve(AG$1.realHome)) return;
1259
+ try {
1260
+ fs.mkdirSync(BRAIN_CONFIG_DIR, { recursive: true });
1261
+ } catch {}
1262
+ for (const d of AG$1.inheritDirs) {
1263
+ const target = path.join(AG$1.realHome, d);
1264
+ const link = path.join(BRAIN_CONFIG_DIR, d);
1265
+ if (!fs.existsSync(target)) continue;
1266
+ let present = false;
1267
+ try {
1268
+ fs.lstatSync(link);
1269
+ present = true;
1270
+ } catch {}
1271
+ if (present) try {
1272
+ const st = fs.lstatSync(link);
1273
+ if (st.isSymbolicLink()) continue;
1274
+ if (st.isDirectory()) if (fs.readdirSync(link).length === 0) fs.rmdirSync(link);
1275
+ else {
1276
+ for (const f of fs.readdirSync(target)) {
1277
+ const dst = path.join(link, f);
1278
+ const src = path.join(target, f);
1279
+ try {
1280
+ if (!fs.existsSync(dst) && fs.statSync(src).isFile()) fs.copyFileSync(src, dst);
1281
+ } catch {}
1282
+ }
1283
+ continue;
1284
+ }
1285
+ else continue;
1286
+ } catch {
1287
+ continue;
1288
+ }
1289
+ try {
1290
+ fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
1291
+ } catch {}
1292
+ }
1293
+ for (const f of AG$1.inheritFiles) {
1294
+ const src = path.join(AG$1.realHome, f);
1295
+ const dst = path.join(BRAIN_CONFIG_DIR, f);
1296
+ try {
1297
+ if (fs.existsSync(src) && !fs.existsSync(dst)) fs.copyFileSync(src, dst);
1298
+ } catch {}
1299
+ }
1300
+ }
1301
+ function readJson$1(file) {
1302
+ try {
1303
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
1304
+ } catch {
1305
+ return null;
1306
+ }
1307
+ }
1308
+ /**
1309
+ * 把 agent 的凭据文件同步进沙箱(如 DSH 的 `.credentials.yaml`)。
1310
+ * 新者胜:首次复制;真实 home 的 token 刷新后覆盖沙箱;沙箱自己刷新过则保留沙箱版本。
1311
+ * 这是敏感文件,仅放在账号沙箱内;Windows 无 POSIX 600,建议专用账号 + 目录 ACL。
1312
+ */
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);
1317
+ try {
1318
+ if (!fs.existsSync(src)) return;
1319
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1320
+ const s = fs.statSync(src);
1321
+ const d = fs.existsSync(dst) ? fs.statSync(dst) : null;
1322
+ if (!d || s.mtimeMs > d.mtimeMs + 1e3) fs.copyFileSync(src, dst);
1323
+ } catch {}
1324
+ }
1325
+ function loadCatalog$1() {
1326
+ return readMcpServers(readJson$1(CATALOG$1), AG$1.mcpFormat);
1327
+ }
1328
+ /** 目录中可选的 server 名(排除禁用项)。 */
1329
+ function catalogNames() {
1330
+ return Object.keys(loadCatalog$1()).filter((n) => !FORBIDDEN.has(n));
1331
+ }
1332
+ function loadActive() {
1333
+ const a = readJson$1(ACTIVE_FILE)?.active;
1334
+ return Array.isArray(a) ? a.filter((x) => typeof x === "string") : [];
1335
+ }
1336
+ function saveActive(list) {
1337
+ fs.mkdirSync(DATA_DIR, { recursive: true });
1338
+ fs.writeFileSync(ACTIVE_FILE, JSON.stringify({ active: list }, null, 2), "utf-8");
1339
+ }
1340
+ /** 按激活集从目录取定义,写入沙箱 MCP 配置(默认空)。保留配置里其它键(如 opencode 的 model)。
1341
+ * `mcpViaAcp` 预设(DSH)不写文件——激活集经 activeMcpServers() 转成 ACP mcpServers 随 session 下发。 */
1342
+ function applySandbox() {
1343
+ if (AG$1.mcpViaAcp) return;
1344
+ const cat = loadCatalog$1();
1345
+ const active = loadActive().filter((n) => cat[n] && !FORBIDDEN.has(n));
1346
+ const servers = {};
1347
+ for (const n of active) servers[n] = cat[n];
1348
+ const next = withMcpServers(readJson$1(BRAIN_MCP_JSON) ?? {}, servers, AG$1.mcpFormat);
1349
+ fs.mkdirSync(path.dirname(BRAIN_MCP_JSON), { recursive: true });
1350
+ fs.writeFileSync(BRAIN_MCP_JSON, JSON.stringify(next, null, 2), "utf-8");
1351
+ }
1352
+ /** brain 当前应挂载的 ACP mcpServers(mcpViaAcp 预设用):激活集 → catalog 定义 → ACP 声明。
1353
+ * 跳过不可转换的坏条目并记日志——官方 DSH 的 session/new 遇到起不来的 MCP 会回滚整个会话。 */
1354
+ function activeMcpServers() {
1355
+ const cat = loadCatalog$1();
1356
+ const active = loadActive().filter((n) => cat[n] && !FORBIDDEN.has(n));
1357
+ const picked = {};
1358
+ for (const n of active) picked[n] = cat[n];
1359
+ const { servers, skipped } = toAcpMcpServers(picked, { exclude: FORBIDDEN });
1360
+ if (skipped.length) console.error(`[wemind/wechat-daemon/mcp] 跳过无法转换的 MCP:${skipped.join(", ")}`);
1361
+ return servers;
1362
+ }
1363
+ /** kiro:继承真实 settings/ 下除 mcp.json 外的文件(cli.json 含模型/免确认等),让大脑与日常使用同款配置。 */
1364
+ function syncKiroSettings$1() {
1365
+ const realSettings = path.join(AG$1.realHome, "settings");
1366
+ const brainSettings = path.join(BRAIN_CONFIG_DIR, "settings");
1367
+ if (!fs.existsSync(realSettings)) return;
1368
+ try {
1369
+ fs.mkdirSync(brainSettings, { recursive: true });
1370
+ } catch {}
1371
+ let names = [];
1372
+ try {
1373
+ names = fs.readdirSync(realSettings);
1374
+ } catch {
1375
+ return;
1376
+ }
1377
+ for (const f of names) {
1378
+ if (f === "mcp.json") continue;
1379
+ try {
1380
+ const src = path.join(realSettings, f);
1381
+ if (fs.statSync(src).isFile()) fs.copyFileSync(src, path.join(brainSettings, f));
1382
+ } catch {}
1383
+ }
1384
+ }
1385
+ /** opencode:把用户全局 opencode.json(c)(去掉 mcp 段)作为基底写进沙箱,继承 model 等设置;mcp 仍由 applySandbox 权威覆盖。 */
1386
+ function syncOpencodeBase$1() {
1387
+ const src = ["opencode.json", "opencode.jsonc"].map((f) => path.join(AG$1.realHome, f)).find((f) => fs.existsSync(f));
1388
+ if (!src) return;
1389
+ let parsed = null;
1390
+ try {
1391
+ const raw = fs.readFileSync(src, "utf-8");
1392
+ parsed = src.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw);
1393
+ } catch {
1394
+ return;
1395
+ }
1396
+ if (!parsed || typeof parsed !== "object") return;
1397
+ delete parsed.mcp;
1398
+ fs.mkdirSync(BRAIN_CONFIG_DIR, { recursive: true });
1399
+ fs.writeFileSync(BRAIN_MCP_JSON, JSON.stringify(parsed, null, 2), "utf-8");
1400
+ }
1401
+ /** 继承真实配置目录的「非 MCP」设置(模型/免确认等),让大脑与日常使用同款配置;mcp 段保持沙箱权威。
1402
+ * `mcpViaAcp` 预设没有文件式 MCP/settings 可继承,跳过(模型走 preset.model + set_config_option)。 */
1403
+ function syncSettings$1() {
1404
+ if (AG$1.mcpViaAcp) return;
1405
+ if (!AG$1.inherit || !AG$1.realHome) return;
1406
+ if (AG$1.mcpFormat === "opencode") syncOpencodeBase$1();
1407
+ else syncKiroSettings$1();
1408
+ }
1409
+ /** 启动时确保沙箱存在并与激活集一致。 */
1410
+ function ensureBrainHome() {
1411
+ syncSettings$1();
1412
+ applySandbox();
1413
+ mirrorRealKiro$1();
1414
+ syncCredentialFile$1();
1415
+ }
1416
+ function addMcp(name) {
1417
+ if (FORBIDDEN.has(name)) return `不允许加载「${name}」(防回环)。`;
1418
+ const cat = loadCatalog$1();
1419
+ if (!cat[name]) return `未找到 MCP「${name}」。可选:${catalogNames().join(", ") || "(无)"}`;
1420
+ if (AG$1.mcpViaAcp && !toAcpMcpServers({ [name]: cat[name] }).servers.length) return `MCP「${name}」的定义无法转换(需要 command 或 url),已拒绝加载。`;
1421
+ const a = loadActive();
1422
+ if (!a.includes(name)) {
1423
+ a.push(name);
1424
+ saveActive(a);
1425
+ }
1426
+ applySandbox();
1427
+ return `已加载 MCP「${name}」。当前激活:${a.join(", ")}(下一条消息生效)`;
1428
+ }
1429
+ function removeMcp(name) {
1430
+ const a = loadActive();
1431
+ if (!a.includes(name)) return `「${name}」当前未激活。`;
1432
+ const next = a.filter((x) => x !== name);
1433
+ saveActive(next);
1434
+ applySandbox();
1435
+ return `已卸载 MCP「${name}」。当前激活:${next.join(", ") || "(空)"}`;
1436
+ }
1437
+ function listMcp() {
1438
+ const avail = catalogNames();
1439
+ const active = loadActive();
1440
+ return `可选 MCP:${avail.join(", ") || "(无)"}\n已激活:${active.join(", ") || "(空,默认不加载)"}`;
1441
+ }
1442
+ //#endregion
1443
+ //#region src/spawn-agent.ts
1444
+ /** cmd 需要加引号的字符:空白 + 元字符(不加引号时 cmd 会自行解释:`&` 断命令、`|` 管道…)。 */
1445
+ const CMD_NEEDS_QUOTE = /[\s"&|<>^()%!]/;
1446
+ /**
1447
+ * cmd.exe(MS CRT)参数转义:无空白/元字符则裸写;否则用双引号包裹,
1448
+ * 并按其规则处理引号前的前导反斜杠与结尾反斜杠。
1449
+ * 注:引号挡得住元字符,但挡不住 `%VAR%` 展开(cmd 命令行无法转义 `%`),与改动前行为一致。
1450
+ */
1451
+ function quoteCmdArg(arg) {
1452
+ if (arg === "") return "\"\"";
1453
+ if (!CMD_NEEDS_QUOTE.test(arg)) return arg;
1454
+ return `"${arg.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1")}"`;
1455
+ }
1456
+ /** POSIX sh 参数转义:安全字符集裸写,否则单引号包裹(内部单引号写成 `'\''`)。 */
1457
+ function quoteShArg(arg) {
1458
+ if (arg === "") return "''";
1459
+ if (!/[^\w@%+=:,./-]/.test(arg)) return arg;
1460
+ return `'${arg.replace(/'/g, "'\\''")}'`;
1461
+ }
1462
+ /** 把 `bin + args` 拼成一条交给 shell 的命令行(`platform` 仅为可测性暴露)。 */
1463
+ function buildShellLine(bin, args, platform = process.platform) {
1464
+ const quote = platform === "win32" ? quoteCmdArg : quoteShArg;
1465
+ return [bin, ...args].map(quote).join(" ");
1466
+ }
1467
+ /**
1468
+ * 启动 agent:`shell=true` 的走「整串命令行 + shell」(见文件头注释),其余直接 spawn。
1469
+ * 默认 `windowsHide: true`(不闪控制台窗口),其余选项透传。
1470
+ */
1471
+ function spawnAgent(agent, opts = {}) {
1472
+ const base = {
1473
+ windowsHide: true,
1474
+ ...opts
1475
+ };
1476
+ if (!agent.shell) return spawn(agent.bin, agent.args, base);
1477
+ return spawn(buildShellLine(agent.bin, agent.args), {
1478
+ ...base,
1479
+ shell: true
1480
+ });
1481
+ }
1482
+ //#endregion
1483
+ //#region src/acp-brain.ts
1484
+ /**
1485
+ * ACP 大脑:维护一个常驻的 ACP agent 进程(JSON-RPC over stdio,换行分隔)。
1486
+ * 通过标准 ACP 协议驱动,agent 可配置(默认 kiro-cli,见 brain-agents.ts),任意兼容 ACP 的 CLI 均可接入。
1487
+ * - 每用户一个 ACP session(原生多轮记忆);session 映射持久化到磁盘。
1488
+ * - 自愈:session/prompt 超时/失败 → 杀进程重建;重建后用 session/load 恢复上下文。
1489
+ * - 诊断日志([wemind/wechat-daemon/acp] 前缀,进 daemon.log):发出请求、收帧、超时时已收 chunk 数与最后收帧时间差。
1490
+ * 跑在 brain 沙箱目录下(默认空 MCP),通过 agent 配置的环境变量把该目录传给 agent。
1491
+ */
1492
+ function dlog$1(...a) {
1493
+ console.error("[wemind/wechat-daemon/acp]", ...a);
1494
+ }
1495
+ const AGENT$1 = resolveAgent(loadBrainConfig());
1496
+ const REQ_TIMEOUT$1 = 3e4;
1497
+ const PROMPT_IDLE_MS$1 = Number(process.env.WECHAT_BRAIN_IDLE_MS) || 12e4;
1498
+ const PROMPT_CAP_MS$1 = Number(process.env.WECHAT_BRAIN_CAP_MS) || 9e5;
1499
+ const FIRST_FRAME_MS$1 = Number(process.env.WECHAT_BRAIN_FIRST_FRAME_MS) || AGENT$1.firstFrameMs || 25e3;
1500
+ const SESSION_MAP_FILE$1 = path.join(DATA_DIR, "brain-sessions.json");
1501
+ const BRAIN_PID_FILE = path.join(DATA_DIR, "brain.pid");
1502
+ let child$1 = null;
1503
+ let initialized$1 = false;
1504
+ let idCounter$1 = 0;
1505
+ const pending$1 = /* @__PURE__ */ new Map();
1506
+ const userSessions = /* @__PURE__ */ new Map();
1507
+ const assembling$1 = /* @__PURE__ */ new Map();
1508
+ let stdoutBuf$1 = "";
1509
+ let lastFrameAt$1 = 0;
1510
+ let frameSeq$1 = 0;
1511
+ let currentOnProgress$1 = null;
1512
+ let answerStarted$1 = false;
1513
+ let thoughtStarted$1 = false;
1514
+ const seenUpdateTypes$1 = /* @__PURE__ */ new Set();
1515
+ const seenTools$1 = /* @__PURE__ */ new Set();
1516
+ const hintedSessions = /* @__PURE__ */ new Set();
1517
+ function isAlive$1() {
1518
+ return !!child$1 && child$1.exitCode === null && !child$1.killed;
1519
+ }
1520
+ /** 杀进程(按 PID 进程树)并清空内部态;不清磁盘 session 映射(留给重建后 load)。 */
1521
+ function reset$1() {
1522
+ const c = child$1;
1523
+ child$1 = null;
1524
+ initialized$1 = false;
1525
+ if (c?.pid) try {
1526
+ if (process.platform === "win32") spawnSync("taskkill", [
1527
+ "/PID",
1528
+ String(c.pid),
1529
+ "/T",
1530
+ "/F"
1531
+ ], {
1532
+ stdio: "ignore",
1533
+ windowsHide: true
1534
+ });
1535
+ else c.kill();
1536
+ } catch {}
1537
+ try {
1538
+ fs.unlinkSync(BRAIN_PID_FILE);
1539
+ } catch {}
1540
+ for (const p of pending$1.values()) {
1541
+ p.clear();
1542
+ p.reject(/* @__PURE__ */ new Error("acp process reset"));
1543
+ }
1544
+ pending$1.clear();
1545
+ userSessions.clear();
1546
+ assembling$1.clear();
1547
+ stdoutBuf$1 = "";
1548
+ }
1549
+ function loadSessionMap$1() {
1550
+ try {
1551
+ const j = JSON.parse(fs.readFileSync(SESSION_MAP_FILE$1, "utf-8"));
1552
+ return j && typeof j === "object" ? j : {};
1553
+ } catch {
1554
+ return {};
1555
+ }
1556
+ }
1557
+ function saveSessionMap$1(m) {
1558
+ fs.mkdirSync(DATA_DIR, { recursive: true });
1559
+ fs.writeFileSync(SESSION_MAP_FILE$1, JSON.stringify(m, null, 2), "utf-8");
1560
+ }
1561
+ function handleReverseRequest$1(f) {
1562
+ const method = String(f.method);
1563
+ dlog$1(`← reverse request ${method}#${f.id}`);
1564
+ if (method === "session/request_permission") {
1565
+ const options = Array.isArray(f.params?.options) ? f.params.options : [];
1566
+ const allow = process.env.WEMIND_ACP_AUTO_ALLOW === "1" ? options.find((o) => o?.kind === "allow_once" || o?.kind === "allow_always") : void 0;
1567
+ const result = allow ? { outcome: {
1568
+ outcome: "selected",
1569
+ optionId: allow.optionId
1570
+ } } : { outcome: { outcome: "cancelled" } };
1571
+ child$1?.stdin.write(JSON.stringify({
1572
+ jsonrpc: "2.0",
1573
+ id: f.id,
1574
+ result
1575
+ }) + "\n");
1576
+ dlog$1(`permission ${allow ? "allowed" : "rejected"}`);
1577
+ return;
1578
+ }
1579
+ child$1?.stdin.write(JSON.stringify({
1580
+ jsonrpc: "2.0",
1581
+ id: f.id,
1582
+ error: {
1583
+ code: -32601,
1584
+ message: "method not supported"
1585
+ }
1586
+ }) + "\n");
1587
+ }
1588
+ function handleFrame$1(f) {
1589
+ lastFrameAt$1 = Date.now();
1590
+ frameSeq$1 += 1;
1591
+ if (typeof f.id === "number" && (f.result !== void 0 || f.error !== void 0)) {
1592
+ const p = pending$1.get(f.id);
1593
+ if (!p) return;
1594
+ p.clear();
1595
+ pending$1.delete(f.id);
1596
+ if (f.error) {
1597
+ dlog$1(`← ${p.method}#${f.id} ERROR ${JSON.stringify(f.error).slice(0, 200)}`);
1598
+ p.reject(new Error(JSON.stringify(f.error)));
1599
+ } else p.resolve(f.result);
1600
+ return;
1601
+ }
1602
+ if (f.method && f.id !== void 0) {
1603
+ handleReverseRequest$1(f);
1604
+ return;
1605
+ }
1606
+ if (f.method === "session/update") {
1607
+ const u = f.params?.update;
1608
+ if (u?.sessionUpdate) seenUpdateTypes$1.add(String(u.sessionUpdate));
1609
+ if (currentOnProgress$1) try {
1610
+ currentOnProgress$1({ type: "activity" });
1611
+ } catch {}
1612
+ if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") {
1613
+ const arr = assembling$1.get(f.params.sessionId);
1614
+ if (arr) arr.push(String(u.content.text ?? ""));
1615
+ if (!answerStarted$1 && currentOnProgress$1) {
1616
+ answerStarted$1 = true;
1617
+ try {
1618
+ currentOnProgress$1({ type: "answer" });
1619
+ } catch {}
1620
+ }
1621
+ } else if (u?.sessionUpdate === "agent_thought_chunk") {
1622
+ if (!thoughtStarted$1 && currentOnProgress$1) {
1623
+ thoughtStarted$1 = true;
1624
+ try {
1625
+ currentOnProgress$1({ type: "thinking" });
1626
+ } catch {}
1627
+ }
1628
+ } else if ((u?.sessionUpdate === "tool_call" || u?.sessionUpdate === "tool_call_update") && currentOnProgress$1) {
1629
+ const tid = String(u.toolCallId ?? u.title ?? "");
1630
+ if (tid && !seenTools$1.has(tid)) {
1631
+ seenTools$1.add(tid);
1632
+ const purpose = typeof u.rawInput?.__tool_use_purpose === "string" ? u.rawInput.__tool_use_purpose : void 0;
1633
+ try {
1634
+ currentOnProgress$1({
1635
+ type: "tool",
1636
+ kind: u.kind,
1637
+ purpose,
1638
+ title: u.title
1639
+ });
1640
+ } catch {}
1641
+ }
1642
+ }
1643
+ }
1644
+ }
1645
+ function onStdout$1(d) {
1646
+ stdoutBuf$1 += d.toString();
1647
+ let i;
1648
+ while ((i = stdoutBuf$1.indexOf("\n")) >= 0) {
1649
+ const line = stdoutBuf$1.slice(0, i).trim();
1650
+ stdoutBuf$1 = stdoutBuf$1.slice(i + 1);
1651
+ if (!line) continue;
1652
+ let f;
1653
+ try {
1654
+ f = JSON.parse(line);
1655
+ } catch {
1656
+ continue;
1657
+ }
1658
+ handleFrame$1(f);
1659
+ }
1660
+ }
1661
+ function request$1(method, params, timeoutMs) {
1662
+ return new Promise((resolve, reject) => {
1663
+ if (!child$1) {
1664
+ reject(/* @__PURE__ */ new Error("no acp process"));
1665
+ return;
1666
+ }
1667
+ const id = ++idCounter$1;
1668
+ dlog$1(`→ ${method}#${id}`);
1669
+ const t = setTimeout(() => {
1670
+ pending$1.delete(id);
1671
+ reject(/* @__PURE__ */ new Error(`${method} timeout after ${timeoutMs}ms`));
1672
+ }, timeoutMs);
1673
+ pending$1.set(id, {
1674
+ resolve,
1675
+ reject,
1676
+ clear: () => clearTimeout(t),
1677
+ method
1678
+ });
1679
+ child$1.stdin.write(JSON.stringify({
1680
+ jsonrpc: "2.0",
1681
+ id,
1682
+ method,
1683
+ params
1684
+ }) + "\n");
1685
+ });
1686
+ }
1687
+ /**
1688
+ * session/prompt 专用:空闲超时(PROMPT_IDLE_MS 内无任何帧才判卡死)+ 硬上限(PROMPT_CAP_MS)。
1689
+ * 只要 brain 还在出帧(工具进度/正文),就不打断——避免误杀"慢但在干活"的长查询。
1690
+ */
1691
+ function promptRequest$1(params) {
1692
+ return new Promise((resolve, reject) => {
1693
+ if (!child$1) {
1694
+ reject(/* @__PURE__ */ new Error("no acp process"));
1695
+ return;
1696
+ }
1697
+ const id = ++idCounter$1;
1698
+ const startedAt = Date.now();
1699
+ lastFrameAt$1 = Date.now();
1700
+ const startSeq = frameSeq$1;
1701
+ dlog$1(`→ session/prompt#${id} (idle=${PROMPT_IDLE_MS$1}ms cap=${PROMPT_CAP_MS$1}ms firstFrame=${FIRST_FRAME_MS$1}ms)`);
1702
+ const iv = setInterval(() => {
1703
+ const idle = Date.now() - lastFrameAt$1;
1704
+ const total = Date.now() - startedAt;
1705
+ const firstFrameTimeout = frameSeq$1 === startSeq && total > FIRST_FRAME_MS$1;
1706
+ if (firstFrameTimeout || idle > PROMPT_IDLE_MS$1 || total > PROMPT_CAP_MS$1) {
1707
+ if (pending$1.delete(id)) {
1708
+ clearInterval(iv);
1709
+ reject(/* @__PURE__ */ new Error(`session/prompt ${firstFrameTimeout ? "no-first-frame" : idle > PROMPT_IDLE_MS$1 ? "idle" : "cap"} timeout (idle=${idle}ms total=${total}ms)`));
1710
+ }
1711
+ }
1712
+ }, 5e3);
1713
+ pending$1.set(id, {
1714
+ resolve,
1715
+ reject,
1716
+ clear: () => clearInterval(iv),
1717
+ method: "session/prompt"
1718
+ });
1719
+ child$1.stdin.write(JSON.stringify({
1720
+ jsonrpc: "2.0",
1721
+ id,
1722
+ method: "session/prompt",
1723
+ params
1724
+ }) + "\n");
1725
+ });
1726
+ }
1727
+ async function ensureProcess$1() {
1728
+ if (isAlive$1() && initialized$1) return;
1729
+ reset$1();
1730
+ const env = { ...process.env };
1731
+ if (AGENT$1.homeEnvVar) env[AGENT$1.homeEnvVar] = BRAIN_HOME;
1732
+ for (const [k, v] of Object.entries(AGENT$1.env ?? {})) env[k] = v.replaceAll("%HOME%", BRAIN_HOME);
1733
+ const proc = spawnAgent(AGENT$1, {
1734
+ cwd: BRAIN_HOME,
1735
+ env
1736
+ });
1737
+ child$1 = proc;
1738
+ dlog$1(`spawn ${AGENT$1.name} acp pid=${proc.pid} bin=${AGENT$1.bin} args=[${AGENT$1.args.join(" ")}]`);
1739
+ try {
1740
+ fs.writeFileSync(BRAIN_PID_FILE, String(proc.pid), "utf-8");
1741
+ } catch {}
1742
+ proc.stdout.on("data", onStdout$1);
1743
+ proc.stderr.on("data", () => {});
1744
+ proc.on("exit", (code) => {
1745
+ dlog$1(`acp exited code=${code}`);
1746
+ if (child$1 === proc) reset$1();
1747
+ });
1748
+ proc.on("error", (e) => {
1749
+ dlog$1(`acp error ${String(e)}`);
1750
+ if (child$1 === proc) reset$1();
1751
+ });
1752
+ await request$1("initialize", {
1753
+ protocolVersion: 1,
1754
+ clientCapabilities: {}
1755
+ }, REQ_TIMEOUT$1);
1756
+ initialized$1 = true;
1757
+ dlog$1("initialized");
1758
+ }
1759
+ /** 按预设选择模型路由(如 DSH 的 deepseek-account);没有 model 的 agent 跳过。 */
1760
+ async function applyModel$1(sid) {
1761
+ if (!AGENT$1.model) return;
1762
+ try {
1763
+ await request$1("session/set_config_option", {
1764
+ sessionId: sid,
1765
+ configId: "model",
1766
+ value: AGENT$1.model
1767
+ }, REQ_TIMEOUT$1);
1768
+ dlog$1(`model set ${AGENT$1.model}`);
1769
+ } catch (e) {
1770
+ dlog$1(`model set failed (${String(e)})`);
1771
+ throw e;
1772
+ }
1773
+ }
1774
+ async function ensureSession$1(userId) {
1775
+ const existing = userSessions.get(userId);
1776
+ if (existing) return existing;
1777
+ const mcpServers = AGENT$1.mcpViaAcp ? activeMcpServers() : [];
1778
+ const map = loadSessionMap$1();
1779
+ const saved = map[userId];
1780
+ if (saved) {
1781
+ const methods = AGENT$1.restore === "resume" ? ["resume"] : ["load", "resume"];
1782
+ for (const method of methods) try {
1783
+ await request$1(`session/${method}`, {
1784
+ sessionId: saved,
1785
+ cwd: BRAIN_HOME,
1786
+ mcpServers
1787
+ }, REQ_TIMEOUT$1);
1788
+ await applyModel$1(saved);
1789
+ userSessions.set(userId, saved);
1790
+ dlog$1(`session/${method} ok ${saved} mcp=[${mcpServers.map((s) => s.name).join(",")}]`);
1791
+ return saved;
1792
+ } catch (e) {
1793
+ dlog$1(`session/${method} failed (${String(e)})`);
1794
+ }
1795
+ }
1796
+ const res = await request$1("session/new", {
1797
+ cwd: BRAIN_HOME,
1798
+ mcpServers
1799
+ }, REQ_TIMEOUT$1);
1800
+ if (!res?.sessionId) throw new Error("session/new returned no sessionId");
1801
+ await applyModel$1(res.sessionId);
1802
+ userSessions.set(userId, res.sessionId);
1803
+ map[userId] = res.sessionId;
1804
+ saveSessionMap$1(map);
1805
+ dlog$1(`session/new ok ${res.sessionId} mcp=[${mcpServers.map((s) => s.name).join(",")}]`);
1806
+ return res.sessionId;
1807
+ }
1808
+ /** 向大脑(配置的 ACP agent)提问(常驻 + 原生多轮)。失败即重建进程(下条用 session/load 恢复上下文)。 */
1809
+ async function askBrain(userId, message, onProgress, images) {
1810
+ await ensureProcess$1();
1811
+ const sid = await ensureSession$1(userId);
1812
+ assembling$1.set(sid, []);
1813
+ currentOnProgress$1 = onProgress ?? null;
1814
+ answerStarted$1 = false;
1815
+ thoughtStarted$1 = false;
1816
+ seenUpdateTypes$1.clear();
1817
+ seenTools$1.clear();
1818
+ const t0 = Date.now();
1819
+ const prompt = [];
1820
+ if (images?.length && !AGENT$1.images) prompt.push({
1821
+ type: "text",
1822
+ text: "(用户发来图片,但当前大脑不支持图片输入;请让用户改用文字,或切换到支持图片的大脑。不要臆测图片内容。)"
1823
+ });
1824
+ if (AGENT$1.images) for (const im of images ?? []) prompt.push({
1825
+ type: "image",
1826
+ mimeType: im.mimeType,
1827
+ data: im.data
1828
+ });
1829
+ if (!hintedSessions.has(sid)) {
1830
+ hintedSessions.add(sid);
1831
+ prompt.push({
1832
+ type: "text",
1833
+ text: "(系统提示:你正在通过微信和用户对话。需要给用户发图片时,在回复里用 Markdown 图片语法 ![](图片的 http 链接或本地文件绝对路径),系统会自动把它作为图片消息发出(你本身不会文生图,可发联网搜到的图、本地已有的图、或自己用代码渲染保存的图表)。重要:服务器从中国大陆下载该图,请选「可直接下载的直链」——优先 raw.githubusercontent.com 等原始文件直链;避免 wikipedia/wikimedia(大陆被墙)以及 pixabay/unsplash 等带热链保护、直链常返回 403 的图床。拿不准时优先把图先下载到本地再用本地绝对路径引用,或直接发链接让用户自己点。其余正常用文字/Markdown 回复。此提示只出现一次,无需理会本句。)"
1834
+ });
1835
+ }
1836
+ prompt.push({
1837
+ type: "text",
1838
+ text: message || "(用户发来一张图片,请查看并回应)"
1839
+ });
1840
+ try {
1841
+ await promptRequest$1({
1842
+ sessionId: sid,
1843
+ prompt
1844
+ });
1845
+ const txt = (assembling$1.get(sid) ?? []).join("").trim();
1846
+ dlog$1(`prompt ok ${Date.now() - t0}ms chunks=${(assembling$1.get(sid) ?? []).length} updateTypes=[${[...seenUpdateTypes$1].join(",")}]`);
1847
+ return txt;
1848
+ } catch (e) {
1849
+ const chunks = (assembling$1.get(sid) ?? []).length;
1850
+ const sinceFrame = lastFrameAt$1 ? Date.now() - lastFrameAt$1 : -1;
1851
+ dlog$1(`prompt FAIL ${Date.now() - t0}ms chunks=${chunks} lastFrameAgo=${sinceFrame}ms err=${String(e)} -> rebuild`);
1852
+ reset$1();
1853
+ throw e;
1854
+ } finally {
1855
+ currentOnProgress$1 = null;
1856
+ assembling$1.delete(sid);
1857
+ }
1858
+ }
1859
+ /** 用户主动开新会话:丢弃旧 session(含磁盘映射),重建一个全新 session。 */
1860
+ async function newSession(userId) {
1861
+ const map = loadSessionMap$1();
1862
+ delete map[userId];
1863
+ saveSessionMap$1(map);
1864
+ userSessions.delete(userId);
1865
+ await ensureProcess$1();
1866
+ await ensureSession$1(userId);
1867
+ }
1868
+ /** MCP 激活集变更后调用:重启 acp 进程(重建后会 session/load 恢复上下文)。 */
1869
+ function restart() {
1870
+ reset$1();
1871
+ }
1872
+ /** 当前大脑是否接受图片输入(预设能力;默认 true)。 */
1873
+ function supportsImages() {
1874
+ return AGENT$1.images;
1875
+ }
1876
+ /** 当前预设的静默期心跳覆盖(默认 undefined → 15s)。 */
1877
+ function heartbeatMs() {
1878
+ return AGENT$1.heartbeatMs;
1879
+ }
1880
+ //#endregion
1881
+ //#region src/image-reply.ts
1882
+ /**
1883
+ * 从 brain 的文本回复里提取「要作为图片发送」的引用,并返回去掉这些引用后的纯文本。
1884
+ * 覆盖三种来源:
1885
+ * A. Markdown 图片 `![alt](src)`,src 可为 http(s) URL 或本地绝对路径;
1886
+ * B. brain 按约定输出的也是上述 Markdown 语法(见 acp-brain 注入的系统提示);
1887
+ * C. 回复中裸露的图片 URL(http(s) 且以图片扩展名结尾)。
1888
+ */
1889
+ const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)(\?[^\s)]*)?$/i;
1890
+ const MAX_BYTES = 10 * 1024 * 1024;
1891
+ function extractImages(text) {
1892
+ const images = [];
1893
+ const seen = /* @__PURE__ */ new Set();
1894
+ const push = (src) => {
1895
+ const s = src.trim();
1896
+ if (s && !seen.has(s)) {
1897
+ seen.add(s);
1898
+ images.push({ src: s });
1899
+ }
1900
+ };
1901
+ let out = text ?? "";
1902
+ out = out.replace(/!\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g, (_m, src) => {
1903
+ push(src);
1904
+ return "";
1905
+ });
1906
+ out = out.replace(/(?<!\()(https?:\/\/[^\s)]+\.(?:png|jpe?g|gif|webp|bmp)(?:\?[^\s)]*)?)/gi, (m) => {
1907
+ push(m);
1908
+ return "";
1909
+ });
1910
+ out = out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
1911
+ return {
1912
+ images,
1913
+ text: out
1914
+ };
1915
+ }
1916
+ /** 把图片引用解析成字节:http(s) 下载,或本地文件读取。带大小/类型校验。 */
1917
+ async function resolveImageBytes(src) {
1918
+ if (/^https?:\/\//i.test(src)) {
1919
+ const res = await fetch(src, {
1920
+ signal: AbortSignal.timeout(15e3),
1921
+ headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" }
1922
+ });
1923
+ if (!res.ok) throw new Error(`下载图片失败 ${res.status}`);
1924
+ const buf = Buffer.from(await res.arrayBuffer());
1925
+ if (buf.length > MAX_BYTES) throw new Error(`图片过大 ${buf.length}`);
1926
+ const ct = res.headers.get("content-type") || "";
1927
+ if (!ct.startsWith("image/") && !IMG_EXT.test(src)) throw new Error(`非图片内容(${ct})`);
1928
+ return buf;
1929
+ }
1930
+ const p = src.startsWith("file://") ? fileURLToPath(src) : src;
1931
+ if (!IMG_EXT.test(p)) throw new Error(`非图片文件(仅按图片扩展名发送): ${p}`);
1932
+ const stat = fs.statSync(p);
1933
+ if (!stat.isFile()) throw new Error("不是文件");
1934
+ if (stat.size > MAX_BYTES) throw new Error(`图片过大 ${stat.size}`);
1935
+ return fs.readFileSync(p);
1936
+ }
1937
+ //#endregion
1938
+ //#region src/md-reply.ts
1939
+ /**
1940
+ * 长回复转 Markdown 文件:超过阈值的回复正文,作为 .md 文件发送(一条消息),
1941
+ * 比拆成多条文本更不易触发 iLink 连续发送上限、且阅读体验完整。
1942
+ * 文件名由内容生成一个合适的标题。
1943
+ */
1944
+ /** 超过此字符数的回复转 .md 文件发送。可用 WECHAT_REPLY_FILE_THRESHOLD 覆盖。 */
1945
+ const LONG_REPLY_THRESHOLD = Number(process.env.WECHAT_REPLY_FILE_THRESHOLD) || 1500;
1946
+ const ILLEGAL_FILENAME = /[\\/:*?"<>|\r\n\t]/g;
1947
+ /** 由内容生成一个合适的标题:取首个 Markdown 标题或首个非空行,清洗为合法文件名片段。 */
1948
+ function generateTitle(text) {
1949
+ let first = "";
1950
+ for (const ln of text.split(/\r?\n/)) {
1951
+ const t = ln.trim();
1952
+ if (t) {
1953
+ first = t;
1954
+ break;
1955
+ }
1956
+ }
1957
+ first = first.replace(/^#{1,6}\s*/, "").replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "").replace(/[*_`~]/g, "");
1958
+ first = first.replace(ILLEGAL_FILENAME, " ").replace(/\s+/g, " ").trim();
1959
+ if (first.length > 40) first = first.slice(0, 40).trim();
1960
+ return first || "回复";
1961
+ }
1962
+ /**
1963
+ * 构造长回复的 md 文件(不落盘,直接内存 buffer)。
1964
+ * 若正文未以 Markdown 标题开头,则前置一个由内容生成的 H1,便于打开后阅读。
1965
+ */
1966
+ function buildMdReply(text) {
1967
+ const title = generateTitle(text);
1968
+ const body = /^\s*#/.test(text) ? text : `# ${title}\n\n${text}`;
1969
+ return {
1970
+ fileName: `${title}.md`,
1971
+ data: Buffer.from(body, "utf-8")
1972
+ };
1973
+ }
1974
+ //#endregion
1975
+ //#region src/worker-sandbox.ts
1976
+ /**
1977
+ * worker 沙箱:与 brain 沙箱(mcp-sandbox.ts)并列,但 MCP 策略相反。
1978
+ *
1979
+ * worker 是无人值守的任务执行机,默认**继承真实配置目录的全部 MCP**(仅排除 wechat 防回环),
1980
+ * 而非 brain 的「默认空 + 对话中按需加载」——因为没人在旁边逐个加载工具,任务需要开箱即用的完整能力。
1981
+ * 记忆/技能/规则(mirrorRealKiro)与 settings(syncSettings,含模型/信任)的继承与 brain 一致。
1982
+ *
1983
+ * 按 agent 适配(同 mcp-sandbox.ts):配置目录、MCP 格式、设置继承方式均随 agent 而变。
1984
+ *
1985
+ * 设计依据:docs/方案C-MVP-远程human-in-the-loop.md §8.4。
1986
+ * 与 mcp-sandbox.ts 有少量重复(mirrorRealKiro/syncSettings),刻意复制以隔离演进、不动已稳定的 brain 沙箱。
1987
+ */
1988
+ const AG = resolveAgent(loadBrainConfig());
1989
+ /** worker 专用沙箱根(由 agent 的 homeEnvVar 指向它)。 */
1990
+ const WORKER_HOME = process.env.WEMIND_WORKER_HOME?.trim() || path.join(DATA_DIR, "worker-home");
1991
+ /** 该 agent 在 worker 沙箱里的「配置目录」(kiro=沙箱根;opencode=<沙箱>/opencode)。 */
1992
+ const WORKER_CONFIG_DIR = path.join(WORKER_HOME, AG.configSubdir);
1993
+ const WORKER_MCP_JSON = path.join(WORKER_CONFIG_DIR, AG.mcpConfigFile);
1994
+ /** MCP「目录」:可继承的 MCP 定义来源(同 brain,默认取 agent 真实配置目录的 catalogFile)。 */
1995
+ const CATALOG = process.env.WEMIND_MCP_CATALOG?.trim() || process.env.WECHAT_MCP_CATALOG?.trim() || path.join(AG.realHome || path.join(os.homedir(), ".kiro"), AG.catalogFile);
1996
+ /** worker 不继承的 MCP:wechat 始终强制排除(防回环),外加 WEMIND_WORKER_MCP_EXCLUDE 指定的。 */
1997
+ function excludeSet() {
1998
+ const raw = process.env.WEMIND_WORKER_MCP_EXCLUDE ?? "wechat";
1999
+ const s = new Set(raw.split(/[,\s]+/).map((x) => x.trim()).filter(Boolean));
2000
+ s.add("wechat");
2001
+ return s;
2002
+ }
2003
+ function readJson(file) {
2004
+ try {
2005
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
2006
+ } catch {
2007
+ return null;
2008
+ }
2009
+ }
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);
2015
+ try {
2016
+ if (!fs.existsSync(src)) return;
2017
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
2018
+ const s = fs.statSync(src);
2019
+ const d = fs.existsSync(dst) ? fs.statSync(dst) : null;
2020
+ if (!d || s.mtimeMs > d.mtimeMs + 1e3) fs.copyFileSync(src, dst);
2021
+ } catch {}
2022
+ }
2023
+ function loadCatalog() {
2024
+ return readMcpServers(readJson(CATALOG), AG.mcpFormat);
2025
+ }
2026
+ /** worker 继承全量 MCP:复制 catalog 全部 server,去掉排除集与 disabled:true 的。
2027
+ * `mcpViaAcp` 预设(DSH)不写文件——全量集合经 workerMcpServers() 转成 ACP mcpServers 随 session 下发。 */
2028
+ function applyWorkerSandbox() {
2029
+ if (AG.mcpViaAcp) return;
2030
+ const cat = loadCatalog();
2031
+ const ex = excludeSet();
2032
+ const servers = {};
2033
+ for (const [name, def] of Object.entries(cat)) {
2034
+ if (ex.has(name)) continue;
2035
+ if (def && typeof def === "object" && def.disabled === true) continue;
2036
+ servers[name] = def;
2037
+ }
2038
+ const next = withMcpServers(readJson(WORKER_MCP_JSON) ?? {}, servers, AG.mcpFormat);
2039
+ fs.mkdirSync(path.dirname(WORKER_MCP_JSON), { recursive: true });
2040
+ fs.writeFileSync(WORKER_MCP_JSON, JSON.stringify(next, null, 2), "utf-8");
2041
+ }
2042
+ /** worker 当前应挂载的 ACP mcpServers(mcpViaAcp 预设用):全量 catalog(除排除集/disabled/坏条目)→ ACP 声明。 */
2043
+ function workerMcpServers() {
2044
+ const cat = loadCatalog();
2045
+ const ex = excludeSet();
2046
+ const picked = {};
2047
+ for (const [name, def] of Object.entries(cat)) {
2048
+ if (ex.has(name)) continue;
2049
+ if (def && typeof def === "object" && def.disabled === true) continue;
2050
+ picked[name] = def;
2051
+ }
2052
+ const { servers, skipped } = toAcpMcpServers(picked, { exclude: ex });
2053
+ if (skipped.length) console.error(`[wemind/wechat-daemon/mcp] worker 跳过无法转换的 MCP:${skipped.join(", ")}`);
2054
+ return servers;
2055
+ }
2056
+ /** kiro:继承真实 settings/ 下除 mcp.json 外的文件(cli.json 含模型/免确认等)。逻辑同 brain 的 syncKiroSettings。 */
2057
+ function syncKiroSettings() {
2058
+ const realSettings = path.join(AG.realHome, "settings");
2059
+ const workerSettings = path.join(WORKER_CONFIG_DIR, "settings");
2060
+ if (!fs.existsSync(realSettings)) return;
2061
+ try {
2062
+ fs.mkdirSync(workerSettings, { recursive: true });
2063
+ } catch {}
2064
+ let names = [];
2065
+ try {
2066
+ names = fs.readdirSync(realSettings);
2067
+ } catch {
2068
+ return;
2069
+ }
2070
+ for (const f of names) {
2071
+ if (f === "mcp.json") continue;
2072
+ try {
2073
+ const src = path.join(realSettings, f);
2074
+ if (fs.statSync(src).isFile()) fs.copyFileSync(src, path.join(workerSettings, f));
2075
+ } catch {}
2076
+ }
2077
+ }
2078
+ /** opencode:把用户全局 opencode.json(c)(去 mcp 段)作基底写入 worker 沙箱,继承 model 等;mcp 由 applyWorkerSandbox 覆盖。 */
2079
+ function syncOpencodeBase() {
2080
+ const src = ["opencode.json", "opencode.jsonc"].map((f) => path.join(AG.realHome, f)).find((f) => fs.existsSync(f));
2081
+ if (!src) return;
2082
+ let parsed = null;
2083
+ try {
2084
+ const raw = fs.readFileSync(src, "utf-8");
2085
+ parsed = src.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw);
2086
+ } catch {
2087
+ return;
2088
+ }
2089
+ if (!parsed || typeof parsed !== "object") return;
2090
+ delete parsed.mcp;
2091
+ fs.mkdirSync(WORKER_CONFIG_DIR, { recursive: true });
2092
+ fs.writeFileSync(WORKER_MCP_JSON, JSON.stringify(parsed, null, 2), "utf-8");
2093
+ }
2094
+ function syncSettings() {
2095
+ if (AG.mcpViaAcp) return;
2096
+ if (!AG.inherit || !AG.realHome) return;
2097
+ if (AG.mcpFormat === "opencode") syncOpencodeBase();
2098
+ else syncKiroSettings();
2099
+ }
2100
+ /** 用 junction/symlink 把真实配置目录的记忆/技能/规则类子目录链入 worker 配置目录;单文件记忆直接拷。逻辑同 brain 的 mirrorRealKiro。 */
2101
+ function mirrorRealKiro() {
2102
+ if (!AG.inherit || !AG.realHome) return;
2103
+ if (path.resolve(WORKER_HOME) === path.resolve(AG.realHome)) return;
2104
+ try {
2105
+ fs.mkdirSync(WORKER_CONFIG_DIR, { recursive: true });
2106
+ } catch {}
2107
+ for (const d of AG.inheritDirs) {
2108
+ const target = path.join(AG.realHome, d);
2109
+ const link = path.join(WORKER_CONFIG_DIR, d);
2110
+ if (!fs.existsSync(target)) continue;
2111
+ let present = false;
2112
+ try {
2113
+ fs.lstatSync(link);
2114
+ present = true;
2115
+ } catch {}
2116
+ if (present) try {
2117
+ const st = fs.lstatSync(link);
2118
+ if (st.isSymbolicLink()) continue;
2119
+ if (st.isDirectory()) if (fs.readdirSync(link).length === 0) fs.rmdirSync(link);
2120
+ else {
2121
+ for (const f of fs.readdirSync(target)) {
2122
+ const dst = path.join(link, f);
2123
+ const src = path.join(target, f);
2124
+ try {
2125
+ if (!fs.existsSync(dst) && fs.statSync(src).isFile()) fs.copyFileSync(src, dst);
2126
+ } catch {}
2127
+ }
2128
+ continue;
2129
+ }
2130
+ else continue;
2131
+ } catch {
2132
+ continue;
2133
+ }
2134
+ try {
2135
+ fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
2136
+ } catch {}
2137
+ }
2138
+ for (const f of AG.inheritFiles) {
2139
+ const src = path.join(AG.realHome, f);
2140
+ const dst = path.join(WORKER_CONFIG_DIR, f);
2141
+ try {
2142
+ if (fs.existsSync(src) && !fs.existsSync(dst)) fs.copyFileSync(src, dst);
2143
+ } catch {}
2144
+ }
2145
+ }
2146
+ /** 启动时确保 worker 沙箱就绪:继承 settings(除 mcp)→ 写全量 MCP(除排除集)→ 链入记忆/技能。 */
2147
+ function ensureWorkerHome() {
2148
+ syncSettings();
2149
+ applyWorkerSandbox();
2150
+ mirrorRealKiro();
2151
+ syncCredentialFile();
2152
+ }
2153
+ //#endregion
2154
+ //#region src/worker-acp.ts
2155
+ /**
2156
+ * worker ACP 客户端:维护一个常驻的 ACP agent 进程,专跑「漫长任务」(与 acp-brain 的对话大脑并存、互不干扰)。
2157
+ * 约 70% 复制自 acp-brain.ts,差异:
2158
+ * - **独立单例**(独立 child/pending/session),与 brain 进程并存;
2159
+ * - **单任务 session**(MVP 单任务,固定 key="task");持久化到 worker-sessions.json,重建后 session/load 续上下文;
2160
+ * - **cwd = 任务工作目录**(WEMIND_WORKER_CWD,默认用户主目录),而 KIRO_HOME 指向 WORKER_HOME 沙箱(继承全量 MCP 除 wechat + 记忆);
2161
+ * - **更宽松超时**(漫长任务,env 可调);
2162
+ * - 去掉 brain 的「可发图」首轮提示(worker 是任务执行,非聊天发图场景)。
2163
+ * 自愈:session/prompt 超时/失败 → 杀进程;下次 askWorker 重建并 session/load 恢复(→ 用户回「继续」即可续跑)。
2164
+ */
2165
+ function dlog(...a) {
2166
+ console.error("[wemind/wechat-daemon/worker]", ...a);
2167
+ }
2168
+ const AGENT = resolveAgent(loadBrainConfig());
2169
+ const WORKER_CWD = process.env.WEMIND_WORKER_CWD?.trim() || os.homedir();
2170
+ const REQ_TIMEOUT = 3e4;
2171
+ const PROMPT_IDLE_MS = Number(process.env.WEMIND_WORKER_IDLE_MS) || 18e4;
2172
+ const PROMPT_CAP_MS = Number(process.env.WEMIND_WORKER_CAP_MS) || 18e5;
2173
+ const FIRST_FRAME_MS = Number(process.env.WEMIND_WORKER_FIRST_FRAME_MS) || AGENT.firstFrameMs || 3e4;
2174
+ const SESSION_MAP_FILE = path.join(DATA_DIR, "worker-sessions.json");
2175
+ const WORKER_PID_FILE = path.join(DATA_DIR, "worker.pid");
2176
+ const TASK_KEY = "task";
2177
+ let pendingCwd;
2178
+ /** 设置「下次(本任务首轮)spawn worker 用的 cwd」。本地命令派发传入终端当前目录;不传/空则用默认 WORKER_CWD。 */
2179
+ function setWorkerCwd(cwd) {
2180
+ pendingCwd = cwd?.trim() || void 0;
2181
+ }
2182
+ /** 本任务有效 cwd(指定优先,否则默认)。 */
2183
+ function effectiveCwd() {
2184
+ return pendingCwd ?? WORKER_CWD;
2185
+ }
2186
+ let child = null;
2187
+ let initialized = false;
2188
+ let idCounter = 0;
2189
+ const pending = /* @__PURE__ */ new Map();
2190
+ const taskSessions = /* @__PURE__ */ new Map();
2191
+ const assembling = /* @__PURE__ */ new Map();
2192
+ let stdoutBuf = "";
2193
+ let lastFrameAt = 0;
2194
+ let frameSeq = 0;
2195
+ let currentOnProgress = null;
2196
+ let answerStarted = false;
2197
+ let thoughtStarted = false;
2198
+ const seenUpdateTypes = /* @__PURE__ */ new Set();
2199
+ const seenTools = /* @__PURE__ */ new Set();
2200
+ function isWorkerAlive() {
2201
+ return !!child && child.exitCode === null && !child.killed;
2202
+ }
2203
+ /** 杀进程(按 PID 进程树)并清空内部态;不清磁盘 session 映射(留给重建后 load)。 */
2204
+ function reset() {
2205
+ const c = child;
2206
+ child = null;
2207
+ initialized = false;
2208
+ if (c?.pid) try {
2209
+ if (process.platform === "win32") spawnSync("taskkill", [
2210
+ "/PID",
2211
+ String(c.pid),
2212
+ "/T",
2213
+ "/F"
2214
+ ], {
2215
+ stdio: "ignore",
2216
+ windowsHide: true
2217
+ });
2218
+ else c.kill();
2219
+ } catch {}
2220
+ try {
2221
+ fs.unlinkSync(WORKER_PID_FILE);
2222
+ } catch {}
2223
+ for (const p of pending.values()) {
2224
+ p.clear();
2225
+ p.reject(/* @__PURE__ */ new Error("worker process reset"));
2226
+ }
2227
+ pending.clear();
2228
+ taskSessions.clear();
2229
+ assembling.clear();
2230
+ stdoutBuf = "";
2231
+ }
2232
+ function loadSessionMap() {
2233
+ try {
2234
+ const j = JSON.parse(fs.readFileSync(SESSION_MAP_FILE, "utf-8"));
2235
+ return j && typeof j === "object" ? j : {};
2236
+ } catch {
2237
+ return {};
2238
+ }
2239
+ }
2240
+ function saveSessionMap(m) {
2241
+ fs.mkdirSync(DATA_DIR, { recursive: true });
2242
+ fs.writeFileSync(SESSION_MAP_FILE, JSON.stringify(m, null, 2), "utf-8");
2243
+ }
2244
+ function handleReverseRequest(f) {
2245
+ const method = String(f.method);
2246
+ dlog(`← reverse request ${method}#${f.id}`);
2247
+ if (method === "session/request_permission") {
2248
+ const options = Array.isArray(f.params?.options) ? f.params.options : [];
2249
+ const allow = process.env.WEMIND_ACP_AUTO_ALLOW === "1" ? options.find((o) => o?.kind === "allow_once" || o?.kind === "allow_always") : void 0;
2250
+ const result = allow ? { outcome: {
2251
+ outcome: "selected",
2252
+ optionId: allow.optionId
2253
+ } } : { outcome: { outcome: "cancelled" } };
2254
+ child?.stdin.write(JSON.stringify({
2255
+ jsonrpc: "2.0",
2256
+ id: f.id,
2257
+ result
2258
+ }) + "\n");
2259
+ dlog(`permission ${allow ? "allowed" : "rejected"}`);
2260
+ return;
2261
+ }
2262
+ child?.stdin.write(JSON.stringify({
2263
+ jsonrpc: "2.0",
2264
+ id: f.id,
2265
+ error: {
2266
+ code: -32601,
2267
+ message: "method not supported"
2268
+ }
2269
+ }) + "\n");
2270
+ }
2271
+ function handleFrame(f) {
2272
+ lastFrameAt = Date.now();
2273
+ frameSeq += 1;
2274
+ if (typeof f.id === "number" && (f.result !== void 0 || f.error !== void 0)) {
2275
+ const p = pending.get(f.id);
2276
+ if (!p) return;
2277
+ p.clear();
2278
+ pending.delete(f.id);
2279
+ if (f.error) {
2280
+ dlog(`← ${p.method}#${f.id} ERROR ${JSON.stringify(f.error).slice(0, 200)}`);
2281
+ p.reject(new Error(JSON.stringify(f.error)));
2282
+ } else p.resolve(f.result);
2283
+ return;
2284
+ }
2285
+ if (f.method && f.id !== void 0) {
2286
+ handleReverseRequest(f);
2287
+ return;
2288
+ }
2289
+ if (f.method === "session/update") {
2290
+ const u = f.params?.update;
2291
+ if (u?.sessionUpdate) seenUpdateTypes.add(String(u.sessionUpdate));
2292
+ if (currentOnProgress) try {
2293
+ currentOnProgress({ type: "activity" });
2294
+ } catch {}
2295
+ if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") {
2296
+ const arr = assembling.get(f.params.sessionId);
2297
+ if (arr) arr.push(String(u.content.text ?? ""));
2298
+ if (!answerStarted && currentOnProgress) {
2299
+ answerStarted = true;
2300
+ try {
2301
+ currentOnProgress({ type: "answer" });
2302
+ } catch {}
2303
+ }
2304
+ } else if (u?.sessionUpdate === "agent_thought_chunk") {
2305
+ if (!thoughtStarted && currentOnProgress) {
2306
+ thoughtStarted = true;
2307
+ try {
2308
+ currentOnProgress({ type: "thinking" });
2309
+ } catch {}
2310
+ }
2311
+ } else if ((u?.sessionUpdate === "tool_call" || u?.sessionUpdate === "tool_call_update") && currentOnProgress) {
2312
+ const tid = String(u.toolCallId ?? u.title ?? "");
2313
+ if (tid && !seenTools.has(tid)) {
2314
+ seenTools.add(tid);
2315
+ const purpose = typeof u.rawInput?.__tool_use_purpose === "string" ? u.rawInput.__tool_use_purpose : void 0;
2316
+ try {
2317
+ currentOnProgress({
2318
+ type: "tool",
2319
+ kind: u.kind,
2320
+ purpose,
2321
+ title: u.title
2322
+ });
2323
+ } catch {}
2324
+ }
2325
+ }
2326
+ }
2327
+ }
2328
+ function onStdout(d) {
2329
+ stdoutBuf += d.toString();
2330
+ let i;
2331
+ while ((i = stdoutBuf.indexOf("\n")) >= 0) {
2332
+ const line = stdoutBuf.slice(0, i).trim();
2333
+ stdoutBuf = stdoutBuf.slice(i + 1);
2334
+ if (!line) continue;
2335
+ let f;
2336
+ try {
2337
+ f = JSON.parse(line);
2338
+ } catch {
2339
+ continue;
2340
+ }
2341
+ handleFrame(f);
2342
+ }
2343
+ }
2344
+ function request(method, params, timeoutMs) {
2345
+ return new Promise((resolve, reject) => {
2346
+ if (!child) {
2347
+ reject(/* @__PURE__ */ new Error("no worker process"));
2348
+ return;
2349
+ }
2350
+ const id = ++idCounter;
2351
+ dlog(`→ ${method}#${id}`);
2352
+ const t = setTimeout(() => {
2353
+ pending.delete(id);
2354
+ reject(/* @__PURE__ */ new Error(`${method} timeout after ${timeoutMs}ms`));
2355
+ }, timeoutMs);
2356
+ pending.set(id, {
2357
+ resolve,
2358
+ reject,
2359
+ clear: () => clearTimeout(t),
2360
+ method
2361
+ });
2362
+ child.stdin.write(JSON.stringify({
2363
+ jsonrpc: "2.0",
2364
+ id,
2365
+ method,
2366
+ params
2367
+ }) + "\n");
2368
+ });
2369
+ }
2370
+ /** session/prompt 专用:空闲超时 + 首帧超时 + 硬上限(漫长任务,比 brain 宽松)。 */
2371
+ function promptRequest(params) {
2372
+ return new Promise((resolve, reject) => {
2373
+ if (!child) {
2374
+ reject(/* @__PURE__ */ new Error("no worker process"));
2375
+ return;
2376
+ }
2377
+ const id = ++idCounter;
2378
+ const startedAt = Date.now();
2379
+ lastFrameAt = Date.now();
2380
+ const startSeq = frameSeq;
2381
+ dlog(`→ session/prompt#${id} (idle=${PROMPT_IDLE_MS}ms cap=${PROMPT_CAP_MS}ms firstFrame=${FIRST_FRAME_MS}ms)`);
2382
+ const iv = setInterval(() => {
2383
+ const idle = Date.now() - lastFrameAt;
2384
+ const total = Date.now() - startedAt;
2385
+ const firstFrameTimeout = frameSeq === startSeq && total > FIRST_FRAME_MS;
2386
+ if (firstFrameTimeout || idle > PROMPT_IDLE_MS || total > PROMPT_CAP_MS) {
2387
+ if (pending.delete(id)) {
2388
+ clearInterval(iv);
2389
+ reject(/* @__PURE__ */ new Error(`session/prompt ${firstFrameTimeout ? "no-first-frame" : idle > PROMPT_IDLE_MS ? "idle" : "cap"} timeout (idle=${idle}ms total=${total}ms)`));
2390
+ }
2391
+ }
2392
+ }, 5e3);
2393
+ pending.set(id, {
2394
+ resolve,
2395
+ reject,
2396
+ clear: () => clearInterval(iv),
2397
+ method: "session/prompt"
2398
+ });
2399
+ child.stdin.write(JSON.stringify({
2400
+ jsonrpc: "2.0",
2401
+ id,
2402
+ method: "session/prompt",
2403
+ params
2404
+ }) + "\n");
2405
+ });
2406
+ }
2407
+ async function ensureProcess() {
2408
+ if (isWorkerAlive() && initialized) return;
2409
+ reset();
2410
+ const env = { ...process.env };
2411
+ if (AGENT.homeEnvVar) env[AGENT.homeEnvVar] = WORKER_HOME;
2412
+ for (const [k, v] of Object.entries(AGENT.env ?? {})) env[k] = v.replaceAll("%HOME%", WORKER_HOME);
2413
+ const cwd = effectiveCwd();
2414
+ const proc = spawnAgent(AGENT, {
2415
+ cwd,
2416
+ env
2417
+ });
2418
+ child = proc;
2419
+ dlog(`spawn ${AGENT.name} worker pid=${proc.pid} cwd=${cwd} home=${WORKER_HOME}`);
2420
+ try {
2421
+ fs.writeFileSync(WORKER_PID_FILE, String(proc.pid), "utf-8");
2422
+ } catch {}
2423
+ proc.stdout.on("data", onStdout);
2424
+ proc.stderr.on("data", () => {});
2425
+ proc.on("exit", (code) => {
2426
+ dlog(`worker exited code=${code}`);
2427
+ if (child === proc) reset();
2428
+ });
2429
+ proc.on("error", (e) => {
2430
+ dlog(`worker error ${String(e)}`);
2431
+ if (child === proc) reset();
2432
+ });
2433
+ await request("initialize", {
2434
+ protocolVersion: 1,
2435
+ clientCapabilities: {}
2436
+ }, REQ_TIMEOUT);
2437
+ initialized = true;
2438
+ dlog("initialized");
2439
+ }
2440
+ async function applyModel(sid) {
2441
+ if (!AGENT.model) return;
2442
+ try {
2443
+ await request("session/set_config_option", {
2444
+ sessionId: sid,
2445
+ configId: "model",
2446
+ value: AGENT.model
2447
+ }, REQ_TIMEOUT);
2448
+ dlog(`model set ${AGENT.model}`);
2449
+ } catch (e) {
2450
+ dlog(`model set failed (${String(e)})`);
2451
+ throw e;
2452
+ }
2453
+ }
2454
+ async function ensureSession(key) {
2455
+ const existing = taskSessions.get(key);
2456
+ if (existing) return existing;
2457
+ const mcpServers = AGENT.mcpViaAcp ? workerMcpServers() : [];
2458
+ const map = loadSessionMap();
2459
+ const saved = map[key];
2460
+ if (saved) {
2461
+ const methods = AGENT.restore === "resume" ? ["resume"] : ["load", "resume"];
2462
+ for (const method of methods) try {
2463
+ await request(`session/${method}`, {
2464
+ sessionId: saved,
2465
+ cwd: effectiveCwd(),
2466
+ mcpServers
2467
+ }, REQ_TIMEOUT);
2468
+ await applyModel(saved);
2469
+ taskSessions.set(key, saved);
2470
+ dlog(`session/${method} ok ${saved} mcp=[${mcpServers.map((s) => s.name).join(",")}]`);
2471
+ return saved;
2472
+ } catch (e) {
2473
+ dlog(`session/${method} failed (${String(e)})`);
2474
+ }
2475
+ }
2476
+ const res = await request("session/new", {
2477
+ cwd: effectiveCwd(),
2478
+ mcpServers
2479
+ }, REQ_TIMEOUT);
2480
+ if (!res?.sessionId) throw new Error("session/new returned no sessionId");
2481
+ await applyModel(res.sessionId);
2482
+ taskSessions.set(key, res.sessionId);
2483
+ map[key] = res.sessionId;
2484
+ saveSessionMap(map);
2485
+ dlog(`session/new ok ${res.sessionId} mcp=[${mcpServers.map((s) => s.name).join(",")}]`);
2486
+ return res.sessionId;
2487
+ }
2488
+ /**
2489
+ * 向 worker 发一轮 prompt(任务派发或注入续跑),等这一轮 ACP 响应结束,返回拼接文本。
2490
+ * 失败即重建进程(下一轮 session/load 恢复上下文)→ 配合「继续」实现中断恢复。
2491
+ */
2492
+ async function askWorker(instruction, onProgress) {
2493
+ await ensureProcess();
2494
+ const sid = await ensureSession(TASK_KEY);
2495
+ assembling.set(sid, []);
2496
+ currentOnProgress = onProgress ?? null;
2497
+ answerStarted = false;
2498
+ thoughtStarted = false;
2499
+ seenUpdateTypes.clear();
2500
+ seenTools.clear();
2501
+ const t0 = Date.now();
2502
+ const prompt = [{
2503
+ type: "text",
2504
+ text: instruction || "(继续)"
2505
+ }];
2506
+ try {
2507
+ await promptRequest({
2508
+ sessionId: sid,
2509
+ prompt
2510
+ });
2511
+ const txt = (assembling.get(sid) ?? []).join("").trim();
2512
+ dlog(`prompt ok ${Date.now() - t0}ms chunks=${(assembling.get(sid) ?? []).length} updateTypes=[${[...seenUpdateTypes].join(",")}]`);
2513
+ return txt;
2514
+ } catch (e) {
2515
+ const chunks = (assembling.get(sid) ?? []).length;
2516
+ const sinceFrame = lastFrameAt ? Date.now() - lastFrameAt : -1;
2517
+ dlog(`prompt FAIL ${Date.now() - t0}ms chunks=${chunks} lastFrameAgo=${sinceFrame}ms err=${String(e)} -> rebuild`);
2518
+ reset();
2519
+ throw e;
2520
+ } finally {
2521
+ currentOnProgress = null;
2522
+ assembling.delete(sid);
2523
+ }
2524
+ }
2525
+ /** 开新任务会话:丢弃旧 session(含磁盘映射),下次 askWorker 全新开始。 */
2526
+ function newWorkerSession() {
2527
+ const map = loadSessionMap();
2528
+ delete map[TASK_KEY];
2529
+ saveSessionMap(map);
2530
+ taskSessions.delete(TASK_KEY);
2531
+ }
2532
+ /** 停止 worker:杀进程、清状态、丢弃任务 session(任务结束)。 */
2533
+ function stopWorker() {
2534
+ reset();
2535
+ newWorkerSession();
2536
+ pendingCwd = void 0;
2537
+ }
2538
+ //#endregion
2539
+ //#region src/task-manager.ts
2540
+ const log$1 = (...a) => console.error("[wemind/wechat-daemon/task]", ...a);
2541
+ let activeTask = null;
2542
+ let push = () => {};
2543
+ function setTaskPush(fn) {
2544
+ push = fn;
2545
+ }
2546
+ let typing = () => {};
2547
+ function setTaskTyping(fn) {
2548
+ typing = fn;
2549
+ }
2550
+ let stateSink = () => {};
2551
+ function setTaskStateSink(fn) {
2552
+ stateSink = fn;
2553
+ }
2554
+ /** 用当前 activeTask 构造快照并落盘(在派发/每轮/waiting/停止等关键节点调用)。 */
2555
+ function emitState() {
2556
+ const t = activeTask;
2557
+ const snap = t ? {
2558
+ active: true,
2559
+ status: t.status,
2560
+ to: t.to,
2561
+ startedAt: t.startedAt,
2562
+ updatedAt: Date.now(),
2563
+ turns: t.turns,
2564
+ lastOutput: t.lastOutput
2565
+ } : {
2566
+ active: false,
2567
+ status: "idle",
2568
+ updatedAt: Date.now(),
2569
+ turns: 0
2570
+ };
2571
+ try {
2572
+ stateSink(snap);
2573
+ } catch {}
2574
+ }
2575
+ const PROGRESS = (process.env.WEMIND_WORKER_PROGRESS ?? "0") === "1";
2576
+ /** 是否存在活跃任务(可选限定某用户)。 */
2577
+ function hasActiveTask(to) {
2578
+ return !!activeTask && (to === void 0 || activeTask.to === to);
2579
+ }
2580
+ /** worker 是否正等该用户回复(waiting 态)。路由用:仅此时「无前缀」消息才默认注入 worker。 */
2581
+ function isAwaiting(to) {
2582
+ return !!activeTask && activeTask.to === to && activeTask.status === "waiting";
2583
+ }
2584
+ /** 派发新任务(后台异步执行,立即返回回执文案,不阻塞调用方)。 */
2585
+ function dispatchTask(to, instruction, cwd) {
2586
+ if (activeTask) return "已有任务在跑,先回复「结束任务」再派发新任务。";
2587
+ setWorkerCwd(cwd);
2588
+ runTask(to, instruction);
2589
+ return "✅ 已派给工作机执行(任务运行中)。需要你决策或中断时我会主动找你;随时可回复「结束任务」终止。";
2590
+ }
2591
+ /** 注入用户消息:waiting → 直接喂下一轮;running → 暂存为 pendingInject。 */
2592
+ function injectTask(to, text) {
2593
+ if (!activeTask || activeTask.to !== to) return;
2594
+ if (activeTask.status === "waiting" && activeTask.injectResolver) {
2595
+ const r = activeTask.injectResolver;
2596
+ activeTask.injectResolver = void 0;
2597
+ r(text);
2598
+ } else {
2599
+ (activeTask.pendingInjects ??= []).push(text);
2600
+ push(to, "📝 已记下,等当前这步跑完就处理。");
2601
+ }
2602
+ }
2603
+ /** 结束任务:停 worker、清状态、唤醒退出循环。 */
2604
+ function stopTask(to) {
2605
+ if (!activeTask || activeTask.to !== to) {
2606
+ emitState();
2607
+ return "当前没有正在运行的任务。";
2608
+ }
2609
+ const task = activeTask;
2610
+ activeTask = null;
2611
+ if (task.injectResolver) {
2612
+ const r = task.injectResolver;
2613
+ task.injectResolver = void 0;
2614
+ r(null);
2615
+ }
2616
+ stopWorker();
2617
+ emitState();
2618
+ return "🛑 任务已结束。";
2619
+ }
2620
+ /** 等待用户注入(waiting 态);若有暂存的 pendingInject 直接消费。 */
2621
+ function waitForInject(task) {
2622
+ if (task.pendingInjects?.length) {
2623
+ const t = task.pendingInjects.join("\n");
2624
+ task.pendingInjects = void 0;
2625
+ return Promise.resolve(t);
2626
+ }
2627
+ return new Promise((resolve) => {
2628
+ task.injectResolver = resolve;
2629
+ });
2630
+ }
2631
+ function reason(e) {
2632
+ const s = String(e instanceof Error ? e.message : e);
2633
+ if (/no-first-frame|idle|cap|timeout/i.test(s)) return "超时/卡住";
2634
+ return "执行出错";
2635
+ }
2636
+ /** 后台任务循环:每轮 askWorker → 推送本轮输出 → 等注入 → 续跑。绝不在 workerLoop 里 await 它。 */
2637
+ async function runTask(to, firstInstruction) {
2638
+ activeTask = {
2639
+ to,
2640
+ status: "running",
2641
+ startedAt: Date.now(),
2642
+ turns: 0
2643
+ };
2644
+ emitState();
2645
+ let instruction = firstInstruction;
2646
+ while (activeTask && instruction !== null) {
2647
+ typing(to);
2648
+ const ti = setInterval(() => typing(to), 5e3);
2649
+ try {
2650
+ const out = await askWorker(instruction, PROGRESS ? (ev) => {
2651
+ if (ev.type === "tool") push(to, "🔧 worker 正在调用工具…");
2652
+ } : void 0);
2653
+ if (!activeTask) break;
2654
+ const text = `🤖【任务】${out || "(本轮无输出,可回复「继续」推进或「结束任务」终止)"}`;
2655
+ activeTask.turns += 1;
2656
+ activeTask.lastOutput = text;
2657
+ push(to, text);
2658
+ } catch (e) {
2659
+ if (!activeTask) break;
2660
+ log$1("worker turn failed:", String(e));
2661
+ const text = `⚠️ 任务中断了(${reason(e)})。回复「继续」我从中断处恢复,或「结束任务」终止。`;
2662
+ activeTask.lastOutput = text;
2663
+ push(to, text);
2664
+ } finally {
2665
+ clearInterval(ti);
2666
+ }
2667
+ if (!activeTask) break;
2668
+ activeTask.status = "waiting";
2669
+ emitState();
2670
+ instruction = await waitForInject(activeTask);
2671
+ if (activeTask) {
2672
+ activeTask.status = "running";
2673
+ emitState();
2674
+ }
2675
+ }
2676
+ emitState();
2677
+ log$1("runTask loop ended");
2678
+ }
2679
+ //#endregion
2680
+ //#region src/index.ts
2681
+ const log = (...a) => console.error("[wemind/wechat-daemon]", ...a);
2682
+ const sleep$2 = (ms) => new Promise((r) => setTimeout(r, ms));
2683
+ /** 分类大脑错误:backend=模型后端瞬时错误(可多重试);timeout=超时/卡死;other=其它。 */
2684
+ function classifyBrainError(e) {
2685
+ const s = String(e instanceof Error ? e.message : e);
2686
+ if (/-32603|internal error|please try again|response stream|overloaded|rate.?limit|\b(429|503)\b/i.test(s)) return "backend";
2687
+ if (/idle timeout|no-first-frame|cap timeout|timeout/i.test(s)) return "timeout";
2688
+ return "other";
2689
+ }
2690
+ /** 按错误类型给用户的失败提示。 */
2691
+ function brainErrorMessage(e) {
2692
+ switch (classifyBrainError(e)) {
2693
+ case "backend": return "(模型后端临时出错了,自动重试多次仍失败,请稍后把消息再发一次)";
2694
+ case "timeout": return "(这次处理超时/卡住被中断了,重试也没成功——可把任务拆小一点再发)";
2695
+ default: return "(处理出错了,请把消息再发一次试试)";
2696
+ }
2697
+ }
2698
+ function extractText(msg) {
2699
+ return (msg.item_list ?? []).map((it) => {
2700
+ if (it.type === MessageItemType.TEXT) return it.text_item?.text ?? "";
2701
+ if (it.type === MessageItemType.VOICE) return it.voice_item?.text ?? "";
2702
+ return "";
2703
+ }).filter((s) => s).join("\n").trim();
2704
+ }
2705
+ /** 工具标题中文化(无 purpose 时的兜底)。 */
2706
+ function zhTool(t) {
2707
+ return t.replace(/^Searching the web for:?\s*/i, "联网搜索:").replace(/^Running:?\s*/i, "执行:").replace(/^Reading\s+/i, "读取 ").replace(/^Writing\s+/i, "写入 ").replace(/^Finding\s+/i, "查找 ");
2708
+ }
2709
+ /** 按工具类型给图标。 */
2710
+ function toolIcon(kind) {
2711
+ switch (kind) {
2712
+ case "read": return "📄";
2713
+ case "search": return "🔍";
2714
+ case "execute": return "⌨️";
2715
+ case "fetch": return "🌐";
2716
+ case "edit":
2717
+ case "write": return "✏️";
2718
+ case "delete": return "🗑️";
2719
+ default: return "🔧";
2720
+ }
2721
+ }
2722
+ /** 解析控制指令(命中则由 daemon 直接处理,不转发给 brain)。 */
2723
+ function parseControl(text) {
2724
+ const t = text.trim();
2725
+ if (/^(\/?mcp|列出\s*mcp|mcp\s*list)$/i.test(t)) return { kind: "list" };
2726
+ if (/^(新会话|重置|清空上下文|\/new|new\s*session)$/i.test(t)) return { kind: "new" };
2727
+ let m = t.match(/^(?:加载|load)\s+(\S+)$/i);
2728
+ if (m) return {
2729
+ kind: "add",
2730
+ name: m[1]
2731
+ };
2732
+ m = t.match(/^(?:卸载|unload)\s+(\S+)$/i);
2733
+ if (m) return {
2734
+ kind: "remove",
2735
+ name: m[1]
2736
+ };
2737
+ return null;
2738
+ }
2739
+ /** 解析「明确发给 worker」指令:任务:xxx / 任务 xxx / >xxx(toWorker:无任务则启动、有任务则注入);结束任务/停止任务 → stop。 */
2740
+ function parseTaskControl(text) {
2741
+ const t = text.trim();
2742
+ if (/^(结束任务|停止任务|\/stop)$/i.test(t)) return { kind: "stop" };
2743
+ let m = t.match(/^(?:任务|task)[::]\s*([\s\S]+)$/i);
2744
+ if (m) return {
2745
+ kind: "toWorker",
2746
+ text: m[1].trim()
2747
+ };
2748
+ m = t.match(/^>\s*([\s\S]+)$/);
2749
+ if (m) return {
2750
+ kind: "toWorker",
2751
+ text: m[1].trim()
2752
+ };
2753
+ return null;
2754
+ }
2755
+ /** 解析「明确问大脑」逃逸前缀:问大脑 xxx / 问大脑:xxx。命中返回去前缀后的文本(强制走 brain,即使 worker 在等你)。 */
2756
+ function parseBrainEscape(text) {
2757
+ const m = text.trim().match(/^问大脑[::]?\s*([\s\S]+)$/);
2758
+ return m ? m[1].trim() : null;
2759
+ }
2760
+ async function handleControl(c, userId) {
2761
+ if (c.kind === "list") return listMcp();
2762
+ if (c.kind === "new") {
2763
+ await newSession(userId);
2764
+ return "已开启新会话(上下文已清空)。";
2765
+ }
2766
+ if (c.kind === "add") {
2767
+ const r = addMcp(c.name);
2768
+ restart();
2769
+ return r;
2770
+ }
2771
+ const r = removeMcp(c.name);
2772
+ restart();
2773
+ return r;
2774
+ }
2775
+ async function setTyping(account, to, ctx, status) {
2776
+ if (!ctx) return;
2777
+ try {
2778
+ const cfg = await getConfig({
2779
+ baseUrl: account.baseUrl,
2780
+ token: account.botToken,
2781
+ ilinkUserId: to,
2782
+ contextToken: ctx
2783
+ });
2784
+ await sendTyping({
2785
+ baseUrl: account.baseUrl,
2786
+ token: account.botToken,
2787
+ body: {
2788
+ ilink_user_id: to,
2789
+ typing_ticket: cfg.typing_ticket,
2790
+ status
2791
+ }
2792
+ });
2793
+ } catch (e) {
2794
+ log(`setTyping(${status}) failed:`, String(e));
2795
+ }
2796
+ }
2797
+ async function reply(account, to, ctx, text, state = MessageState.FINISH, fallbackToken) {
2798
+ const send = (token) => {
2799
+ const req = { msg: {
2800
+ from_user_id: "",
2801
+ to_user_id: to,
2802
+ client_id: generateId("wemind/wechat-daemon"),
2803
+ message_type: MessageType.BOT,
2804
+ message_state: state,
2805
+ item_list: [{
2806
+ type: MessageItemType.TEXT,
2807
+ text_item: { text }
2808
+ }],
2809
+ context_token: token
2810
+ } };
2811
+ return sendMessage({
2812
+ baseUrl: account.baseUrl,
2813
+ token: account.botToken,
2814
+ body: req
2815
+ });
2816
+ };
2817
+ try {
2818
+ await send(ctx);
2819
+ } catch (e) {
2820
+ if (e instanceof ILinkError && e.kind === "param_error") {
2821
+ log("send -2(限流或token失效) → 退避 1.5s 后重试");
2822
+ await new Promise((r) => setTimeout(r, 1500));
2823
+ await send(fallbackToken && fallbackToken !== ctx ? fallbackToken : ctx);
2824
+ return;
2825
+ }
2826
+ if (!(e instanceof ILinkError)) {
2827
+ const t = classifyTransportError(e);
2828
+ if (t.retryable) {
2829
+ log(`send 传输错误(${t.kind}) → 退避 1.5s 后重试`);
2830
+ await new Promise((r) => setTimeout(r, 1500));
2831
+ await send(ctx);
2832
+ return;
2833
+ }
2834
+ }
2835
+ throw e;
2836
+ }
2837
+ }
2838
+ /** 统一的发送错误处理:按错误类型分类记录;会话过期则暂停账号。 */
2839
+ function handleSendError(e, account) {
2840
+ if (e instanceof ILinkError) {
2841
+ log("send error:", e.message);
2842
+ if (e.kind === "session_expired") {
2843
+ pauseSession(account.botId);
2844
+ log("会话过期(-14) on send → 该账号暂停 1h,待重新登录");
2845
+ }
2846
+ } else {
2847
+ const t = classifyTransportError(e);
2848
+ log(`send error(传输/${t.kind}${t.retryable ? ",可重试" : ""}):`, t.message);
2849
+ }
2850
+ }
2851
+ let replyChain = Promise.resolve();
2852
+ let lastSendAt = 0;
2853
+ const MIN_SEND_GAP_MS = Number(process.env.WECHAT_MIN_SEND_GAP_MS) || 1500;
2854
+ const CONSEC_SEND_LIMIT = Number(process.env.WECHAT_CONSEC_SEND_LIMIT) || 10;
2855
+ const HEARTBEAT_MS = Number(process.env.WECHAT_BRAIN_HEARTBEAT_MS) || heartbeatMs() || 15e3;
2856
+ const sendBudget = /* @__PURE__ */ new Map();
2857
+ function budgetOf(to) {
2858
+ let b = sendBudget.get(to);
2859
+ if (!b) {
2860
+ b = {
2861
+ count: 0,
2862
+ awaiting: false
2863
+ };
2864
+ sendBudget.set(to, b);
2865
+ }
2866
+ return b;
2867
+ }
2868
+ let activeTurn = null;
2869
+ function sendOrdered(account, to, ctx, text, state = MessageState.FINISH, fallbackToken) {
2870
+ replyChain = replyChain.then(async () => {
2871
+ const wait = MIN_SEND_GAP_MS - (Date.now() - lastSendAt);
2872
+ if (wait > 0) await sleep$2(wait);
2873
+ await reply(account, to, ctx, text, state, fallbackToken).catch((e) => handleSendError(e, account));
2874
+ lastSendAt = Date.now();
2875
+ });
2876
+ return replyChain;
2877
+ }
2878
+ /** 把长回复作为 .md 文件发送(走同一串行链+节流);发文件失败则兜底回退为文本。 */
2879
+ function sendMdOrdered(account, to, ctx, finalText, fallbackToken) {
2880
+ replyChain = replyChain.then(async () => {
2881
+ const wait = MIN_SEND_GAP_MS - (Date.now() - lastSendAt);
2882
+ if (wait > 0) await sleep$2(wait);
2883
+ const { fileName, data } = buildMdReply(finalText);
2884
+ try {
2885
+ await sendFile({
2886
+ baseUrl: account.baseUrl,
2887
+ token: account.botToken,
2888
+ to,
2889
+ data,
2890
+ fileName,
2891
+ kind: "file",
2892
+ contextToken: ctx
2893
+ });
2894
+ log(`final as md file: ${fileName} (${finalText.length} chars)`);
2895
+ } catch (e) {
2896
+ log("send md file failed, fallback to text:", String(e));
2897
+ await reply(account, to, ctx, finalText, MessageState.FINISH, fallbackToken).catch((er) => handleSendError(er, account));
2898
+ }
2899
+ lastSendAt = Date.now();
2900
+ });
2901
+ return replyChain;
2902
+ }
2903
+ /** 从解密后的字节嗅探图片 MIME。 */
2904
+ function sniffMime(b) {
2905
+ const h = b.subarray(0, 4).toString("hex");
2906
+ if (h.startsWith("89504e47")) return "image/png";
2907
+ if (h.startsWith("47494638")) return "image/gif";
2908
+ if (h.startsWith("52494646")) return "image/webp";
2909
+ return "image/jpeg";
2910
+ }
2911
+ async function handleMessage(account, ctx, to, text, imageItems, createTimeMs, latestToken) {
2912
+ const msgAge = () => createTimeMs ? `${Math.round((Date.now() - createTimeMs) / 1e3)}s` : "?";
2913
+ activeTurn = {
2914
+ from: to,
2915
+ token: ctx
2916
+ };
2917
+ const cx = () => activeTurn?.token ?? ctx;
2918
+ await setTyping(account, to, cx(), 1);
2919
+ const keepAlive = setInterval(() => {
2920
+ setTyping(account, to, cx(), 1).catch(() => {});
2921
+ }, 5e3);
2922
+ const t0 = Date.now();
2923
+ let lastProgressAt = Date.now();
2924
+ const sendProgress = (msg) => {
2925
+ lastProgressAt = Date.now();
2926
+ const b = budgetOf(to);
2927
+ if (b.awaiting) return;
2928
+ const sendWithTyping = (text) => {
2929
+ sendOrdered(account, to, cx(), text, MessageState.GENERATING).then(() => setTyping(account, to, cx(), 1)).catch(() => {});
2930
+ };
2931
+ if (b.count >= CONSEC_SEND_LIMIT - 2) {
2932
+ b.count += 1;
2933
+ b.awaiting = true;
2934
+ sendWithTyping("⚠️ 接近微信连续消息上限,我先暂停进度提示、继续在后台处理;完成后会把结果直接发给你。想立刻恢复进度可回复「继续」。");
2935
+ return;
2936
+ }
2937
+ b.count += 1;
2938
+ sendWithTyping(msg);
2939
+ };
2940
+ if (imageItems.length === 0) {
2941
+ const t = cx();
2942
+ log(`turn start to=${to} msgAge=${msgAge()} usingCtx=…${t ? t.slice(-12) : "NONE"}`);
2943
+ sendProgress("🤖 收到,正在处理…");
2944
+ }
2945
+ let hbCount = 0;
2946
+ let answerNoticeSent = false;
2947
+ const heartbeat = setInterval(() => {
2948
+ if (Date.now() - lastProgressAt >= HEARTBEAT_MS) {
2949
+ hbCount += 1;
2950
+ sendProgress(hbCount === 1 ? "🤔 还在思考/检索中…" : `⏳ 仍在处理中…(已 ${Math.round((Date.now() - t0) / 1e3)} 秒)`);
2951
+ }
2952
+ }, HEARTBEAT_MS);
2953
+ const images = [];
2954
+ if (imageItems.length > 0) {
2955
+ sendProgress(`🖼️ 收到 ${imageItems.length} 张图片,下载识别中…`);
2956
+ for (const im of imageItems) try {
2957
+ const buf = await downloadImage(im);
2958
+ images.push({
2959
+ data: buf.toString("base64"),
2960
+ mimeType: sniffMime(buf)
2961
+ });
2962
+ } catch (e) {
2963
+ log("image download failed:", String(e));
2964
+ }
2965
+ }
2966
+ if (images.length > 0 && !supportsImages()) {
2967
+ clearInterval(keepAlive);
2968
+ clearInterval(heartbeat);
2969
+ await setTyping(account, to, cx(), 2);
2970
+ budgetOf(to).count += 1;
2971
+ try {
2972
+ await sendOrdered(account, to, cx(), "🖼️ 当前大脑暂不支持看图,请改用文字描述,或切换到支持图片的大脑。", MessageState.FINISH);
2973
+ log("image unsupported by current brain; rejected gracefully");
2974
+ } finally {
2975
+ activeTurn = null;
2976
+ }
2977
+ return;
2978
+ }
2979
+ const attempt = (promptText, imgs) => {
2980
+ let step = 0;
2981
+ let lastToolProgressAt = 0;
2982
+ return askBrain(to, promptText, (ev) => {
2983
+ if (ev.type === "tool") {
2984
+ lastProgressAt = Date.now();
2985
+ if (Date.now() - lastToolProgressAt >= 15e3) {
2986
+ lastToolProgressAt = Date.now();
2987
+ step += 1;
2988
+ const desc = ev.purpose || (ev.title ? zhTool(ev.title) : "") || "调用工具";
2989
+ sendProgress(`${toolIcon(ev.kind)} 步骤 ${step}:${desc}`);
2990
+ }
2991
+ } else if (ev.type === "thinking") lastProgressAt = Date.now();
2992
+ else if (ev.type === "answer") {
2993
+ if (!answerNoticeSent) {
2994
+ answerNoticeSent = true;
2995
+ sendProgress("✍️ 正在整理回复…");
2996
+ }
2997
+ } else lastProgressAt = Date.now();
2998
+ }, imgs);
2999
+ };
3000
+ const runWithRetry = async (promptText, imgs) => {
3001
+ let n = 0;
3002
+ while (true) {
3003
+ n++;
3004
+ try {
3005
+ return await attempt(promptText, imgs);
3006
+ } catch (e) {
3007
+ const cat = classifyBrainError(e);
3008
+ log(`brain error (try ${n}, ${cat}): ${String(e)}`);
3009
+ if (n < (cat === "backend" ? 3 : 2)) {
3010
+ sendProgress(cat === "backend" ? "⚠️ 模型后端临时出错,正在重试…" : "⏳ 响应中断了,正在自动重试…");
3011
+ continue;
3012
+ }
3013
+ throw e;
3014
+ }
3015
+ }
3016
+ };
3017
+ let answer;
3018
+ try {
3019
+ answer = await runWithRetry(text, images);
3020
+ } catch (e2) {
3021
+ answer = brainErrorMessage(e2);
3022
+ } finally {
3023
+ clearInterval(keepAlive);
3024
+ clearInterval(heartbeat);
3025
+ }
3026
+ await setTyping(account, to, cx(), 2);
3027
+ const { images: replyImages, text: cleaned } = extractImages(answer || "");
3028
+ const failedImgs = [];
3029
+ for (const im of replyImages) try {
3030
+ const buf = await resolveImageBytes(im.src);
3031
+ await sendImage({
3032
+ baseUrl: account.baseUrl,
3033
+ token: account.botToken,
3034
+ to,
3035
+ image: buf,
3036
+ contextToken: cx()
3037
+ });
3038
+ log(`sent image: ${im.src.slice(0, 80)}`);
3039
+ } catch (e) {
3040
+ log("send image failed:", im.src, String(e));
3041
+ failedImgs.push(im.src);
3042
+ }
3043
+ let finalText = cleaned;
3044
+ if (failedImgs.length) {
3045
+ const note = `(有 ${failedImgs.length} 张图片我没能下载下来,附原链接:\n${failedImgs.join("\n")})`;
3046
+ finalText = finalText ? `${finalText}\n\n${note}` : note;
3047
+ }
3048
+ if (!finalText && !replyImages.length) finalText = "(没有内容)";
3049
+ if (finalText) {
3050
+ budgetOf(to).count += 1;
3051
+ log(`final send: msgAge=${msgAge()} sent=${budgetOf(to).count} len=${finalText.length}`);
3052
+ const ft = latestToken ? latestToken() : void 0;
3053
+ if (finalText.length > LONG_REPLY_THRESHOLD) await sendMdOrdered(account, to, cx(), finalText, ft);
3054
+ else await sendOrdered(account, to, cx(), finalText, MessageState.FINISH, ft);
3055
+ }
3056
+ log(`replied to ${to} (msgAge=${msgAge()}): ${(answer || "").slice(0, 60)}`);
3057
+ activeTurn = null;
3058
+ }
3059
+ const msgQueue = [];
3060
+ let wakeWorker = null;
3061
+ function notifyWorker() {
3062
+ const w = wakeWorker;
3063
+ wakeWorker = null;
3064
+ if (w) w();
3065
+ }
3066
+ /** 发件人鉴权:账号本人(owner,始终允许,作安全兜底) 或 在 allowFrom 授权清单中。 */
3067
+ function isAuthorized(from, owner) {
3068
+ return !!from && (from === owner || isAllowed(from));
3069
+ }
3070
+ async function pollerLoop(account, owner, state) {
3071
+ while (true) {
3072
+ if (isSessionPaused(account.botId)) {
3073
+ await sleep$2(Math.min(getRemainingPauseMs(account.botId), 6e4) || 6e4);
3074
+ continue;
3075
+ }
3076
+ let resp;
3077
+ try {
3078
+ resp = await getUpdates({
3079
+ baseUrl: account.baseUrl,
3080
+ token: account.botToken,
3081
+ updatesBuf: state.updatesBuf,
3082
+ timeoutMs: 25e3
3083
+ });
3084
+ } catch (e) {
3085
+ log("getUpdates error:", String(e));
3086
+ await sleep$2(2e3);
3087
+ continue;
3088
+ }
3089
+ try {
3090
+ if (resp.errcode === -14) {
3091
+ log("session expired (-14); pausing 1h. Send a WeChat message to revive after re-login.");
3092
+ pauseSession(account.botId);
3093
+ continue;
3094
+ }
3095
+ if (resp.errcode) {
3096
+ log("server error:", resp.errcode, resp.errmsg);
3097
+ await sleep$2(2e3);
3098
+ continue;
3099
+ }
3100
+ if (resp.get_updates_buf) state.updatesBuf = resp.get_updates_buf;
3101
+ const news = (resp.msgs ?? []).filter((m) => m.message_type === MessageType.USER && m.message_id && m.message_id > state.lastMessageId);
3102
+ for (const m of news) {
3103
+ const f = m.from_user_id ?? "";
3104
+ if (isAuthorized(f, owner)) log(`recv msg id=${m.message_id} ctx=…${m.context_token ? m.context_token.slice(-12) : "NONE"} age=${m.create_time_ms ? Math.round((Date.now() - m.create_time_ms) / 1e3) + "s" : "?"}`);
3105
+ if (m.context_token) state.contextTokens[`${account.botId}:${f}`] = m.context_token;
3106
+ }
3107
+ let enq = false;
3108
+ for (const msg of news) {
3109
+ state.lastMessageId = Math.max(state.lastMessageId, msg.message_id ?? 0);
3110
+ const from = msg.from_user_id ?? "";
3111
+ if (!isAuthorized(from, owner)) {
3112
+ log("ignore unauthorized sender:", from);
3113
+ continue;
3114
+ }
3115
+ const text = extractText(msg);
3116
+ const voiceItem = (msg.item_list ?? []).find((it) => it.type === MessageItemType.VOICE)?.voice_item;
3117
+ if (voiceItem) log(`recv voice: text=${voiceItem.text ? `"${voiceItem.text.slice(0, 30)}"` : "(空,无微信转写)"} playtime=${voiceItem.playtime ?? "?"}`);
3118
+ const imageItems = (msg.item_list ?? []).filter((it) => it.type === MessageItemType.IMAGE && it.image_item).map((it) => it.image_item);
3119
+ if (!text && imageItems.length === 0) {
3120
+ log("skip empty/unsupported message");
3121
+ continue;
3122
+ }
3123
+ const b = budgetOf(from);
3124
+ const wasAwaiting = b.awaiting;
3125
+ b.count = 0;
3126
+ b.awaiting = false;
3127
+ if (text.trim() === "继续" && activeTurn && activeTurn.from === from && wasAwaiting) {
3128
+ activeTurn.token = msg.context_token ?? activeTurn.token;
3129
+ log("收到「继续」(等待中) → 恢复进度、重置计数、换新 token");
3130
+ continue;
3131
+ }
3132
+ msgQueue.push({
3133
+ from,
3134
+ text,
3135
+ imageItems,
3136
+ createTimeMs: msg.create_time_ms,
3137
+ contextToken: msg.context_token
3138
+ });
3139
+ enq = true;
3140
+ }
3141
+ saveState(state);
3142
+ if (enq) notifyWorker();
3143
+ } catch (e) {
3144
+ log("poller error:", e instanceof Error ? e.stack || e.message : String(e));
3145
+ }
3146
+ }
3147
+ }
3148
+ async function workerLoop(account, state) {
3149
+ while (true) {
3150
+ if (isSessionPaused(account.botId)) {
3151
+ await sleep$2(Math.min(getRemainingPauseMs(account.botId), 3e4) || 3e4);
3152
+ continue;
3153
+ }
3154
+ if (msgQueue.length === 0) {
3155
+ await new Promise((r) => {
3156
+ wakeWorker = r;
3157
+ if (msgQueue.length > 0) {
3158
+ wakeWorker = null;
3159
+ r();
3160
+ }
3161
+ });
3162
+ continue;
3163
+ }
3164
+ const item = msgQueue.shift();
3165
+ const from = item.from;
3166
+ const token = item.contextToken;
3167
+ const latestToken = () => state.contextTokens[`${account.botId}:${from}`];
3168
+ const ctrl = item.text ? parseControl(item.text) : null;
3169
+ if (ctrl) {
3170
+ let r;
3171
+ try {
3172
+ r = await handleControl(ctrl, from);
3173
+ } catch (e) {
3174
+ r = "处理指令出错:" + String(e);
3175
+ }
3176
+ log("control:", JSON.stringify(ctrl), "->", r.replace(/\n/g, " | "));
3177
+ await reply(account, from, token, r, MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
3178
+ continue;
3179
+ }
3180
+ const tcmd = item.text ? parseTaskControl(item.text) : null;
3181
+ if (tcmd?.kind === "stop") {
3182
+ const r = stopTask(from);
3183
+ log("task: stop ->", r);
3184
+ await reply(account, from, token, r, MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
3185
+ continue;
3186
+ }
3187
+ if (tcmd?.kind === "toWorker") {
3188
+ if (hasActiveTask(from)) {
3189
+ log("task: inject(explicit):", tcmd.text.slice(0, 40));
3190
+ injectTask(from, tcmd.text);
3191
+ } else await reply(account, from, token, dispatchTask(from, tcmd.text), MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
3192
+ continue;
3193
+ }
3194
+ const brainEscape = item.text ? parseBrainEscape(item.text) : null;
3195
+ if (!brainEscape && item.imageItems.length === 0 && isAwaiting(from)) {
3196
+ log("task: inject(awaiting):", item.text.slice(0, 40));
3197
+ injectTask(from, item.text);
3198
+ continue;
3199
+ }
3200
+ const brainText = brainEscape ?? item.text;
3201
+ try {
3202
+ await handleMessage(account, token, from, brainText, item.imageItems, item.createTimeMs, latestToken);
3203
+ } catch (e) {
3204
+ 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));
3206
+ }
3207
+ }
3208
+ }
3209
+ const TASK_INBOX_DIR = path.join(DATA_DIR, "task-inbox");
3210
+ const TASK_STATE_FILE = path.join(DATA_DIR, "task-state.json");
3211
+ const TASK_SCAN_MS = Number(process.env.WEMIND_TASK_SCAN_MS) || 1e3;
3212
+ /** 扫描 task-inbox:按文件名(时间戳)升序逐个读、分发到 task-manager(dispatch/reply/stop)、删文件。本地命令默认作用于 owner。 */
3213
+ function scanTaskInbox(owner) {
3214
+ let files;
3215
+ try {
3216
+ files = fs.readdirSync(TASK_INBOX_DIR).filter((f) => f.endsWith(".json"));
3217
+ } catch {
3218
+ return;
3219
+ }
3220
+ if (!files.length) return;
3221
+ files.sort();
3222
+ for (const f of files) {
3223
+ const fp = path.join(TASK_INBOX_DIR, f);
3224
+ let cmd;
3225
+ try {
3226
+ cmd = JSON.parse(fs.readFileSync(fp, "utf-8"));
3227
+ } catch {
3228
+ try {
3229
+ fs.unlinkSync(fp);
3230
+ } catch {}
3231
+ continue;
3232
+ }
3233
+ try {
3234
+ fs.unlinkSync(fp);
3235
+ } catch {}
3236
+ try {
3237
+ if (cmd.kind === "dispatch") log("local task dispatch:", dispatchTask(owner, String(cmd.text ?? ""), cmd.cwd ? String(cmd.cwd) : void 0).slice(0, 50));
3238
+ else if (cmd.kind === "reply") {
3239
+ injectTask(owner, String(cmd.text ?? ""));
3240
+ log("local task reply:", String(cmd.text ?? "").slice(0, 40));
3241
+ } else if (cmd.kind === "stop") log("local task stop:", stopTask(owner));
3242
+ else log("unknown task-inbox cmd:", cmd.kind);
3243
+ } catch (e) {
3244
+ log("task-inbox dispatch error:", String(e));
3245
+ }
3246
+ }
3247
+ }
3248
+ async function runDaemon() {
3249
+ const agent = resolveAgent(loadBrainConfig());
3250
+ const brainProblem = agentProblem(agent);
3251
+ if (brainProblem) {
3252
+ log(`FATAL 大脑不可用:${brainProblem}`);
3253
+ process.exit(78);
3254
+ }
3255
+ log(`brain=${agent.name} bin=${agent.bin} args=[${agent.args.join(" ")}] shell=${agent.shell} (source: ${agent.source})`);
3256
+ const account = loadAccount();
3257
+ if (!account?.botToken) {
3258
+ log("not logged in; run the MCP login_qrcode flow first.");
3259
+ process.exit(1);
3260
+ }
3261
+ const owner = process.env.WECHAT_OWNER_ID?.trim() || account.userId;
3262
+ if (owner) addAllowFrom(owner, DATA_DIR);
3263
+ const pidFile = path.join(DATA_DIR, "daemon.pid");
3264
+ try {
3265
+ fs.writeFileSync(pidFile, String(process.pid), "utf-8");
3266
+ } catch {}
3267
+ const cleanupPid = () => {
3268
+ try {
3269
+ fs.unlinkSync(pidFile);
3270
+ } catch {}
3271
+ };
3272
+ process.on("exit", cleanupPid);
3273
+ process.on("SIGINT", () => {
3274
+ cleanupPid();
3275
+ process.exit(0);
3276
+ });
3277
+ process.on("SIGTERM", () => {
3278
+ cleanupPid();
3279
+ process.exit(0);
3280
+ });
3281
+ process.on("unhandledRejection", (reason) => {
3282
+ log("unhandledRejection:", reason instanceof Error ? reason.stack || reason.message : String(reason));
3283
+ });
3284
+ process.on("uncaughtException", (err) => {
3285
+ log("uncaughtException:", err?.stack || String(err));
3286
+ });
3287
+ ensureBrainHome();
3288
+ ensureWorkerHome();
3289
+ log(`up. bot=${account.botId} owner=${owner} allowFrom=${readAllowFrom(DATA_DIR).length}`);
3290
+ await notifyStart({
3291
+ baseUrl: account.baseUrl,
3292
+ token: account.botToken
3293
+ }).catch(() => {});
3294
+ const state = loadState();
3295
+ setTaskPush((to, text) => {
3296
+ const tok = state.contextTokens[`${account.botId}:${to}`];
3297
+ budgetOf(to).count += 1;
3298
+ sendOrdered(account, to, tok, text, MessageState.FINISH, tok);
3299
+ });
3300
+ setTaskTyping((to) => {
3301
+ setTyping(account, to, state.contextTokens[`${account.botId}:${to}`], 1);
3302
+ });
3303
+ setTaskStateSink((snap) => {
3304
+ try {
3305
+ const tmp = `${TASK_STATE_FILE}.tmp-${process.pid}`;
3306
+ fs.writeFileSync(tmp, JSON.stringify(snap, null, 2), "utf-8");
3307
+ fs.renameSync(tmp, TASK_STATE_FILE);
3308
+ } catch {}
3309
+ });
3310
+ try {
3311
+ fs.rmSync(TASK_INBOX_DIR, {
3312
+ recursive: true,
3313
+ force: true
3314
+ });
3315
+ } catch {}
3316
+ try {
3317
+ fs.mkdirSync(TASK_INBOX_DIR, { recursive: true });
3318
+ } catch {}
3319
+ setInterval(() => scanTaskInbox(owner), TASK_SCAN_MS);
3320
+ pollerLoop(account, owner, state);
3321
+ await workerLoop(account, state);
3322
+ }
3323
+ //#endregion
3324
+ //#region src/pm2-runner.ts
3325
+ function pm2Run(cmd, opts = {}) {
3326
+ return spawnSync(`pm2 ${cmd}`, {
3327
+ shell: true,
3328
+ stdio: opts.quiet ? "ignore" : "inherit",
3329
+ env: opts.env ?? process.env,
3330
+ windowsHide: true
3331
+ });
3332
+ }
3333
+ /** pm2 是否已全局安装、可用。 */
3334
+ function pm2Available() {
3335
+ try {
3336
+ return pm2Run("-v", { quiet: true }).status === 0;
3337
+ } catch {
3338
+ return false;
3339
+ }
3340
+ }
3341
+ /** 账号的 daemon 日志路径,与 run-daemon.ps1 / ecosystem 方式一致:`~/.wemind/<key>/daemon.log`。 */
3342
+ function daemonLogPath(key) {
3343
+ const root = process.env.WEMIND_HOME?.trim() || path.join(os.homedir(), ".wemind");
3344
+ return path.join(root, key, "daemon.log");
3345
+ }
3346
+ /** 用 pm2 启动某账号的 daemon:app 名 `wemind-<key>`,注入 WECHAT_ACCOUNT,日志归一到账号 daemon.log。 */
3347
+ function pm2Start(key, cliPath) {
3348
+ if (!pm2Available()) return {
3349
+ ok: false,
3350
+ msg: "未检测到 pm2。请先安装:npm i -g pm2,再重试 `wemind start`。"
3351
+ };
3352
+ const name = `wemind-${key}`;
3353
+ const log = daemonLogPath(key);
3354
+ pm2Run(`delete ${name}`, { quiet: true });
3355
+ const r = pm2Run(`start "${cliPath}" --name ${name} --interpreter node --output "${log}" --error "${log}" --merge-logs -- daemon`, { env: {
3356
+ ...process.env,
3357
+ WECHAT_ACCOUNT: key
3358
+ } });
3359
+ if (r.status !== 0) return {
3360
+ ok: false,
3361
+ msg: `pm2 启动失败(exit=${r.status})。`
3362
+ };
3363
+ pm2Run("save", { quiet: true });
3364
+ return {
3365
+ ok: true,
3366
+ msg: `已用 pm2 启动 ${name}(日志:${log})。\n 开机自启(首次):执行 \`pm2 startup\`——Linux/macOS 按提示操作;Windows 用计划任务跑 \`pm2 resurrect\`。`
3367
+ };
3368
+ }
3369
+ /** 停止某账号的 daemon(pm2 delete)。 */
3370
+ function pm2Stop(key) {
3371
+ if (!pm2Available()) return {
3372
+ ok: false,
3373
+ msg: "未检测到 pm2。"
3374
+ };
3375
+ const name = `wemind-${key}`;
3376
+ const r = pm2Run(`delete ${name}`, { quiet: true });
3377
+ pm2Run("save", { quiet: true });
3378
+ return r.status === 0 ? {
3379
+ ok: true,
3380
+ msg: `已停止 ${name}。`
3381
+ } : {
3382
+ ok: false,
3383
+ msg: `${name} 未在运行,或停止失败。`
3384
+ };
3385
+ }
3386
+ //#endregion
3387
+ //#region src/proc-identity.ts
3388
+ /** pid 是否活着(零成本,仅探活;不代表是我们的进程)。 */
3389
+ function isAlive(pid) {
3390
+ if (pid === null || pid === void 0 || !Number.isInteger(pid) || pid <= 0) return false;
3391
+ try {
3392
+ process.kill(pid, 0);
3393
+ return true;
3394
+ } catch {
3395
+ return false;
3396
+ }
3397
+ }
3398
+ /** 归一化:小写 + 反斜杠统一成正斜杠,便于跨平台、跨相对/绝对路径匹配。 */
3399
+ function normalize(s) {
3400
+ return s.replaceAll("\\", "/").toLowerCase();
3401
+ }
3402
+ /**
3403
+ * 取文件名。**不依赖平台**:`/` 与 `\` 都当分隔符——
3404
+ * POSIX 的 `path.basename` 不认 `\`,会把 Windows 风格路径整条留下(CI 上踩过)。
3405
+ */
3406
+ function baseName(p) {
3407
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
3408
+ return i >= 0 ? p.slice(i + 1) : p;
3409
+ }
3410
+ /** 某条命令行是否命中「任一 marker 组合」(组合内每个 token 都必须出现、大小写与斜杠不敏感)。 */
3411
+ function matchesMarkers(line, markerSets) {
3412
+ if (!line) return false;
3413
+ const hay = normalize(line);
3414
+ return markerSets.some((set) => set.length > 0 && set.every((t) => hay.includes(normalize(t))));
3415
+ }
3416
+ function parsePipePairs(stdout, out) {
3417
+ for (const raw of stdout.split(/\r?\n/)) {
3418
+ const i = raw.indexOf("|");
3419
+ if (i <= 0) continue;
3420
+ const pid = Number.parseInt(raw.slice(0, i).trim(), 10);
3421
+ if (Number.isInteger(pid) && pid > 0) out.set(pid, raw.slice(i + 1));
3422
+ }
3423
+ }
3424
+ /**
3425
+ * 批量查询 pid → 命令行。查不到(不存在 / 无权限 / 平台不支持)的 pid 不会出现在结果里。
3426
+ * 只查询「活着的」pid(调用方传一堆陈旧 pid 也不会白白起进程)。
3427
+ */
3428
+ function commandLines(pids) {
3429
+ const out = /* @__PURE__ */ new Map();
3430
+ const list = [...new Set(pids.filter((p) => isAlive(p)))];
3431
+ if (!list.length) return out;
3432
+ try {
3433
+ if (process.platform === "win32") parsePipePairs(spawnSync(path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), [
3434
+ "-NoProfile",
3435
+ "-NonInteractive",
3436
+ "-Command",
3437
+ `Get-CimInstance Win32_Process -Filter "${list.map((p) => `ProcessId=${p}`).join(" or ")}" | ForEach-Object { "$($_.ProcessId)|$($_.CommandLine)" }`
3438
+ ], {
3439
+ encoding: "utf8",
3440
+ windowsHide: true,
3441
+ timeout: 15e3
3442
+ }).stdout ?? "", out);
3443
+ else if (process.platform === "linux") for (const p of list) try {
3444
+ out.set(p, fs.readFileSync(`/proc/${p}/cmdline`, "utf-8").replaceAll("\0", " ").trim());
3445
+ } catch {}
3446
+ else for (const p of list) {
3447
+ const line = (spawnSync("ps", [
3448
+ "-o",
3449
+ "command=",
3450
+ "-p",
3451
+ String(p)
3452
+ ], {
3453
+ encoding: "utf8",
3454
+ timeout: 5e3
3455
+ }).stdout ?? "").trim();
3456
+ if (line) out.set(p, line);
3457
+ }
3458
+ } catch {}
3459
+ return out;
3460
+ }
3461
+ /** 该 pid 是否「活着**且确实是我们自己的**进程」(命令行命中 markerSets 之一)。 */
3462
+ function isOurProcess(pid, markerSets) {
3463
+ if (!isAlive(pid)) return false;
3464
+ return matchesMarkers(commandLines([pid]).get(pid), markerSets);
3465
+ }
3466
+ /**
3467
+ * 我们自己的 CLI / wrapper 进程特征:命令行里同时含 `wemind` + `cli.js`。
3468
+ *
3469
+ * 这串必须同时覆盖**三种**安装方式(少一种 → 对应场景下 stop/list/单实例判断全失效):
3470
+ * - 源码仓库:`<node> D:\repo\WeMind\packages\wechat-daemon\dist\cli.js daemon`
3471
+ * (由 marker 组合里的 `wemind` 命中仓库名 WeMind;注意大小写不敏感比较在下面已处理);
3472
+ * - 全局/本地安装:`<node> …/node_modules/@wemind/cli/dist/cli.js daemon`
3473
+ * (npm 的 bin shim 最终 exec 的正是这个路径;Windows 上可能走 `…\@wemind\cli\dist\cli.js` 短路径。
3474
+ * 两种形态都命中 marker 里的 `wemind`——它同时是 scope 名与命令名);
3475
+ * - 源码开发期的 PowerShell wrapper:`pwsh -File …\scripts\run-daemon.ps1`。
3476
+ *
3477
+ * 注意**不能**再用 `wechat-daemon`:那是源码目录名,发布到 npm 后包目录叫 `@wemind/cli`,
3478
+ * 用目录名做特征会让「装出来的 wemind」永远匹配不上,从而 list 永远显示「已停止」。
3479
+ */
3480
+ const CLI_PID_MARKERS = [["wemind", "cli.js"], ["run-daemon.ps1"]];
3481
+ /**
3482
+ * 大脑进程特征:`<bin> <args…>`(如 `opencode acp`、`kiro-cli acp`)。
3483
+ * 取 bin 的**文件名**并去掉扩展名,兼顾 shell=true(命令行是原始命令)与 `--bin` 绝对路径两种情况;
3484
+ * args 是关键区分位——用户自己的 `opencode serve`(本机 OpenCode 服务)不含 `acp`,不会被误判。
3485
+ * bin 为空(配置异常)时返回空数组 → matchesMarkers 恒判否,绝不误杀。
3486
+ */
3487
+ function brainPidMarkers(bin, args) {
3488
+ const name = baseName(bin ?? "").replace(/\.(exe|cmd|bat|ps1)$/i, "");
3489
+ if (!name) return [];
3490
+ return [[name, ...args].filter((t) => t !== "")];
3491
+ }
3492
+ //#endregion
3493
+ //#region src/watchdog-runner.ts
3494
+ const RESTART_DELAY_MS = 5e3;
3495
+ const sleep$1 = (ms) => new Promise((r) => setTimeout(r, ms));
3496
+ function readPidFile(file) {
3497
+ try {
3498
+ const n = Number.parseInt(fs.readFileSync(file, "utf-8").trim(), 10);
3499
+ return Number.isInteger(n) && n > 0 ? n : null;
3500
+ } catch {
3501
+ return null;
3502
+ }
3503
+ }
3504
+ /** 单实例守卫:已有存活**且确认是我们**的看门狗返回 false;否则写自己 pid、返回 true。 */
3505
+ function acquireSingleton(wrapperPidFile) {
3506
+ const old = readPidFile(wrapperPidFile);
3507
+ if (old && isOurProcess(old, CLI_PID_MARKERS)) return false;
3508
+ fs.writeFileSync(wrapperPidFile, String(process.pid), "ascii");
3509
+ return true;
3510
+ }
3511
+ /** 看门狗循环本体(由 `cli.js _watchdog` 调用,常驻):单实例守卫 → 循环拉起 daemon、退出 5s 重拉。 */
3512
+ async function runWatchdog() {
3513
+ const dataDir = DATA_DIR;
3514
+ fs.mkdirSync(dataDir, { recursive: true });
3515
+ const log = path.join(dataDir, "daemon.log");
3516
+ const wrapperPidFile = path.join(dataDir, "wrapper.pid");
3517
+ if (!acquireSingleton(wrapperPidFile)) {
3518
+ fs.appendFileSync(log, `[wemind/wechat-daemon/wrapper] 已有实例在跑,本次退出。${(/* @__PURE__ */ new Date()).toISOString()}\n`);
3519
+ return;
3520
+ }
3521
+ const cleanup = () => {
3522
+ try {
3523
+ if (readPidFile(wrapperPidFile) === process.pid) fs.unlinkSync(wrapperPidFile);
3524
+ } catch {}
3525
+ };
3526
+ process.on("exit", cleanup);
3527
+ process.on("SIGINT", () => {
3528
+ cleanup();
3529
+ process.exit(0);
3530
+ });
3531
+ process.on("SIGTERM", () => {
3532
+ cleanup();
3533
+ process.exit(0);
3534
+ });
3535
+ const cliPath = process.argv[1];
3536
+ for (;;) {
3537
+ fs.appendFileSync(log, `[wemind/wechat-daemon/wrapper] start daemon ${(/* @__PURE__ */ new Date()).toISOString()}\n`);
3538
+ const logFd = fs.openSync(log, "a");
3539
+ const child = spawn(process.execPath, [cliPath, "daemon"], {
3540
+ env: process.env,
3541
+ stdio: [
3542
+ "ignore",
3543
+ logFd,
3544
+ logFd
3545
+ ],
3546
+ windowsHide: true
3547
+ });
3548
+ const code = await new Promise((resolve) => {
3549
+ child.on("exit", (c) => resolve(c ?? -1));
3550
+ child.on("error", () => resolve(-1));
3551
+ });
3552
+ try {
3553
+ fs.closeSync(logFd);
3554
+ } catch {}
3555
+ if (code === 78) {
3556
+ fs.appendFileSync(log, `[wemind/wechat-daemon/wrapper] 大脑配置错误(exit ${code}),已停止自动重启。修好后执行:wemind start <账号>\n`);
3557
+ return;
3558
+ }
3559
+ fs.appendFileSync(log, `[wemind/wechat-daemon/wrapper] daemon exited (code=${code}), restart in 5s ${(/* @__PURE__ */ new Date()).toISOString()}\n`);
3560
+ await sleep$1(RESTART_DELAY_MS);
3561
+ }
3562
+ }
3563
+ /** 从 manage 脱离式启动某账号的看门狗(方案 b:builtin runner)。 */
3564
+ function watchdogStart(key, cliPath) {
3565
+ const child = spawn(process.execPath, [cliPath, "_watchdog"], {
3566
+ detached: true,
3567
+ stdio: "ignore",
3568
+ windowsHide: true,
3569
+ env: {
3570
+ ...process.env,
3571
+ WECHAT_ACCOUNT: key
3572
+ }
3573
+ });
3574
+ child.unref();
3575
+ return {
3576
+ ok: true,
3577
+ msg: `已启动自带看门狗(账号 ${key},看门狗 pid ${child.pid})。日志:${path.join(accountDir(key), "daemon.log")}`
3578
+ };
3579
+ }
3580
+ //#endregion
3581
+ //#region src/brain-check.ts
3582
+ async function checkAgent(ag, timeoutMs = 6e4) {
3583
+ const started = Date.now();
3584
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "wemind-brain-check-"));
3585
+ const env = { ...process.env };
3586
+ if (ag.homeEnvVar) env[ag.homeEnvVar] = home;
3587
+ for (const [k, v] of Object.entries(ag.env ?? {})) env[k] = v.replaceAll("%HOME%", home);
3588
+ const proc = spawnAgent(ag, {
3589
+ cwd: home,
3590
+ env
3591
+ });
3592
+ let buf = "";
3593
+ let seq = 0;
3594
+ const pending = /* @__PURE__ */ new Map();
3595
+ const settleAll = (msg) => {
3596
+ for (const [id, done] of pending) {
3597
+ pending.delete(id);
3598
+ done({
3599
+ id,
3600
+ error: { message: msg }
3601
+ });
3602
+ }
3603
+ };
3604
+ proc.stdout.on("data", (d) => {
3605
+ buf += d.toString();
3606
+ let i;
3607
+ while ((i = buf.indexOf("\n")) >= 0) {
3608
+ const line = buf.slice(0, i).trim();
3609
+ buf = buf.slice(i + 1);
3610
+ if (!line) continue;
3611
+ let frame;
3612
+ try {
3613
+ frame = JSON.parse(line);
3614
+ } catch {
3615
+ continue;
3616
+ }
3617
+ if (typeof frame.id === "number" && pending.has(frame.id)) {
3618
+ const done = pending.get(frame.id);
3619
+ pending.delete(frame.id);
3620
+ done(frame);
3621
+ }
3622
+ }
3623
+ });
3624
+ const stderrTail = [];
3625
+ proc.stderr.on("data", (d) => {
3626
+ stderrTail.push(d.toString());
3627
+ if (stderrTail.length > 20) stderrTail.shift();
3628
+ });
3629
+ proc.on("error", (e) => settleAll(`无法启动:${String(e)}`));
3630
+ proc.on("exit", (code) => settleAll(`进程已退出(code=${code})`));
3631
+ const req = (method, params) => new Promise((resolve, reject) => {
3632
+ const id = ++seq;
3633
+ const t = setTimeout(() => {
3634
+ pending.delete(id);
3635
+ reject(/* @__PURE__ */ new Error(`${method} 超时(${timeoutMs}ms)`));
3636
+ }, timeoutMs);
3637
+ pending.set(id, (frame) => {
3638
+ clearTimeout(t);
3639
+ if (frame?.error) reject(new Error(typeof frame.error === "string" ? frame.error : JSON.stringify(frame.error)));
3640
+ else resolve(frame?.result);
3641
+ });
3642
+ proc.stdin.write(JSON.stringify({
3643
+ jsonrpc: "2.0",
3644
+ id,
3645
+ method,
3646
+ params
3647
+ }) + "\n");
3648
+ });
3649
+ try {
3650
+ await req("initialize", {
3651
+ protocolVersion: 1,
3652
+ clientCapabilities: {}
3653
+ });
3654
+ return {
3655
+ ok: true,
3656
+ detail: `ACP 握手成功(initialize + session/new,sessionId=${(await req("session/new", {
3657
+ cwd: home,
3658
+ mcpServers: []
3659
+ }))?.sessionId ?? "?"})`,
3660
+ ms: Date.now() - started
3661
+ };
3662
+ } catch (e) {
3663
+ const tail = stderrTail.join("").trim().slice(-400);
3664
+ return {
3665
+ ok: false,
3666
+ detail: `${e instanceof Error ? e.message : String(e)}${tail ? `\n stderr: ${tail}` : ""}`,
3667
+ ms: Date.now() - started
3668
+ };
3669
+ } finally {
3670
+ try {
3671
+ if (process.platform === "win32" && proc.pid) spawnSync("taskkill", [
3672
+ "/PID",
3673
+ String(proc.pid),
3674
+ "/T",
3675
+ "/F"
3676
+ ], {
3677
+ stdio: "ignore",
3678
+ windowsHide: true
3679
+ });
3680
+ else proc.kill();
3681
+ } catch {}
3682
+ try {
3683
+ fs.rmSync(home, {
3684
+ recursive: true,
3685
+ force: true
3686
+ });
3687
+ } catch {}
3688
+ }
3689
+ }
3690
+ //#endregion
3691
+ //#region src/version.ts
3692
+ /**
3693
+ * CLI 版本号(`wemind version` / `wemind --version` / help 头部)。
3694
+ * 与 package.json 的 version 必须一致——单测 version.test.ts 会读 package.json 校验,
3695
+ * 避免「发了新版本、CLI 还报旧版本号」这种排查成本极高的小漂移。
3696
+ */
3697
+ const CLI_VERSION = "1.0.0";
3698
+ /**
3699
+ * npm 包名(与 package.json 的 name 一致,单测校验)。
3700
+ * 命令名是 `wemind`,但**包名**是 `@wemind/cli`——两者不同,所以 `npx` 不能靠「唯一 bin」自动解析,
3701
+ * 必须写全 `npx @wemind/cli`(见 README 与 docs/发布-wemind-npm.md)。
3702
+ */
3703
+ const CLI_PKG_NAME = "@wemind/cli";
3704
+ /** 单行版本串(供 --version 输出):命令名 + 版本,包名放括号里便于反馈问题时对上 npm 页面。 */
3705
+ function versionLine() {
3706
+ return `wemind ${CLI_VERSION} (${CLI_PKG_NAME}, node ${process.versions.node}, ${process.platform}-${process.arch})`;
3707
+ }
3708
+ //#endregion
3709
+ //#region src/manage.ts
3710
+ /**
3711
+ * WeMind 多账号管理 CLI。
3712
+ * 账号 key = 由微信 botId 派生的唯一标识;每个账号独立目录 ~/.wemind/<key>。
3713
+ * label = 人类可读别名(可改、可重复);管理命令可用 key 或 label 选择账号。
3714
+ * 推荐用 `login` 一条命令完成:自刷新二维码 → 扫码 → 自动归档(→ 可选自动启动)。
3715
+ */
3716
+ const BASE = process.env.WEMIND_ILINK_BASE?.trim() || "https://ilinkai.weixin.qq.com";
3717
+ const STAGING = path.join(WEMIND_ROOT, ".staging");
3718
+ 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
+ function readPid(dir, name) {
3727
+ try {
3728
+ const n = parseInt(fs.readFileSync(path.join(dir, name), "utf-8").trim(), 10);
3729
+ return Number.isFinite(n) ? n : null;
3730
+ } catch {
3731
+ return null;
3732
+ }
3733
+ }
3734
+ /**
3735
+ * 该 pid 是否「活着**且确实是我们自己的** CLI 进程」。
3736
+ * 只判存活不够:pid 会复用(尤其重启后),陈旧 pid 文件可能指向**别人的**进程 → 会误杀/误判。
3737
+ */
3738
+ function aliveOurs(pid, markers = CLI_PID_MARKERS) {
3739
+ return isOurProcess(pid, markers);
3740
+ }
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);
3753
+ } catch {}
3754
+ }
3755
+ function openFile(p) {
3756
+ try {
3757
+ if (process.platform === "win32") spawn("cmd", [
3758
+ "/c",
3759
+ "start",
3760
+ "",
3761
+ p
3762
+ ], {
3763
+ detached: true,
3764
+ stdio: "ignore",
3765
+ windowsHide: true
3766
+ }).unref();
3767
+ else spawn(process.platform === "darwin" ? "open" : "xdg-open", [p], {
3768
+ detached: true,
3769
+ stdio: "ignore"
3770
+ }).unref();
3771
+ } catch {}
3772
+ }
3773
+ /** key 或 label → key(label 唯一匹配才行)。 */
3774
+ function resolveKey(arg) {
3775
+ if (!arg) throw new Error("需要 <key 或 label>");
3776
+ const accs = listAccounts();
3777
+ if (accs.some((a) => a.key === arg)) return arg;
3778
+ const byLabel = accs.filter((a) => a.label === arg);
3779
+ if (byLabel.length === 1) return byLabel[0].key;
3780
+ if (byLabel.length > 1) throw new Error(`label「${arg}」对应多个账号,请改用 key:${byLabel.map((a) => a.key).join(", ")}`);
3781
+ throw new Error(`未找到账号:${arg}(既不是 key 也不是 label)`);
3782
+ }
3783
+ /** 启动某账号的 daemon(方案 a:委托 pm2 常驻,跑 `wemind daemon`,注入 WECHAT_ACCOUNT)。 */
3784
+ function startDaemon(key, runner) {
3785
+ const cliPath = process.argv[1] || path.join(path.dirname(fileURLToPath(import.meta.url)), "cli.js");
3786
+ stopDaemon(key);
3787
+ const useRunner = (runner || process.env.WEMIND_RUNNER || "builtin").toLowerCase();
3788
+ console.log((useRunner === "pm2" ? pm2Start(key, cliPath) : watchdogStart(key, cliPath)).msg);
3789
+ }
3790
+ /** 停止某账号的常驻:先 pm2 delete(方案 a),再按 pid 兜底杀 wrapper/daemon/brain(兼容直接启动的)。 */
3791
+ function stopDaemon(key) {
3792
+ pm2Stop(key);
3793
+ const dir = accountDir(key);
3794
+ const ag = resolveAgent(loadBrainConfig(dir));
3795
+ const cand = [
3796
+ {
3797
+ pid: readPid(dir, "wrapper.pid"),
3798
+ name: "wrapper",
3799
+ markers: CLI_PID_MARKERS
3800
+ },
3801
+ {
3802
+ pid: readPid(dir, "daemon.pid"),
3803
+ name: "daemon",
3804
+ markers: CLI_PID_MARKERS
3805
+ },
3806
+ {
3807
+ pid: readPid(dir, "brain.pid"),
3808
+ name: "brain",
3809
+ markers: brainPidMarkers(ag.bin, ag.args)
3810
+ }
3811
+ ];
3812
+ const lines = commandLines(cand.map((c) => c.pid));
3813
+ const stopped = [];
3814
+ const skipped = [];
3815
+ for (const c of cand) {
3816
+ if (!c.pid || !isAlive(c.pid)) continue;
3817
+ if (matchesMarkers(lines.get(c.pid), c.markers)) {
3818
+ killPid(c.pid);
3819
+ stopped.push(`${c.name} ${c.pid}`);
3820
+ } else skipped.push(`${c.name} ${c.pid}`);
3821
+ }
3822
+ for (const f of [
3823
+ "wrapper.pid",
3824
+ "daemon.pid",
3825
+ "brain.pid"
3826
+ ]) try {
3827
+ fs.unlinkSync(path.join(dir, f));
3828
+ } catch {}
3829
+ const parts = [];
3830
+ if (stopped.length) parts.push(`已停止 ${stopped.join(" + ")}`);
3831
+ if (skipped.length) parts.push(`跳过 ${skipped.join(" + ")}(非本程序,未杀;已清陈旧 pid 文件)`);
3832
+ return parts.length ? parts.join(";") : "无运行中的进程";
3833
+ }
3834
+ /** 把 STAGING 的登录按 botId 归档 + 登记。 */
3835
+ function finalizeStaging(label) {
3836
+ const acc = readAccount(STAGING);
3837
+ if (!acc?.botId) throw new Error(`staging 无有效登录(${STAGING})`);
3838
+ const key = deriveAccountKey(acc.botId);
3839
+ const dest = accountDir(key);
3840
+ if (fs.existsSync(dest)) {
3841
+ fs.rmSync(STAGING, {
3842
+ recursive: true,
3843
+ force: true
3844
+ });
3845
+ throw new Error(`账号目录已存在: ${dest}(该微信号此前已登录过;如需重登请先 delete ${key})`);
3846
+ }
3847
+ fs.renameSync(STAGING, dest);
3848
+ for (const f of [
3849
+ "qrcode.png",
3850
+ "qrcode.txt",
3851
+ "qr_login.json",
3852
+ "poll.out",
3853
+ "poll.err",
3854
+ "auto.out",
3855
+ "auto.err"
3856
+ ]) try {
3857
+ fs.unlinkSync(path.join(dest, f));
3858
+ } catch {}
3859
+ const meta = registerAccount({
3860
+ key,
3861
+ botId: acc.botId,
3862
+ userId: acc.userId,
3863
+ baseUrl: acc.baseUrl,
3864
+ label: label || key
3865
+ });
3866
+ if (acc.userId) addAllowFrom(acc.userId, dest);
3867
+ return {
3868
+ key,
3869
+ msg: `已归档:key=${key} label=${meta.label} botId=${acc.botId}\n目录:${dest}`
3870
+ };
3871
+ }
3872
+ /**
3873
+ * 懒加载 `qrcode`:只有扫码登录要画二维码,`list` / `brain` / `task` / `daemon` 全用不上。
3874
+ * 顶层 `import` 会让**每次**启动都付约 36ms(实测)解析 qrcode + yargs 依赖树;
3875
+ * 这里在第一次画码时才加载,之后同进程内复用(登录过程会因过期刷新多次)。
3876
+ * 用函数声明而非 `const QRCode = await import(...)`:后者在 ESM 顶层要多一层 TDZ 心智负担。
3877
+ */
3878
+ let qrcodeMod;
3879
+ async function loadQrCode() {
3880
+ if (!qrcodeMod) try {
3881
+ qrcodeMod = await import("qrcode");
3882
+ } catch (e) {
3883
+ throw new Error(`无法加载二维码库 qrcode(依赖缺失或安装损坏):${e instanceof Error ? e.message : String(e)}\n请重试安装:npm i -g @wemind/cli`);
3884
+ }
3885
+ return qrcodeMod;
3886
+ }
3887
+ /** 一条命令完成扫码登录:自刷新二维码 + 终端码 + 自动开图 + 自动归档(→ 可选自动启动)。 */
3888
+ async function cmdLogin(label, autoStart, runner) {
3889
+ const QRCode = await loadQrCode();
3890
+ fs.rmSync(STAGING, {
3891
+ recursive: true,
3892
+ force: true
3893
+ });
3894
+ fs.mkdirSync(STAGING, { recursive: true });
3895
+ const png = path.join(STAGING, "qrcode.png");
3896
+ const overall = Date.now() + 900 * 1e3;
3897
+ let code = "";
3898
+ let opened = false;
3899
+ const refresh = async () => {
3900
+ const qr = await fetchQRCode(BASE);
3901
+ code = qr.qrcode;
3902
+ saveQrState({
3903
+ qrcode: qr.qrcode,
3904
+ qrcodeUrl: qr.qrcode_img_content,
3905
+ createdAt: Date.now()
3906
+ });
3907
+ await QRCode.toFile(png, qr.qrcode_img_content, {
3908
+ width: 480,
3909
+ margin: 2
3910
+ });
3911
+ const term = await QRCode.toString(qr.qrcode_img_content, {
3912
+ type: "terminal",
3913
+ small: true
3914
+ });
3915
+ console.log("\n用要登录的微信扫下面的二维码(过期会自动刷新):\n");
3916
+ console.log(term);
3917
+ console.log("图片(已自动打开): " + png);
3918
+ console.log("链接: " + qr.qrcode_img_content + "\n");
3919
+ if (!opened) {
3920
+ openFile(png);
3921
+ opened = true;
3922
+ }
3923
+ };
3924
+ await refresh();
3925
+ console.log("等待扫码确认…(最多 15 分钟,Ctrl+C 取消)");
3926
+ while (Date.now() < overall) {
3927
+ let s;
3928
+ try {
3929
+ s = await pollQRStatus(BASE, code);
3930
+ } catch {
3931
+ await sleep(2e3);
3932
+ continue;
3933
+ }
3934
+ if (s.status === "confirmed") {
3935
+ 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({
3937
+ botToken: s.bot_token,
3938
+ botId: s.ilink_bot_id,
3939
+ userId: s.ilink_user_id,
3940
+ baseUrl: s.baseurl ?? BASE,
3941
+ savedAt: Date.now()
3942
+ }, null, 2), "utf-8");
3943
+ clearSessionPause(s.ilink_bot_id);
3944
+ await notifyStart({
3945
+ baseUrl: s.baseurl ?? BASE,
3946
+ token: s.bot_token
3947
+ }).catch(() => {});
3948
+ for (const f of ["qrcode.png", "qr_login.json"]) try {
3949
+ fs.unlinkSync(path.join(STAGING, f));
3950
+ } catch {}
3951
+ console.log("\n登录成功,归档中…");
3952
+ const { key, msg } = finalizeStaging(label);
3953
+ console.log(msg);
3954
+ if (autoStart) {
3955
+ startDaemon(key, runner);
3956
+ console.log(`已启动 daemon(账号 ${key})。`);
3957
+ } else console.log(`启动:wemind start ${key}`);
3958
+ return;
3959
+ }
3960
+ if (s.status === "expired") {
3961
+ console.log("二维码过期,自动刷新…");
3962
+ await refresh();
3963
+ continue;
3964
+ }
3965
+ await sleep(1500);
3966
+ }
3967
+ throw new Error("超时(15 分钟)未完成登录");
3968
+ }
3969
+ function cmdList() {
3970
+ const def = getDefaultKey();
3971
+ const accs = listAccounts();
3972
+ if (!accs.length) return "(无已登记账号)。用 `login` 添加。";
3973
+ const entries = accs.map((a) => {
3974
+ const dir = accountDir(a.key);
3975
+ const ag = resolveAgent(loadBrainConfig(dir));
3976
+ return {
3977
+ a,
3978
+ dir,
3979
+ dpid: readPid(dir, "daemon.pid"),
3980
+ bpid: readPid(dir, "brain.pid"),
3981
+ brainMarkers: brainPidMarkers(ag.bin, ag.args)
3982
+ };
3983
+ });
3984
+ const lines = commandLines(entries.flatMap((e) => [e.dpid, e.bpid]));
3985
+ const aliveOursIn = (pid, markers) => !!pid && matchesMarkers(lines.get(pid), markers);
3986
+ return entries.map((e) => {
3987
+ const running = aliveOursIn(e.dpid, CLI_PID_MARKERS) ? `运行中(pid ${e.dpid})` : "已停止";
3988
+ const brainPid = aliveOursIn(e.bpid, e.brainMarkers) ? ` brain(pid ${e.bpid})` : "";
3989
+ return `- ${e.a.key}${e.a.key === def ? " [默认]" : ""} label=${e.a.label} botId=${e.a.botId} agent=${agentLabel(e.dir)} ${running}${brainPid}`;
3990
+ }).join("\n");
3991
+ }
3992
+ const BRAIN_SRC_LABEL = {
3993
+ env: "环境变量 WEMIND_BRAIN*",
3994
+ file: "账号 brain.json",
3995
+ auto: "自动探测(未配置)"
3996
+ };
3997
+ /** list 用:账号「配置 / 自动探测」得到的大脑标签(不看当前 shell 的 env,避免误导)。 */
3998
+ function agentLabel(dir) {
3999
+ const cfg = loadBrainConfig(dir);
4000
+ if (cfg?.agent && cfg.agent.toLowerCase() !== "auto") return cfg.agent;
4001
+ const picked = AUTO_PROBE_ORDER.find((k) => presetAvailable(k));
4002
+ return picked ? `${picked}(自动)` : "(无可用)";
4003
+ }
4004
+ /** `wemind brain`(show):打印某账号大脑的「配置」与「生效」情况。 */
4005
+ function describeBrain(key) {
4006
+ const cfg = loadBrainConfig(accountDir(key));
4007
+ const ag = resolveAgent(cfg);
4008
+ const problem = agentProblem(ag);
4009
+ const cfgLine = cfg ? `brain.json → agent=${cfg.agent}${cfg.bin ? ` bin=${cfg.bin}` : ""}${cfg.args ? ` args=[${cfg.args.join(" ")}]` : ""}${cfg.shell !== void 0 ? ` shell=${cfg.shell}` : ""}` : "(未设置 brain.json)";
4010
+ return [
4011
+ `账号 ${key}`,
4012
+ ` 配置:${cfgLine}`,
4013
+ ` 请求:${ag.requestedKey} 来源:${BRAIN_SRC_LABEL[ag.source]}`,
4014
+ ` 生效:name=${ag.name} bin=${ag.bin} args=[${ag.args.join(" ")}] shell=${ag.shell} home_env=${ag.homeEnvVar ?? "(无)"}`,
4015
+ ` 可用:${problem ? `❌ ${problem}` : `✅ ${ag.binPath ?? ag.bin}`}`,
4016
+ ` 预设:${Object.keys(ACP_AGENT_PRESETS).join("、")}`,
4017
+ ` 自动探测顺序:${AUTO_PROBE_ORDER.join(" → ")}`
4018
+ ].join("\n");
4019
+ }
4020
+ /** `wemind brain set <预设|auto>`:把选择按账号落盘(可选 --bin/--args/--shell;--restart 立即重启 daemon)。 */
4021
+ function cmdBrainSet(agentName, key, opts) {
4022
+ const agent = agentName.trim().toLowerCase();
4023
+ if (agent !== "auto" && !ACP_AGENT_PRESETS[agent]) throw new Error(`未知预设「${agentName}」。可选:auto、${Object.keys(ACP_AGENT_PRESETS).join("、")}(自定义二进制用 --bin 搭配任一预设)`);
4024
+ const cfg = { agent };
4025
+ if (opts.bin) cfg.bin = opts.bin;
4026
+ if (opts.args !== void 0) cfg.args = opts.args.trim() ? opts.args.trim().split(/\s+/) : [];
4027
+ if (opts.shell !== void 0) cfg.shell = opts.shell;
4028
+ const dir = accountDir(key);
4029
+ saveBrainConfig(cfg, dir);
4030
+ const lines = [`已设置 ${key} 的大脑:agent=${agent}${cfg.bin ? ` bin=${cfg.bin}` : ""}${cfg.args ? ` args=[${cfg.args.join(" ")}]` : ""}`, `配置文件:${brainConfigPath(dir)}`];
4031
+ if (opts.restart) if (aliveOurs(readPid(dir, "daemon.pid"))) {
4032
+ startDaemon(key);
4033
+ lines.push(`已重启 daemon(账号 ${key})。`);
4034
+ } else lines.push(`daemon 未在运行,未重启(用 wemind start ${key} 启动)。`);
4035
+ else lines.push(`(daemon 需重启才生效:wemind start ${key})`);
4036
+ return lines.join("\n");
4037
+ }
4038
+ /** `wemind brain unset`:清除账号级配置,回退到自动探测/默认。 */
4039
+ function cmdBrainUnset(key) {
4040
+ return clearBrainConfig(accountDir(key)) ? `${key} 已清除 brain.json(回退到自动探测/默认)。daemon 需重启才生效。` : `${key} 未设置 brain.json(无需清除)。`;
4041
+ }
4042
+ /** `wemind brain check`:真起一个进程跑 ACP 握手,验证大脑「不仅找得到、而且能说 ACP」。 */
4043
+ async function cmdBrainCheck(key) {
4044
+ const ag = resolveAgent(loadBrainConfig(accountDir(key)));
4045
+ const problem = agentProblem(ag);
4046
+ if (problem) return `❌ ${key} 的大脑不可用:${problem}`;
4047
+ const r = await checkAgent(ag);
4048
+ return `${r.ok ? "✅" : "❌"} ${key} 大脑自检(${ag.name},bin=${ag.bin}):${r.detail}(${r.ms}ms)`;
4049
+ }
4050
+ /** brain 子命令派发:show(默认)/ set / unset / check。 */
4051
+ async function cmdBrain(rest) {
4052
+ const argv = [...rest];
4053
+ const takeFlag = (name) => {
4054
+ const i = argv.indexOf(name);
4055
+ if (i < 0) return void 0;
4056
+ const v = argv[i + 1];
4057
+ argv.splice(i, 2);
4058
+ return v;
4059
+ };
4060
+ const bin = takeFlag("--bin");
4061
+ const argsOverride = takeFlag("--args");
4062
+ const restart = argv.includes("--restart");
4063
+ if (restart) argv.splice(argv.indexOf("--restart"), 1);
4064
+ const shell = argv.includes("--shell") ? true : argv.includes("--no-shell") ? false : void 0;
4065
+ for (const f of ["--shell", "--no-shell"]) {
4066
+ const i = argv.indexOf(f);
4067
+ if (i >= 0) argv.splice(i, 1);
4068
+ }
4069
+ const pos = argv.filter((x) => x && !x.startsWith("--"));
4070
+ const first = (pos[0] || "").toLowerCase();
4071
+ if (first && !(/* @__PURE__ */ new Set([
4072
+ "show",
4073
+ "set",
4074
+ "unset",
4075
+ "check"
4076
+ ])).has(first)) return describeBrain(pickKey(pos[0]));
4077
+ const sub = first || "show";
4078
+ switch (sub) {
4079
+ case "show": return describeBrain(pickKey(pos[1]));
4080
+ case "set":
4081
+ if (!pos[1]) throw new Error("用法: brain set <预设|auto> [--bin X] [--args \"…\"] [--shell|--no-shell] [--restart] [<key|label>]");
4082
+ return cmdBrainSet(pos[1], pickKey(pos[2]), {
4083
+ bin,
4084
+ args: argsOverride,
4085
+ shell,
4086
+ restart
4087
+ });
4088
+ case "unset": return cmdBrainUnset(pickKey(pos[1]));
4089
+ case "check": return await cmdBrainCheck(pickKey(pos[1]));
4090
+ default: throw new Error(`未知 brain 子命令「${sub}」(可用:show / set / unset / check)`);
4091
+ }
4092
+ }
4093
+ function cmdAdopt(key, label) {
4094
+ if (!key) throw new Error("用法: adopt <key> [--label 名]");
4095
+ const acc = readAccount(accountDir(key));
4096
+ if (!acc?.botId) throw new Error(`${accountDir(key)} 无有效 account.json`);
4097
+ const meta = registerAccount({
4098
+ key,
4099
+ botId: acc.botId,
4100
+ userId: acc.userId,
4101
+ baseUrl: acc.baseUrl,
4102
+ label: label || key
4103
+ });
4104
+ if (acc.userId) addAllowFrom(acc.userId, accountDir(key));
4105
+ return `已登记:key=${key} label=${meta.label} botId=${acc.botId}`;
4106
+ }
4107
+ async function cmdLogout(sel) {
4108
+ const key = resolveKey(sel);
4109
+ const dir = accountDir(key);
4110
+ const acc = readAccount(dir);
4111
+ const stopMsg = stopDaemon(key);
4112
+ if (acc?.botToken) try {
4113
+ await notifyStop({
4114
+ baseUrl: acc.baseUrl,
4115
+ token: acc.botToken
4116
+ });
4117
+ } catch {}
4118
+ for (const f of [
4119
+ "account.json",
4120
+ "state.json",
4121
+ "qr_login.json",
4122
+ "qrcode.png",
4123
+ "qrcode.txt"
4124
+ ]) try {
4125
+ fs.unlinkSync(path.join(dir, f));
4126
+ } catch {}
4127
+ return `账号 ${key} 已登出(${stopMsg})。目录与会话保留,可重新登录。`;
4128
+ }
4129
+ function cmdDelete(sel) {
4130
+ const key = resolveKey(sel);
4131
+ const stopMsg = stopDaemon(key);
4132
+ try {
4133
+ fs.rmSync(accountDir(key), {
4134
+ recursive: true,
4135
+ force: true
4136
+ });
4137
+ } catch (e) {
4138
+ throw new Error(`删除目录失败: ${String(e)}`);
4139
+ }
4140
+ unregisterAccount(key);
4141
+ return `账号 ${key} 已删除(${stopMsg},已移除目录与注册)。`;
4142
+ }
4143
+ function cmdStart(sel, runner) {
4144
+ const key = resolveKey(sel);
4145
+ startDaemon(key, runner);
4146
+ return `已启动 daemon(账号 ${key})。用 list 查看运行状态。`;
4147
+ }
4148
+ function taskInboxDir(key) {
4149
+ return path.join(accountDir(key), "task-inbox");
4150
+ }
4151
+ /** 原子写一个命令文件到收件箱(临时文件 + rename,避免 daemon 读到半截)。 */
4152
+ function writeTaskCmd(key, obj) {
4153
+ const dir = taskInboxDir(key);
4154
+ fs.mkdirSync(dir, { recursive: true });
4155
+ const fp = path.join(dir, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`);
4156
+ const tmp = `${fp}.tmp`;
4157
+ fs.writeFileSync(tmp, JSON.stringify({
4158
+ ...obj,
4159
+ ts: Date.now()
4160
+ }, null, 2), "utf-8");
4161
+ fs.renameSync(tmp, fp);
4162
+ }
4163
+ /** 选账号:显式 key/label 优先,否则用默认账号。 */
4164
+ function pickKey(sel) {
4165
+ if (sel) return resolveKey(sel);
4166
+ const d = getDefaultKey();
4167
+ if (!d) throw new Error("无默认账号,请指定 <key|label> 或先 login");
4168
+ return d;
4169
+ }
4170
+ /** 预检 daemon 存活(本地命令需 daemon 在跑才有人消费收件箱)。 */
4171
+ function requireDaemonAlive(key) {
4172
+ if (!aliveOurs(readPid(accountDir(key), "daemon.pid"))) throw new Error(`账号 ${key} 的 daemon 未运行。先启动:wemind start ${key}(用 wemind list 查看状态)`);
4173
+ }
4174
+ async function cmdTask(text, cwd, sel) {
4175
+ if (!text) throw new Error("用法: task \"<任务描述>\" [--cwd 目录] [<key|label>]");
4176
+ const key = pickKey(sel);
4177
+ requireDaemonAlive(key);
4178
+ const useCwd = cwd || process.cwd();
4179
+ writeTaskCmd(key, {
4180
+ kind: "dispatch",
4181
+ text,
4182
+ cwd: useCwd
4183
+ });
4184
+ const stateFile = path.join(accountDir(key), "task-state.json");
4185
+ for (let i = 0; i < 15; i++) {
4186
+ await sleep(200);
4187
+ try {
4188
+ if (JSON.parse(fs.readFileSync(stateFile, "utf-8")).active) return `已派发任务(worker 运行中,cwd=${useCwd})。\n用 task-status 看进展、task-reply 回复、task-stop 结束。`;
4189
+ } catch {}
4190
+ }
4191
+ return `已投递任务(cwd=${useCwd})。daemon 处理中,用 task-status 查看。`;
4192
+ }
4193
+ function cmdTaskReply(text, sel) {
4194
+ if (!text) throw new Error("用法: task-reply \"<内容>\" [<key|label>]");
4195
+ const key = pickKey(sel);
4196
+ requireDaemonAlive(key);
4197
+ writeTaskCmd(key, {
4198
+ kind: "reply",
4199
+ text
4200
+ });
4201
+ return `已发送给任务:${text.slice(0, 40)}`;
4202
+ }
4203
+ function cmdTaskStop(sel) {
4204
+ const key = pickKey(sel);
4205
+ requireDaemonAlive(key);
4206
+ writeTaskCmd(key, { kind: "stop" });
4207
+ return "已发送「结束任务」指令。";
4208
+ }
4209
+ function cmdTaskStatus(sel) {
4210
+ const key = pickKey(sel);
4211
+ const stateFile = path.join(accountDir(key), "task-state.json");
4212
+ let s;
4213
+ try {
4214
+ s = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
4215
+ } catch {
4216
+ return `账号 ${key}:无任务状态(task-state.json 不存在,daemon 可能未跑过任务)。`;
4217
+ }
4218
+ if (!s.active) return `账号 ${key}:当前无活跃任务(idle)。`;
4219
+ const since = s.startedAt ? `${Math.round((Date.now() - s.startedAt) / 1e3)}s` : "?";
4220
+ return [
4221
+ `账号 ${key} 任务状态:`,
4222
+ ` 状态:${s.status} 轮次:${s.turns ?? 0} 已运行:${since}`,
4223
+ ` 最近输出:${s.lastOutput ? s.lastOutput.slice(0, 300) : "(无)"}`
4224
+ ].join("\n");
4225
+ }
4226
+ async function runManage(argv) {
4227
+ const cmd = argv.shift();
4228
+ const rest = [...argv];
4229
+ const li = rest.indexOf("--label");
4230
+ let label;
4231
+ if (li >= 0) {
4232
+ label = rest[li + 1];
4233
+ rest.splice(li, 2);
4234
+ }
4235
+ const si = rest.indexOf("--start");
4236
+ const autoStart = si >= 0;
4237
+ if (autoStart) rest.splice(si, 1);
4238
+ const ci = rest.indexOf("--cwd");
4239
+ let cwd;
4240
+ if (ci >= 0) {
4241
+ cwd = rest[ci + 1];
4242
+ rest.splice(ci, 2);
4243
+ }
4244
+ const ri = rest.indexOf("--runner");
4245
+ let runner;
4246
+ if (ri >= 0) {
4247
+ runner = rest[ri + 1];
4248
+ rest.splice(ri, 2);
4249
+ }
4250
+ switch (cmd) {
4251
+ case "version":
4252
+ case "--version":
4253
+ case "-v":
4254
+ console.log(versionLine());
4255
+ break;
4256
+ case "login":
4257
+ await cmdLogin(label, autoStart, runner);
4258
+ break;
4259
+ case "list":
4260
+ console.log(cmdList());
4261
+ break;
4262
+ case "brain":
4263
+ console.log(await cmdBrain(rest));
4264
+ break;
4265
+ case "adopt":
4266
+ console.log(cmdAdopt(rest[0], label));
4267
+ break;
4268
+ case "start":
4269
+ console.log(cmdStart(rest[0], runner));
4270
+ break;
4271
+ case "stop": {
4272
+ const k = resolveKey(rest[0]);
4273
+ console.log(stopDaemon(k));
4274
+ break;
4275
+ }
4276
+ case "task":
4277
+ console.log(await cmdTask(rest[0], cwd, rest[1]));
4278
+ break;
4279
+ case "task-reply":
4280
+ console.log(cmdTaskReply(rest[0], rest[1]));
4281
+ break;
4282
+ case "task-status":
4283
+ console.log(cmdTaskStatus(rest[0]));
4284
+ break;
4285
+ case "task-stop":
4286
+ console.log(cmdTaskStop(rest[0]));
4287
+ break;
4288
+ case "label": {
4289
+ const k = resolveKey(rest[0]);
4290
+ setLabel(k, rest.slice(1).join(" "));
4291
+ console.log(`已设置 ${k} 的别名为「${rest.slice(1).join(" ")}」`);
4292
+ break;
4293
+ }
4294
+ case "set-default": {
4295
+ const k = resolveKey(rest[0]);
4296
+ setDefaultKey(k);
4297
+ console.log(`默认账号 = ${k}`);
4298
+ break;
4299
+ }
4300
+ case "logout":
4301
+ console.log(await cmdLogout(rest[0]));
4302
+ break;
4303
+ case "delete":
4304
+ console.log(cmdDelete(rest[0]));
4305
+ break;
4306
+ case "finalize": {
4307
+ const { msg } = finalizeStaging(label);
4308
+ console.log(msg);
4309
+ break;
4310
+ }
4311
+ case "allow": {
4312
+ const k = resolveKey(rest[0]);
4313
+ const uid = rest[1];
4314
+ if (!uid) throw new Error("用法: allow <key|label> <userId>");
4315
+ const list = addAllowFrom(uid, accountDir(k));
4316
+ console.log(`已授权 ${uid} → ${k}\n当前清单(${list.length}):${list.join(", ")}`);
4317
+ break;
4318
+ }
4319
+ case "revoke": {
4320
+ const k = resolveKey(rest[0]);
4321
+ const uid = rest[1];
4322
+ if (!uid) throw new Error("用法: revoke <key|label> <userId>");
4323
+ const list = removeAllowFrom(uid, accountDir(k));
4324
+ console.log(`已撤销 ${uid} → ${k}\n当前清单(${list.length}):${list.join(", ") || "(空)"}`);
4325
+ break;
4326
+ }
4327
+ case "allowlist": {
4328
+ const k = resolveKey(rest[0]);
4329
+ const list = readAllowFrom(accountDir(k));
4330
+ console.log(`${k} 授权用户(${list.length}):\n${list.map((u) => " - " + u).join("\n") || " (空)"}`);
4331
+ break;
4332
+ }
4333
+ default: console.log(`WeMind 多账号管理(wemind v${CLI_VERSION})
4334
+ 用法: wemind <command> 其余:wemind version | help
4335
+
4336
+ login [--label 名] [--start] 一条命令完成扫码登录:自刷新二维码 + 自动归档(--start 顺带启动 daemon)
4337
+ list 列出账号、别名、运行状态
4338
+ start <key|label> 启动某账号的 daemon
4339
+ stop <key|label> 停止某账号的 daemon
4340
+ brain [show|set|unset|check] [<key|label>] 查看/切换/清除/自检账号的大脑(ACP agent)
4341
+ set: brain set <预设|auto> [--bin X] [--args "…"] [--restart] [<key|label>]
4342
+ 大脑选择按账号持久化在 brain.json;环境变量 WEMIND_BRAIN* 为一次性覆盖
4343
+ task "<描述>" [--cwd 目录] [<key|label>] 本地派发任务给 worker(默认 cwd=当前终端目录)
4344
+ task-reply "<内容>" [<key|label>] 回复/注入当前任务(「task-reply 继续」= 续跑)
4345
+ task-status [<key|label>] 查看当前任务状态与最近输出
4346
+ task-stop [<key|label>] 结束当前任务
4347
+ logout <key|label> 停 daemon + 通知下线 + 清凭证(保留目录,可重登)
4348
+ delete <key|label> 停 daemon + 删除目录 + 移除注册
4349
+ label <key|label> <新别名> 改别名
4350
+ set-default <key|label> 设默认账号
4351
+ adopt <key> [--label 名] 把已存在的账号目录登记进注册表
4352
+ allowlist <key|label> 查看该账号的授权用户清单(allowFrom)
4353
+ allow <key|label> <userId> 授权某用户可驱动该账号 bot
4354
+ revoke <key|label> <userId> 撤销某用户的授权
4355
+
4356
+ 说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label。`);
4357
+ }
4358
+ }
4359
+ //#endregion
4360
+ //#region src/cli.ts
4361
+ /**
4362
+ * wemind —— 完整应用的命令行入口(bin)。
4363
+ * - 子命令 `daemon`:跑 daemon 本体(长轮询 + 大脑 + worker),通常由 pm2 调用,用户不直接用。
4364
+ * - 其余子命令(login/list/start/stop/task…):交给 manage 派发。
4365
+ */
4366
+ const argv = process.argv.slice(2);
4367
+ if (argv[0] === "daemon") runDaemon().catch((err) => {
4368
+ console.error("[wemind/wechat-daemon] fatal:", err);
4369
+ process.exit(1);
4370
+ });
4371
+ else if (argv[0] === "_watchdog") runWatchdog().catch((err) => {
4372
+ console.error("[wemind/wechat-daemon/watchdog] fatal:", err);
4373
+ process.exit(1);
4374
+ });
4375
+ else runManage(argv).catch((e) => {
4376
+ console.error("error:", e instanceof Error ? e.message : e);
4377
+ process.exit(1);
4378
+ });
4379
+ //#endregion
4380
+ export {};
4381
+
4382
+ //# sourceMappingURL=cli.js.map