@remcp/remcp 0.2.173 → 0.2.175

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.173",
3
+ "version": "0.2.175",
4
4
  "description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check bin/remcp.mjs && node --check src/agent-responder.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/agent-relay-connection.mjs && node --check src/agent-responder.mjs && node --check src/agent-runtime-capabilities.mjs && node --check src/agent-update-monitor.mjs && node --check src/agent-update.mjs && node --check src/agent-utilities.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service-macos.mjs && node --check src/cli/service-paths.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/cli/windows-supervisor.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
@@ -0,0 +1,168 @@
1
+ // Agent relay WebSocket lifecycle, bounded reconnect scheduling and handshake security.
2
+ import os from 'node:os';
3
+ import process from 'node:process';
4
+ import WebSocket from 'ws';
5
+ import { VERSION } from './version.mjs';
6
+ import { boundedReconnectDelay, jitter, sendRelayMessage } from './agent-utilities.mjs';
7
+
8
+ const RECONNECT_BASE_MS = 2_000;
9
+ const RECONNECT_FIRST_MS = 500;
10
+ const RECONNECT_MAX_MS = 60_000;
11
+
12
+ // Higher-level agent owns runtime supervision/telemetry; this controller owns only relay transport.
13
+ export function createAgentRelayConnection({
14
+ agentUrl, serverUrl, deviceToken, deviceId, deviceName,
15
+ isStopping, markStopping, getReconnects, setReconnects,
16
+ setSocket, getHelloState, respond, inFlight,
17
+ sendMetricsSample, reportInstallOnce, flushTelemetry,
18
+ checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
19
+ }) {
20
+ let revoked = false;
21
+ let reconnectTimer = null;
22
+ let askedToReconnect = false;
23
+ let requestedReconnectDelayMs = 0;
24
+ function scheduleReconnect(delay) {
25
+ if (isStopping() || revoked || reconnectTimer) return;
26
+ // Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
27
+ // resource-exhaustion model treats this as a barrier, and future callers cannot retain the
28
+ // agent with an attacker-controlled timer lifetime.
29
+ const numericDelay = Number(delay);
30
+ if (!Number.isFinite(numericDelay)) {
31
+ scheduleReconnect(RECONNECT_BASE_MS);
32
+ return;
33
+ }
34
+ if (numericDelay > RECONNECT_MAX_MS) {
35
+ scheduleReconnect(RECONNECT_MAX_MS);
36
+ return;
37
+ }
38
+ const safeDelay = Math.max(100, Math.floor(numericDelay));
39
+ reconnectTimer = setTimeout(() => {
40
+ reconnectTimer = null;
41
+ connect();
42
+ }, safeDelay);
43
+ reconnectTimer.unref?.();
44
+ }
45
+
46
+ function scheduleBackoffReconnect() {
47
+ if (isStopping() || revoked || reconnectTimer) return;
48
+ setReconnects(getReconnects() + 1);
49
+ // The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
50
+ // a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
51
+ const delay = getReconnects() === 1
52
+ ? jitter(RECONNECT_FIRST_MS)
53
+ : jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(getReconnects(), 5)));
54
+ scheduleReconnect(delay);
55
+ }
56
+
57
+ function connect() {
58
+ if (isStopping()) return;
59
+ // The device token is intentionally sent only to the configured ReMCP relay during the
60
+ // authenticated WebSocket handshake.
61
+ // codeql[js/file-access-to-http]
62
+ const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
63
+ // The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
64
+ // A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
65
+ // this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
66
+ ws.on('unexpected-response', (request, response) => {
67
+ const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
68
+ const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
69
+ if (handshakeRevoked) revoked = true;
70
+ console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${handshakeRevoked ? ': this device was revoked' : handshakeDisabled ? ': this device is temporarily disabled' : ''}.`);
71
+ response.resume();
72
+ request.destroy();
73
+ if (handshakeDisabled && !revoked) {
74
+ setReconnects(getReconnects() + 1);
75
+ // Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
76
+ // the relay, but cap recovery so an enable action becomes effective within seconds.
77
+ scheduleReconnect(jitter(RECONNECT_BASE_MS));
78
+ return;
79
+ }
80
+ const status = Number(response.statusCode || 0);
81
+ const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
82
+ if (transientHandshakeFailure && !revoked) {
83
+ // A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
84
+ // With an unexpected-response listener the client does not reliably emit close afterwards,
85
+ // so this branch must schedule its own retry or a still-running agent can stay offline forever.
86
+ scheduleBackoffReconnect();
87
+ }
88
+ });
89
+ setSocket(ws);
90
+ ws.on('open', () => {
91
+ if (reconnectTimer) {
92
+ clearTimeout(reconnectTimer);
93
+ reconnectTimer = null;
94
+ }
95
+ setReconnects(0);
96
+ sendRelayMessage(ws, {
97
+ type: 'hello',
98
+ deviceId,
99
+ deviceName,
100
+ hostname: os.hostname(),
101
+ platform: process.platform,
102
+ arch: process.arch,
103
+ agentVersion: VERSION,
104
+ ...getHelloState(),
105
+ reconnects: getReconnects(),
106
+ });
107
+ console.log(`Connected to ${serverUrl} as ${deviceName}`);
108
+ sendMetricsSample();
109
+ reportInstallOnce();
110
+ flushTelemetry();
111
+ void checkForUpdate();
112
+ schedulePostConnectUpdateChecks();
113
+ });
114
+ ws.on('message', raw => {
115
+ let message;
116
+ try { message = JSON.parse(raw.toString()); } catch { return; }
117
+ if (message?.type === 'reconnect') {
118
+ askedToReconnect = true;
119
+ requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
120
+ return;
121
+ }
122
+ if (message?.type === 'request') void respond(ws, message);
123
+ if (message?.type === 'cancel' && message.id) {
124
+ const controller = inFlight.get(message.id);
125
+ if (controller) controller.abort();
126
+ }
127
+ });
128
+ ws.on('close', code => {
129
+ clearPostConnectUpdateChecks();
130
+ for (const controller of inFlight.values()) controller.abort();
131
+ if (isStopping()) return;
132
+ // 1013 ('reconnect') is what a replica sends before it is replaced by a deployment. The machine
133
+ // is not down, it is moving: reconnect at once and forget the backoff, so the person and the
134
+ // model see nothing at all.
135
+ if (code === 1013 || askedToReconnect) {
136
+ const delayMs = requestedReconnectDelayMs || 150;
137
+ askedToReconnect = false;
138
+ requestedReconnectDelayMs = 0;
139
+ setReconnects(0);
140
+ console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
141
+ scheduleReconnect(delayMs);
142
+ return;
143
+ }
144
+ if (code === 1008 || revoked) {
145
+ // The relay closes with 1008 when the device was revoked. Retrying forever would
146
+ // hide that from the person at the computer.
147
+ console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
148
+ return;
149
+ }
150
+ if (code === 1012) {
151
+ // 1012 ('service restart') is what the relay sends when another agent process took over this
152
+ // device. Staying alive would keep a second runtime and a reconnect loop, so this process
153
+ // stops and leaves the device to the agent that owns the connection.
154
+ console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
155
+ markStopping();
156
+ void stop().finally(() => setTimeout(() => process.exit(0), 100));
157
+ return;
158
+ }
159
+ scheduleBackoffReconnect();
160
+ });
161
+ ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
162
+ }
163
+
164
+ return {
165
+ connect,
166
+ clearReconnect() { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; },
167
+ };
168
+ }
@@ -0,0 +1,56 @@
1
+ // Maintains the local MCP runtime tool cache and bounded retry lifecycle.
2
+ // The paired-agent connection owns the transport; this module owns only capabilities.
3
+ export function normalizeRuntimeToolNames(listed) {
4
+ const tools = Array.isArray(listed?.tools) ? listed.tools : [];
5
+ return [...new Set(tools.map(tool => String(tool?.name || '').trim().slice(0, 128)).filter(Boolean))].slice(0, 256);
6
+ }
7
+
8
+ export function createRuntimeCapabilities({
9
+ getTools, setTools, isActive, announce, jitter, onError = console.error,
10
+ retryBaseMs = 1_000, retryMaxMs = 30_000,
11
+ }) {
12
+ let retryDelay = retryBaseMs;
13
+ let retryTimer = null;
14
+
15
+ function clearRetry() {
16
+ if (retryTimer) clearTimeout(retryTimer);
17
+ retryTimer = null;
18
+ retryDelay = retryBaseMs;
19
+ }
20
+
21
+ function apply(next, { announce: shouldAnnounce = true } = {}) {
22
+ const current = getTools();
23
+ if (next.length === current.length && next.every((name, index) => name === current[index])) return current;
24
+ setTools(next);
25
+ if (shouldAnnounce) announce(next);
26
+ return next;
27
+ }
28
+
29
+ function scheduleRetry(client) {
30
+ if (!isActive(client) || retryTimer) return;
31
+ const delay = jitter(retryDelay);
32
+ retryDelay = Math.min(retryMaxMs, retryDelay * 2);
33
+ retryTimer = setTimeout(() => {
34
+ retryTimer = null;
35
+ void refresh(client);
36
+ }, delay);
37
+ retryTimer.unref?.();
38
+ }
39
+
40
+ async function refresh(client, { announce: shouldAnnounce = true } = {}) {
41
+ if (!client || !isActive(client)) return getTools();
42
+ try {
43
+ const listed = await client.listTools(undefined, { timeout: 3000 });
44
+ // A list request can settle after reconnect/restart. Never publish stale capabilities.
45
+ if (!isActive(client)) return getTools();
46
+ clearRetry();
47
+ apply(normalizeRuntimeToolNames(listed), { announce: shouldAnnounce });
48
+ } catch (error) {
49
+ onError(`ReMCP could not refresh runtime capabilities: ${error instanceof Error ? error.message : String(error)}`);
50
+ scheduleRetry(client);
51
+ }
52
+ return getTools();
53
+ }
54
+
55
+ return { apply, clearRetry, refresh, scheduleRetry };
56
+ }
package/src/agent.mjs CHANGED
@@ -1,32 +1,26 @@
1
1
  import os from 'node:os';
2
2
  import process from 'node:process';
3
- import WebSocket from 'ws';
4
3
  import { Client } from '@modelcontextprotocol/client';
5
4
  import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
6
5
  import { VERSION } from './version.mjs';
7
6
  import { localRuntimeEntry } from './agent-update.mjs';
8
7
 
9
8
  export { localRuntimeEntry, supervisorRestart, updateDecision } from './agent-update.mjs';
10
- import { normalizedServerUrl, sendRelayMessage, jitter, boundedReconnectDelay, deviceMetrics } from './agent-utilities.mjs';
9
+ import { normalizedServerUrl, sendRelayMessage, jitter, deviceMetrics } from './agent-utilities.mjs';
11
10
  import { createAgentUpdateMonitor, UPDATE_CHECK_INTERVAL_MS } from './agent-update-monitor.mjs';
12
11
  import { createAgentResponder } from './agent-responder.mjs';
12
+ import { createAgentRelayConnection } from './agent-relay-connection.mjs';
13
+ import { createRuntimeCapabilities, normalizeRuntimeToolNames } from './agent-runtime-capabilities.mjs';
13
14
 
14
15
  const METRICS_INTERVAL_MS = 5 * 60_000;
15
16
  const TELEMETRY_QUEUE_LIMIT = 500;
16
17
  const TELEMETRY_BATCH_LIMIT = 100;
17
18
  const TELEMETRY_SEND_INTERVAL_MS = 5_000;
18
- const RECONNECT_BASE_MS = 2_000;
19
- // The first retry after an unexpected drop: fast enough that a blip is invisible, slow enough not to
20
- // hammer a server that is genuinely down.
21
- const RECONNECT_FIRST_MS = 500;
22
19
  // Must stay above the runtime's own output ceiling (8 MiB), otherwise a large but legal tool result
23
20
  // closes the stdio connection and restarts the runtime mid-call.
24
21
  const RUNTIME_STDIO_BUFFER_BYTES = 24 * 1024 * 1024;
25
- const RECONNECT_MAX_MS = 60_000;
26
22
  const RUNTIME_RESTART_BASE_MS = 1_000;
27
23
  const RUNTIME_RESTART_MAX_MS = 30_000;
28
- const RUNTIME_TOOLS_RETRY_BASE_MS = 1_000;
29
- const RUNTIME_TOOLS_RETRY_MAX_MS = 30_000;
30
24
  // Below the relay's RPC timeout so the model gets a real error instead of a client-side
31
25
  // timeout while the device keeps working invisibly.
32
26
  const CALL_TIMEOUT_MARGIN_MS = 10_000;
@@ -43,14 +37,9 @@ export async function runAgent(options) {
43
37
  const metricsIntervalMs = Math.max(60_000, Number(options.metricsIntervalMs || process.env.REMCP_AGENT_METRICS_INTERVAL_MS) || METRICS_INTERVAL_MS);
44
38
  const persistState = typeof options.persistState === 'function' ? options.persistState : () => {};
45
39
  let stopping = false;
46
- let revoked = false;
47
40
  const inFlight = new Map();
48
41
  let activeSocket;
49
42
  let reconnects = 0;
50
- let reconnectTimer = null;
51
- // Set when a replica asks this agent to move before it is replaced; the close handler reads it.
52
- let askedToReconnect = false;
53
- let requestedReconnectDelayMs = 0;
54
43
  let pendingRequests = 0;
55
44
  let runtimeVersion = 'unknown';
56
45
  let runtimeRestarts = 0;
@@ -81,8 +70,6 @@ export async function runAgent(options) {
81
70
  let transport = null;
82
71
  let runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
83
72
  let runtimeRestartTimer = null;
84
- let runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
85
- let runtimeToolsRetryTimer = null;
86
73
  let stopPromise = null;
87
74
 
88
75
  function runtimeEnv() {
@@ -97,51 +84,20 @@ export async function runAgent(options) {
97
84
  return env;
98
85
  }
99
86
 
100
- function toolNames(listed) {
101
- const tools = Array.isArray(listed?.tools) ? listed.tools : [];
102
- return [...new Set(tools.map(tool => String(tool?.name || '').trim().slice(0, 128)).filter(Boolean))].slice(0, 256);
103
- }
104
-
105
- function scheduleRuntimeToolsRetry(client) {
106
- if (stopping || runtimeDown || mcp !== client || runtimeToolsRetryTimer) return;
107
- const delay = jitter(runtimeToolsRetryDelay);
108
- runtimeToolsRetryDelay = Math.min(RUNTIME_TOOLS_RETRY_MAX_MS, runtimeToolsRetryDelay * 2);
109
- runtimeToolsRetryTimer = setTimeout(() => {
110
- runtimeToolsRetryTimer = null;
111
- void refreshRuntimeTools(client);
112
- }, delay);
113
- runtimeToolsRetryTimer.unref?.();
114
- }
115
-
116
- function applyRuntimeTools(next, { announce = true } = {}) {
117
- if (next.length === runtimeTools.length && next.every((name, index) => name === runtimeTools[index])) return runtimeTools;
118
- runtimeTools = next;
119
- if (announce) send({ type: 'capabilities', runtimeTools });
120
- return runtimeTools;
121
- }
122
-
123
- async function refreshRuntimeTools(client = mcp, { announce = true } = {}) {
124
- if (!client || runtimeDown) return runtimeTools;
125
- try {
126
- const listed = await client.listTools(undefined, { timeout: 3000 });
127
- if (runtimeToolsRetryTimer) clearTimeout(runtimeToolsRetryTimer);
128
- runtimeToolsRetryTimer = null;
129
- runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
130
- applyRuntimeTools(toolNames(listed), { announce });
131
- } catch (error) {
132
- console.error(`ReMCP could not refresh runtime capabilities: ${error instanceof Error ? error.message : String(error)}`);
133
- scheduleRuntimeToolsRetry(client);
134
- }
135
- return runtimeTools;
136
- }
87
+ const runtimeCapabilities = createRuntimeCapabilities({
88
+ getTools: () => runtimeTools,
89
+ setTools: next => { runtimeTools = next; },
90
+ isActive: client => !stopping && !runtimeDown && mcp === client,
91
+ announce: next => send({ type: 'capabilities', runtimeTools: next }),
92
+ jitter,
93
+ });
94
+ const refreshRuntimeTools = (client = mcp, options) => runtimeCapabilities.refresh(client, options);
137
95
 
138
96
  async function startRuntime() {
139
97
  if (stopping) return;
140
98
  clearTimeout(runtimeRestartTimer);
141
99
  runtimeRestartTimer = null;
142
- if (runtimeToolsRetryTimer) clearTimeout(runtimeToolsRetryTimer);
143
- runtimeToolsRetryTimer = null;
144
- runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
100
+ runtimeCapabilities.clearRetry();
145
101
  runtimeDown = true;
146
102
  const previous = mcp;
147
103
  mcp = null;
@@ -168,10 +124,11 @@ export async function runAgent(options) {
168
124
  onChanged(error, tools) {
169
125
  if (error) {
170
126
  console.error(`ReMCP runtime tool subscription failed: ${error instanceof Error ? error.message : String(error)}`);
171
- scheduleRuntimeToolsRetry(client);
127
+ runtimeCapabilities.scheduleRetry(client);
172
128
  return;
173
129
  }
174
- applyRuntimeTools(toolNames({ tools }));
130
+ if (mcp !== client || runtimeDown || stopping) return;
131
+ runtimeCapabilities.apply(normalizeRuntimeToolNames({ tools }));
175
132
  },
176
133
  },
177
134
  },
@@ -219,9 +176,7 @@ export async function runAgent(options) {
219
176
 
220
177
  function handleRuntimeExit(reason) {
221
178
  if (stopping || runtimeRestartTimer) return;
222
- if (runtimeToolsRetryTimer) clearTimeout(runtimeToolsRetryTimer);
223
- runtimeToolsRetryTimer = null;
224
- runtimeToolsRetryDelay = RUNTIME_TOOLS_RETRY_BASE_MS;
179
+ runtimeCapabilities.clearRetry();
225
180
  runtimeDown = true;
226
181
  runtimeTools = [];
227
182
  send({ type: 'capabilities', runtimeTools });
@@ -290,39 +245,6 @@ export async function runAgent(options) {
290
245
  }
291
246
  }
292
247
 
293
- function scheduleReconnect(delay) {
294
- if (stopping || revoked || reconnectTimer) return;
295
- // Keep the upper-bound guard in control flow immediately before the timer sink. CodeQL's
296
- // resource-exhaustion model treats this as a barrier, and future callers cannot retain the
297
- // agent with an attacker-controlled timer lifetime.
298
- const numericDelay = Number(delay);
299
- if (!Number.isFinite(numericDelay)) {
300
- scheduleReconnect(RECONNECT_BASE_MS);
301
- return;
302
- }
303
- if (numericDelay > RECONNECT_MAX_MS) {
304
- scheduleReconnect(RECONNECT_MAX_MS);
305
- return;
306
- }
307
- const safeDelay = Math.max(100, Math.floor(numericDelay));
308
- reconnectTimer = setTimeout(() => {
309
- reconnectTimer = null;
310
- connect();
311
- }, safeDelay);
312
- reconnectTimer.unref?.();
313
- }
314
-
315
- function scheduleBackoffReconnect() {
316
- if (stopping || revoked || reconnectTimer) return;
317
- reconnects += 1;
318
- // The first retry after an unexpected drop is quick on purpose: a deploy blip, a proxy restart or
319
- // a dropped packet should cost a fraction of a second, not the two seconds the backoff starts at.
320
- const delay = reconnects === 1
321
- ? jitter(RECONNECT_FIRST_MS)
322
- : jitter(Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(reconnects, 5)));
323
- scheduleReconnect(delay);
324
- }
325
-
326
248
  const respond = createAgentResponder({
327
249
  inFlight,
328
250
  onPendingChange: delta => { pendingRequests = Math.max(0, pendingRequests + delta); },
@@ -349,116 +271,23 @@ export async function runAgent(options) {
349
271
  },
350
272
  });
351
273
 
352
- function connect() {
353
- if (stopping) return;
354
- // The device token is intentionally sent only to the configured ReMCP relay during the
355
- // authenticated WebSocket handshake.
356
- // codeql[js/file-access-to-http]
357
- const ws = new WebSocket(agentUrl, { headers: { Authorization: `Bearer ${deviceToken}` } });
358
- // The ws client leaves cleanup/retry to the caller when an unexpected-response listener exists.
359
- // A temporary workspace pause therefore needs an explicit retry; otherwise the first 423 leaves
360
- // this WebSocket stuck in CONNECTING forever and turning the device back on cannot recover it.
361
- ws.on('unexpected-response', (request, response) => {
362
- const handshakeRevoked = String(response.headers['x-remcp-revoked'] || '') === '1';
363
- const handshakeDisabled = String(response.headers['x-remcp-disabled'] || '') === '1';
364
- if (handshakeRevoked) revoked = true;
365
- console.error(`ReMCP relay refused the connection (HTTP ${response.statusCode})${handshakeRevoked ? ': this device was revoked' : handshakeDisabled ? ': this device is temporarily disabled' : ''}.`);
366
- response.resume();
367
- request.destroy();
368
- if (handshakeDisabled && !revoked) {
369
- reconnects += 1;
370
- // Service access is a user-controlled pause, not an outage. Poll slowly enough not to hammer
371
- // the relay, but cap recovery so an enable action becomes effective within seconds.
372
- scheduleReconnect(jitter(RECONNECT_BASE_MS));
373
- return;
374
- }
375
- const status = Number(response.statusCode || 0);
376
- const transientHandshakeFailure = status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
377
- if (transientHandshakeFailure && !revoked) {
378
- // A reverse proxy or relay deploy can refuse the HTTP upgrade before a WebSocket exists.
379
- // With an unexpected-response listener the client does not reliably emit close afterwards,
380
- // so this branch must schedule its own retry or a still-running agent can stay offline forever.
381
- scheduleBackoffReconnect();
382
- }
383
- });
384
- activeSocket = ws;
385
- ws.on('open', () => {
386
- if (reconnectTimer) {
387
- clearTimeout(reconnectTimer);
388
- reconnectTimer = null;
389
- }
390
- reconnects = 0;
391
- sendRelayMessage(ws, {
392
- type: 'hello',
393
- deviceId,
394
- deviceName,
395
- hostname: os.hostname(),
396
- platform: process.platform,
397
- arch: process.arch,
398
- agentVersion: VERSION,
399
- runtimeVersion,
400
- runtimeState: runtimeDown ? 'down' : 'ready',
401
- runtimeError,
402
- runtimeTools,
403
- telemetryEnabled,
404
- reconnects,
405
- });
406
- console.log(`Connected to ${serverUrl} as ${deviceName}`);
407
- sendMetricsSample();
408
- reportInstallOnce();
409
- flushTelemetry();
410
- void checkForUpdate();
411
- schedulePostConnectUpdateChecks();
412
- });
413
- ws.on('message', raw => {
414
- let message;
415
- try { message = JSON.parse(raw.toString()); } catch { return; }
416
- if (message?.type === 'reconnect') {
417
- askedToReconnect = true;
418
- requestedReconnectDelayMs = boundedReconnectDelay(message.delayMs, 150);
419
- return;
420
- }
421
- if (message?.type === 'request') void respond(ws, message);
422
- if (message?.type === 'cancel' && message.id) {
423
- const controller = inFlight.get(message.id);
424
- if (controller) controller.abort();
425
- }
426
- });
427
- ws.on('close', code => {
428
- clearPostConnectUpdateChecks();
429
- for (const controller of inFlight.values()) controller.abort();
430
- if (stopping) return;
431
- // 1013 ('reconnect') is what a replica sends before it is replaced by a deployment. The machine
432
- // is not down, it is moving: reconnect at once and forget the backoff, so the person and the
433
- // model see nothing at all.
434
- if (code === 1013 || askedToReconnect) {
435
- const delayMs = requestedReconnectDelayMs || 150;
436
- askedToReconnect = false;
437
- requestedReconnectDelayMs = 0;
438
- reconnects = 0;
439
- console.log(`ReMCP relay is being redeployed; reconnecting in ${delayMs}ms.`);
440
- scheduleReconnect(delayMs);
441
- return;
442
- }
443
- if (code === 1008 || revoked) {
444
- // The relay closes with 1008 when the device was revoked. Retrying forever would
445
- // hide that from the person at the computer.
446
- console.error('ReMCP access for this device was revoked. Pair the machine again from the ReMCP workspace: remcp connect --server <url> --code <code> --install');
447
- return;
448
- }
449
- if (code === 1012) {
450
- // 1012 ('service restart') is what the relay sends when another agent process took over this
451
- // device. Staying alive would keep a second runtime and a reconnect loop, so this process
452
- // stops and leaves the device to the agent that owns the connection.
453
- console.error('Another ReMCP agent connected for this device; this process will stop. Run one agent per machine (a service manager or the remcp start command).');
454
- stopping = true;
455
- void stop().finally(() => setTimeout(() => process.exit(0), 100));
456
- return;
457
- }
458
- scheduleBackoffReconnect();
459
- });
460
- ws.on('error', error => console.error(`ReMCP relay: ${error.message}`));
461
- }
274
+ const { connect, clearReconnect } = createAgentRelayConnection({
275
+ agentUrl, serverUrl, deviceToken, deviceId, deviceName,
276
+ isStopping: () => stopping,
277
+ markStopping: () => { stopping = true; },
278
+ getReconnects: () => reconnects,
279
+ setReconnects: count => { reconnects = count; },
280
+ setSocket: socket => { activeSocket = socket; },
281
+ getHelloState: () => ({
282
+ runtimeVersion,
283
+ runtimeState: runtimeDown ? 'down' : 'ready',
284
+ runtimeError,
285
+ runtimeTools,
286
+ telemetryEnabled,
287
+ }),
288
+ respond, inFlight, sendMetricsSample, reportInstallOnce, flushTelemetry,
289
+ checkForUpdate, schedulePostConnectUpdateChecks, clearPostConnectUpdateChecks, stop,
290
+ });
462
291
 
463
292
  scheduleMetricsSample();
464
293
  const telemetryFlushTimer = setInterval(flushTelemetry, TELEMETRY_SEND_INTERVAL_MS);
@@ -470,10 +299,8 @@ export async function runAgent(options) {
470
299
  if (stopPromise) return stopPromise;
471
300
  stopping = true;
472
301
  clearTimeout(runtimeRestartTimer);
473
- clearTimeout(runtimeToolsRetryTimer);
474
- runtimeToolsRetryTimer = null;
475
- clearTimeout(reconnectTimer);
476
- reconnectTimer = null;
302
+ runtimeCapabilities.clearRetry();
303
+ clearReconnect();
477
304
  clearPostConnectUpdateChecks();
478
305
  if (telemetryTimer) clearTimeout(telemetryTimer);
479
306
  clearInterval(telemetryFlushTimer);
@@ -0,0 +1,192 @@
1
+ // macOS launchd service and managed CLI shim; no Linux/Windows service side effects.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { globalCliPath, servicePlatform } from './npm-global.mjs';
8
+ import { persistentServiceExpected, canonicalServiceCliPath, canonicalServiceNodePath } from './service-inventory.mjs';
9
+ import { configDir, home, macLogFile, macServiceFile, macServiceLabel } from './env.mjs';
10
+ import { run } from './shell.mjs';
11
+ import { resolvedCliScript, shellQuote, xmlEscape } from './service-paths.mjs';
12
+
13
+ export function macLaunchDomain() {
14
+ if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
15
+ return `gui/${process.getuid()}`;
16
+ }
17
+
18
+ const remcpPathBlockStart = '# >>> ReMCP CLI PATH >>>';
19
+ const remcpPathBlockEnd = '# <<< ReMCP CLI PATH <<<';
20
+ const remcpCliShimMarker = '# ReMCP managed CLI shim';
21
+
22
+ function macCliShimPath() {
23
+ return path.join(home, '.local', 'bin', 'remcp');
24
+ }
25
+
26
+ function interactiveShellProfile() {
27
+ const shell = String(process.env.REMCP_TEST_SHELL || process.env.SHELL || os.userInfo().shell || '/bin/zsh');
28
+ const name = path.basename(shell);
29
+ if (name === 'zsh') return path.join(home, '.zshrc');
30
+ if (name === 'bash') return path.join(home, '.bash_profile');
31
+ return '';
32
+ }
33
+
34
+ function upsertManagedPathBlock(file) {
35
+ if (!file) return false;
36
+ const block = [
37
+ remcpPathBlockStart,
38
+ 'case ":$PATH:" in',
39
+ ' *":$HOME/.local/bin:"*) ;;',
40
+ ' *) export PATH="$HOME/.local/bin:$PATH" ;;',
41
+ 'esac',
42
+ remcpPathBlockEnd,
43
+ ].join('\n');
44
+ let current = '';
45
+ try { current = fs.readFileSync(file, 'utf8'); } catch {}
46
+ const start = current.indexOf(remcpPathBlockStart);
47
+ const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
48
+ let next;
49
+ if (start >= 0 && end >= 0) {
50
+ next = current.slice(0, start) + block + current.slice(end + remcpPathBlockEnd.length);
51
+ } else {
52
+ const prefix = current && !current.endsWith('\n') ? current + '\n' : current;
53
+ next = prefix + (prefix ? '\n' : '') + block + '\n';
54
+ }
55
+ if (next === current) return false;
56
+ fs.writeFileSync(file, next, { mode:0o600 });
57
+ return true;
58
+ }
59
+
60
+ function removeManagedPathBlock(file) {
61
+ if (!file) return;
62
+ let current = '';
63
+ try { current = fs.readFileSync(file, 'utf8'); } catch { return; }
64
+ const start = current.indexOf(remcpPathBlockStart);
65
+ const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
66
+ if (start < 0 || end < 0) return;
67
+ let before = current.slice(0, start);
68
+ let after = current.slice(end + remcpPathBlockEnd.length);
69
+ if (before.endsWith('\n') && after.startsWith('\n')) after = after.slice(1);
70
+ const next = before + after;
71
+ fs.writeFileSync(file, next, { mode:0o600 });
72
+ }
73
+
74
+ export function ensureMacCliCommand(config = {}, { cliPath = '', nodePath = '' } = {}) {
75
+ if (servicePlatform() !== 'darwin') return null;
76
+ const effectiveCli = cliPath || (persistentServiceExpected(config) ? canonicalServiceCliPath(config) : globalCliPath());
77
+ const effectiveNode = nodePath || (persistentServiceExpected(config) ? canonicalServiceNodePath(config) : process.execPath);
78
+ const cliScript = resolvedCliScript(effectiveCli);
79
+ const shim = macCliShimPath();
80
+ try {
81
+ const existing = fs.readFileSync(shim, 'utf8');
82
+ if (!existing.includes(remcpCliShimMarker)) {
83
+ console.error(`ReMCP did not replace ${shim} because it is not a ReMCP-managed command.`);
84
+ return null;
85
+ }
86
+ } catch (error) {
87
+ if (error?.code && error.code !== 'ENOENT') {
88
+ console.error(`ReMCP could not inspect ${shim}: ${error instanceof Error ? error.message : String(error)}`);
89
+ return null;
90
+ }
91
+ }
92
+ const launcher = `#!/bin/sh\n${remcpCliShimMarker}\nexec ${shellQuote(effectiveNode)} ${shellQuote(cliScript)} "$@"\n`;
93
+ fs.mkdirSync(path.dirname(shim), { recursive:true, mode:0o755 });
94
+ const temporary = `${shim}.${process.pid}.${Date.now()}.tmp`;
95
+ fs.writeFileSync(temporary, launcher, { mode:0o755 });
96
+ fs.chmodSync(temporary, 0o755);
97
+ fs.renameSync(temporary, shim);
98
+ upsertManagedPathBlock(interactiveShellProfile());
99
+ return shim;
100
+ }
101
+
102
+ export function removeMacCliCommand() {
103
+ if (servicePlatform() !== 'darwin') return;
104
+ const shim = macCliShimPath();
105
+ try {
106
+ const existing = fs.readFileSync(shim, 'utf8');
107
+ if (existing.includes(remcpCliShimMarker)) fs.unlinkSync(shim);
108
+ } catch {}
109
+ removeManagedPathBlock(interactiveShellProfile());
110
+ }
111
+
112
+ function macServicePlist(cliPath, nodePath = process.execPath) {
113
+ // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
114
+ // that is about to be replaced by the next install) must not abort the repair — a stale plist is
115
+ // exactly the loop this function exists to break.
116
+ const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
117
+ 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`;
118
+ }
119
+
120
+ export function macJobLoaded(target) {
121
+ return spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
122
+ }
123
+
124
+ function submitMacHelper(kind, lines) {
125
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
126
+ const nonce = `${process.pid}.${Date.now()}`;
127
+ const helperFile = path.join(configDir, `launchd-${kind}-${nonce}.sh`);
128
+ const helperLabel = `${macServiceLabel}.${kind}.${nonce}`;
129
+ const script = [
130
+ '#!/bin/sh',
131
+ 'sleep 1',
132
+ 'status=0',
133
+ ...lines.map(line => `${line} || status=$?`),
134
+ `if [ "$status" -ne 0 ]; then echo "ReMCP launchd ${kind} helper failed with status $status" >> ${shellQuote(macLogFile)}; fi`,
135
+ `rm -f ${shellQuote(helperFile)}`,
136
+ `launchctl remove ${shellQuote(helperLabel)} >/dev/null 2>&1 || true`,
137
+ 'exit 0',
138
+ '',
139
+ ].join('\n');
140
+ fs.writeFileSync(helperFile, script, { mode: 0o700 });
141
+ fs.chmodSync(helperFile, 0o700);
142
+ run('launchctl', ['submit', '-l', helperLabel, '--', '/bin/sh', helperFile]);
143
+ return helperLabel;
144
+ }
145
+
146
+ function scheduleMacServiceReload(domain, target) {
147
+ return submitMacHelper('reload', [
148
+ `launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
149
+ `launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
150
+ `launchctl enable ${shellQuote(target)}`,
151
+ `launchctl kickstart -k ${shellQuote(target)}`,
152
+ ]);
153
+ }
154
+
155
+ export function scheduleMacServiceRestart(target) {
156
+ return submitMacHelper('restart', [
157
+ `launchctl kickstart -k ${shellQuote(target)}`,
158
+ ]);
159
+ }
160
+
161
+ export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
162
+ const domain = macLaunchDomain();
163
+ const target = `${domain}/${macServiceLabel}`;
164
+ const plist = macServicePlist(cliPath, nodePath);
165
+ let previous = '';
166
+ try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
167
+ const loaded = macJobLoaded(target);
168
+
169
+ fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
170
+ fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
171
+ fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
172
+
173
+ if (loaded && previous === plist) {
174
+ run('launchctl', ['enable', target]);
175
+ // Never boot out a healthy loaded job just to refresh an in-place npm install. The final
176
+ // kickstart keeps launchd responsible for bringing the replacement agent back.
177
+ if (restart) run('launchctl', ['kickstart', '-k', target]);
178
+ return 'loaded';
179
+ }
180
+
181
+ if (loaded) {
182
+ // A changed Node/npm prefix requires launchd to re-read ProgramArguments. A separate transient
183
+ // launchd job survives booting out com.remcp.agent even when the updater was launched by it.
184
+ scheduleMacServiceReload(domain, target);
185
+ return 'reload-scheduled';
186
+ }
187
+
188
+ run('launchctl', ['bootstrap', domain, macServiceFile]);
189
+ run('launchctl', ['enable', target]);
190
+ if (restart) run('launchctl', ['kickstart', '-k', target]);
191
+ return 'bootstrapped';
192
+ }
@@ -0,0 +1,15 @@
1
+ // Shared service launcher/path escaping across the three supported platforms.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export function resolvedCliScript(cliPath) {
6
+ return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
7
+ }
8
+
9
+ export function shellQuote(value) {
10
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
11
+ }
12
+
13
+ export function xmlEscape(value) {
14
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
15
+ }
@@ -5,26 +5,24 @@ export { persistentServiceExpected, persistentServiceState, releasePairPresentAt
5
5
  // Installing, repairing and removing the background agent: one supervisor per platform, plus the
6
6
  // write-access tweaks a fresh install needs. Nothing here is reachable from a model or an MCP tool.
7
7
  import fs from 'node:fs';
8
- import os from 'node:os';
9
8
  import path from 'node:path';
10
9
  import process from 'node:process';
11
10
  import { spawnSync } from 'node:child_process';
12
- import { resolveNpm } from '../npm.mjs';
11
+ import { resolvedCliScript, shellQuote } from './service-paths.mjs';
12
+ import { macLaunchDomain, ensureMacCliCommand, installMacService, macJobLoaded, scheduleMacServiceRestart, removeMacCliCommand } from './service-macos.mjs';
13
+ export { xmlEscape } from './service-paths.mjs';
14
+ export { macLaunchDomain, ensureMacCliCommand, installMacService } from './service-macos.mjs';
13
15
  import { PACKAGE_NAME, VERSION } from '../version.mjs';
14
16
 
15
17
  import { saveConfig } from './config.mjs';
16
- import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsServiceLauncherFile, windowsTaskName } from './env.mjs';
17
- import { output, run } from './shell.mjs';
18
+ import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsServiceLauncherFile, windowsTaskName } from './env.mjs';
19
+ import { run } from './shell.mjs';
18
20
  import { windowsServiceLauncherText, windowsTaskReliabilityCommand } from './windows-supervisor.mjs';
19
21
 
20
22
  export function quoteSystemd(value) {
21
23
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
22
24
  }
23
25
 
24
- function resolvedCliScript(cliPath) {
25
- return fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
26
- }
27
-
28
26
  function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
29
27
  const cliScript = resolvedCliScript(cliPath);
30
28
  const launcher = `#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(cliScript)} \"$@\"\n`;
@@ -36,10 +34,6 @@ function writeLinuxServiceLauncher(cliPath = globalCliPath(), nodePath = process
36
34
  return linuxServiceLauncherFile;
37
35
  }
38
36
 
39
- export function xmlEscape(value) {
40
- return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
41
- }
42
-
43
37
  export function installLinuxService(cliPath = globalCliPath(), nodePath = process.execPath) {
44
38
  const launcherFile = writeLinuxServiceLauncher(cliPath, nodePath);
45
39
  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`;
@@ -49,191 +43,6 @@ export function installLinuxService(cliPath = globalCliPath(), nodePath = proces
49
43
  run('systemctl', ['--user', 'enable', '--now', 'remcp-agent.service']);
50
44
  }
51
45
 
52
- export function macLaunchDomain() {
53
- if (typeof process.getuid !== 'function') throw new Error('Could not determine the current macOS user');
54
- return `gui/${process.getuid()}`;
55
- }
56
-
57
- function shellQuote(value) {
58
- return `'${String(value).replaceAll("'", "'\\''")}'`;
59
- }
60
-
61
- const remcpPathBlockStart = '# >>> ReMCP CLI PATH >>>';
62
- const remcpPathBlockEnd = '# <<< ReMCP CLI PATH <<<';
63
- const remcpCliShimMarker = '# ReMCP managed CLI shim';
64
-
65
- function macCliShimPath() {
66
- return path.join(home, '.local', 'bin', 'remcp');
67
- }
68
-
69
- function interactiveShellProfile() {
70
- const shell = String(process.env.REMCP_TEST_SHELL || process.env.SHELL || os.userInfo().shell || '/bin/zsh');
71
- const name = path.basename(shell);
72
- if (name === 'zsh') return path.join(home, '.zshrc');
73
- if (name === 'bash') return path.join(home, '.bash_profile');
74
- return '';
75
- }
76
-
77
- function upsertManagedPathBlock(file) {
78
- if (!file) return false;
79
- const block = [
80
- remcpPathBlockStart,
81
- 'case ":$PATH:" in',
82
- ' *":$HOME/.local/bin:"*) ;;',
83
- ' *) export PATH="$HOME/.local/bin:$PATH" ;;',
84
- 'esac',
85
- remcpPathBlockEnd,
86
- ].join('\n');
87
- let current = '';
88
- try { current = fs.readFileSync(file, 'utf8'); } catch {}
89
- const start = current.indexOf(remcpPathBlockStart);
90
- const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
91
- let next;
92
- if (start >= 0 && end >= 0) {
93
- next = current.slice(0, start) + block + current.slice(end + remcpPathBlockEnd.length);
94
- } else {
95
- const prefix = current && !current.endsWith('\n') ? current + '\n' : current;
96
- next = prefix + (prefix ? '\n' : '') + block + '\n';
97
- }
98
- if (next === current) return false;
99
- fs.writeFileSync(file, next, { mode:0o600 });
100
- return true;
101
- }
102
-
103
- function removeManagedPathBlock(file) {
104
- if (!file) return;
105
- let current = '';
106
- try { current = fs.readFileSync(file, 'utf8'); } catch { return; }
107
- const start = current.indexOf(remcpPathBlockStart);
108
- const end = start >= 0 ? current.indexOf(remcpPathBlockEnd, start + remcpPathBlockStart.length) : -1;
109
- if (start < 0 || end < 0) return;
110
- let before = current.slice(0, start);
111
- let after = current.slice(end + remcpPathBlockEnd.length);
112
- if (before.endsWith('\n') && after.startsWith('\n')) after = after.slice(1);
113
- const next = before + after;
114
- fs.writeFileSync(file, next, { mode:0o600 });
115
- }
116
-
117
- export function ensureMacCliCommand(config = {}, { cliPath = '', nodePath = '' } = {}) {
118
- if (servicePlatform() !== 'darwin') return null;
119
- const effectiveCli = cliPath || (persistentServiceExpected(config) ? canonicalServiceCliPath(config) : globalCliPath());
120
- const effectiveNode = nodePath || (persistentServiceExpected(config) ? canonicalServiceNodePath(config) : process.execPath);
121
- const cliScript = resolvedCliScript(effectiveCli);
122
- const shim = macCliShimPath();
123
- try {
124
- const existing = fs.readFileSync(shim, 'utf8');
125
- if (!existing.includes(remcpCliShimMarker)) {
126
- console.error(`ReMCP did not replace ${shim} because it is not a ReMCP-managed command.`);
127
- return null;
128
- }
129
- } catch (error) {
130
- if (error?.code && error.code !== 'ENOENT') {
131
- console.error(`ReMCP could not inspect ${shim}: ${error instanceof Error ? error.message : String(error)}`);
132
- return null;
133
- }
134
- }
135
- const launcher = `#!/bin/sh\n${remcpCliShimMarker}\nexec ${shellQuote(effectiveNode)} ${shellQuote(cliScript)} "$@"\n`;
136
- fs.mkdirSync(path.dirname(shim), { recursive:true, mode:0o755 });
137
- const temporary = `${shim}.${process.pid}.${Date.now()}.tmp`;
138
- fs.writeFileSync(temporary, launcher, { mode:0o755 });
139
- fs.chmodSync(temporary, 0o755);
140
- fs.renameSync(temporary, shim);
141
- upsertManagedPathBlock(interactiveShellProfile());
142
- return shim;
143
- }
144
-
145
- function removeMacCliCommand() {
146
- if (servicePlatform() !== 'darwin') return;
147
- const shim = macCliShimPath();
148
- try {
149
- const existing = fs.readFileSync(shim, 'utf8');
150
- if (existing.includes(remcpCliShimMarker)) fs.unlinkSync(shim);
151
- } catch {}
152
- removeManagedPathBlock(interactiveShellProfile());
153
- }
154
-
155
- function macServicePlist(cliPath, nodePath = process.execPath) {
156
- // launchd wants an absolute path; a symlinked prefix that npm has not materialised yet (or a path
157
- // that is about to be replaced by the next install) must not abort the repair — a stale plist is
158
- // exactly the loop this function exists to break.
159
- const cliScript = fs.existsSync(cliPath) ? fs.realpathSync(cliPath) : path.resolve(cliPath);
160
- 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`;
161
- }
162
-
163
- function macJobLoaded(target) {
164
- return spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
165
- }
166
-
167
- function submitMacHelper(kind, lines) {
168
- fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
169
- const nonce = `${process.pid}.${Date.now()}`;
170
- const helperFile = path.join(configDir, `launchd-${kind}-${nonce}.sh`);
171
- const helperLabel = `${macServiceLabel}.${kind}.${nonce}`;
172
- const script = [
173
- '#!/bin/sh',
174
- 'sleep 1',
175
- 'status=0',
176
- ...lines.map(line => `${line} || status=$?`),
177
- `if [ "$status" -ne 0 ]; then echo "ReMCP launchd ${kind} helper failed with status $status" >> ${shellQuote(macLogFile)}; fi`,
178
- `rm -f ${shellQuote(helperFile)}`,
179
- `launchctl remove ${shellQuote(helperLabel)} >/dev/null 2>&1 || true`,
180
- 'exit 0',
181
- '',
182
- ].join('\n');
183
- fs.writeFileSync(helperFile, script, { mode: 0o700 });
184
- fs.chmodSync(helperFile, 0o700);
185
- run('launchctl', ['submit', '-l', helperLabel, '--', '/bin/sh', helperFile]);
186
- return helperLabel;
187
- }
188
-
189
- function scheduleMacServiceReload(domain, target) {
190
- return submitMacHelper('reload', [
191
- `launchctl bootout ${shellQuote(target)} >/dev/null 2>&1 || true`,
192
- `launchctl bootstrap ${shellQuote(domain)} ${shellQuote(macServiceFile)}`,
193
- `launchctl enable ${shellQuote(target)}`,
194
- `launchctl kickstart -k ${shellQuote(target)}`,
195
- ]);
196
- }
197
-
198
- function scheduleMacServiceRestart(target) {
199
- return submitMacHelper('restart', [
200
- `launchctl kickstart -k ${shellQuote(target)}`,
201
- ]);
202
- }
203
-
204
- export function installMacService(cliPath = globalCliPath(), { restart = true, nodePath = process.execPath } = {}) {
205
- const domain = macLaunchDomain();
206
- const target = `${domain}/${macServiceLabel}`;
207
- const plist = macServicePlist(cliPath, nodePath);
208
- let previous = '';
209
- try { previous = fs.readFileSync(macServiceFile, 'utf8'); } catch {}
210
- const loaded = macJobLoaded(target);
211
-
212
- fs.mkdirSync(path.dirname(macServiceFile), { recursive: true });
213
- fs.mkdirSync(path.dirname(macLogFile), { recursive: true });
214
- fs.writeFileSync(macServiceFile, plist, { mode: 0o600 });
215
-
216
- if (loaded && previous === plist) {
217
- run('launchctl', ['enable', target]);
218
- // Never boot out a healthy loaded job just to refresh an in-place npm install. The final
219
- // kickstart keeps launchd responsible for bringing the replacement agent back.
220
- if (restart) run('launchctl', ['kickstart', '-k', target]);
221
- return 'loaded';
222
- }
223
-
224
- if (loaded) {
225
- // A changed Node/npm prefix requires launchd to re-read ProgramArguments. A separate transient
226
- // launchd job survives booting out com.remcp.agent even when the updater was launched by it.
227
- scheduleMacServiceReload(domain, target);
228
- return 'reload-scheduled';
229
- }
230
-
231
- run('launchctl', ['bootstrap', domain, macServiceFile]);
232
- run('launchctl', ['enable', target]);
233
- if (restart) run('launchctl', ['kickstart', '-k', target]);
234
- return 'bootstrapped';
235
- }
236
-
237
46
  export function writeWindowsServiceLauncher(cliPath = globalCliPath(), nodePath = process.execPath) {
238
47
  const launcher = windowsServiceLauncherText(cliPath, nodePath);
239
48
  fs.mkdirSync(configDir, { recursive:true, mode:0o700 });