@noobdemon/noob-cli 1.10.20 → 1.11.0

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,8 +217,8 @@ 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
+ process.on('exit', cleanupBg);
221
+ process.on('SIGTERM', () => {
176
222
  cleanupBg();
177
223
  process.exit(143);
178
224
  });
@@ -180,39 +226,39 @@ process.on("SIGTERM", () => {
180
226
  // Helper: throw nếu signal đã abort. Dùng ở đầu mỗi tool + giữa các vòng walk dài
181
227
  // để tool fs (glob/grep) cũng phản ứng với Ctrl+C, không chỉ run_command.
182
228
  function checkAbort(signal) {
183
- if (signal?.aborted) throw new Error("aborted");
229
+ if (signal?.aborted) throw new Error('aborted');
184
230
  }
185
231
 
186
232
  export const TOOLS = {
187
233
  async read_file({ path: p, offset, limit }, { signal } = {}) {
188
234
  checkAbort(signal);
189
- const data = await fs.readFile(abs(p), "utf8");
190
- let lines = data.split("\n");
235
+ const data = await fs.readFile(abs(p), 'utf8');
236
+ let lines = data.split('\n');
191
237
  const start = offset ? Math.max(0, offset - 1) : 0;
192
238
  if (offset || limit) lines = lines.slice(start, limit ? start + limit : undefined);
193
239
  const width = String(start + lines.length).length;
194
240
  return clip(
195
- lines.map((l, idx) => String(start + idx + 1).padStart(width) + " " + l).join("\n"),
241
+ lines.map((l, idx) => String(start + idx + 1).padStart(width) + ' ' + l).join('\n')
196
242
  );
197
243
  },
198
244
 
199
245
  async write_file({ path: p, content }, { signal } = {}) {
200
246
  checkAbort(signal);
201
247
  await fs.mkdir(path.dirname(abs(p)), { recursive: true });
202
- await fs.writeFile(abs(p), content ?? "", "utf8");
203
- const n = (content ?? "").split("\n").length;
248
+ await fs.writeFile(abs(p), content ?? '', 'utf8');
249
+ const n = (content ?? '').split('\n').length;
204
250
  return `Wrote ${n} line(s) to ${rel(abs(p))}`;
205
251
  },
206
252
 
207
253
  async edit_file({ path: p, old_string, new_string, replace_all }, { signal } = {}) {
208
254
  checkAbort(signal);
209
255
  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");
256
+ const data = await fs.readFile(file, 'utf8');
257
+ if (old_string === new_string) throw new Error('old_string and new_string are identical');
258
+ const useCRLF = data.includes('\r\n');
213
259
  const adapt = (s) => {
214
- const lf = s.replace(/\r\n/g, "\n");
215
- return useCRLF ? lf.replace(/\n/g, "\r\n") : lf;
260
+ const lf = s.replace(/\r\n/g, '\n');
261
+ return useCRLF ? lf.replace(/\n/g, '\r\n') : lf;
216
262
  };
217
263
  // Thay không diễn giải $&/$1… (split/slice, không dùng String.replace).
218
264
  const applyExact = (cand, newS) => {
@@ -222,14 +268,18 @@ export const TOOLS = {
222
268
  };
223
269
 
224
270
  // 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)]) {
271
+ for (const cand of old_string === adapt(old_string)
272
+ ? [old_string]
273
+ : [old_string, adapt(old_string)]) {
226
274
  const count = data.split(cand).length - 1;
227
275
  if (count === 0) continue;
228
276
  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`);
277
+ throw new Error(
278
+ `old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`
279
+ );
230
280
  // LUÔN adapt new_string về line ending của file (kể cả khi cand match raw old_string),
231
281
  // 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");
282
+ await fs.writeFile(file, applyExact(cand, adapt(new_string)), 'utf8');
233
283
  return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
234
284
  }
235
285
 
@@ -237,29 +287,33 @@ export const TOOLS = {
237
287
  // (Thụt đầu dòng vẫn phải khớp → không bao giờ sửa nhầm chỗ.)
238
288
  const m = matchByLines(data, old_string);
239
289
  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`);
290
+ throw new Error(
291
+ `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`
292
+ );
241
293
  if (m && m.count === 1) {
242
294
  const next = data.slice(0, m.start) + adapt(new_string) + data.slice(m.end);
243
- await fs.writeFile(file, next, "utf8");
295
+ await fs.writeFile(file, next, 'utf8');
244
296
  return `Edited ${rel(file)} (1 replacement(s))`;
245
297
  }
246
298
 
247
299
  // Không thấy → lỗi GIÀU THÔNG TIN: cho model thấy đúng byte trong file để sửa.
248
300
  throw new Error(
249
301
  `old_string not found in ${rel(file)}. Copy the target text EXACTLY (indentation/whitespace included, NO line-number prefix).` +
250
- nearbyContext(data, old_string),
302
+ nearbyContext(data, old_string)
251
303
  );
252
304
  },
253
305
 
254
- async list_dir({ path: p = "." }, { signal } = {}) {
306
+ async list_dir({ path: p = '.' }, { signal } = {}) {
255
307
  checkAbort(signal);
256
308
  const dir = abs(p);
257
309
  const entries = await fs.readdir(dir, { withFileTypes: true });
258
310
  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"));
311
+ .filter((e) => !e.name.startsWith('.git') && e.name !== 'node_modules')
312
+ .sort(
313
+ (a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name)
314
+ )
315
+ .map((e) => (e.isDirectory() ? e.name + '/' : e.name));
316
+ return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => ' ' + r).join('\n'));
263
317
  },
264
318
 
265
319
  async glob({ pattern }, { signal } = {}) {
@@ -279,38 +333,40 @@ export const TOOLS = {
279
333
  }
280
334
  for (const e of ents) {
281
335
  if ((++tickCounter & 0xff) === 0) checkAbort(signal);
282
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
336
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
283
337
  const full = path.join(dir, e.name);
284
338
  if (e.isDirectory()) walk(full);
285
- else if (rx.test(relFrom(root, full).split(path.sep).join("/"))) hits.push(displayPath(full));
339
+ else if (rx.test(relFrom(root, full).split(path.sep).join('/')))
340
+ hits.push(displayPath(full));
286
341
  if (hits.length > 500) return;
287
342
  }
288
343
  })(root);
289
344
  if (hits.length > 500) break;
290
345
  }
291
- return hits.length ? clip(hits.join("\n")) : "No files matched.";
346
+ return hits.length ? clip(hits.join('\n')) : 'No files matched.';
292
347
  },
293
348
 
294
349
  async grep({ pattern, path: p, glob: g }, { signal } = {}) {
295
350
  checkAbort(signal);
296
- const rx = new RegExp(pattern, "i");
351
+ const rx = new RegExp(pattern, 'i');
297
352
  const gRx = g ? globToRegExp(g) : null;
298
353
  const out = [];
299
354
  let tickCounter = 0;
300
355
  function scanFile(full) {
301
356
  if ((++tickCounter & 0xff) === 0) checkAbort(signal);
302
357
  const disp = displayPath(full);
303
- const relp = disp.split(path.sep).join("/");
358
+ const relp = disp.split(path.sep).join('/');
304
359
  if (gRx && !gRx.test(relp)) return;
305
360
  let txt;
306
361
  try {
307
- txt = fssync.readFileSync(full, "utf8");
362
+ txt = fssync.readFileSync(full, 'utf8');
308
363
  } catch {
309
364
  return;
310
365
  }
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)}`);
366
+ if (txt.includes('\u0000')) return; // skip binary files
367
+ txt.split('\n').forEach((l, idx) => {
368
+ if (rx.test(l) && out.length < 200)
369
+ out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
314
370
  });
315
371
  }
316
372
  function walkDir(dir) {
@@ -321,7 +377,7 @@ export const TOOLS = {
321
377
  return;
322
378
  }
323
379
  for (const e of ents) {
324
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
380
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
325
381
  const full = path.join(dir, e.name);
326
382
  if (e.isDirectory()) {
327
383
  walkDir(full);
@@ -331,25 +387,25 @@ export const TOOLS = {
331
387
  }
332
388
  }
333
389
  // Không truyền path → quét cwd + tất cả extra roots. Có path → chỉ vùng đó.
334
- if (p == null || p === "" || p === ".") {
390
+ if (p == null || p === '' || p === '.') {
335
391
  for (const root of listRoots()) walkDir(root);
336
392
  } else {
337
393
  let st;
338
394
  try {
339
395
  st = fssync.statSync(abs(p));
340
396
  } catch {
341
- return "No matches.";
397
+ return 'No matches.';
342
398
  }
343
399
  if (st.isFile()) scanFile(abs(p));
344
400
  else walkDir(abs(p));
345
401
  }
346
- return out.length ? clip(out.join("\n")) : "No matches.";
402
+ return out.length ? clip(out.join('\n')) : 'No matches.';
347
403
  },
348
404
 
349
405
  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];
406
+ const isWin = process.platform === 'win32';
407
+ const shell = isWin ? 'powershell.exe' : '/bin/bash';
408
+ const args = isWin ? ['-NoProfile', '-NonInteractive', '-Command', command] : ['-c', command];
353
409
  // stdin: "ignore" — tiến trình con KHÔNG được chạm vào console/stdin của
354
410
  // CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
355
411
  // 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
@@ -358,32 +414,32 @@ export const TOOLS = {
358
414
  // bằng bg_output, dừng bằng kill_bg.
359
415
  if (background) {
360
416
  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() };
417
+ const child = spawn(shell, args, { cwd: cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
418
+ const proc = { child, command, out: '', exited: false, code: null, startedAt: Date.now() };
363
419
  const cap = (d) => {
364
420
  proc.out += d;
365
421
  if (proc.out.length > MAX_OUT * 2) proc.out = proc.out.slice(-MAX_OUT * 2); // giữ phần mới nhất
366
422
  };
367
- child.stdout.on("data", cap);
368
- child.stderr.on("data", cap);
369
- child.on("error", (e) => {
423
+ child.stdout.on('data', cap);
424
+ child.stderr.on('data', cap);
425
+ child.on('error', (e) => {
370
426
  proc.exited = true;
371
427
  proc.out += `\n[failed to start: ${e.message}]`;
372
428
  });
373
- child.on("close", (code) => {
429
+ child.on('close', (code) => {
374
430
  proc.exited = true;
375
431
  proc.code = code;
376
432
  });
377
433
  bg.set(id, proc);
378
434
  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}}.`,
435
+ `Started background process #${id} (pid ${child.pid ?? '?'}): ${command}\n` +
436
+ `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
437
  );
382
438
  }
383
439
 
384
440
  return new Promise((resolve) => {
385
- const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
386
- let out = "";
441
+ const child = spawn(shell, args, { cwd: cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
442
+ let out = '';
387
443
  let timedOut = false;
388
444
  let aborted = false;
389
445
  const killer = setTimeout(() => {
@@ -398,24 +454,24 @@ export const TOOLS = {
398
454
  };
399
455
  if (signal) {
400
456
  if (signal.aborted) onAbort();
401
- else signal.addEventListener("abort", onAbort, { once: true });
457
+ else signal.addEventListener('abort', onAbort, { once: true });
402
458
  }
403
- child.stdout.on("data", (d) => (out += d));
404
- child.stderr.on("data", (d) => (out += d));
405
- child.on("error", (e) => {
459
+ child.stdout.on('data', (d) => (out += d));
460
+ child.stderr.on('data', (d) => (out += d));
461
+ child.on('error', (e) => {
406
462
  clearTimeout(killer);
407
- signal?.removeEventListener?.("abort", onAbort);
463
+ signal?.removeEventListener?.('abort', onAbort);
408
464
  resolve(`Failed to start command: ${e.message}`);
409
465
  });
410
- child.on("close", (code) => {
466
+ child.on('close', (code) => {
411
467
  clearTimeout(killer);
412
- signal?.removeEventListener?.("abort", onAbort);
468
+ signal?.removeEventListener?.('abort', onAbort);
413
469
  const tail = aborted
414
470
  ? `\n[aborted by user (Ctrl+C) — killed.]`
415
471
  : 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));
472
+ ? `\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.]`
473
+ : `\n[exit code ${code}]`;
474
+ resolve(clip((out.trim() || '(no output)') + tail));
419
475
  });
420
476
  });
421
477
  },
@@ -423,15 +479,20 @@ export const TOOLS = {
423
479
  // Đọ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
480
  bg_output({ id } = {}) {
425
481
  if (id == null) {
426
- if (!bg.size) return "No background processes.";
482
+ if (!bg.size) return 'No background processes.';
427
483
  return [...bg.entries()]
428
- .map(([i, p]) => `#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`)
429
- .join("\n");
484
+ .map(
485
+ ([i, p]) =>
486
+ `#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`
487
+ )
488
+ .join('\n');
430
489
  }
431
490
  const p = bg.get(id);
432
491
  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)"}`);
492
+ const status = p.exited
493
+ ? `exited (code ${p.code})`
494
+ : `running (pid ${p.child.pid}, ${Math.round((Date.now() - p.startedAt) / 1000)}s)`;
495
+ return clip(`#${id} ${status} — ${p.command}\n${p.out.trim() || '(no output yet)'}`);
435
496
  },
436
497
 
437
498
  // Dừng một tiến trình nền (kèm cây con trên Windows).
@@ -448,9 +509,9 @@ export const TOOLS = {
448
509
  // dòng. Trả {count, start, end} (offset ký tự trong data) khi khớp đúng 1 chỗ,
449
510
  // {count>1} khi nhập nhằng, hoặc null khi không thấy.
450
511
  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");
512
+ const trim = (l) => l.replace(/\r$/, '').replace(/[ \t]+$/, '');
513
+ const fileLines = data.split('\n');
514
+ const oldLines = oldStr.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
454
515
  if (!oldLines.length) return null;
455
516
  const nFile = fileLines.map(trim);
456
517
  const nOld = oldLines.map(trim);
@@ -480,9 +541,14 @@ function matchByLines(data, oldStr) {
480
541
  // Khi không khớp: in vùng file gần dòng giống nhất, dạng JSON-escaped để model
481
542
  // thấy rõ tab/space → sửa old_string cho khớp ngay lần sau.
482
543
  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");
544
+ const want = (
545
+ oldStr
546
+ .replace(/\r\n/g, '\n')
547
+ .split('\n')
548
+ .find((l) => l.trim()) || ''
549
+ ).trim();
550
+ if (!want) return '';
551
+ const lines = data.replace(/\r\n/g, '\n').split('\n');
486
552
  // Dòng giống nhất = tiền tố chung dài nhất với `want` (sau khi trim).
487
553
  const commonPrefix = (a, b) => {
488
554
  let i = 0;
@@ -499,68 +565,83 @@ function nearbyContext(data, oldStr) {
499
565
  hit = i;
500
566
  }
501
567
  }
502
- if (hit < 0 || best < 6) return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
568
+ if (hit < 0 || best < 6)
569
+ return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
503
570
  const a = Math.max(0, hit - 2);
504
571
  const b = Math.min(lines.length, hit + 3);
505
572
  const snippet = lines
506
573
  .slice(a, b)
507
574
  .map((l, k) => ` ${a + k + 1}: ${JSON.stringify(l)}`)
508
- .join("\n");
575
+ .join('\n');
509
576
  return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}`;
510
577
  }
511
578
 
512
579
  function globToRegExp(glob) {
513
- let rx = "";
580
+ let rx = '';
514
581
  for (let i = 0; i < glob.length; i++) {
515
582
  const ch = glob[i];
516
- if (ch === "*") {
517
- if (glob[i + 1] === "*") {
518
- rx += ".*";
583
+ if (ch === '*') {
584
+ if (glob[i + 1] === '*') {
585
+ rx += '.*';
519
586
  i++;
520
- if (glob[i + 1] === "/") i++;
521
- } else rx += "[^/]*";
522
- } else if (ch === "?") rx += "[^/]";
523
- else if (".+^${}()|[]\\".includes(ch)) rx += "\\" + ch;
587
+ if (glob[i + 1] === '/') i++;
588
+ } else rx += '[^/]*';
589
+ } else if (ch === '?') rx += '[^/]';
590
+ else if ('.+^${}()|[]\\'.includes(ch)) rx += '\\' + ch;
524
591
  else rx += ch;
525
592
  }
526
- return new RegExp("^" + rx + "$");
593
+ return new RegExp('^' + rx + '$');
527
594
  }
528
595
 
529
- // One-line human preview for the permission prompt / activity log.
596
+ /**
597
+ * Mô tả 1 dòng tool call cho permission prompt / activity log.
598
+ * @param {ToolName} name
599
+ * @param {ToolInput} input
600
+ * @returns {string}
601
+ */
530
602
  export function describe(name, input) {
531
603
  switch (name) {
532
- case "read_file":
604
+ case 'read_file':
533
605
  return `read ${input.path}`;
534
- case "write_file":
535
- return `write ${input.path} (${(input.content ?? "").split("\n").length} lines)`;
536
- case "edit_file":
606
+ case 'write_file':
607
+ return `write ${input.path} (${(input.content ?? '').split('\n').length} lines)`;
608
+ case 'edit_file':
537
609
  return `edit ${input.path}`;
538
- case "list_dir":
539
- return `ls ${input.path || "."}`;
540
- case "glob":
610
+ case 'list_dir':
611
+ return `ls ${input.path || '.'}`;
612
+ case 'glob':
541
613
  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":
614
+ case 'grep':
615
+ return `grep "${input.pattern}"${input.path ? ' in ' + input.path : ''}`;
616
+ case 'run_command':
617
+ return (input.background ? '$ (nền) ' : '$ ') + input.command;
618
+ case 'bg_output':
619
+ return input.id != null ? `xem tiến trình nền #${input.id}` : 'liệt kê tiến trình nền';
620
+ case 'kill_bg':
549
621
  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":
622
+ case 'spawn_agent':
623
+ return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
624
+ case 'spawn_agents':
553
625
  return `↳ ${(input.tasks || []).length} sub-agent song song`;
554
626
  default:
555
627
  return name;
556
628
  }
557
629
  }
558
630
 
631
+ /**
632
+ * Chạy 1 tool theo tên. Ném `Error('Unknown tool: ...')` nếu name không tồn tại,
633
+ * ném `Error('aborted')` nếu signal đã abort trước/giữa khi chạy, hoặc
634
+ * `OutOfScopeError` nếu path nằm ngoài cwd + extraRoots.
635
+ * @param {ToolName} name
636
+ * @param {ToolInput} input
637
+ * @param {ToolRunOpts} [opts]
638
+ * @returns {Promise<string>} Kết quả text (đã clip ở `MAX_OUT`).
639
+ */
559
640
  export async function runTool(name, input, opts = {}) {
560
641
  const fn = TOOLS[name];
561
642
  if (!fn) throw new Error(`Unknown tool: ${name}`);
562
643
  const { signal } = opts;
563
644
  // 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");
645
+ if (signal?.aborted) throw new Error('aborted');
565
646
  return await fn(input || {}, { signal });
566
647
  }