@kkabuka/dsh-agent-notify 0.0.0-stage → 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,234 @@
1
+ # CHANGELOG
2
+
3
+ ## 1.0.2(2026-10-24,修 1.0.1 的改名遗漏)
4
+
5
+ - **Fixed:`cordis.patch.yml` 仍写旧包名 `dsh-agent-notify`** ⇒ 宿主 boot 报 `1 entry did not activate` + `Cannot find package 'dsh-agent-notify'`(bundle 行按名字 import,改名后必须同步)。现改成 `@kkabuka/dsh-agent-notify`,并加**回归锁** `test/patch-name.test.cjs`(断言 patch 的 `name` 与 `package.json` 逐字一致)。
6
+ - 影响面:1.0.1 对**新装机器**不可用(装上也起不来);已发布版本无法修改 ⇒ 以 1.0.2 覆盖。本机 1.0.1 的装机副本已手工同步该行(内容 = 1.0.2)。
7
+
8
+ ## 1.0.1(2026-10-24,首次以 `@kkabuka/dsh-agent-notify` 发布)
9
+
10
+ - **内容 = 内部 `1.0.0-fork.4`**(详见下一条)。改名重定的原因:上游 npm 自 2026-08-22 起停更在 `1.0.0`,`dsh-agent-notify` 这个名字归上游 ⇒ 我们的 0.2.x 适配无法按原名发布;`1.0.1` 语义 = "上游 1.0.0 + 一个补丁",`-fork.N` 后缀不再出现在对外版本号里。
11
+ - **发布形态**:`npm stage publish`(免 2FA)⇒ 网页 Staged Packages 点 Approve + 2FA 批准。npm 已收窄 bypass-2FA 直发(2027-01 全关),故不勾 bypass。
12
+ - **安装**:`dsh plugin --profile web add @kkabuka/dsh-agent-notify@1.0.1`。⚠ 旧名 `dsh-agent-notify`(上游)未适配 0.2.x,勿装。
13
+ - **兼容声明**:`dshCompatibility.testedVersions = 0.2.0-rc.2 / 0.2.1-rc.2`。
14
+
15
+ ## 1.0.0-fork.4(2026-10-06,通知正文改接 Conversation,本库)
16
+
17
+ - 补:**单测 21 例**(2026-10-12 立,`test/{_harness,client.test,card.test}.cjs` + `npm test` = `node --test "test/*.test.cjs"`,**零依赖免安装**)——覆盖决策面(何时发/不发、权限与 send mode 降级留痕、首见只建基线不重放、pending 抑制「任务完成」、子代理开关)与卡片契约(注册面、CHANGELOG fork.3 三枚死令牌回归锁、开关落盘、写失败降级)。**与真页面探针分工**:本册证明不了"浏览器里真的弹出来了",那一面仍归 `scripts/verify-notify.mjs`。**不 bump 版本**(不改包行为);沿革 = 2026-09-27 曾按"不假装成功"撤除指向不存在 `test/` 的声明,本次先有测试再挂声明。
18
+
19
+ 收掉 fork.2 起就挂着的「留档待办」:**通知正文的「最后一句回复」**。rc.2 删了
20
+ `SessionSnapshot.nodes` ⇒ 旧代码 `sessions.binding(id).session.getSnapshot().nodes` **恒空**
21
+ (静默:`Array.isArray(undefined)` 不报错,只是正文悄悄退化成只有标题)。
22
+
23
+ ### 修
24
+
25
+ 装配好的对话在 rc.2 归 **Conversation 视图所有者**管,故正文改用这条链:
26
+
27
+ ```
28
+ ctx.uiConversation.binding(sessionId) // 未 retain 会抛错 ⇒ 由 try/catch 降级
29
+ .target('chat').getSnapshot() // ChatSnapshot
30
+ ├─ navigation.items()[].response // ① 首选:内核自带「有界 assistant 回复预览」
31
+ └─ legacy.nodes(kind='assistant'.blocks[].text) // ② 回退:与旧代码逐字同形的兼容投影
32
+ ```
33
+
34
+ - **① `navigation` 为一等公民**:ChatSnapshot 的 `navigation` 是"Stable live navigation
35
+ projection of the loaded Turns",其 `response` 就是为紧凑预览而生的有界串;`legacy` 是
36
+ 明标 **compatibility** 的投影(将来会撤),故只作回退。
37
+ - **可达性约束与旧版一致**(不是新增限制):`uiConversation.binding()` 对 unknown/inactive
38
+ 会话**抛错**,而 `sessions.binding()` 是"borrow without extending lifetime" ⇒ 只有
39
+ **retained(窗口开着)**的会话才拿得到正文;否则诚实地只显示标题。
40
+ ⚠ 不引入 retain ⇒ **无泄漏**(binding 由 owner 的 ctx effect 回收)。
41
+ - 取值一路 `try/catch`;未 retain 是**正常路径**(不是失败),故不再打 warn(旧代码在这里
42
+ 会刷 `summary read failed`)。
43
+ - 新增诊断:`__agentNotify.preview([sessionId])` → `{ sessionId, text, source }`
44
+ (`source ∈ navigation | legacy-nodes | null`;省略 id = 当前主视图会话,由
45
+ `retainedBy.mainView` 定,**client-local**,不影响别的窗口)。供探针与用户自检。
46
+ - `BUNDLE_VERSION` → `1.0.7-fork.4`;包 `version` → `1.0.0-fork.4`。
47
+
48
+ ### 新增验收件:`scripts/verify-notify.mjs`(真页面 · 只读 · 6 判据)
49
+
50
+ 本包三次修复**全是静默失效型** ⇒ 只有真页面能判。故按 `headless-cdp-standards` §四 建了探针,
51
+ **共用外壳取 `dsh-clips/scripts/probe-harness.mjs`(唯一权威)**,本文件只放自己的判据:
52
+
53
+ | 判据 | 打在哪个修复上 |
54
+ |---|---|
55
+ | N1 版本面 | 装机件确实换新(防「install 成功 ≠ 更新成功」) |
56
+ | N2 信号源面 `sources()` 两项均解析 | fork.2(`sessions.open` / `pendingInteractions` 两处漂移) |
57
+ | N3a/N3b 选择框 computed 底色/边框 = **内核令牌解析值**且 ≠ rgba 兜底 | fork.3(三枚死令牌) |
58
+ | N4 `preview()` 对 mainView 会话给出非空 text + 命名 source | fork.4(本次) |
59
+ | N5 预览有界(≤91 = 90 + 省略号) | fork.4 |
60
+
61
+ **实测(2026-10-06,在役 3080,`--allow-main`,只读):6/6 PASS,exit 0**
62
+
63
+ ```
64
+ N1 PASS __agentNotify.version=1.0.7-fork.4 · 期望=1.0.7-fork.4
65
+ N2 PASS pending=sessionStatus · navigation=uiWorkspace.openSession
66
+ N3a PASS select=rgba(26, 34, 56, 0.65) · token(--dsw-specific-input-major@body)=rgba(26, 34, 56, 0.65) · 兜底=rgba(128,128,128,0.12)
67
+ N3b PASS select=rgba(160, 180, 230, 0.18) · token(--dsw-alias-border-l2@body)=rgba(160, 180, 230, 0.18) · 兜底=rgba(128,128,128,0.35)
68
+ N4 PASS session=session-6260ee54… · source=navigation · len=89
69
+ N5 PASS len=89(上限 91)
70
+ ```
71
+
72
+ - 报告落 `$DSH_HOME/logs/verify-notify-<stamp>.json`(含全文预览);**token 不入报告、不入 stdout**
73
+ (实测报告里 `token=` 零命中)。
74
+ - ⚠ **探针自身踩过一个坑,已修并留痕**:首版读 `document.documentElement` 拿令牌 ⇒ 空串 ⇒ 把
75
+ 「探针读错节点」误报成「令牌不存在」(当时 select 的 computed 值**已经**是非兜底主题色)。
76
+ 根因 = 设计平台表把令牌声明在 **`body`**(`body{--dsw-…}`)而非 `html`。现按 body 优先 /
77
+ html 兜底读,并回报命中处(`@body`)。
78
+ - 隔离实例口径(本域通用):`node <dsh>/lib/bin.js --profile <名> --host 127.0.0.1 --port <端口> --no-open`
79
+ ⇒ token 只落该实例启动日志;**3080 承载进程不碰/不重启/不杀**(实测探针跑完 3080 PID 20936 未变)。
80
+
81
+ ## 1.0.0-fork.3(2026-10-06,卡片令牌修正,本库)
82
+
83
+ 起因 = 收口扫描时逐个复核卡片样式里的 `var()`:**三枚令牌在内核里根本不存在**,全部静默落到
84
+ `rgba()` 兜底色 ⇒ 卡片永远不跟主题走(评审 R21 只抓到其中一枚,且当时把「应为 `-major`」
85
+ 明确标为**推断**)。这是「静默失效」型缺陷:不报错、不崩、控制台无线索。
86
+
87
+ ### 修(只改令牌名,不动任何视觉参数)
88
+
89
+ | 旧(内核不存在) | 新(内核有 `name:` 定义) | 依据 |
90
+ |---|---|---|
91
+ | `--dsw-specific-input-bg` | **`--dsw-specific-input-major`** | 内核**自己就把它当输入面背景**用:`ImageLightbox.module.css:24,41`、多个 client bundle 的 `background:var(--dsw-specific-input-major` ⇒ R21 的推断**至此有实据** |
92
+ | `--dsw-specific-input-border` | **`--dsw-alias-border-l2`** | 内核**没有** input-border 同义令牌 ⇒ 取通用发丝边(全树大量 `.5px solid var(--dsw-alias-border-l2)`),属**替代**而非同名替换 |
93
+ | `--dsw-specific-divider` | **`--dsw-alias-border-l2`** | 同上;这枚 R21 / theme-hub C4 此前**漏抓** |
94
+
95
+ - 三处仍保留原 `rgba()` 兜底值(非 dsh 宿主 / 令牌缺失时照常渲染)。
96
+ - ⚠ 三枚死令牌的**字面量仍留在 `ensureStyle()` 的注释里**(作为「这些名字曾被写错」的留痕)
97
+ ⇒ 将来若有基于 grep 的令牌消费面检查器,会在这三处产生 **3 条已知误报**,属预期。
98
+ - **防复发口径写进注释**:令牌必须在内核里查到 `name:` **定义**才算存在——裸 `var()` 引用
99
+ 不是证据(R21 的「推断」正是缺这一步才没敢下判)。
100
+
101
+ ### 版本面
102
+
103
+ `BUNDLE_VERSION` → `1.0.7-fork.3`;包 `version` → `1.0.0-fork.3`(tgz 改名 ⇒
104
+ `dsh-agent-notify-1.0.0-fork.3.tgz`,破 pnpm store 同版复用)。
105
+
106
+ ### 验证
107
+
108
+ - 装机:`npm pack --pack-destination $DSH_HOME/plugins-local` ⇒
109
+ `dsh-agent-notify-1.0.0-fork.3.tgz` ⇒ `dsh plugin --profile web add <tgz>` ⇒
110
+ 装机副本 `package.json` = 1.0.0-fork.3 / `BUNDLE_VERSION` = 1.0.7-fork.3;
111
+ profile 依赖 = `file:C:/Users/jankin_lv/.dsh/plugins-local/dsh-agent-notify-1.0.0-fork.3.tgz`。
112
+ - **tgz 内特征 grep**(`tar -xOf … package/lib/client.js`):`BUNDLE_VERSION = '1.0.7-fork.3'`、
113
+ `--dsw-specific-input-major`、`--dsw-alias-border-l2` 三处在位;三枚死令牌**不再出现在
114
+ 任何 CSS 规则里**(仅注释命中)。
115
+ - `node --check lib/client.js` ⇒ exit 0。
116
+ - ✅ **页面级复核已补做(2026-10-06,fork.4 那批)**:`scripts/verify-notify.mjs` 的
117
+ **N3a/N3b 判据**在真页面把这条钉死 —— 选择框 computed 底色/边框 = `body` 上
118
+ `--dsw-specific-input-major` / `--dsw-alias-border-l2` 的**解析值**,且**不等于** rgba 兜底
119
+ (`rgba(128,128,128,0.12)` / `rgba(128,128,128,0.35)`)⇒ 三枚死令牌的修复**有机械判据**,
120
+ 不再依赖肉眼。实测值见 fork.4 条目。
121
+ (原登记:本机 headless 上下文丢鉴权 cookie、一次性 token 按纪律不入对话 ⇒ 当时只做到文件级。
122
+ 现由**探针在进程内取 token** 解决,见 fork.4 的 `verify-notify.mjs`。)
123
+ - 回归面:本次只动 CSS 模板串与一处注释,未触碰 `apply` / 订阅 / 导航解析 ⇒ fork.2 的
124
+ `__agentNotify.sources()` 判据不受影响(真机一并看)。
125
+
126
+ ## 1.0.0-fork.2(2026-10-06,0.2.0-rc.2 内核适配,本库)
127
+
128
+ 内核从 0.1.2-rc.1 升到 0.2.0-rc.2 后,本 fork 有三处 client 契约漂移。起报症状 =
129
+ 「点击系统通知不再切换到对应会话」。本次修前两处(都是功能性坏点),第三处只留档。
130
+
131
+ ### 修复 1 · 点击系统通知 → 打开对应会话(用户报的症状)
132
+
133
+ - **旧形**:`ctxRef.sessions.open(sessionId)`,守卫 `typeof ctxRef.sessions.open === 'function'`。
134
+ - **为什么哑**:0.2.0-rc.2 的 `ISessions`(= `ctx.sessions` 的对外面)**已无 `open()`** ——
135
+ 契约注释原话 "navigation belongs to view owners",实现类 `ClientSessions` 方法表里也确实没有。
136
+ 守卫恒为假 ⇒ 点击**静默只聚焦**(不抛错,所以控制台连线索都没有)。
137
+ - **新形**:`resolveOpenSession()` 惰性解析导航动词 —— 优先
138
+ `ctx.uiWorkspace.openSession(target)`(0.2.0-rc.2 的 Workspace 视图所有者入口,实现即
139
+ `replaceMain(...)`,顺带清掉该会话的 completionUnread 小红点),保留旧 `sessions.open()` 回退;
140
+ **只缓存成功结果**(服务可能晚于 apply 注册);解析不到时 `console.warn` 一次(不再静默)。
141
+ - **取值姿势 = `ctx.reflect.get(name, false)`**(cordis 明文档的「免 inject 读服务」)⇒
142
+ **不把 `uiWorkspace` 加进 `exports.inject`**:该服务在组合里缺失时插件仍能加载并继续弹通知
143
+ (硬 inject 会让整个插件永不 apply,连通知一起没)。实测:直接 `ctx.uiWorkspace` 读会抛
144
+ `cannot get property "uiWorkspace" without inject`(插件 fiber 的 inject 里没有它),
145
+ 走 reflect 才拿得到。
146
+
147
+ ### 修复 2 · 「需要你的输入 / 批准 / 审阅」通知全哑(同因;用户未报,自查发现)
148
+
149
+ - **旧形**:`ctx.uiSession.pendingInteractions`(`Map<sessionId, interaction>`)。
150
+ - **为什么哑**:0.2.0-rc.2 **已删该 store**(整个内核 client bundle 里 `pendingInteractions` 零命中),
151
+ 换成 **`ctx.uiSession.sessionStatus`**:
152
+ `Map<sessionId, { running, pendingInteraction, completionUnread }>` + `subscribe`。
153
+ 旧代码 `trySubscribePending()` 撞守卫直接 `return false` ⇒ 这类通知**一条都不弹**。
154
+ - **新形**:`pendingSourceFor(ui)` 把两种 store 归一成
155
+ `{ id, subscribe, entries(), pendingOf(id) }`,优先 `sessionStatus`、回退 `pendingInteractions`
156
+ ⇒ **同一份 bundle 同时服务 0.2.0-rc.2 与 0.1.2-rc.1**(另一台机器若仍在该线不回退)。
157
+ - 交互对象字段**未变**(`kind` / `key` / `sessionId` / `questions` / `reason` / `toolName` 实测仍在),
158
+ 故 body 取值(`pendingSummaryText`)与 identity-diff 语义**原样保留**。
159
+ - 订阅纪律微调:`subscribe()` 成功返回后才置 `pendingSubscribed`(旧代码先置位,subscribe 抛错会留下
160
+ 「以为订上了其实没订」的哑状态)。
161
+
162
+ ### 留档待办(本次未修)· 通知正文缺「最后一句回复」
163
+
164
+ - `lastAssistantText()` 读 `snap.nodes`,但 0.2.0-rc.2 的 `SessionSnapshot` **已无 `nodes` 字段**
165
+ ⇒ 恒返回 `''`,正文退化为只有会话标题(**外观降级,不影响功能**)。
166
+ - 替代走 `ctx.uiConversation.binding(sessionId).snapshot → views[目标视图]`,但 `binding()`
167
+ 会**物化会话绑定**(副作用面)且需选定视图 key ⇒ 单列,不挤进本次修复。
168
+
169
+ ### 诊断面(新增)
170
+
171
+ - `window.__agentNotify.sources()` → `{ pending: 'sessionStatus' | 'pendingInteractions' | null,
172
+ navigation: 'uiWorkspace.openSession' | 'missing' }` —— 下次内核漂移时一眼判面。
173
+ - `BUNDLE_VERSION` → `1.0.7-fork.2`;启动日志行 `loaded (rc.1)` → `loaded (0.2.0-rc.2)`。
174
+
175
+ ### 已复核、未变(别顺手改)
176
+
177
+ - `sessions.list` 的 `phase` / `byId` / `running` / `displayTitle` / `origin` —— 「任务完成」检测照旧。
178
+ - `settings.section` 注册:0.2.0-rc.2 的 slots 面**已无 `ctx.slots.inject()` 方法**(`inject` 变成
179
+ `register` 的选项字段),而插件原有的「无 inject 面则直注册」回退正好走通 ⇒ 设置页正常,无需改。
180
+
181
+ ### 验证
182
+
183
+ - 装机:`npm pack --pack-destination $DSH_HOME/plugins-local` ⇒
184
+ `dsh-agent-notify-1.0.0-fork.2.tgz`(**改名破 pnpm store 同版复用**)⇒
185
+ `dsh plugin --profile web add <tgz>` ⇒ profile 依赖指向 `-1.0.0-fork.2.tgz`,
186
+ 装机副本 `package.json` = 1.0.0-fork.2 / `BUNDLE_VERSION` = 1.0.7-fork.2。
187
+ - 组合树复验:`Get-LoaderDupId`(`dsh --profile web --dump-config`,414 条)= **0 重复**;
188
+ 宿主存活(`http://127.0.0.1:3080/` 返回 401 = 未带 token 的正常应答)。
189
+ - 运行时(真页面 headless):`__DSH_BOOT__` 条目 rev `e404e71efbca` → `1947aa3669ca`;
190
+ `__agentNotify.version` = `1.0.7-fork.2`;
191
+ **`__agentNotify.sources()` = `{ pending: 'sessionStatus', navigation: 'uiWorkspace.openSession' }`**
192
+ (`pending` 非 null ⟺ `subscribe()` 已成功返回,故同时证明修 2 的订阅已挂上)。
193
+ - 跳转端到端:从真页面取到 client 根 ctx,调 `uiWorkspace.openSession(<另一会话>)` ⇒
194
+ `retainedBy.mainView` 从原会话移到目标会话、页面标题随之切换(`switched: true`)。
195
+ ⚠ 该动词 = 侧栏每次点击会话走的同一条内核路径。
196
+ - ✅ **真机确收(2026-10-06,用户)**:点通知气泡**已能跳到该会话** —— fork.2 主修闭环。
197
+ 本机 headless 通知权限为 `denied`,只能验到「动词已解析 + 动词可用」这一层;真机链路
198
+ (OS toast 弹出 → 点击 → 跳转)由用户实测确收。附带确收面 = 通知链路本身在 0.2.0-rc.2
199
+ 上仍是通的(能弹出、可点击)。
200
+
201
+ ## 1.0.0-fork.1(2026-09-07,rc.1 适配 fork,本库)
202
+
203
+ 上游 `1.0.0`(chidaic/dsh-agent-notify,rc.6 时代 client 契约)在 dsh 0.1.2-rc.1 +
204
+ web-all 0.3.13 下冷启动整页崩(`require("@deepseek-ai/dsh-client-runtime/client") missed
205
+ the module table`,见 PITFALLS §12.5)。本 fork 做最小适配救回,行为差异如下:
206
+
207
+ ### 移除(rc.1 硬不兼容点)
208
+ - manifest `dsh.client.inject: ["@deepseek-ai/dsh-client-runtime"]` 删除(只留 `platform: web`)。
209
+ - `lib/client.js` 顶部 `require('@deepseek-ai/dsh-client-runtime/client')` 删除
210
+ (rc.1 运行时无此模块 id;`react` 仍走平台 seed)。
211
+
212
+ ### 需输入信号换源(核心改动)
213
+ - 旧:从 `summary.pendingInteraction` 判「需要你的回答 / 批准 / 审阅」——rc.1
214
+ `SessionSummary` 已移除该字段。
215
+ - 新:订阅根级 **`ctx.uiSession.pendingInteractions`**(Map<sessionId, interaction> +
216
+ subscribe,kind = question / approval / plan-review)。`exports.inject` 增加
217
+ `uiSession`;订阅懒挂(服务可能晚于 apply 注册)+ 存在性守卫。
218
+ - 判新按 interaction 对象身份 diff(新对象 = 新请求,同会话重复来也通知;移除不通知);
219
+ 首次快照即通知既有 pending(「未答问题不静默吞掉」,同旧语义);kind 三值过滤;
220
+ subagent 过滤开关保留。
221
+ - 「任务完成」在停止瞬间若有活 pending 则抑制(避免与「需要回答」双弹)。
222
+
223
+ ### 其它
224
+ - `settings.section` 注册改 `ctx.slots.inject('settings.section', () => ctx.slots.register(...))`
225
+ 姿势(rc.1 slot 目录要求),`label` 改静态字符串;保留无 inject 面时的直注册回退。
226
+ - 点击系统通知 = 聚焦窗口(用户窄化需求:无通知内操作),原 open-session 尝试保留。⛔ **本口径已被 1.0.0-fork.2 取代**(2026-10-06 用户改要「点了就跳到该会话」)—— 看当前行为请读本册顶部 fork.2 条目。
227
+ - 诊断钩子 `window.__agentNotify` 增加 `events()`(最近 20 条通知尝试打点,含
228
+ fired/reason/kind/title/sessionId/mode/permission)——无头验收与排障用。
229
+ - `BUNDLE_VERSION` → `1.0.7-fork.1`。
230
+
231
+ ### 验证
232
+ 无头验收 6/6(无横幅 / hook / 设置卡 / 测试诊断 / 完成事件端到端打点 / requestPermission
233
+ denied 预期);真机 Chrome 授权后 OS toast 与点击聚焦待用户确收。机制全文见
234
+ dsh-internals §3.10。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chidaic
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,95 @@
1
- # Temporary Holding Version
1
+ # dsh-agent-notify
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
+ > **维护中的 fork(`@kkabuka/dsh-agent-notify`,v1.0.1)**:源自 [chidaic/dsh-agent-notify](https://github.com/chidaic/dsh-agent-notify)(MIT;上游 npm 停在 1.0.0 / 2026-08-22,已停更)。本 fork 面向 **dsh 0.2.x** 持续修复,许可与署名仍归上游(见文末)。
4
+ >
5
+ > 相对上游 1.0.0 的四处适配(差异全文 = [CHANGELOG](./CHANGELOG.md)):① 点击跳转改接 `ctx.uiWorkspace.openSession` ② 需输入信号改接 `ctx.uiSession.sessionStatus`(两者保留旧内核回退)③ 卡片三枚失效 CSS 令牌改回内核实有令牌 ④ 通知正文摘要改接 `ctx.uiConversation`(替换已删的 `SessionSnapshot.nodes`)。
6
+
7
+ **安装(npm)**
8
+
9
+ ```
10
+ dsh plugin --profile web add @kkabuka/dsh-agent-notify@1.0.1
11
+ ```
12
+
13
+ > 旧名 `dsh-agent-notify` 是上游包(未适配 0.2.x:会话导航与通知正文会静默失效)——**不要装**。源码安装 = `npm pack` → `dsh plugin --profile web add file:<tgz>`。
14
+
15
+ [![License](https://img.shields.io/npm/l/dsh-agent-notify)](LICENSE)
16
+
17
+ DSH Web GUI 的「任务完成通知」插件:当 agent 执行完任务 / 回复完对话、或需要你做出选择(提问 / 批准 / 审阅计划)时,弹出 **Windows 系统级通知**(通知中心气泡,声音为 Windows 系统通知音)——无论你在哪个窗口都能看到。**纯系统提示:无网页内弹窗、无合成音、无额外 UI 按钮。**
18
+
19
+ 纯浏览器端插件(host half 为空),无需构建工具,手写 bundle 直接加载。
20
+
21
+ ## 功能
22
+
23
+ - **任务完成通知**:会话从 `running` 变为停止时,发送「任务完成」系统通知。
24
+ - **需要输入通知**:agent 提问(`question`)时发送「需要你的回答」。
25
+ - **批准 / 计划审阅通知**:`approval` / `plan-review` 时发送「需要你的批准 / 请审阅计划」。
26
+ - **系统级通知**(浏览器 Notification API,类 Codex CLI 体验):Windows 通知中心弹出气泡,声音为 **Windows 系统通知音**(由 Windows 设置控制);点击气泡会聚焦页面并打开对应会话。三种模式可选:`不发送` / `后台时发送` / `始终发送`(默认)。
27
+ - **自动授权**:首次点击页面时自动请求通知权限(浏览器会询问,选「允许」即可);也可在设置页手动授权。
28
+ - **通知内容**:会话标题(+ 该会话最后一条 assistant 回复的前 90 字摘要)。摘要取自 `ctx.uiConversation` 的 Chat 视图:优先 `navigation.items()[].response`(内核自带的**有界回复预览**),回退 `legacy.nodes` 逐节点取文本。⚠ **前提 = 该会话仍被 retain(窗口开着)**——`uiConversation.binding()` 对未 retain 的会话会抛错,故窗口已关闭的会话只显示标题(与 rc.1 时代同一约束)。每条通知都是全新气泡(不带 `tag`——Windows 上 Chrome 对同 tag 通知只静默更新不弹新气泡,这是本插件历史上「通知消失」的根因,已移除)。
29
+ - **设置入口 = DSH 官方设置界面的一级页面**:设置 → **任务提示**(注册进官方 `settings.section` slot,与「设置 → 宠物」同级):启用通知、是否包含子代理通知、系统通知模式、权限状态与授权、**发送测试通知**(一键诊断,显示具体结果)。设置保存在 `localStorage`(`dsh.agentNotify.settings.v3`)。页面顶部显示插件版本与当前生效配置,便于排查旧缓存。
30
+ - **诊断**:设置页的「发送测试通知」无视发送模式直接弹一条系统通知,并显示结果——「已发送 ✓」却没弹,说明问题在浏览器/Windows 通知设置(地址栏站点权限、Windows 设置 → 系统 → 通知 → 浏览器通知开关与通知声音、专注助手),与插件逻辑无关。
31
+ - **防误报**:
32
+ - 页面加载 / 重连后,第一个列表快照只作为基线,不重放历史完成事件;
33
+ - 重连期间发生的完成不会在恢复连接后误报;
34
+ - 同一 pending 状态不重复通知;
35
+ - 子代理会话默认不通知(可在设置中开启)。
36
+
37
+ ## 安装
38
+
39
+ 从 npm(推荐,安装走 tarball 秒级完成):
40
+
41
+ ```sh
42
+ dsh plugin --profile web add dsh-agent-notify
43
+ ```
44
+
45
+ 从 GitHub:
46
+
47
+ ```sh
48
+ dsh plugin --profile web add github:chidaic/dsh-agent-notify
49
+ ```
50
+
51
+ 或在 **DSH 设置 → 插件市场**([dshmarket](https://github.com/dsh-market/dsh-market))中搜索 `dsh-agent-notify` 一键安装。
52
+
53
+ 浏览器端 bundle 变更后**刷新页面即可**(`/plugins/dsh-agent-notify/client.js` 每次读取最新文件;若遇到旧缓存,用 Ctrl+Shift+R 硬刷新)。
54
+
55
+ ## 配置
56
+
57
+ 无 host 配置。浏览器端设置位于官方设置界面 →「任务提示」页面,持久化于 localStorage。
58
+
59
+ ## 目录结构
60
+
61
+ ```
62
+ dsh-agent-notify/
63
+ ├── package.json # dsh.client 声明(platform: web;inject 已于 rc.1 fork 删除,见 CHANGELOG)
64
+ ├── cordis.patch.yml # 插件行(bundle patch)
65
+ ├── LICENSE # MIT
66
+ ├── CHANGELOG.md
67
+ ├── scripts/
68
+ │ └── verify-notify.mjs # 真页面验收探针(只读;6 判据;共用外壳 = dsh-clips/scripts/probe-harness.mjs)
69
+ ├── test/ # 单测(node:test · 零依赖,`npm test`;夹具 = test/_harness.cjs)
70
+ │ ├── client.test.cjs # 决策契约:何时发/不发、降级留痕、基线不重放、诊断面
71
+ │ └── card.test.cjs # 设置卡片:注册面、死令牌回归锁、落盘、失败路径
72
+ └── lib/
73
+ ├── index.js # host half(空 apply)
74
+ └── client.js # 浏览器 bundle(手写,无构建步骤)
75
+ ```
76
+
77
+ **测试面分工(2026-10-12 补单测)**:① **单测**(`npm test` = `node --test "test/*.test.cjs"`,21 例,**零依赖免安装**)= 决策与契约面(何时发/何时不发、降级留痕、基线不重放、注册面与卡片内容契约);② **无头真页面探针**(`node dsh/plugins/dsh-agent-notify/scripts/verify-notify.mjs --allow-main`,6 判据;口径与证据 = `docs/plugin-matrix.md` 本包行 + 无头验收报告)= 真 DOM / 真 React / 真弹窗面。⚠ **两册互补、不可互替**:单测跑在 `node:vm` 沙箱(stub React/Notification),证明不了"浏览器里真的弹出来了"。
78
+ > 沿革:2026-09-27 曾按"不假装成功"撤除指向**不存在** `test/` 的声明(`test` 脚本 / CI 徽章 / npm test 说明三处);本次是**先有测试再挂声明**,故声明重新成立。`devDependencies`(jsdom / react / react-dom)为真页面链所备,单测不依赖它们。
79
+
80
+ ## 开发
81
+
82
+ - 修改 `lib/client.js` 后运行 `node --check lib/client.js` 验证语法。
83
+ - **单测**:`npm test`(= `node --test "test/*.test.cjs"`;21 例,零依赖免安装)。改 `lib/client.js` 的决策面 / 卡片文案 / 令牌名后必跑;新增测试文件按 `test/<名>.test.cjs` 命名即被 glob 收进(无需改脚本)。
84
+ - 行为改动**必须跑真页面探针**(本包的三个历史缺陷全是「静默失效」型:不报错、不崩、控制台无线索,只有真页面能判):
85
+ ```sh
86
+ node dsh/plugins/dsh-agent-notify/scripts/verify-notify.mjs --allow-main # 在役/隔离实例 3080
87
+ node ... --port 3081 --log <该实例启动日志> --allow-main # 隔离实例
88
+ ```
89
+ ⛔ 探针**只读**(读 computed 样式 + 调插件诊断钩子;会话导航只发生在本探针浏览器内);token 由共用外壳从启动日志**在进程内**取用,不打印、不落报告。
90
+ - 每次行为变更请递增 `BUNDLE_VERSION`(设置页会显示,用于诊断浏览器缓存旧 bundle)。
91
+ - 页内诊断钩子:`__agentNotify.version` / `sources()`(信号源是否解析到)/ `preview([sessionId])`(通知正文会写什么,省略 id 即当前主视图会话)/ `events()`(最近 20 条通知尝试)。
92
+
93
+ ## 许可
94
+
95
+ MIT © [chidaic](https://github.com/chidaic)
@@ -0,0 +1,16 @@
1
+ # @kkabuka/dsh-agent-notify bundle patch: registers the plugin row into the web
2
+ # profile roster. Applied as a profile bundle layer (the `dsh.bundle.patch`
3
+ # manifest field); activate with
4
+ # `dsh plugin --profile web add @kkabuka/dsh-agent-notify@<ver>`.
5
+ #
6
+ # The row is a bare plugin by package name: the host half (exports ".") is an
7
+ # empty apply, and the `dsh.client` declaration in package.json makes the
8
+ # browser half (exports "./client", served at /plugins/@kkabuka/dsh-agent-notify/client.js)
9
+ # load in the web GUI.
10
+ #
11
+ # ⚠ `name` 必须与 package.json 的 `name` 逐字一致:2026-10-24 改名发布时这里漏改
12
+ # (仍写 dsh-agent-notify)⇒ 宿主 boot 报 `1 entry did not activate` +
13
+ # `Cannot find package 'dsh-agent-notify'`。回归锁 = test/patch-name.test.cjs。
14
+ - insert:
15
+ - id: agent-notify
16
+ name: '@kkabuka/dsh-agent-notify'
package/lib/client.js ADDED
@@ -0,0 +1,818 @@
1
+ /**
2
+ * dsh-agent-notify — browser half (hand-written bundle, no build step).
3
+ *
4
+ * What it does:
5
+ * 1. Subscribes to the client sessions list snapshot.
6
+ * 2. When a session flips running:true -> false -> "task done" system
7
+ * notification.
8
+ * 3. When a session gains a pending interaction (question / approval /
9
+ * plan-review) -> "needs your input" system notification. The signal comes
10
+ * from the root uiSession pending store: `ctx.uiSession.sessionStatus` on
11
+ * 0.2.0-rc.2, falling back to the rc.1 `ctx.uiSession.pendingInteractions`
12
+ * map (summary.pendingInteraction was removed back in 0.1.2-rc.1).
13
+ * 4. All notices go through the OS-level notification center (browser
14
+ * Notification API) — no in-page popups, no synthesized sounds.
15
+ * Clicking a system notification focuses the page and opens the owning
16
+ * session through the Workspace view owner (`ctx.uiWorkspace.openSession`,
17
+ * the 0.2.0-rc.2 navigation verb; the pre-0.2 `ctx.sessions.open` read is
18
+ * kept as a fallback). The bubble's sound is the Windows notification sound.
19
+ * 5. Settings live as a first-level page in the official DSH settings
20
+ * surface (`settings.section` slot — Settings → 任务提示, the same
21
+ * mechanism as Settings → 宠物): notifications / subagents / system
22
+ * mode / permission / test button. Persisted in localStorage.
23
+ *
24
+ * Failure policy: nothing here may take the web shell down — every DOM
25
+ * interaction is guarded, and subscription errors degrade to console logs.
26
+ */
27
+ window.__ModuleLoader__.load({
28
+ id: 'dsh-agent-notify',
29
+ factory: (require) => {
30
+ // rc.1 fork: the legacy `@deepseek-ai/dsh-client-runtime` module id does not
31
+ // exist in the 0.1.2-rc.1 runtime (no seed word / graph row / factory), so
32
+ // the original require was dropped. `react` below is a platform seed word.
33
+ const React = require('react')
34
+ var module = { exports: {} }
35
+ var exports = module.exports
36
+
37
+ /** Bump on every behavioral change; shown in the settings page for diagnosing stale bundles. */
38
+ const BUNDLE_VERSION = '1.0.7-fork.4'
39
+ try {
40
+ console.log('[dsh-agent-notify] fork v' + BUNDLE_VERSION + ' loaded (0.2.0-rc.2)')
41
+ } catch (error) { /* ignore */ }
42
+
43
+ // ------------------------------------------------------------------ settings
44
+ // v3: synthesized-sound options removed entirely (pure system notices).
45
+ const SETTINGS_KEY = 'dsh.agentNotify.settings.v3'
46
+ const DEFAULT_SETTINGS = {
47
+ enabled: true,
48
+ subagents: false,
49
+ // System-level notifications (browser Notification API): 'off' never
50
+ // sends, 'background' sends only while the page is hidden (you are in
51
+ // another window), 'always' sends for every event.
52
+ system: 'always',
53
+ }
54
+
55
+ function loadSettings() {
56
+ const base = Object.assign({}, DEFAULT_SETTINGS)
57
+ try {
58
+ const raw = localStorage.getItem(SETTINGS_KEY)
59
+ if (!raw) return base
60
+ const parsed = JSON.parse(raw)
61
+ if (typeof parsed.enabled === 'boolean') base.enabled = parsed.enabled
62
+ if (typeof parsed.subagents === 'boolean') base.subagents = parsed.subagents
63
+ if (parsed.system === 'off' || parsed.system === 'background' || parsed.system === 'always') {
64
+ base.system = parsed.system
65
+ }
66
+ } catch (error) {
67
+ console.warn('[dsh-agent-notify] settings read failed:', error)
68
+ }
69
+ return base
70
+ }
71
+
72
+ const settings = loadSettings()
73
+
74
+ function saveSettings() {
75
+ try {
76
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
77
+ } catch (error) {
78
+ console.warn('[dsh-agent-notify] settings write failed:', error)
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------- card styles
83
+ const STYLE_ID = 'dsh-agent-notify-style'
84
+
85
+ function ensureStyle() {
86
+ if (typeof document === 'undefined') return
87
+ if (document.getElementById(STYLE_ID) !== null) return
88
+ const style = document.createElement('style')
89
+ style.id = STYLE_ID
90
+ style.setAttribute('data-plugin', 'dsh-agent-notify')
91
+ // Token audit (2026-10-06): every var() below must exist in the kernel's
92
+ // design-platform sheet — a name that does not exist fails **silently**
93
+ // into the rgba() default, so it looks fine and simply ignores the theme.
94
+ // Three earlier names never existed (`--dsw-specific-input-bg`,
95
+ // `--dsw-specific-input-border`, `--dsw-specific-divider`); the live
96
+ // equivalents are `--dsw-specific-input-major` (the input-surface
97
+ // background the kernel itself paints, e.g. ImageLightbox.module.css) and
98
+ // `--dsw-alias-border-l2` (the kernel's standard hairline border, used as
99
+ // `.5px solid var(--dsw-alias-border-l2)` all over the tree).
100
+ // Audit method: grep the token name across `node_modules/@deepseek-ai/**`
101
+ // for a `name:` **definition** — a bare use is not proof it exists.
102
+ style.textContent = `
103
+ .dan-card { font-size: 13px; line-height: 1.5; max-width: 560px; }
104
+ .dan-card h3 { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
105
+ .dan-card .dan-card-desc { margin: 0 0 10px; color: var(--dsw-alias-label-secondary, rgba(128, 128, 128, 0.9)); font-size: 12px; }
106
+ .dan-card-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; min-height: 28px; }
107
+ .dan-card-row + .dan-card-row { border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.15)); }
108
+ .dan-card-row > label { display: flex; align-items: center; gap: 8px; cursor: pointer; flex: 1; min-width: 0; }
109
+ .dan-card-row .dan-card-hint { font-size: 11px; color: var(--dsw-alias-label-secondary, rgba(128, 128, 128, 0.9)); }
110
+ .dan-card input[type="checkbox"] { accent-color: #4f8cff; width: 14px; height: 14px; margin: 0; cursor: pointer; flex: none; }
111
+ .dan-card select { background: var(--dsw-specific-input-major, rgba(128, 128, 128, 0.12)); color: inherit;
112
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)); border-radius: 6px;
113
+ font-size: 12px; padding: 3px 6px; cursor: pointer; max-width: 140px; }
114
+ .dan-card button.dan-card-btn { border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
115
+ background: transparent; color: inherit; border-radius: 6px; padding: 3px 10px; font-size: 12px; cursor: pointer; }
116
+ .dan-card button.dan-card-btn:hover { background: var(--dsw-specific-sidebar-nav-item-hover, rgba(128, 128, 128, 0.12)); }
117
+ .dan-card .dan-card-perm { font-size: 11px; color: var(--dsw-alias-label-secondary, rgba(128, 128, 128, 0.9)); white-space: nowrap; }
118
+ `
119
+ document.head.appendChild(style)
120
+ }
121
+
122
+ function truncate(text, max) {
123
+ if (text.length <= max) return text
124
+ return text.slice(0, max - 1).trimEnd() + '…'
125
+ }
126
+
127
+ // ------------------------------------------------- system-level notifications
128
+ const SYSTEM_MODES = {
129
+ off: '不发送',
130
+ background: '后台时发送',
131
+ always: '始终发送',
132
+ }
133
+
134
+ function systemNotificationSupported() {
135
+ return typeof window !== 'undefined' && 'Notification' in window
136
+ }
137
+
138
+ function systemPermission() {
139
+ if (!systemNotificationSupported()) return 'unsupported'
140
+ return window.Notification.permission // 'granted' | 'denied' | 'default'
141
+ }
142
+
143
+ /** Ask the browser for notification permission (must run from a user gesture). */
144
+ function requestSystemPermission() {
145
+ if (!systemNotificationSupported()) return Promise.resolve('unsupported')
146
+ try {
147
+ return Promise.resolve(window.Notification.requestPermission())
148
+ } catch (error) {
149
+ console.warn('[dsh-agent-notify] permission request failed:', error)
150
+ return Promise.resolve('denied')
151
+ }
152
+ }
153
+
154
+ /** Whether the current settings want a system notification for this event. */
155
+ function shouldSendSystemNotification() {
156
+ if (!settings.enabled) return false
157
+ if (settings.system === 'off') return false
158
+ if (settings.system === 'always') return true
159
+ // 'background': only while the page is hidden (user is in another window).
160
+ return typeof document !== 'undefined' && document.hidden === true
161
+ }
162
+
163
+ // ------------------------------------------------------- session navigation
164
+ /**
165
+ * Read one client service without declaring it in `inject`.
166
+ *
167
+ * `ctx.reflect.get(name, false)` is cordis's documented non-inject read
168
+ * ("Read a service from the store without the inject requirement"). Going
169
+ * through it keeps this bundle free of a hard dependency: a deployment whose
170
+ * composition lacks the service still loads and still notifies, instead of
171
+ * never applying because one `inject` entry cannot be satisfied. The plain
172
+ * property read is the fallback for a runtime that exposes it normally.
173
+ */
174
+ function serviceRef(name) {
175
+ const ctx = ctxRef
176
+ if (!ctx) return undefined
177
+ try {
178
+ const reflect = ctx.reflect
179
+ if (reflect && typeof reflect.get === 'function') {
180
+ const impl = reflect.get(name, false)
181
+ if (impl) return impl
182
+ }
183
+ } catch (error) { /* fall through to the direct property read */ }
184
+ try { return ctx[name] } catch (error) { return undefined }
185
+ }
186
+
187
+ /**
188
+ * Resolve the main-view navigation verb for the running kernel.
189
+ *
190
+ * 0.2.0-rc.2 moved selection out of the Session Controller: the `ctx.sessions`
191
+ * face (`ISessions`) no longer has `open()` — "navigation belongs to view
192
+ * owners" — and the Workspace view owner exposes it as
193
+ * `ctx.uiWorkspace.openSession(target)`. The pre-0.2 `sessions.open()` read
194
+ * is kept as a fallback so one bundle serves both kernels.
195
+ *
196
+ * Resolved lazily and cached only on success (the service may activate after
197
+ * this plugin's apply). A miss is logged once, so the next kernel move shows
198
+ * up in the console instead of silently turning the click into focus-only.
199
+ */
200
+ let openSessionVerb = null
201
+ let openSessionMissingLogged = false
202
+
203
+ function resolveOpenSession() {
204
+ if (openSessionVerb) return openSessionVerb
205
+ try {
206
+ const workspace = serviceRef('uiWorkspace')
207
+ if (workspace && typeof workspace.openSession === 'function') {
208
+ openSessionVerb = (id) => workspace.openSession(id)
209
+ return openSessionVerb
210
+ }
211
+ const sessions = serviceRef('sessions')
212
+ if (sessions && typeof sessions.open === 'function') {
213
+ openSessionVerb = (id) => sessions.open(id)
214
+ return openSessionVerb
215
+ }
216
+ } catch (error) {
217
+ console.warn('[dsh-agent-notify] session navigation lookup failed:', error)
218
+ }
219
+ if (!openSessionMissingLogged) {
220
+ openSessionMissingLogged = true
221
+ console.warn('[dsh-agent-notify] no session navigation verb on this kernel (expected ctx.uiWorkspace.openSession); notification clicks will only focus the window')
222
+ }
223
+ return null
224
+ }
225
+
226
+ /**
227
+ * Push a Windows/system-level notification (browser Notification API).
228
+ * Clicking it focuses the page and opens the owning session.
229
+ *
230
+ * No `tag` on purpose: on Windows, Chrome replaces a still-listed
231
+ * notification with the same tag by updating it in place — the bubble
232
+ * never re-pops. A stale entry in the notification center therefore
233
+ * silently swallows every later notification with that tag. Without a
234
+ * tag every notice is a fresh bubble.
235
+ */
236
+ function sendSystemNotification(kind, title, detail, sessionId) {
237
+ // Record every attempted notice for diagnostics (window.__agentNotify.events())
238
+ // regardless of the send-mode / OS-permission outcome further down.
239
+ const attempted = { kind, title, detail, sessionId: sessionId || null, permission: systemPermission(), mode: settings.system }
240
+ if (!shouldSendSystemNotification()) { recordEvent(Object.assign({ fired: false, reason: 'mode' }, attempted)); return }
241
+ if (systemPermission() !== 'granted') { recordEvent(Object.assign({ fired: false, reason: 'permission' }, attempted)); return }
242
+ try {
243
+ const body = detail && detail !== '' ? detail : title
244
+ const notification = new window.Notification(title, {
245
+ body: truncate(body, 200),
246
+ })
247
+ recordEvent(Object.assign({ fired: true, reason: 'ok' }, attempted))
248
+ notification.onclick = () => {
249
+ try {
250
+ window.focus()
251
+ const open = sessionId ? resolveOpenSession() : null
252
+ if (open) open(sessionId)
253
+ } catch (error) { /* ignore */ }
254
+ try { notification.close() } catch (error) { /* ignore */ }
255
+ }
256
+ } catch (error) {
257
+ console.warn('[dsh-agent-notify] system notification failed:', error)
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Diagnostic: send a system notification regardless of the configured send
263
+ * mode (permission still applies). Returns a short result string so the
264
+ * settings page can show exactly what happened.
265
+ */
266
+ function sendTestNotification() {
267
+ if (systemPermission() === 'denied') return '权限被拒绝:点地址栏 🔒 → 网站设置 → 通知 → 允许'
268
+ if (systemPermission() === 'default') return '未授权:再点一次本按钮触发浏览器询问'
269
+ if (systemPermission() === 'unsupported') return '浏览器不支持系统通知(请用 Edge / Chrome)'
270
+ try {
271
+ const notification = new window.Notification('测试通知', {
272
+ body: '如果你看到这条消息,系统通知链路正常 ✓',
273
+ })
274
+ notification.onclick = () => {
275
+ try { window.focus() } catch (error) { /* ignore */ }
276
+ try { notification.close() } catch (error) { /* ignore */ }
277
+ }
278
+ return '已发送 ✓(若未弹出,检查 Windows 通知设置与专注助手)'
279
+ } catch (error) {
280
+ console.warn('[dsh-agent-notify] test notification failed:', error)
281
+ return '发送失败:' + (error && error.message ? error.message : String(error))
282
+ }
283
+ }
284
+
285
+ // Ask for notification permission on the first user gesture (browsers
286
+ // require a gesture for the permission prompt). Asked at most once.
287
+ let permissionAsked = false
288
+ function maybeAskPermission() {
289
+ if (permissionAsked) return
290
+ if (!systemNotificationSupported()) return
291
+ if (settings.system === 'off') return
292
+ if (window.Notification.permission !== 'default') return
293
+ permissionAsked = true
294
+ requestSystemPermission().then((permission) => {
295
+ if (permission === 'granted') {
296
+ sendSystemNotification('done', '系统通知已授权', '任务完成时将弹出系统级通知')
297
+ }
298
+ })
299
+ }
300
+ if (typeof document !== 'undefined') {
301
+ document.addEventListener('pointerdown', maybeAskPermission, { passive: true })
302
+ document.addEventListener('keydown', maybeAskPermission, { passive: true })
303
+ }
304
+
305
+ // ------------------------------------------------------------------- card UI
306
+ /**
307
+ * The settings page registered into the official `settings.section` slot
308
+ * (Settings → 任务提示). Built with plain createElement — no JSX, no
309
+ * build step. Reads/writes the bundle-level settings object and persists
310
+ * to localStorage; component state only drives re-renders.
311
+ */
312
+ function SettingsCard() {
313
+ const [state, setState] = React.useState({
314
+ enabled: settings.enabled,
315
+ subagents: settings.subagents,
316
+ system: settings.system,
317
+ perm: systemPermission(),
318
+ })
319
+ const update = (patch) => {
320
+ Object.assign(settings, patch)
321
+ saveSettings()
322
+ setState((prev) => ({ ...prev, ...patch }))
323
+ }
324
+ const refreshPerm = () => setState((prev) => ({ ...prev, perm: systemPermission() }))
325
+
326
+ const e = React.createElement
327
+ const row = (labelText, control, hint) =>
328
+ e('div', { className: 'dan-card-row', key: labelText },
329
+ e('label', null, control, labelText),
330
+ hint !== undefined ? e('span', { className: 'dan-card-hint' }, hint) : null)
331
+
332
+ const cb = (checked, onChange) =>
333
+ e('input', {
334
+ type: 'checkbox',
335
+ checked,
336
+ onChange: (ev) => onChange(ev.target.checked),
337
+ })
338
+
339
+ const permLabel =
340
+ state.perm === 'granted' ? '✓ 已授权' :
341
+ state.perm === 'denied' ? '✕ 已拒绝' :
342
+ state.perm === 'unsupported' ? '浏览器不支持' : '未授权'
343
+
344
+ return e('div', { className: 'dan-card' },
345
+ e('h3', null, '通知设置(agent 完成提醒)'),
346
+ e('p', { className: 'dan-card-desc' },
347
+ 'agent 执行完任务或需要你输入 / 批准时,弹出 Windows 系统通知(声音为系统通知音,由 Windows 设置控制)。'),
348
+ e('p', { className: 'dan-card-desc', style: { marginTop: '4px', fontSize: '11px' } },
349
+ '版本 v' + BUNDLE_VERSION + ' · 当前:通知 ' + (state.enabled ? '开' : '关') +
350
+ ' · 系统通知 ' + SYSTEM_MODES[state.system] + ' · 权限 ' + permLabel),
351
+ row('启用通知', cb(state.enabled, (v) => {
352
+ update({ enabled: v })
353
+ if (v) sendSystemNotification('done', '通知已开启', '任务完成时将弹出系统通知')
354
+ })),
355
+ row('包含子代理通知', cb(state.subagents, (v) => update({ subagents: v }))),
356
+ row('系统通知', e('select', {
357
+ value: state.system,
358
+ onChange: (ev) => update({ system: ev.target.value }),
359
+ }, Object.keys(SYSTEM_MODES).map((mode) =>
360
+ e('option', { value: mode, key: mode }, SYSTEM_MODES[mode])))),
361
+ row('权限', e('span', { className: 'dan-card-perm' }, permLabel),
362
+ state.perm === 'denied' ? '被拒绝:点地址栏 🔒 图标 → 网站设置 → 通知 → 允许' : ''),
363
+ row('测试', e('button', {
364
+ className: 'dan-card-btn',
365
+ onClick: () => {
366
+ const send = () => {
367
+ const result = sendTestNotification()
368
+ setState((prev) => ({ ...prev, lastTest: result }))
369
+ }
370
+ if (systemPermission() === 'default') {
371
+ requestSystemPermission().then(() => { refreshPerm(); send() })
372
+ } else {
373
+ send()
374
+ }
375
+ },
376
+ }, '发送测试通知'),
377
+ state.lastTest ? state.lastTest : '点击立即弹一条系统通知,用于诊断'),
378
+ )
379
+ }
380
+
381
+ // ------------------------------------------------------------------- detection
382
+ // Detection model across kernel generations:
383
+ // * running transitions come from the sessions list store (SessionSummary
384
+ // keeps `running` / `displayTitle` / `origin` on both lines);
385
+ // * "needs your input" comes from a root uiSession pending store whose
386
+ // shape moved: 0.2.0-rc.2 exposes `ctx.uiSession.sessionStatus`
387
+ // (Map<sessionId, { running, pendingInteraction, completionUnread }>),
388
+ // while 0.1.2-rc.1 exposed `ctx.uiSession.pendingInteractions`
389
+ // (Map<sessionId, interaction>). Both carry the same interaction objects
390
+ // (kind question / approval / plan-review) and both are still accepted
391
+ // here, so one bundle serves either kernel; `summary.pendingInteraction`
392
+ // was removed back in rc.1.
393
+ const prevStates = new Map() // sessionId -> { running }
394
+ const pendingBaseline = new Map() // sessionId -> last interaction object seen
395
+ let pendingSource = null // resolved uiSession pending store adapter
396
+ let pendingSubscribed = false
397
+ let ctxRef = null
398
+
399
+ // Diagnostics ring buffer: every attempted notification is recorded here so
400
+ // headless/console verification can prove a trigger fired even when the OS
401
+ // toast itself is gated by send mode or permission. Exposed as
402
+ // window.__agentNotify.events().
403
+ const recentEvents = []
404
+ function recordEvent(entry) {
405
+ try {
406
+ recentEvents.push(entry)
407
+ if (recentEvents.length > 20) recentEvents.shift()
408
+ } catch (error) { /* ignore */ }
409
+ }
410
+
411
+ const PENDING_LABELS = {
412
+ question: { title: '需要你的回答', kind: 'ask' },
413
+ approval: { title: '需要你的批准', kind: 'warn' },
414
+ 'plan-review': { title: '请审阅计划', kind: 'warn' },
415
+ }
416
+
417
+ function pendingInfo(kind) {
418
+ return PENDING_LABELS[kind] || { title: '需要你的操作', kind: 'ask' }
419
+ }
420
+
421
+ /** Last answered Turn's bounded assistant preview, from the Chat navigation index. */
422
+ function lastRailResponse(chat) {
423
+ try {
424
+ const rail = chat && chat.navigation
425
+ if (!rail || typeof rail.items !== 'function') return ''
426
+ const items = rail.items()
427
+ if (!Array.isArray(items)) return ''
428
+ for (let i = items.length - 1; i >= 0; i--) {
429
+ const item = items[i]
430
+ if (!item || typeof item.response !== 'string') continue
431
+ const text = item.response.replace(/\s+/g, ' ').trim()
432
+ if (text !== '') return text
433
+ }
434
+ } catch (error) { /* fall through to the node walk */ }
435
+ return ''
436
+ }
437
+
438
+ /** The pre-rc.2 walk, over the Chat legacy node projection (same node shape). */
439
+ function lastNodeText(chat) {
440
+ const nodes = chat && chat.legacy && Array.isArray(chat.legacy.nodes) ? chat.legacy.nodes : []
441
+ for (let i = nodes.length - 1; i >= 0; i--) {
442
+ const node = nodes[i]
443
+ if (!node || node.kind !== 'assistant') continue
444
+ const blocks = Array.isArray(node.blocks) ? node.blocks : []
445
+ const parts = []
446
+ for (const block of blocks) {
447
+ if (block && block.kind === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
448
+ parts.push(block.text.trim())
449
+ }
450
+ }
451
+ const text = parts.join(' ').replace(/\s+/g, ' ').trim()
452
+ if (text !== '') return text
453
+ }
454
+ return ''
455
+ }
456
+
457
+ /**
458
+ * The Session the main view currently shows, or null.
459
+ *
460
+ * `retainedBy.mainView` is the client-local (per-browser) main-view retention
461
+ * count, so this reads *this* browser's selection — not another window's.
462
+ * Used by the diagnostic hook's `preview()` and nothing else.
463
+ */
464
+ function mainViewSessionId() {
465
+ try {
466
+ const sessions = ctxRef && ctxRef.sessions
467
+ const list = sessions && sessions.list
468
+ const snap = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null
469
+ const byId = snap && snap.byId ? snap.byId : {}
470
+ for (const id of Object.keys(byId)) {
471
+ const row = byId[id]
472
+ if (row && row.retainedBy && row.retainedBy.mainView > 0) return id
473
+ }
474
+ } catch (error) { /* ignore */ }
475
+ return null
476
+ }
477
+
478
+ /** Which source produced the last preview (diagnostics: `__agentNotify.preview`). */
479
+ let lastPreviewSource = null
480
+
481
+ /**
482
+ * Preview text for one finished session — the notification body's "what did it
483
+ * say" half.
484
+ *
485
+ * 0.2.0-rc.2 removed `SessionSnapshot.nodes`, so the old
486
+ * `sessions.binding(id).session.getSnapshot().nodes` walk returns nothing
487
+ * (silently: `Array.isArray(undefined)` — no error, just an empty body).
488
+ * The assembled conversation now belongs to the Conversation view owner:
489
+ * `ctx.uiConversation.binding(sessionId).target('chat')`, whose
490
+ * `navigation.items()[].response` is a purpose-built **bounded
491
+ * assistant-response preview** per loaded Turn. `legacy.nodes` (a
492
+ * compatibility projection carrying the very node shape the old code read) is
493
+ * the fallback, so the pre-rc.2 semantics stay available.
494
+ *
495
+ * Reachability limit, unchanged from the old code: `uiConversation.binding()`
496
+ * **throws** for an unknown/inactive Session, and `sessions.binding()` is a
497
+ * borrow that never extends a lifetime ⇒ this only yields text while the
498
+ * Session is retained (its window open). Title-only is the honest fallback.
499
+ */
500
+ function lastAssistantText(sessionId) {
501
+ lastPreviewSource = null
502
+ try {
503
+ const conversation = serviceRef('uiConversation')
504
+ if (!conversation || typeof conversation.binding !== 'function') return ''
505
+ const binding = conversation.binding(sessionId)
506
+ if (!binding || typeof binding.target !== 'function') return ''
507
+ const target = binding.target('chat')
508
+ const snap = target && typeof target.getSnapshot === 'function' ? target.getSnapshot() : null
509
+ if (!snap) return ''
510
+ const rail = lastRailResponse(snap)
511
+ if (rail !== '') { lastPreviewSource = 'navigation'; return truncate(rail, 90) }
512
+ const nodes = lastNodeText(snap)
513
+ if (nodes !== '') { lastPreviewSource = 'legacy-nodes'; return truncate(nodes, 90) }
514
+ } catch (error) {
515
+ // Not retained (or no Conversation view owner): title-only, no warning —
516
+ // this is the normal case for a session that finished while closed.
517
+ }
518
+ return ''
519
+ }
520
+
521
+ /**
522
+ * Adapt whichever pending-interaction store this kernel's `ctx.uiSession`
523
+ * exposes into one shape: `{ id, subscribe, entries(), pendingOf(id) }`.
524
+ *
525
+ * 0.2.0-rc.2 replaced the rc.1 `pendingInteractions` Map with `sessionStatus`,
526
+ * which wraps the same interaction object one level deeper. Both adapters are
527
+ * stateless: every read goes to the live snapshot.
528
+ */
529
+ function pendingSourceFor(ui) {
530
+ if (!ui) return null
531
+ const status = ui.sessionStatus
532
+ if (status && typeof status.subscribe === 'function' && typeof status.getSnapshot === 'function') {
533
+ return {
534
+ id: 'sessionStatus',
535
+ subscribe: (listener) => status.subscribe(listener),
536
+ entries: () => {
537
+ const snap = status.getSnapshot()
538
+ const out = []
539
+ if (snap instanceof Map) {
540
+ for (const [sessionId, state] of snap) {
541
+ const interaction = state ? state.pendingInteraction : undefined
542
+ if (interaction) out.push([sessionId, interaction])
543
+ }
544
+ }
545
+ return out
546
+ },
547
+ pendingOf: (sessionId) => {
548
+ const snap = status.getSnapshot()
549
+ const state = snap instanceof Map ? snap.get(sessionId) : undefined
550
+ return state ? state.pendingInteraction : undefined
551
+ },
552
+ }
553
+ }
554
+ const legacy = ui.pendingInteractions
555
+ if (legacy && typeof legacy.subscribe === 'function' && typeof legacy.getSnapshot === 'function') {
556
+ return {
557
+ id: 'pendingInteractions',
558
+ subscribe: (listener) => legacy.subscribe(listener),
559
+ entries: () => {
560
+ const snap = legacy.getSnapshot()
561
+ return snap instanceof Map ? Array.from(snap).filter(([, interaction]) => interaction) : []
562
+ },
563
+ pendingOf: (sessionId) => {
564
+ const snap = legacy.getSnapshot()
565
+ return snap instanceof Map ? snap.get(sessionId) : undefined
566
+ },
567
+ }
568
+ }
569
+ return null
570
+ }
571
+
572
+ /** Current pending interaction for one session, via the resolved store adapter. */
573
+ function currentPendingFor(sessionId) {
574
+ try {
575
+ return pendingSource ? pendingSource.pendingOf(sessionId) : undefined
576
+ } catch (error) {
577
+ return undefined
578
+ }
579
+ }
580
+
581
+ /** Short human text from an interaction for the notification body (guarded). */
582
+ function pendingSummaryText(interaction) {
583
+ try {
584
+ if (!interaction) return ''
585
+ if (interaction.kind === 'approval') {
586
+ if (typeof interaction.reason === 'string' && interaction.reason.trim() !== '') return interaction.reason.trim()
587
+ if (typeof interaction.toolName === 'string' && interaction.toolName.trim() !== '') return '工具 ' + interaction.toolName
588
+ return ''
589
+ }
590
+ const qs = Array.isArray(interaction.questions) ? interaction.questions : []
591
+ for (const q of qs) {
592
+ if (!q) continue
593
+ if (typeof q.question === 'string' && q.question.trim() !== '') return q.question.trim()
594
+ if (typeof q.detail === 'string' && q.detail.trim() !== '') return q.detail.trim()
595
+ }
596
+ if (typeof interaction.detail === 'string' && interaction.detail.trim() !== '') return interaction.detail.trim()
597
+ } catch (error) { /* ignore */ }
598
+ return ''
599
+ }
600
+
601
+ /** Fire the "needs your input" notice for one session/interaction. */
602
+ function notifyPending(sessionId, interaction) {
603
+ const info = pendingInfo(interaction && interaction.kind)
604
+ const sessions = ctxRef && ctxRef.sessions
605
+ const list = sessions && sessions.list
606
+ const byId = list && typeof list.getSnapshot === 'function' ? (list.getSnapshot().byId || {}) : {}
607
+ const summary = byId[sessionId]
608
+ const base = (summary && summary.displayTitle) || sessionId
609
+ const text = pendingSummaryText(interaction)
610
+ const detail = text !== '' ? base + ' · ' + truncate(text, 90) : base
611
+ sendSystemNotification(info.kind, info.title, detail, sessionId)
612
+ }
613
+
614
+ /**
615
+ * Watch the kernel's root pending-interaction store (via the resolved
616
+ * adapter) and raise one notice per fresh interaction. Diffed against
617
+ * pendingBaseline by interaction identity: a fresh interaction object (each
618
+ * new question/approval gets a new `key`) notifies even when the same session
619
+ * already had one; removal never notifies.
620
+ */
621
+ function handlePendingChange() {
622
+ try {
623
+ if (!pendingSource) return
624
+ const sessions = ctxRef && ctxRef.sessions
625
+ const list = sessions && sessions.list
626
+ const listSnap = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null
627
+ if (!listSnap || listSnap.phase !== 'ready') return
628
+ const byId = listSnap.byId || {}
629
+ const visible = new Map()
630
+ for (const [sessionId, interaction] of pendingSource.entries()) {
631
+ if (!interaction) continue
632
+ const kind = interaction.kind
633
+ if (kind !== 'question' && kind !== 'approval' && kind !== 'plan-review') continue
634
+ if (!settings.subagents) {
635
+ const summary = byId[sessionId]
636
+ if (summary && summary.origin === 'subagent') continue
637
+ }
638
+ visible.set(sessionId, interaction)
639
+ }
640
+ for (const [sessionId, interaction] of visible) {
641
+ const seen = pendingBaseline.get(sessionId)
642
+ if (seen !== interaction) {
643
+ notifyPending(sessionId, interaction)
644
+ pendingBaseline.set(sessionId, interaction)
645
+ }
646
+ }
647
+ for (const sessionId of Array.from(pendingBaseline.keys())) {
648
+ if (!visible.has(sessionId)) pendingBaseline.delete(sessionId)
649
+ }
650
+ } catch (error) {
651
+ console.warn('[dsh-agent-notify] pending interaction handling failed:', error)
652
+ }
653
+ }
654
+
655
+ /** Lazily resolve + subscribe the uiSession pending store (it may activate after apply). */
656
+ function trySubscribePending() {
657
+ try {
658
+ if (pendingSubscribed) return true
659
+ const source = pendingSourceFor(ctxRef && ctxRef.uiSession)
660
+ if (!source) return false
661
+ const off = source.subscribe(handlePendingChange)
662
+ pendingSource = source
663
+ pendingSubscribed = true
664
+ ctxRef.effect(() => off, 'agent-notify: uiSession pending subscription')
665
+ handlePendingChange()
666
+ return true
667
+ } catch (error) {
668
+ console.warn('[dsh-agent-notify] pending watcher subscribe failed:', error)
669
+ return false
670
+ }
671
+ }
672
+
673
+ function handleListChange() {
674
+ try {
675
+ const sessions = ctxRef && ctxRef.sessions
676
+ if (!sessions) return
677
+ const snap = sessions.list.getSnapshot()
678
+ if (!snap || snap.phase !== 'ready') return
679
+ const byId = snap.byId || {}
680
+ for (const id of Object.keys(byId)) {
681
+ const summary = byId[id]
682
+ if (!summary) continue
683
+ // Subagent rows are skipped unless enabled in settings.
684
+ if (summary.origin === 'subagent' && !settings.subagents) {
685
+ prevStates.delete(id)
686
+ continue
687
+ }
688
+ const was = prevStates.get(id)
689
+ const nowRunning = summary.running === true
690
+ if (was === undefined) {
691
+ // First sighting (page load / reconnect baseline): record it and
692
+ // never replay a finish. Anything already pending for the session
693
+ // is handled by the uiSession pending watcher baseline instead.
694
+ prevStates.set(id, { running: nowRunning })
695
+ continue
696
+ }
697
+ if (was.running && !nowRunning && currentPendingFor(id) === undefined) {
698
+ // "任务完成" is suppressed when the stop coincides with a live
699
+ // question/approval — that arrival already raised its own notice.
700
+ const title = summary.displayTitle || id
701
+ const summaryText = lastAssistantText(id)
702
+ const detail = summaryText !== '' ? title + ' · ' + summaryText : title
703
+ sendSystemNotification('done', '任务完成', detail, id)
704
+ }
705
+ prevStates.set(id, { running: nowRunning })
706
+ }
707
+ for (const id of Array.from(prevStates.keys())) {
708
+ if (!(id in byId)) prevStates.delete(id)
709
+ }
710
+ // List ready also means: (re)sync the pending watcher baseline once the
711
+ // uiSession store is available (covers pending that arrived pre-list).
712
+ if (!pendingSubscribed) trySubscribePending()
713
+ else handlePendingChange()
714
+ } catch (error) {
715
+ console.warn('[dsh-agent-notify] list change handling failed:', error)
716
+ }
717
+ }
718
+
719
+ function resetBaseline() {
720
+ // Drop every tracked state without re-baselining from the stale
721
+ // snapshot: the list is about to be re-pulled, and the first snapshot
722
+ // after the reset becomes the new baseline. Re-baselining from the old
723
+ // snapshot would replay a finish (running true -> false) that happened
724
+ // while disconnected as a false "task done".
725
+ // pendingBaseline is cleared on purpose: after a reconnect the uiSession
726
+ // store re-publishes still-unanswered interactions as fresh objects, and
727
+ // a question that is still waiting on the user must not be silently
728
+ // swallowed (same policy as the pre-rc.1 baseline).
729
+ prevStates.clear()
730
+ pendingBaseline.clear()
731
+ }
732
+
733
+ // ------------------------------------------------------------------------ apply
734
+ let applied = false
735
+
736
+ exports.inject = ['slots', 'sessions', 'uiSession']
737
+
738
+ exports.apply = function apply(ctx) {
739
+ if (applied) return
740
+ applied = true
741
+ ctxRef = ctx
742
+ try {
743
+ const sessions = ctx.sessions
744
+ if (!sessions || !sessions.list || typeof sessions.list.subscribe !== 'function') {
745
+ console.warn('[dsh-agent-notify] sessions list service unavailable; notifications disabled')
746
+ return
747
+ }
748
+ const unsubscribe = sessions.list.subscribe(handleListChange)
749
+ ctx.effect(() => unsubscribe, 'agent-notify: sessions list subscription')
750
+ ctx.on('connection/reset', resetBaseline)
751
+ // Register a first-level settings page (Settings → 任务提示). rc.1
752
+ // posture: ctx.slots.inject wraps the register so the entry only mounts
753
+ // once the slot is declared; direct-register kept as a fallback for
754
+ // runtimes whose slots face has no inject().
755
+ if (ctx.slots) {
756
+ try {
757
+ ensureStyle()
758
+ const registerSection = () => ctx.slots.register({
759
+ name: 'settings.section',
760
+ id: 'agent-notify',
761
+ order: 131,
762
+ label: '任务提示',
763
+ }, SettingsCard)
764
+ if (typeof ctx.slots.inject === 'function') {
765
+ ctx.slots.inject('settings.section', registerSection)
766
+ } else if (typeof ctx.slots.register === 'function') {
767
+ const unregister = registerSection()
768
+ ctx.effect(() => unregister, 'agent-notify: settings section')
769
+ } else {
770
+ console.warn('[dsh-agent-notify] slots service unusable; settings section not registered')
771
+ }
772
+ } catch (error) {
773
+ console.warn('[dsh-agent-notify] settings section registration failed:', error)
774
+ }
775
+ } else {
776
+ console.warn('[dsh-agent-notify] slots service unavailable; settings section not registered')
777
+ }
778
+ // Establish the baselines once services are ready. The pending watcher
779
+ // subscribes lazily (uiSession may activate after this apply runs).
780
+ handleListChange()
781
+ trySubscribePending()
782
+ // Console diagnostic hook: `window.__agentNotify.test()` sends a test
783
+ // notification and returns the exact result string.
784
+ try {
785
+ window.__agentNotify = {
786
+ version: BUNDLE_VERSION,
787
+ settings: () => Object.assign({}, settings),
788
+ permission: () => systemPermission(),
789
+ test: () => sendTestNotification(),
790
+ events: () => recentEvents.map((e) => Object.assign({}, e)),
791
+ // Which kernel-generation surfaces this bundle actually resolved.
792
+ // `pending` names the adapter in use; `navigation` reports whether a
793
+ // click can still open its session (a miss also logs one warning).
794
+ sources: () => ({
795
+ pending: pendingSource ? pendingSource.id : null,
796
+ navigation: resolveOpenSession() ? 'uiWorkspace.openSession' : 'missing',
797
+ }),
798
+ // Read the notification body's preview half for one session, exactly as
799
+ // a notice would build it, plus which source produced it. `''` means
800
+ // the session is not retained (its window is closed) — the honest
801
+ // title-only case, not a failure. Used by scripts/verify-notify.mjs.
802
+ preview: (sessionId) => {
803
+ const id = sessionId || mainViewSessionId()
804
+ const text = id ? lastAssistantText(id) : ''
805
+ return { sessionId: id, text, source: lastPreviewSource }
806
+ },
807
+ }
808
+ } catch (error) {
809
+ console.warn('[dsh-agent-notify] debug hook install failed:', error)
810
+ }
811
+ } catch (error) {
812
+ console.warn('[dsh-agent-notify] apply failed:', error)
813
+ }
814
+ }
815
+
816
+ return module.exports
817
+ },
818
+ })
package/lib/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * dsh-agent-notify — host half.
3
+ *
4
+ * This plugin is pure browser-side capability: the empty apply exists so the
5
+ * row appears in the host cordis.yml / Loader, while the browser half ships
6
+ * through exports["./client"], discovered from the package.json `dsh.client`
7
+ * declaration (see @deepseek-ai/dsh-client-modules).
8
+ */
9
+
10
+ /** Host plugin body — this package contributes nothing host-side. */
11
+ export function apply() {}
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@kkabuka/dsh-agent-notify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "description": "DSH Web GUI 任务完成/需你输入通知(维护中的 fork,适配 dsh 0.2.x):agent 完成任务或需要你输入/批准时弹出 Windows 系统级通知,点气泡跳回该会话。相对上游 1.0.0:① 点击跳转改接 ctx.uiWorkspace.openSession ② 需输入信号改接 ctx.uiSession.sessionStatus ③ 卡片三枚失效令牌改为内核实有令牌 ④ 通知正文摘要改接 ctx.uiConversation(替换已删的 SessionSnapshot.nodes)。MIT,保留上游版权;源码在 personal-docs/dsh/plugins/dsh-agent-notify。",
4
+ "version": "1.0.2",
5
+ "dshCompatibility": {
6
+ "testedVersions": ["0.2.0-rc.2", "0.2.1-rc.2"]
7
+ },
8
+ "type": "module",
9
+ "main": "lib/index.js",
10
+ "scripts": {
11
+ "test": "node --test \"test/*.test.cjs\""
12
+ },
13
+ "author": "chidaic (original); kkabuka (fork)",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/chidaic/dsh-agent-notify.git"
18
+ },
19
+ "homepage": "https://github.com/chidaic/dsh-agent-notify",
20
+ "bugs": {
21
+ "url": "https://github.com/chidaic/dsh-agent-notify/issues"
22
+ },
23
+ "keywords": [
24
+ "deepseek",
25
+ "harness",
26
+ "dsh",
27
+ "dsh-plugin",
28
+ "notification",
29
+ "notify",
30
+ "windows",
31
+ "toast",
32
+ "web"
33
+ ],
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ },
38
+ "exports": {
39
+ ".": "./lib/index.js",
40
+ "./client": "./lib/client.js",
41
+ "./package.json": "./package.json"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "form": "handwritten"
50
+ }
51
+ },
52
+ "files": [
53
+ "lib",
54
+ "cordis.patch.yml",
55
+ "README.md",
56
+ "LICENSE",
57
+ "CHANGELOG.md"
58
+ ],
59
+ "devDependencies": {
60
+ "jsdom": "^30.0.1",
61
+ "react": "^19.2.8",
62
+ "react-dom": "^19.2.8"
63
+ }
64
+ }