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.
- package/README.md +121 -78
- package/bin/aegiscode.js +9 -1
- package/package.json +3 -3
- package/scripts/predist.mjs +11 -1
- package/src/agents.js +136 -0
- package/src/app.js +522 -164
- package/src/chatflow.js +1475 -0
- package/src/checkpoint.js +85 -0
- package/src/clipboard.js +62 -0
- package/src/commands.js +1234 -150
- package/src/config.js +163 -0
- package/src/deps.js +14 -1
- package/src/devrun.js +110 -0
- package/src/engine.js +62 -0
- package/src/events.js +278 -0
- package/src/export.js +64 -0
- package/src/history.js +201 -0
- package/src/init.js +162 -0
- package/src/input.js +136 -0
- package/src/keys.js +141 -0
- package/src/panels.js +1171 -0
- package/src/permissions.js +102 -0
- package/src/render.js +33 -1
- package/src/summarize.js +90 -0
- package/src/system.js +37 -0
- package/src/tokens.js +166 -0
- package/vendor/desktop/lib/local/agents.js +102 -0
- package/vendor/desktop/lib/local/engine.js +972 -0
- package/vendor/desktop/lib/local/prompt.js +91 -0
- package/vendor/desktop/lib/local/shell.js +208 -0
- package/vendor/desktop/lib/local/tools.js +882 -0
|
@@ -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
|
+
};
|