@mrrisega/dsh-remote 0.7.0-beta.8 → 0.7.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +17 -17
- package/clients/dsh-remote/dsh-events.mjs +6 -6
- package/clients/dsh-remote/e2ee-client.mjs +7 -7
- package/clients/dsh-remote/e2ee-shim-script.js +9 -9
- package/clients/dsh-remote/mobile-adapter.mjs +294 -43
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +1 -1
- package/clients/dsh-remote/test/device-registration-report.test.mjs +1 -1
- package/clients/dsh-remote/test/dsh-events.test.mjs +10 -10
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +4 -4
- package/clients/dsh-remote/test/e2ee-client.test.mjs +7 -7
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +1 -1
- package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +1 -1
- package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +4 -4
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +2 -2
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +6 -6
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +1 -1
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +5 -5
- package/clients/dsh-remote/test/mobile-adapter-settings.test.mjs +352 -0
- package/clients/dsh-remote/test/mobile-adapter.test.mjs +35 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +1 -1
- package/clients/dsh-remote/test/wechat-channel.test.mjs +5 -5
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +26 -26
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +2 -2
- package/clients/dsh-remote/wechat-channel.mjs +9 -9
- package/clients/dsh-remote/wechat-runtime.mjs +38 -38
- package/docs/telemetry.md +3 -3
- package/dsh-setup.mjs +27 -27
- package/package.json +3 -3
- package/packages/dsh-remote-web/lib/client.js +56 -56
- package/packages/dsh-remote-web/lib/fix-prompt.js +1 -1
- package/packages/dsh-remote-web/lib/index.js +380 -59
- package/packages/dsh-remote-web/lib/tarball-update.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +17 -17
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +6 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +7 -7
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +9 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +294 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +9 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +38 -38
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +5 -5
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +27 -27
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +2 -2
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +1 -1
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +2 -2
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +4 -4
- package/packages/dsh-remote-web/test/diag-report.test.mjs +1 -1
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +2 -2
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +1 -1
- package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +3 -3
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +2 -2
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +2 -2
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +1 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -2
- package/packages/dsh-remote-web/test/selfheal-restart-guard.test.mjs +420 -0
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +6 -6
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +2 -2
- package/packages/dsh-remote-web/test/settings-open-path.test.mjs +4 -4
- package/packages/dsh-remote-web/test/setup-output.test.mjs +11 -11
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +1 -1
- package/packages/dsh-remote-web/test/telemetry.test.mjs +4 -4
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +2 -2
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +1 -1
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +7 -7
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +5 -5
- package/scripts/test-net-guard.cjs +1 -1
|
@@ -64,14 +64,14 @@ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* v2 能力表(运营钩子) ——
|
|
67
|
+
* v2 能力表(运营钩子) —— 用户 2026-09-22 确认的分层:
|
|
68
68
|
*
|
|
69
69
|
* · **免费档 = 只读 + 审批**:收得到任务通知(含完成小结、报错、额度、会员到期),
|
|
70
70
|
* 点得了审批回执(选择下一步执行)。但**遥控不了**。
|
|
71
71
|
* · **付费档 = 免费档 + 遥控**:在微信里直接交代任务、切换会话(以及预留的中途纠偏/附件/多会话)。
|
|
72
72
|
*
|
|
73
73
|
* 免费用户尝试付费能力时,回复里带上 App 链接 —— 这既是解释,也是转化入口
|
|
74
|
-
* (见 `#upsellText()
|
|
74
|
+
* (见 `#upsellText()`:用户约定是"如果免费用户要去做其他的东西,就在消息后面跟一个访问 App 的链接")。
|
|
75
75
|
*
|
|
76
76
|
* ⚠️ `pro_max` **必须存在**:服务端的生效套餐取值就是 free / pro / pro_max(见企业端 auth.js 的
|
|
77
77
|
* PLAN_PRIORITY),而 `capabilitiesFor` 对**未知档位回退 free** —— 漏了 pro_max 就会把
|
|
@@ -80,7 +80,7 @@ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
|
80
80
|
*/
|
|
81
81
|
const FREE_CAPABILITIES = Object.freeze([
|
|
82
82
|
"notify", "approve", "status", "stop",
|
|
83
|
-
// ★
|
|
83
|
+
// ★ 用户 2026-09-22 拍板:「免费只能继续已有会话(回复即续接),不能开新会话、不能选用别的会话」。
|
|
84
84
|
// 所以 assign 被**拆成两半**:continue 免费、new 付费 —— 既让免费用户"能接着聊",
|
|
85
85
|
// 又保住"开新任务 / 管会话"是会员能力(否则付费理由就没了)。
|
|
86
86
|
"assign.continue"
|
|
@@ -116,7 +116,7 @@ export function grantsCap(caps, need) {
|
|
|
116
116
|
* 各档位的**额外限制**(与能力表并列,同属"权益包")。
|
|
117
117
|
*
|
|
118
118
|
* `messages_per_month`:该档用户每月最多能给 agent 发多少条消息;**0 或缺省 = 不限**。
|
|
119
|
-
*
|
|
119
|
+
* 用户 2026-09-22:「免费给每月 N 条消息额度,超出再引导升级(可配置,挂在权益包里)」。
|
|
120
120
|
* ⚠️ 只有**派活**(发消息/开任务)计数;审批回执与指令**不计数** ——
|
|
121
121
|
* 否则免费用户会为了省额度而不敢拍板,那等于把安全阀关掉。
|
|
122
122
|
*/
|
|
@@ -126,7 +126,7 @@ export const WECHAT_TIER_LIMITS = Object.freeze({
|
|
|
126
126
|
pro_max: Object.freeze({ messages_per_month: 0 })
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
/** 某档位的限制。未知档位按最低档(free)处理 ——
|
|
129
|
+
/** 某档位的限制。未知档位按最低档(free)处理 —— 与能力表同一约定。 */
|
|
130
130
|
export function limitsFor(tier, table = WECHAT_TIER_LIMITS) {
|
|
131
131
|
const key = Object.prototype.hasOwnProperty.call(table, String(tier || "")) ? String(tier) : "free";
|
|
132
132
|
return table[key] || {};
|
|
@@ -201,7 +201,7 @@ const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
|
|
|
201
201
|
/**
|
|
202
202
|
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
|
|
203
203
|
*
|
|
204
|
-
* 2026-10-09
|
|
204
|
+
* 2026-10-09 用户约定:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
205
205
|
* 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
|
|
206
206
|
* 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
|
|
207
207
|
* 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
|
|
@@ -210,7 +210,7 @@ const MAX_OUTBOX = 5;
|
|
|
210
210
|
/**
|
|
211
211
|
* 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
|
|
212
212
|
*
|
|
213
|
-
*
|
|
213
|
+
* 为什么必须有(同一条用户约定):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
|
|
214
214
|
* 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
|
|
215
215
|
* 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
|
|
216
216
|
*/
|
|
@@ -284,7 +284,7 @@ export const DEFAULT_BIND_TTL_MS = 5 * 60_000;
|
|
|
284
284
|
* 每日简报时刻(本地小时)。
|
|
285
285
|
*
|
|
286
286
|
* ⚠️ 必须是**晚上**而不是早上:简报的内容是"**今天**干了啥"(已完成/出错/你拍板了几次),
|
|
287
|
-
* 早上 9 点发的时候今天才刚开始,整篇都是空的 ——
|
|
287
|
+
* 早上 9 点发的时候今天才刚开始,整篇都是空的 —— 用户原话「每天早上发『今日干了啥事儿』,
|
|
288
288
|
* 这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」。
|
|
289
289
|
* 它同时兼作微信 24h 推送窗口的心跳,所以一天**只发一次**、不要早晚各一次。
|
|
290
290
|
*/
|
|
@@ -500,7 +500,7 @@ export class WeChatRuntime {
|
|
|
500
500
|
cooldown_ms: cooldownMs,
|
|
501
501
|
// 只数**待拍板**的(不含额度/会员到期这类提醒):面板上的数字要与用户真实要做的决定一致
|
|
502
502
|
pending_replies: this.#decisionCount(),
|
|
503
|
-
// ★ 面板要把「你现在能用什么」如实展示出来(
|
|
503
|
+
// ★ 面板要把「你现在能用什么」如实展示出来(用户:话术与权限必须一致,不多承诺)。
|
|
504
504
|
// 这里下发的是**通道实际在用**的那一份(服务端权益包优先,否则内置表按档位),
|
|
505
505
|
// 所以面板不需要自己猜档位 → 展示与判定不可能不一致。
|
|
506
506
|
plan: this.#tier(),
|
|
@@ -517,7 +517,7 @@ export class WeChatRuntime {
|
|
|
517
517
|
: { active: false, failed: !!(this.bind && this.bind.done && !this.bind.result) },
|
|
518
518
|
channel_running: !!this.channelTask,
|
|
519
519
|
events_running: !!this.subscriber,
|
|
520
|
-
// ── 诊断字段(2026-09-25
|
|
520
|
+
// ── 诊断字段(2026-09-25 故障加的)────────────────────────────────────
|
|
521
521
|
// 那次「任务跑完一条微信都没收到,日志里也没有任何报错」暴露了三件必须能看见的事:
|
|
522
522
|
// ① 事件订阅到底 ready 了没、**正在 follow 哪些会话**(follow 集为空 = turn/end 收不到);
|
|
523
523
|
// ② 有没有排进待补发队列的通知(推送失败不再等于消失);
|
|
@@ -536,7 +536,7 @@ export class WeChatRuntime {
|
|
|
536
536
|
};
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
-
/**
|
|
539
|
+
/** 通知文案里用的机器人自称与奖励约定(与产品约定同源)。 */
|
|
540
540
|
#notifyOpts() {
|
|
541
541
|
return { botName: "ClawBot" };
|
|
542
542
|
}
|
|
@@ -749,7 +749,7 @@ export class WeChatRuntime {
|
|
|
749
749
|
/**
|
|
750
750
|
* 取"**最早**一条待答通知"。
|
|
751
751
|
*
|
|
752
|
-
* ⚠️ 必须是**先进先出**,不能取最近一条。真机 bug(
|
|
752
|
+
* ⚠️ 必须是**先进先出**,不能取最近一条。真机 bug(用户报的):
|
|
753
753
|
* 「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,回应一个数字之后,
|
|
754
754
|
* 流程就结束了,后面的几条消息没有让用户继续回应,直接提示为『未回应』」
|
|
755
755
|
* 成因:旧实现按栈顶(最新那条)匹配数字,而**微信是从上往下读的**、消息一发出去位置就固定 ——
|
|
@@ -800,7 +800,7 @@ export class WeChatRuntime {
|
|
|
800
800
|
const from = extractFromUserId(msg);
|
|
801
801
|
const text = normalizeInput(extractInboundText(msg));
|
|
802
802
|
// ★ 每条入站消息都带上 `context_token`,而**出站必须回带它**才发得出去
|
|
803
|
-
// (不进它 → 真机 `ret=-2 errmsg=prepare failed
|
|
803
|
+
// (不进它 → 真机 `ret=-2 errmsg=prepare failed`,用户「没收到微信通道」)。
|
|
804
804
|
// 放在 `if (!text) return` **之前**:图片/语音这类没有文本的消息同样会刷新上下文。
|
|
805
805
|
// 记不住不影响主流程(最坏就是这一条发不出去,与以前一样)。
|
|
806
806
|
try { this.channel.rememberContextToken(extractContextToken(msg), from); } catch { /* 忽略 */ }
|
|
@@ -832,7 +832,7 @@ export class WeChatRuntime {
|
|
|
832
832
|
return;
|
|
833
833
|
}
|
|
834
834
|
if (cls.kind === "message") {
|
|
835
|
-
// 纯文本 = 派活。★ 分两种(
|
|
835
|
+
// 纯文本 = 派活。★ 分两种(用户 2026-09-22 拍板):
|
|
836
836
|
// · **有当前会话** → 「继续已有会话」= assign.continue —— **免费也有**(让免费用户能接着聊)
|
|
837
837
|
// · **没有当前会话** → 等同开新任务 = assign.new —— 付费才有
|
|
838
838
|
// 这样既满足"能继续聊",又保住"开新任务/管会话"是会员能力。
|
|
@@ -896,7 +896,7 @@ export class WeChatRuntime {
|
|
|
896
896
|
}
|
|
897
897
|
|
|
898
898
|
/**
|
|
899
|
-
* **每月消息额度闸**(
|
|
899
|
+
* **每月消息额度闸**(用户 2026-09-22:免费给每月 N 条,超出引导升级)。
|
|
900
900
|
* `messages_per_month` 为 0/缺省 = 不限(付费档走这条)。
|
|
901
901
|
* ⚠️ 只在**派活**前拦;审批回执与指令不计数、也不拦 ——
|
|
902
902
|
* 否则免费用户会为了省额度不敢拍板,那等于把安全阀关掉。
|
|
@@ -1005,7 +1005,7 @@ export class WeChatRuntime {
|
|
|
1005
1005
|
// 只勾细粒度 `assign.new` 而不勾粗粒度根 `assign` 时,旧的 `#can("assign")` 返回**假**,
|
|
1006
1006
|
// 于是**付费用户**拿到一份写着"你没有这些功能"的免费档帮助;
|
|
1007
1007
|
// 反过来(勾了粗根又关掉某个子能力)则会把做不到的指令列成"你能用的"。
|
|
1008
|
-
//
|
|
1008
|
+
// 两个方向都违反用户红线「话术与权限必须一致、不多承诺」。
|
|
1009
1009
|
// 现在每一行都挂一个判据,能不能用由 `#can(...)` **逐条**决定。
|
|
1010
1010
|
const canNew = this.#can("assign.new");
|
|
1011
1011
|
const canContinue = this.#can("assign.continue");
|
|
@@ -1015,7 +1015,7 @@ export class WeChatRuntime {
|
|
|
1015
1015
|
{ ok: this.#can("status"), text: "· /status 查看绑定与推送状态" },
|
|
1016
1016
|
{ ok: true, text: "· /quiet 暂停推送(回复任意消息恢复)" }, // 本地开关,不需要任何能力
|
|
1017
1017
|
{ ok: true, text: "· /unbind 解除微信绑定" },
|
|
1018
|
-
// 「接着当前任务聊」与「开新任务」是**两个独立能力**(
|
|
1018
|
+
// 「接着当前任务聊」与「开新任务」是**两个独立能力**(用户 2026-09-22 拍板把 assign 拆成两半),
|
|
1019
1019
|
// 所以这里也拆成两行、各自判各自的:免费用户会看到第一行可用、第二行落在会员区。
|
|
1020
1020
|
// ⚠️ 行内**不要**再写"会员功能"三个字 —— 那是下面分区标题的专属词,
|
|
1021
1021
|
// 混进可用段会让"会员区之前不得出现 /new"这类结构断言失准(读起来也乱)。
|
|
@@ -1047,7 +1047,7 @@ export class WeChatRuntime {
|
|
|
1047
1047
|
/**
|
|
1048
1048
|
* 免费用户越界时的回复:说清「通知版能做什么」+ 给出可点击的 App 链接。
|
|
1049
1049
|
*
|
|
1050
|
-
*
|
|
1050
|
+
* 用户约定:免费用户要去做别的事情时,就在消息后面跟一个访问 App 的链接,
|
|
1051
1051
|
* 点一下就能到我们的 App 界面 —— 这既是解释,也是把用户引回远程控制主功能的入口。
|
|
1052
1052
|
* ⚠️ 微信不渲染 Markdown,所以这里用「」和纯文本,不要写 `**`。
|
|
1053
1053
|
*/
|
|
@@ -1130,7 +1130,7 @@ export class WeChatRuntime {
|
|
|
1130
1130
|
// ⚠️ 这里以前直接透传上游 `handleCommand()` 的 replyText,而它拼的是**静态** HELP_TEXT ——
|
|
1131
1131
|
// 那份清单把 `/new`、`/ls`、`/use`、`/summary` 与"直接发一句话"一并列成"你能用的"。
|
|
1132
1132
|
// 免费用户敲错一个字,拿到的就是这张会员清单:照着做 → 再吃一次拒绝,
|
|
1133
|
-
// 于是"打错命令"被理解成"这功能坏了"
|
|
1133
|
+
// 于是"打错命令"被理解成"这功能坏了"。用户红线是「话术与权限必须一致、不多承诺」,
|
|
1134
1134
|
// 而 `#helpText()` 存在的唯一理由就是按档位如实分层(见它的注释)。
|
|
1135
1135
|
// 另外把用户敲的那条**原样回显** —— 他才知道是哪个字打错了。
|
|
1136
1136
|
if (cmd) {
|
|
@@ -1161,7 +1161,7 @@ export class WeChatRuntime {
|
|
|
1161
1161
|
*
|
|
1162
1162
|
* 为什么必须有它:`#rememberSession` 只在推送真的发出去了才会被调用 —— 一旦推送失败
|
|
1163
1163
|
* (微信侧 ret=-2)或者订阅漏了 turn/end,当前会话指针就**停在旧会话上**,用户回话会发进
|
|
1164
|
-
*
|
|
1164
|
+
* 那个旧会话(用户 2026-09-25 实测:「刚才我回了个话,而且回到了另外一个对话里面去了」)。
|
|
1165
1165
|
* 有了这个记录,回话前就能对一次表(见 `#retargetToLatestCompletion`)。
|
|
1166
1166
|
*/
|
|
1167
1167
|
#rememberCompletion(sessionId, title) {
|
|
@@ -1206,7 +1206,7 @@ export class WeChatRuntime {
|
|
|
1206
1206
|
/**
|
|
1207
1207
|
* 可切换会话列表(`/ls` 与 `/use` **共用同一份**)—— 过滤必须收口在这里。
|
|
1208
1208
|
*
|
|
1209
|
-
*
|
|
1209
|
+
* 为什么(用户反馈「/ls 列出一堆(未命名)会话」):`listSessions` 返回的会话里有大量**空白会话**
|
|
1210
1210
|
* (新建后一句话没说的坑),官方已经在数据里标了 `blank`(`dsh-events.mjs:1654` 解析出来却
|
|
1211
1211
|
* 一直没人用)。规则:只留 **有名字 / 正在跑 / 当前选中** 的会话;`blank === true` 一律剔除。
|
|
1212
1212
|
*
|
|
@@ -1238,7 +1238,7 @@ export class WeChatRuntime {
|
|
|
1238
1238
|
/**
|
|
1239
1239
|
* `/new [任务]` —— 建一个 DSH 会话,可选地立刻把任务下发进去。
|
|
1240
1240
|
* 默认**沿用最近项目**(cwd 取最近一个会话的 cwd);要换项目就显式给路径,
|
|
1241
|
-
* 避免把简单事做复杂(
|
|
1241
|
+
* 避免把简单事做复杂(用户拍板)。
|
|
1242
1242
|
*/
|
|
1243
1243
|
async #cmdNew(from, task) {
|
|
1244
1244
|
if (!this.subscriber || typeof this.subscriber.createSession !== "function") {
|
|
@@ -1440,14 +1440,14 @@ export class WeChatRuntime {
|
|
|
1440
1440
|
// ⚠️ 这里顺手修掉一个「从来没生效过」的入口:此前提醒的「邀请好友 / 知道了」
|
|
1441
1441
|
// 被当成提问派给 `subscriber.answerQuestion`,拿一个**不存在的 question id** 去答,
|
|
1442
1442
|
// 必然失败 → 用户看到的是「没能替你完成这个选择」。选项文案承诺了、行为却做不到,
|
|
1443
|
-
//
|
|
1443
|
+
// 正是用户红线里"话术与权限不一致"的那一类。
|
|
1444
1444
|
if (pending.notice) {
|
|
1445
1445
|
this.channel.registry.consume(pending.eventId);
|
|
1446
1446
|
this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
|
|
1447
1447
|
if (String(option.value) === "invite") {
|
|
1448
1448
|
const lines = ["邀请好友:在 App 里的「邀请」入口取你的专属链接,发给他即可。"];
|
|
1449
1449
|
if (this.appUrl) lines.push(this.appUrl);
|
|
1450
|
-
// 只说事实,不报具体奖励数字(那是 App
|
|
1450
|
+
// 只说事实,不报具体奖励数字(那是 App/后台的约定,这里报错了就成了假承诺)
|
|
1451
1451
|
lines.push("(只有邀请人得奖励,被邀请方没有奖励;奖励档位以 App 内展示为准。)");
|
|
1452
1452
|
await this.reply(from, lines.join("\n"));
|
|
1453
1453
|
} else {
|
|
@@ -1485,7 +1485,7 @@ export class WeChatRuntime {
|
|
|
1485
1485
|
}
|
|
1486
1486
|
await this.reply(from, `已按你的选择处理:${option.label || digits}。`);
|
|
1487
1487
|
// ★ 还有待拍板的 → 把下一条重发到底部,用户直接在最新那条上回数字即可。
|
|
1488
|
-
// (不重发的话用户得往上翻找哪条没答 —— 翻错就是"答错对象"
|
|
1488
|
+
// (不重发的话用户得往上翻找哪条没答 —— 翻错就是"答错对象",正是用户报的那个 bug 的成因。)
|
|
1489
1489
|
await this.#resurfaceNext(from);
|
|
1490
1490
|
}
|
|
1491
1491
|
|
|
@@ -1545,7 +1545,7 @@ export class WeChatRuntime {
|
|
|
1545
1545
|
|
|
1546
1546
|
// ── 待补发队列(主动推送失败不再静默丢失) ──────────────────────────────
|
|
1547
1547
|
//
|
|
1548
|
-
// 【2026-09-25
|
|
1548
|
+
// 【2026-09-25 故障】用户的任务跑完后**一条微信都没收到**,而日志里躺着 48 条
|
|
1549
1549
|
// `sendmessage 失败:sendMessage: ret=-2 errmsg=prepare failed` —— 平台侧发送失败是
|
|
1550
1550
|
// **间歇性**的(实测同一台机器上有的消息发得出去、有的发不出去),而旧实现失败即丢弃:
|
|
1551
1551
|
// 用户永远不知道那条"任务跑完了"存在过。
|
|
@@ -1742,7 +1742,7 @@ export class WeChatRuntime {
|
|
|
1742
1742
|
].includes(eventNode.kind);
|
|
1743
1743
|
if (!important) {
|
|
1744
1744
|
// ★ 0.6.15:静音也要**记账**。完成推送被静音时,这条"最近完成的任务"仍然必须被记下来 ——
|
|
1745
|
-
//
|
|
1745
|
+
// 否则用户下一秒回话就会发进上一次的会话(用户实测的那个"回到另一个对话")。
|
|
1746
1746
|
// 静音只表示"不打扰",不表示"当它没发生过"。
|
|
1747
1747
|
if (eventNode.kind === NODE_KINDS.TURN_END && eventNode.sessionId) {
|
|
1748
1748
|
this.#rememberCompletion(eventNode.sessionId, "");
|
|
@@ -1768,7 +1768,7 @@ export class WeChatRuntime {
|
|
|
1768
1768
|
}
|
|
1769
1769
|
|
|
1770
1770
|
// 日报要能回答"今天干了啥" → 在既有的 notified/answered 之外,再记完成与失败。
|
|
1771
|
-
// (owner
|
|
1771
|
+
// (owner 约定:日报不该是"今天要做啥"的清单,而是"今天做了什么"的回顾)
|
|
1772
1772
|
if (formatterKind === "stopped") {
|
|
1773
1773
|
this.#bumpToday(String(eventNode.reason || "") === "completed" ? "completed" : "failed");
|
|
1774
1774
|
}
|
|
@@ -1787,7 +1787,7 @@ export class WeChatRuntime {
|
|
|
1787
1787
|
const hit = list ? list.find((s) => s.sessionId === sid) : null;
|
|
1788
1788
|
if (hit && hit.title) title = hit.title;
|
|
1789
1789
|
}
|
|
1790
|
-
// ★ 0.6.15
|
|
1790
|
+
// ★ 0.6.15 修(用户实测):**完成推送要把"当前会话"切到刚跑完的那个**。
|
|
1791
1791
|
// 旧行为只在 sid 已经是当前会话时才更新标题,于是:B 会话跑完推了结论,用户直接在微信里
|
|
1792
1792
|
// 回一句"再改一下",那条消息却被 `#sendToSession` 发给了**上一次的当前会话 A** ——
|
|
1793
1793
|
// A 莫名其妙多了一条指令,B 永远收不到。用户看到的正是"推送过来了,我准备回话,
|
|
@@ -1796,7 +1796,7 @@ export class WeChatRuntime {
|
|
|
1796
1796
|
//
|
|
1797
1797
|
// ★ 0.6.15 补:**先无条件记下"最近完成的任务"**(与推送成败无关)。
|
|
1798
1798
|
// 推送失败(微信 ret=-2)或订阅漏了 turn/end 时,这一步是回话前对表的唯一依据,
|
|
1799
|
-
//
|
|
1799
|
+
// 否则当前会话指针会停在旧会话上,用户回话直接发进旧会话(用户实测的第二个症状)。
|
|
1800
1800
|
this.#rememberCompletion(sid, title);
|
|
1801
1801
|
if (sid && sid !== this.currentSessionId) {
|
|
1802
1802
|
this.#rememberSession(sid, title);
|
|
@@ -1825,7 +1825,7 @@ export class WeChatRuntime {
|
|
|
1825
1825
|
// 现在被降级只有两种原因,文案要分别说清是**哪一种**(否则用户不知道该防什么):
|
|
1826
1826
|
// · 有内容且命中破坏性模式 → 是这一步的**内容**危险;
|
|
1827
1827
|
// · 内容为空但降级了 → 是**这个工具本身**属于删除/覆盖类(按工具名判的)。
|
|
1828
|
-
// ⚠️ 「看不到内容」不再降级(
|
|
1828
|
+
// ⚠️ 「看不到内容」不再降级(用户拍板:DSH 自身有权限控制,不要过严),所以这里
|
|
1829
1829
|
// 不会出现"因为看不见所以不给点"的说法 —— 那样说会与实现不一致。
|
|
1830
1830
|
built = {
|
|
1831
1831
|
text: [
|
|
@@ -1848,7 +1848,7 @@ export class WeChatRuntime {
|
|
|
1848
1848
|
if (!built || !built.text) return { ok: false, reason: "no_text" };
|
|
1849
1849
|
|
|
1850
1850
|
// 已经还有别的待拍板 → 明确说"不止这一条"。不说的话用户会以为答完就结束了,
|
|
1851
|
-
// 后面几条就变成"没人回应"(
|
|
1851
|
+
// 后面几条就变成"没人回应"(用户报的那个 bug 的后半段现象)。
|
|
1852
1852
|
// ⚠️ 数字必须取**发送前**的快照:push 之后它会包含这一条自己。
|
|
1853
1853
|
// ⚠️ 只数**决策**:提醒类(额度/会员到期)不占拍板位,更不该在提醒里说"你还有 N 条待拍板"。
|
|
1854
1854
|
const isNotice = NOTICE_FORMATTER_KINDS.includes(formatterKind);
|
|
@@ -1911,7 +1911,7 @@ export class WeChatRuntime {
|
|
|
1911
1911
|
* 会员/试用临近到期的提醒(提前 2 天、提前 1 天、到期当天)。
|
|
1912
1912
|
*
|
|
1913
1913
|
* ⚠️ 这条**以前根本不存在**:`notifyMembershipExpiring()` 只有定义、全仓零调用方 ——
|
|
1914
|
-
*
|
|
1914
|
+
* 用户以为"提前两天发、提前一天也发",实际一次都没发过。这里补上唯一的触发点。
|
|
1915
1915
|
*
|
|
1916
1916
|
* 数据来源:`opts.accountInfo`(由 bridge 注入,复用它查档位时那次 `/api/me`,不额外打网络)。
|
|
1917
1917
|
* 拿不到就**什么都不发** —— 宁可漏发一次,也不要拿"猜的到期日"去骚扰用户。
|
|
@@ -1955,7 +1955,7 @@ export class WeChatRuntime {
|
|
|
1955
1955
|
* 与 `#maybeRemindExpiry()` 同一套模式(落盘去重 + 只有真发出去了才记账),
|
|
1956
1956
|
* 调用点也照它那样 fire-and-forget(见 `#cmdNew` / `#sendToSession`)。
|
|
1957
1957
|
*
|
|
1958
|
-
*
|
|
1958
|
+
* 触发约定(两个条件取**较宽松**的那个,谁先到用谁 —— 别让用户到 0 才收到):
|
|
1959
1959
|
* · 已用 ≥ 80% ;或 · 剩余 ≤ 2 条
|
|
1960
1960
|
* 频控:**每个自然月最多一次**(`#monthKey()` 落盘去重)。每发一条就念一遍额度是骚扰,
|
|
1961
1961
|
* 而且微信里挡住消息的正是额度本身 —— 提醒的价值在"提前预告",不是"复读"。
|
|
@@ -2022,12 +2022,12 @@ export class WeChatRuntime {
|
|
|
2022
2022
|
*
|
|
2023
2023
|
* ⚠️ 旧实现有三个问题,一起修了:
|
|
2024
2024
|
* ① 只把 `{notified, answered}` 交给下游,**没有 lines** → 用户每天收到的是兜底句
|
|
2025
|
-
* 「今天暂时没有要做的事。」—— 那不是"今天干了啥",等于白发(
|
|
2025
|
+
* 「今天暂时没有要做的事。」—— 那不是"今天干了啥",等于白发(用户:"日报发的内容有点问题")。
|
|
2026
2026
|
* ② 先把 `last_digest_day` 落盘**再**发送 → 一旦这次没发出去(网络抖动/节点被权限关掉),
|
|
2027
2027
|
* 当天的简报就被**烧掉**、当天再也不会补发。
|
|
2028
2028
|
* ③ 「读 state → 发送 → 写 state」是一段**跨 await 的读-改-写**:发送要等网络,
|
|
2029
2029
|
* 这段窗口里第二个执行者会读到"今天还没发"→ **两条都发出去**
|
|
2030
|
-
* (2026-09-23
|
|
2030
|
+
* (2026-09-23 真机:用户收到两条简报)。现在用**原子占位**把"今天这条归我发"锁死。
|
|
2031
2031
|
*/
|
|
2032
2032
|
async #maybeDigest() {
|
|
2033
2033
|
if (this.stopping || !this.channel.account || !this.subscriber) return;
|
|
@@ -2109,7 +2109,7 @@ export class WeChatRuntime {
|
|
|
2109
2109
|
} catch { /* 目录读不了就算了:占位本身仍生效 */ }
|
|
2110
2110
|
}
|
|
2111
2111
|
|
|
2112
|
-
/** 简报正文:回答"**今天**干了啥"(
|
|
2112
|
+
/** 简报正文:回答"**今天**干了啥"(用户约定:日报是回顾,不是待办清单)。 */
|
|
2113
2113
|
#digestLines() {
|
|
2114
2114
|
const s = this.todayStats;
|
|
2115
2115
|
const lines = [];
|
|
@@ -2178,7 +2178,7 @@ export class WeChatRuntime {
|
|
|
2178
2178
|
async beginBind() {
|
|
2179
2179
|
if (this.disabled) return { ok: false, error: "微信通道已禁用" };
|
|
2180
2180
|
if (this.channel.account) return { ok: false, error: "已经绑定过了;如需更换请先解绑" };
|
|
2181
|
-
// ★ 权限门槛(
|
|
2181
|
+
// ★ 权限门槛(用户拍板:必须注册登录后才能用微信机器人)。
|
|
2182
2182
|
// 判据是**本机配置里有账号**(登录后才会写入)。放在最前面:没登录就连二维码都不给,
|
|
2183
2183
|
// 而不是"给二维码但绑上用不了"——后者会让用户白扫一次,体验更差。
|
|
2184
2184
|
// bridge 侧这道闸与面板侧"未登录不显示 tab"是双保险(bridge 可能被别的客户端调)。
|
package/docs/telemetry.md
CHANGED
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
`wechat_bound` / `wechat_unbound`(微信机器人通道的**绑定态跳变**,语义见下)·
|
|
61
61
|
`mirror_load`(手机镜像页**就绪耗时**;附 `ms` 耗时毫秒、`at` 客户端事件时刻、`outcome` = `ok` / `slow`)
|
|
62
62
|
|
|
63
|
-
> `mirror_load` 是唯一带**数值**的事件:`ms`
|
|
63
|
+
> `mirror_load` 是唯一带**数值**的事件:`ms` 由客户端量测(约定见下),服务端把它落在
|
|
64
64
|
> `telemetry_events.value_ms`,`at` 落在 `at_ms`(`created_at` 仍是服务端收包时刻,语义不变)。
|
|
65
65
|
> 量测起点是导航开始(`performance.timeOrigin` 对齐)、终点是页面真正可用;`outcome` 为 `slow`
|
|
66
66
|
> 表示触发了看门狗兜底。**不采集**任何页面内容、URL、设备信息。
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
| `wechat_bound` | 未绑定 → 已绑定(用户扫码完成了绑定) |
|
|
77
77
|
| `wechat_unbound` | 已绑定 → 未绑定(用户在面板里解绑) |
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
约定边界(这三条决定了这些数字**能**读成什么、**不能**读成什么):
|
|
80
80
|
|
|
81
81
|
- **只在跳变时发**:连续多次读到同一个 `bound` 值**一条都不发**。面板在扫码/绑定期间会反复轮询状态,
|
|
82
82
|
若按"读到已绑定就记一条",同一台机器的一次绑定会被刷成几十条。
|
|
@@ -185,7 +185,7 @@ export DSH_REMOTE_TELEMETRY=0
|
|
|
185
185
|
|
|
186
186
|
- 按 `install_id` + `name` 做**匿名漏斗**统计(装机成功率、失败原因分布、注册→可用转化);
|
|
187
187
|
- 不与会话/账号/设备表做关联分析;不长期保存原始 `at` 精度以外的东西;
|
|
188
|
-
- 未知事件名、未知 `fail_code`
|
|
188
|
+
- 未知事件名、未知 `fail_code` 一律丢弃(避免脏数据污染约定)。
|
|
189
189
|
|
|
190
190
|
> 本项目的许可(PolyForm Noncommercial)与隐私承诺都建立在「如实披露」之上:
|
|
191
191
|
> 如果这条通道将来要增加任何字段或事件,必须先改本文与 README,再改代码。
|
package/dsh-setup.mjs
CHANGED
|
@@ -254,7 +254,7 @@ function resolveExecutable(p) {
|
|
|
254
254
|
* 桌面端 `process.execPath` 是 **Electron 应用二进制**而不是 node:不带
|
|
255
255
|
* `ELECTRON_RUN_AS_NODE=1` 直接拿它跑脚本,得到的不是「跑脚本的 node」,而是
|
|
256
256
|
* **又启动一个桌面端应用实例**。自启动服务是系统在应用退出后拉起的,踩到这里
|
|
257
|
-
* 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15
|
|
257
|
+
* 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口故障
|
|
258
258
|
* 一样难排查 —— 所以必须在**写服务定义时**就把变量带上。
|
|
259
259
|
*/
|
|
260
260
|
const IS_ELECTRON_HOST = Boolean(process.versions && process.versions.electron);
|
|
@@ -359,7 +359,7 @@ function nodeEnv(extra) {
|
|
|
359
359
|
*/
|
|
360
360
|
const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
361
361
|
|
|
362
|
-
// ----------
|
|
362
|
+
// ---------- 安装约定(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
|
|
363
363
|
/**
|
|
364
364
|
* install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
|
|
365
365
|
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
@@ -526,7 +526,7 @@ function readPidFile(name) {
|
|
|
526
526
|
*
|
|
527
527
|
* 为什么必须查:pid 文件可能过期,而 pid 会被系统**回收给无关进程**。
|
|
528
528
|
* 去重逻辑信任 `pidAlive()` 只会导致"我多退一次"(安全方向);但监管者接管时要
|
|
529
|
-
* **主动结束**那个 pid ——
|
|
529
|
+
* **主动结束**那个 pid —— 盲杀一个被回收的无关进程是故障。所以动它之前先认一下命令行。
|
|
530
530
|
*/
|
|
531
531
|
function looksLikeWatcher(pid) {
|
|
532
532
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -838,7 +838,7 @@ function spawnDetachedBridge() {
|
|
|
838
838
|
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
839
839
|
*/
|
|
840
840
|
/**
|
|
841
|
-
* 🔴 标签占用保护(2026-10-10
|
|
841
|
+
* 🔴 标签占用保护(2026-10-10 故障):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
|
|
842
842
|
* 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
|
|
843
843
|
* plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
|
|
844
844
|
*
|
|
@@ -984,7 +984,7 @@ function restartBridgeService() {
|
|
|
984
984
|
/**
|
|
985
985
|
* 上游 dsh web 地址(bridge 的转发目标 + watcher 的「dsh web 在线吗」判据**同一个来源**)。
|
|
986
986
|
*
|
|
987
|
-
* 【2026-09-23
|
|
987
|
+
* 【2026-09-23 用户实测故障】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
|
|
988
988
|
* 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
|
|
989
989
|
* watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
|
|
990
990
|
* 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
|
|
@@ -1006,9 +1006,9 @@ let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().repla
|
|
|
1006
1006
|
/**
|
|
1007
1007
|
* 探测桌面端(Electron 应用)当前监听的**回环端口**。
|
|
1008
1008
|
*
|
|
1009
|
-
* 为什么需要(2026-10-11
|
|
1009
|
+
* 为什么需要(2026-10-11 实测故障):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
|
|
1010
1010
|
* 把自己 `ctx.webServer.port` 写进 `<relayDir>/.dsh-upstream` —— 谁最后写谁生效。于是桥的上游
|
|
1011
|
-
* 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080
|
|
1011
|
+
* 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而用户面对的是桌面端 19387,
|
|
1012
1012
|
* 表现就是"命令跑完了但没装上 / 手机连上看到的不是我在用的界面"。
|
|
1013
1013
|
* 装桌面端时把端口**钉进自启动配置**(环境变量优先级最高),两个宿主就抢不动了。
|
|
1014
1014
|
*/
|
|
@@ -1105,7 +1105,7 @@ async function isDshWebUp(timeoutMs = 1200) {
|
|
|
1105
1105
|
|
|
1106
1106
|
/** 已知的 DSH profile 名(`--profile web` / `--profile desktop` 这种写法要能用)。 */
|
|
1107
1107
|
const KNOWN_PROFILE_NAMES = ["web", "desktop"];
|
|
1108
|
-
/** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>`
|
|
1108
|
+
/** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 约定一致)。 */
|
|
1109
1109
|
function profileDirOfName(name) {
|
|
1110
1110
|
return path.join(os.homedir(), ".dsh", "profiles", name);
|
|
1111
1111
|
}
|
|
@@ -1115,13 +1115,13 @@ function isDesktopProfileDir(dir) {
|
|
|
1115
1115
|
}
|
|
1116
1116
|
|
|
1117
1117
|
/**
|
|
1118
|
-
* 解析目标 profile
|
|
1118
|
+
* 解析目标 profile 目录(安装与插件管理共用同一约定)。
|
|
1119
1119
|
*
|
|
1120
1120
|
* 三种写法都支持:
|
|
1121
1121
|
* · `--profile web` / `--profile desktop` → `~/.dsh/profiles/<name>`
|
|
1122
1122
|
* (新增:DeepSeek Harness 桌面端的 profile 就叫 `desktop`)
|
|
1123
1123
|
* · `--profile <任意路径>` → 原样当目录用(老行为,保持不变)
|
|
1124
|
-
* · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile`
|
|
1124
|
+
* · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同约定
|
|
1125
1125
|
* 缺省仍是 Web 版 profile,行为与改动前完全一致。
|
|
1126
1126
|
*/
|
|
1127
1127
|
function resolveProfileDir(argv = []) {
|
|
@@ -1142,8 +1142,8 @@ function resolveProfileDir(argv = []) {
|
|
|
1142
1142
|
/**
|
|
1143
1143
|
* DeepSeek Harness **桌面应用**是否正在运行。
|
|
1144
1144
|
*
|
|
1145
|
-
* 🔴 2026-10-11
|
|
1146
|
-
*
|
|
1145
|
+
* 🔴 2026-10-11 故障:我在应用运行中改写了 `~/.dsh/profiles/desktop/` 里的插件文件,
|
|
1146
|
+
* 应用随即**自己退出/重启**(用户:"怎么又触发了 dsh 的关闭")。安装器自己的提示早就写着
|
|
1147
1147
|
* 「请先**完全退出** DeepSeek Harness 应用再安装(应用独占该 profile)」—— 这条必须由程序强制,
|
|
1148
1148
|
* 不能靠"我记得"。
|
|
1149
1149
|
* 检测方式:macOS 上桌面应用的进程名就是 `DeepSeek`(lsof/pgrep 实测),另加 App 包路径兜底。
|
|
@@ -1165,7 +1165,7 @@ function dshDesktopAppRunning() {
|
|
|
1165
1165
|
|
|
1166
1166
|
/**
|
|
1167
1167
|
* 探测本机**已存在**的 profile(可装载 = 同时有 package.json 与 cordis.patch.yml)。
|
|
1168
|
-
* desktop
|
|
1168
|
+
* desktop 排在前面:用户主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
|
|
1169
1169
|
*/
|
|
1170
1170
|
function detectProfileDirs() {
|
|
1171
1171
|
const root = path.join(os.homedir(), ".dsh", "profiles");
|
|
@@ -1183,7 +1183,7 @@ function detectProfileDirs() {
|
|
|
1183
1183
|
/**
|
|
1184
1184
|
* 本次要操作的 profile 列表。
|
|
1185
1185
|
*
|
|
1186
|
-
*
|
|
1186
|
+
* 用户约定(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
|
|
1187
1187
|
* 以前不带 `--profile` 时**写死装 web** —— 桌面端用户执行官方那条 `npx @mrrisega/dsh-remote@beta`
|
|
1188
1188
|
* 之后,插件全进了 Web 版 profile,而用户面对的是桌面应用 → 面板永远不出现,且极难自查。
|
|
1189
1189
|
*
|
|
@@ -1199,7 +1199,7 @@ function targetProfileDirs(argv = []) {
|
|
|
1199
1199
|
if (!all.length) return [{ name: "web", dir: profileDirOfName("web") }];
|
|
1200
1200
|
/* 默认装哪些?**不是"全部"** —— 机器上常有过实验/自动化用的 profile(headless、web02…),
|
|
1201
1201
|
往那些里面塞插件是越界。规则:
|
|
1202
|
-
· 已知的面向用户的 profile(web / desktop
|
|
1202
|
+
· 已知的面向用户的 profile(web / desktop):**有就装**(这是用户的核心诉求:默认也要给 desktop)
|
|
1203
1203
|
· 其他 profile:**只有本来就已经装了插件**才继续管(升级不能悄悄漏掉它)
|
|
1204
1204
|
· 上面并集为空(例如只有 headless 一种)→ 退回"全部已存在",避免什么都不装 */
|
|
1205
1205
|
const chosen = all.filter((p) => KNOWN_PROFILE_NAMES.includes(p.name)
|
|
@@ -1453,7 +1453,7 @@ function loginRateLimitUntil() {
|
|
|
1453
1453
|
|
|
1454
1454
|
/* ── 守护启动锁:把"一堆守护同时被拉起"的惊群收敛成串行 ─────────────────────────────
|
|
1455
1455
|
*
|
|
1456
|
-
* 🔴 2026-09-26
|
|
1456
|
+
* 🔴 2026-09-26 真机故障(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
|
|
1457
1457
|
* **6 个 `dsh-setup.mjs run` 守护 + 6 个 bridge**,全部用同一个 deviceId 去 router 注册,
|
|
1458
1458
|
* 互相把对方顶掉 —— bridge 日志刷满
|
|
1459
1459
|
* `隧道断开(code=4000, replaced by new connection)` → `2s 后重连` → 再被顶掉,
|
|
@@ -1639,7 +1639,7 @@ async function runBridge() {
|
|
|
1639
1639
|
|
|
1640
1640
|
// 账号/模式/设备身份变了 → 必须重启 bridge(它的凭据是启动时从环境变量固化的)。
|
|
1641
1641
|
// 没有这一条时:面板切了账号、旧 bridge 却继续用旧账号跑,新账号的设备列表里看不到这台机器
|
|
1642
|
-
//(2026-09-19
|
|
1642
|
+
//(2026-09-19 实测故障)。
|
|
1643
1643
|
const fp = accountFingerprint(cfg);
|
|
1644
1644
|
if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
|
|
1645
1645
|
console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
|
|
@@ -1670,7 +1670,7 @@ async function runBridge() {
|
|
|
1670
1670
|
DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
|
|
1671
1671
|
// 上游端口显式传给 bridge:它自己在旧版本里也回退到写死的 3080(见 dsh-bridge.mjs 的 UPSTREAM)。
|
|
1672
1672
|
DSH_BRIDGE_UPSTREAM: upstreamUrl(),
|
|
1673
|
-
//
|
|
1673
|
+
// 安装约定透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
1674
1674
|
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
1675
1675
|
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
1676
1676
|
// 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
|
|
@@ -1903,7 +1903,7 @@ async function setup(argv) {
|
|
|
1903
1903
|
}
|
|
1904
1904
|
|
|
1905
1905
|
const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
|
|
1906
|
-
// 服务状态字符串是给插件面板解析的稳定契约(running / 未运行)
|
|
1906
|
+
// 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改约定。
|
|
1907
1907
|
// 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
|
|
1908
1908
|
// 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
|
|
1909
1909
|
const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
|
|
@@ -1912,7 +1912,7 @@ async function setup(argv) {
|
|
|
1912
1912
|
// Windows 没有 launchd,这条判断对它不成立(否则会把 win32 误报成"本机不支持崩溃自愈")。
|
|
1913
1913
|
const svcFragile = !IS_WIN && !svcSkipped && st.ok && st.selfHealing === false;
|
|
1914
1914
|
const svcDegraded = st.status === "degraded-detached";
|
|
1915
|
-
// Windows:自启动 =
|
|
1915
|
+
// Windows:自启动 = 任务计划程序里的登录任务。约定与 macOS/Linux 不同
|
|
1916
1916
|
// ("任务已注册" ≠ "进程此刻在跑"),所以单独给文案,避免复用"未被系统托管"这种会误导的措辞。
|
|
1917
1917
|
const winTask = IS_WIN && winTaskOk();
|
|
1918
1918
|
const svcState = IS_WIN
|
|
@@ -2110,7 +2110,7 @@ function compareVersionStrings(a, b) {
|
|
|
2110
2110
|
}
|
|
2111
2111
|
|
|
2112
2112
|
/**
|
|
2113
|
-
* 解析 patch 文本里的 insert 行 id(与 dshmarket
|
|
2113
|
+
* 解析 patch 文本里的 insert 行 id(与 dshmarket 同约定的极简解析,不引 YAML 依赖)。
|
|
2114
2114
|
* 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
|
|
2115
2115
|
* dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
|
|
2116
2116
|
* @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
|
|
@@ -2219,7 +2219,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
|
|
|
2219
2219
|
};
|
|
2220
2220
|
// ★★ 稳定形态 = **只由 `dsh.profile.bundles` 激活**,我们**不再往 patch 里写激活行**。
|
|
2221
2221
|
//
|
|
2222
|
-
// 2026-09-22
|
|
2222
|
+
// 2026-09-22 实测故障:`duplicate loader entry id: dsh-remote-web` →
|
|
2223
2223
|
// `dsh: plugin tree failed to load` → **dsh web 启动即挂**(用真实启动复现)。
|
|
2224
2224
|
// 根因是**两处激活同时存在**:
|
|
2225
2225
|
// (a) 插件自带的 bundle 在 `package.json` 的 `dsh.profile.bundles` 里;
|
|
@@ -2322,7 +2322,7 @@ function normalizePatchBase(patch) {
|
|
|
2322
2322
|
/**
|
|
2323
2323
|
* 插件目录里**必须存在且非空**的文件。缺任何一个都说明这次拷贝是"半个插件"。
|
|
2324
2324
|
*
|
|
2325
|
-
* 🔴 为什么必须有这一步(2026-09-26 23:46
|
|
2325
|
+
* 🔴 为什么必须有这一步(2026-09-26 23:46 真实故障):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
|
|
2326
2326
|
* 而 dsh 恰好在这个空窗里启动 → `package.json` 已经声明 bundle,但 `cordis.patch.yml` 还没拷到
|
|
2327
2327
|
* → dsh 读 overlay 直接 `ENOENT: no such file or directory` 退出,**整个 GUI 起不来**:
|
|
2328
2328
|
*
|
|
@@ -2353,7 +2353,7 @@ function missingPluginFiles(dir) {
|
|
|
2353
2353
|
* 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
|
|
2354
2354
|
* include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
|
|
2355
2355
|
*
|
|
2356
|
-
* 🔴 **原子覆盖(2026-09-26
|
|
2356
|
+
* 🔴 **原子覆盖(2026-09-26 故障后的硬要求)**:
|
|
2357
2357
|
* 1. 先整份拷到同级的 `.staging-<pid>`,**全程不碰 dest** —— dsh 任何时刻读到的
|
|
2358
2358
|
* 要么是完整的旧版、要么是完整的新版;
|
|
2359
2359
|
* 2. 拷完**校验**必需文件(见 `PLUGIN_REQUIRED_FILES`),不通过就丢弃 staging、抛错、
|
|
@@ -2406,7 +2406,7 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
2406
2406
|
throw new Error(`插件拷贝失败(已保留原目录不动):${e && e.message ? e.message : e}`);
|
|
2407
2407
|
}
|
|
2408
2408
|
|
|
2409
|
-
// 2) 自检:必需文件一个都不能少(这一步就是 23:46
|
|
2409
|
+
// 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次故障的闸门)
|
|
2410
2410
|
const missing = missingPluginFiles(stage);
|
|
2411
2411
|
if (missing.length > 0) {
|
|
2412
2412
|
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
@@ -2639,7 +2639,7 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
|
|
|
2639
2639
|
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
2640
2640
|
process.exit(1);
|
|
2641
2641
|
}
|
|
2642
|
-
// ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows
|
|
2642
|
+
// ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测故障):
|
|
2643
2643
|
// 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
|
|
2644
2644
|
// 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
|
|
2645
2645
|
// 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
|
|
@@ -2814,7 +2814,7 @@ async function pluginCmd(argv) {
|
|
|
2814
2814
|
let skippedRunning = 0;
|
|
2815
2815
|
const force = hasFlag(argv, "--force");
|
|
2816
2816
|
for (const t of targets) {
|
|
2817
|
-
// ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11
|
|
2817
|
+
// ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
|
|
2818
2818
|
if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
|
|
2819
2819
|
console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
|
|
2820
2820
|
console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1-beta.1",
|
|
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",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"test": "npm run test:router && npm run test:plugin && npm run test:bridge",
|
|
54
|
-
"test:router": "DSH_PROFILE_DIR= NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
|
|
55
|
-
"_comment:test:plugin": "DSH_PROFILE_DIR= = 测试隔离铁律:本套件跑在 DSH 宿主里时环境**天然带着** DSH_PROFILE_DIR(指向真实 ~/.dsh/profiles/desktop),而多数用例只隔离了 HOME —— 于是它们会去写**真实** profile 的 cordis.patch.yml(2026-10-10
|
|
54
|
+
"test:router": "DSH_PROFILE_DIR= NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs packages/relay-router/test/local-session.test.mjs",
|
|
55
|
+
"_comment:test:plugin": "DSH_PROFILE_DIR= = 测试隔离铁律:本套件跑在 DSH 宿主里时环境**天然带着** DSH_PROFILE_DIR(指向真实 ~/.dsh/profiles/desktop),而多数用例只隔离了 HOME —— 于是它们会去写**真实** profile 的 cordis.patch.yml(2026-10-10 故障:测试 relayDir 被写进真 profile,面板连到假配置、验证码/公告全 502)。这里显式清空,让 profile 解析回落到各用例自己的假 HOME。 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
|
|
56
56
|
"test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
57
57
|
"_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
|
|
58
58
|
"test:bridge": "DSH_PROFILE_DIR= DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
|