@remcp/remcp 0.2.196 → 0.2.198

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.196",
3
+ "version": "0.2.198",
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-network-probe-client.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.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/pairing-qr.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.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-deploy-reconnect.mjs && node --check src/agent-network-probe-client.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.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/pairing-qr.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/fetch-update-manifest.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,26 @@
1
+ /**
2
+ * Bound repeated relay-deployment reconnects while preserving fast recovery from
3
+ * a single short interruption. State is per agent connection, never global.
4
+ *
5
+ * The gateway's requested minimum is honored; a burst of sub-10s sessions
6
+ * progressively backs off so dozens of agents cannot spin on a deploy loop.
7
+ */
8
+ export function nextDeployReconnect({
9
+ requestedDelayMs = 150,
10
+ rapidDisconnects = 0,
11
+ connectionUptimeMs = 0,
12
+ } = {}) {
13
+ const input = Number(requestedDelayMs);
14
+ const requested = Number.isFinite(input)
15
+ ? Math.max(100, Math.min(60_000, Math.floor(input)))
16
+ : 150;
17
+ const stable = Number(connectionUptimeMs) >= 10_000;
18
+ const prior = Number.isSafeInteger(rapidDisconnects) && rapidDisconnects >= 0
19
+ ? rapidDisconnects : 0;
20
+ const count = stable ? 0 : Math.min(8, prior + 1);
21
+ const adaptive = count <= 1 ? 0 : Math.min(8_000, 250 * 2 ** Math.min(6, count - 2));
22
+ return {
23
+ delayMs: Math.max(requested, adaptive),
24
+ rapidDisconnects: count,
25
+ };
26
+ }
@@ -4,11 +4,11 @@ import process from 'node:process';
4
4
  import WebSocket from 'ws';
5
5
  import { VERSION } from './version.mjs';
6
6
  import { boundedReconnectDelay, jitter, sendRelayMessage } from './agent-utilities.mjs';
7
+ import { nextDeployReconnect } from './agent-deploy-reconnect.mjs';
7
8
 
8
9
  const RECONNECT_BASE_MS = 2_000;
9
10
  const RECONNECT_FIRST_MS = 500;
10
11
  const RECONNECT_MAX_MS = 60_000;
11
-
12
12
  // Higher-level agent owns runtime supervision/telemetry; this controller owns only relay transport.
13
13
  export function createAgentRelayConnection({
14
14
  agentUrl, serverUrl, deviceToken, deviceId, deviceName,
@@ -21,6 +21,9 @@ export function createAgentRelayConnection({
21
21
  let reconnectTimer = null;
22
22
  let askedToReconnect = false;
23
23
  let requestedReconnectDelayMs = 0;
24
+ let requestedReconnectReason = '';
25
+ let connectedAt = 0;
26
+ let rapidDeployDisconnects = 0;
24
27
  function scheduleReconnect(delay) {
25
28
  if (isStopping() || revoked || reconnectTimer) return;
26
29
  // Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
@@ -88,6 +91,7 @@ export function createAgentRelayConnection({
88
91
  });
89
92
  setSocket(ws);
90
93
  ws.on('open', () => {
94
+ connectedAt = Date.now();
91
95
  if (reconnectTimer) {
92
96
  clearTimeout(reconnectTimer);
93
97
  reconnectTimer = null;
@@ -117,6 +121,8 @@ export function createAgentRelayConnection({
117
121
  if (message?.type === 'reconnect') {
118
122
  askedToReconnect = true;
119
123
  requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
124
+ requestedReconnectReason = ['deploy', 'credentials', 'capacity'].includes(message.reason)
125
+ ? message.reason : 'transient';
120
126
  return;
121
127
  }
122
128
  if (message?.type === 'request') void respond(ws, message);
@@ -129,33 +135,35 @@ export function createAgentRelayConnection({
129
135
  clearPostConnectUpdateChecks();
130
136
  for (const controller of inFlight.values()) controller.abort();
131
137
  if (isStopping()) return;
132
- // 1013 ('reconnect') is what a replica sends before it is replaced by a deployment. The machine
133
- // is not down, it is moving: reconnect at once and forget the backoff, so the person and the
134
- // model see nothing at all.
135
- if (code === 1013 || askedToReconnect) {
136
- const delayMs = requestedReconnectDelayMs || 150;
137
- askedToReconnect = false;
138
- requestedReconnectDelayMs = 0;
139
- setReconnects(0);
140
- console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
141
- scheduleReconnect(delayMs);
142
- return;
143
- }
138
+ // A replacement owner or revoked device takes precedence over a reconnect hint.
144
139
  if (code === 1008 || revoked) {
145
- // The relay closes with 1008 when the device was revoked. Retrying forever would
146
- // hide that from the person at the computer.
147
140
  console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
148
141
  return;
149
142
  }
150
143
  if (code === 1012) {
151
- // 1012 ('service restart') is what the relay sends when another agent process took over this
152
- // device. Staying alive would keep a second runtime and a reconnect loop, so this process
153
- // stops and leaves the device to the agent that owns the connection.
154
144
  console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
155
145
  markStopping();
156
146
  void stop().finally(() => setTimeout(() => process.exit(0), 100));
157
147
  return;
158
148
  }
149
+ if (code === 1013 || askedToReconnect) {
150
+ // 1013 covers transient overload and relay-store failures, not just deployment.
151
+ // Preserve a fast first handover while bounding repeated short-lived sessions.
152
+ const reconnect = nextDeployReconnect({
153
+ requestedDelayMs: requestedReconnectDelayMs || 150,
154
+ rapidDisconnects: rapidDeployDisconnects,
155
+ connectionUptimeMs: connectedAt ? Date.now() - connectedAt : 0,
156
+ });
157
+ rapidDeployDisconnects = reconnect.rapidDisconnects;
158
+ const reason = requestedReconnectReason || 'transient';
159
+ askedToReconnect = false;
160
+ requestedReconnectReason = '';
161
+ requestedReconnectDelayMs = 0;
162
+ setReconnects(0);
163
+ console.log(`ReMCP relay reconnect (${reason}); retrying in ${reconnect.delayMs}ms.`);
164
+ scheduleReconnect(reconnect.delayMs);
165
+ return;
166
+ }
159
167
  scheduleBackoffReconnect();
160
168
  });
161
169
  ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
@@ -2,10 +2,13 @@ import { existsSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import process from 'node:process';
4
4
  import { VERSION } from './version.mjs';
5
+ import { fetchUpdateManifest } from './fetch-update-manifest.mjs';
5
6
  import { globalCliEntry, globalInstalledVersion, isNewer, restartToApplyUpdate, updateDecision, updateInvocationArgs } from './agent-update.mjs';
6
7
 
7
8
  export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
8
- const UPDATE_CHECK_TIMEOUT_MS = 5000;
9
+ const UPDATE_CHECK_TIMEOUT_MS = 12000;
10
+ // A flapping relay must not trigger update discovery on every successful reconnect.
11
+ const UPDATE_CHECK_RECONNECT_GAP_MS = 30000;
9
12
  const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([60_000, 5 * 60_000, 15 * 60_000]);
10
13
  const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
11
14
  const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
@@ -31,6 +34,7 @@ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRu
31
34
  let lastAttemptAt = 0;
32
35
  let lastUpdateCheckError = '';
33
36
  let lastUpdateCheckErrorAt = 0;
37
+ let lastDiscoveryAt = 0;
34
38
 
35
39
  function clearPostConnectUpdateChecks() {
36
40
  for (const timer of postConnectUpdateTimers) clearTimeout(timer);
@@ -46,22 +50,32 @@ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRu
46
50
  : 0;
47
51
  const timer = setTimeout(() => {
48
52
  postConnectUpdateTimers = postConnectUpdateTimers.filter(candidate => candidate !== timer);
49
- void checkForUpdate();
53
+ void checkForUpdate({ scheduled: true });
50
54
  }, delay + jitter);
51
55
  timer.unref?.();
52
56
  postConnectUpdateTimers.push(timer);
53
57
  }
54
58
  }
55
59
 
56
- async function checkForUpdate() {
60
+ async function checkForUpdate({ scheduled = false } = {}) {
57
61
  if (options.autoUpdate === false) return;
58
62
  if (updateInFlight) return;
63
+ const minimumGap = Number.isFinite(Number(options.updateCheckMinGapMs))
64
+ ? Math.max(0, Math.min(UPDATE_CHECK_RECONNECT_GAP_MS, Number(options.updateCheckMinGapMs)))
65
+ : UPDATE_CHECK_RECONNECT_GAP_MS;
66
+ const now = Date.now();
67
+ // Scheduled bounded convergence polls are distinct from reconnect-triggered discovery.
68
+ // Do not starve release-pause/Redis-recovery checks behind the reconnect storm cooldown.
69
+ if (!scheduled && lastDiscoveryAt && now - lastDiscoveryAt < minimumGap) return;
70
+ lastDiscoveryAt = now;
59
71
  try {
60
72
  // This request contains no local file payload; the config-derived URL is the explicitly paired relay.
61
73
  // 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();
74
+ // Bound both connection and JSON body parsing even if platform fetch ignores AbortSignal.
75
+ const advertised = await fetchUpdateManifest(`${serverUrl}/api/agent/version`, {
76
+ timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
77
+ });
78
+ if (!advertised) return;
65
79
  const minimum = advertised.minimum;
66
80
  if (minimum && isNewer(minimum, VERSION)) {
67
81
  console.error(`ReMCP ${VERSION} is older than the minimum supported agent ${minimum}; update with: remcp update`);
@@ -15,6 +15,21 @@ import { npm, runtimeConfigFile } from './env.mjs';
15
15
  // that would take the device offline.
16
16
  // One real handshake with the local runtime, plus everything needed to explain a failure: where the
17
17
  // entry resolved, whether the package is installed, the node that would run it, and the exact error.
18
+ // A stuck local MCP transport must not hold remcp doctor indefinitely.
19
+ export async function doctorDeadline(promise, timeoutMs) {
20
+ let timer;
21
+ try {
22
+ return await Promise.race([
23
+ promise,
24
+ new Promise((_, reject) => {
25
+ timer = setTimeout(() => reject(new Error('Runtime diagnostic timed out after ' + timeoutMs + 'ms')), timeoutMs);
26
+ }),
27
+ ]);
28
+ } finally {
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+
18
33
  export async function diagnoseLocalRuntime(cfg) {
19
34
  const packageName = cfg.runtime?.packageName || '';
20
35
  const diagnosis = {
@@ -54,16 +69,24 @@ export async function diagnoseLocalRuntime(cfg) {
54
69
  { name: 'remcp-doctor', version: VERSION },
55
70
  { versionNegotiation:{ mode:'auto' } },
56
71
  );
57
- const stdio = new StdioClientTransport({ command: process.execPath, args: [entry], env: { ...process.env }, maxBufferSize: 4 * 1024 * 1024 });
58
- const stderr = [];
59
- stdio.onerror = error => stderr.push(String(error?.message || error));
60
- await client.connect(stdio);
61
- diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
62
- const tools = await client.listTools(undefined, { timeout: 20000 });
63
- diagnosis.tools = tools.tools.length;
64
- diagnosis.verdict = 'ok';
65
- await client.close();
66
- return diagnosis;
72
+ // Keep runtime stderr off the inherited CLI pipe: it must not block a
73
+ // parent test runner when a diagnostic child remains active.
74
+ const stdio = new StdioClientTransport({
75
+ command: process.execPath, args: [entry], env: { ...process.env },
76
+ stderr: 'pipe', maxBufferSize: 4 * 1024 * 1024,
77
+ });
78
+ stdio.stderr?.on('data', () => {}); // drain; do not log potentially sensitive output
79
+ try {
80
+ await doctorDeadline(client.connect(stdio), 20_000);
81
+ diagnosis.runtimeVersion = client.getServerVersion()?.version || 'unknown';
82
+ const tools = await doctorDeadline(client.listTools(undefined, { timeout: 20_000 }), 25_000);
83
+ diagnosis.tools = tools.tools.length;
84
+ diagnosis.verdict = 'ok';
85
+ return diagnosis;
86
+ } finally {
87
+ // Clean up even on a failed handshake. Never let close hang the doctor.
88
+ await doctorDeadline(client.close(), 6_000).catch(() => {});
89
+ }
67
90
  } catch (error) {
68
91
  diagnosis.verdict = 'runtime-handshake-failed';
69
92
  diagnosis.error = error instanceof Error ? error.message : String(error);
@@ -23,9 +23,28 @@ export function quoteSystemd(value) {
23
23
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
24
24
  }
25
25
 
26
- function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
26
+ // A systemd user's PATH may resolve system npm even when the CLI lives under nvm.
27
+ // Pin the npm installation target to the CLI's own existing global prefix.
28
+ export function linuxServiceLauncherText(cliPath, nodePath) {
29
+ if (!path.isAbsolute(cliPath) || !path.isAbsolute(nodePath) || path.basename(path.dirname(cliPath)) !== 'bin') {
30
+ throw new Error('Linux service needs absolute CLI and Node paths in a global bin directory');
31
+ }
32
+ const prefix = path.dirname(path.dirname(cliPath));
27
33
  const cliScript = resolvedCliScript(cliPath);
28
- const launcher = `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(cliScript)} \"$@\"\n`;
34
+ const lines = [
35
+ '#!/bin/sh',
36
+ 'NPM_CONFIG_PREFIX=' + shellQuote(prefix),
37
+ 'export NPM_CONFIG_PREFIX',
38
+ 'PATH=' + shellQuote(path.dirname(nodePath)) + ':"$PATH"',
39
+ 'export PATH',
40
+ 'exec ' + shellQuote(nodePath) + ' ' + shellQuote(cliScript) + ' "$@"',
41
+ '',
42
+ ];
43
+ return lines.join('\n');
44
+ }
45
+
46
+ function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
47
+ const launcher = linuxServiceLauncherText(cliPath, nodePath);
29
48
  fs.mkdirSync(path.dirname(linuxServiceLauncherFile), { recursive: true, mode: 0o700 });
30
49
  const temporary = `${linuxServiceLauncherFile}.${process.pid}.${Date.now()}.tmp`;
31
50
  fs.writeFileSync(temporary, launcher, { mode: 0o700 });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Bounded ReMCP release discovery. The deadline covers both the HTTP response
3
+ * and its JSON body. AbortSignal alone does not reliably cancel stalled TCP
4
+ * connects on every supported Node/undici + operating-system combination.
5
+ *
6
+ * No credentials or local computer data are transmitted here: the caller
7
+ * supplies the trusted ReMCP relay's public version endpoint.
8
+ */
9
+ export async function fetchUpdateManifest(url, {
10
+ fetchImpl = globalThis.fetch,
11
+ timeoutMs = 5_000,
12
+ } = {}) {
13
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
14
+ throw new RangeError('Update manifest timeout must be 1–60000 milliseconds');
15
+ }
16
+ const controller = new AbortController();
17
+ let timer;
18
+ const deadline = new Promise((_, reject) => {
19
+ timer = setTimeout(() => {
20
+ controller.abort();
21
+ const error = new Error('ReMCP update manifest check exceeded its deadline');
22
+ error.name = 'TimeoutError';
23
+ reject(error);
24
+ }, timeoutMs);
25
+ });
26
+
27
+ try {
28
+ return await Promise.race([
29
+ (async () => {
30
+ const response = await fetchImpl(url, {
31
+ redirect: 'error',
32
+ signal: controller.signal,
33
+ });
34
+ if (!response.ok) return null;
35
+ return response.json();
36
+ })(),
37
+ deadline,
38
+ ]);
39
+ } finally {
40
+ clearTimeout(timer);
41
+ // Release a response connection if a late completion beats the deadline
42
+ // after Promise.race has already chosen the timed-out branch.
43
+ controller.abort();
44
+ }
45
+ }