@livedesk/client 0.1.121 → 0.1.123

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 (2) hide show
  1. package/bin/livedesk-client-node.js +1591 -1284
  2. package/package.json +44 -44
@@ -1,1306 +1,1613 @@
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, statfsSync } from 'fs';
8
-
9
- const AGENT_VERSION = '0.1.24-livedesk.1';
10
- const DEFAULT_MANAGER = '127.0.0.1:5197';
11
- const DEFAULT_HEARTBEAT_MS = 5000;
12
- const DEFAULT_RECONNECT_MS = 5000;
13
- const EXIT_INVALID_PAIR_TOKEN = 23;
14
- const DEFAULT_AI_MODEL = 'gpt-5.4-mini';
15
- const DEFAULT_LIVE_FPS = 30;
16
- const MAX_LIVE_FPS = 30;
17
- const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
18
- const MAX_AI_OUTPUT_CHARS = 6000;
19
- const MAX_FILE_TRANSFER_FILES = 24;
20
- const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
21
-
22
- function printHelp() {
23
- console.log(`
24
- LiveDesk Client
25
-
26
- Usage:
27
- npx @livedesk/client connect --manager 127.0.0.1:5197 --pair <token>
28
-
29
- Options:
30
- --manager <host:port> LiveDesk Hub address. Default: ${DEFAULT_MANAGER}
31
- --pair <token> LiveDesk Hub pairing token. Can also use LIVEDESK_CLIENT_PAIR_TOKEN.
32
- --slot <number> Screen wall slot number for this computer.
33
- --name <name> Friendly device name. Default: OS hostname.
34
- --heartbeat <ms> Status heartbeat interval. Default: ${DEFAULT_HEARTBEAT_MS}
35
- --device-id <id> Stable device id. Default: generated and saved per OS user.
36
- --thumbnail Enable thumbnail capture when supported. Default on Windows.
37
- --no-thumbnail Disable thumbnail capture capability.
38
- --live Enable focused live screen streaming. Default on.
39
- --no-live Disable focused live screen streaming.
40
- --tasks Enable safe remote task inbox. Default on.
41
- --no-tasks Disable remote task dispatch capability.
42
- --files-dir <path> Default folder for received files. Default: ~/Desktop/LiveDeskFiles.
43
- --ai Enable OpenAI-backed remote AI assist tasks.
44
- --no-ai Disable OpenAI-backed remote AI assist tasks.
45
- --ai-model <model> OpenAI model for AI assist. Default: ${DEFAULT_AI_MODEL}
46
- --fake-ai Use deterministic AI assist responses for smoke tests.
47
- --fake-thumbnail Use generated thumbnail frames for smoke tests.
48
- --exit-on-disconnect Exit after Hub disconnect. Useful for tests.
49
- --exit-on-invalid-pair Exit when the Hub rejects the pair token.
50
- --once Connect, send one status packet, and exit after welcome.
51
- --version Show the agent version.
52
- --help Show this help.
53
- `.trim());
54
- }
55
-
56
- function isTruthy(value) {
57
- return /^(1|true|yes|on)$/i.test(String(value || '').trim());
58
- }
59
-
60
- function isFalsy(value) {
61
- return /^(0|false|no|off)$/i.test(String(value || '').trim());
62
- }
63
-
64
- function isNativeDesktopCaptureEnabledByDefault() {
65
- const platform = os.platform();
66
- const arch = os.arch();
67
- if (platform === 'win32' || platform === 'darwin') {
68
- return true;
69
- }
70
- if (platform === 'linux') {
71
- return ['x64', 'arm64', 'loong64'].includes(arch);
72
- }
73
- return false;
74
- }
75
-
76
- function parseArgs(argv) {
77
- const defaultDesktopCaptureEnabled = isNativeDesktopCaptureEnabledByDefault();
78
- const result = {
79
- command: 'connect',
80
- manager: process.env.LIVEDESK_CLIENT_MANAGER || process.env.MINDEXEC_REMOTE_MANAGER || DEFAULT_MANAGER,
81
- pair: process.env.LIVEDESK_CLIENT_PAIR_TOKEN || process.env.MINDEXEC_REMOTE_PAIR_TOKEN || '',
82
- slotNumber: normalizeSlotNumber(process.env.LIVEDESK_CLIENT_SLOT || process.env.MINDEXEC_REMOTE_SLOT),
83
- name: process.env.LIVEDESK_CLIENT_NAME || process.env.MINDEXEC_REMOTE_NAME || os.hostname(),
84
- heartbeatMs: DEFAULT_HEARTBEAT_MS,
85
- deviceId: '',
86
- thumbnailEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_THUMBNAIL ?? process.env.MINDEXEC_REMOTE_THUMBNAIL),
87
- liveEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_LIVE ?? process.env.MINDEXEC_REMOTE_LIVE),
88
- taskEnabled: !isFalsy(process.env.LIVEDESK_CLIENT_TASKS ?? process.env.MINDEXEC_REMOTE_TASKS),
89
- filesDir: process.env.LIVEDESK_CLIENT_FILES_DIR || process.env.MINDEXEC_REMOTE_FILES_DIR || '',
90
- aiEnabled: isTruthy(process.env.LIVEDESK_CLIENT_AI || process.env.MINDEXEC_REMOTE_AI),
91
- aiModel: process.env.LIVEDESK_CLIENT_AI_MODEL || process.env.MINDEXEC_REMOTE_AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_AI_MODEL,
92
- openAiApiKey: process.env.OPENAI_API_KEY || '',
93
- fakeAi: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_AI || process.env.MINDEXEC_REMOTE_FAKE_AI),
94
- fakeThumbnail: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_THUMBNAIL || process.env.MINDEXEC_REMOTE_FAKE_THUMBNAIL),
95
- exitOnDisconnect: false,
96
- exitOnInvalidPair: false,
97
- once: false,
98
- version: false,
99
- help: false
100
- };
101
-
102
- const args = [...argv];
103
- if (args[0] && !args[0].startsWith('-')) {
104
- result.command = args.shift();
105
- }
106
-
107
- for (let index = 0; index < args.length; index += 1) {
108
- const arg = args[index];
109
- switch (arg) {
110
- case '--manager':
111
- result.manager = args[++index] || result.manager;
112
- break;
113
- case '--pair':
114
- result.pair = args[++index] || '';
115
- break;
116
- case '--slot':
117
- result.slotNumber = normalizeSlotNumber(args[++index] || result.slotNumber);
118
- break;
119
- case '--name':
120
- result.name = args[++index] || result.name;
121
- break;
122
- case '--heartbeat':
123
- result.heartbeatMs = Number(args[++index] || DEFAULT_HEARTBEAT_MS);
124
- break;
125
- case '--device-id':
126
- result.deviceId = args[++index] || '';
127
- break;
128
- case '--thumbnail':
129
- result.thumbnailEnabled = true;
130
- break;
131
- case '--no-thumbnail':
132
- result.thumbnailEnabled = false;
133
- break;
134
- case '--live':
135
- result.liveEnabled = true;
136
- break;
137
- case '--no-live':
138
- result.liveEnabled = false;
139
- break;
140
- case '--tasks':
141
- result.taskEnabled = true;
142
- break;
143
- case '--no-tasks':
144
- result.taskEnabled = false;
145
- break;
146
- case '--files-dir':
147
- result.filesDir = args[++index] || result.filesDir;
148
- break;
149
- case '--ai':
150
- result.aiEnabled = true;
151
- result.taskEnabled = true;
152
- break;
153
- case '--no-ai':
154
- result.aiEnabled = false;
155
- break;
156
- case '--ai-model':
157
- result.aiModel = args[++index] || result.aiModel;
158
- break;
159
- case '--fake-ai':
160
- result.fakeAi = true;
161
- result.aiEnabled = true;
162
- result.taskEnabled = true;
163
- break;
164
- case '--fake-thumbnail':
165
- result.fakeThumbnail = true;
166
- result.thumbnailEnabled = true;
167
- break;
168
- case '--exit-on-disconnect':
169
- result.exitOnDisconnect = true;
170
- break;
171
- case '--exit-on-invalid-pair':
172
- result.exitOnInvalidPair = true;
173
- break;
174
- case '--once':
175
- result.once = true;
176
- break;
177
- case '--help':
178
- case '-h':
179
- result.help = true;
180
- break;
181
- case '--version':
182
- case '-v':
183
- result.version = true;
184
- break;
185
- default:
186
- throw new Error(`Unknown option: ${arg}`);
187
- }
188
- }
189
-
190
- if (!Number.isFinite(result.heartbeatMs) || result.heartbeatMs < 1000) {
191
- result.heartbeatMs = DEFAULT_HEARTBEAT_MS;
192
- }
193
-
194
- return result;
195
- }
196
-
197
- function parseManagerAddress(value) {
198
- let text = String(value || DEFAULT_MANAGER).trim();
199
- text = text.replace(/^tcp:\/\//i, '');
200
- const separator = text.lastIndexOf(':');
201
- if (separator <= 0) {
202
- return {
203
- host: text || '127.0.0.1',
204
- port: 5197
205
- };
206
- }
207
-
208
- const host = text.slice(0, separator).trim() || '127.0.0.1';
209
- const port = Number(text.slice(separator + 1));
210
- return {
211
- host,
212
- port: Number.isFinite(port) ? port : 5197
213
- };
214
- }
215
-
216
- function normalizeDeviceId(value) {
217
- return String(value || '').trim().replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
218
- }
219
-
220
- function normalizeSlotNumber(value) {
221
- const number = Number(String(value || '').trim());
222
- return Number.isInteger(number) && number >= 1 && number <= 999 ? number : 0;
223
- }
224
-
225
- async function getDeviceId(explicitDeviceId = '') {
226
- const explicit = normalizeDeviceId(explicitDeviceId);
227
- if (explicit) {
228
- return explicit;
229
- }
230
-
231
- const stateDir = path.join(os.homedir(), '.livedesk-client');
232
- const statePath = path.join(stateDir, 'device.json');
233
- try {
234
- const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
235
- const existing = normalizeDeviceId(state.deviceId);
236
- if (existing) {
237
- return existing;
238
- }
239
- } catch {
240
- // Create a new device id below.
241
- }
242
-
243
- const deviceId = `livedesk-${crypto.randomUUID()}`;
244
- await fs.mkdir(stateDir, { recursive: true });
245
- await fs.writeFile(statePath, JSON.stringify({
246
- deviceId,
247
- createdAt: new Date().toISOString()
248
- }, null, 2));
249
- return deviceId;
250
- }
251
-
252
- function getRootDiskStatus() {
253
- const root = path.parse(os.homedir()).root || '/';
254
- try {
255
- const stats = statfsSync(root);
256
- const totalBytes = Number(stats.blocks) * Number(stats.bsize);
257
- const freeBytes = Number(stats.bavail) * Number(stats.bsize);
258
- return {
259
- root,
260
- totalBytes,
261
- freeBytes,
262
- usedRatio: totalBytes > 0 ? Number(((totalBytes - freeBytes) / totalBytes).toFixed(4)) : 0
263
- };
264
- } catch {
265
- return { root, totalBytes: 0, freeBytes: 0, usedRatio: 0 };
266
- }
267
- }
268
-
269
- function optionalNumber(value) {
270
- const number = Number(value);
271
- return Number.isFinite(number) ? number : undefined;
272
- }
273
-
274
- function optionalRatio(value) {
275
- const number = optionalNumber(value);
276
- if (number === undefined) {
277
- return undefined;
278
- }
279
- return Math.max(0, Math.min(1, number > 1 ? number / 100 : number));
280
- }
281
-
282
- function getAcceleratorStatus() {
283
- return {
284
- gpu: {
285
- name: process.env.LIVEDESK_GPU_NAME || process.env.MINDEXEC_GPU_NAME || '',
286
- usageRatio: optionalRatio(process.env.LIVEDESK_GPU_USAGE_RATIO || process.env.MINDEXEC_GPU_USAGE_RATIO),
287
- temperatureC: optionalNumber(process.env.LIVEDESK_GPU_TEMPERATURE_C || process.env.MINDEXEC_GPU_TEMPERATURE_C)
288
- },
289
- npu: {
290
- name: process.env.LIVEDESK_NPU_NAME || process.env.MINDEXEC_NPU_NAME || '',
291
- usageRatio: optionalRatio(process.env.LIVEDESK_NPU_USAGE_RATIO || process.env.MINDEXEC_NPU_USAGE_RATIO),
292
- tops: optionalNumber(process.env.LIVEDESK_NPU_TOPS || process.env.MINDEXEC_NPU_TOPS)
293
- }
294
- };
295
- }
296
-
297
- function getNetworkStatus() {
298
- const interfaces = os.networkInterfaces();
299
- const activeName = Object.entries(interfaces).find(([, addresses]) =>
300
- Array.isArray(addresses) && addresses.some(address => !address.internal && address.family === 'IPv4')
301
- )?.[0] || '';
302
- return {
303
- interface: activeName,
304
- link: activeName || '',
305
- rxMbps: optionalNumber(process.env.LIVEDESK_NETWORK_RX_MBPS || process.env.MINDEXEC_NETWORK_RX_MBPS),
306
- txMbps: optionalNumber(process.env.LIVEDESK_NETWORK_TX_MBPS || process.env.MINDEXEC_NETWORK_TX_MBPS),
307
- latencyMs: optionalNumber(process.env.LIVEDESK_NETWORK_LATENCY_MS || process.env.MINDEXEC_NETWORK_LATENCY_MS),
308
- usageRatio: optionalRatio(process.env.LIVEDESK_NETWORK_USAGE_RATIO || process.env.MINDEXEC_NETWORK_USAGE_RATIO)
309
- };
310
- }
311
-
312
- function getStatus(options = {}) {
313
- const totalMem = os.totalmem();
314
- const freeMem = os.freemem();
315
- const cpus = os.cpus();
316
- const loadavg = os.loadavg();
317
- const cpuCores = Math.max(1, cpus.length || os.availableParallelism?.() || 1);
318
- const cpuUsageRatio = loadavg[0] > 0
319
- ? Number(Math.min(1, loadavg[0] / cpuCores).toFixed(4))
320
- : 0;
321
- const usedMemRatio = totalMem > 0 ? Number(((totalMem - freeMem) / totalMem).toFixed(4)) : 0;
322
- const acceleratorStatus = getAcceleratorStatus();
323
- const status = {
324
- uptimeSec: Math.round(os.uptime()),
325
- loadavg,
326
- totalMem,
327
- freeMem,
328
- usedMemRatio,
329
- platform: os.platform(),
330
- release: os.release(),
331
- role: options.aiEnabled ? 'Agent worker' : options.taskEnabled ? 'Task worker' : 'Local machine',
332
- cpu: {
333
- cores: cpuCores,
334
- model: cpus[0]?.model || '',
335
- speedMHz: cpus[0]?.speed || 0,
336
- usageRatio: cpuUsageRatio,
337
- load1: loadavg[0] || 0
338
- },
339
- memory: {
340
- totalBytes: totalMem,
341
- freeBytes: freeMem,
342
- usedRatio: usedMemRatio
343
- },
344
- screenCount: 1,
345
- monitorCount: 1,
346
- screens: {
347
- count: 1,
348
- displays: []
349
- },
350
- disk: getRootDiskStatus(),
351
- gpu: acceleratorStatus.gpu,
352
- npu: acceleratorStatus.npu,
353
- network: getNetworkStatus(),
354
- workload: {
355
- role: options.aiEnabled ? 'Agent worker' : options.taskEnabled ? 'Task worker' : 'Local machine',
356
- status: 'idle',
357
- title: 'idle'
358
- },
359
- timestamp: new Date().toISOString()
360
- };
361
- if (options.slotNumber) {
362
- status.slotNumber = options.slotNumber;
363
- }
364
- return status;
365
- }
366
-
367
- function writeJsonLine(socket, payload) {
368
- if (!socket || socket.destroyed) {
369
- return false;
370
- }
371
-
372
- socket.write(`${JSON.stringify(payload)}\n`);
373
- return true;
374
- }
375
-
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, statfsSync } from 'fs';
8
+ import { spawn } from 'child_process';
9
+
10
+ const AGENT_VERSION = '0.1.24-livedesk.1';
11
+ const DEFAULT_MANAGER = '127.0.0.1:5197';
12
+ const DEFAULT_HEARTBEAT_MS = 5000;
13
+ const DEFAULT_RECONNECT_MS = 5000;
14
+ const EXIT_INVALID_PAIR_TOKEN = 23;
15
+ const DEFAULT_AI_MODEL = 'gpt-5.4-mini';
16
+ const DEFAULT_LIVE_FPS = 30;
17
+ const MAX_LIVE_FPS = 30;
18
+ const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
19
+ const MAX_AI_OUTPUT_CHARS = 6000;
20
+ const MAX_FILE_TRANSFER_FILES = 24;
21
+ const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
22
+ const MAX_AGENT_OUTPUT_CHARS = 32000;
23
+
24
+ function printHelp() {
25
+ console.log(`
26
+ LiveDesk Client
27
+
28
+ Usage:
29
+ npx @livedesk/client connect --manager 127.0.0.1:5197 --pair <token>
30
+
31
+ Options:
32
+ --manager <host:port> LiveDesk Hub address. Default: ${DEFAULT_MANAGER}
33
+ --pair <token> LiveDesk Hub pairing token. Can also use LIVEDESK_CLIENT_PAIR_TOKEN.
34
+ --slot <number> Screen wall slot number for this computer.
35
+ --name <name> Friendly device name. Default: OS hostname.
36
+ --heartbeat <ms> Status heartbeat interval. Default: ${DEFAULT_HEARTBEAT_MS}
37
+ --device-id <id> Stable device id. Default: generated and saved per OS user.
38
+ --thumbnail Enable thumbnail capture when supported. Default on Windows.
39
+ --no-thumbnail Disable thumbnail capture capability.
40
+ --live Enable focused live screen streaming. Default on.
41
+ --no-live Disable focused live screen streaming.
42
+ --tasks Enable safe remote task inbox. Default on.
43
+ --no-tasks Disable remote task dispatch capability.
44
+ --files-dir <path> Default folder for received files. Default: ~/Desktop/LiveDeskFiles.
45
+ --ai Enable OpenAI-backed remote AI assist tasks.
46
+ --no-ai Disable OpenAI-backed remote AI assist tasks.
47
+ --ai-model <model> OpenAI model for AI assist. Default: ${DEFAULT_AI_MODEL}
48
+ --fake-ai Use deterministic AI assist responses for smoke tests.
49
+ --fake-thumbnail Use generated thumbnail frames for smoke tests.
50
+ --exit-on-disconnect Exit after Hub disconnect. Useful for tests.
51
+ --exit-on-invalid-pair Exit when the Hub rejects the pair token.
52
+ --once Connect, send one status packet, and exit after welcome.
53
+ --version Show the agent version.
54
+ --help Show this help.
55
+ `.trim());
56
+ }
57
+
58
+ function isTruthy(value) {
59
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
60
+ }
61
+
62
+ function isFalsy(value) {
63
+ return /^(0|false|no|off)$/i.test(String(value || '').trim());
64
+ }
65
+
66
+ function isNativeDesktopCaptureEnabledByDefault() {
67
+ const platform = os.platform();
68
+ const arch = os.arch();
69
+ if (platform === 'win32' || platform === 'darwin') {
70
+ return true;
71
+ }
72
+ if (platform === 'linux') {
73
+ return ['x64', 'arm64', 'loong64'].includes(arch);
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function parseArgs(argv) {
79
+ const defaultDesktopCaptureEnabled = isNativeDesktopCaptureEnabledByDefault();
80
+ const result = {
81
+ command: 'connect',
82
+ manager: process.env.LIVEDESK_CLIENT_MANAGER || process.env.MINDEXEC_REMOTE_MANAGER || DEFAULT_MANAGER,
83
+ pair: process.env.LIVEDESK_CLIENT_PAIR_TOKEN || process.env.MINDEXEC_REMOTE_PAIR_TOKEN || '',
84
+ slotNumber: normalizeSlotNumber(process.env.LIVEDESK_CLIENT_SLOT || process.env.MINDEXEC_REMOTE_SLOT),
85
+ name: process.env.LIVEDESK_CLIENT_NAME || process.env.MINDEXEC_REMOTE_NAME || os.hostname(),
86
+ heartbeatMs: DEFAULT_HEARTBEAT_MS,
87
+ deviceId: '',
88
+ thumbnailEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_THUMBNAIL ?? process.env.MINDEXEC_REMOTE_THUMBNAIL),
89
+ liveEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_LIVE ?? process.env.MINDEXEC_REMOTE_LIVE),
90
+ taskEnabled: !isFalsy(process.env.LIVEDESK_CLIENT_TASKS ?? process.env.MINDEXEC_REMOTE_TASKS),
91
+ filesDir: process.env.LIVEDESK_CLIENT_FILES_DIR || process.env.MINDEXEC_REMOTE_FILES_DIR || '',
92
+ aiEnabled: isTruthy(process.env.LIVEDESK_CLIENT_AI || process.env.MINDEXEC_REMOTE_AI),
93
+ aiModel: process.env.LIVEDESK_CLIENT_AI_MODEL || process.env.MINDEXEC_REMOTE_AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_AI_MODEL,
94
+ openAiApiKey: process.env.OPENAI_API_KEY || '',
95
+ fakeAi: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_AI || process.env.MINDEXEC_REMOTE_FAKE_AI),
96
+ fakeThumbnail: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_THUMBNAIL || process.env.MINDEXEC_REMOTE_FAKE_THUMBNAIL),
97
+ exitOnDisconnect: false,
98
+ exitOnInvalidPair: false,
99
+ once: false,
100
+ version: false,
101
+ help: false
102
+ };
103
+
104
+ const args = [...argv];
105
+ if (args[0] && !args[0].startsWith('-')) {
106
+ result.command = args.shift();
107
+ }
108
+
109
+ for (let index = 0; index < args.length; index += 1) {
110
+ const arg = args[index];
111
+ switch (arg) {
112
+ case '--manager':
113
+ result.manager = args[++index] || result.manager;
114
+ break;
115
+ case '--pair':
116
+ result.pair = args[++index] || '';
117
+ break;
118
+ case '--slot':
119
+ result.slotNumber = normalizeSlotNumber(args[++index] || result.slotNumber);
120
+ break;
121
+ case '--name':
122
+ result.name = args[++index] || result.name;
123
+ break;
124
+ case '--heartbeat':
125
+ result.heartbeatMs = Number(args[++index] || DEFAULT_HEARTBEAT_MS);
126
+ break;
127
+ case '--device-id':
128
+ result.deviceId = args[++index] || '';
129
+ break;
130
+ case '--thumbnail':
131
+ result.thumbnailEnabled = true;
132
+ break;
133
+ case '--no-thumbnail':
134
+ result.thumbnailEnabled = false;
135
+ break;
136
+ case '--live':
137
+ result.liveEnabled = true;
138
+ break;
139
+ case '--no-live':
140
+ result.liveEnabled = false;
141
+ break;
142
+ case '--tasks':
143
+ result.taskEnabled = true;
144
+ break;
145
+ case '--no-tasks':
146
+ result.taskEnabled = false;
147
+ break;
148
+ case '--files-dir':
149
+ result.filesDir = args[++index] || result.filesDir;
150
+ break;
151
+ case '--ai':
152
+ result.aiEnabled = true;
153
+ result.taskEnabled = true;
154
+ break;
155
+ case '--no-ai':
156
+ result.aiEnabled = false;
157
+ break;
158
+ case '--ai-model':
159
+ result.aiModel = args[++index] || result.aiModel;
160
+ break;
161
+ case '--fake-ai':
162
+ result.fakeAi = true;
163
+ result.aiEnabled = true;
164
+ result.taskEnabled = true;
165
+ break;
166
+ case '--fake-thumbnail':
167
+ result.fakeThumbnail = true;
168
+ result.thumbnailEnabled = true;
169
+ break;
170
+ case '--exit-on-disconnect':
171
+ result.exitOnDisconnect = true;
172
+ break;
173
+ case '--exit-on-invalid-pair':
174
+ result.exitOnInvalidPair = true;
175
+ break;
176
+ case '--once':
177
+ result.once = true;
178
+ break;
179
+ case '--help':
180
+ case '-h':
181
+ result.help = true;
182
+ break;
183
+ case '--version':
184
+ case '-v':
185
+ result.version = true;
186
+ break;
187
+ default:
188
+ throw new Error(`Unknown option: ${arg}`);
189
+ }
190
+ }
191
+
192
+ if (!Number.isFinite(result.heartbeatMs) || result.heartbeatMs < 1000) {
193
+ result.heartbeatMs = DEFAULT_HEARTBEAT_MS;
194
+ }
195
+
196
+ return result;
197
+ }
198
+
199
+ function parseManagerAddress(value) {
200
+ let text = String(value || DEFAULT_MANAGER).trim();
201
+ text = text.replace(/^tcp:\/\//i, '');
202
+ const separator = text.lastIndexOf(':');
203
+ if (separator <= 0) {
204
+ return {
205
+ host: text || '127.0.0.1',
206
+ port: 5197
207
+ };
208
+ }
209
+
210
+ const host = text.slice(0, separator).trim() || '127.0.0.1';
211
+ const port = Number(text.slice(separator + 1));
212
+ return {
213
+ host,
214
+ port: Number.isFinite(port) ? port : 5197
215
+ };
216
+ }
217
+
218
+ function normalizeDeviceId(value) {
219
+ return String(value || '').trim().replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
220
+ }
221
+
222
+ function normalizeSlotNumber(value) {
223
+ const number = Number(String(value || '').trim());
224
+ return Number.isInteger(number) && number >= 1 && number <= 999 ? number : 0;
225
+ }
226
+
227
+ async function getDeviceId(explicitDeviceId = '') {
228
+ const explicit = normalizeDeviceId(explicitDeviceId);
229
+ if (explicit) {
230
+ return explicit;
231
+ }
232
+
233
+ const stateDir = path.join(os.homedir(), '.livedesk-client');
234
+ const statePath = path.join(stateDir, 'device.json');
235
+ try {
236
+ const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
237
+ const existing = normalizeDeviceId(state.deviceId);
238
+ if (existing) {
239
+ return existing;
240
+ }
241
+ } catch {
242
+ // Create a new device id below.
243
+ }
244
+
245
+ const deviceId = `livedesk-${crypto.randomUUID()}`;
246
+ await fs.mkdir(stateDir, { recursive: true });
247
+ await fs.writeFile(statePath, JSON.stringify({
248
+ deviceId,
249
+ createdAt: new Date().toISOString()
250
+ }, null, 2));
251
+ return deviceId;
252
+ }
253
+
254
+ function getRootDiskStatus() {
255
+ const root = path.parse(os.homedir()).root || '/';
256
+ try {
257
+ const stats = statfsSync(root);
258
+ const totalBytes = Number(stats.blocks) * Number(stats.bsize);
259
+ const freeBytes = Number(stats.bavail) * Number(stats.bsize);
260
+ return {
261
+ root,
262
+ totalBytes,
263
+ freeBytes,
264
+ usedRatio: totalBytes > 0 ? Number(((totalBytes - freeBytes) / totalBytes).toFixed(4)) : 0
265
+ };
266
+ } catch {
267
+ return { root, totalBytes: 0, freeBytes: 0, usedRatio: 0 };
268
+ }
269
+ }
270
+
271
+ function optionalNumber(value) {
272
+ const number = Number(value);
273
+ return Number.isFinite(number) ? number : undefined;
274
+ }
275
+
276
+ function optionalRatio(value) {
277
+ const number = optionalNumber(value);
278
+ if (number === undefined) {
279
+ return undefined;
280
+ }
281
+ return Math.max(0, Math.min(1, number > 1 ? number / 100 : number));
282
+ }
283
+
284
+ function getAcceleratorStatus() {
285
+ return {
286
+ gpu: {
287
+ name: process.env.LIVEDESK_GPU_NAME || process.env.MINDEXEC_GPU_NAME || '',
288
+ usageRatio: optionalRatio(process.env.LIVEDESK_GPU_USAGE_RATIO || process.env.MINDEXEC_GPU_USAGE_RATIO),
289
+ temperatureC: optionalNumber(process.env.LIVEDESK_GPU_TEMPERATURE_C || process.env.MINDEXEC_GPU_TEMPERATURE_C)
290
+ },
291
+ npu: {
292
+ name: process.env.LIVEDESK_NPU_NAME || process.env.MINDEXEC_NPU_NAME || '',
293
+ usageRatio: optionalRatio(process.env.LIVEDESK_NPU_USAGE_RATIO || process.env.MINDEXEC_NPU_USAGE_RATIO),
294
+ tops: optionalNumber(process.env.LIVEDESK_NPU_TOPS || process.env.MINDEXEC_NPU_TOPS)
295
+ }
296
+ };
297
+ }
298
+
299
+ function getNetworkStatus() {
300
+ const interfaces = os.networkInterfaces();
301
+ const activeName = Object.entries(interfaces).find(([, addresses]) =>
302
+ Array.isArray(addresses) && addresses.some(address => !address.internal && address.family === 'IPv4')
303
+ )?.[0] || '';
304
+ return {
305
+ interface: activeName,
306
+ link: activeName || '',
307
+ rxMbps: optionalNumber(process.env.LIVEDESK_NETWORK_RX_MBPS || process.env.MINDEXEC_NETWORK_RX_MBPS),
308
+ txMbps: optionalNumber(process.env.LIVEDESK_NETWORK_TX_MBPS || process.env.MINDEXEC_NETWORK_TX_MBPS),
309
+ latencyMs: optionalNumber(process.env.LIVEDESK_NETWORK_LATENCY_MS || process.env.MINDEXEC_NETWORK_LATENCY_MS),
310
+ usageRatio: optionalRatio(process.env.LIVEDESK_NETWORK_USAGE_RATIO || process.env.MINDEXEC_NETWORK_USAGE_RATIO)
311
+ };
312
+ }
313
+
314
+ function getStatus(options = {}) {
315
+ const totalMem = os.totalmem();
316
+ const freeMem = os.freemem();
317
+ const cpus = os.cpus();
318
+ const loadavg = os.loadavg();
319
+ const cpuCores = Math.max(1, cpus.length || os.availableParallelism?.() || 1);
320
+ const cpuUsageRatio = loadavg[0] > 0
321
+ ? Number(Math.min(1, loadavg[0] / cpuCores).toFixed(4))
322
+ : 0;
323
+ const usedMemRatio = totalMem > 0 ? Number(((totalMem - freeMem) / totalMem).toFixed(4)) : 0;
324
+ const acceleratorStatus = getAcceleratorStatus();
325
+ const status = {
326
+ uptimeSec: Math.round(os.uptime()),
327
+ loadavg,
328
+ totalMem,
329
+ freeMem,
330
+ usedMemRatio,
331
+ platform: os.platform(),
332
+ release: os.release(),
333
+ role: options.aiEnabled ? 'Agent worker' : options.taskEnabled ? 'Task worker' : 'Local machine',
334
+ cpu: {
335
+ cores: cpuCores,
336
+ model: cpus[0]?.model || '',
337
+ speedMHz: cpus[0]?.speed || 0,
338
+ usageRatio: cpuUsageRatio,
339
+ load1: loadavg[0] || 0
340
+ },
341
+ memory: {
342
+ totalBytes: totalMem,
343
+ freeBytes: freeMem,
344
+ usedRatio: usedMemRatio
345
+ },
346
+ screenCount: 1,
347
+ monitorCount: 1,
348
+ screens: {
349
+ count: 1,
350
+ displays: []
351
+ },
352
+ disk: getRootDiskStatus(),
353
+ gpu: acceleratorStatus.gpu,
354
+ npu: acceleratorStatus.npu,
355
+ network: getNetworkStatus(),
356
+ workload: {
357
+ role: options.aiEnabled ? 'Agent worker' : options.taskEnabled ? 'Task worker' : 'Local machine',
358
+ status: 'idle',
359
+ title: 'idle'
360
+ },
361
+ timestamp: new Date().toISOString()
362
+ };
363
+ if (options.slotNumber) {
364
+ status.slotNumber = options.slotNumber;
365
+ }
366
+ return status;
367
+ }
368
+
369
+ function writeJsonLine(socket, payload) {
370
+ if (!socket || socket.destroyed) {
371
+ return false;
372
+ }
373
+
374
+ socket.write(`${JSON.stringify(payload)}\n`);
375
+ return true;
376
+ }
377
+
376
378
  function wait(ms) {
377
379
  return new Promise(resolve => setTimeout(resolve, ms));
378
380
  }
379
381
 
380
- function getDefaultFilesDir() {
381
- return path.join(os.homedir(), 'Desktop', 'LiveDeskFiles');
382
- }
383
-
384
- function normalizeDirectoryPath(value, fallback = getDefaultFilesDir()) {
385
- const text = String(value || '').replace(/\0/g, '').trim();
386
- if (!text) {
387
- return fallback;
388
- }
389
- if (path.isAbsolute(text)) {
390
- return path.resolve(text);
391
- }
392
- return path.resolve(fallback, text);
393
- }
394
-
395
- function sanitizePathSegment(value, fallback = 'file') {
396
- const text = String(value || '')
397
- .replace(/\0/g, '')
398
- .replace(/[<>:"|?*\x00-\x1f]/g, '_')
399
- .replace(/[\\/]+/g, '_')
400
- .trim();
401
- const normalized = text && text !== '.' && text !== '..' ? text : fallback;
402
- return normalized.slice(0, 160);
403
- }
404
-
405
- function sanitizeRelativeFilePath(value, fallbackName = 'file') {
406
- const parts = String(value || '')
407
- .replace(/\0/g, '')
408
- .split(/[\\/]+/)
409
- .map(part => sanitizePathSegment(part, ''))
410
- .filter(Boolean)
411
- .filter(part => part !== '.' && part !== '..');
412
- if (parts.length === 0) {
413
- return sanitizePathSegment(fallbackName, 'file');
414
- }
415
- return path.join(...parts.slice(-8));
416
- }
417
-
418
- async function handleFileTransferCommand(options, payload = {}) {
419
- const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
420
- if (files.length === 0) {
421
- throw new Error('No files were included in the transfer.');
422
- }
423
-
424
- const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
425
- await fs.mkdir(baseDir, { recursive: true });
426
-
427
- let totalBytes = 0;
428
- const saved = [];
429
- for (const file of files) {
430
- const name = sanitizePathSegment(file?.name, 'file');
431
- const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
432
- const targetPath = path.resolve(baseDir, relativePath);
433
- const relativeFromBase = path.relative(baseDir, targetPath);
434
- if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
435
- throw new Error(`Unsafe file path: ${relativePath}`);
436
- }
437
-
438
- const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
439
- if (!dataBase64) {
440
- throw new Error(`Missing file data: ${name}`);
441
- }
442
- const buffer = Buffer.from(dataBase64, 'base64');
443
- totalBytes += buffer.length;
444
- if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
445
- throw new Error('File transfer exceeded the local size limit.');
446
- }
447
-
448
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
449
- await fs.writeFile(targetPath, buffer);
450
- saved.push({
451
- name,
452
- relativePath,
453
- path: targetPath,
454
- bytes: buffer.length
455
- });
382
+ async function waitForNodeServiceState(executable, service, expectedState, timeoutMs = 15000) {
383
+ const deadline = Date.now() + Math.max(1000, Math.min(30000, timeoutMs));
384
+ while (Date.now() < deadline) {
385
+ const query = process.platform === 'win32'
386
+ ? await runNodeAgentProcess(executable, ['query', service], 5000)
387
+ : await runNodeAgentProcess(executable, ['is-active', service], 5000);
388
+ const output = String(query.output || '').toLowerCase();
389
+ const reached = process.platform === 'win32'
390
+ ? output.includes(String(expectedState).toLowerCase())
391
+ : expectedState === 'running'
392
+ ? output.includes('active') && !output.includes('inactive')
393
+ : output.includes('inactive') || output.includes('dead') || output.includes('failed');
394
+ if (reached) return query;
395
+ await wait(250);
456
396
  }
457
-
458
- return {
459
- kind: 'file.transfer',
460
- transferId: String(payload.transferId || '').slice(0, 128),
461
- status: 'completed',
462
- directory: baseDir,
463
- files: saved,
464
- totalBytes,
465
- completedAt: new Date().toISOString()
466
- };
397
+ throw new Error(`Service ${service} did not reach ${expectedState} state in time.`);
467
398
  }
468
-
469
- async function handleFileTransferChunkCommand(options, payload = {}) {
470
- const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
471
- const name = sanitizePathSegment(payload.name, 'file');
472
- const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
473
- const targetPath = path.resolve(baseDir, relativePath);
474
- const relativeFromBase = path.relative(baseDir, targetPath);
475
- if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
476
- throw new Error(`Unsafe file path: ${relativePath}`);
477
- }
478
-
479
- const transferId = String(payload.transferId || '').replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
480
- const offset = Math.max(0, Math.floor(Number(payload.offset) || 0));
481
- const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
482
- const final = payload.final === true;
483
- const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
484
- if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
485
- throw new Error('Invalid file transfer chunk.');
486
- }
487
- if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
488
- throw new Error('Empty file chunks are not allowed.');
489
- }
490
-
491
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
492
- const tempPath = `${targetPath}.livedesk-${transferId}.part`;
493
- const handle = await fs.open(tempPath, offset === 0 ? 'w+' : 'r+');
494
- let completedSize = 0;
495
- try {
496
- const before = await handle.stat();
497
- if (offset > before.size) {
498
- throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
499
- }
500
- if (buffer.length > 0) {
501
- await handle.write(buffer, 0, buffer.length, offset);
502
- }
503
- const after = await handle.stat();
504
- completedSize = after.size;
505
- if (final) {
506
- await handle.sync();
507
- if (after.size !== totalBytes) {
508
- throw new Error(`File transfer is incomplete (${after.size}/${totalBytes}).`);
399
+
400
+ function getDefaultFilesDir() {
401
+ return path.join(os.homedir(), 'Desktop', 'LiveDeskFiles');
402
+ }
403
+
404
+ function normalizeDirectoryPath(value, fallback = getDefaultFilesDir()) {
405
+ const text = String(value || '').replace(/\0/g, '').trim();
406
+ if (!text) {
407
+ return fallback;
408
+ }
409
+ if (path.isAbsolute(text)) {
410
+ return path.resolve(text);
411
+ }
412
+ return path.resolve(fallback, text);
413
+ }
414
+
415
+ function sanitizePathSegment(value, fallback = 'file') {
416
+ const text = String(value || '')
417
+ .replace(/\0/g, '')
418
+ .replace(/[<>:"|?*\x00-\x1f]/g, '_')
419
+ .replace(/[\\/]+/g, '_')
420
+ .trim();
421
+ const normalized = text && text !== '.' && text !== '..' ? text : fallback;
422
+ return normalized.slice(0, 160);
423
+ }
424
+
425
+ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
426
+ const parts = String(value || '')
427
+ .replace(/\0/g, '')
428
+ .split(/[\\/]+/)
429
+ .map(part => sanitizePathSegment(part, ''))
430
+ .filter(Boolean)
431
+ .filter(part => part !== '.' && part !== '..');
432
+ if (parts.length === 0) {
433
+ return sanitizePathSegment(fallbackName, 'file');
434
+ }
435
+ return path.join(...parts.slice(-8));
436
+ }
437
+
438
+ async function handleFileTransferCommand(options, payload = {}) {
439
+ const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
440
+ if (files.length === 0) {
441
+ throw new Error('No files were included in the transfer.');
442
+ }
443
+
444
+ const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
445
+ await fs.mkdir(baseDir, { recursive: true });
446
+
447
+ let totalBytes = 0;
448
+ const saved = [];
449
+ for (const file of files) {
450
+ const name = sanitizePathSegment(file?.name, 'file');
451
+ const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
452
+ const targetPath = path.resolve(baseDir, relativePath);
453
+ const relativeFromBase = path.relative(baseDir, targetPath);
454
+ if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
455
+ throw new Error(`Unsafe file path: ${relativePath}`);
456
+ }
457
+
458
+ const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
459
+ if (!dataBase64) {
460
+ throw new Error(`Missing file data: ${name}`);
461
+ }
462
+ const buffer = Buffer.from(dataBase64, 'base64');
463
+ totalBytes += buffer.length;
464
+ if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
465
+ throw new Error('File transfer exceeded the local size limit.');
466
+ }
467
+
468
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
469
+ await fs.writeFile(targetPath, buffer);
470
+ saved.push({
471
+ name,
472
+ relativePath,
473
+ path: targetPath,
474
+ bytes: buffer.length
475
+ });
476
+ }
477
+
478
+ return {
479
+ kind: 'file.transfer',
480
+ transferId: String(payload.transferId || '').slice(0, 128),
481
+ status: 'completed',
482
+ directory: baseDir,
483
+ files: saved,
484
+ totalBytes,
485
+ completedAt: new Date().toISOString()
486
+ };
487
+ }
488
+
489
+ async function handleFileTransferChunkCommand(options, payload = {}) {
490
+ const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
491
+ const name = sanitizePathSegment(payload.name, 'file');
492
+ const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
493
+ const targetPath = path.resolve(baseDir, relativePath);
494
+ const relativeFromBase = path.relative(baseDir, targetPath);
495
+ if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
496
+ throw new Error(`Unsafe file path: ${relativePath}`);
497
+ }
498
+
499
+ const transferId = String(payload.transferId || '').replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
500
+ const offset = Math.max(0, Math.floor(Number(payload.offset) || 0));
501
+ const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
502
+ const final = payload.final === true;
503
+ const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
504
+ if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
505
+ throw new Error('Invalid file transfer chunk.');
506
+ }
507
+ if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
508
+ throw new Error('Empty file chunks are not allowed.');
509
+ }
510
+
511
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
512
+ const tempPath = `${targetPath}.livedesk-${transferId}.part`;
513
+ const handle = await fs.open(tempPath, offset === 0 ? 'w+' : 'r+');
514
+ let completedSize = 0;
515
+ try {
516
+ const before = await handle.stat();
517
+ if (offset > before.size) {
518
+ throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
519
+ }
520
+ if (buffer.length > 0) {
521
+ await handle.write(buffer, 0, buffer.length, offset);
522
+ }
523
+ const after = await handle.stat();
524
+ completedSize = after.size;
525
+ if (final) {
526
+ await handle.sync();
527
+ if (after.size !== totalBytes) {
528
+ throw new Error(`File transfer is incomplete (${after.size}/${totalBytes}).`);
529
+ }
530
+ }
531
+ } finally {
532
+ await handle.close();
533
+ }
534
+
535
+ if (final) {
536
+ await fs.rm(targetPath, { force: true });
537
+ await fs.rename(tempPath, targetPath);
538
+ const lastModified = Number(payload.lastModified || 0);
539
+ if (lastModified > 0) {
540
+ const modifiedAt = new Date(lastModified);
541
+ await fs.utimes(targetPath, modifiedAt, modifiedAt).catch(() => undefined);
542
+ }
543
+ }
544
+
545
+ return {
546
+ kind: 'file.transfer.chunk',
547
+ transferId,
548
+ status: final ? 'completed' : 'receiving',
549
+ relativePath: relativePath.split(path.sep).join('/'),
550
+ offset,
551
+ bytes: buffer.length,
552
+ totalBytes,
553
+ completedSize,
554
+ final,
555
+ completedAt: final ? new Date().toISOString() : null
556
+ };
557
+ }
558
+
559
+ function clampNumber(value, min, max, fallback) {
560
+ const number = Number(value);
561
+ if (!Number.isFinite(number)) {
562
+ return fallback;
563
+ }
564
+
565
+ return Math.max(min, Math.min(max, Math.floor(number)));
566
+ }
567
+
568
+ function createFakeThumbnailFrame(options, payload, frameSeq) {
569
+ const width = clampNumber(payload?.maxWidth, 160, 3840, 360);
570
+ const height = clampNumber(payload?.maxHeight, 90, 2160, 220);
571
+ const now = new Date().toISOString();
572
+ const title = String(options.name || os.hostname()).replace(/[<>&"']/g, '_');
573
+ const svg = [
574
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
575
+ '<rect width="100%" height="100%" fill="#0f172a"/>',
576
+ '<rect x="14" y="14" width="calc(100% - 28px)" height="calc(100% - 28px)" rx="12" fill="#1e293b" stroke="#38bdf8" stroke-width="2"/>',
577
+ `<text x="28" y="48" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${title}</text>`,
578
+ `<text x="28" y="82" fill="#93c5fd" font-family="Arial, sans-serif" font-size="14">LiveDesk Client Thumbnail</text>`,
579
+ `<text x="28" y="${height - 30}" fill="#94a3b8" font-family="Consolas, monospace" font-size="12">${now}</text>`,
580
+ '</svg>'
581
+ ].join('');
582
+
583
+ return {
584
+ frameSeq,
585
+ width,
586
+ height,
587
+ sourceWidth: width,
588
+ sourceHeight: height,
589
+ mimeType: 'image/svg+xml',
590
+ data: Buffer.from(svg, 'utf8').toString('base64'),
591
+ capturedAt: now
592
+ };
593
+ }
594
+
595
+ function readJpegDimensions(buffer) {
596
+ if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
597
+ return { width: 0, height: 0 };
598
+ }
599
+
600
+ let offset = 2;
601
+ while (offset + 9 < buffer.length) {
602
+ if (buffer[offset] !== 0xff) {
603
+ offset += 1;
604
+ continue;
605
+ }
606
+
607
+ const marker = buffer[offset + 1];
608
+ const blockLength = buffer.readUInt16BE(offset + 2);
609
+ if (blockLength < 2) {
610
+ break;
611
+ }
612
+
613
+ const isStartOfFrame = marker >= 0xc0
614
+ && marker <= 0xcf
615
+ && ![0xc4, 0xc8, 0xcc].includes(marker);
616
+ if (isStartOfFrame) {
617
+ return {
618
+ height: buffer.readUInt16BE(offset + 5),
619
+ width: buffer.readUInt16BE(offset + 7)
620
+ };
621
+ }
622
+
623
+ offset += 2 + blockLength;
624
+ }
625
+
626
+ return { width: 0, height: 0 };
627
+ }
628
+
629
+ function normalizeMonitorIndex(value, fallback = 0) {
630
+ return clampInteger(value, 0, 63, fallback);
631
+ }
632
+
633
+ async function captureNativeDesktopFrame(frameSeq, monitorIndex = 0) {
634
+ const screenshotModule = await import('node-screenshots');
635
+ const Monitor = screenshotModule.Monitor || screenshotModule.default?.Monitor;
636
+ if (!Monitor?.all) {
637
+ throw new Error('node-screenshots Monitor API is unavailable.');
638
+ }
639
+
640
+ const monitors = Monitor.all();
641
+ const selectedIndex = Math.max(0, Math.min(monitors.length - 1, normalizeMonitorIndex(monitorIndex)));
642
+ const monitor = monitors[selectedIndex] || monitors.find(item => item?.isPrimary?.() === true) || monitors[0];
643
+ if (!monitor?.captureImage) {
644
+ throw new Error('No capturable desktop monitor was found.');
645
+ }
646
+
647
+ const image = await monitor.captureImage();
648
+ const jpeg = await image.toJpeg();
649
+ const buffer = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
650
+ if (buffer.toString('base64').length > MAX_FRAME_BASE64_CHARS) {
651
+ throw new Error('Desktop capture exceeded the remote frame size limit.');
652
+ }
653
+
654
+ const dimensions = readJpegDimensions(buffer);
655
+ return {
656
+ frameSeq,
657
+ width: dimensions.width,
658
+ height: dimensions.height,
659
+ sourceWidth: dimensions.width,
660
+ sourceHeight: dimensions.height,
661
+ mimeType: 'image/jpeg',
662
+ data: buffer.toString('base64'),
663
+ monitorIndex: selectedIndex,
664
+ monitorCount: Math.max(1, monitors.length),
665
+ capturedAt: new Date().toISOString()
666
+ };
667
+ }
668
+
669
+ async function captureDesktopThumbnailFrame(payload, frameSeq) {
670
+ return await captureNativeDesktopFrame(frameSeq, payload?.monitorIndex ?? payload?.screenIndex ?? payload?.displayIndex);
671
+ }
672
+
673
+ async function captureScreenFrame(options, payload, frameSeq, capability = 'thumbnail') {
674
+ const enabled = capability === 'live' ? options.liveEnabled : options.thumbnailEnabled;
675
+ if (!enabled) {
676
+ throw new Error(`${capability} capability is disabled`);
677
+ }
678
+
679
+ return options.fakeThumbnail
680
+ ? createFakeThumbnailFrame(options, payload, frameSeq)
681
+ : await captureDesktopThumbnailFrame(payload, frameSeq);
682
+ }
683
+
684
+ async function captureThumbnailFrame(options, payload, frameSeq) {
685
+ return await captureScreenFrame(options, payload, frameSeq, 'thumbnail');
686
+ }
687
+
688
+ function normalizeApprovalLevel(value) {
689
+ const level = String(value || 'task-only').trim().toLowerCase();
690
+ return level === 'ai-assist' ? 'ai-assist' : 'task-only';
691
+ }
692
+
693
+ function buildAiPrompt(options, payload, instruction) {
694
+ const title = String(payload?.title || 'Remote AI task').trim();
695
+ return [
696
+ 'You are the LiveDesk client AI assistant running on a controlled remote computer.',
697
+ 'Complete the LiveDesk Hub task as a text-only assistant.',
698
+ 'Do not claim you used shell commands, file writes, browser automation, keyboard input, or mouse input.',
699
+ 'If the task requires external side effects, explain what would be needed and stop.',
700
+ '',
701
+ `Device: ${options.name} (${os.hostname()}, ${os.platform()} ${os.release()}, ${os.arch()})`,
702
+ `Task title: ${title}`,
703
+ '',
704
+ 'Hub instruction:',
705
+ instruction
706
+ ].join('\n');
707
+ }
708
+
709
+ function extractResponseText(response) {
710
+ if (typeof response?.output_text === 'string' && response.output_text.trim()) {
711
+ return response.output_text.trim();
712
+ }
713
+
714
+ const parts = [];
715
+ if (Array.isArray(response?.output)) {
716
+ for (const item of response.output) {
717
+ if (!Array.isArray(item?.content)) {
718
+ continue;
719
+ }
720
+
721
+ for (const content of item.content) {
722
+ if (typeof content?.text === 'string') {
723
+ parts.push(content.text);
724
+ }
725
+ if (typeof content?.output_text === 'string') {
726
+ parts.push(content.output_text);
727
+ }
728
+ }
729
+ }
730
+ }
731
+
732
+ return parts.join('\n').trim();
733
+ }
734
+
735
+ async function runAiAssistTask(options, payload, instruction) {
736
+ if (!options.aiEnabled) {
737
+ throw new Error('AI assist capability is disabled. Start the agent with --ai to enable it.');
738
+ }
739
+
740
+ if (options.fakeAi) {
741
+ return {
742
+ text: [
743
+ `Fake AI assist completed on ${options.name}.`,
744
+ '',
745
+ `Instruction: ${instruction.slice(0, 500)}`,
746
+ '',
747
+ 'No shell, file, input, browser, or persistent side effects were performed.'
748
+ ].join('\n'),
749
+ model: 'fake-ai',
750
+ responseId: `fake-ai-${String(payload?.taskId || crypto.randomUUID()).slice(0, 96)}`
751
+ };
752
+ }
753
+
754
+ if (!options.openAiApiKey) {
755
+ throw new Error('OPENAI_API_KEY is required for --ai remote tasks.');
756
+ }
757
+
758
+ const openaiModule = await import('openai');
759
+ const OpenAI = openaiModule.default || openaiModule.OpenAI;
760
+ const client = new OpenAI({ apiKey: options.openAiApiKey });
761
+ const model = String(payload?.model || options.aiModel || DEFAULT_AI_MODEL).trim() || DEFAULT_AI_MODEL;
762
+ const response = await client.responses.create({
763
+ model,
764
+ input: buildAiPrompt(options, payload, instruction)
765
+ });
766
+ const text = extractResponseText(response);
767
+ if (!text) {
768
+ throw new Error('AI assist returned no text output.');
769
+ }
770
+
771
+ return {
772
+ text: text.slice(0, MAX_AI_OUTPUT_CHARS),
773
+ model,
774
+ responseId: response?.id || ''
775
+ };
776
+ }
777
+
778
+ function clampInteger(value, min, max, fallback) {
779
+ const number = Number(value);
780
+ if (!Number.isFinite(number)) {
781
+ return fallback;
782
+ }
783
+
784
+ return Math.max(min, Math.min(max, Math.floor(number)));
785
+ }
786
+
787
+ function stopLiveStream(activeStreams, streamId = '') {
788
+ if (streamId) {
789
+ const existing = activeStreams.get(streamId);
790
+ if (existing?.timer) {
791
+ clearInterval(existing.timer);
792
+ }
793
+ activeStreams.delete(streamId);
794
+ return existing ? 1 : 0;
795
+ }
796
+
797
+ let stopped = 0;
798
+ for (const stream of activeStreams.values()) {
799
+ if (stream?.timer) {
800
+ clearInterval(stream.timer);
801
+ stopped += 1;
802
+ }
803
+ }
804
+ activeStreams.clear();
805
+ return stopped;
806
+ }
807
+
808
+ function startLiveStream(socket, options, message, nextFrameSeq, activeStreams) {
809
+ if (!options.liveEnabled) {
810
+ throw new Error('live stream capability is disabled');
811
+ }
812
+
813
+ const payload = message.payload || {};
814
+ const streamId = String(payload.streamId || `live-${Date.now()}`)
815
+ .replace(/[^a-zA-Z0-9_.:-]/g, '-')
816
+ .slice(0, 128) || `live-${Date.now()}`;
817
+ const fps = clampInteger(payload.fps, 1, MAX_LIVE_FPS, DEFAULT_LIVE_FPS);
818
+ const intervalMs = Math.max(33, Math.round(1000 / fps));
819
+ stopLiveStream(activeStreams, streamId);
820
+
821
+ const stream = {
822
+ streamId,
823
+ fps,
824
+ intervalMs,
825
+ inFlight: false,
826
+ stopped: false,
827
+ frameDrops: 0,
828
+ timer: null
829
+ };
830
+ console.log('전송모드 - mode1-jpeg (Mode 1 - Test JPEG Binary)');
831
+
832
+ const captureAndSend = async () => {
833
+ if (stream.stopped || stream.inFlight || socket.destroyed) {
834
+ if (stream.inFlight) {
835
+ stream.frameDrops += 1;
836
+ }
837
+ return;
838
+ }
839
+
840
+ stream.inFlight = true;
841
+ try {
842
+ const frame = await captureScreenFrame(options, payload, nextFrameSeq(), 'live');
843
+ if (String(frame.data || '').length > MAX_FRAME_BASE64_CHARS) {
844
+ stream.frameDrops += 1;
845
+ return;
846
+ }
847
+
848
+ const sent = writeJsonLine(socket, {
849
+ type: 'stream.frame',
850
+ commandId: message.commandId,
851
+ streamId,
852
+ frameSeq: frame.frameSeq,
853
+ width: frame.width,
854
+ height: frame.height,
855
+ sourceWidth: frame.sourceWidth || frame.width,
856
+ sourceHeight: frame.sourceHeight || frame.height,
857
+ mimeType: frame.mimeType,
858
+ capturedAt: frame.capturedAt,
859
+ fps,
860
+ mode: 'mode1-jpeg',
861
+ monitorIndex: frame.monitorIndex ?? 0,
862
+ monitorCount: frame.monitorCount ?? 1,
863
+ droppedByAgent: stream.frameDrops,
864
+ data: frame.data
865
+ });
866
+
867
+ if (!sent) {
868
+ stream.stopped = true;
869
+ stopLiveStream(activeStreams, streamId);
870
+ }
871
+ } catch {
872
+ stream.frameDrops += 1;
873
+ } finally {
874
+ stream.inFlight = false;
875
+ }
876
+ };
877
+
878
+ stream.timer = setInterval(() => {
879
+ captureAndSend().catch(() => {
880
+ stream.frameDrops += 1;
881
+ });
882
+ }, intervalMs);
883
+ stream.timer.unref?.();
884
+ activeStreams.set(streamId, stream);
885
+ captureAndSend().catch(() => {
886
+ stream.frameDrops += 1;
887
+ });
888
+ return { streamId, fps, intervalMs };
889
+ }
890
+
891
+ function isSensitiveAgentPath(value) {
892
+ const normalized = String(value || '').replaceAll('\\', '/').toLowerCase();
893
+ return normalized.includes('/.codex/') || normalized.endsWith('/auth.json') || normalized.includes('/.ssh/') || normalized.includes('/id_rsa') || normalized.endsWith('.pem') || normalized.endsWith('.key') || normalized.endsWith('/.env') || normalized.includes('/credential') || normalized.includes('/secret') || normalized.includes('/password');
894
+ }
895
+
896
+ function resolveAgentPath(options, value, permissionMode, rejectSensitive = true) {
897
+ const base = normalizeDirectoryPath(options.filesDir || undefined);
898
+ const text = String(value || '').replace(/\0/g, '').trim();
899
+ if (!text || text.length > 600) throw new Error('path is invalid');
900
+ const resolved = path.resolve(path.isAbsolute(text) ? text : path.join(base, text));
901
+ const relative = path.relative(base, resolved);
902
+ if (permissionMode !== 'full-access' && (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error('path is outside the LiveDesk files directory for this permission mode');
903
+ if (rejectSensitive && isSensitiveAgentPath(resolved)) throw new Error('credential and secret paths are not available');
904
+ return resolved;
905
+ }
906
+
907
+ function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefined) {
908
+ return new Promise((resolve, reject) => {
909
+ const child = spawn(executable, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
910
+ let output = '';
911
+ let timedOut = false;
912
+ const append = chunk => { output = `${output}${String(chunk || '')}`.replace(/[\0\r]/g, ' ').slice(0, MAX_AGENT_OUTPUT_CHARS); };
913
+ child.stdout.on('data', append);
914
+ child.stderr.on('data', append);
915
+ const timer = setTimeout(() => {
916
+ timedOut = true;
917
+ child.kill('SIGTERM');
918
+ setTimeout(() => child.kill('SIGKILL'), 1000).unref?.();
919
+ }, Math.max(1000, Math.min(30000, Number(timeoutMs) || 15000)));
920
+ child.once('error', error => { clearTimeout(timer); reject(error); });
921
+ child.once('close', code => { clearTimeout(timer); resolve({ output: redactAgentOutput(`${output}${timedOut ? '\n[timeout]' : ''}`.trim()), exitCode: timedOut ? -1 : code ?? -1, timedOut }); });
922
+ });
923
+ }
924
+
925
+ function redactAgentOutput(value) {
926
+ let output = String(value || '').replace(/[\0\r]/g, ' ');
927
+ output = output.replace(/\b(token|secret|password|api[-_]?key|authorization|private[-_]?key|connection[-_]?string|access[-_]?key|client[-_]?secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1=[redacted]');
928
+ output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]');
929
+ output = output.replace(/^(\s*(?:set\s+)?(?:[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL|CONNECTION)[A-Z0-9_]*)\s*=\s*).+$/gim, '$1[redacted]');
930
+ return output.slice(0, MAX_AGENT_OUTPUT_CHARS);
931
+ }
932
+
933
+ async function findNodeProcessExecutable(processName) {
934
+ const name = String(processName || '').trim();
935
+ if (!name) return '';
936
+ if (process.platform === 'win32') {
937
+ const result = await runNodeAgentProcess('powershell.exe', [
938
+ '-NoProfile', '-NonInteractive', '-Command',
939
+ '$name=$args[0]; $p=Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq $name } | Select-Object -First 1 -ExpandProperty ExecutablePath; if($p){[Console]::Out.Write($p)}',
940
+ name
941
+ ], 10000);
942
+ return result.exitCode === 0 ? String(result.output || '').trim().split(/\r?\n/)[0] : '';
943
+ }
944
+ if (process.platform === 'linux') {
945
+ const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ] && [ -e "/proc/$pid/exe" ]; then readlink -f "/proc/$pid/exe"; fi', 'livedesk-process-path', name], 10000);
946
+ return result.exitCode === 0 ? String(result.output || '').trim().split(/\r?\n/)[0] : '';
947
+ }
948
+ return '';
949
+ }
950
+
951
+ async function persistNodeEnvironmentVariable(name, value) {
952
+ const variableName = String(name || '').trim();
953
+ const variableValue = String(value ?? '');
954
+ if (process.platform === 'win32') {
955
+ const result = await runNodeAgentProcess('setx.exe', [variableName, variableValue], 15000);
956
+ if (result.exitCode !== 0 || result.timedOut) throw new Error(`Persistent environment update failed (exit ${result.exitCode}).`);
957
+ process.env[variableName] = variableValue;
958
+ return { persisted: true, scope: 'user', output: result.output };
959
+ }
960
+ const profilePath = path.join(os.homedir(), '.profile');
961
+ const marker = `# LiveDesk managed environment: ${variableName}`;
962
+ const quoted = `'${variableValue.replaceAll("'", "'\\\"'\\\"'")}'`;
963
+ let profile = '';
964
+ try { profile = await fs.readFile(profilePath, 'utf8'); } catch { /* create on demand */ }
965
+ const lines = profile.split(/\r?\n/).filter(line => !line.includes(marker));
966
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
967
+ lines.push(marker, `export ${variableName}=${quoted}`);
968
+ await fs.writeFile(profilePath, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
969
+ process.env[variableName] = variableValue;
970
+ return { persisted: true, scope: 'user-profile', path: profilePath };
971
+ }
972
+
973
+ async function executeNodeAgentOperation(options, operation, payload = {}) {
974
+ const permissionMode = String(payload.permissionMode || 'ask');
975
+ const args = payload.toolArguments && typeof payload.toolArguments === 'object' ? payload.toolArguments : payload;
976
+ if (operation === 'file.read') {
977
+ const filePath = resolveAgentPath(options, args.path, permissionMode);
978
+ const info = await fs.stat(filePath);
979
+ const maxBytes = Math.max(1, Math.min(65536, Number(args.maxBytes) || 65536));
980
+ const content = (await fs.readFile(filePath)).subarray(0, maxBytes).toString('utf8');
981
+ return { summary: `Read ${Buffer.byteLength(content)} bytes from ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: info.size, returnedBytes: Buffer.byteLength(content), truncated: info.size > Buffer.byteLength(content), content } };
982
+ }
983
+ if (operation === 'file.write') {
984
+ const filePath = resolveAgentPath(options, args.path, permissionMode);
985
+ const content = String(args.content || '');
986
+ if (Buffer.byteLength(content) > 1048576) throw new Error('file content exceeds the 1 MiB limit');
987
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
988
+ if (args.append === true) await fs.appendFile(filePath, content, 'utf8');
989
+ else await fs.writeFile(filePath, content, 'utf8');
990
+ return { summary: `Wrote ${Buffer.byteLength(content)} bytes to ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: (await fs.stat(filePath)).size, append: args.append === true } };
991
+ }
992
+ if (operation === 'file.delete') {
993
+ const filePath = resolveAgentPath(options, args.path, permissionMode);
994
+ if (args.recursive === true) await fs.rm(filePath, { recursive: true, force: false });
995
+ else await fs.unlink(filePath);
996
+ return { summary: `Deleted ${path.basename(filePath)}.`, data: { path: filePath, recursive: args.recursive === true } };
997
+ }
998
+ if (operation === 'file.list') {
999
+ const directory = resolveAgentPath(options, args.path, permissionMode);
1000
+ const entries = await fs.readdir(directory, { withFileTypes: true });
1001
+ const maxEntries = Math.max(1, Math.min(500, Number(args.maxEntries) || 200));
1002
+ const data = [];
1003
+ for (const entry of entries.slice(0, maxEntries)) {
1004
+ const entryPath = path.join(directory, entry.name);
1005
+ if (isSensitiveAgentPath(entryPath)) continue;
1006
+ data.push({ name: entry.name, path: entryPath, type: entry.isDirectory() ? 'directory' : 'file', sizeBytes: entry.isDirectory() ? 0 : (await fs.stat(entryPath)).size });
1007
+ }
1008
+ return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
1009
+ }
1010
+ if (operation === 'application.launch') {
1011
+ const executable = String(args.executable || '').trim();
1012
+ if (!executable || executable.length > 400 || /[\0\r\n]/.test(executable)) throw new Error('executable is invalid');
1013
+ const child = spawn(executable, Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [], { cwd: args.workingDirectory ? resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined, detached: true, windowsHide: true, stdio: 'ignore' });
1014
+ child.unref();
1015
+ return { summary: `Started ${path.basename(executable)}.`, data: { executable, pid: child.pid } };
1016
+ }
1017
+ if (operation === 'process.control') {
1018
+ const action = String(args.action || 'stop');
1019
+ const processName = String(args.processName || '').trim();
1020
+ if (action === 'restart') {
1021
+ const executable = await findNodeProcessExecutable(processName);
1022
+ if (!executable) throw new Error(`Restart is not supported because the executable path for ${processName} could not be resolved.`);
1023
+ const stop = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T', '/F'] : ['-TERM', processName]);
1024
+ if (stop.exitCode !== 0 && !stop.timedOut) throw new Error(`Process stop failed before restart (exit ${stop.exitCode}).`);
1025
+ const child = spawn(executable, [], { detached: true, windowsHide: true, stdio: 'ignore' });
1026
+ child.unref();
1027
+ return { summary: `Restarted ${processName}.`, data: { action, processName, stop, restarted: true, pid: child.pid, executable } };
1028
+ }
1029
+ const result = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T'] : ['-TERM', processName]);
1030
+ return { summary: `${action} requested for ${processName}.`, data: { action, processName, ...result } };
1031
+ }
1032
+ if (operation === 'application.close') return executeNodeAgentOperation(options, 'process.control', { ...payload, toolArguments: { ...args, action: 'stop' } });
1033
+ if (operation === 'service.control') {
1034
+ const service = String(args.serviceName || '');
1035
+ const action = String(args.action || 'status');
1036
+ const executable = process.platform === 'win32' ? 'sc.exe' : 'systemctl';
1037
+ const actions = action === 'restart' ? ['stop', 'start'] : [action];
1038
+ const results = [];
1039
+ for (const step of actions) {
1040
+ const result = await runNodeAgentProcess(executable, [step, service]);
1041
+ results.push({ action: step, ...result });
1042
+ if (result.exitCode !== 0 || result.timedOut) break;
1043
+ if (step === 'stop') {
1044
+ await waitForNodeServiceState(executable, service, 'stopped');
1045
+ } else if (step === 'start') {
1046
+ await waitForNodeServiceState(executable, service, 'running');
509
1047
  }
510
1048
  }
511
- } finally {
512
- await handle.close();
513
- }
514
-
515
- if (final) {
516
- await fs.rm(targetPath, { force: true });
517
- await fs.rename(tempPath, targetPath);
518
- const lastModified = Number(payload.lastModified || 0);
519
- if (lastModified > 0) {
520
- const modifiedAt = new Date(lastModified);
521
- await fs.utimes(targetPath, modifiedAt, modifiedAt).catch(() => undefined);
522
- }
523
- }
524
-
525
- return {
526
- kind: 'file.transfer.chunk',
527
- transferId,
528
- status: final ? 'completed' : 'receiving',
529
- relativePath: relativePath.split(path.sep).join('/'),
530
- offset,
531
- bytes: buffer.length,
532
- totalBytes,
533
- completedSize,
534
- final,
535
- completedAt: final ? new Date().toISOString() : null
536
- };
537
- }
538
-
539
- function clampNumber(value, min, max, fallback) {
540
- const number = Number(value);
541
- if (!Number.isFinite(number)) {
542
- return fallback;
543
- }
544
-
545
- return Math.max(min, Math.min(max, Math.floor(number)));
546
- }
547
-
548
- function createFakeThumbnailFrame(options, payload, frameSeq) {
549
- const width = clampNumber(payload?.maxWidth, 160, 3840, 360);
550
- const height = clampNumber(payload?.maxHeight, 90, 2160, 220);
551
- const now = new Date().toISOString();
552
- const title = String(options.name || os.hostname()).replace(/[<>&"']/g, '_');
553
- const svg = [
554
- `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
555
- '<rect width="100%" height="100%" fill="#0f172a"/>',
556
- '<rect x="14" y="14" width="calc(100% - 28px)" height="calc(100% - 28px)" rx="12" fill="#1e293b" stroke="#38bdf8" stroke-width="2"/>',
557
- `<text x="28" y="48" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${title}</text>`,
558
- `<text x="28" y="82" fill="#93c5fd" font-family="Arial, sans-serif" font-size="14">LiveDesk Client Thumbnail</text>`,
559
- `<text x="28" y="${height - 30}" fill="#94a3b8" font-family="Consolas, monospace" font-size="12">${now}</text>`,
560
- '</svg>'
561
- ].join('');
562
-
563
- return {
564
- frameSeq,
565
- width,
566
- height,
567
- sourceWidth: width,
568
- sourceHeight: height,
569
- mimeType: 'image/svg+xml',
570
- data: Buffer.from(svg, 'utf8').toString('base64'),
571
- capturedAt: now
572
- };
573
- }
574
-
575
- function readJpegDimensions(buffer) {
576
- if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
577
- return { width: 0, height: 0 };
578
- }
579
-
580
- let offset = 2;
581
- while (offset + 9 < buffer.length) {
582
- if (buffer[offset] !== 0xff) {
583
- offset += 1;
584
- continue;
585
- }
586
-
587
- const marker = buffer[offset + 1];
588
- const blockLength = buffer.readUInt16BE(offset + 2);
589
- if (blockLength < 2) {
590
- break;
591
- }
592
-
593
- const isStartOfFrame = marker >= 0xc0
594
- && marker <= 0xcf
595
- && ![0xc4, 0xc8, 0xcc].includes(marker);
596
- if (isStartOfFrame) {
597
- return {
598
- height: buffer.readUInt16BE(offset + 5),
599
- width: buffer.readUInt16BE(offset + 7)
600
- };
601
- }
602
-
603
- offset += 2 + blockLength;
604
- }
605
-
606
- return { width: 0, height: 0 };
607
- }
608
-
609
- function normalizeMonitorIndex(value, fallback = 0) {
610
- return clampInteger(value, 0, 63, fallback);
611
- }
612
-
613
- async function captureNativeDesktopFrame(frameSeq, monitorIndex = 0) {
614
- const screenshotModule = await import('node-screenshots');
615
- const Monitor = screenshotModule.Monitor || screenshotModule.default?.Monitor;
616
- if (!Monitor?.all) {
617
- throw new Error('node-screenshots Monitor API is unavailable.');
618
- }
619
-
620
- const monitors = Monitor.all();
621
- const selectedIndex = Math.max(0, Math.min(monitors.length - 1, normalizeMonitorIndex(monitorIndex)));
622
- const monitor = monitors[selectedIndex] || monitors.find(item => item?.isPrimary?.() === true) || monitors[0];
623
- if (!monitor?.captureImage) {
624
- throw new Error('No capturable desktop monitor was found.');
625
- }
626
-
627
- const image = await monitor.captureImage();
628
- const jpeg = await image.toJpeg();
629
- const buffer = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
630
- if (buffer.toString('base64').length > MAX_FRAME_BASE64_CHARS) {
631
- throw new Error('Desktop capture exceeded the remote frame size limit.');
632
- }
633
-
634
- const dimensions = readJpegDimensions(buffer);
635
- return {
636
- frameSeq,
637
- width: dimensions.width,
638
- height: dimensions.height,
639
- sourceWidth: dimensions.width,
640
- sourceHeight: dimensions.height,
641
- mimeType: 'image/jpeg',
642
- data: buffer.toString('base64'),
643
- monitorIndex: selectedIndex,
644
- monitorCount: Math.max(1, monitors.length),
645
- capturedAt: new Date().toISOString()
646
- };
647
- }
648
-
649
- async function captureDesktopThumbnailFrame(payload, frameSeq) {
650
- return await captureNativeDesktopFrame(frameSeq, payload?.monitorIndex ?? payload?.screenIndex ?? payload?.displayIndex);
651
- }
652
-
653
- async function captureScreenFrame(options, payload, frameSeq, capability = 'thumbnail') {
654
- const enabled = capability === 'live' ? options.liveEnabled : options.thumbnailEnabled;
655
- if (!enabled) {
656
- throw new Error(`${capability} capability is disabled`);
657
- }
658
-
659
- return options.fakeThumbnail
660
- ? createFakeThumbnailFrame(options, payload, frameSeq)
661
- : await captureDesktopThumbnailFrame(payload, frameSeq);
662
- }
663
-
664
- async function captureThumbnailFrame(options, payload, frameSeq) {
665
- return await captureScreenFrame(options, payload, frameSeq, 'thumbnail');
1049
+ const ok = results.length === actions.length && results.every(result => result.exitCode === 0 && !result.timedOut);
1050
+ if (!ok) throw new Error(`Service ${action} failed for ${service}.`);
1051
+ return { summary: `Service ${action} completed for ${service}.`, data: { service, action, results } };
1052
+ }
1053
+ if (operation === 'command.run') {
1054
+ const command = String(args.command || '');
1055
+ const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
1056
+ const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
1057
+ const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
1058
+ return { summary: result.timedOut ? 'Command timed out.' : `Command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
1059
+ }
1060
+ if (operation === 'script.run') {
1061
+ const scriptPath = resolveAgentPath(options, args.path, permissionMode);
1062
+ const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
1063
+ const scriptArgs = process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])] : [scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])];
1064
+ const result = await runNodeAgentProcess(executable, scriptArgs, args.timeoutMs);
1065
+ return { summary: result.timedOut ? 'Script timed out.' : `Script exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
1066
+ }
1067
+ if (operation === 'software.install') {
1068
+ const manager = String(args.manager || '').toLowerCase();
1069
+ const packageName = String(args.packageName || '');
1070
+ if (!/^[A-Za-z0-9._@:+/-]{1,200}$/.test(packageName)) throw new Error('packageName contains unsupported characters');
1071
+ const commands = { winget: ['winget.exe', ['install', '--id', packageName, '--silent', '--accept-source-agreements', '--accept-package-agreements']], brew: ['brew', ['install', packageName]], apt: ['apt-get', ['install', '-y', packageName]], npm: ['npm', ['install', '--global', args.version ? `${packageName}@${args.version}` : packageName]] };
1072
+ if (!commands[manager]) throw new Error('unsupported package manager');
1073
+ const [executable, commandArgs] = commands[manager];
1074
+ const result = await runNodeAgentProcess(executable, commandArgs, 30000);
1075
+ return { summary: `Package install exited with code ${result.exitCode}.`, data: { manager, packageName, ...result } };
1076
+ }
1077
+ if (operation === 'network.status') return { summary: 'Network status collected.', data: Object.fromEntries(Object.entries(os.networkInterfaces()).map(([name, values]) => [name, (values || []).map(value => ({ address: value.address, family: value.family, internal: value.internal }))])) };
1078
+ if (operation === 'system.power') {
1079
+ const action = String(args.action || '');
1080
+ const delaySec = String(Math.max(0, Math.min(3600, Number(args.delaySec) || 0)));
1081
+ let executable;
1082
+ let powerArgs;
1083
+ if (process.platform === 'win32') {
1084
+ ({ executable, args: powerArgs } = {
1085
+ lock: { executable: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
1086
+ sleep: { executable: 'rundll32.exe', args: ['powrprof.dll,SetSuspendState', '0,1,0'] },
1087
+ logoff: { executable: 'shutdown.exe', args: ['/l'] },
1088
+ restart: { executable: 'shutdown.exe', args: ['/r', '/t', delaySec] },
1089
+ shutdown: { executable: 'shutdown.exe', args: ['/s', '/t', delaySec] }
1090
+ }[action] || {});
1091
+ } else {
1092
+ ({ executable, args: powerArgs } = {
1093
+ lock: { executable: 'loginctl', args: ['lock-session'] },
1094
+ sleep: { executable: 'systemctl', args: ['suspend'] },
1095
+ logoff: { executable: 'loginctl', args: ['terminate-user', os.userInfo().username] },
1096
+ restart: { executable: 'systemctl', args: ['reboot'] },
1097
+ shutdown: { executable: 'systemctl', args: ['poweroff'] }
1098
+ }[action] || {});
1099
+ }
1100
+ if (!executable) throw new Error(`Unsupported power action: ${action}.`);
1101
+ const result = await runNodeAgentProcess(executable, powerArgs, 15000);
1102
+ return { summary: `Power action ${action} requested.`, data: result };
1103
+ }
1104
+ if (operation === 'system.configure') {
1105
+ if (args.action === 'set-environment-variable') {
1106
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,119}$/.test(String(args.name || '')) || /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC)$/i.test(String(args.name))) throw new Error('environment variable name is not allowed');
1107
+ const persistence = await persistNodeEnvironmentVariable(String(args.name), String(args.value ?? ''));
1108
+ return { summary: `Environment variable ${args.name} was persisted.`, data: { action: args.action, name: args.name, changed: true, ...persistence } };
1109
+ }
1110
+ const result = await runNodeAgentProcess(process.platform === 'win32' ? 'tzutil.exe' : 'timedatectl', process.platform === 'win32' ? ['/s', String(args.value || '')] : ['set-timezone', String(args.value || '')]);
1111
+ return { summary: `Timezone update exited with code ${result.exitCode}.`, data: result };
1112
+ }
1113
+ if (operation === 'logs.collect') {
1114
+ const maxLines = Math.max(1, Math.min(500, Number(args.maxLines) || 100));
1115
+ const result = await runNodeAgentProcess(process.platform === 'win32' ? 'powershell.exe' : 'journalctl', process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-Command', `Get-WinEvent -LogName System -MaxEvents ${maxLines} | Format-List`] : ['-n', String(maxLines), '--no-pager', '-o', 'short']);
1116
+ return { summary: 'Recent logs collected.', data: { source: args.source || 'system', ...result, output: result.output.replace(/(token|password|secret|api[-_]?key|authorization)\s*[:=]\s*[^\s]+/gi, '$1=[redacted]') } };
1117
+ }
1118
+ throw new Error(`Unsupported command: ${operation}`);
666
1119
  }
667
1120
 
668
- function normalizeApprovalLevel(value) {
669
- const level = String(value || 'task-only').trim().toLowerCase();
670
- return level === 'ai-assist' ? 'ai-assist' : 'task-only';
1121
+ function normalizeNodeAgentTaskResult(result) {
1122
+ const data = result?.data;
1123
+ const candidates = [result, data, ...(Array.isArray(data?.results) ? data.results : [])];
1124
+ const failedCandidate = candidates.find(candidate => candidate && typeof candidate === 'object' && (
1125
+ candidate.ok === false
1126
+ || candidate.status === 'failed'
1127
+ || candidate.timedOut === true
1128
+ || (Number.isFinite(Number(candidate.exitCode)) && Number(candidate.exitCode) !== 0)
1129
+ ));
1130
+ const ok = result?.ok !== false && !failedCandidate;
1131
+ const error = ok
1132
+ ? ''
1133
+ : String(result?.error || failedCandidate?.error || result?.summary || 'Agent operation failed.').slice(0, 500);
1134
+ return { ...result, ok, status: ok ? 'completed' : 'failed', error };
671
1135
  }
672
1136
 
673
- function buildAiPrompt(options, payload, instruction) {
674
- const title = String(payload?.title || 'Remote AI task').trim();
675
- return [
676
- 'You are the LiveDesk client AI assistant running on a controlled remote computer.',
677
- 'Complete the LiveDesk Hub task as a text-only assistant.',
678
- 'Do not claim you used shell commands, file writes, browser automation, keyboard input, or mouse input.',
679
- 'If the task requires external side effects, explain what would be needed and stop.',
680
- '',
681
- `Device: ${options.name} (${os.hostname()}, ${os.platform()} ${os.release()}, ${os.arch()})`,
682
- `Task title: ${title}`,
683
- '',
684
- 'Hub instruction:',
685
- instruction
686
- ].join('\n');
687
- }
688
-
689
- function extractResponseText(response) {
690
- if (typeof response?.output_text === 'string' && response.output_text.trim()) {
691
- return response.output_text.trim();
692
- }
693
-
694
- const parts = [];
695
- if (Array.isArray(response?.output)) {
696
- for (const item of response.output) {
697
- if (!Array.isArray(item?.content)) {
698
- continue;
699
- }
700
-
701
- for (const content of item.content) {
702
- if (typeof content?.text === 'string') {
703
- parts.push(content.text);
704
- }
705
- if (typeof content?.output_text === 'string') {
706
- parts.push(content.output_text);
707
- }
708
- }
1137
+ const NODE_AGENT_OPERATIONS = new Set(['process.control', 'service.control', 'application.launch', 'application.close', 'file.read', 'file.write', 'file.delete', 'file.list', 'command.run', 'script.run', 'software.install', 'network.status', 'system.power', 'system.configure', 'logs.collect']);
1138
+
1139
+ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1140
+ const command = String(message.command || '');
1141
+ if (command === 'ping') {
1142
+ writeJsonLine(socket, {
1143
+ type: 'command.result',
1144
+ commandId: message.commandId,
1145
+ result: {
1146
+ pong: true,
1147
+ at: new Date().toISOString()
1148
+ }
1149
+ });
1150
+ return;
1151
+ }
1152
+
1153
+ if (NODE_AGENT_OPERATIONS.has(command)) {
1154
+ if (!options.taskEnabled) {
1155
+ writeJsonLine(socket, { type: 'command.result', commandId: message.commandId, error: 'remote task capability is disabled' });
1156
+ return;
709
1157
  }
710
- }
711
-
712
- return parts.join('\n').trim();
713
- }
714
-
715
- async function runAiAssistTask(options, payload, instruction) {
716
- if (!options.aiEnabled) {
717
- throw new Error('AI assist capability is disabled. Start the agent with --ai to enable it.');
718
- }
719
-
720
- if (options.fakeAi) {
721
- return {
722
- text: [
723
- `Fake AI assist completed on ${options.name}.`,
724
- '',
725
- `Instruction: ${instruction.slice(0, 500)}`,
726
- '',
727
- 'No shell, file, input, browser, or persistent side effects were performed.'
728
- ].join('\n'),
729
- model: 'fake-ai',
730
- responseId: `fake-ai-${String(payload?.taskId || crypto.randomUUID()).slice(0, 96)}`
731
- };
732
- }
733
-
734
- if (!options.openAiApiKey) {
735
- throw new Error('OPENAI_API_KEY is required for --ai remote tasks.');
736
- }
737
-
738
- const openaiModule = await import('openai');
739
- const OpenAI = openaiModule.default || openaiModule.OpenAI;
740
- const client = new OpenAI({ apiKey: options.openAiApiKey });
741
- const model = String(payload?.model || options.aiModel || DEFAULT_AI_MODEL).trim() || DEFAULT_AI_MODEL;
742
- const response = await client.responses.create({
743
- model,
744
- input: buildAiPrompt(options, payload, instruction)
745
- });
746
- const text = extractResponseText(response);
747
- if (!text) {
748
- throw new Error('AI assist returned no text output.');
749
- }
750
-
751
- return {
752
- text: text.slice(0, MAX_AI_OUTPUT_CHARS),
753
- model,
754
- responseId: response?.id || ''
755
- };
756
- }
757
-
758
- function clampInteger(value, min, max, fallback) {
759
- const number = Number(value);
760
- if (!Number.isFinite(number)) {
761
- return fallback;
762
- }
763
-
764
- return Math.max(min, Math.min(max, Math.floor(number)));
765
- }
766
-
767
- function stopLiveStream(activeStreams, streamId = '') {
768
- if (streamId) {
769
- const existing = activeStreams.get(streamId);
770
- if (existing?.timer) {
771
- clearInterval(existing.timer);
772
- }
773
- activeStreams.delete(streamId);
774
- return existing ? 1 : 0;
775
- }
776
-
777
- let stopped = 0;
778
- for (const stream of activeStreams.values()) {
779
- if (stream?.timer) {
780
- clearInterval(stream.timer);
781
- stopped += 1;
782
- }
783
- }
784
- activeStreams.clear();
785
- return stopped;
786
- }
787
-
788
- function startLiveStream(socket, options, message, nextFrameSeq, activeStreams) {
789
- if (!options.liveEnabled) {
790
- throw new Error('live stream capability is disabled');
791
- }
792
-
793
- const payload = message.payload || {};
794
- const streamId = String(payload.streamId || `live-${Date.now()}`)
795
- .replace(/[^a-zA-Z0-9_.:-]/g, '-')
796
- .slice(0, 128) || `live-${Date.now()}`;
797
- const fps = clampInteger(payload.fps, 1, MAX_LIVE_FPS, DEFAULT_LIVE_FPS);
798
- const intervalMs = Math.max(33, Math.round(1000 / fps));
799
- stopLiveStream(activeStreams, streamId);
800
-
801
- const stream = {
802
- streamId,
803
- fps,
804
- intervalMs,
805
- inFlight: false,
806
- stopped: false,
807
- frameDrops: 0,
808
- timer: null
809
- };
810
- console.log('전송모드 - mode1-jpeg (Mode 1 - Test JPEG Binary)');
811
-
812
- const captureAndSend = async () => {
813
- if (stream.stopped || stream.inFlight || socket.destroyed) {
814
- if (stream.inFlight) {
815
- stream.frameDrops += 1;
816
- }
817
- return;
818
- }
819
-
820
- stream.inFlight = true;
821
- try {
822
- const frame = await captureScreenFrame(options, payload, nextFrameSeq(), 'live');
823
- if (String(frame.data || '').length > MAX_FRAME_BASE64_CHARS) {
824
- stream.frameDrops += 1;
825
- return;
826
- }
827
-
828
- const sent = writeJsonLine(socket, {
829
- type: 'stream.frame',
830
- commandId: message.commandId,
831
- streamId,
832
- frameSeq: frame.frameSeq,
833
- width: frame.width,
834
- height: frame.height,
835
- sourceWidth: frame.sourceWidth || frame.width,
836
- sourceHeight: frame.sourceHeight || frame.height,
837
- mimeType: frame.mimeType,
838
- capturedAt: frame.capturedAt,
839
- fps,
840
- mode: 'mode1-jpeg',
841
- monitorIndex: frame.monitorIndex ?? 0,
842
- monitorCount: frame.monitorCount ?? 1,
843
- droppedByAgent: stream.frameDrops,
844
- data: frame.data
845
- });
846
-
847
- if (!sent) {
848
- stream.stopped = true;
849
- stopLiveStream(activeStreams, streamId);
850
- }
851
- } catch {
852
- stream.frameDrops += 1;
853
- } finally {
854
- stream.inFlight = false;
855
- }
856
- };
857
-
858
- stream.timer = setInterval(() => {
859
- captureAndSend().catch(() => {
860
- stream.frameDrops += 1;
861
- });
862
- }, intervalMs);
863
- stream.timer.unref?.();
864
- activeStreams.set(streamId, stream);
865
- captureAndSend().catch(() => {
866
- stream.frameDrops += 1;
867
- });
868
- return { streamId, fps, intervalMs };
869
- }
870
-
871
- async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
872
- const command = String(message.command || '');
873
- if (command === 'ping') {
874
- writeJsonLine(socket, {
875
- type: 'command.result',
876
- commandId: message.commandId,
877
- result: {
878
- pong: true,
879
- at: new Date().toISOString()
880
- }
881
- });
882
- return;
883
- }
884
-
885
- if (command === 'agent.task') {
886
- if (!options.taskEnabled) {
887
- writeJsonLine(socket, {
888
- type: 'command.result',
889
- commandId: message.commandId,
890
- error: 'remote task capability is disabled'
891
- });
892
- return;
893
- }
894
-
895
- const payload = message.payload || {};
896
- const instruction = String(payload.instruction || '').replace(/\0/g, '').trim().slice(0, 4000);
897
- if (!instruction) {
898
- writeJsonLine(socket, {
899
- type: 'command.result',
900
- commandId: message.commandId,
901
- error: 'missing task instruction'
902
- });
903
- return;
904
- }
905
-
906
- const approvalLevel = normalizeApprovalLevel(payload.approvalLevel);
907
- const completedAt = new Date().toISOString();
908
- const title = String(payload.title || instruction.split(/\r?\n/)[0] || 'Remote task')
909
- .replace(/[\r\n\t]/g, ' ')
910
- .trim()
911
- .slice(0, 120);
912
- if (approvalLevel === 'ai-assist') {
913
- try {
914
- const aiResult = await runAiAssistTask(options, payload, instruction);
915
- writeJsonLine(socket, {
916
- type: 'command.result',
917
- commandId: message.commandId,
918
- result: {
919
- kind: 'agent.task',
920
- mode: 'ai-assist',
921
- taskId: String(payload.taskId || '').slice(0, 128),
922
- title,
923
- status: 'completed',
924
- summary: aiResult.text,
925
- sideEffects: 'none',
926
- model: aiResult.model,
927
- responseId: aiResult.responseId,
928
- instructionPreview: instruction.slice(0, 320),
929
- completedAt: new Date().toISOString()
930
- }
931
- });
932
- } catch (error) {
933
- writeJsonLine(socket, {
934
- type: 'command.result',
935
- commandId: message.commandId,
936
- error: error?.message || String(error)
937
- });
938
- }
939
- return;
940
- }
941
-
942
- writeJsonLine(socket, {
943
- type: 'command.result',
944
- commandId: message.commandId,
945
- result: {
946
- kind: 'agent.task',
947
- mode: 'task-only',
948
- taskId: String(payload.taskId || '').slice(0, 128),
949
- title,
950
- status: 'completed',
951
- summary: `Task received by ${options.name}. Safe task-only mode confirmed receipt without shell, file, input, or browser side effects.`,
952
- sideEffects: 'none',
953
- instructionPreview: instruction.slice(0, 320),
954
- device: {
955
- hostname: os.hostname(),
956
- platform: os.platform(),
957
- release: os.release(),
958
- arch: os.arch()
959
- },
960
- completedAt
961
- }
962
- });
963
- return;
964
- }
965
-
966
- if (command === 'thumbnail.capture') {
967
1158
  try {
968
- const frameSeq = nextFrameSeq();
969
- const frame = await captureThumbnailFrame(options, message.payload || {}, frameSeq);
970
- writeJsonLine(socket, {
971
- type: 'thumbnail.frame',
972
- commandId: message.commandId,
973
- streamId: message.payload?.streamId || 'thumbnail',
974
- frameSeq: frame.frameSeq,
975
- width: frame.width,
976
- height: frame.height,
977
- sourceWidth: frame.sourceWidth || frame.width,
978
- sourceHeight: frame.sourceHeight || frame.height,
979
- mimeType: frame.mimeType,
980
- capturedAt: frame.capturedAt,
981
- data: frame.data
982
- });
1159
+ const result = normalizeNodeAgentTaskResult(await executeNodeAgentOperation(options, command, message.payload || {}));
983
1160
  writeJsonLine(socket, {
984
1161
  type: 'command.result',
985
1162
  commandId: message.commandId,
986
- result: {
987
- thumbnail: true,
988
- frameSeq: frame.frameSeq,
989
- width: frame.width,
990
- height: frame.height,
991
- sourceWidth: frame.sourceWidth || frame.width,
992
- sourceHeight: frame.sourceHeight || frame.height,
993
- capturedAt: frame.capturedAt
994
- }
995
- });
996
- } catch (error) {
997
- writeJsonLine(socket, {
998
- type: 'command.result',
999
- commandId: message.commandId,
1000
- error: error?.message || String(error)
1001
- });
1002
- }
1003
- return;
1004
- }
1005
-
1006
- if (command === 'stream.start') {
1007
- try {
1008
- const started = startLiveStream(socket, options, message, nextFrameSeq, activeStreams);
1009
- writeJsonLine(socket, {
1010
- type: 'command.result',
1011
- commandId: message.commandId,
1012
- result: {
1013
- stream: true,
1014
- started: true,
1015
- streamId: started.streamId,
1016
- fps: started.fps,
1017
- intervalMs: started.intervalMs,
1018
- mode: 'mode1-jpeg',
1019
- viewOnly: true,
1020
- inputControl: false,
1021
- startedAt: new Date().toISOString()
1022
- }
1023
- });
1163
+ result: {
1164
+ kind: command,
1165
+ mode: String(message.payload?.permissionMode || 'ask'),
1166
+ taskId: String(message.payload?.taskId || '').slice(0, 128),
1167
+ ok: result.ok,
1168
+ status: result.status,
1169
+ summary: result.summary,
1170
+ error: result.error || undefined,
1171
+ data: result.data,
1172
+ sideEffects: ['file.read', 'file.list', 'network.status', 'logs.collect'].includes(command) ? 'none' : 'audited',
1173
+ completedAt: new Date().toISOString()
1174
+ }
1175
+ });
1024
1176
  } catch (error) {
1177
+ const messageText = error?.message || String(error);
1025
1178
  writeJsonLine(socket, {
1026
1179
  type: 'command.result',
1027
1180
  commandId: message.commandId,
1028
- error: error?.message || String(error)
1181
+ error: messageText,
1182
+ result: { ok: false, status: 'failed', error: messageText, completedAt: new Date().toISOString() }
1029
1183
  });
1030
- }
1031
- return;
1032
- }
1033
-
1034
- if (command === 'stream.stop') {
1035
- const streamId = String(message.payload?.streamId || '').slice(0, 128);
1036
- const stopped = stopLiveStream(activeStreams, streamId);
1037
- writeJsonLine(socket, {
1038
- type: 'command.result',
1039
- commandId: message.commandId,
1040
- result: {
1041
- stream: true,
1042
- stopped: true,
1043
- streamId,
1044
- stoppedCount: stopped,
1045
- stoppedAt: new Date().toISOString()
1046
- }
1047
- });
1048
- return;
1049
- }
1050
-
1051
- if (command === 'file.transfer') {
1052
- try {
1053
- const result = await handleFileTransferCommand(options, message.payload || {});
1054
- writeJsonLine(socket, {
1055
- type: 'command.result',
1056
- commandId: message.commandId,
1057
- result
1058
- });
1059
- } catch (error) {
1060
- writeJsonLine(socket, {
1061
- type: 'command.result',
1062
- commandId: message.commandId,
1063
- error: error?.message || String(error)
1064
- });
1065
- }
1066
- return;
1067
- }
1068
-
1069
- if (command === 'file.transfer.chunk') {
1070
- try {
1071
- const result = await handleFileTransferChunkCommand(options, message.payload || {});
1072
- writeJsonLine(socket, {
1073
- type: 'command.result',
1074
- commandId: message.commandId,
1075
- result
1076
- });
1077
- } catch (error) {
1078
- writeJsonLine(socket, {
1079
- type: 'command.result',
1080
- commandId: message.commandId,
1081
- error: error?.message || String(error)
1082
- });
1083
- }
1084
- return;
1085
- }
1086
-
1087
- if (command === 'audio.start' || command === 'audio.stop') {
1088
- writeJsonLine(socket, {
1089
- type: 'command.result',
1090
- commandId: message.commandId,
1091
- error: 'remote audio is only available in a RemoteFast engine with audio support'
1092
- });
1093
- return;
1094
- }
1095
-
1096
- if (command === 'input.control') {
1097
- writeJsonLine(socket, {
1098
- type: 'command.result',
1099
- commandId: message.commandId,
1100
- error: 'input control is only available in the C# RemoteFast engine'
1101
- });
1102
- return;
1103
- }
1104
-
1105
- writeJsonLine(socket, {
1106
- type: 'command.result',
1107
- commandId: message.commandId,
1108
- error: `Unsupported command: ${command}`
1109
- });
1110
- }
1111
-
1112
- function connectOnce(options, deviceId) {
1113
- const manager = parseManagerAddress(options.manager);
1114
- return new Promise((resolve, reject) => {
1115
- const socket = net.createConnection({
1116
- host: manager.host,
1117
- port: manager.port
1118
- });
1119
-
1120
- let buffer = '';
1121
- let heartbeatTimer = null;
1122
- let resolved = false;
1123
- let frameSeq = 0;
1124
- const activeStreams = new Map();
1125
-
1126
- function cleanup() {
1127
- if (heartbeatTimer) {
1128
- clearInterval(heartbeatTimer);
1129
- heartbeatTimer = null;
1130
- }
1131
- stopLiveStream(activeStreams);
1132
- }
1133
-
1134
- function finish(error = null) {
1135
- if (resolved) {
1136
- return;
1137
- }
1138
- resolved = true;
1139
- cleanup();
1140
- if (!socket.destroyed) {
1141
- socket.destroy();
1142
- }
1143
- if (error) {
1144
- reject(error);
1145
- } else {
1146
- resolve();
1147
- }
1148
- }
1149
-
1150
- socket.setEncoding('utf8');
1151
- socket.setNoDelay(true);
1152
- socket.setKeepAlive(true, options.heartbeatMs);
1153
-
1154
- socket.once('connect', () => {
1155
- writeJsonLine(socket, {
1156
- type: 'hello',
1157
- pairToken: options.pair,
1158
- deviceId,
1159
- deviceName: options.name,
1160
- slotNumber: options.slotNumber || undefined,
1161
- hostname: os.hostname(),
1162
- platform: os.platform(),
1163
- arch: os.arch(),
1164
- pid: process.pid,
1165
- agentVersion: AGENT_VERSION,
1166
- capabilities: {
1167
- status: true,
1168
- thumbnail: options.thumbnailEnabled,
1169
- liveStream: options.liveEnabled,
1170
- monitorSelection: true,
1171
- screenCount: 1,
1172
- monitorCount: 1,
1173
- control: false,
1174
- audio: false,
1175
- remoteAudio: false,
1176
- fileTransfer: true,
1177
- remoteFiles: true,
1178
- fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
1179
- computerAgent: options.taskEnabled,
1180
- taskDispatch: options.taskEnabled,
1181
- aiAssist: options.taskEnabled && options.aiEnabled && (options.fakeAi || !!options.openAiApiKey),
1182
- aiModel: options.aiModel,
1183
- aiProvider: options.fakeAi ? 'fake' : (options.openAiApiKey ? 'openai' : ''),
1184
- externalEffects: false
1185
- }
1186
- });
1187
- });
1188
-
1189
- socket.on('data', chunk => {
1190
- buffer += chunk;
1191
- let newlineIndex = buffer.indexOf('\n');
1192
- while (newlineIndex >= 0) {
1193
- const line = buffer.slice(0, newlineIndex).trim();
1194
- buffer = buffer.slice(newlineIndex + 1);
1195
- newlineIndex = buffer.indexOf('\n');
1196
- if (!line) {
1197
- continue;
1198
- }
1199
-
1200
- const message = JSON.parse(line);
1201
- if (message.type === 'welcome') {
1202
- console.log(`Connected to LiveDesk Hub as ${options.name} (${deviceId})`);
1203
- writeJsonLine(socket, {
1204
- type: 'status',
1205
- status: getStatus(options)
1206
- });
1207
- if (options.once) {
1208
- finish();
1209
- return;
1210
- }
1211
-
1212
- heartbeatTimer = setInterval(() => {
1213
- writeJsonLine(socket, {
1214
- type: 'status',
1215
- status: getStatus(options)
1216
- });
1217
- }, options.heartbeatMs);
1218
- } else if (message.type === 'command') {
1219
- handleRemoteCommand(socket, options, message, () => {
1220
- frameSeq += 1;
1221
- return frameSeq;
1222
- }, activeStreams).catch(error => {
1223
- writeJsonLine(socket, {
1224
- type: 'command.result',
1225
- commandId: message.commandId,
1226
- error: error?.message || String(error)
1227
- });
1228
- });
1229
- } else if (message.type === 'disconnect') {
1230
- finish();
1231
- return;
1232
- } else if (message.type === 'error') {
1233
- const remoteError = new Error(message.error || 'LiveDesk Hub rejected the connection.');
1234
- if (message.error === 'invalid-pair-token' && options.exitOnInvalidPair) {
1235
- remoteError.exitCode = EXIT_INVALID_PAIR_TOKEN;
1236
- }
1237
- finish(remoteError);
1238
- return;
1239
- }
1240
- }
1241
- });
1242
-
1243
- socket.once('error', err => finish(err));
1244
- socket.once('close', () => finish());
1245
- });
1246
- }
1247
-
1248
- async function connectWithRetry(options) {
1249
- if (!options.pair) {
1250
- throw new Error('Missing --pair token. Sign in with Google or get it from LiveDesk Hub status.');
1251
- }
1252
-
1253
- const deviceId = await getDeviceId(options.deviceId);
1254
- let attempt = 0;
1255
- let stopping = false;
1256
-
1257
- process.once('SIGINT', () => {
1258
- stopping = true;
1259
- console.log('\nStopping LiveDesk Client...');
1260
- });
1261
- process.once('SIGTERM', () => {
1262
- stopping = true;
1263
- });
1264
-
1265
- while (!stopping) {
1266
- try {
1267
- await connectOnce(options, deviceId);
1268
- if (options.once || options.exitOnDisconnect || stopping) {
1269
- return;
1270
- }
1271
- attempt = 0;
1272
- } catch (err) {
1273
- attempt += 1;
1274
- if (err?.exitCode === EXIT_INVALID_PAIR_TOKEN) {
1275
- throw err;
1276
- }
1277
- const delayMs = DEFAULT_RECONNECT_MS;
1278
- console.error(`LiveDesk Hub connection failed: ${err?.message || err}. Retrying in ${delayMs}ms.`);
1279
- await wait(delayMs);
1280
- }
1281
- }
1282
- }
1283
-
1284
- async function main() {
1285
- const options = parseArgs(process.argv.slice(2));
1286
- if (options.version || options.command === 'version') {
1287
- console.log(AGENT_VERSION);
1288
- return;
1289
- }
1290
-
1291
- if (options.help || options.command === 'help') {
1292
- printHelp();
1293
- return;
1294
- }
1295
-
1296
- if (options.command !== 'connect') {
1297
- throw new Error(`Unknown command: ${options.command}`);
1298
- }
1299
-
1300
- await connectWithRetry(options);
1301
- }
1302
-
1303
- main().catch(err => {
1304
- console.error(err?.message || err);
1305
- process.exit(Number.isInteger(err?.exitCode) ? err.exitCode : 1);
1306
- });
1184
+ }
1185
+ return;
1186
+ }
1187
+
1188
+ if (command === 'agent.task') {
1189
+ if (!options.taskEnabled) {
1190
+ writeJsonLine(socket, {
1191
+ type: 'command.result',
1192
+ commandId: message.commandId,
1193
+ error: 'remote task capability is disabled'
1194
+ });
1195
+ return;
1196
+ }
1197
+
1198
+ const payload = message.payload || {};
1199
+ const instruction = String(payload.instruction || '').replace(/\0/g, '').trim().slice(0, 4000);
1200
+ if (!instruction) {
1201
+ writeJsonLine(socket, {
1202
+ type: 'command.result',
1203
+ commandId: message.commandId,
1204
+ error: 'missing task instruction'
1205
+ });
1206
+ return;
1207
+ }
1208
+
1209
+ const approvalLevel = normalizeApprovalLevel(payload.approvalLevel);
1210
+ const completedAt = new Date().toISOString();
1211
+ const title = String(payload.title || instruction.split(/\r?\n/)[0] || 'Remote task')
1212
+ .replace(/[\r\n\t]/g, ' ')
1213
+ .trim()
1214
+ .slice(0, 120);
1215
+ if (approvalLevel === 'ai-assist') {
1216
+ try {
1217
+ const aiResult = await runAiAssistTask(options, payload, instruction);
1218
+ writeJsonLine(socket, {
1219
+ type: 'command.result',
1220
+ commandId: message.commandId,
1221
+ result: {
1222
+ kind: 'agent.task',
1223
+ mode: 'ai-assist',
1224
+ taskId: String(payload.taskId || '').slice(0, 128),
1225
+ title,
1226
+ status: 'completed',
1227
+ summary: aiResult.text,
1228
+ sideEffects: 'none',
1229
+ model: aiResult.model,
1230
+ responseId: aiResult.responseId,
1231
+ instructionPreview: instruction.slice(0, 320),
1232
+ completedAt: new Date().toISOString()
1233
+ }
1234
+ });
1235
+ } catch (error) {
1236
+ writeJsonLine(socket, {
1237
+ type: 'command.result',
1238
+ commandId: message.commandId,
1239
+ error: error?.message || String(error)
1240
+ });
1241
+ }
1242
+ return;
1243
+ }
1244
+
1245
+ writeJsonLine(socket, {
1246
+ type: 'command.result',
1247
+ commandId: message.commandId,
1248
+ result: {
1249
+ kind: 'agent.task',
1250
+ mode: 'task-only',
1251
+ taskId: String(payload.taskId || '').slice(0, 128),
1252
+ title,
1253
+ status: 'completed',
1254
+ summary: `Task received by ${options.name}. Safe task-only mode confirmed receipt without shell, file, input, or browser side effects.`,
1255
+ sideEffects: 'none',
1256
+ instructionPreview: instruction.slice(0, 320),
1257
+ device: {
1258
+ hostname: os.hostname(),
1259
+ platform: os.platform(),
1260
+ release: os.release(),
1261
+ arch: os.arch()
1262
+ },
1263
+ completedAt
1264
+ }
1265
+ });
1266
+ return;
1267
+ }
1268
+
1269
+ if (command === 'thumbnail.capture') {
1270
+ try {
1271
+ const frameSeq = nextFrameSeq();
1272
+ const frame = await captureThumbnailFrame(options, message.payload || {}, frameSeq);
1273
+ writeJsonLine(socket, {
1274
+ type: 'thumbnail.frame',
1275
+ commandId: message.commandId,
1276
+ streamId: message.payload?.streamId || 'thumbnail',
1277
+ frameSeq: frame.frameSeq,
1278
+ width: frame.width,
1279
+ height: frame.height,
1280
+ sourceWidth: frame.sourceWidth || frame.width,
1281
+ sourceHeight: frame.sourceHeight || frame.height,
1282
+ mimeType: frame.mimeType,
1283
+ capturedAt: frame.capturedAt,
1284
+ data: frame.data
1285
+ });
1286
+ writeJsonLine(socket, {
1287
+ type: 'command.result',
1288
+ commandId: message.commandId,
1289
+ result: {
1290
+ thumbnail: true,
1291
+ frameSeq: frame.frameSeq,
1292
+ width: frame.width,
1293
+ height: frame.height,
1294
+ sourceWidth: frame.sourceWidth || frame.width,
1295
+ sourceHeight: frame.sourceHeight || frame.height,
1296
+ capturedAt: frame.capturedAt
1297
+ }
1298
+ });
1299
+ } catch (error) {
1300
+ writeJsonLine(socket, {
1301
+ type: 'command.result',
1302
+ commandId: message.commandId,
1303
+ error: error?.message || String(error)
1304
+ });
1305
+ }
1306
+ return;
1307
+ }
1308
+
1309
+ if (command === 'stream.start') {
1310
+ try {
1311
+ const started = startLiveStream(socket, options, message, nextFrameSeq, activeStreams);
1312
+ writeJsonLine(socket, {
1313
+ type: 'command.result',
1314
+ commandId: message.commandId,
1315
+ result: {
1316
+ stream: true,
1317
+ started: true,
1318
+ streamId: started.streamId,
1319
+ fps: started.fps,
1320
+ intervalMs: started.intervalMs,
1321
+ mode: 'mode1-jpeg',
1322
+ viewOnly: true,
1323
+ inputControl: false,
1324
+ startedAt: new Date().toISOString()
1325
+ }
1326
+ });
1327
+ } catch (error) {
1328
+ writeJsonLine(socket, {
1329
+ type: 'command.result',
1330
+ commandId: message.commandId,
1331
+ error: error?.message || String(error)
1332
+ });
1333
+ }
1334
+ return;
1335
+ }
1336
+
1337
+ if (command === 'stream.stop') {
1338
+ const streamId = String(message.payload?.streamId || '').slice(0, 128);
1339
+ const stopped = stopLiveStream(activeStreams, streamId);
1340
+ writeJsonLine(socket, {
1341
+ type: 'command.result',
1342
+ commandId: message.commandId,
1343
+ result: {
1344
+ stream: true,
1345
+ stopped: true,
1346
+ streamId,
1347
+ stoppedCount: stopped,
1348
+ stoppedAt: new Date().toISOString()
1349
+ }
1350
+ });
1351
+ return;
1352
+ }
1353
+
1354
+ if (command === 'file.transfer') {
1355
+ try {
1356
+ const result = await handleFileTransferCommand(options, message.payload || {});
1357
+ writeJsonLine(socket, {
1358
+ type: 'command.result',
1359
+ commandId: message.commandId,
1360
+ result
1361
+ });
1362
+ } catch (error) {
1363
+ writeJsonLine(socket, {
1364
+ type: 'command.result',
1365
+ commandId: message.commandId,
1366
+ error: error?.message || String(error)
1367
+ });
1368
+ }
1369
+ return;
1370
+ }
1371
+
1372
+ if (command === 'file.transfer.chunk') {
1373
+ try {
1374
+ const result = await handleFileTransferChunkCommand(options, message.payload || {});
1375
+ writeJsonLine(socket, {
1376
+ type: 'command.result',
1377
+ commandId: message.commandId,
1378
+ result
1379
+ });
1380
+ } catch (error) {
1381
+ writeJsonLine(socket, {
1382
+ type: 'command.result',
1383
+ commandId: message.commandId,
1384
+ error: error?.message || String(error)
1385
+ });
1386
+ }
1387
+ return;
1388
+ }
1389
+
1390
+ if (command === 'audio.start' || command === 'audio.stop') {
1391
+ writeJsonLine(socket, {
1392
+ type: 'command.result',
1393
+ commandId: message.commandId,
1394
+ error: 'remote audio is only available in a RemoteFast engine with audio support'
1395
+ });
1396
+ return;
1397
+ }
1398
+
1399
+ if (command === 'input.control') {
1400
+ writeJsonLine(socket, {
1401
+ type: 'command.result',
1402
+ commandId: message.commandId,
1403
+ error: 'input control is only available in the C# RemoteFast engine'
1404
+ });
1405
+ return;
1406
+ }
1407
+
1408
+ writeJsonLine(socket, {
1409
+ type: 'command.result',
1410
+ commandId: message.commandId,
1411
+ error: `Unsupported command: ${command}`
1412
+ });
1413
+ }
1414
+
1415
+ function connectOnce(options, deviceId) {
1416
+ const manager = parseManagerAddress(options.manager);
1417
+ return new Promise((resolve, reject) => {
1418
+ const socket = net.createConnection({
1419
+ host: manager.host,
1420
+ port: manager.port
1421
+ });
1422
+
1423
+ let buffer = '';
1424
+ let heartbeatTimer = null;
1425
+ let resolved = false;
1426
+ let frameSeq = 0;
1427
+ const activeStreams = new Map();
1428
+
1429
+ function cleanup() {
1430
+ if (heartbeatTimer) {
1431
+ clearInterval(heartbeatTimer);
1432
+ heartbeatTimer = null;
1433
+ }
1434
+ stopLiveStream(activeStreams);
1435
+ }
1436
+
1437
+ function finish(error = null) {
1438
+ if (resolved) {
1439
+ return;
1440
+ }
1441
+ resolved = true;
1442
+ cleanup();
1443
+ if (!socket.destroyed) {
1444
+ socket.destroy();
1445
+ }
1446
+ if (error) {
1447
+ reject(error);
1448
+ } else {
1449
+ resolve();
1450
+ }
1451
+ }
1452
+
1453
+ socket.setEncoding('utf8');
1454
+ socket.setNoDelay(true);
1455
+ socket.setKeepAlive(true, options.heartbeatMs);
1456
+
1457
+ socket.once('connect', () => {
1458
+ writeJsonLine(socket, {
1459
+ type: 'hello',
1460
+ pairToken: options.pair,
1461
+ deviceId,
1462
+ deviceName: options.name,
1463
+ slotNumber: options.slotNumber || undefined,
1464
+ hostname: os.hostname(),
1465
+ platform: os.platform(),
1466
+ arch: os.arch(),
1467
+ pid: process.pid,
1468
+ agentVersion: AGENT_VERSION,
1469
+ capabilities: {
1470
+ status: true,
1471
+ thumbnail: options.thumbnailEnabled,
1472
+ liveStream: options.liveEnabled,
1473
+ monitorSelection: true,
1474
+ screenCount: 1,
1475
+ monitorCount: 1,
1476
+ control: false,
1477
+ audio: false,
1478
+ remoteAudio: false,
1479
+ fileTransfer: true,
1480
+ remoteFiles: true,
1481
+ fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
1482
+ computerAgent: options.taskEnabled,
1483
+ taskDispatch: options.taskEnabled,
1484
+ agentApproval: options.taskEnabled,
1485
+ agentAudit: options.taskEnabled,
1486
+ agentTools: [...NODE_AGENT_OPERATIONS],
1487
+ elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
1488
+ aiAssist: options.taskEnabled && options.aiEnabled && (options.fakeAi || !!options.openAiApiKey),
1489
+ aiModel: options.aiModel,
1490
+ aiProvider: options.fakeAi ? 'fake' : (options.openAiApiKey ? 'openai' : ''),
1491
+ externalEffects: options.taskEnabled
1492
+ }
1493
+ });
1494
+ });
1495
+
1496
+ socket.on('data', chunk => {
1497
+ buffer += chunk;
1498
+ let newlineIndex = buffer.indexOf('\n');
1499
+ while (newlineIndex >= 0) {
1500
+ const line = buffer.slice(0, newlineIndex).trim();
1501
+ buffer = buffer.slice(newlineIndex + 1);
1502
+ newlineIndex = buffer.indexOf('\n');
1503
+ if (!line) {
1504
+ continue;
1505
+ }
1506
+
1507
+ const message = JSON.parse(line);
1508
+ if (message.type === 'welcome') {
1509
+ console.log(`Connected to LiveDesk Hub as ${options.name} (${deviceId})`);
1510
+ writeJsonLine(socket, {
1511
+ type: 'status',
1512
+ status: getStatus(options)
1513
+ });
1514
+ if (options.once) {
1515
+ finish();
1516
+ return;
1517
+ }
1518
+
1519
+ heartbeatTimer = setInterval(() => {
1520
+ writeJsonLine(socket, {
1521
+ type: 'status',
1522
+ status: getStatus(options)
1523
+ });
1524
+ }, options.heartbeatMs);
1525
+ } else if (message.type === 'command') {
1526
+ handleRemoteCommand(socket, options, message, () => {
1527
+ frameSeq += 1;
1528
+ return frameSeq;
1529
+ }, activeStreams).catch(error => {
1530
+ writeJsonLine(socket, {
1531
+ type: 'command.result',
1532
+ commandId: message.commandId,
1533
+ error: error?.message || String(error)
1534
+ });
1535
+ });
1536
+ } else if (message.type === 'disconnect') {
1537
+ finish();
1538
+ return;
1539
+ } else if (message.type === 'error') {
1540
+ const remoteError = new Error(message.error || 'LiveDesk Hub rejected the connection.');
1541
+ if (message.error === 'invalid-pair-token' && options.exitOnInvalidPair) {
1542
+ remoteError.exitCode = EXIT_INVALID_PAIR_TOKEN;
1543
+ }
1544
+ finish(remoteError);
1545
+ return;
1546
+ }
1547
+ }
1548
+ });
1549
+
1550
+ socket.once('error', err => finish(err));
1551
+ socket.once('close', () => finish());
1552
+ });
1553
+ }
1554
+
1555
+ async function connectWithRetry(options) {
1556
+ if (!options.pair) {
1557
+ throw new Error('Missing --pair token. Sign in with Google or get it from LiveDesk Hub status.');
1558
+ }
1559
+
1560
+ const deviceId = await getDeviceId(options.deviceId);
1561
+ let attempt = 0;
1562
+ let stopping = false;
1563
+
1564
+ process.once('SIGINT', () => {
1565
+ stopping = true;
1566
+ console.log('\nStopping LiveDesk Client...');
1567
+ });
1568
+ process.once('SIGTERM', () => {
1569
+ stopping = true;
1570
+ });
1571
+
1572
+ while (!stopping) {
1573
+ try {
1574
+ await connectOnce(options, deviceId);
1575
+ if (options.once || options.exitOnDisconnect || stopping) {
1576
+ return;
1577
+ }
1578
+ attempt = 0;
1579
+ } catch (err) {
1580
+ attempt += 1;
1581
+ if (err?.exitCode === EXIT_INVALID_PAIR_TOKEN) {
1582
+ throw err;
1583
+ }
1584
+ const delayMs = DEFAULT_RECONNECT_MS;
1585
+ console.error(`LiveDesk Hub connection failed: ${err?.message || err}. Retrying in ${delayMs}ms.`);
1586
+ await wait(delayMs);
1587
+ }
1588
+ }
1589
+ }
1590
+
1591
+ async function main() {
1592
+ const options = parseArgs(process.argv.slice(2));
1593
+ if (options.version || options.command === 'version') {
1594
+ console.log(AGENT_VERSION);
1595
+ return;
1596
+ }
1597
+
1598
+ if (options.help || options.command === 'help') {
1599
+ printHelp();
1600
+ return;
1601
+ }
1602
+
1603
+ if (options.command !== 'connect') {
1604
+ throw new Error(`Unknown command: ${options.command}`);
1605
+ }
1606
+
1607
+ await connectWithRetry(options);
1608
+ }
1609
+
1610
+ main().catch(err => {
1611
+ console.error(err?.message || err);
1612
+ process.exit(Number.isInteger(err?.exitCode) ? err.exitCode : 1);
1613
+ });