@noobdemon/noob-cli 1.12.14 → 1.12.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/src/tools.js CHANGED
@@ -110,6 +110,13 @@ export function removeRoot(p) {
110
110
  export function listRoots() {
111
111
  return [cwd(), ...extraRoots];
112
112
  }
113
+ // Xoá toàn bộ extraRoots khỏi bộ nhớ phiên (KHÔNG đụng dirs.json). Dùng cho test
114
+ // isolation: extraRoots là module-global, loadWorkspaceRoots() ở import nạp sẵn
115
+ // nội dung dirs.json → leak sang test. Gọi trong beforeEach để mỗi test thấy scope
116
+ // sạch (chỉ cwd đã chdir). KHÔNG persist — production không nên gọi.
117
+ export function clearRoots() {
118
+ extraRoots.clear();
119
+ }
113
120
  function within(root, full) {
114
121
  if (full === root) return true;
115
122
  const rel = path.relative(root, full);
@@ -180,7 +187,10 @@ function htmlToText(html) {
180
187
  s = s.replace(/<!--[\s\S]*?-->/g, ' ');
181
188
  s = s.replace(/<(script|style|head|noscript|svg)\b[\s\S]*?<\/\1>/gi, ' ');
182
189
  // Block-level tag → newline để giữ cấu trúc đoạn.
183
- s = s.replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6]|ul|ol|table|blockquote)>/gi, '\n');
190
+ s = s.replace(
191
+ /<\/(p|div|section|article|header|footer|li|tr|h[1-6]|ul|ol|table|blockquote)>/gi,
192
+ '\n'
193
+ );
184
194
  s = s.replace(/<br\s*\/?>/gi, '\n');
185
195
  s = s.replace(/<[^>]+>/g, ' '); // gỡ mọi tag còn lại
186
196
  // Decode entity phổ biến.
@@ -541,15 +551,45 @@ export const TOOLS = {
541
551
  let out = '';
542
552
  let timedOut = false;
543
553
  let aborted = false;
554
+ let settled = false;
555
+
556
+ // Resolve ĐÚNG 1 lần. Không phụ thuộc child 'close' để thoát: gradle daemon
557
+ // / JVM grandchildren có thể giữ stdio pipe sống sau taskkill → 'close'
558
+ // không bao giờ bắn → Promise treo vĩnh viễn → tool kẹt → model không
559
+ // được gọi lại. Đây là gốc bug treo CLI khi chạy compileJava.
560
+ const finish = (text) => {
561
+ if (settled) return;
562
+ settled = true;
563
+ clearTimeout(killer);
564
+ clearTimeout(graceTimer);
565
+ signal?.removeEventListener?.('abort', onAbort);
566
+ resolve(text);
567
+ };
568
+
569
+ // Sau khi ra lệnh kill, không chờ 'close' mãi. Cho cây tiến trình 2s ân
570
+ // hạn để thoát sạch; quá hạn → resolve cưỡng bức để giải phóng tool.
571
+ let graceTimer = null;
572
+ const forceResolveAfterKill = () => {
573
+ if (graceTimer || settled) return;
574
+ graceTimer = setTimeout(() => {
575
+ const tail = aborted
576
+ ? `\n[aborted by user (Ctrl+C) — killed (tiến trình con có thể còn sống nền).]`
577
+ : `\n[timed out after ${Math.round(timeout / 1000)}s — killed (tiến trình con có thể còn sống nền). Nếu là server / task chạy lâu, chạy lại với {"background": true}.]`;
578
+ finish(clip((out.trim() || '(no output)') + tail));
579
+ }, 2000);
580
+ };
581
+
544
582
  const killer = setTimeout(() => {
545
583
  timedOut = true;
546
584
  killBgTree(child);
585
+ forceResolveAfterKill();
547
586
  }, timeout);
548
587
  // Ctrl+C trong lúc command đang chạy → kill cây tiến trình con (Windows
549
588
  // dùng taskkill /T để diệt cả grand-children, vd npm spawn node).
550
589
  const onAbort = () => {
551
590
  aborted = true;
552
591
  killBgTree(child);
592
+ forceResolveAfterKill();
553
593
  };
554
594
  if (signal) {
555
595
  if (signal.aborted) onAbort();
@@ -558,19 +598,15 @@ export const TOOLS = {
558
598
  child.stdout.on('data', (d) => (out += d));
559
599
  child.stderr.on('data', (d) => (out += d));
560
600
  child.on('error', (e) => {
561
- clearTimeout(killer);
562
- signal?.removeEventListener?.('abort', onAbort);
563
- resolve(`Failed to start command: ${e.message}`);
601
+ finish(`Failed to start command: ${e.message}`);
564
602
  });
565
603
  child.on('close', (code) => {
566
- clearTimeout(killer);
567
- signal?.removeEventListener?.('abort', onAbort);
568
604
  const tail = aborted
569
605
  ? `\n[aborted by user (Ctrl+C) — killed.]`
570
606
  : timedOut
571
607
  ? `\n[timed out after ${Math.round(timeout / 1000)}s — killed. If this is a server or other long-running task, re-run with {"background": true} instead.]`
572
608
  : `\n[exit code ${code}]`;
573
- resolve(clip((out.trim() || '(no output)') + tail));
609
+ finish(clip((out.trim() || '(no output)') + tail));
574
610
  });
575
611
  });
576
612
  },
@@ -652,7 +688,10 @@ export const TOOLS = {
652
688
  const out = await kgAddObservations([{ entityName, contents }]);
653
689
  const added = out[0]?.addedObservations || [];
654
690
  if (!added.length) return `Không có observation mới cho '${entityName}' (đã trùng)`;
655
- return `Đã thêm ${added.length} observation cho '${entityName}':\n` + added.map((o) => ` - ${o}`).join('\n');
691
+ return (
692
+ `Đã thêm ${added.length} observation cho '${entityName}':\n` +
693
+ added.map((o) => ` - ${o}`).join('\n')
694
+ );
656
695
  },
657
696
  };
658
697
 
package/src/tui.js CHANGED
@@ -107,7 +107,7 @@ function truncStr(s, max) {
107
107
  return s.length > max ? s.slice(0, max - 1) + '…' : s;
108
108
  }
109
109
 
110
- export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
110
+ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, completer } = {}) {
111
111
  const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== '0';
112
112
  const cols = () => process.stdout.columns || 80;
113
113
 
@@ -153,6 +153,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
153
153
  setBusy() {},
154
154
  setMeta() {},
155
155
  setTodos() {},
156
+ setUltra() {},
157
+ setAgentMode() {},
158
+ setGoal() {},
159
+ setBgAgents() {},
160
+ pushAgentLog() {},
161
+ clearAgentLog() {},
156
162
  setPrompt() {},
157
163
  read() {
158
164
  if (queue.length) return Promise.resolve(queue.shift());
@@ -180,6 +186,16 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
180
186
  let busyLabel = '';
181
187
  let busyMeta = ''; // chuỗi phụ (elapsed + token) repl đẩy vào qua setMeta()
182
188
  let todos = []; // [{text, done}] — danh sách todo đang chạy, repl parse từ model output
189
+ let ultra = false; // ULTRA mode đang bật? → segment trong status bar
190
+ let bgAgents = 0; // số sub-agent / workflow chạy nền → segment + gợi ý Ctrl+O
191
+ let agentMode = ''; // build | plan | compose — UI hint mode → segment status bar (Ctrl+T / /mode)
192
+ let goalText = ''; // HARD GOAL hiện tại (/goal) → segment 🎯 status bar (truncate)
193
+ // Ring buffer log sub-agent nền: KHÔNG in ra UI chính, chỉ vào đây. Ctrl+O mở
194
+ // overlay xem. Mỗi entry: chuỗi đã format (có thể kèm màu). Giữ tối đa MAX_LOG.
195
+ const MAX_AGENT_LOG = 500;
196
+ const agentLog = [];
197
+ let overlayOpen = false; // overlay log đang mở?
198
+ let overlayScroll = 0; // offset cuộn từ ĐÁY (0 = xem dòng mới nhất)
183
199
  let frame = 0;
184
200
  let frameTimer = null;
185
201
  let prevRows = 0;
@@ -354,38 +370,71 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
354
370
  return wrapText(liveOut, cols(), 2);
355
371
  }
356
372
  const spin = FRAMES[frame % FRAMES.length];
357
- // Todo progress bar: hiện khi todos, thay thế statusText/busyLabel
373
+ // Badge ghép vào mọi dòng status: ULTRA bật/tắt + số sub-agent nền (gợi ý
374
+ // Ctrl+O xem log). Reset ANSI từng đoạn (chalk tự đóng) → không bleed.
375
+ const modeBadge = agentMode ? c.tool(`◆ ${agentMode}`) : '';
376
+ const goalBadge = goalText ? c.accent('🎯 ') + c.dim(truncStr(goalText, 28)) : '';
377
+ const ultraBadge = ultra ? c.err('🚀 ULTRA') : '';
378
+ const bgBadge = bgAgents ? c.accent(`⊕ ${bgAgents} nền`) + c.dim(' (Ctrl+O)') : '';
379
+ const badges = [modeBadge, goalBadge, ultraBadge, bgBadge].filter(Boolean).join(c.dim(' · '));
380
+ const badgeTail = badges ? c.dim(' · ') + badges : '';
381
+ // Todo progress: 1 dòng gọn dùng chung (📋 done/total bar pct%). Dựng SẴN ở
382
+ // đây để ghép vào status line theo trạng thái — KHÔNG return sớm nuốt status.
383
+ // Rảnh → chỉ dòng này. Busy → ghép cạnh spinner. Mất todo không nuốt spinner.
384
+ let progress = null;
385
+ let taskLine = null;
358
386
  if (todos.length) {
359
387
  const done = todos.filter((t) => t.done).length;
360
388
  const total = todos.length;
361
389
  const current = todos.find((t) => !t.done);
362
390
  const pct = Math.round((done / total) * 100);
363
- // Thanh progress: ████░░░░ 2/5 (40%)
364
- const barW = Math.min(12, Math.floor(cols() * 0.15));
391
+ // Thanh progress dày hơn, màu phân biệt: phần done = ember, còn lại = dim.
392
+ const barW = Math.min(16, Math.max(8, Math.floor(cols() * 0.2)));
365
393
  const filled = Math.round((done / total) * barW);
366
- const bar = '█'.repeat(filled) + '░'.repeat(barW - filled);
367
- const progress = c.ok(`${done}/${total}`) + c.dim(` ${bar} ${pct}%`);
368
- // Task đang làm: ...
369
- const taskLine = current
370
- ? c.accent('▸ ') + c.dim(truncStr(current.text, cols() - 12))
371
- : c.ok(' hoàn thành!');
372
- const meta = busy && busyMeta ? c.dim(' · ' + busyMeta) : '';
373
- const tail = busy ? c.dim(' · Ctrl+C') : '';
374
- return [progress + meta + tail, taskLine];
394
+ const bar = c.accent('█'.repeat(filled)) + c.dim('░'.repeat(barW - filled));
395
+ progress =
396
+ c.accent('📋 ') + c.ok(`${done}`) + c.dim(`/${total}`) + ' ' + bar + c.dim(` ${pct}%`);
397
+ // Task đang làm (▸ amber) — CHỈ dùng lúc busy (dòng 2). Rảnh bỏ để gọn 1 dòng.
398
+ taskLine = current
399
+ ? c.tool(' ') + c.user(truncStr(current.text, cols() - 8))
400
+ : c.ok(' tất cả todo đã xong!');
375
401
  }
402
+
376
403
  // Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
377
404
  // người dùng thấy phiên đang sống — kể cả lúc model im giữa các bước.
378
405
  if (statusText) {
379
406
  const meta = busy && busyMeta ? c.dim(' · ' + busyMeta) : '';
380
407
  const tail = busy ? c.dim(' · Ctrl+C để dừng') : '';
381
- const line = c.dim(spin + ' ') + statusText + meta + tail;
408
+ // Busy + todos → spinner + progress CHUNG dòng 1, task line dòng 2.
409
+ if (busy && progress) {
410
+ const line = c.dim(spin + ' ') + progress + meta + tail + badgeTail;
411
+ const out = wrapText(line, cols(), 2);
412
+ if (taskLine) out.push(taskLine);
413
+ return out;
414
+ }
415
+ const line = c.dim(spin + ' ') + statusText + meta + tail + badgeTail;
382
416
  return wrapText(line, cols(), 2);
383
417
  }
384
418
  if (busy) {
385
- const meta = busyMeta ? ' · ' + busyMeta : '';
386
- const line = c.dim(spin + ' ' + (busyLabel || 'đang chạy') + meta + ' · Ctrl+C để dừng');
419
+ const meta = busyMeta ? c.dim(' · ' + busyMeta) : '';
420
+ // Busy + todos spinner + progress chung dòng 1, task line dòng 2.
421
+ if (progress) {
422
+ const line = c.dim(spin + ' ') + progress + meta + c.dim(' · Ctrl+C') + badgeTail;
423
+ const out = wrapText(line, cols(), 2);
424
+ if (taskLine) out.push(taskLine);
425
+ return out;
426
+ }
427
+ const line =
428
+ c.dim(spin + ' ' + (busyLabel || 'đang chạy')) +
429
+ meta +
430
+ c.dim(' · Ctrl+C để dừng') +
431
+ badgeTail;
387
432
  return wrapText(line, cols(), 2);
388
433
  }
434
+ // Rảnh + todos → 1 dòng progress gọn (KHÔNG task/ctx line, không nuốt ô nhập).
435
+ if (progress) return wrapText(progress + badgeTail, cols(), 2);
436
+ // Rảnh nhưng có ULTRA / sub-agent nền → vẫn hiện badge để user biết trạng thái.
437
+ if (badges) return wrapText(' ' + badges, cols(), 2);
389
438
  return null;
390
439
  }
391
440
  function menuRows() {
@@ -405,11 +454,45 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
405
454
  );
406
455
  });
407
456
  }
457
+ // Overlay log sub-agent nền (Ctrl+O). Thay thế vùng output chính (top/menu)
458
+ // bằng panel cuộn được: header + N dòng log gần nhất theo overlayScroll. KHÔNG
459
+ // đụng renderBar/cursor math — overlay chỉ là các dòng phía TRÊN thanh nhập.
460
+ const OVERLAY_LINES = 12;
461
+ function overlayRows() {
462
+ const head =
463
+ c.accent('┄┄ log sub-agent nền ') +
464
+ c.dim(`(${agentLog.length} dòng · ↑/↓ cuộn · Ctrl+O đóng)`);
465
+ if (!agentLog.length) {
466
+ return [head, c.dim(' (chưa có log — sub-agent nền sẽ ghi vào đây)')];
467
+ }
468
+ // Cửa sổ [start, end) tính từ ĐÁY theo overlayScroll (0 = đáy/mới nhất).
469
+ const end = Math.max(0, agentLog.length - overlayScroll);
470
+ const start = Math.max(0, end - OVERLAY_LINES);
471
+ const body = [];
472
+ const width = Math.max(8, cols() - 2);
473
+ for (const raw of agentLog.slice(start, end)) {
474
+ // Mỗi entry có thể dài → soft-wrap, nhưng chỉ lấy dòng đầu + '…' để panel
475
+ // gọn (1 entry = 1 dòng panel). User cuộn để xem từng entry.
476
+ const oneLine = String(raw).replace(/\n/g, ' ');
477
+ const wrapped = softWrapLine(oneLine, width);
478
+ body.push(
479
+ ' ' + (wrapped.length > 1 ? wrapped[0].replace(ANSI_RE, '') + c.dim('…') : wrapped[0])
480
+ );
481
+ }
482
+ const more = overlayScroll > 0 ? c.dim(` ↓ còn ${overlayScroll} dòng mới hơn`) : '';
483
+ return more ? [head, ...body, more] : [head, ...body];
484
+ }
408
485
  function rows() {
409
486
  const r = [];
410
- const top = topRow();
411
- if (top !== null) r.push(...top);
412
- for (const mr of menuRows()) r.push(mr);
487
+ if (overlayOpen) {
488
+ // Overlay mở: thay top/menu bằng panel log. Thanh nhập vẫn vẽ bên dưới để
489
+ // user /lệnh (vd Ctrl+O đóng) mà không mất con trỏ.
490
+ r.push(...overlayRows());
491
+ } else {
492
+ const top = topRow();
493
+ if (top !== null) r.push(...top);
494
+ for (const mr of menuRows()) r.push(mr);
495
+ }
413
496
  const bar = renderBar();
414
497
  if (bar) r.push(...bar.split('\n'));
415
498
  return r;
@@ -532,9 +615,31 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
532
615
  refreshMenu();
533
616
  draw();
534
617
  }
618
+ // Echo tin nhắn user vừa gửi thành KHỐI nền màu, gutter ember mỗi dòng. Plain
619
+ // text (không phải coloredInput) để bg phủ đều; mỗi dòng tự reset (tránh bleed
620
+ // màu sang hết chiều rộng terminal). Paste chip → 1 dòng tóm tắt [pasted...].
621
+ function echoUserBlock() {
622
+ const plain = cells.map(cellPlain).join('');
623
+ if (!plain.trim()) return promptLabel + coloredInput();
624
+ const gutter = c.userGutter('▌') + ' ';
625
+ const gutterW = 2; // '▌' + space (▌ là 1 cột)
626
+ const budget = Math.max(8, cols() - gutterW);
627
+ const wrapped = plain
628
+ .split('\n')
629
+ .flatMap((ln) => softWrapLine(ln, budget))
630
+ .map((ln) => {
631
+ // softWrapLine có thể đính RESET cuối khi text gốc có ANSI; strip để bg
632
+ // phủ liền, rồi pad tới budget cho khối nền chữ nhật đều mép phải.
633
+ const body = ln.replace(ANSI_RE, '');
634
+ const padded = body + ' '.repeat(Math.max(0, budget - visLen(body)));
635
+ return gutter + c.userBg(' ' + padded + ' ');
636
+ });
637
+ return wrapped.join('\n');
638
+ }
639
+
535
640
  function submit() {
536
641
  const full = fullText();
537
- const echo = promptLabel + coloredInput();
642
+ const echo = echoUserBlock();
538
643
  if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
539
644
  submitHistory.push(full);
540
645
  if (submitHistory.length > 200) submitHistory.shift();
@@ -616,8 +721,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
616
721
  if (fin === 'Z')
617
722
  onShiftTab?.(); // Shift+Tab = bật/tắt yolo
618
723
  else if (fin === 'A' || fin === 'B') {
619
- // ↑/↓: điều hướng menu gợi ý nếu đang mở, ngược lại gọi lại lịch sử.
620
- if (menu.length) {
724
+ // ↑/↓: overlay mở → cuộn log; menu mở điều hướng menu; còn lại lịch sử.
725
+ if (overlayOpen) {
726
+ // ↑ = lùi về dòng cũ (tăng scroll), ↓ = về đáy (giảm scroll).
727
+ if (fin === 'A') overlayScroll = Math.min(agentLog.length, overlayScroll + 1);
728
+ else overlayScroll = Math.max(0, overlayScroll - 1);
729
+ draw();
730
+ } else if (menu.length) {
621
731
  menuIdx =
622
732
  fin === 'A' ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length;
623
733
  draw();
@@ -689,6 +799,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
689
799
  i++;
690
800
  continue;
691
801
  } // Ctrl+L: clear screen
802
+ if (ch === '\x0f') {
803
+ overlayOpen = !overlayOpen;
804
+ overlayScroll = 0; // mở lại = xem đáy (dòng mới nhất)
805
+ draw();
806
+ i++;
807
+ continue;
808
+ } // Ctrl+O: bật/tắt overlay log sub-agent nền
809
+ if (ch === '\x14') {
810
+ onCtrlT?.();
811
+ i++;
812
+ continue;
813
+ } // Ctrl+T: cycle build/plan/compose mode
692
814
  if (ch === '\x01') {
693
815
  cur = 0;
694
816
  draw();
@@ -818,6 +940,35 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
818
940
  todos = Array.isArray(items) ? items : [];
819
941
  draw();
820
942
  },
943
+ setUltra(on) {
944
+ ultra = !!on;
945
+ draw();
946
+ },
947
+ setAgentMode(mode) {
948
+ agentMode = mode || '';
949
+ draw();
950
+ },
951
+ setGoal(text) {
952
+ goalText = text || '';
953
+ draw();
954
+ },
955
+ setBgAgents(n) {
956
+ bgAgents = Math.max(0, n | 0);
957
+ draw();
958
+ },
959
+ // Đẩy 1 dòng log sub-agent nền vào ring buffer (KHÔNG in ra UI chính). Nếu
960
+ // overlay đang mở + đang xem đáy (scroll=0) → redraw để thấy dòng mới ngay.
961
+ pushAgentLog(line) {
962
+ if (line == null) return;
963
+ agentLog.push(String(line));
964
+ if (agentLog.length > MAX_AGENT_LOG) agentLog.shift();
965
+ if (overlayOpen && overlayScroll === 0) draw();
966
+ },
967
+ clearAgentLog() {
968
+ agentLog.length = 0;
969
+ overlayScroll = 0;
970
+ if (overlayOpen) draw();
971
+ },
821
972
  setPrompt(label) {
822
973
  promptLabel = label || '';
823
974
  draw();
package/src/ui.js CHANGED
@@ -26,6 +26,10 @@ export const c = {
26
26
  ok: chalk.hex('#84a98c'),
27
27
  err: chalk.hex('#c0524b'),
28
28
  accent: chalk.hex('#ff7a45'),
29
+ // Tin nhắn user gửi: nền warm-gray đậm + chữ paper-white → khối phân biệt rõ
30
+ // trong scrollback (không lẫn với prose model amber). Gutter ember ▌ ở mép trái.
31
+ userBg: chalk.bgHex('#3a342c').hex('#f5f1e8'),
32
+ userGutter: chalk.hex('#ff7a45'),
29
33
  };
30
34
 
31
35
  // Single-tone "gradient" giữ tương thích API gradient-string nội bộ. Banner
@@ -42,15 +46,14 @@ const term = () => process.stdout.columns || 80;
42
46
  // phân cách bằng ◆ ember nhỏ thay vì dấu · xám.
43
47
  export function banner() {
44
48
  const sidebar = chalk.hex('#ff7a45')('▌');
45
- const wordmark = [
46
- '┏┓╻┏━┓┏━┓┏┓ ',
47
- '┃┗┫┃ ┃┃ ┃┣┻┓',
48
- '╹ ╹┗━┛┗━┛┗━┛',
49
- ].map((row) => sidebar + ' ' + brand(row)).join('\n');
49
+ const wordmark = ['┏┓╻┏━┓┏━┓┏┓ ', '┃┗┫┃ ┃┃ ┃┣┻┓', '╹ ╹┗━┛┗━┛┗━┛']
50
+ .map((row) => sidebar + ' ' + brand(row))
51
+ .join('\n');
50
52
  const diamond = chalk.hex('#ff7a45')('◆');
51
53
  const tag = chalk.hex('#e8e3d8').italic(t.tagline);
52
54
  const meta = ' ' + c.dim('agentic terminal') + ' ' + diamond + ' ' + tag;
53
55
  console.log('\n' + wordmark);
56
+ console.log('');
54
57
  console.log(meta + '\n');
55
58
  }
56
59
 
@@ -30,6 +30,7 @@ export function createBgRegistry(deps = {}) {
30
30
  execTool,
31
31
  spawnAgentToolsDoc,
32
32
  cwd = process.cwd(),
33
+ tui = null, // route log sub-agent nền + cập nhật badge số agent nền (optional; null trong test)
33
34
  } = deps;
34
35
  const _runAgent = deps.runAgent || runAgent;
35
36
  const _createRun = deps.createRun || createRun;
@@ -40,8 +41,14 @@ export function createBgRegistry(deps = {}) {
40
41
  // Hàng đợi run đã settle, chờ main loop drain.
41
42
  const completions = [];
42
43
 
43
- const activeCount = () =>
44
- [...runs.values()].filter((r) => r.status === 'running').length;
44
+ const activeCount = () => [...runs.values()].filter((r) => r.status === 'running').length;
45
+
46
+ // Cập nhật badge số agent nền trên status bar (no-op nếu không có tui).
47
+ const syncBadge = () => {
48
+ try {
49
+ tui?.setBgAgents?.(activeCount());
50
+ } catch {}
51
+ };
45
52
 
46
53
  // Kick off 1 bg workflow. Trả về { ok, id } hoặc { ok:false, error }.
47
54
  function startBgWorkflow({ prompt, name = null } = {}) {
@@ -71,6 +78,10 @@ export function createBgRegistry(deps = {}) {
71
78
  error: null,
72
79
  };
73
80
  runs.set(run.id, entry);
81
+ try {
82
+ tui?.pushAgentLog?.(`🎼 workflow nền bắt đầu — run ${run.id}${name ? ' · ' + name : ''}`);
83
+ } catch {}
84
+ syncBadge();
74
85
 
75
86
  // workflowRun riêng cho bg dispatcher: journal cache dùng run.data này, KHÔNG
76
87
  // đụng state.workflowRun của turn (turn có thể đang chạy /workflow blocking).
@@ -90,8 +101,9 @@ export function createBgRegistry(deps = {}) {
90
101
  stopSpin: noop,
91
102
  startSpin: noop,
92
103
  execTool,
93
- tui: null,
104
+ tui, // sub-agent log → ring buffer (Ctrl+O), KHÔNG in ra UI chính
94
105
  c: null,
106
+ bgAgentLog: true, // cờ: dispatcher route onLog → tui.pushAgentLog thay console.log
95
107
  });
96
108
 
97
109
  // Chạy headless. runAgent options TUI-bound (onStatus/onDelta/onSteer) bỏ —
@@ -111,18 +123,32 @@ export function createBgRegistry(deps = {}) {
111
123
  .then((answer) => {
112
124
  entry.status = 'done';
113
125
  entry.result = typeof answer === 'string' ? answer : String(answer ?? '');
114
- try { _closeRun(run.data, 'done', cwd); } catch {}
126
+ try {
127
+ _closeRun(run.data, 'done', cwd);
128
+ } catch {}
129
+ try {
130
+ tui?.pushAgentLog?.(`✓ workflow nền xong — run ${run.id}`);
131
+ } catch {}
115
132
  completions.push({ id: run.id, name, status: 'done', result: entry.result });
133
+ syncBadge();
116
134
  })
117
135
  .catch((err) => {
118
136
  const isAbort = err?.message === 'aborted' || err?.name === 'AbortError';
119
137
  entry.status = isAbort ? 'interrupted' : 'failed';
120
138
  entry.error = err?.message || String(err);
121
- try { _closeRun(run.data, entry.status, cwd); } catch {}
139
+ try {
140
+ _closeRun(run.data, entry.status, cwd);
141
+ } catch {}
142
+ try {
143
+ tui?.pushAgentLog?.(
144
+ `${isAbort ? '⊘ huỷ' : '✗ lỗi'} workflow nền — run ${run.id}${entry.error ? ': ' + entry.error : ''}`
145
+ );
146
+ } catch {}
122
147
  // Abort do stopBg/sweepOnExit → KHÔNG enqueue completion (user chủ động huỷ).
123
148
  if (!isAbort) {
124
149
  completions.push({ id: run.id, name, status: entry.status, error: entry.error });
125
150
  }
151
+ syncBadge();
126
152
  });
127
153
  entry.promise = promise;
128
154
 
@@ -143,6 +169,7 @@ export function createBgRegistry(deps = {}) {
143
169
  const entry = runs.get(id);
144
170
  if (!entry || entry.status !== 'running') return false;
145
171
  entry.controller.abort();
172
+ syncBadge();
146
173
  return true;
147
174
  }
148
175
 
@@ -157,6 +184,7 @@ export function createBgRegistry(deps = {}) {
157
184
  n++;
158
185
  }
159
186
  }
187
+ if (n) syncBadge();
160
188
  return n;
161
189
  }
162
190
 
@@ -70,7 +70,10 @@ export function hashTask({ task, context, model }) {
70
70
 
71
71
  // Tên file an toàn — chỉ [a-z0-9-_], thay ký tự không hợp lệ bằng `_`.
72
72
  function sanitizeId(s) {
73
- return String(s || '').toLowerCase().replace(/[^a-z0-9_-]/g, '_').slice(0, 80);
73
+ return String(s || '')
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9_-]/g, '_')
76
+ .slice(0, 80);
74
77
  }
75
78
 
76
79
  function makeId(name) {