@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.
- package/Dockerfile +29 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/capture.d.ts +3 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +134 -0
- package/dist/capture.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +84 -0
- package/dist/config.js.map +1 -0
- package/dist/context-extractor.d.ts +17 -0
- package/dist/context-extractor.d.ts.map +1 -0
- package/dist/context-extractor.js +118 -0
- package/dist/context-extractor.js.map +1 -0
- package/dist/debug-logger.d.ts +19 -0
- package/dist/debug-logger.d.ts.map +1 -0
- package/dist/debug-logger.js +48 -0
- package/dist/debug-logger.js.map +1 -0
- package/dist/exec.d.ts +20 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +162 -0
- package/dist/exec.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/pty.d.ts +7 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +27 -0
- package/dist/pty.js.map +1 -0
- package/dist/relay.d.ts +5 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +131 -0
- package/dist/relay.js.map +1 -0
- package/dist/session.d.ts +6 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +250 -0
- package/dist/session.js.map +1 -0
- package/dist/voice-recognition-modelscope.d.ts +50 -0
- package/dist/voice-recognition-modelscope.d.ts.map +1 -0
- package/dist/voice-recognition-modelscope.js +171 -0
- package/dist/voice-recognition-modelscope.js.map +1 -0
- package/dist/vortex-tunnel.d.ts +9 -0
- package/dist/vortex-tunnel.d.ts.map +1 -0
- package/dist/vortex-tunnel.js +993 -0
- package/dist/vortex-tunnel.js.map +1 -0
- package/dist/web-server.d.ts +6 -0
- package/dist/web-server.d.ts.map +1 -0
- package/dist/web-server.js +2096 -0
- package/dist/web-server.js.map +1 -0
- package/docs/CNAME +1 -0
- package/docs/index.html +492 -0
- package/docs/install.sh +329 -0
- package/install.sh +329 -0
- package/package.json +69 -0
- package/scripts/postinstall.js +66 -0
- 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
|