dsh-blocker-notify
English | 简体中文
Real-time attention alerts for DeepSeek Harness (DSH): when an agent gets stuck and needs you to step in, the web UI notifies you in real time — no need to watch the screen waiting for an action.

The three kinds of "blocked" signals that trigger a notification:
| Signal | Description |
|---|---|
Approval / privilege escalation (approval/request) | The agent requests wider sandbox permissions or an approval (e.g. a write escalation under read-only mode) → alerted |
Sandbox denial (FS_SANDBOX_DENIED) | write/edit refused under read-only / workspace-write mode → recorded in the host log only (diagnostics), no user-facing alert |
Waiting for user interaction (question / plan-review) | The agent is waiting for you to answer a question or review a plan → alerted |
> Only events that need your action are alerted (approval / question / plan review). A sandbox denial is a completed event with nothing to act on, so it stays silent — even when the approval policy is never.
The four notification mechanisms
| Mechanism | Effect |
|---|---|
| Global message (banner) | An amber banner in the top-right corner lists every blocked session; each row shows only the session title + state (no tool name / reason); clicking a row jumps to the session and removes that row immediately (independent of the approval outcome); ✕ closes the whole banner; rows reappear when new blockers arrive |
| Workspace entry turns yellow and flashes | The yellow status dot on the affected session row in the left workspace flashes every 1.1s — the yellow mark itself is DSH's own "waiting for user" indicator; this plugin adds the flash animation; it stops automatically once the blocker is resolved |
| Sound (audible chime) | When a new blocker appears, the page plays a short two-tone chime (Web Audio, no audio files); it only rings on blocker-set transitions, never on page load with pre-existing blockers; degrades silently when autoplay is locked or the environment lacks Web Audio |
| OS-level notification | When a new blocker appears, the browser raises an OS notification (Windows Action Center / macOS Notification Center) via the Notification API; clicking the notification focuses DSH (and opens the session directly for a single blocker); one blocker → body session title — kind (tool name — reason), several blockers → one merged notification; when permission is granted the banner header shows a small 🔔 (click for a self-test notification), while default/denied states show an action row in the banner (one-click enable / fix in browser site settings) |
How to use
Option 1: Dynamic plugin (quick enable, ~5 minutes, recommended to try first)
Nothing to install, no DSH restart needed. Downside: process-memory only — re-enable after a DSH restart.
1. Get the source: git clone https://github.com/Frost-Reed/blocker-notify (or download from the GitHub page). 2. Open any session (preferably cordis / creator mode) and send this instruction to the agent (replace <repo-path> with your local clone path):
``text Please enable dsh-blocker-notify as a dynamic plugin: 1. Use the read tool to read <repo-path>/dynamic/host.js and <repo-path>/dynamic/client.js (these are the code.host and code.client function bodies — use the file contents directly). 2. Call cordis_define: plugin: { kind: "new", idPrefix: "ntfy" }; name: "blocker-notify"; purpose: one sentence; code.host = host.js content; code.client = client.js content. 3. Call cordis_run (mode: "run") with the pluginId/packageId returned by cordis_define. 4. The run may require the user to approve the Client half in the web UI; wait for the approval result, do not re-request. 5. When done, use cordis_inspect_self(pluginId, packageId) to confirm host/client are both running. ``
3. Approve the Client half's run request in the web UI (once). 4. Verify immediately (see "Verification" below).
Option 2: Formal bundle install (persistent, v0.2.0)
Packaged per DSH's official plugin spec (dsh.bundle + dsh.client); survives restarts once installed.
> If dsh is not on your PATH (e.g. running from a source checkout or not installed globally), replace dsh below with pnpm dsh (in the source repo root) or npx @deepseek-ai/dsh.
Install from GitHub (published, recommended):
dsh plugin --profile web add github:Frost-Reed/blocker-notifyInstall from a local checkout (development):
dsh plugin --profile web add file:<local-clone-path>After installing:
# Confirm it joined the layer stack
Get-Content $env:USERPROFILE\.dsh\profiles\web\package.json # dsh-blocker-notify should appear in dsh.profile.bundles
# Confirm the row is in the config tree
dsh --dump-config --profile web | Select-String "dsh-blocker-notify"
# New dependencies/layers require a process restart
dsh web> lib/ is committed build output (generated from src/ by tsdown). Git installs run the prepare build script, so pnpm needs a one-time allowBuilds authorization; alternatively install the npm package or a pnpm pack tarball (prebuilt, no authorization needed).
Verification (common to both options)
| Scenario | Action | Expected |
|---|---|---|
| Question self-test | Ask the agent to call ask_user_question once | While the question dialog is open: banner "等待回答" + chime + OS notification; clicking the banner row jumps and removes it |
| Approval / escalation | In settings set the approval policy to ask and the sandbox to read-only, then have the agent attempt a write with sandbox_permissions | When the approval card appears: banner "等待授权" + chime + OS notification (with tool name and reason); clicking the banner row jumps and removes it (whether or not the approval is granted) |
| Sandbox denial | Have the agent write to a denied path without escalation arguments | Silent (no banner, no chime, no OS notification; the host log records it for diagnostics only) |
How it works
Host half (in-process, untagged scope → observes every agent's approvals/tool results)
├─ ctx.on('approval/request', (req, next) => …) // waterfall: observe only, must return next()
└─ ctx.on('tools/result', …) // detects FS_SANDBOX_DENIED
│ writes to the in-process alert log (deduped by key, 10-min TTL, cap 60)
▼
Details channel: formal = webServer route GET /api/dsh-blocker-notify/alerts
dynamic = harness.handle('blocker-notify/alerts') RPC
▼
Client half (browser page, root scope)
├─ useSessions (shell.overlay standard prop) → live pendingInteraction (approval / plan-review / question)
├─ polls details (fetch / host.call) → merges approval details (tool name / reason)
├─ CSS injection → flashes [data-state="warning"] dots
├─ Web Audio → plays a chime when an actionable blocker appears (AudioContext primed on first interaction)
└─ Notification API → raises an OS notification for actionable blockers (one-click enable + self-test in the banner; clicking focuses the page)- Host half: captures blocking signals and maintains the in-process alert log (plain JS, no UI).
- Client half: the four notification channels. Data comes from the
shell.overlaystandard propuseSessions(snapshot{ ids, byId, … };byIdentries carryid/title/pendingInteraction) plus the Host details poll. - The formal and dynamic versions share the same logic; only the "details channel" differs (webServer route vs. dynamic RPC) — see
docs/ROUTE-B.md.
FAQ
| Symptom | Fix |
|---|---|
| No notification at all | Confirm a blocker actually exists (question/approval/sandbox denial); for the dynamic version confirm cordis_inspect_self shows host/client running; refresh the page and retry |
| Flash only, no banner | The banner reads the { ids, byId } snapshot from useSessions — make sure you are on pkg-4 / v0.2.0 or later |
| Banner only, no flash | The flash is the CSS animation (span[data-state=warning]) — make sure you are on pkg-3 / v0.2.0 or later |
| Dynamic version gone after restart | Expected — dynamic plugins are process-memory; switch to Option 2 (formal install) |
| Formal install not visible in the GUI | New dependencies/layers require a dsh web restart; the plugin list is in Settings — reopen to refresh |
First dsh plugin add github:... fails | The package has a prepare build script (tsdown), so pnpm needs authorization: copy the exact package key from the error into allowBuilds in $DSH_HOME/profiles/web/pnpm-workspace.yaml and re-run; lib/ is committed, so an npm package or pnpm pack tarball installs without authorization |
Development & build
src/is TypeScript source (written the official source-repo way);lib/is the tsdown build output (committed).pnpm build(tsdown, replicating the officialclientBundlepreset) generateslib/index.js(ESM named-export plugin) andlib/client.js(officialwindow.__ModuleLoader__.load({ id, factory })web bundle).- Committing
lib/means git installs work; thepreparescript (= tsdown) rebuilds on git install. - Offline smoke test:
node scripts/smoke.mjs(validates both halves' exports,inject, event listeners, route JSON, CSS injection, slot registration, rendering, UTF-8 copy). dynamic/holds the dynamic-plugin function bodies (used by Option 1), logically identical tolib/with a different channel.- Full Route-B research and implementation record:
docs/ROUTE-B.md; fresh-deployment enablement guide:docs/ENABLE.md.
Repository layout
dsh-blocker-notify/
├── LICENSE # MIT
├── package.json # bundle (dsh.bundle) + client (dsh.client) manifests
├── README.md # Chinese readme
├── cordis.patch.yml # bundle patch: inserts the Host row
├── src/ # TypeScript source (official source-repo method)
│ ├── index.ts # Host half (ESM name/inject/apply + webServer route)
│ └── client/index.ts # Client half (built into the ModuleLoader web bundle)
├── tsdown.config.ts # build config (replicates the official clientBundle preset)
├── tsconfig.json
├── lib/ # tsdown build output (committed)
│ ├── index.js # Host half
│ └── client.js # Client half (ModuleLoader format)
├── scripts/
│ └── smoke.mjs # offline smoke test
├── dynamic/ # dynamic-plugin function bodies (Option 1)
│ ├── host.js
│ └── client.js
└── docs/
├── ENABLE.md # fresh-deployment enablement guide
└── ROUTE-B.md # Route-B formalization recordLicense
[MIT](LICENSE)