dsh-notify 0.1.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 Pasumao
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 ADDED
@@ -0,0 +1,126 @@
1
+ # dsh-plugin-notify
2
+
3
+ > ⚠️ **AI 生成项目声明**
4
+ >
5
+ > 本项目(代码、脚本、文档)**由 AI 辅助生成**,仅用于学习和参考。
6
+ > 使用前请自行阅读、审查并充分测试代码,按需修改后再部署到你的环境。
7
+
8
+ dsh Web GUI 的 Windows 通知插件:当 agent **不再运行**时(任务完成 / 被停止 / 出错 / 停下来等你选择 / 会话被关闭),弹一条 Windows 原生 Toast 提醒你回来看结果。适合发起绘画、生成等长任务后离开电脑的场景。
9
+
10
+ - **纯 host 端插件**:零运行时依赖、零构建步骤,普通 ESM;
11
+ - **通知常驻**:`scenario="reminder"`,直到你手动关闭;
12
+ - **位置标注**:通知正文显示 `工作区「…」 · 会话「…」`,一眼看出是哪个工作区的哪个会话;
13
+ - **系统托盘**:任务栏右下角常驻 dsh 鲸鱼图标,双击/右键「打开 dsh」、右键「关闭进程」终止 dsh 后台,dsh 退出后图标自动消失。
14
+
15
+ ## 目录
16
+
17
+ - [触发条件](#触发条件)
18
+ - [安装](#安装)
19
+ - [配置](#配置config均有默认值)
20
+ - [测试](#测试)
21
+ - [排障](#排障)
22
+ - [相关文档](#相关文档)
23
+
24
+ ## 触发条件
25
+
26
+ | 场景 | 信号 | 通知 |
27
+ |---|---|---|
28
+ | 任务正常完成 | `agent/status` running→idle + `turn/end: completed` | 「dsh · 任务完成」 |
29
+ | 你点了停止 / 被中止 | `turn/end: aborted` | 「dsh · 任务已停止」 |
30
+ | 执行出错 | `turn/end: error` | 「dsh · 任务出错」 |
31
+ | 达到输出上限 | `turn/end: max-tokens` | 「dsh · 达到输出上限」 |
32
+ | agent 停下来让你选择 | 未配对的 `ask_user_question` 工具调用 | 「dsh · 在等你选择」 |
33
+ | 运行中的会话被关闭 | `agent/disposed` | 「dsh · 会话已关闭」 |
34
+
35
+ > **位置标注说明**:每条通知正文带一行 `工作区「…」 · 会话「…」`。
36
+ > 工作区取 `workspaceRegistry` 中该会话的归属标题(兜底用会话 cwd 的目录名),
37
+ > 会话取 sidebar 显示的会话标题(无标题时兜底用会话 id 短号,如 `#a1b2c3d4`)。
38
+ > 两个服务都不可用时该行自动省略,不影响通知。
39
+
40
+ ## 安装
41
+
42
+ 本插件是标准的 DSH **bundle**(`package.json` 声明 `dsh.bundle.patch`,包内自带
43
+ `cordis.patch.yml`),安装后会自动加入 profile 的 bundle 层,无需手动写补丁。
44
+
45
+ ### 方式一:npm(推荐)
46
+
47
+ ```powershell
48
+ dsh plugin --profile web add dsh-notify
49
+ ```
50
+
51
+ ### 方式二:GitHub 直接安装(无需 npm)
52
+
53
+ ```powershell
54
+ dsh plugin --profile web add github:Pasumao/dsh-plugin-notify
55
+ ```
56
+
57
+ > git 源插件若带构建脚本,pnpm 会要求先在 `profiles\web\pnpm-workspace.yaml` 的
58
+ > `allowBuilds` 里放行对应包名;本项目零构建(无 prepare 脚本),一般无需配置。
59
+
60
+ ### 方式三:本地路径(开发 / 私有测试)
61
+
62
+ ```powershell
63
+ dsh plugin --profile web add C:/Users/18303/Desktop/dsh-plugin-notify
64
+ ```
65
+
66
+ ### 方式四:手动 link(可选,等同旧版流程)
67
+
68
+ 1. 在 `C:\Users\18303\.dsh\profiles\web\package.json` 的 dependencies 加:
69
+
70
+ ```jsonc
71
+ "dependencies": {
72
+ // ...已有依赖...
73
+ "dsh-notify": "link:C:/Users/18303/Desktop/dsh-plugin-notify"
74
+ }
75
+ ```
76
+
77
+ 2. profile 目录执行安装:
78
+
79
+ ```powershell
80
+ cd C:\Users\18303\.dsh\profiles\web
81
+ pnpm install
82
+ ```
83
+
84
+ 安装完成后 **重启 `dsh web`**:任务栏右下角出现 dsh 鲸鱼小图标即加载成功。
85
+ 如需调整通知行为,在 profile 的 `cordis.patch.yml` 按 id 覆盖 `config` 即可
86
+ (例如 `- id: dsh-plugin-notify` + `config: { cooldownMs: 5000 }`)。
87
+
88
+ ## 配置(`config`,均有默认值)
89
+
90
+ | key | 默认 | 说明 |
91
+ |---|---|---|
92
+ | `enabled` | `true` | 总开关 |
93
+ | `tray` | `true` | 系统托盘图标开关(`false` 关闭) |
94
+ | `rootsOnly` | `true` | 仅根会话通知;子代理不刷屏 |
95
+ | `notifyFinished` / `notifyAborted` / `notifyError` / `notifyWaiting` / `notifyDisposed` | 全 `true` | 分类型开关 |
96
+ | `cooldownMs` | `10000` | 同一会话、同一类型两次通知最小间隔(毫秒) |
97
+ | `titlePrefix` | `'dsh'` | 通知标题前缀(形如 `dsh · 任务完成`) |
98
+ | `iconPath` | 内置 `assets/dsh.png` | 通知 logo PNG 路径;设为空串 `''` 则不显示自定义图标 |
99
+ | `webUrl` | `http://127.0.0.1:3080` | dsh 页面地址(点击通知/托盘「打开 dsh」跳转的地址) |
100
+ | `aumid` | PowerShell 5.1 的 AUMID | Toast 归属的 AppUserModelID(一般无需改) |
101
+ | `summaryMaxChars` | `40` | 任务摘要截断长度 |
102
+ | `powershellPath` | 自动 | 覆盖 PowerShell 5.1 路径 |
103
+
104
+ ## 测试
105
+
106
+ 通知管道单测(英文 Toast):
107
+
108
+ ```powershell
109
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/smoke-notice.ps1
110
+ ```
111
+
112
+ 插件逻辑端到端自测(无需 dsh、无需重启,弹「等待选择 / 任务完成 / 任务已停止」三条中文 Toast):
113
+
114
+ ```powershell
115
+ node scripts/test-harness.mjs
116
+ ```
117
+
118
+ ## 排障
119
+
120
+ - **Toast 不出现**:确认 Windows「通知与操作」里允许 PowerShell 显示通知;首次加载 WinRT 模块稍慢属正常。
121
+ - **图标不显示**:确认 `assets/dsh.png`(通知)与 `assets/dsh.ico`(托盘)随插件目录一起存在。
122
+ - **中文乱码**:经环境变量(UTF-16)与 `-EncodedCommand`(UTF-16LE base64)传参,不应乱码。
123
+
124
+ ## 相关文档
125
+
126
+ - `设计文稿.md`:设计思路、对 dsh 源码的调研结论、实现细节与边界情况。
package/assets/dsh.ico ADDED
Binary file
package/assets/dsh.png ADDED
Binary file
@@ -0,0 +1,11 @@
1
+ # dsh-notify bundle patch: the toast + system-tray notification plugin.
2
+ #
3
+ # Applied as ONE insert over the profile root, after dsh-base/dsh-web-app rows.
4
+ # The user's own profile cordis.patch.yml (and any --patch overlays) is applied
5
+ # afterwards and may override this row's whole `config` by id.
6
+ #
7
+ # `name` must equal the package.json `name` (the loader resolves the plugin
8
+ # module from the profile's node_modules under that name).
9
+ - insert:
10
+ - id: dsh-plugin-notify
11
+ name: dsh-notify
package/lib/index.js ADDED
@@ -0,0 +1,465 @@
1
+ /**
2
+ * dsh-plugin-notify — 任务结束 Windows 通知插件(纯 host 端)
3
+ *
4
+ * 当 agent「不再运行」时(正常运行完成 / 被中止 / 出错 / 达到输出上限 /
5
+ * 停下来等待用户选择 / 会话被关闭),弹一条 Windows 原生 Toast 通知(纯提示,
6
+ * 常驻到手动关闭,点击无动作)。
7
+ *
8
+ * 通知正文除了任务摘要,还会标注「工作区 · 会话」位置行:工作区标题取自
9
+ * `ctx.workspaceRegistry`(按会话归属匹配,兜底用会话 cwd 的目录名),会话
10
+ * 显示名取自 `ctx.sessionTitle`(sidebar 标题,兜底用会话 id 短号)。两个服务
11
+ * 都不可用时自动省略,不影响通知本身。
12
+ *
13
+ * 触发信号(均为主机端 Cordis 事件):
14
+ * - `agent/status`(idle ⇄ running):running→idle 即「不再运行」。
15
+ * - `session/event`:读取 turn 结束原因、最近用户指令、ask_user_question 等待。
16
+ * - `agent/disposed`:会话被关闭/清除的兜底。
17
+ *
18
+ * 通知实现:零依赖,spawn Windows PowerShell 5.1 的 ToastNotificationManager
19
+ * (原生 Toast,自定义 logo + 常驻)。标题/正文经环境变量(UTF-16)传入,
20
+ * 脚本经 -EncodedCommand(UTF-16LE base64)传入,规避代码页与引号转义问题。
21
+ *
22
+ * @module @dsh-external/dsh-plugin-notify
23
+ */
24
+ import { spawn } from 'node:child_process'
25
+ import { existsSync } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ export const name = 'dsh-plugin-notify'
30
+ export const inject = []
31
+
32
+ /** 默认配置;patch 条目的 `config` 字段按对象合并覆盖。 */
33
+ const DEFAULT_CONFIG = {
34
+ /** 总开关。 */
35
+ enabled: true,
36
+ /** 系统托盘图标:表示 dsh 后台运行中(任务栏右下角)。设为 false 关闭。 */
37
+ tray: true,
38
+ /** 仅根会话(用户自己的会话)通知;子代理默认不刷屏。 */
39
+ rootsOnly: true,
40
+ /** 分类型开关。 */
41
+ notifyFinished: true,
42
+ notifyAborted: true,
43
+ notifyError: true,
44
+ notifyWaiting: true,
45
+ notifyDisposed: true,
46
+ /** 同一会话、同一类型两次通知的最小间隔(毫秒,防连发)。 */
47
+ cooldownMs: 10000,
48
+ /** 通知标题前缀。 */
49
+ titlePrefix: 'dsh',
50
+ /** 自定义通知图标 PNG 路径;默认用插件自带的 dsh 鲸鱼 logo。设为空串则不显示自定义图标。 */
51
+ iconPath: undefined,
52
+ /** dsh Web 页面地址(托盘「打开 dsh」跳转的地址)。 */
53
+ webUrl: 'http://127.0.0.1:3080',
54
+ /** Toast 归属的 AppUserModelID;默认借用 Windows PowerShell 5.1 已注册的 AUMID。 */
55
+ aumid: '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe',
56
+ /** 用户指令摘要截断长度(字符)。 */
57
+ summaryMaxChars: 40,
58
+ /** 覆盖 PowerShell 路径;默认探测 5.1 的绝对路径。 */
59
+ powershellPath: undefined,
60
+ }
61
+
62
+ function normalizeConfig(config) {
63
+ const cfg = { ...DEFAULT_CONFIG }
64
+ if (config && typeof config === 'object') Object.assign(cfg, config)
65
+ cfg.cooldownMs = Math.max(0, Math.floor(Number(cfg.cooldownMs) || 0))
66
+ cfg.summaryMaxChars = Math.max(1, Math.floor(Number(cfg.summaryMaxChars) || 40))
67
+ cfg.webUrl = (cfg.webUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '')
68
+ return cfg
69
+ }
70
+
71
+ /** Windows PowerShell 5.1 绝对路径(保底探测)。 */
72
+ function defaultPowershellPath() {
73
+ const windir = process.env.windir || process.env.WINDIR || 'C:\\Windows'
74
+ return join(windir, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
75
+ }
76
+
77
+ /** 插件自带的 dsh 鲸鱼 logo PNG(相对本模块定位,链接/拷贝部署均可用)。 */
78
+ function defaultIconPath() {
79
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'dsh.png')
80
+ }
81
+
82
+ /** 插件自带的 dsh 鲸鱼 logo ICO(托盘图标用)。 */
83
+ function defaultTrayIconPath() {
84
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'dsh.ico')
85
+ }
86
+
87
+ /** 当前托盘图标的子进程(模块级:进程内同一时刻至多一个托盘图标)。 */
88
+ let trayChild = null
89
+
90
+ /**
91
+ * 弹 Toast 的 PowerShell 脚本。全部参数从环境变量读取:
92
+ * DSH_NOTICE_TITLE / DSH_NOTICE_BODY / DSH_NOTICE_LOCATION(「工作区 · 会话」行) /
93
+ * DSH_NOTICE_ICON_PATH(logo PNG) / DSH_NOTICE_AUMID(Toast 归属)
94
+ * 用 ToastNotificationManager 发原生 Toast:自定义 logo + scenario="reminder"
95
+ * 常驻;activationType="background"(无后台任务 → 点击不触发任何动作,纯提示)。
96
+ * location 作为第三个 <text>(ToastGeneric 支持多行正文)。
97
+ */
98
+ const TOAST_SCRIPT = `
99
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
100
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
101
+ function XmlEscape([string]$s) { return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;').Replace('"','&quot;').Replace("'",'&apos;') }
102
+ $title = [string]$env:DSH_NOTICE_TITLE
103
+ $body = [string]$env:DSH_NOTICE_BODY
104
+ $location = [string]$env:DSH_NOTICE_LOCATION
105
+ $icon = [string]$env:DSH_NOTICE_ICON_PATH
106
+ $aumid = [string]$env:DSH_NOTICE_AUMID
107
+ $iconPart = ''
108
+ if ($icon -and (Test-Path $icon)) {
109
+ $iconUri = 'file:///' + ($icon.Replace('\\','/'))
110
+ $iconPart = '<image placement="appLogoOverride" src="' + (XmlEscape $iconUri) + '" hint-crop="circle"/>'
111
+ }
112
+ $locationPart = ''
113
+ if ($location) { $locationPart = '<text>' + (XmlEscape $location) + '</text>' }
114
+ $xmlText = '<toast scenario="reminder" activationType="background"><visual><binding template="ToastGeneric">' + $iconPart + '<text>' + (XmlEscape $title) + '</text><text>' + (XmlEscape $body) + '</text>' + $locationPart + '</binding></visual></toast>'
115
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
116
+ $xml.LoadXml($xmlText)
117
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
118
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($aumid).Show($toast)
119
+ `.trim()
120
+
121
+ /** 派发一条通知(fire-and-forget;失败仅记日志,绝不影响 dsh)。 */
122
+ function fireToast(ctx, cfg, { title, body, location = '' }) {
123
+ const ps = cfg.powershellPath || defaultPowershellPath()
124
+ const iconPath = cfg.iconPath === undefined ? defaultIconPath() : (cfg.iconPath ?? '')
125
+ // -EncodedCommand(UTF-16LE base64):脚本里即便含双引号/换行也不受命令行转义影响。
126
+ const encoded = Buffer.from(TOAST_SCRIPT, 'utf16le').toString('base64')
127
+ let child
128
+ try {
129
+ child = spawn(
130
+ ps,
131
+ ['-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
132
+ {
133
+ env: {
134
+ ...process.env,
135
+ DSH_NOTICE_TITLE: title,
136
+ DSH_NOTICE_BODY: body,
137
+ DSH_NOTICE_LOCATION: location,
138
+ DSH_NOTICE_ICON_PATH: existsSync(iconPath) ? iconPath : '',
139
+ DSH_NOTICE_AUMID: cfg.aumid,
140
+ },
141
+ windowsHide: true,
142
+ stdio: 'ignore',
143
+ },
144
+ )
145
+ } catch (error) {
146
+ ctx.logger.warn(`dsh-plugin-notify: failed to launch notification: ${String(error)}`)
147
+ return
148
+ }
149
+ child.on('error', (error) => {
150
+ ctx.logger.warn(`dsh-plugin-notify: notification process error: ${String(error)}`)
151
+ })
152
+ }
153
+
154
+ function truncate(text, max) {
155
+ if (text.length <= max) return text
156
+ return `${text.slice(0, max)}…`
157
+ }
158
+
159
+ /** 取路径最后一段作为目录显示名(工作区标题的兜底)。 */
160
+ function pathBasename(path) {
161
+ if (typeof path !== 'string' || !path) return ''
162
+ return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
163
+ }
164
+
165
+ /** 解析会话所属工作区的显示名:按归属匹配 workspaceRegistry,找不到则退回 cwd 目录名。 */
166
+ function resolveWorkspaceLabel(ctx, session) {
167
+ const id = session?.id
168
+ try {
169
+ const registry = ctx.workspaceRegistry
170
+ if (registry && typeof registry.list === 'function') {
171
+ const workspaces = registry.list()
172
+ if (Array.isArray(workspaces)) {
173
+ const match = workspaces.find(
174
+ (ws) => ws && Array.isArray(ws.sessionIds) && id != null && ws.sessionIds.includes(id),
175
+ )
176
+ if (match) {
177
+ const label = (match.title || pathBasename(match.path) || '').trim()
178
+ if (label) return truncate(label, 40)
179
+ }
180
+ }
181
+ }
182
+ } catch {
183
+ // workspaceRegistry 服务不可用时忽略,走 cwd 兜底。
184
+ }
185
+ return truncate(pathBasename(session?.header?.cwd), 40)
186
+ }
187
+
188
+ /** 解析会话显示名:优先 sidebar 标题(sessionTitle),兜底用会话 id 短号。 */
189
+ function resolveSessionLabel(ctx, session) {
190
+ try {
191
+ const snapshot = ctx.sessionTitle?.get?.(session)
192
+ const title = snapshot?.title
193
+ if (typeof title === 'string' && title.trim()) return truncate(title.trim(), 40)
194
+ } catch {
195
+ // sessionTitle 服务不可用 / 会话已关闭时忽略,走 id 兜底。
196
+ }
197
+ return typeof session?.id === 'string' && session.id ? `#${session.id.slice(0, 8)}` : ''
198
+ }
199
+
200
+ /** 组装「工作区 · 会话」位置行;任一为空则省略对应段。 */
201
+ function composeLocation(workspace, sessionLabel) {
202
+ const parts = []
203
+ if (workspace) parts.push(`工作区「${workspace}」`)
204
+ if (sessionLabel) parts.push(`会话「${sessionLabel}」`)
205
+ return truncate(parts.join(' · '), 96)
206
+ }
207
+
208
+ /** 从一条 user/message 里取第一个文本块作为任务摘要。 */
209
+ function summarizePrompt(message, maxChars) {
210
+ const blocks = message?.content
211
+ if (!Array.isArray(blocks)) return ''
212
+ for (const block of blocks) {
213
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
214
+ return truncate(block.text.replace(/\s+/g, ' ').trim(), maxChars)
215
+ }
216
+ }
217
+ return ''
218
+ }
219
+
220
+ /** 从 ask_user_question 的原始 arguments JSON 里取第一个问题的文本。 */
221
+ function extractQuestion(rawArguments) {
222
+ try {
223
+ const parsed = JSON.parse(rawArguments)
224
+ const question = Array.isArray(parsed?.questions)
225
+ ? parsed.questions.find((item) => typeof item?.question === 'string' && item.question)
226
+ : null
227
+ return question ? truncate(question.question, 60) : ''
228
+ } catch {
229
+ return ''
230
+ }
231
+ }
232
+
233
+ /** turn/end 的 TurnEndReason.kind → 通知类别。 */
234
+ const REASON_KIND = {
235
+ completed: 'finished',
236
+ aborted: 'aborted',
237
+ error: 'error',
238
+ interrupted: 'error',
239
+ 'max-tokens': 'max-tokens',
240
+ blocked: 'blocked',
241
+ }
242
+
243
+ function createState() {
244
+ return {
245
+ running: false,
246
+ turnStarted: false,
247
+ lastReason: null,
248
+ lastPrompt: '',
249
+ waiting: false,
250
+ waitingCallId: null,
251
+ waitingText: '',
252
+ /** 最近一次可见的会话对象(通知时用于解析位置,随事件更新)。 */
253
+ session: null,
254
+ /** 「工作区 · 会话」标签缓存,通知时解析失败可回退。 */
255
+ workspaceLabel: '',
256
+ sessionLabel: '',
257
+ /** 各类型上次通知时间戳,按类型冷却。 */
258
+ lastToastAt: {},
259
+ }
260
+ }
261
+
262
+ export function apply(ctx, config = {}) {
263
+ const cfg = normalizeConfig(config)
264
+ if (!cfg.enabled) return
265
+
266
+ const states = new Map()
267
+
268
+ // 系统托盘图标:表示 dsh 后台运行中。同一进程内至多一个;卸载/重启 dsh 时清理。
269
+ if (cfg.tray !== false) {
270
+ if (trayChild) {
271
+ try { trayChild.kill() } catch {}
272
+ trayChild = null
273
+ }
274
+ try {
275
+ const root = dirname(fileURLToPath(import.meta.url))
276
+ const trayPath = join(root, '..', 'scripts', 'tray.ps1')
277
+ const child = spawn(
278
+ defaultPowershellPath(),
279
+ ['-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', trayPath, '-ParentPid', String(process.pid), '-IconPath', defaultTrayIconPath(), '-WebUrl', cfg.webUrl],
280
+ {
281
+ env: {
282
+ ...process.env,
283
+ DSH_TRAY_TOOLTIP: 'dsh 后台运行中',
284
+ DSH_TRAY_OPEN: '打开 dsh',
285
+ DSH_TRAY_EXIT: '关闭进程',
286
+ },
287
+ windowsHide: true,
288
+ stdio: 'ignore',
289
+ },
290
+ )
291
+ trayChild = child
292
+ child.on('exit', () => { if (trayChild === child) trayChild = null })
293
+ child.on('error', () => { if (trayChild === child) trayChild = null })
294
+ } catch (error) {
295
+ ctx.logger.warn(`dsh-plugin-notify: failed to start tray icon: ${String(error)}`)
296
+ }
297
+ }
298
+
299
+ const isRoot = (agent) => {
300
+ const depth = agent?.session?.header?.delegationDepth
301
+ return depth == null || depth === 0
302
+ }
303
+ const isTracked = (agent) => (cfg.rootsOnly ? isRoot(agent) : true)
304
+
305
+ const stateFor = (id) => {
306
+ let state = states.get(id)
307
+ if (!state) {
308
+ state = createState()
309
+ states.set(id, state)
310
+ }
311
+ return state
312
+ }
313
+
314
+ const notify = (id, state, kind, session) => {
315
+ const now = Date.now()
316
+ const last = state.lastToastAt[kind] ?? 0
317
+ if (now - last < cfg.cooldownMs) return
318
+ state.lastToastAt[kind] = now
319
+
320
+ const when = new Date().toLocaleTimeString('zh-CN', { hour12: false })
321
+ const prefix = cfg.titlePrefix
322
+ const prompt = state.lastPrompt ? `「${state.lastPrompt}」` : '任务'
323
+ const waitingText = state.waitingText ? `Agent 正在等你选择:${state.waitingText}` : `Agent 正在等你选择(${when})`
324
+
325
+ // 「工作区 · 会话」位置行:优先现取,会话对象缺失时回退状态缓存。
326
+ const live = session ?? state.session
327
+ let workspace = live ? resolveWorkspaceLabel(ctx, live) : state.workspaceLabel
328
+ let sessionLabel = live ? resolveSessionLabel(ctx, live) : state.sessionLabel
329
+ if (!workspace) workspace = state.workspaceLabel
330
+ if (!sessionLabel) sessionLabel = state.sessionLabel
331
+ if (live) {
332
+ if (workspace) state.workspaceLabel = workspace
333
+ if (sessionLabel) state.sessionLabel = sessionLabel
334
+ }
335
+ const location = composeLocation(workspace, sessionLabel)
336
+
337
+ const messages = {
338
+ finished: { title: `${prefix} · 任务完成`, body: `${prompt} 已完成(${when})` },
339
+ aborted: { title: `${prefix} · 任务已停止`, body: `${prompt} 已被中止(${when})` },
340
+ error: { title: `${prefix} · 任务出错`, body: `${prompt} 执行出错(${when})` },
341
+ 'max-tokens': { title: `${prefix} · 达到输出上限`, body: `${prompt} 达到输出上限(${when})` },
342
+ blocked: { title: `${prefix} · 回合被阻断`, body: `${prompt} 回合被阻断(${when})` },
343
+ waiting: { title: `${prefix} · 在等你选择`, body: waitingText },
344
+ disposed: { title: `${prefix} · 会话已关闭`, body: `运行中的会话已关闭(${when})` },
345
+ }
346
+ const message = messages[kind] ?? messages.finished
347
+ fireToast(ctx, cfg, { ...message, location })
348
+ ctx.logger.debug(`dsh-plugin-notify: ${kind} -> "${message.title}" (session ${id}${location ? `, ${location}` : ''})`)
349
+ }
350
+
351
+ const onStatus = ({ agent, status }) => {
352
+ if (!isTracked(agent)) return
353
+ const state = stateFor(agent.id)
354
+ state.session = agent.session ?? state.session
355
+
356
+ if (status === 'running') {
357
+ state.running = true
358
+ return
359
+ }
360
+
361
+ // running → idle:agent 不再运行。
362
+ if (!state.running) return
363
+ state.running = false
364
+
365
+ const active = state.turnStarted || state.waiting
366
+ const kind = REASON_KIND[state.lastReason] ?? 'finished'
367
+ state.turnStarted = false
368
+ state.lastReason = null
369
+
370
+ // 空转(唤醒后消息被清空、无实际回合)不通知。
371
+ if (!active) return
372
+
373
+ const gate = {
374
+ finished: cfg.notifyFinished,
375
+ aborted: cfg.notifyAborted,
376
+ error: cfg.notifyError,
377
+ 'max-tokens': cfg.notifyFinished,
378
+ blocked: cfg.notifyAborted,
379
+ }[kind]
380
+ if (gate === false) return
381
+
382
+ notify(agent.id, state, kind, agent.session)
383
+ }
384
+
385
+ const onSessionEvent = (session, event) => {
386
+ const depth = session.header?.delegationDepth
387
+ if (cfg.rootsOnly && depth != null && depth !== 0) return
388
+ const state = stateFor(session.id)
389
+ state.session = session
390
+
391
+ switch (event.type) {
392
+ case 'turn/start':
393
+ state.turnStarted = true
394
+ break
395
+ case 'turn/end':
396
+ state.lastReason = event.data?.reason?.kind ?? null
397
+ break
398
+ case 'user/message':
399
+ state.lastPrompt = summarizePrompt(event.data?.message, cfg.summaryMaxChars)
400
+ break
401
+ case 'tool/call': {
402
+ const call = event.data
403
+ if (call?.name === 'ask_user_question') {
404
+ state.waiting = true
405
+ state.waitingCallId = call.callId
406
+ state.waitingText = extractQuestion(call.arguments)
407
+ // 此刻状态仍是 running:立即提醒用户回来做选择。
408
+ if (state.running && cfg.notifyWaiting) notify(session.id, state, 'waiting', session)
409
+ }
410
+ break
411
+ }
412
+ case 'tool/result': {
413
+ const result = event.data
414
+ if (state.waiting && result?.message?.callId === state.waitingCallId) {
415
+ state.waiting = false
416
+ state.waitingCallId = null
417
+ state.waitingText = ''
418
+ }
419
+ break
420
+ }
421
+ default:
422
+ break
423
+ }
424
+ }
425
+
426
+ const onDisposed = ({ agent }) => {
427
+ const state = states.get(agent.id)
428
+ if (!state) return
429
+ const wasRunning = state.running
430
+ states.delete(agent.id)
431
+ // agent/disposed 在会话分离前派发,agent.session 仍可读;异常时 notify 走状态缓存兜底。
432
+ if (wasRunning && cfg.notifyDisposed) notify(agent.id, state, 'disposed', agent.session)
433
+ }
434
+
435
+ // 激活时播种:已 running 的 agent 记为「有活动」,完成时照常通知;
436
+ // 空闲的 agent 不产生状态(避免历史会话刷屏)。
437
+ try {
438
+ const agents = ctx.agents?.list?.()
439
+ if (Array.isArray(agents)) {
440
+ for (const agent of agents) {
441
+ if (isTracked(agent) && agent.status === 'running') {
442
+ stateFor(agent.id).running = true
443
+ stateFor(agent.id).turnStarted = true
444
+ }
445
+ }
446
+ }
447
+ } catch {
448
+ // 无 agents 服务时不播种,仅依赖事件。
449
+ }
450
+
451
+ const offStatus = ctx.on('agent/status', onStatus)
452
+ const offSession = ctx.on('session/event', onSessionEvent)
453
+ const offDisposed = ctx.on('agent/disposed', onDisposed)
454
+
455
+ ctx.effect(() => () => {
456
+ offStatus()
457
+ offSession()
458
+ offDisposed()
459
+ states.clear()
460
+ if (trayChild) {
461
+ try { trayChild.kill() } catch {}
462
+ trayChild = null
463
+ }
464
+ })
465
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "dsh-notify",
3
+ "description": "DeepSeek Harness (dsh) Windows notification plugin: a native toast when the agent stops running (finished / aborted / error / waiting for your choice / session closed), plus a system-tray icon. Pure host-side, zero dependencies, zero build.",
4
+ "version": "0.1.1",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "lib",
13
+ "assets",
14
+ "scripts",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "设计文稿.md"
18
+ ],
19
+ "scripts": {
20
+ "smoke": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/smoke-notice.ps1",
21
+ "pack": "npm pack"
22
+ },
23
+ "peerDependencies": {
24
+ "@deepseek-ai/cordis": "^4.0.1"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.5"
28
+ },
29
+ "keywords": [
30
+ "dsh",
31
+ "dsh-plugin",
32
+ "deepseek",
33
+ "harness",
34
+ "cordis",
35
+ "plugin",
36
+ "notify",
37
+ "notification",
38
+ "toast",
39
+ "windows"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Pasumao/dsh-plugin-notify.git"
44
+ },
45
+ "homepage": "https://github.com/Pasumao/dsh-plugin-notify#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/Pasumao/dsh-plugin-notify/issues"
48
+ },
49
+ "license": "MIT",
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,25 @@
1
+ # smoke-notice.ps1 - standalone Toast smoke test.
2
+ #
3
+ # ASCII-only on purpose: Windows PowerShell 5.1 reads .ps1 as the system
4
+ # ANSI code page, so non-ASCII literals here would fail to parse on a
5
+ # Chinese-locale machine. Chinese text is exercised by the plugin itself,
6
+ # which passes title/body via UTF-16 environment variables (code-page safe).
7
+ #
8
+ # Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/smoke-notice.ps1
9
+ param(
10
+ [string]$Title = "DSH notice test",
11
+ [string]$Body = "If you can read this toast, the notification pipeline works."
12
+ )
13
+
14
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
15
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
16
+
17
+ $t = $Title.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;')
18
+ $b = $Body.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;')
19
+ $xmlText = '<toast><visual><binding template="ToastGeneric"><text>' + $t + '</text><text>' + $b + '</text></binding></visual></toast>'
20
+
21
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
22
+ $xml.LoadXml($xmlText)
23
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
24
+ $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
25
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
@@ -0,0 +1,101 @@
1
+ /**
2
+ * test-harness.mjs — dsh-plugin-notify 端到端测试(无需 dsh、无需重启)。
3
+ *
4
+ * 用带事件记录的 mock ctx 直接驱动插件处理器,验证「等待选择 / 任务完成 /
5
+ * 任务被停止」三条链路都会真实弹出 Windows Toast(含中文渲染)。事件载荷
6
+ * 与 dsh 源码中 `agent/status`、`session/event` 的契约保持一致。
7
+ *
8
+ * 运行:node scripts/test-harness.mjs
9
+ */
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+ import { dirname, join } from 'node:path'
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const mod = await import(pathToFileURL(join(here, '..', 'lib', 'index.js')).href)
15
+
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
17
+
18
+ /** 记录 ctx.on 注册的处理器,便于手动派发事件。 */
19
+ const handlers = new Map()
20
+ const ctx = {
21
+ agents: { list: () => [] },
22
+ /** mock workspaceRegistry:test-session 归属「dsh-plugin-notify」工作区。 */
23
+ workspaceRegistry: {
24
+ list: () => [
25
+ {
26
+ id: 'ws-1',
27
+ path: 'C:\\Users\\18303\\Desktop\\dsh-plugin-notify',
28
+ title: 'dsh-plugin-notify',
29
+ sessionIds: ['test-session'],
30
+ },
31
+ ],
32
+ },
33
+ /** mock sessionTitle:返回 sidebar 标题。 */
34
+ sessionTitle: {
35
+ get: (s) => (s?.id === 'test-session' ? { title: '画一只赛博朋克猫' } : undefined),
36
+ },
37
+ on(name, cb) {
38
+ if (!handlers.has(name)) handlers.set(name, [])
39
+ handlers.get(name).push(cb)
40
+ return () => {}
41
+ },
42
+ effect(fn) {
43
+ return () => fn()
44
+ },
45
+ logger: {
46
+ debug: (...a) => console.log('[debug]', ...a),
47
+ warn: (...a) => console.log('[warn]', ...a),
48
+ info: (...a) => console.log('[info]', ...a),
49
+ },
50
+ }
51
+
52
+ // tray: false —— 测试只验证通知管道,不启动常驻托盘图标(否则 node 进程不会退出)。
53
+ mod.apply(ctx, { cooldownMs: 0, summaryMaxChars: 40, tray: false })
54
+
55
+ const emit = (name, ...args) => {
56
+ for (const cb of handlers.get(name) ?? []) cb(...args)
57
+ }
58
+
59
+ const session = {
60
+ id: 'test-session',
61
+ header: { cwd: 'C:\\Users\\18303\\Desktop\\dsh-plugin-notify' }, // 无 delegationDepth = 根会话
62
+ }
63
+ const agent = { id: 'test-session', status: 'running', session }
64
+
65
+ function startTurn() {
66
+ emit('agent/status', { agent, status: 'running' })
67
+ emit('session/event', session, { type: 'turn/start', data: { turn: 1 } })
68
+ }
69
+
70
+ console.log('=== 场景 1:等你选择(ask_user_question)===')
71
+ startTurn()
72
+ emit('session/event', session, {
73
+ type: 'user/message',
74
+ data: { message: { content: [{ type: 'text', text: '画一只赛博朋克猫' }] } },
75
+ })
76
+ emit('session/event', session, {
77
+ type: 'tool/call',
78
+ data: {
79
+ callId: 'c1',
80
+ name: 'ask_user_question',
81
+ arguments: '{"questions":[{"id":"q1","question":"选方案A还是方案B?"}]}',
82
+ },
83
+ })
84
+ await sleep(6500)
85
+
86
+ console.log('=== 场景 2:任务完成 ===')
87
+ emit('session/event', session, { type: 'tool/result', data: { message: { callId: 'c1' } } })
88
+ emit('session/event', session, { type: 'turn/end', data: { reason: { kind: 'completed' } } })
89
+ emit('agent/status', { agent, status: 'idle' })
90
+ await sleep(6500)
91
+
92
+ console.log('=== 场景 3:任务被停止 ===')
93
+ startTurn()
94
+ emit('session/event', session, {
95
+ type: 'turn/end',
96
+ data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
97
+ })
98
+ emit('agent/status', { agent, status: 'idle' })
99
+ await sleep(6500)
100
+
101
+ console.log('done')
@@ -0,0 +1,79 @@
1
+ # tray.ps1 - dsh system-tray icon.
2
+ #
3
+ # Runs a NotifyIcon for as long as the parent dsh process is alive: it shows a
4
+ # small dsh whale in the system tray (taskbar notification area), opens the dsh
5
+ # page on double-click / "Open" menu item, and exits (icon disappears) when the
6
+ # parent process is gone. The "Exit" menu item terminates the parent dsh
7
+ # process (i.e. closes the dsh backend).
8
+ #
9
+ # ASCII-only on purpose: Windows PowerShell 5.1 reads .ps1 as ANSI, so all
10
+ # user-visible Chinese labels come from UTF-16 environment variables set by the
11
+ # plugin. Run with -STA (WinForms message pump requires it).
12
+ param(
13
+ [int]$ParentPid = 0,
14
+ [string]$IconPath = '',
15
+ [string]$WebUrl = 'http://127.0.0.1:3080'
16
+ )
17
+
18
+ Add-Type -AssemblyName System.Windows.Forms
19
+ Add-Type -AssemblyName System.Drawing
20
+
21
+ $tooltip = [string]$env:DSH_TRAY_TOOLTIP
22
+ if (-not $tooltip) { $tooltip = 'dsh running' }
23
+ $openLabel = [string]$env:DSH_TRAY_OPEN
24
+ if (-not $openLabel) { $openLabel = 'Open dsh' }
25
+ $exitLabel = [string]$env:DSH_TRAY_EXIT
26
+ if (-not $exitLabel) { $exitLabel = 'Exit' }
27
+
28
+ $notify = New-Object System.Windows.Forms.NotifyIcon
29
+ if ($IconPath -and (Test-Path $IconPath)) {
30
+ $notify.Icon = [System.Drawing.Icon]::new($IconPath)
31
+ } else {
32
+ $notify.Icon = [System.Drawing.SystemIcons]::Application
33
+ }
34
+ $notify.Text = $tooltip
35
+ $notify.Visible = $true
36
+
37
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
38
+ $openItem = New-Object System.Windows.Forms.ToolStripMenuItem
39
+ $openItem.Text = $openLabel
40
+ $openItem.Add_Click({ Start-Process $WebUrl })
41
+ [void]$menu.Items.Add($openItem)
42
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
43
+ $hideItem = New-Object System.Windows.Forms.ToolStripMenuItem
44
+ $hideItem.Text = $exitLabel
45
+ $hideItem.Add_Click({
46
+ # Terminate the parent dsh process, then exit the tray.
47
+ if ($ParentPid -gt 0) {
48
+ try { Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue } catch {}
49
+ }
50
+ $notify.Visible = $false
51
+ $notify.Dispose()
52
+ [System.Windows.Forms.Application]::Exit()
53
+ })
54
+ [void]$menu.Items.Add($hideItem)
55
+ $notify.ContextMenuStrip = $menu
56
+ $notify.Add_DoubleClick({ Start-Process $WebUrl })
57
+
58
+ # Exit (icon disappears) when the parent dsh process is gone.
59
+ $timer = New-Object System.Windows.Forms.Timer
60
+ $timer.Interval = 3000
61
+ $timer.Add_Tick({
62
+ if ($ParentPid -gt 0) {
63
+ $gone = $false
64
+ try {
65
+ $p = [System.Diagnostics.Process]::GetProcessById($ParentPid)
66
+ if ($p.HasExited) { $gone = $true }
67
+ } catch {
68
+ $gone = $true
69
+ }
70
+ if ($gone) {
71
+ $notify.Visible = $false
72
+ $notify.Dispose()
73
+ [System.Windows.Forms.Application]::Exit()
74
+ }
75
+ }
76
+ })
77
+ $timer.Start()
78
+
79
+ [System.Windows.Forms.Application]::Run()
@@ -0,0 +1,119 @@
1
+ # dsh-plugin-notify 设计文稿
2
+
3
+ > 需求:设计一个 dsh 插件——只要 agent「没在运行了」(完成了绘画/生成等任务、被停止、出错、或停下来让我选择),就在 Windows 上弹系统提示提醒我。
4
+
5
+ ## 1. 目标与范围
6
+
7
+ | 项 | 内容 |
8
+ |---|---|
9
+ | 目标 | 用户用 DSH Web GUI 发起长任务后离开电脑;当 agent 不再运行时弹 Windows 通知,叫用户回来看结果。通知正文标注「工作区 · 会话」位置行,用户能直接知道是哪个工作区的哪个会话完成了。 |
10
+ | 形态 | 纯 **host 端** Cordis 插件(`lib/index.js`,普通 ESM),零运行时依赖、零构建步骤。 |
11
+ | 通知形式 | Windows 原生 Toast(`ToastNotificationManager`),`scenario="reminder"` 常驻直到手动关闭,带 dsh 鲸鱼 logo,点击打开 dsh 页面。 |
12
+ | 后台指示 | 系统托盘(任务栏右下角)常驻 dsh 鲸鱼图标(`NotifyIcon`),双击/右键打开 dsh、右键关闭 dsh 后台,dsh 退出后自动消失。 |
13
+ | 非目标 | 点击通知精确跳转到某个会话(web 端无 URL 深链,且借用 AUMID 的 toast 点击激活在本机不可靠,已放弃)。 |
14
+
15
+ ## 2. 关键结论(对 dsh 源码的调研)
16
+
17
+ 1. **插件注册(标准 bundle)**:依赖的 `package.json` 声明 `dsh.bundle.patch`(指向包内 `cordis.patch.yml`),`dsh plugin --profile <name> add <pkg>` 安装后自动把该包加入 `dsh.profile.bundles` 层(校准逻辑见 `dsh` 的 `plugin` 子命令:按已装依赖里声明了 `dsh.bundle` 的包追加)。包内补丁用 `- insert: [{ id, name, config? }]` 注册自身,用户层 `cordis.patch.yml` 后应用、按 id 覆盖 config。本插件发布名 `dsh-notify`,补丁 `name` 与 `package.json.name` 一致。
18
+ 2. **"不再运行"信号**:`@deepseek-ai/dsh-agent` 的 `agent/status`(`idle ⇄ running`),`running → idle` 即"不再运行";scope 过滤下未打标签的根 ctx 监听者能收到所有 agent 事件。
19
+ 3. **结束原因**:`session/event` 的 `turn/end` 带 `TurnEndReason`:`completed / aborted / blocked / error / max-tokens / interrupted`。
20
+ 4. **"等你选择"信号**:`ask_user_question` 执行时阻塞、agent 保持 `running`,用日志里未配对的 `tool/call`(`name:'ask_user_question'`)检测,`tool/result.message.callId` 配对即结束。
21
+ 5. **配置传递**:cordis loader 调 `apply(ctx, config)`;patch 条目可带 `config:`。
22
+ 6. **Windows 通知**:`powershell.exe`(5.1) 恒在 `%WINDIR%\System32\WindowsPowerShell\v1.0\`;用 `ToastNotificationManager` 发原生 Toast,AUMID 借用 PowerShell 已注册的 `{1AC14E77-...}\WindowsPowerShell\v1.0\powershell.exe`;脚本经 `-EncodedCommand`(UTF-16LE base64)传入、数据经 `env`(UTF-16)传入,规避引号/代码页问题。
23
+ 7. **位置标注数据源**:`ctx.workspaceRegistry`(`dsh-web-app` 提供)`list()` 返回带 `title`/`path`/`sessionIds` 的工作区,按 `sessionIds.includes(id)` 同步匹配归属;`ctx.sessionTitle`(`dsh-base` 提供)`get(session)` 返回 sidebar 显示的会话标题。两者都是进程内同步读,通知时现取即可,异常/缺失时优雅降级。
24
+
25
+ ## 3. 架构
26
+
27
+ ```
28
+ C:\Users\18303\Desktop\dsh-plugin-notify\
29
+ ├── package.json # dsh-notify;main: lib/index.js;dsh.bundle.patch: ./cordis.patch.yml
30
+ ├── cordis.patch.yml # bundle 补丁:- insert 注册 dsh-plugin-notify 行
31
+ ├── lib/index.js # host 半区:事件订阅 + 状态机 + Toast 派发 + 托盘图标
32
+ ├── assets/
33
+ │ ├── dsh.png # Toast 图标(黑色鲸鱼 logo,256×256)
34
+ │ └── dsh.ico # 托盘图标(黑色鲸鱼 logo,32×32)
35
+ ├── 设计文稿.md # 本文档
36
+ ├── README.md # 安装/配置/排障
37
+ └── scripts/
38
+ ├── tray.ps1 # 系统托盘图标(常驻,父进程退出后自动消失)
39
+ ├── smoke-notice.ps1 # 独立冒烟:直接弹一条 Toast
40
+ └── test-harness.mjs # 端到端自测:驱动处理器弹三条真实 Toast
41
+ ```
42
+
43
+ 数据流:
44
+
45
+ ```
46
+ agent 循环 ──agent/status──▶ 状态机(每会话) ──running→idle──▶ 组装文案 ──▶ spawn powershell ──▶ Toast(点击打开 dsh 页面)
47
+ ──session/event─▶ 状态机(记录 turn/reason/摘要/等待) ──等待/完成──▶ 同上
48
+ ──agent/disposed▶ 状态机(兜底"已关闭")
49
+ 宿主启动 ──▶ spawn tray.ps1 ──▶ 系统托盘常驻图标(父进程退出 → 图标消失)
50
+ ```
51
+
52
+ ## 4. 事件订阅与状态机
53
+
54
+ 每个被跟踪的会话维护一条状态记录(`Map<agentId, State>`):`running`、`turnStarted`(区分空转与真实工作)、`lastReason`、`lastPrompt`、`waiting/waitingCallId/waitingText`、`session`(最近可见会话对象引用)、`workspaceLabel/sessionLabel`(「工作区 · 会话」标签缓存)、`lastToastAt[kind]`(按类型冷却)。
55
+
56
+ | 事件 | 动作 |
57
+ |---|---|
58
+ | `agent/status` running | `running=true` |
59
+ | `agent/status` idle | 若 `running=true` 且 `turnStarted||waiting` → 按 `lastReason` 选文案通知;空转不通知 |
60
+ | `session/event` `turn/start` | `turnStarted=true` |
61
+ | `session/event` `turn/end` | 记录 `lastReason` |
62
+ | `session/event` `user/message` | 记录 `lastPrompt`(首个文本块摘要) |
63
+ | `session/event` `tool/call`(`ask_user_question`) | 置 `waiting`,立即通知"等你选择" |
64
+ | `session/event` `tool/result`(配对) | 清除 `waiting` |
65
+ | `agent/disposed` | 若曾 running → 通知"会话已关闭";删状态 |
66
+
67
+ 结束原因 → 标题:`completed`→任务完成、`aborted`→任务已停止、`error/interrupted`→任务出错、`max-tokens`→达到输出上限、`blocked`→回合被阻断。
68
+
69
+ ## 5. 关键实现细节
70
+
71
+ ### 5.1 Toast 派发(fire-and-forget)
72
+
73
+ `TOAST_SCRIPT` 用 `ToastNotificationManager` 发 `<toast scenario="reminder" activationType="protocol" launch="<webUrl>">`(整条常驻且点击打开 dsh 页面),`<image placement="appLogoOverride" src="file:///…dsh.png">` 显示鲸鱼 logo。脚本经 `-EncodedCommand` 传入、数据经 `env` 传入;进程失败只 `ctx.logger.warn`,不影响 dsh。
74
+
75
+ 正文由三个 `<text>` 组成:标题、任务摘要/等待文案、位置行(`工作区「…」 · 会话「…」`,经 `DSH_NOTICE_LOCATION` 环境变量传入,为空则省略该元素)。位置行在 `notify()` 时现取:`resolveWorkspaceLabel` 先按 `workspaceRegistry.list()` 的 `sessionIds` 匹配归属标题、兜底 cwd 目录名;`resolveSessionLabel` 取 `sessionTitle.get(session).title`、兜底会话 id 短号;两者均含长度截断,服务缺失时自动降级(无服务 → 无位置行)。
76
+
77
+ ### 5.2 系统托盘图标
78
+
79
+ host 半区 `apply` 时 `spawn(powershell.exe -STA -File scripts/tray.ps1 -ParentPid <pid> …)`,`tray.ps1` 用 `NotifyIcon` + `Application.Run()` 常驻;内置 `Timer` 每 3s 检查父进程 PID,父进程消失即退出;模块级 `trayChild` 保证进程内至多一个。双击/右键「打开 dsh」`Start-Process <webUrl>`,右键「关闭进程」`Stop-Process <父 PID>`。
80
+
81
+ ### 5.3 防抖与空转过滤
82
+
83
+ 激活时把已 `running` 的 agent 记为"有活动"(完成时照常通知),空闲 agent 不建状态(避免历史会话刷屏);`idle→running→idle` 且无 `turn/start` 不通知;冷却按类型记录,`cooldownMs` 内同类型不重复。
84
+
85
+ ## 6. 配置文件
86
+
87
+ patch 条目 `config`(全部有默认值,见 `README.md` 表格)。核心字段:`enabled`、`tray`、`rootsOnly`、`notifyFinished/Aborted/Error/Waiting/Disposed`、`cooldownMs`、`titlePrefix`(默认 `'dsh'`)、`iconPath`(默认 `assets/dsh.png`)、`webUrl`、`aumid`、`summaryMaxChars`、`powershellPath`。
88
+
89
+ ## 7. 安装步骤
90
+
91
+ 本项目是标准 DSH bundle:`package.json` 声明 `dsh.bundle.patch: ./cordis.patch.yml`,包内自带 `cordis.patch.yml`(`- insert: [{ id: dsh-plugin-notify, name: dsh-notify }]`)。`dsh plugin` 命令是 pnpm 转发器,安装成功后按「已装依赖中声明了 `dsh.bundle` 的包」自动校准 `dsh.profile.bundles`,无需手动写补丁。
92
+
93
+ 安装方式(任选其一):
94
+
95
+ 1. **npm**(推荐):`dsh plugin --profile web add dsh-notify`。
96
+ 2. **GitHub**:`dsh plugin --profile web add github:Pasumao/dsh-plugin-notify`(零构建,一般无需 allowBuilds)。
97
+ 3. **本地路径**:`dsh plugin --profile web add C:/Users/18303/Desktop/dsh-plugin-notify`。
98
+ 4. **手动 link**(旧版流程):dependencies 加 `"dsh-notify": "link:C:/Users/18303/Desktop/dsh-plugin-notify"` 后 `pnpm install`。
99
+
100
+ 装完重启 `dsh web`(托盘图标出现即加载成功)。调整行为时在用户层 `cordis.patch.yml` 按 id 覆盖 config。
101
+
102
+ ## 8. 边界与备选
103
+
104
+ | 情况 | 处理 |
105
+ |---|---|
106
+ | 重载时已空闲的历史会话 | 不建状态、不通知 |
107
+ | 唤醒后被清空的空转 | 无 `turn/start`,不通知 |
108
+ | 子代理大量完成 | `rootsOnly` 默认过滤(`delegationDepth`) |
109
+ | 中文乱码 | `env`(UTF-16) + `-EncodedCommand`(UTF-16LE base64) |
110
+ | PowerShell 缺失/失败 | 仅 warn,不崩溃 |
111
+ | HMR 重复加载 | `ctx.effect` 清理监听/状态/托盘 |
112
+
113
+ 备选(记录):点击精确跳会话需注册 dsh 自有 AUMID(`SHGetPropertyStoreFromParsingName` 在本机返回 `0x80070057` 未成功),或用 BurntToast 模块(`Install-Module BurntToast`)实现可点击 toast。
114
+
115
+ ## 9. 测试与验收
116
+
117
+ 1. `scripts/smoke-notice.ps1` 独立弹 Toast;`node scripts/test-harness.mjs` 驱动处理器弹三条真实 Toast(等待/完成/停止)。
118
+ 2. 端到端(重启后):正常任务完成弹 Toast、点停止弹"任务已停止"、`ask_user_question` 等待期弹"在等你选择"、重载旧会话不弹、子代理不刷屏、关闭 running 会话弹"已关闭";托盘图标常驻、父进程退出自动消失、点击打开 dsh。
119
+ 3. 日志:`ctx.logger` 记录每次通知的 kind/session/「工作区 · 会话」位置行与 spawn 错误。