dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
把本机 MSP 网络上每个节点的工具暴露给 DeepSeek Harness(ESP32 / 桌面应用 / 任何 MSP 节点)
Expose the tools of every node on your local MSP network as DeepSeek Harness tools, so the agent can call them directly:
agent ──(DSH tool)──> dsh-msp ──(MSP/TCP, one connection per node)──> gateway / notchpeninsula / …
└─(MCP/WS)─> ESP32English | 中文
# from GitHub (the repo ships lib/ ready to run — no build step)
dsh plugin --profile <profile> add github:hxh230802/dsh-msp --ignore-workspace-root-check
# pin an exact release (recommended for anyone but the author — tracking the
# branch means every commit is instantly "released")
dsh plugin --profile <profile> add github:hxh230802/dsh-msp#v0.1.1 --ignore-workspace-root-check
# from a local checkout (development; pnpm links, so edits are live)
dsh plugin --profile <profile> add /path/to/dsh-msp --ignore-workspace-root-check
# verify the row landed in the composed config tree
dsh --profile <profile> --dump-config | grep -A8 'id: dsh-msp'This is a host-side plugin: restart the GUI after changing it (reloading the page is not enough). Uninstall with dsh plugin --profile remove dsh-msp.
cordis.patch.yml — merged into the config tree once the package is a profile dependency:
- insert:
- id: dsh-msp
name: dsh-msp
config:
nodesDir: ~/.msp/nodes
includeNodes: [] # non-empty => allowlist only
excludeNodes: [] # e.g. [test.node1, demo_signals]
toolPrefix: msp_
discoveryMs: 3000 # discovery directory scan interval
callTimeoutMs: 30000
retryMs: 3000 # reconnect delay per node
staleAfterMs: 30000 # lease: declarations older than this are dead
maxPeers: 16
verbose: falseconnection. Freshness comes from MSP leases: a declaration not renewed within staleAfterMs is ignored — that is MSP's "crashed processes disappear" property.
[a-z0-9_]. If the toolname already carries that prefix it is not doubled (notchpeninsula_hellomsp_echo → msp_notchpeninsula_hellomsp_echo).
flicker under the model); calls return "not connected". Tools are only removed when the lease expires — i.e. the node is really gone.
MANIFEST_UPDATE (device plugged in orremoved, tools added or dropped) the plugin diffs by name and republishes — no session restart.
ToolResult(error=...) surfaces as a thrown error, sothe model reads the reason instead of treating an empty success as an answer.
lib/msp.js; nothing is installed from npm.dsh-msp-, generated atstartup. It cannot be a fixed dsh-msp: a peer keys its connection table by node id (connections[peer_id] = conn) and its handshake dedup picks a direction by comparing ids, so two processes reporting the same id look like one peer with two connections — the later one evicts the earlier and both end up flapping. Two DSH windows, two profiles, or a second DSH on the same machine would all collide. Set nodeId if you need a stable identity (for example when a peer allowlists you by id).
After a restart the left sidebar shows an MSP icon that opens a full page:
Section · Content Node / tool graph · Centre is this plugin; each outer circle is an MSP node. Solid line = connected, dashed = disconnected; circle size grows with tool count, the number inside is that count Permissions & tools · One row per node: status dot, address, enable/disable button, and the tools it advertises (parameter names; hover for descriptions). Disabling drops the node and its tools immediately Notification switches · Three independent toggles: approval waiting / question waiting / task finished (long tasks only) Recent calls · Time / node / tool / duration or error, from the last 40 calls recorded host-side Discovery directory · Every declaration file and its lease age (including expired ones nobody cleaned up)
Data path: the host registers three routes, the page polls every 2 s.
GET /msp/state → { ok, peers[], calls[], discovered[], policy, notify, notifyIcon, hooks }
POST /msp/node → { nodeId, enabled } change permissions; reconnects/drops immediately
POST /msp/notify → { key, enabled } or { approval|question|turnEnd: boolean } toggle notificationsThe browser half is a hand-written CJS bundle (lib/client.js, wrapped in window.__ModuleLoader__.load, react supplied by the host) — no build toolchain needed. If the host does not expose the slots service, apply exits quietly: the panel is absent but nothing else breaks.
Three switches, one per situation, toggled from the panel:
Switch · Hooked event · Notes Approval waiting · approval/asked · Fires when an approval is raised Question waiting · tools/pre-execute with tool ask_user_question · Sends the question text Task finished · session/event where event.type === 'turn/end' · Only if the turn ran ≥ turnEndMinMs (default 20 s)
Precedence: the panel choice wins over the notifyOn* defaults in cordis.patch.yml. The choice is persisted to ~/.dsh/dsh-msp/notify.json and survives restarts — otherwise every restart would silently re-enable what the user turned off. A missing or corrupt file falls back to the defaults and never blocks startup.
Notifications go through MSP notify: the host calls the node directly, so no model round trip, no tokens, no turn consumed.
In MSP a signal is an announcement ("X already happened") while a tool is a command ("please do X"). The peer's signal notify.toast.raised is described as *"a system notification arrived"* — a past-tense observation. Publishing into it would claim a toast already exists, and a receiver with no matching handler drops it silently (by design: errors are never returned to unauthorised peers).
So the plugin splits the job:
Channel · Mechanism · Semantics · Consumers Announce · publish("dsh.attention.requested") · fire-and-forget, broadcast, many subscribers · phone push, LED, log, another agent Deliver · callTool("notify") · 1:1, waits for a result, actually pops up · the user
signalPath: dsh.attention.requested
useSignal: true # announce only: set useTool: false
useTool: true # deliver only: set useSignal: falseA failed announcement never blocks delivery (and vice versa): the signal is a side channel, popping up is the point.
The notification icon is inline base64, not read from disk at runtime. lib/icon.js holds the base64 of a 64×64 PNG (11.9 KB); assets/ keeps the same PNG as the source image. Reading a file at runtime brings a whole failure surface — package copied or mounted elsewhere, a miscalculated relative path, and on Windows new URL().pathname yielding a /F:/… leading slash (this one bit us once: the icon silently became null).
notifyIcon: true # default: inline base64
notifyIconPath: C:/path/to/x.png # override: only then is a file read; unreadable => fall back
# notifyIcon: false # no icon at all/msp/state reports where the icon came from: {ok, source: "embedded"|"file"|"disabled", chars}.
In the MCP content block, an image block with annotations.role = "icon" is used as the notification icon (the peer echoes "icon": "inline image (N chars)" when it accepted it). Throttling defaults to 3 s per title; if the preferred node is offline the notifier falls back to any connected node that advertises notify.
Tool · Purpose msp_status · Per-node connection state, endpoint and published tools; every node in the discovery directory with its lease age; notification switches, hook counters, icon status, long-task state msp_call_wait · Call a tool and wait for it to finish: signal first, status polling as fallback, pending handle when the wait window expires
msp_call_wait)The model sees a synchronous call; the host does the hard part.
msp_call_wait {node:"gd3", tool:"add_task", args:{urls:[...]}, waitMs:25000}
→ {status:"completed", via:"signal", taskId:"tsk_…", result:{files:[…]}}
→ {status:"pending", taskId:"tsk_…"} # window expired; completion is recorded laterThree layers, in priority order:
Layer · Role · Why it exists Completion signal (task.completed …) · fast path, millisecond return · the point of subscribing at all Polling a status tool (get_task) · fallback · signals are not retained, may be unauthorised (a publisher gates delivery with grant_sub), and are lost while disconnected Background watch after the window · keeps the card alive · a long download must not freeze a turn
Because polling always runs in parallel, not knowing which signal means "done" is a performance problem, never a correctness problem — worst case the call is a few seconds slower.
How the binding is learned. The node's manifest already lists its signal paths, so the plugin subscribes to all of them and matches observers against the handle returned by the start tool: the first event whose payload contains that handle confirms the mapping, which is then cached in ~/.dsh/dsh-msp/bindings.json. Naming heuristics (completed / failed) seed the very first attempt. Nothing has to be declared by the node author.
When the window expires, the task keeps being watched in the background; on completion one record is appended (visible in msp_status) and a notification fires through the same channel as the other hooks.
Note: nodes such as gd3 publish no progress signal — task.added, task.status, task.completed, task.failed, task.removed only. Progress therefore cannot stream into the conversation; the panel can show it, the model sees the final result.
npm test # 6 suites: plugin entry, host routes, client bundle, notifier, prefs, turn tracker
node scripts/smoke.mjs # drive the plugin with a fake ctx against the real MSP network
node scripts/probe-peers.mjs # probe only: connect to fresh nodes, print tools/signals/capabilities
node scripts/call-peer.mjs <node> <tool> '{...}' # call one tool on one node from the CLIsignal) are ignored, so notify.*,media.track.changed and friends never reach the model's context.
session); a node reconnect picks it up.
Isolate it yourself before crossing machines.
MIT