import express from "express"; import { createServer } from "http"; import { Server as SocketIOServer } from "socket.io"; import * as fs from "fs"; import * as pty from "node-pty"; import * as os from "os"; import * as path from "path"; import { simpleGit } from "simple-git"; import { promisify } from "util"; import { v4 as uuidv4 } from "uuid"; import { PersistedSession, SessionConfig, FileDiff } from "./types"; import { isTerminalReady } from "./terminal-utils"; import cors from "cors"; const execAsync = promisify(require("child_process").exec); // Helper function to expand ~ in paths function expandTilde(filepath: string): string { if (filepath.startsWith('~/') || filepath === '~') { return path.join(os.homedir(), filepath.slice(1)); } return filepath; } const app = express(); const httpServer = createServer(app); const io = new SocketIOServer(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } }); const PORT = process.env.PORT || 3000; // Middleware app.use(cors()); app.use(express.json()); app.use(express.static(".")); // Serve static files from current directory // Store for sessions (simple JSON file store) const activePtyProcesses = new Map(); const mcpPollerPtyProcesses = new Map(); // Track which sockets are connected to each session (multiple clients can connect) const sessionSockets = new Map>(); const STORE_PATH = path.join(os.homedir(), ".fleetcode-store.json"); class SimpleStore { private data: any = {}; constructor() { this.load(); } private load() { try { if (fs.existsSync(STORE_PATH)) { this.data = JSON.parse(fs.readFileSync(STORE_PATH, "utf8")); } } catch (error) { console.error("Error loading store:", error); this.data = {}; } } private save() { try { fs.writeFileSync(STORE_PATH, JSON.stringify(this.data, null, 2), "utf8"); } catch (error) { console.error("Error saving store:", error); } } get(key: string, defaultValue?: any): any { return this.data[key] !== undefined ? this.data[key] : defaultValue; } set(key: string, value: any) { this.data[key] = value; this.save(); } } const store = new SimpleStore(); // Helper functions (same as main.ts) function getPersistedSessions(): PersistedSession[] { return (store as any).get("sessions", []); } function getWorktreeBaseDir(): string { const settings = (store as any).get("terminalSettings"); if (settings && settings.worktreeDir) { return expandTilde(settings.worktreeDir); } return path.join(os.homedir(), "worktrees"); } function savePersistedSessions(sessions: PersistedSession[]) { (store as any).set("sessions", sessions); } function getNextSessionNumber(): number { const sessions = getPersistedSessions(); if (sessions.length === 0) return 1; return Math.max(...sessions.map(s => s.number)) + 1; } function extractProjectMcpConfig(projectDir: string): any { try { const claudeConfigPath = path.join(os.homedir(), ".claude.json"); if (!fs.existsSync(claudeConfigPath)) { return {}; } const claudeConfig = JSON.parse(fs.readFileSync(claudeConfigPath, "utf8")); if (!claudeConfig.projects || !claudeConfig.projects[projectDir]) { return {}; } return claudeConfig.projects[projectDir].mcpServers || {}; } catch (error) { console.error("Error extracting MCP config:", error); return {}; } } function getProjectWorktreeDirName(projectDir: string): string { const baseName = path.basename(projectDir); const worktreesBaseDir = getWorktreeBaseDir(); const candidatePath = path.join(worktreesBaseDir, baseName); if (!fs.existsSync(candidatePath)) { return baseName; } const markerFile = path.join(candidatePath, ".fleetcode-project"); if (fs.existsSync(markerFile)) { const existingProjectPath = fs.readFileSync(markerFile, "utf-8").trim(); if (existingProjectPath === projectDir) { return baseName; } } const crypto = require("crypto"); const hash = crypto.createHash("md5").update(projectDir).digest("hex").substring(0, 6); return `${baseName}-${hash}`; } function writeMcpConfigFile(projectDir: string, mcpServers: any): string | null { try { const projectDirName = getProjectWorktreeDirName(projectDir); const worktreesDir = getWorktreeBaseDir(); if (!fs.existsSync(worktreesDir)) { fs.mkdirSync(worktreesDir, { recursive: true }); } const configFilePath = path.join(worktreesDir, projectDirName, "mcp-config.json"); const configContent = JSON.stringify({ mcpServers }, null, 2); const projectWorktreeDir = path.join(worktreesDir, projectDirName); if (!fs.existsSync(projectWorktreeDir)) { fs.mkdirSync(projectWorktreeDir, { recursive: true }); } fs.writeFileSync(configFilePath, configContent, "utf8"); return configFilePath; } catch (error) { console.error("Error writing MCP config file:", error); return null; } } async function createWorktree( projectDir: string, parentBranch: string, sessionNumber: number, sessionUuid: string, customBranchName?: string ): Promise<{ worktreePath: string; branchName: string }> { const git = simpleGit(projectDir); const projectDirName = getProjectWorktreeDirName(projectDir); const worktreesBaseDir = getWorktreeBaseDir(); const projectWorktreeDir = path.join(worktreesBaseDir, projectDirName); const worktreeName = customBranchName || `session${sessionNumber}`; const worktreePath = path.join(projectWorktreeDir, worktreeName); let branchName: string; if (customBranchName) { branchName = customBranchName; } else { const shortUuid = sessionUuid.split('-')[0]; branchName = `fleetcode/${worktreeName}-${shortUuid}`; } if (!fs.existsSync(projectWorktreeDir)) { fs.mkdirSync(projectWorktreeDir, { recursive: true }); const markerFile = path.join(projectWorktreeDir, ".fleetcode-project"); fs.writeFileSync(markerFile, projectDir, "utf-8"); } if (fs.existsSync(worktreePath)) { try { await git.raw(["worktree", "remove", worktreePath, "--force"]); } catch (error) { console.error("Error removing existing worktree:", error); } } try { await git.raw(["branch", "-D", branchName]); } catch (error) { // Branch doesn't exist } await git.raw(["worktree", "add", "-b", branchName, worktreePath, parentBranch]); return { worktreePath, branchName }; } async function removeWorktree(projectDir: string, worktreePath: string) { const git = simpleGit(projectDir); try { await git.raw(["worktree", "remove", worktreePath, "--force"]); } catch (error) { console.error("Error removing worktree:", error); } } async function removeGitBranch(projectDir: string, branchName: string) { const git = simpleGit(projectDir); try { await git.raw(["branch", "-D", branchName]); } catch (error) { console.error("Error removing git branch:", error); } } function parsePatchToFileDiffs(patch: string): FileDiff[] { const fileDiffs: FileDiff[] = []; // Split patch into individual file chunks // Each chunk starts with "diff --git" const fileChunks = patch.split(/(?=diff --git )/g).filter(chunk => chunk.trim()); for (const chunk of fileChunks) { const lines = chunk.split('\n'); // Extract file paths from "diff --git a/... b/..." const diffGitMatch = lines[0].match(/^diff --git a\/(.+?) b\/(.+?)$/); if (!diffGitMatch) continue; let oldPath = diffGitMatch[1]; let newPath = diffGitMatch[2]; // Determine status and paths let status: FileDiff['status'] = 'modified'; let finalPath = newPath; let finalOldPath: string | undefined; let isBinary = false; // Check for file mode changes and status indicators for (const line of lines) { if (line.startsWith('new file mode')) { status = 'added'; finalOldPath = undefined; } else if (line.startsWith('deleted file mode')) { status = 'deleted'; finalPath = oldPath; finalOldPath = undefined; } else if (line.startsWith('rename from')) { status = 'renamed'; finalOldPath = line.substring('rename from '.length).trim(); } else if (line.startsWith('rename to')) { finalPath = line.substring('rename to '.length).trim(); } else if (line.startsWith('Binary files')) { isBinary = true; } } // Count additions and deletions let additions = 0; let deletions = 0; for (const line of lines) { if (line.startsWith('+') && !line.startsWith('+++')) { additions++; } else if (line.startsWith('-') && !line.startsWith('---')) { deletions++; } } fileDiffs.push({ path: finalPath, oldPath: finalOldPath, status, diff: chunk, additions, deletions, binary: isBinary, }); } return fileDiffs; } // Socket.IO handlers io.on("connection", (socket) => { console.log("Client connected:", socket.id); // Send persisted sessions on connection socket.emit("load-persisted-sessions", getPersistedSessions()); socket.on("create-session", async (config: SessionConfig) => { try { // Validate required fields if (!config.projectDir || !config.parentBranch) { const errorMsg = `Project directory and parent branch are required. Got: projectDir="${config.projectDir}", parentBranch="${config.parentBranch}"`; console.error(errorMsg); socket.emit("session-error", errorMsg); return; } const sessionNumber = getNextSessionNumber(); const sessionId = `session-${Date.now()}`; const sessionName = config.branchName || `Session ${sessionNumber}`; const sessionUuid = uuidv4(); // Expand ~ in project directory path const expandedProjectDir = expandTilde(config.projectDir); const { worktreePath, branchName } = await createWorktree( expandedProjectDir, config.parentBranch, sessionNumber, sessionUuid, config.branchName ); // Update config with expanded path const expandedConfig = { ...config, projectDir: expandedProjectDir }; const mcpServers = extractProjectMcpConfig(expandedProjectDir); const mcpConfigPath = writeMcpConfigFile(expandedProjectDir, mcpServers); const persistedSession: PersistedSession = { id: sessionId, number: sessionNumber, name: sessionName, config: expandedConfig, worktreePath, createdAt: Date.now(), sessionUuid, mcpConfigPath: mcpConfigPath || undefined, gitBranch: branchName, }; // Only persist and spawn PTY after worktree creation succeeds const sessions = getPersistedSessions(); sessions.push(persistedSession); savePersistedSessions(sessions); spawnSessionPty(socket, sessionId, worktreePath, expandedConfig, sessionUuid, true, mcpConfigPath || undefined, expandedProjectDir, sessionName); socket.emit("session-created", sessionId, persistedSession); } catch (error) { console.error("Error creating session:", error); socket.emit("session-error", error instanceof Error ? error.message : String(error)); } }); socket.on("session-input", (sessionId: string, data: string) => { const ptyProcess = activePtyProcesses.get(sessionId); if (ptyProcess) { ptyProcess.write(data); } }); socket.on("session-resize", (sessionId: string, cols: number, rows: number) => { const ptyProcess = activePtyProcesses.get(sessionId); if (ptyProcess) { try { ptyProcess.resize(cols, rows); } catch (error) { console.error(`Error resizing session ${sessionId}:`, error); // Remove dead PTY from map activePtyProcesses.delete(sessionId); } } }); socket.on("get-all-sessions", () => { socket.emit("all-sessions", getPersistedSessions()); }); // Handle invoke requests (for async operations) socket.on("invoke", async (requestId: number, channel: string, ...args: any[]) => { try { let result; switch (channel) { case "get-last-settings": result = store.get("lastSessionConfig", { projectDir: "", parentBranch: "", codingAgent: "claude", skipPermissions: true, }); break; case "get-branches": const dirPath = expandTilde(args[0]); try { const git = simpleGit(dirPath); const branchSummary = await git.branch(); result = branchSummary.all; } catch (error) { result = []; } break; case "select-directory": // Web mode cannot open file dialogs - return null result = null; break; case "get-terminal-settings": result = store.get("terminalSettings"); break; case "save-terminal-settings": store.set("terminalSettings", args[0]); result = null; break; case "list-mcp-servers": // MCP not implemented in web mode yet result = []; break; case "add-mcp-server": case "remove-mcp-server": case "get-mcp-server-details": // MCP not implemented in web mode yet result = null; break; default: throw new Error(`Unknown invoke channel: ${channel}`); } socket.emit("invoke-response", requestId, null, result); } catch (error) { socket.emit("invoke-response", requestId, error instanceof Error ? error.message : String(error), null); } }); socket.on("save-settings", (config: SessionConfig) => { store.set("lastSessionConfig", config); }); socket.on("reopen-session", (sessionId: string) => { if (activePtyProcesses.has(sessionId)) { console.log(`[${sessionId}] Reconnecting existing PTY to new socket`); // Add this socket to the session's socket set if (!sessionSockets.has(sessionId)) { sessionSockets.set(sessionId, new Set()); } sessionSockets.get(sessionId)!.add(socket); socket.emit("session-reopened", sessionId); // Send a newline to trigger fresh output const ptyProcess = activePtyProcesses.get(sessionId); if (ptyProcess) { console.log(`[${sessionId}] Sending newline to trigger output`); ptyProcess.write("\r"); } return; } const sessions = getPersistedSessions(); const session = sessions.find(s => s.id === sessionId); if (!session) { console.error("Session not found:", sessionId); socket.emit("session-error", `Session ${sessionId} not found`); return; } try { spawnSessionPty(socket, sessionId, session.worktreePath, session.config, session.sessionUuid, false, session.mcpConfigPath, session.config.projectDir, session.name); socket.emit("session-reopened", sessionId); } catch (error) { console.error("Error reopening session:", error); socket.emit("session-error", error instanceof Error ? error.message : String(error)); } }); socket.on("close-session", (sessionId: string) => { const ptyProcess = activePtyProcesses.get(sessionId); if (ptyProcess) { // Just kill the PTY connection, tmux session keeps running ptyProcess.kill(); activePtyProcesses.delete(sessionId); console.log(`[${sessionId}] Detached from tmux session (session still running)`); } const mcpPoller = mcpPollerPtyProcesses.get(sessionId); if (mcpPoller) { mcpPoller.kill(); mcpPollerPtyProcesses.delete(sessionId); } }); socket.on("delete-session", async (sessionId: string) => { const ptyProcess = activePtyProcesses.get(sessionId); if (ptyProcess) { ptyProcess.kill(); activePtyProcesses.delete(sessionId); } const mcpPoller = mcpPollerPtyProcesses.get(sessionId); if (mcpPoller) { mcpPoller.kill(); mcpPollerPtyProcesses.delete(sessionId); } const sessions = getPersistedSessions(); const sessionIndex = sessions.findIndex(s => s.id === sessionId); if (sessionIndex === -1) { console.error("Session not found:", sessionId); return; } const session = sessions[sessionIndex]; // Kill the tmux session using the session name const safeName = session.name.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase(); const tmuxSessionName = `fleetcode-${safeName}`; try { require('child_process').execSync(`tmux kill-session -t ${tmuxSessionName} 2>&1`); console.log(`[${sessionId}] Killed tmux session: ${tmuxSessionName}`); } catch (error) { // Session might not exist, that's okay } await removeWorktree(session.config.projectDir, session.worktreePath); if (session.gitBranch) { await removeGitBranch(session.config.projectDir, session.gitBranch); } sessions.splice(sessionIndex, 1); savePersistedSessions(sessions); socket.emit("session-deleted", sessionId); }); socket.on("rename-session", (sessionId: string, newName: string) => { const sessions = getPersistedSessions(); const session = sessions.find(s => s.id === sessionId); if (session) { session.name = newName; savePersistedSessions(sessions); } }); socket.on("get-session-diff", async (sessionId: string) => { try { const sessions = getPersistedSessions(); const session = sessions.find(s => s.id === sessionId); if (!session) { socket.emit("session-diff-error", sessionId, "Session not found"); return; } const git = simpleGit(session.worktreePath); const parentBranch = session.config.parentBranch; console.log(`[${sessionId}] Generating unified diff for session, parent branch: ${parentBranch}`); console.log(`[${sessionId}] Worktree path: ${session.worktreePath}`); // Get unified patch for tracked changes (includes staged, unstaged, and committed) const trackedPatch = await git.raw([ 'diff', parentBranch, '--patch', '--find-renames', '--binary' ]); // Get untracked files const statusSummary = await git.status(); // Generate patches for untracked files using --no-index const untrackedPatches: string[] = []; for (const untrackedFile of statusSummary.not_added) { try { const untrackedPatch = await git.raw([ 'diff', '--no-index', '--binary', '/dev/null', untrackedFile ]); untrackedPatches.push(untrackedPatch); } catch (error: any) { // git diff --no-index exits with code 1 when there are differences, which is expected if (error.message && typeof error.message === 'string') { untrackedPatches.push(error.message); } else { console.error(`[${sessionId}] Error generating patch for untracked file ${untrackedFile}:`, error); } } } // Combine all patches const combinedPatch = [trackedPatch, ...untrackedPatches].filter(p => p).join('\n'); console.log(`[${sessionId}] Combined patch length: ${combinedPatch.length} chars`); console.log(`[${sessionId}] Untracked files: ${statusSummary.not_added.length}`); // Parse the combined patch into FileDiff array const fileDiffs: FileDiff[] = parsePatchToFileDiffs(combinedPatch); console.log(`[${sessionId}] Parsed ${fileDiffs.length} file diffs`); socket.emit("session-diff-data", sessionId, fileDiffs); } catch (error) { console.error("Error getting session diff:", error); socket.emit("session-diff-error", sessionId, error instanceof Error ? error.message : String(error)); } }); socket.on("disconnect", () => { // Clean up socket references for this client // Note: We don't kill the PTYs - they keep running for reconnection for (const [sessionId, sockets] of sessionSockets.entries()) { if (sockets.has(socket)) { sockets.delete(socket); console.log(`[${sessionId}] Socket disconnected, ${sockets.size} remaining`); // If no more sockets connected to this session, clean up the set if (sockets.size === 0) { sessionSockets.delete(sessionId); } } } }); }); function spawnSessionPty( socket: any, sessionId: string, worktreePath: string, config: SessionConfig, sessionUuid: string, isNewSession: boolean, mcpConfigPath?: string, projectDir?: string, sessionName?: string ) { // Use tmux for session persistence and resilience // Use session name (sanitized) or fall back to sessionId const safeName = (sessionName || sessionId) .replace(/[^a-zA-Z0-9-_]/g, '-') .toLowerCase(); const tmuxSessionName = `fleetcode-${safeName}`; // Use zsh (check which shell is available) let shell = "zsh"; try { require('child_process').execSync('which zsh'); } catch { shell = "bash"; } let ptyProcess: pty.IPty; // Check if tmux session already exists let sessionExists = false; try { require('child_process').execSync(`tmux has-session -t ${tmuxSessionName} 2>&1`); sessionExists = true; } catch { sessionExists = false; } if (sessionExists) { console.log(`[${sessionId}] Attaching to existing tmux session: ${tmuxSessionName}`); // Session exists, attach to it ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], { name: "xterm-256color", cols: 80, rows: 30, cwd: worktreePath, env: { ...process.env, TERM: "xterm-256color" }, }); } else { // Session doesn't exist, create new one console.log(`[${sessionId}] Creating new tmux session: ${tmuxSessionName} with ${shell}`); // For zsh, use -l (login) to source .zshrc and load direnv // For bash, use -l as well const shellArgs = shell === "zsh" ? ["-l"] : ["-l"]; ptyProcess = pty.spawn("tmux", [ "new-session", "-s", tmuxSessionName, "-c", worktreePath, shell, ...shellArgs ], { name: "xterm-256color", cols: 80, rows: 30, cwd: worktreePath, env: { ...process.env, TERM: "xterm-256color" }, }); } activePtyProcesses.set(sessionId, ptyProcess); // Initialize socket set for this session if (!sessionSockets.has(sessionId)) { sessionSockets.set(sessionId, new Set()); } sessionSockets.get(sessionId)!.add(socket); // Only run initialization if this is a NEW tmux session // If we're attaching to existing, skip all setup (it's already running) const shouldInitialize = !sessionExists; let terminalReady = !shouldInitialize; // Skip setup if attaching to existing let setupCommandsIdx = 0; let dataBuffer = ""; let direnvAllowed = false; ptyProcess.onData((data) => { // Emit to all connected sockets for this session const sockets = sessionSockets.get(sessionId); if (sockets && sockets.size > 0) { sockets.forEach(sock => { sock.emit("session-output", sessionId, data); }); } else { console.log(`[${sessionId}] Warning: PTY output but no sockets connected`); } if (!terminalReady) { dataBuffer += data; if (isTerminalReady(dataBuffer, 0)) { // First, allow direnv if .envrc exists if (!direnvAllowed && fs.existsSync(path.join(worktreePath, ".envrc"))) { console.log(`[${sessionId}] Allowing direnv for worktree`); ptyProcess.write("direnv allow\r"); direnvAllowed = true; } else if (config.setupCommands && setupCommandsIdx < config.setupCommands.length) { const cmd = config.setupCommands[setupCommandsIdx]; ptyProcess.write(cmd + "\r"); setupCommandsIdx++; } else { terminalReady = true; if (config.codingAgent === "claude") { const sessionFlag = isNewSession ? `--session-id ${sessionUuid}` : `--resume ${sessionUuid}`; const skipPermissionsFlag = config.skipPermissions ? "--dangerously-skip-permissions" : ""; const mcpConfigFlag = mcpConfigPath ? `--mcp-config ${mcpConfigPath}` : ""; const flags = [sessionFlag, skipPermissionsFlag, mcpConfigFlag].filter(f => f).join(" "); const claudeCmd = `claude ${flags}`; ptyProcess.write(claudeCmd + "\r"); } else if (config.codingAgent === "codex") { ptyProcess.write("codex\r"); } else if (config.codingAgent === "amp") { ptyProcess.write("nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp\r"); } } } } }); return ptyProcess; } // Start server const port = typeof PORT === 'string' ? parseInt(PORT, 10) : PORT; httpServer.listen(port, "0.0.0.0", () => { console.log(`🚀 FleetCode server running on http://0.0.0.0:${port}`); console.log(`📡 Access from network: http://:${port}`); });