diff --git a/.gitignore b/.gitignore index 5f8d2935f..ebd36a280 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ node_modules/ # Build output dist/ +# Bundled tmux binary (downloaded at install time) +bin/tmux + # TypeScript build info (if enabled later) *.tsbuildinfo diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/package.json b/package.json index 105174391..22ccb0ee6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-relay", - "version": "1.0.9", + "version": "1.0.10", "description": "Real-time agent-to-agent communication system", "type": "module", "main": "dist/index.js", @@ -10,6 +10,8 @@ }, "files": [ "dist/", + "bin/", + "scripts/", "install.sh", "README.md", "LICENSE", @@ -19,7 +21,7 @@ "access": "public" }, "scripts": { - "postinstall": "npm rebuild better-sqlite3", + "postinstall": "npm rebuild better-sqlite3 && node scripts/postinstall.js", "build": "npm run clean && tsc && npm run build:frontend", "build:frontend": "esbuild src/dashboard/frontend/app.ts --bundle --outfile=src/dashboard/public/js/app.js --format=esm --target=es2022 --minify --sourcemap", "postbuild": "cp -r src/dashboard/public dist/dashboard/ && chmod +x dist/cli/index.js", diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 000000000..11e6f8a98 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * Postinstall Script for agent-relay + * + * This script runs after npm install to: + * 1. Rebuild native modules (better-sqlite3) + * 2. Install tmux binary if not available on the system + * + * The tmux binary is installed within the package itself (bin/tmux), + * making it portable and not requiring global installation. + */ + +import { execSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import https from 'node:https'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Get package root directory (parent of scripts/) */ +function getPackageRoot() { + return path.resolve(__dirname, '..'); +} + +/** Installation directory (within the package) */ +function getInstallDir() { + return path.join(getPackageRoot(), 'bin'); +} + +// Colors for console output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + red: '\x1b[31m', +}; + +function info(msg) { + console.log(`${colors.blue}[info]${colors.reset} ${msg}`); +} + +function success(msg) { + console.log(`${colors.green}[success]${colors.reset} ${msg}`); +} + +function warn(msg) { + console.log(`${colors.yellow}[warn]${colors.reset} ${msg}`); +} + +function error(msg) { + console.log(`${colors.red}[error]${colors.reset} ${msg}`); +} + +/** + * Check if tmux is available on the system + */ +function hasSystemTmux() { + try { + execSync('which tmux', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Get platform identifier for tmux-builds + */ +function getPlatformId() { + const platform = os.platform(); + const arch = os.arch(); + + if (platform === 'darwin') { + return arch === 'arm64' ? 'macos-arm64' : 'macos-x86_64'; + } else if (platform === 'linux') { + return arch === 'arm64' ? 'linux-arm64' : 'linux-x86_64'; + } + + return null; +} + +/** + * Download file with redirect support + */ +function downloadFile(url, destPath) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(destPath); + + const request = (currentUrl, redirectCount = 0) => { + if (redirectCount > 5) { + reject(new Error('Too many redirects')); + return; + } + + const urlObj = new URL(currentUrl); + const options = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + headers: { + 'User-Agent': 'agent-relay-installer', + }, + }; + + https + .get(options, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + const location = response.headers.location; + if (location) { + request(location, redirectCount + 1); + return; + } + } + + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }) + .on('error', reject); + }; + + request(url); + }); +} + +/** + * Install tmux binary + */ +async function installTmux() { + const TMUX_VERSION = '3.6a'; + const INSTALL_DIR = getInstallDir(); + const tmuxPath = path.join(INSTALL_DIR, 'tmux'); + + // Check if already installed + if (fs.existsSync(tmuxPath)) { + info('Bundled tmux already installed'); + return true; + } + + const platformId = getPlatformId(); + if (!platformId) { + const platform = os.platform(); + warn(`Unsupported platform: ${platform} ${os.arch()}`); + if (platform === 'win32') { + warn('tmux requires WSL (Windows Subsystem for Linux)'); + warn('Install WSL first, then run: sudo apt install tmux'); + } else { + warn('Please install tmux manually: https://github.com/tmux/tmux/wiki/Installing'); + } + return false; + } + + info(`Installing tmux ${TMUX_VERSION} for ${platformId}...`); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-relay-tmux-')); + const archiveName = `tmux-${TMUX_VERSION}-${platformId}.tar.gz`; + const archivePath = path.join(tmpDir, archiveName); + const downloadUrl = `https://github.com/tmux/tmux-builds/releases/download/v${TMUX_VERSION}/${archiveName}`; + + try { + info('Downloading tmux binary...'); + await downloadFile(downloadUrl, archivePath); + + info('Extracting...'); + execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' }); + + const extractedTmux = path.join(tmpDir, 'tmux'); + if (!fs.existsSync(extractedTmux)) { + throw new Error('tmux binary not found in archive'); + } + + fs.mkdirSync(INSTALL_DIR, { recursive: true }); + fs.copyFileSync(extractedTmux, tmuxPath); + fs.chmodSync(tmuxPath, 0o755); + + success(`Installed tmux to ${tmuxPath}`); + return true; + } catch (err) { + error(`Failed to install tmux: ${err.message}`); + warn('Please install tmux manually, then reinstall: npm install agent-relay'); + return false; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore + } + } +} + +/** + * Main postinstall routine + */ +async function main() { + // Skip in CI environments where tmux isn't needed + if (process.env.CI === 'true') { + info('Skipping tmux install in CI environment'); + return; + } + + // Check if system tmux is available + if (hasSystemTmux()) { + info('System tmux found'); + return; + } + + // Try to install bundled tmux + await installTmux(); +} + +main().catch((err) => { + // Don't fail the install if tmux installation fails + // User can still install tmux manually + warn(`Postinstall warning: ${err.message}`); +}); diff --git a/src/bridge/spawner.test.ts b/src/bridge/spawner.test.ts index 928361a98..65074e6af 100644 --- a/src/bridge/spawner.test.ts +++ b/src/bridge/spawner.test.ts @@ -33,6 +33,12 @@ vi.mock('../utils/project-namespace.js', () => { }; }); +vi.mock('../utils/tmux-resolver.js', () => { + return { + getTmuxPath: vi.fn(() => 'tmux'), + }; +}); + const execAsyncMock = vi.mocked(execAsync); const sleepMock = vi.mocked(sleep); const escapeForTmuxMock = vi.mocked(escapeForTmux); @@ -66,8 +72,8 @@ describe('AgentSpawner', () => { await spawner.ensureSession(); expect(execAsyncMock).toHaveBeenCalledTimes(2); - expect(execAsyncMock.mock.calls[0][0]).toBe(`tmux has-session -t ${session} 2>/dev/null`); - expect(execAsyncMock.mock.calls[1][0]).toBe(`tmux new-session -d -s ${session} -c "${projectRoot}"`); + expect(execAsyncMock.mock.calls[0][0]).toBe(`"tmux" has-session -t ${session} 2>/dev/null`); + expect(execAsyncMock.mock.calls[1][0]).toBe(`"tmux" new-session -d -s ${session} -c "${projectRoot}"`); }); it('does nothing when tmux session already exists', async () => { @@ -77,7 +83,7 @@ describe('AgentSpawner', () => { await spawner.ensureSession(); expect(execAsyncMock).toHaveBeenCalledTimes(1); - expect(execAsyncMock).toHaveBeenCalledWith(`tmux has-session -t ${session} 2>/dev/null`); + expect(execAsyncMock).toHaveBeenCalledWith(`"tmux" has-session -t ${session} 2>/dev/null`); }); it('spawns a worker and tracks it', async () => { @@ -99,12 +105,12 @@ describe('AgentSpawner', () => { window: `${session}:Dev1`, }); expect(spawner.hasWorker('Dev1')).toBe(true); - expect(execAsyncMock).toHaveBeenNthCalledWith(1, `tmux has-session -t ${session} 2>/dev/null`); - expect(execAsyncMock).toHaveBeenNthCalledWith(2, `tmux new-window -t ${session} -n Dev1 -c "${projectRoot}"`); + expect(execAsyncMock).toHaveBeenNthCalledWith(1, `"tmux" has-session -t ${session} 2>/dev/null`); + expect(execAsyncMock).toHaveBeenNthCalledWith(2, `"tmux" new-window -t ${session} -n Dev1 -c "${projectRoot}"`); expect(execAsyncMock).toHaveBeenNthCalledWith(3, 'which agent-relay'); // Find full path - expect(execAsyncMock).toHaveBeenNthCalledWith(4, `tmux send-keys -t ${session}:Dev1 'unset TMUX && /usr/local/bin/agent-relay -n Dev1 -- claude --dangerously-skip-permissions' Enter`); - expect(execAsyncMock).toHaveBeenNthCalledWith(5, `tmux send-keys -t ${session}:Dev1 -l "escaped:Finish the report"`); - expect(execAsyncMock).toHaveBeenNthCalledWith(6, `tmux send-keys -t ${session}:Dev1 Enter`); + expect(execAsyncMock).toHaveBeenNthCalledWith(4, `"tmux" send-keys -t ${session}:Dev1 'unset TMUX && /usr/local/bin/agent-relay -n Dev1 -- claude --dangerously-skip-permissions' Enter`); + expect(execAsyncMock).toHaveBeenNthCalledWith(5, `"tmux" send-keys -t ${session}:Dev1 -l "escaped:Finish the report"`); + expect(execAsyncMock).toHaveBeenNthCalledWith(6, `"tmux" send-keys -t ${session}:Dev1 Enter`); expect(sleepMock).toHaveBeenCalledWith(100); }); @@ -124,7 +130,7 @@ describe('AgentSpawner', () => { // Check that the command includes --dangerously-skip-permissions for claude:opus expect(execAsyncMock).toHaveBeenNthCalledWith( 4, - `tmux send-keys -t ${session}:Opus1 'unset TMUX && /usr/local/bin/agent-relay -n Opus1 -- claude:opus --dangerously-skip-permissions' Enter` + `"tmux" send-keys -t ${session}:Opus1 'unset TMUX && /usr/local/bin/agent-relay -n Opus1 -- claude:opus --dangerously-skip-permissions' Enter` ); }); @@ -144,7 +150,7 @@ describe('AgentSpawner', () => { // Check that the command does NOT include --dangerously-skip-permissions for codex expect(execAsyncMock).toHaveBeenNthCalledWith( 4, - `tmux send-keys -t ${session}:Codex1 'unset TMUX && /usr/local/bin/agent-relay -n Codex1 -- codex' Enter` + `"tmux" send-keys -t ${session}:Codex1 'unset TMUX && /usr/local/bin/agent-relay -n Codex1 -- codex' Enter` ); }); @@ -201,7 +207,7 @@ describe('AgentSpawner', () => { expect(result.success).toBe(false); expect(result.error).toContain('failed to register'); - expect(execAsyncMock).toHaveBeenCalledWith(`tmux kill-window -t ${session}:Late`); + expect(execAsyncMock).toHaveBeenCalledWith(`"tmux" kill-window -t ${session}:Late`); expect(spawner.hasWorker('Late')).toBe(false); }); @@ -223,8 +229,8 @@ describe('AgentSpawner', () => { expect(result).toBe(true); expect(spawner.hasWorker('Worker')).toBe(false); - expect(execAsyncMock).toHaveBeenNthCalledWith(1, `tmux send-keys -t ${session}:Worker '/exit' Enter`); - expect(execAsyncMock).toHaveBeenNthCalledWith(2, `tmux kill-window -t ${session}:Worker`); + expect(execAsyncMock).toHaveBeenNthCalledWith(1, `"tmux" send-keys -t ${session}:Worker '/exit' Enter`); + expect(execAsyncMock).toHaveBeenNthCalledWith(2, `"tmux" kill-window -t ${session}:Worker`); expect(sleepMock).toHaveBeenCalledWith(2000); }); @@ -256,8 +262,8 @@ describe('AgentSpawner', () => { expect(result).toBe(true); expect(spawner.hasWorker('Failing')).toBe(false); expect(execAsyncMock).toHaveBeenCalledTimes(2); - expect(execAsyncMock.mock.calls[0][0]).toBe(`tmux send-keys -t ${session}:Failing '/exit' Enter`); - expect(execAsyncMock.mock.calls[1][0]).toBe(`tmux kill-window -t ${session}:Failing`); + expect(execAsyncMock.mock.calls[0][0]).toBe(`"tmux" send-keys -t ${session}:Failing '/exit' Enter`); + expect(execAsyncMock.mock.calls[1][0]).toBe(`"tmux" kill-window -t ${session}:Failing`); expect(sleepMock).toHaveBeenCalledWith(2000); }); diff --git a/src/bridge/spawner.ts b/src/bridge/spawner.ts index 473469eb1..977e11b9a 100644 --- a/src/bridge/spawner.ts +++ b/src/bridge/spawner.ts @@ -7,12 +7,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { execAsync, sleep, escapeForTmux } from './utils.js'; import { getProjectPaths } from '../utils/project-namespace.js'; +import { getTmuxPath } from '../utils/tmux-resolver.js'; import type { SpawnRequest, SpawnResult, WorkerInfo } from './types.js'; export class AgentSpawner { private activeWorkers: Map = new Map(); private tmuxSession: string; private agentsPath: string; + private projectRoot: string; + private tmuxPath: string; // Resolved path to tmux binary constructor( projectRoot: string, @@ -22,21 +25,23 @@ export class AgentSpawner { this.projectRoot = paths.projectRoot; this.agentsPath = path.join(paths.teamDir, 'agents.json'); + // Resolve tmux path (will throw TmuxNotFoundError if tmux unavailable) + this.tmuxPath = getTmuxPath(); + // Default session name based on project this.tmuxSession = tmuxSession || 'relay-workers'; } - private projectRoot: string; /** * Ensure the worker tmux session exists */ async ensureSession(): Promise { try { - await execAsync(`tmux has-session -t ${this.tmuxSession} 2>/dev/null`); + await execAsync(`"${this.tmuxPath}" has-session -t ${this.tmuxSession} 2>/dev/null`); } catch { // Session doesn't exist, create it await execAsync( - `tmux new-session -d -s ${this.tmuxSession} -c "${this.projectRoot}"` + `"${this.tmuxPath}" new-session -d -s ${this.tmuxSession} -c "${this.projectRoot}"` ); console.log(`[spawner] Created session ${this.tmuxSession}`); } @@ -64,7 +69,7 @@ export class AgentSpawner { // Create new window for worker const windowName = name; - const newWindowCmd = `tmux new-window -t ${this.tmuxSession} -n ${windowName} -c "${this.projectRoot}"`; + const newWindowCmd = `"${this.tmuxPath}" new-window -t ${this.tmuxSession} -n ${windowName} -c "${this.projectRoot}"`; if (debug) console.log(`[spawner:debug] Creating window: ${newWindowCmd}`); await execAsync(newWindowCmd); @@ -90,7 +95,7 @@ export class AgentSpawner { if (debug) console.log(`[spawner:debug] Agent command: ${cmd}`); // Send the command - const sendCmd = `tmux send-keys -t ${this.tmuxSession}:${windowName} '${cmd}' Enter`; + const sendCmd = `"${this.tmuxPath}" send-keys -t ${this.tmuxSession}:${windowName} '${cmd}' Enter`; if (debug) console.log(`[spawner:debug] Sending: ${sendCmd}`); await execAsync(sendCmd); @@ -100,7 +105,7 @@ export class AgentSpawner { const error = `Worker ${name} failed to register within 30s`; console.error(`[spawner] ${error}`); // Clean up the tmux window to avoid orphaned workers - await execAsync(`tmux kill-window -t ${this.tmuxSession}:${windowName}`).catch(() => {}); + await execAsync(`"${this.tmuxPath}" kill-window -t ${this.tmuxSession}:${windowName}`).catch(() => {}); return { success: false, name, @@ -113,11 +118,11 @@ export class AgentSpawner { const escapedTask = escapeForTmux(task); if (debug) console.log(`[spawner:debug] Injecting task: ${escapedTask.substring(0, 50)}...`); await execAsync( - `tmux send-keys -t ${this.tmuxSession}:${windowName} -l "${escapedTask}"` + `"${this.tmuxPath}" send-keys -t ${this.tmuxSession}:${windowName} -l "${escapedTask}"` ); await sleep(100); await execAsync( - `tmux send-keys -t ${this.tmuxSession}:${windowName} Enter` + `"${this.tmuxPath}" send-keys -t ${this.tmuxSession}:${windowName} Enter` ); } @@ -163,7 +168,7 @@ export class AgentSpawner { try { // Send exit command gracefully await execAsync( - `tmux send-keys -t ${worker.window} '/exit' Enter` + `"${this.tmuxPath}" send-keys -t ${worker.window} '/exit' Enter` ).catch(() => {}); // Wait a bit for graceful shutdown @@ -171,7 +176,7 @@ export class AgentSpawner { // Kill the window await execAsync( - `tmux kill-window -t ${worker.window}` + `"${this.tmuxPath}" kill-window -t ${worker.window}` ).catch(() => {}); this.activeWorkers.delete(name); diff --git a/src/cli/index.ts b/src/cli/index.ts index cff880324..a8573303b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,7 @@ import { config as dotenvConfig } from 'dotenv'; import { Daemon } from '../daemon/server.js'; import { RelayClient } from '../wrapper/client.js'; import { generateAgentName } from '../utils/name-generator.js'; +import { getTmuxPath } from '../utils/tmux-resolver.js'; import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -540,6 +541,34 @@ program console.log(`agent-relay v${VERSION}`); }); +// check-tmux - Check tmux availability (hidden - for diagnostics) +program + .command('check-tmux', { hidden: true }) + .description('Check tmux availability and version') + .action(async () => { + const { resolveTmux, checkTmuxVersion } = await import('../utils/tmux-resolver.js'); + + const info = resolveTmux(); + if (!info) { + console.log('tmux: NOT FOUND'); + console.log(''); + console.log('Install tmux, then reinstall agent-relay:'); + console.log(' brew install tmux # macOS'); + console.log(' apt install tmux # Ubuntu/Debian'); + console.log(' npm install agent-relay # Reinstall to bundle tmux'); + process.exit(1); + } + + console.log(`tmux: ${info.path}`); + console.log(`Version: ${info.version}`); + console.log(`Source: ${info.isBundled ? 'bundled' : 'system'}`); + + const versionCheck = checkTmuxVersion(); + if (!versionCheck.ok) { + console.log(`Warning: tmux ${versionCheck.minimum}+ recommended`); + } + }); + // bridge - Multi-project orchestration program .command('bridge') @@ -839,9 +868,10 @@ program // Kill orphaned sessions let killed = 0; + const tmuxPath = getTmuxPath(); for (const session of orphaned) { try { - await execAsync(`tmux kill-session -t ${session.sessionName}`); + await execAsync(`"${tmuxPath}" kill-session -t ${session.sessionName}`); killed++; console.log(`Killed: ${session.sessionName}`); } catch (err) { @@ -860,7 +890,8 @@ interface RelaySessionInfo { async function discoverRelaySessions(): Promise { try { - const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}"'); + const tmuxPath = getTmuxPath(); + const { stdout } = await execAsync(`"${tmuxPath}" list-sessions -F "#{session_name}"`); const sessionNames = stdout .split('\n') .map(s => s.trim()) @@ -879,7 +910,7 @@ async function discoverRelaySessions(): Promise { let cwd: string | undefined; try { const { stdout: cwdOut } = await execAsync( - `tmux display-message -t ${session.sessionName} -p '#{pane_current_path}'` + `"${tmuxPath}" display-message -t ${session.sessionName} -p '#{pane_current_path}'` ); cwd = cwdOut.trim() || undefined; } catch { @@ -1031,9 +1062,10 @@ program .option('--json', 'Output as JSON') .action(async (options: { json?: boolean }) => { try { + const tmuxPath = getTmuxPath(); // Check if worker session exists try { - await execAsync(`tmux has-session -t ${WORKER_SESSION} 2>/dev/null`); + await execAsync(`"${tmuxPath}" has-session -t ${WORKER_SESSION} 2>/dev/null`); } catch { if (options.json) { console.log(JSON.stringify({ workers: [], session: null })); @@ -1045,7 +1077,7 @@ program // List windows in the worker session const { stdout } = await execAsync( - `tmux list-windows -t ${WORKER_SESSION} -F "#{window_index}|#{window_name}|#{pane_current_command}|#{window_activity}"` + `"${tmuxPath}" list-windows -t ${WORKER_SESSION} -F "#{window_index}|#{window_name}|#{pane_current_command}|#{window_activity}"` ); const workers = stdout @@ -1103,11 +1135,12 @@ program .option('-n, --lines ', 'Number of lines to show', '50') .option('-f, --follow', 'Follow output (like tail -f)') .action(async (name: string, options: { lines?: string; follow?: boolean }) => { + const tmuxPath = getTmuxPath(); const window = `${WORKER_SESSION}:${name}`; try { // Check if window exists - await execAsync(`tmux has-session -t ${window} 2>/dev/null`); + await execAsync(`"${tmuxPath}" has-session -t ${window} 2>/dev/null`); } catch { console.error(`Worker "${name}" not found`); console.log(`Run 'agent-relay workers' to see available workers`); @@ -1122,7 +1155,7 @@ program let lastContent = ''; const poll = async () => { try { - const { stdout } = await execAsync(`tmux capture-pane -t ${window} -p -S -100`); + const { stdout } = await execAsync(`"${tmuxPath}" capture-pane -t ${window} -p -S -100`); if (stdout !== lastContent) { // Print only new lines const newContent = stdout.replace(lastContent, ''); @@ -1149,7 +1182,7 @@ program } else { try { const lines = parseInt(options.lines || '50', 10); - const { stdout } = await execAsync(`tmux capture-pane -t ${window} -p -S -${lines}`); + const { stdout } = await execAsync(`"${tmuxPath}" capture-pane -t ${window} -p -S -${lines}`); console.log(`Output from ${window} (last ${lines} lines):`); console.log('─'.repeat(50)); console.log(stdout || '(empty)'); @@ -1165,11 +1198,12 @@ program .description('Attach to a spawned worker tmux window') .argument('', 'Worker name') .action(async (name: string) => { + const tmuxPath = getTmuxPath(); const window = `${WORKER_SESSION}:${name}`; try { // Check if window exists - await execAsync(`tmux has-session -t ${window} 2>/dev/null`); + await execAsync(`"${tmuxPath}" has-session -t ${window} 2>/dev/null`); } catch { console.error(`Worker "${name}" not found`); console.log(`Run 'agent-relay workers' to see available workers`); @@ -1181,7 +1215,7 @@ program // Spawn tmux attach as a child process with stdio inherited const { spawn } = await import('child_process'); - const child = spawn('tmux', ['attach-session', '-t', window], { + const child = spawn(tmuxPath, ['attach-session', '-t', window], { stdio: 'inherit', }); @@ -1197,11 +1231,12 @@ program .argument('', 'Worker name') .option('--force', 'Skip graceful shutdown, kill immediately') .action(async (name: string, options: { force?: boolean }) => { + const tmuxPath = getTmuxPath(); const window = `${WORKER_SESSION}:${name}`; try { // Check if window exists - await execAsync(`tmux has-session -t ${window} 2>/dev/null`); + await execAsync(`"${tmuxPath}" has-session -t ${window} 2>/dev/null`); } catch { console.error(`Worker "${name}" not found`); console.log(`Run 'agent-relay workers' to see available workers`); @@ -1212,7 +1247,7 @@ program // Try graceful shutdown first console.log(`Sending /exit to ${name}...`); try { - await execAsync(`tmux send-keys -t ${window} '/exit' Enter`); + await execAsync(`"${tmuxPath}" send-keys -t ${window} '/exit' Enter`); // Wait for graceful shutdown await new Promise(r => setTimeout(r, 2000)); } catch { @@ -1222,7 +1257,7 @@ program // Kill the window try { - await execAsync(`tmux kill-window -t ${window}`); + await execAsync(`"${tmuxPath}" kill-window -t ${window}`); console.log(`Killed worker: ${name}`); } catch (err) { console.error(`Failed to kill ${name}:`, (err as Error).message); @@ -1236,9 +1271,10 @@ program .description('Show worker tmux session details') .action(async () => { try { + const tmuxPath = getTmuxPath(); // Check if session exists try { - await execAsync(`tmux has-session -t ${WORKER_SESSION} 2>/dev/null`); + await execAsync(`"${tmuxPath}" has-session -t ${WORKER_SESSION} 2>/dev/null`); } catch { console.log(`Session "${WORKER_SESSION}" does not exist`); console.log('Spawn a worker to create it.'); @@ -1250,14 +1286,14 @@ program // Get session info const { stdout: sessionInfo } = await execAsync( - `tmux display-message -t ${WORKER_SESSION} -p "Created: #{session_created_string}\\nWindows: #{session_windows}\\nAttached: #{?session_attached,yes,no}"` + `"${tmuxPath}" display-message -t ${WORKER_SESSION} -p "Created: #{session_created_string}\\nWindows: #{session_windows}\\nAttached: #{?session_attached,yes,no}"` ); console.log(sessionInfo); // List windows console.log('\nWindows:'); const { stdout: windows } = await execAsync( - `tmux list-windows -t ${WORKER_SESSION} -F " #{window_index}: #{window_name} (#{pane_current_command})"` + `"${tmuxPath}" list-windows -t ${WORKER_SESSION} -F " #{window_index}: #{window_name} (#{pane_current_command})"` ); console.log(windows || ' (none)'); diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 19dd970f4..150bc5be6 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -99,6 +99,20 @@ export async function startDashboard( console.log('__dirname:', __dirname); const publicDir = path.join(__dirname, 'public'); console.log('Public dir:', publicDir); + + // Verify public directory exists and contains expected files + if (!fs.existsSync(publicDir)) { + console.error(`[dashboard] ERROR: Public directory not found: ${publicDir}`); + } else { + const files = fs.readdirSync(publicDir); + console.log('Public dir contents:', files.join(', ')); + if (!files.includes('metrics.html')) { + console.error('[dashboard] WARNING: metrics.html not found in public directory'); + } + if (!files.includes('bridge.html')) { + console.error('[dashboard] WARNING: bridge.html not found in public directory'); + } + } const storage: StorageAdapter | undefined = dbPath ? new SqliteStorageAdapter({ dbPath }) : undefined; @@ -889,12 +903,24 @@ export async function startDashboard( // Metrics view route - serves metrics.html app.get('/metrics', (req, res) => { - res.sendFile(path.join(publicDir, 'metrics.html')); + const filePath = path.join(publicDir, 'metrics.html'); + res.sendFile(filePath, (err) => { + if (err) { + console.error(`[dashboard] Failed to serve metrics.html from ${filePath}:`, err.message); + res.status(404).send('Metrics page not found'); + } + }); }); // Bridge view route - serves bridge.html app.get('/bridge', (req, res) => { - res.sendFile(path.join(publicDir, 'bridge.html')); + const filePath = path.join(publicDir, 'bridge.html'); + res.sendFile(filePath, (err) => { + if (err) { + console.error(`[dashboard] Failed to serve bridge.html from ${filePath}:`, err.message); + res.status(404).send('Bridge page not found'); + } + }); }); // Bridge API endpoint - returns multi-project data diff --git a/src/utils/tmux-resolver.ts b/src/utils/tmux-resolver.ts new file mode 100644 index 000000000..b648fd9ab --- /dev/null +++ b/src/utils/tmux-resolver.ts @@ -0,0 +1,207 @@ +/** + * Tmux Binary Resolver + * + * Locates tmux binary with fallback to bundled version. + * Priority: + * 1. System tmux (in PATH) + * 2. Bundled tmux within the agent-relay package (bin/tmux) + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Get the package root directory (where agent-relay is installed) + * This works whether we're in dist/utils/ or src/utils/ + */ +function getPackageRoot(): string { + // Navigate up from dist/utils or src/utils to package root + return path.resolve(__dirname, '..', '..'); +} + +/** Path where bundled tmux binary is installed (within the package) */ +export function getBundledTmuxDir(): string { + return path.join(getPackageRoot(), 'bin'); +} + +export function getBundledTmuxPath(): string { + return path.join(getBundledTmuxDir(), 'tmux'); +} + +// Legacy exports for backwards compatibility +export const BUNDLED_TMUX_DIR = getBundledTmuxDir(); +export const BUNDLED_TMUX_PATH = getBundledTmuxPath(); + +/** Minimum supported tmux version */ +export const MIN_TMUX_VERSION = '3.0'; + +export interface TmuxInfo { + /** Full path to tmux binary */ + path: string; + /** Version string (e.g., "3.6a") */ + version: string; + /** Whether this is the bundled version */ + isBundled: boolean; +} + +/** + * Check if tmux exists at a given path and get its version + */ +function getTmuxVersion(tmuxPath: string): string | null { + try { + const output = execSync(`"${tmuxPath}" -V`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + // Output format: "tmux 3.6a" or similar + const match = output.trim().match(/tmux\s+(\d+\.\d+\w?)/i); + return match ? match[1] : null; + } catch { + return null; + } +} + +/** + * Find tmux in system PATH + */ +function findSystemTmux(): string | null { + try { + const output = execSync('which tmux', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return output.trim() || null; + } catch { + return null; + } +} + +/** + * Resolve tmux binary path with fallback to bundled version. + * Returns null if tmux is not available. + */ +export function resolveTmux(): TmuxInfo | null { + // 1. Check system tmux first + const systemPath = findSystemTmux(); + if (systemPath) { + const version = getTmuxVersion(systemPath); + if (version) { + return { + path: systemPath, + version, + isBundled: false, + }; + } + } + + // 2. Check bundled tmux (within the package) + const bundledPath = getBundledTmuxPath(); + if (fs.existsSync(bundledPath)) { + const version = getTmuxVersion(bundledPath); + if (version) { + return { + path: bundledPath, + version, + isBundled: true, + }; + } + } + + return null; +} + +/** + * Get the tmux command to use. Throws if tmux is not available. + */ +export function getTmuxPath(): string { + const info = resolveTmux(); + if (!info) { + throw new TmuxNotFoundError(); + } + return info.path; +} + +/** + * Check if tmux is available (either system or bundled) + */ +export function isTmuxAvailable(): boolean { + return resolveTmux() !== null; +} + +/** + * Get platform identifier for downloading binaries + */ +export function getPlatformIdentifier(): string | null { + const platform = os.platform(); + const arch = os.arch(); + + if (platform === 'darwin') { + return arch === 'arm64' ? 'macos-arm64' : 'macos-x86_64'; + } else if (platform === 'linux') { + return arch === 'arm64' ? 'linux-arm64' : 'linux-x86_64'; + } + + // Unsupported platform + return null; +} + +/** + * Error thrown when tmux is not available + */ +export class TmuxNotFoundError extends Error { + constructor() { + const platformInstructions = (() => { + switch (os.platform()) { + case 'darwin': + return ' macOS: brew install tmux'; + case 'linux': + return ' Ubuntu/Debian: sudo apt install tmux\n Fedora: sudo dnf install tmux\n Arch: sudo pacman -S tmux'; + case 'win32': + return ' Windows: tmux requires WSL (Windows Subsystem for Linux)\n Install WSL, then: sudo apt install tmux'; + default: + return ' See: https://github.com/tmux/tmux/wiki/Installing'; + } + })(); + + super( + `tmux is required but not found.\n\nInstall tmux:\n${platformInstructions}\n\nThen reinstall agent-relay: npm install agent-relay` + ); + this.name = 'TmuxNotFoundError'; + } +} + +/** + * Parse version string to compare versions + */ +function parseVersion(version: string): { major: number; minor: number } { + const match = version.match(/(\d+)\.(\d+)/); + if (!match) { + return { major: 0, minor: 0 }; + } + return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) }; +} + +/** + * Check if installed tmux version meets minimum requirements + */ +export function checkTmuxVersion(): { ok: boolean; version: string | null; minimum: string } { + const info = resolveTmux(); + if (!info) { + return { ok: false, version: null, minimum: MIN_TMUX_VERSION }; + } + + const installed = parseVersion(info.version); + const required = parseVersion(MIN_TMUX_VERSION); + + const ok = + installed.major > required.major || + (installed.major === required.major && installed.minor >= required.minor); + + return { ok, version: info.version, minimum: MIN_TMUX_VERSION }; +} diff --git a/src/wrapper/tmux-wrapper.ts b/src/wrapper/tmux-wrapper.ts index 6a554d25d..79c4a01d2 100644 --- a/src/wrapper/tmux-wrapper.ts +++ b/src/wrapper/tmux-wrapper.ts @@ -21,6 +21,7 @@ import { InboxManager } from './inbox.js'; import type { SendPayload, SendMeta } from '../protocol/types.js'; import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js'; import { getProjectPaths } from '../utils/project-namespace.js'; +import { getTmuxPath, TmuxNotFoundError } from '../utils/tmux-resolver.js'; const execAsync = promisify(exec); const escapeRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -130,6 +131,7 @@ export class TmuxWrapper { private receivedMessageIdSet: Set = new Set(); private receivedMessageIdOrder: string[] = []; private readonly MAX_RECEIVED_MESSAGES = 2000; + private tmuxPath: string; // Resolved path to tmux binary (system or bundled) constructor(config: TmuxWrapperConfig) { this.config = { @@ -169,6 +171,9 @@ export class TmuxWrapper { // Session name (one agent per name - starting a duplicate kills the existing one) this.sessionName = `relay-${config.name}`; + // Resolve tmux path early so we fail fast if tmux isn't available + this.tmuxPath = getTmuxPath(); + this.client = new RelayClient({ agentName: config.name, socketPath: config.socketPath, @@ -264,7 +269,7 @@ export class TmuxWrapper { */ private async sessionExists(): Promise { try { - await execAsync(`tmux has-session -t ${this.sessionName} 2>/dev/null`); + await execAsync(`"${this.tmuxPath}" has-session -t ${this.sessionName} 2>/dev/null`); return true; } catch { return false; @@ -290,7 +295,7 @@ export class TmuxWrapper { // Kill any existing session with this name try { - execSync(`tmux kill-session -t ${this.sessionName} 2>/dev/null`); + execSync(`"${this.tmuxPath}" kill-session -t ${this.sessionName} 2>/dev/null`); } catch { // Session doesn't exist, that's fine } @@ -302,7 +307,7 @@ export class TmuxWrapper { // Create tmux session try { - execSync(`tmux new-session -d -s ${this.sessionName} -x ${this.config.cols} -y ${this.config.rows}`, { + execSync(`"${this.tmuxPath}" new-session -d -s ${this.sessionName} -x ${this.config.cols} -y ${this.config.rows}`, { cwd: this.config.cwd ?? process.cwd(), stdio: 'pipe', }); @@ -327,7 +332,7 @@ export class TmuxWrapper { for (const setting of tmuxSettings) { try { - execSync(`tmux ${setting}`, { stdio: 'pipe' }); + execSync(`"${this.tmuxPath}" ${setting}`, { stdio: 'pipe' }); } catch { // Some settings may not be available in older tmux versions } @@ -346,7 +351,7 @@ export class TmuxWrapper { for (const setting of tmuxMouseBindings) { try { - execSync(`tmux ${setting}`, { stdio: 'pipe' }); + execSync(`"${this.tmuxPath}" ${setting}`, { stdio: 'pipe' }); } catch { // Ignore on older tmux versions lacking these key tables } @@ -359,7 +364,7 @@ export class TmuxWrapper { TERM: 'xterm-256color', })) { const escaped = value.replace(/"/g, '\\"'); - execSync(`tmux setenv -t ${this.sessionName} ${key} "${escaped}"`); + execSync(`"${this.tmuxPath}" setenv -t ${this.sessionName} ${key} "${escaped}"`); } // Wait for shell to be ready (look for prompt) @@ -447,7 +452,7 @@ export class TmuxWrapper { try { const { stdout } = await execAsync( // -J joins wrapped lines so long prompts/messages stay intact - `tmux capture-pane -t ${this.sessionName} -p -J 2>/dev/null` + `"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J 2>/dev/null` ); // Check if the last non-empty line looks like a prompt @@ -476,7 +481,7 @@ export class TmuxWrapper { * This spawns tmux attach and lets it take over stdin/stdout */ private attachToSession(): void { - this.attachProcess = spawn('tmux', ['attach-session', '-t', this.sessionName], { + this.attachProcess = spawn(this.tmuxPath, ['attach-session', '-t', this.sessionName], { stdio: 'inherit', // User's terminal connects directly to tmux }); @@ -522,7 +527,7 @@ export class TmuxWrapper { // Capture scrollback const { stdout } = await execAsync( // -J joins wrapped lines to avoid truncating ->relay commands mid-line - `tmux capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null` + `"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null` ); // Always parse the FULL capture for ->relay commands @@ -1053,7 +1058,7 @@ export class TmuxWrapper { * Send special keys to tmux */ private async sendKeys(keys: string): Promise { - await execAsync(`tmux send-keys -t ${this.sessionName} ${keys}`); + await execAsync(`"${this.tmuxPath}" send-keys -t ${this.sessionName} ${keys}`); } /** @@ -1069,7 +1074,7 @@ export class TmuxWrapper { .replace(/\$/g, '\\$') .replace(/`/g, '\\`') .replace(/!/g, '\\!'); - await execAsync(`tmux send-keys -t ${this.sessionName} -l "${escaped}"`); + await execAsync(`"${this.tmuxPath}" send-keys -t ${this.sessionName} -l "${escaped}"`); } /** @@ -1088,12 +1093,12 @@ export class TmuxWrapper { // Set tmux buffer then paste // Skip bracketed paste (-p) for CLIs that don't handle it properly (droid, other) - await execAsync(`tmux set-buffer -- "${escaped}"`); + await execAsync(`"${this.tmuxPath}" set-buffer -- "${escaped}"`); const useBracketedPaste = this.cliType === 'claude' || this.cliType === 'codex' || this.cliType === 'gemini'; if (useBracketedPaste) { - await execAsync(`tmux paste-buffer -t ${this.sessionName} -p`); + await execAsync(`"${this.tmuxPath}" paste-buffer -t ${this.sessionName} -p`); } else { - await execAsync(`tmux paste-buffer -t ${this.sessionName}`); + await execAsync(`"${this.tmuxPath}" paste-buffer -t ${this.sessionName}`); } } @@ -1131,7 +1136,7 @@ export class TmuxWrapper { private async getLastLine(): Promise { try { const { stdout } = await execAsync( - `tmux capture-pane -t ${this.sessionName} -p -J 2>/dev/null` + `"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J 2>/dev/null` ); const lines = stdout.split('\n').filter(l => l.length > 0); return lines[lines.length - 1] || ''; @@ -1179,7 +1184,7 @@ export class TmuxWrapper { private async getCursorX(): Promise { try { const { stdout } = await execAsync( - `tmux display-message -t ${this.sessionName} -p "#{cursor_x}" 2>/dev/null` + `"${this.tmuxPath}" display-message -t ${this.sessionName} -p "#{cursor_x}" 2>/dev/null` ); return parseInt(stdout.trim(), 10) || 0; } catch { @@ -1239,7 +1244,7 @@ export class TmuxWrapper { private async capturePaneSignature(): Promise { try { const { stdout } = await execAsync( - `tmux capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null` + `"${this.tmuxPath}" capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null` ); const hash = crypto.createHash('sha1').update(stdout).digest('hex'); return `${stdout.length}:${hash}`; @@ -1297,7 +1302,7 @@ export class TmuxWrapper { // Kill tmux session try { - execSync(`tmux kill-session -t ${this.sessionName} 2>/dev/null`); + execSync(`"${this.tmuxPath}" kill-session -t ${this.sessionName} 2>/dev/null`); } catch { // Ignore }