@twinsearth/w2m-dsh-plugin 1.7.1 → 1.7.2

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.7.1"
8
+ "version": "1.7.2"
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.7.1",
15
+ "version": "1.7.2",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.7.1"
29
+ "version": "1.7.2"
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.7.1",
4
+ "version": "1.7.2",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.7.2] — 2026-10-25
11
+
12
+ **按实测反馈调整编队聊天截图/复制:只复制不预览、快捷键 Ctrl+Shift+P、强制可选中。**
13
+
14
+ - 截图按钮不再在面板里生成缩略图/下载行:抓到画面后**只写入剪贴板**并给一行提示,聊天区域保持干净。
15
+ - 快捷键从 Ctrl+P 改为 **Ctrl+Shift+P(macOS 也支持 Cmd+Shift+P)**;普通 Ctrl+P 不再被劫持,
16
+ 浏览器/应用的打印快捷键恢复原样。
17
+ - 选中&复制:`user-select:text!important` 应用到整个面板的所有子节点(不再只覆盖几个类),
18
+ 并在消息列表上阻止宿主吞掉 mousedown/copy 事件,保证拖选与 Ctrl+C 生效;单条消息的 ⧉ 复制按钮
19
+ 仍走「浏览器剪贴板 → 宿主剪贴板」回退链。
20
+ - 全服统计只认**配置的主 relay**:主 relay 不可达时不再把备用 relay 的数字(1/1 编队、2/5 在线)当"全服"显示,
21
+ 而是显示「主 relay 不可达 · 全服统计不可用」。修复了在线编队/在线机器数量"错误"的观感来源。
22
+ - 证据:`docs/acceptance/1.7.2/CHAT-CLIPBOARD-SHORTCUT.json`(fleet task `01M5ANYMVMR3IOB`;
23
+ 宿主截图实测 159,561 字节并复制成功)。
10
24
  ## [1.7.1] — 2026-10-25
11
25
 
12
26
  **修复桌面壳下聊天截图/复制失败(permission denied)与插件更新"no supported installer available"。**
@@ -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.7.1", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.7.2", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,36 @@
1
+ {
2
+ "version": "1.7.2",
3
+ "case": "CHAT-CLIPBOARD-SHORTCUT",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T18:47:27.925Z",
9
+ "executor": "lead",
10
+ "requirement": "user feedback on v1.7.1: the screenshot must copy to the clipboard only (no thumbnail crowding the chat), the shortcut must be Ctrl+Shift+P (plain Ctrl+P printed the page), and the transcript must actually be selectable and copyable inside the DSH host",
11
+ "task_id": "01M5ANYMVMR3IOB",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "host_capture_smoke": {
15
+ "platform": "win32",
16
+ "ok": true,
17
+ "bytes": 159561,
18
+ "copied": true,
19
+ "note": "host-side capture copied the real screen to the Windows clipboard"
20
+ },
21
+ "assertions": {
22
+ "fleet_chat_capture": "3/3 pass",
23
+ "client_bundle": "26/26 pass",
24
+ "ui_contract": [
25
+ "screenshot renders no preview row; feedback is a toast only",
26
+ "shortcut is Ctrl+Shift+P / Cmd+Shift+P and plain Ctrl+P keeps the print dialog",
27
+ "user-select:text!important is applied to the whole panel (not just one node)",
28
+ "the list stops the host from swallowing mousedown/copy so selection survives"
29
+ ],
30
+ "count_source": "whole-server totals come from the configured primary relay only; when it is unreachable the panel says unavailable instead of showing the backup relay numbers"
31
+ }
32
+ },
33
+ "fixes": [
34
+ "fleet-target: server stats chosen by configured rabbitUrl, not by first reachable relay; buildUiState exposes server_url/server_reachable; client renders unavailable"
35
+ ]
36
+ }
package/lib/client.js CHANGED
@@ -115,6 +115,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
115
115
  relayUnfold: '收起折叠的 relay',
116
116
  relayTemporary: '临时 Relay 节点(回退配置)',
117
117
  globalSummary: '全服:{fleetsOnline}/{fleets} 个编队 · {online}/{total} 台在线',
118
+ globalUnreachable: '主 relay 不可达({url})· 全服统计不可用',
118
119
  updateButton: '检查插件更新',
119
120
  updateChecking: '正在检查 npm…',
120
121
  updating: '正在更新…',
@@ -153,6 +154,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
153
154
  relayUnfold: 'collapse the folded relays',
154
155
  relayTemporary: 'temporary relay nodes (fallback config)',
155
156
  globalSummary: 'fleets {fleetsOnline}/{fleets} · machines {online}/{total} online',
157
+ globalUnreachable: 'primary relay unreachable ({url}) · server totals unavailable',
156
158
  updateButton: 'check for plugin update',
157
159
  updateChecking: 'checking npm…',
158
160
  updating: 'updating…',
@@ -395,13 +397,11 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
395
397
  '.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
396
398
  '.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
397
399
  // v1.6.10: the transcript is selectable text; screenshots get a preview and a copy affordance.
398
- '.w2mChat_panel,.w2mChat_list,.w2mChat_text,.w2mChat_meta{user-select:text!important;-webkit-user-select:text!important}',
400
+ '.w2mChat_panel,.w2mChat_panel *{user-select:text!important;-webkit-user-select:text!important;cursor:text}',
401
+ '.w2mChat_copy{cursor:pointer!important}',
399
402
  '.w2mChat_copy{margin-left:6px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:11px;line-height:14px;padding:0 2px;vertical-align:middle}',
400
403
  '.w2mChat_copy:hover{color:var(--dsw-alias-label-primary)}',
401
404
  '.w2mChat_shotButton{flex:none;height:28px;padding:0 8px;border:.5px solid var(--dsw-alias-border-default);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}',
402
- '.w2mChat_shotPreview{display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:.5px solid var(--dsw-alias-border-default);font-size:11px;color:var(--dsw-alias-label-secondary)}',
403
- '.w2mChat_shotPreview img{max-height:56px;max-width:120px;border-radius:4px;border:.5px solid var(--dsw-alias-border-default)}',
404
- '.w2mChat_shot{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
405
405
  '.w2mChat_toast{padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
406
406
  '.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
407
407
  '.w2mChat_msg.is-session .w2mChat_text{color:var(--dsw-danger)}', '.w2mChat_msg.is-chat .w2mChat_text{color:var(--dsw-success)}', '.w2mChat_msg.is-remote .w2mChat_text{color:var(--dsw-text)}', '.w2mChat_avatar{display:inline-flex;width:16px;height:16px;margin-right:5px;vertical-align:-3px}',
@@ -825,11 +825,11 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
825
825
  const [error, setError] = React.useState(null);
826
826
  const [busy, setBusy] = React.useState(false);
827
827
  const [localMachineId, setLocalMachineId] = React.useState(null);
828
- // v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
829
- // clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
830
- const [shot, setShot] = React.useState(null);
828
+ // v1.7.2: screenshot state is clipboard-only (no preview); `toast` is the only visible feedback.
831
829
  const [shotBusy, setShotBusy] = React.useState(false);
832
830
  const [toast, setToast] = React.useState(null);
831
+ // v1.6.10: screenshot support (button + Ctrl+P). The blob is previewed and copied to the
832
+ // clipboard; the relay's chat payload stays text, so an image cannot be broadcast as a message.
833
833
 
834
834
  const load = React.useCallback(async (signal) => {
835
835
  if (typeof sessionId !== 'string' || sessionId === '') return;
@@ -970,13 +970,20 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
970
970
  // v1.7.1: screenshot with a host fallback. The renderer path (getDisplayMedia) is tried first;
971
971
  // when the host forbids it -- the DSH desktop shell does -- the panel captures on the host and
972
972
  // receives JPEG base64, so the button and Ctrl+P work in both places.
973
+ // v1.7.2: capture and COPY only -- no thumbnail in the panel (the chat got too cramped), and no
974
+ // print-dialog shortcut stealing. The renderer path is tried first; the DSH desktop shell denies
975
+ // getDisplayMedia, so the host route captures and copies on the host side instead.
973
976
  const captureScreenshot = React.useCallback(async () => {
974
977
  if (shotBusy) return undefined;
975
978
  setShotBusy(true); setToast(null);
976
- const showBlob = (blob, copied, source) => {
977
- const url = URL.createObjectURL(blob);
978
- setShot((previous) => { if (previous?.url) URL.revokeObjectURL(previous.url); return { url, blob, at: Date.now() }; });
979
- setToast(copied ? '截图已复制到剪贴板(' + source + ')' : '截图已生成(' + source + '),可点击下载');
979
+ const copyImage = async (blob) => {
980
+ try {
981
+ if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
982
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
983
+ return true;
984
+ }
985
+ } catch { /* host fallback below */ }
986
+ return false;
980
987
  };
981
988
  try {
982
989
  if (typeof navigator?.mediaDevices?.getDisplayMedia === 'function') {
@@ -992,15 +999,8 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
992
999
  canvas.height = video.videoHeight || 720;
993
1000
  canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
994
1001
  const blob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); });
995
- if (blob !== null) {
996
- let copied = false;
997
- try {
998
- if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
999
- await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
1000
- copied = true;
1001
- }
1002
- } catch { /* host copies below */ }
1003
- showBlob(blob, copied, '浏览器');
1002
+ if (blob !== null && await copyImage(blob) === true) {
1003
+ setToast('截图已复制到剪贴板');
1004
1004
  return undefined;
1005
1005
  }
1006
1006
  } catch { /* the host fallback below is the whole point */ }
@@ -1008,21 +1008,9 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1008
1008
  }
1009
1009
  const response = await fetch(CAPTURE_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ copy: true }) });
1010
1010
  const body = await response.json().catch(() => null);
1011
- if (response.ok !== true || body?.ok !== true || typeof body.data_base64 !== 'string') {
1012
- throw new Error((body?.error ?? ('HTTP ' + response.status)) + '(宿主截图兜底也不可用)');
1013
- }
1014
- const binary = atob(body.data_base64);
1015
- const bytes = new Uint8Array(binary.length);
1016
- for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
1017
- const blob = new Blob([bytes], { type: body.mime ?? 'image/jpeg' });
1018
- let copied = body.copied === true;
1019
- try {
1020
- if (!copied && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
1021
- await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
1022
- copied = true;
1023
- }
1024
- } catch { /* host already tried */ }
1025
- showBlob(blob, copied, '宿主');
1011
+ if (response.ok !== true || body?.ok !== true) throw new Error(body?.error ?? ('HTTP ' + response.status));
1012
+ if (body.copied === true) setToast('截图已复制到剪贴板(宿主)');
1013
+ else setToast('截图已生成,但剪贴板不可用');
1026
1014
  } catch (error) {
1027
1015
  setToast('截图失败:' + (error instanceof Error ? error.message : String(error)));
1028
1016
  } finally {
@@ -1031,14 +1019,14 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1031
1019
  return undefined;
1032
1020
  }, [shotBusy]);
1033
1021
 
1034
- // v1.6.10: Ctrl+P (and Cmd+P on macOS) opens the capture while the panel is open. Registered in
1035
- // the capture phase on purpose: the browser print shortcut must not win here.
1022
+ // v1.7.2: Ctrl+Shift+P (Cmd+Shift+P) opens the capture while the panel is open. Plain Ctrl+P is left
1023
+ // alone: hijacking the browser/app print shortcut surprised testers and printed the page instead.
1036
1024
  React.useEffect(() => {
1037
1025
  if (!open) return undefined;
1038
1026
  const onKeyDown = (event) => {
1039
1027
  if (event.defaultPrevented) return;
1040
1028
  const modifier = event.ctrlKey === true || event.metaKey === true;
1041
- if (modifier && event.shiftKey !== true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
1029
+ if (modifier && event.shiftKey === true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
1042
1030
  event.preventDefault();
1043
1031
  captureScreenshot();
1044
1032
  }
@@ -1096,7 +1084,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1096
1084
  ...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
1097
1085
  `${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
1098
1086
  ]),
1099
- h('div', { className: 'w2mChat_list' },
1087
+ h('div', { className: 'w2mChat_list', onMouseDownCapture: (event) => { if (event.button === 0) event.stopPropagation(); }, onCopy: (event) => event.stopPropagation() },
1100
1088
  error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
1101
1089
  messages.length === 0
1102
1090
  ? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
@@ -1112,11 +1100,6 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1112
1100
 
1113
1101
 
1114
1102
  h('div', { className: 'w2mChat_compose' },
1115
- shot === null ? null : h('div', { key: 'shot', className: 'w2mChat_shotPreview' }, [
1116
- h('img', { key: 'img', src: shot.url, alt: '截图预览' }),
1117
- h('a', { key: 'dl', href: shot.url, download: 'w2m-screenshot-' + shot.at + '.png', className: 'w2mChat_shot' }, '下载'),
1118
- h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', onClick: () => { try { navigator.clipboard?.write?.([new ClipboardItem({ 'image/png': shot.blob })]).then(() => setToast('已复制'), () => setToast('复制失败')); } catch { setToast('复制失败'); } } }, '复制'),
1119
- ]),
1120
1103
  toast === null ? null : h('div', { key: 'toast', className: 'w2mChat_toast' }, toast),
1121
1104
  replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
1122
1105
  h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
@@ -1136,7 +1119,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1136
1119
  }
1137
1120
  },
1138
1121
  }),
1139
- h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图(Ctrl+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
1122
+ h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图并复制(Ctrl+Shift+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
1140
1123
  h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
1141
1124
  : null);
1142
1125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
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": {
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.7.1",
4
+ "version": "1.7.2",
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": [
@@ -585,6 +585,9 @@ export function buildUiState(deps) {
585
585
  temporary: relays.temporary === true,
586
586
  // v1.6.2: whole-relay overview (fleets + online machines) for the panel header line.
587
587
  server: relays.server ?? null,
588
+ // v1.7.2: only the configured primary's totals; null means "unavailable", never the backup's.
589
+ server_url: relays.server_url ?? null,
590
+ server_reachable: relays.server_reachable !== false,
588
591
  },
589
592
  };
590
593
  }
@@ -807,10 +810,25 @@ function mountTargetRoutes(connection, deps, status, logger) {
807
810
  return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
808
811
  });
809
812
  const folded = foldRelayItems(items, 3);
810
- // v1.6.2: the primary relay's own view of the whole server, if it reported one.
811
- const primaryStats = items.find((item) => item.ok === true)?.stats ?? null;
812
- // v1.5.2: a fallback list is marked so the panel can render it in red, not as a settled config.
813
- const withMark = { ...folded, temporary: deps.cfg()?.relayTemporary === true, server: primaryStats };
813
+ // v1.7.2: whole-server totals come from the CONFIGURED primary relay only. v1.6.2 used "the first
814
+ // relay that answered", so when the primary was unreachable the panel showed the BACKUP relay's
815
+ // numbers (1/1 fleets, 2/5 machines) under the "whole server" label -- visibly wrong. An
816
+ // unreachable primary reports `server: null` + `server_reachable: false`, and the panel says the
817
+ // totals are unavailable instead of presenting another relay's fleet as the server's.
818
+ const normalize = (value) => (typeof value === 'string' ? value.trim().replace(/\/+$/, '') : null);
819
+ const configuredPrimary = normalize(deps.cfg()?.rabbitUrl) ?? normalize(urls[0]);
820
+ const primaryItem = configuredPrimary === null
821
+ ? items.find((item) => item.ok === true) ?? null
822
+ : items.find((item) => normalize(item.url) === configuredPrimary) ?? null;
823
+ const serverReachable = primaryItem?.ok === true;
824
+ const primaryStats = serverReachable ? primaryItem.stats : null;
825
+ const withMark = {
826
+ ...folded,
827
+ temporary: deps.cfg()?.relayTemporary === true,
828
+ server: primaryStats,
829
+ server_url: configuredPrimary ?? primaryItem?.url ?? null,
830
+ server_reachable: serverReachable,
831
+ };
814
832
  relayCache = { at: now, value: withMark };
815
833
  return withMark;
816
834
  };
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
283
283
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
284
284
  * overrides it at runtime for tests and packagers.
285
285
  */
286
- const PLUGIN_VERSION = '1.7.1';
286
+ const PLUGIN_VERSION = '1.7.2';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;