@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.
package/src/config.mjs ADDED
@@ -0,0 +1,84 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readFileSync } from 'node:fs';
4
+ import { expandHome } from './util.mjs';
5
+
6
+ const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
7
+ export const runtimeConfigPath = path.join(configDir, 'runtime.json');
8
+ export const runtimeConfigDir = configDir;
9
+
10
+ function readConfigFile() {
11
+ try { return JSON.parse(readFileSync(runtimeConfigPath, 'utf8')); } catch { return {}; }
12
+ }
13
+
14
+ const file = readConfigFile();
15
+
16
+ function stringList(value, fallback = []) {
17
+ const source = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : fallback;
18
+ return source.map(item => String(item).trim()).filter(Boolean);
19
+ }
20
+
21
+ function positiveNumber(value, fallback) {
22
+ const parsed = Number(value);
23
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
24
+ }
25
+
26
+ function booleanValue(value, fallback) {
27
+ if (value === undefined || value === null || value === '') return fallback;
28
+ if (typeof value === 'boolean') return value;
29
+ const normalized = String(value).trim().toLowerCase();
30
+ if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) return true;
31
+ if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) return false;
32
+ return fallback;
33
+ }
34
+
35
+ const DANGEROUS_MODES = ['block', 'warn', 'allow'];
36
+
37
+ function dangerousMode(value, fallback = 'block') {
38
+ const normalized = String(value ?? '').trim().toLowerCase();
39
+ return DANGEROUS_MODES.includes(normalized) ? normalized : fallback;
40
+ }
41
+
42
+ const allowedRoots = stringList(process.env.REMCP_RUNTIME_ALLOWED_ROOTS ?? file.allowedRoots)
43
+ .map(root => path.resolve(expandHome(root)));
44
+
45
+ // Telemetry is opt-out, matching the ReMCP client: it is on unless the user (or the
46
+ // ReMCP agent that spawned this runtime) turns it off. It never sends file paths,
47
+ // command strings, arguments, or tool output - only tool names, timings and outcomes.
48
+ const telemetryDisabled = booleanValue(process.env.REMCP_RUNTIME_DISABLE_TELEMETRY, false);
49
+ const telemetryEnabled = telemetryDisabled
50
+ ? false
51
+ : booleanValue(process.env.REMCP_RUNTIME_TELEMETRY ?? file.telemetryEnabled, true);
52
+
53
+ export const runtimeConfig = Object.freeze({
54
+ allowedRoots: Object.freeze(allowedRoots),
55
+ blockedCommands: Object.freeze(stringList(process.env.REMCP_RUNTIME_BLOCKED_COMMANDS ?? file.blockedCommands)),
56
+ dangerousCommands: dangerousMode(process.env.REMCP_RUNTIME_DANGEROUS_COMMANDS ?? file.dangerousCommands),
57
+ maxOutputBytes: positiveNumber(process.env.REMCP_RUNTIME_MAX_OUTPUT_BYTES ?? file.maxOutputBytes, 1024 * 1024),
58
+ maxReadLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_READ_LINES ?? file.maxReadLines, 2000),
59
+ maxBufferedLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_BUFFERED_LINES ?? file.maxBufferedLines, 50000),
60
+ maxWriteBytes: positiveNumber(process.env.REMCP_RUNTIME_MAX_WRITE_BYTES ?? file.maxWriteBytes, 8 * 1024 * 1024),
61
+ defaultShell: String(process.env.REMCP_RUNTIME_SHELL || file.defaultShell || '').trim(),
62
+ name: String(process.env.REMCP_RUNTIME_NAME || file.name || os.hostname()).trim(),
63
+ telemetryEnabled,
64
+ });
65
+
66
+ export function describeConfig() {
67
+ return {
68
+ name: runtimeConfig.name,
69
+ platform: process.platform,
70
+ arch: process.arch,
71
+ node: process.versions.node,
72
+ configFile: runtimeConfigPath,
73
+ allowedRoots: [...runtimeConfig.allowedRoots],
74
+ blockedCommands: [...runtimeConfig.blockedCommands],
75
+ dangerousCommands: runtimeConfig.dangerousCommands,
76
+ maxOutputBytes: runtimeConfig.maxOutputBytes,
77
+ maxReadLines: runtimeConfig.maxReadLines,
78
+ maxBufferedLines: runtimeConfig.maxBufferedLines,
79
+ maxWriteBytes: runtimeConfig.maxWriteBytes,
80
+ defaultShell: runtimeConfig.defaultShell || null,
81
+ telemetryEnabled: runtimeConfig.telemetryEnabled,
82
+ telemetryTransport: 'paired-agent-only',
83
+ };
84
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { toolDefinitions } from './catalog.mjs';
6
+ import { describeConfig, runtimeConfigDir } from './config.mjs';
7
+ import { invokeTool } from './invoke.mjs';
8
+ import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
9
+ import { flush, setTelemetrySink, shutdownTelemetry, telemetryEnabled } from './telemetry.mjs';
10
+ import { VERSION } from './version.mjs';
11
+
12
+ // The MCP SDK is imported lazily so that `--help`, `--version`, `--print-tools` and
13
+ // `--describe` work from a bare checkout or a published tarball with no node_modules.
14
+ // That is what lets CI diff the advertised tool contract against the package users get.
15
+
16
+ const args = process.argv.slice(2);
17
+
18
+ if (args.includes('--help') || args.includes('-h')) {
19
+ process.stdout.write([
20
+ 'ReMCP local device runtime',
21
+ '',
22
+ 'Usage: remcp-runtime [--print-tools] [--describe] [--version]',
23
+ '',
24
+ 'The runtime is normally started by the ReMCP device agent and speaks MCP over stdio.',
25
+ 'It executes file, search, terminal, and process tools locally on this computer.',
26
+ '',
27
+ 'Usage metrics are opt-out: only tool names, timings and outcomes are collected, and they',
28
+ 'travel to your own ReMCP account through the already-authenticated paired agent. Nothing is',
29
+ 'sent to a third party, there is no install ping, and there are no remote feature flags.',
30
+ 'Disable with `remcp telemetry off`, REMCP_RUNTIME_DISABLE_TELEMETRY=1, or',
31
+ '"telemetryEnabled": false in runtime.json.',
32
+ '',
33
+ ].join('\n'));
34
+ process.exit(0);
35
+ }
36
+
37
+ if (args.includes('--version')) {
38
+ process.stdout.write(`${VERSION}\n`);
39
+ process.exit(0);
40
+ }
41
+
42
+ if (args.includes('--print-tools')) {
43
+ process.stdout.write(`${JSON.stringify(toolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })), null, 2)}\n`);
44
+ process.exit(0);
45
+ }
46
+
47
+ if (args.includes('--describe')) {
48
+ process.stdout.write(`${JSON.stringify({ version: VERSION, tools: toolDefinitions.length, ...describeConfig() }, null, 2)}\n`);
49
+ process.exit(0);
50
+ }
51
+
52
+ function announceTelemetryOnce() {
53
+ if (!telemetryEnabled()) return;
54
+ const marker = path.join(runtimeConfigDir, '.telemetry-notice');
55
+ try {
56
+ if (existsSync(marker)) return;
57
+ mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
58
+ writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 });
59
+ } catch { return; }
60
+ console.error('ReMCP runtime: anonymous usage metrics are on (tool names, timings, outcomes only - never file paths, commands or output). They go to your own ReMCP account through the paired agent. Disable with `remcp telemetry off`.');
61
+ }
62
+
63
+ const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
64
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
65
+ const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
66
+
67
+ const server = new Server(
68
+ { name: 'remcp-runtime', version: VERSION },
69
+ {
70
+ capabilities: { tools: {}, experimental: { 'remcp/telemetry': { version: 1, optOut: true } } },
71
+ instructions: [
72
+ 'This runtime executes file, search, terminal, and process tools locally on a computer that its owner paired with ReMCP.',
73
+ 'Inspect before changing: read and list first, then write, edit, move, or run commands only when the user asked for that side effect.',
74
+ 'Paths are absolute or resolve against the runtime working directory; prefer absolute paths.',
75
+ 'Commands matching the built-in catastrophic-command guardrail are refused before they run.',
76
+ ].join(' '),
77
+ },
78
+ );
79
+
80
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
81
+ tools: toolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })),
82
+ }));
83
+
84
+ server.setRequestHandler(CallToolRequestSchema, async request => {
85
+ return invokeTool(request.params.name, request.params.arguments);
86
+ });
87
+
88
+ // Telemetry leaves this process only as an MCP notification to the agent that started
89
+ // it. The runtime never opens a network connection of its own.
90
+ setTelemetrySink(async payload => {
91
+ await server.notification({ method: 'notifications/remcp/telemetry', params: payload });
92
+ });
93
+
94
+ async function shutdown(code = 0) {
95
+ shutdownSessions();
96
+ try { await flush(); } catch {}
97
+ shutdownTelemetry();
98
+ try { await server.close(); } catch {}
99
+ process.exit(code);
100
+ }
101
+
102
+ process.on('SIGINT', () => void shutdown(0));
103
+ process.on('SIGTERM', () => void shutdown(0));
104
+ process.on('exit', () => { shutdownSessions(); shutdownTelemetry(); });
105
+
106
+ const transport = new StdioServerTransport();
107
+ await server.connect(transport);
108
+ startSessionSweeper();
109
+ announceTelemetryOnce();
110
+ console.error(`ReMCP runtime ${VERSION} ready with ${toolDefinitions.length} tools`);
package/src/invoke.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { toolHandlers } from './catalog.mjs';
2
+ import { recordEvent } from './telemetry.mjs';
3
+ import { ToolError, text } from './util.mjs';
4
+
5
+ export function hasTool(name) {
6
+ return toolHandlers.has(name);
7
+ }
8
+
9
+ function errorKind(error) {
10
+ if (error instanceof ToolError) return 'tool_error';
11
+ const code = error?.code;
12
+ return typeof code === 'string' && code ? code.slice(0, 32) : 'runtime_error';
13
+ }
14
+
15
+ export async function invokeTool(name, args = {}) {
16
+ const started = performance.now();
17
+ const definition = toolHandlers.get(name);
18
+ if (!definition) {
19
+ recordEvent('tool_call', { tool: 'unknown_tool', success: false, errorKind: 'unknown_tool', durationMs: 0 });
20
+ return text(`Unknown tool: ${name}`, true);
21
+ }
22
+ try {
23
+ const result = await definition.handler(args || {});
24
+ recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: result?.isError !== true });
25
+ return result;
26
+ } catch (error) {
27
+ recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: false, errorKind: errorKind(error) });
28
+ if (error instanceof ToolError) return text(error.message, true);
29
+ return text(`Tool ${name} failed: ${error instanceof Error ? error.message : String(error)}`, true);
30
+ }
31
+ }
package/src/policy.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { runtimeConfig } from './config.mjs';
2
+ import { fail } from './util.mjs';
3
+
4
+ // Catastrophic host-level commands that a remote model should never run by accident.
5
+ // This is a guardrail, not a sandbox: it protects against obvious mistakes and
6
+ // prompt-injected one-liners, not against a determined adversary who already has
7
+ // shell access through the paired account.
8
+ const DANGEROUS_PATTERNS = [
9
+ { id: 'filesystem-format', description: 'formats a filesystem', pattern: /\bmkfs(\.[a-z0-9]+)?\b/i },
10
+ { id: 'raw-disk-write', description: 'writes raw data to a block device', pattern: /\bdd\b[^\n]*\bof=\s*\/dev\//i },
11
+ { id: 'disk-partition', description: 'repartitions a disk', pattern: /\b(fdisk|sfdisk|cfdisk|parted|diskpart)\b/i },
12
+ { id: 'redirect-to-device', description: 'redirects output into a block device', pattern: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
13
+ { id: 'host-power', description: 'powers off or reboots the machine', pattern: /\b(shutdown|reboot|halt|poweroff)\b/i },
14
+ { id: 'init-runlevel', description: 'changes the init runlevel', pattern: /\binit\s+[06]\b/i },
15
+ { id: 'fork-bomb', description: 'starts a fork bomb', pattern: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
16
+ { id: 'recursive-root-delete', description: 'recursively deletes a root or home path', pattern: /\brm\b(?=[^\n]*\s-[a-z]*r)(?=[^\n]*\s-[a-z]*f)[^\n]*\s(\/|\/\*|~|\$HOME|\$\{HOME\})(\s|$)/i },
17
+ { id: 'chmod-root', description: 'recursively rewrites permissions on a root path', pattern: /\bchmod\b[^\n]*\s(\/|\/\*)(\s|$)/i },
18
+ { id: 'chown-root', description: 'recursively rewrites ownership on a root path', pattern: /\bchown\b[^\n]*\s(\/|\/\*)(\s|$)/i },
19
+ { id: 'history-rewrite', description: 'clears shell history to hide activity', pattern: /\b(history\s+-c|shred\b[^\n]*\.bash_history|>\s*~?\/?\.bash_history)/i },
20
+ { id: 'windows-destructive', description: 'destroys Windows system state', pattern: /\b(format|diskpart|bcdedit|cipher\s+\/w)\b/i },
21
+ ];
22
+
23
+ function normalize(command) {
24
+ return String(command).replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ export function checkCommand(command) {
28
+ const normalized = normalize(command);
29
+ const findings = [];
30
+ for (const blocked of runtimeConfig.blockedCommands) {
31
+ if (normalized.toLowerCase().includes(blocked.toLowerCase())) findings.push({ id: 'policy', description: blocked, source: 'device-policy' });
32
+ }
33
+ if (runtimeConfig.dangerousCommands !== 'allow') {
34
+ for (const entry of DANGEROUS_PATTERNS) {
35
+ if (entry.pattern.test(normalized)) findings.push({ id: entry.id, description: entry.description, source: 'builtin' });
36
+ }
37
+ }
38
+ if (!findings.length) return { warned: false, mode: runtimeConfig.dangerousCommands };
39
+ const unique = [...new Map(findings.map(item => [item.id + item.description, item])).values()];
40
+ if (runtimeConfig.dangerousCommands === 'warn' && unique.every(item => item.source === 'builtin')) {
41
+ return { warned: true, mode: 'warn', findings: unique };
42
+ }
43
+ return { blocked: true, mode: runtimeConfig.dangerousCommands, findings: unique };
44
+ }
45
+
46
+ export function assertAllowedCommand(command) {
47
+ const verdict = checkCommand(command);
48
+ if (verdict.blocked) {
49
+ const detail = verdict.findings.map(item => item.description).join(', ');
50
+ fail(`Command blocked by ReMCP device policy (${detail}). Set REMCP_RUNTIME_BLOCKED_COMMANDS to adjust the device list, or REMCP_RUNTIME_DANGEROUS_COMMANDS=allow to disable the built-in catastrophic-command guardrail.`);
51
+ }
52
+ return verdict;
53
+ }
54
+
55
+ export const dangerousPatternIds = DANGEROUS_PATTERNS.map(entry => entry.id);
@@ -0,0 +1,219 @@
1
+ import { runtimeConfig } from './config.mjs';
2
+
3
+ const processSessions = new Map();
4
+ const searchSessions = new Map();
5
+ let searchCounter = 0;
6
+
7
+ const EXITED_SESSION_TTL_MS = 30 * 60 * 1000;
8
+
9
+ function trimBuffer(session) {
10
+ const overflow = session.lines.length - runtimeConfig.maxBufferedLines;
11
+ if (overflow <= 0) return;
12
+ session.lines.splice(0, overflow);
13
+ session.droppedLines += overflow;
14
+ }
15
+
16
+ function notify(session) {
17
+ const waiters = session.waiters.splice(0, session.waiters.length);
18
+ for (const resolve of waiters) resolve();
19
+ }
20
+
21
+ // A waiter whose timer fires first must remove itself, otherwise finished sessions keep
22
+ // holding closures until the next append (which may never come).
23
+ function waiter(session, timeoutMs) {
24
+ return new Promise(resolve => {
25
+ let settled = false;
26
+ const finish = () => {
27
+ if (settled) return;
28
+ settled = true;
29
+ clearTimeout(timer);
30
+ const index = session.waiters.indexOf(finish);
31
+ if (index !== -1) session.waiters.splice(index, 1);
32
+ resolve();
33
+ };
34
+ const timer = setTimeout(finish, Math.max(0, timeoutMs));
35
+ timer.unref?.();
36
+ session.waiters.push(finish);
37
+ });
38
+ }
39
+
40
+ export function createProcessSession({ pid, child, command, shell }) {
41
+ const session = {
42
+ pid,
43
+ child,
44
+ command,
45
+ shell,
46
+ startedAt: Date.now(),
47
+ finishedAt: null,
48
+ lines: [],
49
+ partial: '',
50
+ lastPartialRead: null,
51
+ droppedLines: 0,
52
+ cursor: 0,
53
+ exitCode: null,
54
+ signal: null,
55
+ exited: false,
56
+ waiters: [],
57
+ lastActivityAt: Date.now(),
58
+ };
59
+ processSessions.set(pid, session);
60
+ return session;
61
+ }
62
+
63
+ export function appendProcessOutput(session, chunk) {
64
+ const combined = session.partial + chunk;
65
+ const parts = combined.split('\n');
66
+ session.partial = parts.pop() ?? '';
67
+ for (const line of parts) session.lines.push(line);
68
+ trimBuffer(session);
69
+ session.lastActivityAt = Date.now();
70
+ notify(session);
71
+ }
72
+
73
+ export function markProcessExited(session, code, signal) {
74
+ if (session.partial) {
75
+ session.lines.push(session.partial);
76
+ session.partial = '';
77
+ }
78
+ session.exited = true;
79
+ session.exitCode = code;
80
+ session.signal = signal;
81
+ session.finishedAt = Date.now();
82
+ notify(session);
83
+ }
84
+
85
+ export function getProcessSession(pid) {
86
+ return processSessions.get(pid) || null;
87
+ }
88
+
89
+ export function listProcessSessions() {
90
+ sweep();
91
+ return [...processSessions.values()].sort((a, b) => a.startedAt - b.startedAt);
92
+ }
93
+
94
+ export function absoluteLine(session, index) {
95
+ return session.droppedLines + index + 1;
96
+ }
97
+
98
+ export function totalLines(session) {
99
+ return session.droppedLines + session.lines.length + (session.partial ? 1 : 0);
100
+ }
101
+
102
+ export function readNewOutput(session) {
103
+ const complete = session.lines.slice(Math.max(0, session.cursor - session.droppedLines));
104
+ session.cursor = session.droppedLines + session.lines.length;
105
+ const parts = [...complete];
106
+ if (session.partial && session.partial !== session.lastPartialRead) parts.push(session.partial);
107
+ session.lastPartialRead = session.partial || null;
108
+ return parts;
109
+ }
110
+
111
+ export function readOutputRange(session, offset, length) {
112
+ const snapshot = session.lines.concat(session.partial ? [session.partial] : []);
113
+ const total = session.droppedLines + snapshot.length;
114
+ const requested = Number.isFinite(Number(offset)) ? Math.trunc(Number(offset)) : 0;
115
+ let start;
116
+ let end;
117
+ if (requested < 0) {
118
+ start = Math.max(0, snapshot.length + requested);
119
+ end = snapshot.length;
120
+ } else {
121
+ start = Math.max(0, Math.min(requested - session.droppedLines, snapshot.length));
122
+ end = Math.min(snapshot.length, start + Math.max(1, Math.trunc(length || 200)));
123
+ }
124
+ session.cursor = session.droppedLines + session.lines.length;
125
+ return { slice: snapshot.slice(start, end), start: session.droppedLines + start + 1, end: session.droppedLines + end, total };
126
+ }
127
+
128
+ export async function waitForProcessActivity(session, timeoutMs) {
129
+ if (session.exited) return;
130
+ await waiter(session, timeoutMs);
131
+ }
132
+
133
+ export async function waitForProcessExit(session, timeoutMs) {
134
+ const deadline = Date.now() + Math.max(0, timeoutMs);
135
+ while (!session.exited && Date.now() < deadline) {
136
+ await waitForProcessActivity(session, Math.min(100, Math.max(10, deadline - Date.now())));
137
+ }
138
+ }
139
+
140
+ export function createSearchSession({ type, pattern, path, filePattern }) {
141
+ const session = {
142
+ id: `search-${++searchCounter}`,
143
+ type,
144
+ pattern,
145
+ path,
146
+ filePattern: filePattern || null,
147
+ startedAt: Date.now(),
148
+ finishedAt: null,
149
+ results: [],
150
+ status: 'running',
151
+ error: null,
152
+ cancel: null,
153
+ waiters: [],
154
+ lastActivityAt: Date.now(),
155
+ };
156
+ searchSessions.set(session.id, session);
157
+ return session;
158
+ }
159
+
160
+ export function appendSearchResults(session, results) {
161
+ if (!results.length) return;
162
+ session.results.push(...results);
163
+ session.lastActivityAt = Date.now();
164
+ notify(session);
165
+ }
166
+
167
+ export function finishSearchSession(session, status, error = null) {
168
+ session.status = status;
169
+ session.error = error ? String(error) : null;
170
+ session.finishedAt = Date.now();
171
+ session.cancel = null;
172
+ notify(session);
173
+ }
174
+
175
+ export function getSearchSession(id) {
176
+ return searchSessions.get(String(id)) || null;
177
+ }
178
+
179
+ export function listSearchSessions() {
180
+ sweep();
181
+ return [...searchSessions.values()].sort((a, b) => a.startedAt - b.startedAt);
182
+ }
183
+
184
+ export async function waitForSearchResults(session, count, timeoutMs) {
185
+ if (session.results.length >= count || session.status !== 'running') return;
186
+ await waiter(session, timeoutMs);
187
+ }
188
+
189
+ export function sweep(now = Date.now()) {
190
+ for (const [pid, session] of processSessions) {
191
+ if (session.exited && session.finishedAt && now - session.finishedAt > EXITED_SESSION_TTL_MS) processSessions.delete(pid);
192
+ }
193
+ for (const [id, session] of searchSessions) {
194
+ if (session.status !== 'running' && session.finishedAt && now - session.finishedAt > EXITED_SESSION_TTL_MS) searchSessions.delete(id);
195
+ }
196
+ }
197
+
198
+ // Eviction used to run only inside list_sessions/list_searches, so an agent that never
199
+ // listed them kept abandoned sessions (and their result arrays) in memory forever.
200
+ let sweepTimer = null;
201
+ export function startSessionSweeper(intervalMs = 5 * 60 * 1000) {
202
+ if (sweepTimer) return;
203
+ sweepTimer = setInterval(() => sweep(), intervalMs);
204
+ sweepTimer.unref?.();
205
+ }
206
+
207
+ export function shutdownSessions() {
208
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
209
+ for (const session of processSessions.values()) {
210
+ if (!session.exited) {
211
+ try { session.child.kill('SIGKILL'); } catch {}
212
+ }
213
+ }
214
+ for (const session of searchSessions.values()) {
215
+ if (session.status === 'running' && session.cancel) {
216
+ try { session.cancel(); } catch {}
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,154 @@
1
+ import process from 'node:process';
2
+ import { runtimeConfig } from './config.mjs';
3
+ import { VERSION } from './version.mjs';
4
+
5
+ // Event fields are whitelisted: an event can never carry a file path, a command
6
+ // string, tool arguments, or tool output. Only tool names, timings, outcomes and
7
+ // coarse error classes leave this process, and only through the paired ReMCP agent.
8
+ const EVENT_FIELDS = {
9
+ tool: value => String(value).slice(0, 64),
10
+ durationMs: value => Math.max(0, Math.round(Number(value))),
11
+ success: value => value === true,
12
+ errorKind: value => String(value).slice(0, 48),
13
+ sessionKind: value => (value === 'process' || value === 'search' ? value : 'other'),
14
+ reason: value => String(value).slice(0, 48),
15
+ count: value => Math.max(0, Math.round(Number(value))),
16
+ };
17
+
18
+ const BUFFER_LIMIT = 250;
19
+ const FLUSH_INTERVAL_MS = 15_000;
20
+ const FLUSH_THRESHOLD = 20;
21
+
22
+ const state = {
23
+ enabled: runtimeConfig.telemetryEnabled,
24
+ buffer: [],
25
+ sink: null,
26
+ timer: null,
27
+ startedAt: Date.now(),
28
+ dropped: 0,
29
+ sent: 0,
30
+ counters: {
31
+ toolCalls: 0,
32
+ toolFailures: 0,
33
+ policyBlocks: 0,
34
+ sessionsStarted: 0,
35
+ searchesStarted: 0,
36
+ bytesWritten: 0,
37
+ writeDenials: 0,
38
+ },
39
+ toolCounts: new Map(),
40
+ };
41
+
42
+ export function telemetryEnabled() {
43
+ return state.enabled;
44
+ }
45
+
46
+ export function telemetryStatus() {
47
+ return {
48
+ enabled: state.enabled,
49
+ transport: 'paired-agent-only',
50
+ endpoint: null,
51
+ thirdParty: false,
52
+ installPing: false,
53
+ remoteFeatureFlags: false,
54
+ buffered: state.buffer.length,
55
+ sentEvents: state.sent,
56
+ droppedEvents: state.dropped,
57
+ counters: { ...state.counters },
58
+ topTools: topTools(5),
59
+ uptimeSeconds: Math.round((Date.now() - state.startedAt) / 1000),
60
+ };
61
+ }
62
+
63
+ function topTools(limit) {
64
+ return [...state.toolCounts.entries()]
65
+ .sort((a, b) => b[1].count - a[1].count)
66
+ .slice(0, limit)
67
+ .map(([tool, value]) => ({ tool, calls: value.count, failures: value.failures }));
68
+ }
69
+
70
+ function bump(tool, success) {
71
+ state.counters.toolCalls += 1;
72
+ if (!success) state.counters.toolFailures += 1;
73
+ const entry = state.toolCounts.get(tool) || { count: 0, failures: 0 };
74
+ entry.count += 1;
75
+ if (!success) entry.failures += 1;
76
+ state.toolCounts.set(tool, entry);
77
+ }
78
+
79
+ export function countEvent(name, amount = 1) {
80
+ if (Object.hasOwn(state.counters, name)) state.counters[name] += Math.max(0, Math.round(Number(amount) || 0));
81
+ }
82
+
83
+ function sanitize(detail) {
84
+ const clean = {};
85
+ for (const [key, coerce] of Object.entries(EVENT_FIELDS)) {
86
+ if (detail[key] === undefined || detail[key] === null) continue;
87
+ const value = coerce(detail[key]);
88
+ if (value === undefined || (typeof value === 'number' && !Number.isFinite(value))) continue;
89
+ clean[key] = value;
90
+ }
91
+ return clean;
92
+ }
93
+
94
+ export function recordEvent(event, detail = {}) {
95
+ const name = String(event).slice(0, 48);
96
+ if (name === 'tool_call') bump(detail.tool ? String(detail.tool).slice(0, 64) : 'unknown', detail.success === true);
97
+ if (name === 'policy_block') state.counters.policyBlocks += 1;
98
+ if (name === 'session_started') state.counters.sessionsStarted += 1;
99
+ if (name === 'write_denied') state.counters.writeDenials += 1;
100
+ if (!state.enabled) return;
101
+ if (state.buffer.length >= BUFFER_LIMIT) {
102
+ state.dropped += 1;
103
+ return;
104
+ }
105
+ state.buffer.push({ event: name, at: Date.now(), ...sanitize(detail) });
106
+ if (state.buffer.length >= FLUSH_THRESHOLD) void flush();
107
+ }
108
+
109
+ export function setTelemetrySink(sink) {
110
+ state.sink = typeof sink === 'function' ? sink : null;
111
+ if (state.sink && !state.timer) {
112
+ state.timer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
113
+ state.timer.unref?.();
114
+ }
115
+ }
116
+
117
+ export async function flush() {
118
+ if (!state.sink || !state.buffer.length) return 0;
119
+ const batch = state.buffer.splice(0, state.buffer.length);
120
+ try {
121
+ await state.sink({
122
+ runtimeVersion: VERSION,
123
+ node: process.versions.node,
124
+ platform: process.platform,
125
+ arch: process.arch,
126
+ name: runtimeConfig.name,
127
+ events: batch,
128
+ });
129
+ state.sent += batch.length;
130
+ return batch.length;
131
+ } catch {
132
+ // Never lose the process over telemetry, and never grow without bound either.
133
+ state.dropped += batch.length;
134
+ return 0;
135
+ }
136
+ }
137
+
138
+ export function shutdownTelemetry() {
139
+ if (state.timer) {
140
+ clearInterval(state.timer);
141
+ state.timer = null;
142
+ }
143
+ try { state.sink?.({ runtimeVersion: VERSION, platform: process.platform, arch: process.arch, name: runtimeConfig.name, events: state.buffer.splice(0, state.buffer.length) }); } catch {}
144
+ }
145
+
146
+ export function resetTelemetryForTests() {
147
+ state.buffer.length = 0;
148
+ state.dropped = 0;
149
+ state.sent = 0;
150
+ state.sink = null;
151
+ if (state.timer) { clearInterval(state.timer); state.timer = null; }
152
+ state.toolCounts.clear();
153
+ for (const key of Object.keys(state.counters)) state.counters[key] = 0;
154
+ }