@twinsearth/w2m-dsh-plugin 1.6.4 → 1.6.6

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.6.4"
8
+ "version": "1.6.6"
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.6.4",
15
+ "version": "1.6.6",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.6.4"
29
+ "version": "1.6.6"
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.6.4",
4
+ "version": "1.6.6",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.6.6] — 2026-10-24
11
+
12
+ **relay 自动 failover:主 relay 故障时自动切到备用,恢复后自动切回。**
13
+
14
+ 现场事故(2026-10-24):主 relay 主机整体不可达(8787 与 SSH 22 都超时),备用 relay 一直健康,
15
+ 但每台机器仍死连主 relay——备用链路只是常驻成员,不会接管,编队停摆。
16
+
17
+ - 新增 `src/plugin/relay-failover.mjs`:非对称滞回状态机(**连续 3 次**探测失败才切走,**连续 5 次**
18
+ 健康才切回;没有可达备选则不切)。纯函数,边界可单测。
19
+ - `startLocalStack` 外包一层 `startFailoverStack`:每 10s 探测池内各 relay `/healthz`;
20
+ 决策为切换时,停止当前 in-process stack,把 `cfg.rabbitUrl` 设为备 relay 并重启 stack——
21
+ **切换是重新入队(fleet key 重新配对),不是重连**(device token 是按 relay 签发的)。
22
+ - `w2m_status` 新增 `failover` 快照:primary/active/pool、失败计数、切换次数、最近切换原因与历史。
23
+ - 测试:relay-failover 5/5、relay 200/200;证据 `docs/acceptance/1.6.6/RELAY-FAILOVER.json`
24
+ (fleet task `01M5ANYMVM9GEKQ`)。
25
+ ## [1.6.5] — 2026-10-24
26
+
27
+ **Windows 后台自动化不再弹窗:VBScript 隐藏包装 + 计划任务工具 + 文档。**
28
+
29
+ 现场故障:给 standby 守护(每 2 分钟)和 TwinsEarth 守望(每 5 分钟)用 `schtasks /TR "node.exe ..."`
30
+ 注册任务,导致桌面每隔几分钟弹出一个控制台窗口(守望脚本还会累积进程)。
31
+
32
+ - 新增 `src/util/windows-hidden-task.mjs`:纯函数构造 VBScript 包装(`WScript.Shell.Run` 窗口样式 0、
33
+ 不等待)与 `schtasks` 参数;命令行/参数先按 Windows 规则引用,再整体做一次 VBScript 转义(避免二次转义)。
34
+ - 新增 `bin/w2m-hidden-task.mjs`:`--name/--every-minutes/--command/--arg/--workdir/--delete`,
35
+ 一条命令注册“无窗口”计划任务。
36
+ - 新增 `docs/WINDOWS-BACKGROUND-TASKS.md`:故障原因、正确做法、原则(分钟级任务只放短检查;一次性任务用完即删)。
37
+ - 测试:windows-hidden-task 5/5(含转义与参数校验);证据
38
+ `docs/acceptance/1.6.5/HIDDEN-BACKGROUND-TASKS.json`(fleet task `01M5ANYMVM30H98`)。
10
39
  ## [1.6.4] — 2026-10-24
11
40
 
12
41
  **修复:`/healthz` 漏发 `fleets_online`(1.6.3 的 `fleetSummary` 已计算,但 `stats()` 没带到公开字段)。**
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * w2m-hidden-task — schedule a command on Windows **without any console window**.
4
+ *
5
+ * Why this tool exists: `schtasks /TR "node.exe ..."` produces a visible console window every time
6
+ * the task fires. On a 2-minute keeper that is a window every two minutes; on a long-running watcher
7
+ * it also accumulates processes. This tool writes a VBScript wrapper and registers
8
+ * `wscript.exe //B wrapper.vbs`, which starts the command hidden and does not wait.
9
+ *
10
+ * Usage:
11
+ * node bin/w2m-hidden-task.mjs --name w2m-keeper --every-minutes 2 \
12
+ * --command "C:\...\node.exe" --arg "C:\...\script.mjs" \
13
+ * [--workdir "C:\..."] [--delete]
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import { writeFileSync, mkdirSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import os from 'node:os';
19
+
20
+ import { buildHiddenWrapper, hiddenTaskArgs } from '../src/util/windows-hidden-task.mjs';
21
+
22
+ /** Parse `--flag value` pairs plus repeatable `--arg`; unknown flags are refused by name. */
23
+ export function parseHiddenTaskArgs(argv) {
24
+ const out = { name: null, everyMinutes: null, command: null, args: [], workdir: null, delete: false, vbsPath: null };
25
+ for (let i = 0; i < argv.length; i += 1) {
26
+ const flag = argv[i];
27
+ const next = () => {
28
+ const value = argv[i + 1];
29
+ if (typeof value !== 'string' || value.startsWith('--')) throw new Error(`${flag} needs a value`);
30
+ i += 1;
31
+ return value;
32
+ };
33
+ switch (flag) {
34
+ case '--name': out.name = next(); break;
35
+ case '--every-minutes': out.everyMinutes = Number(next()); break;
36
+ case '--command': out.command = next(); break;
37
+ case '--arg': out.args.push(next()); break;
38
+ case '--workdir': out.workdir = next(); break;
39
+ case '--vbs': out.vbsPath = next(); break;
40
+ case '--delete': out.delete = true; break;
41
+ case '--help':
42
+ case '-h': out.help = true; break;
43
+ default: throw new Error(`unknown flag: ${flag}`);
44
+ }
45
+ }
46
+ if (out.delete === true) {
47
+ if (typeof out.name !== 'string' || out.name.trim() === '') throw new Error('--delete needs --name');
48
+ return out;
49
+ }
50
+ if (out.name === null || out.command === null || out.everyMinutes === null) {
51
+ throw new Error('--name, --command and --every-minutes are required');
52
+ }
53
+ if (out.vbsPath === null) {
54
+ const safe = String(out.name).replace(/[^A-Za-z0-9._-]/g, '_');
55
+ out.vbsPath = join(os.homedir(), '.dsh', 'xclient', `${safe}-hidden.vbs`);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async function main() {
61
+ let args;
62
+ try {
63
+ args = parseHiddenTaskArgs(process.argv.slice(2));
64
+ } catch (error) {
65
+ process.stderr.write(`w2m-hidden-task: ${error.message}\n`);
66
+ process.exit(2);
67
+ }
68
+ if (args.help === true) {
69
+ process.stdout.write('usage: w2m-hidden-task.mjs --name <task> --every-minutes <n> --command <exe> [--arg <a>]... [--workdir <dir>] [--vbs <path>]\n'
70
+ + ' w2m-hidden-task.mjs --delete --name <task>\n');
71
+ return;
72
+ }
73
+ if (args.delete === true) {
74
+ try {
75
+ execFileSync('schtasks', ['/Delete', '/TN', args.name, '/F'], { stdio: 'ignore' });
76
+ process.stdout.write(`deleted ${args.name}\n`);
77
+ } catch {
78
+ process.stdout.write(`no task named ${args.name}\n`);
79
+ }
80
+ return;
81
+ }
82
+ if (process.platform !== 'win32') throw new Error('hidden scheduled tasks are a Windows feature');
83
+ mkdirSync(dirname(args.vbsPath), { recursive: true });
84
+ writeFileSync(args.vbsPath, buildHiddenWrapper({ command: args.command, args: args.args, workingDirectory: args.workdir }), 'utf8');
85
+ const taskArgs = hiddenTaskArgs({ taskName: args.name, everyMinutes: args.everyMinutes, vbsPath: args.vbsPath });
86
+ execFileSync('schtasks', taskArgs, { stdio: 'ignore' });
87
+ process.stdout.write(`scheduled ${args.name} every ${args.everyMinutes} min (hidden wrapper ${args.vbsPath})\n`);
88
+ }
89
+
90
+ const invokedDirectly = process.argv[1] !== undefined && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
91
+ if (invokedDirectly) {
92
+ main().catch((error) => {
93
+ process.stderr.write(`w2m-hidden-task: ${error?.message ?? error}\n`);
94
+ process.exit(1);
95
+ });
96
+ }
@@ -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.6.4", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.6.6", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,53 @@
1
+ # Windows 后台任务:不弹窗的正确做法
2
+
3
+ ## 现象
4
+
5
+ 用 `schtasks /TR "node.exe script.mjs"` 创建的计划任务,每次触发都会在桌面**弹出一个控制台窗口**。
6
+ 本机曾因此出现"每隔 2–5 分钟自动弹窗",来源是守护/守望任务:
7
+
8
+ | 任务 | 频率 | 实际行为 |
9
+ |---|---|---|
10
+ | standby keeper | 每 2 分钟 | 起 `powershell.exe` → 窗口闪烁 |
11
+ | TwinsEarth watcher | 每 5 分钟 | 起 `node.exe`,且脚本设计跑 6 小时 → 窗口+进程累积 |
12
+
13
+ ## 正确做法
14
+
15
+ 用 `wscript.exe //B wrapper.vbs` 注册任务;VBScript 里:
16
+
17
+ ```vbs
18
+ Set sh = CreateObject("WScript.Shell")
19
+ sh.Run """C:\...\node.exe"" ""C:\...\script.mjs"" --flag", 0, False
20
+ ```
21
+
22
+ - `0` = 隐藏窗口(不显示控制台)
23
+ - `False` = 不等待(任务立即返回)
24
+
25
+ ## 用工具生成(v1.6.5 起)
26
+
27
+ ```powershell
28
+ node bin/w2m-hidden-task.mjs `
29
+ --name w2m-standby-keeper `
30
+ --every-minutes 2 `
31
+ --command "E:\DS\resources\runtime\primary-runtime\dependencies\node\bin\node.exe" `
32
+ --arg "C:\Users\you\.dsh\xclient\standby-keeper.mjs" `
33
+ --workdir "C:\Users\you"
34
+ ```
35
+
36
+ 输出:
37
+
38
+ ```
39
+ scheduled w2m-standby-keeper every 2 min (hidden wrapper C:\Users\you\.dsh\xclient\w2m-standby-keeper-hidden.vbs)
40
+ ```
41
+
42
+ 删除:
43
+
44
+ ```powershell
45
+ node bin/w2m-hidden-task.mjs --delete --name w2m-standby-keeper
46
+ ```
47
+
48
+ ## 原则
49
+
50
+ 1. **不要**把长驻脚本挂到分钟级计划任务上(每次触发都会起一个新实例)——分钟级任务里只放"检查并退出"的短脚本;
51
+ 2. 计划任务一律通过 VBS 隐藏包装启动,不要直接 `node.exe`;
52
+ 3. 长期守护优先用系统服务/launchd/启动项,而不是高频计划任务;
53
+ 4. 一次性任务(如远程重启)用完即删,避免残留(本机曾残留 3 个 `w2m-restart-*`)。
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.6.5",
3
+ "case": "HIDDEN-BACKGROUND-TASKS",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T07:33:03.150Z",
9
+ "executor": "lead",
10
+ "requirement": "Windows background automation never creates a console window: tasks run through a VBScript wrapper with window style 0, and the helper refuses incomplete input",
11
+ "task_id": "01M5ANYMVM30H98",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "hidden_task_suite": "5/5 pass",
16
+ "wrapper": "Run(...,0,False)",
17
+ "task_line": "wscript.exe //B"
18
+ }
19
+ },
20
+ "note": "field symptom: a 2-minute keeper and a 5-minute watcher produced recurring console windows"
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.6.6",
3
+ "case": "RELAY-FAILOVER",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T10:33:27.234Z",
9
+ "executor": "lead",
10
+ "requirement": "when the active relay is unreachable for 3 consecutive probes the plugin moves its in-process stack to the next healthy relay in the pool (re-joining through the fleet key), and returns to the primary after 5 consecutive healthy probes",
11
+ "task_id": "01M5ANYMVM9GEKQ",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "failover_suite": "5/5 pass",
16
+ "hysteresis": "3 failures to leave, 5 successes to return",
17
+ "no_alternative": "stays put",
18
+ "status": "failover snapshot exposed"
19
+ }
20
+ },
21
+ "note": "suites ran as a real fleet task; live drill follows the release"
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
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 test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.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 test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.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 test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.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 test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.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",
@@ -204,7 +204,8 @@
204
204
  "w2m-grant": "./bin/w2m-grant.mjs",
205
205
  "w2m-secondary-link": "bin/w2m-secondary-link.mjs",
206
206
  "w2m-bootstrap": "bin/w2m-bootstrap.mjs",
207
- "w2m-bootstrap-sign": "bin/w2m-bootstrap-sign.mjs"
207
+ "w2m-bootstrap-sign": "bin/w2m-bootstrap-sign.mjs",
208
+ "w2m-hidden-task": "bin/w2m-hidden-task.mjs"
208
209
  },
209
210
  "disclosure": {
210
211
  "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.6.4",
4
+ "version": "1.6.6",
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": [
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Relay failover supervisor (v1.6.6).
3
+ *
4
+ * ## The failure this exists for
5
+ *
6
+ * On 2026-10-24 the primary relay's host went down entirely (HTTP 8787 and SSH 22 both timed out).
7
+ * Every machine kept dialling the dead primary: the fleet was unreachable even though the backup
8
+ * relay was healthy the whole time. The backup carried a "standby member" per machine, but nothing
9
+ * ever *moved* the working connection to it. This module is the decision half of the fix; the wiring
10
+ * in `tools.mjs` restarts the in-process stack against the chosen relay, and re-pairing happens
11
+ * through the fleet key (per-relay tokens differ, so a switch is a re-join, not a reconnect).
12
+ *
13
+ * ## Hysteresis, deliberately asymmetric
14
+ *
15
+ * Switching away needs **3 consecutive** failed probes (a single dropped health request during a
16
+ * deploy must not move a fleet), while returning to the primary needs **5 consecutive** successes.
17
+ * Coming back is the riskier direction — a flapping primary that answers twice must not drag every
18
+ * machine back and forth — so it requires more evidence than leaving does.
19
+ *
20
+ * Pure state machine: no timers, no network. The caller probes and feeds results in, which is what
21
+ * makes the thresholds testable without waiting ten seconds per transition.
22
+ */
23
+
24
+ /** Defaults are exported so the tests and the status report cannot invent different numbers. */
25
+ export const FAILOVER_DEFAULTS = Object.freeze({
26
+ probeIntervalMs: 10_000,
27
+ probeTimeoutMs: 2_000,
28
+ failuresToSwitch: 3,
29
+ successesToReturn: 5,
30
+ });
31
+
32
+ /**
33
+ * The ordered relay pool for this machine.
34
+ *
35
+ * `relayUrls` is the operator's pool (v1.5.1+); `rabbitUrl` is the active one. Deduplicated and
36
+ * normalized so a trailing slash cannot make one relay look like two.
37
+ *
38
+ * @param {{rabbitUrl?: string|null, relayUrls?: string[]|null}} cfg
39
+ * @returns {string[]}
40
+ */
41
+ export function relayPool(cfg) {
42
+ const cleaned = (Array.isArray(cfg?.relayUrls) ? cfg.relayUrls : [])
43
+ .filter((url) => typeof url === 'string' && url.trim() !== '')
44
+ .map((url) => url.trim().replace(/\/+$/, ''));
45
+ const active = typeof cfg?.rabbitUrl === 'string' && cfg.rabbitUrl.trim() !== ''
46
+ ? cfg.rabbitUrl.trim().replace(/\/+$/, '')
47
+ : null;
48
+ const pool = [];
49
+ for (const url of [...(active === null ? [] : [active]), ...cleaned]) {
50
+ if (!pool.includes(url)) pool.push(url);
51
+ }
52
+ return pool;
53
+ }
54
+
55
+ /**
56
+ * Create the failover state machine.
57
+ *
58
+ * @param {object} options
59
+ * @param {string[]} options.pool Ordered relay URLs; `pool[0]` is the primary.
60
+ * @param {string} [options.primary] Overrides which URL counts as "the one to return to".
61
+ * @param {number} [options.failuresToSwitch]
62
+ * @param {number} [options.successesToReturn]
63
+ * @param {() => number} [options.now]
64
+ */
65
+ export function createRelayFailover(options) {
66
+ const pool = Array.isArray(options?.pool) ? [...options.pool] : [];
67
+ if (pool.length === 0) throw new TypeError('pool must contain at least one relay URL');
68
+ const primary = typeof options?.primary === 'string' && pool.includes(options.primary) ? options.primary : pool[0];
69
+ const failuresToSwitch = Number.isInteger(options?.failuresToSwitch) && options.failuresToSwitch > 0
70
+ ? options.failuresToSwitch : FAILOVER_DEFAULTS.failuresToSwitch;
71
+ const successesToReturn = Number.isInteger(options?.successesToReturn) && options.successesToReturn > 0
72
+ ? options.successesToReturn : FAILOVER_DEFAULTS.successesToReturn;
73
+ const now = typeof options?.now === 'function' ? options.now : Date.now;
74
+
75
+ let active = typeof options?.active === 'string' && pool.includes(options.active) ? options.active : primary;
76
+ let failures = 0;
77
+ let primarySuccesses = 0;
78
+ let switches = 0;
79
+ let lastSwitchAtMs = null;
80
+ let lastReason = null;
81
+ const history = [];
82
+
83
+ /**
84
+ * Feed one round of health results and get the decision.
85
+ *
86
+ * @param {Map<string, boolean>|Record<string, boolean>} health url -> reachable
87
+ * @returns {{action: 'stay'|'switch', active: string, target?: string, reason?: string}}
88
+ */
89
+ function assess(health) {
90
+ const reachable = (url) => (health instanceof Map ? health.get(url) : health?.[url]) === true;
91
+ const activeOk = reachable(active);
92
+
93
+ if (activeOk) {
94
+ failures = 0;
95
+ if (active === primary) {
96
+ primarySuccesses = 0;
97
+ }
98
+ // A healthy non-primary route still watches the primary for the way home.
99
+ } else {
100
+ failures += 1;
101
+ }
102
+
103
+ if (active !== primary && reachable(primary)) {
104
+ primarySuccesses += 1;
105
+ } else if (active !== primary) {
106
+ primarySuccesses = 0;
107
+ }
108
+
109
+ if (activeOk !== true && failures >= failuresToSwitch) {
110
+ const alternative = pool.find((url) => url !== active && reachable(url));
111
+ if (alternative !== undefined) {
112
+ const reason = `active relay ${active} failed ${failures} consecutive probes`;
113
+ history.push({ at_ms: now(), from: active, to: alternative, reason });
114
+ active = alternative;
115
+ failures = 0;
116
+ primarySuccesses = 0;
117
+ switches += 1;
118
+ lastSwitchAtMs = now();
119
+ lastReason = reason;
120
+ return { action: 'switch', active, target: alternative, reason };
121
+ }
122
+ // No reachable alternative: stay, but keep reporting the failure streak.
123
+ }
124
+
125
+ if (active !== primary && primarySuccesses >= successesToReturn) {
126
+ const reason = `primary relay ${primary} healthy ${primarySuccesses} consecutive probes`;
127
+ history.push({ at_ms: now(), from: active, to: primary, reason });
128
+ active = primary;
129
+ failures = 0;
130
+ primarySuccesses = 0;
131
+ switches += 1;
132
+ lastSwitchAtMs = now();
133
+ lastReason = reason;
134
+ return { action: 'switch', active, target: primary, reason };
135
+ }
136
+
137
+ return { action: 'stay', active };
138
+ }
139
+
140
+ /** What `w2m_status` reports: which relay is in use and why it last moved. */
141
+ function snapshot() {
142
+ return {
143
+ primary,
144
+ active,
145
+ pool: [...pool],
146
+ failures,
147
+ failures_to_switch: failuresToSwitch,
148
+ primary_successes: primarySuccesses,
149
+ successes_to_return: successesToReturn,
150
+ switches,
151
+ last_switch_at_ms: lastSwitchAtMs,
152
+ last_reason: lastReason,
153
+ history: history.slice(-10),
154
+ };
155
+ }
156
+
157
+ return { assess, snapshot, thresholds: { failuresToSwitch, successesToReturn } };
158
+ }
@@ -1,3 +1,4 @@
1
+ import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
1
2
  import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
2
3
  /**
3
4
  * DSH: Windows2MacO - the DSH Cordis plugin.
@@ -89,6 +90,9 @@ import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './boo
89
90
  * implementation, and vice versa.
90
91
  */
91
92
 
93
+ /** v1.6.6: last failover snapshot, reported by w2m_status and refreshed by the supervisor interval. */
94
+ let relayFailoverStatus = null;
95
+
92
96
  import { execFile } from 'node:child_process';
93
97
 
94
98
  import { resolveChannels } from './update-channels.mjs';
@@ -277,7 +281,7 @@ const MAX_STAGES = 16;
277
281
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
278
282
  * overrides it at runtime for tests and packagers.
279
283
  */
280
- const PLUGIN_VERSION = '1.6.4';
284
+ const PLUGIN_VERSION = '1.6.6';
281
285
 
282
286
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
283
287
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -2193,6 +2197,8 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
2193
2197
  local: status?.local ?? null,
2194
2198
  rabbit_url: status?.rabbit_url ?? cfg.rabbitUrl,
2195
2199
  bootstrap: cfg.bootstrap ?? null,
2200
+ // v1.6.6: which relay is in use and why it last moved.
2201
+ failover: relayFailoverStatus,
2196
2202
  // How many machines the relay holds a live announcement for. `w2m_devices` says which machines
2197
2203
  // paired; this says which of them can be punched at, and the two differ constantly.
2198
2204
  peers_announced: peersAnnounced,
@@ -4956,7 +4962,7 @@ export async function apply(ctx, config = {}) {
4956
4962
  // v1.5.3: a failed auto-start must not surface as an unhandled rejection. The bundled patch now
4957
4963
  // starts the agent by default, and a host whose plugin wiring forbids that (a restricted test
4958
4964
  // harness, a missing relay module) would otherwise reject where nothing can catch it.
4959
- ctx.effect?.(() => startLocalStack(ctx, cfg).catch((error) => {
4965
+ ctx.effect?.(() => startFailoverStack(ctx, cfg).catch((error) => {
4960
4966
  ctx.logger?.warn?.('w2m: auto-start did not complete: ' + (error instanceof Error ? error.message : String(error)));
4961
4967
  }));
4962
4968
  } else {
@@ -7375,6 +7381,70 @@ function sleepWithSignal(ms, signal) {
7375
7381
  */
7376
7382
  const resolvedFleetKey = { value: null, source: null, at: 0 };
7377
7383
 
7384
+ /** Probe one relay /healthz; any failure is `false` (a timeout must not throw into the interval). */
7385
+ async function probeRelayHealth(url, timeoutMs) {
7386
+ try {
7387
+ const response = await fetch(url + '/healthz', {
7388
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
7389
+ });
7390
+ if (response.ok !== true) return false;
7391
+ const body = await response.json().catch(() => null);
7392
+ return body?.ok === true;
7393
+ } catch {
7394
+ return false;
7395
+ }
7396
+ }
7397
+
7398
+ /**
7399
+ * v1.6.6: keep the in-process stack on a reachable relay.
7400
+ *
7401
+ * The primary relay can disappear entirely (2026-10-24: its host was down, so 8787 and SSH were both
7402
+ * unreachable). The stack is restartable: stop it, point cfg.rabbitUrl at the next healthy relay in
7403
+ * the pool, start it again. A switch is a re-join, not just a reconnect -- device tokens are relay-
7404
+ * specific, so startLocalStack pairs again through the account fleet key.
7405
+ *
7406
+ * The decision itself (3 failed probes to leave, 5 healthy ones to come home) lives in
7407
+ * relay-failover.mjs and is unit-tested there; this function only supplies health and performs the
7408
+ * restart.
7409
+ */
7410
+ async function startFailoverStack(ctx, cfg) {
7411
+ let stop = await startLocalStack(ctx, cfg);
7412
+ const pool = relayPool(cfg);
7413
+ if (pool.length < 2) {
7414
+ relayFailoverStatus = null;
7415
+ return stop;
7416
+ }
7417
+ const failover = createRelayFailover({ pool });
7418
+ relayFailoverStatus = failover.snapshot();
7419
+ let switching = false;
7420
+ const timer = setInterval(async () => {
7421
+ if (switching) return;
7422
+ const health = {};
7423
+ await Promise.all(pool.map(async (url) => { health[url] = await probeRelayHealth(url, FAILOVER_DEFAULTS.probeTimeoutMs); }));
7424
+ const decision = failover.assess(health);
7425
+ relayFailoverStatus = failover.snapshot();
7426
+ if (decision.action !== 'switch') return;
7427
+ switching = true;
7428
+ try {
7429
+ ctx.logger?.warn?.('w2m: relay failover ' + decision.active + ' (' + decision.reason + ')');
7430
+ await stop();
7431
+ cfg.rabbitUrl = decision.target;
7432
+ cfg.relaySource = 'failover';
7433
+ stop = await startLocalStack(ctx, cfg);
7434
+ ctx.logger?.info?.('w2m: relay failover stack restarted on ' + decision.target);
7435
+ } catch (error) {
7436
+ ctx.logger?.warn?.('w2m: relay failover restart failed: ' + (error instanceof Error ? error.message : String(error)));
7437
+ } finally {
7438
+ switching = false;
7439
+ }
7440
+ }, FAILOVER_DEFAULTS.probeIntervalMs);
7441
+ timer.unref?.();
7442
+ return async () => {
7443
+ clearInterval(timer);
7444
+ relayFailoverStatus = null;
7445
+ await stop();
7446
+ };
7447
+ }
7378
7448
  async function startLocalStack(ctx, cfg) {
7379
7449
 
7380
7450
  /**
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Windows background tasks without popups (v1.6.5).
3
+ *
4
+ * ## The problem this fixes
5
+ *
6
+ * The standby keeper and the fleet watcher were scheduled with
7
+ * `schtasks /TR "node.exe script.mjs"`. Windows Task Scheduler runs such a command in the
8
+ * interactive session, which creates a **console window every time the task fires** — a 2-minute
9
+ * keeper means a window every two minutes, and a 5-minute watcher that itself runs for hours
10
+ * accumulates both windows and processes. That is a real usability defect: the operator sees the
11
+ * machine "randomly opening windows" and cannot tell what is happening.
12
+ *
13
+ * ## The fix
14
+ *
15
+ * `wscript.exe //B wrapper.vbs` runs invisibly. The wrapper (VBScript) starts the real command with
16
+ * `WScript.Shell.Run(command, 0, False)`: window style 0 = hidden, third argument = do not wait. No
17
+ * console window, no lingering `cmd`, and the task still runs on schedule.
18
+ *
19
+ * Everything here is pure string construction (and one CLI that calls `schtasks`), so the escaping
20
+ * rules — the part that silently breaks hidden wrappers — are unit-testable without touching a real
21
+ * scheduler.
22
+ */
23
+
24
+ /**
25
+ * Command line for Task Scheduler that hides the window.
26
+ *
27
+ * @param {string} vbsPath Absolute path of the wrapper .vbs.
28
+ * @returns {string} e.g. `wscript.exe //B "C:\...\wrapper.vbs"`
29
+ */
30
+ export function hiddenTaskCommandLine(vbsPath) {
31
+ if (typeof vbsPath !== 'string' || vbsPath.trim() === '') throw new TypeError('vbsPath is required');
32
+ return `wscript.exe //B "${vbsPath}"`;
33
+ }
34
+
35
+ /** Escape one literal for the inside of a VBScript double-quoted string. */
36
+ export function vbsString(value) {
37
+ return '"' + String(value).replace(/"/g, '""') + '"';
38
+ }
39
+
40
+ /**
41
+ * Build the wrapper script that runs `command` hidden.
42
+ *
43
+ * The command itself is passed through verbatim, and `arguments` is appended in the same
44
+ * double-quote-escaped form Windows uses on the command line (`""` inside a VBS string becomes one
45
+ * literal quote when the shell rebuilds the command). Every argument is quoted individually so a
46
+ * path with spaces cannot split into two arguments.
47
+ *
48
+ * @param {{command: string, args?: string[], workingDirectory?: string|null}} input
49
+ * @returns {string} VBScript source.
50
+ */
51
+ export function buildHiddenWrapper({ command, args = [], workingDirectory = null }) {
52
+ if (typeof command !== 'string' || command.trim() === '') throw new TypeError('command is required');
53
+ // Build the real command line first (Windows quoting), THEN escape the whole line once for
54
+ // VBScript. Escaping each argument separately and escaping again for the script string would
55
+ // double the quotes twice — the subtle over-escaping that makes a wrapper run the wrong token.
56
+ const quoteWin = (value) => (/[\s"]/.test(String(value)) ? '"' + String(value).replace(/"/g, '\\"') + '"' : String(value));
57
+ const parts = [quoteWin(command), ...(Array.isArray(args) ? args : []).map(quoteWin)];
58
+ const lines = [
59
+ "' Generated by w2m-hidden-task (v1.6.5): run a command with NO console window.",
60
+ 'Set sh = CreateObject("WScript.Shell")',
61
+ ];
62
+ if (typeof workingDirectory === 'string' && workingDirectory !== '') {
63
+ lines.push(`sh.CurrentDirectory = ${vbsString(workingDirectory)}`);
64
+ }
65
+ lines.push(`sh.Run ${vbsString(parts.join(' '))}, 0, False`);
66
+ return lines.join('\r\n') + '\r\n';
67
+ }
68
+
69
+ /**
70
+ * The `schtasks /Create` argument list for a hidden wrapper, without executing anything.
71
+ *
72
+ * @param {{taskName: string, everyMinutes: number, vbsPath: string}} input
73
+ * @returns {string[]}
74
+ */
75
+ export function hiddenTaskArgs({ taskName, everyMinutes, vbsPath }) {
76
+ if (typeof taskName !== 'string' || taskName.trim() === '') throw new TypeError('taskName is required');
77
+ const minutes = Number(everyMinutes);
78
+ if (!Number.isInteger(minutes) || minutes < 1) throw new TypeError('everyMinutes must be a positive integer');
79
+ return [
80
+ '/Create', '/F',
81
+ '/SC', 'MINUTE',
82
+ '/MO', String(minutes),
83
+ '/TN', taskName,
84
+ '/TR', hiddenTaskCommandLine(vbsPath),
85
+ ];
86
+ }