@noobdemon/noob-cli 1.10.20 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js CHANGED
@@ -1,7 +1,7 @@
1
- import fs from "node:fs/promises";
2
- import fssync from "node:fs";
3
- import path from "node:path";
4
- import { spawn } from "node:child_process";
1
+ import fs from 'node:fs/promises';
2
+ import fssync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
5
 
6
6
  const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
7
7
  const cwd = () => process.cwd();
@@ -18,8 +18,8 @@ const extraRoots = new Set();
18
18
  export class OutOfScopeError extends Error {
19
19
  constructor(p, suggestedRoot) {
20
20
  super(`path nằm ngoài phạm vi (cwd + /add-dir): ${p}`);
21
- this.name = "OutOfScopeError";
22
- this.code = "OUT_OF_SCOPE";
21
+ this.name = 'OutOfScopeError';
22
+ this.code = 'OUT_OF_SCOPE';
23
23
  this.path = p;
24
24
  this.suggestedRoot = suggestedRoot;
25
25
  }
@@ -51,14 +51,14 @@ export function nearestExistingDir(p) {
51
51
  // Lưu NGAY khi addRoot được gọi (cả /add-dir lẫn auto-prompt path), nên user
52
52
  // không phải /add-dir lại mỗi lần mở project. Nếu read-only hoặc permission
53
53
  // deny → âm thầm bỏ qua (addRoot vẫn áp dụng cho phiên hiện tại).
54
- const WORKSPACE_DIRS_FILE = () => path.join(cwd(), ".noob", "dirs.json");
54
+ const WORKSPACE_DIRS_FILE = () => path.join(cwd(), '.noob', 'dirs.json');
55
55
  function loadWorkspaceRoots() {
56
56
  try {
57
- const raw = fssync.readFileSync(WORKSPACE_DIRS_FILE(), "utf8");
57
+ const raw = fssync.readFileSync(WORKSPACE_DIRS_FILE(), 'utf8');
58
58
  const arr = JSON.parse(raw);
59
59
  if (!Array.isArray(arr)) return;
60
60
  for (const r of arr) {
61
- if (typeof r !== "string") continue;
61
+ if (typeof r !== 'string') continue;
62
62
  const full = path.resolve(r);
63
63
  try {
64
64
  if (fssync.statSync(full).isDirectory()) extraRoots.add(full);
@@ -70,7 +70,7 @@ function saveWorkspaceRoots() {
70
70
  try {
71
71
  const file = WORKSPACE_DIRS_FILE();
72
72
  fssync.mkdirSync(path.dirname(file), { recursive: true });
73
- fssync.writeFileSync(file, JSON.stringify([...extraRoots], null, 2), "utf8");
73
+ fssync.writeFileSync(file, JSON.stringify([...extraRoots], null, 2), 'utf8');
74
74
  return true;
75
75
  } catch {
76
76
  return false;
@@ -79,11 +79,15 @@ function saveWorkspaceRoots() {
79
79
  loadWorkspaceRoots();
80
80
 
81
81
  export function addRoot(p) {
82
- if (!p) throw new Error("thiếu path");
82
+ if (!p) throw new Error('thiếu path');
83
83
  const full = path.resolve(p);
84
84
  let st;
85
- try { st = fssync.statSync(full); } catch { throw new Error("không tồn tại: " + p); }
86
- if (!st.isDirectory()) throw new Error("không phải thư mục: " + p);
85
+ try {
86
+ st = fssync.statSync(full);
87
+ } catch {
88
+ throw new Error('không tồn tại: ' + p);
89
+ }
90
+ if (!st.isDirectory()) throw new Error('không phải thư mục: ' + p);
87
91
  extraRoots.add(full);
88
92
  saveWorkspaceRoots(); // persist per-workspace — lần sau mở project auto-load
89
93
  return full;
@@ -102,7 +106,7 @@ export function listRoots() {
102
106
  function within(root, full) {
103
107
  if (full === root) return true;
104
108
  const rel = path.relative(root, full);
105
- return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
109
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
106
110
  }
107
111
 
108
112
  const abs = (p) => {
@@ -125,11 +129,11 @@ const abs = (p) => {
125
129
  }
126
130
  throw new OutOfScopeError(p, nearestExistingDir(p));
127
131
  };
128
- const rel = (p) => path.relative(cwd(), p) || ".";
132
+ const rel = (p) => path.relative(cwd(), p) || '.';
129
133
  // Tên rút gọn để hiển thị: nếu path thuộc cwd → relative cwd; nếu thuộc một
130
134
  // extra root → "<rootName>/<rel>" để user phân biệt được; còn lại fallback path tuyệt đối.
131
135
  function displayPath(full) {
132
- if (within(cwd(), full)) return path.relative(cwd(), full) || ".";
136
+ if (within(cwd(), full)) return path.relative(cwd(), full) || '.';
133
137
  for (const r of extraRoots) {
134
138
  if (within(r, full)) {
135
139
  const sub = path.relative(r, full);
@@ -139,10 +143,22 @@ function displayPath(full) {
139
143
  return full;
140
144
  }
141
145
  function relFrom(root, full) {
142
- return path.relative(root, full) || ".";
146
+ return path.relative(root, full) || '.';
143
147
  }
144
148
  // Thư mục bỏ qua khi walk (glob/grep). node_modules + các thư mục build/cache phổ biến.
145
- const SKIP_DIRS = new Set(["node_modules", ".next", "dist", "build", ".venv", "venv", "__pycache__", ".cache", ".turbo", ".parcel-cache", "target"]);
149
+ const SKIP_DIRS = new Set([
150
+ 'node_modules',
151
+ '.next',
152
+ 'dist',
153
+ 'build',
154
+ '.venv',
155
+ 'venv',
156
+ '__pycache__',
157
+ '.cache',
158
+ '.turbo',
159
+ '.parcel-cache',
160
+ 'target',
161
+ ]);
146
162
 
147
163
  function clip(s) {
148
164
  if (s.length <= MAX_OUT) return s;
@@ -150,7 +166,36 @@ function clip(s) {
150
166
  }
151
167
 
152
168
  // Tools that mutate the filesystem or run code require user approval.
153
- export const DESTRUCTIVE = new Set(["write_file", "edit_file", "run_command"]);
169
+ export const DESTRUCTIVE = new Set(['write_file', 'edit_file', 'run_command']);
170
+
171
+ /**
172
+ * Tên các tool model có thể gọi.
173
+ * @typedef {'read_file'|'write_file'|'edit_file'|'list_dir'|'glob'|'grep'|'run_command'|'bg_output'|'kill_bg'} ToolName
174
+ */
175
+
176
+ /**
177
+ * Input cho mỗi tool (chỉ liệt kê field thực sự dùng — gateway có thể gửi thêm).
178
+ * @typedef {Object} ToolInput
179
+ * @property {string} [path] read_file/write_file/edit_file/list_dir
180
+ * @property {number} [offset] read_file: dòng bắt đầu (1-indexed)
181
+ * @property {number} [limit] read_file: số dòng tối đa
182
+ * @property {string} [content] write_file
183
+ * @property {string} [old_string] edit_file
184
+ * @property {string} [new_string] edit_file
185
+ * @property {boolean} [replace_all] edit_file: thay tất cả thay vì 1 lần
186
+ * @property {string} [pattern] glob/grep
187
+ * @property {string} [glob] grep: filter file theo glob
188
+ * @property {string} [command] run_command
189
+ * @property {number} [timeout] run_command: ms (default 60000)
190
+ * @property {boolean} [background] run_command: spawn ngầm, trả về NGAY
191
+ * @property {number} [id] bg_output/kill_bg
192
+ */
193
+
194
+ /**
195
+ * Options khi chạy tool.
196
+ * @typedef {Object} ToolRunOpts
197
+ * @property {AbortSignal} [signal] Ctrl+C → tool bail giữa chừng
198
+ */
154
199
 
155
200
  // ── Tiến trình nền ─────────────────────────────────────────────────────────
156
201
  // Lệnh chạy lâu / không bao giờ thoát (dev server, watcher) KHÔNG được chạy ở
@@ -160,7 +205,8 @@ const bg = new Map(); // id -> { child, command, out, exited, code, startedAt }
160
205
  let bgSeq = 0;
161
206
  function killBgTree(child) {
162
207
  try {
163
- if (process.platform === "win32" && child.pid) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"]);
208
+ if (process.platform === 'win32' && child.pid)
209
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
164
210
  else child.kill();
165
211
  } catch {}
166
212
  }
@@ -171,48 +217,56 @@ function killBgTree(child) {
171
217
  function cleanupBg() {
172
218
  for (const p of bg.values()) killBgTree(p.child);
173
219
  }
174
- process.on("exit", cleanupBg);
175
- process.on("SIGTERM", () => {
220
+ function onSigterm() {
176
221
  cleanupBg();
177
222
  process.exit(143);
178
- });
223
+ }
224
+ // Guard chống đăng ký kép: vitest hot-reload / dynamic import có thể re-evaluate
225
+ // module này → nhiều listener → MaxListenersExceededWarning + leak. Chỉ add khi
226
+ // chưa có handler chính xác này.
227
+ if (!process.listeners('exit').includes(cleanupBg)) {
228
+ process.on('exit', cleanupBg);
229
+ }
230
+ if (!process.listeners('SIGTERM').includes(onSigterm)) {
231
+ process.on('SIGTERM', onSigterm);
232
+ }
179
233
 
180
234
  // Helper: throw nếu signal đã abort. Dùng ở đầu mỗi tool + giữa các vòng walk dài
181
235
  // để tool fs (glob/grep) cũng phản ứng với Ctrl+C, không chỉ run_command.
182
236
  function checkAbort(signal) {
183
- if (signal?.aborted) throw new Error("aborted");
237
+ if (signal?.aborted) throw new Error('aborted');
184
238
  }
185
239
 
186
240
  export const TOOLS = {
187
241
  async read_file({ path: p, offset, limit }, { signal } = {}) {
188
242
  checkAbort(signal);
189
- const data = await fs.readFile(abs(p), "utf8");
190
- let lines = data.split("\n");
243
+ const data = await fs.readFile(abs(p), 'utf8');
244
+ let lines = data.split('\n');
191
245
  const start = offset ? Math.max(0, offset - 1) : 0;
192
246
  if (offset || limit) lines = lines.slice(start, limit ? start + limit : undefined);
193
247
  const width = String(start + lines.length).length;
194
248
  return clip(
195
- lines.map((l, idx) => String(start + idx + 1).padStart(width) + " " + l).join("\n"),
249
+ lines.map((l, idx) => String(start + idx + 1).padStart(width) + ' ' + l).join('\n')
196
250
  );
197
251
  },
198
252
 
199
253
  async write_file({ path: p, content }, { signal } = {}) {
200
254
  checkAbort(signal);
201
255
  await fs.mkdir(path.dirname(abs(p)), { recursive: true });
202
- await fs.writeFile(abs(p), content ?? "", "utf8");
203
- const n = (content ?? "").split("\n").length;
256
+ await fs.writeFile(abs(p), content ?? '', 'utf8');
257
+ const n = (content ?? '').split('\n').length;
204
258
  return `Wrote ${n} line(s) to ${rel(abs(p))}`;
205
259
  },
206
260
 
207
261
  async edit_file({ path: p, old_string, new_string, replace_all }, { signal } = {}) {
208
262
  checkAbort(signal);
209
263
  const file = abs(p);
210
- const data = await fs.readFile(file, "utf8");
211
- if (old_string === new_string) throw new Error("old_string and new_string are identical");
212
- const useCRLF = data.includes("\r\n");
264
+ const data = await fs.readFile(file, 'utf8');
265
+ if (old_string === new_string) throw new Error('old_string and new_string are identical');
266
+ const useCRLF = data.includes('\r\n');
213
267
  const adapt = (s) => {
214
- const lf = s.replace(/\r\n/g, "\n");
215
- return useCRLF ? lf.replace(/\n/g, "\r\n") : lf;
268
+ const lf = s.replace(/\r\n/g, '\n');
269
+ return useCRLF ? lf.replace(/\n/g, '\r\n') : lf;
216
270
  };
217
271
  // Thay không diễn giải $&/$1… (split/slice, không dùng String.replace).
218
272
  const applyExact = (cand, newS) => {
@@ -222,14 +276,18 @@ export const TOOLS = {
222
276
  };
223
277
 
224
278
  // Tier 1 + 2: khớp NGUYÊN VĂN, rồi khớp sau khi chỉnh CRLF cho hợp file.
225
- for (const cand of old_string === adapt(old_string) ? [old_string] : [old_string, adapt(old_string)]) {
279
+ for (const cand of old_string === adapt(old_string)
280
+ ? [old_string]
281
+ : [old_string, adapt(old_string)]) {
226
282
  const count = data.split(cand).length - 1;
227
283
  if (count === 0) continue;
228
284
  if (count > 1 && !replace_all)
229
- throw new Error(`old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`);
285
+ throw new Error(
286
+ `old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`
287
+ );
230
288
  // LUÔN adapt new_string về line ending của file (kể cả khi cand match raw old_string),
231
289
  // tránh tạo file mix CRLF/LF làm git/editor hiển thị diff lạ → user tưởng 'Edited nhưng không apply'.
232
- await fs.writeFile(file, applyExact(cand, adapt(new_string)), "utf8");
290
+ await fs.writeFile(file, applyExact(cand, adapt(new_string)), 'utf8');
233
291
  return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
234
292
  }
235
293
 
@@ -237,29 +295,33 @@ export const TOOLS = {
237
295
  // (Thụt đầu dòng vẫn phải khớp → không bao giờ sửa nhầm chỗ.)
238
296
  const m = matchByLines(data, old_string);
239
297
  if (m && m.count > 1 && !replace_all)
240
- throw new Error(`old_string khớp ${m.count} chỗ (sau khi bỏ qua khoảng trắng cuối dòng) trong ${rel(file)}; thêm dòng ngữ cảnh hoặc dùng replace_all`);
298
+ throw new Error(
299
+ `old_string khớp ${m.count} chỗ (sau khi bỏ qua khoảng trắng cuối dòng) trong ${rel(file)}; thêm dòng ngữ cảnh hoặc dùng replace_all`
300
+ );
241
301
  if (m && m.count === 1) {
242
302
  const next = data.slice(0, m.start) + adapt(new_string) + data.slice(m.end);
243
- await fs.writeFile(file, next, "utf8");
303
+ await fs.writeFile(file, next, 'utf8');
244
304
  return `Edited ${rel(file)} (1 replacement(s))`;
245
305
  }
246
306
 
247
307
  // Không thấy → lỗi GIÀU THÔNG TIN: cho model thấy đúng byte trong file để sửa.
248
308
  throw new Error(
249
309
  `old_string not found in ${rel(file)}. Copy the target text EXACTLY (indentation/whitespace included, NO line-number prefix).` +
250
- nearbyContext(data, old_string),
310
+ nearbyContext(data, old_string)
251
311
  );
252
312
  },
253
313
 
254
- async list_dir({ path: p = "." }, { signal } = {}) {
314
+ async list_dir({ path: p = '.' }, { signal } = {}) {
255
315
  checkAbort(signal);
256
316
  const dir = abs(p);
257
317
  const entries = await fs.readdir(dir, { withFileTypes: true });
258
318
  const rows = entries
259
- .filter((e) => !e.name.startsWith(".git") && e.name !== "node_modules")
260
- .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
261
- .map((e) => (e.isDirectory() ? e.name + "/" : e.name));
262
- return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => " " + r).join("\n"));
319
+ .filter((e) => !e.name.startsWith('.git') && e.name !== 'node_modules')
320
+ .sort(
321
+ (a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name)
322
+ )
323
+ .map((e) => (e.isDirectory() ? e.name + '/' : e.name));
324
+ return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => ' ' + r).join('\n'));
263
325
  },
264
326
 
265
327
  async glob({ pattern }, { signal } = {}) {
@@ -279,38 +341,40 @@ export const TOOLS = {
279
341
  }
280
342
  for (const e of ents) {
281
343
  if ((++tickCounter & 0xff) === 0) checkAbort(signal);
282
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
344
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
283
345
  const full = path.join(dir, e.name);
284
346
  if (e.isDirectory()) walk(full);
285
- else if (rx.test(relFrom(root, full).split(path.sep).join("/"))) hits.push(displayPath(full));
347
+ else if (rx.test(relFrom(root, full).split(path.sep).join('/')))
348
+ hits.push(displayPath(full));
286
349
  if (hits.length > 500) return;
287
350
  }
288
351
  })(root);
289
352
  if (hits.length > 500) break;
290
353
  }
291
- return hits.length ? clip(hits.join("\n")) : "No files matched.";
354
+ return hits.length ? clip(hits.join('\n')) : 'No files matched.';
292
355
  },
293
356
 
294
357
  async grep({ pattern, path: p, glob: g }, { signal } = {}) {
295
358
  checkAbort(signal);
296
- const rx = new RegExp(pattern, "i");
359
+ const rx = new RegExp(pattern, 'i');
297
360
  const gRx = g ? globToRegExp(g) : null;
298
361
  const out = [];
299
362
  let tickCounter = 0;
300
363
  function scanFile(full) {
301
364
  if ((++tickCounter & 0xff) === 0) checkAbort(signal);
302
365
  const disp = displayPath(full);
303
- const relp = disp.split(path.sep).join("/");
366
+ const relp = disp.split(path.sep).join('/');
304
367
  if (gRx && !gRx.test(relp)) return;
305
368
  let txt;
306
369
  try {
307
- txt = fssync.readFileSync(full, "utf8");
370
+ txt = fssync.readFileSync(full, 'utf8');
308
371
  } catch {
309
372
  return;
310
373
  }
311
- if (txt.includes("\u0000")) return; // skip binary files
312
- txt.split("\n").forEach((l, idx) => {
313
- if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
374
+ if (txt.includes('\u0000')) return; // skip binary files
375
+ txt.split('\n').forEach((l, idx) => {
376
+ if (rx.test(l) && out.length < 200)
377
+ out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
314
378
  });
315
379
  }
316
380
  function walkDir(dir) {
@@ -321,7 +385,7 @@ export const TOOLS = {
321
385
  return;
322
386
  }
323
387
  for (const e of ents) {
324
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
388
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
325
389
  const full = path.join(dir, e.name);
326
390
  if (e.isDirectory()) {
327
391
  walkDir(full);
@@ -331,25 +395,25 @@ export const TOOLS = {
331
395
  }
332
396
  }
333
397
  // Không truyền path → quét cwd + tất cả extra roots. Có path → chỉ vùng đó.
334
- if (p == null || p === "" || p === ".") {
398
+ if (p == null || p === '' || p === '.') {
335
399
  for (const root of listRoots()) walkDir(root);
336
400
  } else {
337
401
  let st;
338
402
  try {
339
403
  st = fssync.statSync(abs(p));
340
404
  } catch {
341
- return "No matches.";
405
+ return 'No matches.';
342
406
  }
343
407
  if (st.isFile()) scanFile(abs(p));
344
408
  else walkDir(abs(p));
345
409
  }
346
- return out.length ? clip(out.join("\n")) : "No matches.";
410
+ return out.length ? clip(out.join('\n')) : 'No matches.';
347
411
  },
348
412
 
349
413
  run_command({ command, timeout = 60000, background = false }, { signal } = {}) {
350
- const isWin = process.platform === "win32";
351
- const shell = isWin ? "powershell.exe" : "/bin/bash";
352
- const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
414
+ const isWin = process.platform === 'win32';
415
+ const shell = isWin ? 'powershell.exe' : '/bin/bash';
416
+ const args = isWin ? ['-NoProfile', '-NonInteractive', '-Command', command] : ['-c', command];
353
417
  // stdin: "ignore" — tiến trình con KHÔNG được chạm vào console/stdin của
354
418
  // CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
355
419
  // 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
@@ -358,32 +422,32 @@ export const TOOLS = {
358
422
  // bằng bg_output, dừng bằng kill_bg.
359
423
  if (background) {
360
424
  const id = ++bgSeq;
361
- const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
362
- const proc = { child, command, out: "", exited: false, code: null, startedAt: Date.now() };
425
+ const child = spawn(shell, args, { cwd: cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
426
+ const proc = { child, command, out: '', exited: false, code: null, startedAt: Date.now() };
363
427
  const cap = (d) => {
364
428
  proc.out += d;
365
429
  if (proc.out.length > MAX_OUT * 2) proc.out = proc.out.slice(-MAX_OUT * 2); // giữ phần mới nhất
366
430
  };
367
- child.stdout.on("data", cap);
368
- child.stderr.on("data", cap);
369
- child.on("error", (e) => {
431
+ child.stdout.on('data', cap);
432
+ child.stderr.on('data', cap);
433
+ child.on('error', (e) => {
370
434
  proc.exited = true;
371
435
  proc.out += `\n[failed to start: ${e.message}]`;
372
436
  });
373
- child.on("close", (code) => {
437
+ child.on('close', (code) => {
374
438
  proc.exited = true;
375
439
  proc.code = code;
376
440
  });
377
441
  bg.set(id, proc);
378
442
  return Promise.resolve(
379
- `Started background process #${id} (pid ${child.pid ?? "?"}): ${command}\n` +
380
- `It keeps running and does NOT block further steps. Read its output with bg_output {"id": ${id}}, stop it with kill_bg {"id": ${id}}.`,
443
+ `Started background process #${id} (pid ${child.pid ?? '?'}): ${command}\n` +
444
+ `It keeps running and does NOT block further steps. Read its output with bg_output {"id": ${id}}, stop it with kill_bg {"id": ${id}}.`
381
445
  );
382
446
  }
383
447
 
384
448
  return new Promise((resolve) => {
385
- const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
386
- let out = "";
449
+ const child = spawn(shell, args, { cwd: cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
450
+ let out = '';
387
451
  let timedOut = false;
388
452
  let aborted = false;
389
453
  const killer = setTimeout(() => {
@@ -398,24 +462,24 @@ export const TOOLS = {
398
462
  };
399
463
  if (signal) {
400
464
  if (signal.aborted) onAbort();
401
- else signal.addEventListener("abort", onAbort, { once: true });
465
+ else signal.addEventListener('abort', onAbort, { once: true });
402
466
  }
403
- child.stdout.on("data", (d) => (out += d));
404
- child.stderr.on("data", (d) => (out += d));
405
- child.on("error", (e) => {
467
+ child.stdout.on('data', (d) => (out += d));
468
+ child.stderr.on('data', (d) => (out += d));
469
+ child.on('error', (e) => {
406
470
  clearTimeout(killer);
407
- signal?.removeEventListener?.("abort", onAbort);
471
+ signal?.removeEventListener?.('abort', onAbort);
408
472
  resolve(`Failed to start command: ${e.message}`);
409
473
  });
410
- child.on("close", (code) => {
474
+ child.on('close', (code) => {
411
475
  clearTimeout(killer);
412
- signal?.removeEventListener?.("abort", onAbort);
476
+ signal?.removeEventListener?.('abort', onAbort);
413
477
  const tail = aborted
414
478
  ? `\n[aborted by user (Ctrl+C) — killed.]`
415
479
  : timedOut
416
- ? `\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.]`
417
- : `\n[exit code ${code}]`;
418
- resolve(clip((out.trim() || "(no output)") + tail));
480
+ ? `\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.]`
481
+ : `\n[exit code ${code}]`;
482
+ resolve(clip((out.trim() || '(no output)') + tail));
419
483
  });
420
484
  });
421
485
  },
@@ -423,15 +487,20 @@ export const TOOLS = {
423
487
  // Đọc trạng thái + output đã gom của tiến trình nền. Không có id → liệt kê tất cả.
424
488
  bg_output({ id } = {}) {
425
489
  if (id == null) {
426
- if (!bg.size) return "No background processes.";
490
+ if (!bg.size) return 'No background processes.';
427
491
  return [...bg.entries()]
428
- .map(([i, p]) => `#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`)
429
- .join("\n");
492
+ .map(
493
+ ([i, p]) =>
494
+ `#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`
495
+ )
496
+ .join('\n');
430
497
  }
431
498
  const p = bg.get(id);
432
499
  if (!p) return `No background process #${id}.`;
433
- const status = p.exited ? `exited (code ${p.code})` : `running (pid ${p.child.pid}, ${Math.round((Date.now() - p.startedAt) / 1000)}s)`;
434
- return clip(`#${id} ${status} — ${p.command}\n${p.out.trim() || "(no output yet)"}`);
500
+ const status = p.exited
501
+ ? `exited (code ${p.code})`
502
+ : `running (pid ${p.child.pid}, ${Math.round((Date.now() - p.startedAt) / 1000)}s)`;
503
+ return clip(`#${id} ${status} — ${p.command}\n${p.out.trim() || '(no output yet)'}`);
435
504
  },
436
505
 
437
506
  // Dừng một tiến trình nền (kèm cây con trên Windows).
@@ -448,9 +517,9 @@ export const TOOLS = {
448
517
  // dòng. Trả {count, start, end} (offset ký tự trong data) khi khớp đúng 1 chỗ,
449
518
  // {count>1} khi nhập nhằng, hoặc null khi không thấy.
450
519
  function matchByLines(data, oldStr) {
451
- const trim = (l) => l.replace(/\r$/, "").replace(/[ \t]+$/, "");
452
- const fileLines = data.split("\n");
453
- const oldLines = oldStr.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
520
+ const trim = (l) => l.replace(/\r$/, '').replace(/[ \t]+$/, '');
521
+ const fileLines = data.split('\n');
522
+ const oldLines = oldStr.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
454
523
  if (!oldLines.length) return null;
455
524
  const nFile = fileLines.map(trim);
456
525
  const nOld = oldLines.map(trim);
@@ -480,9 +549,14 @@ function matchByLines(data, oldStr) {
480
549
  // Khi không khớp: in vùng file gần dòng giống nhất, dạng JSON-escaped để model
481
550
  // thấy rõ tab/space → sửa old_string cho khớp ngay lần sau.
482
551
  function nearbyContext(data, oldStr) {
483
- const want = (oldStr.replace(/\r\n/g, "\n").split("\n").find((l) => l.trim()) || "").trim();
484
- if (!want) return "";
485
- const lines = data.replace(/\r\n/g, "\n").split("\n");
552
+ const want = (
553
+ oldStr
554
+ .replace(/\r\n/g, '\n')
555
+ .split('\n')
556
+ .find((l) => l.trim()) || ''
557
+ ).trim();
558
+ if (!want) return '';
559
+ const lines = data.replace(/\r\n/g, '\n').split('\n');
486
560
  // Dòng giống nhất = tiền tố chung dài nhất với `want` (sau khi trim).
487
561
  const commonPrefix = (a, b) => {
488
562
  let i = 0;
@@ -499,68 +573,83 @@ function nearbyContext(data, oldStr) {
499
573
  hit = i;
500
574
  }
501
575
  }
502
- if (hit < 0 || best < 6) return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
576
+ if (hit < 0 || best < 6)
577
+ return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
503
578
  const a = Math.max(0, hit - 2);
504
579
  const b = Math.min(lines.length, hit + 3);
505
580
  const snippet = lines
506
581
  .slice(a, b)
507
582
  .map((l, k) => ` ${a + k + 1}: ${JSON.stringify(l)}`)
508
- .join("\n");
583
+ .join('\n');
509
584
  return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}`;
510
585
  }
511
586
 
512
587
  function globToRegExp(glob) {
513
- let rx = "";
588
+ let rx = '';
514
589
  for (let i = 0; i < glob.length; i++) {
515
590
  const ch = glob[i];
516
- if (ch === "*") {
517
- if (glob[i + 1] === "*") {
518
- rx += ".*";
591
+ if (ch === '*') {
592
+ if (glob[i + 1] === '*') {
593
+ rx += '.*';
519
594
  i++;
520
- if (glob[i + 1] === "/") i++;
521
- } else rx += "[^/]*";
522
- } else if (ch === "?") rx += "[^/]";
523
- else if (".+^${}()|[]\\".includes(ch)) rx += "\\" + ch;
595
+ if (glob[i + 1] === '/') i++;
596
+ } else rx += '[^/]*';
597
+ } else if (ch === '?') rx += '[^/]';
598
+ else if ('.+^${}()|[]\\'.includes(ch)) rx += '\\' + ch;
524
599
  else rx += ch;
525
600
  }
526
- return new RegExp("^" + rx + "$");
601
+ return new RegExp('^' + rx + '$');
527
602
  }
528
603
 
529
- // One-line human preview for the permission prompt / activity log.
604
+ /**
605
+ * Mô tả 1 dòng tool call cho permission prompt / activity log.
606
+ * @param {ToolName} name
607
+ * @param {ToolInput} input
608
+ * @returns {string}
609
+ */
530
610
  export function describe(name, input) {
531
611
  switch (name) {
532
- case "read_file":
612
+ case 'read_file':
533
613
  return `read ${input.path}`;
534
- case "write_file":
535
- return `write ${input.path} (${(input.content ?? "").split("\n").length} lines)`;
536
- case "edit_file":
614
+ case 'write_file':
615
+ return `write ${input.path} (${(input.content ?? '').split('\n').length} lines)`;
616
+ case 'edit_file':
537
617
  return `edit ${input.path}`;
538
- case "list_dir":
539
- return `ls ${input.path || "."}`;
540
- case "glob":
618
+ case 'list_dir':
619
+ return `ls ${input.path || '.'}`;
620
+ case 'glob':
541
621
  return `glob ${input.pattern}`;
542
- case "grep":
543
- return `grep "${input.pattern}"${input.path ? " in " + input.path : ""}`;
544
- case "run_command":
545
- return (input.background ? "$ (nền) " : "$ ") + input.command;
546
- case "bg_output":
547
- return input.id != null ? `xem tiến trình nền #${input.id}` : "liệt kê tiến trình nền";
548
- case "kill_bg":
622
+ case 'grep':
623
+ return `grep "${input.pattern}"${input.path ? ' in ' + input.path : ''}`;
624
+ case 'run_command':
625
+ return (input.background ? '$ (nền) ' : '$ ') + input.command;
626
+ case 'bg_output':
627
+ return input.id != null ? `xem tiến trình nền #${input.id}` : 'liệt kê tiến trình nền';
628
+ case 'kill_bg':
549
629
  return `dừng tiến trình nền #${input.id}`;
550
- case "spawn_agent":
551
- return `↳ sub-agent: ${String(input.task || "").slice(0, 80)}`;
552
- case "spawn_agents":
630
+ case 'spawn_agent':
631
+ return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
632
+ case 'spawn_agents':
553
633
  return `↳ ${(input.tasks || []).length} sub-agent song song`;
554
634
  default:
555
635
  return name;
556
636
  }
557
637
  }
558
638
 
639
+ /**
640
+ * Chạy 1 tool theo tên. Ném `Error('Unknown tool: ...')` nếu name không tồn tại,
641
+ * ném `Error('aborted')` nếu signal đã abort trước/giữa khi chạy, hoặc
642
+ * `OutOfScopeError` nếu path nằm ngoài cwd + extraRoots.
643
+ * @param {ToolName} name
644
+ * @param {ToolInput} input
645
+ * @param {ToolRunOpts} [opts]
646
+ * @returns {Promise<string>} Kết quả text (đã clip ở `MAX_OUT`).
647
+ */
559
648
  export async function runTool(name, input, opts = {}) {
560
649
  const fn = TOOLS[name];
561
650
  if (!fn) throw new Error(`Unknown tool: ${name}`);
562
651
  const { signal } = opts;
563
652
  // Pre-check: nếu user đã Ctrl+C trước khi tool kịp chạy, bail ngay.
564
- if (signal?.aborted) throw new Error("aborted");
653
+ if (signal?.aborted) throw new Error('aborted');
565
654
  return await fn(input || {}, { signal });
566
655
  }