import { exec } from 'child_process'
import { promisify } from 'util'
import fs from 'fs'
import path from 'path'
import chalk from 'chalk'
import { SSHManager } from './ssh-manager.js'
const execAsync = promisify(exec)
export class TmuxOrchestrator {
constructor(testName, agentConfigs, options = {}) {
this.testName = testName
this.sessionName = `hyper-lab-${Date.now()}`
this.agents = new Map()
this.paneMap = new Map() // agentName -> pane index
this.logDir = `/tmp/hyper-lab/logs/${this.sessionName}`
// Create log directory
fs.mkdirSync(this.logDir, { recursive: true })
// Initialize SSH managers
for (const config of agentConfigs) {
this.agents.set(config.name, new SSHManager(config))
}
}
async connect() {
console.log(chalk.blue('🔌 Connecting to agents...\n'))
for (const agent of this.agents.values()) {
await agent.connect()
}
}
async createSession() {
console.log(chalk.blue(`🎬 Creating tmux session: ${this.sessionName}\n`))
// Create tmux session (detached)
await execAsync(`tmux new-session -d -s ${this.sessionName} -x 200 -y 50`)
// STEP 1: Create all panes and set up logging
console.log(chalk.blue('📋 Creating panes...'))
let paneIdx = 0
for (const [agentName, agent] of this.agents) {
if (paneIdx > 0) {
// Split horizontally for first split, then vertically
const splitCmd = paneIdx === 1 ? '-h' : '-v'
await execAsync(`tmux split-window ${splitCmd} -t ${this.sessionName}`)
}
// Map agent to pane
this.paneMap.set(agentName, paneIdx)
// Setup pane title
await this.sendKeys(paneIdx, `printf '\\033]2;%s\\033\\\\' '${agentName}'`)
// Enable logging for this pane
const logFile = path.join(this.logDir, `${agentName}.log`)
await execAsync(`tmux pipe-pane -t ${this.sessionName}.${paneIdx} "cat >> ${logFile}"`)
paneIdx++
}
// STEP 2: SSH into remote panes
console.log(chalk.blue('🔌 Establishing SSH connections...\n'))
for (const [agentName, agent] of this.agents) {
const pane = this.paneMap.get(agentName)
// SSH into agent machine (unless localhost)
if (agent.config.host !== 'localhost' && agent.config.host !== '127.0.0.1') {
console.log(chalk.cyan(` Connecting to ${agentName} (${agent.config.host})...`))
const sshCmd = `ssh ${agent.config.user}@${agent.config.host}`
await this.sendKeys(pane, sshCmd)
// Wait for SSH connection to complete
// SSH can take time with login messages, npm warnings, etc.
await this.sleep(4000)
// Verify connection by sending hostname command and checking result
const expectedHost = agent.config.host.split('.')[0]
await this.sendKeys(pane, 'hostname')
await this.sleep(500)
const paneContent = await this.capturePane(pane, 20)
if (!paneContent.includes(expectedHost)) {
console.log(chalk.yellow(` ⚠️ Warning: SSH to ${agentName} may not have connected`))
console.log(chalk.yellow(` Expected hostname: ${expectedHost}`))
console.log(chalk.yellow(` Pane content: ${paneContent.split('\\n').slice(-3).join(' | ')}`))
} else {
console.log(chalk.green(` ✅ Connected to ${agentName} (${expectedHost})`))
}
} else {
console.log(chalk.green(` ✅ ${agentName} (local)`))
}
}
console.log('')
// Select tiled layout for agent panes
await execAsync(`tmux select-layout -t ${this.sessionName} tiled`)
console.log(chalk.green(`✅ Tmux session created: ${this.sessionName}`))
console.log(chalk.cyan(` Attach: tmux attach -t ${this.sessionName}`))
console.log(chalk.cyan(` Logs: ${this.logDir}\n`))
return this.sessionName
}
async sendKeys(pane, command, pressEnter = true) {
const enterKey = pressEnter ? ' Enter' : ''
const target = `${this.sessionName}.${pane}`
// Escape special characters for shell
const escapedCmd = command.replace(/"/g, '\\"')
await execAsync(`tmux send-keys -t ${target} "${escapedCmd}"${enterKey}`)
}
async capturePane(pane, lines = 100) {
const target = `${this.sessionName}.${pane}`
const result = await execAsync(`tmux capture-pane -t ${target} -p -S -${lines}`)
return result.stdout
}
async log(message, color = 'blue') {
const timestamp = new Date().toISOString().substring(11, 19)
// Log to console only (no orchestrator pane)
const chalkColor = chalk[color] || chalk.white
console.log(chalkColor(`[${timestamp}] ${message}`))
}
async run(agentName, command, args = [], options = {}) {
const agent = this.agents.get(agentName)
if (!agent) {
throw new Error(`Unknown agent: ${agentName}`)
}
const pane = this.paneMap.get(agentName)
await this.log(`Running on ${agentName}: ${command} ${args.join(' ')}`, 'blue')
// Build command with isolated storage
const testRunId = this.sessionName
const homeDir = `/tmp/hyper-lab-test-${testRunId}-${agentName}`
const workDir = agent.config.workDir
// Create home dir and run command
const fullCmd = `mkdir -p ${homeDir} && cd ${workDir}/hyper-wallet && HOME=${homeDir} bare hyper-wallet-autobase.cjs ${command} ${args.join(' ')}`
// Send command to pane (visible in tmux)
await this.sendKeys(pane, fullCmd)
// Also execute via SSH to get return status
const result = await agent.exec(fullCmd, { logOutput: false })
if (result.exitCode !== 0 && !options.allowFailure) {
await this.log(`❌ Command failed on ${agentName}`, 'red')
throw new Error(`Command failed: ${command}`)
}
await this.log(`✅ Completed on ${agentName}`, 'green')
return result
}
async waitForOutput(agentName, pattern, timeout = 10000) {
const pane = this.paneMap.get(agentName)
const start = Date.now()
await this.log(`⏳ Waiting for "${pattern}" on ${agentName}...`, 'yellow')
while (Date.now() - start < timeout) {
const content = await this.capturePane(pane, 50)
if (content.includes(pattern)) {
await this.log(`✅ Found "${pattern}" on ${agentName}`, 'green')
return true
}
await this.sleep(500)
}
throw new Error(`Timeout waiting for "${pattern}" on ${agentName}`)
}
async readLog(agentName) {
const logFile = path.join(this.logDir, `${agentName}.log`)
if (!fs.existsSync(logFile)) {
return ''
}
return fs.readFileSync(logFile, 'utf8')
}
async sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
assert(condition, message) {
if (!condition) {
this.log(`❌ Assertion failed: ${message}`, 'red')
throw new Error(`Assertion failed: ${message}`)
}
this.log(`✅ ${message}`, 'green')
}
async cleanup() {
await this.log('🧹 Cleaning up...', 'blue')
// Close SSH connections
for (const agent of this.agents.values()) {
await agent.disconnect().catch(() => {})
}
await this.log('✅ Cleanup complete', 'green')
console.log(chalk.cyan(`\n📋 Logs saved to: ${this.logDir}`))
console.log(chalk.cyan(`🎬 Tmux session: ${this.sessionName} (still running - attach to inspect)\n`))
}
}