@mindexec/cli 0.2.1 → 0.2.3

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.
Files changed (26) hide show
  1. package/README.md +38 -0
  2. package/package.json +6 -4
  3. package/remote-hub.js +571 -0
  4. package/scripts/remote-hub-smoke.mjs +117 -0
  5. package/server.js +108 -28
  6. package/wwwroot/_content/MindExecution.Shared/css/mind-map-overrides.css +11 -0
  7. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +76 -1
  8. package/wwwroot/_content/MindExecution.Shared/js/mind-map-css3d-manager.js +629 -5
  9. package/wwwroot/_content/MindExecution.Shared/js/mind-map-lod-renderer.js +16 -0
  10. package/wwwroot/_content/MindExecution.Shared/js/mind-map-nodes.js +16 -5
  11. package/wwwroot/_framework/MindExecution.Core.5luow1xgjs.dll +0 -0
  12. package/wwwroot/_framework/{MindExecution.Kernel.gwwc40sc45.dll → MindExecution.Kernel.mot9nj6bzm.dll} +0 -0
  13. package/wwwroot/_framework/{MindExecution.Plugins.Admin.0jgrn1sckv.dll → MindExecution.Plugins.Admin.x9v2drg2f7.dll} +0 -0
  14. package/wwwroot/_framework/{MindExecution.Plugins.Business.13mme2qcag.dll → MindExecution.Plugins.Business.b0kjoyx31x.dll} +0 -0
  15. package/wwwroot/_framework/{MindExecution.Plugins.Concept.dfp2mdt45q.dll → MindExecution.Plugins.Concept.6tojojgh1a.dll} +0 -0
  16. package/wwwroot/_framework/{MindExecution.Plugins.Directory.3w4t6n3se0.dll → MindExecution.Plugins.Directory.fqtbuqadsx.dll} +0 -0
  17. package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.s0qpntz420.dll → MindExecution.Plugins.PlanMaster.j7llfeae6l.dll} +0 -0
  18. package/wwwroot/_framework/{MindExecution.Plugins.YouTube.iu11fq8d16.dll → MindExecution.Plugins.YouTube.yo5fwdhugr.dll} +0 -0
  19. package/wwwroot/_framework/{MindExecution.Shared.7j27dcqnrc.dll → MindExecution.Shared.0qi7vbn9a4.dll} +0 -0
  20. package/wwwroot/_framework/MindExecution.Web.6cv7ad7rik.dll +0 -0
  21. package/wwwroot/_framework/blazor.boot.json +21 -21
  22. package/wwwroot/index.html +3 -3
  23. package/wwwroot/service-worker-assets.js +28 -28
  24. package/wwwroot/service-worker.js +1 -1
  25. package/wwwroot/_framework/MindExecution.Core.1q1trifbuu.dll +0 -0
  26. package/wwwroot/_framework/MindExecution.Web.pq1ty8ov2v.dll +0 -0
package/README.md CHANGED
@@ -45,6 +45,44 @@ precompressed static files from the npm bundle by default. Use
45
45
  `-KeepPackagedGalleryImages` or `-KeepPackagedPrecompressedAssets` only when
46
46
  testing a full untrimmed local package.
47
47
 
48
+ ## Remote Direct quick start
49
+
50
+ LocalBridge also starts a separate RemoteHub listener for direct remote-agent
51
+ experiments. By default it binds to loopback only:
52
+
53
+ ```bash
54
+ npx @mindexec/cli
55
+ npx @mindexec/remote connect --manager 127.0.0.1:5197 --pair <pair-token>
56
+ ```
57
+
58
+ Read the pair token from the protected local endpoint:
59
+
60
+ ```bash
61
+ curl -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/status
62
+ ```
63
+
64
+ LAN/direct mode must be enabled explicitly by changing the RemoteHub bind host:
65
+
66
+ ```powershell
67
+ $env:REMOTE_HUB_HOST="0.0.0.0"
68
+ $env:REMOTE_HUB_PORT="5197"
69
+ $env:REMOTE_HUB_PAIR_TOKEN="<strong-token>"
70
+ npx @mindexec/cli
71
+ ```
72
+
73
+ Device inventory is intentionally not paginated:
74
+
75
+ ```bash
76
+ curl -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/devices
77
+ ```
78
+
79
+ Request and read the latest view-only thumbnail for a connected device:
80
+
81
+ ```bash
82
+ curl -X POST -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/devices/<device-id>/thumbnail/request
83
+ curl -H "X-Bridge-Token: <bridge-token>" http://127.0.0.1:5147/api/remote/devices/<device-id>/thumbnail
84
+ ```
85
+
48
86
  ?먮뒗 ?꾩뿭 ?ㅼ튂 ???ㅽ뻾?⑸땲??
49
87
 
50
88
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindexec/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "MindExec local runtime and bridge CLI",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -8,8 +8,9 @@
8
8
  "start-bridge.bat",
9
9
  "start-bridge.sh",
10
10
  "launch-bridge.cjs",
11
- "server.js",
12
- "codex-runtime.js",
11
+ "server.js",
12
+ "remote-hub.js",
13
+ "codex-runtime.js",
13
14
  "port-guard.cjs",
14
15
  "wwwroot/",
15
16
  "scripts/",
@@ -19,7 +20,8 @@
19
20
  "scripts": {
20
21
  "start": "node launch-bridge.cjs",
21
22
  "dev": "node launch-bridge.cjs --watch",
22
- "test:syntax": "node --check server.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs",
23
+ "test:syntax": "node --check server.js && node --check remote-hub.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs && node --check scripts/remote-hub-smoke.mjs",
24
+ "test:remote": "node scripts/remote-hub-smoke.mjs",
23
25
  "pack:dry": "npm pack --dry-run",
24
26
  "setup:grammars": "node scripts/setup-tree-sitter-grammars.mjs",
25
27
  "postinstall": "npm run setup:grammars"
package/remote-hub.js ADDED
@@ -0,0 +1,571 @@
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 = '127.0.0.1';
7
+ const DEFAULT_HEARTBEAT_MS = 5000;
8
+ const MAX_LINE_CHARS = 4 * 1024 * 1024;
9
+ const MAX_THUMBNAIL_BASE64_CHARS = 3 * 1024 * 1024;
10
+ const REMOTE_PROTOCOL_VERSION = 1;
11
+
12
+ function isEnabledValue(value, fallback = true) {
13
+ if (value === undefined || value === null || value === '') {
14
+ return fallback;
15
+ }
16
+
17
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
18
+ }
19
+
20
+ function isDisabledValue(value) {
21
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
22
+ }
23
+
24
+ function clampNumber(value, min, max, fallback) {
25
+ const number = Number(value);
26
+ if (!Number.isFinite(number)) {
27
+ return fallback;
28
+ }
29
+
30
+ return Math.max(min, Math.min(max, Math.floor(number)));
31
+ }
32
+
33
+ function normalizePort(value) {
34
+ return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
35
+ }
36
+
37
+ function safeString(value, maxLength = 200) {
38
+ return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
39
+ }
40
+
41
+ function normalizeDeviceId(value) {
42
+ const id = safeString(value, 128).replace(/[^a-zA-Z0-9_.:-]/g, '-');
43
+ return id || crypto.randomUUID();
44
+ }
45
+
46
+ function maskToken(token) {
47
+ const value = String(token || '');
48
+ if (value.length <= 8) {
49
+ return value ? '****' : '';
50
+ }
51
+
52
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
53
+ }
54
+
55
+ function timingSafeStringEqual(left, right) {
56
+ const a = Buffer.from(String(left || ''), 'utf8');
57
+ const b = Buffer.from(String(right || ''), 'utf8');
58
+ if (a.length !== b.length) {
59
+ return false;
60
+ }
61
+
62
+ return crypto.timingSafeEqual(a, b);
63
+ }
64
+
65
+ function parseJsonLine(line) {
66
+ const text = String(line || '').trim();
67
+ if (!text) {
68
+ return null;
69
+ }
70
+
71
+ return JSON.parse(text);
72
+ }
73
+
74
+ function serializeDevice(device) {
75
+ if (!device) {
76
+ return null;
77
+ }
78
+
79
+ return {
80
+ deviceId: device.deviceId,
81
+ sessionId: device.sessionId,
82
+ deviceName: device.deviceName,
83
+ hostname: device.hostname,
84
+ platform: device.platform,
85
+ arch: device.arch,
86
+ pid: device.pid,
87
+ agentVersion: device.agentVersion,
88
+ capabilities: { ...device.capabilities },
89
+ connected: device.connected,
90
+ connectedAt: device.connectedAt,
91
+ disconnectedAt: device.disconnectedAt,
92
+ lastSeenAt: device.lastSeenAt,
93
+ lastStatusAt: device.lastStatusAt,
94
+ lastDisconnectReason: device.lastDisconnectReason,
95
+ remoteAddress: device.remoteAddress,
96
+ remotePort: device.remotePort,
97
+ latestThumbnail: device.latestThumbnail ? { ...device.latestThumbnail } : null,
98
+ status: { ...device.status },
99
+ counters: { ...device.counters }
100
+ };
101
+ }
102
+
103
+ function writeJsonLine(socket, payload) {
104
+ if (!socket || socket.destroyed) {
105
+ return false;
106
+ }
107
+
108
+ socket.write(`${JSON.stringify(payload)}\n`);
109
+ return true;
110
+ }
111
+
112
+ export function createRemoteHub(options = {}) {
113
+ const env = options.env || process.env;
114
+ const logEvent = options.logEvent || (() => {});
115
+ const logWarn = options.logWarn || (() => {});
116
+ const emitEvent = options.emitEvent || (() => {});
117
+
118
+ const enabled = isEnabledValue(env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
119
+ && !isDisabledValue(env.REMOTE_HUB_DISABLED);
120
+ const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
121
+ const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
122
+ const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
123
+ const pairToken = safeString(
124
+ env.REMOTE_HUB_PAIR_TOKEN || env.MINDEXEC_REMOTE_PAIR_TOKEN || crypto.randomBytes(6).toString('hex'),
125
+ 256);
126
+
127
+ const devices = new Map();
128
+ const sockets = new Map();
129
+ const allSockets = new Set();
130
+ let server = null;
131
+ let started = false;
132
+ let boundPort = requestedPort;
133
+ let lastError = '';
134
+
135
+ function getStatus({ includeSecrets = false } = {}) {
136
+ const connectedDevices = [...devices.values()].filter(device => device.connected).length;
137
+ return {
138
+ enabled,
139
+ started,
140
+ host,
141
+ port: boundPort || requestedPort,
142
+ protocol: 'tcp-jsonl',
143
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
144
+ heartbeatMs,
145
+ agentPackage: '@mindexec/remote',
146
+ agentEndpoint: `${host}:${boundPort || requestedPort}`,
147
+ pairToken: includeSecrets ? pairToken : undefined,
148
+ pairTokenPreview: maskToken(pairToken),
149
+ deviceCount: devices.size,
150
+ connectedDeviceCount: connectedDevices,
151
+ canvasDeviceListMode: 'all-devices',
152
+ canvasPagination: 'none',
153
+ externalExposure: host === '0.0.0.0' || host === '::',
154
+ lastError
155
+ };
156
+ }
157
+
158
+ function listDevices() {
159
+ return [...devices.values()]
160
+ .map(serializeDevice)
161
+ .filter(Boolean)
162
+ .sort((a, b) => {
163
+ const nameCompare = String(a.deviceName || '').localeCompare(String(b.deviceName || ''));
164
+ if (nameCompare !== 0) {
165
+ return nameCompare;
166
+ }
167
+
168
+ return String(a.deviceId).localeCompare(String(b.deviceId));
169
+ });
170
+ }
171
+
172
+ function emitRemoteEvent(type, device = null, extra = {}) {
173
+ emitEvent(type, {
174
+ ...extra,
175
+ remoteHub: getStatus({ includeSecrets: false }),
176
+ device: serializeDevice(device)
177
+ });
178
+ }
179
+
180
+ function closeExistingDeviceSocket(deviceId, nextSessionId) {
181
+ const existing = devices.get(deviceId);
182
+ if (!existing?.socket || existing.socket.destroyed) {
183
+ return;
184
+ }
185
+
186
+ existing.lastDisconnectReason = 'replaced-by-new-session';
187
+ writeJsonLine(existing.socket, {
188
+ type: 'disconnect',
189
+ reason: 'replaced-by-new-session',
190
+ nextSessionId
191
+ });
192
+ existing.socket.destroy();
193
+ }
194
+
195
+ function attachDevice(socket, hello) {
196
+ const now = new Date().toISOString();
197
+ const sessionId = crypto.randomUUID();
198
+ const deviceId = normalizeDeviceId(hello.deviceId);
199
+
200
+ closeExistingDeviceSocket(deviceId, sessionId);
201
+
202
+ const device = {
203
+ socket,
204
+ deviceId,
205
+ sessionId,
206
+ deviceName: safeString(hello.deviceName || hello.hostname || deviceId, 120),
207
+ hostname: safeString(hello.hostname, 120),
208
+ platform: safeString(hello.platform, 80),
209
+ arch: safeString(hello.arch, 40),
210
+ pid: Number.isFinite(Number(hello.pid)) ? Number(hello.pid) : 0,
211
+ agentVersion: safeString(hello.agentVersion, 40),
212
+ capabilities: typeof hello.capabilities === 'object' && hello.capabilities
213
+ ? { ...hello.capabilities }
214
+ : {},
215
+ connected: true,
216
+ connectedAt: now,
217
+ disconnectedAt: '',
218
+ lastSeenAt: now,
219
+ lastStatusAt: '',
220
+ lastDisconnectReason: '',
221
+ remoteAddress: socket.remoteAddress || '',
222
+ remotePort: socket.remotePort || 0,
223
+ status: {},
224
+ latestThumbnail: null,
225
+ counters: {
226
+ messagesReceived: 1,
227
+ statusReceived: 0,
228
+ commandsSent: 0,
229
+ commandResultsReceived: 0,
230
+ thumbnailFramesReceived: 0,
231
+ thumbnailFramesDropped: 0
232
+ }
233
+ };
234
+
235
+ devices.set(deviceId, device);
236
+ sockets.set(socket, deviceId);
237
+ writeJsonLine(socket, {
238
+ type: 'welcome',
239
+ protocolVersion: REMOTE_PROTOCOL_VERSION,
240
+ sessionId,
241
+ deviceId,
242
+ heartbeatMs,
243
+ serverTime: now
244
+ });
245
+
246
+ logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
247
+ emitRemoteEvent('RemoteDeviceConnected', device);
248
+ return device;
249
+ }
250
+
251
+ function detachSocket(socket, reason = 'socket-closed') {
252
+ const deviceId = sockets.get(socket);
253
+ sockets.delete(socket);
254
+ if (!deviceId) {
255
+ return;
256
+ }
257
+
258
+ const device = devices.get(deviceId);
259
+ if (!device || device.socket !== socket) {
260
+ return;
261
+ }
262
+
263
+ device.connected = false;
264
+ device.socket = null;
265
+ device.disconnectedAt = new Date().toISOString();
266
+ device.lastDisconnectReason = reason;
267
+ logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
268
+ emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
269
+ }
270
+
271
+ function handleAgentMessage(socket, state, message) {
272
+ if (!message || typeof message !== 'object') {
273
+ return;
274
+ }
275
+
276
+ if (!state.authenticated) {
277
+ if (message.type !== 'hello') {
278
+ writeJsonLine(socket, { type: 'error', error: 'hello-required' });
279
+ socket.destroy();
280
+ return;
281
+ }
282
+
283
+ if (!timingSafeStringEqual(message.pairToken, pairToken)) {
284
+ writeJsonLine(socket, { type: 'error', error: 'invalid-pair-token' });
285
+ socket.destroy();
286
+ return;
287
+ }
288
+
289
+ state.authenticated = true;
290
+ state.device = attachDevice(socket, message);
291
+ return;
292
+ }
293
+
294
+ const device = state.device;
295
+ if (!device) {
296
+ socket.destroy();
297
+ return;
298
+ }
299
+
300
+ device.counters.messagesReceived += 1;
301
+ device.lastSeenAt = new Date().toISOString();
302
+
303
+ switch (message.type) {
304
+ case 'heartbeat':
305
+ case 'status':
306
+ device.status = typeof message.status === 'object' && message.status
307
+ ? { ...message.status }
308
+ : {};
309
+ device.lastStatusAt = device.lastSeenAt;
310
+ device.counters.statusReceived += 1;
311
+ emitRemoteEvent('RemoteDeviceStatus', device);
312
+ break;
313
+ case 'command.result':
314
+ device.counters.commandResultsReceived += 1;
315
+ emitRemoteEvent('RemoteCommandResult', device, {
316
+ commandId: safeString(message.commandId, 128),
317
+ result: message.result ?? null,
318
+ error: safeString(message.error, 500)
319
+ });
320
+ break;
321
+ case 'thumbnail.frame': {
322
+ const frameData = safeString(message.data, MAX_THUMBNAIL_BASE64_CHARS + 1);
323
+ const frameSeq = Number(message.frameSeq);
324
+ if (!frameData || frameData.length > MAX_THUMBNAIL_BASE64_CHARS || !Number.isFinite(frameSeq)) {
325
+ device.counters.thumbnailFramesDropped += 1;
326
+ emitRemoteEvent('RemoteFrameDropped', device, {
327
+ reason: 'invalid-thumbnail-frame',
328
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null
329
+ });
330
+ break;
331
+ }
332
+
333
+ const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
334
+ const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
335
+ device.latestThumbnail = {
336
+ streamId: safeString(message.streamId, 128) || 'thumbnail',
337
+ frameSeq,
338
+ commandId: safeString(message.commandId, 128),
339
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
340
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
341
+ mimeType,
342
+ format: mimeType,
343
+ capturedAt,
344
+ receivedAt: device.lastSeenAt,
345
+ byteLength: Math.floor(frameData.length * 3 / 4),
346
+ dataUrl: frameData.startsWith('data:')
347
+ ? frameData
348
+ : `data:${mimeType};base64,${frameData}`
349
+ };
350
+ device.counters.thumbnailFramesReceived += 1;
351
+ emitRemoteEvent('RemoteFrameReceived', device, {
352
+ streamId: device.latestThumbnail.streamId,
353
+ frameSeq,
354
+ width: device.latestThumbnail.width,
355
+ height: device.latestThumbnail.height
356
+ });
357
+ break;
358
+ }
359
+ default:
360
+ emitRemoteEvent('RemoteAgentMessageIgnored', device, {
361
+ messageType: safeString(message.type, 80)
362
+ });
363
+ break;
364
+ }
365
+ }
366
+
367
+ function handleSocket(socket) {
368
+ allSockets.add(socket);
369
+ socket.setEncoding('utf8');
370
+ socket.setNoDelay(true);
371
+ socket.setKeepAlive(true, heartbeatMs);
372
+
373
+ const state = {
374
+ authenticated: false,
375
+ device: null,
376
+ buffer: ''
377
+ };
378
+
379
+ const helloTimer = setTimeout(() => {
380
+ if (!state.authenticated) {
381
+ writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
382
+ socket.destroy();
383
+ }
384
+ }, 10000);
385
+
386
+ socket.on('data', chunk => {
387
+ state.buffer += chunk;
388
+ if (state.buffer.length > MAX_LINE_CHARS) {
389
+ writeJsonLine(socket, { type: 'error', error: 'message-too-large' });
390
+ socket.destroy();
391
+ return;
392
+ }
393
+
394
+ let newlineIndex = state.buffer.indexOf('\n');
395
+ while (newlineIndex >= 0) {
396
+ const line = state.buffer.slice(0, newlineIndex);
397
+ state.buffer = state.buffer.slice(newlineIndex + 1);
398
+
399
+ try {
400
+ const message = parseJsonLine(line);
401
+ handleAgentMessage(socket, state, message);
402
+ } catch (err) {
403
+ writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
404
+ logWarn('remote', `invalid agent message: ${err?.message || err}`);
405
+ }
406
+
407
+ newlineIndex = state.buffer.indexOf('\n');
408
+ }
409
+ });
410
+
411
+ socket.on('close', () => {
412
+ allSockets.delete(socket);
413
+ clearTimeout(helloTimer);
414
+ detachSocket(socket);
415
+ });
416
+ socket.on('error', err => {
417
+ allSockets.delete(socket);
418
+ clearTimeout(helloTimer);
419
+ detachSocket(socket, err?.message || 'socket-error');
420
+ });
421
+ }
422
+
423
+ async function start() {
424
+ if (!enabled || started) {
425
+ return getStatus({ includeSecrets: false });
426
+ }
427
+
428
+ await new Promise((resolve, reject) => {
429
+ server = net.createServer(handleSocket);
430
+ server.once('error', err => {
431
+ lastError = err?.message || String(err);
432
+ server = null;
433
+ reject(err);
434
+ });
435
+ server.listen(requestedPort, host, () => {
436
+ started = true;
437
+ boundPort = server.address()?.port || requestedPort;
438
+ lastError = '';
439
+ logEvent('remote', `RemoteHub listening on tcp://${host}:${boundPort}`, 'success');
440
+ if (host === '0.0.0.0' || host === '::') {
441
+ logWarn('remote', 'RemoteHub is externally reachable. Use a strong pairing token and trusted network.');
442
+ }
443
+ emitRemoteEvent('RemoteHubStarted', null);
444
+ resolve();
445
+ });
446
+ });
447
+
448
+ return getStatus({ includeSecrets: false });
449
+ }
450
+
451
+ async function close() {
452
+ for (const device of devices.values()) {
453
+ if (device.socket && !device.socket.destroyed) {
454
+ writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
455
+ device.socket.destroy();
456
+ }
457
+ }
458
+
459
+ for (const socket of allSockets) {
460
+ if (!socket.destroyed) {
461
+ socket.destroy();
462
+ }
463
+ }
464
+
465
+ sockets.clear();
466
+ if (!server) {
467
+ started = false;
468
+ return;
469
+ }
470
+
471
+ await new Promise(resolve => {
472
+ let settled = false;
473
+ const settle = () => {
474
+ if (settled) {
475
+ return;
476
+ }
477
+
478
+ settled = true;
479
+ clearTimeout(closeTimer);
480
+ resolve();
481
+ };
482
+
483
+ const closeTimer = setTimeout(settle, 1000);
484
+ closeTimer.unref?.();
485
+
486
+ try {
487
+ server.close(settle);
488
+ } catch {
489
+ settle();
490
+ }
491
+ });
492
+
493
+ server = null;
494
+ started = false;
495
+ }
496
+
497
+ function disconnectDevice(deviceId, reason = 'manager-disconnect') {
498
+ const device = devices.get(String(deviceId || ''));
499
+ if (!device?.socket || device.socket.destroyed) {
500
+ return false;
501
+ }
502
+
503
+ writeJsonLine(device.socket, { type: 'disconnect', reason });
504
+ device.socket.destroy();
505
+ return true;
506
+ }
507
+
508
+ function sendCommand(deviceId, command) {
509
+ const device = devices.get(String(deviceId || ''));
510
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
511
+ return { ok: false, error: 'device-not-connected' };
512
+ }
513
+
514
+ const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
515
+ const payload = {
516
+ type: 'command',
517
+ commandId,
518
+ command: safeString(command?.command || 'ping', 80),
519
+ payload: command?.payload ?? null,
520
+ issuedAt: new Date().toISOString()
521
+ };
522
+
523
+ writeJsonLine(device.socket, payload);
524
+ device.counters.commandsSent += 1;
525
+ emitRemoteEvent('RemoteCommandQueued', device, {
526
+ commandId,
527
+ command: payload.command
528
+ });
529
+ return { ok: true, commandId };
530
+ }
531
+
532
+ function requestThumbnail(deviceId, options = {}) {
533
+ return sendCommand(deviceId, {
534
+ command: 'thumbnail.capture',
535
+ commandId: safeString(options.commandId, 128) || crypto.randomUUID(),
536
+ payload: {
537
+ streamId: safeString(options.streamId, 128) || `thumb-${Date.now()}`,
538
+ maxWidth: clampNumber(options.maxWidth, 160, 1920, 360),
539
+ maxHeight: clampNumber(options.maxHeight, 90, 1080, 220),
540
+ quality: clampNumber(options.quality, 20, 95, 55),
541
+ requestedAt: new Date().toISOString()
542
+ }
543
+ });
544
+ }
545
+
546
+ function getDeviceThumbnail(deviceId) {
547
+ const device = devices.get(String(deviceId || ''));
548
+ return device?.latestThumbnail ? { ...device.latestThumbnail } : null;
549
+ }
550
+
551
+ return {
552
+ start,
553
+ close,
554
+ getStatus,
555
+ listDevices,
556
+ disconnectDevice,
557
+ sendCommand,
558
+ requestThumbnail,
559
+ getDeviceThumbnail,
560
+ getPairToken: () => pairToken
561
+ };
562
+ }
563
+
564
+ export function getDefaultRemoteAgentDeviceInfo() {
565
+ return {
566
+ hostname: os.hostname(),
567
+ platform: os.platform(),
568
+ arch: os.arch(),
569
+ pid: process.pid
570
+ };
571
+ }