@twinsearth/w2m-dsh-plugin 2.0.10 → 2.1.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": "2.0.10"
8
+ "version": "2.1.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": "2.0.10",
15
+ "version": "2.1.2",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "2.0.10"
29
+ "version": "2.1.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": "2.0.10",
4
+ "version": "2.1.2",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ All notable changes to this project are documented here.
7
7
  - `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
8
8
  are refused by name (the fleet does not invent commands).
9
9
 
10
+ ## [2.1.2] - 2026-10-31
11
+
12
+ - Remote-view preview: settings toggle, iMac sidebar entry, local 1fps JPEG viewer, target capture module, P2P view frame protocol, host /remote/start|frame|stop routes, encrypted-relay media broker (safety core).
13
+ - Known gaps: P1.5 relay media is not wired end-to-end; peerKey/consent UI and real remote-desktop verification are pending. This release is a test candidate, not feature-complete.
14
+
15
+ ## [2.1.0] - 2026-10-31
16
+
17
+ - Remote-view planning: REMOTE-DESKTOP-2.1.0.md and S2 capture probe (bin/remote-s2-capture.mjs).
18
+ - Packaging: GitHub Packages artifact published as 2.1.0 for version alignment.
19
+ - Status: the remote-desktop UI (settings toggle, iMac sidebar tab, viewer) is **not implemented yet**; 2.1.0 must not be described as feature-complete.
20
+
10
21
  ## [2.0.10] - 2026-10-31
11
22
 
12
23
  - remote-view S2: ship scripts/remote-s2-capture.mjs so macOS capture/TCC probes run as a normal script (no node -e, no reparse refusal); measures 1/2 fps capture timing, JPEG size, ffmpeg presence, writes w2m-s2-result.json and supports exit-code summary.
@@ -44,6 +44,25 @@ export function summaryBits(result) {
44
44
  return bits;
45
45
  }
46
46
 
47
+ function captureWindows() {
48
+ const script = [
49
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
50
+ '$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
51
+ '$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height',
52
+ '$g=[System.Drawing.Graphics]::FromImage($bmp)',
53
+ '$g.CopyFromScreen($b.Left,$b.Top,0,0,$bmp.Size)',
54
+ '$ms=New-Object System.IO.MemoryStream',
55
+ '$bmp.Save($ms,[System.Drawing.Imaging.ImageFormat]::Jpeg)',
56
+ '[Convert]::ToBase64String($ms.ToArray())',
57
+ ].join(';');
58
+ const started = Date.now();
59
+ const run = spawnSync('powershell.exe', ['-NoProfile','-NonInteractive','-STA','-ExecutionPolicy','Bypass','-Command', script], { timeout: 20000, encoding: 'utf8' });
60
+ const ms = Date.now() - started;
61
+ const base64 = String(run.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
62
+ const bytes = /^[A-Za-z0-9+/=]{100,}$/.test(base64) ? Buffer.from(base64, 'base64').length : 0;
63
+ return { ok: run.status === 0 && bytes > 0, status: run.status, ms, bytes, width: null, height: null, error: run.status === 0 ? null : String(run.stderr ?? '').slice(0, 300) };
64
+ }
65
+
47
66
  function capture(file, dimensions = true) {
48
67
  const started = Date.now();
49
68
  const run = spawnSync('screencapture', ['-x', '-t', 'jpg', file], { timeout: 15000, encoding: 'utf8' });
@@ -81,6 +100,16 @@ function main() {
81
100
  tcc_guess: 'unknown',
82
101
  code: null,
83
102
  };
103
+ if (process.platform === 'win32') {
104
+ result.captures.push(captureWindows());
105
+ spawnSync('ping', ['-n', opts.fps === 2 ? '1' : '2', '127.0.0.1'], { timeout: 5000 });
106
+ result.captures.push(captureWindows());
107
+ result.tcc_guess = 'not-applicable-windows';
108
+ result.ok = result.captures.every((entry) => entry.ok === true);
109
+ result.code = result.ok ? 'S2_CAPTURE_OK' : 'S2_CAPTURE_FAILED';
110
+ finish(result, opts, result.ok ? 0 : 3);
111
+ return;
112
+ }
84
113
  if (process.platform !== 'darwin') {
85
114
  result.code = 'S2_UNSUPPORTED_PLATFORM';
86
115
  finish(result, opts, 2);
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.0.10", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/lib/client.js CHANGED
@@ -2115,6 +2115,153 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2115
2115
  ]);
2116
2116
  }
2117
2117
 
2118
+ /* ------------------------------------------------------------------ */
2119
+ /* v2.1.0 remote-view skeleton (view-only; media not wired yet) */
2120
+ /* ------------------------------------------------------------------ */
2121
+
2122
+ const REMOTE_VIEW_KEY = 'w2m.remoteView.enabled';
2123
+ const REMOTE_CONFIG_URL = '/api/w2m/remote/config';
2124
+ const REMOTE_FRAME_URL = '/api/w2m/remote/frame';
2125
+ const readLocalFlag = () => {
2126
+ try { return window.localStorage.getItem(REMOTE_VIEW_KEY) === '1'; } catch { return false; }
2127
+ };
2128
+ const cacheFlag = (value) => { try { window.localStorage.setItem(REMOTE_VIEW_KEY, value ? '1' : '0'); } catch { /* denied */ } };
2129
+ const announceFlag = () => { try { window.dispatchEvent(new Event('w2m-remote-view-changed')); } catch { /* headless */ } };
2130
+
2131
+ function useRemoteViewFlag() {
2132
+ const [enabled, setEnabledState] = React.useState(readLocalFlag);
2133
+ const [ready, setReady] = React.useState(false);
2134
+ React.useEffect(() => {
2135
+ let alive = true;
2136
+ fetch(REMOTE_CONFIG_URL, { credentials: 'same-origin', headers: { accept: 'application/json' } })
2137
+ .then((response) => (response.ok ? response.json() : null))
2138
+ .then((body) => {
2139
+ if (!alive) return;
2140
+ const value = body?.enabled === true;
2141
+ cacheFlag(value);
2142
+ setEnabledState(value);
2143
+ setReady(true);
2144
+ })
2145
+ .catch(() => { if (alive) setReady(true); });
2146
+ const sync = () => setEnabledState(readLocalFlag());
2147
+ window.addEventListener('w2m-remote-view-changed', sync);
2148
+ window.addEventListener('storage', sync);
2149
+ return () => {
2150
+ alive = false;
2151
+ window.removeEventListener('w2m-remote-view-changed', sync);
2152
+ window.removeEventListener('storage', sync);
2153
+ };
2154
+ }, []);
2155
+ const setEnabled = (value) => {
2156
+ const next = value === true;
2157
+ cacheFlag(next);
2158
+ setEnabledState(next);
2159
+ announceFlag();
2160
+ fetch(REMOTE_CONFIG_URL, {
2161
+ method: 'POST', credentials: 'same-origin',
2162
+ headers: { 'content-type': 'application/json' },
2163
+ body: JSON.stringify({ enabled: next }),
2164
+ }).catch(() => { /* local flag remains the fallback */ });
2165
+ };
2166
+ return [enabled, setEnabled, ready];
2167
+ }
2168
+
2169
+ function RemoteViewSettings() {
2170
+ const [enabled, setEnabled, ready] = useRemoteViewFlag();
2171
+ return h('div', { style: { display: 'grid', gap: '10px', padding: '4px 0' } }, [
2172
+ h('div', { key: 'title', style: { fontWeight: 600 } }, '远程桌面'),
2173
+ h('label', { key: 'toggle', style: { display: 'flex', gap: '8px', alignItems: 'center', cursor: 'pointer', opacity: ready ? 1 : 0.6 } }, [
2174
+ h('input', { key: 'box', type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }),
2175
+ h('span', { key: 'text' }, enabled ? '已开启' : '已关闭'),
2176
+ ]),
2177
+ h('div', { key: 'hint', style: { fontSize: '12px', opacity: 0.75, lineHeight: 1.5 } },
2178
+ '仅查看,不发送鼠标/键盘输入。P1 目标为最高 1 帧/秒;实际帧率取决于目标机性能与网络。媒体优先 P2P,relay 只承载信令。'),
2179
+ h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, 'P1 当前为本机回环查看;远端采集/P2P 在后续切片接入。'),
2180
+ ]);
2181
+ }
2182
+
2183
+ function RemoteViewSidebarIcon() {
2184
+ const [enabled, , ready] = useRemoteViewFlag();
2185
+ if (!ready || !enabled) return null;
2186
+ return h('span', { title: '远程桌面', style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } }, h('svg', {
2187
+ width: 20, height: 18, viewBox: '0 0 24 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
2188
+ }, [
2189
+ h('rect', { key: 'screen', x: 2.5, y: 2, width: 19, height: 12, rx: 1.5 }),
2190
+ h('path', { key: 'stand', d: 'M8.5 18h7M12 14.2V18' }),
2191
+ ]));
2192
+ }
2193
+
2194
+ function RemoteViewMainPage() {
2195
+ const [enabled] = useRemoteViewFlag();
2196
+ const [machines, setMachines] = React.useState([]);
2197
+ const [target, setTarget] = React.useState('');
2198
+ const [session, setSession] = React.useState(null);
2199
+ const [live, setLive] = React.useState(true);
2200
+ const [frame, setFrame] = React.useState(null);
2201
+ const [stats, setStats] = React.useState(null);
2202
+ const [error, setError] = React.useState(null);
2203
+ React.useEffect(() => {
2204
+ if (!enabled) return undefined;
2205
+ let alive = true;
2206
+ fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } })
2207
+ .then((response) => (response.ok ? response.json() : null))
2208
+ .then((body) => { if (!alive) return; const list = Array.isArray(body?.machines) ? body.machines.filter((m) => m.online === true) : []; setMachines(list); if (list.length > 0) setTarget((current) => current || list[0].machine_id); })
2209
+ .catch(() => { /* roster unavailable */ });
2210
+ return () => { alive = false; };
2211
+ }, [enabled]);
2212
+ React.useEffect(() => {
2213
+ if (!enabled || !live) return undefined;
2214
+ let alive = true;
2215
+ const tick = async () => {
2216
+ try {
2217
+ const response = await fetch(REMOTE_FRAME_URL, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' } });
2218
+ const body = await response.json().catch(() => null);
2219
+ if (!alive) return;
2220
+ if (body?.ok === true) { setFrame(body); setStats({ capture_ms: body.capture_ms, bytes: body.bytes, at: body.at, encrypted: body.encrypted }); setError(null); }
2221
+ else if (body?.code === 'VIEW_WAITING_FOR_FRAME') { setError(null); }
2222
+ else { setError(body?.code ?? ('HTTP ' + response.status)); }
2223
+ } catch (failure) { if (alive) setError(failure instanceof Error ? failure.message : String(failure)); }
2224
+ };
2225
+ tick();
2226
+ const timer = setInterval(tick, 1000);
2227
+ return () => { alive = false; clearInterval(timer); };
2228
+ }, [enabled, live, session]);
2229
+ const connect = async () => {
2230
+ setError(null);
2231
+ try {
2232
+ const response = await fetch('/api/w2m/remote/start', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ machine_id: target }) });
2233
+ const body = await response.json().catch(() => null);
2234
+ if (!response.ok || body?.ok !== true) { setError(body?.code ?? body?.error ?? ('HTTP ' + response.status)); return; }
2235
+ setFrame(null); setSession(body);
2236
+ } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
2237
+ };
2238
+ const disconnect = async () => {
2239
+ try { await fetch('/api/w2m/remote/stop', { method: 'POST', credentials: 'same-origin' }); } catch { /* best effort */ }
2240
+ setSession(null); setFrame(null); setStats(null);
2241
+ };
2242
+ const ready = enabled && frame?.data_base64;
2243
+ return h('div', { style: { padding: '16px', display: 'grid', gap: '12px', maxWidth: '1100px' } }, [
2244
+ h('div', { key: 'title', style: { fontSize: '16px', fontWeight: 600 } }, '远程桌面 · 仅查看'),
2245
+ h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('P2P 会话:' + (session.machine_id ?? '') + (session.encrypted ? '(加密)' : '(未加密)')) : '未连接目标') : '插件设置中尚未开启远程桌面。'),
2246
+ h('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } }, [
2247
+ h('select', { key: 'target', value: target, disabled: !enabled, onChange: (event) => setTarget(event.target.value), style: { minWidth: '180px' } }, machines.length === 0 ? h('option', { value: '' }, '无在线机器') : machines.map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, (machine.name ?? machine.machine_id)))),
2248
+ h('button', { key: 'connect', type: 'button', disabled: !enabled || target === '' || session !== null, onClick: connect }, '连接'),
2249
+ h('button', { key: 'stop', type: 'button', disabled: session === null, onClick: disconnect }, '停止'),
2250
+ h('button', { key: 'pause', type: 'button', disabled: !enabled, onClick: () => setLive((value) => !value) }, live ? '暂停' : '继续'),
2251
+ ]),
2252
+ h('div', { key: 'canvas', style: { minHeight: '320px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-sm)', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--dsw-alias-bg-layer-1)' } }, ready
2253
+ ? h('img', { src: 'data:' + (frame.mime ?? 'image/jpeg') + ';base64,' + frame.data_base64, alt: '远程桌面画面', style: { maxWidth: '100%', maxHeight: '70vh', display: 'block' } })
2254
+ : h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '本机回环预览(选择目标后点连接)')))),
2255
+ h('div', { key: 'statusline', style: { fontSize: '12px', opacity: 0.7, display: 'flex', gap: '14px', flexWrap: 'wrap' } }, [
2256
+ h('span', { key: 'fps' }, '1 fps 上限'),
2257
+ h('span', { key: 'channel' }, '通道:' + (session ? 'P2P' : '本机回环')),
2258
+ h('span', { key: 'cap' }, '采集:' + (stats?.capture_ms ?? '—') + 'ms'),
2259
+ h('span', { key: 'bytes' }, '帧:' + (stats?.bytes ?? '—') + 'B'),
2260
+ h('span', { key: 'age' }, '最近帧:' + (stats?.at ?? '—')),
2261
+ ]),
2262
+ ]);
2263
+ }
2264
+
2118
2265
  function FleetChat({ sessionId, viewMode }) {
2119
2266
  // v1.9.18: chat emotes are local slash commands, never image bytes. The relay only sees text.
2120
2267
  const CHAT_COMMANDS = ['/微笑', '/火箭', '/烟花'];
@@ -2802,6 +2949,39 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
2802
2949
  error,
2803
2950
  );
2804
2951
  }
2952
+ // v2.1.0: remote-view skeleton — settings toggle, conditional iMac sidebar entry, viewer page.
2953
+ try {
2954
+ slots.inject('settings.section', () => slots.register({
2955
+ name: 'settings.section',
2956
+ id: 'w2m-remote-view',
2957
+ order: 13,
2958
+ label: () => '远程桌面',
2959
+ inject: () => ({ t }),
2960
+ }, RemoteViewSettings));
2961
+ } catch (error) {
2962
+ console.error('@twinsearth/w2m-dsh-plugin: the remote-view settings section was not mounted', error);
2963
+ }
2964
+ try {
2965
+ slots.inject('sidebar.panellist', () => slots.register({
2966
+ name: 'sidebar.panellist',
2967
+ id: 'w2m-remote-view',
2968
+ order: 5,
2969
+ label: () => '',
2970
+ inject: () => ({}),
2971
+ }, RemoteViewSidebarIcon));
2972
+ } catch (error) {
2973
+ console.error('@twinsearth/w2m-dsh-plugin: the remote-view sidebar entry was not mounted', error);
2974
+ }
2975
+ try {
2976
+ slots.inject('main', () => slots.register({
2977
+ name: 'main',
2978
+ key: 'w2m-remote-view',
2979
+ label: () => '远程桌面',
2980
+ inject: () => ({ t }),
2981
+ }, RemoteViewMainPage));
2982
+ } catch (error) {
2983
+ console.error('@twinsearth/w2m-dsh-plugin: the remote-view main page was not mounted', error);
2984
+ }
2805
2985
  }
2806
2986
 
2807
2987
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.0.10",
3
+ "version": "2.1.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": {
@@ -66,7 +66,7 @@
66
66
  "pack": "node scripts/pack.mjs",
67
67
  "reachability": "node scripts/reachability-check.mjs",
68
68
  "reachability:classify": "node scripts/reachability-classify.mjs",
69
- "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 test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs",
69
+ "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 test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs",
70
70
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
71
71
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
72
72
  "test:account-join": "node --test --test-force-exit test/account-join.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": "2.0.10",
4
+ "version": "2.1.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": [
@@ -70,6 +70,8 @@ import {
70
70
  } from '../util/exec-capability.mjs';
71
71
  import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningKey } from './crypto.mjs';
72
72
  import { createXferFrameRouter } from './transfer/router.mjs';
73
+ import { captureScreen } from './view/capture.mjs';
74
+ import { createViewTarget } from './view/frame-channel.mjs';
73
75
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
74
76
  import { punchSession, reverseDialSession } from './p2p.mjs';
75
77
  import { createSigner } from '../signing.mjs';
@@ -3966,6 +3968,15 @@ let attemptAttached = false;
3966
3968
  xferRouter.handle(payload, { channel: event.channel });
3967
3969
  return;
3968
3970
  }
3971
+ if (payload !== null && typeof payload.type === 'string' && payload.type.startsWith('view.')) {
3972
+ const target = createViewTarget({
3973
+ capture: () => captureScreen({}),
3974
+ allow: process.env.W2M_VIEW_ALLOW === '1',
3975
+ send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
3976
+ });
3977
+ target.handle(payload);
3978
+ return;
3979
+ }
3969
3980
  if (payload === null) {
3970
3981
  log('warn', 'ignoring non-JSON frame on a P2P channel', {
3971
3982
  peer: event.peer ?? null,
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Target-side screen capture for remote-view P1.
3
+ *
4
+ * One JPEG per call, bounded by a timeout, injectable `run` so tests never
5
+ * launch PowerShell or screencapture. Errors are named, never "capture failed"
6
+ * without a reason.
7
+ */
8
+ import { spawnSync } from 'node:child_process';
9
+ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ export const VIEW_CAPTURE_CODE = Object.freeze({
14
+ OK: 'CAPTURE_OK',
15
+ UNSUPPORTED: 'CAPTURE_UNSUPPORTED_PLATFORM',
16
+ DENIED: 'CAPTURE_DENIED',
17
+ BLANK: 'CAPTURE_BLANK',
18
+ FAILED: 'CAPTURE_FAILED',
19
+ TOO_LARGE: 'CAPTURE_TOO_LARGE',
20
+ });
21
+
22
+ export const DEFAULT_MAX_FRAME_BYTES = 96 * 1024;
23
+
24
+ const defaultRun = (argv, options = {}) => spawnSync(argv[0], argv.slice(1), {
25
+ timeout: options.timeoutMs ?? 15000,
26
+ encoding: options.encoding ?? 'utf8',
27
+ env: options.env ?? process.env,
28
+ });
29
+
30
+ function windowsCaptureScript(maxBytes) {
31
+ return [
32
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
33
+ '$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
34
+ '$src=New-Object System.Drawing.Bitmap $b.Width,$b.Height',
35
+ '$g=[System.Drawing.Graphics]::FromImage($src)',
36
+ '$g.CopyFromScreen($b.Left,$b.Top,0,0,$src.Size)',
37
+ '$codec=[System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }',
38
+ '$max=' + String(maxBytes),
39
+ '$best=$null',
40
+ 'foreach($w in @(1280,1024,800,640)){',
41
+ ' $h=[int]($b.Height*$w/$b.Width)',
42
+ ' $dst=New-Object System.Drawing.Bitmap $w,$h',
43
+ ' $dg=[System.Drawing.Graphics]::FromImage($dst)',
44
+ ' $dg.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',
45
+ ' $dg.DrawImage($src,0,0,$w,$h)',
46
+ ' foreach($q in @(70,55,40)){',
47
+ ' $p=New-Object System.Drawing.Imaging.EncoderParameters(1)',
48
+ ' $p.Param[0]=New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality,[long]$q)',
49
+ ' $ms=New-Object System.IO.MemoryStream',
50
+ ' $dst.Save($ms,$codec,$p)',
51
+ ' $bytes=$ms.ToArray()',
52
+ ' if($best -eq $null -or $bytes.Length -lt $best.Length){$best=$bytes}',
53
+ ' if($bytes.Length -le $max){[Convert]::ToBase64String($bytes); exit 0}',
54
+ ' }',
55
+ '}',
56
+ '[Convert]::ToBase64String($best)',
57
+ ].join(';');
58
+ }
59
+
60
+
61
+ /**
62
+ * @param {{platform?: NodeJS.Platform, run?: Function, tempRoot?: string, maxBytes?: number}} [options]
63
+ * @returns {{ok: true, code: string, mime: string, data: Buffer, base64: string, bytes: number, capture_ms: number, width: number|null, height: number|null}
64
+ * | {ok: false, code: string, error: string, capture_ms: number}}
65
+ */
66
+ export function captureScreen(options = {}) {
67
+ const platform = options.platform ?? process.platform;
68
+ const run = options.run ?? defaultRun;
69
+ const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_FRAME_BYTES;
70
+ const started = Date.now();
71
+ try {
72
+ let base64 = null;
73
+ let width = null;
74
+ let height = null;
75
+ if (platform === 'win32') {
76
+ const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
77
+ if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.code ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
78
+ base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
79
+ if (!/^[A-Za-z0-9+/=]{100,}$/.test(base64)) return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: 'PowerShell returned no JPEG', capture_ms: Date.now() - started };
80
+ } else if (platform === 'darwin') {
81
+ const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-'));
82
+ const file = join(dir, 'frame.jpg');
83
+ try {
84
+ const result = run(['/usr/sbin/screencapture', '-x', '-t', 'jpg', file], { timeoutMs: 15000 });
85
+ if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `screencapture failed (exit ${result.code})`, capture_ms: Date.now() - started };
86
+ const data = readFileSync(file);
87
+ base64 = data.toString('base64');
88
+ const sips = run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 5000 });
89
+ const w = /pixelWidth:\s*(\d+)/.exec(sips?.stdout ?? '');
90
+ const h = /pixelHeight:\s*(\d+)/.exec(sips?.stdout ?? '');
91
+ width = w === null ? null : Number(w[1]);
92
+ height = h === null ? null : Number(h[1]);
93
+ } finally {
94
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
95
+ }
96
+ } else {
97
+ return { ok: false, code: VIEW_CAPTURE_CODE.UNSUPPORTED, error: `screen capture is not implemented for ${platform}`, capture_ms: Date.now() - started };
98
+ }
99
+ const data = Buffer.from(base64, 'base64');
100
+ if (data.length < 1000) return { ok: false, code: VIEW_CAPTURE_CODE.BLANK, error: 'captured JPEG is too small to be a screen', capture_ms: Date.now() - started };
101
+ if (data.length > maxBytes) return { ok: false, code: VIEW_CAPTURE_CODE.TOO_LARGE, error: `frame ${data.length} > ${maxBytes}`, capture_ms: Date.now() - started };
102
+ return { ok: true, code: VIEW_CAPTURE_CODE.OK, mime: 'image/jpeg', data, base64, bytes: data.length, capture_ms: Date.now() - started, width, height };
103
+ } catch (error) {
104
+ return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: error instanceof Error ? error.message : String(error), capture_ms: Date.now() - started };
105
+ }
106
+ }
107
+
108
+ /** Test/util helper: file size without reading the whole image. */
109
+ export function frameBytes(file) {
110
+ try { return statSync(file).size; } catch { return 0; }
111
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * P2P view frame channel (P1, view-only).
3
+ *
4
+ * Frames are JSON on the existing P2PChannel (one JPEG per frame, ≤96 KiB).
5
+ * The target defaults to DENY; consent is a separate decision supplied by the
6
+ * caller (`allow: true`). Nothing here accepts input events.
7
+ */
8
+ export const VIEW_FRAME = Object.freeze({
9
+ REQUEST: 'view.request',
10
+ FRAME: 'view.frame',
11
+ STOP: 'view.stop',
12
+ ERROR: 'view.error',
13
+ });
14
+
15
+ export const VIEW_CODE = Object.freeze({
16
+ CONSENT_REQUIRED: 'VIEW_CONSENT_REQUIRED',
17
+ DISABLED: 'VIEW_DISABLED',
18
+ FRAME_TOO_LARGE: 'VIEW_FRAME_TOO_LARGE',
19
+ BAD_FRAME: 'VIEW_BAD_FRAME',
20
+ CAPTURE_FAILED: 'VIEW_CAPTURE_FAILED',
21
+ INPUT_FORBIDDEN: 'VIEW_INPUT_FORBIDDEN',
22
+ });
23
+
24
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
25
+ const isInputFrame = (type) => /^(mouse|key|pointer|input)\./.test(type);
26
+
27
+ export function parseViewFrame(payload) {
28
+ try {
29
+ const frame = Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload;
30
+ if (!isObject(frame) || typeof frame.type !== 'string' || !frame.type.startsWith('view.')) return null;
31
+ return frame;
32
+ } catch { return null; }
33
+ }
34
+
35
+ /**
36
+ * Target side: answer view.request with one JPEG frame; refuse by default.
37
+ *
38
+ * @param {{capture: Function, allow?: boolean, send: Function, maxBytes?: number, fps?: number}} deps
39
+ */
40
+ export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1 } = {}) {
41
+ if (typeof capture !== 'function' || typeof send !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
42
+ let seq = 0;
43
+ let stopped = false;
44
+ const reply = (frame) => { try { send(JSON.stringify(frame)); } catch { /* channel gone */ } };
45
+ return {
46
+ ok: true,
47
+ handle(payload) {
48
+ let raw = null;
49
+ try { raw = Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload; } catch { raw = null; }
50
+ if (isObject(raw) && typeof raw.type === 'string' && isInputFrame(raw.type)) {
51
+ reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.INPUT_FORBIDDEN, session_id: raw.session_id ?? null });
52
+ return true;
53
+ }
54
+ const frame = parseViewFrame(payload);
55
+ if (frame === null) return false;
56
+ if (frame.type === VIEW_FRAME.STOP) { stopped = true; return true; }
57
+ if (frame.type !== VIEW_FRAME.REQUEST) return true;
58
+ if (allow !== true) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.CONSENT_REQUIRED, session_id: frame.session_id ?? null }); return true; }
59
+ if (stopped) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.DISABLED, session_id: frame.session_id ?? null }); return true; }
60
+ const result = capture({ maxBytes });
61
+ if (result?.ok !== true) {
62
+ reply({ type: VIEW_FRAME.ERROR, code: result?.code ?? VIEW_CODE.CAPTURE_FAILED, session_id: frame.session_id ?? null });
63
+ return true;
64
+ }
65
+ seq += 1;
66
+ reply({
67
+ type: VIEW_FRAME.FRAME,
68
+ session_id: frame.session_id ?? null,
69
+ seq,
70
+ ts_ms: Date.now(),
71
+ mime: result.mime ?? 'image/jpeg',
72
+ bytes: result.bytes,
73
+ data_b64: result.base64,
74
+ });
75
+ return true;
76
+ },
77
+ stats() { return { seq, stopped, allow: allow === true, fps, max_bytes: maxBytes }; },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Viewer side: request a frame and accept/validate the response.
83
+ *
84
+ * @param {{send: Function, onFrame: Function, maxBytes?: number}} deps
85
+ */
86
+ export function createViewClient({ send, onFrame, maxBytes = 96 * 1024 } = {}) {
87
+ if (typeof send !== 'function' || typeof onFrame !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
88
+ let sessionId = null;
89
+ let lastSeq = 0;
90
+ return {
91
+ ok: true,
92
+ request({ machineId, session }) {
93
+ sessionId = session ?? ('view-' + Date.now());
94
+ send(JSON.stringify({ type: VIEW_FRAME.REQUEST, session_id: sessionId, machine_id: machineId ?? null, fps: 1, max_bytes: maxBytes }));
95
+ return sessionId;
96
+ },
97
+ accept(payload) {
98
+ const frame = parseViewFrame(payload);
99
+ if (frame === null) return false;
100
+ if (frame.type === VIEW_FRAME.ERROR) { onFrame({ ok: false, code: frame.code ?? VIEW_CODE.CAPTURE_FAILED }); return true; }
101
+ if (frame.type !== VIEW_FRAME.FRAME) return true;
102
+ if (sessionId !== null && frame.session_id !== sessionId) return true;
103
+ if (!Number.isInteger(frame.seq) || frame.seq <= lastSeq) return true;
104
+ if (typeof frame.data_b64 !== 'string' || frame.data_b64 === '') { onFrame({ ok: false, code: VIEW_CODE.BAD_FRAME }); return true; }
105
+ const bytes = Buffer.from(frame.data_b64, 'base64').length;
106
+ if (bytes > maxBytes) { onFrame({ ok: false, code: VIEW_CODE.FRAME_TOO_LARGE }); return true; }
107
+ lastSeq = frame.seq;
108
+ onFrame({ ok: true, seq: frame.seq, ts_ms: frame.ts_ms, mime: frame.mime ?? 'image/jpeg', bytes, data_b64: frame.data_b64 });
109
+ return true;
110
+ },
111
+ stop() { if (sessionId !== null) send(JSON.stringify({ type: VIEW_FRAME.STOP, session_id: sessionId })); },
112
+ stats() { return { sessionId, lastSeq }; },
113
+ };
114
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * v2.1.x remote-view host routes.
3
+ *
4
+ * local : /frame captures this host (P1 fallback, no target selected)
5
+ * remote : /start dials the target over P2P and asks its agent for view frames;
6
+ * /frame serves the newest cached frame; /stop ends the session.
7
+ *
8
+ * Remote frames never traverse the relay. A P2P failure is P2P_UNAVAILABLE,
9
+ * never satisfied by a relay/plaintext media fallback.
10
+ */
11
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { captureHostScreen } from './capture-route.mjs';
14
+
15
+ export const REMOTE_CONFIG_PATH = '/api/w2m/remote/config';
16
+ export const REMOTE_FRAME_PATH = '/api/w2m/remote/frame';
17
+ export const REMOTE_START_PATH = '/api/w2m/remote/start';
18
+ export const REMOTE_STOP_PATH = '/api/w2m/remote/stop';
19
+ const NO_STORE = { 'cache-control': 'no-store' };
20
+
21
+ function readConfig(file) {
22
+ try { return { ok: true, enabled: JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))?.enabled === true }; }
23
+ catch { return { ok: true, enabled: false }; }
24
+ }
25
+
26
+ function writeConfig(file, enabled) {
27
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
28
+ const tmp = file + '.tmp-' + process.pid;
29
+ writeFileSync(tmp, JSON.stringify({ enabled: enabled === true, updatedAt: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 });
30
+ renameSync(tmp, file);
31
+ }
32
+
33
+ function payloadJson(payload) {
34
+ try { return Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload; }
35
+ catch { return null; }
36
+ }
37
+
38
+ /**
39
+ * @param {object} ctx Cordis context
40
+ * @param {{stateDir:string, p2p?:object, capture?:Function, platform?:NodeJS.Platform, maxBytes?:number}} deps
41
+ */
42
+ export function registerRemoteViewRoutes(ctx, deps = {}) {
43
+ const status = { mounted: false, reason: 'the host has no Connection service', configPath: null };
44
+ if (typeof ctx?.inject !== 'function') return status;
45
+ if (typeof deps.stateDir !== 'string' || deps.stateDir === '') { status.reason = 'stateDir is not configured'; return status; }
46
+ const file = join(deps.stateDir, 'remote-view.json');
47
+ const capture = deps.capture ?? captureHostScreen;
48
+ const platform = deps.platform ?? process.platform;
49
+ const p2p = deps.p2p ?? null;
50
+ const maxBytes = Number.isInteger(deps.maxBytes) ? deps.maxBytes : 96 * 1024;
51
+ let active = null;
52
+ let listener = null;
53
+
54
+ const stopActive = async () => {
55
+ if (active === null) return;
56
+ try { await active.channel?.send?.(JSON.stringify({ type: 'view.stop', session_id: active.sessionId })); } catch { /* peer gone */ }
57
+ try { active.channel?.close?.(); } catch { /* already closed */ }
58
+ if (p2p?.node && typeof p2p.node.off === 'function' && listener !== null) p2p.node.off('message', listener);
59
+ active = null;
60
+ listener = null;
61
+ };
62
+
63
+ ctx.inject(['connection'], (webCtx) => {
64
+ const registry = webCtx?.connection?.fetch;
65
+ if (registry === undefined || typeof registry.register !== 'function') { status.reason = 'the connection service carries no fetch registry'; return; }
66
+ registry.register({
67
+ path: REMOTE_CONFIG_PATH,
68
+ methods: ['GET', 'POST'],
69
+ requestBody: 'buffered',
70
+ fetch: async (request) => {
71
+ if (request.method === 'GET') return Response.json({ ok: true, ...readConfig(file), session: active === null ? null : { machine_id: active.machineId, encrypted: active.encrypted === true } }, { headers: NO_STORE });
72
+ let body = null;
73
+ try { body = await request.json(); } catch { /* refused below */ }
74
+ if (body === null || typeof body.enabled !== 'boolean') return Response.json({ ok: false, error: 'a JSON body with {"enabled": true|false} is required' }, { status: 400, headers: NO_STORE });
75
+ if (body.enabled === false) await stopActive();
76
+ writeConfig(file, body.enabled);
77
+ return Response.json({ ok: true, enabled: body.enabled }, { headers: NO_STORE });
78
+ },
79
+ });
80
+
81
+ registry.register({
82
+ path: REMOTE_START_PATH,
83
+ methods: ['POST'],
84
+ requestBody: 'buffered',
85
+ fetch: async (request) => {
86
+ if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
87
+ let body = null;
88
+ try { body = await request.json(); } catch { /* refused below */ }
89
+ const machineId = typeof body?.machine_id === 'string' ? body.machine_id : '';
90
+ if (machineId === '') return Response.json({ ok: false, error: 'a JSON body with {"machine_id": "..."} is required' }, { status: 400, headers: NO_STORE });
91
+ if (p2p?.node === null || p2p?.node === undefined) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: 'no P2P node on this host' }, { status: 503, headers: NO_STORE });
92
+ await stopActive();
93
+ try { await p2p.node.start?.(); } catch (error) {
94
+ return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: error instanceof Error ? error.message : String(error) }, { status: 503, headers: NO_STORE });
95
+ }
96
+ const dialled = await p2p.node.dial(machineId, {});
97
+ if (dialled?.ok !== true) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: dialled?.error ?? 'P2P_DIAL_FAILED' }, { status: 502, headers: NO_STORE });
98
+ const sessionId = 'view-' + Date.now().toString(36);
99
+ active = { machineId, sessionId, channel: dialled.channel, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
100
+ listener = (event) => {
101
+ const frame = payloadJson(event?.payload);
102
+ if (frame === null || frame?.session_id !== sessionId) return;
103
+ if (frame.type === 'view.frame') {
104
+ const bytes = typeof frame.data_b64 === 'string' ? Buffer.from(frame.data_b64, 'base64').length : 0;
105
+ if (bytes > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
106
+ active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes, data_base64: frame.data_b64, at: new Date().toISOString(), encrypted: active.encrypted };
107
+ } else if (frame.type === 'view.error') {
108
+ active.error = frame.code ?? 'VIEW_ERROR';
109
+ }
110
+ };
111
+ p2p.node.on('message', listener);
112
+ await dialled.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
113
+ return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
114
+ },
115
+ });
116
+
117
+ registry.register({
118
+ path: REMOTE_STOP_PATH,
119
+ methods: ['POST'],
120
+ fetch: async () => { await stopActive(); return Response.json({ ok: true }, { headers: NO_STORE }); },
121
+ });
122
+
123
+ registry.register({
124
+ path: REMOTE_FRAME_PATH,
125
+ methods: ['POST'],
126
+ fetch: async () => {
127
+ if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
128
+ if (active !== null) {
129
+ if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
130
+ if (active.error !== null) return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE });
131
+ return Response.json({ ok: false, code: 'VIEW_WAITING_FOR_FRAME', machine_id: active.machineId }, { status: 202, headers: NO_STORE });
132
+ }
133
+ const started = Date.now();
134
+ const result = await capture({ platform });
135
+ if (result.ok !== true) return Response.json({ ok: false, code: 'CAPTURE_FAILED', error: result.error ?? null }, { status: 502, headers: NO_STORE });
136
+ return Response.json({ ok: true, mime: result.mime ?? 'image/jpeg', data_base64: result.data_base64, bytes: Math.ceil((result.data_base64?.length ?? 0) * 3 / 4), capture_ms: Date.now() - started, at: new Date().toISOString() }, { headers: NO_STORE });
137
+ },
138
+ });
139
+ status.mounted = true;
140
+ status.reason = null;
141
+ status.configPath = file;
142
+ });
143
+ return status;
144
+ }
@@ -198,7 +198,8 @@ import { registerProjectStreamRoute } from './project-stream-route.mjs';
198
198
  import { planProgress } from './joint-dev/progress.mjs';
199
199
  import { registerJointTools } from './tools-joint.mjs';
200
200
  import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
201
- import { registerCaptureRoutes } from './capture-route.mjs';
201
+ import { registerCaptureRoutes } from './capture-route.mjs';
202
+ import { registerRemoteViewRoutes } from './remote-view-route.mjs';
202
203
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
203
204
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
204
205
  import { renderMemoryView } from './memory-view.mjs';
@@ -291,7 +292,7 @@ const MAX_STAGES = 16;
291
292
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
292
293
  * overrides it at runtime for tests and packagers.
293
294
  */
294
- const PLUGIN_VERSION = '2.0.10';
295
+ const PLUGIN_VERSION = '2.1.2';
295
296
 
296
297
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
297
298
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3609,7 +3610,11 @@ export async function apply(ctx, config = {}) {
3609
3610
  // v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
3610
3611
  // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
3611
3612
  // depend on those permissions.
3612
- registerCaptureRoutes(ctx);
3613
+ registerCaptureRoutes(ctx);
3614
+ const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
3615
+ if (remoteViewRoutes.mounted !== true) {
3616
+ ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
3617
+ }
3613
3618
  if (capabilityRoutes.mounted !== true) {
3614
3619
  ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
3615
3620
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * P1.5 encrypted relay media broker (in-memory only).
3
+ *
4
+ * The relay never sees a JPEG or a session key:
5
+ * - payloads are opaque ciphertext, size-capped to fit the relay frame budget;
6
+ * - one newest frame per session (latest-frame-wins), no history;
7
+ * - every entry expires; nothing is written to disk or logged.
8
+ *
9
+ * HTTP/agent wiring is a separate slice; this module is the safety contract.
10
+ */
11
+ export const REMOTE_MEDIA_CODE = Object.freeze({
12
+ OK: 'REMOTE_MEDIA_OK',
13
+ BAD_SESSION: 'REMOTE_MEDIA_BAD_SESSION',
14
+ BAD_PAYLOAD: 'REMOTE_MEDIA_BAD_PAYLOAD',
15
+ TOO_LARGE: 'REMOTE_MEDIA_TOO_LARGE',
16
+ NOT_FOUND: 'REMOTE_MEDIA_NOT_FOUND',
17
+ EXPIRED: 'REMOTE_MEDIA_EXPIRED',
18
+ });
19
+
20
+ export const DEFAULT_REMOTE_MEDIA_MAX_BYTES = 47 * 1024;
21
+ export const DEFAULT_REMOTE_MEDIA_TTL_MS = 15_000;
22
+
23
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
24
+
25
+ /**
26
+ * @param {{maxBytes?: number, ttlMs?: number, now?: () => number}} [options]
27
+ */
28
+ export function createRemoteMediaBroker(options = {}) {
29
+ const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : DEFAULT_REMOTE_MEDIA_MAX_BYTES;
30
+ const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : DEFAULT_REMOTE_MEDIA_TTL_MS;
31
+ const now = typeof options.now === 'function' ? options.now : Date.now;
32
+ const sessions = new Map();
33
+
34
+ const prune = () => {
35
+ const at = now();
36
+ for (const [id, entry] of sessions) if (entry.expiresAt <= at) sessions.delete(id);
37
+ };
38
+
39
+ return {
40
+ maxBytes,
41
+ ttlMs,
42
+ /** Open or refresh a session addressed to one target machine. */
43
+ open(sessionId, targetMachineId) {
44
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof targetMachineId !== 'string' || targetMachineId === '') {
45
+ return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
46
+ }
47
+ sessions.set(sessionId, { target: targetMachineId, frame: null, expiresAt: now() + ttlMs });
48
+ return { ok: true, code: REMOTE_MEDIA_CODE.OK, session_id: sessionId, target: targetMachineId };
49
+ },
50
+ /** Store one opaque ciphertext frame (latest wins). */
51
+ put(sessionId, targetMachineId, ciphertext) {
52
+ prune();
53
+ const entry = sessions.get(sessionId);
54
+ if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
55
+ if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
56
+ if (!(ciphertext instanceof Uint8Array) || ciphertext.byteLength === 0) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
57
+ if (ciphertext.byteLength > maxBytes) return { ok: false, code: REMOTE_MEDIA_CODE.TOO_LARGE };
58
+ entry.frame = { ciphertext: Buffer.from(ciphertext), bytes: ciphertext.byteLength, at: now() };
59
+ entry.expiresAt = now() + ttlMs;
60
+ return { ok: true, code: REMOTE_MEDIA_CODE.OK, bytes: ciphertext.byteLength };
61
+ },
62
+ /** Read and consume the newest frame, or report why not. */
63
+ take(sessionId, targetMachineId) {
64
+ prune();
65
+ const entry = sessions.get(sessionId);
66
+ if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.EXPIRED };
67
+ if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
68
+ if (entry.frame === null) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
69
+ const frame = entry.frame;
70
+ entry.frame = null;
71
+ return { ok: true, code: REMOTE_MEDIA_CODE.OK, ...frame };
72
+ },
73
+ close(sessionId) { sessions.delete(sessionId); },
74
+ stats() { prune(); return { sessions: sessions.size, maxBytes, ttlMs }; },
75
+ };
76
+ }
77
+
78
+ /** Validate the JSON body of a frame write without decoding the ciphertext. */
79
+ export function parseRemoteFrame(body) {
80
+ if (!isObject(body)) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
81
+ if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
82
+ if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
83
+ if (typeof body.ct !== 'string' || body.ct === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
84
+ let ciphertext;
85
+ try { ciphertext = Buffer.from(body.ct, 'base64'); } catch { return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD }; }
86
+ return { ok: true, session_id: body.session_id, target: body.target, ciphertext };
87
+ }