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>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section" id="mcp-section" style="display: none;">
|
||||
<div class="sidebar-section-header">
|
||||
<div class="sidebar-section-title">MCP SERVERS</div>
|
||||
<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;
|
||||
const activePtyProcesses = new Map<string, pty.IPty>();
|
||||
const mcpPollerPtyProcesses = new Map<string, pty.IPty>();
|
||||
const store = new Store();
|
||||
|
||||
// Helper functions for session management
|
||||
|
|
@ -48,6 +49,108 @@ function getNextSessionNumber(): number {
|
|||
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
|
||||
function spawnSessionPty(
|
||||
sessionId: string,
|
||||
|
|
@ -68,6 +171,7 @@ function spawnSessionPty(
|
|||
activePtyProcesses.set(sessionId, ptyProcess);
|
||||
|
||||
let terminalReady = false;
|
||||
let claudeReady = false;
|
||||
let dataBuffer = "";
|
||||
|
||||
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;
|
||||
|
|
@ -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 fleetcodeDir = path.join(projectDir, ".fleetcode");
|
||||
const worktreeName = `session${sessionNumber}`;
|
||||
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
|
||||
if (!fs.existsSync(fleetcodeDir)) {
|
||||
|
|
@ -250,14 +368,14 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
|
|||
const sessionId = `session-${Date.now()}`;
|
||||
const sessionName = `Session ${sessionNumber}`;
|
||||
|
||||
// Generate UUID for this session (before creating worktree)
|
||||
const sessionUuid = uuidv4();
|
||||
|
||||
// Ensure .fleetcode is excluded (async, don't wait)
|
||||
ensureFleetcodeExcluded(config.projectDir);
|
||||
|
||||
// Create git worktree
|
||||
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber);
|
||||
|
||||
// Generate UUID for this session
|
||||
const sessionUuid = uuidv4();
|
||||
// Create git worktree with unique branch name
|
||||
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber, sessionUuid);
|
||||
|
||||
// Create persisted session metadata
|
||||
const persistedSession: PersistedSession = {
|
||||
|
|
@ -332,6 +450,13 @@ ipcMain.on("close-session", (_event, sessionId: string) => {
|
|||
ptyProcess.kill();
|
||||
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)
|
||||
|
|
@ -343,6 +468,13 @@ ipcMain.on("delete-session", async (_event, sessionId: string) => {
|
|||
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
|
||||
const sessions = getPersistedSessions();
|
||||
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 {
|
||||
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) {
|
||||
console.error("Error listing MCP servers:", error);
|
||||
return [];
|
||||
|
|
@ -541,20 +679,22 @@ const createWindow = () => {
|
|||
}
|
||||
});
|
||||
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(() => {
|
||||
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", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
|
|||
36
renderer.ts
36
renderer.ts
|
|
@ -576,6 +576,15 @@ function switchToSession(sessionId: string) {
|
|||
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
|
||||
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
|
||||
clearUnreadStatus(sessionId);
|
||||
|
||||
|
|
@ -648,6 +657,11 @@ function closeSession(sessionId: string) {
|
|||
switchToSession(activeSessions[0].id);
|
||||
} else {
|
||||
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
|
||||
async function loadMcpServers() {
|
||||
// Only load MCP servers if there's an active session
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
||||
|
||||
// Show loading spinner
|
||||
|
|
@ -952,9 +971,8 @@ async function loadMcpServers() {
|
|||
}
|
||||
|
||||
try {
|
||||
const servers = await ipcRenderer.invoke("list-mcp-servers");
|
||||
mcpServers = servers;
|
||||
renderMcpServers();
|
||||
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
|
||||
// Results will come via mcp-servers-updated event
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP servers:", error);
|
||||
} finally {
|
||||
|
|
@ -1226,14 +1244,14 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
|
|||
});
|
||||
|
||||
// Listen for MCP server updates from main process
|
||||
ipcRenderer.on("mcp-servers-updated", (_event, servers: McpServer[]) => {
|
||||
mcpServers = servers;
|
||||
renderMcpServers();
|
||||
ipcRenderer.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
|
||||
// Only update if this is for the active session
|
||||
if (sessionId === activeSessionId) {
|
||||
mcpServers = servers;
|
||||
renderMcpServers();
|
||||
}
|
||||
});
|
||||
|
||||
// Load MCP servers on startup
|
||||
loadMcpServers();
|
||||
|
||||
// Settings Modal handling
|
||||
const settingsModal = document.getElementById("settings-modal");
|
||||
const openSettingsBtn = document.getElementById("open-settings");
|
||||
|
|
|
|||
Loading…
Reference in a new issue