dsh-push-host 0.0.0-stage → 0.3.2

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/README.md CHANGED
@@ -1,3 +1,668 @@
1
- # Temporary Holding Version
1
+ # dsh-push-host
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH 宿主插件:**进程内**把待答问题推到手机(ntfy),并把手机迟到作答灌回 agent。
4
+ 它是"外部通知器 + 外部作答桥"这条老链路的替代品 —— 插件跑在 DSH 自己进程里,用 DSH 的
5
+ `userQuestions` seam,不再需要 cookie、`/api/remote.mux`、RPC,也不再需要伺候 token 轮换。
6
+
7
+ ```
8
+ ask_user_question (工具, 进程内)
9
+ └─ ctx.waterfall("user-questions/request")
10
+ ├─ dsh-push-host 监听器(prepend)
11
+ │ ├─ POST relay /answer/register {callId, agentId, questions, hostId} ← 手机能答
12
+ │ ├─ POST relay / (ntfy JSON: 标题/正文/#qid/#opts/#web/#name) ← 手机收到
13
+ │ └─ return next() ← 前台作答权仍归 DSH Web UI(插件不抢)
14
+ └─ Web UI 作答器(浏览器在线时照旧作答)
15
+ 轮询环 ctx.interval(s)
16
+ └─ GET relay /answer/pending?host=X (X-Host-Token)
17
+ ├─ 问题仍在 open 窗口 → 不 ack,留在 relay 队列里等(答案不丢)
18
+ └─ 问题已 continued → ctx.userQuestions.answer(活根 agent, callId, {answers})
19
+ → agent.steer(答案消息) → ack
20
+ ```
21
+
22
+ ## 两条硬约束(先读,不然会白跑)
23
+
24
+ 1. **`request.wait.timed` 必须为 true,手机作答才可能生效。**
25
+ `answer()` 只能答 **continued** 态(`dsh-user-questions` 的投影只 fold `wait.timed === true` 的调用)。
26
+ 而 `ask_user_question` 只有在 `tool-ask-user` 配 `mode: timed` 时才会走 `askTimed()`。
27
+ **本机实测(2026-10-12)**:`web` / `desktop` / `headless` 三个 profile 的 `tool-ask-user`
28
+ 都是默认 `mode: legacy`(`--dump-config` 里该条目无 `config`),所以现状是
29
+ `wait = { callId }`、`timed=false` → 投影永远空 → 手机作答无法落地(只能弹通知)。
30
+ 插件对每个问题都打 `timed=` 并在 `timed=false` 时明确拒绝(把该答案 ack 掉并打印原因),
31
+ 不会静默丢答案。
32
+ 要开:`tool-ask-user` 加 `config: { mode: timed, timeout: <秒> }`(0 选项窗口消失,
33
+ 工具到点先返回 `pending`、agent 继续干活,之后手机答案以 user 消息形式插回 agent)。
34
+ 2. **不要和老的 hook 推送同时开。** `deploy/windows/hooks.json` 的 `PreToolUse` 推送与
35
+ 本插件是同一件事的两条路径,同时开会双推(通知栏两条卡片)。任务结束(`Stop`)推送目前
36
+ 仍归 hook 路径,本插件只做"提问 → 作答"这一环。
37
+
38
+ ## 三个踩过的坑(都在这份代码里修掉了,别改回去)
39
+
40
+ 1. **同一个 URL 反复 GET 会被喂回第一次的响应。** 轮询环最初用固定 URL
41
+ `…/answer/pending?host=X`,实测(2026-10-12 真机):relay 队列里明明有两三条待答,
42
+ 插件却反复只处理最早那一条、新的永远不出现。给 URL 追加一个每次都不同的
43
+ `&_=<毫秒时间戳>` 后立刻正常。**结论:走 DSH 的 fetch(自带代理层)做轮询,URL 必须唯一化。**
44
+ 证据:加 `_` 之前日志只有 `delivery deferred for <旧 callId>`;加之后立刻出现
45
+ `queue: 2 item(s) [旧 新]`,两条都被处理。
46
+ 2. **投影状态是嵌套的。** `sessionProjections.stateOf(session, 'userQuestions')` 返回的是
47
+ fold 的内部 state:`{ timed, questions: { active, settled } }` —— active/settled 在
48
+ **`questions` 下面**(照抄 `dsh-user-questions` 自己 `continued()` 的写法)。少写一层会拿到
49
+ `undefined`,而早期代码把 `undefined` 当"问题已结束"→ 把仍在等待中的问题的答案静默 ack 掉。
50
+ **现在的判据是:只有 `settled` 里明确有该 callId 才丢弃,读不到状态一律留在队列里重试**
51
+ (relay TTL 1h 兜底,日志按 callId 节流)。
52
+ 3. **前台窗口期内的答案要"扣着"。** `answer()` 只认 `state === 'continued'`;`state === 'open'`
53
+ 时调用它返回 `false`。此时**不 ack**、留在队列里,等前台等待结束(timed 的 timeout / UI 作答)
54
+ 下一轮再投。
55
+
56
+ ## 生产切换踩过的坑(2026-10-12/13,五个,都在这份代码里修掉了)
57
+
58
+ 1. **schema 校验后的空默认值会盖掉凭据文件。** `apply()` 拿到的 `config` 已经过 schema 校验,
59
+ 空串默认值(`host: ''`、`hostToken: ''`、`ntfyToken: ''`、`regToken: ''`…)**全都在**,
60
+ 于是 `{...fromCredentials, ...config}` 把文件贡献的每个值都擦成空 → 插件在**完全正常的**
61
+ 凭据文件上打 `relay disabled`(旧 hook 已退役 = 手机彻底收不到通知)。
62
+ 修法:空串/`undefined`/`null` 一律算"没设置",`false` 仍然有效(`explicit` 过滤)。
63
+ 回归断言:`plugin-selftest-credentials.mjs` 的「① 空 schema 默认值不覆盖凭据文件」
64
+ 与「① 显式非空仍赢,`false` 不被当成 unset」两条 —— 用**整份 schema 形状的 config**
65
+ (每个字段都带默认值)打 `resolvePluginConfig`,这是唯一能复现该 bug 的输入形状。
66
+ 2. **`regToken` 必须同时在两张表里。** `lib/index.js` 的 `CREDENTIAL_CONFIG_KEYS`(决定哪些键
67
+ *可以*来自文件)与 `lib/credentials.mjs` 的 `CREDENTIAL_KEYS`(决定文件里的键怎么*读进来*)
68
+ 是两处独立白名单。只改前者 = 文件里的 `regToken` 从来不会出现在 `file.value` 里,
69
+ `pickConfigKeys` 什么也挑不到,`X-Reg-Token` 发空串 →
70
+ `POST /answer/register` 永远 `401 bad reg token`(relay 侧判据:
71
+ `X-Reg-Token != open("/etc/answer-relay/regtoken").read().strip()`)。
72
+ 回归断言:`② 每个 CREDENTIAL_KEYS 字段都在 CREDENTIAL_CONFIG_KEYS 里` +
73
+ 「`POST /answer/register` 真的带上文件里的 `X-Reg-Token`」(线级断言,不是看 getter)。
74
+ 3. **`dsh plugin add` 会重写 profile 的 `cordis.patch.yml`**(注释被丢、行尾换成 LF),
75
+ 而且**同版本 `file:` 再 add 一次是 no-op**("Already up to date",代码不会替换)。
76
+ 改完代码要生效必须:`Remove-Item -Recurse ~\.dsh\profiles\<p>\node_modules\<包>` →
77
+ `dsh plugin --profile <p> add <tarball|dir>` →(热加载不生效,见下)重启宿主。
78
+ `hmr` 插件**不会**重载 profile 的 node_modules 里的 bundle 插件:实测改一行注释后
79
+ web.log 十分钟没有新的 `relay ready`。所以「改代码 → 重启」是硬路径。
80
+ 4. **顶层按 id patch `tool-ask-user` 会 `entry not found`。** 它不在 profile 根节点上,而是
81
+ **嵌在 agent preset 的 `plugins:` 列表里**(`preset-standard` / `preset-ptc` / …,一份 profile
82
+ 里可以有好几个同名节点)。改它要么**改进那份 preset 的完整声明里**(patch 会整块替换
83
+ `config`,所以必须逐字重述整份 preset,见 `profiles/web/cordis.patch.yml` 里由
84
+ `make-preset-override.mjs` 生成的那份),要么用顶层 `- insert:` 插一个新节点。
85
+ **`--dump-config` 退出码 0 不等于挂上了**:先看目标 id 那一行的内容。
86
+ 5. **日志里的 `timed=false` 不一定是真的。** `tools/pre-execute`(竞位兜底触发点)在 waterfall
87
+ 之前跑,只能从**工具入参**猜 `timed`(`args.timeout`);而模型通常**不传**那个可选参数 →
88
+ 猜到 `false`,随后真正权威的 `user-questions/request`(看 `wait.timed`)被 `announce()` 的
89
+ 去重挡掉,于是"唯一的证据"就是那条错的 `timed=false` + 那句"没 timed wait"的 note。
90
+ 2026-10-13 排查 ② 时被它误导过一轮。修法:`timed` 被 waterfall 校正回 `true` 时补打
91
+ `question <callId> timed corrected to true by the request waterfall (…)`,并且
92
+ **register 成功也打一行 `register <callId> -> HTTP 200`**(原来只打失败,401 反而藏在
93
+ "没有 200 那行"里)。
94
+ **权威判据只有两个**:probe/waterfall 打出来的 `wait.timed`,以及"问题到点后返回
95
+ `{pending:true}`"(legacy 模式永远返回不了 pending,它要么等到 UI 作答、要么 `NO_PROVIDER`)。
96
+ 反过来,模型显式传 `timeout: -1`("必须等到答案")时走的是 **legacy** 路径 ——
97
+ 那时 `timed=false` 是真的,手机作答确实落不了地(`{"error":"no user-questions answerer
98
+ accepted the request"}`),这不是配置错。
99
+ 6. **默认凭据路径曾经是 cwd 相对路径。** `CREDENTIALS_FILE` 原为
100
+ `.config/dsh-push/credentials.json`(相对),经 `path.resolve()` 后**跟着进程 cwd 走**:
101
+ web 实例的 cwd 恰好是 `C:\Users\cnop` 所以一直没事;换个目录起 DSH(工作区、仓库根、
102
+ 快捷方式的"起始位置")就**静默丢失凭据**(缺文件 = 干净 no-op,日志里只剩一句
103
+ `relay disabled`)。修法:默认值改成 `~/.config/dsh-push/credentials.json`(显式 `~`,
104
+ 由 `expandHome` 拼到 home);用户自己写的相对路径仍是 cwd 相对。
105
+ 回归断言:`⑤ 默认路径锚在 home 目录,不跟 cwd 走`;另外 `plugin-selftest.mjs` 第 5 节
106
+ 现在**显式钉一个不存在的 credentialsFile**——修好路径之后,它才会去读开发机真实的凭据文件。
107
+
108
+ ## 网页提问卡的生命周期:legacy 提问为什么会僵卡锁 composer(2026-10-14,源码级根因,用户真机实遇)
109
+
110
+ 现场:`claimForeground=on` 下手机抢答成功(`answered (foreground)`、工具当场返回、会话继续),
111
+ 但 DSH Web 的提问卡停在可交互未答态**霸占 composer**,用户发不出新消息只能点「跳过」;**刷新也不会好**
112
+ (gateway 把仍挂着的转发请求重投给重连浏览器,未答卡原样重现)。服务端没有任何错:答案已按
113
+ `tool/result` 落 transcript,对已结算 callId 再点跳过是无害空操作
114
+ (`UserQuestionService.answer` 对不在 `continued` 的直接 `return false`)。
115
+
116
+ 根因(三层同时失效,缺一都不会僵):
117
+
118
+ 1. **没人取消转发给浏览器的那条 dispatch**:插件(prepend listener)race 赢过 `next()` 提前结算
119
+ 瀑布后,`dsh-api-remotes` 的 `forwardWaterfall` 没有"上游已结算"的取消路径,dispatch 悬挂;
120
+ 2. **cancel 帧不发**:gateway 的 cancel 只由 `request.signal` abort 触发,而 legacy 的 signal 是
121
+ 回合 phase 信号,正常完成**永不 abort**(新 phase 只替换 AbortController 不 abort 旧的);
122
+ 3. **投影不含 legacy**:`userQuestions` 投影按设计只折叠 `timed` 调用(判据=会话 header 记录的
123
+ 工具 schema 是否声明 `timeout`),legacy 提问连 callId 都不带(匿名卡),reconcile 的删除
124
+ 通道直接跳过它。
125
+
126
+ **会话代际钉死**(最容易误判的一点):ask_user_question 的 timed/legacy 变体在**会话创建时**
127
+ 钉进会话 header,**改 profile + 重启都不换**老会话的变体。所以 `mode: timed` 配好之后:
128
+ 新建会话双向同步正常(手机答→wait 关→signal abort→cancel 帧→卡片即关;网页答→回声→手机卡转已答,
129
+ 2026-10-14 用户真机验收过);老会话仍是 legacy,僵卡照旧点「跳过」无害解锁。
130
+ 判据:web.log 该 callId 行 `timed=` 值(注意坑 5 的校正假象,权威判据仍是 `wait.timed`)。
131
+
132
+ **根治 legacy 的最小补丁**(DSH 源码仓,`packages/user-questions`,编译层
133
+ `dsh-user-questions/lib/index.js` 的 `ask()`):新建 `const dispatchDone = new AbortController()`,
134
+ 向 waterfall 传 `signal: AbortSignal.any([request.signal, dispatchDone.signal])`,`finally` 里
135
+ `dispatchDone.abort()`——瀑布被**任何**应答者(含进程内抢答)结算即触发 gateway cancel 帧关卡,
136
+ timed 路径行为不变。插件侧修不了(`firstOf` 赢后够不着那条 dispatch,别在插件里补)。
137
+
138
+ ## 端到端验收记录(2026-10-12,真 agent + 真 relay + 真 ntfy)
139
+
140
+ 测试 profile `pushtest`(`--from-default-profile headless` 新建),覆盖层插入
141
+ `tool-ask-user`(`mode: timed, timeout: 60`)与本插件,让一个真 agent 连问两个问题;
142
+ 在第一个问题的**前台窗口期内**用 curl 从手机侧提交答案:
143
+
144
+ | 观测 | 证据 |
145
+ |---|---|
146
+ | 通知层推送 | ntfy topic 收到 `DSH 等你选择 [1054fc8c]`,正文含 `📱 作答: …/answer/<callId>` + `#qid q1` + `#opts q1=first\|second` + `#name win-test`,priority 4 |
147
+ | 注册生效 | `GET …/answer/<callId>` 返回作答页;`POST /answer/submit` 回 `{"ok":true,"queued":true,"host":"test1"}` |
148
+ | 窗口期内扣住 | `not answerable yet (state=open); keeping the phone answer queued and retrying` |
149
+ | 超时后投递 | `answered (continued) call_00_5Y7r…`(`ctx.userQuestions.answer()` 返回 true)→ ack → 队列清空 |
150
+ | **agent 真的收到** | agent 最终回复:*"The first call returned pending (no answer before the 60-second timeout); the user later answered it with \"second\"."* |
151
+ | 手机清通知 | topic 收到 `DSH 已答` + `#ans <callId> second`,priority 1 |
152
+ | **任务结束推送** | topic 收到 `DSH 任务结束 [1150afd5]`,正文 `目录: F:\work\work_dsh` + `#name win-test`,priority 3(同轮只发一次) |
153
+ | **UI/任意端作答回声** | 真工具调用返回答案后 topic 收到 `DSH 已答` + `#ans <callId> first`,priority 1(`tools/post-execute` 路径) |
154
+ | 挂载验收 | `--dump-config` 尾部 `# == dsh-push-host` / `- id: push-host` / `name: dsh-push-host` |
155
+ | 纯逻辑回归 | `node plugin-selftest.mjs` → `48/48 checks passed` |
156
+
157
+ ## 分发(npm 包)
158
+
159
+ 本包按可发布的 npm 包组织(`package.json` 带 `repository` / `homepage` / `bugs` / `author` /
160
+ `publishConfig.access: public` / `engines` / `files` 白名单,MIT `LICENSE` 随包)。
161
+ 用户侧只需 `dsh plugin add`:
162
+
163
+ **已发布到 npm:`dsh-push-host@0.3.1`(2026-10-27)** — https://registry.npmjs.org/dsh-push-host
164
+ (`latest` 即最新版;`repository.directory = host-plugin`,市场据此自动关联 npm 包)
165
+
166
+ ```powershell
167
+ # 从 npm 装(推荐,免 allowBuilds 构建授权)
168
+ & $dsh plugin --profile <profile> add dsh-push-host
169
+
170
+ # 从 tarball 装(离线/内网,等价路径,已实测)
171
+ & $dsh plugin --profile <profile> add ./dsh-push-host-0.3.1.tgz
172
+
173
+ # 从本地目录装(开发期,改完立即生效)
174
+ & $dsh plugin --profile <profile> add "link:<仓库>/host-plugin"
175
+ ```
176
+
177
+ 三种方式在 profile 里的落点完全相同:`package.json` 的 `dependencies` 多一项、`dsh.profile.bundles`
178
+ 多一项(DSH 读到本包的 `dsh.bundle.patch` 自动登记 bundle 层)。
179
+
180
+ **打包**:有 npm 的机器直接 `npm pack`(`files` 白名单已配好)。
181
+ DSH 运行时只带 pnpm 的机器上,仓库里配了等价工具,输出同样的 `npm notice` 清单与 sha1:
182
+
183
+ ```powershell
184
+ & $node tools/pack-npm-tarball.mjs host-plugin --dry-run # 只看清单,不落盘
185
+ & $node tools/pack-npm-tarball.mjs host-plugin # 落盘 host-plugin/dsh-push-host-0.3.1.tgz
186
+ ```
187
+
188
+ 实测产物 9 个文件:`LICENSE`、`README.md`、`cordis.patch.yml`、`package.json`、
189
+ `lib/{index.js,relay.js,credentials.mjs,pairing-lib.mjs,pairing.mjs}`(合计 85,706 B,tarball 30,300 B)。
190
+ `package.json` 里的 `files` 白名单决定这份清单;两个 `*-selftest*.mjs` 与 `relay-selftest.mjs`
191
+ **不进包**,需要验收就 `git clone` 仓库跑。
192
+
193
+ 一条命令复现"清单 → 打包 → 装进全新 profile → 验挂载 → 验凭据文件"(8/8):
194
+
195
+ ```powershell
196
+ & $node tools/verify-distribution.mjs --profile pushdist # 也可 --profile <新名> 全新建 profile
197
+ ```
198
+
199
+ ## 安装(已验证命令序列)
200
+
201
+ ```powershell
202
+ $dsh = '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd' # 不在 PATH 上
203
+
204
+ # 1. 从随附模板新建 profile(别动 web / desktop 这两个在用的)
205
+ & $dsh --profile pushdist --from-default-profile headless --dump-config
206
+
207
+ # 2. 把包(或 tarball)装进去
208
+ & $dsh plugin --profile pushdist add ./dsh-push-host-0.3.1.tgz
209
+
210
+ # 3. 验收挂载:看 dump-config 尾部
211
+ & $dsh --profile pushdist --dump-config | Select-Object -Last 3
212
+ # # == dsh-push-host
213
+ # - id: push-host
214
+ # name: dsh-push-host
215
+ ```
216
+
217
+ `dsh plugin add` 后 `profiles/<name>/package.json` 应有两处变化:`dependencies` 多一项、
218
+ `dsh.profile.bundles` 多一项(bundle 机制会读本包 `package.json` 的 `dsh.bundle.patch`)。
219
+ `pnpm` 报 missing peer(`@deepseek-ai/cordis` 等)是**设计如此**:peer 由宿主 closure 提供。
220
+ 三个 profile 里 `--dump-config` **不加载**插件(只打印树),所以 dump-config 里看不到插件的
221
+ `credentials file:` / `relay ready` 日志——那是启动时才打的。
222
+
223
+ **一键版**(给用户用,2026-10-14 实测):上面三步 + timed 切换 + dump-config 验收打包成一条命令,
224
+ 幂等(marker 判重)、改前带备份、新建 profile 的 `[]` 空模板会整体替换而不是追加
225
+ (`[]` 后追加块序列是非法 YAML,实测炸过):
226
+
227
+ ```powershell
228
+ & powershell -NoProfile -ExecutionPolicy Bypass -File deploy/windows/install-user.ps1 `
229
+ -Profile <profile> -DshCli "<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd" `
230
+ -Source .\dsh-push-host-0.3.1.tgz
231
+ # 成功后照打印的 next steps 走:重启 DSH(工具注册是冷的)→ 用新会话(变体钉在会话创建时)
232
+ ```
233
+
234
+ ## 设置页开关:手机作答模式(v0.2.1,用户不用碰命令行)
235
+
236
+ 装 0.2.1 后,设置页出现 **dsh-push 手机作答模式** 一行:显示**实际**是 `timed`(手机可作答)
237
+ 还是 `legacy`(手机只能收通知),右侧一个「启用」按钮。点一下即完成上面「一键版」里
238
+ `tool-ask-user` 那步的全部工作——**且不需要重启宿主**。
239
+
240
+ > **0.2.0 是坏版本,别用**(2026-10-14 生产 P0):它的 `askUserMode` 默认 `legacy`,把
241
+ > 「用户没表态」当成「用户要 legacy」,开机 sync 直接把生产 `tool-ask-user` 从 timed 反向
242
+ > 降级成 legacy 并写进 patch 文件;回滚文件后活进程又被 reconcile 触发再降级两次。0.2.1
243
+ > 的守卫(下面表格)逐条对着这三个缺陷修。
244
+
245
+ 内部链路(都已源码核实 + selftest 覆盖):
246
+
247
+ | 环节 | 实现 |
248
+ |---|---|
249
+ | 客户端 UI | `lib/client.js` 手写 `window.__ModuleLoader__.load({id:"dsh-push-host"})` 模块,注册 `settings.general.item` 槽行,`whileServed('push-host')` 门控;`package.json` 加 `dsh.client:{platform:"web"}` + `exports "./client"` |
250
+ | 状态判定与展示 | 宿主侧 `ctx.tools.get("ask_user_question")` 看 schema 是否含 `timeout` 参数(= timed 变体,判据同 DSH 的 `isTimedAskUserQuestionSchema`);实际态经 `Symbol.for('cosmokit.volatile.write')` 写进 volatile `askUserActual`(**纯内存不落盘**),设置行读它——**不拿期望态当展示值**;`tools/change` 补激活顺序坑 |
251
+ | 客户端→宿主 | 设置行写 push-host 自己的 `.volatile()` 字段 `askUserMode`(**无默认值**:schemastery 不加 `.default/.required` 即 optional——注意它没有 `.optional()` 方法也没有 `z.literal`);宿主订阅 `ctx.on('loader/volatile-update', paths)`(该事件只投 owning fiber)触发 |
252
+ | 永不降级 | **只有显式 `timed` 会写**;未表态或 `legacy` 完全惰性(零写入零备份);`configEditor.edit` 前还有最后一道闸断言 `desired === 'timed'` |
253
+ | 每进程一次 | 成功写一次后置 `done`,后续 sync 直接 return——外部修复(还原文件→reconcile)不会被活进程当成漂移再改回去 |
254
+ | HMR 事务 | volatile-update 在 settings RPC 的 HMR 事务内触发,事务里嵌套 `configEditor.edit` 会抛 `HMR transactions cannot be nested`;所有 sync 经 `setTimeout(0)` 延后到事务外合并执行 |
255
+ | 写入 | `ctx.configEditor.edit(tool-ask-user entry, …)`——DSH 官方通道:文件锁 + 原子写 + 失败回滚 + 热 reconcile;写非 volatile 字段会热重启该插件 fiber,timed 工具当场重注册 |
256
+ | 备份 | `configEditor` 无内建 .bak,edit 前自己 `copyFileSync(patchPath, patchPath + '.bak-<ms>')`(撞名加序号) |
257
+ | 失败可见 | 任何失败(含 home 层/CLI overlay 覆盖同 id 的 "overridden by a home patch")经同一 volatile 符号写进 `askUserStatus`(**内存,不落盘**——0.2.0 曾把状态字符串写进用户 patch),设置行直接显示原因 |
258
+
259
+ 生效范围(UI 上明写,也都是平台事实):**新会话立即生效**;已在跑的老会话保持旧行为——
260
+ `ask_user_question` 的 timed/legacy 变体在**会话创建时**钉进会话 header,改配置/重启都不换
261
+ (见下文「网页提问卡的生命周期」)。若老会话踩到 legacy 僵卡,点卡片「跳过」无害解锁。
262
+ 本开关**只升不降**:要回 legacy 手动改 profile 的 `cordis.patch.yml`(`mode: legacy`)。
263
+
264
+ 无 UI 的场景(headless/VM/自动化)继续用 `deploy/windows/install-user.ps1`,两者干的是同一件事。
265
+
266
+ **selftest 运行时注意**(2026-10-14 实测坑):必须用 **DSH 自带 runtime 的 node**
267
+ (`<DSH 安装目录>\resources\runtime\primary-runtime\dependencies\node\bin\node.exe`)。
268
+ harness 自己的 `~\.dsh\dsh-runtimes\...` 那个 node 同为 v24.21.0、`os.tmpdir()` 同为 `C:\Temp`,
269
+ 但 `copyFileSync` 被拒(`EPERM: operation not permitted, copyfile`);14 段前有 preflight
270
+ 探针,撞到这个坑会打印指引并 **exit 2**(不是假 FAIL)。真基线 **136/136**(2026-10-17 复跑实测;v0.2.2 加 3 条 hash 片段断言;v0.3.0 加 32 条发号断言:URL 拼装、通行证文本、名字清洗、HTTP 四种结果、凭据写回与备份与别名同步、面板状态机含「同一个 seq 绝不重复发号」、**列表载荷瘦身后的形状**(每行只有 `name`+`topics`)、**`CREDENTIAL_KEYS` 与 `CREDENTIAL_CONFIG_KEYS` 必须同步**(第三条防线,见下)、fiber 热重启后回灌进程级暂存、垂死 fiber 把结果交接给新 fiber)。
271
+
272
+ ## 点推送直达来源会话(v0.2.2)
273
+
274
+ 用户「通过消息通知上的卡片的 dsh 网页按钮进入网页后,似乎是之前默认的对话,能进入后直接进入推送通知的那个对话吗」。
275
+
276
+ 两半都在本插件里,**不需要改 DSH 本体、不需要发 App 版本**:
277
+
278
+ 1. **服务端半**(`lib/index.js:webUrl(sessionId)`):把来源会话 id 作为 **URL hash 片段**拼到 `#web` 链接尾
279
+ (`…?token=X#session-<uuid>`)。两条推送路径都带:提问(`questionBody(callId, questions, sessionId)`)
280
+ 与任务结束(`onTurnStopping`)。
281
+ 2. **浏览器半**(`lib/client.js:revealFromHash(ctx)`):读 hash,若当前会话不是它,就展开侧栏、点
282
+ `[data-row-key="session:<id>"]` 那一行;到位后把 hash 抹掉(`history.replaceState`),免得用户切走后
283
+ 刷新又被拽回来。
284
+
285
+ **为什么是 hash 片段而不是查询参数**(两条实测理由,别再改回去):
286
+
287
+ - `?token=` 入口回的是 **`303 See Other → Location: ./`**,查询串在这一步被丢掉;而浏览器跨跳转**保留 fragment**。
288
+ - 网页客户端**根本没有路由**(`createRouter` 在 bundle 里 0 命中;`pushState`/`popstate` 的命中都在 lodash 注释里),
289
+ 所以路径和查询串都没人读。
290
+
291
+ **选择器全是语义化的**(不依赖哈希类名,改版风险低):当前会话 `[data-conversation-session="<id>"]`、
292
+ 侧栏行 `[data-row-key="session:<id>"]`、展开侧栏 `button[aria-label="Open sidebar"]`。这些都是 2026-10-17
293
+ 在 VM1 的 DSH Web 上实测出来的(窄屏 360 CSS px,模拟器 WebView + CDP)。
294
+
295
+ **降级安全**:id 形态不对(不是 `session-<uuid>`)就不拼链接;网页端找不到那一行(会话未加载、分组收起)
296
+ 就静默放弃,最多 20s,等于本功能之前的行为。不认识 hash 的旧网页端也完全不受影响。
297
+
298
+ ## 自助通行证发号面板(v0.3.0,多人自由使用)
299
+
300
+ 用户「我希望能在插件里面有按钮什么的自动生成一个给手机用的通行证,这样的话才能让多人都能自由的使用我们的插件」。
301
+
302
+ **为什么必须有中继侧的服务**(不是插件能自己解决的):我们的 ntfy 2.29.0 里 access-token 的 HTTP API **根本没编译进去**
303
+ (`grep -a -c /v1/tokens /usr/bin/ntfy` = 0),`POST /v1/users` 能建用户但**静默忽略** topic 授权 ⇒ 按 topic 授权与发 token
304
+ **只能在服务器上跑 ntfy CLI**。所以中继上跑了一个极小的发号服务 `dsh-provd`(见仓库 `relay/README.md`),
305
+ 本插件是它的 HTTP 客户端。
306
+
307
+ **新配置项**:
308
+
309
+ | 键 | 默认 | 说明 |
310
+ |---|---|---|
311
+ | `provisionBase` | `https://ntfy.cpyh.xyz/provision` | 发号服务基址(经 edge nginx 反代到 `127.0.0.1:2588`) |
312
+ | `provisionToken` | 空 | 发号服务的共享密钥(服务端 `/etc/dsh-provd/secret`)。**优先用环境变量 `DSH_PUSH_PROVISION_TOKEN`**,别写进 git |
313
+ | `provisionTokenEnv` | `DSH_PUSH_PROVISION_TOKEN` | 上面那个环境变量名 |
314
+
315
+ 三项都在 `CREDENTIAL_CONFIG_KEYS` 里 ⇒ 可以放凭据文件(分层:默认 < 凭据文件 < profile < env)。
316
+
317
+ **面板长在哪**:DSH 网页「设置 → 通用」里新增一行「通行证」(`settings.general.item`,id `push-host-pass-panel`,order 130,
318
+ 就在「手机作答模式」下面)。输入框预填宿主建议的名字(`provisionDefault`,由 `host`/`webName` 清洗而来,中文/空名退化成
319
+ `dsh-local`),三个动作:**发通行证**、**刷新列表**、每条已发通行证后面的**吊销**。
320
+
321
+ **往返机制**(浏览器只能写 volatile 字段,这是实测出来的硬约束):
322
+
323
+ ```
324
+ 浏览器 scope.mutate 写 volatile: provisionAction + provisionSeq(+ provisionName)
325
+ ↓ ctx.on('loader/volatile-update') 命中 provision* ⇒ setTimeout(0) 出 HMR 事务
326
+ 宿主 PassProvisioner.run() ⇒ POST {provisionBase}/issue|revoke、GET /list
327
+ ↓ setVolatileValue 只写内存镜像(绝不 configEditor,免得把结果字符串写进 cordis.patch.yml)
328
+ 浏览器读 provisionResult(通行证文本)/ provisionList(JSON 列表)/ provisionDone(== 本次 seq 就收工)
329
+ ```
330
+
331
+ - **`provisionSeq` 是浏览器生成的请求号**:同一个名字连点两次也必须各得一次结果,所以不能只看 action 字段的变化;
332
+ 宿主对**同一个 seq 绝不重复发号**(selftest 有这条)。页面 busy 用 `done === seq` 判,另加 30s 兜底防宿主不在。
333
+ - **发号成功会写回本机凭据文件**(`topic` + `ntfyToken`=只写 token + `phoneToken`=只读 token),写前自动备份
334
+ `credentials.json.bak-<ts>`、空值绝不覆盖已有密钥、目录不存在会建好。**重启本插件 fiber 后**才按新 topic 发布。
335
+ - **通行证文本格式**:`dshpush://v1?base=<中继>&topic=<topic>&token=<只读 token>` —— App v1.33.0 起解析它;
336
+ 只粘一串 `tk_…` 的老通行证在 App 里仍按默认 topic `dsh` 处理,所以现有手机不会被这次改动破坏。
337
+ - 发号失败(401 无密钥 / 409 名字占用 / 服务不可达)都在面板上显示中文原因,**不动本机凭据文件、不影响已工作的推送链路**。
338
+
339
+ ## 给一个真实用户开通(凭据文件 + 配对)
340
+
341
+ **目标**:开通一个用户,操作者要做的只有两件事 —— 在 relay 上跑一条开号命令、把打印出来的
342
+ 配对载荷存成凭据文件。不需要给用户的 DSH 进程配环境变量,也不需要把令牌写进任何 YAML。
343
+
344
+ ```
345
+ ① relay 上:bash deploy/relay-user-add.sh alice
346
+ ├─ ntfy 用户 dsh-alice-pub(topic dsh-alice 只写)/ dsh-alice-phone(同 topic 只读)
347
+ ├─ relay 主机条目 host=alice + hk_ 队列令牌
348
+ └─ 打印配对载荷 JSON ──┐
349
+ │ 存成 ~/.config/dsh-push/credentials.json(原始 JSON 直接存)
350
+ ② 用户的 DSH 主机上 ────────┘
351
+ └─ dsh-push-pair # 同一份载荷,原样输出给手机
352
+ └─ dsh-push-pair verify # 真打 relay/ntfy,逐项 PASS/FAIL
353
+ ③ 手机 App:粘/扫 载荷里的 relay + topic + readToken
354
+ ```
355
+
356
+ ### 凭据文件(推荐路径,环境变量仍可用)
357
+
358
+ 插件读 `~/.config/dsh-push/credentials.json`(可用 `DSH_PUSH_CREDENTIALS` 或配置项
359
+ `credentialsFile` 指到别处)。**直接就是 `relay-user-add.sh` 打印的那份载荷**,字段名一一对应:
360
+
361
+ ```json
362
+ {
363
+ "v": 1,
364
+ "relay": "https://ntfy.cpyh.xyz",
365
+ "host": "alice",
366
+ "topic": "dsh-alice",
367
+ "writeToken": "tk_…",
368
+ "readToken": "tk_…",
369
+ "hostToken": "hk_…",
370
+ "regToken": "…"
371
+ }
372
+ ```
373
+
374
+ > `regToken` 可以写进凭据文件(2026-10-13 起;env 仍优先),因为是**一份文件带走整条链路**这个
375
+ > 设计目标的一部分。不想把服务器侧秘密落到文件里的人,继续用 `DSH_PUSH_REG_TOKEN` 即可 ——
376
+ > 但**两条路至少要有一条**,否则 `POST /answer/register` 会 401,手机只能收通知、不能作答
377
+ > (见下文坑 ②)。
378
+
379
+ | 载荷字段 | 插件侧含义 | 谁用 |
380
+ |---|---|---|
381
+ | `relay` | `relayBase`(也用来拼手机作答页 `/answer/<callId>`) | 主机 |
382
+ | `host` | `host`,relay 队列的 hostId | 主机 |
383
+ | `topic` | `topic`,本机的 ntfy 主题 | 双方 |
384
+ | `writeToken` | `ntfyToken`,ntfy **写**令牌(只写权限) | 主机(插件推通知) |
385
+ | `readToken` | `phoneToken`,ntfy **读**令牌(只读权限) | 手机 App 订阅 |
386
+ | `hostToken` | `hostToken`,relay 队列 `X-Host-Token` | 主机(取答案/ack) |
387
+ | `regToken` | `regToken`,relay 注册令牌 → `X-Reg-Token` | 主机(注册问题) |
388
+
389
+ 容错与顺序(都实测过):
390
+
391
+ - 键名大小写/连字符不敏感,并接受别名:`writeToken`/`ntfyToken`/`pushToken`/`tkPub`、
392
+ `readToken`/`phoneToken`/`tkRead`、`relay`/`relayBase`/`base`、`host`/`hostId`。
393
+ - 多用户一份文件也行:`{"relay": …, "host": "alice", "hosts": { "alice": { … }, "bob": { … } }}`,
394
+ 根字段是默认值、`hosts[host]` 覆盖它。
395
+ - 覆盖顺序(后者赢):**内置默认 → 凭据文件 → profile/overlay 配置 → 环境变量**。
396
+ 所以老部署(只给三个环境变量)行为完全不变。反向也成立:**显式写下的空串仍算"没设置"**
397
+ (见下文坑 ①),所以一份正常的凭据文件不会被 profile 里 schema 校验出来的空默认值擦掉。
398
+ - **默认路径锚在 home 目录,与进程 cwd 无关**(2026-10-13 修,见下文坑 ⑥):
399
+ `~/.config/dsh-push/credentials.json`。你自己在 `credentialsFile` 里写的**相对**路径仍然
400
+ 按 cwd 解析(老行为不变)。
401
+ - **没有这个文件 = 干净的 no-op**:插件不报错、不抱怨、不打网络,退化成透明监听器(和今天一样)。
402
+ 文件存在但读不了/JSON 坏了,才打一行 `credentials ignored: …` 并继续。
403
+ ⚠ 正因为是静默 no-op,"路径算错了"和"还没开通"在日志里长得一模一样:**启动只信
404
+ `credentials file: <path>` 那一行**(文件被找到才打)。
405
+
406
+ ### 配对载荷:一条命令
407
+
408
+ `lib/pairing.mjs`(npm bin 名 `dsh-push-pair`,也可 `node <包>/lib/pairing.mjs`):
409
+
410
+ ```powershell
411
+ & $node dsh-push-host\lib\pairing.mjs payload --pretty # 打印给手机粘的载荷
412
+ & $node dsh-push-host\lib\pairing.mjs verify # 真打 relay/ntfy 自检(5 项)
413
+ & $node dsh-push-host\lib\pairing.mjs verify --json # 机器可读
414
+ & $node dsh-push-host\lib\pairing.mjs payload --credentials-file D:\creds\alice.json
415
+ # v0.1.1:把网页入口一并写进载荷(存成凭据文件后插件即带 #web 直达链接,基址=手机可达的地址)
416
+ & $node dsh-push-host\lib\pairing.mjs payload --pretty --web-base http://100.x.y.z:3080 --web-name 我的机器
417
+ ```
418
+
419
+ `payload` 输出**与 `relay-user-add.sh` 逐字同构**(`v` = 1、同样 7 个字段、同样字段顺序),
420
+ 所以不存在第二套 schema;缺字段时**拒绝输出**并列出缺哪几项,不会吐半份。
421
+ `webBase`/`webName` 是**可选追加字段**(v0.1.1):知道才写、不知道就不写(7 字段老载荷原样),
422
+ `relay-user-add.sh` 不知道用户的网络所以从不输出它们——只有跑配对命令的人知道手机该拨哪个地址。
423
+
424
+ `verify` 检查(只读 + 最多一条测试通知,不动别人的队列、不改 relay 状态):
425
+
426
+ | 检查 | 通过判据 |
427
+ |---|---|
428
+ | `credentials-file` | 文件存在、JSON 合法 |
429
+ | `payload-fields` | 7 个字段都齐 |
430
+ | `relay-host-token` | `GET /answer/pending?host=<host>` + `X-Host-Token` → 200 |
431
+ | `ntfy-read-token` | `GET /<topic>/json?poll=1` + `Bearer readToken` → 200 |
432
+ | `ntfy-write-token` | `POST /`(ntfy JSON 发布)+ `Bearer writeToken` → 200 |
433
+
434
+ 实测(真实 relay,占位/换掉的令牌):`ntfy-read-token` 用真读令牌 = `HTTP 200`;换成别的 topic
435
+ = `HTTP 403`(ntfy 的 topic 级 ACL);`relay-host-token` 用假 `hk_` = `HTTP 401 bad host token`。
436
+ 即"令牌对不对"这件事在手机上动手之前就能判出来。
437
+
438
+ > **App 侧现状**:App 目前没有导入按钮——relay / topic / readToken 仍要在 App 里手填
439
+ > (见仓库 `README.md` 的"APP 侧")。导入路径的提案(不改 App,只出方案)在
440
+ > `openspec/changes/dsh-push-distribution/specs/companion-app/spec.md`。
441
+
442
+ ### 一个用户跑起来之前需要什么(清单)
443
+
444
+ 1. **relay 上跑过开号命令**:`bash deploy/relay-user-add.sh <user>`(拿到 `hk_` 与两个 `tk_`)。
445
+ 2. **载荷存成了凭据文件**:`~/.config/dsh-push/credentials.json`(或 `DSH_PUSH_CREDENTIALS`)。
446
+ 3. **`dsh-push-pair verify` 5/5 PASS**(relay/ntfy 都通)。
447
+ 4. **插件装进目标 profile**:`dsh plugin --profile <p> add dsh-push-host`,
448
+ `--dump-config` 尾部有 `- id: push-host`。
449
+ 5. **`tool-ask-user` 是 `mode: timed`**(否则手机只能收通知、答案落不了地,见上文硬约束 1):
450
+ ```yaml
451
+ - id: tool-ask-user
452
+ config: { mode: timed, timeout: 120 }
453
+ ```
454
+ 6. **旧的 hook 提问推送已关**(`deploy/windows/hooks.json` 去掉 `PreToolUse` 那组),否则双推。
455
+ 7. **手机 App 已填** relay + topic + readToken 并订阅成功(App 里能看到 ntfy 的连上状态)。
456
+ 8. 收尾:让用户提一个真问题、在手机上答一次,确认 agent 真的收到答案。
457
+
458
+ ## 配置
459
+
460
+ 非机密项放 profile 的 `cordis.patch.yml`,或 `--patch` 覆盖层里按 id 定向;**机密项优先走上面的
461
+ 凭据文件**(文件优先级低于 profile 配置,所以 YAML 里显式写的值仍然能压过文件):
462
+
463
+ ```yaml
464
+ - id: push-host
465
+ config:
466
+ host: test1 # deploy/relay-user-add.sh 开出来的 hostId
467
+ topic: dsh-test1 # 该 host 的 ntfy topic
468
+ webBase: https://<tailnet 网关> # #web 直达链接基址;不配则回退 web.log 里 DSH 自己打印的 URL(v0.1.1)
469
+ webName: 台式机 # 多 DSH 入口选择器的显示名(#name)
470
+ # credentialsFile: D:\creds\alice.json # 不写就用 ~/.config/dsh-push/credentials.json
471
+ ```
472
+
473
+ **令牌不要写进 YAML**:可以来自凭据文件(推荐),也可以来自环境变量(老部署,仍然支持,
474
+ 且优先级最高):
475
+
476
+ | 环境变量 | 含义 |
477
+ |---|---|
478
+ | `DSH_PUSH_HOST_TOKEN` | `hk_…`,relay 队列端点(`/answer/pending`、`/answer/ack`)的 `X-Host-Token` |
479
+ | `DSH_PUSH_REG_TOKEN` | relay 的注册令牌(服务器 `/etc/answer-relay/regtoken`) |
480
+ | `DSH_PUSH_NTFY_TOKEN` | `tk_…`,该 host 的 ntfy 写令牌(topic 权限 write-only) |
481
+ | `DSH_PUSH_CREDENTIALS` | 凭据文件路径(等价于配置项 `credentialsFile`) |
482
+
483
+ 其余字段(均带默认值,缺省也能挂载,不会拖垮 host 启动):
484
+
485
+ | 字段 | 默认 | 说明 |
486
+ |---|---|---|
487
+ | `relayBase` | `https://ntfy.cpyh.xyz` | relay 基址,也用于拼手机作答页 `/answer/<callId>` |
488
+ | `answerBase` | 空 = 用 `relayBase` | 作答页另走别的域名时用 |
489
+ | `host` / `hostToken` | 空 | 二者任一为空 → 整个 relay 侧停用(只当透明监听器) |
490
+ | `credentialsFile` | 空 = 默认路径 | 空串表示 `$DSH_PUSH_CREDENTIALS` 或 `~/.config/dsh-push/credentials.json` |
491
+ | `pollIntervalMs` | `3000` | 队列轮询间隔(500–60000) |
492
+ | `pushQuestions` | `true` | 关掉就只提问不推送 |
493
+ | `notifyTurns` | `true` | 轮次结束(`agent/turn-stopping`)推 `DSH 任务结束`,即旧 `Stop` hook 的等价物 |
494
+ | `echoAnswers` | `true` | 作答后发 `#ans` 静默回声(撤手机通知;relay 自己的回声写死在 topic `dsh`,多租户下到不了你家 topic) |
495
+ | `claimForeground` | `false` | 打开则**抢前台**:把问题挂在 waterfall 上,手机答得更早;代价是与 Web UI 竞速,见下 |
496
+ | `webLog` | `~/.dsh/web.log` | 只用来取最后一个 `?token=` 拼 `#web` |
497
+ | `webBase` / `webName` | 空 | `#web` 直达链接基址 / `#name` 实例名。**`webBase` 留空不再等于没有网页入口**(v0.1.1):插件直接改用 web.log 里 DSH 自己打印的那条 URL——能不能打开取决于 DSH 实际绑的地址(绑 `127.0.0.1` 就只对本机可达)。手机要从别的网络打开,就把手机可达的地址写进 `webBase` |
498
+ | `log` | `true` | `ctx.logger` + stdout 诊断 |
499
+
500
+ ### `claimForeground` 为什么默认关
501
+
502
+ 通知层骨架的取舍(research 作者建议、本插件采用):**监听器 prepend + `return next()`**,
503
+ 前台作答权留给 DSH Web UI —— 浏览器在线时体验与现在完全一致,插件只在旁边推通知。
504
+ 代价是"窗口期内的手机作答"必须等前台等待结束(timed 模式的 timeout,或 UI 作答)。
505
+ 实测(2026-10-13,台式机 c-full):timed `timeout:180` 的提问,手机在 t+30s 经 relay 作答 →
506
+ 插件日志 `state=open` 排队重试 → t+180s 工具返回 `pending` → 提问进投影(continued)→
507
+ 下一轮 poll 立即 `answered (continued)` 折回会话。**手机优先的用户应打开 `claimForeground`**,
508
+ 否则每次手机作答都要白等剩余超时时间。
509
+ 打开 `claimForeground` 后插件会 hold 住 waterfall 并把手机答案与 UI 答案赛跑(谁先到谁生效),
510
+ `mode: legacy`(无 timeout、永久阻塞)的问题也能被手机答,但它与 UI 抢答权,属于实验开关。
511
+
512
+ ## 三个触发点(旧 hook 的逐项等价物)
513
+
514
+ | 旧 hook / 外部桥 | 本插件 | 说明 |
515
+ |---|---|---|
516
+ | `PreToolUse`(提问推送)| `user-questions/request` waterfall(prepend) | 拿到的是服务词汇表的问题对象 + `wait.timed`,比读工具入参更准 |
517
+ | 同上(兜底) | `tools/pre-execute` | **竞位兜底**:waterfall 里"后 prepend 的作答器"会排到本插件前面,若它直接返回答案而不调 `next()`,waterfall 监听器就永不执行(实测:probe 插件 autoAnswer 就是这样)。两个触发点都按 `exec.callId` 去重,正常情况只推一次,且 waterfall 那次会把 `timed` 校正回来 |
518
+ | `PostToolUse`(作答回声 `#ans`)| `tools/post-execute` | 工具带着答案返回 → 推 `#ans`。**注意**:结果是 `{pending:true}`(timed 等超时未答)时**不推**——那问题还能从手机答,推了会把卡片错误地置为已答(旧 hook 在这里会误推,属已知缺陷) |
519
+ | `Stop`(任务结束推送)| `agent/turn-stopping` | 取 `agent.session.header.id` / `.cwd`,按 `session:turn` 去重,priority 3,正文 `目录: <cwd>` + `#web` + `#name`,与 hook 逐字一致;推送是 fire-and-forget,失败不影响轮次 |
520
+ | 外部 answer bridge | `ctx.interval` 轮询 relay 队列 | 见上文;不依赖 `/api/remote.mux` 与本机 cookie |
521
+
522
+ ## 切到"正在用的" profile(生产部署,需用户确认)
523
+
524
+ **前提**:`tool-ask-user` 在 `mode: timed` 才会 fold 投影 —— 否则手机只能收通知、不能作答(见上文硬约束 1)。
525
+
526
+ ```powershell
527
+ $dsh = '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd'
528
+ $profile = 'web' # 生产 profile 名
529
+
530
+ # 1. 备份要改的两个文件(它们不在 git 里,没有回滚路径)
531
+ Copy-Item "<dshHome>\profiles\$profile\cordis.patch.yml" "<dshHome>\profiles\$profile\cordis.patch.yml.bak-plugin"
532
+ Copy-Item "<dshHome>\profiles\$profile\package.json" "<dshHome>\profiles\$profile\package.json.bak-plugin"
533
+
534
+ # 2. tool-ask-user 切 timed:把这段追加到 profiles\$profile\cordis.patch.yml
535
+ # (id 定向 patch 会整块替换该条目的 config,tool-ask-user 本来没有 config,所以只写这两个键)
536
+ # - id: tool-ask-user
537
+ # config:
538
+ # mode: timed
539
+ # timeout: 120
540
+ # 3. 插件 + 三个令牌(令牌走环境变量,不写 YAML)
541
+ & $dsh plugin --profile $profile add "link:F:/work/work_dsh/dsh-push/host-plugin"
542
+ & $dsh --profile $profile --dump-config | Select-Object -Last 3 # 期望看到 - id: push-host
543
+
544
+ # 4. 关掉 hook 的提问推送(避免双推):deploy/windows/hooks.json 去掉 PreToolUse 里
545
+ # ask_user_question / exit_plan_mode 的组,或整体停用该桥插件;Stop 推送可留可撤(插件已接管结束推送)
546
+
547
+ # 5. 重启宿主,判据是"端口持有者 PID 变了"+ dump-config 行内容,不看 status 探针
548
+ ```
549
+
550
+ 令牌(给宿主进程的环境,不是给 App 的):`DSH_PUSH_HOST_TOKEN`(`hk_…`)、`DSH_PUSH_REG_TOKEN`(relay 注册令牌)、
551
+ `DSH_PUSH_NTFY_TOKEN`(该 host 的 ntfy 写令牌)。App 侧继续用生产读令牌 + topic `dsh`(App 的 topic 是硬编码的)。
552
+
553
+ **回滚**:`dsh plugin --profile web remove <包名>` + 把两个 `.bak-plugin` 覆盖回去 + 重启宿主;
554
+ hook 那边恢复 `hooks.json` 即可回到旧链路(两条链路互不破坏数据)。
555
+
556
+ ## 验收
557
+
558
+ ```powershell
559
+ # 1) 纯逻辑:假 ctx + 假 relay,48 项断言(含 open 期答案必须留在队列里的回归项)
560
+ & $dsh-node host-plugin\plugin-selftest.mjs
561
+ # 需要本地 node_modules/@deepseek-ai/schemastery(宿主本来自带,测试时用 junction 指过去)
562
+
563
+ # 1b) 开通层:凭据文件/优先级/缺文件 no-op/坏文件/配对载荷 + 坑 ①⑤ 的回归,54 项断言
564
+ & $dsh-node host-plugin\plugin-selftest-credentials.mjs
565
+
566
+ # 2) relay 客户端:真打公网 HTTPS(读/写/错误令牌/ack 空表)
567
+ node host-plugin\relay-selftest.mjs <hostToken>
568
+
569
+ # 2b) 配对自检:真打 relay/ntfy,逐项 PASS/FAIL(只读 + 一条测试通知)
570
+ node host-plugin\lib\pairing.mjs verify
571
+
572
+ # 3) 真机进程内冒烟:插 probe 插件造一个合成问题,看插件是否推送+注册
573
+ & $dsh --profile pushtest --patch <overlay 见下>
574
+ # [dsh-push-host] relay ready host=test1 topic=dsh-test1 poll=3000ms
575
+ # [dsh-push-host] question pq-… agent=(none) timed=false q=["probe-q1"]
576
+ # ⚠ 这条路**证明不了"答案落回 agent"**:合成问题没有活 agent,deliver() 会停在
577
+ # `no live root agent …`。要验最后一跳用 3b。
578
+
579
+ # 3b) 【推荐】"手机真的能答"的端到端验收 —— 全自动,不需要人碰手机:
580
+ powershell -NoProfile -ExecutionPolicy Bypass -File verify\host-plugin-phone-answer-e2e.ps1
581
+ # 真 agent 提 timed 问题 → 模拟器里的 App 自己作答 → 断言三处:
582
+ # register … -> HTTP 200 / probe timed=true / answered (continued) + agent 收到答案
583
+ # 期望输出 `SUMMARY: 11/11 checks passed`(脚本全文见 verify\host-plugin-phone-answer-e2e.ps1)
584
+
585
+ # 4) 看手机侧真实收到的内容(用该 topic 的读令牌)
586
+ curl -H "Authorization: Bearer <readToken>" "https://ntfy.cpyh.xyz/<topic>/json?poll=1&since=10m"
587
+
588
+ # 5) 从手机侧作答,看队列闭合
589
+ curl -X POST -H "Content-Type: application/json" \
590
+ -d '{"callId":"<callId>","answers":[{"id":"q1","selected":["second"]}]}' \
591
+ https://ntfy.cpyh.xyz/answer/submit
592
+ # → {"ok": true, "queued": true, "host": "test1"} 插件下一轮轮询取走并 ack
593
+ ```
594
+
595
+ ### 无人验收配方(2026-10-13 实弹,probe 只能证明"推送+注册",证明不了"答案落回 agent")
596
+
597
+ 合成问题(probe 的 `selfTest`)**没有活 agent**,所以 `deliver()` 走到
598
+ `no live root agent …` 就停下了 —— 它永远证明不了"手机答案被 apply 进 agent"。
599
+ 要验这一环必须有一个**真 agent 提一个 timed 问题**,再用模拟器里的 App 作答:
600
+
601
+ ```powershell
602
+ # ① 测试 profile(pushtest = dsh-base + dsh-headless + 本插件 + probe)加一层 overlay:
603
+ # - insert: tool-ask-user (mode: timed, timeout: 60) ← headless bundle 里没有这个工具
604
+ # - id: push-host config: { webName: e2e-verify, notifyTurns: false } ← 别往生产 topic 推结束通知
605
+ # - id: agent-default-model + llm-pi-ai ← 从 profiles/web/cordis.patch.yml 抄,指向同一个模型路由
606
+ & $dsh --profile pushtest --patch <overlay.yml> --dump-config # 先验:exit 0 + 尾部 - id: tool-ask-user (mode: timed)
607
+
608
+ # ② 让真 agent 提一个问题(单行 prompt!多行 here-string 会被 dsh.cmd 吃掉,见坑)
609
+ & $dsh --profile pushtest --patch <overlay.yml> 'Call ask_user_question exactly once ... print RESULT_JSON: ...'
610
+
611
+ # ③ 同时让模拟器里的 App 作答(笔电 DESKTOP-CPYH 的 emulator-5554,ssh 过去跑):
612
+ # 轮询 prefs 等带唯一标记的那条 push → 取 callId → 走 App 自己的作答路径
613
+ # a) UI 路径:am start -n xyz.cpyh.dshpush/.QuickAnswerActivity --es callId <cid> + input tap 选项
614
+ # b) 通知按钮路径(稳,uiautomator 常挂):am broadcast -n xyz.cpyh.dshpush/.AnswerReceiver \
615
+ # --es callId <cid> --es qid q1 --es label "<选项>" --ei nid <mid>
616
+ # 两条路都打 POST https://ntfy.cpyh.xyz/answer/submit(AnswerReceiver 与 QuickAnswerActivity 同协议)
617
+ ```
618
+
619
+ 看这三处断言(缺一不可):
620
+
621
+ | 断言 | 日志/证据 |
622
+ |---|---|
623
+ | ① 注册通了 | `[dsh-push-host] register <callId> -> HTTP 200` |
624
+ | ② 等待是 timed | probe 的 `callId = … timed=true`(权威)/插件的 `timed corrected to true by the request waterfall` |
625
+ | ③ 答案落回 agent | `[dsh-push-host] answered (continued) <callId>`(= `ctx.userQuestions.answer()` 返回 true)→ 紧接着 agent 侧收到一条 `answer_to_pending_question` user 消息 |
626
+
627
+ 坑:**模型显式传 `timeout: -1`**("必须等到答案")会走 legacy 路径,问题当场以
628
+ `{"error":"no user-questions answerer accepted the request"}` 返回 —— 这不是链路坏。
629
+ prompt 里要写明"不要传 timeout 字段"。
630
+
631
+ ## 依赖的 relay 契约(`deploy/answer-relay.py`)
632
+
633
+ | 端点 | 用途 |
634
+ |---|---|
635
+ | `POST /answer/register` | `{callId, agentId, questions:[{id,question,options:[str]}], hostId, ttl}` + `X-Reg-Token`;带 `hostId` 即进入队列模式 |
636
+ | `GET /answer/pending?host=X` | `X-Host-Token` → `{items:[{callId, agentId, qids, answers, ts}]}` |
637
+ | `POST /answer/ack` | `{host, callIds}` + `X-Host-Token` |
638
+ | `POST /answer/submit` | 手机提交;该问题有 `hostId` → 入队并回 `{ok, queued:true}` |
639
+ | `GET /answer/<callId>` | 手机/浏览器作答页(插件推送正文里的链接就是这个) |
640
+
641
+ 推送正文沿用 App 既有语法:`📱 作答: <answerBase>/answer/<callId>` + `#qid` / `#opts qid=a|b|c`
642
+ (仅单问题 2–3 选项且标签不含 `|=#`)/ `#web` / `#name`;作答回声 `#ans <callId> <摘要>`,
643
+ 优先级 1(静默)。问题推送优先级 4(App 走紧急通道)。
644
+
645
+ ## 范围与限制
646
+
647
+ - **三个触发点都在插件里**(提问推送 / 结束推送 / 作答回声);`deploy/windows/hooks.json` 那条
648
+ 旧 hook **必须保持退役**(现在是 `{"hooks":{}}`),否则双推。
649
+ - **`mode: legacy` 下手机只能收通知、不能作答**(见上文硬约束 1)。
650
+ - **模型显式传 `timeout: -1` 时也是 legacy 路径**:那个"必须等到答案"的调用没有 timed wait,
651
+ 手机答不了(问题会当场以 `NO_PROVIDER` 结束)。这是调用方的选择,不是链路故障。
652
+ - 手机作答经过 relay 队列,**队列里的 callId 带 `|`**:任何把它交给 shell 的地方都必须引号包住
653
+ (`--es callId 'call_00_x|uuid'`),否则被当管道符切断、静默失效(2026-10-13 实测踩到)。
654
+ - **不查历史**:插件只处理它自己经手(注册)的问题;别人用 hook 注册的问题没有 `hostId`,
655
+ 走 relay 的老 bridge 转发,不经过本插件(老 bridge 已于 2026-10-13 停用并禁用,
656
+ 回滚:`Enable-ScheduledTask -TaskName DshAnswerBridge` + `Start-ScheduledTask`)。
657
+ - **单实例单 host**:一个进程一个 `host`/`topic`;多台机器用 `deploy/relay-user-add.sh <user>` 各开一条。
658
+ - 令牌从**凭据文件或环境变量**读(都不写进 YAML);插件与宿主同进程同权限,装之前按第三方插件审一遍代码。
659
+ - **配对只做到"产出载荷"**:载荷怎么进 App 由 App 决定;App 目前没有导入按钮(提案见
660
+ `openspec/changes/dsh-push-distribution/specs/companion-app/spec.md`),所以手机上仍要手填三项。
661
+ - **本包不包含 relay 服务端**:`deploy/` 里的脚本与 `answer-relay.py` 是服务端侧,需要操作者自己在
662
+ 自己的主机上部署(见 `../deploy/README.md`);默认 `relayBase` 指向本项目的实例,别家部署要改。
663
+
664
+ ## 相关文档
665
+
666
+ - `../deploy/README.md`:relay/ntfy 服务端部署与每用户开号
667
+ - `../verify/README.md`:App 侧验收流水线与硬事实
668
+ - `F:\work\work_dsh\dsh-plugin-api-research.md`:`userQuestions` seam 的源码级契约(本项目外)