@twinsearth/w2m-dsh-plugin 1.6.6 → 1.6.8

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.6"
8
+ "version": "1.6.8"
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.6",
15
+ "version": "1.6.8",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.6.6"
29
+ "version": "1.6.8"
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.6",
4
+ "version": "1.6.8",
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.8] — 2026-10-24
11
+
12
+ **插件面板新增「检查插件更新」按钮:npm 对比 → 自动更新 → 提示重启 DSH。**
13
+
14
+ - 客户端:目标机器菜单 relay 区新增按钮。点击后:① GET `/api/w2m/plugin-update` 联网查 npm latest
15
+ 并与已装版本比较;② 有新版时 POST `{action:"apply"}` 执行安装;③ 安装完成显示"请重启 DSH 以载入最新插件"。
16
+ - 宿主:新增路由 `/api/w2m/plugin-update`。检查逻辑在 `src/plugin/npm-update.mjs`(纯函数,可单测:只认
17
+ `latest > installed`,网络失败是错误值而不是"已最新")。
18
+ - 安装复用**既有已验证更新器**(`auto-update.mjs` → `update-install.mjs`):下载 tarball、用 release
19
+ SHA256SUMS 校验 SHA-256、备份旧包、替换新版——按钮点击不会引入第二套弱校验安装路径。
20
+ - 测试:npm-update 4/4、client-bundle 26/26;证据 `docs/acceptance/1.6.8/PLUGIN-UPDATE-BUTTON.json`
21
+ (fleet task `01M5ANYMVMI9EHA`)。
22
+ - 边界:DSH 插件管理页的「卸载」按钮属于宿主应用(打包在 app.asar 内),插件包无法直接注入其旁边;
23
+ 本按钮位于 w2m 插件自己的面板中。若要放进宿主插件页,需要 DSH 侧提供插件动作扩展点(已列为待评估)。
24
+ ## [1.6.7] — 2026-10-24
25
+
26
+ **修复 v1.6.6 failover 的阻塞 bug:远端 relay 模式下清理函数抛错,切换后无法重启 stack。**
27
+
28
+ 现场演练(停主 relay 180s)暴露:机器掉线后没有切到备用,恢复主 relay 后才重新上线。根因:
29
+
30
+ - `startLocalStack` 的清理函数无条件执行 `await relay.close()`;而 `relay` 只在"本进程自建本地 relay"
31
+ 时才非空,远端 relay 模式下是 `null` → 抛 TypeError;
32
+ - failover 的切换流程 `await stop()` 抛错后被 catch 吞掉,**后续的 restart 从未执行**(stack 停了没起来)。
33
+
34
+ 修复:
35
+ - 清理函数 null-safe/idempotent:仅当 `relay` 是带 `close()` 的对象才调用;`agent.stop()` 失败也继续清理。
36
+ - 切换流程:`stop()` 失败不再跳过 restart;并写诊断日志 `xclient/relay-failover.log`(每轮探测/切换/重启错误)。
37
+ - 测试:relay-failover 5/5、relay 200/200;证据 `docs/acceptance/1.6.7/FAILOVER-CLEANUP-FIX.json`
38
+ (fleet task `01M5ANYMVMFSSP3`)。
10
39
  ## [1.6.6] — 2026-10-24
11
40
 
12
41
  **relay 自动 failover:主 relay 故障时自动切到备用,恢复后自动切回。**
@@ -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.6", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.6.8", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.6.7",
3
+ "case": "FAILOVER-CLEANUP-FIX",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T13:31:02.803Z",
9
+ "executor": "lead",
10
+ "requirement": "a remote-relay in-process stack can be stopped and restarted for failover: the cleanup must not assume a local relay object, a failed stop must not skip the restart, and the switch must leave a diagnostics log",
11
+ "task_id": "01M5ANYMVMFSSP3",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "failover_suite": "5/5",
16
+ "relay_suite": "200/200",
17
+ "cleanup": "null-safe and idempotent",
18
+ "diagnostics": "xclient/relay-failover.log"
19
+ }
20
+ },
21
+ "note": "v1.6.6 field drill stopped the stack and never restarted it: relay.close() threw on remote-relay stacks"
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.6.8",
3
+ "case": "PLUGIN-UPDATE-BUTTON",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T14:39:54.191Z",
9
+ "executor": "lead",
10
+ "requirement": "the plugin panel offers one update button: it checks npm for a newer version, installs it through the verified updater (SHA-256 checked, old package backed up), and tells the user to restart DSH",
11
+ "task_id": "01M5ANYMVMI9EHA",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "npm_update_suite": "4/4 pass",
16
+ "check": "only latest > installed",
17
+ "apply": "delegated to auto-update.mjs verified installer",
18
+ "restart_required": "true after install"
19
+ }
20
+ },
21
+ "note": "button lives in the w2m plugin panel; the DSH plugin-manager page is host UI packaged in app.asar and not editable by a plugin package"
22
+ }
package/lib/client.js CHANGED
@@ -78,6 +78,8 @@ window.__ModuleLoader__.load({
78
78
  const ROUTE = '/api/w2m/target';
79
79
  // v1.4.2: the UI-activated Node-code-execution switch (host: src/plugin/exec-capability-route.mjs).
80
80
  const CAPABILITY_ROUTE = '/api/w2m/capability';
81
+ // v1.6.8: npm update check + verified install (host: src/plugin/update-route.mjs).
82
+ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
81
83
 
82
84
  /**
83
85
  * The locale namespace and its two dictionaries.
@@ -113,6 +115,12 @@ window.__ModuleLoader__.load({
113
115
  relayUnfold: '收起折叠的 relay',
114
116
  relayTemporary: '临时 Relay 节点(回退配置)',
115
117
  globalSummary: '全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
118
+ updateButton: '检查插件更新',
119
+ updateChecking: '正在检查 npm…',
120
+ updating: '正在更新…',
121
+ updateCurrent: '已是最新({version})',
122
+ updateInstalled: '已更新到 {version}',
123
+ restartHint: '请重启 DSH 以载入最新插件',
116
124
  capLabel: 'Node 执行:{state}',
117
125
  capDenied: '拒绝',
118
126
  capTemporary: '临时 24 小时',
@@ -145,6 +153,12 @@ window.__ModuleLoader__.load({
145
153
  relayUnfold: 'collapse the folded relays',
146
154
  relayTemporary: 'temporary relay nodes (fallback config)',
147
155
  globalSummary: 'fleets {fleetsOnline}/{fleets} · machines {online}/{total} online',
156
+ updateButton: 'check for plugin update',
157
+ updateChecking: 'checking npm…',
158
+ updating: 'updating…',
159
+ updateCurrent: 'up to date ({version})',
160
+ updateInstalled: 'updated to {version}',
161
+ restartHint: 'restart DSH to load the new plugin',
148
162
  capLabel: 'Node exec: {state}',
149
163
  capDenied: 'denied',
150
164
  capTemporary: 'temporary 24 h',
@@ -439,7 +453,48 @@ window.__ModuleLoader__.load({
439
453
  * target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
440
454
  * `t` arrives through the entry's inject face (see {@link apply}).
441
455
  */
442
- function TargetSelect({ sessionId, locked: _locked, t }) {
456
+ /** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
457
+ function PluginUpdate({ t }) {
458
+ const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
459
+ const call = async (method, body) => {
460
+ const response = await fetch(PLUGIN_UPDATE_ROUTE, {
461
+ method, credentials: 'same-origin',
462
+ headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
463
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
464
+ });
465
+ let json = null;
466
+ try { json = await response.json(); } catch { /* keep null */ }
467
+ return { status: response.status, json };
468
+ };
469
+ const onClick = async () => {
470
+ setState({ status: 'checking', info: null, error: null });
471
+ try {
472
+ const checked = await call('GET');
473
+ const info = checked.json ?? {};
474
+ if (info.ok !== true) { setState({ status: 'error', info, error: info.error ?? 'update check failed' }); return; }
475
+ if (info.update_available !== true) { setState({ status: 'current', info, error: null }); return; }
476
+ setState({ status: 'applying', info, error: null });
477
+ const applied = await call('POST', { action: 'apply' });
478
+ const result = applied.json ?? {};
479
+ if (result.ok !== true) { setState({ status: 'error', info: result, error: result.error ?? result.reason ?? 'update failed' }); return; }
480
+ setState({ status: 'installed', info: result, error: null });
481
+ } catch (error) {
482
+ setState({ status: 'error', info: null, error: error instanceof Error ? error.message : String(error) });
483
+ }
484
+ };
485
+ const label = state.status === 'checking' ? t('updateChecking')
486
+ : state.status === 'applying' ? t('updating')
487
+ : state.status === 'current' ? t('updateCurrent', { version: state.info?.current ?? '?' })
488
+ : state.status === 'installed' ? t('updateInstalled', { version: state.info?.to ?? state.info?.latest ?? '?' })
489
+ : t('updateButton');
490
+ const disabled = state.status === 'checking' || state.status === 'applying';
491
+ return h('div', { className: cls('relayHint'), 'data-w2m-update': state.status }, [
492
+ h('button', { key: 'b', type: 'button', className: cls('relayToggle'), disabled, onClick: (event) => { event.stopPropagation(); onClick(); } }, label),
493
+ state.status === 'installed' ? h('div', { key: 'r', className: cls('relayHint') }, t('restartHint')) : null,
494
+ state.status === 'error' ? h('div', { key: 'e', className: cls('relayHint') }, String(state.error ?? 'error')) : null,
495
+ ]);
496
+ }
497
+ function TargetSelect({ sessionId, locked: _locked, t }) {
443
498
  const [open, setOpen] = React.useState(false);
444
499
  // v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
445
500
  // the menu shows a summary and this toggle reveals the folded ones.
@@ -639,6 +694,7 @@ window.__ModuleLoader__.load({
639
694
  online: relayFold.server.devices_online ?? '—',
640
695
  total: relayFold.server.devices_total ?? '—',
641
696
  })),
697
+ h(PluginUpdate, { key: 'update', t }),
642
698
  relayFold.hidden_count > 0 && relayOpen === false
643
699
  ? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
644
700
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.6.6",
3
+ "version": "1.6.8",
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 test/windows-hidden-task.test.mjs test/relay-failover.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 test/npm-update.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 test/windows-hidden-task.test.mjs test/relay-failover.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 test/npm-update.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",
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.6",
4
+ "version": "1.6.8",
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,79 @@
1
+ /**
2
+ * v1.6.8 — npm update check for the plugin panel's "更新" button.
3
+ *
4
+ * The button's first half is this module: ask the npm registry for `latest`, compare it against the
5
+ * version installed in the profile, and report the result. Nothing here writes or installs — the
6
+ * install half reuses the existing verified updater (`auto-update.mjs` → `update-install.mjs`),
7
+ * which already downloads a tarball, verifies its SHA-256 against the release's SHA256SUMS, swaps the
8
+ * package in the profile, and takes a backup. Keeping the check pure is what makes "NPM 对比" testable
9
+ * without a network or a profile.
10
+ */
11
+ import { compareVersions } from './update-source.mjs';
12
+
13
+ /** The package this plugin is. */
14
+ export const PLUGIN_PACKAGE_NAME = '@twinsearth/w2m-dsh-plugin';
15
+ /** npm mirrors are fine here: the answer is only "which version is latest", and the tarball is verified separately. */
16
+ export const DEFAULT_NPM_LATEST_URL = `https://registry.npmmirror.com/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}/latest`;
17
+ export const NPM_CHECK_TIMEOUT_MS = 15_000;
18
+
19
+ /**
20
+ * Normalise the npm `latest` document.
21
+ *
22
+ * @param {object} doc
23
+ * @returns {{ok: true, name: string, version: string, tarball: string|null}|{ok: false, error: string}}
24
+ */
25
+ export function parseNpmLatest(doc) {
26
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
27
+ return { ok: false, error: 'the npm latest document is not a JSON object' };
28
+ }
29
+ const version = typeof doc.version === 'string' ? doc.version.trim() : '';
30
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
31
+ return { ok: false, error: `the npm latest document carries no usable version (got ${JSON.stringify(doc.version ?? null)})` };
32
+ }
33
+ const tarball = typeof doc.dist?.tarball === 'string' && doc.dist.tarball !== '' ? doc.dist.tarball : null;
34
+ const name = typeof doc.name === 'string' && doc.name !== '' ? doc.name : PLUGIN_PACKAGE_NAME;
35
+ return { ok: true, name, version, tarball };
36
+ }
37
+
38
+ /**
39
+ * Compare the installed version with npm latest.
40
+ *
41
+ * Update availability is `latest > installed`; equal or older is not an update (a profile can hold a
42
+ * beta/newer local build, and downgrading it from a button would be a surprise, not a service).
43
+ *
44
+ * @param {{latest: {ok: boolean, version?: string, error?: string, tarball?: string|null}, currentVersion: string|null}} input
45
+ * @returns {{ok: boolean, current: string|null, latest: string|null, update_available: boolean, tarball: string|null, error?: string}}
46
+ */
47
+ export function decideNpmUpdate({ latest, currentVersion }) {
48
+ const current = typeof currentVersion === 'string' && currentVersion !== '' ? currentVersion : null;
49
+ if (latest?.ok !== true) {
50
+ return { ok: false, current, latest: null, update_available: false, tarball: null, error: latest?.error ?? 'npm latest could not be read' };
51
+ }
52
+ const available = current !== null && compareVersions(latest.version, current) > 0;
53
+ return { ok: true, current, latest: latest.version, update_available: available, tarball: latest.tarball ?? null };
54
+ }
55
+
56
+ /**
57
+ * Ask npm for `latest` and decide. Never throws: a network failure is a value with `error`.
58
+ *
59
+ * @param {{currentVersion: string|null, fetchImpl?: Function, url?: string, timeoutMs?: number}} input
60
+ */
61
+ export async function checkNpmUpdate(input) {
62
+ const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
63
+ const url = input?.url ?? DEFAULT_NPM_LATEST_URL;
64
+ const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : NPM_CHECK_TIMEOUT_MS;
65
+ let doc;
66
+ try {
67
+ const response = await fetchImpl(url, {
68
+ headers: { accept: 'application/json' },
69
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
70
+ });
71
+ if (response?.ok !== true) {
72
+ return decideNpmUpdate({ latest: { ok: false, error: `npm registry answered HTTP ${response?.status ?? '?'}` }, currentVersion: input?.currentVersion ?? null });
73
+ }
74
+ doc = await response.json();
75
+ } catch (error) {
76
+ return decideNpmUpdate({ latest: { ok: false, error: error instanceof Error ? error.message : String(error) }, currentVersion: input?.currentVersion ?? null });
77
+ }
78
+ return decideNpmUpdate({ latest: parseNpmLatest(doc), currentVersion: input?.currentVersion ?? null });
79
+ }
@@ -190,6 +190,7 @@ import {
190
190
  // `w2m_chat` tool shipped first; this is the browser-facing half that was still missing.
191
191
  import { registerChatRoutes } from './fleet-chat-route.mjs';
192
192
  import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
193
+ import { registerPluginUpdateRoutes } from './update-route.mjs';
193
194
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
194
195
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
195
196
  import { renderMemoryView } from './memory-view.mjs';
@@ -281,7 +282,7 @@ const MAX_STAGES = 16;
281
282
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
282
283
  * overrides it at runtime for tests and packagers.
283
284
  */
284
- const PLUGIN_VERSION = '1.6.6';
285
+ const PLUGIN_VERSION = '1.6.8';
285
286
 
286
287
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
287
288
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3513,6 +3514,9 @@ export async function apply(ctx, config = {}) {
3513
3514
  const capabilityRoutes = registerExecCapabilityRoutes(ctx, {
3514
3515
  capabilityFile: () => defaultExecCapabilityFile(),
3515
3516
  });
3517
+ // v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
3518
+ // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3519
+ const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3516
3520
  if (capabilityRoutes.mounted !== true) {
3517
3521
  ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
3518
3522
  }
@@ -7407,6 +7411,19 @@ async function probeRelayHealth(url, timeoutMs) {
7407
7411
  * relay-failover.mjs and is unit-tested there; this function only supplies health and performs the
7408
7412
  * restart.
7409
7413
  */
7414
+ /** Append one line to the failover diagnostics log; failures are swallowed (diagnostics must not break the switch). */
7415
+ async function appendFailoverLog(cfg, line) {
7416
+ try {
7417
+ const { appendFileSync, mkdirSync, statSync, writeFileSync } = await import('node:fs');
7418
+ const { join, dirname } = await import('node:path');
7419
+ const { homedir } = await import('node:os');
7420
+ const dir = cfg.stateDir ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'xclient');
7421
+ const file = join(dir, 'relay-failover.log');
7422
+ mkdirSync(dirname(file), { recursive: true });
7423
+ try { if (statSync(file).size > 256 * 1024) writeFileSync(file, ''); } catch { /* absent is fine */ }
7424
+ appendFileSync(file, new Date().toISOString() + ' ' + line + '\n');
7425
+ } catch { /* diagnostics only */ }
7426
+ }
7410
7427
  async function startFailoverStack(ctx, cfg) {
7411
7428
  let stop = await startLocalStack(ctx, cfg);
7412
7429
  const pool = relayPool(cfg);
@@ -7422,6 +7439,7 @@ async function startFailoverStack(ctx, cfg) {
7422
7439
  const health = {};
7423
7440
  await Promise.all(pool.map(async (url) => { health[url] = await probeRelayHealth(url, FAILOVER_DEFAULTS.probeTimeoutMs); }));
7424
7441
  const decision = failover.assess(health);
7442
+ await appendFailoverLog(cfg, 'probe ' + JSON.stringify(health) + ' -> ' + decision.action + (decision.target ? ' ' + decision.target : ''));
7425
7443
  relayFailoverStatus = failover.snapshot();
7426
7444
  if (decision.action !== 'switch') return;
7427
7445
  switching = true;
@@ -8077,7 +8095,14 @@ async function startLocalStack(ctx, cfg) {
8077
8095
  ensureFleetJoin = null;
8078
8096
  inProcessAgentStatus = null;
8079
8097
  await agent.stop();
8080
- await relay.close();
8098
+ // v1.6.7: `relay` is only a real relay object when THIS stack started a local relay; a remote-
8099
+ // relay stack has null here. The unconditional close() threw, which is what made the first
8100
+ // failover attempt stop the stack and never restart it.
8101
+ if (relay !== null && typeof relay.close === 'function') {
8102
+ try { await relay.close(); } catch (error) {
8103
+ ctx.logger?.warn?.('w2m: local relay close failed: ' + (error instanceof Error ? error.message : String(error)));
8104
+ }
8105
+ }
8081
8106
  };
8082
8107
  }
8083
8108
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `/api/w2m/plugin-update` — the host half of the plugin panel's "更新" button (v1.6.8).
3
+ *
4
+ * Wire:
5
+ * GET /api/w2m/plugin-update[?check=1] -> { ok, current, latest, update_available, error? }
6
+ * POST /api/w2m/plugin-update { action: 'apply' }
7
+ * -> { ok, outcome, from, to, installed, restart_required, error? }
8
+ *
9
+ * The GET half asks npm for `latest` (src/plugin/npm-update.mjs). The POST half delegates the write
10
+ * to the already-verified updater: `auto-update.mjs` downloads the release tarball, verifies its
11
+ * SHA-256 against SHA256SUMS, installs it over the profile package with a backup, and records the
12
+ * outcome. This module never writes the profile itself — that division is what keeps a button click
13
+ * from inventing a second install path with weaker checks.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ import { checkNpmUpdate } from './npm-update.mjs';
19
+
20
+ export const PLUGIN_UPDATE_ROUTE_PATH = '/api/w2m/plugin-update';
21
+ const NO_STORE = { 'cache-control': 'no-store' };
22
+
23
+ /** Read the version installed in the profile's node_modules; null when it cannot be read. */
24
+ export function readInstalledVersion(profileDir) {
25
+ try {
26
+ const text = readFileSync(join(profileDir, 'node_modules', '@twinsearth', 'w2m-dsh-plugin', 'package.json'), 'utf8');
27
+ const parsed = JSON.parse(text);
28
+ return typeof parsed.version === 'string' ? parsed.version : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Mount the route on the host's Connection fetch registry.
36
+ *
37
+ * @param {object} ctx Cordis context
38
+ * @param {{profileDir: () => string|null, createUpdater?: (options: object) => object}} deps
39
+ * @returns {{mounted: boolean, reason: string|null}}
40
+ */
41
+ export function registerPluginUpdateRoutes(ctx, deps) {
42
+ const status = { mounted: false, reason: 'the host has no Connection service' };
43
+ if (typeof ctx?.inject !== 'function') return status;
44
+ ctx.inject(['connection'], (webCtx) => {
45
+ const registry = webCtx?.connection?.fetch;
46
+ if (registry === undefined || typeof registry.register !== 'function') {
47
+ status.reason = 'the connection service carries no fetch registry';
48
+ return;
49
+ }
50
+ registry.register({
51
+ path: PLUGIN_UPDATE_ROUTE_PATH,
52
+ methods: ['GET', 'POST'],
53
+ requestBody: 'buffered',
54
+ fetch: async (request) => {
55
+ if (request.method !== 'GET' && request.method !== 'POST') {
56
+ return Response.json({ ok: false, error: request.method + ' is not a method this route answers', reason_code: 'METHOD_NOT_ALLOWED' }, { status: 405, headers: NO_STORE });
57
+ }
58
+ const profileDir = typeof deps?.profileDir === 'function' ? deps.profileDir() : null;
59
+ if (typeof profileDir !== 'string' || profileDir === '') {
60
+ return Response.json({ ok: false, error: 'no DSH profile directory was found, so there is nothing to update', reason_code: 'NO_PROFILE' }, { status: 400, headers: NO_STORE });
61
+ }
62
+ const current = readInstalledVersion(profileDir);
63
+ if (request.method === 'GET') {
64
+ const checked = await checkNpmUpdate({ currentVersion: current });
65
+ return Response.json({ ...checked, profile_dir: profileDir }, { headers: NO_STORE });
66
+ }
67
+ let body = null;
68
+ try { body = await request.json(); } catch { /* refused below */ }
69
+ if (body === null || typeof body !== 'object' || body.action !== 'apply') {
70
+ return Response.json({ ok: false, error: 'a JSON body with {"action":"apply"} is required', reason_code: 'UPDATE_BODY_INVALID' }, { status: 400, headers: NO_STORE });
71
+ }
72
+ // Refuse before installing when npm says there is nothing newer.
73
+ const checked = await checkNpmUpdate({ currentVersion: current });
74
+ if (checked.ok !== true) {
75
+ return Response.json({ ok: false, outcome: 'check-failed', error: checked.error, current, restart_required: false }, { status: 502, headers: NO_STORE });
76
+ }
77
+ if (checked.update_available !== true) {
78
+ return Response.json({ ok: true, outcome: 'up-to-date', current, latest: checked.latest, installed: false, restart_required: false }, { headers: NO_STORE });
79
+ }
80
+ let updater;
81
+ try {
82
+ updater = deps?.createUpdater !== undefined ? deps.createUpdater({ profileDir, currentVersion: current }) : await defaultUpdater({ profileDir, currentVersion: current });
83
+ } catch (error) {
84
+ return Response.json({ ok: false, outcome: 'error', error: error instanceof Error ? error.message : String(error), current, restart_required: false }, { status: 500, headers: NO_STORE });
85
+ }
86
+ const result = await updater.check('manual');
87
+ const installedTo = typeof result?.to === 'string' && result.to !== '' ? result.to : null;
88
+ const installed = result?.outcome === 'installed' || installedTo !== null && installedTo !== current;
89
+ return Response.json({
90
+ ok: result?.ok === true && installed === true,
91
+ outcome: result?.outcome ?? 'unknown',
92
+ from: result?.from ?? current,
93
+ to: installedTo,
94
+ installed,
95
+ reason: result?.reason ?? null,
96
+ error: result?.error ?? null,
97
+ restart_required: installed === true,
98
+ }, { headers: NO_STORE });
99
+ },
100
+ });
101
+ status.mounted = true;
102
+ status.reason = null;
103
+ });
104
+ return status;
105
+ }
106
+
107
+ /** Build the verified updater for a manual install (lazy import keeps this route cheap to load). */
108
+ async function defaultUpdater({ profileDir, currentVersion }) {
109
+ const { createAutoUpdater } = await import('./auto-update.mjs');
110
+ return createAutoUpdater({
111
+ profileDir,
112
+ currentVersion,
113
+ enabled: true,
114
+ // A button click is an explicit request: no daily minimum interval may swallow it.
115
+ intervalDays: 0,
116
+ log: () => {},
117
+ });
118
+ }