@bahulam/code 0.1.24 → 0.1.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,8 @@ import {
32
32
  resolveComposeDependencies,
33
33
  } from './plugin-manage.mjs';
34
34
  import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
35
+ import { runMigrations, runSeed, runPostInstall, purgeData } from '../plugins/lifecycle.mjs';
36
+ import { makePluginState } from '../plugins/state.mjs';
35
37
 
36
38
  const RESET = '\x1b[0m';
37
39
  const BOLD = '\x1b[1m';
@@ -68,6 +70,8 @@ function parseArgs(argv) {
68
70
  case '--slug': case '--name': parsed.slug = argv[++i]; break;
69
71
  case '--no-state': parsed.state = false; break;
70
72
  case '--no-workspace': parsed.workspace = false; break;
73
+ case '--no-seed': parsed.no_seed = true; break;
74
+ case '--reseed': parsed.reseed = true; break;
71
75
  default:
72
76
  if (!arg.startsWith('-')) positional.push(arg);
73
77
  break;
@@ -171,6 +175,8 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
171
175
  --slug <name> Override the scaffolded pack slug (pi sources only)
172
176
  --no-state Skip the persistent state layer (pi sources only)
173
177
  --no-workspace Skip the reactive workspace panel (pi sources only)
178
+ --no-seed Skip the lifecycle seed hook on install
179
+ --reseed Re-run the seed hook even if it already ran
174
180
  --project Install into ./.bahulam/plugins/ instead of ~/.bahulam/plugins/
175
181
  --ref <ref> Git branch/tag/commit (git or registry sources)
176
182
  --json Machine-readable output
@@ -417,6 +423,11 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
417
423
  }
418
424
  }
419
425
 
426
+ // Lifecycle — migrations → seed → post_install. Runs after preflight OK.
427
+ // --force also purges the state dir so a clean reinstall starts from zero;
428
+ // omit --force to preserve state across reinstalls (common upgrade case).
429
+ const lifecycleReport = await runLifecycleOnInstall(m, dest, args);
430
+
420
431
  if (args.json) {
421
432
  process.stdout.write(JSON.stringify({
422
433
  ok: true,
@@ -425,6 +436,7 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
425
436
  directory: dest,
426
437
  scaffolded: Boolean(meta),
427
438
  ...(meta ? { pi_package: meta.packageName, namespace: meta.namespace, composed_tools: meta.exposeTools, agent: meta.agentSlug } : {}),
439
+ lifecycle: lifecycleReport,
428
440
  }, null, 2) + '\n');
429
441
  return;
430
442
  }
@@ -441,9 +453,63 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
441
453
  process.stderr.write(` ${DIM}scaffolded${RESET} from ${CYAN}pi:${meta.packageName}${RESET} (namespace ${CYAN}${meta.namespace}${RESET}, ${meta.exposeTools.length} composed tool${meta.exposeTools.length === 1 ? '' : 's'})\n`);
442
454
  process.stderr.write(` ${DIM}Edit the pack under ${dest} to customize.${RESET}\n`);
443
455
  }
456
+ if (lifecycleReport.migrations?.ran || lifecycleReport.seed?.ran || lifecycleReport.post_install?.ran) {
457
+ const parts = [];
458
+ if (lifecycleReport.migrations?.ran) parts.push(`migrations: ${lifecycleReport.migrations.ran} applied`);
459
+ if (lifecycleReport.seed?.ran) parts.push('seed: ran');
460
+ if (lifecycleReport.post_install?.ran) parts.push('post_install: ran');
461
+ process.stderr.write(` ${DIM}lifecycle${RESET} ${parts.join(', ')}\n`);
462
+ }
444
463
  process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
445
464
  }
446
465
 
466
+ /**
467
+ * Run migrations → seed → post_install after a successful preflight.
468
+ * Callers pass `args.force` to also wipe the state dir first (true clean
469
+ * reinstall) and `args.no_seed` to skip seeding.
470
+ *
471
+ * Any lifecycle failure rolls back the plugin directory so the workspace
472
+ * is not left in a half-installed state — same policy as preflight
473
+ * rollback above.
474
+ */
475
+ async function runLifecycleOnInstall(manifest, dest, args) {
476
+ const lifecycle = manifest.config?.lifecycle || null;
477
+ if (!lifecycle) return { skipped: true, reason: 'no config.lifecycle declared' };
478
+ const pluginName = manifest.metadata?.name;
479
+ if (!pluginName) return { skipped: true, reason: 'manifest missing name' };
480
+
481
+ // --force is a true clean reinstall: wipe both the plugin dir (already
482
+ // done by the installer's --force path) AND the state dir. Without
483
+ // --force we keep state across reinstalls so an upgrade doesn't
484
+ // silently discard user data.
485
+ if (args.force) {
486
+ const purged = purgeData(pluginName);
487
+ if (purged.purged) process.stderr.write(` ${YELLOW}!${RESET} --force: purged state dir ${purged.dir}\n`);
488
+ }
489
+
490
+ const stateFactory = () => makePluginState(pluginName, { tables: manifest.config?.state?.tables || [] });
491
+ const report = { migrations: null, seed: null, post_install: null };
492
+ try {
493
+ report.migrations = await runMigrations({ pluginName, pluginDir: dest, manifest, stateFactory });
494
+ if (!args.no_seed) {
495
+ report.seed = await runSeed({
496
+ pluginName, pluginDir: dest, manifest,
497
+ args: { force: Boolean(args.reseed || args.force) },
498
+ stateFactory,
499
+ });
500
+ } else {
501
+ report.seed = { ran: false, reason: '--no-seed flag' };
502
+ }
503
+ report.post_install = await runPostInstall({ pluginName, pluginDir: dest, manifest, args, stateFactory });
504
+ } catch (err) {
505
+ // Roll back the plugin dir. State was intentionally not touched by us
506
+ // during the migration failure (snapshot-rollback took care of it).
507
+ fs.rmSync(dest, { recursive: true, force: true });
508
+ throw new Error(`lifecycle failed — rolled back ${dest}: ${err.message}`);
509
+ }
510
+ return report;
511
+ }
512
+
447
513
  /**
448
514
  * Install-time host check: read the ingredient's requirements sidecar,
449
515
  * verify each detected binary is on PATH. Throws with an actionable
@@ -480,15 +546,31 @@ async function enforceHostRequirements({ piDir, packageName, args }) {
480
546
  }
481
547
 
482
548
  const host = checkRequirementsAgainstHost(reqs);
549
+ const optionalByName = new Map((reqs.system_binaries || []).map(b => [b.name, b.optional === true]));
483
550
  const missing = host.binaries.filter(b => !b.found);
484
- if (missing.length === 0) return;
551
+ const missingRequired = missing.filter(b => !optionalByName.get(b.name));
552
+ const missingOptional = missing.filter(b => optionalByName.get(b.name));
553
+
554
+ const platformKey = process.platform === 'win32' ? 'win32'
555
+ : process.platform === 'darwin' ? 'darwin'
556
+ : 'linux';
557
+ const hintFor = (b) => b.install_hints?.[platformKey] || b.install_hints?.darwin || b.install_hints?.linux;
558
+
559
+ if (missingOptional.length) {
560
+ process.stderr.write(`${YELLOW}!${RESET} ${packageName} is missing ${missingOptional.length} optional binar${missingOptional.length === 1 ? 'y' : 'ies'} — some features will be disabled:\n`);
561
+ for (const b of missingOptional) {
562
+ const hint = hintFor(b);
563
+ process.stderr.write(` ${DIM}·${RESET} ${BOLD}${b.name}${RESET}${hint ? ` — install: ${CYAN}${hint}${RESET}` : ''}\n`);
564
+ }
565
+ }
566
+
567
+ if (missingRequired.length === 0) return;
485
568
 
486
- const platformKey = process.platform === 'darwin' ? 'darwin' : 'linux';
487
569
  const lines = [];
488
- lines.push(`${packageName} needs ${missing.length} system binar${missing.length === 1 ? 'y' : 'ies'} not found on your PATH:`);
489
- for (const b of missing) {
490
- const hint = b.install_hints?.[platformKey];
491
- lines.push(` · ${b.name}${hint ? ` — install: ${CYAN}${hint}${RESET}` : ''}`);
570
+ lines.push(`${packageName} needs ${missingRequired.length} system binar${missingRequired.length === 1 ? 'y' : 'ies'} not found on your PATH:`);
571
+ for (const b of missingRequired) {
572
+ const hint = hintFor(b);
573
+ lines.push(` · ${BOLD}${b.name}${RESET}${hint ? ` — install: ${CYAN}${hint}${RESET}` : ''}`);
492
574
  }
493
575
  if (args.force) {
494
576
  process.stderr.write(`${YELLOW}!${RESET} ${lines.join('\n')}\n`);
@@ -26,6 +26,8 @@ import { parsePluginManifestFile } from '../plugins/manifest.mjs';
26
26
  import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
27
27
  import { parsePiSource } from '../plugins/pi-compose.mjs';
28
28
  import { bahulamHome, pluginDirs, pluginInstallDir } from '../core/paths.mjs';
29
+ import { runPreUninstall, runMigrations, runPostInstall, runSeed, purgeData, readLifecycleRecord } from '../plugins/lifecycle.mjs';
30
+ import { makePluginState } from '../plugins/state.mjs';
29
31
 
30
32
  const RESET = '\x1b[0m';
31
33
  const BOLD = '\x1b[1m';
@@ -275,6 +277,15 @@ async function surfacePackRequirements(packDir, displayName = null) {
275
277
  process.stderr.write(` ${icon} ${l.text}\n`);
276
278
  }
277
279
  if (reqs.system_binaries?.length) {
280
+ const platformKey = process.platform === 'win32' ? 'win32'
281
+ : process.platform === 'darwin' ? 'darwin'
282
+ : 'linux';
283
+ for (const b of reqs.system_binaries) {
284
+ const hint = b.install_hints?.[platformKey] || b.install_hints?.darwin || b.install_hints?.linux;
285
+ if (!hint) continue;
286
+ const tag = b.optional === true ? ` ${DIM}(optional)${RESET}` : '';
287
+ process.stderr.write(` ${DIM}·${RESET} ${BOLD}${b.name}${RESET}${tag} — install: ${CYAN}${hint}${RESET}\n`);
288
+ }
278
289
  const name = displayName || path.basename(packDir);
279
290
  process.stderr.write(` ${DIM}run${RESET} ${CYAN}bahulam plugin doctor ${name}${RESET} ${DIM}to check your environment${RESET}\n`);
280
291
  }
@@ -540,16 +551,116 @@ async function cmdList(args, cwd) {
540
551
 
541
552
  // ── remove ──────────────────────────────────────────────────────────
542
553
 
543
- function cmdRemove(args, cwd) {
554
+ async function cmdRemove(args, cwd) {
544
555
  if (!args.pluginName) throw new Error('remove requires a plugin name');
545
556
  const found = findByName(args.pluginName, cwd);
546
557
  if (!found) throw new Error(`plugin not found: ${args.pluginName}`);
558
+
559
+ // Load the manifest first — if the plugin has a pre_uninstall hook, we
560
+ // must run it BEFORE deleting the plugin dir (hook code lives there).
561
+ const scan = readManifest(found.directory);
562
+ const manifest = scan?.manifest || null;
563
+ const pluginName = manifest?.metadata?.name || found.name;
564
+ const stateFactory = manifest ? () => makePluginState(pluginName, { tables: manifest.config?.state?.tables || [] }) : null;
565
+
566
+ let preHookResult = { keepData: null, warnings: [] };
567
+ if (manifest?.config?.lifecycle?.pre_uninstall) {
568
+ try {
569
+ const r = await runPreUninstall({ pluginName, pluginDir: found.directory, manifest, args, stateFactory });
570
+ preHookResult = r.result || {};
571
+ if (Array.isArray(preHookResult.warnings)) {
572
+ for (const w of preHookResult.warnings) process.stderr.write(` ${YELLOW}!${RESET} ${w}\n`);
573
+ }
574
+ } catch (err) {
575
+ // The hook explicitly failed — that is often intentional
576
+ // ("cannot remove: there is unsaved work"). Do not proceed.
577
+ throw new Error(`pre_uninstall hook failed: ${err.message}. Uninstall aborted; nothing was removed.`);
578
+ }
579
+ }
580
+
581
+ // Decide the data-cleanup behavior. Precedence:
582
+ // 1. Explicit CLI flag: --purge → wipe; --keep-data → keep.
583
+ // 2. pre_uninstall hook result.keepData (bool).
584
+ // 3. TTY → interactive prompt (default keep).
585
+ // 4. Non-TTY (piped/CI) → keep, and print the exact --purge command.
586
+ const dataDir = path.join(bahulamHome(), 'data', pluginName);
587
+ const hasDataDir = fs.existsSync(dataDir);
588
+ let purgeChoice = null; // true = purge, false = keep
589
+ const reasons = [];
590
+ if (args.purge) { purgeChoice = true; reasons.push('--purge'); }
591
+ else if (args.keep_data) { purgeChoice = false; reasons.push('--keep-data'); }
592
+ else if (preHookResult && typeof preHookResult.keepData === 'boolean') {
593
+ purgeChoice = !preHookResult.keepData;
594
+ reasons.push(`pre_uninstall.keepData=${preHookResult.keepData}`);
595
+ } else if (hasDataDir && process.stdin.isTTY && process.stdout.isTTY) {
596
+ purgeChoice = await promptYesNo(
597
+ `Also delete state at ${dataDir}? [y/N] `,
598
+ false,
599
+ );
600
+ reasons.push(`prompt=${purgeChoice ? 'y' : 'n'}`);
601
+ } else {
602
+ purgeChoice = false;
603
+ if (hasDataDir) reasons.push('non-TTY default: keep-data');
604
+ }
605
+
606
+ // Remove the plugin directory.
547
607
  rmrf(found.directory);
608
+
609
+ // Purge or keep the data dir.
610
+ let purged = { purged: false, dir: dataDir };
611
+ if (purgeChoice && hasDataDir) {
612
+ purged = purgeData(pluginName);
613
+ }
614
+
548
615
  if (args.json) {
549
- process.stdout.write(JSON.stringify({ ok: true, removed: found.name, directory: found.directory }) + '\n');
616
+ process.stdout.write(JSON.stringify({
617
+ ok: true,
618
+ removed: found.name,
619
+ directory: found.directory,
620
+ data: {
621
+ kept: !purgeChoice,
622
+ purged: purged.purged,
623
+ directory: dataDir,
624
+ reason: reasons.join(' '),
625
+ },
626
+ pre_uninstall_ran: Boolean(manifest?.config?.lifecycle?.pre_uninstall),
627
+ }) + '\n');
550
628
  return;
551
629
  }
630
+
552
631
  process.stderr.write(`${GREEN}✓${RESET} Removed ${BOLD}${found.name}${RESET} (${found.directory})\n`);
632
+ if (purged.purged) {
633
+ process.stderr.write(` ${DIM}data${RESET} purged ${dataDir}\n`);
634
+ } else if (hasDataDir) {
635
+ process.stderr.write(
636
+ ` ${DIM}data${RESET} kept ${dataDir}${reasons.length ? ` ${DIM}(${reasons.join(' ')})${RESET}` : ''}\n` +
637
+ ` ${DIM}To purge later:${RESET} ${CYAN}bahulam plugin remove ${found.name} --purge${RESET}` +
638
+ ` ${DIM}(plugin dir is gone; this variant only purges the data dir).${RESET}\n`,
639
+ );
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Minimal yes/no prompt — no external deps. Reads a single line from
645
+ * stdin; empty answer takes the default.
646
+ */
647
+ function promptYesNo(prompt, defaultAnswer) {
648
+ return new Promise((resolve) => {
649
+ try { process.stdout.write(prompt); } catch { /* ok */ }
650
+ let input = '';
651
+ const onData = (chunk) => {
652
+ input += chunk.toString();
653
+ const nl = input.indexOf('\n');
654
+ if (nl < 0) return;
655
+ process.stdin.removeListener('data', onData);
656
+ try { process.stdin.pause(); } catch { /* ok */ }
657
+ const answer = input.slice(0, nl).trim().toLowerCase();
658
+ if (!answer) return resolve(defaultAnswer);
659
+ resolve(answer === 'y' || answer === 'yes');
660
+ };
661
+ try { process.stdin.resume(); } catch { /* ok */ }
662
+ process.stdin.on('data', onData);
663
+ });
553
664
  }
554
665
 
555
666
  // ── enable / disable ────────────────────────────────────────────────
@@ -620,7 +731,15 @@ async function cmdUpdate(args, cwd) {
620
731
  const ref = args.ref || found.origin.ref;
621
732
  const subdir = found.origin.subdir;
622
733
  process.stderr.write(`${DIM}Updating ${found.name} from ${found.origin.url}${ref ? ` @ ${ref}` : ''}${subdir ? ` (subdir ${subdir})` : ''}...${RESET}\n`);
623
- if (subdir) {
734
+
735
+ // --force wipes the plugin dir before the fetch so a stale local edit or
736
+ // corrupt install doesn't block the reset. Data dir is preserved.
737
+ if (args.force && !subdir) {
738
+ rmrf(found.directory);
739
+ fs.mkdirSync(found.directory, { recursive: true });
740
+ // Fresh clone into the empty dir.
741
+ await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), found.origin.url, found.directory]);
742
+ } else if (subdir) {
624
743
  // Monorepo: no .git in place, so re-fetch subdir contents into tmp
625
744
  // and rsync them over the install dir (preserves .bahulam-plugin.json).
626
745
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bahulam-plugin-'));
@@ -640,7 +759,29 @@ async function cmdUpdate(args, cwd) {
640
759
  }
641
760
  writeStamp(found.directory, { origin: { ...found.origin, ref: ref || found.origin.ref } });
642
761
  const scan = readManifest(found.directory);
643
- process.stderr.write(`${GREEN}✓${RESET} Updated ${BOLD}${found.name}${RESET}${scan?.manifest?.metadata?.version ? ` to v${scan.manifest.metadata.version}` : ''}\n`);
762
+ const manifest = scan?.manifest;
763
+ process.stderr.write(`${GREEN}✓${RESET} Updated ${BOLD}${found.name}${RESET}${manifest?.metadata?.version ? ` to v${manifest.metadata.version}` : ''}\n`);
764
+
765
+ // Lifecycle: run pending migrations, then post_install. Seed does NOT
766
+ // re-run on update (it's a one-shot). If the update failed at
767
+ // migration time, the state DB is auto-restored via snapshot.
768
+ if (manifest) {
769
+ const pluginName = manifest.metadata?.name || found.name;
770
+ const stateFactory = () => makePluginState(pluginName, { tables: manifest.config?.state?.tables || [] });
771
+ try {
772
+ const migReport = await runMigrations({ pluginName, pluginDir: found.directory, manifest, stateFactory });
773
+ if (migReport.ran) process.stderr.write(` ${DIM}migrations${RESET} ${migReport.ran} applied\n`);
774
+ const postReport = await runPostInstall({ pluginName, pluginDir: found.directory, manifest, args, stateFactory });
775
+ if (postReport.ran) process.stderr.write(` ${DIM}post_install${RESET} ran\n`);
776
+ // Stamp last_upgrade_at
777
+ const rec = readLifecycleRecord(pluginName);
778
+ rec.last_upgrade_at = new Date().toISOString();
779
+ rec.installed_version = manifest.metadata?.version || rec.installed_version;
780
+ fs.writeFileSync(path.join(bahulamHome(), 'data', pluginName, '_bahulam_lifecycle.json'), JSON.stringify(rec, null, 2), 'utf-8');
781
+ } catch (err) {
782
+ throw new Error(`update completed but lifecycle failed: ${err.message}`);
783
+ }
784
+ }
644
785
  }
645
786
 
646
787
  // ── dispatcher ──────────────────────────────────────────────────────
@@ -823,7 +964,7 @@ export async function handlePluginManagementCommand(args, { cwd = process.cwd(),
823
964
  switch (args.action) {
824
965
  case 'validate': case 'check': case 'lint': await cmdValidate(args, cwd); return;
825
966
  case 'list': case 'ls': await cmdList(args, cwd); return;
826
- case 'remove': case 'rm': case 'uninstall': cmdRemove(args, cwd); return;
967
+ case 'remove': case 'rm': case 'uninstall': await cmdRemove(args, cwd); return;
827
968
  case 'enable': toggle(args, cwd, true); return;
828
969
  case 'disable': toggle(args, cwd, false); return;
829
970
  case 'info': cmdInfo(args, cwd); return;
@@ -22,6 +22,7 @@
22
22
  export function parseArgs(args) {
23
23
  const result = {
24
24
  prompt: null,
25
+ runtimeMode: null,
25
26
  model: null,
26
27
  permissionMode: null,
27
28
  outputFormat: null,
@@ -45,6 +46,13 @@ export function parseArgs(args) {
45
46
  showHelp: false,
46
47
  };
47
48
 
49
+ const setRuntimeMode = (mode) => {
50
+ if (result.runtimeMode && result.runtimeMode !== mode) {
51
+ throw new Error(`Runtime modes are mutually exclusive: --${result.runtimeMode} and --${mode}`);
52
+ }
53
+ result.runtimeMode = mode;
54
+ };
55
+
48
56
  for (let i = 0; i < args.length; i++) {
49
57
  const arg = args[i];
50
58
 
@@ -128,10 +136,22 @@ export function parseArgs(args) {
128
136
  break;
129
137
 
130
138
  case '--local':
131
- // Force LocalAgent path (bypass backend). Meant for benchmarks
132
- // that need to exercise the CLI's own LLM code — cache_control
133
- // wiring, prompt-cache stats, etc.
139
+ // Force CLI-side orchestration. Model calls still use the
140
+ // shared Bahulam Gateway; this bypasses backend /api/execute.
134
141
  result.local = true;
142
+ setRuntimeMode('local');
143
+ break;
144
+
145
+ case '--remote':
146
+ setRuntimeMode('remote');
147
+ break;
148
+
149
+ case '--bundled':
150
+ setRuntimeMode('bundled');
151
+ break;
152
+
153
+ case '--direct':
154
+ setRuntimeMode('direct');
135
155
  break;
136
156
 
137
157
  case '--vision': {
@@ -94,3 +94,12 @@ export function readShippedCatalog() {
94
94
  _cache = null;
95
95
  return null;
96
96
  }
97
+
98
+ export function findShippedModel(model) {
99
+ const wanted = String(model || '').trim();
100
+ if (!wanted) return null;
101
+ const catalog = readShippedCatalog() || [];
102
+ return catalog.find(row => row.id === wanted)
103
+ || catalog.find(row => row.id.endsWith(`/${wanted}`))
104
+ || null;
105
+ }
@@ -40,3 +40,19 @@ export const SHIPPED_MODEL_DEFAULTS = readShippedModelDefaults();
40
40
  export const DEFAULT_REASONING_MODEL = SHIPPED_MODEL_DEFAULTS.reasoning;
41
41
  export const DEFAULT_FAST_MODEL = SHIPPED_MODEL_DEFAULTS.fast;
42
42
  export const DEFAULT_PLANNING_MODEL = SHIPPED_MODEL_DEFAULTS.planning;
43
+
44
+ function envModel(names, fallback) {
45
+ for (const name of names) {
46
+ const value = typeof process !== 'undefined' ? process.env?.[name] : null;
47
+ if (typeof value === 'string' && value.trim()) return value.trim();
48
+ }
49
+ return fallback;
50
+ }
51
+
52
+ // Keep the named /model modes aligned with the backend chat mode matrix.
53
+ export const CHAT_MODE_DEFAULTS = Object.freeze({
54
+ fast: envModel(['BAHULAM_CHAT_FAST_MODEL'], DEFAULT_FAST_MODEL),
55
+ thinking: envModel(['BAHULAM_CHAT_THINKING_MODEL'], DEFAULT_PLANNING_MODEL),
56
+ extra_thinking: envModel(['BAHULAM_CHAT_EXTRA_THINKING_MODEL'], 'minimax/minimax-m3'),
57
+ max_thinking: envModel(['BAHULAM_CHAT_MAX_THINKING_MODEL'], 'deepseek/deepseek-v4-pro'),
58
+ });
@@ -154,9 +154,10 @@ export class ApprovalManager {
154
154
 
155
155
  setReadline(rl) { this._rl = rl; }
156
156
 
157
- setExecutionHooks({ onPause, onResume, onApprovalPromptEnd } = {}) {
157
+ setExecutionHooks({ onPause, onResume, onApprovalPromptStart, onApprovalPromptEnd } = {}) {
158
158
  this._execPause = onPause || null;
159
159
  this._execResume = onResume || null;
160
+ this._approvalPromptStart = onApprovalPromptStart || null;
160
161
  this._approvalPromptEnd = onApprovalPromptEnd || null;
161
162
  }
162
163
 
@@ -279,6 +280,12 @@ export class ApprovalManager {
279
280
 
280
281
  const isInteractive = process.stdin.isTTY;
281
282
 
283
+ // Shared lifecycle hook for every runtime. Hosts can leave a
284
+ // persistent approval marker before the transient dock is painted.
285
+ try {
286
+ this._approvalPromptStart?.({ tool: toolName, args, tier, why });
287
+ } catch { /* rendering must never block approval */ }
288
+
282
289
  // In rich TTY mode approval lives in the fixed input dock, replacing
283
290
  // "+ add instruction" until the user decides. Fallback/plain mode
284
291
  // still renders in the transcript.
@@ -465,6 +465,10 @@ function looksLikeAttachment(value) {
465
465
  return looksLikeImagePath(value) || looksLikeDocumentPath(value) || isClipboardAlias(value);
466
466
  }
467
467
 
468
+ export function looksLikeAttachmentReference(value) {
469
+ return looksLikeAttachment(value);
470
+ }
471
+
468
472
  /**
469
473
  * Parse @path references from `input` for BOTH images and documents.
470
474
  * Returns a cleaned instruction (with @refs removed) plus separate
@@ -65,3 +65,19 @@ export function resolveBackendUrl() {
65
65
  // 3. Fallback to production
66
66
  return BACKEND_URLS.production;
67
67
  }
68
+
69
+ /**
70
+ * Resolve the Gateway URL used by npm-owned orchestration.
71
+ *
72
+ * A local backend token must be validated by a Gateway configured against
73
+ * that same local backend; sending it to the production Gateway produces a
74
+ * misleading 401. Keep the explicit override for custom deployments.
75
+ */
76
+ export function resolveGatewayUrl() {
77
+ if (process.env.BAHULAM_GATEWAY_URL) {
78
+ return process.env.BAHULAM_GATEWAY_URL.replace(/\/+$/, '');
79
+ }
80
+ const env = (process.env.TARANG_ENV || process.env.NODE_ENV || 'production').toLowerCase();
81
+ if (env === 'local' || env === 'docker') return 'http://127.0.0.1:8180/v1';
82
+ return 'https://gateway.bahulam.ai/v1';
83
+ }