@remcp/remcp 0.2.46 → 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.46",
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`);
@@ -286,6 +313,15 @@ export async function runAgent(options) {
286
313
  // Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
287
314
  // the relay, but cap recovery so an enable action becomes effective within seconds.
288
315
  scheduleReconnect(jitter(RECONNECT_BASE_MS));
316
+ return;
317
+ }
318
+ const status = Number(response.statusCode || 0);
319
+ const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
320
+ if (transientHandshakeFailure && !revoked) {
321
+ // A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
322
+ // With an unexpected-response listener the client does not reliably emit close afterwards,
323
+ // so this branch must schedule its own retry or a still-running agent can stay offline forever.
324
+ scheduleBackoffReconnect();
289
325
  }
290
326
  });
291
327
  activeSocket = ws;
@@ -306,6 +342,7 @@ export async function runAgent(options) {
306
342
  runtimeVersion,
307
343
  runtimeState: runtimeDown ? 'down' : 'ready',
308
344
  runtimeError,
345
+ runtimeTools,
309
346
  telemetryEnabled,
310
347
  reconnects,
311
348
  }));
@@ -371,6 +408,8 @@ export async function runAgent(options) {
371
408
  let updateInFlight = false;
372
409
  let lastAttemptedVersion = '';
373
410
  let lastAttemptAt = 0;
411
+ let lastUpdateCheckError = '';
412
+ let lastUpdateCheckErrorAt = 0;
374
413
  async function checkForUpdate() {
375
414
  if (options.autoUpdate === false) return;
376
415
  if (updateInFlight) return;
@@ -451,8 +490,17 @@ export async function runAgent(options) {
451
490
  console.error(`remcp update could not start: ${error.message}`);
452
491
  });
453
492
  child.unref();
454
- } catch {
455
- // 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' });
456
504
  }
457
505
  }
458
506
 
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');
@@ -5,10 +5,11 @@ 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';
11
- import { configDir, home, linuxServiceFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
12
+ import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsTaskName } from './env.mjs';
12
13
  import { output, run } from './shell.mjs';
13
14
 
14
15
  export function servicePlatform() {
@@ -28,16 +29,37 @@ 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
  }
34
40
 
41
+ function resolvedCliScript(cliPath) {
42
+ return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
43
+ }
44
+
45
+ function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
46
+ const cliScript = resolvedCliScript(cliPath);
47
+ const launcher = `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(cliScript)} \"$@\"\n`;
48
+ fs.mkdirSync(path.dirname(linuxServiceLauncherFile), { recursive: true, mode: 0o700 });
49
+ const temporary = `${linuxServiceLauncherFile}.${process.pid}.${Date.now()}.tmp`;
50
+ fs.writeFileSync(temporary, launcher, { mode: 0o700 });
51
+ fs.chmodSync(temporary, 0o700);
52
+ fs.renameSync(temporary, linuxServiceLauncherFile);
53
+ return linuxServiceLauncherFile;
54
+ }
55
+
35
56
  export function xmlEscape(value) {
36
57
  return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
37
58
  }
38
59
 
39
- 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`;
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`;
41
63
  fs.mkdirSync(path.dirname(linuxServiceFile), { recursive: true });
42
64
  fs.writeFileSync(linuxServiceFile, unit);
43
65
  run('systemctl', ['--user', 'daemon-reload']);
@@ -53,12 +75,12 @@ function shellQuote(value) {
53
75
  return `'${String(value).replaceAll("'", "'\\''")}'`;
54
76
  }
55
77
 
56
- function macServicePlist(cliPath) {
78
+ function macServicePlist(cliPath, nodePath = process.execPath) {
57
79
  // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
58
80
  // that is about to be replaced by the next install) must not abort the repair — a stale plist is
59
81
  // exactly the loop this function exists to break.
60
82
  const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
61
- 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`;
62
84
  }
63
85
 
64
86
  function macJobLoaded(target) {
@@ -102,10 +124,10 @@ function scheduleMacServiceRestart(target) {
102
124
  ]);
103
125
  }
104
126
 
105
- export function installMacService(cliPath = globalCliPath(), { restart = true } = {}) {
127
+ export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
106
128
  const domain = macLaunchDomain();
107
129
  const target = `${domain}/${macServiceLabel}`;
108
- const plist = macServicePlist(cliPath);
130
+ const plist = macServicePlist(cliPath, nodePath);
109
131
  let previous = '';
110
132
  try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
111
133
  const loaded = macJobLoaded(target);
@@ -136,7 +158,7 @@ export function installMacService(cliPath = globalCliPath(), { restart = true }
136
158
  }
137
159
 
138
160
  export function installWindowsService(cliPath = globalCliPath()) {
139
- const command = `"${cliPath}" start`;
161
+ const command = `"${cliPath}" start --service`;
140
162
  run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']);
141
163
  run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
142
164
  }
@@ -157,6 +179,175 @@ export function persistentServiceExpected(config) {
157
179
  return false;
158
180
  }
159
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
+
160
351
  export function configurePostInstallAccess() {
161
352
  const platform = servicePlatform();
162
353
  try {
@@ -209,11 +400,13 @@ export function installPersistentAgent(config) {
209
400
  console.log(`Installing ReMCP ${VERSION}…`);
210
401
  npmGlobalInstall(`${PACKAGE_NAME}@${VERSION}`, config.runtime.packageSpec);
211
402
  const cliPath = globalCliPath();
212
- if (platform === 'linux') installLinuxService(cliPath);
213
- 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 });
214
407
  else installWindowsService(cliPath);
215
408
  configurePostInstallAccess();
216
- saveConfig({ ...config, serviceInstalled: true });
409
+ saveConfig({ ...config, serviceInstalled: true, serviceCliPath: cliScript, serviceNodePath: nodePath });
217
410
  console.log('ReMCP is installed as a background service. Future updates: remcp update');
218
411
  }
219
412
 
@@ -223,24 +416,24 @@ export function installPersistentAgent(config) {
223
416
  export function ensureServiceIfRecorded(config) {
224
417
  if (!persistentServiceExpected(config)) return false;
225
418
  try {
226
- const cliPath = globalCliPath();
419
+ const cliPath = canonicalServiceCliPath(config);
420
+ const nodePath = canonicalServiceNodePath(config);
227
421
  const platform = servicePlatform();
228
422
  if (platform === 'linux') {
229
423
  if (!fs.existsSync(linuxServiceFile)) {
230
- installLinuxService(cliPath);
424
+ installLinuxService(cliPath, nodePath);
231
425
  } else {
232
- // Node managers can move the global npm prefix between updates (nvm -> Hermes was observed in
233
- // production). An existing systemd unit then keeps launching the old CLI forever even though
234
- // npm successfully installed the new one. Repair the launcher in place before restarting it.
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);
235
429
  const unit = fs.readFileSync(linuxServiceFile, 'utf8');
236
- const expected = `ExecStart=${quoteSystemd(cliPath)} start`;
430
+ const expected = `ExecStart=${quoteSystemd(launcherFile)} start --service`;
237
431
  if (!unit.includes(expected)) {
238
432
  const repaired = /^ExecStart=/m.test(unit) ? unit.replace(/^ExecStart=.*$/m, expected) : '';
239
433
  if (!repaired) {
240
- // A unit that lost its ExecStart line (edited by hand, or written as `ExecStart = …`) cannot
241
- // be patched by substitution. Rewriting the whole unit is what keeps the machine out of the
242
- // "old CLI forever" loop, so fall back to a fresh install instead of giving up.
243
- installLinuxService(cliPath);
434
+ // A hand-edited unit with no launcher line is replaced wholesale; the stable launcher is
435
+ // still refreshed first so the replacement never points back at a retired Node manager.
436
+ installLinuxService(cliPath, nodePath);
244
437
  } else {
245
438
  fs.writeFileSync(linuxServiceFile, repaired);
246
439
  run('systemctl', ['--user', 'daemon-reload']);
@@ -252,11 +445,14 @@ export function ensureServiceIfRecorded(config) {
252
445
  // boot out a loaded agent inline: this updater may itself be a descendant of that LaunchAgent.
253
446
  // installMacService either leaves an unchanged loaded job alone, bootstraps an unloaded job, or
254
447
  // hands a changed launcher to an independent transient launchd helper.
255
- installMacService(cliPath, { restart: false });
448
+ installMacService(cliPath, { restart: false, nodePath });
256
449
  } else if (platform === 'win32') installWindowsService(cliPath);
257
450
  // Upgrade the legacy inferred state only after the supervisor repair succeeded. A failed repair
258
451
  // must not turn a stale artifact into a permanent "managed service" declaration.
259
- 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
+ }
260
456
  return true;
261
457
  } catch (error) {
262
458
  console.error(`Could not ensure the background service: ${error instanceof Error ? error.message : String(error)}`);
@@ -281,7 +477,7 @@ export function restartPersistentServiceIfInstalled(config) {
281
477
  } else {
282
478
  // A plist can survive while launchd has no loaded job (older installs, logout/login cleanup,
283
479
  // manual bootout, or a failed previous update). Re-register it directly.
284
- installMacService(globalCliPath());
480
+ installMacService(canonicalServiceCliPath(config), { nodePath:canonicalServiceNodePath(config) });
285
481
  }
286
482
  return macServiceLabel;
287
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;