@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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe-load a pi package in an isolated child process to discover what
|
|
3
|
+
* tools/commands it registers. Result is cached to <plugin-dir>/.bahulam-tools.json
|
|
4
|
+
* so subsequent invocations skip the probe.
|
|
5
|
+
*
|
|
6
|
+
* Isolation matters: pi extensions can throw at load, do side effects,
|
|
7
|
+
* or use require-in-ESM. We don't want any of that leaking into the CLI
|
|
8
|
+
* process. The child does the load, prints the capture JSON, exits.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
|
+
import { PI_TOOLS_CACHE } from '../pi-compose.mjs';
|
|
16
|
+
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const LOADER_HOOK = path.join(HERE, 'loader-hook.mjs');
|
|
19
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the pi package's entry point. Prefer `package.json.main`, fall
|
|
23
|
+
* back to conventional locations. A pi package may set `pi.main` — we
|
|
24
|
+
* respect that first.
|
|
25
|
+
*/
|
|
26
|
+
function resolvePiEntry(pluginDir) {
|
|
27
|
+
const pkgPath = path.join(pluginDir, 'package.json');
|
|
28
|
+
if (!fs.existsSync(pkgPath)) {
|
|
29
|
+
throw new Error(`Pi package missing package.json: ${pluginDir}`);
|
|
30
|
+
}
|
|
31
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
32
|
+
// Pi's real convention (per pi-web-access and others): `pkg.pi.extensions`
|
|
33
|
+
// is an array of files OR directories. A directory means "scan for
|
|
34
|
+
// *.ts/.js/.mjs entries." A file is loaded directly.
|
|
35
|
+
const piExts = Array.isArray(pkg.pi?.extensions) ? pkg.pi.extensions : [];
|
|
36
|
+
const expandedExts = [];
|
|
37
|
+
for (const rel of piExts) {
|
|
38
|
+
const abs = path.resolve(pluginDir, rel);
|
|
39
|
+
if (!fs.existsSync(abs)) continue;
|
|
40
|
+
const stat = fs.statSync(abs);
|
|
41
|
+
if (stat.isFile()) { expandedExts.push(abs); continue; }
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
for (const entry of fs.readdirSync(abs)) {
|
|
44
|
+
if (/\.(ts|mjs|js|cjs)$/.test(entry)) expandedExts.push(path.join(abs, entry));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (expandedExts.length) return expandedExts;
|
|
49
|
+
|
|
50
|
+
// Fallback: main / exports / conventional entries
|
|
51
|
+
const candidates = [
|
|
52
|
+
pkg.pi?.main,
|
|
53
|
+
pkg.exports?.['.']?.import,
|
|
54
|
+
pkg.exports?.['.']?.default,
|
|
55
|
+
pkg.main,
|
|
56
|
+
'index.mjs', 'index.js', 'dist/index.mjs', 'dist/index.js',
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
for (const rel of candidates) {
|
|
59
|
+
const abs = path.resolve(pluginDir, rel);
|
|
60
|
+
if (fs.existsSync(abs)) return [abs];
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Pi package has no discoverable entry point (tried ${candidates.join(', ')}): ${pluginDir}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the pi package in a child process, capture registered tools/commands.
|
|
67
|
+
* Returns { tools: [{name, schema}], commands: [{cmd}] } — handler
|
|
68
|
+
* functions are intentionally NOT included (they don't serialize).
|
|
69
|
+
* Handlers get re-imported per-invocation at execution time (Step 6).
|
|
70
|
+
*/
|
|
71
|
+
export async function probePiExtension(pluginDir, { pluginName } = {}) {
|
|
72
|
+
const entries = resolvePiEntry(pluginDir);
|
|
73
|
+
const entryUrls = entries.map(e => pathToFileURL(e).href);
|
|
74
|
+
const name = pluginName || path.basename(pluginDir);
|
|
75
|
+
|
|
76
|
+
// Pi's canonical extension pattern: `export default function (pi) { pi.registerTool(...) }`.
|
|
77
|
+
// Some packages split extensions across multiple files (pkg.pi.extensions
|
|
78
|
+
// as an array or a directory). We import each in order, invoke its
|
|
79
|
+
// default export with our shim, aggregate the captures.
|
|
80
|
+
const script = `
|
|
81
|
+
globalThis.__bahulam_pi_captured = { tools: [], commands: [] };
|
|
82
|
+
(async () => {
|
|
83
|
+
const entries = ${JSON.stringify(entryUrls)};
|
|
84
|
+
const { pi } = await import('pi');
|
|
85
|
+
const warnings = [];
|
|
86
|
+
for (const url of entries) {
|
|
87
|
+
try {
|
|
88
|
+
const mod = await import(url);
|
|
89
|
+
const activate = typeof mod?.default === 'function' ? mod.default
|
|
90
|
+
: typeof mod?.activate === 'function' ? mod.activate
|
|
91
|
+
: null;
|
|
92
|
+
if (activate) {
|
|
93
|
+
try { await activate(pi); }
|
|
94
|
+
catch (err) { warnings.push({ entry: url, message: String(err && err.message || err) }); }
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
warnings.push({ entry: url, message: String(err && err.message || err) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const out = {
|
|
101
|
+
tools: globalThis.__bahulam_pi_captured.tools.map(t => ({
|
|
102
|
+
name: t.name,
|
|
103
|
+
description: t.description || (t.schema && t.schema.description) || '',
|
|
104
|
+
input_schema: t.schema && typeof t.schema === 'object'
|
|
105
|
+
? (t.schema.parameters || t.schema.input_schema || t.schema)
|
|
106
|
+
: { type: 'object', properties: {} },
|
|
107
|
+
})),
|
|
108
|
+
commands: globalThis.__bahulam_pi_captured.commands.map(c => ({ command: c.cmd, description: c.description || '' })),
|
|
109
|
+
warnings,
|
|
110
|
+
};
|
|
111
|
+
process.stdout.write(JSON.stringify(out));
|
|
112
|
+
})().catch(err => {
|
|
113
|
+
process.stderr.write(JSON.stringify({ probe_error: String(err && err.message || err) }));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
});
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
return await new Promise((resolve, reject) => {
|
|
119
|
+
const child = spawn(process.execPath, ['--import', LOADER_HOOK, '-e', script], {
|
|
120
|
+
env: { ...process.env, BAHULAM_PI_PLUGIN: name },
|
|
121
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
122
|
+
});
|
|
123
|
+
let stdout = '', stderr = '';
|
|
124
|
+
child.stdout.on('data', d => stdout += d);
|
|
125
|
+
child.stderr.on('data', d => stderr += d);
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
child.kill('SIGKILL');
|
|
128
|
+
reject(new Error(`pi probe timed out after ${PROBE_TIMEOUT_MS}ms: ${pluginDir}`));
|
|
129
|
+
}, PROBE_TIMEOUT_MS);
|
|
130
|
+
child.on('close', code => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
if (code !== 0) {
|
|
133
|
+
const detail = stderr.trim() || `exit ${code}`;
|
|
134
|
+
return reject(new Error(`pi probe failed: ${detail}`));
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
resolve(JSON.parse(stdout));
|
|
138
|
+
} catch (err) {
|
|
139
|
+
reject(new Error(`pi probe returned invalid JSON: ${err.message}`));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Discover-or-cache: reads .bahulam-tools.json if present; probes and
|
|
147
|
+
* writes it if not. Callers get a stable JSON shape either way.
|
|
148
|
+
*/
|
|
149
|
+
export async function discoverPiTools(pluginDir, opts = {}) {
|
|
150
|
+
const cachePath = path.join(pluginDir, PI_TOOLS_CACHE);
|
|
151
|
+
if (fs.existsSync(cachePath) && !opts.force) {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
154
|
+
} catch { /* fall through to probe */ }
|
|
155
|
+
}
|
|
156
|
+
const captured = await probePiExtension(pluginDir, opts);
|
|
157
|
+
const shape = {
|
|
158
|
+
tools: captured.tools,
|
|
159
|
+
commands: captured.commands,
|
|
160
|
+
discovered_at: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
fs.writeFileSync(cachePath, JSON.stringify(shape, null, 2));
|
|
163
|
+
return shape;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load a specific pi tool handler for execution. Re-imports the pi
|
|
168
|
+
* extension entry through the shim, extracts the named tool's handler.
|
|
169
|
+
* Simpler than a persistent child — ~50ms overhead per call, fine for
|
|
170
|
+
* seconds-scale tool invocations. Persistent child is a Sprint 2 profile
|
|
171
|
+
* decision.
|
|
172
|
+
*/
|
|
173
|
+
export async function loadPiToolHandler(pluginDir, toolName, { pluginName } = {}) {
|
|
174
|
+
const entries = resolvePiEntry(pluginDir);
|
|
175
|
+
const entryUrls = entries.map(e => pathToFileURL(e).href);
|
|
176
|
+
const name = pluginName || path.basename(pluginDir);
|
|
177
|
+
|
|
178
|
+
// Same activation dance as probePiExtension: import the module, call
|
|
179
|
+
// its default export with our shim to trigger registrations, then look
|
|
180
|
+
// up the named tool. Pi's canonical tool signature is
|
|
181
|
+
// `execute(id, params)` where id is a call identifier — we generate a
|
|
182
|
+
// synthetic one since our loop doesn't have a native concept for it.
|
|
183
|
+
const script = `
|
|
184
|
+
globalThis.__bahulam_pi_captured = { tools: [], commands: [] };
|
|
185
|
+
(async () => {
|
|
186
|
+
try {
|
|
187
|
+
const { pi } = await import('pi');
|
|
188
|
+
for (const url of ${JSON.stringify(entryUrls)}) {
|
|
189
|
+
const mod = await import(url);
|
|
190
|
+
const activate = typeof mod?.default === 'function' ? mod.default
|
|
191
|
+
: typeof mod?.activate === 'function' ? mod.activate
|
|
192
|
+
: null;
|
|
193
|
+
if (activate) { try { await activate(pi); } catch (_) { /* one bad extension shouldn't block others */ } }
|
|
194
|
+
}
|
|
195
|
+
const target = globalThis.__bahulam_pi_captured.tools.find(t => t.name === ${JSON.stringify(toolName)});
|
|
196
|
+
if (!target || typeof target.handler !== 'function') {
|
|
197
|
+
process.stdout.write(JSON.stringify({ ok: false, error: 'tool not found or no handler: ' + ${JSON.stringify(toolName)} }));
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
const args = JSON.parse(process.env.BAHULAM_PI_ARGS || '{}');
|
|
201
|
+
const callId = 'bahulam-' + Date.now().toString(36);
|
|
202
|
+
// Pi's descriptor-form tool signature is FIVE positional args:
|
|
203
|
+
// execute(callId, params, signal, onUpdate, ctx)
|
|
204
|
+
// Passing fewer causes ctx to arrive as one of the earlier slots
|
|
205
|
+
// (or undefined), which trips interactive-workflow paths that
|
|
206
|
+
// then error with "Missing extension context." Match the signature
|
|
207
|
+
// exactly, with a no-op signal + onUpdate for the non-interactive
|
|
208
|
+
// headless case:
|
|
209
|
+
// signal — AbortSignal from a fresh controller (never fires)
|
|
210
|
+
// onUpdate — event callback; discard in headless mode
|
|
211
|
+
// ctx — extension context:
|
|
212
|
+
// hasUI: false → tools resolve non-interactive workflows
|
|
213
|
+
// cwd → project working directory
|
|
214
|
+
// isProjectTrusted:false → conservative; shell-exec-style tools refuse
|
|
215
|
+
// model/modelRegistry → null; model-dependent tools fail gracefully
|
|
216
|
+
// ui: null → no UI adapter
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const ctx = {
|
|
219
|
+
hasUI: false,
|
|
220
|
+
cwd: process.cwd(),
|
|
221
|
+
isProjectTrusted: false,
|
|
222
|
+
model: null,
|
|
223
|
+
modelRegistry: null,
|
|
224
|
+
ui: null,
|
|
225
|
+
};
|
|
226
|
+
const onUpdate = () => {}; // pi may emit progress events; no-op them
|
|
227
|
+
// Shim tags each capture with _form. Descriptor form invokes as
|
|
228
|
+
// execute(callId, params, signal, onUpdate, ctx); positional legacy
|
|
229
|
+
// form invokes as handler(args).
|
|
230
|
+
const result = target._form === 'positional'
|
|
231
|
+
? await target.handler(args)
|
|
232
|
+
: await target.handler(callId, args, controller.signal, onUpdate, ctx);
|
|
233
|
+
const payload = typeof result === 'string'
|
|
234
|
+
? { output: result }
|
|
235
|
+
: (result && typeof result === 'object' ? result : { output: String(result) });
|
|
236
|
+
process.stdout.write(JSON.stringify({ ok: true, result: payload }));
|
|
237
|
+
} catch (err) {
|
|
238
|
+
process.stdout.write(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
return async function invoke(args) {
|
|
244
|
+
return await new Promise((resolve) => {
|
|
245
|
+
const child = spawn(process.execPath, ['--import', LOADER_HOOK, '-e', script], {
|
|
246
|
+
env: {
|
|
247
|
+
...process.env,
|
|
248
|
+
BAHULAM_PI_PLUGIN: name,
|
|
249
|
+
BAHULAM_PI_ARGS: JSON.stringify(args || {}),
|
|
250
|
+
},
|
|
251
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
252
|
+
});
|
|
253
|
+
let stdout = '', stderr = '';
|
|
254
|
+
child.stdout.on('data', d => stdout += d);
|
|
255
|
+
child.stderr.on('data', d => stderr += d);
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
child.kill('SIGKILL');
|
|
258
|
+
resolve({ success: false, output: `pi tool timed out: ${toolName}` });
|
|
259
|
+
}, 60_000);
|
|
260
|
+
child.on('close', () => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(stdout);
|
|
264
|
+
if (parsed.ok) {
|
|
265
|
+
const raw = parsed.result;
|
|
266
|
+
// Pi tools return an object shaped like MCP responses:
|
|
267
|
+
// { content: [{type:'text', text}], details?: { error? } }
|
|
268
|
+
// Detect the details.error case so we don't dress up a
|
|
269
|
+
// failure as success. The agent can then react to the
|
|
270
|
+
// real error string instead of treating it as content.
|
|
271
|
+
const detailsErr = raw && typeof raw === 'object' && raw.details && raw.details.error;
|
|
272
|
+
if (detailsErr) {
|
|
273
|
+
const txt = Array.isArray(raw.content)
|
|
274
|
+
? raw.content.map(c => c?.text || '').filter(Boolean).join('\n').trim()
|
|
275
|
+
: '';
|
|
276
|
+
return resolve({ success: false, output: txt || String(detailsErr) });
|
|
277
|
+
}
|
|
278
|
+
// Prefer explicit output, then flatten MCP-style content, then
|
|
279
|
+
// fall back to the raw payload for pi tools that return a
|
|
280
|
+
// simple {output: '...'}.
|
|
281
|
+
let output = raw?.output;
|
|
282
|
+
if (!output && Array.isArray(raw?.content)) {
|
|
283
|
+
output = raw.content.map(c => c?.text || '').filter(Boolean).join('\n').trim();
|
|
284
|
+
}
|
|
285
|
+
return resolve({ success: true, output: output ?? raw });
|
|
286
|
+
}
|
|
287
|
+
return resolve({ success: false, output: parsed.error || stderr || 'pi tool failed' });
|
|
288
|
+
} catch {
|
|
289
|
+
resolve({ success: false, output: stderr || 'pi tool returned invalid JSON' });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
}
|