livedesk 0.1.437 → 0.1.438

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,6 +28,7 @@ const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
28
28
  const LIVE_STREAM_MIN_FRESH_MS = 3000;
29
29
  const LIVE_STREAM_MAX_FRESH_MS = 12000;
30
30
  const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
31
+ const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
31
32
  const REMOTE_PROTOCOL_VERSION = 2;
32
33
  const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
33
34
  const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
@@ -154,7 +155,10 @@ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
154
155
  const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
155
156
  const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 8;
156
157
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
157
- const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 64;
158
+ // Native capture backends can emit short bursts (ScreenCaptureKit commonly
159
+ // queues up to eight frames). Drain one frame per event-loop turn so a burst
160
+ // cannot immediately overflow the smaller Hub-to-browser send lanes.
161
+ const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 1;
158
162
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
159
163
  const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
160
164
  const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
@@ -683,6 +687,13 @@ function normalizeRemoteInputEvent(value = {}) {
683
687
  const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
684
688
  const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
685
689
  const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
690
+ const rawPressedCodes = input.pressedCodes ?? input.PressedCodes;
691
+ const pressedCodes = Array.isArray(rawPressedCodes)
692
+ ? [...new Set(rawPressedCodes
693
+ .map(code => safeString(code, 80))
694
+ .filter(Boolean))]
695
+ .slice(0, 64)
696
+ : null;
686
697
  return {
687
698
  type,
688
699
  // A missing monitor is not equivalent to the primary display. Control
@@ -697,8 +708,9 @@ function normalizeRemoteInputEvent(value = {}) {
697
708
  code: safeString(input.code || input.Code, 80),
698
709
  keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
699
710
  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,
711
+ text: safeText(input.text || input.Text, 256),
712
+ repeat: input.repeat === true || input.Repeat === true,
713
+ pressedCodes,
702
714
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
703
715
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
704
716
  altKey: input.altKey === true || input.AltKey === true,
@@ -710,8 +722,9 @@ function normalizeRemoteInputEvent(value = {}) {
710
722
  hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
711
723
  inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
712
724
  inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
713
- requestAck: input.requestAck !== false && input.RequestAck !== false,
714
- issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
725
+ requestAck: input.requestAck !== false && input.RequestAck !== false,
726
+ reason: safeString(input.reason || input.Reason, 120),
727
+ issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
715
728
  hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
716
729
  issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
717
730
  };
@@ -1016,10 +1029,26 @@ function serializeDevice(device, options = {}) {
1016
1029
  audio: !!device.audioSocket && !device.audioSocket.destroyed,
1017
1030
  file: !!device.fileSocket && !device.fileSocket.destroyed
1018
1031
  },
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,
1032
+ latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
1033
+ latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
1034
+ activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
1035
+ liveCapturePause: device.liveCapturePause
1036
+ ? {
1037
+ token: device.liveCapturePause.token,
1038
+ pausedAt: device.liveCapturePause.pausedAt,
1039
+ reason: device.liveCapturePause.reason,
1040
+ streamId: device.liveCapturePause.streamId,
1041
+ streamPurpose: device.liveCapturePause.streamPurpose,
1042
+ monitorIndex: device.liveCapturePause.monitorIndex,
1043
+ phase: device.liveCapturePause.phase,
1044
+ captureStopConfirmed: device.liveCapturePause.captureStopConfirmed === true,
1045
+ captureStopConfirmedAt: device.liveCapturePause.captureStopConfirmedAt || '',
1046
+ stopCommandId: device.liveCapturePause.stopCommandId || '',
1047
+ stopError: device.liveCapturePause.stopError || '',
1048
+ stopAttemptCount: Math.max(0, Number(device.liveCapturePause.stopAttemptCount) || 0)
1049
+ }
1050
+ : null,
1051
+ activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
1023
1052
  latestAudioFrame: device.latestAudioFrame
1024
1053
  ? {
1025
1054
  streamId: device.latestAudioFrame.streamId,
@@ -1510,6 +1539,11 @@ export function createRemoteHub(options = {}) {
1510
1539
  250,
1511
1540
  30000,
1512
1541
  DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
1542
+ const liveCaptureStopAckTimeoutMs = clampNumber(
1543
+ options.liveCaptureStopAckTimeoutMs ?? env.REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS,
1544
+ 250,
1545
+ 30000,
1546
+ DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS);
1513
1547
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1514
1548
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1515
1549
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1538,9 +1572,10 @@ export function createRemoteHub(options = {}) {
1538
1572
  const sockets = new Map();
1539
1573
  const inputSockets = new Map();
1540
1574
  const frameSockets = new Map();
1541
- const audioSockets = new Map();
1542
- const fileSockets = new Map();
1543
- const allSockets = new Set();
1575
+ const audioSockets = new Map();
1576
+ const fileSockets = new Map();
1577
+ const allSockets = new Set();
1578
+ const pendingCommandResultWaiters = new Map();
1544
1579
  const duplicateDeviceLogAt = new Map();
1545
1580
  let server = null;
1546
1581
  let started = false;
@@ -2048,15 +2083,109 @@ export function createRemoteHub(options = {}) {
2048
2083
  });
2049
2084
  }
2050
2085
 
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 = {}) {
2086
+ function emitRemoteEvent(type, device = null, extra = {}) {
2087
+ emitEvent(type, {
2088
+ ...extra,
2089
+ remoteHub: getStatus({ includeSecrets: false }),
2090
+ device: serializeDevice(device)
2091
+ });
2092
+ }
2093
+
2094
+ function settlePendingCommandResult(device, message = {}) {
2095
+ const commandId = safeString(message.commandId, 128);
2096
+ const waiter = pendingCommandResultWaiters.get(commandId);
2097
+ if (!waiter
2098
+ || waiter.deviceId !== device?.deviceId
2099
+ || waiter.sessionId !== device?.sessionId) {
2100
+ return false;
2101
+ }
2102
+
2103
+ pendingCommandResultWaiters.delete(commandId);
2104
+ clearTimeout(waiter.timer);
2105
+ const result = message.result ?? null;
2106
+ const error = safeString(
2107
+ message.error
2108
+ || result?.error
2109
+ || (message.type === 'command.error' ? 'command-failed' : ''),
2110
+ 500);
2111
+ waiter.resolve({
2112
+ ok: !error
2113
+ && result?.ok !== false
2114
+ && result?.status !== 'failed'
2115
+ && result?.status !== 'rejected',
2116
+ commandId,
2117
+ result,
2118
+ error
2119
+ });
2120
+ return true;
2121
+ }
2122
+
2123
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
2124
+ if (!device?.deviceId) {
2125
+ return;
2126
+ }
2127
+ for (const [commandId, waiter] of pendingCommandResultWaiters) {
2128
+ if (waiter.deviceId !== device.deviceId
2129
+ || (waiter.sessionId && waiter.sessionId !== device.sessionId)) {
2130
+ continue;
2131
+ }
2132
+ pendingCommandResultWaiters.delete(commandId);
2133
+ clearTimeout(waiter.timer);
2134
+ waiter.resolve({
2135
+ ok: false,
2136
+ commandId,
2137
+ result: null,
2138
+ error: safeString(error, 500) || 'command-result-unavailable'
2139
+ });
2140
+ }
2141
+ }
2142
+
2143
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
2144
+ const key = safeString(commandId, 128);
2145
+ if (!device?.deviceId || !device?.sessionId || !key) {
2146
+ return Promise.resolve({
2147
+ ok: false,
2148
+ commandId: key,
2149
+ result: null,
2150
+ error: 'command-result-wait-invalid'
2151
+ });
2152
+ }
2153
+
2154
+ return new Promise(resolve => {
2155
+ const previous = pendingCommandResultWaiters.get(key);
2156
+ if (previous) {
2157
+ clearTimeout(previous.timer);
2158
+ previous.resolve({
2159
+ ok: false,
2160
+ commandId: key,
2161
+ result: null,
2162
+ error: 'command-result-wait-replaced'
2163
+ });
2164
+ }
2165
+ const timer = setTimeout(() => {
2166
+ const waiter = pendingCommandResultWaiters.get(key);
2167
+ if (!waiter || waiter.deviceId !== device.deviceId || waiter.sessionId !== device.sessionId) {
2168
+ return;
2169
+ }
2170
+ pendingCommandResultWaiters.delete(key);
2171
+ resolve({
2172
+ ok: false,
2173
+ commandId: key,
2174
+ result: null,
2175
+ error: 'command-result-timeout'
2176
+ });
2177
+ }, timeoutMs);
2178
+ timer.unref?.();
2179
+ pendingCommandResultWaiters.set(key, {
2180
+ deviceId: device.deviceId,
2181
+ sessionId: device.sessionId,
2182
+ timer,
2183
+ resolve
2184
+ });
2185
+ });
2186
+ }
2187
+
2188
+ function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
2060
2189
  const now = new Date().toISOString();
2061
2190
  const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
2062
2191
  const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
@@ -2177,6 +2306,7 @@ export function createRemoteHub(options = {}) {
2177
2306
  existing.lastDisconnectReason = 'replaced-by-new-session';
2178
2307
  failAllPendingTasks(existing, 'replaced-by-new-session');
2179
2308
  failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2309
+ failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
2180
2310
  writeJsonLine(existing.socket, {
2181
2311
  type: 'disconnect',
2182
2312
  reason: 'replaced-by-new-session',
@@ -2716,9 +2846,11 @@ export function createRemoteHub(options = {}) {
2716
2846
  : getSupportedRemoteFrameModeProfiles();
2717
2847
  }
2718
2848
 
2719
- const device = {
2720
- socket,
2721
- inputSocket: null,
2849
+ const device = {
2850
+ socket,
2851
+ inputSocket: null,
2852
+ inputOwnerConnectionId: '',
2853
+ inputOwnerBindingKey: '',
2722
2854
  frameSocket: null,
2723
2855
  audioSocket: null,
2724
2856
  fileSocket: null,
@@ -2759,9 +2891,13 @@ export function createRemoteHub(options = {}) {
2759
2891
  recentFramePayloads: {
2760
2892
  thumbnail: [],
2761
2893
  live: []
2762
- },
2763
- activeLiveStream: null,
2764
- activeAudioStream: null,
2894
+ },
2895
+ activeLiveStream: null,
2896
+ // A manual pause is a Hub-owned capture gate, not an Agent-session
2897
+ // detail. Preserve the same lease across a transient Client
2898
+ // reconnect so no subscriber or watchdog can reacquire FFmpeg.
2899
+ liveCapturePause: existing?.liveCapturePause || null,
2900
+ activeAudioStream: null,
2765
2901
  latestAudioFrame: null,
2766
2902
  latestAudioStatus: null,
2767
2903
  udp: {
@@ -2950,10 +3086,11 @@ export function createRemoteHub(options = {}) {
2950
3086
  closeInputSocket(device, reason);
2951
3087
  closeFrameSocket(device, reason);
2952
3088
  device.disconnectedAt = new Date().toISOString();
2953
- device.lastDisconnectReason = reason;
3089
+ device.lastDisconnectReason = reason;
2954
3090
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2955
3091
  failAllPendingTasks(device, 'device-disconnected');
2956
3092
  failAllPendingInputFallbacks(device, 'device-disconnected');
3093
+ failPendingCommandResultWaiters(device, 'device-disconnected');
2957
3094
  logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2958
3095
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2959
3096
  }
@@ -4044,6 +4181,11 @@ export function createRemoteHub(options = {}) {
4044
4181
  && frameCaptureGeneration === activeCaptureGeneration));
4045
4182
  const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
4046
4183
  const captureTimingAvailable = hasFrameCaptureTiming(message);
4184
+ const videoTransport = transport === 'udp-p2p'
4185
+ ? 'p2p-udp'
4186
+ : transport === 'ws-binary'
4187
+ ? 'direct-ws'
4188
+ : 'direct-tcp';
4047
4189
  device.latestLiveFrame = {
4048
4190
  streamId,
4049
4191
  frameSeq,
@@ -4080,9 +4222,10 @@ export function createRemoteHub(options = {}) {
4080
4222
  agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
4081
4223
  agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
4082
4224
  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),
4225
+ droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4226
+ hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4227
+ udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
4228
+ hardwareEncoder: safeString(message.hardwareEncoder, 80),
4086
4229
  platformProfile: safeString(message.platformProfile, 80),
4087
4230
  monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
4088
4231
  monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
@@ -4096,12 +4239,12 @@ export function createRemoteHub(options = {}) {
4096
4239
  captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
4097
4240
  captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
4098
4241
  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,
4242
+ captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4243
+ capturedAt,
4244
+ receivedAt: device.lastSeenAt,
4245
+ byteLength,
4246
+ transport,
4247
+ contentHash,
4105
4248
  captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
4106
4249
  captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
4107
4250
  convertMs: readFrameStageMs(message, 'convertMs'),
@@ -4110,10 +4253,20 @@ export function createRemoteHub(options = {}) {
4110
4253
  captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
4111
4254
  deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
4112
4255
  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,
4256
+ deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
4257
+ deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4258
+ deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4259
+ videoTransport,
4260
+ frameTransportActive: safeString(message.frameTransportActive, 20) || (videoTransport === 'p2p-udp' ? 'p2p' : 'direct'),
4261
+ frameTransportProtocol: safeString(message.frameTransportProtocol, 20)
4262
+ || (videoTransport === 'p2p-udp' ? 'udp' : videoTransport === 'direct-ws' ? 'ws' : 'tcp'),
4263
+ frameTransportState: safeString(message.frameTransportState, 20) || 'active',
4264
+ frameTransportP2pReady: message.frameTransportP2pReady === true,
4265
+ frameTransportReason: safeString(message.frameTransportReason, 240),
4266
+ frameTransportChangedAt: safeString(message.frameTransportChangedAt, 80),
4267
+ frameTransportSwitchCount: Number.isFinite(Number(message.frameTransportSwitchCount)) ? Number(message.frameTransportSwitchCount) : 0,
4268
+ frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
4269
+ sameContentStreak,
4117
4270
  payload,
4118
4271
  accessToken: createFrameAccessToken()
4119
4272
  };
@@ -4276,7 +4429,7 @@ export function createRemoteHub(options = {}) {
4276
4429
  return true;
4277
4430
  }
4278
4431
 
4279
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
4432
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
4280
4433
  if (!state.authenticated || !state.device) {
4281
4434
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
4282
4435
  socket.destroy();
@@ -4300,28 +4453,29 @@ export function createRemoteHub(options = {}) {
4300
4453
  }
4301
4454
 
4302
4455
  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
- }
4456
+ const binaryTransport = state.binaryTransport === 'ws-binary' ? 'ws-binary' : 'binary';
4457
+ if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4458
+ applyThumbnailFrame(device, header, framePayload, binaryTransport);
4459
+ return;
4460
+ }
4461
+
4462
+ if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4463
+ applyLiveFrame(device, {
4464
+ ...header,
4465
+ hubIngressDropped: Number(state.binaryQueueDrops || 0)
4466
+ }, framePayload, binaryTransport);
4467
+ return;
4468
+ }
4469
+
4470
+ if (frameKind === 'audio' || header.type === 'audio.binary') {
4471
+ applyAudioFrame(device, header, framePayload, binaryTransport);
4472
+ return;
4473
+ }
4474
+
4475
+ if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
4476
+ applyAudioStatus(device, header, framePayload, binaryTransport);
4477
+ return;
4478
+ }
4325
4479
 
4326
4480
  emitRemoteEvent('RemoteAgentMessageIgnored', device, {
4327
4481
  messageType: safeString(header.type, 80),
@@ -4606,6 +4760,7 @@ export function createRemoteHub(options = {}) {
4606
4760
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4607
4761
  500);
4608
4762
  const result = message.result ?? null;
4763
+ settlePendingCommandResult(device, message);
4609
4764
  handleInputFallbackCommandResult(device, message);
4610
4765
  if (error || result?.ok === false || result?.status === 'failed') {
4611
4766
  failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
@@ -4659,10 +4814,11 @@ export function createRemoteHub(options = {}) {
4659
4814
  socket.setNoDelay(true);
4660
4815
  socket.setKeepAlive(true, heartbeatMs);
4661
4816
 
4662
- const state = {
4663
- authenticated: false,
4664
- device: null,
4665
- inputOnly: false,
4817
+ const state = {
4818
+ authenticated: false,
4819
+ device: null,
4820
+ binaryTransport: 'ws-binary',
4821
+ inputOnly: false,
4666
4822
  frameOnly: false,
4667
4823
  audioOnly: false,
4668
4824
  fileOnly: false,
@@ -4816,10 +4972,11 @@ export function createRemoteHub(options = {}) {
4816
4972
  socket.setNoDelay(true);
4817
4973
  socket.setKeepAlive(true, heartbeatMs);
4818
4974
 
4819
- const state = {
4820
- authenticated: false,
4821
- device: null,
4822
- inputOnly: false,
4975
+ const state = {
4976
+ authenticated: false,
4977
+ device: null,
4978
+ binaryTransport: 'binary',
4979
+ inputOnly: false,
4823
4980
  frameOnly: false,
4824
4981
  audioOnly: false,
4825
4982
  fileOnly: false,
@@ -5053,6 +5210,7 @@ export function createRemoteHub(options = {}) {
5053
5210
  for (const device of devices.values()) {
5054
5211
  failAllPendingTasks(device, 'hub-shutdown');
5055
5212
  failAllPendingInputFallbacks(device, 'hub-shutdown');
5213
+ failPendingCommandResultWaiters(device, 'hub-shutdown');
5056
5214
  if (device.socket && !device.socket.destroyed) {
5057
5215
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
5058
5216
  device.socket.destroy();
@@ -5293,10 +5451,58 @@ export function createRemoteHub(options = {}) {
5293
5451
  return { ok: false, error: 'device-not-found' };
5294
5452
  }
5295
5453
 
5296
- if (!device.connected) {
5297
- return { ok: false, error: 'device-not-connected' };
5298
- }
5299
-
5454
+ if (!device.connected) {
5455
+ return { ok: false, error: 'device-not-connected' };
5456
+ }
5457
+
5458
+ const normalized = normalizeRemoteInputEvent(input);
5459
+ if (!normalized.type) {
5460
+ return { ok: false, error: 'missing-input-type' };
5461
+ }
5462
+
5463
+ // A reset only releases native key state. The current browser input
5464
+ // owner must be able to send it even when its former Control binding
5465
+ // has just become stale during a monitor/capture-generation switch.
5466
+ const currentInputOwner = safeString(device.inputOwnerConnectionId, 128);
5467
+ const isCurrentOwnerKeyboardReset = normalized.type.toLowerCase() === 'keyboard.reset'
5468
+ && normalized.hubConnectionId
5469
+ && normalized.hubConnectionId === currentInputOwner;
5470
+ if (isCurrentOwnerKeyboardReset) {
5471
+ const inputSocket = device.inputSocket;
5472
+ if (inputSocket && !inputSocket.destroyed) {
5473
+ const sent = writeJsonLine(inputSocket, {
5474
+ type: 'input.control',
5475
+ payload: {
5476
+ ...normalized,
5477
+ reason: normalized.reason || 'control-keyboard-owner-changed',
5478
+ pressedCodes: [],
5479
+ shiftKey: false,
5480
+ ctrlKey: false,
5481
+ altKey: false,
5482
+ metaKey: false,
5483
+ repeat: false,
5484
+ hubForwardedAtEpochMs: Date.now(),
5485
+ issuedAt: normalized.issuedAt || new Date().toISOString()
5486
+ }
5487
+ });
5488
+ if (sent) {
5489
+ device.inputOwnerBindingKey = '';
5490
+ device.counters.commandsSent += 1;
5491
+ device.inputLastSeenAt = new Date().toISOString();
5492
+ return {
5493
+ ok: true,
5494
+ inputSocket: true,
5495
+ releaseOnly: true,
5496
+ staleBindingAccepted: true
5497
+ };
5498
+ }
5499
+ detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
5500
+ }
5501
+ if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
5502
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5503
+ }
5504
+ }
5505
+
5300
5506
  const denied = policyError(device, 'allowControl');
5301
5507
  if (denied) return { ok: false, error: denied };
5302
5508
 
@@ -5308,10 +5514,6 @@ export function createRemoteHub(options = {}) {
5308
5514
  return { ok: false, error: 'device-input-control-unavailable' };
5309
5515
  }
5310
5516
 
5311
- const normalized = normalizeRemoteInputEvent(input);
5312
- if (!normalized.type) {
5313
- return { ok: false, error: 'missing-input-type' };
5314
- }
5315
5517
  const controlStream = getActiveControlStream(device);
5316
5518
  if (!controlStream
5317
5519
  || !liveStreamHasCurrentFrame(controlStream)
@@ -5357,15 +5559,62 @@ export function createRemoteHub(options = {}) {
5357
5559
 
5358
5560
  const inputSocket = device.inputSocket;
5359
5561
  if (inputSocket && !inputSocket.destroyed) {
5360
- const sent = writeJsonLine(inputSocket, {
5562
+ const previousOwnerConnectionId = safeString(device.inputOwnerConnectionId, 128);
5563
+ const activeInputBindingKey = [
5564
+ safeString(device.sessionId, 160),
5565
+ safeString(controlStream.commandId, 128),
5566
+ Number(controlStream.captureGeneration || 0),
5567
+ activeMonitorIndex
5568
+ ].join(':');
5569
+ const previousInputBindingKey = safeString(device.inputOwnerBindingKey, 512);
5570
+ const ownerChanged = normalized.hubConnectionId
5571
+ && previousOwnerConnectionId
5572
+ && normalized.hubConnectionId !== previousOwnerConnectionId;
5573
+ const bindingChanged = previousInputBindingKey
5574
+ && previousInputBindingKey !== activeInputBindingKey;
5575
+ if (ownerChanged || bindingChanged) {
5576
+ const resetSent = writeJsonLine(inputSocket, {
5577
+ type: 'input.control',
5578
+ payload: {
5579
+ type: 'keyboard.reset',
5580
+ reason: ownerChanged
5581
+ ? 'browser-input-owner-changed'
5582
+ : 'control-input-binding-changed',
5583
+ pressedCodes: [],
5584
+ shiftKey: false,
5585
+ ctrlKey: false,
5586
+ altKey: false,
5587
+ metaKey: false,
5588
+ repeat: false,
5589
+ requestAck: false,
5590
+ inputSeq: 0,
5591
+ hubConnectionId: previousOwnerConnectionId,
5592
+ monitorIndex: activeMonitorIndex,
5593
+ controlSessionId: safeString(device.sessionId, 160),
5594
+ controlCommandId: safeString(controlStream.commandId, 128),
5595
+ captureGeneration: Number(controlStream.captureGeneration || 0),
5596
+ hubForwardedAtEpochMs: Date.now(),
5597
+ issuedAt: new Date().toISOString()
5598
+ }
5599
+ });
5600
+ if (!resetSent) {
5601
+ detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
5602
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5603
+ }
5604
+ }
5605
+ const sent = writeJsonLine(inputSocket, {
5361
5606
  type: 'input.control',
5362
5607
  payload: {
5363
5608
  ...normalized,
5364
5609
  hubForwardedAtEpochMs: Date.now(),
5365
5610
  issuedAt: normalized.issuedAt || new Date().toISOString()
5366
5611
  }
5367
- });
5368
- if (sent) {
5612
+ });
5613
+ if (sent) {
5614
+ if (normalized.hubConnectionId) {
5615
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
5616
+ }
5617
+ device.inputOwnerBindingKey = activeInputBindingKey;
5369
5618
  device.counters.commandsSent += 1;
5370
5619
  device.inputLastSeenAt = new Date().toISOString();
5371
5620
  return {
@@ -5378,9 +5627,16 @@ export function createRemoteHub(options = {}) {
5378
5627
  };
5379
5628
  }
5380
5629
 
5381
- detachInputSocket(inputSocket, 'input-socket-write-failed');
5382
- }
5383
-
5630
+ detachInputSocket(inputSocket, 'input-socket-write-failed');
5631
+ }
5632
+
5633
+ // Current Agents advertise this ordered side channel. Crossing to the
5634
+ // concurrent main command socket during a reconnect can invert
5635
+ // keydown/keyup, so only legacy Agents use the compatibility fallback.
5636
+ if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
5637
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5638
+ }
5639
+
5384
5640
  const hubForwardedAtEpochMs = Date.now();
5385
5641
  const fallback = sendCommand(deviceId, {
5386
5642
  command: 'input.control',
@@ -5424,6 +5680,49 @@ export function createRemoteHub(options = {}) {
5424
5680
  };
5425
5681
  }
5426
5682
 
5683
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
5684
+ const device = devices.get(String(deviceId || ''));
5685
+ const owner = safeString(ownerConnectionId, 128);
5686
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
5687
+ return { ok: false, error: 'input-owner-not-current' };
5688
+ }
5689
+
5690
+ device.inputOwnerConnectionId = '';
5691
+ device.inputOwnerBindingKey = '';
5692
+ const inputSocket = device.inputSocket;
5693
+ if (!inputSocket || inputSocket.destroyed) {
5694
+ return { ok: true, queued: false };
5695
+ }
5696
+ const controlStream = getActiveControlStream(device);
5697
+ const sent = writeJsonLine(inputSocket, {
5698
+ type: 'input.control',
5699
+ payload: {
5700
+ type: 'keyboard.reset',
5701
+ reason: safeString(reason, 120) || 'browser-input-owner-closed',
5702
+ pressedCodes: [],
5703
+ shiftKey: false,
5704
+ ctrlKey: false,
5705
+ altKey: false,
5706
+ metaKey: false,
5707
+ repeat: false,
5708
+ requestAck: false,
5709
+ inputSeq: 0,
5710
+ hubConnectionId: owner,
5711
+ monitorIndex: normalizeMonitorIndex(controlStream?.monitorIndex),
5712
+ controlSessionId: safeString(device.sessionId, 160),
5713
+ controlCommandId: safeString(controlStream?.commandId, 128),
5714
+ captureGeneration: Number(controlStream?.captureGeneration || 0),
5715
+ hubForwardedAtEpochMs: Date.now(),
5716
+ issuedAt: new Date().toISOString()
5717
+ }
5718
+ });
5719
+ if (!sent) {
5720
+ detachInputSocket(inputSocket, 'input-owner-release-write-failed');
5721
+ return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
5722
+ }
5723
+ return { ok: true, queued: true };
5724
+ }
5725
+
5427
5726
  function notifyAgentProgress(deviceIds, progress = {}) {
5428
5727
  const targets = [...new Set((deviceIds || [])
5429
5728
  .map(deviceId => safeString(deviceId, 128))
@@ -5829,24 +6128,42 @@ export function createRemoteHub(options = {}) {
5829
6128
  streamId: stream.streamId,
5830
6129
  commandId: key,
5831
6130
  error: safeString(error, 500) || 'stream-start-failed'
5832
- });
5833
- return true;
5834
- }
5835
- return false;
5836
- }
6131
+ });
6132
+ return true;
6133
+ }
6134
+ for (const stream of streams.values()) {
6135
+ if (safeString(stream?.commandId, 128) !== key || stream?.active !== true) {
6136
+ continue;
6137
+ }
6138
+ clearPendingLiveStreamDescriptor(device, stream);
6139
+ stream.active = false;
6140
+ stream.open = false;
6141
+ stream.stoppedAt = new Date().toISOString();
6142
+ stream.stopReason = safeString(error, 500) || 'stream-start-failed';
6143
+ emitRemoteEvent('RemoteLiveStreamStartFailed', device, {
6144
+ streamId: stream.streamId,
6145
+ commandId: key,
6146
+ error: stream.stopReason
6147
+ });
6148
+ return true;
6149
+ }
6150
+ return false;
6151
+ }
5837
6152
 
5838
6153
  function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5839
6154
  const streams = ensureDeviceLiveStreams(device);
5840
6155
  for (const stream of streams.values()) {
5841
6156
  clearPendingLiveStreamDescriptor(device, stream);
5842
6157
  stream.active = false;
6158
+ stream.open = false;
5843
6159
  stream.stoppedAt = stoppedAt;
5844
6160
  stream.stopReason = reason;
5845
- }
5846
- if (device?.activeLiveStream) {
5847
- device.activeLiveStream.active = false;
5848
- device.activeLiveStream.stoppedAt = stoppedAt;
5849
- device.activeLiveStream.stopReason = reason;
6161
+ }
6162
+ if (device?.activeLiveStream) {
6163
+ device.activeLiveStream.active = false;
6164
+ device.activeLiveStream.open = false;
6165
+ device.activeLiveStream.stoppedAt = stoppedAt;
6166
+ device.activeLiveStream.stopReason = reason;
5850
6167
  }
5851
6168
  }
5852
6169
 
@@ -5868,6 +6185,48 @@ export function createRemoteHub(options = {}) {
5868
6185
  safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5869
6186
  }
5870
6187
 
6188
+ function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose) {
6189
+ const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
6190
+ const activeControlStream = getActiveControlStream(device);
6191
+ if (purpose !== 'control'
6192
+ && activeControlStream
6193
+ && activeControlStream.streamId !== streamId) {
6194
+ return {
6195
+ ok: false,
6196
+ error: 'CONTROL_CAPTURE_OWNS_DEVICE',
6197
+ activeStreamId: activeControlStream.streamId,
6198
+ activeStreamPurpose: 'control',
6199
+ activeCommandId: safeString(activeControlStream.commandId, 128),
6200
+ captureGeneration: Number(activeControlStream.captureGeneration || 0)
6201
+ };
6202
+ }
6203
+
6204
+ const competingStreams = getActiveLiveStreams(device)
6205
+ .filter(stream => stream.streamId !== streamId);
6206
+ for (const competingStream of competingStreams) {
6207
+ const result = stopLiveStream(deviceId, {
6208
+ streamId: competingStream.streamId,
6209
+ streamPurpose: competingStream.streamPurpose,
6210
+ reason: `${purpose}-capture-handoff`
6211
+ });
6212
+ if (result?.ok !== true) {
6213
+ return {
6214
+ ok: false,
6215
+ error: result?.error || 'capture-handoff-stop-failed',
6216
+ activeStreamId: competingStream.streamId,
6217
+ activeStreamPurpose: safeString(competingStream.streamPurpose, 24)
6218
+ };
6219
+ }
6220
+ emitRemoteEvent('RemoteLiveStreamCapturePreempted', device, {
6221
+ streamId: competingStream.streamId,
6222
+ streamPurpose: safeString(competingStream.streamPurpose, 24) || 'wall',
6223
+ replacementStreamId: streamId,
6224
+ replacementStreamPurpose: purpose
6225
+ });
6226
+ }
6227
+ return { ok: true };
6228
+ }
6229
+
5871
6230
  function nextCaptureGeneration(device) {
5872
6231
  const current = Number(device?.captureGenerationCounter || 0);
5873
6232
  const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
@@ -6004,11 +6363,19 @@ export function createRemoteHub(options = {}) {
6004
6363
  && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
6005
6364
  }
6006
6365
 
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) {
6366
+ function startLiveStream(deviceId, options = {}) {
6367
+ const device = devices.get(String(deviceId || ''));
6368
+ const denied = policyError(device);
6369
+ if (denied) return { ok: false, error: denied };
6370
+ if (device?.liveCapturePause?.token) {
6371
+ return {
6372
+ ok: false,
6373
+ error: 'LIVE_CAPTURE_PAUSED',
6374
+ pauseToken: device.liveCapturePause.token,
6375
+ pausedAt: device.liveCapturePause.pausedAt
6376
+ };
6377
+ }
6378
+ if (device?.synthetic === true && device.connected) {
6012
6379
  if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
6013
6380
  return { ok: false, error: 'device-live-stream-unavailable' };
6014
6381
  }
@@ -6019,6 +6386,8 @@ export function createRemoteHub(options = {}) {
6019
6386
  if (modePolicyError) return { ok: false, error: modePolicyError };
6020
6387
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6021
6388
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6389
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6390
+ if (!captureClaim.ok) return captureClaim;
6022
6391
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6023
6392
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6024
6393
  if (activeLiveStream
@@ -6118,6 +6487,8 @@ export function createRemoteHub(options = {}) {
6118
6487
  if (modePolicyError) return { ok: false, error: modePolicyError };
6119
6488
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6120
6489
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6490
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6491
+ if (!captureClaim.ok) return captureClaim;
6121
6492
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6122
6493
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
6123
6494
  if (pendingDescriptor?.commandId) {
@@ -6335,14 +6706,16 @@ export function createRemoteHub(options = {}) {
6335
6706
  };
6336
6707
  }
6337
6708
 
6338
- function stopLiveStream(deviceId, options = {}) {
6709
+ function stopLiveStream(deviceId, options = {}) {
6339
6710
  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 || '';
6711
+ const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
6712
+ const requestedStreamId = safeString(options.streamId, 128);
6713
+ const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
6714
+ const streamId = options.stopAll === true
6715
+ ? ''
6716
+ : requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
6344
6717
  const streamState = getDeviceLiveStream(device, streamId);
6345
- if (streamState && streamState.active !== true) {
6718
+ if (streamState && streamState.active !== true && options.forceCommand !== true) {
6346
6719
  clearPendingLiveStreamDescriptor(device, streamState);
6347
6720
  return {
6348
6721
  ok: true,
@@ -6359,9 +6732,10 @@ export function createRemoteHub(options = {}) {
6359
6732
  device.counters.commandsSent += 1;
6360
6733
  if (streamState) {
6361
6734
  streamState.active = false;
6735
+ streamState.open = false;
6362
6736
  streamState.stoppedAt = new Date().toISOString();
6363
- streamState.stopReason = 'manager-request';
6364
- }
6737
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6738
+ }
6365
6739
  device.counters.liveStreamsStopped += 1;
6366
6740
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6367
6741
  streamId,
@@ -6391,17 +6765,230 @@ export function createRemoteHub(options = {}) {
6391
6765
 
6392
6766
  if (streamState) {
6393
6767
  streamState.active = false;
6768
+ streamState.open = false;
6394
6769
  streamState.stoppedAt = new Date().toISOString();
6395
- streamState.stopReason = 'manager-request';
6396
- }
6770
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6771
+ }
6397
6772
  device.counters.liveStreamsStopped += 1;
6398
6773
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6399
6774
  streamId,
6400
6775
  commandId
6401
6776
  });
6402
6777
 
6403
- return { ok: true, commandId, streamId };
6404
- }
6778
+ return { ok: true, commandId, streamId };
6779
+ }
6780
+
6781
+ function buildLiveCapturePauseResult(pause, extra = {}) {
6782
+ return {
6783
+ paused: true,
6784
+ pauseToken: pause.token,
6785
+ pausedAt: pause.pausedAt,
6786
+ streamId: pause.streamId,
6787
+ streamPurpose: pause.streamPurpose,
6788
+ monitorIndex: pause.monitorIndex,
6789
+ phase: pause.phase,
6790
+ captureStopConfirmed: pause.captureStopConfirmed === true,
6791
+ captureStopConfirmedAt: pause.captureStopConfirmedAt || '',
6792
+ stopCommandId: pause.stopCommandId || '',
6793
+ stopError: pause.stopError || '',
6794
+ stopAttemptCount: Math.max(0, Number(pause.stopAttemptCount) || 0),
6795
+ ...extra
6796
+ };
6797
+ }
6798
+
6799
+ async function confirmLiveCaptureStopped(deviceId, pause) {
6800
+ let device = devices.get(String(deviceId || ''));
6801
+ if (!device || device.liveCapturePause?.token !== pause.token) {
6802
+ return buildLiveCapturePauseResult(pause, {
6803
+ ok: false,
6804
+ error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
6805
+ });
6806
+ }
6807
+
6808
+ pause.phase = 'pausing';
6809
+ pause.captureStopConfirmed = false;
6810
+ pause.captureStopConfirmedAt = '';
6811
+ pause.stopError = '';
6812
+ pause.stopAttemptCount = Math.max(0, Number(pause.stopAttemptCount) || 0) + 1;
6813
+
6814
+ let stopResult = { ok: true, alreadyStopped: true, streamId: pause.streamId };
6815
+ if (device.synthetic !== true) {
6816
+ stopResult = stopLiveStream(deviceId, {
6817
+ reason: pause.reason,
6818
+ // Empty streamId is an intentional Agent contract: stop every
6819
+ // active/quarantined capture, including a native helper that
6820
+ // outlived the Hub's last stream descriptor.
6821
+ stopAll: true,
6822
+ forceCommand: true
6823
+ });
6824
+ }
6825
+
6826
+ if (stopResult?.ok === false) {
6827
+ pause.phase = 'stop-unconfirmed';
6828
+ pause.stopError = safeString(stopResult.error, 500) || 'stream-stop-command-failed';
6829
+ emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
6830
+ pauseToken: pause.token,
6831
+ streamId: pause.streamId,
6832
+ stopError: pause.stopError
6833
+ });
6834
+ return buildLiveCapturePauseResult(pause, {
6835
+ ok: false,
6836
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
6837
+ });
6838
+ }
6839
+
6840
+ pause.stopCommandId = safeString(stopResult.commandId, 128);
6841
+ if (pause.stopCommandId
6842
+ && stopResult.alreadyStopped !== true
6843
+ && stopResult.synthetic !== true) {
6844
+ const acknowledgement = await waitForCommandResult(
6845
+ device,
6846
+ pause.stopCommandId,
6847
+ liveCaptureStopAckTimeoutMs);
6848
+ device = devices.get(String(deviceId || '')) || device;
6849
+ if (device.liveCapturePause?.token !== pause.token) {
6850
+ return buildLiveCapturePauseResult(pause, {
6851
+ ok: false,
6852
+ error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
6853
+ });
6854
+ }
6855
+ if (!acknowledgement.ok
6856
+ || acknowledgement.result?.stream !== true
6857
+ || acknowledgement.result?.stopped !== true) {
6858
+ pause.phase = 'stop-unconfirmed';
6859
+ pause.stopError = safeString(
6860
+ acknowledgement.error
6861
+ || acknowledgement.result?.error
6862
+ || 'stream-stop-not-confirmed',
6863
+ 500);
6864
+ emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
6865
+ pauseToken: pause.token,
6866
+ streamId: pause.streamId,
6867
+ stopCommandId: pause.stopCommandId,
6868
+ stopError: pause.stopError
6869
+ });
6870
+ return buildLiveCapturePauseResult(pause, {
6871
+ ok: false,
6872
+ error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
6873
+ });
6874
+ }
6875
+ }
6876
+
6877
+ pause.phase = 'paused';
6878
+ pause.captureStopConfirmed = true;
6879
+ pause.captureStopConfirmedAt = new Date().toISOString();
6880
+ pause.stopError = '';
6881
+ deactivateDeviceLiveStreams(device, pause.reason, pause.captureStopConfirmedAt);
6882
+ emitRemoteEvent('RemoteLiveCapturePaused', device, {
6883
+ pauseToken: pause.token,
6884
+ pausedAt: pause.pausedAt,
6885
+ streamId: pause.streamId,
6886
+ streamPurpose: pause.streamPurpose,
6887
+ monitorIndex: pause.monitorIndex,
6888
+ stopCommandId: pause.stopCommandId,
6889
+ captureStopConfirmedAt: pause.captureStopConfirmedAt
6890
+ });
6891
+ return buildLiveCapturePauseResult(pause, {
6892
+ ok: true,
6893
+ alreadyStopped: stopResult.alreadyStopped === true
6894
+ });
6895
+ }
6896
+
6897
+ async function pauseLiveCapture(deviceId, options = {}) {
6898
+ const device = devices.get(String(deviceId || ''));
6899
+ if (!device) {
6900
+ return { ok: false, paused: false, error: 'device-not-found' };
6901
+ }
6902
+ const denied = policyError(device);
6903
+ if (denied) return { ok: false, paused: false, error: denied };
6904
+
6905
+ let pause = device.liveCapturePause;
6906
+ if (pause?.token && pause.captureStopConfirmed === true) {
6907
+ return buildLiveCapturePauseResult(pause, {
6908
+ ok: true,
6909
+ alreadyPaused: true
6910
+ });
6911
+ }
6912
+ if (pause?.stopPromise) {
6913
+ return pause.stopPromise;
6914
+ }
6915
+
6916
+ if (!pause?.token) {
6917
+ const activeLiveStream = device.activeLiveStream?.active === true
6918
+ ? device.activeLiveStream
6919
+ : null;
6920
+ pause = {
6921
+ token: crypto.randomUUID(),
6922
+ pausedAt: new Date().toISOString(),
6923
+ reason: safeString(options.reason, 128) || 'user-video-pause',
6924
+ streamId: safeString(activeLiveStream?.streamId, 128),
6925
+ streamPurpose: safeString(activeLiveStream?.streamPurpose, 24),
6926
+ monitorIndex: Math.max(0, Math.floor(Number(activeLiveStream?.monitorIndex) || 0)),
6927
+ phase: 'pausing',
6928
+ captureStopConfirmed: false,
6929
+ captureStopConfirmedAt: '',
6930
+ stopCommandId: '',
6931
+ stopError: '',
6932
+ stopAttemptCount: 0
6933
+ };
6934
+ // Install the authoritative gate before asking the Agent to stop.
6935
+ // A late subscriber or watchdog therefore cannot reacquire FFmpeg
6936
+ // in the pause transition window.
6937
+ device.liveCapturePause = pause;
6938
+ }
6939
+
6940
+ const operation = confirmLiveCaptureStopped(deviceId, pause);
6941
+ pause.stopPromise = operation;
6942
+ try {
6943
+ return await operation;
6944
+ } finally {
6945
+ if (pause.stopPromise === operation) {
6946
+ delete pause.stopPromise;
6947
+ }
6948
+ }
6949
+ }
6950
+
6951
+ function resumeLiveCapture(deviceId, options = {}) {
6952
+ const device = devices.get(String(deviceId || ''));
6953
+ const denied = policyError(device);
6954
+ if (denied) return { ok: false, error: denied };
6955
+ const pause = device?.liveCapturePause;
6956
+ if (!pause?.token) {
6957
+ return { ok: true, paused: false, alreadyResumed: true };
6958
+ }
6959
+ if (pause.phase === 'pausing') {
6960
+ return {
6961
+ ok: false,
6962
+ paused: true,
6963
+ error: 'LIVE_CAPTURE_STOP_IN_PROGRESS',
6964
+ pauseToken: pause.token
6965
+ };
6966
+ }
6967
+ const pauseToken = safeString(options.pauseToken, 128);
6968
+ if (!pauseToken || pauseToken !== pause.token) {
6969
+ return {
6970
+ ok: false,
6971
+ error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
6972
+ };
6973
+ }
6974
+
6975
+ device.liveCapturePause = null;
6976
+ emitRemoteEvent('RemoteLiveCaptureResumed', device, {
6977
+ pauseToken,
6978
+ resumedAt: new Date().toISOString(),
6979
+ streamId: pause.streamId,
6980
+ streamPurpose: pause.streamPurpose,
6981
+ monitorIndex: pause.monitorIndex
6982
+ });
6983
+ return {
6984
+ ok: true,
6985
+ paused: false,
6986
+ resumed: true,
6987
+ streamId: pause.streamId,
6988
+ streamPurpose: pause.streamPurpose,
6989
+ monitorIndex: pause.monitorIndex
6990
+ };
6991
+ }
6405
6992
 
6406
6993
  function startAudioStream(deviceId, options = {}) {
6407
6994
  const device = devices.get(String(deviceId || ''));
@@ -6563,15 +7150,18 @@ export function createRemoteHub(options = {}) {
6563
7150
  sendCommand,
6564
7151
  sendLegacyClientUpdate,
6565
7152
  sendInputControl,
7153
+ releaseInputOwner,
6566
7154
  notifyAgentProgress,
6567
7155
  requestAgentTask,
6568
7156
  requestAgentTaskBatch,
6569
7157
  setHostTarget,
6570
7158
  rotatePairingPin,
6571
- requestThumbnail,
6572
- startLiveStream,
6573
- stopLiveStream,
6574
- startAudioStream,
7159
+ requestThumbnail,
7160
+ startLiveStream,
7161
+ stopLiveStream,
7162
+ pauseLiveCapture,
7163
+ resumeLiveCapture,
7164
+ startAudioStream,
6575
7165
  stopAudioStream,
6576
7166
  getDeviceLiveFrame,
6577
7167
  getDeviceThumbnail,