@bahulam/code 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +5 -2
  2. package/src/agents/loader.mjs +26 -4
  3. package/src/auth/bahulam-auth.mjs +2 -13
  4. package/src/auth/tarang-auth.mjs +313 -0
  5. package/src/commands/agent.mjs +7 -7
  6. package/src/commands/plugin-manage.mjs +449 -0
  7. package/src/commands/plugin.mjs +247 -0
  8. package/src/config/cli-args.mjs +16 -0
  9. package/src/config/env.mjs +2 -0
  10. package/src/config/hook-runner.mjs +8 -8
  11. package/src/config/memory-loader.mjs +7 -3
  12. package/src/config/settings-loader.mjs +5 -3
  13. package/src/core/attachments.mjs +2 -2
  14. package/src/core/background-tasks.mjs +186 -0
  15. package/src/core/headless.mjs +59 -3
  16. package/src/core/local-agent.mjs +10 -1
  17. package/src/core/local-store.mjs +10 -10
  18. package/src/core/paths.mjs +10 -96
  19. package/src/core/policy-resolver.mjs +1 -1
  20. package/src/core/project-context-loader.mjs +2 -2
  21. package/src/core/risk-tier.mjs +1 -0
  22. package/src/core/stream-client.mjs +148 -1
  23. package/src/core/system-prompt.mjs +31 -12
  24. package/src/core/tool-executor.mjs +457 -12
  25. package/src/local-service/agent-relay.mjs +139 -12
  26. package/src/local-service/server.mjs +345 -20
  27. package/src/orchestration/approval.mjs +30 -0
  28. package/src/orchestration/completion-triggers.mjs +40 -0
  29. package/src/orchestration/dispatch.mjs +118 -0
  30. package/src/orchestration/events.mjs +19 -0
  31. package/src/orchestration/graph.mjs +126 -0
  32. package/src/orchestration/node-runner.mjs +193 -0
  33. package/src/orchestration/runner.mjs +200 -0
  34. package/src/plugins/executor.mjs +121 -0
  35. package/src/plugins/loader.mjs +123 -123
  36. package/src/plugins/manifest.mjs +291 -0
  37. package/src/plugins/preflight.mjs +227 -0
  38. package/src/plugins/registry.mjs +233 -0
  39. package/src/plugins/state.mjs +290 -0
  40. package/src/terminal/agents.mjs +26 -4
  41. package/src/terminal/init.mjs +2 -2
  42. package/src/terminal/main.mjs +83 -4
  43. package/src/terminal/repl-explore.mjs +1 -1
  44. package/src/terminal/repl-render.mjs +67 -12
  45. package/src/terminal/repl-state.mjs +4 -2
  46. package/src/terminal/repl.mjs +621 -99
  47. package/src/tools/agent.mjs +6 -2
  48. package/src/tools/analyze-image.mjs +1 -1
  49. package/src/tools/project-overview.mjs +7 -7
  50. package/src/tools/registry.mjs +88 -4
  51. package/src/ui/slash-commands.mjs +19 -1
  52. package/src/ui/sub-agent.mjs +14 -8
@@ -0,0 +1,200 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { backgroundTasks } from '../core/background-tasks.mjs';
3
+ import { normalizeGraphSpec, validateGraph } from './graph.mjs';
4
+ import { deriveApprovalScope } from './approval.mjs';
5
+ import { runNode } from './node-runner.mjs';
6
+ import { graphEvent } from './events.mjs';
7
+
8
+ /**
9
+ * Execute a graph of agent nodes locally, in-process. Yields the same
10
+ * event vocabulary the main renderer already consumes, plus lifecycle
11
+ * events: graph_run_start, graph_node_start, graph_node_result,
12
+ * graph_run_result.
13
+ *
14
+ * pattern: 'sequential' runs nodes one at a time in topological order.
15
+ * pattern: 'parallel' groups nodes into dependency levels — nodes in the
16
+ * same level (no path between them) execute concurrently; their events
17
+ * are buffered and flushed contiguously in topological order so the
18
+ * output stays deterministic while wall-clock shrinks. Handoffs are
19
+ * snapshotted at level start: same-level nodes never see each other,
20
+ * only completed upstream levels.
21
+ */
22
+ export async function* runGraph(graphSpec, triggerInput = {}, ctx = {}, options = {}) {
23
+ const spec = normalizeGraphSpec(graphSpec);
24
+ const agentNodes = validateGraph(spec);
25
+ const runId = options.runId || `gr-${crypto.randomUUID().slice(0, 12)}`;
26
+
27
+ const resolveAgent = ctx.resolveAgent || (() => null);
28
+ for (const node of agentNodes) {
29
+ if (node.type === 'job' || node.type === 'service') continue;
30
+ if (!node.agent) node.agent = resolveAgent(node.agent_slug);
31
+ if (!node.agent) throw new Error(`Unknown agent '${node.agent_slug}' in graph '${spec.name}'`);
32
+ }
33
+
34
+ const approvalScope = deriveApprovalScope(agentNodes, resolveAgent);
35
+ yield graphEvent('graph_run_start', runId, {
36
+ graph: spec.name,
37
+ pattern: spec.pattern,
38
+ node_count: agentNodes.length,
39
+ approval_scope: approvalScope,
40
+ });
41
+
42
+ const levels = spec.pattern === 'parallel'
43
+ ? dependencyLevels(agentNodes, spec.edges)
44
+ : agentNodes.map(node => [node]);
45
+
46
+ const nodeResults = [];
47
+ const handoffs = [];
48
+ let status = 'completed';
49
+ // Service nodes register their process here; the run is their lifetime
50
+ // scope — everything still running is killed when the graph ends,
51
+ // whatever path it ends by (finally covers errors and early consumer
52
+ // abandonment too).
53
+ const serviceJobs = [];
54
+ const nodeOptions = { ...options, runId, serviceJobs };
55
+
56
+ try {
57
+ for (const level of levels) {
58
+ if (status === 'failed') break;
59
+ const levelHandoffs = [...handoffs];
60
+
61
+ for (const node of level) {
62
+ yield graphEvent('graph_node_start', runId, {
63
+ node_id: node.id,
64
+ agent: node.agent_slug || node.id,
65
+ parallel_group: level.length > 1 ? level.map(n => n.id) : undefined,
66
+ });
67
+ }
68
+
69
+ // Solo level: stream events live (no buffering) — this is the /run
70
+ // and sequential-pattern hot path.
71
+ if (level.length === 1) {
72
+ const node = level[0];
73
+ const instruction = buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params);
74
+ let output = '';
75
+ let accumulated = '';
76
+ let nodeStatus = 'completed';
77
+ try {
78
+ for await (const event of runNode(node, node.agent, instruction, ctx, nodeOptions)) {
79
+ if (event.type === 'content' || event.type === 'content_partial') {
80
+ accumulated += event.data?.text || event.data?.content || '';
81
+ }
82
+ if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
83
+ output = event.data.final_response;
84
+ }
85
+ yield event;
86
+ }
87
+ } catch (err) {
88
+ nodeStatus = 'failed';
89
+ output = err?.message || String(err);
90
+ }
91
+ if (!output && nodeStatus === 'completed') output = accumulated;
92
+ const result = { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output };
93
+ nodeResults.push(result);
94
+ yield graphEvent('graph_node_result', runId, result);
95
+ if (result.status === 'failed' && !node.continue_on_error) {
96
+ status = 'failed';
97
+ } else if (result.status === 'completed' && result.output) {
98
+ handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
99
+ }
100
+ continue;
101
+ }
102
+
103
+ // Parallel level: start every node concurrently; drain buffers in
104
+ // topological order so events stay contiguous per node.
105
+ const runs = level.map(node => collectNodeRun(
106
+ node,
107
+ buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params),
108
+ ctx,
109
+ nodeOptions,
110
+ ));
111
+
112
+ for (let i = 0; i < level.length; i++) {
113
+ const { events, result } = await runs[i];
114
+ for (const event of events) yield event;
115
+ nodeResults.push(result);
116
+ yield graphEvent('graph_node_result', runId, result);
117
+ if (result.status === 'failed' && !level[i].continue_on_error) {
118
+ status = 'failed';
119
+ } else if (result.status === 'completed' && result.output) {
120
+ handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
121
+ }
122
+ }
123
+ }
124
+
125
+ } finally {
126
+ for (const id of serviceJobs) {
127
+ try { backgroundTasks.kill(id); } catch { /* already gone */ }
128
+ }
129
+ }
130
+
131
+ const result = {
132
+ run_id: runId,
133
+ graph: spec.name,
134
+ status,
135
+ node_results: nodeResults,
136
+ output: nodeResults.length ? nodeResults[nodeResults.length - 1].output : '',
137
+ };
138
+ yield graphEvent('graph_run_result', runId, result);
139
+ return result;
140
+ }
141
+
142
+ async function collectNodeRun(node, instruction, ctx, options) {
143
+ const events = [];
144
+ let output = '';
145
+ let accumulated = '';
146
+ let nodeStatus = 'completed';
147
+ try {
148
+ for await (const event of runNode(node, node.agent, instruction, ctx, options)) {
149
+ if (event.type === 'content' || event.type === 'content_partial') {
150
+ accumulated += event.data?.text || event.data?.content || '';
151
+ }
152
+ if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
153
+ output = event.data.final_response;
154
+ }
155
+ events.push(event);
156
+ }
157
+ } catch (err) {
158
+ nodeStatus = 'failed';
159
+ output = err?.message || String(err);
160
+ }
161
+ if (!output && nodeStatus === 'completed') output = accumulated;
162
+ return {
163
+ events,
164
+ result: { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output },
165
+ };
166
+ }
167
+
168
+ // Group agent nodes by dependency depth: level N nodes depend only on
169
+ // nodes in levels < N. agentNodes arrive topologically ordered, so each
170
+ // node's predecessors are resolved before it.
171
+ function dependencyLevels(agentNodes, edges) {
172
+ const ids = new Set(agentNodes.map(node => node.id));
173
+ const preds = new Map(agentNodes.map(node => [node.id, []]));
174
+ for (const edge of edges) {
175
+ if (ids.has(edge.source) && ids.has(edge.target)) preds.get(edge.target).push(edge.source);
176
+ }
177
+ const depth = new Map();
178
+ const levels = [];
179
+ for (const node of agentNodes) {
180
+ const upstream = preds.get(node.id);
181
+ const d = upstream.length ? 1 + Math.max(...upstream.map(id => depth.get(id) ?? 0)) : 0;
182
+ depth.set(node.id, d);
183
+ (levels[d] ||= []).push(node);
184
+ }
185
+ return levels;
186
+ }
187
+
188
+ function buildNodeInstruction(node, triggerInput, handoffs, globalParams) {
189
+ const parts = [node.prompt || triggerInput.instruction || ''];
190
+ if (globalParams && Object.keys(globalParams).length) {
191
+ parts.push(`Parameters:\n${JSON.stringify(globalParams, null, 2)}`);
192
+ }
193
+ if (handoffs.length) {
194
+ const upstream = handoffs
195
+ .map(h => `--- handoff from ${h.agent} ---\n${h.output}`)
196
+ .join('\n\n');
197
+ parts.push(`Results from earlier steps:\n\n${upstream}`);
198
+ }
199
+ return parts.filter(Boolean).join('\n\n');
200
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Plugin Executor — dynamic import() of plugin tool handler modules.
3
+ *
4
+ * Plugin tools are .mjs / .js files that export at minimum a `call(input)` function.
5
+ * They are loaded relative to the plugin directory.
6
+ */
7
+
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import path from 'path';
10
+ import { makePluginState } from './state.mjs';
11
+
12
+ /**
13
+ * Load a plugin tool handler by resolving its path relative to the plugin directory.
14
+ * @param {string} pluginDir - Absolute path to the plugin directory
15
+ * @param {string} handlerPath - Relative path from plugin.yaml (e.g. ./tools/my-tool.mjs)
16
+ * @returns {Promise<object|null>} { name, description, inputSchema, call } or null
17
+ */
18
+ export async function loadPluginTool(pluginDir, handlerPath) {
19
+ try {
20
+ const absPath = path.resolve(pluginDir, handlerPath);
21
+ const fileUrl = pathToFileURL(absPath).href;
22
+
23
+ // Cache-busting for development: append timestamp
24
+ const url = `${fileUrl}?t=${Date.now()}`;
25
+ const mod = await import(url);
26
+
27
+ // The module should export at minimum: call(input) => { success, output }
28
+ if (typeof mod.call !== 'function') {
29
+ if (process.env.DEBUG) {
30
+ console.error(`Plugin handler ${handlerPath} does not export a call() function`);
31
+ }
32
+ return null;
33
+ }
34
+
35
+ return {
36
+ name: mod.name || path.basename(handlerPath, path.extname(handlerPath)),
37
+ description: mod.description || '',
38
+ inputSchema: mod.inputSchema || mod.input_schema || { type: 'object', properties: {} },
39
+ call: mod.call,
40
+ validateInput: mod.validateInput || null,
41
+ };
42
+ } catch (err) {
43
+ if (process.env.DEBUG) {
44
+ console.error(`Failed to load plugin tool handler ${handlerPath}: ${err.message}`);
45
+ }
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create a plugin tool executor for a given plugin directory.
52
+ * Loads all tool handlers and returns an execute function.
53
+ *
54
+ * @param {object} manifest - Normalized plugin manifest
55
+ * @returns {Promise<{ execute(name, args): Promise<object>, list(): string[] }>}
56
+ */
57
+ /**
58
+ * Create a plugin tool executor for a given plugin directory.
59
+ * Loads all tool handlers and returns an execute function.
60
+ *
61
+ * @param {object} manifest - Normalized plugin manifest
62
+ * @param {object} [opts]
63
+ * @param {(evt: {plugin: string, op: string, kind: string, target: string, at: string}) => void} [opts.stateEmit]
64
+ * Called (debounced) after every state write commits. The workspace
65
+ * server threads this in so state changes turn into SSE events for
66
+ * live view updates — the Shared Blackboard's reactive pulse.
67
+ * @returns {Promise<{ execute(name, args): Promise<object>, list(): string[], state: object|null }>}
68
+ */
69
+ export async function createPluginToolExecutor(manifest, opts = {}) {
70
+ const pluginDir = manifest._dir || '';
71
+ const tools = manifest.spec?.tools || [];
72
+ const handlers = new Map(); // name → { handler, toolDef }
73
+ const pluginName = manifest.metadata?.name || '';
74
+
75
+ for (const toolDef of tools) {
76
+ if (!toolDef.tool) continue;
77
+ const handler = await loadPluginTool(pluginDir, toolDef.tool);
78
+ if (handler) {
79
+ handlers.set(toolDef.name, { handler, toolDef });
80
+ }
81
+ }
82
+
83
+ // One state instance per plugin, opened lazily so plugins that never
84
+ // touch state don't create empty ~/.bahulam/data/<name> directories.
85
+ let _state = null;
86
+ function getState() {
87
+ if (!pluginName) return null; // no name → no isolation → refuse state
88
+ if (_state) return _state;
89
+ _state = makePluginState(pluginName, { emit: opts.stateEmit || null });
90
+ return _state;
91
+ }
92
+
93
+ return {
94
+ execute: async (name, args, options = {}) => {
95
+ const entry = handlers.get(name);
96
+ if (!entry) {
97
+ return { success: false, output: `Plugin tool not found: ${name}` };
98
+ }
99
+ // Inject the shared-blackboard handle. Handlers opt in by naming
100
+ // it in their signature: `async call(args, { state })`. The
101
+ // getter defers opening the SQLite file until the first access,
102
+ // so handlers that don't use state pay no cost.
103
+ const handlerOpts = {
104
+ ...options,
105
+ get state() { return getState(); },
106
+ pluginName,
107
+ };
108
+ try {
109
+ const result = await entry.handler.call(args || {}, handlerOpts);
110
+ return result?.success !== false
111
+ ? { success: true, output: result?.output ?? result, _tool: name, _plugin: pluginName }
112
+ : { success: false, output: result?.output ?? String(result), _tool: name, _plugin: pluginName };
113
+ } catch (err) {
114
+ return { success: false, output: `Plugin tool error (${name}): ${err.message}`, _tool: name, _plugin: pluginName };
115
+ }
116
+ },
117
+ list: () => [...handlers.keys()],
118
+ /** Get (or open) the plugin's state handle — used by the view API. */
119
+ get state() { return getState(); },
120
+ };
121
+ }
@@ -1,138 +1,138 @@
1
1
  /**
2
- * Plugin Loader — load plugins from directory, git, or npm.
2
+ * Plugin Loader — high-level facade for installing, listing, and removing plugins.
3
3
  *
4
- * Plugins can provide: tools, agents, skills, hooks.
5
- * Plugin format: a directory with a plugin.json manifest.
4
+ * Uses PluginRegistry for scanning/loading, and provides git-based install.
5
+ * Plugin format: a directory with plugin.yaml (bahulam.plugin/1).
6
6
  */
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
11
  import { execSync } from 'child_process';
12
+ import { PluginRegistry } from './registry.mjs';
12
13
 
13
14
  export class PluginLoader {
14
- /**
15
- * @param {string} [pluginDir] - directory to scan for plugins
16
- */
17
- constructor(pluginDir) {
18
- this.pluginDir = pluginDir ||
19
- path.join(os.homedir(), '.claude', 'plugins');
20
- this.plugins = new Map();
21
- }
22
-
23
- /**
24
- * Load plugins from the plugin directory.
25
- * @returns {Array<object>} loaded plugin manifests
26
- */
27
- async loadFromDirectory(dir) {
28
- const targetDir = dir || this.pluginDir;
29
- const loaded = [];
30
-
31
- try {
32
- if (!fs.existsSync(targetDir)) return loaded;
33
-
34
- const entries = fs.readdirSync(targetDir, { withFileTypes: true });
35
- for (const entry of entries) {
36
- if (!entry.isDirectory()) continue;
37
-
38
- const manifestPath = path.join(targetDir, entry.name, 'plugin.json');
39
- if (!fs.existsSync(manifestPath)) continue;
40
-
41
- try {
42
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
43
- manifest._dir = path.join(targetDir, entry.name);
44
- manifest._name = entry.name;
45
- this.plugins.set(manifest.name || entry.name, manifest);
46
- loaded.push(manifest);
47
- } catch {
48
- // Skip malformed plugins
49
- }
50
- }
51
- } catch {
52
- // Directory not readable
15
+ /**
16
+ * @param {Object} [options]
17
+ * @param {string} [options.pluginDir] - Primary plugin directory
18
+ * @param {string[]} [options.pluginDirs] - Additional plugin directories
19
+ * @param {string[]} [options.disabled] - Plugin names to disable
20
+ */
21
+ constructor(options = {}) {
22
+ const { pluginDir, pluginDirs, disabled } = options;
23
+ this.registry = new PluginRegistry({
24
+ pluginDir: pluginDir || path.join(os.homedir(), '.bahulam', 'plugins'),
25
+ pluginDirs,
26
+ disabled,
27
+ });
28
+ this.pluginDir = pluginDir || path.join(os.homedir(), '.bahulam', 'plugins');
29
+ }
30
+
31
+ /**
32
+ * Load all plugins from registered directories.
33
+ * @returns {PluginRegistry}
34
+ */
35
+ load() {
36
+ this.registry.scan();
37
+ return this.registry;
38
+ }
39
+
40
+ /**
41
+ * Load plugins from a specific directory (scans subdirectories).
42
+ * @param {string} dir
43
+ * @returns {PluginRegistry}
44
+ */
45
+ loadFromDirectory(dir) {
46
+ this.registry._scanDir(dir);
47
+ return this.registry;
48
+ }
49
+
50
+ /**
51
+ * Clone a plugin from a git repo and load it.
52
+ * @param {string} repoUrl - git repository URL
53
+ * @param {string} [name] - plugin name (default: repo name)
54
+ * @returns {object|null} loaded manifest
55
+ */
56
+ loadFromGit(repoUrl, name) {
57
+ const pluginName = name || repoUrl.split('/').pop()?.replace('.git', '') || 'plugin';
58
+ const targetDir = path.join(this.pluginDir, pluginName);
59
+
60
+ try {
61
+ fs.mkdirSync(this.pluginDir, { recursive: true });
62
+
63
+ if (fs.existsSync(targetDir)) {
64
+ // Update existing
65
+ execSync('git pull', { cwd: targetDir, stdio: 'pipe' });
66
+ } else {
67
+ // Clone new
68
+ execSync(`git clone --depth 1 ${repoUrl} ${targetDir}`, { stdio: 'pipe' });
69
+ }
70
+
71
+ const manifestPath = path.join(targetDir, 'plugin.yaml');
72
+ const altPath = path.join(targetDir, 'plugin.json');
73
+ const exists = fs.existsSync(manifestPath) ? manifestPath : (fs.existsSync(altPath) ? altPath : null);
74
+
75
+ if (exists) {
76
+ const { parsePluginManifestFile } = await import('./manifest.mjs');
77
+ const manifest = parsePluginManifestFile(exists);
78
+ if (manifest) {
79
+ this.registry.register(manifest);
80
+ return manifest;
53
81
  }
54
-
55
- return loaded;
56
- }
57
-
58
- /**
59
- * Clone a plugin from a git repo and load it.
60
- * @param {string} repoUrl - git repository URL
61
- * @param {string} [name] - plugin name (default: repo name)
62
- * @returns {object|null} loaded manifest
63
- */
64
- async loadFromGit(repoUrl, name) {
65
- const pluginName = name || repoUrl.split('/').pop()?.replace('.git', '') || 'plugin';
66
- const targetDir = path.join(this.pluginDir, pluginName);
67
-
68
- try {
69
- fs.mkdirSync(this.pluginDir, { recursive: true });
70
-
71
- if (fs.existsSync(targetDir)) {
72
- // Update existing
73
- execSync('git pull', { cwd: targetDir, stdio: 'pipe' });
74
- } else {
75
- // Clone new
76
- execSync(`git clone --depth 1 ${repoUrl} ${targetDir}`, { stdio: 'pipe' });
77
- }
78
-
79
- const manifestPath = path.join(targetDir, 'plugin.json');
80
- if (fs.existsSync(manifestPath)) {
81
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
82
- manifest._dir = targetDir;
83
- manifest._name = pluginName;
84
- this.plugins.set(manifest.name || pluginName, manifest);
85
- return manifest;
86
- }
87
- } catch {
88
- // Git operation failed
89
- }
90
-
91
- return null;
82
+ }
83
+ } catch (err) {
84
+ if (process.env.DEBUG) {
85
+ console.error(`Failed to clone plugin ${repoUrl}: ${err.message}`);
86
+ }
92
87
  }
93
88
 
94
- /**
95
- * Get all installed plugins.
96
- * @returns {Array<object>}
97
- */
98
- getInstalledPlugins() {
99
- return [...this.plugins.values()];
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Get all installed plugins.
94
+ * @returns {object[]}
95
+ */
96
+ getInstalledPlugins() {
97
+ return this.registry.list();
98
+ }
99
+
100
+ /**
101
+ * Get a plugin by name.
102
+ * @param {string} name
103
+ * @returns {object|undefined}
104
+ */
105
+ getPlugin(name) {
106
+ return this.registry.get(name);
107
+ }
108
+
109
+ /**
110
+ * Remove a plugin by name.
111
+ * @param {string} name
112
+ * @returns {boolean}
113
+ */
114
+ removePlugin(name) {
115
+ const plugin = this.registry.get(name);
116
+ if (!plugin) return false;
117
+
118
+ try {
119
+ if (plugin._dir && fs.existsSync(plugin._dir)) {
120
+ fs.rmSync(plugin._dir, { recursive: true, force: true });
121
+ }
122
+ } catch (err) {
123
+ if (process.env.DEBUG) {
124
+ console.error(`Failed to remove plugin directory ${plugin._dir}: ${err.message}`);
125
+ }
100
126
  }
101
127
 
102
- /**
103
- * Get a plugin by name.
104
- * @param {string} name
105
- * @returns {object|undefined}
106
- */
107
- getPlugin(name) {
108
- return this.plugins.get(name);
109
- }
110
-
111
- /**
112
- * Remove a plugin by name.
113
- * @param {string} name
114
- * @returns {boolean}
115
- */
116
- removePlugin(name) {
117
- const plugin = this.plugins.get(name);
118
- if (!plugin) return false;
119
-
120
- try {
121
- if (plugin._dir && fs.existsSync(plugin._dir)) {
122
- fs.rmSync(plugin._dir, { recursive: true, force: true });
123
- }
124
- } catch {
125
- // Best effort
126
- }
127
-
128
- return this.plugins.delete(name);
129
- }
130
-
131
- /**
132
- * Get plugin count.
133
- * @returns {number}
134
- */
135
- count() {
136
- return this.plugins.size;
137
- }
138
- }
128
+ return this.registry.remove(name);
129
+ }
130
+
131
+ /**
132
+ * Get plugin count.
133
+ * @returns {number}
134
+ */
135
+ count() {
136
+ return this.registry.count();
137
+ }
138
+ }