@remcp/remcp 0.2.152 → 0.2.154

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.152",
3
+ "version": "0.2.154",
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",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check bin/remcp.mjs && node --check src/agent-update.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/agent-responder.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
@@ -0,0 +1,35 @@
1
+ import os from 'node:os';
2
+ import { VERSION } from './version.mjs';
3
+ import { sendRelayMessage } from './agent-utilities.mjs';
4
+
5
+ export function createAgentResponder({ inFlight, onPendingChange, getRuntimeState }) {
6
+ async function respond(ws, message) {
7
+ onPendingChange(1);
8
+ // The relay forwards a cancel when the MCP client goes away. Without it a cancelled tool call
9
+ // kept running on the machine (a delete still deleted), because nothing told the runtime.
10
+ const controller = new AbortController();
11
+ inFlight.set(message.id, controller);
12
+ const { mcp, runtimeDown, runtimeVersion, runtimeRestarts, callTimeoutMs } = getRuntimeState();
13
+ try {
14
+ let result;
15
+ if (message.method === 'ping') {
16
+ result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
17
+ } else if (!runtimeDown && mcp) {
18
+ const options = { timeout: callTimeoutMs, signal: controller.signal };
19
+ if (message.method === 'tools/list') result = await mcp.listTools(undefined, options);
20
+ else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, options);
21
+ else throw new Error(`Unsupported relay method: ${message.method}`);
22
+ } else {
23
+ throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
24
+ }
25
+ sendRelayMessage(ws, { type: 'response', id: message.id, result });
26
+ } catch (error) {
27
+ const cancelled = controller.signal.aborted;
28
+ sendRelayMessage(ws, { type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } });
29
+ } finally {
30
+ inFlight.delete(message.id);
31
+ onPendingChange(-1);
32
+ }
33
+ }
34
+ return respond;
35
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import process from 'node:process';
4
+ import { VERSION } from './version.mjs';
5
+ import { globalCliEntry, globalInstalledVersion, isNewer, restartToApplyUpdate, updateDecision, updateInvocationArgs } from './agent-update.mjs';
6
+
7
+ export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
8
+ const UPDATE_CHECK_TIMEOUT_MS = 5000;
9
+ const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([60_000, 5 * 60_000, 15 * 60_000]);
10
+ const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
11
+ const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
12
+
13
+ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRuntimeState, onStopRequested, restartRuntime, isStopping, stop }) {
14
+ const postConnectUpdateRecheckDelaysMs = (Array.isArray(options.updateRecheckDelaysMs)
15
+ ? options.updateRecheckDelaysMs : POST_CONNECT_UPDATE_RECHECK_DELAYS_MS)
16
+ .map(Number).filter(delay => Number.isFinite(delay) && delay >= 0 && delay < UPDATE_CHECK_INTERVAL_MS).slice(0, 3);
17
+ const postConnectUpdateRecheckJitterMs = Number.isFinite(Number(options.updateRecheckJitterMs))
18
+ ? Math.max(0, Math.min(60_000, Number(options.updateRecheckJitterMs)))
19
+ : POST_CONNECT_UPDATE_RECHECK_JITTER_MS;
20
+ let postConnectUpdateTimers = [];
21
+
22
+ // Auto-update: the server publishes the versions an agent should be running. A newer
23
+ // release is installed in the background and the service restart picks it up; a failed
24
+ // or skipped update leaves the current version running, so an old agent keeps working.
25
+ //
26
+ // The update must be idempotent across reconnects: a flapping relay used to start one
27
+ // `npm install -g` per reconnect, so a machine could run several installers (and service
28
+ // restarts) at once. One attempt per advertised version, and never two at the same time.
29
+ let updateInFlight = false;
30
+ let lastAttemptedVersion = '';
31
+ let lastAttemptAt = 0;
32
+ let lastUpdateCheckError = '';
33
+ let lastUpdateCheckErrorAt = 0;
34
+
35
+ function clearPostConnectUpdateChecks() {
36
+ for (const timer of postConnectUpdateTimers) clearTimeout(timer);
37
+ postConnectUpdateTimers = [];
38
+ }
39
+
40
+ function schedulePostConnectUpdateChecks() {
41
+ clearPostConnectUpdateChecks();
42
+ if (options.autoUpdate === false) return;
43
+ for (const delay of postConnectUpdateRecheckDelaysMs) {
44
+ const jitter = postConnectUpdateRecheckJitterMs > 0
45
+ ? Math.floor(Math.random() * (postConnectUpdateRecheckJitterMs + 1))
46
+ : 0;
47
+ const timer = setTimeout(() => {
48
+ postConnectUpdateTimers = postConnectUpdateTimers.filter(candidate => candidate !== timer);
49
+ void checkForUpdate();
50
+ }, delay + jitter);
51
+ timer.unref?.();
52
+ postConnectUpdateTimers.push(timer);
53
+ }
54
+ }
55
+
56
+ async function checkForUpdate() {
57
+ if (options.autoUpdate === false) return;
58
+ if (updateInFlight) return;
59
+ try {
60
+ // This request contains no local file payload; the config-derived URL is the explicitly paired relay.
61
+ // codeql[js/file-access-to-http]
62
+ const response = await fetch(`${serverUrl}/api/agent/version`, { redirect: 'error', signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
63
+ if (!response.ok) return;
64
+ const advertised = await response.json();
65
+ const minimum = advertised.minimum;
66
+ if (minimum && isNewer(minimum, VERSION)) {
67
+ console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
68
+ }
69
+ const { runtimeVersion, runtimeDown } = getRuntimeState();
70
+ const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion, runtimePackageName: options.runtime?.packageName, runtimeDown });
71
+ if (!decision.needed) return;
72
+ const target = decision.target;
73
+ queueEvent({ event: 'agent_update', at: Date.now(), reason: `${decision.reason}:${target}`.slice(0, 32), success: true });
74
+ const cli = globalCliEntry();
75
+ if (!cli || !existsSync(cli)) {
76
+ console.error(`ReMCP ${target} is available; run: remcp update`);
77
+ return;
78
+ }
79
+ // A version that already failed to install is retried only after a cooldown, so a broken
80
+ // release cannot turn into an install loop.
81
+ const attemptKey = `${target}|${decision.runtime}`;
82
+ if (attemptKey === lastAttemptedVersion && Date.now() - lastAttemptAt < UPDATE_RETRY_COOLDOWN_MS) return;
83
+ lastAttemptedVersion = attemptKey;
84
+ lastAttemptAt = Date.now();
85
+ updateInFlight = true;
86
+ console.log(`Updating ReMCP to ${target}${decision.runtime ? ` with ${decision.runtime}` : ''} (${decision.reason})…`);
87
+ // Keep the updater pinned to the exact release pair this trusted server advertised. That
88
+ // prevents a newer public npm tag from getting ahead of production during a public-first rollout.
89
+ const updateArgs = updateInvocationArgs(decision, options.trustRuntime === true);
90
+ const child = spawn(process.execPath, [cli, ...updateArgs], {
91
+ detached: true,
92
+ // The updater's own output is the only record of why an install failed, so it is piped back
93
+ // into this agent's log instead of being discarded.
94
+ stdio: ['ignore', 'pipe', 'pipe'],
95
+ env: { ...process.env },
96
+ });
97
+ const forward = chunk => {
98
+ const line = String(chunk).trim();
99
+ if (line) console.error(`remcp update: ${line}`);
100
+ };
101
+ child.stdout?.on('data', forward);
102
+ child.stderr?.on('data', forward);
103
+ child.on('exit', code => {
104
+ updateInFlight = false;
105
+ // The updater installs the packages and then restarts the service this agent runs in, so it is
106
+ // routinely killed by that restart and exits with a signal (`code` is null). That is success,
107
+ // not failure: the installed version is the proof. Without this check the agent kept
108
+ // reinstalling every few seconds — each cycle taking the device offline and making every tool
109
+ // call in that window fail in 1-3 ms ("Device is offline").
110
+ const targetVersion = String(target).split('@').pop();
111
+ const installedNow = globalInstalledVersion();
112
+ const installedTarget = installedNow && targetVersion && !isNewer(targetVersion, installedNow);
113
+ if (code !== 0 && !installedTarget) {
114
+ console.error(`remcp update exited with ${code}; keeping ${VERSION} and retrying after the cooldown.`);
115
+ return;
116
+ }
117
+ if (code !== 0) {
118
+ console.error(`remcp update was terminated (${code}) after installing ${installedNow}; applying it.`);
119
+ }
120
+ void restartToApplyUpdate(cli, stop, onStopRequested, restartRuntime, isStopping);
121
+ });
122
+ child.on('error', error => {
123
+ updateInFlight = false;
124
+ console.error(`remcp update could not start: ${error.message}`);
125
+ });
126
+ child.unref();
127
+ } catch (error) {
128
+ // Update discovery must never take the agent offline, but swallowing the exception made FNM/
129
+ // launchd failures impossible to diagnose. Log only when the reason changes or every 30 min.
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ const now = Date.now();
132
+ if (message !== lastUpdateCheckError || now - lastUpdateCheckErrorAt >= UPDATE_RETRY_COOLDOWN_MS) {
133
+ console.error(`ReMCP auto-update check failed: ${message}`);
134
+ lastUpdateCheckError = message;
135
+ lastUpdateCheckErrorAt = now;
136
+ }
137
+ queueEvent({ event:'agent_update', at:now, reason:'check-failed', success:false, errorClass:error?.name || 'Error' });
138
+ }
139
+ }
140
+ return { checkForUpdate, clearPostConnectUpdateChecks, schedulePostConnectUpdateChecks };
141
+ }
@@ -0,0 +1,50 @@
1
+ import os from 'node:os';
2
+ import { VERSION } from './version.mjs';
3
+
4
+ export function normalizedServerUrl(value, { allowInsecure = false } = {}) {
5
+ const url = new URL(String(value || ''));
6
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('serverUrl must use http or https');
7
+ if (url.username || url.password) throw new Error('serverUrl must not embed credentials');
8
+ const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname.toLowerCase());
9
+ if (url.protocol === 'http:' && !loopback && !allowInsecure) throw new Error('Remote serverUrl must use https');
10
+ url.hash = '';
11
+ url.search = '';
12
+ return url.href.replace(/\/$/, '');
13
+ }
14
+
15
+ export function sendRelayMessage(socket, message) {
16
+ if (socket?.readyState !== 1) return false;
17
+ // The authenticated relay is ReMCP's explicit data boundary: model-requested tool results are
18
+ // intentionally returned to the paired workspace and never to an arbitrary third-party URL.
19
+ // codeql[js/file-access-to-http]
20
+ socket.send(JSON.stringify(message));
21
+ return true;
22
+ }
23
+
24
+ export function jitter(ms) {
25
+ return Math.round(ms * (0.75 + Math.random() * 0.5));
26
+ }
27
+
28
+ export function boundedReconnectDelay(value, fallbackMs = 150) {
29
+ const parsed = Number(value);
30
+ if (!Number.isFinite(parsed)) return fallbackMs;
31
+ return Math.max(100, Math.min(60_000, Math.floor(parsed)));
32
+ }
33
+
34
+ // Device health is sampled locally and is the only thing ReMCP stores about the machine
35
+ // beyond its name, platform and last-seen time. No process list, no file names.
36
+ export function deviceMetrics(extra = {}) {
37
+ const load = os.loadavg?.()[0] ?? 0;
38
+ const totalMem = os.totalmem();
39
+ const freeMem = os.freemem();
40
+ return {
41
+ uptimeSeconds: Math.round(os.uptime()),
42
+ agentUptimeSeconds: Math.round(process.uptime()),
43
+ rssBytes: process.memoryUsage().rss,
44
+ load1: Number.isFinite(load) ? Number(load.toFixed(2)) : 0,
45
+ cpuCount: os.cpus?.().length ?? 0,
46
+ freeMemoryRatio: totalMem > 0 ? Number((freeMem / totalMem).toFixed(3)) : 0,
47
+ agentVersion: VERSION,
48
+ ...extra,
49
+ };
50
+ }
package/src/agent.mjs CHANGED
@@ -1,32 +1,16 @@
1
1
  import os from 'node:os';
2
- import { existsSync } from 'node:fs';
3
2
  import process from 'node:process';
4
- import { spawn } from 'node:child_process';
5
3
  import WebSocket from 'ws';
6
4
  import { Client } from '@modelcontextprotocol/client';
7
5
  import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
8
6
  import { VERSION } from './version.mjs';
9
- import {
10
- globalCliEntry,
11
- globalInstalledVersion,
12
- isNewer,
13
- localRuntimeEntry,
14
- restartToApplyUpdate,
15
- supervisorRestart,
16
- updateDecision,
17
- updateInvocationArgs,
18
- } from './agent-update.mjs';
7
+ import { localRuntimeEntry } from './agent-update.mjs';
19
8
 
20
9
  export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
10
+ import { normalizedServerUrl, sendRelayMessage, jitter, boundedReconnectDelay, deviceMetrics } from './agent-utilities.mjs';
11
+ import { createAgentUpdateMonitor, UPDATE_CHECK_INTERVAL_MS } from './agent-update-monitor.mjs';
12
+ import { createAgentResponder } from './agent-responder.mjs';
21
13
 
22
- const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
23
- const UPDATE_CHECK_TIMEOUT_MS = 5000;
24
- // A deployment can briefly leave one HA replica advertising the previous release. Recheck a few
25
- // times after a stable connection instead of making that unlucky first answer stick for six hours.
26
- const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([60_000, 5 * 60_000, 15 * 60_000]);
27
- const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
28
- // A version that failed to install is retried after this cooldown instead of on every reconnect.
29
- const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
30
14
  const METRICS_INTERVAL_MS = 5 * 60_000;
31
15
  const TELEMETRY_QUEUE_LIMIT = 500;
32
16
  const TELEMETRY_BATCH_LIMIT = 100;
@@ -47,54 +31,6 @@ const RUNTIME_TOOLS_RETRY_MAX_MS = 30_000;
47
31
  // timeout while the device keeps working invisibly.
48
32
  const CALL_TIMEOUT_MARGIN_MS = 10_000;
49
33
 
50
- function normalizedServerUrl(value, { allowInsecure = false } = {}) {
51
- const url = new URL(String(value || ''));
52
- if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('serverUrl must use http or https');
53
- if (url.username || url.password) throw new Error('serverUrl must not embed credentials');
54
- const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname.toLowerCase());
55
- if (url.protocol === 'http:' && !loopback && !allowInsecure) throw new Error('Remote serverUrl must use https');
56
- url.hash = '';
57
- url.search = '';
58
- return url.href.replace(/\/$/, '');
59
- }
60
-
61
- function sendRelayMessage(socket, message) {
62
- if (socket?.readyState !== 1) return false;
63
- // The authenticated relay is ReMCP's explicit data boundary: model-requested tool results are
64
- // intentionally returned to the paired workspace and never to an arbitrary third-party URL.
65
- // codeql[js/file-access-to-http]
66
- socket.send(JSON.stringify(message));
67
- return true;
68
- }
69
-
70
- function jitter(ms) {
71
- return Math.round(ms * (0.75 + Math.random() * 0.5));
72
- }
73
-
74
- function boundedReconnectDelay(value, fallbackMs = 150) {
75
- const parsed = Number(value);
76
- if (!Number.isFinite(parsed)) return fallbackMs;
77
- return Math.max(100, Math.min(60_000, Math.floor(parsed)));
78
- }
79
-
80
- // Device health is sampled locally and is the only thing ReMCP stores about the machine
81
- // beyond its name, platform and last-seen time. No process list, no file names.
82
- function deviceMetrics(extra = {}) {
83
- const load = os.loadavg?.()[0] ?? 0;
84
- const totalMem = os.totalmem();
85
- const freeMem = os.freemem();
86
- return {
87
- uptimeSeconds: Math.round(os.uptime()),
88
- agentUptimeSeconds: Math.round(process.uptime()),
89
- rssBytes: process.memoryUsage().rss,
90
- load1: Number.isFinite(load) ? Number(load.toFixed(2)) : 0,
91
- cpuCount: os.cpus?.().length ?? 0,
92
- freeMemoryRatio: totalMem > 0 ? Number((freeMem / totalMem).toFixed(3)) : 0,
93
- agentVersion: VERSION,
94
- ...extra,
95
- };
96
- }
97
-
98
34
  export async function runAgent(options) {
99
35
  const serverUrl = normalizedServerUrl(options.serverUrl, { allowInsecure: options.allowInsecureTransport === true || process.env.REMCP_ALLOW_INSECURE_TRANSPORT === 'true' });
100
36
  const deviceToken = String(options.deviceToken || '');
@@ -106,22 +42,12 @@ export async function runAgent(options) {
106
42
  const telemetryEnabled = options.telemetryEnabled !== false;
107
43
  const metricsIntervalMs = Math.max(60_000, Number(options.metricsIntervalMs || process.env.REMCP_AGENT_METRICS_INTERVAL_MS) || METRICS_INTERVAL_MS);
108
44
  const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
109
- const postConnectUpdateRecheckDelaysMs = (Array.isArray(options.updateRecheckDelaysMs)
110
- ? options.updateRecheckDelaysMs
111
- : POST_CONNECT_UPDATE_RECHECK_DELAYS_MS)
112
- .map(Number)
113
- .filter(delay => Number.isFinite(delay) && delay >= 0 && delay < UPDATE_CHECK_INTERVAL_MS)
114
- .slice(0, 3);
115
- const postConnectUpdateRecheckJitterMs = Number.isFinite(Number(options.updateRecheckJitterMs))
116
- ? Math.max(0, Math.min(60_000, Number(options.updateRecheckJitterMs)))
117
- : POST_CONNECT_UPDATE_RECHECK_JITTER_MS;
118
45
  let stopping = false;
119
46
  let revoked = false;
120
47
  const inFlight = new Map();
121
48
  let activeSocket;
122
49
  let reconnects = 0;
123
50
  let reconnectTimer = null;
124
- let postConnectUpdateTimers = [];
125
51
  // Set when a replica asks this agent to move before it is replaced; the close handler reads it.
126
52
  let askedToReconnect = false;
127
53
  let requestedReconnectDelayMs = 0;
@@ -397,33 +323,31 @@ export async function runAgent(options) {
397
323
  scheduleReconnect(delay);
398
324
  }
399
325
 
400
- async function respond(ws, message) {
401
- pendingRequests += 1;
402
- // The relay forwards a cancel when the MCP client goes away. Without it a cancelled tool call
403
- // kept running on the machine (a delete still deleted), because nothing told the runtime.
404
- const controller = new AbortController();
405
- inFlight.set(message.id, controller);
406
- try {
407
- let result;
408
- if (message.method === 'ping') {
409
- result = { ok: true, hostname: os.hostname(), platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(os.uptime()), agentVersion: VERSION, runtimeVersion, runtimeRestarts };
410
- } else if (!runtimeDown && mcp) {
411
- const options = { timeout: callTimeoutMs, signal: controller.signal };
412
- if (message.method === 'tools/list') result = await mcp.listTools(undefined, options);
413
- else if (message.method === 'tools/call') result = await mcp.callTool(message.params, undefined, options);
414
- else throw new Error(`Unsupported relay method: ${message.method}`);
415
- } else {
416
- throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
326
+ const respond = createAgentResponder({
327
+ inFlight,
328
+ onPendingChange: delta => { pendingRequests = Math.max(0, pendingRequests + delta); },
329
+ getRuntimeState: () => ({ mcp, runtimeDown, runtimeVersion, runtimeRestarts, callTimeoutMs }),
330
+ });
331
+ const { checkForUpdate, clearPostConnectUpdateChecks, schedulePostConnectUpdateChecks } = createAgentUpdateMonitor({
332
+ options, serverUrl, queueEvent,
333
+ getRuntimeState: () => ({ runtimeVersion, runtimeDown }),
334
+ onStopRequested: () => { stopping = true; },
335
+ isStopping: () => stopping,
336
+ stop,
337
+ restartRuntime: async () => {
338
+ // Rebuild the supervised runtime against the freshly installed exact release.
339
+ runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
340
+ runtimeDown = false;
341
+ runtimeError = '';
342
+ try {
343
+ runtimeEntry = localRuntimeEntry(options.runtime);
344
+ } catch (error) {
345
+ runtimeDown = true;
346
+ runtimeError = error instanceof Error ? error.message : String(error);
417
347
  }
418
- sendRelayMessage(ws, { type: 'response', id: message.id, result });
419
- } catch (error) {
420
- const cancelled = controller.signal.aborted;
421
- sendRelayMessage(ws, { type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } });
422
- } finally {
423
- inFlight.delete(message.id);
424
- pendingRequests = Math.max(0, pendingRequests - 1);
425
- }
426
- }
348
+ if (!runtimeDown && !stopping) await startRuntime();
349
+ },
350
+ });
427
351
 
428
352
  function connect() {
429
353
  if (stopping) return;
@@ -536,136 +460,6 @@ export async function runAgent(options) {
536
460
  ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
537
461
  }
538
462
 
539
- // Auto-update: the server publishes the versions an agent should be running. A newer
540
- // release is installed in the background and the service restart picks it up; a failed
541
- // or skipped update leaves the current version running, so an old agent keeps working.
542
- //
543
- // The update must be idempotent across reconnects: a flapping relay used to start one
544
- // `npm install -g` per reconnect, so a machine could run several installers (and service
545
- // restarts) at once. One attempt per advertised version, and never two at the same time.
546
- let updateInFlight = false;
547
- let lastAttemptedVersion = '';
548
- let lastAttemptAt = 0;
549
- let lastUpdateCheckError = '';
550
- let lastUpdateCheckErrorAt = 0;
551
-
552
- function clearPostConnectUpdateChecks() {
553
- for (const timer of postConnectUpdateTimers) clearTimeout(timer);
554
- postConnectUpdateTimers = [];
555
- }
556
-
557
- function schedulePostConnectUpdateChecks() {
558
- clearPostConnectUpdateChecks();
559
- if (options.autoUpdate === false) return;
560
- for (const delay of postConnectUpdateRecheckDelaysMs) {
561
- const jitter = postConnectUpdateRecheckJitterMs > 0
562
- ? Math.floor(Math.random() * (postConnectUpdateRecheckJitterMs + 1))
563
- : 0;
564
- const timer = setTimeout(() => {
565
- postConnectUpdateTimers = postConnectUpdateTimers.filter(candidate => candidate !== timer);
566
- void checkForUpdate();
567
- }, delay + jitter);
568
- timer.unref?.();
569
- postConnectUpdateTimers.push(timer);
570
- }
571
- }
572
-
573
- async function checkForUpdate() {
574
- if (options.autoUpdate === false) return;
575
- if (updateInFlight) return;
576
- try {
577
- // This request contains no local file payload; the config-derived URL is the explicitly paired relay.
578
- // codeql[js/file-access-to-http]
579
- const response = await fetch(`${serverUrl}/api/agent/version`, { redirect: 'error', signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
580
- if (!response.ok) return;
581
- const advertised = await response.json();
582
- const minimum = advertised.minimum;
583
- if (minimum && isNewer(minimum, VERSION)) {
584
- console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
585
- }
586
- const decision = updateDecision({ advertised, cliVersion: VERSION, runtimeVersion, runtimePackageName: options.runtime?.packageName, runtimeDown });
587
- if (!decision.needed) return;
588
- const target = decision.target;
589
- queueEvent({ event: 'agent_update', at: Date.now(), reason: `${decision.reason}:${target}`.slice(0, 32), success: true });
590
- const cli = globalCliEntry();
591
- if (!cli || !existsSync(cli)) {
592
- console.error(`ReMCP ${target} is available; run: remcp update`);
593
- return;
594
- }
595
- // A version that already failed to install is retried only after a cooldown, so a broken
596
- // release cannot turn into an install loop.
597
- const attemptKey = `${target}|${decision.runtime}`;
598
- if (attemptKey === lastAttemptedVersion && Date.now() - lastAttemptAt < UPDATE_RETRY_COOLDOWN_MS) return;
599
- lastAttemptedVersion = attemptKey;
600
- lastAttemptAt = Date.now();
601
- updateInFlight = true;
602
- console.log(`Updating ReMCP to ${target}${decision.runtime ? ` with ${decision.runtime}` : ''} (${decision.reason})…`);
603
- // Keep the updater pinned to the exact release pair this trusted server advertised. That
604
- // prevents a newer public npm tag from getting ahead of production during a public-first rollout.
605
- const updateArgs = updateInvocationArgs(decision, options.trustRuntime === true);
606
- const child = spawn(process.execPath, [cli, ...updateArgs], {
607
- detached: true,
608
- // The updater's own output is the only record of why an install failed, so it is piped back
609
- // into this agent's log instead of being discarded.
610
- stdio: ['ignore', 'pipe', 'pipe'],
611
- env: { ...process.env },
612
- });
613
- const forward = chunk => {
614
- const line = String(chunk).trim();
615
- if (line) console.error(`remcp update: ${line}`);
616
- };
617
- child.stdout?.on('data', forward);
618
- child.stderr?.on('data', forward);
619
- child.on('exit', code => {
620
- updateInFlight = false;
621
- // The updater installs the packages and then restarts the service this agent runs in, so it is
622
- // routinely killed by that restart and exits with a signal (`code` is null). That is success,
623
- // not failure: the installed version is the proof. Without this check the agent kept
624
- // reinstalling every few seconds — each cycle taking the device offline and making every tool
625
- // call in that window fail in 1-3 ms ("Device is offline").
626
- const targetVersion = String(target).split('@').pop();
627
- const installedNow = globalInstalledVersion();
628
- const installedTarget = installedNow && targetVersion && !isNewer(targetVersion, installedNow);
629
- if (code !== 0 && !installedTarget) {
630
- console.error(`remcp update exited with ${code}; keeping ${VERSION} and retrying after the cooldown.`);
631
- return;
632
- }
633
- if (code !== 0) {
634
- console.error(`remcp update was terminated (${code}) after installing ${installedNow}; applying it.`);
635
- }
636
- void restartToApplyUpdate(cli, stop, () => { stopping = true; }, async () => {
637
- // The packages are installed now: clear the failure, reset the backoff and start again.
638
- runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
639
- runtimeDown = false;
640
- runtimeError = '';
641
- try {
642
- runtimeEntry = localRuntimeEntry(options.runtime);
643
- } catch (error) {
644
- runtimeDown = true;
645
- runtimeError = error instanceof Error ? error.message : String(error);
646
- }
647
- if (!runtimeDown && !stopping) await startRuntime();
648
- }, () => stopping);
649
- });
650
- child.on('error', error => {
651
- updateInFlight = false;
652
- console.error(`remcp update could not start: ${error.message}`);
653
- });
654
- child.unref();
655
- } catch (error) {
656
- // Update discovery must never take the agent offline, but swallowing the exception made FNM/
657
- // launchd failures impossible to diagnose. Log only when the reason changes or every 30 min.
658
- const message = error instanceof Error ? error.message : String(error);
659
- const now = Date.now();
660
- if (message !== lastUpdateCheckError || now - lastUpdateCheckErrorAt >= UPDATE_RETRY_COOLDOWN_MS) {
661
- console.error(`ReMCP auto-update check failed: ${message}`);
662
- lastUpdateCheckError = message;
663
- lastUpdateCheckErrorAt = now;
664
- }
665
- queueEvent({ event:'agent_update', at:now, reason:'check-failed', success:false, errorClass:error?.name || 'Error' });
666
- }
667
- }
668
-
669
463
  scheduleMetricsSample();
670
464
  const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
671
465
  telemetryFlushTimer.unref?.();