@leverageaiapps/locus-beta 2.0.4-beta.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.
Files changed (58) hide show
  1. package/Dockerfile +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/dist/capture.d.ts +3 -0
  5. package/dist/capture.d.ts.map +1 -0
  6. package/dist/capture.js +134 -0
  7. package/dist/capture.js.map +1 -0
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +84 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/context-extractor.d.ts +17 -0
  13. package/dist/context-extractor.d.ts.map +1 -0
  14. package/dist/context-extractor.js +118 -0
  15. package/dist/context-extractor.js.map +1 -0
  16. package/dist/debug-logger.d.ts +19 -0
  17. package/dist/debug-logger.d.ts.map +1 -0
  18. package/dist/debug-logger.js +48 -0
  19. package/dist/debug-logger.js.map +1 -0
  20. package/dist/exec.d.ts +20 -0
  21. package/dist/exec.d.ts.map +1 -0
  22. package/dist/exec.js +162 -0
  23. package/dist/exec.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +83 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/pty.d.ts +7 -0
  29. package/dist/pty.d.ts.map +1 -0
  30. package/dist/pty.js +27 -0
  31. package/dist/pty.js.map +1 -0
  32. package/dist/relay.d.ts +5 -0
  33. package/dist/relay.d.ts.map +1 -0
  34. package/dist/relay.js +131 -0
  35. package/dist/relay.js.map +1 -0
  36. package/dist/session.d.ts +6 -0
  37. package/dist/session.d.ts.map +1 -0
  38. package/dist/session.js +250 -0
  39. package/dist/session.js.map +1 -0
  40. package/dist/voice-recognition-modelscope.d.ts +50 -0
  41. package/dist/voice-recognition-modelscope.d.ts.map +1 -0
  42. package/dist/voice-recognition-modelscope.js +171 -0
  43. package/dist/voice-recognition-modelscope.js.map +1 -0
  44. package/dist/vortex-tunnel.d.ts +9 -0
  45. package/dist/vortex-tunnel.d.ts.map +1 -0
  46. package/dist/vortex-tunnel.js +993 -0
  47. package/dist/vortex-tunnel.js.map +1 -0
  48. package/dist/web-server.d.ts +6 -0
  49. package/dist/web-server.d.ts.map +1 -0
  50. package/dist/web-server.js +2096 -0
  51. package/dist/web-server.js.map +1 -0
  52. package/docs/CNAME +1 -0
  53. package/docs/index.html +492 -0
  54. package/docs/install.sh +329 -0
  55. package/install.sh +329 -0
  56. package/package.json +69 -0
  57. package/scripts/postinstall.js +66 -0
  58. package/scripts/verify-install.js +128 -0
@@ -0,0 +1,993 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.startTunnel = startTunnel;
40
+ exports.stopTunnel = stopTunnel;
41
+ exports.getTunnelUrl = getTunnelUrl;
42
+ exports.isTunnelRunning = isTunnelRunning;
43
+ const axios_1 = __importDefault(require("axios"));
44
+ const ws_1 = __importDefault(require("ws"));
45
+ const http = __importStar(require("http"));
46
+ let tunnelWs = null;
47
+ let tunnelUrl = null;
48
+ let sessionId = null;
49
+ let gatewayUrl = null;
50
+ let localPort = null;
51
+ let heartbeatInterval = null;
52
+ let isReconnecting = false;
53
+ let reconnectAttempts = 0;
54
+ // Heartbeat configuration
55
+ const HEARTBEAT_INTERVAL_MS = 20 * 1000; // Send heartbeat every 20 seconds
56
+ const MAX_RECONNECT_ATTEMPTS = 100; // Max reconnect attempts (essentially unlimited for home use)
57
+ const MAX_RECONNECT_DELAY_MS = 60 * 1000; // Max 60 seconds between reconnect attempts
58
+ const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024; // 64MB max message size for large file transfer
59
+ // Map to store pending HTTP requests
60
+ const pendingRequests = new Map();
61
+ // Current running task
62
+ let currentTask = null;
63
+ // Pending task exec responses (for command execution within tasks)
64
+ const pendingTaskExecs = new Map();
65
+ // Single shared WebSocket connection to local server
66
+ let sharedLocalWs = null;
67
+ let sharedLocalWsReady = false;
68
+ /**
69
+ * Start Vortex tunnel
70
+ * Creates a session with the gateway and establishes WebSocket connection
71
+ */
72
+ function startTunnel(port = 4020, gateway) {
73
+ return new Promise(async (resolve, reject) => {
74
+ try {
75
+ localPort = port;
76
+ gatewayUrl = gateway || process.env.VORTEX_GATEWAY || 'https://vortex.futuretech.social';
77
+ console.log(` [Vortex] Connecting to gateway: ${gatewayUrl}`);
78
+ // Step 1: Create session on gateway (v2 API)
79
+ const response = await axios_1.default.post(`${gatewayUrl}/api/session`, {
80
+ mode: 'http_proxy',
81
+ client_type: `LeverageAI-Agent/${process.platform}`
82
+ });
83
+ const { session_id, url, tunnel_url: sessionUrl, ws_url, expires_in } = response.data;
84
+ sessionId = session_id;
85
+ tunnelUrl = url;
86
+ console.log(` [Vortex] Session created: ${session_id.substring(0, 8)}...`);
87
+ console.log(` [Vortex] Session expires in: ${expires_in}s`);
88
+ // Step 2: Register tunnel with gateway
89
+ await axios_1.default.post(`${gatewayUrl}/api/tunnel/register`, {
90
+ session_id: session_id,
91
+ });
92
+ // Step 3: Connect WebSocket to gateway
93
+ const wsUrl = gatewayUrl.replace('https://', 'wss://').replace('http://', 'ws://') + `/tunnel/${session_id}`;
94
+ console.log(` [Vortex] Establishing WebSocket tunnel...`);
95
+ tunnelWs = new ws_1.default(wsUrl, {
96
+ maxPayload: MAX_PAYLOAD_SIZE
97
+ });
98
+ tunnelWs.on('open', () => {
99
+ console.log(` [Vortex] Tunnel connected`);
100
+ startHeartbeat();
101
+ // Create shared local WebSocket connection
102
+ createSharedLocalConnection();
103
+ resolve(tunnelUrl);
104
+ });
105
+ tunnelWs.on('message', (data) => {
106
+ try {
107
+ const msg = JSON.parse(data.toString());
108
+ handleGatewayMessage(msg);
109
+ }
110
+ catch (e) {
111
+ // Not JSON, might be binary data
112
+ console.error('[Vortex] Invalid message from gateway:', e);
113
+ }
114
+ });
115
+ tunnelWs.on('close', () => {
116
+ console.log(' [Vortex] Tunnel disconnected');
117
+ stopHeartbeat();
118
+ tunnelWs = null;
119
+ // Auto reconnect instead of cleanup
120
+ if (!isReconnecting && sessionId && gatewayUrl) {
121
+ attemptTunnelReconnect();
122
+ }
123
+ });
124
+ tunnelWs.on('error', (err) => {
125
+ console.error(' [Vortex] Tunnel error:', err.message);
126
+ reject(err);
127
+ });
128
+ }
129
+ catch (error) {
130
+ console.error(' [Vortex] Failed to start tunnel:', error.message);
131
+ if (error.response) {
132
+ console.error(' [Vortex] Response:', error.response.data);
133
+ }
134
+ reject(error);
135
+ }
136
+ });
137
+ }
138
+ /**
139
+ * Create shared WebSocket connection to local server
140
+ * This connection is reused for all browser clients
141
+ */
142
+ function createSharedLocalConnection() {
143
+ if (!localPort)
144
+ return;
145
+ if (sharedLocalWs && sharedLocalWs.readyState === ws_1.default.OPEN) {
146
+ return; // Already connected
147
+ }
148
+ console.log(` [Vortex] Creating shared local WebSocket connection to port ${localPort}...`);
149
+ sharedLocalWs = new ws_1.default(`ws://localhost:${localPort}/ws`, {
150
+ maxPayload: MAX_PAYLOAD_SIZE
151
+ });
152
+ sharedLocalWs.on('open', () => {
153
+ console.log(` [Vortex] Shared local WebSocket connected`);
154
+ sharedLocalWsReady = true;
155
+ });
156
+ sharedLocalWs.on('message', (data) => {
157
+ const dataStr = Buffer.isBuffer(data) ? data.toString() : data.toString();
158
+ // Check if this is task-related output (contains markers)
159
+ if (pendingTaskExecs.size > 0 && dataStr.includes('<<TASK_EXEC_')) {
160
+ processTaskOutput(dataStr);
161
+ // Don't forward task execution output to browsers
162
+ return;
163
+ }
164
+ // Check if this is raw terminal output that might be part of task execution
165
+ try {
166
+ const msg = JSON.parse(dataStr);
167
+ if (msg.type === 'output' && pendingTaskExecs.size > 0) {
168
+ // Could be task output - check for markers
169
+ if (msg.data && (msg.data.includes('<<TASK_EXEC_') || currentTask)) {
170
+ processTaskOutput(msg.data);
171
+ return;
172
+ }
173
+ }
174
+ // Handle AI chat task responses from local server
175
+ if (msg.type === 'ai_chat_response' && currentTask && currentTask.type === 'ai_chat') {
176
+ sendToGateway({
177
+ type: 'task_output',
178
+ task_id: currentTask.id,
179
+ chunk: msg.content || ''
180
+ });
181
+ return;
182
+ }
183
+ if (msg.type === 'ai_chat_complete' && currentTask && currentTask.type === 'ai_chat') {
184
+ sendToGateway({
185
+ type: 'task_complete',
186
+ task_id: currentTask.id,
187
+ success: true,
188
+ output: msg.full_response || currentTask.accumulatedOutput
189
+ });
190
+ currentTask = null;
191
+ return;
192
+ }
193
+ if (msg.type === 'ai_chat_error' && currentTask && currentTask.type === 'ai_chat') {
194
+ sendToGateway({
195
+ type: 'task_error',
196
+ task_id: currentTask.id,
197
+ error: msg.error || 'AI chat failed'
198
+ });
199
+ currentTask = null;
200
+ return;
201
+ }
202
+ }
203
+ catch {
204
+ // Not JSON, might be raw output
205
+ if (pendingTaskExecs.size > 0 && dataStr.includes('<<TASK_EXEC_')) {
206
+ processTaskOutput(dataStr);
207
+ return;
208
+ }
209
+ }
210
+ // Forward data from local server to all browsers through gateway (legacy mode)
211
+ if (tunnelWs && tunnelWs.readyState === ws_1.default.OPEN) {
212
+ tunnelWs.send(dataStr);
213
+ }
214
+ });
215
+ sharedLocalWs.on('close', () => {
216
+ console.log(` [Vortex] Shared local WebSocket closed`);
217
+ sharedLocalWsReady = false;
218
+ sharedLocalWs = null;
219
+ // Exponential backoff reconnect
220
+ let retryCount = 0;
221
+ const maxRetries = 10;
222
+ const attemptReconnect = () => {
223
+ if (retryCount >= maxRetries) {
224
+ console.log(` [Vortex] Max local reconnect attempts reached`);
225
+ return;
226
+ }
227
+ retryCount++;
228
+ const delay = Math.min(Math.pow(2, retryCount) * 1000, 30000);
229
+ console.log(` [Vortex] Local reconnect attempt ${retryCount}/${maxRetries} in ${delay / 1000}s...`);
230
+ setTimeout(() => {
231
+ if (tunnelWs && tunnelWs.readyState === ws_1.default.OPEN) {
232
+ createSharedLocalConnection();
233
+ // Check if reconnect succeeded after a short delay
234
+ setTimeout(() => {
235
+ if (!sharedLocalWsReady && retryCount < maxRetries) {
236
+ attemptReconnect();
237
+ }
238
+ }, 2000);
239
+ }
240
+ }, delay);
241
+ };
242
+ attemptReconnect();
243
+ });
244
+ sharedLocalWs.on('error', (err) => {
245
+ console.error(` [Vortex] Shared local WebSocket error:`, err.message);
246
+ sharedLocalWsReady = false;
247
+ });
248
+ }
249
+ // Map of WebSocket connections (for backward compatibility)
250
+ const websocketConnections = new Map();
251
+ /**
252
+ * Handle messages from the gateway
253
+ */
254
+ function handleGatewayMessage(msg) {
255
+ switch (msg.type) {
256
+ case 'http_request':
257
+ // Gateway forwarded an HTTP request from browser
258
+ handleHttpRequest(msg);
259
+ break;
260
+ case 'websocket_connect':
261
+ // New WebSocket connection from browser (legacy protocol)
262
+ handleWebSocketConnect(msg.conn_id);
263
+ break;
264
+ case 'websocket_message':
265
+ // WebSocket message from browser (legacy protocol)
266
+ handleWebSocketMessage(msg.conn_id, msg.data);
267
+ break;
268
+ case 'websocket_binary':
269
+ // WebSocket binary data from browser (legacy protocol)
270
+ handleWebSocketBinary(msg.conn_id, msg.data);
271
+ break;
272
+ case 'websocket_disconnect':
273
+ // WebSocket disconnection from browser (legacy protocol)
274
+ handleWebSocketDisconnect(msg.conn_id);
275
+ break;
276
+ case 'client_connected':
277
+ // New browser client connected (v2 protocol)
278
+ console.log(` [Vortex] Client connected`);
279
+ // Ensure shared local connection is ready
280
+ if (!sharedLocalWsReady) {
281
+ createSharedLocalConnection();
282
+ }
283
+ else {
284
+ // Send Ctrl+C to reset shell state in case previous session left it in a bad state
285
+ // (e.g., stuck in heredoc mode, waiting for input, etc.)
286
+ if (sharedLocalWs && sharedLocalWs.readyState === ws_1.default.OPEN) {
287
+ console.log(` [Vortex] Sending Ctrl+C to reset shell state`);
288
+ sharedLocalWs.send(JSON.stringify({ type: 'input', data: '\x03' }));
289
+ }
290
+ }
291
+ break;
292
+ case 'client_disconnected':
293
+ console.log(` [Vortex] Client disconnected`);
294
+ break;
295
+ case 'pong':
296
+ // Heartbeat response received, connection is alive
297
+ break;
298
+ // ============== Task Queue Protocol (v3) ==============
299
+ case 'task_execute':
300
+ // Gateway asking us to execute a task
301
+ handleTaskExecute(msg);
302
+ break;
303
+ case 'task_cancel':
304
+ // Gateway asking us to cancel a task
305
+ handleTaskCancel(msg.task_id);
306
+ break;
307
+ default:
308
+ // Forward any other message to local server (v2 protocol - direct relay)
309
+ // This handles input, resize, ping, etc. from browser
310
+ if (sharedLocalWs && sharedLocalWs.readyState === ws_1.default.OPEN) {
311
+ sharedLocalWs.send(JSON.stringify(msg));
312
+ }
313
+ break;
314
+ }
315
+ }
316
+ // ============================================================================
317
+ // Task Execution (v3 Protocol)
318
+ // ============================================================================
319
+ /**
320
+ * Handle task execution request from Gateway
321
+ */
322
+ async function handleTaskExecute(msg) {
323
+ const { task_id, task_type, payload } = msg;
324
+ if (currentTask && !currentTask.cancelled) {
325
+ // Already executing a task, shouldn't happen if gateway queues properly
326
+ console.error(` [Task] Error: Already executing task ${currentTask.id}`);
327
+ sendToGateway({
328
+ type: 'task_error',
329
+ task_id,
330
+ error: 'Already executing another task'
331
+ });
332
+ return;
333
+ }
334
+ console.log(` [Task] Executing task ${task_id} (${task_type})`);
335
+ currentTask = {
336
+ id: task_id,
337
+ type: task_type,
338
+ payload,
339
+ startTime: Date.now(),
340
+ accumulatedOutput: '',
341
+ cancelled: false
342
+ };
343
+ try {
344
+ switch (task_type) {
345
+ case 'exec':
346
+ await executeExecTask(task_id, payload);
347
+ break;
348
+ case 'ai_chat':
349
+ await executeAiChatTask(task_id, payload);
350
+ break;
351
+ case 'file_read':
352
+ await executeFileReadTask(task_id, payload);
353
+ break;
354
+ case 'file_write':
355
+ await executeFileWriteTask(task_id, payload);
356
+ break;
357
+ default:
358
+ throw new Error(`Unknown task type: ${task_type}`);
359
+ }
360
+ }
361
+ catch (error) {
362
+ console.error(` [Task] Error executing task ${task_id}:`, error.message);
363
+ if (!currentTask?.cancelled) {
364
+ sendToGateway({
365
+ type: 'task_error',
366
+ task_id,
367
+ error: error.message
368
+ });
369
+ }
370
+ }
371
+ finally {
372
+ currentTask = null;
373
+ }
374
+ }
375
+ /**
376
+ * Handle task cancellation request
377
+ */
378
+ function handleTaskCancel(taskId) {
379
+ if (currentTask && currentTask.id === taskId) {
380
+ console.log(` [Task] Cancelling task ${taskId}`);
381
+ currentTask.cancelled = true;
382
+ // Cancel any pending exec for this task
383
+ for (const [execId, pending] of pendingTaskExecs.entries()) {
384
+ if (pending.taskId === taskId) {
385
+ pending.reject(new Error('Task cancelled'));
386
+ pendingTaskExecs.delete(execId);
387
+ }
388
+ }
389
+ // Send Ctrl+C to interrupt any running command
390
+ if (sharedLocalWs && sharedLocalWs.readyState === ws_1.default.OPEN) {
391
+ sharedLocalWs.send(JSON.stringify({ type: 'input', data: '\x03' }));
392
+ }
393
+ }
394
+ }
395
+ /**
396
+ * Execute a shell command task
397
+ */
398
+ async function executeExecTask(taskId, payload) {
399
+ const { command } = payload;
400
+ if (!command) {
401
+ throw new Error('No command specified');
402
+ }
403
+ if (!sharedLocalWs || sharedLocalWs.readyState !== ws_1.default.OPEN) {
404
+ throw new Error('Local WebSocket not connected');
405
+ }
406
+ console.log(` [Task] Executing command: ${command.substring(0, 50)}...`);
407
+ const result = await executeCommandWithOutput(taskId, command);
408
+ console.log(` [Task] executeCommandWithOutput returned for ${taskId}, exitCode=${result.exitCode}`);
409
+ if (currentTask?.cancelled) {
410
+ console.log(` [Task] Task ${taskId} was cancelled, not sending complete`);
411
+ return;
412
+ }
413
+ console.log(` [Task] Sending task_complete for ${taskId}`);
414
+ sendToGateway({
415
+ type: 'task_complete',
416
+ task_id: taskId,
417
+ success: result.exitCode === 0,
418
+ output: result.output,
419
+ exit_code: result.exitCode
420
+ });
421
+ console.log(` [Task] task_complete sent for ${taskId}`);
422
+ }
423
+ /**
424
+ * Execute command and collect output with streaming
425
+ */
426
+ function executeCommandWithOutput(taskId, command) {
427
+ return new Promise((resolve, reject) => {
428
+ const execId = `exec_${taskId}_${Date.now()}`;
429
+ const startMarker = `<<TASK_EXEC_START_${execId}>>`;
430
+ const endMarkerPrefix = `<<TASK_EXEC_END_${execId}>>_`;
431
+ pendingTaskExecs.set(execId, {
432
+ taskId,
433
+ resolve,
434
+ reject,
435
+ output: ''
436
+ });
437
+ // Set timeout
438
+ const timeout = setTimeout(() => {
439
+ const pending = pendingTaskExecs.get(execId);
440
+ if (pending) {
441
+ pendingTaskExecs.delete(execId);
442
+ reject(new Error('Command timed out'));
443
+ }
444
+ }, 300000); // 5 minute timeout
445
+ // Store timeout for cleanup
446
+ pendingTaskExecs.get(execId).timeout = timeout;
447
+ // Send wrapped command
448
+ const wrappedCommand = `echo '${startMarker}'; ${command}; echo '${endMarkerPrefix}'$?\n`;
449
+ if (sharedLocalWs && sharedLocalWs.readyState === ws_1.default.OPEN) {
450
+ sharedLocalWs.send(JSON.stringify({ type: 'input', data: wrappedCommand }));
451
+ }
452
+ else {
453
+ pendingTaskExecs.delete(execId);
454
+ clearTimeout(timeout);
455
+ reject(new Error('WebSocket not connected'));
456
+ }
457
+ });
458
+ }
459
+ /**
460
+ * Execute AI chat task (forward to local server for AI processing)
461
+ */
462
+ async function executeAiChatTask(taskId, payload) {
463
+ // AI chat tasks are forwarded to the local web server which handles Claude API
464
+ // The local server will send streaming responses back
465
+ if (!sharedLocalWs || sharedLocalWs.readyState !== ws_1.default.OPEN) {
466
+ throw new Error('Local WebSocket not connected');
467
+ }
468
+ // Send AI chat request to local server
469
+ sharedLocalWs.send(JSON.stringify({
470
+ type: 'ai_chat_task',
471
+ task_id: taskId,
472
+ message: payload.message,
473
+ context: payload.context
474
+ }));
475
+ // The response will come through the normal WebSocket message handler
476
+ // which will detect ai_chat_response and forward appropriately
477
+ }
478
+ /**
479
+ * Execute file read task
480
+ * Supports both text files (utf-8) and binary files (base64)
481
+ */
482
+ async function executeFileReadTask(taskId, payload) {
483
+ const { file_path } = payload;
484
+ if (!file_path) {
485
+ throw new Error('No file path specified');
486
+ }
487
+ const fs = require('fs').promises;
488
+ const path = require('path');
489
+ try {
490
+ // Check if file exists and get stats
491
+ const stats = await fs.stat(file_path);
492
+ const fileName = path.basename(file_path);
493
+ const ext = path.extname(file_path).toLowerCase();
494
+ // Binary file extensions
495
+ const binaryExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp',
496
+ '.pdf', '.zip', '.tar', '.gz', '.rar', '.7z',
497
+ '.mp3', '.mp4', '.wav', '.avi', '.mov', '.mkv',
498
+ '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
499
+ '.exe', '.dll', '.so', '.dylib', '.bin'];
500
+ const isBinary = binaryExtensions.includes(ext);
501
+ if (isBinary) {
502
+ // Read as binary and encode to base64
503
+ const buffer = await fs.readFile(file_path);
504
+ const base64Content = buffer.toString('base64');
505
+ // Determine MIME type
506
+ const mimeTypes = {
507
+ '.png': 'image/png',
508
+ '.jpg': 'image/jpeg',
509
+ '.jpeg': 'image/jpeg',
510
+ '.gif': 'image/gif',
511
+ '.bmp': 'image/bmp',
512
+ '.webp': 'image/webp',
513
+ '.pdf': 'application/pdf',
514
+ '.zip': 'application/zip',
515
+ '.mp3': 'audio/mpeg',
516
+ '.mp4': 'video/mp4',
517
+ };
518
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
519
+ sendToGateway({
520
+ type: 'task_complete',
521
+ task_id: taskId,
522
+ success: true,
523
+ file_name: fileName,
524
+ file_path: file_path,
525
+ file_size: stats.size,
526
+ file_type: mimeType,
527
+ content_base64: base64Content,
528
+ is_binary: true
529
+ });
530
+ }
531
+ else {
532
+ // Read as text
533
+ const content = await fs.readFile(file_path, 'utf-8');
534
+ sendToGateway({
535
+ type: 'task_complete',
536
+ task_id: taskId,
537
+ success: true,
538
+ file_name: fileName,
539
+ file_path: file_path,
540
+ file_size: stats.size,
541
+ file_content: content,
542
+ is_binary: false
543
+ });
544
+ }
545
+ }
546
+ catch (error) {
547
+ throw new Error(`Failed to read file: ${error.message}`);
548
+ }
549
+ }
550
+ /**
551
+ * Execute file write task
552
+ */
553
+ async function executeFileWriteTask(taskId, payload) {
554
+ const { file_path, file_content } = payload;
555
+ if (!file_path || file_content === undefined) {
556
+ throw new Error('File path and content required');
557
+ }
558
+ const fs = require('fs').promises;
559
+ try {
560
+ await fs.writeFile(file_path, file_content, 'utf-8');
561
+ sendToGateway({
562
+ type: 'task_complete',
563
+ task_id: taskId,
564
+ success: true,
565
+ output: `File written: ${file_path}`
566
+ });
567
+ }
568
+ catch (error) {
569
+ throw new Error(`Failed to write file: ${error.message}`);
570
+ }
571
+ }
572
+ /**
573
+ * Process output from local WebSocket for task execution
574
+ * Called when we receive output that might contain task markers
575
+ */
576
+ function processTaskOutput(data) {
577
+ console.log(` [Task] processTaskOutput called, data length: ${data.length}, pendingCount: ${pendingTaskExecs.size}`);
578
+ console.log(` [Task] Data preview: ${data.substring(0, 200).replace(/\n/g, '\\n')}`);
579
+ // Check for task exec markers
580
+ for (const [execId, pending] of pendingTaskExecs.entries()) {
581
+ console.log(` [Task] Checking execId: ${execId}`);
582
+ const startMarker = `<<TASK_EXEC_START_${execId}>>`;
583
+ const endMarkerPrefix = `<<TASK_EXEC_END_${execId}>>_`;
584
+ // Track if we've seen start marker
585
+ const hasSeenStart = pending.hasSeenStart || false;
586
+ // Check if this data contains start marker
587
+ if (!hasSeenStart && data.includes(startMarker)) {
588
+ // Extract content after start marker
589
+ const startIdx = data.indexOf(startMarker);
590
+ data = data.substring(startIdx + startMarker.length);
591
+ // Skip leading newlines
592
+ while (data.startsWith('\n') || data.startsWith('\r')) {
593
+ data = data.substring(1);
594
+ }
595
+ pending.hasSeenStart = true;
596
+ }
597
+ else if (!hasSeenStart) {
598
+ // Haven't seen start marker yet, ignore this chunk (it's command echo)
599
+ continue;
600
+ }
601
+ // Check for end marker first (might be in same chunk as start)
602
+ const endIdx = data.indexOf(endMarkerPrefix);
603
+ if (endIdx !== -1) {
604
+ console.log(` [Task] Found end marker for ${execId}, endIdx=${endIdx}`);
605
+ // Only take content before end marker
606
+ const cleanOutput = data.substring(0, endIdx);
607
+ pending.output += cleanOutput;
608
+ const afterEnd = data.substring(endIdx + endMarkerPrefix.length);
609
+ const exitCodeMatch = afterEnd.match(/^(\d+)/);
610
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 0;
611
+ console.log(` [Task] Exit code: ${exitCode}, output length: ${pending.output.length}`);
612
+ // Clear timeout
613
+ if (pending.timeout) {
614
+ clearTimeout(pending.timeout);
615
+ }
616
+ // Send final clean output to gateway
617
+ const finalOutput = pending.output.trim();
618
+ if (currentTask && !currentTask.cancelled) {
619
+ console.log(` [Task] Sending final output to gateway`);
620
+ sendToGateway({
621
+ type: 'task_output',
622
+ task_id: pending.taskId,
623
+ chunk: finalOutput,
624
+ is_final: true
625
+ });
626
+ }
627
+ // Resolve promise
628
+ console.log(` [Task] Resolving promise for ${execId}`);
629
+ pending.resolve({
630
+ output: finalOutput,
631
+ exitCode
632
+ });
633
+ pendingTaskExecs.delete(execId);
634
+ return;
635
+ }
636
+ // No end marker yet, append clean data (for streaming)
637
+ if (currentTask && !currentTask.cancelled && data.length > 0) {
638
+ pending.output += data;
639
+ // Send streaming chunk to gateway (only clean output)
640
+ sendToGateway({
641
+ type: 'task_output',
642
+ task_id: pending.taskId,
643
+ chunk: data
644
+ });
645
+ }
646
+ }
647
+ }
648
+ /**
649
+ * Handle HTTP request forwarded from gateway
650
+ * Forward it to the local server and send response back
651
+ */
652
+ async function handleHttpRequest(msg) {
653
+ const { request_id, method, path, headers, body } = msg;
654
+ if (!localPort) {
655
+ sendToGateway({
656
+ type: 'http_response',
657
+ request_id,
658
+ status: 503,
659
+ headers: { 'Content-Type': 'text/plain' },
660
+ body: 'Local server not configured'
661
+ });
662
+ return;
663
+ }
664
+ try {
665
+ // Remove host header to avoid conflicts
666
+ const requestHeaders = { ...headers };
667
+ delete requestHeaders.host;
668
+ delete requestHeaders.Host;
669
+ // Make request to local server
670
+ const options = {
671
+ hostname: 'localhost',
672
+ port: localPort,
673
+ path: path,
674
+ method: method,
675
+ headers: requestHeaders
676
+ };
677
+ const localReq = http.request(options, (localRes) => {
678
+ const chunks = [];
679
+ localRes.on('data', (chunk) => {
680
+ chunks.push(chunk);
681
+ });
682
+ localRes.on('end', () => {
683
+ const responseBody = Buffer.concat(chunks);
684
+ // Send response back to gateway
685
+ sendToGateway({
686
+ type: 'http_response',
687
+ request_id,
688
+ status: localRes.statusCode || 200,
689
+ headers: localRes.headers,
690
+ body: responseBody.toString('base64'),
691
+ encoding: 'base64'
692
+ });
693
+ });
694
+ });
695
+ localReq.on('error', (err) => {
696
+ console.error('[Vortex] Local request error:', err.message);
697
+ sendToGateway({
698
+ type: 'http_response',
699
+ request_id,
700
+ status: 502,
701
+ headers: { 'Content-Type': 'text/plain' },
702
+ body: `Failed to connect to local server: ${err.message}`
703
+ });
704
+ });
705
+ // Send request body if present
706
+ if (body) {
707
+ // Decode base64 body if encoded
708
+ const bodyBuffer = msg.encoding === 'base64' ? Buffer.from(body, 'base64') : body;
709
+ localReq.write(bodyBuffer);
710
+ }
711
+ localReq.end();
712
+ }
713
+ catch (error) {
714
+ sendToGateway({
715
+ type: 'http_response',
716
+ request_id,
717
+ status: 500,
718
+ headers: { 'Content-Type': 'text/plain' },
719
+ body: `Internal error: ${error.message}`
720
+ });
721
+ }
722
+ }
723
+ /**
724
+ * Handle new WebSocket connection (legacy protocol)
725
+ */
726
+ function handleWebSocketConnect(connId) {
727
+ if (!localPort) {
728
+ sendToGateway({
729
+ type: 'websocket_close',
730
+ conn_id: connId
731
+ });
732
+ return;
733
+ }
734
+ // Create WebSocket connection to local server
735
+ const localWs = new ws_1.default(`ws://localhost:${localPort}/ws`, {
736
+ maxPayload: MAX_PAYLOAD_SIZE
737
+ });
738
+ localWs.on('open', () => {
739
+ console.log(`[Vortex] Local WebSocket connected for ${connId.substring(0, 8)}...`);
740
+ websocketConnections.set(connId, localWs);
741
+ });
742
+ localWs.on('message', (data) => {
743
+ // Forward data from local server to browser through gateway
744
+ // Local server sends JSON text, we need to preserve it
745
+ if (Buffer.isBuffer(data)) {
746
+ sendToGateway({
747
+ type: 'websocket_data',
748
+ conn_id: connId,
749
+ data: data.toString('base64'),
750
+ binary: true
751
+ });
752
+ }
753
+ else {
754
+ // data is already a string (JSON), send it as-is
755
+ sendToGateway({
756
+ type: 'websocket_data',
757
+ conn_id: connId,
758
+ data: data.toString(),
759
+ binary: false
760
+ });
761
+ }
762
+ });
763
+ localWs.on('close', () => {
764
+ console.log(`[Vortex] Local WebSocket closed for ${connId.substring(0, 8)}...`);
765
+ websocketConnections.delete(connId);
766
+ sendToGateway({
767
+ type: 'websocket_close',
768
+ conn_id: connId
769
+ });
770
+ });
771
+ localWs.on('error', (err) => {
772
+ console.error(`[Vortex] Local WebSocket error for ${connId.substring(0, 8)}...`, err.message);
773
+ websocketConnections.delete(connId);
774
+ sendToGateway({
775
+ type: 'websocket_close',
776
+ conn_id: connId
777
+ });
778
+ });
779
+ }
780
+ /**
781
+ * Handle WebSocket message from browser (legacy protocol)
782
+ */
783
+ function handleWebSocketMessage(connId, data) {
784
+ const localWs = websocketConnections.get(connId);
785
+ if (localWs && localWs.readyState === ws_1.default.OPEN) {
786
+ localWs.send(data);
787
+ }
788
+ }
789
+ /**
790
+ * Handle WebSocket binary data from browser (legacy protocol)
791
+ */
792
+ function handleWebSocketBinary(connId, data) {
793
+ const localWs = websocketConnections.get(connId);
794
+ if (localWs && localWs.readyState === ws_1.default.OPEN) {
795
+ // Decode base64 and send as binary
796
+ const buffer = Buffer.from(data, 'base64');
797
+ localWs.send(buffer);
798
+ }
799
+ }
800
+ /**
801
+ * Handle WebSocket disconnection from browser (legacy protocol)
802
+ */
803
+ function handleWebSocketDisconnect(connId) {
804
+ const localWs = websocketConnections.get(connId);
805
+ if (localWs) {
806
+ localWs.close();
807
+ websocketConnections.delete(connId);
808
+ console.log(`[Vortex] WebSocket disconnected for ${connId.substring(0, 8)}...`);
809
+ }
810
+ }
811
+ /**
812
+ * Send message to gateway
813
+ */
814
+ function sendToGateway(msg) {
815
+ if (tunnelWs && tunnelWs.readyState === ws_1.default.OPEN) {
816
+ tunnelWs.send(JSON.stringify(msg));
817
+ }
818
+ }
819
+ /**
820
+ * Start heartbeat to keep connection alive
821
+ * Sends ping every 30 seconds to prevent 5-minute timeout
822
+ */
823
+ function startHeartbeat() {
824
+ stopHeartbeat(); // Clear any existing heartbeat
825
+ heartbeatInterval = setInterval(() => {
826
+ if (tunnelWs && tunnelWs.readyState === ws_1.default.OPEN) {
827
+ sendToGateway({ type: 'ping', timestamp: Date.now() });
828
+ }
829
+ }, HEARTBEAT_INTERVAL_MS);
830
+ console.log(` [Vortex] Heartbeat started (interval: ${HEARTBEAT_INTERVAL_MS / 1000}s)`);
831
+ }
832
+ /**
833
+ * Stop heartbeat timer
834
+ */
835
+ function stopHeartbeat() {
836
+ if (heartbeatInterval) {
837
+ clearInterval(heartbeatInterval);
838
+ heartbeatInterval = null;
839
+ }
840
+ }
841
+ /**
842
+ * Attempt to reconnect tunnel with exponential backoff
843
+ * Keeps the same session ID so iOS doesn't need to re-scan QR code
844
+ */
845
+ async function attemptTunnelReconnect() {
846
+ if (isReconnecting) {
847
+ return;
848
+ }
849
+ isReconnecting = true;
850
+ while (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
851
+ reconnectAttempts++;
852
+ const delay = Math.min(Math.pow(2, reconnectAttempts) * 1000, MAX_RECONNECT_DELAY_MS);
853
+ console.log(` [Vortex] Tunnel reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay / 1000}s...`);
854
+ await new Promise(resolve => setTimeout(resolve, delay));
855
+ try {
856
+ await reconnectTunnel();
857
+ console.log(` [Vortex] Tunnel reconnected successfully!`);
858
+ reconnectAttempts = 0;
859
+ isReconnecting = false;
860
+ return;
861
+ }
862
+ catch (error) {
863
+ console.error(` [Vortex] Reconnect failed: ${error.message}`);
864
+ }
865
+ }
866
+ console.log(` [Vortex] Max reconnect attempts reached, giving up`);
867
+ isReconnecting = false;
868
+ cleanup();
869
+ }
870
+ /**
871
+ * Reconnect tunnel using existing session ID
872
+ */
873
+ async function reconnectTunnel() {
874
+ if (!sessionId || !gatewayUrl || !localPort) {
875
+ throw new Error('Missing session info for reconnect');
876
+ }
877
+ // Re-register tunnel with gateway (session should still exist)
878
+ try {
879
+ await axios_1.default.post(`${gatewayUrl}/api/tunnel/register`, {
880
+ session_id: sessionId,
881
+ });
882
+ }
883
+ catch (e) {
884
+ // If session expired, we need to create a new one
885
+ if (e.response?.status === 404) {
886
+ console.log(` [Vortex] Session expired, creating new session...`);
887
+ // Create new session
888
+ const response = await axios_1.default.post(`${gatewayUrl}/api/session`, {
889
+ mode: 'http_proxy',
890
+ client_type: `LeverageAI-Agent/${process.platform}`
891
+ });
892
+ const { session_id, url } = response.data;
893
+ sessionId = session_id;
894
+ tunnelUrl = url;
895
+ console.log(` [Vortex] New session created: ${session_id.substring(0, 8)}...`);
896
+ console.log(` [Vortex] ⚠️ New URL - need to re-scan QR code: ${url}`);
897
+ // Register new tunnel
898
+ await axios_1.default.post(`${gatewayUrl}/api/tunnel/register`, {
899
+ session_id: session_id,
900
+ });
901
+ }
902
+ else {
903
+ throw e;
904
+ }
905
+ }
906
+ // Connect WebSocket to gateway
907
+ const wsUrl = gatewayUrl.replace('https://', 'wss://').replace('http://', 'ws://') + `/tunnel/${sessionId}`;
908
+ return new Promise((resolve, reject) => {
909
+ tunnelWs = new ws_1.default(wsUrl, {
910
+ maxPayload: MAX_PAYLOAD_SIZE
911
+ });
912
+ tunnelWs.on('open', () => {
913
+ console.log(` [Vortex] Tunnel reconnected`);
914
+ startHeartbeat();
915
+ // Recreate shared local connection
916
+ createSharedLocalConnection();
917
+ resolve();
918
+ });
919
+ tunnelWs.on('message', (data) => {
920
+ try {
921
+ const msg = JSON.parse(data.toString());
922
+ handleGatewayMessage(msg);
923
+ }
924
+ catch (e) {
925
+ console.error('[Vortex] Invalid message from gateway:', e);
926
+ }
927
+ });
928
+ tunnelWs.on('close', () => {
929
+ console.log(' [Vortex] Tunnel disconnected');
930
+ stopHeartbeat();
931
+ tunnelWs = null;
932
+ // Auto reconnect again
933
+ if (!isReconnecting && sessionId && gatewayUrl) {
934
+ attemptTunnelReconnect();
935
+ }
936
+ });
937
+ tunnelWs.on('error', (err) => {
938
+ console.error(' [Vortex] Tunnel error:', err.message);
939
+ reject(err);
940
+ });
941
+ // Timeout for connection
942
+ setTimeout(() => {
943
+ if (tunnelWs && tunnelWs.readyState !== ws_1.default.OPEN) {
944
+ tunnelWs.close();
945
+ reject(new Error('Connection timeout'));
946
+ }
947
+ }, 10000);
948
+ });
949
+ }
950
+ /**
951
+ * Clean up resources
952
+ */
953
+ function cleanup() {
954
+ // Clear any pending requests
955
+ pendingRequests.forEach(({ reject, timeout }) => {
956
+ clearTimeout(timeout);
957
+ reject(new Error('Tunnel closed'));
958
+ });
959
+ pendingRequests.clear();
960
+ // Close shared local WebSocket
961
+ if (sharedLocalWs) {
962
+ sharedLocalWs.close();
963
+ sharedLocalWs = null;
964
+ sharedLocalWsReady = false;
965
+ }
966
+ // Close all WebSocket connections (legacy)
967
+ websocketConnections.forEach(ws => {
968
+ ws.close();
969
+ });
970
+ websocketConnections.clear();
971
+ }
972
+ function stopTunnel() {
973
+ // Set reconnecting to true to prevent auto-reconnect on manual stop
974
+ isReconnecting = true;
975
+ stopHeartbeat();
976
+ if (tunnelWs) {
977
+ tunnelWs.close();
978
+ tunnelWs = null;
979
+ }
980
+ cleanup();
981
+ tunnelUrl = null;
982
+ sessionId = null;
983
+ gatewayUrl = null;
984
+ isReconnecting = false;
985
+ reconnectAttempts = 0;
986
+ }
987
+ function getTunnelUrl() {
988
+ return tunnelUrl;
989
+ }
990
+ function isTunnelRunning() {
991
+ return tunnelWs !== null && tunnelWs.readyState === ws_1.default.OPEN;
992
+ }
993
+ //# sourceMappingURL=vortex-tunnel.js.map