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 +21 -0
- package/README.md +203 -2
- package/client.js +540 -0
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/index.js +356 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +87 -4
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,89 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-reminding",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|