@remcp/runtime 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ import path from 'node:path';
2
+ import { constants } from 'node:fs';
3
+ import { access, copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { runtimeConfig } from '../config.mjs';
5
+ import { countEvent, recordEvent } from '../telemetry.mjs';
6
+ import { clampInteger, displayPath, fail, looksBinary, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
7
+
8
+ const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024;
9
+
10
+ async function readTextFile(absolute) {
11
+ const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
12
+ if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
13
+ if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
14
+ const buffer = await readFile(absolute);
15
+ if (looksBinary(buffer)) fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text`);
16
+ return { info, content: buffer.toString('utf8') };
17
+ }
18
+
19
+ export async function readFileTool(args) {
20
+ const absolute = await resolveSafePath(args.path);
21
+ const { content } = await readTextFile(absolute);
22
+ const lines = splitLines(content);
23
+ const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
24
+ const length = clampInteger(args.length, runtimeConfig.maxReadLines, 1, 10000);
25
+ const { start, end, slice } = pageLines(lines, offset, length);
26
+ const header = lines.length ? `${displayPath(absolute)} (lines ${start + 1}-${end} of ${lines.length})` : `${displayPath(absolute)} (empty file)`;
27
+ return text(`${header}\n${slice.join('\n')}`);
28
+ }
29
+
30
+ export async function readMultipleFilesTool(args) {
31
+ if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
32
+ if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
33
+ const sections = [];
34
+ for (const entry of args.paths) {
35
+ let absolute;
36
+ try {
37
+ absolute = await resolveSafePath(entry, 'paths[]');
38
+ } catch (error) {
39
+ sections.push(`${String(entry)}: error - ${error instanceof Error ? error.message : String(error)}`);
40
+ continue;
41
+ }
42
+ try {
43
+ const { content } = await readTextFile(absolute);
44
+ const lines = splitLines(content);
45
+ const limit = runtimeConfig.maxReadLines;
46
+ const slice = lines.slice(0, limit);
47
+ const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
48
+ sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
49
+ } catch (error) {
50
+ sections.push(`${displayPath(absolute)}: error - ${error instanceof Error ? error.message : String(error)}`);
51
+ }
52
+ }
53
+ return text(sections.join('\n\n'));
54
+ }
55
+
56
+ async function listEntry(base, depth, maxDepth, prefix) {
57
+ const entries = await readdir(base, { withFileTypes: true });
58
+ entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
59
+ const rows = [];
60
+ for (const entry of entries) {
61
+ const child = path.join(base, entry.name);
62
+ if (entry.isDirectory()) {
63
+ rows.push(`${prefix}[DIR] ${entry.name}`);
64
+ if (depth < maxDepth) rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' '));
65
+ } else if (entry.isSymbolicLink()) {
66
+ rows.push(`${prefix}[LINK] ${entry.name}`);
67
+ } else {
68
+ const info = await stat(child).catch(() => null);
69
+ rows.push(`${prefix}[FILE] ${entry.name}${info ? ` (${info.size} bytes)` : ''}`);
70
+ }
71
+ }
72
+ return rows;
73
+ }
74
+
75
+ export async function listDirectoryTool(args) {
76
+ const absolute = await resolveSafePath(args.path);
77
+ const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
78
+ if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
79
+ const depth = clampInteger(args.depth, 1, 1, 5);
80
+ const rows = await listEntry(absolute, 1, depth, '');
81
+ return text(`${displayPath(absolute)}\n${rows.join('\n') || '(empty)'}`);
82
+ }
83
+
84
+ export async function getFileInfoTool(args) {
85
+ const absolute = await resolveSafePath(args.path);
86
+ const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
87
+ const payload = {
88
+ path: displayPath(absolute),
89
+ type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : 'file',
90
+ size: info.size,
91
+ createdAt: info.birthtime.toISOString(),
92
+ modifiedAt: info.mtime.toISOString(),
93
+ permissions: `0${(info.mode & 0o777).toString(8)}`,
94
+ };
95
+ if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
96
+ const buffer = await readFile(absolute).catch(() => null);
97
+ if (buffer && !looksBinary(buffer)) {
98
+ const lines = splitLines(buffer.toString('utf8'));
99
+ payload.lineCount = lines.length;
100
+ payload.lastLine = Math.max(0, lines.length - 1);
101
+ }
102
+ }
103
+ return text(JSON.stringify(payload, null, 2));
104
+ }
105
+
106
+ function assertWritableSize(content) {
107
+ const bytes = Buffer.byteLength(content, 'utf8');
108
+ if (bytes > runtimeConfig.maxWriteBytes) {
109
+ countEvent('writeDenials');
110
+ recordEvent('write_denied', { reason: 'size_limit' });
111
+ fail(`Content is ${bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
112
+ }
113
+ return bytes;
114
+ }
115
+
116
+ export async function writeFileTool(args) {
117
+ const absolute = await resolveSafePath(args.path);
118
+ const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
119
+ const mode = String(args.mode || 'rewrite').toLowerCase();
120
+ if (!['rewrite', 'append'].includes(mode)) fail('mode must be rewrite or append');
121
+ const bytes = assertWritableSize(content);
122
+ await mkdir(path.dirname(absolute), { recursive: true });
123
+ await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
124
+ countEvent('bytesWritten', bytes);
125
+ return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
126
+ }
127
+
128
+ function normalizeForFuzzy(value) {
129
+ return splitLines(value).map(line => line.replace(/[ \t]+/g, ' ').trim());
130
+ }
131
+
132
+ // Whitespace-tolerant fallback: models frequently re-indent an exact block they just
133
+ // read. Every candidate window is compared with collapsed whitespace, and the edit is
134
+ // applied only when the number of candidate windows matches expected_replacements.
135
+ function fuzzyMatchStarts(lines, target) {
136
+ const normalized = lines.map(line => line.replace(/[ \t]+/g, ' ').trim());
137
+ const starts = [];
138
+ for (let start = 0; start + target.length <= normalized.length; start += 1) {
139
+ let equal = true;
140
+ for (let index = 0; index < target.length; index += 1) {
141
+ if (normalized[start + index] !== target[index]) { equal = false; break; }
142
+ }
143
+ if (equal) starts.push(start);
144
+ }
145
+ return starts;
146
+ }
147
+
148
+ export async function editBlockTool(args) {
149
+ const absolute = await resolveSafePath(args.file_path, 'file_path');
150
+ const oldString = typeof args.old_string === 'string' ? args.old_string : fail('old_string must be a string');
151
+ const newString = typeof args.new_string === 'string' ? args.new_string : fail('new_string must be a string');
152
+ if (!oldString) fail('old_string must not be empty');
153
+ if (oldString === newString) fail('old_string and new_string are identical');
154
+ const allowFuzzy = args.allow_fuzzy !== false;
155
+ const expected = Number.isInteger(Number(args.expected_replacements)) ? Math.max(1, Math.trunc(Number(args.expected_replacements))) : 1;
156
+ const { content } = await readTextFile(absolute);
157
+ const occurrences = content.split(oldString).length - 1;
158
+ if (occurrences === expected) {
159
+ const updated = content.split(oldString).join(newString);
160
+ assertWritableSize(updated);
161
+ await writeFile(absolute, updated, 'utf8');
162
+ const delta = splitLines(updated).length - splitLines(content).length;
163
+ return text(`Replaced ${occurrences} occurrence(s) in ${displayPath(absolute)} (${delta >= 0 ? '+' : ''}${delta} lines).`);
164
+ }
165
+ if (occurrences > 0) {
166
+ fail(`Expected ${expected} occurrence(s) of old_string but found ${occurrences}. Add more surrounding context.`);
167
+ }
168
+ if (!allowFuzzy) fail('old_string was not found in the file');
169
+ const target = normalizeForFuzzy(oldString);
170
+ const lines = splitLines(content);
171
+ const starts = target.length ? fuzzyMatchStarts(lines, target) : [];
172
+ if (starts.length !== expected) {
173
+ fail(starts.length
174
+ ? `old_string matched ${starts.length} block(s) after whitespace normalization, expected ${expected}. Add more surrounding context.`
175
+ : 'old_string was not found in the file, even after whitespace normalization');
176
+ }
177
+ const replacement = splitLines(newString);
178
+ const endsWithNewline = /\n$/.test(content);
179
+ for (const start of [...starts].reverse()) lines.splice(start, target.length, ...replacement);
180
+ const updated = `${lines.join('\n')}${endsWithNewline && lines.length ? '\n' : ''}`;
181
+ assertWritableSize(updated);
182
+ await writeFile(absolute, updated, 'utf8');
183
+ const delta = lines.length - splitLines(content).length;
184
+ return text(`Replaced ${starts.length} occurrence(s) in ${displayPath(absolute)} using whitespace-tolerant matching (${delta >= 0 ? '+' : ''}${delta} lines). Re-read the file if exact formatting matters.`);
185
+ }
186
+
187
+ export async function createDirectoryTool(args) {
188
+ const absolute = await resolveSafePath(args.path);
189
+ await mkdir(absolute, { recursive: true });
190
+ return text(`Directory ready: ${displayPath(absolute)}`);
191
+ }
192
+
193
+ async function pathExists(target) {
194
+ try { await access(target, constants.F_OK); return true; } catch { return false; }
195
+ }
196
+
197
+ export async function moveFileTool(args) {
198
+ const source = await resolveSafePath(args.source, 'source');
199
+ const destination = await resolveSafePath(args.destination, 'destination');
200
+ if (source === destination) fail('source and destination are the same path');
201
+ await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
202
+ if (await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Remove it first or pick another name.`);
203
+ await mkdir(path.dirname(destination), { recursive: true });
204
+ try {
205
+ await rename(source, destination);
206
+ } catch (error) {
207
+ if (error?.code !== 'EXDEV') throw error;
208
+ const info = await stat(source);
209
+ if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
210
+ await copyFile(source, destination);
211
+ await unlink(source);
212
+ }
213
+ return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
214
+ }
215
+
216
+ export async function copyFileTool(args) {
217
+ const source = await resolveSafePath(args.source, 'source');
218
+ const destination = await resolveSafePath(args.destination, 'destination');
219
+ if (source === destination) fail('source and destination are the same path');
220
+ const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
221
+ if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
222
+ const overwrite = args.overwrite === true;
223
+ if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
224
+ await mkdir(path.dirname(destination), { recursive: true });
225
+ await copyFile(source, destination, overwrite ? 0 : constants.COPYFILE_EXCL);
226
+ return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
227
+ }
228
+
229
+ export const fileToolHandlers = {
230
+ read_file: readFileTool,
231
+ read_multiple_files: readMultipleFilesTool,
232
+ list_directory: listDirectoryTool,
233
+ get_file_info: getFileInfoTool,
234
+ write_file: writeFileTool,
235
+ edit_block: editBlockTool,
236
+ create_directory: createDirectoryTool,
237
+ move_file: moveFileTool,
238
+ copy_file: copyFileTool,
239
+ };
@@ -0,0 +1,251 @@
1
+ import path from 'node:path';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { runtimeConfig } from '../config.mjs';
5
+ import { countEvent, recordEvent } from '../telemetry.mjs';
6
+ import {
7
+ appendSearchResults,
8
+ createSearchSession,
9
+ finishSearchSession,
10
+ getSearchSession,
11
+ listSearchSessions,
12
+ waitForSearchResults,
13
+ } from '../sessions.mjs';
14
+ import { clampInteger, displayPath, fail, globToRegExp, looksBinary, requireString, resolveSafePath, splitLines, text } from '../util.mjs';
15
+
16
+ const SKIP_DIRECTORIES = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', '.cache', '__pycache__', '.venv', 'venv']);
17
+ const SKIP_GLOBS = ['!**/node_modules/**', '!**/.git/**', '!**/.hg/**', '!**/.svn/**', '!**/dist/**', '!**/build/**', '!**/.cache/**', '!**/__pycache__/**', '!**/.venv/**', '!**/venv/**'];
18
+ const MAX_FALLBACK_FILE_BYTES = 2 * 1024 * 1024;
19
+ const MAX_PATTERN_LENGTH = 400;
20
+
21
+ let ripgrepPath;
22
+
23
+ function ripgrep() {
24
+ if (ripgrepPath !== undefined) return ripgrepPath;
25
+ try {
26
+ const result = spawnSync('rg', ['--version'], { encoding: 'utf8' });
27
+ ripgrepPath = !result.error && result.status === 0 ? 'rg' : null;
28
+ } catch { ripgrepPath = null; }
29
+ return ripgrepPath;
30
+ }
31
+
32
+ function normalizePattern(value, literal) {
33
+ const pattern = requireString(value, 'pattern');
34
+ if (pattern.length > MAX_PATTERN_LENGTH) fail(`pattern must be at most ${MAX_PATTERN_LENGTH} characters`);
35
+ if (literal) return { regex: null, literal: pattern, patternIsLiteral: true };
36
+ try { return { regex: new RegExp(pattern, 'g'), literal: null, patternIsLiteral: false }; } catch (error) {
37
+ // Silently downgrading an invalid regular expression to a substring search changes the
38
+ // meaning of the call without telling anyone.
39
+ fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)}). Pass literalSearch: true to search for this text literally.`);
40
+ }
41
+ }
42
+
43
+ // "*.js|*.ts" is the documented alternation form; ripgrep needs one -g per glob.
44
+ function splitGlobs(value) {
45
+ return String(value || '').split('|').map(part => part.trim()).filter(Boolean);
46
+ }
47
+
48
+ // A file search pattern without any glob metacharacter means "files whose name contains
49
+ // this text", which is how callers read `pattern: "auth"`.
50
+ function fileNameGlob(pattern) {
51
+ return /[*?[\]{}]/.test(pattern) ? pattern : `*${pattern}*`;
52
+ }
53
+
54
+ function matchLine(line, matcher, ignoreCase) {
55
+ if (matcher.literal) {
56
+ return ignoreCase ? line.toLowerCase().includes(matcher.literal.toLowerCase()) : line.includes(matcher.literal);
57
+ }
58
+ matcher.regex.lastIndex = 0;
59
+ return matcher.regex.test(line);
60
+ }
61
+
62
+ function formatContentResult(file, lineNumber, line, contextLines) {
63
+ const rows = [`${displayPath(file)}:${lineNumber}: ${line}`];
64
+ for (const entry of contextLines) rows.push(`${displayPath(file)}-${entry.number}- ${entry.text}`);
65
+ return rows.join('\n');
66
+ }
67
+
68
+ async function walk(target, options, onFile) {
69
+ const info = await stat(target).catch(() => fail(`Search path not found: ${displayPath(target)}`));
70
+ if (info.isFile()) { await onFile(target); return; }
71
+ const entries = await readdir(target, { withFileTypes: true });
72
+ for (const entry of entries) {
73
+ if (options.stopped()) return;
74
+ if (!options.includeHidden && entry.name.startsWith('.')) continue;
75
+ const child = path.join(target, entry.name);
76
+ if (entry.isDirectory()) {
77
+ if (options.skipDirectories.has(entry.name)) continue;
78
+ await walk(child, options, onFile);
79
+ } else if (entry.isFile()) {
80
+ await onFile(child);
81
+ }
82
+ }
83
+ }
84
+
85
+ function runRipgrep(session, { path: target, pattern, searchType, filePattern, ignoreCase, includeHidden, includeIgnored, contextLines, maxResults, patternIsLiteral }) {
86
+ // Flags must come before the `--` separator: anything after it is treated as a path,
87
+ // which silently turned `--hidden` into a search target.
88
+ const args = ['--no-heading', '--color', 'never'];
89
+ if (ignoreCase) args.push('--ignore-case');
90
+ if (includeHidden) args.push('--hidden');
91
+ if (!includeIgnored) for (const glob of SKIP_GLOBS) args.push('-g', glob);
92
+ for (const glob of splitGlobs(filePattern)) args.push('-g', glob);
93
+ if (searchType === 'files') {
94
+ args.push('--files');
95
+ args.push('-g', fileNameGlob(pattern));
96
+ args.push('--', target);
97
+ } else {
98
+ args.push('--line-number', '--with-filename');
99
+ if (patternIsLiteral) args.push('--fixed-strings');
100
+ if (contextLines) args.push('-C', String(contextLines));
101
+ args.push('--', pattern, target);
102
+ }
103
+ const child = spawn(ripgrep(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
104
+ session.cancel = () => child.kill('SIGTERM');
105
+ let buffer = '';
106
+ let stderr = '';
107
+ let collected = 0;
108
+ let capped = false;
109
+ child.stdout.on('data', chunk => {
110
+ buffer += chunk.toString('utf8');
111
+ const lines = buffer.split('\n');
112
+ buffer = lines.pop() ?? '';
113
+ const batch = [];
114
+ for (const line of lines) {
115
+ if (!line.trim()) continue;
116
+ batch.push(line);
117
+ collected += 1;
118
+ if (collected >= maxResults) { capped = true; child.kill('SIGTERM'); break; }
119
+ }
120
+ appendSearchResults(session, batch);
121
+ });
122
+ child.stderr.on('data', chunk => { stderr = `${stderr}${chunk.toString('utf8')}`.slice(-2000); });
123
+ child.on('error', error => finishSearchSession(session, 'failed', error.message));
124
+ child.on('close', code => {
125
+ if (session.status !== 'running') { finishSearchSession(session, session.status); return; }
126
+ // ripgrep exits 2 for a real error (bad pattern, unreadable path) and 0/1 otherwise.
127
+ if (code === 2 && stderr.trim()) { finishSearchSession(session, 'failed', stderr.trim().split('\n')[0]); return; }
128
+ finishSearchSession(session, capped ? 'capped' : 'completed');
129
+ });
130
+ }
131
+
132
+ async function runFallback(session, { path: target, matcher, searchType, filePattern, ignoreCase, contextLines, maxResults, includeHidden, includeIgnored }) {
133
+ const fileGlobs = splitGlobs(filePattern).map(globToRegExp);
134
+ const nameGlob = searchType === 'files' ? globToRegExp(fileNameGlob(session.pattern)) : null;
135
+ const nameLiteral = searchType === 'files' && !/[*?[\]{}]/.test(session.pattern) ? session.pattern : null;
136
+ let collected = 0;
137
+ await walk(target, { includeHidden, skipDirectories: includeIgnored ? new Set() : SKIP_DIRECTORIES, stopped: () => session.status !== 'running' || collected >= maxResults }, async file => {
138
+ if (session.status !== 'running' || collected >= maxResults) return;
139
+ if (fileGlobs.length && !fileGlobs.some(glob => glob.test(path.basename(file)))) return;
140
+ if (searchType === 'files') {
141
+ const base = path.basename(file);
142
+ const matches = (nameGlob && nameGlob.test(base)) || (nameLiteral && (ignoreCase ? base.toLowerCase().includes(nameLiteral.toLowerCase()) : base.includes(nameLiteral)));
143
+ if (!matches) return;
144
+ collected += 1;
145
+ appendSearchResults(session, [displayPath(file)]);
146
+ return;
147
+ }
148
+ const info = await stat(file).catch(() => null);
149
+ if (!info || info.size > MAX_FALLBACK_FILE_BYTES) return;
150
+ const buffer = await readFile(file).catch(() => null);
151
+ if (!buffer || looksBinary(buffer)) return;
152
+ const lines = splitLines(buffer.toString('utf8'));
153
+ for (let index = 0; index < lines.length; index += 1) {
154
+ if (session.status !== 'running' || collected >= maxResults) return;
155
+ if (!matchLine(lines[index], matcher, ignoreCase)) continue;
156
+ const context = [];
157
+ if (contextLines) {
158
+ for (let offset = Math.max(0, index - contextLines); offset <= Math.min(lines.length - 1, index + contextLines); offset += 1) {
159
+ if (offset === index) continue;
160
+ context.push({ number: offset + 1, text: lines[offset] });
161
+ }
162
+ }
163
+ collected += 1;
164
+ appendSearchResults(session, [formatContentResult(file, index + 1, lines[index], context)]);
165
+ }
166
+ });
167
+ finishSearchSession(session, session.status === 'running' ? (collected >= maxResults ? 'capped' : 'completed') : session.status);
168
+ }
169
+
170
+ export async function startSearchTool(args) {
171
+ const target = await resolveSafePath(args.path);
172
+ await stat(target).catch(() => fail(`Search path not found: ${displayPath(target)}`));
173
+ const pattern = requireString(args.pattern, 'pattern');
174
+ const searchType = String(args.searchType || 'content').toLowerCase();
175
+ if (!['content', 'files'].includes(searchType)) fail('searchType must be content or files');
176
+ const filePattern = typeof args.filePattern === 'string' && args.filePattern.trim() ? args.filePattern.trim() : null;
177
+ if (filePattern && path.isAbsolute(filePattern)) fail('filePattern must be a relative glob such as "*.ts"');
178
+ const ignoreCase = args.ignoreCase === true;
179
+ const includeHidden = args.includeHidden === true;
180
+ const includeIgnored = args.includeIgnored === true;
181
+ const contextLines = clampInteger(args.contextLines, 0, 0, 10);
182
+ const maxResults = clampInteger(args.maxResults, 200, 1, 5000);
183
+ const matcher = searchType === 'content' ? normalizePattern(pattern, args.literalSearch === true) : { regex: null, literal: pattern, patternIsLiteral: false };
184
+ const session = createSearchSession({ type: searchType, pattern, path: target, filePattern });
185
+ countEvent('searchesStarted');
186
+ recordEvent('session_started', { sessionKind: 'search', success: true });
187
+ const options = { path: target, pattern, searchType, filePattern, ignoreCase, includeHidden, includeIgnored, contextLines, maxResults, matcher, patternIsLiteral: matcher.patternIsLiteral === true };
188
+ // Literal searches also go to ripgrep through --fixed-strings; the JavaScript fallback
189
+ // only runs when ripgrep is unavailable.
190
+ if (ripgrep()) runRipgrep(session, options);
191
+ else void runFallback(session, options).catch(error => finishSearchSession(session, 'failed', error instanceof Error ? error.message : String(error)));
192
+ await waitForSearchResults(session, 1, 1500);
193
+ const initial = session.results.slice(0, 50);
194
+ const status = session.error ? `failed: ${session.error}` : session.status;
195
+ return text([
196
+ `searchId: ${session.id} · type: ${searchType} · status: ${status} · results so far: ${session.results.length}`,
197
+ `pattern: ${pattern} · path: ${displayPath(target)}`,
198
+ initial.join('\n'),
199
+ session.results.length > initial.length ? `… ${session.results.length - initial.length} more results buffered; use get_more_search_results with searchId ${session.id}` : '',
200
+ ].filter(Boolean).join('\n'));
201
+ }
202
+
203
+ export async function getMoreSearchResultsTool(args) {
204
+ const sessionId = requireString(args.sessionId, 'sessionId');
205
+ const session = getSearchSession(sessionId) || fail(`No search session ${sessionId}`);
206
+ const length = clampInteger(args.length, 100, 1, 1000);
207
+ const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
208
+ if (offset >= 0) await waitForSearchResults(session, offset + length, 1000);
209
+ const total = session.results.length;
210
+ let start;
211
+ let end;
212
+ if (offset < 0) {
213
+ start = Math.max(0, total + offset);
214
+ end = total;
215
+ } else {
216
+ start = Math.min(offset, total);
217
+ end = Math.min(start + length, total);
218
+ }
219
+ const status = session.error ? `failed: ${session.error}` : session.status;
220
+ return text([
221
+ `searchId: ${session.id} · status: ${status} · results ${start}-${end} of ${total}`,
222
+ session.results.slice(start, end).join('\n'),
223
+ ].filter(Boolean).join('\n'));
224
+ }
225
+
226
+ export async function stopSearchTool(args) {
227
+ const sessionId = requireString(args.sessionId, 'sessionId');
228
+ const session = getSearchSession(sessionId) || fail(`No search session ${sessionId}`);
229
+ if (session.status !== 'running') return text(`Search ${sessionId} already ${session.status} with ${session.results.length} results.`);
230
+ session.status = 'stopped';
231
+ try { session.cancel?.(); } catch {}
232
+ finishSearchSession(session, 'stopped');
233
+ return text(`Stopped search ${sessionId} with ${session.results.length} results buffered.`);
234
+ }
235
+
236
+ export async function listSearchesTool() {
237
+ const sessions = listSearchSessions();
238
+ if (!sessions.length) return text('No active searches.');
239
+ const rows = sessions.map(session => {
240
+ const runtimeMs = (session.finishedAt || Date.now()) - session.startedAt;
241
+ return `${session.id} · ${session.type} · ${session.status} · ${session.results.length} results · ${Math.round(runtimeMs / 1000)}s · ${session.pattern} (${displayPath(session.path)})`;
242
+ });
243
+ return text(rows.join('\n'));
244
+ }
245
+
246
+ export const searchToolHandlers = {
247
+ start_search: startSearchTool,
248
+ get_more_search_results: getMoreSearchResultsTool,
249
+ stop_search: stopSearchTool,
250
+ list_searches: listSearchesTool,
251
+ };
@@ -0,0 +1,71 @@
1
+ import process from 'node:process';
2
+ import { describeConfig, runtimeConfig } from '../config.mjs';
3
+ import { dangerousPatternIds } from '../policy.mjs';
4
+ import { listProcessSessions, listSearchSessions } from '../sessions.mjs';
5
+ import { telemetryStatus } from '../telemetry.mjs';
6
+ import { VERSION } from '../version.mjs';
7
+ import { text } from '../util.mjs';
8
+
9
+ // Read-only introspection. Unlike the upstream Desktop Commander there is deliberately
10
+ // no set_config_value: a model must not be able to rewrite its own device limits.
11
+ export async function getRuntimeInfoTool() {
12
+ const telemetry = telemetryStatus();
13
+ return text(JSON.stringify({
14
+ version: VERSION,
15
+ runtime: describeConfig(),
16
+ policy: {
17
+ allowedRoots: [...runtimeConfig.allowedRoots],
18
+ blockedCommands: [...runtimeConfig.blockedCommands],
19
+ dangerousCommands: runtimeConfig.dangerousCommands,
20
+ builtinGuardrailIds: dangerousPatternIds,
21
+ note: 'Guardrails reduce accidents and prompt-injected one-liners; they are not an operating-system sandbox.',
22
+ },
23
+ telemetry: {
24
+ enabled: telemetry.enabled,
25
+ transport: telemetry.transport,
26
+ endpoint: telemetry.endpoint,
27
+ thirdParty: telemetry.thirdParty,
28
+ installPing: telemetry.installPing,
29
+ remoteFeatureFlags: telemetry.remoteFeatureFlags,
30
+ },
31
+ limits: {
32
+ maxOutputBytes: runtimeConfig.maxOutputBytes,
33
+ maxReadLines: runtimeConfig.maxReadLines,
34
+ maxBufferedLines: runtimeConfig.maxBufferedLines,
35
+ maxWriteBytes: runtimeConfig.maxWriteBytes,
36
+ maxConcurrentConnections: 1,
37
+ },
38
+ }, null, 2));
39
+ }
40
+
41
+ export async function getRuntimeStatsTool() {
42
+ const telemetry = telemetryStatus();
43
+ const processes = listProcessSessions();
44
+ const searches = listSearchSessions();
45
+ const running = processes.filter(session => !session.exited).length;
46
+ return text(JSON.stringify({
47
+ runtimeVersion: VERSION,
48
+ uptimeSeconds: telemetry.uptimeSeconds,
49
+ counters: telemetry.counters,
50
+ topTools: telemetry.topTools,
51
+ sessions: {
52
+ processSessions: processes.length,
53
+ processSessionsRunning: running,
54
+ searchSessions: searches.length,
55
+ searchSessionsRunning: searches.filter(session => session.status === 'running').length,
56
+ },
57
+ telemetry: {
58
+ enabled: telemetry.enabled,
59
+ transport: telemetry.transport,
60
+ endpoint: telemetry.endpoint,
61
+ buffered: telemetry.buffered,
62
+ sentEvents: telemetry.sentEvents,
63
+ droppedEvents: telemetry.droppedEvents,
64
+ },
65
+ }, null, 2));
66
+ }
67
+
68
+ export const statsToolHandlers = {
69
+ get_runtime_info: getRuntimeInfoTool,
70
+ get_runtime_stats: getRuntimeStatsTool,
71
+ };
@@ -0,0 +1,64 @@
1
+ import process from 'node:process';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { clampInteger, fail, requireInteger, text } from '../util.mjs';
5
+
6
+ const run = promisify(execFile);
7
+
8
+ const PROTECTED_PIDS = new Set([0, 1]);
9
+ const SECRET_FLAG = /((?:^|\s)(?:--)?[A-Za-z0-9_-]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|AUTHORIZATION)[A-Za-z0-9_-]*)([=:]\s*)(\S+)/gi;
10
+
11
+ export function redactSecrets(command) {
12
+ return String(command).replace(SECRET_FLAG, '$1$2***');
13
+ }
14
+
15
+ export async function listProcessesTool(args = {}) {
16
+ const limit = clampInteger(args.limit, 100, 1, 1000);
17
+ const rows = [];
18
+ const parsed = [];
19
+ if (process.platform === 'win32') {
20
+ const { stdout } = await run('tasklist', ['/FO', 'CSV', '/NH'], { maxBuffer: 8 * 1024 * 1024 }).catch(error => fail(`Could not list processes: ${error.message}`));
21
+ for (const line of stdout.split(/\r?\n/)) {
22
+ const parts = line.split('","').map(part => part.replace(/^"|"$/g, ''));
23
+ if (parts.length >= 5) parsed.push({ mem: parts[4], row: `${redactSecrets(parts[0])},${parts[1]},${parts[2]},${parts[4]}` });
24
+ }
25
+ } else {
26
+ const { stdout } = await run('ps', ['-eo', 'pid=,ppid=,pcpu=,pmem=,etime=,comm=,args='], { maxBuffer: 8 * 1024 * 1024 }).catch(error => fail(`Could not list processes: ${error.message}`));
27
+ for (const line of stdout.split('\n')) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed) continue;
30
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s*(.*)$/);
31
+ if (!match) continue;
32
+ const [, pid, ppid, cpu, mem, elapsed, comm, args] = match;
33
+ parsed.push({ cpu: Number(cpu), mem: Number(mem), row: `${pid},${ppid},${cpu},${mem},${elapsed},${redactSecrets((args || comm).slice(0, 200))}` });
34
+ }
35
+ parsed.sort((a, b) => b.cpu - a.cpu || b.mem - a.mem);
36
+ }
37
+ rows.push('pid,ppid,cpu%,mem%,elapsed,command');
38
+ rows.push(...parsed.slice(0, limit).map(entry => entry.row));
39
+ if (parsed.length > limit) rows.push(`… ${parsed.length - limit} more processes hidden; call list_processes with a higher limit to see them`);
40
+ return text(rows.join('\n'));
41
+ }
42
+
43
+ export async function killProcessTool(args) {
44
+ const pid = requireInteger(args.pid, 'pid');
45
+ if (PROTECTED_PIDS.has(pid)) fail(`Refusing to terminate protected pid ${pid}`);
46
+ if (pid === process.pid) fail('Refusing to terminate the ReMCP runtime process');
47
+ if (pid === process.ppid) fail('Refusing to terminate the ReMCP agent process that hosts this runtime');
48
+ try {
49
+ if (process.platform === 'win32') await run('taskkill', ['/PID', String(pid), '/T', '/F']);
50
+ else process.kill(pid, 'SIGTERM');
51
+ } catch (error) {
52
+ fail(`Could not terminate pid ${pid}: ${error instanceof Error ? error.message : String(error)}`);
53
+ }
54
+ if (process.platform !== 'win32') {
55
+ await new Promise(resolve => setTimeout(resolve, 1500));
56
+ try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch {}
57
+ }
58
+ return text(`Termination signal sent to pid ${pid}.`);
59
+ }
60
+
61
+ export const systemToolHandlers = {
62
+ list_processes: listProcessesTool,
63
+ kill_process: killProcessTool,
64
+ };