mcp-pacemaker 2.0.2 → 2.1.0

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/CONTRIBUTING.md +39 -13
  3. package/README.md +255 -8
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,1007 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-pacemaker — CLI (commander + @clack/prompts + picocolors). MIT.
4
+ *
5
+ * Commands:
6
+ * import --from vscode|cursor|claude [--config <path>] Read your existing client config -> servers.json
7
+ * plan --client vscode|cursor|claude [--port N] Dry-run: show client-config changes
8
+ * install --client vscode|cursor|claude [--port N] Wire client -> bridge, register auto-start, start bridge
9
+ * status Is the bridge up? what's installed?
10
+ * start | stop [--port N] Start/stop the bridge supervisor
11
+ * uninstall Stop bridge, remove auto-start, restore client config
12
+ *
13
+ * Config + state live in ~/.mcp-pacemaker/ . Your client config is backed up to *.bak before any rewrite.
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs';
16
+ import { resolve, dirname, join } from 'node:path';
17
+ import { homedir, platform } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawn, execFileSync, execSync } from 'node:child_process';
20
+ import net from 'node:net';
21
+ import http from 'node:http';
22
+ import { Command } from 'commander';
23
+ import * as p from '@clack/prompts';
24
+ import pc from 'picocolors';
25
+ import { parse as parseToml } from 'smol-toml';
26
+ import { checkServerPaths, checkReservedName } from './config-checks.mjs';
27
+ import { assertDurableRoot, backendDescription, resumeService, servicePaths, stopManagedService } from './service-control.mjs';
28
+ import { createInstance, durableJson, loadInstance, readInstanceState, managedPath, readManagedJson, snapshotPackage } from './managed-state.mjs';
29
+ import { readAutostart } from './upgrade-autostart.mjs';
30
+ import { registerDefaultCli } from './cli-dispatch.mjs';
31
+ import { randomUUID } from 'node:crypto';
32
+ import { canonicalPort, validateSelectedCommand } from './cli-selection.mjs';
33
+ import { requireUserRuntimeContext } from './runtime-context.mjs';
34
+
35
+ const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ const ROOT = resolve(__dirname, '..');
37
+ const selectedContext = process.env.MCP_PACEMAKER_CLI_CONTEXT
38
+ ? JSON.parse(process.env.MCP_PACEMAKER_CLI_CONTEXT) : undefined;
39
+ const selectedInstance = selectedContext ? readInstanceState(selectedContext.directory) : undefined;
40
+ if (selectedInstance &&
41
+ selectedContext.id !== selectedInstance.id) throw new Error('Selected CLI instance identity changed.');
42
+ const explicitInstance = selectedContext?.explicit === true;
43
+ delete process.env.MCP_PACEMAKER_CLI_CONTEXT;
44
+ delete process.env.MCP_PACEMAKER_CLI_INSTANCE;
45
+ const HOME = selectedInstance ? dirname(selectedInstance.config) : resolve(homedir(), '.mcp-pacemaker');
46
+ const CONFIG = selectedInstance?.config ?? resolve(HOME, 'servers.json');
47
+ const STATE = resolve(HOME, 'state.json');
48
+ const DEFAULT_PORT = selectedInstance ? selectedInstance.port : 8791;
49
+ const SERVICE_PATH_HELP = '\nOn Linux/macOS, the full ~/.mcp-pacemaker/service-<port>.sock path must fit in 103 UTF-8 bytes. Longer paths are rejected before setup changes, including with --no-start and --no-autostart.';
50
+
51
+ const ok = (m) => console.log(`${pc.green('✓')} ${m}`);
52
+ // warnCount lets `doctor` report warnings in its summary instead of claiming "all good".
53
+ let warnCount = 0;
54
+ const warn = (m) => { warnCount++; console.log(`${pc.yellow('!')} ${m}`); };
55
+ const err = (m) => console.log(`${pc.red('✗')} ${m}`);
56
+ const info = (m) => console.log(`${pc.cyan('·')} ${m}`);
57
+
58
+ const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
59
+ const writeJson = (p, o) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(o, null, 2) + '\n'); };
60
+
61
+ /* ------------------------------- host adapters ------------------------------ */
62
+ // Each MCP host (editor or CLI) is an adapter: where its config lives, the JSON key its servers
63
+ // sit under, and its config format. Editors and CLIs are just data in this registry; CLI hosts
64
+ // (Claude Code, Copilot CLI, Codex, Gemini) are added in later phases.
65
+ function vscodeConfigPath() {
66
+ const plat = platform(), home = homedir();
67
+ const appdata = process.env.APPDATA || join(home, 'AppData', 'Roaming');
68
+ if (plat === 'win32') return join(appdata, 'Code', 'User', 'mcp.json');
69
+ if (plat === 'darwin') return join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
70
+ return join(home, '.config', 'Code', 'User', 'mcp.json');
71
+ }
72
+ function claudeDesktopConfigPath() {
73
+ const plat = platform(), home = homedir();
74
+ const appdata = process.env.APPDATA || join(home, 'AppData', 'Roaming');
75
+ if (plat === 'win32') return join(appdata, 'Claude', 'claude_desktop_config.json');
76
+ if (plat === 'darwin') return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
77
+ return join(home, '.config', 'Claude', 'claude_desktop_config.json');
78
+ }
79
+ function codexConfigPath() { return join(homedir(), '.codex', 'config.toml'); }
80
+ // Is a binary resolvable on PATH? (used to detect CLI hosts that keep no fixed config file)
81
+ function hasBin(name) {
82
+ try { execSync(platform() === 'win32' ? `where ${name}` : `command -v ${name}`, { stdio: 'ignore' }); return true; }
83
+ catch { return false; }
84
+ }
85
+
86
+ // Bridge endpoints for a server: http-proxied servers use the proxy base /<name>; stdio-backed
87
+ // servers are exposed over SSE (/<name>/sse) and Streamable HTTP (/<name>/mcp).
88
+ const proxyUrl = (name, port) => `http://127.0.0.1:${port}/${name}`;
89
+ const sseUrl = (name, port) => `http://127.0.0.1:${port}/${name}/sse`;
90
+ const mcpUrl = (name, port) => `http://127.0.0.1:${port}/${name}/mcp`;
91
+ // Per-host bridged-entry serializers. Hosts that speak Streamable HTTP use /<name>/mcp for stdio
92
+ // servers; SSE is kept only for hosts not yet verified on Streamable HTTP. SSE is also the
93
+ // transport behind the -32001 "session not found" reconnect failures, so prefer /mcp where possible.
94
+ const entryClassic = (name, def, port) =>
95
+ def.type === 'http' ? { type: 'http', url: proxyUrl(name, port) } : { type: 'sse', url: sseUrl(name, port) };
96
+ const entryStreamable = (name, def, port) =>
97
+ ({ type: 'http', url: def.type === 'http' ? proxyUrl(name, port) : mcpUrl(name, port) });
98
+ const entryGemini = (name, def, port) =>
99
+ ({ httpUrl: def.type === 'http' ? proxyUrl(name, port) : mcpUrl(name, port) });
100
+
101
+ const HOSTS = [
102
+ { id: 'vscode', label: 'VS Code', kind: 'editor', format: 'json', serversKey: 'servers', path: vscodeConfigPath, entry: entryStreamable },
103
+ { id: 'cursor', label: 'Cursor', kind: 'editor', format: 'json', serversKey: 'mcpServers', path: () => join(homedir(), '.cursor', 'mcp.json'), entry: entryClassic },
104
+ { id: 'claude', label: 'Claude Desktop', kind: 'editor', format: 'json', serversKey: 'mcpServers', path: claudeDesktopConfigPath, entry: entryClassic },
105
+ { id: 'copilot-cli', label: 'Copilot CLI', kind: 'cli', format: 'json', serversKey: 'mcpServers', path: () => join(homedir(), '.copilot', 'mcp-config.json'), entry: entryStreamable },
106
+ { id: 'gemini', label: 'Gemini CLI', kind: 'cli', format: 'json', serversKey: 'mcpServers', path: () => join(homedir(), '.gemini', 'settings.json'), entry: entryGemini },
107
+ { id: 'codex', label: 'Codex CLI', kind: 'cli', format: 'toml', path: codexConfigPath, detect: () => hasBin('codex') || existsSync(codexConfigPath()) },
108
+ { id: 'claude-code', label: 'Claude Code', kind: 'cli', format: 'native', bin: 'claude', detect: () => hasBin('claude') },
109
+ ];
110
+ const hostEntry = (id, name, def, port) => (getHost(id).entry || entryClassic)(name, def, port);
111
+ const HOST_IDS = HOSTS.map((h) => h.id);
112
+ function getHost(id) {
113
+ const h = HOSTS.find((x) => x.id === id);
114
+ if (!h) throw new Error(`unknown host "${id}" (known: ${HOST_IDS.join(' | ')})`);
115
+ return h;
116
+ }
117
+ // Back-compat helpers used throughout the CLI, now backed by the registry.
118
+ const clientConfigPath = (id) => getHost(id).path();
119
+ const clientServersKey = (id) => getHost(id).serversKey;
120
+ // Which known hosts have a config on this box.
121
+ function detectClients() {
122
+ return HOSTS.filter((h) => { try { return h.detect ? h.detect() : existsSync(h.path()); } catch { return false; } }).map((h) => h.id);
123
+ }
124
+ function defaultClient(explicit) {
125
+ return explicit || detectClients()[0] || 'vscode';
126
+ }
127
+
128
+ /* ------------------------------- install state ------------------------------ */
129
+ // state.json holds a LIST of wired hosts, each with its own port. Hosts on the same port share
130
+ // one bridge; hosts on different ports each get their own bridge (both topologies supported).
131
+ function readState() {
132
+ if (!existsSync(STATE)) return { hosts: [] };
133
+ let raw; try { raw = readJson(STATE); } catch { return { hosts: [] }; }
134
+ if (Array.isArray(raw.hosts)) return explicitInstance
135
+ ? { ...raw, hosts: raw.hosts.filter(host => host.port === selectedInstance.port) } : raw;
136
+ // migrate legacy single-host shape { client, path, port, installedAt, servers }
137
+ if (raw.client &&
138
+ (!explicitInstance || raw.port === selectedInstance.port)) return { hosts: [{ id: raw.client, path: raw.path, port: raw.port, wiredAt: raw.installedAt, servers: raw.servers || [] }] };
139
+ return { hosts: [] };
140
+ }
141
+ const writeState = (state) => writeJson(STATE, state);
142
+ function upsertHost(state, entry) {
143
+ const i = state.hosts.findIndex((h) => h.id === entry.id);
144
+ if (i >= 0) state.hosts[i] = entry; else state.hosts.push(entry);
145
+ return state;
146
+ }
147
+ const distinctPorts = (state) => explicitInstance ? [selectedInstance.port] : [...new Set(state.hosts.map((h) => h.port))];
148
+
149
+ /* --------------------------------- import ----------------------------------- */
150
+ // Pure import: merge a client's stdio + http servers into servers.json.
151
+ // P2: carry audience/auth/headers through so http servers authenticate with no hand-editing.
152
+ function importServers(client, srcPath) {
153
+ const path = srcPath || clientConfigPath(client);
154
+ if (!existsSync(path)) throw new Error(`client config not found: ${path}`);
155
+ const src = readJson(path)[clientServersKey(client)] || {};
156
+ const out = existsSync(CONFIG) ? readJson(CONFIG) : {};
157
+ let imported = 0, skipped = 0, httpNoAuth = 0;
158
+ for (const [name, def] of Object.entries(src)) {
159
+ const url = def.url || '';
160
+ if (/127\.0\.0\.1|localhost/.test(url)) { skipped++; continue; } // already a local bridge route
161
+ if (def.command) {
162
+ out[name] = { command: def.command, args: def.args || [], ...(def.env ? { env: def.env } : {}), ...(def.cwd ? { cwd: def.cwd } : {}) };
163
+ imported++;
164
+ } else if (url) {
165
+ out[name] = {
166
+ type: 'http', url,
167
+ ...(def.headers ? { headers: def.headers } : {}),
168
+ ...(def.audience ? { audience: def.audience } : {}),
169
+ ...(def.auth ? { auth: def.auth } : {}),
170
+ };
171
+ if (!def.audience && !def.auth) httpNoAuth++;
172
+ imported++;
173
+ } else skipped++;
174
+ }
175
+ writeJson(CONFIG, out);
176
+ return { imported, skipped, httpNoAuth, path };
177
+ }
178
+
179
+ function cmdImport(args) {
180
+ const client = args.from || defaultClient(args.client);
181
+ let r;
182
+ try { r = importServers(client, args.config); } catch (e) { err(e.message); process.exit(1); }
183
+ ok(`imported ${r.imported} server(s) from ${client} -> ${CONFIG}${r.skipped ? ` (${r.skipped} skipped)` : ''}`);
184
+ if (r.httpNoAuth) info(`${r.httpNoAuth} http server(s) have no auth — add an "audience"/"auth" block if they need a token.`);
185
+ else info('auth (audience/auth/headers) carried over from your client config — nothing to hand-edit.');
186
+ }
187
+
188
+ /* ----------------------------- plan / install ------------------------------- */
189
+ // An existing client entry may use a different key than our server name — VS Code's
190
+ // registry-style "io.github.Owner/name" is the common case. Recognize entries that already
191
+ // point at a bridge route for a known server so we rewrite that key in place instead of
192
+ // adding a duplicate (which would double-register the server and its tools).
193
+ const BRIDGE_URL_RE = /^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/([^/?#]+)(?:\/(?:sse|mcp))?\/?$/;
194
+ function bridgeServerName(entry) {
195
+ const url = entry && (entry.url || entry.httpUrl);
196
+ const m = typeof url === 'string' ? url.match(BRIDGE_URL_RE) : null;
197
+ return m ? decodeURIComponent(m[1]) : null;
198
+ }
199
+ // Map server name -> the existing config key that already points at it under another name.
200
+ function aliasKeys(existing, servers) {
201
+ const aliases = new Map();
202
+ for (const [k, v] of Object.entries(existing)) {
203
+ const target = bridgeServerName(v);
204
+ if (target && target !== k && servers[target] && !aliases.has(target)) aliases.set(target, k);
205
+ }
206
+ return aliases;
207
+ }
208
+
209
+ function computeRewrite(client, port) {
210
+ if (!existsSync(CONFIG)) { err(`no ${CONFIG} — run "mcp-pacemaker import --from ${client}" first`); process.exit(1); }
211
+ const servers = readJson(CONFIG);
212
+ const path = clientConfigPath(client);
213
+ const key = clientServersKey(client);
214
+ const cfg = existsSync(path) ? readJson(path) : { [key]: {} };
215
+ cfg[key] = cfg[key] || {};
216
+ const aliases = aliasKeys(cfg[key], servers);
217
+ const changes = [];
218
+ const keyFor = {};
219
+ for (const [name, def] of Object.entries(servers)) {
220
+ // An exact-name entry always wins; otherwise adopt an alias key if one already exists.
221
+ const k = cfg[key][name] !== undefined ? name : (aliases.get(name) ?? name);
222
+ keyFor[name] = k;
223
+ const desired = hostEntry(client, name, def, port);
224
+ if (JSON.stringify(cfg[key][k]) !== JSON.stringify(desired)) changes.push({ name, key: k, from: cfg[key][k], to: desired });
225
+ }
226
+ return { path, key, cfg, servers, changes, keyFor };
227
+ }
228
+
229
+ // --- Codex (TOML) + Claude Code (native) config writers ---
230
+ const tomlKey = (name) => (/^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name));
231
+ const codexBlock = (name, url) => `[mcp_servers.${tomlKey(name)}]\nurl = ${JSON.stringify(url)}\n`;
232
+ // Remove the [mcp_servers.<name>] tables we manage, preserving all other content + comments.
233
+ function stripManagedCodexTables(text, names) {
234
+ const targets = new Set(names.map((n) => `mcp_servers.${n}`));
235
+ const out = []; let skip = false;
236
+ for (const line of text.split(/\r?\n/)) {
237
+ const hdr = line.match(/^\s*\[\[?\s*(.+?)\s*\]\]?\s*$/);
238
+ if (hdr) { skip = targets.has(hdr[1].replace(/["'\s]/g, '')); if (skip) continue; }
239
+ if (!skip) out.push(line);
240
+ }
241
+ return out.join('\n');
242
+ }
243
+ // Merge existing config.toml (minus our tables) with fresh [mcp_servers.<name>] tables.
244
+ function buildCodexConfig(port) {
245
+ const path = codexConfigPath();
246
+ const servers = readJson(CONFIG);
247
+ const names = Object.keys(servers);
248
+ let text = existsSync(path) ? readFileSync(path, 'utf8') : '';
249
+ text = stripManagedCodexTables(text, names).replace(/\n{3,}/g, '\n\n').trimEnd();
250
+ const blocks = names.map((n) => codexBlock(n, servers[n].type === 'http' ? proxyUrl(n, port) : mcpUrl(n, port))).join('\n');
251
+ let merged = (text ? text + '\n\n' : '') + blocks;
252
+ parseToml(merged); // validate the result parses (throws on error)
253
+ if (!merged.endsWith('\n')) merged += '\n';
254
+ return { path, text: merged, names };
255
+ }
256
+ // Claude Code: shell out to the real `claude mcp` CLI (its ~/.claude.json is too stateful to hand-edit).
257
+ const claudeUrl = (name, def, port) => (def.type === 'http' ? proxyUrl(name, port) : mcpUrl(name, port));
258
+ function applyNativeClaude(port) {
259
+ const servers = readJson(CONFIG);
260
+ for (const [name, def] of Object.entries(servers)) {
261
+ try { execSync(`claude mcp remove --scope user ${name}`, { stdio: 'ignore' }); } catch { /* not present */ }
262
+ execSync(`claude mcp add --scope user --transport http ${name} ${claudeUrl(name, def, port)}`, { stdio: 'ignore' });
263
+ }
264
+ return Object.keys(servers);
265
+ }
266
+
267
+ function cmdPlan(args) {
268
+ const client = defaultClient(args.client);
269
+ const host = getHost(client);
270
+ const port = parseInt(args.port || DEFAULT_PORT, 10);
271
+ if (!existsSync(CONFIG)) { err(`no ${CONFIG} — run "mcp-pacemaker import" first`); process.exit(1); }
272
+ const servers = readJson(CONFIG);
273
+ if (host.format === 'toml') {
274
+ const r = buildCodexConfig(port);
275
+ info(`bridge port ${port} · ${r.names.length} server(s) · ${client} (${r.path})`);
276
+ console.log(pc.dim('\n--- config.toml (managed [mcp_servers.*] tables) ---'));
277
+ console.log(r.text.trimEnd());
278
+ console.log(pc.dim(`\nApply with "mcp-pacemaker install --client ${client}" (writes a .bak first).`));
279
+ return;
280
+ }
281
+ if (host.format === 'native') {
282
+ info(`bridge port ${port} · ${Object.keys(servers).length} server(s) · ${client} (via ${host.bin} mcp)`);
283
+ for (const [name, def] of Object.entries(servers)) console.log(` ${host.bin} mcp add --scope user --transport http ${name} ${claudeUrl(name, def, port)}`);
284
+ return;
285
+ }
286
+ const { path, changes } = computeRewrite(client, port);
287
+ info(`bridge port ${port} · ${Object.keys(servers).length} server(s) · client ${client} (${path})`);
288
+ if (!changes.length) { ok('client already wired to the bridge — no changes'); return; }
289
+ console.log(pc.bold(`\nPlanned client-config changes (${changes.length}):`));
290
+ for (const c of changes) {
291
+ const label = c.key && c.key !== c.name ? `${c.name} (as ${c.key})` : c.name;
292
+ console.log(` ${label}: ${c.from ? JSON.stringify(c.from) : '(new)'} -> ${JSON.stringify(c.to)}`);
293
+ }
294
+ console.log(pc.dim(`\nApply with "mcp-pacemaker install --client ${client}" (writes a .bak first).`));
295
+ }
296
+
297
+ async function cmdInstall(args) {
298
+ const port = parseInt(args.port || DEFAULT_PORT, 10);
299
+ servicePaths(CONFIG, port);
300
+ if (args.autostart !== false) assertDurableRoot(realpathSync(process.execPath));
301
+ if (args.autostart !== false ||
302
+ args.start !== false) {
303
+ await requireUserRuntimeContext();
304
+ assertDurableRoot(realpathSync(ROOT));
305
+ }
306
+ const client = defaultClient(args.client);
307
+ const host = getHost(client);
308
+ // P5: never wire a host to a port held by a foreign (non-pacemaker) service.
309
+ if ((await probePort(port)) === 'foreign') { err(`port ${port} is held by a non-pacemaker service — wiring here would hijack it. Re-run with --port <free port>.`); process.exit(1); }
310
+ if (!existsSync(CONFIG)) { err(`no ${CONFIG} — run "mcp-pacemaker import" first`); process.exit(1); }
311
+
312
+ let path, serverNames;
313
+ if (host.format === 'json') {
314
+ const rw = computeRewrite(client, port);
315
+ if (existsSync(rw.path)) { copyFileSync(rw.path, rw.path + '.bak'); info(`backed up ${rw.path} -> ${rw.path}.bak`); }
316
+ for (const [name, def] of Object.entries(rw.servers)) rw.cfg[rw.key][rw.keyFor[name] || name] = hostEntry(client, name, def, port);
317
+ writeJson(rw.path, rw.cfg);
318
+ path = rw.path; serverNames = Object.keys(rw.servers);
319
+ } else if (host.format === 'toml') {
320
+ const r = buildCodexConfig(port);
321
+ if (existsSync(r.path)) { copyFileSync(r.path, r.path + '.bak'); info(`backed up ${r.path} -> ${r.path}.bak`); }
322
+ mkdirSync(dirname(r.path), { recursive: true });
323
+ writeFileSync(r.path, r.text);
324
+ path = r.path; serverNames = r.names;
325
+ } else { // native
326
+ if (!hasBin(host.bin)) { err(`"${host.bin}" not found on PATH — install ${host.label} first, or use "mcp-pacemaker emit --client ${client}".`); process.exit(1); }
327
+ serverNames = applyNativeClaude(port);
328
+ path = `native (${host.bin} mcp)`;
329
+ }
330
+ ok(`wired ${serverNames.length} server(s) in ${client} -> bridge on :${port}`);
331
+ if (args.autostart !== false) {
332
+ await registerAutostart(port);
333
+ ok('registered OS auto-start');
334
+ } else info('skipped OS auto-start (--no-autostart)');
335
+ if (args.start !== false) await startBridge(port); else info('skipped bridge start (--no-start)');
336
+ const state = readState();
337
+ upsertHost(state, { id: client, path, port, wiredAt: new Date().toISOString(), servers: serverNames });
338
+ writeState(state);
339
+ ok('done — run "mcp-pacemaker status" to verify.');
340
+ if (client === 'vscode') info('reload VS Code once so it picks up the rewritten mcp.json.');
341
+ }
342
+
343
+ /* --------------------------------- init (wizard) --------------------------- */
344
+ async function cmdInit(args) {
345
+ let port = parseInt(args.port || DEFAULT_PORT, 10);
346
+ servicePaths(CONFIG, port);
347
+ if (args.autostart !== false) assertDurableRoot(realpathSync(process.execPath));
348
+ if (args.autostart !== false ||
349
+ args.start !== false) {
350
+ await requireUserRuntimeContext();
351
+ assertDurableRoot(realpathSync(ROOT));
352
+ }
353
+ p.intro(pc.bold('mcp-pacemaker setup'));
354
+ const detected = detectClients();
355
+ if (detected.length) p.log.info(`Detected host(s): ${pc.cyan(detected.map((id) => getHost(id).label).join(', '))}`);
356
+
357
+ // P5: if the chosen port is held by a foreign (non-pacemaker) service, pick another.
358
+ while ((await probePort(port)) === 'foreign') {
359
+ p.log.warn(`Port ${port} is in use by a non-pacemaker service.`);
360
+ const np = await p.text({ message: 'Choose a different bridge port:', initialValue: String(port + 1), validate: (v) => (/^\d+$/.test(v) && +v > 0 && +v < 65536 ? undefined : 'enter a valid port 1-65535') });
361
+ if (p.isCancel(np)) { p.cancel('cancelled'); process.exit(0); }
362
+ port = parseInt(np, 10);
363
+ servicePaths(CONFIG, port);
364
+ }
365
+
366
+ // Which hosts to wire (default: all detected). --client accepts one id or a comma-separated list.
367
+ let targets;
368
+ if (args.client) targets = args.client.split(',').map((s) => s.trim()).filter(Boolean);
369
+ else if (detected.length <= 1) targets = detected;
370
+ else {
371
+ const picked = await p.multiselect({
372
+ message: `Wire which host(s) to the bridge on :${port}?`,
373
+ options: detected.map((id) => ({ value: id, label: getHost(id).label, hint: getHost(id).kind })),
374
+ initialValues: detected, required: true,
375
+ });
376
+ if (p.isCancel(picked) || !picked.length) { p.cancel('cancelled — nothing changed'); process.exit(0); }
377
+ targets = picked;
378
+ }
379
+
380
+ // Import SOURCE: a json host with a config file (where the servers come from).
381
+ const jsonDetected = detected.filter((id) => getHost(id).format === 'json');
382
+ const source = args.from || jsonDetected[0] || (targets || []).find((id) => getHost(id).format === 'json') || detected[0] || 'vscode';
383
+ if (!existsSync(CONFIG) || args.from) {
384
+ const s = p.spinner();
385
+ s.start(`Importing servers from ${source}`);
386
+ let r;
387
+ try { r = importServers(source, args.config); }
388
+ catch (e) { s.stop(pc.red(e.message)); p.outro('Set up a client config first (VS Code / Cursor / Claude), then re-run.'); process.exit(1); }
389
+ s.stop(`Imported ${r.imported} server(s)${r.skipped ? ` (${r.skipped} already bridged)` : ''}`);
390
+ if (r.httpNoAuth) p.log.warn(`${r.httpNoAuth} http server(s) have no auth — add "audience"/"auth" in ~/.mcp-pacemaker/servers.json if needed.`);
391
+ } else {
392
+ p.log.info(`Using existing ${CONFIG}`);
393
+ }
394
+ if (!existsSync(CONFIG)) { p.outro('No servers to wire. Run "mcp-pacemaker import --from vscode" first.'); process.exit(1); }
395
+ if (!targets || !targets.length) { p.outro('No hosts detected. Install an MCP host (VS Code / Cursor / Claude / a CLI) first.'); process.exit(0); }
396
+
397
+ p.log.step(`Wiring ${targets.length} host(s) [${targets.join(', ')}] to the bridge on :${port} (a .bak is written per host).`);
398
+ const go = args.yes ? true : await p.confirm({ message: 'Proceed?' });
399
+ if (p.isCancel(go) || !go) { p.cancel('cancelled — nothing changed'); process.exit(0); }
400
+
401
+ // Wire each host; autostart + start the single shared bridge once, after.
402
+ for (const id of targets) {
403
+ try { await cmdInstall({ client: id, port, autostart: false, start: false }); }
404
+ catch (e) { p.log.error(`${id}: ${e.message}`); }
405
+ }
406
+ if (args.autostart !== false) {
407
+ await registerAutostart(port);
408
+ p.log.success('registered OS auto-start');
409
+ }
410
+ if (args.start !== false) await startBridge(port);
411
+
412
+ const reload = [...new Set(targets.map((id) => (id === 'vscode' ? 'reload VS Code' : `restart ${getHost(id).label}`)))].join(' · ');
413
+ p.outro(pc.green(`Done. ${reload} to pick up the bridge.\n`) + ` view it: ${pc.cyan('mcp-pacemaker status')} · ${pc.cyan('mcp-pacemaker top')} · ${pc.cyan('mcp-pacemaker dashboard')}`);
414
+ }
415
+
416
+ /* -------------------------------- upgrade ----------------------------------- */
417
+ async function cmdUpgrade(args) {
418
+ if (selectedInstance &&
419
+ (args.to || args.recover) &&
420
+ !args.instance) args.instance = selectedInstance.directory;
421
+ const managed = args.to !== undefined ||
422
+ args.sxs ||
423
+ args.plan ||
424
+ args.recover ||
425
+ args.instance ||
426
+ args.port !== undefined ||
427
+ args.yes;
428
+ if (managed) {
429
+ const { ManagedUpgrader, planUpgrade, describeUpgrade, upgradeOptions } = await import('./managed-upgrade.mjs');
430
+ const options = upgradeOptions(args);
431
+ const upgrader = new ManagedUpgrader();
432
+ if (options.recover) {
433
+ if (!options.yes) throw new Error('Recovery changes this local instance. Review the retained journal and use --yes to authorize recovery.');
434
+ console.log(JSON.stringify(await upgrader.recover(options.instance), null, 2));
435
+ return;
436
+ }
437
+ const plan = await planUpgrade(args, { home: HOME });
438
+ console.log(describeUpgrade(plan));
439
+ if (args.plan) return;
440
+ if (plan.legacy) {
441
+ const { confirmLegacyUpgrade } = await import('./legacy-installation.mjs');
442
+ const accepted = await confirmLegacyUpgrade(plan, {
443
+ yes: Boolean(args.yes), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
444
+ confirm: message => p.confirm({ message }),
445
+ });
446
+ if (!accepted) return;
447
+ } else if (!args.yes) {
448
+ const approved = await p.confirm({ message: 'Apply this local managed upgrade plan?' });
449
+ if (p.isCancel(approved) || approved !== true) return;
450
+ }
451
+ console.log(JSON.stringify(await upgrader.execute(plan), null, 2));
452
+ return;
453
+ }
454
+ if (args.self) {
455
+ const { checkForUpdate, updateGuidance } = await import('./update-check.mjs');
456
+ const result = await checkForUpdate(readJson(resolve(ROOT, 'package.json')), { registry: args.registry });
457
+ console.log(updateGuidance(result, { registry: args.registry }));
458
+ return;
459
+ }
460
+ if (args.registry !== undefined) throw new Error('--registry applies only to upgrade --self.');
461
+ await requireUserRuntimeContext();
462
+ info(`installed CLI ${pkgVersion}; upgrade rewires hosts and adopts/starts bridges. It does not replace packages or restart an adopted backend.`);
463
+ const state = readState();
464
+ if (!state.hosts.length) { err('no install state — run "init" or "install" first'); process.exit(1); }
465
+ for (const h of state.hosts) {
466
+ info(`re-wiring ${h.id} on :${h.port} from the current servers.json`);
467
+ await cmdInstall({ client: h.id, port: h.port, autostart: false, start: false });
468
+ }
469
+ for (const port of distinctPorts(state)) await startBridge(port);
470
+ ok('host wiring complete; running backend versions reported above. No package replacement or adopted-backend restart performed.');
471
+ }
472
+
473
+ /* ------------------------------ update-check -------------------------------- */
474
+ async function cmdUpdateCheck(args) {
475
+ const pkg = readJson(resolve(ROOT, 'package.json'));
476
+ try {
477
+ const { checkForUpdate, updateGuidance } = await import('./update-check.mjs');
478
+ const result = await checkForUpdate(pkg, { registry: args.registry });
479
+ if (args.json) console.log(JSON.stringify(result));
480
+ else console.log(updateGuidance(result, { registry: args.registry }));
481
+ } catch (error) {
482
+ process.exitCode = 1;
483
+ if (args.json) console.log(JSON.stringify({ name: pkg.name, current: pkg.version, status: 'unknown', error: error.message }));
484
+ else err(`update status unknown: ${error.message}`);
485
+ }
486
+ }
487
+
488
+ /* ------------------------------ start / stop -------------------------------- */
489
+ async function supervisorInvocation(port) {
490
+ const instance = await ensureManagedInstance(port);
491
+ return { cmd: instance.node, args: [join(instance.directory, 'stable-launcher.mjs')] };
492
+ }
493
+ async function ensureManagedInstance(port) {
494
+ if (selectedInstance &&
495
+ selectedInstance.port === port) return selectedInstance;
496
+ const directory = managedPath(CONFIG, port);
497
+ if (existsSync(directory)) return loadInstance(directory);
498
+ if (readAutostart(port).length) {
499
+ throw new Error('An older or unknown autostart entry exists. Exact legacy migration is required; no task or process was changed.');
500
+ }
501
+ const { stageCliDependencies } = await import('./upgrade-dependencies.mjs');
502
+ const { configuredRegistry } = await import('./upgrade-package.mjs');
503
+ const staging = join(HOME, 'packages', `bootstrap-${randomUUID()}`);
504
+ const selection = snapshotPackage(ROOT, join(staging, 'package'), { dependencies: false });
505
+ selection.files.push(...await stageCliDependencies(selection.root, {
506
+ registry: await configuredRegistry(), cache: join(staging, 'cache'),
507
+ }));
508
+ const instance = createInstance({ directory, root: selection.root, selection, config: CONFIG, port });
509
+ registerDefaultCli(HOME, instance);
510
+ return instance;
511
+ }
512
+ // P5: is anything listening on the port, and if so is it ours?
513
+ function portInUse(port) {
514
+ return new Promise((res) => {
515
+ const sock = net.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); res(true); });
516
+ sock.on('error', () => res(false));
517
+ sock.setTimeout(1000, () => { sock.destroy(); res(false); });
518
+ });
519
+ }
520
+ async function probePort(port) {
521
+ if (!(await portInUse(port))) return 'free';
522
+ const s = await httpGet(`http://127.0.0.1:${port}/status`);
523
+ if (s) { try { if (JSON.parse(s.body).service === 'mcp-pacemaker') return 'pacemaker'; } catch { /* not ours */ } }
524
+ return 'foreign';
525
+ }
526
+ async function startBridge(port) {
527
+ await requireUserRuntimeContext();
528
+ // P5: adopt an existing pacemaker bridge; refuse to collide with a foreign service.
529
+ const cls = await probePort(port);
530
+ if (cls === 'pacemaker') {
531
+ await reportBackend(port, 'adopted (not starting a second or restarting it)');
532
+ return;
533
+ }
534
+ if (cls === 'foreign') throw new Error(`port ${port} is held by a non-pacemaker service — not starting. Use --port <free port>.`);
535
+ assertDurableRoot(realpathSync(ROOT));
536
+ const instance = await ensureManagedInstance(port);
537
+ const { startInstance } = await import('./managed-runtime.mjs');
538
+ await startInstance(instance.directory);
539
+ await reportBackend(port, 'started selected managed version');
540
+ }
541
+ async function stopBridge(port) {
542
+ if (port !== undefined) {
543
+ const directory = selectedInstance?.port === port ? selectedInstance.directory : managedPath(CONFIG, port);
544
+ const root = existsSync(directory) ? loadInstance(directory).active.root : ROOT;
545
+ const stopped = await stopManagedService({ root, config: CONFIG, port });
546
+ if (await portInUse(port)) {
547
+ throw new Error(`Managed supervisor stopped, but :${port} is still in use. Another listener was not touched; replacement is not verified.`);
548
+ }
549
+ ok(`stopped managed supervisor ${stopped.supervisorId} and its bridge on :${port}; autostart held until an explicit start`);
550
+ info(`root: ${stopped.root}; config: ${stopped.config}. Other services using this root must also be stopped before replacement.`);
551
+ return;
552
+ }
553
+ const ports = distinctPorts(readState());
554
+ for (const selected of ports.length ? ports : [DEFAULT_PORT]) await stopBridge(selected);
555
+ }
556
+
557
+ // Start one bridge (--port) or all distinct-port bridges recorded in state.
558
+ async function cmdStart(opts) {
559
+ if (explicitInstance) {
560
+ const { startInstance } = await import('./managed-runtime.mjs');
561
+ console.log(JSON.stringify(await startInstance(selectedInstance.directory)));
562
+ return;
563
+ }
564
+ if (opts?.instance) {
565
+ if (opts.port) throw new Error('Use --instance or --port, not both.');
566
+ const { startInstance } = await import('./managed-runtime.mjs');
567
+ console.log(JSON.stringify(await startInstance(resolve(opts.instance))));
568
+ return;
569
+ }
570
+ const port = opts && opts.port ? parseInt(opts.port, 10) : null;
571
+ if (port) { await startBridge(port); return; }
572
+ const ports = distinctPorts(readState());
573
+ if (!ports.length) { await startBridge(DEFAULT_PORT); return; }
574
+ for (const p2 of ports) await startBridge(p2);
575
+ }
576
+
577
+ /* -------------------------------- autostart --------------------------------- */
578
+ const AUTOSTART_ID = 'io.github.girishkvs.mcp-pacemaker';
579
+ async function registerAutostart(port) {
580
+ await requireUserRuntimeContext();
581
+ assertDurableRoot(realpathSync(ROOT));
582
+ assertDurableRoot(realpathSync(process.execPath));
583
+ const plat = platform();
584
+ const { cmd, args } = await supervisorInvocation(port);
585
+ if (plat === 'win32') {
586
+ const instance = await ensureManagedInstance(port);
587
+ execFileSync('pwsh', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolve(ROOT, 'autostart', 'windows', 'register-task.ps1'), '-Port', String(port), '-TaskName', `McpPacemaker-${port}`, '-LauncherPath', join(instance.directory, 'launcher.vbs')], { stdio: 'ignore' });
588
+ recordManagedAutostart(port);
589
+ return;
590
+ }
591
+ if (plat === 'darwin') {
592
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${AUTOSTART_ID}.${port}.plist`);
593
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
594
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
595
+ <plist version="1.0"><dict>
596
+ <key>Label</key><string>${AUTOSTART_ID}.${port}</string>
597
+ <key>ProgramArguments</key><array>${[cmd, ...args].map((a) => `<string>${a.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')}</string>`).join('')}</array>
598
+ <key>RunAtLoad</key><true/>
599
+ <key>KeepAlive</key><false/>
600
+ </dict></plist>\n`;
601
+ mkdirSync(dirname(plistPath), { recursive: true });
602
+ writeFileSync(plistPath, plist);
603
+ try { execFileSync('launchctl', ['unload', plistPath], { stdio: 'ignore' }); } catch { /* first time */ }
604
+ execFileSync('launchctl', ['load', plistPath], { stdio: 'ignore' });
605
+ recordManagedAutostart(port);
606
+ return;
607
+ }
608
+ // linux (systemd --user)
609
+ const unitPath = join(homedir(), '.config', 'systemd', 'user', `mcp-pacemaker-${port}.service`);
610
+ const unit = `[Unit]
611
+ Description=mcp-pacemaker bridge supervisor
612
+ After=default.target
613
+
614
+ [Service]
615
+ Type=simple
616
+ ExecStart=${[cmd, ...args].map((a) => `"${a.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', '$$')}"`).join(' ')}
617
+ Restart=on-failure
618
+
619
+ [Install]
620
+ WantedBy=default.target
621
+ `;
622
+ mkdirSync(dirname(unitPath), { recursive: true });
623
+ writeFileSync(unitPath, unit);
624
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
625
+ execFileSync('systemctl', ['--user', 'enable', '--now', `mcp-pacemaker-${port}.service`], { stdio: 'ignore' });
626
+ recordManagedAutostart(port);
627
+ }
628
+ function recordManagedAutostart(port) {
629
+ const path = join(managedPath(CONFIG, port), 'instance.json');
630
+ durableJson(path, { ...readManagedJson(path), autostart: { kind: 'stable', records: readAutostart(port) } });
631
+ }
632
+ function unregisterAutostart(port) {
633
+ const plat = platform();
634
+ const ports = port ? [port] : distinctPorts(readState());
635
+ let removed = 0;
636
+ for (const pt of (ports.length ? ports : [DEFAULT_PORT])) {
637
+ try {
638
+ if (plat === 'win32') execFileSync('schtasks', ['/Delete', '/TN', `McpPacemaker-${pt}`, '/F'], { stdio: 'ignore' });
639
+ else if (plat === 'darwin') { const pth = join(homedir(), 'Library', 'LaunchAgents', `${AUTOSTART_ID}.${pt}.plist`); execFileSync('launchctl', ['unload', pth], { stdio: 'ignore' }); }
640
+ else execFileSync('systemctl', ['--user', 'disable', '--now', `mcp-pacemaker-${pt}.service`], { stdio: 'ignore' });
641
+ removed++;
642
+ } catch { /* not present */ }
643
+ }
644
+ try { if (plat === 'win32') execFileSync('schtasks', ['/Delete', '/TN', 'McpPacemaker', '/F'], { stdio: 'ignore' }); } catch { /* legacy none */ }
645
+ if (removed) ok(`removed OS auto-start (${removed})`); else warn('no auto-start entry found');
646
+ }
647
+
648
+ /* --------------------------------- status ----------------------------------- */
649
+ async function reportBackend(port, action) {
650
+ const response = await httpGet(`http://127.0.0.1:${port}/api/status`);
651
+ let backend;
652
+ if (response?.status === 200) {
653
+ try { backend = JSON.parse(response.body); }
654
+ catch { throw new Error(`Backend :${port} returned invalid status; version is unknown.`); }
655
+ }
656
+ if (backend?.service !== 'mcp-pacemaker') {
657
+ throw new Error(`Backend :${port} identity/version could not be verified; no upgrade is claimed.`);
658
+ }
659
+ info(`:${port}: ${backendDescription(pkgVersion, backend, action)}`);
660
+ }
661
+
662
+ function httpGet(url) {
663
+ return new Promise((res) => {
664
+ const r = http.get(url, (resp) => { let d = ''; resp.on('data', (c) => (d += c)); resp.on('end', () => res({ status: resp.statusCode, body: d })); });
665
+ r.on('error', () => res(null));
666
+ r.setTimeout(2000, () => { r.destroy(); res(null); });
667
+ });
668
+ }
669
+ async function cmdStatus() {
670
+ info(`installed CLI ${pkgVersion} (${ROOT})`);
671
+ if (selectedInstance?.port === 0) {
672
+ info('Selected managed instance has no verified persisted listener port; start or recover it explicitly.');
673
+ return;
674
+ }
675
+ const state = readState();
676
+ const ports = state.hosts.length ? distinctPorts(state) : [DEFAULT_PORT];
677
+ for (const port of ports) {
678
+ const s = await httpGet(`http://127.0.0.1:${port}/status`);
679
+ if (s && s.status === 200) {
680
+ let j = null; try { j = JSON.parse(s.body); } catch { /* noop */ }
681
+ if (j && j.service === 'mcp-pacemaker') {
682
+ ok(`bridge UP on :${port} — ${j.servers.length} server(s), ${j.sessions} active session(s)`);
683
+ await reportBackend(port, 'observed; status does not replace or restart it');
684
+ const api = await httpGet(`http://127.0.0.1:${port}/api/status`);
685
+ if (api && api.status === 200) {
686
+ try {
687
+ const snap = JSON.parse(api.body);
688
+ const list = snap.servers;
689
+ if (snap.restart && snap.restart.staleClients > 0) {
690
+ warn(` bridge restarted ${Math.round(snap.restart.sinceSec / 60)}m ago — ${snap.restart.staleClients} client(s) have not reconnected; they may need an MCP reload`);
691
+ }
692
+ const failing = list.filter((sv) => sv.health && sv.health.state === 'failing');
693
+ for (const sv of failing) err(` ${sv.name}: failing — ${sv.health.consecutiveFailures} in a row: ${sv.lastError}`);
694
+ for (const sv of list) { if (sv.sessions || (sv.clients && sv.clients.length)) info(` ${sv.name}: ${sv.sessions} session(s)${sv.clients && sv.clients.length ? ` agents=[${sv.clients.join(', ')}]` : ''}`); }
695
+ } catch { /* noop */ }
696
+ }
697
+ } else warn(`:${port} responds but is NOT an mcp-pacemaker bridge (foreign service)`);
698
+ } else err(`bridge DOWN on :${port} (start with "mcp-pacemaker start")`);
699
+ for (const h of state.hosts.filter((x) => x.port === port)) info(` ${h.id} · ${h.servers.length} server(s) · ${h.path}`);
700
+ }
701
+ if (!state.hosts.length) warn('no install state — run "mcp-pacemaker install"');
702
+ }
703
+
704
+ /* --------------------------------- reload ----------------------------------- */
705
+ function httpPost(url, nonce) {
706
+ return new Promise((res) => {
707
+ const u = new URL(url);
708
+ const r = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers: { 'x-mcp-nonce': nonce } },
709
+ (resp) => { let d = ''; resp.on('data', (c) => (d += c)); resp.on('end', () => res({ status: resp.statusCode, body: d })); });
710
+ r.on('error', () => res(null));
711
+ r.setTimeout(5000, () => { r.destroy(); res(null); });
712
+ r.end();
713
+ });
714
+ }
715
+
716
+ async function cmdReload(opts) {
717
+ const port = explicitInstance ? selectedInstance.port
718
+ : opts.port ?? (readState().hosts.length ? distinctPorts(readState())[0] : DEFAULT_PORT);
719
+ const noncePath = join(dirname(CONFIG), 'admin.nonce');
720
+ let nonce;
721
+ try { nonce = readFileSync(noncePath, 'utf8').trim(); }
722
+ catch { err(`no admin nonce at ${noncePath} — is the bridge running?`); process.exit(1); }
723
+
724
+ const r = await httpPost(`http://127.0.0.1:${port}/admin/reload`, nonce);
725
+ if (!r) { err(`bridge DOWN on :${port} (start with "mcp-pacemaker start")`); process.exit(1); }
726
+ let j = null; try { j = JSON.parse(r.body); } catch { /* noop */ }
727
+ if (r.status !== 200 || !j || !j.ok) { err(`reload rejected: ${(j && j.error) || r.body || r.status} — the bridge kept its previous config`); process.exit(1); }
728
+ if (j.unchanged) { ok(`${CONFIG} re-read on :${port} — no changes`); return; }
729
+ ok(`reloaded ${CONFIG} on :${port}`);
730
+ if (j.added.length) info(` added: ${j.added.join(', ')}`);
731
+ if (j.removed.length) info(` removed: ${j.removed.join(', ')}`);
732
+ if (j.changed.length) info(` changed: ${j.changed.join(', ')}`);
733
+ if (!j.added.length && !j.removed.length && !j.changed.length) info(' no server definitions changed');
734
+ else info(` ${j.restarted} session(s) restarted; untouched servers kept theirs`);
735
+ }
736
+
737
+ /* --------------------------------- doctor ----------------------------------- */
738
+ async function cmdDoctor() {
739
+ let fails = 0;
740
+ const major = parseInt(process.versions.node.split('.')[0], 10);
741
+ if (major >= 20) ok(`Node.js ${process.versions.node}`); else { err(`Node.js ${process.versions.node} (need >= 20)`); fails++; }
742
+
743
+ let servers = {};
744
+ if (!existsSync(CONFIG)) warn(`no ${CONFIG} — run "import" then "install"`);
745
+ else {
746
+ try { servers = readJson(CONFIG); ok(`config valid: ${Object.keys(servers).length} server(s)`); }
747
+ catch (e) { err(`config invalid JSON: ${e.message}`); fails++; }
748
+ for (const [n, d] of Object.entries(servers)) {
749
+ const reserved = checkReservedName(n);
750
+ if (reserved) { err(` ${n}: ${reserved.detail}`); fails++; continue; }
751
+ if (!d.command && !d.url) { err(` ${n}: missing "command" or "url"`); fails++; continue; }
752
+ if (d.type === 'http' && !d.auth && !d.audience && !d.headers) { warn(` ${n}: http server with no auth/headers`); continue; }
753
+ const pathCheck = checkServerPaths(n, d, HOME);
754
+ if (pathCheck?.status === 'bad') { err(` ${n}: ${pathCheck.detail}`); fails++; }
755
+ else if (pathCheck) warn(` ${n}: ${pathCheck.detail}`);
756
+ }
757
+ }
758
+
759
+ const state = readState();
760
+ const ports = state.hosts.length ? distinctPorts(state) : [DEFAULT_PORT];
761
+ for (const port of ports) {
762
+ const s = await httpGet(`http://127.0.0.1:${port}/status`);
763
+ if (s && s.status === 200) {
764
+ let svc = null; try { svc = JSON.parse(s.body).service; } catch { /* noop */ }
765
+ if (svc === 'mcp-pacemaker') {
766
+ ok(`bridge reachable on :${port} (mcp-pacemaker)`);
767
+ const api = await httpGet(`http://127.0.0.1:${port}/api/status`);
768
+ if (api && api.status === 200) {
769
+ try {
770
+ const snap = JSON.parse(api.body);
771
+ const agents = [...new Set(snap.servers.flatMap((sv) => sv.clients || []))];
772
+ if (agents.length) info(` connected agents: ${agents.join(', ')}`);
773
+ // Cold start is invisible to the person paying for it: they experience "this tool is
774
+ // slow", not "spawning costs 20s". Naming the number and the exact config that would
775
+ // fix it is the whole point of measuring — but enabling it is theirs to decide.
776
+ for (const sv of snap.servers) {
777
+ if (!sv.advice) continue;
778
+ warn(` ${sv.name}: ${sv.advice.reason}${sv.peakConcurrency ? `, peak ${sv.peakConcurrency} concurrent` : ''}`);
779
+ info(` consider: "sharing": "pool", "minWarm": ${sv.advice.suggest.minWarm} (in ${CONFIG})`);
780
+ }
781
+ } catch { /* noop */ }
782
+ }
783
+ }
784
+ else warn(`:${port} responds but is NOT an mcp-pacemaker bridge (foreign service)`);
785
+ } else warn(`bridge not reachable on :${port} (start with "mcp-pacemaker start")`);
786
+ }
787
+ for (const h of state.hosts) {
788
+ const host = getHost(h.id);
789
+ if (host.format === 'native') { ok(`${h.id} wired via ${host.bin} mcp (${h.servers.length} server(s) -> :${h.port})`); continue; }
790
+ if (!h.path || !existsSync(h.path)) { warn(`${h.id}: config not found (${h.path})`); continue; }
791
+ let wired = 0;
792
+ if (host.format === 'toml') {
793
+ try { const t = parseToml(readFileSync(h.path, 'utf8')); wired = Object.values(t.mcp_servers || {}).filter((v) => /127\.0\.0\.1/.test(v.url || '')).length; } catch { /* parse error */ }
794
+ } else {
795
+ const cfg = readJson(h.path);
796
+ wired = Object.values(cfg[clientServersKey(h.id)] || {}).filter((v) => /127\.0\.0\.1/.test(v.url || v.httpUrl || '')).length;
797
+ }
798
+ if (wired > 0) ok(`${h.id} wired (${wired} server(s) -> :${h.port})`); else warn(`${h.id} not wired to bridge`);
799
+ }
800
+ if (!state.hosts.length) warn('no install state — run "install"');
801
+
802
+ if (fails) console.log(pc.red(`\n${fails} problem(s) found${warnCount ? `, ${warnCount} warning(s)` : ''}.`));
803
+ else if (warnCount) console.log(pc.yellow(`\n${warnCount} warning(s) — no hard failures.`));
804
+ else console.log(pc.green('\nall good.'));
805
+ if (fails) process.exit(1);
806
+ }
807
+
808
+ /* ---------------------------------- emit ------------------------------------ */
809
+ function cmdEmit(args) {
810
+ const client = defaultClient(args.client);
811
+ const host = getHost(client);
812
+ const port = parseInt(args.port || DEFAULT_PORT, 10);
813
+ if (!existsSync(CONFIG)) { err(`no ${CONFIG} — run "import" first`); process.exit(1); }
814
+ const servers = readJson(CONFIG);
815
+ if (host.format === 'toml') { console.log(buildCodexConfig(port).text); return; }
816
+ if (host.format === 'native') {
817
+ for (const [name, def] of Object.entries(servers)) console.log(`${host.bin} mcp add --scope user --transport http ${name} ${claudeUrl(name, def, port)}`);
818
+ return;
819
+ }
820
+ const out = { [clientServersKey(client)]: {} };
821
+ for (const [name, def] of Object.entries(servers)) out[clientServersKey(client)][name] = hostEntry(client, name, def, port);
822
+ console.log(JSON.stringify(out, null, 2));
823
+ }
824
+
825
+ /* ------------------------------- uninstall ---------------------------------- */
826
+ async function cmdUninstall() {
827
+ await stopBridge();
828
+ unregisterAutostart();
829
+ const state = readState();
830
+ for (const h of state.hosts) {
831
+ if (!h.path || h.path.startsWith('native')) continue;
832
+ const bak = h.path + '.bak';
833
+ if (existsSync(bak)) { copyFileSync(bak, h.path); ok(`restored ${h.id} config (${h.path}) from backup`); }
834
+ else warn(`no backup at ${bak} — left ${h.id} config as-is`);
835
+ }
836
+ info('kept ~/.mcp-pacemaker/servers.json (delete it manually if you want a clean slate).');
837
+ }
838
+
839
+ /* -------------------------------- dashboard --------------------------------- */
840
+ function cmdDashboard() {
841
+ const state = readState();
842
+ const port = explicitInstance ? selectedInstance.port : state.hosts[0]?.port || DEFAULT_PORT;
843
+ const url = `http://127.0.0.1:${port}/ui`;
844
+ info(`opening ${url}`);
845
+ try {
846
+ const plat = platform();
847
+ if (plat === 'win32') spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
848
+ else if (plat === 'darwin') spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
849
+ else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
850
+ } catch (e) { warn(`could not open browser: ${e.message}. Visit ${url} manually.`); }
851
+ }
852
+
853
+ /* ---------------------------------- main (commander) ------------------------ */
854
+ const pkgVersion = readJson(resolve(ROOT, 'package.json')).version;
855
+ const program = new Command();
856
+ program
857
+ .name('mcp-pacemaker')
858
+ .description('Keep your MCP servers alive across editor restarts, reboots, and token expiry.')
859
+ .option('--instance <directory>', 'select the exact managed CLI and backend instance')
860
+ .option('--user <sid>', 'explicit account management; canonical SID and exact task/instance required')
861
+ .option('--task <path>', 'exact existing per-user Windows task for explicit account management')
862
+ .version(pkgVersion);
863
+
864
+ program.hook('preAction', (_, command) => {
865
+ const options = command.optsWithGlobals();
866
+ if (options.port !== undefined) {
867
+ options.port = canonicalPort(options.port);
868
+ command.setOptionValue('port', options.port);
869
+ }
870
+ if (explicitInstance) validateSelectedCommand(selectedInstance, command.name(), options);
871
+ });
872
+
873
+ program.command('init')
874
+ .description('Interactive setup: detect all MCP hosts (editors + CLIs), import, wire, autostart, start')
875
+ .addHelpText('after', SERVICE_PATH_HELP)
876
+ .option('--client <client>', 'wire only this host, or a comma-separated list (default: all detected)')
877
+ .option('--from <host>', 'source host to import servers.json from')
878
+ .option('--port <n>', 'bridge port', canonicalPort, DEFAULT_PORT)
879
+ .option('--config <path>', 'source client config to import from')
880
+ .option('-y, --yes', 'skip the confirmation prompt (non-interactive)')
881
+ .option('--no-autostart', 'do not register OS auto-start')
882
+ .option('--no-start', 'do not start the bridge')
883
+ .action(cmdInit);
884
+
885
+ program.command('import')
886
+ .description('Build ~/.mcp-pacemaker/servers.json from an existing host config')
887
+ .option('--from <client>', 'source host: vscode|cursor|claude|copilot-cli|gemini')
888
+ .option('--config <path>', 'source client config path')
889
+ .action(cmdImport);
890
+
891
+ program.command('plan')
892
+ .description('Dry-run: show what install would change in a host config')
893
+ .option('--client <client>', 'host id (vscode|cursor|claude|copilot-cli|gemini|codex|claude-code)')
894
+ .option('--port <n>', 'bridge port', canonicalPort, DEFAULT_PORT)
895
+ .action(cmdPlan);
896
+
897
+ program.command('install')
898
+ .description('Back up + wire a host -> bridge, register auto-start, start the bridge')
899
+ .addHelpText('after', SERVICE_PATH_HELP)
900
+ .option('--client <client>', 'host id (vscode|cursor|claude|copilot-cli|gemini|codex|claude-code)')
901
+ .option('--port <n>', 'bridge port', canonicalPort, DEFAULT_PORT)
902
+ .option('--no-autostart', 'do not register OS auto-start')
903
+ .option('--no-start', 'do not start the bridge')
904
+ .action(cmdInstall);
905
+
906
+ program.command('status').description('Is the bridge up? what is installed?')
907
+ .option('--instance <directory>', 'select an isolated managed instance')
908
+ .action(cmdStatus);
909
+ program.command('reload')
910
+ .description('Re-read servers.json into the running bridge (no restart, untouched servers keep their sessions)')
911
+ .option('--port <n>', 'bridge port', canonicalPort)
912
+ .action(cmdReload);
913
+ program.command('doctor').description('Diagnose config, bridge reachability, client wiring').action(cmdDoctor);
914
+ program.command('logs')
915
+ .description('Read durable bridge logs, including retained rotation and multiline records')
916
+ .option('--config <path>', 'bridge config path (logs are beside it)')
917
+ .option('-f, --follow', 'follow new records and rotation until interrupted')
918
+ .option('--since <time>', 'duration such as 2h, or an ISO timestamp with timezone')
919
+ .option('--server <name>', 'filter by exact server name')
920
+ .option('--grep <text>', 'case-insensitive literal text filter')
921
+ .action(async (opts) => {
922
+ const { runLogs } = await import('./logs.mjs');
923
+ await runLogs({ ...opts, ...(explicitInstance ? { config: CONFIG } : {}) });
924
+ });
925
+ program.command('prewarm')
926
+ .description('Show all stdio pre-warming candidates; changes require an explicit action')
927
+ .option('--port <n>', 'bridge port', canonicalPort)
928
+ .option('--config <path>', 'bridge config path (locates its admin nonce)')
929
+ .option('--json', 'machine-readable output')
930
+ .option('--enable <server>', 'enable pre-warming for this server')
931
+ .option('--count <n>', 'number of warm slots to enable (default: displayed recommendation)')
932
+ .option('--disable <server>', 'disable pre-warming for this server')
933
+ .option('--server <name>', 'server whose previous change is being undone')
934
+ .option('--undo <id>', 'restore the prior settings if no intervening edit occurred')
935
+ .action(async (opts) => {
936
+ const { runPrewarm } = await import('./prewarm.mjs');
937
+ const state = readState();
938
+ await runPrewarm({ ...opts, ...(explicitInstance ? { port: selectedInstance.port, config: CONFIG } : {}) }, {
939
+ configPath: CONFIG,
940
+ ports: explicitInstance ? [selectedInstance.port] : state.hosts.length ? distinctPorts(state) : [DEFAULT_PORT],
941
+ instance: explicitInstance ? selectedInstance.directory : undefined,
942
+ });
943
+ });
944
+ program.command('dashboard').description('Open the web dashboard in your browser').action(cmdDashboard);
945
+ program.command('top')
946
+ .description('Live terminal dashboard (Ink TUI)')
947
+ .option('--port <n>', 'bridge port', canonicalPort)
948
+ .action(async (opts) => {
949
+ const { runTop } = await import('./top.mjs');
950
+ runTop(explicitInstance ? selectedInstance.port : opts.port ? parseInt(opts.port, 10) : undefined,
951
+ explicitInstance ? CONFIG : undefined);
952
+ });
953
+
954
+ program.command('emit')
955
+ .description('Print host-config entries pointing at the bridge (no write)')
956
+ .option('--client <client>', 'host id (vscode|cursor|claude|copilot-cli|gemini|codex|claude-code)')
957
+ .option('--port <n>', 'bridge port', canonicalPort, DEFAULT_PORT)
958
+ .action(cmdEmit);
959
+
960
+ program.command('upgrade')
961
+ .description('Replace a managed version with --to; bare upgrade retains legacy host repair')
962
+ .addHelpText('after', '\nDefault scope is the current user, including elevated callers. Explicit original-account management uses --user <canonical SID> plus --task <exact task path> or --instance <directory>. --all-users is not implemented. Legacy restart requires interactive acknowledgement of uncertain HTTP outcomes and partial descendant coverage; --yes cannot replace it.')
963
+ .option('--to <version>', 'exact stable version to stage and activate')
964
+ .option('--sxs', 'keep the old instance; create an isolated new instance without rewiring hosts')
965
+ .option('--port <n>', 'SxS only: exact port; omitted lets the actual listener use OS-assigned port 0', canonicalPort)
966
+ .option('--instance <directory>', 'select one stable managed instance')
967
+ .option('--plan', 'read-only local plan; no package acquisition or changes')
968
+ .option('--yes', 'confirm only the displayed local upgrade/recovery plan')
969
+ .option('--recover', 'reconcile a retained activation journal for --instance')
970
+ .option('--self', 'resolve the same-major channel and print exact-version guidance (no update)')
971
+ .option('--registry <url>', 'approved npm registry override (otherwise use existing npm configuration)')
972
+ .option('--controller-root <directory>', 'existing protected operation store for explicit account management')
973
+ .action(cmdUpgrade);
974
+
975
+ program.command('update-check')
976
+ .description('Validate the installed major’s npm channel (legacy for 1.x, latest for 2.x)')
977
+ .option('--json', 'machine-readable output')
978
+ .option('--registry <url>', 'approved npm registry override (otherwise use npm configuration)')
979
+ .action(cmdUpdateCheck);
980
+
981
+ program.command('start')
982
+ .description('Start bridge(s): --port for one, else all wired bridges from state')
983
+ .option('--port <n>', 'specific bridge port', canonicalPort)
984
+ .option('--instance <directory>', 'start the persisted managed instance, including SxS')
985
+ .action(cmdStart);
986
+
987
+ program.command('stop')
988
+ .description('Stop matching managed supervisors and hold autostart: --port for one, else wired ports')
989
+ .option('--port <n>', 'specific bridge port', canonicalPort)
990
+ .option('--instance <directory>', 'stop exactly this managed instance')
991
+ .action(async (opts) => {
992
+ if (explicitInstance) {
993
+ const { stopInstance } = await import('./managed-runtime.mjs');
994
+ console.log(JSON.stringify(await stopInstance(selectedInstance)));
995
+ return;
996
+ }
997
+ if (!opts.instance) return stopBridge(opts.port === undefined ? undefined : Number(opts.port));
998
+ if (opts.port) throw new Error('Use --instance or --port, not both.');
999
+ const { stopInstance } = await import('./managed-runtime.mjs');
1000
+ console.log(JSON.stringify(await stopInstance(loadInstance(resolve(opts.instance)))));
1001
+ });
1002
+ program.command('uninstall').description('Stop bridge, remove auto-start, restore client config').action(cmdUninstall);
1003
+
1004
+ // Bare `mcp-pacemaker`: show status if installed, else run the setup wizard.
1005
+ program.action(() => (selectedInstance || existsSync(STATE) ? cmdStatus() : cmdInit({})));
1006
+
1007
+ program.parseAsync(process.argv).catch((e) => { err(e.message); process.exit(1); });