@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 +1 -1
- package/src/agents/loader.mjs +1 -0
- package/src/agents/parser.mjs +1 -0
- package/src/agents/registry.mjs +5 -1
- package/src/commands/plugin-manage.mjs +31 -20
- package/src/commands/plugin.mjs +3 -6
- package/src/config/model-catalog-default.json +2 -2
- package/src/config/model-catalog.mjs +2 -0
- package/src/core/paths.mjs +24 -0
- package/src/core/stream-client.mjs +48 -12
- package/src/core/tool-executor.mjs +133 -11
- package/src/local-service/server.mjs +1 -4
- package/src/plugins/manifest.mjs +207 -1
- package/src/plugins/pi-compat/requirements.mjs +67 -1
- package/src/plugins/pi-compat/scaffold.mjs +209 -4
- package/src/plugins/preflight.mjs +28 -7
- package/src/plugins/registry.mjs +12 -9
- package/src/plugins/state-tools.mjs +86 -0
- package/src/plugins/state.mjs +168 -16
- package/src/tools/registry.mjs +7 -1
package/package.json
CHANGED
package/src/agents/loader.mjs
CHANGED
|
@@ -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,
|
package/src/agents/parser.mjs
CHANGED
|
@@ -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 || {},
|
package/src/agents/registry.mjs
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
77
|
-
|
|
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
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
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`);
|
package/src/commands/plugin.mjs
CHANGED
|
@@ -31,13 +31,10 @@ const YELLOW = '\x1b[33m';
|
|
|
31
31
|
const RED = '\x1b[31m';
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* The one and only directory to search for plugins.
|
|
35
35
|
*/
|
|
36
|
-
function pluginSearchDirs(
|
|
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,
|
package/src/core/paths.mjs
CHANGED
|
@@ -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
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
-
|
|
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
|
|
267
|
-
// plugins.agent_allowlist, or
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
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
|
-
|
|
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
|
|
644
|
-
const state = makePluginState(pluginName, {
|
|
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
|
|
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 || '';
|