diff --git a/package-lock.json b/package-lock.json index 40f2993f..94e36aa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1909,7 +1909,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1926,7 +1925,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/server/routes/plugins.js b/server/routes/plugins.js index c9ecb34f..2455e526 100644 --- a/server/routes/plugins.js +++ b/server/routes/plugins.js @@ -249,7 +249,7 @@ router.all('/:name/rpc/*', async (req, res) => { }); // DELETE /:name — Uninstall plugin (stops server first) -router.delete('/:name', (req, res) => { +router.delete('/:name', async (req, res) => { try { const pluginName = req.params.name; @@ -258,12 +258,12 @@ router.delete('/:name', (req, res) => { return res.status(400).json({ error: 'Invalid plugin name' }); } - // Stop server if running + // Stop server and wait for the process to fully exit before deleting files if (isPluginRunning(pluginName)) { - stopPluginServer(pluginName); + await stopPluginServer(pluginName); } - uninstallPlugin(pluginName); + await uninstallPlugin(pluginName); res.json({ success: true, name: pluginName }); } catch (err) { res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message }); diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js index b95b69ed..9400d4b7 100644 --- a/server/utils/plugin-loader.js +++ b/server/utils/plugin-loader.js @@ -326,13 +326,28 @@ export function updatePluginFromGit(name) { }); } -export function uninstallPlugin(name) { +export async function uninstallPlugin(name) { const pluginDir = getPluginDir(name); if (!pluginDir) { throw new Error(`Plugin "${name}" not found`); } - fs.rmSync(pluginDir, { recursive: true, force: true }); + // On Windows, file handles may be released slightly after process exit. + // Retry a few times with a short delay before giving up. + const MAX_RETRIES = 5; + const RETRY_DELAY_MS = 500; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + fs.rmSync(pluginDir, { recursive: true, force: true }); + break; + } catch (err) { + if (err.code === 'EBUSY' && attempt < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } else { + throw err; + } + } + } // Remove from config const config = getPluginsConfig(); diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js index 11f8fd2c..56731714 100644 --- a/server/utils/plugin-process-manager.js +++ b/server/utils/plugin-process-manager.js @@ -93,26 +93,33 @@ export function startPluginServer(name, pluginDir, serverEntry) { /** * Stop a plugin's server subprocess. + * Returns a Promise that resolves when the process has fully exited. */ export function stopPluginServer(name) { const entry = runningPlugins.get(name); - if (!entry) return; + if (!entry) return Promise.resolve(); - entry.process.kill('SIGTERM'); - - // Force kill after 5 seconds if still running - const forceKillTimer = setTimeout(() => { - if (runningPlugins.has(name)) { - entry.process.kill('SIGKILL'); + return new Promise((resolve) => { + const cleanup = () => { + clearTimeout(forceKillTimer); runningPlugins.delete(name); - } - }, 5000); + resolve(); + }; - entry.process.on('exit', () => { - clearTimeout(forceKillTimer); + entry.process.once('exit', cleanup); + + entry.process.kill('SIGTERM'); + + // Force kill after 5 seconds if still running + const forceKillTimer = setTimeout(() => { + if (runningPlugins.has(name)) { + entry.process.kill('SIGKILL'); + cleanup(); + } + }, 5000); + + console.log(`[Plugins] Server stopped for "${name}"`); }); - - console.log(`[Plugins] Server stopped for "${name}"`); } /** @@ -133,9 +140,11 @@ export function isPluginRunning(name) { * Stop all running plugin servers (called on host shutdown). */ export function stopAllPlugins() { + const stops = []; for (const [name] of runningPlugins) { - stopPluginServer(name); + stops.push(stopPluginServer(name)); } + return Promise.all(stops); } /** diff --git a/src/components/app/MobileNav.tsx b/src/components/app/MobileNav.tsx index ad818051..b5fabd7c 100644 --- a/src/components/app/MobileNav.tsx +++ b/src/components/app/MobileNav.tsx @@ -1,6 +1,5 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react'; import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react'; -import { Dispatch, SetStateAction } from 'react'; import { useTasksSettings } from '../../contexts/TasksSettingsContext'; import { usePlugins } from '../../contexts/PluginsContext'; import { AppTab } from '../../types/app'; @@ -101,7 +100,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M e.preventDefault(); setMoreOpen((v) => !v); }} - className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl w-full relative touch-manipulation transition-all duration-200 active:scale-95 ${ + className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${ isPluginActive || moreOpen ? 'text-primary' : 'text-muted-foreground hover:text-foreground' @@ -110,10 +109,10 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M aria-expanded={moreOpen} > {(isPluginActive && !moreOpen) && ( -
+
)} @@ -123,7 +122,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M {/* Popover menu */} {moreOpen && ( -
+
{enabledPlugins.map((p) => { const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle; const isActive = activeTab === `plugin:${p.name}`; @@ -132,13 +131,13 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M ); diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 1f1159f5..978723d7 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -61,20 +61,6 @@ export const ToolRenderer: React.FC = memo(({ isSubagentContainer, subagentState }) => { - // Route subagent containers to dedicated component - if (isSubagentContainer && subagentState) { - if (mode === 'result') { - return null; - } - return ( - - ); - } - const config = getToolConfig(toolName); const displayConfig: any = mode === 'input' ? config.input : config.result; @@ -94,7 +80,20 @@ export const ToolRenderer: React.FC = memo(({ } }, [displayConfig, parsedData, onFileOpen]); - // Keep hooks above this guard so hook call order stays stable across renders. + // Route subagent containers to dedicated component (after hooks to keep call order stable) + if (isSubagentContainer && subagentState) { + if (mode === 'result') { + return null; + } + return ( + + ); + } + if (!displayConfig) return null; if (displayConfig.type === 'one-line') { diff --git a/src/components/plugins/PluginSettingsTab.tsx b/src/components/plugins/PluginSettingsTab.tsx index 132c9f79..26b69b80 100644 --- a/src/components/plugins/PluginSettingsTab.tsx +++ b/src/components/plugins/PluginSettingsTab.tsx @@ -1,28 +1,28 @@ import { useState } from 'react'; import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; import { usePlugins } from '../../contexts/PluginsContext'; -import PluginIcon from './PluginIcon'; import type { Plugin } from '../../contexts/PluginsContext'; +import PluginIcon from './PluginIcon'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; /* ─── Toggle Switch ─────────────────────────────────────────────────────── */ function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( -