TwinsEarth/dsh-windows2macos ↗★ 1
@twinsearth/w2m-dsh-plugin
跨网络和区域在多台设备上同步驱动同一个项目 适合需要在Windows和macOS多台设备间协同开发同一项目的用户。
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:TwinsEarth/dsh-windows2macos说明文档
阅读完整 README ↗DSH: Windows2MacOS
One DeepSeek account. One instruction. Every machine you own runs the same project.
English | 中文
The problem
You have a Windows desktop and a Mac laptop. You are working on one project. You want to say "run the test suite" once, and have both machines run it — then see whether they agree.
DeepSeek Harness cannot do this today, and not by accident:
- one
dshprocess serves one machine — a profile is a local pnpm workspace; - its transport is loopback only (the web server accepts
127.0.0.1/0.0.0.0and the CLI refuses--host 0.0.0.0, because it ships no TLS or origin policy); - the official agent-team feature is explicit that it does not support teammates with separate working directories or several processes coordinating over one team.
So the cross-machine capability has to be built. This is that build.
What you get
you, on any machine, talking to DSH
│
▼
┌──────────────────────────────────┐ ┌──────────────────────────┐
│ Windows · DSH session │ │ macOS · DSH session │
│ w2m plugin (8 tools) │ │ w2m plugin (8 tools) │
│ │ │ │ │ │
│ Localside agent │ │ Localside agent │
│ · three anchors │ │ · three anchors │
│ · local spool │ │ · local spool │
│ · executes argv │ │ · executes argv │
└──────────┬───────────────────────┘ └──────────┬───────────────┘
│ outbound POST + SSE │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Rabbit relay — devices, tasks, leases, six-state report │
└──────────────────────────────────────────────────────────┘
Both machines only ever make outbound connections. No public IP, no port
forwarding, no SSH server on Windows (which, measured, is not installed by
default — ssh.exe exists, sshd does not).
Two coordination modes
| Mode | What it does | When to use |
|---|---|---|
replicate | every machine runs the same command; results compared side by side | "does this change pass on both platforms?" |
split | work divided by index (explicit or modulo sharding) | one large test suite, several machines |
Six aggregation states
Not four — because "the environments differ" and "the results differ" are different findings, and collapsing them turns noise into bug reports.
| State | Meaning |
|---|---|
consistent | every machine agreed on every comparable field |
divergent | machines disagreed — the report names the field and each machine's value |
divergent-platform | only toolchain/platform differ, and the difference is confined to stdout text — expected |
failed | every machine failed |
partial | some succeeded, some failed |
unverifiable | an anchor did not match, so comparison is refused |
Install
This package can run three ways, and all three start the same binaries.
node bin/w2m-rabbit.mjs ... # from a clone (what the examples below use) npx @twinsearth/w2m-dsh-plugin ... # if it is on a registry w2m-rabbit ... # once installed, via its bin entryThe examples use the clone form because that is what is verified in this repository. Nothing needs to be installed to use the relay or the agent: they are plain Node scripts with no runtime dependencies.
1. The relay (once, on any always-on machine)
node bin/w2m-rabbit.mjs --host 0.0.0.0 --port 8787 --state ~/.dsh/xclient/rabbit
It prints a pairing code. Pair the first machine, and the relay immediately
prints a new code — so you can pair the second machine without restarting
anything. (For scripted onboarding, start it with pairingCodeReusable: true
and one code stays valid for its whole TTL.)
2. Localside (on every machine that should run the project)
node bin/w2m-localside.mjs \
--rabbit http://:8787 \
--pair PAIR-XXXXXXXX \
--project /path/to/your/project \
--name win-desktop \
--state ~/.dsh/xclient/localside-win
--allowed-commands is a default-deny whitelist as a JSON array. Nothing
runs unless it matches a prefix:
--allowed-commands '["node --test","git status --porcelain"]'
⚠️ v0.0.1 note: Localside is verified as a standalone process. Running it in-process from the plugin (
autoStartAgent: true) is implemented but has not been exercised end-to-end in this release — start it as its own process.
3. The DSH plugin
dsh plugin --profile
add @twinsearth/w2m-dsh-plugin@0.0.1
Then give it the relay URL in that profile's cordis.patch.yml:
- id: w2m
config:
rabbitUrl: http://127.0.0.1:8787
stateDir: !!js (process.env.DSH_HOME + '/xclient')
machineName: win-desktop
Restart DSH. You should see eight tools: w2m_devices, w2m_run, w2m_wait,
w2m_report, w2m_status, w2m_update, w2m_history, w2m_stats.
dshmay not be on yourPATH. On a packaged install it lives underresources/runtime/cli/bin/. Ifdshis not found, call it by path.
No npm package yet? Install straight from the release tarball — it needs no registry at all:
dsh plugin --profile
add \
https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.0.1/twinsearth-w2m-dsh-plugin-0.0.1.tgz
Use
Then just talk to DSH:
List my machines, then run
node --teston all of them at the same commit and tell me whether the output matches.
The model calls w2m_devices → w2m_run → w2m_wait → w2m_report and hands
you the comparison.
Why you can trust the comparison
A commit SHA alone is not enough. Measured: on a dirty working tree,
git checkout --detach exits 0 and keeps the local changes — HEAD
matches the base while the working tree does not. Compare on SHA alone and you
will report a logic difference that is really a "these are not the same files"
difference.
So every result carries three anchors:
| Anchor | What it proves |
|---|---|
base_commit | both machines started from the same commit |
pre_tree_fingerprint | both had the same working-tree bytes, including untracked files |
command_hash | both ran the same argv, in the same shell mode, in the same relative cwd |
The fingerprint algorithm is git-temp-index-tree/v1: a throwaway index file
(git read-tree → git add -A → git write-tree) that never touches your
working tree. Each call gets its own temporary index — sharing one
GIT_INDEX_FILE across processes makes them fail with idx.lock: File exists.
git stash create is not used: measured, it drops untracked files and returns
an empty string in both the "clean" and "only untracked files" cases, so the two
are indistinguishable.
Prerequisite: put this in your project root, or the two machines will never fingerprint alike:
* text=auto eol=lf
Measured: with that line, a core.autocrlf=true checkout and a
core.autocrlf=false checkout produce byte-identical working trees. Without it,
one is CRLF and the other LF.
Across networks and regions
New in 0.1.2. Before this version the relay only worked at the root of a host: endpoints were built with
new URL('/v1/stream', rabbitUrl), which discards a path prefix, so anything mounted under/w2manswered 404 to everything. Sub-path deployment now works, and sending work requires its own credential. See CHANGELOG.
Machines only ever make outbound connections, so the relay can be anywhere both sides can reach. Three shapes are supported and documented end to end in docs/DEPLOY.md:
| Shape | TLS terminated by | rabbitUrl looks like |
|---|---|---|
| Tailscale / WireGuard (recommended) | the network — WireGuard encrypts | http://100.x.y.z:8787 |
| Public VPS + domain | the relay (--tls-cert/--tls-key) or a proxy | https://w2m.example.com |
| Tunnel (Cloudflare Tunnel, ngrok) | the tunnel service | https://.example.com or with a sub-path |
Plain
http://over a Tailscale address is not a mistake. WireGuard already provides end-to-end encryption and authenticates both peers; adding TLS on top would add certificate plumbing without adding a property you do not already have. That is why shape A is the recommended default rather than a compromise.
If your proxy or tunnel mounts the relay under a prefix — https://host/w2m/ —
then start it with --base-path /w2m and put the same prefix in rabbitUrl.
Sub-path deployment works because URLs are joined by concatenation; see
CHANGELOG for the bug that made this impossible
before 0.1.2.
Two credentials, on purpose
| Credential | Held by | Authorises |
|---|---|---|
device_token | each machine | taking work, reporting results |
operator_token | only you | sending work (POST /v1/task) |
A machine that can be told what to do should not automatically be able to tell the
others. The relay writes the operator token to /operator-token.txt on
first start and prints it once; the plugin takes it as operatorToken. Starting
the relay with --operator-token '' removes the requirement — it warns loudly,
and that is only appropriate for a trusted LAN.
Surviving a restart
devices.json plus ledger.jsonl mean a restarted relay keeps its paired
machines and its task history, which matters once the relay lives on a VPS you
will eventually reboot. A corrupt ledger line is skipped and reported; a corrupt
device file starts empty and says so rather than silently forgetting every
machine. --no-persist restores in-memory behaviour.
Staying up to date
New in 0.2.3. A machine running this plugin checks its own GitHub releases at 00:00, 03:00 and 05:00 Beijing time and installs a newer version if there is one. This is off by default — an updater that replaces the installed plugin is opt-in.
Turn it on in the profile patch:
# ~/.dsh/profiles/
/cordis.patch.yml
- insert:
- id: w2m-tools
name: '@twinsearth/w2m-dsh-plugin/tools'
config:
rabbitUrl: 'http://100.x.y.z:8787'
autoUpdate: true # default false
# autoUpdateTimes: ['00:00:00', '03:00:00', '05:00:00'] # Beijing wall clock
# autoUpdateTimeZone: Asia/Shanghai # default
# autoUpdateDryRun: true # verify, install nothing
# updateRepo: TwinsEarth/dsh-windows2macos # default
Ask it what it is doing from any session with w2m_update (action: "status" to
inspect, action: "check" to run one cycle now).
What it does and does not do:
- Compares the newest GitHub release against the version it was built as. It installs only a strictly newer, non-prerelease version — a downgrade is worse than a missed update, and a release candidate outranks the release it precedes by SemVer, so the gate is explicit.
- Downloads the tarball and verifies the SHA-256 published in that release's
SHA256SUMSbefore anything is written. A mismatch installs nothing. - Backs up the profile's
package.jsonandpnpm-lock.yaml, then installs throughdsh plugin --profile add(falling back to the runtime's own pnpm). If the install fails, the manifest is restored byte-for-byte. - It does not restart DSH. The new version loads on the next start; the
running process keeps the code it loaded.
w2m_statusreportsrestart_requiredonce an install has happened, and the installed tarball lives in/.w2m-update/so the dependency does not dangle. - It does not hot-swap the running plugin. Nothing may rewrite the module a live Cordis container already loaded, so claiming otherwise would be a lie in the status output.
Honest limits.
- A missed slot runs once on the next start if it was missed by less than 90 minutes (a machine asleep across one slot). Wider than that and the check waits for the next slot rather than firing at an arbitrary hour.
- If pnpm fails after it has already changed
node_modules, restoring the manifest is not enough to guarantee DSH still starts. The result then carriesrollbackComplete: falseand areconciliationcommand;w2m_statussurfaces it asreconciliation_needed. This is the one state that needs a human. - A negative result is never reported as success: a GitHub lookup that fails is recorded as an error, not as "already up to date", because those two look identical in a log and only one of them is a problem.
Security
Read this before exposing the relay to anything.
- Admission is a single-use, rotating pairing code, then a per-device bearer
token stored in
device.json(mode0600). Pairing attempts are rate limited (5 per IP per minute by default, successes included). - Sending work needs the operator token, not a device token.
--trust-proxyis off by default and should stay off unless the relay really is behind a proxy you control: it makes the relay believeX-Forwarded-For, and believing that header without a proxy in front lets any client forge its address and walk past the rate limit.- The relay holds no model credentials and no working copy. It can forge tasks, which is exactly why the machine-side whitelist is the load-bearing control.
- Commands are never shell strings. argv arrays are passed to
spawn(cmd, args, { shell: false }), so nothing a caller types can be reinterpreted as shell syntax. - Default-deny whitelist. A machine runs nothing that does not match
--allowed-commands. - Read-only by default.
replicatetasks run withwrite: false; there is no code path that writes to your project in that mode. - TLS is your choice, but a deliberate one. Shape A gets it from WireGuard;
shapes B and C get it from the relay's
--tls-cert/--tls-keyor from the proxy. Running shape B or C over plainhttp://would put the operator token and every result on the wire in clear text — do not. - The plugin does not read
.credentials.yaml.
Verified, and not verified
This project's rule is that claims carry their evidence.
The same suites run on three platforms in CI — see the badge above, or
.github/workflows/ci.yml. The matrix is
windows-latest × node 20/22, macos-latest × node 22, ubuntu-latest ×
node 22. That matters here more than in most projects: a Windows-and-macOS
coordination tool whose macOS half had never been executed would be a claim, not
a product.
| Suite | Tests | What it covers |
|---|---|---|
| relay | 91 | pairing, auth, SSE with ready-first and seq replay, leases with heartbeat renewal and expiry, dedupe, all six aggregation states, report generation, operation token, rate limiting, persistence and restart reco |