@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 +234 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/cordis.patch.yml +16 -0
- package/lib/client.js +818 -0
- package/lib/index.js +11 -0
- package/package.json +62 -4
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
|
-
#
|
|
1
|
+
# dsh-agent-notify
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
|
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)
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|