@twinsearth/w2m-dsh-plugin 1.6.9 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.6.9"
8
+ "version": "1.7.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.6.9",
15
+ "version": "1.7.1",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.6.9"
29
+ "version": "1.7.1"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.6.9",
4
+ "version": "1.7.1",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.7.1] — 2026-10-25
11
+
12
+ **修复桌面壳下聊天截图/复制失败(permission denied)与插件更新"no supported installer available"。**
13
+
14
+ - 截图:浏览器 `getDisplayMedia` 在 DSH 桌面(Electron)被拒绝(`permission denied`)。新增**宿主侧截图**
15
+ `POST /api/w2m/capture`(Windows PowerShell System.Drawing;macOS screencapture;返回 JPEG base64),
16
+ 按钮与 Ctrl+P 在渲染层捕获失败时自动回退到宿主截图,并在宿主侧复制到剪贴板。
17
+ - 复制:`navigator.clipboard` 被拒时新增**宿主剪贴板** `POST /api/w2m/clipboard`(Windows Set-Clipboard /
18
+ macOS osascript),单条消息复制按钮与复制文本都走这条回退链。
19
+ - 选中:transcript 的 `user-select:text` 改为 `!important`,避免宿主全局样式覆盖导致无法选中。
20
+ - 插件更新:`update-install` 的安装器解析新增桌面运行时布局
21
+ (`<app>/resources/runtime/<runtime>/dependencies/pnpm/bin/pnpm.mjs`、`~/.dsh/dsh-runtimes` 兜底),
22
+ 并用运行时自带的 `node` 执行 pnpm(桌面壳的 `process.execPath` 不是 Node)。修复了更新按钮
23
+ "install update: no supported installer available"。
24
+
25
+ - 测试:capture-route 4/4、fleet-chat-capture 3/3、client-bundle 26/26、update-resolver 4/4;
26
+ 证据 `docs/acceptance/1.7.1/DESKTOP-CAPTURE-UPDATER.json`(fleet task `01M5ANYMVMN9CNQ`)。
27
+ ## [1.7.0] — 2026-10-25
28
+
29
+ **编队聊天框:截图按钮 + Ctrl+P 快捷键 + 对话内容可选中&复制。**
30
+
31
+ - 输入区新增 **📷 截图按钮**:`getDisplayMedia` 让用户选屏幕/窗口 → 抓帧为 PNG → 自动复制到剪贴板,
32
+ 面板内显示缩略预览(可下载/再次复制);宿主不支持或权限被拒时在面板内显示原因,不抛未处理异常。
33
+ - **Ctrl+P(macOS 也支持 Cmd+P)**:聊天面板打开时触发同一截图流程,并在捕获阶段 `preventDefault`,
34
+ 不会弹出浏览器打印对话框。
35
+ - **对话内容可选中&复制**:transcript/消息文本 `user-select:text`;每条消息新增 **⧉ 复制** 按钮;
36
+ 划选文字时点击消息不会误触发"回复"。
37
+
38
+ 已知边界:编队聊天协议仍是纯文本(relay 消息不含图片字段),所以截图是"复制到剪贴板 + 预览/下载",
39
+ 不会作为图片消息广播给其他机器——图片传输需要扩展 relay 消息协议(后续版本)。
40
+
41
+ - 测试:`test/fleet-chat-capture.test.mjs` 3/3;client-bundle 26/26;
42
+ 证据 `docs/acceptance/1.7.0/CHAT-CAPTURE-CONTROLS.json`(fleet task `01M5ANYMVMLRC5H`)。
10
43
  ## [1.6.9] — 2026-10-24
11
44
 
12
45
  **修复 failover 监控从未安装的根因:`startLocalStack` 等待了永不 settle 的 `agent.start()`。**
@@ -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.9", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.7.1", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,23 @@
1
+ {
2
+ "version": "1.7.0",
3
+ "case": "CHAT-CAPTURE-CONTROLS",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T16:20:22.367Z",
9
+ "executor": "lead",
10
+ "requirement": "the fleet-chat panel offers a screenshot button and Ctrl+P (Cmd+P) capture, and the transcript is selectable and copyable",
11
+ "task_id": "01M5ANYMVMLRC5H",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "fleet_chat_capture": "3/3 pass",
16
+ "client_bundle": "26/26 pass",
17
+ "shortcut": "Ctrl+P/Cmd+P registered in capture phase and prevents the print dialog",
18
+ "selection": "user-select:text on transcript, click-to-reply suppressed while text is selected",
19
+ "screenshot": "getDisplayMedia -> PNG -> clipboard + preview/download",
20
+ "limitation": "the relay chat payload is text-only, so an image is not broadcast as a message; it is copied and previewed locally"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": "1.7.1",
3
+ "case": "DESKTOP-CAPTURE-UPDATER",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-24T17:01:53.420Z",
9
+ "executor": "lead",
10
+ "requirement": "on the DSH desktop shell, where the renderer denies getDisplayMedia (permission denied) and clipboard writes, the chat screenshot button and Ctrl+P still capture through the HOST, copy works through the host clipboard, and the plugin update button finds a supported installer",
11
+ "task_id": "01M5ANYMVMN9CNQ",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "host_capture_smoke": {
15
+ "platform": "win32",
16
+ "ok": true,
17
+ "bytes": 150903,
18
+ "copied": true,
19
+ "note": "captured the real screen through PowerShell (System.Drawing) and put it on the Windows clipboard"
20
+ },
21
+ "assertions": {
22
+ "capture_route_tests": "4/4 pass",
23
+ "fleet_chat_capture": "3/3 pass",
24
+ "client_bundle": "26/26 pass",
25
+ "updater_resolver": "4/4 pass"
26
+ },
27
+ "fixes": [
28
+ "POST /api/w2m/capture: host-side PowerShell/macOS/Linux screenshot; returns JPEG base64 and copies on the host",
29
+ "POST /api/w2m/clipboard: host-side text clipboard, used when navigator.clipboard is denied",
30
+ "client falls back from getDisplayMedia -> host capture, and copies text through host when the renderer denies it",
31
+ "user-select:text!important so the transcript stays selectable under host CSS",
32
+ "update installer discovery: desktop resources runtime + per-user dsh-runtimes; runtime node instead of the app executable"
33
+ ]
34
+ }
35
+ }
package/lib/client.js CHANGED
@@ -394,6 +394,15 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
394
394
  '.w2mChat_boardRow{font-size:11px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
395
395
  '.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
396
396
  '.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
+ // 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}',
399
+ '.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
+ '.w2mChat_copy:hover{color:var(--dsw-alias-label-primary)}',
401
+ '.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
+ '.w2mChat_toast{padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
397
406
  '.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
398
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}',
399
408
  '.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
@@ -742,6 +751,9 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
742
751
  // `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
743
752
  // process and this file never holds a credential.
744
753
  const CHAT_ROUTE = '/api/w2m/chat';
754
+ // v1.7.1: host fallbacks for the DSH desktop shell (renderer capture/clipboard are not granted there).
755
+ const CAPTURE_ROUTE = '/api/w2m/capture';
756
+ const CLIPBOARD_ROUTE = '/api/w2m/clipboard';
745
757
 
746
758
  /** One JSON call to the host chat route; a refusal is a value, not an exception. */
747
759
  async function chatCall(options) {
@@ -813,6 +825,11 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
813
825
  const [error, setError] = React.useState(null);
814
826
  const [busy, setBusy] = React.useState(false);
815
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);
831
+ const [shotBusy, setShotBusy] = React.useState(false);
832
+ const [toast, setToast] = React.useState(null);
816
833
 
817
834
  const load = React.useCallback(async (signal) => {
818
835
  if (typeof sessionId !== 'string' || sessionId === '') return;
@@ -917,6 +934,118 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
917
934
  }
918
935
  return undefined;
919
936
  }, [busy, load, sessionId, text]);
937
+ // v1.6.10: clipboard text with a fallback for hosts where navigator.clipboard is absent.
938
+ // v1.7.1: copy through the renderer when allowed, then through the host clipboard route.
939
+ // The DSH desktop shell rejects navigator.clipboard, which is why v1.7.0's copy button failed there.
940
+ const copyText = React.useCallback((value) => {
941
+ const textValue = String(value ?? '');
942
+ if (textValue === '') return undefined;
943
+ const hostFallback = async () => {
944
+ try {
945
+ const response = await fetch(CLIPBOARD_ROUTE, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ text: textValue }) });
946
+ const body = await response.json().catch(() => null);
947
+ if (response.ok === true && body?.ok === true) { setToast('已复制(宿主侧)'); return undefined; }
948
+ } catch { /* fall through to the textarea path */ }
949
+ try {
950
+ const area = document.createElement('textarea');
951
+ area.value = textValue; area.style.position = 'fixed'; area.style.opacity = '0';
952
+ document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area);
953
+ setToast('已复制');
954
+ } catch { setToast('复制失败'); }
955
+ return undefined;
956
+ };
957
+ try {
958
+ if (navigator.clipboard?.writeText) {
959
+ navigator.clipboard.writeText(textValue).then(() => setToast('已复制'), () => { hostFallback(); });
960
+ return undefined;
961
+ }
962
+ } catch { /* fall through */ }
963
+ hostFallback();
964
+ return undefined;
965
+ }, []);
966
+
967
+ // v1.6.10: capture one frame of a user-picked screen/window, preview it, and copy the PNG to the
968
+ // clipboard. getDisplayMedia needs the gesture the button/keypress provides; every failure is
969
+ // reported in the panel rather than as an unhandled rejection.
970
+ // v1.7.1: screenshot with a host fallback. The renderer path (getDisplayMedia) is tried first;
971
+ // when the host forbids it -- the DSH desktop shell does -- the panel captures on the host and
972
+ // receives JPEG base64, so the button and Ctrl+P work in both places.
973
+ const captureScreenshot = React.useCallback(async () => {
974
+ if (shotBusy) return undefined;
975
+ 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 + '),可点击下载');
980
+ };
981
+ try {
982
+ if (typeof navigator?.mediaDevices?.getDisplayMedia === 'function') {
983
+ let stream = null;
984
+ try {
985
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 5 }, audio: false });
986
+ const video = document.createElement('video');
987
+ video.srcObject = stream;
988
+ await video.play();
989
+ await new Promise((resolve) => { setTimeout(resolve, 180); });
990
+ const canvas = document.createElement('canvas');
991
+ canvas.width = video.videoWidth || 1280;
992
+ canvas.height = video.videoHeight || 720;
993
+ canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
994
+ 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, '浏览器');
1004
+ return undefined;
1005
+ }
1006
+ } catch { /* the host fallback below is the whole point */ }
1007
+ finally { try { stream?.getTracks?.().forEach((track) => track.stop()); } catch { /* gone */ } }
1008
+ }
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
+ 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, '宿主');
1026
+ } catch (error) {
1027
+ setToast('截图失败:' + (error instanceof Error ? error.message : String(error)));
1028
+ } finally {
1029
+ setShotBusy(false);
1030
+ }
1031
+ return undefined;
1032
+ }, [shotBusy]);
1033
+
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.
1036
+ React.useEffect(() => {
1037
+ if (!open) return undefined;
1038
+ const onKeyDown = (event) => {
1039
+ if (event.defaultPrevented) return;
1040
+ const modifier = event.ctrlKey === true || event.metaKey === true;
1041
+ if (modifier && event.shiftKey !== true && event.altKey !== true && String(event.key).toLowerCase() === 'p') {
1042
+ event.preventDefault();
1043
+ captureScreenshot();
1044
+ }
1045
+ };
1046
+ document.addEventListener('keydown', onKeyDown, true);
1047
+ return () => document.removeEventListener('keydown', onKeyDown, true);
1048
+ }, [open, captureScreenshot]);
920
1049
 
921
1050
  const unseenCount = messages.reduce((count, message) => (Number.isFinite(message?.at_ms) && message.at_ms > lastSeenMs ? count + 1 : count), 0);
922
1051
  const disabled = typeof sessionId !== 'string' || sessionId === '';
@@ -975,14 +1104,20 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
975
1104
  group.threadId === null ? null : h('div', { key: 'thread:' + group.threadId + ':' + groupIndex, className: 'w2mChat_threadHead' }, '#' + group.threadId),
976
1105
  ...group.messages.map((message) => {
977
1106
  var kind = messageKind(message, localMachineId);
978
- return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }) },
979
- h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
1107
+ return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => { const selection = typeof window.getSelection === 'function' ? String(window.getSelection() ?? '') : ''; if (selection !== '') return; setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }); } },
1108
+ h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', h('button', { key: 'copy', type: 'button', className: 'w2mChat_copy', title: '复制这条消息', onClick: (event) => { event.stopPropagation(); copyText(String(message?.text ?? '')); } }, '⧉'), message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
980
1109
  h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
981
1110
  }),
982
1111
  ].filter((node) => node !== null))),
983
1112
 
984
1113
 
985
1114
  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
+ toast === null ? null : h('div', { key: 'toast', className: 'w2mChat_toast' }, toast),
986
1121
  replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
987
1122
  h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
988
1123
  h('button', { key: 'cancel', type: 'button', className: 'w2mChat_replyCancel', title: '取消回复', onClick: () => setReplyTo(null) }, '×'),
@@ -1001,7 +1136,8 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1001
1136
  }
1002
1137
  },
1003
1138
  }),
1004
- h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
1139
+ h('button', { type: 'button', className: 'w2mChat_shotButton', title: '截图(Ctrl+P)', disabled: shotBusy, onClick: captureScreenshot }, shotBusy ? '截图…' : '📷'),
1140
+ h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
1005
1141
  : null);
1006
1142
  }
1007
1143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.6.9",
3
+ "version": "1.7.1",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs 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 test/agent-start-nonblocking.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 test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.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 test/npm-update.test.mjs test/agent-start-nonblocking.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 test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.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.9",
4
+ "version": "1.7.1",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,243 @@
1
+ /**
2
+ * v1.7.1 — host-side capture and clipboard fallbacks for the fleet-chat panel.
3
+ *
4
+ * ## Why this module exists
5
+ *
6
+ * v1.7.0 put a screenshot button and Ctrl+P in the chat panel, implemented in the browser with
7
+ * `getDisplayMedia` and `navigator.clipboard`. Field test on the DSH **desktop** shell: all three
8
+ * controls failed. A desktop host is not a browser tab — `getDisplayMedia` needs the embedding app to
9
+ * install a display-media request handler, and clipboard writes are subject to the same permission
10
+ * surface. When either rejects, a browser-only implementation has nowhere left to go.
11
+ *
12
+ * So the panel gains a host fallback that does not depend on renderer permissions:
13
+ *
14
+ * POST /api/w2m/capture { copy?: boolean } -> capture the primary screen on the HOST, return
15
+ * { ok, mime, data_base64, path, copied }
16
+ * POST /api/w2m/clipboard { text } -> put text on the HOST clipboard
17
+ *
18
+ * Windows uses PowerShell (System.Drawing + System.Windows.Forms, STA thread for the image
19
+ * clipboard), macOS uses `screencapture`/`pbcopy`/`osascript`, Linux uses `gnome-screenshot`/`xclip`
20
+ * when present. Every command is a fixed argv array — no shell, and no caller input is spliced into a
21
+ * script.
22
+ *
23
+ * This module is deliberately small and injectable: `run` is a parameter so the route can be tested
24
+ * without a screen, and `capturePlan`/`clipboardPlan` are exported as pure data for the same reason.
25
+ */
26
+ import { spawn } from 'node:child_process';
27
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join } from 'node:path';
30
+
31
+ export const CAPTURE_ROUTE_PATH = '/api/w2m/capture';
32
+ export const CLIPBOARD_ROUTE_PATH = '/api/w2m/clipboard';
33
+ export const CAPTURE_TIMEOUT_MS = 20_000;
34
+ const NO_STORE = { 'cache-control': 'no-store' };
35
+
36
+ /** Run a command with no shell, a bounded timeout and captured output. */
37
+ export function runCommand(argv, { timeoutMs = CAPTURE_TIMEOUT_MS, env = process.env } = {}) {
38
+ return new Promise((resolvePromise) => {
39
+ let child;
40
+ try {
41
+ child = spawn(argv[0], argv.slice(1), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
42
+ } catch (error) {
43
+ resolvePromise({ code: null, stdout: '', stderr: String(error?.message ?? error) });
44
+ return;
45
+ }
46
+ let stdout = '';
47
+ let stderr = '';
48
+ let settled = false;
49
+ const finish = (code) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ resolvePromise({ code, stdout, stderr });
53
+ };
54
+ const timer = setTimeout(() => {
55
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
56
+ finish(null);
57
+ }, timeoutMs);
58
+ timer.unref?.();
59
+ child.stdout?.on('data', (chunk) => { stdout = (stdout + chunk.toString('utf8')).slice(-4_000_000); });
60
+ child.stderr?.on('data', (chunk) => { stderr = (stderr + chunk.toString('utf8')).slice(-16_384); });
61
+ child.on('error', (error) => { stderr = String(error?.message ?? error); finish(null); });
62
+ child.on('close', (code) => { clearTimeout(timer); finish(code); });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * The PowerShell script that captures the primary screen as a JPEG and prints base64.
68
+ * `-STA` matters: `Clipboard::SetImage` throws on an MTA thread.
69
+ */
70
+ const WINDOWS_CAPTURE_SCRIPT = [
71
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
72
+ '$b=[System.Windows.Forms.SystemInformation]::VirtualScreen',
73
+ '$bmp=New-Object Drawing.Bitmap($b.Width,$b.Height)',
74
+ '$g=[Drawing.Graphics]::FromImage($bmp)',
75
+ '$g.CopyFromScreen($b.Left,$b.Top,0,0,$bmp.Size)',
76
+ '$max=1600',
77
+ 'if($bmp.Width -gt $max){$r=$max/[double]$bmp.Width;$nw=[int]($bmp.Width*$r);$nh=[int]($bmp.Height*$r);$s=New-Object Drawing.Bitmap($nw,$nh);$gs=[Drawing.Graphics]::FromImage($s);$gs.InterpolationMode=[Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic;$gs.DrawImage($bmp,0,0,$nw,$nh);$bmp=$s}',
78
+ '$ms=New-Object IO.MemoryStream',
79
+ '$enc=[Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object {$_.MimeType -eq "image/jpeg"} | Select-Object -First 1',
80
+ '$ps=New-Object Drawing.Imaging.EncoderParameters(1)',
81
+ '$ps.Param[0]=New-Object Drawing.Imaging.EncoderParameter([Drawing.Imaging.Encoder]::Quality,[long]82)',
82
+ '$bmp.Save($ms,$enc,$ps)',
83
+ 'if($env:W2M_CAPTURE_COPY -eq "1"){[System.Windows.Forms.Clipboard]::SetImage($bmp)}',
84
+ '[Convert]::ToBase64String($ms.ToArray())',
85
+ ].join('; ');
86
+
87
+ /**
88
+ * The capture command for a platform, as pure data.
89
+ *
90
+ * Windows prints base64 to stdout; macOS/Linux write a file whose path the caller returns to Node
91
+ * (`file` is null on Windows).
92
+ *
93
+ * @param {NodeJS.Platform} platform
94
+ * @param {{copyToClipboard?: boolean}} [options]
95
+ * @returns {{argv: string[], file: string|null, mime: string}}
96
+ */
97
+ export function capturePlan(platform, _options = {}) {
98
+ if (platform === 'win32') {
99
+ return {
100
+ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_CAPTURE_SCRIPT],
101
+ file: null,
102
+ mime: 'image/jpeg',
103
+ };
104
+ }
105
+ if (platform === 'darwin') {
106
+ return { argv: ['/usr/sbin/screencapture', '-x', '-t', 'jpg', 'capture'], file: 'capture', mime: 'image/jpeg' };
107
+ }
108
+ if (platform === 'linux') {
109
+ return { argv: ['gnome-screenshot', '-f', 'capture'], file: 'capture', mime: 'image/jpeg' };
110
+ }
111
+ return { argv: [], file: null, mime: 'application/octet-stream' };
112
+ }
113
+
114
+ /** Text-clipboard command for a platform, as pure data. */
115
+ export function clipboardPlan(platform, _text) {
116
+ if (platform === 'win32') return ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $env:W2M_CLIP_TEXT'];
117
+ if (platform === 'darwin') return ['pbcopy'];
118
+ return [];
119
+ }
120
+
121
+ /**
122
+ * Capture the host screen.
123
+ *
124
+ * @param {{platform?: NodeJS.Platform, run?: typeof runCommand, tempRoot?: string, copyToClipboard?: boolean}} [options]
125
+ * @returns {Promise<{ok: boolean, mime?: string, data_base64?: string, path?: string, copied: boolean, error?: string}>}
126
+ */
127
+ export async function captureHostScreen(options = {}) {
128
+ const platform = options.platform ?? process.platform;
129
+ const run = options.run ?? runCommand;
130
+ const copyToClipboard = options.copyToClipboard === true;
131
+ const plan = capturePlan(platform, { copyToClipboard });
132
+ if (plan.argv.length === 0) {
133
+ return { ok: false, copied: false, error: `host capture is not implemented for ${platform}` };
134
+ }
135
+ if (platform === 'win32') {
136
+ const result = await run(plan.argv, { timeoutMs: CAPTURE_TIMEOUT_MS, env: { ...process.env, W2M_CAPTURE_COPY: copyToClipboard ? '1' : '0' } });
137
+ const base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
138
+ if (result.code !== 0 || !/^[A-Za-z0-9+/=]{100,}$/.test(base64)) {
139
+ return { ok: false, copied: false, error: `PowerShell capture failed (exit ${result.code}): ${String(result.stderr ?? '').trim().slice(-400) || 'no output'}` };
140
+ }
141
+ return { ok: true, mime: 'image/jpeg', data_base64: base64, copied: copyToClipboard };
142
+ }
143
+ // macOS / Linux: write to a temp file, read it, and copy via the platform tool when asked.
144
+ const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-capture-'));
145
+ const file = join(dir, platform === 'darwin' ? 'capture.jpg' : 'capture.jpg');
146
+ try {
147
+ const argv = plan.argv.map((part) => (part === 'capture' ? file : part));
148
+ const result = await run(argv, { timeoutMs: CAPTURE_TIMEOUT_MS });
149
+ if (result.code !== 0) {
150
+ return { ok: false, copied: false, error: `screencapture failed (exit ${result.code}): ${String(result.stderr ?? '').trim().slice(-300)}` };
151
+ }
152
+ const data = readFileSync(file);
153
+ let copied = false;
154
+ if (copyToClipboard) {
155
+ if (platform === 'darwin') {
156
+ const script = `set the clipboard to (read (POSIX file "${file.replace(/"/g, '\\"')}") as JPEG picture)`;
157
+ copied = (await run(['osascript', '-e', script], { timeoutMs: CAPTURE_TIMEOUT_MS })).code === 0;
158
+ } else {
159
+ copied = (await run(['xclip', '-selection', 'clipboard', '-t', 'image/jpeg', '-i', file], { timeoutMs: CAPTURE_TIMEOUT_MS })).code === 0;
160
+ }
161
+ }
162
+ return { ok: true, mime: 'image/jpeg', data_base64: data.toString('base64'), path: file, copied };
163
+ } catch (error) {
164
+ return { ok: false, copied: false, error: error instanceof Error ? error.message : String(error) };
165
+ } finally {
166
+ // The base64 is already in memory; the file is only useful for the platform clipboard call.
167
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
168
+ }
169
+ }
170
+
171
+ /** Put text on the host clipboard. */
172
+ export async function copyHostText(text, options = {}) {
173
+ const platform = options.platform ?? process.platform;
174
+ const run = options.run ?? runCommand;
175
+ const argv = clipboardPlan(platform, text);
176
+ if (argv.length === 0) return { ok: false, error: `host clipboard is not implemented for ${platform}` };
177
+ if (platform === 'win32') {
178
+ const result = await run(argv, { env: { ...process.env, W2M_CLIP_TEXT: String(text ?? '') } });
179
+ return { ok: result.code === 0, error: result.code === 0 ? undefined : `clipboard command failed (exit ${result.code}): ${String(result.stderr ?? '').slice(-300)}` };
180
+ }
181
+ if (platform === 'darwin') {
182
+ // pbcopy reads stdin; keep it out of the shell by piping through a temporary spawn is overkill
183
+ // for this fallback — use osascript's clipboard setter instead, where the text is one argv item.
184
+ const script = 'set the clipboard to ' + JSON.stringify(String(text ?? ''));
185
+ const result = await run(['osascript', '-e', script]);
186
+ return { ok: result.code === 0, error: result.code === 0 ? undefined : `osascript failed: ${String(result.stderr ?? '').slice(-300)}` };
187
+ }
188
+ const result = await run(argv);
189
+ return { ok: result.code === 0, error: result.code === 0 ? undefined : `clipboard command failed (exit ${result.code}): ${String(result.stderr ?? '').slice(-300)}` };
190
+ }
191
+
192
+ /**
193
+ * Mount both routes on the host's Connection fetch registry.
194
+ *
195
+ * @param {object} ctx Cordis context
196
+ * @param {{platform?: NodeJS.Platform, run?: typeof runCommand, capture?: typeof captureHostScreen}} [deps]
197
+ * @returns {{mounted: boolean, reason: string|null}}
198
+ */
199
+ export function registerCaptureRoutes(ctx, deps = {}) {
200
+ const status = { mounted: false, reason: 'the host has no Connection service' };
201
+ if (typeof ctx?.inject !== 'function') return status;
202
+ ctx.inject(['connection'], (webCtx) => {
203
+ const registry = webCtx?.connection?.fetch;
204
+ if (registry === undefined || typeof registry.register !== 'function') {
205
+ status.reason = 'the connection service carries no fetch registry';
206
+ return;
207
+ }
208
+ const platform = deps.platform ?? process.platform;
209
+ const run = deps.run ?? runCommand;
210
+ const capture = deps.capture ?? captureHostScreen;
211
+ registry.register({
212
+ path: CAPTURE_ROUTE_PATH,
213
+ methods: ['POST'],
214
+ requestBody: 'buffered',
215
+ fetch: async (request) => {
216
+ let body = null;
217
+ try { body = await request.json(); } catch { /* defaults below */ }
218
+ const copyToClipboard = body?.copy === true;
219
+ const result = await capture({ platform, run, copyToClipboard });
220
+ return Response.json(result, { status: result.ok === true ? 200 : 502, headers: NO_STORE });
221
+ },
222
+ });
223
+ registry.register({
224
+ path: CLIPBOARD_ROUTE_PATH,
225
+ methods: ['POST'],
226
+ requestBody: 'buffered',
227
+ fetch: async (request) => {
228
+ let body = null;
229
+ try { body = await request.json(); } catch { /* refused below */ }
230
+ if (body === null || typeof body?.text !== 'string' || body.text === '') {
231
+ return Response.json({ ok: false, error: 'a JSON body with {"text": "..."} is required' }, { status: 400, headers: NO_STORE });
232
+ }
233
+ // Keep the fallback bounded: a client that asks to copy a megabyte is asking for trouble.
234
+ const text = body.text.slice(0, 1_000_000);
235
+ const result = await copyHostText(text, { platform, run });
236
+ return Response.json(result, { status: result.ok === true ? 200 : 502, headers: NO_STORE });
237
+ },
238
+ });
239
+ status.mounted = true;
240
+ status.reason = null;
241
+ });
242
+ return status;
243
+ }
@@ -191,6 +191,7 @@ import {
191
191
  import { registerChatRoutes } from './fleet-chat-route.mjs';
192
192
  import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
193
193
  import { registerPluginUpdateRoutes } from './update-route.mjs';
194
+ import { registerCaptureRoutes } from './capture-route.mjs';
194
195
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
195
196
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
196
197
  import { renderMemoryView } from './memory-view.mjs';
@@ -282,7 +283,7 @@ const MAX_STAGES = 16;
282
283
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
283
284
  * overrides it at runtime for tests and packagers.
284
285
  */
285
- const PLUGIN_VERSION = '1.6.9';
286
+ const PLUGIN_VERSION = '1.7.1';
286
287
 
287
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
288
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3517,6 +3518,11 @@ export async function apply(ctx, config = {}) {
3517
3518
  // v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
3518
3519
  // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3519
3520
  const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3521
+
3522
+ // v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
3523
+ // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
3524
+ // depend on those permissions.
3525
+ registerCaptureRoutes(ctx);
3520
3526
  if (capabilityRoutes.mounted !== true) {
3521
3527
  ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
3522
3528
  }
@@ -68,7 +68,7 @@ import {
68
68
  statSync,
69
69
  } from 'node:fs';
70
70
  import { homedir } from 'node:os';
71
- import { basename, join, resolve } from 'node:path';
71
+ import { basename, dirname, join, resolve } from 'node:path';
72
72
  import { gunzipSync } from 'node:zlib';
73
73
 
74
74
  /** How much of the installer's stderr is preserved when it fails. */
@@ -225,19 +225,73 @@ export function resolveDshPath(env = process.env) {
225
225
  * @param {Record<string,string|undefined>} [env]
226
226
  * @returns {string|null}
227
227
  */
228
+
229
+ /**
230
+ * Roots that may hold a DSH runtime with its own pnpm.
231
+ *
232
+ * v1.7.1: the desktop app does not ship the CLI layout (`<DSH_HOME>/dsh-runtimes`); it bundles the
233
+ * runtime at `<app>/resources/runtime/<runtime>/dependencies/pnpm/bin/pnpm.mjs`. The v1.6.8 update
234
+ * button reported "no supported installer available" on every desktop install because only the
235
+ * CLI layout was searched. Both layouts are roots here, and the caller iterates their children.
236
+ *
237
+ * @param {{env?: NodeJS.ProcessEnv, execPath?: string, resourcesPath?: string}} [input]
238
+ * @returns {string[]}
239
+ */
240
+ export function runtimeCandidates(input = {}) {
241
+ const env = input.env ?? process.env;
242
+ const execPath = input.execPath ?? process.execPath;
243
+ const resourcesPath = input.resourcesPath ?? process.resourcesPath;
244
+ const roots = [];
245
+ if (typeof env.W2M_RUNTIME_ROOT === 'string' && env.W2M_RUNTIME_ROOT.trim() !== '') roots.push(env.W2M_RUNTIME_ROOT.trim());
246
+ if (typeof resourcesPath === 'string' && resourcesPath !== '') roots.push(join(resourcesPath, 'runtime'));
247
+ try {
248
+ // Only the desktop app layout has `resources/` next to the executable; plain Node does not.
249
+ if (existsSync(join(dirname(execPath), 'resources'))) roots.push(join(dirname(execPath), 'resources', 'runtime'));
250
+ } catch { /* execPath is not a normal path */ }
251
+ roots.push(join(dshHome(env), 'dsh-runtimes'));
252
+ // The desktop shell may rewrite DSH_HOME; the per-user runtime directory is still the real one.
253
+ roots.push(join(homedir(), '.dsh', 'dsh-runtimes'));
254
+ return [...new Set(roots)];
255
+ }
256
+
257
+ /**
258
+ * The runtime layout puts pnpm under `<root>/<runtime>/dependencies/pnpm/bin/pnpm.mjs`; the CLI layout
259
+ * is the same but its root already ends with `dsh-runtimes`. Both shapes are scanned.
260
+ *
261
+ * @param {string[]} roots
262
+ * @returns {string[]}
263
+ */
264
+ function pnpmCandidates(roots) {
265
+ const candidates = [];
266
+ for (const root of roots) {
267
+ candidates.push(join(root, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'));
268
+ let entries = [];
269
+ try { entries = readdirSync(root); } catch { continue; }
270
+ for (const entry of entries) candidates.push(join(root, entry, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'));
271
+ }
272
+ return candidates;
273
+ }
274
+
275
+ /** Locate the runtime's own Node executable (the desktop app's executable is not Node). */
276
+ export function resolveRuntimeNode(env = process.env) {
277
+ const explicit = env.W2M_NODE && env.W2M_NODE.trim() !== '' ? env.W2M_NODE.trim() : null;
278
+ if (explicit) return existsSync(explicit) ? explicit : null;
279
+ const names = process.platform === 'win32' ? ['node.exe'] : ['node'];
280
+ for (const root of runtimeCandidates({ env })) {
281
+ for (const entry of (() => { try { return readdirSync(root); } catch { return []; } })()) {
282
+ for (const name of names) {
283
+ const candidate = join(root, entry, 'dependencies', 'node', 'bin', name);
284
+ if (existsSync(candidate)) return candidate;
285
+ }
286
+ }
287
+ }
288
+ return null;
289
+ }
228
290
  export function resolveRuntimePnpm(env = process.env) {
229
291
  const explicit = env.W2M_PNPM && env.W2M_PNPM.trim() !== '' ? env.W2M_PNPM.trim() : null;
230
292
  if (explicit) return existsSync(explicit) ? explicit : null;
231
293
 
232
- const runtimesDir = join(dshHome(env), 'dsh-runtimes');
233
- let runtimes;
234
- try {
235
- runtimes = readdirSync(runtimesDir);
236
- } catch {
237
- return null;
238
- }
239
- for (const runtime of runtimes) {
240
- const candidate = join(runtimesDir, runtime, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs');
294
+ for (const candidate of pnpmCandidates(runtimeCandidates({ env }))) {
241
295
  if (existsSync(candidate)) return candidate;
242
296
  }
243
297
  return null;
@@ -449,7 +503,8 @@ export async function installUpdate(options = {}) {
449
503
  const stagedTarball = join(stagingDir, basename(tarballPath));
450
504
  const dshPath = options.dshPath !== undefined ? options.dshPath : resolveDshPath();
451
505
  const pnpmPath = options.pnpmPath !== undefined ? options.pnpmPath : resolveRuntimePnpm();
452
- const nodePath = options.nodePath ?? process.execPath;
506
+ // v1.7.1: in the desktop shell `process.execPath` is the app, not Node; pnpm.mjs cannot run on it.
507
+ const nodePath = options.nodePath ?? resolveRuntimeNode() ?? process.execPath;
453
508
  const runner = options.runner ?? createChildProcessRunner(options.runnerOptions);
454
509
 
455
510
  // ---- 1. verify --------------------------------------------------------