165 lines
5.1 KiB
TypeScript
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>;
|