livedesk 0.1.436 → 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,
@@ -1456,7 +1485,9 @@ class RemoteHubWebSocketAgentSocket {
1456
1485
  }
1457
1486
 
1458
1487
  export function createRemoteHub(options = {}) {
1459
- const env = options.env || process.env;
1488
+ const env = options.env || process.env;
1489
+ const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
1490
+ && isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
1460
1491
  const logEvent = options.logEvent || (() => {});
1461
1492
  const logWarn = options.logWarn || (() => {});
1462
1493
  const emitEvent = options.emitEvent || (() => {});
@@ -1508,6 +1539,11 @@ export function createRemoteHub(options = {}) {
1508
1539
  250,
1509
1540
  30000,
1510
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);
1511
1547
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1512
1548
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1513
1549
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1536,9 +1572,10 @@ export function createRemoteHub(options = {}) {
1536
1572
  const sockets = new Map();
1537
1573
  const inputSockets = new Map();
1538
1574
  const frameSockets = new Map();
1539
- const audioSockets = new Map();
1540
- const fileSockets = new Map();
1541
- const allSockets = new Set();
1575
+ const audioSockets = new Map();
1576
+ const fileSockets = new Map();
1577
+ const allSockets = new Set();
1578
+ const pendingCommandResultWaiters = new Map();
1542
1579
  const duplicateDeviceLogAt = new Map();
1543
1580
  let server = null;
1544
1581
  let started = false;
@@ -2046,15 +2083,109 @@ export function createRemoteHub(options = {}) {
2046
2083
  });
2047
2084
  }
2048
2085
 
2049
- function emitRemoteEvent(type, device = null, extra = {}) {
2050
- emitEvent(type, {
2051
- ...extra,
2052
- remoteHub: getStatus({ includeSecrets: false }),
2053
- device: serializeDevice(device)
2054
- });
2055
- }
2056
-
2057
- 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 = {}) {
2058
2189
  const now = new Date().toISOString();
2059
2190
  const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
2060
2191
  const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
@@ -2175,6 +2306,7 @@ export function createRemoteHub(options = {}) {
2175
2306
  existing.lastDisconnectReason = 'replaced-by-new-session';
2176
2307
  failAllPendingTasks(existing, 'replaced-by-new-session');
2177
2308
  failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2309
+ failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
2178
2310
  writeJsonLine(existing.socket, {
2179
2311
  type: 'disconnect',
2180
2312
  reason: 'replaced-by-new-session',
@@ -2714,9 +2846,11 @@ export function createRemoteHub(options = {}) {
2714
2846
  : getSupportedRemoteFrameModeProfiles();
2715
2847
  }
2716
2848
 
2717
- const device = {
2718
- socket,
2719
- inputSocket: null,
2849
+ const device = {
2850
+ socket,
2851
+ inputSocket: null,
2852
+ inputOwnerConnectionId: '',
2853
+ inputOwnerBindingKey: '',
2720
2854
  frameSocket: null,
2721
2855
  audioSocket: null,
2722
2856
  fileSocket: null,
@@ -2757,9 +2891,13 @@ export function createRemoteHub(options = {}) {
2757
2891
  recentFramePayloads: {
2758
2892
  thumbnail: [],
2759
2893
  live: []
2760
- },
2761
- activeLiveStream: null,
2762
- 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,
2763
2901
  latestAudioFrame: null,
2764
2902
  latestAudioStatus: null,
2765
2903
  udp: {
@@ -2948,10 +3086,11 @@ export function createRemoteHub(options = {}) {
2948
3086
  closeInputSocket(device, reason);
2949
3087
  closeFrameSocket(device, reason);
2950
3088
  device.disconnectedAt = new Date().toISOString();
2951
- device.lastDisconnectReason = reason;
3089
+ device.lastDisconnectReason = reason;
2952
3090
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2953
3091
  failAllPendingTasks(device, 'device-disconnected');
2954
3092
  failAllPendingInputFallbacks(device, 'device-disconnected');
3093
+ failPendingCommandResultWaiters(device, 'device-disconnected');
2955
3094
  logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2956
3095
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2957
3096
  }
@@ -4042,6 +4181,11 @@ export function createRemoteHub(options = {}) {
4042
4181
  && frameCaptureGeneration === activeCaptureGeneration));
4043
4182
  const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
4044
4183
  const captureTimingAvailable = hasFrameCaptureTiming(message);
4184
+ const videoTransport = transport === 'udp-p2p'
4185
+ ? 'p2p-udp'
4186
+ : transport === 'ws-binary'
4187
+ ? 'direct-ws'
4188
+ : 'direct-tcp';
4045
4189
  device.latestLiveFrame = {
4046
4190
  streamId,
4047
4191
  frameSeq,
@@ -4078,9 +4222,10 @@ export function createRemoteHub(options = {}) {
4078
4222
  agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
4079
4223
  agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
4080
4224
  agentFrameAgeMs: Number.isFinite(Number(message.agentFrameAgeMs)) ? Number(message.agentFrameAgeMs) : 0,
4081
- droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4082
- hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4083
- 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),
4084
4229
  platformProfile: safeString(message.platformProfile, 80),
4085
4230
  monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
4086
4231
  monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
@@ -4094,12 +4239,12 @@ export function createRemoteHub(options = {}) {
4094
4239
  captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
4095
4240
  captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
4096
4241
  slowFrameCount: Number.isFinite(Number(message.slowFrameCount)) ? Number(message.slowFrameCount) : 0,
4097
- captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4098
- capturedAt,
4099
- receivedAt: device.lastSeenAt,
4100
- byteLength,
4101
- transport,
4102
- contentHash,
4242
+ captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4243
+ capturedAt,
4244
+ receivedAt: device.lastSeenAt,
4245
+ byteLength,
4246
+ transport,
4247
+ contentHash,
4103
4248
  captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
4104
4249
  captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
4105
4250
  convertMs: readFrameStageMs(message, 'convertMs'),
@@ -4108,10 +4253,20 @@ export function createRemoteHub(options = {}) {
4108
4253
  captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
4109
4254
  deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
4110
4255
  deltaTileCount: Number.isFinite(Number(message.deltaTileCount)) ? Number(message.deltaTileCount) : 0,
4111
- deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
4112
- deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
4113
- deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
4114
- 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,
4115
4270
  payload,
4116
4271
  accessToken: createFrameAccessToken()
4117
4272
  };
@@ -4274,7 +4429,7 @@ export function createRemoteHub(options = {}) {
4274
4429
  return true;
4275
4430
  }
4276
4431
 
4277
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
4432
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
4278
4433
  if (!state.authenticated || !state.device) {
4279
4434
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
4280
4435
  socket.destroy();
@@ -4298,28 +4453,29 @@ export function createRemoteHub(options = {}) {
4298
4453
  }
4299
4454
 
4300
4455
  const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
4301
- if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
4302
- applyThumbnailFrame(device, header, framePayload, 'binary');
4303
- return;
4304
- }
4305
-
4306
- if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4307
- applyLiveFrame(device, {
4308
- ...header,
4309
- hubIngressDropped: Number(state.binaryQueueDrops || 0)
4310
- }, framePayload, 'binary');
4311
- return;
4312
- }
4313
-
4314
- if (frameKind === 'audio' || header.type === 'audio.binary') {
4315
- applyAudioFrame(device, header, framePayload, 'binary');
4316
- return;
4317
- }
4318
-
4319
- if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
4320
- applyAudioStatus(device, header, framePayload, 'binary');
4321
- return;
4322
- }
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
+ }
4323
4479
 
4324
4480
  emitRemoteEvent('RemoteAgentMessageIgnored', device, {
4325
4481
  messageType: safeString(header.type, 80),
@@ -4497,9 +4653,14 @@ export function createRemoteHub(options = {}) {
4497
4653
  state.device = device;
4498
4654
  state.inputOnly = true;
4499
4655
  return;
4500
- }
4501
- if (channel === 'frame') {
4502
- const device = attachFrameSocket(socket, message);
4656
+ }
4657
+ if (channel === 'frame') {
4658
+ if (rejectFrameChannelForTest) {
4659
+ writeJsonLine(socket, { type: 'error', error: 'test-frame-channel-unavailable' });
4660
+ socket.end();
4661
+ return;
4662
+ }
4663
+ const device = attachFrameSocket(socket, message);
4503
4664
  if (!device) {
4504
4665
  return;
4505
4666
  }
@@ -4599,6 +4760,7 @@ export function createRemoteHub(options = {}) {
4599
4760
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4600
4761
  500);
4601
4762
  const result = message.result ?? null;
4763
+ settlePendingCommandResult(device, message);
4602
4764
  handleInputFallbackCommandResult(device, message);
4603
4765
  if (error || result?.ok === false || result?.status === 'failed') {
4604
4766
  failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
@@ -4652,10 +4814,11 @@ export function createRemoteHub(options = {}) {
4652
4814
  socket.setNoDelay(true);
4653
4815
  socket.setKeepAlive(true, heartbeatMs);
4654
4816
 
4655
- const state = {
4656
- authenticated: false,
4657
- device: null,
4658
- inputOnly: false,
4817
+ const state = {
4818
+ authenticated: false,
4819
+ device: null,
4820
+ binaryTransport: 'ws-binary',
4821
+ inputOnly: false,
4659
4822
  frameOnly: false,
4660
4823
  audioOnly: false,
4661
4824
  fileOnly: false,
@@ -4809,10 +4972,11 @@ export function createRemoteHub(options = {}) {
4809
4972
  socket.setNoDelay(true);
4810
4973
  socket.setKeepAlive(true, heartbeatMs);
4811
4974
 
4812
- const state = {
4813
- authenticated: false,
4814
- device: null,
4815
- inputOnly: false,
4975
+ const state = {
4976
+ authenticated: false,
4977
+ device: null,
4978
+ binaryTransport: 'binary',
4979
+ inputOnly: false,
4816
4980
  frameOnly: false,
4817
4981
  audioOnly: false,
4818
4982
  fileOnly: false,
@@ -5046,6 +5210,7 @@ export function createRemoteHub(options = {}) {
5046
5210
  for (const device of devices.values()) {
5047
5211
  failAllPendingTasks(device, 'hub-shutdown');
5048
5212
  failAllPendingInputFallbacks(device, 'hub-shutdown');
5213
+ failPendingCommandResultWaiters(device, 'hub-shutdown');
5049
5214
  if (device.socket && !device.socket.destroyed) {
5050
5215
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
5051
5216
  device.socket.destroy();
@@ -5286,10 +5451,58 @@ export function createRemoteHub(options = {}) {
5286
5451
  return { ok: false, error: 'device-not-found' };
5287
5452
  }
5288
5453
 
5289
- if (!device.connected) {
5290
- return { ok: false, error: 'device-not-connected' };
5291
- }
5292
-
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
+
5293
5506
  const denied = policyError(device, 'allowControl');
5294
5507
  if (denied) return { ok: false, error: denied };
5295
5508
 
@@ -5301,10 +5514,6 @@ export function createRemoteHub(options = {}) {
5301
5514
  return { ok: false, error: 'device-input-control-unavailable' };
5302
5515
  }
5303
5516
 
5304
- const normalized = normalizeRemoteInputEvent(input);
5305
- if (!normalized.type) {
5306
- return { ok: false, error: 'missing-input-type' };
5307
- }
5308
5517
  const controlStream = getActiveControlStream(device);
5309
5518
  if (!controlStream
5310
5519
  || !liveStreamHasCurrentFrame(controlStream)
@@ -5350,15 +5559,62 @@ export function createRemoteHub(options = {}) {
5350
5559
 
5351
5560
  const inputSocket = device.inputSocket;
5352
5561
  if (inputSocket && !inputSocket.destroyed) {
5353
- 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, {
5354
5606
  type: 'input.control',
5355
5607
  payload: {
5356
5608
  ...normalized,
5357
5609
  hubForwardedAtEpochMs: Date.now(),
5358
5610
  issuedAt: normalized.issuedAt || new Date().toISOString()
5359
5611
  }
5360
- });
5361
- if (sent) {
5612
+ });
5613
+ if (sent) {
5614
+ if (normalized.hubConnectionId) {
5615
+ device.inputOwnerConnectionId = normalized.hubConnectionId;
5616
+ }
5617
+ device.inputOwnerBindingKey = activeInputBindingKey;
5362
5618
  device.counters.commandsSent += 1;
5363
5619
  device.inputLastSeenAt = new Date().toISOString();
5364
5620
  return {
@@ -5371,9 +5627,16 @@ export function createRemoteHub(options = {}) {
5371
5627
  };
5372
5628
  }
5373
5629
 
5374
- detachInputSocket(inputSocket, 'input-socket-write-failed');
5375
- }
5376
-
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
+
5377
5640
  const hubForwardedAtEpochMs = Date.now();
5378
5641
  const fallback = sendCommand(deviceId, {
5379
5642
  command: 'input.control',
@@ -5417,6 +5680,49 @@ export function createRemoteHub(options = {}) {
5417
5680
  };
5418
5681
  }
5419
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
+
5420
5726
  function notifyAgentProgress(deviceIds, progress = {}) {
5421
5727
  const targets = [...new Set((deviceIds || [])
5422
5728
  .map(deviceId => safeString(deviceId, 128))
@@ -5822,24 +6128,42 @@ export function createRemoteHub(options = {}) {
5822
6128
  streamId: stream.streamId,
5823
6129
  commandId: key,
5824
6130
  error: safeString(error, 500) || 'stream-start-failed'
5825
- });
5826
- return true;
5827
- }
5828
- return false;
5829
- }
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
+ }
5830
6152
 
5831
6153
  function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5832
6154
  const streams = ensureDeviceLiveStreams(device);
5833
6155
  for (const stream of streams.values()) {
5834
6156
  clearPendingLiveStreamDescriptor(device, stream);
5835
6157
  stream.active = false;
6158
+ stream.open = false;
5836
6159
  stream.stoppedAt = stoppedAt;
5837
6160
  stream.stopReason = reason;
5838
- }
5839
- if (device?.activeLiveStream) {
5840
- device.activeLiveStream.active = false;
5841
- device.activeLiveStream.stoppedAt = stoppedAt;
5842
- 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;
5843
6167
  }
5844
6168
  }
5845
6169
 
@@ -5861,6 +6185,48 @@ export function createRemoteHub(options = {}) {
5861
6185
  safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5862
6186
  }
5863
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
+
5864
6230
  function nextCaptureGeneration(device) {
5865
6231
  const current = Number(device?.captureGenerationCounter || 0);
5866
6232
  const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
@@ -5997,11 +6363,19 @@ export function createRemoteHub(options = {}) {
5997
6363
  && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
5998
6364
  }
5999
6365
 
6000
- function startLiveStream(deviceId, options = {}) {
6001
- const device = devices.get(String(deviceId || ''));
6002
- const denied = policyError(device);
6003
- if (denied) return { ok: false, error: denied };
6004
- 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) {
6005
6379
  if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
6006
6380
  return { ok: false, error: 'device-live-stream-unavailable' };
6007
6381
  }
@@ -6012,6 +6386,8 @@ export function createRemoteHub(options = {}) {
6012
6386
  if (modePolicyError) return { ok: false, error: modePolicyError };
6013
6387
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6014
6388
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6389
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6390
+ if (!captureClaim.ok) return captureClaim;
6015
6391
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6016
6392
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6017
6393
  if (activeLiveStream
@@ -6111,6 +6487,8 @@ export function createRemoteHub(options = {}) {
6111
6487
  if (modePolicyError) return { ok: false, error: modePolicyError };
6112
6488
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
6113
6489
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
6490
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
6491
+ if (!captureClaim.ok) return captureClaim;
6114
6492
  const activeLiveStream = getDeviceLiveStream(device, streamId);
6115
6493
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
6116
6494
  if (pendingDescriptor?.commandId) {
@@ -6328,14 +6706,16 @@ export function createRemoteHub(options = {}) {
6328
6706
  };
6329
6707
  }
6330
6708
 
6331
- function stopLiveStream(deviceId, options = {}) {
6709
+ function stopLiveStream(deviceId, options = {}) {
6332
6710
  const device = devices.get(String(deviceId || ''));
6333
- const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
6334
- const requestedStreamId = safeString(options.streamId, 128);
6335
- const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
6336
- 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 || '';
6337
6717
  const streamState = getDeviceLiveStream(device, streamId);
6338
- if (streamState && streamState.active !== true) {
6718
+ if (streamState && streamState.active !== true && options.forceCommand !== true) {
6339
6719
  clearPendingLiveStreamDescriptor(device, streamState);
6340
6720
  return {
6341
6721
  ok: true,
@@ -6352,9 +6732,10 @@ export function createRemoteHub(options = {}) {
6352
6732
  device.counters.commandsSent += 1;
6353
6733
  if (streamState) {
6354
6734
  streamState.active = false;
6735
+ streamState.open = false;
6355
6736
  streamState.stoppedAt = new Date().toISOString();
6356
- streamState.stopReason = 'manager-request';
6357
- }
6737
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6738
+ }
6358
6739
  device.counters.liveStreamsStopped += 1;
6359
6740
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6360
6741
  streamId,
@@ -6384,17 +6765,230 @@ export function createRemoteHub(options = {}) {
6384
6765
 
6385
6766
  if (streamState) {
6386
6767
  streamState.active = false;
6768
+ streamState.open = false;
6387
6769
  streamState.stoppedAt = new Date().toISOString();
6388
- streamState.stopReason = 'manager-request';
6389
- }
6770
+ streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
6771
+ }
6390
6772
  device.counters.liveStreamsStopped += 1;
6391
6773
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
6392
6774
  streamId,
6393
6775
  commandId
6394
6776
  });
6395
6777
 
6396
- return { ok: true, commandId, streamId };
6397
- }
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
+ }
6398
6992
 
6399
6993
  function startAudioStream(deviceId, options = {}) {
6400
6994
  const device = devices.get(String(deviceId || ''));
@@ -6556,15 +7150,18 @@ export function createRemoteHub(options = {}) {
6556
7150
  sendCommand,
6557
7151
  sendLegacyClientUpdate,
6558
7152
  sendInputControl,
7153
+ releaseInputOwner,
6559
7154
  notifyAgentProgress,
6560
7155
  requestAgentTask,
6561
7156
  requestAgentTaskBatch,
6562
7157
  setHostTarget,
6563
7158
  rotatePairingPin,
6564
- requestThumbnail,
6565
- startLiveStream,
6566
- stopLiveStream,
6567
- startAudioStream,
7159
+ requestThumbnail,
7160
+ startLiveStream,
7161
+ stopLiveStream,
7162
+ pauseLiveCapture,
7163
+ resumeLiveCapture,
7164
+ startAudioStream,
6568
7165
  stopAudioStream,
6569
7166
  getDeviceLiveFrame,
6570
7167
  getDeviceThumbnail,