# dsh-interaction-notify — 接口契约(Lead 锁定,勿单方面修改) ## 目标 当 Harness 里的任务**需要用户交互**时(提问 / 授权审批 / 计划批准),弹出 **Windows 系统级通知** 并播放**声音提示**; **点击通知必须把 DeepSeek Harness 窗口带回前台**(同一个对话窗口)。 已知问题(用户实测反馈):上一版试验性通知点击后没有回到 DSH 窗口。 根因:重新拉起 exe 的第 2 个进程拿到前台权限后立刻退出,原进程的 `window.focus()` 被 Windows 前台锁拦截。 因此点击路径**不得依赖重新拉起 exe**,必须走 `assets/activate.ps1`(由 `wscript` 隐藏启动)主动 `SetForegroundWindow`。 ## 目录(`$DSH_HOME\plugins\dsh-interaction-notify\`) ``` CONTRACT.md 本文件(Lead 维护) package.json 插件清单(T1) cordis.patch.yml 插入 profile 的 bundle 补丁(T1) src/detection.js 纯函数:会话事件 -> 交互描述(T1) src/config.js 设置存储(T1) src/notifier.js 去重/节流/统计 -> 调用 deliver(T1) src/index.js Cordis 宿主插件:事件监听 + HTTP 路由(T1) src/deliver.js Windows 投递实现(Lead,勿改) lib/*.js src/ 构建产物(scripts/build.mjs,T1) assets/notify.ps1 弹出通知 + 播放声音(Lead) assets/activate.ps1 点击后聚焦/唤起 DSH 窗口(Lead) assets/activate.vbs 隐藏启动 activate.ps1 的 wscript 入口(Lead) assets/notify.wav 内置提示音(Lead 生成) scripts/build.mjs src -> lib 复制(T1) scripts/make-chime.mjs 生成 notify.wav(Lead) scripts/setup-shortcut.ps1 创建/删除带 AUMID 的开始菜单快捷方式(Lead) scripts/install.mjs 安装/卸载到 profile(T2) test/*.mjs 宿主半功能测试(T3) test/REPORT-*.md 验证证据(T3) README.md 使用说明(T2) ``` **没有 client/browser 半**:本插件只有宿主半,`package.json` 不要声明 `dsh.client`。 ## 触发源(宿主半,唯一可信来源) `ctx.on('session/event', (session, event) => ...)`(提交后 fire-and-forget,回调异常会被吞并记录,安全)。 | 事件 | 触发条件 | kind | |---|---|---| | `approval/asked` | 总是 | `approval` | | `tool/call` | `event.data.name === 'ask_user_question'` | `question` | | `tool/call` | `event.data.name === 'exit_plan_mode'` | `plan` | 事件形状(来自 DSH 宿主源码 `@deepseek-ai/dsh` 等包,勿猜): ```js session/event 的 event = { type, data, ... } // data 见下 approval/asked data = { id, toolName, callId?, reason? } tool/call data = { turn, step, callId, name, arguments } ask_user_question 的 arguments = { questions: [{ id, question, header?, options?, multiSelect? }] } exit_plan_mode 的 arguments = { plan } ``` `session/event` 在根上下文注册可收到全部 session 的事件(`dsh-user-approval`/`dsh-tool-ask-user` 都按此模式消费)。 ## 纯函数契约:`src/detection.js` ```js /** * @returns {{ kind:'approval'|'question'|'plan', id:string, title:string, body:string } | null} */ export function resolveInteraction(event) ``` - `approval`:`id = 'approval:' + data.id`,标题 `需要你的批准`, 正文 `工具「」需要你授权`(有 `reason` 时加 `:`,截断 120 字)。 - `question`:`id = 'question:' + data.callId`,标题 `需要你的回答`, 正文 = 第一个 `questions[0].question`(截断 140 字,去换行);`questions.length > 1` 时追加 `(共 N 个问题)`; 无 questions 时正文 `请打开对话回答问题`。 - `plan`:`id = 'plan:' + data.callId`,标题 `计划已就绪,等待你的批准`,正文 `点击查看计划并批准,或给出修改意见`。 - 其它事件返回 `null`。非对象/缺字段一律返回 `null`(绝不抛错)。 ## 设置契约:`src/config.js` - 落盘 `$DSH_HOME/dsh-interaction-notify/state.json`(原子写:`.tmp--` + rename,mode 0o600)。 - 默认值: ```json { "enabled": true, "events": { "question": true, "approval": true, "plan": true }, "sound": "chime", "volume": 55, "quietWhenFocused": true, "clickToReturn": true, "minIntervalMs": 1200 } ``` - `sound` 枚举 `chime | system | silent`;`volume` 为 0-100 整数(默认 55,第一版实测偏刺耳,已换成更柔和的 C5→E5 双击音);`enabled=false` 时任何事件都不投递。 - `load()` / `save(partial)` 深合并(`events` 单独合并),非法值回退默认;导出 `DEFAULT_CONFIG`、`normalize(raw)`。 - 读文件失败一律回退默认,不抛错。 ## 投递契约:`src/deliver.js`(Lead 已实现,T1 只调用) ```js export async function deliver(message, options) // message: { kind, title, body } // options: { sound?: 'chime'|'system'|'silent', volume?: 0-100, quietWhenFocused?: boolean, clickToReturn?: boolean } // 返回: { ok: boolean, skipped?: 'focused'|'unsupported-platform'|'disabled', code?: number, error?: string } ``` - 非 Windows 平台返回 `{ ok:false, skipped:'unsupported-platform' }`(宿主不得因此报错)。 - `volume` 传入 `assets/notify.ps1`,由 WPF MediaPlayer 实际控制音量(SoundPlayer 兜底时固定音高)。 - 投递为**fire-and-forget 语义**:调用方 `void deliver(...)` 或 `await`,都不得阻塞事件回调超过 ~15s; 失败只记日志,绝不影响会话。 - `deliver` 会自行落盘 `notifications.log` 之外的统计,不需要调用方重复写。 ## `src/notifier.js` ```js export function createNotifier({ deliver, config, logger }) => { stats, // { attempted, delivered, skipped, failed, lastAt, lastError } handleEvent(session, event), // 同步返回;内部决定是否投递 deliverNow(message, options), // /test 用:绕过 quiet/节流,仍尊重 sound } ``` - 去重:同一 `id` 只投递一次(Set,上限 200,超出丢最旧)。 - 节流:距上次投递 < `config.minIntervalMs` 时不投递也不排队(避免连发刷屏);但 `approval` 不受节流(安全相关)。 - 统计与日志:每次尝试写一行 JSON 到 `$DSH_HOME/dsh-interaction-notify/notifications.log` `{t, kind, id, ok, skipped?, error?}`;文件超过 400 行时只保留最后 200 行。 - 任何异常都必须 catch(`logger.warn`),不得让 `session/event` 回调抛错。 ## HTTP 路由(仅 loopback + 同源,复制 thinking-zh 的 `isAllowed`/`guard`/`readBody`) | 路由 | 方法 | 说明 | |---|---|---| | `/api/dsh-interaction-notify/state` | GET | `{ config, platform, rev, stats }` | | `/api/dsh-interaction-notify/set` | POST | 部分配置 `{ enabled?, events?, sound?, volume?, quietWhenFocused?, clickToReturn?, minIntervalMs? }` → `{ config, rev }` | | `/api/dsh-interaction-notify/test` | POST | `{ title?, body?, kind?, sound?, volume? }` → 立即投递测试通知 `{ ok, code?, error?, stats }` | | `/api/dsh-interaction-notify/log` | GET | `{ lines: [...] }` 最近 200 行通知日志 | | `/api/dsh-interaction-notify/diag` | POST | `{ name, payload }` 落盘 `$DSH_HOME/dsh-interaction-notify/diag/.json` → `{ ok, path }` | - 请求体上限 256 KiB,`readBody` 与 thinking-zh 一致。 - 所有 handler 包 try/catch,`writeJson` 统一 `content-type: application/json; charset=utf-8` + `cache-control: no-store`。 ## `src/index.js`(Cordis 宿主半) ```js export const name = 'dsh-interaction-notify' export const inject = ['webServer'] export function createPlugin(deps) // deps = { deliver, config?, logger?, paths? }(测试注入用) export function apply(ctx, config) // 生产入口:createPlugin({ deliver: realDeliver }).apply(ctx, config) ``` - `apply` 内用 `ctx.effect(() => { const dispose = makeRoutes(...).map(r => ctx.webServer.register(r)); const off = ctx.on('session/event', handler); return () => { off(); dispose.forEach(d => d()) } }, 'dsh-interaction-notify: routes + events')`。 - `ctx.logger?.('dsh-interaction-notify')` 拿 logger(不可用时用 `console` 兜底)。 - `$DSH_HOME`:`process.env.DSH_HOME` 否则 `~/.dsh`(与 thinking-zh 一致)。 ## 构建 / 安装 - `node scripts/build.mjs` 把 `src/*.js` 复制到 `lib/*.js`(与 dsh-thinking-zh 完全同构)。 - `node scripts/install.mjs --profile desktop [--dry-run] [--uninstall] [--exe ]` 1. profile `package.json` 加 `"dsh-interaction-notify": "link:../../plugins/dsh-interaction-notify"`; 2. 追加到 `dsh.profile.bundles`(最高优先级层); 3. `node_modules\dsh-interaction-notify` junction; 4. 调 `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/setup-shortcut.ps1 -Action install -ExePath -PluginRoot `(失败只告警,不中断安装); 5. 打印“重启 DSH 后生效”。 - 参考实现:`dsh-thinking-zh` 插件的 `scripts/install.mjs`(幂等 + 备份)。 ## 禁止 - 不改 DSH 安装目录下的任何文件(只读源码除外);不改其它插件目录。 - 不重启 / 不杀 DSH 进程;不执行 install.mjs(由 Lead 统一安装)。 - 不直接写 profile 清单(安装/卸载统一走 `scripts/install.mjs`)。 - 不引入 npm 依赖(只用 Node 内置模块 + PowerShell 5.1)。 ## 验收 1. `node scripts/build.mjs` 成功,`lib/` 与 `src/` 一致。 2. `node --test test/` 全绿(含:检测纯函数、去重/节流、设置合并、路由鉴权与 /test)。 3. `POST /api/dsh-interaction-notify/test` 在真实宿主里能弹通知 + 响铃(Lead E2E)。 4. 点击通知回到 DSH 窗口(Lead E2E)。