@remcp/remcp 0.2.42 → 0.2.43

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 +44 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.42",
3
+ "version": "0.2.43",
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
@@ -77,6 +77,7 @@ export async function runAgent(options) {
77
77
  const inFlight = new Map();
78
78
  let activeSocket;
79
79
  let reconnects = 0;
80
+ let reconnectTimer = null;
80
81
  // Set when a replica asks this agent to move before it is replaced; the close handler reads it.
81
82
  let askedToReconnect = false;
82
83
  let pendingRequests = 0;
@@ -219,6 +220,26 @@ export async function runAgent(options) {
219
220
  }
220
221
  }
221
222
 
223
+ function scheduleReconnect(delay) {
224
+ if (stopping || revoked || reconnectTimer) return;
225
+ reconnectTimer = setTimeout(() => {
226
+ reconnectTimer = null;
227
+ connect();
228
+ }, delay);
229
+ reconnectTimer.unref?.();
230
+ }
231
+
232
+ function scheduleBackoffReconnect() {
233
+ if (stopping || revoked || reconnectTimer) return;
234
+ reconnects += 1;
235
+ // The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
236
+ // a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
237
+ const delay = reconnects === 1
238
+ ? jitter(RECONNECT_FIRST_MS)
239
+ : jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
240
+ scheduleReconnect(delay);
241
+ }
242
+
222
243
  async function respond(ws, message) {
223
244
  pendingRequests += 1;
224
245
  // The relay forwards a cancel when the MCP client goes away. Without it a cancelled tool call
@@ -250,15 +271,29 @@ export async function runAgent(options) {
250
271
  function connect() {
251
272
  if (stopping) return;
252
273
  const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
253
- // A revoked device is refused during the handshake with a 401 and this header, because a bare
254
- // rejection looked like a network problem (close 1006) and the agent retried it forever.
255
- ws.on('unexpected-response', (_request, response) => {
256
- if (String(response.headers['x-remcp-revoked'] || '') === '1') revoked = true;
257
- console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${revoked ? ': this device was revoked' : ''}.`);
274
+ // The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
275
+ // A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
276
+ // this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
277
+ ws.on('unexpected-response', (request, response) => {
278
+ const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
279
+ const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
280
+ if (handshakeRevoked) revoked = true;
281
+ console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${handshakeRevoked ? ': this device was revoked' : handshakeDisabled ? ': this device is temporarily disabled' : ''}.`);
258
282
  response.resume();
283
+ request.destroy();
284
+ if (handshakeDisabled && !revoked) {
285
+ reconnects += 1;
286
+ // Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
287
+ // the relay, but cap recovery so an enable action becomes effective within seconds.
288
+ scheduleReconnect(jitter(RECONNECT_BASE_MS));
289
+ }
259
290
  });
260
291
  activeSocket = ws;
261
292
  ws.on('open', () => {
293
+ if (reconnectTimer) {
294
+ clearTimeout(reconnectTimer);
295
+ reconnectTimer = null;
296
+ }
262
297
  reconnects = 0;
263
298
  ws.send(JSON.stringify({
264
299
  type: 'hello',
@@ -303,7 +338,7 @@ export async function runAgent(options) {
303
338
  askedToReconnect = false;
304
339
  reconnects = 0;
305
340
  console.log('ReMCP relay is being redeployed; reconnecting now.');
306
- setTimeout(connect, 150);
341
+ scheduleReconnect(150);
307
342
  return;
308
343
  }
309
344
  if (code === 1008 || revoked) {
@@ -321,13 +356,7 @@ export async function runAgent(options) {
321
356
  void stop().finally(() => setTimeout(() => process.exit(0), 100));
322
357
  return;
323
358
  }
324
- reconnects += 1;
325
- // The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
326
- // a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
327
- const delay = reconnects === 1
328
- ? jitter(RECONNECT_FIRST_MS)
329
- : jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
330
- setTimeout(connect, delay);
359
+ scheduleBackoffReconnect();
331
360
  });
332
361
  ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
333
362
  }
@@ -443,6 +472,8 @@ export async function runAgent(options) {
443
472
  if (stopPromise) return stopPromise;
444
473
  stopping = true;
445
474
  clearTimeout(runtimeRestartTimer);
475
+ clearTimeout(reconnectTimer);
476
+ reconnectTimer = null;
446
477
  if (telemetryTimer) clearInterval(telemetryTimer);
447
478
  clearInterval(telemetryFlushTimer);
448
479
  clearInterval(updateTimer);