@semalt-ai/code 1.8.3 → 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,8 @@ 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');
13
+ const writer = require('./ui/writer');
12
14
 
13
15
  const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
14
16
 
@@ -22,6 +24,7 @@ function getSkippedOps() { return _skippedOps.slice(); }
22
24
  let _uiActive = false;
23
25
  function setUIActive(v) { _uiActive = v; }
24
26
  function isUIActive() { return _uiActive; }
27
+ // audit: allowed — fires only when TUI is inactive (one-shot non-TUI commands), no live region to protect.
25
28
  function _log(...args) { if (!_uiActive) console.log(...args); }
26
29
 
27
30
  // Reject writes outside the project CWD and in sensitive system/home dirs
@@ -87,7 +90,130 @@ function createToolExecutor(permissionManager, ui, getConfig) {
87
90
  return options.length >= 2 ? options : [];
88
91
  }
89
92
 
90
- 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 = {}) {
91
217
  if (_dryRun) {
92
218
  _log(` ${FG_DARK}[dry-run] $ ${command}${RST}`);
93
219
  _skippedOps.push({ category: 'cmd', symbol: '▶', desc: command });
@@ -95,56 +221,102 @@ function createToolExecutor(permissionManager, ui, getConfig) {
95
221
  return { exit_code: 0, stdout: '', stderr: 'dry-run: command skipped' };
96
222
  }
97
223
 
98
- const approved = await permissionManager.askPermission('shell', command, 'exec');
99
- if (!approved) {
100
- logToolCall('exec', { command }, false, 'denied');
101
- return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
102
- }
103
-
104
224
  const cfg = getConfig ? getConfig() : {};
105
225
  const timeout = cfg.command_timeout_ms || 30000;
226
+ const { signal } = options;
106
227
 
107
228
  return new Promise((resolve) => {
108
229
  let child;
109
230
  try {
110
- 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 });
111
236
  } catch (error) {
112
237
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
113
238
  logToolCall('exec', { command }, true, 'error');
114
239
  return resolve({ exit_code: -1, stdout: '', stderr: error.message });
115
240
  }
241
+ const startedAt = Date.now();
116
242
  let stdout = '';
117
243
  let stderr = '';
118
244
  let killed = false;
245
+ let abortedByUser = false;
246
+
119
247
  const timer = setTimeout(() => {
120
248
  killed = true;
121
- try { child.kill('SIGTERM'); } catch {}
249
+ killTreeEscalating(child);
122
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
+
123
272
  child.stdout.setEncoding('utf8');
124
273
  child.stderr.setEncoding('utf8');
125
274
  child.stdout.on('data', (c) => { stdout += c; });
126
275
  child.stderr.on('data', (c) => { stderr += c; });
127
276
  child.on('error', (error) => {
128
277
  clearTimeout(timer);
278
+ detachAbort();
129
279
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
130
280
  logToolCall('exec', { command }, true, 'error');
131
281
  resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
132
282
  });
133
- child.on('close', (code, signal) => {
283
+ child.on('close', (code, sigName) => {
134
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
+ }
135
294
  if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
136
- const exit_code = killed ? -1 : (code != null ? code : (signal ? -1 : 0));
295
+ const exit_code = killed ? -1 : (code != null ? code : (sigName ? -1 : 0));
137
296
  logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
138
297
  resolve({ exit_code, stdout, stderr });
139
298
  });
140
299
  });
141
300
  }
142
301
 
143
- 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
+ }
144
315
  const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
145
316
 
146
317
  if (action === 'read') {
147
318
  const filePath = arg0;
319
+ const startedAt = Date.now();
148
320
  const stat = await fsp.stat(filePath).catch(() => null);
149
321
  if (stat) {
150
322
  const cfg = getConfig ? getConfig() : {};
@@ -155,8 +327,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
155
327
  return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
156
328
  }
157
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
+ }
158
334
  try {
159
- const data = await fsp.readFile(filePath, 'utf8');
335
+ const data = await fsp.readFile(filePath, { encoding: 'utf8', signal: signal || undefined });
160
336
  const lines = data.split('\n').length;
161
337
  if (lines > 10) {
162
338
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
@@ -164,8 +340,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
164
340
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
165
341
  }
166
342
  logToolCall('read_file', { path: filePath }, true, 'ok');
167
- return { content: data, path: filePath };
343
+ return { content: data, path: filePath, bytes: Buffer.byteLength(data, 'utf8') };
168
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
+ }
169
349
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
170
350
  logToolCall('read_file', { path: filePath }, true, 'error');
171
351
  return { error: error.message };
@@ -188,23 +368,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
188
368
  return _sandboxError(filePath);
189
369
  }
190
370
 
191
- // Read existing content for diff display
192
- let existing = '';
193
- try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
194
-
195
- // For append the final state is existing + new content
196
- const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
197
-
198
- // In CLI mode, print the diff inline. In TUI mode, direct stdout writes
199
- // collide with the live chat-history/status-bar redraw, so we route the
200
- // diff into the permission description instead (where it renders inside
201
- // the permission bubble and is safely truncated by MAX_DESC_LINES).
202
- const diffOutput = _uiActive
203
- ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
204
- : renderDiff(existing, finalContent, filePath);
205
- if (!_uiActive) process.stdout.write(diffOutput + '\n');
206
-
207
- // 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.
208
373
  if (_dryRun) {
209
374
  const verb = action === 'write' ? 'write' : 'append';
210
375
  _skippedOps.push({ category: 'file', symbol: '✎', desc: `${verb} ${filePath}` });
@@ -212,15 +377,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
212
377
  return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
213
378
  }
214
379
 
215
- // Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
216
- let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
217
- if (content) desc += ` (${content.length} chars)`;
218
- if (_uiActive) desc = `${desc}\n${diffOutput}`;
219
- const approved = await permissionManager.askPermission('file', desc, tag);
220
- if (!approved) {
221
- logToolCall(tag, { path: filePath, content }, false, 'denied');
222
- return { error: 'Permission denied' };
223
- }
224
380
  try {
225
381
  const dir = path.dirname(filePath);
226
382
  if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
@@ -270,13 +426,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
270
426
  return _sandboxError(filePath);
271
427
  }
272
428
 
273
- _log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
274
-
275
- const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
276
- if (!approved) {
277
- logToolCall('delete_file', { path: filePath }, false, 'denied');
278
- return { error: 'Permission denied' };
279
- }
280
429
  try {
281
430
  await fsp.unlink(filePath);
282
431
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
@@ -295,11 +444,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
295
444
  logToolCall('make_dir', { path: dirPath }, false, 'denied');
296
445
  return _sandboxError(dirPath);
297
446
  }
298
- const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
299
- if (!approved) {
300
- logToolCall('make_dir', { path: dirPath }, false, 'denied');
301
- return { error: 'Permission denied' };
302
- }
303
447
  try {
304
448
  await fsp.mkdir(dirPath, { recursive: true });
305
449
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
@@ -318,11 +462,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
318
462
  logToolCall('remove_dir', { path: dirPath }, false, 'denied');
319
463
  return _sandboxError(dirPath);
320
464
  }
321
- const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
322
- if (!approved) {
323
- logToolCall('remove_dir', { path: dirPath }, false, 'denied');
324
- return { error: 'Permission denied' };
325
- }
326
465
  try {
327
466
  await fsp.rm(dirPath, { recursive: true, force: true });
328
467
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
@@ -350,13 +489,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
350
489
  return _sandboxError(dst);
351
490
  }
352
491
 
353
- _log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
354
-
355
- const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
356
- if (!approved) {
357
- logToolCall('move_file', { src, dst }, false, 'denied');
358
- return { error: 'Permission denied' };
359
- }
360
492
  try {
361
493
  const dstDir = path.dirname(dst);
362
494
  if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
@@ -393,11 +525,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
393
525
  return _sandboxError(dst);
394
526
  }
395
527
 
396
- const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
397
- if (!approved) {
398
- logToolCall('copy_file', { src, dst }, false, 'denied');
399
- return { error: 'Permission denied' };
400
- }
401
528
  try {
402
529
  const dstDir = path.dirname(dst);
403
530
  if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
@@ -416,11 +543,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
416
543
  const filePath = arg0;
417
544
  const lineNum = arg1;
418
545
  const newContent = arg2;
419
- const approved = await permissionManager.askPermission('file', `Edit line ${lineNum} in ${filePath}`, 'edit_file');
420
- if (!approved) {
421
- logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
422
- return { error: 'Permission denied' };
423
- }
424
546
  try {
425
547
  const data = await fsp.readFile(filePath, 'utf8');
426
548
  const lines = data.split('\n');
@@ -469,11 +591,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
469
591
  const searchStr = arg1;
470
592
  const replaceStr = arg2;
471
593
  const flags = arg3 || '';
472
- const approved = await permissionManager.askPermission('file', `Replace in ${filePath}`, 'replace_in_file');
473
- if (!approved) {
474
- logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
475
- return { error: 'Permission denied' };
476
- }
477
594
  try {
478
595
  const data = await fsp.readFile(filePath, 'utf8');
479
596
  const guardErr = _checkRegexSafety(searchStr, data);
@@ -500,6 +617,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
500
617
  if (action === 'search_files') {
501
618
  const pattern = arg0;
502
619
  const searchDir = arg1 || '.';
620
+ const startedAt = Date.now();
503
621
  try {
504
622
  let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
505
623
  regStr = regStr.replace(/\*\*/g, '\x00');
@@ -510,15 +628,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
510
628
  const matchName = !pattern.includes('/');
511
629
  const files = [];
512
630
  async function walk(dir, rel) {
631
+ if (signal && signal.aborted) return;
513
632
  let entries;
514
633
  try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
515
634
  for (const entry of entries) {
635
+ if (signal && signal.aborted) return;
516
636
  const relPath = rel ? `${rel}/${entry.name}` : entry.name;
517
637
  if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
518
638
  if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
519
639
  }
520
640
  }
521
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
+ }
522
646
  files.sort();
523
647
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
524
648
  logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
@@ -559,11 +683,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
559
683
  if (action === 'set_env') {
560
684
  const varName = arg0;
561
685
  const value = arg1 || '';
562
- const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
563
- if (!approved) {
564
- logToolCall('set_env', { name: varName }, false, 'denied');
565
- return { error: 'Permission denied' };
566
- }
567
686
  process.env[varName] = value;
568
687
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Set env ${varName}${RST}`);
569
688
  logToolCall('set_env', { name: varName }, true, 'ok');
@@ -584,12 +703,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
584
703
  fileName = 'download';
585
704
  }
586
705
  const outPath = path.join(process.cwd(), fileName);
587
- const approved = await permissionManager.askPermission('net', `Download ${url}`, 'download');
588
- if (!approved) {
589
- logToolCall('download', { url }, false, 'denied');
590
- return { error: 'Permission denied' };
591
- }
706
+ const startedAt = Date.now();
592
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
+
593
737
  function doDownload(target, redirectsLeft) {
594
738
  const proto = target.startsWith('https') ? https : http;
595
739
  const req = proto.get(target, (res) => {
@@ -600,27 +744,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
600
744
  if (res.statusCode >= 400) {
601
745
  res.resume();
602
746
  const msg = `HTTP ${res.statusCode}`;
747
+ detachAbort();
603
748
  _log(` ${FG_RED}✗ ${msg}${RST}`);
604
749
  logToolCall('download', { url }, true, 'error');
605
750
  return resolve({ error: msg });
606
751
  }
607
752
  const file = fs.createWriteStream(outPath);
753
+ activeFile = file;
608
754
  res.pipe(file);
609
755
  file.on('finish', () => {
610
756
  file.close();
757
+ detachAbort();
611
758
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
612
759
  logToolCall('download', { url }, true, 'ok');
613
760
  resolve({ status: 'ok', path: outPath });
614
761
  });
615
762
  file.on('error', (err) => {
763
+ if (abortedByUser) {
764
+ detachAbort();
765
+ finishAborted();
766
+ return;
767
+ }
616
768
  fs.unlink(outPath, () => {});
769
+ detachAbort();
617
770
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
618
771
  logToolCall('download', { url }, true, 'error');
619
772
  resolve({ error: err.message });
620
773
  });
621
774
  });
775
+ activeReq = req;
622
776
  req.on('error', (err) => {
777
+ if (abortedByUser) {
778
+ detachAbort();
779
+ finishAborted();
780
+ return;
781
+ }
623
782
  fs.unlink(outPath, () => {});
783
+ detachAbort();
624
784
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
625
785
  logToolCall('download', { url }, true, 'error');
626
786
  resolve({ error: err.message });
@@ -628,6 +788,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
628
788
  req.setTimeout(120000, () => {
629
789
  req.destroy();
630
790
  fs.unlink(outPath, () => {});
791
+ detachAbort();
631
792
  logToolCall('download', { url }, true, 'error');
632
793
  resolve({ error: 'Request timeout' });
633
794
  });
@@ -643,11 +804,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
643
804
  logToolCall('upload', { path: filePath }, false, 'denied');
644
805
  return _sandboxError(filePath);
645
806
  }
646
- const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
647
- if (!approved) {
648
- logToolCall('upload', { path: filePath }, false, 'denied');
649
- return { error: 'Permission denied' };
650
- }
651
807
  try {
652
808
  const dir = path.dirname(filePath);
653
809
  if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
@@ -670,15 +826,37 @@ function createToolExecutor(permissionManager, ui, getConfig) {
670
826
  logToolCall('http_get', { url }, false, 'dry-run');
671
827
  return { status: 'dry-run', message: 'dry-run: network call skipped' };
672
828
  }
673
- const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
674
- if (!approved) {
675
- logToolCall('http_get', { url }, false, 'denied');
676
- return { error: 'Permission denied' };
677
- }
678
829
  const httpCfg = getConfig ? getConfig() : {};
679
830
  const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
680
831
  const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
832
+ const startedAt = Date.now();
681
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
+
682
860
  function doGet(target, redirectsLeft) {
683
861
  const proto = target.startsWith('https') ? https : http;
684
862
  const req = proto.get(target, (res) => {
@@ -704,6 +882,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
704
882
  }
705
883
  });
706
884
  res.on('end', () => {
885
+ if (abortedByUser) return;
886
+ detachAbort();
707
887
  const kept = Buffer.concat(bufs);
708
888
  const keptBytes = kept.length;
709
889
  let body = kept.toString('utf8');
@@ -715,18 +895,29 @@ function createToolExecutor(permissionManager, ui, getConfig) {
715
895
  }
716
896
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${totalBytes} bytes${capped ? `, truncated to ${keptBytes}` : ''})${RST}`);
717
897
  logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
718
- resolve({ status_code: res.statusCode, body });
898
+ // `bytes` is the total transferred payload length (pre-cap);
899
+ // consumers that want to know the wire size without parsing
900
+ // the appended truncation note rely on this.
901
+ resolve({ status_code: res.statusCode, body, bytes: totalBytes });
719
902
  });
720
903
  });
904
+ activeReq = req;
721
905
  req.on('error', (err) => {
906
+ if (abortedByUser) {
907
+ detachAbort();
908
+ finishAborted();
909
+ return;
910
+ }
911
+ detachAbort();
722
912
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
723
913
  logToolCall('http_get', { url: target }, true, 'error');
724
- resolve({ error: err.message });
914
+ resolve({ error: err.message, error_code: err.code });
725
915
  });
726
916
  req.setTimeout(reqTimeoutMs, () => {
727
917
  req.destroy();
918
+ detachAbort();
728
919
  logToolCall('http_get', { url: target }, true, 'error');
729
- resolve({ error: 'Request timeout' });
920
+ resolve({ error: 'Request timeout', error_code: 'ETIMEDOUT' });
730
921
  });
731
922
  }
732
923
  doGet(url, 5);
@@ -735,11 +926,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
735
926
 
736
927
  if (action === 'ask_user') {
737
928
  const question = arg0;
738
- const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
739
- if (!approved) {
740
- logToolCall('ask_user', { question }, false, 'denied');
741
- return { error: 'Permission denied' };
742
- }
743
929
  const options = _parseNumberedOptions(question);
744
930
  if (options.length >= 2) {
745
931
  const selected = await permissionManager.captureSelect({ options });
@@ -747,10 +933,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
747
933
  return { question, answer: selected || options[0] };
748
934
  }
749
935
  if (!process.stdout.isTTY || process.stdin.isRaw) {
750
- process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}\n`);
936
+ writer.scrollback(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}`);
751
937
  logToolCall('ask_user', { question }, true, 'ok');
752
938
  return { question, answer: 'y' };
753
939
  }
940
+ // audit: allowed — inline prompt without trailing newline; unreachable when TUI writer is active
941
+ // (process.stdin.isRaw is true while the TUI input field holds raw mode).
754
942
  process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${FG_GRAY}>${RST} `);
755
943
  const buf = Buffer.alloc(4096);
756
944
  let input = '';
@@ -770,11 +958,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
770
958
  if (action === 'store_memory') {
771
959
  const key = arg0;
772
960
  const value = arg1 || '';
773
- const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
774
- if (!approved) {
775
- logToolCall('store_memory', { key }, false, 'denied');
776
- return { error: 'Permission denied' };
777
- }
778
961
  try {
779
962
  let mem = {};
780
963
  try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
@@ -846,6 +1029,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
846
1029
  return {
847
1030
  agentExecFile,
848
1031
  agentExecShell,
1032
+ describePermission,
849
1033
  };
850
1034
  }
851
1035
 
@@ -865,7 +1049,7 @@ function mapInvokeToCall(toolName, params) {
865
1049
  case 'delete_file':
866
1050
  return p.path ? ['delete_file', p.path] : null;
867
1051
  case 'list_dir':
868
- return ['list_dir', p.path || p.dir || '.'];
1052
+ return ['list_dir', p.path || '.'];
869
1053
  case 'make_dir':
870
1054
  return p.path ? ['make_dir', p.path] : null;
871
1055
  case 'remove_dir':
@@ -877,7 +1061,7 @@ function mapInvokeToCall(toolName, params) {
877
1061
  case 'file_stat':
878
1062
  return p.path ? ['file_stat', p.path] : null;
879
1063
  case 'search_files':
880
- return ['search_files', p.pattern || p.glob || '*', p.dir || '.'];
1064
+ return ['search_files', p.pattern || '*', p.dir || '.'];
881
1065
  case 'search_in_file':
882
1066
  return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
883
1067
  case 'replace_in_file':
@@ -910,9 +1094,6 @@ function mapInvokeToCall(toolName, params) {
910
1094
  return ['system_info'];
911
1095
  case 'exec':
912
1096
  case 'shell':
913
- case 'run':
914
- case 'run_command':
915
- case 'bash':
916
1097
  return p.command ? ['shell', p.command] : null;
917
1098
  default:
918
1099
  return null;