feat: add mulTmux collaborative terminal tool

Add mulTmux as an integrated workspace in canvas-website project:

- Node.js/TypeScript backend with tmux session management
- CLI client with blessed-based terminal UI
- WebSocket-based real-time collaboration
- Token-based authentication with invite links
- Session management (create, join, list)
- PM2 deployment scripts for AI server
- nginx reverse proxy configuration
- Workspace integration with npm scripts

Usage:
- npm run multmux:build - Build server and CLI
- npm run multmux:start - Start production server
- multmux create <name> - Create collaborative session
- multmux join <token> - Join existing session

See MULTMUX_INTEGRATION.md for full documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-24 02:41:03 -08:00
parent 1e55f3a576
commit 1aec51e97b
24 changed files with 1729 additions and 1 deletions

232
MULTMUX_INTEGRATION.md Normal file
View File

@ -0,0 +1,232 @@
# mulTmux Integration
mulTmux is now integrated into the canvas-website project as a collaborative terminal tool. This allows multiple developers to work together in the same terminal session.
## Installation
From the root of the canvas-website project:
```bash
# Install all dependencies including mulTmux packages
npm run multmux:install
# Build mulTmux packages
npm run multmux:build
```
## Available Commands
All commands are run from the **root** of the canvas-website project:
| Command | Description |
|---------|-------------|
| `npm run multmux:install` | Install mulTmux dependencies |
| `npm run multmux:build` | Build server and CLI packages |
| `npm run multmux:dev:server` | Run server in development mode |
| `npm run multmux:dev:cli` | Run CLI in development mode |
| `npm run multmux:start` | Start the production server |
## Quick Start
### 1. Build mulTmux
```bash
npm run multmux:build
```
### 2. Start the Server Locally (for testing)
```bash
npm run multmux:start
```
Server will be available at:
- HTTP API: `http://localhost:3000`
- WebSocket: `ws://localhost:3001`
### 3. Install CLI Globally
```bash
cd multmux/packages/cli
npm link
```
Now you can use the `multmux` command anywhere!
### 4. Create a Session
```bash
# Local testing
multmux create my-session
# Or specify your AI server (when deployed)
multmux create my-session --server http://your-ai-server:3000
```
### 5. Join from Another Terminal
```bash
multmux join <token-from-above> --server ws://your-ai-server:3001
```
## Deploying to AI Server
### Option 1: Using the Deploy Script
```bash
cd multmux
./infrastructure/deploy.sh
```
This will:
- Install system dependencies (tmux, Node.js)
- Build the project
- Set up PM2 for process management
- Start the server
### Option 2: Manual Deployment
1. **SSH to your AI server**
```bash
ssh your-ai-server
```
2. **Clone or copy the project**
```bash
git clone <your-repo>
cd canvas-website
git checkout mulTmux-webtree
```
3. **Install and build**
```bash
npm install
npm run multmux:build
```
4. **Start with PM2**
```bash
cd multmux
npm install -g pm2
pm2 start packages/server/dist/index.js --name multmux-server
pm2 save
pm2 startup
```
## Project Structure
```
canvas-website/
├── multmux/
│ ├── packages/
│ │ ├── server/ # Backend (Node.js + tmux)
│ │ └── cli/ # Command-line client
│ ├── infrastructure/
│ │ ├── deploy.sh # Auto-deployment script
│ │ └── nginx.conf # Reverse proxy config
│ └── README.md # Full documentation
├── package.json # Now includes workspace config
└── MULTMUX_INTEGRATION.md # This file
```
## Usage Examples
### Collaborative Coding Session
```bash
# Developer 1: Create session in project directory
cd /path/to/project
multmux create coding-session --repo $(pwd)
# Developer 2: Join and start coding together
multmux join <token>
# Both can now type in the same terminal!
```
### Debugging Together
```bash
# Create a session for debugging
multmux create debug-auth-issue
# Share token with teammate
# Both can run commands, check logs, etc.
```
### List Active Sessions
```bash
multmux list
```
## Configuration
### Environment Variables
You can customize ports by setting environment variables:
```bash
export PORT=3000 # HTTP API port
export WS_PORT=3001 # WebSocket port
```
### Token Expiration
Default: 60 minutes. To change, edit `/home/jeffe/Github/canvas-website/multmux/packages/server/src/managers/TokenManager.ts:11`
### Session Cleanup
Sessions auto-cleanup when all users disconnect. To change this behavior, edit `/home/jeffe/Github/canvas-website/multmux/packages/server/src/managers/SessionManager.ts:64`
## Troubleshooting
### "Command not found: multmux"
Run `npm link` from the CLI package:
```bash
cd multmux/packages/cli
npm link
```
### "Connection refused"
1. Check server is running:
```bash
pm2 status
```
2. Check ports are available:
```bash
netstat -tlnp | grep -E '3000|3001'
```
3. Check logs:
```bash
pm2 logs multmux-server
```
### Token Expired
Generate a new token:
```bash
curl -X POST http://localhost:3000/api/sessions/<session-id>/tokens \
-H "Content-Type: application/json" \
-d '{"expiresInMinutes": 60}'
```
## Security Notes
- Tokens expire after 60 minutes
- Sessions are isolated per tmux instance
- All input is validated on the server
- Use nginx + SSL for production deployments
## Next Steps
1. **Test locally first**: Run `npm run multmux:start` and try creating/joining sessions
2. **Deploy to AI server**: Use `./infrastructure/deploy.sh`
3. **Set up nginx**: Copy config from `infrastructure/nginx.conf` for SSL/reverse proxy
4. **Share with team**: Send them tokens to collaborate!
For full documentation, see `multmux/README.md`.

35
multmux/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
*.tsbuildinfo
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pm2.log
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# PM2
ecosystem.config.js
.pm2/

240
multmux/README.md Normal file
View File

@ -0,0 +1,240 @@
# mulTmux
A collaborative terminal tool that lets multiple users interact with the same tmux session in real-time.
## Features
- **Real-time Collaboration**: Multiple users can connect to the same terminal session
- **tmux Backend**: Leverages tmux for robust terminal multiplexing
- **Token-based Auth**: Secure invite links with expiration
- **Presence Indicators**: See who's connected to your session
- **Low Resource Usage**: ~200-300MB RAM for typical usage
- **Easy Deployment**: Works alongside existing services on your server
## Architecture
```
┌─────────────┐ ┌──────────────────┐
│ Client │ ──── WebSocket ────────> │ Server │
│ (CLI) │ (token auth) │ │
└─────────────┘ │ ┌────────────┐ │
│ │ Node.js │ │
┌─────────────┐ │ │ Backend │ │
│ Client 2 │ ──── Invite Link ──────> │ └─────┬──────┘ │
│ (CLI) │ │ │ │
└─────────────┘ │ ┌─────▼──────┐ │
│ │ tmux │ │
│ │ Sessions │ │
│ └────────────┘ │
└──────────────────┘
```
## Installation
### Server Setup
1. **Deploy to your AI server:**
```bash
cd multmux
chmod +x infrastructure/deploy.sh
./infrastructure/deploy.sh
```
This will:
- Install tmux if needed
- Build the server
- Set up PM2 for process management
- Start the server
2. **(Optional) Set up nginx reverse proxy:**
```bash
sudo cp infrastructure/nginx.conf /etc/nginx/sites-available/multmux
sudo ln -s /etc/nginx/sites-available/multmux /etc/nginx/sites-enabled/
# Edit the file to set your domain
sudo nano /etc/nginx/sites-available/multmux
sudo nginx -t
sudo systemctl reload nginx
```
### CLI Installation
**On your local machine:**
```bash
cd multmux/packages/cli
npm install
npm run build
npm link # Installs 'multmux' command globally
```
## Usage
### Create a Session
```bash
multmux create my-project --repo /path/to/repo
```
This outputs an invite link like:
```
multmux join a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
```
### Join a Session
```bash
multmux join a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
```
### List Active Sessions
```bash
multmux list
```
### Using a Remote Server
If your server is on a different machine:
```bash
# Create session
multmux create my-project --server http://your-server:3000
# Join session
multmux join <token> --server ws://your-server:3001
```
## CLI Commands
| Command | Description |
|---------|-------------|
| `multmux create <name>` | Create a new collaborative session |
| `multmux join <token>` | Join an existing session |
| `multmux list` | List all active sessions |
### Options
**create:**
- `-s, --server <url>` - Server URL (default: http://localhost:3000)
- `-r, --repo <path>` - Repository path to cd into
**join:**
- `-s, --server <url>` - WebSocket server URL (default: ws://localhost:3001)
**list:**
- `-s, --server <url>` - Server URL (default: http://localhost:3000)
## Server Management
### PM2 Commands
```bash
pm2 status # Check server status
pm2 logs multmux-server # View server logs
pm2 restart multmux-server # Restart server
pm2 stop multmux-server # Stop server
```
### Resource Usage
- **Idle**: ~100-150MB RAM
- **Per session**: ~5-10MB RAM
- **Per user**: ~1-2MB RAM
- **Typical usage**: 200-300MB RAM total
## API Reference
### HTTP API (default: port 3000)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/sessions` | POST | Create a new session |
| `/api/sessions` | GET | List active sessions |
| `/api/sessions/:id` | GET | Get session info |
| `/api/sessions/:id/tokens` | POST | Generate new invite token |
| `/api/health` | GET | Health check |
### WebSocket (default: port 3001)
Connect with: `ws://localhost:3001?token=<your-token>`
**Message Types:**
- `output` - Terminal output from server
- `input` - User input to terminal
- `resize` - Terminal resize event
- `presence` - User join/leave notifications
- `joined` - Connection confirmation
## Security
- **Token Expiration**: Invite tokens expire after 60 minutes (configurable)
- **Session Isolation**: Each session runs in its own tmux instance
- **Input Validation**: All terminal input is validated
- **No Persistence**: Sessions are destroyed when all users leave
## Troubleshooting
### Server won't start
Check if ports are available:
```bash
netstat -tlnp | grep -E '3000|3001'
```
### Can't connect to server
1. Check server is running: `pm2 status`
2. Check logs: `pm2 logs multmux-server`
3. Verify firewall allows ports 3000 and 3001
### Terminal not responding
1. Check WebSocket connection in browser console
2. Verify token hasn't expired
3. Restart session: `pm2 restart multmux-server`
## Development
### Project Structure
```
multmux/
├── packages/
│ ├── server/ # Backend server
│ │ ├── src/
│ │ │ ├── managers/ # Session & token management
│ │ │ ├── websocket/ # WebSocket handler
│ │ │ └── api/ # HTTP routes
│ └── cli/ # CLI client
│ ├── src/
│ │ ├── commands/ # CLI commands
│ │ ├── connection/ # WebSocket client
│ │ └── ui/ # Terminal UI
└── infrastructure/ # Deployment scripts
```
### Running in Development
**Terminal 1 - Server:**
```bash
npm run dev:server
```
**Terminal 2 - CLI:**
```bash
cd packages/cli
npm run dev -- create test-session
```
### Building
```bash
npm run build # Builds both packages
```
## License
MIT
## Contributing
Contributions welcome! Please open an issue or PR.

View File

@ -0,0 +1,91 @@
#!/bin/bash
# mulTmux Deployment Script for AI Server
# This script sets up mulTmux on your existing droplet
set -e
echo "🚀 mulTmux Deployment Script"
echo "============================"
echo ""
# Check if tmux is installed
if ! command -v tmux &> /dev/null; then
echo "📦 Installing tmux..."
sudo apt-get update
sudo apt-get install -y tmux
else
echo "✅ tmux is already installed"
fi
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "📦 Installing Node.js..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
else
echo "✅ Node.js is already installed ($(node --version))"
fi
# Check if npm is installed
if ! command -v npm &> /dev/null; then
echo "❌ npm is not installed. Please install npm first."
exit 1
else
echo "✅ npm is already installed ($(npm --version))"
fi
# Build the server
echo ""
echo "🔨 Building mulTmux..."
cd "$(dirname "$0")/.."
npm install
npm run build
echo ""
echo "📝 Setting up PM2 for process management..."
if ! command -v pm2 &> /dev/null; then
sudo npm install -g pm2
fi
# Create PM2 ecosystem file
cat > ecosystem.config.js << EOF
module.exports = {
apps: [{
name: 'multmux-server',
script: './packages/server/dist/index.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3000,
WS_PORT: 3001
}
}]
};
EOF
echo ""
echo "🚀 Starting mulTmux server with PM2..."
pm2 start ecosystem.config.js
pm2 save
pm2 startup | tail -n 1 | bash || true
echo ""
echo "✅ mulTmux deployed successfully!"
echo ""
echo "Server is running on:"
echo " HTTP API: http://localhost:3000"
echo " WebSocket: ws://localhost:3001"
echo ""
echo "Useful PM2 commands:"
echo " pm2 status - Check server status"
echo " pm2 logs multmux-server - View logs"
echo " pm2 restart multmux-server - Restart server"
echo " pm2 stop multmux-server - Stop server"
echo ""
echo "To install the CLI globally:"
echo " cd packages/cli && npm link"
echo ""

View File

@ -0,0 +1,53 @@
# nginx configuration for mulTmux
# Place this in /etc/nginx/sites-available/multmux
# Then: sudo ln -s /etc/nginx/sites-available/multmux /etc/nginx/sites-enabled/
upstream multmux_api {
server localhost:3000;
}
upstream multmux_ws {
server localhost:3001;
}
server {
listen 80;
server_name your-server-domain.com; # Change this to your domain or IP
# HTTP API
location /api {
proxy_pass http://multmux_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket
location /ws {
proxy_pass http://multmux_ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
# Optional: SSL configuration (if using Let's Encrypt)
# server {
# listen 443 ssl http2;
# server_name your-server-domain.com;
#
# ssl_certificate /etc/letsencrypt/live/your-server-domain.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/your-server-domain.com/privkey.pem;
#
# # Same location blocks as above...
# }

19
multmux/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "multmux",
"version": "0.1.0",
"private": true,
"description": "Collaborative terminal tool with tmux backend",
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build -ws",
"dev:server": "npm run dev -w @multmux/server",
"dev:cli": "npm run dev -w @multmux/cli",
"start:server": "npm run start -w @multmux/server"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,30 @@
{
"name": "@multmux/cli",
"version": "0.1.0",
"description": "mulTmux CLI - collaborative terminal client",
"main": "dist/index.js",
"bin": {
"multmux": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"commander": "^11.1.0",
"ws": "^8.16.0",
"blessed": "^0.1.81",
"chalk": "^4.1.2",
"ora": "^5.4.1",
"node-fetch": "^2.7.0"
},
"devDependencies": {
"@types/ws": "^8.5.10",
"@types/node": "^20.0.0",
"@types/blessed": "^0.1.25",
"@types/node-fetch": "^2.6.9",
"tsx": "^4.7.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,50 @@
import fetch from 'node-fetch';
import chalk from 'chalk';
import ora from 'ora';
export async function createSession(
name: string,
options: { server?: string; repo?: string }
): Promise<void> {
const serverUrl = options.server || 'http://localhost:3000';
const spinner = ora('Creating session...').start();
try {
const response = await fetch(`${serverUrl}/api/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
repoPath: options.repo,
}),
});
if (!response.ok) {
throw new Error(`Failed to create session: ${response.statusText}`);
}
const data: any = await response.json();
spinner.succeed('Session created!');
console.log('');
console.log(chalk.bold('Session Details:'));
console.log(` Name: ${chalk.cyan(data.session.name)}`);
console.log(` ID: ${chalk.gray(data.session.id)}`);
console.log(` Created: ${new Date(data.session.createdAt).toLocaleString()}`);
console.log('');
console.log(chalk.bold('To join this session:'));
console.log(chalk.green(` ${data.inviteUrl}`));
console.log('');
console.log(chalk.bold('Or share this token:'));
console.log(` ${chalk.yellow(data.token)}`);
console.log('');
console.log(chalk.dim('Token expires in 60 minutes'));
} catch (error) {
spinner.fail('Failed to create session');
console.error(chalk.red((error as Error).message));
process.exit(1);
}
}

View File

@ -0,0 +1,45 @@
import chalk from 'chalk';
import ora from 'ora';
import { WebSocketClient } from '../connection/WebSocketClient';
import { TerminalUI } from '../ui/Terminal';
export async function joinSession(
token: string,
options: { server?: string }
): Promise<void> {
const serverUrl = options.server || 'ws://localhost:3001';
const spinner = ora('Connecting to session...').start();
try {
const client = new WebSocketClient(serverUrl, token);
// Wait for connection
await client.connect();
spinner.succeed('Connected!');
// Wait a moment for the 'joined' event
await new Promise((resolve) => {
client.once('joined', resolve);
setTimeout(resolve, 1000); // Fallback timeout
});
console.log(chalk.green('\nJoined session! Press ESC or Ctrl-C to exit.\n'));
// Create terminal UI
const ui = new TerminalUI(client);
// Handle errors
client.on('error', (error: Error) => {
console.error(chalk.red('\nConnection error:'), error.message);
});
client.on('reconnect-failed', () => {
console.error(chalk.red('\nFailed to reconnect. Exiting...'));
process.exit(1);
});
} catch (error) {
spinner.fail('Failed to connect');
console.error(chalk.red((error as Error).message));
process.exit(1);
}
}

View File

@ -0,0 +1,38 @@
import fetch from 'node-fetch';
import chalk from 'chalk';
import ora from 'ora';
export async function listSessions(options: { server?: string }): Promise<void> {
const serverUrl = options.server || 'http://localhost:3000';
const spinner = ora('Fetching sessions...').start();
try {
const response = await fetch(`${serverUrl}/api/sessions`);
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
}
const data: any = await response.json();
spinner.stop();
if (data.sessions.length === 0) {
console.log(chalk.yellow('No active sessions found.'));
return;
}
console.log(chalk.bold(`\nActive Sessions (${data.sessions.length}):\n`));
data.sessions.forEach((session: any) => {
console.log(chalk.cyan(` ${session.name}`));
console.log(` ID: ${chalk.gray(session.id)}`);
console.log(` Clients: ${session.activeClients}`);
console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
console.log('');
});
} catch (error) {
spinner.fail('Failed to fetch sessions');
console.error(chalk.red((error as Error).message));
process.exit(1);
}
}

View File

@ -0,0 +1,120 @@
import WebSocket from 'ws';
import { EventEmitter } from 'events';
export interface TerminalMessage {
type: 'output' | 'input' | 'resize' | 'join' | 'leave' | 'presence' | 'joined' | 'error';
data?: any;
clientId?: string;
timestamp?: number;
sessionId?: string;
sessionName?: string;
message?: string;
}
export class WebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
constructor(private url: string, private token: string) {
super();
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
const wsUrl = `${this.url}?token=${this.token}`;
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
this.reconnectAttempts = 0;
this.emit('connected');
resolve();
});
this.ws.on('message', (data) => {
try {
const message: TerminalMessage = JSON.parse(data.toString());
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse message:', error);
}
});
this.ws.on('close', () => {
this.emit('disconnected');
this.attemptReconnect();
});
this.ws.on('error', (error) => {
this.emit('error', error);
reject(error);
});
});
}
private handleMessage(message: TerminalMessage): void {
switch (message.type) {
case 'output':
this.emit('output', message.data);
break;
case 'joined':
this.emit('joined', {
sessionId: message.sessionId,
sessionName: message.sessionName,
clientId: message.clientId,
});
break;
case 'presence':
this.emit('presence', message.data);
break;
case 'error':
this.emit('error', new Error(message.message || 'Unknown error'));
break;
}
}
sendInput(data: string): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
JSON.stringify({
type: 'input',
data,
timestamp: Date.now(),
})
);
}
}
resize(cols: number, rows: number): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
JSON.stringify({
type: 'resize',
data: { cols, rows },
timestamp: Date.now(),
})
);
}
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private attemptReconnect(): void {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => {
this.emit('reconnecting', this.reconnectAttempts);
this.connect().catch(() => {
// Reconnection failed, will retry
});
}, 1000 * this.reconnectAttempts);
} else {
this.emit('reconnect-failed');
}
}
}

View File

@ -0,0 +1,34 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { createSession } from './commands/create';
import { joinSession } from './commands/join';
import { listSessions } from './commands/list';
const program = new Command();
program
.name('multmux')
.description('Collaborative terminal tool with tmux backend')
.version('0.1.0');
program
.command('create <name>')
.description('Create a new collaborative session')
.option('-s, --server <url>', 'Server URL', 'http://localhost:3000')
.option('-r, --repo <path>', 'Repository path to use')
.action(createSession);
program
.command('join <token>')
.description('Join an existing session with a token')
.option('-s, --server <url>', 'WebSocket server URL', 'ws://localhost:3001')
.action(joinSession);
program
.command('list')
.description('List active sessions')
.option('-s, --server <url>', 'Server URL', 'http://localhost:3000')
.action(listSessions);
program.parse();

View File

@ -0,0 +1,154 @@
import blessed from 'blessed';
import { WebSocketClient } from '../connection/WebSocketClient';
export class TerminalUI {
private screen: blessed.Widgets.Screen;
private terminal: blessed.Widgets.BoxElement;
private statusBar: blessed.Widgets.BoxElement;
private buffer: string = '';
constructor(private client: WebSocketClient) {
// Create screen
this.screen = blessed.screen({
smartCSR: true,
title: 'mulTmux',
});
// Status bar
this.statusBar = blessed.box({
top: 0,
left: 0,
width: '100%',
height: 1,
style: {
fg: 'white',
bg: 'blue',
},
content: ' mulTmux - Connecting...',
});
// Terminal output
this.terminal = blessed.box({
top: 1,
left: 0,
width: '100%',
height: '100%-1',
scrollable: true,
alwaysScroll: true,
scrollbar: {
style: {
bg: 'blue',
},
},
keys: true,
vi: true,
mouse: true,
content: '',
});
this.screen.append(this.statusBar);
this.screen.append(this.terminal);
// Focus terminal
this.terminal.focus();
// Setup event handlers
this.setupEventHandlers();
// Render
this.screen.render();
}
private setupEventHandlers(): void {
// Handle terminal output from server
this.client.on('output', (data: string) => {
this.buffer += data;
this.terminal.setContent(this.buffer);
this.terminal.setScrollPerc(100);
this.screen.render();
});
// Handle connection events
this.client.on('connected', () => {
this.updateStatus('Connected', 'green');
});
this.client.on('joined', (info: any) => {
this.updateStatus(`Session: ${info.sessionName} (${info.clientId.slice(0, 8)})`, 'green');
});
this.client.on('disconnected', () => {
this.updateStatus('Disconnected', 'red');
});
this.client.on('reconnecting', (attempt: number) => {
this.updateStatus(`Reconnecting (${attempt}/5)...`, 'yellow');
});
this.client.on('presence', (data: any) => {
if (data.action === 'join') {
this.showNotification(`User joined (${data.totalClients} online)`);
} else if (data.action === 'leave') {
this.showNotification(`User left (${data.totalClients} online)`);
}
});
// Handle keyboard input
this.screen.on('keypress', (ch: string, key: any) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
this.close();
return;
}
// Send input to server
if (ch) {
this.client.sendInput(ch);
} else if (key.name) {
// Handle special keys
const specialKeys: { [key: string]: string } = {
enter: '\r',
backspace: '\x7f',
tab: '\t',
up: '\x1b[A',
down: '\x1b[B',
right: '\x1b[C',
left: '\x1b[D',
};
if (specialKeys[key.name]) {
this.client.sendInput(specialKeys[key.name]);
}
}
});
// Handle resize
this.screen.on('resize', () => {
const { width, height } = this.terminal;
this.client.resize(width as number, (height as number) - 1);
});
// Quit on Ctrl-C
this.screen.key(['C-c'], () => {
this.close();
});
}
private updateStatus(text: string, color: string = 'blue'): void {
this.statusBar.style.bg = color;
this.statusBar.setContent(` mulTmux - ${text}`);
this.screen.render();
}
private showNotification(text: string): void {
// Append notification to buffer
this.buffer += `\n[mulTmux] ${text}\n`;
this.terminal.setContent(this.buffer);
this.screen.render();
}
close(): void {
this.client.disconnect();
this.screen.destroy();
process.exit(0);
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,26 @@
{
"name": "@multmux/server",
"version": "0.1.0",
"description": "mulTmux server - collaborative terminal backend",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.0",
"ws": "^8.16.0",
"node-pty": "^1.0.0",
"nanoid": "^3.3.7",
"cors": "^2.8.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/ws": "^8.5.10",
"@types/node": "^20.0.0",
"@types/cors": "^2.8.17",
"tsx": "^4.7.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,96 @@
import { Router } from 'express';
import { SessionManager } from '../managers/SessionManager';
import { TokenManager } from '../managers/TokenManager';
export function createRouter(
sessionManager: SessionManager,
tokenManager: TokenManager
): Router {
const router = Router();
// Create a new session
router.post('/sessions', async (req, res) => {
try {
const { name, repoPath } = req.body;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Session name is required' });
}
const session = await sessionManager.createSession(name, repoPath);
const token = tokenManager.generateToken(session.id, 60, 'write');
res.json({
session: {
id: session.id,
name: session.name,
createdAt: session.createdAt,
},
token,
inviteUrl: `multmux join ${token}`,
});
} catch (error) {
console.error('Failed to create session:', error);
res.status(500).json({ error: 'Failed to create session' });
}
});
// List active sessions
router.get('/sessions', (req, res) => {
const sessions = sessionManager.listSessions();
res.json({
sessions: sessions.map((s) => ({
id: s.id,
name: s.name,
createdAt: s.createdAt,
activeClients: s.clients.size,
})),
});
});
// Get session info
router.get('/sessions/:id', (req, res) => {
const session = sessionManager.getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json({
id: session.id,
name: session.name,
createdAt: session.createdAt,
activeClients: session.clients.size,
});
});
// Generate new invite token for existing session
router.post('/sessions/:id/tokens', (req, res) => {
const session = sessionManager.getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const { expiresInMinutes = 60, permissions = 'write' } = req.body;
const token = tokenManager.generateToken(session.id, expiresInMinutes, permissions);
res.json({
token,
inviteUrl: `multmux join ${token}`,
expiresInMinutes,
permissions,
});
});
// Health check
router.get('/health', (req, res) => {
res.json({
status: 'ok',
activeSessions: sessionManager.listSessions().length,
activeTokens: tokenManager.getActiveTokens(),
});
});
return router;
}

View File

@ -0,0 +1,55 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import { SessionManager } from './managers/SessionManager';
import { TokenManager } from './managers/TokenManager';
import { TerminalHandler } from './websocket/TerminalHandler';
import { createRouter } from './api/routes';
const PORT = process.env.PORT || 3000;
const WS_PORT = process.env.WS_PORT || 3001;
async function main() {
// Initialize managers
const sessionManager = new SessionManager();
const tokenManager = new TokenManager();
const terminalHandler = new TerminalHandler(sessionManager, tokenManager);
// HTTP API Server
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api', createRouter(sessionManager, tokenManager));
app.listen(PORT, () => {
console.log(`mulTmux HTTP API listening on port ${PORT}`);
});
// WebSocket Server
const wss = new WebSocketServer({ port: Number(WS_PORT) });
wss.on('connection', (ws, req) => {
// Extract token from query string
const url = new URL(req.url || '', `http://localhost:${WS_PORT}`);
const token = url.searchParams.get('token');
if (!token) {
ws.send(JSON.stringify({ type: 'error', message: 'Token required' }));
ws.close();
return;
}
terminalHandler.handleConnection(ws, token);
});
console.log(`mulTmux WebSocket server listening on port ${WS_PORT}`);
console.log('');
console.log('mulTmux server is ready!');
console.log(`API: http://localhost:${PORT}/api`);
console.log(`WebSocket: ws://localhost:${WS_PORT}`);
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});

View File

@ -0,0 +1,114 @@
import { spawn, ChildProcess } from 'child_process';
import * as pty from 'node-pty';
import { Session } from '../types';
import { nanoid } from 'nanoid';
export class SessionManager {
private sessions: Map<string, Session> = new Map();
private terminals: Map<string, pty.IPty> = new Map();
async createSession(name: string, repoPath?: string): Promise<Session> {
const id = nanoid(16);
const tmuxSessionName = `multmux-${id}`;
const session: Session = {
id,
name,
createdAt: new Date(),
tmuxSessionName,
clients: new Set(),
repoPath,
};
this.sessions.set(id, session);
// Create tmux session
await this.createTmuxSession(tmuxSessionName, repoPath);
// Attach to tmux session with pty
const terminal = pty.spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: repoPath || process.cwd(),
env: process.env as { [key: string]: string },
});
this.terminals.set(id, terminal);
return session;
}
private async createTmuxSession(name: string, cwd?: string): Promise<void> {
return new Promise((resolve, reject) => {
const args = ['new-session', '-d', '-s', name];
if (cwd) {
args.push('-c', cwd);
}
const proc = spawn('tmux', args);
proc.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Failed to create tmux session: exit code ${code}`));
}
});
});
}
getSession(id: string): Session | undefined {
return this.sessions.get(id);
}
getTerminal(sessionId: string): pty.IPty | undefined {
return this.terminals.get(sessionId);
}
addClient(sessionId: string, clientId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.clients.add(clientId);
}
}
removeClient(sessionId: string, clientId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.clients.delete(clientId);
// Clean up session if no clients left
if (session.clients.size === 0) {
this.destroySession(sessionId);
}
}
}
private async destroySession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
const terminal = this.terminals.get(sessionId);
if (terminal) {
terminal.kill();
this.terminals.delete(sessionId);
}
// Kill tmux session
spawn('tmux', ['kill-session', '-t', session.tmuxSessionName]);
this.sessions.delete(sessionId);
}
listSessions(): Session[] {
return Array.from(this.sessions.values());
}
resizeTerminal(sessionId: string, cols: number, rows: number): void {
const terminal = this.terminals.get(sessionId);
if (terminal) {
terminal.resize(cols, rows);
}
}
}

View File

@ -0,0 +1,50 @@
import { nanoid } from 'nanoid';
import { SessionToken } from '../types';
export class TokenManager {
private tokens: Map<string, SessionToken> = new Map();
generateToken(
sessionId: string,
expiresInMinutes: number = 60,
permissions: 'read' | 'write' = 'write'
): string {
const token = nanoid(32);
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
this.tokens.set(token, {
token,
sessionId,
expiresAt,
permissions,
});
// Clean up expired token after expiration
setTimeout(() => this.tokens.delete(token), expiresInMinutes * 60 * 1000);
return token;
}
validateToken(token: string): SessionToken | null {
const sessionToken = this.tokens.get(token);
if (!sessionToken) {
return null;
}
if (sessionToken.expiresAt < new Date()) {
this.tokens.delete(token);
return null;
}
return sessionToken;
}
revokeToken(token: string): void {
this.tokens.delete(token);
}
getActiveTokens(): number {
return this.tokens.size;
}
}

View File

@ -0,0 +1,29 @@
export interface Session {
id: string;
name: string;
createdAt: Date;
tmuxSessionName: string;
clients: Set<string>;
repoPath?: string;
}
export interface SessionToken {
token: string;
sessionId: string;
expiresAt: Date;
permissions: 'read' | 'write';
}
export interface ClientConnection {
id: string;
sessionId: string;
username?: string;
permissions: 'read' | 'write';
}
export interface TerminalMessage {
type: 'output' | 'input' | 'resize' | 'join' | 'leave' | 'presence';
data: any;
clientId?: string;
timestamp: number;
}

View File

@ -0,0 +1,175 @@
import { WebSocket } from 'ws';
import { nanoid } from 'nanoid';
import { SessionManager } from '../managers/SessionManager';
import { TokenManager } from '../managers/TokenManager';
import { TerminalMessage, ClientConnection } from '../types';
export class TerminalHandler {
private clients: Map<string, { ws: WebSocket; connection: ClientConnection }> = new Map();
constructor(
private sessionManager: SessionManager,
private tokenManager: TokenManager
) {}
handleConnection(ws: WebSocket, token: string): void {
// Validate token
const sessionToken = this.tokenManager.validateToken(token);
if (!sessionToken) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired token' }));
ws.close();
return;
}
// Verify session exists
const session = this.sessionManager.getSession(sessionToken.sessionId);
if (!session) {
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
ws.close();
return;
}
const clientId = nanoid(16);
const connection: ClientConnection = {
id: clientId,
sessionId: sessionToken.sessionId,
permissions: sessionToken.permissions,
};
this.clients.set(clientId, { ws, connection });
this.sessionManager.addClient(sessionToken.sessionId, clientId);
// Attach terminal output to WebSocket
const terminal = this.sessionManager.getTerminal(sessionToken.sessionId);
if (terminal) {
const onData = (data: string) => {
const message: TerminalMessage = {
type: 'output',
data,
timestamp: Date.now(),
};
ws.send(JSON.stringify(message));
};
terminal.onData(onData);
// Clean up on disconnect
ws.on('close', () => {
terminal.off('data', onData);
this.handleDisconnect(clientId);
});
}
// Send join confirmation
ws.send(
JSON.stringify({
type: 'joined',
sessionId: session.id,
sessionName: session.name,
clientId,
})
);
// Broadcast presence
this.broadcastToSession(sessionToken.sessionId, {
type: 'presence',
data: {
action: 'join',
clientId,
totalClients: session.clients.size,
},
timestamp: Date.now(),
});
// Handle incoming messages
ws.on('message', (data) => {
this.handleMessage(clientId, data.toString());
});
}
private handleMessage(clientId: string, rawMessage: string): void {
const client = this.clients.get(clientId);
if (!client) return;
try {
const message: TerminalMessage = JSON.parse(rawMessage);
switch (message.type) {
case 'input':
this.handleInput(client.connection, message.data);
break;
case 'resize':
this.handleResize(client.connection, message.data);
break;
}
} catch (error) {
console.error('Failed to parse message:', error);
}
}
private handleInput(connection: ClientConnection, data: string): void {
if (connection.permissions !== 'write') {
return; // Read-only clients can't send input
}
const terminal = this.sessionManager.getTerminal(connection.sessionId);
if (terminal) {
terminal.write(data);
}
// Broadcast input to other clients for cursor tracking
this.broadcastToSession(
connection.sessionId,
{
type: 'input',
data,
clientId: connection.id,
timestamp: Date.now(),
},
connection.id // Exclude sender
);
}
private handleResize(connection: ClientConnection, data: { cols: number; rows: number }): void {
this.sessionManager.resizeTerminal(connection.sessionId, data.cols, data.rows);
}
private handleDisconnect(clientId: string): void {
const client = this.clients.get(clientId);
if (!client) return;
this.sessionManager.removeClient(client.connection.sessionId, clientId);
this.clients.delete(clientId);
// Broadcast leave
const session = this.sessionManager.getSession(client.connection.sessionId);
if (session) {
this.broadcastToSession(client.connection.sessionId, {
type: 'presence',
data: {
action: 'leave',
clientId,
totalClients: session.clients.size,
},
timestamp: Date.now(),
});
}
}
private broadcastToSession(
sessionId: string,
message: TerminalMessage,
excludeClientId?: string
): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) return;
const messageStr = JSON.stringify(message);
for (const [clientId, client] of this.clients.entries()) {
if (client.connection.sessionId === sessionId && clientId !== excludeClientId) {
client.ws.send(messageStr);
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

18
multmux/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist"]
}

View File

@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "Jeff Emmett's personal website",
"type": "module",
"workspaces": [
"multmux/packages/*"
],
"scripts": {
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"",
"dev:client": "vite --host 0.0.0.0 --port 5173",
@ -15,7 +18,12 @@
"deploy:pages": "tsc && vite build",
"deploy:worker": "wrangler deploy",
"deploy:worker:dev": "wrangler deploy --config wrangler.dev.toml",
"types": "tsc --noEmit"
"types": "tsc --noEmit",
"multmux:install": "npm install --workspaces",
"multmux:build": "npm run build --workspace=@multmux/server --workspace=@multmux/cli",
"multmux:dev:server": "npm run dev --workspace=@multmux/server",
"multmux:dev:cli": "npm run dev --workspace=@multmux/cli",
"multmux:start": "npm run start --workspace=@multmux/server"
},
"keywords": [],
"author": "Jeff Emmett",