dsh-reminding 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 liuchaoxu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/liuchaoxu/dsh-reminding/main/icon.svg" width="76" height="76" alt="dsh-reminding icon">
3
+ </p>
2
4
 
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.
5
+ <h1 align="center">dsh-reminding</h1>
6
+
7
+ <p align="center">
8
+ DeepSeek Harness 的系统提醒插件:需要你授权、模型提问或回合结束时弹出系统通知,<br>
9
+ 点一下通知,直接回到对应对话。
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/dsh-reminding"><img src="https://img.shields.io/npm/v/dsh-reminding.svg" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/dsh-reminding"><img src="https://img.shields.io/npm/dm/dsh-reminding.svg" alt="npm downloads"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
16
+ <img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey" alt="Windows, macOS, Linux">
17
+ <img src="https://img.shields.io/badge/dsh-%3E%3D0.2.1--rc.2-4B32C3" alt="DSH version">
18
+ </p>
19
+
20
+ ---
21
+
22
+ ## 为什么需要它
23
+
24
+ 在 DeepSeek Harness 里,模型经常会停下来等你:一次敏感操作需要你点“允许”,一个问题需要你选择,或者一个回合已经跑完。你切到别的窗口以后,这些时刻很容易被错过。
25
+
26
+ `dsh-reminding` 把这三类时刻变成**操作系统的系统通知**(Windows 通知中心 / macOS 通知中心 / Linux libnotify),并把「点击通知」接回 Harness:**直接打开产生这条提醒的那个对话**,不需要在侧栏里翻。
27
+
28
+ - 零依赖:纯 ESM + 浏览器原生 Notification API,没有 npm 运行依赖;
29
+ - 零遥测:不联网、不上报、不写会话日志,提醒只在内存里流转;
30
+ - 零配置即可用:装好就有默认行为,设置页只提供一个总开关、权限状态和测试按钮。
31
+
32
+ ## 提醒时机
33
+
34
+ 插件只读已提交的会话日志(`session/event`),不改变模型行为,也不写任何会话事件:
35
+
36
+ | 会话事件 | 通知标题 | 说明 |
37
+ | --- | --- | --- |
38
+ | `approval/asked` | 需要你的授权 | 工具调用在等待你允许(沙箱升级、敏感操作等);400ms 内被机器应答的请求不会误弹 |
39
+ | `ask_user_question` 工具调用 | 模型在等你回答 | 模型提问,或提交计划审阅 |
40
+ | `turn/end` → `completed` | 对话已完成 | 这一轮回复结束;1.2 秒内紧接的后续轮次会自动合并,不会连续轰炸 |
41
+ | `turn/end` → `error` | 生成失败 | 这一轮因错误中断 |
42
+ | `turn/end` → `max-tokens` | 输出被截断 | 达到输出 token 上限 |
43
+
44
+ - 授权 / 提问被处理(`approval/decided`、对应工具返回结果)后,已经弹出的那条通知会自动关闭;
45
+ - 子代理(subagent)会话不会提醒,避免一次任务被拆成几十条通知;
46
+ - 页面有焦点、且当前地址已经是这条提醒对应的会话时不会弹(你正看着它);
47
+ - 页面失焦、最小化、或停留在另一个对话里时都会正常提醒。
48
+
49
+ ## 点击跳转
50
+
51
+ 每条提醒都带着会话 ID。点击通知会:
52
+
53
+ 1. 把 Harness 窗口切到前台;
54
+ 2. 调用 Client 的 `uiWorkspace.openSession(sessionId)` 打开对应对话。
55
+
56
+ 测试通知只演示弹框,点击它只会聚焦窗口;只有真实提醒才带会话 ID。
57
+
58
+ ## 安装
59
+
60
+ 插件以 bundle 形式发布,`package.json` 的 `dsh.bundle.patch` 会把它挂进当前 profile。
61
+
62
+ ### 方式一:插件页面(推荐)
63
+
64
+ 1. 打开 Harness 侧栏的 **Plugins** 页面;
65
+ 2. 在添加/安装入口输入 `dsh-reminding`;
66
+ 3. 选择安装,成功后页面通常即时生效;若没有看到新的设置行,刷新一次页面。
67
+
68
+ ### 方式二:CLI(高级)
69
+
70
+ ```bash
71
+ dsh plugin --profile <profile> add dsh-reminding
72
+ ```
73
+
74
+ 这条命令把包装进 profile;随后在侧栏 **Plugins** 页面把 `dsh-reminding` 选为启用(bundle 是否加载由 profile 的 `dsh.profile.bundles` 决定,单纯 `pnpm add` 不会自动选中)。
75
+
76
+ > `desktop` 应用管理的 profile 不允许 CLI 直接改写,请使用插件页面。
77
+
78
+ ### 方式三:本地目录(开发)
79
+
80
+ 使用 agent 的 `plugin_manager` `install_bundle`,target 指向本目录的绝对路径。等效的 CLI 写法是 `dsh plugin --profile <profile> add link:/absolute/path/to/dsh-reminding`,安装后同样需要在 **Plugins** 页面启用。
81
+
82
+ ## 设置
83
+
84
+ **设置 → 通用 → 系统提醒** 一行:
85
+
86
+ - **总开关**:关闭后不再弹任何系统通知(保存在浏览器 `localStorage`,按浏览器/设备区分);
87
+ - **权限状态**:已允许 / 未授权 / 已被阻止 / 不支持;
88
+ - **测试**:发一条测试通知,同时也是浏览器要求的用户手势授权入口。
89
+
90
+ 安装后首次加载会尝试自动授权;桌面端通常直接允许,普通浏览器若要求手势,点一次「测试」即可。
91
+
92
+ ## 平台
93
+
94
+ ### Windows
95
+
96
+ 通知出现在右下角通知中心。Windows 会**按应用**单独拦截通知,且拦截发生在插件之外,插件无从感知。收不到弹框时按顺序检查:
97
+
98
+ 1. **设置 → 系统 → 通知 → DeepSeek Harness**:确认「允许通知」和「显示通知横幅」都打开;
99
+ 2. 确认「专注助手 / 勿扰」没有开启;
100
+ 3. 回到 设置 → 通用 → 系统提醒,点一次「测试」。
101
+
102
+ 也可以直接看注册表(应用级开关关闭时 `Enabled = 0`,通知会被系统静默丢弃):
103
+
104
+ ```powershell
105
+ Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\electron.app.DeepSeek Harness'
106
+ ```
107
+
108
+ ### macOS
109
+
110
+ 通知出现在通知中心。首次可能需要在 **系统设置 → 通知 → DeepSeek Harness** 里允许;如果之前选过“不允许”,需要在那里重新打开。
111
+
112
+ ### Linux
113
+
114
+ 依赖桌面环境的通知守护进程(libnotify / dunst / GNOME Shell 等)。没有通知服务的精简桌面不会显示弹框。
115
+
116
+ 浏览器访问 `dsh web` 时同样可用,但受浏览器通知权限与站点设置约束。
117
+
118
+ ## 隐私与安全
119
+
120
+ - **不联网**:插件不发起任何外部请求,也不采集遥测;
121
+ - **只读**:只订阅已提交的 `session/event`,不追加事件、不改会话;
122
+ - **不落盘**:提醒只存在于 Host 进程内存与页面内存,刷新后从当前序号重建基线,不回放旧提醒;
123
+ - **本机路由**:浏览器半边通过 `/reminding/poll` 长轮询取提醒,路由先经过 `ctx.connection.requestRejection` 的浏览器信任栅栏;
124
+ - 通知正文只在本机展示,包含会话标题、工具名或问题文本,不会离开设备。
125
+
126
+ ## 兼容性
127
+
128
+ | 项 | 要求 |
129
+ | --- | --- |
130
+ | DeepSeek Harness | `>= 0.2.1-rc.2`(`engines.dsh`,插件管理器会在安装前校验) |
131
+ | Node.js | `>= 18` |
132
+ | 运行形态 | DSH 桌面应用、`dsh web` 浏览器访问均可 |
133
+ | 操作系统 | Windows、macOS、Linux |
134
+
135
+ DSH 版本兼容声明同时写在 `package.json` 的 `dsh.compatibility.dshReleases` 里。
136
+
137
+ ## 开发
138
+
139
+ 仓库没有构建步骤:`index.js` 是 Host 半边,`client.js` 是浏览器半边,都是可直接加载的 ESM / 纯 JS。
140
+
141
+ ```bash
142
+ git clone https://github.com/liuchaoxu/dsh-reminding.git
143
+ cd dsh-reminding
144
+
145
+ npm run check # 清单、导出、locale、图标、patch 的一致性检查
146
+ npm test # Host 事件流 + Client 通知/跳转的功能测试(无需 DSH 进程)
147
+ npm run pack:check # 查看 npm 实际发布的文件列表
148
+ ```
149
+
150
+ 两段测试都使用 Node 内置能力,不联网、不写盘:
151
+
152
+ - `scripts/test-host.mjs`:模拟 Cordis 上下文,覆盖授权、提问、完成、失败、去抖取消、子代理过滤与长轮询释放;
153
+ - `scripts/test-client.mjs`:加载真实的 `client.js` 工厂,脚本化 Host 响应,断言通知标题/正文与点击后的 `openSession`。
154
+
155
+ 目录:
156
+
157
+ ```text
158
+ .
159
+ ├── index.js # Host 半边:session/event → 提醒流 → /reminding/poll
160
+ ├── client.js # 浏览器半边:长轮询 → 系统通知 → 打开会话
161
+ ├── cordis.patch.yml # bundle patch:插入 dsh-reminding 行
162
+ ├── locale/{zh,en}.json # 插件管理页的标题与描述
163
+ ├── icon.svg # 插件图标
164
+ ├── scripts/ # check + 两段功能测试
165
+ └── .github/ # CI、发布工作流、issue 模板
166
+ ```
167
+
168
+ ### 发布
169
+
170
+ 1. 更新 `package.json` 的 `version` 与 `CHANGELOG.md`;
171
+ 2. 打 tag 并创建 GitHub Release,`Publish to npm` 工作流会用 `NPM_TOKEN` 执行 `npm publish --provenance --access public`(provenance 需要 CI 的 OIDC,因此由工作流完成);
172
+ 3. 也可以在本地执行 `npm publish --access public`(先 `npm login`),但本地产物不带 provenance 证明。
173
+
174
+ ## 常见问题
175
+
176
+ **设置里没有「系统提醒」这一行?** 安装后刷新一次页面;如果仍未出现,检查 设置 → 插件 里 `dsh-reminding` 是否启用。
177
+
178
+ **和 dsh-pet 的通知重复?** 两者都提供系统通知。关掉任意一个即可:本插件在 设置 → 通用 → 系统提醒,dsh-pet 在它自己的设置页。
179
+
180
+ **点击测试通知没有跳转?** 这是预期行为:测试通知没有会话 ID,只会聚焦窗口;真实提醒会打开对应对话。
181
+
182
+ **窗口关掉后还会提醒吗?** 不会。通知逻辑在页面内,这是“点击跳转”的前提;请保持 Harness 窗口(可以最小化)运行。
183
+
184
+ ## License
185
+
186
+ [MIT](LICENSE) © 2026 liuchaoxu
187
+
188
+ ---
189
+
190
+ ## English
191
+
192
+ **System reminders for DeepSeek Harness.** When a tool call is waiting for your approval, the model asks a question (including plan review), or a turn finishes / fails / hits the output limit, the plugin raises an OS notification. Clicking it focuses Harness and opens the exact conversation.
193
+
194
+ - Cross-platform OS notifications via the standard Notification API (Windows toast, macOS Notification Center, Linux libnotify);
195
+ - Approval and question notifications close themselves once the decision is made;
196
+ - Subagent sessions are ignored; completion is debounced so goal rounds do not spam you;
197
+ - Notifications are suppressed only while you are focused on that very conversation;
198
+ - No dependencies, no network calls, no telemetry, no session-log writes; reminders live in memory only.
199
+
200
+ Install from the Harness **Plugins** page by entering `dsh-reminding`, or run `dsh plugin --profile <profile> add dsh-reminding` and then enable the bundle on that page. Then find **Settings → General → System reminders** for the master switch, permission state, and a test button.
201
+
202
+ On Windows, if nothing appears, check **Settings → System → Notifications → DeepSeek Harness** — the OS can disable notifications per app, silently and outside the plugin's control.
203
+
204
+ Requires DeepSeek Harness `>= 0.2.1-rc.2` and Node.js `>= 18`. MIT licensed.
package/client.js ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * dsh-reminding — browser half.
3
+ *
4
+ * Long-polls the Host's `/reminding/poll` feed and turns each item into an OS
5
+ * notification through the standard Notification API, which is what makes the
6
+ * same code work on Windows (toast), macOS (Notification Center) and Linux
7
+ * (libnotify) — the desktop shell and every Chromium-based browser implement
8
+ * it. Clicking a notification focuses the window and opens the exact
9
+ * conversation through `ctx.uiWorkspace.openSession(sessionId)`.
10
+ *
11
+ * The Settings → General row is the one piece of visible UI: a master switch,
12
+ * the live permission state, and a test button (the user gesture browsers
13
+ * require before granting notification permission).
14
+ *
15
+ * Notifications are suppressed when the page has focus AND the URL already
16
+ * shows that conversation, so looking at the answer never produces a redundant
17
+ * toast; being anywhere else (another conversation, another app) does.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-reminding',
21
+ factory(require) {
22
+ const module = { exports: {} }
23
+ const exports = module.exports
24
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
25
+
26
+ const React = require('react')
27
+ const h = React.createElement
28
+
29
+ /** Locale namespace of every user-visible string in this plugin. */
30
+ const NS = 'dsh-reminding'
31
+ /** Host route, document-relative so a directory-mounted GUI still works. */
32
+ const ROUTE_POLL = 'reminding/poll'
33
+ /** Where the master switch lives; localStorage keeps it per browser. */
34
+ const STORAGE_KEY = 'dsh-reminding:settings'
35
+ /** Longest body line sent to the OS; toasts truncate anyway. */
36
+ const MAX_BODY = 140
37
+
38
+ const zh = {
39
+ 'meta.title': '系统提醒',
40
+ 'settings.title': '系统提醒',
41
+ 'settings.description': '对话需要授权、模型提问或回复完成时弹出系统通知;点击通知直接回到对应对话。',
42
+ 'settings.toggle': '系统通知开关',
43
+ 'settings.test': '测试',
44
+ 'settings.permission.granted': '通知权限:已允许',
45
+ 'settings.permission.default': '通知权限:未授权,点击「测试」完成授权',
46
+ 'settings.permission.denied': '通知权限:已被阻止,请在系统或浏览器设置中允许后刷新',
47
+ 'settings.permission.unsupported': '当前环境不支持系统通知',
48
+ 'settings.testSent': '测试通知已发送,请查看系统通知中心。',
49
+ 'settings.testBlocked': '无法发送:通知权限未授予。',
50
+ 'settings.testFailed': '发送失败:{error}',
51
+ 'notify.approval.title': '需要你的授权',
52
+ 'notify.approval.body': '工具「{tool}」正在等待你的授权',
53
+ 'notify.approval.fallback': '有一步操作正在等待你的授权',
54
+ 'notify.approval.reason': '原因:{reason}',
55
+ 'notify.question.title': '模型在等你回答',
56
+ 'notify.question.body': '模型提出了一个问题,点击回到对话处理。',
57
+ 'notify.question.planReview': '模型提交了计划,等待你的审阅。',
58
+ 'notify.completed.title': '对话已完成',
59
+ 'notify.completed.body': '这一轮回复已经结束,点击回到对话查看。',
60
+ 'notify.error.title': '生成失败',
61
+ 'notify.error.body': '这一轮回复因错误中断。',
62
+ 'notify.maxTokens.title': '输出被截断',
63
+ 'notify.maxTokens.body': '已达到输出 token 上限。',
64
+ 'notify.test.title': '测试通知',
65
+ 'notify.test.body': '通知已开启,点击这条消息可回到对话。',
66
+ 'notify.sessionFallback': '对话',
67
+ }
68
+
69
+ const en = {
70
+ 'meta.title': 'System Reminders',
71
+ 'settings.title': 'System reminders',
72
+ 'settings.description': 'OS notifications when a step needs your approval, the model asks a question, or a turn finishes; click a toast to jump back to that conversation.',
73
+ 'settings.toggle': 'System notification switch',
74
+ 'settings.test': 'Test',
75
+ 'settings.permission.granted': 'Notification permission: allowed',
76
+ 'settings.permission.default': 'Notification permission: not granted yet — press “Test” to allow',
77
+ 'settings.permission.denied': 'Notification permission: blocked — allow it in the system or browser settings, then refresh',
78
+ 'settings.permission.unsupported': 'System notifications are unavailable in this environment',
79
+ 'settings.testSent': 'Test notification sent — check your system notification center.',
80
+ 'settings.testBlocked': 'Cannot send: notification permission is not granted.',
81
+ 'settings.testFailed': 'Send failed: {error}',
82
+ 'notify.approval.title': 'Approval needed',
83
+ 'notify.approval.body': 'Tool “{tool}” is waiting for your approval',
84
+ 'notify.approval.fallback': 'An operation is waiting for your approval',
85
+ 'notify.approval.reason': 'Reason: {reason}',
86
+ 'notify.question.title': 'The model is waiting for you',
87
+ 'notify.question.body': 'The model asked a question — click to open the conversation.',
88
+ 'notify.question.planReview': 'The model submitted a plan for your review.',
89
+ 'notify.completed.title': 'Turn finished',
90
+ 'notify.completed.body': 'This reply is finished — click to open the conversation.',
91
+ 'notify.error.title': 'Generation failed',
92
+ 'notify.error.body': 'This turn stopped with an error.',
93
+ 'notify.maxTokens.title': 'Output truncated',
94
+ 'notify.maxTokens.body': 'The output token limit was reached.',
95
+ 'notify.test.title': 'Test notification',
96
+ 'notify.test.body': 'Notifications are on. Click this message to return to the conversation.',
97
+ 'notify.sessionFallback': 'Conversation',
98
+ }
99
+
100
+ /** Substitute `{name}` placeholders without depending on a translate API. */
101
+ function fill(template, params) {
102
+ return String(template).replace(/\{(\w+)\}/g, (match, name) => (
103
+ params !== undefined && params[name] !== undefined ? String(params[name]) : match
104
+ ))
105
+ }
106
+
107
+ /** Fallback translator used when the Client locale service is absent. */
108
+ function fallbackT(key, params) {
109
+ const template = zh[key] ?? key
110
+ return params === undefined ? template : fill(template, params)
111
+ }
112
+
113
+ // -----------------------------------------------------------------------
114
+ // Master switch. Module-level so the polling loop, the settings row and a
115
+ // plugin reload in the same page all observe one value.
116
+ let enabled = true
117
+ try {
118
+ const raw = localStorage.getItem(STORAGE_KEY)
119
+ if (raw !== null) {
120
+ const parsed = JSON.parse(raw)
121
+ if (parsed !== null && typeof parsed === 'object' && parsed.enabled === false) enabled = false
122
+ }
123
+ } catch {
124
+ enabled = true
125
+ }
126
+
127
+ function persistEnabled(next) {
128
+ enabled = next
129
+ try {
130
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ enabled: next }))
131
+ } catch {
132
+ // Private mode or a full quota keeps the in-memory value for this page.
133
+ }
134
+ }
135
+
136
+ /** The context of the active activation, used by toast click handlers. */
137
+ let activeContext = null
138
+
139
+ // -----------------------------------------------------------------------
140
+ // Notification plumbing.
141
+
142
+ /** Current permission, normalized across browsers and the desktop shell. */
143
+ function permissionState() {
144
+ try {
145
+ if (typeof Notification === 'undefined') return 'unsupported'
146
+ return Notification.permission
147
+ } catch {
148
+ return 'unsupported'
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Ask for notification permission. Call from a user gesture when possible;
154
+ * the desktop shell usually answers without showing a prompt.
155
+ * @returns the resulting permission state.
156
+ */
157
+ async function requestPermission() {
158
+ const current = permissionState()
159
+ if (current !== 'default') return current
160
+ try {
161
+ return await Notification.requestPermission()
162
+ } catch {
163
+ return 'error'
164
+ }
165
+ }
166
+
167
+ /** Whether the page is visible and holds keyboard focus. */
168
+ function pageFocused() {
169
+ try {
170
+ return document.hasFocus() && document.visibilityState === 'visible'
171
+ } catch {
172
+ return false
173
+ }
174
+ }
175
+
176
+ /** Whether the current URL already shows that conversation. */
177
+ function viewingSession(sessionId) {
178
+ if (typeof sessionId !== 'string' || sessionId === '') return false
179
+ try {
180
+ return window.location.href.includes(sessionId)
181
+ } catch {
182
+ return false
183
+ }
184
+ }
185
+
186
+ /** Open the conversation that produced one reminder. */
187
+ function openConversation(sessionId) {
188
+ try {
189
+ window.focus()
190
+ } catch {
191
+ // A denied focus is cosmetic: the navigation below still happens.
192
+ }
193
+ if (typeof sessionId !== 'string' || sessionId === '') return
194
+ try {
195
+ activeContext?.get?.('uiWorkspace')?.openSession?.(sessionId)
196
+ } catch (err) {
197
+ console.warn('[dsh-reminding] could not open the conversation:', err)
198
+ }
199
+ }
200
+
201
+ /** Look up one session's title with a defensive read of the Client store. */
202
+ function sessionTitle(sessionId) {
203
+ try {
204
+ const snapshot = activeContext?.get?.('sessions')?.list?.getSnapshot?.()
205
+ if (snapshot === null || snapshot === undefined) return ''
206
+ const summary = snapshot.byId?.[sessionId]
207
+ const direct = summary?.title ?? summary?.name
208
+ if (typeof direct === 'string' && direct.trim() !== '') return direct.trim()
209
+ const values = snapshot.projectionsBySession?.[sessionId]?.values
210
+ const projected = values?.title
211
+ if (typeof projected === 'string' && projected.trim() !== '') return projected.trim()
212
+ if (projected !== null && typeof projected === 'object' && typeof projected.title === 'string') {
213
+ return projected.title.trim()
214
+ }
215
+ } catch {
216
+ // A cold or malformed store only costs the title, never the toast.
217
+ }
218
+ return ''
219
+ }
220
+
221
+ /** Trim one body line for the OS notification. */
222
+ function clip(text) {
223
+ const value = typeof text === 'string' ? text.trim() : ''
224
+ return value.length > MAX_BODY ? `${value.slice(0, MAX_BODY)}…` : value
225
+ }
226
+
227
+ /**
228
+ * Compose the localized title and body of one reminder.
229
+ * @param t - translate function bound to this plugin's namespace.
230
+ * @param item - one Host feed item.
231
+ * @returns `{ title, body }`.
232
+ */
233
+ function describe(t, item) {
234
+ const kind = typeof item?.kind === 'string' ? item.kind : ''
235
+ const title = sessionTitle(item?.sessionId)
236
+ const prefix = title === '' ? '' : `【${title}】`
237
+ const withSession = (text) => `${prefix}${clip(text)}`
238
+ if (kind === 'approval') {
239
+ const tool = typeof item.toolName === 'string' ? item.toolName : ''
240
+ const base = tool === ''
241
+ ? t('notify.approval.fallback')
242
+ : fill(t('notify.approval.body'), { tool })
243
+ const reason = typeof item.reason === 'string' ? item.reason : ''
244
+ return {
245
+ title: t('notify.approval.title'),
246
+ body: withSession(base + (reason === '' ? '' : ` · ${fill(t('notify.approval.reason'), { reason })}`)),
247
+ }
248
+ }
249
+ if (kind === 'question') {
250
+ const question = typeof item.question === 'string' ? item.question : ''
251
+ if (question !== '') {
252
+ return { title: t('notify.question.title'), body: withSession(question) }
253
+ }
254
+ return {
255
+ title: t('notify.question.title'),
256
+ body: withSession(t(item.planReview === true ? 'notify.question.planReview' : 'notify.question.body')),
257
+ }
258
+ }
259
+ if (kind === 'completed') {
260
+ return { title: t('notify.completed.title'), body: withSession(t('notify.completed.body')) }
261
+ }
262
+ if (kind === 'error') {
263
+ const message = typeof item.message === 'string' ? item.message : ''
264
+ return {
265
+ title: t('notify.error.title'),
266
+ body: withSession(message === '' ? t('notify.error.body') : message),
267
+ }
268
+ }
269
+ if (kind === 'max-tokens') {
270
+ return { title: t('notify.maxTokens.title'), body: withSession(t('notify.maxTokens.body')) }
271
+ }
272
+ if (kind === 'test') {
273
+ return { title: t('notify.test.title'), body: t('notify.test.body') }
274
+ }
275
+ return null
276
+ }
277
+
278
+ /** Live toasts by feed key, so a resolved approval/question can close its own toast. */
279
+ const liveToasts = new Map()
280
+
281
+ /** Close one toast by its feed key. */
282
+ function closeToast(key) {
283
+ const entry = liveToasts.get(key)
284
+ if (entry === undefined) return
285
+ liveToasts.delete(key)
286
+ try {
287
+ entry.close()
288
+ } catch {
289
+ // Already dismissed by the OS: nothing to do.
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Show one reminder as an OS notification.
295
+ * @param t - translate function.
296
+ * @param item - feed item.
297
+ * @param options - `force` bypasses the master switch and the focus gate.
298
+ * @returns true when a notification was actually created.
299
+ */
300
+ function showReminder(t, item, options = {}) {
301
+ if (options.force !== true) {
302
+ if (!enabled) return false
303
+ if (pageFocused() && viewingSession(item?.sessionId)) return false
304
+ }
305
+ if (permissionState() !== 'granted') return false
306
+ const copy = describe(t, item)
307
+ if (copy === null) return false
308
+ try {
309
+ const notification = new Notification(copy.title, {
310
+ body: copy.body,
311
+ tag: typeof item.key === 'string' ? item.key : undefined,
312
+ })
313
+ const key = typeof item.key === 'string' ? item.key : ''
314
+ if (key !== '') {
315
+ closeToast(key)
316
+ liveToasts.set(key, notification)
317
+ }
318
+ notification.onclick = () => {
319
+ openConversation(item?.sessionId)
320
+ try {
321
+ notification.close()
322
+ } catch {
323
+ // Closing an already dismissed toast is a no-op.
324
+ }
325
+ if (key !== '') liveToasts.delete(key)
326
+ }
327
+ notification.onclose = () => {
328
+ if (key !== '') liveToasts.delete(key)
329
+ }
330
+ notification.onerror = () => {
331
+ if (key !== '') liveToasts.delete(key)
332
+ }
333
+ return true
334
+ } catch (err) {
335
+ console.warn('[dsh-reminding] could not show the notification:', err)
336
+ return false
337
+ }
338
+ }
339
+
340
+ /** Sleep helper for reconnect backoff (never used on the fast path). */
341
+ function delay(ms) {
342
+ return new Promise((resolve) => {
343
+ setTimeout(resolve, ms)
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Long-poll the Host feed until the plugin is disposed. The next request
349
+ * starts from the network callback rather than a timer, so a background or
350
+ * minimized window keeps receiving reminders instead of being throttled.
351
+ * @param getT - current translate function.
352
+ * @returns disposal function.
353
+ */
354
+ function startFeed(getT) {
355
+ let stopped = false
356
+ let controller = null
357
+ let since = -1
358
+ let retries = 0
359
+
360
+ const handle = (item) => {
361
+ if (item === null || typeof item !== 'object') return
362
+ if (item.kind === 'resolved') {
363
+ closeToast(typeof item.key === 'string' ? item.key : '')
364
+ return
365
+ }
366
+ showReminder(getT(), item)
367
+ }
368
+
369
+ const run = async () => {
370
+ while (!stopped) {
371
+ controller = typeof AbortController === 'function' ? new AbortController() : null
372
+ try {
373
+ const response = await fetch(`${ROUTE_POLL}?since=${since}&wait=1`, {
374
+ cache: 'no-store',
375
+ ...(controller === null ? {} : { signal: controller.signal }),
376
+ })
377
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
378
+ const payload = await response.json()
379
+ retries = 0
380
+ const hostSeq = Number(payload?.seq)
381
+ if (Number.isFinite(hostSeq) && (since < 0 || hostSeq < since)) since = hostSeq
382
+ const events = Array.isArray(payload?.events) ? payload.events : []
383
+ for (const item of events) {
384
+ const itemSeq = Number(item?.seq)
385
+ if (Number.isFinite(itemSeq)) since = Math.max(since, itemSeq)
386
+ handle(item)
387
+ }
388
+ } catch (err) {
389
+ if (stopped) return
390
+ retries += 1
391
+ await delay(Math.min(10_000, 750 * retries))
392
+ }
393
+ }
394
+ }
395
+
396
+ run()
397
+ return () => {
398
+ stopped = true
399
+ if (controller !== null) controller.abort()
400
+ }
401
+ }
402
+
403
+ // -----------------------------------------------------------------------
404
+ // Settings row (Settings → General). The owner passes no props, so the row
405
+ // draws its own label, state and controls from the injected face.
406
+
407
+ const STYLES = [
408
+ '.dsh-reminding-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 10px 0; box-sizing: border-box; }',
409
+ '.dsh-reminding-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }',
410
+ '.dsh-reminding-title { font-size: 14px; font-weight: 500; line-height: 22px; color: var(--dsw-alias-label-primary); }',
411
+ '.dsh-reminding-desc { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }',
412
+ '.dsh-reminding-note { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }',
413
+ '.dsh-reminding-message { font-size: 12px; line-height: 18px; color: var(--dsw-alias-state-success-primary); }',
414
+ '.dsh-reminding-message-error { color: var(--dsw-alias-state-error-primary); }',
415
+ '.dsh-reminding-controls { display: inline-flex; align-items: center; gap: 10px; flex: 0 0 auto; }',
416
+ '.dsh-reminding-switch { position: relative; width: 36px; height: 20px; padding: 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--dsw-alias-bg-layer-2); cursor: pointer; transition: background 120ms ease, border-color 120ms ease; }',
417
+ '.dsh-reminding-switch.is-on { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }',
418
+ '.dsh-reminding-knob { position: absolute; top: 1px; left: 1px; width: 16px; height: 16px; border-radius: 50%; background: var(--dsw-alias-bg-base); transition: transform 120ms ease; }',
419
+ '.dsh-reminding-switch.is-on .dsh-reminding-knob { transform: translateX(16px); }',
420
+ '.dsh-reminding-switch:focus-visible, .dsh-reminding-test:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }',
421
+ '.dsh-reminding-test { height: 28px; padding: 0 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; cursor: pointer; }',
422
+ '.dsh-reminding-test:hover { background: var(--dsw-alias-bg-layer-2); }',
423
+ ].join('\n')
424
+
425
+ /** Settings → General → one row: master switch, permission state, test. */
426
+ function RemindingSettings(props) {
427
+ const t = typeof props?.t === 'function' ? props.t : fallbackT
428
+ const [isEnabled, setIsEnabled] = React.useState(enabled)
429
+ const [permission, setPermission] = React.useState(permissionState)
430
+ const [message, setMessage] = React.useState('')
431
+
432
+ React.useEffect(() => {
433
+ const refresh = () => setPermission(permissionState())
434
+ window.addEventListener('focus', refresh)
435
+ document.addEventListener('visibilitychange', refresh)
436
+ return () => {
437
+ window.removeEventListener('focus', refresh)
438
+ document.removeEventListener('visibilitychange', refresh)
439
+ }
440
+ }, [])
441
+
442
+ const toggle = async () => {
443
+ const next = !isEnabled
444
+ setIsEnabled(next)
445
+ persistEnabled(next)
446
+ setMessage('')
447
+ if (next) setPermission(await requestPermission())
448
+ }
449
+
450
+ const test = async () => {
451
+ setMessage('')
452
+ const granted = await requestPermission()
453
+ setPermission(granted)
454
+ if (granted !== 'granted') {
455
+ setMessage(t(granted === 'unsupported' ? 'settings.permission.unsupported' : 'settings.testBlocked'))
456
+ return
457
+ }
458
+ if (!enabled) {
459
+ persistEnabled(true)
460
+ setIsEnabled(true)
461
+ }
462
+ const sent = showReminder(t, { kind: 'test', key: `test:${Date.now()}`, sessionId: '' }, { force: true })
463
+ setMessage(sent ? t('settings.testSent') : t('settings.testFailed', { error: 'Notification' }))
464
+ }
465
+
466
+ const permissionText = permission === 'granted'
467
+ ? t('settings.permission.granted')
468
+ : permission === 'denied'
469
+ ? t('settings.permission.denied')
470
+ : permission === 'unsupported'
471
+ ? t('settings.permission.unsupported')
472
+ : t('settings.permission.default')
473
+
474
+ return h('div', { className: 'dsh-reminding-row' },
475
+ h('style', { 'data-plugin-css': 'dsh-reminding/styles', dangerouslySetInnerHTML: { __html: STYLES } }),
476
+ h('div', { className: 'dsh-reminding-copy' },
477
+ h('div', { className: 'dsh-reminding-title' }, t('settings.title')),
478
+ h('div', { className: 'dsh-reminding-desc' }, t('settings.description')),
479
+ h('div', { className: 'dsh-reminding-note' }, permissionText),
480
+ message === '' ? null : h('div', {
481
+ className: `dsh-reminding-message${permission === 'granted' ? '' : ' dsh-reminding-message-error'}`,
482
+ }, message),
483
+ ),
484
+ h('div', { className: 'dsh-reminding-controls' },
485
+ h('button', {
486
+ type: 'button',
487
+ role: 'switch',
488
+ 'aria-checked': isEnabled,
489
+ 'aria-label': t('settings.toggle'),
490
+ title: t('settings.toggle'),
491
+ className: `dsh-reminding-switch${isEnabled ? ' is-on' : ''}`,
492
+ onClick: () => { void toggle() },
493
+ }, h('span', { className: 'dsh-reminding-knob' })),
494
+ h('button', {
495
+ type: 'button',
496
+ className: 'dsh-reminding-test',
497
+ onClick: () => { void test() },
498
+ }, t('settings.test')),
499
+ ),
500
+ )
501
+ }
502
+
503
+ /**
504
+ * Activate the browser half.
505
+ * @param ctx - Client plugin context.
506
+ */
507
+ function apply(ctx) {
508
+ activeContext = ctx
509
+
510
+ const locale = typeof ctx.get === 'function' ? ctx.get('locale') : undefined
511
+ let t = fallbackT
512
+ if (locale !== undefined && typeof locale.register === 'function' && typeof locale.bind === 'function') {
513
+ ctx.effect(() => locale.register(NS, { zh, en }), 'dsh-reminding: dictionaries')
514
+ t = locale.bind(NS)
515
+ }
516
+
517
+ // Keep the feed alive for the whole page lifetime, independent of the
518
+ // Settings panel: reminders must arrive with the panel closed.
519
+ ctx.effect(() => startFeed(() => t), 'dsh-reminding: notification feed')
520
+
521
+ // Grant once at boot when the environment answers without a prompt; a
522
+ // browser that requires a gesture will grant from the Test button.
523
+ if (enabled && permissionState() === 'default') {
524
+ void requestPermission()
525
+ }
526
+
527
+ ctx.slots.inject('settings.general.item', () => ctx.slots.register({
528
+ name: 'settings.general.item',
529
+ id: 'dsh-reminding',
530
+ order: 25,
531
+ locale: NS,
532
+ inject: () => ({ t }),
533
+ }, RemindingSettings))
534
+ }
535
+
536
+ exports.apply = apply
537
+ exports.inject = ['slots']
538
+ return module.exports
539
+ },
540
+ })
@@ -0,0 +1,14 @@
1
+ # dsh-reminding bundle patch.
2
+ #
3
+ # The Host half follows the Session log (approval asks, user questions and turn
4
+ # ends) and serves them to the browser half over a loopback Web route; the
5
+ # browser half turns them into OS notifications and opens the matching
6
+ # conversation when a toast is clicked. The browser half is discovered from
7
+ # this row's `dsh.client` declaration in package.json.
8
+ #
9
+ # No Config schema is declared, so the row stays trivially overridable from a
10
+ # user patch layer.
11
+ - insert:
12
+ - id: dsh-reminding
13
+ name: 'dsh-reminding'
14
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Reminder bell">
2
+ <defs>
3
+ <linearGradient id="bell" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#63a8ff"/>
5
+ <stop offset="1" stop-color="#2563eb"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="3" y="3" width="58" height="58" rx="15" fill="#eef4ff"/>
9
+ <path d="M32 13c-7.2 0-12.4 5.3-12.4 12.4v7.4l-4.2 6.3c-.6.9 0 2.1 1.1 2.1h31c1.1 0 1.7-1.2 1.1-2.1l-4.2-6.3v-7.4C44.4 18.3 39.2 13 32 13Z" fill="url(#bell)"/>
10
+ <path d="M32 53.5c2.9 0 5.1-1.9 5.5-4.6H26.5c.4 2.7 2.6 4.6 5.5 4.6Z" fill="#1d4ed8"/>
11
+ <circle cx="46.5" cy="17.5" r="6.5" fill="#ef4444"/>
12
+ <circle cx="46.5" cy="17.5" r="2.6" fill="#fef2f2"/>
13
+ </svg>
package/index.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * dsh-reminding — Host half.
3
+ *
4
+ * Watches the committed Session log and republishes the three moments a user
5
+ * may be away for:
6
+ *
7
+ * - `approval/asked` — a sensitive tool call is waiting for a permission
8
+ * decision (`approval/decided` closes it again),
9
+ * - an `ask_user_question` tool call — the model asked a structured question
10
+ * (or a plan review) and is waiting for the answer (`tool/result` closes it),
11
+ * - `turn/end` with `completed` / `error` / `max-tokens` — the reply settled.
12
+ * Completion is debounced briefly so a goal round or an immediately
13
+ * following turn does not produce one toast per turn.
14
+ *
15
+ * Subagent sessions are ignored: they belong to their parent, and notifying
16
+ * for every delegated step would bury the user in toasts.
17
+ *
18
+ * Delivery is a loopback Web route the browser half long-polls. Every item
19
+ * carries its `sessionId`, which is what makes a toast click open the exact
20
+ * conversation. The route applies the composition's own browser-trust fence
21
+ * through `ctx.connection.requestRejection`, like the shipped browser-facing
22
+ * routes, so a request from an unaccepted authority gets the same 401/403.
23
+ *
24
+ * Everything is derived from the log; nothing is persisted. A page reload
25
+ * starts a fresh baseline and never replays old reminders.
26
+ */
27
+
28
+ export const inject = ['webServer']
29
+
30
+ /** Loopback route the browser half long-polls. */
31
+ const ROUTE_POLL = '/reminding/poll'
32
+ /** How many feed items stay available for a briefly disconnected page. */
33
+ const MAX_FEED = 80
34
+ /** How long one poll is held open before an empty answer releases it. */
35
+ const MAX_WAIT_MS = 25_000
36
+ /** Completion debounce: a follow-up turn inside this window cancels the toast. */
37
+ const COMPLETION_DEBOUNCE_MS = 1_200
38
+ /**
39
+ * Approval confirmation delay: an ask closed by a machine answerer or an
40
+ * immediate cancellation is no longer a human decision worth a toast.
41
+ */
42
+ const APPROVAL_NOTIFY_DELAY_MS = 400
43
+ /** The tool whose call means "the model is waiting for the user's answer". */
44
+ const USER_QUESTION_TOOL = 'ask_user_question'
45
+
46
+ /**
47
+ * Whether one session should produce reminders. A delegated child session is
48
+ * filtered out so its steps do not notify on behalf of the same conversation.
49
+ * @param session - the session whose log grew.
50
+ * @returns true for a user-facing (root) session.
51
+ */
52
+ function shouldNotifySession(session) {
53
+ const header = session?.header
54
+ if (header === null || typeof header !== 'object') return true
55
+ if (header.origin === 'subagent') return false
56
+ const depth = header.delegationDepth
57
+ if (typeof depth === 'number' && Number.isFinite(depth) && depth > 0) return false
58
+ return true
59
+ }
60
+
61
+ /**
62
+ * Parse the `questions` array of one `ask_user_question` call.
63
+ * @param raw - the tool call's JSON argument string.
64
+ * @returns the declared questions, or an empty array when unreadable.
65
+ */
66
+ function parseQuestions(raw) {
67
+ if (typeof raw !== 'string' || raw === '') return []
68
+ try {
69
+ const parsed = JSON.parse(raw)
70
+ const questions = parsed?.questions
71
+ return Array.isArray(questions) ? questions : []
72
+ } catch {
73
+ return []
74
+ }
75
+ }
76
+
77
+ /** Write one JSON response with no caching. */
78
+ function writeJson(res, value) {
79
+ res.writeHead(200, {
80
+ 'content-type': 'application/json; charset=utf-8',
81
+ 'cache-control': 'no-store',
82
+ })
83
+ res.end(JSON.stringify(value))
84
+ }
85
+
86
+ /**
87
+ * Apply the composition's browser-trust fence to one request. `node:http`
88
+ * routes are unauthenticated by construction, so every route owner decides;
89
+ * this mirrors the shipped browser-facing routes and answers 401/403 itself.
90
+ * @param ctx - host context, with the optional `connection` service.
91
+ * @param req - incoming request.
92
+ * @param res - response to answer on when rejected.
93
+ * @returns true when the request was already answered and the handler must stop.
94
+ */
95
+ function rejected(ctx, req, res) {
96
+ let rejection
97
+ try {
98
+ rejection = ctx.get?.('connection')?.requestRejection?.(req)
99
+ } catch {
100
+ rejection = undefined
101
+ }
102
+ if (rejection === undefined) return false
103
+ res.statusCode = rejection
104
+ res.end()
105
+ return true
106
+ }
107
+
108
+ /**
109
+ * Mount the reminder feed.
110
+ * @param ctx - host context with `webServer`.
111
+ */
112
+ export function apply(ctx) {
113
+ let seq = 0
114
+ let disposed = false
115
+ /** Published feed items, oldest first. */
116
+ const feed = []
117
+ /** Long-poll waiters: `{ since, flush(), cancel() }`. */
118
+ const waiters = new Set()
119
+ /** Per-session pending completion timers. */
120
+ const completionTimers = new Map()
121
+ /** Pending approval toasts, keyed by approval request id. */
122
+ const approvalTimers = new Map()
123
+
124
+ /**
125
+ * Append one item to the feed and release every waiter that now has news.
126
+ * @param item - event payload; `kind`, `sessionId` and `key` are the contract.
127
+ */
128
+ function publish(item) {
129
+ if (disposed) return
130
+ seq += 1
131
+ const entry = { seq, at: Date.now(), ...item }
132
+ feed.push(entry)
133
+ if (feed.length > MAX_FEED) feed.splice(0, feed.length - MAX_FEED)
134
+ for (const waiter of [...waiters]) {
135
+ try {
136
+ waiter.flush()
137
+ } catch (err) {
138
+ // A waiter whose socket vanished must not break the event fold.
139
+ console.error('[dsh-reminding] failed to release a poll waiter:', err)
140
+ waiter.cancel()
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Hold one poll open until there is something newer than `since`.
147
+ * @param req - waiting request (its close cancels the wait).
148
+ * @param res - response to answer on.
149
+ * @param since - last sequence the page has already processed.
150
+ */
151
+ function hold(req, res, since) {
152
+ let settled = false
153
+ const waiter = {}
154
+ const finish = (events) => {
155
+ if (settled) return
156
+ settled = true
157
+ clearTimeout(timer)
158
+ waiters.delete(waiter)
159
+ try {
160
+ writeJson(res, { seq, events })
161
+ } catch {
162
+ // The page navigated away while the answer was in flight.
163
+ }
164
+ }
165
+ const timer = setTimeout(() => finish([]), MAX_WAIT_MS)
166
+ if (typeof timer.unref === 'function') timer.unref()
167
+ waiter.flush = () => {
168
+ const events = feed.filter((entry) => entry.seq > since)
169
+ if (events.length > 0) finish(events)
170
+ }
171
+ waiter.cancel = () => {
172
+ if (settled) return
173
+ settled = true
174
+ clearTimeout(timer)
175
+ waiters.delete(waiter)
176
+ }
177
+ waiters.add(waiter)
178
+ req.once('close', waiter.cancel)
179
+ res.once('close', waiter.cancel)
180
+ }
181
+
182
+ /** Cancel a scheduled completion toast for one session. */
183
+ function cancelCompletion(sessionId) {
184
+ const timer = completionTimers.get(sessionId)
185
+ if (timer === undefined) return
186
+ clearTimeout(timer)
187
+ completionTimers.delete(sessionId)
188
+ }
189
+
190
+ /**
191
+ * Schedule the completion reminder for one settled turn; a new turn or a new
192
+ * user message inside the debounce window cancels it.
193
+ * @param sessionId - the settled conversation.
194
+ * @param turn - the turn that ended.
195
+ */
196
+ function scheduleCompletion(sessionId, turn) {
197
+ cancelCompletion(sessionId)
198
+ const timer = setTimeout(() => {
199
+ completionTimers.delete(sessionId)
200
+ publish({ kind: 'completed', key: `turn:${sessionId}:${turn}`, sessionId, turn })
201
+ }, COMPLETION_DEBOUNCE_MS)
202
+ if (typeof timer.unref === 'function') timer.unref()
203
+ completionTimers.set(sessionId, timer)
204
+ }
205
+
206
+ /**
207
+ * Fold one committed session event into feed items. Everything here reads
208
+ * only documented event payloads; an unexpected shape is contained so a
209
+ * reminder can never break the session append that produced it.
210
+ * @param session - the session whose log grew.
211
+ * @param event - the committed event.
212
+ */
213
+ function onSessionEvent(session, event) {
214
+ try {
215
+ if (disposed) return
216
+ if (!shouldNotifySession(session)) return
217
+ const sessionId = String(session?.header?.id ?? session?.id ?? '')
218
+ if (sessionId === '') return
219
+ const data = event?.data ?? {}
220
+ switch (event?.type) {
221
+ case 'approval/asked': {
222
+ const approvalId = String(data.id ?? '')
223
+ if (approvalId === '') break
224
+ const timer = setTimeout(() => {
225
+ approvalTimers.delete(approvalId)
226
+ publish({
227
+ kind: 'approval',
228
+ key: `approval:${approvalId}`,
229
+ sessionId,
230
+ toolName: typeof data.toolName === 'string' ? data.toolName : '',
231
+ reason: typeof data.reason === 'string' ? data.reason : '',
232
+ })
233
+ }, APPROVAL_NOTIFY_DELAY_MS)
234
+ if (typeof timer.unref === 'function') timer.unref()
235
+ approvalTimers.set(approvalId, timer)
236
+ break
237
+ }
238
+ case 'approval/decided': {
239
+ const approvalId = String(data.id ?? '')
240
+ const timer = approvalTimers.get(approvalId)
241
+ if (timer !== undefined) {
242
+ clearTimeout(timer)
243
+ approvalTimers.delete(approvalId)
244
+ }
245
+ publish({
246
+ kind: 'resolved',
247
+ key: `approval:${approvalId}`,
248
+ sessionId,
249
+ outcome: typeof data.outcome === 'string' ? data.outcome : '',
250
+ })
251
+ break
252
+ }
253
+ case 'tool/call': {
254
+ if (data.name !== USER_QUESTION_TOOL) return
255
+ const questions = parseQuestions(data.arguments)
256
+ if (questions.length === 0) return
257
+ const first = questions[0] ?? {}
258
+ const callId = String(data.callId ?? '')
259
+ if (callId === '') return
260
+ publish({
261
+ kind: 'question',
262
+ key: `question:${callId}`,
263
+ sessionId,
264
+ callId,
265
+ question: typeof first.question === 'string' ? first.question : '',
266
+ header: typeof first.header === 'string' ? first.header : '',
267
+ count: questions.length,
268
+ planReview: first?.intent?.kind === 'plan-review',
269
+ })
270
+ break
271
+ }
272
+ case 'tool/result': {
273
+ const callId = String(data?.message?.toolCallId ?? '')
274
+ if (callId === '') return
275
+ publish({ kind: 'resolved', key: `question:${callId}`, sessionId })
276
+ break
277
+ }
278
+ case 'turn/start':
279
+ case 'user/message': {
280
+ cancelCompletion(sessionId)
281
+ break
282
+ }
283
+ case 'turn/end': {
284
+ const reason = data.reason ?? {}
285
+ if (reason.kind === 'completed') {
286
+ scheduleCompletion(sessionId, data.turn)
287
+ } else if (reason.kind === 'error') {
288
+ publish({
289
+ kind: 'error',
290
+ key: `turn:${sessionId}:${String(data.turn ?? '')}:error`,
291
+ sessionId,
292
+ message: typeof reason?.error?.message === 'string' ? reason.error.message : '',
293
+ })
294
+ } else if (reason.kind === 'max-tokens') {
295
+ publish({
296
+ kind: 'max-tokens',
297
+ key: `turn:${sessionId}:${String(data.turn ?? '')}:max`,
298
+ sessionId,
299
+ })
300
+ }
301
+ break
302
+ }
303
+ default:
304
+ break
305
+ }
306
+ } catch (err) {
307
+ console.error('[dsh-reminding] failed to fold a session event:', err)
308
+ }
309
+ }
310
+
311
+ ctx.on('session/event', onSessionEvent)
312
+ ctx.on('agent/disposed', (payload) => {
313
+ const id = payload?.agent?.id
314
+ if (id !== undefined) cancelCompletion(String(id))
315
+ })
316
+
317
+ ctx.effect(() => {
318
+ disposed = false
319
+ return () => {
320
+ disposed = true
321
+ for (const timer of completionTimers.values()) clearTimeout(timer)
322
+ completionTimers.clear()
323
+ for (const timer of approvalTimers.values()) clearTimeout(timer)
324
+ approvalTimers.clear()
325
+ for (const waiter of [...waiters]) waiter.cancel()
326
+ waiters.clear()
327
+ }
328
+ }, 'dsh-reminding: lifecycle')
329
+
330
+ ctx.effect(() => ctx.webServer.register({
331
+ kind: 'exact',
332
+ path: ROUTE_POLL,
333
+ handler: (req, res) => {
334
+ if (rejected(ctx, req, res)) return
335
+ let since = -1
336
+ let wait = true
337
+ try {
338
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
339
+ const raw = Number(url.searchParams.get('since'))
340
+ if (Number.isFinite(raw)) since = Math.trunc(raw)
341
+ wait = url.searchParams.get('wait') !== '0'
342
+ } catch {
343
+ since = -1
344
+ }
345
+ // A negative `since` is the initial handshake: report the current
346
+ // sequence without replaying anything a freshly loaded page never saw.
347
+ // `wait=0` is a non-blocking snapshot, used by diagnostics and tests.
348
+ const backlog = since < 0 ? [] : feed.filter((entry) => entry.seq > since)
349
+ if (since < 0 || backlog.length > 0 || since > seq || !wait) {
350
+ writeJson(res, { seq, events: backlog })
351
+ return
352
+ }
353
+ hold(req, res, since)
354
+ },
355
+ }), 'dsh-reminding: /reminding/poll')
356
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "System Reminders",
4
+ "description": "OS notifications when a step needs your approval, the model asks a question, or a turn finishes; click a toast to jump back to that conversation. Works on Windows, macOS and Linux."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "系统提醒",
4
+ "description": "对话需要授权、模型提问或回复完成时弹出系统通知;点击通知直接回到对应对话。支持 Windows / macOS / Linux。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "dsh-reminding",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "System reminders for DeepSeek Harness: OS notifications when a step needs your approval, the model asks a question, or a turn finishes; click a toast to jump back to that conversation. Works on Windows, macOS and Linux.",
5
+ "license": "MIT",
6
+ "author": "liuchaoxu <ischaoxu@163.com>",
7
+ "homepage": "https://github.com/liuchaoxu/dsh-reminding#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/liuchaoxu/dsh-reminding.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/liuchaoxu/dsh-reminding/issues"
14
+ },
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "deepseek",
20
+ "cordis",
21
+ "cordis-plugin",
22
+ "notification",
23
+ "notifications",
24
+ "desktop-notification",
25
+ "system-notification",
26
+ "toast",
27
+ "reminder",
28
+ "approval",
29
+ "user-question",
30
+ "turn-completed",
31
+ "cross-platform",
32
+ "windows",
33
+ "macos",
34
+ "linux"
35
+ ],
36
+ "type": "module",
37
+ "main": "index.js",
38
+ "exports": {
39
+ ".": "./index.js",
40
+ "./client": "./client.js",
41
+ "./cordis.patch.yml": "./cordis.patch.yml",
42
+ "./locale/*.json": "./locale/*.json",
43
+ "./icon": "./icon.svg",
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "index.js",
48
+ "client.js",
49
+ "cordis.patch.yml",
50
+ "locale/*.json",
51
+ "icon.svg",
52
+ "README.md",
53
+ "LICENSE"
54
+ ],
55
+ "engines": {
56
+ "node": ">=18",
57
+ "dsh": ">=0.2.1-rc.2"
58
+ },
59
+ "scripts": {
60
+ "check": "node scripts/check.mjs",
61
+ "test": "node scripts/test-host.mjs && node scripts/test-client.mjs",
62
+ "test:host": "node scripts/test-host.mjs",
63
+ "test:client": "node scripts/test-client.mjs",
64
+ "pack:check": "npm pack --dry-run",
65
+ "prepublishOnly": "npm run check && npm test"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "dsh": {
71
+ "manifestVersion": 1,
72
+ "bundle": {
73
+ "patch": "./cordis.patch.yml"
74
+ },
75
+ "compatibility": {
76
+ "dshReleases": {
77
+ "0.2.1-rc.2": "compatible"
78
+ }
79
+ },
80
+ "client": {
81
+ "platform": "web",
82
+ "immediately": true,
83
+ "inject": [
84
+ "@deepseek-ai/dsh-client-connection",
85
+ "@deepseek-ai/dsh-client-locale"
86
+ ]
87
+ }
88
+ }
89
+ }