echelon-dev 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Echelon Local Agent - Zero Dependencies
5
+ *
6
+ * Runs on the user's local machine. Receives pair programming commands
7
+ * from the backend via HTTP or WebSocket relay (for mobile/remote trigger).
8
+ *
9
+ * Port: 3457 (different from knoxis at 3456)
10
+ * Config: ~/.echelon/
11
+ *
12
+ * ZERO EXTERNAL DEPENDENCIES - uses only Node.js built-in modules
13
+ */
14
+
15
+ const http = require('http');
16
+ const https = require('https');
17
+ const { exec, spawn, spawnSync } = require('child_process');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const fs = require('fs');
21
+ const url = require('url');
22
+
23
+ const DEFAULT_PORT = parseInt(process.env.ECHELON_AGENT_PORT || '3457', 10);
24
+ const ECHELON_DIR = path.join(os.homedir(), '.echelon');
25
+ const CERT_DIR = process.env.ECHELON_CERT_DIR || path.join(ECHELON_DIR, 'certs');
26
+ const CERT_FILE = path.join(CERT_DIR, 'localhost.pem');
27
+ const KEY_FILE = path.join(CERT_DIR, 'localhost-key.pem');
28
+ const WORKSPACES_FILE = path.join(ECHELON_DIR, 'workspaces.json');
29
+
30
+ const TRUSTED_ORIGINS = [
31
+ 'https://qig.ai', 'https://www.qig.ai', 'https://app.qig.ai',
32
+ 'http://localhost:3000', 'http://localhost:5173',
33
+ 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'
34
+ ];
35
+ const ALLOWED_ORIGINS = (process.env.ECHELON_ALLOWED_ORIGINS || '')
36
+ .split(',').map(o => o.trim()).filter(Boolean);
37
+ const ALL_ALLOWED_ORIGINS = [...new Set([...TRUSTED_ORIGINS, ...ALLOWED_ORIGINS])];
38
+
39
+ const serverMeta = { secure: false, port: DEFAULT_PORT };
40
+ const HEADLESS_TIMEOUT_MS = parseInt(process.env.ECHELON_HEADLESS_TIMEOUT_MS || '1200000', 10);
41
+ const HEADLESS_MAX_OUTPUT = parseInt(process.env.ECHELON_HEADLESS_MAX_OUTPUT_CHARS || '50000', 10);
42
+
43
+ // ===== UTILITY =====
44
+
45
+ function ensureDir(dirPath) {
46
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
47
+ }
48
+
49
+ function safeBasename(value) {
50
+ return String(value || '').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'session';
51
+ }
52
+
53
+ function commandExists(cmd) {
54
+ const detector = os.platform() === 'win32' ? 'where' : 'which';
55
+ return spawnSync(detector, [cmd], { stdio: 'ignore' }).status === 0;
56
+ }
57
+
58
+ function buildShellCommand(command) {
59
+ if (os.platform() === 'win32') return { cmd: 'cmd.exe', args: ['/c', command] };
60
+ return { cmd: 'bash', args: ['-lc', command] };
61
+ }
62
+
63
+ function escapeForShell(value) {
64
+ return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
65
+ .replace(/\$/g, '\\$').replace(/`/g, '\\`').replace(/!/g, '\\!');
66
+ }
67
+
68
+ // ===== CERT GENERATION =====
69
+
70
+ function generateSelfSignedCert() {
71
+ ensureDir(CERT_DIR);
72
+ const result = spawnSync('openssl', [
73
+ 'req', '-x509', '-newkey', 'rsa:2048',
74
+ '-keyout', KEY_FILE, '-out', CERT_FILE,
75
+ '-days', '365', '-nodes', '-subj', '/CN=localhost',
76
+ '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'
77
+ ], { stdio: 'pipe' });
78
+ return result.status === 0;
79
+ }
80
+
81
+ // ===== CORS =====
82
+
83
+ function getCorsHeaders(origin) {
84
+ const headers = {
85
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH',
86
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
87
+ 'Access-Control-Max-Age': '86400'
88
+ };
89
+ if (origin) {
90
+ const isAllowed = ALL_ALLOWED_ORIGINS.some(a => a === '*' || origin === a || origin.endsWith(a.replace('https://', '.')));
91
+ if (isAllowed) {
92
+ headers['Access-Control-Allow-Origin'] = origin;
93
+ headers['Access-Control-Allow-Credentials'] = 'true';
94
+ } else {
95
+ headers['Access-Control-Allow-Origin'] = '*';
96
+ }
97
+ } else {
98
+ headers['Access-Control-Allow-Origin'] = '*';
99
+ }
100
+ return headers;
101
+ }
102
+
103
+ // ===== WORKSPACE MANAGEMENT =====
104
+
105
+ function loadWorkspaces() {
106
+ ensureDir(ECHELON_DIR);
107
+ try {
108
+ if (fs.existsSync(WORKSPACES_FILE)) return JSON.parse(fs.readFileSync(WORKSPACES_FILE, 'utf8'));
109
+ } catch (e) {}
110
+ return {};
111
+ }
112
+
113
+ function saveWorkspaces(ws) {
114
+ ensureDir(ECHELON_DIR);
115
+ fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(ws, null, 2));
116
+ }
117
+
118
+ function resolveWorkspacePath(nameOrPath) {
119
+ if (fs.existsSync(nameOrPath)) return path.resolve(nameOrPath);
120
+ const workspaces = loadWorkspaces();
121
+ if (workspaces[nameOrPath]) return workspaces[nameOrPath];
122
+ const lower = nameOrPath.toLowerCase();
123
+ for (const [name, wsPath] of Object.entries(workspaces)) {
124
+ if (name.toLowerCase().includes(lower)) return wsPath;
125
+ }
126
+ // Also check knoxis registry as fallback
127
+ const knoxisFile = path.join(os.homedir(), '.knoxis', 'workspaces.json');
128
+ if (fs.existsSync(knoxisFile)) {
129
+ try {
130
+ const kws = JSON.parse(fs.readFileSync(knoxisFile, 'utf8'));
131
+ if (kws[nameOrPath]) return kws[nameOrPath];
132
+ for (const [name, wsPath] of Object.entries(kws)) {
133
+ if (name.toLowerCase().includes(lower)) return wsPath;
134
+ }
135
+ } catch (e) {}
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function discoverProjects() {
141
+ const discovered = {};
142
+ const searchDirs = ['Projects', 'IdeaProjects', 'Developer', 'Code', 'dev', 'src', 'work', 'Desktop']
143
+ .map(d => path.join(os.homedir(), d));
144
+
145
+ for (const dir of searchDirs) {
146
+ if (!fs.existsSync(dir)) continue;
147
+ try {
148
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
149
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
150
+ const p = path.join(dir, entry.name);
151
+ const hasProject = ['.git', 'package.json', 'pom.xml', 'src', 'Cargo.toml', 'go.mod']
152
+ .some(f => fs.existsSync(path.join(p, f)));
153
+ if (hasProject) discovered[entry.name] = p;
154
+ }
155
+ } catch (e) {}
156
+ }
157
+ return discovered;
158
+ }
159
+
160
+ // ===== HEADLESS EXECUTION =====
161
+
162
+ function runHeadlessProcess({ workspace, command, prompt, sessionLabel }) {
163
+ return new Promise((resolve) => {
164
+ const startedAt = Date.now();
165
+ const workspaceDir = workspace || process.cwd();
166
+ const logDir = path.join(workspaceDir, '.echelon', 'headless');
167
+ ensureDir(logDir);
168
+ const logFile = path.join(logDir, `${Date.now()}-${safeBasename(sessionLabel)}.log`);
169
+ const logStream = fs.createWriteStream(logFile, { encoding: 'utf8' });
170
+
171
+ const trimmedCommand = String(command || '').trim();
172
+ const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
173
+ const looksLikePairProgram = trimmedCommand.toLowerCase().includes('echelon-pair-program') || trimmedCommand.toLowerCase().includes('knoxis-pair-program');
174
+
175
+ let proc;
176
+ let stdout = '';
177
+ let stderr = '';
178
+ let truncated = false;
179
+ let timedOut = false;
180
+
181
+ const capture = (chunk, isStdErr) => {
182
+ const text = chunk.toString();
183
+ logStream.write(text);
184
+ if (isStdErr) { stderr += text; return; }
185
+ if (stdout.length < HEADLESS_MAX_OUTPUT) { stdout += text; }
186
+ else { truncated = true; }
187
+ };
188
+
189
+ const finish = (exitCode) => {
190
+ try { logStream.end(); } catch (e) {}
191
+ resolve({
192
+ success: exitCode === 0 && !timedOut, exitCode, timedOut, truncated,
193
+ durationMs: Date.now() - startedAt, workspace: workspaceDir, logFile,
194
+ stdout: stdout.trim(), stderr: stderr.trim()
195
+ });
196
+ };
197
+
198
+ const timeout = setTimeout(() => { timedOut = true; try { proc.kill('SIGKILL'); } catch (e) {} }, HEADLESS_TIMEOUT_MS);
199
+
200
+ if (!looksLikePairProgram && hasPrompt && (!trimmedCommand || trimmedCommand.toLowerCase().startsWith('claude'))) {
201
+ if (!commandExists('claude')) { clearTimeout(timeout); finish(127); return; }
202
+ proc = spawn('claude', ['--dangerously-skip-permissions'], { cwd: workspaceDir, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
203
+ proc.stdout.on('data', chunk => capture(chunk, false));
204
+ proc.stderr.on('data', chunk => capture(chunk, true));
205
+ proc.on('close', code => { clearTimeout(timeout); finish(code == null ? 1 : code); });
206
+ proc.stdin.write(prompt);
207
+ proc.stdin.end();
208
+ return;
209
+ }
210
+
211
+ if (trimmedCommand) {
212
+ const spec = buildShellCommand(trimmedCommand);
213
+ proc = spawn(spec.cmd, spec.args, { cwd: workspaceDir, env: process.env });
214
+ proc.stdout.on('data', chunk => capture(chunk, false));
215
+ proc.stderr.on('data', chunk => capture(chunk, true));
216
+ proc.on('close', code => { clearTimeout(timeout); finish(code == null ? 1 : code); });
217
+ return;
218
+ }
219
+
220
+ clearTimeout(timeout);
221
+ finish(1);
222
+ });
223
+ }
224
+
225
+ // ===== TERMINAL OPENING =====
226
+
227
+ function openMacTerminal(workspaceDir, command) {
228
+ return new Promise((resolve, reject) => {
229
+ ensureDir(workspaceDir);
230
+ const escapedDir = workspaceDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
231
+ let finalCommand = command;
232
+ if (command.includes('claude') && !command.includes('--dangerously-skip-permissions')) {
233
+ finalCommand = command.replace(/^claude\s+/, 'claude --dangerously-skip-permissions ');
234
+ }
235
+ const escapedCommand = finalCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
236
+ const appleScript = `tell application "Terminal"\n activate\n set newTab to do script "cd \\"${escapedDir}\\""\n delay 0.5\n do script "${escapedCommand}" in newTab\nend tell`;
237
+ const tempScript = `/tmp/echelon-terminal-${Date.now()}.scpt`;
238
+ fs.writeFileSync(tempScript, appleScript);
239
+ exec(`osascript "${tempScript}"`, (error) => {
240
+ try { fs.unlinkSync(tempScript); } catch (e) {}
241
+ if (error) reject(error); else resolve();
242
+ });
243
+ });
244
+ }
245
+
246
+ function openWindowsTerminal(workspaceDir, command) {
247
+ return new Promise((resolve, reject) => {
248
+ ensureDir(workspaceDir);
249
+ exec(`start cmd /K "cd /d "${workspaceDir}" && ${command}"`, (error) => {
250
+ if (error) reject(error); else resolve();
251
+ });
252
+ });
253
+ }
254
+
255
+ function openLinuxTerminal(workspaceDir, command) {
256
+ return new Promise((resolve, reject) => {
257
+ ensureDir(workspaceDir);
258
+ exec(`gnome-terminal --working-directory="${workspaceDir}" -- bash -c "${command}; exec bash"`, (error) => {
259
+ if (error) {
260
+ exec(`xterm -e "cd '${workspaceDir}' && ${command}; bash"`, (e2) => {
261
+ if (e2) reject(e2); else resolve();
262
+ });
263
+ } else resolve();
264
+ });
265
+ });
266
+ }
267
+
268
+ function openTerminal(workspaceDir, command) {
269
+ const platform = os.platform();
270
+ if (platform === 'darwin') return openMacTerminal(workspaceDir, command);
271
+ if (platform === 'win32') return openWindowsTerminal(workspaceDir, command);
272
+ return openLinuxTerminal(workspaceDir, command);
273
+ }
274
+
275
+ // ===== HTTP HELPERS =====
276
+
277
+ function parseBody(req) {
278
+ return new Promise((resolve, reject) => {
279
+ let body = '';
280
+ req.on('data', chunk => { body += chunk.toString(); });
281
+ req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch (e) { reject(e); } });
282
+ req.on('error', reject);
283
+ });
284
+ }
285
+
286
+ function sendJSON(res, statusCode, data, origin) {
287
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', ...getCorsHeaders(origin) });
288
+ res.end(JSON.stringify(data));
289
+ }
290
+
291
+ // ===== REQUEST HANDLER =====
292
+
293
+ async function handleRequest(req, res) {
294
+ const parsedUrl = url.parse(req.url, true);
295
+ const pathname = parsedUrl.pathname;
296
+ const method = req.method;
297
+ const origin = req.headers.origin || '';
298
+
299
+ if (method === 'OPTIONS') {
300
+ res.writeHead(204, getCorsHeaders(origin));
301
+ return res.end();
302
+ }
303
+
304
+ // Health check
305
+ if (pathname === '/health' && method === 'GET') {
306
+ return sendJSON(res, 200, {
307
+ status: 'healthy', agent: 'echelon-local-agent', version: '1.0.0',
308
+ platform: os.platform(), secure: serverMeta.secure, port: serverMeta.port,
309
+ dependencies: 'none', team: ['knoxis', 'solan', 'astrahelm']
310
+ }, origin);
311
+ }
312
+
313
+ // Execute terminal command (main endpoint for remote trigger)
314
+ if (pathname === '/terminal/execute' && method === 'POST') {
315
+ try {
316
+ const body = await parseBody(req);
317
+ const { workspaceDirectory, workingDirectory, command, prompt, headless, sessionId } = body;
318
+ const workspace = workspaceDirectory || workingDirectory;
319
+
320
+ console.log(`[execute] workspace=${workspace} headless=${!!headless}`);
321
+ if (command) console.log(`[execute] command=${command.substring(0, 100)}`);
322
+
323
+ if (headless) {
324
+ const result = await runHeadlessProcess({ workspace, command, prompt, sessionLabel: sessionId || 'remote' });
325
+ return sendJSON(res, result.success ? 200 : 500, result, origin);
326
+ }
327
+
328
+ await openTerminal(workspace, command);
329
+ return sendJSON(res, 200, { success: true, message: 'Terminal opened', platform: os.platform() }, origin);
330
+ } catch (error) {
331
+ return sendJSON(res, 500, { success: false, error: error.message }, origin);
332
+ }
333
+ }
334
+
335
+ // Start multi-agent pair programming session
336
+ if (pathname === '/pair/start' && method === 'POST') {
337
+ try {
338
+ const body = await parseBody(req);
339
+ const { workspace, task, agents, provider, headless, sessionId, contextFile } = body;
340
+
341
+ if (!task) return sendJSON(res, 400, { success: false, error: 'Task description required' }, origin);
342
+
343
+ let workspaceDir = process.cwd();
344
+ if (workspace) {
345
+ const wsPath = resolveWorkspacePath(workspace);
346
+ if (wsPath) workspaceDir = wsPath;
347
+ else return sendJSON(res, 404, { success: false, error: `Workspace not found: ${workspace}` }, origin);
348
+ }
349
+
350
+ // Build the echelon-pair-program command
351
+ const scriptPath = path.join(__dirname, 'echelon-pair-program.js');
352
+ const promptB64 = Buffer.from(task).toString('base64');
353
+ let cmd = `node "${scriptPath}" --workspace "${workspaceDir}" --prompt-base64 "${promptB64}"`;
354
+ if (agents) cmd += ` --agents "${agents}"`;
355
+ if (provider) cmd += ` --ai-provider "${provider}"`;
356
+ if (contextFile) cmd += ` --context-file "${contextFile}"`;
357
+
358
+ if (headless) {
359
+ const result = await runHeadlessProcess({ workspace: workspaceDir, command: cmd, sessionLabel: sessionId || 'pair' });
360
+ return sendJSON(res, result.success ? 200 : 500, result, origin);
361
+ }
362
+
363
+ await openTerminal(workspaceDir, cmd);
364
+ return sendJSON(res, 200, {
365
+ success: true, message: 'Echelon dev team session started',
366
+ workspace: workspaceDir, task, agents: agents || 'knoxis,solan,astrahelm'
367
+ }, origin);
368
+ } catch (error) {
369
+ return sendJSON(res, 500, { success: false, error: error.message }, origin);
370
+ }
371
+ }
372
+
373
+ // Workspace endpoints
374
+ if (pathname === '/workspace/list' && method === 'GET') {
375
+ const ws = loadWorkspaces();
376
+ const list = Object.entries(ws).map(([name, p]) => ({ name, path: p, exists: fs.existsSync(p) }));
377
+ return sendJSON(res, 200, { success: true, workspaces: list }, origin);
378
+ }
379
+
380
+ if (pathname === '/workspace/get' && method === 'GET') {
381
+ const name = parsedUrl.query.name;
382
+ if (!name) return sendJSON(res, 400, { success: false, error: 'Name required' }, origin);
383
+ const wsPath = resolveWorkspacePath(name);
384
+ if (wsPath) return sendJSON(res, 200, { success: true, name, path: wsPath }, origin);
385
+ return sendJSON(res, 404, { success: false, error: `Not found: ${name}` }, origin);
386
+ }
387
+
388
+ if (pathname === '/workspace/save' && method === 'POST') {
389
+ try {
390
+ const body = await parseBody(req);
391
+ if (!body.name) return sendJSON(res, 400, { success: false, error: 'Name required' }, origin);
392
+ const resolved = path.resolve(body.path || process.cwd());
393
+ if (!fs.existsSync(resolved)) return sendJSON(res, 400, { success: false, error: `Path does not exist: ${resolved}` }, origin);
394
+ const ws = loadWorkspaces();
395
+ ws[body.name] = resolved;
396
+ saveWorkspaces(ws);
397
+ return sendJSON(res, 200, { success: true, name: body.name, path: resolved }, origin);
398
+ } catch (error) {
399
+ return sendJSON(res, 500, { success: false, error: error.message }, origin);
400
+ }
401
+ }
402
+
403
+ if (pathname === '/workspace/discover' && method === 'POST') {
404
+ const discovered = discoverProjects();
405
+ const ws = loadWorkspaces();
406
+ let added = 0;
407
+ for (const [name, p] of Object.entries(discovered)) {
408
+ if (!ws[name]) { ws[name] = p; added++; }
409
+ }
410
+ saveWorkspaces(ws);
411
+ return sendJSON(res, 200, {
412
+ success: true, discovered: Object.keys(discovered).length, added,
413
+ workspaces: Object.entries(ws).map(([name, p]) => ({ name, path: p }))
414
+ }, origin);
415
+ }
416
+
417
+ // Sessions endpoints
418
+ if (pathname === '/sessions/list' && method === 'GET') {
419
+ try {
420
+ const { listSessions } = require('./session-recorder');
421
+ const limit = parseInt(parsedUrl.query.limit || '20');
422
+ return sendJSON(res, 200, { success: true, sessions: listSessions(limit) }, origin);
423
+ } catch (error) {
424
+ return sendJSON(res, 500, { success: false, error: error.message }, origin);
425
+ }
426
+ }
427
+
428
+ if (pathname === '/sessions/get' && method === 'GET') {
429
+ try {
430
+ const { loadSession } = require('./session-recorder');
431
+ const id = parsedUrl.query.id;
432
+ if (!id) return sendJSON(res, 400, { success: false, error: 'Session ID required' }, origin);
433
+ const session = loadSession(id);
434
+ if (!session) return sendJSON(res, 404, { success: false, error: 'Session not found' }, origin);
435
+ return sendJSON(res, 200, { success: true, session }, origin);
436
+ } catch (error) {
437
+ return sendJSON(res, 500, { success: false, error: error.message }, origin);
438
+ }
439
+ }
440
+
441
+ sendJSON(res, 404, { error: 'Not found' }, origin);
442
+ }
443
+
444
+ // ===== WEBSOCKET RELAY =====
445
+ // Connects to backend so your phone/webapp can trigger terminal actions on this machine
446
+
447
+ // Read config files as fallback for env vars (needed when launched via LaunchAgent)
448
+ function loadConfigFallback() {
449
+ const files = [
450
+ path.join(os.homedir(), '.echelon', 'config.json'),
451
+ path.join(os.homedir(), '.knoxis', 'config.json')
452
+ ];
453
+ for (const f of files) {
454
+ try { if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) {}
455
+ }
456
+ return {};
457
+ }
458
+ const _config = loadConfigFallback();
459
+
460
+ const BACKEND_WS_URL = process.env.ECHELON_BACKEND_WS_URL || process.env.KNOXIS_BACKEND_WS_URL || _config.backendWsUrl || null;
461
+ const RELAY_USER_ID = process.env.ECHELON_USER_ID || process.env.KNOXIS_USER_ID || _config.userId || null;
462
+ const RECONNECT_MS = parseInt(process.env.ECHELON_RECONNECT_MS || '2000', 10);
463
+
464
+ let relaySocket = null;
465
+ let reconnectTimer = null;
466
+
467
+ function connectRelay() {
468
+ if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
469
+
470
+ const wsUrl = `${BACKEND_WS_URL}/ws/echelon-terminal?userId=${encodeURIComponent(RELAY_USER_ID)}`;
471
+ console.log(`[relay] Connecting to: ${wsUrl}`);
472
+
473
+ let WebSocketImpl;
474
+ try {
475
+ WebSocketImpl = globalThis.WebSocket || require('ws');
476
+ } catch (e) {
477
+ // Fallback: try knoxis terminal endpoint
478
+ try { WebSocketImpl = require('ws'); } catch (e2) {
479
+ console.warn('[relay] WebSocket requires Node 22+ or "ws" package. Relay disabled.');
480
+ return;
481
+ }
482
+ }
483
+
484
+ try { relaySocket = new WebSocketImpl(wsUrl); } catch (e) {
485
+ console.warn('[relay] Connection failed:', e.message);
486
+ scheduleReconnect();
487
+ return;
488
+ }
489
+
490
+ relaySocket.onopen = () => {
491
+ console.log('[relay] Connected to backend');
492
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
493
+ };
494
+
495
+ relaySocket.onmessage = async (event) => {
496
+ try {
497
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
498
+
499
+ if (msg.type === 'execute_command') {
500
+ console.log(`[relay] Command received: ${msg.requestId}`);
501
+ const workspace = msg.workingDir || process.cwd();
502
+
503
+ let result;
504
+ try {
505
+ if (msg.headless) {
506
+ result = await runHeadlessProcess({ workspace, command: msg.command, prompt: msg.prompt, sessionLabel: msg.requestId || 'relay' });
507
+ } else {
508
+ await openTerminal(workspace, msg.command);
509
+ result = { success: true, message: 'Terminal opened via relay' };
510
+ }
511
+ } catch (err) {
512
+ result = { success: false, error: err.message };
513
+ }
514
+
515
+ if (relaySocket && relaySocket.readyState === 1) {
516
+ relaySocket.send(JSON.stringify({ type: 'command_result', requestId: msg.requestId, ...result, timestamp: Date.now() }));
517
+ }
518
+ } else if (msg.type === 'connected') {
519
+ console.log(`[relay] Backend says: ${msg.message}`);
520
+ }
521
+ } catch (e) {
522
+ console.error('[relay] Error:', e.message);
523
+ }
524
+ };
525
+
526
+ relaySocket.onclose = (event) => {
527
+ const code = event && event.code ? event.code : 'unknown';
528
+ console.log(`[relay] Disconnected (code: ${code})`);
529
+ relaySocket = null;
530
+ if (code === 1006 || code === 1001) {
531
+ console.log('[relay] Unexpected close — reconnecting immediately...');
532
+ setTimeout(() => connectRelay(), 500);
533
+ } else {
534
+ scheduleReconnect();
535
+ }
536
+ };
537
+
538
+ relaySocket.onerror = (err) => {
539
+ console.error('[relay] Error:', err.message || 'connection failed');
540
+ };
541
+ }
542
+
543
+ function scheduleReconnect() {
544
+ if (reconnectTimer || !BACKEND_WS_URL || !RELAY_USER_ID) return;
545
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connectRelay(); }, RECONNECT_MS);
546
+ }
547
+
548
+ function startHeartbeat() {
549
+ if (!BACKEND_WS_URL || !RELAY_USER_ID) return;
550
+ setInterval(() => {
551
+ if (relaySocket && relaySocket.readyState === 1) {
552
+ try { relaySocket.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); } catch (e) {}
553
+ }
554
+ }, 30000);
555
+ }
556
+
557
+ // ===== LAUNCH AGENT (macOS auto-start) =====
558
+
559
+ function ensureLaunchAgent() {
560
+ if (process.platform !== 'darwin') return;
561
+ if (process.env.ECHELON_SKIP_LAUNCH_AGENT === '1') return;
562
+
563
+ try {
564
+ const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
565
+ ensureDir(agentsDir);
566
+ const plistPath = path.join(agentsDir, 'com.echelon.dev.plist');
567
+ const logPath = path.join(os.homedir(), 'Library', 'Logs', 'EchelonDev.log');
568
+
569
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
570
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
571
+ <plist version="1.0">
572
+ <dict>
573
+ <key>Label</key>
574
+ <string>com.echelon.dev</string>
575
+ <key>ProgramArguments</key>
576
+ <array>
577
+ <string>${process.execPath}</string>
578
+ <string>${__filename}</string>
579
+ </array>
580
+ <key>RunAtLoad</key>
581
+ <true/>
582
+ <key>KeepAlive</key>
583
+ <true/>
584
+ <key>StandardOutPath</key>
585
+ <string>${logPath}</string>
586
+ <key>StandardErrorPath</key>
587
+ <string>${logPath}</string>
588
+ </dict>
589
+ </plist>`;
590
+
591
+ let needsWrite = true;
592
+ if (fs.existsSync(plistPath)) {
593
+ if (fs.readFileSync(plistPath, 'utf8') === plist) needsWrite = false;
594
+ }
595
+
596
+ if (needsWrite) {
597
+ fs.writeFileSync(plistPath, plist, 'utf8');
598
+ spawnSync('launchctl', ['unload', plistPath]);
599
+ const load = spawnSync('launchctl', ['load', '-w', plistPath]);
600
+ if (load.status === 0) console.log('[startup] Installed launch agent for auto-start');
601
+ }
602
+ } catch (err) {
603
+ console.warn('[startup] Could not configure auto-start:', err.message);
604
+ }
605
+ }
606
+
607
+ // ===== SERVER CREATION =====
608
+
609
+ function createServer() {
610
+ // Try existing certs
611
+ try {
612
+ if (fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
613
+ serverMeta.secure = true;
614
+ return https.createServer({ key: fs.readFileSync(KEY_FILE), cert: fs.readFileSync(CERT_FILE) }, handleRequest);
615
+ }
616
+ } catch (err) {}
617
+
618
+ // Try generating certs
619
+ if (generateSelfSignedCert() && fs.existsSync(KEY_FILE) && fs.existsSync(CERT_FILE)) {
620
+ try {
621
+ serverMeta.secure = true;
622
+ return https.createServer({ key: fs.readFileSync(KEY_FILE), cert: fs.readFileSync(CERT_FILE) }, handleRequest);
623
+ } catch (err) {}
624
+ }
625
+
626
+ serverMeta.secure = false;
627
+ console.warn('[warn] Running in HTTP mode - HTTPS frontends may have CORS issues');
628
+ return http.createServer(handleRequest);
629
+ }
630
+
631
+ // ===== GRACEFUL SHUTDOWN =====
632
+
633
+ process.on('SIGINT', () => {
634
+ console.log('\nShutting down Echelon Local Agent...');
635
+ if (relaySocket) try { relaySocket.close(); } catch (e) {}
636
+ process.exit(0);
637
+ });
638
+ process.on('SIGTERM', () => process.exit(0));
639
+
640
+ // ===== START =====
641
+
642
+ ensureLaunchAgent();
643
+ const server = createServer();
644
+
645
+ server.on('error', (err) => {
646
+ if (err.code === 'EADDRINUSE') {
647
+ console.log(`\nEchelon Local Agent is already running on port ${serverMeta.port}.`);
648
+ console.log('No action needed — your existing agent is handling requests.\n');
649
+ process.exit(0);
650
+ return;
651
+ }
652
+ console.error('Server error:', err);
653
+ process.exit(1);
654
+ });
655
+
656
+ connectRelay();
657
+ startHeartbeat();
658
+
659
+ server.listen(serverMeta.port, () => {
660
+ const scheme = serverMeta.secure ? 'https' : 'http';
661
+ console.log('');
662
+ console.log('==============================================');
663
+ console.log(' Echelon Local Agent v1.0.0');
664
+ console.log('==============================================');
665
+ console.log(` Mode: ${serverMeta.secure ? 'HTTPS' : 'HTTP'}`);
666
+ console.log(` Listening: ${scheme}://localhost:${serverMeta.port}`);
667
+ console.log(` Team: Knoxis, Solan, Astrahelm`);
668
+ console.log('');
669
+ console.log(' Endpoints:');
670
+ console.log(' POST /terminal/execute - Execute command (remote trigger)');
671
+ console.log(' POST /pair/start - Start multi-agent session');
672
+ console.log(' GET /workspace/list - List workspaces');
673
+ console.log(' POST /workspace/discover - Auto-discover projects');
674
+ console.log(' GET /sessions/list - List recorded sessions');
675
+ console.log(' GET /health - Health check');
676
+ console.log('');
677
+ if (BACKEND_WS_URL && RELAY_USER_ID) {
678
+ console.log(` Relay: ${BACKEND_WS_URL} (user: ${RELAY_USER_ID})`);
679
+ } else {
680
+ console.log(' Relay: Not configured (set ECHELON_BACKEND_WS_URL + ECHELON_USER_ID)');
681
+ }
682
+ console.log('');
683
+ });