v3/implementation/adrs/ADR-015-v2-unified-plugin-system.md
Accepted - Supersedes ADR-015
2026-01-16
ADR-015 established the unified plugin system with the microkernel pattern. This revision addresses identified gaps and adds production-ready features:
| Issue | Severity | Resolution |
|---|---|---|
| No version constraint enforcement | Medium | Semver range checking for dependencies |
| Missing dependent check on unload | Medium | Dependency graph prevents unsafe removal |
| No parallel initialization | Low | Configurable parallel/sequential init |
| No plugin sandboxing | Medium | Worker thread isolation option |
| Service discoverability | Low | ServiceContainer.list() method |
| No rollback on partial failure | Medium | Transaction-based initialization |
Enhance the plugin system with the following additions:
interface PluginDependency {
name: string;
version: string; // Semver range: "^3.0.0", ">=2.1.0 <3.0.0"
optional?: boolean; // Don't fail if missing
peerDependency?: boolean; // Expect host to provide
}
// In PluginMetadata
dependencies?: PluginDependency[];
Validation Algorithm:
function satisfiesVersion(required: string, actual: string): boolean {
// Uses semver range matching
// "^3.0.0" matches 3.x.x
// "~3.1.0" matches 3.1.x
// ">=2.0.0 <3.0.0" matches 2.x.x
}
class DependencyGraph {
private adjacencyList = new Map<string, Set<string>>();
private reverseAdjacency = new Map<string, Set<string>>();
addPlugin(name: string, dependencies: string[]): void;
removePlugin(name: string): void;
getDependents(name: string): string[]; // Who depends on this?
getDependencies(name: string): string[]; // What does this depend on?
getLoadOrder(): string[]; // Topological sort
canSafelyRemove(name: string): boolean; // No dependents?
getRemovalOrder(name: string): string[]; // Cascade unload order
}
Safe Unload Protocol:
async unregister(name: string, options?: UnregisterOptions): Promise<void> {
const dependents = this.dependencyGraph.getDependents(name);
if (dependents.length > 0) {
if (options?.cascade) {
// Unload dependents first (in reverse order)
const order = this.dependencyGraph.getRemovalOrder(name);
for (const dep of order) {
await this.shutdownPlugin(dep);
}
} else if (options?.force) {
this.logger.warn(`Force removing ${name}, breaking: ${dependents.join(', ')}`);
} else {
throw new Error(`Cannot remove ${name}: required by ${dependents.join(', ')}`);
}
}
await this.shutdownPlugin(name);
}
interface PluginRegistryConfig {
// ... existing config
initializationStrategy: 'sequential' | 'parallel' | 'parallel-safe';
maxParallelInit?: number; // Limit concurrent initializations
}
// Parallel-safe strategy:
// 1. Group plugins by dependency depth
// 2. Initialize each depth level in parallel
// 3. Wait for level N before starting level N+1
Implementation:
async initializeParallel(): Promise<void> {
const levels = this.dependencyGraph.getDepthLevels();
for (const level of levels) {
const promises = level.map(name =>
this.initializeWithTimeout(this.plugins.get(name)!)
);
const results = await Promise.allSettled(promises);
// Handle failures - mark as error, continue with others
for (let i = 0; i < results.length; i++) {
if (results[i].status === 'rejected') {
this.plugins.get(level[i])!.error = results[i].reason;
}
}
}
}
interface ServiceContainer {
get<T>(key: string): T | undefined;
set<T>(key: string, value: T): void;
has(key: string): boolean;
delete(key: string): boolean;
// New methods
list(): string[]; // All registered keys
listByPrefix(prefix: string): string[]; // Filter by prefix
getMetadata(key: string): ServiceMetadata | undefined;
setWithMetadata<T>(key: string, value: T, metadata: ServiceMetadata): void;
}
interface ServiceMetadata {
description?: string;
provider: string; // Plugin that registered it
version?: string;
deprecated?: boolean;
replacement?: string;
}
interface PluginCollection {
readonly id: string;
readonly name: string;
readonly version: string;
readonly description?: string;
readonly author?: string;
readonly plugins: PluginCollectionEntry[];
readonly categories?: string[];
readonly license?: string;
readonly repository?: string;
}
interface PluginCollectionEntry {
readonly plugin: IPlugin | PluginFactory;
readonly defaultEnabled: boolean;
readonly category: PluginCategory;
readonly tags?: string[];
readonly requiredCapabilities?: PluginCapability[];
}
type PluginCategory =
| 'agent' // Agent types and definitions
| 'task' // Task types and handlers
| 'tool' // MCP tools
| 'memory' // Memory backends
| 'provider' // LLM providers
| 'hook' // Lifecycle hooks
| 'worker' // Background workers
| 'integration' // External integrations
| 'utility'; // General utilities
type PluginCapability =
| 'network' // Can make network requests
| 'filesystem' // Can access filesystem
| 'subprocess' // Can spawn processes
| 'memory' // Can store persistent data
| 'llm' // Can call LLM APIs
| 'mcp'; // Can register MCP tools
class PluginCollectionManager {
private collections = new Map<string, PluginCollection>();
private enabledPlugins = new Set<string>();
private registry: PluginRegistry;
// Collection management
async loadCollection(collection: PluginCollection): Promise<void>;
async unloadCollection(collectionId: string): Promise<void>;
listCollections(): PluginCollection[];
getCollection(id: string): PluginCollection | undefined;
// Plugin activation
async enablePlugin(collectionId: string, pluginName: string): Promise<void>;
async disablePlugin(collectionId: string, pluginName: string): Promise<void>;
isEnabled(collectionId: string, pluginName: string): boolean;
// Bulk operations
async enableCategory(category: PluginCategory): Promise<void>;
async disableCategory(category: PluginCategory): Promise<void>;
async enableAll(collectionId: string): Promise<void>;
async disableAll(collectionId: string): Promise<void>;
// Filtering
getPluginsByCategory(category: PluginCategory): PluginCollectionEntry[];
getPluginsByTag(tag: string): PluginCollectionEntry[];
searchPlugins(query: string): PluginCollectionEntry[];
// State persistence
async saveState(path: string): Promise<void>;
async loadState(path: string): Promise<void>;
exportState(): CollectionManagerState;
importState(state: CollectionManagerState): Promise<void>;
}
interface CollectionManagerState {
version: string;
collections: string[];
enabledPlugins: Record<string, string[]>; // collectionId -> pluginNames
settings: Record<string, Record<string, unknown>>; // plugin settings
}
// Core collection - essential plugins
const coreCollection: PluginCollection = {
id: 'claude-flow-core',
name: 'Claude Flow Core Plugins',
version: '3.0.0',
plugins: [
{ plugin: memoryPlugin, defaultEnabled: true, category: 'memory' },
{ plugin: agentDBPlugin, defaultEnabled: true, category: 'memory' },
{ plugin: sessionPlugin, defaultEnabled: true, category: 'hook' },
]
};
// Development collection - coding assistance
const developmentCollection: PluginCollection = {
id: 'claude-flow-development',
name: 'Development Tools',
version: '3.0.0',
plugins: [
{ plugin: coderAgentPlugin, defaultEnabled: true, category: 'agent' },
{ plugin: testerAgentPlugin, defaultEnabled: true, category: 'agent' },
{ plugin: reviewerAgentPlugin, defaultEnabled: false, category: 'agent' },
{ plugin: gitIntegrationPlugin, defaultEnabled: true, category: 'integration' },
{ plugin: linterPlugin, defaultEnabled: false, category: 'tool' },
]
};
// Intelligence collection - AI/ML features
const intelligenceCollection: PluginCollection = {
id: 'claude-flow-intelligence',
name: 'Intelligence & Learning',
version: '3.0.0',
plugins: [
{ plugin: sonaPlugin, defaultEnabled: false, category: 'integration' },
{ plugin: reasoningBankPlugin, defaultEnabled: false, category: 'memory' },
{ plugin: patternLearningPlugin, defaultEnabled: false, category: 'hook' },
]
};
// Swarm collection - multi-agent coordination
const swarmCollection: PluginCollection = {
id: 'claude-flow-swarm',
name: 'Swarm Coordination',
version: '3.0.0',
plugins: [
{ plugin: hiveMindPlugin, defaultEnabled: true, category: 'integration' },
{ plugin: maestroPlugin, defaultEnabled: true, category: 'integration' },
{ plugin: consensusPlugin, defaultEnabled: false, category: 'integration' },
{ plugin: coordinatorAgentPlugin, defaultEnabled: true, category: 'agent' },
]
};
// Security collection - security features
const securityCollection: PluginCollection = {
id: 'claude-flow-security',
name: 'Security & Audit',
version: '3.0.0',
plugins: [
{ plugin: inputValidationPlugin, defaultEnabled: true, category: 'hook' },
{ plugin: pathSecurityPlugin, defaultEnabled: true, category: 'hook' },
{ plugin: auditLogPlugin, defaultEnabled: false, category: 'hook' },
{ plugin: securityScanPlugin, defaultEnabled: false, category: 'tool' },
]
};
interface HotReloadOptions {
preserveState?: boolean; // Keep plugin state across reload
migrateState?: (oldState: unknown, newVersion: string) => unknown;
timeout?: number;
}
class PluginRegistry {
async reload(
name: string,
newPlugin: IPlugin | PluginFactory,
options?: HotReloadOptions
): Promise<void> {
const entry = this.plugins.get(name);
if (!entry) throw new Error(`Plugin ${name} not found`);
// Capture state if preserving
let state: unknown;
if (options?.preserveState && entry.plugin.getState) {
state = await entry.plugin.getState();
}
// Shutdown old plugin
await entry.plugin.shutdown();
// Resolve and validate new plugin
const resolved = typeof newPlugin === 'function' ? await newPlugin() : newPlugin;
if (!validatePlugin(resolved)) {
throw new Error('Invalid plugin replacement');
}
// Verify same name
if (resolved.metadata.name !== name) {
throw new Error(`Plugin name mismatch: expected ${name}, got ${resolved.metadata.name}`);
}
// Initialize new plugin
const context = this.createPluginContext(entry);
await this.initializeWithTimeout(resolved, context);
// Restore state if applicable
if (state && options?.migrateState) {
state = options.migrateState(state, resolved.metadata.version);
}
if (state && resolved.setState) {
await resolved.setState(state);
}
// Update entry
entry.plugin = resolved;
entry.initTime = new Date();
entry.error = undefined;
// Recollect extension points
this.invalidateCaches();
this.logger.info(`Plugin reloaded: ${name} -> v${resolved.metadata.version}`);
}
}
interface ConflictResolution {
strategy: 'first' | 'last' | 'error' | 'namespace';
namespaceTemplate?: string; // e.g., "{plugin}:{name}"
}
interface PluginRegistryConfig {
// ... existing config
conflictResolution?: {
mcpTools?: ConflictResolution;
cliCommands?: ConflictResolution;
agentTypes?: ConflictResolution;
taskTypes?: ConflictResolution;
};
}
// With namespace strategy, tools become:
// plugin-a:search, plugin-b:search
// Instead of conflicting "search" names
interface PluginPermissions {
network?: boolean | { allowedHosts: string[] };
filesystem?: boolean | { allowedPaths: string[], readOnly?: boolean };
subprocess?: boolean | { allowedCommands: string[] };
memory?: boolean | { maxSizeMb: number };
llm?: boolean | { allowedModels: string[], maxTokensPerDay?: number };
mcp?: boolean | { maxTools: number };
}
interface PluginConfig {
// ... existing config
permissions?: PluginPermissions;
}
// Permission enforcement in context
class SecurePluginContext implements PluginContext {
private permissions: PluginPermissions;
async fetch(url: string): Promise<Response> {
if (!this.permissions.network) {
throw new PermissionDeniedError('network access not permitted');
}
if (typeof this.permissions.network === 'object') {
const host = new URL(url).hostname;
if (!this.permissions.network.allowedHosts.includes(host)) {
throw new PermissionDeniedError(`host ${host} not in allowlist`);
}
}
return fetch(url);
}
}
.claude-flow/plugins/installed.json| Metric | ADR-015 | ADR-015-v2 |
|---|---|---|
| Plugin load time | ~20ms | ~20ms |
| Hook execution | ~0.5ms | ~0.5ms |
| Parallel init (10 plugins) | N/A | ~100ms total |
| Dependency resolution | ~1ms | ~2ms (with graph) |
| Collection load | N/A | ~50ms |
| Hot reload | N/A | ~100ms |
Plugin Registry Tests 45 tests (was 23)
Dependency Graph Tests 20 tests (new)
Version Constraint Tests 15 tests (new)
Collection Manager Tests 25 tests (new)
Permission Model Tests 20 tests (new)
Hot Reload Tests 15 tests (new)
SDK Builder Tests 17 tests (unchanged)
Security Module Tests 40 tests (unchanged)
─────────────────────────────────────────────
Total 197 tests
// Existing plugins work as-is
const myPlugin = new PluginBuilder('my-plugin', '1.0.0')
.withMCPTools([...])
.build();
// Add version constraints to dependencies
const myPlugin = new PluginBuilder('my-plugin', '1.0.0')
.withDependencies([
{ name: 'core-plugin', version: '^3.0.0' },
{ name: 'optional-feature', version: '>=1.0.0', optional: true }
])
.withMCPTools([...])
.build();
// Organize plugins into collections
const myCollection: PluginCollection = {
id: 'my-collection',
name: 'My Plugin Collection',
version: '1.0.0',
plugins: [
{ plugin: pluginA, defaultEnabled: true, category: 'tool' },
{ plugin: pluginB, defaultEnabled: false, category: 'agent' },
]
};
await collectionManager.loadCollection(myCollection);
| Feature | Status | Notes |
|---|---|---|
plugins install --name <pkg> | Working | Installs from npm, persists to manifest |
plugins list --installed | Working | Reads from persisted manifest |
plugins uninstall --name <pkg> | Working | Removes from npm and manifest |
plugins toggle --name <pkg> | Working | Enable/disable persists to manifest |
plugins upgrade --name <pkg> | Working | Upgrades via npm |
plugins list (registry) | Working | Shows available plugins (demo + real npm stats) |
plugins search | Working | Searches plugin registry |
plugins info | Working | Shows detailed plugin info |
| Local plugin install | Working | plugins install --name ./path/to/plugin |
| Feature | Status | Notes |
|---|---|---|
| IPFS Registry | Demo | CIDs are placeholders, falls back to hardcoded list |
| IPNS Resolution | Demo | Returns demo registry, npm stats are real |
| Plugin Signature Verification | Demo | Checks format only, no real crypto verification |
| Dynamic CLI Command Registration | TBD | Plugins can't yet add new CLI commands at runtime |
| Hook Integration | TBD | Plugin hooks not yet loaded by CLI |
CLI Commands (plugins.ts)
│
└── PluginManager (manager.ts)
│
├── InstalledPlugins manifest (.claude-flow/plugins/installed.json)
├── npm install/uninstall
└── PluginDiscoveryService (discovery.ts)
│
├── Demo Registry (hardcoded, with real npm stats)
└── IPFS/IPNS (demo mode, returns demo registry)