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; } } }