# dsh-notice-center 🔔🐋

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

[![test](https://github.com/SCP-QQ/dsh-notice-center/actions/workflows/test.yml/badge.svg)](https://github.com/SCP-QQ/dsh-notice-center/actions/workflows/test.yml)
[![npm](https://img.shields.io/npm/v/dsh-notice-center.svg)](https://www.npmjs.com/package/dsh-notice-center)

<img src="docs/images/banner-en.jpg" alt="dsh-notice-center — Get Every Message, Never Miss a Thing" width="100%">

**In one line**: a **notification center** for DeepSeek Harness — it turns the tab's whale icon into a status light and gets your attention while you are looking elsewhere.

| | |
|---|---|
| Package | `dsh-notice-center` |
| Plugin instance id | `notice-center` |
| Settings namespace | `notice-center` |
| Install shapes | npm package / GitHub repo / tarball / local link — all four supported |

## Contents

**Usage**

- [Why you need it](#why-you-need-it)
- [Features](#features)
- [Installation](#installation)
- [Getting started in three steps](#getting-started-in-three-steps)
- [Settings reference](#settings-reference)
- [Troubleshooting](#troubleshooting)
- [Known limitations](#known-limitations)

**Development**

- [Architecture](#architecture)
- [Project layout](#project-layout)
- [Life of a notification](#life-of-a-notification)
- [Local development](#local-development)
- [Tests](#tests)
- [Releasing](#releasing)
- [Changelog](./CHANGELOG.md)
- [Design constraints](#design-constraints)

## Why you need it

Inside Harness, the two signals that matter — "a session finished" and "something is waiting for your decision" — only show up on the **sidebar dots** and **inside the session itself**. The moment you switch to another tab or another app, they are invisible.

Long tasks make this worse: you go do something else, come back, and the session finished ages ago — or an approval request has been sitting there for ten minutes.

This plugin moves both signals somewhere you will actually see them:

| While you are away from the DSH page | What you get |
|---|---|
| A session finished | The tab whale turns **green**, plus a system notification (with the turn duration when it is a single notification) |
| Something needs you | The tab whale turns **amber**, plus a system notification (saying whether it is an approval / question / plan review) |

## Features

### 1. Whale status light

Three colours are encoded directly into the tab icon, driven by **the same official signal** as the sidebar dots, so they can never drift apart:

| Whale colour | Meaning | When it clears |
|---|---|---|
| <img src="docs/images/whale-green.svg" width="20" alt="Green"> Green | A session **finished** while you were not watching it | Clears once you open that session; back to default once you have opened them all |
| <img src="docs/images/whale-amber.svg" width="20" alt="Amber"> Amber | A session **awaits you**: question / approval / plan review | Clears once handled |
| <img src="docs/images/whale-black.svg" width="20" alt="Black"> Black | All clear (the stock icon) | Default state |

![Tab status light: top to bottom — finished (green), pending (amber), default (black)](docs/images/tab-status-light.png)

- Only **main sessions** count; subagents do not affect it
- When both apply, **amber wins** — a pending interaction is the primary status, so a session stuck waiting on you is never masked by another session that just finished
- All three colours are configurable (including the default colour: leave it unset to keep the stock icon)

### 2. System notifications

Two events raise a notification, and by default **only while you are not looking**:

| Event | Trigger | What the notification says |
|---|---|---|
| **Finished** | A session completes | Title = session name; body = `Session finished`, with the turn duration appended when it is a single notification (e.g. `Session finished · turn took 3s`) |
| **Pending** | A new pending interaction appears | Title = session name; body = the interaction type: `Approval needed · <tool>` / `Choose an option` (`Choose options` for multi-select, `Type an answer` when there are no options, `N questions` for a batch) / `Plan review`; unknown types fall back to `Something awaits you` |

| Finished (green) | Pending (amber) |
|---|---|
| ![Finished notification](docs/images/notification-done-en.png) | ![Pending notification](docs/images/notification-pending-en.png) |

> Notifications are raised by the browser (Chrome here), so the card shows the browser and the site `127.0.0.1:3080` — that is expected.
> The screenshots show the English UI; notification copy is localised at runtime.

Other behaviour:

- **Click a notification** = focus the window and open that session
- **Only while you are not looking** — "foreground" means the tab is *visible* **and** the window has focus. Switching tabs, minimising, or having another app on top all notify. To also notify while you are looking (you are parked in a session but away from the screen, e.g. on your phone), turn on **Notify in the foreground**
- **Aggregation**: events within 300ms are merged into one notification, with `+N` appended to the title
- **Optional persistence**: turn off **Auto hide** and the notification stays on screen until you dismiss it
- The notification icon follows your configured status-light colours

### 3. Sounds

Finished and pending each have their own dropdown, **47 entries** in total: 2 built-in synthesised chimes plus 45 mp3s taken from opencode's bundled sound library.

| Pack | Entries |
|---|---|
| Built-in (synthesised) | `Chime Up` (finished default, rising two-tone), `Chime Down` (pending default, falling two-tone) |
| Alert | Alert 01–10 |
| Bip-bop | Bip-bop 01–10 |
| Staplebops | Staplebops 01–07 |
| Nope | Nope 01–12 |
| Yup | Yup 01–06 |

- **Hover to preview**: hovering a dropdown entry plays it (120ms debounce, so sweeping the list does not turn into noise)
- **Preview after adjusting volume**: releasing the volume slider plays the finished chime at the new volume
- With sound on, **the system notification sound is muted** and the plugin sound is used instead
- Audio is served by the host half at `/notice-center-sounds/<id>.mp3`; if that route is unavailable it **falls back to the built-in chime**, never to silence
- No migration needed: existing configs keep using `Chime Up` / `Chime Down`

## Installation

### Requirements

- **Official DeepSeek Harness**: `1.4.0` requires **`0.1.7-rc.2+`**; `1.3.x` supports `0.1.2-rc.1+`
  - The range is declared in `engines.dsh` and the `@deepseek-ai/dsh-*` peers, guarded by `test/compat-range.smoke.mjs`. The two lines split by host version: **the Market never offers 1.4.0 to an older host, and the DSH core refuses to load it** — older hosts simply stay on `1.3.x` with no update prompt.
  - The version gate only compares version strings and cannot detect a removed API (0.1.7 dropped `settings.register` while the old declaration still passed), so never treat it as a compatibility guarantee on its own.
- System notifications need a **secure context** — `http://127.0.0.1:3080` or `localhost` works; over a **LAN IP** the browser's Notification API is unavailable (a browser restriction, not a plugin issue)

### Option 1: from npm (recommended)

```sh
dsh plugin --profile web add dsh-notice-center
```

Package page: <https://www.npmjs.com/package/dsh-notice-center> (`latest` is stable; prereleases go to the `next` tag — install with `dsh-notice-center@next`)

### Option 2: straight from GitHub

```sh
dsh plugin --profile web add github:SCP-QQ/dsh-notice-center
# or over SSH (when a proxy interferes with HTTPS on your machine)
dsh plugin --profile web add git+ssh://git@github.com:SCP-QQ/dsh-notice-center.git
```

### Option 3: from a tarball (offline / internal distribution)

```sh
npm pack                                        # produces dsh-notice-center-<version>.tgz
dsh plugin --profile web add ./dsh-notice-center-<version>.tgz
```

### Option 4: local link (while editing the code)

In the profile's `package.json`: add `"dsh-notice-center": "link:<project dir>"` to `dependencies` and `"dsh-notice-center"` to `dsh.profile.bundles`, then run `pnpm install` inside the profile directory.

> **All four options require a Harness restart to take effect.**
> Installing from npm / GitHub needs no extra `@deepseek-ai/*` packages from the profile — the host half's only runtime dependency is `@deepseek-ai/schemastery` (available on npm).

### Uninstall

```sh
dsh plugin --profile web remove dsh-notice-center
```

Remove the dependency and the `dsh.profile.bundles` entry, then restart — nothing is left behind.

## Getting started in three steps

1. **Open the settings page** — Settings → **Notification center** (a sidebar entry)
2. **Grant system notifications** — the master switch is **on by default**: after the page loads, your first click or key press makes the plugin ask the browser for notification permission once (browsers **refuse** requests without a user gesture, so the page cannot pop it on its own — it borrows your first interaction). Choose *Allow* and you also get a "Notifications enabled" confirmation. Frequency rules:

   - **at most once per page load** (clicking more in the same session never re-prompts);
   - choose *Allow* or *Block* → **never asked again** (the permission is settled);
   - merely closing the prompt with **× / Esc** (neither choice) → not a decision, so the **next page load asks again** until you decide;
   - to stop it entirely: turn the **System notifications** switch off, or choose *Block*.

   Later, use the status chip right **below the switch** (the short state is always visible; hover it or focus it with the keyboard for the full explanation):

   | State | Chip | What to do |
   |---|---|---|
   | **Not granted** (you dismissed the prompt) | "Not granted - Grant", clickable | Click it and the browser asks again |
   | **Blocked** | warning icon + "Blocked" | Browsers do **not** ask twice (and a page may not open site settings): hover for the recovery path — click the icon at the left of the address bar → Notifications → Allow, or open `chrome://settings/content/notifications` |
   | **Unsupported** | one small line | Usually a **secure context** problem (see *Install → Requirements*): use `http://127.0.0.1:3080` |
   | Granted | hidden (the row stays clean) | — |

   The switch itself always works (you can turn notifications off even while unauthorised — and turning it off stops the automatic request); change the permission in site settings and the chip follows immediately.
3. **Tune as you like** — expand the two groups: change colours, pick sounds, set volume, toggle **Notify in the foreground** / **Auto hide**

<img src="docs/images/settings-en.png" width="440" alt="Notification center settings page">

After that, whenever you switch away or minimise, a finished session or a pending interaction notifies you — and clicking the notification jumps straight back to that session.

## Settings reference

| Group | Setting | Key | Default | Notes |
|---|---|---|---|---|
| Whale status light | Master switch | `colorsEnabled` | on | When off, the stock icon is kept |
| | Finished | `green` | `#22C55E` | Finished status-light colour (official sidebar colour) |
| | Pending | `amber` | `#F59E0B` | Pending status-light colour (official sidebar colour) |
| | Default colour | `black` | unset | Unset = keep the stock `/favicon.svg` |
| System notifications | Master switch | `notifyEnabled` | **on** | On by default; the first interaction asks for browser permission, and a status chip appears below the switch while it is missing |
| | Auto hide | `notifyAutoHide` | on | Off = the notification stays until you dismiss it |
| | Notify in the foreground | `notifyForeground` | off | On = also notify while you are looking at the page |
| | Sound | `notifySound` | on | When on, the system sound is muted and the plugin sound plays |
| | Volume | `notifyVolume` | `0.6` | 0–1 |
| | Finished sound | `notifyDoneSound` | `builtin-up` | one of 47 |
| | Pending sound | `notifyPendingSound` | `builtin-down` | one of 47 |

Settings are persisted by Harness' settings service into the `notice-center:` section of `<DSH_HOME>/settings.yaml`.
The settings page also shows a one-line footer at the very top with the plugin name and version (e.g. "Notification center v1.3.0") — clicking it opens the repository in a new tab.

## Troubleshooting

| Symptom | Cause and fix |
|---|---|
| No "Notification center" entry in Settings | The plugin did not load. Make sure you **restarted Harness** after installing, then look for `settings namespace "notice-center" registered` in the Harness console |
| A switch does nothing / a new setting has no effect | After changing the **host half** (`lib/index.mjs`) you must restart Harness — settings writes go through the host schema |
| A permission prompt popped up on my very first click after installing | The master switch is on by default, so the plugin borrows your **first interaction** (click / key press) to ask once — browsers refuse permission requests without a user gesture. Choose *Block* if you do not want it, and use the status chip in the settings later |
| No notifications at all | Three conditions are required: ① the master switch is on; ② browser permission is *Allow*; ③ the page is not in the foreground when the event fires (use **Notify in the foreground** to relax this) |
| No notifications over a LAN IP | Non-secure context: the browser disables the Notification API, so system notifications are **entirely unavailable** (unrelated to *Notify in the foreground*). Use `127.0.0.1` / `localhost`; over a LAN only the tab status light works |
| Nothing after closing the tab | Known limitation, see below |
| The green light disappears after a refresh | Expected. That state is the official signal's in-memory state and is lost on refresh |
| The notification flashes by too fast | Turn **Auto hide** off and it stays until dismissed |
| No sound | Check in order: sound switch, volume not 0, whether the sound route is available (it falls back to the built-in chime, so it should never be fully silent) |
| Notifications swallowed by the OS | Windows Focus Assist / Do Not Disturb intercepts them; the plugin cannot detect it |

## Known limitations

- **No notifications once the browser tab is closed** — Web Push needs a server plus HTTPS, which is not realistic for a local plugin
- **The Notification API is unavailable in a non-secure context** (LAN IP)
- **The green state is lost on refresh** — it lives in memory only, which is the behaviour of the official signal itself
- **System-level Do Not Disturb swallows notifications** (Windows Focus Assist etc.); the plugin cannot detect it
- **The auto-hide duration is not configurable** — the Web Notification spec has no duration parameter; the desktop decides (roughly 5–20 seconds)
- **The notification card cannot be styled** — the card is rendered by the browser with no CSS or theming hook; only title, body and icon are yours

---

# Development

> Everything below is for maintainers: architecture, local development, tests and releasing. If you only want to use the plugin, you can stop here.

## Architecture

The plugin has **two halves** running in different processes, exchanging configuration through Harness' settings service:

| Part | File | Runs in | Responsibility |
|---|---|---|---|
| **Host half** | `lib/index.mjs` | Harness main process (Node) | ① Declare the `Config` schema (settings form, write validation and the legacy `settings.yaml` import all derive from it); ② serve `/notice-center-sounds/<id>.mp3` |
| **Browser half** | `lib/client.cjs` | Browser (plugin bundle) | favicon state machine + notification state machine + sound library + settings page |
| **Bundle patch** | `cordis.patch.yml` | Loader layer | Insert one row `id: notice-center` into the plugin tree (that id IS the settings namespace) |

**The host half's two optional channels**:

- `ctx.inject(["settings"], …)` — declares "this plugin ships its own page": `settings.configure({ auto: false }, ctx.fiber)`. The owner must be the plugin's own fiber (`describe()` looks the policy up by `entry.fiber`, and the `inject` child is a different fiber)
- `ctx.inject(["webServer"], …)` — mounts the sound route. When the service is absent the route is not mounted and the browser half falls back to the built-in chime

**The settings contract (since 0.1.7 — the easiest thing to break silently here)**:

- The form is derived from the **plugin's own Config**: dsh-settings' `describe()` reads the entry fiber's `runtime.Config`, and the entry id is the namespace — the plugin **no longer registers a namespace** (the old `settings.register(ns, schema)` was removed in 0.1.7).
- **Every field must be `.volatile()`**: `volatileForm()` only projects fields under the nearest volatile ancestor, so one unmarked field disappears from the settings page entirely (`test/host-half.smoke.mjs` asserts this field by field). `.volatile()` requires **schemastery ≥ 3.18.4**.
- Existing users need no manual migration: at startup dsh-settings reads `<DSH_HOME>/settings.yaml`, writes each section into the entry carrying the **same id**, then renames the file to `settings.yaml.imported`. Our section name equals the entry id (`notice-center`).

**Why the host half does not import `@deepseek-ai/dsh-settings`**: it keeps the host half's only runtime dependency at `@deepseek-ai/schemastery` — and because profiles default to `autoInstallPeers: false`, resolving any `@deepseek-ai` peer would actually fail on a version mismatch and break loading. This is what makes a clean registry install possible.

**Where the browser half gets its data** (all official client services; the plugin holds no state of its own):

| Service | Provided by | Used for |
|---|---|---|
| `configForms` | `@deepseek-ai/dsh-client-ui-settings` | `configForms.get(entryId)` reads and writes settings (the old `settingsScope` was removed in 0.1.7); the snapshot carries `status` / `writable` |
| `sessions` | `@deepseek-ai/dsh-api-session-controller/client` | Session list snapshot: `origin` / `running` / `displayTitle` |
| `uiSession` | `@deepseek-ai/dsh-client-ui-session` | `sessionStatus`: `Map<sessionId, { running, pendingInteraction, completionUnread }>` — 0.1.7 removed both the row fields `completed` / `pendingInteraction` and the `pendingInteractions` store; the official workspace session rows use the same mapping (`completed` ≡ `completionUnread`) |

The sound route is deliberately narrow: only `^[a-z0-9-]+\.mp3$` filenames are served (no path traversal), and because the audio is a package constant it is cached `immutable`.

## Project layout

```
package.json                    package metadata (scripts: test / test:host / test:client)
cordis.patch.yml                bundle patch layer (insert id: notice-center)
lib/index.mjs                   host half: settings schema + sound static route
lib/client.cjs                  browser half: favicon machine + notification machine + sound library + settings page
assets/audio/*.mp3              45 opencode sounds (MIT; see assets/audio/README.md)
docs/images/                    README screenshots, banners and the social-preview card
test/host-half.smoke.mjs        host-half smoke test (164 lines)
test/client-half.smoke.mjs      browser-half smoke test: state machine + real settings-page render (687 lines)
.github/workflows/test.yml      CI: runs pnpm test on push / PR
.github/workflows/publish.yml   release: tag-triggered, OIDC, no token
```

## Life of a notification

```
sessions.list changes
   └─ sync()
        ├─ trackEdges()        running true->false edge (compensation when the current session finishes)
        ├─ detectTransitions() official completed false->true; new sessions in pendingInteractions
        │     └─ queueNotification(kind, sessionId, label, typeLabel)
        │          ├─ master switch off            -> dropped
        │          ├─ this same completion already queued -> dropped (no double-send)
        │          └─ enqueued + 300ms aggregation window
        └─ targetOf() -> favicon becomes green / amber / default

300ms later, flushNotifications()
   ├─ permission not granted                     -> dropped
   ├─ page in foreground and foreground notify off -> dropped
   ├─ group by kind -> one Notification per group (title = session name, body = event type)
   └─ play the selected sound
```

Notifications deliberately carry **no `tag`**: the same session reuses the same tag across runs, and Windows/Chrome treats the later one as an *update* to an existing notification — it replaces silently and never raises a banner again.

## Local development

```sh
pnpm install          # the only runtime dependency is @deepseek-ai/schemastery
npm test              # host-half + browser-half smoke tests
```

**The restart rule has two halves — this is the easiest thing to trip over:**

| What you changed | Restart Harness? | Why |
|---|---|---|
| `lib/client.cjs` (browser half) | **No** — a page refresh is cleanest | Client HMR stat-polls the bundle and tells the browser to reload that plugin |
| `lib/index.mjs` (host half) | **Yes, mandatory** | Host-half hot reload relies on `cordis-plugin-hmr` (needs the loader to run with `--expose-internals`), which does not work in practice. **Schema changes (adding/removing settings) also need a restart** — otherwise the settings page write fails validation, which looks like "the switch does nothing" |
| First install, any rename | **Yes, mandatory** | The plugin tree and bundle list changed |

Debugging: browser-half errors land in the DevTools console; a successful host-half registration logs `dsh-notice-center: settings namespace "notice-center" registered` to the Harness console.

## Tests

```sh
npm test            # both (this is what CI runs)
npm run test:host   # host half only
npm run test:client # browser half only
```

Both smoke tests are **zero-dependency, pure Node** — no browser needed:

- **Host half** (164 lines): actually executes `apply()`, asserts only the expected services are injected, checks schema defaults and validation, exercises the sound route (serves an mp3 / 404 for unknown / rejects path traversal / all 45 packaged files present) and verifies that an existing `settings.yaml` passes the schema **with every field preserved verbatim**
- **Browser half** (687 lines): stubs `window.__ModuleLoader__` / `document` / `Notification`, evaluates the bundle and takes its factory, assembles `apply()` with a fake `ctx`, then drives state transitions and asserts the `Notification` constructor arguments. The settings page is **really rendered** against a recording stub (React element tree), asserting switch order, that each control writes only its own field, collapse behaviour, the footer, and more

Invariants pinned by the tests — do not break these:

- `PLUGIN_VERSION` / `PLUGIN_NAME` / `PLUGIN_REPO` in `lib/client.cjs` must match `package.json`
- The `SOUND_PACKS` library must map **one-to-one** onto the files in `assets/audio/`; adding a sound means updating it in **two places**: `lib/client.cjs` and `test/client-half.smoke.mjs`
- The `zh` and `en` dictionaries must have identical key sets

## Releasing

Releasing is driven by a **tag-triggered** [`publish.yml`](.github/workflows/publish.yml) — ordinary commits and pushes **never publish**:

| Intent | Command | Result |
|---|---|---|
| Commit only (feature not validated yet) | `git commit` + `git push` | Only `test.yml` runs; npm sees nothing |
| Prerelease (for testers) | set version to `1.3.0-rc.1` → commit → `git tag v1.3.0-rc.1 && git push origin v1.3.0-rc.1` | Published to the **`next`** tag; `latest` untouched |
| Stable release | set version to `1.3.0` → commit → `git tag v1.3.0 && git push origin v1.3.0` | Published to **`latest`**, with provenance attached |
| Validate the pipeline only | Actions → publish → Run workflow | Installs, tests and runs `npm pack --dry-run` — **publishes nothing** |

The workflow has **three gates**; failing any one blocks the release:

1. The tag must match the `package.json` version
2. The tagged commit must already be on `main`
3. Tests must pass

The publish step is also **idempotent**: if that version already exists on the registry it is skipped, so re-tagging or re-running never fails the build.

Before tagging, add this release to [`CHANGELOG.md`](./CHANGELOG.md) (user-visible changes plus compatibility notes) — it is the source of the release notes shown on the npm page and the GitHub Release.

### One-time setup: npm Trusted Publishing (OIDC)

Publishing needs no npm token at all (so there is nothing to expire or leak). Open the package page → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → choose **GitHub Actions**:

<https://www.npmjs.com/package/dsh-notice-center/access>

| Field | Value |
|---|---|
| Organization or user | `SCP-QQ` |
| Repository | `dsh-notice-center` |
| Workflow filename | `publish.yml` (**filename only**, no path; renaming the file requires updating this or publishing returns 403) |
| Environment name | `npm` (must match the workflow's `environment:`) |
| **Allowed actions → Allow npm publish** | ✅ **required** |

> ⚠️ That checkbox is new npm behaviour: a trusted publisher may **only `npm stage publish`** by default, so without *Allow npm publish* the workflow's `npm publish` is rejected.
> If you would rather have "staged only, human approves in the browser", leave it unchecked and change the last step to `npm stage publish`.

- With OIDC, npm attaches **provenance automatically** (no `--provenance` flag); the package page shows the attestation
- To require a manual approval after pushing a tag: repository Settings → Environments → `npm` → add **Required reviewers** (optional)
- Publishing from your own machine is still possible with a granular token: put `//registry.npmjs.org/:_authToken=<token>` in an `.npmrc` at the project root. That file is already in `.gitignore` — **never commit it**

## Design constraints

Read these before changing code — each one came from a real bug:

1. **No `tag` on notifications** — the same tag is treated as an *update* and silently replaces (see "Life of a notification")
2. **"Foreground" is `tab visible && window focused`** — using `visibilityState` alone misreads "browser buried behind another app" as "the user is looking", which is exactly when a notification matters most
3. **Amber beats green** — matching the official `sessionStatuses`; a pending interaction is the primary status
4. **The turn duration comes from observing the `running` edge** — a session already running when the page loads has no known start, so no duration is shown (never guessed)
5. **The host half keeps zero `@deepseek-ai` runtime dependencies** — only `schemastery`, otherwise a registry install breaks on peer resolution
6. **The sound route only allows `^[a-z0-9-]+\.mp3$`** — the filename goes straight into a path join, so it must be whitelisted

## Feedback and contributing

- Issues and ideas: <https://github.com/SCP-QQ/dsh-notice-center/issues>
- Run `npm test` before proposing a change; settings-related changes should update the assertions in `test/*.smoke.mjs`
- Sound assets come from [anomalyco/opencode](https://github.com/anomalyco/opencode) (MIT) — keep [`assets/audio/README.md`](./assets/audio/README.md) in sync when replacing or removing them

## License

MIT — see [`LICENSE`](./LICENSE) for the full text.

The 45 sound files in `assets/audio/*.mp3` come from [anomalyco/opencode](https://github.com/anomalyco/opencode) (`packages/ui/src/assets/audio/`), released upstream under MIT; the same licence and attribution are kept here. If upstream changes its terms, replace or remove this directory.
