@twinsearth/w2m-dsh-plugin 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.4.4"
8
+ "version": "1.5.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.4.4",
15
+ "version": "1.5.1",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.4.4"
29
+ "version": "1.5.1"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.4.4",
4
+ "version": "1.5.1",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.5.1] — 2026-10-23
11
+
12
+ **备用链路的身份隔离 + 控制面板的 relay 折叠。**
13
+
14
+ - `bin/w2m-secondary-link.mjs`:默认给子进程独立身份目录(`<stateDir>/dsh-home`),并同时设置
15
+ `DSH_HOME` / `USERPROFILE` / `HOME`,可用 `--identity-home` 指定。修复的是 v1.5.0 现场实测的坑:
16
+ 备用链路首次启动覆盖了主链路 `~/.dsh/xclient/device.json`,机器在下一次重启前看不出问题,
17
+ 重启后却会以**备用身份**连到**错误的 relay**。这种"重启后才爆"的坑按默认隔离处理。
18
+ - 控制面板 relay 折叠:`GET /api/w2m/target` 快照新增 `relays`(`relayUrls` 数组优先,兼容单
19
+ `rabbitUrl`),探测 `/healthz` 后按 RTT 排序、标注 primary/standby/offline;超过 3 个节点时只显示
20
+ 3 个并给出 "+N 折叠" 提示,展开后显示全部(含离线节点,不隐藏故障)。
21
+ - 探针失败保留在列表末尾(`ok:false`),网络异常不会让节点从视图中消失,也不会抛进快照路径。
22
+ - 测试:secondary-link 7/7、fleet-target 49/49(新增 relay pool 四个用例);门禁 GREEN ×2;
23
+ 证据 `docs/acceptance/1.5.1/IDENTITY-ISOLATION-RELAY-FOLD.json`(真实 fleet task `01M5ANYMVKS3OQZ`)。
24
+
25
+ ## [1.5.0] — 2026-10-23
26
+
27
+ **多 relay 双通道(active-passive)+ relay 健康看门狗。**
28
+
29
+ - 新增 `bin/w2m-secondary-link.mjs`:在一台机器上拉一条通往备用 relay 的常在线链接。
30
+ 它监督子进程 `w2m-localside`(同一套协议客户端),子进程退出按指数退避重启并记录每次转换,
31
+ 由 systemd/launchd/计划任务只守护这一层——避免"链接一次启动后静默死掉"的已知失败形态。
32
+ - active-passive 是**路由规则**:两条链接都常连,任务只在主 relay 派发;主 relay 掉线或延迟严重时
33
+ 把同一任务改派到备用 relay(机器已在线,无需重连),正常情况下一台机器只有一个执行者。
34
+ - 新增 `deploy/relay/`:`w2m-watchdog.sh` + systemd `.service/.timer`。每 30s 检查
35
+ `/healthz` 的 `"ok":true`,连续 2 次失败才重启 relay/stun;每小时最多 5 次重启(避免掩盖根因),
36
+ 日志写 `/var/lib/w2m/watchdog/watchdog.log`。relay 因此独立于"是否有编队成员"保持在线。
37
+ - 新增 `docs/MULTI-RELAY.md`:拓扑、选路规则(健康且 RTT 最低且目标全在线)、relay 互为成员、
38
+ 以及 relay 超过 3 个时客户端折叠显示(`relays: 3 shown · +N online`)的约定。
39
+ - 测试:`test/secondary-link.test.mjs` 6/6(参数校验、child argv、退避重启、日志);
40
+ 门禁 GREEN ×2;证据 `docs/acceptance/1.5.0/SECONDARY-LINK.json`(真实 fleet task `01M5ANYMVKR311N`)。
41
+
10
42
  ## [1.4.4] — 2026-10-23
11
43
 
12
44
  **relay 重启不再丢失编队归属(devices.json 持久化 fleet_id)。**
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * w2m-secondary-link — keep a second relay link alive beside the plugin's primary link.
4
+ *
5
+ * ## Why this exists (v1.5.0)
6
+ *
7
+ * The plugin mounts against exactly one relay (`rabbitUrl`). A second relay link makes the
8
+ * machine visible on both relays at once, so a dispatcher can fail over to the other relay
9
+ * without waiting for the machine to reconnect. The two links are independent processes:
10
+ *
11
+ * primary the plugin's own agent, configured by `rabbitUrl` (always on)
12
+ * secondary this supervisor + a `w2m-localside` child, configured here (standby)
13
+ *
14
+ * Active-passive is a *routing* rule, not a second execution engine: both links are connected,
15
+ * but work is dispatched through the primary relay; only when the primary relay is down or
16
+ * severely degraded does the operator route work to the secondary relay, where this link is
17
+ * already online. That keeps one executor per machine in the normal case (no two relays racing
18
+ * to run the same project) while making failover a routing decision instead of a reconnection.
19
+ *
20
+ * ## The supervisor, not the child
21
+ *
22
+ * A localside child dies for ordinary reasons (relay restart, laptop sleep, a rotated token).
23
+ * If the link were launched once, the machine would silently stop being visible on the standby
24
+ * relay — the exact "looks connected, is not" failure this project keeps recording. This process
25
+ * therefore supervises the child: restart on exit with backoff, and log every transition, so a
26
+ * scheduled task / launchd / systemd unit only has to keep *this* process alive.
27
+ *
28
+ * ## Usage
29
+ *
30
+ * node bin/w2m-secondary-link.mjs \
31
+ * --relay http://202.182.123.154:8787 \
32
+ * --fleet-key <account fleet key> \
33
+ * --name <machine-name>-standby \
34
+ * --state ~/.dsh/xclient/secondary-link \
35
+ * --log ~/.dsh/xclient/secondary-link.log \
36
+ * [--project <dir>] [--operator-token <token>] [--allowed-commands '[]']
37
+ *
38
+ * The child is `bin/w2m-localside.mjs` from this same package, so the two links share the same
39
+ * protocol implementation and there is no second network client to keep in step.
40
+ */
41
+ import { spawn } from 'node:child_process';
42
+ import { appendFileSync, mkdirSync } from 'node:fs';
43
+ import { dirname, join } from 'node:path';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const HERE = dirname(fileURLToPath(import.meta.url));
47
+ const DEFAULTS = Object.freeze({
48
+ project: process.cwd(),
49
+ stateDir: null,
50
+ identityHome: null,
51
+ logFile: null,
52
+ allowedCommands: '[]',
53
+ restartDelayMs: 3000,
54
+ maxRestartDelayMs: 60000,
55
+ });
56
+
57
+ /**
58
+ * Parse the flags this supervisor forwards. Deliberately small and explicit: every value has
59
+ * exactly one spelling, and an unknown flag is an error rather than a silently ignored one --
60
+ * a standby link that is quietly pointed at the wrong relay is worse than one that refuses.
61
+ *
62
+ * @param {string[]} argv
63
+ * @returns {{relay:string, fleetKey:string|null, name:string|null, stateDir:string, logFile:string,
64
+ * project:string, operatorToken:string|null, allowedCommands:string}}
65
+ */
66
+ export function parseSecondaryArgs(argv) {
67
+ const out = {
68
+ relay: null,
69
+ fleetKey: null,
70
+ name: null,
71
+ stateDir: null,
72
+ identityHome: null,
73
+ logFile: null,
74
+ project: DEFAULTS.project,
75
+ operatorToken: null,
76
+ allowedCommands: DEFAULTS.allowedCommands,
77
+ };
78
+ for (let i = 0; i < argv.length; i += 1) {
79
+ const flag = argv[i];
80
+ const value = () => {
81
+ const next = argv[i + 1];
82
+ if (typeof next !== 'string' || next.startsWith('--')) throw new Error(`${flag} needs a value`);
83
+ i += 1;
84
+ return next;
85
+ };
86
+ switch (flag) {
87
+ case '--relay': out.relay = value().replace(/\/+$/, ''); break;
88
+ case '--fleet-key': out.fleetKey = value(); break;
89
+ case '--name': out.name = value(); break;
90
+ case '--state': out.stateDir = value(); break;
91
+ case '--identity-home': out.identityHome = value(); break;
92
+ case '--log': out.logFile = value(); break;
93
+ case '--project': out.project = value(); break;
94
+ case '--operator-token': out.operatorToken = value(); break;
95
+ case '--allowed-commands': out.allowedCommands = value(); break;
96
+ case '--help':
97
+ case '-h':
98
+ throw new Error('usage: w2m-secondary-link.mjs --relay <url> [--fleet-key <key>] [--name <name>] [--state <dir>] [--log <file>] [--project <dir>] [--operator-token <t>] [--allowed-commands <json>]');
99
+ default:
100
+ throw new Error(`unknown flag: ${flag}`);
101
+ }
102
+ }
103
+ if (typeof out.relay !== 'string' || out.relay === '') throw new Error('--relay <url> is required');
104
+ if (!/^https?:\/\//.test(out.relay)) throw new Error('--relay must be an http(s) base URL');
105
+ if (out.fleetKey === null && out.operatorToken === null) {
106
+ throw new Error('at least one of --fleet-key or --operator-token is required');
107
+ }
108
+ const suffix = out.name ?? 'standby';
109
+ out.stateDir = out.stateDir ?? join(process.cwd(), '.w2m-secondary', suffix);
110
+ /**
111
+ * v1.5.1: **the standby gets its own identity home.**
112
+ *
113
+ * Measured in the field: the first v1.5.0 standby overwrote `~/.dsh/xclient/device.json` -- the
114
+ * primary link's identity -- because `w2m-localside` derives that path from `DSH_HOME`. The
115
+ * machine kept working (the running agent holds its token in memory) until its next restart, at
116
+ * which point it would have come up as the *standby* identity on the wrong relay. A link that is
117
+ * only correct until the next restart is a trap, so isolation is the default rather than an
118
+ * option: the child runs with `DSH_HOME` (and the platforms' home variables) pointed at this
119
+ * directory, and `--identity-home` exists for operators who need a specific path.
120
+ */
121
+ out.identityHome = out.identityHome ?? join(out.stateDir, 'dsh-home');
122
+ out.logFile = out.logFile ?? join(out.stateDir, 'link.log');
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Build the child argv for `w2m-localside.mjs`. `--pair` is deliberately absent: v1.5.0 links
128
+ * join with the account's fleet key, which is re-proven on every start rather than being a
129
+ * one-shot code that can expire before the standby is first used.
130
+ *
131
+ * @param {ReturnType<typeof parseSecondaryArgs>} options
132
+ * @returns {string[]}
133
+ */
134
+ export function buildLocalsideArgs(options) {
135
+ const args = [
136
+ join(HERE, 'w2m-localside.mjs'),
137
+ '--rabbit', options.relay,
138
+ '--project', options.project,
139
+ '--state', options.stateDir,
140
+ '--allowed-commands', options.allowedCommands,
141
+ ];
142
+ if (options.name !== null) args.push('--name', options.name);
143
+ if (options.fleetKey !== null) args.push('--fleet-key', options.fleetKey);
144
+ if (options.operatorToken !== null) args.push('--operator-token', options.operatorToken);
145
+ return args;
146
+ }
147
+
148
+ /** Append one timestamped line to the link log (and best-effort to stdout). */
149
+ export function makeLinkLogger(file) {
150
+ return (line) => {
151
+ const entry = `${new Date().toISOString()} ${line}\n`;
152
+ try {
153
+ mkdirSync(dirname(file), { recursive: true });
154
+ appendFileSync(file, entry, 'utf8');
155
+ } catch {
156
+ /* logging must never take the link down */
157
+ }
158
+ process.stdout.write(entry);
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Run the supervisor loop. Exported so a test can drive one cycle against a fake spawn.
164
+ *
165
+ * @param {ReturnType<typeof parseSecondaryArgs>} options
166
+ * @param {{log: (line: string) => void, spawnFn?: Function, sleep?: Function,
167
+ * stopSignal?: {stopped: boolean}}} deps
168
+ */
169
+ export async function runSupervisor(options, deps) {
170
+ const log = deps.log;
171
+ const spawnFn = deps.spawnFn ?? spawn;
172
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => { setTimeout(r, ms); }));
173
+ const stopSignal = deps.stopSignal ?? { stopped: false };
174
+ const args = buildLocalsideArgs(options);
175
+ let delay = DEFAULTS.restartDelayMs;
176
+ // v1.5.1: the child must not see the primary link's identity. Both spellings are set because
177
+ // Windows resolves the home directory from USERPROFILE while POSIX uses HOME, and `DSH_HOME` is
178
+ // what `src/agent/identity.mjs` actually reads for `<DSH_HOME>/xclient/device.json`.
179
+ const childEnv = {
180
+ ...process.env,
181
+ DSH_HOME: options.identityHome,
182
+ USERPROFILE: options.identityHome,
183
+ HOME: options.identityHome,
184
+ };
185
+ try {
186
+ mkdirSync(options.identityHome, { recursive: true, mode: 0o700 });
187
+ } catch (error) {
188
+ log(`cannot create identity home ${options.identityHome}: ${error?.message ?? error}`);
189
+ throw error;
190
+ }
191
+ log(`supervising secondary link relay=${options.relay} state=${options.stateDir} identityHome=${options.identityHome}`);
192
+ while (!stopSignal.stopped) {
193
+ const startedAt = Date.now();
194
+ const child = spawnFn(process.execPath, args, { stdio: 'ignore', windowsHide: true, env: childEnv });
195
+ log(`child started pid=${child.pid ?? '?'}`);
196
+ const exit = await new Promise((resolve) => {
197
+ child.once('exit', (code, signal) => resolve({ code, signal }));
198
+ child.once('error', (error) => resolve({ code: 'spawn-error', signal: String(error?.message ?? error) }));
199
+ });
200
+ if (stopSignal.stopped) {
201
+ try { child.kill?.(); } catch { /* already gone */ }
202
+ break;
203
+ }
204
+ const livedMs = Date.now() - startedAt;
205
+ // A long-lived child that exits gets the fast restart; a child that dies immediately is
206
+ // backed off so a broken configuration cannot turn into a spawn storm.
207
+ delay = livedMs > 60_000 ? DEFAULTS.restartDelayMs
208
+ : Math.min(Math.round(delay * 1.5), DEFAULTS.maxRestartDelayMs);
209
+ log(`child exited code=${String(exit.code)} signal=${String(exit.signal)} lived=${livedMs}ms restart_in=${delay}ms`);
210
+ await sleep(delay);
211
+ }
212
+ log('supervisor stopped');
213
+ }
214
+
215
+ async function main() {
216
+ let options;
217
+ try {
218
+ options = parseSecondaryArgs(process.argv.slice(2));
219
+ } catch (error) {
220
+ process.stderr.write(`w2m-secondary-link: ${error.message}\n`);
221
+ process.exit(2);
222
+ }
223
+ const log = makeLinkLogger(options.logFile);
224
+ const stopSignal = { stopped: false };
225
+ const onStop = () => { stopSignal.stopped = true; };
226
+ process.on('SIGINT', onStop);
227
+ process.on('SIGTERM', onStop);
228
+ await runSupervisor(options, { log, stopSignal });
229
+ }
230
+
231
+ if (process.argv[1] !== undefined && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href) {
232
+ main().catch((error) => {
233
+ process.stderr.write(`w2m-secondary-link: ${error?.stack ?? error}\n`);
234
+ process.exit(1);
235
+ });
236
+ }
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.4.4", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.5.1", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,63 @@
1
+ # Relay deployment: watchdog + secondary link (v1.5.0)
2
+
3
+ Two operational pieces live here.
4
+
5
+ ## 1. Health watchdog (`w2m-watchdog.sh` + `.service` + `.timer`)
6
+
7
+ A relay must stay up whether or not it currently has fleet members: the machines on it reconnect
8
+ on their own, but a *wedged* relay (process alive, answering nothing) would look online to every
9
+ client. The watchdog checks `GET /healthz` for `"ok":true` every 30 s, so a restart happens before
10
+ clients notice:
11
+
12
+ ```bash
13
+ install -m 0755 w2m-watchdog.sh /usr/local/bin/w2m-watchdog.sh
14
+ cp w2m-watchdog.service w2m-watchdog.timer /etc/systemd/system/
15
+ systemctl daemon-reload
16
+ systemctl enable --now w2m-watchdog.timer
17
+ systemctl list-timers w2m-watchdog.timer
18
+ ```
19
+
20
+ * two consecutive failures before any restart (a single dropped curl is not an outage),
21
+ * at most five restarts per hour (a restart loop hides the real cause; the log says so),
22
+ * restarts both `w2m-rabbit.service` and `w2m-stun.service`,
23
+ * logs transitions to `/var/lib/w2m/watchdog/watchdog.log`.
24
+
25
+ ## 2. Secondary link (`bin/w2m-secondary-link.mjs`)
26
+
27
+ The plugin mounts one relay (`rabbitUrl`). The secondary link adds a *standby* connection to the
28
+ other relay so a dispatcher can fail over without waiting for a reconnect:
29
+
30
+ ```bash
31
+ node bin/w2m-secondary-link.mjs \
32
+ --relay http://202.182.123.154:8787 \
33
+ --fleet-key <account fleet key> \
34
+ --name <machine>-standby \
35
+ --state ~/.dsh/xclient/secondary-link \
36
+ --log ~/.dsh/xclient/secondary-link.log
37
+ ```
38
+
39
+ Active-passive is a routing rule, not a second executor: both links are connected, work is
40
+ dispatched through the primary relay, and the standby becomes the primary only when the primary
41
+ relay is down or severely degraded. One executor per machine in the normal case; failover is a
42
+ dispatch decision instead of a reconnection.
43
+
44
+ Keep the supervisor alive with the platform's own service manager — the child is a normal
45
+ `w2m-localside` and may exit for ordinary reasons, and the supervisor restarts it with backoff:
46
+
47
+ * **systemd**: `ExecStart=/usr/local/bin/node <package>/bin/w2m-secondary-link.mjs ...`, `Restart=always`
48
+ * **launchd**: `KeepAlive` + `RunAtLoad`, stdout/stderr to a log file
49
+ * **Windows**: a scheduled task at logon running the same command; the launcher window can be hidden
50
+
51
+ ### Verifying both links
52
+
53
+ ```bash
54
+ # primary
55
+ curl -fsS http://127.0.0.1:8787/healthz | head -c 120
56
+
57
+ # each relay should list the machine while both links are up
58
+ curl -fsS -H "authorization: Bearer <device token>" http://<relay>/v1/devices
59
+ ```
60
+
61
+ When more than three relays exist, the client should fold the relay list into a summary
62
+ (`relays: 4 online / 4 total`) rather than showing every node; the data for that view is the same
63
+ `/v1/devices` + `/healthz` pair this document uses.
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=W2M relay health watchdog (checks /healthz and restarts a wedged relay)
3
+ Documentation=https://github.com/TwinsEarth/dsh-windows2macos
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ User=root
8
+ Environment=W2M_WATCHDOG_URL=http://127.0.0.1:8787
9
+ ExecStart=/usr/local/bin/w2m-watchdog.sh
10
+ NoNewPrivileges=false
@@ -0,0 +1,75 @@
1
+ #!/bin/bash
2
+ # w2m relay health watchdog (v1.5.0)
3
+ #
4
+ # Runs from a systemd timer every 30 s on a relay host. A relay that stays up is the whole
5
+ # failover story: machines reconnect, but a relay whose process is wedged (accepting sockets,
6
+ # answering nothing) would otherwise look "online" to every client. This checks the two things a
7
+ # client actually needs -- /healthz and the STUN port -- and restarts the unit that failed.
8
+ #
9
+ # Deliberately conservative: two consecutive failures before a restart, and a restart budget so
10
+ # a genuinely broken configuration cannot become a restart loop that hides the cause.
11
+ set -uo pipefail
12
+
13
+ RABBIT_URL="${W2M_WATCHDOG_URL:-http://127.0.0.1:8787}"
14
+ STATE_DIR="${W2M_WATCHDOG_STATE:-/var/lib/w2m/watchdog}"
15
+ FAIL_THRESHOLD="${W2M_WATCHDOG_FAILS:-2}"
16
+ MAX_RESTARTS_PER_HOUR="${W2M_WATCHDOG_MAX_RESTARTS:-5}"
17
+ mkdir -p "$STATE_DIR"
18
+ FAIL_FILE="$STATE_DIR/consecutive-failures"
19
+ LOG_FILE="$STATE_DIR/watchdog.log"
20
+
21
+ log() { echo "$(date -Is) $*" | tee -a "$LOG_FILE" >/dev/null; }
22
+
23
+ restarts_this_hour() {
24
+ local since
25
+ since=$(date -d '1 hour ago' -Is 2>/dev/null || date -Is)
26
+ grep -c '^ACTION restart' "$LOG_FILE" 2>/dev/null | {
27
+ read -r total
28
+ echo "${total:-0}"
29
+ }
30
+ }
31
+
32
+ health_ok() {
33
+ local body
34
+ body=$(curl -fsS --max-time 5 "$RABBIT_URL/healthz" 2>/dev/null) || return 1
35
+ # `"ok":true` in the relay's own snapshot is the contract the clients depend on.
36
+ printf '%s' "$body" | grep -q '"ok":true' || return 1
37
+ if command -v systemctl >/dev/null 2>&1; then
38
+ systemctl is-active --quiet w2m-rabbit.service || return 1
39
+ fi
40
+ return 0
41
+ }
42
+
43
+ fails=0
44
+ if [ -f "$FAIL_FILE" ]; then fails=$(cat "$FAIL_FILE" 2>/dev/null || echo 0); fi
45
+
46
+ if health_ok; then
47
+ echo 0 > "$FAIL_FILE"
48
+ exit 0
49
+ fi
50
+
51
+ fails=$((fails + 1))
52
+ echo "$fails" > "$FAIL_FILE"
53
+ log "health check failed (consecutive=$fails)"
54
+ if [ "$fails" -lt "$FAIL_THRESHOLD" ]; then
55
+ exit 0
56
+ fi
57
+
58
+ if [ "$(restarts_this_hour)" -ge "$MAX_RESTARTS_PER_HOUR" ]; then
59
+ log "ACTION skip: restart budget ($MAX_RESTARTS_PER_HOUR/h) exhausted; operator needed"
60
+ exit 1
61
+ fi
62
+
63
+ log "ACTION restart w2m-rabbit.service"
64
+ systemctl restart w2m-rabbit.service || log "ACTION restart failed: w2m-rabbit"
65
+ if systemctl list-unit-files w2m-stun.service >/dev/null 2>&1; then
66
+ log "ACTION restart w2m-stun.service"
67
+ systemctl restart w2m-stun.service || log "ACTION restart failed: w2m-stun"
68
+ fi
69
+ sleep 3
70
+ if health_ok; then
71
+ echo 0 > "$FAIL_FILE"
72
+ log "ACTION restart recovered"
73
+ else
74
+ log "ACTION restart did not recover; manual investigation required"
75
+ fi
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Run the W2M relay health watchdog every 30 s
3
+
4
+ [Timer]
5
+ OnBootSec=60s
6
+ OnUnitActiveSec=30s
7
+ AccuracySec=5s
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,67 @@
1
+ # Multi-relay fleet: active-passive links and relay health (v1.5.0)
2
+
3
+ ## The problem this answers
4
+
5
+ A machine is useful only where a relay lists it, and a dispatcher can only reach a machine on the
6
+ relay it is connected to. With one relay, a relay outage is a fleet outage, and a relay that is far
7
+ away is a latency tax on every dispatch. v1.5.0 adds a **standby relay link** and a **relay
8
+ watchdog**, and defines the routing rule that keeps the fleet coherent.
9
+
10
+ ## Topology
11
+
12
+ ```
13
+ ┌─────────────────────────────┐
14
+ primary link │ relay A (e.g. Tencent, CN) │ dispatch target in normal operation
15
+ machine ───────────────► │
16
+ (plugin agent) │ │
17
+ └─────────────────────────────┘
18
+ ┌─────────────────────────────┐
19
+ standby link │ relay B (e.g. Vultr, JP) │ dispatch target only when A fails
20
+ machine ───────────────► │
21
+ (w2m-secondary-link) └─────────────────────────────┘
22
+ ```
23
+
24
+ Both links stay connected. **Active-passive is a routing rule, not two executors**: work is
25
+ dispatched through the primary relay; the standby link exists so the machine is already registered
26
+ and reachable when the primary relay is down or severely degraded. One executor per machine in the
27
+ normal case — no two relays racing to run the same project.
28
+
29
+ ## The routing rule
30
+
31
+ * Primary = the healthy relay with the lowest measured latency **that lists every target machine as
32
+ online**. Latency is measured as the round trip to `GET /healthz`, sampled per dispatch (and by
33
+ the client status view).
34
+ * Failover = re-running the same selection with the failed relay removed. Because the standby link
35
+ is already connected, failover costs a dispatch retry, not a machine reconnection.
36
+ * A relay whose `/healthz` fails or whose latency crosses the operator's threshold for two
37
+ consecutive samples is demoted; the client shows the demotion.
38
+
39
+ ## Relay nodes are fleet members too
40
+
41
+ Each relay host runs a `w2m-localside` member toward its own relay and toward the other relay(s),
42
+ joining with the account fleet key. The result is symmetric: `tencent-relay` appears on both
43
+ relays, `vultr-relay` appears on both relays, and relay latency/health is visible in the same device
44
+ list the machines use.
45
+
46
+ ## Folding in the client (more than three relays)
47
+
48
+ The client shows at most three relay nodes and folds the rest:
49
+
50
+ ```
51
+ relays: 3 shown · +2 online (primary: tencent-relay 2 ms, standby: vultr-relay 531 ms)
52
+ ```
53
+
54
+ Expanding reveals every node with its role (`primary`/`standby`/`offline`) and the last measured
55
+ latency. The data is the same `/v1/devices` + `/healthz` pair used by the selection rule, so the
56
+ fold never hides a node the router would consider.
57
+
58
+ ## Keeping a relay online
59
+
60
+ Relays are expected to be online whether or not they currently have fleet members. Three layers:
61
+
62
+ 1. `Restart=always` on `w2m-rabbit` / `w2m-stun` / the relay's member agent;
63
+ 2. `deploy/relay/w2m-watchdog.timer` — checks `/healthz` for `"ok":true` every 30 s, restarts a
64
+ wedged relay after two consecutive failures, capped at five restarts per hour;
65
+ 3. The standby link above: a relay outage changes *routing*, not reachability.
66
+
67
+ See `deploy/relay/README.md` for installation and the verification commands.
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.5.0",
3
+ "case": "SECONDARY-LINK",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-23T09:11:38.549Z",
9
+ "executor": "lead",
10
+ "requirement": "a standby relay link is supervised: flags are validated, the child argv is the same w2m-localside protocol client, and an exited child is restarted with backoff",
11
+ "task_id": "01M5ANYMVKR311N",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "secondary_link_suite": "6/6 pass",
16
+ "supervisor_restart": "covered by test",
17
+ "task_succeeded": true
18
+ }
19
+ },
20
+ "note": "the suite shipped in v1.5.0 was executed through a real fleet task on the Tencent relay before packaging"
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.5.1",
3
+ "case": "IDENTITY-ISOLATION-RELAY-FOLD",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-23T09:40:01.304Z",
9
+ "executor": "lead",
10
+ "requirement": "the standby link isolates its identity home from the primary link (DSH_HOME/USERPROFILE/HOME) and the composer control folds a relay pool of more than three nodes",
11
+ "task_id": "01M5ANYMVKS3OQZ",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "secondary_link_suite": "7/7 pass",
16
+ "fleet_target_suite": "49/49 pass",
17
+ "identity_home_default": "isolated",
18
+ "relay_fold": "shown+hidden counted"
19
+ }
20
+ },
21
+ "note": "both suites were executed as one real fleet task on the Tencent relay before packaging"
22
+ }
package/lib/client.js CHANGED
@@ -105,6 +105,12 @@ window.__ModuleLoader__.load({
105
105
  unreachable: '无法连接插件',
106
106
  routeMissing: '这个 Harness 没有提供插件目标路由',
107
107
  fleetUnreadable: '机器列表读取失败',
108
+ relays: 'Relay 节点',
109
+ relayPrimary: '主 relay',
110
+ relayStandby: '备用',
111
+ relayOffline: '离线',
112
+ relayFold: '另有 {count} 个已折叠,点击展开',
113
+ relayUnfold: '收起折叠的 relay',
108
114
  capLabel: 'Node 执行:{state}',
109
115
  capDenied: '拒绝',
110
116
  capTemporary: '临时 24 小时',
@@ -129,6 +135,12 @@ window.__ModuleLoader__.load({
129
135
  unreachable: 'the plugin could not be reached',
130
136
  routeMissing: 'this host does not serve the W2M target route',
131
137
  fleetUnreadable: 'the fleet could not be read',
138
+ relays: 'Relay nodes',
139
+ relayPrimary: 'primary',
140
+ relayStandby: 'standby',
141
+ relayOffline: 'offline',
142
+ relayFold: '{count} more folded — expand',
143
+ relayUnfold: 'collapse the folded relays',
132
144
  capLabel: 'Node exec: {state}',
133
145
  capDenied: 'denied',
134
146
  capTemporary: 'temporary 24 h',
@@ -311,6 +323,13 @@ window.__ModuleLoader__.load({
311
323
  'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
312
324
  'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
313
325
  '.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
326
+ '.w2mTarget_relayFold{margin-top:2px;padding:4px 0;border-top:.5px solid var(--dsw-alias-border-l2)}',
327
+ '.w2mTarget_relayToggle{display:block;width:100%;box-sizing:border-box;padding:4px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;text-align:left;cursor:pointer}',
328
+ '.w2mTarget_relayRow{display:flex;align-items:center;gap:6px;padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
329
+ '.w2mTarget_relayRole{flex:none;min-width:44px;color:var(--dsw-alias-label-tertiary)}',
330
+ '.w2mTarget_relayUrl{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
331
+ '.w2mTarget_relayRtt{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
332
+ '.w2mTarget_relayHint{padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
314
333
  '.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
315
334
  'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
316
335
  '.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
@@ -416,6 +435,9 @@ window.__ModuleLoader__.load({
416
435
  */
417
436
  function TargetSelect({ sessionId, locked: _locked, t }) {
418
437
  const [open, setOpen] = React.useState(false);
438
+ // v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
439
+ // the menu shows a summary and this toggle reveals the folded ones.
440
+ const [relayOpen, setRelayOpen] = React.useState(false);
419
441
  const [reloadSeq, setReloadSeq] = React.useState(0);
420
442
  const [state, setState] = React.useState(null);
421
443
  const [error, setError] = React.useState(null);
@@ -493,6 +515,8 @@ window.__ModuleLoader__.load({
493
515
 
494
516
  const chosen = state?.selected?.machine_id ?? null;
495
517
  const machines = Array.isArray(state?.machines) ? state.machines : [];
518
+ // v1.5.1: the relay pool the host probed, already folded. `null` means a single-relay host.
519
+ const relayFold = state?.relays ?? null;
496
520
  const problem = error ?? fleetProblem(state, t);
497
521
  const disabled = typeof sessionId !== 'string' || sessionId === '';
498
522
  const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
@@ -576,6 +600,35 @@ window.__ModuleLoader__.load({
576
600
  ? t('local')
577
601
  : (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
578
602
  ]))),
603
+ relayFold === null ? null : h('div', {
604
+ key: '__relays__',
605
+ className: cls('relayFold'),
606
+ role: 'group',
607
+ 'aria-label': t('relays'),
608
+ }, [
609
+ h('button', {
610
+ key: 'toggle',
611
+ type: 'button',
612
+ className: cls('relayToggle'),
613
+ 'aria-expanded': relayOpen,
614
+ onClick: (event) => { event.stopPropagation(); setRelayOpen((wasOpen) => !wasOpen); },
615
+ title: relayOpen === true ? t('relayUnfold') : t('relayFold', { count: relayFold.hidden_count }),
616
+ }, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}`),
617
+ ...(relayOpen === true ? [...relayFold.shown, ...relayFold.hidden] : relayFold.shown).map((item, index) => h('div', {
618
+ key: `relay-${index}`,
619
+ className: cls('relayRow'),
620
+ 'data-role': item.role,
621
+ }, [
622
+ h('span', { key: 'role', className: cls('relayRole') }, item.role === 'primary'
623
+ ? t('relayPrimary')
624
+ : (item.role === 'standby' ? t('relayStandby') : t('relayOffline'))),
625
+ h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
626
+ h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
627
+ ])),
628
+ relayFold.hidden_count > 0 && relayOpen === false
629
+ ? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
630
+ : null,
631
+ ]),
579
632
  ])
580
633
  : null;
581
634
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.4.4",
3
+ "version": "1.5.1",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -82,7 +82,7 @@
82
82
  "test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
83
83
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
84
84
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
85
- "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
85
+ "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs",
86
86
  "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
87
87
  "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
88
88
  "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
@@ -161,7 +161,8 @@
161
161
  "test:reliability-metrics": "node --test --test-force-exit test/metrics.test.mjs",
162
162
  "test:evidence": "node --test --test-force-exit test/evidence.test.mjs",
163
163
  "test:grant-issue": "node --test --test-force-exit test/grant-issue.test.mjs",
164
- "test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs"
164
+ "test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs",
165
+ "test:secondary-link": "node --test --test-force-exit test/secondary-link.test.mjs"
165
166
  },
166
167
  "devDependencies": {
167
168
  "@eslint/js": "^10.0.1",
@@ -199,7 +200,8 @@
199
200
  "w2m-mcp": "bin/w2m-mcp.mjs",
200
201
  "w2m-rabbit": "bin/w2m-rabbit.mjs",
201
202
  "w2m-stun": "bin/w2m-stun.mjs",
202
- "w2m-grant": "./bin/w2m-grant.mjs"
203
+ "w2m-grant": "./bin/w2m-grant.mjs",
204
+ "w2m-secondary-link": "bin/w2m-secondary-link.mjs"
203
205
  },
204
206
  "disclosure": {
205
207
  "cloud": true,
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.4.4",
4
+ "version": "1.5.1",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -434,6 +434,85 @@ export async function readFleet(deps) {
434
434
  };
435
435
  }
436
436
 
437
+ /**
438
+ * v1.5.1: the relay pool the client folds, derived from configuration.
439
+ *
440
+ * `relayUrls` (array) is the multi-relay form; the single `rabbitUrl` stays valid and is returned
441
+ * as a one-element pool, so a host that never configured a pool behaves exactly as it did.
442
+ *
443
+ * @param {{relayUrls?: unknown, rabbitUrl?: string|null}|null|undefined} cfg
444
+ * @returns {string[]} Normalized base URLs, order preserved, duplicates removed.
445
+ */
446
+ export function relayUrlsFromConfig(cfg) {
447
+ const raw = Array.isArray(cfg?.relayUrls) ? cfg.relayUrls : [];
448
+ const urls = raw
449
+ .filter((entry) => typeof entry === 'string' && entry.trim() !== '')
450
+ .map((entry) => entry.trim().replace(/\/+$/, ''));
451
+ if (urls.length > 0) return [...new Set(urls)];
452
+ if (typeof cfg?.rabbitUrl === 'string' && cfg.rabbitUrl.trim() !== '') {
453
+ return [cfg.rabbitUrl.trim().replace(/\/+$/, '')];
454
+ }
455
+ return [];
456
+ }
457
+
458
+ /**
459
+ * Fold a relay pool for display: at most `limit` nodes are shown, the rest are counted.
460
+ *
461
+ * The client is expected to render `shown` and `hidden` exactly as given: a view that silently
462
+ * shows every node when there are dozens is the failure this helper exists to prevent.
463
+ *
464
+ * @param {object[]} items Probe results, best first.
465
+ * @param {number} [limit]
466
+ * @returns {{shown: object[], hidden: object[], hidden_count: number, total: number, online: number}}
467
+ */
468
+ export function foldRelayItems(items, limit = 3) {
469
+ const list = Array.isArray(items) ? items : [];
470
+ const shown = list.slice(0, Math.max(0, limit));
471
+ const hidden = list.slice(Math.max(0, limit));
472
+ return {
473
+ shown,
474
+ hidden,
475
+ hidden_count: hidden.length,
476
+ total: list.length,
477
+ online: list.filter((item) => item?.ok === true).length,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Probe each relay's `/healthz` and return the pool sorted by round-trip time (fastest first).
483
+ * A relay that fails is kept and sorted last with `ok:false` — disappearing from the list would
484
+ * hide exactly the outage the operator needs to see.
485
+ *
486
+ * @param {string[]} urls
487
+ * @param {{timeoutMs?: number, fetchImpl?: Function}} [options]
488
+ * @returns {Promise<object[]>}
489
+ */
490
+ export async function probeRelays(urls, options = {}) {
491
+ const timeoutMs = Number.isInteger(options.timeoutMs) ? options.timeoutMs : 1200;
492
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
493
+ const list = Array.isArray(urls) ? urls : [];
494
+ const results = await Promise.all(list.map(async (url) => {
495
+ const started = Date.now();
496
+ try {
497
+ const response = await fetchImpl(`${url}/healthz`, {
498
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
499
+ ? AbortSignal.timeout(timeoutMs)
500
+ : undefined,
501
+ });
502
+ const body = await response.json().catch(() => ({}));
503
+ if (!response.ok || body?.ok !== true) {
504
+ return { url, ok: false, rtt_ms: null, error: `HTTP ${response.status}` };
505
+ }
506
+ return { url, ok: true, rtt_ms: Date.now() - started, relay_id: body.relay_id ?? null };
507
+ } catch (error) {
508
+ return { url, ok: false, rtt_ms: null, error: error instanceof Error ? error.message : String(error) };
509
+ }
510
+ }));
511
+ const reachable = results.filter((item) => item.ok === true).sort((a, b) => (a.rtt_ms ?? 0) - (b.rtt_ms ?? 0));
512
+ const unreachable = results.filter((item) => item.ok !== true);
513
+ return [...reachable, ...unreachable];
514
+ }
515
+
437
516
  /**
438
517
  * Build the snapshot the browser renders.
439
518
  *
@@ -447,7 +526,7 @@ export async function readFleet(deps) {
447
526
  * @returns {object}
448
527
  */
449
528
  export function buildUiState(deps) {
450
- const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false } = deps;
529
+ const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false, relays = null } = deps;
451
530
  const selection = store.get(sessionId);
452
531
  const machines = Array.isArray(fleet?.machines) ? fleet.machines : [];
453
532
  const selected = selection === null ? null : machines.find((machine) => machine.machine_id === selection.machine_id) ?? null;
@@ -479,6 +558,20 @@ export function buildUiState(deps) {
479
558
  // but cannot reach this half, and the honest answer is to say so rather than to look empty.
480
559
  detail_unavailable: detailUnavailable === true,
481
560
  },
561
+ // v1.5.1: the relay pool, folded. `null` means the host did not probe (single-relay host or
562
+ // probe disabled); the control then renders no relay section instead of inventing one.
563
+ relays: relays === null
564
+ ? null
565
+ : {
566
+ total: relays.total,
567
+ online: relays.online,
568
+ hidden_count: relays.hidden_count,
569
+ shown: relays.shown,
570
+ hidden: relays.hidden,
571
+ // Fastest reachable relay is the one a dispatch would prefer; naming it here keeps the
572
+ // fold honest when it hides nodes that the router would still consider.
573
+ primary_url: relays.shown.find((item) => item.ok === true)?.url ?? null,
574
+ },
482
575
  };
483
576
  }
484
577
 
@@ -680,12 +773,38 @@ function mountTargetRoutes(connection, deps, status, logger) {
680
773
  return value;
681
774
  };
682
775
 
776
+ /** v1.5.1: relay probing is cached like the fleet: opening the menu must not turn into a burst. */
777
+ let relayCache = { at: 0, value: null };
778
+ const relayPoolFor = async () => {
779
+ const now = Date.now();
780
+ if (relayCache.value !== null && now - relayCache.at < FLEET_CACHE_MS) return relayCache.value;
781
+ const urls = relayUrlsFromConfig(deps.cfg());
782
+ if (urls.length === 0) {
783
+ relayCache = { at: now, value: null };
784
+ return null;
785
+ }
786
+ const probed = await probeRelays(urls);
787
+ // The fold's primary is the fastest reachable node; every other reachable node is standby, and
788
+ // an unreachable one keeps its place at the end as `offline` rather than disappearing.
789
+ let reachableSeen = 0;
790
+ const items = probed.map((item) => {
791
+ if (item.ok !== true) return { ...item, role: 'offline' };
792
+ reachableSeen += 1;
793
+ return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
794
+ });
795
+ const folded = foldRelayItems(items, 3);
796
+ relayCache = { at: now, value: folded };
797
+ return folded;
798
+ };
799
+
683
800
  const snapshot = async (sessionId, signal) => {
684
801
  const cfg = deps.cfg();
685
802
  const identity = await deps.localIdentity();
686
803
  const fleet = await fleetFor(signal);
804
+ const relays = await relayPoolFor();
687
805
  return buildUiState({
688
806
  store: deps.store,
807
+ relays,
689
808
  sessionId,
690
809
  fleet,
691
810
  localMachineName: identity.machine_name ?? cfg.machineName ?? null,
@@ -276,7 +276,7 @@ const MAX_STAGES = 16;
276
276
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
277
277
  * overrides it at runtime for tests and packagers.
278
278
  */
279
- const PLUGIN_VERSION = '1.4.4';
279
+ const PLUGIN_VERSION = '1.5.1';
280
280
 
281
281
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
282
282
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;