fleetcode/server.ts
Anish Lakhwara f75c054e65 update
2025-11-08 21:58:34 -08:00

892 lines
28 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, MergeStatus, MergeResult } 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("check-merge-status", async (sessionId: string) => {
try {
const sessions = getPersistedSessions();
const session = sessions.find(s => s.id === sessionId);
if (!session) {
socket.emit("merge-status-error", sessionId, "Session not found");
return;
}
const git = simpleGit(session.worktreePath);
const parentBranch = session.config.parentBranch;
const currentBranch = session.gitBranch || (await git.revparse(['--abbrev-ref', 'HEAD']));
// Check how many commits ahead of parent
const revListOutput = await git.raw([
'rev-list',
'--count',
`${parentBranch}..${currentBranch}`
]);
const commitsAhead = parseInt(revListOutput.trim(), 10);
// Check for uncommitted changes
const statusSummary = await git.status();
const hasUncommittedChanges = !statusSummary.isClean();
const mergeStatus: MergeStatus = {
hasCommits: commitsAhead > 0,
commitsAhead,
hasUncommittedChanges
};
console.log(`[${sessionId}] Merge status: ${JSON.stringify(mergeStatus)}`);
socket.emit("merge-status-data", sessionId, mergeStatus);
} catch (error) {
console.error("Error checking merge status:", error);
socket.emit("merge-status-error", sessionId, error instanceof Error ? error.message : String(error));
}
});
socket.on("merge-to-parent", async (sessionId: string) => {
try {
const sessions = getPersistedSessions();
const session = sessions.find(s => s.id === sessionId);
if (!session) {
socket.emit("merge-result", sessionId, {
success: false,
message: "Session not found"
} as MergeResult);
return;
}
const projectDir = session.config.projectDir;
const parentBranch = session.config.parentBranch;
const worktreeBranch = session.gitBranch || (await simpleGit(session.worktreePath).revparse(['--abbrev-ref', 'HEAD']));
console.log(`[${sessionId}] Starting merge: ${worktreeBranch} -> ${parentBranch} in ${projectDir}`);
const git = simpleGit(projectDir);
// Checkout parent branch
await git.checkout(parentBranch);
console.log(`[${sessionId}] Checked out ${parentBranch}`);
// Attempt merge
try {
await git.merge([worktreeBranch]);
console.log(`[${sessionId}] Merge successful`);
socket.emit("merge-result", sessionId, {
success: true,
message: `Successfully merged ${worktreeBranch} into ${parentBranch}`
} as MergeResult);
} catch (mergeError: any) {
// Check if it's a merge conflict
const status = await git.status();
if (status.conflicted.length > 0) {
console.log(`[${sessionId}] Merge conflicts detected: ${status.conflicted.join(', ')}`);
// Abort the merge
await git.raw(['merge', '--abort']);
socket.emit("merge-result", sessionId, {
success: false,
conflicts: status.conflicted,
message: `Merge conflicts in: ${status.conflicted.join(', ')}`
} as MergeResult);
} else {
throw mergeError;
}
}
} catch (error) {
console.error("Error merging to parent:", error);
socket.emit("merge-result", sessionId, {
success: false,
message: error instanceof Error ? error.message : String(error)
} as MergeResult);
}
});
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}`);
});