@elontools/runner 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1241 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runner v3 - Core Entry Point (Node.js/TypeScript)
4
+ * Executa comandos enviados pelo backend via API
5
+ */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { spawn } from 'child_process';
10
+ import * as crypto from 'crypto';
11
+ // ============================================================================
12
+ // CONFIGURATION
13
+ // ============================================================================
14
+ let config = {
15
+ serverUrl: 'https://elontools.com',
16
+ mode: 'headless',
17
+ };
18
+ const startTime = Date.now();
19
+ // Parse CLI arguments + fallback to config.json
20
+ function parseArgs() {
21
+ const args = process.argv.slice(2);
22
+ for (const arg of args) {
23
+ if (arg.startsWith('--server-url=')) {
24
+ config.serverUrl = arg.split('=')[1];
25
+ }
26
+ else if (arg.startsWith('--pairing-token=')) {
27
+ config.pairingToken = arg.split('=')[1];
28
+ }
29
+ else if (arg.startsWith('--runner-id=')) {
30
+ config.runnerId = arg.split('=')[1];
31
+ }
32
+ else if (arg.startsWith('--runner-token=')) {
33
+ config.runnerToken = arg.split('=')[1];
34
+ }
35
+ else if (arg.startsWith('--mode=')) {
36
+ config.mode = arg.split('=')[1] || 'headless';
37
+ }
38
+ else if (arg.startsWith('--config-path=')) {
39
+ config.configPath = arg.split('=')[1];
40
+ }
41
+ }
42
+ // Fallback: read ~/.elon-runner/config.json if missing runner credentials
43
+ if (!config.runnerId || !config.runnerToken) {
44
+ try {
45
+ const homeDir = os.homedir();
46
+ const configPath = config.configPath || path.join(homeDir, '.elon-runner', 'config.json');
47
+ if (fs.existsSync(configPath)) {
48
+ const saved = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
49
+ if (!config.runnerId && saved.runnerId)
50
+ config.runnerId = saved.runnerId;
51
+ if (!config.runnerToken && saved.runnerToken)
52
+ config.runnerToken = saved.runnerToken;
53
+ if (!config.serverUrl && saved.serverUrl)
54
+ config.serverUrl = saved.serverUrl;
55
+ if (saved.mode)
56
+ config.mode = saved.mode;
57
+ logger.info('📂 Config loaded from ' + configPath, {
58
+ hasRunnerId: !!config.runnerId,
59
+ hasRunnerToken: !!config.runnerToken,
60
+ });
61
+ }
62
+ }
63
+ catch (err) {
64
+ logger.warn('Failed to read config.json fallback', err);
65
+ }
66
+ }
67
+ logger.info('✅ Runner v3 iniciado', {
68
+ serverUrl: config.serverUrl,
69
+ mode: config.mode,
70
+ hasPairingToken: !!config.pairingToken,
71
+ hasRunnerId: !!config.runnerId,
72
+ hasRunnerToken: !!config.runnerToken,
73
+ });
74
+ }
75
+ // ============================================================================
76
+ // LOGGER
77
+ // ============================================================================
78
+ const logger = {
79
+ info: (msg, data) => {
80
+ console.log(`[${new Date().toISOString()}] ℹ️ ${msg}`, data ? JSON.stringify(data) : '');
81
+ },
82
+ success: (msg, data) => {
83
+ console.log(`[${new Date().toISOString()}] ✅ ${msg}`, data ? JSON.stringify(data) : '');
84
+ },
85
+ warn: (msg, data) => {
86
+ console.warn(`[${new Date().toISOString()}] ⚠️ ${msg}`, data ? JSON.stringify(data) : '');
87
+ },
88
+ error: (msg, err) => {
89
+ console.error(`[${new Date().toISOString()}] ❌ ${msg}`, err ? JSON.stringify(err) : '');
90
+ },
91
+ };
92
+ // ============================================================================
93
+ // API INTEGRATION
94
+ // ============================================================================
95
+ async function registerRunner() {
96
+ if (!config.pairingToken) {
97
+ logger.error('Pairing token não fornecido');
98
+ return false;
99
+ }
100
+ try {
101
+ logger.info('📝 Registrando runner com API...');
102
+ const os = require('os');
103
+ const platform = os.platform() === 'darwin' ? 'macos' : os.platform() === 'win32' ? 'windows' : 'linux';
104
+ const arch = os.arch() === 'arm64' ? 'arm64' : 'x64';
105
+ const hostname = os.hostname() || 'elon-runner';
106
+ const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/register`, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ },
111
+ body: JSON.stringify({
112
+ pairing_token: config.pairingToken,
113
+ name: hostname,
114
+ os: platform,
115
+ arch: arch,
116
+ mode: config.mode || 'ui',
117
+ version: process.env.ELON_VERSION || '3.1.1',
118
+ }),
119
+ });
120
+ if (!response.ok) {
121
+ logger.error(`Falha ao registrar: ${response.status}`, await response.text());
122
+ return false;
123
+ }
124
+ const json = (await response.json());
125
+ logger.info('Register response: ' + JSON.stringify(json));
126
+ // API returns { success, data: { success, data: { runner_id, runner_token } } }
127
+ const data = json?.data?.data || json?.data || json;
128
+ logger.info('Parsed data: runner_id=' + data?.runner_id + ' runner_token=' + (data?.runner_token ? 'rt3_***' : 'MISSING'));
129
+ config.runnerId = data.runner_id;
130
+ config.runnerToken = data.runner_token;
131
+ logger.info('Config after register: runnerId=' + config.runnerId + ' hasToken=' + !!config.runnerToken);
132
+ config.pairingToken = undefined; // Remove após registrar
133
+ logger.success(`✅ Runner registrado — Runner ID: ${config.runnerId}`);
134
+ return true;
135
+ }
136
+ catch (error) {
137
+ logger.error('Erro ao registrar runner', error);
138
+ return false;
139
+ }
140
+ }
141
+ // Cache permissions — detect ONCE at startup, never re-trigger dialogs
142
+ let cachedPermissions = null;
143
+ let permissionsDetectedAt = 0;
144
+ const PERM_CACHE_MS = 10 * 60 * 1000; // re-check every 10 min (file perms only, not screen)
145
+ function detectPermissions() {
146
+ // Return cache if fresh
147
+ if (cachedPermissions && (Date.now() - permissionsDetectedAt < PERM_CACHE_MS)) {
148
+ return cachedPermissions;
149
+ }
150
+ const home = os.homedir();
151
+ const perms = {
152
+ desktop: false,
153
+ documents: false,
154
+ downloads: false,
155
+ screen_capture: cachedPermissions?.screen_capture ?? false, // never re-test screen capture
156
+ accessibility: cachedPermissions?.accessibility ?? false,
157
+ microphone: cachedPermissions?.microphone ?? false,
158
+ };
159
+ if (process.platform !== 'darwin') {
160
+ cachedPermissions = { desktop: true, documents: true, downloads: true, screen_capture: true, accessibility: true, microphone: true };
161
+ permissionsDetectedAt = Date.now();
162
+ return cachedPermissions;
163
+ }
164
+ // File access: try readdirSync on protected folders (safe — no dialog if already granted/denied)
165
+ const folderMap = {
166
+ 'Desktop': 'desktop',
167
+ 'Documents': 'documents',
168
+ 'Downloads': 'downloads',
169
+ };
170
+ for (const [folder, key] of Object.entries(folderMap)) {
171
+ try {
172
+ const p = path.join(home, folder);
173
+ if (fs.existsSync(p)) {
174
+ fs.readdirSync(p);
175
+ perms[key] = true;
176
+ }
177
+ }
178
+ catch {
179
+ perms[key] = false;
180
+ }
181
+ }
182
+ // Screen capture & accessibility: ONLY detect ONCE at first call
183
+ // These trigger macOS permission dialogs, so never re-run
184
+ if (!cachedPermissions) {
185
+ // Accessibility: check via AppleScript
186
+ try {
187
+ const { execSync } = require('child_process');
188
+ execSync('osascript -e \'tell application "System Events" to get name of first process\'', { timeout: 3000, stdio: 'pipe' });
189
+ perms.accessibility = true;
190
+ }
191
+ catch {
192
+ perms.accessibility = false;
193
+ }
194
+ // Screen capture: DO NOT run screencapture — it triggers dialog every time if denied
195
+ // Instead just mark as unknown/false; actual status detected when user enables capture via dashboard
196
+ perms.screen_capture = false;
197
+ // Microphone: basic check (no dialog trigger)
198
+ try {
199
+ const { execSync } = require('child_process');
200
+ execSync('system_profiler SPAudioDataType 2>/dev/null | head -1', { timeout: 3000, stdio: 'pipe' });
201
+ perms.microphone = true;
202
+ }
203
+ catch {
204
+ perms.microphone = false;
205
+ }
206
+ }
207
+ cachedPermissions = perms;
208
+ permissionsDetectedAt = Date.now();
209
+ return perms;
210
+ }
211
+ async function sendHeartbeat() {
212
+ if (!config.runnerId || !config.runnerToken) {
213
+ logger.warn('Sem runner_id/token para heartbeat');
214
+ return;
215
+ }
216
+ try {
217
+ const openclawBin = findOpenClawBinary();
218
+ const permissions = detectPermissions();
219
+ const payload = {
220
+ status: 'online',
221
+ has_openclaw: !!openclawBin,
222
+ permissions,
223
+ };
224
+ const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/heartbeat`, {
225
+ method: 'POST',
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ 'Authorization': `Bearer ${config.runnerToken}`,
229
+ },
230
+ body: JSON.stringify(payload),
231
+ });
232
+ if (!response.ok) {
233
+ const errText = await response.text();
234
+ logger.error(`Heartbeat falhou (${response.status}): ${errText}`);
235
+ }
236
+ else {
237
+ logger.info('💓 Heartbeat enviado', { uptime: Math.round((Date.now() - startTime) / 1000) });
238
+ }
239
+ }
240
+ catch (error) {
241
+ logger.warn('Erro ao enviar heartbeat', error instanceof Error ? error.message : error);
242
+ }
243
+ }
244
+ async function pullJobs() {
245
+ if (!config.runnerId || !config.runnerToken) {
246
+ return [];
247
+ }
248
+ try {
249
+ const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/pull`, {
250
+ method: 'GET',
251
+ headers: {
252
+ 'Authorization': `Bearer ${config.runnerToken}`,
253
+ },
254
+ });
255
+ if (!response.ok) {
256
+ return [];
257
+ }
258
+ return (await response.json()).jobs || [];
259
+ }
260
+ catch (error) {
261
+ logger.warn('Erro ao pull jobs', error);
262
+ return [];
263
+ }
264
+ }
265
+ async function reportJobResult(jobId, result) {
266
+ if (!config.runnerId || !config.runnerToken) {
267
+ return;
268
+ }
269
+ try {
270
+ await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/report`, {
271
+ method: 'POST',
272
+ headers: {
273
+ 'Content-Type': 'application/json',
274
+ 'Authorization': `Bearer ${config.runnerToken}`,
275
+ },
276
+ body: JSON.stringify({
277
+ job_id: jobId,
278
+ status: result.status,
279
+ output: result.output,
280
+ error: result.error,
281
+ }),
282
+ });
283
+ logger.success('📤 Job result reportado', { jobId, status: result.status });
284
+ }
285
+ catch (error) {
286
+ logger.error('Erro ao reportar job result', error);
287
+ }
288
+ }
289
+ // ============================================================================
290
+ // JOB EXECUTION
291
+ // ============================================================================
292
+ async function executeJob(job) {
293
+ logger.info('🚀 Executando job', { jobId: job.id, command: job.command });
294
+ try {
295
+ const result = await executeCommand(job.command, job.args || []);
296
+ return {
297
+ status: 'completed',
298
+ output: result.stdout,
299
+ error: null,
300
+ };
301
+ }
302
+ catch (error) {
303
+ return {
304
+ status: 'failed',
305
+ output: '',
306
+ error: error.message || 'Unknown error',
307
+ };
308
+ }
309
+ }
310
+ function executeCommand(command, args) {
311
+ return new Promise((resolve, reject) => {
312
+ const proc = spawn(command, args, {
313
+ shell: process.env.SHELL || true,
314
+ stdio: 'pipe',
315
+ env: getShellEnv(),
316
+ cwd: os.homedir(),
317
+ });
318
+ let stdout = '';
319
+ let stderr = '';
320
+ proc.stdout?.on('data', (data) => {
321
+ stdout += data.toString();
322
+ process.stdout.write(data);
323
+ });
324
+ proc.stderr?.on('data', (data) => {
325
+ stderr += data.toString();
326
+ process.stderr.write(data);
327
+ });
328
+ proc.on('close', (code) => {
329
+ if (code === 0) {
330
+ resolve({ stdout, stderr });
331
+ }
332
+ else {
333
+ reject(new Error(`Command failed with code ${code}: ${stderr}`));
334
+ }
335
+ });
336
+ proc.on('error', (error) => {
337
+ reject(error);
338
+ });
339
+ });
340
+ }
341
+ // ============================================================================
342
+ // SYSTEM INFO
343
+ // ============================================================================
344
+ function getPlatform() {
345
+ return process.platform; // 'darwin' (macOS), 'linux', 'win32' (Windows)
346
+ }
347
+ function getArch() {
348
+ return process.arch; // 'arm64', 'x64', etc
349
+ }
350
+ // ============================================================================
351
+ // FILE SYNC
352
+ // ============================================================================
353
+ const SYNC_INTERVAL_MS = 60000; // every 60s
354
+ const MAX_FILE_SIZE = 512 * 1024; // 512KB max per file
355
+ const MAX_FILES_PER_PUSH = 100;
356
+ let lastFileHashes = {};
357
+ const IGNORE_PATTERNS = [
358
+ 'node_modules', '.git', '.DS_Store', 'dist', 'build', '.next',
359
+ '__pycache__', '.venv', 'venv', '.env', 'secrets',
360
+ '.elon-runner', 'runner.log',
361
+ ];
362
+ const TEXT_EXTENSIONS = new Set([
363
+ '.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.json', '.yaml', '.yml',
364
+ '.toml', '.html', '.css', '.scss', '.sh', '.bash', '.zsh', '.py',
365
+ '.rb', '.go', '.rs', '.sql', '.env.example', '.gitignore', '.cfg',
366
+ '.ini', '.conf', '.xml', '.csv', '.log', '.dockerfile', '.makefile',
367
+ ]);
368
+ function shouldIgnore(name) {
369
+ return IGNORE_PATTERNS.some(p => name === p || name.startsWith('.'));
370
+ }
371
+ function isTextFile(filePath) {
372
+ const ext = path.extname(filePath).toLowerCase();
373
+ const base = path.basename(filePath).toLowerCase();
374
+ return TEXT_EXTENSIONS.has(ext) || base === 'dockerfile' || base === 'makefile' || base === '.gitignore';
375
+ }
376
+ function scanDir(dir, prefix = '') {
377
+ const results = [];
378
+ try {
379
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
380
+ for (const entry of entries) {
381
+ if (shouldIgnore(entry.name))
382
+ continue;
383
+ const fullPath = path.join(dir, entry.name);
384
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
385
+ if (entry.isDirectory()) {
386
+ results.push(...scanDir(fullPath, relPath));
387
+ }
388
+ else if (entry.isFile() && isTextFile(entry.name)) {
389
+ try {
390
+ const stat = fs.statSync(fullPath);
391
+ if (stat.size > MAX_FILE_SIZE)
392
+ continue;
393
+ const content = fs.readFileSync(fullPath, 'utf-8');
394
+ results.push({ file_path: relPath, content, size_bytes: stat.size });
395
+ }
396
+ catch { }
397
+ }
398
+ if (results.length >= MAX_FILES_PER_PUSH * 2)
399
+ break;
400
+ }
401
+ }
402
+ catch { }
403
+ return results;
404
+ }
405
+ // ============================================================================
406
+ // FILE ACCESS PERMISSIONS (macOS protected folders)
407
+ // ============================================================================
408
+ // Folders that require macOS TCC consent (triggers dialog once)
409
+ const PROTECTED_FOLDERS = ['Desktop', 'Documents', 'Downloads'];
410
+ // Non-protected folders — never trigger dialogs
411
+ const SAFE_FOLDERS = ['Projects', 'Developer', 'dev', '.elon-runner'];
412
+ // Cache: which protected folders the user granted access to
413
+ const grantedFolders = new Set();
414
+ let permissionsProbed = false;
415
+ /**
416
+ * Probe protected folders ONCE at startup.
417
+ * Each folder triggers a macOS dialog if not yet consented.
418
+ * We try fs.readdirSync — if it works, user allowed it. If it throws, denied.
419
+ * This runs ONCE so the user sees each dialog only once (not on every sync).
420
+ */
421
+ function probeFilePermissions() {
422
+ if (permissionsProbed)
423
+ return;
424
+ permissionsProbed = true;
425
+ const homeDir = os.homedir();
426
+ logger.info('📂 Solicitando permissões de arquivo (macOS)...');
427
+ for (const folder of PROTECTED_FOLDERS) {
428
+ const fullPath = path.join(homeDir, folder);
429
+ try {
430
+ if (!fs.existsSync(fullPath))
431
+ continue;
432
+ // This triggers the macOS permission dialog if not yet granted
433
+ fs.readdirSync(fullPath);
434
+ grantedFolders.add(fullPath);
435
+ logger.success(`📂 Acesso permitido: ~/${folder}`);
436
+ }
437
+ catch (err) {
438
+ logger.warn(`📂 Acesso negado ou inexistente: ~/${folder} — ${err.code || err.message}`);
439
+ }
440
+ }
441
+ // Safe folders — always add if they exist
442
+ for (const folder of SAFE_FOLDERS) {
443
+ const fullPath = path.join(homeDir, folder);
444
+ try {
445
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
446
+ grantedFolders.add(fullPath);
447
+ }
448
+ }
449
+ catch { }
450
+ }
451
+ logger.info(`📂 Pastas acessíveis: ${grantedFolders.size}`, [...grantedFolders].map(p => path.basename(p)));
452
+ }
453
+ async function syncFiles() {
454
+ if (!config.runnerId || !config.runnerToken)
455
+ return;
456
+ try {
457
+ // Use only folders that were granted access during startup probe
458
+ const scanDirs = [...grantedFolders];
459
+ let allFiles = [];
460
+ for (const dir of scanDirs) {
461
+ const dirName = path.basename(dir);
462
+ const files = scanDir(dir, dirName);
463
+ allFiles.push(...files);
464
+ if (allFiles.length >= MAX_FILES_PER_PUSH * 2)
465
+ break;
466
+ }
467
+ // Filter to only changed files (hash check)
468
+ const changedFiles = [];
469
+ const newHashes = {};
470
+ for (const f of allFiles) {
471
+ const hash = crypto.createHash('md5').update(f.content).digest('hex');
472
+ newHashes[f.file_path] = hash;
473
+ if (lastFileHashes[f.file_path] !== hash) {
474
+ changedFiles.push(f);
475
+ }
476
+ }
477
+ lastFileHashes = newHashes;
478
+ if (changedFiles.length === 0)
479
+ return;
480
+ // Push in batches
481
+ const batch = changedFiles.slice(0, MAX_FILES_PER_PUSH);
482
+ const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/push-files`, {
483
+ method: 'POST',
484
+ headers: {
485
+ 'Content-Type': 'application/json',
486
+ 'Authorization': `Bearer ${config.runnerToken}`,
487
+ },
488
+ body: JSON.stringify({ files: batch }),
489
+ });
490
+ if (response.ok) {
491
+ const json = (await response.json());
492
+ logger.info(`📁 Files synced: ${json?.data?.files_synced || batch.length} files`);
493
+ }
494
+ else {
495
+ logger.warn(`File sync failed: ${response.status}`);
496
+ }
497
+ }
498
+ catch (error) {
499
+ logger.warn('File sync error', error instanceof Error ? error.message : error);
500
+ }
501
+ }
502
+ // ============================================================================
503
+ // TERMINAL COMMAND EXECUTION
504
+ // ============================================================================
505
+ const TERMINAL_TIMEOUT_MS = 300000; // 5 min max per command
506
+ // Build full PATH for macOS (Homebrew, nvm, etc)
507
+ function getShellEnv() {
508
+ const home = os.homedir();
509
+ const existing = process.env.PATH || '/usr/bin:/bin';
510
+ const extraPaths = [
511
+ '/opt/homebrew/bin',
512
+ '/opt/homebrew/sbin',
513
+ '/usr/local/bin',
514
+ '/usr/local/sbin',
515
+ `${home}/.nvm/versions/node/current/bin`,
516
+ `${home}/.volta/bin`,
517
+ `${home}/.bun/bin`,
518
+ ];
519
+ return {
520
+ ...process.env,
521
+ PATH: [...extraPaths, existing].join(':'),
522
+ HOME: home,
523
+ USER: os.userInfo().username,
524
+ LANG: 'en_US.UTF-8',
525
+ };
526
+ }
527
+ async function executeTerminalCommand(cmd) {
528
+ logger.info(`🖥️ Executando comando terminal [${cmd.id}]`, { command: cmd.command });
529
+ const shellEnv = getShellEnv();
530
+ // Use user's default shell on macOS, fallback to /bin/sh
531
+ const userShell = process.env.SHELL || '/bin/zsh';
532
+ const isZsh = userShell.includes('zsh');
533
+ // Wrap command to source profile for full env
534
+ const wrappedCmd = isZsh
535
+ ? `source ~/.zshrc 2>/dev/null; ${cmd.command}`
536
+ : cmd.command;
537
+ const proc = spawn(wrappedCmd, [], {
538
+ shell: userShell,
539
+ cwd: cmd.cwd || os.homedir(),
540
+ stdio: 'pipe',
541
+ env: shellEnv,
542
+ });
543
+ let buffer = '';
544
+ let flushTimeout = null;
545
+ let done = false;
546
+ const flushBuffer = async (status, exitCode) => {
547
+ if (flushTimeout) {
548
+ clearTimeout(flushTimeout);
549
+ flushTimeout = null;
550
+ }
551
+ const chunk = buffer;
552
+ buffer = '';
553
+ try {
554
+ await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/terminal/output`, {
555
+ method: 'POST',
556
+ headers: {
557
+ 'Content-Type': 'application/json',
558
+ 'Authorization': `Bearer ${config.runnerToken}`,
559
+ },
560
+ body: JSON.stringify({
561
+ command_id: cmd.id,
562
+ output_chunk: chunk,
563
+ status,
564
+ exit_code: exitCode,
565
+ }),
566
+ });
567
+ }
568
+ catch { }
569
+ };
570
+ const scheduleFlush = () => {
571
+ if (flushTimeout)
572
+ return;
573
+ flushTimeout = setTimeout(async () => {
574
+ flushTimeout = null;
575
+ if (buffer.length > 0 && !done) {
576
+ await flushBuffer('running');
577
+ }
578
+ }, 500);
579
+ };
580
+ proc.stdout?.on('data', (data) => {
581
+ buffer += data.toString();
582
+ scheduleFlush();
583
+ });
584
+ proc.stderr?.on('data', (data) => {
585
+ buffer += data.toString();
586
+ scheduleFlush();
587
+ });
588
+ // Timeout kill
589
+ const killTimer = setTimeout(() => {
590
+ if (!done) {
591
+ logger.warn(`⏱️ Comando terminal atingiu timeout (${TERMINAL_TIMEOUT_MS}ms) [${cmd.id}]`);
592
+ proc.kill('SIGKILL');
593
+ }
594
+ }, TERMINAL_TIMEOUT_MS);
595
+ proc.on('close', async (code) => {
596
+ done = true;
597
+ clearTimeout(killTimer);
598
+ const exitCode = code ?? -1;
599
+ const status = exitCode === 0 ? 'completed' : 'error';
600
+ await flushBuffer(status, exitCode);
601
+ logger.info(`✅ Comando terminal finalizado [${cmd.id}]`, { exitCode, status });
602
+ });
603
+ proc.on('error', async (err) => {
604
+ done = true;
605
+ clearTimeout(killTimer);
606
+ buffer += `\n[Erro ao executar: ${err.message}]`;
607
+ await flushBuffer('error', -1);
608
+ logger.error(`❌ Erro ao executar comando terminal [${cmd.id}]`, err.message);
609
+ });
610
+ }
611
+ async function pollTerminalCommands() {
612
+ if (!config.runnerId || !config.runnerToken) {
613
+ logger.warn('🖥️ Terminal poll skipped: no runnerId/token');
614
+ return;
615
+ }
616
+ try {
617
+ const res = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/terminal/pending`, {
618
+ headers: { 'Authorization': `Bearer ${config.runnerToken}` },
619
+ });
620
+ if (!res.ok)
621
+ return;
622
+ const data = (await res.json());
623
+ // API returns { success, data: { success, data: { commands } } } due to nested success() wrapper
624
+ const cmds = data?.data?.data?.commands || data?.data?.commands || data?.commands || [];
625
+ if (cmds.length > 0) {
626
+ logger.info(`🖥️ ${cmds.length} comando(s) terminal pendente(s)`);
627
+ }
628
+ for (const cmd of cmds) {
629
+ executeTerminalCommand(cmd).catch(() => { });
630
+ }
631
+ }
632
+ catch { }
633
+ }
634
+ // ============================================================================
635
+ // SCREEN CAPTURE & UI CONTROL (macOS)
636
+ // ============================================================================
637
+ const SCREEN_CAPTURE_INTERVAL_MS = 2000; // Capture every 2s
638
+ let screenCaptureActive = false;
639
+ let screenCaptureInterval = null;
640
+ let isCapturing = false;
641
+ /**
642
+ * Capture screen on macOS using native screencapture command
643
+ */
644
+ let screenPermissionGranted = false;
645
+ let screenPermissionCheckedAt = 0;
646
+ const SCREEN_PERMISSION_RECHECK_MS = 120000; // Re-check every 2 min
647
+ /**
648
+ * Check screen recording permission WITHOUT triggering the system dialog.
649
+ * Uses CGPreflightScreenCaptureAccess() via Python — returns true/false silently.
650
+ */
651
+ async function checkScreenPermission() {
652
+ try {
653
+ const { execSync } = await import('child_process');
654
+ const result = execSync(`python3 -c "import Quartz; print('1' if Quartz.CGPreflightScreenCaptureAccess() else '0')" 2>/dev/null`, { timeout: 5000, encoding: 'utf-8' }).trim();
655
+ return result === '1';
656
+ }
657
+ catch {
658
+ // Fallback: assume not granted
659
+ return false;
660
+ }
661
+ }
662
+ async function captureScreen() {
663
+ if (process.platform !== 'darwin')
664
+ return null;
665
+ // Check permission silently (no dialog) — cache result, re-check periodically
666
+ const now = Date.now();
667
+ if (!screenPermissionGranted || (now - screenPermissionCheckedAt > SCREEN_PERMISSION_RECHECK_MS)) {
668
+ screenPermissionGranted = await checkScreenPermission();
669
+ screenPermissionCheckedAt = now;
670
+ if (!screenPermissionGranted) {
671
+ // Only log once per check cycle
672
+ if (now - screenPermissionCheckedAt < 1000) {
673
+ logger.warn('📸 Gravação de tela não autorizada. Conceda em: Ajustes do Sistema → Privacidade → Gravação de Tela → Elon Tools');
674
+ }
675
+ return null;
676
+ }
677
+ else {
678
+ logger.success('📸 Permissão de gravação de tela OK');
679
+ }
680
+ }
681
+ if (!screenPermissionGranted)
682
+ return null;
683
+ const tmpFile = `/tmp/elon-screen-${Date.now()}.jpg`;
684
+ try {
685
+ const { execSync } = await import('child_process');
686
+ execSync(`screencapture -x -t jpg -C "${tmpFile}"`, { timeout: 5000, stdio: 'pipe' });
687
+ if (!fs.existsSync(tmpFile))
688
+ return null;
689
+ const data = fs.readFileSync(tmpFile);
690
+ fs.unlinkSync(tmpFile);
691
+ return data.length > 500 ? data : null;
692
+ }
693
+ catch (err) {
694
+ logger.warn('📸 Screen capture failed:', err.message);
695
+ // Permission might have been revoked
696
+ screenPermissionGranted = false;
697
+ try {
698
+ fs.unlinkSync(tmpFile);
699
+ }
700
+ catch { }
701
+ return null;
702
+ }
703
+ }
704
+ /**
705
+ * Upload captured frame to backend
706
+ */
707
+ async function uploadFrame(frameData) {
708
+ try {
709
+ await fetch(`${config.serverUrl}/api/v1/runner-v3-ui/${config.runnerId}/ui/frame`, {
710
+ method: 'POST',
711
+ headers: {
712
+ 'Authorization': `Bearer ${config.runnerToken}`,
713
+ 'Content-Type': 'image/jpeg',
714
+ },
715
+ body: frameData,
716
+ });
717
+ }
718
+ catch { }
719
+ }
720
+ /**
721
+ * Screen capture loop — runs every 2s when in UI mode
722
+ */
723
+ async function screenCaptureLoop() {
724
+ if (!config.runnerId || !config.runnerToken)
725
+ return;
726
+ if (isCapturing)
727
+ return;
728
+ isCapturing = true;
729
+ try {
730
+ const frame = await captureScreen();
731
+ if (frame && frame.length > 0) {
732
+ await uploadFrame(frame);
733
+ }
734
+ }
735
+ catch { }
736
+ isCapturing = false;
737
+ }
738
+ /**
739
+ * Execute a UI command (mouse/keyboard) on macOS
740
+ */
741
+ async function executeUICommand(cmd) {
742
+ if (process.platform !== 'darwin') {
743
+ logger.warn(`🖱️ UI commands not supported on ${process.platform}`);
744
+ return;
745
+ }
746
+ const { execSync } = await import('child_process');
747
+ const d = cmd.data;
748
+ try {
749
+ switch (cmd.type) {
750
+ case 'mouse_move': {
751
+ const x = Number(d.x), y = Number(d.y);
752
+ // Use AppleScript for mouse move
753
+ execSync(`osascript -e 'tell application "System Events" to set position of mouse to {${x}, ${y}}'`, { timeout: 3000 });
754
+ break;
755
+ }
756
+ case 'click': {
757
+ const x = Number(d.x), y = Number(d.y);
758
+ execSync(`osascript -e '
759
+ do shell script "python3 -c \\"
760
+ import Quartz
761
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
762
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
763
+ import time; time.sleep(0.05)
764
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
765
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
766
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
767
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
768
+ \\"'`, { timeout: 5000 });
769
+ break;
770
+ }
771
+ case 'double_click': {
772
+ const x = Number(d.x), y = Number(d.y);
773
+ execSync(`osascript -e '
774
+ do shell script "python3 -c \\"
775
+ import Quartz
776
+ for i in range(2):
777
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
778
+ Quartz.CGEventSetIntegerValueField(evt, Quartz.kCGMouseEventClickState, i+1)
779
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
780
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
781
+ Quartz.CGEventSetIntegerValueField(evt, Quartz.kCGMouseEventClickState, i+1)
782
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
783
+ \\"'`, { timeout: 5000 });
784
+ break;
785
+ }
786
+ case 'right_click': {
787
+ const x = Number(d.x), y = Number(d.y);
788
+ execSync(`osascript -e '
789
+ do shell script "python3 -c \\"
790
+ import Quartz
791
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved, (${x}, ${y}), Quartz.kCGMouseButtonRight)
792
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
793
+ import time; time.sleep(0.05)
794
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, (${x}, ${y}), Quartz.kCGMouseButtonRight)
795
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
796
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, (${x}, ${y}), Quartz.kCGMouseButtonRight)
797
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
798
+ \\"'`, { timeout: 5000 });
799
+ break;
800
+ }
801
+ case 'key_press': {
802
+ const key = String(d.key);
803
+ execSync(`osascript -e 'tell application "System Events" to keystroke "${key.replace(/"/g, '\\"')}"'`, { timeout: 3000 });
804
+ break;
805
+ }
806
+ case 'key_combo': {
807
+ const keys = d.keys || [];
808
+ // Map modifier names to AppleScript
809
+ const modMap = {
810
+ 'cmd': 'command down', 'command': 'command down',
811
+ 'ctrl': 'control down', 'control': 'control down',
812
+ 'alt': 'option down', 'option': 'option down',
813
+ 'shift': 'shift down',
814
+ };
815
+ const modifiers = keys.slice(0, -1).map(k => modMap[k.toLowerCase()] || '').filter(Boolean);
816
+ const finalKey = keys[keys.length - 1] || '';
817
+ if (modifiers.length > 0 && finalKey) {
818
+ execSync(`osascript -e 'tell application "System Events" to keystroke "${finalKey}" using {${modifiers.join(', ')}}'`, { timeout: 3000 });
819
+ }
820
+ else if (finalKey) {
821
+ execSync(`osascript -e 'tell application "System Events" to keystroke "${finalKey}"'`, { timeout: 3000 });
822
+ }
823
+ break;
824
+ }
825
+ case 'type_text': {
826
+ const text = String(d.text || '');
827
+ // Type text character by character via AppleScript
828
+ execSync(`osascript -e 'tell application "System Events" to keystroke "${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"'`, { timeout: 10000 });
829
+ break;
830
+ }
831
+ case 'scroll': {
832
+ const x = Number(d.x), y = Number(d.y), deltaY = Number(d.deltaY);
833
+ execSync(`osascript -e '
834
+ do shell script "python3 -c \\"
835
+ import Quartz
836
+ evt = Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, ${deltaY > 0 ? -3 : 3})
837
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
838
+ \\"'`, { timeout: 3000 });
839
+ break;
840
+ }
841
+ case 'drag': {
842
+ const fromX = Number(d.fromX), fromY = Number(d.fromY);
843
+ const toX = Number(d.toX), toY = Number(d.toY);
844
+ execSync(`osascript -e '
845
+ do shell script "python3 -c \\"
846
+ import Quartz, time
847
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (${fromX}, ${fromY}), Quartz.kCGMouseButtonLeft)
848
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
849
+ time.sleep(0.1)
850
+ steps = 10
851
+ for i in range(1, steps+1):
852
+ x = ${fromX} + (${toX}-${fromX})*i/steps
853
+ y = ${fromY} + (${toY}-${fromY})*i/steps
854
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDragged, (x, y), Quartz.kCGMouseButtonLeft)
855
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
856
+ time.sleep(0.02)
857
+ evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (${toX}, ${toY}), Quartz.kCGMouseButtonLeft)
858
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
859
+ \\"'`, { timeout: 10000 });
860
+ break;
861
+ }
862
+ case 'enable_screen_capture': {
863
+ if (!screenCaptureActive) {
864
+ const hasPermission = await checkScreenPermission();
865
+ if (hasPermission) {
866
+ screenCaptureActive = true;
867
+ screenCaptureInterval = setInterval(() => screenCaptureLoop().catch(() => { }), SCREEN_CAPTURE_INTERVAL_MS);
868
+ logger.success('📸 Screen capture ATIVADO pelo dashboard');
869
+ }
870
+ else {
871
+ logger.warn('📸 Permissão de gravação de tela não concedida. Conceda em: Ajustes do Sistema → Privacidade → Gravação de Tela → Elon Tools');
872
+ }
873
+ }
874
+ break;
875
+ }
876
+ case 'disable_screen_capture': {
877
+ if (screenCaptureActive && screenCaptureInterval) {
878
+ clearInterval(screenCaptureInterval);
879
+ screenCaptureInterval = null;
880
+ screenCaptureActive = false;
881
+ logger.info('📸 Screen capture DESATIVADO pelo dashboard');
882
+ }
883
+ break;
884
+ }
885
+ default:
886
+ logger.warn(`🖱️ Unknown UI command type: ${cmd.type}`);
887
+ }
888
+ logger.info(`🖱️ UI command executed: ${cmd.type} [${cmd.id}]`);
889
+ }
890
+ catch (err) {
891
+ logger.error(`🖱️ UI command failed: ${cmd.type} [${cmd.id}]`, err.message);
892
+ }
893
+ }
894
+ /**
895
+ * Poll and execute UI commands from backend
896
+ */
897
+ async function pollUICommands() {
898
+ if (!config.runnerId || !config.runnerToken)
899
+ return;
900
+ try {
901
+ const res = await fetch(`${config.serverUrl}/api/v1/runner-v3-ui/${config.runnerId}/ui/pull`, {
902
+ headers: { 'Authorization': `Bearer ${config.runnerToken}` },
903
+ });
904
+ if (!res.ok)
905
+ return;
906
+ const data = (await res.json());
907
+ const cmds = data?.commands || [];
908
+ for (const cmd of cmds) {
909
+ await executeUICommand(cmd);
910
+ }
911
+ }
912
+ catch { }
913
+ }
914
+ // ============================================================================
915
+ // CHAT SYNC — Read OpenClaw JSONL + push to backend
916
+ // ============================================================================
917
+ const OPENCLAW_DATA_DIR = path.join(os.homedir(), '.openclaw');
918
+ const CHAT_SYNC_INTERVAL_MS = 3000;
919
+ let lastSyncedTimestamp = {}; // per session_key
920
+ let chatSyncInitialDone = false;
921
+ /**
922
+ * Find OpenClaw session JSONL files
923
+ */
924
+ function findOpenClawSessions() {
925
+ const sessions = [];
926
+ // Standard OpenClaw paths:
927
+ // ~/.openclaw/agents/<agentId>/sessions/<sessionKey>/transcript.jsonl
928
+ // ~/.openclaw/agents/<agentId>/agent/config.yaml (for agent name)
929
+ const agentsDir = path.join(OPENCLAW_DATA_DIR, 'agents');
930
+ if (!fs.existsSync(agentsDir))
931
+ return sessions;
932
+ try {
933
+ const agentIds = fs.readdirSync(agentsDir).filter(d => {
934
+ try {
935
+ return fs.statSync(path.join(agentsDir, d)).isDirectory();
936
+ }
937
+ catch {
938
+ return false;
939
+ }
940
+ });
941
+ for (const agentId of agentIds) {
942
+ // Try to read agent name from config
943
+ let agentName = agentId;
944
+ try {
945
+ const configPath = path.join(agentsDir, agentId, 'agent', 'config.yaml');
946
+ if (fs.existsSync(configPath)) {
947
+ const configContent = fs.readFileSync(configPath, 'utf-8');
948
+ const nameMatch = configContent.match(/^name:\s*["']?([^"'\n]+)/m);
949
+ if (nameMatch)
950
+ agentName = nameMatch[1].trim();
951
+ }
952
+ }
953
+ catch { }
954
+ const sessionsDir = path.join(agentsDir, agentId, 'sessions');
955
+ if (!fs.existsSync(sessionsDir))
956
+ continue;
957
+ try {
958
+ const sessionDirs = fs.readdirSync(sessionsDir).filter(d => {
959
+ try {
960
+ return fs.statSync(path.join(sessionsDir, d)).isDirectory();
961
+ }
962
+ catch {
963
+ return false;
964
+ }
965
+ });
966
+ for (const sessionDir of sessionDirs) {
967
+ // Look for transcript.jsonl or messages.jsonl
968
+ const transcriptPath = path.join(sessionsDir, sessionDir, 'transcript.jsonl');
969
+ const messagesPath = path.join(sessionsDir, sessionDir, 'messages.jsonl');
970
+ const filePath = fs.existsSync(transcriptPath) ? transcriptPath : fs.existsSync(messagesPath) ? messagesPath : null;
971
+ if (filePath) {
972
+ const sessionKey = `agent:${agentId}:${sessionDir}`;
973
+ sessions.push({ sessionKey, filePath, agentName });
974
+ }
975
+ }
976
+ }
977
+ catch { }
978
+ }
979
+ }
980
+ catch { }
981
+ return sessions;
982
+ }
983
+ /**
984
+ * Parse JSONL file and extract messages
985
+ */
986
+ function parseJSONLMessages(filePath, afterTimestamp) {
987
+ const messages = [];
988
+ try {
989
+ const content = fs.readFileSync(filePath, 'utf-8');
990
+ const lines = content.split('\n').filter(l => l.trim());
991
+ for (const line of lines) {
992
+ try {
993
+ const entry = JSON.parse(line);
994
+ // OpenClaw JSONL format: { role, content, timestamp, ... }
995
+ // Or: { type: "message", role, content, ... }
996
+ const role = entry.role || (entry.type === 'assistant' ? 'assistant' : entry.type === 'user' ? 'user' : null);
997
+ const content = entry.content || entry.text || entry.message || '';
998
+ const timestamp = entry.timestamp || entry.ts || entry.created_at || new Date().toISOString();
999
+ if (!role || !content)
1000
+ continue;
1001
+ // Skip system/tool messages for chat display
1002
+ if (role === 'system' || role === 'tool' || role === 'tool_call')
1003
+ continue;
1004
+ // Filter by timestamp
1005
+ if (afterTimestamp && timestamp <= afterTimestamp)
1006
+ continue;
1007
+ messages.push({
1008
+ id: entry.id || `cm_${crypto.createHash('md5').update(line).digest('hex').slice(0, 16)}`,
1009
+ role,
1010
+ content: typeof content === 'string' ? content : JSON.stringify(content),
1011
+ timestamp,
1012
+ });
1013
+ }
1014
+ catch { }
1015
+ }
1016
+ }
1017
+ catch { }
1018
+ return messages;
1019
+ }
1020
+ /**
1021
+ * Sync chat messages from OpenClaw to backend
1022
+ */
1023
+ async function syncChatMessages() {
1024
+ if (!config.runnerId || !config.runnerToken)
1025
+ return;
1026
+ const sessions = findOpenClawSessions();
1027
+ if (sessions.length === 0)
1028
+ return;
1029
+ for (const session of sessions) {
1030
+ try {
1031
+ const isFullSync = !chatSyncInitialDone;
1032
+ const afterTs = lastSyncedTimestamp[session.sessionKey];
1033
+ const messages = parseJSONLMessages(session.filePath, isFullSync ? undefined : afterTs);
1034
+ if (messages.length === 0)
1035
+ continue;
1036
+ const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/chat-sync`, {
1037
+ method: 'POST',
1038
+ headers: {
1039
+ 'Content-Type': 'application/json',
1040
+ 'Authorization': `Bearer ${config.runnerToken}`,
1041
+ },
1042
+ body: JSON.stringify({
1043
+ sessionKey: session.sessionKey,
1044
+ sessionId: session.sessionKey,
1045
+ agentName: session.agentName,
1046
+ messages,
1047
+ isFullSync,
1048
+ }),
1049
+ });
1050
+ if (response.ok) {
1051
+ // Update last synced timestamp
1052
+ const lastMsg = messages[messages.length - 1];
1053
+ if (lastMsg) {
1054
+ lastSyncedTimestamp[session.sessionKey] = lastMsg.timestamp;
1055
+ }
1056
+ if (messages.length > 0) {
1057
+ logger.info(`💬 Chat synced: ${session.sessionKey} (${messages.length} msgs, agent: ${session.agentName})`);
1058
+ }
1059
+ }
1060
+ else {
1061
+ logger.warn(`Chat sync failed for ${session.sessionKey}: ${response.status}`);
1062
+ }
1063
+ }
1064
+ catch (err) {
1065
+ logger.warn('Chat sync error', err instanceof Error ? err.message : err);
1066
+ }
1067
+ }
1068
+ chatSyncInitialDone = true;
1069
+ }
1070
+ /**
1071
+ * Poll pending chat messages from user → inject into OpenClaw
1072
+ */
1073
+ async function pollChatPending() {
1074
+ if (!config.runnerId || !config.runnerToken)
1075
+ return;
1076
+ try {
1077
+ const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/chat-pending`, {
1078
+ headers: { 'Authorization': `Bearer ${config.runnerToken}` },
1079
+ });
1080
+ if (!response.ok)
1081
+ return;
1082
+ const json = (await response.json());
1083
+ const messages = json?.data?.messages || [];
1084
+ for (const msg of messages) {
1085
+ try {
1086
+ // Inject message into OpenClaw via CLI
1087
+ // openclaw chat send --session <sessionKey> --message <content>
1088
+ const sessionKey = msg.session_key || 'agent:main:main';
1089
+ const content = msg.content || '';
1090
+ if (!content)
1091
+ continue;
1092
+ // Use openclaw CLI to inject the message
1093
+ const openclawBin = findOpenClawBinary();
1094
+ if (openclawBin) {
1095
+ const proc = spawn(openclawBin, ['chat', 'send', '--session', sessionKey, '--message', content], {
1096
+ env: getShellEnv(),
1097
+ timeout: 30000,
1098
+ });
1099
+ await new Promise((resolve) => {
1100
+ proc.on('close', () => resolve());
1101
+ proc.on('error', () => resolve());
1102
+ });
1103
+ }
1104
+ // Ack the message
1105
+ await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/chat-ack`, {
1106
+ method: 'POST',
1107
+ headers: {
1108
+ 'Content-Type': 'application/json',
1109
+ 'Authorization': `Bearer ${config.runnerToken}`,
1110
+ },
1111
+ body: JSON.stringify({ message_id: msg.id }),
1112
+ });
1113
+ }
1114
+ catch { }
1115
+ }
1116
+ }
1117
+ catch { }
1118
+ }
1119
+ /**
1120
+ * Find openclaw binary in common paths
1121
+ */
1122
+ function findOpenClawBinary() {
1123
+ const candidates = [
1124
+ '/opt/homebrew/bin/openclaw',
1125
+ '/usr/local/bin/openclaw',
1126
+ path.join(os.homedir(), '.nvm/versions/node', 'openclaw'),
1127
+ path.join(os.homedir(), '.volta/bin/openclaw'),
1128
+ ];
1129
+ // Also check PATH
1130
+ const pathDirs = (process.env.PATH || '').split(':');
1131
+ for (const dir of pathDirs) {
1132
+ candidates.push(path.join(dir, 'openclaw'));
1133
+ }
1134
+ for (const candidate of candidates) {
1135
+ try {
1136
+ if (fs.existsSync(candidate))
1137
+ return candidate;
1138
+ }
1139
+ catch { }
1140
+ }
1141
+ return null;
1142
+ }
1143
+ // ============================================================================
1144
+ // MAIN LOOP
1145
+ // ============================================================================
1146
+ async function main() {
1147
+ try {
1148
+ parseArgs();
1149
+ // 1. Register if needed
1150
+ if (config.pairingToken && !config.runnerId) {
1151
+ const registered = await registerRunner();
1152
+ if (!registered) {
1153
+ logger.error('Falha ao registrar runner, encerrando');
1154
+ process.exit(1);
1155
+ }
1156
+ }
1157
+ // 1.5. Probe file permissions (macOS protected folders — triggers dialogs ONCE)
1158
+ if (process.platform === 'darwin') {
1159
+ probeFilePermissions();
1160
+ }
1161
+ // 2. Start heartbeat loop (every 30s)
1162
+ setInterval(() => {
1163
+ sendHeartbeat().catch(() => { }); // Silent fail
1164
+ }, 30000);
1165
+ // Send first heartbeat immediately
1166
+ await sendHeartbeat();
1167
+ // 3. Start job polling loop (every 5s)
1168
+ setInterval(async () => {
1169
+ try {
1170
+ const jobs = await pullJobs();
1171
+ for (const job of jobs) {
1172
+ const result = await executeJob(job);
1173
+ await reportJobResult(job.id, result);
1174
+ }
1175
+ }
1176
+ catch (error) {
1177
+ logger.error('Erro no job loop', error);
1178
+ }
1179
+ }, 5000);
1180
+ // Poll jobs immediately
1181
+ const initialJobs = await pullJobs();
1182
+ for (const job of initialJobs) {
1183
+ const result = await executeJob(job);
1184
+ await reportJobResult(job.id, result);
1185
+ }
1186
+ // 4. Start file sync (every 60s)
1187
+ setTimeout(() => syncFiles().catch(() => { }), 5000); // first sync after 5s
1188
+ setInterval(() => syncFiles().catch(() => { }), SYNC_INTERVAL_MS);
1189
+ // 5. Terminal command polling (every 2s)
1190
+ logger.info('🖥️ Iniciando terminal polling (2s interval)...');
1191
+ setInterval(() => {
1192
+ pollTerminalCommands().catch((err) => {
1193
+ logger.error('Terminal poll error', err?.message || err);
1194
+ });
1195
+ }, 2000);
1196
+ // First poll immediately
1197
+ pollTerminalCommands().catch((err) => {
1198
+ logger.error('Terminal first poll error', err?.message || err);
1199
+ });
1200
+ // 6. UI commands polling (UI mode only, macOS) — screen capture is OFF by default
1201
+ // Screen capture only starts when user explicitly enables it via dashboard
1202
+ if (config.mode === 'ui' && process.platform === 'darwin') {
1203
+ logger.info('🖱️ Iniciando UI command polling (500ms interval)...');
1204
+ logger.info('📸 Screen capture DESATIVADO por padrão — ative pelo dashboard quando necessário');
1205
+ setInterval(() => pollUICommands().catch(() => { }), 500);
1206
+ }
1207
+ // 7. Chat sync — sync OpenClaw JSONL messages + poll pending (every 3s)
1208
+ logger.info('💬 Iniciando chat sync (3s interval)...');
1209
+ setInterval(() => {
1210
+ syncChatMessages().catch(() => { });
1211
+ pollChatPending().catch(() => { });
1212
+ }, 3000);
1213
+ // First sync after 2s
1214
+ setTimeout(() => {
1215
+ syncChatMessages().catch(() => { });
1216
+ pollChatPending().catch(() => { });
1217
+ }, 2000);
1218
+ logger.success('🎯 Runner v3 operacional (com terminal + chat)');
1219
+ // Keep process alive
1220
+ process.on('SIGINT', () => {
1221
+ logger.info('🛑 Encerrando runner...');
1222
+ process.exit(0);
1223
+ });
1224
+ process.on('SIGTERM', () => {
1225
+ logger.info('🛑 Encerrando runner (SIGTERM)...');
1226
+ process.exit(0);
1227
+ });
1228
+ }
1229
+ catch (error) {
1230
+ logger.error('Erro fatal', error);
1231
+ process.exit(1);
1232
+ }
1233
+ }
1234
+ // ============================================================================
1235
+ // START
1236
+ // ============================================================================
1237
+ main().catch((error) => {
1238
+ logger.error('Erro ao iniciar main', error);
1239
+ process.exit(1);
1240
+ });
1241
+ //# sourceMappingURL=index.js.map