@remcp/remcp 0.2.47 → 0.2.48

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
@@ -17,8 +17,8 @@ credential under `~/.config/remcp/`, installs the runtime from npm, and register
17
17
  ## Commands
18
18
 
19
19
  ```text
20
- remcp start Run the device agent in the foreground
21
- remcp status Show version, pairing, runtime, telemetry and server health as JSON
20
+ remcp start Run the device agent when no managed background service is already running
21
+ remcp status Show device/agent versions, update consistency and server reachability as JSON
22
22
  remcp doctor Same report plus a real tool handshake with the local runtime, and which
23
23
  macOS privacy folders (Desktop, Documents, Downloads, iCloud Drive) this
24
24
  computer currently lets ReMCP use
@@ -31,8 +31,7 @@ remcp godmode [status|on|off]
31
31
  remcp --version
32
32
  ```
33
33
 
34
- `remcp status` reports the runtime the device would install, whether the agent service is running,
35
- and the current usage-metrics state, so a support request can be answered with one paste.
34
+ `remcp status` reports the configured/installed runtime, the managed-agent state, whether multiple local ReMCP installations are consistent, whether an update is required, server reachability, and the current usage-metrics state. It deliberately does not expose backend database/provider, relay topology, fleet size, or other operational internals. `remcp doctor` adds local runtime/filesystem diagnostics, so a support request can still be answered with one paste.
36
35
 
37
36
  ## Unrestricted mode
38
37
 
@@ -66,11 +65,15 @@ permission, and `remcp doctor` reports the state of each one.
66
65
 
67
66
  ## Updates
68
67
 
69
- The agent asks the server which versions it should run when it connects and every six hours, installs
70
- a newer client and runtime in the background, and restarts the service so they take effect. A failed
71
- install is retried no more often than every thirty minutes, so a broken release cannot turn into an
72
- install loop. `remcp update` does the same immediately; `remcp auto-update off` turns the automatic
73
- check off for a machine that must not change on its own.
68
+ 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 the same computer has been used from more than one Node manager (for example NVM plus Hermes/FNM/Homebrew), ReMCP keeps the canonical service installation stable and converges the known local installations on the same release pair. `remcp status` reports mixed installations instead of hiding the skew. 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.
69
+
70
+ ## Computer-use routing
71
+
72
+ When an AI host controls a visible application, ReMCP is designed for semantic targeting rather than screenshot-coordinate loops:
73
+
74
+ `Accessibility/UI Automation → browser DOM/CDP → OCR → coordinates`
75
+
76
+ Use `computer_snapshot` for an unfamiliar state, native `ui_*` tools for desktop applications, and `browser_*` tools for debuggable Chromium page content. `browser_navigate action=new_tab` can create the first page target when the debug browser has none. `type_text` with its default `method=auto` is for exact Unicode text; `keyboard` is for shortcuts and navigation/control keys. `type_text method=keys` emits physical keys and therefore follows the computer's active keyboard layout. Prefer `wait_for_ui` or `browser_wait` to fixed sleeps and use `screenshot_region` only when pixel/layout verification adds information. For PDF/DOCX/XLSX content, use the structured document tools before automating Office applications.
74
77
 
75
78
  ## Screenshots
76
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
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",
@@ -2,11 +2,23 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { resolveNpm } from './npm.mjs';
6
7
  import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
7
8
  import { PACKAGE_NAME, VERSION } from './version.mjs';
8
9
 
9
10
  const npm = resolveNpm();
11
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const PACKAGE_CLI_ENTRY = path.join(PACKAGE_ROOT, 'bin', 'remcp.mjs');
13
+ const PACKAGE_MANIFEST = path.join(PACKAGE_ROOT, 'package.json');
14
+
15
+ function packageLocalNodeModules() {
16
+ const marker = `${path.sep}node_modules${path.sep}`;
17
+ const index = PACKAGE_ROOT.lastIndexOf(marker);
18
+ if (index < 0) return null;
19
+ return PACKAGE_ROOT.slice(0, index + `${path.sep}node_modules`.length);
20
+ }
21
+
10
22
  // How long a handing-over agent waits for its replacement to take the device over before it keeps
11
23
  // running itself. Long enough for a fresh process to connect and be registered.
12
24
  const REPLACEMENT_HANDOVER_TIMEOUT_MS = 20_000;
@@ -19,6 +31,11 @@ function globalNodeModules() {
19
31
 
20
32
  export function localRuntimeEntry(runtimeValue) {
21
33
  const runtime = normalizeRuntime(runtimeValue);
34
+ const localModules = packageLocalNodeModules();
35
+ if (localModules) {
36
+ const sibling = path.join(localModules, ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
37
+ if (existsSync(sibling)) return sibling;
38
+ }
22
39
  const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
23
40
  if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
24
41
  return candidate;
@@ -31,7 +48,7 @@ function parseVersion(value) {
31
48
  return match ? { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] || '' } : null;
32
49
  }
33
50
 
34
- function isNewer(candidate, current) {
51
+ export function isNewer(candidate, current) {
35
52
  const a = parseVersion(candidate);
36
53
  const b = parseVersion(current);
37
54
  if (!a || !b) return false;
@@ -142,7 +159,11 @@ export async function restartToApplyUpdate(cli, stopAgent, markStopping, onRunti
142
159
  // The version of the globally installed client, read from the package the CLI resolves to.
143
160
  export function globalInstalledVersion() {
144
161
  try {
145
- const cli = globalCliEntry();
162
+ try {
163
+ const local = JSON.parse(readFileSync(PACKAGE_MANIFEST, 'utf8')).version;
164
+ if (local) return local;
165
+ } catch {}
166
+ const cli = globalCliEntry({ packageLocal:false });
146
167
  if (!cli) return null;
147
168
  const base = path.dirname(cli);
148
169
  const candidates = [
@@ -182,8 +203,12 @@ export function supervisorRestart({ platform = process.platform, dockerenv = exi
182
203
  return null;
183
204
  }
184
205
 
185
- export function globalCliEntry() {
186
- const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
206
+ export function globalCliEntry({ packageLocal = true } = {}) {
207
+ // The running agent already knows exactly which @remcp/remcp package owns it. Prefer the sibling
208
+ // CLI entry and avoid asking npm which global prefix happens to be active in a minimal service
209
+ // environment (FNM/NVM/Hermes/Homebrew can all disagree with the shell).
210
+ if (packageLocal && existsSync(PACKAGE_CLI_ENTRY)) return PACKAGE_CLI_ENTRY;
211
+ const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding:'utf8' });
187
212
  if (prefix.error || prefix.status !== 0) return null;
188
213
  const base = String(prefix.stdout || '').trim();
189
214
  return process.platform === 'win32'
package/src/agent.mjs CHANGED
@@ -84,6 +84,7 @@ export async function runAgent(options) {
84
84
  let runtimeVersion = 'unknown';
85
85
  let runtimeRestarts = 0;
86
86
  let runtimeDown = false;
87
+ let runtimeTools = [];
87
88
  // The reason the runtime is not running, sent to the server so the workspace can show something
88
89
  // actionable instead of a machine that merely looks connected.
89
90
  let runtimeError = '';
@@ -119,6 +120,25 @@ export async function runAgent(options) {
119
120
  return env;
120
121
  }
121
122
 
123
+ function toolNames(listed) {
124
+ const tools = Array.isArray(listed?.tools) ? listed.tools : [];
125
+ return [...new Set(tools.map(tool => String(tool?.name || '').trim().slice(0, 128)).filter(Boolean))].slice(0, 256);
126
+ }
127
+
128
+ async function refreshRuntimeTools(client = mcp, { announce = true } = {}) {
129
+ if (!client || runtimeDown) return runtimeTools;
130
+ try {
131
+ const listed = await client.listTools(undefined, { timeout: 3000 });
132
+ const next = toolNames(listed);
133
+ if (next.length === runtimeTools.length && next.every((name, index) => name === runtimeTools[index])) return runtimeTools;
134
+ runtimeTools = next;
135
+ if (announce) send({ type: 'capabilities', runtimeTools });
136
+ } catch (error) {
137
+ console.error(`ReMCP could not refresh runtime capabilities: ${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ return runtimeTools;
140
+ }
141
+
122
142
  async function startRuntime() {
123
143
  if (stopping) return;
124
144
  clearTimeout(runtimeRestartTimer);
@@ -139,6 +159,10 @@ export async function runAgent(options) {
139
159
  mcp = client;
140
160
  transport = stdio;
141
161
  client.fallbackNotificationHandler = async notification => {
162
+ if (notification?.method === 'notifications/tools/list_changed') {
163
+ await refreshRuntimeTools(client);
164
+ return;
165
+ }
142
166
  if (!telemetryEnabled) return;
143
167
  if (notification?.method !== 'notifications/remcp/telemetry') return;
144
168
  const events = Array.isArray(notification.params?.events) ? notification.params.events : [];
@@ -159,6 +183,7 @@ export async function runAgent(options) {
159
183
  runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
160
184
  runtimeDown = false;
161
185
  runtimeError = '';
186
+ await refreshRuntimeTools(client);
162
187
  console.log(`ReMCP local runtime ready (${runtimeVersion})`);
163
188
  send({ type: 'metrics', runtimeState: 'ready', runtimeError: '', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
164
189
  if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
@@ -173,6 +198,8 @@ export async function runAgent(options) {
173
198
  function handleRuntimeExit(reason) {
174
199
  if (stopping || runtimeRestartTimer) return;
175
200
  runtimeDown = true;
201
+ runtimeTools = [];
202
+ send({ type: 'capabilities', runtimeTools });
176
203
  const delay = jitter(runtimeRestartDelay);
177
204
  runtimeRestartDelay = Math.min(RUNTIME_RESTART_MAX_MS, runtimeRestartDelay * 2);
178
205
  console.error(`ReMCP local runtime ${reason}; restarting in ${delay}ms`);
@@ -315,6 +342,7 @@ export async function runAgent(options) {
315
342
  runtimeVersion,
316
343
  runtimeState: runtimeDown ? 'down' : 'ready',
317
344
  runtimeError,
345
+ runtimeTools,
318
346
  telemetryEnabled,
319
347
  reconnects,
320
348
  }));
@@ -380,6 +408,8 @@ export async function runAgent(options) {
380
408
  let updateInFlight = false;
381
409
  let lastAttemptedVersion = '';
382
410
  let lastAttemptAt = 0;
411
+ let lastUpdateCheckError = '';
412
+ let lastUpdateCheckErrorAt = 0;
383
413
  async function checkForUpdate() {
384
414
  if (options.autoUpdate === false) return;
385
415
  if (updateInFlight) return;
@@ -460,8 +490,17 @@ export async function runAgent(options) {
460
490
  console.error(`remcp update could not start: ${error.message}`);
461
491
  });
462
492
  child.unref();
463
- } catch {
464
- // Offline, DNS failure, older server without the endpoint: keep running as-is.
493
+ } catch (error) {
494
+ // Update discovery must never take the agent offline, but swallowing the exception made FNM/
495
+ // launchd failures impossible to diagnose. Log only when the reason changes or every 30 min.
496
+ const message = error instanceof Error ? error.message : String(error);
497
+ const now = Date.now();
498
+ if (message !== lastUpdateCheckError || now - lastUpdateCheckErrorAt >= UPDATE_RETRY_COOLDOWN_MS) {
499
+ console.error(`ReMCP auto-update check failed: ${message}`);
500
+ lastUpdateCheckError = message;
501
+ lastUpdateCheckErrorAt = now;
502
+ }
503
+ queueEvent({ event:'agent_update', at:now, reason:'check-failed', success:false, errorClass:error?.name || 'Error' });
465
504
  }
466
505
  }
467
506
 
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { spawnSync } from 'node:child_process';
8
+ import { resolveNpm } from '../npm.mjs';
8
9
  import { PACKAGE_NAME, VERSION } from '../version.mjs';
9
10
 
10
11
  import { saveConfig } from './config.mjs';
@@ -28,6 +29,11 @@ export function npmGlobalInstall(...specs) {
28
29
  run(npm.command, [...npm.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
29
30
  }
30
31
 
32
+ export function npmGlobalInstallForNode(nodePath, ...specs) {
33
+ const resolved = resolveNpm({ nodePath, home, platform:servicePlatform() });
34
+ run(resolved.command, [...resolved.args, 'install', '--global', ...specs, '--no-audit', '--no-fund', '--loglevel=error']);
35
+ }
36
+
31
37
  export function quoteSystemd(value) {
32
38
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
33
39
  }
@@ -36,9 +42,9 @@ function resolvedCliScript(cliPath) {
36
42
  return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
37
43
  }
38
44
 
39
- function writeLinuxServiceLauncher(cliPath = globalCliPath()) {
45
+ function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
40
46
  const cliScript = resolvedCliScript(cliPath);
41
- const launcher = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(cliScript)} \"$@\"\n`;
47
+ const launcher = `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(cliScript)} \"$@\"\n`;
42
48
  fs.mkdirSync(path.dirname(linuxServiceLauncherFile), { recursive: true, mode: 0o700 });
43
49
  const temporary = `${linuxServiceLauncherFile}.${process.pid}.${Date.now()}.tmp`;
44
50
  fs.writeFileSync(temporary, launcher, { mode: 0o700 });
@@ -51,9 +57,9 @@ export function xmlEscape(value) {
51
57
  return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
52
58
  }
53
59
 
54
- export function installLinuxService(cliPath = globalCliPath()) {
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`;
60
+ export function installLinuxService(cliPath = globalCliPath(), nodePath = process.execPath) {
61
+ const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
62
+ const unit = `[Unit]\nDescription=ReMCP device agent\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=${quoteSystemd(launcherFile)} start --service\nRestart=always\nRestartSec=3\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
57
63
  fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
58
64
  fs.writeFileSync(linuxServiceFile, unit);
59
65
  run('systemctl', ['--user', 'daemon-reload']);
@@ -69,12 +75,12 @@ function shellQuote(value) {
69
75
  return `'${String(value).replaceAll("'", "'\\''")}'`;
70
76
  }
71
77
 
72
- function macServicePlist(cliPath) {
78
+ function macServicePlist(cliPath, nodePath = process.execPath) {
73
79
  // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
74
80
  // that is about to be replaced by the next install) must not abort the repair — a stale plist is
75
81
  // exactly the loop this function exists to break.
76
82
  const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
77
- return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${macServiceLabel}</string>\n<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string></array>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>${xmlEscape(macLogFile)}</string>\n<key>StandardErrorPath</key><string>${xmlEscape(macLogFile)}</string>\n</dict></plist>\n`;
83
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${macServiceLabel}</string>\n<key>ProgramArguments</key><array><string>${xmlEscape(nodePath)}</string><string>${xmlEscape(cliScript)}</string><string>start</string><string>--service</string></array>\n<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>${xmlEscape(macLogFile)}</string>\n<key>StandardErrorPath</key><string>${xmlEscape(macLogFile)}</string>\n</dict></plist>\n`;
78
84
  }
79
85
 
80
86
  function macJobLoaded(target) {
@@ -118,10 +124,10 @@ function scheduleMacServiceRestart(target) {
118
124
  ]);
119
125
  }
120
126
 
121
- export function installMacService(cliPath = globalCliPath(), { restart = true } = {}) {
127
+ export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
122
128
  const domain = macLaunchDomain();
123
129
  const target = `${domain}/${macServiceLabel}`;
124
- const plist = macServicePlist(cliPath);
130
+ const plist = macServicePlist(cliPath, nodePath);
125
131
  let previous = '';
126
132
  try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
127
133
  const loaded = macJobLoaded(target);
@@ -152,7 +158,7 @@ export function installMacService(cliPath = globalCliPath(), { restart = true }
152
158
  }
153
159
 
154
160
  export function installWindowsService(cliPath = globalCliPath()) {
155
- const command = `"${cliPath}" start`;
161
+ const command = `"${cliPath}" start --service`;
156
162
  run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']);
157
163
  run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
158
164
  }
@@ -173,6 +179,175 @@ export function persistentServiceExpected(config) {
173
179
  return false;
174
180
  }
175
181
 
182
+ export function persistentServiceState(config) {
183
+ const platform = servicePlatform();
184
+ const expected = persistentServiceExpected(config);
185
+ if (platform === 'linux') {
186
+ const installed = fs.existsSync(linuxServiceFile);
187
+ const active = installed && spawnSync('systemctl', ['--user', 'is-active', '--quiet', 'remcp-agent.service'], { stdio: 'ignore' }).status === 0;
188
+ return { expected, installed, active, manager:'systemd-user', name:'remcp-agent.service' };
189
+ }
190
+ if (platform === 'darwin') {
191
+ const installed = fs.existsSync(macServiceFile);
192
+ const target = typeof process.getuid === 'function' ? 'gui/' + process.getuid() + '/' + macServiceLabel : macServiceLabel;
193
+ const active = installed && spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
194
+ return { expected, installed, active, manager:'launchd', name:macServiceLabel };
195
+ }
196
+ if (platform === 'win32') {
197
+ const query = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName, '/FO', 'LIST'], { encoding:'utf8' });
198
+ const installed = query.status === 0;
199
+ const outputText = String(query.stdout || '');
200
+ const active = installed && /(?:Status|Состояние):\s*Running/i.test(outputText);
201
+ return { expected, installed, active, manager:'schtasks', name:windowsTaskName };
202
+ }
203
+ return { expected:false, installed:false, active:false, manager:null, name:null };
204
+ }
205
+
206
+ function inferServiceIdentity() {
207
+ const platform = servicePlatform();
208
+ if (platform === 'linux' && fs.existsSync(linuxServiceLauncherFile)) {
209
+ try {
210
+ const body = fs.readFileSync(linuxServiceLauncherFile, 'utf8');
211
+ const match = body.match(/^exec\s+'([^']+)'\s+'([^']+)'/m);
212
+ if (match) return { nodePath:match[1], cliPath:match[2] };
213
+ } catch {}
214
+ }
215
+ if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
216
+ try {
217
+ const body = fs.readFileSync(macServiceFile, 'utf8');
218
+ const args = [...body.matchAll(/<string>([^<]+)<\/string>/g)].map(match => match[1]);
219
+ const startIndex = args.indexOf('start');
220
+ if (startIndex >= 2) return { nodePath:args[startIndex - 2], cliPath:args[startIndex - 1] };
221
+ } catch {}
222
+ }
223
+ return {};
224
+ }
225
+
226
+ function canonicalServiceCliPath(config) {
227
+ const configured = String(config?.serviceCliPath || '').trim();
228
+ if (configured && fs.existsSync(configured)) return configured;
229
+ const inferred = inferServiceIdentity().cliPath;
230
+ if (inferred && fs.existsSync(inferred)) return inferred;
231
+ return globalCliPath();
232
+ }
233
+
234
+ function canonicalServiceNodePath(config) {
235
+ const configured = String(config?.serviceNodePath || '').trim();
236
+ if (configured && fs.existsSync(configured)) return configured;
237
+ const inferred = inferServiceIdentity().nodePath;
238
+ if (inferred && fs.existsSync(inferred)) return inferred;
239
+ return process.execPath;
240
+ }
241
+
242
+ function versionFromPackageJson(file) {
243
+ try {
244
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
245
+ return typeof parsed.version === 'string' ? parsed.version : null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function packageVersionBesideCli(cliPath, packageName) {
252
+ if (!cliPath || !packageName) return null;
253
+ const marker = path.sep + 'node_modules' + path.sep;
254
+ const index = cliPath.lastIndexOf(marker);
255
+ if (index < 0) return null;
256
+ const nodeModules = cliPath.slice(0, index + (path.sep + 'node_modules').length);
257
+ return versionFromPackageJson(path.join(nodeModules, ...String(packageName).split('/'), 'package.json'));
258
+ }
259
+
260
+ export function installationVersionsAtCliPath(cliPath, runtimePackageName) {
261
+ if (!cliPath) return { cliVersion:null, runtimeVersion:null, cliPath:null };
262
+ let resolvedCliPath = cliPath;
263
+ try { if (fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
264
+ return {
265
+ cliVersion:packageVersionBesideCli(resolvedCliPath, PACKAGE_NAME),
266
+ runtimeVersion:packageVersionBesideCli(resolvedCliPath, runtimePackageName),
267
+ cliPath:resolvedCliPath,
268
+ };
269
+ }
270
+
271
+ export function serviceInstallationInfo(config) {
272
+ if (!persistentServiceExpected(config)) return { cliVersion:null, runtimeVersion:null, cliPath:null, nodePath:null };
273
+ const cliPath = canonicalServiceCliPath(config);
274
+ const nodePath = canonicalServiceNodePath(config);
275
+ let resolvedCliPath = cliPath;
276
+ try { if (cliPath && fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
277
+ return {
278
+ cliVersion:packageVersionBesideCli(resolvedCliPath, PACKAGE_NAME),
279
+ runtimeVersion:packageVersionBesideCli(resolvedCliPath, config?.runtime?.packageName),
280
+ cliPath:resolvedCliPath || cliPath,
281
+ nodePath,
282
+ };
283
+ }
284
+
285
+ export function currentInstallationInfo(config) {
286
+ let cliPath = process.argv[1] || '';
287
+ try { if (cliPath && fs.existsSync(cliPath)) cliPath = fs.realpathSync(cliPath); } catch {}
288
+ return {
289
+ cliVersion:VERSION,
290
+ runtimeVersion:config?.runtime?.packageName ? packageVersionBesideCli(cliPath, config.runtime.packageName) : null,
291
+ cliPath:cliPath || null,
292
+ nodePath:process.execPath,
293
+ };
294
+ }
295
+
296
+ export function rememberCurrentInstallation(config, { service = false } = {}) {
297
+ if (!config || typeof config !== 'object') return config;
298
+ const info = currentInstallationInfo(config);
299
+ if (!info.cliPath || !info.nodePath) return config;
300
+ const key = info.nodePath + '\u0000' + info.cliPath;
301
+ const existing = Array.isArray(config.installations) ? config.installations.filter(item => item && typeof item === 'object') : [];
302
+ const filtered = existing.filter(item => ((item.nodePath || '') + '\u0000' + (item.cliPath || '')) !== key);
303
+ const record = {
304
+ nodePath:info.nodePath,
305
+ cliPath:info.cliPath,
306
+ cliVersion:info.cliVersion,
307
+ runtimeVersion:info.runtimeVersion,
308
+ service:Boolean(service),
309
+ lastSeenAt:new Date().toISOString(),
310
+ };
311
+ const installations = [...filtered, record].slice(-8);
312
+ const next = {
313
+ ...config,
314
+ installations,
315
+ ...(service ? { serviceInstalled:true, serviceCliPath:info.cliPath, serviceNodePath:info.nodePath } : {}),
316
+ };
317
+ saveConfig(next);
318
+ return next;
319
+ }
320
+
321
+ export function syncKnownInstallations(config, ...specs) {
322
+ const serviceInfo = serviceInstallationInfo(config);
323
+ const current = currentInstallationInfo(config);
324
+ const candidates = [
325
+ ...(Array.isArray(config?.installations) ? config.installations : []),
326
+ ...(serviceInfo.nodePath ? [{ nodePath:serviceInfo.nodePath, cliPath:serviceInfo.cliPath, service:true }] : []),
327
+ ];
328
+ const currentNode = (() => { try { return fs.realpathSync(current.nodePath); } catch { return current.nodePath; } })();
329
+ const seen = new Set();
330
+ const results = [];
331
+ for (const candidate of candidates) {
332
+ const nodePath = String(candidate?.nodePath || '').trim();
333
+ if (!nodePath || !fs.existsSync(nodePath)) continue;
334
+ let resolvedNode = nodePath;
335
+ try { resolvedNode = fs.realpathSync(nodePath); } catch {}
336
+ if (resolvedNode === currentNode || seen.has(resolvedNode)) continue;
337
+ seen.add(resolvedNode);
338
+ const required = Boolean(candidate?.service) || resolvedNode === serviceInfo.nodePath;
339
+ try {
340
+ npmGlobalInstallForNode(resolvedNode, ...specs);
341
+ results.push({ nodePath:resolvedNode, ok:true, required });
342
+ } catch (error) {
343
+ const message = error instanceof Error ? error.message : String(error);
344
+ results.push({ nodePath:resolvedNode, ok:false, required, error:message });
345
+ if (required) throw new Error('Could not update canonical ReMCP service installation at ' + resolvedNode + ': ' + message);
346
+ }
347
+ }
348
+ return results;
349
+ }
350
+
176
351
  export function configurePostInstallAccess() {
177
352
  const platform = servicePlatform();
178
353
  try {
@@ -225,11 +400,13 @@ export function installPersistentAgent(config) {
225
400
  console.log(`Installing ReMCP ${VERSION}…`);
226
401
  npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
227
402
  const cliPath = globalCliPath();
228
- if (platform === 'linux') installLinuxService(cliPath);
229
- else if (platform === 'darwin') installMacService(cliPath);
403
+ const cliScript = resolvedCliScript(cliPath);
404
+ const nodePath = process.execPath;
405
+ if (platform === 'linux') installLinuxService(cliPath, nodePath);
406
+ else if (platform === 'darwin') installMacService(cliPath, { nodePath });
230
407
  else installWindowsService(cliPath);
231
408
  configurePostInstallAccess();
232
- saveConfig({ ...config, serviceInstalled: true });
409
+ saveConfig({ ...config, serviceInstalled: true, serviceCliPath: cliScript, serviceNodePath: nodePath });
233
410
  console.log('ReMCP is installed as a background service. Future updates: remcp update');
234
411
  }
235
412
 
@@ -239,23 +416,24 @@ export function installPersistentAgent(config) {
239
416
  export function ensureServiceIfRecorded(config) {
240
417
  if (!persistentServiceExpected(config)) return false;
241
418
  try {
242
- const cliPath = globalCliPath();
419
+ const cliPath = canonicalServiceCliPath(config);
420
+ const nodePath = canonicalServiceNodePath(config);
243
421
  const platform = servicePlatform();
244
422
  if (platform === 'linux') {
245
423
  if (!fs.existsSync(linuxServiceFile)) {
246
- installLinuxService(cliPath);
424
+ installLinuxService(cliPath, nodePath);
247
425
  } else {
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);
426
+ // Keep the supervisor bound to the installation that owns the service. A manual update from
427
+ // another nvm/Hermes/Homebrew prefix must not silently steal service ownership.
428
+ const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
251
429
  const unit = fs.readFileSync(linuxServiceFile, 'utf8');
252
- const expected = `ExecStart=${quoteSystemd(launcherFile)} start`;
430
+ const expected = `ExecStart=${quoteSystemd(launcherFile)} start --service`;
253
431
  if (!unit.includes(expected)) {
254
432
  const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
255
433
  if (!repaired) {
256
434
  // A hand-edited unit with no launcher line is replaced wholesale; the stable launcher is
257
435
  // still refreshed first so the replacement never points back at a retired Node manager.
258
- installLinuxService(cliPath);
436
+ installLinuxService(cliPath, nodePath);
259
437
  } else {
260
438
  fs.writeFileSync(linuxServiceFile, repaired);
261
439
  run('systemctl', ['--user', 'daemon-reload']);
@@ -267,11 +445,14 @@ export function ensureServiceIfRecorded(config) {
267
445
  // boot out a loaded agent inline: this updater may itself be a descendant of that LaunchAgent.
268
446
  // installMacService either leaves an unchanged loaded job alone, bootstraps an unloaded job, or
269
447
  // hands a changed launcher to an independent transient launchd helper.
270
- installMacService(cliPath, { restart: false });
448
+ installMacService(cliPath, { restart: false, nodePath });
271
449
  } else if (platform === 'win32') installWindowsService(cliPath);
272
450
  // Upgrade the legacy inferred state only after the supervisor repair succeeded. A failed repair
273
451
  // must not turn a stale artifact into a permanent "managed service" declaration.
274
- if (config?.serviceInstalled !== true) saveConfig({ ...config, serviceInstalled: true });
452
+ const cliScript = resolvedCliScript(cliPath);
453
+ if (config?.serviceInstalled !== true || config?.serviceCliPath !== cliScript || config?.serviceNodePath !== nodePath) {
454
+ saveConfig({ ...config, serviceInstalled: true, serviceCliPath: cliScript, serviceNodePath: nodePath });
455
+ }
275
456
  return true;
276
457
  } catch (error) {
277
458
  console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
@@ -296,7 +477,7 @@ export function restartPersistentServiceIfInstalled(config) {
296
477
  } else {
297
478
  // A plist can survive while launchd has no loaded job (older installs, logout/login cleanup,
298
479
  // manual bootout, or a failed previous update). Re-register it directly.
299
- installMacService(globalCliPath());
480
+ installMacService(canonicalServiceCliPath(config), { nodePath:canonicalServiceNodePath(config) });
300
481
  }
301
482
  return macServiceLabel;
302
483
  }
@@ -11,7 +11,7 @@ import { PACKAGE_NAME, VERSION } from '../version.mjs';
11
11
  import { loadConfig, saveConfig } from './config.mjs';
12
12
  import { installedVersion } from './doctor.mjs';
13
13
  import { linuxServiceFile, macServiceFile, officialOrigin } from './env.mjs';
14
- import { ensureServiceIfRecorded, npmGlobalInstall, restartPersistentServiceIfInstalled } from './service.mjs';
14
+ import { currentInstallationInfo, ensureServiceIfRecorded, installationVersionsAtCliPath, npmGlobalInstall, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
15
15
 
16
16
  const UPDATE_DISCOVERY_TIMEOUT_MS = 5000;
17
17
 
@@ -168,19 +168,59 @@ export async function resolveUpdateTargets({
168
168
  }
169
169
  }
170
170
 
171
+ export function updateAlreadyCurrent({ cfg, targets, currentInstall, serviceInstall, serviceExpected }) {
172
+ const targetClientVersion = specVersion(PACKAGE_NAME, targets?.clientSpec);
173
+ const targetRuntimeVersion = specVersion(cfg?.runtime?.packageName, targets?.runtimeSpec);
174
+ if (!targetClientVersion || !targetRuntimeVersion) return false;
175
+
176
+ const currentClientVersion = String(currentInstall?.cliVersion || '').trim();
177
+ const currentRuntimeVersion = String(currentInstall?.runtimeVersion || '').trim();
178
+ if (currentClientVersion !== targetClientVersion || currentRuntimeVersion !== targetRuntimeVersion) return false;
179
+
180
+ if (serviceExpected) {
181
+ if (String(serviceInstall?.cliVersion || '').trim() !== targetClientVersion) return false;
182
+ if (String(serviceInstall?.runtimeVersion || '').trim() !== targetRuntimeVersion) return false;
183
+ }
184
+
185
+ // A machine can have more than one Node manager. Do not claim "already current" while a known,
186
+ // still-existing ReMCP installation is recorded on an older release; the normal update path will
187
+ // converge it through syncKnownInstallations().
188
+ for (const item of Array.isArray(cfg?.installations) ? cfg.installations : []) {
189
+ const cliPath = String(item?.cliPath || '').trim();
190
+ if (!cliPath || !fs.existsSync(cliPath)) continue;
191
+ const actual = installationVersionsAtCliPath(cliPath, cfg.runtime.packageName);
192
+ if (String(actual.cliVersion || '').trim() !== targetClientVersion) return false;
193
+ if (String(actual.runtimeVersion || '').trim() !== targetRuntimeVersion) return false;
194
+ }
195
+ return true;
196
+ }
197
+
171
198
  export async function updateCommand(flags) {
172
- const cfg = loadConfig();
199
+ let cfg = rememberCurrentInstallation(loadConfig());
173
200
  const targets = await resolveUpdateTargets({ cfg, flags });
174
201
  if (targets.warning) console.error(`ReMCP update: ${targets.warning}`);
175
202
 
203
+ const currentInstall = currentInstallationInfo(cfg);
204
+ if (!currentInstall.runtimeVersion) currentInstall.runtimeVersion = installedVersion(cfg.runtime.packageName);
205
+ const serviceExpected = persistentServiceExpected(cfg);
206
+ const serviceInstall = serviceInstallationInfo(cfg);
207
+ const alreadyCurrent = targets.installable && updateAlreadyCurrent({
208
+ cfg,
209
+ targets,
210
+ currentInstall,
211
+ serviceInstall,
212
+ serviceExpected,
213
+ });
214
+
176
215
  if (flags.check) {
177
216
  console.log(JSON.stringify({
178
217
  current: VERSION,
179
- installedRuntime: installedVersion(cfg.runtime.packageName),
218
+ installedRuntime: currentInstall.runtimeVersion,
180
219
  clientSpec: targets.clientSpec,
181
220
  runtimeSpec: targets.runtimeSpec,
182
221
  updateSource: targets.source,
183
222
  installable: targets.installable,
223
+ upToDate: alreadyCurrent,
184
224
  managedService: fs.existsSync(linuxServiceFile) || fs.existsSync(macServiceFile),
185
225
  supervisor: supervisorRestart() ?? 'none',
186
226
  }, null, 2));
@@ -191,14 +231,35 @@ export async function updateCommand(flags) {
191
231
  throw new Error(targets.warning || 'Could not resolve a complete client/runtime release pair.');
192
232
  }
193
233
 
234
+ if (alreadyCurrent) {
235
+ if (targets.persistRuntime) {
236
+ saveConfig({ ...cfg, runtime:{ ...cfg.runtime, packageSpec:targets.runtimeSpec } });
237
+ }
238
+ const currentRuntimeVersion = String(currentInstall.runtimeVersion || specVersion(cfg.runtime.packageName, targets.runtimeSpec) || '?');
239
+ console.log(`ReMCP is already up to date (client ${currentInstall.cliVersion || VERSION}, runtime ${currentRuntimeVersion}).`);
240
+ return;
241
+ }
242
+
194
243
  const before = { cli: VERSION, runtime: installedVersion(cfg.runtime.packageName) };
195
244
  console.log(`Updating ReMCP to ${targets.clientSpec} with ${targets.runtimeSpec}…`);
196
245
  npmGlobalInstall(targets.clientSpec, targets.runtimeSpec);
197
- // The npm prefix can change across Node-manager upgrades. Repair an existing persistent-service
198
- // launcher only after the install, when globalCliPath() points at the CLI we just installed.
246
+
247
+ // Converge every ReMCP installation this account has actually used on this machine. The canonical
248
+ // service installation is mandatory; secondary nvm/Hermes/Homebrew copies are best-effort and
249
+ // remain visible in status if they cannot be updated.
250
+ const synced = syncKnownInstallations(cfg, targets.clientSpec, targets.runtimeSpec);
251
+ for (const item of synced) {
252
+ if (!item.ok) console.error(`ReMCP update: could not sync secondary installation at ${item.nodePath}: ${item.error}`);
253
+ }
254
+
255
+ // Persist the validated runtime pair before repairing the supervisor so a repair cannot be
256
+ // overwritten by an older in-memory config object.
257
+ if (targets.persistRuntime) {
258
+ saveConfig({ ...cfg, runtime:{ ...cfg.runtime, packageSpec:targets.runtimeSpec } });
259
+ cfg = loadConfig();
260
+ }
199
261
  ensureServiceIfRecorded(cfg);
200
- // Only a validated spec is persisted, so a failed update cannot leave the install unable to start.
201
- if (targets.persistRuntime) saveConfig({ ...cfg, runtime: { ...cfg.runtime, packageSpec: targets.runtimeSpec } });
262
+ cfg = loadConfig();
202
263
  const after = { cli: installedVersion(PACKAGE_NAME), runtime: installedVersion(cfg.runtime.packageName) };
203
264
  const restarted = restartPersistentServiceIfInstalled(cfg);
204
265
  if (restarted) {
package/src/cli.mjs CHANGED
@@ -2,15 +2,16 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { runAgent } from './agent.mjs';
5
+ import { isNewer } from './agent-update.mjs';
5
6
  import { normalizeRuntime } from './runtime.mjs';
6
7
  import { PACKAGE_NAME, VERSION } from './version.mjs';
7
8
  import { probeFilesystemAccess } from './fs-access.mjs';
8
9
 
9
10
  import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
10
11
  import { assertRuntimeTrust, pairWithDeviceCode } from './cli/connect.mjs';
11
- import { diagnoseLocalRuntime, runtimeAllowedRoots } from './cli/doctor.mjs';
12
+ import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
12
13
  import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
13
- import { installPersistentAgent, restartPersistentServiceIfInstalled, uninstallPersistentService } from './cli/service.mjs';
14
+ import { currentInstallationInfo, installPersistentAgent, persistentServiceState, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, uninstallPersistentService } from './cli/service.mjs';
14
15
  import { run } from './cli/shell.mjs';
15
16
  import { updateCommand } from './cli/update.mjs';
16
17
  function parse(argv) {
@@ -92,7 +93,15 @@ export async function main(argv = process.argv.slice(2)) {
92
93
  }
93
94
 
94
95
  if (command === 'start') {
95
- const cfg = loadConfig();
96
+ const supervised = flags.service === true
97
+ || Boolean(process.env.INVOCATION_ID)
98
+ || String(process.env.XPC_SERVICE_NAME || '').includes('com.remcp.agent');
99
+ const cfg = rememberCurrentInstallation(loadConfig(), { service:supervised });
100
+ const service = persistentServiceState(cfg);
101
+ if (!supervised && service.active) {
102
+ console.log(`ReMCP is already running as a background service (${service.name}). Use \`remcp status\` to inspect it or \`remcp doctor\` to diagnose it.`);
103
+ return;
104
+ }
96
105
  const telemetry = telemetryState();
97
106
  await runAgent({
98
107
  ...cfg,
@@ -173,29 +182,97 @@ export async function main(argv = process.argv.slice(2)) {
173
182
  }
174
183
 
175
184
  if (command === 'status' || command === 'doctor') {
176
- const cfg = loadConfig();
177
- const health = await fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache: 'no-store' }).then(r => r.json()).catch(error => ({ error: error.message }));
185
+ const cfg = rememberCurrentInstallation(loadConfig());
186
+ const [health, advertised] = await Promise.all([
187
+ fetch(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache:'no-store' })
188
+ .then(async response => ({ reachable:response.ok && (await response.json().catch(() => null))?.ok === true }))
189
+ .catch(error => ({ reachable:false, error:error.message })),
190
+ fetch(`${cfg.serverUrl}/api/agent/version?fresh=${Date.now()}`, { cache:'no-store' })
191
+ .then(response => response.ok ? response.json() : null)
192
+ .catch(() => null),
193
+ ]);
194
+ const telemetry = telemetryState();
195
+ const service = persistentServiceState(cfg);
196
+ const currentInstall = currentInstallationInfo(cfg);
197
+ const serviceInstall = serviceInstallationInfo(cfg);
198
+ const installedRuntime = installedVersion(cfg.runtime.packageName);
199
+ const targetClient = String(advertised?.cliVersion || '').trim() || null;
200
+ const targetRuntime = String(advertised?.runtime || '').trim() || null;
201
+ const configuredRuntime = String(cfg.runtime.packageSpec || '');
202
+ const installationPaths = new Set([
203
+ currentInstall.cliPath,
204
+ serviceInstall.cliPath,
205
+ ...(Array.isArray(cfg.installations) ? cfg.installations.map(item => item?.cliPath) : []),
206
+ ].filter(Boolean));
207
+ const multipleInstallations = installationPaths.size > 1;
208
+ const knownCliVersions = [VERSION, serviceInstall.cliVersion].filter(Boolean);
209
+ const knownRuntimeVersions = [installedRuntime, serviceInstall.runtimeVersion].filter(Boolean);
210
+ const versionConsistent = cfg.runtime.packageName === '@remcp/runtime'
211
+ ? new Set([...knownCliVersions, ...knownRuntimeVersions]).size <= 1
212
+ : new Set(knownCliVersions).size <= 1 && new Set(knownRuntimeVersions).size <= 1;
213
+ const clientMeetsTarget = Boolean(targetClient && !isNewer(targetClient, VERSION));
214
+ const configuredRuntimeMeetsTarget = Boolean(!targetRuntime || (configuredRuntime && !isNewer(targetRuntime, configuredRuntime)));
215
+ const runtimeMeetsTarget = Boolean(!targetRuntime || (installedRuntime && !isNewer(targetRuntime, installedRuntime)));
216
+ const serviceClientMeetsTarget = !service.installed
217
+ || Boolean(targetClient && serviceInstall.cliVersion && !isNewer(targetClient, serviceInstall.cliVersion));
218
+ const serviceRuntimeMeetsTarget = !service.installed
219
+ || Boolean(!targetRuntime || (serviceInstall.runtimeVersion && !isNewer(targetRuntime, serviceInstall.runtimeVersion)));
220
+ const upToDate = Boolean(
221
+ targetClient
222
+ && clientMeetsTarget
223
+ && configuredRuntimeMeetsTarget
224
+ && runtimeMeetsTarget
225
+ && serviceClientMeetsTarget
226
+ && serviceRuntimeMeetsTarget
227
+ );
228
+ const updateState = !targetClient
229
+ ? 'unknown'
230
+ : !upToDate
231
+ ? 'update_available'
232
+ : versionConsistent
233
+ ? (isNewer(VERSION, targetClient) ? 'ahead' : 'current')
234
+ : 'mixed_current';
178
235
  const report = {
179
- configured: true,
180
- cliVersion: VERSION,
181
- deviceId: cfg.deviceId,
182
- deviceName: cfg.deviceName,
183
- server: cfg.serverUrl,
184
- runtime: cfg.runtime,
185
- telemetry: telemetryState(),
186
- serverHealth: health,
236
+ configured:true,
237
+ device:{ id:cfg.deviceId, name:cfg.deviceName },
238
+ client:{ version:VERSION },
239
+ runtime:{
240
+ packageName:cfg.runtime.packageName,
241
+ configured:configuredRuntime,
242
+ installed:installedRuntime,
243
+ },
244
+ agent:{
245
+ mode:service.installed ? 'service' : 'manual',
246
+ running:service.active,
247
+ manager:service.manager,
248
+ serviceCliVersion:serviceInstall.cliVersion,
249
+ serviceRuntimeVersion:serviceInstall.runtimeVersion,
250
+ },
251
+ server:{
252
+ url:cfg.serverUrl,
253
+ reachable:health.reachable === true,
254
+ ...(health.error ? { error:health.error } : {}),
255
+ },
256
+ update:{
257
+ auto:cfg.autoUpdate !== false,
258
+ targetClient,
259
+ targetRuntime,
260
+ multipleInstallations,
261
+ versionConsistent,
262
+ state:updateState,
263
+ upToDate,
264
+ },
265
+ telemetry:{ enabled:telemetry.enabled },
187
266
  };
188
- // `doctor` answers the question the workspace cannot: is this machine actually able to run a
189
- // tool? It resolves the runtime entry, installs nothing, and tries one real MCP handshake with
190
- // the runtime, so the failure is visible here instead of only as "runtime not running".
191
267
  if (command === 'doctor') {
192
268
  report.diagnosis = await diagnoseLocalRuntime(cfg);
193
- // A computer can pass every other check and still be unable to write where the tools work:
194
- // macOS answers EACCES for Desktop until TCC is granted, Windows has Controlled folder access,
195
- // Linux answers EACCES for a folder this user does not own. The probe writes and removes a
196
- // temporary file in each allowed root, so the doctor reports what actually happens rather than
197
- // what the permission bits claim, and names the fix for this platform and this binary.
198
- report.diagnosis.filesystem = await probeFilesystemAccess({ roots: runtimeAllowedRoots() });
269
+ report.diagnosis.filesystem = await probeFilesystemAccess({ roots:runtimeAllowedRoots() });
270
+ report.diagnostic = {
271
+ serviceExpected:service.expected,
272
+ serviceInstalled:service.installed,
273
+ runtimeEntry:cfg.runtime.entry,
274
+ npmManaged:true,
275
+ };
199
276
  }
200
277
  console.log(JSON.stringify(report, null, 2));
201
278
  if (command === 'doctor' && report.diagnosis.verdict !== 'ok') process.exitCode = 1;