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 (
-