Implement per-session MCP server polling with headless PTY
- Add headless PTY poller for each session that runs in session's worktree directory - Spawn MCP poller when Claude shows > prompt (authenticated context) - Parse MCP server output with regex, merge servers by name to handle chunked data - Show/hide MCP section based on active session - Clean up MCP pollers on session close/delete - Add unique UUID suffix to branch names to prevent conflicts on recreate - Remove global MCP polling interval in favor of per-session polling
This commit is contained in:
parent
8dfe6be026
commit
54e7a1adb4
|
|
@ -31,7 +31,7 @@
|
||||||
<div id="session-list"></div>
|
<div id="session-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section" id="mcp-section" style="display: none;">
|
||||||
<div class="sidebar-section-header">
|
<div class="sidebar-section-header">
|
||||||
<div class="sidebar-section-title">MCP SERVERS</div>
|
<div class="sidebar-section-title">MCP SERVERS</div>
|
||||||
<button id="add-mcp-server" class="section-add-btn" title="Add MCP Server">+</button>
|
<button id="add-mcp-server" class="section-add-btn" title="Add MCP Server">+</button>
|
||||||
|
|
|
||||||
174
main.ts
174
main.ts
|
|
@ -31,6 +31,7 @@ interface PersistedSession {
|
||||||
|
|
||||||
let mainWindow: BrowserWindow;
|
let mainWindow: BrowserWindow;
|
||||||
const activePtyProcesses = new Map<string, pty.IPty>();
|
const activePtyProcesses = new Map<string, pty.IPty>();
|
||||||
|
const mcpPollerPtyProcesses = new Map<string, pty.IPty>();
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
|
|
||||||
// Helper functions for session management
|
// Helper functions for session management
|
||||||
|
|
@ -48,6 +49,108 @@ function getNextSessionNumber(): number {
|
||||||
return Math.max(...sessions.map(s => s.number)) + 1;
|
return Math.max(...sessions.map(s => s.number)) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spawn headless PTY for MCP polling
|
||||||
|
function spawnMcpPoller(sessionId: string, worktreePath: string) {
|
||||||
|
const shell = os.platform() === "darwin" ? "zsh" : "bash";
|
||||||
|
const ptyProcess = pty.spawn(shell, ["-l"], {
|
||||||
|
name: "xterm-color",
|
||||||
|
cols: 80,
|
||||||
|
rows: 30,
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
mcpPollerPtyProcesses.set(sessionId, ptyProcess);
|
||||||
|
|
||||||
|
let outputBuffer = "";
|
||||||
|
const serverMap = new Map<string, any>();
|
||||||
|
|
||||||
|
ptyProcess.onData((data) => {
|
||||||
|
// Accumulate output without displaying it
|
||||||
|
outputBuffer += data;
|
||||||
|
|
||||||
|
// Parse output whenever we have MCP server entries
|
||||||
|
// Match lines like: "servername: url (type) - ✓ Connected" or "servername: command (stdio) - ✓ Connected"
|
||||||
|
// Pattern handles both SSE (with URLs) and stdio (with commands/paths)
|
||||||
|
const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|stdio)\)\s+-\s+[✓⚠]/m;
|
||||||
|
|
||||||
|
if (mcpServerLineRegex.test(data) || data.includes("No MCP servers configured")) {
|
||||||
|
try {
|
||||||
|
const servers = parseMcpOutput(outputBuffer);
|
||||||
|
|
||||||
|
// Merge servers into the map (upsert by name)
|
||||||
|
servers.forEach(server => {
|
||||||
|
serverMap.set(server.name, server);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allServers = Array.from(serverMap.values());
|
||||||
|
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("mcp-servers-updated", sessionId, allServers);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing MCP output:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear buffer when we see the shell prompt (command finished)
|
||||||
|
if ((data.includes("% ") || data.includes("$ ") || data.includes("➜ ")) &&
|
||||||
|
outputBuffer.includes("claude mcp list")) {
|
||||||
|
outputBuffer = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for shell to be ready, then start polling loop
|
||||||
|
setTimeout(() => {
|
||||||
|
// Run `claude mcp list` every 60 seconds
|
||||||
|
const pollMcp = () => {
|
||||||
|
if (mcpPollerPtyProcesses.has(sessionId)) {
|
||||||
|
ptyProcess.write("claude mcp list\r");
|
||||||
|
setTimeout(pollMcp, 60000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pollMcp();
|
||||||
|
}, 2000); // Wait 2s for shell to initialize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse MCP server list output
|
||||||
|
function parseMcpOutput(output: string): any[] {
|
||||||
|
const servers = [];
|
||||||
|
|
||||||
|
if (output.includes("No MCP servers configured")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = output.trim().split("\n").filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Skip header lines, empty lines, and status messages
|
||||||
|
if (line.includes("MCP servers") ||
|
||||||
|
line.includes("---") ||
|
||||||
|
line.includes("Checking") ||
|
||||||
|
line.includes("health") ||
|
||||||
|
line.includes("claude mcp list") ||
|
||||||
|
!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only parse lines that match the MCP server format
|
||||||
|
// Must have: "name: something (SSE|stdio) - status"
|
||||||
|
const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|stdio)\)\s+-\s+[✓⚠]/);
|
||||||
|
if (serverMatch) {
|
||||||
|
const serverName = serverMatch[1];
|
||||||
|
const isConnected = line.includes("✓") || line.includes("Connected");
|
||||||
|
|
||||||
|
servers.push({
|
||||||
|
name: serverName,
|
||||||
|
connected: isConnected
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to spawn PTY and setup coding agent
|
// Helper function to spawn PTY and setup coding agent
|
||||||
function spawnSessionPty(
|
function spawnSessionPty(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|
@ -68,6 +171,7 @@ function spawnSessionPty(
|
||||||
activePtyProcesses.set(sessionId, ptyProcess);
|
activePtyProcesses.set(sessionId, ptyProcess);
|
||||||
|
|
||||||
let terminalReady = false;
|
let terminalReady = false;
|
||||||
|
let claudeReady = false;
|
||||||
let dataBuffer = "";
|
let dataBuffer = "";
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
|
|
@ -115,6 +219,18 @@ function spawnSessionPty(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect when Claude is ready (shows "> " prompt)
|
||||||
|
if (terminalReady && !claudeReady && config.codingAgent === "claude") {
|
||||||
|
if (data.includes("> ")) {
|
||||||
|
claudeReady = true;
|
||||||
|
// Spawn MCP poller now that Claude is authenticated and ready
|
||||||
|
// Check if poller doesn't already exist to prevent duplicates
|
||||||
|
if (!mcpPollerPtyProcesses.has(sessionId)) {
|
||||||
|
spawnMcpPoller(sessionId, worktreePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return ptyProcess;
|
return ptyProcess;
|
||||||
|
|
@ -159,12 +275,14 @@ async function ensureFleetcodeExcluded(projectDir: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number): Promise<string> {
|
async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number, sessionUuid: string): Promise<string> {
|
||||||
const git = simpleGit(projectDir);
|
const git = simpleGit(projectDir);
|
||||||
const fleetcodeDir = path.join(projectDir, ".fleetcode");
|
const fleetcodeDir = path.join(projectDir, ".fleetcode");
|
||||||
const worktreeName = `session${sessionNumber}`;
|
const worktreeName = `session${sessionNumber}`;
|
||||||
const worktreePath = path.join(fleetcodeDir, worktreeName);
|
const worktreePath = path.join(fleetcodeDir, worktreeName);
|
||||||
const branchName = `fleetcode/session${sessionNumber}`;
|
// Include short UUID to ensure branch uniqueness across deletes/recreates
|
||||||
|
const shortUuid = sessionUuid.split('-')[0];
|
||||||
|
const branchName = `fleetcode/session${sessionNumber}-${shortUuid}`;
|
||||||
|
|
||||||
// Create .fleetcode directory if it doesn't exist
|
// Create .fleetcode directory if it doesn't exist
|
||||||
if (!fs.existsSync(fleetcodeDir)) {
|
if (!fs.existsSync(fleetcodeDir)) {
|
||||||
|
|
@ -250,14 +368,14 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
|
||||||
const sessionId = `session-${Date.now()}`;
|
const sessionId = `session-${Date.now()}`;
|
||||||
const sessionName = `Session ${sessionNumber}`;
|
const sessionName = `Session ${sessionNumber}`;
|
||||||
|
|
||||||
|
// Generate UUID for this session (before creating worktree)
|
||||||
|
const sessionUuid = uuidv4();
|
||||||
|
|
||||||
// Ensure .fleetcode is excluded (async, don't wait)
|
// Ensure .fleetcode is excluded (async, don't wait)
|
||||||
ensureFleetcodeExcluded(config.projectDir);
|
ensureFleetcodeExcluded(config.projectDir);
|
||||||
|
|
||||||
// Create git worktree
|
// Create git worktree with unique branch name
|
||||||
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber);
|
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber, sessionUuid);
|
||||||
|
|
||||||
// Generate UUID for this session
|
|
||||||
const sessionUuid = uuidv4();
|
|
||||||
|
|
||||||
// Create persisted session metadata
|
// Create persisted session metadata
|
||||||
const persistedSession: PersistedSession = {
|
const persistedSession: PersistedSession = {
|
||||||
|
|
@ -332,6 +450,13 @@ ipcMain.on("close-session", (_event, sessionId: string) => {
|
||||||
ptyProcess.kill();
|
ptyProcess.kill();
|
||||||
activePtyProcesses.delete(sessionId);
|
activePtyProcesses.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kill MCP poller if active
|
||||||
|
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
|
||||||
|
if (mcpPoller) {
|
||||||
|
mcpPoller.kill();
|
||||||
|
mcpPollerPtyProcesses.delete(sessionId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete session (kill PTY, remove worktree, delete from store)
|
// Delete session (kill PTY, remove worktree, delete from store)
|
||||||
|
|
@ -343,6 +468,13 @@ ipcMain.on("delete-session", async (_event, sessionId: string) => {
|
||||||
activePtyProcesses.delete(sessionId);
|
activePtyProcesses.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kill MCP poller if active
|
||||||
|
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
|
||||||
|
if (mcpPoller) {
|
||||||
|
mcpPoller.kill();
|
||||||
|
mcpPollerPtyProcesses.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// Find and remove from persisted sessions
|
// Find and remove from persisted sessions
|
||||||
const sessions = getPersistedSessions();
|
const sessions = getPersistedSessions();
|
||||||
const sessionIndex = sessions.findIndex(s => s.id === sessionId);
|
const sessionIndex = sessions.findIndex(s => s.id === sessionId);
|
||||||
|
|
@ -476,9 +608,15 @@ async function getMcpServerDetails(name: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("list-mcp-servers", async () => {
|
ipcMain.handle("list-mcp-servers", async (_event, sessionId: string) => {
|
||||||
try {
|
try {
|
||||||
return await listMcpServers();
|
// Trigger an immediate MCP list command in the session's poller
|
||||||
|
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
|
||||||
|
if (mcpPoller) {
|
||||||
|
mcpPoller.write("claude mcp list\r");
|
||||||
|
}
|
||||||
|
// Return empty array - actual results will come via mcp-servers-updated event
|
||||||
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error listing MCP servers:", error);
|
console.error("Error listing MCP servers:", error);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -541,20 +679,22 @@ const createWindow = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
activePtyProcesses.clear();
|
activePtyProcesses.clear();
|
||||||
|
|
||||||
|
// Kill all MCP poller processes
|
||||||
|
mcpPollerPtyProcesses.forEach((ptyProcess, sessionId) => {
|
||||||
|
try {
|
||||||
|
ptyProcess.kill();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error killing MCP poller for session ${sessionId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mcpPollerPtyProcesses.clear();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
// Refresh MCP server list every minute and broadcast to all windows
|
|
||||||
setInterval(async () => {
|
|
||||||
const servers = await listMcpServers();
|
|
||||||
BrowserWindow.getAllWindows().forEach(window => {
|
|
||||||
window.webContents.send("mcp-servers-updated", servers);
|
|
||||||
});
|
|
||||||
}, 60000); // 60000ms = 1 minute
|
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
|
||||||
32
renderer.ts
32
renderer.ts
|
|
@ -576,6 +576,15 @@ function switchToSession(sessionId: string) {
|
||||||
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
|
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
|
||||||
activeSessionId = sessionId;
|
activeSessionId = sessionId;
|
||||||
|
|
||||||
|
// Show MCP section when a session is active
|
||||||
|
const mcpSection = document.getElementById("mcp-section");
|
||||||
|
if (mcpSection) {
|
||||||
|
mcpSection.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load MCP servers for this session
|
||||||
|
loadMcpServers();
|
||||||
|
|
||||||
// Clear unread status when switching to this session
|
// Clear unread status when switching to this session
|
||||||
clearUnreadStatus(sessionId);
|
clearUnreadStatus(sessionId);
|
||||||
|
|
||||||
|
|
@ -648,6 +657,11 @@ function closeSession(sessionId: string) {
|
||||||
switchToSession(activeSessions[0].id);
|
switchToSession(activeSessions[0].id);
|
||||||
} else {
|
} else {
|
||||||
activeSessionId = null;
|
activeSessionId = null;
|
||||||
|
// Hide MCP section when no sessions are active
|
||||||
|
const mcpSection = document.getElementById("mcp-section");
|
||||||
|
if (mcpSection) {
|
||||||
|
mcpSection.style.display = "none";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -943,6 +957,11 @@ createBtn?.addEventListener("click", () => {
|
||||||
|
|
||||||
// MCP Server management functions
|
// MCP Server management functions
|
||||||
async function loadMcpServers() {
|
async function loadMcpServers() {
|
||||||
|
// Only load MCP servers if there's an active session
|
||||||
|
if (!activeSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
||||||
|
|
||||||
// Show loading spinner
|
// Show loading spinner
|
||||||
|
|
@ -952,9 +971,8 @@ async function loadMcpServers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const servers = await ipcRenderer.invoke("list-mcp-servers");
|
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
|
||||||
mcpServers = servers;
|
// Results will come via mcp-servers-updated event
|
||||||
renderMcpServers();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load MCP servers:", error);
|
console.error("Failed to load MCP servers:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1226,14 +1244,14 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for MCP server updates from main process
|
// Listen for MCP server updates from main process
|
||||||
ipcRenderer.on("mcp-servers-updated", (_event, servers: McpServer[]) => {
|
ipcRenderer.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
|
||||||
|
// Only update if this is for the active session
|
||||||
|
if (sessionId === activeSessionId) {
|
||||||
mcpServers = servers;
|
mcpServers = servers;
|
||||||
renderMcpServers();
|
renderMcpServers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load MCP servers on startup
|
|
||||||
loadMcpServers();
|
|
||||||
|
|
||||||
// Settings Modal handling
|
// Settings Modal handling
|
||||||
const settingsModal = document.getElementById("settings-modal");
|
const settingsModal = document.getElementById("settings-modal");
|
||||||
const openSettingsBtn = document.getElementById("open-settings");
|
const openSettingsBtn = document.getElementById("open-settings");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue