dsh-notify-ding 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@
15
15
  - 某个会话从执行中落到空闲、也就是一轮对话跑完时叮咚一次。
16
16
  - 两种触发都会弹浏览器系统通知,同时请宿主播放 Windows 内置提示音。
17
17
  - 不判断页面是否聚焦,也不区分是否为当前会话,任何会话有事都会响。
18
+ - 通知不设自动关闭时间,会一直留在系统通知里,直到对应会话发生操作:会话被选中、该会话开始新一轮、待回答被解决,或会话从列表移除。
19
+ - 每个会话最多保留一条通知,新通知会顶掉同会话的旧通知;点击通知会聚焦窗口并切到该会话。
18
20
  - 同一待回答请求只响一次,两次响铃之间至少有 800 毫秒间隔,且该间隔跨标签页共享,多开标签页不会重复响。
19
21
 
20
22
  ## 使用
@@ -57,7 +59,7 @@ dsh plugin --profile web remove dsh-notify-ding
57
59
 
58
60
  浏览器半身订阅 `ctx.sessions.list` 与 `ctx.uiSession.pendingInteractions` 两个只读快照,用边沿检测判断该不该响:运行态从真落到假算一轮跑完,出现新 key 的待回答交互算需要人工介入。首帧只建基线不发声,同一待回答请求只响一次,两次响铃之间留至少 800 毫秒并把该时刻写入 localStorage 跨标签页共享。
59
61
 
60
- 判定通过时它做两件事:弹浏览器系统通知,以及向宿主发一个 POST。宿主半身只负责后者,它注册的 `/dsh-notify-ding/ding` 路由收到请求就用 PowerShell 播放提示音。完整设计见 [设计说明](docs/design.md)。
62
+ 判定通过时它做两件事:弹浏览器系统通知,以及向宿主发一个 POST。宿主半身只负责后者,它注册的 `/dsh-notify-ding/ding` 路由收到请求就用 PowerShell 播放提示音。通知按会话记录且不设自动关闭,同一会话只留最新一条;会话被选中、开始新一轮、待回答被解决或从列表移除时会关掉对应通知,点击通知则聚焦窗口、切换过去并关掉该条。完整设计见 [设计说明](docs/design.md)。
61
63
 
62
64
  ## 要求与开发
63
65
 
package/cordis.patch.yml CHANGED
@@ -1,9 +1,7 @@
1
- # dsh-notify-ding bundle patch: mount the notification plugin.
2
- #
3
- # Applied when this bundle appears in a profile's dsh.profile.bundles list.
4
- # The node half (package root) owns the host-side sound route and the optional
5
- # window sound; the browser half (dsh.client) is discovered automatically
6
- # through the package's dsh.client declaration + exports["./client"].
1
+ # dsh-notify-ding bundle patch:把通知插件插进 profile 的 layer stack。
2
+ # 当本 bundle 出现在 profile 的 dsh.profile.bundles 列表里时应用。
3
+ # 主机端半身负责宿主的提示音路由与可选的窗口提示音。
4
+ # 浏览器半身由包的 dsh.client 声明与 exports["./client"] 自动发现。
7
5
 
8
6
  - insert:
9
7
  - id: dsh-notify-ding
package/docs/design.md CHANGED
@@ -28,6 +28,23 @@
28
28
  2. **同一待回答请求只响一次**:按交互的稳定 key 记忆,消失后清理记录但不发声。
29
29
  3. **滚动最小间隔 800 毫秒**:两次响铃之间至少间隔这么久。窗口内到达的请求不丢弃,而是挂到窗口边界的定时器上补发,保证不漏报。该时刻写入 localStorage 跨标签页共享,多开标签页时同一件事只响一轮。
30
30
 
31
+ ## 通知的存活与关闭
32
+
33
+ 浏览器系统通知不再设自动关闭时间,并且带上 `requireInteraction`,会一直留在系统通知里,直到对应会话发生操作。插件按会话 id 记录当前挂着的通知,每个会话最多一条,新的先关掉旧的。
34
+
35
+ 以下任一情况出现,就算对应会话得到了一次操作,插件会关掉它的通知:
36
+
37
+ 1. **会话被选中**:`sessions.list.current` 变成了该会话。
38
+ 2. **开始新一轮**:该会话的运行态从假升到真,说明用户回到了这里。
39
+ 3. **待回答被解决**:该会话的待回答交互 key 消失;被新 key 顶掉也算,替换场景下先关旧通知再响新的一条,不会误关。
40
+ 4. **会话被移除**:该会话从列表消失。
41
+
42
+ 选中信号有两处细节。`current` 短暂变成 `undefined` 是 DSH 的 masked gap,选中会话暂时不在列表时就会出现,不算一次操作,也不覆盖上一个有效选中。选中变化先于响铃处理,因此同一帧里既跑完又刚被选中的会话,通知不会被建立后立刻关掉。
43
+
44
+ 点击通知会先聚焦窗口,再经 `ctx.sessions.open(sessionId)` 切到该会话,然后关掉这条通知;会话已不在列表时只聚焦、不切换。插件卸载时会把还挂着的通知全部关掉。
45
+
46
+ 只识别这四种状态信号,因此通知只有在 DSH 里真的对那个会话动了手才会消失;单纯回到浏览器窗口不会关掉它。
47
+
31
48
  ## 声音与通知的关系
32
49
 
33
50
  两者互相独立:浏览器通知弹不出来时声音照响,宿主播音失败时通知照弹。任一侧失败只记一条 `console.warn`,不抛错、不互相阻塞。
@@ -42,3 +59,4 @@ dsh web 由 connection 插件提供认证边界:根路径带 token 打开后
42
59
  - 播音走 PowerShell 进程,单次开销约是一百毫秒级,慢但不阻塞响应。
43
60
  - 「一轮跑完」只覆盖正常结束与被打断这两种空闲落点,插件不区分具体结束原因。
44
61
  - Windows 聚焦助手开启时系统通知可能被静音,但宿主播音不受影响。
62
+ - 页面被关掉后,浏览器通知可能仍留在系统通知中心,此时插件已不在运行,无法再收回,只能手动清掉。
package/lib/client.js CHANGED
@@ -18,8 +18,14 @@ window.__ModuleLoader__.load({
18
18
  const MIN_INTERVAL_MS = 800;
19
19
  /** 跨标签页共享「上次响铃时刻」的 localStorage 键。 */
20
20
  const LAST_DING_KEY = "dsh-notify-ding.lastDingAt";
21
- /** 系统通知自动关闭的延时。 */
22
- const NOTIFICATION_TTL_MS = 1e4;
21
+ /** 当前挂在系统通知里、按会话索引的通知,新通知会顶掉同会话的旧通知。 */
22
+ const liveNotifications = /* @__PURE__ */ new Map();
23
+ /** 打开会话的入口,由 apply 从 ctx.sessions 注入;缺失时点击通知只关通知。 */
24
+ let sessionOpener;
25
+ /** 会话列表快照源,点击通知切会话前用它确认会话还在。 */
26
+ let listSourceRef;
27
+ /** 上一帧被选中的会话,用来识别「用户选中了某个会话」这个操作信号。 */
28
+ let lastCurrent;
23
29
  /** 记录各会话上一帧状态,用来做边沿检测。 */
24
30
  const tracks = /* @__PURE__ */ new Map();
25
31
  /** 本页面上次响铃的时间戳,兜住同一帧内的重复触发。 */
@@ -68,29 +74,69 @@ window.__ModuleLoader__.load({
68
74
  console.warn("[dsh-notify-ding] 请求宿主播音失败:" + String(error));
69
75
  });
70
76
  }
77
+ /** 关掉某会话的通知;没有或已关就当无操作。 */
78
+ function closeNotification(sessionId) {
79
+ const notification = liveNotifications.get(sessionId);
80
+ if (!notification) return;
81
+ liveNotifications.delete(sessionId);
82
+ try {
83
+ notification.close();
84
+ } catch (error) {
85
+ console.warn("[dsh-notify-ding] 关闭系统通知失败:" + String(error));
86
+ }
87
+ }
88
+ /** 关掉当前挂着的全部通知,插件卸载时收尾。 */
89
+ function closeAllNotifications() {
90
+ for (const sessionId of Array.from(liveNotifications.keys())) closeNotification(sessionId);
91
+ }
92
+ /** 聚焦窗口并切到目标会话;缺少入口或会话已不在列表时只聚焦。 */
93
+ function openSessionInUi(sessionId) {
94
+ try {
95
+ window.focus();
96
+ } catch (error) {
97
+ console.warn("[dsh-notify-ding] 聚焦窗口失败:" + String(error));
98
+ }
99
+ const open = sessionOpener;
100
+ const source = listSourceRef;
101
+ if (!open || !source) return;
102
+ try {
103
+ if (source.getSnapshot().byId[sessionId] === void 0) return;
104
+ open(sessionId);
105
+ } catch (error) {
106
+ console.warn("[dsh-notify-ding] 打开会话失败:" + String(error));
107
+ }
108
+ }
71
109
  /**
72
- * 弹一条浏览器系统通知。
110
+ * 弹一条浏览器系统通知,并挂在对应会话名下。
111
+ *
112
+ * 通知不设自动关闭时间,会一直挂在系统通知里,直到对应会话发生操作。
113
+ * 每个会话最多保留一条,新的会先关掉同会话的旧的。
114
+ * 点击通知会聚焦窗口并切到该会话,同时关掉这条通知。
73
115
  *
74
116
  * 浏览器未授权时不强行索要权限,而是记下需要补授权,等到用户下一次
75
117
  * 与页面交互时再请求——权限请求必须由用户手势触发。
76
118
  *
119
+ * @param sessionId 这条通知所属的会话。
77
120
  * @param title 通知标题。
78
121
  * @param body 通知正文。
79
122
  */
80
- function showSystemNotification(title, body) {
123
+ function showSystemNotification(sessionId, title, body) {
81
124
  if (typeof Notification === "undefined") return;
82
125
  const present = () => {
126
+ closeNotification(sessionId);
83
127
  try {
84
- const notification = new Notification(title, { body });
128
+ const notification = new Notification(title, {
129
+ body,
130
+ requireInteraction: true
131
+ });
85
132
  notification.onclick = () => {
86
- try {
87
- window.focus();
88
- } catch (error) {
89
- console.warn("[dsh-notify-ding] 聚焦窗口失败:" + String(error));
90
- }
91
- notification.close();
133
+ openSessionInUi(sessionId);
134
+ closeNotification(sessionId);
92
135
  };
93
- window.setTimeout(() => notification.close(), NOTIFICATION_TTL_MS);
136
+ notification.onclose = () => {
137
+ if (liveNotifications.get(sessionId) === notification) liveNotifications.delete(sessionId);
138
+ };
139
+ liveNotifications.set(sessionId, notification);
94
140
  } catch (error) {
95
141
  console.warn("[dsh-notify-ding] 弹出系统通知失败:" + String(error));
96
142
  }
@@ -126,11 +172,12 @@ window.__ModuleLoader__.load({
126
172
  }
127
173
  /**
128
174
  * 发一次「叮咚」:弹系统通知并请宿主播音。
175
+ * @param sessionId 这条通知所属的会话。
129
176
  * @param title 通知标题。
130
177
  * @param body 通知正文。
131
178
  */
132
- function emit(title, body) {
133
- showSystemNotification(title, body);
179
+ function emit(sessionId, title, body) {
180
+ showSystemNotification(sessionId, title, body);
134
181
  requestHostDing();
135
182
  }
136
183
  /**
@@ -140,19 +187,21 @@ window.__ModuleLoader__.load({
140
187
  * 被节流压下的请求不会直接丢弃,而是挂到窗口边界的定时器上补发,
141
188
  * 这样连续两次状态变化仍然各响一次,只是间隔被拉开,不会出现漏报。
142
189
  *
190
+ * @param sessionId 这条通知所属的会话。
143
191
  * @param title 通知标题。
144
192
  * @param body 通知正文。
145
193
  */
146
- function ding(title, body) {
194
+ function ding(sessionId, title, body) {
147
195
  const now = Date.now();
148
196
  const since = now - Math.max(lastDingAt, readSharedLastDing());
149
197
  if (since < 0 || since >= MIN_INTERVAL_MS) {
150
198
  lastDingAt = now;
151
199
  writeSharedLastDing(now);
152
- emit(title, body);
200
+ emit(sessionId, title, body);
153
201
  return;
154
202
  }
155
203
  deferred = {
204
+ sessionId,
156
205
  title,
157
206
  body
158
207
  };
@@ -165,7 +214,7 @@ window.__ModuleLoader__.load({
165
214
  const stamp = Date.now();
166
215
  lastDingAt = stamp;
167
216
  writeSharedLastDing(stamp);
168
- emit(pending.title, pending.body);
217
+ emit(pending.sessionId, pending.title, pending.body);
169
218
  }, MIN_INTERVAL_MS - since);
170
219
  }
171
220
  /** 会话标题,缺失时退回会话 id,保证通知里总有个能认的名字。 */
@@ -177,10 +226,22 @@ window.__ModuleLoader__.load({
177
226
  * 用一帧会话列表快照做边沿检测:新出现的待回答交互响一次,运行态从真落到假响一次。
178
227
  *
179
228
  * 首帧只建基线不响,否则每次刷新页面都会把既有状态补报一轮。
229
+ * 同时识别「会话被操作」的信号,把对应会话还挂着的通知关掉:
230
+ * 运行态从假升到真说明已回到该会话开始新一轮,会话被选中,
231
+ * 以及会话从列表消失,都算一次操作。
232
+ *
233
+ * 选中变化先于响铃处理,否则同一帧里既跑完又刚被选中的会话,通知会建立后
234
+ * 立刻被关掉。current 短暂变空是 DSH 的 masked gap,选中会话暂时不在列表时
235
+ * 就会出现,不算一次操作,也不该把上一个有效选中抹掉。
180
236
  *
181
237
  * @param state 当前会话列表快照。
238
+ * @param baseline 是否首帧,首帧只建基线。
182
239
  */
183
240
  function scanSessions(state, baseline) {
241
+ if (state.current !== void 0) {
242
+ if (!baseline && state.current !== lastCurrent) closeNotification(state.current);
243
+ lastCurrent = state.current;
244
+ }
184
245
  const seen = /* @__PURE__ */ new Set();
185
246
  for (const id of Object.keys(state.byId)) {
186
247
  const summary = state.byId[id];
@@ -194,27 +255,41 @@ window.__ModuleLoader__.load({
194
255
  };
195
256
  tracks.set(id, track);
196
257
  }
197
- if (!baseline && track.running && !summary.running) ding("DSH 完成了一轮", titleOf(summary, id) + " 已空闲");
258
+ if (!baseline) {
259
+ if (track.running && !summary.running) ding(id, "DSH 完成了一轮", titleOf(summary, id) + " 已空闲");
260
+ if (!track.running && summary.running) closeNotification(id);
261
+ }
198
262
  track.running = summary.running;
199
263
  }
200
- for (const id of Array.from(tracks.keys())) if (!seen.has(id)) tracks.delete(id);
264
+ for (const id of Array.from(tracks.keys())) if (!seen.has(id)) {
265
+ closeNotification(id);
266
+ tracks.delete(id);
267
+ }
201
268
  }
202
269
  /**
203
270
  * 用一帧待回答交互快照做边沿检测:某会话冒出新的 key 时响一次。
204
271
  *
205
- * 消失的 key 只清理记录,不触发任何声音。
272
+ * 先处理「旧 key 失效」:被解决或被新 key 顶掉时关掉该会话的通知;
273
+ * 再处理「新 key 出现」并叮咚,因此替换场景下新通知不会被误关。
206
274
  *
207
275
  * @param pending 当前按会话索引的待回答交互快照。
208
276
  * @param state 与本次快照同时读到的会话列表,用来取标题。
277
+ * @param baseline 是否首帧,首帧只建基线。
209
278
  */
210
279
  function scanPending(pending, state, baseline) {
280
+ if (!baseline) for (const [id, track] of tracks) {
281
+ if (track.pendingKeys.size === 0) continue;
282
+ const currentKey = pending.get(id)?.key;
283
+ if (currentKey !== void 0 && track.pendingKeys.has(currentKey)) continue;
284
+ closeNotification(id);
285
+ }
211
286
  for (const [id, interaction] of pending) {
212
287
  const track = tracks.get(id);
213
288
  if (!track) continue;
214
289
  if (track.pendingKeys.has(interaction.key)) continue;
215
290
  track.pendingKeys.add(interaction.key);
216
291
  if (baseline) continue;
217
- ding("DSH 需要你回答", titleOf(state.byId[id], id) + " 正在等你");
292
+ ding(id, "DSH 需要你回答", titleOf(state.byId[id], id) + " 正在等你");
218
293
  }
219
294
  for (const [id, track] of tracks) {
220
295
  if (track.pendingKeys.size === 0) continue;
@@ -235,6 +310,9 @@ window.__ModuleLoader__.load({
235
310
  if (typeof Notification !== "undefined" && Notification.permission === "default") armPermissionGesture();
236
311
  const pendingSource = ctx.uiSession.pendingInteractions;
237
312
  const listSource = ctx.sessions.list;
313
+ const open = ctx.sessions.open;
314
+ sessionOpener = typeof open === "function" ? open.bind(ctx.sessions) : void 0;
315
+ listSourceRef = listSource;
238
316
  /** 是否已经用首帧快照建立过基线;首帧只记录不发声。 */
239
317
  let initialized = false;
240
318
  /** 读两个源并各扫一遍,保证两处看到的是同一帧会话列表。 */
@@ -266,6 +344,10 @@ window.__ModuleLoader__.load({
266
344
  deferredTimer = 0;
267
345
  }
268
346
  deferred = null;
347
+ closeAllNotifications();
348
+ sessionOpener = void 0;
349
+ listSourceRef = void 0;
350
+ lastCurrent = void 0;
269
351
  tracks.clear();
270
352
  });
271
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-ding",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "在 DSH 需要人工回答或一轮对话跑完时,弹出系统通知并让宿主播放 Windows 内置提示音。标准可安装的 dsh 客户端插件。",
5
5
  "keywords": [
6
6
  "deepseek-harness",