@semalt-ai/code 1.8.1 → 1.8.4

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/lib/tools.js CHANGED
@@ -1,17 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const fsp = require('fs/promises');
4
5
  const http = require('http');
5
6
  const https = require('https');
6
7
  const os = require('os');
7
8
  const path = require('path');
8
- const { spawnSync } = require('child_process');
9
+ const { spawn } = require('child_process');
9
10
 
10
11
  const { logToolCall } = require('./audit');
12
+ const writer = require('./ui/writer');
11
13
 
12
14
  const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
13
15
 
14
16
  const _dryRun = process.argv.includes('--dry-run');
17
+ const _allowAnywhere = process.argv.includes('--allow-anywhere');
15
18
  const _skippedOps = [];
16
19
  function getSkippedOps() { return _skippedOps.slice(); }
17
20
 
@@ -19,15 +22,63 @@ function getSkippedOps() { return _skippedOps.slice(); }
19
22
  // handles all tool-status display via onToolEnd callbacks instead.
20
23
  let _uiActive = false;
21
24
  function setUIActive(v) { _uiActive = v; }
25
+ function isUIActive() { return _uiActive; }
26
+ // audit: allowed — fires only when TUI is inactive (one-shot non-TUI commands), no live region to protect.
22
27
  function _log(...args) { if (!_uiActive) console.log(...args); }
23
28
 
24
- // Chunk store for large http_get responses.
25
- // Maps URL { chunks: string[], total: number, delivered: number }
26
- const _httpChunkStore = new Map();
27
- const HTTP_CHUNK_CHARS = 10000;
29
+ // Reject writes outside the project CWD and in sensitive system/home dirs
30
+ // (~/.ssh, ~/.aws, ~/.gnupg, /etc, /boot, /sys, /proc). Override with
31
+ // --allow-anywhere when an agent genuinely needs to touch outside paths.
32
+ function isPathSafe(filePath) {
33
+ if (_allowAnywhere) return true;
34
+ if (typeof filePath !== 'string' || !filePath) return false;
35
+ const resolved = path.resolve(filePath);
36
+ const home = os.homedir();
37
+ const banned = [
38
+ path.join(home, '.ssh') + path.sep,
39
+ path.join(home, '.aws') + path.sep,
40
+ path.join(home, '.gnupg') + path.sep,
41
+ '/etc/',
42
+ '/boot/',
43
+ '/sys/',
44
+ '/proc/',
45
+ ];
46
+ for (const b of banned) {
47
+ if (resolved === b.slice(0, -1) || resolved.startsWith(b)) return false;
48
+ }
49
+ const cwd = process.cwd();
50
+ const cwdPrefix = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
51
+ return resolved === cwd || resolved.startsWith(cwdPrefix);
52
+ }
53
+
54
+ function _sandboxError(filePath) {
55
+ return { error: `Path outside allowed area: ${filePath}. Use --allow-anywhere to override.` };
56
+ }
57
+
58
+ // Cheap ReDoS guard. Rejects pathologically long patterns, common
59
+ // catastrophic-backtracking anti-patterns, and pattern×data sizes large
60
+ // enough to hang the regex engine.
61
+ function _checkRegexSafety(pattern, data) {
62
+ if (typeof pattern !== 'string') return null;
63
+ if (pattern.length > 1000) {
64
+ return { error: 'Pattern rejected: length exceeds 1000 chars' };
65
+ }
66
+ if (/(\(.*[+*].*\).*[+*])|(\[.*\].*[+*].*[+*])/.test(pattern)) {
67
+ return { error: 'Pattern rejected: potentially catastrophic backtracking' };
68
+ }
69
+ const dataLen = typeof data === 'string' ? data.length : 0;
70
+ if (dataLen * pattern.length > 10_000_000) {
71
+ return { error: 'Pattern too complex for input size' };
72
+ }
73
+ return null;
74
+ }
28
75
 
29
76
  function createToolExecutor(permissionManager, ui, getConfig) {
30
77
  const { BOLD, DIM, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, renderDiff } = ui;
78
+ // Continuation lines in a system-message bubble (chat-history.js else branch)
79
+ // are indented by 5 spaces. Let the diff renderer reserve those columns so
80
+ // its lines don't auto-wrap inside the bubble.
81
+ const DIFF_BUBBLE_INSET = 5;
31
82
 
32
83
  function _parseNumberedOptions(text) {
33
84
  const options = [];
@@ -52,20 +103,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
52
103
  return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
53
104
  }
54
105
 
55
- try {
56
- const cfg = getConfig ? getConfig() : {};
57
- const timeout = cfg.command_timeout_ms || 30000;
58
- const result = spawnSync(command, { shell: true, encoding: 'utf8', timeout });
59
- const stdout = result.stdout || '';
60
- const stderr = result.stderr || '';
61
- const exitCode = result.status ?? 0;
62
- logToolCall('exec', { command }, true, exitCode === 0 ? 'ok' : 'error');
63
- return { exit_code: exitCode, stdout, stderr };
64
- } catch (error) {
65
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
66
- logToolCall('exec', { command }, true, 'error');
67
- return { exit_code: -1, stdout: '', stderr: error.message };
68
- }
106
+ const cfg = getConfig ? getConfig() : {};
107
+ const timeout = cfg.command_timeout_ms || 30000;
108
+
109
+ return new Promise((resolve) => {
110
+ let child;
111
+ try {
112
+ child = spawn(command, { shell: true });
113
+ } catch (error) {
114
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
115
+ logToolCall('exec', { command }, true, 'error');
116
+ return resolve({ exit_code: -1, stdout: '', stderr: error.message });
117
+ }
118
+ let stdout = '';
119
+ let stderr = '';
120
+ let killed = false;
121
+ const timer = setTimeout(() => {
122
+ killed = true;
123
+ try { child.kill('SIGTERM'); } catch {}
124
+ }, timeout);
125
+ child.stdout.setEncoding('utf8');
126
+ child.stderr.setEncoding('utf8');
127
+ child.stdout.on('data', (c) => { stdout += c; });
128
+ child.stderr.on('data', (c) => { stderr += c; });
129
+ child.on('error', (error) => {
130
+ clearTimeout(timer);
131
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
132
+ logToolCall('exec', { command }, true, 'error');
133
+ resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
134
+ });
135
+ child.on('close', (code, signal) => {
136
+ clearTimeout(timer);
137
+ if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
138
+ const exit_code = killed ? -1 : (code != null ? code : (signal ? -1 : 0));
139
+ logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
140
+ resolve({ exit_code, stdout, stderr });
141
+ });
142
+ });
69
143
  }
70
144
 
71
145
  async function agentExecFile(action, ...args) {
@@ -73,8 +147,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
73
147
 
74
148
  if (action === 'read') {
75
149
  const filePath = arg0;
76
- try {
77
- const stat = fs.statSync(filePath);
150
+ const stat = await fsp.stat(filePath).catch(() => null);
151
+ if (stat) {
78
152
  const cfg = getConfig ? getConfig() : {};
79
153
  const maxBytes = (cfg.max_file_size_kb || 512) * 1024;
80
154
  if (stat.size > maxBytes) {
@@ -82,16 +156,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
82
156
  logToolCall('read_file', { path: filePath }, false, 'error');
83
157
  return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
84
158
  }
85
- } catch {
86
- // file doesn't exist yet — readFileSync will report it
87
- }
88
- const approved = await permissionManager.askPermission('file', `Read ${filePath}`, 'read_file');
89
- if (!approved) {
90
- logToolCall('read_file', { path: filePath }, false, 'denied');
91
- return { error: 'Permission denied' };
92
159
  }
93
160
  try {
94
- const data = fs.readFileSync(filePath, 'utf8');
161
+ const data = await fsp.readFile(filePath, 'utf8');
95
162
  const lines = data.split('\n').length;
96
163
  if (lines > 10) {
97
164
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
@@ -99,7 +166,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
99
166
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
100
167
  }
101
168
  logToolCall('read_file', { path: filePath }, true, 'ok');
102
- return { content: data, path: filePath };
169
+ return { content: data, path: filePath, bytes: Buffer.byteLength(data, 'utf8') };
103
170
  } catch (error) {
104
171
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
105
172
  logToolCall('read_file', { path: filePath }, true, 'error');
@@ -118,9 +185,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
118
185
  return blocked;
119
186
  }
120
187
 
188
+ if (!isPathSafe(filePath)) {
189
+ logToolCall(tag, { path: filePath }, false, 'denied');
190
+ return _sandboxError(filePath);
191
+ }
192
+
121
193
  // Read existing content for diff display
122
194
  let existing = '';
123
- try { existing = fs.readFileSync(filePath, 'utf8'); } catch {}
195
+ try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
124
196
 
125
197
  // For append the final state is existing + new content
126
198
  const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
@@ -129,8 +201,10 @@ function createToolExecutor(permissionManager, ui, getConfig) {
129
201
  // collide with the live chat-history/status-bar redraw, so we route the
130
202
  // diff into the permission description instead (where it renders inside
131
203
  // the permission bubble and is safely truncated by MAX_DESC_LINES).
132
- const diffOutput = renderDiff(existing, finalContent, filePath);
133
- if (!_uiActive) process.stdout.write(diffOutput + '\n');
204
+ const diffOutput = _uiActive
205
+ ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
206
+ : renderDiff(existing, finalContent, filePath);
207
+ if (!_uiActive) writer.scrollback(diffOutput);
134
208
 
135
209
  // Dry-run: record the skipped op and return without writing
136
210
  if (_dryRun) {
@@ -140,7 +214,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
140
214
  return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
141
215
  }
142
216
 
143
- // Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
217
+ // Permission check — routes through TUI dialog in chat mode, interactiveSelect in non-TUI flows
144
218
  let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
145
219
  if (content) desc += ` (${content.length} chars)`;
146
220
  if (_uiActive) desc = `${desc}\n${diffOutput}`;
@@ -151,9 +225,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
151
225
  }
152
226
  try {
153
227
  const dir = path.dirname(filePath);
154
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
155
- if (action === 'write') fs.writeFileSync(filePath, content || '');
156
- else fs.appendFileSync(filePath, content || '');
228
+ if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
229
+ if (action === 'write') await fsp.writeFile(filePath, content || '');
230
+ else await fsp.appendFile(filePath, content || '');
157
231
  const verb = action === 'write' ? 'Wrote' : 'Appended to';
158
232
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
159
233
  logToolCall(tag, { path: filePath, content }, true, 'ok');
@@ -167,13 +241,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
167
241
 
168
242
  if (action === 'list_dir') {
169
243
  const dirPath = arg0;
170
- const approved = await permissionManager.askPermission('file', `List ${dirPath}`, 'list_dir');
171
- if (!approved) {
172
- logToolCall('list_dir', { path: dirPath }, false, 'denied');
173
- return { error: 'Permission denied' };
174
- }
175
244
  try {
176
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
245
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
177
246
  const items = entries.map((e) => {
178
247
  if (e.isSymbolicLink()) return `[L] ${e.name}`;
179
248
  if (e.isDirectory()) return `[D] ${e.name}`;
@@ -198,6 +267,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
198
267
  return blocked;
199
268
  }
200
269
 
270
+ if (!isPathSafe(filePath)) {
271
+ logToolCall('delete_file', { path: filePath }, false, 'denied');
272
+ return _sandboxError(filePath);
273
+ }
274
+
201
275
  _log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
202
276
 
203
277
  const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
@@ -206,7 +280,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
206
280
  return { error: 'Permission denied' };
207
281
  }
208
282
  try {
209
- fs.unlinkSync(filePath);
283
+ await fsp.unlink(filePath);
210
284
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
211
285
  logToolCall('delete_file', { path: filePath }, true, 'ok');
212
286
  return { status: 'ok', path: filePath };
@@ -219,13 +293,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
219
293
 
220
294
  if (action === 'make_dir') {
221
295
  const dirPath = arg0;
296
+ if (!isPathSafe(dirPath)) {
297
+ logToolCall('make_dir', { path: dirPath }, false, 'denied');
298
+ return _sandboxError(dirPath);
299
+ }
222
300
  const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
223
301
  if (!approved) {
224
302
  logToolCall('make_dir', { path: dirPath }, false, 'denied');
225
303
  return { error: 'Permission denied' };
226
304
  }
227
305
  try {
228
- fs.mkdirSync(dirPath, { recursive: true });
306
+ await fsp.mkdir(dirPath, { recursive: true });
229
307
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
230
308
  logToolCall('make_dir', { path: dirPath }, true, 'ok');
231
309
  return { status: 'ok', path: dirPath };
@@ -238,13 +316,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
238
316
 
239
317
  if (action === 'remove_dir') {
240
318
  const dirPath = arg0;
319
+ if (!isPathSafe(dirPath)) {
320
+ logToolCall('remove_dir', { path: dirPath }, false, 'denied');
321
+ return _sandboxError(dirPath);
322
+ }
241
323
  const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
242
324
  if (!approved) {
243
325
  logToolCall('remove_dir', { path: dirPath }, false, 'denied');
244
326
  return { error: 'Permission denied' };
245
327
  }
246
328
  try {
247
- fs.rmSync(dirPath, { recursive: true, force: true });
329
+ await fsp.rm(dirPath, { recursive: true, force: true });
248
330
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
249
331
  logToolCall('remove_dir', { path: dirPath }, true, 'ok');
250
332
  return { status: 'ok', path: dirPath };
@@ -265,6 +347,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
265
347
  return blocked;
266
348
  }
267
349
 
350
+ if (!isPathSafe(dst)) {
351
+ logToolCall('move_file', { src, dst }, false, 'denied');
352
+ return _sandboxError(dst);
353
+ }
354
+
268
355
  _log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
269
356
 
270
357
  const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
@@ -274,14 +361,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
274
361
  }
275
362
  try {
276
363
  const dstDir = path.dirname(dst);
277
- if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
364
+ if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
278
365
  try {
279
- fs.renameSync(src, dst);
366
+ await fsp.rename(src, dst);
280
367
  } catch (renameErr) {
281
368
  if (renameErr.code !== 'EXDEV') throw renameErr;
282
369
  // Cross-device rename not supported — copy then remove
283
- fs.cpSync(src, dst, { recursive: true });
284
- fs.rmSync(src, { recursive: true, force: true });
370
+ await fsp.cp(src, dst, { recursive: true });
371
+ await fsp.rm(src, { recursive: true, force: true });
285
372
  }
286
373
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
287
374
  logToolCall('move_file', { src, dst }, true, 'ok');
@@ -303,6 +390,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
303
390
  return blocked;
304
391
  }
305
392
 
393
+ if (!isPathSafe(dst)) {
394
+ logToolCall('copy_file', { src, dst }, false, 'denied');
395
+ return _sandboxError(dst);
396
+ }
397
+
306
398
  const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
307
399
  if (!approved) {
308
400
  logToolCall('copy_file', { src, dst }, false, 'denied');
@@ -310,8 +402,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
310
402
  }
311
403
  try {
312
404
  const dstDir = path.dirname(dst);
313
- if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
314
- fs.cpSync(src, dst, { recursive: true });
405
+ if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
406
+ await fsp.cp(src, dst, { recursive: true });
315
407
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Copied ${src} → ${dst}${RST}`);
316
408
  logToolCall('copy_file', { src, dst }, true, 'ok');
317
409
  return { status: 'ok', src, dst };
@@ -332,14 +424,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
332
424
  return { error: 'Permission denied' };
333
425
  }
334
426
  try {
335
- const data = fs.readFileSync(filePath, 'utf8');
427
+ const data = await fsp.readFile(filePath, 'utf8');
336
428
  const lines = data.split('\n');
337
429
  if (lineNum < 1 || lineNum > lines.length) {
338
430
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
339
431
  return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
340
432
  }
341
433
  lines[lineNum - 1] = newContent;
342
- fs.writeFileSync(filePath, lines.join('\n'));
434
+ await fsp.writeFile(filePath, lines.join('\n'));
343
435
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
344
436
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
345
437
  return { status: 'ok', path: filePath, line: lineNum };
@@ -353,13 +445,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
353
445
  if (action === 'search_in_file') {
354
446
  const filePath = arg0;
355
447
  const pattern = arg1;
356
- const approved = await permissionManager.askPermission('file', `Search in ${filePath}`, 'search_in_file');
357
- if (!approved) {
358
- logToolCall('search_in_file', { path: filePath, pattern }, false, 'denied');
359
- return { error: 'Permission denied' };
360
- }
361
448
  try {
362
- const data = fs.readFileSync(filePath, 'utf8');
449
+ const data = await fsp.readFile(filePath, 'utf8');
450
+ const guardErr = _checkRegexSafety(pattern, data);
451
+ if (guardErr) {
452
+ logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
453
+ return guardErr;
454
+ }
363
455
  const regex = new RegExp(pattern);
364
456
  const matches = data.split('\n')
365
457
  .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
@@ -385,13 +477,18 @@ function createToolExecutor(permissionManager, ui, getConfig) {
385
477
  return { error: 'Permission denied' };
386
478
  }
387
479
  try {
388
- const data = fs.readFileSync(filePath, 'utf8');
480
+ const data = await fsp.readFile(filePath, 'utf8');
481
+ const guardErr = _checkRegexSafety(searchStr, data);
482
+ if (guardErr) {
483
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
484
+ return guardErr;
485
+ }
389
486
  const safeFlags = flags.replace(/[^gimsuy]/g, '');
390
487
  const countFlags = safeFlags.includes('g') ? safeFlags : safeFlags + 'g';
391
488
  const count = (data.match(new RegExp(searchStr, countFlags)) || []).length;
392
489
  const regex = new RegExp(searchStr, safeFlags || undefined);
393
490
  const newData = data.replace(regex, replaceStr);
394
- fs.writeFileSync(filePath, newData);
491
+ await fsp.writeFile(filePath, newData);
395
492
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
396
493
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
397
494
  return { status: 'ok', path: filePath, count };
@@ -405,11 +502,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
405
502
  if (action === 'search_files') {
406
503
  const pattern = arg0;
407
504
  const searchDir = arg1 || '.';
408
- const approved = await permissionManager.askPermission('file', `Search files: ${pattern} in ${searchDir}`, 'search_files');
409
- if (!approved) {
410
- logToolCall('search_files', { pattern, dir: searchDir }, false, 'denied');
411
- return { error: 'Permission denied' };
412
- }
413
505
  try {
414
506
  let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
415
507
  regStr = regStr.replace(/\*\*/g, '\x00');
@@ -419,16 +511,16 @@ function createToolExecutor(permissionManager, ui, getConfig) {
419
511
  const regex = new RegExp(`^${regStr}$`);
420
512
  const matchName = !pattern.includes('/');
421
513
  const files = [];
422
- function walk(dir, rel) {
514
+ async function walk(dir, rel) {
423
515
  let entries;
424
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
516
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
425
517
  for (const entry of entries) {
426
518
  const relPath = rel ? `${rel}/${entry.name}` : entry.name;
427
519
  if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
428
- if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
520
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
429
521
  }
430
522
  }
431
- walk(searchDir, '');
523
+ await walk(searchDir, '');
432
524
  files.sort();
433
525
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
434
526
  logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
@@ -442,13 +534,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
442
534
 
443
535
  if (action === 'file_stat') {
444
536
  const filePath = arg0;
445
- const approved = await permissionManager.askPermission('file', `Stat ${filePath}`, 'file_stat');
446
- if (!approved) {
447
- logToolCall('file_stat', { path: filePath }, false, 'denied');
448
- return { error: 'Permission denied' };
449
- }
450
537
  try {
451
- const stat = fs.statSync(filePath);
538
+ const stat = await fsp.stat(filePath);
452
539
  const type = stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file';
453
540
  const size_kb = (stat.size / 1024).toFixed(2);
454
541
  const mode = '0o' + stat.mode.toString(8);
@@ -474,7 +561,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
474
561
  if (action === 'set_env') {
475
562
  const varName = arg0;
476
563
  const value = arg1 || '';
477
- const approved = await permissionManager.askPermission('shell', `Set env ${varName}=${value}`, 'set_env');
564
+ const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
478
565
  if (!approved) {
479
566
  logToolCall('set_env', { name: varName }, false, 'denied');
480
567
  return { error: 'Permission denied' };
@@ -499,7 +586,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
499
586
  fileName = 'download';
500
587
  }
501
588
  const outPath = path.join(process.cwd(), fileName);
502
- const approved = await permissionManager.askPermission('shell', `Download ${url}`, 'download');
589
+ const approved = await permissionManager.askPermission('net', `Download ${url}`, 'download');
503
590
  if (!approved) {
504
591
  logToolCall('download', { url }, false, 'denied');
505
592
  return { error: 'Permission denied' };
@@ -554,6 +641,10 @@ function createToolExecutor(permissionManager, ui, getConfig) {
554
641
  if (action === 'upload') {
555
642
  const filePath = arg0;
556
643
  const encodedContent = arg1 || '';
644
+ if (!isPathSafe(filePath)) {
645
+ logToolCall('upload', { path: filePath }, false, 'denied');
646
+ return _sandboxError(filePath);
647
+ }
557
648
  const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
558
649
  if (!approved) {
559
650
  logToolCall('upload', { path: filePath }, false, 'denied');
@@ -561,9 +652,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
561
652
  }
562
653
  try {
563
654
  const dir = path.dirname(filePath);
564
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
655
+ if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
565
656
  const buffer = Buffer.from(encodedContent.trim(), 'base64');
566
- fs.writeFileSync(filePath, buffer);
657
+ await fsp.writeFile(filePath, buffer);
567
658
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Uploaded ${buffer.length} bytes to ${filePath}${RST}`);
568
659
  logToolCall('upload', { path: filePath }, true, 'ok');
569
660
  return { status: 'ok', path: filePath, bytes: buffer.length };
@@ -574,69 +665,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
574
665
  }
575
666
  }
576
667
 
577
- function buildHttpResult(url, statusCode, body, raw) {
578
- // Strip HTML markup so the LLM receives readable text instead of raw HTML.
579
- // Reduces a typical 150k-char page to ~25-40k chars, cutting chunk count
580
- // from ~16 to ~3 and preventing context-overflow re-fetch loops.
581
- // Pass raw=true to skip stripping (e.g. when the agent needs to parse markup).
582
- const looksLikeHtml = !raw && (/^\s*<!doctype\s+html/i.test(body) || /^\s*<html[\s>]/i.test(body));
583
- const processedBody = looksLikeHtml
584
- ? body
585
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
586
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
587
- .replace(/<[^>]+>/g, ' ')
588
- .replace(/&nbsp;/gi, ' ')
589
- .replace(/&amp;/gi, '&')
590
- .replace(/&lt;/gi, '<')
591
- .replace(/&gt;/gi, '>')
592
- .replace(/&quot;/gi, '"')
593
- .replace(/&#x?[\da-f]+;/gi, ' ')
594
- .replace(/\s{2,}/g, ' ')
595
- .trim()
596
- : body;
597
-
598
- if (processedBody.length > HTTP_CHUNK_CHARS) {
599
- const chunks = [];
600
- for (let i = 0; i < processedBody.length; i += HTTP_CHUNK_CHARS) {
601
- chunks.push(processedBody.slice(i, i + HTTP_CHUNK_CHARS));
602
- }
603
- _httpChunkStore.set(url, { chunks: chunks.slice(1), total: chunks.length, delivered: 1 });
604
- return { status_code: statusCode, body: chunks[0], chunked: true, part: 1, total_parts: chunks.length, key: url };
605
- }
606
- _httpChunkStore.delete(url);
607
- return { status_code: statusCode, body: processedBody };
608
- }
609
-
610
- if (action === 'http_get_next') {
611
- const key = arg0;
612
- const store = _httpChunkStore.get(key);
613
- if (!store || store.chunks.length === 0) {
614
- _httpChunkStore.delete(key);
615
- return { key, body: '', part: null, total_parts: null, done: true };
616
- }
617
- const nextChunk = store.chunks[0];
618
- store.chunks = store.chunks.slice(1);
619
- store.delivered += 1;
620
- const done = store.chunks.length === 0;
621
- if (done) _httpChunkStore.delete(key);
622
- return { key, body: nextChunk, part: store.delivered, total_parts: store.total, done };
623
- }
624
-
625
668
  if (action === 'http_get') {
626
669
  const url = arg0;
627
- const rawHtml = arg1 === 'true';
628
670
  if (_dryRun) {
629
671
  _skippedOps.push({ category: 'net', symbol: '↓', desc: `GET ${url}` });
630
672
  logToolCall('http_get', { url }, false, 'dry-run');
631
673
  return { status: 'dry-run', message: 'dry-run: network call skipped' };
632
674
  }
633
- const approved = await permissionManager.askPermission('shell', `HTTP GET ${url}`, 'http_get');
675
+ const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
634
676
  if (!approved) {
635
677
  logToolCall('http_get', { url }, false, 'denied');
636
678
  return { error: 'Permission denied' };
637
679
  }
638
680
  const httpCfg = getConfig ? getConfig() : {};
639
681
  const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
682
+ const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
640
683
  return new Promise((resolve) => {
641
684
  function doGet(target, redirectsLeft) {
642
685
  const proto = target.startsWith('https') ? https : http;
@@ -645,24 +688,50 @@ function createToolExecutor(permissionManager, ui, getConfig) {
645
688
  res.resume();
646
689
  return doGet(res.headers.location, redirectsLeft - 1);
647
690
  }
648
- let data = '';
649
- res.setEncoding('utf8');
650
- res.on('data', (chunk) => { data += chunk; });
691
+ const bufs = [];
692
+ let totalBytes = 0;
693
+ let capped = false;
694
+ res.on('data', (chunk) => {
695
+ totalBytes += chunk.length;
696
+ if (!capped) {
697
+ if (totalBytes <= maxBytes) {
698
+ bufs.push(chunk);
699
+ } else {
700
+ const keep = maxBytes - (totalBytes - chunk.length);
701
+ if (keep > 0) bufs.push(chunk.slice(0, keep));
702
+ capped = true;
703
+ // Keep the connection draining so totalBytes reflects reality,
704
+ // but stop buffering further bytes.
705
+ }
706
+ }
707
+ });
651
708
  res.on('end', () => {
652
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${data.length} chars)${RST}`);
709
+ const kept = Buffer.concat(bufs);
710
+ const keptBytes = kept.length;
711
+ let body = kept.toString('utf8');
712
+ if (capped) {
713
+ const origKb = (totalBytes / 1024).toFixed(0);
714
+ const keptKb = (keptBytes / 1024).toFixed(0);
715
+ const droppedKb = ((totalBytes - keptBytes) / 1024).toFixed(0);
716
+ body += `\n\n[... truncated: original was ${origKb}KB, showing first ${keptKb}KB. The remaining ${droppedKb}KB was discarded. If you need the rest, narrow your request (e.g. fetch a specific subpage) rather than retrying this URL.]`;
717
+ }
718
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${totalBytes} bytes${capped ? `, truncated to ${keptBytes}` : ''})${RST}`);
653
719
  logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
654
- resolve(buildHttpResult(url, res.statusCode, data, rawHtml));
720
+ // `bytes` is the total transferred payload length (pre-cap);
721
+ // consumers that want to know the wire size without parsing
722
+ // the appended truncation note rely on this.
723
+ resolve({ status_code: res.statusCode, body, bytes: totalBytes });
655
724
  });
656
725
  });
657
726
  req.on('error', (err) => {
658
727
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
659
728
  logToolCall('http_get', { url: target }, true, 'error');
660
- resolve({ error: err.message });
729
+ resolve({ error: err.message, error_code: err.code });
661
730
  });
662
731
  req.setTimeout(reqTimeoutMs, () => {
663
732
  req.destroy();
664
733
  logToolCall('http_get', { url: target }, true, 'error');
665
- resolve({ error: 'Request timeout' });
734
+ resolve({ error: 'Request timeout', error_code: 'ETIMEDOUT' });
666
735
  });
667
736
  }
668
737
  doGet(url, 5);
@@ -671,7 +740,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
671
740
 
672
741
  if (action === 'ask_user') {
673
742
  const question = arg0;
674
- const approved = await permissionManager.askPermission('shell', `Ask user: ${question}`, 'ask_user');
743
+ const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
675
744
  if (!approved) {
676
745
  logToolCall('ask_user', { question }, false, 'denied');
677
746
  return { error: 'Permission denied' };
@@ -683,10 +752,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
683
752
  return { question, answer: selected || options[0] };
684
753
  }
685
754
  if (!process.stdout.isTTY || process.stdin.isRaw) {
686
- process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}\n`);
755
+ writer.scrollback(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}`);
687
756
  logToolCall('ask_user', { question }, true, 'ok');
688
757
  return { question, answer: 'y' };
689
758
  }
759
+ // audit: allowed — inline prompt without trailing newline; unreachable when TUI writer is active
760
+ // (process.stdin.isRaw is true while the TUI input field holds raw mode).
690
761
  process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${FG_GRAY}>${RST} `);
691
762
  const buf = Buffer.alloc(4096);
692
763
  let input = '';
@@ -706,17 +777,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
706
777
  if (action === 'store_memory') {
707
778
  const key = arg0;
708
779
  const value = arg1 || '';
709
- const approved = await permissionManager.askPermission('file', `Store memory: ${key}`, 'store_memory');
780
+ const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
710
781
  if (!approved) {
711
782
  logToolCall('store_memory', { key }, false, 'denied');
712
783
  return { error: 'Permission denied' };
713
784
  }
714
785
  try {
715
786
  let mem = {};
716
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
787
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
717
788
  mem[key] = value;
718
- fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
719
- fs.writeFileSync(MEMORY_PATH, JSON.stringify(mem, null, 2));
789
+ await fsp.mkdir(path.dirname(MEMORY_PATH), { recursive: true });
790
+ await fsp.writeFile(MEMORY_PATH, JSON.stringify(mem, null, 2));
720
791
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Stored memory: ${key}${RST}`);
721
792
  logToolCall('store_memory', { key }, true, 'ok');
722
793
  return { status: 'ok', key };
@@ -729,14 +800,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
729
800
 
730
801
  if (action === 'recall_memory') {
731
802
  const key = arg0;
732
- const approved = await permissionManager.askPermission('file', `Recall memory: ${key}`, 'recall_memory');
733
- if (!approved) {
734
- logToolCall('recall_memory', { key }, false, 'denied');
735
- return { error: 'Permission denied' };
736
- }
737
803
  try {
738
804
  let mem = {};
739
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
805
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
740
806
  const found = key in mem;
741
807
  const value = found ? mem[key] : null;
742
808
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Recalled memory: ${key}${RST}`);
@@ -750,14 +816,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
750
816
  }
751
817
 
752
818
  if (action === 'list_memories') {
753
- const approved = await permissionManager.askPermission('file', 'List memories', 'list_memories');
754
- if (!approved) {
755
- logToolCall('list_memories', {}, false, 'denied');
756
- return { error: 'Permission denied' };
757
- }
758
819
  try {
759
820
  let mem = {};
760
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
821
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
761
822
  const keys = Object.keys(mem);
762
823
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Listed ${keys.length} memory key(s)${RST}`);
763
824
  logToolCall('list_memories', {}, true, 'ok');
@@ -770,11 +831,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
770
831
  }
771
832
 
772
833
  if (action === 'system_info') {
773
- const approved = await permissionManager.askPermission('shell', 'System info', 'system_info');
774
- if (!approved) {
775
- logToolCall('system_info', {}, false, 'denied');
776
- return { error: 'Permission denied' };
777
- }
778
834
  const info = {
779
835
  platform: os.platform(),
780
836
  arch: os.arch(),
@@ -848,9 +904,7 @@ function mapInvokeToCall(toolName, params) {
848
904
  case 'upload':
849
905
  return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
850
906
  case 'http_get':
851
- return p.url ? ['http_get', p.url, p.raw || ''] : null;
852
- case 'http_get_next':
853
- return p.key ? ['http_get_next', p.key] : null;
907
+ return p.url ? ['http_get', p.url] : null;
854
908
  case 'ask_user':
855
909
  return p.question ? ['ask_user', p.question] : null;
856
910
  case 'store_memory':
@@ -872,7 +926,74 @@ function mapInvokeToCall(toolName, params) {
872
926
  }
873
927
  }
874
928
 
875
- function extractToolCalls(text) {
929
+ // Compile a regex twice — once with double quotes, once with single — from a
930
+ // template where `Q` stands for the quote char. Matches from both variants
931
+ // are returned in a single iterable.
932
+ function _matchDual(text, template) {
933
+ const results = [];
934
+ for (const q of ['"', "'"]) {
935
+ const re = new RegExp(template.replace(/Q/g, q), 'g');
936
+ for (const m of text.matchAll(re)) results.push(m);
937
+ }
938
+ return results;
939
+ }
940
+
941
+ // Models sometimes wrap the inline body of a single-value tool tag in a nested
942
+ // pseudo-tag, e.g. `<list_dir><path>/tmp/foo</path></list_dir>` instead of the
943
+ // documented `<list_dir>/tmp/foo</list_dir>`. When the body is exactly one
944
+ // wrapper element (no siblings, no surrounding text), unwrap it once so the
945
+ // parser recovers the intended value. Safe to call on any inline-content body
946
+ // — a plain path/command/URL won't match the regex and is returned as-is.
947
+ function _unwrapInnerTag(inner) {
948
+ if (inner == null) return inner;
949
+ const trimmed = String(inner).trim();
950
+ const m = trimmed.match(/^<(\w+)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/);
951
+ if (!m) return inner;
952
+ return m[2].trim();
953
+ }
954
+
955
+ // MiniMax-M2 tool-call XML repair. Some inference backends — notably mlx-lm
956
+ // on Apple Silicon clusters (see ml-explore/mlx-lm#1145) — strip the leading
957
+ // `<` (or `</`) from `<invoke>` and `<parameter>` tags when sampling this
958
+ // model, producing malformed output like:
959
+ //
960
+ // <minimax:tool_call>
961
+ // invoke name="get_weather"> <-- `<` missing
962
+ // parameter name="x">v</parameter>
963
+ // invoke> <-- `</` missing
964
+ // </minimax:tool_call>
965
+ //
966
+ // Conservative repair: anchor each rewrite to the start of a line so parameter
967
+ // values that happen to contain the substring `parameter>` or `invoke>` mid-
968
+ // line are not corrupted. Limitation: a parameter value whose content begins a
969
+ // new line with exactly `invoke>` or `parameter>` at column 0 will still be
970
+ // rewritten — accepted as unfixable without full XML parsing. Opt-in via
971
+ // `repair_malformed_tool_xml`; silent text mutation is dangerous when wrong.
972
+ function repairMinimaxMalformedXml(text) {
973
+ if (typeof text !== 'string' || !text) return text;
974
+ return text
975
+ .replace(/^(\s*)invoke(\s+name=)/gm, '$1<invoke$2')
976
+ .replace(/^(\s*)parameter(\s+name=)/gm, '$1<parameter$2')
977
+ .replace(/^(\s*)invoke>/gm, '$1</invoke>')
978
+ .replace(/^(\s*)parameter>/gm, '$1</parameter>');
979
+ }
980
+
981
+ /**
982
+ * Parse tool-call tags out of assistant text.
983
+ *
984
+ * @param {string} text Assistant reply text to scan.
985
+ * @param {object} [options]
986
+ * @param {boolean} [options.repairMalformedXml] Run the MiniMax XML repair
987
+ * pass before parsing.
988
+ * @param {string} [options.model] Active model name. Accepted but unused
989
+ * today — reserved for future per-model preprocess hooks and per-model
990
+ * format prioritization (e.g. preferring JSON over XML for models that
991
+ * emit both).
992
+ * @param {object} [options.config] Resolved config. Same rationale as
993
+ * `model`: accepted for forward-compat, not consumed here yet.
994
+ */
995
+ function extractToolCalls(text, options = {}) {
996
+ if (options.repairMalformedXml) text = repairMinimaxMalformedXml(text);
876
997
  const calls = [];
877
998
 
878
999
  // MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
@@ -902,6 +1023,48 @@ function extractToolCalls(text) {
902
1023
  }
903
1024
  }
904
1025
 
1026
+ // Qwen3-Coder / Qwen3.5 XML tool-call format. Distinct from MiniMax and
1027
+ // Hermes: the tool name lives on the opening tag as an `=name` suffix
1028
+ // rather than a `name="..."` attribute, and parameters use the same `=key`
1029
+ // shape:
1030
+ //
1031
+ // <function=write_file>
1032
+ // <parameter=path>a.json</parameter>
1033
+ // <parameter=content>{"k":1}</parameter>
1034
+ // </function>
1035
+ //
1036
+ // Values are kept as raw strings. The vLLM reference parser consults the
1037
+ // tool's JSON schema to decide per-parameter whether to string/int/
1038
+ // literal_eval a value; `mapInvokeToCall` has no schema, so schema-guided
1039
+ // decoding isn't available here. The existing string-typed tools match the
1040
+ // MiniMax/Hermes convention (content for write_file, command for shell,
1041
+ // etc.) arrive as raw strings, which is also the vLLM default when a param
1042
+ // is declared string-typed — the dominant case.
1043
+ //
1044
+ // TODO: if a tool ever takes an object/array param, revisit and port the
1045
+ // schema-guided typing logic from
1046
+ // https://github.com/vllm-project/vllm/tree/main/vllm/tool_parsers
1047
+ // (qwen3xml_tool_parser.py → find_tool_properties / repair_param_type).
1048
+ //
1049
+ // Limitation: the non-greedy `[\s\S]*?` anchors on the first `</parameter>`
1050
+ // inside the value. A parameter whose value legitimately contains the
1051
+ // literal substring `</parameter>` — possible for a code-writing tool
1052
+ // emitting docs about this very format — will be truncated. We accept
1053
+ // this rather than attempt balanced/escaped matching.
1054
+ const QWEN3_FN_BLOCK_RE = /<function=([^\s>]+)\s*>([\s\S]*?)<\/function>/g;
1055
+ const QWEN3_PARAM_RE = /<parameter=([^\s>]+)\s*>([\s\S]*?)<\/parameter>/g;
1056
+ for (const fnMatch of text.matchAll(QWEN3_FN_BLOCK_RE)) {
1057
+ const params = {};
1058
+ for (const pMatch of fnMatch[2].matchAll(QWEN3_PARAM_RE)) {
1059
+ let val = pMatch[2];
1060
+ if (val.startsWith('\n')) val = val.slice(1);
1061
+ if (val.endsWith('\n')) val = val.slice(0, -1);
1062
+ params[pMatch[1]] = val;
1063
+ }
1064
+ const call = mapInvokeToCall(fnMatch[1], params);
1065
+ if (call) calls.push(call);
1066
+ }
1067
+
905
1068
  // Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
906
1069
  // and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
907
1070
  //
@@ -935,10 +1098,39 @@ function extractToolCalls(text) {
935
1098
  let parsed = null;
936
1099
  try { parsed = JSON.parse(inner); } catch {}
937
1100
  if (!parsed) {
1101
+ // Walk the string tracking quote/escape state and brace depth. Slice
1102
+ // the first balanced {...} block we find. Falls back to lastIndexOf
1103
+ // if the walker can't lock onto a balanced pair.
938
1104
  const firstBrace = inner.indexOf('{');
939
- const lastBrace = inner.lastIndexOf('}');
940
- if (firstBrace !== -1 && lastBrace > firstBrace) {
941
- try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
1105
+ if (firstBrace !== -1) {
1106
+ let depth = 0;
1107
+ let inString = false;
1108
+ let escaped = false;
1109
+ let endIdx = -1;
1110
+ for (let i = firstBrace; i < inner.length; i++) {
1111
+ const ch = inner[i];
1112
+ if (inString) {
1113
+ if (escaped) { escaped = false; continue; }
1114
+ if (ch === '\\') { escaped = true; continue; }
1115
+ if (ch === '"') { inString = false; }
1116
+ continue;
1117
+ }
1118
+ if (ch === '"') { inString = true; continue; }
1119
+ if (ch === '{') depth++;
1120
+ else if (ch === '}') {
1121
+ depth--;
1122
+ if (depth === 0) { endIdx = i; break; }
1123
+ }
1124
+ }
1125
+ if (endIdx !== -1) {
1126
+ try { parsed = JSON.parse(inner.slice(firstBrace, endIdx + 1)); } catch {}
1127
+ }
1128
+ if (!parsed) {
1129
+ const lastBrace = inner.lastIndexOf('}');
1130
+ if (lastBrace > firstBrace) {
1131
+ try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
1132
+ }
1133
+ }
942
1134
  }
943
1135
  }
944
1136
  if (!parsed) continue;
@@ -964,113 +1156,120 @@ function extractToolCalls(text) {
964
1156
  }
965
1157
 
966
1158
  for (const match of text.matchAll(/<(?:shell|exec|run_command|run)>([\s\S]*?)<\/(?:shell|exec|run_command|run)>/g)) {
967
- calls.push(['shell', match[1].trim()]);
1159
+ calls.push(['shell', _unwrapInnerTag(match[1]).trim()]);
968
1160
  }
969
1161
 
970
1162
  for (const match of text.matchAll(/<read_file>([\s\S]*?)<\/read_file>/g)) {
971
- calls.push(['read', match[1].trim()]);
1163
+ calls.push(['read', _unwrapInnerTag(match[1]).trim()]);
972
1164
  }
973
1165
 
974
- for (const match of text.matchAll(/<read_file\s+path="([^"]+)"\s*\/?>/g)) {
1166
+ for (const match of _matchDual(text, '<read_file\\s+path=Q([^Q]+)Q\\s*\\/?>')) {
975
1167
  calls.push(['read', match[1]]);
976
1168
  }
977
1169
 
978
- for (const match of text.matchAll(/<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g)) {
1170
+ for (const match of _matchDual(text, '<write_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/write_file>')) {
979
1171
  calls.push(['write', match[1], match[2]]);
980
1172
  }
981
1173
 
982
- for (const match of text.matchAll(/<create_file\s+path="([^"]+)">([\s\S]*?)<\/create_file>/g)) {
1174
+ for (const match of _matchDual(text, '<create_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/create_file>')) {
983
1175
  calls.push(['write', match[1], match[2]]);
984
1176
  }
985
1177
 
986
- for (const match of text.matchAll(/<append_file\s+path="([^"]+)">([\s\S]*?)<\/append_file>/g)) {
1178
+ for (const match of _matchDual(text, '<append_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/append_file>')) {
987
1179
  calls.push(['append', match[1], match[2]]);
988
1180
  }
989
1181
 
990
1182
  for (const match of text.matchAll(/<list_dir>([\s\S]*?)<\/list_dir>/g)) {
991
- calls.push(['list_dir', match[1].trim()]);
1183
+ calls.push(['list_dir', _unwrapInnerTag(match[1]).trim()]);
992
1184
  }
993
1185
 
994
1186
  for (const match of text.matchAll(/<search_files>([\s\S]*?)<\/search_files>/g)) {
995
- calls.push(['search_files', match[1].trim(), '.']);
1187
+ calls.push(['search_files', _unwrapInnerTag(match[1]).trim(), '.']);
996
1188
  }
997
1189
 
998
- for (const match of text.matchAll(/<search_files\s+pattern="([^"]+)"(?:\s+dir="([^"]*)")?\s*(?:><\/search_files>|\/>)/g)) {
1190
+ for (const match of _matchDual(text, '<search_files\\s+pattern=Q([^Q]+)Q(?:\\s+dir=Q([^Q]*)Q)?\\s*(?:><\\/search_files>|\\/>)')) {
999
1191
  calls.push(['search_files', match[1], match[2] || '.']);
1000
1192
  }
1001
1193
 
1002
1194
  for (const match of text.matchAll(/<delete_file>([\s\S]*?)<\/delete_file>/g)) {
1003
- calls.push(['delete_file', match[1].trim()]);
1195
+ calls.push(['delete_file', _unwrapInnerTag(match[1]).trim()]);
1004
1196
  }
1005
1197
 
1006
1198
  for (const match of text.matchAll(/<make_dir>([\s\S]*?)<\/make_dir>/g)) {
1007
- calls.push(['make_dir', match[1].trim()]);
1199
+ calls.push(['make_dir', _unwrapInnerTag(match[1]).trim()]);
1008
1200
  }
1009
1201
 
1010
1202
  for (const match of text.matchAll(/<remove_dir>([\s\S]*?)<\/remove_dir>/g)) {
1011
- calls.push(['remove_dir', match[1].trim()]);
1203
+ calls.push(['remove_dir', _unwrapInnerTag(match[1]).trim()]);
1012
1204
  }
1013
1205
 
1014
1206
  for (const match of text.matchAll(/<get_env>([\s\S]*?)<\/get_env>/g)) {
1015
- calls.push(['get_env', match[1].trim()]);
1207
+ calls.push(['get_env', _unwrapInnerTag(match[1]).trim()]);
1016
1208
  }
1017
1209
 
1018
- for (const match of text.matchAll(/<set_env\s+name="([^"]+)"\s+value="([^"]*)"\s*(?:><\/set_env>|\/>)/g)) {
1210
+ for (const match of _matchDual(text, '<set_env\\s+name=Q([^Q]+)Q\\s+value=Q([^Q]*)Q\\s*(?:><\\/set_env>|\\/>)')) {
1019
1211
  calls.push(['set_env', match[1], match[2]]);
1020
1212
  }
1021
1213
 
1022
- for (const match of text.matchAll(/<move_file\s+src="([^"]+)"\s+dst="([^"]+)"\s*(?:><\/move_file>|\/>)/g)) {
1214
+ for (const match of _matchDual(text, '<move_file\\s+src=Q([^Q]+)Q\\s+dst=Q([^Q]+)Q\\s*(?:><\\/move_file>|\\/>)')) {
1023
1215
  calls.push(['move_file', match[1], match[2]]);
1024
1216
  }
1025
1217
 
1026
- for (const match of text.matchAll(/<copy_file\s+src="([^"]+)"\s+dst="([^"]+)"\s*(?:><\/copy_file>|\/>)/g)) {
1218
+ for (const match of _matchDual(text, '<copy_file\\s+src=Q([^Q]+)Q\\s+dst=Q([^Q]+)Q\\s*(?:><\\/copy_file>|\\/>)')) {
1027
1219
  calls.push(['copy_file', match[1], match[2]]);
1028
1220
  }
1029
1221
 
1030
- for (const match of text.matchAll(/<edit_file\s+path="([^"]+)"\s+line="(\d+)">([\s\S]*?)<\/edit_file>/g)) {
1222
+ for (const match of _matchDual(text, '<edit_file\\s+path=Q([^Q]+)Q\\s+line=Q(\\d+)Q>([\\s\\S]*?)<\\/edit_file>')) {
1031
1223
  calls.push(['edit_file', match[1], parseInt(match[2], 10), match[3]]);
1032
1224
  }
1033
1225
 
1034
- for (const match of text.matchAll(/<search_in_file\s+path="([^"]+)">([\s\S]*?)<\/search_in_file>/g)) {
1226
+ for (const match of _matchDual(text, '<search_in_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/search_in_file>')) {
1035
1227
  calls.push(['search_in_file', match[1], match[2].trim()]);
1036
1228
  }
1037
1229
 
1038
- for (const match of text.matchAll(/<replace_in_file\s+path="([^"]+)"\s+search="([^"]*)"\s+replace="([^"]*)">([\s\S]*?)<\/replace_in_file>/g)) {
1230
+ for (const match of _matchDual(text, '<replace_in_file\\s+path=Q([^Q]+)Q\\s+search=Q([^Q]*)Q\\s+replace=Q([^Q]*)Q>([\\s\\S]*?)<\\/replace_in_file>')) {
1039
1231
  calls.push(['replace_in_file', match[1], match[2], match[3], match[4].trim()]);
1040
1232
  }
1041
1233
 
1042
1234
  for (const match of text.matchAll(/<download>([\s\S]*?)<\/download>/g)) {
1043
- calls.push(['download', match[1].trim()]);
1235
+ calls.push(['download', _unwrapInnerTag(match[1]).trim()]);
1044
1236
  }
1045
1237
 
1046
- for (const match of text.matchAll(/<upload\s+path="([^"]+)">([\s\S]*?)<\/upload>/g)) {
1238
+ for (const match of _matchDual(text, '<upload\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/upload>')) {
1047
1239
  calls.push(['upload', match[1], match[2]]);
1048
1240
  }
1049
1241
 
1050
1242
  for (const match of text.matchAll(/<file_stat>([\s\S]*?)<\/file_stat>/g)) {
1051
- calls.push(['file_stat', match[1].trim()]);
1243
+ calls.push(['file_stat', _unwrapInnerTag(match[1]).trim()]);
1052
1244
  }
1053
1245
 
1054
1246
  for (const match of text.matchAll(/<http_get\b([^>]*?)(?:><\/http_get>|\/>)/g)) {
1055
1247
  const attrStr = match[1];
1056
- const urlMatch = attrStr.match(/url="([^"]+)"/);
1057
- const rawMatch = attrStr.match(/raw="([^"]+)"/);
1058
- if (urlMatch) calls.push(['http_get', urlMatch[1], rawMatch ? rawMatch[1] : '']);
1248
+ const urlMatch = attrStr.match(/url="([^"]+)"/) || attrStr.match(/url='([^']+)'/);
1249
+ if (urlMatch) calls.push(['http_get', urlMatch[1]]);
1059
1250
  }
1060
1251
 
1061
- for (const match of text.matchAll(/<http_get_next\s+key="([^"]+)"\s*(?:><\/http_get_next>|\/>)/g)) {
1062
- calls.push(['http_get_next', match[1]]);
1252
+ // Inline-content form: <http_get>URL</http_get>. Models mirror the style of
1253
+ // <list_dir>, <download>, etc. even though the system prompt advertises the
1254
+ // attribute form — accept both so the second tag in a multi-call response
1255
+ // isn't silently dropped. Also tolerate `<http_get>url="URL"</http_get>` where
1256
+ // the model put the attribute syntax in the body.
1257
+ for (const match of text.matchAll(/<http_get>([\s\S]*?)<\/http_get>/g)) {
1258
+ const inner = match[1].trim();
1259
+ if (!inner) continue;
1260
+ const urlAttr = inner.match(/url="([^"]+)"/) || inner.match(/url='([^']+)'/);
1261
+ calls.push(['http_get', urlAttr ? urlAttr[1] : _unwrapInnerTag(inner).trim()]);
1063
1262
  }
1064
1263
 
1065
- for (const match of text.matchAll(/<ask_user\s+question="([^"]+)"\s*(?:><\/ask_user>|\/>)/g)) {
1264
+ for (const match of _matchDual(text, '<ask_user\\s+question=Q([^Q]+)Q\\s*(?:><\\/ask_user>|\\/>)')) {
1066
1265
  calls.push(['ask_user', match[1]]);
1067
1266
  }
1068
1267
 
1069
- for (const match of text.matchAll(/<store_memory\s+key="([^"]+)">([\s\S]*?)<\/store_memory>/g)) {
1268
+ for (const match of _matchDual(text, '<store_memory\\s+key=Q([^Q]+)Q>([\\s\\S]*?)<\\/store_memory>')) {
1070
1269
  calls.push(['store_memory', match[1], match[2]]);
1071
1270
  }
1072
1271
 
1073
- for (const match of text.matchAll(/<recall_memory\s+key="([^"]+)"\s*(?:><\/recall_memory>|\/>)/g)) {
1272
+ for (const match of _matchDual(text, '<recall_memory\\s+key=Q([^Q]+)Q\\s*(?:><\\/recall_memory>|\\/>)')) {
1074
1273
  calls.push(['recall_memory', match[1]]);
1075
1274
  }
1076
1275
 
@@ -1085,10 +1284,31 @@ function extractToolCalls(text) {
1085
1284
  return calls;
1086
1285
  }
1087
1286
 
1287
+ // Transform a TOOL_SPECS-shaped object into an OpenAI-format `tools` array
1288
+ // suitable for the `tools` field of a chat/completions request. Pure: no
1289
+ // filtering, no caching, no validation. Insertion order is preserved.
1290
+ function buildToolsSchema(toolSpecs) {
1291
+ const tools = [];
1292
+ for (const [name, spec] of Object.entries(toolSpecs)) {
1293
+ tools.push({
1294
+ type: 'function',
1295
+ function: {
1296
+ name,
1297
+ description: spec.description,
1298
+ parameters: spec.parameters,
1299
+ },
1300
+ });
1301
+ }
1302
+ return tools;
1303
+ }
1304
+
1088
1305
  module.exports = {
1306
+ buildToolsSchema,
1089
1307
  createToolExecutor,
1090
1308
  extractToolCalls,
1091
1309
  getSkippedOps,
1310
+ isUIActive,
1092
1311
  mapInvokeToCall,
1312
+ repairMinimaxMalformedXml,
1093
1313
  setUIActive,
1094
1314
  };