@liangliang205/dsh-task-reminder 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 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
- # Temporary Holding Version
1
+ # dsh-task-reminder
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ - **前台判定需要页面配合**:页面没加载(例如刚启动还没打开窗口)时按"不在前台"处理,会照常提醒。
@@ -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
@@ -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('&', '&amp;')
22
+ .replaceAll('<', '&lt;')
23
+ .replaceAll('>', '&gt;')
24
+ .replaceAll('"', '&quot;')
25
+ .replaceAll("'", '&apos;')
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,111 @@
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
+ id: 'dsh-task-reminder',
12
+ factory: function (_require) {
13
+ 'use strict'
14
+ var module = { exports: {} }
15
+
16
+ /** Host endpoint installed by the plugin's own web route. */
17
+ var ENDPOINT = '/task-reminder/api/visibility'
18
+ /** Re-report interval, shorter than the host's expiry for one report. */
19
+ var HEARTBEAT_MS = 20000
20
+
21
+ var clientId = (function () {
22
+ try {
23
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
24
+ return crypto.randomUUID()
25
+ }
26
+ } catch (error) {
27
+ // A browser without crypto.randomUUID falls through to the random id.
28
+ }
29
+ return 'c' + Math.random().toString(36).slice(2) + Date.now().toString(36)
30
+ })()
31
+
32
+ /** POST one report; failures are irrelevant because the next heartbeat retries. */
33
+ function post(active, keepalive) {
34
+ var body = JSON.stringify({ client: clientId, active: active === true })
35
+ try {
36
+ if (typeof fetch === 'function') {
37
+ fetch(ENDPOINT, {
38
+ method: 'POST',
39
+ headers: { 'content-type': 'application/json' },
40
+ body: body,
41
+ keepalive: keepalive === true,
42
+ }).catch(function () {})
43
+ return
44
+ }
45
+ } catch (error) {
46
+ // Fall through to the beacon path.
47
+ }
48
+ try {
49
+ if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
50
+ navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' }))
51
+ }
52
+ } catch (error) {
53
+ // Offline or blocked; nothing to retry beyond the next heartbeat.
54
+ }
55
+ }
56
+
57
+ /** Visible and focused; a desktop window can lose focus while still visible. */
58
+ function isActive() {
59
+ try {
60
+ if (typeof document === 'undefined' || document.visibilityState !== 'visible') return false
61
+ return typeof document.hasFocus !== 'function' || document.hasFocus()
62
+ } catch (error) {
63
+ return true
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Attach the focus reporters for this page instance.
69
+ * @param {{ effect?: (factory: () => () => void) => unknown }} ctx - client plugin context.
70
+ */
71
+ function apply(ctx) {
72
+ var last = null
73
+
74
+ var report = function (keepalive) {
75
+ var active = isActive()
76
+ last = active
77
+ post(active, keepalive)
78
+ }
79
+ var onWake = function () {
80
+ var active = isActive()
81
+ if (active !== last) report(false)
82
+ }
83
+ var onLeave = function () {
84
+ last = false
85
+ post(false, true)
86
+ }
87
+
88
+ document.addEventListener('visibilitychange', onWake)
89
+ window.addEventListener('focus', onWake)
90
+ window.addEventListener('blur', onWake)
91
+ window.addEventListener('pagehide', onLeave)
92
+ report(false)
93
+ var timer = setInterval(function () {
94
+ // A heartbeat keeps the last known state current without extra state changes.
95
+ post(last === true, false)
96
+ }, HEARTBEAT_MS)
97
+
98
+ var cleanup = function () {
99
+ clearInterval(timer)
100
+ document.removeEventListener('visibilitychange', onWake)
101
+ window.removeEventListener('focus', onWake)
102
+ window.removeEventListener('blur', onWake)
103
+ window.removeEventListener('pagehide', onLeave)
104
+ }
105
+ if (typeof ctx.effect === 'function') ctx.effect(function () { return cleanup })
106
+ }
107
+
108
+ module.exports = { name: 'task-reminder-client', apply: apply }
109
+ return module.exports
110
+ },
111
+ })
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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
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
+ }