Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Empty file added bin/.gitkeep
Empty file.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -10,6 +10,8 @@
},
"files": [
"dist/",
"bin/",
"scripts/",
"install.sh",
"README.md",
"LICENSE",
Expand All @@ -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",
Expand Down
225 changes: 225 additions & 0 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
36 changes: 21 additions & 15 deletions src/bridge/spawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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);
});

Expand All @@ -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`
);
});

Expand All @@ -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`
);
});

Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down
Loading
Loading