@livedesk/hub 0.1.7 → 0.1.9

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,7 +28,13 @@ 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 REMOTE_PROTOCOL_VERSION = 2;
31
+ const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
32
+ const REMOTE_POLICY_BYPASS_COMMANDS = new Set([
33
+ 'ping',
34
+ 'stream.stop',
35
+ 'audio.stop'
36
+ ]);
37
+ const REMOTE_PROTOCOL_VERSION = 2;
32
38
  const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
33
39
  const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
34
40
  const REMOTE_FRAME_PROTOCOL_VERSION = 2;
@@ -154,7 +160,10 @@ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
154
160
  const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
155
161
  const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 8;
156
162
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
157
- const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 64;
163
+ // Native capture backends can emit short bursts (ScreenCaptureKit commonly
164
+ // queues up to eight frames). Drain one frame per event-loop turn so a burst
165
+ // cannot immediately overflow the smaller Hub-to-browser send lanes.
166
+ const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 1;
158
167
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
159
168
  const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
160
169
  const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
@@ -683,9 +692,18 @@ function normalizeRemoteInputEvent(value = {}) {
683
692
  const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
684
693
  const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
685
694
  const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
695
+ const rawPressedCodes = input.pressedCodes ?? input.PressedCodes;
696
+ const pressedCodes = Array.isArray(rawPressedCodes)
697
+ ? [...new Set(rawPressedCodes
698
+ .map(code => safeString(code, 80))
699
+ .filter(Boolean))]
700
+ .slice(0, 64)
701
+ : null;
686
702
  return {
687
703
  type,
688
- monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : 0,
704
+ // A missing monitor is not equivalent to the primary display. Control
705
+ // input must prove the exact monitor owned by its capture generation.
706
+ monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : null,
689
707
  normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
690
708
  normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
691
709
  button: safeString(input.button || input.Button, 24),
@@ -695,8 +713,9 @@ function normalizeRemoteInputEvent(value = {}) {
695
713
  code: safeString(input.code || input.Code, 80),
696
714
  keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
697
715
  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,
716
+ text: safeText(input.text || input.Text, 256),
717
+ repeat: input.repeat === true || input.Repeat === true,
718
+ pressedCodes,
700
719
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
701
720
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
702
721
  altKey: input.altKey === true || input.AltKey === true,
@@ -708,8 +727,9 @@ function normalizeRemoteInputEvent(value = {}) {
708
727
  hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
709
728
  inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
710
729
  inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
711
- requestAck: input.requestAck !== false && input.RequestAck !== false,
712
- issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
730
+ requestAck: input.requestAck !== false && input.RequestAck !== false,
731
+ reason: safeString(input.reason || input.Reason, 120),
732
+ issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
713
733
  hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
714
734
  issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
715
735
  };
@@ -1014,10 +1034,26 @@ function serializeDevice(device, options = {}) {
1014
1034
  audio: !!device.audioSocket && !device.audioSocket.destroyed,
1015
1035
  file: !!device.fileSocket && !device.fileSocket.destroyed
1016
1036
  },
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,
1037
+ latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1038
+ latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1039
+ activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
1040
+ liveCapturePause: device.liveCapturePause
1041
+ ? {
1042
+ token: device.liveCapturePause.token,
1043
+ pausedAt: device.liveCapturePause.pausedAt,
1044
+ reason: device.liveCapturePause.reason,
1045
+ streamId: device.liveCapturePause.streamId,
1046
+ streamPurpose: device.liveCapturePause.streamPurpose,
1047
+ monitorIndex: device.liveCapturePause.monitorIndex,
1048
+ phase: device.liveCapturePause.phase,
1049
+ captureStopConfirmed: device.liveCapturePause.captureStopConfirmed === true,
1050
+ captureStopConfirmedAt: device.liveCapturePause.captureStopConfirmedAt || '',
1051
+ stopCommandId: device.liveCapturePause.stopCommandId || '',
1052
+ stopError: device.liveCapturePause.stopError || '',
1053
+ stopAttemptCount: Math.max(0, Number(device.liveCapturePause.stopAttemptCount) || 0)
1054
+ }
1055
+ : null,
1056
+ activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
1021
1057
  latestAudioFrame: device.latestAudioFrame
1022
1058
  ? {
1023
1059
  streamId: device.latestAudioFrame.streamId,
@@ -1454,7 +1490,9 @@ class RemoteHubWebSocketAgentSocket {
1454
1490
  }
1455
1491
 
1456
1492
  export function createRemoteHub(options = {}) {
1457
- const env = options.env || process.env;
1493
+ const env = options.env || process.env;
1494
+ const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
1495
+ && isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
1458
1496
  const logEvent = options.logEvent || (() => {});
1459
1497
  const logWarn = options.logWarn || (() => {});
1460
1498
  const emitEvent = options.emitEvent || (() => {});
@@ -1482,12 +1520,16 @@ export function createRemoteHub(options = {}) {
1482
1520
  }
1483
1521
  }
1484
1522
 
1485
- function policyError(device, permission = '') {
1486
- const policy = getDevicePolicy(device);
1487
- if (policy.accessMode === 'block-remote-access') return 'remote-access-blocked-by-settings';
1488
- if (permission && policy[permission] === false) {
1489
- return `${permission}-blocked-by-settings`;
1490
- }
1523
+ function policyError(device, permission = '', command = '') {
1524
+ const policy = getDevicePolicy(device);
1525
+ const commandName = safeString(command, 80);
1526
+ if (policy.accessMode === 'block-remote-access'
1527
+ && !REMOTE_POLICY_BYPASS_COMMANDS.has(commandName)) {
1528
+ return 'remote-access-blocked-by-settings';
1529
+ }
1530
+ if (permission && policy[permission] === false) {
1531
+ return `${permission}-blocked-by-settings`;
1532
+ }
1491
1533
  return '';
1492
1534
  }
1493
1535
 
@@ -1506,6 +1548,11 @@ export function createRemoteHub(options = {}) {
1506
1548
  250,
1507
1549
  30000,
1508
1550
  DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
1551
+ const liveCaptureStopAckTimeoutMs = clampNumber(
1552
+ options.liveCaptureStopAckTimeoutMs ?? env.REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS,
1553
+ 250,
1554
+ 30000,
1555
+ DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS);
1509
1556
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1510
1557
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1511
1558
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1534,9 +1581,10 @@ export function createRemoteHub(options = {}) {
1534
1581
  const sockets = new Map();
1535
1582
  const inputSockets = new Map();
1536
1583
  const frameSockets = new Map();
1537
- const audioSockets = new Map();
1538
- const fileSockets = new Map();
1539
- const allSockets = new Set();
1584
+ const audioSockets = new Map();
1585
+ const fileSockets = new Map();
1586
+ const allSockets = new Set();
1587
+ const pendingCommandResultWaiters = new Map();
1540
1588
  const duplicateDeviceLogAt = new Map();
1541
1589
  let server = null;
1542
1590
  let started = false;
@@ -2044,15 +2092,109 @@ export function createRemoteHub(options = {}) {
2044
2092
  });
2045
2093
  }
2046
2094
 
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 = {}) {
2095
+ function emitRemoteEvent(type, device = null, extra = {}) {
2096
+ emitEvent(type, {
2097
+ ...extra,
2098
+ remoteHub: getStatus({ includeSecrets: false }),
2099
+ device: serializeDevice(device)
2100
+ });
2101
+ }
2102
+
2103
+ function settlePendingCommandResult(device, message = {}) {
2104
+ const commandId = safeString(message.commandId, 128);
2105
+ const waiter = pendingCommandResultWaiters.get(commandId);
2106
+ if (!waiter
2107
+ || waiter.deviceId !== device?.deviceId
2108
+ || waiter.sessionId !== device?.sessionId) {
2109
+ return false;
2110
+ }
2111
+
2112
+ pendingCommandResultWaiters.delete(commandId);
2113
+ clearTimeout(waiter.timer);
2114
+ const result = message.result ?? null;
2115
+ const error = safeString(
2116
+ message.error
2117
+ || result?.error
2118
+ || (message.type === 'command.error' ? 'command-failed' : ''),
2119
+ 500);
2120
+ waiter.resolve({
2121
+ ok: !error
2122
+ && result?.ok !== false
2123
+ && result?.status !== 'failed'
2124
+ && result?.status !== 'rejected',
2125
+ commandId,
2126
+ result,
2127
+ error
2128
+ });
2129
+ return true;
2130
+ }
2131
+
2132
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
2133
+ if (!device?.deviceId) {
2134
+ return;
2135
+ }
2136
+ for (const [commandId, waiter] of pendingCommandResultWaiters) {
2137
+ if (waiter.deviceId !== device.deviceId
2138
+ || (waiter.sessionId && waiter.sessionId !== device.sessionId)) {
2139
+ continue;
2140
+ }
2141
+ pendingCommandResultWaiters.delete(commandId);
2142
+ clearTimeout(waiter.timer);
2143
+ waiter.resolve({
2144
+ ok: false,
2145
+ commandId,
2146
+ result: null,
2147
+ error: safeString(error, 500) || 'command-result-unavailable'
2148
+ });
2149
+ }
2150
+ }
2151
+
2152
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
2153
+ const key = safeString(commandId, 128);
2154
+ if (!device?.deviceId || !device?.sessionId || !key) {
2155
+ return Promise.resolve({
2156
+ ok: false,
2157
+ commandId: key,
2158
+ result: null,
2159
+ error: 'command-result-wait-invalid'
2160
+ });
2161
+ }
2162
+
2163
+ return new Promise(resolve => {
2164
+ const previous = pendingCommandResultWaiters.get(key);
2165
+ if (previous) {
2166
+ clearTimeout(previous.timer);
2167
+ previous.resolve({
2168
+ ok: false,
2169
+ commandId: key,
2170
+ result: null,
2171
+ error: 'command-result-wait-replaced'
2172
+ });
2173
+ }
2174
+ const timer = setTimeout(() => {
2175
+ const waiter = pendingCommandResultWaiters.get(key);
2176
+ if (!waiter || waiter.deviceId !== device.deviceId || waiter.sessionId !== device.sessionId) {
2177
+ return;
2178
+ }
2179
+ pendingCommandResultWaiters.delete(key);
2180
+ resolve({
2181
+ ok: false,
2182
+ commandId: key,
2183
+ result: null,
2184
+ error: 'command-result-timeout'
2185
+ });
2186
+ }, timeoutMs);
2187
+ timer.unref?.();
2188
+ pendingCommandResultWaiters.set(key, {
2189
+ deviceId: device.deviceId,
2190
+ sessionId: device.sessionId,
2191
+ timer,
2192
+ resolve
2193
+ });
2194
+ });
2195
+ }
2196
+
2197
+ function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
2056
2198
  const now = new Date().toISOString();
2057
2199
  const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
2058
2200
  const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
@@ -2144,9 +2286,12 @@ export function createRemoteHub(options = {}) {
2144
2286
  byteLength: frame.byteLength
2145
2287
  });
2146
2288
  streamState.latestFrame = frame;
2147
- streamState.lastFrameAt = frame.receivedAt;
2148
- streamState.lastFrameSeq = frame.frameSeq;
2149
- streamState.framesReceived = (streamState.framesReceived || 0) + 1;
2289
+ streamState.lastFrameAt = frame.receivedAt;
2290
+ streamState.lastFrameSeq = frame.frameSeq;
2291
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
2292
+ // A synthetic PNG is a complete independently decodable surface and
2293
+ // therefore key-frame equivalent for Control readiness.
2294
+ streamState.readyFrameReceived = true;
2150
2295
  device.lastSeenAt = frame.receivedAt;
2151
2296
  device.counters.liveFramesReceived += 1;
2152
2297
  emitRemoteEvent('RemoteFrameReceived', device, {
@@ -2170,6 +2315,7 @@ export function createRemoteHub(options = {}) {
2170
2315
  existing.lastDisconnectReason = 'replaced-by-new-session';
2171
2316
  failAllPendingTasks(existing, 'replaced-by-new-session');
2172
2317
  failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2318
+ failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
2173
2319
  writeJsonLine(existing.socket, {
2174
2320
  type: 'disconnect',
2175
2321
  reason: 'replaced-by-new-session',
@@ -2709,9 +2855,11 @@ export function createRemoteHub(options = {}) {
2709
2855
  : getSupportedRemoteFrameModeProfiles();
2710
2856
  }
2711
2857
 
2712
- const device = {
2713
- socket,
2714
- inputSocket: null,
2858
+ const device = {
2859
+ socket,
2860
+ inputSocket: null,
2861
+ inputOwnerConnectionId: '',
2862
+ inputOwnerBindingKey: '',
2715
2863
  frameSocket: null,
2716
2864
  audioSocket: null,
2717
2865
  fileSocket: null,
@@ -2752,9 +2900,13 @@ export function createRemoteHub(options = {}) {
2752
2900
  recentFramePayloads: {
2753
2901
  thumbnail: [],
2754
2902
  live: []
2755
- },
2756
- activeLiveStream: null,
2757
- activeAudioStream: null,
2903
+ },
2904
+ activeLiveStream: null,
2905
+ // A manual pause is a Hub-owned capture gate, not an Agent-session
2906
+ // detail. Preserve the same lease across a transient Client
2907
+ // reconnect so no subscriber or watchdog can reacquire FFmpeg.
2908
+ liveCapturePause: existing?.liveCapturePause || null,
2909
+ activeAudioStream: null,
2758
2910
  latestAudioFrame: null,
2759
2911
  latestAudioStatus: null,
2760
2912
  udp: {
@@ -2943,10 +3095,11 @@ export function createRemoteHub(options = {}) {
2943
3095
  closeInputSocket(device, reason);
2944
3096
  closeFrameSocket(device, reason);
2945
3097
  device.disconnectedAt = new Date().toISOString();
2946
- device.lastDisconnectReason = reason;
3098
+ device.lastDisconnectReason = reason;
2947
3099
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2948
3100
  failAllPendingTasks(device, 'device-disconnected');
2949
3101
  failAllPendingInputFallbacks(device, 'device-disconnected');
3102
+ failPendingCommandResultWaiters(device, 'device-disconnected');
2950
3103
  logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2951
3104
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2952
3105
  }
@@ -3329,6 +3482,20 @@ export function createRemoteHub(options = {}) {
3329
3482
  return {
3330
3483
  deviceId: device.deviceId,
3331
3484
  commandId: safeString(message.commandId || message.CommandId || pending?.commandId, 128),
3485
+ hubConnectionId: safeString(
3486
+ message.hubConnectionId
3487
+ || message.HubConnectionId
3488
+ || result.hubConnectionId
3489
+ || result.HubConnectionId
3490
+ || pending?.hubConnectionId,
3491
+ 128),
3492
+ inputEventId: safeString(
3493
+ message.inputEventId
3494
+ || message.InputEventId
3495
+ || result.inputEventId
3496
+ || result.InputEventId
3497
+ || pending?.inputEventId,
3498
+ 128),
3332
3499
  inputSeq,
3333
3500
  inputType: safeString(
3334
3501
  message.inputType
@@ -3337,6 +3504,13 @@ export function createRemoteHub(options = {}) {
3337
3504
  || result.InputType
3338
3505
  || pending?.inputType,
3339
3506
  48),
3507
+ monitorIndex: Number(
3508
+ message.monitorIndex
3509
+ ?? message.MonitorIndex
3510
+ ?? result.monitorIndex
3511
+ ?? result.MonitorIndex
3512
+ ?? pending?.monitorIndex
3513
+ ?? 0) || 0,
3340
3514
  issuedAtEpochMs: Number(
3341
3515
  message.issuedAtEpochMs
3342
3516
  ?? message.IssuedAtEpochMs
@@ -3602,13 +3776,35 @@ export function createRemoteHub(options = {}) {
3602
3776
  : 0;
3603
3777
  }
3604
3778
 
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
-
3779
+ function readFrameStageMs(message, property) {
3780
+ const value = Number(message?.[property]);
3781
+ return Number.isFinite(value) && value >= 0
3782
+ ? Math.round(value)
3783
+ : 0;
3784
+ }
3785
+
3786
+ function readOptionalFrameStageMs(message, property) {
3787
+ const rawValue = message?.[property];
3788
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
3789
+ return undefined;
3790
+ }
3791
+ const value = Number(rawValue);
3792
+ return Number.isFinite(value) && value >= 0
3793
+ ? Math.round(value)
3794
+ : undefined;
3795
+ }
3796
+
3797
+ function hasFrameCaptureTiming(message) {
3798
+ if (message?.captureTimingAvailable === false) {
3799
+ return false;
3800
+ }
3801
+ if (message?.captureTimingAvailable === true) {
3802
+ return true;
3803
+ }
3804
+ return ['captureP95Ms', 'captureAverageMs', 'captureStageMs']
3805
+ .some(property => readOptionalFrameStageMs(message, property) !== undefined);
3806
+ }
3807
+
3612
3808
  function computeSameContentStreak(previousFrame, contentHash) {
3613
3809
  if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
3614
3810
  return 0;
@@ -3682,7 +3878,7 @@ export function createRemoteHub(options = {}) {
3682
3878
  byteLength,
3683
3879
  transport,
3684
3880
  contentHash,
3685
- captureMs: readFrameCaptureMs(message),
3881
+ captureMs: readFrameCaptureMs(message),
3686
3882
  captureStageMs: readFrameStageMs(message, 'captureStageMs'),
3687
3883
  convertMs: readFrameStageMs(message, 'convertMs'),
3688
3884
  compressMs: readFrameStageMs(message, 'compressMs'),
@@ -3774,6 +3970,7 @@ export function createRemoteHub(options = {}) {
3774
3970
  lastFrameAt: '',
3775
3971
  lastFrameSeq: 0,
3776
3972
  framesReceived: 0,
3973
+ readyFrameReceived: false,
3777
3974
  latestFrame: null,
3778
3975
  stoppedAt: '',
3779
3976
  stopReason: ''
@@ -3991,6 +4188,13 @@ export function createRemoteHub(options = {}) {
3991
4188
  && (activeCaptureGeneration <= 0
3992
4189
  || (frameCaptureGeneration > 0
3993
4190
  && frameCaptureGeneration === activeCaptureGeneration));
4191
+ const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
4192
+ const captureTimingAvailable = hasFrameCaptureTiming(message);
4193
+ const videoTransport = transport === 'udp-p2p'
4194
+ ? 'p2p-udp'
4195
+ : transport === 'ws-binary'
4196
+ ? 'direct-ws'
4197
+ : 'direct-tcp';
3994
4198
  device.latestLiveFrame = {
3995
4199
  streamId,
3996
4200
  frameSeq,
@@ -4027,9 +4231,10 @@ export function createRemoteHub(options = {}) {
4027
4231
  agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
4028
4232
  agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
4029
4233
  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),
4234
+ droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4235
+ hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4236
+ udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
4237
+ hardwareEncoder: safeString(message.hardwareEncoder, 80),
4033
4238
  platformProfile: safeString(message.platformProfile, 80),
4034
4239
  monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
4035
4240
  monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
@@ -4037,29 +4242,40 @@ export function createRemoteHub(options = {}) {
4037
4242
  transferProtocolVersion: transfer.transferProtocolVersion,
4038
4243
  handshake: transfer.handshake,
4039
4244
  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'),
4245
+ requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
4246
+ effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
4247
+ captureTimingAvailable,
4248
+ captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
4249
+ captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
4044
4250
  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'),
4251
+ captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4252
+ capturedAt,
4253
+ receivedAt: device.lastSeenAt,
4254
+ byteLength,
4255
+ transport,
4256
+ contentHash,
4257
+ captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
4258
+ captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
4053
4259
  convertMs: readFrameStageMs(message, 'convertMs'),
4054
4260
  compressMs: readFrameStageMs(message, 'compressMs'),
4055
4261
  captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
4056
4262
  captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
4057
4263
  deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
4058
4264
  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,
4265
+ deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
4266
+ deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4267
+ deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4268
+ videoTransport,
4269
+ frameTransportActive: safeString(message.frameTransportActive, 20) || (videoTransport === 'p2p-udp' ? 'p2p' : 'direct'),
4270
+ frameTransportProtocol: safeString(message.frameTransportProtocol, 20)
4271
+ || (videoTransport === 'p2p-udp' ? 'udp' : videoTransport === 'direct-ws' ? 'ws' : 'tcp'),
4272
+ frameTransportState: safeString(message.frameTransportState, 20) || 'active',
4273
+ frameTransportP2pReady: message.frameTransportP2pReady === true,
4274
+ frameTransportReason: safeString(message.frameTransportReason, 240),
4275
+ frameTransportChangedAt: safeString(message.frameTransportChangedAt, 80),
4276
+ frameTransportSwitchCount: Number.isFinite(Number(message.frameTransportSwitchCount)) ? Number(message.frameTransportSwitchCount) : 0,
4277
+ frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
4278
+ sameContentStreak,
4063
4279
  payload,
4064
4280
  accessToken: createFrameAccessToken()
4065
4281
  };
@@ -4073,9 +4289,12 @@ export function createRemoteHub(options = {}) {
4073
4289
  mimeType: device.latestLiveFrame.mimeType,
4074
4290
  byteLength: device.latestLiveFrame.byteLength
4075
4291
  });
4076
- streamState.lastFrameAt = device.lastSeenAt;
4077
- streamState.lastFrameSeq = frameSeq;
4078
- streamState.framesReceived = (streamState.framesReceived || 0) + 1;
4292
+ streamState.lastFrameAt = device.lastSeenAt;
4293
+ streamState.lastFrameSeq = frameSeq;
4294
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
4295
+ streamState.readyFrameReceived = readyFrameReceivedBeforeFrame
4296
+ || (currentGenerationVerified
4297
+ && liveFrameSatisfiesReadiness(streamState, device.latestLiveFrame));
4079
4298
  streamState.mode = transfer.mode;
4080
4299
  streamState.frameMode = transfer.frameMode;
4081
4300
  streamState.frameProfile = transfer.frameProfile;
@@ -4093,7 +4312,9 @@ export function createRemoteHub(options = {}) {
4093
4312
  streamState.monitorCount = device.latestLiveFrame.monitorCount;
4094
4313
  ensureDeviceLiveStreams(device).set(streamId, streamState);
4095
4314
  device.counters.liveFramesReceived += 1;
4096
- if (streamState.framesReceived === 1 && liveStreamHasCurrentFrame(streamState)) {
4315
+ if (!readyFrameReceivedBeforeFrame
4316
+ && streamState.readyFrameReceived === true
4317
+ && liveStreamHasCurrentFrame(streamState)) {
4097
4318
  emitRemoteEvent('RemoteLiveStreamReady', device, {
4098
4319
  streamId,
4099
4320
  commandId: streamState.commandId,
@@ -4217,7 +4438,7 @@ export function createRemoteHub(options = {}) {
4217
4438
  return true;
4218
4439
  }
4219
4440
 
4220
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
4441
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
4221
4442
  if (!state.authenticated || !state.device) {
4222
4443
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
4223
4444
  socket.destroy();
@@ -4241,28 +4462,29 @@ export function createRemoteHub(options = {}) {
4241
4462
  }
4242
4463
 
4243
4464
  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
- }
4465
+ const binaryTransport = state.binaryTransport === 'ws-binary' ? 'ws-binary' : 'binary';
4466
+ if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4467
+ applyThumbnailFrame(device, header, framePayload, binaryTransport);
4468
+ return;
4469
+ }
4470
+
4471
+ if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4472
+ applyLiveFrame(device, {
4473
+ ...header,
4474
+ hubIngressDropped: Number(state.binaryQueueDrops || 0)
4475
+ }, framePayload, binaryTransport);
4476
+ return;
4477
+ }
4478
+
4479
+ if (frameKind === 'audio' || header.type === 'audio.binary') {
4480
+ applyAudioFrame(device, header, framePayload, binaryTransport);
4481
+ return;
4482
+ }
4483
+
4484
+ if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
4485
+ applyAudioStatus(device, header, framePayload, binaryTransport);
4486
+ return;
4487
+ }
4266
4488
 
4267
4489
  emitRemoteEvent('RemoteAgentMessageIgnored', device, {
4268
4490
  messageType: safeString(header.type, 80),
@@ -4440,9 +4662,14 @@ export function createRemoteHub(options = {}) {
4440
4662
  state.device = device;
4441
4663
  state.inputOnly = true;
4442
4664
  return;
4443
- }
4444
- if (channel === 'frame') {
4445
- const device = attachFrameSocket(socket, message);
4665
+ }
4666
+ if (channel === 'frame') {
4667
+ if (rejectFrameChannelForTest) {
4668
+ writeJsonLine(socket, { type: 'error', error: 'test-frame-channel-unavailable' });
4669
+ socket.end();
4670
+ return;
4671
+ }
4672
+ const device = attachFrameSocket(socket, message);
4446
4673
  if (!device) {
4447
4674
  return;
4448
4675
  }
@@ -4493,23 +4720,12 @@ export function createRemoteHub(options = {}) {
4493
4720
 
4494
4721
  if (state.inputOnly) {
4495
4722
  device.inputLastSeenAt = new Date().toISOString();
4496
- if (message.type === 'input.applied') {
4723
+ if (message.type === 'input.applied' || message.type === 'input.error') {
4497
4724
  handleRemoteInputOutcome(device, {
4498
4725
  ...message,
4499
4726
  agentApplyMs: Number(message.agentApplyMs || 0) || 0
4500
4727
  }, 'input');
4501
4728
  }
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
4729
  return;
4514
4730
  }
4515
4731
  if (state.frameOnly) {
@@ -4553,6 +4769,7 @@ export function createRemoteHub(options = {}) {
4553
4769
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4554
4770
  500);
4555
4771
  const result = message.result ?? null;
4772
+ settlePendingCommandResult(device, message);
4556
4773
  handleInputFallbackCommandResult(device, message);
4557
4774
  if (error || result?.ok === false || result?.status === 'failed') {
4558
4775
  failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
@@ -4606,10 +4823,11 @@ export function createRemoteHub(options = {}) {
4606
4823
  socket.setNoDelay(true);
4607
4824
  socket.setKeepAlive(true, heartbeatMs);
4608
4825
 
4609
- const state = {
4610
- authenticated: false,
4611
- device: null,
4612
- inputOnly: false,
4826
+ const state = {
4827
+ authenticated: false,
4828
+ device: null,
4829
+ binaryTransport: 'ws-binary',
4830
+ inputOnly: false,
4613
4831
  frameOnly: false,
4614
4832
  audioOnly: false,
4615
4833
  fileOnly: false,
@@ -4763,10 +4981,11 @@ export function createRemoteHub(options = {}) {
4763
4981
  socket.setNoDelay(true);
4764
4982
  socket.setKeepAlive(true, heartbeatMs);
4765
4983
 
4766
- const state = {
4767
- authenticated: false,
4768
- device: null,
4769
- inputOnly: false,
4984
+ const state = {
4985
+ authenticated: false,
4986
+ device: null,
4987
+ binaryTransport: 'binary',
4988
+ inputOnly: false,
4770
4989
  frameOnly: false,
4771
4990
  audioOnly: false,
4772
4991
  fileOnly: false,
@@ -5000,6 +5219,7 @@ export function createRemoteHub(options = {}) {
5000
5219
  for (const device of devices.values()) {
5001
5220
  failAllPendingTasks(device, 'hub-shutdown');
5002
5221
  failAllPendingInputFallbacks(device, 'hub-shutdown');
5222
+ failPendingCommandResultWaiters(device, 'hub-shutdown');
5003
5223
  if (device.socket && !device.socket.destroyed) {
5004
5224
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
5005
5225
  device.socket.destroy();
@@ -5137,7 +5357,7 @@ export function createRemoteHub(options = {}) {
5137
5357
  : commandName.startsWith('agent.')
5138
5358
  ? 'allowAgent'
5139
5359
  : '';
5140
- const denied = policyError(device, requiredPermission);
5360
+ const denied = policyError(device, requiredPermission, commandName);
5141
5361
  if (denied) return { ok: false, error: denied };
5142
5362
  if (device?.synthetic === true && device.connected) {
5143
5363
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
@@ -5240,10 +5460,58 @@ export function createRemoteHub(options = {}) {
5240
5460
  return { ok: false, error: 'device-not-found' };
5241
5461
  }
5242
5462
 
5243
- if (!device.connected) {
5244
- return { ok: false, error: 'device-not-connected' };
5245
- }
5246
-
5463
+ if (!device.connected) {
5464
+ return { ok: false, error: 'device-not-connected' };
5465
+ }
5466
+
5467
+ const normalized = normalizeRemoteInputEvent(input);
5468
+ if (!normalized.type) {
5469
+ return { ok: false, error: 'missing-input-type' };
5470
+ }
5471
+
5472
+ // A reset only releases native key state. The current browser input
5473
+ // owner must be able to send it even when its former Control binding
5474
+ // has just become stale during a monitor/capture-generation switch.
5475
+ const currentInputOwner = safeString(device.inputOwnerConnectionId, 128);
5476
+ const isCurrentOwnerKeyboardReset = normalized.type.toLowerCase() === 'keyboard.reset'
5477
+ && normalized.hubConnectionId
5478
+ && normalized.hubConnectionId === currentInputOwner;
5479
+ if (isCurrentOwnerKeyboardReset) {
5480
+ const inputSocket = device.inputSocket;
5481
+ if (inputSocket && !inputSocket.destroyed) {
5482
+ const sent = writeJsonLine(inputSocket, {
5483
+ type: 'input.control',
5484
+ payload: {
5485
+ ...normalized,
5486
+ reason: normalized.reason || 'control-keyboard-owner-changed',
5487
+ pressedCodes: [],
5488
+ shiftKey: false,
5489
+ ctrlKey: false,
5490
+ altKey: false,
5491
+ metaKey: false,
5492
+ repeat: false,
5493
+ hubForwardedAtEpochMs: Date.now(),
5494
+ issuedAt: normalized.issuedAt || new Date().toISOString()
5495
+ }
5496
+ });
5497
+ if (sent) {
5498
+ device.inputOwnerBindingKey = '';
5499
+ device.counters.commandsSent += 1;
5500
+ device.inputLastSeenAt = new Date().toISOString();
5501
+ return {
5502
+ ok: true,
5503
+ inputSocket: true,
5504
+ releaseOnly: true,
5505
+ staleBindingAccepted: true
5506
+ };
5507
+ }
5508
+ detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
5509
+ }
5510
+ if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
5511
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5512
+ }
5513
+ }
5514
+
5247
5515
  const denied = policyError(device, 'allowControl');
5248
5516
  if (denied) return { ok: false, error: denied };
5249
5517
 
@@ -5255,10 +5523,6 @@ export function createRemoteHub(options = {}) {
5255
5523
  return { ok: false, error: 'device-input-control-unavailable' };
5256
5524
  }
5257
5525
 
5258
- const normalized = normalizeRemoteInputEvent(input);
5259
- if (!normalized.type) {
5260
- return { ok: false, error: 'missing-input-type' };
5261
- }
5262
5526
  const controlStream = getActiveControlStream(device);
5263
5527
  if (!controlStream
5264
5528
  || !liveStreamHasCurrentFrame(controlStream)
@@ -5292,18 +5556,74 @@ export function createRemoteHub(options = {}) {
5292
5556
  activeCaptureGeneration: Number(controlStream.captureGeneration || 0)
5293
5557
  };
5294
5558
  }
5559
+ const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
5560
+ if (!Number.isInteger(normalized.monitorIndex)
5561
+ || normalized.monitorIndex !== activeMonitorIndex) {
5562
+ return {
5563
+ ok: false,
5564
+ error: 'STALE_CONTROL_MONITOR',
5565
+ activeMonitorIndex
5566
+ };
5567
+ }
5295
5568
 
5296
5569
  const inputSocket = device.inputSocket;
5297
5570
  if (inputSocket && !inputSocket.destroyed) {
5298
- const sent = writeJsonLine(inputSocket, {
5571
+ const previousOwnerConnectionId = safeString(device.inputOwnerConnectionId, 128);
5572
+ const activeInputBindingKey = [
5573
+ safeString(device.sessionId, 160),
5574
+ safeString(controlStream.commandId, 128),
5575
+ Number(controlStream.captureGeneration || 0),
5576
+ activeMonitorIndex
5577
+ ].join(':');
5578
+ const previousInputBindingKey = safeString(device.inputOwnerBindingKey, 512);
5579
+ const ownerChanged = normalized.hubConnectionId
5580
+ && previousOwnerConnectionId
5581
+ && normalized.hubConnectionId !== previousOwnerConnectionId;
5582
+ const bindingChanged = previousInputBindingKey
5583
+ && previousInputBindingKey !== activeInputBindingKey;
5584
+ if (ownerChanged || bindingChanged) {
5585
+ const resetSent = writeJsonLine(inputSocket, {
5586
+ type: 'input.control',
5587
+ payload: {
5588
+ type: 'keyboard.reset',
5589
+ reason: ownerChanged
5590
+ ? 'browser-input-owner-changed'
5591
+ : 'control-input-binding-changed',
5592
+ pressedCodes: [],
5593
+ shiftKey: false,
5594
+ ctrlKey: false,
5595
+ altKey: false,
5596
+ metaKey: false,
5597
+ repeat: false,
5598
+ requestAck: false,
5599
+ inputSeq: 0,
5600
+ hubConnectionId: previousOwnerConnectionId,
5601
+ monitorIndex: activeMonitorIndex,
5602
+ controlSessionId: safeString(device.sessionId, 160),
5603
+ controlCommandId: safeString(controlStream.commandId, 128),
5604
+ captureGeneration: Number(controlStream.captureGeneration || 0),
5605
+ hubForwardedAtEpochMs: Date.now(),
5606
+ issuedAt: new Date().toISOString()
5607
+ }
5608
+ });
5609
+ if (!resetSent) {
5610
+ detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
5611
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5612
+ }
5613
+ }
5614
+ const sent = writeJsonLine(inputSocket, {
5299
5615
  type: 'input.control',
5300
5616
  payload: {
5301
5617
  ...normalized,
5302
5618
  hubForwardedAtEpochMs: Date.now(),
5303
5619
  issuedAt: normalized.issuedAt || new Date().toISOString()
5304
5620
  }
5305
- });
5306
- if (sent) {
5621
+ });
5622
+ if (sent) {
5623
+ if (normalized.hubConnectionId) {
5624
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
5625
+ }
5626
+ device.inputOwnerBindingKey = activeInputBindingKey;
5307
5627
  device.counters.commandsSent += 1;
5308
5628
  device.inputLastSeenAt = new Date().toISOString();
5309
5629
  return {
@@ -5311,13 +5631,21 @@ export function createRemoteHub(options = {}) {
5311
5631
  inputSocket: true,
5312
5632
  sessionId: device.sessionId,
5313
5633
  commandId: controlStream.commandId,
5314
- captureGeneration: controlStream.captureGeneration
5634
+ captureGeneration: controlStream.captureGeneration,
5635
+ monitorIndex: activeMonitorIndex
5315
5636
  };
5316
5637
  }
5317
5638
 
5318
- detachInputSocket(inputSocket, 'input-socket-write-failed');
5319
- }
5320
-
5639
+ detachInputSocket(inputSocket, 'input-socket-write-failed');
5640
+ }
5641
+
5642
+ // Current Agents advertise this ordered side channel. Crossing to the
5643
+ // concurrent main command socket during a reconnect can invert
5644
+ // keydown/keyup, so only legacy Agents use the compatibility fallback.
5645
+ if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
5646
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5647
+ }
5648
+
5321
5649
  const hubForwardedAtEpochMs = Date.now();
5322
5650
  const fallback = sendCommand(deviceId, {
5323
5651
  command: 'input.control',
@@ -5332,6 +5660,9 @@ export function createRemoteHub(options = {}) {
5332
5660
  commandId: fallback.commandId,
5333
5661
  inputSeq: normalized.inputSeq,
5334
5662
  inputType: normalized.type,
5663
+ monitorIndex: normalized.monitorIndex,
5664
+ hubConnectionId: normalized.hubConnectionId,
5665
+ inputEventId: normalized.inputEventId,
5335
5666
  issuedAtEpochMs: normalized.issuedAtEpochMs,
5336
5667
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
5337
5668
  hubForwardedAtEpochMs,
@@ -5348,6 +5679,7 @@ export function createRemoteHub(options = {}) {
5348
5679
  deliveryCommandId: fallback.commandId,
5349
5680
  commandId: controlStream.commandId,
5350
5681
  captureGeneration: controlStream.captureGeneration,
5682
+ monitorIndex: activeMonitorIndex,
5351
5683
  inputSeq: normalized.inputSeq
5352
5684
  }
5353
5685
  : {
@@ -5357,6 +5689,49 @@ export function createRemoteHub(options = {}) {
5357
5689
  };
5358
5690
  }
5359
5691
 
5692
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
5693
+ const device = devices.get(String(deviceId || ''));
5694
+ const owner = safeString(ownerConnectionId, 128);
5695
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
5696
+ return { ok: false, error: 'input-owner-not-current' };
5697
+ }
5698
+
5699
+ device.inputOwnerConnectionId = '';
5700
+ device.inputOwnerBindingKey = '';
5701
+ const inputSocket = device.inputSocket;
5702
+ if (!inputSocket || inputSocket.destroyed) {
5703
+ return { ok: true, queued: false };
5704
+ }
5705
+ const controlStream = getActiveControlStream(device);
5706
+ const sent = writeJsonLine(inputSocket, {
5707
+ type: 'input.control',
5708
+ payload: {
5709
+ type: 'keyboard.reset',
5710
+ reason: safeString(reason, 120) || 'browser-input-owner-closed',
5711
+ pressedCodes: [],
5712
+ shiftKey: false,
5713
+ ctrlKey: false,
5714
+ altKey: false,
5715
+ metaKey: false,
5716
+ repeat: false,
5717
+ requestAck: false,
5718
+ inputSeq: 0,
5719
+ hubConnectionId: owner,
5720
+ monitorIndex: normalizeMonitorIndex(controlStream?.monitorIndex),
5721
+ controlSessionId: safeString(device.sessionId, 160),
5722
+ controlCommandId: safeString(controlStream?.commandId, 128),
5723
+ captureGeneration: Number(controlStream?.captureGeneration || 0),
5724
+ hubForwardedAtEpochMs: Date.now(),
5725
+ issuedAt: new Date().toISOString()
5726
+ }
5727
+ });
5728
+ if (!sent) {
5729
+ detachInputSocket(inputSocket, 'input-owner-release-write-failed');
5730
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5731
+ }
5732
+ return { ok: true, queued: true };
5733
+ }
5734
+
5360
5735
  function notifyAgentProgress(deviceIds, progress = {}) {
5361
5736
  const targets = [...new Set((deviceIds || [])
5362
5737
  .map(deviceId => safeString(deviceId, 128))
@@ -5762,24 +6137,42 @@ export function createRemoteHub(options = {}) {
5762
6137
  streamId: stream.streamId,
5763
6138
  commandId: key,
5764
6139
  error: safeString(error, 500) || 'stream-start-failed'
5765
- });
5766
- return true;
5767
- }
5768
- return false;
5769
- }
6140
+ });
6141
+ return true;
6142
+ }
6143
+ for (const stream of streams.values()) {
6144
+ if (safeString(stream?.commandId, 128) !== key || stream?.active !== true) {
6145
+ continue;
6146
+ }
6147
+ clearPendingLiveStreamDescriptor(device, stream);
6148
+ stream.active = false;
6149
+ stream.open = false;
6150
+ stream.stoppedAt = new Date().toISOString();
6151
+ stream.stopReason = safeString(error, 500) || 'stream-start-failed';
6152
+ emitRemoteEvent('RemoteLiveStreamStartFailed', device, {
6153
+ streamId: stream.streamId,
6154
+ commandId: key,
6155
+ error: stream.stopReason
6156
+ });
6157
+ return true;
6158
+ }
6159
+ return false;
6160
+ }
5770
6161
 
5771
6162
  function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5772
6163
  const streams = ensureDeviceLiveStreams(device);
5773
6164
  for (const stream of streams.values()) {
5774
6165
  clearPendingLiveStreamDescriptor(device, stream);
5775
6166
  stream.active = false;
6167
+ stream.open = false;
5776
6168
  stream.stoppedAt = stoppedAt;
5777
6169
  stream.stopReason = reason;
5778
- }
5779
- if (device?.activeLiveStream) {
5780
- device.activeLiveStream.active = false;
5781
- device.activeLiveStream.stoppedAt = stoppedAt;
5782
- device.activeLiveStream.stopReason = reason;
6170
+ }
6171
+ if (device?.activeLiveStream) {
6172
+ device.activeLiveStream.active = false;
6173
+ device.activeLiveStream.open = false;
6174
+ device.activeLiveStream.stoppedAt = stoppedAt;
6175
+ device.activeLiveStream.stopReason = reason;
5783
6176
  }
5784
6177
  }
5785
6178
 
@@ -5801,6 +6194,48 @@ export function createRemoteHub(options = {}) {
5801
6194
  safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5802
6195
  }
5803
6196
 
6197
+ function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose) {
6198
+ const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
6199
+ const activeControlStream = getActiveControlStream(device);
6200
+ if (purpose !== 'control'
6201
+ && activeControlStream
6202
+ && activeControlStream.streamId !== streamId) {
6203
+ return {
6204
+ ok: false,
6205
+ error: 'CONTROL_CAPTURE_OWNS_DEVICE',
6206
+ activeStreamId: activeControlStream.streamId,
6207
+ activeStreamPurpose: 'control',
6208
+ activeCommandId: safeString(activeControlStream.commandId, 128),
6209
+ captureGeneration: Number(activeControlStream.captureGeneration || 0)
6210
+ };
6211
+ }
6212
+
6213
+ const competingStreams = getActiveLiveStreams(device)
6214
+ .filter(stream => stream.streamId !== streamId);
6215
+ for (const competingStream of competingStreams) {
6216
+ const result = stopLiveStream(deviceId, {
6217
+ streamId: competingStream.streamId,
6218
+ streamPurpose: competingStream.streamPurpose,
6219
+ reason: `${purpose}-capture-handoff`
6220
+ });
6221
+ if (result?.ok !== true) {
6222
+ return {
6223
+ ok: false,
6224
+ error: result?.error || 'capture-handoff-stop-failed',
6225
+ activeStreamId: competingStream.streamId,
6226
+ activeStreamPurpose: safeString(competingStream.streamPurpose, 24)
6227
+ };
6228
+ }
6229
+ emitRemoteEvent('RemoteLiveStreamCapturePreempted', device, {
6230
+ streamId: competingStream.streamId,
6231
+ streamPurpose: safeString(competingStream.streamPurpose, 24) || 'wall',
6232
+ replacementStreamId: streamId,
6233
+ replacementStreamPurpose: purpose
6234
+ });
6235
+ }
6236
+ return { ok: true };
6237
+ }
6238
+
5804
6239
  function nextCaptureGeneration(device) {
5805
6240
  const current = Number(device?.captureGenerationCounter || 0);
5806
6241
  const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
@@ -5855,12 +6290,31 @@ export function createRemoteHub(options = {}) {
5855
6290
  return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5856
6291
  }
5857
6292
 
6293
+ function liveStreamRequiresReadyKeyFrame(activeLiveStream) {
6294
+ if (safeString(activeLiveStream?.streamPurpose, 24).toLowerCase() !== 'control') {
6295
+ return false;
6296
+ }
6297
+ const codec = safeString(activeLiveStream?.codec, 80).toLowerCase();
6298
+ const mode = safeString(activeLiveStream?.frameMode || activeLiveStream?.mode, 80).toLowerCase();
6299
+ return codec.includes('h264') || mode === 'mode3-h264-hw';
6300
+ }
6301
+
6302
+ function liveFrameSatisfiesReadiness(activeLiveStream, frame) {
6303
+ return !liveStreamRequiresReadyKeyFrame(activeLiveStream)
6304
+ || frame?.isKeyFrame === true
6305
+ || safeString(frame?.chunkType, 20).toLowerCase() === 'key';
6306
+ }
6307
+
5858
6308
  function liveStreamHasCurrentFrame(activeLiveStream) {
5859
6309
  if (!activeLiveStream?.active
5860
6310
  || activeLiveStream.open !== true
5861
6311
  || Number(activeLiveStream.framesReceived || 0) < 1) {
5862
6312
  return false;
5863
6313
  }
6314
+ if (liveStreamRequiresReadyKeyFrame(activeLiveStream)
6315
+ && activeLiveStream.readyFrameReceived !== true) {
6316
+ return false;
6317
+ }
5864
6318
  const latestFrame = activeLiveStream.latestFrame;
5865
6319
  if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
5866
6320
  return false;
@@ -5918,11 +6372,19 @@ export function createRemoteHub(options = {}) {
5918
6372
  && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
5919
6373
  }
5920
6374
 
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) {
6375
+ function startLiveStream(deviceId, options = {}) {
6376
+ const device = devices.get(String(deviceId || ''));
6377
+ const denied = policyError(device);
6378
+ if (denied) return { ok: false, error: denied };
6379
+ if (device?.liveCapturePause?.token) {
6380
+ return {
6381
+ ok: false,
6382
+ error: 'LIVE_CAPTURE_PAUSED',
6383
+ pauseToken: device.liveCapturePause.token,
6384
+ pausedAt: device.liveCapturePause.pausedAt
6385
+ };
6386
+ }
6387
+ if (device?.synthetic === true && device.connected) {
5926
6388
  if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
5927
6389
  return { ok: false, error: 'device-live-stream-unavailable' };
5928
6390
  }
@@ -5933,6 +6395,8 @@ export function createRemoteHub(options = {}) {
5933
6395
  if (modePolicyError) return { ok: false, error: modePolicyError };
5934
6396
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5935
6397
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6398
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6399
+ if (!captureClaim.ok) return captureClaim;
5936
6400
  const activeLiveStream = getDeviceLiveStream(device, streamId);
5937
6401
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5938
6402
  if (activeLiveStream
@@ -5984,6 +6448,7 @@ export function createRemoteHub(options = {}) {
5984
6448
  lastFrameAt: '',
5985
6449
  lastFrameSeq: 0,
5986
6450
  framesReceived: 0,
6451
+ readyFrameReceived: false,
5987
6452
  streamPurpose,
5988
6453
  captureGeneration
5989
6454
  });
@@ -6031,6 +6496,8 @@ export function createRemoteHub(options = {}) {
6031
6496
  if (modePolicyError) return { ok: false, error: modePolicyError };
6032
6497
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6033
6498
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6499
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6500
+ if (!captureClaim.ok) return captureClaim;
6034
6501
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6035
6502
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
6036
6503
  if (pendingDescriptor?.commandId) {
@@ -6201,8 +6668,9 @@ export function createRemoteHub(options = {}) {
6201
6668
  stoppedAt: '',
6202
6669
  stopReason: '',
6203
6670
  lastFrameAt: '',
6204
- lastFrameSeq: 0,
6671
+ lastFrameSeq: 0,
6205
6672
  framesReceived: 0,
6673
+ readyFrameReceived: false,
6206
6674
  streamPurpose,
6207
6675
  captureGeneration,
6208
6676
  latestFrame: null,
@@ -6247,14 +6715,16 @@ export function createRemoteHub(options = {}) {
6247
6715
  };
6248
6716
  }
6249
6717
 
6250
- function stopLiveStream(deviceId, options = {}) {
6718
+ function stopLiveStream(deviceId, options = {}) {
6251
6719
  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 || '';
6720
+ const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
6721
+ const requestedStreamId = safeString(options.streamId, 128);
6722
+ const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
6723
+ const streamId = options.stopAll === true
6724
+ ? ''
6725
+ : requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
6256
6726
  const streamState = getDeviceLiveStream(device, streamId);
6257
- if (streamState && streamState.active !== true) {
6727
+ if (streamState && streamState.active !== true && options.forceCommand !== true) {
6258
6728
  clearPendingLiveStreamDescriptor(device, streamState);
6259
6729
  return {
6260
6730
  ok: true,
@@ -6271,9 +6741,10 @@ export function createRemoteHub(options = {}) {
6271
6741
  device.counters.commandsSent += 1;
6272
6742
  if (streamState) {
6273
6743
  streamState.active = false;
6744
+ streamState.open = false;
6274
6745
  streamState.stoppedAt = new Date().toISOString();
6275
- streamState.stopReason = 'manager-request';
6276
- }
6746
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6747
+ }
6277
6748
  device.counters.liveStreamsStopped += 1;
6278
6749
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6279
6750
  streamId,
@@ -6303,17 +6774,230 @@ export function createRemoteHub(options = {}) {
6303
6774
 
6304
6775
  if (streamState) {
6305
6776
  streamState.active = false;
6777
+ streamState.open = false;
6306
6778
  streamState.stoppedAt = new Date().toISOString();
6307
- streamState.stopReason = 'manager-request';
6308
- }
6779
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6780
+ }
6309
6781
  device.counters.liveStreamsStopped += 1;
6310
6782
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6311
6783
  streamId,
6312
6784
  commandId
6313
6785
  });
6314
6786
 
6315
- return { ok: true, commandId, streamId };
6316
- }
6787
+ return { ok: true, commandId, streamId };
6788
+ }
6789
+
6790
+ function buildLiveCapturePauseResult(pause, extra = {}) {
6791
+ return {
6792
+ paused: true,
6793
+ pauseToken: pause.token,
6794
+ pausedAt: pause.pausedAt,
6795
+ streamId: pause.streamId,
6796
+ streamPurpose: pause.streamPurpose,
6797
+ monitorIndex: pause.monitorIndex,
6798
+ phase: pause.phase,
6799
+ captureStopConfirmed: pause.captureStopConfirmed === true,
6800
+ captureStopConfirmedAt: pause.captureStopConfirmedAt || '',
6801
+ stopCommandId: pause.stopCommandId || '',
6802
+ stopError: pause.stopError || '',
6803
+ stopAttemptCount: Math.max(0, Number(pause.stopAttemptCount) || 0),
6804
+ ...extra
6805
+ };
6806
+ }
6807
+
6808
+ async function confirmLiveCaptureStopped(deviceId, pause) {
6809
+ let device = devices.get(String(deviceId || ''));
6810
+ if (!device || device.liveCapturePause?.token !== pause.token) {
6811
+ return buildLiveCapturePauseResult(pause, {
6812
+ ok: false,
6813
+ error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
6814
+ });
6815
+ }
6816
+
6817
+ pause.phase = 'pausing';
6818
+ pause.captureStopConfirmed = false;
6819
+ pause.captureStopConfirmedAt = '';
6820
+ pause.stopError = '';
6821
+ pause.stopAttemptCount = Math.max(0, Number(pause.stopAttemptCount) || 0) + 1;
6822
+
6823
+ let stopResult = { ok: true, alreadyStopped: true, streamId: pause.streamId };
6824
+ if (device.synthetic !== true) {
6825
+ stopResult = stopLiveStream(deviceId, {
6826
+ reason: pause.reason,
6827
+ // Empty streamId is an intentional Agent contract: stop every
6828
+ // active/quarantined capture, including a native helper that
6829
+ // outlived the Hub's last stream descriptor.
6830
+ stopAll: true,
6831
+ forceCommand: true
6832
+ });
6833
+ }
6834
+
6835
+ if (stopResult?.ok === false) {
6836
+ pause.phase = 'stop-unconfirmed';
6837
+ pause.stopError = safeString(stopResult.error, 500) || 'stream-stop-command-failed';
6838
+ emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
6839
+ pauseToken: pause.token,
6840
+ streamId: pause.streamId,
6841
+ stopError: pause.stopError
6842
+ });
6843
+ return buildLiveCapturePauseResult(pause, {
6844
+ ok: false,
6845
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
6846
+ });
6847
+ }
6848
+
6849
+ pause.stopCommandId = safeString(stopResult.commandId, 128);
6850
+ if (pause.stopCommandId
6851
+ && stopResult.alreadyStopped !== true
6852
+ && stopResult.synthetic !== true) {
6853
+ const acknowledgement = await waitForCommandResult(
6854
+ device,
6855
+ pause.stopCommandId,
6856
+ liveCaptureStopAckTimeoutMs);
6857
+ device = devices.get(String(deviceId || '')) || device;
6858
+ if (device.liveCapturePause?.token !== pause.token) {
6859
+ return buildLiveCapturePauseResult(pause, {
6860
+ ok: false,
6861
+ error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
6862
+ });
6863
+ }
6864
+ if (!acknowledgement.ok
6865
+ || acknowledgement.result?.stream !== true
6866
+ || acknowledgement.result?.stopped !== true) {
6867
+ pause.phase = 'stop-unconfirmed';
6868
+ pause.stopError = safeString(
6869
+ acknowledgement.error
6870
+ || acknowledgement.result?.error
6871
+ || 'stream-stop-not-confirmed',
6872
+ 500);
6873
+ emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
6874
+ pauseToken: pause.token,
6875
+ streamId: pause.streamId,
6876
+ stopCommandId: pause.stopCommandId,
6877
+ stopError: pause.stopError
6878
+ });
6879
+ return buildLiveCapturePauseResult(pause, {
6880
+ ok: false,
6881
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
6882
+ });
6883
+ }
6884
+ }
6885
+
6886
+ pause.phase = 'paused';
6887
+ pause.captureStopConfirmed = true;
6888
+ pause.captureStopConfirmedAt = new Date().toISOString();
6889
+ pause.stopError = '';
6890
+ deactivateDeviceLiveStreams(device, pause.reason, pause.captureStopConfirmedAt);
6891
+ emitRemoteEvent('RemoteLiveCapturePaused', device, {
6892
+ pauseToken: pause.token,
6893
+ pausedAt: pause.pausedAt,
6894
+ streamId: pause.streamId,
6895
+ streamPurpose: pause.streamPurpose,
6896
+ monitorIndex: pause.monitorIndex,
6897
+ stopCommandId: pause.stopCommandId,
6898
+ captureStopConfirmedAt: pause.captureStopConfirmedAt
6899
+ });
6900
+ return buildLiveCapturePauseResult(pause, {
6901
+ ok: true,
6902
+ alreadyStopped: stopResult.alreadyStopped === true
6903
+ });
6904
+ }
6905
+
6906
+ async function pauseLiveCapture(deviceId, options = {}) {
6907
+ const device = devices.get(String(deviceId || ''));
6908
+ if (!device) {
6909
+ return { ok: false, paused: false, error: 'device-not-found' };
6910
+ }
6911
+ const denied = policyError(device, '', 'stream.stop');
6912
+ if (denied) return { ok: false, paused: false, error: denied };
6913
+
6914
+ let pause = device.liveCapturePause;
6915
+ if (pause?.token && pause.captureStopConfirmed === true) {
6916
+ return buildLiveCapturePauseResult(pause, {
6917
+ ok: true,
6918
+ alreadyPaused: true
6919
+ });
6920
+ }
6921
+ if (pause?.stopPromise) {
6922
+ return pause.stopPromise;
6923
+ }
6924
+
6925
+ if (!pause?.token) {
6926
+ const activeLiveStream = device.activeLiveStream?.active === true
6927
+ ? device.activeLiveStream
6928
+ : null;
6929
+ pause = {
6930
+ token: crypto.randomUUID(),
6931
+ pausedAt: new Date().toISOString(),
6932
+ reason: safeString(options.reason, 128) || 'user-video-pause',
6933
+ streamId: safeString(activeLiveStream?.streamId, 128),
6934
+ streamPurpose: safeString(activeLiveStream?.streamPurpose, 24),
6935
+ monitorIndex: Math.max(0, Math.floor(Number(activeLiveStream?.monitorIndex) || 0)),
6936
+ phase: 'pausing',
6937
+ captureStopConfirmed: false,
6938
+ captureStopConfirmedAt: '',
6939
+ stopCommandId: '',
6940
+ stopError: '',
6941
+ stopAttemptCount: 0
6942
+ };
6943
+ // Install the authoritative gate before asking the Agent to stop.
6944
+ // A late subscriber or watchdog therefore cannot reacquire FFmpeg
6945
+ // in the pause transition window.
6946
+ device.liveCapturePause = pause;
6947
+ }
6948
+
6949
+ const operation = confirmLiveCaptureStopped(deviceId, pause);
6950
+ pause.stopPromise = operation;
6951
+ try {
6952
+ return await operation;
6953
+ } finally {
6954
+ if (pause.stopPromise === operation) {
6955
+ delete pause.stopPromise;
6956
+ }
6957
+ }
6958
+ }
6959
+
6960
+ function resumeLiveCapture(deviceId, options = {}) {
6961
+ const device = devices.get(String(deviceId || ''));
6962
+ const denied = policyError(device);
6963
+ if (denied) return { ok: false, error: denied };
6964
+ const pause = device?.liveCapturePause;
6965
+ if (!pause?.token) {
6966
+ return { ok: true, paused: false, alreadyResumed: true };
6967
+ }
6968
+ if (pause.phase === 'pausing') {
6969
+ return {
6970
+ ok: false,
6971
+ paused: true,
6972
+ error: 'LIVE_CAPTURE_STOP_IN_PROGRESS',
6973
+ pauseToken: pause.token
6974
+ };
6975
+ }
6976
+ const pauseToken = safeString(options.pauseToken, 128);
6977
+ if (!pauseToken || pauseToken !== pause.token) {
6978
+ return {
6979
+ ok: false,
6980
+ error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
6981
+ };
6982
+ }
6983
+
6984
+ device.liveCapturePause = null;
6985
+ emitRemoteEvent('RemoteLiveCaptureResumed', device, {
6986
+ pauseToken,
6987
+ resumedAt: new Date().toISOString(),
6988
+ streamId: pause.streamId,
6989
+ streamPurpose: pause.streamPurpose,
6990
+ monitorIndex: pause.monitorIndex
6991
+ });
6992
+ return {
6993
+ ok: true,
6994
+ paused: false,
6995
+ resumed: true,
6996
+ streamId: pause.streamId,
6997
+ streamPurpose: pause.streamPurpose,
6998
+ monitorIndex: pause.monitorIndex
6999
+ };
7000
+ }
6317
7001
 
6318
7002
  function startAudioStream(deviceId, options = {}) {
6319
7003
  const device = devices.get(String(deviceId || ''));
@@ -6475,15 +7159,18 @@ export function createRemoteHub(options = {}) {
6475
7159
  sendCommand,
6476
7160
  sendLegacyClientUpdate,
6477
7161
  sendInputControl,
7162
+ releaseInputOwner,
6478
7163
  notifyAgentProgress,
6479
7164
  requestAgentTask,
6480
7165
  requestAgentTaskBatch,
6481
7166
  setHostTarget,
6482
7167
  rotatePairingPin,
6483
- requestThumbnail,
6484
- startLiveStream,
6485
- stopLiveStream,
6486
- startAudioStream,
7168
+ requestThumbnail,
7169
+ startLiveStream,
7170
+ stopLiveStream,
7171
+ pauseLiveCapture,
7172
+ resumeLiveCapture,
7173
+ startAudioStream,
6487
7174
  stopAudioStream,
6488
7175
  getDeviceLiveFrame,
6489
7176
  getDeviceThumbnail,