@noobdemon/noob-cli 1.12.10 → 1.12.11

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,11 @@
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.11] - 2026-06-13
6
+
7
+ ### Added
8
+ - **Tool `workflow` — orchestration chạy NỀN (background), parity với Claude Code** (`src/workflow-bg.js` mới + `src/tools.js` + `src/repl/agent-dispatch.js` + `src/repl.js`): model gọi `workflow {prompt, name?}` → kick off headless `runAgent` chạy nền với own `AbortController` + own `TokenMeter`, trả về NGAY (không block turn). Khác `spawn_agents` (block lượt tới khi mọi sub-agent xong): `workflow` detach hẳn — user tiếp tục làm việc khác, kết quả consolidated tự chèn vào lượt sau qua `pending` queue. Registry session-scope (`createBgRegistry`), cap 3 run nền song song (vượt → tool trả ERROR), không lồng (bg dispatcher KHÔNG có bgRegistry → workflow nền không tự spawn workflow nền). Main loop drain completion giữa các lượt → notify + auto-inject. Exit CLI (Ctrl+C×2 hoặc /quit) → `sweepOnExit` mark mọi run active = interrupted (journal resume-able qua `/workflow resume <id>`). Lệnh mới: `/workflow active` (list run đang chạy), `/workflow stop <id>|all` (huỷ, resume được). Smoke `scripts/smoke-workflow-bg.mjs` 6/6 pass (start-returns-immediately, cap-3, drain-yields-result, stopBg-interrupted, sweepOnExit, empty-prompt-reject).
9
+
5
10
  ## [1.12.10] - 2026-06-13
6
11
 
7
12
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.12.10",
3
+ "version": "1.12.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/i18n.js CHANGED
@@ -205,6 +205,9 @@ export const t = {
205
205
  workflowAgentAskPrompt:
206
206
  ' bật agent mode và chạy workflow? [y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ',
207
207
  workflowAgentEnabled: 'đã bật agent mode cho workflow này.',
208
+ bgWorkflowDone: (label) => `workflow nền ${label} đã xong`,
209
+ bgWorkflowFailed: (label, err) => `workflow nền ${label} lỗi: ${err}`,
210
+ bgWorkflowSweep: (n) => `${n} workflow nền bị ngắt — resume bằng /workflow resume <id>`,
208
211
  workflowAgentDenied:
209
212
  'đã huỷ /workflow — agent mode vẫn TẮT. Gõ /agent rồi chạy lại lệnh nếu muốn.',
210
213
  // saved workflows (CRUD)
@@ -33,6 +33,9 @@ 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
+ // bgRegistry: tuỳ chọn — chỉ wire ở dispatcher của TURN (repl.js), KHÔNG ở bg
37
+ // dispatcher (tránh đệ quy: workflow nền spawn workflow nền). Thiếu → tool báo lỗi.
38
+ const bgRegistry = deps.bgRegistry || null;
36
39
  // Test injection points: production luôn dùng default; smoke test pass mock.
37
40
  const runSubAgent = deps.runSubAgent || defaultRunSubAgent;
38
41
  const findModel = deps.findModel || defaultFindModel;
@@ -95,6 +98,44 @@ export function createAgentDispatcher(deps) {
95
98
  };
96
99
  }
97
100
 
101
+ // workflow: kick off orchestration NỀN, trả về NGAY. Cần agentMode (workflow
102
+ // spawn sub-agent) + bgRegistry (chỉ wire ở turn dispatcher, KHÔNG ở bg → chống
103
+ // workflow nền tự spawn workflow nền).
104
+ if (name === 'workflow') {
105
+ if (!bgRegistry)
106
+ return {
107
+ 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).',
109
+ };
110
+ if (!state.agentMode)
111
+ return {
112
+ allow: true,
113
+ result: 'ERROR: agent mode đang TẮT — gõ /agent on trước khi chạy workflow nền.',
114
+ };
115
+ const prompt = input?.prompt || '';
116
+ const wfName = input?.name || null;
117
+ const r = bgRegistry.startBgWorkflow({ prompt, name: wfName });
118
+ if (!r.ok) {
119
+ if (r.error === 'cap')
120
+ return {
121
+ allow: true,
122
+ result: `ERROR: đã đạt ${bgRegistry.MAX_BG} workflow nền song song — chờ một run xong hoặc /workflow stop <id>.`,
123
+ };
124
+ if (r.error === 'empty_prompt')
125
+ return { allow: true, result: 'ERROR: workflow cần field "prompt" (yêu cầu cho workflow).' };
126
+ return { allow: true, result: 'ERROR khởi tạo workflow nền: ' + r.error };
127
+ }
128
+ stopSpin();
129
+ console.log(
130
+ chalk.hex('#8b5cf6')(` 🎼 workflow nền bắt đầu — run ${r.id}`)
131
+ );
132
+ startSpin(t.thinking);
133
+ return {
134
+ allow: true,
135
+ result: `workflow đã chạy NỀN (run ${r.id}). Trả về ngay — KHÔNG chờ. Kết quả consolidated sẽ tự chèn vào lượt sau khi xong. Huỷ: /workflow stop ${r.id}. Tiếp tục việc khác hoặc kết thúc reply.`,
136
+ };
137
+ }
138
+
98
139
  // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
99
140
  // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
100
141
  if (name === 'spawn_agent' || name === 'spawn_agents') {
@@ -56,6 +56,8 @@ export function workflowHelp({ c, t }) {
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
58
  console.log(' /workflow resume <id> chạy lại run, skip task đã done → tiết kiệm token');
59
+ console.log(' /workflow active workflow nền đang chạy');
60
+ console.log(' /workflow stop <id> | all huỷ workflow nền (resume được)');
59
61
  console.log(' /workflow delete|rm <name> xoá workflow đã lưu');
60
62
  console.log('');
61
63
  console.log(c.accent(' Nhanh nhất để thử:'));
package/src/repl.js CHANGED
@@ -82,6 +82,7 @@ import {
82
82
  shortCwd, shortPath, relTime, firstLine, truncate, fmtTime, fmtK, preview,
83
83
  } from './repl/utils.js';
84
84
  import { createAgentDispatcher } from './repl/agent-dispatch.js';
85
+ import { createBgRegistry } from './workflow-bg.js';
85
86
  export async function startRepl(opts = {}) {
86
87
  const state = createState(opts, config);
87
88
  const tokenMeter = new TokenMeter();
@@ -252,6 +253,10 @@ export async function startRepl(opts = {}) {
252
253
  // lần 2 trong cửa sổ 1.5s → thoát
253
254
  if (sigintTimer) clearTimeout(sigintTimer);
254
255
  exiting = true;
256
+ // Bg workflow đang chạy → abort + mark interrupted (journal resume-able).
257
+ const swept = bgRegistry.sweepOnExit();
258
+ 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>`)));
255
260
  persist();
256
261
  console.log(c.dim('\n ' + t.bye));
257
262
  tui.close(); // khôi phục terminal (raw mode/paste/stdout) trước khi thoát
@@ -453,6 +458,8 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
453
458
  if (sub === 'runs') return workflowRuns();
454
459
  if (sub === 'log') return workflowLog(rest);
455
460
  if (sub === 'resume') return workflowResume(rest);
461
+ if (sub === 'stop') return workflowStop(rest);
462
+ if (sub === 'active') return workflowActive();
456
463
  }
457
464
  // Default: ad-hoc workflow (giữ behavior cũ — model design workflow từ request).
458
465
  await workflowExecute(trimmed);
@@ -594,6 +601,42 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
594
601
  return _workflowList({ c, t });
595
602
  }
596
603
 
604
+ // Huỷ workflow nền theo id (hoặc `all`). Journal mark interrupted → resume được.
605
+ function workflowStop(arg) {
606
+ const id = (arg || '').trim();
607
+ if (!id) {
608
+ const active = bgRegistry.activeRuns();
609
+ if (!active.length) return console.log(c.dim(' không có workflow nền nào đang chạy.'));
610
+ 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) : ''));
612
+ return;
613
+ }
614
+ if (id === 'all') {
615
+ const n = bgRegistry.sweepOnExit();
616
+ 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.'))
618
+ );
619
+ }
620
+ const ok = bgRegistry.stopBg(id);
621
+ console.log(
622
+ ok
623
+ ? c.tool(' 🎼 đã huỷ workflow nền ' + c.accent(id) + c.dim(' — resume bằng /workflow resume ' + id))
624
+ : c.err(' không tìm thấy workflow nền đang chạy với id: ' + id)
625
+ );
626
+ }
627
+
628
+ // List workflow nền đang chạy.
629
+ function workflowActive() {
630
+ const active = bgRegistry.activeRuns();
631
+ if (!active.length) return console.log(c.dim(' không có workflow nền nào đang chạy.'));
632
+ console.log(c.tool(` 🎼 workflow nền đang chạy (${active.length}/${bgRegistry.MAX_BG}):`));
633
+ const now = Date.now();
634
+ for (const r of active) {
635
+ 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`));
637
+ }
638
+ }
639
+
597
640
  function workflowLoad(name) {
598
641
  return _workflowLoad(name, { c, t });
599
642
  }
@@ -1292,6 +1335,21 @@ NGUYÊN TẮC:
1292
1335
 
1293
1336
  // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
1294
1337
  while (true) {
1338
+ // Drain bg workflow đã xong → notify + auto-inject kết quả vào pending (gửi
1339
+ // như lượt mới, model tổng hợp cho user). Làm TRƯỚC khi đọc input để kết quả
1340
+ // nền không kẹt sau lượt user đang gõ.
1341
+ const fin = bgRegistry.drainCompletions();
1342
+ for (const f of fin) {
1343
+ const label = f.name ? `${f.name} (${f.id})` : f.id;
1344
+ if (f.status === 'done') {
1345
+ console.log(c.tool(' 🎼 ' + (t.bgWorkflowDone ? t.bgWorkflowDone(label) : `workflow nền ${label} đã xong`)));
1346
+ pending.push(
1347
+ `[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.`
1348
+ );
1349
+ } else {
1350
+ console.log(c.err(' 🎼 ' + (t.bgWorkflowFailed ? t.bgWorkflowFailed(label, f.error) : `workflow nền ${label} lỗi: ${f.error}`)));
1351
+ }
1352
+ }
1295
1353
  let input;
1296
1354
  if (pending.length) {
1297
1355
  // Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
@@ -1419,7 +1477,7 @@ NGUYÊN TẮC:
1419
1477
  // src/repl/agent-dispatch.js (v1.12.x). Factory được gọi MỖI turn vì abort
1420
1478
  // được rebind trong handle() — không cache.
1421
1479
  const dispatchTool = createAgentDispatcher({
1422
- state, abort, tokenMeter, stopSpin, startSpin, execTool, tui, c,
1480
+ state, abort, tokenMeter, stopSpin, startSpin, execTool, tui, c, bgRegistry,
1423
1481
  });
1424
1482
 
1425
1483
  const answer = await runAgent({
@@ -1596,6 +1654,18 @@ NGUYÊN TẮC:
1596
1654
  return await execToolCore(name, input, { retried: false });
1597
1655
  }
1598
1656
 
1657
+ // Background workflow registry — session-scope, sống ngoài turn. Tool `workflow`
1658
+ // (qua turn dispatcher) gọi startBgWorkflow → headless runAgent nền. Drain ở main
1659
+ // loop giữa lượt → notify + auto-inject. bgRegistry KHÔNG wire vào bg dispatcher
1660
+ // (chống lồng workflow nền). state + execTool + spawnAgentToolsDoc đã sẵn ở scope.
1661
+ const bgRegistry = createBgRegistry({
1662
+ createAgentDispatcher,
1663
+ state,
1664
+ execTool,
1665
+ spawnAgentToolsDoc,
1666
+ cwd: process.cwd(),
1667
+ });
1668
+
1599
1669
  // Phần thân tool (tách riêng để retry khi user vừa approve thêm extra root).
1600
1670
  // Flow OutOfScopeError: tool ném → repl hỏi user "add folder X? [y/n/a]" → nếu
1601
1671
  // y/a: addRoot + persist (đã làm trong addRoot) + state.extraRoots sync + chạy
@@ -1998,11 +2068,15 @@ NGUYÊN TẮC:
1998
2068
  break;
1999
2069
  case 'exit':
2000
2070
  case 'quit':
2001
- case 'q':
2071
+ case 'q': {
2072
+ const swept = bgRegistry.sweepOnExit();
2073
+ if (swept > 0)
2074
+ console.log(c.dim(' ' + (t.bgWorkflowSweep ? t.bgWorkflowSweep(swept) : `${swept} workflow nền bị ngắt — resume bằng /workflow resume <id>`)));
2002
2075
  persist();
2003
2076
  exiting = true;
2004
2077
  console.log(c.dim(' ' + t.bye));
2005
2078
  return true;
2079
+ }
2006
2080
  default:
2007
2081
  console.log(c.err(' ' + t.unknownCmd(cmd)) + c.dim(' ' + t.tryHelp));
2008
2082
  }
package/src/subagent.js CHANGED
@@ -87,6 +87,9 @@ Khi nhiệm vụ phức tạp / chia được thành nhiều phần độc lập
87
87
  Chạy 1 sub-agent độc lập, trả về kết quả dạng string (text cuối của sub-agent).
88
88
  - spawn_agents {"agents": [{"task": str, "context"?: str, "model"?: str}, ...]}
89
89
  Chạy NHIỀU sub-agent SONG SONG (Promise.all). Trả về mảng kết quả ghép theo thứ tự, mỗi phần có header "── sub-agent #N ──".
90
+ - workflow {"prompt": str, "name"?: str}
91
+ Chạy orchestration NỀN (background) — trả về NGAY, KHÔNG chờ. Dùng khi job dài, nhiều sub-agent, user muốn làm việc khác trong lúc chờ. Kết quả consolidated tự chèn vào lượt sau khi xong. Cap 3 workflow nền song song. Huỷ: /workflow stop <id>.
92
+ KHÁC spawn_agents: spawn_agents BLOCK lượt hiện tại tới khi mọi sub-agent xong (dùng khi cần kết quả NGAY trong lượt này để tổng hợp). workflow detach hẳn (dùng cho job dài user không muốn ngồi chờ). KHÔNG lồng — workflow nền không tự gọi workflow nền.
90
93
 
91
94
  ## Rules
92
95
  1. TASK PHẢI CỤ THỂ: nêu rõ goal + output mong đợi (vd "đọc src/api.js, liệt kê tất cả endpoint + method, trả về dạng bảng markdown"). Đừng giao task mơ hồ kiểu "phân tích code".
package/src/tools.js CHANGED
@@ -213,7 +213,7 @@ export const DESTRUCTIVE = new Set(['write_file', 'edit_file', 'run_command']);
213
213
 
214
214
  /**
215
215
  * Tên các tool model có thể gọi.
216
- * @typedef {'read_file'|'web_fetch'|'write_file'|'edit_file'|'list_dir'|'glob'|'grep'|'run_command'|'bg_output'|'kill_bg'} ToolName
216
+ * @typedef {'read_file'|'web_fetch'|'write_file'|'edit_file'|'list_dir'|'glob'|'grep'|'run_command'|'bg_output'|'kill_bg'|'workflow'} ToolName
217
217
  */
218
218
 
219
219
  /**
@@ -234,6 +234,8 @@ export const DESTRUCTIVE = new Set(['write_file', 'edit_file', 'run_command']);
234
234
  * @property {number} [id] bg_output/kill_bg
235
235
  * @property {string} [url] web_fetch: URL http/https cần tải
236
236
  * @property {boolean} [raw] web_fetch: true = trả HTML thô, không strip
237
+ * @property {string} [prompt] workflow: yêu cầu workflow chạy nền
238
+ * @property {string} [name] workflow: tên tuỳ chọn cho run
237
239
  */
238
240
 
239
241
  /**
@@ -774,6 +776,8 @@ export function describe(name, input) {
774
776
  return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
775
777
  case 'spawn_agents':
776
778
  return `↳ ${(input.tasks || []).length} sub-agent song song`;
779
+ case 'workflow':
780
+ return `🎼 workflow nền: ${String(input.prompt || '').slice(0, 60)}`;
777
781
  case 'write_todos': {
778
782
  const items = Array.isArray(input?.todos) ? input.todos : [];
779
783
  const done = items.filter((it) => it && it.done).length;
@@ -0,0 +1,172 @@
1
+ // Background workflow registry — chạy orchestration NỀN, không block turn.
2
+ //
3
+ // Khác /workflow blocking (repl.js:521-569): tool `workflow` model gọi → kick
4
+ // off headless runAgent với own AbortController + own TokenMeter, trả về NGAY
5
+ // {run_id}. Turn không chờ. Kết quả consolidated được drain ở main loop giữa
6
+ // các lượt → push vào `pending` → auto-inject lượt sau.
7
+ //
8
+ // Lifecycle: session-scope (sống ngoài turn spawn nó). Cap MAX_BG=3 song song.
9
+ // Exit CLI → sweepOnExit() mark mọi run active = interrupted (journal resume-able).
10
+ //
11
+ // KHÔNG closure coupling — plain map + functions, deps inject để test (noob.md rule).
12
+
13
+ import { runAgent } from './agent.js';
14
+ import { TokenMeter } from './tokens.js';
15
+ import { createRun, closeRun } from './workflow-runs.js';
16
+
17
+ export const MAX_BG = 3;
18
+
19
+ // Tạo registry. Deps inject (production wire ở repl.js; test pass mock).
20
+ // deps.createAgentDispatcher — factory dựng dispatchTool cho 1 bg run
21
+ // deps.state — state object (agentMode, model…)
22
+ // deps.execTool — async (name, input) → {allow, result}
23
+ // deps.spawnAgentToolsDoc — (depth) => string doc sub-agent tool
24
+ // deps.cwd — process.cwd() cho journal path
25
+ // deps.runAgent / deps.createRun / deps.closeRun — test override
26
+ export function createBgRegistry(deps = {}) {
27
+ const {
28
+ createAgentDispatcher,
29
+ state,
30
+ execTool,
31
+ spawnAgentToolsDoc,
32
+ cwd = process.cwd(),
33
+ } = deps;
34
+ const _runAgent = deps.runAgent || runAgent;
35
+ const _createRun = deps.createRun || createRun;
36
+ const _closeRun = deps.closeRun || closeRun;
37
+
38
+ // runId → { id, name, controller, meter, status, startedAt, result, error }
39
+ const runs = new Map();
40
+ // Hàng đợi run đã settle, chờ main loop drain.
41
+ const completions = [];
42
+
43
+ const activeCount = () =>
44
+ [...runs.values()].filter((r) => r.status === 'running').length;
45
+
46
+ // Kick off 1 bg workflow. Trả về { ok, id } hoặc { ok:false, error }.
47
+ function startBgWorkflow({ prompt, name = null } = {}) {
48
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
49
+ return { ok: false, error: 'empty_prompt' };
50
+ }
51
+ if (activeCount() >= MAX_BG) {
52
+ return { ok: false, error: 'cap' };
53
+ }
54
+ let run;
55
+ try {
56
+ run = _createRun({ name: name || 'bg', workflowPrompt: prompt, cwd });
57
+ } catch (e) {
58
+ return { ok: false, error: 'journal:' + (e?.message || e) };
59
+ }
60
+
61
+ const controller = new AbortController();
62
+ const meter = new TokenMeter();
63
+ const entry = {
64
+ id: run.id,
65
+ name,
66
+ controller,
67
+ meter,
68
+ status: 'running',
69
+ startedAt: Date.now(),
70
+ result: null,
71
+ error: null,
72
+ };
73
+ runs.set(run.id, entry);
74
+
75
+ // workflowRun riêng cho bg dispatcher: journal cache dùng run.data này, KHÔNG
76
+ // đụng state.workflowRun của turn (turn có thể đang chạy /workflow blocking).
77
+ const bgWorkflowRun = { id: run.id, data: run.data, path: run.path };
78
+
79
+ // Headless dispatcher: own controller + meter, stub spinner/tui (bg không có UI).
80
+ const noop = () => {};
81
+ const dispatchTool = createAgentDispatcher({
82
+ state: { ...state, workflowRun: bgWorkflowRun },
83
+ abort: controller,
84
+ tokenMeter: meter,
85
+ stopSpin: noop,
86
+ startSpin: noop,
87
+ execTool,
88
+ tui: null,
89
+ c: null,
90
+ });
91
+
92
+ // Chạy headless. runAgent options TUI-bound (onStatus/onDelta/onSteer) bỏ —
93
+ // đều optional, ?.-guard trong agent.js.
94
+ const history = [{ role: 'user', content: prompt }];
95
+ const promise = _runAgent({
96
+ history,
97
+ model: state.model.id,
98
+ signal: controller.signal,
99
+ tokenMeter: meter,
100
+ goal: prompt,
101
+ extraToolsDoc: spawnAgentToolsDoc ? spawnAgentToolsDoc(0) : '',
102
+ onTool: (n, inp) => dispatchTool(n, inp, 0),
103
+ })
104
+ .then((answer) => {
105
+ entry.status = 'done';
106
+ entry.result = typeof answer === 'string' ? answer : String(answer ?? '');
107
+ try { _closeRun(run.data, 'done', cwd); } catch {}
108
+ completions.push({ id: run.id, name, status: 'done', result: entry.result });
109
+ })
110
+ .catch((err) => {
111
+ const isAbort = err?.message === 'aborted' || err?.name === 'AbortError';
112
+ entry.status = isAbort ? 'interrupted' : 'failed';
113
+ entry.error = err?.message || String(err);
114
+ try { _closeRun(run.data, entry.status, cwd); } catch {}
115
+ // Abort do stopBg/sweepOnExit → KHÔNG enqueue completion (user chủ động huỷ).
116
+ if (!isAbort) {
117
+ completions.push({ id: run.id, name, status: entry.status, error: entry.error });
118
+ }
119
+ });
120
+ entry.promise = promise;
121
+
122
+ return { ok: true, id: run.id };
123
+ }
124
+
125
+ // Lấy + xoá các run đã settle. Main loop gọi giữa lượt → notify + auto-inject.
126
+ function drainCompletions() {
127
+ if (!completions.length) return [];
128
+ const out = completions.splice(0);
129
+ for (const c of out) runs.delete(c.id);
130
+ return out;
131
+ }
132
+
133
+ // Huỷ 1 bg run. controller.abort() → runAgent throw 'aborted' → catch mark
134
+ // interrupted + closeRun. Trả về true nếu có run để huỷ.
135
+ function stopBg(id) {
136
+ const entry = runs.get(id);
137
+ if (!entry || entry.status !== 'running') return false;
138
+ entry.controller.abort();
139
+ return true;
140
+ }
141
+
142
+ // Exit CLI: abort + mark interrupted mọi run active. Journal đầy đủ để resume.
143
+ // Đồng bộ (không await) — exit path không chờ promise settle.
144
+ function sweepOnExit() {
145
+ let n = 0;
146
+ for (const entry of runs.values()) {
147
+ if (entry.status === 'running') {
148
+ entry.controller.abort();
149
+ entry.status = 'interrupted';
150
+ n++;
151
+ }
152
+ }
153
+ return n;
154
+ }
155
+
156
+ // List run đang chạy — cho /workflow active + cảnh báo exit.
157
+ function activeRuns() {
158
+ return [...runs.values()]
159
+ .filter((r) => r.status === 'running')
160
+ .map((r) => ({ id: r.id, name: r.name, startedAt: r.startedAt }));
161
+ }
162
+
163
+ return {
164
+ startBgWorkflow,
165
+ drainCompletions,
166
+ stopBg,
167
+ sweepOnExit,
168
+ activeRuns,
169
+ activeCount,
170
+ MAX_BG,
171
+ };
172
+ }