dsh-notify-me 1.11.0 → 1.12.0

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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.12.0] — 2026-10-21
6
+
7
+ ### 新增
8
+
9
+ - **人走开但窗口没走也算离开**(来自 [#16](https://github.com/chromoany/dsh-notify-me/pull/16),[@shrekcg](https://github.com/shrekcg)):`idleMinutes`(默认 3 分钟,`0` 关掉)内没有任何键鼠输入,就把「窗口在前台」按离开处理——「需要你」照常提醒、「回复完成」按离开规则提醒、`autoDismiss: "present"` 也不再撤掉那条通知。此前只认窗口失焦 / 页面隐藏,人坐在桌前离开座位时一切都还是「你在看」。
10
+ - **闲置到期会补发被压下的审批**(维护者补的):`currentHiddenOnly` 把当前对话的等待只留标题标记时,原先只有失焦 / 页面隐藏事件才会补发;现在闲置定时器到期即补发,人离开但窗口没动也能收到。一次性触发,队列清空后不重复。
11
+ - **「回复完成」提醒时机三选一**(#16):`donePolicy` —— `"background"`(默认,旧行为)/ `"smart"`(屏幕上那条静默,别的对话完成照常提醒)/ `"always"`(从不静默),设置页「什么时候提醒」里换成下拉。
12
+ - **通知自动消失三选一**(#16):`autoDismiss` —— `"present"`(默认,只有你在页面时才计时关闭,离开时收到的留在通知中心)/ `"always"`(旧行为)/ `"never"`(普通通知等你点掉)。带「同意 / 拒绝」的审批通知仍按 12 秒走。
13
+
14
+ ### 移除
15
+
16
+ - **页内审批卡片整条删掉**:1.11.0 删掉「浮层通道」后,桌面端还留着一张兜底卡片(`dsh-app://` 注册不了 Service Worker,审批按钮画在窗口角落)。结果是审批提醒**同时**弹系统通知和页内卡片——与「只留通知」的目标冲突,而且卡片只在窗口前台时看得见,需要提醒的时刻恰恰不在。现在 `drawOverlayCard` / `showFallbackApprovalCard` / `.dnm-ov-*` 样式 / 「审批卡片位置」设置子行 / `overlayCorner` 配置键 / `debug().card` 全部删除。桌面端的审批提醒就是普通系统通知,点通知回到对话里裁决。
17
+ - 老配置里的 `overlayCorner` 在下次读配置时删掉,其余设置不受影响。
18
+ - `window.__dshNotifyMe.debug()` 去掉 `card`;`doneHiddenOnly` 换成 `donePolicy` / `idleMinutes` / `autoDismiss` / `away`(`doneHiddenOnly` 作为读写别名保留,旧脚本仍能写)。
19
+
20
+ ### 迁移
21
+
22
+ - 旧配置 `doneHiddenOnly: false` → `donePolicy: "always"`,其余 → `"background"`;写回时两者仍互相同步。
23
+ - `idleMinutes` 默认 3;写 `0` 退回只看窗口失焦 / 页面隐藏。
24
+
25
+ ### 测试
26
+
27
+ - 冒烟删掉 `cardLayer` / `desktopFallbackCard`,新增 `awayWhileFocusedAndIdle`、`idleFlushesQuieted`、`donePolicyScopes`、`autoDismissPolicy`;设置页用例改为断言「审批卡片位置」子行与旧通道选项都已消失、三档下拉落盘正确、旧配置仍能加载。
28
+
29
+ ### 文档
30
+
31
+ - README 中英:设置清单换成三档下拉、补闲置判定与通知自收、删「副屏与审批卡片」整节、控制台示例同步、致谢加上 [@shrekcg](https://github.com/shrekcg) 与 #16。
32
+
5
33
  ## [1.11.0] — 2026-10-21
6
34
 
7
35
  ### 移除
package/README.en.md CHANGED
@@ -24,11 +24,11 @@
24
24
  A notification plugin for DSH Web / Desktop, so you can keep full access off without watching the page all day.
25
25
 
26
26
  - **When input is needed:** approval, plan review, and questions trigger notifications, sound, and a title marker.
27
- - **When a background reply finishes:** return to the result; click the notification to open its conversation.
27
+ - **When a background reply finishes:** return to the result; click the notification to open its conversation. Pick "background only", "quiet for the open one", or "always".
28
+ - **When you walk off but the window stays in front:** over 3 minutes without input counts as away, and the alert still fires; a toast you have not seen is not closed on a timer before you return.
28
29
  - **When you leave your computer:** optionally forward alerts to Discord, Bark, Gotify, or your own webhook.
29
- - **When you do not watch the primary display:** system notifications only ever appear on the primary display; the Desktop approval card is drawn in the page and follows the DSH window onto whichever screen it is on (see [Secondary displays and the approval card](#secondary-displays-and-the-approval-card)).
30
30
 
31
- > Keep the page or desktop app running; backgrounding / minimizing is fine. System notifications require permission. Approval buttons need a Service Worker: Web (`http://127.0.0.1` or `https`) has one, the Desktop `dsh-app://` pages cannot register one, so their notifications carry no buttons and approvals are decided on a card in the window corner instead.
31
+ > Keep the page or desktop app running; backgrounding / minimizing is fine. System notifications require permission. Approval buttons need a Service Worker: Web (`http://127.0.0.1` or `https`) has one, the Desktop `dsh-app://` pages cannot register one, so their notifications carry no buttons — click the notification to decide in the conversation.
32
32
 
33
33
  > 🔎 Looking for it? Search `dsh-notify-me`, **message alerts**, **notification**, **desktop notification**, **reminder**, **approval alert**, **external notifications**, **Discord / Bark / Gotify push** or **可操作提醒 / 消息提醒 / 桌面通知 / 需要你操作** (npm keywords include both English and Chinese terms).
34
34
 
@@ -43,7 +43,7 @@ A notification plugin for DSH Web / Desktop, so you can keep full access off wit
43
43
 
44
44
  ![The Notify me settings page: header, how you get told, when to alert, try it, and the collapsed Advanced block](https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/screenshots/settings-en.png)
45
45
 
46
- Just the plugin panel (the real component, captured before 1.11.0) without the DSH navigation sidebar. Row explanations live in a hover tooltip that appears when the pointer rests on a title, which is why only the titles show here. 1.11.0 removed the "alert type" row at the top; the rest is unchanged.
46
+ Just the plugin panel (the real component, on the same dark theme as the host) without the DSH navigation sidebar. Row explanations live in a hover tooltip that appears when the pointer rests on a title, which is why only the titles show here.
47
47
  </details>
48
48
 
49
49
  ## Installation
@@ -73,14 +73,14 @@ The Electron application manages the `desktop` profile. Do not change the Web co
73
73
  2. Open **Settings → Notify me**. Confirm that the section and plugin version at its foot appear, and leave the master switch and sound enabled.
74
74
  3. Click **Test "reply finished"** on the settings page. A test notification should appear and sound according to your cue settings. This local test does not forward to external endpoints and ignores the "while the window is in front" toggle.
75
75
 
76
- Web users can also test approval buttons. On Desktop the shell cannot register a Service Worker, so this test draws an in-page card with Approve / Reject in the window corner (since 1.10.4; earlier versions sent a plain notification).
76
+ Web users can also test approval buttons. On Desktop the shell cannot register a Service Worker, so this test sends a plain notification without buttons — click it to open the conversation.
77
77
 
78
78
  **Optional console check** — in environments with DevTools, you can also run:
79
79
 
80
80
  ```js
81
81
  window.__dshNotifyMe.test("done") // "reply finished" sample
82
82
  window.__dshNotifyMe.test("attention") // "needs your input" sample
83
- window.__dshNotifyMe.test("approval") // Approve / Reject with an active Web bridge; an in-page card on Desktop
83
+ window.__dshNotifyMe.test("approval") // Approve / Reject with an active Web bridge; a plain notification on Desktop
84
84
  window.__dshNotifyMe.debug() // the bridge field reports the button channel: "active" = buttons work
85
85
  ```
86
86
 
@@ -102,11 +102,11 @@ dsh-notify-me decouples **supervision** from **sitting in front of the screen**:
102
102
  | Event | Default behavior |
103
103
  | --- | --- |
104
104
  | 🔔 Input needed | Alerts even in the foreground; the conversation on screen keeps only a title marker |
105
- | ✅ Reply finished | Alerts while the page is hidden / backgrounded |
105
+ | ✅ Reply finished | Alerts while the page is hidden / backgrounded (or "quiet for the open one" / "always") |
106
106
 
107
- **Input needed** covers approval requests, plan review, and questions (`ask_user_question`). Approvals carry Approve / Reject: on Web as a system notification with buttons, on Desktop (which cannot register a Service Worker) as a card drawn in the window corner.
107
+ **Input needed** covers approval requests, plan review, and questions (`ask_user_question`). The Approve / Reject buttons only exist on Web (they need a Service Worker); the Desktop cannot register one, so its notifications carry no buttons — click the notification to decide in the conversation.
108
108
 
109
- **Reply finished** also covers background sessions. A finished but unread session keeps the `✅ Reply finished ·` title marker until opened. Title markers are independent of the "while the window is in front" toggles.
109
+ **Reply finished** also covers background sessions. A finished but unread session keeps the `✅ Reply finished ·` title marker until opened. Title markers are independent of the alert policy.
110
110
 
111
111
  > **Background** subagent child sessions are **not** alerted about by default: DSH lists each of them as its own session, but they are steps inside the parent conversation's turn, so an alert for them is only noise. The subagent session you have open is not part of this and alerts as usual. Settings → "Ignore subagent sessions" turns the mute off.
112
112
 
@@ -118,26 +118,14 @@ Open **Settings → Notify me**. The page runs as how you get told, when to aler
118
118
 
119
119
  - **Enable reminders**: master switch — off means no toasts, no sounds, and the tab title is left alone;
120
120
  - **Sound / Volume**: the switch is direct; the slider folds into that row — click the title (▸ turns into ▾) to reveal it. What it plays comes from Advanced → Alert sound;
121
- - **Decide approvals from the toast** (on by default): approvals carry Approve / Reject that settle the request in place. On Web that is a system notification with buttons; on Desktop `dsh-app://` cannot register a Service Worker, so the buttons are drawn on a card in the window corner (auto-dismissed after 12 s, one card per approval). Its corner comes from the Corner sub-row underneath — see [Secondary displays and the approval card](#secondary-displays-and-the-approval-card).
122
-
123
- ### Secondary displays and the approval card
124
-
125
- Windows draws system notification banners on the primary display only. There is no setting in Windows and no API in the browser to aim one at another monitor, so with DSH parked on a second screen and the primary display unwatched, a toast is as good as not sent. That part is out of reach — the only thing that can show up on the other screen comes from inside the page.
126
-
127
- One thing does: the Desktop approval card. It is drawn in the DSH page, so it appears on whichever display the window sits on, and it carries Approve / Reject (Desktop toasts have no buttons).
128
-
129
- - **Position** — the Corner sub-row under "Decide approvals from the toast" shows the current value (bottom right by default); click it open (▸ turns into ▾), then pick top right / bottom right / top left / bottom left. Changes apply immediately;
130
- - **Approvals only** — plain "needs you" and "reply finished" alerts never draw a card; they are system notifications only;
131
- - **Lifetime** — an approval card retires after 12 s; clicking the card body opens its conversation. At most 4 cards stack at once;
132
- - **No notification permission needed** — this path touches no Service Worker and needs no permission.
133
-
134
- > 1.11.0 removed the "alert type" choice. The in-page overlay used to work as a standalone channel, but it is only visible while the DSH window is in front — and the moment an alert matters is exactly when the window is in the background. With the overlay chosen, backgrounding left only the sound and the tab marker, which is no alert at all.
121
+ - **Decide approvals from the toast** (on by default): approvals carry Approve / Reject that settle the request in place. On Web that is a system notification with buttons; on Desktop `dsh-app://` cannot register a Service Worker, so the notification carries no buttons — click it to decide in the conversation. The line underneath states whether buttons are possible here.
135
122
 
136
123
  ### When to alert
137
124
 
138
- - **Also alert "needs you" while the window is in front** (on): turn it off to hear only once the window goes to the background — another app focused, the window covered, or minimized all count;
139
- - **Stay quiet for the conversation on screen** (on): a wait belonging to the conversation you are looking at keeps only the tab marker so the approval card stays readable; moving the window to the background restores alerts;
140
- - **Also alert "reply finished" while the window is in front** (off): by default only background finishes alert (a covered Desktop window counts as background since 1.10.5, not only a minimized one);
125
+ - **Also alert "needs you" while the window is in front** (on): turn it off to hear only once the window goes to the background — another app focused, the window covered, or minimized all count. A window left in front while nobody touches it (3 minutes without input by default) also counts as background; `idleMinutes: 0` restores the window-only rule;
126
+ - **Stay quiet for the conversation on screen** (on): a wait belonging to the conversation you are looking at keeps only the tab marker, so it does not interrupt what you are doing; moving the window to the background or going idle restores alerts;
127
+ - **When "reply finished" alerts** (default "background only"): three choices. "Background only" is the old rule — alert when you are not watching the page; "quiet for the open one" stays silent only for the conversation on screen while any other conversation still alerts (useful with several open); "always" never suppresses. An old profile's "also alert reply finished while in front" switch migrates to this setting;
128
+ - **Dismiss the toast automatically** (default "keep while away"): three choices. "Keep while away" only closes a toast a few seconds later while you are at the page, so one that arrived while you were away is still in the notification centre when you come back; "always close" is the old tidy-up; "never close" leaves plain toasts up until you dismiss one. Approvals keep their 12 s rule and are likewise not withdrawn while you are away;
141
129
  - **Ignore subagent sessions** (on): skip background sessions spawned by a subagent — the parent conversation covers them; the one you have open still alerts;
142
130
  - **Click a toast to open that conversation** (on): the click switches to the conversation that raised it; in a browser it also brings the window forward, while on the Desktop shell it is flaky — sometimes the window comes forward, sometimes only the conversation switches (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)); with several tabs open only one is raised, and off means a click does nothing.
143
131
 
@@ -193,7 +181,6 @@ The last block inside Advanced. The page protocol is detected automatically to t
193
181
  ## How it alerts
194
182
 
195
183
  - **System notification** — a toast through the browser into the Windows action center / macOS Notification Center (clicking it opens the conversation it came from; supported Web environments offer approval buttons, Desktop does not)
196
- - **Desktop approval card** — `dsh-app://` cannot put Approve / Reject on a toast, so approval alerts also drop a clickable card in the window corner; see [Secondary displays and the approval card](#secondary-displays-and-the-approval-card)
197
184
  - **Sound** — WebAudio beeps by default (distinct patterns for "needs you" vs "done"); each kind can instead play a picked audio file, or hand the sound over to the operating system's own notification sound
198
185
  - **Tab title marker** — while something is waiting on you, the tab title is prefixed with `🔔 Action needed · …` / `🔔 需要你 · …`; while a background session is **finished but unread**, with `✅ Reply finished · …` / `✅ 回复完成 · …`, gone once you open that session (or it starts running again). Both kinds can be up together, "needs you" first; a host title change is rebased under the markers
199
186
  - **External notifications** (optional, default off) — the same alert is also forwarded to the push targets you configure: Discord Webhook / Bark / Gotify / custom webhook — so the push channel on your phone picks it up while you step away from the machine
@@ -225,7 +212,7 @@ Two traps are worth remembering: a package listed in `dsh.client.inject` that a
225
212
 
226
213
  </details>
227
214
 
228
- `1.9.0` neither widens nor narrows that range: the settings page is plain React rendering over the existing CSS variables (every colour carries a fallback) and adds no host API. The overlay, title markers and window raising still go through the 1.7 / 1.8 paths, so **plugin entries stay compatible from 0.1.1-rc.2 through 0.2.1-rc.2**, and stepping back to an older host needs no config change. The only new surface is visual: the bell in the page header is the plugin's own SVG (`docs/brand/`).
215
+ `1.9.0` neither widens nor narrows that range: the settings page is plain React rendering over the existing CSS variables (every colour carries a fallback) and adds no host API. The alerts, title markers and window raising still go through the 1.7 / 1.8 paths, so **plugin entries stay compatible from 0.1.1-rc.2 through 0.2.1-rc.2**, and stepping back to an older host needs no config change. The only new surface is visual: the bell in the page header is the plugin's own SVG (`docs/brand/`).
229
216
 
230
217
  ## Advanced: console configuration
231
218
 
@@ -240,17 +227,17 @@ window.__dshNotifyMe.setConfig({
240
227
  enabled: true, // false = disable all reminders (master switch)
241
228
  language: "auto", // 'auto' follow the UI | 'zh' | 'en'
242
229
  attentionHiddenOnly: false, // true = don't alert "needs you" while the page is visible
243
- currentHiddenOnly: true, // true = waits in the conversation on screen keep only the tab marker (replayed once backgrounded)
244
- doneHiddenOnly: true, // false = also alert "reply finished" while visible
230
+ currentHiddenOnly: true, // true = waits in the conversation on screen keep only the tab marker (replayed once away)
231
+ donePolicy: "background", // "reply finished" policy: 'background' only when away | 'smart' quiet only for the open one | 'always' always alert
232
+ idleMinutes: 3, // minutes without pointer/keyboard input before a focused page counts as away (0 = off)
233
+ autoDismiss: "present", // toast self-dismiss: 'present' keep while away | 'always' always close | 'never' never close
245
234
  ignoreSubagent: true, // true = never alert for background subagent sessions (the open one is exempt)
246
- quickActions: true, // Approve / Reject on approval alerts
235
+ quickActions: true, // Approve / Reject buttons on approval toasts
247
236
  sound: true, // cue toggle (also governs the "system notification sound" mode)
248
237
  soundDone: "synth", // "reply finished" cue source: 'synth' built-in | 'custom' picked file | 'system' OS sound
249
238
  soundAttention: "synth", // "needs you" cue source, same values
250
239
  volume: 0.5, // 0..1
251
240
  autoFocus: true, // clicking a toast opens the conversation it came from
252
- quickActions: true, // Approve / Reject buttons on approval toasts
253
- overlayCorner: "bottom-right", // Desktop approval card anchor: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
254
241
  externalEnabled: false, // external-notification forwarding master switch (default off: no endpoint, nothing is sent)
255
242
  externalAttention: true, // forward "needs you" (exactly the same trigger rules as local alerts)
256
243
  externalDone: true, // forward "reply finished"
@@ -287,7 +274,7 @@ The reminder core stays dependency-free and self-contained: notification copy re
287
274
 
288
275
  ### Quick decisions, and how they reach the agent
289
276
 
290
- Action buttons (`actions`) belong to persistent notifications shown through `ServiceWorkerRegistration.showNotification()`; passing `actions` to the `new Notification()` constructor throws a `TypeError`. That is a browser rule no plugin can route around, so the bundle holds two jobs in the same bytes: the page half registers the reminder factory as it always did, and the very same file is registered as a Service Worker whose only task is relaying which button was clicked back to the page. The decision itself happens in the page, through the same `PendingApproval.answer('allowed-once' | 'rejected')` the approval card uses — which is why "Approve" is exactly "Allow once" and nothing broader. The "raise a window and switch its conversation" part of a body click is scheduled on the worker half too: `client.focus()` raises exactly one window (a visible one first) and routes the conversation switch to that window alone, so several open DSH tabs never fight over focus or all switch at once.
277
+ Action buttons (`actions`) belong to persistent notifications shown through `ServiceWorkerRegistration.showNotification()`; passing `actions` to the `new Notification()` constructor throws a `TypeError`. That is a browser rule no plugin can route around, so the bundle holds two jobs in the same bytes: the page half registers the reminder factory as it always did, and the very same file is registered as a Service Worker whose only task is relaying which button was clicked back to the page. The decision itself happens in the page, through the same `PendingApproval.answer('allowed-once' | 'rejected')` the conversation's approval prompt uses — which is why "Approve" is exactly "Allow once" and nothing broader. The "raise a window and switch its conversation" part of a body click is scheduled on the worker half too: `client.focus()` raises exactly one window (a visible one first) and routes the conversation switch to that window alone, so several open DSH tabs never fight over focus or all switch at once.
291
278
 
292
279
  Every button toast is booked against its interaction key, so it can only decide the request it was raised for. Once the page answers that request or a newer one replaces it, clicking the leftover buttons decides nothing and just closes the notification. Button toasts stay 12s (plain ones 5s) and retire on their own, so an approval left undecided there still waits for you in the page.
293
280
 
@@ -312,7 +299,7 @@ There is no page-side fallback either: the shell preload only exposes `dshDeskto
312
299
  - The first sound/toast of each page load needs one user click on the page first (browser autoplay + permission policy).
313
300
  - Toasts are only produced once the notification permission is granted; declining means sound + title marker only.
314
301
  - On the Desktop shell (DSH Desktop 0.2.1-rc.2 / Windows) a toast click does not reliably bring the window forward: sometimes it does, sometimes only the conversation switches. The plugin's `dsh://open` deep link on its own failed 3/3 in isolation (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)), so when it does come forward it is most likely the OS/Chromium activating the app on the notification click itself ([discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446)).
315
- - System notification banners are drawn on the primary display only: Windows has no setting and Chromium no API to aim one at another monitor, so on a multi-monitor setup the second screen never receives them. The one thing that does reach another display is the Desktop approval card, drawn in the page and carried by the window; for plain alerts across screens, forward them to your phone with external notifications.
302
+ - System notification banners are drawn on the primary display only: Windows has no setting and Chromium no API to aim one at another monitor, so on a multi-monitor setup the second screen never receives them. Forwarding to your phone with external notifications is the only route across screens (since 1.12.0 the page no longer draws an approval card).
316
303
  - Quick-decision buttons need a Service Worker: they work on `http://127.0.0.1` and `https`, not on plain `http://192.168.x.x`-style LAN addresses, and some browsers or desktop hosts never render notification buttons. **The Desktop (`dsh-app://`) is tested and cannot register a Service Worker**, so buttons never render there and approval toasts fall back to their buttonless form. The `bridge` field of `window.__dshNotifyMe.debug()` says why; everything else keeps working.
317
304
  - macOS: in the browser, alerts, sounds, the title marker and toast-click navigation carry no OS assumptions and work as on Windows. Two caveats: DSH Desktop (Electron) notifications require the app to be code-signed — unsigned builds simply emit no notification — and whether the official macOS build satisfies that is untested; and notification buttons on the macOS desktop host are likewise untested (the Windows desktop host is tested and renders none, see the line above), falling back to the buttonless form above.
318
305
  - Config lives in browser `localStorage`: switching browsers/devices or clearing site data returns to defaults (one-click restore in the Settings page).
@@ -328,7 +315,7 @@ There is no page-side fallback either: the shell preload only exposes `dshDeskto
328
315
  ```powershell
329
316
  node --check lib\client.js
330
317
  node --check lib\index.js
331
- node smoke\smoke-test.cjs # offline state-machine test: three host generations + master switch + zh/en cases + the quick-decision bridge (worker half included)
318
+ node smoke\smoke-test.cjs # offline state-machine test: three host generations + master switch + zh/en cases + the quick-decision bridge (worker half included) + idle detection and toast self-dismiss
332
319
  node smoke\cordis-host-test.mjs # real-cordis end-to-end (skips when no local DSH installation is found)
333
320
  npm pack --dry-run # preview the published tarball
334
321
  ```
@@ -345,6 +332,7 @@ Contributors (by handle; the link points at the PR / issue they worked on):
345
332
  - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10): the per-kind alert sound source (built-in synth / picked audio file / system notification sound, chosen independently for "needs you" and "reply done"), with its settings UI, fallback chain, tests and docs; the two refinements — a "system" preview sending a real test notification instead of doing nothing, and the fake React holding hook state by index across renders — come from the same PR.
346
333
  - [@yanzhaohui1999](https://github.com/yanzhaohui1999) — [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14): the 482-session idle-page CPU report (pinning `currentSessionId()` rescanning the whole table per row, 5.9%~6.5% of CPU) and the fix (derive the on-screen session once per list change), with a red-then-green regression assertion — a 4-row and a 40-row list change must cost the same (upstream reported 7 vs 43, 5 vs 5 after).
347
334
  - [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi) — [#12](https://github.com/chromoany/dsh-notify-me/issues/12): the external-notification forwarding suggestion (Discord Webhook / Bark / Gotify / custom webhook) and its use case (1.7.0).
335
+ - [@shrekcg](https://github.com/shrekcg) — [#16](https://github.com/chromoany/dsh-notify-me/pull/16): counting "in front but walked away" as away (`idleMinutes`, 3 minutes by default), deciding "reply finished" alerts by which conversation finished (`donePolicy`, three choices), the three-way toast self-dismiss policy (`autoDismiss`), with matching smoke cases and old-config migration; when it landed in 1.12.0 the maintainer added the idle-expiry replay for suppressed approvals, removed the leftover in-page approval card, and brought docs and version up to date.
348
336
 
349
337
  Attribution policy: external PRs are landed as maintainer commits — the original commit is not reused and no Co-authored-by trailer is added, so GitHub's contributors graph lists the maintainer only and contributors are credited above.
350
338
 
package/README.md CHANGED
@@ -24,11 +24,11 @@
24
24
  DSH Web / Desktop 的通知插件,让你敢不开完整访问,也不必一直守着页面。
25
25
 
26
26
  - **需要你操作时**:审批、方案确认、提问,用通知、提示音和标题标记提醒。
27
- - **后台回复完成时**:及时回来查看结果,点击通知可回到对应对话。
27
+ - **后台回复完成时**:及时回来查看结果,点击通知可回到对应对话;提醒时机可选「仅后台 / 仅当前对话静默 / 总是提醒」。
28
+ - **人走开但窗口没走**:窗口留在最前台、人却离开座位,超过 3 分钟没有输入也算离开,该提醒的照常提醒;收到的那条通知不会在你回来之前自己消失。
28
29
  - **离开电脑时**:可选转发到 Discord、Bark、Gotify 或自己的 Webhook。
29
- - **不看主屏时**:系统通知只会画在主屏,副屏看不到;桌面端的审批卡片画在页面里,跟着 DSH 窗口走到哪块屏(见[副屏与审批卡片](#副屏与审批卡片))。
30
30
 
31
- > 页面或桌面应用需保持运行,后台 / 最小化可以。系统通知需要通知权限。审批按钮靠 Service Worker 才能进通知:Web 端(`http://127.0.0.1` 或 `https`)有,桌面端 `dsh-app://` 页面注册不了,通知上没有按钮,审批改在窗口角落的卡片上裁决。
31
+ > 页面或桌面应用需保持运行,后台 / 最小化可以。系统通知需要通知权限。审批按钮靠 Service Worker 才能进通知:Web 端(`http://127.0.0.1` 或 `https`)有,桌面端 `dsh-app://` 页面注册不了,通知上没有按钮,点通知回到对话里裁决。
32
32
 
33
33
  > 🔎 想找这个插件?可以搜:`dsh-notify-me`、**消息提醒**、**可操作提醒**、**桌面通知**、**需要你操作**、**回复完成提醒**、**后台完成提醒**、**外部通知**、**Discord / Bark / Gotify 推送**(npm 关键词已含以上中英文词)。
34
34
 
@@ -43,7 +43,7 @@ DSH Web / Desktop 的通知插件,让你敢不开完整访问,也不必一
43
43
 
44
44
  ![通知提醒设置页:页眉、提醒方式、什么时候提醒、试一试与折叠的高级设置](https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/screenshots/settings-zh.png)
45
45
 
46
- 只截插件自己的面板(真实组件,1.11.0 之前的截图),不带 DSH 左侧导航栏;每行说明在鼠标停在标题文字上时才出现,所以这里只看到标题。1.11.0 删掉了最上面的「通知类型」一行,其余行没变。
46
+ 只截插件自己的面板(真实组件,与宿主同款深色主题),不带 DSH 左侧导航栏;每行说明在鼠标停在标题文字上时才出现,所以这里只看到标题。
47
47
  </details>
48
48
 
49
49
  ## 安装
@@ -98,11 +98,11 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
98
98
  | 事件 | 默认行为 |
99
99
  | --- | --- |
100
100
  | 🔔 需要你操作 | 页面可见也提醒;当前对话只留标题标记 |
101
- | ✅ 回复完成 | 页面隐藏 / 后台时提醒 |
101
+ | ✅ 回复完成 | 页面隐藏 / 后台时提醒(可选「仅当前对话静默」或「总是提醒」) |
102
102
 
103
- **需要你操作**包括审批请求、方案待确认(plan review)和提问(`ask_user_question`)。审批带「同意 / 拒绝」:Web 走带按钮的系统通知,桌面端(注册不了 Service Worker)改画在窗口角落的卡片上。
103
+ **需要你操作**包括审批请求、方案待确认(plan review)和提问(`ask_user_question`)。审批带「同意 / 拒绝」的按钮只在 Web 端有(需要 Service Worker);桌面端注册不了,通知上没有按钮,点通知回到对话里裁决。
104
104
 
105
- **回复完成**也覆盖后台会话;完成但还没看时,标题带 `✅ 回复完成 ·` 标记,打开对应会话即撤。标题标记不受「窗口在前台时也提醒」开关限制。
105
+ **回复完成**也覆盖后台会话;完成但还没看时,标题带 `✅ 回复完成 ·` 标记,打开对应会话即撤。标题标记不受提醒策略限制。
106
106
 
107
107
  > **后台**子代理(subagent)子会话的等待与完成默认**不提醒**:DSH 把每个子代理子会话列成独立会话,但它们是所属主对话那一轮里的步骤,提醒只会是噪音。你当前打开的那个子代理会话不算在内,照常提醒。设置页「子代理会话不提醒」可关掉这个静音。
108
108
 
@@ -114,26 +114,14 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
114
114
 
115
115
  - **启用提醒** 总开关:关闭后不弹通知、不响提示音,也不改标签页标题;
116
116
  - **提示音 / 音量**:开关直接切;音量滑块收在该行里,点一下标题(▸ 变成 ▾)展开。具体播什么由「高级设置 → 音效来源」决定;
117
- - **审批通知上直接裁决**(默认开):审批带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。Web 走带按钮的系统通知;桌面端 `dsh-app://` 注册不了 Service Worker,于是把按钮画在窗口角落的卡片上(12 秒自动收起,同一条审批不叠第二张卡)。卡片位置由它下面的子行「审批卡片位置」控制,见[副屏与审批卡片](#副屏与审批卡片)。
118
-
119
- ### 副屏与审批卡片
120
-
121
- Windows 的系统通知横幅固定画在主屏上,系统没有设置项、浏览器也没有 API 能把它挪到别的显示器。主屏不看、DSH 停在副屏时,通知就等于没发。这一条改不掉——真要在副屏看见,只能靠页面里的东西。
122
-
123
- 页面里还剩一种:桌面端的审批卡片。它画在 DSH 页面里,窗口在哪块屏就出现在哪块屏,卡片上能直接「同意 / 拒绝」(桌面端通知带不了按钮)。
124
-
125
- - **位置**:「审批通知上直接裁决」下面的「审批卡片位置」子行,行上直接显示当前值(默认右下);点它(▸ 变 ▾)再选右上 / 右下 / 左上 / 左下,改完立刻生效;
126
- - **只给审批**:普通提醒与「回复完成」不画卡片,它们只有系统通知;
127
- - **停留时间**:审批卡片 12 秒自动收起;点卡片正文切回对应对话。最多同时叠 4 张;
128
- - **不用通知权限**:这条路径不碰 Service Worker,也不依赖通知权限。
129
-
130
- > 1.11.0 删掉了「通知类型」二选一。页面浮层曾经能当独立通道用,但它只在 DSH 窗口处于前台时看得见——需要提醒的时刻恰恰是窗口在后台。选浮层、切走之后后台只剩提示音和标题标记,等于没提醒。
117
+ - **审批通知上直接裁决**(默认开):审批带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。Web 走带按钮的系统通知;桌面端 `dsh-app://` 注册不了 Service Worker,通知上没有按钮,点通知回到对话里裁决。设置页那行下面会如实说明当前环境能不能给按钮。
131
118
 
132
119
  ### 什么时候提醒
133
120
 
134
- - **窗口在前台时也提醒「需要你」**(默认开):窗口在前台也提醒;关掉后只有窗口转到后台才提醒——切到别的应用、被别的窗口遮住、最小化都算后台;
135
- - **当前对话不弹通知**(默认开):等待就发生在你正看着的对话里时只留标签页标记,免得挡住审批卡片;窗口转到后台后照常提醒,若你已处理掉就作废;
136
- - **窗口在前台时也提醒「回复完成」**(默认关):默认只在窗口转到后台时提醒,避免打扰(桌面端窗口被遮住也算后台,1.10.5 起不再要求最小化);
121
+ - **窗口在前台时也提醒「需要你」**(默认开):窗口在前台也提醒;关掉后只有窗口转到后台才提醒——切到别的应用、被别的窗口遮住、最小化都算后台。窗口一直留在前台但人离开座位(默认 3 分钟没有任何键鼠输入)同样算后台;`idleMinutes: 0` 退回只看窗口;
122
+ - **当前对话不弹通知**(默认开):等待就发生在你正看着的对话里时只留标签页标记,免得打断眼前的操作;人离开(窗口转到后台或闲置超时)后照常提醒,若你已处理掉就作废;
123
+ - **「回复完成」什么时候提醒**(默认「仅后台」):三选一。「仅后台」=原来的规则,不在看页面才提醒;「仅当前对话静默」=屏幕上那条静默,别的对话完成照常提醒,多开对话时有用;「总是提醒」=从不静默。老配置里的「窗口在前台时也提醒『回复完成』」开关自动迁移到这一项;
124
+ - **通知自动消失**(默认「离开时保留」):三选一。「离开时保留」=只有你在看页面时才几秒后关闭,人不在时收到的那条留在通知中心等你回来;「总是关闭」=旧行为;「从不关闭」=普通通知一直留着,等你点掉。带「同意 / 拒绝」的审批通知仍按 12 秒走,同样不会在人不在时被撤掉;
137
125
  - **子代理会话不提醒**(默认开):跳过后台子代理(subagent)会话——它们是主对话那一轮的步骤,完成由主对话自己的提醒覆盖;你当前打开的那个子代理会话照常提醒;
138
126
  - **点击通知回到对应对话**(默认开):点通知切到提醒所属的对话;浏览器里同时把窗口带到前台;桌面端不稳定——有时窗口会上前,有时只切对话(见[桌面端怎么抬窗的](#桌面端怎么抬窗的));多个标签页同时开着时只调起一个(优先当前可见的那个);关掉后点通知不做任何事。
139
127
 
@@ -189,7 +177,6 @@ Windows 的系统通知横幅固定画在主屏上,系统没有设置项、浏
189
177
  ## 提醒渠道
190
178
 
191
179
  - **系统通知**:经浏览器弹出的系统通知,进 Windows 通知中心或 macOS 通知中心(点击回到提醒所属的对话;支持快捷裁决的 Web 环境可点审批按钮直接裁决,桌面端没有按钮)
192
- - **桌面端审批卡片**:`dsh-app://` 下系统通知带不了「同意 / 拒绝」,审批类提醒额外在窗口角落画一张可点卡片;见[副屏与审批卡片](#副屏与审批卡片)
193
180
  - **提示音**:默认是 WebAudio 合成音(「需要你」与「完成」使用不同音型);可在设置里为两类提醒分别换成自定义音频文件,或改成让操作系统播放它自己的通知音
194
181
  - **标签页标题标记**:有待处理事项时,标题前出现 `🔔 需要你 · …` / `🔔 Action needed · …`;后台会话**完成但还没看**时出现 `✅ 回复完成 · …` / `✅ Reply finished · …`,打开对应会话(或它再次开跑)即消失。两种标记可同时出现,「需要你」在前;宿主改写标题后标记基于新标题重建
195
182
  - **外部通知**(可选,默认关):按设置里配的推送目标,把同一条提醒转发到 Discord Webhook / Bark / Gotify / 自定义 Webhook——临时离开电脑时,手机上的推送渠道照样收到
@@ -221,7 +208,7 @@ Windows 的系统通知横幅固定画在主屏上,系统没有设置项、浏
221
208
 
222
209
  </details>
223
210
 
224
- `1.9.0` 的界面重排没有放宽也没有收紧这个范围:设置页是纯 React 渲染 + 既有 CSS 变量(每个色值都带 fallback),没有引入任何新的宿主 API;浮层、标题标记、抬窗仍走 1.7 / 1.8 那几条路径。**0.1.1-rc.2 到 0.2.1-rc.2 的插件条目照旧兼容**,换回旧宿主也不需要改配置。新增的只有界面元素——设置页页眉的铃铛是插件自己画的 SVG(`docs/brand/`),与宿主无关。
211
+ `1.9.0` 的界面重排没有放宽也没有收紧这个范围:设置页是纯 React 渲染 + 既有 CSS 变量(每个色值都带 fallback),没有引入任何新的宿主 API;提醒、标题标记、抬窗仍走 1.7 / 1.8 那几条路径。**0.1.1-rc.2 到 0.2.1-rc.2 的插件条目照旧兼容**,换回旧宿主也不需要改配置。新增的只有界面元素——设置页页眉的铃铛是插件自己画的 SVG(`docs/brand/`),与宿主无关。
225
212
 
226
213
  ## 控制台高级配置
227
214
 
@@ -236,8 +223,10 @@ window.__dshNotifyMe.setConfig({
236
223
  enabled: true, // false = 关闭所有提醒(主开关)
237
224
  language: "auto", // 'auto' 跟随界面 | 'zh' 简体中文 | 'en' English
238
225
  attentionHiddenOnly: false, // true = 页面可见时「需要你」不提醒
239
- currentHiddenOnly: true, // true = 当前对话的等待只留标题标记(转后台补发)
240
- doneHiddenOnly: true, // false = 页面可见时「完成」也提醒
226
+ currentHiddenOnly: true, // true = 当前对话的等待只留标题标记(人离开后补发)
227
+ donePolicy: "background", // 「回复完成」策略:'background' 仅后台 | 'smart' 仅当前对话静默 | 'always' 总是提醒
228
+ idleMinutes: 3, // 超过多少分钟没有键鼠输入算「离开」(0 = 关掉闲置判定)
229
+ autoDismiss: "present", // 通知自动消失:'present' 离开时保留 | 'always' 总是关闭 | 'never' 从不关闭
241
230
  ignoreSubagent: true, // true = 后台子代理(subagent)会话不提醒(当前打开的照常)
242
231
  sound: true, // 提示音开关(含「系统通知音」模式)
243
232
  soundDone: "synth", // 「完成」音效来源:'synth' 内置 | 'custom' 自定义文件 | 'system' 系统通知音
@@ -245,7 +234,6 @@ window.__dshNotifyMe.setConfig({
245
234
  volume: 0.5, // 音量 0~1
246
235
  autoFocus: true, // 点通知回到提醒所属的对话
247
236
  quickActions: true, // 审批通知上的「同意 / 拒绝」按钮
248
- overlayCorner: "bottom-right", // 桌面端审批卡片位置:'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
249
237
  externalEnabled: false, // 外部通知转发主开关(默认关:不填端点就什么都不发)
250
238
  externalAttention: true, // 转发「需要你」(触发规则跟本地提醒完全一致)
251
239
  externalDone: true, // 转发「回复完成」
@@ -282,9 +270,9 @@ window.__dshNotifyMe.testExternal() // 显式外发测试:向有
282
270
 
283
271
  ### 快捷裁决怎么走通的
284
272
 
285
- 通知按钮(`actions`)只属于 Service Worker 弹的持久通知,往 `new Notification()` 里传 `actions` 会直接抛 `TypeError`——这是绕不开的浏览器限制。所以 bundle 一份文件干两件事:页面里照常注册提醒工厂,同一份字节再注册成一个只干转信的 Service Worker,把「点了哪个按钮」(同意 / 拒绝 / 点正文)带回页面。真正的裁决发生在页面里,调的是审批卡片自己用的 `PendingApproval.answer('allowed-once' | 'rejected')`,所以「同意」就是「允许一次」,不多放行任何东西。点正文的「调窗口 + 切对话」同样归 worker 这半侧调度:它用 `client.focus()` 只调起一个窗口(优先当前可见的),并只让那个窗口切对话——多个 DSH 标签页不会互相抢焦点或一起乱切。
273
+ 通知按钮(`actions`)只属于 Service Worker 弹的持久通知,往 `new Notification()` 里传 `actions` 会直接抛 `TypeError`——这是绕不开的浏览器限制。所以 bundle 一份文件干两件事:页面里照常注册提醒工厂,同一份字节再注册成一个只干转信的 Service Worker,把「点了哪个按钮」(同意 / 拒绝 / 点正文)带回页面。真正的裁决发生在页面里,调的是对话里审批「允许一次 / 拒绝」用的同一个 `PendingApproval.answer('allowed-once' | 'rejected')`,所以「同意」就是「允许一次」,不多放行任何东西。点正文的「调窗口 + 切对话」同样归 worker 这半侧调度:它用 `client.focus()` 只调起一个窗口(优先当前可见的),并只让那个窗口切对话——多个 DSH 标签页不会互相抢焦点或一起乱切。
286
274
 
287
- 每条按钮通知按交互 key 记账,一次只认领一条等待;请求被页面里答掉、或被新请求顶替之后,残留通知上的按钮再点也不会误裁,只是安静地关掉。带按钮的通知停留 12 秒(普通通知 5 秒),到点自动撤;没来得及点就回页面里裁决,卡片一直等着你。
275
+ 每条按钮通知按交互 key 记账,一次只认领一条等待;请求被页面里答掉、或被新请求顶替之后,残留通知上的按钮再点也不会误裁,只是安静地关掉。带按钮的通知停留 12 秒(普通通知 5 秒),到点自动撤;没来得及点就回页面里裁决,对话里的审批一直等着你。
288
276
 
289
277
  ### 桌面端怎么抬窗的
290
278
 
@@ -305,7 +293,7 @@ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的
305
293
  - 页面必须开着才会提醒(后台标签/最小化可以;关标签页即失效——浏览器层方案固有限制)。
306
294
  - 通知经由浏览器弹出,需允许浏览器通知权限,且勿扰模式不能屏蔽它。
307
295
  - 桌面端(DSH Desktop 0.2.1-rc.2 / Windows)点通知后窗口不一定上前台:有时会,有时只切对话。插件发的 `dsh://open` 深链单独复测 3/3 无效(详见[桌面端怎么抬窗的](#桌面端怎么抬窗的)),能抬的时候多半是系统/Chromium 在通知激活时自己把窗口带起来了([discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446))。
308
- - 系统通知横幅只画在主屏:Windows 没有设置项、Chromium 没有 API 能把它指到别的显示器(多屏时副屏收不到系统通知)。页面里唯一还会出现在副屏的是桌面端的审批卡片,它跟着 DSH 窗口走;普通提醒要跨屏,只能靠外部推送转到手机。
296
+ - 系统通知横幅只画在主屏:Windows 没有设置项、Chromium 没有 API 能把它指到别的显示器(多屏时副屏收不到系统通知)。要跨屏提醒只能靠外部推送转到手机(1.12.0 起页面里不再画审批卡片)。
309
297
  - 每次页面加载后第一次出声/弹通知前,需在页面上点击过一次(浏览器自动播放与权限策略)。
310
298
  - 未授权通知权限时只有提示音与标题标记。
311
299
  - 快捷裁决按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;**桌面端(`dsh-app://`)已实测无法注册 Service Worker**,按钮渲染不了,审批通知退回无按钮样式。浏览器或桌面宿主不渲染通知按钮时,`window.__dshNotifyMe.debug()` 的 `bridge` 字段会说明原因,其余功能不受影响。
@@ -323,7 +311,7 @@ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的
323
311
  ```powershell
324
312
  node --check lib\client.js
325
313
  node --check lib\index.js
326
- node smoke\smoke-test.cjs # 离线状态机冒烟测试:三代宿主 + 主开关 + 中英语言用例 + 快捷裁决桥(Service Worker 半侧一并跑)+ 桌面审批卡片
314
+ node smoke\smoke-test.cjs # 离线状态机冒烟测试:三代宿主 + 主开关 + 中英语言用例 + 快捷裁决桥(Service Worker 半侧一并跑)+ 闲置判定与通知自收
327
315
  node smoke\cordis-host-test.mjs # 真 cordis 端到端(找不到本机 DSH 时自动跳过)
328
316
  npm pack --dry-run # 预览发布包
329
317
  ```
@@ -340,6 +328,7 @@ npm pack --dry-run # 预览发布包
340
328
  - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10):「音效来源」功能(内置合成音 / 自定义音频文件 / 系统通知音,「需要你」与「回复完成」各自独立选),含设置页交互、回落链与测试、文档;「系统通知音」试听改发测试通知、假 React 按索引跨渲染保持 hook 状态这两处修正同样出自该 PR。
341
329
  - [@yanzhaohui1999](https://github.com/yanzhaohui1999) — [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14):482 个会话下空闲页面烧 CPU 的性能报告(定位到 `currentSessionId()` 每行重扫全表,实测占 CPU 5.9%~6.5%)与修复(一次列表变化只推导一次「当前会话」),并带先红后绿的回归断言——4 行与 40 行的列表变化代价必须相等(上游报 7 vs 43,修后 5 vs 5)。
342
330
  - [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi) — [#12](https://github.com/chromoany/dsh-notify-me/issues/12):外部通知转发(Discord Webhook / Bark / Gotify / 自定义 Webhook)的功能建议与使用场景(1.7.0)。
331
+ - [@shrekcg](https://github.com/shrekcg) — [#16](https://github.com/chromoany/dsh-notify-me/pull/16):把「人在前台但离开了」并入离开判定(`idleMinutes`,默认 3 分钟)、「回复完成」按哪个对话完成决定是否提醒(`donePolicy` 三选一)、通知自动消失的三档策略(`autoDismiss`),并带上对应冒烟用例与旧配置迁移;1.12.0 落地时维护者补了闲置到期补发被压下的审批、删掉残留的页内审批卡片、补齐文档与版本。
343
332
 
344
333
  署名口径:本仓库收编外部 PR 时以维护者提交落地——不沿用原 commit、不挂 Co-authored-by,GitHub 的 contributors 图只显示维护者,贡献者以上面的致谢署名。
345
334
 
package/lib/client.js CHANGED
@@ -226,10 +226,26 @@
226
226
  // being hidden is the reliable "away" signal.
227
227
  attentionHiddenOnly: false, // needs-input alerts even while the page is visible
228
228
  // A wait inside the conversation already on screen needs no toast: the
229
- // approval card is right there, and a toast lands on top of it. Leaving
230
- // the page brings that alert back (see flushQuieted).
229
+ // request is right in front of the user. Leaving the page brings that
230
+ // alert back (see flushQuieted).
231
231
  currentHiddenOnly: true, // the visible session's own waits stay silent
232
- doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
232
+ doneHiddenOnly: true, // legacy boolean, kept in sync with donePolicy below
233
+ // When a finished reply should raise a toast. "background" alerts only
234
+ // while the page is not being watched; "smart" stays silent only for the
235
+ // conversation already on screen, so a second conversation finishing
236
+ // behind the one you are reading still reaches you; "always" never
237
+ // suppresses. "Watched" is away(), so a window left in front with nobody
238
+ // at the keyboard still counts as away.
239
+ donePolicy: "background",
240
+ // Minutes without pointer/keyboard input before a focused page counts as
241
+ // away. 0 disables the idle half and restores the focus-only rule.
242
+ idleMinutes: 3,
243
+ // Whether a toast goes away on its own. "present" closes it a few
244
+ // seconds later only while you are at the page, so an alert that arrived
245
+ // while you were away is still in the notification center when you come
246
+ // back; "always" is the old tidy-up; "never" leaves plain toasts up until
247
+ // you dismiss them. Approvals keep their own 12 s rule.
248
+ autoDismiss: "present",
233
249
  // Background subagent child sessions are steps of another conversation's
234
250
  // turn: DSH lists each of them as its own row (origin: 'subagent'), so
235
251
  // their waits and finish edges would toast noise the user cannot act on.
@@ -249,23 +265,17 @@
249
265
  soundAttention: "synth",
250
266
  volume: 0.5,
251
267
  autoFocus: true, // clicking the toast raises DSH and switches to the alerted conversation
252
- // Approval toasts carry 同意/拒绝 buttons and settle the request on one
253
- // click (the same answer('allowed-once' | 'rejected') the in-page
254
- // approval card sends). Buttons need persistent notifications, served
255
- // through the Service Worker half of this same file; where that cannot
256
- // run, the toast simply falls back to its plain form.
268
+ // Approval notifications carry 同意/拒绝 buttons and settle the request on
269
+ // one click (the same answer('allowed-once' | 'rejected') a manual
270
+ // decision sends). Buttons need persistent notifications, served through
271
+ // the Service Worker half of this same file; where that cannot run, the
272
+ // alert is a plain notification without buttons.
257
273
  quickActions: true,
258
- // Alerts reach the user through the OS notification center; there is no
259
- // second channel. An in-page card would follow the DSH window to whatever
260
- // display it sits on, but it is invisible the moment the window is not in
261
- // front — which is exactly when a reminder is worth anything — so 1.11.0
262
- // removed it as a channel. The card layer survives for the one case that
263
- // needs it: the desktop shell cannot put Approve / Reject on a system
264
- // notification, so an approval also drops a clickable card in the corner
265
- // (see showFallbackApprovalCard).
266
- // Which corner of this window that approval card stacks in. The screen
267
- // follows the window — the page cannot aim it anywhere else either.
268
- overlayCorner: "bottom-right", // bottom-right | bottom-left | top-right | top-left
274
+ // Alerts reach the user through the OS notification center and nowhere
275
+ // else. 1.11.0 dropped the in-page overlay (invisible the moment the window
276
+ // is not in front — exactly when a reminder is worth anything), and 1.12.0
277
+ // dropped the approval card that outlived it: a second on-page copy next
278
+ // to the notification is noise.
269
279
  // External forwarding: mirror every alert the plugin actually delivers to
270
280
  // webhook endpoints (Discord / Bark / Gotify / custom), so a phone or
271
281
  // another box can tell the user to come back. Off by default — nothing
@@ -282,18 +292,37 @@
282
292
  var raw = localStorage.getItem(LS_CONFIG);
283
293
  if (raw) cfg = JSON.parse(raw);
284
294
  } catch (e) { /* corrupt storage -> defaults */ }
285
- // The alert channel is gone: 1.11.0 removed the in-page overlay as a
286
- // delivery option, so an old config's `channel` / `overlay` / `toast` keys
287
- // mean nothing now. Dropped here so the next write stores a clean config.
295
+ // The in-page delivery options are gone: 1.11.0 removed the overlay as a
296
+ // channel, 1.12.0 the approval card. An old config's `channel` / `overlay`
297
+ // / `toast` / `overlayCorner` keys mean nothing now — dropped here so the
298
+ // next write stores a clean config.
288
299
  delete cfg.channel;
289
300
  delete cfg.overlay;
290
301
  delete cfg.toast;
302
+ delete cfg.overlayCorner;
303
+ // Older builds exposed one boolean switch. A profile that had "also
304
+ // alert while the page is open" turned on maps to "always"; everything
305
+ // else keeps the background rule. Applied before the defaults fill so a
306
+ // migrated value is not overwritten.
307
+ if (typeof cfg.donePolicy !== "string" && cfg.doneHiddenOnly === false) cfg.donePolicy = "always";
291
308
  for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
309
+ // The boolean is now derived, never an input: keeping the two in step
310
+ // means anything still reading doneHiddenOnly (a script, the 1.x smoke
311
+ // suite, a downgrade) sees what the policy actually does.
312
+ cfg.doneHiddenOnly = cfg.donePolicy !== "always";
292
313
  return cfg;
293
314
  }
294
315
  function writeConfig(patch) {
295
316
  var cfg = readConfig();
296
317
  for (var k in patch) cfg[k] = patch[k];
318
+ // Legacy alias, live in both directions: patching the old boolean still
319
+ // selects the policy, so an existing caller keeps its meaning instead of
320
+ // silently losing the setting to a key nothing reads any more.
321
+ if (!("donePolicy" in patch) && "doneHiddenOnly" in patch) {
322
+ cfg.donePolicy = patch.doneHiddenOnly === false ? "always" : "background";
323
+ }
324
+ cfg.donePolicy = cfg.donePolicy === "smart" || cfg.donePolicy === "always" ? cfg.donePolicy : "background";
325
+ cfg.doneHiddenOnly = cfg.donePolicy !== "always";
297
326
  try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
298
327
  return cfg;
299
328
  }
@@ -303,12 +332,6 @@
303
332
  function setConfigLocal(patch) {
304
333
  config = writeConfig(patch);
305
334
  applyTitle();
306
- // A new corner moves the approval-card layer without waiting for the next
307
- // alert. Cards themselves are left alone: an unrelated patch must not
308
- // sweep a pending approval off the screen.
309
- try {
310
- if (overlayLayer) overlayLayer.className = "dnm-ov-layer dnm-ov-" + overlayCorner();
311
- } catch (e) {}
312
335
  return config;
313
336
  }
314
337
 
@@ -663,13 +686,12 @@
663
686
  } catch (e) {}
664
687
  }
665
688
  // One toast button press. outcome: 'allowed-once' | 'rejected' — exactly
666
- // the two decisions the in-page approval card exposes.
689
+ // the two decisions a manual answer in the conversation makes.
667
690
  function decidePending(key, outcome) {
668
691
  var rec = key ? pendingActions[key] : null;
669
692
  if (!rec) return false; // already answered elsewhere, or a stale toast
670
693
  delete pendingActions[key];
671
694
  if (!rec.test) closeNotificationTag(rec.tag);
672
- closeOverlayForKey(key);
673
695
  if (rec.test) {
674
696
  // Test toasts settle nothing real; the feedback toast IS the proof that
675
697
  // the button clicked all the way through the worker bridge.
@@ -691,10 +713,10 @@
691
713
  // they are the only kind whose decision is a plain binary, and the only one
692
714
  // whose object answers answer('allowed-once' | 'rejected').
693
715
  function quickFor(key, it, sessionId) {
694
- // The buttons themselves are a rendering concern: the overlay always
695
- // draws them, and a toast needs a live worker bridge for OS actions. The
696
- // pending record is kept either way, so a click still settles the same
697
- // decision (and debug().actionKeys stays honest about what is waiting).
716
+ // The buttons are a rendering concern: a notification needs a live worker
717
+ // bridge for OS actions. The pending record is kept either way, so a click
718
+ // still settles the same decision (and debug().actionKeys stays honest
719
+ // about what is waiting).
698
720
  if (!config.quickActions) return null;
699
721
  if (!it || it.kind !== "approval" || typeof it.answer !== "function") return null;
700
722
  var q = {
@@ -784,243 +806,18 @@
784
806
  }
785
807
  // Longer for an actionable toast so there is time to click it, shorter
786
808
  // for a plain one: a notification that just sits there is noise.
787
- setTimeout(function () { n.close(); }, quick ? 12000 : 5000);
809
+ // close() also withdraws the toast from the notification center, so a
810
+ // timer must not erase an alert the user never got to see. autoDismiss
811
+ // picks between the three behaviours (see DEFAULTS).
812
+ var policy = config.autoDismiss === "always" || config.autoDismiss === "never" ? config.autoDismiss : "present";
813
+ if (quick) {
814
+ setTimeout(function () { if (policy !== "always" && hidden()) return; n.close(); }, 12000);
815
+ } else if (policy !== "never") {
816
+ setTimeout(function () { if (policy !== "always" && hidden()) return; n.close(); }, 5000);
817
+ }
788
818
  } catch (e) {}
789
819
  }
790
820
 
791
- // ─────────────────── page overlay reminders ──────────────────────
792
- // Windows draws notification banners on the primary display and exposes no
793
- // way to aim them at another monitor; a user who keeps DSH on a second
794
- // screen therefore never sees a toast. This overlay draws the same alert
795
- // inside the page instead, so it lands on whatever screen the DSH window
796
- // occupies. It also needs no notification permission, and — unlike OS
797
- // action buttons, which require the Service Worker the desktop shell cannot
798
- // run — it can carry the 同意/拒绝 buttons everywhere. Off by default;
799
- // independent of the toast switch.
800
- var OVERLAY_STYLE_ID = "dsh-notify-me/overlay";
801
- var OVERLAY_ID = "dsh-notify-me-overlay";
802
- var OVERLAY_MAX_CARDS = 4;
803
- // How long a card stays. "needs you" deserves a longer look than a finished
804
- // reply; actionable cards get the longest window but still fade, because a
805
- // card that never leaves is just clutter once the user has moved on.
806
- var OVERLAY_TTL = { attention: 5000, done: 5000, action: 12000 };
807
- var OVERLAY_CORNERS = ["bottom-right", "bottom-left", "top-right", "top-left"];
808
- var OVERLAY_CSS = "" +
809
- ".dnm-ov-layer{position:fixed;z-index:2147483000;display:flex;flex-direction:column;gap:10px;pointer-events:none;max-width:calc(100vw - 24px)}" +
810
- ".dnm-ov-bottom-right{right:16px;bottom:16px;align-items:flex-end}" +
811
- ".dnm-ov-bottom-left{left:16px;bottom:16px;align-items:flex-start}" +
812
- ".dnm-ov-top-right{right:16px;top:16px;align-items:flex-end}" +
813
- ".dnm-ov-top-left{left:16px;top:16px;align-items:flex-start}" +
814
- ".dnm-ov{pointer-events:auto;width:320px;max-width:calc(100vw - 32px);box-sizing:border-box;border-radius:12px;padding:10px 12px 11px;display:flex;flex-direction:column;gap:7px;font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary,#f3f4f6);background:var(--dsw-alias-bg-layer-3,rgba(30,32,36,.97));border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));border-left-width:3px;box-shadow:0 12px 32px rgba(0,0,0,.32);cursor:pointer;animation:dnmOvIn .16s ease-out}" +
815
- ".dnm-ov-attention{border-left-color:var(--dsw-alias-state-warning-primary,#f59e0b)}" +
816
- ".dnm-ov-done{border-left-color:var(--dsw-alias-state-success-primary,#22c55e)}" +
817
- ".dnm-ov-head{display:flex;align-items:center;gap:7px}" +
818
- ".dnm-ov-dot{flex:none;width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.55}" +
819
- ".dnm-ov-attention .dnm-ov-dot{color:var(--dsw-alias-state-warning-primary,#f59e0b);opacity:1}" +
820
- ".dnm-ov-done .dnm-ov-dot{color:var(--dsw-alias-state-success-primary,#22c55e);opacity:1}" +
821
- ".dnm-ov-title{flex:1;min-width:0;font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" +
822
- ".dnm-ov-x,.dnm-ov-more{flex:none;border:0;background:transparent;color:inherit;opacity:.6;font-size:15px;line-height:15px;padding:0 2px;cursor:pointer;font-family:inherit}" +
823
- ".dnm-ov-x:hover,.dnm-ov-more:hover{opacity:1}" +
824
- ".dnm-ov-more{font-size:11px;opacity:.8;white-space:nowrap}" +
825
- ".dnm-ov-body{color:var(--dsw-alias-label-secondary,rgba(220,222,226,.86));font-size:12px;line-height:18px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}" +
826
- ".dnm-ov.dnm-ov-open .dnm-ov-body{display:block;-webkit-line-clamp:unset;max-height:40vh;overflow:auto}" +
827
- ".dnm-ov-actions{display:flex;gap:8px;margin-top:2px}" +
828
- ".dnm-ov-btn{flex:1;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.06));color:inherit;border-radius:8px;padding:5px 10px;font-size:12px;font-family:inherit;cursor:pointer}" +
829
- ".dnm-ov-btn:hover{background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.12))}" +
830
- ".dnm-ov-approve{border-color:transparent;background:var(--dsw-alias-state-business-primary,#3b82f6);color:#fff}" +
831
- ".dnm-ov-approve:hover{background:var(--dsw-alias-state-business-primary,#3b82f6);filter:brightness(1.06)}" +
832
- ".dnm-ov-reject{color:inherit}" +
833
- "@keyframes dnmOvIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}";
834
- var overlayLayer = null;
835
- var overlayCards = Object.create(null); // id -> { card, key, kind, timer }
836
- var overlaySeq = 0;
837
- function overlayCorner() {
838
- return OVERLAY_CORNERS.indexOf(config.overlayCorner) === -1 ? "bottom-right" : config.overlayCorner;
839
- }
840
- function injectOverlayStyle() {
841
- try {
842
- if (typeof document === "undefined" || typeof document.createElement !== "function") return;
843
- if (!document.head && !document.documentElement) return;
844
- var sel = "style[data-plugin-css=" + JSON.stringify(OVERLAY_STYLE_ID) + "]";
845
- var seen = false;
846
- try { seen = !!(document.querySelector && document.querySelector(sel)); } catch (e) {}
847
- if (seen) return;
848
- var s = document.createElement("style");
849
- s.dataset.plugin = "dsh-notify-me";
850
- s.dataset.pluginCss = OVERLAY_STYLE_ID;
851
- s.textContent = OVERLAY_CSS;
852
- (document.head || document.documentElement).appendChild(s);
853
- } catch (e) {}
854
- }
855
- // The fixed layer lives directly under <body> so no ancestor transform or
856
- // stacking context in the host app can drag the cards away from the corner.
857
- function ensureOverlayLayer() {
858
- try {
859
- if (typeof document === "undefined" || typeof document.createElement !== "function" || !document.body) return null;
860
- injectOverlayStyle();
861
- if (!overlayLayer || !overlayLayer.parentNode) {
862
- // A reload of this bundle can leave a previous layer behind; the id is
863
- // the only handle an HMR-free page gives us.
864
- var old = null;
865
- try { old = document.getElementById && document.getElementById(OVERLAY_ID); } catch (e) {}
866
- if (old && old.parentNode) old.parentNode.removeChild(old);
867
- overlayLayer = document.createElement("div");
868
- overlayLayer.id = OVERLAY_ID;
869
- overlayLayer.setAttribute("role", "status");
870
- overlayLayer.setAttribute("aria-live", "polite");
871
- document.body.appendChild(overlayLayer);
872
- }
873
- overlayLayer.className = "dnm-ov-layer dnm-ov-" + overlayCorner();
874
- return overlayLayer;
875
- } catch (e) { return null; }
876
- }
877
- function dismissOverlay(id) {
878
- var rec = overlayCards[id];
879
- if (!rec) return;
880
- delete overlayCards[id];
881
- if (rec.timer) { try { clearTimeout(rec.timer); } catch (e) {} }
882
- try { if (rec.card && rec.card.parentNode) rec.card.parentNode.removeChild(rec.card); } catch (e) {}
883
- }
884
- // An approval that gets answered elsewhere (the in-page card, another
885
- // toast, a session switch) must not leave a stale overlay behind.
886
- function closeOverlayForKey(key) {
887
- if (!key) return;
888
- var ids = Object.keys(overlayCards);
889
- for (var i = 0; i < ids.length; i++) {
890
- if (overlayCards[ids[i]].key === key) dismissOverlay(ids[i]);
891
- }
892
- }
893
- function clearOverlays() {
894
- var ids = Object.keys(overlayCards);
895
- for (var i = 0; i < ids.length; i++) dismissOverlay(ids[i]);
896
- if (overlayLayer) {
897
- try { if (overlayLayer.parentNode) overlayLayer.parentNode.removeChild(overlayLayer); } catch (e) {}
898
- overlayLayer = null;
899
- }
900
- }
901
- function overlayCount() { return Object.keys(overlayCards).length; }
902
- // The card builder behind the desktop approval fallback below (the overlay
903
- // channel that also used it is gone since 1.11.0). `opts` keeps behaviours
904
- // apart: a click must not dismiss an unanswered approval, and a
905
- // "详情 / Details" toggle exposes the body that the CSS clamps to 3 lines.
906
- function drawOverlayCard(kind, title, body, sessionId, quick, opts) {
907
- opts = opts || {};
908
- var layer = ensureOverlayLayer();
909
- if (!layer) return false;
910
- // One card per approval key: a re-delivery (the quieted copy that fires
911
- // when the page is backgrounded) must not stack a second card.
912
- if (quick && typeof quick.key === "string") {
913
- var known = Object.keys(overlayCards);
914
- for (var k = 0; k < known.length; k++) {
915
- if (overlayCards[known[k]].key === quick.key) return false;
916
- }
917
- }
918
- var d = texts();
919
- try {
920
- var id = "ov" + (++overlaySeq);
921
- var baseClass = "dnm-ov dnm-ov-" + (kind === "attention" ? "attention" : "done");
922
- var card = document.createElement("div");
923
- card.className = baseClass;
924
- card.setAttribute("role", kind === "attention" ? "alert" : "status");
925
- var head = document.createElement("div");
926
- head.className = "dnm-ov-head";
927
- var dot = document.createElement("span");
928
- dot.className = "dnm-ov-dot";
929
- var label = document.createElement("span");
930
- label.className = "dnm-ov-title";
931
- label.textContent = String(title == null ? "" : title);
932
- var more = null;
933
- if (opts.expand && body) {
934
- more = document.createElement("button");
935
- more.type = "button";
936
- more.className = "dnm-ov-more";
937
- more.setAttribute("aria-label", d.details);
938
- more.setAttribute("aria-expanded", "false");
939
- more.textContent = d.details;
940
- more.onclick = function (ev) {
941
- if (ev && ev.stopPropagation) ev.stopPropagation();
942
- var open = card.className.indexOf("dnm-ov-open") !== -1;
943
- card.className = baseClass + (open ? "" : " dnm-ov-open");
944
- more.textContent = open ? d.details : d.collapse;
945
- more.setAttribute("aria-expanded", open ? "false" : "true");
946
- };
947
- }
948
- var close = document.createElement("button");
949
- close.type = "button";
950
- close.className = "dnm-ov-x";
951
- close.setAttribute("aria-label", d.dismiss);
952
- close.textContent = "×";
953
- close.onclick = function (ev) {
954
- if (ev && ev.stopPropagation) ev.stopPropagation();
955
- dismissOverlay(id);
956
- };
957
- head.appendChild(dot);
958
- head.appendChild(label);
959
- if (more) head.appendChild(more);
960
- head.appendChild(close);
961
- card.appendChild(head);
962
- if (body) {
963
- var bodyEl = document.createElement("div");
964
- bodyEl.className = "dnm-ov-body";
965
- bodyEl.textContent = String(body);
966
- card.appendChild(bodyEl);
967
- }
968
- if (quick && typeof quick.key === "string") {
969
- var actions = document.createElement("div");
970
- actions.className = "dnm-ov-actions";
971
- var actionBtn = function (cls, text, outcome) {
972
- var b = document.createElement("button");
973
- b.type = "button";
974
- b.className = "dnm-ov-btn " + cls;
975
- b.textContent = text;
976
- b.onclick = function (ev) {
977
- if (ev && ev.stopPropagation) ev.stopPropagation();
978
- try { decidePending(quick.key, outcome); } catch (e) {}
979
- dismissOverlay(id);
980
- };
981
- return b;
982
- };
983
- actions.appendChild(actionBtn("dnm-ov-approve", d.approve, "allowed-once"));
984
- actions.appendChild(actionBtn("dnm-ov-reject", d.reject, "rejected"));
985
- card.appendChild(actions);
986
- }
987
- // A click on the card body opens the conversation, exactly like a toast
988
- // body click — and here it is an ordinary page gesture, so it reaches the
989
- // desktop's dsh:// raise path even though a worker click would not. The
990
- // overlay channel also takes the card down; the fallback card stays, so
991
- // the decision is still one click away after a look at the conversation.
992
- card.onclick = function () {
993
- if (config.autoFocus) focusSession(sessionId || null);
994
- if (opts.dismissOnClick !== false) dismissOverlay(id);
995
- };
996
- layer.appendChild(card);
997
- var rec = { card: card, key: (quick && quick.key) ? quick.key : null, kind: kind, timer: null };
998
- overlayCards[id] = rec;
999
- var ids = Object.keys(overlayCards);
1000
- for (var i = 0; i < ids.length - OVERLAY_MAX_CARDS; i++) dismissOverlay(ids[i]);
1001
- var ttl = (quick && typeof quick.key === "string")
1002
- ? OVERLAY_TTL.action
1003
- : OVERLAY_TTL[kind === "attention" ? "attention" : "done"];
1004
- if (ttl) rec.timer = setTimeout(function () { dismissOverlay(id); }, ttl);
1005
- return true;
1006
- } catch (e) { return false; }
1007
- }
1008
- // The desktop shell cannot register the Service Worker that OS action
1009
- // buttons require, so an approval toast on a dsh-app:// page has no button
1010
- // to click. An approval therefore also drops a card into the in-page layer,
1011
- // in the configured corner and with the same styling: its buttons call
1012
- // decidePending(), the exact path a Web toast button takes. Only states that
1013
- // prove the bridge is unusable qualify — 'idle'/'registering' are
1014
- // transitional, 'disabled' means the quick-decision switch is off (quickFor()
1015
- // then returns null, so no card is drawn at all), and the Web path stays
1016
- // untouched once the worker is up.
1017
- function showFallbackApprovalCard(kind, title, body, sessionId, quick) {
1018
- if (kind !== "attention") return false;
1019
- if (!quick || typeof quick.key !== "string") return false;
1020
- if (bridgeActive()) return false;
1021
- if (swState.indexOf("unsupported:") !== 0 && swState.indexOf("failed:") !== 0) return false;
1022
- return drawOverlayCard(kind, title, body, sessionId, quick, { dismissOnClick: false, expand: true });
1023
- }
1024
821
  // Sound: WebAudio beeps (no asset needed). The context only runs after a
1025
822
  // user gesture; we unlock it on the first pointer/key event.
1026
823
  var audioCtx = null;
@@ -1331,10 +1128,7 @@
1331
1128
  var rec = pendingActions[key];
1332
1129
  if (rec) {
1333
1130
  delete pendingActions[key];
1334
- if (!rec.test) {
1335
- closeNotificationTag(rec.tag);
1336
- closeOverlayForKey(key);
1337
- }
1131
+ if (!rec.test) closeNotificationTag(rec.tag);
1338
1132
  }
1339
1133
  if (!attentionKeysMarked[key]) return;
1340
1134
  delete attentionKeysMarked[key];
@@ -1377,7 +1171,62 @@
1377
1171
  // to the conversation on screen; a blur alone never fires visibilitychange.
1378
1172
  try { flushQuieted(); } catch (e) {}
1379
1173
  }
1380
- function away() { return hidden() || !pageFocused; }
1174
+ // Focus answers "is the window in front", not "is anyone there". A window
1175
+ // left in front while its owner walks off still reports focus forever, so
1176
+ // track real input and treat a page nobody has touched for `idleMinutes` as
1177
+ // away too. Everything that keys on away() benefits without changing shape.
1178
+ var lastActivityAt = Date.now();
1179
+ function noteActivity() {
1180
+ lastActivityAt = Date.now();
1181
+ if (!idleTimer) armIdle();
1182
+ }
1183
+ (function watchActivity() {
1184
+ var events = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart"];
1185
+ for (var ai = 0; ai < events.length; ai++) {
1186
+ try { window.addEventListener(events[ai], noteActivity, { passive: true, capture: true }); } catch (e) {}
1187
+ }
1188
+ })();
1189
+ function idleMinutes() {
1190
+ var m = config && config.idleMinutes;
1191
+ return (typeof m === "number" && isFinite(m) && m > 0) ? m : 0;
1192
+ }
1193
+ function idleExpired() {
1194
+ var m = idleMinutes();
1195
+ return m ? (Date.now() - lastActivityAt) >= m * 60000 : false;
1196
+ }
1197
+ // Going idle is the one "away" transition that fires no event: the quieted
1198
+ // queue is normally flushed by a blur or a visibility change, and neither
1199
+ // comes when the user simply stops touching a window that stays in front.
1200
+ // The wait queued ten seconds before they walked off would then stay silent
1201
+ // until they came back. Re-check exactly when the idle window closes.
1202
+ var idleTimer = null;
1203
+ function onIdleCheck() {
1204
+ idleTimer = null;
1205
+ if (!idleExpired()) { armIdle(); return; }
1206
+ try { flushQuieted(); } catch (e) {}
1207
+ }
1208
+ function armIdle() {
1209
+ var m = idleMinutes();
1210
+ if (!m) return;
1211
+ var left = m * 60000 - (Date.now() - lastActivityAt);
1212
+ if (left < 0) left = 0;
1213
+ try { clearTimeout(idleTimer); } catch (e) {}
1214
+ try { idleTimer = setTimeout(onIdleCheck, left); } catch (e) { idleTimer = null; }
1215
+ }
1216
+ function away() { return hidden() || !pageFocused || idleExpired(); }
1217
+ function donePolicy() {
1218
+ var p = config && config.donePolicy;
1219
+ return p === "smart" || p === "always" ? p : "background";
1220
+ }
1221
+ // True when this completion must stay silent. Only "smart" looks at which
1222
+ // conversation finished: the one on screen is already being read, but one
1223
+ // further down the sidebar is information the user does not have yet.
1224
+ function doneSuppressed(isCurrent) {
1225
+ var p = donePolicy();
1226
+ if (p === "always") return false;
1227
+ if (p === "smart") return !!isCurrent && !away();
1228
+ return !away();
1229
+ }
1381
1230
 
1382
1231
  // Deliver one alert regardless of visibility rules (used by the test
1383
1232
  // buttons); respects the master switch and permission state. The title
@@ -1393,7 +1242,6 @@
1393
1242
  function deliver(kind, title, body, sessionId, quick) {
1394
1243
  if (!config.enabled) return;
1395
1244
  showToast(kind, title, body, sessionId, quick);
1396
- showFallbackApprovalCard(kind, title, body, sessionId, quick);
1397
1245
  if (kind === "attention") {
1398
1246
  playAttentionSound();
1399
1247
  } else {
@@ -1405,11 +1253,10 @@
1405
1253
  if (!config.enabled) return false;
1406
1254
  if (kind === "attention" && config.attentionHiddenOnly && !away()) return false;
1407
1255
  // The wait belongs to the conversation the user is looking at right now:
1408
- // the approval card is already on screen, so a toast would only cover the
1409
- // thing it is asking about. The caller keeps the tab marker, and
1410
- // flushQuieted() re-delivers the alert the moment the page is backgrounded.
1256
+ // the request is already on screen. The caller keeps the tab marker, and
1257
+ // flushQuieted() re-delivers the alert once the page is away.
1411
1258
  if (kind === "attention" && quietedByVisibleSession(isCurrent)) return false;
1412
- if (kind === "done" && config.doneHiddenOnly && !away()) return false;
1259
+ if (kind === "done" && doneSuppressed(isCurrent)) return false;
1413
1260
  deliver(kind, title, body, sessionId, quick);
1414
1261
  // The alert reached the user locally (or would have, had the tab a
1415
1262
  // voice): mirror the same copy to the outside world. Fire-and-forget —
@@ -1426,7 +1273,10 @@
1426
1273
  // Queued copies of the alerts that rule suppressed. Delivered by key so a
1427
1274
  // wait that got answered while the user was still on the page never fires.
1428
1275
  function queueQuieted(key, copy) {
1429
- if (key && copy) quietedAttention[key] = copy;
1276
+ if (key && copy) {
1277
+ quietedAttention[key] = copy;
1278
+ armIdle();
1279
+ }
1430
1280
  }
1431
1281
  function flushQuieted() {
1432
1282
  if (!config.enabled || !away()) return;
@@ -1524,7 +1374,7 @@
1524
1374
  var d = texts();
1525
1375
  var body = sessionLabel || d.doneBody;
1526
1376
  if (snippet) body = body + "\n" + snippet;
1527
- fire("done", d.doneTitle, body, false, sessionId);
1377
+ fire("done", d.doneTitle, body, !!(sessionId && sessionId === currentSessionId()), sessionId);
1528
1378
  }
1529
1379
  // Every "reply finished" report funnels through here, so the edge path and
1530
1380
  // the completionUnread level flag cannot double-alert one completion. The
@@ -2033,7 +1883,7 @@
2033
1883
  });
2034
1884
  } catch (e) {}
2035
1885
  // keep in sync with package.json "version" on every release
2036
- api.version = "1.11.0";
1886
+ api.version = "1.12.0";
2037
1887
  api.setConfig = function (patch) { return setConfigLocal(patch); };
2038
1888
  api.resetConfig = function () {
2039
1889
  try {
@@ -2092,7 +1942,9 @@
2092
1942
  language: effectiveLang(),
2093
1943
  attentionHiddenOnly: config.attentionHiddenOnly,
2094
1944
  currentHiddenOnly: config.currentHiddenOnly,
2095
- doneHiddenOnly: config.doneHiddenOnly,
1945
+ donePolicy: donePolicy(),
1946
+ idleMinutes: idleMinutes(),
1947
+ autoDismiss: config.autoDismiss,
2096
1948
  ignoreSubagent: config.ignoreSubagent,
2097
1949
  sound: config.sound,
2098
1950
  soundDone: soundMode("done"),
@@ -2100,14 +1952,6 @@
2100
1952
  customSoundDone: !!readSound("done"),
2101
1953
  customSoundAttention: !!readSound("attention"),
2102
1954
  quickActions: config.quickActions,
2103
- // The in-page card layer still exists for the desktop approval fallback
2104
- // (the shell's toasts carry no buttons); `cards` is the live stack and
2105
- // `corner` the configured anchor.
2106
- card: {
2107
- enabled: config.quickActions,
2108
- corner: overlayCorner(),
2109
- cards: overlayCount()
2110
- },
2111
1955
  // external webhook forwarding: counts come from externalStat (tallied
2112
1956
  // on every send), targets is the raw vs validated count.
2113
1957
  external: {
@@ -2170,7 +2014,7 @@
2170
2014
  enable: "启用提醒",
2171
2015
  enableHint: "关闭后不弹通知、不响提示音,也不改标签页标题。",
2172
2016
  hostEnv: "运行环境",
2173
- hostEnvHint: "默认自动识别:页面协议是 dsh-app:// 即桌面端(DSH Desktop),其余为 Web 端。识别不准时手动指定:桌面端走 dsh:// 深链调窗(效果不稳定),系统通知不带按钮、浮层才带;Web 端走 window.focus() 与 Service Worker 桥。",
2017
+ hostEnvHint: "默认自动识别:页面协议是 dsh-app:// 即桌面端(DSH Desktop),其余为 Web 端。识别不准时手动指定:桌面端走 dsh:// 深链调窗(效果不稳定),系统通知不带「同意 / 拒绝」按钮;Web 端走 window.focus() 与 Service Worker 桥。",
2174
2018
  hostEnvAuto: "自动识别(推荐)",
2175
2019
  hostEnvDesktop: "桌面端(DSH Desktop)",
2176
2020
  hostEnvWeb: "Web 端(浏览器)",
@@ -2237,26 +2081,28 @@
2237
2081
  attentionVisible: "窗口在前台时也提醒「需要你」",
2238
2082
  attentionVisibleHint: "窗口在前台也提醒。关掉后只在窗口转到后台(切走、被遮挡或最小化)时提醒。",
2239
2083
  currentQuiet: "当前对话不弹通知",
2240
- currentQuietHint: "等待就发生在你正看着的对话里时只留标签页标记,免得挡住审批卡片;窗口转到后台后照常提醒。",
2241
- doneVisible: "窗口在前台时也提醒「回复完成」",
2242
- doneVisibleHint: "窗口在前台也提醒。默认只在窗口转到后台时提醒,避免打扰。",
2084
+ currentQuietHint: "等待就发生在你正看着的对话里时只留标签页标记,免得打断眼前的操作;人离开(窗口转到后台或一段时间没动)后照常提醒。",
2085
+ donePolicy: "「回复完成」什么时候提醒",
2086
+ donePolicyHint: "「仅当前对话静默」=你在看的那条完成了不弹,别的对话完成了照常提醒,多开对话时有用。",
2087
+ dpBackground: "仅后台",
2088
+ dpSmart: "仅当前对话静默",
2089
+ dpAlways: "总是提醒",
2090
+ autoDismiss: "通知自动消失",
2091
+ autoDismissHint: "离开电脑时收到的那条,回来还要能看见。选「离开时保留」时只在你看页面时才关掉。",
2092
+ adPresent: "离开时保留",
2093
+ adAlways: "总是关闭",
2094
+ adNever: "从不关闭",
2243
2095
  subagentQuiet: "子代理会话不提醒",
2244
2096
  subagentQuietHint: "跳过后台子代理会话(结果由所属主对话的提醒覆盖);你正打开的那个照常提醒。",
2245
2097
  autoFocus: "点击通知回到对应对话",
2246
2098
  autoFocusHint: "点通知会切到这条提醒所属的对话;关掉后点击不做任何事。",
2247
2099
  quickActions: "审批通知上直接裁决",
2248
- quickActionsHint: "审批通知上带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。需要 Service Worker(http://127.0.0.1 或 https);用不了时按钮改画在窗口角落的卡片上(桌面端就是这条路)。",
2249
- quickApprovalNote: "「同意」等价于审批卡片上的「允许一次」,只放行这一次。",
2100
+ quickActionsHint: "审批通知上带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。需要 Service Worker(http://127.0.0.1 或 https);用不了时(桌面端页面即如此)通知照常弹,只是不带按钮,点通知回到对话里裁决。",
2101
+ quickApprovalNote: "「同意」等价于对话里审批的「允许一次」,只放行这一次。",
2250
2102
  bridgeOff: "快捷裁决按钮暂不可用(",
2251
- bridgeOffTail: "):系统通知没有按钮,按钮改画在窗口角落的卡片上。",
2252
- bridgeOffDesktop: "系统通知本身没有「同意 / 拒绝」按钮:桌面端页面(dsh-app://)注册不了 Service Worker(宿主少给 allowServiceWorkers 特权)。审批类提醒会同时在窗口角落弹一张卡片,按钮就在卡片上。",
2253
- overlayCorner: "审批卡片位置",
2254
- overlayCornerHint: "桌面端审批卡片贴在 DSH 窗口的哪个角;窗口在哪块屏,卡片就出现在哪块屏。",
2255
- overlayPosTR: "右上",
2256
- overlayPosBR: "右下",
2257
- overlayPosTL: "左上",
2258
- overlayPosBL: "左下",
2259
- testApprovalNoBridge: "已发测试提醒:系统通知没有按钮,快捷裁决按钮画在窗口角落的卡片上。",
2103
+ bridgeOffTail: "):这条通知不带按钮,点它回到对话里裁决。",
2104
+ bridgeOffDesktop: "桌面端页面(dsh-app://)注册不了 Service Worker(宿主少给 allowServiceWorkers 特权),系统通知本身没有「同意 / 拒绝」按钮:审批提醒照常弹出,点通知回到对话里裁决。",
2105
+ testApprovalNoBridge: "已发测试提醒:桌面端通知不带按钮,点通知回到对话里裁决。",
2260
2106
  testNeedsQuick: "先打开上面的「审批通知上直接裁决」才能测按钮。",
2261
2107
  volume: "音量",
2262
2108
  language: "通知语言",
@@ -2293,7 +2139,7 @@
2293
2139
  enable: "Enable reminders",
2294
2140
  enableHint: "When off: no toasts, no sounds, and the tab title is left alone.",
2295
2141
  hostEnv: "Environment",
2296
- hostEnvHint: "Auto-detect by default: a dsh-app:// page is the Desktop (DSH Desktop) shell, anything else is the Web. Override it if detection comes out wrong: on the Desktop a toast click fires the dsh:// deep link (the effect is unreliable) and system notifications carry no buttons (the overlay does); on the Web it is window.focus() plus the Service Worker bridge.",
2142
+ hostEnvHint: "Auto-detect by default: a dsh-app:// page is the Desktop (DSH Desktop) shell, anything else is the Web. Override it if detection comes out wrong: on the Desktop a toast click fires the dsh:// deep link (the effect is unreliable) and system notifications carry no Approve / Reject buttons; on the Web it is window.focus() plus the Service Worker bridge.",
2297
2143
  hostEnvAuto: "Auto-detect (recommended)",
2298
2144
  hostEnvDesktop: "Desktop (DSH Desktop)",
2299
2145
  hostEnvWeb: "Web (browser)",
@@ -2360,26 +2206,28 @@
2360
2206
  attentionVisible: "Also alert \"needs you\" while the window is in front",
2361
2207
  attentionVisibleHint: "Alert even while the window has focus. Turn off to hear only once the window goes to the background (another app, covered, or minimised).",
2362
2208
  currentQuiet: "Stay quiet for the conversation on screen",
2363
- currentQuietHint: "A wait belonging to the conversation on screen keeps only the tab marker, so the approval card stays readable; moving the window to the background restores alerts.",
2364
- doneVisible: "Also alert \"reply finished\" while the window is in front",
2365
- doneVisibleHint: "Alert even while the window has focus. By default only finishes in the background alert.",
2209
+ currentQuietHint: "A wait belonging to the conversation on screen keeps only the tab marker, so the page is not covered; moving the window to the background or stepping away from it restores alerts.",
2210
+ donePolicy: "When \"reply finished\" alerts",
2211
+ donePolicyHint: "\"Quiet for the open one\" means the conversation you are reading finishes silently while any other conversation still alerts — useful with several conversations open.",
2212
+ dpBackground: "Background only",
2213
+ dpSmart: "Quiet for the open one",
2214
+ dpAlways: "Always",
2215
+ autoDismiss: "Dismiss the toast automatically",
2216
+ autoDismissHint: "An alert that arrived while you were away should still be there when you return. \"Keep while away\" only tidies up while the page is in front.",
2217
+ adPresent: "Keep while away",
2218
+ adAlways: "Always close",
2219
+ adNever: "Never close",
2366
2220
  subagentQuiet: "Ignore subagent sessions",
2367
2221
  subagentQuietHint: "Skip sessions spawned by a subagent (the parent conversation covers them); the one you have open still alerts.",
2368
2222
  autoFocus: "Click a toast to open that conversation",
2369
2223
  autoFocusHint: "A click switches to the conversation the alert came from; off means a click does nothing.",
2370
2224
  quickActions: "Decide approvals from the toast",
2371
- quickActionsHint: "Approval notifications carry Approve / Reject; one click settles the request without switching back. Needs a Service Worker browser (http://127.0.0.1 or https); when it is unavailable the buttons land on a card in the window corner (the Desktop shell's route).",
2372
- quickApprovalNote: "\"Approve\" equals \"Allow once\" on the approval card — it allows that one call only.",
2225
+ quickActionsHint: "Approval notifications carry Approve / Reject; one click settles the request without switching back. Needs a Service Worker browser (http://127.0.0.1 or https); where it cannot run (the Desktop shell) the alert is a plain notification without buttons — clicking it opens the conversation.",
2226
+ quickApprovalNote: "\"Approve\" equals \"Allow once\" in the conversation — it allows that one call only.",
2373
2227
  bridgeOff: "Quick-decision buttons are unavailable right now (",
2374
- bridgeOffTail: "); system notifications carry no buttons, so they are drawn on a card in the window corner instead.",
2375
- bridgeOffDesktop: "System notifications carry no Approve / Reject buttons: dsh-app:// pages cannot register a Service Worker (the shell does not grant the allowServiceWorkers privilege). An approval therefore also drops a clickable card in the window corner, buttons and all.",
2376
- overlayCorner: "Approval card position",
2377
- overlayCornerHint: "Which corner of the DSH window the Desktop approval card sits in; it follows the window to whatever display it is on.",
2378
- overlayPosTR: "Top right",
2379
- overlayPosBR: "Bottom right",
2380
- overlayPosTL: "Top left",
2381
- overlayPosBL: "Bottom left",
2382
- testApprovalNoBridge: "Test alert sent: system notifications carry no buttons, so the quick-decision buttons are drawn on a card in the window corner.",
2228
+ bridgeOffTail: "); this notification carries no buttons — click it to open the conversation and decide.",
2229
+ bridgeOffDesktop: "dsh-app:// pages cannot register a Service Worker (the shell does not grant the allowServiceWorkers privilege), and a system notification carries no Approve / Reject buttons: the approval alert still pops, click it to open the conversation and decide.",
2230
+ testApprovalNoBridge: "Test alert sent: Desktop notifications carry no buttons — click it to open the conversation and decide.",
2383
2231
  testNeedsQuick: "Turn on \"Decide approvals from the toast\" above to test the buttons.",
2384
2232
  volume: "Volume",
2385
2233
  language: "Notification language",
@@ -2529,8 +2377,8 @@
2529
2377
  ".dnm-headTitle{font-size:15px;font-weight:600;line-height:21px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
2530
2378
  ".dnm-headSub{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
2531
2379
  ".dnm-secTitle{margin:2px 0 -6px;padding:0 2px;font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-secondary,rgba(90,90,90,.9));letter-spacing:.02em}" +
2532
- // Sub-rows hang under the row they belong to (corner of the overlay,
2533
- // volume under Sound) instead of reading as siblings.
2380
+ // Sub-rows hang under the row they belong to (volume under Sound)
2381
+ // instead of reading as siblings.
2534
2382
  ".dnm-rowSub{padding-left:26px;background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.04))}" +
2535
2383
  ".dnm-rowSub .dnm-rowLabel{font-weight:500}" +
2536
2384
  ".dnm-rowStack{flex-direction:column;align-items:stretch;gap:10px}" +
@@ -2686,6 +2534,22 @@
2686
2534
  try { window.addEventListener("scroll", hideTip, true); } catch (e) {}
2687
2535
  })();
2688
2536
 
2537
+ // A labelled <select> row, for settings that pick one of three behaviours
2538
+ // rather than switching something on.
2539
+ function rowSelect(key, labelText, hintText, current, options, onPick) {
2540
+ return tipRow("dnm-row", hintText, [
2541
+ h("div", { className: "dnm-rowText" }, [
2542
+ h("div", { className: "dnm-rowLabel" }, labelText),
2543
+ srOnly(hintText)
2544
+ ]),
2545
+ h("select", {
2546
+ className: "dnm-select",
2547
+ value: current,
2548
+ "aria-label": labelText,
2549
+ onChange: function (e) { onPick(e.currentTarget.value); }
2550
+ }, options.map(function (o) { return h("option", { value: o.value }, o.label); }))
2551
+ ]);
2552
+ }
2689
2553
  function rowSwitch(key, labelText, hintText, checked, onChange) {
2690
2554
  return tipRow("dnm-row", hintText, [
2691
2555
  h("div", { className: "dnm-rowText" }, [
@@ -3073,34 +2937,23 @@
3073
2937
  rowSwitch("quickActions", t("quickActions"), t("quickActionsHint"), cfg.quickActions, function (v) {
3074
2938
  patch({ quickActions: v });
3075
2939
  if (v) ensureActionBridge();
3076
- }),
3077
- // The in-page card only exists to carry Approve / Reject where the OS
3078
- // toast cannot (the desktop shell), so its corner hangs under the
3079
- // quick-decision switch rather than a channel picker.
3080
- cfg.quickActions ? tipRow("dnm-row dnm-rowSub", t("overlayCornerHint"), [
3081
- h("div", { className: "dnm-rowText" }, [
3082
- h("div", { className: "dnm-rowLabel" }, t("overlayCorner")),
3083
- srOnly(t("overlayCornerHint"))
3084
- ]),
3085
- h("select", {
3086
- className: "dnm-select",
3087
- value: overlayCorner(),
3088
- "aria-label": t("overlayCorner"),
3089
- onChange: function (e) { patch({ overlayCorner: e.currentTarget.value }); }
3090
- }, [
3091
- h("option", { value: "top-right" }, t("overlayPosTR")),
3092
- h("option", { value: "bottom-right" }, t("overlayPosBR")),
3093
- h("option", { value: "top-left" }, t("overlayPosTL")),
3094
- h("option", { value: "bottom-left" }, t("overlayPosBL"))
3095
- ])
3096
- ]) : null
2940
+ })
3097
2941
  ]),
3098
2942
  // 2) Which events are worth interrupting for.
3099
2943
  secTitle(t("secWhen")),
3100
2944
  card([
3101
2945
  rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
3102
2946
  rowSwitch("currentQuiet", t("currentQuiet"), t("currentQuietHint"), cfg.currentHiddenOnly, function (v) { patch({ currentHiddenOnly: v }); }),
3103
- rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
2947
+ rowSelect("donePolicy", t("donePolicy"), t("donePolicyHint"), cfg.donePolicy, [
2948
+ { value: "background", label: t("dpBackground") },
2949
+ { value: "smart", label: t("dpSmart") },
2950
+ { value: "always", label: t("dpAlways") }
2951
+ ], function (v) { patch({ donePolicy: v }); }),
2952
+ rowSelect("autoDismiss", t("autoDismiss"), t("autoDismissHint"), cfg.autoDismiss, [
2953
+ { value: "present", label: t("adPresent") },
2954
+ { value: "always", label: t("adAlways") },
2955
+ { value: "never", label: t("adNever") }
2956
+ ], function (v) { patch({ autoDismiss: v }); }),
3104
2957
  rowSwitch("subagentQuiet", t("subagentQuiet"), t("subagentQuietHint"), cfg.ignoreSubagent, function (v) { patch({ ignoreSubagent: v }); }),
3105
2958
  rowSwitch("autoFocus", t("autoFocus"), t("autoFocusHint"), cfg.autoFocus, function (v) { patch({ autoFocus: v }); })
3106
2959
  ]),
@@ -3349,10 +3202,10 @@
3349
3202
  return function () {
3350
3203
  disposed = true;
3351
3204
  if (retryTimer) clearInterval(retryTimer);
3205
+ if (idleTimer) { try { clearTimeout(idleTimer); } catch (e) {} idleTimer = null; }
3352
3206
  for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
3353
3207
  detachFace();
3354
3208
  detachUiSession();
3355
- clearOverlays();
3356
3209
  };
3357
3210
  }, "dsh-notify-me: watchers");
3358
3211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "离开 DSH 页面也能收到提醒:需要你操作 / 后台回复完成时发系统通知、标题标记与提示音,支持审批快捷裁决与外部推送,Web 版与桌面版均可用。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,8 +35,8 @@
35
35
  "needs-your-input",
36
36
  "approval-alert",
37
37
  "approval-actions",
38
- "multi-monitor",
39
- "in-page-overlay",
38
+ "idle-detection",
39
+ "auto-dismiss",
40
40
  "background-alert",
41
41
  "external-notification",
42
42
  "webhook",
@@ -55,8 +55,8 @@
55
55
  "外部通知",
56
56
  "推送提醒",
57
57
  "提醒",
58
- "副屏提醒",
59
- "浮层提醒"
58
+ "闲置判定",
59
+ "离开判定"
60
60
  ],
61
61
  "engines": {
62
62
  "node": ">=18"