# @wingsky-1/dsh-notifier
[![npm](https://img.shields.io/npm/v/@wingsky-1/dsh-notifier)](https://www.npmjs.com/package/@wingsky-1/dsh-notifier)
[![GitHub Releases](https://img.shields.io/github/v/release/wingsky-1/dsh-plugin-hub)](https://github.com/wingsky-1/dsh-plugin-hub/releases)

Notifications for approval / completion / error events: get alerted even when you are away from the browser.

[简体中文](README.md) | **English**

## Quick install

```sh
dsh plugin --profile web add @wingsky-1/dsh-notifier
```

> After install / uninstall / update, **restart `dsh web` once** (bundle layers are only composed at startup) for changes to take effect.

## Quick navigation

[Before you start](#before-you-start) · [Deployment and access](#deployment--how-to-access-important) · [Quick start](#quick-start) · [Common configuration](#common-configuration) · [Verification and troubleshooting](#verification-and-troubleshooting) · [Detailed reference](#detailed-reference) · [Development and architecture](#development-and-architecture)

<a id="before-you-start"></a><a id="user-content-before-you-start"></a>
## Before you start

Prerequisite: DeepSeek Harness installed and `dsh web` running normally (for running dsh
without a global install, see "Without a global dsh install" below).

<a id="deployment--how-to-access-important"></a><a id="user-content-deployment--how-to-access-important"></a>
## Deployment & how to access (important)

All plugin endpoints are protected by a **loopback fence**: only calls originating from the local
loopback interface (`127.0.0.1` / `localhost`) are accepted. Therefore, when you access
`http://<server IP>:3080` directly from a LAN browser, `/api/dsh-notifier/*` always returns
403 and the notification channels do not work — this is the expected behavior of the security
guardrail, not a plugin fault; the page will show a guidance hint.

Pick one of the following access forms (both the settings card and the README surface a hint for each):

| Form | How to access | Notes |
|---|---|---|
| Local desktop | `http://127.0.0.1:3080` | Secure context: both browser notifications and system notifications work |
| LAN HTTPS (recommended) | `https://<server IP>:3443` (dsh-lan-proxy) | Satisfies loopback check via proxy + secure context; on mobile, "Add to Home Screen" enables PWA-grade notifications |
| Tunnel | After `ssh -L 3080:127.0.0.1:3080 <server>`, visit the local address | Loopback + secure context, same effect as local |

> Verified (2026-08): `https://<IP>:3443/api/dsh-notifier/health` returns 200,
> SSE long connection (`/api/dsh-notifier/events`) delivers its first frame normally via 3443.

<a id="security--boundaries"></a><a id="user-content-security--boundaries"></a>
## Security model

- Notification text only contains metadata such as task title / tool name / request reason — **never tool parameters** (prevents sensitive info leakage)
- **Notification body and title are no longer masked (#733 convergence)**: the old `sanitizeContent` rule table (paths / PEM private keys / connection-string credentials / tokens / emails …) has been deleted — notifications, history writes (including suppressed entries) and delivery all carry the original text; the body is not truncated here, and length is capped by each delivery channel's display limit. Deployments that need "a given kind of text never appears in logs" must handle it at the event source
- **The only remaining credential masking is in the settings view**: channel credentials in `GET /config`'s `user` + `effective` and in `PUT` success responses (bark `deviceKey`, webhook `token` / `password` / `headerValue`) are always masked as `********`; submitting the full mask = keep the original value (backfilled aligned by instance id so a reordering never swaps credentials between instances); **a mask with no stored value to restore (a new instance, or a cross-type mask left over from retyping) returns 400 — the write path and the dry-run share that one sentence, and a placeholder never reaches disk** (`CHANNEL_SECRET_FIELDS` is the per-channel-type single source of truth)
- **Outbound error reasons no longer replace credential literals (measured risk, documented as-is)**: Bark 4xx response bodies echo the device key, and webhook non-2xx response bodies echo the credentials they received — failure reasons are only truncated (webhook response body 200 chars; status entry 300 chars), with **no guarantee that credentials stay out of the error text**. Those texts live in the reason's `detail` field (reasons are structured as of 0.2.4, see "Delivery reliability"), and go to server logs, the status file (`status.json`) and the notification history (`history.jsonl`), reaching the settings page via `GET /status` and `GET /history`; deployments sensitive to error-text exposure should act on the bullet above
- **Channel identity (`id`) is unique, cross-type included (#1016)**: two channels sharing one identity is not "a duplicate row in the UI" but **credential destruction** — the settings page submits `channels` as one whole group, the merge takes the first entry by bare `id` and lets the remaining keys inherit that entry's values, so the second entry's real credentials (bark `deviceKey`, webhook credentials) are overwritten by the first — **one credential destroyed per save**. A cross-type duplicate `id` is worse: mask restoration looks up by bare `id`, so a bark written with `id: "browser"` hits the built-in browser entry and restores its mask onto the bark's keys. The write path therefore returns an **unconditional 400** for a duplicate identity (the message carries 1-based indices and says to delete one of them); the upgrade step **no longer** silently de-duplicates by identity (that would delete the second entry, credentials and all, at upgrade time); the delivery projection takes the first entry per identity — which keeps one notification from going out twice, at the price of **the entry that loses no longer being delivered at all**, and of the identity being claimed by **whichever entry comes first in the array, not by whichever one works**: a half-broken entry whose required delivery key is an empty string claims the identity first and pushes a later healthy entry with the same `id` out of the delivery pool (the half-broken entry itself is dropped from that pool anyway). **No path hides or deletes your data**: the view is forwarded verbatim, and deleting a card and saving goes through
- Server-side internal errors still return fixed wording (root causes only go to server logs)
- System notification failures are no longer silent: when a channel **executed an action and it failed**, the status row is written as `failed` and the per-channel detail appears in the notification history; when **no command can be constructed at all**, the outcome is `skipped` plus exactly one warn (as of 0.2.4 linux / darwin emit that log too — previously only the win32 branch did). A missing / non-executable native binary (ENOENT etc.) is caught by the `error` event and **never bubbles up as an unhandled error that crashes the host process** (see issue #1)
- **The two channels are delivered to different machines (don't confuse them)**:
  - **Browser notifications** are pushed to **the browser client you are actually using** (your Mac / phone both count), and pop a native notification via the browser's Notification API; they require permission and by default only pop when the page is hidden (the settings card can enable "also when visible"). No matter which machine dsh web runs on, as long as browser notifications are allowed you receive them on your own Mac.
  - **System notifications (host toast)** are popped on the desktop of **the machine dsh web runs on**: if dsh web runs on a Linux server (headless, no desktop session) or some other machine, the toast appears on **that server**, not your Mac — see `/diagnostics` for system-channel availability and the settings card for browser-channel availability. To also get the system toast on your Mac, run dsh web directly on your Mac (it then uses macOS `osascript`); macOS has no `notify-send`, and the system notification is already implemented via `osascript` (zero dependencies, nothing to install)
- **iOS difference**: Safari's normal tabs have no Web Notifications API (only the "Add to Home Screen" PWA does); on iOS the available channels are "in-page banner + sound when the page is visible" and system notifications after HTTPS + A2HS
- **The capability self-check (as of 0.2.4) exposes a partial fingerprint of the host's software stack, and it is visible to the LAN once forwarded through `dsh-lan-proxy`**: `capabilities.host.sound.players` on `/diagnostics` lists the matched player executable names (in fallback-chain order, **all** matches: `paplay` → `pw-play` → `aplay` → `ffplay`; always `afplay` on darwin), and the `checked` arrays reveal whether `notify-send` is installed; `/health` carries only a **summary** (verdict / unknownDimensions / popup.state / sound.state, no players/checked details). This is a **deliberate trade-off** — users cannot act on "the host cannot play sound" unless they can see it — but be aware of what it means combined with lan-proxy's existing posture (that plugin's README states that requests forwarded through it are considered trusted by design). **Containment**: executable names only, tone-file availability as a boolean, **never absolute paths**, and `remediation` `params` come solely from an internal data table (never passed through input), so no `/etc/os-release` content or raw command output can appear in a response
- **Probing has no side effects**: the capability self-check issues only two read-only queries to `org.freedesktop.DBus` (`NameHasOwner` and `ListActivatableNames`) and **never triggers service activation** (no `busctl status`/`list`, no `StartServiceByName`); on `darwin`/`win32` that child process is not even started
- **Temporary audio files (as of 0.2.4)**: on Linux, when the themed event sound is missing, self-play creates a 0700 per-instance directory under the system temp directory and writes a 0600 WAV opened with `wx` (`wx` refuses pre-existing paths and symlinks); the file is deleted as soon as playback ends — only **this** delivery's file, so a concurrent delivery is unaffected — while the directory is removed when the process exits / the plugin unloads. On a read-only `/tmp` nothing is staged: a sound-only delivery is recorded as `skipped` (`reasonSystemToneUnwritable`), while popup+sound still counts as `ok` with one warn (the popup already went out, so sound is best-effort)
- **On-disk permissions and write order (#1016)**: neither the plugin-private directory `<DSH_HOME>/@wingsky-1/dsh-notifier/` nor the data files in it (`config.json`, `history.jsonl`, `status.json`, `seq.json`, `version`) drift with the umask — a directory this plugin creates lands `0700`, and each file is written as a `0600` temporary name in the same directory that is then `rename`d over the target (`config.json` and `status.json` carry the bark device key, webhook credentials and failure reasons that may echo credentials). **Pre-existing directories are tightened to `mode & 0700`**: only the owner bits are kept, exactly as they are, so no permission bit is ever added. An older `0755`/`0777` directory is therefore pulled back to `0700`, while a directory an operator deliberately made read-only becomes `0500` and **stays read-only** — the plugin never re-opens it for writing. **The mask takes the special bits with it**: this plugin goes through the syscall-level `chmod` (Node `fs.chmod`), which sets only the bits present in its mode argument and clears every bit absent from it (coreutils' `chmod(1)` deliberately keeps setuid/setgid on directories — that is not what is meant here), so setuid/setgid/sticky outside the owner triple are cleared too — measured on the very same formula, `2770`, `1777`, `4755` and `7777` all end up `0700`, and the setgid of a team-shared `2770` is silently dropped (a hardening direction: those bits carry no practical meaning on a private data directory, but it is a **registered behavior change**). On a directory carrying a POSIX extended ACL the ACL mask is tightened to `0` as well, so named entries keep their own perm bits while their **effective** permissions drop to zero. **Within a single process**, **asynchronous** writes to the same path land in submission order (the queue is an in-process in-memory map: concurrent `rename` order was never guaranteed, and on the sequence file that showed up as the client silently dropping frames by seq; two processes sharing one `DSH_HOME` are not serialized against each other). The synchronous write `writeTextAtomicSync` is **not on that chain** — a synchronous function has no promise to hang it on, and it serves the exclusive path during an upgrade, so a **second assembly on hot reload can run it concurrently with an in-flight async write**; the serialization guarantee does not cover that. A failed write leaves no temporary file behind (unless the cleanup itself fails, in which case one stays: its name carries a random suffix, so the next write will not overwrite it, and it does not mask the real failure reason)
- **Residual trust surface of D-Bus notifications**: on Linux the notification body is handed to the **current owner** of `org.freedesktop.Notifications`. Any process of the same UID that grabs the name first receives the content (cross-UID takeover does not work: the session bus is one socket per user). Deployments that need process isolation within the same user should evaluate the system channel themselves
- **Capability contract evolution**: `capabilities` only gains keys, never loses them; the client **ignores unknown groups and unknown `verdict` values** (rendered as "unknown" rather than an error); with an older server that sends no `capabilities`, the settings page degrades gracefully. Upgrading the server therefore does not require upgrading the client
- Browser notifications require a **secure context** (HTTPS or localhost); LAN HTTP access automatically routes through the fallback channel (banner / sound / title reminder)
- Browser notification permission is requested within a gesture (via the "Request notification permission" button on the Settings → Plugins → dsh-notifier card)
- Windows system notifications are implemented via a PowerShell WinRT script, with the command passed as a parameter array and title/body packed into a single base64 (UTF-8 JSON) payload argument (no shell concatenation surface, and immune to PS 5.1 command-line argument parsing ambiguities, see issue #238); the script idempotently registers the AppUserModelId `DSH.dsh-notifier` on startup (HKCU, no admin required) — an unregistered AUMID gets toasts silently dropped by Windows 10/11. The AUMID follows the `Company.Product` convention to avoid collisions in the public namespace (`HKCU\SOFTWARE\Classes\AppUserModelId`) where same-named apps overwrite each other's display names; a legacy `DSH` key registered by older versions is harmless leftover (just an empty registry entry, does not affect new toasts) and can be removed manually with `Remove-Item -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\DSH"` if desired

<a id="quick-start"></a><a id="user-content-quick-start"></a>
## Quick start

### Install plugins (add)

```sh
dsh plugin --profile web add @wingsky-1/dsh-notifier
```

> After install / uninstall / update, **restart `dsh web` once** (bundle layers are only
> composed at startup) for changes to take effect.

### Access and verify

Use an access form above, open Settings → Plugins → dsh-notifier, and click "Request notification permission". Send a test notification and check History for the per-channel result. Browser popups default to hidden pages; enable "also when visible" if needed. System toasts appear on the host, not the remote browser’s device.

```sh
curl -s http://127.0.0.1:3080/api/dsh-notifier/health
```

<a id="common-configuration"></a><a id="user-content-common-configuration"></a>
## Common configuration

Edit the card under Settings → Plugins → dsh-notifier. Configuration is plugin-owned at `<DSH_HOME>/@wingsky-1/dsh-notifier/config.json`; storage, migration and unknown-key semantics are in Detailed reference.

Example values (defaults; `channels` / `kindRoutes` / `allowKinds` are new M2 keys):

```json
{
  "notifyAsk": true,
  "notifyQuestion": true,
  "notifyTaskDone": true,
  "notifySubagentDone": false,
  "notifyTaskError": true,
  "notifyTurnEnd": false,
  "quietHours": { "enabled": false, "windows": [{ "start": "22:00", "end": "08:00" }], "allowKinds": [] },
  "historyMaxAgeDays": 0,
  "channels": [
    { "type": "browser", "id": "browser", "enabled": true, "popup": true, "sound": true, "whenVisible": false },
    { "type": "system", "id": "system", "enabled": true, "popup": true, "sound": true }
  ],
  "kindRoutes": {},
  "allowKinds": []
}
```

> The browser and system notifications are the two **built-in entries** in `channels`: they live in
> the same array as bark / webhook instances and share the same rendering and decision logic; the
> only thing special about them is that they **cannot be deleted** (a write whose `channels` lacks
> a built-in entry returns 400). The 8 top-level channel keys of 0.2.3 (`systemEnabled` /
> `browserEnabled` / `systemNotify` / `browserNotify` / `notifyWhenVisible` / `notifySound` /
> `browserSound` / `systemSound`) are **moved into these two entries and deleted** during the
> upgrade — the migration is done in one version, with no second place where the old keys still
> read. Submitting them after the upgrade returns 400 (refresh the page if it was open before the
> upgrade).

### Per-channel three switches (#640 / #641; folded into channel entries as of 0.2.4)

The browser and system notifications are two **built-in channel entries** in the `channels`
array, with the same shape, rendering and decision logic as bark / webhook instances:

```json
{ "type": "browser", "id": "browser", "enabled": true, "popup": true, "sound": true, "whenVisible": false }
{ "type": "system",  "id": "system",  "enabled": true, "popup": true, "sound": true }
```

| Field | Type | Meaning |
|---|---|---|
| `enabled` | boolean | **channel switch (send or not)**: the only delivery gate per channel; off = no delivery at all |
| `popup` | boolean | **popup switch (pop or not)**: off with sound on = sound only |
| `sound` | `boolean \| tone id` | sound (whether / which tone) |
| `whenVisible` | boolean | whether to also pop while the page is visible (browser channel only; sent with the frame and executed by the page) |

**What gets sent is up to the channel**: the decision pipeline judges `enabled` only, per
channel; popup and sound are handed to the channel as-is, and it decides whether this one pops,
sounds, sounds without popping, or sends nothing at all. So a channel with "popup off + sound
off" **still receives the delivery** — the outlet determines there is nothing to send this time,
and history records a `skipped` entry (neither disguising it as a successful delivery, nor
judging the shape inside the pipeline on the outlet's behalf).

**The old top-level keys are moved away and deleted during the upgrade**: the 0.2.3 keys
`systemEnabled` / `browserEnabled` / `systemNotify` / `browserNotify` / `notifyWhenVisible` /
`notifySound` / `browserSound` / `systemSound` are moved into the two built-in entries by the 0.2.4
upgrade chain and then **deleted** from the configuration file — the channel shape is expressed in
the entries alone. Submitting those keys after the upgrade returns 400 (with a hint to refresh)
instead of silently doing nothing.

Values: `false` = muted (a popup may still show, without sound); `true` = **follow the
system default**; a tone id = an explicit built-in tone (`ding` / `bell` / `chime` /
`pop` — the 4 tones have consistent meaning across platforms; pick one in the "Tone"
dropdown of each channel card and hit Preview: the preview is synthesized locally
with Web Audio as a listening reference — **the real system sound follows the platform
and system settings**).

Delivery matrix (**with the channel switch off nothing is delivered, regardless of popup
and sound**; with it on, popup × sound decide the shape):

| Enabled | Popup | Sound | Behavior |
|---|---|---|---|
| Off | any | any | **no delivery at all** (sending depends on the channel switch only) |
| On | On | `false` | Show notification, silent |
| On | On | `true` | Show notification, OS-default sound |
| On | On | tone id | Show notification; the app self-plays the tone (system notification silenced to avoid double sound) |
| On | Off | `true`/tone id | **Sound only**: no popup, self-play only (page alive / host self-play) |
| On | Off | `false` | Enters the pool but **sends nothing**: history records `skipped`, and the settings card says so |

- **`notifySound` (old global key) is migrated by the upgrade**: its value is spread onto the two
  built-in entries' `sound` by the 0.2.4 upgrade (an outlet key — `browserSound` / `systemSound` —
  wins when present), and the old key is then deleted. Users who had muted the old sound therefore
  **stay muted** after upgrade — no surprise sound. From 0.2.4 the settings UI only writes the
  entries; there is no global sound switch any more.
- **Browser sound unlock prerequisite**: browser `true`/tone self-play needs an
  unlocked page audio context — browser autoplay policy requires one user gesture
  (opening the notification center / any sound-row interaction unlocks the
  AudioContext). A purely background page that was never interacted with may stay
  silent (notifications still pop, just no sound) — a browser policy constraint,
  not a plugin defect.
- **Linux `true` special case (#640 fix)**: Linux desktop daemons differ widely in
  sound-hint support (GNOME silent by default / KDE only since 2025 / Xfce needs
  libcanberra), so the system channel's `sound: true` means "**self-play the default event
  sound**": the host walks the fallback chain `paplay` → `pw-play` → `aplay` → `ffplay`
  and **stops at the first success** (no double sound), instead of relying on the daemon.
  When the freedesktop event sound is missing it now uses a **tone synthesized at runtime**
  (as of 0.2.4), so a host without the sound theme no longer stays silent.
  **Finding only sound-server players is a known degradation**: `paplay`/`pw-play` always
  fail on a host without a sound server, so the capability face reports `degraded` with
  `host-only-sound-server-players` — installing a player that talks to ALSA directly fixes
  it (`alsa-utils` provides `aplay`, `ffmpeg` provides `ffplay`; on dnf-family hosts
  `ffmpeg` comes from RPM Fusion). A host with no audio device still stays silent.
  **Behavior change for existing Linux installs**: system notifications used to be silent
  (notify-send had no sound hint); after this upgrade, sound-on self-plays the event sound.
- **Tone × platform mapping (approximate, best-effort)**:

| Tone | Browser (Web Audio) | macOS | Linux (event sound, synthesized when missing) | Windows |
|---|---|---|---|---|
| `ding` | double short high | Glass (NSSound) | `message-new-instant.oga` | `C:\Windows\Media\Windows Ding.wav` |
| `bell` | single mid-high | Tink | `bell.oga` | `Windows Chimes.wav` |
| `chime` | three-note ascent | Sosumi | `complete.oga` | `Windows Chord.wav` |
| `pop` | short low | Pop | `message.oga` | `Windows Balloon.wav` |
| `true` (system) | OS default (not silent) | Glass (kept) | default event self-play (synthesized when missing) | toast default system sound; near-default wav for sound-only |

  macOS playback is subject to the system "Allow notification sounds" setting; Windows
  tones are played by the host `SoundPlayer` from built-in wav files (allow-listed
  paths, silent when missing); Linux event files are the oga files guaranteed to exist
  in the sound-theme-freedesktop base package under
  `/usr/share/sounds/freedesktop/stereo/` (multi-path probing, **falling back to a tone
  synthesized at runtime when missing**).
  Self-play always uses argument-array spawning (no shell concatenation) and
  allow-listed file paths.
- The host platform is exposed via the `platform` field of `/api/dsh-notifier/health`
  (the system card shows a platform hint from it — the browser OS and the host OS can
  differ; don't confuse them).
- **System-channel availability lives in `/diagnostics`** (probed on the host, with
  remediation advice); **browser-channel availability is computed in the settings card**
  (client-side, so it differs per device).

### Event routing and confirmed kinds

`kindRoutes`: a sparse kind → channelId[] routing map (e.g.
`{ "error": ["browser", "system", "bark:phone"] }`); kinds without an entry broadcast to every
enabled channel; the events area of the settings page edits it both ways (sharing one copy of
the configuration with the channel cards).
`allowKinds`: the list of confirmed dynamic kinds (notification types registered by other
plugins are persisted here once you confirm them).

### Bark push channel (M2, issue #366)

Configure via the "Notification center → Delivery channels → Add Bark push" tab (or edit the
configuration JSON above directly). Per-instance fields: `id` (auto-generated then locked),
`name` (display name), `baseUrl` (Bark server address, http/https), `deviceKey` (found in the
Bark app; always masked as `********` in responses, submitting the mask = keep the original
value), `enabled` (default **false** — outbound authorization must be granted explicitly).

Optional parameters (all omitted = not sent; unknown string/number keys pass through verbatim
since #1016 S2 a channel entry's unknown keys are **no longer passed through** — they
never reach the effective config or the push body and are rejected with 400 on submit;
`device_key` / `device_keys` / `ciphertext` are reserved keys, with wording that
differs from the "never legal" keys):

| Field | Notes |
|---|---|
| `sound` | Ringtone name (Bark Sounds list) |
| `group` | Group (notifications in the same group collapse on the phone) |
| `icon` | Icon URL (**must be reachable from the phone's network**, not from the server; SVG needs iOS 17+; empty uses the Bark default) |
| `url` | URL opened when the notification is tapped |
| `badge` | App badge number |
| `level` | Instance-level urgency override; when omitted, mapped automatically from event severity: `failure→timeSensitive`, `warning/success→active`, `info→passive` |
| `levels` | Per-event (kind) urgency sparse mapping (below) |

`levels` (kind→level sparse mapping matrix): sets Bark urgency for a concrete event type,
**taking precedence over the instance-level `level` and the severity auto-mapping**; unconfigured
types use the default. Suited to per-event differentiation such as "questions must ring,
subagent completions stay quiet":

```json
{ "id": "phone", "type": "bark", "baseUrl": "https://api.day.app", "deviceKey": "…",
  "enabled": true, "levels": { "question": "timeSensitive", "subagent-done": "passive" } }
```

- Keys are event kinds (the built-ins `ask/question/done/subagent-done/error/turn-end/test` or
  dynamic kinds; any string); values are limited to `active` / `timeSensitive` / `passive` /
  `critical`; at most 64 entries (the write side rejects an over-limit submission; an
  over-limit value already on disk is not re-judged by an unrelated save), each key at
  most 64 chars.
- Full precedence: `levels[kind]` > `level` > severity mapping > not carried.
- Note: `critical` requires special Apple authorization (regular apps cannot request it);
  without it Bark may downgrade or reject the request.
- Orthogonal to `kindRoutes` (kind→channelId[] routing): routing decides "which channels receive
  it", `levels` decides "how loudly this instance rings".

Delivery reliability: 10 s hard timeout, network errors / 5xx retried ×2 (4xx not retried), at
most 2 in-flight deliveries per instance (built-in channels are unlimited); success requires
both HTTP 2xx and a response body with `code===200`. Terminal delivery states
are persisted to this plugin's status file (see "Storage layout" below), and
the settings-page channel-card status row is refreshed on card load and after sending a test
via `GET /api/dsh-notifier/status` (no polling, the D20 stance).

- **Bark channel credentials & outbound security (M2)**:
  - **The device key never lands in the URL**: pushes go to `POST {baseUrl}/push` with a JSON body (the `device_key` field) — reverse-proxy access logs record URLs and headers by default, never bodies
  - **A single masking exit**: `deviceKey` in `GET /config`'s user+effective and in `PUT` success responses is always masked as `********`; submitting the full mask = keep the original value (backfilled aligned by instance id so a reordering never swaps credentials between instances)
  - **No credential replacement at the error exit (measured)**: Bark 4xx response bodies echo the key verbatim — failure reasons are truncated as-is and go to the logger and to `status.json`; the literal device-key replacement is gone, and so is the `sent` event as an exit (see "Security model")
  - **SSRF posture**: `baseUrl` is limited to the http/https scheme and credential URLs (`user:pass@host`) are rejected. These criteria (plus URL parse failure) are checked one by one by a pre-egress gate (`deliver/url-gate.ts`) before `fetch`: a non-conforming target is never sent, and its reason lands in the failure reason's `detail` (`retryable=false`, no re-send — a URL is a configuration fact, re-sending only hits the same wrong address three times). **No domain allowlist** — pointing baseUrl at an intranet self-hosted bark-server is a legitimate case
  - **`/push` is joined onto the pathname via the URL API (#1016 P0)**: this used to be the string concat `baseUrl + "/push"`, where a base carrying a query dropped `/push` into the query string (the request actually hit the truncated path) and a base with a trailing slash produced a `//push` double slash; now `/push` always lands on the pathname and the query is preserved as-is after it
  - **Declared residual risk (security debt, the #1016 relaxation)**: this bullet used to promise "query/hash are dropped" to users. The current direction change **gives up machine enforcement** — credentials in the query (`?token=`, `?api_key=`) travel in the URL and land in the peer's access logs, and there is now **no machine check at all**, only this README's documented convention that credentials go in request headers only, never in the URL. A diagnostic warning for credential-shaped queries needs a new presentation surface (config batch scope), and blocking them on the write face by keyword would also hit a legitimate `?api_key=` on a user's self-hosted gateway — both are registered as follow-ups
  - **Other known residual risk**: a caller able to reach dsh web from the LAN (through a lan-proxy reverse proxy it can cross the loopback fence, see the deployment doc) can use `/test` to trigger one outbound POST to `baseUrl` (semi-blind: the response error summary echoes only a truncated raw excerpt). Deployments sensitive to that risk can turn the plugin's `enabled` off or adopt a dedicated-port setup (a later version)

### Webhook push channel (#508)

Configure via the "Notification center → Delivery channels → Add Webhook push" tab (or edit
the configuration JSON directly). Purpose: receive notifications on Android via ntfy /
Gotify / a self-hosted push gateway, complementing Bark (iOS); each delivery POSTs a JSON
body to `url`.

Per-instance fields (`type` fixed to `"webhook"`):

| Field | Notes |
|---|---|
| `id` | Instance id (2-32 chars, lowercase letters/digits/hyphens; locked after creation; the alignment key for `kindRoutes` and mask backfill) |
| `name` | Display name (falls back to the id) |
| `url` | Target URL (the write face only requires a non-empty string; the pre-egress gate rejects non-http(s) and URLs carrying embedded credentials, and preserves the query as-is) |
| `enabled` | Whether enabled (default **false** — outbound authorization must be granted explicitly) |
| `auth` | Authentication: `none` (default) / `bearer` / `basic` / `header` |
| `token` | Bearer mode accepts the **raw token only** (e.g. `tk-...`), **not `Bearer tk-...`**; the plugin constructs `Authorization: Bearer <token>` and a pasted prefix causes a double prefix and likely 401 (secret: always masked `********`) |
| `username` | Basic auth username (not a secret) |
| `password` | Basic auth password (secret: always masked) |
| `headerName` / `headerValue` | Custom-header authentication (see constraints below). |
| `preset` | Preset: `ntfy` (default) / `gotify` / `custom` (self-hosted gateway); selects the `{{priority}}` mapping and the default template |
| `template` | JSON body template (≤8192 chars, the write side rejects an over-limit submission; empty = preset default template) |
| `timeoutSec` | Delivery timeout in seconds (1-60, default 10; authoritatively clamped server-side) |

**Custom-header constraints**: Custom-header auth (`headerValue` is a secret: masked); header names are limited to letters/digits/hyphens (≤64 chars) and forbid `content-type` / `content-length` / `host` / `cookie` / `authorization`

Presets and the channel-aware `{{priority}}` mapping (selected by `preset`; `{{severity}}`
is always the raw severity):

| preset | info | success | warning | failure |
|---|---|---|---|---|
| `ntfy` | 3 | 2 | 4 | 5 |
| `gotify` | 3 | 3 | 7 | 9 |

`custom` does no mapping — `{{priority}}` passes the severity through verbatim for the
gateway to handle.

Template placeholder list: `{{title}}`, `{{message}}`, `{{kind}}`, `{{severity}}`,
`{{priority}}` (mapping above), `{{source}}` (renders as an empty string, reserved), and
`{{ts}}` (rounded epoch milliseconds, emitted as a bare number — the only placeholder
allowed unquoted in the template).

Rendering semantics (JSON-aware, two steps): `{{ts}}` is substituted as a numeric literal
first → the template is parsed with `JSON.parse` → placeholders are replaced in string
values only while walking the parsed tree → re-serialized with `JSON.stringify`.
Substitution happens inside already-parsed strings and is uniformly escaped on
re-serialization, so notification content containing quotes or `"}}` cannot break out of a
string to inject extra fields. A template that is not valid JSON fails that channel's
delivery and is recorded (never silently downgraded to plain text; other channels are
unaffected). The `ntfy` preset's `priority` is normalized to the numbers 1..5 before
sending (legacy name literals are compat-mapped; illegal values fail that delivery and
are recorded).

**ntfy JSON publishing (#1102)**: the official protocol requires publishing JSON to the
service root URL with a mandatory `body.topic`. Recommended setup: put the service root
URL in `url` (e.g. `https://ntfy.sh/`; a self-hosted prefixed root such as
`https://self.example/ntfy/` is kept as-is) and the real `topic` in the template. Legacy
setups (`https://ntfy.sh/<topic>` plus the default template) stay working: a
slash-free single-segment path has its trailing segment extracted as the topic and the request is
rewritten to the root URL (only the trailing topic segment itself is removed; the prefix,
including separator slashes, and the query are preserved verbatim). With an explicit topic and a
non-root path, a path ending in `/` counts as a self-hosted prefixed root and is kept
verbatim, never truncated; a trailing segment that decodes to the explicit topic (no trailing
slash) is likewise
removed on its own with the prefix kept; a differing trailing segment (no trailing slash) is a conflict and
fails with a configuration error without sending. With no usable topic in the template: a
slash-free single-segment path is extracted, a root path fails with missing-topic, and a
trailing-slash or multi-segment path is refused as ambiguous (a prefixed root cannot be told
apart from a topic address) —
none of them ever sends to a wrong address, and nothing is guessed. `topic` allows letters/digits/`_`/`-`
(1..64 chars). No hand-editing of the config file is needed, and no new public config
key is introduced.

Delivery reliability: timeout 1-60 s (default 10); **failures are never retried
automatically** — 4xx / 5xx / network errors / render failures all end as a terminal
failure recorded in the status file and the notification history (the raw host text is
truncated as-is into the reason's `detail`, see "Security model");
re-send via "Send test notification" to verify. Reference channels in `kindRoutes` as
`webhook:<id>` (same `type:id` shape as `bark:<id>`).

Example instance (stored alongside Bark instances in the `channels` array, ids unique
across types):

```json
{ "id": "droid", "type": "webhook", "url": "https://ntfy.sh/",
  "enabled": true, "auth": "bearer", "token": "…", "preset": "ntfy",
  "template": "{\"topic\":\"mytopic\",\"title\":\"{{title}}\",\"message\":\"{{message}}\",\"tags\":[\"{{kind}}\"],\"priority\":\"{{priority}}\"}",
  "timeoutSec": 10 }
```

The legacy shape (`url: https://ntfy.sh/mytopic` plus the default template) still delivers
(auto-compat rewrite); new setups should use the root-URL shape above.

- **Webhook channel credentials & outbound security (#508)**:
  - **Disabled by default**: `enabled` defaults to false — outbound authorization must be granted explicitly (same posture as Bark)
  - **Credentials never land in the URL**: credentials travel only in request headers (bearer → `Authorization: Bearer`, basic → `Authorization: Basic` (base64), header → custom header name + value); reverse-proxy access logs (URL + header names) never see them
  - **Credential masking funneled via `CHANNEL_SECRET_FIELDS`**: the masked-field list is a per-channel-type single source of truth (bark → `deviceKey`, webhook → `token`/`password`/`headerValue`); GET /config (user + effective) and PUT success responses always mask `********`, submitting the full mask = keep the original value (backfilled aligned by instance id so a reordering never swaps credentials between instances). The mask means "unmodified" **only on credential fields**: on any other key it is an ordinary string (a channel name typed as eight asterisks is written through as-is), while a mask sitting on *another* type's credential field name (a cross-type leftover from retyping) returns 400; so does a mask on this type's credential field with no stored value to restore (a new instance, a renamed id). Those are **two distinct hints** (`RESIDUAL_MASK_HINT` / `NEW_CHANNEL_MASK_HINT`), and the write path and the dry-run give the **verbatim same** hint for the same situation (both defined in `src/server/config/impl/service/merge.ts`)
  - **Reserved keys block config bypass (`WEBHOOK_RESERVED_KEYS`)**: credential alias keys such as `auth_token` / `access_token` / `bearer_token` / `api_key` / `apikey` / `client_secret` / `secret` / `password_hash` are rejected with 400 on write (never materialized on read; whatever is already on disk is preserved) — legitimate credentials can only enter via the known secret fields (masked end to end)
  - **JSON injection protection**: the template renders JSON-aware in two steps (value-level substitution + uniform re-serialization escaping); notification content cannot break out of a string to inject extra JSON fields
  - **Outbound errors do not replace credentials**: same as Bark — non-2xx response bodies are truncated to 200 chars and enter the reason's `detail` as-is, with no credential-literal replacement and no rule table (see "Security model")
  - **URL SSRF posture (same gate as Bark)**: http/https schemes only, credential URLs (`user:pass@host`) rejected — checked one by one before egress, which refuses to send a non-conforming target (`retryable=false`, reason in `detail`); no domain allowlist — an intranet self-hosted gateway is a legitimate use case; custom header names forbid end-to-end headers (`content-type`/`content-length`/`host`/`cookie`/`authorization`) against request smuggling / JSON body corruption
  - **The url is used verbatim, never concatenated** (unlike Bark): `fetch(target.url)` sends it as written, so an address carrying a query (`https://gateway/hook?tenant=x`) is legitimate and works. The same declared residual risk as above applies: credentials in the query are no longer machine-blocked (the #1016 relaxation)
  - **No retry on failure**: a failed delivery is terminal (4xx/5xx/network/render) — no retry-driven outbound amplification

  - Webhook is an **additive channel type**: the semantics and compatibility commitments of existing channels and notification outputs (SSE frames / system notifications / history jsonl) are unchanged

<a id="verification-and-troubleshooting"></a><a id="user-content-verification-and-troubleshooting"></a>
## Verification and troubleshooting

Check health and host capabilities from loopback; inspect the History tab for per-notification outcomes rather than relying only on the channel status row. The first capability probe has an 8s total budget; later reads use the shared cache.

```sh
curl -s http://127.0.0.1:3080/api/dsh-notifier/health
curl -s http://127.0.0.1:3080/api/dsh-notifier/diagnostics
```

### Delivery outcomes and reasons

**Three terminal states with different meanings (0.2.4)**: `ok` = a channel really executed an
action and it succeeded; `failed` = an action was executed and it failed (there is failure
evidence, so the status row is written); `skipped` = there was **no executable action at all**
(popup and sound are both off, or this host cannot produce the command). The system channel treats popup
and sound as two independent actions: **once the popup has gone out, a sound failure is best-effort and
does not change the terminal state** (sound is no longer the only action); a popup failure still flips it. `skipped` **does not
write the status row** — the channel did nothing, so there is no "latest delivery outcome" to
speak of, and writing success would claim success on its behalf. A green status row therefore does
not mean this particular notification arrived: every entry in the **History tab** carries its
per-channel delivery detail (which channel, which outcome, which reason), and that is the only
per-notification visible surface.

When popup and sound are both enabled, system-channel `ok` / "delivered" confirms only the popup action; it does not confirm sound playback or that a person heard it. For sound-only delivery, `ok` means the player command completed successfully, but still does not mean a person heard the sound. If the popup succeeds but self-play fails, the dsh web host log records `dsh-notifier: 提示音播放失败` and the player exit detail. `/diagnostics` reports host capability probes, not the result of this playback. If you expected a browser notification sound, allow notifications in the browser's site settings and check the browser channel status on the settings card; for host sound failures, check the host audio device and Windows sound settings or use the browser channel.

When the popup succeeds but the self-play command fails or cannot be prepared/executed, the `ok` result carries a sound warning (`soundWarning`, reusing the existing reason shape `{ code, params?, detail? }` with no new codes — only the three facts: whole-chain failure, unwritable temp file, or no playable sound): the history entry shows an extra warning line under that channel, the status row appends the same warning after "Last delivery OK" with a warn-toned dot, and draft tests return it the same way. The overall outcome stays successful and the consecutive-failure counter still resets; the next delivery clears or replaces the old warning, and failure records never carry an old warning. The static capability self-check (`/diagnostics`) only reports probe conclusions — never the result of an actual playback, and never that a person heard a sound. The absence of a warning does not mean the sound was heard: only command failure (or inability to prepare/execute) is observed.

**Delivery reasons are structured (0.2.4)**: `{ code, params?, detail? }` — `code` is rendered into
the current language by the client dictionary, and `detail` holds raw host output (HTTP response
body, stderr tail, JSON.parse error) and is **never the primary text**; the UI folds it away behind
a "Raw host output" label. The upgrade folds pre-0.2.4 prose reasons in `status.json` /
`history.jsonl` into `code: "reasonLegacy"` with the original sentence in `detail` (idempotent; the
read side is tolerant as well, so a hand-edited file cannot make the UI show `undefined`).

<a id="detailed-reference"></a><a id="user-content-detailed-reference"></a>
## Detailed reference

### Configuration storage and migration

Configuration is owned by the plugin itself and lives in `config.json` inside its **package-private
storage directory** (`<DSH_HOME>/@wingsky-1/dsh-notifier/config.json`, `~/.dsh` by default), read and
written through the plugin card under Settings → Plugins → dsh-notifier or via
`GET/PUT /api/dsh-notifier/config`. During upgrade, **legacy locations are read once at assembly time
and migrated into the current shape**. The only formal historical sources are the `dsh-notifier`
sections of `<DSH_HOME>/settings.yaml.imported` and `<DSH_HOME>/settings.yaml`: the imported file is
merged first, then the current file overrides it. If either formal file exists but cannot be read,
parsed, validated, or serialized, the upgrade fails immediately instead of falling through to a
guessed location. Only when the formal sources yield no data does migration fall back, in order, to
the registered section returned by settings `describe()` and then to the older self-maintained
`dsh-notifier.json` (including the `.migrated.bak` left by an earlier migration). What is read is
merged into the current `config.json` (legacy values override the file, the same precedence the old
write path used), and the 8 top-level channel keys are then moved into the two built-in entries of
`channels` and **deleted** (see "Per-channel three switches"). After that `config.json` is the only
read/write path.

**Unknown-key semantics (#1016 S2: wide reads, strict writes)**: on the **read** side keys
the plugin does not recognize are still **preserved verbatim and kept visible** (existing
values are never lost or migrated); on the **write** side they are always **rejected with
400** — the same rule for top-level keys and for keys inside a channel entry.

- **Reading**: `GET /api/dsh-notifier/config` returns unknown keys verbatim in `user`
  (the raw user layer), keeping future-version / third-party keys visible. `effective` is
  the **view projection** (#1016 S3: known-key subset + verbatim + masking, **no default
  filling**): it takes only the 11 known **top-level** keys (an unknown top-level key would
  trip the write path's 400, so it never enters the view), while unknown keys **inside a
  `channels` entry** are passed through as-is — the client hands them back unchanged, the write
  path reads that as "brought back verbatim" and does not re-judge their domain, so they are not
  rejected. The **delivery layer** (`readConfig()`) is a different channel: it materializes
  field by field over the known keys, and an unknown key never reaches it.
- **Writing**: `PUT /api/dsh-notifier/config` is an incremental patch. A patch carrying an
  unknown key (e.g. `{"futureKey":1}`) returns **400** "futureKey is not a known config key —
  delete it or check the spelling"; only an empty patch `{}` (or a patch with nothing
  writable after filtering, e.g. only assembly keys) returns **400** "need at least one
  config key". **Unknown keys already on disk are unaffected**: they are not in the patch,
  `channels` keeps them field by field, and saving other known keys neither drops them nor
  gets rejected because of them. To clear one, delete it manually in `config.json` (it is
  also removed automatically when upgrading to 0.2.9 or later — see "Upgrade path").
- **Why no longer pass-through**: the pass-through surface required the read side to fold a
  channel entry's unknown keys into an `extras` sub-object and hand it back verbatim, while
  the write side only accepted string/number values — so the `extras` **object itself**
  collided with that rule and the whole configuration became unsaveable (#1016 defect B).
  Narrowing to "keys this version knows" closes that self-collision while still keeping
  what is already on disk.
- **Upgrade path**: since 0.2.9, unknown keys in the configuration file are **cleaned up at
  upgrade time** (top-level keys and keys inside channel entries alike; every category is
  listed in the 0.2.9 migration entry under the configuration-format appendix) — they could
  never be submitted anyway (the write path always returns 400), and keeping them only makes
  the settings page show a field that cannot be changed. **The boundary is "a value that
  cannot possibly be legal in this version"**: anything the user has already expressed stays
  byte for byte (a full `levels` map, an 8192-character `template`, credentials left empty, a
  half-broken entry whose required key is empty or absent) — the upgrade deletes keys, it never
  rewrites values.
- **Legacy migration**: unknown keys in the old configuration (the 0.2.3 settings
  namespace and the older self-maintained json) are **preserved** when read — written
  when missing from the user layer, never overwriting existing ones; a legacy file
  containing only unknown keys is no longer treated as "no valid keys". That only
  guarantees they are not lost on the way into `config.json`; once the version marker
  reaches 0.2.9 the shape cleanup drops keys this version does not know (see "Upgrade
  path").
- **Boundary exceptions**:
  - `patch` **must be an object**: non-object shapes (arrays, `null`, numbers, etc.)
    always return 400 — arrays are never passed through as numeric-index dirty keys.
  - Prototype-chain keys (`__proto__`, `constructor`, `prototype`, which JSON text can
    inject as own keys) are always **stripped** on the write path — never judged, never
    written. Stripping already blocks the prototype rewrite, so they are not reported as
    "unknown key" 400s.
  - Composition-layer assembly keys (`configFile` / `toastScript` / `historyFile` /
    `statusFile` / `enabled`) are cordis composition/startup parameters and **never
    enter the user layer** — PUT and migration drop same-named keys; entry
    composition goes through the whitelist filter.
  - Reserved keys are rejected with 400 too — `device_key` / `device_keys` / `ciphertext`
    inside a Bark channel instance, and `WEBHOOK_RESERVED_KEYS` (`auth_token` /
    `access_token` / `bearer_token` / `api_key` / `apikey` / `client_secret` / `secret` /
    `password_hash`) inside a webhook channel instance — but the wording differs from the
    "never legal" keys: the former says "is a reserved key: credentials can only go through
    the known fields", the latter says "is not a known key: delete it or check the spelling".
    The two need different troubleshooting.

Consequence: after an upgrade, if the settings page does not show a field that still
exists in `config.json`, that is the intended preserve behavior — saving other known
settings will not lose it.

> **Storage layout (#733 convergence)**: configuration, notification history, channel status, the SSE
> seq counter and the storage version marker all live under `DSH_HOME/@wingsky-1/dsh-notifier/` —
> `config.json` / `history.jsonl` / `status.json` / `seq.json` / `version` (`version` is the upgrade
> chain's scale). Legacy locations are **read once at startup**: the DSH_HOME-root
> `dsh-notifier-history.jsonl` / `dsh-notifier-status.json` / `notifier-seq.json` are renamed to
> `.migrated.bak` after being moved; the two generations of configuration (the 0.2.3 settings
> namespace and the older `dsh-notifier.json`) are read without being renamed.
> All paths respect `DSH_HOME` (#510): they resolve to `~/.dsh` when the variable is unset and
> follow the isolated home when set — isolated environments (multi-instance / test sandboxes /
> dsh-verify-isolated) never touch the real `~/.dsh`.

### SSE lifecycle and retired connection cap

> The SSE connection table (managed by shared/sse-hub since #515) **no longer has a
> connection cap**: cap eviction has been removed entirely — it was a stopgap for the
> "connection leak" era. Two complementary reclamation paths remain: **stalled
> reclamation** (writes rejected for over 90 s → disconnect) and **maxAge rotation**
> (alive for over 120 min with no business frames → actively disconnected; clients
> auto-reconnect and replay with `since`, so it is transparent). Reclamation-path
> counters are exposed as `sseEvicts` on `/api/dsh-notifier/health`.
>
> The cap's config key `maxConnections` (default 16, range 1–1024) is retired as of
> **0.2.5** too: the settings page no longer shows it and `effective` does not contain it.
> Its write path follows the same policy as the 0.2.3 channel keys above — submitting it
> returns **400** — but the reason differs: this key has no successor (the whole mechanism
> is gone), so it carries its own hint, which again points at the way out (refresh if the
> page was open before the upgrade). A residual value in an existing `config.json` is not
> auto-cleaned and never reaches the effective config: saving other known settings does not
> drop it, and it still appears verbatim in the `user` view of GET /config — delete the
> line by hand in `config.json` to clean it up.

### Routes (all behind the loopback fence)

| Route | Method | Notes |
|---|---|---|
| `/api/dsh-notifier/config` | GET/PUT | File-backed user layer (stored values kept, credentials masked); snapshot and incremental updates; unknown keys are read-only (preserved, never submitted). |
| `/api/dsh-notifier/events` | GET | SSE frames with reconnect replay. |
| `/api/dsh-notifier/test` | POST | Send a test through the service pipeline. |
| `/api/dsh-notifier/history` | GET / **DELETE** | Read or clear notification history. |
| `/api/dsh-notifier/status` | GET | Latest channel outcomes and failure counts. |
| `/api/dsh-notifier/kinds` | GET / POST | Read and confirm dynamic event kinds. |
| `/api/dsh-notifier/health` | GET | Health and capability summary. |
| `/api/dsh-notifier/diagnostics` | GET | Detailed host capability probe. |

#### `/config`

**GET** returns `{ok, user, revision, effective, writable}` (`user` = configuration-file user layer (stored values with unknown keys preserved and credentials masked), `revision` for optimistic concurrency, `effective` = resolved config; **credential fields (bark `deviceKey` / webhook `token`·`password`·`headerValue`) are always masked**); **PUT** accepts `{patch, expectedRevision?}` (incremental patch, optional `expectedRevision` for optimistic concurrency), returns `{ok, user, revision}` (also masked). **`channels` merges field by field** (#1016 S2): a key the patch does not carry keeps its on-disk value, a key whose value is the mask `********` keeps its stored value, a key whose value is `null` or an empty string is deleted, anything else is written; deleting a required key (`url` / `baseUrl` / `deviceKey`) returns 400 "required key, cannot be deleted". **Other top-level keys are still whole-value replacement** (e.g. `kindRoutes`: the whole table is swapped, removing an entry means dropping it from the table — `null` is not accepted)

#### `/events`

SSE notification frames (browser EventSource subscription; `?since=<seq>` replays missed frames after reconnect)

#### `/test`

Test notification (funnels through the service pipeline, bypasses Do-Not-Disturb; optional body `{channelId}` to test a single channel)

#### `/history`

GET recent notification records (up to 200, filtered by `historyMaxAgeDays`; entries suppressed by DND are flagged `suppressed`; every record carries per-channel delivery detail `channels[]` whose `reason` is a structured reason); **DELETE clears**

#### `/status`

Channel delivery status (per-channel latest delivery terminal state + consecutive failure count; the failure reason is a structured object `{code, params?, detail?}` whose `detail` is truncated as-is to 300 chars, with no credential replacement)

#### `/kinds`

GET the dynamic kind list (including confirmation state); POST `{kind, confirmed}` writes a confirmation (persisted to `allowKinds`); the 200 response carries `revision` (for the client to sync its optimistic-concurrency version)

#### `/health`

Health check (`{ok, plugin, platform, sseEvicts, capabilities}`; `capabilities.host` is a **summary** — the verdict plus the state of the two dimensions, constant size)

#### `/diagnostics`

Full capability self-check (`capabilities.host` with per-dimension `checked`, the player list, tone-file availability and the `remediation` advice). It **shares the one probe** with `/health` (one probe per process, cache reads afterwards); the **first** probe may wait up to the 8s total budget (`CAPABILITY_BUDGET_MS`) — a known cost

Error mapping (PUT /config): invalid config key → 400 (`{ok:false, error:{error:"配置校验失败: <key>", hint}}`); stale `expectedRevision` conflict → 409 (`code:"SETTINGS_CONFLICT"`); settings service unavailable → 503 (`code:"settings-unavailable"`); write failure → 500 (root cause only in server logs).

Error mapping (POST /kinds): kind-confirmation CAS retries (≤2) exhausted → 409 (`code:"SETTINGS_CONFLICT"`, rare: sustained concurrent writes during confirmation); settings service unavailable → 503 (`code:"settings-unavailable"`, same semantics as PUT /config); write failure → 500 (fixed `error` text, root cause only in server logs).

### Configuration format appendix: defaults, migration, and masking rules

- Defaults follow `DEFAULT_CONFIG` in `src/server/config/impl/model/index.ts`: event toggles `notifyAsk` / `notifyQuestion` / `notifyTaskDone` / `notifyTaskError` on, `notifySubagentDone` / `notifyTurnEnd` off; `quietHours` is `{enabled:false, windows:[{start:"22:00", end:"08:00"}]}`; `channels` always carries the two built-in entries (browser and system, each `enabled` / `popup` / `sound` on, plus `whenVisible:false` on browser); `kindRoutes` and `allowKinds` are empty, `historyMaxAgeDays` is 0.
- 0.2.3 → 0.2.4 migration: the 8 top-level channel keys (`systemEnabled` / `browserEnabled` / `systemNotify` / `browserNotify` / `notifyWhenVisible` / `notifySound` / `browserSound` / `systemSound`) are moved into the two built-in entries at assembly time and then deleted (`src/server/upgrade/impl/steps/config-shape.ts`); submitting them after the upgrade always returns 400 with a refresh hint, and leftover lines must be removed by hand in `config.json`.
- 0.2.5 → 0.2.6 migration: the legacy do-not-disturb `start`/`end` are moved into `windows[0]` at assembly time and the old keys deleted (`src/server/upgrade/impl/steps/quiet-windows.ts`); submitting the old shape (no `windows`) after the upgrade always returns 400 with a refresh hint.
- **0.2.8 → 0.2.9 migration (configuration-shape cleanup, `src/server/upgrade/impl/steps/canonical-keys.ts`)**:
  the only upgrade step of this plugin that **discards values** (the 0.2.4 / 0.2.6 steps delete the
  old keys but move every value to a new one). Every criterion is "condition → delete/fill"; it
  never guesses and never clamps a value (an out-of-range value
  loses its key rather than becoming a boundary value), and on an already clean file it
  **writes nothing at all** — your hand-edited formatting is never re-serialized. **Deleted**:
  - unknown top-level keys (including `maxConnections`, retired in 0.2.5, and hand-written
    future keys), top-level values whose type differs from the default table (`notifyAsk` as a
    string, `allowKinds` as a number), out-of-range `historyMaxAgeDays`, and a `channels` value
    that is not an array (the key is dropped);
  - keys inside a channel entry that are not in that type's known-key set (including the
    credential aliases `device_key` / `auth_token` etc.), and keys whose value has the wrong
    shape: a non-boolean where a boolean belongs, out-of-range counts (`timeoutMs` /
    `timeoutSec`), illegal enums (`level` / `auth` / `preset`), non-string credential values;
  - **whole entries** that are not objects, whose `type` is unknown, or whose identity does not
    hold (an outbound entry with a missing or empty `id`). **A duplicate `id` is not a deletion
    criterion**: this step no longer de-duplicates by identity and drops nothing for it (the
    previous behaviour deleted the second entry, credentials and all, at upgrade time). A
    duplicate identity is handed to the **400 from the write path** instead, for the user to
    pick one entry and delete it — see "Channel identity (`id`) is unique, cross-type included"
    in the security model.
  **Filled by the same step** (filling is not deleting): the two built-in entries (browser /
  system) are added when absent, and a present one gets its **absent fields** filled from the
  default table — **explicit values are never overwritten** (an explicit `false` is you switching
  it off).
  **Left untouched**: every legal-but-non-default value (a 64-entry `levels` map, an
  8192-character `template`, an over-long name, credentials left empty), and **half-broken
  entries** — an entry whose required key is empty or absent is kept whole (it is visible in
  the settings page, fillable, and removable, and the delivery side already drops it entirely).
  Half-broken entries hand-edited into the file *after* the upgrade are not repaired by this
  step either: it runs once, when the version marker advances.
- Masking rules: the per-channel-type credential field list is centralized in `CHANNEL_SECRET_FIELDS` (bark → `deviceKey`, webhook → `token` / `password` / `headerValue`); `user` and `effective` of GET /config plus PUT success responses are always masked as `********`, submitting a full mask means keep the original value (backfilled aligned by instance id). The mask semantics cover **credential fields only**: on a non-credential key it is written through as an ordinary value (a channel name typed as eight asterisks is neither treated as a credential sentinel nor silently dropped); a mask on another type's credential field name (cross-type leftover) returns 400, and so does a mask on this type's credential field with no stored value to restore (a new instance, a renamed id). Those are **two different hints** (`RESIDUAL_MASK_HINT` / `NEW_CHANNEL_MASK_HINT`); the write path and the dry-run give the verbatim same hint for the same situation, and both literals live in `src/server/config/impl/service/merge.ts` — a placeholder is not a credential and never reaches disk.

### Client contract: throttling, gesture unlock, and frame path

- Autoplay throttling: `PLAY_THROTTLE_MS` is 1500 ms and covers both autoplay paths (notification tones and sound-only delivery); previews bypass it (`gate()` in `src/client/notify/audio.ts`).
- Gesture unlock: the browser autoplay policy requires one user interaction — the first click anywhere on the page calls `unlock()` to unlock the AudioContext; previews are in-gesture operations with explicit unlock plus throttle bypass (`src/client/index.tsx`).
- Frame path: the decision pipeline emits frames through the composition-root-local `FrameBus` (`src/index.ts`), the browser exit subscribes via `onFrame` and serves them over the SSE route `/api/dsh-notifier/events`; the client subscribes with EventSource, actively reconnects with `?since=<seq>` for replay, and dedupes by seq (`src/client/notify/session.ts`).
- Forwarding semantics via 3443 are only referenced, never restated here: see the "Security model" and "Verification and troubleshooting" sections of the `dsh-lan-proxy` README.

### Uninstall plugins (remove)

```sh
dsh plugin --profile web remove @wingsky-1/dsh-notifier
```

### Update plugins (update)

```sh
dsh plugin --profile web update @wingsky-1/dsh-notifier
```

> After install / uninstall / update, **restart `dsh web` once** (bundle layers are only
> composed at startup) for changes to take effect.

<details>
<summary>Installation variants: version pinning and npx</summary>

### Pin a version (@version)

Omitting `@version` installs the default latest (recommended). Only when the registry has not synced the latest yet, or the latest has issues in your environment, append `@version` to the package name:

```sh
dsh plugin --profile web add @wingsky-1/dsh-notifier@<version>
```

### Without a global dsh install

If there is no global `dsh` command on the machine, use `npx` to run it on the fly (`dsh plugin`
calls `pnpm` under the hood, so `pnpm` and `Node.js` must still be installed locally):

```sh
npx @deepseek-ai/dsh plugin --profile web add @wingsky-1/dsh-notifier
npx @deepseek-ai/dsh plugin --profile web remove @wingsky-1/dsh-notifier
npx @deepseek-ai/dsh plugin --profile web update @wingsky-1/dsh-notifier
```

</details>

### Features

- **Ask you a question** (on by default): notifies when `ask_user_question` / the GUI question popup is triggered
- **Approval reminder**: notifies when the real approval path `approval/request` is triggered, including task title, raw tool ID, request reason, and an action hint
- **Completion reminder**: notifies when a task transitions from running to idle (`agent/status` running → idle), including task title and elapsed time; completion detection is **push-first with a snapshot fallback** — the latest `turn/end` remembered from the `session/event` push stream is the primary evidence (dispatched synchronously post-commit, always fresh), and the snapshot read-back (`lastTurnEndOf`) only serves as a fallback when the push is missing (the plugin was mounted mid-turn, or the event was dispatched inside a reload window before the new fiber remembered it) (issue #290 phase two: a one-off snapshot read lag no longer solidifies into permanent silence; the same turn notifies only once, and a skipped decision emits an observable warn log that names the evidence source); subagent completion uses a separate toggle `notifySubagentDone` (off by default; subagents include spawned ones with `origin: subagent` and fork-delegated workers whose runtime ownership holds — fork mainline sessions without ownership are unaffected and still report as main-task completion); no completion notification is sent when the user stops generation / interrupts / the task fails / the task is blocked (when this turn's `turn/end` reason is `aborted`/`interrupted`/`error`/`blocked` it is always silent — failed tasks are handled separately by the error reminder's "task errored" so the same turn never both errors and falsely reports completion)
- **Error reminder**: notifies when a task errors (`agent/error`), including task title, the errored turn/step, and the error message (first 300 chars)
- **Turn completion** (off by default): notifies on `agent/turn-stopping`
- **Dual channels**:
  - System notifications: native Windows toast (embedded PowerShell WinRT script, zero dependencies); macOS uses `osascript` (display notification, zero dependencies); Linux uses `notify-send` (only when available)
  - Browser notifications: SSE frame push + Notification API (only pops when the page is hidden)
- **Three switches per channel: enabled / popup / sound**: the browser and system channels are
  each a **built-in channel entry** in `channels`, carrying "Enabled" (send or not), "Popup"
  (pop or not) and "Sound" (sound or not, which tone). **Turning the channel switch off means
  no delivery at all** — not even sound. That is the dividing line from the old behavior: a
  single key used to act as both popup toggle and channel switch, so "popup off + sound on"
  still made noise. Sound values: muted / follow the system default / built-in tones
  `ding`·`bell`·`chime`·`pop` with Preview; Linux system notification sound is fixed by host
  self-play of freedesktop event sounds (notify-send used to carry no sound hint and DE support
  varies); see "Configuration → Per-channel three switches".
- **Insecure-context fallback**: on LAN HTTP access the browser blocks system-level popups — automatically falls back to "in-page banner + sound + title reminder"
- **Do-not-disturb windows (multi-window as of 0.2.6)**: up to 5 windows (`quietHours.windows`); hitting any one suppresses (union semantics, overlaps allowed), an empty array means no hit; a single window supports crossing midnight (e.g. 22:00 → 08:00); an **urgent exception** can be set (`quietHours.allowKinds`: events still reminded during DND). The default candidates are the high-frequency blocking kinds (approval / question / error); the settings page lets you check **all 6 built-in events** (including task-done / subagent-done / turn-end) with one-click "Follow enabled events" or "Reset default". Exemption is orthogonal to the event toggles — a disabled event never produces notifications anyway, and the exemption entry stays intact; disabled events are shown dimmed (reduced opacity) on the settings page and can still be exempted. The settings page adds/removes rows and previews the current hit from this device clock (preview for reference only; the server verdict and history rule). **Upgrade note (0.2.6)**: legacy `start`/`end` are moved into `windows[0]` at assembly time and the old keys deleted; submitting the old shape after the upgrade always returns 400 with a refresh hint. **Older upgrade note**: now that the allow-list is open, kinds that older configurations used to filter out (e.g. hand-edited `done`/`turn-end`) will be reminded again during DND — a behavior change; adjust the exemptions on the settings page if you do not want that
- **Settings-card diagnostics**: the plugin card under Settings → Plugins → dsh-notifier shows the browser notification permission status and a secure-context hint, plus the 10 most recent notification records, a "Send test notification" button, and a "Clear history" entry
- **Host capability self-check (as of 0.2.4)**: the **body of the system card** shows the host-channel verdict (whether popup / sound work, plus any dimension that cannot be determined), and lists remediation advice when something is unavailable (which package to install, or switch to the browser channel); the detail (which dimensions were probed, which players were found) is collapsed. It deliberately stays out of the **card header**, which is hidden on narrow screens — exactly where "why did nothing ring" matters most. The **body of the browser card** shows the browser-channel verdict, which is computed **on this device**, so it differs per device. Both come from `GET /diagnostics`

Notifications use Chinese or English as explicitly selected in DSH; an unset preference (automatic language mode) or an unsupported language falls back to Chinese. In automatic mode, host notifications do not use the language automatically matched by the browser. No separate notification language configuration is needed.

| Text scope | Behavior |
|---|---|
| Built-in notifications | Product copy for the 6 built-in event notifications and their durations, plus test notifications (including draft dry runs) and the default title used when an SDK caller does not supply one, is translated between Chinese and English |
| Identifiers and names | Regular tool IDs and MCP tool IDs are shown verbatim; an unknown tool name is also shown verbatim, and a missing name is rendered as `?` |
| Caller content | Task names, questions, request reasons, raw error text, and titles and bodies supplied by SDK callers remain unchanged |
| Channels and history | All channels share the same copy; history records keep the body in the language used when they were sent, and changing the language affects only subsequent notifications |

<a id="development-and-architecture"></a><a id="user-content-development-and-architecture"></a>
## Development and architecture

For architecture and runtime mechanisms, see the [TOGAF 4A architecture document](../../docs/architecture/dsh-notifier.md) (in Chinese): Business, Application, Data, and Technology views.

### Event subscription and scope semantics ({global:true} trade-off)

When subscribing to host events (`approval/request`, `user-questions/request`, `session/event`,
`agent/status`, `agent/disposed`, `agent/error`, `agent/turn-stopping`), the plugin always
registers `{ global: true }` (cordis `EventOptions`, "Receive the event regardless of
context filter checks"). The trade-off (issue #290):

- **Events arrive anyway under an untagged flat mount**: a plugin ctx inserted flat via
  `cordis.patch.yml` carries no scope tag, and the host dsh-scope event dispatch lets
  listener contexts without a scope tag through — agent-scoped events arrive even without
  `{ global: true }`;
- **`{ global: true }` is consumer-side defense**: it decouples event arrival from the host's
  scope-dispatch semantics — if the plugin ever runs in a private-scoped mount form (listener
  ctx carrying a scope tag that does not match the event carrier's scope), `hook.global`
  passes unconditionally in the dispatch filter, so notifications never go mute on scope
  filtering (every `ctx.on` registration in this plugin carries the option);
- **The cost (the trade-off)**: `global` also receives **cross-scope** events — under an
  extreme deployment of many plugins and many scopes it may receive events outside the
  current ctx scope. Every listener in this plugin consumes with "payload self-validation +
  per-agent / event-content filtering" (event payloads are untrusted across the host
  boundary, validated field by field at runtime, and a non-finite turn is skipped outright),
  so a cross-scope arrival is only filtered out silently and never produces a wrong
  notification; this stage **adds no configuration key** to control that behavior.

### Outward contract (surface visible to other plugins, #733 convergence)

The service surface is exposed on the host context as `ctx["wingsky.notifier"]`, and its `apiVersion` is **2**. What this domain-by-domain rewrite converges on the outside:

- **The `wingsky-notify/sent` event is retired**: delivery terminal states are now carried by two query surfaces — `GET /api/dsh-notifier/status` (per-channel latest state + consecutive failure count) and `GET /api/dsh-notifier/history` (recent records, including per-channel delivery details);
- **`registerChannel` is retired**: it promised a channel-contribution model that never landed; channel types are built-in only (system / browser / bark / webhook);
- **`send` no longer returns an accepted array**: it returns `Promise<void>` — the `ok` in that array meant "accepted", not "delivered", and misreading the direction is more expensive than having no return value at all;
- **`registerKind` and `send` themselves are unchanged**: consumers using only those two are unaffected.

### Type dependencies

Host-side types come from the official `@deepseek-ai/*` packages (`dsh-agent`,
`dsh-session`, `dsh-host-webserver`, ...; versions are pinned in the repository's
`pnpm-workspace.yaml` catalog and upgraded with DSH releases): **`import type` only,
compile-time usage** — build artifacts contain zero official runtime imports; all
runtime objects are injected by the dsh host. The package declares this host coupling
as optional peerDependencies; consumers running type checks against the plugin must
be able to resolve these official packages (skipping type checking is unaffected).

### Test layers

Tests are organized under `test/{unit,integration,e2e,client,client-dom,client-unit}/`: host unit and integration tests, e2e smoke tests, client artifact checks, DOM tests and client unit tests. Mutation testing reuses the relevant assertions through the lib→src hook; these layers do not imply real-machine coverage on every platform.

```sh
# Source is in src/, must build after changes
pnpm --filter @wingsky-1/dsh-notifier build
pnpm --filter @wingsky-1/dsh-notifier test
```

### Real-machine gaps (platform matrix status, issue #766)

This section records, as-is, which platform behaviors have only been verified at the unit /
simulated layer and which have never run on a real machine. It neither claims full coverage nor
leaves the gaps for the reader to guess. The current state is verifiable by command:

- **CI has Linux runners only**: all 15 `runs-on` entries under `.github/workflows/` are
  `ubuntu-latest`, and `windows-latest` / `macos-latest` match 0 times:

  ```sh
  git grep -n "runs-on" origin/main -- .github/workflows                          # 15 lines, all ubuntu-latest
  git grep -n -E "windows-latest|macos-latest" origin/main -- .github/workflows   # no output, exit 1
  ```

- **`windowsHide` appears in the implementation only, with zero hits in the test surface**: the
  package's single occurrence is `src/server/channels/impl/system/deps.ts:81` —
  `{ windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }` — while the test surface
  (`packages/dsh-notifier/test`) has none:

  ```sh
  git grep -n windowsHide origin/main -- packages/dsh-notifier/test   # no output, exit 1
  ```

  On Linux it is an **equivalent mutant** — flipping it never turns a test red; only a real
  Windows machine can tell the difference.

| Surface | Verification layer | Real-machine status |
|---|---|---|
| Linux system notification: stub `notify-send` receives the exact argv | e2e (real child process, stub prepended to PATH) | Verifies the stub, not a real machine |
| Linux system notification: real `notify-send` invoked and its exit code asserted | e2e, platform marker | **Conditional coverage**; prerequisites below. |
| Decision face on all three platforms (platform probing, command construction, missing toast-script warning, SoundPlayer allow-list, stderr pipe) | unit tests, injected fake process-facts port | Verifies branch logic; **not** real-machine behavior |
| Windows real machine: does `toast.ps1` + the PowerShell base64 payload actually pop a toast | none | **Not covered** (no `windows-latest` runner; the win32 e2e case has not been written) |
| Windows real machine: real behavior of `spawn({windowsHide:true, …})` | none | **Not covered** (same as above) |
| macOS real machine: `osascript -e 'display notification …'` popup | none | **Not covered** (no `macos-latest` runner) |
| macOS real machine: `afplay` self-play and the listening experience of the tone mapping | none | **Not covered** (same as above; [sound-playback-design.md](./docs/sound-playback-design.md) states "listening equivalence not measured (no mac host)") |

**Real Linux notification prerequisites**: **Conditional coverage**: it actually runs only when the test machine has a real `notify-send` on the system PATH **and** a D-Bus session is available (`DBUS_SESSION_BUS_ADDRESS` set, or `$XDG_RUNTIME_DIR/bus` present); otherwise it skips with a reason. Whether it is covered therefore depends on the runtime environment and is not inferred here

How platform-marked cases are written, and the original "pending a CI matrix" plan, live in the
header comment of [test/e2e/smoke.test.ts](./test/e2e/smoke.test.ts).

**Trigger condition (what would close these gaps)**: adding `windows-latest` / `macos-latest`
runners to `.github/workflows/` to run the e2e project, plus platform-marked cases for those two
branches — that is option 1 of issue #766. This round takes option 2 (register only, no workflow
change): it needs runner quota and is a separate path. Until then, real-machine behavior can only
be confirmed by running on a machine of the target platform:

```sh
pnpm --filter @wingsky-1/dsh-notifier test
```

## License

MIT
