@mrrisega/dsh-remote 0.7.0-beta.2 → 0.7.0-beta.3

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.
@@ -254,8 +254,25 @@ function resolveInstallVersion() {
254
254
  return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
255
255
  } catch { return ""; }
256
256
  }
257
+ /**
258
+ * 宿主(DeepSeek Harness)版本:**由插件注入**,bridge 自己算不出来。
259
+ *
260
+ * 为什么必须是注入:宿主版本要从"宿主进程的 argv / 安装目录"推出来,而 bridge 是插件
261
+ * **另外拉起的独立进程**(脱离宿主,见 dsh-setup.mjs 的守护),它的 argv 与宿主毫无关系。
262
+ * 插件跑在宿主进程内,能拿到真实版本(见 packages/dsh-remote-web/lib/index.js 的
263
+ * harnessVersion()),所以由它经 DSH_BRIDGE_HARNESS_VERSION 传下来。
264
+ *
265
+ * 与 INSTALL_VERSION 的区别(曾经把这两个数混为一谈,导致版本问题答不出来):
266
+ * install_version = **本插件**的版本(用户装的 dsh-remote-web 是哪个版本)
267
+ * harness_version = **宿主**的版本(用户跑的 DeepSeek Harness 是哪个版本)
268
+ * 只知其一都定位不了"是不是宿主升级导致的兼容性故障"。
269
+ */
270
+ function resolveHarnessVersion() {
271
+ return String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
272
+ }
257
273
  const INSTALL_SOURCE = resolveInstallSource();
258
274
  const INSTALL_VERSION = resolveInstallVersion();
275
+ const HARNESS_VERSION = resolveHarnessVersion();
259
276
 
260
277
  // ---------- 稳定设备身份(.dsh-config.json) ----------
261
278
 
@@ -1399,6 +1416,9 @@ async function registerDeviceInAccount(token) {
1399
1416
  // install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
1400
1417
  install_source: INSTALL_SOURCE,
1401
1418
  install_version: INSTALL_VERSION,
1419
+ // v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
1420
+ // 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
1421
+ harness_version: HARNESS_VERSION,
1402
1422
  host_os: process.platform,
1403
1423
  host_arch: process.arch
1404
1424
  })
@@ -1244,7 +1244,12 @@ const SCRIPT = `(() => {
1244
1244
  旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
1245
1245
  反而让用户以为哪里坏了。 */
1246
1246
  const RTT_KEEP = 8, RTT_MAX_MS = 20000;
1247
- const rttSamples = [];
1247
+ /* 🔴 样本数组挂在 window 上,**不在 buildRemoteMenu 的闭包里**(2026-10-09 加固)。
1248
+ 为什么:fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而菜单/悬浮按钮
1249
+ 在官方 SPA 重渲染时可能被**重建**。若样本数组长在 buildRemoteMenu 里,包装就会一直喂给
1250
+ "上一代"渲染器(那个节点已经不在文档里),新界面读自己的空数组 → **永远停在「测速中…」**。
1251
+ 挂在 window 上则任何一代渲染器都读同一份样本。 */
1252
+ const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
1248
1253
  const rttEl = fab.querySelector('[data-role="rtt"]');
1249
1254
  const netEl = menu.querySelector('[data-role="net"]');
1250
1255
  const rttValue = () => {
@@ -1262,11 +1267,13 @@ const SCRIPT = `(() => {
1262
1267
  try {
1263
1268
  const ms = rttValue();
1264
1269
  const g = ms === null ? "" : rttGrade(ms);
1270
+ // 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
1271
+ const shown = ms === null ? null : Math.max(1, Math.round(ms));
1265
1272
  if (rttEl) {
1266
- if (ms === null) { rttEl.hidden = true; rttEl.textContent = ""; rttEl.removeAttribute("data-rtt"); }
1273
+ if (shown === null) { rttEl.hidden = true; rttEl.textContent = ""; rttEl.removeAttribute("data-rtt"); }
1267
1274
  else {
1268
1275
  rttEl.hidden = false;
1269
- rttEl.textContent = ms + "ms";
1276
+ rttEl.textContent = shown + "ms";
1270
1277
  rttEl.setAttribute("data-rtt", g);
1271
1278
  }
1272
1279
  }
@@ -1274,17 +1281,22 @@ const SCRIPT = `(() => {
1274
1281
  netEl.setAttribute("data-rtt", g || "");
1275
1282
  const ico = netEl.querySelector(".ico");
1276
1283
  const txt = netEl.querySelector(".txt");
1277
- if (ico) ico.textContent = ms === null ? "📶" : (g === "bad" ? "📵" : "📶");
1278
- if (txt) txt.textContent = ms === null ? "测速中…(等一次网络请求后显示)" : (ms + " ms · " + rttLabel(g));
1284
+ if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
1285
+ if (txt) txt.textContent = shown === null ? "测速中…(等一次网络请求后显示)" : (shown + " ms · " + rttLabel(g));
1279
1286
  }
1280
- try { fab.title = ms === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + ms + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
1287
+ try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
1281
1288
  } catch (e) { /* 延迟显示失败绝不影响主功能 */ }
1282
1289
  };
1283
1290
  /** 记一个成功样本(由 fetch 包装在**响应到达**时调用;失败/超时不调用)。 */
1284
1291
  const noteRtt = (ms) => {
1285
1292
  if (typeof ms !== "number" || !isFinite(ms)) return;
1286
1293
  const r = Math.round(ms);
1287
- if (r < 1 || r > RTT_MAX_MS) return; // 亚毫秒=同帧缓存命中;>20s=卡住/超时 → 都不是"网络延迟"
1294
+ /* 🔴 旧写法是 if (r < 1 || ...) return; ,注释说"亚毫秒=同帧缓存命中"。这个假设是**错的**:
1295
+ 本机/回环访问(桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms,
1296
+ 于是**每一个**样本都被丢掉,界面永远停在「测速中…(等一次网络请求后显示)」——
1297
+ 业主 2026-10-09 实测反馈的正是这个现象。只拒绝负数与超时;0 → 展示层夹到 1ms。
1298
+ (面板侧 chip 从一开始就是 v <= 0 + Math.max(1, round),两边口径由此对齐。) */
1299
+ if (r < 0 || r > RTT_MAX_MS) return;
1288
1300
  rttSamples.push(r);
1289
1301
  while (rttSamples.length > RTT_KEEP) rttSamples.shift();
1290
1302
  renderRtt();
@@ -0,0 +1,133 @@
1
+ // 设备登记上报口径回归(2026-10 版本分析需求)。
2
+ //
3
+ // 【为什么必须有】生产反馈只能看到「插件版本」(install_version),从来拿不到
4
+ // 「宿主 DeepSeek Harness 版本」—— 于是「是不是 DSH 升级导致的兼容性故障」这个问题
5
+ // 在后台**答不出来**(数据不能回填,只能从新版开始积累)。
6
+ // 宿主版本由插件在**宿主进程里**问出来(harnessVersion()),经 DSH_BRIDGE_HARNESS_VERSION
7
+ // 传给 bridge;bridge 自己(独立进程)推不出来。本用例走真实 bridge 进程 + 假账号 API,
8
+ // 逐字断言**桥到底往 /api/devices 发了什么**。
9
+ import assert from "node:assert/strict";
10
+ import { spawn } from "node:child_process";
11
+ import http from "node:http";
12
+ import { mkdtemp, rm } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import test from "node:test";
16
+ import { setTimeout as delay } from "node:timers/promises";
17
+ import { fileURLToPath } from "node:url";
18
+ import { WebSocketServer } from "ws";
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+ const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
22
+
23
+ test("设备登记:harness_version(宿主版本)与 install_version(插件版本)必须分别如实上报", async () => {
24
+ let seen = null;
25
+ const api = http.createServer((req, res) => {
26
+ if (req.url === "/api/device-login") {
27
+ res.writeHead(200, { "content-type": "application/json" });
28
+ res.end(JSON.stringify({ token: "token-1" }));
29
+ return;
30
+ }
31
+ if (req.url === "/api/devices") {
32
+ let raw = "";
33
+ req.on("data", (c) => { raw += c; });
34
+ req.on("end", () => {
35
+ try { seen = JSON.parse(raw); } catch { seen = { parseError: raw }; }
36
+ res.writeHead(201, { "content-type": "application/json" });
37
+ res.end(JSON.stringify({ device: { id: "dev-000000000002" } }));
38
+ });
39
+ return;
40
+ }
41
+ res.writeHead(404, { "content-type": "application/json" });
42
+ res.end(JSON.stringify({ error: "not found" }));
43
+ });
44
+ await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
45
+
46
+ const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
47
+ await new Promise((resolve) => wss.once("listening", resolve));
48
+
49
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-report-"));
50
+ const child = spawn(process.execPath, [BRIDGE], {
51
+ env: {
52
+ ...process.env,
53
+ DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
54
+ DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
55
+ DSH_BRIDGE_PHONE: "test-account",
56
+ DSH_BRIDGE_PASSWORD: "test-password",
57
+ DSH_BRIDGE_DEVICE_ID: "dev-000000000002",
58
+ DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
59
+ // 插件在宿主进程里探测到的宿主版本 → 经环境变量注入 bridge
60
+ DSH_BRIDGE_HARNESS_VERSION: "0.2.0-rc.2",
61
+ DSH_BRIDGE_INSTALL_VERSION: "0.7.0-beta.2",
62
+ DSH_BRIDGE_INSTALL_SOURCE: "plugin_market",
63
+ },
64
+ stdio: "ignore",
65
+ });
66
+
67
+ try {
68
+ for (let i = 0; i < 60 && !seen; i += 1) await delay(100, null, { ref: false });
69
+ assert.ok(seen, "bridge 应在 6 秒内完成设备登记(POST /api/devices)");
70
+ assert.equal(seen.harness_version, "0.2.0-rc.2", "★宿主版本必须进登记请求(这是本次需求的核心)");
71
+ assert.equal(seen.install_version, "0.7.0-beta.2", "插件版本照旧上报,两个数都要有");
72
+ assert.equal(seen.install_source, "plugin_market");
73
+ assert.equal(seen.device_id, "dev-000000000002");
74
+ assert.ok(seen.host_os && seen.host_arch, "平台/架构照旧");
75
+ } finally {
76
+ try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
77
+ await new Promise((r) => wss.close(r));
78
+ await new Promise((r) => api.close(r));
79
+ await rm(tempDir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ test("设备登记:老插件不注入 DSH_BRIDGE_HARNESS_VERSION 时,字段必须是空串(不是缺字段、更不是猜)", async () => {
84
+ let seen = null;
85
+ const api = http.createServer((req, res) => {
86
+ if (req.url === "/api/device-login") {
87
+ res.writeHead(200, { "content-type": "application/json" });
88
+ res.end(JSON.stringify({ token: "token-1" }));
89
+ return;
90
+ }
91
+ if (req.url === "/api/devices") {
92
+ let raw = "";
93
+ req.on("data", (c) => { raw += c; });
94
+ req.on("end", () => {
95
+ try { seen = JSON.parse(raw); } catch { seen = null; }
96
+ res.writeHead(201, { "content-type": "application/json" });
97
+ res.end(JSON.stringify({ device: { id: "dev-000000000003" } }));
98
+ });
99
+ return;
100
+ }
101
+ res.writeHead(404, { "content-type": "application/json" });
102
+ res.end(JSON.stringify({ error: "not found" }));
103
+ });
104
+ await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
105
+ const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
106
+ await new Promise((resolve) => wss.once("listening", resolve));
107
+
108
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-report-"));
109
+ const env = { ...process.env };
110
+ delete env.DSH_BRIDGE_HARNESS_VERSION; // 模拟老插件
111
+ const child = spawn(process.execPath, [BRIDGE], {
112
+ env: {
113
+ ...env,
114
+ DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
115
+ DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
116
+ DSH_BRIDGE_PHONE: "test-account",
117
+ DSH_BRIDGE_PASSWORD: "test-password",
118
+ DSH_BRIDGE_DEVICE_ID: "dev-000000000003",
119
+ DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
120
+ },
121
+ stdio: "ignore",
122
+ });
123
+ try {
124
+ for (let i = 0; i < 60 && !seen; i += 1) await delay(100, null, { ref: false });
125
+ assert.ok(seen, "bridge 仍应完成设备登记");
126
+ assert.equal(seen.harness_version, "", "取不到就上报空串(= 未上报),绝不猜一个错的版本");
127
+ } finally {
128
+ try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
129
+ await new Promise((r) => wss.close(r));
130
+ await new Promise((r) => api.close(r));
131
+ await rm(tempDir, { recursive: true, force: true });
132
+ }
133
+ });
@@ -535,6 +535,47 @@ test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程
535
535
  }
536
536
  });
537
537
 
538
+ test("★ 亚毫秒样本(本机/回环自测)也必须出数 —— 不得永远停在「测速中…」", async () => {
539
+ // 业主 2026-10-09 实测反馈:「小悬浮窗一直提示『测速中,等待下一次网络请求后开始测速』」。
540
+ // 根因:旧实现是 `if (r < 1) return;`(注释写"亚毫秒=同帧缓存命中"),但**本机/回环**访问
541
+ // (桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms —— 于是**每一个**样本
542
+ // 都被丢掉,界面永远停在空态。面板侧 chip 从来没这个毛病(`v <= 0` + `Math.max(1, …)`),
543
+ // 这条用例把两侧口径钉在一起。
544
+ const env = harness({ width: 390, fetchImpl: async () => ({ status: 200, ok: true }) }); // 零延迟 = 亚毫秒
545
+ runAdapter(env);
546
+ const rtt = findFab(env).querySelector('[data-role="rtt"]');
547
+ assert.equal(rtt.hidden, true, "前提:还没有样本");
548
+ await env.win.fetch("/api/session/list");
549
+ await new Promise((r) => setTimeout(r, 10));
550
+ assert.equal(rtt.hidden, false, "★亚毫秒的真实往返必须记成样本(否则本机自测永远是「测速中…」)");
551
+ assert.equal(String(rtt.textContent), "1ms", "展示层夹到 1ms —— 0ms 是谎话");
552
+ const net = findMenu(env).querySelector(".dsh-ma-menu-net");
553
+ assert.ok(!/测速中/.test(String(net.textContent)),
554
+ `菜单行不得再显示空态,实际:${net.textContent}`);
555
+ });
556
+
557
+ test("★ 悬浮窗被官方 SPA 重渲染重建后,已积累的样本仍要显示(样本不跟着某一代闭包走)", async () => {
558
+ // fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而悬浮窗可能被重建。
559
+ // 若样本数组长在 buildRemoteMenu 的闭包里,包装会一直喂"上一代"(节点已不在文档里),
560
+ // 新界面读自己那份空数组 → 又是永远「测速中…」。样本挂在 window 上才不会这样。
561
+ const env = harness({
562
+ width: 390,
563
+ fetchImpl: async () => { await new Promise((r) => setTimeout(r, 5)); return { status: 200, ok: true }; },
564
+ });
565
+ runAdapter(env);
566
+ await env.win.fetch("/api/session/list");
567
+ await new Promise((r) => setTimeout(r, 10));
568
+ assert.equal(findFab(env).querySelector('[data-role="rtt"]').hidden, false, "前提:第一代已有样本");
569
+
570
+ // 模拟 SPA 重渲染:官方把 body 子树整片换掉(我们的悬浮窗跟着消失),适配层再建一代。
571
+ env.body.children = [];
572
+ runAdapter(env);
573
+ const fab2 = findFab(env);
574
+ assert.ok(fab2, "重建后应有新的悬浮按钮");
575
+ assert.equal(fab2.querySelector('[data-role="rtt"]').hidden, false,
576
+ "★重建后的悬浮窗必须立刻显示已积累的样本(旧实现读自己那份空数组 → 永远「测速中…」)");
577
+ });
578
+
538
579
  test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速中…」是唯一正确的空态)", async () => {
539
580
  const env = harness({
540
581
  width: 390,
@@ -1983,6 +1983,77 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
1983
1983
  }
1984
1984
  });
1985
1985
 
1986
+ test("★ 待补发队列:过期的**不发**(丢弃最早的),且只保留最近几条", async () => {
1987
+ // 业主 2026-10-09 口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
1988
+ // 两条理由:① 迟到几小时的"任务跑完了"只会误导用户;② 攒到十几条会在平台恢复时一次连打,
1989
+ // 实测正是这种突发批量触发 `ret=-2 prepare failed` 被限流 → 补发又失败 → 队列再涨(死循环)。
1990
+ const ilink = await fakeIlink();
1991
+ try {
1992
+ const sub = makeFakeSubscriber();
1993
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
1994
+ const statePath = path.join(relayDir, ".wechat-state.json");
1995
+ const stateOf = () => JSON.parse(fs.readFileSync(statePath, "utf8"));
1996
+ const now = Date.now();
1997
+ const H = 60 * 60 * 1000;
1998
+
1999
+ // 手工造一段积压:2 条很旧(>2h)+ 1 条刚失败(刚刚)
2000
+ rt.channel.writeState({
2001
+ pending_outbox: [
2002
+ { text: "很久以前的完成通知A", kind: "stopped", at: now - 5 * H },
2003
+ { text: "很久以前的完成通知B", kind: "stopped", at: now - 3 * H },
2004
+ { text: "刚刚的完成通知C", kind: "stopped", at: now - 1000 },
2005
+ ],
2006
+ });
2007
+ // ① 面板看到的是**有效**条数(过期的不该再显示成"待补发")
2008
+ assert.equal(rt.status().pending_outbox, 1, "status 必须按保质期报有效条数(否则重启后面板显示永远发不出去的旧条目)");
2009
+
2010
+ // ② 用户随便发一条 → 触发补发:只发那条没过期的
2011
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
2012
+ await waitFor(() => (stateOf().pending_outbox || []).length === 0);
2013
+ const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
2014
+ assert.equal(resent.length, 1, `★ 只应补发未过期的那一条,实际补发 ${resent.length} 条`);
2015
+ assert.match(resent[0], /刚刚的完成通知C/, "补发的必须是没过期的那条");
2016
+ assert.ok(!resent.some((t) => /很久以前的完成通知/.test(t)), "★ 过期条目绝不能发出去");
2017
+ } finally {
2018
+ await ilink.close();
2019
+ }
2020
+ });
2021
+
2022
+ test("★ 待补发队列:超过上限只留最近几条(丢最早,且如实记日志)", async () => {
2023
+ const ilink = await fakeIlink({ onSendMessage: (_s, reply) => reply({ ret: -2, errmsg: "prepare failed" }) });
2024
+ try {
2025
+ const infos = [];
2026
+ const warns = [];
2027
+ const sub = makeFakeSubscriber();
2028
+ sub.sessionsFixture = [{ sessionId: "session-done", title: "跑完的活", running: false, cwd: "/p" }];
2029
+ sub.summaryFixture = "做完了。";
2030
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2031
+ rt.logger = { info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
2032
+ const statePath = path.join(relayDir, ".wechat-state.json");
2033
+ const stateOf = () => JSON.parse(fs.readFileSync(statePath, "utf8"));
2034
+
2035
+ // 连推 9 条(每一条都发不出去 → 全部入队)。每条正文不同,才能断言"留下的是最近的"。
2036
+ for (let i = 1; i <= 9; i += 1) {
2037
+ sub.summaryFixture = `这是第${i}条结论。`;
2038
+ await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: i });
2039
+ }
2040
+ const queued = stateOf().pending_outbox || [];
2041
+ assert.equal(queued.length, 5, `★ 队列上限 = 最近 5 条,实际 ${queued.length}`);
2042
+ const kept = queued.map((q) => String(q.text)).join("\n");
2043
+ for (const i of [5, 6, 7, 8, 9]) {
2044
+ assert.match(kept, new RegExp(`这是第${i}条结论`), `最近的第 ${i} 条必须保留`);
2045
+ }
2046
+ for (const i of [1, 2, 3, 4]) {
2047
+ assert.ok(!kept.includes(`这是第${i}条结论`), `★ 最早的第 ${i} 条必须被丢弃(只留最近几条)`);
2048
+ }
2049
+ assert.ok(warns.some((m) => /待补发队列丢弃 \d+ 条/.test(m)),
2050
+ `丢数据必须如实记日志(不得静默),实际:${JSON.stringify(warns)}`);
2051
+ } finally {
2052
+ await ilink.close();
2053
+ }
2054
+ });
2055
+
2056
+
1986
2057
  test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
1987
2058
  // 那次「一条推送都没有、日志里也没有任何报错」的根因就在这类静默里:
1988
2059
  // `#emitFault` 只 emit('fault'),而运行时没接这个事件 —— follow 失败/发现失败/流上限
@@ -1990,9 +2061,10 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
1990
2061
  const ilink = await fakeIlink();
1991
2062
  try {
1992
2063
  const warns = [];
2064
+ const infos = [];
1993
2065
  const sub = makeFakeSubscriber();
1994
2066
  const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
1995
- rt.logger = { info() {}, warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
2067
+ rt.logger = { info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
1996
2068
  await rt.startChannel(); // 真实接线:#startSubscriber() → 给注入的订阅器挂 handler
1997
2069
  try {
1998
2070
  sub.emit("fault", { code: "follow-limit", message: "follow 流已达上限 8", sessionId: "session-x", expected: false });
@@ -2002,9 +2074,23 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
2002
2074
  assert.equal(st.last_fault && st.last_fault.code, "follow-limit", "非预期的订阅异常要记进状态供面板显示");
2003
2075
 
2004
2076
  // 预期内的(子代理会话必然被拒 / 会话刚被删)只记日志,不该污染故障状态
2077
+ const warnCountBefore = warns.length;
2005
2078
  sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
2006
2079
  const st2 = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2007
2080
  assert.equal(st2.last_fault.code, "follow-limit", "expected 的异常不该覆盖真正的故障");
2081
+ // ★2026-10-09:预期内的拒绝**不能写成 warn** —— 每生成一个子代理就会来一条,
2082
+ // 成片的 `订阅异常 session/agent-busy …` 会把真正的故障淹掉(业主现场看到的就是这个)。
2083
+ assert.equal(warns.length, warnCountBefore, `expected 的拒绝不得写 warn,实际:${JSON.stringify(warns)}`);
2084
+ assert.ok(infos.some((m) => m.includes("订阅跳过(预期") && m.includes("session=sub-1")),
2085
+ `expected 的拒绝要有一条 info 留痕(绝不静默),实际:${JSON.stringify(infos)}`);
2086
+ // 同一对 (code, session) 反复来也只报一次:否则子代理一多照样刷屏
2087
+ const infoCountBefore = infos.length;
2088
+ sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
2089
+ sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
2090
+ assert.equal(infos.length, infoCountBefore, "同一 (code, session) 的预期拒绝只报一次(防刷屏)");
2091
+ // 但**不同**会话仍各自留痕一次(可诊断,不被去重抹掉)
2092
+ sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-2", expected: true });
2093
+ assert.equal(infos.length, infoCountBefore + 1, "不同子代理会话各留一条");
2008
2094
  } finally {
2009
2095
  await rt.stopChannel();
2010
2096
  }
@@ -199,10 +199,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
199
199
  const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
200
200
 
201
201
  /**
202
- * 主动推送失败后的**待补发队列**上限(见 `#pushProactive`)。
203
- * 为什么是 20:够放下"一轮里所有该告诉用户的事";再多下去,补发就变成刷屏了。
202
+ * 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
203
+ *
204
+ * 2026-10-09 业主口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
205
+ * 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
206
+ * 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
207
+ * 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
208
+ */
209
+ const MAX_OUTBOX = 5;
210
+ /**
211
+ * 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
212
+ *
213
+ * 为什么必须有(同一条业主口径):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
214
+ * 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
215
+ * 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
204
216
  */
205
- const MAX_OUTBOX = 20;
217
+ const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
206
218
  /** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
207
219
  const OUTBOX_MARK = "(补发)";
208
220
 
@@ -337,6 +349,12 @@ function sendJson(res, status, body) {
337
349
  }
338
350
 
339
351
  export class WeChatRuntime {
352
+ /**
353
+ * 「预期内的订阅拒绝」已报过的 (code|sessionId) 集合 —— 见 fault 处理器。
354
+ * 只是防刷屏:每生成一个**子代理**会话都会被订阅层拒一次,不记集合就是每个子代理刷一行 warn。
355
+ */
356
+ #expectedFaultSeen = new Set();
357
+
340
358
  /**
341
359
  * @param {{
342
360
  * relayDir: string,
@@ -508,7 +526,9 @@ export class WeChatRuntime {
508
526
  followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
509
527
  ? this.subscriber.sessions()
510
528
  : [],
511
- pending_outbox: Array.isArray(state.pending_outbox) ? state.pending_outbox.length : 0,
529
+ // 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
530
+ // 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
531
+ pending_outbox: this.#pruneOutbox(state.pending_outbox).kept.length,
512
532
  last_fault: state.last_fault || null,
513
533
  last_completed_session_id: state.last_completed_session_id || "",
514
534
  last_completed_at: Number(state.last_completed_at || 0),
@@ -1547,14 +1567,44 @@ export class WeChatRuntime {
1547
1567
  return list;
1548
1568
  }
1549
1569
 
1570
+ /**
1571
+ * 丢掉**过期**条目(返回保留的与丢掉的数量)。
1572
+ * `at` 缺失/非法 = 更早的落盘格式(那时还没有 at 字段)→ 一律视为过期:
1573
+ * 留着只会把"最近几条"挤掉,而它们必然比 TTL 更老。
1574
+ */
1575
+ #pruneOutbox(list) {
1576
+ const now = this.clock();
1577
+ const kept = [];
1578
+ let dropped = 0;
1579
+ for (const item of Array.isArray(list) ? list : []) {
1580
+ const at = Number(item && item.at);
1581
+ if (!Number.isFinite(at) || at <= 0 || now - at > OUTBOX_TTL_MS) { dropped += 1; continue; }
1582
+ kept.push(item);
1583
+ }
1584
+ return { kept, dropped };
1585
+ }
1586
+
1587
+ /** 丢弃过期的、并按上限只留最近几条;丢了就**如实记一行日志**(绝不静默丢)。 */
1588
+ #trimOutbox(list) {
1589
+ const { kept, dropped } = this.#pruneOutbox(list);
1590
+ let overflow = 0;
1591
+ while (kept.length > MAX_OUTBOX) { kept.shift(); overflow += 1; }
1592
+ const lost = dropped + overflow;
1593
+ if (lost > 0) {
1594
+ const mins = Math.round(OUTBOX_TTL_MS / 60000);
1595
+ const why = [dropped ? `${dropped} 条超 ${mins} 分钟未送达` : "", overflow ? `${overflow} 条超出最近 ${MAX_OUTBOX} 条上限` : ""]
1596
+ .filter(Boolean).join("、");
1597
+ this.logger.warn(`[wechat] 待补发队列丢弃 ${lost} 条(${why}),保留最近 ${kept.length} 条`);
1598
+ }
1599
+ return kept;
1600
+ }
1601
+
1550
1602
  #enqueueOutbox(text, kind = "") {
1551
1603
  const body = String(text || "");
1552
1604
  if (!body) return 0;
1553
1605
  const list = this.#outbox();
1554
1606
  list.push({ text: body, kind: String(kind || ""), at: this.clock() });
1555
- // 上限:丢最旧的(用户最需要的是"最近发生了什么",而无限攒下去只会让补发像刷屏)
1556
- while (list.length > MAX_OUTBOX) list.shift();
1557
- return this.#writeOutbox(list).length;
1607
+ return this.#writeOutbox(this.#trimOutbox(list)).length;
1558
1608
  }
1559
1609
 
1560
1610
  /**
@@ -1562,7 +1612,11 @@ export class WeChatRuntime {
1562
1612
  * @returns {Promise<number>} 这次补发成功的条数
1563
1613
  */
1564
1614
  async #flushOutbox(to) {
1565
- const list = this.#outbox();
1615
+ // 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
1616
+ // 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
1617
+ const raw = this.#outbox();
1618
+ const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
1619
+ if (list.length !== raw.length) this.#writeOutbox(list);
1566
1620
  if (!list.length || !to) return 0;
1567
1621
  let sent = 0;
1568
1622
  const rest = [...list];
@@ -1645,10 +1699,20 @@ export class WeChatRuntime {
1645
1699
  const code = String((info && info.code) || "unknown");
1646
1700
  const message = String((info && info.message) || "").slice(0, 160);
1647
1701
  const sessionId = String((info && info.sessionId) || "");
1648
- this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1649
- if (!(info && info.expected === true)) {
1650
- this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
1702
+ // 预期内的拒绝(子代理会话必然被拒 `session/agent-busy` / 会话刚被删 `session/not-found`)
1703
+ // **不是故障**:每生成一个子代理就会来一条,写成 warn 会把真正的故障淹掉
1704
+ // (2026-10-09 现场:日志里成片 `订阅异常 session/agent-busy … require their durable parent address`)。
1705
+ // 但也不能完全静默(2026-09-25 的教训:fault 以前没人接,全丢)→ 降为 info,且同一对只报一次。
1706
+ if (info && info.expected === true) {
1707
+ const key = `${code}|${sessionId}`;
1708
+ if (this.#expectedFaultSeen.size < 200 && !this.#expectedFaultSeen.has(key)) {
1709
+ this.#expectedFaultSeen.add(key);
1710
+ this.logger.info(`[wechat/events] 订阅跳过(预期,不重试) ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1711
+ }
1712
+ return;
1651
1713
  }
1714
+ this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1715
+ this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
1652
1716
  });
1653
1717
  }
1654
1718
 
package/dsh-setup.mjs CHANGED
@@ -362,9 +362,13 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
362
362
  * 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
363
363
  * DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
364
364
  * install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
365
+ * harness_version:**只能由外部注入** —— 宿主(DeepSeek Harness)版本要从宿主进程的 argv /
366
+ * 安装目录推出来,而安装器是独立进程,推不出来。插件会在宿主进程内问出来再传进来;
367
+ * 用户直接跑 `npx @mrrisega/dsh-remote` 时没有这个值 → 留空(= 未上报,绝不猜一个错的)。
365
368
  */
366
369
  const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
367
370
  const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
371
+ const HARNESS_VERSION = String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
368
372
 
369
373
  /**
370
374
  * 把文件权限收紧到「只有本人可读写」。
@@ -708,7 +712,7 @@ function writeAutostartFile() {
708
712
  <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
709
713
  <key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
710
714
  <key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
711
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
715
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
712
716
  </dict></plist>`;
713
717
  // ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
714
718
  // 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
@@ -724,7 +728,7 @@ function writeAutostartFile() {
724
728
  if (process.platform === "linux") {
725
729
  const dir = path.join(os.homedir(), ".config/systemd/user");
726
730
  fs.mkdirSync(dir, { recursive: true });
727
- const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
731
+ const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\nEnvironment=DSH_BRIDGE_HARNESS_VERSION=${HARNESS_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
728
732
  const unitPath = autostartFilePath();
729
733
  fs.writeFileSync(unitPath, unit);
730
734
  return unitPath; // 路径在最终汇总里统一展示
@@ -1505,6 +1509,8 @@ async function runBridge() {
1505
1509
  // 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1506
1510
  DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
1507
1511
  DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
1512
+ // 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
1513
+ DSH_BRIDGE_HARNESS_VERSION: HARNESS_VERSION,
1508
1514
  ...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
1509
1515
  ...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
1510
1516
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.0-beta.2",
3
+ "version": "0.7.0-beta.3",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",