@bahulam/code 0.1.24 → 0.1.26
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/commands/install.mjs +88 -6
- package/src/commands/plugin-manage.mjs +146 -5
- package/src/config/cli-args.mjs +23 -3
- package/src/config/model-catalog.mjs +9 -0
- package/src/config/model-defaults.mjs +16 -0
- package/src/core/approval.mjs +8 -1
- package/src/core/attachments.mjs +4 -0
- package/src/core/backend-url.mjs +16 -0
- package/src/core/context-reduction.mjs +207 -0
- package/src/core/headless.mjs +85 -13
- package/src/core/local-agent.mjs +352 -44
- package/src/core/model-selection.mjs +72 -0
- package/src/core/request-retry.mjs +106 -0
- package/src/core/tool-error.mjs +155 -0
- package/src/core/usage-normalization.mjs +32 -0
- package/src/local-service/agent-relay.mjs +279 -7
- package/src/local-service/server.mjs +37 -0
- package/src/orchestration/node-runner.mjs +7 -3
- package/src/plugins/executor.mjs +4 -4
- package/src/plugins/lifecycle.mjs +337 -0
- package/src/plugins/manifest.mjs +37 -0
- package/src/plugins/pi-compat/requirements.mjs +30 -17
- package/src/plugins/preflight.mjs +37 -0
- package/src/terminal/main.mjs +10 -1
- package/src/terminal/paste-input.mjs +52 -0
- package/src/terminal/repl.mjs +169 -36
- package/src/tools/registry.mjs +1 -16
- package/src/core/agent-loop.mjs +0 -503
- package/src/core/context-manager.mjs +0 -198
- package/src/tools/agent.mjs +0 -142
|
@@ -288,6 +288,19 @@ async function routeRequest({ req, res, sessionId, token, events, sseClients, em
|
|
|
288
288
|
return;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// Vendor asset: Bahulam plugin design system (CSS/JS shared across views).
|
|
292
|
+
// Served from assets/bahulam-plugin/ so every plugin workspace view can
|
|
293
|
+
// import a common design language without inlining or server-side packing.
|
|
294
|
+
if (req.method === 'GET' && url.pathname.startsWith('/vendor/bahulam-plugin/')) {
|
|
295
|
+
sendPackageAsset({
|
|
296
|
+
res,
|
|
297
|
+
root: fileURLToPath(new URL('../../assets/bahulam-plugin/', import.meta.url)),
|
|
298
|
+
pathname: url.pathname,
|
|
299
|
+
prefix: '/vendor/bahulam-plugin/',
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
291
304
|
if (req.method === 'GET' && url.pathname === '/assets/bahulam-mark.png') {
|
|
292
305
|
sendBrandMark(res);
|
|
293
306
|
return;
|
|
@@ -312,6 +325,30 @@ async function routeRequest({ req, res, sessionId, token, events, sseClients, em
|
|
|
312
325
|
return;
|
|
313
326
|
}
|
|
314
327
|
|
|
328
|
+
// Trace export — one JSONL line per tool call with FULL args / output /
|
|
329
|
+
// error / stack (no eliding). Pass ?turns=1 to also stream user +
|
|
330
|
+
// assistant messages (as separate lines with role="turn") for
|
|
331
|
+
// correlation.
|
|
332
|
+
if (req.method === 'GET' && url.pathname === '/api/trace/export') {
|
|
333
|
+
const relay = getAgentRelay(session);
|
|
334
|
+
const includeTurns = url.searchParams.get('turns') === '1' || url.searchParams.get('turns') === 'true';
|
|
335
|
+
const data = relay.fullTrace({ includeTurns });
|
|
336
|
+
const filename = `trace-${session.id || 'session'}-${Date.now()}.jsonl`;
|
|
337
|
+
res.writeHead(200, {
|
|
338
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
339
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
340
|
+
'Cache-Control': 'no-store',
|
|
341
|
+
});
|
|
342
|
+
if (includeTurns) {
|
|
343
|
+
for (const t of data.turns || []) res.write(JSON.stringify({ role: 'turn', ...t }) + '\n');
|
|
344
|
+
for (const e of data.trace || []) res.write(JSON.stringify({ role: 'tool', ...e }) + '\n');
|
|
345
|
+
} else {
|
|
346
|
+
for (const e of data || []) res.write(JSON.stringify(e) + '\n');
|
|
347
|
+
}
|
|
348
|
+
res.end();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
315
352
|
if (req.method === 'GET' && url.pathname === '/api/chat/sessions') {
|
|
316
353
|
const relay = getAgentRelay(session);
|
|
317
354
|
const historySessions = await relay.listHistorySessions();
|
|
@@ -43,7 +43,8 @@ export async function* runNode(node, agent, instruction, ctx, options = {}) {
|
|
|
43
43
|
|
|
44
44
|
const model = node.model || effectiveAgent.model || ctx.defaultModel || null;
|
|
45
45
|
const { apiKey = null, openRouterKey = null } = ctx.credentials || {};
|
|
46
|
-
|
|
46
|
+
const useGateway = ctx.modelTransport === 'gateway' && ctx.gatewayToken;
|
|
47
|
+
if (!useGateway && !apiKey && !openRouterKey) {
|
|
47
48
|
throw new Error(
|
|
48
49
|
`Cannot run node '${node.id}' locally: no model API key. ` +
|
|
49
50
|
'Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY, or log in and use the session substrate.',
|
|
@@ -60,8 +61,11 @@ export async function* runNode(node, agent, instruction, ctx, options = {}) {
|
|
|
60
61
|
.filter(schema => declaredTools.has(schema.name));
|
|
61
62
|
|
|
62
63
|
const localAgent = new LocalAgent({
|
|
63
|
-
apiKey,
|
|
64
|
-
openRouterKey,
|
|
64
|
+
apiKey: useGateway ? null : apiKey,
|
|
65
|
+
openRouterKey: useGateway ? null : openRouterKey,
|
|
66
|
+
gatewayUrl: useGateway ? ctx.gatewayUrl : null,
|
|
67
|
+
gatewayToken: useGateway ? ctx.gatewayToken : null,
|
|
68
|
+
sessionId: ctx.sessionId || null,
|
|
65
69
|
model,
|
|
66
70
|
toolExecutor: scopedExecutor,
|
|
67
71
|
cwd: ctx.cwd || process.cwd(),
|
package/src/plugins/executor.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { makePluginState } from './state.mjs';
|
|
11
|
+
import { normalizeToolResult } from '../core/tool-error.mjs';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Load a plugin tool handler by resolving its path relative to the plugin directory.
|
|
@@ -105,13 +106,12 @@ export async function createPluginToolExecutor(manifest, opts = {}) {
|
|
|
105
106
|
get state() { return getState(); },
|
|
106
107
|
pluginName,
|
|
107
108
|
};
|
|
109
|
+
const traceId = options?._trace_id || null;
|
|
108
110
|
try {
|
|
109
111
|
const result = await entry.handler.call(args || {}, handlerOpts);
|
|
110
|
-
return result
|
|
111
|
-
? { success: true, output: result?.output ?? result, _tool: name, _plugin: pluginName }
|
|
112
|
-
: { success: false, output: result?.output ?? String(result), _tool: name, _plugin: pluginName };
|
|
112
|
+
return normalizeToolResult({ tool: name, plugin: pluginName, traceId }, result, null);
|
|
113
113
|
} catch (err) {
|
|
114
|
-
return {
|
|
114
|
+
return normalizeToolResult({ tool: name, plugin: pluginName, traceId }, null, err);
|
|
115
115
|
}
|
|
116
116
|
},
|
|
117
117
|
list: () => [...handlers.keys()],
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin lifecycle — install / seed / upgrade / uninstall / migrations.
|
|
3
|
+
*
|
|
4
|
+
* The manifest declares four optional hook files under `config.lifecycle`:
|
|
5
|
+
*
|
|
6
|
+
* config:
|
|
7
|
+
* lifecycle:
|
|
8
|
+
* seed: ./hooks/seed.mjs
|
|
9
|
+
* post_install: ./hooks/post-install.mjs
|
|
10
|
+
* pre_uninstall: ./hooks/pre-uninstall.mjs
|
|
11
|
+
* migrations:
|
|
12
|
+
* - { version: "0.3.0", sql: ./migrations/0.3.0.sql }
|
|
13
|
+
* - { version: "0.4.0", run: ./migrations/0.4.0.mjs }
|
|
14
|
+
*
|
|
15
|
+
* All hooks receive one argument, a context object:
|
|
16
|
+
*
|
|
17
|
+
* ctx = {
|
|
18
|
+
* pluginDir, // absolute path to the installed plugin folder
|
|
19
|
+
* dataDir, // ~/.bahulam/data/<name>
|
|
20
|
+
* state, // per-plugin state handle (lazy getter — same shape as tool handlers see)
|
|
21
|
+
* args, // CLI/programmatic invocation args (installer passes flags etc.)
|
|
22
|
+
* log(level, msg, meta?), // structured log — routed to stdout + a per-plugin lifecycle log
|
|
23
|
+
* ranBefore(key), // hook idempotency helper — returns true if the given key was recorded
|
|
24
|
+
* recordRun(key, extra?), // mark a hook / migration as run
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* Hook contract:
|
|
28
|
+
* export async function run(ctx) { ... }
|
|
29
|
+
*
|
|
30
|
+
* May return { keepData?: bool, warnings?: string[] } — pre_uninstall
|
|
31
|
+
* uses this to influence the caller's data-cleanup default.
|
|
32
|
+
*
|
|
33
|
+
* Migration entry:
|
|
34
|
+
* { version: "0.3.0", sql?: "./migrations/0.3.0.sql", run?: "./migrations/0.3.0.mjs" }
|
|
35
|
+
*
|
|
36
|
+
* Exactly one of `sql` or `run` must be present. SQL files execute via
|
|
37
|
+
* the plugin state DB's `exec`. JS modules export `run(ctx)`; the ctx
|
|
38
|
+
* is the same shape as hook ctx (but `args` is null).
|
|
39
|
+
*
|
|
40
|
+
* State bookkeeping is stored in a JSON file at
|
|
41
|
+
* `<dataDir>/_bahulam_lifecycle.json`:
|
|
42
|
+
*
|
|
43
|
+
* {
|
|
44
|
+
* installed_at: "2026-09-17T...",
|
|
45
|
+
* installed_version: "0.3.0",
|
|
46
|
+
* last_upgrade_at: null,
|
|
47
|
+
* applied_migrations: ["0.3.0"],
|
|
48
|
+
* runs: { seed: {at: "...", version: "0.3.0"}, post_install: {...} }
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* The migration path is snapshot-then-roll-forward: before a batch of
|
|
52
|
+
* migrations runs, the SQLite file is copied to
|
|
53
|
+
* `<dataDir>/state.db.pre-<from>-to-<to>` and restored on failure.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import fs from 'node:fs';
|
|
57
|
+
import path from 'node:path';
|
|
58
|
+
import os from 'node:os';
|
|
59
|
+
import { pathToFileURL } from 'node:url';
|
|
60
|
+
|
|
61
|
+
const DATA_ROOT = () => path.join(os.homedir(), '.bahulam', 'data');
|
|
62
|
+
const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
63
|
+
|
|
64
|
+
function pluginDataDir(pluginName) {
|
|
65
|
+
if (!PLUGIN_NAME_RE.test(pluginName)) throw new Error(`invalid plugin name for lifecycle: ${pluginName}`);
|
|
66
|
+
const dir = path.join(DATA_ROOT(), pluginName);
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
68
|
+
return dir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lifecyclePath(dataDir) {
|
|
72
|
+
return path.join(dataDir, '_bahulam_lifecycle.json');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readLifecycle(dataDir) {
|
|
76
|
+
const p = lifecyclePath(dataDir);
|
|
77
|
+
if (!fs.existsSync(p)) return { runs: {}, applied_migrations: [] };
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
80
|
+
return { runs: {}, applied_migrations: [], ...parsed };
|
|
81
|
+
} catch { return { runs: {}, applied_migrations: [] }; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeLifecycle(dataDir, record) {
|
|
85
|
+
fs.writeFileSync(lifecyclePath(dataDir), JSON.stringify(record, null, 2), 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function nowIso() { return new Date().toISOString(); }
|
|
89
|
+
|
|
90
|
+
function resolveHookPath(pluginDir, ref) {
|
|
91
|
+
if (!ref) return null;
|
|
92
|
+
// Ref is a POSIX relative path from the manifest — `./hooks/seed.mjs`
|
|
93
|
+
const abs = path.resolve(pluginDir, ref);
|
|
94
|
+
if (!fs.existsSync(abs)) throw new Error(`lifecycle hook not found: ${ref} (looked at ${abs})`);
|
|
95
|
+
return abs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load a hook module and return its `run` function. Cache-busted per
|
|
100
|
+
* call so an operator can edit a hook and re-run install/upgrade
|
|
101
|
+
* without a fresh Node process.
|
|
102
|
+
*/
|
|
103
|
+
async function loadHookRun(pluginDir, ref, label) {
|
|
104
|
+
const abs = resolveHookPath(pluginDir, ref);
|
|
105
|
+
if (!abs) return null;
|
|
106
|
+
const url = `${pathToFileURL(abs).href}?t=${Date.now()}`;
|
|
107
|
+
const mod = await import(url);
|
|
108
|
+
if (typeof mod.run !== 'function') {
|
|
109
|
+
throw new Error(`lifecycle ${label} hook ${ref} must export a run(ctx) function`);
|
|
110
|
+
}
|
|
111
|
+
return mod.run;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a hook context. `stateFactory` is a thunk so hooks that never
|
|
116
|
+
* touch state don't open the SQLite handle.
|
|
117
|
+
*/
|
|
118
|
+
function makeCtx({ pluginDir, dataDir, args, record, logger, stateFactory }) {
|
|
119
|
+
const runs = record.runs || {};
|
|
120
|
+
const applied = new Set(record.applied_migrations || []);
|
|
121
|
+
return {
|
|
122
|
+
pluginDir,
|
|
123
|
+
dataDir,
|
|
124
|
+
args: args || null,
|
|
125
|
+
get state() { return stateFactory ? stateFactory() : null; },
|
|
126
|
+
log: (level, msg, meta) => logger(level, msg, meta),
|
|
127
|
+
ranBefore(key) {
|
|
128
|
+
if (String(key).startsWith('migration:')) return applied.has(String(key).slice('migration:'.length));
|
|
129
|
+
return Boolean(runs[key]);
|
|
130
|
+
},
|
|
131
|
+
recordRun(key, extra) {
|
|
132
|
+
if (String(key).startsWith('migration:')) {
|
|
133
|
+
applied.add(String(key).slice('migration:'.length));
|
|
134
|
+
record.applied_migrations = [...applied];
|
|
135
|
+
} else {
|
|
136
|
+
record.runs = record.runs || {};
|
|
137
|
+
record.runs[key] = { at: nowIso(), ...(extra || {}) };
|
|
138
|
+
}
|
|
139
|
+
writeLifecycle(dataDir, record);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeLogger(pluginName, dataDir) {
|
|
145
|
+
const logPath = path.join(dataDir, '_bahulam_lifecycle.log');
|
|
146
|
+
const quiet = process.env.BAHULAM_LIFECYCLE_QUIET === '1';
|
|
147
|
+
return (level, msg, meta) => {
|
|
148
|
+
const line = `${nowIso()} [${level}] ${pluginName}: ${msg}${meta ? ' ' + JSON.stringify(meta) : ''}\n`;
|
|
149
|
+
try { fs.appendFileSync(logPath, line); } catch { /* best-effort */ }
|
|
150
|
+
if (quiet) return;
|
|
151
|
+
const target = level === 'error' || level === 'warn' ? process.stderr : process.stdout;
|
|
152
|
+
try { target.write(line); } catch { /* best-effort */ }
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Public API — runSeed / runPostInstall / runPreUninstall / runMigrations.
|
|
158
|
+
*
|
|
159
|
+
* All calls are idempotent: calling runSeed twice will not re-run the
|
|
160
|
+
* seed unless the caller passes { force: true }. Post-install always
|
|
161
|
+
* runs on install (both fresh + --force) and on update.
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
export async function runSeed({ pluginName, pluginDir, manifest, args = {}, stateFactory = null } = {}) {
|
|
165
|
+
const ref = manifest?.config?.lifecycle?.seed;
|
|
166
|
+
if (!ref) return { ran: false, reason: 'no seed hook declared' };
|
|
167
|
+
const dataDir = pluginDataDir(pluginName);
|
|
168
|
+
const record = readLifecycle(dataDir);
|
|
169
|
+
const logger = makeLogger(pluginName, dataDir);
|
|
170
|
+
const version = manifest?.metadata?.version || 'unversioned';
|
|
171
|
+
if (record.runs?.seed && !args.force) {
|
|
172
|
+
logger('info', 'seed already ran; skipping (pass --force or --reseed to re-run)');
|
|
173
|
+
return { ran: false, reason: 'already ran', prior: record.runs.seed };
|
|
174
|
+
}
|
|
175
|
+
const runFn = await loadHookRun(pluginDir, ref, 'seed');
|
|
176
|
+
const ctx = makeCtx({ pluginDir, dataDir, args, record, logger, stateFactory });
|
|
177
|
+
const started = Date.now();
|
|
178
|
+
try {
|
|
179
|
+
const result = await runFn(ctx);
|
|
180
|
+
ctx.recordRun('seed', { version, duration_ms: Date.now() - started });
|
|
181
|
+
logger('info', 'seed completed', { duration_ms: Date.now() - started });
|
|
182
|
+
return { ran: true, duration_ms: Date.now() - started, result };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
logger('error', 'seed failed', { message: err.message });
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function runPostInstall({ pluginName, pluginDir, manifest, args = {}, stateFactory = null } = {}) {
|
|
190
|
+
const ref = manifest?.config?.lifecycle?.post_install;
|
|
191
|
+
if (!ref) return { ran: false, reason: 'no post_install hook declared' };
|
|
192
|
+
const dataDir = pluginDataDir(pluginName);
|
|
193
|
+
const record = readLifecycle(dataDir);
|
|
194
|
+
const logger = makeLogger(pluginName, dataDir);
|
|
195
|
+
const runFn = await loadHookRun(pluginDir, ref, 'post_install');
|
|
196
|
+
const ctx = makeCtx({ pluginDir, dataDir, args, record, logger, stateFactory });
|
|
197
|
+
const started = Date.now();
|
|
198
|
+
try {
|
|
199
|
+
const result = await runFn(ctx);
|
|
200
|
+
ctx.recordRun('post_install', { version: manifest?.metadata?.version, duration_ms: Date.now() - started });
|
|
201
|
+
// Also stamp installed_at / installed_version on first install
|
|
202
|
+
if (!record.installed_at) {
|
|
203
|
+
record.installed_at = nowIso();
|
|
204
|
+
record.installed_version = manifest?.metadata?.version || null;
|
|
205
|
+
writeLifecycle(dataDir, record);
|
|
206
|
+
}
|
|
207
|
+
logger('info', 'post_install completed', { duration_ms: Date.now() - started });
|
|
208
|
+
return { ran: true, duration_ms: Date.now() - started, result };
|
|
209
|
+
} catch (err) {
|
|
210
|
+
logger('error', 'post_install failed', { message: err.message });
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* pre_uninstall may return { keepData?: bool, warnings?: [] } which
|
|
217
|
+
* uninstall commands use as the default for the data-cleanup decision.
|
|
218
|
+
*/
|
|
219
|
+
export async function runPreUninstall({ pluginName, pluginDir, manifest, args = {}, stateFactory = null } = {}) {
|
|
220
|
+
const ref = manifest?.config?.lifecycle?.pre_uninstall;
|
|
221
|
+
if (!ref) return { ran: false, reason: 'no pre_uninstall hook declared', result: {} };
|
|
222
|
+
const dataDir = pluginDataDir(pluginName);
|
|
223
|
+
const record = readLifecycle(dataDir);
|
|
224
|
+
const logger = makeLogger(pluginName, dataDir);
|
|
225
|
+
const runFn = await loadHookRun(pluginDir, ref, 'pre_uninstall');
|
|
226
|
+
const ctx = makeCtx({ pluginDir, dataDir, args, record, logger, stateFactory });
|
|
227
|
+
try {
|
|
228
|
+
const result = await runFn(ctx);
|
|
229
|
+
logger('info', 'pre_uninstall completed');
|
|
230
|
+
return { ran: true, result: result || {} };
|
|
231
|
+
} catch (err) {
|
|
232
|
+
logger('error', 'pre_uninstall failed', { message: err.message });
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Purge the data dir. Called by uninstall when --purge or interactive
|
|
239
|
+
* y-answer wins.
|
|
240
|
+
*/
|
|
241
|
+
export function purgeData(pluginName) {
|
|
242
|
+
const dir = path.join(DATA_ROOT(), pluginName);
|
|
243
|
+
if (fs.existsSync(dir)) {
|
|
244
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
245
|
+
return { purged: true, dir };
|
|
246
|
+
}
|
|
247
|
+
return { purged: false, dir };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Apply pending migrations sequentially, snapshotting the state DB
|
|
252
|
+
* beforehand and restoring on failure.
|
|
253
|
+
*/
|
|
254
|
+
export async function runMigrations({ pluginName, pluginDir, manifest, stateFactory = null } = {}) {
|
|
255
|
+
const list = Array.isArray(manifest?.config?.lifecycle?.migrations) ? manifest.config.lifecycle.migrations : [];
|
|
256
|
+
if (!list.length) return { ran: 0, skipped: 0, applied: [] };
|
|
257
|
+
|
|
258
|
+
const dataDir = pluginDataDir(pluginName);
|
|
259
|
+
const record = readLifecycle(dataDir);
|
|
260
|
+
const logger = makeLogger(pluginName, dataDir);
|
|
261
|
+
const applied = new Set(record.applied_migrations || []);
|
|
262
|
+
|
|
263
|
+
const pending = list.filter(m => !applied.has(m.version));
|
|
264
|
+
if (!pending.length) {
|
|
265
|
+
logger('info', `migrations up-to-date (${applied.size} applied)`);
|
|
266
|
+
return { ran: 0, skipped: list.length, applied: [] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Snapshot the DB file first — restore on any failure.
|
|
270
|
+
const dbPath = path.join(dataDir, 'state.db');
|
|
271
|
+
const snapPath = fs.existsSync(dbPath)
|
|
272
|
+
? path.join(dataDir, `state.db.pre-${pending[0].version}-${nowIso().replace(/[:.]/g, '-')}`)
|
|
273
|
+
: null;
|
|
274
|
+
if (snapPath) {
|
|
275
|
+
fs.copyFileSync(dbPath, snapPath);
|
|
276
|
+
logger('info', `migration snapshot: ${path.basename(snapPath)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const ranNow = [];
|
|
280
|
+
try {
|
|
281
|
+
for (const m of pending) {
|
|
282
|
+
if (!m.version) throw new Error('migration entry missing version');
|
|
283
|
+
if (!m.sql && !m.run) throw new Error(`migration ${m.version} needs one of sql/run`);
|
|
284
|
+
if (m.sql && m.run) throw new Error(`migration ${m.version}: only one of sql/run allowed`);
|
|
285
|
+
logger('info', `applying migration ${m.version}`);
|
|
286
|
+
const started = Date.now();
|
|
287
|
+
if (m.sql) {
|
|
288
|
+
const abs = resolveHookPath(pluginDir, m.sql);
|
|
289
|
+
const sqlText = fs.readFileSync(abs, 'utf-8');
|
|
290
|
+
const state = stateFactory ? stateFactory() : null;
|
|
291
|
+
if (!state) throw new Error(`migration ${m.version}: SQL migration requires a state handle`);
|
|
292
|
+
state.exec ? state.exec(sqlText) : execViaQuery(state, sqlText);
|
|
293
|
+
} else {
|
|
294
|
+
const runFn = await loadHookRun(pluginDir, m.run, `migration:${m.version}`);
|
|
295
|
+
const ctx = makeCtx({ pluginDir, dataDir, args: null, record, logger, stateFactory });
|
|
296
|
+
await runFn(ctx);
|
|
297
|
+
}
|
|
298
|
+
applied.add(m.version);
|
|
299
|
+
record.applied_migrations = [...applied];
|
|
300
|
+
writeLifecycle(dataDir, record);
|
|
301
|
+
ranNow.push({ version: m.version, duration_ms: Date.now() - started });
|
|
302
|
+
logger('info', `migration ${m.version} applied`, { duration_ms: Date.now() - started });
|
|
303
|
+
}
|
|
304
|
+
return { ran: ranNow.length, skipped: list.length - pending.length, applied: ranNow };
|
|
305
|
+
} catch (err) {
|
|
306
|
+
logger('error', `migration failed — restoring snapshot`, { message: err.message });
|
|
307
|
+
if (snapPath && fs.existsSync(snapPath)) {
|
|
308
|
+
// Best-effort restore. If the failure was mid-write, the WAL may
|
|
309
|
+
// linger; wiping it is safer than leaving inconsistent frames.
|
|
310
|
+
try { fs.copyFileSync(snapPath, dbPath); } catch { /* fall through */ }
|
|
311
|
+
for (const ext of ['-wal', '-shm']) {
|
|
312
|
+
const aux = dbPath + ext;
|
|
313
|
+
if (fs.existsSync(aux)) { try { fs.rmSync(aux); } catch { /* ok */ } }
|
|
314
|
+
}
|
|
315
|
+
// Restore lifecycle record to pre-migration state
|
|
316
|
+
const preRecord = readLifecycle(dataDir);
|
|
317
|
+
preRecord.applied_migrations = [...(preRecord.applied_migrations || [])].filter(v => !ranNow.some(r => r.version === v));
|
|
318
|
+
writeLifecycle(dataDir, preRecord);
|
|
319
|
+
logger('warn', 'snapshot restored — plugin is at the pre-migration schema. Fix and re-run install/update.');
|
|
320
|
+
}
|
|
321
|
+
err.migration_context = { attempted: ranNow.map(r => r.version), failed_on: pending[ranNow.length]?.version };
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fallback exec for JSON-backed state (no exec method, only query).
|
|
327
|
+
function execViaQuery(state, sqlText) {
|
|
328
|
+
const stmts = sqlText.split(/;\s*$/m).map(s => s.trim()).filter(Boolean);
|
|
329
|
+
for (const s of stmts) {
|
|
330
|
+
if (typeof state.query === 'function') state.query(s);
|
|
331
|
+
else throw new Error('cannot execute raw SQL against the JSON-backed state fallback');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function readLifecycleRecord(pluginName) {
|
|
336
|
+
return readLifecycle(pluginDataDir(pluginName));
|
|
337
|
+
}
|
package/src/plugins/manifest.mjs
CHANGED
|
@@ -172,6 +172,42 @@ const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
|
172
172
|
// manifest degrades into a working table instead of a failed CREATE.
|
|
173
173
|
const SQL_TYPES = new Set(['INTEGER', 'TEXT', 'REAL', 'BLOB', 'NUMERIC']);
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Normalize `config.lifecycle` — install / seed / uninstall / migration hooks.
|
|
177
|
+
* Returns null when the section is absent so callers can distinguish
|
|
178
|
+
* "no lifecycle declared" from "empty lifecycle".
|
|
179
|
+
*
|
|
180
|
+
* Shape:
|
|
181
|
+
* {
|
|
182
|
+
* seed: "./hooks/seed.mjs" | null,
|
|
183
|
+
* post_install: "./hooks/post-install.mjs" | null,
|
|
184
|
+
* pre_uninstall: "./hooks/pre-uninstall.mjs" | null,
|
|
185
|
+
* migrations: [
|
|
186
|
+
* { version: "0.3.0", sql: "./migrations/0.3.0.sql" },
|
|
187
|
+
* { version: "0.4.0", run: "./migrations/0.4.0.mjs" },
|
|
188
|
+
* ],
|
|
189
|
+
* }
|
|
190
|
+
*/
|
|
191
|
+
function normalizeLifecycle(value) {
|
|
192
|
+
if (!value || typeof value !== 'object') return null;
|
|
193
|
+
const out = {
|
|
194
|
+
seed: typeof value.seed === 'string' ? value.seed : null,
|
|
195
|
+
post_install: typeof value.post_install === 'string' ? value.post_install : null,
|
|
196
|
+
pre_uninstall: typeof value.pre_uninstall === 'string' ? value.pre_uninstall : null,
|
|
197
|
+
migrations: [],
|
|
198
|
+
};
|
|
199
|
+
if (Array.isArray(value.migrations)) {
|
|
200
|
+
for (const m of value.migrations) {
|
|
201
|
+
if (!m || typeof m !== 'object' || !m.version) continue;
|
|
202
|
+
const entry = { version: String(m.version) };
|
|
203
|
+
if (typeof m.sql === 'string') entry.sql = m.sql;
|
|
204
|
+
if (typeof m.run === 'string') entry.run = m.run;
|
|
205
|
+
out.migrations.push(entry);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
175
211
|
function normalizeSqlType(value) {
|
|
176
212
|
const raw = String(value || '').trim().toUpperCase();
|
|
177
213
|
if (!raw) return 'TEXT';
|
|
@@ -520,6 +556,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
520
556
|
mcpServers,
|
|
521
557
|
composes,
|
|
522
558
|
state,
|
|
559
|
+
...(normalizeLifecycle(config.lifecycle) ? { lifecycle: normalizeLifecycle(config.lifecycle) } : {}),
|
|
523
560
|
},
|
|
524
561
|
source,
|
|
525
562
|
_dir: source ? path.dirname(source) : '',
|
|
@@ -26,20 +26,22 @@ export const REQUIREMENTS_FILE = '.bahulam-requirements.json';
|
|
|
26
26
|
// Known binary → install hint DB. Extend as we learn new pi packages.
|
|
27
27
|
// Keep it small and honest: unknown binaries just report the name.
|
|
28
28
|
const INSTALL_HINTS = {
|
|
29
|
-
ffmpeg: { darwin: 'brew install ffmpeg', linux: 'apt install -y ffmpeg' },
|
|
30
|
-
ffprobe: { darwin: 'brew install ffmpeg', linux: 'apt install -y ffmpeg' },
|
|
31
|
-
imagemagick: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick' },
|
|
32
|
-
convert: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick' },
|
|
33
|
-
magick: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick' },
|
|
34
|
-
docker: { darwin: 'brew install --cask docker', linux: 'https://docs.docker.com/engine/install/' },
|
|
35
|
-
git: { darwin: 'brew install git', linux: 'apt install -y git' },
|
|
36
|
-
python: { darwin: 'brew install python', linux: 'apt install -y python3' },
|
|
37
|
-
python3: { darwin: 'brew install python', linux: 'apt install -y python3' },
|
|
38
|
-
node: { darwin: 'brew install node', linux: 'apt install -y nodejs' },
|
|
39
|
-
yt_dlp: { darwin: 'brew install yt-dlp', linux: 'pip install yt-dlp' },
|
|
40
|
-
'yt-dlp': { darwin: 'brew install yt-dlp', linux: 'pip install yt-dlp' },
|
|
41
|
-
pandoc: { darwin: 'brew install pandoc', linux: 'apt install -y pandoc' },
|
|
42
|
-
|
|
29
|
+
ffmpeg: { darwin: 'brew install ffmpeg', linux: 'apt install -y ffmpeg', win32: 'choco install ffmpeg' },
|
|
30
|
+
ffprobe: { darwin: 'brew install ffmpeg', linux: 'apt install -y ffmpeg', win32: 'choco install ffmpeg' },
|
|
31
|
+
imagemagick: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick', win32: 'choco install imagemagick' },
|
|
32
|
+
convert: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick', win32: 'choco install imagemagick' },
|
|
33
|
+
magick: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick', win32: 'choco install imagemagick' },
|
|
34
|
+
docker: { darwin: 'brew install --cask docker', linux: 'https://docs.docker.com/engine/install/', win32: 'choco install docker-desktop' },
|
|
35
|
+
git: { darwin: 'brew install git', linux: 'apt install -y git', win32: 'choco install git' },
|
|
36
|
+
python: { darwin: 'brew install python', linux: 'apt install -y python3', win32: 'choco install python' },
|
|
37
|
+
python3: { darwin: 'brew install python', linux: 'apt install -y python3', win32: 'choco install python' },
|
|
38
|
+
node: { darwin: 'brew install node', linux: 'apt install -y nodejs', win32: 'choco install nodejs-lts' },
|
|
39
|
+
yt_dlp: { darwin: 'brew install yt-dlp', linux: 'pip install yt-dlp', win32: 'choco install yt-dlp' },
|
|
40
|
+
'yt-dlp': { darwin: 'brew install yt-dlp', linux: 'pip install yt-dlp', win32: 'choco install yt-dlp' },
|
|
41
|
+
pandoc: { darwin: 'brew install pandoc', linux: 'apt install -y pandoc', win32: 'choco install pandoc' },
|
|
42
|
+
pdflatex: { darwin: 'brew install --cask basictex', linux: 'apt install -y texlive-latex-recommended', win32: 'choco install miktex' },
|
|
43
|
+
xelatex: { darwin: 'brew install --cask mactex-no-gui', linux: 'apt install -y texlive-xetex', win32: 'choco install miktex' },
|
|
44
|
+
tesseract: { darwin: 'brew install tesseract', linux: 'apt install -y tesseract-ocr', win32: 'choco install tesseract' },
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
// Shell keywords that mean "the arg after me is the binary" when
|
|
@@ -462,11 +464,22 @@ export function formatRequirementsReport(reqs, { verbose = false } = {}) {
|
|
|
462
464
|
if (!reqs) return lines;
|
|
463
465
|
|
|
464
466
|
if (reqs.system_binaries?.length) {
|
|
465
|
-
|
|
467
|
+
const platformKey = process.platform === 'win32' ? 'win32'
|
|
468
|
+
: process.platform === 'darwin' ? 'darwin'
|
|
469
|
+
: 'linux';
|
|
470
|
+
const required = reqs.system_binaries.filter(b => b.optional !== true);
|
|
471
|
+
const optional = reqs.system_binaries.filter(b => b.optional === true);
|
|
472
|
+
if (required.length) {
|
|
473
|
+
lines.push({ level: 'warn', text: `system binaries required: ${required.map(b => b.name).join(', ')}` });
|
|
474
|
+
}
|
|
475
|
+
if (optional.length) {
|
|
476
|
+
lines.push({ level: 'info', text: `system binaries optional: ${optional.map(b => b.name).join(', ')}` });
|
|
477
|
+
}
|
|
466
478
|
if (verbose) {
|
|
467
479
|
for (const b of reqs.system_binaries) {
|
|
468
|
-
const hint = b.install_hints?.darwin || b.install_hints?.linux;
|
|
469
|
-
|
|
480
|
+
const hint = b.install_hints?.[platformKey] || b.install_hints?.darwin || b.install_hints?.linux;
|
|
481
|
+
const tag = b.optional === true ? ' (optional)' : '';
|
|
482
|
+
lines.push({ level: 'info', text: ` ${b.name}${tag}${hint ? ` — install: ${hint}` : ''}` });
|
|
470
483
|
}
|
|
471
484
|
}
|
|
472
485
|
}
|
|
@@ -249,6 +249,43 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
249
249
|
else if (!/\.(html?|htm)$/i.test(source)) warnings.push(`View "${label}": source should be an .html file`);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// 6b. Lifecycle hooks + migrations
|
|
253
|
+
const lifecycle = manifest.config?.lifecycle || null;
|
|
254
|
+
if (lifecycle) {
|
|
255
|
+
const checkHook = (ref, label) => {
|
|
256
|
+
if (!ref) return;
|
|
257
|
+
const abs = path.resolve(pluginDir, ref);
|
|
258
|
+
const inside = abs === pluginDir || abs.startsWith(pluginDir + path.sep);
|
|
259
|
+
if (!inside) errors.push(`Lifecycle ${label}: path escapes the plugin directory: ${ref}`);
|
|
260
|
+
else if (!fs.existsSync(abs)) errors.push(`Lifecycle ${label} hook not found: ${ref}`);
|
|
261
|
+
else if (!fs.statSync(abs).isFile()) errors.push(`Lifecycle ${label} hook is not a file: ${ref}`);
|
|
262
|
+
else if (!/\.(mjs|js|cjs)$/.test(ref)) warnings.push(`Lifecycle ${label} hook should be .mjs/.js/.cjs: ${ref}`);
|
|
263
|
+
};
|
|
264
|
+
checkHook(lifecycle.seed, 'seed');
|
|
265
|
+
checkHook(lifecycle.post_install, 'post_install');
|
|
266
|
+
checkHook(lifecycle.pre_uninstall, 'pre_uninstall');
|
|
267
|
+
|
|
268
|
+
const seenVersions = new Set();
|
|
269
|
+
for (const [i, m] of (lifecycle.migrations || []).entries()) {
|
|
270
|
+
const tag = `migration #${i + 1}${m.version ? ` (${m.version})` : ''}`;
|
|
271
|
+
if (!m.version) { errors.push(`${tag}: missing version`); continue; }
|
|
272
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][\w.-]+)?$/.test(m.version)) {
|
|
273
|
+
warnings.push(`${tag}: version "${m.version}" is not semver — sort order may be unpredictable`);
|
|
274
|
+
}
|
|
275
|
+
if (seenVersions.has(m.version)) errors.push(`${tag}: duplicate version`);
|
|
276
|
+
seenVersions.add(m.version);
|
|
277
|
+
if (!m.sql && !m.run) errors.push(`${tag}: needs one of sql or run`);
|
|
278
|
+
if (m.sql && m.run) errors.push(`${tag}: only one of sql or run allowed`);
|
|
279
|
+
const target = m.sql || m.run;
|
|
280
|
+
if (target) {
|
|
281
|
+
const abs = path.resolve(pluginDir, target);
|
|
282
|
+
const inside = abs === pluginDir || abs.startsWith(pluginDir + path.sep);
|
|
283
|
+
if (!inside) errors.push(`${tag}: path escapes the plugin directory: ${target}`);
|
|
284
|
+
else if (!fs.existsSync(abs)) errors.push(`${tag}: file not found: ${target}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
252
289
|
// 7. Install collision
|
|
253
290
|
const existing = new Set(
|
|
254
291
|
Array.from(opts.existingPluginNames?.() || [])
|
package/src/terminal/main.mjs
CHANGED
|
@@ -57,6 +57,10 @@ function parsePluginArgs(argv) {
|
|
|
57
57
|
case '--global': parsed.global = true; break;
|
|
58
58
|
case '--force': case '-f': parsed.force = true; break;
|
|
59
59
|
case '--ref': case '--tag': case '--branch': parsed.ref = argv[++i]; break;
|
|
60
|
+
case '--purge': parsed.purge = true; break;
|
|
61
|
+
case '--keep-data': parsed.keep_data = true; break;
|
|
62
|
+
case '--no-seed': parsed.no_seed = true; break;
|
|
63
|
+
case '--reseed': parsed.reseed = true; break;
|
|
60
64
|
default:
|
|
61
65
|
if (!arg.startsWith('-')) positional.push(arg);
|
|
62
66
|
break;
|
|
@@ -336,6 +340,10 @@ async function main() {
|
|
|
336
340
|
bahulam --agent <slug> -p "x" Run a named agent (local deterministic graph)
|
|
337
341
|
bahulam --workflow <name> -p Run a named workflow (local deterministic graph)
|
|
338
342
|
bahulam --headless -p "x" Non-interactive: auto-approve, JSONL output
|
|
343
|
+
bahulam --remote -p "x" Remote backend /api/execute orchestration
|
|
344
|
+
bahulam --bundled -p "x" Local bundled backend-style orchestration
|
|
345
|
+
bahulam --local -p "x" Local npm orchestration via Bahulam Gateway
|
|
346
|
+
bahulam --direct -p "x" Local npm orchestration via provider API
|
|
339
347
|
bahulam --headless -p "x" --vision screenshot.png
|
|
340
348
|
Attach an image via the vision analysis pipeline
|
|
341
349
|
bahulam --resume Resume last conversation
|
|
@@ -525,7 +533,7 @@ async function main() {
|
|
|
525
533
|
const effectivePrompt = args.prompt || (daemonSpawned && daemonPrompt) || '';
|
|
526
534
|
const hasGraphTarget = Boolean(args.agent || args.workflow);
|
|
527
535
|
if ((effectivePrompt || hasGraphTarget)
|
|
528
|
-
&& (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY || hasGraphTarget)) {
|
|
536
|
+
&& (daemonSpawned || process.argv.includes('--headless') || args.runtimeMode || !process.stdin.isTTY || hasGraphTarget)) {
|
|
529
537
|
const { runHeadless } = await import('../core/headless.mjs');
|
|
530
538
|
await runHeadless({
|
|
531
539
|
instruction: effectivePrompt,
|
|
@@ -534,6 +542,7 @@ async function main() {
|
|
|
534
542
|
verbose: args.verbose,
|
|
535
543
|
cacheReport: args.cacheReport,
|
|
536
544
|
local: args.local,
|
|
545
|
+
mode: args.runtimeMode || (args.local ? 'local' : 'remote'),
|
|
537
546
|
vision: args.vision,
|
|
538
547
|
agent: args.agent,
|
|
539
548
|
workflow: args.workflow,
|