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

15
server/bootstrapPrompt.js vendored Normal file
View File

@@ -0,0 +1,15 @@
export function buildBootstrapPrompt() {
return [
'Team agent modunu aktif et.',
'Team icerisindeki roller -> Team Lead: "Mazlum" (erkek), Frontend Developer: "Berkecan" (erkek), Backend Developer: "Simsar" (erkek), UI/UX Designer: "Aybuke" (disi), iOS Developer: "Ive" (erkek) ve takim uyelerine kahveleri getirmesi icin Trainee: "Irgatov" (erkek).',
"Bu takim yapisini aynen koru.",
"Takim ici tum mesajlarda konusan kisi zorunlu olarak ad etiketiyle baslasin.",
"Her cevap yalnizca su formatla baslasin: `Mazlum:` veya `Berkecan:` veya `Simsar:` veya `Aybuke:` veya `Ive:` veya `Irgatov:`.",
"Etiketsiz cevap verme. `Ben`, `Team Lead`, `Frontend Developer`, `UI/UX Designer`, `biz`, `takim olarak` gibi baslangiclar kullanma.",
"Gecerli ornek: `Mazlum: Buradayim.` Gecersiz ornek: `Buradayim.`",
"Kullanici tek bir kisiye seslenirse sadece o kisi cevap versin ve cevabi kendi ad etiketiyle baslatsin.",
"Kullanici tum takima veya genel bir goreve seslenirse once `Mazlum:` cevap versin. Gerekirse digerleri ayri satirlarda kendi ad etiketiyle devam etsin.",
"Her ekip uyesi her mesajda kendi sabit adini kullanir, isim degistirmez.",
"Ilk cevap olarak yalnizca takimin hazir oldugunu ve rollerin aktiflestigini bildir. Bu ilk cevap da `Mazlum:` ile baslasin."
].join(" ");
}

94
server/config.js Normal file
View File

@@ -0,0 +1,94 @@
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
function toBool(value, fallback = false) {
if (value == null) {
return fallback;
}
return String(value).toLowerCase() === "true";
}
export function getActiveApiKey() {
const activeKey = (process.env.ACTIVE_KEY ?? "pro").toLowerCase();
if (activeKey === "lite") {
return process.env.API_KEY_LITE ?? "";
}
return process.env.API_KEY_PRO ?? "";
}
export function getRuntimeConfig() {
const claudeBin = resolveClaudeBinary(process.env.CLAUDE_BIN ?? "claude");
return {
port: Number(process.env.PORT ?? 3001),
nodeEnv: process.env.NODE_ENV ?? "development",
claudeBin,
shell: process.env.CLAUDE_SHELL ?? "/bin/zsh",
workspaceDir: process.env.CLAUDE_WORKSPACE_DIR
? path.resolve(process.env.CLAUDE_WORKSPACE_DIR)
: process.cwd(),
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "",
anthropicModel: process.env.ANTHROPIC_MODEL ?? "",
activeKey: (process.env.ACTIVE_KEY ?? "pro").toLowerCase(),
claudeArgs: process.env.CLAUDE_ARGS?.trim() ? process.env.CLAUDE_ARGS.trim().split(/\s+/) : ["--dangerously-skip-permissions"],
watchLogLimit: Number(process.env.WATCH_LOG_LIMIT ?? 400),
chatChunkLimit: Number(process.env.CHAT_CHUNK_LIMIT ?? 2000),
logToConsole: toBool(process.env.LOG_TO_CONSOLE, true)
};
}
function resolveClaudeBinary(rawValue) {
const candidates = [];
if (rawValue) {
candidates.push(rawValue);
}
candidates.push("/Users/wisecolt-macmini/.local/bin/claude");
candidates.push("/usr/local/bin/claude");
candidates.push("/opt/homebrew/bin/claude");
for (const candidate of candidates) {
if (path.isAbsolute(candidate) && fs.existsSync(candidate)) {
return candidate;
}
}
const pathEntries = String(process.env.PATH ?? "").split(path.delimiter);
for (const entry of pathEntries) {
const candidate = path.join(entry, rawValue);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return rawValue;
}
export function getClaudeEnv(config) {
const cleanEnv = { ...process.env };
delete cleanEnv.ANTHROPIC_AUTH_TOKEN;
return {
...cleanEnv,
ANTHROPIC_API_KEY: getActiveApiKey(),
ANTHROPIC_BASE_URL: config.anthropicBaseUrl,
ANTHROPIC_MODEL: config.anthropicModel,
TERM: "xterm-256color",
COLORTERM: "truecolor"
};
}
export function getPublicRuntimeConfig(config) {
return {
claudeBin: config.claudeBin,
anthropicBaseUrl: config.anthropicBaseUrl,
anthropicModel: config.anthropicModel,
activeKey: config.activeKey,
workspaceDir: config.workspaceDir
};
}

51
server/index.js Normal file
View File

@@ -0,0 +1,51 @@
import express from "express";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { Server } from "socket.io";
import { getPublicRuntimeConfig, getRuntimeConfig } from "./config.js";
import { SessionManager } from "./sessionManager.js";
import { registerSocketHandlers } from "./socketHandlers.js";
const config = getRuntimeConfig();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*"
}
});
const sessionManager = new SessionManager({ io, config });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webDistPath = path.resolve(__dirname, "../web/dist");
app.get("/health", (req, res) => {
res.json({
ok: true,
runtime: getPublicRuntimeConfig(config),
session: sessionManager.getState()
});
});
app.get("/api/session/state", (req, res) => {
res.json({
state: sessionManager.getState(),
logs: sessionManager.getLogSnapshot(),
chat: sessionManager.getChatSnapshot()
});
});
if (config.nodeEnv === "production") {
app.use(express.static(webDistPath));
app.get("*", (req, res) => {
res.sendFile(path.join(webDistPath, "index.html"));
});
}
registerSocketHandlers(io, sessionManager);
server.listen(config.port, () => {
console.log(`Retro console server listening on http://localhost:${config.port}`);
});

40
server/logService.js Normal file
View File

@@ -0,0 +1,40 @@
export class LogService {
constructor(limit = 400, logger = console) {
this.limit = limit;
this.logger = logger;
this.entries = [];
}
createEntry(type, message, meta = {}) {
return {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type,
message,
meta,
ts: new Date().toISOString()
};
}
push(type, message, meta = {}) {
const entry = this.createEntry(type, message, meta);
this.entries.push(entry);
if (this.entries.length > this.limit) {
this.entries.splice(0, this.entries.length - this.limit);
}
if (this.logger && type !== "output") {
this.logger.info(`[${entry.type}] ${entry.message}`);
}
return entry;
}
snapshot() {
return [...this.entries];
}
clear() {
this.entries = [];
}
}

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

234
server/sessionManager.js Normal file
View File

@@ -0,0 +1,234 @@
import stripAnsi from "strip-ansi";
import { buildBootstrapPrompt } from "./bootstrapPrompt.js";
import { LogService } from "./logService.js";
import { PtyService } from "./ptyService.js";
import { getClaudeEnv, getPublicRuntimeConfig } from "./config.js";
import { findMentionedMember } from "./teamConfig.js";
function cleanChunk(value) {
return stripAnsi(value).replace(/\r/g, "");
}
function isLikelyFollowUp(prompt) {
const normalized = String(prompt ?? "").trim();
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
return [
wordCount <= 8,
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|şekersiz|detaylandir|detaylandır|devam|peki|neden|nasil|nasıl)\b/i.test(normalized)
].some(Boolean);
}
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
const explicitTarget = findMentionedMember(prompt);
const targetMember = explicitTarget ?? (lastDirectedMember && isLikelyFollowUp(prompt) ? lastDirectedMember : null);
if (!targetMember) {
return {
targetMember: null,
routedPrompt: [
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
"Bu mesaj genel bir gorev veya genel konusmadir.",
"Cevabi once Mazlum baslatsin.",
"Konusan herkes ad etiketi kullansin.",
"[KULLANICI MESAJI]",
prompt
].join("\n")
};
}
return {
targetMember,
routedPrompt: [
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
`Bu mesaj ${targetMember.name} icindir.`,
`Yalnizca ${targetMember.name} cevap versin.`,
`Cevap \`${targetMember.name}:\` ile baslasin.`,
"[KULLANICI MESAJI]",
prompt
].join("\n")
};
}
export class SessionManager {
constructor({ io, config }) {
this.io = io;
this.config = config;
this.logService = new LogService(config.watchLogLimit, config.logToConsole ? console : null);
this.ptyService = null;
this.chatOutput = "";
this.lastDirectedMember = null;
this.state = {
status: "idle",
startedAt: null,
teamActivated: false,
lastError: null,
runtime: getPublicRuntimeConfig(config)
};
}
getState() {
return {
...this.state,
runtime: getPublicRuntimeConfig(this.config)
};
}
getLogSnapshot() {
return this.logService.snapshot();
}
getChatSnapshot() {
return this.chatOutput;
}
emitState() {
this.io.emit("session:state", this.getState());
}
emitLog(entry) {
this.io.emit("log:entry", entry);
}
emitChat(chunk) {
this.io.emit("chat:chunk", { chunk });
}
addLog(type, message, meta = {}) {
const entry = this.logService.push(type, message, meta);
this.emitLog(entry);
return entry;
}
setState(patch) {
this.state = {
...this.state,
...patch
};
this.emitState();
}
async start() {
if (this.ptyService?.isRunning()) {
throw new Error("Session is already running");
}
this.chatOutput = "";
this.lastDirectedMember = null;
this.logService.clear();
this.io.emit("chat:reset");
this.ptyService = new PtyService({
cwd: this.config.workspaceDir,
env: getClaudeEnv(this.config)
});
this.setState({
status: "starting",
startedAt: new Date().toISOString(),
teamActivated: false,
lastError: null
});
this.addLog("lifecycle", `Starting Claude session in ${this.config.workspaceDir}`);
try {
await this.ptyService.start({
command: this.config.claudeBin,
args: this.config.claudeArgs,
onData: (chunk) => this.handlePtyData(chunk),
onExit: (event) => this.handlePtyExit(event)
});
this.setState({ status: "running" });
this.addLog("lifecycle", `Claude process started with binary ${this.config.claudeBin}`);
} catch (error) {
this.setState({
status: "error",
lastError: error.message
});
this.addLog("error", error.message);
throw error;
}
}
async stop() {
if (!this.ptyService?.isRunning()) {
return;
}
this.addLog("lifecycle", "Stopping Claude session");
await this.ptyService.stop();
this.lastDirectedMember = null;
this.setState({
status: "stopped",
teamActivated: false
});
}
async sendPrompt(prompt) {
if (!this.ptyService?.isRunning()) {
throw new Error("Session is not running");
}
const { routedPrompt, targetMember } = buildRoutedPrompt(prompt, this.lastDirectedMember);
const input = `${routedPrompt}\r`;
this.lastDirectedMember = targetMember ?? null;
this.addLog("input", prompt);
await this.ptyService.write(input);
}
async sendRawPrompt(prompt, meta = {}) {
if (!this.ptyService?.isRunning()) {
throw new Error("Session is not running");
}
const input = `${prompt}\r`;
this.addLog("input", meta.label ?? prompt, meta);
await this.ptyService.write(input);
}
async activateTeam() {
const prompt = buildBootstrapPrompt();
this.lastDirectedMember = null;
await this.sendRawPrompt(prompt, { label: "[bootstrap] Team activation prompt sent" });
this.setState({ teamActivated: true });
this.addLog("system", "Team activation prompt sent");
}
resize({ cols, rows }) {
this.ptyService?.resize(cols, rows);
}
clearLogs() {
this.logService.clear();
this.io.emit("log:snapshot", []);
this.addLog("system", "Watch log cleared");
}
handlePtyData(chunk) {
const clean = cleanChunk(chunk);
if (!clean) {
return;
}
this.chatOutput += clean;
if (this.chatOutput.length > this.config.chatChunkLimit * 20) {
this.chatOutput = this.chatOutput.slice(-this.config.chatChunkLimit * 20);
}
this.emitChat(clean);
this.addLog("output", clean);
}
handlePtyExit(event) {
const exitCode = event?.exitCode ?? 0;
const signal = event?.signal ?? 0;
const detail = event?.error ? `, error=${event.error}` : "";
this.addLog("lifecycle", `Claude session exited (code=${exitCode}, signal=${signal}${detail})`);
this.setState({
status: "stopped",
teamActivated: false
});
this.ptyService = null;
}
}

56
server/socketHandlers.js Normal file
View File

@@ -0,0 +1,56 @@
export function registerSocketHandlers(io, sessionManager) {
io.on("connection", (socket) => {
socket.emit("session:state", sessionManager.getState());
socket.emit("log:snapshot", sessionManager.getLogSnapshot());
socket.emit("chat:snapshot", { content: sessionManager.getChatSnapshot() });
socket.on("session:start", async (payload, callback) => {
try {
await sessionManager.start();
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("session:stop", async (payload, callback) => {
try {
await sessionManager.stop();
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("team:activate", async (payload, callback) => {
try {
await sessionManager.activateTeam();
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("prompt:send", async ({ prompt }, callback) => {
try {
await sessionManager.sendPrompt(prompt);
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("terminal:resize", ({ cols, rows }) => {
sessionManager.resize({ cols, rows });
});
socket.on("logs:clear", (payload, callback) => {
sessionManager.clearLogs();
callback?.({ ok: true });
});
});
}

31
server/teamConfig.js Normal file
View File

@@ -0,0 +1,31 @@
const TEAM_MEMBERS = [
{ id: "mazlum", name: "Mazlum", aliases: ["mazlum", "team lead", "lead"] },
{ id: "berkecan", name: "Berkecan", aliases: ["berkecan", "frontend developer", "frontend"] },
{ id: "simsar", name: "Simsar", aliases: ["simsar", "backend developer", "backend"] },
{ id: "aybuke", name: "Aybuke", aliases: ["aybuke", "aybüke", "ui/ux designer", "designer"] },
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios"] },
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern"] }
];
function normalizeText(value) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
export function findMentionedMember(prompt) {
const normalizedPrompt = normalizeText(prompt);
for (const member of TEAM_MEMBERS) {
for (const alias of member.aliases) {
if (normalizedPrompt.includes(normalizeText(alias))) {
return member;
}
}
}
return null;
}
export { TEAM_MEMBERS };