@noobdemon/noob-cli 1.12.13 → 1.12.15

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  Tất cả thay đổi đáng kể của `@noobdemon/noob-cli` được ghi vào file này.
4
4
 
5
+ ## [1.12.15] - 2026-06-16
6
+
7
+ ### Fixed
8
+ - **Todo display nuốt thanh status + đè ô nhập** (`src/tui.js` `topRow()`): todo branch return sớm vô điều kiện — lúc rảnh vẫn vẽ full progress + task + ctx (2-3 dòng) đè vùng trên ô nhập; lúc busy mất animated spinner. Fix: gỡ early-return, dựng `progress` (1 dòng) + `taskLine` sẵn rồi ghép theo trạng thái. Rảnh → 1 dòng progress gọn (📋 done/total bar %), không task/ctx line → không nuốt ô nhập. Busy → spinner + progress chung dòng 1, task line ▸ dòng 2 → giữ cả spinner LẪN tiến độ. Smoke `smoke-tui-render` tách 4a (rảnh: progress, KHÔNG task line) + 4b (busy: task line hiện) — 8/8 pass.
9
+ - **Test stale `tests/models.test.js` + leak isolation `tests/tools.test.js`**: models test hardcode catalog cũ (`gateway-claude-opus-4-7`, provider `google`) → cập nhật theo catalog hiện tại (`gateway-claude-opus-4-8`, `deepseek`). grep test fail do `extraRoots` module-global leak từ `loadWorkspaceRoots()` (nạp `.noob/dirs.json` chứa Temp dir) → thêm export `clearRoots()` gọi trong `beforeEach`. grep 17s → 997ms. 98/98 vitest pass.
10
+
11
+ ## [1.12.14] - 2026-06-14
12
+
13
+ ### Fixed
14
+ - **Bg workflow sub-agent kẹt model cũ khi `/model` đổi giữa chừng** (`src/workflow-bg.js`): `startBgWorkflow` spread `{...state}` → snapshot `state.model` lúc spawn. User `/model` đổi sau khi bg run bắt đầu → sub-agent bg đẻ ra vẫn dùng model cũ thay vì CLI pick hiện tại. Fix: thay spread bằng `Object.create(state)` proto-delegate (chỉ override `workflowRun`) → dispatcher đọc `state.model.id` SỐNG mỗi lần spawn sub-agent, theo đúng model CLI đang chọn. Giới hạn còn lại: top-level bg run = 1 `runAgent` dài, model chốt lúc spawn (không swap mid-flight được); chỉ sub-agent track CLI sống. Foreground `spawn_agent` không đụng — đã đọc model sống sẵn. Smoke `workflow-bg` 6/6 pass.
15
+
5
16
  ## [1.12.13] - 2026-06-14
6
17
 
7
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.12.13",
3
+ "version": "1.12.15",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/api.js CHANGED
@@ -391,8 +391,7 @@ async function streamOnce({
391
391
  // UND_ERR_*). Tự throw nguyên dạng → streamWithRetry KHÔNG retry (vì check
392
392
  // err.name === 'ApiError'). Bọc thành ApiError retryable để được backoff.
393
393
  const causeCode = err?.cause?.code || err?.code;
394
- const isNetworkDrop =
395
- err?.name === 'TypeError' && /fetch failed/i.test(err?.message || '');
394
+ const isNetworkDrop = err?.name === 'TypeError' && /fetch failed/i.test(err?.message || '');
396
395
  const isSocketErr =
397
396
  typeof causeCode === 'string' &&
398
397
  /^(ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|ENOTFOUND|EPIPE|UND_ERR_)/i.test(
@@ -422,7 +421,12 @@ const USAGE_CACHE_MS = 90_000;
422
421
  export async function usage({ force = false } = {}) {
423
422
  const now = Date.now();
424
423
  const key = config.apiKey || '';
425
- if (!force && _usageCache.data && _usageCache.key === key && now - _usageCache.at < USAGE_CACHE_MS) {
424
+ if (
425
+ !force &&
426
+ _usageCache.data &&
427
+ _usageCache.key === key &&
428
+ now - _usageCache.at < USAGE_CACHE_MS
429
+ ) {
426
430
  return _usageCache.data;
427
431
  }
428
432
  const resp = await fetch(config.gatewayUrl + '/api/usage', { headers: authHeaders() });
package/src/diff.js CHANGED
@@ -32,7 +32,8 @@ function diffLines(a, b) {
32
32
  while (i > 0 && j > 0) {
33
33
  if (a[i - 1] === b[j - 1]) {
34
34
  ops.push({ type: 'eq', text: a[i - 1] });
35
- i--; j--;
35
+ i--;
36
+ j--;
36
37
  } else if (t[i - 1][j] >= t[i][j - 1]) {
37
38
  ops.push({ type: 'del', text: a[i - 1] });
38
39
  i--;
@@ -41,8 +42,12 @@ function diffLines(a, b) {
41
42
  j--;
42
43
  }
43
44
  }
44
- while (i > 0) { ops.push({ type: 'del', text: a[--i] }); }
45
- while (j > 0) { ops.push({ type: 'add', text: b[--j] }); }
45
+ while (i > 0) {
46
+ ops.push({ type: 'del', text: a[--i] });
47
+ }
48
+ while (j > 0) {
49
+ ops.push({ type: 'add', text: b[--j] });
50
+ }
46
51
  return ops.reverse();
47
52
  }
48
53
 
@@ -98,7 +103,11 @@ function groupHunks(ops, context = 3) {
98
103
 
99
104
  // Render unified diff với màu. `label` là tiêu đề (vd path file).
100
105
  // `maxLines` cap tổng số dòng render (mặc định 60) — diff quá dài hiện cuối `... +N dòng`.
101
- export function renderUnifiedDiff(oldText, newText, { label = '', context = 3, maxLines = 60 } = {}) {
106
+ export function renderUnifiedDiff(
107
+ oldText,
108
+ newText,
109
+ { label = '', context = 3, maxLines = 60 } = {}
110
+ ) {
102
111
  const a = oldText.split('\n');
103
112
  const b = newText.split('\n');
104
113
  const ops = diffLines(a, b);
package/src/i18n.js CHANGED
@@ -95,7 +95,8 @@ export const t = {
95
95
  ` noob.md : ${lines} dòng · ${bytes} · ${rules} rules · ${notes} notes · cập nhật ${ago}`,
96
96
  memoryStatsArchive: (lines, bytes, ago) =>
97
97
  ` noob-archive.md : ${lines} dòng · ${bytes} · cập nhật ${ago} (không inject vào prompt)`,
98
- memoryStatsArchiveMissing: ' noob-archive.md : (chưa có — tạo bằng tay nếu cần lưu Notes lịch sử)',
98
+ memoryStatsArchiveMissing:
99
+ ' noob-archive.md : (chưa có — tạo bằng tay nếu cần lưu Notes lịch sử)',
99
100
  memoryStatsOk: ' ✓ trong ngưỡng khuyến nghị (≤ 200 dòng / ≤ 20KB)',
100
101
  memoryStatsWarnLines: (n) =>
101
102
  ` ⚠ noob.md ${n} dòng > 200 — cân nhắc archive Notes cũ sang noob-archive.md để giảm token bloat mỗi turn`,
@@ -33,6 +33,22 @@ import { t } from '../i18n.js';
33
33
  */
34
34
  export function createAgentDispatcher(deps) {
35
35
  const { state, abort, tokenMeter, stopSpin, startSpin, execTool, tui, c } = deps;
36
+ // bgAgentLog: true → log sub-agent đi vào ring buffer (tui.pushAgentLog, xem
37
+ // bằng Ctrl+O), KHÔNG in ra UI chính. Dùng cho workflow nền (headless). False/
38
+ // thiếu → behavior cũ: in console.log ra UI chính (sub-agent foreground).
39
+ const bgAgentLog = !!deps.bgAgentLog;
40
+ // Route 1 dòng log sub-agent: nền → ring buffer; foreground → console.log.
41
+ const emitAgentLog = (line) => {
42
+ if (bgAgentLog) {
43
+ try {
44
+ tui?.pushAgentLog?.(line);
45
+ } catch {}
46
+ } else {
47
+ stopSpin?.();
48
+ console.log(chalk.hex('#8b5cf6')(' ' + line));
49
+ startSpin?.(t.thinking);
50
+ }
51
+ };
36
52
  // bgRegistry: tuỳ chọn — chỉ wire ở dispatcher của TURN (repl.js), KHÔNG ở bg
37
53
  // dispatcher (tránh đệ quy: workflow nền spawn workflow nền). Thiếu → tool báo lỗi.
38
54
  const bgRegistry = deps.bgRegistry || null;
@@ -68,7 +84,9 @@ export function createAgentDispatcher(deps) {
68
84
  // Flag: lượt này model đã dùng write_todos → repl skip parse markdown để
69
85
  // không overwrite structured state bằng parser fragile. Reset đầu mỗi turn.
70
86
  state._todosFromTool = true;
71
- try { tui?.setTodos?.(todos); } catch {}
87
+ try {
88
+ tui?.setTodos?.(todos);
89
+ } catch {}
72
90
  const done = todos.filter((t) => t.done).length;
73
91
  // In compact: lần đầu (prev rỗng) hoặc list thay đổi tập text → in full.
74
92
  // Nếu cùng tập text + chỉ khác trạng thái done → in diff (dòng vừa toggle).
@@ -105,7 +123,8 @@ export function createAgentDispatcher(deps) {
105
123
  if (!bgRegistry)
106
124
  return {
107
125
  allow: true,
108
- result: 'ERROR: workflow nền không khả dụng ở ngữ cảnh này (vd đang trong 1 workflow nền khác — không lồng).',
126
+ result:
127
+ 'ERROR: workflow nền không khả dụng ở ngữ cảnh này (vd đang trong 1 workflow nền khác — không lồng).',
109
128
  };
110
129
  if (!state.agentMode)
111
130
  return {
@@ -122,13 +141,14 @@ export function createAgentDispatcher(deps) {
122
141
  result: `ERROR: đã đạt ${bgRegistry.MAX_BG} workflow nền song song — chờ một run xong hoặc /workflow stop <id>.`,
123
142
  };
124
143
  if (r.error === 'empty_prompt')
125
- return { allow: true, result: 'ERROR: workflow cần field "prompt" (yêu cầu cho workflow).' };
144
+ return {
145
+ allow: true,
146
+ result: 'ERROR: workflow cần field "prompt" (yêu cầu cho workflow).',
147
+ };
126
148
  return { allow: true, result: 'ERROR khởi tạo workflow nền: ' + r.error };
127
149
  }
128
150
  stopSpin();
129
- console.log(
130
- chalk.hex('#8b5cf6')(` 🎼 workflow nền bắt đầu — run ${r.id}`)
131
- );
151
+ console.log(chalk.hex('#8b5cf6')(` 🎼 workflow nền bắt đầu — run ${r.id}`));
132
152
  startSpin(t.thinking);
133
153
  return {
134
154
  allow: true,
@@ -172,8 +192,7 @@ export function createAgentDispatcher(deps) {
172
192
  if (m) {
173
193
  subModel = m.id;
174
194
  modelTag = ` [${m.name}]`;
175
- } else
176
- modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
195
+ } else modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
177
196
  }
178
197
  const taskBody = task?.task || task?.prompt || '';
179
198
  const taskCtx = task?.context || '';
@@ -190,9 +209,7 @@ export function createAgentDispatcher(deps) {
190
209
  )
191
210
  );
192
211
  startSpin(t.thinking);
193
- return Promise.resolve(
194
- `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
195
- );
212
+ return Promise.resolve(`── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`);
196
213
  }
197
214
  recordWorkflowTaskStart(runData, {
198
215
  hash,
@@ -209,11 +226,7 @@ export function createAgentDispatcher(deps) {
209
226
  signal: abort.signal,
210
227
  tokenMeter,
211
228
  dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
212
- onLog: (msg) => {
213
- stopSpin();
214
- console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
215
- startSpin(t.thinking);
216
- },
229
+ onLog: (msg) => emitAgentLog(msg + modelTag),
217
230
  })
218
231
  .then((r) => {
219
232
  recordWorkflowTaskDone(runData, hash, r);
@@ -233,11 +246,7 @@ export function createAgentDispatcher(deps) {
233
246
  signal: abort.signal,
234
247
  tokenMeter,
235
248
  dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
236
- onLog: (msg) => {
237
- stopSpin();
238
- console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
239
- startSpin(t.thinking);
240
- },
249
+ onLog: (msg) => emitAgentLog(msg + modelTag),
241
250
  })
242
251
  .then((r) => `── sub-agent #${i + 1}${modelTag} ──\n${r}`)
243
252
  .catch(
@@ -102,8 +102,7 @@ export async function askWorkflowAgentMode({ tui, ask, pending, c, t, truncate }
102
102
  console.log(
103
103
  c.tool(
104
104
  ' ' +
105
- (t.workflowAgentAskHint ||
106
- '🎼 /workflow cần spawn sub-agent — agent mode hiện đang TẮT.')
105
+ (t.workflowAgentAskHint || '🎼 /workflow cần spawn sub-agent — agent mode hiện đang TẮT.')
107
106
  )
108
107
  );
109
108
  try {
@@ -15,12 +15,7 @@
15
15
  // đa dòng — flushCompleteLines phải HOLD các dòng cuối là table-row/sep
16
16
  // candidate để chờ đủ data trước khi parse.
17
17
  import chalk from 'chalk';
18
- import {
19
- renderMarkdown,
20
- renderInline,
21
- renderHeadingLine,
22
- renderBulletPrefix,
23
- } from '../ui.js';
18
+ import { renderMarkdown, renderInline, renderHeadingLine, renderBulletPrefix } from '../ui.js';
24
19
 
25
20
  function renderStreamLine(line, inCodeFence) {
26
21
  if (inCodeFence) return line; // code fence body: in raw, không parse markdown.
@@ -62,11 +57,7 @@ export function renderStreamBlock(lines, fenceState) {
62
57
  continue;
63
58
  }
64
59
  // Detect table: dòng hiện tại là row + dòng kế tiếp là separator.
65
- if (
66
- looksLikeTableRow(ln) &&
67
- i + 1 < lines.length &&
68
- TABLE_SEP_RE.test(lines[i + 1])
69
- ) {
60
+ if (looksLikeTableRow(ln) && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1])) {
70
61
  // Gom hết các dòng row liên tiếp.
71
62
  const tableLines = [ln, lines[i + 1]];
72
63
  let j = i + 2;
@@ -55,7 +55,9 @@ export function workflowHelp({ c, t }) {
55
55
  console.log(' /workflow run <name> [extra] chạy (built-in HOẶC saved, có thể thêm ngữ cảnh)');
56
56
  console.log(' /workflow runs list run journal đã chạy (resume-able)');
57
57
  console.log(' /workflow log <id> xem chi tiết 1 run (sub-agent task + kết quả)');
58
- console.log(' /workflow resume <id> chạy lại run, skip task đã done → tiết kiệm token');
58
+ console.log(
59
+ ' /workflow resume <id> chạy lại run, skip task đã done → tiết kiệm token'
60
+ );
59
61
  console.log(' /workflow active workflow nền đang chạy');
60
62
  console.log(' /workflow stop <id> | all huỷ workflow nền (resume được)');
61
63
  console.log(' /workflow delete|rm <name> xoá workflow đã lưu');
package/src/repl.js CHANGED
@@ -5,8 +5,22 @@ import chalk from 'chalk';
5
5
  import { createTui } from './tui.js';
6
6
  import { runAgent, maybeSummarize, buildSystem, buildUserMessage } from './agent.js';
7
7
  import { spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from './subagent.js';
8
- import { TokenMeter, countMessages, CONTEXT_WINDOW, countTokens } from './tokens.js';
9
- import { stream, usage, cachedUsage, resetUsageCache, evaluateQuotaWarning, ApiError, resetMemoryToken } from './api.js';
8
+ import {
9
+ TokenMeter,
10
+ countMessages,
11
+ CONTEXT_WINDOW,
12
+ SEND_LIMIT_TOKENS,
13
+ countTokens,
14
+ } from './tokens.js';
15
+ import {
16
+ stream,
17
+ usage,
18
+ cachedUsage,
19
+ resetUsageCache,
20
+ evaluateQuotaWarning,
21
+ ApiError,
22
+ resetMemoryToken,
23
+ } from './api.js';
10
24
  import {
11
25
  runTool,
12
26
  describe,
@@ -26,10 +40,7 @@ import {
26
40
  askAddRoot as _askAddRoot,
27
41
  askWorkflowAgentMode as _askWorkflowAgentMode,
28
42
  } from './repl/permission.js';
29
- import {
30
- runImprove as _runImprove,
31
- runKarpathy as _runKarpathy,
32
- } from './repl/commands/prompts.js';
43
+ import { runImprove as _runImprove, runKarpathy as _runKarpathy } from './repl/commands/prompts.js';
33
44
  import { config } from './config.js';
34
45
  import { loadMemory, memoryPath, memoryStats } from './memory.js';
35
46
  import { t } from './i18n.js';
@@ -79,7 +90,14 @@ import {
79
90
  } from './repl/workflow-commands.js';
80
91
  import { createState } from './repl/state.js';
81
92
  import {
82
- shortCwd, shortPath, relTime, firstLine, truncate, fmtTime, fmtK, preview,
93
+ shortCwd,
94
+ shortPath,
95
+ relTime,
96
+ firstLine,
97
+ truncate,
98
+ fmtTime,
99
+ fmtK,
100
+ preview,
83
101
  } from './repl/utils.js';
84
102
  import { createAgentDispatcher } from './repl/agent-dispatch.js';
85
103
  import { createBgRegistry } from './workflow-bg.js';
@@ -165,8 +183,7 @@ export async function startRepl(opts = {}) {
165
183
  _askPermission(name, { tui, ask, pending, c, t, truncate, targetPath });
166
184
  const askAddRoot = (root, targetPath) =>
167
185
  _askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate });
168
- const askWorkflowAgentMode = () =>
169
- _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
186
+ const askWorkflowAgentMode = () => _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
170
187
 
171
188
  // ── quota soft-cap guard ───────────────────────────────────────────
172
189
  // Trước khi start ULTRA / loop / workflow (3 lệnh ăn nhiều request nhất),
@@ -232,6 +249,7 @@ export async function startRepl(opts = {}) {
232
249
  abort = null;
233
250
  if (state.ultra) {
234
251
  state.ultra = false; // Ctrl+C cũng dừng vòng tự hành, không chỉ lượt hiện tại
252
+ tui.setUltra(false); // tắt badge ULTRA trên status bar
235
253
  console.log(c.tool(' ' + t.ultraStopped));
236
254
  }
237
255
  if (pending.length) {
@@ -256,7 +274,14 @@ export async function startRepl(opts = {}) {
256
274
  // Bg workflow đang chạy → abort + mark interrupted (journal resume-able).
257
275
  const swept = bgRegistry.sweepOnExit();
258
276
  if (swept > 0)
259
- console.log(c.dim(' ' + (t.bgWorkflowSweep ? t.bgWorkflowSweep(swept) : `${swept} workflow nền bị ngắt — resume bằng /workflow resume <id>`)));
277
+ console.log(
278
+ c.dim(
279
+ ' ' +
280
+ (t.bgWorkflowSweep
281
+ ? t.bgWorkflowSweep(swept)
282
+ : `${swept} workflow nền bị ngắt — resume bằng /workflow resume <id>`)
283
+ )
284
+ );
260
285
  persist();
261
286
  console.log(c.dim('\n ' + t.bye));
262
287
  tui.close(); // khôi phục terminal (raw mode/paste/stdout) trước khi thoát
@@ -608,19 +633,29 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
608
633
  const active = bgRegistry.activeRuns();
609
634
  if (!active.length) return console.log(c.dim(' không có workflow nền nào đang chạy.'));
610
635
  console.log(c.err(' cần id: /workflow stop <id> | all'));
611
- for (const r of active) console.log(' ' + c.accent(r.id) + (r.name ? c.dim(' · ' + r.name) : ''));
636
+ for (const r of active)
637
+ console.log(' ' + c.accent(r.id) + (r.name ? c.dim(' · ' + r.name) : ''));
612
638
  return;
613
639
  }
614
640
  if (id === 'all') {
615
641
  const n = bgRegistry.sweepOnExit();
616
642
  return console.log(
617
- c.tool(' 🎼 ' + (n ? `đã huỷ ${n} workflow nền — resume bằng /workflow resume <id>` : 'không có workflow nền nào đang chạy.'))
643
+ c.tool(
644
+ ' 🎼 ' +
645
+ (n
646
+ ? `đã huỷ ${n} workflow nền — resume bằng /workflow resume <id>`
647
+ : 'không có workflow nền nào đang chạy.')
648
+ )
618
649
  );
619
650
  }
620
651
  const ok = bgRegistry.stopBg(id);
621
652
  console.log(
622
653
  ok
623
- ? c.tool(' 🎼 đã huỷ workflow nền ' + c.accent(id) + c.dim(' — resume bằng /workflow resume ' + id))
654
+ ? c.tool(
655
+ ' 🎼 đã huỷ workflow nền ' +
656
+ c.accent(id) +
657
+ c.dim(' — resume bằng /workflow resume ' + id)
658
+ )
624
659
  : c.err(' không tìm thấy workflow nền đang chạy với id: ' + id)
625
660
  );
626
661
  }
@@ -633,7 +668,9 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
633
668
  const now = Date.now();
634
669
  for (const r of active) {
635
670
  const secs = Math.round((now - r.startedAt) / 1000);
636
- console.log(' ' + c.accent(r.id) + (r.name ? c.dim(' · ' + r.name) : '') + c.dim(` · ${secs}s`));
671
+ console.log(
672
+ ' ' + c.accent(r.id) + (r.name ? c.dim(' · ' + r.name) : '') + c.dim(` · ${secs}s`)
673
+ );
637
674
  }
638
675
  }
639
676
 
@@ -802,16 +839,23 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
802
839
  const items = listWorkflowRuns(20);
803
840
  if (!items.length) {
804
841
  console.log(c.dim(' (chưa có workflow run nào trong workspace này)'));
805
- console.log(c.dim(' Run đầu tiên được tạo khi bạn /workflow <yêu cầu> hoặc /workflow run <name>.'));
842
+ console.log(
843
+ c.dim(' Run đầu tiên được tạo khi bạn /workflow <yêu cầu> hoặc /workflow run <name>.')
844
+ );
806
845
  return;
807
846
  }
808
- console.log('\n' + chalk.bold(' ' + (t.workflowRunsTitle || '📓 Workflow runs (workspace này)')));
847
+ console.log(
848
+ '\n' + chalk.bold(' ' + (t.workflowRunsTitle || '📓 Workflow runs (workspace này)'))
849
+ );
809
850
  for (const r of items) {
810
851
  const statusColor =
811
- r.status === 'done' ? c.ok :
812
- r.status === 'interrupted' ? c.tool :
813
- r.status === 'failed' ? c.err :
814
- c.accent;
852
+ r.status === 'done'
853
+ ? c.ok
854
+ : r.status === 'interrupted'
855
+ ? c.tool
856
+ : r.status === 'failed'
857
+ ? c.err
858
+ : c.accent;
815
859
  console.log(
816
860
  c.dim(' ') +
817
861
  c.accent(r.id.padEnd(40)) +
@@ -820,18 +864,25 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
820
864
  );
821
865
  }
822
866
  console.log(
823
- c.dim('\n /workflow log <id> xem chi tiết\n /workflow resume <id> chạy lại, skip task đã done\n')
867
+ c.dim(
868
+ '\n /workflow log <id> xem chi tiết\n /workflow resume <id> chạy lại, skip task đã done\n'
869
+ )
824
870
  );
825
871
  }
826
872
 
827
873
  // /workflow log <id> — xem chi tiết 1 run: prompt, tasks, kết quả mỗi sub-agent.
828
874
  function workflowLog(arg) {
829
- if (!arg) return console.log(c.err(' Cách dùng: /workflow log <id> (xem id bằng /workflow runs)'));
875
+ if (!arg)
876
+ return console.log(c.err(' Cách dùng: /workflow log <id> (xem id bằng /workflow runs)'));
830
877
  const r = loadWorkflowRun(arg);
831
878
  if (!r.ok) return console.log(c.err(' Không tìm thấy run: ' + arg));
832
879
  const d = r.data;
833
880
  console.log('\n' + chalk.bold(' 📓 ' + d.id));
834
- console.log(c.dim(` name: ${d.name || '(adhoc)'} · status: ${d.status} · started: ${relTime(d.startedAt)} · updated: ${relTime(d.updatedAt)}`));
881
+ console.log(
882
+ c.dim(
883
+ ` name: ${d.name || '(adhoc)'} · status: ${d.status} · started: ${relTime(d.startedAt)} · updated: ${relTime(d.updatedAt)}`
884
+ )
885
+ );
835
886
  console.log(c.dim(' workflow prompt: ' + truncate(d.workflowPrompt || '', 120)));
836
887
  if (!d.tasks?.length) {
837
888
  console.log(c.dim(' (chưa có sub-agent task nào được ghi nhận)'));
@@ -839,10 +890,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
839
890
  }
840
891
  console.log('\n' + c.dim(' ── sub-agent tasks ──'));
841
892
  d.tasks.forEach((tk, i) => {
842
- const statusColor =
843
- tk.status === 'done' ? c.ok :
844
- tk.status === 'failed' ? c.err :
845
- c.tool;
893
+ const statusColor = tk.status === 'done' ? c.ok : tk.status === 'failed' ? c.err : c.tool;
846
894
  console.log(
847
895
  c.dim(` #${i + 1} `) +
848
896
  statusColor(tk.status.padEnd(8)) +
@@ -851,7 +899,8 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
851
899
  );
852
900
  if (tk.context) console.log(c.dim(' context: ' + truncate(tk.context, 80)));
853
901
  if (tk.model) console.log(c.dim(' model: ' + tk.model));
854
- if (tk.result) console.log(c.dim(' result: ' + truncate(tk.result.replace(/\s+/g, ' '), 120)));
902
+ if (tk.result)
903
+ console.log(c.dim(' result: ' + truncate(tk.result.replace(/\s+/g, ' '), 120)));
855
904
  if (tk.error) console.log(c.err(' error: ' + truncate(tk.error, 120)));
856
905
  });
857
906
  console.log('');
@@ -860,13 +909,19 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
860
909
  // /workflow resume <id> — chạy lại workflow với cùng prompt; dispatchTool tự
861
910
  // hit cache trong journal cho mọi task đã done lần trước → tiết kiệm token.
862
911
  async function workflowResume(arg) {
863
- if (!arg) return console.log(c.err(' Cách dùng: /workflow resume <id> (xem id bằng /workflow runs)'));
912
+ if (!arg)
913
+ return console.log(c.err(' Cách dùng: /workflow resume <id> (xem id bằng /workflow runs)'));
864
914
  const r = loadWorkflowRun(arg);
865
915
  if (!r.ok) return console.log(c.err(' Không tìm thấy run: ' + arg));
866
916
  const d = r.data;
867
- if (!d.workflowPrompt) return console.log(c.err(' Run này không có workflow prompt — không thể resume.'));
917
+ if (!d.workflowPrompt)
918
+ return console.log(c.err(' Run này không có workflow prompt — không thể resume.'));
868
919
  if (d.status === 'done') {
869
- console.log(c.tool(' ⚠ run đã ở trạng thái DONE — vẫn resume nhưng có thể chạy lại task mới nếu model spawn khác.'));
920
+ console.log(
921
+ c.tool(
922
+ ' ⚠ run đã ở trạng thái DONE — vẫn resume nhưng có thể chạy lại task mới nếu model spawn khác.'
923
+ )
924
+ );
870
925
  }
871
926
  await workflowExecute(d.workflowPrompt, { resumeRun: d });
872
927
  }
@@ -884,6 +939,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
884
939
  if (!(await checkQuotaBeforeHeavy('/ultra'))) return;
885
940
  state.mode = 'chat'; // tự hành chỉ chạy ở chế độ agent
886
941
  state.ultra = true;
942
+ tui.setUltra(true); // badge 🚀 ULTRA trên status bar
887
943
  console.log(c.accent(' 🚀 ' + t.ultraOn));
888
944
  // Mốc history TRƯỚC khi ULTRA bơm prompt — kết thúc thì cắt về để các lượt sau không bị "dính" mục tiêu cũ.
889
945
  const baseLen = state.history.length;
@@ -929,6 +985,7 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
929
985
  // token <<ULTRA_DONE>>…) để các yêu cầu SAU đó không bị model coi như vẫn
930
986
  // đang tự hành / vẫn theo đuổi mục tiêu cũ.
931
987
  state.ultra = false;
988
+ tui.setUltra(false); // tắt badge khi thoát ULTRA
932
989
  if (state.history.length > baseLen) state.history.length = baseLen;
933
990
  state.history.push({
934
991
  role: 'user',
@@ -1211,7 +1268,13 @@ NGUYÊN TẮC:
1211
1268
  const stats = memoryStats() || { rules: 0, notes: 0, mtime: st.mtimeMs };
1212
1269
  console.log(
1213
1270
  c.dim(
1214
- t.memoryStatsMain(mainLines, fmtBytes(mainBytes), stats.rules, stats.notes, relTime(stats.mtime))
1271
+ t.memoryStatsMain(
1272
+ mainLines,
1273
+ fmtBytes(mainBytes),
1274
+ stats.rules,
1275
+ stats.notes,
1276
+ relTime(stats.mtime)
1277
+ )
1215
1278
  )
1216
1279
  );
1217
1280
  } catch {
@@ -1345,6 +1408,7 @@ NGUYÊN TẮC:
1345
1408
  execTool,
1346
1409
  spawnAgentToolsDoc,
1347
1410
  cwd: process.cwd(),
1411
+ tui, // route log sub-agent nền → ring buffer (Ctrl+O xem), cập nhật badge số agent nền
1348
1412
  });
1349
1413
 
1350
1414
  // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
@@ -1356,12 +1420,23 @@ NGUYÊN TẮC:
1356
1420
  for (const f of fin) {
1357
1421
  const label = f.name ? `${f.name} (${f.id})` : f.id;
1358
1422
  if (f.status === 'done') {
1359
- console.log(c.tool(' 🎼 ' + (t.bgWorkflowDone ? t.bgWorkflowDone(label) : `workflow nền ${label} đã xong`)));
1423
+ console.log(
1424
+ c.tool(
1425
+ ' 🎼 ' + (t.bgWorkflowDone ? t.bgWorkflowDone(label) : `workflow nền ${label} đã xong`)
1426
+ )
1427
+ );
1360
1428
  pending.push(
1361
1429
  `[Kết quả workflow nền "${label}"]\n${f.result}\n\nTổng hợp kết quả trên cho user (ngắn gọn, nêu điểm chính). Đây là output từ workflow chạy nền — KHÔNG chạy lại.`
1362
1430
  );
1363
1431
  } else {
1364
- console.log(c.err(' 🎼 ' + (t.bgWorkflowFailed ? t.bgWorkflowFailed(label, f.error) : `workflow nền ${label} lỗi: ${f.error}`)));
1432
+ console.log(
1433
+ c.err(
1434
+ ' 🎼 ' +
1435
+ (t.bgWorkflowFailed
1436
+ ? t.bgWorkflowFailed(label, f.error)
1437
+ : `workflow nền ${label} lỗi: ${f.error}`)
1438
+ )
1439
+ );
1365
1440
  }
1366
1441
  }
1367
1442
  let input;
@@ -1491,7 +1566,15 @@ NGUYÊN TẮC:
1491
1566
  // src/repl/agent-dispatch.js (v1.12.x). Factory được gọi MỖI turn vì abort
1492
1567
  // được rebind trong handle() — không cache.
1493
1568
  const dispatchTool = createAgentDispatcher({
1494
- state, abort, tokenMeter, stopSpin, startSpin, execTool, tui, c, bgRegistry,
1569
+ state,
1570
+ abort,
1571
+ tokenMeter,
1572
+ stopSpin,
1573
+ startSpin,
1574
+ execTool,
1575
+ tui,
1576
+ c,
1577
+ bgRegistry,
1495
1578
  });
1496
1579
 
1497
1580
  const answer = await runAgent({
@@ -1558,20 +1641,31 @@ NGUYÊN TẮC:
1558
1641
  // Lý do: auto-compact gián đoạn workflow giữa chừng, summary có thể mất chi
1559
1642
  // tiết user cần. Giữ 2 mốc CẢNH BÁO (60% / 80%) để user biết khi nào nên
1560
1643
  // chạy /compact, nhưng KHÔNG tự động chạy nữa.
1561
- // Với CONTEXT_WINDOW = 200k tokens:
1644
+ // QUAN TRỌNG: key off SEND_LIMIT_TOKENS (ngưỡng gửi THẬT ~200k), KHÔNG phải
1645
+ // CONTEXT_WINDOW (1M, chỉ để hiển thị ctx%). Nếu dùng CONTEXT_WINDOW thì cảnh
1646
+ // báo 80% = 800k token — gateway đã reject ở ~200k từ lâu, cảnh báo thành
1647
+ // code chết. Với SEND_LIMIT_TOKENS = 200k:
1562
1648
  // 60% (120k) → nhắc nhẹ một lần
1563
1649
  // 80% (160k) → cảnh báo mạnh — nên /compact ngay trước khi provider reject
1564
1650
  try {
1565
1651
  const totalTokens = countMessages(state.history);
1566
1652
  const k = Math.round(totalTokens / 1000);
1567
- const pct = Math.round((totalTokens / CONTEXT_WINDOW) * 100);
1568
- if (totalTokens >= CONTEXT_WINDOW * 0.8) {
1653
+ const pct = Math.round((totalTokens / SEND_LIMIT_TOKENS) * 100);
1654
+ if (totalTokens >= SEND_LIMIT_TOKENS * 0.8) {
1569
1655
  // Mốc 2 (≥80% — 160k tokens): cảnh báo mạnh, gợi ý /compact ngay.
1570
- console.log(c.err(` ⚠ ${t.veryLongSession(k)} (${pct}% context) — gõ /compact để tóm tắt, tránh provider reject ở ~200k.`));
1656
+ console.log(
1657
+ c.err(
1658
+ ` ⚠ ${t.veryLongSession(k)} (${pct}% giới hạn gửi) — gõ /compact để tóm tắt, tránh provider reject ở ~200k.`
1659
+ )
1660
+ );
1571
1661
  state._longSessionWarned = true;
1572
- } else if (totalTokens >= CONTEXT_WINDOW * 0.6 && !state._longSessionWarned) {
1662
+ } else if (totalTokens >= SEND_LIMIT_TOKENS * 0.6 && !state._longSessionWarned) {
1573
1663
  // Mốc 1 (≥60% — 120k tokens): nhắc nhẹ một lần.
1574
- console.log(c.dim(` ⓘ ${t.longSession(k)} (${pct}% context) — cân nhắc /compact nếu phiên còn dài.`));
1664
+ console.log(
1665
+ c.dim(
1666
+ ` ⓘ ${t.longSession(k)} (${pct}% giới hạn gửi) — cân nhắc /compact nếu phiên còn dài.`
1667
+ )
1668
+ );
1575
1669
  state._longSessionWarned = true;
1576
1670
  }
1577
1671
  } catch {}
@@ -1589,7 +1683,11 @@ NGUYÊN TẮC:
1589
1683
 
1590
1684
  // Diff preview — show user CHÍNH XÁC dòng nào bị xoá/thêm trước khi approve.
1591
1685
  // Tránh tin model mù: bắt được hallucinate nội dung sớm.
1592
- if (name === 'edit_file' && typeof input.old_string === 'string' && typeof input.new_string === 'string') {
1686
+ if (
1687
+ name === 'edit_file' &&
1688
+ typeof input.old_string === 'string' &&
1689
+ typeof input.new_string === 'string'
1690
+ ) {
1593
1691
  try {
1594
1692
  const filePath = path.resolve(process.cwd(), input.path || '');
1595
1693
  let oldText = input.old_string;
@@ -1616,7 +1714,11 @@ NGUYÊN TẮC:
1616
1714
  console.log(renderUnifiedDiff(oldText, newText, { label: input.path || '(unknown path)' }));
1617
1715
  } catch (e) {
1618
1716
  // Fallback nếu đọc file lỗi: ít nhất show old vs new raw.
1619
- console.log(renderUnifiedDiff(input.old_string, input.new_string, { label: input.path || '(unknown path)' }));
1717
+ console.log(
1718
+ renderUnifiedDiff(input.old_string, input.new_string, {
1719
+ label: input.path || '(unknown path)',
1720
+ })
1721
+ );
1620
1722
  }
1621
1723
  } else if (name === 'write_file' && typeof input.content === 'string') {
1622
1724
  try {
@@ -1624,13 +1726,28 @@ NGUYÊN TẮC:
1624
1726
  if (fs.existsSync(filePath)) {
1625
1727
  // File đã tồn tại → write_file là OVERWRITE → show diff old vs new.
1626
1728
  const oldContent = fs.readFileSync(filePath, 'utf8');
1627
- console.log(renderUnifiedDiff(oldContent, input.content, { label: input.path + ' (OVERWRITE)', maxLines: 60 }));
1729
+ console.log(
1730
+ renderUnifiedDiff(oldContent, input.content, {
1731
+ label: input.path + ' (OVERWRITE)',
1732
+ maxLines: 60,
1733
+ })
1734
+ );
1628
1735
  } else {
1629
1736
  // File mới → preview top 20 dòng + tổng số dòng.
1630
- console.log(renderNewFilePreview(input.content, { label: input.path || '(unknown path)', maxLines: 20 }));
1737
+ console.log(
1738
+ renderNewFilePreview(input.content, {
1739
+ label: input.path || '(unknown path)',
1740
+ maxLines: 20,
1741
+ })
1742
+ );
1631
1743
  }
1632
1744
  } catch {
1633
- console.log(renderNewFilePreview(input.content, { label: input.path || '(unknown path)', maxLines: 20 }));
1745
+ console.log(
1746
+ renderNewFilePreview(input.content, {
1747
+ label: input.path || '(unknown path)',
1748
+ maxLines: 20,
1749
+ })
1750
+ );
1634
1751
  }
1635
1752
  }
1636
1753
 
@@ -1640,7 +1757,11 @@ NGUYÊN TẮC:
1640
1757
  const rawPath = typeof input?.path === 'string' && input.path.length > 0 ? input.path : null;
1641
1758
  let absPath = null;
1642
1759
  if (rawPath) {
1643
- try { absPath = path.resolve(process.cwd(), rawPath); } catch { absPath = null; }
1760
+ try {
1761
+ absPath = path.resolve(process.cwd(), rawPath);
1762
+ } catch {
1763
+ absPath = null;
1764
+ }
1644
1765
  }
1645
1766
  const fileKey = absPath ? name + ':' + absPath : null;
1646
1767
 
@@ -1777,11 +1898,18 @@ NGUYÊN TẮC:
1777
1898
  } else {
1778
1899
  const [, name, etype, obsRaw] = m2;
1779
1900
  const observations = obsRaw
1780
- ? obsRaw.split(';').map((s) => s.trim()).filter(Boolean)
1901
+ ? obsRaw
1902
+ .split(';')
1903
+ .map((s) => s.trim())
1904
+ .filter(Boolean)
1781
1905
  : [];
1782
1906
  const created = await kgCreateEntities([{ name, entityType: etype, observations }]);
1783
- if (!created.length) console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
1784
- else console.log(c.ok(' ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`);
1907
+ if (!created.length)
1908
+ console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
1909
+ else
1910
+ console.log(
1911
+ c.ok(' ✓ ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`
1912
+ );
1785
1913
  }
1786
1914
  } else if (sub === 'obs') {
1787
1915
  const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
@@ -1791,7 +1919,8 @@ NGUYÊN TẮC:
1791
1919
  const [, name, obs] = m2;
1792
1920
  const out = await kgAddObservations([{ entityName: name, contents: [obs] }]);
1793
1921
  const added = out[0]?.addedObservations || [];
1794
- if (!added.length) console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
1922
+ if (!added.length)
1923
+ console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
1795
1924
  else console.log(c.ok(' ✓ ') + `đã thêm obs cho ${name}: ${added[0]}`);
1796
1925
  }
1797
1926
  } else if (sub === 'link') {
@@ -1831,7 +1960,8 @@ NGUYÊN TẮC:
1831
1960
  } else {
1832
1961
  const names = rest.split(/\s+/).filter(Boolean);
1833
1962
  const g = await kgOpenNodes(names);
1834
- if (!g.entities.length) console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
1963
+ if (!g.entities.length)
1964
+ console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
1835
1965
  else console.log(kgFormat(g));
1836
1966
  }
1837
1967
  } else if (sub === 'search') {
@@ -2073,7 +2203,14 @@ NGUYÊN TẮC:
2073
2203
  case 'q': {
2074
2204
  const swept = bgRegistry.sweepOnExit();
2075
2205
  if (swept > 0)
2076
- console.log(c.dim(' ' + (t.bgWorkflowSweep ? t.bgWorkflowSweep(swept) : `${swept} workflow nền bị ngắt — resume bằng /workflow resume <id>`)));
2206
+ console.log(
2207
+ c.dim(
2208
+ ' ' +
2209
+ (t.bgWorkflowSweep
2210
+ ? t.bgWorkflowSweep(swept)
2211
+ : `${swept} workflow nền bị ngắt — resume bằng /workflow resume <id>`)
2212
+ )
2213
+ );
2077
2214
  persist();
2078
2215
  exiting = true;
2079
2216
  console.log(c.dim(' ' + t.bye));
@@ -2294,5 +2431,3 @@ function listModels() {
2294
2431
  }
2295
2432
  console.log('\n' + lines.join('\n') + c.dim('\n\n ' + t.modelListHint) + '\n');
2296
2433
  }
2297
-
2298
-
package/src/tokens.js CHANGED
@@ -57,12 +57,19 @@ export function countMessages(messages = []) {
57
57
  // window đủ rộng (256 chars) để qua mọi ranh giới token thực tế của cl100k/o200k
58
58
  // (token dài nhất ~ vài chục byte).
59
59
  const TAIL_WINDOW = 256;
60
- // Context window tối đa của model. Đặt 200k tokens — match Claude 3.5/Opus 4,
61
- // GPT-4o, an toàn cho mọi model phổ biến qua gateway (Gemini 1M, DeepSeek
62
- // 128k, Grok 128k...). Đặt cao hơn 200k là vô nghĩa: provider sẽ reject prompt
63
- // TRƯỚC khi auto-compact của repl.js hội trigger user thấy 'compact
64
- // không hoạt động' logic compact vẫn đúng.
65
- export const CONTEXT_WINDOW = 200_000;
60
+ // Context window cho HIỂN THỊ (ctx% ở status bar). Đặt 1M tokens.
61
+ // QUAN TRỌNG đây con số DISPLAY-ONLY: ctx% = contextTokens / CONTEXT_WINDOW.
62
+ // Ngưỡng nén/tóm tắt THỰC TẾ (MAX_PROMPT_CHARS, SUMMARIZE_THRESHOLD_CHARS trong
63
+ // src/agent.js) GIỮ NGUYÊN mức an toàn (~200k token) để gateway/upstream không
64
+ // reject prompt TRƯỚC khi compact kịp chạy. Nếu sau này xác nhận gateway thật sự
65
+ // nhận ~1M token context, mới nâng các ngưỡng đó cho khớp.
66
+ export const CONTEXT_WINDOW = 1_000_000;
67
+
68
+ // Ngưỡng GỬI THẬT (real ceiling). Cảnh báo phiên dài + logic compact key off CON
69
+ // SỐ NÀY, không phải CONTEXT_WINDOW. Tách 2 hằng: CONTEXT_WINDOW chỉ để hiển thị
70
+ // ctx%; SEND_LIMIT_TOKENS là mức mà vượt qua thì gateway/upstream bắt đầu reject.
71
+ // Nếu nâng mức gửi thật (gateway nhận nhiều hơn), đổi DUY NHẤT hằng này.
72
+ export const SEND_LIMIT_TOKENS = 200_000;
66
73
 
67
74
  export class TokenMeter {
68
75
  constructor() {
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
@@ -153,6 +153,10 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
153
153
  setBusy() {},
154
154
  setMeta() {},
155
155
  setTodos() {},
156
+ setUltra() {},
157
+ setBgAgents() {},
158
+ pushAgentLog() {},
159
+ clearAgentLog() {},
156
160
  setPrompt() {},
157
161
  read() {
158
162
  if (queue.length) return Promise.resolve(queue.shift());
@@ -180,6 +184,14 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
180
184
  let busyLabel = '';
181
185
  let busyMeta = ''; // chuỗi phụ (elapsed + token) repl đẩy vào qua setMeta()
182
186
  let todos = []; // [{text, done}] — danh sách todo đang chạy, repl parse từ model output
187
+ let ultra = false; // ULTRA mode đang bật? → segment trong status bar
188
+ let bgAgents = 0; // số sub-agent / workflow chạy nền → segment + gợi ý Ctrl+O
189
+ // Ring buffer log sub-agent nền: KHÔNG in ra UI chính, chỉ vào đây. Ctrl+O mở
190
+ // overlay xem. Mỗi entry: chuỗi đã format (có thể kèm màu). Giữ tối đa MAX_LOG.
191
+ const MAX_AGENT_LOG = 500;
192
+ const agentLog = [];
193
+ let overlayOpen = false; // overlay log đang mở?
194
+ let overlayScroll = 0; // offset cuộn từ ĐÁY (0 = xem dòng mới nhất)
183
195
  let frame = 0;
184
196
  let frameTimer = null;
185
197
  let prevRows = 0;
@@ -354,38 +366,69 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
354
366
  return wrapText(liveOut, cols(), 2);
355
367
  }
356
368
  const spin = FRAMES[frame % FRAMES.length];
357
- // Todo progress bar: hiện khi todos, thay thế statusText/busyLabel
369
+ // Badge ghép vào mọi dòng status: ULTRA bật/tắt + số sub-agent nền (gợi ý
370
+ // Ctrl+O xem log). Reset ANSI từng đoạn (chalk tự đóng) → không bleed.
371
+ const ultraBadge = ultra ? c.err('🚀 ULTRA') : '';
372
+ const bgBadge = bgAgents ? c.accent(`⊕ ${bgAgents} nền`) + c.dim(' (Ctrl+O)') : '';
373
+ const badges = [ultraBadge, bgBadge].filter(Boolean).join(c.dim(' · '));
374
+ const badgeTail = badges ? c.dim(' · ') + badges : '';
375
+ // Todo progress: 1 dòng gọn dùng chung (📋 done/total bar pct%). Dựng SẴN ở
376
+ // đây để ghép vào status line theo trạng thái — KHÔNG return sớm nuốt status.
377
+ // Rảnh → chỉ dòng này. Busy → ghép cạnh spinner. Mất todo không nuốt spinner.
378
+ let progress = null;
379
+ let taskLine = null;
358
380
  if (todos.length) {
359
381
  const done = todos.filter((t) => t.done).length;
360
382
  const total = todos.length;
361
383
  const current = todos.find((t) => !t.done);
362
384
  const pct = Math.round((done / total) * 100);
363
- // Thanh progress: ████░░░░ 2/5 (40%)
364
- const barW = Math.min(12, Math.floor(cols() * 0.15));
385
+ // Thanh progress dày hơn, màu phân biệt: phần done = ember, còn lại = dim.
386
+ const barW = Math.min(16, Math.max(8, Math.floor(cols() * 0.2)));
365
387
  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];
388
+ const bar = c.accent('█'.repeat(filled)) + c.dim('░'.repeat(barW - filled));
389
+ progress =
390
+ c.accent('📋 ') + c.ok(`${done}`) + c.dim(`/${total}`) + ' ' + bar + c.dim(` ${pct}%`);
391
+ // Task đang làm (▸ amber) — CHỈ dùng lúc busy (dòng 2). Rảnh bỏ để gọn 1 dòng.
392
+ taskLine = current
393
+ ? c.tool(' ') + c.user(truncStr(current.text, cols() - 8))
394
+ : c.ok(' tất cả todo đã xong!');
375
395
  }
396
+
376
397
  // Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
377
398
  // người dùng thấy phiên đang sống — kể cả lúc model im giữa các bước.
378
399
  if (statusText) {
379
400
  const meta = busy && busyMeta ? c.dim(' · ' + busyMeta) : '';
380
401
  const tail = busy ? c.dim(' · Ctrl+C để dừng') : '';
381
- const line = c.dim(spin + ' ') + statusText + meta + tail;
402
+ // Busy + todos → spinner + progress CHUNG dòng 1, task line dòng 2.
403
+ if (busy && progress) {
404
+ const line = c.dim(spin + ' ') + progress + meta + tail + badgeTail;
405
+ const out = wrapText(line, cols(), 2);
406
+ if (taskLine) out.push(taskLine);
407
+ return out;
408
+ }
409
+ const line = c.dim(spin + ' ') + statusText + meta + tail + badgeTail;
382
410
  return wrapText(line, cols(), 2);
383
411
  }
384
412
  if (busy) {
385
- const meta = busyMeta ? ' · ' + busyMeta : '';
386
- const line = c.dim(spin + ' ' + (busyLabel || 'đang chạy') + meta + ' · Ctrl+C để dừng');
413
+ const meta = busyMeta ? c.dim(' · ' + busyMeta) : '';
414
+ // Busy + todos spinner + progress chung dòng 1, task line dòng 2.
415
+ if (progress) {
416
+ const line = c.dim(spin + ' ') + progress + meta + c.dim(' · Ctrl+C') + badgeTail;
417
+ const out = wrapText(line, cols(), 2);
418
+ if (taskLine) out.push(taskLine);
419
+ return out;
420
+ }
421
+ const line =
422
+ c.dim(spin + ' ' + (busyLabel || 'đang chạy')) +
423
+ meta +
424
+ c.dim(' · Ctrl+C để dừng') +
425
+ badgeTail;
387
426
  return wrapText(line, cols(), 2);
388
427
  }
428
+ // Rảnh + todos → 1 dòng progress gọn (KHÔNG task/ctx line, không nuốt ô nhập).
429
+ if (progress) return wrapText(progress + badgeTail, cols(), 2);
430
+ // Rảnh nhưng có ULTRA / sub-agent nền → vẫn hiện badge để user biết trạng thái.
431
+ if (badges) return wrapText(' ' + badges, cols(), 2);
389
432
  return null;
390
433
  }
391
434
  function menuRows() {
@@ -405,11 +448,45 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
405
448
  );
406
449
  });
407
450
  }
451
+ // Overlay log sub-agent nền (Ctrl+O). Thay thế vùng output chính (top/menu)
452
+ // bằng panel cuộn được: header + N dòng log gần nhất theo overlayScroll. KHÔNG
453
+ // đụng renderBar/cursor math — overlay chỉ là các dòng phía TRÊN thanh nhập.
454
+ const OVERLAY_LINES = 12;
455
+ function overlayRows() {
456
+ const head =
457
+ c.accent('┄┄ log sub-agent nền ') +
458
+ c.dim(`(${agentLog.length} dòng · ↑/↓ cuộn · Ctrl+O đóng)`);
459
+ if (!agentLog.length) {
460
+ return [head, c.dim(' (chưa có log — sub-agent nền sẽ ghi vào đây)')];
461
+ }
462
+ // Cửa sổ [start, end) tính từ ĐÁY theo overlayScroll (0 = đáy/mới nhất).
463
+ const end = Math.max(0, agentLog.length - overlayScroll);
464
+ const start = Math.max(0, end - OVERLAY_LINES);
465
+ const body = [];
466
+ const width = Math.max(8, cols() - 2);
467
+ for (const raw of agentLog.slice(start, end)) {
468
+ // Mỗi entry có thể dài → soft-wrap, nhưng chỉ lấy dòng đầu + '…' để panel
469
+ // gọn (1 entry = 1 dòng panel). User cuộn để xem từng entry.
470
+ const oneLine = String(raw).replace(/\n/g, ' ');
471
+ const wrapped = softWrapLine(oneLine, width);
472
+ body.push(
473
+ ' ' + (wrapped.length > 1 ? wrapped[0].replace(ANSI_RE, '') + c.dim('…') : wrapped[0])
474
+ );
475
+ }
476
+ const more = overlayScroll > 0 ? c.dim(` ↓ còn ${overlayScroll} dòng mới hơn`) : '';
477
+ return more ? [head, ...body, more] : [head, ...body];
478
+ }
408
479
  function rows() {
409
480
  const r = [];
410
- const top = topRow();
411
- if (top !== null) r.push(...top);
412
- for (const mr of menuRows()) r.push(mr);
481
+ if (overlayOpen) {
482
+ // Overlay mở: thay top/menu bằng panel log. Thanh nhập vẫn vẽ bên dưới để
483
+ // user /lệnh (vd Ctrl+O đóng) mà không mất con trỏ.
484
+ r.push(...overlayRows());
485
+ } else {
486
+ const top = topRow();
487
+ if (top !== null) r.push(...top);
488
+ for (const mr of menuRows()) r.push(mr);
489
+ }
413
490
  const bar = renderBar();
414
491
  if (bar) r.push(...bar.split('\n'));
415
492
  return r;
@@ -532,9 +609,31 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
532
609
  refreshMenu();
533
610
  draw();
534
611
  }
612
+ // Echo tin nhắn user vừa gửi thành KHỐI nền màu, gutter ember mỗi dòng. Plain
613
+ // text (không phải coloredInput) để bg phủ đều; mỗi dòng tự reset (tránh bleed
614
+ // màu sang hết chiều rộng terminal). Paste chip → 1 dòng tóm tắt [pasted...].
615
+ function echoUserBlock() {
616
+ const plain = cells.map(cellPlain).join('');
617
+ if (!plain.trim()) return promptLabel + coloredInput();
618
+ const gutter = c.userGutter('▌') + ' ';
619
+ const gutterW = 2; // '▌' + space (▌ là 1 cột)
620
+ const budget = Math.max(8, cols() - gutterW);
621
+ const wrapped = plain
622
+ .split('\n')
623
+ .flatMap((ln) => softWrapLine(ln, budget))
624
+ .map((ln) => {
625
+ // softWrapLine có thể đính RESET cuối khi text gốc có ANSI; strip để bg
626
+ // phủ liền, rồi pad tới budget cho khối nền chữ nhật đều mép phải.
627
+ const body = ln.replace(ANSI_RE, '');
628
+ const padded = body + ' '.repeat(Math.max(0, budget - visLen(body)));
629
+ return gutter + c.userBg(' ' + padded + ' ');
630
+ });
631
+ return wrapped.join('\n');
632
+ }
633
+
535
634
  function submit() {
536
635
  const full = fullText();
537
- const echo = promptLabel + coloredInput();
636
+ const echo = echoUserBlock();
538
637
  if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
539
638
  submitHistory.push(full);
540
639
  if (submitHistory.length > 200) submitHistory.shift();
@@ -616,8 +715,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
616
715
  if (fin === 'Z')
617
716
  onShiftTab?.(); // Shift+Tab = bật/tắt yolo
618
717
  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) {
718
+ // ↑/↓: overlay mở → cuộn log; menu mở điều hướng menu; còn lại lịch sử.
719
+ if (overlayOpen) {
720
+ // ↑ = lùi về dòng cũ (tăng scroll), ↓ = về đáy (giảm scroll).
721
+ if (fin === 'A') overlayScroll = Math.min(agentLog.length, overlayScroll + 1);
722
+ else overlayScroll = Math.max(0, overlayScroll - 1);
723
+ draw();
724
+ } else if (menu.length) {
621
725
  menuIdx =
622
726
  fin === 'A' ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length;
623
727
  draw();
@@ -689,6 +793,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
689
793
  i++;
690
794
  continue;
691
795
  } // Ctrl+L: clear screen
796
+ if (ch === '\x0f') {
797
+ overlayOpen = !overlayOpen;
798
+ overlayScroll = 0; // mở lại = xem đáy (dòng mới nhất)
799
+ draw();
800
+ i++;
801
+ continue;
802
+ } // Ctrl+O: bật/tắt overlay log sub-agent nền
692
803
  if (ch === '\x01') {
693
804
  cur = 0;
694
805
  draw();
@@ -818,6 +929,27 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
818
929
  todos = Array.isArray(items) ? items : [];
819
930
  draw();
820
931
  },
932
+ setUltra(on) {
933
+ ultra = !!on;
934
+ draw();
935
+ },
936
+ setBgAgents(n) {
937
+ bgAgents = Math.max(0, n | 0);
938
+ draw();
939
+ },
940
+ // Đẩy 1 dòng log sub-agent nền vào ring buffer (KHÔNG in ra UI chính). Nếu
941
+ // overlay đang mở + đang xem đáy (scroll=0) → redraw để thấy dòng mới ngay.
942
+ pushAgentLog(line) {
943
+ if (line == null) return;
944
+ agentLog.push(String(line));
945
+ if (agentLog.length > MAX_AGENT_LOG) agentLog.shift();
946
+ if (overlayOpen && overlayScroll === 0) draw();
947
+ },
948
+ clearAgentLog() {
949
+ agentLog.length = 0;
950
+ overlayScroll = 0;
951
+ if (overlayOpen) draw();
952
+ },
821
953
  setPrompt(label) {
822
954
  promptLabel = label || '';
823
955
  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,11 +46,9 @@ 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;
@@ -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).
@@ -78,19 +89,27 @@ export function createBgRegistry(deps = {}) {
78
89
 
79
90
  // Headless dispatcher: own controller + meter, stub spinner/tui (bg không có UI).
80
91
  const noop = () => {};
92
+ // Prototype-delegate tới state SỐNG: đọc model/agentMode… luôn lấy giá trị
93
+ // hiện tại (user /model giữa chừng → bg sub-agent theo CLI pick). Chỉ override
94
+ // workflowRun cho bg journal. KHÔNG spread (spread = snapshot model lúc spawn).
95
+ const bgState = Object.create(state);
96
+ bgState.workflowRun = bgWorkflowRun;
81
97
  const dispatchTool = createAgentDispatcher({
82
- state: { ...state, workflowRun: bgWorkflowRun },
98
+ state: bgState,
83
99
  abort: controller,
84
100
  tokenMeter: meter,
85
101
  stopSpin: noop,
86
102
  startSpin: noop,
87
103
  execTool,
88
- tui: null,
104
+ tui, // sub-agent log → ring buffer (Ctrl+O), KHÔNG in ra UI chính
89
105
  c: null,
106
+ bgAgentLog: true, // cờ: dispatcher route onLog → tui.pushAgentLog thay console.log
90
107
  });
91
108
 
92
109
  // Chạy headless. runAgent options TUI-bound (onStatus/onDelta/onSteer) bỏ —
93
110
  // đều optional, ?.-guard trong agent.js.
111
+ // Top-level bg run: model chốt lúc spawn (1 runAgent dài, không swap giữa
112
+ // chừng được). Sub-agent nó đẻ ra đọc model SỐNG qua bgState proto → theo CLI.
94
113
  const history = [{ role: 'user', content: prompt }];
95
114
  const promise = _runAgent({
96
115
  history,
@@ -104,18 +123,32 @@ export function createBgRegistry(deps = {}) {
104
123
  .then((answer) => {
105
124
  entry.status = 'done';
106
125
  entry.result = typeof answer === 'string' ? answer : String(answer ?? '');
107
- 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 {}
108
132
  completions.push({ id: run.id, name, status: 'done', result: entry.result });
133
+ syncBadge();
109
134
  })
110
135
  .catch((err) => {
111
136
  const isAbort = err?.message === 'aborted' || err?.name === 'AbortError';
112
137
  entry.status = isAbort ? 'interrupted' : 'failed';
113
138
  entry.error = err?.message || String(err);
114
- 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 {}
115
147
  // Abort do stopBg/sweepOnExit → KHÔNG enqueue completion (user chủ động huỷ).
116
148
  if (!isAbort) {
117
149
  completions.push({ id: run.id, name, status: entry.status, error: entry.error });
118
150
  }
151
+ syncBadge();
119
152
  });
120
153
  entry.promise = promise;
121
154
 
@@ -136,6 +169,7 @@ export function createBgRegistry(deps = {}) {
136
169
  const entry = runs.get(id);
137
170
  if (!entry || entry.status !== 'running') return false;
138
171
  entry.controller.abort();
172
+ syncBadge();
139
173
  return true;
140
174
  }
141
175
 
@@ -150,6 +184,7 @@ export function createBgRegistry(deps = {}) {
150
184
  n++;
151
185
  }
152
186
  }
187
+ if (n) syncBadge();
153
188
  return n;
154
189
  }
155
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) {