@noobdemon/noob-cli 1.12.1 → 1.12.3

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.
@@ -0,0 +1,168 @@
1
+ // Agent tool dispatcher — xử lý spawn_agent / spawn_agents (sub-agent recursion +
2
+ // workflow journal cache) hoặc forward sang execTool cho các tool thường.
3
+ //
4
+ // Tách khỏi src/repl.js (v1.12.x) để:
5
+ // - giảm closure-coupling trong startRepl (rule noob.md "pure logic tách khỏi
6
+ // closure để testable")
7
+ // - smoke test 6 nhánh chính bằng mock thay vì E2E spawn process
8
+ //
9
+ // Pattern dùng: factory createAgentDispatcher(deps) trả về function dispatchTool.
10
+ // Factory được gọi MỖI turn trong handle() vì abort/printer được rebind theo turn —
11
+ // không cache singleton ở scope startRepl.
12
+
13
+ import chalk from 'chalk';
14
+ import { runSubAgent as defaultRunSubAgent, MAX_SUBAGENT_DEPTH } from '../subagent.js';
15
+ import { findModel as defaultFindModel } from '../models.js';
16
+ import * as defaultJournal from '../workflow-runs.js';
17
+ import { t } from '../i18n.js';
18
+
19
+ /**
20
+ * Tạo dispatcher cho 1 turn agent.
21
+ *
22
+ * @param {object} deps
23
+ * @param {object} deps.state — state object (cần state.agentMode, state.model, state.workflowRun)
24
+ * @param {AbortController} deps.abort — controller hiện tại của turn (đọc abort.signal)
25
+ * @param {object} deps.tokenMeter — TokenMeter instance, forward xuống sub-agent
26
+ * @param {function} deps.stopSpin — dừng spinner UI trước khi log
27
+ * @param {function} deps.startSpin — khởi động lại spinner sau log
28
+ * @param {function} deps.execTool — async (name, input) → {allow, result} cho tool thường
29
+ * @param {function} [deps.runSubAgent] — (chỉ dùng cho test) override sub-agent runner
30
+ * @param {function} [deps.findModel] — (chỉ dùng cho test) override model resolver
31
+ * @param {object} [deps.journal] — (chỉ dùng cho test) override workflow journal helpers
32
+ * @returns {function} dispatchTool(name, input, depth=0) → {allow, result}
33
+ */
34
+ export function createAgentDispatcher(deps) {
35
+ const { state, abort, tokenMeter, stopSpin, startSpin, execTool } = deps;
36
+ // Test injection points: production luôn dùng default; smoke test pass mock.
37
+ const runSubAgent = deps.runSubAgent || defaultRunSubAgent;
38
+ const findModel = deps.findModel || defaultFindModel;
39
+ const j = deps.journal || defaultJournal;
40
+ const hashWorkflowTask = j.hashTask;
41
+ const lookupWorkflowTaskResult = j.lookupTaskResult;
42
+ const recordWorkflowTaskStart = j.recordTaskStart;
43
+ const recordWorkflowTaskDone = j.recordTaskDone;
44
+ const recordWorkflowTaskFailed = j.recordTaskFailed;
45
+
46
+ const dispatchTool = async (name, input, depth = 0) => {
47
+ // spawn_agent / spawn_agents chỉ được phép khi agentMode bật; depth giới hạn
48
+ // bởi MAX_SUBAGENT_DEPTH để tránh đệ quy nổ.
49
+ if (name === 'spawn_agent' || name === 'spawn_agents') {
50
+ if (!state.agentMode)
51
+ return {
52
+ allow: true,
53
+ result: 'ERROR: agent mode đang TẮT — gõ /agent on để bật trước khi spawn.',
54
+ };
55
+ if (depth >= MAX_SUBAGENT_DEPTH)
56
+ return {
57
+ allow: true,
58
+ result: `ERROR: đã đạt depth tối đa (${MAX_SUBAGENT_DEPTH}) — không spawn thêm.`,
59
+ };
60
+ const tasks =
61
+ name === 'spawn_agent' ? [input] : Array.isArray(input?.agents) ? input.agents : [];
62
+ if (!tasks.length) return { allow: true, result: 'ERROR: thiếu task cho sub-agent.' };
63
+ stopSpin();
64
+ console.log(
65
+ chalk.hex('#8b5cf6')(
66
+ ` ⊕ spawn ${tasks.length} sub-agent (depth ${depth + 1}/${MAX_SUBAGENT_DEPTH})`
67
+ )
68
+ );
69
+ startSpin(t.thinking);
70
+ try {
71
+ const runData = state.workflowRun?.data || null;
72
+ const results = await Promise.all(
73
+ tasks.map((task, i) => {
74
+ // Per-sub-agent model routing: task.model có thể là id model hoặc tên thân thiện.
75
+ // findModel() resolve cả hai; nếu không match thì fallback model của cha.
76
+ let subModel = state.model.id;
77
+ let modelTag = '';
78
+ if (task?.model) {
79
+ const m = findModel(task.model);
80
+ if (m) {
81
+ subModel = m.id;
82
+ modelTag = ` [${m.name}]`;
83
+ } else
84
+ modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
85
+ }
86
+ const taskBody = task?.task || task?.prompt || '';
87
+ const taskCtx = task?.context || '';
88
+ // Workflow journal: nếu đang trong run + task đã done lần trước → return
89
+ // cached result, tiết kiệm token. Hash = crc32(task+ctx+model).
90
+ if (runData) {
91
+ const hash = hashWorkflowTask({ task: taskBody, context: taskCtx, model: subModel });
92
+ const cached = lookupWorkflowTaskResult(runData, hash);
93
+ if (cached !== null) {
94
+ stopSpin();
95
+ console.log(
96
+ chalk.hex('#8b5cf6')(
97
+ ` ⊘ sub-agent #${i + 1}${modelTag} skip — đã done trong run trước (cached)`
98
+ )
99
+ );
100
+ startSpin(t.thinking);
101
+ return Promise.resolve(
102
+ `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
103
+ );
104
+ }
105
+ recordWorkflowTaskStart(runData, {
106
+ hash,
107
+ task: taskBody,
108
+ context: taskCtx,
109
+ model: subModel,
110
+ });
111
+ // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
112
+ return runSubAgent({
113
+ task: taskBody,
114
+ context: taskCtx,
115
+ depth: depth + 1,
116
+ model: subModel,
117
+ signal: abort.signal,
118
+ tokenMeter,
119
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
120
+ onLog: (msg) => {
121
+ stopSpin();
122
+ console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
123
+ startSpin(t.thinking);
124
+ },
125
+ })
126
+ .then((r) => {
127
+ recordWorkflowTaskDone(runData, hash, r);
128
+ return `── sub-agent #${i + 1}${modelTag} ──\n${r}`;
129
+ })
130
+ .catch((e) => {
131
+ recordWorkflowTaskFailed(runData, hash, e);
132
+ return `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`;
133
+ });
134
+ }
135
+ // Không có active workflow run → behavior cũ.
136
+ return runSubAgent({
137
+ task: taskBody,
138
+ context: taskCtx,
139
+ depth: depth + 1,
140
+ model: subModel,
141
+ signal: abort.signal,
142
+ tokenMeter,
143
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
144
+ onLog: (msg) => {
145
+ stopSpin();
146
+ console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
147
+ startSpin(t.thinking);
148
+ },
149
+ })
150
+ .then((r) => `── sub-agent #${i + 1}${modelTag} ──\n${r}`)
151
+ .catch(
152
+ (e) => `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`
153
+ );
154
+ })
155
+ );
156
+ return { allow: true, result: results.join('\n\n') };
157
+ } catch (err) {
158
+ return { allow: true, result: 'ERROR sub-agent: ' + (err?.message || String(err)) };
159
+ }
160
+ }
161
+ stopSpin();
162
+ const res = await execTool(name, input);
163
+ startSpin(t.thinking);
164
+ return res;
165
+ };
166
+
167
+ return dispatchTool;
168
+ }
@@ -20,30 +20,42 @@
20
20
  // - truncate: helper cắt chuỗi dài.
21
21
 
22
22
  /**
23
- * Hỏi quyền chung cho 1 tool call. Trả về 'y' | 'n' | 'a'.
24
- * 'a' = auto-approve mọi lần gọi tool name này trong phiên còn lại.
23
+ * Hỏi quyền chung cho 1 tool call. Trả về 'y' | 'n' | 'a' | 't' | 'f'.
24
+ * 'y' = đồng ý lần này
25
+ * 'n' = từ chối
26
+ * 'a' = always — auto-approve tool này tới hết phiên
27
+ * 't' = this turn — auto-approve tool này tới hết turn hiện tại
28
+ * 'f' = this file — auto-approve mọi tool destructive trên path này tới hết phiên
29
+ * (chỉ hiện khi có `targetPath`, vd edit_file/write_file)
30
+ *
31
+ * `targetPath` (optional) = path file đang bị thao tác — nếu có, hiện kèm option `f`.
25
32
  */
26
- export async function askPermission(name, { tui, ask, pending, c, t, truncate }) {
33
+ export async function askPermission(name, { tui, ask, pending, c, t, truncate, targetPath } = {}) {
27
34
  tui.setBusy(false);
28
- console.log(
29
- c.tool(' Cần quyền: ' + name) +
30
- c.dim(' — y (đồng ý) / n (từ chối) / a (luôn cho phép)')
31
- );
35
+ const hasFile = typeof targetPath === 'string' && targetPath.length > 0;
36
+ const headerHint = hasFile
37
+ ? ' — y (đồng ý) / n (từ chối) / a (luôn cho phép) / t (turn này) / f (file này)'
38
+ : ' — y (đồng ý) / n (từ chối) / a (luôn cho phép) / t (turn này)';
39
+ console.log(c.tool(' ⏸ Cần quyền: ' + name) + c.dim(headerHint));
40
+ if (hasFile) console.log(c.dim(' file: ' + targetPath));
41
+ const promptHint = hasFile
42
+ ? '[y] có / [n] không / [a] luôn ' + name + ' / [t] hết turn / [f] file này › '
43
+ : '[y] có / [n] không / [a] luôn ' + name + ' / [t] hết turn › ';
32
44
  try {
33
45
  while (true) {
34
- const raw = await ask(
35
- c.tool(' cho phép? ') + c.dim('[y] có / [n] không / [a] luôn ' + name + ' › ')
36
- );
46
+ const raw = await ask(c.tool(' cho phép? ') + c.dim(promptHint));
37
47
  if (raw == null) return 'n';
38
48
  const a = raw.trim().toLowerCase();
39
49
  if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
40
50
  if (a === 'n' || a === 'no' || a === 'không') return 'n';
41
51
  if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
52
+ if (a === 't' || a === 'turn') return 't';
53
+ if (hasFile && (a === 'f' || a === 'file')) return 'f';
42
54
  if (raw.trim().length > 3) {
43
55
  pending.push(raw);
44
56
  console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
45
57
  }
46
- console.log(c.dim(' ' + t.permRetry));
58
+ console.log(c.dim(' ' + (t.permRetryExtended || t.permRetry)));
47
59
  }
48
60
  } finally {
49
61
  tui.setBusy(true, t.thinking);
package/src/repl/state.js CHANGED
@@ -19,7 +19,9 @@ export function createState(opts = {}, config) {
19
19
  model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
20
20
  mode: 'chat', // chat | merge | search
21
21
  history: [],
22
- autoApprove: new Set(),
22
+ autoApprove: new Set(), // tool name → 'a' (always, phiên)
23
+ autoApproveTurn: new Set(), // tool name → 't' (this turn, reset sau mỗi runAgent)
24
+ autoApproveFile: new Set(), // 'name:absPath' → 'f' (this file, phiên)
23
25
  yolo: !!opts.yolo || config.yoloDefault,
24
26
  ultra: false, // /ultra chế độ tự hành đang chạy
25
27
  agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents