@semalt-ai/code 1.8.4 → 1.8.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/lib/tools.js CHANGED
@@ -9,6 +9,7 @@ const path = require('path');
9
9
  const { spawn } = require('child_process');
10
10
 
11
11
  const { logToolCall } = require('./audit');
12
+ const { spawnWithGroup, killTreeEscalating } = require('./proc');
12
13
  const writer = require('./ui/writer');
13
14
 
14
15
  const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
@@ -89,7 +90,130 @@ function createToolExecutor(permissionManager, ui, getConfig) {
89
90
  return options.length >= 2 ? options : [];
90
91
  }
91
92
 
92
- async function agentExecShell(command) {
93
+ // Build the permission descriptor for a [action, ...args] call tuple.
94
+ // Returns null when no permission gate is needed — read-only ops, dry-run
95
+ // skips, and --readonly blocks short-circuit through the executor's own
96
+ // error path. When non-null the caller (the agent loop) feeds the three
97
+ // fields straight into permissionManager.askPermission.
98
+ //
99
+ // Side effects, intentionally hosted here so they fire pre-picker:
100
+ // - write/append: render the file diff. In CLI mode the diff is
101
+ // emitted to scrollback; in TUI mode it's appended to `description`
102
+ // so it renders inside the picker bubble.
103
+ // - delete_file / move_file: emit a CLI warning line above the picker.
104
+ // Centralizing these means the executor body stays purely about the
105
+ // operation itself.
106
+ async function describePermission(call) {
107
+ if (!Array.isArray(call) || call.length === 0) return null;
108
+ const [action, ...args] = call;
109
+
110
+ // Dry-run skips the gate for every tool whose executor has a dry-run
111
+ // early return — asking the user to authorize an op that won't run is
112
+ // confusing UX. write/append are handled inside their own case so the
113
+ // diff still renders before the skip. Tools without dry-run support
114
+ // (delete_file, make_dir, etc.) fall through and get gated normally.
115
+ if (_dryRun && (action === 'shell' || action === 'exec' || action === 'download' || action === 'http_get')) {
116
+ return null;
117
+ }
118
+
119
+ // --readonly blocks the op deterministically; no point prompting first.
120
+ // The executor's own readonlyBlock() check produces the user-facing
121
+ // error message on the next dispatch step.
122
+ const READONLY_TAG = {
123
+ write: 'write_file',
124
+ append: 'append_file',
125
+ delete_file: 'delete_file',
126
+ move_file: 'move_file',
127
+ copy_file: 'copy_file',
128
+ };
129
+ const roTag = READONLY_TAG[action];
130
+ if (roTag && permissionManager.readonlyBlock(roTag)) return null;
131
+
132
+ switch (action) {
133
+ case 'shell':
134
+ case 'exec':
135
+ return { actionType: 'shell', description: args[0] || '', tag: 'exec' };
136
+
137
+ case 'write':
138
+ case 'append': {
139
+ const filePath = args[0];
140
+ const content = args[1];
141
+ const tag = action === 'write' ? 'write_file' : 'append_file';
142
+
143
+ let existing = '';
144
+ try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
145
+ const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
146
+ const diffOutput = _uiActive
147
+ ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
148
+ : renderDiff(existing, finalContent, filePath);
149
+ if (!_uiActive) writer.scrollback(diffOutput);
150
+
151
+ // Dry-run renders the diff (above) but skips the picker — the
152
+ // executor's dry-run early return reports the skip.
153
+ if (_dryRun) return null;
154
+
155
+ let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
156
+ if (content) desc += ` (${content.length} chars)`;
157
+ if (_uiActive) desc = `${desc}\n${diffOutput}`;
158
+ return { actionType: 'file', description: desc, tag };
159
+ }
160
+
161
+ case 'delete_file': {
162
+ const filePath = args[0];
163
+ _log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
164
+ return { actionType: 'file', description: `Delete ${filePath}`, tag: 'delete_file' };
165
+ }
166
+
167
+ case 'make_dir':
168
+ return { actionType: 'file', description: `Create directory ${args[0]}`, tag: 'make_dir' };
169
+
170
+ case 'remove_dir':
171
+ return { actionType: 'file', description: `Remove directory ${args[0]}`, tag: 'remove_dir' };
172
+
173
+ case 'move_file': {
174
+ const src = args[0];
175
+ const dst = args[1];
176
+ _log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
177
+ return { actionType: 'file', description: `Move ${src} to ${dst}`, tag: 'move_file' };
178
+ }
179
+
180
+ case 'copy_file':
181
+ return { actionType: 'file', description: `Copy ${args[0]} to ${args[1]}`, tag: 'copy_file' };
182
+
183
+ case 'edit_file':
184
+ return { actionType: 'file', description: `Edit line ${args[1]} in ${args[0]}`, tag: 'edit_file' };
185
+
186
+ case 'replace_in_file':
187
+ return { actionType: 'file', description: `Replace in ${args[0]}`, tag: 'replace_in_file' };
188
+
189
+ case 'set_env':
190
+ return { actionType: 'env', description: `Set env ${args[0]}=${args[1] || ''}`, tag: 'set_env' };
191
+
192
+ case 'download':
193
+ return { actionType: 'net', description: `Download ${args[0]}`, tag: 'download' };
194
+
195
+ case 'upload':
196
+ return { actionType: 'file', description: `Upload to ${args[0]}`, tag: 'upload' };
197
+
198
+ case 'http_get':
199
+ return { actionType: 'net', description: `HTTP GET ${args[0]}`, tag: 'http_get' };
200
+
201
+ // ask_user is a real gate — "do you want me to ask the user this
202
+ // question?" — separate from the question prompt itself (which is
203
+ // captureSelect or stdin further down in the executor). Lifted here
204
+ // so the activity bubble doesn't pre-date grant.
205
+ case 'ask_user':
206
+ return { actionType: 'user', description: `Ask user: ${args[0]}`, tag: 'ask_user' };
207
+
208
+ case 'store_memory':
209
+ return { actionType: 'memory', description: `Store memory: ${args[0]}`, tag: 'store_memory' };
210
+
211
+ default:
212
+ return null;
213
+ }
214
+ }
215
+
216
+ async function agentExecShell(command, options = {}) {
93
217
  if (_dryRun) {
94
218
  _log(` ${FG_DARK}[dry-run] $ ${command}${RST}`);
95
219
  _skippedOps.push({ category: 'cmd', symbol: '▶', desc: command });
@@ -97,56 +221,102 @@ function createToolExecutor(permissionManager, ui, getConfig) {
97
221
  return { exit_code: 0, stdout: '', stderr: 'dry-run: command skipped' };
98
222
  }
99
223
 
100
- const approved = await permissionManager.askPermission('shell', command, 'exec');
101
- if (!approved) {
102
- logToolCall('exec', { command }, false, 'denied');
103
- return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
104
- }
105
-
106
224
  const cfg = getConfig ? getConfig() : {};
107
225
  const timeout = cfg.command_timeout_ms || 30000;
226
+ const { signal } = options;
108
227
 
109
228
  return new Promise((resolve) => {
110
229
  let child;
111
230
  try {
112
- child = spawn(command, { shell: true });
231
+ // spawnWithGroup gives us a process-group leader on POSIX so
232
+ // killTreeEscalating can reach descendants via -pid. With shell:true
233
+ // a plain child.kill targets only the sh wrapper, leaving the real
234
+ // workload (find /, pipelines, etc.) running as orphans.
235
+ child = spawnWithGroup(spawn, command, [], { shell: true });
113
236
  } catch (error) {
114
237
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
115
238
  logToolCall('exec', { command }, true, 'error');
116
239
  return resolve({ exit_code: -1, stdout: '', stderr: error.message });
117
240
  }
241
+ const startedAt = Date.now();
118
242
  let stdout = '';
119
243
  let stderr = '';
120
244
  let killed = false;
245
+ let abortedByUser = false;
246
+
121
247
  const timer = setTimeout(() => {
122
248
  killed = true;
123
- try { child.kill('SIGTERM'); } catch {}
249
+ killTreeEscalating(child);
124
250
  }, timeout);
251
+
252
+ let onAbort = null;
253
+ const detachAbort = () => {
254
+ if (onAbort && signal) {
255
+ signal.removeEventListener('abort', onAbort);
256
+ onAbort = null;
257
+ }
258
+ };
259
+ if (signal) {
260
+ if (signal.aborted) {
261
+ abortedByUser = true;
262
+ killTreeEscalating(child);
263
+ } else {
264
+ onAbort = () => {
265
+ abortedByUser = true;
266
+ killTreeEscalating(child);
267
+ };
268
+ signal.addEventListener('abort', onAbort, { once: true });
269
+ }
270
+ }
271
+
125
272
  child.stdout.setEncoding('utf8');
126
273
  child.stderr.setEncoding('utf8');
127
274
  child.stdout.on('data', (c) => { stdout += c; });
128
275
  child.stderr.on('data', (c) => { stderr += c; });
129
276
  child.on('error', (error) => {
130
277
  clearTimeout(timer);
278
+ detachAbort();
131
279
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
132
280
  logToolCall('exec', { command }, true, 'error');
133
281
  resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
134
282
  });
135
- child.on('close', (code, signal) => {
283
+ child.on('close', (code, sigName) => {
136
284
  clearTimeout(timer);
285
+ detachAbort();
286
+ if (abortedByUser) {
287
+ const elapsed_s = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
288
+ const note = `[user interrupted after ${elapsed_s}s]`;
289
+ stderr += (stderr ? '\n' : '') + note;
290
+ logToolCall('exec', { command }, true, 'aborted');
291
+ resolve({ exit_code: -1, stdout, stderr, aborted: true, elapsed_s });
292
+ return;
293
+ }
137
294
  if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
138
- const exit_code = killed ? -1 : (code != null ? code : (signal ? -1 : 0));
295
+ const exit_code = killed ? -1 : (code != null ? code : (sigName ? -1 : 0));
139
296
  logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
140
297
  resolve({ exit_code, stdout, stderr });
141
298
  });
142
299
  });
143
300
  }
144
301
 
145
- async function agentExecFile(action, ...args) {
302
+ async function agentExecFile(action, ...rest) {
303
+ // The trailing arg may be an options object `{ signal }`. Detect and peel
304
+ // it off so positional args line up with the existing per-action branches.
305
+ // All real positional args are strings or numbers, so a plain object at
306
+ // the tail is unambiguously options.
307
+ let signal = null;
308
+ let args = rest;
309
+ const last = rest[rest.length - 1];
310
+ if (last && typeof last === 'object' && !Array.isArray(last)
311
+ && Object.getPrototypeOf(last) === Object.prototype) {
312
+ signal = last.signal || null;
313
+ args = rest.slice(0, -1);
314
+ }
146
315
  const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
147
316
 
148
317
  if (action === 'read') {
149
318
  const filePath = arg0;
319
+ const startedAt = Date.now();
150
320
  const stat = await fsp.stat(filePath).catch(() => null);
151
321
  if (stat) {
152
322
  const cfg = getConfig ? getConfig() : {};
@@ -157,8 +327,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
157
327
  return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
158
328
  }
159
329
  }
330
+ if (signal && signal.aborted) {
331
+ logToolCall('read_file', { path: filePath }, true, 'aborted');
332
+ return { aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) };
333
+ }
160
334
  try {
161
- const data = await fsp.readFile(filePath, 'utf8');
335
+ const data = await fsp.readFile(filePath, { encoding: 'utf8', signal: signal || undefined });
162
336
  const lines = data.split('\n').length;
163
337
  if (lines > 10) {
164
338
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
@@ -168,6 +342,10 @@ function createToolExecutor(permissionManager, ui, getConfig) {
168
342
  logToolCall('read_file', { path: filePath }, true, 'ok');
169
343
  return { content: data, path: filePath, bytes: Buffer.byteLength(data, 'utf8') };
170
344
  } catch (error) {
345
+ if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
346
+ logToolCall('read_file', { path: filePath }, true, 'aborted');
347
+ return { aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) };
348
+ }
171
349
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
172
350
  logToolCall('read_file', { path: filePath }, true, 'error');
173
351
  return { error: error.message };
@@ -190,23 +368,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
190
368
  return _sandboxError(filePath);
191
369
  }
192
370
 
193
- // Read existing content for diff display
194
- let existing = '';
195
- try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
196
-
197
- // For append the final state is existing + new content
198
- const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
199
-
200
- // In CLI mode, print the diff inline. In TUI mode, direct stdout writes
201
- // collide with the live chat-history/status-bar redraw, so we route the
202
- // diff into the permission description instead (where it renders inside
203
- // the permission bubble and is safely truncated by MAX_DESC_LINES).
204
- const diffOutput = _uiActive
205
- ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
206
- : renderDiff(existing, finalContent, filePath);
207
- if (!_uiActive) writer.scrollback(diffOutput);
208
-
209
- // Dry-run: record the skipped op and return without writing
371
+ // Dry-run: record the skipped op and return without writing. The diff
372
+ // was already rendered in describePermission ahead of this dispatch.
210
373
  if (_dryRun) {
211
374
  const verb = action === 'write' ? 'write' : 'append';
212
375
  _skippedOps.push({ category: 'file', symbol: '✎', desc: `${verb} ${filePath}` });
@@ -214,15 +377,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
214
377
  return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
215
378
  }
216
379
 
217
- // Permission check — routes through TUI dialog in chat mode, interactiveSelect in non-TUI flows
218
- let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
219
- if (content) desc += ` (${content.length} chars)`;
220
- if (_uiActive) desc = `${desc}\n${diffOutput}`;
221
- const approved = await permissionManager.askPermission('file', desc, tag);
222
- if (!approved) {
223
- logToolCall(tag, { path: filePath, content }, false, 'denied');
224
- return { error: 'Permission denied' };
225
- }
226
380
  try {
227
381
  const dir = path.dirname(filePath);
228
382
  if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
@@ -272,13 +426,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
272
426
  return _sandboxError(filePath);
273
427
  }
274
428
 
275
- _log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
276
-
277
- const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
278
- if (!approved) {
279
- logToolCall('delete_file', { path: filePath }, false, 'denied');
280
- return { error: 'Permission denied' };
281
- }
282
429
  try {
283
430
  await fsp.unlink(filePath);
284
431
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
@@ -297,11 +444,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
297
444
  logToolCall('make_dir', { path: dirPath }, false, 'denied');
298
445
  return _sandboxError(dirPath);
299
446
  }
300
- const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
301
- if (!approved) {
302
- logToolCall('make_dir', { path: dirPath }, false, 'denied');
303
- return { error: 'Permission denied' };
304
- }
305
447
  try {
306
448
  await fsp.mkdir(dirPath, { recursive: true });
307
449
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
@@ -320,11 +462,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
320
462
  logToolCall('remove_dir', { path: dirPath }, false, 'denied');
321
463
  return _sandboxError(dirPath);
322
464
  }
323
- const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
324
- if (!approved) {
325
- logToolCall('remove_dir', { path: dirPath }, false, 'denied');
326
- return { error: 'Permission denied' };
327
- }
328
465
  try {
329
466
  await fsp.rm(dirPath, { recursive: true, force: true });
330
467
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
@@ -352,13 +489,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
352
489
  return _sandboxError(dst);
353
490
  }
354
491
 
355
- _log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
356
-
357
- const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
358
- if (!approved) {
359
- logToolCall('move_file', { src, dst }, false, 'denied');
360
- return { error: 'Permission denied' };
361
- }
362
492
  try {
363
493
  const dstDir = path.dirname(dst);
364
494
  if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
@@ -395,11 +525,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
395
525
  return _sandboxError(dst);
396
526
  }
397
527
 
398
- const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
399
- if (!approved) {
400
- logToolCall('copy_file', { src, dst }, false, 'denied');
401
- return { error: 'Permission denied' };
402
- }
403
528
  try {
404
529
  const dstDir = path.dirname(dst);
405
530
  if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
@@ -418,11 +543,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
418
543
  const filePath = arg0;
419
544
  const lineNum = arg1;
420
545
  const newContent = arg2;
421
- const approved = await permissionManager.askPermission('file', `Edit line ${lineNum} in ${filePath}`, 'edit_file');
422
- if (!approved) {
423
- logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
424
- return { error: 'Permission denied' };
425
- }
426
546
  try {
427
547
  const data = await fsp.readFile(filePath, 'utf8');
428
548
  const lines = data.split('\n');
@@ -471,11 +591,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
471
591
  const searchStr = arg1;
472
592
  const replaceStr = arg2;
473
593
  const flags = arg3 || '';
474
- const approved = await permissionManager.askPermission('file', `Replace in ${filePath}`, 'replace_in_file');
475
- if (!approved) {
476
- logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
477
- return { error: 'Permission denied' };
478
- }
479
594
  try {
480
595
  const data = await fsp.readFile(filePath, 'utf8');
481
596
  const guardErr = _checkRegexSafety(searchStr, data);
@@ -502,6 +617,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
502
617
  if (action === 'search_files') {
503
618
  const pattern = arg0;
504
619
  const searchDir = arg1 || '.';
620
+ const startedAt = Date.now();
505
621
  try {
506
622
  let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
507
623
  regStr = regStr.replace(/\*\*/g, '\x00');
@@ -512,15 +628,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
512
628
  const matchName = !pattern.includes('/');
513
629
  const files = [];
514
630
  async function walk(dir, rel) {
631
+ if (signal && signal.aborted) return;
515
632
  let entries;
516
633
  try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
517
634
  for (const entry of entries) {
635
+ if (signal && signal.aborted) return;
518
636
  const relPath = rel ? `${rel}/${entry.name}` : entry.name;
519
637
  if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
520
638
  if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
521
639
  }
522
640
  }
523
641
  await walk(searchDir, '');
642
+ if (signal && signal.aborted) {
643
+ logToolCall('search_files', { pattern, dir: searchDir }, true, 'aborted');
644
+ return { aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) };
645
+ }
524
646
  files.sort();
525
647
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
526
648
  logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
@@ -561,11 +683,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
561
683
  if (action === 'set_env') {
562
684
  const varName = arg0;
563
685
  const value = arg1 || '';
564
- const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
565
- if (!approved) {
566
- logToolCall('set_env', { name: varName }, false, 'denied');
567
- return { error: 'Permission denied' };
568
- }
569
686
  process.env[varName] = value;
570
687
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Set env ${varName}${RST}`);
571
688
  logToolCall('set_env', { name: varName }, true, 'ok');
@@ -586,12 +703,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
586
703
  fileName = 'download';
587
704
  }
588
705
  const outPath = path.join(process.cwd(), fileName);
589
- const approved = await permissionManager.askPermission('net', `Download ${url}`, 'download');
590
- if (!approved) {
591
- logToolCall('download', { url }, false, 'denied');
592
- return { error: 'Permission denied' };
593
- }
706
+ const startedAt = Date.now();
594
707
  return new Promise((resolve) => {
708
+ let abortedByUser = false;
709
+ let onAbort = null;
710
+ let activeReq = null;
711
+ let activeFile = null;
712
+ const detachAbort = () => {
713
+ if (onAbort && signal) {
714
+ try { signal.removeEventListener('abort', onAbort); } catch {}
715
+ onAbort = null;
716
+ }
717
+ };
718
+ const finishAborted = () => {
719
+ fs.unlink(outPath, () => {});
720
+ logToolCall('download', { url }, true, 'aborted');
721
+ resolve({ aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) });
722
+ };
723
+ if (signal) {
724
+ if (signal.aborted) {
725
+ abortedByUser = true;
726
+ finishAborted();
727
+ return;
728
+ }
729
+ onAbort = () => {
730
+ abortedByUser = true;
731
+ try { if (activeReq) activeReq.destroy(new Error('Aborted')); } catch {}
732
+ try { if (activeFile) activeFile.destroy(); } catch {}
733
+ };
734
+ signal.addEventListener('abort', onAbort, { once: true });
735
+ }
736
+
595
737
  function doDownload(target, redirectsLeft) {
596
738
  const proto = target.startsWith('https') ? https : http;
597
739
  const req = proto.get(target, (res) => {
@@ -602,27 +744,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
602
744
  if (res.statusCode >= 400) {
603
745
  res.resume();
604
746
  const msg = `HTTP ${res.statusCode}`;
747
+ detachAbort();
605
748
  _log(` ${FG_RED}✗ ${msg}${RST}`);
606
749
  logToolCall('download', { url }, true, 'error');
607
750
  return resolve({ error: msg });
608
751
  }
609
752
  const file = fs.createWriteStream(outPath);
753
+ activeFile = file;
610
754
  res.pipe(file);
611
755
  file.on('finish', () => {
612
756
  file.close();
757
+ detachAbort();
613
758
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
614
759
  logToolCall('download', { url }, true, 'ok');
615
760
  resolve({ status: 'ok', path: outPath });
616
761
  });
617
762
  file.on('error', (err) => {
763
+ if (abortedByUser) {
764
+ detachAbort();
765
+ finishAborted();
766
+ return;
767
+ }
618
768
  fs.unlink(outPath, () => {});
769
+ detachAbort();
619
770
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
620
771
  logToolCall('download', { url }, true, 'error');
621
772
  resolve({ error: err.message });
622
773
  });
623
774
  });
775
+ activeReq = req;
624
776
  req.on('error', (err) => {
777
+ if (abortedByUser) {
778
+ detachAbort();
779
+ finishAborted();
780
+ return;
781
+ }
625
782
  fs.unlink(outPath, () => {});
783
+ detachAbort();
626
784
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
627
785
  logToolCall('download', { url }, true, 'error');
628
786
  resolve({ error: err.message });
@@ -630,6 +788,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
630
788
  req.setTimeout(120000, () => {
631
789
  req.destroy();
632
790
  fs.unlink(outPath, () => {});
791
+ detachAbort();
633
792
  logToolCall('download', { url }, true, 'error');
634
793
  resolve({ error: 'Request timeout' });
635
794
  });
@@ -645,11 +804,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
645
804
  logToolCall('upload', { path: filePath }, false, 'denied');
646
805
  return _sandboxError(filePath);
647
806
  }
648
- const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
649
- if (!approved) {
650
- logToolCall('upload', { path: filePath }, false, 'denied');
651
- return { error: 'Permission denied' };
652
- }
653
807
  try {
654
808
  const dir = path.dirname(filePath);
655
809
  if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
@@ -672,15 +826,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
672
826
  logToolCall('http_get', { url }, false, 'dry-run');
673
827
  return { status: 'dry-run', message: 'dry-run: network call skipped' };
674
828
  }
675
- const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
676
- if (!approved) {
677
- logToolCall('http_get', { url }, false, 'denied');
678
- return { error: 'Permission denied' };
679
- }
680
829
  const httpCfg = getConfig ? getConfig() : {};
681
830
  const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
682
831
  const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
832
+ const startedAt = Date.now();
683
833
  return new Promise((resolve) => {
834
+ let abortedByUser = false;
835
+ let onAbort = null;
836
+ let activeReq = null;
837
+ const detachAbort = () => {
838
+ if (onAbort && signal) {
839
+ try { signal.removeEventListener('abort', onAbort); } catch {}
840
+ onAbort = null;
841
+ }
842
+ };
843
+ const finishAborted = () => {
844
+ logToolCall('http_get', { url }, true, 'aborted');
845
+ resolve({ aborted: true, elapsed_s: Math.max(0, Math.round((Date.now() - startedAt) / 1000)) });
846
+ };
847
+ if (signal) {
848
+ if (signal.aborted) {
849
+ abortedByUser = true;
850
+ finishAborted();
851
+ return;
852
+ }
853
+ onAbort = () => {
854
+ abortedByUser = true;
855
+ try { if (activeReq) activeReq.destroy(new Error('Aborted')); } catch {}
856
+ };
857
+ signal.addEventListener('abort', onAbort, { once: true });
858
+ }
859
+
684
860
  function doGet(target, redirectsLeft) {
685
861
  const proto = target.startsWith('https') ? https : http;
686
862
  const req = proto.get(target, (res) => {
@@ -706,6 +882,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
706
882
  }
707
883
  });
708
884
  res.on('end', () => {
885
+ if (abortedByUser) return;
886
+ detachAbort();
709
887
  const kept = Buffer.concat(bufs);
710
888
  const keptBytes = kept.length;
711
889
  let body = kept.toString('utf8');
@@ -723,13 +901,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
723
901
  resolve({ status_code: res.statusCode, body, bytes: totalBytes });
724
902
  });
725
903
  });
904
+ activeReq = req;
726
905
  req.on('error', (err) => {
906
+ if (abortedByUser) {
907
+ detachAbort();
908
+ finishAborted();
909
+ return;
910
+ }
911
+ detachAbort();
727
912
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
728
913
  logToolCall('http_get', { url: target }, true, 'error');
729
914
  resolve({ error: err.message, error_code: err.code });
730
915
  });
731
916
  req.setTimeout(reqTimeoutMs, () => {
732
917
  req.destroy();
918
+ detachAbort();
733
919
  logToolCall('http_get', { url: target }, true, 'error');
734
920
  resolve({ error: 'Request timeout', error_code: 'ETIMEDOUT' });
735
921
  });
@@ -740,11 +926,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
740
926
 
741
927
  if (action === 'ask_user') {
742
928
  const question = arg0;
743
- const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
744
- if (!approved) {
745
- logToolCall('ask_user', { question }, false, 'denied');
746
- return { error: 'Permission denied' };
747
- }
748
929
  const options = _parseNumberedOptions(question);
749
930
  if (options.length >= 2) {
750
931
  const selected = await permissionManager.captureSelect({ options });
@@ -777,11 +958,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
777
958
  if (action === 'store_memory') {
778
959
  const key = arg0;
779
960
  const value = arg1 || '';
780
- const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
781
- if (!approved) {
782
- logToolCall('store_memory', { key }, false, 'denied');
783
- return { error: 'Permission denied' };
784
- }
785
961
  try {
786
962
  let mem = {};
787
963
  try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
@@ -853,6 +1029,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
853
1029
  return {
854
1030
  agentExecFile,
855
1031
  agentExecShell,
1032
+ describePermission,
856
1033
  };
857
1034
  }
858
1035
 
@@ -872,7 +1049,7 @@ function mapInvokeToCall(toolName, params) {
872
1049
  case 'delete_file':
873
1050
  return p.path ? ['delete_file', p.path] : null;
874
1051
  case 'list_dir':
875
- return ['list_dir', p.path || p.dir || '.'];
1052
+ return ['list_dir', p.path || '.'];
876
1053
  case 'make_dir':
877
1054
  return p.path ? ['make_dir', p.path] : null;
878
1055
  case 'remove_dir':
@@ -884,7 +1061,7 @@ function mapInvokeToCall(toolName, params) {
884
1061
  case 'file_stat':
885
1062
  return p.path ? ['file_stat', p.path] : null;
886
1063
  case 'search_files':
887
- return ['search_files', p.pattern || p.glob || '*', p.dir || '.'];
1064
+ return ['search_files', p.pattern || '*', p.dir || '.'];
888
1065
  case 'search_in_file':
889
1066
  return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
890
1067
  case 'replace_in_file':
@@ -917,9 +1094,6 @@ function mapInvokeToCall(toolName, params) {
917
1094
  return ['system_info'];
918
1095
  case 'exec':
919
1096
  case 'shell':
920
- case 'run':
921
- case 'run_command':
922
- case 'bash':
923
1097
  return p.command ? ['shell', p.command] : null;
924
1098
  default:
925
1099
  return null;