DSH HUB
HomePlugin StoreRankingsPublish Guide
Plugin source
Back to catalog

ikashana /

dsh-notify

Verified

Task-monitoring notification plugin for DeepSeek Harness (dsh): turn/end alerts, human-confirmation with timeout escalation, SAPI voice, HTTP/A2A/MCP channels — zero-dependency build-free bundle

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@24a946eb

dsh-notify

A DeepSeek Harness (dsh) task-monitoring notification plugin. While the agent works, it pushes key states to you:

  • turn/end: notifies on task completion/error/blocked/max-tokens (title, reason, duration by default)
  • human confirmation needed: notifies on approval dialogs and ask_user_question (with a question summary)
  • confirmation timeout escalation: after 10 minutes without a decision, re-alerts via SAPI voice + high-priority webhooks
  • model-initiated notify tool: notify tool (off by default) lets the model push a message to you proactively

Four channel kinds: HTTP webhooks (QQ bot / serverchan / DingTalk / WeCom / ntfy are the same channel with different endpoints), A2A agents (one-way message/send), MCP tools (streamable-http notification bridges), and Windows SAPI local voice. Pure Node, zero runtime dependencies, build-free bundle, installed in the same dsh.bundle.patch shape as dsh-desktop-shell.

Installation

dsh plugin --profile web add github:ikashana/dsh-notify
# headless works too (voice/HTTP/A2A/MCP all still send):
dsh plugin --profile headless add github:ikashana/dsh-notify

The plugin is inserted via a bundle patch and defaults to listen-only, no disturbance (no HTTP/A2A/MCP channels, voice off, notify tool off). Configure endpoints by replacing the whole config under the same id in your profile's cordis.patch.yml (a non-insert patch replaces the target entry's entire config object — rewrite every key you want to keep):

- id: notify
  config:
    http:
      channels:
        - id: ntfy
          url: 'https://ntfy.sh/my-topic'
    sapi:
      enabled: true
    tools:
      notify:
        enabled: true

Keep private config (endpoints with secrets) in a local patch/config file and gitignore it; the public repo has zero hardcoded secrets.

Triggers

1. turn/end (automatic)

Listens to session/event turn/end. The reason.kind whitelist defaults to [completed, error, blocked, max-tokens] (aborted is a user-initiated cancel and stays quiet by default; configurable). If a new turn/start appears inside the cooldown window after a turn ends (default 10 s), the pending notification is cancelled — multi-turn jobs report only the final state. Only root sessions notify (subagent children stay silent; configurable). Duration = envelope time of turn/end minus turn/start.

Dispose resend: a notification still inside its cooldown window is sent immediately when the plugin disposes (process exit / HMR unload) instead of waiting — for a one-shot headless task that exits right after turn/end, this is the moment the last state gets out. Entries already cancelled by a new turn/start are not resent (the cancel semantics hold).

2. Human confirmation needed

  • approval/asked: notifies when an approval dialog appears (summary = tool name + reason).
  • ask_user_question tool/call: parses the arguments JSON for the question summary. Headless false-positive switch: headless has no userQuestions provider, so the ask fails immediately but tool/call still fires — askUserQuestion: 'auto' (default) only sends when a web layer is detected (a webServer service exists); 'on' forces, 'off' disables.

After the resolution signal arrives (approval/decided by id, tool/result by message.source.callId), the default is silence; notifyDecided: true sends "confirmed/rejected/…".

3. Confirmation timeout escalation

Per-session pending table + a ctx.timeout() timer (effect-managed, cleaned up on dispose). On timeout (default 10 min, timeoutMs: 0 disables) sends a priority escalation: SAPI voice + the high-priority channels in escalation.channelIds (queue-jumping).

Dispose resend: if confirmations are still pending when the plugin disposes, one escalation is resent (escalation only — never the confirmation first-report, which was already sent when the event happened); no resend when escalation is disabled (timeoutMs: 0).

4. notify tool (model-initiated, off by default)

With tools.notify.enabled: true, the model gains a notify tool: { message: string, channel?: string, priority?: boolean }. message is the model's verbatim text, enqueued as a kind='model-message' notification through the regular channel chain; channel restricts sending to that channel id; priority jumps the queue. Subagent sessions are silently skipped (consistent with the root-session filter).

Privacy note: a model-initiated message is explicit content — it bypasses the turnEnd.includeText privacy switch and appears verbatim in the notification. The tool is off by default for exactly this reason: enabling it authorizes the model to push arbitrary text to every configured channel.

Channels

HTTP (lib/channels/http.js)

url/headers/body are fully template-rendered; 10 s timeout + exponential backoff with 2 retries (429/5xx/network errors retry, other 4xx fail over immediately); a failed primary channel falls back to the fallback chain. Secrets never sit in plain text:

  • ${env:XXX} — read from the environment
  • ${credential:REF} — resolved via the dsh credentials service (optional; errors if the service is absent)

A2A (lib/channels/a2a.js)

One-way send to an A2A protocol 1.0 agent: POST JSON-RPC 2.0 message/send to agentUrl with params.message = { messageId, role: 'agent', parts: [{ kind: 'text', text }] }. Both messageId and the request id use node:crypto random UUIDs (receivers dedupe by messageId to prevent replays). The text renders through the template config (default 【{{reason}}】{{title}}|{{duration}}). No SSE stream, no task polling — a response without a JSON-RPC error means accepted. Failure classification, retries, and fallback behave exactly like the HTTP channel.

MCP (lib/channels/mcp.js)

A streamable-http MCP server notification bridge: the first send performs one initialize handshake (protocol version tries 2025-03-26 first, falls back to 2024-11-05 if rejected; the server's accepted version wins), the handshake result is cached, and every subsequent notification calls tools/call ({ name: config.tool, arguments: rendered params }), caching and echoing the mcp-session-id response header. Handshake failures are fail-soft: the error goes to the queue's retry/failover chain and the next send re-handshakes automatically. Responses may be SSE (text/event-stream); the minimal implementation parses only the first frame, preferring application/json.

SAPI voice (lib/channels/sapi.js, Windows only)

Spawns powershell.exe (Windows PowerShell 5.1, not pwsh) with -NoProfile -NonInteractive -EncodedCommand; the script travels as UTF-16LE Base64 so Chinese never mangles through GBK; System.Speech synthesizes with an auto-picked zh-CN voice; windowsHide: true + stdio: 'ignore' means no console window, and the child is unref'ed so it plays independently. Fail-soft: any failure is silently skipped. Voice queue: at most 1 Speak at a time; new notifications merge (keep the latest) or drop.

Queue (lib/queue.js)

Concurrency 2, per-channel exponential backoff, primary → fallback failover (fallback ids can reference any channel type), priority queue-jumping; dispose flushes (default 2 s cap — inside the 5 s headless exit grace, so messages get out).

Template variables

{{title}} (session title, 「无标题」/“Untitled” when unavailable), {{reason}}, {{text}}, {{duration}}, {{timestamp}}; missing fields render as empty strings.

reason text
turn/end 任务完成 / 任务出错 / 任务被阻塞 / 超出 token 上限 / 任务已中止 / 任务中断
confirmation 需要人工确认
decided 确认已结束 (text carries 已确认/已拒绝/已取消/无法确认/已收到回答)
escalation 等待确认超时 (duration = time waited)
model-message 模型消息 (text = model's verbatim message)

Configuration table (defaults = code DEFAULTS; Config validated with schemastery; all keys optional)

Key Default Meaning
turnEnd.reasons [completed, error, blocked, max-tokens] trigger whitelist; explicit [] disables this trigger
turnEnd.cooldownMs 10000 quiet window after turn end (ms); a new turn inside it cancels
turnEnd.includeText false body preview switch (recent assistant text)
turnEnd.textMaxChars 500 body truncation length
confirmation.approval true approval/asked notification switch
confirmation.askUserQuestion 'auto' 'auto'/'on'/'off': auto = only with a web layer
confirmation.timeoutMs 600000 escalation threshold, 0 disables
confirmation.notifyDecided false send “confirmed” after resolution signals
confirmation.includeQuestion true include the question summary (no option details)
confirmation.questionMaxChars 120 question summary truncation
rootOnly true notify root sessions only (origin/delegationDepth check)
http.timeoutMs 10000 HTTP request timeout
http.retries 2 per-channel retry count
http.retryBaseMs 500 exponential backoff base (500, 1000, 2000…)
http.channels [] HTTP channel list, see below
a2a.timeoutMs / a2a.retries / a2a.retryBaseMs same as http A2A global defaults
a2a.channels [] A2A channel list: { id, agentUrl, template?, fallback? }
mcp.timeoutMs / mcp.retries / mcp.retryBaseMs same as http MCP global defaults
mcp.channels [] MCP channel list: { id, url, tool, arguments?, headers?, fallback? }
tools.notify.enabled false notify tool switch (model-initiated notifications)
sapi.enabled false voice switch (Windows only)
sapi.template '任务通知:{{title}},状态:{{reason}}。' speech template
sapi.voice 'auto' 'auto' = auto zh-CN; or a voice-name match (e.g. 'Huihui')
sapi.queue 'merge' at most 1 Speak at a time: merge keeps latest / drop discards
escalation.sapi true escalation includes voice (requires sapi.enabled)
escalation.channelIds [] high-priority channel ids for escalation (any channel type)
queue.concurrency 2 send concurrency cap
queue.flushTimeoutMs 2000 dispose flush wait cap

Each http.channels entry: { id, url, method='POST', headers={}, body, timeoutMs, retries, retryBaseMs, fallback: [backup channel ids] }. Object/array body values are JSON-serialized automatically. fallback on a2a.channels/mcp.channels shares the same failover chain and can reference any configured channel id.

Platform examples

ntfy (simplest)

- id: ntfy
  url: 'https://ntfy.sh/{{your-topic}}'
  headers:
    Authorization: 'Bearer ${env:NTFY_TOKEN}'   # private topics only, optional
  body: |
    【{{reason}}】{{title}}
    时长:{{duration}} | {{timestamp}}

serverchan

- id: serverchan
  url: 'https://sctapi.ftqq.com/${env:SERVERCHAN_SENDKEY}.send'
  method: POST
  headers:
    Content-Type: 'application/x-www-form-urlencoded'
  body: 'title={{reason}}:{{title}}&desp={{text}}%0A时长 {{duration}}'

DingTalk custom robot

- id: dingtalk
  url: 'https://oapi.dingtalk.com/robot/send?access_token=${env:DINGTALK_TOKEN}'
  headers:
    Content-Type: 'application/json'
  body:
    msgtype: text
    text:
      content: '【{{reason}}】{{title}}\n时长:{{duration}}\n{{text}}'

WeCom robot

- id: wecom
  url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${env:WECOM_KEY}'
  headers:
    Content-Type: 'application/json'
  body:
    msgtype: text
    text:
      content: '【{{reason}}】{{title}}\n时长:{{duration}}'

QQ bot (OneBot 11 HTTP, e.g. NapCat/LLOneBot)

- id: qq
  url: 'http://127.0.0.1:3000/send_private_msg'
  headers:
    Authorization: 'Bearer ${env:ONEBOT_TOKEN}'
    Content-Type: 'application/json'
  body:
    user_id: 3021778961        # replace with your QQ id, or use send_group_msg + group_id
    message: '【{{reason}}】{{title}}\n时长:{{duration}}'

SAPI voice

sapi:
  enabled: true
  template: '任务通知:{{title}},状态:{{reason}}。'
  voice: auto

A2A agent

a2a:
  channels:
    - id: hermes-agent
      agentUrl: 'http://127.0.0.1:9900/a2a'
      template: '【{{reason}}】{{title}}|时长 {{duration}}'

MCP notification bridge (streamable-http)

mcp:
  channels:
    - id: mail-bridge
      url: 'http://127.0.0.1:8080/mcp'
      headers:
        Authorization: 'Bearer ${env:MCP_TOKEN}'   # optional
      tool: send
      arguments:
        to: 'you@example.com'
        text: '【{{reason}}】{{title}}|时长 {{duration}}'

Primary/backup failover

- id: primary
  url: 'https://ntfy.sh/a'
  fallback: ['backup']
- id: backup
  url: 'https://sctapi.ftqq.com/${env:SENDKEY}.send'
  body: 'title={{reason}}:{{title}}'

Privacy defaults

Notifications carry only title + reason + duration + timestamp by default. Body preview (turnEnd.includeText) and question details are off; question summaries cap at 120 chars. Secrets go through ${env:}/credentials references, never plain config text. Exception — the notify tool: a model-initiated message is explicit content, sent verbatim with no privacy filter — the tool is off by default; enabling it is the authorization.

Development

node --check lib/*.js lib/channels/*.js scripts/smoke.mjs   # syntax gate
node scripts/smoke.mjs                                     # zero-dependency smoke (32 items; PASS = green)

The smoke suite covers all templates/queue logic, HTTP channel rendering and secret resolution, SAPI script construction, A2A message/send structure, MCP initialize handshake/version fallback/SSE parsing, notify-tool schema projection and execution, and both triggers (fake ctx drives events and timers, including dispose-resend semantics).

Known Limitations

  • SMTP not implemented: email notifications go through an MCP mail bridge (see the example above — any mail MCP server's send tool).
  • SAPI unavailable in non-interactive sessions: System.Speech needs a desktop interactive session (Windows Session 0 services or SSH sessions have no audio device); use webhook/A2A/MCP channels there.
  • A2A is one-way message/send only: no SSE subscription, no task polling — accepted means sent; use a full A2A client if you need to read task results.
  • MCP is a minimal implementation: SSE responses parse only the first frame; an expired session makes the next call fail into the retry/failover chain (the retry re-handshakes, so it self-heals).
  • Notifications are not persisted: dispose flushes only 2 s, the rest is dropped.
  • askUserQuestion: 'auto' treats the webServer service as “has a web layer”; a third-party frontend that doesn't register that service looks like headless — set 'on' to force.
  • After an escalation fires, that session's pending table is cleared, so later resolution signals no longer trigger “confirmed” (avoids duplicate noise).
DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

APIPublish GuideAbout
—/ 5

No ratings yet

Verified DSH bundle

Commit 24a946ebe153

Community comments

No comments yet. Be the first to write one.