@remcp/remcp 0.2.112 → 0.2.114

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/agent.mjs +54 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.112",
3
+ "version": "0.2.114",
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",
package/src/agent.mjs CHANGED
@@ -27,7 +27,7 @@ const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([60_000, 5 * 60_000,
27
27
  const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
28
28
  // A version that failed to install is retried after this cooldown instead of on every reconnect.
29
29
  const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
30
- const METRICS_INTERVAL_MS = 60_000;
30
+ const METRICS_INTERVAL_MS = 5 * 60_000;
31
31
  const TELEMETRY_QUEUE_LIMIT = 500;
32
32
  const TELEMETRY_BATCH_LIMIT = 100;
33
33
  const TELEMETRY_SEND_INTERVAL_MS = 5_000;
@@ -71,6 +71,12 @@ function jitter(ms) {
71
71
  return Math.round(ms * (0.75 + Math.random() * 0.5));
72
72
  }
73
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
+
74
80
  // Device health is sampled locally and is the only thing ReMCP stores about the machine
75
81
  // beyond its name, platform and last-seen time. No process list, no file names.
76
82
  function deviceMetrics(extra = {}) {
@@ -98,6 +104,7 @@ export async function runAgent(options) {
98
104
  const agentUrl = serverUrl.replace(/^http/, 'ws') + '/agent';
99
105
  const callTimeoutMs = Math.max(5_000, Number(options.rpcTimeoutMs || 120000) - CALL_TIMEOUT_MARGIN_MS);
100
106
  const telemetryEnabled = options.telemetryEnabled !== false;
107
+ const metricsIntervalMs = Math.max(60_000, Number(options.metricsIntervalMs || process.env.REMCP_AGENT_METRICS_INTERVAL_MS) || METRICS_INTERVAL_MS);
101
108
  const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
102
109
  const postConnectUpdateRecheckDelaysMs = (Array.isArray(options.updateRecheckDelaysMs)
103
110
  ? options.updateRecheckDelaysMs
@@ -117,6 +124,7 @@ export async function runAgent(options) {
117
124
  let postConnectUpdateTimers = [];
118
125
  // Set when a replica asks this agent to move before it is replaced; the close handler reads it.
119
126
  let askedToReconnect = false;
127
+ let requestedReconnectDelayMs = 0;
120
128
  let pendingRequests = 0;
121
129
  let runtimeVersion = 'unknown';
122
130
  let runtimeRestarts = 0;
@@ -127,6 +135,7 @@ export async function runAgent(options) {
127
135
  let runtimeError = '';
128
136
  const telemetryQueue = [];
129
137
  let telemetryTimer = null;
138
+ let lastMetricsSentAt = 0;
130
139
  // A device whose runtime is missing or unreadable must still run the agent: the agent is what
131
140
  // installs and repairs the runtime, so failing here would remove the only path back.
132
141
  let runtimeEntry = '';
@@ -310,6 +319,28 @@ export async function runAgent(options) {
310
319
  if (!send({ type: 'telemetry', runtimeVersion, agentVersion: VERSION, events: batch })) telemetryQueue.unshift(...batch);
311
320
  }
312
321
 
322
+ function sendMetricsSample({ force = false } = {}) {
323
+ if (activeSocket?.readyState !== 1) return false;
324
+ const at = Date.now();
325
+ // Reconnects during a deploy reuse the same agent process. Do not turn every socket move into
326
+ // another PostgreSQL metrics write; the regular jittered sample is enough.
327
+ if (!force && lastMetricsSentAt && at - lastMetricsSentAt < Math.floor(metricsIntervalMs / 2)) return false;
328
+ const sent = send({ type: 'metrics', runtimeState: runtimeDown ? 'down' : 'ready', runtimeError, metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
329
+ if (sent) lastMetricsSentAt = at;
330
+ return sent;
331
+ }
332
+
333
+ function scheduleMetricsSample() {
334
+ if (stopping) return;
335
+ telemetryTimer = setTimeout(() => {
336
+ telemetryTimer = null;
337
+ sendMetricsSample();
338
+ flushTelemetry();
339
+ scheduleMetricsSample();
340
+ }, jitter(metricsIntervalMs));
341
+ telemetryTimer.unref?.();
342
+ }
343
+
313
344
  function queueEvent(event) {
314
345
  if (!telemetryEnabled) return;
315
346
  if (telemetryQueue.length >= TELEMETRY_QUEUE_LIMIT) telemetryQueue.shift();
@@ -335,10 +366,23 @@ export async function runAgent(options) {
335
366
 
336
367
  function scheduleReconnect(delay) {
337
368
  if (stopping || revoked || reconnectTimer) return;
369
+ // Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
370
+ // resource-exhaustion model treats this as a barrier, and future callers cannot retain the
371
+ // agent with an attacker-controlled timer lifetime.
372
+ const numericDelay = Number(delay);
373
+ if (!Number.isFinite(numericDelay)) {
374
+ scheduleReconnect(RECONNECT_BASE_MS);
375
+ return;
376
+ }
377
+ if (numericDelay > RECONNECT_MAX_MS) {
378
+ scheduleReconnect(RECONNECT_MAX_MS);
379
+ return;
380
+ }
381
+ const safeDelay = Math.max(100, Math.floor(numericDelay));
338
382
  reconnectTimer = setTimeout(() => {
339
383
  reconnectTimer = null;
340
384
  connect();
341
- }, delay);
385
+ }, safeDelay);
342
386
  reconnectTimer.unref?.();
343
387
  }
344
388
 
@@ -436,7 +480,7 @@ export async function runAgent(options) {
436
480
  reconnects,
437
481
  });
438
482
  console.log(`Connected to ${serverUrl} as ${deviceName}`);
439
- send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown }), runtimeState: runtimeDown ? 'down' : 'ready', runtimeError });
483
+ sendMetricsSample();
440
484
  reportInstallOnce();
441
485
  flushTelemetry();
442
486
  void checkForUpdate();
@@ -447,6 +491,7 @@ export async function runAgent(options) {
447
491
  try { message = JSON.parse(raw.toString()); } catch { return; }
448
492
  if (message?.type === 'reconnect') {
449
493
  askedToReconnect = true;
494
+ requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
450
495
  return;
451
496
  }
452
497
  if (message?.type === 'request') void respond(ws, message);
@@ -463,10 +508,12 @@ export async function runAgent(options) {
463
508
  // is not down, it is moving: reconnect at once and forget the backoff, so the person and the
464
509
  // model see nothing at all.
465
510
  if (code === 1013 || askedToReconnect) {
511
+ const delayMs = requestedReconnectDelayMs || 150;
466
512
  askedToReconnect = false;
513
+ requestedReconnectDelayMs = 0;
467
514
  reconnects = 0;
468
- console.log('ReMCP relay is being redeployed; reconnecting now.');
469
- scheduleReconnect(150);
515
+ console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
516
+ scheduleReconnect(delayMs);
470
517
  return;
471
518
  }
472
519
  if (code === 1008 || revoked) {
@@ -619,13 +666,7 @@ export async function runAgent(options) {
619
666
  }
620
667
  }
621
668
 
622
- telemetryTimer = setInterval(() => {
623
- if (activeSocket?.readyState === 1) {
624
- send({ type: 'metrics', runtimeState: runtimeDown ? 'down' : 'ready', runtimeError, metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
625
- flushTelemetry();
626
- }
627
- }, METRICS_INTERVAL_MS);
628
- telemetryTimer.unref?.();
669
+ scheduleMetricsSample();
629
670
  const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
630
671
  telemetryFlushTimer.unref?.();
631
672
  const updateTimer = setInterval(() => void checkForUpdate(), UPDATE_CHECK_INTERVAL_MS);
@@ -640,7 +681,7 @@ export async function runAgent(options) {
640
681
  clearTimeout(reconnectTimer);
641
682
  reconnectTimer = null;
642
683
  clearPostConnectUpdateChecks();
643
- if (telemetryTimer) clearInterval(telemetryTimer);
684
+ if (telemetryTimer) clearTimeout(telemetryTimer);
644
685
  clearInterval(telemetryFlushTimer);
645
686
  clearInterval(updateTimer);
646
687
  for (const controller of inFlight.values()) controller.abort();