@nexrall/code-core 1.0.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/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/agent/agentTypes.d.ts +17 -0
- package/dist/agent/agentTypes.d.ts.map +1 -0
- package/dist/agent/agentTypes.js +156 -0
- package/dist/agent/loop.d.ts +3 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +775 -0
- package/dist/api/client.d.ts +27 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +414 -0
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +95 -0
- package/dist/checkpoint/manager.d.ts +86 -0
- package/dist/checkpoint/manager.d.ts.map +1 -0
- package/dist/checkpoint/manager.js +409 -0
- package/dist/commands/loader.d.ts +18 -0
- package/dist/commands/loader.d.ts.map +1 -0
- package/dist/commands/loader.js +183 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/mcp/client.d.ts +46 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +128 -0
- package/dist/mcp/httpClient.d.ts +25 -0
- package/dist/mcp/httpClient.d.ts.map +1 -0
- package/dist/mcp/httpClient.js +143 -0
- package/dist/mcp/manager.d.ts +56 -0
- package/dist/mcp/manager.d.ts.map +1 -0
- package/dist/mcp/manager.js +234 -0
- package/dist/mcp/sseClient.d.ts +30 -0
- package/dist/mcp/sseClient.d.ts.map +1 -0
- package/dist/mcp/sseClient.js +224 -0
- package/dist/permissions/rules.d.ts +20 -0
- package/dist/permissions/rules.d.ts.map +1 -0
- package/dist/permissions/rules.js +218 -0
- package/dist/plugins/index.d.ts +18 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +139 -0
- package/dist/tools/executor.d.ts +6 -0
- package/dist/tools/executor.d.ts.map +1 -0
- package/dist/tools/executor.js +1458 -0
- package/dist/tools/sandbox.d.ts +13 -0
- package/dist/tools/sandbox.d.ts.map +1 -0
- package/dist/tools/sandbox.js +140 -0
- package/dist/tools/symbols.d.ts +13 -0
- package/dist/tools/symbols.d.ts.map +1 -0
- package/dist/tools/symbols.js +279 -0
- package/dist/types.d.ts +186 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +92 -0
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.executeTool = executeTool;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const https = __importStar(require("https"));
|
|
41
|
+
const http = __importStar(require("http"));
|
|
42
|
+
const child_process_1 = require("child_process");
|
|
43
|
+
const sandbox_1 = require("./sandbox");
|
|
44
|
+
const auth_1 = require("../auth");
|
|
45
|
+
const client_1 = require("../api/client");
|
|
46
|
+
const symbols_1 = require("./symbols");
|
|
47
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
48
|
+
const DEFAULT_TIMEOUT_MS = 60000;
|
|
49
|
+
const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
|
|
50
|
+
const MAX_READ_BYTES = 500 * 1024; // 500 KB
|
|
51
|
+
const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
|
|
52
|
+
// ─── Blocked commands (safety) ────────────────────────────────────────────────
|
|
53
|
+
// Regexes are tested against the LOWERCASED command so case variants and minor
|
|
54
|
+
// spacing differences (double spaces, no-space pipes) are all caught.
|
|
55
|
+
// Tier 1 — destructive patterns matched against the FULL lowercased command,
|
|
56
|
+
// including data sections (quotes, heredoc bodies). These are specific enough
|
|
57
|
+
// that false positives are rare, and testing the full string means a payload
|
|
58
|
+
// hidden in a fake heredoc/quote (e.g. `echo "<<EOF"\nrm -rf /\nEOF`) is still
|
|
59
|
+
// caught.
|
|
60
|
+
const BLOCKED_REGEXES = [
|
|
61
|
+
// Filesystem destruction — match rm/chmod with any whitespace between flags
|
|
62
|
+
/rm\s+-rf\s+\//, // rm -rf /… (any path under /)
|
|
63
|
+
/rm\s+-rf\s+~/, // rm -rf ~
|
|
64
|
+
/rm\s+-rf\s+\*/, // rm -rf *
|
|
65
|
+
/rm\s+--no-preserve-root/,
|
|
66
|
+
/chmod\s+-[rr]\s+777\s+\//,
|
|
67
|
+
/chown\s+-[rr]/,
|
|
68
|
+
// Disk/device overwrite
|
|
69
|
+
/dd\s+if=\/dev\/(zero|random|urandom)/,
|
|
70
|
+
// Fork bomb
|
|
71
|
+
/:\(\)\s*\{.*:\|:&/,
|
|
72
|
+
// Pipe execution from network. [^|\n]* between the fetcher and the pipe
|
|
73
|
+
// covers the URL/flags ("curl https://x.sh | sh"), which the old \s*-only
|
|
74
|
+
// version missed entirely; no-space variants (curl|bash) are still caught.
|
|
75
|
+
/curl[^|\n]*\|\s*(bash|sh|python\d*|node|perl|ruby)\b/,
|
|
76
|
+
/wget[^|\n]*\|\s*(bash|sh|python\d*|node|perl|ruby)\b/,
|
|
77
|
+
/curl\s+.*-[oO]\s*-.*\|\s*(bash|sh)/, // curl -o - … | bash
|
|
78
|
+
// Credential theft
|
|
79
|
+
/cat\s+\/etc\/(shadow|passwd)/,
|
|
80
|
+
];
|
|
81
|
+
// Tier 2 — words that are dangerous only when INVOKED as a command. A plain
|
|
82
|
+
// \b-word match caused constant false positives once heredocs were allowed:
|
|
83
|
+
// `grep reboot README.md`, `git log --grep shutdown`, or a heredoc body that
|
|
84
|
+
// merely MENTIONS "mkfs" all got blocked. These are now matched only:
|
|
85
|
+
// 1. after data sections (heredoc bodies, quoted strings) are stripped, and
|
|
86
|
+
// 2. at a command position — start of string/line, after ; & | ` ( $( or a
|
|
87
|
+
// privilege-escalation prefix (sudo/doas/exec).
|
|
88
|
+
const BLOCKED_COMMAND_WORDS = /(?:^|[;&|`\n(]|\$\(|\bsudo\s+|\bdoas\s+|\bexec\s+)\s*(shutdown|reboot|halt|poweroff|mkfs|fdisk|shred)\b/;
|
|
89
|
+
// Remove shell data sections so tier-2 word rules only see command positions.
|
|
90
|
+
// Heredoc bodies first (<<TAG / <<-TAG / <<'TAG' / <<"TAG" … up to the line
|
|
91
|
+
// that equals TAG), then double- and single-quoted strings. Intentionally
|
|
92
|
+
// naive — this feeds a blocklist heuristic, not an executor.
|
|
93
|
+
function stripDataSections(command) {
|
|
94
|
+
return command
|
|
95
|
+
// Heredoc: the (['"]?) quote group always participates, so the \2 tag
|
|
96
|
+
// backreference is safe (JS backrefs to non-participating groups match "").
|
|
97
|
+
.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\2(?=\s|;|&|$)/g, ' ')
|
|
98
|
+
.replace(/"(?:[^"\\]|\\[\s\S])*"/g, '""')
|
|
99
|
+
.replace(/'[^']*'/g, "''");
|
|
100
|
+
}
|
|
101
|
+
function isCommandBlocked(command) {
|
|
102
|
+
const lower = command.toLowerCase();
|
|
103
|
+
for (const re of BLOCKED_REGEXES) {
|
|
104
|
+
if (re.test(lower))
|
|
105
|
+
return re.toString();
|
|
106
|
+
}
|
|
107
|
+
if (BLOCKED_COMMAND_WORDS.test(stripDataSections(lower))) {
|
|
108
|
+
return BLOCKED_COMMAND_WORDS.toString();
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
// ─── Path resolution ──────────────────────────────────────────────────────────
|
|
113
|
+
// workDir is threaded through executeTool so relative paths resolve correctly
|
|
114
|
+
// even when the agent runs in a sub-context with a different cwd.
|
|
115
|
+
function resolvePath(inputPath, workDir) {
|
|
116
|
+
const base = workDir ?? process.cwd();
|
|
117
|
+
const resolved = path.isAbsolute(inputPath)
|
|
118
|
+
? inputPath
|
|
119
|
+
: path.resolve(base, inputPath);
|
|
120
|
+
// Path traversal guard: reject paths that escape the workspace root.
|
|
121
|
+
// Absolute paths are allowed as-is (agent may legitimately reference /usr/bin etc.);
|
|
122
|
+
// relative paths must stay within the resolved base.
|
|
123
|
+
if (!path.isAbsolute(inputPath)) {
|
|
124
|
+
const rel = path.relative(base, resolved);
|
|
125
|
+
if (rel.startsWith('..')) {
|
|
126
|
+
throw new Error(`Path traversal denied: "${inputPath}" escapes the workspace root.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
// ─── Tool Implementations ─────────────────────────────────────────────────────
|
|
132
|
+
// Heuristic binary file detector.
|
|
133
|
+
// Strategy: scan for null bytes (definitive) and C0 control chars that are
|
|
134
|
+
// never valid in UTF-8 text. The old version counted ALL bytes < 32 as
|
|
135
|
+
// "non-text", which caused false positives on UTF-8 files containing multibyte
|
|
136
|
+
// sequences like → (0xE2 0x80 0x94) or emoji — their continuation bytes
|
|
137
|
+
// (0x80-0xBF) land in the "non-text" bucket and push the ratio over the 10%
|
|
138
|
+
// threshold. The fix is to skip UTF-8 continuation bytes (0x80-0xBF) and
|
|
139
|
+
// leading bytes ≥ 0xC0, counting only the seven truly-non-text C0 ranges.
|
|
140
|
+
function isBinaryFile(filePath) {
|
|
141
|
+
try {
|
|
142
|
+
const SAMPLE = 8192;
|
|
143
|
+
const buf = Buffer.alloc(SAMPLE);
|
|
144
|
+
const fd = fs.openSync(filePath, 'r');
|
|
145
|
+
const bytesRead = fs.readSync(fd, buf, 0, SAMPLE, 0);
|
|
146
|
+
fs.closeSync(fd);
|
|
147
|
+
if (bytesRead === 0)
|
|
148
|
+
return false;
|
|
149
|
+
let nonText = 0;
|
|
150
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
151
|
+
const b = buf[i];
|
|
152
|
+
if (b === 0)
|
|
153
|
+
return true; // null byte → definitely binary
|
|
154
|
+
// Skip UTF-8 multibyte sequence bytes — they are NOT indicators of binary.
|
|
155
|
+
// 0xC0-0xFF leading bytes of 2-4 byte sequences (≥192)
|
|
156
|
+
// 0x80-0xBF continuation bytes (128-191)
|
|
157
|
+
if (b >= 0x80)
|
|
158
|
+
continue;
|
|
159
|
+
// C0 control chars that never appear in valid text (tab=9, LF=10, CR=13 are fine)
|
|
160
|
+
if (b < 9 || b === 11 || b === 12 || (b >= 14 && b < 32))
|
|
161
|
+
nonText++;
|
|
162
|
+
}
|
|
163
|
+
// >30% non-text ASCII control chars → binary (was 10%, too aggressive)
|
|
164
|
+
return nonText / bytesRead > 0.30;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function readFile(input, workDir) {
|
|
171
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
172
|
+
const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
|
|
173
|
+
const limit = typeof input.limit === 'number' ? Math.max(1, Math.floor(input.limit)) : 0;
|
|
174
|
+
if (!filePath)
|
|
175
|
+
return { error: 'Missing required parameter: path' };
|
|
176
|
+
try {
|
|
177
|
+
const resolved = resolvePath(filePath, workDir);
|
|
178
|
+
const stat = fs.statSync(resolved);
|
|
179
|
+
// Always enforce size cap — even with offset/limit we still readFileSync the whole
|
|
180
|
+
// file before slicing, so a 500 MB file would OOM regardless of the slice range.
|
|
181
|
+
if (stat.size > MAX_READ_BYTES) {
|
|
182
|
+
const kb = (stat.size / 1024).toFixed(0);
|
|
183
|
+
return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Use offset+limit to read sections, or search_files to locate the relevant part first.` };
|
|
184
|
+
}
|
|
185
|
+
// Reject binary files early — reading them as UTF-8 produces garbage.
|
|
186
|
+
if (isBinaryFile(resolved)) {
|
|
187
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
188
|
+
return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
|
|
189
|
+
}
|
|
190
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
191
|
+
const allLines = content.split('\n');
|
|
192
|
+
const totalLines = allLines.length;
|
|
193
|
+
const startLine = offset; // 0-based
|
|
194
|
+
const endLine = limit > 0 ? Math.min(startLine + limit, totalLines) : totalLines;
|
|
195
|
+
const slice = allLines.slice(startLine, endLine);
|
|
196
|
+
// Prefix each line with its 1-based line number (like cat -n / Claude Code view)
|
|
197
|
+
const numbered = slice.map((l, i) => {
|
|
198
|
+
const lineNo = String(startLine + i + 1).padStart(4, ' ');
|
|
199
|
+
return `${lineNo}\t${l}`;
|
|
200
|
+
}).join('\n');
|
|
201
|
+
const rangeNote = (offset > 0 || limit > 0)
|
|
202
|
+
? ` lines ${startLine + 1}-${endLine}/${totalLines}`
|
|
203
|
+
: ` ${totalLines} lines`;
|
|
204
|
+
return { output: `[File: ${resolved} (${rangeNote})]\n${numbered}` };
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
return { error: err.message };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function writeFile(input, workDir) {
|
|
211
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
212
|
+
const content = typeof input.content === 'string' ? input.content : null;
|
|
213
|
+
if (!filePath)
|
|
214
|
+
return { error: 'Missing required parameter: path' };
|
|
215
|
+
if (content === null)
|
|
216
|
+
return { error: 'Missing required parameter: content (must be a string)' };
|
|
217
|
+
try {
|
|
218
|
+
const resolved = resolvePath(filePath, workDir);
|
|
219
|
+
const dir = path.dirname(resolved);
|
|
220
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
221
|
+
const isNew = !fs.existsSync(resolved);
|
|
222
|
+
// Guard against accidentally overwriting an existing file with empty content
|
|
223
|
+
if (!isNew && content === '') {
|
|
224
|
+
return { error: `Refusing to overwrite ${resolved} with empty content. Provide the full file content or use edit_file for targeted changes.` };
|
|
225
|
+
}
|
|
226
|
+
// Preserve file permissions (mode bits) when overwriting an existing file.
|
|
227
|
+
// writeFileSync creates new files with umask-default mode, stripping +x etc.
|
|
228
|
+
let existingMode;
|
|
229
|
+
if (!isNew) {
|
|
230
|
+
try {
|
|
231
|
+
existingMode = fs.statSync(resolved).mode;
|
|
232
|
+
}
|
|
233
|
+
catch { /* ignore */ }
|
|
234
|
+
}
|
|
235
|
+
fs.writeFileSync(resolved, content, 'utf-8');
|
|
236
|
+
if (existingMode !== undefined) {
|
|
237
|
+
try {
|
|
238
|
+
fs.chmodSync(resolved, existingMode);
|
|
239
|
+
}
|
|
240
|
+
catch { /* non-fatal */ }
|
|
241
|
+
}
|
|
242
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
243
|
+
const lines = content.split('\n').length;
|
|
244
|
+
if (isNew) {
|
|
245
|
+
return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
|
|
246
|
+
}
|
|
247
|
+
return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)` };
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
return { error: err.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function listDirectory(input, workDir) {
|
|
254
|
+
const dirPath = typeof input.path === 'string' ? input.path : '.';
|
|
255
|
+
try {
|
|
256
|
+
const resolved = resolvePath(dirPath, workDir);
|
|
257
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
258
|
+
// Sort: directories first, then files, each group alphabetically
|
|
259
|
+
const dirs = entries
|
|
260
|
+
.filter((e) => e.isDirectory())
|
|
261
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
262
|
+
const files = entries
|
|
263
|
+
.filter((e) => !e.isDirectory())
|
|
264
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
265
|
+
const lines = [`[Directory: ${resolved}]`];
|
|
266
|
+
for (const d of dirs) {
|
|
267
|
+
lines.push(`📁 ${d.name}/`);
|
|
268
|
+
}
|
|
269
|
+
for (const f of files) {
|
|
270
|
+
try {
|
|
271
|
+
const stat = fs.statSync(path.join(resolved, f.name));
|
|
272
|
+
const size = formatBytes(stat.size);
|
|
273
|
+
lines.push(`📄 ${f.name} (${size})`);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
lines.push(`📄 ${f.name}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
let output = lines.join('\n');
|
|
280
|
+
if (output.length > MAX_OUTPUT_CHARS) {
|
|
281
|
+
output = output.slice(0, MAX_OUTPUT_CHARS) + `\n[Truncated — ${entries.length} total entries]`;
|
|
282
|
+
}
|
|
283
|
+
return { output };
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
return { error: err.message };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function formatBytes(bytes) {
|
|
290
|
+
if (bytes < 1024)
|
|
291
|
+
return `${bytes} B`;
|
|
292
|
+
if (bytes < 1024 * 1024)
|
|
293
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
294
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
295
|
+
}
|
|
296
|
+
const _bgShells = new Map();
|
|
297
|
+
let _bgCounter = 0;
|
|
298
|
+
function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
299
|
+
const id = `bg_${++_bgCounter}`;
|
|
300
|
+
const child = (0, child_process_1.spawn)(command, { shell: true, cwd: workDir ?? process.cwd(), env: process.env });
|
|
301
|
+
const shell = {
|
|
302
|
+
id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
|
|
303
|
+
status: 'running', exitCode: null,
|
|
304
|
+
};
|
|
305
|
+
const append = (chunk) => {
|
|
306
|
+
if (shell.truncated)
|
|
307
|
+
return;
|
|
308
|
+
const remaining = MAX_OUTPUT_CHARS - shell.output.length;
|
|
309
|
+
if (chunk.length >= remaining) {
|
|
310
|
+
shell.output += chunk.slice(0, remaining);
|
|
311
|
+
shell.truncated = true;
|
|
312
|
+
}
|
|
313
|
+
else
|
|
314
|
+
shell.output += chunk;
|
|
315
|
+
};
|
|
316
|
+
child.stdout?.on('data', (c) => append(c.toString('utf-8')));
|
|
317
|
+
child.stderr?.on('data', (c) => append(c.toString('utf-8')));
|
|
318
|
+
child.on('close', (code) => {
|
|
319
|
+
shell.status = shell.status === 'killed' ? 'killed' : 'exited';
|
|
320
|
+
shell.exitCode = code;
|
|
321
|
+
// Evict oldest exited shell once map grows past 50 to prevent session-long leak
|
|
322
|
+
if (_bgShells.size > 50) {
|
|
323
|
+
for (const [k, v] of _bgShells) {
|
|
324
|
+
if (v.status !== 'running') {
|
|
325
|
+
_bgShells.delete(k);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
child.on('error', (err) => { append(`\n[spawn error: ${err.message}]`); shell.status = 'exited'; });
|
|
332
|
+
_bgShells.set(id, shell);
|
|
333
|
+
const noteLine = note ? `\n[${note}]` : '';
|
|
334
|
+
return { output: `Started background shell ${id}: ${displayCommand}${noteLine}\nUse bash_output with shell_id "${id}" to read output, kill_shell to stop it.` };
|
|
335
|
+
}
|
|
336
|
+
async function bashOutput(input) {
|
|
337
|
+
const id = typeof input.shell_id === 'string' ? input.shell_id : '';
|
|
338
|
+
if (!id)
|
|
339
|
+
return { error: 'Missing required parameter: shell_id' };
|
|
340
|
+
const shell = _bgShells.get(id);
|
|
341
|
+
if (!shell)
|
|
342
|
+
return { error: `No background shell with id "${id}".` };
|
|
343
|
+
const fresh = shell.output.slice(shell.readCursor);
|
|
344
|
+
shell.readCursor = shell.output.length;
|
|
345
|
+
const statusLine = shell.status === 'running'
|
|
346
|
+
? '[still running]'
|
|
347
|
+
: `[${shell.status}${shell.exitCode != null ? `, exit ${shell.exitCode}` : ''}]`;
|
|
348
|
+
const trunc = shell.truncated ? '\n[output truncated at 100KB]' : '';
|
|
349
|
+
return { output: `Shell ${id} ${statusLine}\n${fresh || '(no new output)'}${trunc}` };
|
|
350
|
+
}
|
|
351
|
+
async function killShell(input) {
|
|
352
|
+
const id = typeof input.shell_id === 'string' ? input.shell_id : '';
|
|
353
|
+
if (!id)
|
|
354
|
+
return { error: 'Missing required parameter: shell_id' };
|
|
355
|
+
const shell = _bgShells.get(id);
|
|
356
|
+
if (!shell)
|
|
357
|
+
return { error: `No background shell with id "${id}".` };
|
|
358
|
+
if (shell.status === 'running') {
|
|
359
|
+
shell.status = 'killed';
|
|
360
|
+
try {
|
|
361
|
+
shell.child.kill('SIGTERM');
|
|
362
|
+
}
|
|
363
|
+
catch { /* ignore */ }
|
|
364
|
+
// Escalate to SIGKILL after 3 s if process is still alive
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
try {
|
|
367
|
+
if (shell.status === 'killed')
|
|
368
|
+
shell.child.kill('SIGKILL');
|
|
369
|
+
}
|
|
370
|
+
catch { /* ignore */ }
|
|
371
|
+
}, 3000);
|
|
372
|
+
}
|
|
373
|
+
return { output: `Background shell ${id} ${shell.status}.` };
|
|
374
|
+
}
|
|
375
|
+
// abortSignal / sandbox are injected by executeTool when available
|
|
376
|
+
async function bash(input, abortSignal, sandbox, workDir) {
|
|
377
|
+
const command = typeof input.command === 'string' ? input.command : '';
|
|
378
|
+
// Cap description length — an adversarially-long description would bloat
|
|
379
|
+
// checkpoint labels and the permission dialog without adding value.
|
|
380
|
+
const _rawDesc = typeof input.description === 'string' ? input.description : '';
|
|
381
|
+
void _rawDesc.slice(0, 200); // consumed only for display; actual value unused here
|
|
382
|
+
const timeoutMs = typeof input.timeout_ms === 'number'
|
|
383
|
+
? Math.max(1000, input.timeout_ms) // clamp: 0 or negative would fire before process starts
|
|
384
|
+
: DEFAULT_TIMEOUT_MS;
|
|
385
|
+
const background = input.run_in_background === true;
|
|
386
|
+
if (!command)
|
|
387
|
+
return { error: 'Missing required parameter: command' };
|
|
388
|
+
// Multi-line commands (heredocs, `git commit -m "$(cat <<EOF …)"`, inline
|
|
389
|
+
// python) are allowed. Safety holds because:
|
|
390
|
+
// 1. isCommandBlocked() runs regexes over the FULL string and `\s` matches
|
|
391
|
+
// newlines, so "ls\nrm -rf /" is still caught — a newline can't hide a
|
|
392
|
+
// blocked command from the pattern check.
|
|
393
|
+
// 2. Permission prefix-rules (Bash(npm run test:*)) are newline-aware in
|
|
394
|
+
// rules.ts: an *allow* prefix only matches single-line commands, so a
|
|
395
|
+
// multi-line command can never ride an allow rule past the user prompt.
|
|
396
|
+
const blockedBy = isCommandBlocked(command);
|
|
397
|
+
if (blockedBy) {
|
|
398
|
+
return { error: `Command blocked for safety. Matched rule: ${blockedBy}` };
|
|
399
|
+
}
|
|
400
|
+
// Optionally wrap the command in an OS sandbox (Seatbelt / bubblewrap).
|
|
401
|
+
let effectiveCommand = command;
|
|
402
|
+
let sandboxNote;
|
|
403
|
+
if (sandbox?.enabled) {
|
|
404
|
+
const w = (0, sandbox_1.wrapCommandForSandbox)(command, workDir ?? process.cwd(), sandbox);
|
|
405
|
+
effectiveCommand = w.command;
|
|
406
|
+
if (!w.sandboxed)
|
|
407
|
+
sandboxNote = w.note;
|
|
408
|
+
}
|
|
409
|
+
if (background)
|
|
410
|
+
return startBackgroundShell(effectiveCommand, command, workDir, sandboxNote);
|
|
411
|
+
return new Promise((resolve) => {
|
|
412
|
+
const prependNote = (r) => {
|
|
413
|
+
if (!sandboxNote)
|
|
414
|
+
return r;
|
|
415
|
+
if (r.output !== undefined)
|
|
416
|
+
r.output = `[${sandboxNote}]\n${r.output}`;
|
|
417
|
+
return r;
|
|
418
|
+
};
|
|
419
|
+
const child = (0, child_process_1.spawn)(effectiveCommand, {
|
|
420
|
+
shell: true,
|
|
421
|
+
cwd: workDir ?? process.cwd(),
|
|
422
|
+
env: process.env,
|
|
423
|
+
});
|
|
424
|
+
let output = '';
|
|
425
|
+
let truncated = false;
|
|
426
|
+
let settled = false;
|
|
427
|
+
// Cap output in the data handler so we never accumulate more than MAX_OUTPUT_CHARS in RAM
|
|
428
|
+
const appendOutput = (chunk) => {
|
|
429
|
+
if (truncated)
|
|
430
|
+
return;
|
|
431
|
+
const remaining = MAX_OUTPUT_CHARS - output.length;
|
|
432
|
+
if (chunk.length >= remaining) {
|
|
433
|
+
output += chunk.slice(0, remaining);
|
|
434
|
+
truncated = true;
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
output += chunk;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const done = (code, signal, timedOut = false) => {
|
|
441
|
+
if (settled)
|
|
442
|
+
return;
|
|
443
|
+
settled = true;
|
|
444
|
+
const truncNote = truncated ? '\n[Output truncated at 100KB]' : '';
|
|
445
|
+
if (timedOut) {
|
|
446
|
+
resolve({ error: `Command timed out after ${timeoutMs}ms`, interrupted: true });
|
|
447
|
+
}
|
|
448
|
+
else if (abortSignal?.aborted) {
|
|
449
|
+
resolve({ error: 'Command stopped by user', interrupted: true });
|
|
450
|
+
}
|
|
451
|
+
else if (code !== 0) {
|
|
452
|
+
resolve(prependNote({ output: output + `\n[Exit code: ${code ?? signal ?? 'unknown'}]` + truncNote }));
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
resolve(prependNote({ output: output + truncNote }));
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
child.stdout?.on('data', (chunk) => appendOutput(chunk.toString('utf-8')));
|
|
459
|
+
child.stderr?.on('data', (chunk) => appendOutput(chunk.toString('utf-8')));
|
|
460
|
+
child.on('close', (code, signal) => done(code, signal));
|
|
461
|
+
child.on('error', (err) => {
|
|
462
|
+
settled = true;
|
|
463
|
+
clearInterval(pollAbort); // prevent leak — interval keeps firing after resolve
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
resolve({ error: err.message });
|
|
466
|
+
});
|
|
467
|
+
// Timeout kill: SIGTERM first, escalate to SIGKILL after 3 s if still alive
|
|
468
|
+
const timer = setTimeout(() => {
|
|
469
|
+
if (settled)
|
|
470
|
+
return;
|
|
471
|
+
child.kill('SIGTERM');
|
|
472
|
+
setTimeout(() => { if (!settled)
|
|
473
|
+
child.kill('SIGKILL'); }, 3000);
|
|
474
|
+
done(null, null, true);
|
|
475
|
+
}, timeoutMs);
|
|
476
|
+
child.on('close', () => clearTimeout(timer));
|
|
477
|
+
// Abort signal kill
|
|
478
|
+
const pollAbort = setInterval(() => {
|
|
479
|
+
if (abortSignal?.aborted && !settled) {
|
|
480
|
+
clearInterval(pollAbort);
|
|
481
|
+
child.kill('SIGTERM');
|
|
482
|
+
done(null, null, false);
|
|
483
|
+
}
|
|
484
|
+
}, 200);
|
|
485
|
+
child.on('close', () => clearInterval(pollAbort));
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// Detect ripgrep once per process — preferred over grep (faster, respects .gitignore).
|
|
489
|
+
let _rgChecked = false;
|
|
490
|
+
let _rgAvailable = false;
|
|
491
|
+
function ripgrepAvailable() {
|
|
492
|
+
if (!_rgChecked) {
|
|
493
|
+
const probe = (0, child_process_1.spawnSync)('rg', ['--version'], { encoding: 'utf-8', timeout: 3000 });
|
|
494
|
+
_rgAvailable = probe.status === 0;
|
|
495
|
+
_rgChecked = true;
|
|
496
|
+
}
|
|
497
|
+
return _rgAvailable;
|
|
498
|
+
}
|
|
499
|
+
async function searchFiles(input, workDir) {
|
|
500
|
+
const pattern = typeof input.pattern === 'string' ? input.pattern : '';
|
|
501
|
+
const searchPath = typeof input.path === 'string' ? input.path : '.';
|
|
502
|
+
const searchType = typeof input.type === 'string' ? input.type : 'content';
|
|
503
|
+
const ignoreCase = input.case_insensitive === true;
|
|
504
|
+
const contextLines = typeof input.context_lines === 'number' ? Math.min(10, Math.max(0, input.context_lines)) : 0;
|
|
505
|
+
const include = typeof input.include === 'string' ? input.include : ''; // e.g. "*.ts"
|
|
506
|
+
if (!pattern)
|
|
507
|
+
return { error: 'Missing required parameter: pattern' };
|
|
508
|
+
try {
|
|
509
|
+
const resolved = resolvePath(searchPath, workDir);
|
|
510
|
+
if (searchType === 'filename') {
|
|
511
|
+
const matches = [];
|
|
512
|
+
const patternLower = pattern.toLowerCase();
|
|
513
|
+
walkDir(resolved, (filePath) => {
|
|
514
|
+
const name = path.basename(filePath);
|
|
515
|
+
const nameLower = name.toLowerCase();
|
|
516
|
+
// Support glob-style wildcards (* and ?) in addition to substring + regex
|
|
517
|
+
if (nameLower.includes(patternLower) || matchesGlob(name, pattern) || matchesPattern(name, pattern)) {
|
|
518
|
+
matches.push(filePath);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
if (matches.length === 0)
|
|
522
|
+
return { output: 'No matching files found.' };
|
|
523
|
+
let out = matches.join('\n');
|
|
524
|
+
if (out.length > MAX_OUTPUT_CHARS)
|
|
525
|
+
out = out.slice(0, MAX_OUTPUT_CHARS) + '\n[Truncated]';
|
|
526
|
+
return { output: out };
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
// Content search — prefer ripgrep (faster, .gitignore-aware) when
|
|
530
|
+
// installed, fall back to plain grep with hardcoded excludes otherwise.
|
|
531
|
+
let result;
|
|
532
|
+
if (ripgrepAvailable()) {
|
|
533
|
+
const args = ['--line-number', '--no-heading', '--color=never', '--hidden', '--glob', '!.git'];
|
|
534
|
+
if (ignoreCase)
|
|
535
|
+
args.push('-i');
|
|
536
|
+
if (contextLines > 0)
|
|
537
|
+
args.push(`-C${contextLines}`);
|
|
538
|
+
if (include)
|
|
539
|
+
args.push('--glob', include);
|
|
540
|
+
args.push('-m', '200', '--regexp', pattern, resolved);
|
|
541
|
+
result = (0, child_process_1.spawnSync)('rg', args, { encoding: 'utf-8', timeout: DEFAULT_TIMEOUT_MS });
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
const args = ['-rn', '--binary-files=without-match', '--color=never'];
|
|
545
|
+
if (ignoreCase)
|
|
546
|
+
args.push('-i');
|
|
547
|
+
if (contextLines > 0)
|
|
548
|
+
args.push(`-C${contextLines}`);
|
|
549
|
+
if (include)
|
|
550
|
+
args.push(`--include=${include}`);
|
|
551
|
+
args.push('--exclude-dir=.git', '--exclude-dir=node_modules', '--exclude-dir=dist', '--exclude-dir=.next', '--exclude-dir=__pycache__', '--exclude-dir=.turbo', '--exclude-dir=coverage', '--exclude-dir=.cache', '-m', '200', pattern, resolved);
|
|
552
|
+
result = (0, child_process_1.spawnSync)('grep', args, { encoding: 'utf-8', timeout: DEFAULT_TIMEOUT_MS });
|
|
553
|
+
}
|
|
554
|
+
let output = result.stdout ?? '';
|
|
555
|
+
const stderr = result.stderr ?? '';
|
|
556
|
+
// Both rg and grep use exit code 1 = "no matches", 2 = error.
|
|
557
|
+
if (result.status === 1 && !output)
|
|
558
|
+
return { output: 'No matches found.' };
|
|
559
|
+
if (result.status !== 0 && result.status !== 1)
|
|
560
|
+
return { error: stderr || 'search failed' };
|
|
561
|
+
if (output.length > MAX_OUTPUT_CHARS)
|
|
562
|
+
output = output.slice(0, MAX_OUTPUT_CHARS) + '\n[Output truncated at 100KB]';
|
|
563
|
+
return { output: output || 'No matches found.' };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
return { error: err.message };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function matchesGlob(name, pattern) {
|
|
571
|
+
// Convert simple glob (* → .*, ? → .) to regex
|
|
572
|
+
try {
|
|
573
|
+
const re = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
|
574
|
+
return re.test(name);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function matchesPattern(name, pattern) {
|
|
581
|
+
// Guard against ReDoS: reject patterns longer than 200 chars or with
|
|
582
|
+
// catastrophic backtracking structures like (a+)+ before compiling.
|
|
583
|
+
if (pattern.length > 200)
|
|
584
|
+
return false;
|
|
585
|
+
try {
|
|
586
|
+
const regex = new RegExp(pattern, 'i');
|
|
587
|
+
return regex.test(name);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function walkDir(dirPath, callback, _visited = new Set()) {
|
|
594
|
+
try {
|
|
595
|
+
// Resolve symlinks to detect cycles — a symlink pointing to a parent dir
|
|
596
|
+
// would cause infinite recursion without this guard.
|
|
597
|
+
const real = fs.realpathSync(dirPath);
|
|
598
|
+
if (_visited.has(real))
|
|
599
|
+
return;
|
|
600
|
+
_visited.add(real);
|
|
601
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
602
|
+
for (const entry of entries) {
|
|
603
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
604
|
+
// isDirectory() returns false for symlinks — use isSymbolicLink() to detect
|
|
605
|
+
// symlinked directories and walk them too (with cycle protection above).
|
|
606
|
+
const isDir = entry.isDirectory() || (entry.isSymbolicLink() && (() => {
|
|
607
|
+
try {
|
|
608
|
+
return fs.statSync(fullPath).isDirectory();
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
})());
|
|
614
|
+
if (isDir) {
|
|
615
|
+
// Skip common noise directories
|
|
616
|
+
if (['.git', 'node_modules', 'dist', '.next', '__pycache__', '.turbo', 'coverage'].includes(entry.name))
|
|
617
|
+
continue;
|
|
618
|
+
walkDir(fullPath, callback, _visited);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
callback(fullPath);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// Skip unreadable directories
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function createDirectory(input, workDir) {
|
|
630
|
+
const dirPath = typeof input.path === 'string' ? input.path : '';
|
|
631
|
+
if (!dirPath) {
|
|
632
|
+
return { error: 'Missing required parameter: path' };
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
const resolved = resolvePath(dirPath, workDir);
|
|
636
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
637
|
+
return { output: `Directory created: ${resolved}` };
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
return { error: err.message };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function moveFile(input, workDir) {
|
|
644
|
+
const source = typeof input.source === 'string' ? input.source : '';
|
|
645
|
+
const dest = typeof input.dest === 'string' ? input.dest : '';
|
|
646
|
+
if (!source)
|
|
647
|
+
return { error: 'Missing required parameter: source' };
|
|
648
|
+
if (!dest)
|
|
649
|
+
return { error: 'Missing required parameter: dest' };
|
|
650
|
+
try {
|
|
651
|
+
const resolvedSource = resolvePath(source, workDir);
|
|
652
|
+
const resolvedDest = resolvePath(dest, workDir);
|
|
653
|
+
fs.mkdirSync(path.dirname(resolvedDest), { recursive: true });
|
|
654
|
+
try {
|
|
655
|
+
fs.renameSync(resolvedSource, resolvedDest);
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
// renameSync throws EXDEV when source/dest are on different filesystems
|
|
659
|
+
// (e.g. /tmp → /Users). Fall back to copy + delete.
|
|
660
|
+
if (e.code === 'EXDEV') {
|
|
661
|
+
fs.copyFileSync(resolvedSource, resolvedDest);
|
|
662
|
+
fs.unlinkSync(resolvedSource);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
throw e;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return { output: `Moved ${resolvedSource} → ${resolvedDest}` };
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
return { error: err.message };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// ─── Shared string-edit helpers ───────────────────────────────────────────────
|
|
675
|
+
// Count non-overlapping literal occurrences of `needle` in `haystack`.
|
|
676
|
+
// indexOf-based — safe for any chars including ( . $ * + ?
|
|
677
|
+
// String.split(string) treats its argument as a regex in edge-cases.
|
|
678
|
+
function countOccurrences(haystack, needle) {
|
|
679
|
+
if (!needle)
|
|
680
|
+
return 0;
|
|
681
|
+
let count = 0;
|
|
682
|
+
let pos = 0;
|
|
683
|
+
while ((pos = haystack.indexOf(needle, pos)) !== -1) {
|
|
684
|
+
count++;
|
|
685
|
+
pos += needle.length;
|
|
686
|
+
}
|
|
687
|
+
return count;
|
|
688
|
+
}
|
|
689
|
+
// Literal first-occurrence replace — does NOT treat $& / $1 / $' / $` in
|
|
690
|
+
// replacement as special patterns (unlike String.replace(string, string)).
|
|
691
|
+
function literalReplace(haystack, needle, replacement) {
|
|
692
|
+
const idx = haystack.indexOf(needle);
|
|
693
|
+
if (idx === -1)
|
|
694
|
+
return haystack;
|
|
695
|
+
return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length);
|
|
696
|
+
}
|
|
697
|
+
// Normalize CRLF → LF so Windows files match LF old_strings.
|
|
698
|
+
function normalizeLF(s) {
|
|
699
|
+
return s.replace(/\r\n/g, '\n');
|
|
700
|
+
}
|
|
701
|
+
async function editFile(input, workDir) {
|
|
702
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
703
|
+
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
|
704
|
+
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
|
705
|
+
if (!filePath)
|
|
706
|
+
return { error: 'Missing required parameter: path' };
|
|
707
|
+
if (!oldString)
|
|
708
|
+
return { error: 'Missing required parameter: old_string' };
|
|
709
|
+
try {
|
|
710
|
+
const resolved = resolvePath(filePath, workDir);
|
|
711
|
+
const original = fs.readFileSync(resolved, 'utf-8');
|
|
712
|
+
// Normalize CRLF → LF for matching; restore on write if needed
|
|
713
|
+
const wasCRLF = original.includes('\r\n');
|
|
714
|
+
const origNorm = normalizeLF(original);
|
|
715
|
+
const oldNorm = normalizeLF(oldString);
|
|
716
|
+
const newNorm = normalizeLF(newString);
|
|
717
|
+
const count = countOccurrences(origNorm, oldNorm);
|
|
718
|
+
if (count === 0) {
|
|
719
|
+
return { error: `old_string not found in ${resolved}. Read the file first to verify the exact text to replace.` };
|
|
720
|
+
}
|
|
721
|
+
if (count > 1) {
|
|
722
|
+
return { error: `old_string appears ${count} times in ${resolved} — it must be unique. Add more surrounding context to make it unambiguous.` };
|
|
723
|
+
}
|
|
724
|
+
let updated = literalReplace(origNorm, oldNorm, newNorm);
|
|
725
|
+
if (wasCRLF)
|
|
726
|
+
updated = updated.replace(/\n/g, '\r\n');
|
|
727
|
+
// Atomic write: write to a temp file then rename so a crash mid-write never
|
|
728
|
+
// leaves the original file truncated or partially overwritten.
|
|
729
|
+
const tmp = resolved + '.nexrall_tmp';
|
|
730
|
+
fs.writeFileSync(tmp, updated, 'utf-8');
|
|
731
|
+
fs.renameSync(tmp, resolved);
|
|
732
|
+
const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
|
|
733
|
+
const linesBefore = origNorm.split('\n').length;
|
|
734
|
+
const linesAfter = updated.split('\n').length;
|
|
735
|
+
return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}` };
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
return { error: err.message };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function copyFile(input, workDir) {
|
|
742
|
+
const source = typeof input.source === 'string' ? input.source : '';
|
|
743
|
+
const destination = typeof input.destination === 'string' ? input.destination : '';
|
|
744
|
+
if (!source)
|
|
745
|
+
return { error: 'Missing required parameter: source' };
|
|
746
|
+
if (!destination)
|
|
747
|
+
return { error: 'Missing required parameter: destination' };
|
|
748
|
+
try {
|
|
749
|
+
const resolvedSrc = resolvePath(source, workDir);
|
|
750
|
+
const resolvedDest = resolvePath(destination, workDir);
|
|
751
|
+
// Guard: reject if destination is an existing directory
|
|
752
|
+
if (fs.existsSync(resolvedDest) && fs.statSync(resolvedDest).isDirectory()) {
|
|
753
|
+
return { error: `Destination is a directory: ${resolvedDest}. Provide a full file path including the filename.` };
|
|
754
|
+
}
|
|
755
|
+
fs.mkdirSync(path.dirname(resolvedDest), { recursive: true });
|
|
756
|
+
fs.copyFileSync(resolvedSrc, resolvedDest);
|
|
757
|
+
return { output: `Copied: ${resolvedSrc} → ${resolvedDest}` };
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
return { error: err.message };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function deleteFile(input, workDir) {
|
|
764
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
765
|
+
if (!filePath) {
|
|
766
|
+
return { error: 'Missing required parameter: path' };
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const resolved = resolvePath(filePath, workDir);
|
|
770
|
+
const stat = fs.statSync(resolved);
|
|
771
|
+
if (stat.isDirectory()) {
|
|
772
|
+
return { error: `Path is a directory, not a file: ${resolved}. Use a recursive delete tool or remove contents first.` };
|
|
773
|
+
}
|
|
774
|
+
fs.unlinkSync(resolved);
|
|
775
|
+
return { output: `Deleted: ${resolved}` };
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
return { error: err.message };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// Strip HTML tags → readable plain text (like Claude Code's WebFetch)
|
|
782
|
+
function stripHtml(html) {
|
|
783
|
+
return html
|
|
784
|
+
// Remove <script> and <style> blocks entirely
|
|
785
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
786
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
787
|
+
// Block elements → newlines
|
|
788
|
+
.replace(/<\/(p|div|section|article|li|h[1-6]|tr|blockquote)>/gi, '\n')
|
|
789
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
790
|
+
.replace(/<hr\s*\/?>/gi, '\n---\n')
|
|
791
|
+
// Headings → markdown-style
|
|
792
|
+
.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, l, t) => '#'.repeat(Number(l)) + ' ' + t + '\n')
|
|
793
|
+
// Links → [text](url)
|
|
794
|
+
.replace(/<a[^>]+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => `${text.trim()} (${href})`)
|
|
795
|
+
// Remove all remaining tags
|
|
796
|
+
.replace(/<[^>]+>/g, '')
|
|
797
|
+
// Decode common HTML entities
|
|
798
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
|
799
|
+
// Collapse 3+ blank lines → 2
|
|
800
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
801
|
+
.trim();
|
|
802
|
+
}
|
|
803
|
+
const MAX_REDIRECTS = 10;
|
|
804
|
+
const FETCH_USER_AGENT = 'Nexrall-Code/1.0 (+https://nexrall.com)';
|
|
805
|
+
async function fetchUrl(input, _workDir, _redirectCount = 0) {
|
|
806
|
+
const url = typeof input.url === 'string' ? input.url : '';
|
|
807
|
+
if (!url)
|
|
808
|
+
return { error: 'Missing required parameter: url' };
|
|
809
|
+
if (_redirectCount > MAX_REDIRECTS) {
|
|
810
|
+
return { error: `Too many redirects (>${MAX_REDIRECTS}) fetching ${url}` };
|
|
811
|
+
}
|
|
812
|
+
let parsedUrl;
|
|
813
|
+
try {
|
|
814
|
+
parsedUrl = new URL(url);
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return { error: `Invalid URL: ${url}` };
|
|
818
|
+
}
|
|
819
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
820
|
+
return { error: `Unsupported protocol: ${parsedUrl.protocol}. Only http and https are supported.` };
|
|
821
|
+
}
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
|
824
|
+
const req = transport.get(url, {
|
|
825
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
826
|
+
headers: {
|
|
827
|
+
'User-Agent': FETCH_USER_AGENT,
|
|
828
|
+
'Accept-Encoding': 'identity', // opt-out of gzip/deflate — we read raw Buffer as UTF-8
|
|
829
|
+
'Accept': 'text/html,text/plain,application/json,*/*',
|
|
830
|
+
},
|
|
831
|
+
}, (res) => {
|
|
832
|
+
// Follow redirects (301/302/307/308) with depth counter to prevent infinite loops
|
|
833
|
+
const status = res.statusCode ?? 0;
|
|
834
|
+
if ((status === 301 || status === 302 || status === 307 || status === 308) && res.headers.location) {
|
|
835
|
+
req.destroy();
|
|
836
|
+
// Resolve relative Location headers against the current URL
|
|
837
|
+
let nextUrl;
|
|
838
|
+
try {
|
|
839
|
+
nextUrl = new URL(res.headers.location, url).href;
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
resolve({ error: `Invalid redirect location: ${res.headers.location}` });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
fetchUrl({ url: nextUrl }, undefined, _redirectCount + 1).then(resolve);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const contentType = res.headers['content-type'] ?? '';
|
|
849
|
+
const isHtml = contentType.includes('text/html');
|
|
850
|
+
const chunks = [];
|
|
851
|
+
let totalBytes = 0;
|
|
852
|
+
let truncated = false;
|
|
853
|
+
res.on('data', (chunk) => {
|
|
854
|
+
if (totalBytes + chunk.length > MAX_FETCH_BYTES) {
|
|
855
|
+
const remaining = MAX_FETCH_BYTES - totalBytes;
|
|
856
|
+
if (remaining > 0)
|
|
857
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
858
|
+
totalBytes = MAX_FETCH_BYTES;
|
|
859
|
+
truncated = true;
|
|
860
|
+
req.destroy();
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
chunks.push(chunk);
|
|
864
|
+
totalBytes += chunk.length;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
res.on('end', () => {
|
|
868
|
+
let body = Buffer.concat(chunks).toString('utf-8');
|
|
869
|
+
if (isHtml)
|
|
870
|
+
body = stripHtml(body);
|
|
871
|
+
const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
|
|
872
|
+
resolve({ output: `HTTP ${status}\n\n${body}${truncNote}` });
|
|
873
|
+
});
|
|
874
|
+
res.on('error', (err) => resolve({ error: err.message }));
|
|
875
|
+
});
|
|
876
|
+
req.on('error', (err) => resolve({ error: err.message }));
|
|
877
|
+
req.on('timeout', () => {
|
|
878
|
+
req.destroy();
|
|
879
|
+
resolve({ error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms` });
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
// ─── Image assets (generate_image / stock_photo) ───────────────────────────────
|
|
884
|
+
// Keys live on the server — these tools call authenticated Nexrall endpoints that
|
|
885
|
+
// proxy KIE (AI generation) / Unsplash (stock), then download the returned URL into
|
|
886
|
+
// the workspace so the agent can reference a real local file immediately.
|
|
887
|
+
// ─── Unified diff builder ─────────────────────────────────────────────────────
|
|
888
|
+
// fileContent is optional — when provided, the diff header includes the
|
|
889
|
+
// real line number of the change (like a proper unified diff @@ hunk header).
|
|
890
|
+
function buildDiff(filePath, oldStr, newStr, fileContent) {
|
|
891
|
+
const oldLines = oldStr.split('\n');
|
|
892
|
+
const newLines = newStr.split('\n');
|
|
893
|
+
const MAX_SHOW = 8;
|
|
894
|
+
// Compute 1-based start line of the change if we have the full file content
|
|
895
|
+
let hunkHeader = '';
|
|
896
|
+
if (fileContent) {
|
|
897
|
+
const idx = fileContent.indexOf(oldStr);
|
|
898
|
+
if (idx !== -1) {
|
|
899
|
+
const lineNo = fileContent.slice(0, idx).split('\n').length;
|
|
900
|
+
hunkHeader = ` @@ -${lineNo},${oldLines.length} +${lineNo},${newLines.length} @@`;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const lines = [`--- ${filePath}${hunkHeader}`, `+++ ${filePath}${hunkHeader}`];
|
|
904
|
+
oldLines.slice(0, MAX_SHOW).forEach(l => lines.push(`- ${l}`));
|
|
905
|
+
if (oldLines.length > MAX_SHOW)
|
|
906
|
+
lines.push(` … (${oldLines.length - MAX_SHOW} more removed lines)`);
|
|
907
|
+
newLines.slice(0, MAX_SHOW).forEach(l => lines.push(`+ ${l}`));
|
|
908
|
+
if (newLines.length > MAX_SHOW)
|
|
909
|
+
lines.push(` … (${newLines.length - MAX_SHOW} more added lines)`);
|
|
910
|
+
return lines.join('\n');
|
|
911
|
+
}
|
|
912
|
+
// ─── multi_edit ───────────────────────────────────────────────────────────────
|
|
913
|
+
async function multiEdit(input, workDir) {
|
|
914
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
915
|
+
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
916
|
+
if (!filePath)
|
|
917
|
+
return { error: 'Missing required parameter: path' };
|
|
918
|
+
if (!edits.length)
|
|
919
|
+
return { error: 'Missing required parameter: edits (must be a non-empty array)' };
|
|
920
|
+
try {
|
|
921
|
+
const resolved = resolvePath(filePath, workDir);
|
|
922
|
+
const rawFile = fs.readFileSync(resolved, 'utf-8');
|
|
923
|
+
// Work in LF-normalised space so CRLF files match LF old_strings
|
|
924
|
+
const wasCRLF = rawFile.includes('\r\n');
|
|
925
|
+
let content = normalizeLF(rawFile);
|
|
926
|
+
const diffs = [];
|
|
927
|
+
for (let i = 0; i < edits.length; i++) {
|
|
928
|
+
const edit = edits[i];
|
|
929
|
+
// Normalize incoming old/new strings too
|
|
930
|
+
const oldStr = normalizeLF(typeof edit.old_string === 'string' ? edit.old_string : '');
|
|
931
|
+
const newStr = normalizeLF(typeof edit.new_string === 'string' ? edit.new_string : '');
|
|
932
|
+
if (!oldStr)
|
|
933
|
+
return { error: `Edit #${i + 1}: missing old_string` };
|
|
934
|
+
// indexOf-based count — safe for regex metacharacters like ( . $ * + ?
|
|
935
|
+
const count = countOccurrences(content, oldStr);
|
|
936
|
+
if (count === 0) {
|
|
937
|
+
return { error: `Edit #${i + 1}: old_string not found (after applying ${i} prior edit(s)). Re-read the file and retry.` };
|
|
938
|
+
}
|
|
939
|
+
if (count > 1) {
|
|
940
|
+
return { error: `Edit #${i + 1}: old_string appears ${count} times — it must be unique. Add more surrounding context.` };
|
|
941
|
+
}
|
|
942
|
+
// literalReplace: $ signs in newStr are never treated as patterns
|
|
943
|
+
// Pass the pre-replace content so line numbers reflect the file state at
|
|
944
|
+
// the point this edit was applied (not the final state after all edits).
|
|
945
|
+
diffs.push(buildDiff(filePath, oldStr, newStr, content));
|
|
946
|
+
content = literalReplace(content, oldStr, newStr);
|
|
947
|
+
}
|
|
948
|
+
// Restore original line endings before writing (atomic temp+rename)
|
|
949
|
+
const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
|
|
950
|
+
const tmp = resolved + '.nexrall_tmp';
|
|
951
|
+
fs.writeFileSync(tmp, finalContent, 'utf-8');
|
|
952
|
+
fs.renameSync(tmp, resolved);
|
|
953
|
+
return {
|
|
954
|
+
output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
|
|
955
|
+
diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n'),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
catch (err) {
|
|
959
|
+
return { error: err.message };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// ─── glob ─────────────────────────────────────────────────────────────────────
|
|
963
|
+
async function glob(input, workDir) {
|
|
964
|
+
const pattern = typeof input.pattern === 'string' ? input.pattern : '';
|
|
965
|
+
const searchDir = typeof input.path === 'string' ? input.path : '.';
|
|
966
|
+
if (!pattern)
|
|
967
|
+
return { error: 'Missing required parameter: pattern' };
|
|
968
|
+
try {
|
|
969
|
+
const resolved = resolvePath(searchDir, workDir);
|
|
970
|
+
const matches = [];
|
|
971
|
+
// Convert glob pattern to regex
|
|
972
|
+
const toRegex = (p) => {
|
|
973
|
+
const escaped = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
974
|
+
const re = escaped
|
|
975
|
+
.replace(/\*\*\//g, '(?:[^/]+/)*') // **/ = zero or more segments (fixes root-level: index.ts matches **/*.ts)
|
|
976
|
+
.replace(/\*\*/g, '.*') // ** matches anything
|
|
977
|
+
.replace(/\*/g, '[^/]*') // * matches within one segment
|
|
978
|
+
.replace(/\?/g, '[^/]'); // ? matches single non-slash char
|
|
979
|
+
return new RegExp(`^${re}$`);
|
|
980
|
+
};
|
|
981
|
+
const regex = toRegex(pattern);
|
|
982
|
+
walkDir(resolved, (filePath) => {
|
|
983
|
+
const rel = path.relative(resolved, filePath).replace(/\\/g, '/');
|
|
984
|
+
// Only fall back to basename test for patterns without a path separator
|
|
985
|
+
// (i.e. single-segment patterns like "*.ts"). Patterns with "/" (e.g.
|
|
986
|
+
// "src/*.ts") must match the full relative path to avoid false positives.
|
|
987
|
+
const useBasename = !pattern.includes('/') && !pattern.includes('**');
|
|
988
|
+
if (regex.test(rel) || (useBasename && regex.test(path.basename(filePath)))) {
|
|
989
|
+
matches.push(filePath);
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
if (!matches.length)
|
|
993
|
+
return { output: `No files matched pattern: ${pattern}` };
|
|
994
|
+
let out = matches.join('\n');
|
|
995
|
+
if (out.length > MAX_OUTPUT_CHARS)
|
|
996
|
+
out = out.slice(0, MAX_OUTPUT_CHARS) + '\n[Truncated]';
|
|
997
|
+
return { output: `${matches.length} file(s) matched "${pattern}":\n${out}` };
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
return { error: err.message };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const _todoScopes = new Map();
|
|
1004
|
+
function todoScope(key) {
|
|
1005
|
+
const k = key || 'default';
|
|
1006
|
+
let s = _todoScopes.get(k);
|
|
1007
|
+
if (!s) {
|
|
1008
|
+
s = { items: [], nextId: 1 };
|
|
1009
|
+
_todoScopes.set(k, s);
|
|
1010
|
+
}
|
|
1011
|
+
return s;
|
|
1012
|
+
}
|
|
1013
|
+
async function todoWrite(input, scopeKey) {
|
|
1014
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
1015
|
+
if (!todos.length)
|
|
1016
|
+
return { error: 'todos must be a non-empty array' };
|
|
1017
|
+
const scope = todoScope(scopeKey);
|
|
1018
|
+
scope.items.length = 0;
|
|
1019
|
+
scope.nextId = 1;
|
|
1020
|
+
for (const t of todos) {
|
|
1021
|
+
const item = t;
|
|
1022
|
+
const content = typeof item.content === 'string' ? item.content.trim() : '';
|
|
1023
|
+
const status = (item.status === 'in_progress' || item.status === 'completed') ? item.status : 'pending';
|
|
1024
|
+
if (!content)
|
|
1025
|
+
continue;
|
|
1026
|
+
scope.items.push({ id: scope.nextId++, content, status });
|
|
1027
|
+
}
|
|
1028
|
+
return { output: formatTodos(scope) };
|
|
1029
|
+
}
|
|
1030
|
+
async function todoRead(_input, scopeKey) {
|
|
1031
|
+
const scope = todoScope(scopeKey);
|
|
1032
|
+
if (!scope.items.length)
|
|
1033
|
+
return { output: 'No todos yet. Use todo_write to create a task list.' };
|
|
1034
|
+
return { output: formatTodos(scope) };
|
|
1035
|
+
}
|
|
1036
|
+
function formatTodos(scope) {
|
|
1037
|
+
if (!scope.items.length)
|
|
1038
|
+
return 'Task list is empty.';
|
|
1039
|
+
const icons = { pending: '☐', in_progress: '▶', completed: '✓' };
|
|
1040
|
+
const lines = scope.items.map(t => `${icons[t.status] ?? '☐'} [${t.id}] ${t.content} (${t.status})`);
|
|
1041
|
+
return `Tasks (${scope.items.filter(t => t.status === 'completed').length}/${scope.items.length} done):\n${lines.join('\n')}`;
|
|
1042
|
+
}
|
|
1043
|
+
function nbJoin(src) {
|
|
1044
|
+
return Array.isArray(src) ? src.join('') : (src ?? '');
|
|
1045
|
+
}
|
|
1046
|
+
function formatNbOutput(out) {
|
|
1047
|
+
const lines = [];
|
|
1048
|
+
switch (out.output_type) {
|
|
1049
|
+
case 'stream': {
|
|
1050
|
+
const txt = nbJoin((out.text ?? ''));
|
|
1051
|
+
lines.push(`[${out.name === 'stderr' ? 'stderr' : 'stdout'}] ${txt.slice(0, 3000)}${txt.length > 3000 ? '\n[truncated]' : ''}`);
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
case 'execute_result':
|
|
1055
|
+
case 'display_data': {
|
|
1056
|
+
const d = out.data ?? {};
|
|
1057
|
+
const plain = d['text/plain'] ? nbJoin(d['text/plain']) : null;
|
|
1058
|
+
const html = d['text/html'] ? nbJoin(d['text/html']) : null;
|
|
1059
|
+
const hasImg = 'image/png' in d || 'image/jpeg' in d || 'image/svg+xml' in d;
|
|
1060
|
+
if (plain)
|
|
1061
|
+
lines.push(plain.slice(0, 2000) + (plain.length > 2000 ? '\n[truncated]' : ''));
|
|
1062
|
+
else if (html)
|
|
1063
|
+
lines.push(stripHtml(html).slice(0, 1000));
|
|
1064
|
+
if (hasImg)
|
|
1065
|
+
lines.push('[image output — not shown in text mode]');
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
case 'error': {
|
|
1069
|
+
lines.push(`[error] ${out.ename ?? 'Error'}: ${out.evalue ?? ''}`);
|
|
1070
|
+
if (out.traceback) {
|
|
1071
|
+
const clean = out.traceback
|
|
1072
|
+
.map(l => l.replace(/\x1b\[[0-9;]*[mGKH]/g, ''))
|
|
1073
|
+
.join('\n');
|
|
1074
|
+
lines.push(clean.slice(0, 1500) + (clean.length > 1500 ? '\n[truncated]' : ''));
|
|
1075
|
+
}
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return lines.join('\n');
|
|
1080
|
+
}
|
|
1081
|
+
// ─── notebook_read ────────────────────────────────────────────────────────────
|
|
1082
|
+
async function notebookRead(input, workDir) {
|
|
1083
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
1084
|
+
if (!filePath)
|
|
1085
|
+
return { error: 'Missing required parameter: path' };
|
|
1086
|
+
if (!filePath.endsWith('.ipynb'))
|
|
1087
|
+
return { error: 'File must be a Jupyter notebook (.ipynb)' };
|
|
1088
|
+
try {
|
|
1089
|
+
const resolved = resolvePath(filePath, workDir);
|
|
1090
|
+
const stat = fs.statSync(resolved);
|
|
1091
|
+
if (stat.size > MAX_READ_BYTES) {
|
|
1092
|
+
return { error: `Notebook too large (${(stat.size / 1024).toFixed(0)} KB). Max: ${MAX_READ_BYTES / 1024} KB.` };
|
|
1093
|
+
}
|
|
1094
|
+
const nb = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
|
|
1095
|
+
if (!Array.isArray(nb.cells))
|
|
1096
|
+
return { error: 'Invalid notebook: missing cells array.' };
|
|
1097
|
+
const lines = [`[Notebook: ${resolved} — ${nb.cells.length} cells, nbformat ${nb.nbformat}]`];
|
|
1098
|
+
nb.cells.forEach((cell, i) => {
|
|
1099
|
+
const src = nbJoin(cell.source);
|
|
1100
|
+
const execLabel = cell.cell_type === 'code'
|
|
1101
|
+
? (cell.execution_count != null ? ` (exec #${cell.execution_count})` : ' (not executed)')
|
|
1102
|
+
: '';
|
|
1103
|
+
lines.push('');
|
|
1104
|
+
lines.push(`${'─'.repeat(60)}`);
|
|
1105
|
+
lines.push(`Cell ${i} [${cell.cell_type}]${execLabel}`);
|
|
1106
|
+
lines.push('─'.repeat(60));
|
|
1107
|
+
lines.push(src || '(empty)');
|
|
1108
|
+
if (cell.cell_type === 'code' && cell.outputs?.length) {
|
|
1109
|
+
lines.push('');
|
|
1110
|
+
lines.push('▶ Output:');
|
|
1111
|
+
cell.outputs.forEach(o => {
|
|
1112
|
+
const formatted = formatNbOutput(o);
|
|
1113
|
+
if (formatted)
|
|
1114
|
+
lines.push(formatted);
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
let output = lines.join('\n');
|
|
1119
|
+
if (output.length > MAX_OUTPUT_CHARS) {
|
|
1120
|
+
output = output.slice(0, MAX_OUTPUT_CHARS) + '\n\n[Notebook output truncated at 100KB]';
|
|
1121
|
+
}
|
|
1122
|
+
return { output };
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
if (err instanceof SyntaxError)
|
|
1126
|
+
return { error: `Notebook is not valid JSON: ${err.message}` };
|
|
1127
|
+
return { error: err.message };
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
// ─── notebook_edit ────────────────────────────────────────────────────────────
|
|
1131
|
+
async function notebookEdit(input, workDir) {
|
|
1132
|
+
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
1133
|
+
const editType = typeof input.edit_type === 'string' ? input.edit_type : '';
|
|
1134
|
+
// Accept cell_index as number OR numeric string (model sometimes sends "0" as string)
|
|
1135
|
+
const rawIdx = input.cell_index;
|
|
1136
|
+
const cellIndex = typeof rawIdx === 'number' ? Math.floor(rawIdx)
|
|
1137
|
+
: typeof rawIdx === 'string' && rawIdx.trim() !== '' ? parseInt(rawIdx, 10)
|
|
1138
|
+
: -1;
|
|
1139
|
+
const source = typeof input.source === 'string' ? input.source : null;
|
|
1140
|
+
const cellType = (typeof input.cell_type === 'string' ? input.cell_type : 'code');
|
|
1141
|
+
if (!filePath)
|
|
1142
|
+
return { error: 'Missing required parameter: path' };
|
|
1143
|
+
if (!editType)
|
|
1144
|
+
return { error: 'Missing required parameter: edit_type — must be "replace", "insert", or "delete"' };
|
|
1145
|
+
if (!['replace', 'insert', 'delete'].includes(editType)) {
|
|
1146
|
+
return { error: `Invalid edit_type: "${editType}". Must be one of: replace, insert, delete` };
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
const resolved = resolvePath(filePath, workDir);
|
|
1150
|
+
const nb = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
|
|
1151
|
+
if (!Array.isArray(nb.cells))
|
|
1152
|
+
return { error: 'Invalid notebook: missing cells array.' };
|
|
1153
|
+
const n = nb.cells.length;
|
|
1154
|
+
const save = () => fs.writeFileSync(resolved, JSON.stringify(nb, null, 1) + '\n', 'utf-8');
|
|
1155
|
+
if (editType === 'replace') {
|
|
1156
|
+
if (cellIndex < 0 || cellIndex >= n)
|
|
1157
|
+
return { error: `cell_index ${cellIndex} out of range (0–${n - 1})` };
|
|
1158
|
+
if (source === null)
|
|
1159
|
+
return { error: 'Missing required parameter: source (required for replace)' };
|
|
1160
|
+
const cell = nb.cells[cellIndex];
|
|
1161
|
+
cell.source = source;
|
|
1162
|
+
if (cell.cell_type === 'code') {
|
|
1163
|
+
cell.outputs = [];
|
|
1164
|
+
cell.execution_count = null;
|
|
1165
|
+
}
|
|
1166
|
+
save();
|
|
1167
|
+
return { output: `Replaced source of cell ${cellIndex} in ${resolved}.${cell.cell_type === 'code' ? ' Outputs cleared.' : ''}` };
|
|
1168
|
+
}
|
|
1169
|
+
if (editType === 'delete') {
|
|
1170
|
+
if (cellIndex < 0 || cellIndex >= n)
|
|
1171
|
+
return { error: `cell_index ${cellIndex} out of range (0–${n - 1})` };
|
|
1172
|
+
const removed = nb.cells.splice(cellIndex, 1)[0];
|
|
1173
|
+
save();
|
|
1174
|
+
return { output: `Deleted cell ${cellIndex} [${removed.cell_type}] from ${resolved}. Notebook now has ${nb.cells.length} cells.` };
|
|
1175
|
+
}
|
|
1176
|
+
// insert: inserts AFTER cell_index, or at end if -1
|
|
1177
|
+
if (source === null)
|
|
1178
|
+
return { error: 'Missing required parameter: source (required for insert)' };
|
|
1179
|
+
const insertAt = cellIndex >= 0 ? Math.min(cellIndex + 1, n) : n;
|
|
1180
|
+
const newCell = {
|
|
1181
|
+
cell_type: cellType,
|
|
1182
|
+
source,
|
|
1183
|
+
metadata: {},
|
|
1184
|
+
...(cellType === 'code' ? { execution_count: null, outputs: [] } : {}),
|
|
1185
|
+
};
|
|
1186
|
+
nb.cells.splice(insertAt, 0, newCell);
|
|
1187
|
+
save();
|
|
1188
|
+
return { output: `Inserted new ${cellType} cell at index ${insertAt} in ${resolved}. Notebook now has ${nb.cells.length} cells.` };
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
if (err instanceof SyntaxError)
|
|
1192
|
+
return { error: `Notebook is not valid JSON: ${err.message}` };
|
|
1193
|
+
return { error: err.message };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// ─── Image tools — proxied through api.nexrall.com so API keys never leave the server ────
|
|
1197
|
+
const VALID_ASPECT_RATIOS = new Set(['1:1', '9:16', '16:9', '3:4', '4:3', '3:2', '2:3', '5:4', '4:5', '21:9', 'auto']);
|
|
1198
|
+
// Minimal HTTPS helpers (mirrors the Node built-in pattern used by fetchUrl above).
|
|
1199
|
+
// We avoid `node-fetch` here because executor.ts targets both Node 18+ (global fetch)
|
|
1200
|
+
// and older runtimes; using `https` directly is always safe.
|
|
1201
|
+
function _httpsPost(url, body, headers, timeoutMs = 30000) {
|
|
1202
|
+
return new Promise((resolve, reject) => {
|
|
1203
|
+
const u = new URL(url);
|
|
1204
|
+
const chunks = [];
|
|
1205
|
+
const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
|
|
1206
|
+
res.on('data', (c) => chunks.push(c));
|
|
1207
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }));
|
|
1208
|
+
});
|
|
1209
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out after ${timeoutMs}ms`)); });
|
|
1210
|
+
req.on('error', reject);
|
|
1211
|
+
req.write(body);
|
|
1212
|
+
req.end();
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
function _httpsGet(url, headers, timeoutMs = 20000) {
|
|
1216
|
+
return new Promise((resolve, reject) => {
|
|
1217
|
+
const u = new URL(url);
|
|
1218
|
+
const transport = u.protocol === 'https:' ? https : http;
|
|
1219
|
+
const chunks = [];
|
|
1220
|
+
const req = transport.get({ hostname: u.hostname, path: u.pathname + u.search, headers, timeout: timeoutMs }, (res) => {
|
|
1221
|
+
// Follow redirects once
|
|
1222
|
+
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
|
|
1223
|
+
req.destroy();
|
|
1224
|
+
_httpsGet(new URL(res.headers.location, url).href, headers, timeoutMs).then(resolve, reject);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
res.on('data', (c) => chunks.push(c));
|
|
1228
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks), contentType: res.headers['content-type'] ?? '' }));
|
|
1229
|
+
});
|
|
1230
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Download timed out after ${timeoutMs}ms`)); });
|
|
1231
|
+
req.on('error', reject);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* generate_image — generate an AI image via KIE (google/nano-banana) and save it to
|
|
1236
|
+
* a workspace-relative path. The backend holds the KIE key and charges a fixed fee
|
|
1237
|
+
* ($0.10) from the user's wallet on success.
|
|
1238
|
+
*
|
|
1239
|
+
* { prompt, path, aspect_ratio? }
|
|
1240
|
+
*/
|
|
1241
|
+
async function generateImage(input, workDir) {
|
|
1242
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
1243
|
+
const relPath = typeof input.path === 'string' ? input.path.trim() : '';
|
|
1244
|
+
const aspectRatio = typeof input.aspect_ratio === 'string' && VALID_ASPECT_RATIOS.has(input.aspect_ratio)
|
|
1245
|
+
? input.aspect_ratio : '1:1';
|
|
1246
|
+
if (!prompt)
|
|
1247
|
+
return { error: 'Missing required parameter: prompt' };
|
|
1248
|
+
if (!relPath)
|
|
1249
|
+
return { error: 'Missing required parameter: path' };
|
|
1250
|
+
const token = (0, auth_1.getToken)();
|
|
1251
|
+
if (!token)
|
|
1252
|
+
return { error: 'Not authenticated. Run `nexrall-code login` first.' };
|
|
1253
|
+
// 1. Request the backend to generate the image (async task + poll handled server-side).
|
|
1254
|
+
// Backend may take up to ~2.5 min — use a 170s timeout with room to spare.
|
|
1255
|
+
let imageUrl;
|
|
1256
|
+
let cost;
|
|
1257
|
+
let balance;
|
|
1258
|
+
try {
|
|
1259
|
+
const r = await _httpsPost(`${client_1.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt, aspect_ratio: aspectRatio }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 170000);
|
|
1260
|
+
let json = {};
|
|
1261
|
+
try {
|
|
1262
|
+
json = JSON.parse(r.body);
|
|
1263
|
+
}
|
|
1264
|
+
catch (_) { }
|
|
1265
|
+
if (r.status !== 200)
|
|
1266
|
+
return { error: json.error || `Server error ${r.status}` };
|
|
1267
|
+
imageUrl = json.url ?? '';
|
|
1268
|
+
cost = json.cost;
|
|
1269
|
+
balance = json.balance;
|
|
1270
|
+
if (!imageUrl)
|
|
1271
|
+
return { error: 'Image generation succeeded but no URL was returned.' };
|
|
1272
|
+
}
|
|
1273
|
+
catch (err) {
|
|
1274
|
+
return { error: `generate_image failed: ${err.message}` };
|
|
1275
|
+
}
|
|
1276
|
+
// 2. Download the image URL into the workspace.
|
|
1277
|
+
const destPath = resolvePath(relPath, workDir);
|
|
1278
|
+
try {
|
|
1279
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
1280
|
+
}
|
|
1281
|
+
catch (_) { }
|
|
1282
|
+
try {
|
|
1283
|
+
const dl = await _httpsGet(imageUrl, { 'User-Agent': FETCH_USER_AGENT }, 60000);
|
|
1284
|
+
if (dl.status !== 200)
|
|
1285
|
+
return { error: `Failed to download generated image (HTTP ${dl.status}).` };
|
|
1286
|
+
fs.writeFileSync(destPath, dl.body);
|
|
1287
|
+
const kb = (dl.body.length / 1024).toFixed(1);
|
|
1288
|
+
const costNote = cost != null ? ` ($${cost.toFixed(2)} charged` + (balance != null ? `, balance $${balance.toFixed(2)})` : ')') : '';
|
|
1289
|
+
return { output: `Generated and saved image to ${destPath} (${kb} KB)${costNote}.` };
|
|
1290
|
+
}
|
|
1291
|
+
catch (err) {
|
|
1292
|
+
return { error: `Failed to save image to ${destPath}: ${err.message}` };
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* stock_photo — search Unsplash for real photos and optionally download the top result
|
|
1297
|
+
* into the workspace. Free — no wallet charge.
|
|
1298
|
+
*
|
|
1299
|
+
* { query, path?, count?, orientation? }
|
|
1300
|
+
*/
|
|
1301
|
+
async function stockPhoto(input, workDir) {
|
|
1302
|
+
const query = typeof input.query === 'string' ? input.query.trim() : '';
|
|
1303
|
+
const relPath = typeof input.path === 'string' ? input.path.trim() : '';
|
|
1304
|
+
const count = typeof input.count === 'number' ? Math.min(Math.max(Math.floor(input.count), 1), 10) : 3;
|
|
1305
|
+
const orientation = typeof input.orientation === 'string' ? input.orientation : '';
|
|
1306
|
+
if (!query)
|
|
1307
|
+
return { error: 'Missing required parameter: query' };
|
|
1308
|
+
const token = (0, auth_1.getToken)();
|
|
1309
|
+
if (!token)
|
|
1310
|
+
return { error: 'Not authenticated. Run `nexrall-code login` first.' };
|
|
1311
|
+
// 1. Search via the backend proxy (Unsplash key lives on the server).
|
|
1312
|
+
let photos;
|
|
1313
|
+
try {
|
|
1314
|
+
const u = new URL(`${client_1.API_BASE}/api/code/assets/stock`);
|
|
1315
|
+
u.searchParams.set('query', query);
|
|
1316
|
+
u.searchParams.set('count', String(count));
|
|
1317
|
+
if (orientation)
|
|
1318
|
+
u.searchParams.set('orientation', orientation);
|
|
1319
|
+
const r = await _httpsGet(u.toString(), { Authorization: `Bearer ${token}` });
|
|
1320
|
+
let json = {};
|
|
1321
|
+
try {
|
|
1322
|
+
json = JSON.parse(r.body.toString('utf-8'));
|
|
1323
|
+
}
|
|
1324
|
+
catch (_) { }
|
|
1325
|
+
if (r.status !== 200)
|
|
1326
|
+
return { error: json.error || `Server error ${r.status}` };
|
|
1327
|
+
photos = (json.photos ?? []);
|
|
1328
|
+
if (!photos.length)
|
|
1329
|
+
return { output: `No stock photos found for "${query}".` };
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
return { error: `stock_photo search failed: ${err.message}` };
|
|
1333
|
+
}
|
|
1334
|
+
// 2a. If path given — download the top result into the workspace.
|
|
1335
|
+
if (relPath) {
|
|
1336
|
+
const top = photos[0];
|
|
1337
|
+
if (!top?.url)
|
|
1338
|
+
return { error: 'No usable photo URL returned from Unsplash.' };
|
|
1339
|
+
const destPath = resolvePath(relPath, workDir);
|
|
1340
|
+
try {
|
|
1341
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
1342
|
+
}
|
|
1343
|
+
catch (_) { }
|
|
1344
|
+
try {
|
|
1345
|
+
const dl = await _httpsGet(top.url, { 'User-Agent': FETCH_USER_AGENT });
|
|
1346
|
+
if (dl.status !== 200)
|
|
1347
|
+
return { error: `Failed to download photo (HTTP ${dl.status}).` };
|
|
1348
|
+
fs.writeFileSync(destPath, dl.body);
|
|
1349
|
+
const kb = (dl.body.length / 1024).toFixed(1);
|
|
1350
|
+
const credit = top.credit ? ` — Photo by ${top.credit} on Unsplash` : '';
|
|
1351
|
+
return { output: `Downloaded "${top.alt || query}" to ${destPath} (${kb} KB).${credit}` };
|
|
1352
|
+
}
|
|
1353
|
+
catch (err) {
|
|
1354
|
+
return { error: `Failed to save photo to ${destPath}: ${err.message}` };
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// 2b. No path — return candidate URLs so the agent can pick.
|
|
1358
|
+
const lines = photos.map((p, i) => `${i + 1}. ${p.url}${p.width && p.height ? ` (${p.width}×${p.height})` : ''}${p.alt ? ` — ${p.alt}` : ''}${p.credit ? ` · Photo by ${p.credit} on Unsplash` : ''}`);
|
|
1359
|
+
return { output: `Found ${photos.length} photo(s) for "${query}":\n${lines.join('\n')}` };
|
|
1360
|
+
}
|
|
1361
|
+
// ─── Memory tools ─────────────────────────────────────────────────────────────
|
|
1362
|
+
const MEMORY_FILE = path.join(os.homedir(), '.nexrall', 'memory.md');
|
|
1363
|
+
async function memoryWrite(input) {
|
|
1364
|
+
const content = typeof input.content === 'string' ? input.content.trim() : '';
|
|
1365
|
+
if (!content)
|
|
1366
|
+
return { error: 'content is required' };
|
|
1367
|
+
const dir = path.dirname(MEMORY_FILE);
|
|
1368
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1369
|
+
// Dedup: skip if an entry with the same first 60 chars already exists.
|
|
1370
|
+
// The old check used full substring match which blocked "React + TypeScript"
|
|
1371
|
+
// if "React" was already stored. Now we only deduplicate near-identical entries.
|
|
1372
|
+
if (fs.existsSync(MEMORY_FILE)) {
|
|
1373
|
+
const existing = fs.readFileSync(MEMORY_FILE, 'utf-8').toLowerCase();
|
|
1374
|
+
const fingerprint = content.toLowerCase().slice(0, 60);
|
|
1375
|
+
if (existing.includes(fingerprint)) {
|
|
1376
|
+
return { output: `Memory already recorded (skipped duplicate): ${content}` };
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
1380
|
+
const entry = `\n- [${timestamp}] ${content}`;
|
|
1381
|
+
fs.appendFileSync(MEMORY_FILE, entry, 'utf-8');
|
|
1382
|
+
return { output: `Memory saved: ${content}` };
|
|
1383
|
+
}
|
|
1384
|
+
async function memoryRead(_input) {
|
|
1385
|
+
try {
|
|
1386
|
+
if (!fs.existsSync(MEMORY_FILE))
|
|
1387
|
+
return { output: 'No memories saved yet.' };
|
|
1388
|
+
const content = fs.readFileSync(MEMORY_FILE, 'utf-8').trim();
|
|
1389
|
+
return { output: content || 'No memories saved yet.' };
|
|
1390
|
+
}
|
|
1391
|
+
catch (err) {
|
|
1392
|
+
return { error: err.message };
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
// ─── Tool Dispatch ────────────────────────────────────────────────────────────
|
|
1396
|
+
const TOOL_MAP = {
|
|
1397
|
+
read_file: readFile,
|
|
1398
|
+
write_file: writeFile,
|
|
1399
|
+
list_directory: listDirectory,
|
|
1400
|
+
bash: bash,
|
|
1401
|
+
search_files: searchFiles,
|
|
1402
|
+
glob: glob,
|
|
1403
|
+
create_directory: createDirectory,
|
|
1404
|
+
edit_file: editFile,
|
|
1405
|
+
multi_edit: multiEdit,
|
|
1406
|
+
copy_file: copyFile,
|
|
1407
|
+
move_file: moveFile,
|
|
1408
|
+
delete_file: deleteFile,
|
|
1409
|
+
fetch_url: fetchUrl,
|
|
1410
|
+
generate_image: generateImage,
|
|
1411
|
+
stock_photo: stockPhoto,
|
|
1412
|
+
todo_write: todoWrite,
|
|
1413
|
+
todo_read: todoRead,
|
|
1414
|
+
notebook_read: notebookRead,
|
|
1415
|
+
notebook_edit: notebookEdit,
|
|
1416
|
+
memory_write: memoryWrite,
|
|
1417
|
+
memory_read: memoryRead,
|
|
1418
|
+
bash_output: bashOutput,
|
|
1419
|
+
kill_shell: killShell,
|
|
1420
|
+
// LSP-lite fallback (regex-based). In VS Code these names are intercepted by
|
|
1421
|
+
// executeExternalTool (real language server) BEFORE reaching this executor,
|
|
1422
|
+
// so this fallback only serves the CLI / headless environments.
|
|
1423
|
+
get_symbols: symbols_1.getSymbols,
|
|
1424
|
+
get_workspace_symbols: symbols_1.getWorkspaceSymbols,
|
|
1425
|
+
};
|
|
1426
|
+
// Tools that accept a workDir for correct relative-path resolution.
|
|
1427
|
+
// TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
|
|
1428
|
+
const WORKDIR_TOOLS = new Set([
|
|
1429
|
+
'read_file', 'write_file', 'list_directory', 'search_files', 'glob',
|
|
1430
|
+
'create_directory', 'edit_file', 'multi_edit', 'copy_file', 'move_file',
|
|
1431
|
+
'delete_file', 'notebook_read', 'notebook_edit',
|
|
1432
|
+
'generate_image', 'stock_photo',
|
|
1433
|
+
'get_symbols', 'get_workspace_symbols',
|
|
1434
|
+
]);
|
|
1435
|
+
async function executeTool(name, input, abortSignal, sandbox, workDir, agentScope) {
|
|
1436
|
+
if (!TOOL_MAP[name])
|
|
1437
|
+
return { error: `Unknown tool: ${name}` };
|
|
1438
|
+
try {
|
|
1439
|
+
if (name === 'bash')
|
|
1440
|
+
return await bash(input, abortSignal, sandbox, workDir);
|
|
1441
|
+
// Todo tools are scoped per agent (top-level vs each sub-agent) so
|
|
1442
|
+
// concurrent sub-agents don't clobber each other's task lists.
|
|
1443
|
+
if (name === 'todo_write')
|
|
1444
|
+
return await todoWrite(input, agentScope);
|
|
1445
|
+
if (name === 'todo_read')
|
|
1446
|
+
return await todoRead(input, agentScope);
|
|
1447
|
+
// Inject workDir into all file-system tools so relative paths resolve
|
|
1448
|
+
// against the workspace root rather than process.cwd().
|
|
1449
|
+
if (WORKDIR_TOOLS.has(name)) {
|
|
1450
|
+
return await TOOL_MAP[name](input, workDir);
|
|
1451
|
+
}
|
|
1452
|
+
return await TOOL_MAP[name](input);
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
return { error: `Unexpected error in tool ${name}: ${err.message}` };
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
//# sourceMappingURL=executor.js.map
|