mcp-tmux-server
Version:
MCP server for TMUX terminal multiplexer operations
350 lines (297 loc) • 11.6 kB
text/typescript
import { readFile, writeFile, mkdir, readdir, stat, unlink } from 'fs/promises';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { TmuxController } from './tmux-tools.js';
export interface AgentMessage {
id: string;
from: string;
to: string;
type: 'command' | 'message' | 'response';
content: string;
timestamp: string;
sessionInfo?: {
session: string;
window?: string;
pane?: string;
};
}
export interface AgentInfo {
id: string;
name: string;
session: string;
lastSeen: string;
status: 'online' | 'offline';
}
export class AgentCommunicationManager {
private agentId: string;
private agentName: string;
private messagesDir: string;
private agentsDir: string;
private tmux: TmuxController;
constructor(agentName: string) {
this.agentId = randomUUID();
this.agentName = agentName;
this.messagesDir = join(process.env.HOME || '/tmp', '.mcp-tmux', 'messages');
this.agentsDir = join(process.env.HOME || '/tmp', '.mcp-tmux', 'agents');
this.tmux = new TmuxController();
this.initializeDirectories();
}
private async initializeDirectories(): Promise<void> {
try {
await mkdir(this.messagesDir, { recursive: true });
await mkdir(this.agentsDir, { recursive: true });
} catch (error) {
// Directory already exists, ignore error
}
}
async registerAgent(sessionName: string): Promise<string> {
const agentInfo: AgentInfo = {
id: this.agentId,
name: this.agentName,
session: sessionName,
lastSeen: new Date().toISOString(),
status: 'online'
};
const agentFile = join(this.agentsDir, `${this.agentId}.json`);
await writeFile(agentFile, JSON.stringify(agentInfo, null, 2));
return `Agent "${this.agentName}" registered with ID: ${this.agentId}`;
}
async unregisterAgent(): Promise<string> {
const agentFile = join(this.agentsDir, `${this.agentId}.json`);
try {
await unlink(agentFile);
return `Agent "${this.agentName}" unregistered successfully`;
} catch (error: any) {
return `Failed to unregister agent: ${error.message}`;
}
}
async listActiveAgents(): Promise<AgentInfo[]> {
try {
const files = await readdir(this.agentsDir);
const agents: AgentInfo[] = [];
for (const file of files) {
if (file.endsWith('.json')) {
try {
const content = await readFile(join(this.agentsDir, file), 'utf-8');
const agent: AgentInfo = JSON.parse(content);
// Check if expired (active within 5 minutes)
const lastSeen = new Date(agent.lastSeen);
const now = new Date();
const diffMinutes = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
if (diffMinutes < 5) {
agent.status = 'online';
agents.push(agent);
} else {
agent.status = 'offline';
agents.push(agent);
}
} catch (error) {
// Ignore corrupted files
}
}
}
return agents;
} catch (error) {
return [];
}
}
async getAgentInfo(agentId: string): Promise<AgentInfo | null> {
try {
const agentFile = join(this.agentsDir, `${agentId}.json`);
const content = await readFile(agentFile, 'utf-8');
return JSON.parse(content);
} catch (error) {
return null;
}
}
async sendMessage(targetAgentId: string, content: string, type: 'command' | 'message' = 'message'): Promise<string> {
const message: AgentMessage = {
id: randomUUID(),
from: this.agentId,
to: targetAgentId,
type,
content,
timestamp: new Date().toISOString()
};
const messageFile = join(this.messagesDir, `${message.id}.json`);
await writeFile(messageFile, JSON.stringify(message, null, 2));
// If sending a message, try to send directly to target agent's tmux window
if (type === 'message') {
const targetAgent = await this.getAgentInfo(targetAgentId);
if (targetAgent && targetAgent.session) {
try {
// Send message to tmux window
const target = `${targetAgent.session}:0`; // Assume agent is in window 0
await this.tmux.sendKeys(target, content, false); // Send message content first, without Enter
// Wait 0.5 seconds for UI to register (reference: send-claude-message.sh)
await new Promise(resolve => setTimeout(resolve, 500));
// Send Enter key to submit message
await this.tmux.sendKeys(target, '', true); // Only send Enter
return `Message sent to agent ${targetAgentId} in tmux session ${targetAgent.session}: ${content}`;
} catch (error: any) {
return `Message saved for agent ${targetAgentId}, but failed to send to tmux: ${error.message}`;
}
}
}
return `Message sent to agent ${targetAgentId}: ${content}`;
}
async sendCommandToAgent(targetAgentId: string, command: string, sessionInfo?: { session: string; window?: string; pane?: string }): Promise<string> {
const message: AgentMessage = {
id: randomUUID(),
from: this.agentId,
to: targetAgentId,
type: 'command',
content: command,
timestamp: new Date().toISOString(),
sessionInfo
};
const messageFile = join(this.messagesDir, `${message.id}.json`);
await writeFile(messageFile, JSON.stringify(message, null, 2));
// If there's session info, send directly to tmux
if (sessionInfo) {
try {
const target = sessionInfo.pane || `${sessionInfo.session}:${sessionInfo.window || 0}`;
// Send command content first, without Enter
await this.tmux.sendKeys(target, command, false);
// Wait 0.5 seconds for UI to register (reference: send-claude-message.sh)
await new Promise(resolve => setTimeout(resolve, 500));
// Send Enter key to submit command
await this.tmux.sendKeys(target, '', true);
return `Command sent to agent ${targetAgentId} and executed in tmux: ${command}`;
} catch (error: any) {
return `Command queued for agent ${targetAgentId}, but failed to execute in tmux: ${error.message}`;
}
}
// If no session info, try to get from agent info
const targetAgent = await this.getAgentInfo(targetAgentId);
if (targetAgent && targetAgent.session) {
try {
const target = `${targetAgent.session}:0`; // Assume agent is in window 0
// Send command content first, without Enter
await this.tmux.sendKeys(target, command, false);
// Wait 0.5 seconds for UI to register
await new Promise(resolve => setTimeout(resolve, 500));
// Send Enter key to submit command
await this.tmux.sendKeys(target, '', true);
return `Command sent to agent ${targetAgentId} in tmux session ${targetAgent.session}: ${command}`;
} catch (error: any) {
return `Command queued for agent ${targetAgentId}, but failed to send to tmux: ${error.message}`;
}
}
return `Command queued for agent ${targetAgentId}: ${command}`;
}
async getIncomingMessages(): Promise<AgentMessage[]> {
try {
const files = await readdir(this.messagesDir);
const messages: AgentMessage[] = [];
for (const file of files) {
if (file.endsWith('.json')) {
try {
const content = await readFile(join(this.messagesDir, file), 'utf-8');
const message: AgentMessage = JSON.parse(content);
if (message.to === this.agentId) {
messages.push(message);
}
} catch (error) {
// Ignore corrupted files
}
}
}
// Sort by time
return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
} catch (error) {
return [];
}
}
async processIncomingCommands(): Promise<string[]> {
const messages = await this.getIncomingMessages();
const results: string[] = [];
for (const message of messages) {
if (message.type === 'command') {
try {
// Execute command (can be integrated with tmux or other execution environment)
const result = `Processed command from ${message.from}: ${message.content}`;
results.push(result);
// Send response
await this.sendMessage(message.from, `Command executed: ${message.content}`, 'message');
// Delete processed message
await this.deleteMessage(message.id);
} catch (error: any) {
results.push(`Failed to process command: ${error.message}`);
}
}
}
return results;
}
async deleteMessage(messageId: string): Promise<void> {
try {
const messageFile = join(this.messagesDir, `${messageId}.json`);
await unlink(messageFile);
} catch (error) {
// File may have been deleted, ignore error
}
}
async clearOldMessages(hoursOld: number = 24): Promise<string> {
try {
const files = await readdir(this.messagesDir);
let deletedCount = 0;
const cutoffTime = new Date(Date.now() - hoursOld * 60 * 60 * 1000);
for (const file of files) {
if (file.endsWith('.json')) {
const filePath = join(this.messagesDir, file);
const stats = await stat(filePath);
if (stats.mtime < cutoffTime) {
await unlink(filePath);
deletedCount++;
}
}
}
return `Deleted ${deletedCount} old messages`;
} catch (error: any) {
return `Failed to clear old messages: ${error.message}`;
}
}
async getMessageHistory(targetAgentId?: string, limit: number = 50): Promise<AgentMessage[]> {
try {
const files = await readdir(this.messagesDir);
const messages: AgentMessage[] = [];
for (const file of files) {
if (file.endsWith('.json')) {
try {
const content = await readFile(join(this.messagesDir, file), 'utf-8');
const message: AgentMessage = JSON.parse(content);
const isRelevant = !targetAgentId ||
message.from === targetAgentId ||
message.to === targetAgentId ||
message.from === this.agentId ||
message.to === this.agentId;
if (isRelevant) {
messages.push(message);
}
} catch (error) {
// Ignore corrupted files
}
}
}
// Sort by time and limit quantity
return messages
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, limit);
} catch (error) {
return [];
}
}
async updateHeartbeat(): Promise<void> {
const agentFile = join(this.agentsDir, `${this.agentId}.json`);
try {
const content = await readFile(agentFile, 'utf-8');
const agentInfo: AgentInfo = JSON.parse(content);
agentInfo.lastSeen = new Date().toISOString();
agentInfo.status = 'online';
await writeFile(agentFile, JSON.stringify(agentInfo, null, 2));
} catch (error) {
// If file doesn't exist, re-register
// Can handle re-registration logic here
}
}
}