@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.
- package/README.md +36 -0
- package/bin/livedesk-client-fast.js +23 -0
- package/bin/livedesk-client-node.js +930 -0
- package/bin/livedesk-client.js +306 -0
- package/fast/osx-arm64/mindexec-remote-fast +0 -0
- package/fast/osx-arm64/mindexec-remote-fast.deps.json +24 -0
- package/fast/osx-arm64/mindexec-remote-fast.dll +0 -0
- package/fast/osx-arm64/mindexec-remote-fast.pdb +0 -0
- package/fast/osx-arm64/mindexec-remote-fast.runtimeconfig.json +13 -0
- package/fast/osx-x64/mindexec-remote-fast +0 -0
- package/fast/osx-x64/mindexec-remote-fast.deps.json +24 -0
- package/fast/osx-x64/mindexec-remote-fast.dll +0 -0
- package/fast/osx-x64/mindexec-remote-fast.pdb +0 -0
- package/fast/osx-x64/mindexec-remote-fast.runtimeconfig.json +13 -0
- package/fast/win-x64/mindexec-remote-fast.deps.json +24 -0
- package/fast/win-x64/mindexec-remote-fast.dll +0 -0
- package/fast/win-x64/mindexec-remote-fast.exe +0 -0
- package/fast/win-x64/mindexec-remote-fast.pdb +0 -0
- package/fast/win-x64/mindexec-remote-fast.runtimeconfig.json +20 -0
- package/package.json +38 -0
|
@@ -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
|
+
});
|