diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..ca83e731
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "plugins/starter"]
+ path = plugins/starter
+ url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git
diff --git a/README.md b/README.md
index fc985a5d..4acb9610 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,24 @@
@@ -59,7 +61,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
-- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
+- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
## Quick Start
diff --git a/package-lock.json b/package-lock.json
index b1f9203e..9f64eb49 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/index.js b/server/index.js
index 30116c4b..e745bf11 100755
--- a/server/index.js
+++ b/server/index.js
@@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
+import pluginsRoutes from './routes/plugins.js';
+import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
@@ -389,6 +391,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
+// Plugins API Routes (protected)
+app.use('/api/plugins', authenticateToken, pluginsRoutes);
+
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -2532,7 +2537,20 @@ async function startServer() {
// Start watching the projects folder for changes
await setupProjectsWatcher();
+
+ // Start server-side plugin processes for enabled plugins
+ startEnabledPluginServers().catch(err => {
+ console.error('[Plugins] Error during startup:', err.message);
+ });
});
+
+ // Clean up plugin processes on shutdown
+ const shutdownPlugins = async () => {
+ await stopAllPlugins();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => void shutdownPlugins());
+ process.on('SIGINT', () => void shutdownPlugins());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);
diff --git a/server/routes/plugins.js b/server/routes/plugins.js
new file mode 100644
index 00000000..ef490c45
--- /dev/null
+++ b/server/routes/plugins.js
@@ -0,0 +1,303 @@
+import express from 'express';
+import path from 'path';
+import http from 'http';
+import mime from 'mime-types';
+import fs from 'fs';
+import {
+ scanPlugins,
+ getPluginsConfig,
+ getPluginsDir,
+ savePluginsConfig,
+ getPluginDir,
+ resolvePluginAssetPath,
+ installPluginFromGit,
+ updatePluginFromGit,
+ uninstallPlugin,
+} from '../utils/plugin-loader.js';
+import {
+ startPluginServer,
+ stopPluginServer,
+ getPluginPort,
+ isPluginRunning,
+} from '../utils/plugin-process-manager.js';
+
+const router = express.Router();
+
+// GET / — List all installed plugins (includes server running status)
+router.get('/', (req, res) => {
+ try {
+ const plugins = scanPlugins().map(p => ({
+ ...p,
+ serverRunning: p.server ? isPluginRunning(p.name) : false,
+ }));
+ res.json({ plugins });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
+ }
+});
+
+// GET /:name/manifest — Get single plugin manifest
+router.get('/:name/manifest', (req, res) => {
+ try {
+ if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === req.params.name);
+ if (!plugin) {
+ return res.status(404).json({ error: 'Plugin not found' });
+ }
+ res.json(plugin);
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
+ }
+});
+
+// GET /:name/assets/* — Serve plugin static files
+router.get('/:name/assets/*', (req, res) => {
+ const pluginName = req.params.name;
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+ const assetPath = req.params[0];
+
+ if (!assetPath) {
+ return res.status(400).json({ error: 'No asset path specified' });
+ }
+
+ const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
+ if (!resolvedPath) {
+ return res.status(404).json({ error: 'Asset not found' });
+ }
+
+ try {
+ const stat = fs.statSync(resolvedPath);
+ if (!stat.isFile()) {
+ return res.status(404).json({ error: 'Asset not found' });
+ }
+ } catch {
+ return res.status(404).json({ error: 'Asset not found' });
+ }
+
+ const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
+ res.setHeader('Content-Type', contentType);
+ const stream = fs.createReadStream(resolvedPath);
+ stream.on('error', () => {
+ if (!res.headersSent) {
+ res.status(500).json({ error: 'Failed to read asset' });
+ } else {
+ res.end();
+ }
+ });
+ stream.pipe(res);
+});
+
+// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
+router.put('/:name/enable', async (req, res) => {
+ try {
+ const { enabled } = req.body;
+ if (typeof enabled !== 'boolean') {
+ return res.status(400).json({ error: '"enabled" must be a boolean' });
+ }
+
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === req.params.name);
+ if (!plugin) {
+ return res.status(404).json({ error: 'Plugin not found' });
+ }
+
+ const config = getPluginsConfig();
+ config[req.params.name] = { ...config[req.params.name], enabled };
+ savePluginsConfig(config);
+
+ // Start or stop the plugin server as needed
+ if (plugin.server) {
+ if (enabled && !isPluginRunning(plugin.name)) {
+ const pluginDir = getPluginDir(plugin.name);
+ if (pluginDir) {
+ try {
+ await startPluginServer(plugin.name, pluginDir, plugin.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
+ }
+ }
+ } else if (!enabled && isPluginRunning(plugin.name)) {
+ await stopPluginServer(plugin.name);
+ }
+ }
+
+ res.json({ success: true, name: req.params.name, enabled });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to update plugin', details: err.message });
+ }
+});
+
+// POST /install — Install plugin from git URL
+router.post('/install', async (req, res) => {
+ try {
+ const { url } = req.body;
+ if (!url || typeof url !== 'string') {
+ return res.status(400).json({ error: '"url" is required and must be a string' });
+ }
+
+ // Basic URL validation
+ if (!url.startsWith('https://') && !url.startsWith('git@')) {
+ return res.status(400).json({ error: 'URL must start with https:// or git@' });
+ }
+
+ const manifest = await installPluginFromGit(url);
+
+ // Auto-start the server if the plugin has one (enabled by default)
+ if (manifest.server) {
+ const pluginDir = getPluginDir(manifest.name);
+ if (pluginDir) {
+ try {
+ await startPluginServer(manifest.name, pluginDir, manifest.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
+ }
+ }
+ }
+
+ res.json({ success: true, plugin: manifest });
+ } catch (err) {
+ res.status(400).json({ error: 'Failed to install plugin', details: err.message });
+ }
+});
+
+// POST /:name/update — Pull latest from git (restarts server if running)
+router.post('/:name/update', async (req, res) => {
+ try {
+ const pluginName = req.params.name;
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+
+ const wasRunning = isPluginRunning(pluginName);
+ if (wasRunning) {
+ await stopPluginServer(pluginName);
+ }
+
+ const manifest = await updatePluginFromGit(pluginName);
+
+ // Restart server if it was running before the update
+ if (wasRunning && manifest.server) {
+ const pluginDir = getPluginDir(pluginName);
+ if (pluginDir) {
+ try {
+ await startPluginServer(pluginName, pluginDir, manifest.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
+ }
+ }
+ }
+
+ res.json({ success: true, plugin: manifest });
+ } catch (err) {
+ res.status(400).json({ error: 'Failed to update plugin', details: err.message });
+ }
+});
+
+// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
+router.all('/:name/rpc/*', async (req, res) => {
+ const pluginName = req.params.name;
+ const rpcPath = req.params[0] || '';
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+
+ let port = getPluginPort(pluginName);
+ if (!port) {
+ // Lazily start the plugin server if it exists and is enabled
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === pluginName);
+ if (!plugin || !plugin.server) {
+ return res.status(503).json({ error: 'Plugin server is not running' });
+ }
+ if (!plugin.enabled) {
+ return res.status(503).json({ error: 'Plugin is disabled' });
+ }
+ const pluginDir = path.join(getPluginsDir(), plugin.dirName);
+ try {
+ port = await startPluginServer(pluginName, pluginDir, plugin.server);
+ } catch (err) {
+ return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
+ }
+ }
+
+ // Inject configured secrets as headers
+ const config = getPluginsConfig();
+ const pluginConfig = config[pluginName] || {};
+ const secrets = pluginConfig.secrets || {};
+
+ const headers = {
+ 'content-type': req.headers['content-type'] || 'application/json',
+ };
+
+ // Add per-plugin secrets as X-Plugin-Secret-* headers
+ for (const [key, value] of Object.entries(secrets)) {
+ headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
+ }
+
+ // Reconstruct query string
+ const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
+
+ const options = {
+ hostname: '127.0.0.1',
+ port,
+ path: `/${rpcPath}${qs}`,
+ method: req.method,
+ headers,
+ };
+
+ const proxyReq = http.request(options, (proxyRes) => {
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res);
+ });
+
+ proxyReq.on('error', (err) => {
+ if (!res.headersSent) {
+ res.status(502).json({ error: 'Plugin server error', details: err.message });
+ } else {
+ res.end();
+ }
+ });
+
+ // Forward body (already parsed by express JSON middleware, so re-stringify).
+ // Check content-length to detect whether a body was actually sent, since
+ // req.body can be falsy for valid payloads like 0, false, null, or {}.
+ const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
+ if (hasBody && req.body !== undefined) {
+ const bodyStr = JSON.stringify(req.body);
+ proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
+ proxyReq.write(bodyStr);
+ }
+
+ proxyReq.end();
+});
+
+// DELETE /:name — Uninstall plugin (stops server first)
+router.delete('/:name', async (req, res) => {
+ try {
+ const pluginName = req.params.name;
+
+ // Validate name format to prevent path traversal
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+
+ // Stop server and wait for the process to fully exit before deleting files
+ if (isPluginRunning(pluginName)) {
+ await stopPluginServer(pluginName);
+ }
+
+ await uninstallPlugin(pluginName);
+ res.json({ success: true, name: pluginName });
+ } catch (err) {
+ res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
+ }
+});
+
+export default router;
diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js
new file mode 100644
index 00000000..e48b7686
--- /dev/null
+++ b/server/utils/plugin-loader.js
@@ -0,0 +1,408 @@
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import { spawn } from 'child_process';
+
+const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
+const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
+
+const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
+
+/** Strip embedded credentials from a repo URL before exposing it to the client. */
+function sanitizeRepoUrl(raw) {
+ try {
+ const u = new URL(raw);
+ u.username = '';
+ u.password = '';
+ return u.toString().replace(/\/$/, '');
+ } catch {
+ // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
+ return raw.replace(/\/\/[^@/]+@/, '//');
+ }
+}
+const ALLOWED_TYPES = ['react', 'module'];
+const ALLOWED_SLOTS = ['tab'];
+
+export function getPluginsDir() {
+ if (!fs.existsSync(PLUGINS_DIR)) {
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
+ }
+ return PLUGINS_DIR;
+}
+
+export function getPluginsConfig() {
+ try {
+ if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
+ return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
+ }
+ } catch {
+ // Corrupted config, start fresh
+ }
+ return {};
+}
+
+export function savePluginsConfig(config) {
+ const dir = path.dirname(PLUGINS_CONFIG_PATH);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+ fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
+}
+
+export function validateManifest(manifest) {
+ if (!manifest || typeof manifest !== 'object') {
+ return { valid: false, error: 'Manifest must be a JSON object' };
+ }
+
+ for (const field of REQUIRED_MANIFEST_FIELDS) {
+ if (!manifest[field] || typeof manifest[field] !== 'string') {
+ return { valid: false, error: `Missing or invalid required field: ${field}` };
+ }
+ }
+
+ // Sanitize name — only allow alphanumeric, hyphens, underscores
+ if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
+ return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
+ }
+
+ if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
+ return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
+ }
+
+ if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
+ return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
+ }
+
+ // Validate entry is a relative path without traversal
+ if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
+ return { valid: false, error: 'Entry must be a relative path without ".."' };
+ }
+
+ if (manifest.server !== undefined && manifest.server !== null) {
+ if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
+ return { valid: false, error: 'Server entry must be a relative path string without ".."' };
+ }
+ }
+
+ if (manifest.permissions !== undefined) {
+ if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
+ return { valid: false, error: 'Permissions must be an array of strings' };
+ }
+ }
+
+ return { valid: true };
+}
+
+export function scanPlugins() {
+ const pluginsDir = getPluginsDir();
+ const config = getPluginsConfig();
+ const plugins = [];
+
+ let entries;
+ try {
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
+ } catch {
+ return plugins;
+ }
+
+ const seenNames = new Set();
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ // Skip transient temp directories from in-progress installs
+ if (entry.name.startsWith('.tmp-')) continue;
+
+ const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
+ if (!fs.existsSync(manifestPath)) continue;
+
+ try {
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
+ continue;
+ }
+
+ // Skip duplicate manifest names
+ if (seenNames.has(manifest.name)) {
+ console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
+ continue;
+ }
+ seenNames.add(manifest.name);
+
+ // Try to read git remote URL
+ let repoUrl = null;
+ try {
+ const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
+ if (fs.existsSync(gitConfigPath)) {
+ const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
+ const match = gitConfig.match(/url\s*=\s*(.+)/);
+ if (match) {
+ repoUrl = match[1].trim().replace(/\.git$/, '');
+ // Convert SSH URLs to HTTPS
+ if (repoUrl.startsWith('git@')) {
+ repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
+ }
+ // Strip embedded credentials (e.g. https://user:pass@host/...)
+ repoUrl = sanitizeRepoUrl(repoUrl);
+ }
+ }
+ } catch { /* ignore */ }
+
+ plugins.push({
+ name: manifest.name,
+ displayName: manifest.displayName,
+ version: manifest.version || '0.0.0',
+ description: manifest.description || '',
+ author: manifest.author || '',
+ icon: manifest.icon || 'Puzzle',
+ type: manifest.type || 'module',
+ slot: manifest.slot || 'tab',
+ entry: manifest.entry,
+ server: manifest.server || null,
+ permissions: manifest.permissions || [],
+ enabled: config[manifest.name]?.enabled !== false, // enabled by default
+ dirName: entry.name,
+ repoUrl,
+ });
+ } catch (err) {
+ console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
+ }
+ }
+
+ return plugins;
+}
+
+export function getPluginDir(name) {
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === name);
+ if (!plugin) return null;
+ return path.join(getPluginsDir(), plugin.dirName);
+}
+
+export function resolvePluginAssetPath(name, assetPath) {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) return null;
+
+ const resolved = path.resolve(pluginDir, assetPath);
+
+ // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
+ if (!fs.existsSync(resolved)) return null;
+
+ const realResolved = fs.realpathSync(resolved);
+ const realPluginDir = fs.realpathSync(pluginDir);
+ if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
+ return null;
+ }
+
+ return realResolved;
+}
+
+export function installPluginFromGit(url) {
+ return new Promise((resolve, reject) => {
+ if (typeof url !== 'string' || !url.trim()) {
+ return reject(new Error('Invalid URL: must be a non-empty string'));
+ }
+ if (url.startsWith('-')) {
+ return reject(new Error('Invalid URL: must not start with "-"'));
+ }
+
+ // Extract repo name from URL for directory name
+ const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
+ const repoName = urlClean.split('/').pop();
+
+ if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
+ return reject(new Error('Could not determine a valid directory name from the URL'));
+ }
+
+ const pluginsDir = getPluginsDir();
+ const targetDir = path.resolve(pluginsDir, repoName);
+
+ // Ensure the resolved target directory stays within the plugins directory
+ if (!targetDir.startsWith(pluginsDir + path.sep)) {
+ return reject(new Error('Invalid plugin directory path'));
+ }
+
+ if (fs.existsSync(targetDir)) {
+ return reject(new Error(`Plugin directory "${repoName}" already exists`));
+ }
+
+ // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
+ const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
+
+ const cleanupTemp = () => {
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
+ };
+
+ const finalize = (manifest) => {
+ try {
+ fs.renameSync(tempDir, targetDir);
+ } catch (err) {
+ cleanupTemp();
+ return reject(new Error(`Failed to move plugin into place: ${err.message}`));
+ }
+ resolve(manifest);
+ };
+
+ const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stderr = '';
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
+
+ gitProcess.on('close', (code) => {
+ if (code !== 0) {
+ cleanupTemp();
+ return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
+ }
+
+ // Validate manifest exists
+ const manifestPath = path.join(tempDir, 'manifest.json');
+ if (!fs.existsSync(manifestPath)) {
+ cleanupTemp();
+ return reject(new Error('Cloned repository does not contain a manifest.json'));
+ }
+
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ } catch {
+ cleanupTemp();
+ return reject(new Error('manifest.json is not valid JSON'));
+ }
+
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ cleanupTemp();
+ return reject(new Error(`Invalid manifest: ${validation.error}`));
+ }
+
+ // Reject if another installed plugin already uses this name
+ const existing = scanPlugins().find(p => p.name === manifest.name);
+ if (existing) {
+ cleanupTemp();
+ return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
+ }
+
+ // Run npm install if package.json exists.
+ // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
+ const packageJsonPath = path.join(tempDir, 'package.json');
+ if (fs.existsSync(packageJsonPath)) {
+ const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
+ cwd: tempDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ npmProcess.on('close', (npmCode) => {
+ if (npmCode !== 0) {
+ cleanupTemp();
+ return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
+ }
+ finalize(manifest);
+ });
+
+ npmProcess.on('error', (err) => {
+ cleanupTemp();
+ reject(err);
+ });
+ } else {
+ finalize(manifest);
+ }
+ });
+
+ gitProcess.on('error', (err) => {
+ cleanupTemp();
+ reject(new Error(`Failed to spawn git: ${err.message}`));
+ });
+ });
+}
+
+export function updatePluginFromGit(name) {
+ return new Promise((resolve, reject) => {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) {
+ return reject(new Error(`Plugin "${name}" not found`));
+ }
+
+ // Only fast-forward to avoid silent divergence
+ const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
+ cwd: pluginDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stderr = '';
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
+
+ gitProcess.on('close', (code) => {
+ if (code !== 0) {
+ return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
+ }
+
+ // Re-validate manifest after update
+ const manifestPath = path.join(pluginDir, 'manifest.json');
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ } catch {
+ return reject(new Error('manifest.json is not valid JSON after update'));
+ }
+
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ return reject(new Error(`Invalid manifest after update: ${validation.error}`));
+ }
+
+ // Re-run npm install if package.json exists
+ const packageJsonPath = path.join(pluginDir, 'package.json');
+ if (fs.existsSync(packageJsonPath)) {
+ const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
+ cwd: pluginDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ npmProcess.on('close', (npmCode) => {
+ if (npmCode !== 0) {
+ return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
+ }
+ resolve(manifest);
+ });
+ npmProcess.on('error', (err) => reject(err));
+ } else {
+ resolve(manifest);
+ }
+ });
+
+ gitProcess.on('error', (err) => {
+ reject(new Error(`Failed to spawn git: ${err.message}`));
+ });
+ });
+}
+
+export async function uninstallPlugin(name) {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) {
+ throw new Error(`Plugin "${name}" not found`);
+ }
+
+ // 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();
+ delete config[name];
+ savePluginsConfig(config);
+}
diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js
new file mode 100644
index 00000000..d5fa493e
--- /dev/null
+++ b/server/utils/plugin-process-manager.js
@@ -0,0 +1,184 @@
+import { spawn } from 'child_process';
+import path from 'path';
+import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
+
+// Map
+const runningPlugins = new Map();
+// Map> — in-flight start operations
+const startingPlugins = new Map();
+
+/**
+ * Start a plugin's server subprocess.
+ * The plugin's server entry must print a JSON line with { ready: true, port: }
+ * to stdout within 10 seconds.
+ */
+export function startPluginServer(name, pluginDir, serverEntry) {
+ if (runningPlugins.has(name)) {
+ return Promise.resolve(runningPlugins.get(name).port);
+ }
+
+ // Coalesce concurrent starts for the same plugin
+ if (startingPlugins.has(name)) {
+ return startingPlugins.get(name);
+ }
+
+ const startPromise = new Promise((resolve, reject) => {
+
+ const serverPath = path.join(pluginDir, serverEntry);
+
+ // Restricted env — only essentials, no host secrets
+ const pluginProcess = spawn('node', [serverPath], {
+ cwd: pluginDir,
+ env: {
+ PATH: process.env.PATH,
+ HOME: process.env.HOME,
+ NODE_ENV: process.env.NODE_ENV || 'production',
+ PLUGIN_NAME: name,
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let resolved = false;
+ let stdout = '';
+
+ const timeout = setTimeout(() => {
+ if (!resolved) {
+ resolved = true;
+ pluginProcess.kill();
+ reject(new Error('Plugin server did not report ready within 10 seconds'));
+ }
+ }, 10000);
+
+ pluginProcess.stdout.on('data', (data) => {
+ if (resolved) return;
+ stdout += data.toString();
+
+ // Look for the JSON ready line
+ const lines = stdout.split('\n');
+ for (const line of lines) {
+ try {
+ const msg = JSON.parse(line.trim());
+ if (msg.ready && typeof msg.port === 'number') {
+ clearTimeout(timeout);
+ resolved = true;
+ runningPlugins.set(name, { process: pluginProcess, port: msg.port });
+
+ pluginProcess.on('exit', () => {
+ runningPlugins.delete(name);
+ });
+
+ console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
+ resolve(msg.port);
+ }
+ } catch {
+ // Not JSON yet, keep buffering
+ }
+ }
+ });
+
+ pluginProcess.stderr.on('data', (data) => {
+ console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
+ });
+
+ pluginProcess.on('error', (err) => {
+ clearTimeout(timeout);
+ if (!resolved) {
+ resolved = true;
+ reject(new Error(`Failed to start plugin server: ${err.message}`));
+ }
+ });
+
+ pluginProcess.on('exit', (code) => {
+ clearTimeout(timeout);
+ runningPlugins.delete(name);
+ if (!resolved) {
+ resolved = true;
+ reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
+ }
+ });
+ }).finally(() => {
+ startingPlugins.delete(name);
+ });
+
+ startingPlugins.set(name, startPromise);
+ return startPromise;
+}
+
+/**
+ * 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 Promise.resolve();
+
+ return new Promise((resolve) => {
+ const cleanup = () => {
+ clearTimeout(forceKillTimer);
+ runningPlugins.delete(name);
+ resolve();
+ };
+
+ 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}"`);
+ });
+}
+
+/**
+ * Get the port a running plugin server is listening on.
+ */
+export function getPluginPort(name) {
+ return runningPlugins.get(name)?.port ?? null;
+}
+
+/**
+ * Check if a plugin's server is running.
+ */
+export function isPluginRunning(name) {
+ return runningPlugins.has(name);
+}
+
+/**
+ * Stop all running plugin servers (called on host shutdown).
+ */
+export function stopAllPlugins() {
+ const stops = [];
+ for (const [name] of runningPlugins) {
+ stops.push(stopPluginServer(name));
+ }
+ return Promise.all(stops);
+}
+
+/**
+ * Start servers for all enabled plugins that have a server entry.
+ * Called once on host server boot.
+ */
+export async function startEnabledPluginServers() {
+ const plugins = scanPlugins();
+ const config = getPluginsConfig();
+
+ for (const plugin of plugins) {
+ if (!plugin.server) continue;
+ if (config[plugin.name]?.enabled === false) continue;
+
+ const pluginDir = getPluginDir(plugin.name);
+ if (!pluginDir) continue;
+
+ try {
+ await startPluginServer(plugin.name, pluginDir, plugin.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
+ }
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 564ee1a3..bcbda826 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
+import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -14,8 +15,9 @@ export default function App() {
-
-
+
+
+
@@ -24,8 +26,9 @@ export default function App() {
-
-
+
+
+
diff --git a/src/components/app/MobileNav.tsx b/src/components/app/MobileNav.tsx
index 0ca82bc9..ea49f009 100644
--- a/src/components/app/MobileNav.tsx
+++ b/src/components/app/MobileNav.tsx
@@ -1,8 +1,35 @@
-import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
-import { Dispatch, SetStateAction } from 'react';
+import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
+import {
+ MessageSquare,
+ Folder,
+ Terminal,
+ GitBranch,
+ ClipboardCheck,
+ Ellipsis,
+ Puzzle,
+ Box,
+ Database,
+ Globe,
+ Wrench,
+ Zap,
+ BarChart3,
+ type LucideIcon,
+} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
+import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
+const PLUGIN_ICON_MAP: Record = {
+ Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
+};
+
+type CoreTabId = Exclude;
+type CoreNavItem = {
+ id: CoreTabId;
+ icon: LucideIcon;
+ label: string;
+};
+
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch>;
@@ -12,39 +39,43 @@ type MobileNavProps = {
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
+ const { plugins } = usePlugins();
+ const [moreOpen, setMoreOpen] = useState(false);
+ const moreRef = useRef(null);
- const navItems = [
- {
- id: 'chat',
- icon: MessageSquare,
- label: 'Chat',
- onClick: () => setActiveTab('chat')
- },
- {
- id: 'shell',
- icon: Terminal,
- label: 'Shell',
- onClick: () => setActiveTab('shell')
- },
- {
- id: 'files',
- icon: Folder,
- label: 'Files',
- onClick: () => setActiveTab('files')
- },
- {
- id: 'git',
- icon: GitBranch,
- label: 'Git',
- onClick: () => setActiveTab('git')
- },
- ...(shouldShowTasksTab ? [{
- id: 'tasks',
- icon: ClipboardCheck,
- label: 'Tasks',
- onClick: () => setActiveTab('tasks')
- }] : [])
+ const enabledPlugins = plugins.filter((p) => p.enabled);
+ const hasPlugins = enabledPlugins.length > 0;
+ const isPluginActive = activeTab.startsWith('plugin:');
+
+ // Close the menu on outside tap
+ useEffect(() => {
+ if (!moreOpen) return;
+ const handleTap = (e: PointerEvent) => {
+ const target = e.target;
+ if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
+ setMoreOpen(false);
+ }
+ };
+ document.addEventListener('pointerdown', handleTap);
+ return () => document.removeEventListener('pointerdown', handleTap);
+ }, [moreOpen]);
+
+ // Close menu when a plugin tab is selected
+ const selectPlugin = (name: string) => {
+ const pluginTab = `plugin:${name}` as AppTab;
+ setActiveTab(pluginTab);
+ setMoreOpen(false);
+ };
+
+ const baseCoreItems: CoreNavItem[] = [
+ { id: 'chat', icon: MessageSquare, label: 'Chat' },
+ { id: 'shell', icon: Terminal, label: 'Shell' },
+ { id: 'files', icon: Folder, label: 'Files' },
+ { id: 'git', icon: GitBranch, label: 'Git' },
];
+ const coreItems: CoreNavItem[] = shouldShowTasksTab
+ ? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
+ : baseCoreItems;
return (
- {navItems.map((item) => {
+ {coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
- item.onClick();
+ setActiveTab(item.id);
}}
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
? 'text-primary'
@@ -85,6 +116,62 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
);
})}
+
+ {/* "More" button — only shown when there are enabled plugins */}
+ {hasPlugins && (
+
+
setMoreOpen((v) => !v)}
+ onTouchStart={(e) => {
+ e.preventDefault();
+ setMoreOpen((v) => !v);
+ }}
+ 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'
+ }`}
+ aria-label="More plugins"
+ aria-expanded={moreOpen}
+ >
+ {(isPluginActive && !moreOpen) && (
+
+ )}
+
+
+ More
+
+
+
+ {/* Popover menu */}
+ {moreOpen && (
+
+ {enabledPlugins.map((p) => {
+ const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
+ const isActive = activeTab === `plugin:${p.name}`;
+
+ return (
+ selectPlugin(p.name)}
+ className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${
+ isActive
+ ? 'bg-primary/8 text-primary'
+ : 'text-foreground hover:bg-muted/60'
+ }`}
+ >
+
+ {p.displayName}
+
+ );
+ })}
+
+ )}
+
+ )}
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/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx
index 7f071c0e..c08c2840 100644
--- a/src/components/git-panel/view/GitPanel.tsx
+++ b/src/components/git-panel/view/GitPanel.tsx
@@ -107,7 +107,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
{activeView === 'changes' && (
();
+
type CommitComposerProps = {
isMobile: boolean;
+ projectPath: string;
selectedFileCount: number;
isHidden: boolean;
onCommit: (message: string) => Promise;
@@ -14,13 +18,24 @@ type CommitComposerProps = {
export default function CommitComposer({
isMobile,
+ projectPath,
selectedFileCount,
isHidden,
onCommit,
onGenerateMessage,
onRequestConfirmation,
}: CommitComposerProps) {
- const [commitMessage, setCommitMessage] = useState('');
+ const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? '');
+
+ const setCommitMessage = (msg: string) => {
+ setCommitMessageRaw(msg);
+ if (msg) {
+ commitMessageCache.set(projectPath, msg);
+ } else {
+ commitMessageCache.delete(projectPath);
+ }
+ };
+
const [isCommitting, setIsCommitting] = useState(false);
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(isMobile);
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index 786f807e..5d6ef053 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -3,6 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../git-panel/view/GitPanel';
+import PluginTabContent from '../../plugins/view/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
@@ -158,6 +159,16 @@ function MainContent({
{shouldShowTasksTab && }
+
+ {activeTab.startsWith('plugin:') && (
+
+ )}