SCP-QQ/dsh-notice-center

dsh-notice-center

把标签页图标变成绿/琥珀状态灯,并在会话跑完或有交互待处理时发送浏览器通知,含 47 个可选音效。

Uncategorized

dsh-notice-center 🔔🐋

中文 | English

dsh-notice-center — Get Every Message, Never Miss a Thing

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.

Packagedsh-notice-center
Plugin instance idnotice-center
Settings namespacenotice-center
Install shapesnpm package / GitHub repo / tarball / local link — all four supported

Contents

Usage

Development

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 pageWhat you get
A session finishedThe tab whale turns green, plus a system notification (with the turn duration when it is a single notification)
Something needs youThe 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 colourMeaningWhen it clears
Green GreenA session finished while you were not watching itClears once you open that session; back to default once you have opened them all
Amber AmberA session awaits you: question / approval / plan reviewClears once handled
Black BlackAll clear (the stock icon)Default state

Tab status light: top to bottom — finished (green), pending (amber), default (black)

  • 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:

EventTriggerWhat the notification says
FinishedA session completesTitle = session name; body = Session finished, with the turn duration appended when it is a single notification (e.g. Session finished · turn took 3s)
PendingA new pending interaction appearsTitle = 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 notificationPending notification

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.

PackEntries
Built-in (synthesised)Chime Up (finished default, rising two-tone), Chime Down (pending default, falling two-tone)
AlertAlert 01–10
Bip-bopBip-bop 01–10
StaplebopsStaplebops 01–07
NopeNope 01–12
YupYup 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)
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

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)

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

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

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):

    StateChipWhat to do
    Not granted (you dismissed the prompt)"Not granted - Grant", clickableClick it and the browser asks again
    Blockedwarning 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
    Unsupportedone small lineUsually a secure context problem (see Install → Requirements): use http://127.0.0.1:3080
    Grantedhidden (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

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

GroupSettingKeyDefaultNotes
Whale status lightMaster switchcolorsEnabledonWhen off, the stock icon is kept
Finishedgreen#22C55EFinished status-light colour (official sidebar colour)
Pendingamber#F59E0BPending status-light colour (official sidebar colour)
Default colourblackunsetUnset = keep the stock /favicon.svg
System notificationsMaster switchnotifyEnabledonOn by default; the first interaction asks for browser permission, and a status chip appears below the switch while it is missing
Auto hidenotifyAutoHideonOff = the notification stays until you dismiss it
Notify in the foregroundnotifyForegroundoffOn = also notify while you are looking at the page
SoundnotifySoundonWhen on, the system sound is muted and the plugin sound plays
VolumenotifyVolume0.60–1
Finished soundnotifyDoneSoundbuiltin-upone of 47
Pending soundnotifyPendingSoundbuiltin-downone 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

SymptomCause and fix
No "Notification center" entry in SettingsThe 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 effectAfter 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 installingThe 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 allThree 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 IPNon-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 tabKnown limitation, see below
The green light disappears after a refreshExpected. That state is the official signal's in-memory state and is lost on refresh
The notification flashes by too fastTurn Auto hide off and it stays until dismissed
No soundCheck 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 OSWindows 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:

PartFileRuns inResponsibility
Host halflib/index.mjsHarness 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 halflib/client.cjsBrowser (plugin bundle)favicon state machine + notification state machine + sound library + settings page
Bundle patchcordis.patch.ymlLoader layerInsert 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):

ServiceProvided byUsed for
configForms@deepseek-ai/dsh-client-ui-settingsconfigForms.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/clientSession list snapshot: origin / running / displayTitle
uiSession@deepseek-ai/dsh-client-ui-sessionsessionStatus: 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

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 changedRestart Harness?Why
lib/client.cjs (browser half)No — a page refresh is cleanestClient HMR stat-polls the bundle and tells the browser to reload that plugin
lib/index.mjs (host half)Yes, mandatoryHost-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 renameYes, mandatoryThe 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

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 — ordinary commits and pushes never publish: