tinyagent
Version:
Connect your local shell to any device - access your dev environment from anywhere
1,084 lines • 57.8 kB
JavaScript
"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