DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

Notify Relay — DeepSeek Harness 插件(DSH Plugin)
DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录
← Plugins
N

dsh-notify-relay

Notify Relay

DSH 通知中继:把任务完成/失败/待审批等生命周期事件经过去重、免打扰、摘要合批的规则中枢,外联到 Bark/Server酱/Telegram/企业微信/飞书/钉钉/ntfy/通用 webhook。失败自动重试且重启不丢。严重度分级(Bark level/call、ntfy Priority、钉钉 isAtAll)、单通道熔断、抖动退避、外联自检告警。零运行时依赖。

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add github:Archaofan/dsh-notify-relay#1e459641202a54f4b2203bfe554ad412bbed8777
README兼容性版本

兼容性与来源证明

Notify Relay 以 dsh-notify-relay 发布,当前版本为 0.4.3。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
web
发布来源
github
Registry 更新时间
2026/9/25

版本

0.4.3stable
2026/9/25
0.4.2stable
2026/9/25
0.4.0stable
2026/9/25
查看其余 3 个版本收起版本
0.3.3stable
2026/9/25
0.3.2stable
2026/9/24
0.3.0stable
2026/9/24

相关插件

正在加载相关插件…

最新版
0.4.3
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
未提供
文件数
未提供
Surface
web
许可证
MIT
发布源
github
GitHub
★ 0
周下载
0
最近提交
2026/9/25
查看源码 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题

相关插件

继续浏览 integrations-communication 分类下经过校验的插件。

Acp App@deepseek-ai/dsh-acp-appdsh ACP 配置文件包:基于 dsh-base 的仅限自动化的 JSON-RPC stdio 和进程生命周期管理Univer Officedsh-univer-officeDSH × Univer 集成,内置协作 Gateway 和 Viewer:内联预览、实时浮动 Worktree 窗口,以及 DeepSeek Harness 中的会话结束审查操作。DSCODE@toddzheng024/dscode-bundle完整的 DeepSeek 编码代理,支持持久化 shell、Ultra 协作和自动权限审查。Bridge@wenbin_wb/dsh-bridge手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。

README

dsh-notify-relay

The outbound rule center for DSH: lifecycle events — task done, task failed, task aborted, task blocked, request failed, approval asked, approval decided, tool failed — go through dedup, quiet hours and digest batching, then out to Bark, ServerChan, Telegram, WeCom, Feishu, DingTalk, ntfy or any webhook. Failed deliveries retry with backoff and survive a restart.

Zero runtime dependencies, no build step, two source files (host face + browser face). Bilingual (zh / en) — the UI follows DSH's own language, and the language of the text pushed to your phone is set separately.

中文说明

Why another notifier

There are already plenty of DSH plugins that forward a message somewhere. What none of them do is decide whether the message should be sent at all. That decision is the whole product:

RuleWhat it stops
DedupThe same failure re-firing every second. A fingerprint of (kind, session, title, body) suppresses a repeat inside a 1–1440 minute window
Quiet hoursA build breaking at 02:40. Wrap-aware (22:00 → 08:00 crosses midnight); each repeat is either dropped or held for the next digest
DigestTwenty failures arriving as twenty pushes. Held notifications are batched into one message every N minutes, flushed on demand
Mute/notify mute 60 while you are in the middle of something. Suppresses everything, including fresh fingerprints
Approval pierceQuiet hours and digest never hold an approval request. Holding one until 08:00 stalls the task until 08:00, and the user blames the notifier, not the clock
RetryA channel that is briefly down. A failed delivery goes into a durable outbox and retries with 30s → 1m → 2m → … → 30min backoff; the queue survives a restart
RoutingPer-channel event filters: Telegram only for failures, Bark for everything
RedactionA token in a log file. Secrets are stored under a •••••••• mask in every API response and never appear in the delivery log

The channel adapters are deliberately thin — seven of them, each a pure function that builds one payload. The value is the layer above them.

What you see

WhereWhat you get
Official settings pageSettings navigation → Outbound relay: master switch, eight event switches, dedup window, quiet hours (start / end / mode), digest interval, delivery language, pending-retry queue, and the channel editor
Channel editorAdd / edit / delete channels; per-channel name, kind, endpoint, secrets, event filter, and a Test button that fires one real delivery to that channel
Sidebar footerA status pill — on/off, last delivery outcome, rail mode when the sidebar is collapsed. Clicking it opens the recent delivery log
Slash commands/notify status, /notify test [channel], /notify mute <minutes>, /notify unmute, /notify flush, /notify retry — no model round-trip

Rows where the rule center decided not to deliver also appear in the delivery log, with the reason (dedup / muted / quiet-hold / digest / no channel). "Did it fire at all?" is the most common complaint about notification plugins in this ecosystem, and a log of successful sends cannot answer it — the interesting row is the missing one.

The channel editor shows a masked secret and only sends it back to the host if you actually change it, so opening the editor and pressing Save never wipes a token you did not touch.

Channels

KindEndpointNotes
barkyour Bark server URLiOS push, group = dsh-notify-relay
serverchanyour SendKeyServerChan (Server酱)
telegrambot token + chat idsendMessage, silent on task-done
wecomwebhook keyWeCom robot markdown
feishuwebhook tokenFeishu interactive card
dingtalkaccess token + sign secret (optional)DingTalk custom robot markdown; critical @-mentions everyone
ntfytopic (+ optional server)ntfy JSON publish
webhookany URLGeneric JSON POST; an optional custom header carries the secret

Each channel has its own event filter (* for everything). Unknown kinds are dropped at validation time rather than failing the boot, so a config written by a newer version cannot take the plugin down. Unknown secret fields inside a known kind are kept — dropping them would silently destroy credentials.

Install

Verified against DSH 0.1.6-alpha.2 and 0.1.7-rc.2. The plugin code is identical across both: nothing is version-gated at runtime, and the only 0.1.7 difference found was the first-run onboarding (a full-page mask that intercepts every click), which affects the test harness rather than the plugin.

# production profile
dsh plugin --profile web add github:Archaofan/dsh-notify-relay --ignore-scripts

# or from a local checkout / tarball
dsh plugin --profile web add file:./dsh-notify-relay-0.3.3.tgz --ignore-scripts

--ignore-scripts matters: the plugin has no install scripts and no runtime dependencies, so refusing to run them is both faster and safer.

After installing, restart DSH (or reload the web app) and open Settings → Outbound relay.

If the second install fails with ERR_PNPM_MISSING_TARBALL_INTEGRITY

This is a pnpm bug, not a plugin one, and it reproduces in plain pnpm with no DSH involved:

$ pnpm add https://github.com/Archaofan/dsh-notify-relay/releases/download/v0.3.3/dsh-notify-relay-0.3.3.tgz
# ok, but the lockfile records `resolution: {tarball: ...}` with NO integrity
$ pnpm add <any-other-package>
ERR_PNPM_MISSING_TARBALL_INTEGRITY  Cannot install package "dsh-notify-relay@...":
its lockfile entry has no "integrity" field, so pnpm cannot verify the tarball.

When pnpm serves a tarball from its content-addressable store rather than downloading it, it writes the lockfile entry without an integrity field. The install that caused it succeeds; the next install in that profile then refuses to run, because pnpm will not install a tarball it cannot verify.

The first install always works. So this only bites when you add a second plugin to a profile that already carries one.

To clear it, delete both files and reinstall — deleting only the lockfile does not work, because pnpm regenerates it from package.json and the store is still warm:

# <DSH_HOME>/profiles/<profile>/
rm -rf node_modules pnpm-lock.yaml
dsh plugin --profile web add file:./dsh-notify-relay-0.3.3.tgz --ignore-scripts

pnpm store prune, pnpm add --force, and pointing at a cold --store-dir were all tested and none of them clear it once node_modules still holds the package.

Configure

The fastest path is the settings page. If you prefer to seed the file directly, it lives at <DSH_HOME>/storages/notify-relay/config.json and is validated on every load — a bad value falls back to the default rather than crashing:

{
  "enabled": true,
  "language": "en",
  "events": {
    "task.done": false,
    "task.failed": true,
    "task.aborted": true,
    "task.blocked": true,
    "request.failed": true,
    "approval.asked": true,
    "approval.decided": false,
    "tool.failed": true
  },
  "dedup": { "windowMinutes": 10 },
  "quiet": { "enabled": false, "start": "22:00", "end": "08:00", "mode": "digest" },
  "digest": { "enabled": false, "intervalMinutes": 30 },
  "deepLink": "",
  "channels": [
    {
      "id": "phone",
      "name": "My phone",
      "kind": "bark",
      "url": "https://api.day.app/YOUR_KEY",
      "secrets": {},
      "events": ["*"]
    }
  ]
}

mode is digest (hold the repeats, flush as one message) or drop.

language is the delivery language (zh / en). It governs the text that leaves the machine — the digest title, the /notify replies, the suppression reasons. The UI language still follows DSH's own setting, because the person reading the settings page and the person reading a push at 2am are not necessarily the same person. An unrecognised value falls back to zh.

deepLink is optional. Only Bark (url) and ntfy (Click) have a native tap-target field; the other channels have no equivalent and get nothing rather than a URL pasted into the body where it is not clickable. It must be an http(s) URL containing {session}, which is substituted per notification — a link without a session is a lie, so it is not sent:

"deepLink": "https://dsh.example.com/session/{session}"

Severity

A flat "it happened" is not enough. An approval request and a task-done are both events, but one costs you money if it is missed and the other is trivia. Every mature notification system separates severity from content; a notifier that cannot forces you to either miss the important one or be spammed by the unimportant one.

KindSeverity
approval.askedcritical
task.failed, task.aborted, task.blocked, request.failed, tool.failed, relay.degradedhigh
task.done, approval.decided, testnormal
(nothing maps to low; it exists so a channel can downgrade)low

A digest is not in this table, because it has no severity of its own. It inherits the most severe kind it holds: a digest of five task.failed events is five high events and arrives as high, not normal. Turning on batching is a request for fewer messages, not for less urgency — and until 0.4.1 the digest was hardcoded to normal, so a batch of failures reached Bark as active instead of timeSensitive and ntfy as Priority 3 instead of 4, with nothing in the log saying so.

The ceiling is high, never critical: approval.asked is the only critical kind and it pierces batching, so it is never held. A digest of failures should break through, but must not @-mention a whole DingTalk group.

Severity is not decoration — it lands on real API fields, verified against the vendor docs:

ChannelFieldCriticalNormal
Barklevelcritical + call: '1' + volume: '10'active
Bark—low → passive, high → timeSensitive
ntfyPriority53 (low → 2, high → 4)
Telegramdisable_notificationfalsetrue only for low
webhookJSON severity"critical""normal"

Bark's critical + call is the only primitive in this plugin's whole channel set that breaks through iOS silent mode and Do Not Disturb. It is reserved for approval requests, because using it for anything else is how a notifier gets uninstalled. Server酱, WeCom and Feishu have no severity primitive at all, so they keep their existing shape — inventing a field the API ignores is worse than omitting one.

Circuit breakers

Without one, a channel that is hard down — revoked token, decommissioned host, DNS that stopped resolving — is retried on every single notification, six times each, forever. Each retry costs a full timeout, so the outbox grows, the backoff ceiling stretches to thirty minutes, and your working channels are delayed behind the dead one.

Three consecutive failures open a channel's breaker. It then gets no requests at all until the cooldown elapses (1m → 5m → 15m → 30m, doubling from one minute), when exactly one probe goes out. A success resets everything, including the ladder, so a channel that recovers does not carry a longer penalty than it earned.

Two deliberate choices:

  • A tripped channel is not queued for retry. Retrying it per-notification is the behaviour the breaker exists to stop. The skip is still written to the log with error: "circuit open" — a silent skip is the exact failure this plugin was written to eliminate.
  • Breakers are not persisted. A breaker is a statement about the network now, and a restart is exactly when the network may have been fixed. Re-tripping after a restart costs three attempts; trusting a stale "unhealthy" flag across a restart could suppress a channel for thirty minutes for no reason.

The channel card in the settings page shows the breaker state and how long until the probe, so a tripped channel explains itself instead of looking like a config bug.

The relay reports on itself

This is the single highest-leverage thing a notifier can do, and the one this plugin was missing. A notifier that quietly stops notifying is strictly worse than no notifier, because you believe you are covered. Healthchecks.io is built on exactly this idea — a Period plus a Grace Time, so that silence is itself an alert.

Every tick of the heartbeat timer (the same one that drains the outbox) checks three things and, at most once an hour, warns you through a healthy channel:

  • a channel whose breaker is open, with its id
  • notifications stuck in the retry queue for more than 30 minutes, with their age
  • nothing delivered at all since start-up, with an enabled channel configured

It is deliberately conservative. Once an hour, because a warning that fires on every tick trains you to ignore it. Only through a healthy channel, because warning through the dead one adds a failure to the thing being reported and could recurse. Never queued for retry, because a degraded warning that fails must not join the outbox it is describing. And never through classify(), because relay.degraded is not a user-configurable event and the event switches must not be able to silence it.

If every channel is open there is nobody to tell, so the warning is recorded as a log row instead — at least it is visible to the one person who opens the panel.

/notify status reports the same state on demand: breaker ids, the age of the oldest queued item, and how long since the last successful delivery.

Where the events come from

Eight kinds, two arrival paths, and the difference is not cosmetic:

KindSourceDefault
task.doneturn/end, reason.kind = completedoff
task.failedagent/erroron
task.abortedturn/end, reason.kind = abortedon
task.blockedturn/end, reason.kind = blockedon
request.failedagent/request-erroron
approval.askedapproval/askedon, pierces quiet hours and digest
approval.decidedapproval/decidedoff
tool.failedtool/result with an object data.erroron

turn/end carries a reason (completed / aborted / blocked / error / max-tokens / interrupted), so "the task finished" and "the task was aborted" are two different notifications rather than one bland "task done".

Session events (turn/*, approval/*, tool/result) are dispatched once, under the single name session/event, as (session, event) with the real name in event.type. Registering ctx.on('turn/end') buys a listener on an event DSH never dispatches — which is exactly the dead half of the first release's event coverage, and the reason the harness carries a "listener on a session-event sub-name" broken variant.

How the rule engine decides

Every event goes through classify(), which returns exactly one verdict:

off          → the master switch is off; nothing happens
muted        → inside a mute window; nothing happens
dedup        → the same fingerprint was already delivered inside the window
quiet-drop   → inside quiet hours with mode = drop
quiet-hold   → inside quiet hours with mode = digest; queued for the next flush
digest       → digest batching is on; queued for the next flush
send         → delivered now

Delivery itself never throws and never blocks the event: a channel that times out (10s) or refuses the connection is recorded as failed in the log and the other channels still get their message. Concurrency is capped at 3 so a misconfigured endpoint cannot flood the socket.

Changing the digest interval takes effect immediately

intervalMinutes is read when the timer is armed, and the timer is re-armed on the save that changes it — so retiming 30 → 5 does what the settings page says it does. Before 0.4.3 it did not: armDigest early-returns when a timer is already running, so a save on a relay that already held events was inert and the pending digest still fired on the interval that was current when the first event arrived. The setting was stored correctly, which is what made it hard to see.

The same applied to the outbox heartbeat, which shares the tick and is worse there: it re-armed with the delay captured when it was first armed, so the heartbeat stayed on the old interval forever, across every flush.

One trap worth knowing if you extend this: disarmDigest() also empties the held queue. That is right when batching is being switched off and catastrophic when it is merely being retimed — every batched notification would be dropped on the save that was meant to reschedule their delivery. The retime path uses rearmDigest(), which disposes only the timer, and there is a broken-build variant that puts the naive version back to prove the gate still catches it.

Backoff is exponential with bounded jitter: 30s, 1m, 2m, 4m … capped at 30 minutes, then multiplied by a uniform draw in [0.75, 1.25]. Without jitter every pending item computes the same next-attempt time, so the moment a shared channel recovers — or the moment the process restarts and reloads the whole outbox at once — every queued notification fires at it simultaneously. A notifier whose own failure mode is "many things failed at once" is precisely the case that produces a thundering herd. The bound is ±25% rather than full [0, cap] jitter, because full jitter can schedule a retry effectively immediately, which for a 10s-timeout channel means a hot loop.

A digest inherits the audience of the kinds it holds. That distinction shipped as a bug in 0.2.0: the digest was delivered with kind: 'digest', so channelsFor matched it against each channel's events list and filtered it out — a channel configured events: ['task.failed'], which is this README's own worked example, silently received no digest at all.

Verification

This plugin was not shipped on the strength of "it looks right". Four gates, all of which must pass, and three of which are designed to fail:

GateWhat it proves
.sandbox/host-harness.cjsThe fail-loud inject contract, the rule engine (dedup / quiet hours / severity mapping / payload builders / redaction), a real HTTP delivery to a loopback server, the deep link on the wire, the breaker state machine, the durable outbox (enqueue / backoff / retry / give-up / restart recovery), the digest interval retiming, and 18 broken-build variants
.sandbox/client-harness.cjsMaterialization against a strict fake ctx, the inject contract, the settings-section thunk label across a language switch, dictionary key parity, the editor round-trip, event-vocabulary parity with the host, channel-vocabulary parity with the host, and 5 broken variants
.sandbox/live-check.cjsThe plugin booted inside a real DSH: config write → read-back → real socket delivery → log → reset → /retry → delivery language
.sandbox/e2e-notify.mjsThe browser half in a real GUI: pill, delivery panel, official settings section, and a UI change that round-trips through the host
node .sandbox/gate.cjs        # both harnesses, both languages, plus variants

Both harnesses run once per language, because a build that only materializes in Chinese proves nothing about the English UI.

The releases were e2e'd too, not inferred

v0.3.3 and v0.2.5 ship the same index.js and client.js bytes as the versions before them, so it would have been reasonable to argue that a browser re-run "would test the same bytes". That is an inference, and inferences are what gates exist to replace. .sandbox/verify-e2e-releases.cjs runs the browser half of both plugins against a real GUI, on both runtimes:

Runtimenotify-relay v0.3.3session-suspend v0.2.5
DSH 0.1.6-alpha.228 checks, 0 failures19 checks, 0 failures
DSH 0.1.7-rc.228 checks, 0 failures7 checks, 0 failures

Both plugins activate (no "did not activate"), raise no page error and no console error, render their pill / sidebar rows, render the official settings section, and a change made in the UI round-trips through to the host. The session-suspend row count is lower on 0.1.7 because that profile is fresh and holds only the empty draft — the hover chain needs a parked session to measure against, and it skips itself rather than passing vacuously.

Bugs the gates only caught because they were built to fail

A POST route that never ran. The first cut declared GET /config and POST /config as two routes. webServer.match() looks the path up in a Map and ignores the method entirely, so the second registration threw, the duplicate was swallowed as a double-mount, and every save landed on the read handler — 200 OK, nothing persisted. Unit tests could not see it, because the harness's fake webServer matched on method like a real router. It took a live boot, reading back a config that had not changed. The fix is one route per path with the method dispatched inside; the harness's fake is now byte-faithful to the real one (Map keyed by path, duplicates throw), and a broken variant re-registers a path on purpose.

A logger passed in as the delivery log. registerRoutes(server, log) took ctx.logger(PLUGIN_ID) under the name log, which shadowed the array, so /log threw on every call and answered 500. Nothing caught it until live-check.cjs actually invoked the route. The parameter is gone and the harness now calls the route rather than only asserting that it exists.

Half the event coverage was dead. The first release registered both ctx.on('turn/end') and ctx.on('approval/asked'). Neither event is ever dispatched under that name — session events appear only under session/event — so neither listener ran, and two whole notification categories were silently never sent. The fix is session/event plus an inner switch on event.type. The harness now keeps a DISPATCHED_EVENT_NAMES registry and asserts that every registered name is in it, that HOST_EVENT_NAMES matches the real registrations, and that no listener sits on a session sub-name. Two broken variants reproduce the dead-listener and dropped-argument forms.

A mixin service's methods live on the context, not on the service. @cordisjs/plugin-timer calls ctx.mixin('timer', [...]), so hostCtx.timeout(...) is correct. The first harness offered only ctx.timer.timeout, and the fail-loud proxy rightly refused ctx.timeout — but the digest path was never reached with a queued item, so the gate stayed green over code it had never executed. The fake ctx now provides both surfaces and allows a mixin's method names whenever the mixin service itself is declared.

The event vocabulary is duplicated, and the copies drifted. The browser half never receives the host's EVENT_KINDS, so it carries its own list. When the turn-end reasons were split out, the host list grew from four kinds to eight and the browser list did not move — the settings page ended up with four switches for eight events, and four kinds were silently unconfigurable. Each half was internally consistent, so both gates stayed green; only a real browser, counting switches, caught it. The client harness now imports the host's EVENT_KINDS and compares the two lists element for element, in order.

The outbox probe read the wrong home. The variant runner restores DSH_HOME in its own finally, which runs before the behavioural probe — so every write the mutated plugin made landed in the real user home while the probe read an empty scratch, and the gate green-lit a regression it never actually saw. The probe now re-points DSH_HOME itself and asserts that both the config write and the outbox file landed before drawing any conclusion.

All ten are recorded in DEV-NOTES.md with the exact symptom, so the next person does not have to rediscover them.

Uninstall

dsh plugin --profile web remove dsh-notify-relay

License

MIT