UNPKG

tinyagent

Version:

Connect your local shell to any device - access your dev environment from anywhere

1,084 lines 57.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ShellClient = void 0; const ws_1 = __importDefault(require("ws")); // @ts-ignore - node-pty types not available const pty = __importStar(require("node-pty")); const chalk_1 = __importDefault(require("chalk")); const ora_1 = __importDefault(require("ora")); const http_1 = __importDefault(require("http")); const https_1 = __importDefault(require("https")); const net_1 = __importDefault(require("net")); const qr_generator_1 = require("./qr-generator"); const firebase_auth_simple_1 = require("./firebase-auth-simple"); const shared_types_1 = require("./shared-types"); const tunnel_manager_1 = require("./tunnel-manager"); class ShellClient { options; ws; ptyProcess; // node-pty IPty serverProcess; // node-pty IPty tunnelManager; heartbeatInterval; reconnectTimeout; portCheckInterval; isConnected = false; spinner = (0, ora_1.default)(); terminalBuffer = ''; exposedPorts = new Set(); lastKnownPorts = new Set(); portActivity = {}; stdinListener; lastInputSource = 'local'; lastInputTime = Date.now(); terminalDimensions = { local: { cols: 80, rows: 24 }, mobile: { cols: 80, rows: 24 } }; connectedClients = 1; // Start with 1 (self) clientTypes = { mobile: 0, shell: 0, host: 0 }; statusLineInterval; isShowingQR = false; bufferedOutput = []; currentCommand = ''; hasInitializedTerminal = false; // Track if we've already set up terminal (avoid clearing on mobile reconnect) commandCheckInterval; viewMode = 'desktop'; // For manual toggle statusBarEnabled = true; // Toggle for status bar scrollbackMode = false; // Track if we're in scrollback mode // Security properties sessionToken; securityTier = 3; authClient; constructor(options) { this.options = options; this.authClient = new firebase_auth_simple_1.FirebaseAuthClient(); // Use provided session token if reconnecting if (options.sessionToken) { this.sessionToken = options.sessionToken; } if (options.createTunnel !== false) { this.tunnelManager = new tunnel_manager_1.TunnelManager(); } // Get initial terminal dimensions if (process.stdout.isTTY) { this.terminalDimensions.local = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }; } // Start status line updates this.startStatusLine(); // Handle process exit to clean up terminal process.on('exit', () => this.cleanup()); process.on('SIGINT', () => { this.cleanup(); process.exit(0); }); process.on('SIGTERM', () => { this.cleanup(); process.exit(0); }); // Handle terminal resize process.stdout.on('resize', () => { if (process.stdout.isTTY) { this.terminalDimensions.local = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }; // Only resize PTY if we're in desktop view mode if (this.viewMode === 'desktop' && this.ptyProcess) { const ptyRows = this.statusBarEnabled ? Math.max(1, this.terminalDimensions.local.rows - 1) // Reserve line for status bar : this.terminalDimensions.local.rows; // Use full height this.ptyProcess.resize(this.terminalDimensions.local.cols, ptyRows); } // Update status line with new width this.updateStatusLine(); } }); } async connect() { const debug = this.options.verbose || process.env.DEBUG; if (debug) { console.log(chalk_1.default.gray('[DEBUG] Starting connection...')); console.log(chalk_1.default.gray(`[DEBUG] Relay URL: ${this.options.relayUrl}`)); console.log(chalk_1.default.gray(`[DEBUG] Session ID: ${this.options.sessionId}`)); console.log(chalk_1.default.gray(`[DEBUG] Has existing sessionToken: ${!!this.sessionToken}`)); } this.spinner.start('Creating session...'); // Convert WebSocket URL to HTTP URL for API calls const httpUrl = this.options.relayUrl.replace('wss://', 'https://').replace('ws://', 'http://'); // Step 1: Create session via HTTP API (if we don't have a sessionToken) if (!this.sessionToken) { try { const authToken = this.authClient.isAuthenticated() ? this.authClient.getToken() : undefined; const headers = { 'Content-Type': 'application/json' }; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } if (debug) { console.log(chalk_1.default.gray(`[DEBUG] Creating session via API: ${httpUrl}/api/sessions`)); console.log(chalk_1.default.gray(`[DEBUG] Request body: ${JSON.stringify({ sessionId: this.options.sessionId, password: !!this.options.password, isPublic: this.options.isPublic })}`)); } const createResponse = await fetch(`${httpUrl}/api/sessions`, { method: 'POST', headers, body: JSON.stringify({ sessionId: this.options.sessionId, password: this.options.password, isPublic: this.options.isPublic }) }); if (debug) { console.log(chalk_1.default.gray(`[DEBUG] API response status: ${createResponse.status}`)); } if (!createResponse.ok) { const error = await createResponse.text(); this.spinner.fail(`Failed to create session: ${error}`); if (debug) { console.log(chalk_1.default.red(`[DEBUG] API error response: ${error}`)); } return; } const sessionData = await createResponse.json(); this.sessionToken = sessionData.sessionToken; this.securityTier = sessionData.metadata.securityTier; if (debug) { console.log(chalk_1.default.gray(`[DEBUG] Session created successfully`)); console.log(chalk_1.default.gray(`[DEBUG] Session token: ${this.sessionToken?.substring(0, 16)}...`)); console.log(chalk_1.default.gray(`[DEBUG] Security tier: ${this.securityTier}`)); } this.spinner.succeed(`Session created (tier ${this.securityTier})`); // Show QR code with session token for mobile connection console.log(); (0, qr_generator_1.generateConnectionQR)({ sessionId: this.options.sessionId, relayUrl: this.options.relayUrl, sessionToken: this.sessionToken, securityTier: this.securityTier }); console.log(chalk_1.default.cyan('\nScan the QR code with Tinyagent mobile app to connect.')); console.log(chalk_1.default.gray('Press Ctrl+S to show QR code again.\n')); // Show security warning for public sessions if (this.securityTier === 3) { console.log(chalk_1.default.yellow.bold('⚠️ Warning: This is a public session')); console.log(chalk_1.default.yellow('Anyone with the QR code can access your shell.')); console.log(chalk_1.default.yellow('For better security, use: tinyagent login\n')); } } catch (error) { this.spinner.fail(`Failed to create session: ${error.message}`); if (debug) { console.log(chalk_1.default.red(`[DEBUG] Exception during session creation: ${error.stack || error}`)); } return; } } // Step 2: Connect via WebSocket this.spinner.start('Connecting to relay server...'); const wsUrl = `${this.options.relayUrl}/ws/${this.options.sessionId}`; if (debug) { console.log(chalk_1.default.gray(`[DEBUG] Connecting WebSocket to: ${wsUrl}`)); } this.ws = new ws_1.default(wsUrl); this.ws.on('open', async () => { this.spinner.succeed('Connected to relay server'); this.isConnected = true; // Get auth token if user is authenticated const authToken = this.authClient.isAuthenticated() ? this.authClient.getToken() : undefined; const initMessage = { type: shared_types_1.MessageType.SESSION_INIT, sessionId: this.options.sessionId, timestamp: Date.now(), clientType: 'shell', sessionToken: this.sessionToken, authToken: authToken || undefined, password: this.options.password }; if (debug) { console.log(chalk_1.default.gray(`[DEBUG] Sending SESSION_INIT message`)); console.log(chalk_1.default.gray(`[DEBUG] clientType: shell, hasSessionToken: ${!!this.sessionToken}, hasAuthToken: ${!!authToken}, hasPassword: ${!!this.options.password}`)); } // Send SESSION_INIT with security credentials this.sendMessage(initMessage); this.startHeartbeat(); }); this.ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); if (debug) { console.log(chalk_1.default.gray(`[DEBUG] Received message: ${message.type}`)); if (message.type === shared_types_1.MessageType.SESSION_AUTH_RESPONSE) { console.log(chalk_1.default.gray(`[DEBUG] Auth response - success: ${message.success}, error: ${message.error || 'none'}`)); } if (message.type === shared_types_1.MessageType.SESSION_ERROR) { console.log(chalk_1.default.red(`[DEBUG] Session error: ${message.error}`)); } } this.handleMessage(message); } catch (e) { if (debug) { console.log(chalk_1.default.red(`[DEBUG] Failed to parse message: ${e.message}`)); console.log(chalk_1.default.red(`[DEBUG] Raw data: ${data.toString().substring(0, 200)}`)); } } }); this.ws.on('close', (code, reason) => { this.isConnected = false; if (debug) { console.log(chalk_1.default.gray(`[DEBUG] WebSocket closed - code: ${code}, reason: ${reason?.toString() || 'none'}`)); } console.log(chalk_1.default.yellow('Connection closed')); this.cleanup(); this.scheduleReconnect(); }); this.ws.on('error', (error) => { if (debug) { console.log(chalk_1.default.red(`[DEBUG] WebSocket error: ${error.message}`)); console.log(chalk_1.default.red(`[DEBUG] Error stack: ${error.stack}`)); } this.spinner.fail(`WebSocket error: ${error.message}`); this.cleanup(); }); } handleMessage(message) { switch (message.type) { case shared_types_1.MessageType.SESSION_READY: // Only initialize on first SESSION_READY, not on mobile reconnect if (!this.hasInitializedTerminal) { console.log(chalk_1.default.green('Session ready')); this.setupTerminalWithStatusBar(); this.startShell(); if (this.options.serverCommand) { this.startServer(); } // Always start port detection for HTTP tunneling this.startPortDetection(); this.hasInitializedTerminal = true; } else { // Mobile reconnected - just log it, don't clear screen if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.blue('Mobile client reconnected')); } } break; case shared_types_1.MessageType.SHELL_DATA: const dataMsg = message; if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.cyan(`[RECEIVED FROM MOBILE] ${JSON.stringify(dataMsg.data)}`)); } // Mark mobile as last input source this.lastInputSource = 'mobile'; this.lastInputTime = Date.now(); // Just write the data without resizing - user controls dimensions with Ctrl+R if (this.ptyProcess) { this.ptyProcess.write(dataMsg.data); } break; case shared_types_1.MessageType.SHELL_RESIZE: const resizeMsg = message; if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.blue(`[TERMINAL RESIZE] Received from mobile: ${resizeMsg.cols}x${resizeMsg.rows}`)); console.log(chalk_1.default.yellow(`[TERMINAL RESIZE] Current dimensions - Local: ${this.terminalDimensions.local.cols}x${this.terminalDimensions.local.rows}, Mobile: ${this.terminalDimensions.mobile.cols}x${this.terminalDimensions.mobile.rows}`)); console.log(chalk_1.default.yellow(`[TERMINAL RESIZE] Last input source: ${this.lastInputSource}`)); } // Store mobile dimensions const previousMobileDimensions = { ...this.terminalDimensions.mobile }; this.terminalDimensions.mobile = { cols: resizeMsg.cols, rows: resizeMsg.rows }; // Always resize to mobile dimensions when receiving resize from mobile if (this.ptyProcess) { const ptyRows = this.statusBarEnabled ? Math.max(1, resizeMsg.rows - 1) // Reserve line for status bar : resizeMsg.rows; // Use full height this.ptyProcess.resize(resizeMsg.cols, ptyRows); // Update view mode to mobile since we're resizing to mobile dimensions this.viewMode = 'mobile'; this.updateStatusLine(); if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.green(`[TERMINAL RESIZE] Applied mobile dimensions: ${resizeMsg.cols}x${resizeMsg.rows}`)); } } break; case shared_types_1.MessageType.COMMAND: const cmdMsg = message; this.handleCommand(cmdMsg); break; case shared_types_1.MessageType.SESSION_AUTH_RESPONSE: const authResponse = message; if (!authResponse.success) { console.error(chalk_1.default.red(`Authentication failed: ${authResponse.error}`)); this.disconnect(); } else { if (authResponse.securityTier) { this.securityTier = authResponse.securityTier; } if (process.env.DEBUG || this.options.verbose) { console.log(chalk_1.default.green(`Authenticated (tier ${this.securityTier})`)); } } break; case shared_types_1.MessageType.SESSION_ERROR: console.error(chalk_1.default.red(`Session error: ${message.error}`)); break; case shared_types_1.MessageType.HTTP_REQUEST: const httpMsg = message; this.handleHttpRequest(httpMsg); break; case shared_types_1.MessageType.CLIENT_COUNT: const countMsg = message; // Subtract 1 to exclude the current shell client from the count this.connectedClients = Math.max(0, countMsg.count - 1); this.clientTypes = countMsg.clientTypes || { mobile: 0, shell: 0, host: 0 }; // Check if mobile client just connected if (this.clientTypes.mobile > 0) { // If we're showing QR code, hide it automatically if (this.isShowingQR) { this.hideQRCode(); } // Switch to mobile view if in desktop mode if (this.viewMode === 'desktop') { console.log(chalk_1.default.green('\nMobile client connected, switching to mobile view')); this.viewMode = 'mobile'; // Apply mobile dimensions if we have them if (this.ptyProcess && this.terminalDimensions.mobile.cols > 0) { const { cols, rows } = this.terminalDimensions.mobile; const ptyRows = this.statusBarEnabled ? Math.max(1, rows - 1) // Reserve line for status bar : rows; // Use full height this.ptyProcess.resize(cols, ptyRows); } } } this.updateStatusLine(); break; } } startShell() { if (this.ptyProcess) { console.log(chalk_1.default.yellow('Shell already started')); return; } if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.blue(`Starting shell: ${this.options.shell}`)); console.log(chalk_1.default.blue('[DEBUG] Using shell-client-v2 with node-pty')); } // Parse shell args for login shell const shellArgs = []; if (this.options.shell.includes('bash') || this.options.shell.includes('zsh')) { shellArgs.push('-l'); // login shell } // Create a minimal environment to let the login shell set up its own PATH const minimalEnv = { TERM: 'xterm-256color', USER: process.env.USER, HOME: process.env.HOME, SHELL: process.env.SHELL, LANG: process.env.LANG, LC_ALL: process.env.LC_ALL, // Explicitly set a basic PATH to ensure system commands are available PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' }; // Create PTY with initial size based on last input source const initialDimensions = this.lastInputSource === 'mobile' ? this.terminalDimensions.mobile : this.terminalDimensions.local; // Reserve one line for status bar if enabled const ptyRows = this.statusBarEnabled ? Math.max(1, initialDimensions.rows - 1) : initialDimensions.rows; if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.cyan(`[PTY INIT] Creating PTY with dimensions: ${initialDimensions.cols}x${ptyRows} (source: ${this.lastInputSource}, reserved 1 line for status)`)); } try { this.ptyProcess = pty.spawn(this.options.shell, shellArgs, { name: 'xterm-256color', cols: initialDimensions.cols, rows: ptyRows, env: minimalEnv, cwd: process.cwd() }); if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.green(`[DEBUG] PTY spawned successfully, pid: ${this.ptyProcess.pid}`)); } } catch (error) { console.error(chalk_1.default.red(`[ERROR] Failed to spawn PTY: ${error.message}`)); console.error(chalk_1.default.red(`[ERROR] Stack: ${error.stack}`)); throw error; } // Handle PTY output this.ptyProcess.onData((data) => { // Detect commands in the output this.detectCommand(data); // Only log in debug mode or with --verbose flag if (process.env.DEBUG || process.argv.includes('--verbose')) { console.log(chalk_1.default.gray(`[SHELL OUTPUT] ${JSON.stringify(data)}`)); } else { // If showing QR code, buffer the output if (this.isShowingQR) { this.bufferedOutput.push(data); } else { // Write directly to stdout to preserve terminal control sequences process.stdout.write(data); } } // Store in buffer for late-joining clients this.terminalBuffer += data; if (this.terminalBuffer.length > 10000) { this.terminalBuffer = this.terminalBuffer.slice(-10000); } this.sendMessage({ type: shared_types_1.MessageType.SHELL_DATA, sessionId: this.options.sessionId, timestamp: Date.now(), data: data }); }); this.ptyProcess.onExit(({ exitCode }) => { console.log(chalk_1.default.yellow(`Shell exited with code ${exitCode}`)); this.disconnect(); }); // Set up local terminal input handling AFTER PTY is ready this.setupLocalInput(); // Send initial buffer if any (last 5000 chars to avoid overwhelming mobile) if (this.terminalBuffer) { setTimeout(() => { // Send only the last portion of the buffer for mobile clients const bufferToSend = this.terminalBuffer.length > 5000 ? '...' + this.terminalBuffer.slice(-5000) : this.terminalBuffer; this.sendMessage({ type: shared_types_1.MessageType.SHELL_DATA, sessionId: this.options.sessionId, timestamp: Date.now(), data: bufferToSend }); }, 100); } // Auto-start Claude if enabled if (this.options.autoStartClaude) { setTimeout(() => { this.checkAndStartClaude(); }, 1000); // Wait a second for shell to initialize } // Start monitoring for running commands this.startCommandMonitoring(); } async startServer() { if (!this.options.serverCommand) return; console.log(chalk_1.default.blue(`Starting server: ${this.options.serverCommand}`)); this.serverProcess = pty.spawn('sh', ['-c', this.options.serverCommand], { name: 'xterm', env: { ...process.env, PORT: this.options.serverPort?.toString() } }); this.serverProcess.onData((data) => { console.log(chalk_1.default.gray(`[SERVER] ${data.trim()}`)); }); if (this.tunnelManager && this.options.serverPort) { setTimeout(async () => { try { const tunnelUrl = await this.tunnelManager.createTunnel(this.options.serverPort); console.log(chalk_1.default.green(`Tunnel created: ${tunnelUrl}`)); this.sendMessage({ type: shared_types_1.MessageType.TUNNEL_URL, sessionId: this.options.sessionId, timestamp: Date.now(), url: tunnelUrl, port: this.options.serverPort }); } catch (error) { console.error(chalk_1.default.red(`Failed to create tunnel: ${error}`)); } }, 3000); } } handleCommand(message) { switch (message.command) { case 'start_server': if (!this.serverProcess) { this.startServer(); } break; case 'stop_server': if (this.serverProcess) { this.serverProcess.kill(); this.serverProcess = undefined; } break; case 'terminate': this.disconnect(); break; } } sendMessage(message) { if (this.ws && this.ws.readyState === ws_1.default.OPEN) { this.ws.send(JSON.stringify(message)); } } startHeartbeat() { this.heartbeatInterval = setInterval(() => { this.sendMessage({ type: shared_types_1.MessageType.HEARTBEAT, sessionId: this.options.sessionId, timestamp: Date.now() }); }, shared_types_1.HEARTBEAT_INTERVAL); } scheduleReconnect() { if (this.reconnectTimeout) return; this.reconnectTimeout = setTimeout(() => { console.log(chalk_1.default.blue('Attempting to reconnect...')); this.reconnectTimeout = undefined; this.connect(); }, shared_types_1.RECONNECT_DELAY); } async handleHttpRequest(request) { // Use targetPort from request, or try to extract from headers, or default to 3000 const port = request.targetPort || 3000; // Track port activity if (!this.portActivity[port]) { this.portActivity[port] = { lastAccess: Date.now(), requestCount: 0 }; } this.portActivity[port].lastAccess = Date.now(); this.portActivity[port].requestCount++; // Silently handle HTTP request const makeRequest = (options, redirectCount = 0) => { const protocol = options.port === 443 ? https_1.default : http_1.default; const req = protocol.request(options, (res) => { // Response received // Handle redirects (3xx status codes) if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (redirectCount >= 5) { // Too many redirects console.error(chalk_1.default.red(`[HTTP] Too many redirects for ${request.path}`)); this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, head: { status: 310, headers: { 'content-type': 'text/plain' } }, data: Buffer.from('Too many redirects').toString('base64'), end: true }); return; } const location = res.headers.location; // Following redirect // Parse the redirect URL let redirectUrl; try { if (location.startsWith('http://') || location.startsWith('https://')) { redirectUrl = new URL(location); } else if (location.startsWith('//')) { redirectUrl = new URL(`http:${location}`); } else if (location.startsWith('/')) { redirectUrl = new URL(location, `http://localhost:${port}`); } else { // Relative URL const basePath = request.path.substring(0, request.path.lastIndexOf('/') + 1); redirectUrl = new URL(location, `http://localhost:${port}${basePath}`); } } catch (e) { console.error(chalk_1.default.red(`[HTTP] Invalid redirect URL: ${location}`)); redirectUrl = new URL(location, `http://localhost:${port}`); } // Only follow redirects to localhost if (redirectUrl.hostname !== 'localhost' && redirectUrl.hostname !== '127.0.0.1') { // Not following external redirect // Send the redirect response as-is this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, head: { status: res.statusCode, headers: res.headers } }); res.on('data', (chunk) => { this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, data: chunk.toString('base64') }); }); res.on('end', () => { this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, end: true }); }); return; } // Follow the redirect const newOptions = { hostname: 'localhost', port: parseInt(redirectUrl.port) || port, path: redirectUrl.pathname + redirectUrl.search, method: 'GET', // Redirects typically become GET requests headers: { ...request.headers, host: `localhost:${parseInt(redirectUrl.port) || port}` } }; makeRequest(newOptions, redirectCount + 1); return; } // Send response head this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, head: { status: res.statusCode || 200, headers: res.headers } }); // Stream response body res.on('data', (chunk) => { this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, data: chunk.toString('base64') }); }); res.on('end', () => { this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, end: true }); }); }); req.on('error', (error) => { console.error(chalk_1.default.red(`[HTTP] Request error: ${error.message}`)); // Send error response this.sendMessage({ type: shared_types_1.MessageType.HTTP_RESPONSE, sessionId: this.options.sessionId, timestamp: Date.now(), streamId: request.streamId, head: { status: 502, headers: { 'content-type': 'text/plain' } }, data: Buffer.from(`Gateway Error: ${error.message}`).toString('base64'), end: true }); }); // Write request body if present if (request.body && request.method !== 'GET' && request.method !== 'HEAD') { const body = typeof request.body === 'string' ? Buffer.from(request.body, 'base64') : request.body; req.write(body); } req.end(); }; // Start the request const initialOptions = { hostname: 'localhost', port, path: request.path, method: request.method, headers: { ...request.headers, host: `localhost:${port}` } }; makeRequest(initialOptions); } startPortDetection() { if (this.options.verbose) { console.log(chalk_1.default.gray('[HTTP] Starting port detection...')); } // Check for active ports every 5 seconds this.portCheckInterval = setInterval(() => { this.detectActivePorts(); }, 5000); // Initial check with a small delay to ensure services are ready setTimeout(() => { if (this.options.verbose) { console.log(chalk_1.default.gray('[HTTP] Initial port scan...')); } this.detectActivePorts(); }, 1000); } async detectActivePorts() { const commonPorts = [3000, 3001, 3002, 3003, 3004, 3005, 4000, 4200, 5000, 5173, 8000, 8080, 8081, 9000]; const activePorts = new Set(); const portsWithActivity = new Set(); for (const port of commonPorts) { if (await this.isPortActive(port)) { activePorts.add(port); // Check if this port has recent activity (within last 30 seconds) if (this.portActivity[port] && (Date.now() - this.portActivity[port].lastAccess) < 30000) { portsWithActivity.add(port); } } } // Clean up old port activity data Object.keys(this.portActivity).forEach(port => { const portNum = parseInt(port); if (!activePorts.has(portNum) || (Date.now() - this.portActivity[portNum].lastAccess) > 60000) { delete this.portActivity[portNum]; } }); // Debug logging if (process.env.DEBUG_PORTS) { console.log(chalk_1.default.gray(`[DEBUG] Active ports found: ${[...activePorts].join(', ') || 'none'}`)); console.log(chalk_1.default.gray(`[DEBUG] Ports with recent activity: ${[...portsWithActivity].join(', ') || 'none'}`)); } // Check if ports have changed const portsChanged = activePorts.size !== this.lastKnownPorts.size || [...activePorts].some(port => !this.lastKnownPorts.has(port)) || [...this.lastKnownPorts].some(port => !activePorts.has(port)); if (portsChanged) { this.lastKnownPorts = new Set(activePorts); this.exposedPorts = new Set(activePorts); // Send updated port list to relay with activity information this.sendMessage({ type: shared_types_1.MessageType.REGISTER_PORT, sessionId: this.options.sessionId, timestamp: Date.now(), ports: [...activePorts], portActivity: this.portActivity }); // Log detected ports only when they change if (portsWithActivity.size > 0) { console.log(chalk_1.default.blue(`[HTTP] Active ports: ${[...portsWithActivity].join(', ')}`)); console.log(chalk_1.default.blue(`[HTTP] Access your dev server at:`)); console.log(chalk_1.default.blue(` https://tinyagent.app/${this.options.sessionId}/`)); } else if (this.options.verbose) { // Only show verbose logs in verbose mode if (activePorts.size > 0) { console.log(chalk_1.default.gray(`[HTTP] Ports detected (${[...activePorts].join(', ')}), waiting for activity...`)); } else if (this.lastKnownPorts.size > 0) { console.log(chalk_1.default.gray(`[HTTP] No active ports detected`)); } } } } isPortActive(port) { return new Promise((resolve) => { const socket = new net_1.default.Socket(); socket.setTimeout(300); // Increased timeout for better detection socket.on('connect', () => { socket.destroy(); resolve(true); }); socket.on('timeout', () => { socket.destroy(); resolve(false); }); socket.on('error', () => { resolve(false); }); socket.connect(port, 'localhost'); }); } setupLocalInput() { const debug = this.options.verbose || process.env.DEBUG; if (debug) { console.log(chalk_1.default.gray('[DEBUG] Setting up local input')); console.log(chalk_1.default.gray(`[DEBUG] stdin.isTTY: ${process.stdin.isTTY}`)); } // Set stdin to raw mode to capture all keystrokes if (process.stdin.isTTY) { process.stdin.setRawMode(true); if (debug) { console.log(chalk_1.default.gray('[DEBUG] Raw mode enabled')); } } process.stdin.setEncoding('utf8'); // Handle local keyboard input this.stdinListener = () => { process.stdin.on('data', (data) => { if (debug) { console.log(chalk_1.default.gray(`[DEBUG] stdin received: ${JSON.stringify(data)} (${data.length} chars)`)); } // Mark local as last input source this.lastInputSource = 'local'; this.lastInputTime = Date.now(); // Handle special control sequences BEFORE sending to PTY // Ctrl+S to show QR code (S for Show) if (data === '\x13') { // Ctrl+S this.showQRCode(); return; // Don't pass to PTY } // Ctrl+F to toggle between desktop and mobile view (F for Fit) if (data === '\x06') { // Ctrl+F this.toggleViewMode(); return; // Don't pass to PTY } // Ctrl+B to toggle status bar (B for Bar) if (data === '\x02') { // Ctrl+B this.toggleStatusBar(); return; // Don't pass to PTY } // Ctrl+Q to quit the session (Q for Quit) if (data === '\x11') { // Ctrl+Q console.log(chalk_1.default.yellow('\nReceived Ctrl+Q, disconnecting...')); this.disconnect(); process.exit(0); } // Send all other input to local PTY (including Ctrl+C for interrupts) if (this.ptyProcess) { if (debug) { console.log(chalk_1.default.gray(`[DEBUG] Writing to PTY: ${JSON.stringify(data)}`)); } this.ptyProcess.write(data); } else { if (debug) { console.log(chalk_1.default.red('[DEBUG] PTY not available, cannot write input')); } } }); }; this.stdinListener(); // Resume stdin to start receiving data process.stdin.resume(); if (debug) { console.log(chalk_1.default.gray('[DEBUG] Local input setup complete, stdin resumed')); } } cleanup() { // Reset scrolling region if (process.stdout.isTTY) { process.stdout.write('\x1b[r'); // Reset scroll region process.stdout.write('\x1b[?25h'); // Show cursor } // Restore terminal settings if (process.stdin.isTTY) { process.stdin.setRawMode(false); } process.stdin.pause(); if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); } if (this.portCheckInterval) { clearInterval(this.portCheckInterval); } if (this.statusLineInterval) { clearInterval(this.statusLineInterval); } if (this.commandCheckInterval) { clearInterval(this.commandCheckInterval); } if (this.ptyProcess) { this.ptyProcess.kill(); } if (this.serverProcess) { this.serverProcess.kill(); } if (this.tunnelManager) { this.tunnelManager.closeTunnel(); } } checkAndStartClaude() { if (this.ptyProcess) { // Simple approach: just try to run claude // If it exists, it will start. If not, the error will show in terminal console.log(chalk_1.default.green('\n✓ Attempting to start Claude...')); // Just run claude without clearing - keep QR code visible setTimeout(() => { if (this.ptyProcess) { const claudeCommand = this.options.resumeClaude ? 'claude --resume' : 'claude'; this.ptyProcess.write(`${claudeCommand}\n`); // Set command as 'claude' when we start it this.currentCommand = claudeCommand; this.sendCommandUpdate(claudeCommand); } }, 500); } } startStatusLine() { // Update status line every second this.statusLineInterval = setInterval(() => { this.updateStatusLine(); }, 1000); // Initial update this.updateStatusLine(); } setupTerminalWithStatusBar() { if (!process.stdout.isTTY) return; // Clear screen and set up scrolling region process.stdout.write('\x1b[2J'); // Clear entire screen process.stdout.write('\x1b[1;1H'); // Move to top if (this.statusBarEnabled) { // Set scrolling region to exclude top line (ANSI escape: CSI r) const rows = process.stdout.rows || 24; process.stdout.write(`\x1b[2;${rows}r`); // Set scroll region from line 2 to bottom // Move cursor to second line process.stdout.write('\x1b[2;1H'); // Initial status line update this.updateStatusLine(); } else { // No scroll region restriction process.stdout.write('\x1b[r'); // Reset scroll region to full terminal } } updateStatusLine() { if (!process.stdout.isTTY || !this.statusBarEnabled) return; const cols = process.stdout.columns || 80; const sessionInfo = `tinyagent session: ${this.options.sessionId}`; const viewInfo = `[${this.viewMode}]`; const mobileStatus = this.clientTypes.mobile > 0 ? '📱' : ''; const clientInfo = `${this.connectedClients} client${this.connectedClients !== 1 ? 's' : ''} connected ${mobileStatus}`; const shortcuts = `[Ctrl+S: Show QR | Ctrl+F: Fit | Ctrl+B: Toggle Bar]`; // For smaller terminals, use abbreviated version let middlePart = shortcuts; if (cols < 100) { middlePart = '[^S:QR ^F:Fit ^B:Bar]'; } else if (cols < 80) { middlePart = '[^S ^F ^B]'; } // Calculate spacing const leftPart = `${sessionInfo} ${viewInfo}`; const rightPart = clientInfo; const totalLength = leftPart.length + middlePart.length + rightPart.length + 2; // +2 for surrounding spaces const spacing = Math.max(2, cols - totalLength); const leftSpacing = Math.floor(spacing / 2); const rightSpacing = spacing - leftSpacing; // Build status line (exactly cols width, no padding) const content = ` ${leftPart}${' '.repeat(leftSpacing)}${middlePart}${' '.repeat(rightSpacing)}${rightPart} `; const statusLine = chalk_1.default.bgBlue.white.bold(content.substring(0, cols)); // Save cursor position, move to top line (outside scroll region), print status, restore cursor process.stdout.write('\x1b7'); // Save cursor position process.stdout.write('\x1b[1;1H'); // Move to line 1, column 1 process.stdout.write(statusLine); // Write status process.stdout.write('\x1b[K'); // Clear to end of line (in case terminal is wider) process.stdout.write('\x1b8'); // Restore cursor position } hideQRCode() { if (!this.isShowingQR) return; // Clear flag this.isShowingQR = false; // Switch back to main screen buffer (restores previous content) process.stdout.write('\x1b[?1049l'); // Restore status line this.updateStatusLine(); // Replay any buffered output that came in while QR was shown if (this.bufferedOutput.length > 0) { this.bufferedOutput.forEach(output => { process.stdout.write(output); }); this.bufferedOutput = []; } } showQRCode() { // Set flag to start buffering this.isShowingQR = true; this.bufferedOutput = []; // Save cursor position before clearing process.stdout.write('\x1b[?1049h'); // Switch to alternate screen buffer // Clear screen and show QR code console.clear(); this.updateStatusLine(); (0, qr_generator_1.generateConnectionQR)({ sessionId: this.options.sessionId, relayUrl: this.options.relayUrl, sessionToken: this.sessionToken, securityTier: this.securityTier }); console.log(chalk_1.default.gray('Press any key to return to your session...')); // Set up a temporary stdin handler for returning from QR view const qrExitHandler = (data) => { // Remove this handler process.stdin.removeListener('data', qrExitHandler); // Hide QR code this.hideQRCode(); // Re-enable the normal stdin handler if (this.stdinListener) { this.stdinListener(); } }; // Remove the normal stdin listener temporarily process.stdin.removeAllListeners('data'); // Add the QR exit handler process.stdin.on('data', qrExitHandler); } toggleViewMode() { // Toggle between desktop and mobile view this.viewMode = th