claude-code-mobile-terminal-server
Version:
WebSocket terminal server for Claude Code Mobile - enables mobile access to Claude Code in GitHub Codespaces
439 lines (396 loc) ⢠17.9 kB
JavaScript
const WebSocket = require('ws');
const pty = require('node-pty');
const express = require('express');
const cors = require('cors');
const http = require('http');
const path = require('path');
const fs = require('fs');
class TerminalServer {
constructor(options = {}) {
this.port = options.port || process.env.CC_MOBILE_PORT || 8080;
this.host = options.host || process.env.CC_MOBILE_HOST || '0.0.0.0';
this.terminals = new Map();
this.server = null;
this.wss = null;
this.setupExpress();
this.setupWebSocket();
}
setupExpress() {
this.app = express();
this.app.use(cors());
this.app.use(express.json());
// Basic info page
this.app.get('/', (req, res) => {
res.send(`
<html>
<head><title>Claude Code Mobile Terminal Server</title></head>
<body style="font-family: monospace; padding: 20px; background: #000; color: #0f0;">
<h1>š Terminal Server Running</h1>
<p>WebSocket: ws://${this.host}:${this.port}</p>
<p><a href="/mobile" style="color: #ff0;">š± Mobile Terminal</a></p>
<p><a href="/test">š„ļø Test Terminal</a></p>
<p><a href="/health">š Health Check</a></p>
</body>
</html>
`);
});
// Health check
this.app.get('/health', (req, res) => {
res.json({
status: 'ok',
activeTerminals: this.terminals.size,
claudeCodeAvailable: this.checkClaudeAvailable(),
uptime: process.uptime()
});
});
// Receive structured actions from hooks and broadcast to clients
this.app.post('/actions', (req, res) => {
try {
const token = process.env.CC_MOBILE_ACTIONS_TOKEN || '';
if (token) {
const hdr = req.headers['x-cc-actions-token'] || req.headers['authorization'] || '';
const val = Array.isArray(hdr) ? hdr[0] : hdr;
if (!val || (!val.includes(token) && val !== token)) {
return res.status(401).json({ error: 'unauthorized' });
}
} else {
// If no token configured, only allow localhost
const ip = req.ip || '';
const local = ip.includes('127.0.0.1') || ip === '::1' || ip.includes('::ffff:127.0.0.1');
if (!local) {
return res.status(403).json({ error: 'forbidden' });
}
}
const { actions, terminalId } = req.body || {};
const payload = { type: 'actions', actions: Array.isArray(actions) ? actions : [] };
if (terminalId && this.terminals.has(terminalId)) {
const entry = this.terminals.get(terminalId);
if (entry && entry.ws && entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify(payload));
}
} else {
// Broadcast to all clients
if (this.wss && this.wss.clients) {
for (const client of this.wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(payload));
}
}
}
}
res.json({ ok: true, delivered: true });
} catch (e) {
console.error('POST /actions error:', e);
res.status(500).json({ error: 'server_error' });
}
});
// Mobile terminal interface
this.app.get('/mobile', (req, res) => {
res.send(`
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Claude Code Mobile</title>
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: #0f0f0f; color: white; height: 100vh; overflow: hidden; }
.container { display: flex; flex-direction: column; height: 100vh; }
.header { background: linear-gradient(135deg, #3b82f6, #8b5cf6); padding: 15px; text-align: center; }
.terminal-section { flex: 1; padding: 10px; overflow: hidden; }
#terminal { height: 100%; width: 100%; }
.controls { background: #1a1a1a; padding: 10px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.btn { background: #333; color: white; border: none; border-radius: 6px; padding: 12px 16px; font-size: 14px; cursor: pointer; min-width: 70px; }
.btn:active { background: #555; transform: scale(0.95); }
.btn.primary { background: #3b82f6; }
.virtual-keyboard { background: #2a2a2a; padding: 15px 10px; display: none; }
.virtual-keyboard.show { display: block; }
.key-row { display: flex; gap: 6px; margin-bottom: 8px; justify-content: center; }
.key { background: #404040; color: white; border: none; border-radius: 6px; padding: 12px 8px; font-size: 14px; cursor: pointer; min-width: 35px; text-align: center; }
.key:active { background: #555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>š Claude Code Mobile</h2>
<span id="status">Disconnected</span>
</div>
<div class="terminal-section">
<div id="terminal"></div>
</div>
<div class="controls">
<button class="btn primary" onclick="connect()" id="connectBtn">Connect</button>
<button class="btn" onclick="cmd('ls -la')" disabled id="lsBtn">ls -la</button>
<button class="btn" onclick="cmd('pwd')" disabled id="pwdBtn">pwd</button>
<button class="btn" onclick="cmd('claude')" disabled id="claudeBtn">claude</button>
<button class="btn" onclick="cmd('clear')" disabled id="clearBtn">clear</button>
<button class="btn" onclick="toggleKeyboard()" disabled id="keyboardBtn">keyboard</button>
</div>
<div class="virtual-keyboard" id="keyboard">
<div class="key-row">
<div class="key" onclick="sendKey('Escape')">Esc</div>
<div class="key" onclick="sendKey('Tab')">Tab</div>
<div class="key" onclick="sendKey('ArrowUp')">ā</div>
<div class="key" onclick="sendKey('ArrowDown')">ā</div>
<div class="key" onclick="sendKey('ArrowLeft')">ā</div>
<div class="key" onclick="sendKey('ArrowRight')">ā</div>
</div>
<div class="key-row">
<div class="key" onclick="sendCtrl('c')">^C</div>
<div class="key" onclick="sendCtrl('z')">^Z</div>
<div class="key" onclick="sendKey('Enter')">ā</div>
<div class="key" onclick="sendKey('Backspace')">ā«</div>
<div class="key" onclick="toggleKeyboard()">Hide</div>
</div>
</div>
</div>
<script>
let terminal, ws, fitAddon;
let isConnected = false;
function init() {
terminal = new Terminal({
theme: { background: '#0f0f0f', foreground: '#e5e7eb', cursor: '#3b82f6' },
fontSize: 14, fontFamily: 'monospace', cursorBlink: true
});
fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(document.getElementById('terminal'));
setTimeout(() => fitAddon.fit(), 100);
terminal.onData(data => {
if (ws && isConnected) ws.send(JSON.stringify({ type: 'input', data }));
});
terminal.writeln('š Claude Code Mobile Terminal');
terminal.writeln('š± Touch-optimized interface');
terminal.writeln('');
terminal.writeln('Click "Connect" to start');
}
function connect() {
if (isConnected) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl = protocol + '//' + window.location.host + '/?program=claude';
document.getElementById('status').textContent = 'Connecting...';
terminal.writeln('\\r\\nConnecting to: ' + wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
isConnected = true;
document.getElementById('status').textContent = 'Connected';
document.getElementById('connectBtn').textContent = 'Connected';
['lsBtn', 'pwdBtn', 'claudeBtn', 'clearBtn', 'keyboardBtn'].forEach(id => {
document.getElementById(id).disabled = false;
});
terminal.writeln('ā
Connected to terminal server');
};
ws.onerror = () => {
document.getElementById('status').textContent = 'Connection Failed';
terminal.writeln('ā Connection failed');
};
ws.onclose = () => {
isConnected = false;
document.getElementById('status').textContent = 'Disconnected';
document.getElementById('connectBtn').textContent = 'Connect';
['lsBtn', 'pwdBtn', 'claudeBtn', 'clearBtn', 'keyboardBtn'].forEach(id => {
document.getElementById(id).disabled = true;
});
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'output') terminal.write(msg.data);
if (msg.type === 'connected') {
terminal.writeln('\\r\\nšÆ Terminal ready');
}
};
}
function cmd(command) {
if (ws && isConnected) {
ws.send(JSON.stringify({ type: 'input', data: command + '\\r' }));
}
}
function toggleKeyboard() {
const kb = document.getElementById('keyboard');
kb.classList.toggle('show');
document.getElementById('keyboardBtn').textContent = kb.classList.contains('show') ? 'hide' : 'keyboard';
setTimeout(() => fitAddon.fit(), 100);
}
function sendKey(key) {
if (!ws || !isConnected) return;
let data;
switch(key) {
case 'Enter': data = '\\r'; break;
case 'Backspace': data = '\\b'; break;
case 'Tab': data = '\\t'; break;
case 'Escape': data = '\\x1b'; break;
case 'ArrowUp': data = '\\x1b[A'; break;
case 'ArrowDown': data = '\\x1b[B'; break;
case 'ArrowLeft': data = '\\x1b[D'; break;
case 'ArrowRight': data = '\\x1b[C'; break;
default: data = key;
}
ws.send(JSON.stringify({ type: 'input', data }));
}
function sendCtrl(key) {
if (!ws || !isConnected) return;
const code = key.charCodeAt(0) - 96;
ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(code) }));
}
window.addEventListener('load', init);
window.addEventListener('resize', () => setTimeout(() => fitAddon.fit(), 100));
</script>
</body>
</html>
`);
});
// Simple test page
this.app.get('/test', (req, res) => {
res.send(`
<html>
<head>
<title>Terminal Test</title>
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
<style>
body { margin: 20px; background: #000; color: white; font-family: monospace; }
#terminal { height: 400px; border: 1px solid #333; }
button { padding: 10px; margin: 5px; background: #333; color: white; border: none; cursor: pointer; }
button:hover { background: #555; }
</style>
</head>
<body>
<h2>š„ļø Terminal Test</h2>
<button onclick="connect()">Connect</button>
<button onclick="testCommand('ls -la')">ls -la</button>
<button onclick="testCommand('pwd')">pwd</button>
<button onclick="testCommand('claude --version')">claude --version</button>
<button onclick="testCommand('clear')">clear</button>
<button onclick="testCommand('claude')">Start Claude</button>
<div id="terminal"></div>
<script>
const terminal = new Terminal({ theme: { background: '#000' } });
terminal.open(document.getElementById('terminal'));
let ws = null;
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/?program=claude';
terminal.writeln('Connecting to: ' + wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = () => terminal.writeln('ā
Connected');
ws.onerror = () => terminal.writeln('ā Connection failed');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'output') terminal.write(msg.data);
if (msg.type === 'connected') terminal.writeln('\\r\\nšÆ Terminal ready');
};
terminal.onData(data => {
if (ws) ws.send(JSON.stringify({ type: 'input', data }));
});
}
function testCommand(cmd) {
if (ws) ws.send(JSON.stringify({ type: 'input', data: cmd + '\\r' }));
}
</script>
</body>
</html>
`);
});
}
setupWebSocket() {
this.server = http.createServer(this.app);
this.wss = new WebSocket.Server({ server: this.server });
this.wss.on('connection', (ws, request) => {
console.log('š WebSocket connected');
const url = require('url');
const { query } = url.parse(request.url, true);
const requestedProgram = (query && query.program) ? String(query.program) : '';
const terminalId = 'term-' + Date.now();
const defaultShell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
let spawnProgram = defaultShell;
let spawnArgs = [];
if (requestedProgram) {
if (requestedProgram === 'claude' && this.checkClaudeAvailable()) {
spawnProgram = 'claude';
} else if (requestedProgram !== 'claude') {
// Allow arbitrary program if requested; rely on PATH to resolve
spawnProgram = requestedProgram;
}
}
const ptyProcess = pty.spawn(spawnProgram, spawnArgs, {
name: 'xterm-256color',
cols: 80,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`
}
});
this.terminals.set(terminalId, { ptyProcess, ws });
// Terminal output to WebSocket
ptyProcess.on('data', (data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
});
// WebSocket input to terminal
ws.on('message', (message) => {
try {
const msg = JSON.parse(message);
if (msg.type === 'input') {
ptyProcess.write(msg.data);
} else if (msg.type === 'actions' && msg.actions) {
// Allow client to push actions too (advanced use)
const payload = { type: 'actions', actions: msg.actions };
ws.send(JSON.stringify(payload));
} else if (msg.type === 'resize') {
const cols = Math.max(msg.cols || 80, 20);
const rows = Math.max(msg.rows || 30, 10);
ptyProcess.resize(cols, rows);
} else if (msg.type === 'clear') {
ptyProcess.write('\\u001b[2J\\u001b[3J\\u001b[H');
}
} catch (error) {
console.error('Message error:', error);
}
});
// Cleanup
ws.on('close', () => {
console.log('šŗ WebSocket closed');
ptyProcess.kill();
this.terminals.delete(terminalId);
});
// Send ready signal and expose chosen program
ws.send(JSON.stringify({ type: 'connected', terminalId, program: spawnProgram }));
});
}
checkClaudeAvailable() {
try {
const { execSync } = require('child_process');
execSync('which claude', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
async start() {
return new Promise((resolve, reject) => {
this.server.listen(this.port, this.host, (error) => {
if (error) {
reject(error);
return;
}
console.log(`š Terminal Server: http://${this.host}:${this.port}`);
resolve();
});
});
}
}
// Direct execution
if (require.main === module) {
const server = new TerminalServer();
server.start().catch(console.error);
}
module.exports = { TerminalServer };