@aitherium/shell-cli 1.1.0 → 1.11.1

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/dist/renderer.js CHANGED
@@ -6,13 +6,34 @@ import ora from 'ora';
6
6
  import { marked } from 'marked';
7
7
  import { markedTerminal } from 'marked-terminal';
8
8
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
9
- import { join } from 'path';
9
+ import { join, resolve } from 'path';
10
10
  import { homedir } from 'os';
11
+ import { spawn } from 'child_process';
12
+ import { getActiveConfig } from './config.js';
11
13
  // Configure marked for terminal rendering
12
14
  marked.use(markedTerminal({ reflowText: true, width: 80 }));
15
+ /* ── Trace verbosity ─────────────────────────────────────────────
16
+ * FULL trace is ON by default: every pipeline stage (planning/MCTS,
17
+ * neurons, middleware, per-turn timings) prints a permanent line so the
18
+ * user can SEE what the system is doing on every turn without asking.
19
+ * Set AITHER_TRACE=quiet (or off/0/false) to collapse stages back into a
20
+ * single transient status line.
21
+ */
22
+ export const TRACE_FULL = (() => {
23
+ const v = (process.env.AITHER_TRACE || '').toLowerCase().trim();
24
+ if (v === 'quiet' || v === 'off' || v === '0' || v === 'false' || v === 'none')
25
+ return false;
26
+ return true; // default: full trace
27
+ })();
28
+ /* ── OSC 8 terminal hyperlink ────────────────────────────────── */
29
+ /** Wrap text in an OSC 8 clickable hyperlink for terminals that support it. */
30
+ export function osc8Link(url, label) {
31
+ const text = label || url;
32
+ return `\x1b]8;;${url}\x1b\\${chalk.cyan.underline(text)}\x1b]8;;\x1b\\`;
33
+ }
13
34
  /* ── Banner ─────────────────────────────────────────────────── */
14
35
  export function renderBanner(info) {
15
- const version = 'v1.1.0';
36
+ const version = 'v1.11.1'; // keep in sync with package.json
16
37
  const title = `AitherShell ${version}`;
17
38
  let connected;
18
39
  if (info.genesisOnline === true) {
@@ -121,42 +142,136 @@ function stripWebCruft(text) {
121
142
  * and render as clickable OSC 8 terminal hyperlinks.
122
143
  */
123
144
  export function resolveImagePath(relativePath) {
124
- const { homedir } = require('os');
125
- const { resolve } = require('path');
145
+ // ESM module use the top-level imports, NOT require() (which is undefined
146
+ // here and made renderMarkdown throw → raw ![](...) markdown leaked through).
126
147
  const home = homedir();
127
- // Try common AitherOS paths
128
- const candidates = [
129
- resolve(home, 'AitherOS', relativePath),
130
- resolve(home, 'AitherOS-Fresh', 'AitherOS', relativePath),
131
- resolve('D:', 'AitherOS-Fresh', 'AitherOS', relativePath),
132
- resolve('/app', 'AitherOS', relativePath),
133
- ];
134
- for (const p of candidates) {
135
- try {
136
- if (require('fs').existsSync(p))
137
- return p;
148
+ const rel = relativePath.replace(/^\/api\/files\?path=/, '');
149
+ // If it's already an existing absolute path, keep it.
150
+ try {
151
+ if (/^([A-Za-z]:[/\\]|\/)/.test(rel) && existsSync(rel))
152
+ return rel;
153
+ }
154
+ catch { /* */ }
155
+ // Try known AitherOS roots (the Library is bind-mounted under <root>/AitherOS).
156
+ const roots = [
157
+ process.env.AITHER_ROOT,
158
+ 'D:\\AitherOS-Fresh', // dev box repo root
159
+ resolve(home, 'AitherOS-Fresh'),
160
+ resolve(home, 'AitherOS'),
161
+ ].filter(Boolean);
162
+ for (const root of roots) {
163
+ for (const sub of ['AitherOS', '']) {
164
+ try {
165
+ const p = resolve(root, sub, rel);
166
+ if (existsSync(p))
167
+ return p;
168
+ }
169
+ catch { /* */ }
138
170
  }
139
- catch { }
140
171
  }
141
- return resolve(relativePath);
172
+ return resolve(rel); // best-effort (may not exist) — caller can check
173
+ }
174
+ /** Strip OSC-8 hyperlink escapes (blessed/TUI can't render them → raw `]8;;`). */
175
+ export function stripOsc8(text) {
176
+ // ESC ]8;;<url> ESC \ …label… ESC ]8;; ESC \ → keep just the label.
177
+ return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x1b\\|\x07)/g, '');
178
+ }
179
+ /** The backend the shell is ACTUALLY connected to (local genesis, a remote
180
+ * gateway/mcp, etc.) — image URLs must resolve against IT, not a hardcoded host. */
181
+ function backendBase() {
182
+ const cfg = getActiveConfig();
183
+ const base = cfg?.genesisUrl
184
+ || process.env.AITHER_API_URL || process.env.AITHER_GENESIS_URL
185
+ || 'http://127.0.0.1:8001';
186
+ return String(base).replace(/\/+$/, '');
142
187
  }
143
188
  function resolveImagePaths(text) {
144
189
  // Match ![alt](/api/files?path=...) or ![alt](Library/...)
145
190
  return text.replace(/!\[([^\]]*)\]\((?:\/api\/files\?path=)?([^)]+)\)/g, (_match, alt, rawPath) => {
146
- // Resolve to absolute path — Library is under AitherOS/
147
- let absPath = rawPath;
148
- if (!rawPath.match(/^[A-Za-z]:[/\\]/) && !rawPath.startsWith('/')) {
149
- // Relative path — resolve from repo root
150
- const repoRoot = process.env.AITHER_ROOT || process.cwd();
151
- absPath = `${repoRoot}/AitherOS/${rawPath}`.replace(/\//g, '\\');
152
- }
153
191
  const label = alt || rawPath.split(/[/\\]/).pop() || 'image';
154
- const fileUrl = `file:///${absPath.replace(/\\/g, '/')}`;
155
- // OSC 8 terminal hyperlink: \e]8;;url\e\\ text \e]8;;\e\\
156
- const link = `\x1b]8;;${fileUrl}\x1b\\${chalk.cyan.underline(label)}\x1b]8;;\x1b\\`;
157
- return `${link} ${chalk.dim(absPath)}`;
192
+ const base = backendBase();
193
+ const isLocalBackend = /(^|\/\/)(127\.0\.0\.1|localhost)\b/.test(base);
194
+ // If the raw path is already a full URL, keep it.
195
+ const isAbsUrl = /^https?:\/\//i.test(rawPath);
196
+ let url;
197
+ let shown;
198
+ // Only a LOCAL backend can have the file on THIS disk (bind-mounted
199
+ // Library). For a remote backend (gateway/mcp/…) the file lives there, so
200
+ // always link the backend's HTTP URL — never guess a local path.
201
+ const local = (!isAbsUrl && isLocalBackend) ? (() => {
202
+ try {
203
+ const c = resolveImagePath(rawPath);
204
+ return existsSync(c) ? c : null;
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ })() : null;
210
+ if (local) {
211
+ url = `file:///${local.replace(/\\/g, '/')}`;
212
+ shown = local;
213
+ }
214
+ else if (isAbsUrl) {
215
+ url = rawPath;
216
+ shown = rawPath;
217
+ }
218
+ else {
219
+ const p = rawPath.replace(/^\/api\/files\?path=/, '');
220
+ url = `${base}/api/files?path=${encodeURIComponent(p)}`;
221
+ shown = url;
222
+ }
223
+ const link = `\x1b]8;;${url}\x1b\\${chalk.cyan.underline(label)}\x1b]8;;\x1b\\`;
224
+ return `🖼 ${link} ${chalk.dim(shown)}`;
158
225
  });
159
226
  }
227
+ // Images already auto-opened this process, so we don't reopen on re-render.
228
+ const _openedImages = new Set();
229
+ /** Open a local image in the OS default viewer (detached). Best-effort. */
230
+ export function openLocalImage(p) {
231
+ if (!p || _openedImages.has(p))
232
+ return;
233
+ try {
234
+ if (!existsSync(p))
235
+ return;
236
+ }
237
+ catch {
238
+ return;
239
+ }
240
+ _openedImages.add(p);
241
+ try {
242
+ const plat = process.platform;
243
+ if (plat === 'win32')
244
+ spawn('cmd', ['/c', 'start', '', p], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
245
+ else if (plat === 'darwin')
246
+ spawn('open', [p], { detached: true, stdio: 'ignore' }).unref();
247
+ else
248
+ spawn('xdg-open', [p], { detached: true, stdio: 'ignore' }).unref();
249
+ }
250
+ catch { /* viewer unavailable — the path is still shown */ }
251
+ }
252
+ /** Scan answer text for generated-image markdown and open any LOCAL ones in the
253
+ * OS viewer (so the user actually SEES the image — the TUI can't render it
254
+ * inline). Opt out with AITHER_AUTO_OPEN_IMAGES=0. */
255
+ export function autoOpenImagesFromText(text) {
256
+ if (!text)
257
+ return;
258
+ const off = (process.env.AITHER_AUTO_OPEN_IMAGES || '').toLowerCase();
259
+ if (off === '0' || off === 'false' || off === 'off' || off === 'no')
260
+ return;
261
+ const re = /!\[[^\]]*\]\((?:\/api\/files\?path=)?([^)]+)\)/g;
262
+ let m;
263
+ while ((m = re.exec(text)) !== null) {
264
+ const raw = m[1];
265
+ if (/^https?:\/\//i.test(raw))
266
+ continue; // remote — nothing local to open
267
+ try {
268
+ const c = resolveImagePath(raw);
269
+ if (existsSync(c))
270
+ openLocalImage(c);
271
+ }
272
+ catch { /* */ }
273
+ }
274
+ }
160
275
  export function renderMarkdown(text) {
161
276
  try {
162
277
  let clean = text;
@@ -235,6 +350,20 @@ export function getSessionArtifacts() {
235
350
  export function clearSessionArtifacts() {
236
351
  _sessionArtifacts = [];
237
352
  }
353
+ /** Register a locally-saved file as a session artifact (so /get + /artifacts find it). */
354
+ export function addSessionArtifact(a) {
355
+ const filename = a.path.split(/[\\/]/).pop() || a.path;
356
+ _sessionArtifacts.push({
357
+ filename,
358
+ path: a.path,
359
+ size: a.size ?? 0,
360
+ language: a.language || 'image',
361
+ retrieve_cmd: `/get ${_sessionArtifacts.length + 1}`,
362
+ download_url: '',
363
+ timestamp: new Date().toISOString(),
364
+ });
365
+ return _sessionArtifacts.length;
366
+ }
238
367
  export function createStreamRenderer(sessionId, prompt, steeringBar) {
239
368
  let spinner = null;
240
369
  let content = '';
@@ -244,8 +373,12 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
244
373
  let completePrinted = false; // Prevent duplicate timing lines
245
374
  const seenToolResults = new Set(); // Dedup tool_result entries
246
375
  let tokenStreamed = false; // Track if answer was delivered via token events
376
+ let contentDisplayed = false; // True when actual response text was written to stdout
247
377
  let tokensStarted = false; // Once true, spinner is permanently disabled for this session
378
+ let llmFired = false; // Set on llm_start — suppresses post-gen middleware even without token events
248
379
  let lastStage = ''; // Current pipeline stage — shown in heartbeat spinner
380
+ let eagerActive = false; // Eager streaming: multiple answer_segments this turn
381
+ let eagerSegments = 0; // Count of segments rendered
249
382
  // ── Session trace collection ──
250
383
  const traceEvents = [];
251
384
  const traceToolCalls = [];
@@ -323,8 +456,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
323
456
  }
324
457
  if (event.data.agent)
325
458
  traceAgent = event.data.agent;
326
- if (event.data.model)
459
+ // Skip the "auto"/"unknown" placeholder — the real model arrives in
460
+ // llm_start / complete. Capturing it here is why the footer said "auto".
461
+ if (event.data.model && event.data.model !== 'auto' && event.data.model !== 'unknown') {
327
462
  traceModel = event.data.model;
463
+ }
328
464
  startSpinner(chalk.dim(`${event.data.agent || 'thinking'}...`));
329
465
  break;
330
466
  case 'thinking': {
@@ -392,8 +528,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
392
528
  break;
393
529
  }
394
530
  case 'thinking_end': {
395
- // Clear any streaming line remnant
396
- process.stdout.write('\r\x1b[2K');
531
+ // Only clear the streaming thinking line if we haven't started
532
+ // writing answer tokens — otherwise we'd erase the answer.
533
+ if (!tokensStarted && !contentDisplayed) {
534
+ process.stdout.write('\r\x1b[2K');
535
+ }
397
536
  break;
398
537
  }
399
538
  case 'progress':
@@ -407,6 +546,8 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
407
546
  'affect_start', 'prefire_start', 'facet_plan_start',
408
547
  'plan_start', 'planner_start', 'planner_done',
409
548
  'research_start',
549
+ // Classification start
550
+ 'classify_start',
410
551
  // IntentPlanner sub-phases
411
552
  'decompose_start', 'tool_match', 'critique_start',
412
553
  // Context assembly sub-phases
@@ -430,20 +571,67 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
430
571
  }
431
572
  break;
432
573
  }
574
+ case 'answer_segment': {
575
+ // Eager streaming: a new answer block (initial fast pass, or a
576
+ // refinement once background enrichment lands). Print a header and
577
+ // reset per-segment streaming state so tokens render cleanly.
578
+ eagerActive = true;
579
+ eagerSegments += 1;
580
+ stopSpinner();
581
+ const segKind = event.data.kind || 'initial';
582
+ const segReason = event.data.reason ? chalk.dim(` (${event.data.reason})`) : '';
583
+ if (hasOutput)
584
+ process.stdout.write('\n');
585
+ if (segKind === 'continuation') {
586
+ log(chalk.cyan(' ↳ Continuing') + segReason);
587
+ }
588
+ else if (segKind === 'refinement') {
589
+ log(chalk.cyan(' ↻ Refining') + segReason);
590
+ }
591
+ else {
592
+ log(chalk.cyan(' 💬 Initial') + segReason);
593
+ }
594
+ tokensStarted = false; // re-enter first-token path for this segment
595
+ content = ''; // per-segment accumulator (final captured at complete)
596
+ break;
597
+ }
598
+ case 'segment_end': {
599
+ if (!content.endsWith('\n'))
600
+ process.stdout.write('\n');
601
+ break;
602
+ }
433
603
  case 'token': {
434
604
  stopSpinner();
435
- tokensStarted = true; // Permanently disable spinner for this response
605
+ if (!tokensStarted) {
606
+ tokensStarted = true; // Permanently disable spinner for this response
607
+ // Clear any residual progress/thinking line before first token
608
+ process.stdout.write('\r\x1b[2K');
609
+ if (steeringBar?.active)
610
+ steeringBar.enterTokenMode();
611
+ }
436
612
  const t = decodeHtmlEntities(event.data.t || '');
437
613
  content += t;
438
614
  process.stdout.write(t);
439
615
  hasOutput = true;
440
616
  tokenStreamed = true;
617
+ contentDisplayed = true;
441
618
  break;
442
619
  }
443
620
  case 'message':
444
621
  case 'answer':
445
622
  case 'final_answer': {
446
623
  stopSpinner();
624
+ // Eager streaming already rendered the answer as live segments —
625
+ // the terminal `answer` event is just the canonical copy for
626
+ // trace/getContent. Capture it, don't re-print.
627
+ if (eagerActive) {
628
+ content = event.data.answer || event.data.content || content;
629
+ contentDisplayed = true;
630
+ hasOutput = true;
631
+ break;
632
+ }
633
+ // Clear any residual spinner/thinking line
634
+ process.stdout.write('\r\x1b[2K');
447
635
  const answer = event.data.response || event.data.answer || event.data.content || '';
448
636
  if (answer) {
449
637
  // Dedup: skip if already printed (AgentRuntime emits final_answer,
@@ -466,6 +654,7 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
466
654
  if (!answer.endsWith('\n'))
467
655
  process.stdout.write('\n');
468
656
  hasOutput = true;
657
+ contentDisplayed = true;
469
658
  }
470
659
  // Render interactive/display blocks in terminal
471
660
  const blocks = event.data.render_blocks;
@@ -592,19 +781,26 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
592
781
  case 'done':
593
782
  case 'complete': {
594
783
  stopSpinner();
784
+ if (steeringBar?.active)
785
+ steeringBar.exitTokenMode();
595
786
  // Only render content + timing once (engine + router can both emit 'complete')
596
787
  if (completePrinted)
597
788
  break;
598
789
  completePrinted = true;
599
- if (!hasOutput && event.data.content) {
790
+ if (!contentDisplayed && event.data.content) {
600
791
  content = event.data.content;
792
+ process.stdout.write('\r\x1b[2K');
601
793
  process.stdout.write(renderMarkdown(wrapBareCode(content)));
602
794
  hasOutput = true;
795
+ contentDisplayed = true;
603
796
  }
604
- else if (tokenStreamed) {
797
+ else if (tokenStreamed && !eagerActive) {
605
798
  // Content was streamed raw via token events — re-render with
606
799
  // markdown so code blocks display properly. wrapBareCode catches
607
800
  // bare code even when no fences are present.
801
+ // Skipped in eager mode: segments already streamed as plain text
802
+ // and `content` only holds the last segment, so a markdown
803
+ // re-render would duplicate / mangle the multi-segment output.
608
804
  const wrapped = wrapBareCode(content);
609
805
  if (wrapped !== content || content.includes('```')) {
610
806
  process.stdout.write('\n');
@@ -613,19 +809,18 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
613
809
  }
614
810
  if (hasOutput)
615
811
  process.stdout.write('\n');
616
- const meta = [];
617
- if (event.data.model || event.data.model_used)
618
- meta.push(event.data.model || event.data.model_used);
619
- if (event.data.duration_ms || event.data.elapsed_ms) {
620
- meta.push(`${Math.round(event.data.duration_ms || event.data.elapsed_ms)}ms`);
621
- }
622
- if (event.data.turns_completed)
623
- meta.push(`${event.data.turns_completed} turns`);
812
+ autoOpenImagesFromText(content); // pop generated images in the OS viewer
813
+ // Update trace vars from the authoritative complete event, then let
814
+ // finish() print the SINGLE canonical footer. Printing a footer here
815
+ // too caused the duplicate "[model \u00b7 ms \u00b7 agent]" + "[agent | model
816
+ // | time]" lines the user saw.
817
+ const _cm = event.data.model || event.data.model_used;
818
+ if (_cm && _cm !== 'auto' && _cm !== 'unknown')
819
+ traceModel = _cm;
624
820
  if (event.data.agent)
625
- meta.push(event.data.agent);
626
- if (meta.length) {
627
- log(chalk.dim(` [${meta.join(' \u00b7 ')}]`));
628
- }
821
+ traceAgent = event.data.agent;
822
+ if (event.data.turns_completed)
823
+ traceTurns = event.data.turns_completed;
629
824
  break;
630
825
  }
631
826
  case 'error':
@@ -639,6 +834,15 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
639
834
  stopSpinner();
640
835
  hasOutput = true; // Prevent "(no response)" — we already showed trace output
641
836
  break;
837
+ case 'stream_interrupted':
838
+ // Transport died mid-turn (backend/LB restart) — unlike a soft
839
+ // timeout the connection is GONE, so say so instead of going silent.
840
+ stopSpinner();
841
+ log(chalk.yellow(`\n ⚠ Stream interrupted — ${event.data.error || 'connection lost'}`));
842
+ log(chalk.dim(' The backend connection dropped mid-turn; any answer above is partial. Resend to retry.'));
843
+ traceErrors.push(`stream interrupted: ${event.data.error || 'connection lost'}`);
844
+ hasOutput = true;
845
+ break;
642
846
  case 'heartbeat':
643
847
  case 'keepalive': {
644
848
  const elapsedMs = event.data.elapsed_ms || event.data.elapsed;
@@ -1066,8 +1270,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1066
1270
  case 'llm_start': {
1067
1271
  // From UCB: has model, prompt_tokens_est, has_tools
1068
1272
  // From AgentRuntime: has turn, timestamp (minimal)
1273
+ llmFired = true;
1069
1274
  const llmModel = event.data.model || 'auto';
1070
- traceModel = llmModel;
1275
+ if (event.data.model && event.data.model !== 'auto' && event.data.model !== 'unknown') {
1276
+ traceModel = event.data.model;
1277
+ }
1071
1278
  const promptTokens = event.data.prompt_tokens_est || 0;
1072
1279
  const hasTools2 = event.data.has_tools ? ' + tools' : '';
1073
1280
  const turnInfo = event.data.turn ? `Turn ${event.data.turn} · ` : '';
@@ -1083,6 +1290,8 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1083
1290
  case 'llm_done':
1084
1291
  case 'llm_end': {
1085
1292
  stopSpinner();
1293
+ if (tokensStarted)
1294
+ break; // complete handler prints timing metadata
1086
1295
  const llmMs = event.data.llm_time_ms || event.data.duration_ms || 0;
1087
1296
  const tokensUsed = event.data.tokens_used || event.data.tokens || 0;
1088
1297
  const llmModelUsed = event.data.model_used || event.data.model || 'default';
@@ -1093,21 +1302,32 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1093
1302
  }
1094
1303
  case 'middleware_progress': {
1095
1304
  const mwName = event.data.middleware || event.data.stage || 'middleware';
1096
- const mwStatus = event.data.status || 'running';
1097
1305
  const mwDetail = event.data.detail || '';
1306
+ const mwElapsed = event.data.elapsed_ms ? ` ${Math.round(event.data.elapsed_ms)}ms` : '';
1098
1307
  lastStage = mwName;
1099
- const mwIcon = mwStatus === 'complete' ? chalk.green('✓') : chalk.yellow('');
1100
- const detailStr = mwDetail ? chalk.dim(` (${mwDetail})`) : '';
1308
+ const mwLabel = `${mwName}${mwDetail ? ` (${mwDetail})` : ''}`;
1309
+ // FULL TRACE (default): print a permanent line for every pipeline
1310
+ // stage — even after the LLM fires — so the user sees exactly what
1311
+ // the system is doing and where the time goes, every turn.
1312
+ if (TRACE_FULL) {
1313
+ log(chalk.dim(` ⚙ ${mwLabel}${mwElapsed}`));
1314
+ if (steeringBar?.active)
1315
+ steeringBar.setStatus(`${mwName}...`);
1316
+ break;
1317
+ }
1318
+ // QUIET mode: after LLM fires, suppress visible lines — bar status only.
1319
+ if (tokensStarted || llmFired) {
1320
+ if (steeringBar?.active)
1321
+ steeringBar.setStatus(`${mwName}...`);
1322
+ break;
1323
+ }
1324
+ // QUIET mode pre-generation: ONE transient status line instead of a
1325
+ // permanent line per middleware (avoids "→ planexecutor: running" spam).
1101
1326
  if (steeringBar?.active) {
1102
- // Log line goes through interceptor (one bar redraw).
1103
- // Status updates without extra redraws.
1104
- console.log(chalk.dim(` ${mwIcon} ${mwName}: ${mwStatus}${detailStr}`));
1105
- steeringBar.setStatus(`${mwName}...`);
1327
+ steeringBar.setStatus(`${mwLabel}...`);
1106
1328
  }
1107
1329
  else {
1108
- stopSpinner();
1109
- log(chalk.dim(` ${mwIcon} ${mwName}: ${mwStatus}${detailStr}`));
1110
- startSpinner(chalk.dim(`${mwName}...`));
1330
+ startSpinner(chalk.dim(`${mwLabel}...`));
1111
1331
  }
1112
1332
  break;
1113
1333
  }
@@ -1181,6 +1401,7 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1181
1401
  const artCmd = event.data.retrieve_cmd || '';
1182
1402
  const artLang = event.data.language || '';
1183
1403
  const artPath = event.data.path || '';
1404
+ const artDownloadUrl = event.data.download_url || '';
1184
1405
  // Track artifact in session store
1185
1406
  _sessionArtifacts.push({
1186
1407
  filename: artFile,
@@ -1188,14 +1409,15 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1188
1409
  size: artSize,
1189
1410
  language: artLang,
1190
1411
  retrieve_cmd: artCmd,
1412
+ download_url: artDownloadUrl,
1191
1413
  timestamp: new Date().toISOString(),
1192
1414
  });
1193
1415
  const artIdx = _sessionArtifacts.length;
1194
- log(chalk.green.bold(`\n 📦 Artifact #${artIdx}: ${artFile} (${artSize} bytes)`));
1195
- if (artCmd) {
1196
- log(chalk.dim(` Retrieve: ${artCmd}`));
1197
- log(chalk.cyan(` Run /artifacts to list all, /get ${artIdx} to download`));
1198
- }
1416
+ const artSizeStr = artSize > 1024
1417
+ ? `${(artSize / 1024).toFixed(0)} KB`
1418
+ : `${artSize} bytes`;
1419
+ log(chalk.green.bold(`\n 📦 Artifact #${artIdx}: ${artFile} (${artSizeStr})`));
1420
+ log(chalk.cyan(` /get ${artIdx}`) + chalk.dim(` to save to current directory`));
1199
1421
  break;
1200
1422
  }
1201
1423
  // Pipeline stages that update spinner
@@ -1415,11 +1637,57 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1415
1637
  }
1416
1638
  break;
1417
1639
  }
1640
+ case 'mcts_iteration': {
1641
+ // Live MCTS planning progress — shows the planner is actually
1642
+ // working instead of a silent "planexecutor" blackout.
1643
+ const mi = event.data.iteration ?? '?';
1644
+ const mt = event.data.total ?? '?';
1645
+ const mbv = typeof event.data.best_value === 'number' ? event.data.best_value.toFixed(3) : '?';
1646
+ const mms = event.data.elapsed_ms ? ` ${Math.round(event.data.elapsed_ms)}ms` : '';
1647
+ lastStage = `mcts ${mi}/${mt}`;
1648
+ if (TRACE_FULL) {
1649
+ log(chalk.dim(` 🌲 MCTS: iter ${mi}/${mt} · best=${mbv} · depth=${event.data.depth ?? '?'}${mms}`));
1650
+ }
1651
+ else if (steeringBar?.active) {
1652
+ steeringBar.setStatus(`mcts ${mi}/${mt}...`);
1653
+ }
1654
+ break;
1655
+ }
1656
+ case 'plan_phase': {
1657
+ const ppPhase = event.data.name || event.data.phase || 'phase';
1658
+ const ppMs = event.data.elapsed_ms ? ` ${Math.round(event.data.elapsed_ms)}ms` : '';
1659
+ const ppCand = event.data.candidates != null ? ` · ${event.data.candidates} candidate(s)` : '';
1660
+ const ppIter = event.data.iterations ? ` · ${event.data.iterations} iters` : '';
1661
+ lastStage = `plan: ${ppPhase}`;
1662
+ if (TRACE_FULL) {
1663
+ log(chalk.dim(` 📐 Plan phase: ${ppPhase}${ppMs}${ppCand}${ppIter}`));
1664
+ }
1665
+ else if (steeringBar?.active) {
1666
+ steeringBar.setStatus(`plan: ${ppPhase}...`);
1667
+ }
1668
+ break;
1669
+ }
1670
+ case 'plan_start': {
1671
+ const psTier = event.data.tier || '';
1672
+ const psTimeout = event.data.timeout_ms ? ` (budget ${Math.round(event.data.timeout_ms)}ms)` : '';
1673
+ lastStage = 'planning';
1674
+ if (TRACE_FULL) {
1675
+ log(chalk.dim(` 📐 Planning started${psTier ? `: ${psTier}` : ''}${psTimeout}`));
1676
+ }
1677
+ else if (steeringBar?.active) {
1678
+ steeringBar.setStatus('planning...');
1679
+ }
1680
+ break;
1681
+ }
1418
1682
  case 'plan_complete':
1419
1683
  case 'plan_status': {
1420
1684
  const pcStatus = event.data.status || event.data.outcome || 'done';
1421
1685
  lastStage = `plan: ${pcStatus}`;
1422
- log(chalk.dim(` [plan_complete] ${pcStatus}`));
1686
+ // "timeout"/"no_plan" are normal — planning is optional enrichment,
1687
+ // the turn proceeds without a plan. Don't alarm the user.
1688
+ const pcBenign = pcStatus === 'timeout' || pcStatus === 'no_plan' || pcStatus === 'no_result';
1689
+ const pcIcon = pcBenign ? '○' : '✓';
1690
+ log(chalk.dim(` ${pcIcon} Planning ${pcBenign ? `skipped (${pcStatus}) — proceeding without a plan` : pcStatus}`));
1423
1691
  break;
1424
1692
  }
1425
1693
  case 'turn_progress': {
@@ -1433,6 +1701,48 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1433
1701
  }
1434
1702
  break;
1435
1703
  }
1704
+ // ── Pipeline transparency events ──
1705
+ case 'middleware_chain_complete': {
1706
+ const mwCount = event.data.count || 0;
1707
+ const mwMs = Math.round(event.data.total_ms || 0);
1708
+ log(chalk.dim(` 🔗 Pipeline: ${mwCount} stages, ${mwMs}ms`));
1709
+ break;
1710
+ }
1711
+ case 'context_flame_graph': {
1712
+ const neurons = event.data.neurons_fired || 0;
1713
+ const ctxTokens = event.data.total_tokens || 0;
1714
+ const quality = (event.data.quality_score || 0).toFixed(2);
1715
+ log(chalk.dim(` 🧠 Context: ${neurons} neurons, ${ctxTokens} tokens, quality=${quality}`));
1716
+ break;
1717
+ }
1718
+ case 'prefire_result': {
1719
+ if (event.data.tool) {
1720
+ log(chalk.dim(` ⚡ Pre-fired: ${event.data.tool} (${event.data.chars || 0} chars)`));
1721
+ }
1722
+ else if (event.data.tools) {
1723
+ log(chalk.dim(` ⚡ Pre-firing: ${event.data.tools.join(', ')}`));
1724
+ }
1725
+ break;
1726
+ }
1727
+ case 'context_eviction': {
1728
+ const evCount = event.data.evicted || 0;
1729
+ const evTokens = event.data.tokens_freed || 0;
1730
+ log(chalk.dim(` 🗑 Evicted: ${evCount} chunks, ${evTokens} tokens freed`));
1731
+ break;
1732
+ }
1733
+ case 'neuron_fire': {
1734
+ const nChunks = event.data.chunks_count || 0;
1735
+ const nTokens = event.data.total_tokens || 0;
1736
+ log(chalk.dim(` 🧬 Neurons: ${nChunks} chunks, ${nTokens} tokens`));
1737
+ break;
1738
+ }
1739
+ case 'notebook_export': {
1740
+ log(chalk.dim(` 📓 Notebook: ${event.data.notebook_id || ''}`));
1741
+ break;
1742
+ }
1743
+ case 'speculative_prefetch':
1744
+ case 'outcome_recorded':
1745
+ break; // silent — internal bookkeeping
1436
1746
  default: {
1437
1747
  // Show content from unexpected events as fallback
1438
1748
  if (event.data.content && !hasOutput) {
@@ -1441,10 +1751,24 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
1441
1751
  content += event.data.content;
1442
1752
  hasOutput = true;
1443
1753
  }
1444
- // Show meaningful unknown events so nothing is silently swallowed
1445
- const msg = event.data.message || event.data.reason || event.data.status || '';
1754
+ // Show meaningful unknown events so nothing is silently swallowed.
1755
+ // Pipeline substages are discriminated by `stage` (the wire type is
1756
+ // often 'pipeline'); label by stage and prefer a human field, else —
1757
+ // under full trace — show a compact scalar dump so telemetry like
1758
+ // llm_route / thinking_budget / reasoning_summary is never invisible.
1759
+ const d = event.data || {};
1760
+ const label = d.stage || event.type;
1761
+ let msg = d.message || d.reason || d.status || d.summary || d.detail || '';
1762
+ if (!msg && TRACE_FULL && (d.stage || event.type === 'pipeline')) {
1763
+ msg = Object.entries(d)
1764
+ .filter(([k, v]) => k !== 'type' && k !== 'stage' &&
1765
+ (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
1766
+ .slice(0, 4)
1767
+ .map(([k, v]) => `${k}=${String(v).slice(0, 30)}`)
1768
+ .join(' ');
1769
+ }
1446
1770
  if (msg && event.type !== 'debug') {
1447
- log(chalk.dim(` [${event.type}] ${String(msg).slice(0, 120)}`));
1771
+ log(chalk.dim(` [${label}] ${String(msg).slice(0, 120)}`));
1448
1772
  }
1449
1773
  break;
1450
1774
  }
@@ -1640,31 +1964,32 @@ export function formatTable(headers, rows) {
1640
1964
  const body = rows.map(row => row.map((cell, i) => pad(cell || '', widths[i])).join(' ')).join('\n');
1641
1965
  return `${chalk.bold(header)}\n${chalk.dim(sep)}\n${body}`;
1642
1966
  }
1643
- /* ── Steering Bar — dedicated input line during streaming ─────── */
1967
+ /* ── Steering Bar — fixed bottom bar via ANSI scroll regions ──── */
1644
1968
  /**
1645
- * Keeps a visible input line at the bottom of the terminal while
1646
- * stream output prints above. Works on Windows Terminal (no ANSI
1647
- * scroll regions).
1969
+ * Keeps a fixed input bar at the bottom of the terminal while
1970
+ * stream output scrolls above. Uses DECSTBM (Set Scrolling Region)
1971
+ * to physically separate the output area from the bar — no stdout
1972
+ * monkey-patching, no erase/redraw races.
1648
1973
  *
1649
- * How it works:
1650
- * 1. Intercepts process.stdout.write
1651
- * 2. Before each write: erase the bar line so output doesn't collide
1652
- * 3. After each write: redraw the bar on a fresh line below output
1653
- * 4. User keystrokes update the bar's displayed text in real time
1654
- * 5. On Enter, the bar clears and the repl routes to /chat/steer
1974
+ * Layout:
1975
+ * Rows 1 to (rows-3): SCROLL REGION — all output, tokens, spinners
1976
+ * Row (rows-2): Status spinner (e.g., "generation...")
1977
+ * Row (rows-1): Separator line ────────
1978
+ * Row (rows): [steer] > input
1655
1979
  *
1656
- * The bar line looks like:
1657
- * ── [steer] > what the user is typing_
1980
+ * Output cannot collide with the bar because they occupy different
1981
+ * terminal regions.
1658
1982
  */
1659
1983
  export class SteeringBar {
1660
1984
  _active = false;
1661
1985
  _inputText = '';
1662
1986
  _statusText = '';
1663
- _origWrite = null;
1664
- _barDrawn = false;
1665
- _debounceTimer = null;
1666
1987
  _spinFrame = 0;
1667
1988
  _spinTimer = null;
1989
+ _tokenMode = false;
1990
+ _resizeHandler = null;
1991
+ _resizeTimer = null;
1992
+ _lastRows = 0; // Last geometry we drew the bar at (for resize cleanup)
1668
1993
  static _SPIN_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1669
1994
  get active() { return this._active; }
1670
1995
  get inputText() { return this._inputText; }
@@ -1674,90 +1999,90 @@ export class SteeringBar {
1674
1999
  this._active = true;
1675
2000
  this._inputText = '';
1676
2001
  this._statusText = '';
1677
- this._barDrawn = false;
1678
2002
  if (!process.stdout.isTTY)
1679
2003
  return;
1680
- const self = this;
1681
- this._origWrite = process.stdout.write.bind(process.stdout);
1682
- const origWrite = this._origWrite;
1683
- process.stdout.write = function (chunk, encodingOrCb, cb) {
1684
- if (!self._active) {
1685
- return origWrite(chunk, encodingOrCb, cb);
1686
- }
1687
- // Erase bar before writing output
1688
- if (self._barDrawn) {
1689
- origWrite('\x1b8\x1b[J');
1690
- self._barDrawn = false;
2004
+ // Set up scroll region and draw initial bar
2005
+ this._setupRegion();
2006
+ // Handle terminal resize. Dragging a terminal edge fires many 'resize'
2007
+ // events in a burst; redrawing the scroll-region bar on each one (against
2008
+ // partially-reflowed geometry) is what left a stale duplicate green
2009
+ // "aither >" prompt behind. Debounce, then do a CLEAN repaint that resets
2010
+ // the scroll region and erases the OLD bar lines before redrawing.
2011
+ this._resizeHandler = () => this._handleResize();
2012
+ process.stdout.on('resize', this._resizeHandler);
2013
+ }
2014
+ /** Debounced, artifact-free resize repaint. */
2015
+ _handleResize() {
2016
+ if (!this._active || !process.stdout.isTTY)
2017
+ return;
2018
+ if (this._resizeTimer)
2019
+ clearTimeout(this._resizeTimer);
2020
+ this._resizeTimer = setTimeout(() => {
2021
+ this._resizeTimer = null;
2022
+ if (!this._active || !process.stdout.isTTY)
2023
+ return;
2024
+ // Clear the bar lines at their PREVIOUS positions (the source of the
2025
+ // stale duplicate prompt) with the scroll region reset to full screen
2026
+ // so we can address any row, then re-establish the region + redraw.
2027
+ const oldRows = this._lastRows || (process.stdout.rows || 24);
2028
+ let buf = '\x1b7\x1b[r'; // save cursor, reset scroll region to full
2029
+ for (let r = Math.max(1, oldRows - 2); r <= oldRows; r++) {
2030
+ buf += `\x1b[${r};1H\x1b[2K`; // clear each old bar line
1691
2031
  }
1692
- // Write the actual output
1693
- const result = origWrite(chunk, encodingOrCb, cb);
1694
- // Schedule bar redraw (debounced token bursts produce many writes)
1695
- if (self._debounceTimer)
1696
- clearTimeout(self._debounceTimer);
1697
- self._debounceTimer = setTimeout(() => {
1698
- self._debounceTimer = null;
1699
- if (self._active)
1700
- self._drawBar(origWrite);
1701
- }, 30);
1702
- return result;
1703
- };
1704
- // Draw initial bar
1705
- this._drawBar(origWrite);
2032
+ buf += '\x1b8'; // restore cursor
2033
+ process.stdout.write(buf);
2034
+ this._setupRegion(); // recompute region for new geometry + redraw
2035
+ }, 60);
1706
2036
  }
1707
2037
  deactivate() {
1708
2038
  if (!this._active)
1709
2039
  return;
1710
2040
  this._active = false;
1711
2041
  this._statusText = '';
1712
- if (this._debounceTimer) {
1713
- clearTimeout(this._debounceTimer);
1714
- this._debounceTimer = null;
1715
- }
1716
2042
  if (this._spinTimer) {
1717
2043
  clearInterval(this._spinTimer);
1718
2044
  this._spinTimer = null;
1719
2045
  }
1720
- // Erase bar if drawn
1721
- if (this._barDrawn && this._origWrite) {
1722
- this._origWrite('\x1b8\x1b[J');
1723
- this._barDrawn = false;
2046
+ if (this._resizeTimer) {
2047
+ clearTimeout(this._resizeTimer);
2048
+ this._resizeTimer = null;
1724
2049
  }
1725
- // Restore stdout.write
1726
- if (this._origWrite) {
1727
- process.stdout.write = this._origWrite;
1728
- this._origWrite = null;
2050
+ if (this._resizeHandler) {
2051
+ process.stdout.removeListener('resize', this._resizeHandler);
2052
+ this._resizeHandler = null;
1729
2053
  }
1730
- }
1731
- /** Schedule a single debounced redraw. All state changes route here. */
1732
- _scheduleRedraw() {
1733
- if (!this._active || !this._origWrite)
1734
- return;
1735
- if (this._debounceTimer)
1736
- clearTimeout(this._debounceTimer);
1737
- const w = this._origWrite;
1738
- this._debounceTimer = setTimeout(() => {
1739
- this._debounceTimer = null;
1740
- if (!this._active)
1741
- return;
1742
- if (this._barDrawn) {
1743
- w('\x1b8\x1b[J');
1744
- this._barDrawn = false;
2054
+ if (process.stdout.isTTY) {
2055
+ const rows = process.stdout.rows || 24;
2056
+ // Save cursor (in scroll region), clear the bottom 3 bar lines
2057
+ let buf = '\x1b7';
2058
+ for (let r = rows - 2; r <= rows; r++) {
2059
+ buf += `\x1b[${r};1H\x1b[2K`;
1745
2060
  }
1746
- this._drawBar(w);
1747
- }, 16);
2061
+ // Reset scroll region to full screen, restore cursor, move to new line
2062
+ buf += '\x1b[r\x1b8\n';
2063
+ process.stdout.write(buf);
2064
+ }
2065
+ }
2066
+ /** Set scroll region and draw bar, preserving cursor position. */
2067
+ _setupRegion() {
2068
+ const rows = process.stdout.rows || 24;
2069
+ const scrollEnd = Math.max(1, rows - 3);
2070
+ // Save cursor, set scroll region, restore cursor — content stays put
2071
+ process.stdout.write(`\x1b7\x1b[1;${scrollEnd}r\x1b8`);
2072
+ this._lastRows = rows; // remember geometry for the next resize cleanup
2073
+ this._drawBar();
1748
2074
  }
1749
2075
  /** Show spinner + status text in the bar (replaces ora when bar is active). */
1750
2076
  setStatus(text) {
1751
2077
  this._statusText = text;
1752
- if (!this._spinTimer && this._active && this._origWrite) {
1753
- const origWrite = this._origWrite;
2078
+ if (!this._spinTimer && this._active) {
1754
2079
  this._spinTimer = setInterval(() => {
1755
2080
  this._spinFrame = (this._spinFrame + 1) % SteeringBar._SPIN_CHARS.length;
1756
2081
  if (this._active)
1757
- this._scheduleRedraw();
2082
+ this._drawBar();
1758
2083
  }, 80);
1759
2084
  }
1760
- // Don't redraw immediately — the interceptor or spin timer will do it
2085
+ this._drawBar();
1761
2086
  }
1762
2087
  /** Clear status text. */
1763
2088
  clearStatus() {
@@ -1766,47 +2091,64 @@ export class SteeringBar {
1766
2091
  clearInterval(this._spinTimer);
1767
2092
  this._spinTimer = null;
1768
2093
  }
1769
- // Don't redraw — next stdout write or setStatus will handle it
2094
+ this._drawBar();
1770
2095
  }
1771
2096
  /** Update displayed input text (called from readline _ttyWrite hook). */
1772
2097
  setInput(text) {
1773
2098
  this._inputText = text;
1774
- if (this._active && this._origWrite) {
1775
- // Keystroke — redraw immediately (no debounce for input responsiveness)
1776
- if (this._debounceTimer)
1777
- clearTimeout(this._debounceTimer);
1778
- this._debounceTimer = null;
1779
- if (this._barDrawn) {
1780
- this._origWrite('\x1b8\x1b[J');
1781
- this._barDrawn = false;
1782
- }
1783
- this._drawBar(this._origWrite);
1784
- }
2099
+ this._drawBar();
1785
2100
  }
1786
2101
  /** Clear input after steer send. */
1787
2102
  clearInput() {
1788
2103
  this._inputText = '';
1789
- this._scheduleRedraw();
2104
+ this._drawBar();
2105
+ }
2106
+ enterTokenMode() {
2107
+ this._tokenMode = true;
2108
+ if (this._spinTimer) {
2109
+ clearInterval(this._spinTimer);
2110
+ this._spinTimer = null;
2111
+ }
2112
+ }
2113
+ exitTokenMode() {
2114
+ this._tokenMode = false;
1790
2115
  }
1791
- _drawBar(write) {
2116
+ /**
2117
+ * Draw the fixed bottom bar — single atomic write.
2118
+ * Uses absolute positioning outside the scroll region, then restores
2119
+ * cursor back into the scroll region.
2120
+ */
2121
+ _drawBar() {
2122
+ if (!this._active || !process.stdout.isTTY)
2123
+ return;
2124
+ const rows = process.stdout.rows || 24;
1792
2125
  const cols = process.stdout.columns || 80;
1793
2126
  const barWidth = Math.min(cols, 60);
1794
- const sep = chalk.dim('─'.repeat(barWidth));
1795
- // Status line (spinner + status text)
1796
- let statusLine = '';
2127
+ // Status line content
2128
+ let statusContent = '';
1797
2129
  if (this._statusText) {
1798
2130
  const frame = SteeringBar._SPIN_CHARS[this._spinFrame];
1799
- statusLine = `\n${chalk.cyan(frame)} ${chalk.dim(this._statusText)}\x1b[K`;
2131
+ statusContent = `${chalk.cyan(frame)} ${chalk.dim(this._statusText)}`;
1800
2132
  }
1801
- // Input prompt
1802
- const prefix = chalk.dim(' [steer]') + chalk.yellow(' > ');
1803
- const prefixLen = 11;
2133
+ // Input line content
2134
+ const prefix = chalk.dim(' aither') + chalk.yellow(' > ');
2135
+ const prefixLen = 10;
1804
2136
  const maxInput = cols - prefixLen - 1;
1805
2137
  const display = this._inputText.length > maxInput
1806
2138
  ? this._inputText.slice(-maxInput)
1807
2139
  : this._inputText;
1808
- write('\x1b7'); // Save cursor position (end of content)
1809
- write(`${statusLine}\n${sep}\n${prefix}${display}\x1b[K`);
1810
- this._barDrawn = true;
2140
+ const sep = chalk.dim(''.repeat(barWidth));
2141
+ // Build entire bar as one atomic buffer:
2142
+ // Save cursor (in scroll region)
2143
+ let buf = '\x1b7';
2144
+ // Row rows-2: status line
2145
+ buf += `\x1b[${rows - 2};1H\x1b[2K${statusContent}`;
2146
+ // Row rows-1: separator
2147
+ buf += `\x1b[${rows - 1};1H\x1b[2K${sep}`;
2148
+ // Row rows: input prompt
2149
+ buf += `\x1b[${rows};1H\x1b[2K${prefix}${display}`;
2150
+ // Restore cursor (back to scroll region)
2151
+ buf += '\x1b8';
2152
+ process.stdout.write(buf);
1811
2153
  }
1812
2154
  }