@bahulam/code 0.1.11 → 0.1.13
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/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/install.mjs +295 -0
- package/src/commands/plugin-manage.mjs +280 -88
- package/src/config/cli-args.mjs +16 -0
- package/src/config/settings-loader.mjs +15 -0
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +54 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +95 -15
- package/src/core/tool-executor.mjs +266 -15
- package/src/local-service/agent-relay.mjs +1 -1
- package/src/local-service/server.mjs +116 -14
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- package/src/plugins/executor.mjs +2 -2
- package/src/plugins/manifest.mjs +30 -27
- package/src/plugins/pi-compat/loader-hook.mjs +45 -0
- package/src/plugins/pi-compat/probe.mjs +294 -0
- package/src/plugins/pi-compat/scaffold.mjs +487 -0
- package/src/plugins/pi-compat/shim.mjs +134 -0
- package/src/plugins/pi-compose.mjs +147 -0
- package/src/plugins/preflight.mjs +35 -10
- package/src/plugins/registry.mjs +6 -0
- package/src/terminal/agents.mjs +8 -3
- package/src/terminal/main.mjs +39 -7
- package/src/terminal/paste-input.mjs +23 -0
- package/src/terminal/repl-render.mjs +65 -10
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +624 -103
- package/src/tools/agent.mjs +6 -2
- package/src/tools/registry.mjs +107 -4
- package/src/ui/input-dock.mjs +5 -2
- package/src/ui/slash-commands.mjs +1 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi composition helpers.
|
|
3
|
+
*
|
|
4
|
+
* This is the contract layer for PRD-102 §13.6.1b. It deliberately does
|
|
5
|
+
* not install or execute pi packages yet; it gives manifest/preflight/
|
|
6
|
+
* registry code one normalized shape to test against.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PI_TOOLS_CACHE = '.bahulam-tools.json';
|
|
10
|
+
|
|
11
|
+
const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
12
|
+
const NAMESPACE_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
13
|
+
const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
|
|
14
|
+
|
|
15
|
+
export function parsePiSource(source) {
|
|
16
|
+
const raw = String(source || '').trim();
|
|
17
|
+
if (!raw.startsWith('pi:')) return null;
|
|
18
|
+
const spec = raw.slice(3).trim();
|
|
19
|
+
if (!spec) return null;
|
|
20
|
+
|
|
21
|
+
let packageName = spec;
|
|
22
|
+
let versionRange = '';
|
|
23
|
+
if (spec.startsWith('@')) {
|
|
24
|
+
const slash = spec.indexOf('/');
|
|
25
|
+
const versionAt = slash >= 0 ? spec.indexOf('@', slash + 1) : -1;
|
|
26
|
+
if (versionAt > 0) {
|
|
27
|
+
packageName = spec.slice(0, versionAt);
|
|
28
|
+
versionRange = spec.slice(versionAt + 1);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
const versionAt = spec.lastIndexOf('@');
|
|
32
|
+
if (versionAt > 0) {
|
|
33
|
+
packageName = spec.slice(0, versionAt);
|
|
34
|
+
versionRange = spec.slice(versionAt + 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!NPM_NAME_RE.test(packageName)) return null;
|
|
39
|
+
return {
|
|
40
|
+
kind: 'pi',
|
|
41
|
+
source: raw,
|
|
42
|
+
spec,
|
|
43
|
+
package_name: packageName,
|
|
44
|
+
packageName,
|
|
45
|
+
version_range: versionRange || null,
|
|
46
|
+
versionRange: versionRange || null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeCompose(composeDef, index = 0) {
|
|
51
|
+
const source = String(composeDef?.source || '').trim();
|
|
52
|
+
const parsed = parsePiSource(source);
|
|
53
|
+
const expose = Array.isArray(composeDef?.expose)
|
|
54
|
+
? composeDef.expose.map(item => String(item || '').trim()).filter(Boolean)
|
|
55
|
+
: [];
|
|
56
|
+
const namespace = String(composeDef?.as || '').trim();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
source,
|
|
60
|
+
as: namespace || '',
|
|
61
|
+
expose,
|
|
62
|
+
verified: composeDef?.verified === true,
|
|
63
|
+
package_name: parsed?.package_name || '',
|
|
64
|
+
packageName: parsed?.packageName || '',
|
|
65
|
+
version_range: parsed?.version_range || null,
|
|
66
|
+
versionRange: parsed?.versionRange || null,
|
|
67
|
+
_index: index,
|
|
68
|
+
_kind: 'pi',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeComposes(value) {
|
|
73
|
+
if (!Array.isArray(value)) return [];
|
|
74
|
+
return value
|
|
75
|
+
.map((item, index) => normalizeCompose(item, index))
|
|
76
|
+
.filter(item => item.source || item.expose.length || item.as);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Anthropic's tool-name regex (`^[a-zA-Z0-9_-]{1,64}$`) forbids dots, and
|
|
80
|
+
// the backend's client-tool sanitizer enforces the same shape. `__` is the
|
|
81
|
+
// convention Claude Code and MCP both use for namespaced tool names, so
|
|
82
|
+
// stay compatible: `namespace__tool`.
|
|
83
|
+
export const COMPOSED_TOOL_SEPARATOR = '__';
|
|
84
|
+
|
|
85
|
+
export function composedToolName(compose, exposedName) {
|
|
86
|
+
const name = String(exposedName || '').trim();
|
|
87
|
+
return compose?.as ? `${compose.as}${COMPOSED_TOOL_SEPARATOR}${name}` : name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validateCompose(compose) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
const warnings = [];
|
|
93
|
+
const label = `Compose #${Number.isInteger(compose?._index) ? compose._index : '?'}`;
|
|
94
|
+
|
|
95
|
+
if (!parsePiSource(compose?.source)) {
|
|
96
|
+
errors.push(`${label}: source must be a pi npm spec, for example pi:@scope/package@^1.0.0`);
|
|
97
|
+
}
|
|
98
|
+
if (compose?.as && !NAMESPACE_RE.test(compose.as)) {
|
|
99
|
+
errors.push(`${label}: as "${compose.as}" must match ${NAMESPACE_RE}`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(compose?.expose) || compose.expose.length === 0) {
|
|
102
|
+
errors.push(`${label}: expose must list at least one pi tool`);
|
|
103
|
+
} else {
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const exposed of compose.expose) {
|
|
106
|
+
if (!TOOL_NAME_RE.test(exposed)) {
|
|
107
|
+
errors.push(`${label}: expose "${exposed}" must match ${TOOL_NAME_RE}`);
|
|
108
|
+
}
|
|
109
|
+
if (seen.has(exposed)) {
|
|
110
|
+
errors.push(`${label}: duplicate exposed tool "${exposed}"`);
|
|
111
|
+
}
|
|
112
|
+
seen.add(exposed);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (compose?.verified !== true) {
|
|
116
|
+
warnings.push(`${label}: ${compose?.source || 'pi package'} is unverified; hosted Studios require verified pi packages`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { errors, warnings };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function expandComposedTools(pluginName, pluginDir, composes = []) {
|
|
123
|
+
const tools = [];
|
|
124
|
+
for (const compose of composes || []) {
|
|
125
|
+
for (const exposedName of compose.expose || []) {
|
|
126
|
+
tools.push({
|
|
127
|
+
name: composedToolName(compose, exposedName),
|
|
128
|
+
description: `Composed pi tool ${exposedName} from ${compose.source}`,
|
|
129
|
+
input_schema: { type: 'object', properties: {} },
|
|
130
|
+
tool: '',
|
|
131
|
+
plugin_name: pluginName,
|
|
132
|
+
_plugin_name: pluginName,
|
|
133
|
+
_plugin_dir: pluginDir,
|
|
134
|
+
_composed: {
|
|
135
|
+
kind: 'pi',
|
|
136
|
+
source: compose.source,
|
|
137
|
+
package_name: compose.package_name,
|
|
138
|
+
version_range: compose.version_range,
|
|
139
|
+
namespace: compose.as || null,
|
|
140
|
+
original_name: exposedName,
|
|
141
|
+
verified: compose.verified === true,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return tools;
|
|
147
|
+
}
|
|
@@ -22,6 +22,7 @@ import * as os from 'node:os';
|
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
24
|
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
25
|
+
import { composedToolName, validateCompose } from './pi-compose.mjs';
|
|
25
26
|
|
|
26
27
|
const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
27
28
|
const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
@@ -86,6 +87,7 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
86
87
|
const views = manifest.spec?.workspace?.views || [];
|
|
87
88
|
const mcpServers = manifest.spec?.mcpServers || {};
|
|
88
89
|
const mcpServerNames = new Set(Object.keys(mcpServers));
|
|
90
|
+
const composes = manifest.spec?.composes || [];
|
|
89
91
|
|
|
90
92
|
// MCP server sanity — every server should have EITHER command (stdio)
|
|
91
93
|
// OR url (remote). Anything else is meaningless config.
|
|
@@ -101,6 +103,26 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
// Pi composition sanity. Composed tools become part of the agent-visible
|
|
107
|
+
// tool namespace, but they are not local files and are not imported here.
|
|
108
|
+
const composedToolNames = new Set();
|
|
109
|
+
for (const compose of composes) {
|
|
110
|
+
const validated = validateCompose(compose);
|
|
111
|
+
errors.push(...validated.errors);
|
|
112
|
+
warnings.push(...validated.warnings);
|
|
113
|
+
if (compose.as && mcpServerNames.has(compose.as)) {
|
|
114
|
+
errors.push(`Compose #${compose._index}: namespace "${compose.as}" collides with an MCP server name`);
|
|
115
|
+
}
|
|
116
|
+
for (const exposedName of compose.expose || []) {
|
|
117
|
+
const fullName = composedToolName(compose, exposedName);
|
|
118
|
+
if (composedToolNames.has(fullName)) errors.push(`Composed tool "${fullName}": duplicate name`);
|
|
119
|
+
if (RESERVED_TOOL_NAMES.has(fullName)) {
|
|
120
|
+
errors.push(`Composed tool "${fullName}": shadows a built-in tool`);
|
|
121
|
+
}
|
|
122
|
+
composedToolNames.add(fullName);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
104
126
|
// 2 + 3 + 4. Tool checks
|
|
105
127
|
const toolNames = new Set();
|
|
106
128
|
for (const [i, tool] of tools.entries()) {
|
|
@@ -114,26 +136,29 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
114
136
|
if (RESERVED_TOOL_NAMES.has(tool.name)) {
|
|
115
137
|
errors.push(`Tool "${t}": shadows a built-in tool — pick a different name (built-ins always win)`);
|
|
116
138
|
}
|
|
139
|
+
if (composedToolNames.has(tool.name)) {
|
|
140
|
+
errors.push(`Tool "${t}": collides with a composed pi tool`);
|
|
141
|
+
}
|
|
117
142
|
if (!tool.description || tool.description.length < 8) {
|
|
118
143
|
warnings.push(`Tool "${t}": description is missing or very short (<8 chars) — the model uses this to decide when to call it`);
|
|
119
144
|
}
|
|
120
|
-
if (!tool.
|
|
145
|
+
if (!tool.tool) { errors.push(`Tool "${t}": missing tool module path (tool: ./tools/<name>.mjs)`); continue; }
|
|
121
146
|
|
|
122
|
-
const
|
|
147
|
+
const toolModulePath = path.resolve(pluginDir, tool.tool);
|
|
123
148
|
// Traversal guard
|
|
124
|
-
const inside =
|
|
125
|
-
if (!inside) errors.push(`Tool "${t}":
|
|
126
|
-
else if (!fs.existsSync(
|
|
149
|
+
const inside = toolModulePath === pluginDir || toolModulePath.startsWith(pluginDir + path.sep);
|
|
150
|
+
if (!inside) errors.push(`Tool "${t}": tool module path escapes the plugin directory`);
|
|
151
|
+
else if (!fs.existsSync(toolModulePath)) errors.push(`Tool "${t}": tool module not found: ${tool.tool}`);
|
|
127
152
|
else {
|
|
128
153
|
try {
|
|
129
154
|
// Cache-bust because a previous install may have imported an older
|
|
130
155
|
// copy at the same path in this process.
|
|
131
|
-
const mod = await import(`${pathToFileURL(
|
|
156
|
+
const mod = await import(`${pathToFileURL(toolModulePath).href}?preflight=${Date.now()}`);
|
|
132
157
|
if (typeof mod.call !== 'function') {
|
|
133
|
-
errors.push(`Tool "${t}":
|
|
158
|
+
errors.push(`Tool "${t}": tool module ${tool.tool} does not export an async \`call\` function`);
|
|
134
159
|
}
|
|
135
160
|
} catch (err) {
|
|
136
|
-
errors.push(`Tool "${t}":
|
|
161
|
+
errors.push(`Tool "${t}": tool module ${tool.tool} failed to import: ${err.message}`);
|
|
137
162
|
}
|
|
138
163
|
}
|
|
139
164
|
|
|
@@ -166,12 +191,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
166
191
|
// plugin's mcpServers. The <tool> half is discovered live.
|
|
167
192
|
if (toolRef.includes('.')) {
|
|
168
193
|
const serverName = toolRef.split('.', 1)[0];
|
|
169
|
-
if (!mcpServerNames.has(serverName)) {
|
|
194
|
+
if (!mcpServerNames.has(serverName) && !composedToolNames.has(toolRef)) {
|
|
170
195
|
errors.push(`Agent "${slug}": tool "${toolRef}" references MCP server "${serverName}" which is not declared in mcpServers`);
|
|
171
196
|
}
|
|
172
197
|
continue;
|
|
173
198
|
}
|
|
174
|
-
if (!toolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
|
|
199
|
+
if (!toolNames.has(toolRef) && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
|
|
175
200
|
errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
|
|
176
201
|
}
|
|
177
202
|
}
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
12
|
+
import { expandComposedTools } from './pi-compose.mjs';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_PLUGIN_DIRS = () => [
|
|
14
15
|
path.join(process.cwd(), '.bahulam', 'plugins'),
|
|
@@ -159,6 +160,11 @@ export class PluginRegistry {
|
|
|
159
160
|
_plugin_dir: plugin._dir,
|
|
160
161
|
});
|
|
161
162
|
}
|
|
163
|
+
tools.push(...expandComposedTools(
|
|
164
|
+
plugin.metadata?.name || '',
|
|
165
|
+
plugin._dir,
|
|
166
|
+
plugin.spec?.composes || [],
|
|
167
|
+
));
|
|
162
168
|
}
|
|
163
169
|
return tools;
|
|
164
170
|
}
|
package/src/terminal/agents.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const TOOL_ALIASES = new Map([
|
|
|
113
113
|
['grep', 'search_code'],
|
|
114
114
|
]);
|
|
115
115
|
|
|
116
|
-
function canonicalToolName(value) {
|
|
116
|
+
export function canonicalToolName(value) {
|
|
117
117
|
const key = String(value || '').trim().toLowerCase();
|
|
118
118
|
return TOOL_ALIASES.get(key) || key;
|
|
119
119
|
}
|
|
@@ -133,7 +133,7 @@ function normalizeScopedArgs(toolName, args = {}, { projectRoot = null } = {}) {
|
|
|
133
133
|
return next;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
|
|
136
|
+
export function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
|
|
137
137
|
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
|
138
138
|
const allowed = new Set(tools.map(canonicalToolName).filter(Boolean));
|
|
139
139
|
if (!allowed.size) return baseExecutor;
|
|
@@ -152,7 +152,11 @@ function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } =
|
|
|
152
152
|
baseExecutor,
|
|
153
153
|
toolName,
|
|
154
154
|
normalizeScopedArgs(toolName, args, { projectRoot }),
|
|
155
|
-
|
|
155
|
+
{
|
|
156
|
+
...options,
|
|
157
|
+
internal: true,
|
|
158
|
+
subAgent: agent.slug || agent.command || agent.name || true,
|
|
159
|
+
},
|
|
156
160
|
);
|
|
157
161
|
},
|
|
158
162
|
};
|
|
@@ -317,6 +321,7 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
|
|
|
317
321
|
token: creds.token,
|
|
318
322
|
toolExecutor,
|
|
319
323
|
approvalManager: agentApproval,
|
|
324
|
+
pluginRegistry: options.pluginRegistry || ctx.pluginRegistry || null,
|
|
320
325
|
});
|
|
321
326
|
|
|
322
327
|
session.turns++;
|
package/src/terminal/main.mjs
CHANGED
|
@@ -24,7 +24,7 @@ const subcommand = process.argv[2];
|
|
|
24
24
|
const subcommandArgs = process.argv.slice(3);
|
|
25
25
|
|
|
26
26
|
const PLUGIN_MANAGEMENT_COMMANDS = new Set([
|
|
27
|
-
'
|
|
27
|
+
'validate', 'check', 'lint',
|
|
28
28
|
'list', 'ls', 'remove', 'rm', 'uninstall',
|
|
29
29
|
'enable', 'disable', 'info', 'update', 'upgrade',
|
|
30
30
|
]);
|
|
@@ -63,8 +63,7 @@ function parsePluginArgs(argv) {
|
|
|
63
63
|
}
|
|
64
64
|
if (positional.length && PLUGIN_MANAGEMENT_COMMANDS.has(positional[0].toLowerCase())) {
|
|
65
65
|
parsed.action = positional.shift().toLowerCase();
|
|
66
|
-
if (
|
|
67
|
-
else if (['validate', 'check', 'lint'].includes(parsed.action)) {
|
|
66
|
+
if (['validate', 'check', 'lint'].includes(parsed.action)) {
|
|
68
67
|
// Accepts either a directory path or an installed plugin name.
|
|
69
68
|
const arg = positional.shift() || null;
|
|
70
69
|
if (arg && (arg.includes('/') || fs.existsSync?.(arg))) parsed.source = arg;
|
|
@@ -279,7 +278,28 @@ async function main() {
|
|
|
279
278
|
return;
|
|
280
279
|
}
|
|
281
280
|
|
|
281
|
+
if (subcommand === 'pull') {
|
|
282
|
+
const { handlePullCommand } = await import('../commands/install.mjs');
|
|
283
|
+
await handlePullCommand(subcommandArgs, { cwd: process.cwd() });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (subcommand === 'install') {
|
|
288
|
+
const { handleInstallCommand } = await import('../commands/install.mjs');
|
|
289
|
+
await handleInstallCommand(subcommandArgs, { cwd: process.cwd() });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
282
293
|
if (subcommand === 'plugin' || subcommand === 'plugins') {
|
|
294
|
+
// `install`/`pull` moved to top-level. Detect the old form and redirect.
|
|
295
|
+
if (subcommandArgs[0] === 'install' || subcommandArgs[0] === 'pull') {
|
|
296
|
+
const verb = subcommandArgs[0];
|
|
297
|
+
const rest = subcommandArgs.slice(1);
|
|
298
|
+
process.stderr.write(`\x1b[33m!\x1b[0m \`bahulam plugin ${verb}\` moved to top-level. Use:\n`);
|
|
299
|
+
process.stderr.write(` \x1b[36mbahulam install ${rest.join(' ')}\x1b[0m (pack — scaffolds around pi:, installs git/tarball/local)\n`);
|
|
300
|
+
process.stderr.write(` \x1b[36mbahulam pull ${rest.join(' ')}\x1b[0m (ingredient only — pi: sources)\n`);
|
|
301
|
+
process.exit(2);
|
|
302
|
+
}
|
|
283
303
|
const args = parsePluginArgs(subcommandArgs);
|
|
284
304
|
if (args.action && args.action !== 'open') {
|
|
285
305
|
const { handlePluginManagementCommand } = await import('../commands/plugin-manage.mjs');
|
|
@@ -306,6 +326,8 @@ async function main() {
|
|
|
306
326
|
\x1b[1mUsage:\x1b[0m
|
|
307
327
|
bahulam Start interactive REPL
|
|
308
328
|
bahulam "instruction" Run a single instruction
|
|
329
|
+
bahulam --agent <slug> -p "x" Run a named agent (local deterministic graph)
|
|
330
|
+
bahulam --workflow <name> -p Run a named workflow (local deterministic graph)
|
|
309
331
|
bahulam --headless -p "x" Non-interactive: auto-approve, JSONL output
|
|
310
332
|
bahulam --headless -p "x" --vision screenshot.png
|
|
311
333
|
Attach an image via the vision analysis pipeline
|
|
@@ -330,8 +352,14 @@ async function main() {
|
|
|
330
352
|
bahulam workspace list List recent local workspace sessions
|
|
331
353
|
bahulam local open [path] Alias for workspace open
|
|
332
354
|
|
|
333
|
-
\x1b[
|
|
334
|
-
bahulam
|
|
355
|
+
\x1b[1mPacks & ingredients:\x1b[0m
|
|
356
|
+
bahulam pull pi:<name> Pull a pi ingredient (composable, not runnable on its own)
|
|
357
|
+
bahulam install pi:<name> Pull ingredient + scaffold a full Bahulam pack around it
|
|
358
|
+
bahulam install <git-url> Install a hand-authored pack from git
|
|
359
|
+
bahulam install <local-path> Install a hand-authored pack from disk
|
|
360
|
+
bahulam plugin list List installed packs and pi ingredients
|
|
361
|
+
bahulam plugin remove <name> Remove an installed pack
|
|
362
|
+
bahulam plugin <name> [path] Open a workspace with an installed pack
|
|
335
363
|
|
|
336
364
|
\x1b[1mAnalytics:\x1b[0m
|
|
337
365
|
bahulam sessions List recent local sessions
|
|
@@ -360,7 +388,7 @@ async function main() {
|
|
|
360
388
|
/architect <query> Spawn architecture planning agent
|
|
361
389
|
/agents create <name> Create project-local user-defined agent YAML
|
|
362
390
|
/agents edit <name> Open a local agent YAML in your editor
|
|
363
|
-
/agents sync [name]
|
|
391
|
+
/agents sync [name] Optionally publish local agents to backend/account
|
|
364
392
|
/attach <image-path> Attach an image to next prompt
|
|
365
393
|
/attach clipboard Attach image copied to macOS/Windows clipboard
|
|
366
394
|
/exit Exit the REPL
|
|
@@ -480,7 +508,9 @@ async function main() {
|
|
|
480
508
|
const daemonSpawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
|
|
481
509
|
const daemonPrompt = daemonSpawned ? (process.env.BAHULAM_DAEMON_INITIAL_PROMPT || '').trim() : '';
|
|
482
510
|
const effectivePrompt = args.prompt || (daemonSpawned && daemonPrompt) || '';
|
|
483
|
-
|
|
511
|
+
const hasGraphTarget = Boolean(args.agent || args.workflow);
|
|
512
|
+
if ((effectivePrompt || hasGraphTarget)
|
|
513
|
+
&& (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY || hasGraphTarget)) {
|
|
484
514
|
const { runHeadless } = await import('../core/headless.mjs');
|
|
485
515
|
await runHeadless({
|
|
486
516
|
instruction: effectivePrompt,
|
|
@@ -490,6 +520,8 @@ async function main() {
|
|
|
490
520
|
cacheReport: args.cacheReport,
|
|
491
521
|
local: args.local,
|
|
492
522
|
vision: args.vision,
|
|
523
|
+
agent: args.agent,
|
|
524
|
+
workflow: args.workflow,
|
|
493
525
|
});
|
|
494
526
|
return;
|
|
495
527
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function isRawMultilinePasteChunk(text) {
|
|
2
|
+
const value = String(text || '');
|
|
3
|
+
if (!value) return false;
|
|
4
|
+
if (!/[\r\n]/.test(value)) return false;
|
|
5
|
+
|
|
6
|
+
const withoutLineBreaks = value.replace(/[\r\n]/g, '');
|
|
7
|
+
if (!withoutLineBreaks.length) return false;
|
|
8
|
+
|
|
9
|
+
// A single printable char followed by Enter can be delivered in one chunk
|
|
10
|
+
// by some terminals; keep that as normal line submission.
|
|
11
|
+
return withoutLineBreaks.length > 1 || value.split(/\r?\n|\r/).length > 2;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizePastedText(text) {
|
|
15
|
+
return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pastedTextLabel(text) {
|
|
19
|
+
const value = normalizePastedText(text);
|
|
20
|
+
const lines = value ? value.split('\n').length : 0;
|
|
21
|
+
if (lines > 1) return `[text copied · ${lines} lines]`;
|
|
22
|
+
return '[text copied]';
|
|
23
|
+
}
|
|
@@ -34,8 +34,8 @@ import * as queue from '../ui/render-queue.mjs';
|
|
|
34
34
|
// queue active) → coalesced last-wins status that can never interleave
|
|
35
35
|
// with content. Legacy dock path and bare-TTY inPlace stay as fallbacks
|
|
36
36
|
// until their write-sites migrate onto the queue too.
|
|
37
|
-
// Max
|
|
38
|
-
const SUB_AGENT_WINDOW_ROWS =
|
|
37
|
+
// Max live-window lines under the spinner during a sub-agent run.
|
|
38
|
+
const SUB_AGENT_WINDOW_ROWS = 8;
|
|
39
39
|
|
|
40
40
|
function statusWidth() {
|
|
41
41
|
return Math.max(8, (process.stderr.columns || process.stdout.columns || 120) - 1);
|
|
@@ -49,6 +49,18 @@ function fitStatusLines(lines) {
|
|
|
49
49
|
return (Array.isArray(lines) ? lines : [lines]).map(fitStatusLine);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function subAgentWindowLines(win) {
|
|
53
|
+
if (win?.groups instanceof Map && win.groups.size) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
for (const group of win.groups.values()) {
|
|
56
|
+
if (group?.header) lines.push(group.header);
|
|
57
|
+
for (const line of group?.lines || []) lines.push(line);
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
return (win?.lines || []).slice(-SUB_AGENT_WINDOW_ROWS);
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
function presentStatus(rendered) {
|
|
53
65
|
// Watch panel override: when active, render the watch panel entries as a
|
|
54
66
|
// multi-line status block instead of the normal spinner/status line.
|
|
@@ -69,10 +81,11 @@ function presentStatus(rendered) {
|
|
|
69
81
|
const fitted = fitStatusLine(rendered);
|
|
70
82
|
if (queue.isActive()) {
|
|
71
83
|
const win = runtime.subAgentWindow;
|
|
72
|
-
|
|
84
|
+
const subAgentLines = subAgentWindowLines(win);
|
|
85
|
+
if (win?.active && subAgentLines.length) {
|
|
73
86
|
queue.statusBlock([
|
|
74
87
|
fitted,
|
|
75
|
-
...fitStatusLines(
|
|
88
|
+
...fitStatusLines(subAgentLines.map(l => ` ${c.dim(l)}`)),
|
|
76
89
|
]);
|
|
77
90
|
return;
|
|
78
91
|
}
|
|
@@ -95,7 +108,7 @@ export function pushSubAgentWindowLine(line) {
|
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
export function setSubAgentWindowActive(active) {
|
|
98
|
-
runtime.subAgentWindow = { active: Boolean(active), lines: [] };
|
|
111
|
+
runtime.subAgentWindow = { active: Boolean(active), lines: [], groups: new Map() };
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
/**
|
|
@@ -109,6 +122,25 @@ export function rebuildSubAgentWindow(lines) {
|
|
|
109
122
|
const win = runtime.subAgentWindow;
|
|
110
123
|
if (!win?.active) return;
|
|
111
124
|
win.lines = Array.isArray(lines) ? lines.slice() : [];
|
|
125
|
+
win.groups = new Map();
|
|
126
|
+
repaintSpinnerStatus();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function rebuildSubAgentWindowGroups(groups) {
|
|
130
|
+
const win = runtime.subAgentWindow;
|
|
131
|
+
if (!win?.active) return;
|
|
132
|
+
win.groups = new Map();
|
|
133
|
+
win.lines = [];
|
|
134
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
135
|
+
const key = group?.runId || group?.key || group?.label;
|
|
136
|
+
if (!key) continue;
|
|
137
|
+
win.groups.set(key, {
|
|
138
|
+
runId: group.runId || null,
|
|
139
|
+
label: group.label || 'sub-agent',
|
|
140
|
+
header: group.header || group.label || 'sub-agent',
|
|
141
|
+
lines: Array.isArray(group.lines) ? group.lines.slice() : [],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
112
144
|
repaintSpinnerStatus();
|
|
113
145
|
}
|
|
114
146
|
|
|
@@ -308,11 +340,27 @@ export function renderToolCall(data) {
|
|
|
308
340
|
const args = data?.args || {};
|
|
309
341
|
const indent = subAgentIndent();
|
|
310
342
|
const callId = data?.call_id || data?._callId || `${tool}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
343
|
+
const fromSubAgent = Boolean(data?.internal || data?.sub_agent || data?.sub_agent_label || data?.sub_agent_run_id);
|
|
311
344
|
|
|
312
345
|
// If a previous head is still pending (no result yet), flush it as a
|
|
313
346
|
// regular two-line shape before starting the next one.
|
|
314
347
|
flushPendingHead();
|
|
315
348
|
|
|
349
|
+
// Sub-agent live window (queue mode): inner tool calls stream into the
|
|
350
|
+
// fixed-height status block instead of appending transcript lines. Keep
|
|
351
|
+
// this ahead of explore-collapse so parallel explores do not merge into
|
|
352
|
+
// one global read/search spinner.
|
|
353
|
+
if (fromSubAgent && queue.isActive() && runtime.subAgentWindow?.active) {
|
|
354
|
+
recordCard({ id: callId, tool, args, startedAt: Date.now() });
|
|
355
|
+
session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
|
|
356
|
+
const label = readToolLabel(tool, { args });
|
|
357
|
+
const runId = data?.run_id || data?.sub_agent_run_id || '';
|
|
358
|
+
const runSuffix = runId ? ` run ${String(runId).slice(0, 8)}` : '';
|
|
359
|
+
const lane = data?.sub_agent_label ? `[${data.sub_agent_label}${runSuffix}] ` : '';
|
|
360
|
+
pushSubAgentWindowLine(label ? `${lane}→ ${tool} · ${label}` : `${lane}→ ${tool}`);
|
|
361
|
+
return; // the spinner tick paints the window block
|
|
362
|
+
}
|
|
363
|
+
|
|
316
364
|
// ── Explore-run collapse ────────────────────────────────────────────────
|
|
317
365
|
// For list/read/search/index tools, skip the per-call head entirely and
|
|
318
366
|
// update a single animated summary spinner. The transcript stays clean;
|
|
@@ -328,9 +376,8 @@ export function renderToolCall(data) {
|
|
|
328
376
|
return;
|
|
329
377
|
}
|
|
330
378
|
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// card is still recorded so /expand, /last, and `d` show full detail.
|
|
379
|
+
// Legacy sub-agent live window fallback for events without explicit
|
|
380
|
+
// sub-agent metadata.
|
|
334
381
|
if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
|
|
335
382
|
recordCard({ id: callId, tool, args, startedAt: Date.now() });
|
|
336
383
|
session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
|
|
@@ -410,6 +457,14 @@ export function renderToolResult(data, eventType = 'tool_result') {
|
|
|
410
457
|
indent: gutter,
|
|
411
458
|
columns: process.stderr.columns || 120,
|
|
412
459
|
});
|
|
460
|
+
const fromSubAgent = Boolean(data?.internal || data?.sub_agent || data?.sub_agent_label || data?.sub_agent_run_id);
|
|
461
|
+
|
|
462
|
+
// Sub-agent live window: the call line is already streaming in the
|
|
463
|
+
// status block; the result stays card-only (close card summarizes). This
|
|
464
|
+
// must run before explore-collapse for read/search tools used by explores.
|
|
465
|
+
if (fromSubAgent && queue.isActive() && runtime.subAgentWindow?.active) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
413
468
|
|
|
414
469
|
// Explore tools: the call already updated the summary spinner. Refresh
|
|
415
470
|
// the "latest" hint with the result's file if we have one, and skip the
|
|
@@ -421,8 +476,8 @@ export function renderToolResult(data, eventType = 'tool_result') {
|
|
|
421
476
|
return;
|
|
422
477
|
}
|
|
423
478
|
|
|
424
|
-
//
|
|
425
|
-
//
|
|
479
|
+
// Legacy sub-agent live window fallback for events without explicit
|
|
480
|
+
// sub-agent metadata.
|
|
426
481
|
if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
|
|
427
482
|
return;
|
|
428
483
|
}
|
|
@@ -43,7 +43,8 @@ export const runtime = {
|
|
|
43
43
|
|
|
44
44
|
// Explore-run collapse (read/list/search/index bursts as concise progress).
|
|
45
45
|
exploreRun: { counts: {}, recent: [], lineActive: false, lastPrintedSummary: '', lastPrintedTotal: 0, lastPrintedAt: 0 },
|
|
46
|
-
foldedSubAgentTools: null, //
|
|
46
|
+
foldedSubAgentTools: null, // current folded sub-agent tool bucket
|
|
47
|
+
foldedSubAgentToolMap: new Map(), // run-aware buckets for parallel sub-agents
|
|
47
48
|
|
|
48
49
|
// Animated spinner state (single shared interval; text/frame drive inPlace).
|
|
49
50
|
spinInterval: null,
|
|
@@ -61,7 +62,7 @@ export const runtime = {
|
|
|
61
62
|
// its inner tool calls stream into a fixed-height status block (last
|
|
62
63
|
// N lines under the spinner) instead of appending to the transcript.
|
|
63
64
|
// Full detail stays on the recorded cards (/expand, /last, `d`).
|
|
64
|
-
subAgentWindow: { active: false, lines: [] },
|
|
65
|
+
subAgentWindow: { active: false, lines: [], groups: new Map() },
|
|
65
66
|
|
|
66
67
|
// PRD-092: Watch panel — toggled by /watch. When active, the spinner
|
|
67
68
|
// area renders a compact agent-activity summary instead of the spinner.
|
|
@@ -106,6 +107,7 @@ export const session = {
|
|
|
106
107
|
lastTurnDuration: 0,
|
|
107
108
|
toolCounts: {}, // per-tool histogram (mission report)
|
|
108
109
|
subAgentCounts: {}, // per-sub-agent histogram (mission report)
|
|
110
|
+
activeSubAgentRuns: new Map(), // run_id -> active sub-agent lane
|
|
109
111
|
savedUsd: 0, // total sub-agent cost (for "saved by routing")
|
|
110
112
|
lastTask: '', // most recent user prompt (mission report title)
|
|
111
113
|
lastReasoning: '', // captured from agent for /why
|