@bahulam/code 2.6.15 → 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.
@@ -29,6 +29,7 @@ import { createToolExecutor } from '../core/tool-executor.mjs';
29
29
  import { buildWorkScope, promptProjectRoots } from '../core/work-scope.mjs';
30
30
  import { CheckpointManager } from '../core/checkpoints.mjs';
31
31
  import { HookRunner } from '../config/hook-runner.mjs';
32
+ import { readShippedCatalog } from '../config/model-catalog.mjs';
32
33
  import { runPreflight } from '../onboarding/preflight.mjs';
33
34
  import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
34
35
  import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
@@ -42,6 +43,7 @@ import {
42
43
  import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
43
44
  import { TarangAuth } from '../auth/tarang-auth.mjs';
44
45
  import { ApprovalManager } from '../core/approval.mjs';
46
+ import * as telemetry from '../telemetry/index.mjs';
45
47
  import { resolveBackendUrl } from '../core/backend-url.mjs';
46
48
  import { formatMessageWindow, lowWindowStatus, messagesRemaining } from '../core/rate-limit-display.mjs';
47
49
  import { formatAgentErrorGuidance } from '../core/error-guidance.mjs';
@@ -49,6 +51,7 @@ import { BUILTIN_AGENTS, findBuiltinAgent, localAgentMatches, runAgent, runAgent
49
51
  import { createAgentFile, isVsCodeTerminal, listLocalAgents, openAgentFile, syncAgentsToBackend } from '../agents/scaffold.mjs';
50
52
  import { SessionManager } from '../core/session-manager.mjs';
51
53
  import { parseArgs } from '../config/cli-args.mjs';
54
+ import { pickModelOverridesForm } from './repl-model-form.mjs';
52
55
  import { loadEffectivePolicy, formatPolicySourceRows } from '../core/policy-resolver.mjs';
53
56
  import { loadProjectContext } from '../core/project-context-loader.mjs';
54
57
  import { buildContextEnvelope } from '../core/context-envelope.mjs';
@@ -72,8 +75,10 @@ import { toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
72
75
  import { exploreCategory, exploreCollapseEnabled, isExploreTool } from './repl-explore.mjs';
73
76
  import { session, orbitRef, sessionMgrRef, runtime } from './repl-state.mjs';
74
77
  import { safeCwd } from './repl-utils.mjs';
78
+ import * as rqueue from '../ui/render-queue.mjs';
75
79
  import {
76
80
  appendContent,
81
+ bumpSpinnerProgress,
77
82
  clearPendingHead,
78
83
  clippedThinking,
79
84
  expandIndex,
@@ -82,12 +87,14 @@ import {
82
87
  flushExploreRun,
83
88
  flushPendingHead,
84
89
  isInlineOutcomeTool,
90
+ pushSubAgentWindowLine,
85
91
  renderBlockBoundary,
86
92
  renderExploreRun,
87
93
  renderFileDiffEvent,
88
94
  renderStagnation,
89
95
  renderToolCall,
90
96
  renderToolResult,
97
+ setSubAgentWindowActive,
91
98
  startContentStream,
92
99
  startSpinner,
93
100
  stopSpinner,
@@ -321,16 +328,211 @@ function sessionModelOverrideEntries() {
321
328
  }
322
329
 
323
330
  function printModelCommandUsage() {
324
- process.stderr.write(` ${c.gray('Usage:')} /model [model]\n`);
331
+ process.stderr.write(` ${c.gray('Usage:')} /model interactive per-role form\n`);
332
+ process.stderr.write(` /model <model> set coding model directly\n`);
325
333
  process.stderr.write(` /model <role> <model>\n`);
326
- process.stderr.write(` /model clear [role]\n`);
334
+ process.stderr.write(` /model ${NAMED_MODEL_MODES_LIST.join('|')}\n`);
335
+ process.stderr.write(` /model list · status · refresh · clear [role]\n`);
327
336
  process.stderr.write(` ${c.gray('Roles:')} ${MODEL_ROLE_ORDER.map(role => MODEL_ROLE_LABELS[role]).join(', ')}\n`);
328
337
  }
329
338
 
339
+ // ── W7: curated platform catalog (PRD-076) ──
340
+ // Platform-route model overrides are validated against the backend's
341
+ // curated catalog (harness_validated models only). BYOK route skips
342
+ // validation entirely — the user's key, the user's models. Fail-open:
343
+ // if the catalog can't be fetched, the backend remains the enforcer.
344
+
345
+ const NAMED_MODEL_MODES_LIST = ['fast', 'thinking', 'extra', 'max'];
346
+ const NAMED_MODEL_MODES = new Set(NAMED_MODEL_MODES_LIST);
347
+
348
+ let _modelCatalogCache = null;
349
+ let _modelCatalogError = null;
350
+ let _modelCatalogSource = null; // 'snapshot' | 'backend'
351
+ let _backendRefreshInFlight = null;
352
+
353
+ async function fetchModelCatalog(ctx) {
354
+ // Seed from the shipped snapshot so /model list, /model form and the
355
+ // curation warnings work offline. The backend refresh below overlays
356
+ // any drift without blocking the UI.
357
+ if (!_modelCatalogCache) {
358
+ const shipped = readShippedCatalog();
359
+ if (shipped && shipped.length) {
360
+ _modelCatalogCache = shipped;
361
+ _modelCatalogSource = 'snapshot';
362
+ _modelCatalogError = null;
363
+ }
364
+ }
365
+
366
+ // Kick off a background backend refresh at most once per REPL session.
367
+ // Snapshot already served the caller — this only matters for the *next*
368
+ // /model list. Skip entirely when offline mode is forced.
369
+ if (!_backendRefreshInFlight && _modelCatalogSource !== 'backend' &&
370
+ process.env.BAHULAM_MODEL_CATALOG_OFFLINE !== '1') {
371
+ _backendRefreshInFlight = refreshCatalogFromBackend(ctx).finally(() => {
372
+ _backendRefreshInFlight = null;
373
+ });
374
+ }
375
+
376
+ if (_modelCatalogCache) return _modelCatalogCache;
377
+
378
+ // Snapshot missing (shouldn't happen in a shipped package) — fall back
379
+ // to whatever the backend refresh produces.
380
+ await _backendRefreshInFlight;
381
+ return _modelCatalogCache;
382
+ }
383
+
384
+ async function refreshCatalogFromBackend(ctx) {
385
+ try {
386
+ const creds = ctx?.auth?.loadCredentials?.();
387
+ if (!creds?.backendUrl) {
388
+ if (!_modelCatalogCache) _modelCatalogError = 'not logged in (no backend url)';
389
+ return;
390
+ }
391
+ const headers = { 'X-Product': 'bahulam' };
392
+ if (creds.token) headers['Authorization'] = `Bearer ${creds.token}`;
393
+ const resp = await fetch(`${creds.backendUrl}/api/models`, {
394
+ headers,
395
+ signal: AbortSignal.timeout(3000),
396
+ });
397
+ if (!resp.ok) {
398
+ if (!_modelCatalogCache) _modelCatalogError = `backend returned ${resp.status}`;
399
+ return;
400
+ }
401
+ const data = await resp.json();
402
+ const models = Array.isArray(data?.models) ? data.models : null;
403
+ if (models && models.length) {
404
+ _modelCatalogCache = models;
405
+ _modelCatalogSource = 'backend';
406
+ _modelCatalogError = null;
407
+ } else if (!_modelCatalogCache) {
408
+ _modelCatalogError = models ? 'catalog is empty' : 'unexpected response shape';
409
+ }
410
+ } catch (err) {
411
+ if (!_modelCatalogCache) {
412
+ _modelCatalogError = err?.name === 'TimeoutError' ? 'backend timeout (3s)' : 'backend unreachable';
413
+ }
414
+ }
415
+ }
416
+
417
+ function isByokModelRoute() {
418
+ return session.routePreference === 'byok' || (!session.routePreference && session.isByok);
419
+ }
420
+
421
+ // PRD-076 W7b: mirror session model state into ~/.bahulam/config.json so
422
+ // picks survive restarts and the bundled Python runtime — which already
423
+ // reads `model_config` via read_local_model_config() — sees them without
424
+ // requiring a live backend fetch.
425
+ function persistSessionModelState(ctx) {
426
+ try {
427
+ const auth = ctx?.auth;
428
+ if (!auth?.saveCredentials) return;
429
+ auth.saveCredentials({
430
+ model_config: { ...(session.modelOverrides || {}) },
431
+ model_mode: session.modelMode || null,
432
+ route_preference: session.routePreference || null,
433
+ });
434
+ } catch {
435
+ // Persistence is best-effort — never break the /model command over
436
+ // a config write failure.
437
+ }
438
+ }
439
+
440
+ function modelCreditBadge(model) {
441
+ const usd = Number(model?.input_cost_usd_per_m);
442
+ if (!Number.isFinite(usd) || usd <= 0) return '';
443
+ const credits = usd * 200; // credits = provider cost × 2 × 100/USD
444
+ return `~${credits < 10 ? credits.toFixed(1) : String(Math.round(credits))} cr/M in`;
445
+ }
446
+
447
+ async function warnIfNotCurated(model, ctx) {
448
+ if (isByokModelRoute()) return;
449
+ const catalog = await fetchModelCatalog(ctx);
450
+ if (!catalog) return;
451
+ const row = catalog.find(m => m?.id === model);
452
+ if (!row) {
453
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`${model} is not in the platform catalog — the backend may reject it. See /model list.`)}\n`);
454
+ } else if (row.harness_validated === false) {
455
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`${model} is not harness-validated for the platform route — cost/quality untuned. See /model list.`)}\n`);
456
+ }
457
+ }
458
+
459
+ async function printModelCatalog(ctx) {
460
+ const catalog = await fetchModelCatalog(ctx);
461
+ if (!catalog) {
462
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Model catalog unavailable — ${_modelCatalogError || 'unknown error'}.`)}\n`);
463
+ return;
464
+ }
465
+ const curated = catalog.filter(m => m?.harness_validated);
466
+ process.stderr.write(`\n ${c.bold('Platform catalog')} ${c.dim('(harness-validated, credit-priced)')}\n`);
467
+ process.stderr.write(` ${c.gray('─'.repeat(64))}\n`);
468
+ if (!curated.length) {
469
+ process.stderr.write(` ${c.dim('(none published yet)')}\n`);
470
+ }
471
+ for (const m of curated) {
472
+ const badge = modelCreditBadge(m);
473
+ const tiers = Array.isArray(m.platform_access_tier) && m.platform_access_tier.length
474
+ ? c.dim(` [${m.platform_access_tier.join(', ')}]`)
475
+ : '';
476
+ process.stderr.write(` ${c.brand(String(m.id || '').padEnd(38))} ${badge ? c.dim(badge.padEnd(16)) : ''.padEnd(16)}${tiers}\n`);
477
+ }
478
+ const rest = catalog.length - curated.length;
479
+ if (rest > 0) {
480
+ process.stderr.write(` ${c.dim(`+${rest} more models available on the BYOK route (--route byok, own API key)`)}\n`);
481
+ }
482
+ process.stderr.write('\n');
483
+ }
484
+
485
+ function applyLaunchModelArgs(cliArgs, ctx) {
486
+ const route = String(cliArgs.route || '').trim().toLowerCase();
487
+ if (route) {
488
+ if (route === 'platform' || route === 'byok') {
489
+ session.routePreference = route;
490
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Model route:')} ${c.brand(route)}\n`);
491
+ } else {
492
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Unknown --route ${cliArgs.route} (expected platform|byok)`)}\n`);
493
+ }
494
+ }
495
+
496
+ const value = String(cliArgs.model || '').trim();
497
+ if (!value) return Promise.resolve();
498
+
499
+ if (NAMED_MODEL_MODES.has(value.toLowerCase())) {
500
+ session.modelMode = value.toLowerCase();
501
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Session model mode:')} ${c.brand(session.modelMode)}\n`);
502
+ return Promise.resolve();
503
+ }
504
+
505
+ const pending = [];
506
+ for (const part of value.split(',').map(s => s.trim()).filter(Boolean)) {
507
+ const eq = part.indexOf('=');
508
+ let role = 'reasoning';
509
+ let model = part;
510
+ if (eq > 0) {
511
+ const maybeRole = normalizeModelRole(part.slice(0, eq));
512
+ if (!maybeRole) {
513
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Unknown model role in --model: ${part.slice(0, eq)}`)}\n`);
514
+ continue;
515
+ }
516
+ role = maybeRole;
517
+ model = part.slice(eq + 1).trim();
518
+ }
519
+ if (!model) continue;
520
+ session.modelOverrides = { ...(session.modelOverrides || {}), [role]: model };
521
+ if (role === 'reasoning') session.model = model;
522
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Session ${MODEL_ROLE_LABELS[role] || role} model:`)} ${c.brand(model)}\n`);
523
+ pending.push(warnIfNotCurated(model, ctx));
524
+ }
525
+ return Promise.all(pending);
526
+ }
527
+
330
528
  function printModelStatus() {
331
529
  process.stderr.write(`\n ${c.bold('Models')}\n`);
332
530
  process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
333
531
  process.stderr.write(` ${c.gray('Active coding')} ${session.model || 'backend default'}\n`);
532
+ process.stderr.write(` ${c.gray('Route ')} ${session.routePreference || (session.isByok ? 'byok' : 'platform')}\n`);
533
+ if (session.modelMode) {
534
+ process.stderr.write(` ${c.gray('Mode ')} ${session.modelMode}\n`);
535
+ }
334
536
 
335
537
  const limits = session.modelLimits || {};
336
538
  const rows = [
@@ -358,16 +560,124 @@ function printModelStatus() {
358
560
  printModelCommandUsage();
359
561
  }
360
562
 
361
- function handleModelCommand(rest = '') {
563
+ const MODEL_FORM_ROLES = ['reasoning', 'fast', 'orchestrator', 'explore', 'plan'];
564
+
565
+ async function openModelForm(ctx) {
566
+ const limits = session.modelLimits || {};
567
+ const defaultsByRole = {
568
+ reasoning: limits.coder?.model,
569
+ fast: limits.explorer?.model,
570
+ orchestrator: limits.orchestrator?.model,
571
+ explore: limits.explorer?.model,
572
+ plan: limits.orchestrator?.model,
573
+ };
574
+ const roles = MODEL_FORM_ROLES.map(role => ({
575
+ role,
576
+ label: MODEL_ROLE_LABELS[role] || role,
577
+ current: (session.modelOverrides || {})[role] || null,
578
+ defaultLabel: defaultsByRole[role] || null,
579
+ }));
580
+ const catalog = await fetchModelCatalog(ctx);
581
+ // No curated catalog (backend down, empty table, …): fall back to the
582
+ // distinct models the backend already reported for this session so the
583
+ // form is still navigable instead of a dead single-option row.
584
+ const fallbackIds = [...new Set([
585
+ ...Object.values(limits).map(l => l?.model),
586
+ ...Object.values(session.modelOverrides || {}),
587
+ ].filter(Boolean))];
588
+ const result = await pickModelOverridesForm({
589
+ rl: ctx?._rl || null,
590
+ roles,
591
+ catalog: catalog || [],
592
+ fallbackIds,
593
+ unavailableNote: catalog ? null : (_modelCatalogError || 'backend unreachable'),
594
+ });
595
+ if (!result) {
596
+ process.stderr.write(` ${c.dim('No model changes.')}\n`);
597
+ return;
598
+ }
599
+ // Merge: form rows replace their roles; overrides on roles the form
600
+ // doesn't show (verify/debug/…) are left untouched.
601
+ const next = { ...(session.modelOverrides || {}) };
602
+ for (const role of MODEL_FORM_ROLES) delete next[role];
603
+ Object.assign(next, result.overrides);
604
+ session.modelOverrides = next;
605
+ if (result.overrides.reasoning) session.model = result.overrides.reasoning;
606
+ persistSessionModelState(ctx);
607
+ const chosen = Object.entries(result.overrides);
608
+ if (!chosen.length) {
609
+ process.stderr.write(` ${c.green('✓')} ${c.dim('All roles back to backend defaults.')}\n`);
610
+ return;
611
+ }
612
+ for (const [role, model] of chosen) {
613
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`${MODEL_ROLE_LABELS[role] || role}:`)} ${c.brand(model)}\n`);
614
+ }
615
+ }
616
+
617
+ async function handleModelCommand(rest = '', ctx) {
362
618
  const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
363
619
  if (parts.length === 0) {
620
+ if (process.stdin.isTTY) {
621
+ await openModelForm(ctx);
622
+ } else {
623
+ printModelStatus();
624
+ }
625
+ return;
626
+ }
627
+
628
+ if (parts[0] === 'status') {
364
629
  printModelStatus();
365
630
  return;
366
631
  }
367
632
 
633
+ if (parts[0] === 'form') {
634
+ await openModelForm(ctx);
635
+ return;
636
+ }
637
+
638
+ if (parts[0] === 'list' || parts[0] === 'catalog') {
639
+ await printModelCatalog(ctx);
640
+ return;
641
+ }
642
+
643
+ if (parts[0] === 'refresh') {
644
+ // Bust the in-process catalog and pull a fresh /api/models. This only
645
+ // touches the in-memory cache — persisted /model picks in
646
+ // ~/.bahulam/config.json (model_config, model_mode, route_preference)
647
+ // are untouched, so session overrides survive the refresh.
648
+ _modelCatalogCache = null;
649
+ _modelCatalogError = null;
650
+ _modelCatalogSource = null;
651
+ _backendRefreshInFlight = null;
652
+ process.stderr.write(` ${c.dim('Refreshing model catalog…')}\n`);
653
+ await refreshCatalogFromBackend(ctx);
654
+ if (_modelCatalogSource !== 'backend') {
655
+ // Backend fetch failed — restore the shipped snapshot so subsequent
656
+ // /model commands still see a catalog.
657
+ const shipped = readShippedCatalog();
658
+ if (shipped && shipped.length) {
659
+ _modelCatalogCache = shipped;
660
+ _modelCatalogSource = 'snapshot';
661
+ }
662
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Backend refresh failed — ${_modelCatalogError || 'unknown error'}. Showing shipped snapshot.`)}\n`);
663
+ }
664
+ await printModelCatalog(ctx);
665
+ return;
666
+ }
667
+
668
+ if (parts.length === 1 && NAMED_MODEL_MODES.has(parts[0].toLowerCase())) {
669
+ session.modelMode = parts[0].toLowerCase();
670
+ persistSessionModelState(ctx);
671
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Session model mode:')} ${c.brand(session.modelMode)}\n`);
672
+ process.stderr.write(` ${c.dim('The platform maps this mode to pinned models. Use /model clear to reset.')}\n`);
673
+ return;
674
+ }
675
+
368
676
  if (parts[0] === 'clear' || parts[0] === 'reset') {
369
677
  if (parts.length === 1) {
370
678
  session.modelOverrides = {};
679
+ session.modelMode = null;
680
+ persistSessionModelState(ctx);
371
681
  process.stderr.write(` ${c.green('✓')} ${c.dim('Cleared all session model overrides.')}\n`);
372
682
  return;
373
683
  }
@@ -378,6 +688,7 @@ function handleModelCommand(rest = '') {
378
688
  return;
379
689
  }
380
690
  delete session.modelOverrides[role];
691
+ persistSessionModelState(ctx);
381
692
  process.stderr.write(` ${c.green('✓')} ${c.dim(`Cleared ${MODEL_ROLE_LABELS[role] || role} model override.`)}\n`);
382
693
  return;
383
694
  }
@@ -397,8 +708,10 @@ function handleModelCommand(rest = '') {
397
708
 
398
709
  session.modelOverrides = { ...(session.modelOverrides || {}), [role]: model };
399
710
  if (role === 'reasoning') session.model = model;
711
+ persistSessionModelState(ctx);
400
712
  process.stderr.write(` ${c.green('✓')} ${c.dim(`Session ${MODEL_ROLE_LABELS[role] || role} model override:`)} ${c.brand(model)}\n`);
401
713
  process.stderr.write(` ${c.dim('Use /model clear or /model clear <role> to return to backend settings.')}\n`);
714
+ await warnIfNotCurated(model, ctx);
402
715
  }
403
716
 
404
717
  function stripPathQuotes(value) {
@@ -634,8 +947,10 @@ function commandCompletions(line) {
634
947
  const top = ['/help', '/status', '/plan', '/tasks', '/history', '/settings', '/why'];
635
948
  const namespaced = HELP_GROUPS.flatMap(g => g.commands.map(([name]) => name.split(/\s+/)[0]));
636
949
  const all = [...new Set([...top, ...namespaced, ...Object.keys(COMMANDS), '/quit'])].sort();
637
- const hits = all.filter(cmd => cmd.startsWith(line));
638
- return hits.length ? hits : all;
950
+ // No fallback-to-all: a non-matching prefix ("/Users/...", a pasted
951
+ // path) must yield NOTHING so the hint overlay hides, not the full
952
+ // catalog. Bare "/" still matches every command via startsWith.
953
+ return all.filter(cmd => cmd.startsWith(line));
639
954
  }
640
955
 
641
956
  function slashCommandSuggestions(line, limit = 5) {
@@ -1081,7 +1396,10 @@ function renderEvent(event) {
1081
1396
  runtime.lastRenderedBlock = 'thinking';
1082
1397
  session._lastEmittedThinking = text;
1083
1398
  }
1084
- startSpinner(text.slice(0, 80));
1399
+ // Shared 'thinking' phase — the elapsed clock keeps counting
1400
+ // across successive thinking events instead of resetting per
1401
+ // thought, so the user sees "… · 24s" during long reasoning.
1402
+ startSpinner(text.slice(0, 80), { phase: 'thinking' });
1085
1403
  // Capture reasoning so /why can replay it.
1086
1404
  session.lastReasoning = text;
1087
1405
  }
@@ -1348,7 +1666,12 @@ function renderEvent(event) {
1348
1666
  runtime.lastRenderedBlock = 'subagent';
1349
1667
  session.inSubAgent = inSubAgentBlock(); // kept for legacy readers
1350
1668
  session.subAgentCounts[agentType] = (session.subAgentCounts[agentType] || 0) + 1;
1351
- startSpinner(`${agentType}: working...`);
1669
+ // Fixed-height live tool window under the spinner (queue mode):
1670
+ // inner tool calls stream here instead of flooding the transcript.
1671
+ setSubAgentWindowActive(true);
1672
+ // Phase per sub-agent run: the status line counts elapsed time and
1673
+ // tool calls live ("plan agent · 4 calls · 32s") for the whole run.
1674
+ startSpinner(`${agentType} agent`, { phase: `sub:${agentType}:${Date.now()}` });
1352
1675
  break;
1353
1676
  }
1354
1677
 
@@ -1358,16 +1681,24 @@ function renderEvent(event) {
1358
1681
  const agentType = data?.type || 'sub-agent';
1359
1682
  const tool = data?.tool || '';
1360
1683
  if (!tool) break;
1684
+ // Feed the live window from THIS event — it always fires (55/55 in
1685
+ // observed runs), unlike the inner tool_call render path which
1686
+ // diverts for explore-category tools and folded verbosity modes.
1687
+ // Dedup-consecutive inside the push keeps repeat tools quiet.
1688
+ pushSubAgentWindowLine(`→ ${tool}`);
1361
1689
  // Don't clobber an active explore-run spinner. "exploring · 5 read ·
1362
1690
  // 2 searched" is more informative than "explore → search_code", and
1363
1691
  // sub_agent_tool fires on every step of a sub-agent — otherwise the
1364
1692
  // spinner would flip-flop between the two texts and read as blank.
1365
1693
  if (runtime.exploreRun.lineActive && isExploreTool(tool)) break;
1694
+ // Same phase → clock keeps counting; the counter shows progress.
1695
+ bumpSpinnerProgress();
1366
1696
  updateSpinner(`${agentType} → ${tool}`);
1367
1697
  break;
1368
1698
  }
1369
1699
 
1370
1700
  case 'sub_agent_complete': {
1701
+ setSubAgentWindowActive(false);
1371
1702
  stopSpinner();
1372
1703
  clearPendingHead();
1373
1704
  flushFoldedSubAgentTools();
@@ -1479,6 +1810,8 @@ function renderEvent(event) {
1479
1810
  }
1480
1811
 
1481
1812
  case 'complete': {
1813
+ // Fire first_answer on the first turn's completion
1814
+ if (session.turns === 1 && session.user) telemetry.track('first_answer', {});
1482
1815
  stopSpinner();
1483
1816
  flushContent();
1484
1817
  flushFoldedSubAgentTools();
@@ -1887,6 +2220,8 @@ async function prepareDirectAgentRunContext(ctx, instruction = '') {
1887
2220
  execContext.model_overrides = modelOverrides;
1888
2221
  if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
1889
2222
  }
2223
+ if (session.modelMode) execContext.model_mode = session.modelMode;
2224
+ if (session.routePreference) execContext.model_route = session.routePreference;
1890
2225
  return execContext;
1891
2226
  }
1892
2227
 
@@ -2029,8 +2364,10 @@ async function handleCommand(input, ctx) {
2029
2364
 
2030
2365
  case '/login':
2031
2366
  process.stderr.write(`${c.brand('Starting login flow...')}\n`);
2367
+ telemetry.track('login_shown', { method: 'repl_command' });
2032
2368
  try {
2033
2369
  await ctx.auth.login();
2370
+ telemetry.track('login_completed', { method: 'repl_command' });
2034
2371
  process.stderr.write(`${c.green('✓ Login successful!')}\n`);
2035
2372
  await fetchUser(ctx);
2036
2373
  } catch (err) {
@@ -2471,7 +2808,7 @@ async function handleCommand(input, ctx) {
2471
2808
  }
2472
2809
 
2473
2810
  case '/model': {
2474
- handleModelCommand(rest);
2811
+ await handleModelCommand(rest, ctx);
2475
2812
  return;
2476
2813
  }
2477
2814
 
@@ -2536,6 +2873,67 @@ async function handleCommand(input, ctx) {
2536
2873
  return;
2537
2874
  }
2538
2875
 
2876
+ case '/auto': {
2877
+ // Session autopilot for long-running jobs: auto-approve routine
2878
+ // writes/shell while STILL prompting for dangerous tiers (rm,
2879
+ // force-push, command substitution, …) and never overriding hard
2880
+ // safety blocks. Distinct from the launch-time --freeswim flag,
2881
+ // which approves everything including dangerous tiers.
2882
+ const sub = (rest || '').trim().toLowerCase();
2883
+ if (sub === 'off') {
2884
+ ctx.approval.approveAll = false;
2885
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Auto mode off — approvals prompt again.')}\n`);
2886
+ return;
2887
+ }
2888
+ if (sub === '' || sub === 'on') {
2889
+ ctx.approval.approveAll = true;
2890
+ process.stderr.write(` ${c.green('✓')} ${c.bold('Auto mode on')} ${c.dim('— routine tool calls auto-approve this session.')}\n`);
2891
+ process.stderr.write(` ${c.dim('Still prompts: dangerous shell (rm/force-push/substitution), protected files.')}\n`);
2892
+ process.stderr.write(` ${c.dim('Hard safety blocks stay enforced. Disable with /auto off · inspect with /approvals.')}\n`);
2893
+ process.stderr.write(` ${c.dim('Tip: start your message with #auto to switch the backend agent into autonomous mode too.')}\n`);
2894
+ return;
2895
+ }
2896
+ // status / anything else → show current mode
2897
+ process.stderr.write(` ${c.dim('Approval mode:')} ${ctx.approval.getModeLabel()}\n`);
2898
+ process.stderr.write(` ${c.dim('Usage: /auto [on|off|status]')}\n`);
2899
+ return;
2900
+ }
2901
+
2902
+ case '/approvals': {
2903
+ const parts = (rest || '').trim().split(/\s+/).filter(Boolean);
2904
+ const action = (parts[0] || 'list').toLowerCase();
2905
+ if (action === 'clear') {
2906
+ const wasActive = ctx.approval.revoke();
2907
+ process.stderr.write(wasActive
2908
+ ? ` ${c.green('✓')} ${c.dim('All session approvals cleared.')}\n`
2909
+ : ` ${c.gray('No session approvals were active.')}\n`);
2910
+ return;
2911
+ }
2912
+ if (action === 'allow' && parts[1]) {
2913
+ const tool = parts[1];
2914
+ ctx.approval.approvedToolTypes.add(tool);
2915
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Auto-approving ${tool} for this session (dangerous tiers still prompt).`)}\n`);
2916
+ return;
2917
+ }
2918
+ if (action === 'remove' && parts[1]) {
2919
+ const removed = ctx.approval.approvedToolTypes.delete(parts[1]);
2920
+ process.stderr.write(removed
2921
+ ? ` ${c.green('✓')} ${c.dim(`${parts[1]} will prompt again.`)}\n`
2922
+ : ` ${c.gray(`${parts[1]} had no session grant.`)}\n`);
2923
+ return;
2924
+ }
2925
+ // list (default)
2926
+ const s = ctx.approval.getSummary();
2927
+ process.stderr.write(`\n ${c.bold('Session approvals')}\n`);
2928
+ process.stderr.write(` ${c.dim('Mode')} ${ctx.approval.getModeLabel()}\n`);
2929
+ process.stderr.write(` ${c.dim('Allow-all')} ${s.autoApproveAll ? c.green('on') : c.dim('off')}\n`);
2930
+ process.stderr.write(` ${c.dim('Tool grants')} ${s.autoApprovedTypes.length ? s.autoApprovedTypes.join(', ') : c.dim('none')}\n`);
2931
+ process.stderr.write(` ${c.dim('Trust rules')} ${c.dim(`${s.trust.sessionRules || 0} session · ${s.trust.projectRules || 0} project`)}\n`);
2932
+ process.stderr.write(` ${c.dim('Decisions')} ${c.dim(`${s.approved} approved · ${s.denied} denied`)}\n`);
2933
+ process.stderr.write(` ${c.dim('Edit: /approvals allow <tool> · /approvals remove <tool> · /approvals clear · /auto [on|off]')}\n\n`);
2934
+ return;
2935
+ }
2936
+
2539
2937
  case '/sessions': {
2540
2938
  const resumable = await listResumableSessions();
2541
2939
  if (resumable.length === 0) {
@@ -2862,6 +3260,8 @@ export async function startTerminalRepl() {
2862
3260
  model: session.model,
2863
3261
  modelLimits: session.modelLimits,
2864
3262
  modelOverrides: session.modelOverrides,
3263
+ modelMode: session.modelMode,
3264
+ routePreference: session.routePreference,
2865
3265
  isByok: session.isByok,
2866
3266
  subscriptionTier: session.subscriptionTier,
2867
3267
  creditsTotal: session.creditsTotal,
@@ -2904,6 +3304,8 @@ export async function startTerminalRepl() {
2904
3304
  model: preserved.model,
2905
3305
  modelLimits: preserved.modelLimits,
2906
3306
  modelOverrides: preserved.modelOverrides,
3307
+ modelMode: preserved.modelMode,
3308
+ routePreference: preserved.routePreference,
2907
3309
  blockedOps: 0,
2908
3310
  delegations: [],
2909
3311
  phases: [],
@@ -3186,6 +3588,25 @@ export async function startTerminalRepl() {
3186
3588
  // Clear the spinner line
3187
3589
  process.stderr.write(`\r${' '.repeat(60)}\r`);
3188
3590
  process.stderr.write(` ${c.green('✓')} ${c.dim('Ready; projects will be indexed on demand')}\n`);
3591
+
3592
+ // PRD-076 W7b: restore persisted /model picks from ~/.bahulam/config.json
3593
+ // before applying CLI-flag overrides, so `bahulam-code` alone re-uses
3594
+ // last session's choices and `bahulam-code --model foo` still wins.
3595
+ try {
3596
+ const persisted = auth.loadCredentials();
3597
+ if (persisted?.modelConfig && Object.keys(persisted.modelConfig).length) {
3598
+ session.modelOverrides = { ...persisted.modelConfig };
3599
+ if (persisted.modelConfig.reasoning) session.model = persisted.modelConfig.reasoning;
3600
+ }
3601
+ if (persisted?.modelMode) session.modelMode = persisted.modelMode;
3602
+ if (persisted?.routePreference) session.routePreference = persisted.routePreference;
3603
+ } catch { /* best-effort restore */ }
3604
+
3605
+ // --model / --route launch overrides (after fetchUser so they win
3606
+ // over the profile default; catalog validation is fail-open).
3607
+ if (cliArgs.model || cliArgs.route) {
3608
+ try { await applyLaunchModelArgs(cliArgs, ctx); } catch {}
3609
+ }
3189
3610
  if (session.user) {
3190
3611
  process.stderr.write(` ${c.green('✓')} ${c.dim(`Logged in as ${session.user.github_username || session.user.email || 'user'}`)}\n`);
3191
3612
  }
@@ -3412,50 +3833,92 @@ export async function startTerminalRepl() {
3412
3833
  readline.cursorTo(process.stderr, col);
3413
3834
  }
3414
3835
 
3415
- function renderSlashHint(line = '', { preserveSelection = false } = {}) {
3836
+ // Hint frames must NOT go through the patched std streams — under the
3837
+ // render queue, redirected writes are sanitized (cursor CSI stripped)
3838
+ // and serialized into the transcript. That's exactly the "pasted a
3839
+ // path, command list flooded the message area" bug. queue.raw() is the
3840
+ // serialized trusted channel for cursor-addressed frame writes;
3841
+ // readline helpers are the legacy no-queue path.
3842
+ function writeHintFrame(frame) {
3843
+ if (rqueue.isActive()) {
3844
+ rqueue.raw(frame);
3845
+ return;
3846
+ }
3847
+ process.stderr.write(frame);
3848
+ }
3849
+
3850
+ let slashHintTimer = null;
3851
+
3852
+ function renderSlashHintNow(line = '', { preserveSelection = false } = {}) {
3416
3853
  if (!process.stderr.isTTY || term().plain || !inputActive || !promptBottomPaddingLines()) return;
3417
3854
  const rows = promptBottomPaddingLines();
3418
3855
  const suggestions = slashCommandSuggestions(line, Math.min(5, rows));
3856
+ // Nothing matches (pasted path, typo) → hide instead of rendering
3857
+ // an empty/robotic frame.
3858
+ if (!suggestions.length) {
3859
+ if (slashHintVisible) clearSlashHint();
3860
+ return;
3861
+ }
3419
3862
  const cols = process.stdout.columns || 80;
3420
3863
  if (!preserveSelection || line !== slashHintLine) slashHintSelected = 0;
3421
3864
  slashHintItems = suggestions;
3422
3865
  slashHintLine = line;
3423
3866
  if (slashHintSelected >= slashHintItems.length) slashHintSelected = Math.max(0, slashHintItems.length - 1);
3424
3867
 
3425
- readline.moveCursor(process.stderr, 0, 1);
3868
+ // Compose the whole frame as ONE write: down a row, paint each hint
3869
+ // row (clear + text), then back up to the input row.
3870
+ let frame = '\x1b[1B';
3426
3871
  for (let i = 0; i < rows; i++) {
3427
- readline.clearLine(process.stderr, 0);
3428
- readline.cursorTo(process.stderr, 0);
3872
+ frame += '\x1b[2K\x1b[G';
3429
3873
  const item = suggestions[i];
3430
3874
  if (item) {
3431
3875
  const marker = i === slashHintSelected ? c.brand('›') : c.dim(' ');
3432
3876
  const command = item.command.padEnd(13);
3433
3877
  const maxDesc = Math.max(0, cols - 21);
3434
3878
  const desc = truncateHintText(item.description, maxDesc);
3435
- process.stderr.write(` ${marker} ${c.brand(command)}${desc ? c.dim(desc) : ''}`);
3879
+ frame += ` ${marker} ${c.brand(command)}${desc ? c.dim(desc) : ''}`;
3436
3880
  }
3437
- if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
3881
+ if (i < rows - 1) frame += '\x1b[1B';
3438
3882
  }
3439
- readline.moveCursor(process.stderr, 0, -rows);
3883
+ frame += `\x1b[${rows}A`;
3884
+ writeHintFrame(frame);
3440
3885
  restoreReadlineCursor();
3441
- slashHintVisible = suggestions.length > 0;
3886
+ slashHintVisible = true;
3442
3887
  slashHintRowsVisible = rows;
3443
3888
  }
3444
3889
 
3890
+ // Debounced entry point: a bracketed paste delivers the buffer as many
3891
+ // rapid input events — rendering per event is what turned one paste
3892
+ // into dozens of hint frames. Selection-preserving calls (arrow keys)
3893
+ // stay immediate for responsiveness.
3894
+ function renderSlashHint(line = '', opts = {}) {
3895
+ if (opts.preserveSelection) {
3896
+ if (slashHintTimer) { clearTimeout(slashHintTimer); slashHintTimer = null; }
3897
+ renderSlashHintNow(line, opts);
3898
+ return;
3899
+ }
3900
+ if (slashHintTimer) clearTimeout(slashHintTimer);
3901
+ slashHintTimer = setTimeout(() => {
3902
+ slashHintTimer = null;
3903
+ renderSlashHintNow(typeof rl?.line === 'string' ? rl.line : line, opts);
3904
+ }, 24);
3905
+ }
3906
+
3445
3907
  function clearSlashHint({ restoreCursor: shouldRestoreCursor = true } = {}) {
3908
+ if (slashHintTimer) { clearTimeout(slashHintTimer); slashHintTimer = null; }
3446
3909
  if (!slashHintVisible || !process.stderr.isTTY || term().plain) {
3447
3910
  slashHintVisible = false;
3448
3911
  slashHintRowsVisible = 0;
3449
3912
  return;
3450
3913
  }
3451
3914
  const rows = slashHintRowsVisible || promptBottomPaddingLines() || 1;
3452
- readline.moveCursor(process.stderr, 0, 1);
3915
+ let frame = '\x1b[1B';
3453
3916
  for (let i = 0; i < rows; i++) {
3454
- readline.clearLine(process.stderr, 0);
3455
- readline.cursorTo(process.stderr, 0);
3456
- if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
3917
+ frame += '\x1b[2K\x1b[G';
3918
+ if (i < rows - 1) frame += '\x1b[1B';
3457
3919
  }
3458
- readline.moveCursor(process.stderr, 0, -rows);
3920
+ frame += `\x1b[${rows}A`;
3921
+ writeHintFrame(frame);
3459
3922
  // The dock's bottom rule + tips row live in the rows we just cleared.
3460
3923
  // Repaint the frame (input row untouched) so they reappear.
3461
3924
  if (isInputDockMounted()) redrawDockFrame();
@@ -3852,6 +4315,9 @@ export async function startTerminalRepl() {
3852
4315
  session.history.push(userMessage);
3853
4316
  session.agentHistory.push(userMessage);
3854
4317
  session.turns++;
4318
+ // Fire first_prompt on user's first turn
4319
+ if (session.turns === 1) telemetry.track('first_prompt', {});
4320
+
3855
4321
  session.toolCalls = 0;
3856
4322
  session.subAgentToolCalls = 0;
3857
4323
  session.lastTask = originalInput;
@@ -4294,6 +4760,8 @@ export async function startTerminalRepl() {
4294
4760
  execContext.model_overrides = modelOverrides;
4295
4761
  if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
4296
4762
  }
4763
+ if (session.modelMode) execContext.model_mode = session.modelMode;
4764
+ if (session.routePreference) execContext.model_route = session.routePreference;
4297
4765
  // PRD-071: seed work_scope from CLI so the backend has a byte-stable
4298
4766
  // scope block from turn 1. Uses projectResources already gathered by
4299
4767
  // the envelope above.