@liangliang205/dsh-task-reminder 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -2
- package/cordis.patch.yml +34 -0
- package/lib/channels.js +189 -0
- package/lib/client.js +114 -0
- package/lib/index.js +275 -0
- package/lib/payload.js +188 -0
- package/lib/presence.js +60 -0
- package/lib/server.js +116 -0
- package/lib/sound.ps1 +15 -0
- package/lib/toast.ps1 +14 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-task-reminder contributors
|
|
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,163 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-task-reminder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
会话**完成、失败、被阻塞、等你授权、等你回答**时提醒你:带 DeepSeek Harness 名字和图标的系统通知 + 提示音 + 可选 Webhook。你正看着窗口时不打扰。
|
|
4
|
+
|
|
5
|
+
DSH 插件 bundle,零改动 DSH 核心,可发布到 npm 后用 `dsh plugin add` 一键安装。
|
|
6
|
+
|
|
7
|
+
> **npm 包名**:`@liangliang205/dsh-task-reminder`(无 scope 的 `dsh-task-reminder` 被 npm 以“与 `dsh-taskreminder` 过于相似”拒绝)。
|
|
8
|
+
|
|
9
|
+
## 它会提醒的五种时刻
|
|
10
|
+
|
|
11
|
+
| 时刻 | 判定依据(会话日志事件) | 文案 |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| 任务完成 | `turn/end` → `reason.kind = completed` | DSH · 任务完成 |
|
|
14
|
+
| 任务失败 | `turn/end` → `error`(带错误摘要) | DSH · 任务失败 |
|
|
15
|
+
| 被阻塞 | `turn/end` → `blocked` | DSH · 等待你处理 |
|
|
16
|
+
| 等你授权 | `approval/asked`(同一 `id` 只提醒一次) | DSH · 需要授权(带工具名与原因) |
|
|
17
|
+
| 等你回答 | `tool/call` 且工具是 `ask_user_question` | DSH · 需要回答(带第一个问题) |
|
|
18
|
+
| 超限 / 中止 | `turn/end` → `max-tokens` / `aborted` | 可分别用配置关掉 |
|
|
19
|
+
|
|
20
|
+
`forked`、`interrupted` 不是真实的任务结束,永不提醒;子代理会话默认静默。
|
|
21
|
+
|
|
22
|
+
## 你正看着窗口时不打扰
|
|
23
|
+
|
|
24
|
+
浏览器侧(`lib/client.js`)把「页面可见 + 窗口有焦点」上报给 Host;Host 在收到报告后的 `activeWindowMs`(默认 45 秒)内认为你在前台,期间的提醒只写日志、不弹通知。页面失焦、切走、关闭都会立即上报。
|
|
25
|
+
|
|
26
|
+
- **失败开放**:没有任何客户端报告时(比如 headless profile、页面还没加载),一律照常提醒——宁可吵,不可静默失效。
|
|
27
|
+
- **心跳**:前台状态每 20 秒续期,页面崩溃后 45 秒自动过期,不会永久静音。
|
|
28
|
+
- **无浏览器构建**:`lib/client.js` 直接以 `window.__ModuleLoader__` 形式手写,仓库里没有打包步骤。
|
|
29
|
+
|
|
30
|
+
诊断端点(仅回环/受信 host 可访问):
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
# 当前是否判定为"你在前台",以及每个页面实例的上报
|
|
34
|
+
curl http://127.0.0.1:55543/task-reminder/api/state
|
|
35
|
+
# {"ok":true,"foreground":false,"clients":[{"clientId":"…","active":false,"ageMs":2604}]}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
上报接口 `POST /task-reminder/api/visibility` 强制要求 `content-type: application/json`——跨站表单发不出这个头,浏览器的预检又不会被批准,所以第三方网页无法伪造"你在前台"。
|
|
39
|
+
|
|
40
|
+
## 和已有的 `dsh-task-notify` 什么关系
|
|
41
|
+
|
|
42
|
+
npm 上已经有 `dsh-task-notify@0.4.1`(Gitee: `chy959799/dsh-task-notify`),功能相近,所以本包换名发布,避免撞名。两者取舍不同:
|
|
43
|
+
|
|
44
|
+
| 能力 | 本包 `dsh-task-reminder` | 已有 `dsh-task-notify` |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Windows 通知归属 | WinRT toast + `com.deepseek.dsh` AUMID,显示 **DeepSeek Harness** + DSH 图标 | `node-notifier`(Windows 走 SnoreToast,需要额外可执行文件) |
|
|
47
|
+
| 提醒时刻 | 完成/失败/阻塞/阻塞授权/等待回答/超限/中止 | 完成 + 授权 + 提问 |
|
|
48
|
+
| 推送渠道 | ✅ 系统通知 + 提示音 + Webhook(固定 payload) | 仅系统通知 |
|
|
49
|
+
| 前台静默 | ✅ 可见 + 有焦点才算前台,超时自动过期 | ✅ 仅页面可见性 |
|
|
50
|
+
| 设置界面 | ❌ 只走 `cordis.patch.yml`(见下) | ✅ 设置页 |
|
|
51
|
+
| 运行时依赖 | 0(纯 Node + 系统能力) | `node-notifier`、`schemastery` |
|
|
52
|
+
|
|
53
|
+
一句话:**要"推到手机/别的机器"或"原生 DSH 品牌通知"用这个;要图形化设置页用那个。**
|
|
54
|
+
|
|
55
|
+
### 为什么暂时没有设置页
|
|
56
|
+
|
|
57
|
+
DSH 的设置页给插件投影的只有 schema 里标了 `.volatile()` 的字段(`dsh-settings` 的 form projection),而且要自带 React 客户端包注册 `settings.section` slot 才有完整界面。本包当前选择零依赖 + 零构建,所以配置走 profile 的 `cordis.patch.yml`;要图形化编辑,下一步是把配置改成 schemastery `Config` 并补一个 `settings.section` 客户端包。
|
|
58
|
+
|
|
59
|
+
## 安装
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
# 从 npm 安装(推荐)
|
|
63
|
+
dsh plugin --profile desktop add @liangliang205/dsh-task-reminder
|
|
64
|
+
|
|
65
|
+
# 本地路径(开发)
|
|
66
|
+
dsh plugin --profile desktop add D:\pr\dsh-task-reminder
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
也可以让 agent 用 `plugin_manager` 工具:`install_bundle` + 目标名或绝对路径。装完热生效,不用重启应用。
|
|
70
|
+
|
|
71
|
+
## 配置
|
|
72
|
+
|
|
73
|
+
默认值在 `cordis.patch.yml`。要改就在 profile 的 `cordis.patch.yml` 里按 id 覆盖;patch 会整体替换该行 `config`,没写的字段回落到插件内置默认值:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
- id: task-reminder
|
|
77
|
+
name: '@liangliang205/dsh-task-reminder'
|
|
78
|
+
config:
|
|
79
|
+
onlyWhenBlurred: false # 不管你在不在看,都弹
|
|
80
|
+
minDurationMs: 8000 # 8 秒内的短任务不提醒
|
|
81
|
+
notifyOn: [completed, blocked]
|
|
82
|
+
webhookUrl: https://example.com/hook
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| 字段 | 默认 | 含义 |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `locale` | `zh` | 文案语言,`zh` / `en` |
|
|
88
|
+
| `desktop` | `true` | 系统通知:Windows toast / macOS 通知中心 / Linux `notify-send` |
|
|
89
|
+
| `sound` | `true` | 提示音;与通知同开时 toast 自身静音,避免响两声 |
|
|
90
|
+
| `onlyWhenBlurred` | `true` | 页面在前台时静默(失败开放) |
|
|
91
|
+
| `notifyOn` | 全部 5 种 | 哪些 `turn/end` 原因提醒 |
|
|
92
|
+
| `notifyOnApproval` | `true` | 工具等待授权时提醒 |
|
|
93
|
+
| `notifyOnQuestion` | `true` | agent 提问时提醒 |
|
|
94
|
+
| `minDurationMs` | `0` | 短于此时长的任务不提醒(毫秒) |
|
|
95
|
+
| `includeSubagents` | `false` | 子代理会话是否也提醒 |
|
|
96
|
+
| `minGapMs` | `1200` | 提醒之间的最小间隔 |
|
|
97
|
+
| `activeWindowMs` | `45000` | 一次前台报告的有效期(毫秒) |
|
|
98
|
+
| `soundFile` | 空 | 自定义提示音(`.wav`) |
|
|
99
|
+
| `toastAppId` | `com.deepseek.dsh` | toast 归属的 AppUserModelID |
|
|
100
|
+
| `webhookUrl` | 空 | 非空则 POST payload |
|
|
101
|
+
| `webhookTimeoutMs` | `5000` | Webhook 超时 |
|
|
102
|
+
| `notifyOnStart` | `false` | 挂载时先发一条"已开启",用于验证接线 |
|
|
103
|
+
| `verbose` | `false` | 每次提醒写一行日志 |
|
|
104
|
+
|
|
105
|
+
### Webhook payload
|
|
106
|
+
|
|
107
|
+
字段风格对齐 StarRailAssistant 的通知数据包,现成的 webhook 消费端(Bark / Server酱 / n8n / OneBot)可以直接接:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"title": "DSH · 任务完成",
|
|
112
|
+
"event": "session.turn.completed",
|
|
113
|
+
"result": "success",
|
|
114
|
+
"timestamp": "2026-11-07 20:25:39",
|
|
115
|
+
"sessionId": "smoke-session",
|
|
116
|
+
"sessionTitle": "本地实现任务完成提醒",
|
|
117
|
+
"cwd": "D:\\pr\\deepseek-harness",
|
|
118
|
+
"durationMs": 74000,
|
|
119
|
+
"message": "会话任务已完成:本地实现任务完成提醒",
|
|
120
|
+
"detail": "1m 14s · deepseek-harness"
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`event` 取值:`session.turn.completed` / `session.turn.failed` / `session.turn.blocked` / `session.turn.cancelled` / `session.turn.max-tokens` / `session.approval.asked` / `session.question.asked`;`result` 取值 `success` / `failure` / `blocked` / `cancelled` / `truncated`,其中"等你处理"的三类都是 `blocked`,便于消费端优先处理。
|
|
125
|
+
|
|
126
|
+
## 验证
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
node --test # 20 个行为测试:文案、配置校验、真实 HTTP webhook、子代理静默、
|
|
130
|
+
# 授权去重、提问解析、前台门控、路由鉴权、浏览器半体的上报与清理
|
|
131
|
+
node tools/notify-smoke.mjs # 手工触发一次真实通知(走生产代码路径)
|
|
132
|
+
npm pack --dry-run # 检查发布内容
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 发布
|
|
136
|
+
|
|
137
|
+
```powershell
|
|
138
|
+
npm login # 必做一次
|
|
139
|
+
npm version patch # 0.2.0 → 0.2.1(首次发布 0.2.0 时不要执行)
|
|
140
|
+
npm publish --access public
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
两个踩过的坑:
|
|
144
|
+
|
|
145
|
+
1. **首次发布会要求 2FA**:邮件 OTP 型账号必须用带 **Bypass two-factor authentication** 的 granular token,登录验证码不能用于发布。注意 npm 已公告 2027 年 1 月起取消“token 直发”,之后要走 `npm stage publish` + 网页 2FA 批准。
|
|
146
|
+
2. **包名过近似会被拒**:`dsh-task-reminder` 与已存在的 `dsh-taskreminder` 被判相似,因此发布在 `@liangliang205` scope 下。
|
|
147
|
+
|
|
148
|
+
发布前确认:
|
|
149
|
+
|
|
150
|
+
1. `dsh.bundle.patch` 指向 `cordis.patch.yml`——DSH 认出"这是组合包"的唯一依据;
|
|
151
|
+
2. `dsh.client.platform = "web"` 且 `exports["./client"]` 存在——浏览器半体靠这两个才能被扫到并注入;
|
|
152
|
+
3. `files` 覆盖 `lib/`、`cordis.patch.yml`、`README.md`、`LICENSE`。
|
|
153
|
+
|
|
154
|
+
想被 **Discover** 页发现,需要一个市场索引 JSON(格式 1,见 DSH 仓库 `packages/util/plugin-market-index`),托管后可让用户把自己的索引 URL 加进 Discover;进官方目录则要走 DSH 仓库的 PR。
|
|
155
|
+
|
|
156
|
+
## 已知限制
|
|
157
|
+
|
|
158
|
+
- **只验证了 Windows**;macOS / Linux 分支按平台命令行写好,未真机跑过。
|
|
159
|
+
- **每个 turn 都提醒**:一个任务被拆成多轮追问会提醒多次,还没按"整段任务"聚合。
|
|
160
|
+
- **应用退出后没有提醒**:提醒由 host 进程内插件发出,没有后台守护。
|
|
161
|
+
- **toast 依赖已注册的 AUMID**:源码跑 Web 版或换机器时,要么用已安装的桌面版,要么把 `toastAppId` 换成该机器上已注册的 AUMID,否则 Windows 会静默丢弃通知。
|
|
162
|
+
- **没有设置页**:配置只走 `cordis.patch.yml`,见上文原因。
|
|
163
|
+
- **前台判定需要页面配合**:页面没加载(例如刚启动还没打开窗口)时按"不在前台"处理,会照常提醒。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# dsh-task-reminder bundle patch: one Host row over the profile root.
|
|
2
|
+
#
|
|
3
|
+
# Install into a profile with:
|
|
4
|
+
# dsh plugin --profile <name> add @liangliang205/dsh-task-reminder
|
|
5
|
+
# or send the plugin_manager install_bundle target (a registry name, an
|
|
6
|
+
# absolute path, or a `file:`/`link:` spec). The row below is the shipped
|
|
7
|
+
# default; override `config` by id from the profile's own cordis.patch.yml.
|
|
8
|
+
|
|
9
|
+
- insert:
|
|
10
|
+
- id: task-reminder
|
|
11
|
+
name: '@liangliang205/dsh-task-reminder'
|
|
12
|
+
config:
|
|
13
|
+
locale: zh
|
|
14
|
+
desktop: true
|
|
15
|
+
sound: true
|
|
16
|
+
onlyWhenBlurred: true
|
|
17
|
+
notifyOn:
|
|
18
|
+
- completed
|
|
19
|
+
- error
|
|
20
|
+
- blocked
|
|
21
|
+
- aborted
|
|
22
|
+
- max-tokens
|
|
23
|
+
notifyOnApproval: true
|
|
24
|
+
notifyOnQuestion: true
|
|
25
|
+
minDurationMs: 0
|
|
26
|
+
includeSubagents: false
|
|
27
|
+
minGapMs: 1200
|
|
28
|
+
activeWindowMs: 45000
|
|
29
|
+
soundFile: ''
|
|
30
|
+
toastAppId: com.deepseek.dsh
|
|
31
|
+
webhookUrl: ''
|
|
32
|
+
webhookTimeoutMs: 5000
|
|
33
|
+
notifyOnStart: false
|
|
34
|
+
verbose: false
|
package/lib/channels.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery channels for one reminder payload: an OS notification, a sound, and
|
|
3
|
+
* an optional webhook. Delivery is best-effort by design — a reminder must never
|
|
4
|
+
* fail a turn — so every channel contains its own errors and reports them to the
|
|
5
|
+
* plugin log.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const scriptDir = fileURLToPath(new URL('.', import.meta.url))
|
|
14
|
+
|
|
15
|
+
/** Longest a notification helper may run before it is killed. */
|
|
16
|
+
const HELPER_TIMEOUT_MS = 10_000
|
|
17
|
+
|
|
18
|
+
/** XML-escape one toast text node. */
|
|
19
|
+
function escapeXml(value) {
|
|
20
|
+
return String(value)
|
|
21
|
+
.replaceAll('&', '&')
|
|
22
|
+
.replaceAll('<', '<')
|
|
23
|
+
.replaceAll('>', '>')
|
|
24
|
+
.replaceAll('"', '"')
|
|
25
|
+
.replaceAll("'", ''')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Escape one AppleScript string literal. */
|
|
29
|
+
function escapeAppleScript(value) {
|
|
30
|
+
return String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run one helper process and reject with its stderr on failure. */
|
|
34
|
+
function run(command, args) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
execFile(command, args, { timeout: HELPER_TIMEOUT_MS, windowsHide: true }, (error, stdout, stderr) => {
|
|
37
|
+
if (error !== null) {
|
|
38
|
+
const detail = typeof stderr === 'string' && stderr.trim() !== '' ? stderr.trim() : error.message
|
|
39
|
+
reject(new Error(`${command} failed: ${detail}`))
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
resolve(stdout)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `ToastGeneric` document; `silent` suppresses the toast's own cue when the sound channel is on. */
|
|
48
|
+
function toastXml(payload, silent) {
|
|
49
|
+
const detail = payload.detail === undefined ? '' : `<text>${escapeXml(payload.detail)}</text>`
|
|
50
|
+
return '<?xml version="1.0" encoding="utf-8"?>'
|
|
51
|
+
+ '<toast duration="short">'
|
|
52
|
+
+ '<visual><binding template="ToastGeneric">'
|
|
53
|
+
+ `<text>${escapeXml(payload.title)}</text>`
|
|
54
|
+
+ `<text>${escapeXml(payload.message)}</text>`
|
|
55
|
+
+ detail
|
|
56
|
+
+ '</binding></visual>'
|
|
57
|
+
+ (silent ? '<audio silent="true"/>' : '')
|
|
58
|
+
+ '</toast>'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Show a Windows toast under the DSH AppUserModelID so it carries the app name and icon. */
|
|
62
|
+
async function windowsToast(payload, settings) {
|
|
63
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-task-notify-'))
|
|
64
|
+
const xmlPath = join(dir, 'toast.xml')
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(xmlPath, toastXml(payload, settings.sound), 'utf8')
|
|
67
|
+
await run('powershell.exe', [
|
|
68
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
69
|
+
'-File', join(scriptDir, 'toast.ps1'),
|
|
70
|
+
'-XmlPath', xmlPath,
|
|
71
|
+
'-AppId', settings.toastAppId,
|
|
72
|
+
])
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(dir, { recursive: true, force: true })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Show a notification on the current platform; unsupported platforms report why. */
|
|
79
|
+
async function showDesktop(payload, settings, log) {
|
|
80
|
+
switch (process.platform) {
|
|
81
|
+
case 'win32':
|
|
82
|
+
await windowsToast(payload, settings)
|
|
83
|
+
return
|
|
84
|
+
case 'darwin':
|
|
85
|
+
await run('osascript', [
|
|
86
|
+
'-e',
|
|
87
|
+
`display notification "${escapeAppleScript(payload.message)}" with title "${escapeAppleScript(payload.title)}"`,
|
|
88
|
+
])
|
|
89
|
+
return
|
|
90
|
+
case 'linux':
|
|
91
|
+
await run('notify-send', ['--app-name=DeepSeek Harness', payload.title, payload.message])
|
|
92
|
+
return
|
|
93
|
+
default:
|
|
94
|
+
log(`desktop notifications are not supported on ${process.platform}; skipping`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Play the configured cue; `soundFile` overrides the platform default. */
|
|
99
|
+
async function playSound(settings, log) {
|
|
100
|
+
const file = settings.soundFile
|
|
101
|
+
if (process.platform === 'win32') {
|
|
102
|
+
await run('powershell.exe', [
|
|
103
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
104
|
+
'-File', join(scriptDir, 'sound.ps1'),
|
|
105
|
+
'-SoundFile', file,
|
|
106
|
+
])
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (process.platform === 'darwin') {
|
|
110
|
+
await run('afplay', [file === '' ? '/System/Library/Sounds/Glass.aiff' : file])
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (process.platform === 'linux') {
|
|
114
|
+
await run('paplay', [file === '' ? '/usr/share/sounds/freedesktop/stereo/complete.oga' : file])
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
log(`sound playback is not supported on ${process.platform}; skipping`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** POST the payload to one webhook; failures stay local to the log. */
|
|
121
|
+
async function postWebhook(payload, settings, log) {
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch(settings.webhookUrl, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: JSON.stringify(payload),
|
|
127
|
+
signal: AbortSignal.timeout(settings.webhookTimeoutMs),
|
|
128
|
+
})
|
|
129
|
+
if (!response.ok) log(`webhook answered ${response.status} ${response.statusText}`)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
log(`webhook delivery failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Create the channel set.
|
|
137
|
+
* @param {object} settings - resolved plugin settings.
|
|
138
|
+
* @param {{ log: (message: string, error?: unknown) => void }} hooks - plugin logger.
|
|
139
|
+
* @returns {{ notify: (payload: object) => void, dispose: () => void }} dispatcher.
|
|
140
|
+
*/
|
|
141
|
+
export function createChannels(settings, hooks) {
|
|
142
|
+
const { log } = hooks
|
|
143
|
+
const queue = []
|
|
144
|
+
let pumping = false
|
|
145
|
+
let disposed = false
|
|
146
|
+
|
|
147
|
+
const report = (label) => (error) => {
|
|
148
|
+
log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Delay between notification groups so a burst of finished turns does not stack toasts. */
|
|
152
|
+
const idle = () => new Promise((resolve) => {
|
|
153
|
+
setTimeout(resolve, settings.minGapMs).unref()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
async function pump() {
|
|
157
|
+
if (pumping) return
|
|
158
|
+
pumping = true
|
|
159
|
+
try {
|
|
160
|
+
while (queue.length > 0 && !disposed) {
|
|
161
|
+
const group = queue.shift()
|
|
162
|
+
await Promise.allSettled(group.map((task) => task()))
|
|
163
|
+
if (queue.length > 0 && !disposed) await idle()
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
pumping = false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const enqueue = (tasks) => {
|
|
171
|
+
if (disposed) return
|
|
172
|
+
queue.push(tasks)
|
|
173
|
+
void pump()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
notify(payload) {
|
|
178
|
+
const tasks = []
|
|
179
|
+
if (settings.desktop) tasks.push(() => showDesktop(payload, settings, log).catch(report('desktop notification')))
|
|
180
|
+
if (settings.sound) tasks.push(() => playSound(settings, log).catch(report('sound')))
|
|
181
|
+
if (tasks.length > 0) enqueue(tasks)
|
|
182
|
+
if (settings.webhookUrl !== '') void postWebhook(payload, settings, log)
|
|
183
|
+
},
|
|
184
|
+
dispose() {
|
|
185
|
+
disposed = true
|
|
186
|
+
queue.length = 0
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half: report whether this page is in front, so the host can stay
|
|
3
|
+
* quiet while you are looking at it.
|
|
4
|
+
*
|
|
5
|
+
* Written directly in the client module-loader form (`window.__ModuleLoader__`)
|
|
6
|
+
* because this package ships without a browser build step. It registers no
|
|
7
|
+
* slot and renders nothing: one JSON POST per state change, plus a heartbeat
|
|
8
|
+
* that expires when the page is closed.
|
|
9
|
+
*/
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
// Must equal the npm package name: the client module table is keyed by the
|
|
12
|
+
// graph row id, which is the package name. A mismatch fails the whole web
|
|
13
|
+
// boot with "no registered factory".
|
|
14
|
+
id: '@liangliang205/dsh-task-reminder',
|
|
15
|
+
factory: function (_require) {
|
|
16
|
+
'use strict'
|
|
17
|
+
var module = { exports: {} }
|
|
18
|
+
|
|
19
|
+
/** Host endpoint installed by the plugin's own web route. */
|
|
20
|
+
var ENDPOINT = '/task-reminder/api/visibility'
|
|
21
|
+
/** Re-report interval, shorter than the host's expiry for one report. */
|
|
22
|
+
var HEARTBEAT_MS = 20000
|
|
23
|
+
|
|
24
|
+
var clientId = (function () {
|
|
25
|
+
try {
|
|
26
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
27
|
+
return crypto.randomUUID()
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
// A browser without crypto.randomUUID falls through to the random id.
|
|
31
|
+
}
|
|
32
|
+
return 'c' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
|
33
|
+
})()
|
|
34
|
+
|
|
35
|
+
/** POST one report; failures are irrelevant because the next heartbeat retries. */
|
|
36
|
+
function post(active, keepalive) {
|
|
37
|
+
var body = JSON.stringify({ client: clientId, active: active === true })
|
|
38
|
+
try {
|
|
39
|
+
if (typeof fetch === 'function') {
|
|
40
|
+
fetch(ENDPOINT, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: body,
|
|
44
|
+
keepalive: keepalive === true,
|
|
45
|
+
}).catch(function () {})
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// Fall through to the beacon path.
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
53
|
+
navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' }))
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Offline or blocked; nothing to retry beyond the next heartbeat.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Visible and focused; a desktop window can lose focus while still visible. */
|
|
61
|
+
function isActive() {
|
|
62
|
+
try {
|
|
63
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'visible') return false
|
|
64
|
+
return typeof document.hasFocus !== 'function' || document.hasFocus()
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Attach the focus reporters for this page instance.
|
|
72
|
+
* @param {{ effect?: (factory: () => () => void) => unknown }} ctx - client plugin context.
|
|
73
|
+
*/
|
|
74
|
+
function apply(ctx) {
|
|
75
|
+
var last = null
|
|
76
|
+
|
|
77
|
+
var report = function (keepalive) {
|
|
78
|
+
var active = isActive()
|
|
79
|
+
last = active
|
|
80
|
+
post(active, keepalive)
|
|
81
|
+
}
|
|
82
|
+
var onWake = function () {
|
|
83
|
+
var active = isActive()
|
|
84
|
+
if (active !== last) report(false)
|
|
85
|
+
}
|
|
86
|
+
var onLeave = function () {
|
|
87
|
+
last = false
|
|
88
|
+
post(false, true)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
document.addEventListener('visibilitychange', onWake)
|
|
92
|
+
window.addEventListener('focus', onWake)
|
|
93
|
+
window.addEventListener('blur', onWake)
|
|
94
|
+
window.addEventListener('pagehide', onLeave)
|
|
95
|
+
report(false)
|
|
96
|
+
var timer = setInterval(function () {
|
|
97
|
+
// A heartbeat keeps the last known state current without extra state changes.
|
|
98
|
+
post(last === true, false)
|
|
99
|
+
}, HEARTBEAT_MS)
|
|
100
|
+
|
|
101
|
+
var cleanup = function () {
|
|
102
|
+
clearInterval(timer)
|
|
103
|
+
document.removeEventListener('visibilitychange', onWake)
|
|
104
|
+
window.removeEventListener('focus', onWake)
|
|
105
|
+
window.removeEventListener('blur', onWake)
|
|
106
|
+
window.removeEventListener('pagehide', onLeave)
|
|
107
|
+
}
|
|
108
|
+
if (typeof ctx.effect === 'function') ctx.effect(function () { return cleanup })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { name: 'task-reminder-client', apply: apply }
|
|
112
|
+
return module.exports
|
|
113
|
+
},
|
|
114
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task-reminder` — remind the user when a session turn finishes or stalls on
|
|
3
|
+
* the user.
|
|
4
|
+
*
|
|
5
|
+
* The plugin reads the durable session log: `turn/start` and `session/title`
|
|
6
|
+
* describe the work, `turn/end` closes it, `approval/asked` and a question
|
|
7
|
+
* tool call mark a wait. One payload per event fans out to an OS notification,
|
|
8
|
+
* an audible cue, and an optional webhook. It registers no tool, service, or
|
|
9
|
+
* model-visible text: it moves facts the log already records to a place the
|
|
10
|
+
* user notices — and, when a page reports itself in front, it stays quiet.
|
|
11
|
+
*/
|
|
12
|
+
import { buildAttentionPayload, buildPayload, buildStartupPayload, NOTIFIABLE_REASONS } from './payload.js'
|
|
13
|
+
import { createChannels } from './channels.js'
|
|
14
|
+
import { createPresenceTracker } from './presence.js'
|
|
15
|
+
import { installRoute } from './server.js'
|
|
16
|
+
|
|
17
|
+
/** Plugin name used by the loader entry. */
|
|
18
|
+
export const name = 'task-reminder'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Defaults for every setting; a deployment overrides them from `cordis.yml`,
|
|
22
|
+
* and nothing here is a hardcoded tunable the profile cannot reach.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULTS = {
|
|
25
|
+
/** Copy language for titles and messages. */
|
|
26
|
+
locale: 'zh',
|
|
27
|
+
/** Show an OS notification (Windows toast, macOS notification, `notify-send`). */
|
|
28
|
+
desktop: true,
|
|
29
|
+
/** Play a cue when a turn finishes. */
|
|
30
|
+
sound: true,
|
|
31
|
+
/** Stay quiet while a page reports itself in front; fails open without reports. */
|
|
32
|
+
onlyWhenBlurred: true,
|
|
33
|
+
/** Reasons that produce a reminder; `forked` and `interrupted` are never live turn ends. */
|
|
34
|
+
notifyOn: [...NOTIFIABLE_REASONS],
|
|
35
|
+
/** Remind when a tool waits for the user's approval. */
|
|
36
|
+
notifyOnApproval: true,
|
|
37
|
+
/** Remind when the agent waits for an answer. */
|
|
38
|
+
notifyOnQuestion: true,
|
|
39
|
+
/** Skip reminders for turns shorter than this, in milliseconds. */
|
|
40
|
+
minDurationMs: 0,
|
|
41
|
+
/** Remind for subagent sessions too; off keeps one reminder per user-visible task. */
|
|
42
|
+
includeSubagents: false,
|
|
43
|
+
/** Minimum spacing between notification groups, in milliseconds. */
|
|
44
|
+
minGapMs: 1200,
|
|
45
|
+
/** How long one foreground report stays current, in milliseconds. */
|
|
46
|
+
activeWindowMs: 45_000,
|
|
47
|
+
/** Absolute custom cue path; empty selects the platform default. */
|
|
48
|
+
soundFile: '',
|
|
49
|
+
/** Windows AppUserModelID that owns the toast; `com.deepseek.dsh` is the desktop build's. */
|
|
50
|
+
toastAppId: 'com.deepseek.dsh',
|
|
51
|
+
/** POST the payload here when non-empty. */
|
|
52
|
+
webhookUrl: '',
|
|
53
|
+
/** Deadline for one webhook request, in milliseconds. */
|
|
54
|
+
webhookTimeoutMs: 5000,
|
|
55
|
+
/** Announce that reminders are on when the plugin mounts; a wiring check. */
|
|
56
|
+
notifyOnStart: false,
|
|
57
|
+
/** Log every dispatched reminder. */
|
|
58
|
+
verbose: false,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const KNOWN_KEYS = new Set(Object.keys(DEFAULTS))
|
|
62
|
+
|
|
63
|
+
/** Tool whose call means the agent is waiting on the user's answer. */
|
|
64
|
+
const QUESTION_TOOL = 'ask_user_question'
|
|
65
|
+
|
|
66
|
+
/** Raise a configuration error naming the offending field. */
|
|
67
|
+
function invalid(field, expected) {
|
|
68
|
+
throw new TypeError(`task-reminder: config.${field} must be ${expected}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate and default the loader configuration. Malformed values fail at load
|
|
73
|
+
* instead of silently disabling reminders.
|
|
74
|
+
* @param {Record<string, unknown> | undefined} raw - the entry's `config`.
|
|
75
|
+
* @returns {typeof DEFAULTS} resolved settings.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveConfig(raw) {
|
|
78
|
+
const input = raw ?? {}
|
|
79
|
+
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
80
|
+
throw new TypeError('task-reminder: config must be a mapping')
|
|
81
|
+
}
|
|
82
|
+
for (const key of Object.keys(input)) {
|
|
83
|
+
if (!KNOWN_KEYS.has(key)) throw new TypeError(`task-reminder: config.${key} is not a known setting`)
|
|
84
|
+
}
|
|
85
|
+
const settings = { ...DEFAULTS, ...input }
|
|
86
|
+
if (settings.locale !== 'zh' && settings.locale !== 'en') invalid('locale', '"zh" or "en"')
|
|
87
|
+
for (const field of [
|
|
88
|
+
'desktop', 'sound', 'onlyWhenBlurred', 'includeSubagents',
|
|
89
|
+
'notifyOnApproval', 'notifyOnQuestion', 'notifyOnStart', 'verbose',
|
|
90
|
+
]) {
|
|
91
|
+
if (typeof settings[field] !== 'boolean') invalid(field, 'a boolean')
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(settings.notifyOn)) invalid('notifyOn', `an array of ${NOTIFIABLE_REASONS.join(', ')}`)
|
|
94
|
+
for (const reason of settings.notifyOn) {
|
|
95
|
+
if (!NOTIFIABLE_REASONS.includes(reason)) {
|
|
96
|
+
throw new TypeError(`task-reminder: config.notifyOn has unknown reason "${reason}"`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const field of ['minDurationMs', 'minGapMs', 'activeWindowMs', 'webhookTimeoutMs']) {
|
|
100
|
+
if (!Number.isFinite(settings[field]) || settings[field] < 0) invalid(field, 'a non-negative number')
|
|
101
|
+
}
|
|
102
|
+
for (const field of ['soundFile', 'toastAppId', 'webhookUrl']) {
|
|
103
|
+
if (typeof settings[field] !== 'string') invalid(field, 'a string')
|
|
104
|
+
}
|
|
105
|
+
if (settings.soundFile !== '' && settings.sound === false) {
|
|
106
|
+
throw new TypeError('task-reminder: config.soundFile is set while config.sound is false')
|
|
107
|
+
}
|
|
108
|
+
return settings
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Describe the wait a `tool/call` puts on the user.
|
|
113
|
+
* @param {{ data: { name: string, arguments: string } }} event - the recorded tool call.
|
|
114
|
+
* @returns {string | undefined} the first question text, or undefined for other tools.
|
|
115
|
+
*/
|
|
116
|
+
export function questionOf(event) {
|
|
117
|
+
if (event.data.name !== QUESTION_TOOL) return undefined
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(event.data.arguments === '' ? '{}' : event.data.arguments)
|
|
120
|
+
const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined
|
|
121
|
+
const text = first?.question ?? first?.header
|
|
122
|
+
return typeof text === 'string' && text.trim() !== '' ? text.trim() : ''
|
|
123
|
+
} catch {
|
|
124
|
+
// A non-JSON argument string still means the tool was called; the reminder
|
|
125
|
+
// says "answer needed" without the question text.
|
|
126
|
+
return ''
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Mount the reminder listener.
|
|
132
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - plugin context.
|
|
133
|
+
* @param {Record<string, unknown>} [config] - loader configuration.
|
|
134
|
+
*/
|
|
135
|
+
export function apply(ctx, config) {
|
|
136
|
+
const settings = resolveConfig(config)
|
|
137
|
+
const logger = ctx.logger
|
|
138
|
+
const log = (message) => {
|
|
139
|
+
if (settings.verbose) logger.info(message)
|
|
140
|
+
}
|
|
141
|
+
const warn = (message, error) => {
|
|
142
|
+
logger.warn(error === undefined ? message : `${message}: ${error instanceof Error ? error.message : String(error)}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const channels = createChannels(settings, { log: warn })
|
|
146
|
+
ctx.effect(() => () => channels.dispose(), 'task-reminder channels')
|
|
147
|
+
|
|
148
|
+
const presence = createPresenceTracker({ activeWindowMs: settings.activeWindowMs })
|
|
149
|
+
// The route waits for the Web host instead of gating plugin activation: a
|
|
150
|
+
// profile without one (headless, SDK) still gets completion reminders.
|
|
151
|
+
ctx.inject(['webServer'], (scope) => {
|
|
152
|
+
const startup = scope.get('webStartup')
|
|
153
|
+
scope.effect(() => installRoute({
|
|
154
|
+
webServer: scope.webServer,
|
|
155
|
+
trustedHosts: Array.isArray(startup?.trustedHosts) ? startup.trustedHosts : undefined,
|
|
156
|
+
presence,
|
|
157
|
+
warn,
|
|
158
|
+
}), 'task-reminder foreground route')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
/** In front means a page reported itself visible and focused recently. */
|
|
162
|
+
const quiet = () => settings.onlyWhenBlurred && presence.foreground()
|
|
163
|
+
|
|
164
|
+
/** Turn start time per session id, learned from `turn/start`. */
|
|
165
|
+
const turnStarts = new Map()
|
|
166
|
+
/** Latest `session/title` value per session id. */
|
|
167
|
+
const titles = new Map()
|
|
168
|
+
/** Approval ids already announced, so one question yields one reminder. */
|
|
169
|
+
const announcedApprovals = new Set()
|
|
170
|
+
|
|
171
|
+
const isSubagent = (session) => session.header.origin === 'subagent' || session.header.parentSession !== undefined
|
|
172
|
+
|
|
173
|
+
const dispatch = (payload) => {
|
|
174
|
+
if (quiet()) {
|
|
175
|
+
log(`skipped (page in front): ${payload.event}`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
log(`reminder: ${payload.event} session=${payload.sessionId} result=${payload.result}`)
|
|
179
|
+
channels.notify(payload)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sessionFacts = (session) => ({
|
|
183
|
+
sessionId: session.id,
|
|
184
|
+
...(titles.get(session.id) === undefined ? {} : { sessionTitle: titles.get(session.id) }),
|
|
185
|
+
...(session.header.cwd === undefined ? {} : { cwd: session.header.cwd }),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const observeTurnEnd = (session, event) => {
|
|
189
|
+
const reason = event.data.reason
|
|
190
|
+
const startedAt = turnStarts.get(session.id)
|
|
191
|
+
turnStarts.delete(session.id)
|
|
192
|
+
if (reason.kind === 'forked') return
|
|
193
|
+
if (!settings.includeSubagents && isSubagent(session)) return
|
|
194
|
+
if (!settings.notifyOn.includes(reason.kind)) return
|
|
195
|
+
const endedAt = typeof event.time === 'number' ? event.time : Date.now()
|
|
196
|
+
if (settings.minDurationMs > 0 && startedAt !== undefined && endedAt - startedAt < settings.minDurationMs) return
|
|
197
|
+
dispatch(buildPayload({
|
|
198
|
+
reason: reason.kind,
|
|
199
|
+
turn: event.data.turn,
|
|
200
|
+
...sessionFacts(session),
|
|
201
|
+
...(startedAt === undefined ? {} : { startedAt }),
|
|
202
|
+
endedAt,
|
|
203
|
+
...(reason.kind === 'error' ? { errorMessage: reason.error.message } : {}),
|
|
204
|
+
}, settings.locale))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const observeApproval = (session, event) => {
|
|
208
|
+
if (!settings.notifyOnApproval) return
|
|
209
|
+
if (!settings.includeSubagents && isSubagent(session)) return
|
|
210
|
+
const id = event.data.id
|
|
211
|
+
if (typeof id !== 'string' || announcedApprovals.has(id)) return
|
|
212
|
+
announcedApprovals.add(id)
|
|
213
|
+
dispatch(buildAttentionPayload({
|
|
214
|
+
kind: 'approval',
|
|
215
|
+
...sessionFacts(session),
|
|
216
|
+
...(typeof event.data.toolName === 'string' ? { toolName: event.data.toolName } : {}),
|
|
217
|
+
...(typeof event.data.reason === 'string' ? { reason: event.data.reason } : {}),
|
|
218
|
+
at: typeof event.time === 'number' ? event.time : Date.now(),
|
|
219
|
+
}, settings.locale))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const observeQuestion = (session, event) => {
|
|
223
|
+
if (!settings.notifyOnQuestion) return
|
|
224
|
+
if (!settings.includeSubagents && isSubagent(session)) return
|
|
225
|
+
const question = questionOf(event)
|
|
226
|
+
if (question === undefined) return
|
|
227
|
+
dispatch(buildAttentionPayload({
|
|
228
|
+
kind: 'question',
|
|
229
|
+
...sessionFacts(session),
|
|
230
|
+
...(question === '' ? {} : { question }),
|
|
231
|
+
at: typeof event.time === 'number' ? event.time : Date.now(),
|
|
232
|
+
}, settings.locale))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
ctx.on('session/event', (session, event) => {
|
|
236
|
+
// A reminder must never disturb the log, the loop, or a sibling listener.
|
|
237
|
+
try {
|
|
238
|
+
switch (event.type) {
|
|
239
|
+
case 'turn/start':
|
|
240
|
+
turnStarts.set(session.id, typeof event.time === 'number' ? event.time : Date.now())
|
|
241
|
+
return
|
|
242
|
+
case 'session/title':
|
|
243
|
+
titles.set(session.id, event.data.title)
|
|
244
|
+
return
|
|
245
|
+
case 'turn/end':
|
|
246
|
+
observeTurnEnd(session, event)
|
|
247
|
+
return
|
|
248
|
+
case 'approval/asked':
|
|
249
|
+
observeApproval(session, event)
|
|
250
|
+
return
|
|
251
|
+
case 'approval/decided':
|
|
252
|
+
if (typeof event.data.id === 'string') announcedApprovals.delete(event.data.id)
|
|
253
|
+
return
|
|
254
|
+
case 'tool/call':
|
|
255
|
+
observeQuestion(session, event)
|
|
256
|
+
return
|
|
257
|
+
default:
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
warn('observer failed', error)
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
ctx.on('session/disposed', (session) => {
|
|
266
|
+
turnStarts.delete(session.id)
|
|
267
|
+
titles.delete(session.id)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (settings.notifyOnStart) {
|
|
271
|
+
const payload = buildStartupPayload(settings.locale, Date.now())
|
|
272
|
+
log(`reminder: ${payload.event}`)
|
|
273
|
+
channels.notify(payload)
|
|
274
|
+
}
|
|
275
|
+
}
|
package/lib/payload.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure notification-payload construction and user-facing text for one finished
|
|
3
|
+
* session turn. Kept free of I/O so the wording and the wire format are testable
|
|
4
|
+
* without a host.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Wire `result` value for each logged `turn/end` reason. */
|
|
8
|
+
export const RESULT_BY_REASON = {
|
|
9
|
+
completed: 'success',
|
|
10
|
+
error: 'failure',
|
|
11
|
+
blocked: 'blocked',
|
|
12
|
+
aborted: 'cancelled',
|
|
13
|
+
'max-tokens': 'truncated',
|
|
14
|
+
interrupted: 'interrupted',
|
|
15
|
+
forked: 'forked',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Wire `event` value for each reason; matches the `<entity>.<action>` form. */
|
|
19
|
+
const EVENT_BY_REASON = {
|
|
20
|
+
completed: 'session.turn.completed',
|
|
21
|
+
error: 'session.turn.failed',
|
|
22
|
+
blocked: 'session.turn.blocked',
|
|
23
|
+
aborted: 'session.turn.cancelled',
|
|
24
|
+
'max-tokens': 'session.turn.max-tokens',
|
|
25
|
+
interrupted: 'session.turn.interrupted',
|
|
26
|
+
forked: 'session.turn.forked',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Attention kinds that are not turn ends: a tool needs approval, or the agent asked a question. */
|
|
30
|
+
export const ATTENTION_KINDS = ['approval', 'question']
|
|
31
|
+
|
|
32
|
+
/** Reasons a reminder can be configured for; `forked` is never a live turn end. */
|
|
33
|
+
export const NOTIFIABLE_REASONS = ['completed', 'error', 'blocked', 'aborted', 'max-tokens']
|
|
34
|
+
|
|
35
|
+
const MESSAGES = {
|
|
36
|
+
zh: {
|
|
37
|
+
completed: { title: 'DSH · 任务完成', message: '会话任务已完成' },
|
|
38
|
+
error: { title: 'DSH · 任务失败', message: '会话任务失败' },
|
|
39
|
+
blocked: { title: 'DSH · 等待你处理', message: '会话任务被阻塞,正在等待你的输入或确认' },
|
|
40
|
+
aborted: { title: 'DSH · 任务已中止', message: '会话任务被中止' },
|
|
41
|
+
'max-tokens': { title: 'DSH · 达到输出上限', message: '会话任务在达到输出上限后结束' },
|
|
42
|
+
approval: { title: 'DSH · 需要授权', message: '有工具在等你的授权' },
|
|
43
|
+
question: { title: 'DSH · 需要回答', message: '会话正在等你的回答' },
|
|
44
|
+
started: { title: 'DSH · 提醒已开启', message: '会话任务完成时会提醒你' },
|
|
45
|
+
withTitle: ':',
|
|
46
|
+
tool: '工具',
|
|
47
|
+
},
|
|
48
|
+
en: {
|
|
49
|
+
completed: { title: 'DSH - task finished', message: 'The session task finished' },
|
|
50
|
+
error: { title: 'DSH - task failed', message: 'The session task failed' },
|
|
51
|
+
blocked: { title: 'DSH - waiting for you', message: 'The session task is blocked on your input or approval' },
|
|
52
|
+
aborted: { title: 'DSH - task cancelled', message: 'The session task was cancelled' },
|
|
53
|
+
'max-tokens': { title: 'DSH - output limit reached', message: 'The session task ended at its output-token limit' },
|
|
54
|
+
approval: { title: 'DSH - approval needed', message: 'A tool is waiting for your approval' },
|
|
55
|
+
question: { title: 'DSH - answer needed', message: 'The session is waiting for your answer' },
|
|
56
|
+
started: { title: 'DSH - reminders on', message: 'You are notified when a session task finishes' },
|
|
57
|
+
withTitle: ': ',
|
|
58
|
+
tool: 'tool',
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Human-readable duration.
|
|
64
|
+
* @param {number | undefined} ms - elapsed milliseconds, or undefined when the turn start was not observed.
|
|
65
|
+
* @returns {string | undefined} `"1m 12s"`, `"820ms"`, or undefined.
|
|
66
|
+
*/
|
|
67
|
+
export function formatDuration(ms) {
|
|
68
|
+
if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined
|
|
69
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
70
|
+
const seconds = Math.round(ms / 1000)
|
|
71
|
+
if (seconds < 60) return `${seconds}s`
|
|
72
|
+
const minutes = Math.floor(seconds / 60)
|
|
73
|
+
const rest = seconds % 60
|
|
74
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Local wall-clock timestamp with the field order the payload contract documents.
|
|
79
|
+
* @param {number} epochMs - epoch milliseconds.
|
|
80
|
+
* @returns {string} `"YYYY-MM-DD HH:mm:ss"`.
|
|
81
|
+
*/
|
|
82
|
+
export function formatTimestamp(epochMs) {
|
|
83
|
+
const d = new Date(epochMs)
|
|
84
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
85
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
86
|
+
+ ` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Last path segment of a workspace directory, for a one-line toast body. */
|
|
90
|
+
function basename(path) {
|
|
91
|
+
if (typeof path !== 'string' || path === '') return undefined
|
|
92
|
+
const parts = path.split(/[\\/]+/).filter(Boolean)
|
|
93
|
+
return parts.at(-1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the payload handed to every channel.
|
|
98
|
+
* @param {object} input - observed turn facts.
|
|
99
|
+
* @param {string} input.reason - `turn/end` reason kind.
|
|
100
|
+
* @param {number} input.turn - turn number from the log.
|
|
101
|
+
* @param {string} input.sessionId - session identity.
|
|
102
|
+
* @param {string} [input.sessionTitle] - latest `session/title` value.
|
|
103
|
+
* @param {string} [input.cwd] - session working directory from its header.
|
|
104
|
+
* @param {number} [input.startedAt] - `turn/start` time in epoch milliseconds.
|
|
105
|
+
* @param {number} input.endedAt - `turn/end` time in epoch milliseconds.
|
|
106
|
+
* @param {string} [input.errorMessage] - flattened failure message for the error reason.
|
|
107
|
+
* @param {'zh' | 'en'} locale - copy language.
|
|
108
|
+
* @returns {{ title: string, event: string, result: string, timestamp: string, sessionId: string,
|
|
109
|
+
* sessionTitle?: string, cwd?: string, durationMs?: number, message: string, detail?: string }} payload.
|
|
110
|
+
*/
|
|
111
|
+
export function buildPayload(input, locale) {
|
|
112
|
+
const copy = MESSAGES[locale] ?? MESSAGES.zh
|
|
113
|
+
const base = copy[input.reason] ?? { title: `DSH · ${input.reason}`, message: `会话任务结束(${input.reason})` }
|
|
114
|
+
const durationMs = input.startedAt === undefined ? undefined : Math.max(0, input.endedAt - input.startedAt)
|
|
115
|
+
const named = input.sessionTitle === undefined || input.sessionTitle.trim() === ''
|
|
116
|
+
? base.message
|
|
117
|
+
: `${base.message}${copy.withTitle}${input.sessionTitle.trim()}`
|
|
118
|
+
const failed = input.reason === 'error' && input.errorMessage !== undefined
|
|
119
|
+
? `${named}(${input.errorMessage})`
|
|
120
|
+
: named
|
|
121
|
+
const facts = [formatDuration(durationMs), basename(input.cwd)].filter((v) => v !== undefined)
|
|
122
|
+
return {
|
|
123
|
+
title: base.title,
|
|
124
|
+
event: EVENT_BY_REASON[input.reason] ?? `session.turn.${input.reason}`,
|
|
125
|
+
result: RESULT_BY_REASON[input.reason] ?? 'unknown',
|
|
126
|
+
timestamp: formatTimestamp(input.endedAt),
|
|
127
|
+
sessionId: input.sessionId,
|
|
128
|
+
...(input.sessionTitle === undefined ? {} : { sessionTitle: input.sessionTitle }),
|
|
129
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
130
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
131
|
+
message: failed,
|
|
132
|
+
...(facts.length === 0 ? {} : { detail: facts.join(' · ') }),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Payload for an agent that is waiting on the user rather than finishing: an
|
|
138
|
+
* approval question, or a question tool. Same wire fields as a turn reminder,
|
|
139
|
+
* with `result: "blocked"` so consumers can rank it above completions.
|
|
140
|
+
* @param {object} input - observed attention facts.
|
|
141
|
+
* @param {'approval' | 'question'} input.kind - which wait it is.
|
|
142
|
+
* @param {string} input.sessionId - session identity.
|
|
143
|
+
* @param {string} [input.sessionTitle] - latest `session/title` value.
|
|
144
|
+
* @param {string} [input.cwd] - session working directory from its header.
|
|
145
|
+
* @param {string} [input.toolName] - tool the approval is about.
|
|
146
|
+
* @param {string} [input.reason] - the asker's explanation, when it gave one.
|
|
147
|
+
* @param {string} [input.question] - first question text for a question tool.
|
|
148
|
+
* @param {number} input.at - epoch milliseconds when the wait was recorded.
|
|
149
|
+
* @param {'zh' | 'en'} locale - copy language.
|
|
150
|
+
* @returns {object} payload handed to every channel.
|
|
151
|
+
*/
|
|
152
|
+
export function buildAttentionPayload(input, locale) {
|
|
153
|
+
const copy = MESSAGES[locale] ?? MESSAGES.zh
|
|
154
|
+
const base = copy[input.kind]
|
|
155
|
+
const detail = input.kind === 'approval'
|
|
156
|
+
? [input.toolName === undefined ? undefined : `${copy.tool} ${input.toolName}`, input.reason]
|
|
157
|
+
: [input.question]
|
|
158
|
+
const extra = detail.filter((value) => typeof value === 'string' && value.trim() !== '').join(' · ')
|
|
159
|
+
const facts = [basename(input.cwd)].filter((v) => v !== undefined)
|
|
160
|
+
return {
|
|
161
|
+
title: base.title,
|
|
162
|
+
event: input.kind === 'approval' ? 'session.approval.asked' : 'session.question.asked',
|
|
163
|
+
result: 'blocked',
|
|
164
|
+
timestamp: formatTimestamp(input.at),
|
|
165
|
+
sessionId: input.sessionId,
|
|
166
|
+
...(input.sessionTitle === undefined ? {} : { sessionTitle: input.sessionTitle }),
|
|
167
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
168
|
+
message: extra === '' ? base.message : `${base.message}${copy.withTitle}${extra.slice(0, 160)}`,
|
|
169
|
+
...(facts.length === 0 ? {} : { detail: facts.join(' · ') }),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Payload announcing that the plugin itself mounted, used as a wiring check.
|
|
175
|
+
* @param {'zh' | 'en'} locale - copy language.
|
|
176
|
+
* @param {number} now - epoch milliseconds.
|
|
177
|
+
* @returns {object} a payload with `event: "plugin.started"`.
|
|
178
|
+
*/
|
|
179
|
+
export function buildStartupPayload(locale, now) {
|
|
180
|
+
const copy = MESSAGES[locale] ?? MESSAGES.zh
|
|
181
|
+
return {
|
|
182
|
+
title: copy.started.title,
|
|
183
|
+
event: 'plugin.started',
|
|
184
|
+
result: 'success',
|
|
185
|
+
timestamp: formatTimestamp(now),
|
|
186
|
+
message: copy.started.message,
|
|
187
|
+
}
|
|
188
|
+
}
|
package/lib/presence.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground tracking for the "only when the window is not in front" rule.
|
|
3
|
+
*
|
|
4
|
+
* The browser half reports one record per page instance; the host keeps the
|
|
5
|
+
* latest state and treats a report as current for `activeWindowMs`. A closed
|
|
6
|
+
* window, a crashed page, or a lost network leaves a stale active record, which
|
|
7
|
+
* expiry turns back into "not in front" so reminders cannot be lost forever.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create the tracker.
|
|
12
|
+
* @param {object} options - tracker settings.
|
|
13
|
+
* @param {number} options.activeWindowMs - how long one report stays current.
|
|
14
|
+
* @param {() => number} [options.now] - clock, injectable for tests.
|
|
15
|
+
* @returns {{ report: (clientId: string, active: boolean) => void,
|
|
16
|
+
* foreground: () => boolean, snapshot: () => { foreground: boolean,
|
|
17
|
+
* clients: Array<{ clientId: string, active: boolean, ageMs: number }> } }} tracker.
|
|
18
|
+
*/
|
|
19
|
+
export function createPresenceTracker(options) {
|
|
20
|
+
const now = options.now ?? Date.now
|
|
21
|
+
const activeWindowMs = options.activeWindowMs
|
|
22
|
+
const clients = new Map()
|
|
23
|
+
|
|
24
|
+
const live = () => {
|
|
25
|
+
const at = now()
|
|
26
|
+
for (const [clientId, record] of clients) {
|
|
27
|
+
if (at - record.seenAt > activeWindowMs) clients.delete(clientId)
|
|
28
|
+
}
|
|
29
|
+
return at
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
report(clientId, active) {
|
|
34
|
+
if (typeof clientId !== 'string' || clientId === '') return
|
|
35
|
+
clients.set(clientId, { active: active === true, seenAt: now() })
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Whether the page is in front. Without any current report the answer is
|
|
39
|
+
* false — failing open, because a reminder that never arrives is worse than
|
|
40
|
+
* one that arrives while the window is focused.
|
|
41
|
+
* @returns {boolean} whether a page reported itself in front recently.
|
|
42
|
+
*/
|
|
43
|
+
foreground() {
|
|
44
|
+
live()
|
|
45
|
+
for (const record of clients.values()) if (record.active) return true
|
|
46
|
+
return false
|
|
47
|
+
},
|
|
48
|
+
snapshot() {
|
|
49
|
+
const at = live()
|
|
50
|
+
return {
|
|
51
|
+
foreground: [...clients.values()].some((record) => record.active),
|
|
52
|
+
clients: [...clients.entries()].map(([clientId, record]) => ({
|
|
53
|
+
clientId,
|
|
54
|
+
active: record.active,
|
|
55
|
+
ageMs: at - record.seenAt,
|
|
56
|
+
})),
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's loopback JSON endpoint: pages report their foreground state, and
|
|
3
|
+
* the state is readable for diagnostics.
|
|
4
|
+
*
|
|
5
|
+
* The route answers only requests that look like they came from the harness
|
|
6
|
+
* page: the `Host` header must name the loopback interface or a trusted host
|
|
7
|
+
* the profile declared, and a report must carry `content-type: application/json`,
|
|
8
|
+
* which a cross-origin browser request cannot send without a preflight this
|
|
9
|
+
* route never grants.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Largest accepted report body; a report is one small JSON object. */
|
|
13
|
+
const MAX_BODY_BYTES = 4_096
|
|
14
|
+
|
|
15
|
+
/** Whether one request addresses a host this deployment serves. */
|
|
16
|
+
function allowedHost(req, trustedHosts) {
|
|
17
|
+
const host = String(req.headers?.host ?? '').split(':')[0]
|
|
18
|
+
if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return true
|
|
19
|
+
return Array.isArray(trustedHosts) && trustedHosts.includes(host)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read a bounded JSON body; malformed or oversized input resolves to undefined. */
|
|
23
|
+
function readJson(req) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let size = 0
|
|
26
|
+
const chunks = []
|
|
27
|
+
req.on('data', (chunk) => {
|
|
28
|
+
size += chunk.length
|
|
29
|
+
if (size > MAX_BODY_BYTES) {
|
|
30
|
+
resolve(undefined)
|
|
31
|
+
req.destroy()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
chunks.push(chunk)
|
|
35
|
+
})
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
39
|
+
resolve(parsed !== null && typeof parsed === 'object' ? parsed : undefined)
|
|
40
|
+
} catch {
|
|
41
|
+
// A non-JSON body cannot carry a report; the caller answers 400.
|
|
42
|
+
resolve(undefined)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
req.on('error', () => resolve(undefined))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Write one JSON response. */
|
|
50
|
+
function json(res, status, value) {
|
|
51
|
+
const body = JSON.stringify(value)
|
|
52
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) })
|
|
53
|
+
res.end(body)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Install the endpoint on an already-mounted web server.
|
|
58
|
+
* @param {object} options - installation inputs.
|
|
59
|
+
* @param {{ register: (options: object) => () => void }} options.webServer - the host web server service.
|
|
60
|
+
* @param {string[] | undefined} options.trustedHosts - hosts the profile trusts besides loopback.
|
|
61
|
+
* @param {{ report: (clientId: string, active: boolean) => void, snapshot: () => object }} options.presence - foreground tracker.
|
|
62
|
+
* @param {(message: string, error?: unknown) => void} options.warn - plugin log sink.
|
|
63
|
+
* @returns {() => void} disposer that removes the route.
|
|
64
|
+
*/
|
|
65
|
+
export function installRoute(options) {
|
|
66
|
+
const { webServer, trustedHosts, presence, warn } = options
|
|
67
|
+
const base = '/task-reminder/api'
|
|
68
|
+
return webServer.register({
|
|
69
|
+
kind: 'prefix',
|
|
70
|
+
path: base,
|
|
71
|
+
handler: async (req, res) => {
|
|
72
|
+
try {
|
|
73
|
+
if (!allowedHost(req, trustedHosts)) {
|
|
74
|
+
json(res, 403, { ok: false, error: 'forbidden' })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
78
|
+
const method = pathname.startsWith(`${base}/`) ? pathname.slice(base.length + 1) : undefined
|
|
79
|
+
if (method === 'state') {
|
|
80
|
+
if (req.method !== 'GET') {
|
|
81
|
+
json(res, 405, { ok: false, error: 'method' })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
json(res, 200, { ok: true, ...presence.snapshot() })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (method === 'visibility') {
|
|
88
|
+
if (req.method !== 'POST') {
|
|
89
|
+
json(res, 405, { ok: false, error: 'method' })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (!String(req.headers?.['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
93
|
+
json(res, 415, { ok: false, error: 'content-type' })
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const body = await readJson(req)
|
|
97
|
+
if (body === undefined) {
|
|
98
|
+
json(res, 400, { ok: false, error: 'body' })
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
presence.report(body.client, body.active === true)
|
|
102
|
+
json(res, 200, { ok: true })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
json(res, 404, { ok: false, error: 'not-found' })
|
|
106
|
+
} catch (error) {
|
|
107
|
+
warn('foreground route failed', error)
|
|
108
|
+
try {
|
|
109
|
+
json(res, 500, { ok: false, error: 'internal' })
|
|
110
|
+
} catch {
|
|
111
|
+
// The response was already written; nothing left to report to the page.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
package/lib/sound.ps1
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ASCII-only on purpose: Windows PowerShell 5.1 decodes a BOM-less script with the
|
|
2
|
+
# ANSI code page. The player runs on a background thread, so the script holds the
|
|
3
|
+
# process alive for the cue's first second instead of exiting and cutting it off.
|
|
4
|
+
param(
|
|
5
|
+
[string]$SoundFile = ''
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
if ($SoundFile -ne '') {
|
|
10
|
+
$player = New-Object Media.SoundPlayer $SoundFile
|
|
11
|
+
$player.Play()
|
|
12
|
+
} else {
|
|
13
|
+
[System.Media.SystemSounds]::Asterisk.Play()
|
|
14
|
+
}
|
|
15
|
+
Start-Sleep -Milliseconds 900
|
package/lib/toast.ps1
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ASCII-only on purpose: Windows PowerShell 5.1 decodes a BOM-less script with the
|
|
2
|
+
# ANSI code page, so localized text travels in the UTF-8 XML payload instead.
|
|
3
|
+
param(
|
|
4
|
+
[Parameter(Mandatory = $true)][string]$XmlPath,
|
|
5
|
+
[string]$AppId = 'com.deepseek.dsh'
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
10
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
11
|
+
$document = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
12
|
+
$document.LoadXml((Get-Content -Raw -Encoding UTF8 -LiteralPath $XmlPath))
|
|
13
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification $document
|
|
14
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppId).Show($toast)
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liangliang205/dsh-task-reminder",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Remind you when a DSH session task finishes or waits on you: branded Windows toast, audible cue, and an SRA-compatible webhook.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"dsh",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"dsh-plugin",
|
|
24
|
+
"dsh-bundle",
|
|
25
|
+
"notification",
|
|
26
|
+
"toast",
|
|
27
|
+
"webhook"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"client": {
|
|
37
|
+
"platform": "web"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test",
|
|
42
|
+
"pack:check": "npm pack --dry-run"
|
|
43
|
+
}
|
|
44
|
+
}
|