tinyagent
Version:
Connect your local shell to any device - access your dev environment from anywhere
304 lines • 12.4 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"));
const child_process_1 = require("child_process");
const chalk_1 = __importDefault(require("chalk"));
const ora_1 = __importDefault(require("ora"));
const path = __importStar(require("path"));
const shared_types_1 = require("@remote-shell/shared-types");
const tunnel_manager_1 = require("./tunnel-manager");
class ShellClient {
options;
ws;
shellProcess;
serverProcess;
tunnelManager;
heartbeatInterval;
reconnectTimeout;
isConnected = false;
spinner = (0, ora_1.default)();
terminalBuffer = '';
currentSize = { rows: 24, cols: 80 };
constructor(options) {
this.options = options;
if (options.createTunnel !== false) {
this.tunnelManager = new tunnel_manager_1.TunnelManager();
}
}
async connect() {
this.spinner.start('Connecting to relay server...');
const wsUrl = `${this.options.relayUrl}/ws/${this.options.sessionId}`;
this.ws = new ws_1.default(wsUrl);
this.ws.on('open', () => {
this.spinner.succeed('Connected to relay server');
this.isConnected = true;
this.sendMessage({
type: shared_types_1.MessageType.SESSION_INIT,
sessionId: this.options.sessionId,
timestamp: Date.now(),
clientType: 'shell'
});
this.startHeartbeat();
});
this.ws.on('message', (data) => {
this.handleMessage(JSON.parse(data.toString()));
});
this.ws.on('close', () => {
this.isConnected = false;
console.log(chalk_1.default.yellow('Connection closed'));
this.cleanup();
this.scheduleReconnect();
});
this.ws.on('error', (error) => {
this.spinner.fail(`WebSocket error: ${error.message}`);
this.cleanup();
});
}
handleMessage(message) {
switch (message.type) {
case shared_types_1.MessageType.SESSION_READY:
console.log(chalk_1.default.green('Session ready'));
this.startShell();
if (this.options.serverCommand) {
this.startServer();
}
break;
case shared_types_1.MessageType.SHELL_DATA:
const dataMsg = message;
console.log(chalk_1.default.cyan(`[RECEIVED FROM MOBILE] ${JSON.stringify(dataMsg.data)}`));
if (this.shellProcess && this.shellProcess.stdin && !this.shellProcess.stdin.destroyed) {
try {
// Write raw data to Python PTY wrapper stdin
const success = this.shellProcess.stdin.write(dataMsg.data);
if (success) {
console.log(chalk_1.default.green(`[SENT TO SHELL] ${JSON.stringify(dataMsg.data)}`));
}
else {
console.log(chalk_1.default.yellow('[WARNING] Shell stdin buffer full, data queued'));
}
}
catch (error) {
console.log(chalk_1.default.red(`[ERROR] Failed to write to shell: ${error}`));
}
}
else {
console.log(chalk_1.default.red('[ERROR] Shell process stdin not available or destroyed'));
}
break;
case shared_types_1.MessageType.SHELL_RESIZE:
const resizeMsg = message;
console.log(chalk_1.default.blue(`[INFO] Terminal resize: ${resizeMsg.cols}x${resizeMsg.rows}`));
this.currentSize = { rows: resizeMsg.rows, cols: resizeMsg.cols };
// For now, just store the size - we'll recreate the shell with proper size on reconnect
// TODO: Implement proper resize handling with ioctl
break;
case shared_types_1.MessageType.COMMAND:
const cmdMsg = message;
this.handleCommand(cmdMsg);
break;
case shared_types_1.MessageType.SESSION_ERROR:
console.error(chalk_1.default.red(`Session error: ${message.error}`));
break;
}
}
startShell() {
if (this.shellProcess) {
console.log(chalk_1.default.yellow('Shell already started, sending current buffer to new client'));
// Send the current terminal buffer to the newly connected client
if (this.terminalBuffer) {
this.sendMessage({
type: shared_types_1.MessageType.SHELL_DATA,
sessionId: this.options.sessionId,
timestamp: Date.now(),
data: this.terminalBuffer
});
}
return;
}
console.log(chalk_1.default.blue(`Starting shell: ${this.options.shell}`));
// Use Python PTY wrapper with proper stdin handling
const pythonScript = path.join(__dirname, '..', 'src', 'pty-wrapper.py');
// Let the PTY wrapper handle shell arguments for proper login shell
this.shellProcess = (0, child_process_1.spawn)('python3', [pythonScript, this.options.shell], {
env: {
...process.env,
TERM: 'xterm-256color',
LINES: String(this.currentSize.rows),
COLUMNS: String(this.currentSize.cols),
PYTHONUNBUFFERED: '1'
},
stdio: ['pipe', 'pipe', 'pipe']
});
// Handle shell output
this.shellProcess.stdout?.on('data', (data) => {
const output = data.toString();
console.log(chalk_1.default.gray(`[SHELL OUTPUT] ${JSON.stringify(output)}`));
// Store in buffer for late-joining clients
this.terminalBuffer += output;
// Keep buffer size reasonable (last ~10KB)
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: output
});
});
this.shellProcess.stderr?.on('data', (data) => {
console.error(chalk_1.default.red(`[SHELL ERROR] ${data.toString()}`));
});
this.shellProcess.on('exit', (code) => {
console.log(chalk_1.default.yellow(`Shell exited with code ${code}`));
this.disconnect();
});
// Send initial terminal buffer if reconnecting
if (this.terminalBuffer) {
setTimeout(() => {
this.sendMessage({
type: shared_types_1.MessageType.SHELL_DATA,
sessionId: this.options.sessionId,
timestamp: Date.now(),
data: this.terminalBuffer
});
}, 100);
}
}
async startServer() {
if (!this.options.serverCommand)
return;
console.log(chalk_1.default.blue(`Starting server: ${this.options.serverCommand}`));
this.serverProcess = (0, child_process_1.spawn)(this.options.serverCommand, [], {
shell: true,
env: { ...process.env, PORT: this.options.serverPort?.toString() }
});
this.serverProcess.stdout?.on('data', (data) => {
console.log(chalk_1.default.gray(`[SERVER] ${data.toString().trim()}`));
});
this.serverProcess.stderr?.on('data', (data) => {
console.error(chalk_1.default.red(`[SERVER ERROR] ${data.toString().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); // Wait for server to start
}
}
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);
}
cleanup() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
if (this.shellProcess) {
this.shellProcess.kill();
}
if (this.serverProcess) {
this.serverProcess.kill();
}
if (this.tunnelManager) {
this.tunnelManager.closeTunnel();
}
}
disconnect() {
this.cleanup();
if (this.ws) {
this.ws.close();
}
}
}
exports.ShellClient = ShellClient;
//# sourceMappingURL=shell-client.js.map