@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/CHANGELOG.md +465 -0
- package/README.md +113 -27
- package/bin/noob.js +40 -27
- package/package.json +30 -2
- package/src/agent.js +213 -124
- package/src/api.js +105 -48
- package/src/config.js +11 -11
- package/src/i18n.js +171 -148
- package/src/memory.js +24 -13
- package/src/models.js +96 -46
- package/src/prompts/system.md +85 -0
- package/src/repl/complete.js +120 -0
- package/src/repl/todos.js +38 -0
- package/src/repl/ultra.js +62 -0
- package/src/repl/workflow-commands.js +238 -0
- package/src/repl.js +794 -769
- package/src/sessions.js +20 -20
- package/src/skills.js +13 -9
- package/src/subagent.js +3 -3
- package/src/tokens.js +37 -12
- package/src/tools.js +202 -121
- package/src/tui.js +240 -124
- package/src/ui.js +44 -44
- package/src/update.js +21 -21
- package/src/workflows-builtin.js +16 -14
- package/src/workflows.js +29 -27
package/src/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import fssync from
|
|
3
|
-
import path from
|
|
4
|
-
import { spawn } from
|
|
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 =
|
|
22
|
-
this.code =
|
|
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(),
|
|
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(),
|
|
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 !==
|
|
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),
|
|
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(
|
|
82
|
+
if (!p) throw new Error('thiếu path');
|
|
83
83
|
const full = path.resolve(p);
|
|
84
84
|
let st;
|
|
85
|
-
try {
|
|
86
|
-
|
|
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(
|
|
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([
|
|
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([
|
|
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 ===
|
|
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(
|
|
175
|
-
process.on(
|
|
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(
|
|
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),
|
|
190
|
-
let lines = data.split(
|
|
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) +
|
|
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 ??
|
|
203
|
-
const n = (content ??
|
|
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,
|
|
211
|
-
if (old_string === new_string) throw new Error(
|
|
212
|
-
const useCRLF = data.includes(
|
|
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,
|
|
215
|
-
return useCRLF ? lf.replace(/\n/g,
|
|
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)
|
|
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(
|
|
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)),
|
|
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(
|
|
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,
|
|
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 =
|
|
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(
|
|
260
|
-
.sort(
|
|
261
|
-
|
|
262
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
362
|
+
txt = fssync.readFileSync(full, 'utf8');
|
|
308
363
|
} catch {
|
|
309
364
|
return;
|
|
310
365
|
}
|
|
311
|
-
if (txt.includes(
|
|
312
|
-
txt.split(
|
|
313
|
-
if (rx.test(l) && out.length < 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(
|
|
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 ===
|
|
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
|
|
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(
|
|
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 ===
|
|
351
|
-
const shell = isWin ?
|
|
352
|
-
const args = isWin ? [
|
|
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: [
|
|
362
|
-
const proc = { child, command, out:
|
|
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(
|
|
368
|
-
child.stderr.on(
|
|
369
|
-
child.on(
|
|
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(
|
|
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 ??
|
|
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: [
|
|
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(
|
|
457
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
402
458
|
}
|
|
403
|
-
child.stdout.on(
|
|
404
|
-
child.stderr.on(
|
|
405
|
-
child.on(
|
|
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?.(
|
|
463
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
408
464
|
resolve(`Failed to start command: ${e.message}`);
|
|
409
465
|
});
|
|
410
|
-
child.on(
|
|
466
|
+
child.on('close', (code) => {
|
|
411
467
|
clearTimeout(killer);
|
|
412
|
-
signal?.removeEventListener?.(
|
|
468
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
413
469
|
const tail = aborted
|
|
414
470
|
? `\n[aborted by user (Ctrl+C) — killed.]`
|
|
415
471
|
: timedOut
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
resolve(clip((out.trim() ||
|
|
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
|
|
482
|
+
if (!bg.size) return 'No background processes.';
|
|
427
483
|
return [...bg.entries()]
|
|
428
|
-
.map(
|
|
429
|
-
|
|
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
|
|
434
|
-
|
|
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$/,
|
|
452
|
-
const fileLines = data.split(
|
|
453
|
-
const oldLines = oldStr.replace(/\r\n/g,
|
|
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 = (
|
|
484
|
-
|
|
485
|
-
|
|
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)
|
|
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(
|
|
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] ===
|
|
521
|
-
} else rx +=
|
|
522
|
-
} else if (ch ===
|
|
523
|
-
else if (
|
|
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(
|
|
593
|
+
return new RegExp('^' + rx + '$');
|
|
527
594
|
}
|
|
528
595
|
|
|
529
|
-
|
|
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
|
|
604
|
+
case 'read_file':
|
|
533
605
|
return `read ${input.path}`;
|
|
534
|
-
case
|
|
535
|
-
return `write ${input.path} (${(input.content ??
|
|
536
|
-
case
|
|
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
|
|
539
|
-
return `ls ${input.path ||
|
|
540
|
-
case
|
|
610
|
+
case 'list_dir':
|
|
611
|
+
return `ls ${input.path || '.'}`;
|
|
612
|
+
case 'glob':
|
|
541
613
|
return `glob ${input.pattern}`;
|
|
542
|
-
case
|
|
543
|
-
return `grep "${input.pattern}"${input.path ?
|
|
544
|
-
case
|
|
545
|
-
return (input.background ?
|
|
546
|
-
case
|
|
547
|
-
return input.id != null ? `xem tiến trình nền #${input.id}` :
|
|
548
|
-
case
|
|
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
|
|
551
|
-
return `↳ sub-agent: ${String(input.task ||
|
|
552
|
-
case
|
|
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(
|
|
645
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
565
646
|
return await fn(input || {}, { signal });
|
|
566
647
|
}
|