@livedesk/hub 0.1.6 → 0.1.8

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
@@ -28,6 +28,7 @@ const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
28
28
  const LIVE_STREAM_MIN_FRESH_MS = 3000;
29
29
  const LIVE_STREAM_MAX_FRESH_MS = 12000;
30
30
  const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
31
+ const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
31
32
  const REMOTE_PROTOCOL_VERSION = 2;
32
33
  const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
33
34
  const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
@@ -154,7 +155,10 @@ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
154
155
  const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
155
156
  const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 8;
156
157
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
157
- const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 64;
158
+ // Native capture backends can emit short bursts (ScreenCaptureKit commonly
159
+ // queues up to eight frames). Drain one frame per event-loop turn so a burst
160
+ // cannot immediately overflow the smaller Hub-to-browser send lanes.
161
+ const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 1;
158
162
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
159
163
  const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
160
164
  const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
@@ -683,9 +687,18 @@ function normalizeRemoteInputEvent(value = {}) {
683
687
  const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
684
688
  const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
685
689
  const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
690
+ const rawPressedCodes = input.pressedCodes ?? input.PressedCodes;
691
+ const pressedCodes = Array.isArray(rawPressedCodes)
692
+ ? [...new Set(rawPressedCodes
693
+ .map(code => safeString(code, 80))
694
+ .filter(Boolean))]
695
+ .slice(0, 64)
696
+ : null;
686
697
  return {
687
698
  type,
688
- monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : 0,
699
+ // A missing monitor is not equivalent to the primary display. Control
700
+ // input must prove the exact monitor owned by its capture generation.
701
+ monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : null,
689
702
  normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
690
703
  normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
691
704
  button: safeString(input.button || input.Button, 24),
@@ -695,8 +708,9 @@ function normalizeRemoteInputEvent(value = {}) {
695
708
  code: safeString(input.code || input.Code, 80),
696
709
  keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
697
710
  location: Number.isFinite(location) ? Math.max(0, Math.min(255, Math.trunc(location))) : 0,
698
- text: safeText(input.text || input.Text, 256),
699
- repeat: input.repeat === true || input.Repeat === true,
711
+ text: safeText(input.text || input.Text, 256),
712
+ repeat: input.repeat === true || input.Repeat === true,
713
+ pressedCodes,
700
714
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
701
715
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
702
716
  altKey: input.altKey === true || input.AltKey === true,
@@ -708,8 +722,9 @@ function normalizeRemoteInputEvent(value = {}) {
708
722
  hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
709
723
  inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
710
724
  inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
711
- requestAck: input.requestAck !== false && input.RequestAck !== false,
712
- issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
725
+ requestAck: input.requestAck !== false && input.RequestAck !== false,
726
+ reason: safeString(input.reason || input.Reason, 120),
727
+ issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
713
728
  hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
714
729
  issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
715
730
  };
@@ -1014,10 +1029,26 @@ function serializeDevice(device, options = {}) {
1014
1029
  audio: !!device.audioSocket && !device.audioSocket.destroyed,
1015
1030
  file: !!device.fileSocket && !device.fileSocket.destroyed
1016
1031
  },
1017
- latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1018
- latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1019
- activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
1020
- activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
1032
+ latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1033
+ latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1034
+ activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
1035
+ liveCapturePause: device.liveCapturePause
1036
+ ? {
1037
+ token: device.liveCapturePause.token,
1038
+ pausedAt: device.liveCapturePause.pausedAt,
1039
+ reason: device.liveCapturePause.reason,
1040
+ streamId: device.liveCapturePause.streamId,
1041
+ streamPurpose: device.liveCapturePause.streamPurpose,
1042
+ monitorIndex: device.liveCapturePause.monitorIndex,
1043
+ phase: device.liveCapturePause.phase,
1044
+ captureStopConfirmed: device.liveCapturePause.captureStopConfirmed === true,
1045
+ captureStopConfirmedAt: device.liveCapturePause.captureStopConfirmedAt || '',
1046
+ stopCommandId: device.liveCapturePause.stopCommandId || '',
1047
+ stopError: device.liveCapturePause.stopError || '',
1048
+ stopAttemptCount: Math.max(0, Number(device.liveCapturePause.stopAttemptCount) || 0)
1049
+ }
1050
+ : null,
1051
+ activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
1021
1052
  latestAudioFrame: device.latestAudioFrame
1022
1053
  ? {
1023
1054
  streamId: device.latestAudioFrame.streamId,
@@ -1454,7 +1485,9 @@ class RemoteHubWebSocketAgentSocket {
1454
1485
  }
1455
1486
 
1456
1487
  export function createRemoteHub(options = {}) {
1457
- const env = options.env || process.env;
1488
+ const env = options.env || process.env;
1489
+ const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
1490
+ && isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
1458
1491
  const logEvent = options.logEvent || (() => {});
1459
1492
  const logWarn = options.logWarn || (() => {});
1460
1493
  const emitEvent = options.emitEvent || (() => {});
@@ -1506,6 +1539,11 @@ export function createRemoteHub(options = {}) {
1506
1539
  250,
1507
1540
  30000,
1508
1541
  DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
1542
+ const liveCaptureStopAckTimeoutMs = clampNumber(
1543
+ options.liveCaptureStopAckTimeoutMs ?? env.REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS,
1544
+ 250,
1545
+ 30000,
1546
+ DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS);
1509
1547
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1510
1548
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1511
1549
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1534,9 +1572,10 @@ export function createRemoteHub(options = {}) {
1534
1572
  const sockets = new Map();
1535
1573
  const inputSockets = new Map();
1536
1574
  const frameSockets = new Map();
1537
- const audioSockets = new Map();
1538
- const fileSockets = new Map();
1539
- const allSockets = new Set();
1575
+ const audioSockets = new Map();
1576
+ const fileSockets = new Map();
1577
+ const allSockets = new Set();
1578
+ const pendingCommandResultWaiters = new Map();
1540
1579
  const duplicateDeviceLogAt = new Map();
1541
1580
  let server = null;
1542
1581
  let started = false;
@@ -2044,15 +2083,109 @@ export function createRemoteHub(options = {}) {
2044
2083
  });
2045
2084
  }
2046
2085
 
2047
- function emitRemoteEvent(type, device = null, extra = {}) {
2048
- emitEvent(type, {
2049
- ...extra,
2050
- remoteHub: getStatus({ includeSecrets: false }),
2051
- device: serializeDevice(device)
2052
- });
2053
- }
2054
-
2055
- function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
2086
+ function emitRemoteEvent(type, device = null, extra = {}) {
2087
+ emitEvent(type, {
2088
+ ...extra,
2089
+ remoteHub: getStatus({ includeSecrets: false }),
2090
+ device: serializeDevice(device)
2091
+ });
2092
+ }
2093
+
2094
+ function settlePendingCommandResult(device, message = {}) {
2095
+ const commandId = safeString(message.commandId, 128);
2096
+ const waiter = pendingCommandResultWaiters.get(commandId);
2097
+ if (!waiter
2098
+ || waiter.deviceId !== device?.deviceId
2099
+ || waiter.sessionId !== device?.sessionId) {
2100
+ return false;
2101
+ }
2102
+
2103
+ pendingCommandResultWaiters.delete(commandId);
2104
+ clearTimeout(waiter.timer);
2105
+ const result = message.result ?? null;
2106
+ const error = safeString(
2107
+ message.error
2108
+ || result?.error
2109
+ || (message.type === 'command.error' ? 'command-failed' : ''),
2110
+ 500);
2111
+ waiter.resolve({
2112
+ ok: !error
2113
+ && result?.ok !== false
2114
+ && result?.status !== 'failed'
2115
+ && result?.status !== 'rejected',
2116
+ commandId,
2117
+ result,
2118
+ error
2119
+ });
2120
+ return true;
2121
+ }
2122
+
2123
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
2124
+ if (!device?.deviceId) {
2125
+ return;
2126
+ }
2127
+ for (const [commandId, waiter] of pendingCommandResultWaiters) {
2128
+ if (waiter.deviceId !== device.deviceId
2129
+ || (waiter.sessionId && waiter.sessionId !== device.sessionId)) {
2130
+ continue;
2131
+ }
2132
+ pendingCommandResultWaiters.delete(commandId);
2133
+ clearTimeout(waiter.timer);
2134
+ waiter.resolve({
2135
+ ok: false,
2136
+ commandId,
2137
+ result: null,
2138
+ error: safeString(error, 500) || 'command-result-unavailable'
2139
+ });
2140
+ }
2141
+ }
2142
+
2143
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
2144
+ const key = safeString(commandId, 128);
2145
+ if (!device?.deviceId || !device?.sessionId || !key) {
2146
+ return Promise.resolve({
2147
+ ok: false,
2148
+ commandId: key,
2149
+ result: null,
2150
+ error: 'command-result-wait-invalid'
2151
+ });
2152
+ }
2153
+
2154
+ return new Promise(resolve => {
2155
+ const previous = pendingCommandResultWaiters.get(key);
2156
+ if (previous) {
2157
+ clearTimeout(previous.timer);
2158
+ previous.resolve({
2159
+ ok: false,
2160
+ commandId: key,
2161
+ result: null,
2162
+ error: 'command-result-wait-replaced'
2163
+ });
2164
+ }
2165
+ const timer = setTimeout(() => {
2166
+ const waiter = pendingCommandResultWaiters.get(key);
2167
+ if (!waiter || waiter.deviceId !== device.deviceId || waiter.sessionId !== device.sessionId) {
2168
+ return;
2169
+ }
2170
+ pendingCommandResultWaiters.delete(key);
2171
+ resolve({
2172
+ ok: false,
2173
+ commandId: key,
2174
+ result: null,
2175
+ error: 'command-result-timeout'
2176
+ });
2177
+ }, timeoutMs);
2178
+ timer.unref?.();
2179
+ pendingCommandResultWaiters.set(key, {
2180
+ deviceId: device.deviceId,
2181
+ sessionId: device.sessionId,
2182
+ timer,
2183
+ resolve
2184
+ });
2185
+ });
2186
+ }
2187
+
2188
+ function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
2056
2189
  const now = new Date().toISOString();
2057
2190
  const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
2058
2191
  const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
@@ -2144,9 +2277,12 @@ export function createRemoteHub(options = {}) {
2144
2277
  byteLength: frame.byteLength
2145
2278
  });
2146
2279
  streamState.latestFrame = frame;
2147
- streamState.lastFrameAt = frame.receivedAt;
2148
- streamState.lastFrameSeq = frame.frameSeq;
2149
- streamState.framesReceived = (streamState.framesReceived || 0) + 1;
2280
+ streamState.lastFrameAt = frame.receivedAt;
2281
+ streamState.lastFrameSeq = frame.frameSeq;
2282
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
2283
+ // A synthetic PNG is a complete independently decodable surface and
2284
+ // therefore key-frame equivalent for Control readiness.
2285
+ streamState.readyFrameReceived = true;
2150
2286
  device.lastSeenAt = frame.receivedAt;
2151
2287
  device.counters.liveFramesReceived += 1;
2152
2288
  emitRemoteEvent('RemoteFrameReceived', device, {
@@ -2170,6 +2306,7 @@ export function createRemoteHub(options = {}) {
2170
2306
  existing.lastDisconnectReason = 'replaced-by-new-session';
2171
2307
  failAllPendingTasks(existing, 'replaced-by-new-session');
2172
2308
  failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2309
+ failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
2173
2310
  writeJsonLine(existing.socket, {
2174
2311
  type: 'disconnect',
2175
2312
  reason: 'replaced-by-new-session',
@@ -2709,9 +2846,11 @@ export function createRemoteHub(options = {}) {
2709
2846
  : getSupportedRemoteFrameModeProfiles();
2710
2847
  }
2711
2848
 
2712
- const device = {
2713
- socket,
2714
- inputSocket: null,
2849
+ const device = {
2850
+ socket,
2851
+ inputSocket: null,
2852
+ inputOwnerConnectionId: '',
2853
+ inputOwnerBindingKey: '',
2715
2854
  frameSocket: null,
2716
2855
  audioSocket: null,
2717
2856
  fileSocket: null,
@@ -2752,9 +2891,13 @@ export function createRemoteHub(options = {}) {
2752
2891
  recentFramePayloads: {
2753
2892
  thumbnail: [],
2754
2893
  live: []
2755
- },
2756
- activeLiveStream: null,
2757
- activeAudioStream: null,
2894
+ },
2895
+ activeLiveStream: null,
2896
+ // A manual pause is a Hub-owned capture gate, not an Agent-session
2897
+ // detail. Preserve the same lease across a transient Client
2898
+ // reconnect so no subscriber or watchdog can reacquire FFmpeg.
2899
+ liveCapturePause: existing?.liveCapturePause || null,
2900
+ activeAudioStream: null,
2758
2901
  latestAudioFrame: null,
2759
2902
  latestAudioStatus: null,
2760
2903
  udp: {
@@ -2943,10 +3086,11 @@ export function createRemoteHub(options = {}) {
2943
3086
  closeInputSocket(device, reason);
2944
3087
  closeFrameSocket(device, reason);
2945
3088
  device.disconnectedAt = new Date().toISOString();
2946
- device.lastDisconnectReason = reason;
3089
+ device.lastDisconnectReason = reason;
2947
3090
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2948
3091
  failAllPendingTasks(device, 'device-disconnected');
2949
3092
  failAllPendingInputFallbacks(device, 'device-disconnected');
3093
+ failPendingCommandResultWaiters(device, 'device-disconnected');
2950
3094
  logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2951
3095
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2952
3096
  }
@@ -3329,6 +3473,20 @@ export function createRemoteHub(options = {}) {
3329
3473
  return {
3330
3474
  deviceId: device.deviceId,
3331
3475
  commandId: safeString(message.commandId || message.CommandId || pending?.commandId, 128),
3476
+ hubConnectionId: safeString(
3477
+ message.hubConnectionId
3478
+ || message.HubConnectionId
3479
+ || result.hubConnectionId
3480
+ || result.HubConnectionId
3481
+ || pending?.hubConnectionId,
3482
+ 128),
3483
+ inputEventId: safeString(
3484
+ message.inputEventId
3485
+ || message.InputEventId
3486
+ || result.inputEventId
3487
+ || result.InputEventId
3488
+ || pending?.inputEventId,
3489
+ 128),
3332
3490
  inputSeq,
3333
3491
  inputType: safeString(
3334
3492
  message.inputType
@@ -3337,6 +3495,13 @@ export function createRemoteHub(options = {}) {
3337
3495
  || result.InputType
3338
3496
  || pending?.inputType,
3339
3497
  48),
3498
+ monitorIndex: Number(
3499
+ message.monitorIndex
3500
+ ?? message.MonitorIndex
3501
+ ?? result.monitorIndex
3502
+ ?? result.MonitorIndex
3503
+ ?? pending?.monitorIndex
3504
+ ?? 0) || 0,
3340
3505
  issuedAtEpochMs: Number(
3341
3506
  message.issuedAtEpochMs
3342
3507
  ?? message.IssuedAtEpochMs
@@ -3602,13 +3767,35 @@ export function createRemoteHub(options = {}) {
3602
3767
  : 0;
3603
3768
  }
3604
3769
 
3605
- function readFrameStageMs(message, property) {
3606
- const value = Number(message?.[property]);
3607
- return Number.isFinite(value) && value >= 0
3608
- ? Math.round(value)
3609
- : 0;
3610
- }
3611
-
3770
+ function readFrameStageMs(message, property) {
3771
+ const value = Number(message?.[property]);
3772
+ return Number.isFinite(value) && value >= 0
3773
+ ? Math.round(value)
3774
+ : 0;
3775
+ }
3776
+
3777
+ function readOptionalFrameStageMs(message, property) {
3778
+ const rawValue = message?.[property];
3779
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
3780
+ return undefined;
3781
+ }
3782
+ const value = Number(rawValue);
3783
+ return Number.isFinite(value) && value >= 0
3784
+ ? Math.round(value)
3785
+ : undefined;
3786
+ }
3787
+
3788
+ function hasFrameCaptureTiming(message) {
3789
+ if (message?.captureTimingAvailable === false) {
3790
+ return false;
3791
+ }
3792
+ if (message?.captureTimingAvailable === true) {
3793
+ return true;
3794
+ }
3795
+ return ['captureP95Ms', 'captureAverageMs', 'captureStageMs']
3796
+ .some(property => readOptionalFrameStageMs(message, property) !== undefined);
3797
+ }
3798
+
3612
3799
  function computeSameContentStreak(previousFrame, contentHash) {
3613
3800
  if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
3614
3801
  return 0;
@@ -3682,7 +3869,7 @@ export function createRemoteHub(options = {}) {
3682
3869
  byteLength,
3683
3870
  transport,
3684
3871
  contentHash,
3685
- captureMs: readFrameCaptureMs(message),
3872
+ captureMs: readFrameCaptureMs(message),
3686
3873
  captureStageMs: readFrameStageMs(message, 'captureStageMs'),
3687
3874
  convertMs: readFrameStageMs(message, 'convertMs'),
3688
3875
  compressMs: readFrameStageMs(message, 'compressMs'),
@@ -3774,6 +3961,7 @@ export function createRemoteHub(options = {}) {
3774
3961
  lastFrameAt: '',
3775
3962
  lastFrameSeq: 0,
3776
3963
  framesReceived: 0,
3964
+ readyFrameReceived: false,
3777
3965
  latestFrame: null,
3778
3966
  stoppedAt: '',
3779
3967
  stopReason: ''
@@ -3991,6 +4179,13 @@ export function createRemoteHub(options = {}) {
3991
4179
  && (activeCaptureGeneration <= 0
3992
4180
  || (frameCaptureGeneration > 0
3993
4181
  && frameCaptureGeneration === activeCaptureGeneration));
4182
+ const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
4183
+ const captureTimingAvailable = hasFrameCaptureTiming(message);
4184
+ const videoTransport = transport === 'udp-p2p'
4185
+ ? 'p2p-udp'
4186
+ : transport === 'ws-binary'
4187
+ ? 'direct-ws'
4188
+ : 'direct-tcp';
3994
4189
  device.latestLiveFrame = {
3995
4190
  streamId,
3996
4191
  frameSeq,
@@ -4027,9 +4222,10 @@ export function createRemoteHub(options = {}) {
4027
4222
  agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
4028
4223
  agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
4029
4224
  agentFrameAgeMs: Number.isFinite(Number(message.agentFrameAgeMs)) ? Number(message.agentFrameAgeMs) : 0,
4030
- droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4031
- hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4032
- hardwareEncoder: safeString(message.hardwareEncoder, 80),
4225
+ droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4226
+ hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4227
+ udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
4228
+ hardwareEncoder: safeString(message.hardwareEncoder, 80),
4033
4229
  platformProfile: safeString(message.platformProfile, 80),
4034
4230
  monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
4035
4231
  monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
@@ -4037,29 +4233,40 @@ export function createRemoteHub(options = {}) {
4037
4233
  transferProtocolVersion: transfer.transferProtocolVersion,
4038
4234
  handshake: transfer.handshake,
4039
4235
  fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : streamState.fps,
4040
- requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
4041
- effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
4042
- captureAverageMs: readFrameStageMs(message, 'captureAverageMs'),
4043
- captureP95Ms: readFrameStageMs(message, 'captureP95Ms'),
4236
+ requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
4237
+ effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
4238
+ captureTimingAvailable,
4239
+ captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
4240
+ captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
4044
4241
  slowFrameCount: Number.isFinite(Number(message.slowFrameCount)) ? Number(message.slowFrameCount) : 0,
4045
- captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4046
- capturedAt,
4047
- receivedAt: device.lastSeenAt,
4048
- byteLength,
4049
- transport,
4050
- contentHash,
4051
- captureMs: readFrameCaptureMs(message),
4052
- captureStageMs: readFrameStageMs(message, 'captureStageMs'),
4242
+ captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4243
+ capturedAt,
4244
+ receivedAt: device.lastSeenAt,
4245
+ byteLength,
4246
+ transport,
4247
+ contentHash,
4248
+ captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
4249
+ captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
4053
4250
  convertMs: readFrameStageMs(message, 'convertMs'),
4054
4251
  compressMs: readFrameStageMs(message, 'compressMs'),
4055
4252
  captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
4056
4253
  captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
4057
4254
  deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
4058
4255
  deltaTileCount: Number.isFinite(Number(message.deltaTileCount)) ? Number(message.deltaTileCount) : 0,
4059
- deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
4060
- deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4061
- deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4062
- sameContentStreak,
4256
+ deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
4257
+ deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4258
+ deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4259
+ videoTransport,
4260
+ frameTransportActive: safeString(message.frameTransportActive, 20) || (videoTransport === 'p2p-udp' ? 'p2p' : 'direct'),
4261
+ frameTransportProtocol: safeString(message.frameTransportProtocol, 20)
4262
+ || (videoTransport === 'p2p-udp' ? 'udp' : videoTransport === 'direct-ws' ? 'ws' : 'tcp'),
4263
+ frameTransportState: safeString(message.frameTransportState, 20) || 'active',
4264
+ frameTransportP2pReady: message.frameTransportP2pReady === true,
4265
+ frameTransportReason: safeString(message.frameTransportReason, 240),
4266
+ frameTransportChangedAt: safeString(message.frameTransportChangedAt, 80),
4267
+ frameTransportSwitchCount: Number.isFinite(Number(message.frameTransportSwitchCount)) ? Number(message.frameTransportSwitchCount) : 0,
4268
+ frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
4269
+ sameContentStreak,
4063
4270
  payload,
4064
4271
  accessToken: createFrameAccessToken()
4065
4272
  };
@@ -4073,9 +4280,12 @@ export function createRemoteHub(options = {}) {
4073
4280
  mimeType: device.latestLiveFrame.mimeType,
4074
4281
  byteLength: device.latestLiveFrame.byteLength
4075
4282
  });
4076
- streamState.lastFrameAt = device.lastSeenAt;
4077
- streamState.lastFrameSeq = frameSeq;
4078
- streamState.framesReceived = (streamState.framesReceived || 0) + 1;
4283
+ streamState.lastFrameAt = device.lastSeenAt;
4284
+ streamState.lastFrameSeq = frameSeq;
4285
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
4286
+ streamState.readyFrameReceived = readyFrameReceivedBeforeFrame
4287
+ || (currentGenerationVerified
4288
+ && liveFrameSatisfiesReadiness(streamState, device.latestLiveFrame));
4079
4289
  streamState.mode = transfer.mode;
4080
4290
  streamState.frameMode = transfer.frameMode;
4081
4291
  streamState.frameProfile = transfer.frameProfile;
@@ -4093,7 +4303,9 @@ export function createRemoteHub(options = {}) {
4093
4303
  streamState.monitorCount = device.latestLiveFrame.monitorCount;
4094
4304
  ensureDeviceLiveStreams(device).set(streamId, streamState);
4095
4305
  device.counters.liveFramesReceived += 1;
4096
- if (streamState.framesReceived === 1 && liveStreamHasCurrentFrame(streamState)) {
4306
+ if (!readyFrameReceivedBeforeFrame
4307
+ && streamState.readyFrameReceived === true
4308
+ && liveStreamHasCurrentFrame(streamState)) {
4097
4309
  emitRemoteEvent('RemoteLiveStreamReady', device, {
4098
4310
  streamId,
4099
4311
  commandId: streamState.commandId,
@@ -4217,7 +4429,7 @@ export function createRemoteHub(options = {}) {
4217
4429
  return true;
4218
4430
  }
4219
4431
 
4220
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
4432
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
4221
4433
  if (!state.authenticated || !state.device) {
4222
4434
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
4223
4435
  socket.destroy();
@@ -4241,28 +4453,29 @@ export function createRemoteHub(options = {}) {
4241
4453
  }
4242
4454
 
4243
4455
  const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
4244
- if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4245
- applyThumbnailFrame(device, header, framePayload, 'binary');
4246
- return;
4247
- }
4248
-
4249
- if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4250
- applyLiveFrame(device, {
4251
- ...header,
4252
- hubIngressDropped: Number(state.binaryQueueDrops || 0)
4253
- }, framePayload, 'binary');
4254
- return;
4255
- }
4256
-
4257
- if (frameKind === 'audio' || header.type === 'audio.binary') {
4258
- applyAudioFrame(device, header, framePayload, 'binary');
4259
- return;
4260
- }
4261
-
4262
- if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
4263
- applyAudioStatus(device, header, framePayload, 'binary');
4264
- return;
4265
- }
4456
+ const binaryTransport = state.binaryTransport === 'ws-binary' ? 'ws-binary' : 'binary';
4457
+ if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4458
+ applyThumbnailFrame(device, header, framePayload, binaryTransport);
4459
+ return;
4460
+ }
4461
+
4462
+ if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4463
+ applyLiveFrame(device, {
4464
+ ...header,
4465
+ hubIngressDropped: Number(state.binaryQueueDrops || 0)
4466
+ }, framePayload, binaryTransport);
4467
+ return;
4468
+ }
4469
+
4470
+ if (frameKind === 'audio' || header.type === 'audio.binary') {
4471
+ applyAudioFrame(device, header, framePayload, binaryTransport);
4472
+ return;
4473
+ }
4474
+
4475
+ if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
4476
+ applyAudioStatus(device, header, framePayload, binaryTransport);
4477
+ return;
4478
+ }
4266
4479
 
4267
4480
  emitRemoteEvent('RemoteAgentMessageIgnored', device, {
4268
4481
  messageType: safeString(header.type, 80),
@@ -4440,9 +4653,14 @@ export function createRemoteHub(options = {}) {
4440
4653
  state.device = device;
4441
4654
  state.inputOnly = true;
4442
4655
  return;
4443
- }
4444
- if (channel === 'frame') {
4445
- const device = attachFrameSocket(socket, message);
4656
+ }
4657
+ if (channel === 'frame') {
4658
+ if (rejectFrameChannelForTest) {
4659
+ writeJsonLine(socket, { type: 'error', error: 'test-frame-channel-unavailable' });
4660
+ socket.end();
4661
+ return;
4662
+ }
4663
+ const device = attachFrameSocket(socket, message);
4446
4664
  if (!device) {
4447
4665
  return;
4448
4666
  }
@@ -4493,23 +4711,12 @@ export function createRemoteHub(options = {}) {
4493
4711
 
4494
4712
  if (state.inputOnly) {
4495
4713
  device.inputLastSeenAt = new Date().toISOString();
4496
- if (message.type === 'input.applied') {
4714
+ if (message.type === 'input.applied' || message.type === 'input.error') {
4497
4715
  handleRemoteInputOutcome(device, {
4498
4716
  ...message,
4499
4717
  agentApplyMs: Number(message.agentApplyMs || 0) || 0
4500
4718
  }, 'input');
4501
4719
  }
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
4720
  return;
4514
4721
  }
4515
4722
  if (state.frameOnly) {
@@ -4553,6 +4760,7 @@ export function createRemoteHub(options = {}) {
4553
4760
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4554
4761
  500);
4555
4762
  const result = message.result ?? null;
4763
+ settlePendingCommandResult(device, message);
4556
4764
  handleInputFallbackCommandResult(device, message);
4557
4765
  if (error || result?.ok === false || result?.status === 'failed') {
4558
4766
  failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
@@ -4606,10 +4814,11 @@ export function createRemoteHub(options = {}) {
4606
4814
  socket.setNoDelay(true);
4607
4815
  socket.setKeepAlive(true, heartbeatMs);
4608
4816
 
4609
- const state = {
4610
- authenticated: false,
4611
- device: null,
4612
- inputOnly: false,
4817
+ const state = {
4818
+ authenticated: false,
4819
+ device: null,
4820
+ binaryTransport: 'ws-binary',
4821
+ inputOnly: false,
4613
4822
  frameOnly: false,
4614
4823
  audioOnly: false,
4615
4824
  fileOnly: false,
@@ -4763,10 +4972,11 @@ export function createRemoteHub(options = {}) {
4763
4972
  socket.setNoDelay(true);
4764
4973
  socket.setKeepAlive(true, heartbeatMs);
4765
4974
 
4766
- const state = {
4767
- authenticated: false,
4768
- device: null,
4769
- inputOnly: false,
4975
+ const state = {
4976
+ authenticated: false,
4977
+ device: null,
4978
+ binaryTransport: 'binary',
4979
+ inputOnly: false,
4770
4980
  frameOnly: false,
4771
4981
  audioOnly: false,
4772
4982
  fileOnly: false,
@@ -5000,6 +5210,7 @@ export function createRemoteHub(options = {}) {
5000
5210
  for (const device of devices.values()) {
5001
5211
  failAllPendingTasks(device, 'hub-shutdown');
5002
5212
  failAllPendingInputFallbacks(device, 'hub-shutdown');
5213
+ failPendingCommandResultWaiters(device, 'hub-shutdown');
5003
5214
  if (device.socket && !device.socket.destroyed) {
5004
5215
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
5005
5216
  device.socket.destroy();
@@ -5240,10 +5451,58 @@ export function createRemoteHub(options = {}) {
5240
5451
  return { ok: false, error: 'device-not-found' };
5241
5452
  }
5242
5453
 
5243
- if (!device.connected) {
5244
- return { ok: false, error: 'device-not-connected' };
5245
- }
5246
-
5454
+ if (!device.connected) {
5455
+ return { ok: false, error: 'device-not-connected' };
5456
+ }
5457
+
5458
+ const normalized = normalizeRemoteInputEvent(input);
5459
+ if (!normalized.type) {
5460
+ return { ok: false, error: 'missing-input-type' };
5461
+ }
5462
+
5463
+ // A reset only releases native key state. The current browser input
5464
+ // owner must be able to send it even when its former Control binding
5465
+ // has just become stale during a monitor/capture-generation switch.
5466
+ const currentInputOwner = safeString(device.inputOwnerConnectionId, 128);
5467
+ const isCurrentOwnerKeyboardReset = normalized.type.toLowerCase() === 'keyboard.reset'
5468
+ && normalized.hubConnectionId
5469
+ && normalized.hubConnectionId === currentInputOwner;
5470
+ if (isCurrentOwnerKeyboardReset) {
5471
+ const inputSocket = device.inputSocket;
5472
+ if (inputSocket && !inputSocket.destroyed) {
5473
+ const sent = writeJsonLine(inputSocket, {
5474
+ type: 'input.control',
5475
+ payload: {
5476
+ ...normalized,
5477
+ reason: normalized.reason || 'control-keyboard-owner-changed',
5478
+ pressedCodes: [],
5479
+ shiftKey: false,
5480
+ ctrlKey: false,
5481
+ altKey: false,
5482
+ metaKey: false,
5483
+ repeat: false,
5484
+ hubForwardedAtEpochMs: Date.now(),
5485
+ issuedAt: normalized.issuedAt || new Date().toISOString()
5486
+ }
5487
+ });
5488
+ if (sent) {
5489
+ device.inputOwnerBindingKey = '';
5490
+ device.counters.commandsSent += 1;
5491
+ device.inputLastSeenAt = new Date().toISOString();
5492
+ return {
5493
+ ok: true,
5494
+ inputSocket: true,
5495
+ releaseOnly: true,
5496
+ staleBindingAccepted: true
5497
+ };
5498
+ }
5499
+ detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
5500
+ }
5501
+ if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
5502
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5503
+ }
5504
+ }
5505
+
5247
5506
  const denied = policyError(device, 'allowControl');
5248
5507
  if (denied) return { ok: false, error: denied };
5249
5508
 
@@ -5255,10 +5514,6 @@ export function createRemoteHub(options = {}) {
5255
5514
  return { ok: false, error: 'device-input-control-unavailable' };
5256
5515
  }
5257
5516
 
5258
- const normalized = normalizeRemoteInputEvent(input);
5259
- if (!normalized.type) {
5260
- return { ok: false, error: 'missing-input-type' };
5261
- }
5262
5517
  const controlStream = getActiveControlStream(device);
5263
5518
  if (!controlStream
5264
5519
  || !liveStreamHasCurrentFrame(controlStream)
@@ -5292,18 +5547,74 @@ export function createRemoteHub(options = {}) {
5292
5547
  activeCaptureGeneration: Number(controlStream.captureGeneration || 0)
5293
5548
  };
5294
5549
  }
5550
+ const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
5551
+ if (!Number.isInteger(normalized.monitorIndex)
5552
+ || normalized.monitorIndex !== activeMonitorIndex) {
5553
+ return {
5554
+ ok: false,
5555
+ error: 'STALE_CONTROL_MONITOR',
5556
+ activeMonitorIndex
5557
+ };
5558
+ }
5295
5559
 
5296
5560
  const inputSocket = device.inputSocket;
5297
5561
  if (inputSocket && !inputSocket.destroyed) {
5298
- const sent = writeJsonLine(inputSocket, {
5562
+ const previousOwnerConnectionId = safeString(device.inputOwnerConnectionId, 128);
5563
+ const activeInputBindingKey = [
5564
+ safeString(device.sessionId, 160),
5565
+ safeString(controlStream.commandId, 128),
5566
+ Number(controlStream.captureGeneration || 0),
5567
+ activeMonitorIndex
5568
+ ].join(':');
5569
+ const previousInputBindingKey = safeString(device.inputOwnerBindingKey, 512);
5570
+ const ownerChanged = normalized.hubConnectionId
5571
+ && previousOwnerConnectionId
5572
+ && normalized.hubConnectionId !== previousOwnerConnectionId;
5573
+ const bindingChanged = previousInputBindingKey
5574
+ && previousInputBindingKey !== activeInputBindingKey;
5575
+ if (ownerChanged || bindingChanged) {
5576
+ const resetSent = writeJsonLine(inputSocket, {
5577
+ type: 'input.control',
5578
+ payload: {
5579
+ type: 'keyboard.reset',
5580
+ reason: ownerChanged
5581
+ ? 'browser-input-owner-changed'
5582
+ : 'control-input-binding-changed',
5583
+ pressedCodes: [],
5584
+ shiftKey: false,
5585
+ ctrlKey: false,
5586
+ altKey: false,
5587
+ metaKey: false,
5588
+ repeat: false,
5589
+ requestAck: false,
5590
+ inputSeq: 0,
5591
+ hubConnectionId: previousOwnerConnectionId,
5592
+ monitorIndex: activeMonitorIndex,
5593
+ controlSessionId: safeString(device.sessionId, 160),
5594
+ controlCommandId: safeString(controlStream.commandId, 128),
5595
+ captureGeneration: Number(controlStream.captureGeneration || 0),
5596
+ hubForwardedAtEpochMs: Date.now(),
5597
+ issuedAt: new Date().toISOString()
5598
+ }
5599
+ });
5600
+ if (!resetSent) {
5601
+ detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
5602
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5603
+ }
5604
+ }
5605
+ const sent = writeJsonLine(inputSocket, {
5299
5606
  type: 'input.control',
5300
5607
  payload: {
5301
5608
  ...normalized,
5302
5609
  hubForwardedAtEpochMs: Date.now(),
5303
5610
  issuedAt: normalized.issuedAt || new Date().toISOString()
5304
5611
  }
5305
- });
5306
- if (sent) {
5612
+ });
5613
+ if (sent) {
5614
+ if (normalized.hubConnectionId) {
5615
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
5616
+ }
5617
+ device.inputOwnerBindingKey = activeInputBindingKey;
5307
5618
  device.counters.commandsSent += 1;
5308
5619
  device.inputLastSeenAt = new Date().toISOString();
5309
5620
  return {
@@ -5311,13 +5622,21 @@ export function createRemoteHub(options = {}) {
5311
5622
  inputSocket: true,
5312
5623
  sessionId: device.sessionId,
5313
5624
  commandId: controlStream.commandId,
5314
- captureGeneration: controlStream.captureGeneration
5625
+ captureGeneration: controlStream.captureGeneration,
5626
+ monitorIndex: activeMonitorIndex
5315
5627
  };
5316
5628
  }
5317
5629
 
5318
- detachInputSocket(inputSocket, 'input-socket-write-failed');
5319
- }
5320
-
5630
+ detachInputSocket(inputSocket, 'input-socket-write-failed');
5631
+ }
5632
+
5633
+ // Current Agents advertise this ordered side channel. Crossing to the
5634
+ // concurrent main command socket during a reconnect can invert
5635
+ // keydown/keyup, so only legacy Agents use the compatibility fallback.
5636
+ if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
5637
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5638
+ }
5639
+
5321
5640
  const hubForwardedAtEpochMs = Date.now();
5322
5641
  const fallback = sendCommand(deviceId, {
5323
5642
  command: 'input.control',
@@ -5332,6 +5651,9 @@ export function createRemoteHub(options = {}) {
5332
5651
  commandId: fallback.commandId,
5333
5652
  inputSeq: normalized.inputSeq,
5334
5653
  inputType: normalized.type,
5654
+ monitorIndex: normalized.monitorIndex,
5655
+ hubConnectionId: normalized.hubConnectionId,
5656
+ inputEventId: normalized.inputEventId,
5335
5657
  issuedAtEpochMs: normalized.issuedAtEpochMs,
5336
5658
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
5337
5659
  hubForwardedAtEpochMs,
@@ -5348,6 +5670,7 @@ export function createRemoteHub(options = {}) {
5348
5670
  deliveryCommandId: fallback.commandId,
5349
5671
  commandId: controlStream.commandId,
5350
5672
  captureGeneration: controlStream.captureGeneration,
5673
+ monitorIndex: activeMonitorIndex,
5351
5674
  inputSeq: normalized.inputSeq
5352
5675
  }
5353
5676
  : {
@@ -5357,6 +5680,49 @@ export function createRemoteHub(options = {}) {
5357
5680
  };
5358
5681
  }
5359
5682
 
5683
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
5684
+ const device = devices.get(String(deviceId || ''));
5685
+ const owner = safeString(ownerConnectionId, 128);
5686
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
5687
+ return { ok: false, error: 'input-owner-not-current' };
5688
+ }
5689
+
5690
+ device.inputOwnerConnectionId = '';
5691
+ device.inputOwnerBindingKey = '';
5692
+ const inputSocket = device.inputSocket;
5693
+ if (!inputSocket || inputSocket.destroyed) {
5694
+ return { ok: true, queued: false };
5695
+ }
5696
+ const controlStream = getActiveControlStream(device);
5697
+ const sent = writeJsonLine(inputSocket, {
5698
+ type: 'input.control',
5699
+ payload: {
5700
+ type: 'keyboard.reset',
5701
+ reason: safeString(reason, 120) || 'browser-input-owner-closed',
5702
+ pressedCodes: [],
5703
+ shiftKey: false,
5704
+ ctrlKey: false,
5705
+ altKey: false,
5706
+ metaKey: false,
5707
+ repeat: false,
5708
+ requestAck: false,
5709
+ inputSeq: 0,
5710
+ hubConnectionId: owner,
5711
+ monitorIndex: normalizeMonitorIndex(controlStream?.monitorIndex),
5712
+ controlSessionId: safeString(device.sessionId, 160),
5713
+ controlCommandId: safeString(controlStream?.commandId, 128),
5714
+ captureGeneration: Number(controlStream?.captureGeneration || 0),
5715
+ hubForwardedAtEpochMs: Date.now(),
5716
+ issuedAt: new Date().toISOString()
5717
+ }
5718
+ });
5719
+ if (!sent) {
5720
+ detachInputSocket(inputSocket, 'input-owner-release-write-failed');
5721
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5722
+ }
5723
+ return { ok: true, queued: true };
5724
+ }
5725
+
5360
5726
  function notifyAgentProgress(deviceIds, progress = {}) {
5361
5727
  const targets = [...new Set((deviceIds || [])
5362
5728
  .map(deviceId => safeString(deviceId, 128))
@@ -5762,24 +6128,42 @@ export function createRemoteHub(options = {}) {
5762
6128
  streamId: stream.streamId,
5763
6129
  commandId: key,
5764
6130
  error: safeString(error, 500) || 'stream-start-failed'
5765
- });
5766
- return true;
5767
- }
5768
- return false;
5769
- }
6131
+ });
6132
+ return true;
6133
+ }
6134
+ for (const stream of streams.values()) {
6135
+ if (safeString(stream?.commandId, 128) !== key || stream?.active !== true) {
6136
+ continue;
6137
+ }
6138
+ clearPendingLiveStreamDescriptor(device, stream);
6139
+ stream.active = false;
6140
+ stream.open = false;
6141
+ stream.stoppedAt = new Date().toISOString();
6142
+ stream.stopReason = safeString(error, 500) || 'stream-start-failed';
6143
+ emitRemoteEvent('RemoteLiveStreamStartFailed', device, {
6144
+ streamId: stream.streamId,
6145
+ commandId: key,
6146
+ error: stream.stopReason
6147
+ });
6148
+ return true;
6149
+ }
6150
+ return false;
6151
+ }
5770
6152
 
5771
6153
  function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5772
6154
  const streams = ensureDeviceLiveStreams(device);
5773
6155
  for (const stream of streams.values()) {
5774
6156
  clearPendingLiveStreamDescriptor(device, stream);
5775
6157
  stream.active = false;
6158
+ stream.open = false;
5776
6159
  stream.stoppedAt = stoppedAt;
5777
6160
  stream.stopReason = reason;
5778
- }
5779
- if (device?.activeLiveStream) {
5780
- device.activeLiveStream.active = false;
5781
- device.activeLiveStream.stoppedAt = stoppedAt;
5782
- device.activeLiveStream.stopReason = reason;
6161
+ }
6162
+ if (device?.activeLiveStream) {
6163
+ device.activeLiveStream.active = false;
6164
+ device.activeLiveStream.open = false;
6165
+ device.activeLiveStream.stoppedAt = stoppedAt;
6166
+ device.activeLiveStream.stopReason = reason;
5783
6167
  }
5784
6168
  }
5785
6169
 
@@ -5801,6 +6185,48 @@ export function createRemoteHub(options = {}) {
5801
6185
  safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5802
6186
  }
5803
6187
 
6188
+ function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose) {
6189
+ const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
6190
+ const activeControlStream = getActiveControlStream(device);
6191
+ if (purpose !== 'control'
6192
+ && activeControlStream
6193
+ && activeControlStream.streamId !== streamId) {
6194
+ return {
6195
+ ok: false,
6196
+ error: 'CONTROL_CAPTURE_OWNS_DEVICE',
6197
+ activeStreamId: activeControlStream.streamId,
6198
+ activeStreamPurpose: 'control',
6199
+ activeCommandId: safeString(activeControlStream.commandId, 128),
6200
+ captureGeneration: Number(activeControlStream.captureGeneration || 0)
6201
+ };
6202
+ }
6203
+
6204
+ const competingStreams = getActiveLiveStreams(device)
6205
+ .filter(stream => stream.streamId !== streamId);
6206
+ for (const competingStream of competingStreams) {
6207
+ const result = stopLiveStream(deviceId, {
6208
+ streamId: competingStream.streamId,
6209
+ streamPurpose: competingStream.streamPurpose,
6210
+ reason: `${purpose}-capture-handoff`
6211
+ });
6212
+ if (result?.ok !== true) {
6213
+ return {
6214
+ ok: false,
6215
+ error: result?.error || 'capture-handoff-stop-failed',
6216
+ activeStreamId: competingStream.streamId,
6217
+ activeStreamPurpose: safeString(competingStream.streamPurpose, 24)
6218
+ };
6219
+ }
6220
+ emitRemoteEvent('RemoteLiveStreamCapturePreempted', device, {
6221
+ streamId: competingStream.streamId,
6222
+ streamPurpose: safeString(competingStream.streamPurpose, 24) || 'wall',
6223
+ replacementStreamId: streamId,
6224
+ replacementStreamPurpose: purpose
6225
+ });
6226
+ }
6227
+ return { ok: true };
6228
+ }
6229
+
5804
6230
  function nextCaptureGeneration(device) {
5805
6231
  const current = Number(device?.captureGenerationCounter || 0);
5806
6232
  const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
@@ -5855,12 +6281,31 @@ export function createRemoteHub(options = {}) {
5855
6281
  return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5856
6282
  }
5857
6283
 
6284
+ function liveStreamRequiresReadyKeyFrame(activeLiveStream) {
6285
+ if (safeString(activeLiveStream?.streamPurpose, 24).toLowerCase() !== 'control') {
6286
+ return false;
6287
+ }
6288
+ const codec = safeString(activeLiveStream?.codec, 80).toLowerCase();
6289
+ const mode = safeString(activeLiveStream?.frameMode || activeLiveStream?.mode, 80).toLowerCase();
6290
+ return codec.includes('h264') || mode === 'mode3-h264-hw';
6291
+ }
6292
+
6293
+ function liveFrameSatisfiesReadiness(activeLiveStream, frame) {
6294
+ return !liveStreamRequiresReadyKeyFrame(activeLiveStream)
6295
+ || frame?.isKeyFrame === true
6296
+ || safeString(frame?.chunkType, 20).toLowerCase() === 'key';
6297
+ }
6298
+
5858
6299
  function liveStreamHasCurrentFrame(activeLiveStream) {
5859
6300
  if (!activeLiveStream?.active
5860
6301
  || activeLiveStream.open !== true
5861
6302
  || Number(activeLiveStream.framesReceived || 0) < 1) {
5862
6303
  return false;
5863
6304
  }
6305
+ if (liveStreamRequiresReadyKeyFrame(activeLiveStream)
6306
+ && activeLiveStream.readyFrameReceived !== true) {
6307
+ return false;
6308
+ }
5864
6309
  const latestFrame = activeLiveStream.latestFrame;
5865
6310
  if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
5866
6311
  return false;
@@ -5918,11 +6363,19 @@ export function createRemoteHub(options = {}) {
5918
6363
  && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
5919
6364
  }
5920
6365
 
5921
- function startLiveStream(deviceId, options = {}) {
5922
- const device = devices.get(String(deviceId || ''));
5923
- const denied = policyError(device);
5924
- if (denied) return { ok: false, error: denied };
5925
- if (device?.synthetic === true && device.connected) {
6366
+ function startLiveStream(deviceId, options = {}) {
6367
+ const device = devices.get(String(deviceId || ''));
6368
+ const denied = policyError(device);
6369
+ if (denied) return { ok: false, error: denied };
6370
+ if (device?.liveCapturePause?.token) {
6371
+ return {
6372
+ ok: false,
6373
+ error: 'LIVE_CAPTURE_PAUSED',
6374
+ pauseToken: device.liveCapturePause.token,
6375
+ pausedAt: device.liveCapturePause.pausedAt
6376
+ };
6377
+ }
6378
+ if (device?.synthetic === true && device.connected) {
5926
6379
  if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
5927
6380
  return { ok: false, error: 'device-live-stream-unavailable' };
5928
6381
  }
@@ -5933,6 +6386,8 @@ export function createRemoteHub(options = {}) {
5933
6386
  if (modePolicyError) return { ok: false, error: modePolicyError };
5934
6387
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5935
6388
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6389
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6390
+ if (!captureClaim.ok) return captureClaim;
5936
6391
  const activeLiveStream = getDeviceLiveStream(device, streamId);
5937
6392
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5938
6393
  if (activeLiveStream
@@ -5984,6 +6439,7 @@ export function createRemoteHub(options = {}) {
5984
6439
  lastFrameAt: '',
5985
6440
  lastFrameSeq: 0,
5986
6441
  framesReceived: 0,
6442
+ readyFrameReceived: false,
5987
6443
  streamPurpose,
5988
6444
  captureGeneration
5989
6445
  });
@@ -6031,6 +6487,8 @@ export function createRemoteHub(options = {}) {
6031
6487
  if (modePolicyError) return { ok: false, error: modePolicyError };
6032
6488
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6033
6489
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6490
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6491
+ if (!captureClaim.ok) return captureClaim;
6034
6492
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6035
6493
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
6036
6494
  if (pendingDescriptor?.commandId) {
@@ -6201,8 +6659,9 @@ export function createRemoteHub(options = {}) {
6201
6659
  stoppedAt: '',
6202
6660
  stopReason: '',
6203
6661
  lastFrameAt: '',
6204
- lastFrameSeq: 0,
6662
+ lastFrameSeq: 0,
6205
6663
  framesReceived: 0,
6664
+ readyFrameReceived: false,
6206
6665
  streamPurpose,
6207
6666
  captureGeneration,
6208
6667
  latestFrame: null,
@@ -6247,14 +6706,16 @@ export function createRemoteHub(options = {}) {
6247
6706
  };
6248
6707
  }
6249
6708
 
6250
- function stopLiveStream(deviceId, options = {}) {
6709
+ function stopLiveStream(deviceId, options = {}) {
6251
6710
  const device = devices.get(String(deviceId || ''));
6252
- const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
6253
- const requestedStreamId = safeString(options.streamId, 128);
6254
- const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
6255
- const streamId = requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
6711
+ const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
6712
+ const requestedStreamId = safeString(options.streamId, 128);
6713
+ const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
6714
+ const streamId = options.stopAll === true
6715
+ ? ''
6716
+ : requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
6256
6717
  const streamState = getDeviceLiveStream(device, streamId);
6257
- if (streamState && streamState.active !== true) {
6718
+ if (streamState && streamState.active !== true && options.forceCommand !== true) {
6258
6719
  clearPendingLiveStreamDescriptor(device, streamState);
6259
6720
  return {
6260
6721
  ok: true,
@@ -6271,9 +6732,10 @@ export function createRemoteHub(options = {}) {
6271
6732
  device.counters.commandsSent += 1;
6272
6733
  if (streamState) {
6273
6734
  streamState.active = false;
6735
+ streamState.open = false;
6274
6736
  streamState.stoppedAt = new Date().toISOString();
6275
- streamState.stopReason = 'manager-request';
6276
- }
6737
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6738
+ }
6277
6739
  device.counters.liveStreamsStopped += 1;
6278
6740
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6279
6741
  streamId,
@@ -6303,17 +6765,230 @@ export function createRemoteHub(options = {}) {
6303
6765
 
6304
6766
  if (streamState) {
6305
6767
  streamState.active = false;
6768
+ streamState.open = false;
6306
6769
  streamState.stoppedAt = new Date().toISOString();
6307
- streamState.stopReason = 'manager-request';
6308
- }
6770
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6771
+ }
6309
6772
  device.counters.liveStreamsStopped += 1;
6310
6773
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6311
6774
  streamId,
6312
6775
  commandId
6313
6776
  });
6314
6777
 
6315
- return { ok: true, commandId, streamId };
6316
- }
6778
+ return { ok: true, commandId, streamId };
6779
+ }
6780
+
6781
+ function buildLiveCapturePauseResult(pause, extra = {}) {
6782
+ return {
6783
+ paused: true,
6784
+ pauseToken: pause.token,
6785
+ pausedAt: pause.pausedAt,
6786
+ streamId: pause.streamId,
6787
+ streamPurpose: pause.streamPurpose,
6788
+ monitorIndex: pause.monitorIndex,
6789
+ phase: pause.phase,
6790
+ captureStopConfirmed: pause.captureStopConfirmed === true,
6791
+ captureStopConfirmedAt: pause.captureStopConfirmedAt || '',
6792
+ stopCommandId: pause.stopCommandId || '',
6793
+ stopError: pause.stopError || '',
6794
+ stopAttemptCount: Math.max(0, Number(pause.stopAttemptCount) || 0),
6795
+ ...extra
6796
+ };
6797
+ }
6798
+
6799
+ async function confirmLiveCaptureStopped(deviceId, pause) {
6800
+ let device = devices.get(String(deviceId || ''));
6801
+ if (!device || device.liveCapturePause?.token !== pause.token) {
6802
+ return buildLiveCapturePauseResult(pause, {
6803
+ ok: false,
6804
+ error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
6805
+ });
6806
+ }
6807
+
6808
+ pause.phase = 'pausing';
6809
+ pause.captureStopConfirmed = false;
6810
+ pause.captureStopConfirmedAt = '';
6811
+ pause.stopError = '';
6812
+ pause.stopAttemptCount = Math.max(0, Number(pause.stopAttemptCount) || 0) + 1;
6813
+
6814
+ let stopResult = { ok: true, alreadyStopped: true, streamId: pause.streamId };
6815
+ if (device.synthetic !== true) {
6816
+ stopResult = stopLiveStream(deviceId, {
6817
+ reason: pause.reason,
6818
+ // Empty streamId is an intentional Agent contract: stop every
6819
+ // active/quarantined capture, including a native helper that
6820
+ // outlived the Hub's last stream descriptor.
6821
+ stopAll: true,
6822
+ forceCommand: true
6823
+ });
6824
+ }
6825
+
6826
+ if (stopResult?.ok === false) {
6827
+ pause.phase = 'stop-unconfirmed';
6828
+ pause.stopError = safeString(stopResult.error, 500) || 'stream-stop-command-failed';
6829
+ emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
6830
+ pauseToken: pause.token,
6831
+ streamId: pause.streamId,
6832
+ stopError: pause.stopError
6833
+ });
6834
+ return buildLiveCapturePauseResult(pause, {
6835
+ ok: false,
6836
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
6837
+ });
6838
+ }
6839
+
6840
+ pause.stopCommandId = safeString(stopResult.commandId, 128);
6841
+ if (pause.stopCommandId
6842
+ && stopResult.alreadyStopped !== true
6843
+ && stopResult.synthetic !== true) {
6844
+ const acknowledgement = await waitForCommandResult(
6845
+ device,
6846
+ pause.stopCommandId,
6847
+ liveCaptureStopAckTimeoutMs);
6848
+ device = devices.get(String(deviceId || '')) || device;
6849
+ if (device.liveCapturePause?.token !== pause.token) {
6850
+ return buildLiveCapturePauseResult(pause, {
6851
+ ok: false,
6852
+ error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
6853
+ });
6854
+ }
6855
+ if (!acknowledgement.ok
6856
+ || acknowledgement.result?.stream !== true
6857
+ || acknowledgement.result?.stopped !== true) {
6858
+ pause.phase = 'stop-unconfirmed';
6859
+ pause.stopError = safeString(
6860
+ acknowledgement.error
6861
+ || acknowledgement.result?.error
6862
+ || 'stream-stop-not-confirmed',
6863
+ 500);
6864
+ emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
6865
+ pauseToken: pause.token,
6866
+ streamId: pause.streamId,
6867
+ stopCommandId: pause.stopCommandId,
6868
+ stopError: pause.stopError
6869
+ });
6870
+ return buildLiveCapturePauseResult(pause, {
6871
+ ok: false,
6872
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
6873
+ });
6874
+ }
6875
+ }
6876
+
6877
+ pause.phase = 'paused';
6878
+ pause.captureStopConfirmed = true;
6879
+ pause.captureStopConfirmedAt = new Date().toISOString();
6880
+ pause.stopError = '';
6881
+ deactivateDeviceLiveStreams(device, pause.reason, pause.captureStopConfirmedAt);
6882
+ emitRemoteEvent('RemoteLiveCapturePaused', device, {
6883
+ pauseToken: pause.token,
6884
+ pausedAt: pause.pausedAt,
6885
+ streamId: pause.streamId,
6886
+ streamPurpose: pause.streamPurpose,
6887
+ monitorIndex: pause.monitorIndex,
6888
+ stopCommandId: pause.stopCommandId,
6889
+ captureStopConfirmedAt: pause.captureStopConfirmedAt
6890
+ });
6891
+ return buildLiveCapturePauseResult(pause, {
6892
+ ok: true,
6893
+ alreadyStopped: stopResult.alreadyStopped === true
6894
+ });
6895
+ }
6896
+
6897
+ async function pauseLiveCapture(deviceId, options = {}) {
6898
+ const device = devices.get(String(deviceId || ''));
6899
+ if (!device) {
6900
+ return { ok: false, paused: false, error: 'device-not-found' };
6901
+ }
6902
+ const denied = policyError(device);
6903
+ if (denied) return { ok: false, paused: false, error: denied };
6904
+
6905
+ let pause = device.liveCapturePause;
6906
+ if (pause?.token && pause.captureStopConfirmed === true) {
6907
+ return buildLiveCapturePauseResult(pause, {
6908
+ ok: true,
6909
+ alreadyPaused: true
6910
+ });
6911
+ }
6912
+ if (pause?.stopPromise) {
6913
+ return pause.stopPromise;
6914
+ }
6915
+
6916
+ if (!pause?.token) {
6917
+ const activeLiveStream = device.activeLiveStream?.active === true
6918
+ ? device.activeLiveStream
6919
+ : null;
6920
+ pause = {
6921
+ token: crypto.randomUUID(),
6922
+ pausedAt: new Date().toISOString(),
6923
+ reason: safeString(options.reason, 128) || 'user-video-pause',
6924
+ streamId: safeString(activeLiveStream?.streamId, 128),
6925
+ streamPurpose: safeString(activeLiveStream?.streamPurpose, 24),
6926
+ monitorIndex: Math.max(0, Math.floor(Number(activeLiveStream?.monitorIndex) || 0)),
6927
+ phase: 'pausing',
6928
+ captureStopConfirmed: false,
6929
+ captureStopConfirmedAt: '',
6930
+ stopCommandId: '',
6931
+ stopError: '',
6932
+ stopAttemptCount: 0
6933
+ };
6934
+ // Install the authoritative gate before asking the Agent to stop.
6935
+ // A late subscriber or watchdog therefore cannot reacquire FFmpeg
6936
+ // in the pause transition window.
6937
+ device.liveCapturePause = pause;
6938
+ }
6939
+
6940
+ const operation = confirmLiveCaptureStopped(deviceId, pause);
6941
+ pause.stopPromise = operation;
6942
+ try {
6943
+ return await operation;
6944
+ } finally {
6945
+ if (pause.stopPromise === operation) {
6946
+ delete pause.stopPromise;
6947
+ }
6948
+ }
6949
+ }
6950
+
6951
+ function resumeLiveCapture(deviceId, options = {}) {
6952
+ const device = devices.get(String(deviceId || ''));
6953
+ const denied = policyError(device);
6954
+ if (denied) return { ok: false, error: denied };
6955
+ const pause = device?.liveCapturePause;
6956
+ if (!pause?.token) {
6957
+ return { ok: true, paused: false, alreadyResumed: true };
6958
+ }
6959
+ if (pause.phase === 'pausing') {
6960
+ return {
6961
+ ok: false,
6962
+ paused: true,
6963
+ error: 'LIVE_CAPTURE_STOP_IN_PROGRESS',
6964
+ pauseToken: pause.token
6965
+ };
6966
+ }
6967
+ const pauseToken = safeString(options.pauseToken, 128);
6968
+ if (!pauseToken || pauseToken !== pause.token) {
6969
+ return {
6970
+ ok: false,
6971
+ error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
6972
+ };
6973
+ }
6974
+
6975
+ device.liveCapturePause = null;
6976
+ emitRemoteEvent('RemoteLiveCaptureResumed', device, {
6977
+ pauseToken,
6978
+ resumedAt: new Date().toISOString(),
6979
+ streamId: pause.streamId,
6980
+ streamPurpose: pause.streamPurpose,
6981
+ monitorIndex: pause.monitorIndex
6982
+ });
6983
+ return {
6984
+ ok: true,
6985
+ paused: false,
6986
+ resumed: true,
6987
+ streamId: pause.streamId,
6988
+ streamPurpose: pause.streamPurpose,
6989
+ monitorIndex: pause.monitorIndex
6990
+ };
6991
+ }
6317
6992
 
6318
6993
  function startAudioStream(deviceId, options = {}) {
6319
6994
  const device = devices.get(String(deviceId || ''));
@@ -6475,15 +7150,18 @@ export function createRemoteHub(options = {}) {
6475
7150
  sendCommand,
6476
7151
  sendLegacyClientUpdate,
6477
7152
  sendInputControl,
7153
+ releaseInputOwner,
6478
7154
  notifyAgentProgress,
6479
7155
  requestAgentTask,
6480
7156
  requestAgentTaskBatch,
6481
7157
  setHostTarget,
6482
7158
  rotatePairingPin,
6483
- requestThumbnail,
6484
- startLiveStream,
6485
- stopLiveStream,
6486
- startAudioStream,
7159
+ requestThumbnail,
7160
+ startLiveStream,
7161
+ stopLiveStream,
7162
+ pauseLiveCapture,
7163
+ resumeLiveCapture,
7164
+ startAudioStream,
6487
7165
  stopAudioStream,
6488
7166
  getDeviceLiveFrame,
6489
7167
  getDeviceThumbnail,