@mindexec/cli 0.2.393 → 0.2.395

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindexec/cli",
3
- "version": "0.2.393",
3
+ "version": "0.2.395",
4
4
  "description": "MindExec local runtime and bridge CLI",
5
5
  "main": "server.js",
6
6
  "type": "module",
package/remote-hub.js CHANGED
@@ -20,9 +20,77 @@ const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 4;
20
20
  const RECENT_LIVE_FRAME_CACHE_LIMIT = 80;
21
21
  const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 3 * 1024 * 1024;
22
22
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 1024 * 1024;
23
- const REMOTE_PROTOCOL_VERSION = 1;
23
+ const REMOTE_PROTOCOL_VERSION = 2;
24
+ const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
25
+ const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
26
+ const REMOTE_FRAME_PROTOCOL_VERSION = 2;
27
+ const REMOTE_FRAME_HANDSHAKE = 'smartview-request-open-frame';
28
+ const DEFAULT_REMOTE_FRAME_MODE = 'remote-fast';
29
+ const REMOTE_FRAME_MODE_PROFILES = Object.freeze({
30
+ thumbnail: Object.freeze({
31
+ mode: 'thumbnail',
32
+ label: 'Thumbnail PNG/JPEG Binary',
33
+ transport: 'ws-binary',
34
+ encoding: 'image',
35
+ codec: 'image',
36
+ compression: 'image/*',
37
+ profile: 'thumbnail-binary-v1',
38
+ modeVersion: 1,
39
+ implemented: true
40
+ }),
41
+ 'remote-fast': Object.freeze({
42
+ mode: 'remote-fast',
43
+ label: 'RemoteFast JPEG Binary',
44
+ transport: 'ws-binary',
45
+ encoding: 'image',
46
+ codec: 'jpeg',
47
+ compression: 'image/jpeg',
48
+ profile: 'jpeg-binary-v1',
49
+ modeVersion: 1,
50
+ implemented: true
51
+ }),
52
+ 'remote-quality': Object.freeze({
53
+ mode: 'remote-quality',
54
+ label: 'RemoteQuality JPEG Binary',
55
+ transport: 'ws-binary',
56
+ encoding: 'image',
57
+ codec: 'jpeg',
58
+ compression: 'image/jpeg',
59
+ profile: 'jpeg-quality-binary-v1',
60
+ modeVersion: 1,
61
+ implemented: true
62
+ }),
63
+ 'video-codec': Object.freeze({
64
+ mode: 'video-codec',
65
+ label: 'Video Codec Stream',
66
+ transport: 'ws-binary',
67
+ encoding: 'video',
68
+ codec: 'video',
69
+ compression: 'video/*',
70
+ profile: 'future-video-codec',
71
+ modeVersion: 0,
72
+ implemented: false
73
+ })
74
+ });
75
+ const REMOTE_FRAME_MODE_ALIASES = new Map([
76
+ ['fast', 'remote-fast'],
77
+ ['remotefast', 'remote-fast'],
78
+ ['remote_fast', 'remote-fast'],
79
+ ['remote-fast', 'remote-fast'],
80
+ ['quality', 'remote-quality'],
81
+ ['remotequality', 'remote-quality'],
82
+ ['remote_quality', 'remote-quality'],
83
+ ['remote-quality', 'remote-quality'],
84
+ ['thumb', 'thumbnail'],
85
+ ['thumbnail', 'thumbnail'],
86
+ ['video', 'video-codec'],
87
+ ['codec', 'video-codec'],
88
+ ['video-codec', 'video-codec']
89
+ ]);
24
90
  const MAX_SYNTHETIC_DEVICES = 1000;
25
91
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
92
+ const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
93
+ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 5000;
26
94
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
27
95
  const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
28
96
  const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
@@ -56,6 +124,115 @@ function safeString(value, maxLength = 200) {
56
124
  return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
57
125
  }
58
126
 
127
+ function normalizeRemoteFrameMode(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
128
+ const requested = safeString(value, 80).toLowerCase();
129
+ const fallbackMode = REMOTE_FRAME_MODE_PROFILES[fallback] ? fallback : DEFAULT_REMOTE_FRAME_MODE;
130
+ if (!requested) {
131
+ return fallbackMode;
132
+ }
133
+
134
+ const normalized = REMOTE_FRAME_MODE_ALIASES.get(requested) || requested;
135
+ const profile = REMOTE_FRAME_MODE_PROFILES[normalized];
136
+ if (!profile || profile.implemented !== true) {
137
+ return fallbackMode;
138
+ }
139
+
140
+ return normalized;
141
+ }
142
+
143
+ function serializeRemoteFrameModeProfile(profile) {
144
+ return {
145
+ mode: profile.mode,
146
+ label: profile.label,
147
+ transport: profile.transport,
148
+ encoding: profile.encoding,
149
+ codec: profile.codec,
150
+ compression: profile.compression,
151
+ profile: profile.profile,
152
+ modeVersion: profile.modeVersion,
153
+ implemented: profile.implemented === true
154
+ };
155
+ }
156
+
157
+ function getRemoteFrameModeProfile(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
158
+ const mode = normalizeRemoteFrameMode(value, fallback);
159
+ return serializeRemoteFrameModeProfile(REMOTE_FRAME_MODE_PROFILES[mode]);
160
+ }
161
+
162
+ function getSupportedRemoteFrameModeProfiles() {
163
+ return Object.values(REMOTE_FRAME_MODE_PROFILES)
164
+ .filter(profile => profile.implemented === true)
165
+ .map(serializeRemoteFrameModeProfile);
166
+ }
167
+
168
+ function buildRemoteFrameProtocolDescriptor() {
169
+ return {
170
+ protocol: REMOTE_FRAME_PROTOCOL,
171
+ version: REMOTE_FRAME_PROTOCOL_VERSION,
172
+ handshake: REMOTE_FRAME_HANDSHAKE,
173
+ defaultMode: DEFAULT_REMOTE_FRAME_MODE,
174
+ modes: getSupportedRemoteFrameModeProfiles()
175
+ };
176
+ }
177
+
178
+ function buildRemoteFrameTransferDescriptor(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
179
+ const profile = getRemoteFrameModeProfile(value, fallback);
180
+ return {
181
+ mode: profile.mode,
182
+ frameMode: profile.mode,
183
+ frameProfile: profile.profile,
184
+ encoding: profile.encoding,
185
+ codec: profile.codec,
186
+ compression: profile.compression,
187
+ transferProtocol: REMOTE_FRAME_PROTOCOL,
188
+ transferProtocolVersion: REMOTE_FRAME_PROTOCOL_VERSION,
189
+ handshake: REMOTE_FRAME_HANDSHAKE
190
+ };
191
+ }
192
+
193
+ function buildRemoteFrameTransferDescriptorFromMessage(message = {}, fallback = DEFAULT_REMOTE_FRAME_MODE) {
194
+ const descriptor = buildRemoteFrameTransferDescriptor(message.frameMode || message.mode, fallback);
195
+ return {
196
+ ...descriptor,
197
+ frameProfile: safeString(message.frameProfile, 80) || descriptor.frameProfile,
198
+ encoding: safeString(message.encoding, 40) || descriptor.encoding,
199
+ codec: safeString(message.codec, 40) || descriptor.codec,
200
+ compression: safeString(message.compression || message.mimeType || message.format, 80) || descriptor.compression,
201
+ transferProtocol: safeString(message.transferProtocol, 80) || descriptor.transferProtocol,
202
+ transferProtocolVersion: Number.isFinite(Number(message.transferProtocolVersion))
203
+ ? Math.max(1, Math.floor(Number(message.transferProtocolVersion)))
204
+ : descriptor.transferProtocolVersion,
205
+ handshake: safeString(message.handshake, 80) || descriptor.handshake
206
+ };
207
+ }
208
+
209
+ function normalizeIncomingFrameModeProfile(value) {
210
+ if (!value) {
211
+ return null;
212
+ }
213
+
214
+ if (typeof value === 'string') {
215
+ return getRemoteFrameModeProfile(value);
216
+ }
217
+
218
+ if (typeof value !== 'object') {
219
+ return null;
220
+ }
221
+
222
+ const profile = buildRemoteFrameTransferDescriptor(value.mode || value.frameMode || value.profile);
223
+ return {
224
+ mode: profile.mode,
225
+ label: safeString(value.label, 80) || profile.mode,
226
+ transport: safeString(value.transport, 40) || 'ws-binary',
227
+ encoding: safeString(value.encoding, 40) || profile.encoding,
228
+ codec: safeString(value.codec, 40) || profile.codec,
229
+ compression: safeString(value.compression, 80) || profile.compression,
230
+ profile: safeString(value.profile || value.frameProfile, 80) || profile.frameProfile,
231
+ modeVersion: Number.isFinite(Number(value.modeVersion)) ? Math.max(0, Math.floor(Number(value.modeVersion))) : 1,
232
+ implemented: value.implemented !== false
233
+ };
234
+ }
235
+
59
236
  function isLoopbackHost(value) {
60
237
  const host = String(value || '').trim().toLowerCase();
61
238
  return host === 'localhost'
@@ -540,6 +717,12 @@ function serializeDevice(device, options = {}) {
540
717
  arch: device.arch,
541
718
  pid: device.pid,
542
719
  agentVersion: device.agentVersion,
720
+ protocol: device.protocol,
721
+ protocolVersion: device.protocolVersion,
722
+ frameProtocol: device.frameProtocol ? { ...device.frameProtocol } : null,
723
+ frameModes: Array.isArray(device.frameModes)
724
+ ? device.frameModes.map(profile => ({ ...profile }))
725
+ : [],
543
726
  capabilities: { ...device.capabilities },
544
727
  connected: device.connected,
545
728
  connectedAt: device.connectedAt,
@@ -882,6 +1065,7 @@ export function createRemoteHub(options = {}) {
882
1065
  const taskBatches = new Map();
883
1066
  const sockets = new Map();
884
1067
  const allSockets = new Set();
1068
+ const duplicateDeviceLogAt = new Map();
885
1069
  let server = null;
886
1070
  let started = false;
887
1071
  let boundPort = requestedPort;
@@ -1083,6 +1267,9 @@ export function createRemoteHub(options = {}) {
1083
1267
  port: boundPort || requestedPort,
1084
1268
  protocol: 'tcp-jsonl',
1085
1269
  protocolVersion: REMOTE_PROTOCOL_VERSION,
1270
+ agentProtocol: REMOTE_AGENT_PROTOCOL,
1271
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
1272
+ frameModes: getSupportedRemoteFrameModeProfiles(),
1086
1273
  heartbeatMs,
1087
1274
  taskTimeoutMs,
1088
1275
  managerPackage,
@@ -1180,6 +1367,7 @@ export function createRemoteHub(options = {}) {
1180
1367
 
1181
1368
  function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
1182
1369
  const now = new Date().toISOString();
1370
+ const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
1183
1371
  const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
1184
1372
  + Number(device?.counters?.liveFramesReceived || 0)
1185
1373
  + 1;
@@ -1192,6 +1380,14 @@ export function createRemoteHub(options = {}) {
1192
1380
  mimeType: 'image/png',
1193
1381
  format: 'image/png',
1194
1382
  mode,
1383
+ frameMode: descriptor.frameMode,
1384
+ frameProfile: descriptor.frameProfile,
1385
+ encoding: descriptor.encoding,
1386
+ codec: 'png',
1387
+ compression: 'image/png',
1388
+ transferProtocol: descriptor.transferProtocol,
1389
+ transferProtocolVersion: descriptor.transferProtocolVersion,
1390
+ handshake: descriptor.handshake,
1195
1391
  fps: clampNumber(options.fps, 1, 24, mode === 'remote-fast' ? 20 : 1),
1196
1392
  capturedAt: now,
1197
1393
  receivedAt: now,
@@ -1236,7 +1432,7 @@ export function createRemoteHub(options = {}) {
1236
1432
  return null;
1237
1433
  }
1238
1434
 
1239
- const frame = makeSyntheticFrame(device, streamId || device.activeLiveStream.streamId, 'remote-fast', {
1435
+ const frame = makeSyntheticFrame(device, streamId || device.activeLiveStream.streamId, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE, {
1240
1436
  commandId: options.commandId || device.activeLiveStream.commandId,
1241
1437
  width: options.maxWidth || 960,
1242
1438
  height: options.maxHeight || 540,
@@ -1281,6 +1477,63 @@ export function createRemoteHub(options = {}) {
1281
1477
  existing.socket.destroy();
1282
1478
  }
1283
1479
 
1480
+ function getDeviceActivityMs(device) {
1481
+ return Math.max(
1482
+ Date.parse(device?.lastSeenAt || '') || 0,
1483
+ Date.parse(device?.lastStatusAt || '') || 0,
1484
+ Date.parse(device?.connectedAt || '') || 0
1485
+ );
1486
+ }
1487
+
1488
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
1489
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
1490
+ return false;
1491
+ }
1492
+
1493
+ const lastActivityMs = getDeviceActivityMs(device);
1494
+ if (!lastActivityMs) {
1495
+ return true;
1496
+ }
1497
+
1498
+ const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
1499
+ return nowMs - lastActivityMs <= freshnessMs;
1500
+ }
1501
+
1502
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
1503
+ const retryAfterMs = Math.max(DUPLICATE_DEVICE_LOG_THROTTLE_MS, Math.min(30000, heartbeatMs));
1504
+ writeJsonLine(socket, {
1505
+ type: 'disconnect',
1506
+ reason: 'duplicate-device-active',
1507
+ retryAfterMs,
1508
+ activeSessionId: existing.sessionId,
1509
+ attemptedSessionId
1510
+ });
1511
+
1512
+ existing.counters.duplicateConnectionsRejected = (existing.counters.duplicateConnectionsRejected || 0) + 1;
1513
+ const nowMs = Date.now();
1514
+ const lastLoggedAt = duplicateDeviceLogAt.get(existing.deviceId) || 0;
1515
+ if (nowMs - lastLoggedAt >= DUPLICATE_DEVICE_LOG_THROTTLE_MS) {
1516
+ duplicateDeviceLogAt.set(existing.deviceId, nowMs);
1517
+ logWarn(
1518
+ 'remote',
1519
+ `duplicate device connection suppressed ${existing.deviceName} (${existing.deviceId}); keeping active session ${existing.sessionId}`);
1520
+ }
1521
+
1522
+ try {
1523
+ socket.end?.();
1524
+ } catch {
1525
+ // Best-effort graceful close before hard destroy.
1526
+ }
1527
+
1528
+ setTimeout(() => {
1529
+ try {
1530
+ socket.destroy?.();
1531
+ } catch {
1532
+ // Ignore destroy failures.
1533
+ }
1534
+ }, 25).unref?.();
1535
+ }
1536
+
1284
1537
  function clearSyntheticFleet() {
1285
1538
  let removed = 0;
1286
1539
  for (const [deviceId, device] of devices.entries()) {
@@ -1422,11 +1675,23 @@ export function createRemoteHub(options = {}) {
1422
1675
  }
1423
1676
 
1424
1677
  if (isLive) {
1678
+ const transfer = buildRemoteFrameTransferDescriptor(DEFAULT_REMOTE_FRAME_MODE);
1425
1679
  device.activeLiveStream = {
1426
1680
  streamId: `synthetic-live-${ordinal}`,
1427
1681
  commandId: `synthetic-live-command-${ordinal}`,
1428
1682
  active: true,
1429
- mode: 'remote-fast',
1683
+ open: true,
1684
+ openedAt: seenAt,
1685
+ openTransport: 'synthetic',
1686
+ mode: transfer.mode,
1687
+ frameMode: transfer.frameMode,
1688
+ frameProfile: transfer.frameProfile,
1689
+ encoding: transfer.encoding,
1690
+ codec: transfer.codec,
1691
+ compression: transfer.compression,
1692
+ transferProtocol: transfer.transferProtocol,
1693
+ transferProtocolVersion: transfer.transferProtocolVersion,
1694
+ handshake: transfer.handshake,
1430
1695
  fps: 20,
1431
1696
  startedAt: seenAt,
1432
1697
  stoppedAt: '',
@@ -1470,12 +1735,37 @@ export function createRemoteHub(options = {}) {
1470
1735
  }
1471
1736
 
1472
1737
  function attachDevice(socket, hello) {
1473
- const now = new Date().toISOString();
1738
+ const nowMs = Date.now();
1739
+ const now = new Date(nowMs).toISOString();
1474
1740
  const sessionId = crypto.randomUUID();
1475
1741
  const deviceId = normalizeDeviceId(hello.deviceId);
1742
+ const existing = devices.get(deviceId);
1743
+
1744
+ if (isDeviceConnectionFresh(existing, nowMs)) {
1745
+ rejectDuplicateActiveDevice(socket, existing, sessionId);
1746
+ return null;
1747
+ }
1476
1748
 
1477
1749
  closeExistingDeviceSocket(deviceId, sessionId);
1478
1750
 
1751
+ const frameProtocol = typeof hello.frameProtocol === 'object' && hello.frameProtocol
1752
+ ? { ...hello.frameProtocol }
1753
+ : null;
1754
+ const frameModes = Array.isArray(hello.frameModes)
1755
+ ? hello.frameModes.map(normalizeIncomingFrameModeProfile).filter(Boolean)
1756
+ : [];
1757
+ const capabilities = typeof hello.capabilities === 'object' && hello.capabilities
1758
+ ? { ...hello.capabilities }
1759
+ : {};
1760
+ if (!capabilities.frameProtocol) {
1761
+ capabilities.frameProtocol = frameProtocol || buildRemoteFrameProtocolDescriptor();
1762
+ }
1763
+ if (!capabilities.frameModes) {
1764
+ capabilities.frameModes = frameModes.length > 0
1765
+ ? frameModes.map(profile => ({ ...profile }))
1766
+ : getSupportedRemoteFrameModeProfiles();
1767
+ }
1768
+
1479
1769
  const device = {
1480
1770
  socket,
1481
1771
  deviceId,
@@ -1486,9 +1776,11 @@ export function createRemoteHub(options = {}) {
1486
1776
  arch: safeString(hello.arch, 40),
1487
1777
  pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
1488
1778
  agentVersion: safeString(hello.agentVersion, 40),
1489
- capabilities: typeof hello.capabilities === 'object' && hello.capabilities
1490
- ? { ...hello.capabilities }
1491
- : {},
1779
+ protocol: safeString(hello.protocol, 80) || REMOTE_AGENT_PROTOCOL,
1780
+ protocolVersion: Number.isFinite(Number(hello.protocolVersion)) ? Math.max(1, Math.floor(Number(hello.protocolVersion))) : 1,
1781
+ frameProtocol: frameProtocol || buildRemoteFrameProtocolDescriptor(),
1782
+ frameModes: frameModes.length > 0 ? frameModes : getSupportedRemoteFrameModeProfiles(),
1783
+ capabilities,
1492
1784
  connected: true,
1493
1785
  connectedAt: now,
1494
1786
  disconnectedAt: '',
@@ -1517,10 +1809,13 @@ export function createRemoteHub(options = {}) {
1517
1809
  sockets.set(socket, deviceId);
1518
1810
  writeJsonLine(socket, {
1519
1811
  type: 'welcome',
1812
+ protocol: REMOTE_AGENT_PROTOCOL,
1520
1813
  protocolVersion: REMOTE_PROTOCOL_VERSION,
1521
1814
  sessionId,
1522
1815
  deviceId,
1523
1816
  heartbeatMs,
1817
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
1818
+ frameModes: getSupportedRemoteFrameModeProfiles(),
1524
1819
  serverTime: now
1525
1820
  });
1526
1821
 
@@ -1956,6 +2251,7 @@ export function createRemoteHub(options = {}) {
1956
2251
  const payload = buildFramePayloadBuffer(framePayload, frameData);
1957
2252
  const contentHash = buildFrameContentHash(message, payload);
1958
2253
  const sameContentStreak = computeSameContentStreak(device.latestThumbnail, contentHash);
2254
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, 'thumbnail');
1959
2255
  device.latestThumbnail = {
1960
2256
  streamId: safeString(message.streamId, 128) || 'thumbnail',
1961
2257
  frameSeq,
@@ -1964,6 +2260,14 @@ export function createRemoteHub(options = {}) {
1964
2260
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
1965
2261
  mimeType,
1966
2262
  format: mimeType,
2263
+ frameMode: transfer.frameMode,
2264
+ frameProfile: transfer.frameProfile,
2265
+ encoding: transfer.encoding,
2266
+ codec: transfer.codec,
2267
+ compression: transfer.compression || mimeType,
2268
+ transferProtocol: transfer.transferProtocol,
2269
+ transferProtocolVersion: transfer.transferProtocolVersion,
2270
+ handshake: transfer.handshake,
1967
2271
  capturedAt,
1968
2272
  receivedAt: device.lastSeenAt,
1969
2273
  byteLength,
@@ -1988,6 +2292,49 @@ export function createRemoteHub(options = {}) {
1988
2292
  return true;
1989
2293
  }
1990
2294
 
2295
+ function applyLiveStreamOpen(device, message, transport = 'json') {
2296
+ const streamId = safeString(message.streamId, 128) || 'live';
2297
+ if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
2298
+ emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
2299
+ reason: 'stale-live-stream-open',
2300
+ streamId,
2301
+ transport
2302
+ });
2303
+ return false;
2304
+ }
2305
+
2306
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
2307
+ const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
2308
+ device.activeLiveStream = {
2309
+ ...device.activeLiveStream,
2310
+ open: true,
2311
+ openedAt,
2312
+ openTransport: transport,
2313
+ mode: transfer.mode,
2314
+ frameMode: transfer.frameMode,
2315
+ frameProfile: transfer.frameProfile,
2316
+ encoding: transfer.encoding,
2317
+ codec: transfer.codec,
2318
+ compression: transfer.compression,
2319
+ transferProtocol: transfer.transferProtocol,
2320
+ transferProtocolVersion: transfer.transferProtocolVersion,
2321
+ handshake: transfer.handshake,
2322
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(device.activeLiveStream.width || 0),
2323
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(device.activeLiveStream.height || 0),
2324
+ openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(device.activeLiveStream.openedFrameSeq || 0)
2325
+ };
2326
+ emitRemoteEvent('RemoteLiveStreamOpened', device, {
2327
+ streamId,
2328
+ mode: transfer.mode,
2329
+ frameMode: transfer.frameMode,
2330
+ frameProfile: transfer.frameProfile,
2331
+ encoding: transfer.encoding,
2332
+ compression: transfer.compression,
2333
+ transport
2334
+ });
2335
+ return true;
2336
+ }
2337
+
1991
2338
  function applyLiveFrame(device, message, framePayload, transport = 'json-base64') {
1992
2339
  const frameData = Buffer.isBuffer(framePayload)
1993
2340
  ? ''
@@ -2024,6 +2371,21 @@ export function createRemoteHub(options = {}) {
2024
2371
  const payload = buildFramePayloadBuffer(framePayload, frameData);
2025
2372
  const contentHash = buildFrameContentHash(message, payload);
2026
2373
  const sameContentStreak = computeSameContentStreak(device.latestLiveFrame, contentHash);
2374
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
2375
+ if (device.activeLiveStream.open !== true) {
2376
+ device.activeLiveStream.open = true;
2377
+ device.activeLiveStream.openedAt = device.lastSeenAt;
2378
+ device.activeLiveStream.openTransport = `${transport}-implicit`;
2379
+ device.activeLiveStream.mode = transfer.mode;
2380
+ device.activeLiveStream.frameMode = transfer.frameMode;
2381
+ device.activeLiveStream.frameProfile = transfer.frameProfile;
2382
+ device.activeLiveStream.encoding = transfer.encoding;
2383
+ device.activeLiveStream.codec = transfer.codec;
2384
+ device.activeLiveStream.compression = transfer.compression;
2385
+ device.activeLiveStream.transferProtocol = transfer.transferProtocol;
2386
+ device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
2387
+ device.activeLiveStream.handshake = transfer.handshake;
2388
+ }
2027
2389
  device.latestLiveFrame = {
2028
2390
  streamId,
2029
2391
  frameSeq,
@@ -2032,7 +2394,15 @@ export function createRemoteHub(options = {}) {
2032
2394
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
2033
2395
  mimeType,
2034
2396
  format: mimeType,
2035
- mode: safeString(message.mode || device.activeLiveStream.mode || 'remote-fast', 80),
2397
+ mode: transfer.mode,
2398
+ frameMode: transfer.frameMode,
2399
+ frameProfile: transfer.frameProfile,
2400
+ encoding: transfer.encoding,
2401
+ codec: transfer.codec,
2402
+ compression: transfer.compression || mimeType,
2403
+ transferProtocol: transfer.transferProtocol,
2404
+ transferProtocolVersion: transfer.transferProtocolVersion,
2405
+ handshake: transfer.handshake,
2036
2406
  fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : device.activeLiveStream.fps,
2037
2407
  capturedAt,
2038
2408
  receivedAt: device.lastSeenAt,
@@ -2056,6 +2426,15 @@ export function createRemoteHub(options = {}) {
2056
2426
  device.activeLiveStream.lastFrameAt = device.lastSeenAt;
2057
2427
  device.activeLiveStream.lastFrameSeq = frameSeq;
2058
2428
  device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
2429
+ device.activeLiveStream.mode = transfer.mode;
2430
+ device.activeLiveStream.frameMode = transfer.frameMode;
2431
+ device.activeLiveStream.frameProfile = transfer.frameProfile;
2432
+ device.activeLiveStream.encoding = transfer.encoding;
2433
+ device.activeLiveStream.codec = transfer.codec;
2434
+ device.activeLiveStream.compression = transfer.compression;
2435
+ device.activeLiveStream.transferProtocol = transfer.transferProtocol;
2436
+ device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
2437
+ device.activeLiveStream.handshake = transfer.handshake;
2059
2438
  device.counters.liveFramesReceived += 1;
2060
2439
  emitRemoteEvent('RemoteFrameReceived', device, {
2061
2440
  streamId,
@@ -2116,8 +2495,13 @@ export function createRemoteHub(options = {}) {
2116
2495
  return;
2117
2496
  }
2118
2497
 
2498
+ const device = attachDevice(socket, message);
2499
+ if (!device) {
2500
+ return;
2501
+ }
2502
+
2119
2503
  state.authenticated = true;
2120
- state.device = attachDevice(socket, message);
2504
+ state.device = device;
2121
2505
  return;
2122
2506
  }
2123
2507
 
@@ -2165,6 +2549,10 @@ export function createRemoteHub(options = {}) {
2165
2549
  applyThumbnailFrame(device, message, message.data, 'json-base64');
2166
2550
  break;
2167
2551
  }
2552
+ case 'stream.open': {
2553
+ applyLiveStreamOpen(device, message, 'json');
2554
+ break;
2555
+ }
2168
2556
  case 'stream.frame': {
2169
2557
  applyLiveFrame(device, message, message.data, 'json-base64');
2170
2558
  break;
@@ -2823,12 +3211,24 @@ export function createRemoteHub(options = {}) {
2823
3211
  const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
2824
3212
  const fps = clampNumber(options.fps, 1, 24, 20);
2825
3213
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3214
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
2826
3215
  device.counters.commandsSent += 1;
2827
3216
  device.activeLiveStream = {
2828
3217
  streamId,
2829
3218
  commandId,
2830
3219
  active: true,
2831
- mode: 'remote-fast',
3220
+ open: true,
3221
+ openedAt: now,
3222
+ openTransport: 'synthetic',
3223
+ mode: transfer.mode,
3224
+ frameMode: transfer.frameMode,
3225
+ frameProfile: transfer.frameProfile,
3226
+ encoding: transfer.encoding,
3227
+ codec: transfer.codec,
3228
+ compression: transfer.compression,
3229
+ transferProtocol: transfer.transferProtocol,
3230
+ transferProtocolVersion: transfer.transferProtocolVersion,
3231
+ handshake: transfer.handshake,
2832
3232
  fps,
2833
3233
  startedAt: now,
2834
3234
  stoppedAt: '',
@@ -2848,9 +3248,10 @@ export function createRemoteHub(options = {}) {
2848
3248
  streamId,
2849
3249
  commandId,
2850
3250
  fps,
3251
+ mode: transfer.mode,
2851
3252
  synthetic: true
2852
3253
  });
2853
- return { ok: true, commandId, streamId, fps, synthetic: true };
3254
+ return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, synthetic: true };
2854
3255
  }
2855
3256
 
2856
3257
  if (!device?.socket || device.socket.destroyed || !device.connected) {
@@ -2864,12 +3265,21 @@ export function createRemoteHub(options = {}) {
2864
3265
  const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
2865
3266
  const fps = clampNumber(options.fps, 1, 24, 12);
2866
3267
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3268
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
2867
3269
  const result = sendCommand(deviceId, {
2868
3270
  command: 'stream.start',
2869
3271
  commandId,
2870
3272
  payload: {
2871
3273
  streamId,
2872
- mode: safeString(options.mode, 80) || 'remote-fast',
3274
+ mode: transfer.mode,
3275
+ frameMode: transfer.frameMode,
3276
+ frameProfile: transfer.frameProfile,
3277
+ encoding: transfer.encoding,
3278
+ codec: transfer.codec,
3279
+ compression: transfer.compression,
3280
+ transferProtocol: transfer.transferProtocol,
3281
+ transferProtocolVersion: transfer.transferProtocolVersion,
3282
+ handshake: transfer.handshake,
2873
3283
  fps,
2874
3284
  maxWidth: clampNumber(options.maxWidth, 320, 2560, 960),
2875
3285
  maxHeight: clampNumber(options.maxHeight, 180, 1440, 540),
@@ -2886,7 +3296,18 @@ export function createRemoteHub(options = {}) {
2886
3296
  streamId,
2887
3297
  commandId,
2888
3298
  active: true,
2889
- mode: 'remote-fast',
3299
+ open: false,
3300
+ openedAt: '',
3301
+ openTransport: '',
3302
+ mode: transfer.mode,
3303
+ frameMode: transfer.frameMode,
3304
+ frameProfile: transfer.frameProfile,
3305
+ encoding: transfer.encoding,
3306
+ codec: transfer.codec,
3307
+ compression: transfer.compression,
3308
+ transferProtocol: transfer.transferProtocol,
3309
+ transferProtocolVersion: transfer.transferProtocolVersion,
3310
+ handshake: transfer.handshake,
2890
3311
  fps,
2891
3312
  startedAt: now,
2892
3313
  stoppedAt: '',
@@ -2899,10 +3320,12 @@ export function createRemoteHub(options = {}) {
2899
3320
  emitRemoteEvent('RemoteLiveStreamStarted', device, {
2900
3321
  streamId,
2901
3322
  commandId,
2902
- fps
3323
+ fps,
3324
+ mode: transfer.mode,
3325
+ frameMode: transfer.frameMode
2903
3326
  });
2904
3327
 
2905
- return { ok: true, commandId, streamId, fps };
3328
+ return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode };
2906
3329
  }
2907
3330
 
2908
3331
  function stopLiveStream(deviceId, options = {}) {
@@ -88,6 +88,25 @@ try {
88
88
  pid: process.pid,
89
89
  agentVersion: '0.0.0-ws-smoke',
90
90
  runtime: 'node-smoke',
91
+ protocol: 'mindexec.remote.agent',
92
+ protocolVersion: 2,
93
+ frameProtocol: {
94
+ protocol: 'mindexec.remote.frame.v2',
95
+ version: 2,
96
+ handshake: 'smartview-request-open-frame',
97
+ defaultMode: 'remote-fast'
98
+ },
99
+ frameModes: [{
100
+ mode: 'remote-fast',
101
+ label: 'RemoteFast JPEG Binary',
102
+ transport: 'ws-binary',
103
+ encoding: 'image',
104
+ codec: 'jpeg',
105
+ compression: 'image/jpeg',
106
+ profile: 'jpeg-binary-v1',
107
+ modeVersion: 1,
108
+ implemented: true
109
+ }],
91
110
  capabilities: {
92
111
  status: true,
93
112
  thumbnail: true,
@@ -109,13 +128,20 @@ try {
109
128
  }
110
129
  }));
111
130
 
112
- await waitFor(() => received.find(item => item.type === 'welcome'), 5000, 'welcome');
131
+ const welcome = await waitFor(() => received.find(item => item.type === 'welcome'), 5000, 'welcome');
132
+ assert.equal(welcome.protocol, 'mindexec.remote.agent');
133
+ assert.equal(welcome.frameProtocol?.handshake, 'smartview-request-open-frame');
134
+ assert.equal(welcome.frameProtocol?.defaultMode, 'remote-fast');
135
+ assert.ok(welcome.frameModes?.some(item => item.mode === 'remote-fast'));
113
136
  const device = await waitFor(() => {
114
137
  const current = hub.listDevices();
115
138
  return current.length === 1 && current[0].deviceId === DEVICE_ID ? current[0] : null;
116
139
  }, 5000, 'device registration');
117
140
  assert.equal(device.connected, true);
118
141
  assert.equal(device.capabilities.binaryFrames, true);
142
+ assert.equal(device.protocol, 'mindexec.remote.agent');
143
+ assert.equal(device.frameProtocol?.handshake, 'smartview-request-open-frame');
144
+ assert.ok(device.frameModes?.some(item => item.mode === 'remote-fast'));
119
145
 
120
146
  const inputCommand = hub.sendInputControl(DEVICE_ID, { type: 'noop' });
121
147
  assert.equal(inputCommand.ok, true);
@@ -170,10 +196,36 @@ try {
170
196
  quality: 50
171
197
  });
172
198
  assert.equal(liveCommand.ok, true);
173
- await waitFor(
199
+ const liveMessage = await waitFor(
174
200
  () => received.find(item => item.commandId === liveCommand.commandId),
175
201
  5000,
176
202
  'live command');
203
+ assert.equal(liveMessage.command, 'stream.start');
204
+ assert.equal(liveMessage.payload?.handshake, 'smartview-request-open-frame');
205
+ assert.equal(liveMessage.payload?.mode, 'remote-fast');
206
+ assert.equal(liveMessage.payload?.frameMode, 'remote-fast');
207
+ assert.equal(liveMessage.payload?.frameProfile, 'jpeg-binary-v1');
208
+ assert.equal(liveMessage.payload?.compression, 'image/jpeg');
209
+ assert.equal(liveMessage.payload?.transferProtocol, 'mindexec.remote.frame.v2');
210
+ ws.send(JSON.stringify({
211
+ type: 'stream.open',
212
+ commandId: liveCommand.commandId,
213
+ streamId: 'remote-agent-ws-live',
214
+ mode: 'remote-fast',
215
+ frameMode: 'remote-fast',
216
+ frameProfile: 'jpeg-binary-v1',
217
+ encoding: 'image',
218
+ codec: 'png',
219
+ compression: 'image/png',
220
+ transferProtocol: 'mindexec.remote.frame.v2',
221
+ transferProtocolVersion: 2,
222
+ handshake: 'smartview-request-open-frame',
223
+ fps: 10,
224
+ width: 2,
225
+ height: 1,
226
+ openedAt: new Date().toISOString()
227
+ }));
228
+ await waitFor(() => hub.listDevices()[0]?.activeLiveStream?.open === true, 5000, 'live stream open');
177
229
  writeAgentBinaryFrame(ws, {
178
230
  frameKind: 'stream',
179
231
  commandId: liveCommand.commandId,
@@ -182,6 +234,15 @@ try {
182
234
  width: 2,
183
235
  height: 1,
184
236
  mimeType: 'image/png',
237
+ mode: 'remote-fast',
238
+ frameMode: 'remote-fast',
239
+ frameProfile: 'jpeg-binary-v1',
240
+ encoding: 'image',
241
+ codec: 'png',
242
+ compression: 'image/png',
243
+ transferProtocol: 'mindexec.remote.frame.v2',
244
+ transferProtocolVersion: 2,
245
+ handshake: 'smartview-request-open-frame',
185
246
  fps: 10,
186
247
  capturedAt: new Date().toISOString()
187
248
  }, SMOKE_PNG);
@@ -191,6 +252,12 @@ try {
191
252
  }, 5000, 'websocket binary live frame');
192
253
  assert.equal(liveDevice.latestLiveFrame.transport, 'binary');
193
254
  assert.equal(liveDevice.latestLiveFrame.streamId, 'remote-agent-ws-live');
255
+ assert.equal(liveDevice.activeLiveStream.open, true);
256
+ assert.equal(liveDevice.activeLiveStream.frameMode, 'remote-fast');
257
+ assert.equal(liveDevice.latestLiveFrame.frameMode, 'remote-fast');
258
+ assert.equal(liveDevice.latestLiveFrame.frameProfile, 'jpeg-binary-v1');
259
+ assert.equal(liveDevice.latestLiveFrame.compression, 'image/png');
260
+ assert.equal(liveDevice.latestLiveFrame.transferProtocol, 'mindexec.remote.frame.v2');
194
261
 
195
262
  ws.close();
196
263
  console.log('RemoteAgent WebSocket smoke OK');
@@ -85,6 +85,9 @@ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, label, traceFra
85
85
  BRIDGE_REQUIRE_TOKEN: '1',
86
86
  MINDEXEC_REMOTE_HUB: '1',
87
87
  MINDEXEC_REMOTE_TRACE_FRAMES: traceFrames ? '1' : '',
88
+ MINDEXEC_REMOTE_REGISTRY_FOLLOWER: '0',
89
+ MINDEXEC_REMOTE_REGISTRY_REALTIME: '0',
90
+ MINDEXEC_REMOTE_HOST_TARGET_AUTO_RENEW: '0',
88
91
  REMOTE_HUB_HOST: '127.0.0.1',
89
92
  REMOTE_HUB_PORT: String(remoteHubPort),
90
93
  REMOTE_HUB_PAIR_TOKEN: PAIR_TOKEN,
@@ -214,6 +214,23 @@ try {
214
214
  quality: 50
215
215
  });
216
216
  assert.equal(liveCommand.ok, true);
217
+ assert.equal(liveCommand.frameMode, 'remote-fast');
218
+ writeJsonLine(socket, {
219
+ type: 'stream.open',
220
+ commandId: liveCommand.commandId,
221
+ streamId: 'smoke-live',
222
+ mode: 'remote-fast',
223
+ frameMode: 'remote-fast',
224
+ frameProfile: 'jpeg-binary-v1',
225
+ encoding: 'image',
226
+ codec: 'png',
227
+ compression: 'image/png',
228
+ transferProtocol: 'mindexec.remote.frame.v2',
229
+ transferProtocolVersion: 2,
230
+ handshake: 'smartview-request-open-frame',
231
+ fps: 5,
232
+ openedAt: new Date().toISOString()
233
+ });
217
234
  writeJsonLine(socket, {
218
235
  type: 'stream.frame',
219
236
  commandId: liveCommand.commandId,
@@ -223,6 +240,14 @@ try {
223
240
  height: 1,
224
241
  mimeType: 'image/png',
225
242
  mode: 'remote-fast',
243
+ frameMode: 'remote-fast',
244
+ frameProfile: 'jpeg-binary-v1',
245
+ encoding: 'image',
246
+ codec: 'png',
247
+ compression: 'image/png',
248
+ transferProtocol: 'mindexec.remote.frame.v2',
249
+ transferProtocolVersion: 2,
250
+ handshake: 'smartview-request-open-frame',
226
251
  fps: 5,
227
252
  capturedAt: new Date().toISOString(),
228
253
  data: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'
@@ -233,7 +258,11 @@ try {
233
258
  return current[0]?.latestLiveFrame?.streamId === 'smoke-live' ? current[0] : null;
234
259
  });
235
260
  assert.equal(liveDevice.activeLiveStream.active, true);
261
+ assert.equal(liveDevice.activeLiveStream.open, true);
262
+ assert.equal(liveDevice.activeLiveStream.handshake, 'smartview-request-open-frame');
236
263
  assert.equal(liveDevice.latestLiveFrame.mode, 'remote-fast');
264
+ assert.equal(liveDevice.latestLiveFrame.frameMode, 'remote-fast');
265
+ assert.equal(liveDevice.latestLiveFrame.compression, 'image/png');
237
266
  assert.equal(liveDevice.counters.liveFramesReceived, 1);
238
267
  const serializedFirstLiveFrame = hub.getDeviceLiveFrame('smoke-device', { includeDataUrl: false });
239
268
  const serializedFirstLiveUrl = new URL(serializedFirstLiveFrame.framePath, 'http://127.0.0.1');
@@ -247,6 +276,14 @@ try {
247
276
  height: 1,
248
277
  mimeType: 'image/png',
249
278
  mode: 'remote-fast',
279
+ frameMode: 'remote-fast',
280
+ frameProfile: 'jpeg-binary-v1',
281
+ encoding: 'image',
282
+ codec: 'png',
283
+ compression: 'image/png',
284
+ transferProtocol: 'mindexec.remote.frame.v2',
285
+ transferProtocolVersion: 2,
286
+ handshake: 'smartview-request-open-frame',
250
287
  fps: 5,
251
288
  capturedAt: new Date().toISOString()
252
289
  }, smokePngFrame);
@@ -265,6 +302,8 @@ try {
265
302
  const serializedBinaryLiveFrame = hub.getDeviceLiveFrame('smoke-device', { includeDataUrl: false });
266
303
  assert.equal(serializedBinaryLiveFrame.streamId, 'smoke-live');
267
304
  assert.equal(serializedBinaryLiveFrame.frameSeq, 5);
305
+ assert.equal(serializedBinaryLiveFrame.frameMode, 'remote-fast');
306
+ assert.equal(serializedBinaryLiveFrame.compression, 'image/png');
268
307
  assert.equal(serializedBinaryLiveFrame.contentHash, binaryLiveDevice.latestLiveFrame.contentHash);
269
308
  assert.ok(serializedBinaryLiveFrame.framePath.includes('/api/remote/devices/smoke-device/live/frame?'));
270
309
  assert.ok(!('dataUrl' in serializedBinaryLiveFrame));
@@ -515,6 +554,78 @@ try {
515
554
  });
516
555
  assert.equal(staleSessionTask.ok, true);
517
556
  const oldSessionId = lateTimedOutDevice.sessionId;
557
+
558
+ const duplicateSocket = net.createConnection({ host: '127.0.0.1', port: status.port });
559
+ duplicateSocket.setEncoding('utf8');
560
+ const duplicateMessages = [];
561
+ let duplicateBuffer = '';
562
+ await new Promise((resolve, reject) => {
563
+ duplicateSocket.once('connect', resolve);
564
+ duplicateSocket.once('error', reject);
565
+ });
566
+ duplicateSocket.on('error', () => { });
567
+ duplicateSocket.on('data', chunk => {
568
+ duplicateBuffer += chunk;
569
+ let lineBreakIndex = duplicateBuffer.indexOf('\n');
570
+ while (lineBreakIndex >= 0) {
571
+ const line = duplicateBuffer.slice(0, lineBreakIndex).trim();
572
+ duplicateBuffer = duplicateBuffer.slice(lineBreakIndex + 1);
573
+ if (line) {
574
+ duplicateMessages.push(JSON.parse(line));
575
+ }
576
+ lineBreakIndex = duplicateBuffer.indexOf('\n');
577
+ }
578
+ });
579
+ writeJsonLine(duplicateSocket, {
580
+ type: 'hello',
581
+ pairToken: 'smoke-token',
582
+ deviceId: 'smoke-device',
583
+ deviceName: 'Smoke Device Duplicate',
584
+ hostname: 'smoke-host-duplicate',
585
+ platform: process.platform,
586
+ arch: process.arch,
587
+ pid: process.pid,
588
+ agentVersion: '0.0.0-smoke-duplicate',
589
+ capabilities: {
590
+ status: true,
591
+ thumbnail: false,
592
+ control: false,
593
+ liveStream: true,
594
+ computerAgent: true,
595
+ taskDispatch: true,
596
+ aiAssist: false
597
+ }
598
+ });
599
+
600
+ const duplicateDisconnect = await waitFor(() => (
601
+ duplicateMessages.find(message => message?.type === 'disconnect')
602
+ ), 1000);
603
+ assert.equal(duplicateDisconnect.reason, 'duplicate-device-active');
604
+ assert.equal(duplicateDisconnect.activeSessionId, oldSessionId);
605
+ assert.equal(duplicateDisconnect.retryAfterMs >= 1000, true);
606
+ await wait(100);
607
+ const duplicateRejectedDevice = hub.listDevices()[0];
608
+ assert.equal(duplicateRejectedDevice.sessionId, oldSessionId);
609
+ assert.equal(duplicateRejectedDevice.deviceName, 'Smoke Device');
610
+ assert.equal(duplicateRejectedDevice.connected, true);
611
+ assert.equal(duplicateRejectedDevice.counters.duplicateConnectionsRejected, 1);
612
+ assert.equal(duplicateRejectedDevice.latestTask.taskId, staleSessionTask.taskId);
613
+ duplicateSocket.destroy();
614
+
615
+ socket.destroy();
616
+ const disconnectedDevice = await waitFor(() => {
617
+ const current = hub.listDevices();
618
+ return current.length === 1
619
+ && current[0]?.sessionId === oldSessionId
620
+ && current[0]?.connected === false
621
+ ? current[0]
622
+ : null;
623
+ });
624
+ assert.equal(disconnectedDevice.latestTask.status, 'failed');
625
+ assert.equal(
626
+ ['device-disconnected', 'task-timeout'].includes(disconnectedDevice.latestTask.error),
627
+ true);
628
+
518
629
  const replacementSocket = net.createConnection({ host: '127.0.0.1', port: status.port });
519
630
  replacementSocket.setEncoding('utf8');
520
631
  await new Promise((resolve, reject) => {
@@ -565,25 +676,6 @@ try {
565
676
  assert.equal(reconnectedDevice.latestTask, null);
566
677
  assert.equal(reconnectedDevice.counters.taskResultsReceived, 0);
567
678
 
568
- writeJsonLine(socket, {
569
- type: 'status',
570
- status: {
571
- uptimeSec: 999,
572
- totalMem: 999,
573
- freeMem: 0
574
- }
575
- });
576
- writeJsonLine(socket, {
577
- type: 'command.result',
578
- commandId: staleSessionTask.commandId,
579
- result: {
580
- kind: 'agent.task',
581
- taskId: staleSessionTask.taskId,
582
- status: 'completed',
583
- summary: 'This old-session result must not mutate the reconnected device.',
584
- completedAt: new Date().toISOString()
585
- }
586
- });
587
679
  await wait(100);
588
680
  const staleSessionIgnoredDevice = hub.listDevices()[0];
589
681
  assert.equal(staleSessionIgnoredDevice.sessionId, reconnectedDevice.sessionId);
@@ -592,7 +684,6 @@ try {
592
684
  assert.equal(staleSessionIgnoredDevice.latestTask, null);
593
685
  assert.equal(staleSessionIgnoredDevice.counters.taskResultsReceived, 0);
594
686
 
595
- socket.destroy();
596
687
  replacementSocket.destroy();
597
688
  await waitFor(() => hub.listDevices()[0]?.connected === false);
598
689
  console.log('RemoteHub smoke OK');
package/server.js CHANGED
@@ -2550,12 +2550,14 @@ function clampRemoteFrameWsNumber(value, min, max, fallback) {
2550
2550
  }
2551
2551
 
2552
2552
  function normalizeRemoteFrameWsLiveOptions(payload = {}) {
2553
+ const mode = String(payload.frameMode || payload.mode || 'remote-fast').trim() || 'remote-fast';
2553
2554
  return {
2554
2555
  fps: clampRemoteFrameWsNumber(payload.fps, 1, 24, REMOTE_FRAME_WS_DEFAULT_FPS),
2555
2556
  maxWidth: clampRemoteFrameWsNumber(payload.maxWidth, 320, 2560, REMOTE_FRAME_WS_DEFAULT_MAX_WIDTH),
2556
2557
  maxHeight: clampRemoteFrameWsNumber(payload.maxHeight, 180, 1440, REMOTE_FRAME_WS_DEFAULT_MAX_HEIGHT),
2557
2558
  quality: clampRemoteFrameWsNumber(payload.quality, 20, 95, REMOTE_FRAME_WS_DEFAULT_QUALITY),
2558
- mode: String(payload.mode || 'remote-fast').trim() || 'remote-fast'
2559
+ mode,
2560
+ frameMode: mode
2559
2561
  };
2560
2562
  }
2561
2563
 
@@ -2671,6 +2673,14 @@ function buildRemoteFrameBinaryPacket(frameEvent) {
2671
2673
  height: Number(frame.height || 0) || 0,
2672
2674
  mimeType: frameEvent.mimeType || frame.mimeType || frame.format || 'image/jpeg',
2673
2675
  mode: frame.mode || '',
2676
+ frameMode: frame.frameMode || frame.mode || '',
2677
+ frameProfile: frame.frameProfile || '',
2678
+ encoding: frame.encoding || '',
2679
+ codec: frame.codec || '',
2680
+ compression: frame.compression || frame.mimeType || frame.format || '',
2681
+ transferProtocol: frame.transferProtocol || '',
2682
+ transferProtocolVersion: Number(frame.transferProtocolVersion || 0) || 0,
2683
+ handshake: frame.handshake || '',
2674
2684
  fps: Number(frame.fps || 0) || 0,
2675
2685
  capturedAt: frame.capturedAt || '',
2676
2686
  receivedAt: frame.receivedAt || '',