791 lines
25 KiB
TypeScript
791 lines
25 KiB
TypeScript
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<string, pty.IPty>();
|
|
const mcpPollerPtyProcesses = new Map<string, pty.IPty>();
|
|
// Track which sockets are connected to each session (multiple clients can connect)
|
|
const sessionSockets = new Map<string, Set<any>>();
|
|
|
|
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://<your-ip>:${port}`);
|
|
});
|