@livedesk/client 0.1.0

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,930 @@
1
+ #!/usr/bin/env node
2
+
3
+ import net from 'net';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import crypto from 'crypto';
7
+ import { promises as fs } from 'fs';
8
+
9
+ const AGENT_VERSION = '0.1.15-livedesk.1';
10
+ const DEFAULT_MANAGER = '127.0.0.1:5197';
11
+ const DEFAULT_HEARTBEAT_MS = 5000;
12
+ const DEFAULT_AI_MODEL = 'gpt-5.4-mini';
13
+ const DEFAULT_LIVE_FPS = 20;
14
+ const MAX_LIVE_FPS = 24;
15
+ const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
16
+ const MAX_AI_OUTPUT_CHARS = 6000;
17
+
18
+ function printHelp() {
19
+ console.log(`
20
+ LiveDesk Client
21
+
22
+ Usage:
23
+ npx @livedesk/client connect --manager 127.0.0.1:5197 --pair <token>
24
+
25
+ Options:
26
+ --manager <host:port> RemoteHub address. Default: ${DEFAULT_MANAGER}
27
+ --pair <token> RemoteHub pairing token. Can also use LIVEDESK_CLIENT_PAIR_TOKEN.
28
+ --name <name> Friendly device name. Default: OS hostname.
29
+ --heartbeat <ms> Status heartbeat interval. Default: ${DEFAULT_HEARTBEAT_MS}
30
+ --device-id <id> Stable device id. Default: generated and saved per OS user.
31
+ --thumbnail Enable thumbnail capture when supported. Default on Windows.
32
+ --no-thumbnail Disable thumbnail capture capability.
33
+ --live Enable focused live screen streaming. Default on.
34
+ --no-live Disable focused live screen streaming.
35
+ --tasks Enable safe remote task inbox. Default on.
36
+ --no-tasks Disable remote task dispatch capability.
37
+ --ai Enable OpenAI-backed remote AI assist tasks.
38
+ --no-ai Disable OpenAI-backed remote AI assist tasks.
39
+ --ai-model <model> OpenAI model for AI assist. Default: ${DEFAULT_AI_MODEL}
40
+ --fake-ai Use deterministic AI assist responses for smoke tests.
41
+ --fake-thumbnail Use generated thumbnail frames for smoke tests.
42
+ --exit-on-disconnect Exit after manager disconnect. Useful for tests.
43
+ --once Connect, send one status packet, and exit after welcome.
44
+ --version Show the agent version.
45
+ --help Show this help.
46
+ `.trim());
47
+ }
48
+
49
+ function isTruthy(value) {
50
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
51
+ }
52
+
53
+ function isFalsy(value) {
54
+ return /^(0|false|no|off)$/i.test(String(value || '').trim());
55
+ }
56
+
57
+ function parseArgs(argv) {
58
+ const defaultDesktopCaptureEnabled = os.platform() === 'win32';
59
+ const result = {
60
+ command: 'connect',
61
+ manager: process.env.LIVEDESK_CLIENT_MANAGER || process.env.MINDEXEC_REMOTE_MANAGER || DEFAULT_MANAGER,
62
+ pair: process.env.LIVEDESK_CLIENT_PAIR_TOKEN || process.env.MINDEXEC_REMOTE_PAIR_TOKEN || '',
63
+ name: process.env.LIVEDESK_CLIENT_NAME || process.env.MINDEXEC_REMOTE_NAME || os.hostname(),
64
+ heartbeatMs: DEFAULT_HEARTBEAT_MS,
65
+ deviceId: '',
66
+ thumbnailEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_THUMBNAIL ?? process.env.MINDEXEC_REMOTE_THUMBNAIL),
67
+ liveEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_LIVE ?? process.env.MINDEXEC_REMOTE_LIVE),
68
+ taskEnabled: !isFalsy(process.env.LIVEDESK_CLIENT_TASKS ?? process.env.MINDEXEC_REMOTE_TASKS),
69
+ aiEnabled: isTruthy(process.env.LIVEDESK_CLIENT_AI || process.env.MINDEXEC_REMOTE_AI),
70
+ aiModel: process.env.LIVEDESK_CLIENT_AI_MODEL || process.env.MINDEXEC_REMOTE_AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_AI_MODEL,
71
+ openAiApiKey: process.env.OPENAI_API_KEY || '',
72
+ fakeAi: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_AI || process.env.MINDEXEC_REMOTE_FAKE_AI),
73
+ fakeThumbnail: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_THUMBNAIL || process.env.MINDEXEC_REMOTE_FAKE_THUMBNAIL),
74
+ exitOnDisconnect: false,
75
+ once: false,
76
+ version: false,
77
+ help: false
78
+ };
79
+
80
+ const args = [...argv];
81
+ if (args[0] && !args[0].startsWith('-')) {
82
+ result.command = args.shift();
83
+ }
84
+
85
+ for (let index = 0; index < args.length; index += 1) {
86
+ const arg = args[index];
87
+ switch (arg) {
88
+ case '--manager':
89
+ result.manager = args[++index] || result.manager;
90
+ break;
91
+ case '--pair':
92
+ result.pair = args[++index] || '';
93
+ break;
94
+ case '--name':
95
+ result.name = args[++index] || result.name;
96
+ break;
97
+ case '--heartbeat':
98
+ result.heartbeatMs = Number(args[++index] || DEFAULT_HEARTBEAT_MS);
99
+ break;
100
+ case '--device-id':
101
+ result.deviceId = args[++index] || '';
102
+ break;
103
+ case '--thumbnail':
104
+ result.thumbnailEnabled = true;
105
+ break;
106
+ case '--no-thumbnail':
107
+ result.thumbnailEnabled = false;
108
+ break;
109
+ case '--live':
110
+ result.liveEnabled = true;
111
+ break;
112
+ case '--no-live':
113
+ result.liveEnabled = false;
114
+ break;
115
+ case '--tasks':
116
+ result.taskEnabled = true;
117
+ break;
118
+ case '--no-tasks':
119
+ result.taskEnabled = false;
120
+ break;
121
+ case '--ai':
122
+ result.aiEnabled = true;
123
+ result.taskEnabled = true;
124
+ break;
125
+ case '--no-ai':
126
+ result.aiEnabled = false;
127
+ break;
128
+ case '--ai-model':
129
+ result.aiModel = args[++index] || result.aiModel;
130
+ break;
131
+ case '--fake-ai':
132
+ result.fakeAi = true;
133
+ result.aiEnabled = true;
134
+ result.taskEnabled = true;
135
+ break;
136
+ case '--fake-thumbnail':
137
+ result.fakeThumbnail = true;
138
+ result.thumbnailEnabled = true;
139
+ break;
140
+ case '--exit-on-disconnect':
141
+ result.exitOnDisconnect = true;
142
+ break;
143
+ case '--once':
144
+ result.once = true;
145
+ break;
146
+ case '--help':
147
+ case '-h':
148
+ result.help = true;
149
+ break;
150
+ case '--version':
151
+ case '-v':
152
+ result.version = true;
153
+ break;
154
+ default:
155
+ throw new Error(`Unknown option: ${arg}`);
156
+ }
157
+ }
158
+
159
+ if (!Number.isFinite(result.heartbeatMs) || result.heartbeatMs < 1000) {
160
+ result.heartbeatMs = DEFAULT_HEARTBEAT_MS;
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ function parseManagerAddress(value) {
167
+ let text = String(value || DEFAULT_MANAGER).trim();
168
+ text = text.replace(/^tcp:\/\//i, '');
169
+ const separator = text.lastIndexOf(':');
170
+ if (separator <= 0) {
171
+ return {
172
+ host: text || '127.0.0.1',
173
+ port: 5197
174
+ };
175
+ }
176
+
177
+ const host = text.slice(0, separator).trim() || '127.0.0.1';
178
+ const port = Number(text.slice(separator + 1));
179
+ return {
180
+ host,
181
+ port: Number.isFinite(port) ? port : 5197
182
+ };
183
+ }
184
+
185
+ function normalizeDeviceId(value) {
186
+ return String(value || '').trim().replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
187
+ }
188
+
189
+ async function getDeviceId(explicitDeviceId = '') {
190
+ const explicit = normalizeDeviceId(explicitDeviceId);
191
+ if (explicit) {
192
+ return explicit;
193
+ }
194
+
195
+ const stateDir = path.join(os.homedir(), '.livedesk-client');
196
+ const statePath = path.join(stateDir, 'device.json');
197
+ try {
198
+ const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
199
+ const existing = normalizeDeviceId(state.deviceId);
200
+ if (existing) {
201
+ return existing;
202
+ }
203
+ } catch {
204
+ // Create a new device id below.
205
+ }
206
+
207
+ const deviceId = `livedesk-${crypto.randomUUID()}`;
208
+ await fs.mkdir(stateDir, { recursive: true });
209
+ await fs.writeFile(statePath, JSON.stringify({
210
+ deviceId,
211
+ createdAt: new Date().toISOString()
212
+ }, null, 2));
213
+ return deviceId;
214
+ }
215
+
216
+ function getStatus() {
217
+ const totalMem = os.totalmem();
218
+ const freeMem = os.freemem();
219
+ return {
220
+ uptimeSec: Math.round(os.uptime()),
221
+ loadavg: os.loadavg(),
222
+ totalMem,
223
+ freeMem,
224
+ usedMemRatio: totalMem > 0 ? Number(((totalMem - freeMem) / totalMem).toFixed(4)) : 0,
225
+ platform: os.platform(),
226
+ release: os.release(),
227
+ timestamp: new Date().toISOString()
228
+ };
229
+ }
230
+
231
+ function writeJsonLine(socket, payload) {
232
+ if (!socket || socket.destroyed) {
233
+ return false;
234
+ }
235
+
236
+ socket.write(`${JSON.stringify(payload)}\n`);
237
+ return true;
238
+ }
239
+
240
+ function wait(ms) {
241
+ return new Promise(resolve => setTimeout(resolve, ms));
242
+ }
243
+
244
+ function clampNumber(value, min, max, fallback) {
245
+ const number = Number(value);
246
+ if (!Number.isFinite(number)) {
247
+ return fallback;
248
+ }
249
+
250
+ return Math.max(min, Math.min(max, Math.floor(number)));
251
+ }
252
+
253
+ function createFakeThumbnailFrame(options, payload, frameSeq) {
254
+ const width = clampNumber(payload?.maxWidth, 160, 1920, 360);
255
+ const height = clampNumber(payload?.maxHeight, 90, 1080, 220);
256
+ const now = new Date().toISOString();
257
+ const title = String(options.name || os.hostname()).replace(/[<>&"']/g, '_');
258
+ const svg = [
259
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
260
+ '<rect width="100%" height="100%" fill="#0f172a"/>',
261
+ '<rect x="14" y="14" width="calc(100% - 28px)" height="calc(100% - 28px)" rx="12" fill="#1e293b" stroke="#38bdf8" stroke-width="2"/>',
262
+ `<text x="28" y="48" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${title}</text>`,
263
+ `<text x="28" y="82" fill="#93c5fd" font-family="Arial, sans-serif" font-size="14">LiveDesk Client Thumbnail</text>`,
264
+ `<text x="28" y="${height - 30}" fill="#94a3b8" font-family="Consolas, monospace" font-size="12">${now}</text>`,
265
+ '</svg>'
266
+ ].join('');
267
+
268
+ return {
269
+ frameSeq,
270
+ width,
271
+ height,
272
+ mimeType: 'image/svg+xml',
273
+ data: Buffer.from(svg, 'utf8').toString('base64'),
274
+ capturedAt: now
275
+ };
276
+ }
277
+
278
+ function readJpegDimensions(buffer) {
279
+ if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
280
+ return { width: 0, height: 0 };
281
+ }
282
+
283
+ let offset = 2;
284
+ while (offset + 9 < buffer.length) {
285
+ if (buffer[offset] !== 0xff) {
286
+ offset += 1;
287
+ continue;
288
+ }
289
+
290
+ const marker = buffer[offset + 1];
291
+ const blockLength = buffer.readUInt16BE(offset + 2);
292
+ if (blockLength < 2) {
293
+ break;
294
+ }
295
+
296
+ const isStartOfFrame = marker >= 0xc0
297
+ && marker <= 0xcf
298
+ && ![0xc4, 0xc8, 0xcc].includes(marker);
299
+ if (isStartOfFrame) {
300
+ return {
301
+ height: buffer.readUInt16BE(offset + 5),
302
+ width: buffer.readUInt16BE(offset + 7)
303
+ };
304
+ }
305
+
306
+ offset += 2 + blockLength;
307
+ }
308
+
309
+ return { width: 0, height: 0 };
310
+ }
311
+
312
+ async function captureNativeDesktopFrame(frameSeq) {
313
+ const screenshotModule = await import('node-screenshots');
314
+ const Monitor = screenshotModule.Monitor || screenshotModule.default?.Monitor;
315
+ if (!Monitor?.all) {
316
+ throw new Error('node-screenshots Monitor API is unavailable.');
317
+ }
318
+
319
+ const monitors = Monitor.all();
320
+ const monitor = monitors.find(item => item?.isPrimary?.() === true) || monitors[0];
321
+ if (!monitor?.captureImage) {
322
+ throw new Error('No capturable desktop monitor was found.');
323
+ }
324
+
325
+ const image = await monitor.captureImage();
326
+ const jpeg = await image.toJpeg();
327
+ const buffer = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
328
+ if (buffer.toString('base64').length > MAX_FRAME_BASE64_CHARS) {
329
+ throw new Error('Desktop capture exceeded the remote frame size limit.');
330
+ }
331
+
332
+ const dimensions = readJpegDimensions(buffer);
333
+ return {
334
+ frameSeq,
335
+ width: dimensions.width,
336
+ height: dimensions.height,
337
+ mimeType: 'image/jpeg',
338
+ data: buffer.toString('base64'),
339
+ capturedAt: new Date().toISOString()
340
+ };
341
+ }
342
+
343
+ async function captureDesktopThumbnailFrame(payload, frameSeq) {
344
+ return await captureNativeDesktopFrame(frameSeq);
345
+ }
346
+
347
+ async function captureScreenFrame(options, payload, frameSeq, capability = 'thumbnail') {
348
+ const enabled = capability === 'live' ? options.liveEnabled : options.thumbnailEnabled;
349
+ if (!enabled) {
350
+ throw new Error(`${capability} capability is disabled`);
351
+ }
352
+
353
+ return options.fakeThumbnail
354
+ ? createFakeThumbnailFrame(options, payload, frameSeq)
355
+ : await captureDesktopThumbnailFrame(payload, frameSeq);
356
+ }
357
+
358
+ async function captureThumbnailFrame(options, payload, frameSeq) {
359
+ return await captureScreenFrame(options, payload, frameSeq, 'thumbnail');
360
+ }
361
+
362
+ function normalizeApprovalLevel(value) {
363
+ const level = String(value || 'task-only').trim().toLowerCase();
364
+ return level === 'ai-assist' ? 'ai-assist' : 'task-only';
365
+ }
366
+
367
+ function buildAiPrompt(options, payload, instruction) {
368
+ const title = String(payload?.title || 'Remote AI task').trim();
369
+ return [
370
+ 'You are the LiveDesk client AI assistant running on a controlled remote computer.',
371
+ 'Complete the manager task as a text-only assistant.',
372
+ 'Do not claim you used shell commands, file writes, browser automation, keyboard input, or mouse input.',
373
+ 'If the task requires external side effects, explain what would be needed and stop.',
374
+ '',
375
+ `Device: ${options.name} (${os.hostname()}, ${os.platform()} ${os.release()}, ${os.arch()})`,
376
+ `Task title: ${title}`,
377
+ '',
378
+ 'Manager instruction:',
379
+ instruction
380
+ ].join('\n');
381
+ }
382
+
383
+ function extractResponseText(response) {
384
+ if (typeof response?.output_text === 'string' && response.output_text.trim()) {
385
+ return response.output_text.trim();
386
+ }
387
+
388
+ const parts = [];
389
+ if (Array.isArray(response?.output)) {
390
+ for (const item of response.output) {
391
+ if (!Array.isArray(item?.content)) {
392
+ continue;
393
+ }
394
+
395
+ for (const content of item.content) {
396
+ if (typeof content?.text === 'string') {
397
+ parts.push(content.text);
398
+ }
399
+ if (typeof content?.output_text === 'string') {
400
+ parts.push(content.output_text);
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ return parts.join('\n').trim();
407
+ }
408
+
409
+ async function runAiAssistTask(options, payload, instruction) {
410
+ if (!options.aiEnabled) {
411
+ throw new Error('AI assist capability is disabled. Start the agent with --ai to enable it.');
412
+ }
413
+
414
+ if (options.fakeAi) {
415
+ return {
416
+ text: [
417
+ `Fake AI assist completed on ${options.name}.`,
418
+ '',
419
+ `Instruction: ${instruction.slice(0, 500)}`,
420
+ '',
421
+ 'No shell, file, input, browser, or persistent side effects were performed.'
422
+ ].join('\n'),
423
+ model: 'fake-ai',
424
+ responseId: `fake-ai-${String(payload?.taskId || crypto.randomUUID()).slice(0, 96)}`
425
+ };
426
+ }
427
+
428
+ if (!options.openAiApiKey) {
429
+ throw new Error('OPENAI_API_KEY is required for --ai remote tasks.');
430
+ }
431
+
432
+ const openaiModule = await import('openai');
433
+ const OpenAI = openaiModule.default || openaiModule.OpenAI;
434
+ const client = new OpenAI({ apiKey: options.openAiApiKey });
435
+ const model = String(payload?.model || options.aiModel || DEFAULT_AI_MODEL).trim() || DEFAULT_AI_MODEL;
436
+ const response = await client.responses.create({
437
+ model,
438
+ input: buildAiPrompt(options, payload, instruction)
439
+ });
440
+ const text = extractResponseText(response);
441
+ if (!text) {
442
+ throw new Error('AI assist returned no text output.');
443
+ }
444
+
445
+ return {
446
+ text: text.slice(0, MAX_AI_OUTPUT_CHARS),
447
+ model,
448
+ responseId: response?.id || ''
449
+ };
450
+ }
451
+
452
+ function clampInteger(value, min, max, fallback) {
453
+ const number = Number(value);
454
+ if (!Number.isFinite(number)) {
455
+ return fallback;
456
+ }
457
+
458
+ return Math.max(min, Math.min(max, Math.floor(number)));
459
+ }
460
+
461
+ function stopLiveStream(activeStreams, streamId = '') {
462
+ if (streamId) {
463
+ const existing = activeStreams.get(streamId);
464
+ if (existing?.timer) {
465
+ clearInterval(existing.timer);
466
+ }
467
+ activeStreams.delete(streamId);
468
+ return existing ? 1 : 0;
469
+ }
470
+
471
+ let stopped = 0;
472
+ for (const stream of activeStreams.values()) {
473
+ if (stream?.timer) {
474
+ clearInterval(stream.timer);
475
+ stopped += 1;
476
+ }
477
+ }
478
+ activeStreams.clear();
479
+ return stopped;
480
+ }
481
+
482
+ function startLiveStream(socket, options, message, nextFrameSeq, activeStreams) {
483
+ if (!options.liveEnabled) {
484
+ throw new Error('live stream capability is disabled');
485
+ }
486
+
487
+ const payload = message.payload || {};
488
+ const streamId = String(payload.streamId || `live-${Date.now()}`)
489
+ .replace(/[^a-zA-Z0-9_.:-]/g, '-')
490
+ .slice(0, 128) || `live-${Date.now()}`;
491
+ const fps = clampInteger(payload.fps, 1, MAX_LIVE_FPS, DEFAULT_LIVE_FPS);
492
+ const intervalMs = Math.max(33, Math.round(1000 / fps));
493
+ stopLiveStream(activeStreams, streamId);
494
+
495
+ const stream = {
496
+ streamId,
497
+ fps,
498
+ intervalMs,
499
+ inFlight: false,
500
+ stopped: false,
501
+ frameDrops: 0,
502
+ timer: null
503
+ };
504
+
505
+ const captureAndSend = async () => {
506
+ if (stream.stopped || stream.inFlight || socket.destroyed) {
507
+ if (stream.inFlight) {
508
+ stream.frameDrops += 1;
509
+ }
510
+ return;
511
+ }
512
+
513
+ stream.inFlight = true;
514
+ try {
515
+ const frame = await captureScreenFrame(options, payload, nextFrameSeq(), 'live');
516
+ if (String(frame.data || '').length > MAX_FRAME_BASE64_CHARS) {
517
+ stream.frameDrops += 1;
518
+ return;
519
+ }
520
+
521
+ const sent = writeJsonLine(socket, {
522
+ type: 'stream.frame',
523
+ commandId: message.commandId,
524
+ streamId,
525
+ frameSeq: frame.frameSeq,
526
+ width: frame.width,
527
+ height: frame.height,
528
+ mimeType: frame.mimeType,
529
+ capturedAt: frame.capturedAt,
530
+ fps,
531
+ mode: 'remote-fast',
532
+ droppedByAgent: stream.frameDrops,
533
+ data: frame.data
534
+ });
535
+
536
+ if (!sent) {
537
+ stream.stopped = true;
538
+ stopLiveStream(activeStreams, streamId);
539
+ }
540
+ } catch {
541
+ stream.frameDrops += 1;
542
+ } finally {
543
+ stream.inFlight = false;
544
+ }
545
+ };
546
+
547
+ stream.timer = setInterval(() => {
548
+ captureAndSend().catch(() => {
549
+ stream.frameDrops += 1;
550
+ });
551
+ }, intervalMs);
552
+ stream.timer.unref?.();
553
+ activeStreams.set(streamId, stream);
554
+ captureAndSend().catch(() => {
555
+ stream.frameDrops += 1;
556
+ });
557
+ return { streamId, fps, intervalMs };
558
+ }
559
+
560
+ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
561
+ const command = String(message.command || '');
562
+ if (command === 'ping') {
563
+ writeJsonLine(socket, {
564
+ type: 'command.result',
565
+ commandId: message.commandId,
566
+ result: {
567
+ pong: true,
568
+ at: new Date().toISOString()
569
+ }
570
+ });
571
+ return;
572
+ }
573
+
574
+ if (command === 'agent.task') {
575
+ if (!options.taskEnabled) {
576
+ writeJsonLine(socket, {
577
+ type: 'command.result',
578
+ commandId: message.commandId,
579
+ error: 'remote task capability is disabled'
580
+ });
581
+ return;
582
+ }
583
+
584
+ const payload = message.payload || {};
585
+ const instruction = String(payload.instruction || '').replace(/\0/g, '').trim().slice(0, 4000);
586
+ if (!instruction) {
587
+ writeJsonLine(socket, {
588
+ type: 'command.result',
589
+ commandId: message.commandId,
590
+ error: 'missing task instruction'
591
+ });
592
+ return;
593
+ }
594
+
595
+ const approvalLevel = normalizeApprovalLevel(payload.approvalLevel);
596
+ const completedAt = new Date().toISOString();
597
+ const title = String(payload.title || instruction.split(/\r?\n/)[0] || 'Remote task')
598
+ .replace(/[\r\n\t]/g, ' ')
599
+ .trim()
600
+ .slice(0, 120);
601
+ if (approvalLevel === 'ai-assist') {
602
+ try {
603
+ const aiResult = await runAiAssistTask(options, payload, instruction);
604
+ writeJsonLine(socket, {
605
+ type: 'command.result',
606
+ commandId: message.commandId,
607
+ result: {
608
+ kind: 'agent.task',
609
+ mode: 'ai-assist',
610
+ taskId: String(payload.taskId || '').slice(0, 128),
611
+ title,
612
+ status: 'completed',
613
+ summary: aiResult.text,
614
+ sideEffects: 'none',
615
+ model: aiResult.model,
616
+ responseId: aiResult.responseId,
617
+ instructionPreview: instruction.slice(0, 320),
618
+ completedAt: new Date().toISOString()
619
+ }
620
+ });
621
+ } catch (error) {
622
+ writeJsonLine(socket, {
623
+ type: 'command.result',
624
+ commandId: message.commandId,
625
+ error: error?.message || String(error)
626
+ });
627
+ }
628
+ return;
629
+ }
630
+
631
+ writeJsonLine(socket, {
632
+ type: 'command.result',
633
+ commandId: message.commandId,
634
+ result: {
635
+ kind: 'agent.task',
636
+ mode: 'task-only',
637
+ taskId: String(payload.taskId || '').slice(0, 128),
638
+ title,
639
+ status: 'completed',
640
+ summary: `Task received by ${options.name}. Safe task-only mode confirmed receipt without shell, file, input, or browser side effects.`,
641
+ sideEffects: 'none',
642
+ instructionPreview: instruction.slice(0, 320),
643
+ device: {
644
+ hostname: os.hostname(),
645
+ platform: os.platform(),
646
+ release: os.release(),
647
+ arch: os.arch()
648
+ },
649
+ completedAt
650
+ }
651
+ });
652
+ return;
653
+ }
654
+
655
+ if (command === 'thumbnail.capture') {
656
+ try {
657
+ const frameSeq = nextFrameSeq();
658
+ const frame = await captureThumbnailFrame(options, message.payload || {}, frameSeq);
659
+ writeJsonLine(socket, {
660
+ type: 'thumbnail.frame',
661
+ commandId: message.commandId,
662
+ streamId: message.payload?.streamId || 'thumbnail',
663
+ frameSeq: frame.frameSeq,
664
+ width: frame.width,
665
+ height: frame.height,
666
+ mimeType: frame.mimeType,
667
+ capturedAt: frame.capturedAt,
668
+ data: frame.data
669
+ });
670
+ writeJsonLine(socket, {
671
+ type: 'command.result',
672
+ commandId: message.commandId,
673
+ result: {
674
+ thumbnail: true,
675
+ frameSeq: frame.frameSeq,
676
+ width: frame.width,
677
+ height: frame.height,
678
+ capturedAt: frame.capturedAt
679
+ }
680
+ });
681
+ } catch (error) {
682
+ writeJsonLine(socket, {
683
+ type: 'command.result',
684
+ commandId: message.commandId,
685
+ error: error?.message || String(error)
686
+ });
687
+ }
688
+ return;
689
+ }
690
+
691
+ if (command === 'stream.start') {
692
+ try {
693
+ const started = startLiveStream(socket, options, message, nextFrameSeq, activeStreams);
694
+ writeJsonLine(socket, {
695
+ type: 'command.result',
696
+ commandId: message.commandId,
697
+ result: {
698
+ stream: true,
699
+ started: true,
700
+ streamId: started.streamId,
701
+ fps: started.fps,
702
+ intervalMs: started.intervalMs,
703
+ mode: 'remote-fast',
704
+ viewOnly: true,
705
+ inputControl: false,
706
+ startedAt: new Date().toISOString()
707
+ }
708
+ });
709
+ } catch (error) {
710
+ writeJsonLine(socket, {
711
+ type: 'command.result',
712
+ commandId: message.commandId,
713
+ error: error?.message || String(error)
714
+ });
715
+ }
716
+ return;
717
+ }
718
+
719
+ if (command === 'stream.stop') {
720
+ const streamId = String(message.payload?.streamId || '').slice(0, 128);
721
+ const stopped = stopLiveStream(activeStreams, streamId);
722
+ writeJsonLine(socket, {
723
+ type: 'command.result',
724
+ commandId: message.commandId,
725
+ result: {
726
+ stream: true,
727
+ stopped: true,
728
+ streamId,
729
+ stoppedCount: stopped,
730
+ stoppedAt: new Date().toISOString()
731
+ }
732
+ });
733
+ return;
734
+ }
735
+
736
+ if (command === 'input.control') {
737
+ writeJsonLine(socket, {
738
+ type: 'command.result',
739
+ commandId: message.commandId,
740
+ error: 'input control is only available in the C# RemoteFast engine'
741
+ });
742
+ return;
743
+ }
744
+
745
+ writeJsonLine(socket, {
746
+ type: 'command.result',
747
+ commandId: message.commandId,
748
+ error: `Unsupported command: ${command}`
749
+ });
750
+ }
751
+
752
+ function connectOnce(options, deviceId) {
753
+ const manager = parseManagerAddress(options.manager);
754
+ return new Promise((resolve, reject) => {
755
+ const socket = net.createConnection({
756
+ host: manager.host,
757
+ port: manager.port
758
+ });
759
+
760
+ let buffer = '';
761
+ let heartbeatTimer = null;
762
+ let resolved = false;
763
+ let frameSeq = 0;
764
+ const activeStreams = new Map();
765
+
766
+ function cleanup() {
767
+ if (heartbeatTimer) {
768
+ clearInterval(heartbeatTimer);
769
+ heartbeatTimer = null;
770
+ }
771
+ stopLiveStream(activeStreams);
772
+ }
773
+
774
+ function finish(error = null) {
775
+ if (resolved) {
776
+ return;
777
+ }
778
+ resolved = true;
779
+ cleanup();
780
+ if (!socket.destroyed) {
781
+ socket.destroy();
782
+ }
783
+ if (error) {
784
+ reject(error);
785
+ } else {
786
+ resolve();
787
+ }
788
+ }
789
+
790
+ socket.setEncoding('utf8');
791
+ socket.setNoDelay(true);
792
+ socket.setKeepAlive(true, options.heartbeatMs);
793
+
794
+ socket.once('connect', () => {
795
+ writeJsonLine(socket, {
796
+ type: 'hello',
797
+ pairToken: options.pair,
798
+ deviceId,
799
+ deviceName: options.name,
800
+ hostname: os.hostname(),
801
+ platform: os.platform(),
802
+ arch: os.arch(),
803
+ pid: process.pid,
804
+ agentVersion: AGENT_VERSION,
805
+ capabilities: {
806
+ status: true,
807
+ thumbnail: options.thumbnailEnabled,
808
+ liveStream: options.liveEnabled,
809
+ control: false,
810
+ computerAgent: options.taskEnabled,
811
+ taskDispatch: options.taskEnabled,
812
+ aiAssist: options.taskEnabled && options.aiEnabled && (options.fakeAi || !!options.openAiApiKey),
813
+ aiModel: options.aiModel,
814
+ aiProvider: options.fakeAi ? 'fake' : (options.openAiApiKey ? 'openai' : ''),
815
+ externalEffects: false
816
+ }
817
+ });
818
+ });
819
+
820
+ socket.on('data', chunk => {
821
+ buffer += chunk;
822
+ let newlineIndex = buffer.indexOf('\n');
823
+ while (newlineIndex >= 0) {
824
+ const line = buffer.slice(0, newlineIndex).trim();
825
+ buffer = buffer.slice(newlineIndex + 1);
826
+ newlineIndex = buffer.indexOf('\n');
827
+ if (!line) {
828
+ continue;
829
+ }
830
+
831
+ const message = JSON.parse(line);
832
+ if (message.type === 'welcome') {
833
+ console.log(`Connected to RemoteHub as ${options.name} (${deviceId})`);
834
+ writeJsonLine(socket, {
835
+ type: 'status',
836
+ status: getStatus()
837
+ });
838
+ if (options.once) {
839
+ finish();
840
+ return;
841
+ }
842
+
843
+ heartbeatTimer = setInterval(() => {
844
+ writeJsonLine(socket, {
845
+ type: 'status',
846
+ status: getStatus()
847
+ });
848
+ }, options.heartbeatMs);
849
+ } else if (message.type === 'command') {
850
+ handleRemoteCommand(socket, options, message, () => {
851
+ frameSeq += 1;
852
+ return frameSeq;
853
+ }, activeStreams).catch(error => {
854
+ writeJsonLine(socket, {
855
+ type: 'command.result',
856
+ commandId: message.commandId,
857
+ error: error?.message || String(error)
858
+ });
859
+ });
860
+ } else if (message.type === 'disconnect') {
861
+ finish();
862
+ return;
863
+ } else if (message.type === 'error') {
864
+ finish(new Error(message.error || 'RemoteHub rejected the connection.'));
865
+ return;
866
+ }
867
+ }
868
+ });
869
+
870
+ socket.once('error', err => finish(err));
871
+ socket.once('close', () => finish());
872
+ });
873
+ }
874
+
875
+ async function connectWithRetry(options) {
876
+ if (!options.pair) {
877
+ throw new Error('Missing --pair token. Get it from the manager RemoteHub status.');
878
+ }
879
+
880
+ const deviceId = await getDeviceId(options.deviceId);
881
+ let attempt = 0;
882
+ let stopping = false;
883
+
884
+ process.once('SIGINT', () => {
885
+ stopping = true;
886
+ console.log('\nStopping LiveDesk Client...');
887
+ });
888
+ process.once('SIGTERM', () => {
889
+ stopping = true;
890
+ });
891
+
892
+ while (!stopping) {
893
+ try {
894
+ await connectOnce(options, deviceId);
895
+ if (options.once || options.exitOnDisconnect || stopping) {
896
+ return;
897
+ }
898
+ attempt = 0;
899
+ } catch (err) {
900
+ attempt += 1;
901
+ const delayMs = Math.min(10000, 500 * attempt);
902
+ console.error(`RemoteHub connection failed: ${err?.message || err}. Retrying in ${delayMs}ms.`);
903
+ await wait(delayMs);
904
+ }
905
+ }
906
+ }
907
+
908
+ async function main() {
909
+ const options = parseArgs(process.argv.slice(2));
910
+ if (options.version || options.command === 'version') {
911
+ console.log(AGENT_VERSION);
912
+ return;
913
+ }
914
+
915
+ if (options.help || options.command === 'help') {
916
+ printHelp();
917
+ return;
918
+ }
919
+
920
+ if (options.command !== 'connect') {
921
+ throw new Error(`Unknown command: ${options.command}`);
922
+ }
923
+
924
+ await connectWithRetry(options);
925
+ }
926
+
927
+ main().catch(err => {
928
+ console.error(err?.message || err);
929
+ process.exit(1);
930
+ });