@bahulam/code 2.6.17 → 2.6.18

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.
@@ -41,6 +41,7 @@ export function createToolExecutor({
41
41
  skillInstaller = null,
42
42
  checkpoints = null,
43
43
  hookRunner = null,
44
+ interactionHandler = null,
44
45
  } = {}) {
45
46
  const occRegistry = createToolRegistry();
46
47
  const skillTool = occRegistry.get('Skill');
@@ -49,6 +50,23 @@ export function createToolExecutor({
49
50
  cwd: process.cwd(),
50
51
  homeDir: skillsLoader.homeDir || os.homedir(),
51
52
  });
53
+
54
+ // ── Auto-register the current working directory as a project ──
55
+ // Without this, shell / list_files / read_attachment fail with
56
+ // "No projects registered. Call get_project_overview first." on
57
+ // any fresh folder — including a legitimate user CWD they just
58
+ // cd'd into to start work. The model then has to spend a turn
59
+ // registering before it can do anything, which is a poor first-run
60
+ // UX. Fire-and-forget: if registration fails (permissions, weird
61
+ // FS), the model can still call get_project_overview explicitly.
62
+ // bypassProjectMarkers=true because we don't require a .git etc.
63
+ // for the current directory to be usable — the user chose to be here.
64
+ // Opt out via BAHULAM_SKIP_AUTO_REGISTER=true for tests or headless
65
+ // scripts that want a truly empty registry.
66
+ if (process.env.BAHULAM_SKIP_AUTO_REGISTER !== 'true') {
67
+ projectRegistry.register(process.cwd(), { bypassProjectMarkers: true })
68
+ .catch(() => { /* silent — model can register explicitly */ });
69
+ }
52
70
  let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
53
71
  let _readOnlyCacheGeneration = 0;
54
72
  const readOnlyResultCache = new Map();
@@ -595,6 +613,240 @@ export function createToolExecutor({
595
613
  // ── Tool mapping table ──────────────────────────────────────
596
614
 
597
615
  const toolMap = {
616
+ // 0. ask_user → interactive direction question (client-executed).
617
+ // The UI form is injected by the REPL via `interactionHandler`;
618
+ // headless/piped sessions have none and get the best-judgment
619
+ // fallback so the agent is never blocked on a missing human.
620
+ ask_user: async (args, options = {}) => {
621
+ throwIfAborted(options.signal);
622
+ const question = String(args?.question || '').trim();
623
+ const choices = Array.isArray(args?.options)
624
+ ? args.options.map(o => String(o || '').trim()).filter(Boolean)
625
+ : [];
626
+ if (!question) {
627
+ return { success: false, output: 'ask_user requires a question.', _tool: 'ask_user' };
628
+ }
629
+ if (choices.length < 2 || choices.length > 4) {
630
+ return { success: false, output: 'ask_user requires 2-4 options.', _tool: 'ask_user' };
631
+ }
632
+ if (!interactionHandler || !process.stdin.isTTY) {
633
+ return {
634
+ success: true,
635
+ output: 'No interactive user is available in this session. Proceed with your best judgment and state the assumption you made.',
636
+ _tool: 'ask_user',
637
+ };
638
+ }
639
+ const res = await interactionHandler({ question, options: choices, context: args?.context });
640
+ throwIfAborted(options.signal);
641
+ if (!res || !res.answer) {
642
+ return {
643
+ success: true,
644
+ output: 'The user declined to answer. Proceed with your best judgment and state the assumption you made.',
645
+ _tool: 'ask_user',
646
+ };
647
+ }
648
+ return {
649
+ success: true,
650
+ output: `User answered: ${res.answer}${res.source === 'free_text' ? ' (typed answer, not one of the offered options)' : ''}`,
651
+ _tool: 'ask_user',
652
+ };
653
+ },
654
+
655
+ // 0b. read_attachment → chunked text extraction from a local document.
656
+ // Backend registers the schema; execution happens client-side because
657
+ // only the CLI has the user's filesystem. Supports the `path` mode
658
+ // (local file); `upload_id` is chat-only and returns a redirect
659
+ // error. Uses the shared prose-chunker so chunk boundaries and
660
+ // page/chunk numbering match the server-side path executor byte-
661
+ // for-byte (documents.py). Supports total_chunks metadata mode,
662
+ // chunk_range/chunk_no selection, page filtering (PDFs), and
663
+ // case-insensitive query substring filtering.
664
+ read_attachment: async (args, options = {}) => {
665
+ throwIfAborted(options.signal);
666
+ const uploadId = String(args?.upload_id || '').trim();
667
+ const rawPath = String(args?.path || '').trim();
668
+ if (uploadId && !rawPath) {
669
+ return {
670
+ success: false,
671
+ output: 'upload_id is a chat-only mode. In the CLI, pass path=<local path> to read a file the user has on disk.',
672
+ _tool: 'read_attachment',
673
+ };
674
+ }
675
+ if (!rawPath) {
676
+ return {
677
+ success: false,
678
+ output: 'read_attachment requires path=<local path> (upload_id is chat-only).',
679
+ _tool: 'read_attachment',
680
+ };
681
+ }
682
+ // Route through projectRegistry.resolvePath — the same helper
683
+ // read_file/edit_file use. This handles shell-escape unescaping,
684
+ // LLM-quoting normalization, and external-file registration for
685
+ // paths outside registered project roots (attachments in
686
+ // ~/Downloads, /tmp, etc. are legitimate).
687
+ let abs;
688
+ try {
689
+ abs = await resolvePath(rawPath, args, { allowExternalFileRead: true });
690
+ } catch (err) {
691
+ return { success: false, output: String(err?.message || err), _tool: 'read_attachment' };
692
+ }
693
+
694
+ const { extractFromPath } = await import('../context/prose-chunker.mjs');
695
+ let mime, chunks;
696
+ try {
697
+ ({ mime, chunks } = await extractFromPath(abs));
698
+ } catch (err) {
699
+ return { success: false, output: `Failed to read ${abs}: ${err?.message || err}`, _tool: 'read_attachment' };
700
+ }
701
+ if (!mime) {
702
+ return { success: false, output: `File not found or not a regular file: ${abs}`, _tool: 'read_attachment' };
703
+ }
704
+ if (!chunks.length) {
705
+ return {
706
+ success: false,
707
+ output: `Unsupported or empty file (mime=${mime}). Supported: pdf, txt, md/mdx, csv, tsv, json, yaml, toml, html, log, rst. For CSV/Excel analysis use read_table; for images use analyze_image.`,
708
+ _tool: 'read_attachment',
709
+ };
710
+ }
711
+
712
+ // Ingest into the project's BM25 index so subsequent search_code
713
+ // (and future search_document) calls surface this doc's chunks.
714
+ // Best-effort: skip if the file isn't inside a registered project
715
+ // (external attachment like ~/Downloads/foo.pdf), and never let
716
+ // an index write fail the tool.
717
+ try {
718
+ const owningProject = projectRegistry.projectForPath(abs);
719
+ if (owningProject?.retriever?.addProseChunks) {
720
+ const rel = path.relative(owningProject.resource.root, abs);
721
+ owningProject.retriever.addProseChunks(rel, chunks);
722
+ }
723
+ } catch { /* best-effort */ }
724
+
725
+ const totalChunks = chunks.length;
726
+ const totalPages = new Set(chunks.map(c => c.page).filter(p => p != null)).size;
727
+ const totalChars = chunks.reduce((s, c) => s + c.text.length, 0);
728
+
729
+ // total_chunks metadata mode — size-before-read for large docs.
730
+ if (args?.total_chunks) {
731
+ const previewLen = Math.min(400, chunks[0].text.length);
732
+ const preview = chunks[0].text.slice(0, previewLen);
733
+ const previewSuffix = previewLen < chunks[0].text.length ? '…' : '';
734
+ const pagesLine = totalPages ? ` · ${totalPages} pages` : '';
735
+ return {
736
+ success: true,
737
+ output: `📄 ${path.basename(abs)} · ${totalChars} chars · ${totalChunks} chunks (0-${totalChunks - 1})${pagesLine}\n\nFirst chunk preview:\n${preview}${previewSuffix}\n\nUse chunk_range='N-M' or chunk_no=N to read specific chunks.`,
738
+ _tool: 'read_attachment',
739
+ _path: abs,
740
+ _mime: mime,
741
+ _total_chunks: totalChunks,
742
+ _total_pages: totalPages,
743
+ _total_chars: totalChars,
744
+ };
745
+ }
746
+
747
+ // chunk_range / chunk_no selection.
748
+ let selected = chunks;
749
+ const rangeStr = String(args?.chunk_range ?? '').trim()
750
+ || (args?.chunk_no !== undefined && args?.chunk_no !== null
751
+ ? String(args.chunk_no).trim()
752
+ : '');
753
+ if (rangeStr) {
754
+ const m = rangeStr.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
755
+ if (!m) {
756
+ return {
757
+ success: false,
758
+ output: `Invalid chunk_range: '${rangeStr}'. Use 'N' for a single chunk or 'N-M' for an inclusive range.`,
759
+ _tool: 'read_attachment',
760
+ };
761
+ }
762
+ const start = parseInt(m[1], 10);
763
+ const end = m[2] != null ? parseInt(m[2], 10) : start;
764
+ if (end < start) {
765
+ return {
766
+ success: false,
767
+ output: `Invalid chunk_range '${rangeStr}': end (${end}) is before start (${start}).`,
768
+ _tool: 'read_attachment',
769
+ };
770
+ }
771
+ selected = chunks.filter(c => c.chunk_no >= start && c.chunk_no <= end);
772
+ if (!selected.length) {
773
+ return {
774
+ success: false,
775
+ output: `No chunks in range ${start}-${end}. Doc has ${totalChunks} chunks (0-${totalChunks - 1}).`,
776
+ _tool: 'read_attachment',
777
+ };
778
+ }
779
+ }
780
+
781
+ // page filter (PDF only — no-op on text docs where page is null).
782
+ if (args?.page !== undefined && args?.page !== null) {
783
+ const p = parseInt(args.page, 10);
784
+ if (Number.isFinite(p)) {
785
+ selected = selected.filter(c => c.page === p);
786
+ if (!selected.length) {
787
+ return {
788
+ success: false,
789
+ output: `No chunks on page ${p}. Doc has ${totalPages} pages.`,
790
+ _tool: 'read_attachment',
791
+ };
792
+ }
793
+ }
794
+ }
795
+
796
+ // query substring filter (case-insensitive, per-chunk).
797
+ const query = String(args?.query || '').trim();
798
+ if (query) {
799
+ const q = query.toLowerCase();
800
+ selected = selected.filter(c => c.text.toLowerCase().includes(q));
801
+ if (!selected.length) {
802
+ return {
803
+ success: true,
804
+ output: `(No chunks matched query="${query}".)`,
805
+ _tool: 'read_attachment',
806
+ _path: abs,
807
+ _total_chunks: totalChunks,
808
+ _returned_chunks: 0,
809
+ };
810
+ }
811
+ }
812
+
813
+ // Render chunks — matches server _render_chunks format:
814
+ // [page N, chunk M]\n<text>\n\n
815
+ const maxChars = Math.max(1000, Number(args?.max_chars) || 100_000);
816
+ const lines = [];
817
+ let total = 0;
818
+ let truncated = false;
819
+ let renderedCount = 0;
820
+ for (const c of selected) {
821
+ const headerBits = [];
822
+ if (c.page != null) headerBits.push(`page ${c.page}`);
823
+ headerBits.push(`chunk ${c.chunk_no}`);
824
+ const block = `[${headerBits.join(', ')}]\n${c.text}`;
825
+ if (total + block.length + 2 > maxChars) {
826
+ lines.push(`... [truncated at chunk ${c.chunk_no} to fit max_chars=${maxChars}. Use chunk_range='${c.chunk_no}-${selected[selected.length - 1].chunk_no}' to read the rest.]`);
827
+ truncated = true;
828
+ break;
829
+ }
830
+ lines.push(block);
831
+ total += block.length + 2;
832
+ renderedCount += 1;
833
+ }
834
+
835
+ const pagesLine = totalPages ? ` · ${totalPages} pages` : '';
836
+ const truncNote = truncated ? ' (truncated)' : '';
837
+ const header = `📄 ${path.basename(abs)} · ${totalChunks} chunks total${pagesLine} · showing ${renderedCount}${truncNote}\n\n`;
838
+ return {
839
+ success: true,
840
+ output: header + lines.join('\n\n'),
841
+ _tool: 'read_attachment',
842
+ _path: abs,
843
+ _mime: mime,
844
+ _total_chunks: totalChunks,
845
+ _returned_chunks: renderedCount,
846
+ _truncated: truncated,
847
+ };
848
+ },
849
+
598
850
  // 1. shell → Bash + classification + smart output filtering
599
851
  shell: async (args, options = {}) => {
600
852
  throwIfAborted(options.signal);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Interactive ask_user form — rendered when the agent calls the ask_user
3
+ * tool mid-turn to get direction (architecture choice, design decision,
4
+ * ambiguous next step, conflicting instructions, …).
5
+ *
6
+ * Same raw-stdin overlay pattern as repl-model-form.mjs: pause readline,
7
+ * raw mode on, redraw in place, restore on exit. Two input modes:
8
+ * list — ↑↓ move across options (+ an "Other" row), Enter selects,
9
+ * Esc declines (agent proceeds with its own judgment)
10
+ * text — the "Other" row opens a free-text line; Enter submits,
11
+ * Esc returns to the list
12
+ */
13
+
14
+ import { c } from './ansi.mjs';
15
+ import { fitAnsiLine, writeOverlayFrame, eraseOverlayFrame } from './repl-format.mjs';
16
+
17
+ const OTHER_SENTINEL = '__other__';
18
+
19
+ /**
20
+ * @param {object} opts
21
+ * @param {object|null} opts.rl readline instance to pause/resume
22
+ * @param {string} opts.question the question to display
23
+ * @param {string[]} opts.options 2-4 option labels from the agent
24
+ * @param {string} [opts.context] optional one-line context above the question
25
+ * @returns {Promise<{answer: string, source: 'option'|'free_text'}|null>}
26
+ * null = user declined (Esc) — the agent should proceed on its own
27
+ */
28
+ export async function askUserForm({ rl, question, options, context }) {
29
+ if (!process.stdin.isTTY) return null;
30
+ if (rl) rl.pause();
31
+
32
+ const optionRows = (options || []).map(o => String(o || '').trim()).filter(Boolean);
33
+ const rows = [...optionRows, OTHER_SENTINEL];
34
+
35
+ return await new Promise((resolve) => {
36
+ const wasRaw = process.stdin.isRaw;
37
+ let cursor = 0;
38
+ let renderedLines = 0;
39
+ let mode = 'list'; // 'list' | 'text'
40
+ let freeText = '';
41
+
42
+ const render = () => {
43
+ const cols = Math.max(60, process.stderr.columns || 120);
44
+ const lines = [];
45
+ lines.push(` ${c.bold('Agent question')} ${c.dim('· pick an option or type your own · Esc to let the agent decide')}`);
46
+ if (context) {
47
+ lines.push(fitAnsiLine(` ${c.dim(String(context))}`, cols - 1));
48
+ }
49
+ lines.push('');
50
+ lines.push(fitAnsiLine(` ${c.brand('?')} ${c.bold(String(question || ''))}`, cols - 1));
51
+ lines.push('');
52
+ rows.forEach((row, i) => {
53
+ const active = i === cursor;
54
+ const marker = active ? c.brand('▸') : ' ';
55
+ if (row === OTHER_SENTINEL) {
56
+ if (mode === 'text' && active) {
57
+ lines.push(fitAnsiLine(` ${marker} ${c.brand('Other:')} ${freeText}${c.brand('▎')}`, cols - 1));
58
+ } else {
59
+ lines.push(fitAnsiLine(` ${marker} ${active ? c.brand('Other — type your own answer') : c.dim('Other — type your own answer')}`, cols - 1));
60
+ }
61
+ } else {
62
+ lines.push(fitAnsiLine(` ${marker} ${active ? c.brand(row) : row}`, cols - 1));
63
+ }
64
+ });
65
+ lines.push('');
66
+ lines.push(fitAnsiLine(
67
+ mode === 'text'
68
+ ? ` ${c.dim('type your answer · Enter submit · Esc back to options')}`
69
+ : ` ${c.dim('↑↓ move · Enter select · Esc decline (agent decides)')}`,
70
+ cols - 1,
71
+ ));
72
+ writeOverlayFrame(renderedLines, lines);
73
+ renderedLines = lines.length;
74
+ };
75
+
76
+ const cleanup = (value) => {
77
+ process.stdin.removeListener('data', onData);
78
+ process.stdin.setRawMode(wasRaw || false);
79
+ eraseOverlayFrame(renderedLines);
80
+ if (rl) rl.resume();
81
+ resolve(value);
82
+ };
83
+
84
+ const onData = (data) => {
85
+ const key = data.toString('utf8');
86
+
87
+ if (mode === 'text') {
88
+ if (key === '\x1b') { mode = 'list'; freeText = ''; render(); return; }
89
+ if (key === '\x03') { cleanup(null); return; }
90
+ if (key === '\r' || key === '\n') {
91
+ const answer = freeText.trim();
92
+ if (answer) { cleanup({ answer, source: 'free_text' }); }
93
+ return;
94
+ }
95
+ if (key === '\x7f' || key === '\b') { freeText = freeText.slice(0, -1); render(); return; }
96
+ // Ignore other escape sequences (arrows etc.) while typing.
97
+ if (key.startsWith('\x1b')) return;
98
+ const printable = [...key].filter(ch => ch >= ' ').join('');
99
+ if (printable) { freeText += printable; render(); }
100
+ return;
101
+ }
102
+
103
+ // list mode
104
+ if (key === '\x1b' || key === '\x03' || key === 'q') { cleanup(null); return; }
105
+ if (key === '\r' || key === '\n') {
106
+ const row = rows[cursor];
107
+ if (row === OTHER_SENTINEL) { mode = 'text'; freeText = ''; render(); return; }
108
+ cleanup({ answer: row, source: 'option' });
109
+ return;
110
+ }
111
+ if (key === '\x1b[A') { cursor = Math.max(0, cursor - 1); render(); return; }
112
+ if (key === '\x1b[B') { cursor = Math.min(rows.length - 1, cursor + 1); render(); return; }
113
+ };
114
+
115
+ process.stdin.setRawMode(true);
116
+ process.stdin.resume();
117
+ process.stdin.on('data', onData);
118
+ render();
119
+ });
120
+ }
@@ -385,8 +385,11 @@ export function renderToolResult(data, eventType = 'tool_result') {
385
385
  // ── Single-line combined emit ──
386
386
  // If the head for this call is still buffered (no interleaving content
387
387
  // landed), and the combined line fits the terminal width, emit ONE line
388
- // and skip the gutter entirely.
389
- if (runtime.pendingHead && runtime.pendingHead.callId === callId && !hasLint && !runtime.pendingHead.head.includes('\n')) {
388
+ // and skip the gutter entirely. Multi-line result text (shell preview
389
+ // with rows + "+ N more" tail) skips this path — a wrapped multi-line
390
+ // block needs its own real estate.
391
+ const outcomeIsMultiLine = outcome.includes('\n');
392
+ if (runtime.pendingHead && runtime.pendingHead.callId === callId && !hasLint && !runtime.pendingHead.head.includes('\n') && !outcomeIsMultiLine) {
390
393
  const cols = process.stderr.columns || 120;
391
394
  const combined = `${runtime.pendingHead.head} ${outcome}`;
392
395
  if (stripAnsi(combined).length <= cols) {
@@ -421,7 +424,15 @@ export function renderToolResult(data, eventType = 'tool_result') {
421
424
  }
422
425
 
423
426
  // Two-line shape: gutter under the (already-printed or just-flushed) head.
424
- process.stderr.write(`${gutter}${outcome}\n`);
427
+ // Multi-line outcome (shell preview with rows + "+ N more" tail): prepend
428
+ // the gutter to each line so the block stays aligned instead of ragged.
429
+ if (outcomeIsMultiLine) {
430
+ for (const line of outcome.split('\n')) {
431
+ process.stderr.write(`${gutter}${line}\n`);
432
+ }
433
+ } else {
434
+ process.stderr.write(`${gutter}${outcome}\n`);
435
+ }
425
436
  if (diffPreview) {
426
437
  process.stderr.write(`${diffPreview}\n`);
427
438
  rememberFileDiffPreview(data);
@@ -30,6 +30,7 @@ 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
32
  import { readShippedCatalog } from '../config/model-catalog.mjs';
33
+ import { askUserForm } from './repl-ask-form.mjs';
33
34
  import { runPreflight } from '../onboarding/preflight.mjs';
34
35
  import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
35
36
  import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
@@ -1437,6 +1438,33 @@ function renderEvent(event) {
1437
1438
  break;
1438
1439
  }
1439
1440
 
1441
+ case 'summarize': {
1442
+ // Backend collapsed older history into a summary. Two phases:
1443
+ // pre_turn — fires at the start of a new turn before hydration
1444
+ // mid_turn — fires between iterations during a long autonomous run
1445
+ // Both share the same env threshold (BAHULAM_SUMMARIZE_THRESHOLD,
1446
+ // default 160k est tokens). One cache miss per fire; each fires at
1447
+ // most once per turn.
1448
+ stopSpinner();
1449
+ flushContent();
1450
+ flushPendingHead();
1451
+ renderBlockBoundary('status', { compactSame: true });
1452
+ const phase = String(data?.phase || 'pre_turn');
1453
+ const phaseTag = phase === 'mid_turn' ? 'mid-turn' : 'pre-turn';
1454
+ const collapsed = Number(data?.collapsed_messages || 0);
1455
+ const kept = Number(data?.kept_recent || 0);
1456
+ const before = Number(data?.before_est_tokens || 0);
1457
+ const beforeK = before ? ` · ~${Math.round(before / 1000)}k est tokens` : '';
1458
+ const keptPart = kept ? ` · kept last ${kept}` : '';
1459
+ const preview = String(data?.summary_preview || '').trim();
1460
+ process.stderr.write(` ${c.brand('✎')} ${c.dim(`context summarized (${phaseTag}) · ${collapsed} older message${collapsed === 1 ? '' : 's'} collapsed${keptPart}${beforeK}`)}\n`);
1461
+ if (preview) {
1462
+ process.stderr.write(` ${c.dim('› ' + preview)}\n`);
1463
+ }
1464
+ runtime.lastRenderedBlock = 'status';
1465
+ break;
1466
+ }
1467
+
1440
1468
  case 'reconnecting': {
1441
1469
  stopSpinner();
1442
1470
  flushContent();
@@ -1686,6 +1714,23 @@ function renderEvent(event) {
1686
1714
  // diverts for explore-category tools and folded verbosity modes.
1687
1715
  // Dedup-consecutive inside the push keeps repeat tools quiet.
1688
1716
  pushSubAgentWindowLine(`→ ${tool}`);
1717
+ // Rich-mode fallback: when the render queue isn't active (bare TTY,
1718
+ // --plain, or before the input dock mounts), the live window under
1719
+ // the spinner is invisible — pushSubAgentWindowLine feeds into a
1720
+ // status block that never renders. Guarantee sub-agent tool activity
1721
+ // is visible by emitting a small dim transcript line too, dedup'd
1722
+ // per (agentType, tool). Queue-active mode keeps its clean spinner
1723
+ // + window; the fallback line is only for the "otherwise blind"
1724
+ // case that surfaced in 2.6.17 reports.
1725
+ if (!rqueue.isActive()) {
1726
+ const label = data?.label || '';
1727
+ const hint = label ? ` · ${label}` : '';
1728
+ const key = `${agentType}:${tool}:${label}`;
1729
+ if (session._lastSubAgentInlineKey !== key) {
1730
+ session._lastSubAgentInlineKey = key;
1731
+ process.stderr.write(` ${c.dim(`→ ${agentType} · ${tool}${hint}`)}\n`);
1732
+ }
1733
+ }
1689
1734
  // Don't clobber an active explore-run spinner. "exploring · 5 read ·
1690
1735
  // 2 searched" is more informative than "explore → search_code", and
1691
1736
  // sub_agent_tool fires on every step of a sub-agent — otherwise the
@@ -2877,8 +2922,9 @@ async function handleCommand(input, ctx) {
2877
2922
  // Session autopilot for long-running jobs: auto-approve routine
2878
2923
  // writes/shell while STILL prompting for dangerous tiers (rm,
2879
2924
  // 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.
2925
+ // safety blocks. Distinct from the launch-time
2926
+ // --dangerously-skip-permissions flag, which approves everything
2927
+ // including dangerous tiers.
2882
2928
  const sub = (rest || '').trim().toLowerCase();
2883
2929
  if (sub === 'off') {
2884
2930
  ctx.approval.approveAll = false;
@@ -3182,8 +3228,30 @@ export async function startTerminalRepl() {
3182
3228
  let latestProjectContext = null;
3183
3229
  let latestEnvelope = null;
3184
3230
  let hookRunner = new HookRunner({ cwd: safeCwd() });
3185
- let toolExecutor = createToolExecutor({ checkpoints, hookRunner });
3186
- const skipPerms = cliArgs.freeswim;
3231
+
3232
+ // ask_user tool → interactive overlay form (repl-ask-form.mjs). `ctx` is
3233
+ // declared below; the arrow only dereferences it at call time (mid-turn),
3234
+ // so the lazy reference is safe. Stop the spinner and flush streamed
3235
+ // content first so the overlay doesn't fight the render queue.
3236
+ const askUserInteraction = async (req) => {
3237
+ stopSpinner();
3238
+ flushContent();
3239
+ const res = await askUserForm({
3240
+ rl: ctx?._rl || null,
3241
+ question: req?.question || '',
3242
+ options: Array.isArray(req?.options) ? req.options : [],
3243
+ context: req?.context || '',
3244
+ });
3245
+ if (res?.answer) {
3246
+ process.stderr.write(` ${c.green('✓')} ${c.dim('answered:')} ${c.brand(res.answer)}\n`);
3247
+ } else {
3248
+ process.stderr.write(` ${c.dim('Question declined — agent proceeds with its own judgment.')}\n`);
3249
+ }
3250
+ return res;
3251
+ };
3252
+
3253
+ let toolExecutor = createToolExecutor({ checkpoints, hookRunner, interactionHandler: askUserInteraction });
3254
+ const skipPerms = cliArgs.skipPermissions;
3187
3255
  let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
3188
3256
 
3189
3257
  // Session manager — persists conversation messages to .bahulam/conversations/
@@ -3276,7 +3344,7 @@ export async function startTerminalRepl() {
3276
3344
  checkpoints = new CheckpointManager(safeCwd());
3277
3345
  effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
3278
3346
  hookRunner = new HookRunner({ cwd: safeCwd() });
3279
- toolExecutor = createToolExecutor({ checkpoints, hookRunner });
3347
+ toolExecutor = createToolExecutor({ checkpoints, hookRunner, interactionHandler: askUserInteraction });
3280
3348
  approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
3281
3349
  if (ctx._rl) approval.setReadline(ctx._rl);
3282
3350
  sessionMgr = new SessionManager(safeCwd());
@@ -3456,7 +3524,7 @@ export async function startTerminalRepl() {
3456
3524
  checkpoints = new CheckpointManager(safeCwd());
3457
3525
  effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
3458
3526
  hookRunner = new HookRunner({ cwd: safeCwd(), sessionId });
3459
- toolExecutor = createToolExecutor({ checkpoints, hookRunner });
3527
+ toolExecutor = createToolExecutor({ checkpoints, hookRunner, interactionHandler: askUserInteraction });
3460
3528
  approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
3461
3529
  if (ctx._rl) approval.setReadline(ctx._rl);
3462
3530
  sessionMgr = new SessionManager(safeCwd());
@@ -3576,7 +3644,7 @@ export async function startTerminalRepl() {
3576
3644
 
3577
3645
  // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
3578
3646
  // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
3579
- if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
3647
+ if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.skipPermissions) {
3580
3648
  try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
3581
3649
  catch { /* preflight is best-effort */ }
3582
3650
  }
@@ -4714,7 +4782,10 @@ export async function startTerminalRepl() {
4714
4782
  startSpinner('thinking…');
4715
4783
 
4716
4784
  const execContext = { cwd: safeCwd() };
4717
- if (skipPerms) execContext.freeswim = true;
4785
+ if (skipPerms) {
4786
+ execContext.skip_permissions = true;
4787
+ execContext.freeswim = true; // legacy wire alias — drop after cloud backend 2.7 rollout
4788
+ }
4718
4789
  effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
4719
4790
  approval.policy = effectivePolicy.policy;
4720
4791
  if (approval.trustStore) approval.trustStore.policy = effectivePolicy.policy;
@@ -4754,7 +4825,10 @@ export async function startTerminalRepl() {
4754
4825
  ctx.latestProjectContext = latestProjectContext;
4755
4826
  ctx.latestEnvelope = latestEnvelope;
4756
4827
  Object.assign(execContext, latestEnvelope);
4757
- if (skipPerms) execContext.freeswim = true;
4828
+ if (skipPerms) {
4829
+ execContext.skip_permissions = true;
4830
+ execContext.freeswim = true; // legacy wire alias — drop after cloud backend 2.7 rollout
4831
+ }
4758
4832
  const modelOverrides = Object.fromEntries(sessionModelOverrideEntries());
4759
4833
  if (Object.keys(modelOverrides).length > 0) {
4760
4834
  execContext.model_overrides = modelOverrides;
@@ -156,6 +156,14 @@ export function summarizeResult(tool, data) {
156
156
  if (tool === 'shell') {
157
157
  const structured = structuredOutputSummary(data.output_preview || data.output);
158
158
  if (structured) return structured;
159
+ // Multi-row preview: first N rows + a "+ M more" tail so long
160
+ // outputs (e.g. `ls`) surface their scale instead of collapsing
161
+ // to a single first line with no hint that more exists.
162
+ const { preview, remaining } = outputPreviewRows(data, shellPreviewRows());
163
+ if (!preview) return { text: 'ok', tone: 'success' };
164
+ if (remaining === 0) return { text: preview, tone: 'success' };
165
+ const tail = paint.text.dim(`+ ${remaining} more row${remaining === 1 ? '' : 's'}`);
166
+ return { text: `${preview}\n${tail}`, tone: 'success' };
159
167
  }
160
168
  const head = firstOutputLine(data).slice(0, 100);
161
169
  return { text: head || 'ok', tone: 'success' };
@@ -413,6 +421,24 @@ function firstOutputLine(data) {
413
421
  return String(o).split('\n').map(l => l.trim()).find(Boolean) || '';
414
422
  }
415
423
 
424
+ // Preview the first N non-empty output rows, joined by \n. Returns
425
+ // { preview, remaining, total } — remaining is how many non-empty rows
426
+ // were dropped past N. Long individual rows get clipped to `perRow` chars.
427
+ function outputPreviewRows(data, n, perRow = 200) {
428
+ const o = data?.output_preview ?? data?.output ?? data?.message ?? '';
429
+ const rows = String(o).split('\n').map(l => l.trim()).filter(Boolean);
430
+ const shown = rows.slice(0, n).map(l => l.length > perRow ? l.slice(0, perRow - 1) + '…' : l);
431
+ return { preview: shown.join('\n'), remaining: Math.max(0, rows.length - n), total: rows.length };
432
+ }
433
+
434
+ // Default rows shown in the shell result preview. Overridable via env for
435
+ // power users; keep it small — the result line rides above every command
436
+ // and eats vertical space in a long session.
437
+ function shellPreviewRows() {
438
+ const raw = parseInt(process.env.BAHULAM_SHELL_PREVIEW_ROWS ?? '', 10);
439
+ return Number.isFinite(raw) && raw >= 1 ? raw : 2;
440
+ }
441
+
416
442
  function lineCount(s) {
417
443
  if (!s) return 0;
418
444
  return String(s).split('\n').filter(Boolean).length;