@bahulam/code 0.1.16 → 0.1.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@ import { parseAgentDefinition } from './parser.mjs';
16
16
  export class AgentLoader {
17
17
  constructor() {
18
18
  this.agents = new Map();
19
+ this.aliases = new Map();
19
20
  this.searchPaths = [];
20
21
  }
21
22
 
@@ -48,9 +49,7 @@ export class AgentLoader {
48
49
  try {
49
50
  const content = fs.readFileSync(filePath, 'utf-8');
50
51
  const agent = parseAgentDefinition(content, ext);
51
- if (agent && agent.name && !this.agents.has(agent.name)) {
52
- this.agents.set(agent.name, { ...agent, source: filePath });
53
- }
52
+ this._register(agent, filePath);
54
53
  } catch (err) {
55
54
  if (process.env.DEBUG) {
56
55
  console.error(`Failed to load agent ${filePath}: ${err.message}`);
@@ -71,30 +70,50 @@ export class AgentLoader {
71
70
  loadFromPlugins(plugins) {
72
71
  if (!Array.isArray(plugins)) return this;
73
72
  for (const plugin of plugins) {
74
- const agents = plugin.spec?.agents || [];
73
+ const agents = plugin.config?.agents || [];
75
74
  for (const agentDef of agents) {
76
75
  const slug = agentDef.slug || agentDef.name || '';
77
76
  if (!slug) continue;
78
- // Project agents take precedence — skip if already registered
79
- if (this.agents.has(slug)) continue;
80
- this.agents.set(slug, {
77
+ this._register({
81
78
  ...agentDef,
82
79
  slug,
83
80
  source: `plugin:${plugin.metadata?.name || 'unknown'}`,
84
81
  source_scope: 'plugin',
85
- });
82
+ }, null);
86
83
  }
87
84
  }
88
85
  return this;
89
86
  }
90
87
 
88
+ _register(agent, sourcePath = null) {
89
+ if (!agent) return null;
90
+ const slug = normalizeKey(agent.slug || agent.id || agent.name);
91
+ if (!slug) return null;
92
+ const aliases = [
93
+ slug,
94
+ normalizeKey(agent.name),
95
+ normalizeKey(agent.id),
96
+ ].filter(Boolean);
97
+
98
+ // Earlier search paths have higher precedence: project beats global,
99
+ // and both beat plugin-provided agents.
100
+ if (aliases.some(alias => this.aliases.has(alias))) return null;
101
+
102
+ const stored = { ...agent, slug: agent.slug || slug, ...(sourcePath ? { source: sourcePath } : {}) };
103
+ this.agents.set(slug, stored);
104
+ for (const alias of aliases) this.aliases.set(alias, slug);
105
+ return stored;
106
+ }
107
+
91
108
  /**
92
109
  * Get an agent definition by name.
93
110
  * @param {string} name
94
111
  * @returns {object|null}
95
112
  */
96
113
  get(name) {
97
- return this.agents.get(name) || null;
114
+ const key = normalizeKey(name);
115
+ const slug = this.aliases.get(key) || key;
116
+ return this.agents.get(slug) || null;
98
117
  }
99
118
 
100
119
  /**
@@ -111,6 +130,14 @@ export class AgentLoader {
111
130
  * @returns {boolean}
112
131
  */
113
132
  has(name) {
114
- return this.agents.has(name);
133
+ return Boolean(this.get(name));
115
134
  }
116
135
  }
136
+
137
+ function normalizeKey(value) {
138
+ return String(value || '')
139
+ .trim()
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, '-')
142
+ .replace(/^-+|-+$/g, '');
143
+ }
@@ -0,0 +1,240 @@
1
+ import { BUILTIN_AGENTS } from '../terminal/agents.mjs';
2
+ import { loadBahulamSettings } from '../config/settings-loader.mjs';
3
+ import { agentToSpec, listLocalAgents } from './scaffold.mjs';
4
+ import * as crypto from 'node:crypto';
5
+
6
+ export const SUB_AGENT_EVENT_SCHEMA = [
7
+ { type: 'sub_agent_start', description: 'A delegated sub-agent run started.' },
8
+ { type: 'sub_agent_tool', description: 'A sub-agent requested or completed a tool call.' },
9
+ { type: 'sub_agent_complete', description: 'A delegated sub-agent run completed.' },
10
+ { type: 'graph_run_start', description: 'A local workflow or delegated graph run started.' },
11
+ { type: 'graph_node_start', description: 'One sub-agent, job, or service node started.' },
12
+ { type: 'graph_node_result', description: 'One sub-agent, job, or service node completed.' },
13
+ { type: 'graph_run_result', description: 'The local workflow or delegated graph run completed.' },
14
+ { type: 'tool_call', description: 'A tool call was requested; sub_agent identifies delegated calls.' },
15
+ { type: 'tool_result', description: 'A tool call completed; sub_agent identifies delegated calls.' },
16
+ { type: 'content', description: 'A sub-agent emitted final or partial answer content.' },
17
+ { type: 'error', description: 'A sub-agent, tool, or graph node failed.' },
18
+ ];
19
+
20
+ const READ_ONLY_TOOLS = ['read_file', 'search_code', 'list_files', 'search_files', 'get_file_info'];
21
+
22
+ export function compactAgentMetadata(agent) {
23
+ return {
24
+ slug: agent.slug,
25
+ name: agent.name,
26
+ description: agent.description || '',
27
+ role: agent.role || 'specialist',
28
+ model: agent.model || null,
29
+ models: agent.models || undefined,
30
+ tools: Array.isArray(agent.tools) ? agent.tools : [],
31
+ capabilities: Array.isArray(agent.capabilities) ? agent.capabilities : [],
32
+ domains: Array.isArray(agent.domains) ? agent.domains : [],
33
+ source_scope: agent.source_scope || 'unknown',
34
+ source: agent.source || '',
35
+ content_hash: agent.content_hash || '',
36
+ read_only: Boolean(agent.read_only || agent.readOnly),
37
+ runnable: agent.runnable !== false,
38
+ };
39
+ }
40
+
41
+ export function createAgentRegistry({
42
+ cwd = process.cwd(),
43
+ pluginRegistry = null,
44
+ channel = 'main',
45
+ settingsLoader = loadBahulamSettings,
46
+ } = {}) {
47
+ function currentCwd() {
48
+ return typeof cwd === 'function' ? cwd() : cwd || process.cwd();
49
+ }
50
+
51
+ function pluginAgentAllowlist() {
52
+ try {
53
+ const loaded = settingsLoader({ cwd: currentCwd() });
54
+ const settings = loaded?.settings || loaded || {};
55
+ const allowlist = settings?.plugins?.agent_allowlist;
56
+ return Array.isArray(allowlist) ? allowlist.map(String) : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function normalizeAgentTools(tools) {
63
+ if (Array.isArray(tools)) return tools.map(String).filter(Boolean);
64
+ if (typeof tools === 'string') {
65
+ return tools.split(',').map(item => item.trim()).filter(Boolean);
66
+ }
67
+ return READ_ONLY_TOOLS.slice(0, 3);
68
+ }
69
+
70
+ function pluginAgentToLocalShape(agentDef) {
71
+ const slug = agentDef.slug || agentDef.name || '';
72
+ const pluginName = agentDef._plugin_name || '';
73
+ const base = {
74
+ ...agentDef,
75
+ slug,
76
+ name: agentDef.name || slug,
77
+ description: agentDef.description || '',
78
+ role: agentDef.role || 'specialist',
79
+ model: agentDef.model || null,
80
+ models: agentDef.models || null,
81
+ tools: normalizeAgentTools(agentDef.tools || agentDef.agent_tools),
82
+ capabilities: Array.isArray(agentDef.capabilities) ? agentDef.capabilities : [],
83
+ domains: Array.isArray(agentDef.domains) ? agentDef.domains : [],
84
+ prompt: agentDef.prompt || agentDef.system_prompt || agentDef.systemPrompt || '',
85
+ system_prompt: agentDef.system_prompt || agentDef.systemPrompt || agentDef.prompt || '',
86
+ source_scope: 'plugin',
87
+ source: agentDef.source || (pluginName ? `plugin:${pluginName}` : 'plugin'),
88
+ content_hash: agentDef.content_hash || '',
89
+ };
90
+ const spec = agentDef.spec || agentToSpec(base);
91
+ if (spec.config?.metadata && typeof spec.config.metadata === 'object') {
92
+ spec.config.metadata.source = base.source;
93
+ spec.config.metadata.source_scope = 'plugin';
94
+ }
95
+ spec.source = base.source;
96
+ spec.source_scope = 'plugin';
97
+ const content = JSON.stringify(spec);
98
+ return {
99
+ ...base,
100
+ slug: spec.slug,
101
+ spec,
102
+ content_hash: agentDef.content_hash || crypto.createHash('sha256').update(content).digest('hex'),
103
+ };
104
+ }
105
+
106
+ function platformAgentToLocalShape(def) {
107
+ const base = {
108
+ slug: def.command,
109
+ command: def.command,
110
+ name: def.name || def.command,
111
+ description: def.description || '',
112
+ role: 'platform',
113
+ model: null,
114
+ models: undefined,
115
+ tools: Array.isArray(def.tools) && def.tools.length
116
+ ? def.tools
117
+ : (def.readOnly ? READ_ONLY_TOOLS : []),
118
+ capabilities: [],
119
+ domains: [],
120
+ prompt: def.systemPrompt || '',
121
+ system_prompt: def.systemPrompt || '',
122
+ source_scope: 'platform',
123
+ source: 'platform:cli',
124
+ content_hash: '',
125
+ read_only: Boolean(def.readOnly),
126
+ readOnly: Boolean(def.readOnly),
127
+ runnable: true,
128
+ };
129
+ const spec = agentToSpec(base);
130
+ spec.source = base.source;
131
+ spec.source_scope = 'platform';
132
+ if (spec.config?.metadata && typeof spec.config.metadata === 'object') {
133
+ spec.config.metadata.source = base.source;
134
+ spec.config.metadata.source_scope = 'platform';
135
+ }
136
+ return { ...base, spec };
137
+ }
138
+
139
+ function listPlatformAgents() {
140
+ return BUILTIN_AGENTS.map(platformAgentToLocalShape);
141
+ }
142
+
143
+ function listPluginAgents() {
144
+ if (!pluginRegistry?.listAgents) return [];
145
+ return pluginRegistry.listAgents().map(pluginAgentToLocalShape).filter(agent => agent.slug);
146
+ }
147
+
148
+ function listRunnables() {
149
+ const bySlug = new Map();
150
+ for (const agent of listLocalAgents(currentCwd())) {
151
+ if (agent.slug && !bySlug.has(agent.slug)) {
152
+ bySlug.set(agent.slug, { ...agent, runnable: true });
153
+ }
154
+ }
155
+ for (const agent of listPlatformAgents()) {
156
+ if (agent.slug && !bySlug.has(agent.slug)) {
157
+ bySlug.set(agent.slug, agent);
158
+ }
159
+ }
160
+ const allowlist = new Set(pluginAgentAllowlist());
161
+ for (const agent of listPluginAgents()) {
162
+ if (!agent.slug || bySlug.has(agent.slug)) continue;
163
+ if (channel === 'workspace' || allowlist.has(agent.slug)) {
164
+ bySlug.set(agent.slug, { ...agent, runnable: true });
165
+ }
166
+ }
167
+ return [...bySlug.values()];
168
+ }
169
+
170
+ function listWorkspaceScopedPluginAgents() {
171
+ const runnableSlugs = new Set(listRunnables().map(agent => agent.slug));
172
+ return listPluginAgents()
173
+ .filter(agent => agent.slug && !runnableSlugs.has(agent.slug))
174
+ .map(agent => ({ ...agent, runnable: false }));
175
+ }
176
+
177
+ function listAvailableAgents() {
178
+ // Backend workspaces such as kepler-code already implement their
179
+ // platform roles (explore/plan/verify/debug/refactor) as reserved
180
+ // meta-tool targets. Only send extension agents here so we do not
181
+ // shadow the backend's native routing.
182
+ return listRunnables().filter(agent => agent.source_scope !== 'platform');
183
+ }
184
+
185
+ function findAgent(target) {
186
+ const needle = String(target || '').trim().toLowerCase();
187
+ if (!needle) return null;
188
+ return listRunnables().find(agent => [
189
+ agent.slug,
190
+ agent.id,
191
+ agent.command,
192
+ agent.name,
193
+ ].some(value => String(value || '').trim().toLowerCase() === needle)) || null;
194
+ }
195
+
196
+ function filterAgents(args = {}) {
197
+ let scope = String(args.scope || '').trim();
198
+ if (scope === 'builtin') scope = 'platform';
199
+ if (scope && !['project', 'global', 'plugin', 'platform'].includes(scope)) {
200
+ throw new Error('scope must be "project", "global", "plugin", "platform", or "builtin"');
201
+ }
202
+ const pool = scope === 'plugin'
203
+ ? [...listRunnables(), ...listWorkspaceScopedPluginAgents()]
204
+ : listRunnables();
205
+ const query = String(args.query || args.name || '').trim().toLowerCase();
206
+ return pool
207
+ .filter(agent => !scope || agent.source_scope === scope)
208
+ .filter(agent => {
209
+ if (!query) return true;
210
+ return [
211
+ agent.slug,
212
+ agent.name,
213
+ agent.description,
214
+ agent.role,
215
+ ...(Array.isArray(agent.capabilities) ? agent.capabilities : []),
216
+ ...(Array.isArray(agent.domains) ? agent.domains : []),
217
+ ].some(value => String(value || '').toLowerCase().includes(query));
218
+ });
219
+ }
220
+
221
+ function observability() {
222
+ return {
223
+ version: 1,
224
+ events: SUB_AGENT_EVENT_SCHEMA,
225
+ correlation_fields: ['graph_run_id', 'node_id', 'sub_agent', 'sub_agent_run_id', 'call_id'],
226
+ tool_attribution_fields: ['internal', 'sub_agent', 'subAgent'],
227
+ };
228
+ }
229
+
230
+ return {
231
+ listRunnables,
232
+ listAvailableAgents,
233
+ listPlatformAgents,
234
+ listPluginAgents,
235
+ listWorkspaceScopedPluginAgents,
236
+ filterAgents,
237
+ findAgent,
238
+ observability,
239
+ };
240
+ }
@@ -4,7 +4,7 @@
4
4
  * bahulam pull <src> — install an ingredient (currently pi:<name>).
5
5
  * No pack scaffolding; the raw package lands in
6
6
  * ~/.bahulam/plugins-pi/ and is only useful when
7
- * referenced by a pack's spec.composes:.
7
+ * referenced by a pack's config.composes:.
8
8
  *
9
9
  * bahulam install <src> — install a full pack.
10
10
  * For pi:<name>: pulls the ingredient AND
@@ -88,7 +88,7 @@ export async function handlePullCommand(argv, { cwd = process.cwd() } = {}) {
88
88
 
89
89
  Pull an ingredient (pi package) into ~/.bahulam/plugins-pi/. The
90
90
  ingredient is composable but not directly runnable — reference it from
91
- a pack's ${CYAN}spec.composes:${RESET} block, or use ${CYAN}bahulam install pi:<name>${RESET}
91
+ a pack's ${CYAN}config.composes:${RESET} block, or use ${CYAN}bahulam install pi:<name>${RESET}
92
92
  to auto-scaffold a full pack around it.
93
93
 
94
94
  Sources:
@@ -133,7 +133,7 @@ export async function handlePullCommand(argv, { cwd = process.cwd() } = {}) {
133
133
  process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
134
134
  process.stderr.write(` ${YELLOW}!${RESET} Pi packages run with your full system permissions. Bahulam does not audit pi packages.\n`);
135
135
  process.stderr.write(` ${DIM}Wrap in a pack:${RESET} ${CYAN}bahulam install pi:${classified.package_name}${RESET}\n`);
136
- process.stderr.write(` ${DIM}Compose in yours:${RESET} ${CYAN}spec.composes: [{source: pi:${classified.package_name}, expose: [...]}]${RESET}\n\n`);
136
+ process.stderr.write(` ${DIM}Compose in yours:${RESET} ${CYAN}config.composes: [{source: pi:${classified.package_name}, expose: [...]}]${RESET}\n\n`);
137
137
  } catch (err) {
138
138
  process.stderr.write(`\x1b[31m✗\x1b[0m ${err.message}\n`);
139
139
  process.exit(1);
@@ -397,7 +397,7 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
397
397
 
398
398
  // Host check for each composed pi ingredient (same policy as the
399
399
  // scaffolder path). Blocks install if a required binary is missing.
400
- const composes = m.spec?.composes || [];
400
+ const composes = m.config?.composes || [];
401
401
  if (composes.length && !meta) {
402
402
  // meta present == scaffolder path already did this pre-scaffold
403
403
  const { bahulamHome } = await import('../core/paths.mjs');
@@ -431,11 +431,11 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
431
431
 
432
432
  process.stderr.write(`\n${GREEN}✓${RESET} Installed ${BOLD}${m.metadata.name}${RESET} v${m.metadata.version}\n`);
433
433
  process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
434
- const nativeTools = (m.spec.tools || []).map(t => t.name);
435
- const composedCount = (m.spec.composes || []).reduce((n, c) => n + ((c.expose || []).length || 0), 0);
434
+ const nativeTools = (m.config.tools || []).map(t => t.name);
435
+ const composedCount = (m.config.composes || []).reduce((n, c) => n + ((c.expose || []).length || 0), 0);
436
436
  process.stderr.write(` ${DIM}tools${RESET} ${nativeTools.length ? nativeTools.join(', ') : '(none)'}${composedCount ? ` ${DIM}+ ${composedCount} composed${RESET}` : ''}\n`);
437
- process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
438
- const views = m.spec.workspace?.views || [];
437
+ process.stderr.write(` ${DIM}agents${RESET} ${(m.config.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
438
+ const views = m.config.views || [];
439
439
  process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
440
440
  if (meta) {
441
441
  process.stderr.write(` ${DIM}scaffolded${RESET} from ${CYAN}pi:${meta.packageName}${RESET} (namespace ${CYAN}${meta.namespace}${RESET}, ${meta.exposeTools.length} composed tool${meta.exposeTools.length === 1 ? '' : 's'})\n`);
@@ -24,6 +24,7 @@ import { spawn } from 'node:child_process';
24
24
  import { parsePluginManifestFile } from '../plugins/manifest.mjs';
25
25
  import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
26
26
  import { parsePiSource } from '../plugins/pi-compose.mjs';
27
+ import { bahulamHome } from '../core/paths.mjs';
27
28
 
28
29
  const RESET = '\x1b[0m';
29
30
  const BOLD = '\x1b[1m';
@@ -38,13 +39,13 @@ const INSTALL_STAMP = '.bahulam-plugin.json';
38
39
  function searchDirs(cwd) {
39
40
  return [
40
41
  { scope: 'project', dir: path.join(cwd, '.bahulam', 'plugins') },
41
- { scope: 'global', dir: path.join(os.homedir(), '.bahulam', 'plugins') },
42
+ { scope: 'global', dir: path.join(bahulamHome(), 'plugins') },
42
43
  ];
43
44
  }
44
45
 
45
46
  export function pluginTargetDir({ global, cwd }) {
46
47
  return global
47
- ? path.join(os.homedir(), '.bahulam', 'plugins')
48
+ ? path.join(bahulamHome(), 'plugins')
48
49
  : path.join(cwd, '.bahulam', 'plugins');
49
50
  }
50
51
 
@@ -72,7 +73,7 @@ function scanInstalled(cwd) {
72
73
  // else in the plugins/ dir is stray (readManifest returns null).
73
74
  if (!parsed && !disabled) continue;
74
75
  const stamp = readStamp(pluginDir);
75
- const agentSlugs = (parsed?.manifest?.spec?.agents || [])
76
+ const agentSlugs = (parsed?.manifest?.config?.agents || [])
76
77
  .map(a => a.slug || a.name).filter(Boolean);
77
78
  found.push({
78
79
  scope,
@@ -81,10 +82,10 @@ function scanInstalled(cwd) {
81
82
  name: parsed?.manifest?.metadata?.name || entry.name.replace(/\.disabled$/, ''),
82
83
  version: parsed?.manifest?.metadata?.version || null,
83
84
  description: parsed?.manifest?.metadata?.description || '',
84
- tools: parsed?.manifest?.spec?.tools?.length || 0,
85
- agents: parsed?.manifest?.spec?.agents?.length || 0,
86
- views: parsed?.manifest?.spec?.workspace?.views?.length || 0,
87
- composes: parsed?.manifest?.spec?.composes?.length || 0,
85
+ tools: parsed?.manifest?.config?.tools?.length || 0,
86
+ agents: parsed?.manifest?.config?.agents?.length || 0,
87
+ views: parsed?.manifest?.config?.views?.length || 0,
88
+ composes: parsed?.manifest?.config?.composes?.length || 0,
88
89
  agentSlugs,
89
90
  disabled,
90
91
  origin: stamp?.origin || null,
@@ -390,12 +391,12 @@ export async function installFromLocal({ src, targetDir, force }) {
390
391
  }
391
392
 
392
393
  /**
393
- * Auto-install pi packages referenced by a pack's spec.composes:. Callers
394
+ * Auto-install pi packages referenced by a pack's config.composes:. Callers
394
395
  * invoke this after preflight so a hand-authored pack that composes
395
396
  * missing pi ingredients still resolves in one command.
396
397
  */
397
398
  export async function resolveComposeDependencies(manifest, { targetDir } = {}) {
398
- const composes = manifest?.spec?.composes || [];
399
+ const composes = manifest?.config?.composes || [];
399
400
  if (!composes.length) return;
400
401
  const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
401
402
  const { bahulamHome } = await import('../core/paths.mjs');
@@ -446,7 +447,7 @@ async function cmdList(args, cwd) {
446
447
  if (!p.composes) continue;
447
448
  try {
448
449
  const m = readManifest(p.directory);
449
- const composes = m?.manifest?.spec?.composes || [];
450
+ const composes = m?.manifest?.config?.composes || [];
450
451
  pluginComposes.set(p.name, composes.map(c => c.package_name || c.packageName).filter(Boolean));
451
452
  } catch { /* skip */ }
452
453
  }
@@ -519,7 +520,7 @@ async function cmdList(args, cwd) {
519
520
  const orphans = pi.filter(p => usedBy(p.name).length === 0);
520
521
  if (orphans.length) {
521
522
  process.stderr.write(`\n${YELLOW}!${RESET} ${orphans.length} pi ingredient${orphans.length === 1 ? '' : 's'} installed but not composed by any pack.\n`);
522
- process.stderr.write(` Pi ingredients are unusable on their own — reference in a pack's ${CYAN}spec.composes:${RESET} block.\n`);
523
+ process.stderr.write(` Pi ingredients are unusable on their own — reference in a pack's ${CYAN}config.composes:${RESET} block.\n`);
523
524
  }
524
525
  }
525
526
 
@@ -588,9 +589,9 @@ function cmdInfo(args, cwd) {
588
589
  }
589
590
  if (found.installed_at) process.stderr.write(` ${DIM}installed${RESET} ${found.installed_at}\n`);
590
591
  if (m) {
591
- process.stderr.write(`\n ${DIM}tools${RESET} ${(m.spec.tools || []).map(t => t.name).join(', ') || '(none)'}\n`);
592
- process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
593
- const views = m.spec.workspace?.views || [];
592
+ process.stderr.write(`\n ${DIM}tools${RESET} ${(m.config.tools || []).map(t => t.name).join(', ') || '(none)'}\n`);
593
+ process.stderr.write(` ${DIM}agents${RESET} ${(m.config.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
594
+ const views = m.config.views || [];
594
595
  process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
595
596
  }
596
597
  process.stderr.write('\n');
@@ -711,7 +712,7 @@ async function cmdDoctor(args, cwd) {
711
712
  const found = findByName(target, cwd);
712
713
  if (!found) throw new Error(`plugin not found: ${target}`);
713
714
  const scan = readManifest(found.directory);
714
- const composes = scan?.manifest?.spec?.composes || [];
715
+ const composes = scan?.manifest?.config?.composes || [];
715
716
  for (const c of composes) {
716
717
  if (!c.package_name) continue;
717
718
  const safe = c.package_name.replace(/[/@]/g, '_');
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import * as fs from 'node:fs';
14
- import * as os from 'node:os';
15
14
  import * as path from 'node:path';
16
15
  import {
17
16
  createLocalWorkspaceSession,
@@ -21,6 +20,7 @@ import {
21
20
  } from '../local-service/session-store.mjs';
22
21
  import { startLocalWorkspaceService } from '../local-service/server.mjs';
23
22
  import { openLocalBrowser } from '../local-service/browser.mjs';
23
+ import { bahulamHome } from '../core/paths.mjs';
24
24
 
25
25
  const RESET = '\x1b[0m';
26
26
  const BOLD = '\x1b[1m';
@@ -33,20 +33,22 @@ const RED = '\x1b[31m';
33
33
  /**
34
34
  * Standard directories to search for plugins.
35
35
  */
36
- const PLUGIN_SEARCH_DIRS = [
37
- path.join(process.cwd(), '.bahulam', 'plugins'),
38
- path.join(os.homedir(), '.bahulam', 'plugins'),
39
- ];
36
+ function pluginSearchDirs(cwd = process.cwd()) {
37
+ return [
38
+ path.join(cwd, '.bahulam', 'plugins'),
39
+ path.join(bahulamHome(), 'plugins'),
40
+ ];
41
+ }
40
42
 
41
43
  /**
42
44
  * Find a plugin directory by name across all standard search paths.
43
45
  * Returns the directory path and manifests on success, null on miss.
44
46
  */
45
- function findPluginDir(name) {
47
+ function findPluginDir(name, cwd = process.cwd()) {
46
48
  const needle = String(name || '').trim().toLowerCase();
47
49
  if (!needle) return null;
48
50
 
49
- for (const searchDir of PLUGIN_SEARCH_DIRS) {
51
+ for (const searchDir of pluginSearchDirs(cwd)) {
50
52
  try {
51
53
  if (!fs.existsSync(searchDir)) continue;
52
54
  const entries = fs.readdirSync(searchDir, { withFileTypes: true });
@@ -113,17 +115,18 @@ export async function handlePluginCommand(args, { cwd = process.cwd() } = {}) {
113
115
  const targetPath = String(args.targetPath || cwd).trim();
114
116
 
115
117
  if (!pluginName || args.help) {
116
- printPluginUsage();
118
+ printPluginUsage(cwd);
117
119
  process.exit(args.help ? 0 : 1);
118
120
  }
119
121
 
120
122
  // 1. Find the plugin
121
- const found = findPluginDir(pluginName);
123
+ const found = findPluginDir(pluginName, cwd);
122
124
  if (!found) {
125
+ const searchDirs = pluginSearchDirs(cwd);
123
126
  process.stderr.write(
124
127
  `${RED}✗ Plugin "${pluginName}" not found.${RESET}\n` +
125
128
  ` ${DIM}Searched:${RESET}\n` +
126
- PLUGIN_SEARCH_DIRS.map(d => ` ${d}`).join('\n') + '\n' +
129
+ searchDirs.map(d => ` ${d}`).join('\n') + '\n' +
127
130
  ` ${DIM}Create a plugin.yaml or plugin.json in one of these directories.${RESET}\n`
128
131
  );
129
132
  process.exit(1);
@@ -210,7 +213,7 @@ export async function handlePluginCommand(args, { cwd = process.cwd() } = {}) {
210
213
  });
211
214
  }
212
215
 
213
- function printPluginUsage() {
216
+ function printPluginUsage(cwd = process.cwd()) {
214
217
  process.stderr.write(
215
218
  `${BOLD}PLUGIN COMMANDS${RESET}\n` +
216
219
  ` ${CYAN}bahulam plugin <name> [path]${RESET} Open a workspace with a plugin loaded\n` +
@@ -238,7 +241,7 @@ function printPluginUsage() {
238
241
  ` --force overwrite existing install\n` +
239
242
  `\n` +
240
243
  ` ${DIM}Search paths (later overrides earlier):${RESET}\n` +
241
- PLUGIN_SEARCH_DIRS.map(d => ` ${d}`).join('\n') + '\n' +
244
+ pluginSearchDirs(cwd).map(d => ` ${d}`).join('\n') + '\n' +
242
245
  `\n` +
243
246
  ` ${DIM}Example:${RESET}\n` +
244
247
  ` bahulam plugin install https://github.com/community/seo-toolkit\n` +
@@ -14,11 +14,26 @@ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
14
14
  const KILL_ESCALATION_MS = 5000;
15
15
  const MAX_TAIL_BYTES = 64 * 1024;
16
16
 
17
+ function trimShellBackgroundOperator(command) {
18
+ const raw = String(command || '');
19
+ let i = raw.length - 1;
20
+ while (i >= 0 && /\s/.test(raw[i])) i -= 1;
21
+ if (raw[i] !== '&') return raw;
22
+ if (raw[i - 1] === '&' || raw[i - 1] === '\\') return raw;
23
+ return raw.slice(0, i).trimEnd();
24
+ }
25
+
17
26
  function stripAnsi(str) {
18
27
  // eslint-disable-next-line no-control-regex
19
28
  return String(str || '').replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
20
29
  }
21
30
 
31
+ function setChildStdioRef(proc, ref) {
32
+ for (const stream of [proc?.stdout, proc?.stderr]) {
33
+ try { stream?.[ref ? 'ref' : 'unref']?.(); } catch { /* best effort */ }
34
+ }
35
+ }
36
+
22
37
  class BackgroundTasks {
23
38
  constructor() {
24
39
  this.jobs = new Map();
@@ -35,12 +50,15 @@ class BackgroundTasks {
35
50
  start({ command, cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS, name = '', on_complete = null }) {
36
51
  this._installExitHook();
37
52
  const id = `job-${++this._seq}-${Date.now().toString(36)}`;
53
+ const originalCommand = String(command || '');
54
+ const managedCommand = trimShellBackgroundOperator(originalCommand);
38
55
  const logDir = path.join(cwd, '.bahulam', 'tmp', 'jobs');
39
56
  fs.mkdirSync(logDir, { recursive: true });
40
57
  const logPath = path.join(logDir, `${id}.log`);
41
58
  const logStream = fs.createWriteStream(logPath);
59
+ try { logStream.unref?.(); } catch { /* best effort */ }
42
60
 
43
- const proc = spawn('bash', ['-c', command], {
61
+ const proc = spawn('bash', ['-c', managedCommand], {
44
62
  cwd,
45
63
  env: { ...process.env },
46
64
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -49,8 +67,9 @@ class BackgroundTasks {
49
67
 
50
68
  const job = {
51
69
  id,
52
- name: name || command.slice(0, 60),
53
- command,
70
+ name: name || managedCommand.slice(0, 60),
71
+ command: managedCommand,
72
+ original_command: originalCommand !== managedCommand ? originalCommand : null,
54
73
  cwd,
55
74
  pid: proc.pid,
56
75
  status: 'running',
@@ -62,6 +81,7 @@ class BackgroundTasks {
62
81
  timed_out: false,
63
82
  on_complete,
64
83
  _proc: proc,
84
+ _logStream: logStream,
65
85
  _done: null,
66
86
  };
67
87
 
@@ -106,6 +126,7 @@ class BackgroundTasks {
106
126
  });
107
127
 
108
128
  proc.unref();
129
+ setChildStdioRef(proc, false);
109
130
  this.jobs.set(id, job);
110
131
  return this.describe(id);
111
132
  }
@@ -120,8 +141,12 @@ class BackgroundTasks {
120
141
  // process so fast commands still get their close event before Node decides
121
142
  // the top-level await is unsettled.
122
143
  try { job._proc?.ref?.(); } catch { /* best effort */ }
144
+ try { job._logStream?.ref?.(); } catch { /* best effort */ }
145
+ setChildStdioRef(job._proc, true);
123
146
  return job._done.finally(() => {
124
147
  try { job._proc?.unref?.(); } catch { /* best effort */ }
148
+ try { job._logStream?.unref?.(); } catch { /* best effort */ }
149
+ setChildStdioRef(job._proc, false);
125
150
  });
126
151
  }
127
152
 
@@ -132,6 +157,7 @@ class BackgroundTasks {
132
157
  id: job.id,
133
158
  name: job.name,
134
159
  command: job.command,
160
+ original_command: job.original_command,
135
161
  pid: job.pid,
136
162
  status: job.status,
137
163
  exit_code: job.exit_code,