@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,233 @@
1
+ /**
2
+ * Plugin Registry — scan, load, validate, and deduplicate plugin manifests.
3
+ *
4
+ * Scans standard directories for plugin.yaml / plugin.json manifests.
5
+ * Follows the same pattern as AgentLoader and SkillsLoader.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
12
+
13
+ const DEFAULT_PLUGIN_DIRS = () => [
14
+ path.join(process.cwd(), '.bahulam', 'plugins'),
15
+ path.join(os.homedir(), '.bahulam', 'plugins'),
16
+ ];
17
+
18
+ export class PluginRegistry {
19
+ /**
20
+ * @param {Object} [options]
21
+ * @param {string[]} [options.pluginDirs] - Directories to scan (default: project .bahulam/plugins + ~/.bahulam/plugins)
22
+ * @param {string[]} [options.disabled] - Plugin names to skip
23
+ * @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
24
+ * @param {string[]} [options.active] - Alias for enabled
25
+ * @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
26
+ */
27
+ constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
28
+ this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : DEFAULT_PLUGIN_DIRS());
29
+ this.disabled = new Set(
30
+ (Array.isArray(disabled) ? disabled : [])
31
+ .map(s => String(s).trim().toLowerCase())
32
+ .filter(Boolean),
33
+ );
34
+ const enabledList = Array.isArray(enabled) ? enabled : (Array.isArray(active) ? active : []);
35
+ this.enabled = new Set(
36
+ enabledList
37
+ .map(s => String(s).trim().toLowerCase())
38
+ .filter(Boolean),
39
+ );
40
+ this.plugins = new Map(); // name → manifest
41
+ this.errors = []; // { name, message }
42
+ }
43
+
44
+ /**
45
+ * Scan all plugin directories and load manifests.
46
+ * @returns {this}
47
+ */
48
+ scan() {
49
+ for (const dir of this.pluginDirs) {
50
+ this._scanDir(dir);
51
+ }
52
+ return this;
53
+ }
54
+
55
+ _scanDir(dir) {
56
+ try {
57
+ if (!fs.existsSync(dir)) return;
58
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ for (const entry of entries) {
60
+ if (!entry.isDirectory()) continue;
61
+ const pluginDir = path.join(dir, entry.name);
62
+
63
+ // Try plugin.yaml, plugin.json (in that order)
64
+ let manifestPath = path.join(pluginDir, 'plugin.yaml');
65
+ if (!fs.existsSync(manifestPath)) {
66
+ manifestPath = path.join(pluginDir, 'plugin.json');
67
+ if (!fs.existsSync(manifestPath)) continue;
68
+ }
69
+
70
+ const manifest = parsePluginManifestFile(manifestPath);
71
+ if (!manifest) {
72
+ this.errors.push({
73
+ plugin: entry.name,
74
+ message: `Failed to parse manifest: ${manifestPath}`,
75
+ });
76
+ continue;
77
+ }
78
+
79
+ this.register(manifest);
80
+ }
81
+ } catch (err) {
82
+ if (process.env.DEBUG) {
83
+ console.error(`Plugin registry scan error in ${dir}: ${err.message}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Register a plugin manifest.
90
+ * @param {object} manifest - Normalized manifest from normalizeManifest()
91
+ * @returns {boolean} true if registered, false if skipped (disabled or duplicate)
92
+ */
93
+ register(manifest) {
94
+ const name = manifest.metadata?.name || '';
95
+ if (!name) return false;
96
+
97
+ const lowerName = name.toLowerCase();
98
+ const aliases = [
99
+ lowerName,
100
+ manifest._dir ? path.basename(manifest._dir).toLowerCase() : '',
101
+ ].filter(Boolean);
102
+
103
+ if (this.enabled.size > 0 && !aliases.some(alias => this.enabled.has(alias))) {
104
+ return false;
105
+ }
106
+
107
+ // Check disabled list
108
+ if (aliases.some(alias => this.disabled.has(alias))) {
109
+ if (process.env.DEBUG) {
110
+ console.warn(`Plugin "${name}" is disabled, skipping`);
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Check for existing (first wins — project overrides global)
116
+ if (this.plugins.has(lowerName)) {
117
+ return false; // silently skip duplicates
118
+ }
119
+
120
+ // Validate
121
+ const { valid, errors } = validatePluginManifest(manifest);
122
+ if (!valid) {
123
+ this.errors.push({ plugin: name, message: errors.join('; ') });
124
+ return false;
125
+ }
126
+
127
+ this.plugins.set(lowerName, manifest);
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Get a plugin by name.
133
+ * @param {string} name
134
+ * @returns {object|null}
135
+ */
136
+ get(name) {
137
+ return this.plugins.get(String(name || '').toLowerCase()) || null;
138
+ }
139
+
140
+ /**
141
+ * List all registered plugins.
142
+ * @returns {object[]}
143
+ */
144
+ list() {
145
+ return [...this.plugins.values()];
146
+ }
147
+
148
+ /**
149
+ * List all tools from all plugins.
150
+ * @returns {object[]}
151
+ */
152
+ listTools() {
153
+ const tools = [];
154
+ for (const plugin of this.plugins.values()) {
155
+ for (const tool of (plugin.spec?.tools || [])) {
156
+ tools.push({
157
+ ...tool,
158
+ _plugin_name: plugin.metadata?.name,
159
+ _plugin_dir: plugin._dir,
160
+ });
161
+ }
162
+ }
163
+ return tools;
164
+ }
165
+
166
+ /**
167
+ * List every plugin-declared MCP server across the registry.
168
+ *
169
+ * Each entry is [{plugin, name, config}] where `config` is a
170
+ * Claude-Desktop-compatible object (command/args/env or url/headers).
171
+ * Consumers spawn one McpClient per entry at session start; the
172
+ * server's tools are then namespaced as `<name>.<tool>` in the
173
+ * tool executor so two plugins can ship servers with the same tool
174
+ * name without collision.
175
+ * @returns {{plugin: string, name: string, config: object}[]}
176
+ */
177
+ listMcpServers() {
178
+ const out = [];
179
+ for (const plugin of this.plugins.values()) {
180
+ const servers = plugin.spec?.mcpServers || {};
181
+ const pluginName = plugin.metadata?.name || '';
182
+ for (const [name, config] of Object.entries(servers)) {
183
+ if (config && typeof config === 'object') {
184
+ out.push({ plugin: pluginName, name, config });
185
+ }
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * List all agents from all plugins.
193
+ * @returns {object[]}
194
+ */
195
+ listAgents() {
196
+ const agents = [];
197
+ for (const plugin of this.plugins.values()) {
198
+ for (const agent of (plugin.spec?.agents || [])) {
199
+ agents.push({
200
+ ...agent,
201
+ _plugin_name: plugin.metadata?.name,
202
+ });
203
+ }
204
+ }
205
+ return agents;
206
+ }
207
+
208
+ /**
209
+ * Check if a plugin exists.
210
+ * @param {string} name
211
+ * @returns {boolean}
212
+ */
213
+ has(name) {
214
+ return this.plugins.has(String(name || '').toLowerCase());
215
+ }
216
+
217
+ /**
218
+ * Remove a plugin by name.
219
+ * @param {string} name
220
+ * @returns {boolean}
221
+ */
222
+ remove(name) {
223
+ return this.plugins.delete(String(name || '').toLowerCase());
224
+ }
225
+
226
+ /**
227
+ * Get plugin count.
228
+ * @returns {number}
229
+ */
230
+ count() {
231
+ return this.plugins.size;
232
+ }
233
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Plugin state — the Shared Blackboard.
3
+ *
4
+ * Every plugin gets its own SQLite sidecar at ~/.bahulam/data/<name>/state.db.
5
+ * Two built-in tables ship with every DB; plugins are free to `CREATE TABLE`
6
+ * additional ones via `state.query(sql)`:
7
+ *
8
+ * kv — small structured values (watchlists, prefs, cursors, form
9
+ * state). One row per key, JSON-encoded value, last-write-wins
10
+ * with an updated_at timestamp.
11
+ *
12
+ * records — append-only event log (backtest runs, decisions, alerts,
13
+ * anything you want a history of). Named streams via the
14
+ * `stream` column; plugin owns the stream namespace.
15
+ *
16
+ * The exported `makePluginState(pluginName, {emit})` returns a proxy with
17
+ * the methods handlers and view routes call. Every write fires `emit(evt)`
18
+ * synchronously AFTER commit — the SSE bus turns that into a
19
+ * `plugin_state_changed` event so views can re-render live. When the same
20
+ * key/stream is written many times in quick succession the emit hook
21
+ * debounces to at most one event per 50ms per (plugin, kind, target).
22
+ *
23
+ * We use Node's built-in `node:sqlite` (v22+, experimental) so plugin
24
+ * authors get relational storage with zero install steps. The
25
+ * ExperimentalWarning is silenced once at module load.
26
+ *
27
+ * A NOTE ON SAFETY: `state.query(sql, params)` is a raw SQL escape hatch
28
+ * intended for the plugin's OWN tools and views — never expose it to
29
+ * untrusted input (the plugin author owns the SQL). The higher-level
30
+ * get/set/patch/append/list methods parameterize everything.
31
+ */
32
+
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import { DatabaseSync } from 'node:sqlite';
37
+
38
+ // Silence the single "SQLite is an experimental feature" warning that
39
+ // node:sqlite emits at first import. Users would see it on every plugin
40
+ // launch otherwise, which is noise, not signal.
41
+ {
42
+ const orig = process.emit;
43
+ process.emit = function (name, warning, ...rest) {
44
+ if (name === 'warning' && warning?.name === 'ExperimentalWarning'
45
+ && /SQLite/i.test(String(warning.message || ''))) {
46
+ return false;
47
+ }
48
+ return orig.call(this, name, warning, ...rest);
49
+ };
50
+ }
51
+
52
+ const DATA_ROOT = () => path.join(os.homedir(), '.bahulam', 'data');
53
+ const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
54
+ const DEBOUNCE_MS = 50;
55
+
56
+ // Open handles cached per plugin — SQLite in WAL mode is happy with one
57
+ // handle per process, and this is a single-process dev tool. Handles live
58
+ // for the lifetime of the CLI; explicit close() is available for tests.
59
+ const _handles = new Map(); // pluginName -> { db, dir, path }
60
+
61
+ function pluginDataDir(pluginName) {
62
+ if (!PLUGIN_NAME_RE.test(pluginName)) {
63
+ throw new Error(`invalid plugin name for state dir: ${pluginName}`);
64
+ }
65
+ const dir = path.join(DATA_ROOT(), pluginName);
66
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ return dir;
68
+ }
69
+
70
+ function openDb(pluginName) {
71
+ if (_handles.has(pluginName)) return _handles.get(pluginName);
72
+ const dir = pluginDataDir(pluginName);
73
+ const dbPath = path.join(dir, 'state.db');
74
+ const db = new DatabaseSync(dbPath);
75
+ // WAL: multiple readers, one writer; robust against concurrent view+agent.
76
+ db.exec('PRAGMA journal_mode = WAL');
77
+ db.exec('PRAGMA synchronous = NORMAL');
78
+ db.exec('PRAGMA foreign_keys = ON');
79
+ // Bootstrap schema — idempotent so evolving plugins never crash on start.
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS kv (
82
+ key TEXT PRIMARY KEY,
83
+ value TEXT NOT NULL,
84
+ updated_at TEXT NOT NULL
85
+ );
86
+ CREATE TABLE IF NOT EXISTS records (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ stream TEXT NOT NULL,
89
+ payload TEXT NOT NULL,
90
+ created_at TEXT NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS records_stream_idx
93
+ ON records(stream, id DESC);
94
+ `);
95
+ const handle = { db, dir, path: dbPath };
96
+ _handles.set(pluginName, handle);
97
+ return handle;
98
+ }
99
+
100
+ function now() { return new Date().toISOString(); }
101
+
102
+ function deepMerge(base, patch) {
103
+ if (Array.isArray(base) || Array.isArray(patch)) return patch;
104
+ if (base == null || typeof base !== 'object') return patch;
105
+ if (patch == null || typeof patch !== 'object') return patch;
106
+ const out = { ...base };
107
+ for (const [k, v] of Object.entries(patch)) {
108
+ if (v && typeof v === 'object' && !Array.isArray(v)
109
+ && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) {
110
+ out[k] = deepMerge(out[k], v);
111
+ } else {
112
+ out[k] = v;
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Build a per-plugin state proxy.
120
+ * @param {string} pluginName Must match /^[a-z0-9][a-z0-9._-]{0,63}$/i
121
+ * @param {object} [opts]
122
+ * @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
123
+ * Called (debounced) after every write commits. The workspace server
124
+ * turns this into an SSE `plugin_state_changed` event for the browser.
125
+ * @returns proxy with { get, set, patch, append, list, query, delete, close, db, path }
126
+ */
127
+ export function makePluginState(pluginName, { emit = null } = {}) {
128
+ const { db, path: dbPath } = openDb(pluginName);
129
+
130
+ // One debounce timer per (kind, target). Fast writes coalesce into
131
+ // exactly one plugin_state_changed event. Pending entry is stored so
132
+ // close() can flush synchronously for tests and controlled shutdown.
133
+ const timers = new Map(); // key -> timeout handle
134
+ const pending = new Map(); // key -> event payload to fire on flush
135
+ function fire(op, kind, target) {
136
+ if (typeof emit !== 'function') return;
137
+ const key = `${kind}:${target}`;
138
+ if (timers.has(key)) clearTimeout(timers.get(key));
139
+ pending.set(key, { plugin: pluginName, op, kind, target, at: now() });
140
+ timers.set(key, setTimeout(() => {
141
+ const evt = pending.get(key);
142
+ timers.delete(key);
143
+ pending.delete(key);
144
+ if (evt) {
145
+ try { emit(evt); }
146
+ catch { /* emit failure must never surface into the tool call */ }
147
+ }
148
+ }, DEBOUNCE_MS));
149
+ }
150
+ function flushPending() {
151
+ for (const [key, evt] of pending) {
152
+ clearTimeout(timers.get(key));
153
+ try { emit(evt); } catch { /* swallow */ }
154
+ }
155
+ timers.clear();
156
+ pending.clear();
157
+ }
158
+
159
+ // Prepared statements are cached on first use (better-sqlite3-style perf,
160
+ // node:sqlite exposes prepare() too).
161
+ const stmts = {
162
+ get: db.prepare('SELECT value FROM kv WHERE key = ?'),
163
+ upsert: db.prepare('INSERT INTO kv(key, value, updated_at) VALUES(?, ?, ?) '
164
+ + 'ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at'),
165
+ del: db.prepare('DELETE FROM kv WHERE key = ?'),
166
+ keys: db.prepare('SELECT key FROM kv ORDER BY key'),
167
+ append: db.prepare('INSERT INTO records(stream, payload, created_at) VALUES(?, ?, ?)'),
168
+ listAsc: db.prepare('SELECT id, payload, created_at FROM records WHERE stream = ? ORDER BY id ASC LIMIT ?'),
169
+ listDesc: db.prepare('SELECT id, payload, created_at FROM records WHERE stream = ? ORDER BY id DESC LIMIT ?'),
170
+ };
171
+
172
+ return {
173
+ /** Read one key. Returns `fallback` (default null) when the key is absent. */
174
+ get(key, fallback = null) {
175
+ const row = stmts.get.get(String(key));
176
+ if (!row) return fallback;
177
+ try { return JSON.parse(row.value); }
178
+ catch { return fallback; }
179
+ },
180
+
181
+ /** Write one key with a whole value. Fires plugin_state_changed. */
182
+ set(key, value) {
183
+ const k = String(key);
184
+ stmts.upsert.run(k, JSON.stringify(value), now());
185
+ fire('set', 'kv', k);
186
+ return value;
187
+ },
188
+
189
+ /**
190
+ * Deep-merge a partial into an existing object under `key`. Arrays
191
+ * replace; nested objects merge recursively. When the key is absent,
192
+ * `patch` is stored as-is. Matches the "PATCH endpoint for surgeon
193
+ * precision" contract.
194
+ */
195
+ patch(key, partial) {
196
+ const k = String(key);
197
+ const cur = this.get(k, null);
198
+ const next = deepMerge(cur, partial);
199
+ stmts.upsert.run(k, JSON.stringify(next), now());
200
+ fire('patch', 'kv', k);
201
+ return next;
202
+ },
203
+
204
+ /** Remove one key. */
205
+ delete(key) {
206
+ const k = String(key);
207
+ const info = stmts.del.run(k);
208
+ if (info.changes > 0) fire('delete', 'kv', k);
209
+ return info.changes > 0;
210
+ },
211
+
212
+ /** List every kv key currently present. */
213
+ keys() { return stmts.keys.all().map(r => r.key); },
214
+
215
+ /** Append one row to a named stream. Returns the new row's id. */
216
+ append(stream, payload) {
217
+ const s = String(stream);
218
+ const info = stmts.append.run(s, JSON.stringify(payload), now());
219
+ fire('append', 'records', s);
220
+ return Number(info.lastInsertRowid);
221
+ },
222
+
223
+ /**
224
+ * Read from a stream.
225
+ * @param {string} stream
226
+ * @param {object} [opts] { limit?: number = 50, order?: 'asc'|'desc' = 'desc' }
227
+ * @returns [{ id, payload, created_at }]
228
+ */
229
+ list(stream, { limit = 50, order = 'desc' } = {}) {
230
+ const s = String(stream);
231
+ const cap = Math.max(1, Math.min(10000, Math.floor(limit)));
232
+ const stmt = order === 'asc' ? stmts.listAsc : stmts.listDesc;
233
+ return stmt.all(s, cap).map(r => ({
234
+ id: r.id,
235
+ payload: safeParse(r.payload),
236
+ created_at: r.created_at,
237
+ }));
238
+ },
239
+
240
+ /**
241
+ * Raw SQL escape hatch — for the plugin's own advanced use. Always
242
+ * use parameters, never string-concat user input into SQL. Returns
243
+ * whatever the underlying prepared statement returns; SELECTs come
244
+ * back as an array of rows.
245
+ */
246
+ query(sql, params = []) {
247
+ const stmt = db.prepare(String(sql));
248
+ const args = Array.isArray(params) ? params : [params];
249
+ // node:sqlite prepared statements expose all() for SELECT-like
250
+ // queries and run() for DML; iterate() is available too.
251
+ const first = String(sql).trim().slice(0, 6).toUpperCase();
252
+ if (first.startsWith('SELECT') || first.startsWith('PRAGMA')) {
253
+ return stmt.all(...args);
254
+ }
255
+ const info = stmt.run(...args);
256
+ // Any DML on kv/records is announced generically so views can
257
+ // refresh; more specific writes go through set/patch/append.
258
+ fire('query', 'kv', '*');
259
+ return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
260
+ },
261
+
262
+ /** Direct DatabaseSync handle for callers that know what they need. */
263
+ get db() { return db; },
264
+ /** Absolute path to the DB file on disk. */
265
+ get path() { return dbPath; },
266
+
267
+ /** Test hook — closes the underlying handle. Flushes any pending
268
+ * emit events synchronously so tests can observe them without a wait. */
269
+ close() {
270
+ flushPending();
271
+ db.close();
272
+ _handles.delete(pluginName);
273
+ },
274
+ };
275
+ }
276
+
277
+ function safeParse(text) {
278
+ try { return JSON.parse(text); } catch { return text; }
279
+ }
280
+
281
+ /**
282
+ * Test helper — reset all cached handles. Called by tests between cases so
283
+ * a temp $HOME override actually takes effect.
284
+ */
285
+ export function _resetForTests() {
286
+ for (const h of _handles.values()) {
287
+ try { h.db.close(); } catch { /* ignore */ }
288
+ }
289
+ _handles.clear();
290
+ }
@@ -113,7 +113,7 @@ const TOOL_ALIASES = new Map([
113
113
  ['grep', 'search_code'],
114
114
  ]);
115
115
 
116
- function canonicalToolName(value) {
116
+ export function canonicalToolName(value) {
117
117
  const key = String(value || '').trim().toLowerCase();
118
118
  return TOOL_ALIASES.get(key) || key;
119
119
  }
@@ -133,7 +133,7 @@ function normalizeScopedArgs(toolName, args = {}, { projectRoot = null } = {}) {
133
133
  return next;
134
134
  }
135
135
 
136
- function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
136
+ export function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
137
137
  const tools = Array.isArray(agent.tools) ? agent.tools : [];
138
138
  const allowed = new Set(tools.map(canonicalToolName).filter(Boolean));
139
139
  if (!allowed.size) return baseExecutor;
@@ -152,7 +152,11 @@ function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } =
152
152
  baseExecutor,
153
153
  toolName,
154
154
  normalizeScopedArgs(toolName, args, { projectRoot }),
155
- options,
155
+ {
156
+ ...options,
157
+ internal: true,
158
+ subAgent: agent.slug || agent.command || agent.name || true,
159
+ },
156
160
  );
157
161
  },
158
162
  };
@@ -163,6 +167,22 @@ export function findBuiltinAgent(agentName) {
163
167
  return BUILTIN_AGENTS.find(agent => agent.command === target || agent.name.toLowerCase() === target) || null;
164
168
  }
165
169
 
170
+ /**
171
+ * Search for an agent across built-in agents, project .bahulam/agents, and plugin agents.
172
+ * @param {string} agentName
173
+ * @param {object} [agentLoader] - Instance of AgentLoader with loadFromPlugins() called
174
+ * @returns {object|null}
175
+ */
176
+ export function findAgent(agentName, agentLoader = null) {
177
+ const builtin = findBuiltinAgent(agentName);
178
+ if (builtin) return builtin;
179
+ if (agentLoader) {
180
+ const local = agentLoader.get(agentName);
181
+ if (local) return local;
182
+ }
183
+ return null;
184
+ }
185
+
166
186
  export function localAgentMatches(agent, target) {
167
187
  const needle = String(target || '').trim().toLowerCase();
168
188
  if (!needle) return false;
@@ -301,6 +321,7 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
301
321
  token: creds.token,
302
322
  toolExecutor,
303
323
  approvalManager: agentApproval,
324
+ pluginRegistry: options.pluginRegistry || ctx.pluginRegistry || null,
304
325
  });
305
326
 
306
327
  session.turns++;
@@ -344,7 +365,8 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
344
365
  * @param {Function} renderEvent - Event renderer function
345
366
  */
346
367
  export async function runAgent(agentName, instruction, ctx, session, renderEvent) {
347
- const agent = findBuiltinAgent(agentName);
368
+ const agentLoader = ctx?.agentLoader || null;
369
+ const agent = findAgent(agentName, agentLoader);
348
370
  if (!agent) {
349
371
  process.stderr.write(` ${c.red('Unknown agent: ' + agentName)}\n`);
350
372
  return;
@@ -28,7 +28,7 @@ Format v1. Markdown files are intentionally hand-editable.
28
28
  'config.json': JSON.stringify({
29
29
  version: 1,
30
30
  context: {
31
- loadEveryTurn: ['KEPLER.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
31
+ loadEveryTurn: ['BAHULAM.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
32
32
  showReloadNotice: true,
33
33
  },
34
34
  planning: { owner: 'auto', onUserEditedPlan: 'prefer_user_plan' },
@@ -57,7 +57,7 @@ Format v1. Markdown files are intentionally hand-editable.
57
57
  Stop: [],
58
58
  },
59
59
  }, null, 2) + '\n',
60
- 'KEPLER.md': `# Project
60
+ 'BAHULAM.md': `# Project
61
61
 
62
62
  ## Quick Facts
63
63
  - Stack: