dsh-helper 0.16.6 → 0.16.8

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/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@
8
8
  > (`package-lock.json` 根节点跟齐)与本文件顶部段落,两者连同提交一起走;发布只补 annotated tag,
9
9
  > 不再用 `npm version` 升版。
10
10
 
11
+ ## [0.16.8] — 未发布
12
+
13
+ **桌面端的重启按钮不再骗人** —— 原先在 DSH Desktop 里它显示为可点,一点就报「重启失败:untrusted」。
14
+
15
+ ### 修复
16
+
17
+ - **根因**:桌面窗口跑在 `dsh-app://app`,请求由 Electron 主进程的 `forwardWebRequest` 转发给宿主,
18
+ 而它转发前会删掉 `origin` / `host` / `cookie` / `sec-fetch-site`。重启闸 `trustedRestartRequest`
19
+ 要求 `Origin` 存在且与 `Host` 同源,于是桌面端恒为 false;而状态接口走的是松一档的
20
+ `trustedLocalRequest`(放行无 Origin),按钮因此显示成"可点" —— 两者不一致,成了
21
+ 「看起来能用、一点就 403」。
22
+ - 新增 `detectedDesktopHost()`(判据 `DSH_CLIENT_VERSION`,Electron 主进程只发给桌面端宿主;
23
+ 刻意不吃 `ELECTRON_RUN_AS_NODE` —— 那是「Electron 当 Node 用」的通用标记,会误伤别的 Electron 应用):
24
+ 桌面端 `/restart/status` 返回 `allowed: false, reason: 'desktop'`,按钮置灰并提示
25
+ 「桌面端宿主由 DeepSeek Harness App 管理:退出 App 后重新打开即可」;`POST /restart` 补上同一档 403,
26
+ 且**排在来源闸之前**,免得用户继续看到没头没尾的 untrusted。
27
+ - 与上游 dshmarket 口径一致:它在桌面端把重启标成 `supported: false` / `managedBy: 'desktop-host'`。
28
+ 桌面端也确实不该自己重启 —— 宿主一退出,Electron 主进程立刻 `fail("dsh desktop host stopped")`
29
+ 进 fatal 恢复;插件另起的替代进程还拿不到主进程持有的 hostCookie(窗口会 401)。
30
+ - 冒烟:新增 4 条桌面端断言(含「只认 `DSH_CLIENT_VERSION`」与「非桌面端的无 Origin 请求仍被来源闸拒掉」
31
+ 两条边界,后者保证这一档没把来源闸整体顶掉),host-smoke 114 → 118 项;`docs/DEVELOPMENT.md`
32
+ 自检表的项数同步。
33
+
34
+ ### 备注
35
+
36
+ - **桌面端其余功能不受影响**:`/config`、`/send-file`、`/restart/status` 走的都是放行无 Origin 的那道闸,
37
+ 一直是正常的;桌面端只有「重启」这一项走不通。
38
+ - 桌面端要重启宿主,用 App 自己的方式:退出 App 后重新打开(开发模式下 App 菜单里另有
39
+ 「重启应用与 Host」,正式版没有这一项)。
40
+
41
+ ## [0.16.7] — 未发布
42
+
43
+ **README 精简** —— 357 行 → 55 行。
44
+
45
+ ### 变更
46
+
47
+ - 删掉:HTTP 投递端点的 46 行接口文档、时区与夏令时的 31 行说明、设置与配置的 37 行字段表、
48
+ 各功能的实现细节(复用了哪个后端、怎么编排)、常见问题 / 兼容性 / 已知限制三节、
49
+ 以及开发与更新日志。
50
+ - 保留并压缩:一句话介绍、安装(含「先装一个 IM 插件」这个前提)、把文件发到微信、定时任务、
51
+ 界面上的小东西(表格)、设置与卸载、许可。
52
+ - 细节去哪儿找:**HTTP 端点与全部配置项在插件信息页里**;实现细节与踩坑记录在
53
+ `docs/DEVELOPMENT.md`;版本变更在 `CHANGELOG.md`。
54
+
11
55
  ## [0.16.6] — 未发布
12
56
 
13
57
  **新增 `npm run verify:ci`** —— 在本地按 CI 的方式跑一遍,把那两处"本机比 CI 富"的地方补平。
package/README.md CHANGED
@@ -3,43 +3,11 @@
3
3
  [![npm](https://img.shields.io/npm/v/dsh-helper.svg)](https://www.npmjs.com/package/dsh-helper)
4
4
  [![license](https://img.shields.io/npm/l/dsh-helper.svg)](./LICENSE)
5
5
 
6
- 给 DeepSeek Harness 补上几件它自己没做的事:
7
-
8
- | 能力 | 一句话 |
9
- | --- | --- |
10
- | **把文件发到微信** | 在**任意** DSH 会话(含网页/CLI 对话框发起的)把本机文件主动投递到 IM;图片/视频自动走图片/视频消息 |
11
- | **定时任务** | 按 cron 把一段提示词交给一个**全新**的 agent 会话执行,带执行记录与关联会话 |
12
- | **活动呼吸灯** | 工作区图标随会话状态呼吸,折叠时用橙色标记未读 |
13
- | **活跃指示** | 会话标题栏一枚心电图:有活动或有待看就亮,点开列出需要注意的会话并直接接上 |
14
- | **对话完成提示音** | 会话跑完出一声(可只在切走时响),音色与音量可调 |
15
- | **关闭侧栏毛玻璃** | macOS 桌面端左侧栏那层系统原生毛玻璃,一个开关盖掉 |
16
- | **一键重启** | 设置页头部一键重启宿主(四道安全闸) |
17
-
18
- **它不修改 IM 插件一行源码**,只读复用它的实现与账号配置。装了哪个就走哪个后端:
19
-
20
- | 后端 | 复用什么 | 何时使用 |
21
- | --- | --- | --- |
22
- | `@michengai/dsh-im-connect` | 它的微信通道工厂(`createWeixinChannel` → `sendFile`),由本插件造一个「只发不收」的实例 | 装了就用它(默认) |
23
- | `@xmanrui/dsh-im` | 它的微信协议模块(`weixin-api.mjs` + `state-store` / `config-store`),由本插件串起「读文件 → 上传 → 发送」 | 没装 im-connect 时退回 |
24
-
25
- ---
26
-
27
- ### 解决什么问题
28
-
29
- IM 插件自己的文件投递只发生在**它接管的那个 IM 会话里**:回合结束时,把它认得的产出(`write`/`edit` 成功的文件、`present` 明确交付的文件)回传到聊天窗口。所以「在 DSH 桌面/网页对话框里说『把这个文件发我微信』」这条路它覆盖不到——文件交付不出 IM 会话。
30
-
31
- 本插件补的就是这一段:在任何会话(含网页 / CLI / 定时任务新开的会话)主动把本机文件投递到 IM。做法是复用 IM 插件**已经写好的实现**、由本插件完成编排,而不是复刻协议:登录态、CDN 加密上传、`context_token` 发送、图片/视频/文件分流都归上游。为什么不 patch 上游、也不复刻协议,见[开发文档](./docs/DEVELOPMENT.md#im-只读复用)。
6
+ 给 DeepSeek Harness 补几件日常小事:**把文件发到微信**、**定时任务**、活动呼吸灯、活跃指示、完成提示音、一键重启。
32
7
 
33
8
  ### 安装
34
9
 
35
- **前提:先装好一个 IM 插件并接入微信。** 本插件复用它的通道与账号配置,没有它投递无法工作(其余能力不受影响):
36
-
37
- - **`@michengai/dsh-im-connect`(推荐)** —— 多平台 IM 助理;装好后在它自己的设置里扫码接入微信;
38
- - **`@xmanrui/dsh-im`(旧)** —— 在「设置 → IM机器人」里接入微信。
39
-
40
- 两个都装时默认走 im-connect(要改见下面的 `backend`)。
41
-
42
- **DSH Web / CLI**:
10
+ 先装一个 IM 插件(二选一),并在它自己的设置里接好微信 —— 微信投递复用它的通道与账号,没有它投递用不了(其余能力不受影响):
43
11
 
44
12
  ```bash
45
13
  # 1. 装一个 IM 插件(二选一),并在它自己的设置里接好微信
@@ -49,309 +17,39 @@ dsh plugin --profile web add @michengai/dsh-im-connect # 或 @xmanrui/dsh-im
49
17
  dsh plugin --profile web add dsh-helper
50
18
  ```
51
19
 
52
- **DSH Desktop**:在**插件市场**里依次搜 `@michengai/dsh-im-connect`(或 `@xmanrui/dsh-im`)与
53
- `dsh-helper` 安装(桌面端的 profile 由 Electron 应用独占,`dsh plugin --profile desktop`
54
- 会被 CLI 拒绝 —— 任何插件都一样)。
20
+ **DSH Desktop**:在**插件市场**里依次搜上面两个包安装。
55
21
 
56
- 装完**重启 DSH**。你会得到:插件信息页的设置卡片、Agent 工具 `send_file_to_im`、
57
- 侧栏「定时任务」入口、任何会话可用的 HTTP 投递端点。
22
+ 装完重启 DSH 生效。
58
23
 
59
24
  ### 把文件发到微信
60
25
 
61
- #### 入口一:工具 `send_file_to_im`
62
-
63
- 给 Agent 用,**只对「建立时已加载本插件」的会话可见**(已在跑的会话请用 HTTP 入口)。
26
+ 在**任意会话**里说一句「把这个文件发我微信」,模型会调 `send_file_to_im`:
64
27
 
65
- | 参数 | 必填 | 说明 |
66
- | --- | --- | --- |
67
- | `path` | 是 | 文件绝对路径,或相对当前工作区的路径 |
68
- | `toUserId` | 否 | 临时指定收件人 |
69
-
70
- #### 入口二:HTTP 端点
71
-
72
- 给任何会话、任何脚本用,不依赖工具表:
73
-
74
- ```bash
75
- curl -s -X POST http://127.0.0.1:19387/api/dsh-helper/send-file \
76
- -H 'content-type: application/json' \
77
- -d '{"path":"/绝对/路径/文件.ext"}'
78
- ```
79
-
80
- > 端口以宿主实际监听的为准(DSH Desktop / `dsh web` 当前是 **19387**);此前这里写的是 3080,那是旧版本的默认端口,照抄会连不上。
81
-
82
- 成功返回:
83
-
84
- ```json
85
- {"sent":true,"fileName":"...","bytes":2378,"toUserId":"...","botId":"...","hadContextToken":true,"via":"image"}
86
- ```
87
-
88
- `sent: true` 只代表**平台已受理**,不代表对方已读;`via` 是 `"image"`(图片消息)/ `"video"`(视频消息)/ `"file"`(文件);`hadContextToken: false` 见[常见问题](#常见问题)。
89
-
90
- **来源限制**:该路由能读本机任意文件并发走微信,因此浏览器请求必须与 DSH 同源,跨站 POST 会被 **403**;curl 与本机脚本无 Origin,照常可用。文件超 **200 MiB** 在读取前直接拒绝,投递超过 **5 分钟**未完成会以 **504** 取消。
91
-
92
- **残留的暴露面(照实说)**:拦得住跨站,拦不住**同机的其它进程** —— 它们发的请求同样没有 Origin,与你的 curl 无法区分,因此默认也能调这个路由把文件发走。单用户的个人电脑上这通常可以接受;多用户机器、或本机跑着不信任的进程时,请打开下面两道闸门。
93
-
94
- **两道可选闸门**(设置页 →「文件投递」卡片,**都默认关闭**:不开就与从前逐字节同行为,既有脚本一个字都不用改):
95
-
96
- | 闸门 | 关掉时 | 打开后 |
97
- | --- | --- | --- |
98
- | HTTP 投递口令 | 任何本机进程都能调 `/send-file` | 每个请求都必须带口令(请求头 `x-dsh-helper-token`,或 body 的 `token` 字段),否则 **401** |
99
- | 允许投递的目录 | 任何本机路径都能发(包括 `~/.ssh` 这类) | 只有白名单目录**及其子目录**里的文件能发出去,其余一律拒绝 |
100
-
101
- 带了口令的 curl:
102
-
103
- ```bash
104
- curl -s -X POST http://127.0.0.1:19387/api/dsh-helper/send-file \
105
- -H 'content-type: application/json' \
106
- -H 'x-dsh-helper-token: 你的口令' \
107
- -d '{"path":"/绝对/路径/文件.ext"}'
108
- ```
109
-
110
- 几点说明:
111
-
112
- - 口令**从不回显**:`GET /api/dsh-helper/config` 只返回 `sendTokenSet` 这一个比特,设置页也只显示「已设置」。那条 GET 与本路由同属"无 Origin 的本机请求也放行",回明文就等于任何同机进程都能读到口令,闸门形同虚设。改口令是三段式:不传该字段 = 不修改,传空串 = 清除,传非空 = 设置。
113
- - 目录白名单对**工具路径同样生效**(`send_file_to_im` 也走同一个读文件收口点),否则模型调用那条路就成了绕过白名单的后门。
114
- - 白名单两侧都会做 `realpath`:既防止"在白名单目录里放一个指向 `/etc/passwd` 的符号链接"这种绕过,也保证 macOS 上 `/tmp`(真实路径 `/private/tmp`)这类写法不会把合法路径误拒。目录可用 `~` 表示家目录。
115
-
116
- #### 图片 / 视频自动走「图片消息」「视频消息」
117
-
118
- 微信的附件分成互不相通的消息类型:按文件发落成一张要点开的文件卡片,按图片/视频发则能直接预览。**谁来判定,取决于后端**:
119
-
120
- | 后端 | 判定依据 | `imageAsPicture` 开关 |
121
- | --- | --- | --- |
122
- | im-connect | 文件名后缀,发送前按**内容魔数**纠偏:真图片叫错名字会自动纠正后缀按图片发(可直接预览),文本改名 `.jpg` 会改按文件发(免得被微信拒收)。只动发送时的文件名,磁盘原文件不动 | 不生效(上游自己决定,我们插不上手) |
123
- | dsh-im | 本插件按**内容魔数**(JPEG/PNG/GIF/WebP/BMP),扩展名只在魔数认不出时兜底 | 生效:关掉就一律按文件发 |
124
-
125
- dsh-im 后端另有一个自动降级:上游还没有 `sendImage`(老版本)时自动退回按文件发。
28
+ - 图片 / 视频 / 其他文件按类型自动选微信消息形式,不用你操心格式;
29
+ - 提不到上下文时会给出可操作的提示(按提示在微信给机器人发一条消息再试)。
126
30
 
127
31
  ### 定时任务
128
32
 
129
- 按 cron 把一段提示词交给一个**全新**的 agent 会话执行。面板在**侧栏 → 定时任务**。
130
-
131
- #### 新建一条
132
-
133
- | 字段 | 说明 |
134
- | --- | --- |
135
- | 标题 | 最多 200 字符,运行时会钉成会话标题「标题 · MM-DD HH:mm」 |
136
- | 提示词 | 最多 20000 字符,**原样**提交给新会话(`@专家名` 照常生效) |
137
- | 运行时间 | cron 表达式(见下) |
138
- | 启用 | 停用后保留数据,不再触发 |
139
- | 工作区 | 不绑定 / 绑定已有工作区 / 自定义绝对路径(目录不存在自动创建) |
140
-
141
- 列表支持搜索与「全部 / 已启用 / 已停用」过滤,每条行尾有「立即执行 / 编辑 / 停用·启用 / 删除」。
142
-
143
- **总开关**(设置卡片里的「启用定时任务」)控制整个功能:关闭后左侧入口整条消失、停止全部触发、拒绝手动执行;任务与记录保留,重新打开后一起恢复。
144
-
145
- #### 自然语言建任务
146
-
147
- 在对话里说「帮我每天 9 点半获取每日热点新闻 10 条」,Agent 会调用 `create_schedule_task` 建好。若 DSH 官方的「自动化任务」也开着,Agent 会**先弹窗让你选**独立任务还是会话内提醒,不替你决定。除创建外还有 `list_schedule_tasks` / `update_schedule_task` / `delete_schedule_task`,改和删都会先回显目标标题与 cron 让你确认。
33
+ 侧栏「定时任务」里是按 cron 跑的独立任务:**到点新开一个会话**执行提示词,不依赖发起它的那个对话 —— 关掉对话照跑。
148
34
 
149
- #### cron 怎么写
35
+ - 面板里新建,或直接用自然语言描述(「每周一早上 9 点……」);
36
+ - 支持一次性任务(指定日期,跑完自动停用);
37
+ - 每次执行都留下记录与关联会话。
150
38
 
151
- `分 时 日 月 周`,首位可加秒(秒 ≠ 0 时写六段),例如 `0 9 * * *`。面板提供图形化选择器(每天 / 每周 / 工作日 / 每 N 小时 / 每 N 分钟 / 自定义),切「自定义」直接写表达式;保存前显示**最近 5 次执行时间**供确认。
39
+ ### 界面上的小东西
152
40
 
153
- #### 只跑一次("今年 9 月 28 日"这种)
154
-
155
- cron 没有「年」这个位置,`0 9 28 9 *` 字面意思是「每年这天」。把**「最多执行次数」填 1** 即可:**成功**跑完那一次自动停用(列表标「限次 1/1」)。上限与已跑次数落盘,重启宿主不重跑。
156
-
157
- 只有成功的运行消耗次数:**失败不计数**(否则一次性任务失败一次就永久停用,连重试的机会都没有),改为**连续失败 5 次自动停用**(列表标「已连续失败 5 次」),成功一次即清零。这个计数同样落盘,重启不会把它重置 —— 它的作用是止损:一个配了坏提示词的分钟级 cron 否则会无限开新会话烧额度。
158
-
159
- #### 时区与夏令时
160
-
161
- cron 按**宿主进程所在的本地时区**解释(不传 `timezone` 给 croner,跟随系统设置)。所以「每天 9 点」就是你这台机器上的 9 点;面板里「最近 5 次执行时间」也按本地时区显示,不会有 8 小时偏移。
162
-
163
- 有夏令时的时区里,切换当天的行为是**实测**出来的(croner 9.x,2026-09-28):
164
-
165
- | 情况 | 表现 |
41
+ | 功能 | 做什么 |
166
42
  | --- | --- |
167
- | 春季拨快,任务时间落在「不存在的那一小时」(如纽约 2026-03-08 的 02:30) | 当天**推后 1 小时**触发(03:30),**不会跳过这一天** |
168
- | 秋季拨慢,任务时间落在「重复出现的那一小时」(如纽约 2026-11-01 的 01:30) | 当天只触发**一次**(取第一次),**不会跑两遍** |
169
-
170
- 中国时区(`Asia/Shanghai`)没有夏令时,上面两条对国内部署不适用;只有宿主跑在别的时区时才需要关心。
171
-
172
- 想自己复现(`nextRuns(n, from)` 从指定时刻往后算):
173
-
174
- ```bash
175
- cat > /tmp/tz-probe.mjs <<'EOF'
176
- import { Cron } from "croner";
177
- const [, , expr, from, n] = process.argv;
178
- const job = new Cron(expr, { paused: true });
179
- for (const d of job.nextRuns(Number(n ?? 3), from || undefined)) {
180
- console.log(` ${d.toISOString()} 本地: ${d.toString().slice(0, 24)}`);
181
- }
182
- job.stop();
183
- EOF
184
- TZ=America/New_York node /tmp/tz-probe.mjs "30 2 * * *" "2026-03-06T12:00:00Z" 3 # 春季拨快
185
- TZ=America/New_York node /tmp/tz-probe.mjs "30 1 * * *" "2026-10-30T12:00:00Z" 3 # 秋季拨慢
186
- ```
187
-
188
- ⚠️ 每个时区要用**独立进程**跑(像上面这样分两条命令):在同一个进程里改 `process.env.TZ` 不总生效,V8 会缓存时区数据,测出来的结果可能是上一个时区的。
189
-
190
- #### 执行与记录
191
-
192
- 执行时在绑定工作区下**新建会话**、挂默认 preset,把提示词作为**用户消息**提交;同一事项**不并发**(上一轮没跑完就跳过本轮)。每条保留**最近 20 次**执行记录(时间、成败、当次提示词,成功还带会话 id),可点「关联会话」跳回或恢复已归档会话。
43
+ | 活动呼吸灯 | 工作区有活动时图标发光,折叠时标记未读 |
44
+ | 活跃指示 | 会话标题栏一枚心电图,点开列出需要注意的会话 |
45
+ | 完成提示音 | 会话跑完响一声(可只在切走时响) |
46
+ | 界面外观 | 关掉 macOS 桌面端左侧栏的系统毛玻璃 |
47
+ | 一键重启 | 标题栏按钮,重启 DeepSeek Harness |
193
48
 
194
- **上限**:事项 200 条、单事项记录 20 条。数据在 `$DSH_HOME/integrations/dsh-helper/schedule.json`(原子写)。
49
+ ### 设置与卸载
195
50
 
196
- ### 活动呼吸灯
197
-
198
- 工作区行的**文件夹图标**随会话状态呼吸,不动行背景、不做缩放。
199
-
200
- | 风格 | 说明 |
201
- | --- | --- |
202
- | **彩色流转**(默认) | 蓝 → 青 → 品红流转,带一层边缘光 |
203
- | **脉冲** | 常态灰与主题蓝之间明暗脉动 |
204
- | **光环** | 轮廓外柔光呼吸扩散 |
205
-
206
- **活动** = 会话正在跑或停在等你操作;**未读** = 会话在主视图外停下等你确认。折叠时绿点看不见,就把图标染成橙色顶上(橙色优先于活动呼吸)。
207
-
208
- ![折叠 / 展开 × 活动 / 未读 的三种风格与两种主题](./docs/unread.png)
209
-
210
- ### 活跃指示
211
-
212
- 会话标题栏(右上角那排按钮里)一枚**心电图图标**:有会话正在跑、或有事等你查看时显示彩色,高亮沿着心电路径来回滚动;全部空闲且没有待看的显示灰色、静止。它只是指示,不挡任何点击。
213
-
214
- 点它弹出「需要注意的会话」清单:
215
-
216
- | 行 | 含义 |
217
- | --- | --- |
218
- | **当前会话** | 面板顶部固定一行:当前会话 id + 标题,点一下复制 id |
219
- | **运行中** | 绿点;正在跑的主会话(**只有子代理在跑也算**)排在前面 |
220
- | **待查看** | 橙点;跑完停在等你确认(`completed`)的主会话 |
221
-
222
- 清单里每条都能点:会先展开它在侧栏所属的**工作区分组**(宿主对已折叠过的分组不会自动展开),再直接接上那个会话;每条下面的小字是会话 id,点一下即复制。子代理会话与「新会话」占位行刻意不进清单 —— 用户要的是「点开能接上的主会话」。
223
-
224
- 设置卡片里可以整个关掉(「显示活跃指示」,默认开)。偏好存 `localStorage`,与呼吸灯、提示音一致。这项能力 2026-09-28 从 **T专家(dsh-expert)** 迁来,位置、外观与行为都没变。
225
-
226
- ### 对话完成提示音
227
-
228
- 会话**跑完**出一声。声音由浏览器现场合成(Web Audio 振荡器 + 包络),插件不含任何音频文件。
229
-
230
- | 设置 | 默认 | 说明 |
231
- | --- | --- | --- |
232
- | 播放提示音 | 开 | 总开关 |
233
- | 音色 | 叮咚 | 叮咚 / 单音叮 / 三连音 / 电子哔 / 街机金币 / 扫频 / 激光 / 自定义;点一下即试听 |
234
- | 音量 | 中 | 轻 / 中 / 响 |
235
- | 只在切走时响 | 关 | 打开后只有页面在后台或窗口失焦时才出声 |
236
-
237
- **自定义提示音**:音色选「自定义」后挑一个浏览器能解码的音频(mp3/wav/m4a/ogg 等,≤ 4 MB),存在浏览器 IndexedDB,**不上传**、不写插件目录,只属于当前浏览器。
238
-
239
- 「完成」= 会话执行状态由跑转停(同一份状态快照,不加宿主钩子)。两条刻意的设计:**停下来等 300ms 再响**(避免多轮任务连响)、**只认顶层会话**(子代理与 Agent Teams 队友不算)。
240
-
241
- ### 界面外观
242
-
243
- | 设置 | 默认 | 说明 |
244
- | --- | --- | --- |
245
- | 关闭侧栏毛玻璃 | 关 | 盖掉 macOS 桌面端左侧栏那层**系统原生毛玻璃**,还原成实色侧栏 |
246
-
247
- 这层毛玻璃不是插件画的,而是**原生窗口材质**:桌面端在 macOS 上创建窗口时写死 `vibrancy: "sidebar"` + 窗口底色全透明(`app.asar` 内 `lib/main.js`),渲染层再让开底色——官方 base.css 在 `html[data-platform=darwin]` 下把 `html`/`body` 设为 `transparent`,ui-layout 把框架设为 `none`,侧栏列只填 **40%**(深色 50%)不透明色,而主内容列与右栏是实色。所以只有左边那一条能透出模糊,右侧永远是纯色。
248
-
249
- 宿主没有开关(主进程只在最小化/隐藏时临时 `setVibrancy(null)`),也没有环境变量可关。本插件用**纯样式覆盖**实现:给 `body` 铺上不透明的 `--dsw-alias-bg-base`(随深浅主题走,`html,body,#root` 都是满高,铺满整个视口 → 底下那层原生模糊被完全遮住),再把侧栏列还原成官方**非 macOS 平台**的实色填充与 0.5px 分隔线。零侵入、可逆、不动 App 本体(改 `app.asar` 会破坏代码签名与自动更新)。
250
-
251
- 约束两条:只在 macOS 桌面端有效(浏览器里访问同一个 GUI 本来就没有这层材质);侧栏列按 `[class*="_sidebarCol"]` 后缀匹配,官方哪天改了类名这条会降级失效——毛玻璃照样消失,只是侧栏底色仍是官方那层淡色。
252
-
253
- ### 一键重启 DeepSeek Harness
254
-
255
- 「设置 → 通用设置」头部多一个**重启**按钮。点击后弹确认,服务端过**四道闸**:配置开关(`restartEnabled`)、请求来源(同源环回)、调试器附着、进程监督器;然后 spawn 脱离的 helper 起新进程、本进程自杀。不满足条件时按钮禁用并说明原因,失败日志在 `/tmp/dsh-helper-restart-<时间戳>.*.log`。
256
-
257
- ### 设置与配置
258
-
259
- 设置卡片在**插件列表 → 辅助补丁**的插件信息页,按用途分成六张卡片:
260
-
261
- | 卡片 | 里面是什么 | 存在哪 |
262
- | --- | --- | --- |
263
- | **文件投递** | 允许投递、图片以图片消息发送(+ 当前走哪个 IM 后端) | `config.json`,保存即生效 |
264
- | **定时任务** | 定时任务总开关 | 同上 |
265
- | **活动提示** | 显示活跃指示、活动呼吸灯(含提示风格 / 节奏) | 浏览器 `localStorage` |
266
- | **完成提示音** | 开关、音色、音量、只在切走时响、试听 | 同上 |
267
- | **界面外观** | 关闭侧栏毛玻璃 | 同上 |
268
- | **宿主操作** | 启用「重启 DeepSeek Harness」按钮 | `config.json` |
269
-
270
- 开关与按钮用的是 DSH 官方 primitives(`Switch` / `Button`),与其它官方插件同一套观感。
271
-
272
- ![插件信息页上的设置卡片:文件投递、定时任务开关、活动呼吸灯与完成提示音](./docs/settings.png)
273
-
274
- > 图为 0.5.x。此后卡片陆续加过「界面外观」(0.6.0)与「活跃指示」(0.9.0),0.9.1 起整张卡片按用途重排成六张。
275
-
276
- host 侧配置在 `$DSH_HOME/integrations/dsh-helper/config.json`(首次加载自动创建):
277
-
278
- | 字段 | 作用 |
279
- | --- | --- |
280
- | `enabled` | 投递总开关(`false` 时工具与端点都拒绝投递) |
281
- | `imageAsPicture` | 图片是否改用图片消息(默认 `true`) |
282
- | `restartEnabled` | 是否允许「重启」按钮工作 |
283
- | `scheduleEnabled` | 定时任务总开关(`false` 隐藏入口、停触发、拒手动执行) |
284
- | `botId` | 用哪个微信账号:im-connect 填它的账号 id(如 `weixin_a50bc9312964`),dsh-im 填机器人 id;省略取第一个可用账号 |
285
- | `toUserId` | 默认收件人(微信用户 id);省略时用账号里绑定的那个人(im-connect 的 `allowedUserId` / dsh-im 的 `ownerUserId`) |
286
- | `imConnectDir` | im-connect 安装目录,一般自动探测,无需填 |
287
- | `dshImDir` | dsh-im 安装目录,一般自动探测,无需填 |
288
- | `sendToken` | **可选**,HTTP 投递口令;空/缺省 = 不启用。设了之后 `POST /send-file` 必须带它(请求头 `x-dsh-helper-token` 或 body 的 `token`),否则 401。`GET /config` **不回明文**,只回 `sendTokenSet`。建议在设置页改,不要手写这个文件 |
289
- | `sendAllowRoots` | **可选**,允许投递的目录数组;空/缺省 = 不限制。非空时只有这些目录及其子目录里的文件能发出去,**工具与 HTTP 两条入口都受限**。支持 `~`,两侧都会做 `realpath` |
290
- | `backend` | `auto`(默认)/ `im-connect` / `dsh-im`:走哪个后端。显式指定时找不到就报错,**不静默换路**(免得文件从另一条路发出去) |
291
-
292
- 设置卡片里会显示**当前走哪个后端**(探测结果,只读)。
293
-
294
- ### 常见问题
295
-
296
- **投递失败,`hadContextToken: false` / 报 `-2 prepare failed`**
297
-
298
- 主动投递依赖 `contextToken`,它只在机器人收到入站消息时刷新。DSH 刚重启时通道虽 ready 却无会话上下文,投递会失败——**文字和文件都一样**,非本插件引入的限制。**恢复办法**:在微信里给机器人随便发一条消息再投递即可。`-2` 是腾讯官方协议未定义的 `prepare failed`,现有证据都指向「缺少活跃的用户会话上下文」,插件提示会给出「本地登录态最后写入于 N 分钟前」这类线索,而不是断言原因。
299
-
300
- **换了 IM 插件(dsh-im ⇄ dsh-im-connect)之后发不出去**
301
-
302
- 本插件不含 IM 协议,投递全靠复用所装插件的实现。所以:
303
-
304
- 1. 确认新插件已装进**当前 profile** 的 `dsh.profile.bundles` 并重启;
305
- 2. 在「插件列表 → 辅助补丁」的设置卡片里看**当前后端**那一行 —— 显示「没有检测到可用的 IM 插件」就是没装到,显示的还是旧插件就是旧插件仍在 `bundles` 里;
306
- 3. 两个都装时默认 im-connect;要固定一个就在 `config.json` 里写 `"backend": "im-connect"` 或 `"dsh-im"`。
307
-
308
- **工具 `send_file_to_im` 在某个会话里不存在**
309
-
310
- 工具只对「建立时已加载本插件」的会话可见,已在跑的会话请新建一个,或直接用 HTTP 端点。
311
-
312
- **面板报「定时任务服务暂时不可用」**
313
-
314
- 多半是宿主版本偏老(缺 `reflect`),或插件没被真正加载。先确认 `dsh.profile.bundles` 里有 `dsh-helper`,再重启。
315
-
316
- **报「im-connect / dsh-im 可能改了内部结构,请更新 dsh-helper」**
317
-
318
- 上游升级后内部模块路径或导出名变了(本插件只读 import 它们的模块,不复制协议)。升级本插件即可,其余能力不受影响。
319
-
320
- ### 兼容性
321
-
322
- | 项 | 要求 |
323
- | --- | --- |
324
- | Node | `^22.19.0 \|\| >=24` |
325
- | DSH 宿主 | `>=0.1.5-rc.1` |
326
- | IM 插件 | `@michengai/dsh-im-connect >=0.1.50` 或 `@xmanrui/dsh-im >=4.24.0`(都是可选 peer;一个都没有时投递不可用,其余正常) |
327
- | React | `^18.2.0`(可选 peer,由宿主提供) |
328
-
329
- ### 已知限制
330
-
331
- - 主动投递需要「活的」会话上下文(见[常见问题](#常见问题)),这是上游协议限制。
332
- - 投递文件 200 MiB 本地上限(读取前拒绝),微信侧以更小的上游回执为准(单文件通常 50 MiB 左右)。
333
- - im-connect 后端复用它的内部模块(`lib/channels/weixin.js`、`lib/engine/credentials.js`),上游改结构时要跟着升级本插件;报错会明说,不会静默失效。
334
- - im-connect 后端下「图片以图片消息发送」开关不生效:图片/视频按文件名后缀分流(发送前会按内容魔数纠偏,见上表),分类不由这个开关决定。
335
- - im-connect 后端目前只覆盖**微信**渠道(其它渠道的通道工厂参数不同,尚未接入)。
336
- - 定时任务与 [`@weibaohui/dsh-tasks`](https://www.npmjs.com/package/@weibaohui/dsh-tasks) **零共享**:数据、服务名、错误码独立,可同时装、同时用。
337
- - 呼吸灯靠官方工作区行的 `data-row-key` 锚点,宿主改动该语义属性需跟着升级。
338
- - 提示音只在浏览器侧存在,CLI/无头运行没有声音;响过一声后 **5 秒内不再响**(去重 + 压掉 turn 结束时 running 的几下抖动),两个标签页同刻仍可能各响一声。
339
- - 自定义提示音存浏览器 IndexedDB,只在该浏览器可用,换浏览器需重选(回退「叮咚」)。
340
- - 「关闭侧栏毛玻璃」是**样式遮挡**,不是真的关掉窗口的 vibrancy:主进程那层材质还在,只是被不透明底色盖住(想真关得改 `app.asar`,会破坏代码签名与自动更新)。它靠官方侧栏列的类名后缀匹配,官方改名后只剩"窗口底不透明"这一半效果。
341
- - dsh-im 后端的图片判定以魔数为主,文本文件改名成 `.jpg` 会按图片发出然后被上游拒绝(有意取舍);im-connect 后端按后缀分流、发送前按魔数纠偏,两种名不副实都会被纠正(视频不纠偏——格式头复杂、场景少,仍按后缀)。
342
-
343
- ### 开发与更新日志
344
-
345
- - 构建、自检、槽位与样式约定、宿主适配的坑见 [docs/DEVELOPMENT.md](./docs/DEVELOPMENT.md)。
346
- - 发布流程见 [docs/PUBLISHING.md](./docs/PUBLISHING.md)。
347
- - 版本变更见 [CHANGELOG.md](./CHANGELOG.md)。
348
-
349
- ```bash
350
- npm run build # 重建 lib/client.js(改 src/client/** 后必跑)
351
- npm run verify # build + 客户端/host/渲染冒烟
352
- npm run preview # 把呼吸灯样式渲染成预览页
353
- ```
51
+ 所有开关都在**插件列表 → 辅助补丁**的信息页里。运行数据在 `~/.dsh/integrations/dsh-helper/`,卸载**不会**删它。
354
52
 
355
- ### 许可
53
+ ### 许可与反馈
356
54
 
357
- [MIT](./LICENSE) © jiuaiwo
55
+ MIT(见 `LICENSE`)。源码与问题反馈:<https://github.com/jiuaiwo/dsh-helper>
@@ -53,7 +53,7 @@ npm run build # tools/build-client.mjs → lib/client.js(esbuild)
53
53
  | --- | --- | --- |
54
54
  | `npm run typecheck` | `tsc -p tsconfig.json`:只检查标了 `// @ts-check` 的自研 Host 文件(`lib/schedule*.js`),不碰构建产物 | 见下 |
55
55
  | `npm run smoke` | 模拟 `__ModuleLoader__` + 假 React/DOM 跑客户端逻辑:打标、未读判定、cron 模型往返、样式表结构、布局度量、remote 方法集合、提示音、界面外观覆盖规则、设置卡片分组与投递加固两项的呈现、apply 分阶段降级 | 81 项 |
56
- | `npm run host-smoke` | host 侧纯函数与真实引擎:图片嗅探、投递方式、错误翻译、投递加固(口令 / 目录白名单)、重启安全闸、平台适配 spawn、文案表、来源闸,以及用真实调度引擎跑的定时任务用例(执行窗口并发变更、迁移诊断自动合并 / 仍搁浅、合并幂等性 = 删任务后提示消失 / migration 字段穿过 persist、损坏文件留存、改名迁移与幂等) | 114 项 |
56
+ | `npm run host-smoke` | host 侧纯函数与真实引擎:图片嗅探、投递方式、错误翻译、投递加固(口令 / 目录白名单)、重启安全闸、平台适配 spawn、文案表、来源闸,以及用真实调度引擎跑的定时任务用例(执行窗口并发变更、迁移诊断自动合并 / 仍搁浅、合并幂等性 = 删任务后提示消失 / migration 字段穿过 persist、损坏文件留存、改名迁移与幂等) | 118 项 |
57
57
  | `npm run render-smoke` | 客户端组件真实渲染断言(配置卡片、定时任务面板、界面外观组、标题栏刷新按钮) | 10 项 |
58
58
  | `npm run preview` | 把呼吸灯样式抽成预览页,调动画参数用 | — |
59
59
  | `npm run verify` | `typecheck` + `build` + 上面三个冒烟 | — |
package/lib/client.js CHANGED
@@ -23481,6 +23481,8 @@ function restartDisabledTitle(reason) {
23481
23481
  return "\u5BBF\u4E3B\u6B63\u88AB\u8C03\u8BD5\u5668\u9644\u7740\uFF0C\u4E0D\u80FD\u91CD\u542F";
23482
23482
  case "supervisor":
23483
23483
  return "\u5BBF\u4E3B\u7531 systemd \u7B49\u76D1\u7763\u5668\u7BA1\u7406\uFF0C\u91CD\u542F\u529F\u80FD\u5DF2\u7981\u7528";
23484
+ case "desktop":
23485
+ return "\u684C\u9762\u7AEF\u5BBF\u4E3B\u7531 DeepSeek Harness App \u7BA1\u7406\uFF1A\u9000\u51FA App \u540E\u91CD\u65B0\u6253\u5F00\u5373\u53EF";
23484
23486
  case "untrusted":
23485
23487
  return "\u8BF7\u6C42\u6765\u6E90\u4E0D\u662F\u540C\u6E90\u73AF\u56DE";
23486
23488
  default:
package/lib/index.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  import { IMAGE_EXTENSIONS, sniffImageKind } from '../src/host/image-sniff.js';
34
34
  import {
35
35
  detectedDebugger,
36
+ detectedDesktopHost,
36
37
  detectedSupervisor,
37
38
  scheduleRestart,
38
39
  servingPort,
@@ -713,8 +714,13 @@ async function handleRestartStatusRequest(request, response) {
713
714
  const restartEnabled = settings.restartEnabled !== false;
714
715
  const supervisor = detectedSupervisor();
715
716
  const debuggerAttached = detectedDebugger();
717
+ const desktopHost = detectedDesktopHost();
716
718
  let reason = null;
719
+ // 桌面端排在 debugger / supervisor 之前:它是"这一档从根本上不该点",
720
+ // 而调试器/监督器是"这台机器上的偶然状态"。桌面端打开宿主调试端口时两者会同时命中,
721
+ // 那时该告诉用户的是前者。
717
722
  if (!restartEnabled) reason = 'disabled-in-config';
723
+ else if (desktopHost !== null) reason = 'desktop';
718
724
  else if (debuggerAttached !== null) reason = 'debugger';
719
725
  else if (supervisor !== null) reason = 'supervisor';
720
726
  json(response, 200, {
@@ -722,14 +728,15 @@ async function handleRestartStatusRequest(request, response) {
722
728
  reason,
723
729
  supervisor,
724
730
  debugger: debuggerAttached,
731
+ desktopHost,
725
732
  restartEnabled,
726
733
  });
727
734
  }
728
735
 
729
736
  /**
730
737
  * POST /api/dsh-helper/restart
731
- * 过四道闸:restartEnabled / trustedRestartRequest / detectedDebugger / detectedSupervisor
732
- * 通过则 scheduleRestart(port),本进程 500ms 后自杀。
738
+ * 过五道闸:restartEnabled / detectedDesktopHost / trustedRestartRequest /
739
+ * detectedDebugger / detectedSupervisor,通过则 scheduleRestart(port),本进程 500ms 后自杀。
733
740
  */
734
741
  async function handleRestartRequest(request, response) {
735
742
  if (request.method !== 'POST') {
@@ -742,6 +749,13 @@ async function handleRestartRequest(request, response) {
742
749
  json(response, 403, { error: 'disabled-in-config', reason: 'disabled-in-config' });
743
750
  return;
744
751
  }
752
+ // 桌面端必须排在 trustedRestartRequest **之前**:Electron 的 forwardWebRequest 转发时
753
+ // 会剥掉 Origin,这一档在桌面端永远过不了来源闸,先判桌面端才能给出说得通的理由
754
+ // (否则用户看到的是没头没尾的 untrusted,而按钮又是亮的)。
755
+ if (detectedDesktopHost() !== null) {
756
+ json(response, 403, { error: 'desktop', reason: 'desktop' });
757
+ return;
758
+ }
745
759
  if (!trustedRestartRequest(request)) {
746
760
  json(response, 403, { error: 'untrusted', reason: 'untrusted' });
747
761
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-helper",
3
- "version": "0.16.6",
3
+ "version": "0.16.8",
4
4
  "type": "module",
5
5
  "description": "给 DeepSeek Harness 打补丁:在任意会话把文件主动投递到 IM(微信)、按 cron 跑定时任务、工作区活动呼吸灯(含折叠未读提示)、会话标题栏活跃指示、对话完成提示音,另有一键重启按钮。",
6
6
  "icon": "icon.svg",
@@ -7,10 +7,13 @@
7
7
  * - scheduleRestart 不接受 recovery 参数(不写 .recovery.json)
8
8
  * - restartHelperSource 不带 recovery 移交分支
9
9
  * - 删除 recoveryScriptPath(依赖 lib/recovery.js 产物,本插件无产物)
10
+ * - 新增 detectedDesktopHost:dshmarket 靠配置里的 desktopHost 标记达成同一件事,
11
+ * 本插件没有那份配置,改成从宿主环境自己认
10
12
  *
11
13
  * 安全闸逻辑一行不漏照搬:
12
14
  * - supervisor: INVOCATION_ID||JOURNAL_STREAM 且 (ppid===1 || comm==='systemd')
13
15
  * - debugger: inspector.url() || execArgv/NODE_OPTIONS 按 token 前缀匹配
16
+ * - desktop: DSH_CLIENT_VERSION 非空(Electron 主进程只发给桌面端宿主)
14
17
  * - 请求来源: trustedRestartRequest(独立文件)
15
18
  */
16
19
 
@@ -81,6 +84,35 @@ export function detectedSupervisor(
81
84
  return null;
82
85
  }
83
86
 
87
+ /**
88
+ * 桌面端宿主检测(DSH Desktop)。
89
+ *
90
+ * 判据取 `DSH_CLIENT_VERSION`:Electron 主进程在 spawn 宿主进程时注入它
91
+ * (app.asar/lib/main.js 里构造 DesktopHostProcess 的那处 env),`dsh web`、
92
+ * 内置 CLI、headless 都不会有。比 `ELECTRON_RUN_AS_NODE` 精确 —— 后者是所有
93
+ * "把 Electron 当 Node 用"的通用标记,别的 Electron 应用里跑 dsh 也会命中。
94
+ *
95
+ * 为什么桌面端要单独判一档(2026-10-06 实测定案):桌面窗口跑在 `dsh-app://app`,
96
+ * 请求由 Electron 主进程的 forwardWebRequest 转发给宿主,而它**转发前会删掉
97
+ * origin / host / cookie / sec-fetch-site**。于是重启闸(trustedRestartRequest
98
+ * 要求 Origin 存在且与 Host 同源)在桌面端恒为 false,用户点按钮只会拿到
99
+ * `403 untrusted`;而 status 走的是松一档的 trustedLocalRequest(放行无 Origin),
100
+ * 按钮反倒显示成"可点"——两者不一致,就成了"看起来能用、一点就报错"。
101
+ * 上游 dshmarket 在 routes.ts 里把桌面端的 restart 直接标成
102
+ * `supported: false` / `managedBy: 'desktop-host'`,本插件补上同一档。
103
+ *
104
+ * 桌面端也确实不该自己重启:宿主一退出,Electron 主进程立刻
105
+ * `fail("dsh desktop host stopped")` 进 fatal 恢复;插件另起的替代进程还拿不到
106
+ * 主进程持有的 hostCookie(窗口会 401)。正确做法是让用户从 App 侧退出后重开。
107
+ *
108
+ * @returns {'desktop' | null}
109
+ */
110
+ export function detectedDesktopHost(env = process.env) {
111
+ const version = env.DSH_CLIENT_VERSION;
112
+ if (typeof version !== 'string' || version.trim() === '') return null;
113
+ return 'desktop';
114
+ }
115
+
84
116
  /**
85
117
  * 检测器附着检测。按以下优先级:
86
118
  * 1. inspector.url() 已设置(覆盖 --inspect 启动 / inspector.open() / SIGUSR1 attach)