@livedesk/hub 0.1.5 → 0.1.7

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/src/remote-hub.js CHANGED
@@ -24,9 +24,10 @@ const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
24
24
  const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
25
25
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
26
26
  const LIVE_STREAM_PENDING_REUSE_MS = 5000;
27
- const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
28
- const LIVE_STREAM_MIN_FRESH_MS = 3000;
29
- const LIVE_STREAM_MAX_FRESH_MS = 12000;
27
+ const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
28
+ const LIVE_STREAM_MIN_FRESH_MS = 3000;
29
+ const LIVE_STREAM_MAX_FRESH_MS = 12000;
30
+ const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
30
31
  const REMOTE_PROTOCOL_VERSION = 2;
31
32
  const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
32
33
  const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
@@ -58,14 +59,14 @@ const REMOTE_FRAME_MODE_PROFILES = Object.freeze({
58
59
  modeVersion: 1,
59
60
  implemented: true
60
61
  }),
61
- 'mode2-lzo': Object.freeze({
62
- mode: 'mode2-lzo',
63
- label: 'Mode 2 - RGB565 LZO Wall',
62
+ 'mode2-lzo': Object.freeze({
63
+ mode: 'mode2-lzo',
64
+ label: 'Internal Atlas Input - RGB565 LZO',
64
65
  transport: 'ws-binary',
65
66
  encoding: 'image-raw',
66
67
  codec: 'rgb565',
67
68
  compression: 'lzo1x',
68
- profile: 'mode2-lzo-rgb565-v1',
69
+ profile: 'atlas-input-rgb565-lzo-v1',
69
70
  modeVersion: 2,
70
71
  implemented: true
71
72
  }),
@@ -151,7 +152,7 @@ const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
151
152
  const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
152
153
  const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
153
154
  const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
154
- const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 256;
155
+ const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 8;
155
156
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
156
157
  const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 64;
157
158
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
@@ -320,7 +321,7 @@ function isWildcardHost(value) {
320
321
  || host === '';
321
322
  }
322
323
 
323
- function isPrivateIPv4(value) {
324
+ function isPrivateIPv4(value) {
324
325
  const parts = String(value || '').split('.').map(part => Number(part));
325
326
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
326
327
  return false;
@@ -329,9 +330,71 @@ function isPrivateIPv4(value) {
329
330
  return parts[0] === 10
330
331
  || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
331
332
  || (parts[0] === 192 && parts[1] === 168);
332
- }
333
-
334
- function isPublicIPv4(value) {
333
+ }
334
+
335
+ function isPrivateNetworkAddress(value) {
336
+ const address = String(value || '').replace(/^::ffff:/i, '').trim().toLowerCase();
337
+ return isLoopbackHost(address)
338
+ || isPrivateIPv4(address)
339
+ || address.startsWith('fe80:')
340
+ || address.startsWith('fc')
341
+ || address.startsWith('fd');
342
+ }
343
+
344
+ function readCpuTimes() {
345
+ let idle = 0;
346
+ let total = 0;
347
+ for (const cpu of os.cpus()) {
348
+ const times = Object.values(cpu.times || {}).map(Number);
349
+ idle += Number(cpu.times?.idle || 0);
350
+ total += times.reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0);
351
+ }
352
+ return { idle, total };
353
+ }
354
+
355
+ function createHubRuntimeDiagnosticsSampler() {
356
+ let previousCpu = readCpuTimes();
357
+ let previousProcessCpu = process.cpuUsage();
358
+ let previousAt = Date.now();
359
+ return () => {
360
+ const now = Date.now();
361
+ const cpu = readCpuTimes();
362
+ const processCpu = process.cpuUsage();
363
+ const elapsedMs = Math.max(1, now - previousAt);
364
+ const totalDelta = Math.max(0, cpu.total - previousCpu.total);
365
+ const idleDelta = Math.max(0, cpu.idle - previousCpu.idle);
366
+ const processMicros = Math.max(
367
+ 0,
368
+ processCpu.user - previousProcessCpu.user + processCpu.system - previousProcessCpu.system
369
+ );
370
+ const cpuLogicalCores = Math.max(1, os.cpus().length || 1);
371
+ const totalMemoryBytes = Math.max(0, Number(os.totalmem() || 0));
372
+ const freeMemoryBytes = Math.max(0, Number(os.freemem() || 0));
373
+ const snapshot = {
374
+ collectedAt: new Date(now).toISOString(),
375
+ system: {
376
+ cpuLogicalCores,
377
+ cpuUsageRatio: totalDelta > 0
378
+ ? Math.max(0, Math.min(1, 1 - idleDelta / totalDelta))
379
+ : null,
380
+ processCpuRatio: Math.max(0, Math.min(1, processMicros / (elapsedMs * 1000 * cpuLogicalCores))),
381
+ totalMemoryBytes,
382
+ freeMemoryBytes,
383
+ memoryUsedRatio: totalMemoryBytes > 0
384
+ ? Math.max(0, Math.min(1, (totalMemoryBytes - freeMemoryBytes) / totalMemoryBytes))
385
+ : null,
386
+ processMemoryBytes: Math.max(0, Number(process.memoryUsage().rss || 0)),
387
+ uptimeSeconds: Math.max(0, Number(os.uptime() || 0))
388
+ }
389
+ };
390
+ previousCpu = cpu;
391
+ previousProcessCpu = processCpu;
392
+ previousAt = now;
393
+ return snapshot;
394
+ };
395
+ }
396
+
397
+ function isPublicIPv4(value) {
335
398
  const parts = String(value || '').split('.').map(part => Number(part));
336
399
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
337
400
  return false;
@@ -617,11 +680,12 @@ function normalizeRemoteInputEvent(value = {}) {
617
680
  const location = Number(input.location ?? input.Location);
618
681
  const monitorIndex = Number(input.monitorIndex ?? input.MonitorIndex ?? input.screenIndex ?? input.ScreenIndex ?? input.displayIndex ?? input.DisplayIndex);
619
682
  const inputSeq = Number(input.inputSeq ?? input.InputSeq);
620
- const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
621
- const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
622
- return {
623
- type,
624
- monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : 0,
683
+ const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
684
+ const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
685
+ const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
686
+ return {
687
+ type,
688
+ monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : 0,
625
689
  normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
626
690
  normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
627
691
  button: safeString(input.button || input.Button, 24),
@@ -636,9 +700,14 @@ function normalizeRemoteInputEvent(value = {}) {
636
700
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
637
701
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
638
702
  altKey: input.altKey === true || input.AltKey === true,
639
- metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
640
- controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
641
- inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
703
+ metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
704
+ controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
705
+ controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
706
+ controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
707
+ captureGeneration: Number.isSafeInteger(captureGeneration) && captureGeneration > 0 ? captureGeneration : 0,
708
+ hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
709
+ inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
710
+ inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
642
711
  requestAck: input.requestAck !== false && input.RequestAck !== false,
643
712
  issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
644
713
  hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
@@ -920,9 +989,10 @@ function serializeDevice(device, options = {}) {
920
989
  platform: device.platform,
921
990
  arch: device.arch,
922
991
  slotNumber: device.slotNumber || 0,
923
- pid: device.pid,
924
- agentVersion: device.agentVersion,
925
- protocol: device.protocol,
992
+ pid: device.pid,
993
+ agentVersion: device.agentVersion,
994
+ productVersion: device.productVersion,
995
+ protocol: device.protocol,
926
996
  protocolVersion: device.protocolVersion,
927
997
  frameProtocol: device.frameProtocol ? { ...device.frameProtocol } : null,
928
998
  frameModes: Array.isArray(device.frameModes)
@@ -1383,7 +1453,7 @@ class RemoteHubWebSocketAgentSocket {
1383
1453
  }
1384
1454
  }
1385
1455
 
1386
- export function createRemoteHub(options = {}) {
1456
+ export function createRemoteHub(options = {}) {
1387
1457
  const env = options.env || process.env;
1388
1458
  const logEvent = options.logEvent || (() => {});
1389
1459
  const logWarn = options.logWarn || (() => {});
@@ -1396,9 +1466,10 @@ export function createRemoteHub(options = {}) {
1396
1466
  const getWelcomeDevicePolicy = typeof options.getWelcomeDevicePolicy === 'function'
1397
1467
  ? options.getWelcomeDevicePolicy
1398
1468
  : getEffectiveDevicePolicy;
1399
- const udpTransport = options.udpTransport && typeof options.udpTransport === 'object'
1400
- ? options.udpTransport
1401
- : null;
1469
+ const udpTransport = options.udpTransport && typeof options.udpTransport === 'object'
1470
+ ? options.udpTransport
1471
+ : null;
1472
+ const sampleHubRuntimeDiagnostics = createHubRuntimeDiagnosticsSampler();
1402
1473
 
1403
1474
  function getDevicePolicy(device) {
1404
1475
  try {
@@ -1425,11 +1496,16 @@ export function createRemoteHub(options = {}) {
1425
1496
  const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
1426
1497
  const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
1427
1498
  const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
1428
- const taskTimeoutMs = clampNumber(
1429
- options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
1430
- 50,
1431
- 30 * 60 * 1000,
1432
- DEFAULT_AGENT_TASK_TIMEOUT_MS);
1499
+ const taskTimeoutMs = clampNumber(
1500
+ options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
1501
+ 50,
1502
+ 30 * 60 * 1000,
1503
+ DEFAULT_AGENT_TASK_TIMEOUT_MS);
1504
+ const inputAckTimeoutMs = clampNumber(
1505
+ options.inputAckTimeoutMs ?? env.REMOTE_HUB_INPUT_ACK_TIMEOUT_MS,
1506
+ 250,
1507
+ 30000,
1508
+ DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
1433
1509
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1434
1510
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1435
1511
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1750,11 +1826,22 @@ export function createRemoteHub(options = {}) {
1750
1826
  };
1751
1827
  }
1752
1828
 
1753
- function getStatus({ includeSecrets = false } = {}) {
1754
- const connectedDevices = [...devices.values()].filter(device => device.connected).length;
1755
- const activeHostTarget = serializeHostTarget();
1756
- const routeInfo = getAgentEndpointRouteInfo();
1757
- return {
1829
+ function getStatus({ includeSecrets = false } = {}) {
1830
+ const connectedDevices = [...devices.values()].filter(device => device.connected).length;
1831
+ const connectedRoutes = [...devices.values()]
1832
+ .filter(device => device.connected)
1833
+ .map(device => String(device.remoteAddress || '').replace(/^::ffff:/i, '').trim())
1834
+ .filter(Boolean);
1835
+ const externalDeviceCount = connectedRoutes.filter(address => !isPrivateNetworkAddress(address)).length;
1836
+ const activeHostTarget = serializeHostTarget();
1837
+ const routeInfo = getAgentEndpointRouteInfo();
1838
+ const diagnostics = sampleHubRuntimeDiagnostics();
1839
+ diagnostics.network = {
1840
+ lanDeviceCount: connectedRoutes.length - externalDeviceCount,
1841
+ externalDeviceCount,
1842
+ route: externalDeviceCount > 0 ? 'external' : connectedRoutes.length > 0 ? 'lan' : 'idle'
1843
+ };
1844
+ return {
1758
1845
  enabled,
1759
1846
  started,
1760
1847
  host,
@@ -1804,9 +1891,10 @@ export function createRemoteHub(options = {}) {
1804
1891
  sessionCount: 0,
1805
1892
  readySessionCount: 0
1806
1893
  },
1807
- externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
1808
- lastError
1809
- };
1894
+ externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
1895
+ diagnostics,
1896
+ lastError
1897
+ };
1810
1898
  }
1811
1899
 
1812
1900
  function rotatePairingPin() {
@@ -1971,9 +2059,11 @@ export function createRemoteHub(options = {}) {
1971
2059
  + Number(device?.counters?.liveFramesReceived || 0)
1972
2060
  + 1;
1973
2061
  return {
1974
- streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
1975
- frameSeq,
1976
- commandId: safeString(options.commandId, 128),
2062
+ streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
2063
+ frameSeq,
2064
+ commandId: safeString(options.commandId, 128),
2065
+ captureGeneration: Number(options.captureGeneration || 0),
2066
+ currentGenerationVerified: true,
1977
2067
  width: clampNumber(options.width, 2, 3840, mode === 'thumbnail' ? 360 : 640),
1978
2068
  height: clampNumber(options.height, 1, 2160, mode === 'thumbnail' ? 220 : 360),
1979
2069
  mimeType: 'image/png',
@@ -2038,10 +2128,11 @@ export function createRemoteHub(options = {}) {
2038
2128
  commandId: options.commandId || streamState.commandId,
2039
2129
  width: options.maxWidth || 960,
2040
2130
  height: options.maxHeight || 540,
2041
- monitorIndex: options.monitorIndex ?? streamState.monitorIndex ?? 0,
2042
- monitorCount: options.monitorCount ?? streamState.monitorCount ?? 1,
2043
- fps: options.fps || streamState.fps
2044
- });
2131
+ monitorIndex: options.monitorIndex ?? streamState.monitorIndex ?? 0,
2132
+ monitorCount: options.monitorCount ?? streamState.monitorCount ?? 1,
2133
+ fps: options.fps || streamState.fps,
2134
+ captureGeneration: streamState.captureGeneration
2135
+ });
2045
2136
  device.latestLiveFrame = frame;
2046
2137
  rememberRecentFramePayload(device, 'live', frame);
2047
2138
  emitFrame({
@@ -2076,9 +2167,10 @@ export function createRemoteHub(options = {}) {
2076
2167
  return;
2077
2168
  }
2078
2169
 
2079
- existing.lastDisconnectReason = 'replaced-by-new-session';
2080
- failAllPendingTasks(existing, 'replaced-by-new-session');
2081
- writeJsonLine(existing.socket, {
2170
+ existing.lastDisconnectReason = 'replaced-by-new-session';
2171
+ failAllPendingTasks(existing, 'replaced-by-new-session');
2172
+ failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2173
+ writeJsonLine(existing.socket, {
2082
2174
  type: 'disconnect',
2083
2175
  reason: 'replaced-by-new-session',
2084
2176
  nextSessionId
@@ -2313,7 +2405,7 @@ export function createRemoteHub(options = {}) {
2313
2405
  const replace = options.replace !== false;
2314
2406
  const now = new Date();
2315
2407
  const platforms = ['win32', 'linux', 'darwin'];
2316
- const machineKinds = ['Mac mini', 'Mini PC', 'Workstation', 'Render node', 'Dev box'];
2408
+ const machineKinds = ['Mac mini', 'Mini PC', 'Client', 'Render node', 'Dev box'];
2317
2409
  const workloadRoles = ['LLM worker', 'Image worker', 'Video worker', 'Idle reserve', 'Build worker'];
2318
2410
  const gpuNames = ['Apple M-series GPU', 'NVIDIA RTX local', 'Radeon Pro', 'Intel Arc', 'Integrated GPU'];
2319
2411
  const npuNames = ['Apple Neural Engine', 'Ryzen AI NPU', 'Intel AI Boost', 'Qualcomm Hexagon', 'NPU not present'];
@@ -2630,9 +2722,10 @@ export function createRemoteHub(options = {}) {
2630
2722
  platform: safeString(hello.platform, 80),
2631
2723
  arch: safeString(hello.arch, 40),
2632
2724
  slotNumber: normalizeSlotNumber(hello.slotNumber ?? hello.slot ?? hello.computerNumber),
2633
- pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
2634
- agentVersion: safeString(hello.agentVersion, 40),
2635
- protocol: safeString(hello.protocol, 80) || REMOTE_AGENT_PROTOCOL,
2725
+ pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
2726
+ agentVersion: safeString(hello.agentVersion, 40),
2727
+ productVersion: safeString(hello.productVersion, 40),
2728
+ protocol: safeString(hello.protocol, 80) || REMOTE_AGENT_PROTOCOL,
2636
2729
  protocolVersion: Number.isFinite(Number(hello.protocolVersion)) ? Math.max(1, Math.floor(Number(hello.protocolVersion))) : 1,
2637
2730
  frameProtocol: frameProtocol || buildRemoteFrameProtocolDescriptor(),
2638
2731
  frameModes: frameModes.length > 0 ? frameModes : getSupportedRemoteFrameModeProfiles(),
@@ -2851,9 +2944,10 @@ export function createRemoteHub(options = {}) {
2851
2944
  closeFrameSocket(device, reason);
2852
2945
  device.disconnectedAt = new Date().toISOString();
2853
2946
  device.lastDisconnectReason = reason;
2854
- deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2855
- failAllPendingTasks(device, 'device-disconnected');
2856
- logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2947
+ deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2948
+ failAllPendingTasks(device, 'device-disconnected');
2949
+ failAllPendingInputFallbacks(device, 'device-disconnected');
2950
+ logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2857
2951
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2858
2952
  }
2859
2953
 
@@ -3111,10 +3205,10 @@ export function createRemoteHub(options = {}) {
3111
3205
  return task;
3112
3206
  }
3113
3207
 
3114
- function failAllPendingTasks(device, error = 'device-disconnected') {
3115
- if (!device?.pendingTaskCommands) {
3116
- return 0;
3117
- }
3208
+ function failAllPendingTasks(device, error = 'device-disconnected') {
3209
+ if (!device?.pendingTaskCommands) {
3210
+ return 0;
3211
+ }
3118
3212
 
3119
3213
  const commandIds = [...device.pendingTaskCommands.keys()];
3120
3214
  let failed = 0;
@@ -3122,11 +3216,263 @@ export function createRemoteHub(options = {}) {
3122
3216
  if (failPendingTask(device, commandId, error)) {
3123
3217
  failed += 1;
3124
3218
  }
3125
- }
3126
- return failed;
3127
- }
3128
-
3129
- function scheduleTaskTimeout(device, task) {
3219
+ }
3220
+ return failed;
3221
+ }
3222
+
3223
+ function ensurePendingInputFallbacks(device) {
3224
+ if (!(device?.pendingInputFallbacks instanceof Map)) {
3225
+ Object.defineProperty(device, 'pendingInputFallbacks', {
3226
+ value: new Map(),
3227
+ enumerable: false,
3228
+ configurable: true,
3229
+ writable: true
3230
+ });
3231
+ }
3232
+ if (!(device?.completedInputFallbacks instanceof Map)) {
3233
+ Object.defineProperty(device, 'completedInputFallbacks', {
3234
+ value: new Map(),
3235
+ enumerable: false,
3236
+ configurable: true,
3237
+ writable: true
3238
+ });
3239
+ }
3240
+ return device.pendingInputFallbacks;
3241
+ }
3242
+
3243
+ function pruneCompletedInputFallbacks(device, now = Date.now()) {
3244
+ ensurePendingInputFallbacks(device);
3245
+ for (const [key, expiresAt] of device.completedInputFallbacks.entries()) {
3246
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
3247
+ device.completedInputFallbacks.delete(key);
3248
+ }
3249
+ }
3250
+ }
3251
+
3252
+ function getInputFallbackCompletionKeys(commandId, inputSeq) {
3253
+ const keys = [];
3254
+ const normalizedCommandId = safeString(commandId, 128);
3255
+ const normalizedInputSeq = Number(inputSeq || 0);
3256
+ if (normalizedCommandId) {
3257
+ keys.push(`command:${normalizedCommandId}`);
3258
+ }
3259
+ if (Number.isSafeInteger(normalizedInputSeq) && normalizedInputSeq > 0) {
3260
+ keys.push(`seq:${normalizedInputSeq}`);
3261
+ }
3262
+ return keys;
3263
+ }
3264
+
3265
+ function rememberCompletedInputFallback(device, pending) {
3266
+ pruneCompletedInputFallbacks(device);
3267
+ const expiresAt = Date.now() + Math.max(inputAckTimeoutMs * 2, 10000);
3268
+ for (const key of getInputFallbackCompletionKeys(pending?.commandId, pending?.inputSeq)) {
3269
+ device.completedInputFallbacks.set(key, expiresAt);
3270
+ }
3271
+ while (device.completedInputFallbacks.size > 256) {
3272
+ const oldestKey = device.completedInputFallbacks.keys().next().value;
3273
+ if (!oldestKey) {
3274
+ break;
3275
+ }
3276
+ device.completedInputFallbacks.delete(oldestKey);
3277
+ }
3278
+ }
3279
+
3280
+ function wasInputFallbackCompleted(device, commandId, inputSeq) {
3281
+ pruneCompletedInputFallbacks(device);
3282
+ return getInputFallbackCompletionKeys(commandId, inputSeq)
3283
+ .some(key => device.completedInputFallbacks.has(key));
3284
+ }
3285
+
3286
+ function findPendingInputFallback(device, commandId, inputSeq) {
3287
+ const pendingFallbacks = ensurePendingInputFallbacks(device);
3288
+ const key = safeString(commandId, 128);
3289
+ if (key && pendingFallbacks.has(key)) {
3290
+ return pendingFallbacks.get(key);
3291
+ }
3292
+ const sequence = Number(inputSeq || 0);
3293
+ if (!Number.isSafeInteger(sequence) || sequence <= 0) {
3294
+ return null;
3295
+ }
3296
+ for (const pending of pendingFallbacks.values()) {
3297
+ if (Number(pending?.inputSeq || 0) === sequence) {
3298
+ return pending;
3299
+ }
3300
+ }
3301
+ return null;
3302
+ }
3303
+
3304
+ function takePendingInputFallback(device, commandId, inputSeq) {
3305
+ const pending = findPendingInputFallback(device, commandId, inputSeq);
3306
+ if (!pending) {
3307
+ return null;
3308
+ }
3309
+ if (pending.timeoutTimer) {
3310
+ clearTimeout(pending.timeoutTimer);
3311
+ pending.timeoutTimer = null;
3312
+ }
3313
+ ensurePendingInputFallbacks(device).delete(pending.commandId);
3314
+ rememberCompletedInputFallback(device, pending);
3315
+ return pending;
3316
+ }
3317
+
3318
+ function buildRemoteInputOutcome(device, message = {}, pending = null) {
3319
+ const result = message?.result && typeof message.result === 'object'
3320
+ ? message.result
3321
+ : {};
3322
+ const inputSeq = Number(
3323
+ message.inputSeq
3324
+ ?? message.InputSeq
3325
+ ?? result.inputSeq
3326
+ ?? result.InputSeq
3327
+ ?? pending?.inputSeq
3328
+ ?? 0) || 0;
3329
+ return {
3330
+ deviceId: device.deviceId,
3331
+ commandId: safeString(message.commandId || message.CommandId || pending?.commandId, 128),
3332
+ inputSeq,
3333
+ inputType: safeString(
3334
+ message.inputType
3335
+ || message.InputType
3336
+ || result.inputType
3337
+ || result.InputType
3338
+ || pending?.inputType,
3339
+ 48),
3340
+ issuedAtEpochMs: Number(
3341
+ message.issuedAtEpochMs
3342
+ ?? message.IssuedAtEpochMs
3343
+ ?? result.issuedAtEpochMs
3344
+ ?? result.IssuedAtEpochMs
3345
+ ?? pending?.issuedAtEpochMs
3346
+ ?? 0) || 0,
3347
+ hubReceivedAtEpochMs: Number(
3348
+ message.hubReceivedAtEpochMs
3349
+ ?? message.HubReceivedAtEpochMs
3350
+ ?? result.hubReceivedAtEpochMs
3351
+ ?? result.HubReceivedAtEpochMs
3352
+ ?? pending?.hubReceivedAtEpochMs
3353
+ ?? 0) || 0,
3354
+ hubForwardedAtEpochMs: Number(
3355
+ message.hubForwardedAtEpochMs
3356
+ ?? message.HubForwardedAtEpochMs
3357
+ ?? result.hubForwardedAtEpochMs
3358
+ ?? result.HubForwardedAtEpochMs
3359
+ ?? pending?.hubForwardedAtEpochMs
3360
+ ?? 0) || 0,
3361
+ fallback: pending?.fallback === true,
3362
+ hubAcknowledgedAtEpochMs: Date.now()
3363
+ };
3364
+ }
3365
+
3366
+ function emitRemoteInputApplied(device, message = {}, pending = null) {
3367
+ const outcome = buildRemoteInputOutcome(device, message, pending);
3368
+ emitEvent('RemoteInputApplied', {
3369
+ ...outcome,
3370
+ agentApplyMs: Number(
3371
+ message.agentApplyMs
3372
+ ?? message.AgentApplyMs
3373
+ ?? message.result?.agentApplyMs
3374
+ ?? message.result?.AgentApplyMs
3375
+ ?? 0) || 0
3376
+ });
3377
+ }
3378
+
3379
+ function emitRemoteInputError(device, message = {}, pending = null, fallbackError = 'remote-input-failed') {
3380
+ const outcome = buildRemoteInputOutcome(device, message, pending);
3381
+ emitEvent('RemoteInputError', {
3382
+ ...outcome,
3383
+ error: safeString(
3384
+ message.error
3385
+ || message.Error
3386
+ || message.result?.error
3387
+ || message.result?.Error
3388
+ || fallbackError,
3389
+ 600) || fallbackError,
3390
+ failedAtEpochMs: Number(
3391
+ message.failedAtEpochMs
3392
+ ?? message.FailedAtEpochMs
3393
+ ?? message.result?.failedAtEpochMs
3394
+ ?? message.result?.FailedAtEpochMs
3395
+ ?? 0) || 0
3396
+ });
3397
+ }
3398
+
3399
+ function handleRemoteInputOutcome(device, message = {}, source = 'input') {
3400
+ const commandId = safeString(message.commandId || message.CommandId, 128);
3401
+ const inputSeq = Number(message.inputSeq ?? message.InputSeq ?? 0) || 0;
3402
+ const pending = takePendingInputFallback(device, commandId, inputSeq);
3403
+ if (!pending
3404
+ && source === 'main'
3405
+ && wasInputFallbackCompleted(device, commandId, inputSeq)) {
3406
+ return false;
3407
+ }
3408
+ // Main-channel input acknowledgements only exist for a tracked
3409
+ // compatibility fallback. Ignore stale/unmatched messages so an old
3410
+ // session cannot acknowledge input from the current control session.
3411
+ if (!pending && source === 'main') {
3412
+ return false;
3413
+ }
3414
+ if (message.type === 'input.error') {
3415
+ emitRemoteInputError(device, message, pending);
3416
+ } else {
3417
+ emitRemoteInputApplied(device, message, pending);
3418
+ }
3419
+ return true;
3420
+ }
3421
+
3422
+ function handleInputFallbackCommandResult(device, message = {}) {
3423
+ const commandId = safeString(message.commandId || message.CommandId, 128);
3424
+ const pending = findPendingInputFallback(device, commandId, 0);
3425
+ if (!pending) {
3426
+ return false;
3427
+ }
3428
+ const completed = takePendingInputFallback(device, commandId, pending.inputSeq);
3429
+ const result = message.result && typeof message.result === 'object' ? message.result : {};
3430
+ const error = safeString(message.error || message.Error || result.error || result.Error, 600);
3431
+ const failed = message.type === 'command.error'
3432
+ || !!error
3433
+ || result.ok === false
3434
+ || safeString(result.status, 40).toLowerCase() === 'failed';
3435
+ if (failed) {
3436
+ emitRemoteInputError(device, { ...message, error: error || 'remote-input-failed' }, completed);
3437
+ } else {
3438
+ emitRemoteInputApplied(device, message, completed);
3439
+ }
3440
+ return true;
3441
+ }
3442
+
3443
+ function schedulePendingInputFallback(device, pending) {
3444
+ const pendingFallbacks = ensurePendingInputFallbacks(device);
3445
+ const existing = pendingFallbacks.get(pending.commandId);
3446
+ if (existing?.timeoutTimer) {
3447
+ clearTimeout(existing.timeoutTimer);
3448
+ }
3449
+ pendingFallbacks.set(pending.commandId, pending);
3450
+ pending.timeoutTimer = setTimeout(() => {
3451
+ const timedOut = takePendingInputFallback(device, pending.commandId, pending.inputSeq);
3452
+ if (timedOut) {
3453
+ emitRemoteInputError(device, { error: 'remote-input-timeout' }, timedOut, 'remote-input-timeout');
3454
+ }
3455
+ }, inputAckTimeoutMs);
3456
+ pending.timeoutTimer.unref?.();
3457
+ }
3458
+
3459
+ function failAllPendingInputFallbacks(device, error = 'device-disconnected') {
3460
+ if (!device || typeof device !== 'object') {
3461
+ return 0;
3462
+ }
3463
+ const pendingFallbacks = ensurePendingInputFallbacks(device);
3464
+ const pending = [...pendingFallbacks.values()];
3465
+ for (const item of pending) {
3466
+ const failed = takePendingInputFallback(device, item.commandId, item.inputSeq);
3467
+ if (failed) {
3468
+ emitRemoteInputError(device, { error }, failed, error);
3469
+ }
3470
+ }
3471
+ device.completedInputFallbacks.clear();
3472
+ return pending.length;
3473
+ }
3474
+
3475
+ function scheduleTaskTimeout(device, task) {
3130
3476
  const commandId = safeString(task?.commandId, 128);
3131
3477
  if (!device?.pendingTaskTimers || !commandId) {
3132
3478
  return;
@@ -3365,10 +3711,108 @@ export function createRemoteHub(options = {}) {
3365
3711
  return true;
3366
3712
  }
3367
3713
 
3368
- function applyLiveStreamOpen(device, message, transport = 'json') {
3369
- const streamId = safeString(message.streamId, 128) || 'live';
3370
- const streamState = getDeviceLiveStream(device, streamId);
3371
- if (!streamState?.active) {
3714
+ function promotePendingLiveStream(device, streamState, message, transport) {
3715
+ const pending = getPendingLiveStreamDescriptor(streamState);
3716
+ const commandId = safeString(message.commandId, 128);
3717
+ if (!pending || !commandId || commandId !== safeString(pending.commandId, 128)) {
3718
+ return null;
3719
+ }
3720
+ const messageCaptureGeneration = Number(message.captureGeneration || 0);
3721
+ const pendingCaptureGeneration = Number(pending.captureGeneration || 0);
3722
+ if (messageCaptureGeneration > 0
3723
+ && pendingCaptureGeneration > 0
3724
+ && messageCaptureGeneration !== pendingCaptureGeneration) {
3725
+ emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3726
+ reason: 'stale-pending-capture-generation',
3727
+ streamId: streamState.streamId,
3728
+ commandId,
3729
+ captureGeneration: messageCaptureGeneration,
3730
+ pendingCaptureGeneration,
3731
+ transport
3732
+ });
3733
+ return null;
3734
+ }
3735
+
3736
+ const previousCommandId = safeString(streamState.commandId, 128);
3737
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(
3738
+ message,
3739
+ pending.mode || pending.frameMode || DEFAULT_REMOTE_FRAME_MODE);
3740
+ const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
3741
+ const nextStreamState = {
3742
+ ...streamState,
3743
+ ...pending,
3744
+ streamId: streamState.streamId,
3745
+ commandId,
3746
+ active: true,
3747
+ open: true,
3748
+ openedAt,
3749
+ openTransport: transport,
3750
+ mode: transfer.mode,
3751
+ frameMode: transfer.frameMode,
3752
+ frameProfile: transfer.frameProfile,
3753
+ encoding: transfer.encoding,
3754
+ codec: transfer.codec,
3755
+ compression: transfer.compression,
3756
+ transferProtocol: transfer.transferProtocol,
3757
+ transferProtocolVersion: transfer.transferProtocolVersion,
3758
+ handshake: transfer.handshake,
3759
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(pending.width || 0),
3760
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(pending.height || 0),
3761
+ sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(pending.sourceWidth || 0),
3762
+ sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(pending.sourceHeight || 0),
3763
+ monitorIndex: Number.isFinite(Number(message.monitorIndex))
3764
+ ? normalizeMonitorIndex(message.monitorIndex)
3765
+ : Number(pending.monitorIndex || 0),
3766
+ monitorCount: Number.isFinite(Number(message.monitorCount))
3767
+ ? clampNumber(message.monitorCount, 1, 64, 1)
3768
+ : Number(pending.monitorCount || 1),
3769
+ streamPurpose: safeString(message.streamPurpose || pending.streamPurpose, 24) || 'wall',
3770
+ captureGeneration: messageCaptureGeneration > 0
3771
+ ? messageCaptureGeneration
3772
+ : pendingCaptureGeneration,
3773
+ openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : 0,
3774
+ lastFrameAt: '',
3775
+ lastFrameSeq: 0,
3776
+ framesReceived: 0,
3777
+ latestFrame: null,
3778
+ stoppedAt: '',
3779
+ stopReason: ''
3780
+ };
3781
+ clearPendingLiveStreamDescriptor(device, streamState);
3782
+ setPendingLiveStreamDescriptor(nextStreamState, null);
3783
+ if (device.latestLiveFrame?.streamId === streamState.streamId
3784
+ && safeString(device.latestLiveFrame.commandId, 128) === previousCommandId) {
3785
+ device.latestLiveFrame = null;
3786
+ }
3787
+ setDeviceLiveStream(device, nextStreamState);
3788
+ return {
3789
+ streamState: nextStreamState,
3790
+ transfer,
3791
+ previousCommandId
3792
+ };
3793
+ }
3794
+
3795
+ function emitLiveStreamOpened(device, streamState, transfer, transport, previousCommandId = '') {
3796
+ emitRemoteEvent('RemoteLiveStreamOpened', device, {
3797
+ streamId: streamState.streamId,
3798
+ commandId: streamState.commandId,
3799
+ previousCommandId,
3800
+ handoff: !!previousCommandId,
3801
+ mode: transfer.mode,
3802
+ frameMode: transfer.frameMode,
3803
+ frameProfile: transfer.frameProfile,
3804
+ encoding: transfer.encoding,
3805
+ compression: transfer.compression,
3806
+ transport,
3807
+ captureGeneration: Number(streamState.captureGeneration || 0),
3808
+ streamPurpose: safeString(streamState.streamPurpose, 24) || 'wall'
3809
+ });
3810
+ }
3811
+
3812
+ function applyLiveStreamOpen(device, message, transport = 'json') {
3813
+ const streamId = safeString(message.streamId, 128) || 'live';
3814
+ const streamState = getDeviceLiveStream(device, streamId);
3815
+ if (!streamState?.active) {
3372
3816
  emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3373
3817
  reason: 'stale-live-stream-open',
3374
3818
  streamId,
@@ -3376,12 +3820,13 @@ export function createRemoteHub(options = {}) {
3376
3820
  });
3377
3821
  return false;
3378
3822
  }
3379
-
3380
- const commandId = safeString(message.commandId, 128);
3381
- const activeCommandId = safeString(streamState.commandId, 128);
3382
- const pendingCommandId = safeString(streamState.pendingCommandId, 128);
3383
- const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3384
- const isPendingGeneration = !!pendingCommandId && commandId === pendingCommandId;
3823
+
3824
+ const commandId = safeString(message.commandId, 128);
3825
+ const activeCommandId = safeString(streamState.commandId, 128);
3826
+ const pendingDescriptor = getPendingLiveStreamDescriptor(streamState);
3827
+ const pendingCommandId = safeString(pendingDescriptor?.commandId, 128);
3828
+ const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3829
+ const isPendingGeneration = !!pendingCommandId && commandId === pendingCommandId;
3385
3830
  if (!isActiveGeneration && !isPendingGeneration) {
3386
3831
  emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3387
3832
  reason: 'stale-live-stream-generation',
@@ -3391,21 +3836,31 @@ export function createRemoteHub(options = {}) {
3391
3836
  pendingCommandId,
3392
3837
  transport
3393
3838
  });
3394
- return false;
3395
- }
3396
-
3397
- const activatingPendingGeneration = isPendingGeneration && commandId !== activeCommandId;
3398
- const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3399
- const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
3400
- const nextStreamState = {
3401
- ...streamState,
3402
- commandId: activatingPendingGeneration ? commandId : streamState.commandId,
3403
- pendingCommandId: activatingPendingGeneration ? '' : streamState.pendingCommandId,
3404
- pendingStartedAt: activatingPendingGeneration ? '' : streamState.pendingStartedAt,
3405
- pendingRestartToken: activatingPendingGeneration ? '' : streamState.pendingRestartToken,
3406
- open: true,
3407
- openedAt,
3408
- openTransport: transport,
3839
+ return false;
3840
+ }
3841
+
3842
+ const activatingPendingGeneration = isPendingGeneration && commandId !== activeCommandId;
3843
+ if (activatingPendingGeneration) {
3844
+ const promoted = promotePendingLiveStream(device, streamState, message, transport);
3845
+ if (!promoted) {
3846
+ return false;
3847
+ }
3848
+ emitLiveStreamOpened(
3849
+ device,
3850
+ promoted.streamState,
3851
+ promoted.transfer,
3852
+ transport,
3853
+ promoted.previousCommandId);
3854
+ return true;
3855
+ }
3856
+
3857
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3858
+ const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
3859
+ const nextStreamState = {
3860
+ ...streamState,
3861
+ open: true,
3862
+ openedAt,
3863
+ openTransport: transport,
3409
3864
  mode: transfer.mode,
3410
3865
  frameMode: transfer.frameMode,
3411
3866
  frameProfile: transfer.frameProfile,
@@ -3419,47 +3874,54 @@ export function createRemoteHub(options = {}) {
3419
3874
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(streamState.height || 0),
3420
3875
  sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(streamState.sourceWidth || 0),
3421
3876
  sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(streamState.sourceHeight || 0),
3422
- monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
3423
- monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
3424
- openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(streamState.openedFrameSeq || 0)
3425
- };
3426
- setDeviceLiveStream(device, nextStreamState);
3427
- emitRemoteEvent('RemoteLiveStreamOpened', device, {
3428
- streamId,
3429
- commandId: nextStreamState.commandId,
3430
- previousCommandId: activatingPendingGeneration ? activeCommandId : '',
3431
- handoff: activatingPendingGeneration,
3432
- mode: transfer.mode,
3433
- frameMode: transfer.frameMode,
3434
- frameProfile: transfer.frameProfile,
3435
- encoding: transfer.encoding,
3436
- compression: transfer.compression,
3437
- transport
3438
- });
3439
- return true;
3440
- }
3877
+ monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
3878
+ monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
3879
+ streamPurpose: safeString(message.streamPurpose || streamState.streamPurpose, 24) || 'wall',
3880
+ captureGeneration: Number.isSafeInteger(Number(message.captureGeneration)) && Number(message.captureGeneration) > 0
3881
+ ? Number(message.captureGeneration)
3882
+ : Number(streamState.captureGeneration || 0),
3883
+ openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(streamState.openedFrameSeq || 0)
3884
+ };
3885
+ setDeviceLiveStream(device, nextStreamState);
3886
+ emitLiveStreamOpened(device, nextStreamState, transfer, transport);
3887
+ return true;
3888
+ }
3441
3889
 
3442
3890
  function applyLiveFrame(device, message, framePayload, transport = 'json-base64') {
3443
3891
  const frameData = Buffer.isBuffer(framePayload)
3444
3892
  ? ''
3445
3893
  : safeString(framePayload, MAX_STREAM_BASE64_CHARS + 1);
3446
- const frameSeq = Number(message.frameSeq);
3447
- const streamId = safeString(message.streamId, 128) || 'live';
3448
- const commandId = safeString(message.commandId, 128);
3449
- const streamState = getDeviceLiveStream(device, streamId);
3450
- if (!streamState?.active) {
3894
+ const frameSeq = Number(message.frameSeq);
3895
+ const streamId = safeString(message.streamId, 128) || 'live';
3896
+ const commandId = safeString(message.commandId, 128);
3897
+ let streamState = getDeviceLiveStream(device, streamId);
3898
+ if (!streamState?.active) {
3451
3899
  device.counters.liveFramesDropped += 1;
3452
3900
  emitRemoteEvent('RemoteFrameDropped', device, {
3453
3901
  reason: 'stale-live-stream-frame',
3454
3902
  streamId,
3455
3903
  frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
3456
3904
  transport
3457
- });
3458
- return false;
3459
- }
3460
- const activeCommandId = safeString(streamState.commandId, 128);
3461
- const pendingCommandId = safeString(streamState.pendingCommandId, 128);
3462
- const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3905
+ });
3906
+ return false;
3907
+ }
3908
+ let activeCommandId = safeString(streamState.commandId, 128);
3909
+ let pendingCommandId = safeString(getPendingLiveStreamDescriptor(streamState)?.commandId, 128);
3910
+ if (pendingCommandId && commandId === pendingCommandId && commandId !== activeCommandId) {
3911
+ const promoted = promotePendingLiveStream(device, streamState, message, `${transport}-implicit`);
3912
+ if (promoted) {
3913
+ streamState = promoted.streamState;
3914
+ activeCommandId = safeString(streamState.commandId, 128);
3915
+ pendingCommandId = '';
3916
+ emitLiveStreamOpened(
3917
+ device,
3918
+ streamState,
3919
+ promoted.transfer,
3920
+ `${transport}-implicit`,
3921
+ promoted.previousCommandId);
3922
+ }
3923
+ }
3924
+ const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3463
3925
  if (!isActiveGeneration) {
3464
3926
  device.counters.liveFramesDropped += 1;
3465
3927
  emitRemoteEvent('RemoteFrameDropped', device, {
@@ -3471,8 +3933,25 @@ export function createRemoteHub(options = {}) {
3471
3933
  frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
3472
3934
  transport
3473
3935
  });
3474
- return false;
3475
- }
3936
+ return false;
3937
+ }
3938
+ const frameCaptureGeneration = Number(message.captureGeneration || streamState.captureGeneration || 0);
3939
+ const activeCaptureGeneration = Number(streamState.captureGeneration || 0);
3940
+ if (activeCaptureGeneration > 0
3941
+ && frameCaptureGeneration > 0
3942
+ && frameCaptureGeneration !== activeCaptureGeneration) {
3943
+ device.counters.liveFramesDropped += 1;
3944
+ emitRemoteEvent('RemoteFrameDropped', device, {
3945
+ reason: 'stale-capture-generation-frame',
3946
+ streamId,
3947
+ commandId,
3948
+ captureGeneration: frameCaptureGeneration,
3949
+ activeCaptureGeneration,
3950
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
3951
+ transport
3952
+ });
3953
+ return false;
3954
+ }
3476
3955
 
3477
3956
  const byteLength = normalizeFrameByteLength(framePayload, frameData);
3478
3957
  if ((!Buffer.isBuffer(framePayload) && !frameData)
@@ -3494,7 +3973,7 @@ export function createRemoteHub(options = {}) {
3494
3973
  const contentHash = buildFrameContentHash(message, payload);
3495
3974
  const sameContentStreak = computeSameContentStreak(streamState.latestFrame, contentHash);
3496
3975
  const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3497
- if (streamState.open !== true) {
3976
+ if (streamState.open !== true) {
3498
3977
  streamState.open = true;
3499
3978
  streamState.openedAt = device.lastSeenAt;
3500
3979
  streamState.openTransport = `${transport}-implicit`;
@@ -3506,14 +3985,22 @@ export function createRemoteHub(options = {}) {
3506
3985
  streamState.compression = transfer.compression;
3507
3986
  streamState.transferProtocol = transfer.transferProtocol;
3508
3987
  streamState.transferProtocolVersion = transfer.transferProtocolVersion;
3509
- streamState.handshake = transfer.handshake;
3510
- }
3511
- device.latestLiveFrame = {
3512
- streamId,
3513
- frameSeq,
3514
- streamFrameSeq: Number.isFinite(Number(message.streamFrameSeq)) ? Number(message.streamFrameSeq) : frameSeq,
3515
- commandId,
3516
- width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
3988
+ streamState.handshake = transfer.handshake;
3989
+ }
3990
+ const currentGenerationVerified = (!activeCommandId || commandId === activeCommandId)
3991
+ && (activeCaptureGeneration <= 0
3992
+ || (frameCaptureGeneration > 0
3993
+ && frameCaptureGeneration === activeCaptureGeneration));
3994
+ device.latestLiveFrame = {
3995
+ streamId,
3996
+ frameSeq,
3997
+ streamFrameSeq: Number.isFinite(Number(message.streamFrameSeq)) ? Number(message.streamFrameSeq) : frameSeq,
3998
+ commandId: commandId || activeCommandId,
3999
+ sessionId: device.sessionId,
4000
+ captureGeneration: activeCaptureGeneration || frameCaptureGeneration,
4001
+ currentGenerationVerified,
4002
+ streamPurpose: safeString(message.streamPurpose || streamState.streamPurpose, 24) || 'wall',
4003
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
3517
4004
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
3518
4005
  sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(message.width || 0),
3519
4006
  sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(message.height || 0),
@@ -3603,11 +4090,21 @@ export function createRemoteHub(options = {}) {
3603
4090
  streamState.sourceWidth = device.latestLiveFrame.sourceWidth;
3604
4091
  streamState.sourceHeight = device.latestLiveFrame.sourceHeight;
3605
4092
  streamState.monitorIndex = device.latestLiveFrame.monitorIndex;
3606
- streamState.monitorCount = device.latestLiveFrame.monitorCount;
3607
- ensureDeviceLiveStreams(device).set(streamId, streamState);
3608
- device.counters.liveFramesReceived += 1;
3609
- return true;
3610
- }
4093
+ streamState.monitorCount = device.latestLiveFrame.monitorCount;
4094
+ ensureDeviceLiveStreams(device).set(streamId, streamState);
4095
+ device.counters.liveFramesReceived += 1;
4096
+ if (streamState.framesReceived === 1 && liveStreamHasCurrentFrame(streamState)) {
4097
+ emitRemoteEvent('RemoteLiveStreamReady', device, {
4098
+ streamId,
4099
+ commandId: streamState.commandId,
4100
+ captureGeneration: Number(streamState.captureGeneration || 0),
4101
+ streamPurpose: safeString(streamState.streamPurpose, 24) || 'wall',
4102
+ monitorIndex: Number(streamState.monitorIndex || 0),
4103
+ frameSeq
4104
+ });
4105
+ }
4106
+ return true;
4107
+ }
3611
4108
 
3612
4109
  function applyAudioFrame(device, message, framePayload, transport = 'json-base64') {
3613
4110
  const audioData = Buffer.isBuffer(framePayload)
@@ -3906,8 +4403,21 @@ export function createRemoteHub(options = {}) {
3906
4403
  return;
3907
4404
  }
3908
4405
 
3909
- if (!state.authenticated) {
3910
- if (message.type !== 'hello') {
4406
+ if (!state.authenticated) {
4407
+ if (message.type === 'slot.assign') {
4408
+ if (!timingSafeStringEqual(message.pairToken, pairToken)) {
4409
+ writeJsonLine(socket, { type: 'slot.assignment', ok: false, error: 'invalid-pair-token' });
4410
+ socket.end();
4411
+ return;
4412
+ }
4413
+ const result = assignDeviceSlot(
4414
+ message.deviceId || message.DeviceId,
4415
+ message.slotNumber ?? message.slot ?? message.SlotNumber);
4416
+ writeJsonLine(socket, { type: 'slot.assignment', ...result });
4417
+ socket.end();
4418
+ return;
4419
+ }
4420
+ if (message.type !== 'hello') {
3911
4421
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
3912
4422
  socket.destroy();
3913
4423
  return;
@@ -3981,32 +4491,27 @@ export function createRemoteHub(options = {}) {
3981
4491
  return;
3982
4492
  }
3983
4493
 
3984
- if (state.inputOnly) {
3985
- device.inputLastSeenAt = new Date().toISOString();
3986
- if (message.type === 'input.applied') {
3987
- emitEvent('RemoteInputApplied', {
3988
- deviceId: device.deviceId,
3989
- inputSeq: Number(message.inputSeq || 0) || 0,
3990
- inputType: safeString(message.inputType, 48),
3991
- issuedAtEpochMs: Number(message.issuedAtEpochMs || 0) || 0,
3992
- hubReceivedAtEpochMs: Number(message.hubReceivedAtEpochMs || 0) || 0,
3993
- hubForwardedAtEpochMs: Number(message.hubForwardedAtEpochMs || 0) || 0,
3994
- agentApplyMs: Number(message.agentApplyMs || 0) || 0,
3995
- hubAcknowledgedAtEpochMs: Date.now()
3996
- });
3997
- }
3998
- if (message.type === 'input.error') {
3999
- emitEvent('RemoteInputError', {
4000
- deviceId: device.deviceId,
4001
- inputSeq: Number(message.inputSeq || 0) || 0,
4002
- inputType: safeString(message.inputType, 48),
4003
- error: safeString(message.error, 600) || 'remote-input-failed',
4004
- failedAtEpochMs: Number(message.failedAtEpochMs || 0) || 0,
4005
- hubAcknowledgedAtEpochMs: Date.now()
4006
- });
4007
- }
4008
- return;
4009
- }
4494
+ if (state.inputOnly) {
4495
+ device.inputLastSeenAt = new Date().toISOString();
4496
+ if (message.type === 'input.applied') {
4497
+ handleRemoteInputOutcome(device, {
4498
+ ...message,
4499
+ agentApplyMs: Number(message.agentApplyMs || 0) || 0
4500
+ }, 'input');
4501
+ }
4502
+ if (message.type === 'input.error') {
4503
+ const pending = takePendingInputFallback(
4504
+ device,
4505
+ message.commandId || message.CommandId,
4506
+ message.inputSeq ?? message.InputSeq);
4507
+ emitEvent('RemoteInputError', {
4508
+ ...buildRemoteInputOutcome(device, message, pending),
4509
+ error: safeString(message.error || message.Error, 600) || 'remote-input-failed',
4510
+ failedAtEpochMs: Number(message.failedAtEpochMs ?? message.FailedAtEpochMs ?? 0) || 0
4511
+ });
4512
+ }
4513
+ return;
4514
+ }
4010
4515
  if (state.frameOnly) {
4011
4516
  device.frameLastSeenAt = new Date().toISOString();
4012
4517
  return;
@@ -4028,25 +4533,30 @@ export function createRemoteHub(options = {}) {
4028
4533
  device.status = typeof message.status === 'object' && message.status
4029
4534
  ? { ...message.status }
4030
4535
  : {};
4031
- {
4032
- const nextSlotNumber = normalizeSlotNumber(device.status.slotNumber);
4033
- if (nextSlotNumber > 0) {
4034
- device.slotNumber = nextSlotNumber;
4035
- }
4036
- }
4536
+ {
4537
+ const nextSlotNumber = normalizeSlotNumber(device.status.slotNumber);
4538
+ const assignedSlotNumber = normalizeSlotNumber(device.slotAssignment?.slotNumber);
4539
+ if (nextSlotNumber > 0 && (!assignedSlotNumber || nextSlotNumber === assignedSlotNumber)) {
4540
+ device.slotNumber = nextSlotNumber;
4541
+ }
4542
+ }
4037
4543
  device.lastStatusAt = device.lastSeenAt;
4038
4544
  device.counters.statusReceived += 1;
4039
4545
  emitRemoteEvent('RemoteDeviceStatus', device);
4040
4546
  break;
4041
- case 'command.result':
4042
- device.counters.commandResultsReceived += 1;
4043
- {
4044
- const commandId = safeString(message.commandId, 128);
4045
- const error = safeString(message.error, 500);
4046
- const result = message.result ?? null;
4047
- if (error || result?.ok === false || result?.status === 'failed') {
4048
- failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
4049
- }
4547
+ case 'command.result':
4548
+ case 'command.error':
4549
+ device.counters.commandResultsReceived += 1;
4550
+ {
4551
+ const commandId = safeString(message.commandId, 128);
4552
+ const error = safeString(
4553
+ message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4554
+ 500);
4555
+ const result = message.result ?? null;
4556
+ handleInputFallbackCommandResult(device, message);
4557
+ if (error || result?.ok === false || result?.status === 'failed') {
4558
+ failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
4559
+ }
4050
4560
  const task = applyTaskResult(device, commandId, result, error);
4051
4561
  if (task) {
4052
4562
  emitRemoteEvent('RemoteTaskResult', device, {
@@ -4061,8 +4571,12 @@ export function createRemoteHub(options = {}) {
4061
4571
  commandId: safeString(message.commandId, 128),
4062
4572
  result: message.result ?? null,
4063
4573
  error: safeString(message.error, 500)
4064
- });
4065
- break;
4574
+ });
4575
+ break;
4576
+ case 'input.applied':
4577
+ case 'input.error':
4578
+ handleRemoteInputOutcome(device, message, 'main');
4579
+ break;
4066
4580
  case 'thumbnail.frame': {
4067
4581
  applyThumbnailFrame(device, message, message.data, 'json-base64');
4068
4582
  break;
@@ -4483,9 +4997,10 @@ export function createRemoteHub(options = {}) {
4483
4997
  }
4484
4998
 
4485
4999
  async function close() {
4486
- for (const device of devices.values()) {
4487
- failAllPendingTasks(device, 'hub-shutdown');
4488
- if (device.socket && !device.socket.destroyed) {
5000
+ for (const device of devices.values()) {
5001
+ failAllPendingTasks(device, 'hub-shutdown');
5002
+ failAllPendingInputFallbacks(device, 'hub-shutdown');
5003
+ if (device.socket && !device.socket.destroyed) {
4489
5004
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
4490
5005
  device.socket.destroy();
4491
5006
  }
@@ -4530,15 +5045,16 @@ export function createRemoteHub(options = {}) {
4530
5045
  started = false;
4531
5046
  }
4532
5047
 
4533
- function disconnectDevice(deviceId, reason = 'manager-disconnect') {
5048
+ function disconnectDevice(deviceId, reason = 'manager-disconnect') {
4534
5049
  const device = devices.get(String(deviceId || ''));
4535
5050
  if (device?.synthetic === true) {
4536
5051
  device.connected = false;
4537
5052
  device.disconnectedAt = new Date().toISOString();
4538
5053
  device.lastDisconnectReason = reason;
4539
- deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
4540
- failAllPendingTasks(device, 'device-disconnected');
4541
- emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
5054
+ deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
5055
+ failAllPendingTasks(device, 'device-disconnected');
5056
+ failAllPendingInputFallbacks(device, 'device-disconnected');
5057
+ emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
4542
5058
  return true;
4543
5059
  }
4544
5060
 
@@ -4547,11 +5063,67 @@ export function createRemoteHub(options = {}) {
4547
5063
  }
4548
5064
 
4549
5065
  writeJsonLine(device.socket, { type: 'disconnect', reason });
4550
- device.socket.destroy();
4551
- return true;
4552
- }
4553
-
4554
- function sendCommand(deviceId, command) {
5066
+ device.socket.destroy();
5067
+ return true;
5068
+ }
5069
+
5070
+ function assignDeviceSlot(deviceId, value) {
5071
+ const normalizedDeviceId = safeString(deviceId, 160);
5072
+ const slotNumber = normalizeSlotNumber(value);
5073
+ if (!normalizedDeviceId) {
5074
+ return { ok: false, error: 'device-id-required' };
5075
+ }
5076
+ if (slotNumber <= 0) {
5077
+ return { ok: false, error: 'invalid-slot-number' };
5078
+ }
5079
+
5080
+ const device = devices.get(normalizedDeviceId);
5081
+ if (!device?.connected) {
5082
+ return { ok: false, error: 'device-not-connected' };
5083
+ }
5084
+
5085
+ const conflict = [...devices.values()].find(candidate =>
5086
+ candidate?.connected
5087
+ && candidate.deviceId !== normalizedDeviceId
5088
+ && normalizeSlotNumber(candidate.slotNumber) === slotNumber);
5089
+ if (conflict) {
5090
+ return {
5091
+ ok: false,
5092
+ error: 'slot-taken',
5093
+ slotNumber,
5094
+ conflict: {
5095
+ deviceId: conflict.deviceId,
5096
+ deviceName: conflict.deviceName || conflict.hostname || conflict.deviceId,
5097
+ hostname: conflict.hostname || '',
5098
+ slotNumber
5099
+ }
5100
+ };
5101
+ }
5102
+
5103
+ const previousSlotNumber = normalizeSlotNumber(device.slotNumber);
5104
+ device.slotNumber = slotNumber;
5105
+ device.status = {
5106
+ ...(device.status && typeof device.status === 'object' ? device.status : {}),
5107
+ slotNumber
5108
+ };
5109
+ device.slotAssignment = {
5110
+ slotNumber,
5111
+ assignedAt: new Date().toISOString()
5112
+ };
5113
+ emitRemoteEvent('RemoteDeviceSlotAssigned', device, {
5114
+ previousSlotNumber,
5115
+ slotNumber
5116
+ });
5117
+ return {
5118
+ ok: true,
5119
+ deviceId: normalizedDeviceId,
5120
+ deviceName: device.deviceName || device.hostname || normalizedDeviceId,
5121
+ previousSlotNumber,
5122
+ slotNumber
5123
+ };
5124
+ }
5125
+
5126
+ function sendCommand(deviceId, command) {
4555
5127
  const device = devices.get(String(deviceId || ''));
4556
5128
  const commandName = safeString(command?.command || 'ping', 80);
4557
5129
  const requiredPermission = commandName === 'input.control'
@@ -4629,20 +5201,28 @@ export function createRemoteHub(options = {}) {
4629
5201
  return { ok: false, error: 'device-not-connected' };
4630
5202
  }
4631
5203
 
4632
- const command = safeString(options.command, 20000);
4633
- if (!command) return { ok: false, error: 'client-update-command-missing' };
4634
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
4635
- const sent = writeJsonLine(device.socket, {
4636
- type: 'command',
4637
- commandId,
4638
- command: 'command.run',
4639
- payload: {
4640
- command,
4641
- timeoutMs: clampNumber(options.timeoutMs, 1000, 120000, 30000),
4642
- permissionMode: 'full-access'
4643
- },
4644
- issuedAt: new Date().toISOString()
4645
- });
5204
+ const command = safeString(options.command, 20000);
5205
+ if (!command) return { ok: false, error: 'client-update-command-missing' };
5206
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5207
+ const timeoutMs = clampNumber(options.timeoutMs, 1000, 120000, 30000);
5208
+ const sent = writeJsonLine(device.socket, {
5209
+ type: 'command',
5210
+ commandId,
5211
+ command: 'command.run',
5212
+ payload: {
5213
+ command,
5214
+ timeoutMs,
5215
+ permissionMode: 'full-access',
5216
+ // RemoteFast releases before 0.1.170 read command.run inputs
5217
+ // from toolArguments, while Node and current Agents accept the
5218
+ // direct payload fields. Carry both during the update bridge.
5219
+ toolArguments: {
5220
+ command,
5221
+ timeoutMs
5222
+ }
5223
+ },
5224
+ issuedAt: new Date().toISOString()
5225
+ });
4646
5226
  if (!sent) return { ok: false, error: 'device-not-connected' };
4647
5227
  device.counters.commandsSent += 1;
4648
5228
  emitRemoteEvent('RemoteCommandQueued', device, {
@@ -4664,20 +5244,57 @@ export function createRemoteHub(options = {}) {
4664
5244
  return { ok: false, error: 'device-not-connected' };
4665
5245
  }
4666
5246
 
4667
- const denied = policyError(device, 'allowControl');
4668
- if (denied) return { ok: false, error: denied };
4669
-
4670
- if (!readCapabilityFlag(device.capabilities, 'control')) {
4671
- return { ok: false, error: 'device-input-control-unavailable' };
4672
- }
4673
-
4674
- const normalized = normalizeRemoteInputEvent(input);
4675
- if (!normalized.type) {
4676
- return { ok: false, error: 'missing-input-type' };
4677
- }
4678
-
4679
- const inputSocket = device.inputSocket;
4680
- if (inputSocket && !inputSocket.destroyed) {
5247
+ const denied = policyError(device, 'allowControl');
5248
+ if (denied) return { ok: false, error: denied };
5249
+
5250
+ if (device.capabilities?.controlPermission === false
5251
+ || device.capabilities?.accessibilityPermission === false) {
5252
+ return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
5253
+ }
5254
+ if (!readCapabilityFlag(device.capabilities, 'control')) {
5255
+ return { ok: false, error: 'device-input-control-unavailable' };
5256
+ }
5257
+
5258
+ const normalized = normalizeRemoteInputEvent(input);
5259
+ if (!normalized.type) {
5260
+ return { ok: false, error: 'missing-input-type' };
5261
+ }
5262
+ const controlStream = getActiveControlStream(device);
5263
+ if (!controlStream
5264
+ || !liveStreamHasCurrentFrame(controlStream)
5265
+ || !safeString(controlStream.commandId, 128)
5266
+ || Number(controlStream.captureGeneration || 0) <= 0) {
5267
+ return { ok: false, error: 'CONTROL_NOT_READY' };
5268
+ }
5269
+ if (getPendingLiveStreamDescriptor(controlStream)) {
5270
+ return { ok: false, error: 'CAPTURE_TRANSITION_IN_PROGRESS' };
5271
+ }
5272
+ if (!normalized.controlSessionId
5273
+ || normalized.controlSessionId !== safeString(device.sessionId, 160)) {
5274
+ return {
5275
+ ok: false,
5276
+ error: 'STALE_CONTROL_SESSION',
5277
+ activeSessionId: safeString(device.sessionId, 160)
5278
+ };
5279
+ }
5280
+ if (!normalized.controlCommandId
5281
+ || normalized.controlCommandId !== safeString(controlStream.commandId, 128)) {
5282
+ return {
5283
+ ok: false,
5284
+ error: 'STALE_CONTROL_SESSION',
5285
+ activeCommandId: safeString(controlStream.commandId, 128)
5286
+ };
5287
+ }
5288
+ if (normalized.captureGeneration !== Number(controlStream.captureGeneration || 0)) {
5289
+ return {
5290
+ ok: false,
5291
+ error: 'STALE_CAPTURE_GENERATION',
5292
+ activeCaptureGeneration: Number(controlStream.captureGeneration || 0)
5293
+ };
5294
+ }
5295
+
5296
+ const inputSocket = device.inputSocket;
5297
+ if (inputSocket && !inputSocket.destroyed) {
4681
5298
  const sent = writeJsonLine(inputSocket, {
4682
5299
  type: 'input.control',
4683
5300
  payload: {
@@ -4687,22 +5304,57 @@ export function createRemoteHub(options = {}) {
4687
5304
  }
4688
5305
  });
4689
5306
  if (sent) {
4690
- device.counters.commandsSent += 1;
4691
- device.inputLastSeenAt = new Date().toISOString();
4692
- return { ok: true, inputSocket: true };
4693
- }
5307
+ device.counters.commandsSent += 1;
5308
+ device.inputLastSeenAt = new Date().toISOString();
5309
+ return {
5310
+ ok: true,
5311
+ inputSocket: true,
5312
+ sessionId: device.sessionId,
5313
+ commandId: controlStream.commandId,
5314
+ captureGeneration: controlStream.captureGeneration
5315
+ };
5316
+ }
4694
5317
 
4695
5318
  detachInputSocket(inputSocket, 'input-socket-write-failed');
4696
5319
  }
4697
5320
 
4698
- return sendCommand(deviceId, {
5321
+ const hubForwardedAtEpochMs = Date.now();
5322
+ const fallback = sendCommand(deviceId, {
4699
5323
  command: 'input.control',
4700
- payload: {
4701
- ...normalized,
4702
- hubForwardedAtEpochMs: Date.now(),
4703
- issuedAt: normalized.issuedAt || new Date().toISOString()
4704
- }
5324
+ payload: {
5325
+ ...normalized,
5326
+ hubForwardedAtEpochMs,
5327
+ issuedAt: normalized.issuedAt || new Date().toISOString()
5328
+ }
4705
5329
  });
5330
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
5331
+ schedulePendingInputFallback(device, {
5332
+ commandId: fallback.commandId,
5333
+ inputSeq: normalized.inputSeq,
5334
+ inputType: normalized.type,
5335
+ issuedAtEpochMs: normalized.issuedAtEpochMs,
5336
+ hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
5337
+ hubForwardedAtEpochMs,
5338
+ fallback: true,
5339
+ timeoutTimer: null
5340
+ });
5341
+ }
5342
+ return fallback.ok
5343
+ ? {
5344
+ ...fallback,
5345
+ inputSocket: false,
5346
+ fallback: true,
5347
+ sessionId: device.sessionId,
5348
+ deliveryCommandId: fallback.commandId,
5349
+ commandId: controlStream.commandId,
5350
+ captureGeneration: controlStream.captureGeneration,
5351
+ inputSeq: normalized.inputSeq
5352
+ }
5353
+ : {
5354
+ ...fallback,
5355
+ error: fallback.error || 'CONTROL_NOT_READY',
5356
+ detail: 'input-channel-not-connected'
5357
+ };
4706
5358
  }
4707
5359
 
4708
5360
  function notifyAgentProgress(deviceIds, progress = {}) {
@@ -5015,31 +5667,100 @@ export function createRemoteHub(options = {}) {
5015
5667
  || (device?.activeLiveStream?.streamId === id ? device.activeLiveStream : null);
5016
5668
  }
5017
5669
 
5018
- function setDeviceLiveStream(device, stream) {
5019
- if (!device || !stream?.streamId) {
5020
- return stream;
5021
- }
5022
- ensureDeviceLiveStreams(device).set(stream.streamId, stream);
5023
- device.activeLiveStream = stream;
5024
- return stream;
5025
- }
5026
-
5027
- function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
5028
- const key = safeString(commandId, 128);
5029
- if (!device || !key) {
5030
- return false;
5031
- }
5032
- const streams = ensureDeviceLiveStreams(device);
5033
- for (const stream of streams.values()) {
5034
- if (safeString(stream?.pendingCommandId, 128) !== key) {
5035
- continue;
5036
- }
5037
- stream.pendingCommandId = '';
5038
- stream.pendingStartedAt = '';
5039
- stream.pendingRestartToken = '';
5040
- emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
5041
- streamId: stream.streamId,
5042
- commandId: key,
5670
+ function setDeviceLiveStream(device, stream) {
5671
+ if (!device || !stream?.streamId) {
5672
+ return stream;
5673
+ }
5674
+ ensureDeviceLiveStreams(device).set(stream.streamId, stream);
5675
+ device.activeLiveStream = stream;
5676
+ return stream;
5677
+ }
5678
+
5679
+ function ensureLiveStreamReplacementTimers(device) {
5680
+ if (!(device?.liveStreamReplacementTimers instanceof Map)) {
5681
+ Object.defineProperty(device, 'liveStreamReplacementTimers', {
5682
+ value: new Map(),
5683
+ enumerable: false,
5684
+ configurable: true,
5685
+ writable: true
5686
+ });
5687
+ }
5688
+ return device.liveStreamReplacementTimers;
5689
+ }
5690
+
5691
+ function getPendingLiveStreamDescriptor(stream) {
5692
+ return stream?.pendingDescriptor && typeof stream.pendingDescriptor === 'object'
5693
+ ? stream.pendingDescriptor
5694
+ : null;
5695
+ }
5696
+
5697
+ function setPendingLiveStreamDescriptor(stream, descriptor) {
5698
+ const pending = descriptor && typeof descriptor === 'object'
5699
+ ? { ...descriptor }
5700
+ : null;
5701
+ stream.pendingDescriptor = pending;
5702
+ // Keep the scalar fields for older status consumers while the
5703
+ // descriptor remains the canonical pending-generation state.
5704
+ stream.pendingCommandId = safeString(pending?.commandId, 128);
5705
+ stream.pendingCaptureGeneration = Number(pending?.captureGeneration || 0);
5706
+ stream.pendingStartedAt = safeString(pending?.startedAt, 80);
5707
+ stream.pendingRestartToken = safeString(pending?.restartToken, 128);
5708
+ return pending;
5709
+ }
5710
+
5711
+ function clearLiveStreamReplacementTimer(device, streamId) {
5712
+ const timers = ensureLiveStreamReplacementTimers(device);
5713
+ const key = safeString(streamId, 128);
5714
+ const timer = timers.get(key);
5715
+ if (timer) {
5716
+ clearTimeout(timer);
5717
+ }
5718
+ timers.delete(key);
5719
+ }
5720
+
5721
+ function clearPendingLiveStreamDescriptor(device, stream) {
5722
+ if (!stream) {
5723
+ return null;
5724
+ }
5725
+ const pending = getPendingLiveStreamDescriptor(stream);
5726
+ clearLiveStreamReplacementTimer(device, stream.streamId);
5727
+ setPendingLiveStreamDescriptor(stream, null);
5728
+ return pending;
5729
+ }
5730
+
5731
+ function scheduleLiveStreamReplacementTimeout(device, stream) {
5732
+ const pending = getPendingLiveStreamDescriptor(stream);
5733
+ if (!pending?.commandId) {
5734
+ clearLiveStreamReplacementTimer(device, stream?.streamId);
5735
+ return;
5736
+ }
5737
+ clearLiveStreamReplacementTimer(device, stream.streamId);
5738
+ const timer = setTimeout(() => {
5739
+ const current = getDeviceLiveStream(device, stream.streamId);
5740
+ const currentPending = getPendingLiveStreamDescriptor(current);
5741
+ if (currentPending?.commandId === pending.commandId) {
5742
+ failPendingLiveStream(device, pending.commandId, 'stream-start-timeout');
5743
+ }
5744
+ }, LIVE_STREAM_REPLACEMENT_PENDING_MS);
5745
+ timer.unref?.();
5746
+ ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
5747
+ }
5748
+
5749
+ function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
5750
+ const key = safeString(commandId, 128);
5751
+ if (!device || !key) {
5752
+ return false;
5753
+ }
5754
+ const streams = ensureDeviceLiveStreams(device);
5755
+ for (const stream of streams.values()) {
5756
+ const pending = getPendingLiveStreamDescriptor(stream);
5757
+ if (safeString(pending?.commandId, 128) !== key) {
5758
+ continue;
5759
+ }
5760
+ clearPendingLiveStreamDescriptor(device, stream);
5761
+ emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
5762
+ streamId: stream.streamId,
5763
+ commandId: key,
5043
5764
  error: safeString(error, 500) || 'stream-start-failed'
5044
5765
  });
5045
5766
  return true;
@@ -5047,12 +5768,13 @@ export function createRemoteHub(options = {}) {
5047
5768
  return false;
5048
5769
  }
5049
5770
 
5050
- function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5051
- const streams = ensureDeviceLiveStreams(device);
5052
- for (const stream of streams.values()) {
5053
- stream.active = false;
5054
- stream.stoppedAt = stoppedAt;
5055
- stream.stopReason = reason;
5771
+ function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5772
+ const streams = ensureDeviceLiveStreams(device);
5773
+ for (const stream of streams.values()) {
5774
+ clearPendingLiveStreamDescriptor(device, stream);
5775
+ stream.active = false;
5776
+ stream.stoppedAt = stoppedAt;
5777
+ stream.stopReason = reason;
5056
5778
  }
5057
5779
  if (device?.activeLiveStream) {
5058
5780
  device.activeLiveStream.active = false;
@@ -5061,11 +5783,30 @@ export function createRemoteHub(options = {}) {
5061
5783
  }
5062
5784
  }
5063
5785
 
5064
- function makeStableLiveStreamId(deviceId, purpose = 'wall') {
5065
- const hash = crypto.createHash('sha1').update(String(deviceId || 'device')).digest('hex').slice(0, 16);
5066
- const role = safeString(purpose, 24).toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'wall';
5067
- return `${role}-${hash}`;
5068
- }
5786
+ function makeStableLiveStreamId(deviceId, purpose = 'wall') {
5787
+ const hash = crypto.createHash('sha1').update(String(deviceId || 'device')).digest('hex').slice(0, 16);
5788
+ const role = safeString(purpose, 24).toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'wall';
5789
+ return `${role}-${hash}`;
5790
+ }
5791
+
5792
+ function getActiveLiveStreams(device) {
5793
+ if (!device || typeof device !== 'object') {
5794
+ return [];
5795
+ }
5796
+ return [...ensureDeviceLiveStreams(device).values()].filter(stream => stream?.active === true);
5797
+ }
5798
+
5799
+ function getActiveControlStream(device) {
5800
+ return getActiveLiveStreams(device).find(stream =>
5801
+ safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5802
+ }
5803
+
5804
+ function nextCaptureGeneration(device) {
5805
+ const current = Number(device?.captureGenerationCounter || 0);
5806
+ const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
5807
+ device.captureGenerationCounter = next;
5808
+ return next;
5809
+ }
5069
5810
 
5070
5811
  function normalizeLivePlatform(value) {
5071
5812
  const normalized = safeString(value, 32).toLowerCase();
@@ -5075,20 +5816,27 @@ export function createRemoteHub(options = {}) {
5075
5816
  return normalized || 'unknown';
5076
5817
  }
5077
5818
 
5078
- function normalizeLiveStreamStartOptions(options = {}) {
5079
- const transfer = buildRemoteFrameTransferDescriptor(options.mode || options.frameMode || DEFAULT_REMOTE_FRAME_MODE);
5080
- const streamPurpose = safeString(options.streamPurpose || options.purpose || 'wall', 24) || 'wall';
5081
- const platform = normalizeLivePlatform(options.platform);
5082
- const macWallProfile = platform === 'macos' && streamPurpose !== 'control';
5083
- const maxFps = streamPurpose === 'control' ? 60 : macWallProfile ? 8 : 30;
5084
- const defaultFps = macWallProfile && options.fps === undefined ? 4 : 8;
5085
- const maxWidth = clampNumber(options.maxWidth, 320, macWallProfile ? 432 : 3840, macWallProfile ? 432 : 640);
5086
- const maxHeight = clampNumber(options.maxHeight, 180, macWallProfile ? 243 : 2160, macWallProfile ? 243 : 360);
5087
- const fps = clampNumber(options.fps, 1, maxFps, defaultFps);
5088
- const quality = clampNumber(options.quality, 20, 95, 45);
5089
- const monitorIndex = normalizeMonitorIndex(options.monitorIndex ?? options.screenIndex ?? options.displayIndex);
5090
- return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose, platform, macWallProfile };
5091
- }
5819
+ function normalizeLiveStreamStartOptions(options = {}) {
5820
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || options.frameMode || DEFAULT_REMOTE_FRAME_MODE);
5821
+ const streamPurpose = safeString(options.streamPurpose || options.purpose || 'wall', 24) || 'wall';
5822
+ const platform = normalizeLivePlatform(options.platform);
5823
+ const maxFps = streamPurpose === 'control' ? 60 : 30;
5824
+ const defaultFps = 8;
5825
+ const maxWidth = clampNumber(options.maxWidth, 320, 3840, 640);
5826
+ const maxHeight = clampNumber(options.maxHeight, 180, 2160, 360);
5827
+ const fps = clampNumber(options.fps, 1, maxFps, defaultFps);
5828
+ const quality = clampNumber(options.quality, 20, 95, 45);
5829
+ const monitorIndex = normalizeMonitorIndex(options.monitorIndex ?? options.screenIndex ?? options.displayIndex);
5830
+ return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose, platform };
5831
+ }
5832
+
5833
+ function liveStreamModePolicyError(normalized) {
5834
+ if (normalized?.transfer?.frameMode === 'mode2-lzo'
5835
+ && normalized?.streamPurpose !== 'atlas') {
5836
+ return 'MODE2_ATLAS_ONLY: Mode 2 is reserved for internal Mode 4 Atlas input and cannot be used for Wall or Control.';
5837
+ }
5838
+ return '';
5839
+ }
5092
5840
 
5093
5841
  function liveStreamStartStillPending(activeLiveStream) {
5094
5842
  if (!activeLiveStream?.active || activeLiveStream.open === true) {
@@ -5098,13 +5846,35 @@ export function createRemoteHub(options = {}) {
5098
5846
  return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_PENDING_REUSE_MS;
5099
5847
  }
5100
5848
 
5101
- function liveStreamReplacementStillPending(activeLiveStream) {
5102
- if (!activeLiveStream?.active || !safeString(activeLiveStream.pendingCommandId, 128)) {
5103
- return false;
5104
- }
5105
- const startedAt = Date.parse(activeLiveStream.pendingStartedAt || '');
5106
- return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5107
- }
5849
+ function liveStreamReplacementStillPending(activeLiveStream) {
5850
+ const pending = getPendingLiveStreamDescriptor(activeLiveStream);
5851
+ if (!activeLiveStream?.active || !safeString(pending?.commandId, 128)) {
5852
+ return false;
5853
+ }
5854
+ const startedAt = Date.parse(pending.startedAt || '');
5855
+ return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5856
+ }
5857
+
5858
+ function liveStreamHasCurrentFrame(activeLiveStream) {
5859
+ if (!activeLiveStream?.active
5860
+ || activeLiveStream.open !== true
5861
+ || Number(activeLiveStream.framesReceived || 0) < 1) {
5862
+ return false;
5863
+ }
5864
+ const latestFrame = activeLiveStream.latestFrame;
5865
+ if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
5866
+ return false;
5867
+ }
5868
+ const activeCommandId = safeString(activeLiveStream.commandId, 128);
5869
+ const frameCommandId = safeString(latestFrame.commandId, 128);
5870
+ if (activeCommandId && frameCommandId !== activeCommandId) {
5871
+ return false;
5872
+ }
5873
+ const activeCaptureGeneration = Number(activeLiveStream.captureGeneration || 0);
5874
+ const frameCaptureGeneration = Number(latestFrame.captureGeneration || 0);
5875
+ return activeCaptureGeneration <= 0
5876
+ || (frameCaptureGeneration > 0 && frameCaptureGeneration === activeCaptureGeneration);
5877
+ }
5108
5878
 
5109
5879
  function getLiveStreamFreshWindowMs(activeLiveStream) {
5110
5880
  const fps = Number(activeLiveStream?.fps || 0);
@@ -5157,29 +5927,36 @@ export function createRemoteHub(options = {}) {
5157
5927
  return { ok: false, error: 'device-live-stream-unavailable' };
5158
5928
  }
5159
5929
 
5160
- const now = new Date().toISOString();
5161
- const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
5162
- const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5163
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
5164
- const activeLiveStream = getDeviceLiveStream(device, streamId);
5165
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5930
+ const now = new Date().toISOString();
5931
+ const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
5932
+ const modePolicyError = liveStreamModePolicyError(normalized);
5933
+ if (modePolicyError) return { ok: false, error: modePolicyError };
5934
+ const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5935
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
5936
+ const activeLiveStream = getDeviceLiveStream(device, streamId);
5937
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5166
5938
  if (activeLiveStream
5167
- && liveStreamMatchesOptions(activeLiveStream, normalized)
5168
- && (activeLiveStream.open === true || liveStreamStartStillPending(activeLiveStream))) {
5169
- return {
5170
- ok: true,
5171
- commandId: activeLiveStream.commandId,
5172
- streamId,
5173
- fps,
5939
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
5940
+ && (activeLiveStream.open === true || liveStreamStartStillPending(activeLiveStream))) {
5941
+ return {
5942
+ ok: true,
5943
+ commandId: activeLiveStream.commandId,
5944
+ sessionId: device.sessionId,
5945
+ streamId,
5946
+ streamPurpose,
5947
+ fps,
5174
5948
  mode: transfer.mode,
5175
- frameMode: transfer.frameMode,
5176
- monitorIndex,
5177
- synthetic: true,
5178
- reused: true
5179
- };
5180
- }
5181
- device.counters.commandsSent += 1;
5182
- setDeviceLiveStream(device, {
5949
+ frameMode: transfer.frameMode,
5950
+ monitorIndex,
5951
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5952
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5953
+ synthetic: true,
5954
+ reused: true
5955
+ };
5956
+ }
5957
+ const captureGeneration = nextCaptureGeneration(device);
5958
+ device.counters.commandsSent += 1;
5959
+ setDeviceLiveStream(device, {
5183
5960
  streamId,
5184
5961
  commandId,
5185
5962
  active: true,
@@ -5204,11 +5981,12 @@ export function createRemoteHub(options = {}) {
5204
5981
  startedAt: now,
5205
5982
  stoppedAt: '',
5206
5983
  stopReason: '',
5207
- lastFrameAt: '',
5208
- lastFrameSeq: 0,
5209
- framesReceived: 0,
5210
- streamPurpose
5211
- });
5984
+ lastFrameAt: '',
5985
+ lastFrameSeq: 0,
5986
+ framesReceived: 0,
5987
+ streamPurpose,
5988
+ captureGeneration
5989
+ });
5212
5990
  device.counters.liveStreamsStarted += 1;
5213
5991
  applySyntheticLiveFrame(device, streamId, {
5214
5992
  commandId,
@@ -5224,7 +6002,20 @@ export function createRemoteHub(options = {}) {
5224
6002
  mode: transfer.mode,
5225
6003
  synthetic: true
5226
6004
  });
5227
- return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, monitorIndex, synthetic: true };
6005
+ return {
6006
+ ok: true,
6007
+ commandId,
6008
+ sessionId: device.sessionId,
6009
+ streamId,
6010
+ streamPurpose,
6011
+ fps,
6012
+ mode: transfer.mode,
6013
+ frameMode: transfer.frameMode,
6014
+ monitorIndex,
6015
+ captureGeneration,
6016
+ ready: true,
6017
+ synthetic: true
6018
+ };
5228
6019
  }
5229
6020
 
5230
6021
  if (!device?.socket || device.socket.destroyed || !device.connected) {
@@ -5234,51 +6025,66 @@ export function createRemoteHub(options = {}) {
5234
6025
  return { ok: false, error: 'device-live-stream-unavailable' };
5235
6026
  }
5236
6027
 
5237
- const now = new Date().toISOString();
5238
- const normalized = normalizeLiveStreamStartOptions({ ...options, platform: device.platform });
5239
- const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5240
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
5241
- const activeLiveStream = getDeviceLiveStream(device, streamId);
5242
- if (activeLiveStream?.pendingCommandId) {
5243
- if (liveStreamReplacementStillPending(activeLiveStream)) {
5244
- emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
5245
- streamId,
5246
- commandId: activeLiveStream.pendingCommandId,
5247
- activeCommandId: activeLiveStream.commandId,
5248
- reason: safeString(options.restartReason || 'duplicate-restart', 128)
5249
- });
5250
- return {
5251
- ok: true,
5252
- commandId: activeLiveStream.pendingCommandId,
5253
- streamId,
5254
- fps: Number(activeLiveStream.fps || fps),
5255
- mode: activeLiveStream.mode || transfer.mode,
5256
- frameMode: activeLiveStream.frameMode || transfer.frameMode,
5257
- monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
5258
- pending: true,
5259
- reused: true
5260
- };
5261
- }
5262
- activeLiveStream.pendingCommandId = '';
5263
- activeLiveStream.pendingStartedAt = '';
5264
- activeLiveStream.pendingRestartToken = '';
5265
- }
6028
+ const now = new Date().toISOString();
6029
+ const normalized = normalizeLiveStreamStartOptions({ ...options, platform: device.platform });
6030
+ const modePolicyError = liveStreamModePolicyError(normalized);
6031
+ if (modePolicyError) return { ok: false, error: modePolicyError };
6032
+ const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6033
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6034
+ const activeLiveStream = getDeviceLiveStream(device, streamId);
6035
+ const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
6036
+ if (pendingDescriptor?.commandId) {
6037
+ const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
6038
+ if (pendingMatchesRequest && liveStreamReplacementStillPending(activeLiveStream)) {
6039
+ emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
6040
+ streamId,
6041
+ commandId: pendingDescriptor.commandId,
6042
+ activeCommandId: activeLiveStream.commandId,
6043
+ reason: safeString(options.restartReason || 'duplicate-restart', 128)
6044
+ });
6045
+ return {
6046
+ ok: true,
6047
+ commandId: pendingDescriptor.commandId,
6048
+ sessionId: device.sessionId,
6049
+ streamId,
6050
+ streamPurpose: safeString(pendingDescriptor.streamPurpose, 24) || streamPurpose,
6051
+ fps: Number(pendingDescriptor.fps || fps),
6052
+ mode: pendingDescriptor.mode || transfer.mode,
6053
+ frameMode: pendingDescriptor.frameMode || transfer.frameMode,
6054
+ monitorIndex: Number(pendingDescriptor.monitorIndex ?? monitorIndex),
6055
+ captureGeneration: Number(pendingDescriptor.captureGeneration || 0),
6056
+ ready: false,
6057
+ pending: true,
6058
+ reused: true
6059
+ };
6060
+ }
6061
+ failPendingLiveStream(
6062
+ device,
6063
+ pendingDescriptor.commandId,
6064
+ liveStreamReplacementStillPending(activeLiveStream)
6065
+ ? 'stream-restart-superseded'
6066
+ : 'stream-start-timeout');
6067
+ }
5266
6068
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5267
6069
  if (activeLiveStream
5268
6070
  && options.forceRestart !== true
5269
6071
  && options.reuseExisting === true
5270
6072
  && liveStreamMatchesOptions(activeLiveStream, normalized)
5271
6073
  && liveStreamIsReusable(activeLiveStream)) {
5272
- return {
5273
- ok: true,
5274
- commandId: activeLiveStream.commandId,
5275
- streamId,
6074
+ return {
6075
+ ok: true,
6076
+ commandId: activeLiveStream.commandId,
6077
+ sessionId: device.sessionId,
6078
+ streamId,
6079
+ streamPurpose,
5276
6080
  fps: Number(activeLiveStream.fps || fps),
5277
6081
  mode: activeLiveStream.mode || transfer.mode,
5278
- frameMode: activeLiveStream.frameMode || transfer.frameMode,
5279
- monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
5280
- reused: true
5281
- };
6082
+ frameMode: activeLiveStream.frameMode || transfer.frameMode,
6083
+ monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
6084
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
6085
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
6086
+ reused: true
6087
+ };
5282
6088
  }
5283
6089
  if (activeLiveStream
5284
6090
  && options.forceRestart !== true
@@ -5293,16 +6099,20 @@ export function createRemoteHub(options = {}) {
5293
6099
  monitorIndex
5294
6100
  });
5295
6101
  }
5296
- return {
5297
- ok: true,
5298
- commandId: activeLiveStream.commandId,
5299
- streamId,
6102
+ return {
6103
+ ok: true,
6104
+ commandId: activeLiveStream.commandId,
6105
+ sessionId: device.sessionId,
6106
+ streamId,
6107
+ streamPurpose,
5300
6108
  fps,
5301
6109
  mode: transfer.mode,
5302
- frameMode: transfer.frameMode,
5303
- monitorIndex,
5304
- reused: true
5305
- };
6110
+ frameMode: transfer.frameMode,
6111
+ monitorIndex,
6112
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
6113
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
6114
+ reused: true
6115
+ };
5306
6116
  }
5307
6117
  if (options.forceRestart === true && activeLiveStream) {
5308
6118
  emitRemoteEvent('RemoteLiveStreamForcedRestart', device, {
@@ -5329,10 +6139,11 @@ export function createRemoteHub(options = {}) {
5329
6139
  lastFrameAt: activeLiveStream.lastFrameAt || ''
5330
6140
  });
5331
6141
  }
5332
- const result = sendCommand(deviceId, {
5333
- command: 'stream.start',
5334
- commandId,
5335
- payload: {
6142
+ const captureGeneration = nextCaptureGeneration(device);
6143
+ const result = sendCommand(deviceId, {
6144
+ command: 'stream.start',
6145
+ commandId,
6146
+ payload: {
5336
6147
  streamId,
5337
6148
  mode: transfer.mode,
5338
6149
  frameMode: transfer.frameMode,
@@ -5347,34 +6158,32 @@ export function createRemoteHub(options = {}) {
5347
6158
  maxWidth,
5348
6159
  maxHeight,
5349
6160
  quality,
5350
- monitorIndex,
5351
- streamPurpose,
5352
- requestedAt: now
5353
- }
6161
+ monitorIndex,
6162
+ streamPurpose,
6163
+ captureGeneration,
6164
+ sessionId: device.sessionId,
6165
+ requestedAt: now
6166
+ }
5354
6167
  });
5355
6168
 
5356
6169
  if (!result.ok) {
5357
6170
  return result;
5358
6171
  }
5359
6172
 
5360
- const previousCommandId = safeString(activeLiveStream?.commandId, 128);
5361
- const replacingActiveStream = activeLiveStream?.active === true
5362
- && !!previousCommandId
5363
- && previousCommandId !== commandId;
5364
- setDeviceLiveStream(device, {
5365
- ...(activeLiveStream || {}),
5366
- streamId,
5367
- commandId: replacingActiveStream ? previousCommandId : commandId,
5368
- pendingCommandId: replacingActiveStream ? commandId : '',
5369
- pendingStartedAt: replacingActiveStream ? now : '',
5370
- pendingRestartToken: replacingActiveStream ? safeString(options.restartToken, 128) : '',
5371
- active: true,
5372
- open: replacingActiveStream ? activeLiveStream.open === true : false,
5373
- openedAt: replacingActiveStream ? activeLiveStream.openedAt || '' : '',
5374
- openTransport: replacingActiveStream ? activeLiveStream.openTransport || '' : '',
5375
- mode: transfer.mode,
5376
- frameMode: transfer.frameMode,
5377
- frameProfile: transfer.frameProfile,
6173
+ const previousCommandId = safeString(activeLiveStream?.commandId, 128);
6174
+ const replacingActiveStream = activeLiveStream?.active === true
6175
+ && !!previousCommandId
6176
+ && previousCommandId !== commandId;
6177
+ const nextDescriptor = {
6178
+ streamId,
6179
+ commandId,
6180
+ active: true,
6181
+ open: false,
6182
+ openedAt: '',
6183
+ openTransport: '',
6184
+ mode: transfer.mode,
6185
+ frameMode: transfer.frameMode,
6186
+ frameProfile: transfer.frameProfile,
5378
6187
  encoding: transfer.encoding,
5379
6188
  codec: transfer.codec,
5380
6189
  compression: transfer.compression,
@@ -5385,16 +6194,32 @@ export function createRemoteHub(options = {}) {
5385
6194
  maxWidth,
5386
6195
  maxHeight,
5387
6196
  quality,
5388
- monitorIndex,
5389
- monitorCount: 1,
5390
- startedAt: replacingActiveStream ? activeLiveStream.startedAt || now : now,
5391
- stoppedAt: '',
5392
- stopReason: '',
5393
- lastFrameAt: '',
6197
+ monitorIndex,
6198
+ monitorCount: 1,
6199
+ startedAt: now,
6200
+ restartToken: safeString(options.restartToken, 128),
6201
+ stoppedAt: '',
6202
+ stopReason: '',
6203
+ lastFrameAt: '',
5394
6204
  lastFrameSeq: 0,
5395
- framesReceived: 0,
5396
- streamPurpose
5397
- });
6205
+ framesReceived: 0,
6206
+ streamPurpose,
6207
+ captureGeneration,
6208
+ latestFrame: null,
6209
+ width: 0,
6210
+ height: 0,
6211
+ sourceWidth: 0,
6212
+ sourceHeight: 0
6213
+ };
6214
+ if (replacingActiveStream) {
6215
+ setPendingLiveStreamDescriptor(activeLiveStream, nextDescriptor);
6216
+ scheduleLiveStreamReplacementTimeout(device, activeLiveStream);
6217
+ setDeviceLiveStream(device, activeLiveStream);
6218
+ } else {
6219
+ const nextStreamState = { ...nextDescriptor };
6220
+ setPendingLiveStreamDescriptor(nextStreamState, null);
6221
+ setDeviceLiveStream(device, nextStreamState);
6222
+ }
5398
6223
  device.counters.liveStreamsStarted += 1;
5399
6224
  emitRemoteEvent('RemoteLiveStreamStarted', device, {
5400
6225
  streamId,
@@ -5407,7 +6232,19 @@ export function createRemoteHub(options = {}) {
5407
6232
  monitorIndex
5408
6233
  });
5409
6234
 
5410
- return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, monitorIndex };
6235
+ return {
6236
+ ok: true,
6237
+ commandId,
6238
+ sessionId: device.sessionId,
6239
+ streamId,
6240
+ streamPurpose,
6241
+ fps,
6242
+ mode: transfer.mode,
6243
+ frameMode: transfer.frameMode,
6244
+ monitorIndex,
6245
+ captureGeneration,
6246
+ ready: false
6247
+ };
5411
6248
  }
5412
6249
 
5413
6250
  function stopLiveStream(deviceId, options = {}) {
@@ -5417,24 +6254,25 @@ export function createRemoteHub(options = {}) {
5417
6254
  const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
5418
6255
  const streamId = requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
5419
6256
  const streamState = getDeviceLiveStream(device, streamId);
5420
- if (streamState && streamState.active !== true) {
5421
- return {
6257
+ if (streamState && streamState.active !== true) {
6258
+ clearPendingLiveStreamDescriptor(device, streamState);
6259
+ return {
5422
6260
  ok: true,
5423
6261
  commandId: safeString(options.commandId, 128),
5424
6262
  streamId,
5425
- alreadyStopped: true
5426
- };
5427
- }
5428
- if (device?.synthetic === true && device.connected) {
5429
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5430
- device.counters.commandsSent += 1;
5431
- if (streamState) {
5432
- streamState.active = false;
5433
- streamState.pendingCommandId = '';
5434
- streamState.pendingStartedAt = '';
5435
- streamState.pendingRestartToken = '';
5436
- streamState.stoppedAt = new Date().toISOString();
5437
- streamState.stopReason = 'manager-request';
6263
+ alreadyStopped: true
6264
+ };
6265
+ }
6266
+ if (streamState) {
6267
+ clearPendingLiveStreamDescriptor(device, streamState);
6268
+ }
6269
+ if (device?.synthetic === true && device.connected) {
6270
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6271
+ device.counters.commandsSent += 1;
6272
+ if (streamState) {
6273
+ streamState.active = false;
6274
+ streamState.stoppedAt = new Date().toISOString();
6275
+ streamState.stopReason = 'manager-request';
5438
6276
  }
5439
6277
  device.counters.liveStreamsStopped += 1;
5440
6278
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
@@ -5462,14 +6300,11 @@ export function createRemoteHub(options = {}) {
5462
6300
  if (!result.ok) {
5463
6301
  return result;
5464
6302
  }
5465
-
5466
- if (streamState) {
5467
- streamState.active = false;
5468
- streamState.pendingCommandId = '';
5469
- streamState.pendingStartedAt = '';
5470
- streamState.pendingRestartToken = '';
5471
- streamState.stoppedAt = new Date().toISOString();
5472
- streamState.stopReason = 'manager-request';
6303
+
6304
+ if (streamState) {
6305
+ streamState.active = false;
6306
+ streamState.stoppedAt = new Date().toISOString();
6307
+ streamState.stopReason = 'manager-request';
5473
6308
  }
5474
6309
  device.counters.liveStreamsStopped += 1;
5475
6310
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
@@ -5634,9 +6469,10 @@ export function createRemoteHub(options = {}) {
5634
6469
  getTaskBatch,
5635
6470
  cancelTaskBatch,
5636
6471
  retryTaskBatch,
5637
- listDeviceFrames,
5638
- disconnectDevice,
5639
- sendCommand,
6472
+ listDeviceFrames,
6473
+ disconnectDevice,
6474
+ assignDeviceSlot,
6475
+ sendCommand,
5640
6476
  sendLegacyClientUpdate,
5641
6477
  sendInputControl,
5642
6478
  notifyAgentProgress,