livedesk 0.1.0 → 0.1.2

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.
@@ -0,0 +1,3949 @@
1
+ import net from 'net';
2
+ import os from 'os';
3
+ import crypto from 'crypto';
4
+
5
+ const DEFAULT_REMOTE_HUB_PORT = 5197;
6
+ const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
7
+ const DEFAULT_HEARTBEAT_MS = 5000;
8
+ const DEFAULT_AGENT_TASK_TIMEOUT_MS = 120000;
9
+ const MAX_LINE_CHARS = 4 * 1024 * 1024;
10
+ const MAX_THUMBNAIL_BASE64_CHARS = 3 * 1024 * 1024;
11
+ const MAX_STREAM_BASE64_CHARS = 3 * 1024 * 1024;
12
+ const MAX_THUMBNAIL_BINARY_BYTES = Math.floor(MAX_THUMBNAIL_BASE64_CHARS * 3 / 4);
13
+ const MAX_STREAM_BINARY_BYTES = Math.floor(MAX_STREAM_BASE64_CHARS * 3 / 4);
14
+ const MAX_AUDIO_BASE64_CHARS = 1024 * 1024;
15
+ const MAX_AUDIO_BINARY_BYTES = Math.floor(MAX_AUDIO_BASE64_CHARS * 3 / 4);
16
+ const MAX_AGENT_TASK_CHARS = 4000;
17
+ const MAX_AGENT_TASK_RESULT_CHARS = 3000;
18
+ const RECENT_TASK_LIMIT = 12;
19
+ const RECENT_TASK_BATCH_LIMIT = 16;
20
+ const RECENT_FRAME_CACHE_TTL_MS = 12000;
21
+ const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 4;
22
+ const RECENT_LIVE_FRAME_CACHE_LIMIT = 80;
23
+ const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 3 * 1024 * 1024;
24
+ const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 1024 * 1024;
25
+ const REMOTE_PROTOCOL_VERSION = 2;
26
+ const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
27
+ const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
28
+ const REMOTE_FRAME_PROTOCOL_VERSION = 2;
29
+ const REMOTE_FRAME_HANDSHAKE = 'smartview-request-open-frame';
30
+ const DEFAULT_REMOTE_FRAME_MODE = 'remote-fast';
31
+ const REMOTE_FRAME_MODE_PROFILES = Object.freeze({
32
+ thumbnail: Object.freeze({
33
+ mode: 'thumbnail',
34
+ label: 'Thumbnail PNG/JPEG Binary',
35
+ transport: 'ws-binary',
36
+ encoding: 'image',
37
+ codec: 'image',
38
+ compression: 'image/*',
39
+ profile: 'thumbnail-binary-v1',
40
+ modeVersion: 1,
41
+ implemented: true
42
+ }),
43
+ 'remote-fast': Object.freeze({
44
+ mode: 'remote-fast',
45
+ label: 'RemoteFast JPEG Binary',
46
+ transport: 'ws-binary',
47
+ encoding: 'image',
48
+ codec: 'jpeg',
49
+ compression: 'image/jpeg',
50
+ profile: 'jpeg-binary-v1',
51
+ modeVersion: 1,
52
+ implemented: true
53
+ }),
54
+ 'remote-quality': Object.freeze({
55
+ mode: 'remote-quality',
56
+ label: 'RemoteQuality JPEG Binary',
57
+ transport: 'ws-binary',
58
+ encoding: 'image',
59
+ codec: 'jpeg',
60
+ compression: 'image/jpeg',
61
+ profile: 'jpeg-quality-binary-v1',
62
+ modeVersion: 1,
63
+ implemented: true
64
+ }),
65
+ 'video-codec': Object.freeze({
66
+ mode: 'video-codec',
67
+ label: 'Video Codec Stream',
68
+ transport: 'ws-binary',
69
+ encoding: 'video',
70
+ codec: 'video',
71
+ compression: 'video/*',
72
+ profile: 'future-video-codec',
73
+ modeVersion: 0,
74
+ implemented: false
75
+ })
76
+ });
77
+ const REMOTE_FRAME_MODE_ALIASES = new Map([
78
+ ['fast', 'remote-fast'],
79
+ ['remotefast', 'remote-fast'],
80
+ ['remote_fast', 'remote-fast'],
81
+ ['remote-fast', 'remote-fast'],
82
+ ['quality', 'remote-quality'],
83
+ ['remotequality', 'remote-quality'],
84
+ ['remote_quality', 'remote-quality'],
85
+ ['remote-quality', 'remote-quality'],
86
+ ['thumb', 'thumbnail'],
87
+ ['thumbnail', 'thumbnail'],
88
+ ['video', 'video-codec'],
89
+ ['codec', 'video-codec'],
90
+ ['video-codec', 'video-codec']
91
+ ]);
92
+ const MAX_SYNTHETIC_DEVICES = 1000;
93
+ const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
94
+ const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
95
+ const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
96
+ const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
97
+ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
98
+ const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
99
+ const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
100
+ const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
101
+ const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
102
+ const PUBLIC_IPV4_PROVIDERS = Object.freeze([
103
+ 'https://api.ipify.org?format=json',
104
+ 'https://checkip.amazonaws.com/',
105
+ 'https://ifconfig.me/ip'
106
+ ]);
107
+
108
+ function isEnabledValue(value, fallback = true) {
109
+ if (value === undefined || value === null || value === '') {
110
+ return fallback;
111
+ }
112
+
113
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
114
+ }
115
+
116
+ function isDisabledValue(value) {
117
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
118
+ }
119
+
120
+ function clampNumber(value, min, max, fallback) {
121
+ const number = Number(value);
122
+ if (!Number.isFinite(number)) {
123
+ return fallback;
124
+ }
125
+
126
+ return Math.max(min, Math.min(max, Math.floor(number)));
127
+ }
128
+
129
+ function normalizePort(value) {
130
+ return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
131
+ }
132
+
133
+ function safeString(value, maxLength = 200) {
134
+ return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
135
+ }
136
+
137
+ function normalizeRemoteFrameMode(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
138
+ const requested = safeString(value, 80).toLowerCase();
139
+ const fallbackMode = REMOTE_FRAME_MODE_PROFILES[fallback] ? fallback : DEFAULT_REMOTE_FRAME_MODE;
140
+ if (!requested) {
141
+ return fallbackMode;
142
+ }
143
+
144
+ const normalized = REMOTE_FRAME_MODE_ALIASES.get(requested) || requested;
145
+ const profile = REMOTE_FRAME_MODE_PROFILES[normalized];
146
+ if (!profile || profile.implemented !== true) {
147
+ return fallbackMode;
148
+ }
149
+
150
+ return normalized;
151
+ }
152
+
153
+ function serializeRemoteFrameModeProfile(profile) {
154
+ return {
155
+ mode: profile.mode,
156
+ label: profile.label,
157
+ transport: profile.transport,
158
+ encoding: profile.encoding,
159
+ codec: profile.codec,
160
+ compression: profile.compression,
161
+ profile: profile.profile,
162
+ modeVersion: profile.modeVersion,
163
+ implemented: profile.implemented === true
164
+ };
165
+ }
166
+
167
+ function getRemoteFrameModeProfile(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
168
+ const mode = normalizeRemoteFrameMode(value, fallback);
169
+ return serializeRemoteFrameModeProfile(REMOTE_FRAME_MODE_PROFILES[mode]);
170
+ }
171
+
172
+ function getSupportedRemoteFrameModeProfiles() {
173
+ return Object.values(REMOTE_FRAME_MODE_PROFILES)
174
+ .filter(profile => profile.implemented === true)
175
+ .map(serializeRemoteFrameModeProfile);
176
+ }
177
+
178
+ function buildRemoteFrameProtocolDescriptor() {
179
+ return {
180
+ protocol: REMOTE_FRAME_PROTOCOL,
181
+ version: REMOTE_FRAME_PROTOCOL_VERSION,
182
+ handshake: REMOTE_FRAME_HANDSHAKE,
183
+ defaultMode: DEFAULT_REMOTE_FRAME_MODE,
184
+ modes: getSupportedRemoteFrameModeProfiles()
185
+ };
186
+ }
187
+
188
+ function buildRemoteFrameTransferDescriptor(value, fallback = DEFAULT_REMOTE_FRAME_MODE) {
189
+ const profile = getRemoteFrameModeProfile(value, fallback);
190
+ return {
191
+ mode: profile.mode,
192
+ frameMode: profile.mode,
193
+ frameProfile: profile.profile,
194
+ encoding: profile.encoding,
195
+ codec: profile.codec,
196
+ compression: profile.compression,
197
+ transferProtocol: REMOTE_FRAME_PROTOCOL,
198
+ transferProtocolVersion: REMOTE_FRAME_PROTOCOL_VERSION,
199
+ handshake: REMOTE_FRAME_HANDSHAKE
200
+ };
201
+ }
202
+
203
+ function buildRemoteFrameTransferDescriptorFromMessage(message = {}, fallback = DEFAULT_REMOTE_FRAME_MODE) {
204
+ const descriptor = buildRemoteFrameTransferDescriptor(message.frameMode || message.mode, fallback);
205
+ return {
206
+ ...descriptor,
207
+ frameProfile: safeString(message.frameProfile, 80) || descriptor.frameProfile,
208
+ encoding: safeString(message.encoding, 40) || descriptor.encoding,
209
+ codec: safeString(message.codec, 40) || descriptor.codec,
210
+ compression: safeString(message.compression || message.mimeType || message.format, 80) || descriptor.compression,
211
+ transferProtocol: safeString(message.transferProtocol, 80) || descriptor.transferProtocol,
212
+ transferProtocolVersion: Number.isFinite(Number(message.transferProtocolVersion))
213
+ ? Math.max(1, Math.floor(Number(message.transferProtocolVersion)))
214
+ : descriptor.transferProtocolVersion,
215
+ handshake: safeString(message.handshake, 80) || descriptor.handshake
216
+ };
217
+ }
218
+
219
+ function normalizeIncomingFrameModeProfile(value) {
220
+ if (!value) {
221
+ return null;
222
+ }
223
+
224
+ if (typeof value === 'string') {
225
+ return getRemoteFrameModeProfile(value);
226
+ }
227
+
228
+ if (typeof value !== 'object') {
229
+ return null;
230
+ }
231
+
232
+ const profile = buildRemoteFrameTransferDescriptor(value.mode || value.frameMode || value.profile);
233
+ return {
234
+ mode: profile.mode,
235
+ label: safeString(value.label, 80) || profile.mode,
236
+ transport: safeString(value.transport, 40) || 'ws-binary',
237
+ encoding: safeString(value.encoding, 40) || profile.encoding,
238
+ codec: safeString(value.codec, 40) || profile.codec,
239
+ compression: safeString(value.compression, 80) || profile.compression,
240
+ profile: safeString(value.profile || value.frameProfile, 80) || profile.frameProfile,
241
+ modeVersion: Number.isFinite(Number(value.modeVersion)) ? Math.max(0, Math.floor(Number(value.modeVersion))) : 1,
242
+ implemented: value.implemented !== false
243
+ };
244
+ }
245
+
246
+ function isLoopbackHost(value) {
247
+ const host = String(value || '').trim().toLowerCase();
248
+ return host === 'localhost'
249
+ || host === '127.0.0.1'
250
+ || host === '::1'
251
+ || host === '[::1]';
252
+ }
253
+
254
+ function isWildcardHost(value) {
255
+ const host = String(value || '').trim().toLowerCase();
256
+ return host === '0.0.0.0'
257
+ || host === '::'
258
+ || host === '[::]'
259
+ || host === '';
260
+ }
261
+
262
+ function isPrivateIPv4(value) {
263
+ const parts = String(value || '').split('.').map(part => Number(part));
264
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
265
+ return false;
266
+ }
267
+
268
+ return parts[0] === 10
269
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
270
+ || (parts[0] === 192 && parts[1] === 168);
271
+ }
272
+
273
+ function isPublicIPv4(value) {
274
+ const parts = String(value || '').split('.').map(part => Number(part));
275
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {
276
+ return false;
277
+ }
278
+
279
+ if (parts[0] === 0 || parts[0] === 10 || parts[0] === 127 || parts[0] >= 224) {
280
+ return false;
281
+ }
282
+
283
+ if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) {
284
+ return false;
285
+ }
286
+
287
+ if (parts[0] === 169 && parts[1] === 254) {
288
+ return false;
289
+ }
290
+
291
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) {
292
+ return false;
293
+ }
294
+
295
+ if (parts[0] === 192 && (parts[1] === 0 || parts[1] === 168)) {
296
+ return false;
297
+ }
298
+
299
+ if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) {
300
+ return false;
301
+ }
302
+
303
+ return true;
304
+ }
305
+
306
+ function isPreferredPhysicalInterfaceName(value) {
307
+ const name = String(value || '').toLowerCase();
308
+ return /(^|[\s_\-()])((wi[\s_\-]?fi)|wifi|wireless|wlan|ethernet|lan|이더넷|en\d+|eth\d+)(?=$|[\s_\-()])/i.test(name);
309
+ }
310
+
311
+ function isVirtualInterfaceName(value) {
312
+ const name = String(value || '').toLowerCase();
313
+ return /virtual|vethernet|hyper-v|hyperv|vmware|virtualbox|vbox|docker|wsl|container|bluetooth|loopback|npcap|isatap|teredo/i.test(name);
314
+ }
315
+
316
+ function getIPv4LastOctet(value) {
317
+ const parts = String(value || '').split('.').map(part => Number(part));
318
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part))) {
319
+ return -1;
320
+ }
321
+
322
+ return parts[3];
323
+ }
324
+
325
+ function scoreReachableIPv4Candidate(candidate) {
326
+ const address = String(candidate?.address || '');
327
+ const name = String(candidate?.name || '');
328
+ let score = 0;
329
+
330
+ if (isPrivateIPv4(address)) {
331
+ score += 1000;
332
+ }
333
+
334
+ if (/^192\.168\./.test(address)) {
335
+ score += 90;
336
+ } else if (/^10\./.test(address)) {
337
+ score += 80;
338
+ } else if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(address)) {
339
+ score += 70;
340
+ }
341
+
342
+ if (isPreferredPhysicalInterfaceName(name)) {
343
+ score += 500;
344
+ }
345
+
346
+ if (isVirtualInterfaceName(name)) {
347
+ score -= 1200;
348
+ }
349
+
350
+ if (/^169\.254\./.test(address)) {
351
+ score -= 2000;
352
+ }
353
+
354
+ const lastOctet = getIPv4LastOctet(address);
355
+ if (lastOctet === 1) {
356
+ score -= 60;
357
+ } else if (lastOctet === 0 || lastOctet === 255) {
358
+ score -= 100;
359
+ }
360
+
361
+ if (candidate?.mac && candidate.mac === '00:00:00:00:00:00') {
362
+ score -= 50;
363
+ }
364
+
365
+ return score;
366
+ }
367
+
368
+ function getReachableIPv4CandidateEntries() {
369
+ const candidates = [];
370
+ const interfaces = os.networkInterfaces();
371
+ for (const [name, entries] of Object.entries(interfaces)) {
372
+ for (const entry of entries || []) {
373
+ if (entry?.family !== 'IPv4' || entry.internal) {
374
+ continue;
375
+ }
376
+
377
+ const address = safeString(entry.address, 64);
378
+ if (!address || address === '0.0.0.0') {
379
+ continue;
380
+ }
381
+
382
+ candidates.push({
383
+ address,
384
+ name: safeString(name, 128),
385
+ mac: safeString(entry.mac, 32),
386
+ score: 0,
387
+ virtual: isVirtualInterfaceName(name),
388
+ preferred: isPreferredPhysicalInterfaceName(name)
389
+ });
390
+ }
391
+ }
392
+
393
+ return candidates
394
+ .map(candidate => ({
395
+ ...candidate,
396
+ score: scoreReachableIPv4Candidate(candidate)
397
+ }))
398
+ .sort((left, right) => right.score - left.score || left.address.localeCompare(right.address))
399
+ .filter((candidate, index, all) => all.findIndex(item => item.address === candidate.address) === index);
400
+ }
401
+
402
+ function getReachableIPv4Candidates() {
403
+ return getReachableIPv4CandidateEntries().map(candidate => candidate.address);
404
+ }
405
+
406
+ function getReachableIPv4Address() {
407
+ const candidates = getReachableIPv4Candidates();
408
+ return candidates.find(isPrivateIPv4) || candidates[0] || '127.0.0.1';
409
+ }
410
+
411
+ function buildEndpoint(hostValue, portValue) {
412
+ const hostText = safeString(hostValue, 128);
413
+ const port = normalizePort(portValue);
414
+ if (!hostText || !port) {
415
+ return '';
416
+ }
417
+
418
+ const hostPart = hostText.includes(':') && !hostText.startsWith('[')
419
+ ? `[${hostText}]`
420
+ : hostText;
421
+ return `${hostPart}:${port}`;
422
+ }
423
+
424
+ function extractIPv4FromText(value) {
425
+ const text = safeString(value, 1000);
426
+ const match = text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
427
+ return match ? match[0] : '';
428
+ }
429
+
430
+ async function fetchTextWithTimeout(url, timeoutMs) {
431
+ const controller = new AbortController();
432
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
433
+ try {
434
+ const response = await fetch(url, {
435
+ signal: controller.signal,
436
+ headers: {
437
+ accept: 'application/json,text/plain;q=0.9,*/*;q=0.1',
438
+ 'user-agent': 'LiveDesk-Hub/0.1'
439
+ }
440
+ });
441
+ if (!response.ok) {
442
+ throw new Error(`${response.status} ${response.statusText}`);
443
+ }
444
+ return await response.text();
445
+ } finally {
446
+ clearTimeout(timeoutId);
447
+ }
448
+ }
449
+
450
+ function normalizeSlotNumber(value) {
451
+ const number = Number(value);
452
+ return Number.isInteger(number) && number >= 1 && number <= 999 ? number : 0;
453
+ }
454
+
455
+ function parseManagerEndpoint(value) {
456
+ const endpoint = safeString(value, 256);
457
+ const match = endpoint.match(/^(\[[^\]]+\]|[^:\s]+):(\d{1,5})$/);
458
+ if (!match) {
459
+ return null;
460
+ }
461
+
462
+ const host = match[1].replace(/^\[|\]$/g, '');
463
+ const port = Number(match[2]);
464
+ if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
465
+ return null;
466
+ }
467
+
468
+ return { endpoint, host, port };
469
+ }
470
+
471
+ function getAccountRouteEndpointReason(endpoint, context = {}) {
472
+ const parsed = parseManagerEndpoint(endpoint);
473
+ if (!parsed) {
474
+ return 'invalid-manager-endpoint';
475
+ }
476
+
477
+ if (isLoopbackHost(parsed.host)) {
478
+ return context.wildcardWithoutReachableAddress ? 'no-reachable-ipv4' : 'loopback-endpoint';
479
+ }
480
+
481
+ if (isWildcardHost(parsed.host)) {
482
+ return 'wildcard-endpoint';
483
+ }
484
+
485
+ if (/^169\.254\./.test(parsed.host)) {
486
+ return 'link-local-endpoint';
487
+ }
488
+
489
+ return 'ok';
490
+ }
491
+
492
+ function safeText(value, maxLength = 1000) {
493
+ return String(value ?? '').replace(/\0/g, '').trim().slice(0, maxLength);
494
+ }
495
+
496
+ function normalizeApprovalLevel(value) {
497
+ const level = safeString(value, 80).toLowerCase();
498
+ return level === 'ai-assist' ? 'ai-assist' : 'task-only';
499
+ }
500
+
501
+ function normalizeRemoteInputEvent(value = {}) {
502
+ const input = value && typeof value === 'object' ? value : {};
503
+ const type = safeString(input.type || input.Type, 48);
504
+ const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
505
+ const normalizedY = Number(input.normalizedY ?? input.NormalizedY);
506
+ const deltaX = Number(input.deltaX ?? input.DeltaX);
507
+ const deltaY = Number(input.deltaY ?? input.DeltaY);
508
+ return {
509
+ type,
510
+ normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
511
+ normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
512
+ button: safeString(input.button || input.Button, 24),
513
+ deltaX: Number.isFinite(deltaX) ? Math.max(-4096, Math.min(4096, deltaX)) : 0,
514
+ deltaY: Number.isFinite(deltaY) ? Math.max(-4096, Math.min(4096, deltaY)) : 0,
515
+ key: safeString(input.key || input.Key, 80),
516
+ code: safeString(input.code || input.Code, 80),
517
+ text: safeText(input.text || input.Text, 256),
518
+ repeat: input.repeat === true || input.Repeat === true,
519
+ controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
520
+ issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
521
+ };
522
+ }
523
+
524
+ function readCapabilityFlag(capabilities, key) {
525
+ const value = capabilities?.[key];
526
+ if (typeof value === 'boolean') {
527
+ return value;
528
+ }
529
+ if (typeof value === 'number') {
530
+ return value !== 0;
531
+ }
532
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
533
+ }
534
+
535
+ function createDeviceCounters(overrides = {}) {
536
+ return {
537
+ messagesReceived: 0,
538
+ statusReceived: 0,
539
+ commandsSent: 0,
540
+ commandResultsReceived: 0,
541
+ thumbnailFramesReceived: 0,
542
+ thumbnailFramesDropped: 0,
543
+ liveFramesReceived: 0,
544
+ liveFramesDropped: 0,
545
+ liveStreamsStarted: 0,
546
+ liveStreamsStopped: 0,
547
+ audioStreamsStarted: 0,
548
+ audioStreamsStopped: 0,
549
+ audioFramesReceived: 0,
550
+ audioFramesDropped: 0,
551
+ tasksQueued: 0,
552
+ taskResultsReceived: 0,
553
+ taskResultsFailed: 0,
554
+ taskResultsTimedOut: 0,
555
+ ...overrides
556
+ };
557
+ }
558
+
559
+ function normalizeDeviceId(value) {
560
+ const id = safeString(value, 128).replace(/[^a-zA-Z0-9_.:-]/g, '-');
561
+ return id || crypto.randomUUID();
562
+ }
563
+
564
+ function maskToken(token) {
565
+ const value = String(token || '');
566
+ if (value.length <= 8) {
567
+ return value ? '****' : '';
568
+ }
569
+
570
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
571
+ }
572
+
573
+ function timingSafeStringEqual(left, right) {
574
+ const a = Buffer.from(String(left || ''), 'utf8');
575
+ const b = Buffer.from(String(right || ''), 'utf8');
576
+ if (a.length !== b.length) {
577
+ return false;
578
+ }
579
+
580
+ return crypto.timingSafeEqual(a, b);
581
+ }
582
+
583
+ function parseJsonLine(line) {
584
+ const text = String(line || '').trim();
585
+ if (!text) {
586
+ return null;
587
+ }
588
+
589
+ return JSON.parse(text);
590
+ }
591
+
592
+ function createFrameAccessToken() {
593
+ return crypto.randomBytes(18).toString('base64url');
594
+ }
595
+
596
+ function buildRemoteFramePath(deviceId, frameKind, frame) {
597
+ const token = safeString(frame?.accessToken, 128);
598
+ const frameSeq = Number(frame?.frameSeq);
599
+ if (!deviceId || !token || !Number.isFinite(frameSeq)) {
600
+ return '';
601
+ }
602
+
603
+ const endpoint = frameKind === 'thumbnail' ? 'thumbnail' : 'live/frame';
604
+ const params = new URLSearchParams({
605
+ format: 'binary',
606
+ seq: String(Math.floor(frameSeq)),
607
+ token
608
+ });
609
+ return `/api/remote/devices/${encodeURIComponent(deviceId)}/${endpoint}?${params.toString()}`;
610
+ }
611
+
612
+ function serializeRemoteFrame(frame, deviceId, frameKind, options = {}) {
613
+ if (!frame) {
614
+ return null;
615
+ }
616
+
617
+ const {
618
+ payload,
619
+ accessToken,
620
+ dataUrl,
621
+ ...publicFrame
622
+ } = frame;
623
+
624
+ const framePath = buildRemoteFramePath(deviceId, frameKind, frame);
625
+ const serialized = {
626
+ ...publicFrame,
627
+ framePath,
628
+ frameUrl: framePath
629
+ };
630
+ if (options.includeDataUrl === true) {
631
+ serialized.dataUrl = dataUrl || buildFrameDataUrl(payload, '', publicFrame.mimeType || publicFrame.format || 'image/jpeg');
632
+ }
633
+
634
+ return serialized;
635
+ }
636
+
637
+ function getRecentFrameCache(device, frameKind) {
638
+ if (!device) {
639
+ return null;
640
+ }
641
+
642
+ if (!device.recentFramePayloads) {
643
+ device.recentFramePayloads = {
644
+ thumbnail: [],
645
+ live: []
646
+ };
647
+ }
648
+
649
+ const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
650
+ if (!Array.isArray(device.recentFramePayloads[kind])) {
651
+ device.recentFramePayloads[kind] = [];
652
+ }
653
+
654
+ return device.recentFramePayloads[kind];
655
+ }
656
+
657
+ function getRecentFrameCacheLimit(frameKind) {
658
+ return frameKind === 'thumbnail'
659
+ ? RECENT_THUMBNAIL_FRAME_CACHE_LIMIT
660
+ : RECENT_LIVE_FRAME_CACHE_LIMIT;
661
+ }
662
+
663
+ function getRecentFrameCacheMaxBytes(frameKind) {
664
+ return frameKind === 'thumbnail'
665
+ ? RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES
666
+ : RECENT_LIVE_FRAME_CACHE_MAX_BYTES;
667
+ }
668
+
669
+ function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
670
+ if (!Array.isArray(cache)) {
671
+ return;
672
+ }
673
+
674
+ const limit = getRecentFrameCacheLimit(frameKind);
675
+ const maxBytes = getRecentFrameCacheMaxBytes(frameKind);
676
+ let byteTotal = 0;
677
+ for (let index = cache.length - 1; index >= 0; index -= 1) {
678
+ const entry = cache[index];
679
+ const frame = entry?.frame;
680
+ if (!entry
681
+ || entry.expiresAt <= nowMs
682
+ || !frame
683
+ || !Buffer.isBuffer(frame.payload)
684
+ || !Number.isFinite(Number(frame.frameSeq))
685
+ || !safeString(frame.accessToken, 128)) {
686
+ cache.splice(index, 1);
687
+ }
688
+ }
689
+
690
+ for (let index = 0; index < cache.length; index += 1) {
691
+ const entry = cache[index];
692
+ const byteLength = Number(entry?.byteLength || entry?.frame?.payload?.length || 0) || 0;
693
+ byteTotal += byteLength;
694
+ if (index >= limit || byteTotal > maxBytes) {
695
+ cache.splice(index);
696
+ break;
697
+ }
698
+ }
699
+ }
700
+
701
+ function rememberRecentFramePayload(device, frameKind, frame) {
702
+ if (!device || !frame || !Buffer.isBuffer(frame.payload)) {
703
+ return;
704
+ }
705
+
706
+ const frameSeq = Number(frame.frameSeq);
707
+ const accessToken = safeString(frame.accessToken, 128);
708
+ if (!Number.isFinite(frameSeq) || !accessToken) {
709
+ return;
710
+ }
711
+
712
+ const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
713
+ const cache = getRecentFrameCache(device, kind);
714
+ if (!cache) {
715
+ return;
716
+ }
717
+
718
+ const nowMs = Date.now();
719
+ const normalizedSeq = Math.floor(frameSeq);
720
+ for (let index = cache.length - 1; index >= 0; index -= 1) {
721
+ const existing = cache[index]?.frame;
722
+ if (Number(existing?.frameSeq) === normalizedSeq
723
+ || safeString(existing?.accessToken, 128) === accessToken) {
724
+ cache.splice(index, 1);
725
+ }
726
+ }
727
+
728
+ cache.unshift({
729
+ frame,
730
+ byteLength: frame.payload.length,
731
+ cachedAt: nowMs,
732
+ expiresAt: nowMs + RECENT_FRAME_CACHE_TTL_MS
733
+ });
734
+ pruneRecentFrameCache(cache, kind, nowMs);
735
+ }
736
+
737
+ function isFramePayloadRequestMatch(frame, options = {}) {
738
+ if (!frame || !Buffer.isBuffer(frame.payload)) {
739
+ return false;
740
+ }
741
+
742
+ const requestedSeq = Number(options.frameSeq);
743
+ if (Number.isFinite(requestedSeq) && Math.floor(requestedSeq) !== Number(frame.frameSeq)) {
744
+ return false;
745
+ }
746
+
747
+ const token = safeString(options.token, 128);
748
+ if (token && !timingSafeStringEqual(token, frame.accessToken)) {
749
+ return false;
750
+ }
751
+
752
+ if (options.requireToken === true && !token) {
753
+ return false;
754
+ }
755
+
756
+ return true;
757
+ }
758
+
759
+ function findRecentFramePayload(device, frameKind, options = {}) {
760
+ const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
761
+ const latest = kind === 'thumbnail'
762
+ ? device?.latestThumbnail
763
+ : device?.latestLiveFrame;
764
+ if (isFramePayloadRequestMatch(latest, options)) {
765
+ return latest;
766
+ }
767
+
768
+ const token = safeString(options.token, 128);
769
+ const requestedSeq = Number(options.frameSeq);
770
+ if (!token && !Number.isFinite(requestedSeq)) {
771
+ return null;
772
+ }
773
+
774
+ const cache = getRecentFrameCache(device, kind);
775
+ if (!cache) {
776
+ return null;
777
+ }
778
+
779
+ pruneRecentFrameCache(cache, kind);
780
+ const entry = cache.find(item => isFramePayloadRequestMatch(item?.frame, options));
781
+ return entry?.frame || null;
782
+ }
783
+
784
+ function serializeDevice(device, options = {}) {
785
+ if (!device) {
786
+ return null;
787
+ }
788
+
789
+ return {
790
+ deviceId: device.deviceId,
791
+ sessionId: device.sessionId,
792
+ deviceName: device.deviceName,
793
+ hostname: device.hostname,
794
+ platform: device.platform,
795
+ arch: device.arch,
796
+ slotNumber: device.slotNumber || 0,
797
+ pid: device.pid,
798
+ agentVersion: device.agentVersion,
799
+ protocol: device.protocol,
800
+ protocolVersion: device.protocolVersion,
801
+ frameProtocol: device.frameProtocol ? { ...device.frameProtocol } : null,
802
+ frameModes: Array.isArray(device.frameModes)
803
+ ? device.frameModes.map(profile => ({ ...profile }))
804
+ : [],
805
+ capabilities: { ...device.capabilities },
806
+ connected: device.connected,
807
+ connectedAt: device.connectedAt,
808
+ disconnectedAt: device.disconnectedAt,
809
+ lastSeenAt: device.lastSeenAt,
810
+ lastStatusAt: device.lastStatusAt,
811
+ lastDisconnectReason: device.lastDisconnectReason,
812
+ remoteAddress: device.remoteAddress,
813
+ remotePort: device.remotePort,
814
+ latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
815
+ latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
816
+ activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
817
+ activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
818
+ latestAudioFrame: device.latestAudioFrame
819
+ ? {
820
+ streamId: device.latestAudioFrame.streamId,
821
+ frameSeq: device.latestAudioFrame.frameSeq,
822
+ mimeType: device.latestAudioFrame.mimeType,
823
+ sampleRate: device.latestAudioFrame.sampleRate,
824
+ channels: device.latestAudioFrame.channels,
825
+ capturedAt: device.latestAudioFrame.capturedAt,
826
+ receivedAt: device.latestAudioFrame.receivedAt,
827
+ byteLength: device.latestAudioFrame.byteLength
828
+ }
829
+ : null,
830
+ latestTask: device.latestTask ? { ...device.latestTask } : null,
831
+ synthetic: device.synthetic === true,
832
+ recentTasks: Array.isArray(device.recentTasks)
833
+ ? device.recentTasks.map(task => ({ ...task }))
834
+ : [],
835
+ status: { ...device.status },
836
+ counters: { ...device.counters }
837
+ };
838
+ }
839
+
840
+ function serializeTaskBatch(batch) {
841
+ if (!batch) {
842
+ return null;
843
+ }
844
+
845
+ return {
846
+ batchId: batch.batchId,
847
+ title: batch.title,
848
+ instructionPreview: batch.instructionPreview,
849
+ approvalLevel: batch.approvalLevel,
850
+ status: batch.status,
851
+ requestedAt: batch.requestedAt,
852
+ updatedAt: batch.updatedAt,
853
+ total: batch.total,
854
+ queued: batch.queued,
855
+ pending: batch.pending,
856
+ completed: batch.completed,
857
+ failed: batch.failed,
858
+ timedOut: batch.timedOut,
859
+ results: Array.isArray(batch.results)
860
+ ? batch.results.map(item => ({ ...item }))
861
+ : []
862
+ };
863
+ }
864
+
865
+ function writeJsonLine(socket, payload) {
866
+ if (!socket || socket.destroyed) {
867
+ return false;
868
+ }
869
+
870
+ if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
871
+ return socket.sendJson(payload);
872
+ }
873
+
874
+ socket.write(`${JSON.stringify(payload)}\n`);
875
+ return true;
876
+ }
877
+
878
+ function isRemoteHubWebSocketUpgradeStart(buffer) {
879
+ if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
880
+ return false;
881
+ }
882
+
883
+ return buffer.subarray(0, Math.min(buffer.length, 16)).toString('latin1').startsWith('GET ');
884
+ }
885
+
886
+ function parseRemoteHubWebSocketUpgrade(buffer) {
887
+ const headerEnd = buffer.indexOf('\r\n\r\n');
888
+ if (headerEnd < 0) {
889
+ return null;
890
+ }
891
+
892
+ const headerText = buffer.subarray(0, headerEnd).toString('latin1');
893
+ const lines = headerText.split('\r\n');
894
+ const requestLine = lines.shift() || '';
895
+ const [method, path] = requestLine.split(/\s+/);
896
+ const headers = {};
897
+ for (const line of lines) {
898
+ const separator = line.indexOf(':');
899
+ if (separator <= 0) {
900
+ continue;
901
+ }
902
+
903
+ headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim();
904
+ }
905
+
906
+ return {
907
+ method,
908
+ path,
909
+ headers,
910
+ head: buffer.subarray(headerEnd + 4)
911
+ };
912
+ }
913
+
914
+ function buildRemoteHubWebSocketAcceptKey(key) {
915
+ return crypto
916
+ .createHash('sha1')
917
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
918
+ .digest('base64');
919
+ }
920
+
921
+ function buildRemoteHubWebSocketFrame(opcode, payload) {
922
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf8');
923
+ let header = null;
924
+ if (body.length < 126) {
925
+ header = Buffer.allocUnsafe(2);
926
+ header[0] = 0x80 | opcode;
927
+ header[1] = body.length;
928
+ } else if (body.length <= 0xffff) {
929
+ header = Buffer.allocUnsafe(4);
930
+ header[0] = 0x80 | opcode;
931
+ header[1] = 126;
932
+ header.writeUInt16BE(body.length, 2);
933
+ } else {
934
+ header = Buffer.allocUnsafe(10);
935
+ header[0] = 0x80 | opcode;
936
+ header[1] = 127;
937
+ header.writeBigUInt64BE(BigInt(body.length), 2);
938
+ }
939
+
940
+ return Buffer.concat([header, body], header.length + body.length);
941
+ }
942
+
943
+ function parseRemoteHubWebSocketBinaryFrame(buffer) {
944
+ if (!Buffer.isBuffer(buffer) || buffer.length < 5) {
945
+ return null;
946
+ }
947
+
948
+ const metaLength = buffer.readUInt32BE(0);
949
+ if (!Number.isFinite(metaLength)
950
+ || metaLength <= 0
951
+ || metaLength > 64 * 1024
952
+ || 4 + metaLength >= buffer.length) {
953
+ return null;
954
+ }
955
+
956
+ const header = JSON.parse(buffer.subarray(4, 4 + metaLength).toString('utf8'));
957
+ const payload = buffer.subarray(4 + metaLength);
958
+ return { header, payload };
959
+ }
960
+
961
+ class RemoteHubWebSocketAgentSocket {
962
+ constructor(socket) {
963
+ this.__remoteHubWebSocket = true;
964
+ this.socket = socket;
965
+ this.remoteAddress = socket.remoteAddress;
966
+ this.remotePort = socket.remotePort;
967
+ this.destroyed = false;
968
+ this.buffer = Buffer.alloc(0);
969
+ this.fragmentOpcode = 0;
970
+ this.fragments = [];
971
+ this.onTextMessage = () => {};
972
+ this.onBinaryMessage = () => {};
973
+ this.onCloseMessage = () => {};
974
+ }
975
+
976
+ setNoDelay() {}
977
+
978
+ setKeepAlive() {}
979
+
980
+ sendJson(payload) {
981
+ return this.sendText(JSON.stringify(payload));
982
+ }
983
+
984
+ write(payload) {
985
+ if (this.destroyed) {
986
+ return false;
987
+ }
988
+
989
+ const text = (Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload || '')).replace(/\n$/, '');
990
+ return this.sendText(text);
991
+ }
992
+
993
+ sendText(text) {
994
+ return this.sendFrame(0x1, Buffer.from(String(text || ''), 'utf8'));
995
+ }
996
+
997
+ sendFrame(opcode, payload) {
998
+ if (this.destroyed || this.socket.destroyed) {
999
+ return false;
1000
+ }
1001
+
1002
+ try {
1003
+ this.socket.write(buildRemoteHubWebSocketFrame(opcode, payload));
1004
+ return true;
1005
+ } catch {
1006
+ this.destroy();
1007
+ return false;
1008
+ }
1009
+ }
1010
+
1011
+ destroy() {
1012
+ if (this.destroyed) {
1013
+ return;
1014
+ }
1015
+
1016
+ this.destroyed = true;
1017
+ try {
1018
+ if (!this.socket.destroyed) {
1019
+ this.socket.write(buildRemoteHubWebSocketFrame(0x8, Buffer.alloc(0)));
1020
+ }
1021
+ } catch {
1022
+ // Ignore close-frame failures.
1023
+ }
1024
+ try {
1025
+ this.socket.destroy();
1026
+ } catch {
1027
+ // Ignore socket destroy failures.
1028
+ }
1029
+ }
1030
+
1031
+ handleData(chunk) {
1032
+ if (this.destroyed) {
1033
+ return;
1034
+ }
1035
+
1036
+ this.buffer = this.buffer.length > 0
1037
+ ? Buffer.concat([this.buffer, chunk])
1038
+ : chunk;
1039
+
1040
+ while (!this.destroyed) {
1041
+ if (this.buffer.length < 2) {
1042
+ return;
1043
+ }
1044
+
1045
+ const first = this.buffer[0];
1046
+ const second = this.buffer[1];
1047
+ const fin = (first & 0x80) !== 0;
1048
+ const opcode = first & 0x0f;
1049
+ const masked = (second & 0x80) !== 0;
1050
+ let payloadLength = second & 0x7f;
1051
+ let offset = 2;
1052
+
1053
+ if (payloadLength === 126) {
1054
+ if (this.buffer.length < offset + 2) return;
1055
+ payloadLength = this.buffer.readUInt16BE(offset);
1056
+ offset += 2;
1057
+ } else if (payloadLength === 127) {
1058
+ if (this.buffer.length < offset + 8) return;
1059
+ const bigLength = this.buffer.readBigUInt64BE(offset);
1060
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
1061
+ this.destroy();
1062
+ return;
1063
+ }
1064
+ payloadLength = Number(bigLength);
1065
+ offset += 8;
1066
+ }
1067
+
1068
+ if (!masked) {
1069
+ this.destroy();
1070
+ return;
1071
+ }
1072
+
1073
+ if (this.buffer.length < offset + 4 + payloadLength) {
1074
+ return;
1075
+ }
1076
+
1077
+ const mask = this.buffer.subarray(offset, offset + 4);
1078
+ offset += 4;
1079
+ const payload = Buffer.from(this.buffer.subarray(offset, offset + payloadLength));
1080
+ this.buffer = this.buffer.subarray(offset + payloadLength);
1081
+ for (let index = 0; index < payload.length; index += 1) {
1082
+ payload[index] ^= mask[index & 3];
1083
+ }
1084
+
1085
+ if (opcode === 0x8) {
1086
+ this.destroyed = true;
1087
+ this.onCloseMessage('websocket-close');
1088
+ try {
1089
+ this.socket.destroy();
1090
+ } catch {
1091
+ // Ignore socket destroy failures.
1092
+ }
1093
+ return;
1094
+ }
1095
+
1096
+ if (opcode === 0x9) {
1097
+ this.sendFrame(0xA, payload);
1098
+ continue;
1099
+ }
1100
+
1101
+ if (opcode === 0xA) {
1102
+ continue;
1103
+ }
1104
+
1105
+ let messageOpcode = opcode;
1106
+ let messagePayload = payload;
1107
+ if (!fin) {
1108
+ this.fragmentOpcode = opcode;
1109
+ this.fragments = [payload];
1110
+ continue;
1111
+ }
1112
+
1113
+ if (opcode === 0x0) {
1114
+ this.fragments.push(payload);
1115
+ messageOpcode = this.fragmentOpcode;
1116
+ messagePayload = Buffer.concat(this.fragments);
1117
+ this.fragmentOpcode = 0;
1118
+ this.fragments = [];
1119
+ }
1120
+
1121
+ if (messageOpcode === 0x1) {
1122
+ this.onTextMessage(messagePayload.toString('utf8'));
1123
+ } else if (messageOpcode === 0x2) {
1124
+ this.onBinaryMessage(messagePayload);
1125
+ }
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ export function createRemoteHub(options = {}) {
1131
+ const env = options.env || process.env;
1132
+ const logEvent = options.logEvent || (() => {});
1133
+ const logWarn = options.logWarn || (() => {});
1134
+ const emitEvent = options.emitEvent || (() => {});
1135
+ const emitFrame = typeof options.emitFrame === 'function' ? options.emitFrame : (() => {});
1136
+ const emitAudio = typeof options.emitAudio === 'function' ? options.emitAudio : (() => {});
1137
+
1138
+ const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
1139
+ && !isDisabledValue(env.REMOTE_HUB_DISABLED);
1140
+ const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
1141
+ const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
1142
+ const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
1143
+ const taskTimeoutMs = clampNumber(
1144
+ options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
1145
+ 50,
1146
+ 30 * 60 * 1000,
1147
+ DEFAULT_AGENT_TASK_TIMEOUT_MS);
1148
+ const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1149
+ const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1150
+ const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
1151
+ const publicEndpoint = safeString(env.LIVEDESK_REMOTE_PUBLIC_ENDPOINT || env.MINDEXEC_REMOTE_PUBLIC_ENDPOINT || env.REMOTE_HUB_PUBLIC_ENDPOINT, 256);
1152
+ const publicHost = safeString(env.LIVEDESK_REMOTE_PUBLIC_HOST || env.MINDEXEC_REMOTE_PUBLIC_HOST || env.REMOTE_HUB_PUBLIC_HOST, 128);
1153
+ const pairToken = safeString(
1154
+ options.pairToken || env.REMOTE_HUB_PAIR_TOKEN || env.LIVEDESK_CLIENT_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
1155
+ 256);
1156
+ const duplicateDeviceLogThrottleMs = clampNumber(
1157
+ env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_LOG_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_LOG_MS,
1158
+ 5000,
1159
+ 10 * 60 * 1000,
1160
+ DUPLICATE_DEVICE_LOG_THROTTLE_MS);
1161
+ const duplicateDeviceRetryAfterMs = clampNumber(
1162
+ env.MINDEXEC_REMOTE_DUPLICATE_DEVICE_RETRY_MS || env.REMOTE_HUB_DUPLICATE_DEVICE_RETRY_MS,
1163
+ 5000,
1164
+ 10 * 60 * 1000,
1165
+ DUPLICATE_DEVICE_RETRY_AFTER_MS);
1166
+
1167
+ const devices = new Map();
1168
+ const taskBatches = new Map();
1169
+ const sockets = new Map();
1170
+ const allSockets = new Set();
1171
+ const duplicateDeviceLogAt = new Map();
1172
+ let server = null;
1173
+ let started = false;
1174
+ let boundPort = requestedPort;
1175
+ let lastError = '';
1176
+ let hostTarget = null;
1177
+ let publicIPv4Cache = {
1178
+ address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
1179
+ updatedAt: 0,
1180
+ checkedAt: 0,
1181
+ error: ''
1182
+ };
1183
+
1184
+ if (!isPublicIPv4(publicIPv4Cache.address)) {
1185
+ publicIPv4Cache.address = '';
1186
+ }
1187
+
1188
+ const publicIpDiscoveryEnabled = isEnabledValue(env.LIVEDESK_PUBLIC_IP_DISCOVERY ?? env.REMOTE_HUB_PUBLIC_IP_DISCOVERY, true)
1189
+ && !isDisabledValue(env.LIVEDESK_PUBLIC_IP_DISCOVERY_DISABLED ?? env.REMOTE_HUB_PUBLIC_IP_DISCOVERY_DISABLED);
1190
+
1191
+ function getPublicIPv4EndpointCandidate() {
1192
+ const address = safeString(publicIPv4Cache.address, 64);
1193
+ if (!isPublicIPv4(address)) {
1194
+ return null;
1195
+ }
1196
+
1197
+ const endpoint = buildEndpoint(address, boundPort || requestedPort);
1198
+ if (!endpoint) {
1199
+ return null;
1200
+ }
1201
+
1202
+ return {
1203
+ address,
1204
+ name: 'Public IPv4',
1205
+ mac: '',
1206
+ score: -100,
1207
+ virtual: false,
1208
+ preferred: false,
1209
+ public: true,
1210
+ endpoint
1211
+ };
1212
+ }
1213
+
1214
+ async function refreshPublicIPv4Candidate(options = {}) {
1215
+ if (!publicIpDiscoveryEnabled) {
1216
+ return publicIPv4Cache.address;
1217
+ }
1218
+
1219
+ const now = Date.now();
1220
+ const force = options.force === true;
1221
+ const refreshMs = clampNumber(
1222
+ env.LIVEDESK_PUBLIC_IP_REFRESH_MS || env.REMOTE_HUB_PUBLIC_IP_REFRESH_MS,
1223
+ 30_000,
1224
+ 60 * 60 * 1000,
1225
+ DEFAULT_PUBLIC_IP_REFRESH_MS);
1226
+
1227
+ if (!force && publicIPv4Cache.checkedAt > 0 && now - publicIPv4Cache.checkedAt < refreshMs) {
1228
+ return publicIPv4Cache.address;
1229
+ }
1230
+
1231
+ publicIPv4Cache.checkedAt = now;
1232
+ const timeoutMs = clampNumber(
1233
+ env.LIVEDESK_PUBLIC_IP_TIMEOUT_MS || env.REMOTE_HUB_PUBLIC_IP_TIMEOUT_MS,
1234
+ 500,
1235
+ 10_000,
1236
+ DEFAULT_PUBLIC_IP_TIMEOUT_MS);
1237
+
1238
+ for (const provider of PUBLIC_IPV4_PROVIDERS) {
1239
+ try {
1240
+ const text = await fetchTextWithTimeout(provider, timeoutMs);
1241
+ let address = '';
1242
+ try {
1243
+ const payload = JSON.parse(text);
1244
+ address = safeString(payload?.ip || payload?.origin || payload?.address, 64);
1245
+ } catch {
1246
+ address = extractIPv4FromText(text);
1247
+ }
1248
+ address = extractIPv4FromText(address) || address;
1249
+ if (!isPublicIPv4(address)) {
1250
+ continue;
1251
+ }
1252
+ if (publicIPv4Cache.address !== address) {
1253
+ logEvent('remote', `public IPv4 candidate detected ${address}`, 'remote');
1254
+ }
1255
+ publicIPv4Cache = {
1256
+ address,
1257
+ updatedAt: Date.now(),
1258
+ checkedAt: Date.now(),
1259
+ error: ''
1260
+ };
1261
+ return publicIPv4Cache.address;
1262
+ } catch (err) {
1263
+ publicIPv4Cache.error = err instanceof Error ? err.message : String(err);
1264
+ }
1265
+ }
1266
+
1267
+ return publicIPv4Cache.address;
1268
+ }
1269
+
1270
+ function getAnnouncedHost() {
1271
+ if (publicHost) {
1272
+ return publicHost;
1273
+ }
1274
+
1275
+ if (isWildcardHost(host)) {
1276
+ return getReachableIPv4Address();
1277
+ }
1278
+
1279
+ return host;
1280
+ }
1281
+
1282
+ function getAgentEndpoint() {
1283
+ if (publicEndpoint) {
1284
+ return publicEndpoint;
1285
+ }
1286
+
1287
+ return `${getAnnouncedHost()}:${boundPort || requestedPort}`;
1288
+ }
1289
+
1290
+ function getAgentEndpointRouteInfo() {
1291
+ const includeInterfaceCandidates = !publicEndpoint && isWildcardHost(host);
1292
+ const candidateDetails = includeInterfaceCandidates
1293
+ ? getReachableIPv4CandidateEntries()
1294
+ : [];
1295
+ const candidateEndpoints = candidateDetails
1296
+ .map(candidate => ({
1297
+ ...candidate,
1298
+ endpoint: buildEndpoint(candidate.address, boundPort || requestedPort)
1299
+ }))
1300
+ .filter(candidate => candidate.endpoint);
1301
+ const publicCandidate = getPublicIPv4EndpointCandidate();
1302
+ const externalCandidateEndpoints = publicCandidate ? [publicCandidate] : [];
1303
+ const endpoint = getAgentEndpoint();
1304
+ const endpointCandidates = [
1305
+ endpoint,
1306
+ ...candidateEndpoints.map(candidate => candidate.endpoint),
1307
+ ...externalCandidateEndpoints.map(candidate => candidate.endpoint)
1308
+ ].filter((value, index, all) => value && all.indexOf(value) === index);
1309
+ const wildcardWithoutReachableAddress =
1310
+ !publicEndpoint
1311
+ && !publicHost
1312
+ && isWildcardHost(host)
1313
+ && candidateDetails.length === 0;
1314
+ const reason = getAccountRouteEndpointReason(endpoint, { wildcardWithoutReachableAddress });
1315
+ const parsed = parseManagerEndpoint(endpoint);
1316
+ return {
1317
+ endpoint,
1318
+ host: parsed?.host || '',
1319
+ port: parsed?.port || 0,
1320
+ accountRouteReady: reason === 'ok',
1321
+ reason,
1322
+ candidates: endpointCandidates,
1323
+ candidateDetails: [
1324
+ ...candidateEndpoints,
1325
+ ...externalCandidateEndpoints
1326
+ ]
1327
+ };
1328
+ }
1329
+
1330
+ function getActiveHostTarget() {
1331
+ if (!hostTarget) {
1332
+ return null;
1333
+ }
1334
+
1335
+ const expiresMs = Date.parse(hostTarget.expiresAt || '');
1336
+ if (Number.isFinite(expiresMs) && expiresMs <= Date.now()) {
1337
+ const expiredTarget = hostTarget;
1338
+ hostTarget = null;
1339
+ emitRemoteEvent('RemoteHostTargetExpired', null, {
1340
+ hostTarget: {
1341
+ ...expiredTarget,
1342
+ active: false
1343
+ }
1344
+ });
1345
+ return null;
1346
+ }
1347
+
1348
+ return hostTarget;
1349
+ }
1350
+
1351
+ function serializeHostTarget(target = getActiveHostTarget()) {
1352
+ if (!target) {
1353
+ return {
1354
+ active: false,
1355
+ nodeId: '',
1356
+ leaseId: '',
1357
+ hostInstanceId: '',
1358
+ endpoint: '',
1359
+ endpointCandidates: [],
1360
+ activatedAt: '',
1361
+ updatedAt: '',
1362
+ expiresAt: ''
1363
+ };
1364
+ }
1365
+
1366
+ return {
1367
+ active: true,
1368
+ nodeId: target.nodeId,
1369
+ leaseId: target.leaseId,
1370
+ hostInstanceId: target.hostInstanceId || hostInstanceId,
1371
+ endpoint: target.endpoint,
1372
+ endpointCandidates: Array.isArray(target.endpointCandidates) ? [...target.endpointCandidates] : [target.endpoint].filter(Boolean),
1373
+ activatedAt: target.activatedAt,
1374
+ updatedAt: target.updatedAt,
1375
+ expiresAt: target.expiresAt
1376
+ };
1377
+ }
1378
+
1379
+ async function setHostTarget(options = {}) {
1380
+ const enabled = options.enabled !== false;
1381
+ const nodeId = safeString(options.nodeId, 128);
1382
+ if (!enabled) {
1383
+ const previous = getActiveHostTarget();
1384
+ if (!previous) {
1385
+ return {
1386
+ ok: true,
1387
+ active: false,
1388
+ hostTarget: serializeHostTarget(null)
1389
+ };
1390
+ }
1391
+
1392
+ if (nodeId && previous.nodeId && nodeId !== previous.nodeId) {
1393
+ return {
1394
+ ok: false,
1395
+ error: 'host-target-node-mismatch',
1396
+ active: true,
1397
+ hostTarget: serializeHostTarget(previous)
1398
+ };
1399
+ }
1400
+
1401
+ hostTarget = null;
1402
+ emitRemoteEvent('RemoteHostTargetCleared', null, {
1403
+ hostTarget: {
1404
+ ...serializeHostTarget(previous),
1405
+ active: false
1406
+ }
1407
+ });
1408
+ return {
1409
+ ok: true,
1410
+ active: false,
1411
+ hostTarget: serializeHostTarget(null)
1412
+ };
1413
+ }
1414
+
1415
+ if (!nodeId) {
1416
+ return {
1417
+ ok: false,
1418
+ error: 'missing-node-id',
1419
+ active: false,
1420
+ hostTarget: serializeHostTarget()
1421
+ };
1422
+ }
1423
+
1424
+ const now = new Date();
1425
+ const leaseMs = clampNumber(options.leaseMs, 5000, 10 * 60 * 1000, DEFAULT_HOST_TARGET_LEASE_MS);
1426
+ const previous = getActiveHostTarget();
1427
+ const activeSameNode = previous?.nodeId === nodeId;
1428
+ await refreshPublicIPv4Candidate({ force: options.refreshPublicIp === true || options.takeover === true });
1429
+ const routeInfo = getAgentEndpointRouteInfo();
1430
+ hostTarget = {
1431
+ nodeId,
1432
+ leaseId: activeSameNode && previous?.leaseId
1433
+ ? previous.leaseId
1434
+ : (safeString(options.leaseId, 128) || crypto.randomUUID()),
1435
+ hostInstanceId,
1436
+ endpoint: routeInfo.endpoint,
1437
+ endpointCandidates: routeInfo.candidates,
1438
+ activatedAt: activeSameNode && previous?.activatedAt ? previous.activatedAt : now.toISOString(),
1439
+ updatedAt: now.toISOString(),
1440
+ expiresAt: new Date(now.getTime() + leaseMs).toISOString()
1441
+ };
1442
+
1443
+ emitRemoteEvent('RemoteHostTargetSet', null, {
1444
+ hostTarget: serializeHostTarget(hostTarget),
1445
+ replacedNodeId: previous && previous.nodeId !== nodeId ? previous.nodeId : ''
1446
+ });
1447
+ if (!activeSameNode || !routeInfo.accountRouteReady) {
1448
+ logEvent(
1449
+ 'remote',
1450
+ `host target ${routeInfo.accountRouteReady ? 'account-ready' : 'local-only'} endpoint=${routeInfo.endpoint} candidates=${routeInfo.candidates.length} reason=${routeInfo.reason}`,
1451
+ 'remote');
1452
+ }
1453
+ return {
1454
+ ok: true,
1455
+ active: true,
1456
+ hostTarget: serializeHostTarget(hostTarget)
1457
+ };
1458
+ }
1459
+
1460
+ function getStatus({ includeSecrets = false } = {}) {
1461
+ const connectedDevices = [...devices.values()].filter(device => device.connected).length;
1462
+ const activeHostTarget = serializeHostTarget();
1463
+ const routeInfo = getAgentEndpointRouteInfo();
1464
+ return {
1465
+ enabled,
1466
+ started,
1467
+ host,
1468
+ agentHost: routeInfo.host || getAnnouncedHost(),
1469
+ port: boundPort || requestedPort,
1470
+ protocol: 'tcp-jsonl',
1471
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
1472
+ agentProtocol: REMOTE_AGENT_PROTOCOL,
1473
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
1474
+ frameModes: getSupportedRemoteFrameModeProfiles(),
1475
+ heartbeatMs,
1476
+ taskTimeoutMs,
1477
+ managerPackage,
1478
+ managerVersion,
1479
+ hostInstanceId,
1480
+ agentPackage: '@livedesk/client',
1481
+ agentEndpoint: routeInfo.endpoint,
1482
+ agentEndpointAccountRouteReady: routeInfo.accountRouteReady,
1483
+ agentEndpointRouteReason: routeInfo.reason,
1484
+ agentEndpointCandidates: routeInfo.candidates,
1485
+ agentEndpointCandidateDetails: routeInfo.candidateDetails,
1486
+ pairToken: includeSecrets ? pairToken : undefined,
1487
+ pairTokenPreview: maskToken(pairToken),
1488
+ deviceCount: devices.size,
1489
+ connectedDeviceCount: connectedDevices,
1490
+ canvasDeviceListMode: 'all-devices',
1491
+ canvasPagination: 'none',
1492
+ hostTargetActive: activeHostTarget.active,
1493
+ hostTargetNodeId: activeHostTarget.nodeId,
1494
+ hostTargetLeaseId: activeHostTarget.leaseId,
1495
+ hostTargetHostInstanceId: activeHostTarget.hostInstanceId,
1496
+ hostTargetEndpoint: activeHostTarget.endpoint,
1497
+ hostTargetEndpointCandidates: activeHostTarget.endpointCandidates,
1498
+ hostTargetActivatedAt: activeHostTarget.activatedAt,
1499
+ hostTargetUpdatedAt: activeHostTarget.updatedAt,
1500
+ hostTargetExpiresAt: activeHostTarget.expiresAt,
1501
+ publicIPv4Address: publicIPv4Cache.address,
1502
+ publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
1503
+ publicIPv4Error: publicIPv4Cache.error,
1504
+ externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
1505
+ lastError
1506
+ };
1507
+ }
1508
+
1509
+ function listDevices(options = {}) {
1510
+ const serializeOptions = {
1511
+ includeDataUrl: options.includeDataUrl === true
1512
+ };
1513
+ return [...devices.values()]
1514
+ .map(device => serializeDevice(device, serializeOptions))
1515
+ .filter(Boolean)
1516
+ .sort((a, b) => {
1517
+ const nameCompare = String(a.deviceName || '').localeCompare(String(b.deviceName || ''));
1518
+ const slotA = Number(a.slotNumber || 0);
1519
+ const slotB = Number(b.slotNumber || 0);
1520
+ if (slotA > 0 || slotB > 0) {
1521
+ if (slotA <= 0) {
1522
+ return 1;
1523
+ }
1524
+ if (slotB <= 0) {
1525
+ return -1;
1526
+ }
1527
+ if (slotA !== slotB) {
1528
+ return slotA - slotB;
1529
+ }
1530
+ }
1531
+ if (nameCompare !== 0) {
1532
+ return nameCompare;
1533
+ }
1534
+
1535
+ return String(a.deviceId).localeCompare(String(b.deviceId));
1536
+ });
1537
+ }
1538
+
1539
+ function listDeviceFrames(options = {}) {
1540
+ const ids = Array.isArray(options.deviceIds)
1541
+ ? options.deviceIds.map(value => safeString(value, 160)).filter(Boolean)
1542
+ : [];
1543
+ const idSet = ids.length > 0 ? new Set(ids) : null;
1544
+ const includeThumbnail = options.includeThumbnail !== false;
1545
+ const includeLive = options.includeLive !== false;
1546
+
1547
+ return [...devices.values()]
1548
+ .filter(device => !idSet || idSet.has(device.deviceId))
1549
+ .map(device => ({
1550
+ deviceId: device.deviceId,
1551
+ connected: device.connected === true,
1552
+ thumbnailEnabled: readCapabilityFlag(device.capabilities, 'thumbnail'),
1553
+ liveStreamActive: device.activeLiveStream?.active === true,
1554
+ liveStreamId: device.activeLiveStream?.streamId || '',
1555
+ liveStreamFps: Number(device.activeLiveStream?.fps || 0),
1556
+ liveStreamLastFrameAt: device.activeLiveStream?.lastFrameAt || '',
1557
+ thumbnail: includeThumbnail
1558
+ ? serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', { includeDataUrl: false })
1559
+ : null,
1560
+ live: includeLive
1561
+ ? serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', { includeDataUrl: false })
1562
+ : null
1563
+ }))
1564
+ .sort((a, b) => String(a.deviceId).localeCompare(String(b.deviceId)));
1565
+ }
1566
+
1567
+ function listTaskBatches() {
1568
+ return [...taskBatches.values()]
1569
+ .map(serializeTaskBatch)
1570
+ .filter(Boolean)
1571
+ .sort((left, right) => String(right.requestedAt || '').localeCompare(String(left.requestedAt || '')));
1572
+ }
1573
+
1574
+ function getLatestTaskBatch() {
1575
+ return listTaskBatches()[0] || null;
1576
+ }
1577
+
1578
+ function emitRemoteEvent(type, device = null, extra = {}) {
1579
+ emitEvent(type, {
1580
+ ...extra,
1581
+ remoteHub: getStatus({ includeSecrets: false }),
1582
+ device: serializeDevice(device)
1583
+ });
1584
+ }
1585
+
1586
+ function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
1587
+ const now = new Date().toISOString();
1588
+ const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
1589
+ const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
1590
+ + Number(device?.counters?.liveFramesReceived || 0)
1591
+ + 1;
1592
+ return {
1593
+ streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
1594
+ frameSeq,
1595
+ commandId: safeString(options.commandId, 128),
1596
+ width: clampNumber(options.width, 2, 2560, mode === 'remote-fast' ? 960 : 360),
1597
+ height: clampNumber(options.height, 1, 1440, mode === 'remote-fast' ? 540 : 220),
1598
+ mimeType: 'image/png',
1599
+ format: 'image/png',
1600
+ mode,
1601
+ frameMode: descriptor.frameMode,
1602
+ frameProfile: descriptor.frameProfile,
1603
+ encoding: descriptor.encoding,
1604
+ codec: 'png',
1605
+ compression: 'image/png',
1606
+ transferProtocol: descriptor.transferProtocol,
1607
+ transferProtocolVersion: descriptor.transferProtocolVersion,
1608
+ handshake: descriptor.handshake,
1609
+ fps: clampNumber(options.fps, 1, 24, mode === 'remote-fast' ? 20 : 1),
1610
+ capturedAt: now,
1611
+ receivedAt: now,
1612
+ byteLength: 68,
1613
+ transport: 'synthetic',
1614
+ contentHash: SYNTHETIC_FRAME_HASH,
1615
+ captureMs: 0,
1616
+ sameContentStreak: 0,
1617
+ dataUrl: SYNTHETIC_FRAME_DATA_URL,
1618
+ payload: SYNTHETIC_FRAME_PAYLOAD,
1619
+ accessToken: createFrameAccessToken()
1620
+ };
1621
+ }
1622
+
1623
+ function applySyntheticThumbnail(device, command) {
1624
+ if (!device) {
1625
+ return null;
1626
+ }
1627
+
1628
+ const payload = command?.payload || {};
1629
+ const frame = makeSyntheticFrame(device, payload.streamId || 'synthetic-thumb', 'thumbnail', {
1630
+ commandId: command?.commandId,
1631
+ width: payload.maxWidth || 360,
1632
+ height: payload.maxHeight || 220
1633
+ });
1634
+ delete frame.mode;
1635
+ delete frame.fps;
1636
+ device.latestThumbnail = frame;
1637
+ rememberRecentFramePayload(device, 'thumbnail', frame);
1638
+ device.lastSeenAt = frame.receivedAt;
1639
+ device.counters.thumbnailFramesReceived += 1;
1640
+ emitRemoteEvent('RemoteFrameReceived', device, {
1641
+ streamId: frame.streamId,
1642
+ frameSeq: frame.frameSeq,
1643
+ synthetic: true
1644
+ });
1645
+ return frame;
1646
+ }
1647
+
1648
+ function applySyntheticLiveFrame(device, streamId, options = {}) {
1649
+ if (!device?.activeLiveStream?.active) {
1650
+ return null;
1651
+ }
1652
+
1653
+ const frame = makeSyntheticFrame(device, streamId || device.activeLiveStream.streamId, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE, {
1654
+ commandId: options.commandId || device.activeLiveStream.commandId,
1655
+ width: options.maxWidth || 960,
1656
+ height: options.maxHeight || 540,
1657
+ fps: options.fps || device.activeLiveStream.fps
1658
+ });
1659
+ device.latestLiveFrame = frame;
1660
+ rememberRecentFramePayload(device, 'live', frame);
1661
+ emitFrame({
1662
+ kind: 'live',
1663
+ deviceId: device.deviceId,
1664
+ frame: serializeRemoteFrame(frame, device.deviceId, 'live', { includeDataUrl: false }),
1665
+ payload: frame.payload,
1666
+ mimeType: frame.mimeType,
1667
+ byteLength: frame.byteLength
1668
+ });
1669
+ device.activeLiveStream.lastFrameAt = frame.receivedAt;
1670
+ device.activeLiveStream.lastFrameSeq = frame.frameSeq;
1671
+ device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
1672
+ device.lastSeenAt = frame.receivedAt;
1673
+ device.counters.liveFramesReceived += 1;
1674
+ emitRemoteEvent('RemoteFrameReceived', device, {
1675
+ streamId: frame.streamId,
1676
+ frameSeq: frame.frameSeq,
1677
+ synthetic: true
1678
+ });
1679
+ return frame;
1680
+ }
1681
+
1682
+ function closeExistingDeviceSocket(deviceId, nextSessionId) {
1683
+ const existing = devices.get(deviceId);
1684
+ if (!existing?.socket || existing.socket.destroyed) {
1685
+ return;
1686
+ }
1687
+
1688
+ existing.lastDisconnectReason = 'replaced-by-new-session';
1689
+ failAllPendingTasks(existing, 'replaced-by-new-session');
1690
+ writeJsonLine(existing.socket, {
1691
+ type: 'disconnect',
1692
+ reason: 'replaced-by-new-session',
1693
+ nextSessionId
1694
+ });
1695
+ existing.socket.destroy();
1696
+ }
1697
+
1698
+ function getDeviceActivityMs(device) {
1699
+ return Math.max(
1700
+ Date.parse(device?.lastSeenAt || '') || 0,
1701
+ Date.parse(device?.lastStatusAt || '') || 0,
1702
+ Date.parse(device?.connectedAt || '') || 0
1703
+ );
1704
+ }
1705
+
1706
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
1707
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
1708
+ return false;
1709
+ }
1710
+
1711
+ const lastActivityMs = getDeviceActivityMs(device);
1712
+ if (!lastActivityMs) {
1713
+ return true;
1714
+ }
1715
+
1716
+ const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
1717
+ return nowMs - lastActivityMs <= freshnessMs;
1718
+ }
1719
+
1720
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
1721
+ const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
1722
+ writeJsonLine(socket, {
1723
+ type: 'disconnect',
1724
+ reason: 'duplicate-device-active',
1725
+ retryAfterMs,
1726
+ activeSessionId: existing.sessionId,
1727
+ attemptedSessionId
1728
+ });
1729
+
1730
+ existing.counters.duplicateConnectionsRejected = (existing.counters.duplicateConnectionsRejected || 0) + 1;
1731
+ const nowMs = Date.now();
1732
+ const lastLoggedAt = duplicateDeviceLogAt.get(existing.deviceId) || 0;
1733
+ if (nowMs - lastLoggedAt >= duplicateDeviceLogThrottleMs) {
1734
+ duplicateDeviceLogAt.set(existing.deviceId, nowMs);
1735
+ logEvent(
1736
+ 'remote',
1737
+ `duplicate device connection suppressed ${existing.deviceName} (${existing.deviceId}); keeping active session ${existing.sessionId}`,
1738
+ 'remote');
1739
+ }
1740
+
1741
+ try {
1742
+ socket.end?.();
1743
+ } catch {
1744
+ // Best-effort graceful close before hard destroy.
1745
+ }
1746
+
1747
+ setTimeout(() => {
1748
+ try {
1749
+ socket.destroy?.();
1750
+ } catch {
1751
+ // Ignore destroy failures.
1752
+ }
1753
+ }, 25).unref?.();
1754
+ }
1755
+
1756
+ function clearSyntheticFleet() {
1757
+ let removed = 0;
1758
+ for (const [deviceId, device] of devices.entries()) {
1759
+ if (device.synthetic === true) {
1760
+ devices.delete(deviceId);
1761
+ removed += 1;
1762
+ }
1763
+ }
1764
+
1765
+ return { ok: true, removed, total: devices.size };
1766
+ }
1767
+
1768
+ function seedSyntheticFleet(options = {}) {
1769
+ const count = clampNumber(options.count, 1, MAX_SYNTHETIC_DEVICES, 250);
1770
+ const connectedRatio = Math.max(0, Math.min(1, Number(options.connectedRatio ?? 0.88)));
1771
+ const thumbnailRatio = Math.max(0, Math.min(1, Number(options.thumbnailRatio ?? 0.7)));
1772
+ const aiAssistRatio = Math.max(0, Math.min(1, Number(options.aiAssistRatio ?? 0.35)));
1773
+ const liveCount = clampNumber(options.liveCount, 0, Math.min(count, 24), Math.min(6, count));
1774
+ const replace = options.replace !== false;
1775
+ const now = new Date();
1776
+ const platforms = ['win32', 'linux', 'darwin'];
1777
+ const machineKinds = ['Mac mini', 'Mini PC', 'Workstation', 'Render node', 'Dev box'];
1778
+ const workloadRoles = ['LLM worker', 'Image worker', 'Video worker', 'Idle reserve', 'Build worker'];
1779
+ const gpuNames = ['Apple M-series GPU', 'NVIDIA RTX local', 'Radeon Pro', 'Intel Arc', 'Integrated GPU'];
1780
+ const npuNames = ['Apple Neural Engine', 'Ryzen AI NPU', 'Intel AI Boost', 'Qualcomm Hexagon', 'NPU not present'];
1781
+
1782
+ if (replace) {
1783
+ clearSyntheticFleet();
1784
+ }
1785
+
1786
+ let connected = 0;
1787
+ let aiAssist = 0;
1788
+ let live = 0;
1789
+ for (let index = 0; index < count; index += 1) {
1790
+ const ordinal = index + 1;
1791
+ const deviceId = `synthetic-${String(ordinal).padStart(4, '0')}`;
1792
+ const isConnected = index / count < connectedRatio;
1793
+ const hasThumbnail = index / count < thumbnailRatio;
1794
+ const hasAiAssist = index / count < aiAssistRatio;
1795
+ const isLive = isConnected && index < liveCount;
1796
+ const seenAt = new Date(now.getTime() - index * 1350).toISOString();
1797
+ const connectedAt = new Date(now.getTime() - (index + 8) * 60000).toISOString();
1798
+ const platform = platforms[index % platforms.length];
1799
+ const usedMemRatio = Number((0.18 + (index % 71) / 100).toFixed(2));
1800
+ const load1 = Number(((index % 19) / 3).toFixed(2));
1801
+ const cpuCores = platform === 'darwin' ? 10 : index % 3 === 0 ? 24 : 12;
1802
+ const cpuUsageRatio = Number(Math.min(0.96, Math.max(0.04, load1 / Math.max(1, cpuCores))).toFixed(2));
1803
+ const diskTotalBytes = (index % 4 === 0 ? 4 : 2) * 1024 ** 4;
1804
+ const diskUsedRatio = Number((0.22 + (index % 61) / 100).toFixed(2));
1805
+ const diskFreeBytes = Math.round(diskTotalBytes * (1 - diskUsedRatio));
1806
+ const temperatureC = 42 + (index % 48);
1807
+ const gpuUsageRatio = Number(Math.min(0.98, Math.max(0.03, ((index * 7) % 89) / 100)).toFixed(2));
1808
+ const gpuMemoryTotalBytes = (platform === 'darwin' ? 32 : index % 3 === 0 ? 24 : 12) * 1024 ** 3;
1809
+ const gpuMemoryUsedRatio = Number((0.2 + ((index * 5) % 67) / 100).toFixed(2));
1810
+ const gpuMemoryFreeBytes = Math.round(gpuMemoryTotalBytes * (1 - gpuMemoryUsedRatio));
1811
+ const npuUsageRatio = index % 5 === 4
1812
+ ? undefined
1813
+ : Number(Math.min(0.95, Math.max(0.02, ((index * 11) % 73) / 100)).toFixed(2));
1814
+ const npuTops = index % 5 === 4 ? 0 : platform === 'darwin' ? 38 : index % 2 === 0 ? 45 : 16;
1815
+ const machineKind = machineKinds[index % machineKinds.length];
1816
+ const workloadRole = workloadRoles[index % workloadRoles.length];
1817
+ const toolBusy = index % 9 === 0;
1818
+ const toolOnline = index % 4 !== 3;
1819
+ const taskStatus = index % 17 === 0
1820
+ ? 'failed'
1821
+ : index % 5 === 0
1822
+ ? 'completed'
1823
+ : index % 7 === 0
1824
+ ? 'queued'
1825
+ : '';
1826
+ const latestTask = taskStatus
1827
+ ? {
1828
+ taskId: `synthetic-task-${ordinal}`,
1829
+ commandId: `synthetic-command-${ordinal}`,
1830
+ title: taskStatus === 'failed' ? 'Synthetic issue check' : 'Synthetic status task',
1831
+ instructionPreview: 'Synthetic fleet scale validation task.',
1832
+ status: taskStatus,
1833
+ approvalLevel: hasAiAssist ? 'ai-assist' : 'task-only',
1834
+ requestedAt: seenAt,
1835
+ sentAt: seenAt,
1836
+ updatedAt: seenAt,
1837
+ completedAt: taskStatus === 'completed' || taskStatus === 'failed' ? seenAt : '',
1838
+ error: taskStatus === 'failed' ? 'Synthetic task failure sample.' : '',
1839
+ resultKind: 'synthetic-agent-task',
1840
+ resultModel: hasAiAssist ? 'synthetic-ai' : '',
1841
+ resultResponseId: hasAiAssist ? `synthetic-response-${ordinal}` : '',
1842
+ resultSummary: taskStatus === 'failed'
1843
+ ? 'Synthetic task reported a sample issue.'
1844
+ : 'Synthetic task completed for scale validation.'
1845
+ }
1846
+ : null;
1847
+ const device = {
1848
+ socket: null,
1849
+ synthetic: true,
1850
+ deviceId,
1851
+ sessionId: `synthetic-session-${ordinal}`,
1852
+ deviceName: `${machineKind} ${String(ordinal).padStart(3, '0')}`,
1853
+ hostname: `synthetic-host-${String(ordinal).padStart(4, '0')}`,
1854
+ platform,
1855
+ arch: index % 4 === 0 ? 'arm64' : 'x64',
1856
+ pid: 0,
1857
+ agentVersion: 'synthetic-scale',
1858
+ capabilities: {
1859
+ status: true,
1860
+ thumbnail: hasThumbnail,
1861
+ control: false,
1862
+ liveStream: true,
1863
+ computerAgent: true,
1864
+ taskDispatch: true,
1865
+ aiAssist: hasAiAssist,
1866
+ aiModel: hasAiAssist ? 'synthetic-ai' : '',
1867
+ aiProvider: hasAiAssist ? 'synthetic' : ''
1868
+ },
1869
+ connected: isConnected,
1870
+ connectedAt: isConnected ? connectedAt : '',
1871
+ disconnectedAt: isConnected ? '' : seenAt,
1872
+ lastSeenAt: seenAt,
1873
+ lastStatusAt: seenAt,
1874
+ lastDisconnectReason: isConnected ? '' : 'synthetic-offline',
1875
+ remoteAddress: 'synthetic.local',
1876
+ remotePort: 0,
1877
+ status: {
1878
+ platform,
1879
+ release: platform === 'win32' ? 'Windows 11' : platform === 'darwin' ? 'macOS' : 'Linux',
1880
+ uptimeSec: (ordinal * 791) % 1209600,
1881
+ role: workloadRole,
1882
+ usedMemRatio,
1883
+ totalMem: cpuCores >= 20 ? 128 * 1024 ** 3 : 64 * 1024 ** 3,
1884
+ freeMem: Math.round((cpuCores >= 20 ? 128 * 1024 ** 3 : 64 * 1024 ** 3) * (1 - usedMemRatio)),
1885
+ loadavg: [load1, Math.max(0, load1 - 0.2), Math.max(0, load1 - 0.4)],
1886
+ cpu: {
1887
+ cores: cpuCores,
1888
+ usageRatio: cpuUsageRatio,
1889
+ load1
1890
+ },
1891
+ memory: {
1892
+ usedRatio: usedMemRatio
1893
+ },
1894
+ disk: {
1895
+ root: platform === 'win32' ? 'C:\\' : '/',
1896
+ totalBytes: diskTotalBytes,
1897
+ freeBytes: diskFreeBytes,
1898
+ usedRatio: diskUsedRatio
1899
+ },
1900
+ gpu: {
1901
+ name: gpuNames[index % gpuNames.length],
1902
+ usageRatio: gpuUsageRatio,
1903
+ temperatureC: temperatureC + 3,
1904
+ memory: {
1905
+ totalBytes: gpuMemoryTotalBytes,
1906
+ freeBytes: gpuMemoryFreeBytes,
1907
+ usedRatio: gpuMemoryUsedRatio
1908
+ }
1909
+ },
1910
+ npu: {
1911
+ name: npuNames[index % npuNames.length],
1912
+ usageRatio: npuUsageRatio,
1913
+ tops: npuTops
1914
+ },
1915
+ thermal: {
1916
+ cpuC: temperatureC,
1917
+ gpuC: temperatureC + 3
1918
+ },
1919
+ network: {
1920
+ link: index % 6 === 0 ? '2.5GbE' : '1GbE',
1921
+ latencyMs: 1 + (index % 12),
1922
+ rxMbps: 35 + ((index * 13) % 640),
1923
+ txMbps: 12 + ((index * 17) % 220),
1924
+ usageRatio: Number(Math.min(0.94, ((index * 9) % 81) / 100).toFixed(2))
1925
+ },
1926
+ workload: {
1927
+ role: workloadRole,
1928
+ status: taskStatus || (toolBusy ? 'running' : 'idle'),
1929
+ title: taskStatus ? 'Fleet validation task' : toolBusy ? 'Processing local job' : 'idle',
1930
+ summary: taskStatus ? 'Synthetic status for fleet-scale validation.' : ''
1931
+ },
1932
+ tools: {
1933
+ ollama: {
1934
+ status: toolOnline ? (toolBusy ? 'busy' : 'online') : 'offline',
1935
+ port: 11434,
1936
+ version: toolOnline ? 'local' : ''
1937
+ },
1938
+ lmStudio: {
1939
+ status: index % 5 === 0 ? 'online' : 'offline',
1940
+ port: 1234
1941
+ },
1942
+ comfyui: {
1943
+ status: index % 3 === 0 ? (toolBusy ? 'busy' : 'online') : 'offline',
1944
+ port: 8188
1945
+ },
1946
+ stableDiffusion: {
1947
+ status: index % 7 === 0 ? 'online' : 'offline',
1948
+ port: 7860
1949
+ }
1950
+ }
1951
+ },
1952
+ latestThumbnail: null,
1953
+ latestLiveFrame: null,
1954
+ recentFramePayloads: {
1955
+ thumbnail: [],
1956
+ live: []
1957
+ },
1958
+ activeLiveStream: null,
1959
+ activeAudioStream: null,
1960
+ latestAudioFrame: null,
1961
+ latestTask,
1962
+ recentTasks: latestTask ? [latestTask] : [],
1963
+ pendingTaskCommands: new Map(),
1964
+ pendingTaskTimers: new Map(),
1965
+ counters: createDeviceCounters({
1966
+ messagesReceived: 1,
1967
+ statusReceived: 1,
1968
+ tasksQueued: latestTask ? 1 : 0,
1969
+ taskResultsReceived: latestTask && ['completed', 'failed'].includes(latestTask.status) ? 1 : 0,
1970
+ taskResultsFailed: latestTask?.status === 'failed' ? 1 : 0
1971
+ })
1972
+ };
1973
+
1974
+ if (hasThumbnail) {
1975
+ const frame = makeSyntheticFrame(device, `synthetic-thumb-${ordinal}`, 'thumbnail', {
1976
+ width: 360,
1977
+ height: 220
1978
+ });
1979
+ delete frame.mode;
1980
+ delete frame.fps;
1981
+ frame.receivedAt = seenAt;
1982
+ frame.capturedAt = seenAt;
1983
+ device.latestThumbnail = frame;
1984
+ rememberRecentFramePayload(device, 'thumbnail', frame);
1985
+ device.counters.thumbnailFramesReceived = 1;
1986
+ }
1987
+
1988
+ if (isLive) {
1989
+ const transfer = buildRemoteFrameTransferDescriptor(DEFAULT_REMOTE_FRAME_MODE);
1990
+ device.activeLiveStream = {
1991
+ streamId: `synthetic-live-${ordinal}`,
1992
+ commandId: `synthetic-live-command-${ordinal}`,
1993
+ active: true,
1994
+ open: true,
1995
+ openedAt: seenAt,
1996
+ openTransport: 'synthetic',
1997
+ mode: transfer.mode,
1998
+ frameMode: transfer.frameMode,
1999
+ frameProfile: transfer.frameProfile,
2000
+ encoding: transfer.encoding,
2001
+ codec: transfer.codec,
2002
+ compression: transfer.compression,
2003
+ transferProtocol: transfer.transferProtocol,
2004
+ transferProtocolVersion: transfer.transferProtocolVersion,
2005
+ handshake: transfer.handshake,
2006
+ fps: 20,
2007
+ startedAt: seenAt,
2008
+ stoppedAt: '',
2009
+ stopReason: '',
2010
+ lastFrameAt: '',
2011
+ lastFrameSeq: 0,
2012
+ framesReceived: 0
2013
+ };
2014
+ applySyntheticLiveFrame(device, device.activeLiveStream.streamId, {
2015
+ commandId: device.activeLiveStream.commandId,
2016
+ fps: 20
2017
+ });
2018
+ live += 1;
2019
+ }
2020
+
2021
+ if (isConnected) {
2022
+ connected += 1;
2023
+ }
2024
+ if (hasAiAssist) {
2025
+ aiAssist += 1;
2026
+ }
2027
+ devices.set(deviceId, device);
2028
+ }
2029
+
2030
+ emitRemoteEvent('RemoteSyntheticFleetSeeded', null, {
2031
+ count,
2032
+ connected,
2033
+ aiAssist,
2034
+ live
2035
+ });
2036
+ return {
2037
+ ok: true,
2038
+ synthetic: true,
2039
+ seeded: count,
2040
+ connected,
2041
+ aiAssist,
2042
+ live,
2043
+ total: devices.size,
2044
+ max: MAX_SYNTHETIC_DEVICES
2045
+ };
2046
+ }
2047
+
2048
+ function attachDevice(socket, hello) {
2049
+ const nowMs = Date.now();
2050
+ const now = new Date(nowMs).toISOString();
2051
+ const sessionId = crypto.randomUUID();
2052
+ const deviceId = normalizeDeviceId(hello.deviceId);
2053
+ const existing = devices.get(deviceId);
2054
+
2055
+ if (isDeviceConnectionFresh(existing, nowMs)) {
2056
+ rejectDuplicateActiveDevice(socket, existing, sessionId);
2057
+ return null;
2058
+ }
2059
+
2060
+ closeExistingDeviceSocket(deviceId, sessionId);
2061
+
2062
+ const frameProtocol = typeof hello.frameProtocol === 'object' && hello.frameProtocol
2063
+ ? { ...hello.frameProtocol }
2064
+ : null;
2065
+ const frameModes = Array.isArray(hello.frameModes)
2066
+ ? hello.frameModes.map(normalizeIncomingFrameModeProfile).filter(Boolean)
2067
+ : [];
2068
+ const capabilities = typeof hello.capabilities === 'object' && hello.capabilities
2069
+ ? { ...hello.capabilities }
2070
+ : {};
2071
+ if (!capabilities.frameProtocol) {
2072
+ capabilities.frameProtocol = frameProtocol || buildRemoteFrameProtocolDescriptor();
2073
+ }
2074
+ if (!capabilities.frameModes) {
2075
+ capabilities.frameModes = frameModes.length > 0
2076
+ ? frameModes.map(profile => ({ ...profile }))
2077
+ : getSupportedRemoteFrameModeProfiles();
2078
+ }
2079
+
2080
+ const device = {
2081
+ socket,
2082
+ deviceId,
2083
+ sessionId,
2084
+ deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
2085
+ hostname: safeString(hello.hostname, 120),
2086
+ platform: safeString(hello.platform, 80),
2087
+ arch: safeString(hello.arch, 40),
2088
+ slotNumber: normalizeSlotNumber(hello.slotNumber ?? hello.slot ?? hello.computerNumber),
2089
+ pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
2090
+ agentVersion: safeString(hello.agentVersion, 40),
2091
+ protocol: safeString(hello.protocol, 80) || REMOTE_AGENT_PROTOCOL,
2092
+ protocolVersion: Number.isFinite(Number(hello.protocolVersion)) ? Math.max(1, Math.floor(Number(hello.protocolVersion))) : 1,
2093
+ frameProtocol: frameProtocol || buildRemoteFrameProtocolDescriptor(),
2094
+ frameModes: frameModes.length > 0 ? frameModes : getSupportedRemoteFrameModeProfiles(),
2095
+ capabilities,
2096
+ connected: true,
2097
+ connectedAt: now,
2098
+ disconnectedAt: '',
2099
+ lastSeenAt: now,
2100
+ lastStatusAt: '',
2101
+ lastDisconnectReason: '',
2102
+ remoteAddress: socket.remoteAddress || '',
2103
+ remotePort: socket.remotePort || 0,
2104
+ status: {},
2105
+ latestThumbnail: null,
2106
+ latestLiveFrame: null,
2107
+ recentFramePayloads: {
2108
+ thumbnail: [],
2109
+ live: []
2110
+ },
2111
+ activeLiveStream: null,
2112
+ activeAudioStream: null,
2113
+ latestAudioFrame: null,
2114
+ latestTask: null,
2115
+ recentTasks: [],
2116
+ pendingTaskCommands: new Map(),
2117
+ pendingTaskTimers: new Map(),
2118
+ synthetic: false,
2119
+ counters: createDeviceCounters({ messagesReceived: 1 })
2120
+ };
2121
+
2122
+ devices.set(deviceId, device);
2123
+ sockets.set(socket, deviceId);
2124
+ writeJsonLine(socket, {
2125
+ type: 'welcome',
2126
+ protocol: REMOTE_AGENT_PROTOCOL,
2127
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
2128
+ sessionId,
2129
+ deviceId,
2130
+ heartbeatMs,
2131
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2132
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2133
+ serverTime: now
2134
+ });
2135
+
2136
+ logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
2137
+ emitRemoteEvent('RemoteDeviceConnected', device);
2138
+ return device;
2139
+ }
2140
+
2141
+ function detachSocket(socket, reason = 'socket-closed') {
2142
+ const deviceId = sockets.get(socket);
2143
+ sockets.delete(socket);
2144
+ if (!deviceId) {
2145
+ return;
2146
+ }
2147
+
2148
+ const device = devices.get(deviceId);
2149
+ if (!device || device.socket !== socket) {
2150
+ return;
2151
+ }
2152
+
2153
+ device.connected = false;
2154
+ device.socket = null;
2155
+ device.disconnectedAt = new Date().toISOString();
2156
+ device.lastDisconnectReason = reason;
2157
+ if (device.activeLiveStream) {
2158
+ device.activeLiveStream.active = false;
2159
+ device.activeLiveStream.stoppedAt = device.disconnectedAt;
2160
+ device.activeLiveStream.stopReason = reason;
2161
+ }
2162
+ failAllPendingTasks(device, 'device-disconnected');
2163
+ logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2164
+ emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2165
+ }
2166
+
2167
+ function rememberDeviceTask(device, task) {
2168
+ if (!device || !task) {
2169
+ return null;
2170
+ }
2171
+
2172
+ const existingIndex = device.recentTasks.findIndex(item =>
2173
+ item.taskId === task.taskId || item.commandId === task.commandId);
2174
+ if (existingIndex >= 0) {
2175
+ device.recentTasks.splice(existingIndex, 1);
2176
+ }
2177
+
2178
+ device.recentTasks.unshift(task);
2179
+ if (device.recentTasks.length > RECENT_TASK_LIMIT) {
2180
+ device.recentTasks.length = RECENT_TASK_LIMIT;
2181
+ }
2182
+
2183
+ device.latestTask = task;
2184
+ if (task.commandId) {
2185
+ device.pendingTaskCommands.set(task.commandId, task);
2186
+ }
2187
+
2188
+ return task;
2189
+ }
2190
+
2191
+ function trimTaskBatches() {
2192
+ const ordered = [...taskBatches.values()]
2193
+ .sort((left, right) => String(right.requestedAt || '').localeCompare(String(left.requestedAt || '')));
2194
+ for (const batch of ordered.slice(RECENT_TASK_BATCH_LIMIT)) {
2195
+ taskBatches.delete(batch.batchId);
2196
+ }
2197
+ }
2198
+
2199
+ function beginTaskBatch(deviceIds, options = {}) {
2200
+ const targets = [...new Set((deviceIds || [])
2201
+ .map(deviceId => safeString(deviceId, 128))
2202
+ .filter(Boolean))]
2203
+ .slice(0, 500);
2204
+ const now = new Date().toISOString();
2205
+ const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
2206
+ const batchId = safeString(options.batchId, 128) || crypto.randomUUID();
2207
+ const title = safeString(options.title, 120)
2208
+ || safeString(instruction.split(/\r?\n/)[0], 120)
2209
+ || 'Remote task batch';
2210
+ const batch = {
2211
+ batchId,
2212
+ title,
2213
+ instructionPreview: safeText(instruction, 320),
2214
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
2215
+ status: targets.length > 0 ? 'running' : 'failed',
2216
+ requestedAt: now,
2217
+ updatedAt: now,
2218
+ total: targets.length,
2219
+ queued: 0,
2220
+ pending: 0,
2221
+ completed: 0,
2222
+ failed: targets.length > 0 ? 0 : 1,
2223
+ timedOut: 0,
2224
+ results: targets.map(deviceId => ({
2225
+ deviceId,
2226
+ ok: false,
2227
+ status: 'targeted',
2228
+ error: '',
2229
+ commandId: '',
2230
+ taskId: '',
2231
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
2232
+ updatedAt: now
2233
+ }))
2234
+ };
2235
+
2236
+ taskBatches.set(batchId, batch);
2237
+ trimTaskBatches();
2238
+ return batch;
2239
+ }
2240
+
2241
+ function getTaskBatchItem(batch, deviceId) {
2242
+ if (!batch) {
2243
+ return null;
2244
+ }
2245
+
2246
+ const id = safeString(deviceId, 128);
2247
+ if (!id) {
2248
+ return null;
2249
+ }
2250
+
2251
+ let item = batch.results.find(result => result.deviceId === id);
2252
+ if (!item) {
2253
+ item = {
2254
+ deviceId: id,
2255
+ ok: false,
2256
+ status: 'targeted',
2257
+ error: '',
2258
+ commandId: '',
2259
+ taskId: '',
2260
+ approvalLevel: batch.approvalLevel,
2261
+ updatedAt: new Date().toISOString()
2262
+ };
2263
+ batch.results.push(item);
2264
+ batch.total = batch.results.length;
2265
+ }
2266
+
2267
+ return item;
2268
+ }
2269
+
2270
+ function recalculateTaskBatch(batch) {
2271
+ if (!batch) {
2272
+ return null;
2273
+ }
2274
+
2275
+ const results = Array.isArray(batch.results) ? batch.results : [];
2276
+ batch.total = results.length;
2277
+ batch.queued = results.filter(item => item.ok === true).length;
2278
+ batch.completed = results.filter(item => item.status === 'completed').length;
2279
+ batch.failed = results.filter(item => item.status === 'failed' || (item.ok === false && !!item.error)).length;
2280
+ batch.timedOut = results.filter(item => item.error === 'task-timeout').length;
2281
+ batch.pending = results.filter(item =>
2282
+ item.ok === true
2283
+ && item.status !== 'completed'
2284
+ && item.status !== 'failed').length;
2285
+ batch.updatedAt = new Date().toISOString();
2286
+
2287
+ if (batch.total === 0) {
2288
+ batch.status = 'failed';
2289
+ } else if (batch.completed + batch.failed >= batch.total) {
2290
+ batch.status = batch.failed > 0 ? 'completed-with-failures' : 'completed';
2291
+ } else if (batch.queued > 0) {
2292
+ batch.status = 'running';
2293
+ } else {
2294
+ batch.status = 'failed';
2295
+ }
2296
+
2297
+ return batch;
2298
+ }
2299
+
2300
+ function syncTaskBatchFromTask(device, task) {
2301
+ const batchId = safeString(task?.batchId, 128);
2302
+ if (!batchId) {
2303
+ return null;
2304
+ }
2305
+
2306
+ const batch = taskBatches.get(batchId);
2307
+ if (!batch) {
2308
+ return null;
2309
+ }
2310
+
2311
+ const item = getTaskBatchItem(batch, device?.deviceId);
2312
+ if (!item) {
2313
+ return null;
2314
+ }
2315
+
2316
+ item.ok = true;
2317
+ item.status = safeString(task.status, 40) || 'queued';
2318
+ item.error = safeString(task.error, 500);
2319
+ item.commandId = safeString(task.commandId, 128);
2320
+ item.taskId = safeString(task.taskId, 128);
2321
+ item.approvalLevel = normalizeApprovalLevel(task.approvalLevel || batch.approvalLevel);
2322
+ item.updatedAt = task.updatedAt || task.completedAt || task.sentAt || new Date().toISOString();
2323
+ return recalculateTaskBatch(batch);
2324
+ }
2325
+
2326
+ function syncTaskBatchDispatchFailure(batchId, deviceId, error, extra = {}) {
2327
+ const batch = taskBatches.get(safeString(batchId, 128));
2328
+ if (!batch) {
2329
+ return null;
2330
+ }
2331
+
2332
+ const item = getTaskBatchItem(batch, deviceId);
2333
+ if (!item) {
2334
+ return null;
2335
+ }
2336
+
2337
+ item.ok = false;
2338
+ item.status = 'failed';
2339
+ item.error = safeString(error, 500) || 'task-dispatch-failed';
2340
+ item.commandId = safeString(extra.commandId, 128);
2341
+ item.taskId = safeString(extra.taskId, 128);
2342
+ item.approvalLevel = normalizeApprovalLevel(extra.approvalLevel || batch.approvalLevel);
2343
+ item.updatedAt = new Date().toISOString();
2344
+ return recalculateTaskBatch(batch);
2345
+ }
2346
+
2347
+ function clearTaskTimeout(device, commandId) {
2348
+ const key = safeString(commandId, 128);
2349
+ if (!device?.pendingTaskTimers || !key) {
2350
+ return;
2351
+ }
2352
+
2353
+ const timer = device.pendingTaskTimers.get(key);
2354
+ if (timer) {
2355
+ clearTimeout(timer);
2356
+ }
2357
+ device.pendingTaskTimers.delete(key);
2358
+ }
2359
+
2360
+ function failPendingTask(device, commandId, error = 'task-timeout') {
2361
+ const key = safeString(commandId, 128);
2362
+ if (!device?.pendingTaskCommands || !key) {
2363
+ return null;
2364
+ }
2365
+
2366
+ const task = device.pendingTaskCommands.get(key);
2367
+ if (!task) {
2368
+ clearTaskTimeout(device, key);
2369
+ return null;
2370
+ }
2371
+
2372
+ const now = new Date().toISOString();
2373
+ clearTaskTimeout(device, key);
2374
+ task.status = 'failed';
2375
+ task.updatedAt = now;
2376
+ task.completedAt = now;
2377
+ task.error = safeString(error, 500) || 'task-timeout';
2378
+ task.resultSummary = task.error;
2379
+ task.resultKind = task.resultKind || 'agent-task';
2380
+ device.latestTask = task;
2381
+ device.pendingTaskCommands.delete(key);
2382
+ device.counters.taskResultsReceived += 1;
2383
+ device.counters.taskResultsFailed += 1;
2384
+ if (task.error === 'task-timeout') {
2385
+ device.counters.taskResultsTimedOut += 1;
2386
+ }
2387
+ syncTaskBatchFromTask(device, task);
2388
+ emitRemoteEvent('RemoteTaskResult', device, {
2389
+ commandId: key,
2390
+ taskId: task.taskId,
2391
+ status: task.status,
2392
+ error: task.error
2393
+ });
2394
+ return task;
2395
+ }
2396
+
2397
+ function failAllPendingTasks(device, error = 'device-disconnected') {
2398
+ if (!device?.pendingTaskCommands) {
2399
+ return 0;
2400
+ }
2401
+
2402
+ const commandIds = [...device.pendingTaskCommands.keys()];
2403
+ let failed = 0;
2404
+ for (const commandId of commandIds) {
2405
+ if (failPendingTask(device, commandId, error)) {
2406
+ failed += 1;
2407
+ }
2408
+ }
2409
+ return failed;
2410
+ }
2411
+
2412
+ function scheduleTaskTimeout(device, task) {
2413
+ const commandId = safeString(task?.commandId, 128);
2414
+ if (!device?.pendingTaskTimers || !commandId) {
2415
+ return;
2416
+ }
2417
+
2418
+ clearTaskTimeout(device, commandId);
2419
+ const timer = setTimeout(() => {
2420
+ failPendingTask(device, commandId, 'task-timeout');
2421
+ }, taskTimeoutMs);
2422
+ timer.unref?.();
2423
+ device.pendingTaskTimers.set(commandId, timer);
2424
+ }
2425
+
2426
+ function summarizeTaskResult(result) {
2427
+ if (result && typeof result === 'object') {
2428
+ return safeText(
2429
+ result.summary
2430
+ || result.message
2431
+ || result.output
2432
+ || result.result
2433
+ || JSON.stringify(result),
2434
+ MAX_AGENT_TASK_RESULT_CHARS);
2435
+ }
2436
+
2437
+ return safeText(result ?? '', MAX_AGENT_TASK_RESULT_CHARS);
2438
+ }
2439
+
2440
+ function applyTaskResult(device, commandId, result, error) {
2441
+ if (!device || !commandId) {
2442
+ return null;
2443
+ }
2444
+
2445
+ const resultTaskId = result && typeof result === 'object'
2446
+ ? safeString(result.taskId, 128)
2447
+ : '';
2448
+ const task = resultTaskId
2449
+ ? device.recentTasks.find(item => item.taskId === resultTaskId)
2450
+ : device.pendingTaskCommands.get(commandId);
2451
+ if (!task) {
2452
+ return null;
2453
+ }
2454
+
2455
+ if (device.pendingTaskCommands.get(commandId) !== task) {
2456
+ return null;
2457
+ }
2458
+
2459
+ const now = device.lastSeenAt || new Date().toISOString();
2460
+ const status = error
2461
+ ? 'failed'
2462
+ : safeString(result?.status, 40) || 'completed';
2463
+ clearTaskTimeout(device, commandId);
2464
+ task.status = status;
2465
+ task.updatedAt = now;
2466
+ task.completedAt = safeString(result?.completedAt, 80) || now;
2467
+ task.error = error;
2468
+ task.resultSummary = error || summarizeTaskResult(result);
2469
+ task.resultKind = safeString(result?.kind || result?.mode || 'agent-task', 80);
2470
+ task.resultModel = safeString(result?.model || result?.aiModel || '', 120);
2471
+ task.resultResponseId = safeString(result?.responseId || result?.id || '', 160);
2472
+ device.latestTask = task;
2473
+ device.pendingTaskCommands.delete(commandId);
2474
+ device.counters.taskResultsReceived += 1;
2475
+ if (error) {
2476
+ device.counters.taskResultsFailed += 1;
2477
+ }
2478
+ syncTaskBatchFromTask(device, task);
2479
+
2480
+ return task;
2481
+ }
2482
+
2483
+ function normalizeFrameByteLength(framePayload, frameData = '') {
2484
+ if (Buffer.isBuffer(framePayload)) {
2485
+ return framePayload.length;
2486
+ }
2487
+
2488
+ return Math.floor(String(frameData || '').length * 3 / 4);
2489
+ }
2490
+
2491
+ function buildFrameDataUrl(framePayload, frameData, mimeType) {
2492
+ if (Buffer.isBuffer(framePayload)) {
2493
+ return `data:${mimeType};base64,${framePayload.toString('base64')}`;
2494
+ }
2495
+
2496
+ return String(frameData || '').startsWith('data:')
2497
+ ? String(frameData || '')
2498
+ : `data:${mimeType};base64,${frameData}`;
2499
+ }
2500
+
2501
+ function buildFramePayloadBuffer(framePayload, frameData) {
2502
+ if (Buffer.isBuffer(framePayload)) {
2503
+ return Buffer.from(framePayload);
2504
+ }
2505
+
2506
+ const raw = String(frameData || '');
2507
+ const commaIndex = raw.indexOf(',');
2508
+ const base64 = raw.startsWith('data:') && commaIndex >= 0
2509
+ ? raw.slice(commaIndex + 1)
2510
+ : raw;
2511
+ return Buffer.from(base64, 'base64');
2512
+ }
2513
+
2514
+ function buildFrameContentHash(message, payload) {
2515
+ const provided = safeString(message.contentHash || message.frameHash || message.hash, 96)
2516
+ .toLowerCase()
2517
+ .replace(/[^a-z0-9:_-]/g, '')
2518
+ .slice(0, 64);
2519
+ if (provided) {
2520
+ return provided;
2521
+ }
2522
+
2523
+ if (!Buffer.isBuffer(payload) || payload.length === 0) {
2524
+ return '';
2525
+ }
2526
+
2527
+ return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);
2528
+ }
2529
+
2530
+ function readFrameCaptureMs(message) {
2531
+ const value = Number(message.captureMs);
2532
+ return Number.isFinite(value) && value >= 0
2533
+ ? Math.round(value)
2534
+ : 0;
2535
+ }
2536
+
2537
+ function computeSameContentStreak(previousFrame, contentHash) {
2538
+ if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
2539
+ return 0;
2540
+ }
2541
+
2542
+ return clampNumber(Number(previousFrame.sameContentStreak || 0) + 1, 0, 1000000, 1);
2543
+ }
2544
+
2545
+ function applyThumbnailFrame(device, message, framePayload, transport = 'json-base64') {
2546
+ const frameData = Buffer.isBuffer(framePayload)
2547
+ ? ''
2548
+ : safeString(framePayload, MAX_THUMBNAIL_BASE64_CHARS + 1);
2549
+ const frameSeq = Number(message.frameSeq);
2550
+ const byteLength = normalizeFrameByteLength(framePayload, frameData);
2551
+ if ((!Buffer.isBuffer(framePayload) && !frameData)
2552
+ || byteLength > MAX_THUMBNAIL_BINARY_BYTES
2553
+ || !Number.isFinite(frameSeq)) {
2554
+ device.counters.thumbnailFramesDropped += 1;
2555
+ emitRemoteEvent('RemoteFrameDropped', device, {
2556
+ reason: 'invalid-thumbnail-frame',
2557
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
2558
+ transport
2559
+ });
2560
+ return false;
2561
+ }
2562
+
2563
+ const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
2564
+ const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
2565
+ const payload = buildFramePayloadBuffer(framePayload, frameData);
2566
+ const contentHash = buildFrameContentHash(message, payload);
2567
+ const sameContentStreak = computeSameContentStreak(device.latestThumbnail, contentHash);
2568
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, 'thumbnail');
2569
+ device.latestThumbnail = {
2570
+ streamId: safeString(message.streamId, 128) || 'thumbnail',
2571
+ frameSeq,
2572
+ commandId: safeString(message.commandId, 128),
2573
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
2574
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
2575
+ mimeType,
2576
+ format: mimeType,
2577
+ frameMode: transfer.frameMode,
2578
+ frameProfile: transfer.frameProfile,
2579
+ encoding: transfer.encoding,
2580
+ codec: transfer.codec,
2581
+ compression: transfer.compression || mimeType,
2582
+ transferProtocol: transfer.transferProtocol,
2583
+ transferProtocolVersion: transfer.transferProtocolVersion,
2584
+ handshake: transfer.handshake,
2585
+ capturedAt,
2586
+ receivedAt: device.lastSeenAt,
2587
+ byteLength,
2588
+ transport,
2589
+ contentHash,
2590
+ captureMs: readFrameCaptureMs(message),
2591
+ sameContentStreak,
2592
+ payload,
2593
+ accessToken: createFrameAccessToken()
2594
+ };
2595
+ rememberRecentFramePayload(device, 'thumbnail', device.latestThumbnail);
2596
+ device.counters.thumbnailFramesReceived += 1;
2597
+ emitRemoteEvent('RemoteFrameReceived', device, {
2598
+ streamId: device.latestThumbnail.streamId,
2599
+ frameSeq,
2600
+ width: device.latestThumbnail.width,
2601
+ height: device.latestThumbnail.height,
2602
+ transport,
2603
+ contentHash,
2604
+ sameContentStreak
2605
+ });
2606
+ return true;
2607
+ }
2608
+
2609
+ function applyLiveStreamOpen(device, message, transport = 'json') {
2610
+ const streamId = safeString(message.streamId, 128) || 'live';
2611
+ if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
2612
+ emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
2613
+ reason: 'stale-live-stream-open',
2614
+ streamId,
2615
+ transport
2616
+ });
2617
+ return false;
2618
+ }
2619
+
2620
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
2621
+ const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
2622
+ device.activeLiveStream = {
2623
+ ...device.activeLiveStream,
2624
+ open: true,
2625
+ openedAt,
2626
+ openTransport: transport,
2627
+ mode: transfer.mode,
2628
+ frameMode: transfer.frameMode,
2629
+ frameProfile: transfer.frameProfile,
2630
+ encoding: transfer.encoding,
2631
+ codec: transfer.codec,
2632
+ compression: transfer.compression,
2633
+ transferProtocol: transfer.transferProtocol,
2634
+ transferProtocolVersion: transfer.transferProtocolVersion,
2635
+ handshake: transfer.handshake,
2636
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(device.activeLiveStream.width || 0),
2637
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(device.activeLiveStream.height || 0),
2638
+ openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(device.activeLiveStream.openedFrameSeq || 0)
2639
+ };
2640
+ emitRemoteEvent('RemoteLiveStreamOpened', device, {
2641
+ streamId,
2642
+ mode: transfer.mode,
2643
+ frameMode: transfer.frameMode,
2644
+ frameProfile: transfer.frameProfile,
2645
+ encoding: transfer.encoding,
2646
+ compression: transfer.compression,
2647
+ transport
2648
+ });
2649
+ return true;
2650
+ }
2651
+
2652
+ function applyLiveFrame(device, message, framePayload, transport = 'json-base64') {
2653
+ const frameData = Buffer.isBuffer(framePayload)
2654
+ ? ''
2655
+ : safeString(framePayload, MAX_STREAM_BASE64_CHARS + 1);
2656
+ const frameSeq = Number(message.frameSeq);
2657
+ const streamId = safeString(message.streamId, 128) || 'live';
2658
+ if (!device.activeLiveStream?.active || device.activeLiveStream.streamId !== streamId) {
2659
+ device.counters.liveFramesDropped += 1;
2660
+ emitRemoteEvent('RemoteFrameDropped', device, {
2661
+ reason: 'stale-live-stream-frame',
2662
+ streamId,
2663
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
2664
+ transport
2665
+ });
2666
+ return false;
2667
+ }
2668
+
2669
+ const byteLength = normalizeFrameByteLength(framePayload, frameData);
2670
+ if ((!Buffer.isBuffer(framePayload) && !frameData)
2671
+ || byteLength > MAX_STREAM_BINARY_BYTES
2672
+ || !Number.isFinite(frameSeq)) {
2673
+ device.counters.liveFramesDropped += 1;
2674
+ emitRemoteEvent('RemoteFrameDropped', device, {
2675
+ reason: 'invalid-live-frame',
2676
+ streamId,
2677
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
2678
+ transport
2679
+ });
2680
+ return false;
2681
+ }
2682
+
2683
+ const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
2684
+ const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
2685
+ const payload = buildFramePayloadBuffer(framePayload, frameData);
2686
+ const contentHash = buildFrameContentHash(message, payload);
2687
+ const sameContentStreak = computeSameContentStreak(device.latestLiveFrame, contentHash);
2688
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, device.activeLiveStream.mode || DEFAULT_REMOTE_FRAME_MODE);
2689
+ if (device.activeLiveStream.open !== true) {
2690
+ device.activeLiveStream.open = true;
2691
+ device.activeLiveStream.openedAt = device.lastSeenAt;
2692
+ device.activeLiveStream.openTransport = `${transport}-implicit`;
2693
+ device.activeLiveStream.mode = transfer.mode;
2694
+ device.activeLiveStream.frameMode = transfer.frameMode;
2695
+ device.activeLiveStream.frameProfile = transfer.frameProfile;
2696
+ device.activeLiveStream.encoding = transfer.encoding;
2697
+ device.activeLiveStream.codec = transfer.codec;
2698
+ device.activeLiveStream.compression = transfer.compression;
2699
+ device.activeLiveStream.transferProtocol = transfer.transferProtocol;
2700
+ device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
2701
+ device.activeLiveStream.handshake = transfer.handshake;
2702
+ }
2703
+ device.latestLiveFrame = {
2704
+ streamId,
2705
+ frameSeq,
2706
+ commandId: safeString(message.commandId, 128),
2707
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
2708
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
2709
+ mimeType,
2710
+ format: mimeType,
2711
+ mode: transfer.mode,
2712
+ frameMode: transfer.frameMode,
2713
+ frameProfile: transfer.frameProfile,
2714
+ encoding: transfer.encoding,
2715
+ codec: transfer.codec,
2716
+ compression: transfer.compression || mimeType,
2717
+ transferProtocol: transfer.transferProtocol,
2718
+ transferProtocolVersion: transfer.transferProtocolVersion,
2719
+ handshake: transfer.handshake,
2720
+ fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : device.activeLiveStream.fps,
2721
+ capturedAt,
2722
+ receivedAt: device.lastSeenAt,
2723
+ byteLength,
2724
+ transport,
2725
+ contentHash,
2726
+ captureMs: readFrameCaptureMs(message),
2727
+ sameContentStreak,
2728
+ payload,
2729
+ accessToken: createFrameAccessToken()
2730
+ };
2731
+ rememberRecentFramePayload(device, 'live', device.latestLiveFrame);
2732
+ emitFrame({
2733
+ kind: 'live',
2734
+ deviceId: device.deviceId,
2735
+ frame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', { includeDataUrl: false }),
2736
+ payload: device.latestLiveFrame.payload,
2737
+ mimeType: device.latestLiveFrame.mimeType,
2738
+ byteLength: device.latestLiveFrame.byteLength
2739
+ });
2740
+ device.activeLiveStream.lastFrameAt = device.lastSeenAt;
2741
+ device.activeLiveStream.lastFrameSeq = frameSeq;
2742
+ device.activeLiveStream.framesReceived = (device.activeLiveStream.framesReceived || 0) + 1;
2743
+ device.activeLiveStream.mode = transfer.mode;
2744
+ device.activeLiveStream.frameMode = transfer.frameMode;
2745
+ device.activeLiveStream.frameProfile = transfer.frameProfile;
2746
+ device.activeLiveStream.encoding = transfer.encoding;
2747
+ device.activeLiveStream.codec = transfer.codec;
2748
+ device.activeLiveStream.compression = transfer.compression;
2749
+ device.activeLiveStream.transferProtocol = transfer.transferProtocol;
2750
+ device.activeLiveStream.transferProtocolVersion = transfer.transferProtocolVersion;
2751
+ device.activeLiveStream.handshake = transfer.handshake;
2752
+ device.counters.liveFramesReceived += 1;
2753
+ emitRemoteEvent('RemoteFrameReceived', device, {
2754
+ streamId,
2755
+ frameSeq,
2756
+ width: device.latestLiveFrame.width,
2757
+ height: device.latestLiveFrame.height,
2758
+ mode: device.latestLiveFrame.mode,
2759
+ transport,
2760
+ contentHash,
2761
+ sameContentStreak
2762
+ });
2763
+ return true;
2764
+ }
2765
+
2766
+ function applyAudioFrame(device, message, framePayload, transport = 'json-base64') {
2767
+ const audioData = Buffer.isBuffer(framePayload)
2768
+ ? ''
2769
+ : safeString(framePayload, MAX_AUDIO_BASE64_CHARS + 1);
2770
+ const frameSeq = Number(message.frameSeq);
2771
+ const streamId = safeString(message.streamId, 128) || device.activeAudioStream?.streamId || 'audio';
2772
+ const byteLength = normalizeFrameByteLength(framePayload, audioData);
2773
+ if ((!Buffer.isBuffer(framePayload) && !audioData)
2774
+ || byteLength > MAX_AUDIO_BINARY_BYTES
2775
+ || !Number.isFinite(frameSeq)) {
2776
+ device.counters.audioFramesDropped += 1;
2777
+ emitRemoteEvent('RemoteAudioFrameDropped', device, {
2778
+ reason: 'invalid-audio-frame',
2779
+ streamId,
2780
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
2781
+ transport
2782
+ });
2783
+ return false;
2784
+ }
2785
+
2786
+ const payload = buildFramePayloadBuffer(framePayload, audioData);
2787
+ const mimeType = safeString(message.mimeType || message.format || 'audio/webm;codecs=opus', 120) || 'audio/webm;codecs=opus';
2788
+ const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
2789
+ device.latestAudioFrame = {
2790
+ streamId,
2791
+ frameSeq,
2792
+ commandId: safeString(message.commandId, 128),
2793
+ mimeType,
2794
+ format: mimeType,
2795
+ sampleRate: Number.isFinite(Number(message.sampleRate)) ? Number(message.sampleRate) : 48000,
2796
+ channels: Number.isFinite(Number(message.channels)) ? Math.max(1, Math.min(8, Math.floor(Number(message.channels)))) : 2,
2797
+ capturedAt,
2798
+ receivedAt: device.lastSeenAt,
2799
+ byteLength,
2800
+ transport,
2801
+ payload
2802
+ };
2803
+ if (device.activeAudioStream) {
2804
+ device.activeAudioStream.active = true;
2805
+ device.activeAudioStream.open = true;
2806
+ device.activeAudioStream.lastFrameAt = device.lastSeenAt;
2807
+ device.activeAudioStream.lastFrameSeq = frameSeq;
2808
+ device.activeAudioStream.framesReceived = (device.activeAudioStream.framesReceived || 0) + 1;
2809
+ device.activeAudioStream.mimeType = mimeType;
2810
+ device.activeAudioStream.sampleRate = device.latestAudioFrame.sampleRate;
2811
+ device.activeAudioStream.channels = device.latestAudioFrame.channels;
2812
+ }
2813
+ device.counters.audioFramesReceived += 1;
2814
+ emitAudio({
2815
+ deviceId: device.deviceId,
2816
+ frame: {
2817
+ streamId,
2818
+ frameSeq,
2819
+ mimeType,
2820
+ sampleRate: device.latestAudioFrame.sampleRate,
2821
+ channels: device.latestAudioFrame.channels,
2822
+ capturedAt,
2823
+ receivedAt: device.latestAudioFrame.receivedAt,
2824
+ byteLength
2825
+ },
2826
+ payload,
2827
+ mimeType,
2828
+ byteLength
2829
+ });
2830
+ emitRemoteEvent('RemoteAudioFrameReceived', device, {
2831
+ streamId,
2832
+ frameSeq,
2833
+ mimeType,
2834
+ byteLength,
2835
+ transport
2836
+ });
2837
+ return true;
2838
+ }
2839
+
2840
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
2841
+ if (!state.authenticated || !state.device) {
2842
+ writeJsonLine(socket, { type: 'error', error: 'hello-required' });
2843
+ socket.destroy();
2844
+ return;
2845
+ }
2846
+
2847
+ const device = state.device;
2848
+ device.counters.messagesReceived += 1;
2849
+ device.lastSeenAt = new Date().toISOString();
2850
+
2851
+ const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
2852
+ if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
2853
+ applyThumbnailFrame(device, header, framePayload, 'binary');
2854
+ return;
2855
+ }
2856
+
2857
+ if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
2858
+ applyLiveFrame(device, header, framePayload, 'binary');
2859
+ return;
2860
+ }
2861
+
2862
+ if (frameKind === 'audio' || header.type === 'audio.binary') {
2863
+ applyAudioFrame(device, header, framePayload, 'binary');
2864
+ return;
2865
+ }
2866
+
2867
+ emitRemoteEvent('RemoteAgentMessageIgnored', device, {
2868
+ messageType: safeString(header.type, 80),
2869
+ frameKind
2870
+ });
2871
+ }
2872
+
2873
+ function handleAgentMessage(socket, state, message) {
2874
+ if (!message || typeof message !== 'object') {
2875
+ return;
2876
+ }
2877
+
2878
+ if (!state.authenticated) {
2879
+ if (message.type !== 'hello') {
2880
+ writeJsonLine(socket, { type: 'error', error: 'hello-required' });
2881
+ socket.destroy();
2882
+ return;
2883
+ }
2884
+
2885
+ if (!timingSafeStringEqual(message.pairToken, pairToken)) {
2886
+ writeJsonLine(socket, { type: 'error', error: 'invalid-pair-token' });
2887
+ socket.destroy();
2888
+ return;
2889
+ }
2890
+
2891
+ const device = attachDevice(socket, message);
2892
+ if (!device) {
2893
+ return;
2894
+ }
2895
+
2896
+ state.authenticated = true;
2897
+ state.device = device;
2898
+ return;
2899
+ }
2900
+
2901
+ const device = state.device;
2902
+ if (!device) {
2903
+ socket.destroy();
2904
+ return;
2905
+ }
2906
+
2907
+ device.counters.messagesReceived += 1;
2908
+ device.lastSeenAt = new Date().toISOString();
2909
+
2910
+ switch (message.type) {
2911
+ case 'heartbeat':
2912
+ case 'status':
2913
+ device.status = typeof message.status === 'object' && message.status
2914
+ ? { ...message.status }
2915
+ : {};
2916
+ {
2917
+ const nextSlotNumber = normalizeSlotNumber(device.status.slotNumber);
2918
+ if (nextSlotNumber > 0) {
2919
+ device.slotNumber = nextSlotNumber;
2920
+ }
2921
+ }
2922
+ device.lastStatusAt = device.lastSeenAt;
2923
+ device.counters.statusReceived += 1;
2924
+ emitRemoteEvent('RemoteDeviceStatus', device);
2925
+ break;
2926
+ case 'command.result':
2927
+ device.counters.commandResultsReceived += 1;
2928
+ {
2929
+ const commandId = safeString(message.commandId, 128);
2930
+ const error = safeString(message.error, 500);
2931
+ const task = applyTaskResult(device, commandId, message.result ?? null, error);
2932
+ if (task) {
2933
+ emitRemoteEvent('RemoteTaskResult', device, {
2934
+ commandId,
2935
+ taskId: task.taskId,
2936
+ status: task.status,
2937
+ error: task.error
2938
+ });
2939
+ }
2940
+ }
2941
+ emitRemoteEvent('RemoteCommandResult', device, {
2942
+ commandId: safeString(message.commandId, 128),
2943
+ result: message.result ?? null,
2944
+ error: safeString(message.error, 500)
2945
+ });
2946
+ break;
2947
+ case 'thumbnail.frame': {
2948
+ applyThumbnailFrame(device, message, message.data, 'json-base64');
2949
+ break;
2950
+ }
2951
+ case 'stream.open': {
2952
+ applyLiveStreamOpen(device, message, 'json');
2953
+ break;
2954
+ }
2955
+ case 'stream.frame': {
2956
+ applyLiveFrame(device, message, message.data, 'json-base64');
2957
+ break;
2958
+ }
2959
+ case 'audio.frame': {
2960
+ applyAudioFrame(device, message, message.data, 'json-base64');
2961
+ break;
2962
+ }
2963
+ default:
2964
+ emitRemoteEvent('RemoteAgentMessageIgnored', device, {
2965
+ messageType: safeString(message.type, 80)
2966
+ });
2967
+ break;
2968
+ }
2969
+ }
2970
+
2971
+ function handleWebSocketAgentSocket(socket) {
2972
+ allSockets.add(socket);
2973
+ socket.setNoDelay(true);
2974
+ socket.setKeepAlive(true, heartbeatMs);
2975
+
2976
+ const state = {
2977
+ authenticated: false,
2978
+ device: null
2979
+ };
2980
+
2981
+ const helloTimer = setTimeout(() => {
2982
+ if (!state.authenticated) {
2983
+ writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
2984
+ socket.destroy();
2985
+ }
2986
+ }, 10000);
2987
+
2988
+ socket.onTextMessage = text => {
2989
+ try {
2990
+ handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
2991
+ } catch (err) {
2992
+ writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
2993
+ logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
2994
+ }
2995
+ };
2996
+
2997
+ socket.onBinaryMessage = payload => {
2998
+ try {
2999
+ const packet = parseRemoteHubWebSocketBinaryFrame(payload);
3000
+ if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
3001
+ writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
3002
+ return;
3003
+ }
3004
+
3005
+ const frameKind = safeString(packet.header.frameKind || packet.header.kind || packet.header.frameType, 40).toLowerCase();
3006
+ const maxBytes = frameKind === 'thumbnail'
3007
+ ? MAX_THUMBNAIL_BINARY_BYTES
3008
+ : MAX_STREAM_BINARY_BYTES;
3009
+ const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
3010
+ if (!Number.isFinite(byteLength)
3011
+ || byteLength < 1
3012
+ || byteLength > maxBytes
3013
+ || byteLength !== packet.payload.length) {
3014
+ writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
3015
+ logWarn('remote', 'invalid websocket binary frame from agent.');
3016
+ return;
3017
+ }
3018
+
3019
+ handleAgentBinaryFrame(socket, state, packet.header, packet.payload);
3020
+ } catch (err) {
3021
+ writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
3022
+ logWarn('remote', `invalid websocket binary frame: ${err?.message || err}`);
3023
+ }
3024
+ };
3025
+
3026
+ const detach = reason => {
3027
+ allSockets.delete(socket);
3028
+ clearTimeout(helloTimer);
3029
+ detachSocket(socket, reason);
3030
+ };
3031
+
3032
+ socket.onCloseMessage = reason => detach(reason || 'websocket-closed');
3033
+ socket.socket.on('close', () => detach('websocket-closed'));
3034
+ socket.socket.on('error', err => detach(err?.message || 'websocket-error'));
3035
+ }
3036
+
3037
+ function handleWebSocketUpgradeSocket(socket, firstChunk) {
3038
+ let upgradeBuffer = Buffer.isBuffer(firstChunk) ? firstChunk : Buffer.from(firstChunk);
3039
+ let onUpgradeData = null;
3040
+ const fail = (status, message) => {
3041
+ try {
3042
+ socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
3043
+ } catch {
3044
+ // Ignore write failures while rejecting the upgrade.
3045
+ }
3046
+ socket.destroy();
3047
+ };
3048
+
3049
+ const completeUpgrade = () => {
3050
+ const request = parseRemoteHubWebSocketUpgrade(upgradeBuffer);
3051
+ if (!request) {
3052
+ return false;
3053
+ }
3054
+
3055
+ const upgrade = String(request.headers.upgrade || '').toLowerCase();
3056
+ const connection = String(request.headers.connection || '').toLowerCase();
3057
+ const key = safeString(request.headers['sec-websocket-key'], 256);
3058
+ const pathName = safeString(String(request.path || '').split('?')[0], 128) || '/';
3059
+ const allowedPath = pathName === '/'
3060
+ || pathName === '/remote-agent'
3061
+ || pathName === '/api/remote/agent/ws';
3062
+ if (request.method !== 'GET'
3063
+ || upgrade !== 'websocket'
3064
+ || !connection.includes('upgrade')
3065
+ || !key
3066
+ || !allowedPath) {
3067
+ fail(400, 'Bad Request');
3068
+ return true;
3069
+ }
3070
+
3071
+ const acceptKey = buildRemoteHubWebSocketAcceptKey(key);
3072
+ socket.write([
3073
+ 'HTTP/1.1 101 Switching Protocols',
3074
+ 'Upgrade: websocket',
3075
+ 'Connection: Upgrade',
3076
+ `Sec-WebSocket-Accept: ${acceptKey}`,
3077
+ '\r\n'
3078
+ ].join('\r\n'));
3079
+
3080
+ if (onUpgradeData) {
3081
+ socket.removeListener('data', onUpgradeData);
3082
+ }
3083
+ const wsSocket = new RemoteHubWebSocketAgentSocket(socket);
3084
+ const onFrameData = chunk => wsSocket.handleData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3085
+ socket.on('data', onFrameData);
3086
+ socket.once('close', () => {
3087
+ socket.removeListener('data', onFrameData);
3088
+ });
3089
+ handleWebSocketAgentSocket(wsSocket);
3090
+ if (request.head.length > 0) {
3091
+ wsSocket.handleData(request.head);
3092
+ }
3093
+ return true;
3094
+ };
3095
+
3096
+ onUpgradeData = chunk => {
3097
+ upgradeBuffer = Buffer.concat([upgradeBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
3098
+ if (upgradeBuffer.length > 16 * 1024) {
3099
+ fail(431, 'Request Header Fields Too Large');
3100
+ return;
3101
+ }
3102
+ completeUpgrade();
3103
+ };
3104
+
3105
+ if (!completeUpgrade()) {
3106
+ socket.on('data', onUpgradeData);
3107
+ }
3108
+ }
3109
+
3110
+ function handleTcpAgentSocket(socket, firstChunk = null) {
3111
+ allSockets.add(socket);
3112
+ socket.setNoDelay(true);
3113
+ socket.setKeepAlive(true, heartbeatMs);
3114
+
3115
+ const state = {
3116
+ authenticated: false,
3117
+ device: null,
3118
+ buffer: Buffer.alloc(0),
3119
+ pendingBinaryFrame: null
3120
+ };
3121
+
3122
+ const helloTimer = setTimeout(() => {
3123
+ if (!state.authenticated) {
3124
+ writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
3125
+ socket.destroy();
3126
+ }
3127
+ }, 10000);
3128
+
3129
+ const processData = chunk => {
3130
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3131
+ state.buffer = state.buffer.length > 0
3132
+ ? Buffer.concat([state.buffer, incoming])
3133
+ : incoming;
3134
+
3135
+ while (!socket.destroyed) {
3136
+ if (state.pendingBinaryFrame) {
3137
+ const { header, byteLength } = state.pendingBinaryFrame;
3138
+ if (state.buffer.length < byteLength) {
3139
+ return;
3140
+ }
3141
+
3142
+ const framePayload = state.buffer.subarray(0, byteLength);
3143
+ state.buffer = state.buffer.subarray(byteLength);
3144
+ state.pendingBinaryFrame = null;
3145
+ handleAgentBinaryFrame(socket, state, header, framePayload);
3146
+ continue;
3147
+ }
3148
+
3149
+ const newlineIndex = state.buffer.indexOf(0x0a);
3150
+ if (newlineIndex < 0) {
3151
+ if (state.buffer.length > MAX_LINE_CHARS) {
3152
+ writeJsonLine(socket, { type: 'error', error: 'message-too-large' });
3153
+ socket.destroy();
3154
+ }
3155
+ return;
3156
+ }
3157
+
3158
+ const lineBuffer = state.buffer.subarray(0, newlineIndex);
3159
+ state.buffer = state.buffer.subarray(newlineIndex + 1);
3160
+
3161
+ try {
3162
+ const message = parseJsonLine(lineBuffer.toString('utf8'));
3163
+ if (message?.type === 'frame.binary') {
3164
+ const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
3165
+ const maxBytes = frameKind === 'thumbnail'
3166
+ ? MAX_THUMBNAIL_BINARY_BYTES
3167
+ : MAX_STREAM_BINARY_BYTES;
3168
+ const byteLength = Number(message.byteLength ?? message.payloadBytes ?? message.dataLength);
3169
+ if (!Number.isFinite(byteLength) || byteLength < 1 || byteLength > maxBytes) {
3170
+ writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
3171
+ logWarn('remote', 'invalid binary frame header from agent.');
3172
+ continue;
3173
+ }
3174
+
3175
+ state.pendingBinaryFrame = {
3176
+ header: message,
3177
+ byteLength: Math.floor(byteLength)
3178
+ };
3179
+ continue;
3180
+ }
3181
+
3182
+ handleAgentMessage(socket, state, message);
3183
+ } catch (err) {
3184
+ writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
3185
+ logWarn('remote', `invalid agent message: ${err?.message || err}`);
3186
+ }
3187
+ }
3188
+ };
3189
+
3190
+ socket.on('data', processData);
3191
+ if (firstChunk) {
3192
+ processData(firstChunk);
3193
+ }
3194
+
3195
+ socket.on('close', () => {
3196
+ allSockets.delete(socket);
3197
+ clearTimeout(helloTimer);
3198
+ detachSocket(socket);
3199
+ });
3200
+ socket.on('error', err => {
3201
+ allSockets.delete(socket);
3202
+ clearTimeout(helloTimer);
3203
+ detachSocket(socket, err?.message || 'socket-error');
3204
+ });
3205
+ }
3206
+
3207
+ function handleSocket(socket) {
3208
+ socket.once('data', chunk => {
3209
+ const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3210
+ if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
3211
+ handleWebSocketUpgradeSocket(socket, firstChunk);
3212
+ return;
3213
+ }
3214
+
3215
+ handleTcpAgentSocket(socket, firstChunk);
3216
+ });
3217
+
3218
+ socket.once('error', () => {
3219
+ // The transport-specific handler owns logging after the first byte.
3220
+ });
3221
+ }
3222
+
3223
+ async function start() {
3224
+ if (!enabled || started) {
3225
+ return getStatus({ includeSecrets: false });
3226
+ }
3227
+
3228
+ await new Promise((resolve, reject) => {
3229
+ server = net.createServer(handleSocket);
3230
+ server.once('error', err => {
3231
+ lastError = err?.message || String(err);
3232
+ server = null;
3233
+ reject(err);
3234
+ });
3235
+ server.listen(requestedPort, host, () => {
3236
+ started = true;
3237
+ boundPort = server.address()?.port || requestedPort;
3238
+ lastError = '';
3239
+ logEvent('remote', `RemoteHub listening on tcp://${host}:${boundPort}`, 'success');
3240
+ if (host === '0.0.0.0' || host === '::') {
3241
+ logWarn('remote', 'RemoteHub is externally reachable. Use a strong pairing token and trusted network.');
3242
+ }
3243
+ emitRemoteEvent('RemoteHubStarted', null);
3244
+ resolve();
3245
+ });
3246
+ });
3247
+
3248
+ return getStatus({ includeSecrets: false });
3249
+ }
3250
+
3251
+ async function close() {
3252
+ for (const device of devices.values()) {
3253
+ failAllPendingTasks(device, 'hub-shutdown');
3254
+ if (device.socket && !device.socket.destroyed) {
3255
+ writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
3256
+ device.socket.destroy();
3257
+ }
3258
+ }
3259
+
3260
+ for (const socket of allSockets) {
3261
+ if (!socket.destroyed) {
3262
+ socket.destroy();
3263
+ }
3264
+ }
3265
+
3266
+ sockets.clear();
3267
+ if (!server) {
3268
+ started = false;
3269
+ return;
3270
+ }
3271
+
3272
+ await new Promise(resolve => {
3273
+ let settled = false;
3274
+ const settle = () => {
3275
+ if (settled) {
3276
+ return;
3277
+ }
3278
+
3279
+ settled = true;
3280
+ clearTimeout(closeTimer);
3281
+ resolve();
3282
+ };
3283
+
3284
+ const closeTimer = setTimeout(settle, 1000);
3285
+ closeTimer.unref?.();
3286
+
3287
+ try {
3288
+ server.close(settle);
3289
+ } catch {
3290
+ settle();
3291
+ }
3292
+ });
3293
+
3294
+ server = null;
3295
+ started = false;
3296
+ }
3297
+
3298
+ function disconnectDevice(deviceId, reason = 'manager-disconnect') {
3299
+ const device = devices.get(String(deviceId || ''));
3300
+ if (device?.synthetic === true) {
3301
+ device.connected = false;
3302
+ device.disconnectedAt = new Date().toISOString();
3303
+ device.lastDisconnectReason = reason;
3304
+ if (device.activeLiveStream) {
3305
+ device.activeLiveStream.active = false;
3306
+ device.activeLiveStream.stoppedAt = device.disconnectedAt;
3307
+ device.activeLiveStream.stopReason = reason;
3308
+ }
3309
+ failAllPendingTasks(device, 'device-disconnected');
3310
+ emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
3311
+ return true;
3312
+ }
3313
+
3314
+ if (!device?.socket || device.socket.destroyed) {
3315
+ return false;
3316
+ }
3317
+
3318
+ writeJsonLine(device.socket, { type: 'disconnect', reason });
3319
+ device.socket.destroy();
3320
+ return true;
3321
+ }
3322
+
3323
+ function sendCommand(deviceId, command) {
3324
+ const device = devices.get(String(deviceId || ''));
3325
+ if (device?.synthetic === true && device.connected) {
3326
+ const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
3327
+ device.counters.commandsSent += 1;
3328
+ device.lastSeenAt = new Date().toISOString();
3329
+ if (command?.command === 'thumbnail.capture') {
3330
+ applySyntheticThumbnail(device, {
3331
+ commandId,
3332
+ payload: command?.payload || {}
3333
+ });
3334
+ }
3335
+ emitRemoteEvent('RemoteCommandQueued', device, {
3336
+ commandId,
3337
+ command: safeString(command?.command || 'ping', 80),
3338
+ synthetic: true
3339
+ });
3340
+ emitRemoteEvent('RemoteCommandResult', device, {
3341
+ commandId,
3342
+ result: { ok: true, synthetic: true },
3343
+ error: ''
3344
+ });
3345
+ return { ok: true, commandId, synthetic: true };
3346
+ }
3347
+
3348
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
3349
+ return { ok: false, error: 'device-not-connected' };
3350
+ }
3351
+
3352
+ const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
3353
+ const payload = {
3354
+ type: 'command',
3355
+ commandId,
3356
+ command: safeString(command?.command || 'ping', 80),
3357
+ payload: command?.payload ?? null,
3358
+ issuedAt: new Date().toISOString()
3359
+ };
3360
+
3361
+ writeJsonLine(device.socket, payload);
3362
+ device.counters.commandsSent += 1;
3363
+ emitRemoteEvent('RemoteCommandQueued', device, {
3364
+ commandId,
3365
+ command: payload.command
3366
+ });
3367
+ return { ok: true, commandId };
3368
+ }
3369
+
3370
+ function sendInputControl(deviceId, input = {}) {
3371
+ const device = devices.get(String(deviceId || ''));
3372
+ if (!device) {
3373
+ return { ok: false, error: 'device-not-found' };
3374
+ }
3375
+
3376
+ if (!device.connected) {
3377
+ return { ok: false, error: 'device-not-connected' };
3378
+ }
3379
+
3380
+ if (!readCapabilityFlag(device.capabilities, 'control')) {
3381
+ return { ok: false, error: 'device-input-control-unavailable' };
3382
+ }
3383
+
3384
+ const normalized = normalizeRemoteInputEvent(input);
3385
+ if (!normalized.type) {
3386
+ return { ok: false, error: 'missing-input-type' };
3387
+ }
3388
+
3389
+ return sendCommand(deviceId, {
3390
+ command: 'input.control',
3391
+ payload: {
3392
+ ...normalized,
3393
+ issuedAt: normalized.issuedAt || new Date().toISOString()
3394
+ }
3395
+ });
3396
+ }
3397
+
3398
+ function requestAgentTask(deviceId, options = {}) {
3399
+ const device = devices.get(String(deviceId || ''));
3400
+ if (!device) {
3401
+ return { ok: false, error: 'device-not-found' };
3402
+ }
3403
+
3404
+ if (device?.synthetic === true && device.connected) {
3405
+ const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
3406
+ if (!instruction) {
3407
+ return { ok: false, error: 'missing-instruction' };
3408
+ }
3409
+
3410
+ const now = new Date().toISOString();
3411
+ const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
3412
+ if (approvalLevel === 'ai-assist' && !readCapabilityFlag(device.capabilities, 'aiAssist')) {
3413
+ return { ok: false, error: 'device-ai-assist-unavailable' };
3414
+ }
3415
+
3416
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3417
+ const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
3418
+ const title = safeString(options.title, 120)
3419
+ || safeString(instruction.split(/\r?\n/)[0], 120)
3420
+ || 'Remote task';
3421
+ const task = {
3422
+ batchId: safeString(options.batchId, 128),
3423
+ taskId,
3424
+ commandId,
3425
+ title,
3426
+ instructionPreview: safeText(instruction, 320),
3427
+ status: 'completed',
3428
+ approvalLevel,
3429
+ requestedAt: now,
3430
+ sentAt: now,
3431
+ updatedAt: now,
3432
+ completedAt: now,
3433
+ error: '',
3434
+ resultKind: approvalLevel === 'ai-assist' ? 'synthetic-ai-assist' : 'synthetic-agent-task',
3435
+ resultModel: approvalLevel === 'ai-assist'
3436
+ ? safeString(options.model, 120) || 'synthetic-ai'
3437
+ : '',
3438
+ resultResponseId: approvalLevel === 'ai-assist'
3439
+ ? `synthetic-response-${taskId}`
3440
+ : '',
3441
+ resultSummary: approvalLevel === 'ai-assist'
3442
+ ? `Synthetic AI assist completed for ${device.deviceName}.`
3443
+ : `Synthetic task accepted by ${device.deviceName}.`
3444
+ };
3445
+
3446
+ device.counters.commandsSent += 1;
3447
+ device.counters.commandResultsReceived += 1;
3448
+ device.counters.tasksQueued += 1;
3449
+ device.counters.taskResultsReceived += 1;
3450
+ device.lastSeenAt = now;
3451
+ rememberDeviceTask(device, task);
3452
+ device.pendingTaskCommands.delete(commandId);
3453
+ syncTaskBatchFromTask(device, task);
3454
+ emitRemoteEvent('RemoteTaskQueued', device, {
3455
+ commandId,
3456
+ taskId,
3457
+ title,
3458
+ approvalLevel,
3459
+ synthetic: true
3460
+ });
3461
+ emitRemoteEvent('RemoteTaskResult', device, {
3462
+ commandId,
3463
+ taskId,
3464
+ status: task.status,
3465
+ error: '',
3466
+ synthetic: true
3467
+ });
3468
+ return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
3469
+ }
3470
+
3471
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
3472
+ return { ok: false, error: 'device-not-connected' };
3473
+ }
3474
+
3475
+ const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
3476
+ if (!instruction) {
3477
+ return { ok: false, error: 'missing-instruction' };
3478
+ }
3479
+
3480
+ const now = new Date().toISOString();
3481
+ const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
3482
+ if (approvalLevel === 'ai-assist' && !readCapabilityFlag(device.capabilities, 'aiAssist')) {
3483
+ return { ok: false, error: 'device-ai-assist-unavailable' };
3484
+ }
3485
+
3486
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3487
+ const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
3488
+ const title = safeString(options.title, 120)
3489
+ || safeString(instruction.split(/\r?\n/)[0], 120)
3490
+ || 'Remote task';
3491
+ const task = {
3492
+ batchId: safeString(options.batchId, 128),
3493
+ taskId,
3494
+ commandId,
3495
+ title,
3496
+ instructionPreview: safeText(instruction, 320),
3497
+ status: 'queued',
3498
+ approvalLevel,
3499
+ requestedAt: now,
3500
+ sentAt: now,
3501
+ updatedAt: now,
3502
+ completedAt: '',
3503
+ error: '',
3504
+ resultKind: '',
3505
+ resultModel: '',
3506
+ resultResponseId: '',
3507
+ resultSummary: ''
3508
+ };
3509
+
3510
+ const sent = writeJsonLine(device.socket, {
3511
+ type: 'command',
3512
+ commandId,
3513
+ command: 'agent.task',
3514
+ payload: {
3515
+ taskId,
3516
+ title,
3517
+ instruction,
3518
+ approvalLevel,
3519
+ model: safeString(options.model, 120),
3520
+ requestedAt: now
3521
+ },
3522
+ issuedAt: now
3523
+ });
3524
+
3525
+ if (!sent) {
3526
+ return { ok: false, error: 'device-not-connected' };
3527
+ }
3528
+
3529
+ device.counters.commandsSent += 1;
3530
+ device.counters.tasksQueued += 1;
3531
+ rememberDeviceTask(device, task);
3532
+ syncTaskBatchFromTask(device, task);
3533
+ scheduleTaskTimeout(device, task);
3534
+ emitRemoteEvent('RemoteTaskQueued', device, {
3535
+ commandId,
3536
+ taskId,
3537
+ title,
3538
+ approvalLevel
3539
+ });
3540
+ return { ok: true, commandId, taskId, approvalLevel };
3541
+ }
3542
+
3543
+ function requestAgentTaskBatch(deviceIds, options = {}) {
3544
+ const targets = [...new Set((deviceIds || [])
3545
+ .map(deviceId => safeString(deviceId, 128))
3546
+ .filter(Boolean))]
3547
+ .slice(0, 500);
3548
+
3549
+ if (targets.length === 0) {
3550
+ return {
3551
+ ok: false,
3552
+ error: 'no-target-devices',
3553
+ total: 0,
3554
+ queued: 0,
3555
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
3556
+ results: []
3557
+ };
3558
+ }
3559
+
3560
+ const batch = beginTaskBatch(targets, options);
3561
+ const results = targets.map(deviceId => {
3562
+ const result = requestAgentTask(deviceId, {
3563
+ ...options,
3564
+ batchId: batch.batchId
3565
+ });
3566
+
3567
+ if (result.ok !== true) {
3568
+ syncTaskBatchDispatchFailure(batch.batchId, deviceId, result.error, result);
3569
+ }
3570
+
3571
+ return {
3572
+ deviceId,
3573
+ ...result
3574
+ };
3575
+ });
3576
+
3577
+ const queued = results.filter(result => result.ok === true).length;
3578
+ const updatedBatch = taskBatches.get(batch.batchId) || batch;
3579
+ recalculateTaskBatch(updatedBatch);
3580
+ return {
3581
+ ok: queued > 0,
3582
+ batchId: batch.batchId,
3583
+ total: targets.length,
3584
+ queued,
3585
+ approvalLevel: batch.approvalLevel,
3586
+ results,
3587
+ batch: serializeTaskBatch(updatedBatch),
3588
+ error: queued > 0 ? undefined : 'no-task-queued'
3589
+ };
3590
+ }
3591
+
3592
+ function requestThumbnail(deviceId, options = {}) {
3593
+ return sendCommand(deviceId, {
3594
+ command: 'thumbnail.capture',
3595
+ commandId: safeString(options.commandId, 128) || crypto.randomUUID(),
3596
+ payload: {
3597
+ streamId: safeString(options.streamId, 128) || `thumb-${Date.now()}`,
3598
+ maxWidth: clampNumber(options.maxWidth, 160, 1920, 360),
3599
+ maxHeight: clampNumber(options.maxHeight, 90, 1080, 220),
3600
+ quality: clampNumber(options.quality, 20, 95, 55),
3601
+ requestedAt: new Date().toISOString()
3602
+ }
3603
+ });
3604
+ }
3605
+
3606
+ function startLiveStream(deviceId, options = {}) {
3607
+ const device = devices.get(String(deviceId || ''));
3608
+ if (device?.synthetic === true && device.connected) {
3609
+ if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
3610
+ return { ok: false, error: 'device-live-stream-unavailable' };
3611
+ }
3612
+
3613
+ const now = new Date().toISOString();
3614
+ const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
3615
+ const fps = clampNumber(options.fps, 1, 24, 20);
3616
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3617
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
3618
+ device.counters.commandsSent += 1;
3619
+ device.activeLiveStream = {
3620
+ streamId,
3621
+ commandId,
3622
+ active: true,
3623
+ open: true,
3624
+ openedAt: now,
3625
+ openTransport: 'synthetic',
3626
+ mode: transfer.mode,
3627
+ frameMode: transfer.frameMode,
3628
+ frameProfile: transfer.frameProfile,
3629
+ encoding: transfer.encoding,
3630
+ codec: transfer.codec,
3631
+ compression: transfer.compression,
3632
+ transferProtocol: transfer.transferProtocol,
3633
+ transferProtocolVersion: transfer.transferProtocolVersion,
3634
+ handshake: transfer.handshake,
3635
+ fps,
3636
+ startedAt: now,
3637
+ stoppedAt: '',
3638
+ stopReason: '',
3639
+ lastFrameAt: '',
3640
+ lastFrameSeq: 0,
3641
+ framesReceived: 0
3642
+ };
3643
+ device.counters.liveStreamsStarted += 1;
3644
+ applySyntheticLiveFrame(device, streamId, {
3645
+ commandId,
3646
+ maxWidth: options.maxWidth,
3647
+ maxHeight: options.maxHeight,
3648
+ fps
3649
+ });
3650
+ emitRemoteEvent('RemoteLiveStreamStarted', device, {
3651
+ streamId,
3652
+ commandId,
3653
+ fps,
3654
+ mode: transfer.mode,
3655
+ synthetic: true
3656
+ });
3657
+ return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, synthetic: true };
3658
+ }
3659
+
3660
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
3661
+ return { ok: false, error: 'device-not-connected' };
3662
+ }
3663
+ if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
3664
+ return { ok: false, error: 'device-live-stream-unavailable' };
3665
+ }
3666
+
3667
+ const now = new Date().toISOString();
3668
+ const streamId = safeString(options.streamId, 128) || `live-${Date.now()}`;
3669
+ const fps = clampNumber(options.fps, 1, 24, 12);
3670
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3671
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || DEFAULT_REMOTE_FRAME_MODE);
3672
+ const result = sendCommand(deviceId, {
3673
+ command: 'stream.start',
3674
+ commandId,
3675
+ payload: {
3676
+ streamId,
3677
+ mode: transfer.mode,
3678
+ frameMode: transfer.frameMode,
3679
+ frameProfile: transfer.frameProfile,
3680
+ encoding: transfer.encoding,
3681
+ codec: transfer.codec,
3682
+ compression: transfer.compression,
3683
+ transferProtocol: transfer.transferProtocol,
3684
+ transferProtocolVersion: transfer.transferProtocolVersion,
3685
+ handshake: transfer.handshake,
3686
+ fps,
3687
+ maxWidth: clampNumber(options.maxWidth, 320, 2560, 960),
3688
+ maxHeight: clampNumber(options.maxHeight, 180, 1440, 540),
3689
+ quality: clampNumber(options.quality, 20, 95, 60),
3690
+ requestedAt: now
3691
+ }
3692
+ });
3693
+
3694
+ if (!result.ok) {
3695
+ return result;
3696
+ }
3697
+
3698
+ device.activeLiveStream = {
3699
+ streamId,
3700
+ commandId,
3701
+ active: true,
3702
+ open: false,
3703
+ openedAt: '',
3704
+ openTransport: '',
3705
+ mode: transfer.mode,
3706
+ frameMode: transfer.frameMode,
3707
+ frameProfile: transfer.frameProfile,
3708
+ encoding: transfer.encoding,
3709
+ codec: transfer.codec,
3710
+ compression: transfer.compression,
3711
+ transferProtocol: transfer.transferProtocol,
3712
+ transferProtocolVersion: transfer.transferProtocolVersion,
3713
+ handshake: transfer.handshake,
3714
+ fps,
3715
+ startedAt: now,
3716
+ stoppedAt: '',
3717
+ stopReason: '',
3718
+ lastFrameAt: '',
3719
+ lastFrameSeq: 0,
3720
+ framesReceived: 0
3721
+ };
3722
+ device.counters.liveStreamsStarted += 1;
3723
+ emitRemoteEvent('RemoteLiveStreamStarted', device, {
3724
+ streamId,
3725
+ commandId,
3726
+ fps,
3727
+ mode: transfer.mode,
3728
+ frameMode: transfer.frameMode
3729
+ });
3730
+
3731
+ return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode };
3732
+ }
3733
+
3734
+ function stopLiveStream(deviceId, options = {}) {
3735
+ const device = devices.get(String(deviceId || ''));
3736
+ if (device?.synthetic === true && device.connected) {
3737
+ const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
3738
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3739
+ device.counters.commandsSent += 1;
3740
+ if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
3741
+ device.activeLiveStream.active = false;
3742
+ device.activeLiveStream.stoppedAt = new Date().toISOString();
3743
+ device.activeLiveStream.stopReason = 'manager-request';
3744
+ }
3745
+ device.counters.liveStreamsStopped += 1;
3746
+ emitRemoteEvent('RemoteLiveStreamStopped', device, {
3747
+ streamId,
3748
+ commandId,
3749
+ synthetic: true
3750
+ });
3751
+ return { ok: true, commandId, streamId, synthetic: true };
3752
+ }
3753
+
3754
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
3755
+ return { ok: false, error: 'device-not-connected' };
3756
+ }
3757
+
3758
+ const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
3759
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3760
+ const result = sendCommand(deviceId, {
3761
+ command: 'stream.stop',
3762
+ commandId,
3763
+ payload: {
3764
+ streamId,
3765
+ requestedAt: new Date().toISOString()
3766
+ }
3767
+ });
3768
+
3769
+ if (!result.ok) {
3770
+ return result;
3771
+ }
3772
+
3773
+ if (device.activeLiveStream && (!streamId || device.activeLiveStream.streamId === streamId)) {
3774
+ device.activeLiveStream.active = false;
3775
+ device.activeLiveStream.stoppedAt = new Date().toISOString();
3776
+ device.activeLiveStream.stopReason = 'manager-request';
3777
+ }
3778
+ device.counters.liveStreamsStopped += 1;
3779
+ emitRemoteEvent('RemoteLiveStreamStopped', device, {
3780
+ streamId,
3781
+ commandId
3782
+ });
3783
+
3784
+ return { ok: true, commandId, streamId };
3785
+ }
3786
+
3787
+ function startAudioStream(deviceId, options = {}) {
3788
+ const device = devices.get(String(deviceId || ''));
3789
+ if (!device) {
3790
+ return { ok: false, error: 'device-not-found' };
3791
+ }
3792
+ if (!device.connected) {
3793
+ return { ok: false, error: 'device-not-connected' };
3794
+ }
3795
+ if (!readCapabilityFlag(device.capabilities, 'audio') && !readCapabilityFlag(device.capabilities, 'remoteAudio')) {
3796
+ return { ok: false, error: 'device-audio-unavailable' };
3797
+ }
3798
+ if (device.synthetic === true || !device.socket || device.socket.destroyed) {
3799
+ return { ok: false, error: 'device-audio-unavailable' };
3800
+ }
3801
+
3802
+ const now = new Date().toISOString();
3803
+ const streamId = safeString(options.streamId, 128) || `audio-${Date.now()}`;
3804
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3805
+ const result = sendCommand(deviceId, {
3806
+ command: 'audio.start',
3807
+ commandId,
3808
+ payload: {
3809
+ streamId,
3810
+ mimeType: safeString(options.mimeType, 120) || 'audio/webm;codecs=opus',
3811
+ sampleRate: clampNumber(options.sampleRate, 8000, 192000, 48000),
3812
+ channels: clampNumber(options.channels, 1, 8, 2),
3813
+ requestedAt: now
3814
+ }
3815
+ });
3816
+ if (!result.ok) {
3817
+ return result;
3818
+ }
3819
+
3820
+ device.activeAudioStream = {
3821
+ streamId,
3822
+ commandId,
3823
+ active: true,
3824
+ open: false,
3825
+ startedAt: now,
3826
+ stoppedAt: '',
3827
+ stopReason: '',
3828
+ lastFrameAt: '',
3829
+ lastFrameSeq: 0,
3830
+ framesReceived: 0,
3831
+ mimeType: safeString(options.mimeType, 120) || 'audio/webm;codecs=opus',
3832
+ sampleRate: clampNumber(options.sampleRate, 8000, 192000, 48000),
3833
+ channels: clampNumber(options.channels, 1, 8, 2)
3834
+ };
3835
+ device.counters.audioStreamsStarted += 1;
3836
+ emitRemoteEvent('RemoteAudioStreamStarted', device, {
3837
+ streamId,
3838
+ commandId
3839
+ });
3840
+ return { ok: true, commandId, streamId };
3841
+ }
3842
+
3843
+ function stopAudioStream(deviceId, options = {}) {
3844
+ const device = devices.get(String(deviceId || ''));
3845
+ if (!device) {
3846
+ return { ok: false, error: 'device-not-found' };
3847
+ }
3848
+ if (!device.connected) {
3849
+ return { ok: false, error: 'device-not-connected' };
3850
+ }
3851
+ if (device.synthetic === true || !device.socket || device.socket.destroyed) {
3852
+ return { ok: false, error: 'device-audio-unavailable' };
3853
+ }
3854
+ const streamId = safeString(options.streamId, 128) || device.activeAudioStream?.streamId || '';
3855
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
3856
+ const result = sendCommand(deviceId, {
3857
+ command: 'audio.stop',
3858
+ commandId,
3859
+ payload: {
3860
+ streamId,
3861
+ requestedAt: new Date().toISOString()
3862
+ }
3863
+ });
3864
+ if (!result.ok) {
3865
+ return result;
3866
+ }
3867
+ if (device.activeAudioStream && (!streamId || device.activeAudioStream.streamId === streamId)) {
3868
+ device.activeAudioStream.active = false;
3869
+ device.activeAudioStream.stoppedAt = new Date().toISOString();
3870
+ device.activeAudioStream.stopReason = 'manager-request';
3871
+ }
3872
+ device.counters.audioStreamsStopped += 1;
3873
+ emitRemoteEvent('RemoteAudioStreamStopped', device, {
3874
+ streamId,
3875
+ commandId
3876
+ });
3877
+ return { ok: true, commandId, streamId };
3878
+ }
3879
+
3880
+ function getDeviceLiveFrame(deviceId, options = {}) {
3881
+ const device = devices.get(String(deviceId || ''));
3882
+ return serializeRemoteFrame(device?.latestLiveFrame, device?.deviceId, 'live', {
3883
+ includeDataUrl: options.includeDataUrl === true
3884
+ });
3885
+ }
3886
+
3887
+ function getDeviceThumbnail(deviceId, options = {}) {
3888
+ const device = devices.get(String(deviceId || ''));
3889
+ return serializeRemoteFrame(device?.latestThumbnail, device?.deviceId, 'thumbnail', {
3890
+ includeDataUrl: options.includeDataUrl === true
3891
+ });
3892
+ }
3893
+
3894
+ function getFramePayload(deviceId, frameKind, options = {}) {
3895
+ const device = devices.get(String(deviceId || ''));
3896
+ if (!device) {
3897
+ return null;
3898
+ }
3899
+
3900
+ const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
3901
+ const frame = findRecentFramePayload(device, kind, options);
3902
+ if (!frame) {
3903
+ return null;
3904
+ }
3905
+
3906
+ return {
3907
+ frame: serializeRemoteFrame(frame, device.deviceId, kind, { includeDataUrl: false }),
3908
+ payload: frame.payload,
3909
+ mimeType: safeString(frame.mimeType || frame.format || 'application/octet-stream', 120) || 'application/octet-stream',
3910
+ byteLength: frame.payload.length
3911
+ };
3912
+ }
3913
+
3914
+ return {
3915
+ start,
3916
+ close,
3917
+ getStatus,
3918
+ listDevices,
3919
+ listTaskBatches,
3920
+ getLatestTaskBatch,
3921
+ listDeviceFrames,
3922
+ disconnectDevice,
3923
+ sendCommand,
3924
+ sendInputControl,
3925
+ requestAgentTask,
3926
+ requestAgentTaskBatch,
3927
+ setHostTarget,
3928
+ requestThumbnail,
3929
+ startLiveStream,
3930
+ stopLiveStream,
3931
+ startAudioStream,
3932
+ stopAudioStream,
3933
+ getDeviceLiveFrame,
3934
+ getDeviceThumbnail,
3935
+ getFramePayload,
3936
+ seedSyntheticFleet,
3937
+ clearSyntheticFleet,
3938
+ getPairToken: () => pairToken
3939
+ };
3940
+ }
3941
+
3942
+ export function getDefaultRemoteAgentDeviceInfo() {
3943
+ return {
3944
+ hostname: os.hostname(),
3945
+ platform: os.platform(),
3946
+ arch: os.arch(),
3947
+ pid: process.pid
3948
+ };
3949
+ }