# 它是怎么工作的 这份文档写给**想读代码、改代码、或者排查疑难的人**:README 只讲用户能看到的事,凡是"为什么必须这样做""边界情况怎么处理""某个字段是干什么的",都在这里。 > 代码位置:浏览器半侧 `client.js`(全部提醒逻辑),宿主半侧 `index.js`(只做浏览器做不到的两件事)。 > 自检怎么跑、每一套断言覆盖什么,见 [CONTRIBUTING.md](CONTRIBUTING.md)。 ## 目录 - [1. 两半的分工](#1-两半的分工) - [2. 三条检测通道与边沿表](#2-三条检测通道与边沿表) - [3. 一次停止只报一次:分类与去重](#3-一次停止只报一次分类与去重) - [4. 子智能体过滤](#4-子智能体过滤) - [5. 点击通知:打开会话,并落到你这次提问](#5-点击通知打开会话并落到你这次提问) - [6. 通知本身](#6-通知本身) - [7. 审批按钮桥(Service Worker)](#7-审批按钮桥service-worker) - [8. 提示音](#8-提示音) - [9. 设置与持久化](#9-设置与持久化) - [10. 桌面端:点通知把窗口拉回前台](#10-桌面端点通知把窗口拉回前台) - [11. 检查更新与就地升级](#11-检查更新与就地升级) - [12. 有意保守的地方与已知限制](#12-有意保守的地方与已知限制) --- ## 1. 两半的分工 浏览器半侧(`client.js`)承载**全部**提醒逻辑:检测、分类、去重、提示音、通知、审批按钮、落点、设置页。宿主半侧(`index.js`)只多做两件页面做不到的事: 1. **桌面端点通知把窗口拉回前台**(第 10 节)——一条 Connection 精确 Fetch 路由 `POST /api/task-reminder/window-activation`; 2. **设置页底部「检查更新 / 立即更新」**(第 11 节)——两条路由,一条读 npm 上的最新版本,一条交给 DSH 自己的插件管理服务安装。 两条能力都不参与"提醒"本身:没有 web 服务器的组合里这些路由根本不注册,路由注册抛错也被吞掉,插件照样装载(自检里各有一条断言守着这一点)。三条路由都只接受 `POST`、都按 buffered 方式读请求体。 ## 2. 三条检测通道与边沿表 任务"停止"这件事有三个来源,插件三条全部订阅,但**共用同一张边沿表**,所以一次完成不会提醒两遍: | 通道 | 说明 | | :--- | :--- | | 宿主转发的 `api-session/status` 事件 | 桌面端上比较稀疏,不能单独依赖 | | 官方会话列表自身的 `running` 位 | 常规路径 | | `uiSession.sessionStatus` 快照里的 `running` 位 | 与侧边栏运行指示灯同源;fork 子会话的列表投影陈旧 / 不翻时,这条路仍然可靠 | 边沿表只记"这个会话上一次是不是在跑"。分类还在途时的重复边沿由**在途守卫**加**回合号判据**(下一节)双兜底。自检里有一条"边沿风暴"用例:无论通道怎么重复,每个回合最终恰好一条通知。 ## 3. 一次停止只报一次:分类与去重 停止到达时,插件读该会话持久日志里最后一条 `turn/end` 的 reason 来分类。分类结果分三种: | 分类结果 | 判据 | 处理 | | :--- | :--- | :--- | | **closed** | 拿到了本次回合的 `turn/end` | 按 reason:`completed`(含 `max-tokens` 截断)→ 报完成;`error` → 只报错误,正文取网关原文;`aborted` / `blocked` / `interrupted` → 不报 | | **unclosed** | 最后一条 `turn/start` 比最后一条 `turn/end` 新——本次回合的 `turn/end` 还没落盘 | 订阅该会话的事件流等它落盘;到截止(deadline)仍等不到 → **不弹窗、不放提示音**,只记一笔 `stats.suppressedUnclosed` | | **unreadable** | 完全读不到(会话不在册、读存储失败、日志里没有可用的 `turn/end`) | 仍按完成兜底,靠 5 秒对账与迟到纠正兜住 | 几个关键点: - **快路径:正在看的会话 0 次 RPC。** 会话已被 DSH retain(用户正开着它)时,插件借 `ctx.sessions.binding()` 拿到绑定,**同步**读事件窗口——弹窗与提示音在停止边沿的同一个同步段里发出,不先等一次 RPC。没 retain 的会话走一次 `sessions.using` + 订阅它的 eventSource 等 `turn/end` 落盘;不再做 3×120 ms、最多 4 次 RPC 的轮询重试。 - **不会把"点停止"误报成完成。** 以前看到"最后一条 `turn/start` 比 `turn/end` 新"只会重试等待,等不到就猜成完成;现在这是独立的 **unclosed** 态:等到 deadline 还不闭合就干脆不报——宁可这一次不提醒,也不弹一条"其实已经被取消 / 还没结束"的完成。 - **回合号判据(1.5.4 起)。** closed 时顺带取出 `turn/end` 的 `data.turn`,每个会话记下"最近一次结清的回合号"。同一个回合的重复边沿(通道重复、列表陈旧回放)**确定性丢掉,不看时间窗**;而上一轮刚停、新任务开始后 5 秒内完成的新回合照报——桌面端事件通道稀疏时,旧实现恰好会把这种完成整条吞掉。 - **5 秒窗口只留给少数方向。** `error → completion` 不报、`completion → error` 撤回先发的那条完成;以及**读不到回合号时的兜底**(`api-session/error` 事件本身不带回合号,兜底路径自己报的那次停止也没有号可记)。这里有意保守:宁可漏掉这 5 秒内的新回合,也不冒重复弹窗的风险。 兜底路径(unreadable → 先按完成报)之后,分类可能**迟到**,插件会纠正: - 迟到的 **`error`** → 用错误通知替换先前那条完成通知(原有行为); - 迟到的 **`aborted` / `blocked` / `interrupted`** → 撤掉先前那条兜底完成通知、不补错误(1.6.4 起); - 两种纠正都记 `stats.lateCorrections`。排障看 `state().stats.suppressedUnclosed` / `state().stats.lateCorrections`,就知道这次停止是"等到上限没等到"还是"报完又纠正了"。 ## 4. 子智能体过滤 lead 派出的子代理、以及其他智能体自己起的子智能体,停止都发生在各自独立的会话里——对插件来说只是又一个 `sessionId`,一个回合里很容易连响好几声,而你真正在等的那个对话还在跑。 判据只取官方会话列表行上的 `origin === 'subagent'`(也就是官方侧边栏用来把子会话挡在列表外的那个字段),并且**在边沿、分类、提示音、通知之前**就滤掉,三条检测通道与错误事件一律适用。fork 出来的会话带 `parentId` 但没有这个 origin,所以照常提醒。设置页的「子智能体提醒」开关可以把这类会话的完成 / 等待 / 出错重新打开,出厂默认关闭。 ## 5. 点击通知:打开会话,并落到你这次提问 DSH 没有对外暴露滚动接口(`openSession(target)` 不收参数,`ctx.uiConversation` 没有导航能力,聊天视图自己的滚动也不对包外导出),所以插件做的是"读者要做的事": 1. 打开对应会话; 2. 在会话流列里找到**你自己的最后一条消息**——`[data-chat-flow]` 列里 `[data-chat-flow-kind="user"]` / `"steering"` 的最后一行(跳过 hidden 行); 3. 把它对齐到对话滚动口顶部下方 **24 px**——和应用自己的"跳到第 N 轮"是同一个落点; 4. **连着复查几拍**:应用自己的滚动恢复可能晚于第一拍落地;某个方向已经滚不动(到顶 / 到底)也算就位,免得每一拍再叠一点像素把视口推走; 5. 滚动口取 `[data-conversation-scroll]`,取不到就用列的父节点; 6. 只有当前挂载的会话就是刚打开的那个(`data-conversation-session` 对上)才动 DOM,正在切换的视图不碰。 将来 DOM 变了,最多丢掉这一步,**绝不会影响"打开会话"**。结果记在 `state().stats.lastQuestionJump`:`aligned` / `no-column` / `other-session` / `no-question` / `no-scroller` / `timeout`。 `__dshTaskReminder.report()` 是给这一步排障用的:一行 JSON 里既有上次落点走到哪一步,也有 DSH 那套内部 DOM 的实况(挂载的会话号、会话流列 / 你的消息行 / 滚动口各自的条数与几何)——DSH 升级改了 DOM 时,看哪一项变成 0 / null 就知道断在哪。点通知"打开会话"那一步的留痕(`lastJump`,含 `verifiedBy`,用 DSH 自己的 `mainReference` 或 DOM 核对会话到底切没切)和通知点击有没有真的送达页面(`lastClick`)也在同一份输出里。 ## 6. 通知本身 - **通知 tag 每条独立**(1.5.5 的行为,1.6.2 恢复):后一条不顶掉前一条,通知中心里逐条留着;tag 现在带递增序号(1.6.4 起),同一毫秒里连发的两条也不会共用 tag。曾经试过"按类型固定 tag + `renotify`",看着更清爽,但 Windows 会替换上一条横幅,而**被替换的那条在通知中心里点不动**——Electron 在 Windows 上不投递通知中心条目的 click(electron#29461),现场表现就是"点了通知窗口抬不起来"。 - **「弹窗一直挂着」** 给五类通知都加上 `requireInteraction`:没被点过的横幅可能永远不把点击投递给页面,让横幅一直挂着,点击就必定送得到;代价是每个通知都要用户自己收掉。 - **等待类通知会自己收掉**:提问 / 方案待确认 / 退回普通样式的审批,在挂起消散(已回答 / 交互关闭 / 会话消失)时 `close()`,不在通知中心留"点了再也没有对应等待"的陈旧条目。未结清的等待通知所在会话列在 `state().waitingToasts`,正常很快清空;有常驻条目就说明有通知没收掉。 - **通知对象按对象身份保活**:非持久通知的 JS 对象一旦被回收,点击就再也到不了 `onclick`。页面级表 `window.__dshTaskReminderToasts`(一个 `Set`,上限 20 条,超出丢最老的)逐条按住它们,点击 / 关闭 / 等待消散时按身份释放——**不是按类型只留一条**:tag 已经每条独立,同类型本来就可以同时挂好几条,按类型挤掉会让旧条目的点击失效。插件卸载时不关闭用户还没点的通知,只清掉实例内的等待账本。 - **通知文案**:`通知语言`(跟随界面 / 简体中文 / English)只影响通知的标题、正文与按钮文字;设置页永远跟随界面语言。默认值是"跟随界面",设置页把当前**生效**的语言直接显示成选中的那一档——不用户不动它就是跟随界面,动了就是钉住,少一档、少一次决策。 - **正文规则**:完成=会话名;审批=**「工具名:理由」**(本地化的 `displayReason` 按通知语言取,退回未本地化的 `reason`,两者都空才退回会话名——1.6.1 修的 bug 就是这里以前恒等于会话名);提问 / 方案待确认=首个问题的原文(中文通知语言下先过一遍宿主文案对照,方案待审的原文是宿主写死的英文);出错=网关原文,取不到才退回会话名。拼好的正文按固定字数上限截断,免得系统通知拦腰切出难看的截痕。 ## 7. 审批按钮桥(Service Worker) 审批通知上的「同意 / 拒绝」需要 Service Worker,所以**同一份 `client.js` 既当页面、又当插件自己的 worker**:脚本 URL 取自启动图里自己那一行的 bundle URL。worker 只负责把"点了哪个按钮 / 点了正文"转回页面。 - 点击走页面级的路由表(`window.__dshTaskReminderClickRouter`),"点了打开会话"与"点了裁决"分开;普通通知对象本身也登记在页面级保活表里(`window.__dshTaskReminderToasts`,按对象身份,见第 6 节),热重载清空实例内状态后仍能对上号。带按钮的持久通知由 worker 持有,卸载前按 tag `closeTag` 收掉。 - 裁决调的是审批对象自己的 `answer('allowed-once' | 'rejected')`——和审批卡片上「允许一次 / 拒绝」同一个动词,不多放行任何东西;并且**不把窗口拽到前台**。 - 等待在别处(审批卡片、另一个标签页)被处理掉后,它的通知会被收掉;等待已经被新请求顶替时,陈旧通知再点也不会误裁(`stale` 路径直接 no-op)。 - 作用域:所有 DSH 客户端 bundle 都从同一个 `/plugins/` 路由发出,所以注册落在共享的 `/plugins/` 作用域上,一个 Origin 只能有一个这样的 worker。同类通知插件装两个会互相顶替,只有一个还能用按钮;两个都装还会让每次停止提醒两遍。 - 降级:用不了 worker 的环境(局域网 http 这类非安全上下文、`dsh-app://` 的桌面端)退回不带按钮的普通通知,并在设置页「系统弹窗」行下如实说明原因(`notify.approval.desktop` / `notify.approval.insecure` / `notify.approval.unavailable`)。`state().approvalBridge` 为 `active` 就是按钮可用;`test('approval')` 可以给自己发一条带按钮的测试通知(只回一条测试反馈,不裁决任何真实请求)。 ## 8. 提示音 - **四种合成音效**:两声(经典)、三声上扬、上升琶音、圆润三角波,全部用 Web Audio 现场生成,不带任何音频文件。每个音效有自己的频率与节奏表,峰值按音量缩放;三角波同等峰值听起来更响,所以"圆润"那一档的 peak 给得低一些。 - 提示音与窗口状态无关:只要最终判定为**要提醒**(完成 / 出错 / 在等你操作)都会响,跟弹窗时机无关;`弹窗时机` 只决定要不要发通知。分类为 unclosed 的停止不响(见第 3 节),被取消的回合也不响。 - 提示音与通知在同一次处理里发出:快路径(会话被 retain)下就在停止边沿的同步段;未 retain 时等 `turn/end` 落盘、分类出来后立刻发。 - **自定义音效**:字节存进浏览器的 IndexedDB(库名 `dsh.task-reminder`,不是 localStorage——本地存储那点配额装不下音频,而且是同步写),用 `decodeAudioData` 解码成 `AudioBuffer`,走同一个音量增益播放,首尾各 10 ms 淡入淡出,避免硬切的文件爆音。上限 5 MB。存不下或解不开(隐私模式、配额满、编码不支持)时行内如实说明并回落到第一种合成音效,不会变哑;下次装载以 IndexedDB 为唯一事实来源恢复。 - **首次提示音慢几秒**:Chrome 的首次音频渲染要启动系统音频设备(某些 Windows 机器上 3–5 秒),而自动播放策略只允许从用户手势触发这次启动。插件在装载时就排了一条听不见的预热音,并在用户手势时拉活挂起的 `AudioContext`。 ## 9. 设置与持久化 八个配置项的值落浏览器本地存储(`localStorage`,键前缀 `dsh.task-reminder.`),重启后仍在;自定义音效的**元数据**(文件名 / 大小)也在本地存储,**音频本体**在 IndexedDB。所以宿主半侧不需要注册任何设置命名空间。 | 配置 | 键 | 出厂值 | | :--- | :--- | :--- | | 系统弹窗 | `dsh.task-reminder.notify` | 开 | | 弹窗时机 | `dsh.task-reminder.notify-mode` | 任何情况都弹 | | 通知语言 | `dsh.task-reminder.notify-language` | 跟随界面(auto) | | 子智能体提醒 | `dsh.task-reminder.subagent` | 关 | | 弹窗一直挂着 | `dsh.task-reminder.sticky` | 关 | | 完成提示音 | `dsh.task-reminder.sound` | 开 | | 提示音音效 | `dsh.task-reminder.sound-choice` | 第一种(两声·经典) | | 自定义音效元数据 | `dsh.task-reminder.custom-sound` | 无 | | 提示音音量 | `dsh.task-reminder.volume` | 80 | 读出坏值时按默认处理(写回时才归一化),所以手改过本地存储也不会把设置页变成空白。**恢复默认**写回上表出厂值(通知语言跟随界面、音量 80 等),但**自定义音效文件会保留**——它是用户的素材而不是配置值,要删得在「自定义音效」行点「清除」。 设置页是独立一页(`设置 → 任务提醒`,设置导航 order 44),值用 `createSnapshotStore(value, { persist: { name } })` 持久化。 开发时的生效规则:宿主只在进程启动时读客户端产物,浏览器又会缓存旧 bundle;`dsh-client-hmr` 每 500 ms 轮询客户端产物的 mtime / ctime / size,页面上的插件会自动换新,但发布出去的包与 `dsh plugin add` 装的副本仍按"重启 + 硬刷新"最稳。 ## 10. 桌面端:点通知把窗口拉回前台 渲染进程拉不起被 Windows 最小化、或被 DSH 收进托盘的窗口:`window.focus()` 对这种窗口无效,只有主进程的 `focusPrimaryWindow()`(`restore()` → `show()` → `focus()`)能拉起来——它能被托盘点击和"再启动一份应用"(`dsh://open`,走 `second-instance`)触发,而插件页面既没有那条 IPC,也发不出外部协议。 所以点击通知时页面会 `POST api/task-reminder/window-activation`(宿主半侧注册的 Connection 精确 Fetch 路由;契约测试会拿宿主侧的绝对路径和页面必须用的相对形式对拍),宿主半侧随即再启动一份应用:新进程拿不到单实例锁、立刻退出,正在跑的那份把窗口拉到前台。 - 页面只认"确实在桌面端"这一个前提(`'dshDesktop' in window`),**每次点击都发**,不按 `document.hasFocus()` 短路——Electron 里窗口只是被挡住 / 藏起来时它仍可能报 `true`,按它跳过就等于永远唤不起。 - 宿主那边按 `0 / 700 / 2200 ms` 补发,在"直起 exe"、"PowerShell 强抢前台"(`AttachThreadInput` + `SetForegroundWindow`,绕开 Windows 的前台锁)、"explorer 走协议"之间轮换,但**确认窗口已在前台就立刻收手**:助手动手前先看一次,已经在前台就不抢(最小化的不算,照样 restore);助手退出码 0 = 收尾时目标窗口已经是前台,拿到这个信号剩下的补发全部作废。所以成功的那次点击最多打扰约 0.7 秒,而不是在 4 秒里抢五次前台。 - macOS 走 `open dsh://open`;其他平台跳过。普通浏览器、没有这张路由表的 DSH 组合、或请求失败,都是静默空操作——"打开对应会话"这条主路径永远不受影响。 - 每个去路都记一笔留痕:`state().stats.lastActivation` = `{ at, route, reason, status?, error? }`,`reason` 取值 `no-desktop` / `no-fetch` / `sent` / `answered` / `failed` / `error`。注意 `answered` / 204 只代表宿主已经发出唤醒命令,**不代表窗口到了前台**。 ## 11. 检查更新与就地升级 宿主半侧读 npm 上最新发布的版本,页面把结果推给设置页底部的状态机。那一行可能处的状态(文案取自 `client.js` 的文案字典,`{当前}` / `{最新}` 是实际版本号): | 状态 | 这一行怎么说 | 按钮 | | :--- | :--- | :--- | | 还没查过 | `当前版本 v{当前}` | 检查更新 | | 正在检查 | `正在检查更新…` | 置灰 | | 已是最新 | `当前版本 v{当前},已是最新版本` | 检查更新 | | 有新版本 | `当前版本 v{当前},发现新版本 v{最新};更新后重启 DSH 生效` | 更新到 v{最新} | | 正在更新 | `正在更新到 v{最新}…` | 置灰 | | 更新完成 | `已更新到 v{最新}:重启 DSH 后生效` | 刷新页面 | | 检查失败 | `检查更新失败:{原因}` | 检查更新 | | 升级失败 | `更新失败:{原因};若其实已经装完,重启 DSH 后以实际版本为准` | 检查更新 | | 本地开发安装 | `当前版本 v{当前},最新 v{最新};本地开发安装不能一键更新` | 检查更新(不会升级) | | 没有插件管理服务 | `当前版本 v{当前},最新 v{最新};当前组合没有插件管理服务,请手动更新` | 检查更新(不会升级) | 状态不持久化:每次打开设置页都从「还没查过」开始。它也确实排在整页最后一行(自检有一条断言守着这个位置)。最后两态的按钮退化为「检查更新」:只再查一次,不会给一个点了没反应的升级按钮。 - **双源**:**同时**问 npm 官方源与 `registry.npmmirror.com`,取两边答出来的**更高**版本——镜像还没同步完,不会把"其实有新版本"说成"已是最新";其中一个源连不上也照样有另一个的答案;只有两个源都不通才报失败。 - **双传输**:每个源都用两条路并行问——直连 HTTPS(`agent: false`,绕开 Node 装在全局 agent 上的代理),以及环境变量确实开着代理时的代理路径。原因是 Node 24 在 `NODE_USE_ENV_PROXY=1` 时让 `fetch` 按 `HTTPS_PROXY` 走代理,本地代理恰好关着(Clash 没开、公司代理断线)就会 `ECONNREFUSED 127.0.0.1:7897`,而直连其实完全正常。谁先答用谁的;失败说明带上真正的原因(`ECONNREFUSED …` / `ENOTFOUND …`),不是 undici 那句光秃秃的 `fetch failed`。 - **检查有界**:整次检查有总超时,答复体也有大小上限——某个源挂住、或回了一个异常大的页面时,设置页不会永远停在「正在检查更新…」,失败会如实写原因。 - **升级是两步链路(1.6.4 起)**:升级仍然交给 DSH 自己的插件管理服务(`pluginManager.installBundle`),不是自己拼一条 pnpm 命令。先正常 install;当前 DSH(0.2.1+)在 HMR 存在时会对已安装的包答 `already-installed`,这时改走官方升级路径 `removeBundle → installBundle`。两步都失败时会尝试把**当前版本装回去**,尽量别让 profile 停在「删了没装回」的状态。宿主只把新版本落到磁盘、不动正在跑的这棵树——提出这次更新的插件本身就跑在这棵树里;因此结果无论记成 `restart-required` 还是 `applied`,设置页都只说「重启 DSH 后生效」,文案不变。 - **两种情况如实拒绝**:profile 里是 `link:` / `file:` 的本地开发安装(仍然告诉你有没有新版,但按钮退化为「检查更新」——只再查一次,不提供会覆盖工作目录的升级动作);没有插件管理服务的组合(说明原因,按钮同样只退化为「检查更新」,请手动升级)。 - 没有上传、没有遥测:检查就是一次 HTTPS `GET` 取 `dist-tags.latest`,更新就是在你自己的 profile 里做一次正常的包安装。 ## 12. 有意保守的地方与已知限制 - **分类到上限仍 unclosed 时宁可漏、不可误报**:这一轮干脆不弹窗不放音(`stats.suppressedUnclosed`),而不是猜成完成;只有完全读不到(unreadable)才按完成兜底,那时 5 秒窗口内的新回合仍可能被吞掉一次——两种都是有意选的更便宜的错。 - **Windows 上通知中心条目不投递 click**(electron#29461):所以 tag 才回到"每条独立",也才有了「弹窗一直挂着」这个开关。 - **全屏 / 请勿打扰绕不过**:Web Notification 没有绕过"请勿打扰"的接口,Windows 在前台有全屏应用时隐藏横幅(连优先级通知一起),提醒只进通知中心,退出全屏也不重放。提示音照常响。 - **热重载会清空实例内的留痕**:所以跳转与落点的留痕在页面级表里也存一份,取更新的那个。 - **`/plugins/` 作用域只能有一个此类 worker**:同时装两个用同一招的通知插件会互相顶替。