livedesk 0.1.437 → 0.1.439

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.
@@ -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,6 +692,13 @@ 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
704
  // A missing monitor is not equivalent to the primary display. Control
@@ -697,8 +713,9 @@ function normalizeRemoteInputEvent(value = {}) {
697
713
  code: safeString(input.code || input.Code, 80),
698
714
  keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
699
715
  location: Number.isFinite(location) ? Math.max(0, Math.min(255, Math.trunc(location))) : 0,
700
- text: safeText(input.text || input.Text, 256),
701
- 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,
702
719
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
703
720
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
704
721
  altKey: input.altKey === true || input.AltKey === true,
@@ -710,8 +727,9 @@ function normalizeRemoteInputEvent(value = {}) {
710
727
  hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
711
728
  inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
712
729
  inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
713
- requestAck: input.requestAck !== false && input.RequestAck !== false,
714
- 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,
715
733
  hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
716
734
  issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
717
735
  };
@@ -1016,10 +1034,26 @@ function serializeDevice(device, options = {}) {
1016
1034
  audio: !!device.audioSocket && !device.audioSocket.destroyed,
1017
1035
  file: !!device.fileSocket && !device.fileSocket.destroyed
1018
1036
  },
1019
- latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1020
- latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1021
- activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
1022
- 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,
1023
1057
  latestAudioFrame: device.latestAudioFrame
1024
1058
  ? {
1025
1059
  streamId: device.latestAudioFrame.streamId,
@@ -1486,12 +1520,16 @@ export function createRemoteHub(options = {}) {
1486
1520
  }
1487
1521
  }
1488
1522
 
1489
- function policyError(device, permission = '') {
1490
- const policy = getDevicePolicy(device);
1491
- if (policy.accessMode === 'block-remote-access') return 'remote-access-blocked-by-settings';
1492
- if (permission && policy[permission] === false) {
1493
- return `${permission}-blocked-by-settings`;
1494
- }
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
+ }
1495
1533
  return '';
1496
1534
  }
1497
1535
 
@@ -1510,6 +1548,11 @@ export function createRemoteHub(options = {}) {
1510
1548
  250,
1511
1549
  30000,
1512
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);
1513
1556
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1514
1557
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1515
1558
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1538,9 +1581,10 @@ export function createRemoteHub(options = {}) {
1538
1581
  const sockets = new Map();
1539
1582
  const inputSockets = new Map();
1540
1583
  const frameSockets = new Map();
1541
- const audioSockets = new Map();
1542
- const fileSockets = new Map();
1543
- const allSockets = new Set();
1584
+ const audioSockets = new Map();
1585
+ const fileSockets = new Map();
1586
+ const allSockets = new Set();
1587
+ const pendingCommandResultWaiters = new Map();
1544
1588
  const duplicateDeviceLogAt = new Map();
1545
1589
  let server = null;
1546
1590
  let started = false;
@@ -2048,15 +2092,109 @@ export function createRemoteHub(options = {}) {
2048
2092
  });
2049
2093
  }
2050
2094
 
2051
- function emitRemoteEvent(type, device = null, extra = {}) {
2052
- emitEvent(type, {
2053
- ...extra,
2054
- remoteHub: getStatus({ includeSecrets: false }),
2055
- device: serializeDevice(device)
2056
- });
2057
- }
2058
-
2059
- 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 = {}) {
2060
2198
  const now = new Date().toISOString();
2061
2199
  const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
2062
2200
  const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
@@ -2177,6 +2315,7 @@ export function createRemoteHub(options = {}) {
2177
2315
  existing.lastDisconnectReason = 'replaced-by-new-session';
2178
2316
  failAllPendingTasks(existing, 'replaced-by-new-session');
2179
2317
  failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2318
+ failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
2180
2319
  writeJsonLine(existing.socket, {
2181
2320
  type: 'disconnect',
2182
2321
  reason: 'replaced-by-new-session',
@@ -2716,9 +2855,11 @@ export function createRemoteHub(options = {}) {
2716
2855
  : getSupportedRemoteFrameModeProfiles();
2717
2856
  }
2718
2857
 
2719
- const device = {
2720
- socket,
2721
- inputSocket: null,
2858
+ const device = {
2859
+ socket,
2860
+ inputSocket: null,
2861
+ inputOwnerConnectionId: '',
2862
+ inputOwnerBindingKey: '',
2722
2863
  frameSocket: null,
2723
2864
  audioSocket: null,
2724
2865
  fileSocket: null,
@@ -2759,9 +2900,13 @@ export function createRemoteHub(options = {}) {
2759
2900
  recentFramePayloads: {
2760
2901
  thumbnail: [],
2761
2902
  live: []
2762
- },
2763
- activeLiveStream: null,
2764
- 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,
2765
2910
  latestAudioFrame: null,
2766
2911
  latestAudioStatus: null,
2767
2912
  udp: {
@@ -2950,10 +3095,11 @@ export function createRemoteHub(options = {}) {
2950
3095
  closeInputSocket(device, reason);
2951
3096
  closeFrameSocket(device, reason);
2952
3097
  device.disconnectedAt = new Date().toISOString();
2953
- device.lastDisconnectReason = reason;
3098
+ device.lastDisconnectReason = reason;
2954
3099
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2955
3100
  failAllPendingTasks(device, 'device-disconnected');
2956
3101
  failAllPendingInputFallbacks(device, 'device-disconnected');
3102
+ failPendingCommandResultWaiters(device, 'device-disconnected');
2957
3103
  logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2958
3104
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2959
3105
  }
@@ -4044,6 +4190,11 @@ export function createRemoteHub(options = {}) {
4044
4190
  && frameCaptureGeneration === activeCaptureGeneration));
4045
4191
  const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
4046
4192
  const captureTimingAvailable = hasFrameCaptureTiming(message);
4193
+ const videoTransport = transport === 'udp-p2p'
4194
+ ? 'p2p-udp'
4195
+ : transport === 'ws-binary'
4196
+ ? 'direct-ws'
4197
+ : 'direct-tcp';
4047
4198
  device.latestLiveFrame = {
4048
4199
  streamId,
4049
4200
  frameSeq,
@@ -4080,9 +4231,10 @@ export function createRemoteHub(options = {}) {
4080
4231
  agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
4081
4232
  agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
4082
4233
  agentFrameAgeMs: Number.isFinite(Number(message.agentFrameAgeMs)) ? Number(message.agentFrameAgeMs) : 0,
4083
- droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4084
- hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4085
- 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),
4086
4238
  platformProfile: safeString(message.platformProfile, 80),
4087
4239
  monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
4088
4240
  monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
@@ -4096,12 +4248,12 @@ export function createRemoteHub(options = {}) {
4096
4248
  captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
4097
4249
  captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
4098
4250
  slowFrameCount: Number.isFinite(Number(message.slowFrameCount)) ? Number(message.slowFrameCount) : 0,
4099
- captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4100
- capturedAt,
4101
- receivedAt: device.lastSeenAt,
4102
- byteLength,
4103
- transport,
4104
- contentHash,
4251
+ captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4252
+ capturedAt,
4253
+ receivedAt: device.lastSeenAt,
4254
+ byteLength,
4255
+ transport,
4256
+ contentHash,
4105
4257
  captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
4106
4258
  captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
4107
4259
  convertMs: readFrameStageMs(message, 'convertMs'),
@@ -4110,10 +4262,20 @@ export function createRemoteHub(options = {}) {
4110
4262
  captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
4111
4263
  deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
4112
4264
  deltaTileCount: Number.isFinite(Number(message.deltaTileCount)) ? Number(message.deltaTileCount) : 0,
4113
- deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
4114
- deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4115
- deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4116
- 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,
4117
4279
  payload,
4118
4280
  accessToken: createFrameAccessToken()
4119
4281
  };
@@ -4276,7 +4438,7 @@ export function createRemoteHub(options = {}) {
4276
4438
  return true;
4277
4439
  }
4278
4440
 
4279
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
4441
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
4280
4442
  if (!state.authenticated || !state.device) {
4281
4443
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
4282
4444
  socket.destroy();
@@ -4300,28 +4462,29 @@ export function createRemoteHub(options = {}) {
4300
4462
  }
4301
4463
 
4302
4464
  const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
4303
- if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4304
- applyThumbnailFrame(device, header, framePayload, 'binary');
4305
- return;
4306
- }
4307
-
4308
- if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4309
- applyLiveFrame(device, {
4310
- ...header,
4311
- hubIngressDropped: Number(state.binaryQueueDrops || 0)
4312
- }, framePayload, 'binary');
4313
- return;
4314
- }
4315
-
4316
- if (frameKind === 'audio' || header.type === 'audio.binary') {
4317
- applyAudioFrame(device, header, framePayload, 'binary');
4318
- return;
4319
- }
4320
-
4321
- if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
4322
- applyAudioStatus(device, header, framePayload, 'binary');
4323
- return;
4324
- }
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
+ }
4325
4488
 
4326
4489
  emitRemoteEvent('RemoteAgentMessageIgnored', device, {
4327
4490
  messageType: safeString(header.type, 80),
@@ -4606,6 +4769,7 @@ export function createRemoteHub(options = {}) {
4606
4769
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4607
4770
  500);
4608
4771
  const result = message.result ?? null;
4772
+ settlePendingCommandResult(device, message);
4609
4773
  handleInputFallbackCommandResult(device, message);
4610
4774
  if (error || result?.ok === false || result?.status === 'failed') {
4611
4775
  failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
@@ -4659,10 +4823,11 @@ export function createRemoteHub(options = {}) {
4659
4823
  socket.setNoDelay(true);
4660
4824
  socket.setKeepAlive(true, heartbeatMs);
4661
4825
 
4662
- const state = {
4663
- authenticated: false,
4664
- device: null,
4665
- inputOnly: false,
4826
+ const state = {
4827
+ authenticated: false,
4828
+ device: null,
4829
+ binaryTransport: 'ws-binary',
4830
+ inputOnly: false,
4666
4831
  frameOnly: false,
4667
4832
  audioOnly: false,
4668
4833
  fileOnly: false,
@@ -4816,10 +4981,11 @@ export function createRemoteHub(options = {}) {
4816
4981
  socket.setNoDelay(true);
4817
4982
  socket.setKeepAlive(true, heartbeatMs);
4818
4983
 
4819
- const state = {
4820
- authenticated: false,
4821
- device: null,
4822
- inputOnly: false,
4984
+ const state = {
4985
+ authenticated: false,
4986
+ device: null,
4987
+ binaryTransport: 'binary',
4988
+ inputOnly: false,
4823
4989
  frameOnly: false,
4824
4990
  audioOnly: false,
4825
4991
  fileOnly: false,
@@ -5053,6 +5219,7 @@ export function createRemoteHub(options = {}) {
5053
5219
  for (const device of devices.values()) {
5054
5220
  failAllPendingTasks(device, 'hub-shutdown');
5055
5221
  failAllPendingInputFallbacks(device, 'hub-shutdown');
5222
+ failPendingCommandResultWaiters(device, 'hub-shutdown');
5056
5223
  if (device.socket && !device.socket.destroyed) {
5057
5224
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
5058
5225
  device.socket.destroy();
@@ -5190,7 +5357,7 @@ export function createRemoteHub(options = {}) {
5190
5357
  : commandName.startsWith('agent.')
5191
5358
  ? 'allowAgent'
5192
5359
  : '';
5193
- const denied = policyError(device, requiredPermission);
5360
+ const denied = policyError(device, requiredPermission, commandName);
5194
5361
  if (denied) return { ok: false, error: denied };
5195
5362
  if (device?.synthetic === true && device.connected) {
5196
5363
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
@@ -5293,10 +5460,58 @@ export function createRemoteHub(options = {}) {
5293
5460
  return { ok: false, error: 'device-not-found' };
5294
5461
  }
5295
5462
 
5296
- if (!device.connected) {
5297
- return { ok: false, error: 'device-not-connected' };
5298
- }
5299
-
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
+
5300
5515
  const denied = policyError(device, 'allowControl');
5301
5516
  if (denied) return { ok: false, error: denied };
5302
5517
 
@@ -5308,10 +5523,6 @@ export function createRemoteHub(options = {}) {
5308
5523
  return { ok: false, error: 'device-input-control-unavailable' };
5309
5524
  }
5310
5525
 
5311
- const normalized = normalizeRemoteInputEvent(input);
5312
- if (!normalized.type) {
5313
- return { ok: false, error: 'missing-input-type' };
5314
- }
5315
5526
  const controlStream = getActiveControlStream(device);
5316
5527
  if (!controlStream
5317
5528
  || !liveStreamHasCurrentFrame(controlStream)
@@ -5357,15 +5568,62 @@ export function createRemoteHub(options = {}) {
5357
5568
 
5358
5569
  const inputSocket = device.inputSocket;
5359
5570
  if (inputSocket && !inputSocket.destroyed) {
5360
- 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, {
5361
5615
  type: 'input.control',
5362
5616
  payload: {
5363
5617
  ...normalized,
5364
5618
  hubForwardedAtEpochMs: Date.now(),
5365
5619
  issuedAt: normalized.issuedAt || new Date().toISOString()
5366
5620
  }
5367
- });
5368
- if (sent) {
5621
+ });
5622
+ if (sent) {
5623
+ if (normalized.hubConnectionId) {
5624
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
5625
+ }
5626
+ device.inputOwnerBindingKey = activeInputBindingKey;
5369
5627
  device.counters.commandsSent += 1;
5370
5628
  device.inputLastSeenAt = new Date().toISOString();
5371
5629
  return {
@@ -5378,9 +5636,16 @@ export function createRemoteHub(options = {}) {
5378
5636
  };
5379
5637
  }
5380
5638
 
5381
- detachInputSocket(inputSocket, 'input-socket-write-failed');
5382
- }
5383
-
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
+
5384
5649
  const hubForwardedAtEpochMs = Date.now();
5385
5650
  const fallback = sendCommand(deviceId, {
5386
5651
  command: 'input.control',
@@ -5424,6 +5689,49 @@ export function createRemoteHub(options = {}) {
5424
5689
  };
5425
5690
  }
5426
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
+
5427
5735
  function notifyAgentProgress(deviceIds, progress = {}) {
5428
5736
  const targets = [...new Set((deviceIds || [])
5429
5737
  .map(deviceId => safeString(deviceId, 128))
@@ -5829,24 +6137,42 @@ export function createRemoteHub(options = {}) {
5829
6137
  streamId: stream.streamId,
5830
6138
  commandId: key,
5831
6139
  error: safeString(error, 500) || 'stream-start-failed'
5832
- });
5833
- return true;
5834
- }
5835
- return false;
5836
- }
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
+ }
5837
6161
 
5838
6162
  function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5839
6163
  const streams = ensureDeviceLiveStreams(device);
5840
6164
  for (const stream of streams.values()) {
5841
6165
  clearPendingLiveStreamDescriptor(device, stream);
5842
6166
  stream.active = false;
6167
+ stream.open = false;
5843
6168
  stream.stoppedAt = stoppedAt;
5844
6169
  stream.stopReason = reason;
5845
- }
5846
- if (device?.activeLiveStream) {
5847
- device.activeLiveStream.active = false;
5848
- device.activeLiveStream.stoppedAt = stoppedAt;
5849
- 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;
5850
6176
  }
5851
6177
  }
5852
6178
 
@@ -5868,6 +6194,48 @@ export function createRemoteHub(options = {}) {
5868
6194
  safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5869
6195
  }
5870
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
+
5871
6239
  function nextCaptureGeneration(device) {
5872
6240
  const current = Number(device?.captureGenerationCounter || 0);
5873
6241
  const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
@@ -6004,11 +6372,19 @@ export function createRemoteHub(options = {}) {
6004
6372
  && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
6005
6373
  }
6006
6374
 
6007
- function startLiveStream(deviceId, options = {}) {
6008
- const device = devices.get(String(deviceId || ''));
6009
- const denied = policyError(device);
6010
- if (denied) return { ok: false, error: denied };
6011
- 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) {
6012
6388
  if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
6013
6389
  return { ok: false, error: 'device-live-stream-unavailable' };
6014
6390
  }
@@ -6019,6 +6395,8 @@ export function createRemoteHub(options = {}) {
6019
6395
  if (modePolicyError) return { ok: false, error: modePolicyError };
6020
6396
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6021
6397
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6398
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6399
+ if (!captureClaim.ok) return captureClaim;
6022
6400
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6023
6401
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6024
6402
  if (activeLiveStream
@@ -6118,6 +6496,8 @@ export function createRemoteHub(options = {}) {
6118
6496
  if (modePolicyError) return { ok: false, error: modePolicyError };
6119
6497
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6120
6498
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6499
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6500
+ if (!captureClaim.ok) return captureClaim;
6121
6501
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6122
6502
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
6123
6503
  if (pendingDescriptor?.commandId) {
@@ -6335,14 +6715,16 @@ export function createRemoteHub(options = {}) {
6335
6715
  };
6336
6716
  }
6337
6717
 
6338
- function stopLiveStream(deviceId, options = {}) {
6718
+ function stopLiveStream(deviceId, options = {}) {
6339
6719
  const device = devices.get(String(deviceId || ''));
6340
- const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
6341
- const requestedStreamId = safeString(options.streamId, 128);
6342
- const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
6343
- 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 || '';
6344
6726
  const streamState = getDeviceLiveStream(device, streamId);
6345
- if (streamState && streamState.active !== true) {
6727
+ if (streamState && streamState.active !== true && options.forceCommand !== true) {
6346
6728
  clearPendingLiveStreamDescriptor(device, streamState);
6347
6729
  return {
6348
6730
  ok: true,
@@ -6359,9 +6741,10 @@ export function createRemoteHub(options = {}) {
6359
6741
  device.counters.commandsSent += 1;
6360
6742
  if (streamState) {
6361
6743
  streamState.active = false;
6744
+ streamState.open = false;
6362
6745
  streamState.stoppedAt = new Date().toISOString();
6363
- streamState.stopReason = 'manager-request';
6364
- }
6746
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6747
+ }
6365
6748
  device.counters.liveStreamsStopped += 1;
6366
6749
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6367
6750
  streamId,
@@ -6391,17 +6774,230 @@ export function createRemoteHub(options = {}) {
6391
6774
 
6392
6775
  if (streamState) {
6393
6776
  streamState.active = false;
6777
+ streamState.open = false;
6394
6778
  streamState.stoppedAt = new Date().toISOString();
6395
- streamState.stopReason = 'manager-request';
6396
- }
6779
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6780
+ }
6397
6781
  device.counters.liveStreamsStopped += 1;
6398
6782
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6399
6783
  streamId,
6400
6784
  commandId
6401
6785
  });
6402
6786
 
6403
- return { ok: true, commandId, streamId };
6404
- }
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
+ }
6405
7001
 
6406
7002
  function startAudioStream(deviceId, options = {}) {
6407
7003
  const device = devices.get(String(deviceId || ''));
@@ -6563,15 +7159,18 @@ export function createRemoteHub(options = {}) {
6563
7159
  sendCommand,
6564
7160
  sendLegacyClientUpdate,
6565
7161
  sendInputControl,
7162
+ releaseInputOwner,
6566
7163
  notifyAgentProgress,
6567
7164
  requestAgentTask,
6568
7165
  requestAgentTaskBatch,
6569
7166
  setHostTarget,
6570
7167
  rotatePairingPin,
6571
- requestThumbnail,
6572
- startLiveStream,
6573
- stopLiveStream,
6574
- startAudioStream,
7168
+ requestThumbnail,
7169
+ startLiveStream,
7170
+ stopLiveStream,
7171
+ pauseLiveCapture,
7172
+ resumeLiveCapture,
7173
+ startAudioStream,
6575
7174
  stopAudioStream,
6576
7175
  getDeviceLiveFrame,
6577
7176
  getDeviceThumbnail,