168 lines
4.2 KiB
JavaScript
168 lines
4.2 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const TYPE_CHUNK_SIZE = 18;
|
|
const TYPE_CHUNK_DELAY_MS = 22;
|
|
|
|
function shellEscape(value) {
|
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function buildLaunchCommand(command, args, env) {
|
|
const exports = Object.entries(env)
|
|
.filter(([, value]) => value != null && value !== "")
|
|
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
|
|
.join("; ");
|
|
|
|
const commandPart = [command, ...args].map((part) => shellEscape(part)).join(" ");
|
|
return `${exports}; exec ${commandPart}`;
|
|
}
|
|
|
|
async function runTmux(args) {
|
|
return execFileAsync("/opt/homebrew/bin/tmux", args);
|
|
}
|
|
|
|
async function typeLikeHuman(sessionName, text) {
|
|
for (let index = 0; index < text.length; index += TYPE_CHUNK_SIZE) {
|
|
const chunk = text.slice(index, index + TYPE_CHUNK_SIZE);
|
|
if (chunk) {
|
|
await runTmux(["send-keys", "-t", sessionName, "-l", chunk]);
|
|
await wait(TYPE_CHUNK_DELAY_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class PtyService {
|
|
constructor({ cwd, env }) {
|
|
this.cwd = cwd;
|
|
this.env = env;
|
|
this.sessionName = null;
|
|
this.pollTimer = null;
|
|
this.lastSnapshot = "";
|
|
this.onData = null;
|
|
this.onExit = null;
|
|
}
|
|
|
|
async start({ command, args = [], onData, onExit }) {
|
|
if (this.sessionName) {
|
|
throw new Error("PTY session is already running");
|
|
}
|
|
|
|
this.onData = onData;
|
|
this.onExit = onExit;
|
|
this.lastSnapshot = "";
|
|
this.sessionName = `retro_claude_${Date.now()}`;
|
|
const launchCommand = buildLaunchCommand(command, args, this.env);
|
|
|
|
await runTmux([
|
|
"new-session",
|
|
"-d",
|
|
"-s",
|
|
this.sessionName,
|
|
"-c",
|
|
this.cwd,
|
|
"/bin/zsh",
|
|
"-lc",
|
|
launchCommand
|
|
]);
|
|
|
|
this.startPolling();
|
|
}
|
|
|
|
async write(input) {
|
|
if (!this.sessionName) {
|
|
throw new Error("No active PTY session");
|
|
}
|
|
|
|
const normalized = String(input ?? "");
|
|
const chunks = normalized.split(/\r\n|\r|\n/);
|
|
const shouldPressEnterAtEnd = /[\r\n]$/.test(normalized);
|
|
|
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
const chunk = chunks[index];
|
|
if (chunk) {
|
|
await typeLikeHuman(this.sessionName, chunk);
|
|
}
|
|
|
|
if (index < chunks.length - 1) {
|
|
await runTmux(["send-keys", "-t", this.sessionName, "Enter"]);
|
|
await wait(35);
|
|
}
|
|
}
|
|
|
|
if (shouldPressEnterAtEnd) {
|
|
await wait(60);
|
|
await runTmux(["send-keys", "-t", this.sessionName, "Enter"]);
|
|
}
|
|
}
|
|
|
|
resize(cols, rows) {
|
|
void cols;
|
|
void rows;
|
|
}
|
|
|
|
async stop() {
|
|
if (!this.sessionName) {
|
|
return;
|
|
}
|
|
|
|
const sessionName = this.sessionName;
|
|
this.stopPolling();
|
|
this.sessionName = null;
|
|
this.lastSnapshot = "";
|
|
await runTmux(["kill-session", "-t", sessionName]).catch(() => {});
|
|
}
|
|
|
|
isRunning() {
|
|
return Boolean(this.sessionName);
|
|
}
|
|
|
|
startPolling() {
|
|
this.stopPolling();
|
|
this.pollTimer = setInterval(async () => {
|
|
if (!this.sessionName) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await runTmux(["capture-pane", "-p", "-t", this.sessionName, "-S", "-200"]);
|
|
const snapshot = stdout ?? "";
|
|
if (!snapshot || snapshot === this.lastSnapshot) {
|
|
return;
|
|
}
|
|
|
|
const payload = snapshot.startsWith(this.lastSnapshot)
|
|
? snapshot.slice(this.lastSnapshot.length)
|
|
: snapshot;
|
|
|
|
this.lastSnapshot = snapshot;
|
|
this.onData?.(payload);
|
|
} catch (error) {
|
|
const message = String(error.stderr ?? error.message ?? "");
|
|
if (message.includes("can't find session")) {
|
|
const previous = this.sessionName;
|
|
this.stopPolling();
|
|
this.sessionName = null;
|
|
this.lastSnapshot = "";
|
|
this.onExit?.({
|
|
exitCode: 0,
|
|
signal: 0,
|
|
error: previous ? undefined : "tmux session missing"
|
|
});
|
|
} else {
|
|
this.onData?.(`\n[tmux-error] ${message}\n`);
|
|
}
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
stopPolling() {
|
|
if (this.pollTimer) {
|
|
clearInterval(this.pollTimer);
|
|
this.pollTimer = null;
|
|
}
|
|
}
|
|
}
|