@livedesk/client 0.1.122 → 0.1.124

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