dsh-notify-me 1.14.0 → 1.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -19,18 +19,16 @@
19
19
 
20
20
  </div>
21
21
 
22
- [Install](#installation) · [Preview](#ui-preview) · [Settings](#configure-it-from-dsh-settings) · [Compatibility](#compatibility) · [Changelog](CHANGELOG.md)
22
+ [Install](#installation) · [Preview](#ui-preview) · [Settings](#configure-it-from-dsh-settings) · [Compatibility](#compatibility) · [Known limitations](#known-limitations) · [Changelog](CHANGELOG.md)
23
23
 
24
- A notification plugin for DSH Web / Desktop, so you can keep full access off without watching the page all day.
24
+ A notification plugin for DSH Web / Desktop, so you can afford to keep full access off without watching the page all day.
25
25
 
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. 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.
26
+ - **When input is needed:** approval, plan review, and questions get a notification, a sound, and a title marker.
27
+ - **When a background reply finishes:** come back for the result; clicking the notification returns to that 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 as usual; the toast that arrived does not clear itself before you come back.
29
29
  - **When you leave your computer:** optionally forward alerts to Discord, Bark, Gotify, Feishu group bots, ServerChan, WxPusher, or your own webhook.
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 — click the notification to decide in the conversation.
32
-
33
- > 🔎 Looking for it? Search `dsh-notify-me`, **message alerts**, **notification**, **desktop notification**, **reminder**, **approval alert**, **external notifications**, **Android push**, **Discord / Bark / Gotify / Feishu / ServerChan / WxPusher push** or **可操作提醒 / 消息提醒 / 桌面通知 / 需要你操作** (npm keywords include both English and Chinese terms).
31
+ > The page or desktop app has to stay running (background / minimized is fine); system notifications need notification permission. Approval buttons rely on a Service Worker, so only Web has them — Desktop notifications carry no buttons, and clicking one takes you back to the conversation to decide.
34
32
 
35
33
  ## UI Preview
36
34
 
@@ -43,7 +41,8 @@ A notification plugin for DSH Web / Desktop, so you can keep full access off wit
43
41
 
44
42
  ![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
43
 
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.
44
+ Just the plugin's own panel (the real component, on the same dark theme as the host), without the DSH navigation sidebar; row explanations only appear once the pointer rests on the title text, which is why only the titles show here.
45
+
47
46
  </details>
48
47
 
49
48
  ## Installation
@@ -69,31 +68,27 @@ The Electron application manages the `desktop` profile. Do not change the Web co
69
68
 
70
69
  ## First-use check
71
70
 
72
- 1. Click once in the DSH page and allow notifications when prompted. If you previously declined, allow them in the browser's site settings. The operating system must also allow notifications from the browser / DSH Desktop.
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
- 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.
71
+ 1. Click once in the DSH page and allow notifications when prompted. If you declined before, allow them in the browser's site settings. The operating system also has to allow notifications from the browser / DSH Desktop.
72
+ 2. Open **Settings → Notify me**. Confirm that the section and the plugin version at its foot appear, and leave "Enable reminders" and "Sound" on.
73
+ 3. Click **Test "reply finished"** on the settings page. A test notification should appear and play a sound according to your current sound settings. This local test is not forwarded to any external endpoint, and the "also alert while the window is in front" switch does not apply to it.
75
74
 
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.
75
+ Web users can also test the approval buttons, which then appear on the notification. Desktop cannot get a Service Worker, so this test sends a plain notification; real approval alerts work the same way — the toast fires, and clicking it takes you back to the conversation to decide.
77
76
 
78
77
  **Optional console check** — in environments with DevTools, you can also run:
79
78
 
80
79
  ```js
81
80
  window.__dshNotifyMe.test("done") // "reply finished" sample
82
- window.__dshNotifyMe.test("attention") // "needs your input" sample
83
- window.__dshNotifyMe.test("approval") // Approve / Reject with an active Web bridge; a plain notification on Desktop
84
- window.__dshNotifyMe.debug() // the bridge field reports the button channel: "active" = buttons work
81
+ window.__dshNotifyMe.test("attention") // "needs you" sample
82
+ window.__dshNotifyMe.test("approval") // Approve / Reject when the bridge is available; a plain notification otherwise (which is what Desktop gets)
83
+ window.__dshNotifyMe.debug() // the bridge field reports the state: "active" = buttons work
85
84
  ```
86
85
 
87
- If no test notification appears, first check:
88
-
89
- - the notification permission was declined — click anywhere on the page and choose **Allow** (address-bar lock → Site settings → Notifications → Allow → reload);
90
- - the DSH process wasn't restarted after install;
91
- - your OS/browser notification settings block the browser's toasts.
86
+ If no test notification appears, check in this order: whether the notification permission was declined (click once on the page and choose **Allow**; or address-bar lock → Site settings → Notifications → Allow → reload), whether DSH was restarted after installing, and whether the browser's notifications are switched off in the OS settings.
92
87
 
93
88
  ## Why you need it
94
89
 
95
90
  - **Without full access (`workspace-write` + ask):** keep approval before actions, but a missed request leaves the agent waiting.
96
- - **With full access:** fewer approval interruptions, at the cost of interception before actions.
91
+ - **With full access:** fewer approval interruptions, at the cost of losing the ability to intercept beforehand.
97
92
 
98
93
  dsh-notify-me decouples **supervision** from **sitting in front of the screen**: it pulls you back with a system notification only when a decision is genuinely needed (approval / plan review / question) or a reply finishes — **so you can afford to keep full access off** instead of trading away interception for speed. It does not lower the risk of full access itself (it is not a sandbox); it reduces the time spent waiting on missed approvals in ask-mode.
99
94
 
@@ -104,7 +99,7 @@ dsh-notify-me decouples **supervision** from **sitting in front of the screen**:
104
99
  | 🔔 Input needed | Alerts even in the foreground; the conversation on screen keeps only a title marker |
105
100
  | ✅ Reply finished | Alerts while the page is hidden / backgrounded (or "quiet for the open one" / "always") |
106
101
 
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.
102
+ **Input needed** covers approval requests, plan review, and questions (`ask_user_question`). The approve / reject quick decision only works on Web, where the toast carries the buttons; on Desktop, click the notification to decide in the conversation (see [Known limitations](#known-limitations)).
108
103
 
109
104
  **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
105
 
@@ -112,98 +107,99 @@ dsh-notify-me decouples **supervision** from **sitting in front of the screen**:
112
107
 
113
108
  ## Configure it from DSH Settings
114
109
 
115
- Open **Settings → Notify me**. The page runs as how you get told, when to alert, and a try-it block; the knobs you rarely touch fold into **Advanced** at the bottom. Rows show titles only — the explanation pops up next to the pointer once it rests on the title text (hovering a switch does nothing), with a hidden copy kept for screen readers. Rows marked with ▸ open a sub-item when clicked.
110
+ Open **Settings → Notify me**. The page lays out three blocks — how you get told, when to alert, and try it — and the knobs you rarely change fold into **Advanced** at the bottom, one click to open. Rows show only a title; the explanation pops up in a small box when the pointer rests on the title text (only the title itself triggers it, not the empty rest of the row; for touch or keyboard a hidden copy is kept for screen readers). Rows marked with ▸ open a sub-item on click.
116
111
 
117
112
  ### How you get told
118
113
 
119
- - **Enable reminders**: master switch — off means no toasts, no sounds, and the tab title is left alone;
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 notification carries no buttons — click it to decide in the conversation. The line under the Try-it block states whether buttons are possible here.
114
+ - **Enable reminders** — master switch: off means no toasts, no sound, and the tab title is left alone;
115
+ - **Sound / Volume** — the switch toggles directly; the volume slider is folded into that row and revealed by clicking the title (▸ becomes ▾). What it actually plays is decided by Advanced → Alert sound source;
116
+ - **Decide approvals from the toast** (on by default) — approvals carry Approve / Reject, one click settles the request without switching back. Web gets a system notification with buttons; Desktop `dsh-app://` cannot register a Service Worker, so the notification has no buttons and you click it to decide in the conversation. The line under "Try it" states truthfully whether the current environment can provide buttons.
122
117
 
123
118
  ### When to alert
124
119
 
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;
129
- - **Ignore subagent sessions** (on): skip background sessions spawned by a subagent — the parent conversation covers them; the one you have open still alerts;
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 the host route raises the native window (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)) and a failure only leaves the conversation switch in place, with the reason in `debug().raiseLast`; with several tabs open only one is raised, and off means a click does nothing.
120
+ - **Also alert "needs you" while the window is in front** (on by default): off means you only hear it once the window goes to the background;
121
+ - **Stay quiet for the conversation on screen** (on by default): a wait in the conversation you are looking at keeps only the tab marker, and is delivered once you leave;
122
+ - **When "reply finished" alerts** (default "background only"): background only / quiet for the open one / always — pick one;
123
+ - **Dismiss the toast automatically** (default "keep while away"): keep while away / always close / never close — pick one;
124
+ - **Ignore subagent sessions** (on by default): skips background subagent sessions, while the one you have open still alerts;
125
+ - **Click a toast to open that conversation** (on by default): switches to that conversation and brings the window forward — on Desktop through native raising — and with several tabs only one is raised.
131
126
 
132
- ### Try it
127
+ <details>
128
+ <summary>Edge cases behind the switches</summary>
133
129
 
134
- Three buttons send one "needs you", one "reply finished" and one "approval buttons" test alert with the current settings — deliberately not limited by the switches above. A line underneath states which channel can carry approval buttons right now.
130
+ - **What counts as background**: switching to another app, being covered by another window, or minimizing all count; a window left in front with 3 minutes of no keyboard or mouse input counts too, and `idleMinutes: 0` falls back to looking only at the window.
131
+ - **When "stay quiet for the conversation on screen" is replayed**: as usual once the window goes to the background or the idle timer fires; if you already handled it, it is dropped.
132
+ - **The three "reply finished" choices**: "background only" = alert when you are not watching the page; "quiet for the open one" = the one on screen stays silent while other conversations still alert, useful with several open; "always" = never suppress. An old profile's "also alert 'reply finished' while in front" switch migrates to this setting automatically.
133
+ - **"Keep while away"**: a toast only closes a few seconds later while you are actually at the page; one that arrived while you were away stays in the notification centre until you come back. Approval toasts with Approve / Reject still follow their 12 s rule and are likewise not withdrawn while you are away.
134
+ - **Fallback for a toast click**: on Desktop a window that will not raise leaves only the conversation switch, with the reason in `debug().raiseLast`; turn this switch off and a click does nothing.
135
135
 
136
- The plugin version sits at the bottom of the page (`dsh-notify-me v…`) — copy that line when reporting a bug or checking whether an update landed.
136
+ </details>
137
137
 
138
- ### Advanced
138
+ ### Try it
139
139
 
140
- Collapsed by default. It holds the three things you rarely touch: alert sounds, external forwarding, and runtime & diagnostics — each documented below.
140
+ Three buttons send one "needs you", one "reply finished" and one "approval buttons" test alert with the current settings, right away, and deliberately **not** limited by the switches under "when to alert"; the line underneath states which channel can carry approval buttons right now.
141
141
 
142
- ### Sound
142
+ The plugin version is always shown at the bottom of the settings page (`dsh-notify-me v…`) — copy that line when reporting a bug or checking whether an update landed.
143
143
 
144
- Choose a built-in cue, custom audio file, or system notification sound independently for each alert kind.
144
+ ### Advanced
145
145
 
146
- <details>
147
- <summary>Custom audio, previews, and fallback behavior</summary>
146
+ Collapsed by default, one click to open. It holds the three things you rarely touch — alert sound sources, external push, and runtime & diagnostics — covered in the three subsections below.
148
147
 
149
- - **Alert sound** (picked separately for "needs you" and "reply finished") — built-in cue (default) / custom audio file / system notification sound. "Custom audio file" takes one local audio file in the settings page (1 MiB limit, stored in the browser, with preview and remove); "system notification sound" plays no plugin cue at all — the toast still appears and the operating system supplies the sound, so its timbre follows your system notification settings; in that mode "Preview" sends a test alert instead, because the sound only exists as part of a toast and that is the sound you hear. Any source that has no file, cannot be read, or does not fit falls back to the built-in cue, so an alert never goes silent;
148
+ #### Alert sound
150
149
 
151
- </details>
150
+ For each alert kind — "needs you" and "reply finished" — you can pick a built-in cue (the default), a custom audio file, or the system notification sound, independently. A custom file is capped at 1 MiB, lives in the browser, and can be previewed and removed; the system notification sound plays no plugin cue at all — the toast still fires and the sound is handed to the operating system, so its timbre follows your system notification settings, and in that mode Preview sends a test alert instead. Any source that has no file, cannot be read, or does not fit falls back to the built-in cue, so an alert never goes silent.
152
151
 
153
- ### External push
152
+ #### External push
154
153
 
155
- Off by default. Supports Discord Webhook / Bark / Gotify / Feishu group bots / ServerChan (Turbo and ServerChan³) / WxPusher / custom webhooks. Alert text is sent to the addresses you enter; use trusted endpoints.
154
+ Off by default. Supports Discord Webhook / Bark / Gotify / Feishu group bots / ServerChan (Turbo and ServerChan³) / WxPusher / custom webhooks. Alert text is sent to the addresses you enter; use trusted endpoints only.
156
155
 
157
156
  <details>
158
157
  <summary>Target URLs, forwarding switches, test messages, and the failure log</summary>
159
158
 
160
- - **External notifications** (default off) — forward each alert to your own push endpoints as well, so it reaches you away from the machine: **Discord Webhook** / **Bark** / **Gotify** / **Feishu group bot** / **ServerChan** / **WxPusher** / **custom webhook**, any number of targets, each of which can be enabled or disabled on its own (Discord takes the webhook URL; Bark `https://api.day.app/<device key>`; Gotify `https://<server>/message?token=<app token>`; Feishu the group-bot webhook plus an optional verification secret; ServerChan the full `…/send/<SendKey>.send` URL; WxPusher the appToken and the receiver UID; a custom endpoint receives one POST of JSON: `{source, event, title, body, text, sessionId, time}`). "Forward 'needs you'" and "Forward 'reply finished'" switch independently; **Send test notification** sends one test message to every target (not limited by those two switches, and never pretending to be a real alert). Forwarding follows exactly the same trigger rules as local alerts ("stay quiet for the conversation on screen" and friends apply to it too); the alert text is sent as-is to the addresses you fill in, so **only use endpoints you trust**;
161
- - **Reliable delivery** — failures that look transient (network error, 5xx, 429) are retried after 2 s and 6 s; anything else stops at once. A delivery that still fails is recorded in the failure log (10 entries at most — title and body snippet plus host only, never the full endpoint), which the settings page shows and can clear;
162
- - **Per-target state** — the right end of each target row shows how its last delivery went (last delivery ok / sent-unverified / failed with the reason) next to a switch of its own, so an endpoint that is temporarily down can be turned off alone instead of muting a whole alert kind;
163
- - **Signing** — a custom target with a secret sends `X-DSH-Notify-Signature` (HMAC-SHA256 over the body, `sha256=<hex>`); a Feishu target with a secret carries `timestamp` / `sign` in the body per Feishu's own algorithm;
164
- - **Message template** (optional) — Feishu / ServerChan / WxPusher / custom targets accept a template with `{{title}}` `{{body}}` `{{event}}` `{{time}}` `{{session}}`; empty means "title, newline, body";
165
- - **DingTalk is not supported** — its robot endpoint answers no CORS header at all and only accepts `application/json`, so a page can neither read the reply nor send a conforming request (under no-cors the content type is restricted to the safelisted ones). To reach DingTalk, relay it through a machine you control and point a custom webhook at that.
159
+ - **External notifications** (default off): forward each alert to your own push endpoints as well, so it reaches you away from the machine; any number of targets, each of which can be enabled or disabled on its own. "Forward 'needs you'" and "Forward 'reply finished'" switch independently; **Send test notification** sends one test message to every target (not limited by those two switches, and never pretending to be a real alert). Forwarding follows exactly the same trigger rules as local alerts ("stay quiet for the conversation on screen" and friends apply to it too); the alert text is sent as-is to the addresses you fill in, so **only use endpoints you trust**;
160
+ - **What each target takes**: Discord — the webhook URL; Bark — `https://api.day.app/<device key>`; Gotify — `https://<server>/message?token=<app token>`; Feishu — the group-bot webhook plus an optional verification secret; ServerChan — the full `…/send/<SendKey>.send` URL; WxPusher — the appToken and the receiver UID; a custom endpoint receives one POST of JSON: `{source, event, title, body, text, sessionId, time}`;
161
+ - **Reliable delivery**: failures that look transient (network error, 5xx, 429) are retried after 2 s and 6 s; anything else stops at once. A delivery that still fails is recorded in the failure log (10 entries at most — title and body snippet plus host only, never the full endpoint), which the settings page shows and can clear;
162
+ - **Per-target state**: the right end of each target row shows how its last delivery went (last delivery ok / sent-unverified / failed with the reason) next to a switch of its own, so an endpoint that is temporarily down can be turned off alone instead of muting a whole alert kind;
163
+ - **Signing**: a custom target with a secret sends `X-DSH-Notify-Signature` (HMAC-SHA256 over the body, `sha256=<hex>`); a Feishu target with a secret carries `timestamp` / `sign` in the body per Feishu's own algorithm;
164
+ - **Message template** (optional): Feishu / ServerChan / WxPusher / custom targets accept a template with `{{title}}` `{{body}}` `{{event}}` `{{time}}` `{{session}}`; empty means "title, newline, body";
165
+ - **DingTalk is not supported**: its robot endpoint answers no CORS header at all and only accepts `application/json`, so a page can neither read the reply nor send a conforming request (under no-cors the content type is restricted to the safelisted ones). To reach DingTalk, relay it through a machine you control and point a custom webhook at that.
166
166
 
167
167
  </details>
168
168
 
169
- ### Runtime & diagnostics
169
+ #### Runtime & diagnostics
170
170
 
171
- The last block inside Advanced. The page protocol is detected automatically to tell Web from Desktop (`dsh-app://`); override it by hand if detection is wrong — Desktop clicks go through the host route for native activation (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)) and carry no quick-decision buttons, while Web uses `window.focus()` plus the Service Worker bridge. When the override disagrees with detection the page says so.
171
+ The last block inside Advanced. The page protocol is detected automatically to tell Web from Desktop (DSH Desktop, `dsh-app://`); override it by hand if detection is wrong — on Desktop a toast click goes through the host route for native raising (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)) and carries no quick-decision buttons, while Web uses `window.focus()` plus the Service Worker bridge. When the manual override disagrees with detection the page says so.
172
172
 
173
173
  <details>
174
- <summary>Alert language, Desktop window raising, reset</summary>
174
+ <summary>Alert language, Desktop window raising, and reset</summary>
175
175
 
176
- - **Alert language**: follow the UI / Simplified Chinese / English — governs alert copy and the `🔔 …` title marker;
177
- - **How the Desktop window is raised**: `window.focus()` cannot lift a native Electron window, so since 1.14.0 the page POSTs to the host route `/__dsh-notify-me/focus-window` and the host runs one PowerShell pass to activate the window natively — measured raising it back from both minimized and covered (see the next section);
178
- - **Reset settings**: restores every switch and cue setting to the factory defaults (webhook targets and chosen audio files are kept).
176
+ - **Alert language**: follow the UI / Simplified Chinese / English — governs the alert copy and the `🔔 …` title marker;
177
+ - **Desktop window raising**: since 1.14.0 the host route does the native activation, and raising back a minimized or covered window is measured to work (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised));
178
+ - **Reset settings**: restores every switch and sound setting to the factory defaults (external push targets and chosen custom audio files are kept).
179
179
 
180
180
  </details>
181
181
 
182
- > The settings sidebar icon is rendered by the DSH settings shell: it hard-codes icons per section id (account / models / agent-presets / plugins / archived-sessions) and `settings.section` has no icon field. The plugin therefore patches that row after the panel renders: it finds its own entry by the localised label text, swaps the host `<svg>` for the brand bell (keeping the host icon class), and follows re-renders with a MutationObserver; if the host changes its markup the patch quietly gives up and the default gear stays.
183
-
184
- > Notification permission is required: click once on the page → **Allow** (or address-bar lock → Site settings → Notifications → Allow → reload). On macOS, also allow the browser under System Settings → Notifications.
185
-
186
- ## How it alerts
182
+ ## Alert channels
187
183
 
188
- - **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)
189
- - **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
190
- - **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
191
- - **External notifications** (optional, default off) — the same alert is also forwarded to the push targets you configure: Discord Webhook / Bark / Gotify / Feishu group bot / ServerChan / WxPusher / custom webhook — so the push channel on your phone picks it up while you step away from the machine
184
+ | Channel | What it does |
185
+ | --- | --- |
186
+ | System notification | Through the browser into the Windows action center / macOS Notification Center; clicking it returns to the conversation it came from. Web can carry Approve / Reject |
187
+ | Sound | WebAudio cue by default, with different patterns for "needs you" and "done"; swap in a custom audio file, or hand the sound over to the system notification sound |
188
+ | Tab title | `🔔 Action needed · …` / `✅ Reply finished · …`, following the alert language; gone once you open that session, and both markers can be up at the same time |
189
+ | External push (optional, default off) | Forwards the same alert to Discord / Bark / Gotify / Feishu / ServerChan / WxPusher / a custom webhook |
192
190
 
193
- This is a **browser-layer** plugin: the DSH page must stay open (minimized or backgrounded is fine — that's exactly the "away" state it watches for).
191
+ This is a **browser-layer** plugin: the DSH page has to stay open (minimized or backgrounded is fine — that is exactly the "away" state it watches for).
194
192
 
195
193
  ## Compatibility
196
194
 
197
- Per-release adaptation records live in [package.json](package.json), under `dsh.compatibility.dshReleases`. The current host (`0.2.1-rc.2`) no longer reads that field — installation gates only on `peerDependencies` and `engines` — so it is a human-readable record, not a gate.
198
-
199
- Verified to actually activate on `0.1.2-rc.1`, `0.1.5-rc.1`, `0.1.5-rc.2` and `0.2.0-rc.2` (each booted in its own profile: the plugin entry lands in the boot page's `__DSH_BOOT__` with its inject dependencies resolved, `/plugins/??dsh-notify-me/client.js&rev=…` serves a build containing the new code, and the Settings → Notifications section registers, i.e. the plugin's `apply` really runs), and previously on `0.1.1-rc.2`.
195
+ Per-release adaptation records live in [package.json](package.json) under `dsh.compatibility.dshReleases`; the current host (`0.2.1-rc.2`) gates installation on `peerDependencies` and `engines` only and no longer reads that field, so it is a human-readable record, not a gate.
200
196
 
201
- `0.2.1-rc.2` (current latest) got its own isolated run: an isolated `DSH_HOME` booting `dsh web` (70 boot entries) placed the plugin entry 59th, in the `application` batch, with all three inject targets present and ordered before it; `/plugins/??dsh-notify-me/client.js&rev=…` served the then-installed 1.7.0 source build (140,153 B); loaded in a headless Chromium page, `apply` ran — `window.__dshNotifyMe.debug()` reported `uiSession: bound`, `uiSessionSource: sessionStatus`, `bridge: active`. Host-side shapes match `0.2.0-rc.2`; no plugin code change was needed.
197
+ Every release was tested to **actually activate**: `0.1.1-rc.2`, `0.1.2-rc.1`, `0.1.5-rc.1`, `0.1.5-rc.2`, `0.2.0-rc.2` and `0.2.1-rc.2` (the criteria: the plugin entry lands in the boot page's `__DSH_BOOT__`, its inject dependencies resolve, `/plugins/??dsh-notify-me/client.js&rev=…` serves a build containing the new code, and the "Notify me" section appears in Settings). 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 `1.9.0` settings redesign added no new host API, and the bell in the settings page header is the plugin's own SVG (`docs/brand/`).
202
198
 
203
199
  <details>
204
- <summary>Host generations and adaptation diagnostics</summary>
200
+ <summary>Host-generation shape differences and two known traps</summary>
205
201
 
206
- The plugin picks its interaction source per host generation; the table below marks where each shape appeared — it is not a gate (the current host does not read `dshReleases`):
202
+ The plugin picks its interaction source per host generation; the table below marks the boundaries of each shape, not a gate:
207
203
 
208
204
  | Host generation | "Needs you" interaction source | Current session | Toast-click navigation |
209
205
  | --- | --- | --- | --- |
@@ -211,17 +207,17 @@ The plugin picks its interaction source per host generation; the table below mar
211
207
  | `0.1.2-alpha.2 .. 0.1.6-alpha.1` | `uiSession.pendingInteractions` | snapshot `current` | `sessions.open()` |
212
208
  | `>= 0.1.6-alpha.2` (incl. `0.2.0-rc.2`, `0.2.1-rc.2`) | `uiSession.sessionStatus` | `retainedBy.mainView` | `uiWorkspace.openSession()` |
213
209
 
214
- The ranges are illustrative: the plugin picks its source by store shape (whenever `sessionStatus` exists it takes the new path); the version numbers only mark where each shape appeared. The `peerDependencies` range in `package.json` shapes npm's peer warning — `0.2.1-rc.2` already falls inside `>=0.2.0-rc.1 <0.3.0-0`, so no exemption is needed; `dshReleases` is just an archive, real compatibility is per-release testing, and untested new versions fall back to shape-driven behaviour.
210
+ The ranges are illustrative: the plugin picks its source by store shape (whenever `sessionStatus` exists it takes the new path), and the version numbers only mark where each shape appeared. The `peerDependencies` range shapes npm's peer warning — `0.2.1-rc.2` already falls inside `>=0.2.0-rc.1 <0.3.0-0`, so no exemption is needed.
215
211
 
216
- Two traps are worth remembering: a package listed in `dsh.client.inject` that a newer runtime no longer ships parks the client entry at `pending (waiting for services: …)` forever — which is what broke 1.1.3 on `0.1.2-rc.1` and later; and when the host renames a store or a field nothing throws, alerts just go **silent** — `0.2.0-rc.2` moved `pendingInteractions`→`sessionStatus`, `list.current`→`retainedBy.mainView` and `sessions.open()`→`uiWorkspace.openSession()`. So every adaptation must confirm `window.__dshNotifyMe.debug()` reports `bound` and which source answered. `0.2.1-rc.2` changed neither: `sessionStatus` is still a Map of `{running, pendingInteraction, completionUnread}`, `uiWorkspace.openSession(id)` is unchanged, and the session list still has no `current`.
212
+ `0.2.1-rc.2` got a separate isolated run: an isolated `DSH_HOME` booting `dsh web` (70 boot entries) placed the plugin entry 59th, in the `application` batch, with all three inject targets present and ordered before it; `client.js` served the then-installed 1.7.0 source build (140,153 B); loaded in a headless Chromium page, `apply` ran normally and `debug()` reported `uiSession: bound`, `uiSessionSource: sessionStatus`, `bridge: active`. Host-side shapes match `0.2.0-rc.2`; no code change was needed.
217
213
 
218
- </details>
214
+ Two traps are worth remembering: a package listed in `dsh.client.inject` that a newer runtime no longer ships parks the client entry at `pending (waiting for services: …)` forever — which is exactly how 1.1.3 broke on `0.1.2-rc.1` and later; and when the host swaps a store or a field name nothing throws, alerts just go **silent** — `pendingInteractions`→`sessionStatus`, `list.current`→`retainedBy.mainView` and `sessions.open()`→`uiWorkspace.openSession()` were all this kind. So every adaptation has to confirm with `window.__dshNotifyMe.debug()` that it really reports `bound`.
219
215
 
220
- `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/`).
216
+ </details>
221
217
 
222
- ## Advanced: console configuration
218
+ ## Advanced console configuration
223
219
 
224
- The Settings page is the primary entry; preferences live in `localStorage` (key `dshNotifyMe.config`) and can be scripted from the console:
220
+ The Settings page is the first entry point; to script or bulk-edit preferences, or to reset them, the config lives in `localStorage` (`dshNotifyMe.config`) and can be adjusted live from the console:
225
221
 
226
222
  <details>
227
223
  <summary>Console API and storage reference</summary>
@@ -230,24 +226,24 @@ The Settings page is the primary entry; preferences live in `localStorage` (key
230
226
  window.__dshNotifyMe.config // view current config
231
227
  window.__dshNotifyMe.setConfig({
232
228
  enabled: true, // false = disable all reminders (master switch)
233
- language: "auto", // 'auto' follow the UI | 'zh' | 'en'
229
+ language: "auto", // 'auto' follow the UI | 'zh' Simplified Chinese | 'en' English
234
230
  attentionHiddenOnly: false, // true = don't alert "needs you" while the page is visible
235
- currentHiddenOnly: true, // true = waits in the conversation on screen keep only the tab marker (replayed once away)
231
+ currentHiddenOnly: true, // true = waits in the conversation on screen keep only the title marker (replayed once away)
236
232
  donePolicy: "background", // "reply finished" policy: 'background' only when away | 'smart' quiet only for the open one | 'always' always alert
237
- idleMinutes: 3, // minutes without pointer/keyboard input before a focused page counts as away (0 = off)
238
- autoDismiss: "present", // toast self-dismiss: 'present' keep while away | 'always' always close | 'never' never close
239
- ignoreSubagent: true, // true = never alert for background subagent sessions (the open one is exempt)
233
+ idleMinutes: 3, // minutes without keyboard/mouse input before "away" counts (0 = turn the idle rule off)
234
+ autoDismiss: "present", // toast auto-dismiss: 'present' keep while away | 'always' always close | 'never' never close
235
+ ignoreSubagent: true, // true = no alerts for background subagent sessions (the open one still alerts)
236
+ sound: true, // sound switch (also governs the "system notification sound" mode)
237
+ soundDone: "synth", // "reply finished" sound source: 'synth' built-in | 'custom' picked file | 'system' OS notification sound
238
+ soundAttention: "synth", // "needs you" sound source, same values
239
+ volume: 0.5, // volume 0..1
240
+ autoFocus: true, // a toast click returns to the conversation that raised it
240
241
  quickActions: true, // Approve / Reject buttons on approval toasts
241
- sound: true, // cue toggle (also governs the "system notification sound" mode)
242
- soundDone: "synth", // "reply finished" cue source: 'synth' built-in | 'custom' picked file | 'system' OS sound
243
- soundAttention: "synth", // "needs you" cue source, same values
244
- volume: 0.5, // 0..1
245
- autoFocus: true, // clicking a toast opens the conversation it came from
246
- externalEnabled: false, // external-notification forwarding master switch (default off: no endpoint, nothing is sent)
242
+ externalEnabled: false, // external forwarding master switch (default off: no endpoint, nothing is sent)
247
243
  externalAttention: true, // forward "needs you" (exactly the same trigger rules as local alerts)
248
244
  externalDone: true, // forward "reply finished"
249
245
  externalRetryDelays: [2000, 6000], // backoff for retryable failures (ms, with jitter; [] = send once)
250
- externalTargets: [] // push targets, see the shape below
246
+ externalTargets: [] // push targets, see the field notes below
251
247
  })
252
248
  // Target shape: {id, type, url, secret?, token?, uid?, template?, enabled?}
253
249
  // type: 'discord' | 'bark' | 'gotify' | 'feishu' | 'serverchan' | 'wxpusher' | 'custom'
@@ -259,67 +255,94 @@ window.__dshNotifyMe.decide("approval:3", "allowed-once") // programmatic decis
259
255
  window.__dshNotifyMe.testExternal() // explicit outbound test to valid targets; resolves {sent, unverified, failed, statuses}
260
256
  ```
261
257
 
262
- > **External-test switch behavior:** the settings page's "Send test notification" button requires both the plugin and external-forwarding master switches to be on, but ignores the two per-kind forwarding switches. The console `testExternal()` call is an explicit test: with valid configured targets, it sends to them even when those master switches are off; with no valid targets it sends nothing. Ordinary `test("done")` / `test("attention")` / `test("approval")` calls only test local alerts and never forward externally.
258
+ > **External-test switch boundary:** the settings page's "Send test notification" needs the "Enable reminders" and "External notifications" master switches on, but ignores the two per-kind forwarding switches. The console `testExternal()` call is an explicit test: with valid configured targets it sends to them even when those master switches are off; with no valid targets it sends nothing. Ordinary `test("done")` / `test("attention")` / `test("approval")` calls only test local alerts and never forward externally.
263
259
 
264
- > Picked audio files do not live in `dshNotifyMe.config`: each kind gets its own key (`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`, holding `{name, size, data}` with a data URL), because a file inflates by ~1.37x once base64-encoded and would slow down every config read. `resetConfig()` clears both keys along with the config.
260
+ > Picked audio files do not live in `dshNotifyMe.config`: each kind gets its own key (`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`, holding a `{name, size, data}` data URL), because one file inflates by ~1.37× once turned into a data URL and would slow down every config read. `resetConfig()` clears both keys along with the config.
265
261
  >
266
- > To see which source is live, check `soundDone` / `soundAttention` and `customSoundDone` / `customSoundAttention` in `window.__dshNotifyMe.debug()`.
262
+ > To confirm which source is actually in effect, check `soundDone` / `soundAttention` and `customSoundDone` / `customSoundAttention` in `window.__dshNotifyMe.debug()`.
267
263
 
268
264
  </details>
269
265
 
270
266
  ## How it works
271
267
 
272
- The reminder core watches session state and pending interactions. The page handles approval decisions; Desktop window activation uses a host route plus native Win32 calls.
268
+ The reminder core watches session state and pending interactions; approval decisions happen in the page, and Desktop window raising goes through a host route plus native Win32 activation.
273
269
 
274
270
  <details>
275
- <summary>Event sources, approval bridge, and Desktop window activation</summary>
271
+ <summary>Event sources and graceful degradation</summary>
276
272
 
277
273
  The reminder core picks its interaction source per host generation (see the table above); the current session always comes from the client `sessions` service:
278
274
 
279
- - the **selected session's** `SessionSnapshot`: a `running: true → false` edge means a reply finished;
280
- - **pending interactions**: a new key in `uiSession.sessionStatus` (`>= 0.1.6`, per-session `pendingInteraction`) or in `pendingInteractions` (`0.1.2 .. 0.1.5`) means the agent is waiting on you — approval requests carry the tool name and reason, questions their text; older hosts fall back to the controller snapshot's `pending[]`;
281
- - **every other listed session's** summary: a `running: true → false` edge (or the legacy `completed` flag) alerts you about background work. The host's completion-unread level flag backs that edge up and owns the title marker: `sessionStatus` rows' `completionUnread` on `>= 0.1.6` (lit when a background session finishes, cleared when it is opened / re-runs / disappears), the list row's `completed` reminder bit on older hosts (cleared on select). It re-reports a completion whose edge this page never saw (a plugin remount mid-turn, coalesced snapshots) exactly once, and when both views of one completion arrive together a short window collapses them into a single alert.
282
- - **subagent sessions**: **background** rows carrying `origin: 'subagent'` (and a `parentId`) stay muted by default. The subagent session you have open is exempt — its alerts follow the normal current-conversation rules (tab marker only while the page is visible, delivered on background), its outstanding waits are picked up when you switch into it, and switching away mutes them again. A fork has a `parentId` but no `origin`, is your own conversation, and keeps alerting throughout.
275
+ - **The current session's** `SessionSnapshot`: a `running: true → false` edge means a reply finished;
276
+ - **pending interactions**: a new key in `uiSession`'s `sessionStatus` (`>= 0.1.6`, per-session `pendingInteraction`) or in `pendingInteractions` (`0.1.2 .. 0.1.5`) means the agent is waiting on you — the container carries the approval tool name / reason or the question text; older hosts fall back to the controller snapshot's `pending[]`;
277
+ - **Every other listed session's** summary: a `running: true → false` edge (or the legacy `completed` edge) alerts you about background work. The host's completion-unread level flag covers gaps and owns the title marker: `completionUnread` on `sessionStatus` rows on `>= 0.1.6` (lit when a background session finishes, cleared when it is opened / starts running again / disappears), the list row's `completed` reminder bit on older hosts (cleared on select). It re-reports a completion edge the page never saw (a plugin hot reload mid-turn, a snapshot merge that jumps) exactly once, and when the edge and the level flag arrive together a short window collapses them into a single alert;
278
+ - **Subagent sessions**: **background** rows carrying `origin: 'subagent'` (and a `parentId`) are muted by default; the subagent session you have open is not muted — its alerts follow the normal current-conversation rules (tab marker only while the page is visible by default, delivered once backgrounded), outstanding waits are caught up when you switch in, and it goes back to muted when you switch away. A fork has a `parentId` but no `origin`, is your own conversation, and keeps alerting as usual.
283
279
 
284
- The reminder core stays dependency-free and self-contained: notification copy resolves at alert time from the chosen language (follow-interface / Simplified Chinese / English). The Settings page is an **optional** React surface — it only registers into Settings when the web profile provides the `slots` / `locale` services and `react`; without them the plugin degrades gracefully to alerts-only (no Settings page).
280
+ The reminder core still has zero third-party runtime dependencies and is fully self-contained: notification copy resolves at alert time from the chosen language (follow the interface / Simplified Chinese / English). The Settings page is an **optional** React surface — it only registers into Settings → Notify me when the DSH web profile provides `slots` / `locale` / `react`; missing any of them, the plugin degrades gracefully to alerts-only (no Settings page), with no functional loss.
281
+
282
+ </details>
285
283
 
286
284
  ### Quick decisions, and how they reach the agent
287
285
 
288
- 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.
286
+ Notifications with Approve / Reject buttons can only be raised by a Service Worker (passing `actions` to `new Notification()` throws a `TypeError` outright), so the same bundle file is registered a second time as a worker whose only job is relaying which button was clicked back to the page. **The decision still happens in the page**, through the same `PendingApproval.answer('allowed-once' | 'rejected')` the approval panel uses for Allow once / Reject — so "Approve" is exactly "Allow once" and releases nothing extra.
289
287
 
290
- 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.
288
+ <details>
289
+ <summary>Button-toast bookkeeping and window raising</summary>
290
+
291
+ Each button toast is booked against its interaction key, and one toast claims only one wait; once the request has been answered in the page, or replaced by a newer request, clicking the leftover buttons decides nothing and just closes it quietly. Button toasts stay 12 s (plain ones 5 s) and are withdrawn on their own; if you miss the click, the approval in the conversation is still waiting for you.
292
+
293
+ The "raise a window and switch its conversation" part of a body click is scheduled on the worker half as well: `client.focus()` raises exactly one window (a visible one first) and routes the conversation switch to that window alone, so several DSH tabs never fight over focus or all switch at once.
294
+
295
+ </details>
291
296
 
292
297
  ### How the Desktop window gets raised
293
298
 
294
- DSH Desktop is a thin Electron shell and its page lives under `dsh-app://app`: DOM `window.focus()` only touches the browsing-context window, never the native one, and the shell's `will-navigate` handler blocks the page from firing a `dsh:` URL. From 1.5.2 through 1.13.0 the plugin sidestepped that with a hidden iframe navigating to `dsh://open`, relying on the app's single-instance yield to focus the window second-hand. Isolated retests on 0.2.1-rc.2 failed 3/3, so 1.14.0 removes that path and goes through the plugin's own host route instead.
299
+ DSH Desktop is a thin Electron shell and its page lives under `dsh-app://app`: DOM `window.focus()` only moves the browsing-context window and never touches the native one, and the shell's `will-navigate` closes the usual page-side exit of firing a `dsh:` URL. The route now is the plugin's own host route — the page POSTs `/__dsh-notify-me/focus-window`, the host spawns `powershell.exe` to run `lib/focus-window.ps1`, which enumerates native windows and activates one adaptively (the `dsh://open` deep link used from 1.5.2 through 1.13.0 retested 3/3 ineffective on `0.2.1-rc.2`, and 1.14.0 deletes it entirely).
300
+
301
+ <details>
302
+ <summary>The route, the raise order, the measurements, and the security gate</summary>
303
+
304
+ The shell forwards every path under `dsh-app://app` other than static assets straight to the host HTTP server (with an origin check and the host cookie injected), so that POST is a same-origin request with no CORS involved; the host half (`lib/index.js`) already runs as a child of the Electron main process, so `process.ppid` is the browser process that owns the main window. On a toast click: the page POSTs → the host spawns one `powershell.exe` running `lib/focus-window.ps1 -ProcessId <ppid>` → the script enumerates that pid's visible, owner-less, non-toolwindow top-level windows (preferring class `Chrome_WidgetWin_1`, then the largest area from `GetWindowRect`) → activates adaptively → compares `GetForegroundWindow()` and prints `FOCUSED=1|0` → the host answers `200` / `202`.
295
305
 
296
- For every non-asset path under `dsh-app://app` the shell forwards the request to the host HTTP server (with an origin check and the host cookie injected), so a page POST to `/__dsh-notify-me/focus-window` is a same-origin request with no CORS involved. The host half (`lib/index.js`) already runs as a child of the Electron main process, and its `process.ppid` is that of the window's browser process. On a toast click the page POSTs, the host spawns one `powershell.exe` running `lib/focus-window.ps1 -ProcessId <ppid>`, the script enumerates the visible, owner-less, non-toolwindow top-level windows of that pid (preferring class `Chrome_WidgetWin_1`, then the largest area from `GetWindowRect`), raises it, compares `GetForegroundWindow()` and prints `FOCUSED=1|0`, and the host answers `200` / `202`.
306
+ The raise order is adaptive: already in front means do nothing (the common case, zero flicker); minimized means `SW_RESTORE` first; then a synthetic Alt tap (`keybd_event` down/up) grants the calling thread foreground permission before `SetForegroundWindow`; only if all of that fails does it fall back to `SW_MINIMIZE` + `SW_RESTORE`. `SetForegroundWindow` on its own gets refused by the foreground lock — per call, unrelated to `ForegroundLockTimeout`. That also re-reads why the 1.5.2 deep-link approach was "sometimes it works": the times the window did come forward were most likely the OS activating the app on the click itself.
297
307
 
298
- The raise order is adaptive and comes from the five-method table measured in [discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913): already in front means do nothing (the common case, no flicker); minimized means `SW_RESTORE` first; then a synthetic Alt tap (`keybd_event` down/up) grants the calling thread foreground permission before `SetForegroundWindow`; only if all of that fails does it fall back to `SW_MINIMIZE` + `SW_RESTORE`. `SetForegroundWindow` on its own gets refused by the foreground lock — per call, unrelated to `ForegroundLockTimeout`. That also re-reads the old "sometimes it works" behaviour: the times the window did come forward were most likely the OS activating the app on the click itself.
308
+ Measured with `powershell.exe` 5.1 in one round: window in front ×3, minimized with Notepad in front ×3, visible but covered ×3 — all `FOCUSED=1` with the foreground comparison matching and every `IsIconic` flipped back from `true` to `false`; Notepad maximized covering it completely, minimized plus fully covered, and `-ProcessId 0` auto-detection all succeeded too. Script-internal time peaked at 434 ms, process start to exit at 782 ms, well inside the 8 s cap; `pwsh` 7.6 behaved the same in a control round, only ~100 ms slower compiling the `Add-Type` helpers (the script deliberately avoids PowerShell 7 syntax). A pid that does not exist and a pid with no visible window both settle on `FOCUSED=0` with `exit 0`.
299
309
 
300
- Measured with `powershell.exe` 5.1 in one round: window in front ×3, minimized with Notepad in front ×3, visible but covered ×3 — all `FOCUSED=1` with the foreground comparison matching and every `IsIconic` flipped back to `false`; Notepad maximized, minimized plus maximized, and `-ProcessId 0` auto-detection all succeeded too. Script-internal time peaked at 434 ms, process wall time at 782 ms, well inside the 8 s cap; `pwsh` 7.6 behaved identically, only slower by ~100 ms compiling the `Add-Type` helpers (the script avoids PowerShell 7 syntax on purpose). A pid that does not exist and a pid with no visible window both settle on `FOCUSED=0` and `exit 0`.
310
+ Security-wise the plugin opens exactly one exact route. Requests outside the vocabulary are refused: anything other than GET / HEAD / POST answers `405` (including a preflight `OPTIONS`, and it never returns a CORS header), a non-Desktop host (Web, macOS / Linux, plain Node) `501`, and a missing `x-dsh-notify-me-focus: focus` header `403`.
301
311
 
302
- The route is a single exact path and says no by default: non-POST answers `405`, a non-Desktop host (Web, macOS / Linux, plain Node) `501`, a missing `x-dsh-notify-me-focus: focus` header `403`. A cross-origin page would have to pass a preflight to send that header and the route allows no CORS at all, so other sites in the browser cannot drive it. A `GET` returns `{ok, route, enabled, desktop, platform}` so you can check the route from a browser after installing. Calls within 400 ms coalesce into one, and a script that has not answered after 8 s is killed and counted as a failure.
312
+ That header is only a "this came from the page" marker, not the defence — a DNS-rebinding page is same-origin with the address it resolved, so it can send any custom header and triggers no preflight. The real gate is three checks: the peer address must be loopback; `Host` must be a loopback name (`127.0.0.1` / `localhost` / `[::1]`, port optional), because the shell deletes `host` when forwarding and fetch refills it from the loopback URL, while a rebound page still has the attacker's domain in `Host`; and an `Origin`, when present, must be same-origin with that `Host`. Any failure answers `403`, with `reason` set to `bad-remote` / `bad-host` / `cross-origin`. A `GET` returns only `{ok, route, enabled, desktop, platform}`, so after installing you can open it in a browser to confirm the route is there. Calls within 400 ms coalesce into one, and a script that has not answered after 8 s is killed and counted as a failure.
303
313
 
304
- A few cases still will not raise, and they answer `202` with the conversation switch intact: a target window owned by a higher integrity level (elevated / UAC), a window on another virtual desktop, or a foreground that keeps getting stolen. A tray-hidden window (`SW_HIDE` rather than minimized) is filtered out as well; DSH does not do that today.
314
+ The cases where it will not raise are the same ones listed under [Known limitations](#known-limitations) (elevated window, another virtual desktop, a foreground repeatedly stolen, a window hidden to the tray with `SW_HIDE`); they all answer `202` truthfully and the conversation switch still happens.
315
+
316
+ </details>
305
317
 
306
318
  ## Known limitations
307
319
 
308
- - The page must be open for alerts to fire (background tab / minimized is fine; closing the tab stops it — that's inherent to a browser-layer plugin).
309
- - Notifications appear through the browser, so the browser needs "show notifications" permission in the OS notification settings, and notification-center "Do Not Disturb" must not suppress them.
310
- - The first sound/toast of each page load needs one user click on the page first (browser autoplay + permission policy).
311
- - Toasts are only produced once the notification permission is granted; declining means sound + title marker only.
312
- - On the Desktop shell (DSH Desktop, Windows) a toast click can still fail to raise the window: a target owned by a higher integrity level (elevated / UAC), a window on another virtual desktop, a foreground that keeps getting stolen, or a window hidden with `SW_HIDE` to the tray all end in `FOCUSED=0` and a `202` from the route, with the conversation switch intact (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)).
313
- - 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).
314
- - 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** (the shell does not grant the `allowServiceWorkers` privilege), 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.
315
- - 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.
316
- - Config lives in browser `localStorage`: switching browsers/devices or clearing site data returns to defaults (one-click restore in the Settings page).
317
- - On `>= 0.1.6` (including `0.2.0-rc.2`, `0.2.1-rc.2`) the "reply finished" toast body carries only the session label, no assistant snippet: host snapshots no longer expose `nodes`. The alert itself still fires.
318
- - The title marker is written into `document.title` alongside the host: when the host recomputes the title it can overwrite the marker until the next alert event rewrites it.
319
- - "Stay quiet for the conversation on screen" is judged by whether the window has focus: a window covered by another app, a switched-away app, or a minimized one all count as background, so waits in the current conversation alert there too; once the window is back in front, or you touch the keyboard / mouse again (no longer idle), it goes quiet again.
320
- - External notification forwarding is subject to browser CORS rules: an endpoint that does not allow cross-origin requests falls back to a "sent but unverifiable" delivery (no-cors) and is reported honestly as such; Discord, Feishu, ServerChan, and WxPusher all allow cross-origin, so delivery to them is confirmed. DingTalk allows no CORS header at all and only accepts `application/json`, so a page cannot post to it — hence no DingTalk target type (relay it through your own box and use a custom webhook). A failure that looks transient (network error, 5xx, 429) is retried on the backoff; a delivery that still fails lands in the failure log, and anything else stops at once. Forwarding runs only after the local alert has decided to fire, so anything the rules silence is never sent out.
321
-
322
- - **The settings sidebar glyph relies on a DOM patch**: the shell hard-codes icons per section id and `settings.section` exposes no icon field, so the plugin can only swap the `<svg>` in its own row after the panel renders. It depends on the current panel structure (`data-shortcut-modal="settings"` plus a label `<span>` inside each nav button); if DSH changes that markup the patch silently stops working and the default gear shows instead — nothing else is affected.
320
+ **Runtime prerequisites**
321
+
322
+ - The page must be open for alerts to fire (background tab / minimized is fine; closing the tab stops it — inherent to a browser-layer plugin).
323
+ - Notifications come through the browser, so notification permission must be granted; the first sound / toast after each page load needs one click on the page first; without permission only the sound and the title marker are left.
324
+ - System notification banners are drawn on the primary display only: Windows has no setting and Chromium no API to aim one at another monitor (on a multi-monitor setup the second screen never gets them). The only way across screens is external push to your phone (since 1.12.0 the page no longer draws an approval card).
325
+ - Config lives in browser `localStorage`: switching browsers / devices or clearing site data returns to the defaults (the Settings page restores them in one click).
326
+
327
+ **Platform differences**
328
+
329
+ - Quick-decision buttons need a Service Worker: `http://127.0.0.1` and `https` work, `http://192.168.x.x`-style LAN addresses do not; **the Desktop (`dsh-app://`) is tested and cannot register a Service Worker** (the host does not grant the `allowServiceWorkers` privilege), so approval toasts fall back to their buttonless form. When the host renders no notification buttons, the `bridge` field of `debug()` explains why, and nothing else is affected.
330
+ - On Desktop (Windows) a toast click can still fail to raise the window: when the target window is owned by a higher integrity level (elevated / UAC), sits on another virtual desktop, has its foreground repeatedly stolen by another process, or is hidden to the tray with `SW_HIDE`, the host script answers `FOCUSED=0` and the route answers `202`, with the conversation switch intact (see [How the Desktop window gets raised](#how-the-desktop-window-gets-raised)).
331
+ - macOS: alerts, sound, the title marker and toast-click navigation carry no platform assumptions and work as usual; but DSH Desktop (Electron) system notifications require the app to be signed — an unsigned build simply emits no notification — and whether the official macOS build meets that precondition is untested; notification button rendering on the macOS desktop host is likewise untested (the Windows desktop host is tested and renders none).
332
+
333
+ **Alert content and timing**
334
+
335
+ - On `>= 0.1.6` (including `0.2.0-rc.2`, `0.2.1-rc.2`) the "reply finished" alert body carries only the session name and no reply snippet any more — host snapshots no longer provide `nodes`; the alert itself still fires as usual.
336
+ - The title marker is written into `document.title` alongside the host: when the host recomputes the title the marker can be overwritten until the next alert event writes it again.
337
+ - "Stay quiet for the conversation on screen" is judged by whether the window is in front: covered by another app, switched to another app, or minimized all count as background; back in front or with fresh keyboard / mouse input it goes quiet again.
338
+
339
+ **External push**
340
+
341
+ - Bounded by browser CORS: an endpoint that does not allow cross-origin degrades to "sent but unverifiable" (no-cors) and is labelled as such; Discord, Feishu, ServerChan and WxPusher do allow cross-origin, so delivery to them is confirmed. DingTalk allows no CORS header at all and only accepts `application/json`, so a page cannot send to it — hence no DingTalk target type (relay it through your own box and use a custom target). A transient failure (network error, 5xx, 429) is retried on the backoff, and a delivery that still fails lands in the failure log; anything else stops at once, no retry. Forwarding is triggered only after the local alert has decided to fire, so an alert muted by the rules is never sent out either.
342
+
343
+ **Settings icon patch**
344
+
345
+ - The sidebar row icon relies on a DOM patch: the shell hard-codes an icon per section id and `settings.section` has no icon field, so the plugin can only swap that row's `<svg>` for a bell after the panel renders. It hinges on the host's current panel structure (`data-shortcut-modal="settings"` plus a label `<span>` inside the nav button); if DSH changes that structure it silently falls back to the default gear and nothing else is affected. The logo and naming decisions are in [docs/brand/README.md](docs/brand/README.md).
323
346
 
324
347
  ## Development
325
348
 
@@ -336,18 +359,17 @@ npm pack --dry-run # preview the published tarball
336
359
 
337
360
  ## Credits & how contributions land
338
361
 
339
- Contributors (by handle; the link points at the PR / issue they worked on):
340
-
341
- - [@AgMahone](https://github.com/AgMahone) — [#7](https://github.com/chromoany/dsh-notify-me/pull/7): the 0.1.6+ / 0.2.0 host-generation support, plus the double-chime and cordis-lookup reports and retesting;
342
- - [@d0ublecl1ck](https://github.com/d0ublecl1ck) — [#8](https://github.com/chromoany/dsh-notify-me/pull/8): muting subagent sessions (`ignoreSubagent`) with its tests and docs; the 1.4.0 refinement — mute only background subagents, alert as usual for the one you have open — is his suggestion from the #8 discussion, landed by the maintainer;
343
- - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9): the layer-by-layer investigation and three rounds of real-shell retesting of the desktop raise path (1.5.2) — confirming that a toast click raises the window and switches conversations reliably, and pinning down the reproduction difference ("gesture-less scripted injection is silently dropped by Chromium"), which corrected the initial "the sandbox blocks it" reading;
344
- - [@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.
345
- - [@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).
346
- - [@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).
347
- - [@yuexian7](https://github.com/yuexian7) — [#17](https://github.com/chromoany/dsh-notify-me/issues/17): the ask for alerts on an Android phone — Discord is awkward in mainland China and Bark has no Android app, so a self-hosted relay was the only option; 1.13.0's Feishu / ServerChan / WxPusher targets, retries, and failure log came out of it.
348
- - [@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.
362
+ | Contributor | Contribution |
363
+ |:---:|:---|
364
+ | <a href="https://github.com/AgMahone"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/AgMahone.png" width="40" height="40" alt="@AgMahone"></a> | [#7](https://github.com/chromoany/dsh-notify-me/pull/7): the 0.1.6+ / 0.2.0 host-generation compatibility reports and retesting |
365
+ | <a href="https://github.com/d0ublecl1ck"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/d0ublecl1ck.png" width="40" height="40" alt="@d0ublecl1ck"></a> | [#8](https://github.com/chromoany/dsh-notify-me/pull/8): muting subagent sessions (`ignoreSubagent`), with tests and docs |
366
+ | <a href="https://github.com/EliteOtaku"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/EliteOtaku.png" width="40" height="40" alt="@EliteOtaku"></a> | [#9](https://github.com/chromoany/dsh-notify-me/issues/9): the layer-by-layer investigation of the desktop raise path, retested in a real shell |
367
+ | <a href="https://github.com/Wh1stle05"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/Wh1stle05.png" width="40" height="40" alt="@Wh1stle05"></a> | [#10](https://github.com/chromoany/dsh-notify-me/pull/10): per-kind sound sources (built-in cue / custom audio file / system notification sound) |
368
+ | <a href="https://github.com/yanzhaohui1999"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/yanzhaohui1999.png" width="40" height="40" alt="@yanzhaohui1999"></a> | [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14): the idle-page CPU report and fix for busy session lists |
369
+ | <a href="https://github.com/RiZhiZhaoYi"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/RiZhiZhaoYi.png" width="40" height="40" alt="@RiZhiZhaoYi"></a> | [#12](https://github.com/chromoany/dsh-notify-me/issues/12): external notification forwarding (Discord / Bark / Gotify / custom webhook) |
370
+ | <a href="https://github.com/yuexian7"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/yuexian7.png" width="40" height="40" alt="@yuexian7"></a> | [#17](https://github.com/chromoany/dsh-notify-me/issues/17): the ask for Android delivery, which led to the Feishu / ServerChan / WxPusher targets |
371
+ | <a href="https://github.com/shrekcg"><img src="https://raw.githubusercontent.com/chromoany/dsh-notify-me/main/docs/contributors/shrekcg.png" width="40" height="40" alt="@shrekcg"></a> | [#16](https://github.com/chromoany/dsh-notify-me/pull/16): away detection (`idleMinutes`), reply-done routing (`donePolicy`), auto-dismiss (`autoDismiss`) |
349
372
 
350
- - [@xinming-chenniang](https://github.com/xinming-chenniang) — [deepseek-harness discussions #10446](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19143913): the five-method Win32 raise comparison (`SetForegroundWindow` alone refused by the foreground lock, accepted after a synthetic Alt tap, minimize→restore working in both scenarios), plus a working proof of the "host-registered same-origin route + PowerShell fallback chain" detour; the direct basis for 1.14.0's host route and `lib/focus-window.ps1`.
351
373
  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.
352
374
 
353
375
  ## License