aegiscode 6.1.0 → 6.2.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,882 @@
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 crypto = require('node:crypto');
43
+ const fs = require('node:fs');
44
+ const path = require('node:path');
45
+ const { agentRoles } = require('./agents.js');
46
+
47
+ const OUTPUT_CAP = 30_000; // chars fed back to the model per tool result
48
+ const READ_LINE_CAP = 2000; // default line limit for readFile
49
+ const MATCH_CAP = 100; // max glob/grep hits per call
50
+ // readFileSync loads the whole file before `limit` ever truncates — refuse
51
+ // oversized reads outright instead of freezing the turn on a multi-GB log.
52
+ const READ_SIZE_CAP = 25 * 1024 * 1024;
53
+ const GREP_SIZE_CAP = 10 * 1024 * 1024; // grep silently skips files above this
54
+ const EXEC_TIMEOUT_DEFAULT = 120_000;
55
+ const EXEC_TIMEOUT_CAP = 600_000; // 10 minutes, matches the CLI's cap
56
+ const EXEC_MAX_BUFFER = 1_048_576; // 1 MB of combined stdout+stderr
57
+ // The LCS diff below is O(lines_before * lines_after); past this many cells
58
+ // (or this many lines on either side) the diff is skipped in favor of a
59
+ // one-line summary rather than freezing the approval flow on a huge file.
60
+ const DIFF_MAX_CELLS = 4_000_000;
61
+ const DIFF_MAX_LINES = 20_000;
62
+
63
+ /** Truncate oversized tool output (the model never needs the whole log). */
64
+ function cap(s) {
65
+ const text = String(s == null ? '' : s);
66
+ return text.length > OUTPUT_CAP
67
+ ? `${text.slice(0, OUTPUT_CAP)}\n… (truncated)`
68
+ : text;
69
+ }
70
+
71
+ const ok = (output) => ({ ok: true, output: cap(output) });
72
+ const fail = (error) => ({ ok: false, error: cap(error) });
73
+
74
+ // ── Schemas ─────────────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Canonical descriptions + parameters, in one place per tool. The two wire
78
+ * formats below are pure projections of this map, so a schema can never drift
79
+ * between providers (see the conversion test in test/local-tools.test.mjs).
80
+ */
81
+ const SCHEMAS = {
82
+ readFile: {
83
+ name: 'readFile',
84
+ description:
85
+ 'Reads a file from the local filesystem. The file_path parameter must be an absolute path. ' +
86
+ 'Returns line-numbered content.',
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ file_path: { type: 'string', description: 'The absolute path to the file to read' },
91
+ offset: { type: 'number', description: 'The line number to start reading from (0-based)' },
92
+ limit: { type: 'number', description: `The number of lines to read (max 10000, default ${READ_LINE_CAP})` },
93
+ },
94
+ required: ['file_path'],
95
+ additionalProperties: false,
96
+ },
97
+ },
98
+ writeFile: {
99
+ name: 'writeFile',
100
+ description:
101
+ 'Writes a file to the local filesystem. Parent directories are created automatically. ' +
102
+ 'Use it to create or replace a whole file; it overwrites whatever was there.',
103
+ parameters: {
104
+ type: 'object',
105
+ properties: {
106
+ file_path: { type: 'string', description: 'The absolute path to the file to write' },
107
+ content: { type: 'string', description: 'The full contents of the file' },
108
+ },
109
+ required: ['file_path', 'content'],
110
+ additionalProperties: false,
111
+ },
112
+ },
113
+ editFile: {
114
+ name: 'editFile',
115
+ description:
116
+ 'Performs an exact string replacement in a file. old_string must be unique in the file ' +
117
+ 'unless replace_all is true. Use this instead of writeFile when changing part of an ' +
118
+ 'existing file — it fails loudly on a non-unique or missing match instead of guessing.',
119
+ parameters: {
120
+ type: 'object',
121
+ properties: {
122
+ file_path: { type: 'string', description: 'The absolute path to the file to modify' },
123
+ old_string: { type: 'string', description: 'The text to replace (must be unique unless replace_all is true)' },
124
+ new_string: { type: 'string', description: 'The text to replace it with' },
125
+ replace_all: { type: 'boolean', description: 'If true, replace all occurrences of old_string' },
126
+ },
127
+ required: ['file_path', 'old_string', 'new_string'],
128
+ additionalProperties: false,
129
+ },
130
+ },
131
+ listDir: {
132
+ name: 'listDir',
133
+ description:
134
+ 'Lists one directory (non-recursive). Directories are marked with a trailing slash. ' +
135
+ 'Skips node_modules, .git and dist.',
136
+ parameters: {
137
+ type: 'object',
138
+ properties: {
139
+ path: { type: 'string', description: 'The directory to list (default: the working directory)' },
140
+ },
141
+ required: [],
142
+ additionalProperties: false,
143
+ },
144
+ },
145
+ glob: {
146
+ name: 'glob',
147
+ description:
148
+ 'Find files matching a glob pattern. Supports **, * and ?. Skips node_modules, .git and dist by default.',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {
152
+ pattern: { type: 'string', description: 'The glob pattern to match, e.g. "**/*.test.js"' },
153
+ path: { type: 'string', description: 'The directory to search from (default: the working directory)' },
154
+ },
155
+ required: ['pattern'],
156
+ additionalProperties: false,
157
+ },
158
+ },
159
+ grep: {
160
+ name: 'grep',
161
+ description:
162
+ 'Search file contents using a regular expression. Returns file:line matches. ' +
163
+ 'Skips node_modules, .git and dist by default.',
164
+ parameters: {
165
+ type: 'object',
166
+ properties: {
167
+ pattern: { type: 'string', description: 'The regular expression to search for' },
168
+ path: { type: 'string', description: 'The directory to search in (default: the working directory)' },
169
+ },
170
+ required: ['pattern'],
171
+ additionalProperties: false,
172
+ },
173
+ },
174
+ exec: {
175
+ name: 'exec',
176
+ description:
177
+ 'Executes a shell command in a persistent shell session and returns its combined ' +
178
+ 'stdout+stderr plus the exit code. State (cd, exported env vars) carries across calls ' +
179
+ 'within the same turn — it is a real session, not a fresh process each time. ' +
180
+ 'Use for system operations, git commands and package management.',
181
+ parameters: {
182
+ type: 'object',
183
+ properties: {
184
+ command: { type: 'string', description: 'The shell command to execute' },
185
+ cwd: { type: 'string', description: 'Run this one command in a different directory without moving the session (the session cwd is unchanged for later calls)' },
186
+ timeout: { type: 'number', description: `Timeout in milliseconds (max ${EXEC_TIMEOUT_CAP}, default ${EXEC_TIMEOUT_DEFAULT})` },
187
+ description: { type: 'string', description: 'A brief description of what the command does (for display)' },
188
+ },
189
+ required: ['command'],
190
+ additionalProperties: false,
191
+ },
192
+ },
193
+ task: {
194
+ name: 'task',
195
+ description:
196
+ 'Spawn a specialized subagent to autonomously handle a focused, multi-step sub-task. ' +
197
+ 'The subagent runs its own tool loop (readFile/writeFile/editFile/listDir/glob/grep/exec) ' +
198
+ 'on the same model and returns a final report as the tool result. Use it to delegate work ' +
199
+ 'like scanning for vulnerabilities, reviewing code, planning a refactor, or scaffolding a ' +
200
+ 'component — give it a complete, self-contained prompt since it cannot ask follow-up ' +
201
+ 'questions. Subagents can delegate further with task, so a large job can be split ' +
202
+ 'hierarchically as deep as useful.',
203
+ parameters: {
204
+ type: 'object',
205
+ properties: {
206
+ description: { type: 'string', description: 'A short (3-5 word) description of the sub-task' },
207
+ subagent_type: {
208
+ type: 'string',
209
+ enum: [...agentRoles(), 'general'],
210
+ description: 'Which specialist preset to spawn (general = a capable all-purpose agent)',
211
+ },
212
+ prompt: { type: 'string', description: 'The full, self-contained task instructions for the subagent' },
213
+ },
214
+ required: ['description', 'prompt'],
215
+ additionalProperties: false,
216
+ },
217
+ },
218
+ };
219
+
220
+ // task is executed by the chat loop (it needs to run the model), not by the
221
+ // local executors below — advertised in the schemas but handled in
222
+ // engine.js's chat(). Subagents get task too, so delegation can nest —
223
+ // engine.js drops it (via includeSubagent) once the delegation chain passes
224
+ // MAX_SUBAGENT_DEPTH, hard-bounding runaway recursion.
225
+ const SUBAGENT_TOOL = 'task';
226
+
227
+ /** Tool names in advertisement order. `includeSubagent: false` drops task (depth cap). */
228
+ function toolNames({ includeSubagent = true } = {}) {
229
+ return schemaList(includeSubagent).map((s) => s.name);
230
+ }
231
+
232
+ function schemaList(includeSubagent) {
233
+ return Object.values(SCHEMAS).filter((s) => includeSubagent || s.name !== SUBAGENT_TOOL);
234
+ }
235
+
236
+ /** Anthropic Messages API tool definitions ({name, description, input_schema}). */
237
+ function anthropicTools({ includeSubagent = true } = {}) {
238
+ return schemaList(includeSubagent).map((s) => ({
239
+ name: s.name,
240
+ description: s.description,
241
+ input_schema: s.parameters,
242
+ }));
243
+ }
244
+
245
+ /** OpenAI-compatible /chat/completions tool definitions ({type:'function', function}). */
246
+ function openaiTools({ includeSubagent = true } = {}) {
247
+ return schemaList(includeSubagent).map((s) => ({
248
+ type: 'function',
249
+ function: { name: s.name, description: s.description, parameters: s.parameters },
250
+ }));
251
+ }
252
+
253
+ /**
254
+ * The advertised tool list for one wire format. `wire` is 'anthropic' or
255
+ * anything else (treated as OpenAI-compatible) — the same split the transport
256
+ * layer uses. `includeSubagent: false` drops the task tool (subagent depth cap).
257
+ */
258
+ function toolsFor(wire, { includeSubagent = true } = {}) {
259
+ return wire === 'anthropic' ? anthropicTools({ includeSubagent }) : openaiTools({ includeSubagent });
260
+ }
261
+
262
+ /**
263
+ * Convert an OpenAI-format tool list into Anthropic's. Exported because it is
264
+ * the exact transformation the loop depends on (and it is unit-tested as
265
+ * such): an endpoint that speaks Anthropic must never be handed
266
+ * `{type:'function', function:{…}}`.
267
+ */
268
+ function openaiToAnthropicTools(tools) {
269
+ if (!Array.isArray(tools)) return [];
270
+ return tools
271
+ .map((t) => {
272
+ const fn = (t && t.function) || t || {};
273
+ if (!fn.name) return null;
274
+ return {
275
+ name: fn.name,
276
+ description: fn.description || '',
277
+ input_schema: fn.parameters || { type: 'object', properties: {} },
278
+ };
279
+ })
280
+ .filter(Boolean);
281
+ }
282
+
283
+ /** Reverse projection (Anthropic → OpenAI), for symmetry/completeness. */
284
+ function anthropicToOpenaiTools(tools) {
285
+ if (!Array.isArray(tools)) return [];
286
+ return tools
287
+ .map((t) => {
288
+ if (!t || !t.name) return null;
289
+ return {
290
+ type: 'function',
291
+ function: {
292
+ name: t.name,
293
+ description: t.description || '',
294
+ parameters: t.input_schema || { type: 'object', properties: {} },
295
+ },
296
+ };
297
+ })
298
+ .filter(Boolean);
299
+ }
300
+
301
+ // ── Executors ───────────────────────────────────────────────────────────────
302
+
303
+ function readFile({ file_path, offset = 0, limit } = {}) {
304
+ try {
305
+ if (!file_path) return fail('file_path is required');
306
+ const stat = fs.statSync(file_path);
307
+ if (stat.isDirectory()) return fail(`${file_path} is a directory (use listDir)`);
308
+ if (stat.size > READ_SIZE_CAP) {
309
+ const mb = (stat.size / 1048576).toFixed(1);
310
+ return fail(
311
+ `${file_path} is ${mb} MB — too large to read (limit ${READ_SIZE_CAP / 1048576} MB). ` +
312
+ 'Use exec with grep/head to inspect it instead.'
313
+ );
314
+ }
315
+ const lines = fs.readFileSync(file_path, 'utf8').split('\n');
316
+ const start = Math.max(0, Number(offset) || 0);
317
+ const count = Math.max(1, Math.min(Number(limit) || READ_LINE_CAP, 10_000));
318
+ const picked = lines.slice(start, start + count);
319
+ const numbered = picked.map((l, i) => `${i + start + 1}| ${l}`).join('\n');
320
+ const tail =
321
+ start + picked.length < lines.length
322
+ ? `\n… (${lines.length - start - picked.length} more lines)`
323
+ : '';
324
+ return ok(numbered + tail);
325
+ } catch (e) {
326
+ return fail(e && e.message ? e.message : String(e));
327
+ }
328
+ }
329
+
330
+ function writeFile({ file_path, content } = {}) {
331
+ try {
332
+ if (!file_path) return fail('file_path is required');
333
+ fs.mkdirSync(path.dirname(file_path), { recursive: true });
334
+ fs.writeFileSync(file_path, String(content == null ? '' : content), 'utf8');
335
+ return ok(`Wrote ${String(content == null ? '' : content).length} bytes to ${file_path}`);
336
+ } catch (e) {
337
+ return fail(e && e.message ? e.message : String(e));
338
+ }
339
+ }
340
+
341
+ function editFile(args = {}) {
342
+ try {
343
+ const preview = previewEditFile(args);
344
+ if (!preview.ok) return fail(preview.error);
345
+ fs.writeFileSync(args.file_path, preview.after, 'utf8');
346
+ return ok(`Edited ${args.file_path} (${preview.count} occurrence${preview.count > 1 ? 's' : ''} replaced)`);
347
+ } catch (e) {
348
+ return fail(e && e.message ? e.message : String(e));
349
+ }
350
+ }
351
+
352
+ // ── Approval-gate helpers ───────────────────────────────────────────────────
353
+ //
354
+ // The renderer approval gate (desktop/lib/local/engine.js gatedExecuteTool)
355
+ // needs to show the user a diff BEFORE a mutating call runs, then re-verify
356
+ // the file hasn't moved out from under it before actually writing. Everything
357
+ // below is pure preparation: it never writes to disk on its own except
358
+ // applyWriteChecked/applyEditChecked, which are the only functions engine.js
359
+ // calls once the user has approved.
360
+
361
+ /** exec/writeFile/editFile change machine state; listDir/glob/grep/readFile
362
+ * never do — this is the set the approval gate checks against. */
363
+ const MUTATING_TOOLS = new Set(['exec', 'writeFile', 'editFile']);
364
+
365
+ function sha256(text) {
366
+ return crypto.createHash('sha256').update(text == null ? '' : text, 'utf8').digest('hex');
367
+ }
368
+
369
+ /** Read a file for a preview/hash snapshot. Distinguishes "doesn't exist yet"
370
+ * (ok, exists:false) from a real read failure (permissions, is a directory —
371
+ * ok:false), so callers can tell a brand-new file from a broken path. */
372
+ function readForPreview(file_path) {
373
+ try {
374
+ if (!fs.existsSync(file_path)) return { ok: true, exists: false, content: null };
375
+ if (fs.statSync(file_path).isDirectory()) {
376
+ return { ok: false, error: `${file_path} is a directory` };
377
+ }
378
+ return { ok: true, exists: true, content: fs.readFileSync(file_path, 'utf8') };
379
+ } catch (e) {
380
+ return { ok: false, error: e && e.message ? e.message : String(e) };
381
+ }
382
+ }
383
+
384
+ /** Classic LCS line diff: returns the {type:'equal'|'delete'|'insert', a, b}
385
+ * op list turning array `a` into array `b`, indices into each array. */
386
+ function diffLines(a, b) {
387
+ const n = a.length;
388
+ const m = b.length;
389
+ const dp = new Array(n + 1);
390
+ for (let i = 0; i <= n; i++) dp[i] = new Int32Array(m + 1);
391
+ for (let i = n - 1; i >= 0; i--) {
392
+ for (let j = m - 1; j >= 0; j--) {
393
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
394
+ }
395
+ }
396
+ const ops = [];
397
+ let i = 0;
398
+ let j = 0;
399
+ while (i < n && j < m) {
400
+ if (a[i] === b[j]) {
401
+ ops.push({ type: 'equal', a: i, b: j });
402
+ i++;
403
+ j++;
404
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
405
+ ops.push({ type: 'delete', a: i });
406
+ i++;
407
+ } else {
408
+ ops.push({ type: 'insert', b: j });
409
+ j++;
410
+ }
411
+ }
412
+ while (i < n) ops.push({ type: 'delete', a: i++ });
413
+ while (j < m) ops.push({ type: 'insert', b: j++ });
414
+ return ops;
415
+ }
416
+
417
+ /** Group an op list into unified-diff hunks (git diff -Ucontext style) and
418
+ * render them as text with a/b line numbers in the @@ headers. */
419
+ function formatUnifiedDiff(ops, a, b, { label, context }) {
420
+ const n = ops.length;
421
+ const keep = new Array(n).fill(false);
422
+ for (let i = 0; i < n; i++) {
423
+ if (ops[i].type === 'equal') continue;
424
+ keep[i] = true;
425
+ for (let k = 1; k <= context; k++) {
426
+ if (i - k >= 0) keep[i - k] = true;
427
+ if (i + k < n) keep[i + k] = true;
428
+ }
429
+ }
430
+ const hunkRanges = [];
431
+ let start = -1;
432
+ for (let i = 0; i <= n; i++) {
433
+ if (i < n && keep[i]) {
434
+ if (start === -1) start = i;
435
+ } else if (start !== -1) {
436
+ hunkRanges.push([start, i - 1]);
437
+ start = -1;
438
+ }
439
+ }
440
+ if (!hunkRanges.length) return `--- ${label}\n+++ ${label}\n(no changes)`;
441
+
442
+ const out = [`--- ${label}`, `+++ ${label}`];
443
+ for (const [s, e] of hunkRanges) {
444
+ let aLine = 1;
445
+ let bLine = 1;
446
+ for (let i = 0; i < s; i++) {
447
+ if (ops[i].type !== 'insert') aLine++;
448
+ if (ops[i].type !== 'delete') bLine++;
449
+ }
450
+ let aCount = 0;
451
+ let bCount = 0;
452
+ const body = [];
453
+ for (let i = s; i <= e; i++) {
454
+ const op = ops[i];
455
+ if (op.type === 'equal') {
456
+ body.push(` ${a[op.a]}`);
457
+ aCount++;
458
+ bCount++;
459
+ } else if (op.type === 'delete') {
460
+ body.push(`-${a[op.a]}`);
461
+ aCount++;
462
+ } else {
463
+ body.push(`+${b[op.b]}`);
464
+ bCount++;
465
+ }
466
+ }
467
+ out.push(`@@ -${aLine},${aCount} +${bLine},${bCount} @@`);
468
+ out.push(...body);
469
+ }
470
+ return out.join('\n');
471
+ }
472
+
473
+ /** Unified diff between two whole-file strings (`before` may be null — a new
474
+ * file). Falls back to a one-line summary for files too large to diff cheaply. */
475
+ function unifiedDiff(before, after, { label = 'file', context = 3 } = {}) {
476
+ const a = before == null ? [] : String(before).split('\n');
477
+ const b = after == null ? [] : String(after).split('\n');
478
+ if (a.length > DIFF_MAX_LINES || b.length > DIFF_MAX_LINES || a.length * b.length > DIFF_MAX_CELLS) {
479
+ const added = Math.max(0, b.length - a.length);
480
+ const removed = Math.max(0, a.length - b.length);
481
+ return `--- ${label}\n+++ ${label}\n@@ file too large to preview — approx +${added}/-${removed} lines @@`;
482
+ }
483
+ return formatUnifiedDiff(diffLines(a, b), a, b, { label, context });
484
+ }
485
+
486
+ /** Build the {before, after, diff, hash} preview for a writeFile call without
487
+ * touching disk. `hash` is the sha256 of the CURRENT on-disk content (null
488
+ * for a not-yet-existing file) — the snapshot applyWriteChecked re-verifies
489
+ * against before actually writing. */
490
+ function previewWriteFile({ file_path, content } = {}) {
491
+ if (!file_path) return { ok: false, error: 'file_path is required' };
492
+ const read = readForPreview(file_path);
493
+ if (!read.ok) return { ok: false, error: read.error };
494
+ const before = read.exists ? read.content : null;
495
+ const after = String(content == null ? '' : content);
496
+ return {
497
+ ok: true,
498
+ before,
499
+ after,
500
+ diff: unifiedDiff(before, after, { label: file_path }),
501
+ hash: before == null ? null : sha256(before),
502
+ };
503
+ }
504
+
505
+ /** Same shape as previewWriteFile, for editFile — shares its validation with
506
+ * the executor above so there is exactly one place that knows how to apply
507
+ * an edit. */
508
+ function previewEditFile({ file_path, old_string, new_string, replace_all } = {}) {
509
+ if (!file_path) return { ok: false, error: 'file_path is required' };
510
+ if (old_string === undefined || old_string === '') {
511
+ return { ok: false, error: 'old_string is required and must be non-empty' };
512
+ }
513
+ const read = readForPreview(file_path);
514
+ if (!read.ok) return { ok: false, error: read.error };
515
+ if (!read.exists) return { ok: false, error: `${file_path} does not exist` };
516
+ const before = read.content;
517
+ const count = before.split(old_string).length - 1;
518
+ if (count === 0) return { ok: false, error: `old_string not found in ${file_path}` };
519
+ if (count > 1 && !replace_all) {
520
+ return { ok: false, error: `old_string is not unique (${count} matches) — use replace_all or more context` };
521
+ }
522
+ const after = replace_all
523
+ ? before.split(old_string).join(new_string == null ? '' : new_string)
524
+ : before.replace(old_string, new_string == null ? '' : new_string);
525
+ return {
526
+ ok: true,
527
+ before,
528
+ after,
529
+ diff: unifiedDiff(before, after, { label: file_path }),
530
+ hash: sha256(before),
531
+ count,
532
+ };
533
+ }
534
+
535
+ /** Dispatch a preview by tool name. Only writeFile/editFile have one — exec
536
+ * has nothing to diff, and the approval gate skips this call for it. */
537
+ function previewMutation(name, args) {
538
+ if (name === 'writeFile') return previewWriteFile(args);
539
+ if (name === 'editFile') return previewEditFile(args);
540
+ return { ok: false, error: `no diff preview for ${name}` };
541
+ }
542
+
543
+ /** Apply a writeFile the user has approved, but only if the file on disk
544
+ * still matches the hash captured at preview time — otherwise something
545
+ * else changed it while the approval card was open, and applying blind
546
+ * would silently clobber that change. */
547
+ function applyWriteChecked({ file_path, content } = {}, expectedHash) {
548
+ try {
549
+ if (!file_path) return fail('file_path is required');
550
+ const read = readForPreview(file_path);
551
+ if (!read.ok) return fail(read.error);
552
+ const currentHash = read.exists ? sha256(read.content) : null;
553
+ if (currentHash !== expectedHash) {
554
+ return fail(
555
+ `${file_path} changed on disk since the diff was shown — refusing to apply a stale write. Re-run writeFile to get an updated diff.`
556
+ );
557
+ }
558
+ fs.mkdirSync(path.dirname(file_path), { recursive: true });
559
+ fs.writeFileSync(file_path, String(content == null ? '' : content), 'utf8');
560
+ return ok(`Wrote ${String(content == null ? '' : content).length} bytes to ${file_path}`);
561
+ } catch (e) {
562
+ return fail(e && e.message ? e.message : String(e));
563
+ }
564
+ }
565
+
566
+ /** Same guard as applyWriteChecked, for an already-computed editFile result
567
+ * (`after` — the preview's replacement, not recomputed here since a hash
568
+ * match means the source it was computed from is still exactly on disk). */
569
+ function applyEditChecked({ file_path, after } = {}, expectedHash) {
570
+ try {
571
+ if (!file_path) return fail('file_path is required');
572
+ const read = readForPreview(file_path);
573
+ if (!read.ok) return fail(read.error);
574
+ const currentHash = read.exists ? sha256(read.content) : null;
575
+ if (currentHash !== expectedHash) {
576
+ return fail(
577
+ `${file_path} changed on disk since the diff was shown — refusing to apply a stale edit. Re-run editFile to get an updated diff.`
578
+ );
579
+ }
580
+ fs.writeFileSync(file_path, after, 'utf8');
581
+ return ok(`Edited ${file_path}`);
582
+ } catch (e) {
583
+ return fail(e && e.message ? e.message : String(e));
584
+ }
585
+ }
586
+
587
+ /** Apply an approved writeFile/editFile call using the hash captured in its
588
+ * `preview` (see previewMutation) — the single entry point engine.js calls
589
+ * once the user has said yes. */
590
+ function applyChecked(name, args, preview) {
591
+ if (name === 'writeFile') return applyWriteChecked(args, preview.hash);
592
+ return applyEditChecked({ file_path: args.file_path, after: preview.after }, preview.hash);
593
+ }
594
+
595
+ const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.aegiscode']);
596
+
597
+ function listDir({ path: dir } = {}) {
598
+ try {
599
+ const base = dir || process.cwd();
600
+ const entries = fs.readdirSync(base, { withFileTypes: true });
601
+ const rows = entries
602
+ .filter((e) => !IGNORED_DIRS.has(e.name))
603
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
604
+ .sort((a, b) => a.localeCompare(b));
605
+ return ok(rows.join('\n') || '(empty directory)');
606
+ } catch (e) {
607
+ return fail(e && e.message ? e.message : String(e));
608
+ }
609
+ }
610
+
611
+ /** Translate a glob into a regex over '/'-separated relative paths. */
612
+ function globToRegex(pattern) {
613
+ let re = '^';
614
+ for (let i = 0; i < pattern.length; i++) {
615
+ const c = pattern[i];
616
+ if (c === '*') {
617
+ if (pattern[i + 1] === '*') {
618
+ re += '.*';
619
+ i++;
620
+ } else re += '[^/]*';
621
+ } else if (c === '?') re += '[^/]';
622
+ else if (/[.+^${}()|[\]\\]/.test(c)) re += `\\${c}`;
623
+ else re += c;
624
+ }
625
+ return new RegExp(re + '$');
626
+ }
627
+
628
+ function walk(dir, fn, depth = 0) {
629
+ if (depth > 12) return; // hard bound: never crawl an unbounded tree
630
+ let entries;
631
+ try {
632
+ entries = fs.readdirSync(dir, { withFileTypes: true });
633
+ } catch {
634
+ return;
635
+ }
636
+ for (const e of entries) {
637
+ if (IGNORED_DIRS.has(e.name)) continue;
638
+ const full = path.join(dir, e.name);
639
+ if (e.isDirectory()) walk(full, fn, depth + 1);
640
+ else fn(full);
641
+ }
642
+ }
643
+
644
+ function glob({ pattern, path: root } = {}) {
645
+ try {
646
+ if (!pattern) return fail('pattern is required');
647
+ const base = root || process.cwd();
648
+ const re = globToRegex(String(pattern));
649
+ const hits = [];
650
+ walk(base, (full) => {
651
+ if (hits.length >= MATCH_CAP) return;
652
+ const rel = path.relative(base, full).split(path.sep).join('/');
653
+ if (re.test(rel)) hits.push(rel);
654
+ });
655
+ return ok(hits.join('\n') || '(no matches)');
656
+ } catch (e) {
657
+ return fail(e && e.message ? e.message : String(e));
658
+ }
659
+ }
660
+
661
+ function grep({ pattern, path: root } = {}) {
662
+ try {
663
+ if (!pattern) return fail('pattern is required');
664
+ const re = new RegExp(pattern);
665
+ const base = root || process.cwd();
666
+ const hits = [];
667
+ walk(base, (full) => {
668
+ if (hits.length >= MATCH_CAP) return;
669
+ let text;
670
+ try {
671
+ // Skip oversized files (logs, dumps, binaries) instead of slurping
672
+ // them — a 10 GB log would otherwise stall the whole tool loop.
673
+ if (fs.statSync(full).size > GREP_SIZE_CAP) return;
674
+ text = fs.readFileSync(full, 'utf8');
675
+ } catch {
676
+ return;
677
+ }
678
+ const rel = path.relative(base, full).split(path.sep).join('/');
679
+ for (const [i, line] of text.split('\n').entries()) {
680
+ if (hits.length >= MATCH_CAP) return;
681
+ if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.slice(0, 160)}`);
682
+ }
683
+ });
684
+ return ok(hits.join('\n') || '(no matches)');
685
+ } catch (e) {
686
+ return fail(e && e.message ? e.message : String(e));
687
+ }
688
+ }
689
+
690
+ /** Shell to run `exec` in: cmd.exe on Windows, $SHELL (or /bin/sh) elsewhere. */
691
+ function shellSpec() {
692
+ if (process.platform === 'win32') {
693
+ return { cmd: process.env.ComSpec || 'cmd.exe', arg: '/d /s /c' };
694
+ }
695
+ return { cmd: process.env.SHELL || '/bin/sh', arg: '-c' };
696
+ }
697
+
698
+ /**
699
+ * Run a shell command as a fresh one-shot process (no state carried to the
700
+ * next call). Never rejects: spawn failures, a stalled child, a non-zero
701
+ * exit and output overflow all resolve as `{ ok:false, error }` (a non-zero
702
+ * exit is reported as an error so the model sees the failure, with whatever
703
+ * the command printed attached).
704
+ */
705
+ function execOneShot({ command, cwd, timeout, maxBuffer } = {}) {
706
+ return new Promise((resolve) => {
707
+ const limit = Math.min(Number(maxBuffer) || EXEC_MAX_BUFFER, EXEC_MAX_BUFFER);
708
+ const ms = Math.min(Number(timeout) || EXEC_TIMEOUT_DEFAULT, EXEC_TIMEOUT_CAP);
709
+ const { cmd, arg } = shellSpec();
710
+
711
+ let child;
712
+ try {
713
+ child = spawn(cmd, [arg, command], {
714
+ cwd: cwd || process.cwd(),
715
+ timeout: ms,
716
+ killSignal: 'SIGKILL',
717
+ windowsHide: true,
718
+ });
719
+ } catch (e) {
720
+ resolve(fail(`spawn failed: ${e && e.message ? e.message : e}`));
721
+ return;
722
+ }
723
+
724
+ let out = '';
725
+ let bytes = 0;
726
+ let overflow = false;
727
+ let timedOut = false;
728
+ let settled = false;
729
+
730
+ const timer = setTimeout(() => {
731
+ timedOut = true;
732
+ try {
733
+ child.kill('SIGKILL');
734
+ } catch {
735
+ /* already gone */
736
+ }
737
+ }, ms);
738
+
739
+ const collect = (chunk) => {
740
+ bytes += chunk.length;
741
+ if (bytes > limit) {
742
+ overflow = true;
743
+ try {
744
+ child.kill('SIGKILL');
745
+ } catch {
746
+ /* already gone */
747
+ }
748
+ return;
749
+ }
750
+ out += chunk;
751
+ };
752
+
753
+ if (child.stdout) child.stdout.on('data', collect);
754
+ if (child.stderr) child.stderr.on('data', collect);
755
+
756
+ const done = (res) => {
757
+ if (settled) return;
758
+ settled = true;
759
+ clearTimeout(timer);
760
+ resolve(res);
761
+ };
762
+
763
+ child.on('error', (e) => done(fail(`spawn failed: ${e && e.message ? e.message : e}`)));
764
+ child.on('close', (code, signal) => {
765
+ const body = out.trim();
766
+ if (timedOut) {
767
+ done(fail(`command timed out after ${ms}ms${body ? `\n${body}` : ''}`));
768
+ return;
769
+ }
770
+ if (overflow) {
771
+ // The buffer cap is what the model should see: trimming to OUTPUT_CAP
772
+ // below would hide the fact that output was dropped.
773
+ done(fail(`output exceeded ${limit} bytes and was truncated\n${body}`));
774
+ return;
775
+ }
776
+ const label = `exit ${code == null ? `signal ${signal}` : code}`;
777
+ if (code === 0) done(ok(body || `(${label}, no output)`));
778
+ else done(fail(`${label}${body ? `\n${body}` : ''}`));
779
+ });
780
+ });
781
+ }
782
+
783
+ /**
784
+ * Run a persistent-session command and translate the session's { content,
785
+ * isError } shape into this file's { ok, output } / { ok:false, error }
786
+ * convention. An abort signal disposes the session immediately instead of
787
+ * waiting out the command's own timeout.
788
+ */
789
+ function execInShell(shell, { command, cwd, timeout }, signal) {
790
+ if (signal && signal.aborted) {
791
+ shell.dispose();
792
+ return Promise.resolve(fail('aborted'));
793
+ }
794
+ return new Promise((resolve) => {
795
+ const onAbort = () => shell.dispose();
796
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
797
+ const release = () => { if (signal) signal.removeEventListener('abort', onAbort); };
798
+ shell.run(command, { timeout, working_directory: cwd }).then(
799
+ (r) => { release(); resolve(r.isError ? fail(r.content) : ok(r.content)); },
800
+ (e) => { release(); resolve(fail(`shell session failed: ${e && e.message ? e.message : e}`)); }
801
+ );
802
+ });
803
+ }
804
+
805
+ /**
806
+ * Execute a shell command. When the caller supplies `ctx.getShell` (the
807
+ * chat loop always does — see engine.js), the command runs in that turn's
808
+ * persistent session so cd/export/env state carries to the next call. Without
809
+ * one (e.g. a direct executeTool call in tests) it falls back to a one-shot
810
+ * spawn — same output shape, no persisted state. Never rejects.
811
+ */
812
+ function exec({ command, cwd, timeout, maxBuffer } = {}, ctx = {}) {
813
+ if (!command || typeof command !== 'string') {
814
+ return Promise.resolve(fail('command is required'));
815
+ }
816
+ const shell = typeof ctx.getShell === 'function' ? ctx.getShell() : null;
817
+ if (shell) return execInShell(shell, { command, cwd, timeout }, ctx.signal);
818
+ return execOneShot({ command, cwd, timeout, maxBuffer });
819
+ }
820
+
821
+ const EXECUTORS = { readFile, writeFile, editFile, listDir, glob, grep, exec };
822
+
823
+ /** True when `name` is a tool this layer can run (task is excluded — see SUBAGENT_TOOL). */
824
+ function isTool(name) {
825
+ return Object.prototype.hasOwnProperty.call(EXECUTORS, name);
826
+ }
827
+
828
+ /**
829
+ * Execute one tool call. Always resolves `{ ok, output }` or `{ ok, error }`
830
+ * — an unknown tool name, a non-object args payload and an exploding executor
831
+ * all land on the error branch rather than rejecting. `ctx` carries per-turn
832
+ * state (getShell, signal); omit it and exec falls back to a one-shot spawn.
833
+ */
834
+ async function executeTool(name, args, ctx) {
835
+ if (!isTool(name)) return fail(`unknown tool "${name}" (known: ${toolNames().join(', ')})`);
836
+ const input = args && typeof args === 'object' ? args : {};
837
+ try {
838
+ return await EXECUTORS[name](input, ctx || {});
839
+ } catch (e) {
840
+ return fail(e && e.message ? e.message : String(e));
841
+ }
842
+ }
843
+
844
+ /**
845
+ * The string a tool result contributes to the conversation: the output on
846
+ * success, `error: …` on failure — the same shape the CLI feeds back.
847
+ */
848
+ function toolResultText(result) {
849
+ if (!result) return 'error: tool produced no result';
850
+ return result.ok ? String(result.output == null ? '' : result.output) : `error: ${result.error}`;
851
+ }
852
+
853
+ module.exports = {
854
+ // schemas
855
+ SCHEMAS,
856
+ SUBAGENT_TOOL,
857
+ toolNames,
858
+ anthropicTools,
859
+ openaiTools,
860
+ toolsFor,
861
+ openaiToAnthropicTools,
862
+ anthropicToOpenaiTools,
863
+ // execution
864
+ executeTool,
865
+ isTool,
866
+ toolResultText,
867
+ // approval gate (desktop/lib/local/engine.js gatedExecuteTool)
868
+ MUTATING_TOOLS,
869
+ previewMutation,
870
+ applyChecked,
871
+ // limits (unit tests assert against them instead of hard-coding numbers)
872
+ OUTPUT_CAP,
873
+ READ_LINE_CAP,
874
+ MATCH_CAP,
875
+ READ_SIZE_CAP,
876
+ GREP_SIZE_CAP,
877
+ EXEC_TIMEOUT_DEFAULT,
878
+ EXEC_TIMEOUT_CAP,
879
+ EXEC_MAX_BUFFER,
880
+ DIFF_MAX_CELLS,
881
+ DIFF_MAX_LINES,
882
+ };