@bahulam/code 0.1.20 → 0.1.22

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.20",
3
+ "version": "0.1.22",
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": {
@@ -93,6 +93,7 @@ export class AgentLoader {
93
93
  slug,
94
94
  normalizeKey(agent.name),
95
95
  normalizeKey(agent.id),
96
+ ...(Array.isArray(agent.aliases) ? agent.aliases.map(normalizeKey) : []),
96
97
  ].filter(Boolean);
97
98
 
98
99
  // Earlier search paths have higher precedence: project beats global,
@@ -189,6 +189,7 @@ function normalizeAgent(data) {
189
189
  model: data.model || agent.model || null,
190
190
  models: data.models && typeof data.models === 'object' ? data.models : {},
191
191
  tools: Array.isArray(data.tools) ? data.tools : Array.isArray(config?.tools) ? config.tools : [],
192
+ aliases: Array.isArray(data.aliases) ? data.aliases : Array.isArray(metadata.aliases) ? metadata.aliases : [],
192
193
  capabilities: Array.isArray(data.capabilities) ? data.capabilities : Array.isArray(metadata.capabilities) ? metadata.capabilities : [],
193
194
  domains: Array.isArray(data.domains) ? data.domains : Array.isArray(metadata.domains) ? metadata.domains : [],
194
195
  hooks: data.hooks || {},
@@ -30,6 +30,7 @@ export function compactAgentMetadata(agent) {
30
30
  tools: Array.isArray(agent.tools) ? agent.tools : [],
31
31
  capabilities: Array.isArray(agent.capabilities) ? agent.capabilities : [],
32
32
  domains: Array.isArray(agent.domains) ? agent.domains : [],
33
+ aliases: Array.isArray(agent.aliases) ? agent.aliases : [],
33
34
  source_scope: agent.source_scope || 'unknown',
34
35
  source: agent.source || '',
35
36
  content_hash: agent.content_hash || '',
@@ -81,6 +82,7 @@ export function createAgentRegistry({
81
82
  tools: normalizeAgentTools(agentDef.tools || agentDef.agent_tools),
82
83
  capabilities: Array.isArray(agentDef.capabilities) ? agentDef.capabilities : [],
83
84
  domains: Array.isArray(agentDef.domains) ? agentDef.domains : [],
85
+ aliases: Array.isArray(agentDef.aliases) ? agentDef.aliases.map(String).filter(Boolean) : [],
84
86
  prompt: agentDef.prompt || agentDef.system_prompt || agentDef.systemPrompt || '',
85
87
  system_prompt: agentDef.system_prompt || agentDef.systemPrompt || agentDef.prompt || '',
86
88
  source_scope: 'plugin',
@@ -160,7 +162,7 @@ export function createAgentRegistry({
160
162
  const allowlist = new Set(pluginAgentAllowlist());
161
163
  for (const agent of listPluginAgents()) {
162
164
  if (!agent.slug || bySlug.has(agent.slug)) continue;
163
- if (channel === 'workspace' || allowlist.has(agent.slug)) {
165
+ if (channel === 'workspace' || allowlist.has(agent.slug) || agent.entry_agent === true) {
164
166
  bySlug.set(agent.slug, { ...agent, runnable: true });
165
167
  }
166
168
  }
@@ -190,6 +192,7 @@ export function createAgentRegistry({
190
192
  agent.id,
191
193
  agent.command,
192
194
  agent.name,
195
+ ...(Array.isArray(agent.aliases) ? agent.aliases : []),
193
196
  ].some(value => String(value || '').trim().toLowerCase() === needle)) || null;
194
197
  }
195
198
 
@@ -212,6 +215,7 @@ export function createAgentRegistry({
212
215
  agent.name,
213
216
  agent.description,
214
217
  agent.role,
218
+ ...(Array.isArray(agent.aliases) ? agent.aliases : []),
215
219
  ...(Array.isArray(agent.capabilities) ? agent.capabilities : []),
216
220
  ...(Array.isArray(agent.domains) ? agent.domains : []),
217
221
  ].some(value => String(value || '').toLowerCase().includes(query));
@@ -7,8 +7,9 @@
7
7
  * yet; if a bare name is given to `install`, we look it up in the
8
8
  * awesome-bahulam-plugins index (a plain JSON manifest hosted in the
9
9
  * community repo).
10
- * - Install target: `~/.bahulam/plugins/` by default, `.bahulam/plugins/`
11
- * with --project. Never global npm, never modifies the user's PATH.
10
+ * - Install target: `~/.bahulam/plugins/`. Always. Plugins have no project
11
+ * scope (skills have one, separately). Never global npm, never modifies
12
+ * the user's PATH.
12
13
  * - Disable: rename directory to `<name>.disabled`. The plugin registry
13
14
  * only scans directories with a valid manifest, so this hides the plugin
14
15
  * without deleting it. `enable` reverses the rename.
@@ -24,7 +25,7 @@ import { spawn } from 'node:child_process';
24
25
  import { parsePluginManifestFile } from '../plugins/manifest.mjs';
25
26
  import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
26
27
  import { parsePiSource } from '../plugins/pi-compose.mjs';
27
- import { bahulamHome } from '../core/paths.mjs';
28
+ import { bahulamHome, pluginDirs, pluginInstallDir } from '../core/paths.mjs';
28
29
 
29
30
  const RESET = '\x1b[0m';
30
31
  const BOLD = '\x1b[1m';
@@ -36,17 +37,20 @@ const RED = '\x1b[31m';
36
37
 
37
38
  const INSTALL_STAMP = '.bahulam-plugin.json';
38
39
 
39
- function searchDirs(cwd) {
40
- return [
41
- { scope: 'project', dir: path.join(cwd, '.bahulam', 'plugins') },
42
- { scope: 'global', dir: path.join(bahulamHome(), 'plugins') },
43
- ];
40
+ function searchDirs(_cwd) {
41
+ // One scope. `_cwd` is accepted but ignored so callers don't churn, and so
42
+ // nobody is tempted to reintroduce cwd-dependence here.
43
+ return pluginDirs().map(dir => ({ scope: 'global', dir }));
44
44
  }
45
45
 
46
- export function pluginTargetDir({ global, cwd }) {
47
- return global
48
- ? path.join(bahulamHome(), 'plugins')
49
- : path.join(cwd, '.bahulam', 'plugins');
46
+ /**
47
+ * Where a plugin install lands: always the global root.
48
+ *
49
+ * The old `{global, cwd}` options are accepted and ignored — `global: false`
50
+ * no longer selects a project directory, because there isn't one.
51
+ */
52
+ export function pluginTargetDir() {
53
+ return pluginInstallDir();
50
54
  }
51
55
 
52
56
  function readManifest(dir) {
@@ -73,8 +77,12 @@ function scanInstalled(cwd) {
73
77
  // else in the plugins/ dir is stray (readManifest returns null).
74
78
  if (!parsed && !disabled) continue;
75
79
  const stamp = readStamp(pluginDir);
76
- const agentSlugs = (parsed?.manifest?.config?.agents || [])
77
- .map(a => a.slug || a.name).filter(Boolean);
80
+ const agents = parsed?.manifest?.config?.agents || [];
81
+ const agentSlugs = agents.map(a => a.slug || a.name).filter(Boolean);
82
+ const entryAgentSlugs = agents
83
+ .filter(a => a.entry_agent === true)
84
+ .map(a => a.slug || a.name)
85
+ .filter(Boolean);
78
86
  found.push({
79
87
  scope,
80
88
  directory: pluginDir,
@@ -87,6 +95,7 @@ function scanInstalled(cwd) {
87
95
  views: parsed?.manifest?.config?.views?.length || 0,
88
96
  composes: parsed?.manifest?.config?.composes?.length || 0,
89
97
  agentSlugs,
98
+ entryAgentSlugs,
90
99
  disabled,
91
100
  origin: stamp?.origin || null,
92
101
  installed_at: stamp?.installed_at || null,
@@ -433,11 +442,12 @@ async function cmdList(args, cwd) {
433
442
  const plugins = scanInstalled(cwd);
434
443
  const pi = scanPiIngredients();
435
444
  const allowlist = new Set(await readAgentAllowlist(cwd));
436
- // A pack is "enabled" for this session when at least one of its
437
- // agents is in plugins.agent_allowlist (PRD-102 §6.2.1). Packs with
438
- // no agents (tools-only packs) always count as enabled — the
439
- // allowlist gate only exists for agents.
440
- const enabled = (p) => p.agentSlugs.length === 0 || p.agentSlugs.some(s => allowlist.has(s));
445
+ // A pack is "enabled" for this session when it has no agents, exposes an
446
+ // entry agent, or has at least one agent explicitly allowlisted. Helpers
447
+ // without entry_agent stay workspace-scoped until allowlisted.
448
+ const enabled = (p) => p.agentSlugs.length === 0
449
+ || (p.entryAgentSlugs || []).length > 0
450
+ || p.agentSlugs.some(s => allowlist.has(s));
441
451
  const composerFor = (piName) => plugins.filter(p =>
442
452
  (p.composes > 0) && Boolean(p) // composes is a count; details need re-read
443
453
  );
@@ -460,6 +470,7 @@ async function cmdList(args, cwd) {
460
470
  ...p,
461
471
  enabled: enabled(p),
462
472
  allowlisted_agents: p.agentSlugs.filter(s => allowlist.has(s)),
473
+ entry_agents: p.entryAgentSlugs || [],
463
474
  }));
464
475
  process.stdout.write(JSON.stringify({
465
476
  ok: true,
@@ -497,7 +508,7 @@ async function cmdList(args, cwd) {
497
508
  const notEnabled = plugins.filter(p => !p.disabled && !enabled(p));
498
509
  if (notEnabled.length) {
499
510
  process.stderr.write(`\n${YELLOW}!${RESET} ${notEnabled.length} pack${notEnabled.length === 1 ? '' : 's'} installed but NOT enabled in this session.\n`);
500
- process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted.\n`);
511
+ process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted or declared as entry_agent.\n`);
501
512
  process.stderr.write(` Add to ${CYAN}.bahulam/settings.json${RESET}:\n`);
502
513
  const slugs = notEnabled.flatMap(p => p.agentSlugs);
503
514
  process.stderr.write(` ${DIM}{ "plugins": { "agent_allowlist": ${JSON.stringify(slugs)} } }${RESET}\n`);
@@ -31,13 +31,10 @@ const YELLOW = '\x1b[33m';
31
31
  const RED = '\x1b[31m';
32
32
 
33
33
  /**
34
- * Standard directories to search for plugins.
34
+ * The one and only directory to search for plugins.
35
35
  */
36
- function pluginSearchDirs(cwd = process.cwd()) {
37
- return [
38
- path.join(cwd, '.bahulam', 'plugins'),
39
- path.join(bahulamHome(), 'plugins'),
40
- ];
36
+ function pluginSearchDirs() {
37
+ return [path.join(bahulamHome(), 'plugins')];
41
38
  }
42
39
 
43
40
  /**
@@ -10,7 +10,7 @@
10
10
  {"value": "us.anthropic.claude-opus-4-6-v1", "label": "Claude Opus 4.6 (Bedrock)", "provider": "anthropic", "inputCost": 15, "outputCost": 75, "context": 200000, "maxOutput": 32000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": false},
11
11
  {"value": "us.anthropic.claude-sonnet-4-6", "label": "Claude Sonnet 4.6 (Bedrock)", "provider": "anthropic", "inputCost": 3, "outputCost": 15, "context": 200000, "maxOutput": 64000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": false},
12
12
  {"value": "us.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Claude Haiku 4.5 (Bedrock)", "provider": "anthropic", "inputCost": 0.8, "outputCost": 4, "context": 200000, "maxOutput": 8192, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
13
- {"value": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro", "provider": "google", "category": "image", "inputCost": 1.25, "outputCost": 10, "context": 1048576, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
13
+ {"value": "google/gemini-2.5-pro", "label": "Gemini 2.5 Pro", "provider": "google", "category": "multimodal", "inputModalities": ["text", "image", "file", "audio", "video"], "outputModalities": ["text"], "inputCost": 1.25, "outputCost": 10, "context": 1048576, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
14
14
  {"value": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash", "provider": "google", "inputCost": 0.15, "outputCost": 0.6, "context": 1048576, "maxOutput": 65535, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
15
15
  {"value": "google/gemini-2.5-flash-lite", "label": "Gemini 2.5 Flash Lite", "provider": "google", "inputCost": 0.075, "outputCost": 0.3, "context": 1048576, "maxOutput": 65535, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
16
16
  {"value": "openai/gpt-4.1", "label": "GPT-4.1", "provider": "openai", "inputCost": 2, "outputCost": 8, "context": 1047576, "maxOutput": 32768, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
@@ -19,7 +19,7 @@
19
19
  {"value": "openai/codex-mini", "label": "Codex Mini", "provider": "openai", "inputCost": 1.5, "outputCost": 6, "context": 200000, "maxOutput": 100000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": false},
20
20
  {"value": "deepseek/deepseek-v4-flash", "label": "DeepSeek V4 Flash", "provider": "deepseek", "inputCost": 0.098, "outputCost": 0.197, "context": 1048576, "maxOutput": 384000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
21
21
  {"value": "deepseek/deepseek-v4-flash-0731", "label": "DeepSeek V4 Flash (0731)", "provider": "deepseek", "inputCost": 0.098, "outputCost": 0.197, "context": 1310720, "maxOutput": 384000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"DeepSeek\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
22
- {"value": "deepseek/deepseek-v4-flash-vision-exp", "label": "DeepSeek V4 Flash Vision Exp", "provider": "deepseek", "category": "image", "inputCost": 0.22, "outputCost": 0.66, "context": 1048576, "maxOutput": 384000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "platformAccessTier": ["free", "pro", "tier_49", "tier_99"]},
22
+ {"value": "deepseek/deepseek-v4-flash-vision-exp", "label": "DeepSeek V4 Flash Vision Exp", "provider": "deepseek", "category": "multimodal", "inputModalities": ["text", "image"], "outputModalities": ["text"], "inputCost": 0.22, "outputCost": 0.66, "context": 1048576, "maxOutput": 384000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "platformAccessTier": ["free", "pro", "tier_49", "tier_99"]},
23
23
  {"value": "deepseek/deepseek-v4-pro", "label": "DeepSeek V4 Pro", "provider": "deepseek", "inputCost": 0.435, "outputCost": 0.87, "context": 1048576, "maxOutput": 384000, "supportsTools": true, "supportsReasoning": true, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"DeepSeek\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
24
24
  {"value": "deepseek/deepseek-chat-v3-0324", "label": "DeepSeek V3", "provider": "deepseek", "inputCost": 0.27, "outputCost": 1.1, "context": 163840, "maxOutput": 163840, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
25
25
  {"value": "deepseek/deepseek-r1-0528", "label": "DeepSeek R1 0528", "provider": "deepseek", "inputCost": 0.8, "outputCost": 2.4, "context": 163840, "maxOutput": 32768, "supportsTools": true, "supportsReasoning": true, "harnessValidated": false},
@@ -37,6 +37,8 @@ function normalizeSnapshotRow(row) {
37
37
  provider: row.provider || (id.includes('/') ? id.split('/', 1)[0] : 'unknown'),
38
38
  label: row.label || id,
39
39
  category,
40
+ input_modalities: row.inputModalities ?? row.input_modalities ?? null,
41
+ output_modalities: row.outputModalities ?? row.output_modalities ?? null,
40
42
  input_cost_usd_per_m: row.inputCost ?? row.input_cost_usd_per_m ?? null,
41
43
  output_cost_usd_per_m: row.outputCost ?? row.output_cost_usd_per_m ?? null,
42
44
  context_length: row.context ?? row.context_length ?? null,
@@ -7,6 +7,9 @@
7
7
  * history.jsonl — prompt history
8
8
  * hooks.json — global hooks
9
9
  * conversations/ — conversation JSONL files
10
+ * plugins/ — installed plugins (THE plugin root)
11
+ * plugins-pi/ — pi ingredients (composed, not run directly)
12
+ * data/{plugin}/ — per-plugin state (state.db)
10
13
  * projects/
11
14
  * {hash}/ — per-project data (hash of project path)
12
15
  * index/ — BM25 search index
@@ -57,6 +60,27 @@ export function bahulamHome() {
57
60
  return resolveHome();
58
61
  }
59
62
 
63
+ /**
64
+ * The one and only plugin root: `~/.bahulam/plugins`.
65
+ *
66
+ * Deliberately takes no `cwd`. There used to be a project-scoped second root
67
+ * (`<cwd>/.bahulam/plugins`), which meant the set of installed plugins
68
+ * depended on the directory you happened to launch from — the same command
69
+ * saw different plugins in different terminals. Plugins are global; only
70
+ * skills keep a project scope.
71
+ */
72
+ export function pluginInstallDir() {
73
+ return path.join(bahulamHome(), 'plugins');
74
+ }
75
+
76
+ /**
77
+ * Directories the plugin registry scans. Exactly one entry, forever.
78
+ * @returns {string[]}
79
+ */
80
+ export function pluginDirs() {
81
+ return [pluginInstallDir()];
82
+ }
83
+
60
84
  /** ~/.bahulam/projects/{hash}/ for a given project path. */
61
85
  export function projectDir(projectPath) {
62
86
  return path.join(bahulamHome(), 'projects', projectHash(projectPath));
@@ -203,12 +203,45 @@ export class BahulamStreamClient {
203
203
  }
204
204
 
205
205
  /**
206
- * Plugin tools are intentionally not advertised as primary client_tools.
207
- * They are executable by the local callback handler, but the primary model
208
- * should reach them by delegating to an agent that declares them.
206
+ * Plugin tool schemas for tools that have NO owning agent.
207
+ * These are advertised as direct client_tools so the primary model can
208
+ * call them without going through delegate(). Tools already claimed by
209
+ * a plugin agent stay in client_agent_tools and are not duplicated.
209
210
  */
210
- _getPluginToolSchemas() {
211
- return [];
211
+ _getUnclaimedPluginToolSchemas(context = {}, clientAgents = []) {
212
+ if (!this.pluginRegistry) return [];
213
+ const pluginTools = this._getPluginToolMap();
214
+ if (!pluginTools.size) return [];
215
+
216
+ // Collect tool names declared by plugin agents that are actually
217
+ // advertised. Tools owned only by hidden/workspace-scoped helpers still
218
+ // need to be exposed as direct client_tools.
219
+ const clientAgentSlugs = new Set(this._getPluginAgentSchemas().map(a => a.slug));
220
+ const agentToolNames = new Set();
221
+ for (const agent of (this.pluginRegistry.listAgents?.() || [])) {
222
+ const slug = agent.slug || agent.name || '';
223
+ if (!clientAgentSlugs.has(slug)) continue;
224
+ for (const t of (agent.tools || [])) {
225
+ if (t) agentToolNames.add(String(t).trim());
226
+ }
227
+ }
228
+ for (const name of this._collectAgentScopedToolRefs(context, clientAgents).keys()) {
229
+ agentToolNames.add(name);
230
+ }
231
+
232
+ // Return schemas for tools NOT claimed by any agent
233
+ const schemas = [];
234
+ for (const [name, tool] of pluginTools) {
235
+ if (agentToolNames.has(name)) continue; // already in client_agent_tools
236
+ schemas.push({
237
+ name,
238
+ description: tool.description || '',
239
+ input_schema: tool.input_schema || { type: 'object', properties: {} },
240
+ source_scope: 'plugin',
241
+ plugin_name: tool._plugin_name || tool.plugin_name || null,
242
+ });
243
+ }
244
+ return schemas;
212
245
  }
213
246
 
214
247
  _collectAgentScopedToolRefs(context = {}, clientAgents = []) {
@@ -263,11 +296,11 @@ export class BahulamStreamClient {
263
296
  */
264
297
  _getPluginAgentSchemas() {
265
298
  if (!this.pluginRegistry) return [];
266
- // Only plugin agents admitted to the main-loop registry (settings
267
- // plugins.agent_allowlist, or the session plugin in workspace-channel
268
- // executors) are advertised. Workspace-scoped plugin agents stay out
269
- // of the main-turn payload; without an executor registry, fall back
270
- // to advertising everything (legacy behavior).
299
+ // Only plugin agents admitted to the runtime registry are advertised:
300
+ // entry agents, settings plugins.agent_allowlist, or all plugin agents
301
+ // in workspace-channel executors. Other helpers stay out of the
302
+ // main-turn payload; without an executor registry, fall back to
303
+ // advertising everything (legacy behavior).
271
304
  const runnables = this.toolExecutor?.listRunnables?.();
272
305
  const admitted = Array.isArray(runnables)
273
306
  ? new Set(runnables.filter(a => a.source_scope === 'plugin').map(a => a.slug))
@@ -365,10 +398,13 @@ export class BahulamStreamClient {
365
398
  const body = { instruction, context };
366
399
  if (messages && messages.length > 0) body.messages = messages;
367
400
  if (this.sessionId) body.session_id = this.sessionId;
368
- const clientTools = this._getPluginToolSchemas();
369
- if (clientTools.length > 0) body.client_tools = clientTools;
401
+ // Plugin tools with no owning agent are advertised as direct client_tools
402
+ // so the primary model can call them. Agent-scoped tools stay in
403
+ // client_agent_tools — _getUnclaimedPluginToolSchemas excludes those.
370
404
  const clientAgents = this._getPluginAgentSchemas();
371
405
  if (clientAgents.length > 0) body.client_agents = clientAgents;
406
+ const clientTools = this._getUnclaimedPluginToolSchemas(context, clientAgents);
407
+ if (clientTools.length > 0) body.client_tools = clientTools;
372
408
  const clientAgentTools = this._getClientAgentToolSchemas(context, clientAgents);
373
409
  if (clientAgentTools.length > 0) body.client_agent_tools = clientAgentTools;
374
410
  const requestId = `cli-${_uuidLike()}`;
@@ -31,6 +31,7 @@ import { backgroundTasks } from './background-tasks.mjs';
31
31
  import { normalizeLintOutput, resolveLintCommand } from './lint-resolver.mjs';
32
32
  import { PluginRegistry } from '../plugins/registry.mjs';
33
33
  import { loadPluginTool } from '../plugins/executor.mjs';
34
+ import { makePluginState } from '../plugins/state.mjs';
34
35
  import * as fs from 'node:fs';
35
36
  import * as os from 'node:os';
36
37
  import * as path from 'node:path';
@@ -60,9 +61,9 @@ export function createToolExecutor({
60
61
  // no reactive pulse.
61
62
  stateEmit = null,
62
63
  delegateRunner = null,
63
- // Execution channel. 'main' (REPL/headless/CLI): plugin agents are
64
- // workspace-scoped and excluded from listings and the agent-context
65
- // envelope unless allowlisted in settings plugins.agent_allowlist.
64
+ // Execution channel. 'main' (REPL/headless/CLI): plugin entry agents and
65
+ // allowlisted plugin agents are listed in the agent-context envelope.
66
+ // Other plugin helpers stay workspace-scoped.
66
67
  // 'workspace' (plugin workspace sessions via agent-relay): the
67
68
  // session plugin's agents are fully available.
68
69
  channel = 'main',
@@ -632,20 +633,82 @@ export function createToolExecutor({
632
633
  return true;
633
634
  }
634
635
 
635
- // Per-plugin state handles are opened lazily on first tool call and
636
- // cached process-wide. `makePluginState` itself dedupes on plugin
637
- // name, so this Map only exists to avoid re-attaching stateEmit on
638
- // every registered tool.
636
+ // Per-plugin state handles are opened lazily on first call and cached
637
+ // process-wide. `makePluginState` itself dedupes on plugin name, so
638
+ // this Map only exists to avoid re-attaching stateEmit on every
639
+ // registered tool — and to keep the agent-context summary reading the
640
+ // same connection a plugin tool writes through.
639
641
  const _pluginStateHandles = new Map(); // pluginName -> state proxy
640
- async function _pluginStateFor(pluginName) {
642
+
643
+ /**
644
+ * The normalized `config.state` block a plugin declared, or null when
645
+ * it declared none. Declared tables are created and additively
646
+ * migrated when the DB opens, so this is also the schema the plugin's
647
+ * own handlers query via `state.query()`.
648
+ */
649
+ function pluginStateDecl(pluginName) {
650
+ if (!pluginName || typeof pluginRegistry?.list !== 'function') return null;
651
+ const plugin = pluginRegistry.list().find(p => p.metadata?.name === pluginName);
652
+ return plugin?.config?.state || null;
653
+ }
654
+
655
+ function _pluginStateFor(pluginName) {
641
656
  if (!pluginName) return null;
642
657
  if (_pluginStateHandles.has(pluginName)) return _pluginStateHandles.get(pluginName);
643
- const { makePluginState } = await import('../plugins/state.mjs');
644
- const state = makePluginState(pluginName, { emit: stateEmit });
658
+ const decl = pluginStateDecl(pluginName);
659
+ const state = makePluginState(pluginName, {
660
+ emit: stateEmit,
661
+ tables: decl?.tables || [],
662
+ });
645
663
  _pluginStateHandles.set(pluginName, state);
646
664
  return state;
647
665
  }
648
666
 
667
+ // ── Declared plugin state → agent context ────────────────────
668
+ // Tier 1 of the state-visibility contract. Plugins that declare
669
+ // `config.state.context_always` get exactly those keys and streams
670
+ // injected into the agent context each turn, so a fresh session opens
671
+ // already knowing what the previous one left behind — instead of
672
+ // spending a tool call to rediscover it, or silently redoing work.
673
+ //
674
+ // Opt-in by construction: a plugin that declares nothing contributes
675
+ // nothing, and an opted-in plugin that has recorded nothing yet is
676
+ // skipped rather than shipping an empty block.
677
+ //
678
+ // Reads are keyed on the state DB's size+mtime so the payload stays
679
+ // byte-identical between turns when nothing wrote. Same reasoning as
680
+ // the memory digest above: a context block that churns on every turn
681
+ // invalidates the backend's prompt cache on every ExecuteRequest.
682
+ const _pluginStateCache = new Map(); // pluginName -> { key, entry }
683
+
684
+ function pluginStateContext() {
685
+ if (typeof pluginRegistry?.list !== 'function') return [];
686
+ const out = [];
687
+ for (const plugin of pluginRegistry.list()) {
688
+ const name = plugin.metadata?.name;
689
+ const decl = plugin.config?.state;
690
+ if (!name || !decl?.context_always?.length) continue;
691
+ try {
692
+ const state = _pluginStateFor(name);
693
+ const stat = fs.existsSync(state.path) ? fs.statSync(state.path) : null;
694
+ const key = stat ? `${stat.size}:${Math.round(stat.mtimeMs)}` : 'missing';
695
+ const cached = _pluginStateCache.get(name);
696
+ if (cached && cached.key === key) {
697
+ out.push(cached.entry);
698
+ continue;
699
+ }
700
+ const summary = state.summary(decl.context_always);
701
+ const hasContent = Object.keys(summary.kv).length > 0
702
+ || Object.values(summary.streams).some(rows => rows.length > 0);
703
+ if (!hasContent) continue;
704
+ const entry = { plugin: name, ...summary };
705
+ _pluginStateCache.set(name, { key, entry });
706
+ out.push(entry);
707
+ } catch { /* one broken plugin must never break the session */ }
708
+ }
709
+ return out;
710
+ }
711
+
649
712
  /**
650
713
  * Register one MCP tool under `<serverName>.<toolName>` (namespaced
651
714
  * to prevent collisions between plugins that ship servers with the
@@ -706,6 +769,56 @@ export function createToolExecutor({
706
769
  const name = String(toolDef.name || '').trim();
707
770
  if (!name || toolMap[name]) continue;
708
771
  const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
772
+ if (toolDef._state_tool) {
773
+ // Manifest-declared state query tool (config.state.context_tools).
774
+ // There is no module to import — the author declared a table
775
+ // and an optional WHERE clause; the CLI supplies the handler.
776
+ // `readTable` refuses undeclared tables, so author-supplied
777
+ // SQL can't be steered into arbitrary table access.
778
+ const spec = toolDef._state_tool;
779
+ registerPluginTool(name, async (args) => {
780
+ try {
781
+ const state = _pluginStateFor(spec.plugin || pluginName);
782
+ if (!state) {
783
+ return {
784
+ success: false,
785
+ output: `Plugin state unavailable for '${name}'.`,
786
+ _tool: name,
787
+ _plugin: pluginName,
788
+ };
789
+ }
790
+ const bound = (spec.params || []).map(key => args?.[key] ?? null);
791
+ // An unfiltered call should list everything, not match
792
+ // nothing — `topic = NULL` is never true in SQL. Apply
793
+ // the WHERE only once a declared param was supplied.
794
+ const useWhere = Boolean(spec.where) && bound.some(v => v !== null);
795
+ const rows = state.readTable(spec.table, {
796
+ where: useWhere ? spec.where : '',
797
+ params: useWhere ? bound : [],
798
+ limit: args?.limit ?? spec.limit,
799
+ });
800
+ return {
801
+ success: true,
802
+ output: rows.length
803
+ ? JSON.stringify(rows, null, 2)
804
+ : `No rows in ${spec.table}${useWhere ? ' matching those filters' : ''}.`,
805
+ rows,
806
+ count: rows.length,
807
+ _tool: name,
808
+ _plugin: pluginName,
809
+ _state_tool: true,
810
+ };
811
+ } catch (err) {
812
+ return {
813
+ success: false,
814
+ output: `Plugin state tool error (${name}): ${err.message}`,
815
+ _tool: name,
816
+ _plugin: pluginName,
817
+ };
818
+ }
819
+ }, { pluginName, source: 'state', stateTool: spec });
820
+ continue;
821
+ }
709
822
  if (toolDef._composed?.kind === 'pi') {
710
823
  // Composed pi tools resolve at invocation time: look up the
711
824
  // installed pi package's directory, load the specific handler
@@ -2281,7 +2394,7 @@ export function createToolExecutor({
2281
2394
  const agents = filterLocalAgents(args).map(compactAgentMetadata);
2282
2395
  const payload = { agents, count: agents.length };
2283
2396
  if (agents.some(agent => agent.runnable === false)) {
2284
- payload.note = 'Agents with runnable:false are workspace-scoped plugin agents; add their slug to settings plugins.agent_allowlist to invoke them from the main loop.';
2397
+ payload.note = 'Agents with runnable:false are workspace-scoped plugin helpers; declare an entry_agent or add their slug to settings plugins.agent_allowlist to invoke them from the main loop.';
2285
2398
  }
2286
2399
  return {
2287
2400
  success: true,
@@ -2777,6 +2890,7 @@ export function createToolExecutor({
2777
2890
  getAgentContext() {
2778
2891
  const global = projectRegistry.getGlobalContext();
2779
2892
  const mem = _readMemorySnapshot();
2893
+ const pluginState = pluginStateContext();
2780
2894
  return {
2781
2895
  identity: global.identity,
2782
2896
  preferences: global.preferences,
@@ -2804,6 +2918,14 @@ export function createToolExecutor({
2804
2918
  spec: agent.spec,
2805
2919
  })),
2806
2920
  sub_agent_observability: agentRegistry.observability(),
2921
+ // Cross-session plugin state. Only plugins that opted in via
2922
+ // config.state.context_always appear here, and only the keys
2923
+ // and streams they named — this is how a plugin's local app
2924
+ // state survives a session boundary without the agent having
2925
+ // to know to go looking for it.
2926
+ ...(pluginState.length ? {
2927
+ plugin_state: pluginState,
2928
+ } : {}),
2807
2929
  // Background jobs the model should know about. Stable fields
2808
2930
  // only (no durations) so the entry — and the prompt cache —
2809
2931
  // changes on status transitions, not every turn.
@@ -640,10 +640,7 @@ function scanPlugins(session) {
640
640
  const dirs = new Map();
641
641
  try {
642
642
  const registry = new PluginRegistry({
643
- pluginDirs: [
644
- path.join(session.root_path || process.cwd(), '.bahulam', 'plugins'),
645
- path.join(os.homedir(), '.bahulam', 'plugins'),
646
- ],
643
+ pluginDirs: [path.join(os.homedir(), '.bahulam', 'plugins')],
647
644
  }).scan();
648
645
  for (const manifest of registry.list()) {
649
646
  const pluginName = manifest.metadata?.name || '';