@remcp/remcp 0.1.4 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,92 +1,71 @@
1
- <p align="center">
2
- <img src="./assets/remcp-logo.png" width="168" alt="ReMCP logo">
3
- </p>
1
+ # Device client
4
2
 
5
- <h1 align="center">ReMCP</h1>
6
-
7
- <p align="center"><strong>Your computer. Your tools. One secure MCP connection.</strong></p>
8
-
9
- <p align="center">
10
- <a href="https://www.npmjs.com/package/@remcp/remcp"><img alt="npm" src="https://img.shields.io/npm/v/%40remcp%2Fremcp?style=flat-square&label=npm"></a>
11
- <a href="https://www.npmjs.com/package/@remcp/remcp"><img alt="downloads" src="https://img.shields.io/npm/dm/%40remcp%2Fremcp?style=flat-square&label=downloads"></a>
12
- <img alt="Node.js" src="https://img.shields.io/node/v/%40remcp%2Fremcp?style=flat-square&label=node">
13
- <a href="./LICENSE"><img alt="license" src="https://img.shields.io/npm/l/%40remcp%2Fremcp?style=flat-square"></a>
14
- <a href="https://github.com/antonbaider/remcp/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/antonbaider/remcp?style=flat-square"></a>
15
- </p>
16
-
17
- <p align="center">
18
- <a href="https://remcp.delio24.com">Website</a> ·
19
- <a href="https://remcp.delio24.com/docs">Docs</a> ·
20
- <a href="https://remcp.delio24.com/security">Security</a> ·
21
- <a href="https://remcp.delio24.com/support">Support</a>
22
- </p>
23
-
24
- ReMCP is the lightweight device client that connects a computer you own or administer to your ReMCP workspace. The agent makes an outbound connection only; you do not need to expose an inbound port on the machine.
25
-
26
- ## Install
27
-
28
- **Requires Node.js 22.5 or newer.**
3
+ `@remcp/remcp` is the ReMCP device client. It pairs a computer with ReMCP, installs the first-party
4
+ local runtime, and runs the outbound-only agent that serves your MCP client's tool calls.
29
5
 
30
6
  ```bash
31
7
  npm install --global @remcp/remcp@latest
8
+ remcp --version
9
+ remcp status
32
10
  ```
33
11
 
34
- Then open **[ReMCP Connect a machine](https://remcp.delio24.com/app/connect)** and generate a one-time pairing command. Run that exact command on the computer you want to connect.
35
-
36
- > Pairing codes are generated in the authenticated workspace, expire automatically, and are single-use. Do not invent or reuse a code from documentation.
12
+ Pairing commands are generated in the workspace at <https://remcp.delio24.com/app/connect>. The
13
+ generated command runs `remcp connect --server … --code … --install`, which stores a per-device
14
+ credential under `~/.config/remcp/`, installs the runtime from npm, and registers a user service
15
+ (systemd on Linux, LaunchAgent on macOS, Scheduled Task on Windows).
37
16
 
38
17
  ## Commands
39
18
 
40
- | Command | Purpose |
41
- | --- | --- |
42
- | `remcp start` | Start the device agent in the foreground |
43
- | `remcp status` | Show pairing and service health |
44
- | `remcp doctor` | Run the same connectivity diagnostics in JSON form |
45
- | `remcp install` | Install the persistent Linux user service |
46
- | `remcp update` | Update ReMCP and the compatible local runtime |
47
- | `remcp uninstall` | Stop and remove the user service |
48
- | `remcp uninstall --purge` | Remove the service and global packages |
49
- | `remcp --version` | Print the installed client version |
19
+ ```text
20
+ remcp start Run the device agent in the foreground
21
+ remcp status Show version, pairing, runtime, telemetry and server health as JSON
22
+ remcp doctor Alias for status
23
+ remcp update Update the client and runtime, then restart the user service
24
+ remcp install Install or repair the user service
25
+ remcp uninstall Remove the user service
26
+ remcp uninstall --purge Remove the service and the global packages
27
+ remcp telemetry [status|on|off]
28
+ remcp --version
29
+ ```
50
30
 
51
- ## How pairing works
31
+ `remcp status` reports the runtime the device would install, whether the agent service is running,
32
+ and the current usage-metrics state, so a support request can be answered with one paste.
52
33
 
53
- 1. Sign in to your ReMCP workspace.
54
- 2. Generate a one-time pairing command.
55
- 3. Run the command on your machine.
56
- 4. ReMCP stores the device credential locally with restrictive permissions.
57
- 5. The agent establishes an outbound WebSocket connection to the configured ReMCP service.
34
+ ## What runs on your computer
58
35
 
59
- For the official service, compatible runtime metadata is delivered as part of the pairing response. A custom ReMCP server must be explicitly trusted with `--trust-runtime` before the client accepts runtime metadata from it.
36
+ - the agent (`remcp start`), which holds the device credential and dials
37
+ `wss://remcp.delio24.com/agent`;
38
+ - [`@remcp/runtime`](https://www.npmjs.com/package/@remcp/runtime), spawned by the agent as an MCP
39
+ stdio server. The runtime executes the tools, opens no network connection, and is supervised: if it
40
+ exits, the agent restarts it with backoff and reports the restart instead of failing silently.
60
41
 
61
- ## Security model
42
+ Configuration lives in `~/.config/remcp/config.json` (client) and `~/.config/remcp/runtime.json`
43
+ (runtime: allowed roots, blocked commands, output and write limits, and the usage-metrics switch).
44
+ Run `npx @remcp/runtime --describe` to print the runtime's effective configuration.
62
45
 
63
- - Outbound-only device connection.
64
- - Per-device revocable credential.
65
- - Local client configuration stored with restrictive permissions.
66
- - Runtime metadata validated before installation.
67
- - Custom servers require an explicit runtime trust decision.
68
- - No pairing secrets belong in issues, screenshots, logs, or documentation.
46
+ ## Usage metrics
69
47
 
70
- If a machine should no longer be connected, revoke it from the workspace and remove the local service.
48
+ Opt-out, self-hosted, and limited to tool names, timings, outcomes, error classes, and device health
49
+ samples. No paths, no commands, no arguments, no output, no third-party endpoint, no install ping.
50
+ Disable with `remcp telemetry off` or `REMCP_RUNTIME_DISABLE_TELEMETRY=1`; the switch applies to both
51
+ the client and the runtime, and restarts the service so it takes effect immediately.
71
52
 
72
- ## Update
53
+ ## Security
73
54
 
74
- ```bash
75
- remcp update
76
- remcp --version
77
- remcp status
78
- ```
55
+ - Outbound-only connection; the machine never listens.
56
+ - One revocable credential per paired device, stored with restrictive permissions.
57
+ - Runtime metadata from a custom server is only accepted with an explicit `--trust-runtime`.
58
+ - Revoking a device in the workspace closes the connection; the agent then stops retrying and says so
59
+ instead of reconnecting forever.
79
60
 
80
61
  ## Development
81
62
 
82
63
  ```bash
83
- npm ci
64
+ npm install
84
65
  npm run check
85
66
  npm test
86
- npm audit --omit=dev
87
- npm pack --dry-run
88
67
  ```
89
68
 
90
69
  ## License
91
70
 
92
- MIT © ReMCP contributors. See [LICENSE](LICENSE).
71
+ MIT.
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.1.4",
4
- "description": "Secure ReMCP device client for connecting your computer to MCP over an outbound-only agent.",
3
+ "version": "0.2.4",
4
+ "description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://remcp.delio24.com",
8
- "bin": { "remcp": "bin/remcp.mjs" },
9
- "engines": { "node": ">=22.5.0" },
10
- "files": ["bin", "src", "LICENSE", "README.md"],
8
+ "bin": {
9
+ "remcp": "bin/remcp.mjs"
10
+ },
11
+ "engines": {
12
+ "node": ">=22.5.0"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
11
20
  "scripts": {
12
21
  "check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
13
22
  "test": "node --test test/*.test.mjs"
@@ -16,9 +25,26 @@
16
25
  "@modelcontextprotocol/sdk": "^1.30.0",
17
26
  "ws": "^8.21.3"
18
27
  },
19
- "overrides": { "sharp": "0.35.4", "uuid": "11.1.1" },
20
- "repository": { "type": "git", "url": "git+https://github.com/antonbaider/remcp.git" },
21
- "bugs": { "url": "https://github.com/antonbaider/remcp/issues" },
22
- "publishConfig": { "access": "public" },
23
- "keywords": ["mcp", "model-context-protocol", "remote", "computer", "cli", "agent", "chatgpt", "codex"]
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/antonbaider/remcp.git",
31
+ "directory": "packages/client"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/antonbaider/remcp/issues"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "keywords": [
40
+ "mcp",
41
+ "model-context-protocol",
42
+ "remote",
43
+ "computer",
44
+ "cli",
45
+ "agent",
46
+ "chatgpt",
47
+ "codex",
48
+ "self-hosted"
49
+ ]
24
50
  }
package/src/agent.mjs CHANGED
@@ -10,6 +10,17 @@ import { normalizeRuntime } from './runtime.mjs';
10
10
  import { VERSION } from './version.mjs';
11
11
 
12
12
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
13
+ const METRICS_INTERVAL_MS = 60_000;
14
+ const TELEMETRY_QUEUE_LIMIT = 500;
15
+ const TELEMETRY_BATCH_LIMIT = 100;
16
+ const TELEMETRY_SEND_INTERVAL_MS = 5_000;
17
+ const RECONNECT_BASE_MS = 2_000;
18
+ const RECONNECT_MAX_MS = 60_000;
19
+ const RUNTIME_RESTART_BASE_MS = 1_000;
20
+ const RUNTIME_RESTART_MAX_MS = 30_000;
21
+ // Below the relay's RPC timeout so the model gets a real error instead of a client-side
22
+ // timeout while the device keeps working invisibly.
23
+ const CALL_TIMEOUT_MARGIN_MS = 10_000;
13
24
 
14
25
  function globalNodeModules() {
15
26
  const result = spawnSync(npmCommand, ['root', '--global'], { encoding: 'utf8' });
@@ -24,6 +35,28 @@ function localRuntimeEntry(runtimeValue) {
24
35
  return candidate;
25
36
  }
26
37
 
38
+ function jitter(ms) {
39
+ return Math.round(ms * (0.75 + Math.random() * 0.5));
40
+ }
41
+
42
+ // Device health is sampled locally and is the only thing ReMCP stores about the machine
43
+ // beyond its name, platform and last-seen time. No process list, no file names.
44
+ function deviceMetrics(extra = {}) {
45
+ const load = os.loadavg?.()[0] ?? 0;
46
+ const totalMem = os.totalmem();
47
+ const freeMem = os.freemem();
48
+ return {
49
+ uptimeSeconds: Math.round(os.uptime()),
50
+ agentUptimeSeconds: Math.round(process.uptime()),
51
+ rssBytes: process.memoryUsage().rss,
52
+ load1: Number.isFinite(load) ? Number(load.toFixed(2)) : 0,
53
+ cpuCount: os.cpus?.().length ?? 0,
54
+ freeMemoryRatio: totalMem > 0 ? Number((freeMem / totalMem).toFixed(3)) : 0,
55
+ agentVersion: VERSION,
56
+ ...extra,
57
+ };
58
+ }
59
+
27
60
  export async function runAgent(options) {
28
61
  const serverUrl = String(options.serverUrl || '').replace(/\/$/, '');
29
62
  const deviceToken = String(options.deviceToken || '');
@@ -31,25 +64,137 @@ export async function runAgent(options) {
31
64
  const deviceName = String(options.deviceName || os.hostname());
32
65
  if (!serverUrl || !deviceToken || !deviceId) throw new Error('serverUrl, deviceToken and deviceId are required');
33
66
  const agentUrl = serverUrl.replace(/^http/, 'ws') + '/agent';
34
- const reconnectMs = Number(options.reconnectMs || 3000);
67
+ const callTimeoutMs = Math.max(5_000, Number(options.rpcTimeoutMs || 120000) - CALL_TIMEOUT_MARGIN_MS);
68
+ const telemetryEnabled = options.telemetryEnabled !== false;
69
+ const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
35
70
  let stopping = false;
36
71
  let activeSocket;
72
+ let reconnects = 0;
73
+ let pendingRequests = 0;
74
+ let runtimeVersion = 'unknown';
75
+ let runtimeRestarts = 0;
76
+ let runtimeDown = false;
77
+ const telemetryQueue = [];
78
+ let telemetryTimer = null;
79
+ const runtimeEntry = localRuntimeEntry(options.runtime);
80
+
81
+ // --- local runtime supervision ------------------------------------------------------
82
+ // If the runtime dies (a bad shell, a broken pipe, an OOM) the agent used to stay
83
+ // "online" forever and every later call failed with an opaque "Not connected". Now the
84
+ // transport is watched and the runtime is restarted with backoff.
85
+ let mcp = null;
86
+ let transport = null;
87
+ let runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
88
+
89
+ function runtimeEnv() {
90
+ // The SDK's stdio transport does not inherit the environment by default; spreading
91
+ // process.env here is what gives the runtime its REMCP_RUNTIME_* configuration, PATH,
92
+ // HOME and the telemetry opt-out.
93
+ const env = { ...process.env };
94
+ if (!telemetryEnabled) env.REMCP_RUNTIME_DISABLE_TELEMETRY = '1';
95
+ return env;
96
+ }
37
97
 
38
- const mcp = new Client({ name: 'remcp-agent', version: VERSION });
39
- const transport = new StdioClientTransport({ command: process.execPath, args: [localRuntimeEntry(options.runtime)] });
40
- await mcp.connect(transport);
41
- console.log('ReMCP local runtime ready');
98
+ async function startRuntime() {
99
+ if (stopping) return;
100
+ runtimeDown = false;
101
+ const client = new Client({ name: 'remcp-agent', version: VERSION });
102
+ const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv() });
103
+ mcp = client;
104
+ transport = stdio;
105
+ client.fallbackNotificationHandler = async notification => {
106
+ if (!telemetryEnabled) return;
107
+ if (notification?.method !== 'notifications/remcp/telemetry') return;
108
+ const events = Array.isArray(notification.params?.events) ? notification.params.events : [];
109
+ if (!events.length) return;
110
+ if (notification.params?.runtimeVersion) runtimeVersion = String(notification.params.runtimeVersion);
111
+ for (const event of events) {
112
+ if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
113
+ telemetryQueue.push(event);
114
+ }
115
+ };
116
+ stdio.onclose = () => handleRuntimeExit('closed');
117
+ stdio.onerror = error => console.error(`ReMCP local runtime error: ${error instanceof Error ? error.message : String(error)}`);
118
+ try {
119
+ await client.connect(stdio);
120
+ runtimeVersion = client.getServerVersion()?.version || runtimeVersion;
121
+ runtimeRestarts += 1;
122
+ runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
123
+ console.log(`ReMCP local runtime ready (${runtimeVersion})`);
124
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
125
+ if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
126
+ } catch (error) {
127
+ console.error(`ReMCP local runtime failed to start: ${error instanceof Error ? error.message : String(error)}`);
128
+ handleRuntimeExit('failed');
129
+ }
130
+ }
131
+
132
+ function handleRuntimeExit(reason) {
133
+ if (stopping || runtimeDown) return;
134
+ runtimeDown = true;
135
+ const delay = jitter(runtimeRestartDelay);
136
+ runtimeRestartDelay = Math.min(RUNTIME_RESTART_MAX_MS, runtimeRestartDelay * 2);
137
+ console.error(`ReMCP local runtime ${reason}; restarting in ${delay}ms`);
138
+ queueEvent({ event: 'runtime_down', at: Date.now(), reason: reason.slice(0, 24) });
139
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: true }) });
140
+ setTimeout(() => { void startRuntime(); }, delay).unref?.();
141
+ }
142
+
143
+ // --- relay connection ---------------------------------------------------------------
144
+ function send(message) {
145
+ if (activeSocket?.readyState === 1) {
146
+ activeSocket.send(JSON.stringify(message));
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function flushTelemetry() {
153
+ if (!telemetryEnabled || !telemetryQueue.length) return;
154
+ const batch = telemetryQueue.splice(0, TELEMETRY_BATCH_LIMIT);
155
+ if (!send({ type: 'telemetry', runtimeVersion, agentVersion: VERSION, events: batch })) telemetryQueue.unshift(...batch);
156
+ }
157
+
158
+ function queueEvent(event) {
159
+ if (!telemetryEnabled) return;
160
+ if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
161
+ telemetryQueue.push(event);
162
+ }
163
+
164
+ function reportInstallOnce() {
165
+ if (!telemetryEnabled || options.installReported === true) return;
166
+ if (send({
167
+ type: 'install',
168
+ agentVersion: VERSION,
169
+ runtimeVersion,
170
+ node: process.versions.node,
171
+ platform: process.platform,
172
+ arch: process.arch,
173
+ installSpec: String(options.installSpec || ''),
174
+ })) {
175
+ persistState({ installReported: true });
176
+ console.log('ReMCP reported this installation to your own workspace (disable with `remcp telemetry off`).');
177
+ }
178
+ }
42
179
 
43
180
  async function respond(ws, message) {
181
+ pendingRequests += 1;
44
182
  try {
45
183
  let result;
46
- if (message.method === 'tools/list') result = await mcp.listTools();
47
- else if (message.method === 'tools/call') result = await mcp.callTool(message.params);
48
- else if (message.method === 'ping') result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()) };
49
- else throw new Error(`Unsupported relay method: ${message.method}`);
184
+ if (message.method === 'ping') {
185
+ result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
186
+ } else if (!runtimeDown && mcp) {
187
+ if (message.method === 'tools/list') result = await mcp.listTools(undefined, { timeout: callTimeoutMs });
188
+ else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, { timeout: callTimeoutMs });
189
+ else throw new Error(`Unsupported relay method: ${message.method}`);
190
+ } else {
191
+ throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
192
+ }
50
193
  ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
51
194
  } catch (error) {
52
195
  ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: error instanceof Error ? error.message : String(error) } }));
196
+ } finally {
197
+ pendingRequests = Math.max(0, pendingRequests - 1);
53
198
  }
54
199
  }
55
200
 
@@ -58,28 +203,67 @@ export async function runAgent(options) {
58
203
  const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
59
204
  activeSocket = ws;
60
205
  ws.on('open', () => {
61
- ws.send(JSON.stringify({ type: 'hello', deviceId, deviceName, hostname: os.hostname(), platform: process.platform, arch: process.arch, agentVersion: VERSION }));
206
+ reconnects = 0;
207
+ ws.send(JSON.stringify({
208
+ type: 'hello',
209
+ deviceId,
210
+ deviceName,
211
+ hostname: os.hostname(),
212
+ platform: process.platform,
213
+ arch: process.arch,
214
+ agentVersion: VERSION,
215
+ runtimeVersion,
216
+ telemetryEnabled,
217
+ reconnects,
218
+ }));
62
219
  console.log(`Connected to ${serverUrl} as ${deviceName}`);
220
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }) });
221
+ reportInstallOnce();
222
+ flushTelemetry();
63
223
  });
64
224
  ws.on('message', raw => {
65
225
  let message;
66
226
  try { message = JSON.parse(raw.toString()); } catch { return; }
67
227
  if (message?.type === 'request') void respond(ws, message);
68
228
  });
69
- ws.on('close', () => { if (!stopping) setTimeout(connect, reconnectMs); });
229
+ ws.on('close', code => {
230
+ if (stopping) return;
231
+ if (code === 1008) {
232
+ // The relay closes with 1008 when the device was revoked. Retrying forever would
233
+ // hide that from the person at the computer.
234
+ console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
235
+ return;
236
+ }
237
+ reconnects += 1;
238
+ const delay = jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
239
+ setTimeout(connect, delay);
240
+ });
70
241
  ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
71
242
  }
72
243
 
244
+ telemetryTimer = setInterval(() => {
245
+ if (activeSocket?.readyState === 1) {
246
+ send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
247
+ flushTelemetry();
248
+ }
249
+ }, METRICS_INTERVAL_MS);
250
+ telemetryTimer.unref?.();
251
+ const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
252
+ telemetryFlushTimer.unref?.();
253
+
73
254
  async function stop() {
74
255
  if (stopping) return;
75
256
  stopping = true;
257
+ if (telemetryTimer) clearInterval(telemetryTimer);
258
+ clearInterval(telemetryFlushTimer);
76
259
  try { activeSocket?.close(); } catch {}
77
- try { await mcp.close(); } catch {}
78
- try { await transport.close(); } catch {}
260
+ try { await mcp?.close(); } catch {}
261
+ try { await transport?.close(); } catch {}
79
262
  }
80
263
 
81
264
  process.once('SIGINT', () => void stop().finally(() => process.exit(0)));
82
265
  process.once('SIGTERM', () => void stop().finally(() => process.exit(0)));
266
+ await startRuntime();
83
267
  connect();
84
- return { stop };
268
+ return { stop, runtimeVersion: () => runtimeVersion, runtimeRestarts: () => runtimeRestarts, isRuntimeDown: () => runtimeDown };
85
269
  }
package/src/cli.mjs CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawnSync } from 'node:child_process';
6
+ import { randomUUID } from 'node:crypto';
6
7
  import { runAgent } from './agent.mjs';
7
8
  import { normalizeRuntime } from './runtime.mjs';
8
9
  import { PACKAGE_NAME, VERSION } from './version.mjs';
@@ -10,19 +11,26 @@ import { PACKAGE_NAME, VERSION } from './version.mjs';
10
11
  const home = os.homedir();
11
12
  const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
12
13
  const configFile = path.join(configDir, 'config.json');
13
- const serviceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
14
+ const runtimeConfigFile = path.join(configDir, 'runtime.json');
15
+ const machineIdFile = path.join(configDir, 'machine-id');
16
+ const linuxServiceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
17
+ const macServiceLabel = 'com.remcp.agent';
18
+ const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
19
+ const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
20
+ const windowsTaskName = 'ReMCP Agent';
14
21
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
15
22
  const officialOrigin = 'https://remcp.delio24.com';
16
23
 
17
24
  function parse(argv) {
18
25
  const [command = 'help', ...rest] = argv;
19
26
  const flags = {};
27
+ const positional = [];
20
28
  for (let i = 0; i < rest.length; i++) {
21
- if (!rest[i].startsWith('--')) continue;
29
+ if (!rest[i].startsWith('--')) { positional.push(rest[i]); continue; }
22
30
  const key = rest[i].slice(2);
23
31
  flags[key] = rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[++i] : true;
24
32
  }
25
- return { command, flags };
33
+ return { command, flags, positional };
26
34
  }
27
35
 
28
36
  function run(command, args, options = {}) {
@@ -55,13 +63,77 @@ function saveConfig(value) {
55
63
  fs.chmodSync(configFile, 0o600);
56
64
  }
57
65
 
66
+ function readJsonFile(file) {
67
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
68
+ }
69
+
70
+ function writeJsonFile(file, value) {
71
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
72
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
73
+ fs.chmodSync(file, 0o600);
74
+ }
75
+
76
+ function flagEnabled(value) {
77
+ return value === undefined || value === null ? true : value !== false;
78
+ }
79
+
80
+ // One switch for the whole machine: the client and the runtime it spawns must agree,
81
+ // otherwise the runtime would keep reporting after the user opted out.
82
+ function telemetryState() {
83
+ const client = readJsonFile(configFile);
84
+ const runtime = readJsonFile(runtimeConfigFile);
85
+ const clientEnabled = flagEnabled(client.telemetryEnabled);
86
+ const runtimeEnabled = flagEnabled(runtime.telemetryEnabled);
87
+ return {
88
+ enabled: clientEnabled && runtimeEnabled,
89
+ clientEnabled,
90
+ runtimeEnabled,
91
+ installReported: client.installReported === true,
92
+ configFile,
93
+ runtimeConfigFile,
94
+ transport: 'paired-agent-only',
95
+ endpoint: null,
96
+ collects: 'tool names, durations, outcomes, error classes, and device health samples',
97
+ neverCollects: 'file paths, file contents, command strings, tool arguments, and tool output',
98
+ thirdParty: false,
99
+ installPing: false,
100
+ remoteFeatureFlags: false,
101
+ };
102
+ }
103
+
104
+ function setTelemetry(enabled) {
105
+ const client = readJsonFile(configFile);
106
+ client.telemetryEnabled = enabled;
107
+ writeJsonFile(configFile, client);
108
+ const runtime = readJsonFile(runtimeConfigFile);
109
+ runtime.telemetryEnabled = enabled;
110
+ writeJsonFile(runtimeConfigFile, runtime);
111
+ return telemetryState();
112
+ }
113
+
114
+ function ensureMachineId() {
115
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
116
+ try {
117
+ const existing = fs.readFileSync(machineIdFile, 'utf8').trim();
118
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(existing)) return existing;
119
+ } catch {}
120
+ const value = randomUUID();
121
+ fs.writeFileSync(machineIdFile, value + '\n', { mode: 0o600 });
122
+ fs.chmodSync(machineIdFile, 0o600);
123
+ return value;
124
+ }
125
+
126
+ function servicePlatform() {
127
+ return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
128
+ }
129
+
58
130
  function globalPrefix() {
59
131
  return output(npmCommand, ['prefix', '--global']);
60
132
  }
61
133
 
62
134
  function globalCliPath() {
63
135
  const prefix = globalPrefix();
64
- return process.platform === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
136
+ return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
65
137
  }
66
138
 
67
139
  function npmGlobalInstall(...specs) {
@@ -72,33 +144,82 @@ function quoteSystemd(value) {
72
144
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
73
145
  }
74
146
 
147
+ function xmlEscape(value) {
148
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
149
+ }
150
+
75
151
  function installLinuxService(cliPath = globalCliPath()) {
76
152
  const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(cliPath)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
77
- fs.mkdirSync(path.dirname(serviceFile), { recursive: true });
78
- fs.writeFileSync(serviceFile, unit);
153
+ fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
154
+ fs.writeFileSync(linuxServiceFile, unit);
79
155
  run('systemctl', ['--user', 'daemon-reload']);
80
156
  run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
81
157
  }
82
158
 
159
+ function macLaunchDomain() {
160
+ if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
161
+ return `gui/${process.getuid()}`;
162
+ }
163
+
164
+ function installMacService(cliPath = globalCliPath()) {
165
+ const domain = macLaunchDomain();
166
+ const target = `${domain}/${macServiceLabel}`;
167
+ const cliScript = fs.realpathSync(cliPath);
168
+ fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
169
+ fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
170
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${macServiceLabel}</string>\n<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string></array>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>${xmlEscape(macLogFile)}</string>\n<key>StandardErrorPath</key><string>${xmlEscape(macLogFile)}</string>\n</dict></plist>\n`;
171
+ fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
172
+ spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
173
+ run('launchctl', ['bootstrap', domain, macServiceFile]);
174
+ run('launchctl', ['enable', target]);
175
+ run('launchctl', ['kickstart', '-k', target]);
176
+ }
177
+
178
+ function installWindowsService(cliPath = globalCliPath()) {
179
+ const command = `"${cliPath}" start`;
180
+ run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F']);
181
+ run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
182
+ }
183
+
83
184
  function installPersistentAgent(config) {
84
- if (process.platform !== 'linux') throw new Error('Automatic background service installation currently supports Linux');
185
+ const platform = servicePlatform();
186
+ if (!['linux', 'darwin', 'win32'].includes(platform)) throw new Error(`Automatic background service installation is not supported on ${platform}`);
85
187
  console.log(`Installing ReMCP ${VERSION}…`);
86
188
  npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
87
- installLinuxService(globalCliPath());
88
- console.log('ReMCP is installed as a user service. Future updates: remcp update');
189
+ const cliPath = globalCliPath();
190
+ if (platform === 'linux') installLinuxService(cliPath);
191
+ else if (platform === 'darwin') installMacService(cliPath);
192
+ else installWindowsService(cliPath);
193
+ console.log('ReMCP is installed as a background service. Future updates: remcp update');
89
194
  }
90
195
 
91
- function restartLinuxServiceIfInstalled() {
92
- if (process.platform !== 'linux' || !fs.existsSync(serviceFile)) return;
93
- run('systemctl', ['--user', 'daemon-reload']);
94
- run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
196
+ function restartPersistentServiceIfInstalled() {
197
+ const platform = servicePlatform();
198
+ if (platform === 'linux' && fs.existsSync(linuxServiceFile)) {
199
+ run('systemctl', ['--user', 'daemon-reload']);
200
+ run('systemctl', ['--user', 'restart', 'remcp-agent.service']);
201
+ } else if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
202
+ run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
203
+ } else if (platform === 'win32') {
204
+ const result = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' });
205
+ if (result.status === 0) run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
206
+ }
95
207
  }
96
208
 
97
- function uninstallLinuxService() {
98
- if (process.platform !== 'linux') return;
99
- spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
100
- try { fs.unlinkSync(serviceFile); } catch {}
101
- spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
209
+ function uninstallPersistentService() {
210
+ const platform = servicePlatform();
211
+ if (platform === 'linux') {
212
+ spawnSync('systemctl', ['--user', 'disable', '--now', 'remcp-agent.service'], { stdio: 'inherit' });
213
+ try { fs.unlinkSync(linuxServiceFile); } catch {}
214
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
215
+ } else if (platform === 'darwin') {
216
+ const domain = macLaunchDomain();
217
+ spawnSync('launchctl', ['bootout', domain, macServiceFile], { stdio: 'ignore' });
218
+ try { fs.unlinkSync(macServiceFile); } catch {}
219
+ } else if (platform === 'win32') {
220
+ spawnSync('schtasks.exe', ['/End', '/TN', windowsTaskName], { stdio: 'ignore' });
221
+ spawnSync('schtasks.exe', ['/Delete', '/TN', windowsTaskName, '/F'], { stdio: 'ignore' });
222
+ }
102
223
  }
103
224
 
104
225
  function assertRuntimeTrust(server, flags) {
@@ -109,11 +230,11 @@ function assertRuntimeTrust(server, flags) {
109
230
  }
110
231
 
111
232
  function printHelp() {
112
- console.log(`ReMCP ${VERSION}\n\nCommands:\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp --version\n\nPairing commands are generated in the ReMCP workspace.`);
233
+ console.log(`ReMCP ${VERSION}\n\nCommands:\n remcp start\n remcp status\n remcp doctor\n remcp update\n remcp install\n remcp uninstall\n remcp uninstall --purge\n remcp telemetry [status|on|off]\n remcp --version\n\nPairing commands are generated in the ReMCP workspace.\n\nUsage metrics are opt-out (tool names, timings, outcomes only, sent to your own ReMCP\naccount through the paired agent). Disable them at any time with: remcp telemetry off`);
113
234
  }
114
235
 
115
236
  export async function main(argv = process.argv.slice(2)) {
116
- const { command, flags } = parse(argv);
237
+ const { command, flags, positional } = parse(argv);
117
238
 
118
239
  if (command === '--version' || command === '-v' || command === 'version') {
119
240
  console.log(VERSION);
@@ -128,7 +249,7 @@ export async function main(argv = process.argv.slice(2)) {
128
249
  const response = await fetch(`${server}/api/pair/claim`, {
129
250
  method: 'POST',
130
251
  headers: { 'content-type': 'application/json' },
131
- body: JSON.stringify({ code, name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
252
+ body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
132
253
  });
133
254
  if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
134
255
  const paired = await response.json();
@@ -138,6 +259,7 @@ export async function main(argv = process.argv.slice(2)) {
138
259
  deviceToken: paired.deviceToken,
139
260
  deviceName: String(flags.name || os.hostname()),
140
261
  runtime: normalizeRuntime(paired.runtime),
262
+ machineId: ensureMachineId(),
141
263
  };
142
264
  saveConfig(config);
143
265
  console.log(`Paired ${os.hostname()} with ${server}`);
@@ -146,14 +268,43 @@ export async function main(argv = process.argv.slice(2)) {
146
268
  }
147
269
 
148
270
  if (command === 'start') {
149
- await runAgent(loadConfig());
271
+ const cfg = loadConfig();
272
+ const telemetry = telemetryState();
273
+ await runAgent({
274
+ ...cfg,
275
+ telemetryEnabled: telemetry.enabled,
276
+ installReported: telemetry.installReported,
277
+ installSpec: `${PACKAGE_NAME}@${VERSION}`,
278
+ persistState: patch => saveConfig({ ...cfg, ...patch }),
279
+ });
150
280
  return;
151
281
  }
152
282
 
283
+ if (command === 'telemetry') {
284
+ const action = String(positional[0] || 'status').toLowerCase();
285
+ if (action === 'status') {
286
+ console.log(JSON.stringify(telemetryState(), null, 2));
287
+ return;
288
+ }
289
+ if (action === 'on' || action === 'enable') {
290
+ console.log(JSON.stringify(setTelemetry(true), null, 2));
291
+ restartPersistentServiceIfInstalled();
292
+ console.log('Usage metrics enabled and the agent restarted to apply it.');
293
+ return;
294
+ }
295
+ if (action === 'off' || action === 'disable') {
296
+ console.log(JSON.stringify(setTelemetry(false), null, 2));
297
+ restartPersistentServiceIfInstalled();
298
+ console.log('Usage metrics disabled and the agent restarted to apply it.');
299
+ return;
300
+ }
301
+ throw new Error('Usage: remcp telemetry [status|on|off]');
302
+ }
303
+
153
304
  if (command === 'status' || command === 'doctor') {
154
305
  const cfg = loadConfig();
155
306
  const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json());
156
- console.log(JSON.stringify({ configured: true, cliVersion: VERSION, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, serverHealth: health }, null, 2));
307
+ console.log(JSON.stringify({ configured: true, cliVersion: VERSION, deviceId: cfg.deviceId, deviceName: cfg.deviceName, server: cfg.serverUrl, runtime: cfg.runtime, telemetry: telemetryState(), serverHealth: health }, null, 2));
157
308
  return;
158
309
  }
159
310
 
@@ -166,13 +317,13 @@ export async function main(argv = process.argv.slice(2)) {
166
317
  const cfg = loadConfig();
167
318
  console.log('Updating ReMCP to the latest published version…');
168
319
  npmGlobalInstall(`${PACKAGE_NAME}@latest`, cfg.runtime.packageSpec);
169
- restartLinuxServiceIfInstalled();
320
+ restartPersistentServiceIfInstalled();
170
321
  console.log('ReMCP updated. Run `remcp --version` or `remcp status` to verify.');
171
322
  return;
172
323
  }
173
324
 
174
325
  if (command === 'uninstall') {
175
- uninstallLinuxService();
326
+ uninstallPersistentService();
176
327
  if (flags.purge) {
177
328
  const cfg = loadConfig(false);
178
329
  const specs = [PACKAGE_NAME, ...(cfg?.runtime?.packageName ? [cfg.runtime.packageName] : [])];
package/src/version.mjs CHANGED
@@ -2,5 +2,5 @@ import { readFileSync } from 'node:fs';
2
2
 
3
3
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
4
 
5
- export const PACKAGE_NAME = pkg.name;
5
+ export const PACKAGE_NAME = '@remcp/remcp';
6
6
  export const VERSION = pkg.version;