agent-orchestrator/packages/plugins/runtime-tmux/src/index.ts

165 lines
5.1 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { setTimeout as sleep } from "node:timers/promises";
import { randomUUID } from "node:crypto";
import { writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
PluginModule,
Runtime,
RuntimeCreateConfig,
RuntimeHandle,
RuntimeMetrics,
AttachInfo,
} from "@composio/ao-core";
const execFileAsync = promisify(execFile);
export const manifest = {
name: "tmux",
slot: "runtime" as const,
description: "Runtime plugin: tmux sessions",
version: "0.1.0",
};
/** Only allow safe characters in session IDs */
const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/;
function assertValidSessionId(id: string): void {
if (!SAFE_SESSION_ID.test(id)) {
throw new Error(`Invalid session ID "${id}": must match ${SAFE_SESSION_ID}`);
}
}
/** Run a tmux command and return stdout */
async function tmux(...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("tmux", args);
return stdout.trimEnd();
}
export function create(): Runtime {
return {
name: "tmux",
async create(config: RuntimeCreateConfig): Promise<RuntimeHandle> {
assertValidSessionId(config.sessionId);
const sessionName = config.sessionId;
// Build environment flags: -e KEY=VALUE for each env var
const envArgs: string[] = [];
for (const [key, value] of Object.entries(config.environment ?? {})) {
envArgs.push("-e", `${key}=${value}`);
}
// Create tmux session in detached mode
await tmux("new-session", "-d", "-s", sessionName, "-c", config.workspacePath, ...envArgs);
// Send the launch command — clean up the session if this fails
try {
await tmux("send-keys", "-t", sessionName, config.launchCommand, "Enter");
} catch (err: unknown) {
try {
await tmux("kill-session", "-t", sessionName);
} catch {
// Best-effort cleanup
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to send launch command to session "${sessionName}": ${msg}`, {
cause: err,
});
}
return {
id: sessionName,
runtimeName: "tmux",
data: {
createdAt: Date.now(),
workspacePath: config.workspacePath,
},
};
},
async destroy(handle: RuntimeHandle): Promise<void> {
try {
await tmux("kill-session", "-t", handle.id);
} catch {
// Session may already be dead — that's fine
}
},
async sendMessage(handle: RuntimeHandle, message: string): Promise<void> {
// Clear any partial input
await tmux("send-keys", "-t", handle.id, "C-u");
// For long or multiline messages, use load-buffer + paste-buffer
// Use randomUUID to avoid temp file collisions on concurrent sends
if (message.includes("\n") || message.length > 200) {
const bufferName = `ao-${randomUUID()}`;
const tmpPath = join(tmpdir(), `ao-send-${randomUUID()}.txt`);
writeFileSync(tmpPath, message, { encoding: "utf-8", mode: 0o600 });
try {
await tmux("load-buffer", "-b", bufferName, tmpPath);
await tmux("paste-buffer", "-b", bufferName, "-t", handle.id, "-d");
} finally {
// Clean up temp file and tmux buffer (in case paste-buffer failed
// and the -d flag didn't delete it)
try {
unlinkSync(tmpPath);
} catch {
// ignore cleanup errors
}
try {
await tmux("delete-buffer", "-b", bufferName);
} catch {
// Buffer may already be deleted by -d flag — that's fine
}
}
} else {
// Use -l (literal) so text like "Enter" or "Space" isn't interpreted
// as tmux key names
await tmux("send-keys", "-t", handle.id, "-l", message);
}
// Small delay to let tmux process the pasted text before pressing Enter.
// Without this, Enter can arrive before the text is fully rendered.
await sleep(300);
await tmux("send-keys", "-t", handle.id, "Enter");
},
async getOutput(handle: RuntimeHandle, lines = 50): Promise<string> {
try {
return await tmux("capture-pane", "-t", handle.id, "-p", "-S", `-${lines}`);
} catch {
return "";
}
},
async isAlive(handle: RuntimeHandle): Promise<boolean> {
try {
await tmux("has-session", "-t", handle.id);
return true;
} catch {
return false;
}
},
async getMetrics(handle: RuntimeHandle): Promise<RuntimeMetrics> {
const createdAt = (handle.data.createdAt as number) ?? Date.now();
return {
uptimeMs: Date.now() - createdAt,
};
},
async getAttachInfo(handle: RuntimeHandle): Promise<AttachInfo> {
return {
type: "tmux",
target: handle.id,
command: `tmux attach -t ${handle.id}`,
};
},
};
}
export default { manifest, create } satisfies PluginModule<Runtime>;