@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.
@@ -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
- if (!apiKey && !openRouterKey) {
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(),
@@ -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?.success !== false
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 { success: false, output: `Plugin tool error (${name}): ${err.message}`, _tool: name, _plugin: pluginName };
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
+ }
@@ -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
- tesseract: { darwin: 'brew install tesseract', linux: 'apt install -y tesseract-ocr' },
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
- lines.push({ level: 'warn', text: `system binaries required: ${reqs.system_binaries.map(b => b.name).join(', ')}` });
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
- lines.push({ level: 'info', text: ` ${b.name}${hint ? ` install: ${hint}` : ''}` });
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?.() || [])
@@ -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,