@remcp/remcp 0.2.15 → 0.2.18

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.15",
3
+ "version": "0.2.18",
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/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs && node --check src/npm.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
package/src/agent.mjs CHANGED
@@ -17,6 +17,9 @@ const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
17
17
  const UPDATE_CHECK_TIMEOUT_MS = 5000;
18
18
  // A version that failed to install is retried after this cooldown instead of on every reconnect.
19
19
  const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
20
+ // How long a handing-over agent waits for its replacement to take the device over before it keeps
21
+ // running itself. Long enough for a fresh process to install nothing, connect and be registered.
22
+ const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
20
23
  const METRICS_INTERVAL_MS = 60_000;
21
24
  const TELEMETRY_QUEUE_LIMIT = 500;
22
25
  const TELEMETRY_BATCH_LIMIT = 100;
@@ -109,7 +112,7 @@ export function updateDecision({ advertised, cliVersion, runtimeVersion, runtime
109
112
  // Applies a freshly installed version. Exiting is what a supervisor needs; without one the new CLI is
110
113
  // started in this process' place. Either way the agent stops holding a stale runtime, which is what
111
114
  // makes an update actually take effect on a machine that no service manager watches.
112
- async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired) {
115
+ async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepaired, isStopping) {
113
116
  try {
114
117
  const installed = globalInstalledVersion();
115
118
  if (installed && !isNewer(installed, VERSION)) {
@@ -130,6 +133,15 @@ async function restartToApplyUpdate(cli, stopAgent, markStopping, onRuntimeRepai
130
133
  console.log(`ReMCP ${installed || 'a newer version'} installed and verified (${reported}); restarting to apply it.`);
131
134
  if (!supervisorRestart()) {
132
135
  spawn(process.execPath, [cli, 'start'], { detached: true, stdio: 'ignore', env: { ...process.env } }).unref();
136
+ // Stepping aside is only safe once the replacement really holds the device: the relay closes
137
+ // this socket with 1012 ('replaced') the moment another agent takes the machine over, and that
138
+ // close is what stops this process. Without the wait, a replacement that cannot start left the
139
+ // machine connected in `/health` and offline everywhere else, with nobody left to retry.
140
+ await new Promise(resolve => setTimeout(resolve, REPLACEMENT_HANDOVER_TIMEOUT_MS));
141
+ if (!isStopping()) {
142
+ console.error(`The replacement agent did not take over within ${Math.round(REPLACEMENT_HANDOVER_TIMEOUT_MS / 1000)}s; keeping ${VERSION} running. Retry with: remcp update`);
143
+ return;
144
+ }
133
145
  }
134
146
  markStopping();
135
147
  await stopAgent().catch(() => {});
@@ -158,9 +170,27 @@ function globalInstalledVersion() {
158
170
  }
159
171
  }
160
172
 
161
- function supervisorRestart() {
162
- if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
163
- try { if (existsSync('/.dockerenv')) return 'docker'; } catch {}
173
+ // True when this process is the one a service manager owns: launchd and systemd's system manager run
174
+ // a unit's main process as a child of PID 1, and `systemd --user` runs it as a child of the user
175
+ // manager. Anything else a terminal, a shell inside another unit, a CI runner job — has nobody
176
+ // waiting to start the agent again.
177
+ function parentIsServiceManager() {
178
+ if (process.ppid === 1) return true;
179
+ if (process.platform === 'win32') return false;
180
+ try { return readFileSync(`/proc/${process.ppid}/comm`, 'utf8').trim() === 'systemd'; } catch { return false; }
181
+ }
182
+
183
+ // What starts the agent again after it exits to apply an update, or null when it has to start its own
184
+ // replacement. systemd sets INVOCATION_ID and JOURNAL_STREAM for a unit and every child of that unit
185
+ // inherits them, so a `remcp start` run from a shell inside a service (a CI runner, a systemd-run
186
+ // scope, another agent) believed a supervisor would bring it back: the update exited into nothing and
187
+ // the workspace showed the machine offline until someone started the agent by hand. Only the unit's
188
+ // own main process is restarted, so that is what the check requires.
189
+ //
190
+ // Injectable for tests: the verdict must not depend on the machine that runs them.
191
+ export function supervisorRestart({ platform = process.platform, dockerenv = existsSync('/.dockerenv'), parentOurs = parentIsServiceManager() } = {}) {
192
+ if (parentOurs) return platform === 'darwin' ? 'launchd' : 'systemd';
193
+ if (dockerenv) return 'docker';
164
194
  return null;
165
195
  }
166
196
 
@@ -500,7 +530,7 @@ export async function runAgent(options) {
500
530
  runtimeError = error instanceof Error ? error.message : String(error);
501
531
  }
502
532
  if (!runtimeDown && !stopping) await startRuntime();
503
- });
533
+ }, () => stopping);
504
534
  });
505
535
  child.on('error', error => {
506
536
  updateInFlight = false;
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { localRuntimeEntry, runAgent } from './agent.mjs';
7
+ import { localRuntimeEntry, runAgent, supervisorRestart } from './agent.mjs';
8
8
  import { npmVersion, resolveNpm } from './npm.mjs';
9
9
  import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
10
10
  import { PACKAGE_NAME, VERSION } from './version.mjs';
@@ -241,15 +241,9 @@ function restartPersistentServiceIfInstalled() {
241
241
  // The agent the user installed with `remcp install` is the one this CLI manages. A machine can also
242
242
  // be supervised by its own systemd unit, by Docker, or by a terminal, and in those cases installing
243
243
  // a new version is not enough: the running process keeps the old code until something restarts it.
244
- // systemd marks every unit process with INVOCATION_ID and Docker leaves /.dockerenv, so those two
245
- // cases can be handed over by exiting (the supervisor starts the new build); anything else gets an
246
- // explicit instruction instead of a silent exit that would take the device offline.
247
- function supervisorRestart() {
248
- if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) return 'systemd';
249
- try { if (fs.existsSync('/.dockerenv')) return 'docker'; } catch {}
250
- return null;
251
- }
252
-
244
+ // `supervisorRestart` (agent.mjs) answers which of those is true, and only reports a service manager
245
+ // when it really owns this process: a terminal gets an explicit instruction instead of a silent exit
246
+ // that would take the device offline.
253
247
  // One real handshake with the local runtime, plus everything needed to explain a failure: where the
254
248
  // entry resolved, whether the package is installed, the node that would run it, and the exact error.
255
249
  async function diagnoseLocalRuntime(cfg) {
package/src/npm.mjs CHANGED
@@ -43,15 +43,19 @@ export function npmCandidates({ nodePath = process.execPath, home = os.homedir()
43
43
 
44
44
  // Resolves how to run npm. `source` is reported by `remcp doctor` and logged at agent startup so a
45
45
  // machine where npm cannot be found is obvious before an update is needed.
46
- export function resolveNpm({ nodePath = process.execPath, home = os.homedir(), platform = process.platform } = {}) {
46
+ //
47
+ // `exists` is injectable so a test can describe a machine with no npm at all instead of asking the
48
+ // machine running the test: the Linux candidate list carries fixed prefixes (/usr, /usr/local) that
49
+ // a CI runner or a developer laptop usually does have, which made that case untestable there.
50
+ export function resolveNpm({ nodePath = process.execPath, home = os.homedir(), platform = process.platform, exists = existsSync } = {}) {
47
51
  const override = String(process.env.REMCP_NPM || '').trim();
48
52
  if (override) return { command: override, args: [], source: `REMCP_NPM=${override}` };
49
53
  const { cli, binaries } = npmCandidates({ nodePath, home, platform });
50
54
  for (const candidate of cli) {
51
- if (existsSync(candidate)) return { command: nodePath, args: [candidate], source: `node ${candidate}` };
55
+ if (exists(candidate)) return { command: nodePath, args: [candidate], source: `node ${candidate}` };
52
56
  }
53
57
  for (const candidate of binaries) {
54
- if (existsSync(candidate)) return { command: candidate, args: [], source: candidate };
58
+ if (exists(candidate)) return { command: candidate, args: [], source: candidate };
55
59
  }
56
60
  return { command: platform === 'win32' ? 'npm.cmd' : 'npm', args: [], source: 'PATH' };
57
61
  }