dsh-swarmdrop
Give your DeepSeek Harness agent a
channel to your own devices. It can push what it just produced straight to your
phone, and you can @-reference what your phone sent back — no account, no public
IP, end-to-end encrypted.
The transport is SwarmDrop, driven through its CLI.
Status: developer preview. dsh itself declares breaking changes, and this plugin sits on its extension seams. Pin a version.
Pin the dsh line too. The peer ranges target
0.1.0-rc.x; npm'slatesttag on the@deepseek-ai/*packages still points at the older0.0.1-rc.xline, so an unpinned install resolves to packages this plugin does not target.
What you get
| In dsh | What happens |
|---|---|
| "send the report to my phone" | The agent calls swarmdrop_send_files; a transfer row appears in the conversation and follows it to completion. |
/swarmdrop send ./report.pdf phone |
Same, without a model round trip. |
@ in the composer |
Your inbox — everything your devices sent this machine — as reference candidates. |
| Your phone sends a file | A row appears in the conversation, and the item becomes referenceable. |
| The SwarmDrop button at the sidebar foot | Node status and network posture, start/stop, your paired devices, and pairing a new one — without leaving dsh for a terminal. |
The panel
A dot beside Settings says whether a node is running: green for running, amber for stopped, grey while the first answer is still in flight. Opening it gives you
- Node — running or stopped, its node id, and one button to change that.
- Network — NAT class, relay reservation, bootstrap, connected peers, listen addresses. Shown only while a node is running, because every field would otherwise read "unknown" and say nothing the node row did not.
- Devices — what is paired and whether it is online.
unknownis its own state, not a synonym for offline. - Pairing — "Add a device" issues an invite and staffs the desk; the link it shows opens SwarmDrop's own page, which draws the QR code for your phone to scan. When a device shows up you see its name, system, link type and full node id before deciding.
Pairing still requires a person to look at the far side's identity — that has not been relaxed, only moved. An invite is a one-shot capability that travels as a link, and whoever presents it first consumes it, so SwarmDrop's node refuses every inbound request unless someone is at the desk.
The desk stays staffed until you press Cancel, not until you dismiss the panel — you will usually be looking at your phone at that moment, and a popover that closes when you click away would take the invite with it. The sidebar dot turns amber-grey while a window is open, so a desk left staffed is visible without opening anything.
Install
dsh plugin --profile <name> add dsh-swarmdrop
That is all — the package declares a dsh.bundle, so dsh appends it to the
profile's bundle list and its config layer activates on the next start. Verify
before launching with dsh --profile <name> --dump-config, which should show a
# == dsh-swarmdrop layer.
dsh plugin forwards to pnpm inside the profile directory, so it takes any pnpm
target — no npm publish required:
dsh plugin --profile <name> add /path/to/dsh-swarmdrop # a local checkout
dsh plugin --profile <name> add ./dsh-swarmdrop-0.1.0.tgz # from `npm pack`
Remove it with dsh plugin --profile <name> remove dsh-swarmdrop, which drops
the dependency and the layer together.
The SwarmDrop binary
It comes along as an optional dependency, and the plugin finds that copy
itself — nothing else to install. If you already have SwarmDrop from Homebrew
or the install script and would rather use it, set SWARMDROP_BIN to its path
and the bundled copy is ignored.
You will see pnpm say Ignored build scripts: swarmdrop during the install.
That is fine: the npm package fetches its platform binary from a postinstall
hook, pnpm blocks those by default, and the shim falls back to fetching on first
use instead. The only visible effect is that the first SwarmDrop call after
installing takes a few seconds longer than the rest.
swarmdrop 0.5.0 or newer is required. 0.4.0 added swarmdrop watch, which
this plugin subscribes to; 0.5.0 added invite create --decide-from-stdin, which
is what lets the panel run the pairing desk. On 0.4.0 everything else works and
pairing reports that the CLI is too old.
Pair a device from the panel — the plugin has nothing to talk to otherwise. The terminal route still works if you prefer it:
swarmdrop invite create # scan the QR from your phone's SwarmDrop app
Nothing here requires a SwarmDrop node to be running: the plugin loads cleanly on a machine where you have not started one, the tools say so rather than failing mysteriously, and the panel offers to start one.
Tools
| Tool | What it does |
|---|---|
swarmdrop_send_files |
Send files or directories to one of your devices. |
swarmdrop_send_text |
Send a short message to a device's inbox. |
swarmdrop_list_devices |
Your paired devices and whether they are online. |
swarmdrop_list_inbox |
What your devices have sent this machine. |
swarmdrop_inbox_files |
Local paths of one inbox entry's files. |
presence is three-valued (online / offline / unknown). unknown means no
SwarmDrop node is running to probe with — it is not the same as offline, and the
distinction is the difference between "your phone is asleep" and "start SwarmDrop".
How it is put together
src/
cli.ts the `swarmdrop` binary: one-shot calls, the subscription, pairing
machine.ts what this machine looks like, folded from the subscription
pairing.ts the pairing desk: one window, and who is standing at it
revision.ts the shared "something changed" counter the panel parks on
bridge.ts machine-wide happenings → per-session events
panel.ts the panel's RPC channel (status, devices, pairing)
panel-wire.ts the panel's wire contract, compiled by both halves
projection.ts the inbox roll, as a Session projection (what `@` reads)
tools.ts what the model can call
command.ts what you can type
types.ts the Session event family this plugin owns
client/ the browser half: the panel, conversation rows, the `@` source
Four decisions worth knowing before changing anything:
Two kinds of data, two carriers. Conversation rows and @ candidates travel
in the session log: they must rebuild identically after a refresh, a history
page, or a replay months later, so the @ menu reads a session projection — the
Node half registers a pure fold, the framework drives it over committed events in
log order, and the browser receives a finished value.
The panel's data does not go there. Node liveness, devices and network
posture are facts about now; a session event claiming "the node is up" would be
a claim about a moment, persisted forever, and read back as though still true. So
the panel has a channel of its own — ctx.connection.rpc.handle('/swarmdrop', …)
on the Host, rpc.call in the browser. Both work under every dsh carrier,
including reaching a dsh at home from your phone.
An earlier version of this file said dsh gives third-party plugins no Client→Node RPC. That was wrong. What is true, and load-bearing, is the split above: the transcript rebuilds from the log, and nothing may bypass that.
The panel long-polls, because it cannot be pushed to. dsh forwards Host events to the browser from a fixed allowlist a third-party plugin cannot extend. So the panel parks a request on the Host until something changes — which is not a downgrade from a push: the request is already waiting when the change lands, so the answer leaves immediately rather than at the next tick of a timer.
Events record what happened, not what is. swarmdrop/sent,
swarmdrop/inbox-received and swarmdrop/transfer are things that occurred at a
point in time, so replaying a conversation months later still explains it. The
one whole-value event, swarmdrop/inbox-baseline, answers "what did you have at
hand when this started" — which is exactly the context a reader needs.
Every payload carries a version. These land in your session log, which
outlives the process and gets replayed. A format change that still parses but
means something different is the worst failure available.
A limitation you should know about
dsh refuses to read a session log containing an event type it does not know,
unless the event is marked ignorable. Neither escape is available to a
third-party plugin: the known set is generated from the types declared inside the
dsh repository, and Session.append() offers no way to set the marker. dsh knows
— its own source says a registration surface for out-of-repo events "is deferred
until such a consumer exists".
This plugin is that consumer, so at load it announces its four event types to the
running harness. That makes them readable here. It does not put ignorable
on the events, so:
Disable this plugin rather than uninstalling it, if conversations that used it still matter to you. A harness without the plugin refuses to open a session log containing its events — you would see "unknown to this harness", and the whole conversation, not just the SwarmDrop rows, becomes unreadable.
The announcement also relies on the plugin and dsh resolving the same
@deepseek-ai/dsh-session module instance. That holds for an ordinary install;
it does not hold when dsh is run from a source checkout under tsx, where the
two halves get separate module graphs and the announcement lands on a copy
nothing reads.
Development
npm install
npm run typecheck # both halves
npm run build # lib/index.js + lib/client.js
The two halves compile as separate TypeScript programs, and that is not
optional. dsh augments Context.sessions differently on the two sides (Node:
SessionStore; browser: ISessions), so putting both in one program makes the
browser half compile against the Node service surface and fail with errors that
point nowhere near the cause. The same rule applies inside the source: client
files must never import a package root — only /types and /client
subpaths, which carry no Context augmentation.
The browser bundle is not an ordinary ESM build. dsh's loader expects the
./client entry to register itself with
window.__ModuleLoader__.load({ id, factory }), resolving externals through an
injected require — no import map, no globals. dsh builds its own with a shared
tsdown preset that is not published, so scripts/build-client.mjs reimplements
the wrapper; it is 30 lines. id must equal the package name, because that is
the entry name the host composed into window.__DSH_BOOT__.
Two things a reader will otherwise rediscover the hard way: Two things a reader will otherwise rediscover the hard way:
- The conversation-node cookbook's snippet does not compile as written.
ChatNodeViewPropsbundlest: TranslateNS<'conversation'>, but the slot only injectstwhen the registration passeslocale, and the namespace value first-party code passes is not exported. Seesrc/client/nodes.tsx. exec.agentis optional. A nested Code-Mode dispatch has no agent, so a send still happens but has no conversation to attribute itself to.- npm's
latesttag lags the real version line.npm view @deepseek-ai/…reports0.0.1-rc.1, whose client packages depend on@deepseek-ai/dsh-compact— a package that is not published, making that line unresolvable. The line actually in use is0.1.0-rc.x, which resolves cleanly. Checknpm view <pkg> versionsrather than the bareversion.
License
MIT
No comments yet. Be the first to write one.