@remcp/remcp 0.2.45 → 0.2.47

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.45",
3
+ "version": "0.2.47",
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
@@ -286,6 +286,15 @@ export async function runAgent(options) {
286
286
  // Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
287
287
  // the relay, but cap recovery so an enable action becomes effective within seconds.
288
288
  scheduleReconnect(jitter(RECONNECT_BASE_MS));
289
+ return;
290
+ }
291
+ const status = Number(response.statusCode || 0);
292
+ const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
293
+ if (transientHandshakeFailure && !revoked) {
294
+ // A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
295
+ // With an unexpected-response listener the client does not reliably emit close afterwards,
296
+ // so this branch must schedule its own retry or a still-running agent can stay offline forever.
297
+ scheduleBackoffReconnect();
289
298
  }
290
299
  });
291
300
  activeSocket = ws;
package/src/cli/env.mjs CHANGED
@@ -11,6 +11,7 @@ export const configFile = path.join(configDir, 'config.json');
11
11
  export const runtimeConfigFile = path.join(configDir, 'runtime.json');
12
12
  export const machineIdFile = path.join(configDir, 'machine-id');
13
13
  export const linuxServiceFile = path.join(home, '.config', 'systemd', 'user', 'remcp-agent.service');
14
+ export const linuxServiceLauncherFile = path.join(configDir, 'remcp-agent-launcher');
14
15
  export const macServiceLabel = 'com.remcp.agent';
15
16
  export const macServiceFile = path.join(home, 'Library', 'LaunchAgents', `${macServiceLabel}.plist`);
16
17
  export const macLogFile = path.join(home, 'Library', 'Logs', 'remcp-agent.log');
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process';
8
8
  import { PACKAGE_NAME, VERSION } from '../version.mjs';
9
9
 
10
10
  import { saveConfig } from './config.mjs';
11
- import { configDir, home, linuxServiceFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
11
+ import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
12
12
  import { output, run } from './shell.mjs';
13
13
 
14
14
  export function servicePlatform() {
@@ -32,12 +32,28 @@ export function quoteSystemd(value) {
32
32
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
33
33
  }
34
34
 
35
+ function resolvedCliScript(cliPath) {
36
+ return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
37
+ }
38
+
39
+ function writeLinuxServiceLauncher(cliPath = globalCliPath()) {
40
+ const cliScript = resolvedCliScript(cliPath);
41
+ const launcher = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(cliScript)} \"$@\"\n`;
42
+ fs.mkdirSync(path.dirname(linuxServiceLauncherFile), { recursive: true, mode: 0o700 });
43
+ const temporary = `${linuxServiceLauncherFile}.${process.pid}.${Date.now()}.tmp`;
44
+ fs.writeFileSync(temporary, launcher, { mode: 0o700 });
45
+ fs.chmodSync(temporary, 0o700);
46
+ fs.renameSync(temporary, linuxServiceLauncherFile);
47
+ return linuxServiceLauncherFile;
48
+ }
49
+
35
50
  export function xmlEscape(value) {
36
51
  return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
37
52
  }
38
53
 
39
54
  export function installLinuxService(cliPath = globalCliPath()) {
40
- 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`;
55
+ const launcherFile = writeLinuxServiceLauncher(cliPath);
56
+ const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(launcherFile)} start\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
41
57
  fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
42
58
  fs.writeFileSync(linuxServiceFile, unit);
43
59
  run('systemctl', ['--user', 'daemon-reload']);
@@ -65,23 +81,41 @@ function macJobLoaded(target) {
65
81
  return spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
66
82
  }
67
83
 
68
- function scheduleMacServiceReload(domain, target) {
84
+ function submitMacHelper(kind, lines) {
69
85
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
70
- const helperFile = path.join(configDir, `launchd-reload-${process.pid}.sh`);
71
- const helperLabel = `${macServiceLabel}.reload.${process.pid}`;
86
+ const nonce = `${process.pid}.${Date.now()}`;
87
+ const helperFile = path.join(configDir, `launchd-${kind}-${nonce}.sh`);
88
+ const helperLabel = `${macServiceLabel}.${kind}.${nonce}`;
72
89
  const script = [
73
90
  '#!/bin/sh',
74
91
  'sleep 1',
75
- `launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
76
- `launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
77
- `launchctl enable ${shellQuote(target)}`,
78
- `launchctl kickstart -k ${shellQuote(target)}`,
92
+ 'status=0',
93
+ ...lines.map(line => `${line} || status=$?`),
94
+ `if [ "$status" -ne 0 ]; then echo "ReMCP launchd ${kind} helper failed with status $status" >> ${shellQuote(macLogFile)}; fi`,
79
95
  `rm -f ${shellQuote(helperFile)}`,
96
+ `launchctl remove ${shellQuote(helperLabel)} >/dev/null 2>&1 || true`,
97
+ 'exit 0',
80
98
  '',
81
99
  ].join('\n');
82
100
  fs.writeFileSync(helperFile, script, { mode: 0o700 });
83
101
  fs.chmodSync(helperFile, 0o700);
84
102
  run('launchctl', ['submit', '-l', helperLabel, '--', '/bin/sh', helperFile]);
103
+ return helperLabel;
104
+ }
105
+
106
+ function scheduleMacServiceReload(domain, target) {
107
+ return submitMacHelper('reload', [
108
+ `launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
109
+ `launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
110
+ `launchctl enable ${shellQuote(target)}`,
111
+ `launchctl kickstart -k ${shellQuote(target)}`,
112
+ ]);
113
+ }
114
+
115
+ function scheduleMacServiceRestart(target) {
116
+ return submitMacHelper('restart', [
117
+ `launchctl kickstart -k ${shellQuote(target)}`,
118
+ ]);
85
119
  }
86
120
 
87
121
  export function installMacService(cliPath = globalCliPath(), { restart = true } = {}) {
@@ -211,17 +245,16 @@ export function ensureServiceIfRecorded(config) {
211
245
  if (!fs.existsSync(linuxServiceFile)) {
212
246
  installLinuxService(cliPath);
213
247
  } else {
214
- // Node managers can move the global npm prefix between updates (nvm -> Hermes was observed in
215
- // production). An existing systemd unit then keeps launching the old CLI forever even though
216
- // npm successfully installed the new one. Repair the launcher in place before restarting it.
248
+ // Keep the systemd unit independent of nvm/Hermes/Homebrew prefixes. Only this small launcher
249
+ // changes when npm or Node moves, so every install converges on one supervisor entrypoint.
250
+ const launcherFile = writeLinuxServiceLauncher(cliPath);
217
251
  const unit = fs.readFileSync(linuxServiceFile, 'utf8');
218
- const expected = `ExecStart=${quoteSystemd(cliPath)} start`;
252
+ const expected = `ExecStart=${quoteSystemd(launcherFile)} start`;
219
253
  if (!unit.includes(expected)) {
220
254
  const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
221
255
  if (!repaired) {
222
- // A unit that lost its ExecStart line (edited by hand, or written as `ExecStart = …`) cannot
223
- // be patched by substitution. Rewriting the whole unit is what keeps the machine out of the
224
- // "old CLI forever" loop, so fall back to a fresh install instead of giving up.
256
+ // A hand-edited unit with no launcher line is replaced wholesale; the stable launcher is
257
+ // still refreshed first so the replacement never points back at a retired Node manager.
225
258
  installLinuxService(cliPath);
226
259
  } else {
227
260
  fs.writeFileSync(linuxServiceFile, repaired);
@@ -255,12 +288,14 @@ export function restartPersistentServiceIfInstalled(config) {
255
288
  return 'remcp-agent.service';
256
289
  }
257
290
  if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
258
- try {
259
- run('launchctl', ['kickstart', '-k', `${macLaunchDomain()}/${macServiceLabel}`]);
260
- } catch {
291
+ const target = `${macLaunchDomain()}/${macServiceLabel}`;
292
+ if (macJobLoaded(target)) {
293
+ // Never kill a loaded LaunchAgent inline: the caller may itself be a child of that job.
294
+ // A transient helper survives the handoff and performs the restart after this CLI returns.
295
+ scheduleMacServiceRestart(target);
296
+ } else {
261
297
  // A plist can survive while launchd has no loaded job (older installs, logout/login cleanup,
262
- // manual bootout, or a failed previous update). Re-register the job instead of surfacing the
263
- // opaque launchctl 113 error. installMacService is idempotent and rewrites stale Node paths too.
298
+ // manual bootout, or a failed previous update). Re-register it directly.
264
299
  installMacService(globalCliPath());
265
300
  }
266
301
  return macServiceLabel;