feat: retro Claude ekip konsolunu kur

This commit is contained in:
2026-03-16 23:38:15 +03:00
parent 9294028fb2
commit 68d5c2afea
32 changed files with 5207 additions and 0 deletions

167
server/ptyService.js Normal file
View File

@@ -0,0 +1,167 @@
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;
}
}
}