aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
328 lines (283 loc) • 9.79 kB
JavaScript
/**
* tmux Manager - Session management for the daemon REPL
*
* Creates and manages tmux sessions with a 2-pane layout:
* - Top pane (70%): Interactive chat REPL
* - Bottom pane (30%): Status dashboard
*
* @implements @.aiwg/requirements/use-cases/UC-TMUX-001.md
* @tests @test/unit/daemon/tmux-manager.test.js
*/
import { execSync, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
export class TmuxManager extends EventEmitter {
constructor(options = {}) {
super();
this.sessionName = options.sessionName || 'aiwg-daemon';
this.chatPaneRatio = options.chatPaneRatio || 70;
this.daemonScript = options.daemonScript || null;
this.statusRefreshMs = options.statusRefreshMs || 5000;
this.statusTimer = null;
}
/**
* Check if tmux is installed and available
*/
isTmuxAvailable() {
try {
execSync('tmux -V', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Check if session already exists
*/
sessionExists() {
try {
execSync(`tmux has-session -t ${this.sessionName} 2>/dev/null`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Create a new tmux session with 2-pane layout
* @param {object} options - Creation options
* @param {string} options.chatCommand - Command for the chat pane
* @param {string} options.statusCommand - Command for the status pane
*/
createSession(options = {}) {
if (!this.isTmuxAvailable()) {
throw new Error('tmux is not installed. Install with: apt install tmux');
}
if (this.sessionExists()) {
throw new Error(`Session "${this.sessionName}" already exists. Use attach() instead.`);
}
const chatCommand = options.chatCommand || 'echo "Chat pane ready"';
const statusCommand = options.statusCommand || 'echo "Status pane ready"';
try {
// Create session in detached mode with the chat command
execSync(
`tmux new-session -d -s ${this.sessionName} -x 200 -y 50 '${this._escapeShell(chatCommand)}'`,
{ stdio: 'pipe' }
);
// Split horizontally for status pane (bottom)
const statusPercent = 100 - this.chatPaneRatio;
execSync(
`tmux split-window -t ${this.sessionName} -v -p ${statusPercent} '${this._escapeShell(statusCommand)}'`,
{ stdio: 'pipe' }
);
// Select the top pane (chat) as active
execSync(`tmux select-pane -t ${this.sessionName}:0.0`, { stdio: 'pipe' });
this.emit('session:created', { sessionName: this.sessionName });
return true;
} catch (err) {
throw new Error(`Failed to create tmux session: ${err.message}`);
}
}
/**
* Attach to the existing session
* @param {boolean} readonly - Attach in read-only mode
*/
attach(readonly = false) {
if (!this.sessionExists()) {
throw new Error(`Session "${this.sessionName}" does not exist. Use createSession() first.`);
}
const readonlyFlag = readonly ? ' -r' : '';
// Use spawn to attach interactively
const proc = spawn('tmux', ['attach-session', '-t', this.sessionName, ...(readonly ? ['-r'] : [])], {
stdio: 'inherit',
});
return new Promise((resolve, reject) => {
proc.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`tmux attach exited with code ${code}`));
}
});
proc.on('error', (err) => {
reject(new Error(`Failed to attach to tmux: ${err.message}`));
});
});
}
/**
* Send text to a specific pane
* @param {number} pane - Pane index (0 = chat, 1 = status)
* @param {string} text - Text to send
* @param {boolean} enter - Whether to send Enter after text
*/
sendToPane(pane, text, enter = true) {
if (!this.sessionExists()) {
throw new Error(`Session "${this.sessionName}" does not exist`);
}
const target = `${this.sessionName}:0.${pane}`;
const enterSuffix = enter ? ' Enter' : '';
try {
execSync(
`tmux send-keys -t ${target} ${this._escapeForTmux(text)}${enterSuffix}`,
{ stdio: 'pipe' }
);
} catch (err) {
throw new Error(`Failed to send to pane ${pane}: ${err.message}`);
}
}
/**
* Update the status pane content
* @param {string} content - Status text to display
*/
updateStatusPane(content) {
if (!this.sessionExists()) return;
const target = `${this.sessionName}:0.1`;
try {
// Clear pane and write new content
execSync(`tmux send-keys -t ${target} C-c`, { stdio: 'pipe' });
execSync(`tmux send-keys -t ${target} C-l`, { stdio: 'pipe' });
// Write each line
const lines = content.split('\n');
for (const line of lines) {
execSync(
`tmux send-keys -t ${target} ${this._escapeForTmux(line)} Enter`,
{ stdio: 'pipe' }
);
}
} catch {
// Status update failure is non-critical
}
}
/**
* Start auto-refreshing the status pane
* @param {function} statusFn - Function that returns status text
*/
startStatusRefresh(statusFn) {
this.stopStatusRefresh();
this.statusTimer = setInterval(() => {
try {
const content = statusFn();
this.updateStatusPane(content);
} catch {
// Non-critical
}
}, this.statusRefreshMs);
}
/**
* Stop auto-refreshing the status pane
*/
stopStatusRefresh() {
if (this.statusTimer) {
clearInterval(this.statusTimer);
this.statusTimer = null;
}
}
/**
* Kill the tmux session
*/
killSession() {
this.stopStatusRefresh();
if (!this.sessionExists()) return;
try {
execSync(`tmux kill-session -t ${this.sessionName}`, { stdio: 'pipe' });
this.emit('session:killed', { sessionName: this.sessionName });
} catch (err) {
throw new Error(`Failed to kill session: ${err.message}`);
}
}
/**
* List all tmux sessions
*/
listSessions() {
try {
const output = execSync('tmux list-sessions -F "#{session_name}:#{session_windows}:#{session_attached}"', {
stdio: 'pipe',
encoding: 'utf8',
});
return output
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
const [name, windows, attached] = line.split(':');
return { name, windows: parseInt(windows, 10), attached: attached === '1' };
});
} catch {
return [];
}
}
/**
* Get pane info for the session
*/
getPaneInfo() {
if (!this.sessionExists()) return null;
try {
const output = execSync(
`tmux list-panes -t ${this.sessionName} -F "#{pane_index}:#{pane_width}x#{pane_height}:#{pane_active}"`,
{ stdio: 'pipe', encoding: 'utf8' }
);
return output
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
const [index, size, active] = line.split(':');
const [width, height] = size.split('x');
return {
index: parseInt(index, 10),
width: parseInt(width, 10),
height: parseInt(height, 10),
active: active === '1',
};
});
} catch {
return null;
}
}
// --- Private helpers ---
_escapeShell(str) {
return str.replace(/'/g, "'\\''");
}
_escapeForTmux(str) {
// Wrap in quotes for tmux send-keys
return `"${str.replace(/"/g, '\\"')}"`;
}
}
/**
* Generate a status dashboard string
* @param {object} supervisor - AgentSupervisor instance
* @param {object} replChat - REPLChat instance
*/
export function formatStatusDashboard(supervisor, replChat) {
const lines = [];
const now = new Date().toISOString().slice(11, 19);
lines.push(`╔════════════════════════════════════════╗`);
lines.push(`║ AIWG Daemon Status ${now} ║`);
lines.push(`╠════════════════════════════════════════╣`);
if (supervisor) {
const status = supervisor.getStatus();
lines.push(`║ Agents: ${status.running} running / ${status.queued} queued `);
lines.push(`║ Max concurrent: ${status.maxConcurrency} `);
if (status.tasks.running.length > 0) {
lines.push(`╠────────────────────────────────────────╣`);
lines.push(`║ Running Tasks: `);
for (const t of status.tasks.running) {
const prompt = t.prompt.slice(0, 35).padEnd(35);
lines.push(`║ ${t.id}: ${prompt}`);
}
}
if (status.tasks.queued.length > 0) {
lines.push(`╠────────────────────────────────────────╣`);
lines.push(`║ Queued Tasks: `);
for (const t of status.tasks.queued) {
const prompt = t.prompt.slice(0, 35).padEnd(35);
lines.push(`║ ${t.id}: ${prompt}`);
}
}
}
if (replChat) {
lines.push(`╠────────────────────────────────────────╣`);
lines.push(`║ Chat: ${replChat.isProcessing ? 'Processing...' : 'Ready'} Queue: ${replChat.queueSize} `);
lines.push(`║ History: ${replChat.getHistory().length} messages `);
}
lines.push(`╚════════════════════════════════════════╝`);
return lines.join('\n');
}