@remcp/remcp 0.2.234 → 0.2.236

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/README.md CHANGED
@@ -90,7 +90,7 @@ permission, and `remcp doctor` reports the state of each one.
90
90
 
91
91
  ## Updates
92
92
 
93
- The agent asks the server which versions it should run on every successful connection and every six hours, installs the exact trusted client/runtime release pair in the background, and restarts the managed service so it takes effect. The updater resolves itself and the sibling runtime from the package that is actually running instead of depending on an interactive shell PATH. When a computer has several Node.js environments (for example NVM, FNM or a system installation), ReMCP updates only the canonical managed service packages. It does not upgrade Node.js, rewrite unrelated npm packages, or silently remove old ReMCP copies. `remcp status` counts package roots verified from the current CLI/service and bounded installation history; aliases and vanished paths are not extra installations. `remcp doctor` adds the exact read-only package roots, corresponding client/runtime versions, aliases and historical/cache classifications. These are **physical package copies, not a count of running agents**. Old copies are never automatically removed; verifying process, startup and script dependencies is required before an explicit owner-approved cleanup. A failed install is retried no more often than every thirty minutes and update-check failures are logged, so a broken release cannot become a silent install loop. `remcp update` does the same immediately; `remcp auto-update off` disables automatic checks.
93
+ The agent checks the server on a stable per-device phase within a 30-minute cycle, rather than on every reconnect. New automatic installs are spread over a deterministic 24-hour window per version; older unattended clients are admitted through a shared Redis budget of 120 update hints per minute. The exact trusted client/runtime release pair is installed in the background and the managed service restarts to apply it. An explicit manual `remcp update` remains immediate. The updater resolves itself and the sibling runtime from the package that is actually running instead of depending on an interactive shell PATH. When a computer has several Node.js environments (for example NVM, FNM or a system installation), ReMCP updates only the canonical managed service packages. It does not upgrade Node.js, rewrite unrelated npm packages, or silently remove old ReMCP copies. `remcp status` counts package roots verified from the current CLI/service and bounded installation history; aliases and vanished paths are not extra installations. `remcp doctor` adds the exact read-only package roots, corresponding client/runtime versions, aliases and historical/cache classifications. These are **physical package copies, not a count of running agents**. Old copies are never automatically removed; verifying process, startup and script dependencies is required before an explicit owner-approved cleanup. A failed install is retried no more often than every thirty minutes and update-check failures are logged, so a broken release cannot become a silent install loop. `remcp update` does the same immediately; `remcp auto-update off` disables automatic checks.
94
94
 
95
95
  ## Computer-use routing
96
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.234",
3
+ "version": "0.2.236",
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-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/installation-audit.mjs && node --check src/cli/mac-native-temperature.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/supervised-agent-lifecycle.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-cli-shim.mjs && node --check src/cli/windows-owned-job-source.mjs && node --check src/cli/windows-owned-job.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/cli/windows-update-lock.mjs && node --check src/cli/windows-user-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",
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-rollout.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/installation-audit.mjs && node --check src/cli/mac-native-temperature.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/supervised-agent-lifecycle.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-cli-shim.mjs && node --check src/cli/windows-owned-job-source.mjs && node --check src/cli/windows-owned-job.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/cli/windows-update-lock.mjs && node --check src/cli/windows-user-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": {
@@ -18,6 +18,7 @@ export function createAgentRelayConnection({
18
18
  setSocket, getHelloState, respond, inFlight,
19
19
  sendMetricsSample, reportInstallOnce, flushTelemetry,
20
20
  checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
21
+ checkUpdatesOnConnect = false,
21
22
  heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS, handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
22
23
  }) {
23
24
  let revoked = false;
@@ -163,7 +164,9 @@ export function createAgentRelayConnection({
163
164
  sendMetricsSample();
164
165
  reportInstallOnce();
165
166
  flushTelemetry();
166
- void checkForUpdate();
167
+ // Normal fleet clients poll on a stable per-device phase, not all on
168
+ // every relay handshake. Explicit short QA rechecks retain hot-path coverage.
169
+ if (checkUpdatesOnConnect) void checkForUpdate();
167
170
  schedulePostConnectUpdateChecks();
168
171
  });
169
172
  ws.on('pong', () => { if (activeSocket === ws) awaitingPong = false; });
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import process from 'node:process';
4
4
  import { VERSION } from './version.mjs';
5
5
  import { fetchUpdateManifest } from './fetch-update-manifest.mjs';
6
+ import { updateRolloutEligibility } from './agent-update-rollout.mjs';
6
7
  import { globalCliEntry, globalInstalledVersion, isNewer, restartToApplyUpdate, updateDecision, updateInvocationArgs } from './agent-update.mjs';
7
8
 
8
9
  // Poll releases while connected too: relying on reconnect alone can leave a
@@ -11,7 +12,9 @@ export const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000;
11
12
  const UPDATE_CHECK_TIMEOUT_MS = 12000;
12
13
  // A flapping relay must not trigger update discovery on every successful reconnect.
13
14
  const UPDATE_CHECK_RECONNECT_GAP_MS = 30000;
14
- const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([60_000, 5 * 60_000, 15 * 60_000]);
15
+ // Fleet clients already have a per-device wall-clock polling phase. Avoid
16
+ // bursty reconnect-triggered rechecks; explicit short QA fixtures may opt in.
17
+ const POST_CONNECT_UPDATE_RECHECK_DELAYS_MS = Object.freeze([]);
15
18
  const POST_CONNECT_UPDATE_RECHECK_JITTER_MS = 30_000;
16
19
  const UPDATE_RETRY_COOLDOWN_MS = 30 * 60 * 1000;
17
20
 
@@ -99,6 +102,15 @@ export function createAgentUpdateMonitor({ options, serverUrl, queueEvent, getRu
99
102
  // proves convergence only after the replacement agent reconnects.
100
103
  queueEvent({ event: 'agent_update_available', at: observedAt, reason: decision.reason.slice(0, 32) });
101
104
  }
105
+ const eligible = updateRolloutEligibility({
106
+ deviceId: options.deviceId,
107
+ target: availabilityKey,
108
+ rollout: advertised.rollout,
109
+ // An explicitly raised minimum version or broken local runtime needs
110
+ // repair without the normal gradual fleet-release delay.
111
+ urgent: decision.reason === 'runtime-repair' || Boolean(minimum && isNewer(minimum, VERSION)),
112
+ }, observedAt);
113
+ if (!eligible.ready) return;
102
114
  const cli = resolveCli();
103
115
  if (!cli || !existsSync(cli)) {
104
116
  if (announce) console.error(`ReMCP ${target} is available; run: remcp update`);
@@ -0,0 +1,92 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // A 24-hour fleet rollout makes 100,000 downloads roughly 1.16/sec instead
4
+ // of initiating all installers when a new version first becomes visible.
5
+ export const ROLLOUT_WINDOW_MS = 24 * 60 * 60 * 1000;
6
+ const ROLLOUT_MAX_WINDOW_MS = 7 * ROLLOUT_WINDOW_MS;
7
+ const ROLLOUT_STATE_TTL_SECONDS = 120 * 24 * 60 * 60;
8
+
9
+ export function updateRolloutEligibility({ deviceId, target, rollout, urgent = false }, now = Date.now()) {
10
+ if (urgent || !rollout) return { ready:true, readyAt:now };
11
+ const startAtMs = Number(rollout.startAtMs);
12
+ const windowMs = Number(rollout.windowMs);
13
+ // A malformed policy must not accidentally release the whole fleet at once.
14
+ if (!String(deviceId || '').trim() || !String(target || '').trim()
15
+ || !Number.isSafeInteger(startAtMs) || startAtMs < 1_600_000_000_000
16
+ || !Number.isSafeInteger(windowMs) || windowMs < 60_000 || windowMs > ROLLOUT_MAX_WINDOW_MS) {
17
+ return { ready:false, readyAt:null };
18
+ }
19
+ // A stable local device identifier is hashed, not transmitted to the public
20
+ // update endpoint. A distinct release has a distinct deterministic slot.
21
+ const hash = createHash('sha256').update('remcp-agent-rollout-v1\0')
22
+ .update(String(deviceId)).update('\0').update(String(target)).digest();
23
+ const offsetMs = Math.floor(hash.readUInt32BE(0) / 0x100000000 * windowMs);
24
+ const readyAt = startAtMs + offsetMs;
25
+ // The response's server timestamp prevents incorrect client wall clocks
26
+ // from synchronously skipping all fleet slots.
27
+ const serverNowMs = Number(rollout.serverNowMs);
28
+ const effectiveNow = Number.isSafeInteger(serverNowMs) && serverNowMs >= 1_600_000_000_000
29
+ ? serverNowMs : now;
30
+ return { ready:effectiveNow >= readyAt, readyAt };
31
+ }
32
+
33
+ // Fleet update checks are phased on the wall clock as well. A 100k-device
34
+ // reconnect wave must not turn into 100k immediate manifest GETs or a synchronized
35
+ // 30-minute polling loop. The phase survives agent restarts with no disk state.
36
+ export function nextFleetManifestDelayMs(deviceId, now = Date.now(), intervalMs = 30 * 60 * 1000) {
37
+ if (!String(deviceId || '').trim() || !Number.isSafeInteger(intervalMs) || intervalMs < 1000) {
38
+ throw new Error('Fleet manifest scheduling requires a paired device and a valid interval');
39
+ }
40
+ const hash = createHash('sha256').update('remcp-update-check-phase-v1\0').update(String(deviceId)).digest();
41
+ const phase = Math.floor(hash.readUInt32BE(0) / 0x100000000 * intervalMs);
42
+ const elapsed = ((now % intervalMs) + intervalMs) % intervalMs;
43
+ return (phase - elapsed + intervalMs) % intervalMs || intervalMs;
44
+ }
45
+
46
+ // Legacy clients (before scheduler v1) cannot defer installs themselves.
47
+ // Admit a bounded number of old automatic update checks per minute across the
48
+ // entire HA cluster; the rest retry their next normal check. Manual status,
49
+ // bootstrap and manually requested updates use their own unthrottled paths.
50
+ export async function claimLegacyManifestSlot(command, version, {
51
+ now = Date.now, limitPerMinute = 120, isClosed = () => false,
52
+ } = {}) {
53
+ if (isClosed() || !command?.isReady) throw new Error('Shared legacy update admission unavailable');
54
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(version))) {
55
+ throw new Error('Invalid version for legacy update admission');
56
+ }
57
+ if (!Number.isSafeInteger(limitPerMinute) || limitPerMinute < 1 || limitPerMinute > 6000) {
58
+ throw new Error('Invalid legacy update admission quota');
59
+ }
60
+ const minute = Math.floor(now() / 60_000);
61
+ const key = 'remcp:deploy:legacy-update-admit:v1:' + version + ':' + minute;
62
+ const script = "local n=redis.call('INCR',KEYS[1]); if n == 1 then redis.call('EXPIRE',KEYS[1],90) end; return n <= tonumber(ARGV[1]) and 1 or 0";
63
+ return Number(await command.eval(script, { keys:[key], arguments:[String(limitPerMinute)] })) === 1;
64
+ }
65
+
66
+ // Use only immutable HTTP traits that identify the old agent's no-header
67
+ // automatic fetch. The trusted user-requested manual update uses Accept JSON.
68
+ export function isLegacyAutomaticUpdateRequest(req) {
69
+ const header = name => String(req?.get?.(name) || req?.headers?.[name.toLowerCase()] || '').toLowerCase();
70
+ return /^(?:node|undici)(?:\/[0-9.]+)?$/.test(header('user-agent'))
71
+ && header('accept').trim() === '*/*'
72
+ && header('x-remcp-update-scheduler') !== 'v1'
73
+ && !req?.query?.fresh;
74
+ }
75
+
76
+ // The shared Redis epoch is created exactly once for each released version.
77
+ // A rolling HA deploy or process restart must not reset the fleet schedule.
78
+ // Use the same Redis coordination store as the existing release pause gate.
79
+ export async function getOrCreateRolloutStart(command, version, now = Date.now, isClosed = () => false) {
80
+ if (isClosed() || !command?.isReady) throw new Error('Shared rollout control plane unavailable');
81
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(version))) {
82
+ throw new Error('Invalid release version for update rollout');
83
+ }
84
+ const key = 'remcp:deploy:agent-rollout-start:v1:' + version;
85
+ const proposed = String(now());
86
+ await command.set(key, proposed, { NX:true, EX:ROLLOUT_STATE_TTL_SECONDS });
87
+ const persisted = Number(await command.get(key));
88
+ if (!Number.isSafeInteger(persisted) || persisted < 1_600_000_000_000) {
89
+ throw new Error('Shared rollout start is unavailable');
90
+ }
91
+ return persisted;
92
+ }
package/src/agent.mjs CHANGED
@@ -8,6 +8,7 @@ import { localRuntimeEntry } from './agent-update.mjs';
8
8
  export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
9
9
  import { normalizedServerUrl, sendRelayMessage, jitter, deviceMetrics } from './agent-utilities.mjs';
10
10
  import { createAgentUpdateMonitor, UPDATE_CHECK_INTERVAL_MS } from './agent-update-monitor.mjs';
11
+ import { nextFleetManifestDelayMs } from './agent-update-rollout.mjs';
11
12
  import { createAgentResponder } from './agent-responder.mjs';
12
13
  import { createAgentRelayConnection } from './agent-relay-connection.mjs';
13
14
  import { createRuntimeCapabilities, normalizeRuntimeToolNames } from './agent-runtime-capabilities.mjs';
@@ -288,12 +289,20 @@ export async function runAgent(options) {
288
289
  }),
289
290
  respond, inFlight, sendMetricsSample, reportInstallOnce, flushTelemetry,
290
291
  checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
292
+ checkUpdatesOnConnect: Array.isArray(options.updateRecheckDelaysMs),
291
293
  });
292
294
 
293
295
  scheduleMetricsSample();
294
296
  const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
295
297
  telemetryFlushTimer.unref?.();
296
- const updateTimer = setInterval(() => void checkForUpdate(), UPDATE_CHECK_INTERVAL_MS);
298
+ // Spread 100k devices across the entire 30-minute manifest polling cycle.
299
+ // The stable device phase survives reconnects/restarts without local writes.
300
+ let recurringUpdateTimer = null;
301
+ const updateTimer = setTimeout(() => {
302
+ void checkForUpdate({ scheduled:true });
303
+ recurringUpdateTimer = setInterval(() => void checkForUpdate({ scheduled:true }), UPDATE_CHECK_INTERVAL_MS);
304
+ recurringUpdateTimer.unref?.();
305
+ }, nextFleetManifestDelayMs(deviceId, Date.now(), UPDATE_CHECK_INTERVAL_MS));
297
306
  updateTimer.unref?.();
298
307
 
299
308
  async function stop() {
@@ -305,7 +314,8 @@ export async function runAgent(options) {
305
314
  clearPostConnectUpdateChecks();
306
315
  if (telemetryTimer) clearTimeout(telemetryTimer);
307
316
  clearInterval(telemetryFlushTimer);
308
- clearInterval(updateTimer);
317
+ clearTimeout(updateTimer);
318
+ if (recurringUpdateTimer) clearInterval(recurringUpdateTimer);
309
319
  for (const controller of inFlight.values()) controller.abort();
310
320
  activeSocket?.terminate();
311
321
  stopPromise = Promise.allSettled([mcp?.close(), transport?.close()]).then(results => {
@@ -4,6 +4,14 @@ import path from 'node:path';
4
4
  const CLIENT_PACKAGE = '@remcp/remcp';
5
5
  const MAX_HISTORY = 32;
6
6
 
7
+ function isExplicitCliAlias(cliPath) {
8
+ // A symlink in an ancestor directory (e.g. macOS /var -> /private/var)
9
+ // changes realpath but does not make the final CLI entry itself an alias.
10
+ // lstat checks the final path component without following that entry.
11
+ try { return fs.lstatSync(cliPath).isSymbolicLink(); }
12
+ catch { return false; } // A concurrently removed entry cannot be trusted.
13
+ }
14
+
7
15
  // Inventory only paths previously recorded by this owner and the two current CLI
8
16
  // entrypoints. Never crawl npm/node directories or infer running processes.
9
17
  function installedPackageAt(cliPath) {
@@ -52,7 +60,7 @@ export function auditKnownInstallations(config, { currentCliPath, serviceCliPath
52
60
  }
53
61
  copy.sources.add(candidate.source);
54
62
  copy.cliPaths.add(candidate.path);
55
- if (candidate.path !== installed.resolved && !aliases.some(a => a.path === candidate.path)) {
63
+ if (isExplicitCliAlias(candidate.path) && !aliases.some(a => a.path === candidate.path)) {
56
64
  aliases.push({ path:candidate.path, resolvesTo:installed.resolved });
57
65
  }
58
66
  }
package/src/cli.mjs CHANGED
@@ -44,7 +44,7 @@ function printHelp() {
44
44
  }
45
45
 
46
46
  // The running agent snapshots update policy at startup. A saved toggle alone does not
47
- // change its six-hour polling loop, so reload a managed service after a policy change.
47
+ // change its per-device polling schedule, so reload a managed service after a policy change.
48
48
  // Defer Linux handoff to avoid terminating a remote command before it returns.
49
49
  export function applyAutoUpdatePreference(cfg, enabled, {
50
50
  persist = saveConfig,
@@ -219,7 +219,7 @@ export async function main(argv = process.argv.slice(2)) {
219
219
  const action = String(positional[0] || 'status').toLowerCase();
220
220
  const cfg = loadConfig(false) || {};
221
221
  if (action === 'status') {
222
- console.log(JSON.stringify({ autoUpdate: autoUpdateEnabled(cfg), checkIntervalHours: 6 }, null, 2));
222
+ console.log(JSON.stringify({ autoUpdate: autoUpdateEnabled(cfg), checkIntervalHours: 0.5, fleetRolloutHours:24 }, null, 2));
223
223
  return;
224
224
  }
225
225
  if (['on', 'enable', 'off', 'disable'].includes(action)) {
@@ -29,6 +29,9 @@ export async function fetchUpdateManifest(url, {
29
29
  (async () => {
30
30
  const response = await fetchImpl(url, {
31
31
  redirect: 'error',
32
+ // Signals support for deterministic fleet rollout; old agents send
33
+ // plain fetch requests and use a shared Redis admission budget.
34
+ headers: { 'x-remcp-update-scheduler':'v1' },
32
35
  signal: controller.signal,
33
36
  });
34
37
  if (!response.ok) return null;