aegis-desktop 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,638 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tools.js — the local tool layer for the desktop agent loop (client half of
5
+ * aegiscodex-dev's tool calling).
6
+ *
7
+ * Two jobs, mirroring aegiscodex-dev/src/tools.js:
8
+ * 1. Tool schemas in the wire format each API family expects — Anthropic's
9
+ * `{name, description, input_schema}` vs OpenAI-compatible
10
+ * `{type:'function', function:{name, description, parameters}}`.
11
+ * 2. Local execution of the builtin tools (readFile, writeFile, editFile,
12
+ * listDir, glob, grep, exec), so a chat turn in the desktop app can
13
+ * actually touch the machine instead of only talking about it.
14
+ *
15
+ * `exec` runs in a persistent shell session (shell.js) when the caller
16
+ * supplies `ctx.getShell` — cd/export/env state then carries across calls
17
+ * within one turn, same as the CLI's Bash tool. `task` is advertised here
18
+ * (SUBAGENT_TOOL) but has no local executor: it needs to run the model, so
19
+ * engine.js's chat loop handles it directly as a nested subagent turn.
20
+ *
21
+ * Path note: this lives under desktop/lib/local/ (not desktop/lib/) because
22
+ * the CI thin-shell guard (.github/workflows/ci.yml, step 4) allowlists only
23
+ * `desktop/lib/local/`, `desktop/lib/sync/` and `desktop/lib/settings.js` as
24
+ * transport paths — a new file directly under desktop/lib/ fails that guard.
25
+ *
26
+ * Everything here is self-contained (node:child_process + node:fs + node:path
27
+ * only, no new dependencies) and NEVER throws: each executor resolves either
28
+ * `{ ok: true, output }` or `{ ok: false, error }`, so a bad path, a dead
29
+ * command or a hostile arg string can only ever become a tool error handed
30
+ * back to the model — never a rejected IPC call or a crashed main process.
31
+ *
32
+ * SECURITY: this is a deliberate widening of the app's sandbox. The renderer
33
+ * stays contextIsolated + sandboxed with no fs/child_process of its own; the
34
+ * executor lives in the MAIN process and is reachable from the renderer only
35
+ * through the whitelisted `tools:` IPC surface (see main.js/preload.js and
36
+ * docs/desktop-tools.md). There is no path jail: the tools deliberately run
37
+ * with the user's own privileges, exactly like the CLI does. Treat any future
38
+ * renderer-side input that reaches these args as privileged.
39
+ */
40
+
41
+ const { spawn } = require('node:child_process');
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+ const { agentRoles } = require('./agents.js');
45
+
46
+ const OUTPUT_CAP = 30_000; // chars fed back to the model per tool result
47
+ const READ_LINE_CAP = 2000; // default line limit for readFile
48
+ const MATCH_CAP = 100; // max glob/grep hits per call
49
+ // readFileSync loads the whole file before `limit` ever truncates — refuse
50
+ // oversized reads outright instead of freezing the turn on a multi-GB log.
51
+ const READ_SIZE_CAP = 25 * 1024 * 1024;
52
+ const GREP_SIZE_CAP = 10 * 1024 * 1024; // grep silently skips files above this
53
+ const EXEC_TIMEOUT_DEFAULT = 120_000;
54
+ const EXEC_TIMEOUT_CAP = 600_000; // 10 minutes, matches the CLI's cap
55
+ const EXEC_MAX_BUFFER = 1_048_576; // 1 MB of combined stdout+stderr
56
+
57
+ /** Truncate oversized tool output (the model never needs the whole log). */
58
+ function cap(s) {
59
+ const text = String(s == null ? '' : s);
60
+ return text.length > OUTPUT_CAP
61
+ ? `${text.slice(0, OUTPUT_CAP)}\n… (truncated)`
62
+ : text;
63
+ }
64
+
65
+ const ok = (output) => ({ ok: true, output: cap(output) });
66
+ const fail = (error) => ({ ok: false, error: cap(error) });
67
+
68
+ // ── Schemas ─────────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Canonical descriptions + parameters, in one place per tool. The two wire
72
+ * formats below are pure projections of this map, so a schema can never drift
73
+ * between providers (see the conversion test in test/local-tools.test.mjs).
74
+ */
75
+ const SCHEMAS = {
76
+ readFile: {
77
+ name: 'readFile',
78
+ description:
79
+ 'Reads a file from the local filesystem. The file_path parameter must be an absolute path. ' +
80
+ 'Returns line-numbered content.',
81
+ parameters: {
82
+ type: 'object',
83
+ properties: {
84
+ file_path: { type: 'string', description: 'The absolute path to the file to read' },
85
+ offset: { type: 'number', description: 'The line number to start reading from (0-based)' },
86
+ limit: { type: 'number', description: `The number of lines to read (max 10000, default ${READ_LINE_CAP})` },
87
+ },
88
+ required: ['file_path'],
89
+ additionalProperties: false,
90
+ },
91
+ },
92
+ writeFile: {
93
+ name: 'writeFile',
94
+ description:
95
+ 'Writes a file to the local filesystem. Parent directories are created automatically. ' +
96
+ 'Use it to create or replace a whole file; it overwrites whatever was there.',
97
+ parameters: {
98
+ type: 'object',
99
+ properties: {
100
+ file_path: { type: 'string', description: 'The absolute path to the file to write' },
101
+ content: { type: 'string', description: 'The full contents of the file' },
102
+ },
103
+ required: ['file_path', 'content'],
104
+ additionalProperties: false,
105
+ },
106
+ },
107
+ editFile: {
108
+ name: 'editFile',
109
+ description:
110
+ 'Performs an exact string replacement in a file. old_string must be unique in the file ' +
111
+ 'unless replace_all is true. Use this instead of writeFile when changing part of an ' +
112
+ 'existing file — it fails loudly on a non-unique or missing match instead of guessing.',
113
+ parameters: {
114
+ type: 'object',
115
+ properties: {
116
+ file_path: { type: 'string', description: 'The absolute path to the file to modify' },
117
+ old_string: { type: 'string', description: 'The text to replace (must be unique unless replace_all is true)' },
118
+ new_string: { type: 'string', description: 'The text to replace it with' },
119
+ replace_all: { type: 'boolean', description: 'If true, replace all occurrences of old_string' },
120
+ },
121
+ required: ['file_path', 'old_string', 'new_string'],
122
+ additionalProperties: false,
123
+ },
124
+ },
125
+ listDir: {
126
+ name: 'listDir',
127
+ description:
128
+ 'Lists one directory (non-recursive). Directories are marked with a trailing slash. ' +
129
+ 'Skips node_modules, .git and dist.',
130
+ parameters: {
131
+ type: 'object',
132
+ properties: {
133
+ path: { type: 'string', description: 'The directory to list (default: the working directory)' },
134
+ },
135
+ required: [],
136
+ additionalProperties: false,
137
+ },
138
+ },
139
+ glob: {
140
+ name: 'glob',
141
+ description:
142
+ 'Find files matching a glob pattern. Supports **, * and ?. Skips node_modules, .git and dist by default.',
143
+ parameters: {
144
+ type: 'object',
145
+ properties: {
146
+ pattern: { type: 'string', description: 'The glob pattern to match, e.g. "**/*.test.js"' },
147
+ path: { type: 'string', description: 'The directory to search from (default: the working directory)' },
148
+ },
149
+ required: ['pattern'],
150
+ additionalProperties: false,
151
+ },
152
+ },
153
+ grep: {
154
+ name: 'grep',
155
+ description:
156
+ 'Search file contents using a regular expression. Returns file:line matches. ' +
157
+ 'Skips node_modules, .git and dist by default.',
158
+ parameters: {
159
+ type: 'object',
160
+ properties: {
161
+ pattern: { type: 'string', description: 'The regular expression to search for' },
162
+ path: { type: 'string', description: 'The directory to search in (default: the working directory)' },
163
+ },
164
+ required: ['pattern'],
165
+ additionalProperties: false,
166
+ },
167
+ },
168
+ exec: {
169
+ name: 'exec',
170
+ description:
171
+ 'Executes a shell command in a persistent shell session and returns its combined ' +
172
+ 'stdout+stderr plus the exit code. State (cd, exported env vars) carries across calls ' +
173
+ 'within the same turn — it is a real session, not a fresh process each time. ' +
174
+ 'Use for system operations, git commands and package management.',
175
+ parameters: {
176
+ type: 'object',
177
+ properties: {
178
+ command: { type: 'string', description: 'The shell command to execute' },
179
+ cwd: { type: 'string', description: 'Run this one command in a different directory without moving the session (the session cwd is unchanged for later calls)' },
180
+ timeout: { type: 'number', description: `Timeout in milliseconds (max ${EXEC_TIMEOUT_CAP}, default ${EXEC_TIMEOUT_DEFAULT})` },
181
+ description: { type: 'string', description: 'A brief description of what the command does (for display)' },
182
+ },
183
+ required: ['command'],
184
+ additionalProperties: false,
185
+ },
186
+ },
187
+ task: {
188
+ name: 'task',
189
+ description:
190
+ 'Spawn a specialized subagent to autonomously handle a focused, multi-step sub-task. ' +
191
+ 'The subagent runs its own tool loop (readFile/writeFile/editFile/listDir/glob/grep/exec) ' +
192
+ 'on the same model and returns a final report as the tool result. Use it to delegate work ' +
193
+ 'like scanning for vulnerabilities, reviewing code, planning a refactor, or scaffolding a ' +
194
+ 'component — give it a complete, self-contained prompt since it cannot ask follow-up ' +
195
+ 'questions. Subagents can delegate further with task, so a large job can be split ' +
196
+ 'hierarchically as deep as useful.',
197
+ parameters: {
198
+ type: 'object',
199
+ properties: {
200
+ description: { type: 'string', description: 'A short (3-5 word) description of the sub-task' },
201
+ subagent_type: {
202
+ type: 'string',
203
+ enum: [...agentRoles(), 'general'],
204
+ description: 'Which specialist preset to spawn (general = a capable all-purpose agent)',
205
+ },
206
+ prompt: { type: 'string', description: 'The full, self-contained task instructions for the subagent' },
207
+ },
208
+ required: ['description', 'prompt'],
209
+ additionalProperties: false,
210
+ },
211
+ },
212
+ };
213
+
214
+ // task is executed by the chat loop (it needs to run the model), not by the
215
+ // local executors below — advertised in the schemas but handled in
216
+ // engine.js's chat(). Subagents get task too, so delegation can nest —
217
+ // engine.js drops it (via includeSubagent) once the delegation chain passes
218
+ // MAX_SUBAGENT_DEPTH, hard-bounding runaway recursion.
219
+ const SUBAGENT_TOOL = 'task';
220
+
221
+ /** Tool names in advertisement order. `includeSubagent: false` drops task (depth cap). */
222
+ function toolNames({ includeSubagent = true } = {}) {
223
+ return schemaList(includeSubagent).map((s) => s.name);
224
+ }
225
+
226
+ function schemaList(includeSubagent) {
227
+ return Object.values(SCHEMAS).filter((s) => includeSubagent || s.name !== SUBAGENT_TOOL);
228
+ }
229
+
230
+ /** Anthropic Messages API tool definitions ({name, description, input_schema}). */
231
+ function anthropicTools({ includeSubagent = true } = {}) {
232
+ return schemaList(includeSubagent).map((s) => ({
233
+ name: s.name,
234
+ description: s.description,
235
+ input_schema: s.parameters,
236
+ }));
237
+ }
238
+
239
+ /** OpenAI-compatible /chat/completions tool definitions ({type:'function', function}). */
240
+ function openaiTools({ includeSubagent = true } = {}) {
241
+ return schemaList(includeSubagent).map((s) => ({
242
+ type: 'function',
243
+ function: { name: s.name, description: s.description, parameters: s.parameters },
244
+ }));
245
+ }
246
+
247
+ /**
248
+ * The advertised tool list for one wire format. `wire` is 'anthropic' or
249
+ * anything else (treated as OpenAI-compatible) — the same split the transport
250
+ * layer uses. `includeSubagent: false` drops the task tool (subagent depth cap).
251
+ */
252
+ function toolsFor(wire, { includeSubagent = true } = {}) {
253
+ return wire === 'anthropic' ? anthropicTools({ includeSubagent }) : openaiTools({ includeSubagent });
254
+ }
255
+
256
+ /**
257
+ * Convert an OpenAI-format tool list into Anthropic's. Exported because it is
258
+ * the exact transformation the loop depends on (and it is unit-tested as
259
+ * such): an endpoint that speaks Anthropic must never be handed
260
+ * `{type:'function', function:{…}}`.
261
+ */
262
+ function openaiToAnthropicTools(tools) {
263
+ if (!Array.isArray(tools)) return [];
264
+ return tools
265
+ .map((t) => {
266
+ const fn = (t && t.function) || t || {};
267
+ if (!fn.name) return null;
268
+ return {
269
+ name: fn.name,
270
+ description: fn.description || '',
271
+ input_schema: fn.parameters || { type: 'object', properties: {} },
272
+ };
273
+ })
274
+ .filter(Boolean);
275
+ }
276
+
277
+ /** Reverse projection (Anthropic → OpenAI), for symmetry/completeness. */
278
+ function anthropicToOpenaiTools(tools) {
279
+ if (!Array.isArray(tools)) return [];
280
+ return tools
281
+ .map((t) => {
282
+ if (!t || !t.name) return null;
283
+ return {
284
+ type: 'function',
285
+ function: {
286
+ name: t.name,
287
+ description: t.description || '',
288
+ parameters: t.input_schema || { type: 'object', properties: {} },
289
+ },
290
+ };
291
+ })
292
+ .filter(Boolean);
293
+ }
294
+
295
+ // ── Executors ───────────────────────────────────────────────────────────────
296
+
297
+ function readFile({ file_path, offset = 0, limit } = {}) {
298
+ try {
299
+ if (!file_path) return fail('file_path is required');
300
+ const stat = fs.statSync(file_path);
301
+ if (stat.isDirectory()) return fail(`${file_path} is a directory (use listDir)`);
302
+ if (stat.size > READ_SIZE_CAP) {
303
+ const mb = (stat.size / 1048576).toFixed(1);
304
+ return fail(
305
+ `${file_path} is ${mb} MB — too large to read (limit ${READ_SIZE_CAP / 1048576} MB). ` +
306
+ 'Use exec with grep/head to inspect it instead.'
307
+ );
308
+ }
309
+ const lines = fs.readFileSync(file_path, 'utf8').split('\n');
310
+ const start = Math.max(0, Number(offset) || 0);
311
+ const count = Math.max(1, Math.min(Number(limit) || READ_LINE_CAP, 10_000));
312
+ const picked = lines.slice(start, start + count);
313
+ const numbered = picked.map((l, i) => `${i + start + 1}| ${l}`).join('\n');
314
+ const tail =
315
+ start + picked.length < lines.length
316
+ ? `\n… (${lines.length - start - picked.length} more lines)`
317
+ : '';
318
+ return ok(numbered + tail);
319
+ } catch (e) {
320
+ return fail(e && e.message ? e.message : String(e));
321
+ }
322
+ }
323
+
324
+ function writeFile({ file_path, content } = {}) {
325
+ try {
326
+ if (!file_path) return fail('file_path is required');
327
+ fs.mkdirSync(path.dirname(file_path), { recursive: true });
328
+ fs.writeFileSync(file_path, String(content == null ? '' : content), 'utf8');
329
+ return ok(`Wrote ${String(content == null ? '' : content).length} bytes to ${file_path}`);
330
+ } catch (e) {
331
+ return fail(e && e.message ? e.message : String(e));
332
+ }
333
+ }
334
+
335
+ function editFile({ file_path, old_string, new_string, replace_all } = {}) {
336
+ try {
337
+ if (!file_path) return fail('file_path is required');
338
+ if (old_string === undefined || old_string === '') {
339
+ return fail('old_string is required and must be non-empty');
340
+ }
341
+ const src = fs.readFileSync(file_path, 'utf8');
342
+ const count = src.split(old_string).length - 1;
343
+ if (count === 0) return fail(`old_string not found in ${file_path}`);
344
+ if (count > 1 && !replace_all) {
345
+ return fail(`old_string is not unique (${count} matches) — use replace_all or more context`);
346
+ }
347
+ const next = replace_all
348
+ ? src.split(old_string).join(new_string == null ? '' : new_string)
349
+ : src.replace(old_string, new_string == null ? '' : new_string);
350
+ fs.writeFileSync(file_path, next, 'utf8');
351
+ return ok(`Edited ${file_path} (${count} occurrence${count > 1 ? 's' : ''} replaced)`);
352
+ } catch (e) {
353
+ return fail(e && e.message ? e.message : String(e));
354
+ }
355
+ }
356
+
357
+ const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.aegiscode']);
358
+
359
+ function listDir({ path: dir } = {}) {
360
+ try {
361
+ const base = dir || process.cwd();
362
+ const entries = fs.readdirSync(base, { withFileTypes: true });
363
+ const rows = entries
364
+ .filter((e) => !IGNORED_DIRS.has(e.name))
365
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
366
+ .sort((a, b) => a.localeCompare(b));
367
+ return ok(rows.join('\n') || '(empty directory)');
368
+ } catch (e) {
369
+ return fail(e && e.message ? e.message : String(e));
370
+ }
371
+ }
372
+
373
+ /** Translate a glob into a regex over '/'-separated relative paths. */
374
+ function globToRegex(pattern) {
375
+ let re = '^';
376
+ for (let i = 0; i < pattern.length; i++) {
377
+ const c = pattern[i];
378
+ if (c === '*') {
379
+ if (pattern[i + 1] === '*') {
380
+ re += '.*';
381
+ i++;
382
+ } else re += '[^/]*';
383
+ } else if (c === '?') re += '[^/]';
384
+ else if (/[.+^${}()|[\]\\]/.test(c)) re += `\\${c}`;
385
+ else re += c;
386
+ }
387
+ return new RegExp(re + '$');
388
+ }
389
+
390
+ function walk(dir, fn, depth = 0) {
391
+ if (depth > 12) return; // hard bound: never crawl an unbounded tree
392
+ let entries;
393
+ try {
394
+ entries = fs.readdirSync(dir, { withFileTypes: true });
395
+ } catch {
396
+ return;
397
+ }
398
+ for (const e of entries) {
399
+ if (IGNORED_DIRS.has(e.name)) continue;
400
+ const full = path.join(dir, e.name);
401
+ if (e.isDirectory()) walk(full, fn, depth + 1);
402
+ else fn(full);
403
+ }
404
+ }
405
+
406
+ function glob({ pattern, path: root } = {}) {
407
+ try {
408
+ if (!pattern) return fail('pattern is required');
409
+ const base = root || process.cwd();
410
+ const re = globToRegex(String(pattern));
411
+ const hits = [];
412
+ walk(base, (full) => {
413
+ if (hits.length >= MATCH_CAP) return;
414
+ const rel = path.relative(base, full).split(path.sep).join('/');
415
+ if (re.test(rel)) hits.push(rel);
416
+ });
417
+ return ok(hits.join('\n') || '(no matches)');
418
+ } catch (e) {
419
+ return fail(e && e.message ? e.message : String(e));
420
+ }
421
+ }
422
+
423
+ function grep({ pattern, path: root } = {}) {
424
+ try {
425
+ if (!pattern) return fail('pattern is required');
426
+ const re = new RegExp(pattern);
427
+ const base = root || process.cwd();
428
+ const hits = [];
429
+ walk(base, (full) => {
430
+ if (hits.length >= MATCH_CAP) return;
431
+ let text;
432
+ try {
433
+ // Skip oversized files (logs, dumps, binaries) instead of slurping
434
+ // them — a 10 GB log would otherwise stall the whole tool loop.
435
+ if (fs.statSync(full).size > GREP_SIZE_CAP) return;
436
+ text = fs.readFileSync(full, 'utf8');
437
+ } catch {
438
+ return;
439
+ }
440
+ const rel = path.relative(base, full).split(path.sep).join('/');
441
+ for (const [i, line] of text.split('\n').entries()) {
442
+ if (hits.length >= MATCH_CAP) return;
443
+ if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.slice(0, 160)}`);
444
+ }
445
+ });
446
+ return ok(hits.join('\n') || '(no matches)');
447
+ } catch (e) {
448
+ return fail(e && e.message ? e.message : String(e));
449
+ }
450
+ }
451
+
452
+ /** Shell to run `exec` in: cmd.exe on Windows, $SHELL (or /bin/sh) elsewhere. */
453
+ function shellSpec() {
454
+ if (process.platform === 'win32') {
455
+ return { cmd: process.env.ComSpec || 'cmd.exe', arg: '/d /s /c' };
456
+ }
457
+ return { cmd: process.env.SHELL || '/bin/sh', arg: '-c' };
458
+ }
459
+
460
+ /**
461
+ * Run a shell command as a fresh one-shot process (no state carried to the
462
+ * next call). Never rejects: spawn failures, a stalled child, a non-zero
463
+ * exit and output overflow all resolve as `{ ok:false, error }` (a non-zero
464
+ * exit is reported as an error so the model sees the failure, with whatever
465
+ * the command printed attached).
466
+ */
467
+ function execOneShot({ command, cwd, timeout, maxBuffer } = {}) {
468
+ return new Promise((resolve) => {
469
+ const limit = Math.min(Number(maxBuffer) || EXEC_MAX_BUFFER, EXEC_MAX_BUFFER);
470
+ const ms = Math.min(Number(timeout) || EXEC_TIMEOUT_DEFAULT, EXEC_TIMEOUT_CAP);
471
+ const { cmd, arg } = shellSpec();
472
+
473
+ let child;
474
+ try {
475
+ child = spawn(cmd, [arg, command], {
476
+ cwd: cwd || process.cwd(),
477
+ timeout: ms,
478
+ killSignal: 'SIGKILL',
479
+ windowsHide: true,
480
+ });
481
+ } catch (e) {
482
+ resolve(fail(`spawn failed: ${e && e.message ? e.message : e}`));
483
+ return;
484
+ }
485
+
486
+ let out = '';
487
+ let bytes = 0;
488
+ let overflow = false;
489
+ let timedOut = false;
490
+ let settled = false;
491
+
492
+ const timer = setTimeout(() => {
493
+ timedOut = true;
494
+ try {
495
+ child.kill('SIGKILL');
496
+ } catch {
497
+ /* already gone */
498
+ }
499
+ }, ms);
500
+
501
+ const collect = (chunk) => {
502
+ bytes += chunk.length;
503
+ if (bytes > limit) {
504
+ overflow = true;
505
+ try {
506
+ child.kill('SIGKILL');
507
+ } catch {
508
+ /* already gone */
509
+ }
510
+ return;
511
+ }
512
+ out += chunk;
513
+ };
514
+
515
+ if (child.stdout) child.stdout.on('data', collect);
516
+ if (child.stderr) child.stderr.on('data', collect);
517
+
518
+ const done = (res) => {
519
+ if (settled) return;
520
+ settled = true;
521
+ clearTimeout(timer);
522
+ resolve(res);
523
+ };
524
+
525
+ child.on('error', (e) => done(fail(`spawn failed: ${e && e.message ? e.message : e}`)));
526
+ child.on('close', (code, signal) => {
527
+ const body = out.trim();
528
+ if (timedOut) {
529
+ done(fail(`command timed out after ${ms}ms${body ? `\n${body}` : ''}`));
530
+ return;
531
+ }
532
+ if (overflow) {
533
+ // The buffer cap is what the model should see: trimming to OUTPUT_CAP
534
+ // below would hide the fact that output was dropped.
535
+ done(fail(`output exceeded ${limit} bytes and was truncated\n${body}`));
536
+ return;
537
+ }
538
+ const label = `exit ${code == null ? `signal ${signal}` : code}`;
539
+ if (code === 0) done(ok(body || `(${label}, no output)`));
540
+ else done(fail(`${label}${body ? `\n${body}` : ''}`));
541
+ });
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Run a persistent-session command and translate the session's { content,
547
+ * isError } shape into this file's { ok, output } / { ok:false, error }
548
+ * convention. An abort signal disposes the session immediately instead of
549
+ * waiting out the command's own timeout.
550
+ */
551
+ function execInShell(shell, { command, cwd, timeout }, signal) {
552
+ if (signal && signal.aborted) {
553
+ shell.dispose();
554
+ return Promise.resolve(fail('aborted'));
555
+ }
556
+ return new Promise((resolve) => {
557
+ const onAbort = () => shell.dispose();
558
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
559
+ const release = () => { if (signal) signal.removeEventListener('abort', onAbort); };
560
+ shell.run(command, { timeout, working_directory: cwd }).then(
561
+ (r) => { release(); resolve(r.isError ? fail(r.content) : ok(r.content)); },
562
+ (e) => { release(); resolve(fail(`shell session failed: ${e && e.message ? e.message : e}`)); }
563
+ );
564
+ });
565
+ }
566
+
567
+ /**
568
+ * Execute a shell command. When the caller supplies `ctx.getShell` (the
569
+ * chat loop always does — see engine.js), the command runs in that turn's
570
+ * persistent session so cd/export/env state carries to the next call. Without
571
+ * one (e.g. a direct executeTool call in tests) it falls back to a one-shot
572
+ * spawn — same output shape, no persisted state. Never rejects.
573
+ */
574
+ function exec({ command, cwd, timeout, maxBuffer } = {}, ctx = {}) {
575
+ if (!command || typeof command !== 'string') {
576
+ return Promise.resolve(fail('command is required'));
577
+ }
578
+ const shell = typeof ctx.getShell === 'function' ? ctx.getShell() : null;
579
+ if (shell) return execInShell(shell, { command, cwd, timeout }, ctx.signal);
580
+ return execOneShot({ command, cwd, timeout, maxBuffer });
581
+ }
582
+
583
+ const EXECUTORS = { readFile, writeFile, editFile, listDir, glob, grep, exec };
584
+
585
+ /** True when `name` is a tool this layer can run (task is excluded — see SUBAGENT_TOOL). */
586
+ function isTool(name) {
587
+ return Object.prototype.hasOwnProperty.call(EXECUTORS, name);
588
+ }
589
+
590
+ /**
591
+ * Execute one tool call. Always resolves `{ ok, output }` or `{ ok, error }`
592
+ * — an unknown tool name, a non-object args payload and an exploding executor
593
+ * all land on the error branch rather than rejecting. `ctx` carries per-turn
594
+ * state (getShell, signal); omit it and exec falls back to a one-shot spawn.
595
+ */
596
+ async function executeTool(name, args, ctx) {
597
+ if (!isTool(name)) return fail(`unknown tool "${name}" (known: ${toolNames().join(', ')})`);
598
+ const input = args && typeof args === 'object' ? args : {};
599
+ try {
600
+ return await EXECUTORS[name](input, ctx || {});
601
+ } catch (e) {
602
+ return fail(e && e.message ? e.message : String(e));
603
+ }
604
+ }
605
+
606
+ /**
607
+ * The string a tool result contributes to the conversation: the output on
608
+ * success, `error: …` on failure — the same shape the CLI feeds back.
609
+ */
610
+ function toolResultText(result) {
611
+ if (!result) return 'error: tool produced no result';
612
+ return result.ok ? String(result.output == null ? '' : result.output) : `error: ${result.error}`;
613
+ }
614
+
615
+ module.exports = {
616
+ // schemas
617
+ SCHEMAS,
618
+ SUBAGENT_TOOL,
619
+ toolNames,
620
+ anthropicTools,
621
+ openaiTools,
622
+ toolsFor,
623
+ openaiToAnthropicTools,
624
+ anthropicToOpenaiTools,
625
+ // execution
626
+ executeTool,
627
+ isTool,
628
+ toolResultText,
629
+ // limits (unit tests assert against them instead of hard-coding numbers)
630
+ OUTPUT_CAP,
631
+ READ_LINE_CAP,
632
+ MATCH_CAP,
633
+ READ_SIZE_CAP,
634
+ GREP_SIZE_CAP,
635
+ EXEC_TIMEOUT_DEFAULT,
636
+ EXEC_TIMEOUT_CAP,
637
+ EXEC_MAX_BUFFER,
638
+ };