@bahulam/code 2.6.14 → 2.6.16

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": "2.6.14",
3
+ "version": "2.6.16",
4
4
  "description": "Bahulam Code \u2014 abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "start": "node src/terminal/main.mjs",
17
- "test": "BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-backend-url.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-agent-history.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-sse-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-tool-executor.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-project-artifacts.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-work-scope.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-skills.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-callback.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-rate-limit-display.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-preflight.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-formatter.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-terminal-rendering.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-input-dock.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-live-steering-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-slash-commands.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval-log.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-kepler-contract.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-session-manager.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-safety.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-jsonl-writer.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-analytics.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-stagnation.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-attachments.mjs"
17
+ "test": "BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-backend-url.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-agent-history.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-sse-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-tool-executor.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-project-artifacts.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-work-scope.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-skills.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-callback.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-rate-limit-display.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-preflight.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-formatter.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-terminal-rendering.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-render-queue.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-input-dock.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-live-steering-client.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-slash-commands.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-approval-log.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-kepler-contract.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-session-manager.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-safety.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-jsonl-writer.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-analytics.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-stagnation.mjs && BAHULAM_HOME=/tmp/bahulam-code-test-home node test/test-attachments.mjs"
18
18
  },
19
19
  "engines": {
20
20
  "node": ">=18.0.0"
@@ -49,8 +49,8 @@
49
49
  "web-tree-sitter": "^0.26.9"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@bahulam/runtime-darwin-arm64": "2.6.14",
53
- "@bahulam/runtime-linux-x64": "2.6.14",
54
- "@bahulam/runtime-win32-x64": "2.6.14"
52
+ "@bahulam/runtime-darwin-arm64": "2.6.16",
53
+ "@bahulam/runtime-linux-x64": "2.6.16",
54
+ "@bahulam/runtime-win32-x64": "2.6.16"
55
55
  }
56
56
  }
@@ -76,6 +76,12 @@ export class TarangAuth {
76
76
  models: this._config.models || {},
77
77
  configuredProviders: this._config.configured_providers || [],
78
78
  gatewayConfig: this._config.gateway_config || {},
79
+ // PRD-076 W7: persisted /model picks. `modelConfig` mirrors the
80
+ // Python-side key read_local_model_config() looks for; keep the
81
+ // snake_case JSON key so the runtime picks it up unchanged.
82
+ modelConfig: this._config.model_config || {},
83
+ modelMode: this._config.model_mode || null,
84
+ routePreference: this._config.route_preference || null,
79
85
  };
80
86
  }
81
87
 
@@ -29,6 +29,7 @@ export function parseArgs(args) {
29
29
  timeout: null,
30
30
  allowedTools: null,
31
31
  disallowedTools: null,
32
+ route: null,
32
33
  resume: false,
33
34
  resumeSessionId: null,
34
35
  headless: false,
@@ -49,6 +50,10 @@ export function parseArgs(args) {
49
50
  result.model = args[++i];
50
51
  break;
51
52
 
53
+ case '--route':
54
+ result.route = args[++i];
55
+ break;
56
+
52
57
  case '--permission-mode':
53
58
  result.permissionMode = args[++i];
54
59
  break;
@@ -169,7 +174,8 @@ export function getUsageText() {
169
174
  Usage: occ [options] [prompt]
170
175
 
171
176
  Options:
172
- --model, -m <model> Model to use (default: claude-sonnet-4-6)
177
+ --model, -m <id|role=id> Session model override (also named modes: fast|thinking|extra|max)
178
+ --route <platform|byok> Model route; platform validates against the curated catalog
173
179
  --permission-mode <mode> Permission mode (bypassPermissions, acceptEdits, plan, auto, dontAsk)
174
180
  --print, -p <prompt> Non-interactive mode: run prompt and exit
175
181
  --output-format <fmt> Output format: text, json, stream-json
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shipped model catalog snapshot — Node side of PRD-076 W7.
3
+ *
4
+ * The canonical source is `codekepler-backend/app/services/
5
+ * model_catalog_snapshot.json` (admin dashboard edits it and publishes to
6
+ * Supabase). `stage-runtime-app.sh` copies that file into every runtime
7
+ * wheel at `runtime/app/services/model_catalog_snapshot.json`, so when
8
+ * `@bahulam/runtime-<platform>-<arch>` is installed as a sibling optional
9
+ * dep of `@bahulam/code`, the file is already on disk — we read from
10
+ * there instead of maintaining a third copy in the CLI package.
11
+ */
12
+
13
+ import * as fs from 'node:fs';
14
+ import { runtimeSnapshotPath } from '../core/bundled-runtime.mjs';
15
+
16
+ let _cache = null;
17
+
18
+ // Snapshot rows are camelCase (matches how the backend delivers the
19
+ // static file); the /api/models response is snake_case. Normalize once
20
+ // here so downstream consumers can treat both sources identically.
21
+ function normalizeSnapshotRow(row) {
22
+ const id = row.value || row.id;
23
+ if (!id) return null;
24
+ return {
25
+ id,
26
+ provider: row.provider || (id.includes('/') ? id.split('/', 1)[0] : 'unknown'),
27
+ label: row.label || id,
28
+ input_cost_usd_per_m: row.inputCost ?? row.input_cost_usd_per_m ?? null,
29
+ output_cost_usd_per_m: row.outputCost ?? row.output_cost_usd_per_m ?? null,
30
+ context_length: row.context ?? row.context_length ?? null,
31
+ max_output: row.maxOutput ?? row.max_output ?? null,
32
+ supports_tools: row.supportsTools ?? row.supports_tools ?? false,
33
+ supports_reasoning: row.supportsReasoning ?? row.supports_reasoning ?? false,
34
+ harness_validated: row.harnessValidated ?? row.harness_validated ?? false,
35
+ cache_profile: row.cacheProfile ?? row.cache_profile ?? null,
36
+ platform_access_tier: row.platformAccessTier ?? row.platform_access_tier ?? [],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Read the shipped catalog snapshot from the installed runtime bundle.
42
+ * Returns an array of normalized rows (API-shape) or null if the runtime
43
+ * isn't installed / the file is unreadable. Cached after first success.
44
+ */
45
+ export function readShippedCatalog() {
46
+ if (_cache !== null) return _cache;
47
+ try {
48
+ const raw = fs.readFileSync(runtimeSnapshotPath(), 'utf-8');
49
+ const data = JSON.parse(raw);
50
+ const rows = Array.isArray(data?.models) ? data.models : [];
51
+ _cache = rows.map(normalizeSnapshotRow).filter(Boolean);
52
+ return _cache;
53
+ } catch {
54
+ _cache = null;
55
+ return null;
56
+ }
57
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { isSensitiveConfigPath } from './safety.mjs';
3
4
 
4
5
  const REDACTED = 'REDACTED';
5
6
  const MAX_ARG_LOG_CHARS = 4000;
@@ -53,10 +54,32 @@ function sanitizeForLog(value, depth = 0) {
53
54
  function safeArgs(args) {
54
55
  if (args?.command) return redactSensitive(String(args.command)).slice(0, MAX_ARG_LOG_CHARS);
55
56
  if (args?.file_path || args?.path) return redactSensitive(String(args.file_path || args.path)).slice(0, MAX_ARG_LOG_CHARS);
57
+ if (Array.isArray(args?.files)) {
58
+ try {
59
+ const { files, ...rest } = args;
60
+ return JSON.stringify({
61
+ ...sanitizeForLog(rest),
62
+ files: files.slice(0, 100).map(file => sanitizeProjectFileArg(file)),
63
+ }).slice(0, MAX_ARG_LOG_CHARS);
64
+ } catch {
65
+ return '[files omitted]';
66
+ }
67
+ }
56
68
  try { return JSON.stringify(sanitizeForLog(args || {})).slice(0, MAX_ARG_LOG_CHARS); }
57
69
  catch { return redactSensitive(String(args || '')).slice(0, MAX_ARG_LOG_CHARS); }
58
70
  }
59
71
 
72
+ function sanitizeProjectFileArg(file) {
73
+ if (typeof file === 'string') return redactSensitive(file);
74
+ if (!file || typeof file !== 'object') return sanitizeForLog(file);
75
+ const out = sanitizeForLog(file);
76
+ const filePath = file.path || file.file_path || '';
77
+ if (isSensitiveConfigPath(filePath) && typeof out.content === 'string') {
78
+ out.content = '[redacted sensitive config]';
79
+ }
80
+ return out;
81
+ }
82
+
60
83
  function safeText(value, max = 500) {
61
84
  if (!value) return undefined;
62
85
  return redactSensitive(String(value)).slice(0, max);
@@ -25,7 +25,7 @@ import {
25
25
  defaultOptions as approvalOptions,
26
26
  } from '../ui/approval.mjs';
27
27
  import { clearInputPrompt, isInputDockMounted, moveToContent, renderDockOverlay } from '../ui/input-dock.mjs';
28
- import { validateShellCommand } from './safety.mjs';
28
+ import { isApprovalRequiredWritePath, isSensitiveConfigPath, validateShellCommand } from './safety.mjs';
29
29
  import { classifyCommand } from '../permissions/command-classifier.mjs';
30
30
  import { ApprovalLog } from './approval-log.mjs';
31
31
  import { TrustStore } from './trust.mjs';
@@ -46,9 +46,19 @@ const WRITE_TOOLS = new Set([
46
46
 
47
47
  function defaultWhy(tier, tool, args) {
48
48
  const subject = approvalSummary(tool, args);
49
+ const protectedTarget = protectedWriteTarget(tool, args);
50
+ if (protectedTarget) {
51
+ const target = protectedTarget.path;
52
+ if (protectedTarget.sensitive) {
53
+ return `Edits sensitive environment config (${target}). Confirm before writing; values stay redacted in summaries and diffs.`;
54
+ }
55
+ return `Edits protected project file (${subject || target}). Confirm before writing.`;
56
+ }
49
57
  switch (tier) {
50
58
  case TIERS.SENSITIVE_READ:
51
59
  return `Reads a sensitive path (${subject || 'secret-like file'}). Confirm before exposing its contents to the agent.`;
60
+ case TIERS.PROTECTED_EDIT:
61
+ return `Edits a protected project file. Confirm before writing.`;
52
62
  case TIERS.SHELL_DANGEROUS:
53
63
  return `Shell command matches a high-risk pattern (rm -rf, sudo, force push, etc.). Confirm before running.`;
54
64
  case TIERS.DESTRUCTIVE:
@@ -62,6 +72,26 @@ function defaultWhy(tier, tool, args) {
62
72
  }
63
73
  }
64
74
 
75
+ function protectedWriteTarget(tool, args = {}) {
76
+ const paths = writeTargetPaths(tool, args);
77
+ const path = paths.find(isApprovalRequiredWritePath);
78
+ if (!path) return null;
79
+ return { path, sensitive: isSensitiveConfigPath(path) };
80
+ }
81
+
82
+ function writeTargetPaths(tool, args = {}) {
83
+ const key = String(tool || '').toLowerCase();
84
+ if (key === 'write_file' || key === 'edit_file') {
85
+ return [args.file_path || args.path].filter(Boolean);
86
+ }
87
+ if (key === 'write_project') {
88
+ return (args.files || [])
89
+ .map(file => typeof file === 'string' ? file : file?.path || file?.file_path)
90
+ .filter(Boolean);
91
+ }
92
+ return [];
93
+ }
94
+
65
95
  function approvalSummary(tool, args = {}) {
66
96
  const summary = toolDisplaySummary(tool, args);
67
97
  if (tool !== 'shell') return summary;
@@ -118,6 +148,8 @@ export class ApprovalManager {
118
148
  this.history = [];
119
149
  this.rejectionHints = [];
120
150
  this._rl = null;
151
+ this._approvalPromptActive = false;
152
+ this._approvalPromptWasRaw = false;
121
153
  }
122
154
 
123
155
  setReadline(rl) { this._rl = rl; }
@@ -128,6 +160,29 @@ export class ApprovalManager {
128
160
  this._approvalPromptEnd = onApprovalPromptEnd || null;
129
161
  }
130
162
 
163
+ _beginApprovalInput() {
164
+ if (this._approvalPromptActive || !process.stdin.isTTY) return false;
165
+ this._approvalPromptActive = true;
166
+ this._approvalPromptWasRaw = process.stdin.isRaw;
167
+ if (this._execPause) this._execPause();
168
+ if (this._rl) this._rl.pause();
169
+ if (typeof process.stdin.setRawMode === 'function') {
170
+ process.stdin.setRawMode(true);
171
+ }
172
+ process.stdin.resume();
173
+ return true;
174
+ }
175
+
176
+ _endApprovalInput() {
177
+ if (!this._approvalPromptActive) return;
178
+ if (typeof process.stdin.setRawMode === 'function') {
179
+ process.stdin.setRawMode(this._approvalPromptWasRaw || false);
180
+ }
181
+ if (this._rl) this._rl.resume();
182
+ if (this._execResume) this._execResume();
183
+ this._approvalPromptActive = false;
184
+ }
185
+
131
186
  revoke() {
132
187
  const wasActive = this.approveAll || this.approvedToolTypes.size > 0;
133
188
  this.approveAll = false;
@@ -275,6 +330,9 @@ export class ApprovalManager {
275
330
  printedHeight = block.split('\n').length;
276
331
  };
277
332
 
333
+ const ownsApprovalInput = isInteractive ? this._beginApprovalInput() : false;
334
+
335
+ try {
278
336
  if (isInteractive) drawPrompt();
279
337
 
280
338
  // ── Input loop ─────────────────────────────────────────────────
@@ -334,7 +392,7 @@ export class ApprovalManager {
334
392
  return { approved: true, tier };
335
393
 
336
394
  case 'allow-session': {
337
- if (!this.policy.hitl?.allowSessionTrust) return this._prompt(toolName, args, context);
395
+ if (!this.policy.hitl?.allowSessionTrust) return await this._prompt(toolName, args, context);
338
396
  const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'SESSION' });
339
397
  write(` ${GREEN}✓${RST} ${DIM}trusted for this session: ${rule.pattern}${RST}\n\n`);
340
398
  this.history.push({ tool: toolName, decision: 'session-trust', tier, time: Date.now(), rule_id: rule.id });
@@ -343,7 +401,7 @@ export class ApprovalManager {
343
401
  }
344
402
 
345
403
  case 'allow-project': {
346
- if (!this.policy.hitl?.allowProjectTrust) return this._prompt(toolName, args, context);
404
+ if (!this.policy.hitl?.allowProjectTrust) return await this._prompt(toolName, args, context);
347
405
  const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'PROJECT' });
348
406
  write(` ${GREEN}✓${RST} ${DIM}trusted for this project: ${rule.pattern}${RST}\n\n`);
349
407
  this.history.push({ tool: toolName, decision: 'project-trust', tier, time: Date.now(), rule_id: rule.id });
@@ -362,7 +420,7 @@ export class ApprovalManager {
362
420
  }
363
421
 
364
422
  case 'allow-all':
365
- if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
423
+ if (requiresExplicitApproval(tier)) return await this._prompt(toolName, args, context);
366
424
  this.approveAll = true;
367
425
  write(` ${GREEN}✓✓${RST} ${DIM}allow-all activated${RST}\n\n`);
368
426
  this.history.push({ tool: toolName, decision: 'approve-all', tier, time: Date.now() });
@@ -370,7 +428,7 @@ export class ApprovalManager {
370
428
  return { approved: true, tier };
371
429
 
372
430
  case 'allow-type':
373
- if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
431
+ if (requiresExplicitApproval(tier)) return await this._prompt(toolName, args, context);
374
432
  this.approvedToolTypes.add(toolName);
375
433
  this.history.push({ tool: toolName, decision: 'type-approve', tier, time: Date.now() });
376
434
  this.approvalLog.append({ tool: toolName, args, tier, decision: 'type-approve', scope: 'session', prompt });
@@ -380,7 +438,7 @@ export class ApprovalManager {
380
438
  case 'why':
381
439
  write(`\n ${DIM}${(context.reason || why).slice(0, 400)}${RST}\n\n`);
382
440
  printedHeight = 0;
383
- return this._prompt(toolName, args, context);
441
+ return await this._prompt(toolName, args, context);
384
442
 
385
443
  case 'edit':
386
444
  case 'replan':
@@ -395,7 +453,10 @@ export class ApprovalManager {
395
453
  }
396
454
 
397
455
  default:
398
- return this._prompt(toolName, args, context);
456
+ return await this._prompt(toolName, args, context);
457
+ }
458
+ } finally {
459
+ if (ownsApprovalInput) this._endApprovalInput();
399
460
  }
400
461
  }
401
462
 
@@ -406,16 +467,25 @@ export class ApprovalManager {
406
467
  return;
407
468
  }
408
469
 
409
- if (this._execPause) this._execPause();
410
- if (this._rl) this._rl.pause();
470
+ const managesInput = !this._approvalPromptActive;
471
+ if (managesInput) {
472
+ if (this._execPause) this._execPause();
473
+ if (this._rl) this._rl.pause();
474
+ }
411
475
 
412
476
  const wasRaw = process.stdin.isRaw;
413
- process.stdin.setRawMode(true);
477
+ if (managesInput && typeof process.stdin.setRawMode === 'function') {
478
+ process.stdin.setRawMode(true);
479
+ }
414
480
  process.stdin.resume();
415
481
  process.stdin.once('data', (data) => {
416
- process.stdin.setRawMode(wasRaw || false);
417
- if (this._rl) this._rl.resume();
418
- if (this._execResume) this._execResume();
482
+ if (managesInput) {
483
+ if (typeof process.stdin.setRawMode === 'function') {
484
+ process.stdin.setRawMode(wasRaw || false);
485
+ }
486
+ if (this._rl) this._rl.resume();
487
+ if (this._execResume) this._execResume();
488
+ }
419
489
 
420
490
  const bytes = [...data];
421
491
  const str = data.toString();
@@ -444,8 +514,11 @@ export class ApprovalManager {
444
514
  return;
445
515
  }
446
516
 
447
- if (this._execPause) this._execPause();
448
- if (this._rl) this._rl.pause();
517
+ const managesInput = !this._approvalPromptActive;
518
+ if (managesInput) {
519
+ if (this._execPause) this._execPause();
520
+ if (this._rl) this._rl.pause();
521
+ }
449
522
 
450
523
  const wasRaw = process.stdin.isRaw;
451
524
  if (typeof process.stdin.setRawMode === 'function') {
@@ -460,8 +533,10 @@ export class ApprovalManager {
460
533
  if (typeof process.stdin.setRawMode === 'function') {
461
534
  process.stdin.setRawMode(wasRaw || false);
462
535
  }
463
- if (this._rl) this._rl.resume();
464
- if (this._execResume) this._execResume();
536
+ if (managesInput) {
537
+ if (this._rl) this._rl.resume();
538
+ if (this._execResume) this._execResume();
539
+ }
465
540
  };
466
541
  const finish = () => {
467
542
  cleanup();
@@ -535,6 +610,7 @@ function promptForLog(tool, args, tier, why) {
535
610
  function approvalTitleForLog(tier) {
536
611
  switch (tier) {
537
612
  case TIERS.SENSITIVE_READ:
613
+ case TIERS.PROTECTED_EDIT:
538
614
  case TIERS.SHELL_DANGEROUS:
539
615
  case TIERS.DESTRUCTIVE:
540
616
  return 'EXPLICIT APPROVAL';
@@ -364,6 +364,18 @@ export function isRuntimeInstalled() {
364
364
  return fs.existsSync(_runtimeBin());
365
365
  }
366
366
 
367
+ /**
368
+ * Absolute path to the shipped model catalog snapshot inside the runtime
369
+ * bundle. Backend edits `app/services/model_catalog_snapshot.json` as the
370
+ * canonical source (admin dashboard publishes it to Supabase);
371
+ * stage-runtime-app.sh copies that same file into every runtime wheel at
372
+ * `runtime/app/services/model_catalog_snapshot.json`. The CLI reads from
373
+ * there so there is no third copy to keep in sync.
374
+ */
375
+ export function runtimeSnapshotPath() {
376
+ return path.join(_runtimeRoot(), 'runtime', 'app', 'services', 'model_catalog_snapshot.json');
377
+ }
378
+
367
379
  /** Diagnostic snapshot for `bahulam doctor`. */
368
380
  export function runtimeInfo() {
369
381
  return {
@@ -251,12 +251,16 @@ export function formatAgentErrorGuidance(data = {}) {
251
251
  lines.push('Upgrade your plan for a larger 5-hour message window, or switch to BYOK if available.');
252
252
  if (pricingUrl) lines.push(`Open ${pricingUrl} to upgrade.`);
253
253
  } else if (phase === 'gateway' || code.includes('gateway')) {
254
- const label = providerGuidance?.label || 'provider';
255
- lines.push(`The ${label} gateway failed before the agent could respond.`);
256
- if (providerGuidance) {
254
+ // Generic first: on the platform route the user has NO provider key —
255
+ // telling them to "check your DeepSeek API key" is wrong and alarming.
256
+ // Provider-key guidance only applies when we know the route is BYOK.
257
+ const label = providerGuidance?.label || compact(data.provider) || 'model provider';
258
+ lines.push(`The upstream ${label} request failed before the agent could respond — usually a transient provider-side error.`);
259
+ lines.push('Retry the turn. If it persists, switch models with /model and try again.');
260
+ if (compact(data.route) === 'byok' && providerGuidance) {
257
261
  lines.push(...providerGuidance.lines);
258
262
  } else {
259
- lines.push('Check the selected provider, model, and BYOK credentials in settings, then retry.');
263
+ lines.push('Using your own key (BYOK)? Verify it is saved and active in settings, then run /login so provider settings sync.');
260
264
  }
261
265
  } else if (/authentication|token/i.test(message)) {
262
266
  lines.push('Run /login to re-authenticate.');
@@ -8,7 +8,7 @@ export function buildFileDiff({
8
8
  after = '',
9
9
  cwd = process.cwd(),
10
10
  context = 3,
11
- maxDiffLines = 400,
11
+ maxDiffLines = Infinity,
12
12
  } = {}) {
13
13
  const oldText = before == null ? '' : String(before);
14
14
  const newText = after == null ? '' : String(after);
@@ -21,11 +21,11 @@ const MAX_ITERATIONS = 50;
21
21
  const TOOL_SCHEMAS = [
22
22
  {
23
23
  name: 'shell',
24
- description: 'Run one non-interactive shell command and return stdout/stderr. Do not use command substitution, backticks, or $(); run separate simple shell calls instead.',
24
+ description: 'Run one non-interactive shell command and return stdout/stderr. Command substitution (backticks, $()) is allowed but triggers a user-approval prompt; prefer plain commands when equivalent.',
25
25
  input_schema: {
26
26
  type: 'object',
27
27
  properties: {
28
- command: { type: 'string', description: 'The command to execute. Avoid backticks and $(); split dependent checks into separate commands.' },
28
+ command: { type: 'string', description: 'The command to execute. Backticks/$() are allowed (user approval required); use them when splitting would be awkward.' },
29
29
  timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000)' },
30
30
  },
31
31
  required: ['command'],
@@ -478,7 +478,7 @@ export class LocalAgent {
478
478
  'You are Bahulam Code, Bahulam\'s AI coding agent running in local mode.',
479
479
  'You have access to tools for reading, writing, and executing code.',
480
480
  'Use tools to accomplish the user\'s request. Be concise and direct.',
481
- 'For shell tools, never use command substitution, backticks, or $(). Run separate simple commands and carry values forward in text.',
481
+ 'Shell command substitution (backticks, $()) is allowed but requires user approval use it when it is the natural expression; otherwise prefer plain commands.',
482
482
  ];
483
483
  if (context.cwd) parts.push(`Working directory: ${context.cwd}`);
484
484
  if (context.gitBranch) parts.push(`Git branch: ${context.gitBranch}`);
@@ -14,12 +14,15 @@
14
14
  * intent can't be hidden behind a friendly description.
15
15
  */
16
16
 
17
+ import { isApprovalRequiredWritePath, isSensitiveConfigPath } from './safety.mjs';
18
+
17
19
  // ── Tier enum ────────────────────────────────────────────────────────────
18
20
 
19
21
  export const TIERS = Object.freeze({
20
22
  READ: 'read',
21
23
  SENSITIVE_READ: 'sensitive-read',
22
24
  LOCAL_EDIT: 'local-edit',
25
+ PROTECTED_EDIT: 'protected-edit',
23
26
  SHELL_SAFE: 'shell-safe',
24
27
  SHELL_MEDIUM: 'shell-medium',
25
28
  SHELL_DANGEROUS: 'shell-dangerous',
@@ -38,6 +41,7 @@ export const BEHAVIOR = Object.freeze({
38
41
  [TIERS.READ]: 'auto',
39
42
  [TIERS.SENSITIVE_READ]: 'prompt-explicit',
40
43
  [TIERS.LOCAL_EDIT]: 'auto-with-undo',
44
+ [TIERS.PROTECTED_EDIT]: 'prompt-explicit',
41
45
  [TIERS.SHELL_SAFE]: 'auto',
42
46
  [TIERS.SHELL_MEDIUM]: 'prompt-safe',
43
47
  [TIERS.SHELL_DANGEROUS]: 'prompt-explicit',
@@ -77,6 +81,14 @@ const LOCAL_EDIT_TOOLS = new Set([
77
81
  'workflow_create_multi', 'workflow_sync_multi',
78
82
  ]);
79
83
 
84
+ const WRITE_PATH_KEYS = [
85
+ 'path', 'file_path', 'file', 'target',
86
+ ];
87
+
88
+ const WRITE_PATH_ARRAY_KEYS = [
89
+ 'paths', 'file_paths', 'targets',
90
+ ];
91
+
80
92
  const DESTRUCTIVE_TOOLS = new Set([
81
93
  'delete_file', 'skill_remove',
82
94
  ]);
@@ -219,6 +231,7 @@ const TIER_ORDER = [
219
231
  TIERS.SENSITIVE_READ,
220
232
  TIERS.SHELL_SAFE,
221
233
  TIERS.LOCAL_EDIT,
234
+ TIERS.PROTECTED_EDIT,
222
235
  TIERS.NETWORK,
223
236
  TIERS.SHELL_MEDIUM,
224
237
  TIERS.DESTRUCTIVE,
@@ -243,7 +256,9 @@ export function classify(tool, args = {}) {
243
256
  if (READ_TOOLS.has(tool)) {
244
257
  return isSensitiveRead(args) ? TIERS.SENSITIVE_READ : TIERS.READ;
245
258
  }
246
- if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
259
+ if (LOCAL_EDIT_TOOLS.has(tool)) {
260
+ return isApprovalRequiredWrite(args) ? TIERS.PROTECTED_EDIT : TIERS.LOCAL_EDIT;
261
+ }
247
262
  if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
248
263
  if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
249
264
 
@@ -273,6 +288,7 @@ export function label(tier) {
273
288
  case TIERS.READ: return 'READ';
274
289
  case TIERS.SENSITIVE_READ: return 'SENSITIVE-READ';
275
290
  case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
291
+ case TIERS.PROTECTED_EDIT: return 'PROTECTED-EDIT';
276
292
  case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
277
293
  case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
278
294
  case TIERS.SHELL_DANGEROUS: return 'SHELL-DANGEROUS';
@@ -310,11 +326,15 @@ export function isSensitiveReadPath(filePath = '') {
310
326
 
311
327
  const parts = normalized.split('/').filter(Boolean);
312
328
  const base = parts[parts.length - 1] || normalized;
313
- if (base === '.env') return true;
329
+ if (isSensitiveConfigPath(base)) return true;
314
330
  if (/\.pem$/i.test(base)) return true;
315
331
  return parts.includes('secrets');
316
332
  }
317
333
 
334
+ export function isApprovalRequiredWrite(args = {}) {
335
+ return extractWritePaths(args).some(isApprovalRequiredWritePath);
336
+ }
337
+
318
338
  function extractReadPaths(args = {}) {
319
339
  const paths = [];
320
340
  for (const key of READ_PATH_KEYS) {
@@ -335,3 +355,34 @@ function extractReadPaths(args = {}) {
335
355
  }
336
356
  return paths;
337
357
  }
358
+
359
+ function extractWritePaths(args = {}) {
360
+ const paths = [];
361
+ for (const key of WRITE_PATH_KEYS) {
362
+ if (typeof args[key] === 'string') paths.push(args[key]);
363
+ }
364
+ for (const key of WRITE_PATH_ARRAY_KEYS) {
365
+ const value = args[key];
366
+ if (!Array.isArray(value)) continue;
367
+ for (const item of value) {
368
+ if (typeof item === 'string') {
369
+ paths.push(item);
370
+ } else if (item && typeof item === 'object') {
371
+ for (const nestedKey of WRITE_PATH_KEYS) {
372
+ if (typeof item[nestedKey] === 'string') paths.push(item[nestedKey]);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ if (Array.isArray(args.files)) {
378
+ for (const item of args.files) {
379
+ if (typeof item === 'string') paths.push(item);
380
+ else if (item && typeof item === 'object') {
381
+ for (const nestedKey of WRITE_PATH_KEYS) {
382
+ if (typeof item[nestedKey] === 'string') paths.push(item[nestedKey]);
383
+ }
384
+ }
385
+ }
386
+ }
387
+ return paths;
388
+ }