@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 +5 -5
- package/src/auth/tarang-auth.mjs +6 -0
- package/src/config/cli-args.mjs +7 -1
- package/src/config/model-catalog.mjs +57 -0
- package/src/core/approval-log.mjs +23 -0
- package/src/core/approval.mjs +93 -17
- package/src/core/bundled-runtime.mjs +12 -0
- package/src/core/error-guidance.mjs +8 -4
- package/src/core/file-diff.mjs +1 -1
- package/src/core/local-agent.mjs +3 -3
- package/src/core/risk-tier.mjs +53 -2
- package/src/core/safety.mjs +61 -4
- package/src/core/tool-executor.mjs +25 -13
- package/src/core/trust.mjs +5 -3
- package/src/index.mjs +7 -3
- package/src/permissions/command-classifier.mjs +50 -2
- package/src/telemetry/index.mjs +97 -71
- package/src/terminal/main.mjs +40 -0
- package/src/terminal/repl-format.mjs +64 -4
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +256 -33
- package/src/terminal/repl-resume.mjs +27 -12
- package/src/terminal/repl-state.mjs +16 -0
- package/src/terminal/repl.mjs +644 -85
- package/src/terminal/tool-display.mjs +20 -1
- package/src/ui/approval.mjs +33 -5
- package/src/ui/input-dock.mjs +192 -16
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +2 -0
- package/src/ui/sub-agent.mjs +17 -2
- package/src/ui/tool-card.mjs +156 -13
- package/src/ui/tool-details.mjs +96 -3
package/src/terminal/repl.mjs
CHANGED
|
@@ -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,14 +87,18 @@ import {
|
|
|
82
87
|
flushExploreRun,
|
|
83
88
|
flushPendingHead,
|
|
84
89
|
isInlineOutcomeTool,
|
|
90
|
+
pushSubAgentWindowLine,
|
|
85
91
|
renderBlockBoundary,
|
|
86
92
|
renderExploreRun,
|
|
93
|
+
renderFileDiffEvent,
|
|
87
94
|
renderStagnation,
|
|
88
95
|
renderToolCall,
|
|
89
96
|
renderToolResult,
|
|
97
|
+
setSubAgentWindowActive,
|
|
90
98
|
startContentStream,
|
|
91
99
|
startSpinner,
|
|
92
100
|
stopSpinner,
|
|
101
|
+
transcriptRenderableLines,
|
|
93
102
|
thinkingPrefix,
|
|
94
103
|
updateSpinner,
|
|
95
104
|
} from './repl-render.mjs';
|
|
@@ -319,16 +328,211 @@ function sessionModelOverrideEntries() {
|
|
|
319
328
|
}
|
|
320
329
|
|
|
321
330
|
function printModelCommandUsage() {
|
|
322
|
-
process.stderr.write(` ${c.gray('Usage:')} /model
|
|
331
|
+
process.stderr.write(` ${c.gray('Usage:')} /model interactive per-role form\n`);
|
|
332
|
+
process.stderr.write(` /model <model> set coding model directly\n`);
|
|
323
333
|
process.stderr.write(` /model <role> <model>\n`);
|
|
324
|
-
process.stderr.write(` /model
|
|
334
|
+
process.stderr.write(` /model ${NAMED_MODEL_MODES_LIST.join('|')}\n`);
|
|
335
|
+
process.stderr.write(` /model list · status · refresh · clear [role]\n`);
|
|
325
336
|
process.stderr.write(` ${c.gray('Roles:')} ${MODEL_ROLE_ORDER.map(role => MODEL_ROLE_LABELS[role]).join(', ')}\n`);
|
|
326
337
|
}
|
|
327
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
|
+
|
|
328
528
|
function printModelStatus() {
|
|
329
529
|
process.stderr.write(`\n ${c.bold('Models')}\n`);
|
|
330
530
|
process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
|
|
331
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
|
+
}
|
|
332
536
|
|
|
333
537
|
const limits = session.modelLimits || {};
|
|
334
538
|
const rows = [
|
|
@@ -356,16 +560,124 @@ function printModelStatus() {
|
|
|
356
560
|
printModelCommandUsage();
|
|
357
561
|
}
|
|
358
562
|
|
|
359
|
-
|
|
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) {
|
|
360
618
|
const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
|
|
361
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') {
|
|
362
629
|
printModelStatus();
|
|
363
630
|
return;
|
|
364
631
|
}
|
|
365
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
|
+
|
|
366
676
|
if (parts[0] === 'clear' || parts[0] === 'reset') {
|
|
367
677
|
if (parts.length === 1) {
|
|
368
678
|
session.modelOverrides = {};
|
|
679
|
+
session.modelMode = null;
|
|
680
|
+
persistSessionModelState(ctx);
|
|
369
681
|
process.stderr.write(` ${c.green('✓')} ${c.dim('Cleared all session model overrides.')}\n`);
|
|
370
682
|
return;
|
|
371
683
|
}
|
|
@@ -376,6 +688,7 @@ function handleModelCommand(rest = '') {
|
|
|
376
688
|
return;
|
|
377
689
|
}
|
|
378
690
|
delete session.modelOverrides[role];
|
|
691
|
+
persistSessionModelState(ctx);
|
|
379
692
|
process.stderr.write(` ${c.green('✓')} ${c.dim(`Cleared ${MODEL_ROLE_LABELS[role] || role} model override.`)}\n`);
|
|
380
693
|
return;
|
|
381
694
|
}
|
|
@@ -395,8 +708,10 @@ function handleModelCommand(rest = '') {
|
|
|
395
708
|
|
|
396
709
|
session.modelOverrides = { ...(session.modelOverrides || {}), [role]: model };
|
|
397
710
|
if (role === 'reasoning') session.model = model;
|
|
711
|
+
persistSessionModelState(ctx);
|
|
398
712
|
process.stderr.write(` ${c.green('✓')} ${c.dim(`Session ${MODEL_ROLE_LABELS[role] || role} model override:`)} ${c.brand(model)}\n`);
|
|
399
713
|
process.stderr.write(` ${c.dim('Use /model clear or /model clear <role> to return to backend settings.')}\n`);
|
|
714
|
+
await warnIfNotCurated(model, ctx);
|
|
400
715
|
}
|
|
401
716
|
|
|
402
717
|
function stripPathQuotes(value) {
|
|
@@ -632,8 +947,10 @@ function commandCompletions(line) {
|
|
|
632
947
|
const top = ['/help', '/status', '/plan', '/tasks', '/history', '/settings', '/why'];
|
|
633
948
|
const namespaced = HELP_GROUPS.flatMap(g => g.commands.map(([name]) => name.split(/\s+/)[0]));
|
|
634
949
|
const all = [...new Set([...top, ...namespaced, ...Object.keys(COMMANDS), '/quit'])].sort();
|
|
635
|
-
|
|
636
|
-
|
|
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));
|
|
637
954
|
}
|
|
638
955
|
|
|
639
956
|
function slashCommandSuggestions(line, limit = 5) {
|
|
@@ -1079,7 +1396,10 @@ function renderEvent(event) {
|
|
|
1079
1396
|
runtime.lastRenderedBlock = 'thinking';
|
|
1080
1397
|
session._lastEmittedThinking = text;
|
|
1081
1398
|
}
|
|
1082
|
-
|
|
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' });
|
|
1083
1403
|
// Capture reasoning so /why can replay it.
|
|
1084
1404
|
session.lastReasoning = text;
|
|
1085
1405
|
}
|
|
@@ -1099,17 +1419,20 @@ function renderEvent(event) {
|
|
|
1099
1419
|
}
|
|
1100
1420
|
}
|
|
1101
1421
|
if (text) {
|
|
1102
|
-
renderBlockBoundary('content');
|
|
1103
|
-
if (!runtime.contentHeaderPrinted) {
|
|
1104
|
-
process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
|
|
1105
|
-
runtime.contentHeaderPrinted = true;
|
|
1106
|
-
}
|
|
1107
1422
|
const rendered = renderMarkdown(text);
|
|
1108
|
-
|
|
1109
|
-
|
|
1423
|
+
const lines = transcriptRenderableLines(rendered);
|
|
1424
|
+
if (lines.length) {
|
|
1425
|
+
renderBlockBoundary('content', { compactSame: true });
|
|
1426
|
+
if (!runtime.contentHeaderPrinted) {
|
|
1427
|
+
process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
|
|
1428
|
+
runtime.contentHeaderPrinted = true;
|
|
1429
|
+
}
|
|
1430
|
+
for (const line of lines) {
|
|
1431
|
+
process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
|
|
1432
|
+
}
|
|
1433
|
+
runtime.renderedContentThisTurn = true;
|
|
1434
|
+
runtime.lastRenderedBlock = 'content';
|
|
1110
1435
|
}
|
|
1111
|
-
runtime.renderedContentThisTurn = true;
|
|
1112
|
-
runtime.lastRenderedBlock = 'content';
|
|
1113
1436
|
}
|
|
1114
1437
|
break;
|
|
1115
1438
|
}
|
|
@@ -1229,6 +1552,14 @@ function renderEvent(event) {
|
|
|
1229
1552
|
break;
|
|
1230
1553
|
}
|
|
1231
1554
|
|
|
1555
|
+
case 'file_diff': {
|
|
1556
|
+
stopSpinner();
|
|
1557
|
+
flushContent();
|
|
1558
|
+
flushPendingHead();
|
|
1559
|
+
renderFileDiffEvent(data);
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1232
1563
|
case 'plan': {
|
|
1233
1564
|
stopSpinner();
|
|
1234
1565
|
flushContent();
|
|
@@ -1335,7 +1666,12 @@ function renderEvent(event) {
|
|
|
1335
1666
|
runtime.lastRenderedBlock = 'subagent';
|
|
1336
1667
|
session.inSubAgent = inSubAgentBlock(); // kept for legacy readers
|
|
1337
1668
|
session.subAgentCounts[agentType] = (session.subAgentCounts[agentType] || 0) + 1;
|
|
1338
|
-
|
|
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()}` });
|
|
1339
1675
|
break;
|
|
1340
1676
|
}
|
|
1341
1677
|
|
|
@@ -1345,16 +1681,24 @@ function renderEvent(event) {
|
|
|
1345
1681
|
const agentType = data?.type || 'sub-agent';
|
|
1346
1682
|
const tool = data?.tool || '';
|
|
1347
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}`);
|
|
1348
1689
|
// Don't clobber an active explore-run spinner. "exploring · 5 read ·
|
|
1349
1690
|
// 2 searched" is more informative than "explore → search_code", and
|
|
1350
1691
|
// sub_agent_tool fires on every step of a sub-agent — otherwise the
|
|
1351
1692
|
// spinner would flip-flop between the two texts and read as blank.
|
|
1352
1693
|
if (runtime.exploreRun.lineActive && isExploreTool(tool)) break;
|
|
1694
|
+
// Same phase → clock keeps counting; the counter shows progress.
|
|
1695
|
+
bumpSpinnerProgress();
|
|
1353
1696
|
updateSpinner(`${agentType} → ${tool}`);
|
|
1354
1697
|
break;
|
|
1355
1698
|
}
|
|
1356
1699
|
|
|
1357
1700
|
case 'sub_agent_complete': {
|
|
1701
|
+
setSubAgentWindowActive(false);
|
|
1358
1702
|
stopSpinner();
|
|
1359
1703
|
clearPendingHead();
|
|
1360
1704
|
flushFoldedSubAgentTools();
|
|
@@ -1466,6 +1810,8 @@ function renderEvent(event) {
|
|
|
1466
1810
|
}
|
|
1467
1811
|
|
|
1468
1812
|
case 'complete': {
|
|
1813
|
+
// Fire first_answer on the first turn's completion
|
|
1814
|
+
if (session.turns === 1 && session.user) telemetry.track('first_answer', {});
|
|
1469
1815
|
stopSpinner();
|
|
1470
1816
|
flushContent();
|
|
1471
1817
|
flushFoldedSubAgentTools();
|
|
@@ -1474,17 +1820,20 @@ function renderEvent(event) {
|
|
|
1474
1820
|
|
|
1475
1821
|
const summary = data?.summary || '';
|
|
1476
1822
|
if (summary && !runtime.renderedContentThisTurn) {
|
|
1477
|
-
renderBlockBoundary('content');
|
|
1478
|
-
if (!runtime.contentHeaderPrinted) {
|
|
1479
|
-
process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
|
|
1480
|
-
runtime.contentHeaderPrinted = true;
|
|
1481
|
-
}
|
|
1482
1823
|
const rendered = renderMarkdown(summary);
|
|
1483
|
-
|
|
1484
|
-
|
|
1824
|
+
const lines = transcriptRenderableLines(rendered);
|
|
1825
|
+
if (lines.length) {
|
|
1826
|
+
renderBlockBoundary('content', { compactSame: true });
|
|
1827
|
+
if (!runtime.contentHeaderPrinted) {
|
|
1828
|
+
process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
|
|
1829
|
+
runtime.contentHeaderPrinted = true;
|
|
1830
|
+
}
|
|
1831
|
+
for (const line of lines) {
|
|
1832
|
+
process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
|
|
1833
|
+
}
|
|
1834
|
+
runtime.renderedContentThisTurn = true;
|
|
1835
|
+
runtime.lastRenderedBlock = 'content';
|
|
1485
1836
|
}
|
|
1486
|
-
runtime.renderedContentThisTurn = true;
|
|
1487
|
-
runtime.lastRenderedBlock = 'content';
|
|
1488
1837
|
}
|
|
1489
1838
|
|
|
1490
1839
|
// Update session token counts
|
|
@@ -1871,6 +2220,8 @@ async function prepareDirectAgentRunContext(ctx, instruction = '') {
|
|
|
1871
2220
|
execContext.model_overrides = modelOverrides;
|
|
1872
2221
|
if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
|
|
1873
2222
|
}
|
|
2223
|
+
if (session.modelMode) execContext.model_mode = session.modelMode;
|
|
2224
|
+
if (session.routePreference) execContext.model_route = session.routePreference;
|
|
1874
2225
|
return execContext;
|
|
1875
2226
|
}
|
|
1876
2227
|
|
|
@@ -2013,8 +2364,10 @@ async function handleCommand(input, ctx) {
|
|
|
2013
2364
|
|
|
2014
2365
|
case '/login':
|
|
2015
2366
|
process.stderr.write(`${c.brand('Starting login flow...')}\n`);
|
|
2367
|
+
telemetry.track('login_shown', { method: 'repl_command' });
|
|
2016
2368
|
try {
|
|
2017
2369
|
await ctx.auth.login();
|
|
2370
|
+
telemetry.track('login_completed', { method: 'repl_command' });
|
|
2018
2371
|
process.stderr.write(`${c.green('✓ Login successful!')}\n`);
|
|
2019
2372
|
await fetchUser(ctx);
|
|
2020
2373
|
} catch (err) {
|
|
@@ -2455,7 +2808,7 @@ async function handleCommand(input, ctx) {
|
|
|
2455
2808
|
}
|
|
2456
2809
|
|
|
2457
2810
|
case '/model': {
|
|
2458
|
-
handleModelCommand(rest);
|
|
2811
|
+
await handleModelCommand(rest, ctx);
|
|
2459
2812
|
return;
|
|
2460
2813
|
}
|
|
2461
2814
|
|
|
@@ -2520,6 +2873,67 @@ async function handleCommand(input, ctx) {
|
|
|
2520
2873
|
return;
|
|
2521
2874
|
}
|
|
2522
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
|
+
|
|
2523
2937
|
case '/sessions': {
|
|
2524
2938
|
const resumable = await listResumableSessions();
|
|
2525
2939
|
if (resumable.length === 0) {
|
|
@@ -2784,6 +3198,54 @@ export async function startTerminalRepl() {
|
|
|
2784
3198
|
|
|
2785
3199
|
const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints, effectivePolicy, latestProjectContext, latestEnvelope, pendingVisionPaths: [] };
|
|
2786
3200
|
|
|
3201
|
+
let startupOutputRow = 1;
|
|
3202
|
+
let startupOutputCol = 1;
|
|
3203
|
+
|
|
3204
|
+
function trackStartupOutput(chunk) {
|
|
3205
|
+
if (!process.stderr.isTTY || term().plain) return;
|
|
3206
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? '');
|
|
3207
|
+
if (!text) return;
|
|
3208
|
+
const clean = text
|
|
3209
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
3210
|
+
.replace(/\x1b[()][A-Za-z0-9]/g, '');
|
|
3211
|
+
const width = Math.max(1, process.stderr.columns || process.stdout.columns || 80);
|
|
3212
|
+
for (const ch of clean) {
|
|
3213
|
+
if (ch === '\r') {
|
|
3214
|
+
startupOutputCol = 1;
|
|
3215
|
+
continue;
|
|
3216
|
+
}
|
|
3217
|
+
if (ch === '\n') {
|
|
3218
|
+
startupOutputRow++;
|
|
3219
|
+
startupOutputCol = 1;
|
|
3220
|
+
continue;
|
|
3221
|
+
}
|
|
3222
|
+
startupOutputCol++;
|
|
3223
|
+
if (startupOutputCol > width) {
|
|
3224
|
+
startupOutputRow++;
|
|
3225
|
+
startupOutputCol = 1;
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
function startStartupOutputTracking() {
|
|
3231
|
+
if (!process.stderr.isTTY || term().plain) return () => {};
|
|
3232
|
+
const originalWrite = process.stderr.write;
|
|
3233
|
+
function trackedStartupWrite(chunk, ...args) {
|
|
3234
|
+
trackStartupOutput(chunk);
|
|
3235
|
+
return originalWrite.call(this, chunk, ...args);
|
|
3236
|
+
}
|
|
3237
|
+
process.stderr.write = trackedStartupWrite;
|
|
3238
|
+
return () => {
|
|
3239
|
+
if (process.stderr.write === trackedStartupWrite) {
|
|
3240
|
+
process.stderr.write = originalWrite;
|
|
3241
|
+
}
|
|
3242
|
+
};
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
function startupCursorSeed() {
|
|
3246
|
+
return { row: startupOutputRow, col: startupOutputCol };
|
|
3247
|
+
}
|
|
3248
|
+
|
|
2787
3249
|
async function startNewSession({ announce = true } = {}) {
|
|
2788
3250
|
stopSpinner();
|
|
2789
3251
|
flushContent();
|
|
@@ -2798,6 +3260,8 @@ export async function startTerminalRepl() {
|
|
|
2798
3260
|
model: session.model,
|
|
2799
3261
|
modelLimits: session.modelLimits,
|
|
2800
3262
|
modelOverrides: session.modelOverrides,
|
|
3263
|
+
modelMode: session.modelMode,
|
|
3264
|
+
routePreference: session.routePreference,
|
|
2801
3265
|
isByok: session.isByok,
|
|
2802
3266
|
subscriptionTier: session.subscriptionTier,
|
|
2803
3267
|
creditsTotal: session.creditsTotal,
|
|
@@ -2840,6 +3304,8 @@ export async function startTerminalRepl() {
|
|
|
2840
3304
|
model: preserved.model,
|
|
2841
3305
|
modelLimits: preserved.modelLimits,
|
|
2842
3306
|
modelOverrides: preserved.modelOverrides,
|
|
3307
|
+
modelMode: preserved.modelMode,
|
|
3308
|
+
routePreference: preserved.routePreference,
|
|
2843
3309
|
blockedOps: 0,
|
|
2844
3310
|
delegations: [],
|
|
2845
3311
|
phases: [],
|
|
@@ -3103,57 +3569,86 @@ export async function startTerminalRepl() {
|
|
|
3103
3569
|
// ── Print banner + preflight + init BEFORE mounting the status bar ──
|
|
3104
3570
|
// The status bar shrinks the scroll region; if it mounts first, the
|
|
3105
3571
|
// banner scrolls off-screen before the user ever sees it.
|
|
3106
|
-
|
|
3572
|
+
const stopStartupOutputTracking = startStartupOutputTracking();
|
|
3573
|
+
let dockCursor = startupCursorSeed();
|
|
3574
|
+
try {
|
|
3575
|
+
printBanner(auth);
|
|
3107
3576
|
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3577
|
+
// Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
|
|
3578
|
+
// KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
|
|
3579
|
+
if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
|
|
3580
|
+
try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
|
|
3581
|
+
catch { /* preflight is best-effort */ }
|
|
3582
|
+
}
|
|
3114
3583
|
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3584
|
+
// ── Initialization ──
|
|
3585
|
+
process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
|
|
3586
|
+
await fetchUser(ctx);
|
|
3118
3587
|
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
if (
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3588
|
+
// Clear the spinner line
|
|
3589
|
+
process.stderr.write(`\r${' '.repeat(60)}\r`);
|
|
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
|
+
}
|
|
3610
|
+
if (session.user) {
|
|
3611
|
+
process.stderr.write(` ${c.green('✓')} ${c.dim(`Logged in as ${session.user.github_username || session.user.email || 'user'}`)}\n`);
|
|
3612
|
+
}
|
|
3613
|
+
// ── Resume previous session ──
|
|
3614
|
+
if (cliArgs.resume) {
|
|
3615
|
+
const lastSession = cliArgs.resumeSessionId
|
|
3616
|
+
? { sessionId: cliArgs.resumeSessionId }
|
|
3617
|
+
: sessionMgr.getLastSession();
|
|
3618
|
+
|
|
3619
|
+
if (lastSession) {
|
|
3620
|
+
const resumed = await activateResumedSession(lastSession.sessionId, 'startup');
|
|
3621
|
+
if (resumed.ok) {
|
|
3622
|
+
process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messageCountLabel(resumed.messages)}`)}`);
|
|
3623
|
+
process.stderr.write(` ${c.dim('· project')} ${c.brand(path.basename(safeCwd()))}`);
|
|
3624
|
+
process.stderr.write(` ${c.dim(`· agent ${resumed.historyMode}`)}`);
|
|
3625
|
+
if (resumed.switchedProject) process.stderr.write(` ${c.dim('(cwd restored)')}`);
|
|
3626
|
+
if (resumed.projectMissing) process.stderr.write(` ${c.yellow('(saved project path unavailable; using current cwd)')}`);
|
|
3627
|
+
if (resumed.instruction) process.stderr.write(` ${c.dim('—')} ${c.dim(resumed.instruction.slice(0, 50))}`);
|
|
3628
|
+
process.stderr.write('\n');
|
|
3629
|
+
renderResumePreview(resumed, { renderEvent });
|
|
3630
|
+
} else {
|
|
3631
|
+
process.stderr.write(` ${c.yellow('!')} ${c.dim(resumed.reason || 'No conversation found for session ' + lastSession.sessionId)}\n`);
|
|
3632
|
+
}
|
|
3142
3633
|
} else {
|
|
3143
|
-
process.stderr.write(` ${c.yellow('!')} ${c.dim(
|
|
3634
|
+
process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
|
|
3144
3635
|
}
|
|
3145
|
-
} else {
|
|
3146
|
-
process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
|
|
3147
3636
|
}
|
|
3148
|
-
}
|
|
3149
3637
|
|
|
3150
|
-
|
|
3638
|
+
process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
|
|
3639
|
+
} finally {
|
|
3640
|
+
dockCursor = startupCursorSeed();
|
|
3641
|
+
stopStartupOutputTracking();
|
|
3642
|
+
}
|
|
3151
3643
|
|
|
3152
3644
|
// Keep one bottom-reserved UI surface: the fixed input dock. The older
|
|
3153
3645
|
// status bar used the same terminal scroll-region primitive, so mounting
|
|
3154
3646
|
// both would make prompt placement unpredictable.
|
|
3155
3647
|
orbitRef.current = createOrbit();
|
|
3156
|
-
const inputDockActive = mountInputDock(
|
|
3648
|
+
const inputDockActive = mountInputDock({
|
|
3649
|
+
initialContentRow: dockCursor.row,
|
|
3650
|
+
initialContentCol: dockCursor.col,
|
|
3651
|
+
});
|
|
3157
3652
|
if (inputDockActive) {
|
|
3158
3653
|
process.on('beforeExit', unmountInputDock);
|
|
3159
3654
|
process.on('exit', unmountInputDock);
|
|
@@ -3338,50 +3833,92 @@ export async function startTerminalRepl() {
|
|
|
3338
3833
|
readline.cursorTo(process.stderr, col);
|
|
3339
3834
|
}
|
|
3340
3835
|
|
|
3341
|
-
|
|
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 } = {}) {
|
|
3342
3853
|
if (!process.stderr.isTTY || term().plain || !inputActive || !promptBottomPaddingLines()) return;
|
|
3343
3854
|
const rows = promptBottomPaddingLines();
|
|
3344
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
|
+
}
|
|
3345
3862
|
const cols = process.stdout.columns || 80;
|
|
3346
3863
|
if (!preserveSelection || line !== slashHintLine) slashHintSelected = 0;
|
|
3347
3864
|
slashHintItems = suggestions;
|
|
3348
3865
|
slashHintLine = line;
|
|
3349
3866
|
if (slashHintSelected >= slashHintItems.length) slashHintSelected = Math.max(0, slashHintItems.length - 1);
|
|
3350
3867
|
|
|
3351
|
-
|
|
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';
|
|
3352
3871
|
for (let i = 0; i < rows; i++) {
|
|
3353
|
-
|
|
3354
|
-
readline.cursorTo(process.stderr, 0);
|
|
3872
|
+
frame += '\x1b[2K\x1b[G';
|
|
3355
3873
|
const item = suggestions[i];
|
|
3356
3874
|
if (item) {
|
|
3357
3875
|
const marker = i === slashHintSelected ? c.brand('›') : c.dim(' ');
|
|
3358
3876
|
const command = item.command.padEnd(13);
|
|
3359
3877
|
const maxDesc = Math.max(0, cols - 21);
|
|
3360
3878
|
const desc = truncateHintText(item.description, maxDesc);
|
|
3361
|
-
|
|
3879
|
+
frame += ` ${marker} ${c.brand(command)}${desc ? c.dim(desc) : ''}`;
|
|
3362
3880
|
}
|
|
3363
|
-
if (i < rows - 1)
|
|
3881
|
+
if (i < rows - 1) frame += '\x1b[1B';
|
|
3364
3882
|
}
|
|
3365
|
-
|
|
3883
|
+
frame += `\x1b[${rows}A`;
|
|
3884
|
+
writeHintFrame(frame);
|
|
3366
3885
|
restoreReadlineCursor();
|
|
3367
|
-
slashHintVisible =
|
|
3886
|
+
slashHintVisible = true;
|
|
3368
3887
|
slashHintRowsVisible = rows;
|
|
3369
3888
|
}
|
|
3370
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
|
+
|
|
3371
3907
|
function clearSlashHint({ restoreCursor: shouldRestoreCursor = true } = {}) {
|
|
3908
|
+
if (slashHintTimer) { clearTimeout(slashHintTimer); slashHintTimer = null; }
|
|
3372
3909
|
if (!slashHintVisible || !process.stderr.isTTY || term().plain) {
|
|
3373
3910
|
slashHintVisible = false;
|
|
3374
3911
|
slashHintRowsVisible = 0;
|
|
3375
3912
|
return;
|
|
3376
3913
|
}
|
|
3377
3914
|
const rows = slashHintRowsVisible || promptBottomPaddingLines() || 1;
|
|
3378
|
-
|
|
3915
|
+
let frame = '\x1b[1B';
|
|
3379
3916
|
for (let i = 0; i < rows; i++) {
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
|
|
3917
|
+
frame += '\x1b[2K\x1b[G';
|
|
3918
|
+
if (i < rows - 1) frame += '\x1b[1B';
|
|
3383
3919
|
}
|
|
3384
|
-
|
|
3920
|
+
frame += `\x1b[${rows}A`;
|
|
3921
|
+
writeHintFrame(frame);
|
|
3385
3922
|
// The dock's bottom rule + tips row live in the rows we just cleared.
|
|
3386
3923
|
// Repaint the frame (input row untouched) so they reappear.
|
|
3387
3924
|
if (isInputDockMounted()) redrawDockFrame();
|
|
@@ -3778,6 +4315,9 @@ export async function startTerminalRepl() {
|
|
|
3778
4315
|
session.history.push(userMessage);
|
|
3779
4316
|
session.agentHistory.push(userMessage);
|
|
3780
4317
|
session.turns++;
|
|
4318
|
+
// Fire first_prompt on user's first turn
|
|
4319
|
+
if (session.turns === 1) telemetry.track('first_prompt', {});
|
|
4320
|
+
|
|
3781
4321
|
session.toolCalls = 0;
|
|
3782
4322
|
session.subAgentToolCalls = 0;
|
|
3783
4323
|
session.lastTask = originalInput;
|
|
@@ -3851,6 +4391,21 @@ export async function startTerminalRepl() {
|
|
|
3851
4391
|
}
|
|
3852
4392
|
runtime.afterContentFlush = focusExecutionInput;
|
|
3853
4393
|
|
|
4394
|
+
function printExecutionInstruction(instruction) {
|
|
4395
|
+
if (isInputDockMounted()) {
|
|
4396
|
+
clearInputPrompt();
|
|
4397
|
+
moveToContent();
|
|
4398
|
+
} else if (executionInputVisible) {
|
|
4399
|
+
process.stderr.write('\n');
|
|
4400
|
+
}
|
|
4401
|
+
renderBlockBoundary('user', { compactSame: true });
|
|
4402
|
+
process.stderr.write(`${transcriptHeader('you', { tone: 'user' })} ${paint.text.dim('follow-up')}\n`);
|
|
4403
|
+
for (const line of String(instruction || '').split('\n')) {
|
|
4404
|
+
process.stderr.write(`${transcriptLine(line, { tone: 'user' })}\n`);
|
|
4405
|
+
}
|
|
4406
|
+
runtime.lastRenderedBlock = 'user';
|
|
4407
|
+
}
|
|
4408
|
+
|
|
3854
4409
|
async function submitExecutionInstruction() {
|
|
3855
4410
|
const instruction = executionInputBuffer.trim();
|
|
3856
4411
|
executionInputBuffer = '';
|
|
@@ -3869,18 +4424,14 @@ export async function startTerminalRepl() {
|
|
|
3869
4424
|
executionInputVisible = false;
|
|
3870
4425
|
return;
|
|
3871
4426
|
}
|
|
4427
|
+
printExecutionInstruction(instruction);
|
|
3872
4428
|
if (isInputDockMounted()) {
|
|
3873
|
-
clearInputPrompt();
|
|
3874
|
-
moveToContent();
|
|
3875
|
-
process.stderr.write(`${executionInputPrefix()}${instruction}\n`);
|
|
3876
4429
|
renderDockInput(executionInputPrefix(), '', {
|
|
3877
4430
|
context: buildContextStrip(),
|
|
3878
4431
|
meta: buildDockMeta(),
|
|
3879
4432
|
tips: executionInputTips(),
|
|
3880
4433
|
});
|
|
3881
4434
|
moveToContent();
|
|
3882
|
-
} else if (executionInputVisible) {
|
|
3883
|
-
process.stderr.write('\n');
|
|
3884
4435
|
}
|
|
3885
4436
|
executionInputVisible = false;
|
|
3886
4437
|
// Live steering (PRD-081 §5.2): submit through the dedicated
|
|
@@ -3906,18 +4457,26 @@ export async function startTerminalRepl() {
|
|
|
3906
4457
|
});
|
|
3907
4458
|
|
|
3908
4459
|
if (status === 'accepted') {
|
|
4460
|
+
renderBlockBoundary('status', { compactSame: true });
|
|
3909
4461
|
process.stderr.write(` ${c.green('↳')} ${c.dim('sent to running agent')}\n`);
|
|
4462
|
+
runtime.lastRenderedBlock = 'status';
|
|
3910
4463
|
} else if (status === 'duplicate') {
|
|
4464
|
+
renderBlockBoundary('status', { compactSame: true });
|
|
3911
4465
|
process.stderr.write(` ${c.dim('↳ already sent (idempotent)')}\n`);
|
|
4466
|
+
runtime.lastRenderedBlock = 'status';
|
|
3912
4467
|
} else if (status === 'queued_next_turn') {
|
|
3913
4468
|
_queuedLines.push(instruction);
|
|
4469
|
+
renderBlockBoundary('status', { compactSame: true });
|
|
3914
4470
|
process.stderr.write(` ${c.yellow('↳')} ${c.dim('task ended — queued for next turn')}\n`);
|
|
4471
|
+
runtime.lastRenderedBlock = 'status';
|
|
3915
4472
|
} else {
|
|
3916
4473
|
// no_task, error, or unknown — fall back to next-turn queue so the
|
|
3917
4474
|
// user's text is never silently lost.
|
|
3918
4475
|
_queuedLines.push(instruction);
|
|
3919
4476
|
const errBits = result && result.error ? ` ${c.dim(`(${String(result.error).slice(0, 80)})`)}` : '';
|
|
4477
|
+
renderBlockBoundary('status', { compactSame: true });
|
|
3920
4478
|
process.stderr.write(` ${c.yellow('↳')} ${c.dim('queued for next turn')}${errBits}\n`);
|
|
4479
|
+
runtime.lastRenderedBlock = 'status';
|
|
3921
4480
|
}
|
|
3922
4481
|
}
|
|
3923
4482
|
|
|
@@ -4147,8 +4706,6 @@ export async function startTerminalRepl() {
|
|
|
4147
4706
|
moveToContent();
|
|
4148
4707
|
}
|
|
4149
4708
|
startContentStream();
|
|
4150
|
-
process.stderr.write(`\n${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
|
|
4151
|
-
runtime.contentHeaderPrinted = true;
|
|
4152
4709
|
|
|
4153
4710
|
// Immediate feedback so the screen isn't blank between submit and the
|
|
4154
4711
|
// first backend event. The first `status`, `thinking`, or `content_*`
|
|
@@ -4203,6 +4760,8 @@ export async function startTerminalRepl() {
|
|
|
4203
4760
|
execContext.model_overrides = modelOverrides;
|
|
4204
4761
|
if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
|
|
4205
4762
|
}
|
|
4763
|
+
if (session.modelMode) execContext.model_mode = session.modelMode;
|
|
4764
|
+
if (session.routePreference) execContext.model_route = session.routePreference;
|
|
4206
4765
|
// PRD-071: seed work_scope from CLI so the backend has a byte-stable
|
|
4207
4766
|
// scope block from turn 1. Uses projectResources already gathered by
|
|
4208
4767
|
// the envelope above.
|