diff --git a/examples/plugins/hello-world/README.md b/examples/plugins/hello-world/README.md
new file mode 100644
index 0000000..1897ab5
--- /dev/null
+++ b/examples/plugins/hello-world/README.md
@@ -0,0 +1,227 @@
+# Project Stats — example plugin
+
+Scans the currently selected project and shows file counts, lines of code, a file-type breakdown chart, largest files, and recently modified files.
+
+This is the example plugin that ships with Claude Code UI. It demonstrates all three plugin capabilities in a way that's immediately useful. See the sections below for the full plugin authoring guide.
+
+## How plugins work
+
+A plugin's UI is a plain ES module loaded directly into the host app — no iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The module calls the server through an `api.rpc()` helper (the host proxies the calls using its own auth token).
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Host server │
+│ │
+│ Lifecycle: │
+│ git clone / git pull Install & update │
+│ npm install Dependency setup │
+│ │
+│ Runtime: │
+│ GET /api/plugins List plugins │
+│ GET /api/plugins/:name/assets/* Serve static files │
+│ ALL /api/plugins/:name/rpc/* Proxy → subprocess │
+│ PUT /api/plugins/:name/enable Toggle + start/stop │
+│ DELETE /api/plugins/:name Uninstall + stop │
+│ │
+│ Plugin subprocess (server.js): │
+│ Runs as a child process with restricted env │
+│ Listens on random local port │
+│ Receives secrets via X-Plugin-Secret-* headers │
+└───────────┬─────────────────────────┬───────────────────┘
+ │ serves static files │ proxies RPC
+┌───────────▼─────────────────────────▼───────────────────┐
+│ Frontend (browser) │
+│ │
+│ Plugin module (index.js) │
+│ import(url) → mount(container, api) │
+│ api.context — theme / project / session │
+│ api.onContextChange — subscribe to changes │
+│ api.rpc(method, path, body) → Promise │
+└─────────────────────────────────────────────────────────┘
+```
+
+## Plugin structure
+
+```
+my-plugin/
+ manifest.json # Required — plugin metadata
+ index.js # Frontend entry point (ES module, mount/unmount exports)
+ server.js # Optional — backend entry point (runs as subprocess)
+ package.json # Optional — npm dependencies for server.js
+```
+
+All files in the plugin directory are accessible via `/api/plugins/:name/assets/`.
+
+## manifest.json
+
+```jsonc
+{
+ "name": "hello-world", // Unique id — alphanumeric, hyphens, underscores only
+ "displayName": "Hello World", // Shown in the UI
+ "version": "1.0.0",
+ "description": "Short description shown in settings.",
+ "author": "Your Name",
+ "icon": "Puzzle", // Lucide icon name (see available icons below)
+ "type": "module", // "module" (default) or "iframe" (legacy)
+ "slot": "tab", // Where the plugin appears — only "tab" is supported today
+ "entry": "index.js", // Frontend entry file, relative to plugin directory
+ "server": "server.js", // Optional — backend entry file, runs as Node.js subprocess
+ "permissions": [] // Reserved for future use
+}
+```
+
+### Available icons
+
+`Puzzle` (default), `Box`, `Database`, `Globe`, `Terminal`, `Wrench`, `Zap`, `BarChart3`, `Folder`, `MessageSquare`, `GitBranch`
+
+## Installation
+
+**Manual:** Copy your plugin folder into `~/.claude-code-ui/plugins/`.
+
+**From git:** In Settings > Plugins, paste a git URL and click Install. The repo is cloned into the plugins directory.
+
+---
+
+## Frontend — Module API
+
+The host dynamically imports your entry file and calls `mount(container, api)`. When the plugin tab is closed or the plugin is disabled, `unmount(container)` is called.
+
+```js
+// index.js
+
+export function mount(container, api) {
+ // api.context — current snapshot: { theme, project, session }
+ // api.onContextChange(cb) — subscribe, returns an unsubscribe function
+ // api.rpc(method, path, body?) — call the plugin's server subprocess
+
+ container.innerHTML = '
Hello!
';
+
+ const unsub = api.onContextChange((ctx) => {
+ container.style.background = ctx.theme === 'dark' ? '#111' : '#fff';
+ });
+
+ container._cleanup = unsub;
+}
+
+export function unmount(container) {
+ if (typeof container._cleanup === 'function') container._cleanup();
+ container.innerHTML = '';
+}
+```
+
+### Context object
+
+```js
+api.context // always up to date
+// {
+// theme: "dark" | "light",
+// project: { name: string, path: string } | null,
+// session: { id: string, title: string } | null,
+// }
+```
+
+### RPC helper
+
+```js
+// Calls /api/plugins/:name/rpc/hello via the host's authenticated fetch
+const data = await api.rpc('GET', '/hello');
+
+// With a JSON body
+const result = await api.rpc('POST', '/echo', { greeting: 'hi' });
+```
+
+---
+
+## Backend — Server subprocess
+
+Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle:
+
+### How it works
+
+1. When the plugin is enabled, the host spawns `node server.js` as a child process
+2. The subprocess **must** print a JSON line to stdout: `{"ready": true, "port": 12345}`
+3. The host records the port and proxies requests from `/api/plugins/:name/rpc/*` to it
+4. When the plugin is disabled or uninstalled, the host sends SIGTERM to the process
+
+### Restricted environment
+
+The subprocess runs with a **minimal env** — only `PATH`, `HOME`, `NODE_ENV`, and `PLUGIN_NAME`. It does **not** inherit the host's API keys, database URLs, or other secrets from `process.env`.
+
+### Secrets
+
+Per-plugin secrets are stored in `~/.claude-code-ui/plugins.json` and injected as HTTP headers on every proxied request:
+
+```json
+{
+ "hello-world": {
+ "enabled": true,
+ "secrets": {
+ "apiKey": "sk-live-..."
+ }
+ }
+}
+```
+
+The plugin's server receives these as `x-plugin-secret-apikey` headers — they are per-call, never stored in the subprocess env.
+
+### Example server.js
+
+```js
+const http = require('http');
+
+const server = http.createServer((req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+
+ // Read host-injected secrets
+ const apiKey = req.headers['x-plugin-secret-apikey'];
+
+ if (req.method === 'GET' && req.url === '/hello') {
+ res.end(JSON.stringify({ message: 'Hello!', hasApiKey: Boolean(apiKey) }));
+ return;
+ }
+
+ res.writeHead(404);
+ res.end(JSON.stringify({ error: 'Not found' }));
+});
+
+// Listen on a random port and signal readiness
+server.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ console.log(JSON.stringify({ ready: true, port }));
+});
+```
+
+---
+
+## Frontend — Mobile
+
+On desktop, each enabled plugin gets its own tab in the tab bar. On mobile, plugins are grouped under a single "More" button in the bottom navigation to save space.
+
+## Security
+
+### Frontend isolation
+
+Plugin modules run in the same JS context as the host app but have no access to auth tokens or internal state — only the `api` object passed to `mount`. They cannot make authenticated API calls directly; all server communication goes through `api.rpc()`, which the host proxies.
+
+### Server subprocess isolation
+
+The subprocess runs as a separate OS process with:
+
+- **Restricted env** — no host secrets inherited; only `PATH`, `HOME`, `NODE_ENV`, `PLUGIN_NAME`
+- **Per-call secrets** — injected as HTTP headers by the host proxy, never stored in process env
+- **Process boundary** — a crash in the plugin cannot crash the host
+- **Auth stripping** — the host removes `authorization` and `cookie` headers before proxying
+
+The subprocess runs as the same OS user, so it has the same filesystem/network access. This matches the trust model of VS Code extensions, Grafana backend plugins, and Terraform providers — the user explicitly installs the plugin.
+
+### Install-time protections
+
+npm `postinstall` scripts are blocked during installation (`--ignore-scripts`). Plugins that need npm packages should ship pre-built or use packages that work without postinstall hooks.
+
+## Try it
+
+```bash
+cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/
+```
+
+Then open Settings > Plugins — "Project Stats" should appear. Enable it, select a project, and open its tab to see the stats.
diff --git a/examples/plugins/hello-world/index.html b/examples/plugins/hello-world/index.html
new file mode 100644
index 0000000..ea8bf1c
--- /dev/null
+++ b/examples/plugins/hello-world/index.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+ Hello World Plugin
+
+
+
+ Hello from a plugin!
+ This tab is rendered inside a sandboxed iframe.
+
+
+ Theme —
+ Project —
+ Session —
+
+
+ Server RPC
+ This calls the plugin's own Node.js server subprocess via the postMessage RPC bridge.
+ Call GET /hello
+ Call POST /echo
+ Click a button to make an RPC call...
+
+
+
+
diff --git a/examples/plugins/hello-world/index.js b/examples/plugins/hello-world/index.js
new file mode 100644
index 0000000..48a9c4b
--- /dev/null
+++ b/examples/plugins/hello-world/index.js
@@ -0,0 +1,272 @@
+/**
+ * Project Stats plugin — module entry point.
+ *
+ * api shape:
+ * api.context — current PluginContext snapshot
+ * api.onContextChange(cb) → unsubscribe — called on theme/project/session changes
+ * api.rpc(method, path, body?) → Promise — proxied to this plugin's server subprocess
+ */
+
+const PALETTE = [
+ '#6366f1','#22d3ee','#f59e0b','#10b981',
+ '#f43f5e','#a78bfa','#fb923c','#34d399',
+ '#60a5fa','#e879f9','#facc15','#4ade80',
+];
+
+function ensureAssets() {
+ if (document.getElementById('ps-font')) return;
+ const link = document.createElement('link');
+ link.id = 'ps-font';
+ link.rel = 'stylesheet';
+ link.href = 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap';
+ document.head.appendChild(link);
+
+ const s = document.createElement('style');
+ s.id = 'ps-styles';
+ s.textContent = `
+ @keyframes ps-grow { from { width: 0 } }
+ @keyframes ps-fadeup { from { opacity:0; transform:translateY(6px) } to { opacity:1; transform:translateY(0) } }
+ @keyframes ps-pulse { 0%,100% { opacity:.3 } 50% { opacity:.6 } }
+ .ps-bar { animation: ps-grow 0.75s cubic-bezier(.16,1,.3,1) both }
+ .ps-up { animation: ps-fadeup 0.4s ease both }
+ .ps-skel { animation: ps-pulse 1.6s ease infinite }
+ `;
+ document.head.appendChild(s);
+}
+
+const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace";
+
+function fmt(b) {
+ if (b < 1024) return `${b}B`;
+ if (b < 1048576) return `${(b / 1024).toFixed(1)}KB`;
+ return `${(b / 1048576).toFixed(1)}MB`;
+}
+
+function ago(ms) {
+ const s = Math.floor((Date.now() - ms) / 1000);
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ return `${Math.floor(s / 86400)}d ago`;
+}
+
+function countUp(el, target, formatFn, duration = 900) {
+ const start = performance.now();
+ function tick(now) {
+ const p = Math.min((now - start) / duration, 1);
+ const ease = 1 - (1 - p) ** 3;
+ el.textContent = formatFn(Math.round(target * ease));
+ if (p < 1) requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+}
+
+function v(dark) {
+ return dark ? {
+ bg: '#08080f',
+ surface: '#0e0e1a',
+ border: '#1a1a2c',
+ text: '#e2e0f0',
+ muted: '#52507a',
+ accent: '#fbbf24',
+ dim: 'rgba(251,191,36,0.1)',
+ } : {
+ bg: '#fafaf9',
+ surface: '#ffffff',
+ border: '#e8e6f0',
+ text: '#0f0e1a',
+ muted: '#9490b0',
+ accent: '#d97706',
+ dim: 'rgba(217,119,6,0.08)',
+ };
+}
+
+function skeletonRows(c, widths) {
+ return widths.map((w, i) => `
+
`).join('');
+}
+
+export function mount(container, api) {
+ ensureAssets();
+ let cache = null;
+
+ const root = document.createElement('div');
+ Object.assign(root.style, {
+ height: '100%', overflowY: 'auto', boxSizing: 'border-box',
+ padding: '24px', fontFamily: MONO,
+ });
+ container.appendChild(root);
+
+ function render(ctx, stats) {
+ const c = v(ctx.theme === 'dark');
+ root.style.background = c.bg;
+ root.style.color = c.text;
+
+ if (!ctx.project) {
+ root.innerHTML = `
+
+
~/.projects/
+└── (none selected)
+
select a project
+
`;
+ return;
+ }
+
+ if (!stats) {
+ root.innerHTML = `
+
+
${ctx.project.name}▌
+
${ctx.project.path}
+
+
+ ${[0, 1, 2].map(i => `
+
+ ${skeletonRows(c, [65])}
+ ${skeletonRows(c, [40])}
+
`).join('')}
+
+
+ ${[75, 55, 38, 22, 14].map((w, i) => `
+
`).join('')}
+
`;
+ return;
+ }
+
+ const maxCount = stats.byExtension[0]?.[1] || 1;
+
+ root.innerHTML = `
+
+
+
+ ${ctx.project.name}▌
+
+
${ctx.project.path}
+
+
+ ↻ refresh
+
+
+
+
+ ${[
+ ['files', stats.totalFiles, n => n.toLocaleString()],
+ ['lines', stats.totalLines, n => n.toLocaleString()],
+ ['total size', stats.totalSize, fmt],
+ ].map(([label, val, fmt], i) => `
+
`).join('')}
+
+
+
+
file types
+ ${stats.byExtension.map(([ext, count], i) => `
+
`).join('')}
+
+
+
+ ${[
+ ['largest files', stats.largest.map(f => [f.name, fmt(f.size)])],
+ ['recently modified', stats.recent.map(f => [f.name, ago(f.mtime)])],
+ ].map(([title, rows], ci) => `
+
+
${title}
+ ${rows.map(([name, val], ri) => `
+
`).join('')}
+
`).join('')}
+
+ `;
+
+ // Count-up animations for metric cards
+ [
+ [0, stats.totalFiles, n => n.toLocaleString()],
+ [1, stats.totalLines, n => n.toLocaleString()],
+ [2, stats.totalSize, fmt],
+ ].forEach(([i, val, formatFn]) => {
+ const el = root.querySelector(`#ps-m-${i}`);
+ if (el) countUp(el, val, formatFn);
+ });
+
+ root.querySelector('#ps-refresh')?.addEventListener('click', () => {
+ cache = null;
+ load(api.context);
+ });
+ }
+
+ async function load(ctx) {
+ if (!ctx.project) { cache = null; render(ctx, null); return; }
+ render(ctx, null);
+ try {
+ const stats = await api.rpc('GET', `stats?path=${encodeURIComponent(ctx.project.path)}`);
+ cache = { projectPath: ctx.project.path, stats };
+ render(ctx, stats);
+ } catch (err) {
+ const c = v(ctx.theme === 'dark');
+ root.style.background = c.bg;
+ root.innerHTML = `
+
+ ✗ ${err.message}
+
`;
+ }
+ }
+
+ load(api.context);
+
+ const unsubscribe = api.onContextChange(ctx => {
+ if (ctx.project?.path === cache?.projectPath) render(ctx, cache?.stats ?? null);
+ else load(ctx);
+ });
+
+ container._psUnsubscribe = unsubscribe;
+}
+
+export function unmount(container) {
+ if (typeof container._psUnsubscribe === 'function') {
+ container._psUnsubscribe();
+ delete container._psUnsubscribe;
+ }
+ container.innerHTML = '';
+}
diff --git a/examples/plugins/hello-world/manifest.json b/examples/plugins/hello-world/manifest.json
new file mode 100644
index 0000000..aa62231
--- /dev/null
+++ b/examples/plugins/hello-world/manifest.json
@@ -0,0 +1,13 @@
+{
+ "name": "hello-world",
+ "displayName": "Project Stats",
+ "version": "2.0.0",
+ "description": "Scans the current project and shows file counts, lines of code, file-type breakdown, largest files, and recently modified files.",
+ "author": "Claude Code UI",
+ "icon": "icon.svg",
+ "type": "module",
+ "slot": "tab",
+ "entry": "index.js",
+ "server": "server.js",
+ "permissions": []
+}
diff --git a/examples/plugins/hello-world/server.js b/examples/plugins/hello-world/server.js
new file mode 100644
index 0000000..d800fbf
--- /dev/null
+++ b/examples/plugins/hello-world/server.js
@@ -0,0 +1,105 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const TEXT_EXTS = new Set([
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.astro',
+ '.css', '.scss', '.sass', '.less',
+ '.html', '.htm', '.xml', '.svg',
+ '.json', '.yaml', '.yml', '.toml', '.ini',
+ '.md', '.mdx', '.txt', '.rst',
+ '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.hpp', '.cs',
+ '.sh', '.bash', '.zsh', '.fish',
+ '.sql', '.graphql', '.gql',
+]);
+
+const SKIP_DIRS = new Set([
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
+ 'coverage', '.cache', '__pycache__', '.venv', 'venv',
+ 'target', 'vendor', '.turbo', 'out', '.output', 'tmp',
+]);
+
+function scan(dir, max = 5000) {
+ const files = [];
+ (function walk(d, depth) {
+ if (depth > 6 || files.length >= max) return;
+ let entries;
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
+ for (const e of entries) {
+ if (files.length >= max) break;
+ if (e.name.startsWith('.') && e.name !== '.env') continue;
+ const full = path.join(d, e.name);
+ if (e.isDirectory()) {
+ if (!SKIP_DIRS.has(e.name)) walk(full, depth + 1);
+ } else if (e.isFile()) {
+ try {
+ const stat = fs.statSync(full);
+ files.push({
+ full,
+ rel: path.relative(dir, full),
+ ext: path.extname(e.name).toLowerCase() || '(none)',
+ size: stat.size,
+ mtime: stat.mtimeMs,
+ });
+ } catch { /* skip unreadable */ }
+ }
+ }
+ })(dir, 0);
+ return files;
+}
+
+function countLines(full, size) {
+ if (size > 256 * 1024) return 0; // skip large files
+ try { return (fs.readFileSync(full, 'utf-8').match(/\n/g) || []).length + 1; }
+ catch { return 0; }
+}
+
+function getStats(projectPath) {
+ if (!projectPath || !path.isAbsolute(projectPath)) throw new Error('Invalid path');
+ if (!fs.existsSync(projectPath)) throw new Error('Path does not exist');
+
+ const files = scan(projectPath);
+ const byExt = {};
+ let totalLines = 0;
+ let totalSize = 0;
+
+ for (const f of files) {
+ byExt[f.ext] = (byExt[f.ext] || 0) + 1;
+ totalSize += f.size;
+ if (TEXT_EXTS.has(f.ext)) totalLines += countLines(f.full, f.size);
+ }
+
+ return {
+ totalFiles: files.length,
+ totalLines,
+ totalSize,
+ byExtension: Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 12),
+ largest: [...files].sort((a, b) => b.size - a.size).slice(0, 6).map(f => ({ name: f.rel, size: f.size })),
+ recent: [...files].sort((a, b) => b.mtime - a.mtime).slice(0, 6).map(f => ({ name: f.rel, mtime: f.mtime })),
+ };
+}
+
+const server = http.createServer((req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+
+ if (req.method === 'GET' && req.url.startsWith('/stats')) {
+ try {
+ const { searchParams } = new URL(req.url, 'http://localhost');
+ const stats = getStats(searchParams.get('path'));
+ res.end(JSON.stringify(stats));
+ } catch (err) {
+ res.writeHead(400);
+ res.end(JSON.stringify({ error: err.message }));
+ }
+ return;
+ }
+
+ res.writeHead(404);
+ res.end(JSON.stringify({ error: 'Not found' }));
+});
+
+server.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ // Signal readiness to the host — this JSON line is required
+ console.log(JSON.stringify({ ready: true, port }));
+});
diff --git a/server/index.js b/server/index.js
index 8f25fc2..c112266 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);
@@ -2487,7 +2492,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 = () => {
+ stopAllPlugins();
+ process.exit(0);
+ };
+ process.on('SIGTERM', shutdownPlugins);
+ process.on('SIGINT', 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 0000000..c9ecb34
--- /dev/null
+++ b/server/routes/plugins.js
@@ -0,0 +1,273 @@
+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 {
+ 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;
+ 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' });
+ }
+
+ const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
+ res.setHeader('Content-Type', contentType);
+ fs.createReadStream(resolvedPath).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)) {
+ 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) {
+ 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) => {
+ res.status(502).json({ error: 'Plugin server error', details: err.message });
+ });
+
+ // Forward body (already parsed by express JSON middleware, so re-stringify)
+ if (req.body && Object.keys(req.body).length > 0) {
+ 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', (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 if running
+ if (isPluginRunning(pluginName)) {
+ stopPluginServer(pluginName);
+ }
+
+ 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 0000000..a13a7ff
--- /dev/null
+++ b/server/utils/plugin-loader.js
@@ -0,0 +1,288 @@
+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'];
+const ALLOWED_TYPES = ['iframe', '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 });
+ }
+ fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2));
+}
+
+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(', ')}` };
+ }
+
+ 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;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) 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;
+ }
+
+ 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 || 'iframe',
+ 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,
+ });
+ } 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 — resolved path must be within plugin directory
+ if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
+ return null;
+ }
+
+ if (!fs.existsSync(resolved)) return null;
+
+ return resolved;
+}
+
+export function installPluginFromGit(url) {
+ return new Promise((resolve, reject) => {
+ // 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.join(pluginsDir, repoName);
+
+ if (fs.existsSync(targetDir)) {
+ return reject(new Error(`Plugin directory "${repoName}" already exists`));
+ }
+
+ const gitProcess = spawn('git', ['clone', '--depth', '1', url, targetDir], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stderr = '';
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
+
+ gitProcess.on('close', (code) => {
+ if (code !== 0) {
+ // Clean up failed clone
+ try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch {}
+ return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
+ }
+
+ // Validate manifest exists
+ const manifestPath = path.join(targetDir, 'manifest.json');
+ if (!fs.existsSync(manifestPath)) {
+ fs.rmSync(targetDir, { recursive: true, force: true });
+ return reject(new Error('Cloned repository does not contain a manifest.json'));
+ }
+
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ } catch {
+ fs.rmSync(targetDir, { recursive: true, force: true });
+ return reject(new Error('manifest.json is not valid JSON'));
+ }
+
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ fs.rmSync(targetDir, { recursive: true, force: true });
+ return reject(new Error(`Invalid manifest: ${validation.error}`));
+ }
+
+ // Run npm install if package.json exists.
+ // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
+ const packageJsonPath = path.join(targetDir, 'package.json');
+ if (fs.existsSync(packageJsonPath)) {
+ const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
+ cwd: targetDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ npmProcess.on('close', (npmCode) => {
+ if (npmCode !== 0) {
+ console.warn(`[Plugins] npm install for ${repoName} exited with code ${npmCode}`);
+ }
+ resolve(manifest);
+ });
+
+ npmProcess.on('error', () => {
+ // npm not available, continue anyway
+ resolve(manifest);
+ });
+ } else {
+ resolve(manifest);
+ }
+ });
+
+ gitProcess.on('error', (err) => {
+ 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', () => resolve(manifest));
+ npmProcess.on('error', () => resolve(manifest));
+ } else {
+ resolve(manifest);
+ }
+ });
+
+ gitProcess.on('error', (err) => {
+ reject(new Error(`Failed to spawn git: ${err.message}`));
+ });
+ });
+}
+
+export function uninstallPlugin(name) {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) {
+ throw new Error(`Plugin "${name}" not found`);
+ }
+
+ fs.rmSync(pluginDir, { recursive: true, force: true });
+
+ // 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 0000000..11f8fd2
--- /dev/null
+++ b/server/utils/plugin-process-manager.js
@@ -0,0 +1,162 @@
+import { spawn } from 'child_process';
+import path from 'path';
+import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
+
+// Map
+const runningPlugins = 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) {
+ return new Promise((resolve, reject) => {
+ if (runningPlugins.has(name)) {
+ return resolve(runningPlugins.get(name).port);
+ }
+
+ 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`));
+ }
+ });
+ });
+}
+
+/**
+ * Stop a plugin's server subprocess.
+ */
+export function stopPluginServer(name) {
+ const entry = runningPlugins.get(name);
+ if (!entry) return;
+
+ entry.process.kill('SIGTERM');
+
+ // Force kill after 5 seconds if still running
+ const forceKillTimer = setTimeout(() => {
+ if (runningPlugins.has(name)) {
+ entry.process.kill('SIGKILL');
+ runningPlugins.delete(name);
+ }
+ }, 5000);
+
+ entry.process.on('exit', () => {
+ clearTimeout(forceKillTimer);
+ });
+
+ 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() {
+ for (const [name] of runningPlugins) {
+ stopPluginServer(name);
+ }
+}
+
+/**
+ * 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 8593236..bd27d10 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
+import { PluginsProvider } from './contexts/PluginsContext';
import ProtectedRoute from './components/ProtectedRoute';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -15,8 +16,9 @@ export default function App() {
-
-
+
+
+
@@ -25,8 +27,9 @@ export default function App() {
-
-
+
+
+
diff --git a/src/components/MobileNav.jsx b/src/components/MobileNav.jsx
index 6c26579..acdea88 100644
--- a/src/components/MobileNav.jsx
+++ b/src/components/MobileNav.jsx
@@ -1,43 +1,48 @@
-import React from 'react';
-import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
+import React, { useState, useRef, useEffect } from 'react';
+import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTaskMaster } from '../contexts/TaskMasterContext';
+import { usePlugins } from '../contexts/PluginsContext';
+
+const PLUGIN_ICON_MAP = {
+ Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
+};
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
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) => {
+ if (moreRef.current && !moreRef.current.contains(e.target)) {
+ setMoreOpen(false);
+ }
+ };
+ document.addEventListener('pointerdown', handleTap);
+ return () => document.removeEventListener('pointerdown', handleTap);
+ }, [moreOpen]);
+
+ // Close menu when a plugin tab is selected
+ const selectPlugin = (name) => {
+ setActiveTab(`plugin:${name}`);
+ setMoreOpen(false);
+ };
+
+ const coreItems = [
+ { id: 'chat', icon: MessageSquare, label: 'Chat' },
+ { id: 'shell', icon: Terminal, label: 'Shell' },
+ { id: 'files', icon: Folder, label: 'Files' },
+ { id: 'git', icon: GitBranch, label: 'Git' },
+ ...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
];
return (
@@ -48,17 +53,17 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
>
- {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={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
isActive
@@ -81,6 +86,62 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
);
})}
+
+ {/* "More" button — only shown when there are enabled plugins */}
+ {hasPlugins && (
+
+
setMoreOpen((v) => !v)}
+ onTouchStart={(e) => {
+ 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 ${
+ 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 items-center gap-2.5 w-full px-3.5 py-2.5 text-sm transition-colors ${
+ isActive
+ ? 'text-primary bg-primary/8'
+ : 'text-foreground hover:bg-muted/60'
+ }`}
+ >
+
+ {p.displayName}
+
+ );
+ })}
+
+ )}
+
+ )}
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index 3a03022..7b6c09f 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -9,6 +9,7 @@ import ErrorBoundary from '../../ErrorBoundary';
import MainContentHeader from './subcomponents/MainContentHeader';
import MainContentStateView from './subcomponents/MainContentStateView';
import TaskMasterPanel from './subcomponents/TaskMasterPanel';
+import PluginTabContent from '../../plugins/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
@@ -158,6 +159,16 @@ function MainContent({
{shouldShowTasksTab && }
+
+ {activeTab.startsWith('plugin:') && (
+
+ )}
p.enabled)
+ .map((p) => ({
+ kind: 'plugin',
+ id: `plugin:${p.name}` as AppTab,
+ label: p.displayName,
+ pluginName: p.name,
+ iconFile: p.icon,
+ }));
+
+ const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs];
return (
{tabs.map((tab) => {
- const Icon = tab.icon;
const isActive = tab.id === activeTab;
+ const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;
return (
-
+
setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 px-2.5 py-[5px] text-sm font-medium rounded-md transition-all duration-150 ${
@@ -54,8 +88,16 @@ export default function MainContentTabSwitcher({
: 'text-muted-foreground hover:text-foreground'
}`}
>
-
- {t(tab.labelKey)}
+ {tab.kind === 'builtin' ? (
+
+ ) : (
+
+ )}
+ {displayLabel}
);
diff --git a/src/components/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
index a9f095c..5b93fbb 100644
--- a/src/components/main-content/view/subcomponents/MainContentTitle.tsx
+++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
+import { usePlugins } from '../../../../contexts/PluginsContext';
type MainContentTitleProps = {
activeTab: AppTab;
@@ -9,7 +10,11 @@ type MainContentTitleProps = {
shouldShowTasksTab: boolean;
};
-function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
+function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {
+ if (activeTab.startsWith('plugin:') && pluginDisplayName) {
+ return pluginDisplayName;
+ }
+
if (activeTab === 'files') {
return t('mainContent.projectFiles');
}
@@ -40,6 +45,11 @@ export default function MainContentTitle({
shouldShowTasksTab,
}: MainContentTitleProps) {
const { t } = useTranslation();
+ const { plugins } = usePlugins();
+
+ const pluginDisplayName = activeTab.startsWith('plugin:')
+ ? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName
+ : undefined;
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
const showChatNewSession = activeTab === 'chat' && !selectedSession;
@@ -68,7 +78,7 @@ export default function MainContentTitle({
) : (
- {getTabTitle(activeTab, shouldShowTasksTab, t)}
+ {getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
{selectedProject.displayName}
diff --git a/src/components/plugins/PluginSettingsTab.tsx b/src/components/plugins/PluginSettingsTab.tsx
new file mode 100644
index 0000000..c1d6578
--- /dev/null
+++ b/src/components/plugins/PluginSettingsTab.tsx
@@ -0,0 +1,347 @@
+import { useState } from 'react';
+import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ChevronRight } from 'lucide-react';
+import { usePlugins } from '../../contexts/PluginsContext';
+import PluginIcon from './PluginIcon';
+import type { Plugin } from '../../contexts/PluginsContext';
+
+/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
+function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
+ return (
+
+ onChange(e.target.checked)}
+ />
+
+
+ );
+}
+
+/* ─── Server Dot ────────────────────────────────────────────────────────── */
+function ServerDot({ running }: { running: boolean }) {
+ if (!running) return null;
+ return (
+
+
+
+
+
+
+ running
+
+
+ );
+}
+
+/* ─── Plugin Card ───────────────────────────────────────────────────────── */
+type PluginCardProps = {
+ plugin: Plugin;
+ index: number;
+ onToggle: (enabled: boolean) => void;
+ onUpdate: () => void;
+ onUninstall: () => void;
+ updating: boolean;
+ confirmingUninstall: boolean;
+ onCancelUninstall: () => void;
+ updateError: string | null;
+};
+
+function PluginCard({
+ plugin,
+ index,
+ onToggle,
+ onUpdate,
+ onUninstall,
+ updating,
+ confirmingUninstall,
+ onCancelUninstall,
+ updateError,
+}: PluginCardProps) {
+ const accentColor = plugin.enabled
+ ? 'bg-emerald-500'
+ : 'bg-muted-foreground/20';
+
+ return (
+
+ {/* Left accent bar */}
+
+
+
+ {/* Header row */}
+
+
+
+
+
+
+ {plugin.displayName}
+
+
+ v{plugin.version}
+
+
+ {plugin.type}
+
+
+
+ {plugin.description && (
+
+ {plugin.description}
+
+ )}
+ {plugin.author && (
+
+ {plugin.author}
+
+ )}
+
+
+
+ {/* Controls */}
+
+
+ {updating ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {/* Confirm uninstall banner */}
+ {confirmingUninstall && (
+
+
+ Remove {plugin.displayName} ? This cannot be undone.
+
+
+
+ Cancel
+
+
+ Remove
+
+
+
+ )}
+
+ {/* Update error */}
+ {updateError && (
+
+
+ {updateError}
+
+ )}
+
+
+ );
+}
+
+/* ─── Empty State ───────────────────────────────────────────────────────── */
+function EmptyState() {
+ return (
+
+
+
~/.claude-code-ui/plugins/
+
+
+ (empty)
+
+
+
No plugins installed
+
+ Install from git or drop a folder in the plugins directory
+
+
+ );
+}
+
+/* ─── Main Component ────────────────────────────────────────────────────── */
+export default function PluginSettingsTab() {
+ const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
+ usePlugins();
+
+ const [gitUrl, setGitUrl] = useState('');
+ const [installing, setInstalling] = useState(false);
+ const [installError, setInstallError] = useState(null);
+ const [confirmUninstall, setConfirmUninstall] = useState(null);
+ const [updatingPlugin, setUpdatingPlugin] = useState(null);
+ const [updateErrors, setUpdateErrors] = useState>({});
+
+ const handleUpdate = async (name: string) => {
+ setUpdatingPlugin(name);
+ setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
+ const result = await updatePlugin(name);
+ if (!result.success) {
+ setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' }));
+ }
+ setUpdatingPlugin(null);
+ };
+
+ const handleInstall = async () => {
+ if (!gitUrl.trim()) return;
+ setInstalling(true);
+ setInstallError(null);
+ const result = await installPlugin(gitUrl.trim());
+ if (result.success) {
+ setGitUrl('');
+ } else {
+ setInstallError(result.error || 'Installation failed');
+ }
+ setInstalling(false);
+ };
+
+ const handleUninstall = async (name: string) => {
+ if (confirmUninstall !== name) {
+ setConfirmUninstall(name);
+ return;
+ }
+ await uninstallPlugin(name);
+ setConfirmUninstall(null);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Plugins
+
+
+ Extend the interface with custom tabs. Drop a folder in{' '}
+
+ ~/.claude-code-ui/plugins/
+ {' '}
+ or install from git.
+
+
+ {!loading && plugins.length > 0 && (
+
+ {plugins.filter((p) => p.enabled).length}/{plugins.length}
+
+ )}
+
+
+ {/* Install from Git */}
+
+
+
+
+ Install from git
+
+
+
+
+
+ $
+
+ {
+ setGitUrl(e.target.value);
+ setInstallError(null);
+ }}
+ placeholder="git clone https://github.com/user/my-plugin"
+ className="flex-1 px-1.5 py-2.5 text-xs font-mono bg-transparent text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') void handleInstall();
+ }}
+ />
+
+ {installing ? (
+
+ ) : (
+ 'Install'
+ )}
+
+
+
+ {installError && (
+
{installError}
+ )}
+
+
+ {/* Plugin List */}
+
+ {loading ? (
+
+
+ scanning plugins…
+
+ ) : plugins.length === 0 ? (
+
+ ) : (
+ plugins.map((plugin, index) => (
+
void togglePlugin(plugin.name, enabled)}
+ onUpdate={() => void handleUpdate(plugin.name)}
+ onUninstall={() => void handleUninstall(plugin.name)}
+ updating={updatingPlugin === plugin.name}
+ confirmingUninstall={confirmUninstall === plugin.name}
+ onCancelUninstall={() => setConfirmUninstall(null)}
+ updateError={updateErrors[plugin.name] ?? null}
+ />
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/components/plugins/PluginTabContent.tsx b/src/components/plugins/PluginTabContent.tsx
new file mode 100644
index 0000000..602824a
--- /dev/null
+++ b/src/components/plugins/PluginTabContent.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useRef } from 'react';
+import { useTheme } from '../../contexts/ThemeContext';
+import { authenticatedFetch } from '../../utils/api';
+import { usePlugins } from '../../contexts/PluginsContext';
+import type { Project, ProjectSession } from '../../types/app';
+
+type PluginTabContentProps = {
+ pluginName: string;
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+};
+
+type PluginContext = {
+ theme: 'dark' | 'light';
+ project: { name: string; path: string } | null;
+ session: { id: string; title: string } | null;
+};
+
+function buildContext(
+ isDarkMode: boolean,
+ selectedProject: Project | null,
+ selectedSession: ProjectSession | null,
+): PluginContext {
+ return {
+ theme: isDarkMode ? 'dark' : 'light',
+ project: selectedProject
+ ? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
+ : null,
+ session: selectedSession
+ ? { id: selectedSession.id, title: selectedSession.title }
+ : null,
+ };
+}
+
+export default function PluginTabContent({
+ pluginName,
+ selectedProject,
+ selectedSession,
+}: PluginTabContentProps) {
+ const containerRef = useRef(null);
+ const { isDarkMode } = useTheme();
+ const { plugins } = usePlugins();
+
+ // Stable refs so effects don't need context values in their dep arrays
+ const contextRef = useRef(buildContext(isDarkMode, selectedProject, selectedSession));
+ const contextCallbacksRef = useRef void>>(new Set());
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const moduleRef = useRef(null);
+
+ const plugin = plugins.find(p => p.name === pluginName);
+
+ // Keep contextRef current and notify the mounted plugin on every context change
+ useEffect(() => {
+ const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
+ contextRef.current = ctx;
+
+ for (const cb of contextCallbacksRef.current) {
+ try { cb(ctx); } catch { /* plugin error — ignore */ }
+ }
+ }, [isDarkMode, selectedProject, selectedSession]);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ let active = true;
+ const container = containerRef.current;
+ const entryFile = plugin?.entry ?? 'index.js';
+
+ (async () => {
+ try {
+ // Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
+ // Then import it via a Blob URL so the browser never makes an unauthenticated request.
+ const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`;
+ const res = await authenticatedFetch(assetUrl);
+ if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
+ const jsText = await res.text();
+ const blob = new Blob([jsText], { type: 'application/javascript' });
+ const blobUrl = URL.createObjectURL(blob);
+ // @vite-ignore
+ const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
+ if (!active || !containerRef.current) return;
+
+ moduleRef.current = mod;
+
+ const api = {
+ get context(): PluginContext { return contextRef.current; },
+
+ onContextChange(cb: (ctx: PluginContext) => void): () => void {
+ contextCallbacksRef.current.add(cb);
+ return () => contextCallbacksRef.current.delete(cb);
+ },
+
+ async rpc(method: string, path: string, body?: unknown): Promise {
+ const cleanPath = String(path).replace(/^\//, '');
+ const res = await authenticatedFetch(
+ `/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
+ {
+ method: method || 'GET',
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ },
+ );
+ if (!res.ok) throw new Error(`RPC error ${res.status}`);
+ return res.json();
+ },
+ };
+
+ await mod.mount?.(container, api);
+ } catch (err) {
+ if (!active) return;
+ console.error(`[Plugin:${pluginName}] Failed to load:`, err);
+ if (containerRef.current) {
+ containerRef.current.innerHTML = `Plugin failed to load: ${String(err)}
`;
+ }
+ }
+ })();
+
+ return () => {
+ active = false;
+ try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
+ contextCallbacksRef.current.clear();
+ moduleRef.current = null;
+ };
+ }, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
+
+ return
;
+}
diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts
index 7466ef4..eff5e13 100644
--- a/src/components/settings/types/types.ts
+++ b/src/components/settings/types/types.ts
@@ -1,6 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
-export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
+export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'plugins';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';
diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx
index ab861a2..d1a4776 100644
--- a/src/components/settings/view/Settings.tsx
+++ b/src/components/settings/view/Settings.tsx
@@ -10,6 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
+import PluginSettingsTab from '../../plugins/PluginSettingsTab';
import { useSettingsController } from '../hooks/useSettingsController';
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
@@ -176,6 +177,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
)}
+
+ {activeTab === 'plugins' && (
+
+ )}
diff --git a/src/components/settings/view/SettingsMainTabs.tsx b/src/components/settings/view/SettingsMainTabs.tsx
index f1886f1..f868b62 100644
--- a/src/components/settings/view/SettingsMainTabs.tsx
+++ b/src/components/settings/view/SettingsMainTabs.tsx
@@ -1,4 +1,4 @@
-import { GitBranch, Key } from 'lucide-react';
+import { GitBranch, Key, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SettingsMainTab } from '../types/types';
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
type MainTabConfig = {
id: SettingsMainTab;
- labelKey: string;
+ labelKey?: string;
+ label?: string;
icon?: typeof GitBranch;
};
@@ -19,6 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
+ { id: 'plugins', label: 'Plugins', icon: Puzzle },
];
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
@@ -44,7 +46,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
}`}
>
{Icon && }
- {t(tab.labelKey)}
+ {tab.labelKey ? t(tab.labelKey) : tab.label}
);
})}
diff --git a/src/contexts/PluginsContext.tsx b/src/contexts/PluginsContext.tsx
new file mode 100644
index 0000000..4da04fd
--- /dev/null
+++ b/src/contexts/PluginsContext.tsx
@@ -0,0 +1,130 @@
+import { createContext, useCallback, useContext, useEffect, useState } from 'react';
+import type { ReactNode } from 'react';
+import { authenticatedFetch } from '../utils/api';
+
+export type Plugin = {
+ name: string;
+ displayName: string;
+ version: string;
+ description: string;
+ author: string;
+ icon: string;
+ type: 'iframe' | 'react' | 'module';
+ slot: 'tab';
+ entry: string;
+ server: string | null;
+ permissions: string[];
+ enabled: boolean;
+ serverRunning: boolean;
+ dirName: string;
+};
+
+type PluginsContextValue = {
+ plugins: Plugin[];
+ loading: boolean;
+ refreshPlugins: () => Promise;
+ installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
+ uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
+ updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
+ togglePlugin: (name: string, enabled: boolean) => Promise;
+};
+
+const PluginsContext = createContext(null);
+
+export function usePlugins() {
+ const context = useContext(PluginsContext);
+ if (!context) {
+ throw new Error('usePlugins must be used within a PluginsProvider');
+ }
+ return context;
+}
+
+export function PluginsProvider({ children }: { children: ReactNode }) {
+ const [plugins, setPlugins] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refreshPlugins = useCallback(async () => {
+ try {
+ const res = await authenticatedFetch('/api/plugins');
+ if (res.ok) {
+ const data = await res.json();
+ setPlugins(data.plugins || []);
+ }
+ } catch (err) {
+ console.error('[Plugins] Failed to fetch plugins:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refreshPlugins();
+ }, [refreshPlugins]);
+
+ const installPlugin = useCallback(async (url: string) => {
+ try {
+ const res = await authenticatedFetch('/api/plugins/install', {
+ method: 'POST',
+ body: JSON.stringify({ url }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ await refreshPlugins();
+ return { success: true };
+ }
+ return { success: false, error: data.details || data.error || 'Install failed' };
+ } catch (err) {
+ return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
+ }
+ }, [refreshPlugins]);
+
+ const uninstallPlugin = useCallback(async (name: string) => {
+ try {
+ const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
+ method: 'DELETE',
+ });
+ const data = await res.json();
+ if (res.ok) {
+ await refreshPlugins();
+ return { success: true };
+ }
+ return { success: false, error: data.details || data.error || 'Uninstall failed' };
+ } catch (err) {
+ return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
+ }
+ }, [refreshPlugins]);
+
+ const updatePlugin = useCallback(async (name: string) => {
+ try {
+ const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
+ method: 'POST',
+ });
+ const data = await res.json();
+ if (res.ok) {
+ await refreshPlugins();
+ return { success: true };
+ }
+ return { success: false, error: data.details || data.error || 'Update failed' };
+ } catch (err) {
+ return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
+ }
+ }, [refreshPlugins]);
+
+ const togglePlugin = useCallback(async (name: string, enabled: boolean) => {
+ try {
+ await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
+ method: 'PUT',
+ body: JSON.stringify({ enabled }),
+ });
+ await refreshPlugins();
+ } catch (err) {
+ console.error('[Plugins] Failed to toggle plugin:', err);
+ }
+ }, [refreshPlugins]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts
index 61333f3..8461253 100644
--- a/src/hooks/useProjectsState.ts
+++ b/src/hooks/useProjectsState.ts
@@ -106,10 +106,14 @@ const isUpdateAdditive = (
const VALID_TABS: Set = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
+const isValidTab = (tab: string): tab is AppTab => {
+ return VALID_TABS.has(tab) || tab.startsWith('plugin:');
+};
+
const readPersistedTab = (): AppTab => {
try {
const stored = localStorage.getItem('activeTab');
- if (stored && VALID_TABS.has(stored)) {
+ if (stored && isValidTab(stored)) {
return stored as AppTab;
}
} catch {
diff --git a/src/types/app.ts b/src/types/app.ts
index a19d278..9abaac6 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,6 +1,6 @@
export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
-export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
+export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
export interface ProjectSession {
id: string;