@echomem/mcp 1.4.36 → 1.4.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,7 +55,7 @@ For headless systems and development, the granular CLI commands remain available
55
55
  | `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
56
56
  | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
57
57
  | `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
58
- | `npx -y @echomem/mcp@latest update --all` | One-shot update: install the latest bridge durably and repoint detected client configs, with no browser login |
58
+ | `npx -y @echomem/mcp@latest update --all` | Bootstrap or repair the durable per-user runtime and repoint detected client configs, with no browser login |
59
59
  | `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
60
60
  | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
61
61
  | `echomem-mcp unlock` | Privately unlock the vault on this trusted device |
@@ -65,10 +65,11 @@ For headless systems and development, the granular CLI commands remain available
65
65
  | `echomem-mcp logout` | Remove stored credentials |
66
66
 
67
67
  The bridge reports its package version in MCP server instructions and in tool descriptions. It also
68
- checks npm for a newer published bridge using a cached, non-blocking check. Agents can call
69
- `echomem_update_status` to show the user whether an update exists and then run
70
- `npx -y @echomem/mcp@latest update --all` if the user agrees. The bridge does not auto-update on
71
- every MCP startup.
68
+ checks npm for a newer published bridge using a cached, non-blocking check. Standalone installations
69
+ stage compatible updates in the background under `~/.echomem/mcp-runtime` and atomically activate
70
+ them for the next MCP session; the current handshake never waits for npm. Agents can call
71
+ `echomem_update_status` to inspect progress. `ECHO_DISABLE_AUTO_UPDATE=1` disables automatic
72
+ installation, and `npx -y @echomem/mcp@latest update --all` remains the bootstrap and repair command.
72
73
 
73
74
  Agents can still call `echo_context_health` for an on-demand local context-health report. It reads
74
75
  the local Codex/Claude logs and does not require a separate process or desktop overlay.
@@ -189,14 +190,9 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
189
190
  * **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
190
191
  * **`search_others_memories`**: Search other users' public memories through MemoryFeed public search.
191
192
  * **`delete_memory`**: Delete a single personal memory through a two-step confirmation flow. First call with `memoryId` only to preview the target and receive `confirmationToken`; after the user explicitly confirms, call again with `confirmed: true` and that exact token. This deletes the memory row only and preserves raw `source_of_truth` conversation records.
192
- * **`echomem_update_status`**: Check the installed bridge against the latest published npm version. Works without login, uses cached background checks in normal operation, and returns the update command to show the user when a newer bridge exists.
193
+ * **`echomem_update_status`**: Check the installed bridge against the latest published npm version. Works without login, uses cached background checks in normal operation, and reports automatic installation state plus a fallback repair command.
193
194
  * **`echo_context_health`**: Return the local Codex/Claude context-health score as markdown. Works without login and uploads no transcript content.
194
195
 
195
- Legacy aliases are preserved for compatibility:
196
-
197
- * `search_memories_by_description_semantic` -> `search_memories`
198
- * `search_memories_by_time_range` -> `get_memories_by_time_range`
199
-
200
196
  Contract reference:
201
197
 
202
198
  * `docs/PUBLIC_API_CONTRACT_V1.md`
@@ -0,0 +1,254 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { echoConfigDir } from "./keystore.js";
6
+ import { MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION } from "./package-metadata.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const RUNTIME_LAYOUT = 1;
9
+ const UPDATE_LOCK_STALE_MS = 15 * 60 * 1000;
10
+ export function headlessRuntimeRoot() {
11
+ return path.join(echoConfigDir(), "mcp-runtime");
12
+ }
13
+ function versionsRoot() {
14
+ return path.join(headlessRuntimeRoot(), "versions");
15
+ }
16
+ function activeRuntimePath() {
17
+ return path.join(headlessRuntimeRoot(), "active.json");
18
+ }
19
+ export function headlessLauncherPath() {
20
+ return path.join(headlessRuntimeRoot(), "launcher.mjs");
21
+ }
22
+ function packageEntryForPrefix(prefix) {
23
+ return path.join(prefix, "node_modules", ...MCP_PACKAGE_NAME.split("/"), "dist", "index.js");
24
+ }
25
+ function packageJsonForPrefix(prefix) {
26
+ return path.join(prefix, "node_modules", ...MCP_PACKAGE_NAME.split("/"), "package.json");
27
+ }
28
+ function safeVersion(value) {
29
+ const version = value.trim();
30
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
31
+ throw new Error(`Invalid EchoMem MCP version: ${value}`);
32
+ }
33
+ return version;
34
+ }
35
+ function relativeEntryForVersion(version) {
36
+ return path.join("versions", version, "node_modules", ...MCP_PACKAGE_NAME.split("/"), "dist", "index.js");
37
+ }
38
+ function atomicWriteJson(file, value) {
39
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
40
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
41
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { mode: 0o600 });
42
+ fs.renameSync(temporary, file);
43
+ try {
44
+ fs.chmodSync(file, 0o600);
45
+ }
46
+ catch {
47
+ /* Best effort on platforms without POSIX permissions. */
48
+ }
49
+ }
50
+ function ensureLauncher() {
51
+ const root = headlessRuntimeRoot();
52
+ const launcher = headlessLauncherPath();
53
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
54
+ const source = [
55
+ "#!/usr/bin/env node",
56
+ 'import fs from "node:fs";',
57
+ 'import path from "node:path";',
58
+ 'import { fileURLToPath, pathToFileURL } from "node:url";',
59
+ "const root = path.dirname(fileURLToPath(import.meta.url));",
60
+ 'const active = JSON.parse(fs.readFileSync(path.join(root, "active.json"), "utf8"));',
61
+ 'if (active.layout !== 1 || typeof active.entry !== "string" || path.isAbsolute(active.entry) || active.entry.split(/[\\\\/]/).includes("..")) {',
62
+ ' throw new Error("EchoMem MCP runtime pointer is invalid; run `npx -y @echomem/mcp@latest update --all`.");',
63
+ "}",
64
+ "const entry = path.resolve(root, active.entry);",
65
+ 'if (!fs.existsSync(entry)) throw new Error("EchoMem MCP runtime is missing; run `npx -y @echomem/mcp@latest update --all`.");',
66
+ "await import(pathToFileURL(entry).href);",
67
+ "",
68
+ ].join("\n");
69
+ let current = "";
70
+ try {
71
+ current = fs.readFileSync(launcher, "utf8");
72
+ }
73
+ catch {
74
+ /* Create it below. */
75
+ }
76
+ if (current !== source)
77
+ fs.writeFileSync(launcher, source, { mode: 0o700 });
78
+ try {
79
+ fs.chmodSync(launcher, 0o700);
80
+ }
81
+ catch {
82
+ /* Best effort on platforms without POSIX permissions. */
83
+ }
84
+ return launcher;
85
+ }
86
+ function readPackageVersion(prefix) {
87
+ try {
88
+ const parsed = JSON.parse(fs.readFileSync(packageJsonForPrefix(prefix), "utf8"));
89
+ return typeof parsed.version === "string" ? parsed.version : undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ export function readHeadlessRuntimeInstallation() {
96
+ try {
97
+ const active = JSON.parse(fs.readFileSync(activeRuntimePath(), "utf8"));
98
+ if (active.layout !== RUNTIME_LAYOUT || typeof active.version !== "string" || typeof active.entry !== "string") {
99
+ return undefined;
100
+ }
101
+ if (path.isAbsolute(active.entry) || active.entry.split(/[\\/]/).includes(".."))
102
+ return undefined;
103
+ const entry = path.resolve(headlessRuntimeRoot(), active.entry);
104
+ const expectedRoot = `${path.resolve(headlessRuntimeRoot())}${path.sep}`;
105
+ if (!entry.startsWith(expectedRoot) || !fs.existsSync(entry))
106
+ return undefined;
107
+ const prefix = path.join(versionsRoot(), active.version);
108
+ if (readPackageVersion(prefix) !== active.version)
109
+ return undefined;
110
+ return { version: active.version, launcher: ensureLauncher(), entry };
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ function activateRuntime(prefix, version) {
117
+ const entry = packageEntryForPrefix(prefix);
118
+ if (!fs.existsSync(entry) || readPackageVersion(prefix) !== version) {
119
+ throw new Error(`${MCP_PACKAGE_NAME}@${version} did not install a valid MCP entry point`);
120
+ }
121
+ const launcher = ensureLauncher();
122
+ atomicWriteJson(activeRuntimePath(), {
123
+ layout: RUNTIME_LAYOUT,
124
+ version,
125
+ entry: relativeEntryForVersion(version),
126
+ activatedAt: new Date().toISOString(),
127
+ });
128
+ return { version, launcher, entry };
129
+ }
130
+ function resolveNpmInvocation() {
131
+ const candidates = [
132
+ process.env.npm_execpath,
133
+ path.resolve(path.dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
134
+ path.resolve(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
135
+ ].filter((candidate) => Boolean(candidate));
136
+ for (const npmCli of candidates) {
137
+ try {
138
+ if (fs.existsSync(npmCli))
139
+ return { command: process.execPath, prefixArgs: [npmCli] };
140
+ }
141
+ catch {
142
+ /* Keep looking. */
143
+ }
144
+ }
145
+ return { command: process.platform === "win32" ? "npm.cmd" : "npm", prefixArgs: [] };
146
+ }
147
+ function npmInstallArguments(prefix, version) {
148
+ return [
149
+ "install",
150
+ "--ignore-scripts",
151
+ "--omit=dev",
152
+ "--no-audit",
153
+ "--no-fund",
154
+ "--no-package-lock",
155
+ "--prefix",
156
+ prefix,
157
+ `${MCP_PACKAGE_NAME}@${version}`,
158
+ ];
159
+ }
160
+ function prepareStaging(version) {
161
+ const destination = path.join(versionsRoot(), version);
162
+ fs.mkdirSync(versionsRoot(), { recursive: true, mode: 0o700 });
163
+ const staging = path.join(versionsRoot(), `.${version}-${process.pid}-${Date.now()}`);
164
+ fs.rmSync(staging, { recursive: true, force: true });
165
+ return { destination, staging };
166
+ }
167
+ function finishStaging(staging, destination, version) {
168
+ if (readPackageVersion(staging) !== version || !fs.existsSync(packageEntryForPrefix(staging))) {
169
+ throw new Error(`npm installed an invalid ${MCP_PACKAGE_NAME}@${version} runtime`);
170
+ }
171
+ if (fs.existsSync(destination))
172
+ fs.rmSync(destination, { recursive: true, force: true });
173
+ fs.renameSync(staging, destination);
174
+ return activateRuntime(destination, version);
175
+ }
176
+ export function installHeadlessRuntimeSync(targetVersion = MCP_PACKAGE_VERSION) {
177
+ const version = safeVersion(targetVersion);
178
+ const existing = readHeadlessRuntimeInstallation();
179
+ if (existing?.version === version)
180
+ return existing;
181
+ const destination = path.join(versionsRoot(), version);
182
+ if (readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
183
+ return activateRuntime(destination, version);
184
+ }
185
+ const { staging } = prepareStaging(version);
186
+ const npm = resolveNpmInvocation();
187
+ try {
188
+ execFileSync(npm.command, [...npm.prefixArgs, ...npmInstallArguments(staging, version)], {
189
+ stdio: "inherit",
190
+ windowsHide: true,
191
+ });
192
+ return finishStaging(staging, destination, version);
193
+ }
194
+ catch (error) {
195
+ fs.rmSync(staging, { recursive: true, force: true });
196
+ throw new Error(`Could not stage ${MCP_PACKAGE_NAME}@${version}: ${error instanceof Error ? error.message : String(error)}`);
197
+ }
198
+ }
199
+ function acquireUpdateLock() {
200
+ const lock = path.join(headlessRuntimeRoot(), "update.lock");
201
+ fs.mkdirSync(headlessRuntimeRoot(), { recursive: true, mode: 0o700 });
202
+ try {
203
+ const descriptor = fs.openSync(lock, "wx", 0o600);
204
+ fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
205
+ fs.closeSync(descriptor);
206
+ }
207
+ catch {
208
+ try {
209
+ const age = Date.now() - fs.statSync(lock).mtimeMs;
210
+ if (age <= UPDATE_LOCK_STALE_MS)
211
+ return undefined;
212
+ fs.rmSync(lock, { force: true });
213
+ return acquireUpdateLock();
214
+ }
215
+ catch {
216
+ return undefined;
217
+ }
218
+ }
219
+ return () => fs.rmSync(lock, { force: true });
220
+ }
221
+ export async function autoUpdateHeadlessRuntime(targetVersion) {
222
+ if (process.env.ECHO_DISABLE_AUTO_UPDATE === "1")
223
+ return { state: "disabled" };
224
+ let version;
225
+ try {
226
+ version = safeVersion(targetVersion);
227
+ }
228
+ catch (error) {
229
+ return { state: "failed", error: error instanceof Error ? error.message : String(error) };
230
+ }
231
+ if (readHeadlessRuntimeInstallation()?.version === version)
232
+ return { state: "already-installed" };
233
+ const releaseLock = acquireUpdateLock();
234
+ if (!releaseLock)
235
+ return { state: "busy" };
236
+ const { destination, staging } = prepareStaging(version);
237
+ const npm = resolveNpmInvocation();
238
+ try {
239
+ await execFileAsync(npm.command, [...npm.prefixArgs, ...npmInstallArguments(staging, version)], {
240
+ timeout: 120_000,
241
+ windowsHide: true,
242
+ maxBuffer: 1024 * 1024,
243
+ });
244
+ const installation = finishStaging(staging, destination, version);
245
+ return { state: "installed", installation };
246
+ }
247
+ catch (error) {
248
+ fs.rmSync(staging, { recursive: true, force: true });
249
+ return { state: "failed", error: error instanceof Error ? error.message : String(error) };
250
+ }
251
+ finally {
252
+ releaseLock();
253
+ }
254
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { runCli } from "./setup.js";
15
15
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS, MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, } from "./package-metadata.js";
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
17
17
  import { checkLatestUpdateStatus, formatUpdateNotice, formatUpdateStatusText, startBackgroundUpdateCheck, } from "./update-check.js";
18
+ import { autoUpdateHeadlessRuntime } from "./headless-runtime.js";
18
19
  const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
19
20
  const ECHO_PRICING_URL = process.env.ECHO_PRICING_URL || "https://echoknows.com/account";
20
21
  const ECHO_MEMORY_WEB_URL = (process.env.ECHO_MEMORY_WEB_URL || "https://echoknows.com/memory").replace(/\/$/, "");
@@ -1216,7 +1217,7 @@ class EchoMemMCPServer {
1216
1217
  this.events = new EventLogger({ session_id: this.client.getSessionId(), app_version: SERVER_VERSION });
1217
1218
  if (!DESKTOP_MANAGED) {
1218
1219
  startBackgroundUpdateCheck((status) => {
1219
- this.updateStatus = status;
1220
+ this.handleUpdateStatus(status);
1220
1221
  });
1221
1222
  }
1222
1223
  this.setupToolHandlers();
@@ -1226,6 +1227,33 @@ class EchoMemMCPServer {
1226
1227
  process.exit(0);
1227
1228
  });
1228
1229
  }
1230
+ handleUpdateStatus(status) {
1231
+ this.updateStatus = status;
1232
+ if (!status.updateAvailable || !status.latestVersion)
1233
+ return;
1234
+ this.updateStatus = { ...status, autoUpdateState: "installing" };
1235
+ void autoUpdateHeadlessRuntime(status.latestVersion).then((result) => {
1236
+ if (result.state === "installed" || result.state === "already-installed") {
1237
+ this.updateStatus = { ...status, autoUpdateState: "ready" };
1238
+ }
1239
+ else if (result.state === "busy") {
1240
+ this.updateStatus = { ...status, autoUpdateState: "installing" };
1241
+ }
1242
+ else {
1243
+ this.updateStatus = {
1244
+ ...status,
1245
+ autoUpdateState: "failed",
1246
+ error: result.state === "failed" ? result.error : "automatic updates are disabled",
1247
+ };
1248
+ }
1249
+ }).catch((error) => {
1250
+ this.updateStatus = {
1251
+ ...status,
1252
+ autoUpdateState: "failed",
1253
+ error: error instanceof Error ? error.message : String(error),
1254
+ };
1255
+ });
1256
+ }
1229
1257
  getMcpClientAnalytics() {
1230
1258
  const hostPlatform = normalizeMcpHostPlatform(this.mcpClientName)
1231
1259
  ?? detectMcpHostFromEnv()
@@ -1320,8 +1348,8 @@ class EchoMemMCPServer {
1320
1348
  }
1321
1349
  const force = isRecord(request.params.arguments) && request.params.arguments.force === true;
1322
1350
  const status = await checkLatestUpdateStatus({ force });
1323
- this.updateStatus = status;
1324
- return { content: [{ type: "text", text: formatUpdateStatusText(status) }] };
1351
+ this.handleUpdateStatus(status);
1352
+ return { content: [{ type: "text", text: formatUpdateStatusText(this.updateStatus ?? status) }] };
1325
1353
  }
1326
1354
  if (canonicalName === canonicalToolNames.contextHealth) {
1327
1355
  const client = isRecord(request.params.arguments) && typeof request.params.arguments.client === "string"
@@ -2319,10 +2347,7 @@ Details: ${m.details || "N/A"}`;
2319
2347
  return {
2320
2348
  content: [{
2321
2349
  type: "text",
2322
- text: [
2323
- scopeText,
2324
- `No sharing decision was recorded for ${groupName}. The checkpoint remains private. Ask again at a later qualifying checkpoint; never interpret decline or cancel as No.`,
2325
- ].filter(Boolean).join("\n\n"),
2350
+ text: this.sharingFallbackText(scopeText, groupName),
2326
2351
  }],
2327
2352
  };
2328
2353
  }
@@ -28,13 +28,13 @@ export const MCP_VAULT_UNLOCK_INSTRUCTION = MCP_DESKTOP_MANAGED
28
28
  ? "open Echo Desktop and unlock the vault there"
29
29
  : "run `echomem-mcp unlock` locally";
30
30
  const MCP_UPDATE_INSTRUCTION = MCP_DESKTOP_MANAGED
31
- ? "Echo Desktop manages this MCP runtime; install an Echo Desktop update when one is offered"
32
- : `update once with \`${MCP_UPDATE_ALL_COMMAND}\``;
31
+ ? "Echo Desktop auto-installs compatible MCP updates"
32
+ : `updates auto-install in the background; on failure run \`${MCP_UPDATE_ALL_COMMAND}\``;
33
33
  export const MEMORY_CITATION_INSTRUCTION = 'If the user-facing answer materially relies on one or more EchoMem memories, end it with a compact "EchoMem sources:" list containing only the memories actually used. For memories owned by teammates or accepted friends, call record_memory_citations immediately before the final answer with those exact Memory IDs. Do not cite memories that were merely retrieved. Use each memory key as the Markdown label and its canonical https://echoknows.com/memory/<memory-id> URL. Omit the sources section and citation receipt when no memory informed the answer.';
34
34
  export const SAVED_MEMORY_RECEIPT_INSTRUCTION = 'After save_conversation succeeds and returns one or more saved memory IDs, make the save visible in the final user-facing answer with a compact "EchoMem saved:" list containing every memory created by that call. Use each memory key as the Markdown label and its canonical https://echoknows.com/memory/<memory-id> URL. This save receipt is separate from "EchoMem sources:" and does not imply that the newly saved memories informed the answer.';
35
35
  export const MCP_SERVER_INSTRUCTIONS = [
36
36
  `${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
37
- `If this bridge is stale, ${MCP_UPDATE_INSTRUCTION} and start a new MCP session; never auto-update at startup.`,
37
+ `If stale, ${MCP_UPDATE_INSTRUCTION}; restart the MCP session afterward. Updates never delay startup.`,
38
38
  "Before re-deriving prior decisions or preferences, use search_memories.",
39
39
  `Before the final response for a durable decision, implementation, fix, commit, passing verification, release, or milestone, call save_conversation. Skip secrets and trivial work. If the encrypted vault is locked, tell the user to ${MCP_VAULT_UNLOCK_INSTRUCTION}.`,
40
40
  "After a successful save, show every memory created by that call in a compact EchoMem saved: list with canonical links; this is separate from \"EchoMem sources:\".",
@@ -45,5 +45,5 @@ export const MCP_SERVER_INSTRUCTIONS = [
45
45
  "Group profiles, conversation sharing, publication, sensitive-memory flags, and deletion require explicit user confirmation. Never store or log an echo_grp_ invite code.",
46
46
  ].join(" ");
47
47
  export function withMcpVersion(description) {
48
- return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, ${MCP_UPDATE_INSTRUCTION}, then start a new MCP session. Do not run updates repeatedly or on every startup.`;
48
+ return `${description}\n\nEchoMem MCP bridge: ${MCP_PACKAGE_LABEL}. If this version is stale, ${MCP_UPDATE_INSTRUCTION}, then start a new MCP session. Do not run manual updates repeatedly.`;
49
49
  }
package/dist/setup.js CHANGED
@@ -34,6 +34,7 @@ import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
34
34
  import { installSaveCheckpointHooks } from "./hud/hooks.js";
35
35
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
36
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
37
+ import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, } from "./headless-runtime.js";
37
38
  // The setup dashboard, account login, and encryption passphrase entry are all served by this
38
39
  // localhost bridge. The hosted API only sends OTP email, verifies the code, and mints a device token.
39
40
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
@@ -239,6 +240,10 @@ export function buildServerEntry(opts = {}) {
239
240
  if (opts.devEntryPath) {
240
241
  return { command: "node", args: [opts.devEntryPath] };
241
242
  }
243
+ const managed = readHeadlessRuntimeInstallation();
244
+ if (managed) {
245
+ return { command: process.execPath, args: [managed.launcher] };
246
+ }
242
247
  // Spawn the ALREADY-INSTALLED bridge directly (this node + this script's real path) instead of
243
248
  // `npx -y @echomem/mcp`. `npx -y` re-resolves and, on a cache miss, NETWORK-fetches from the npm
244
249
  // registry on EVERY client start — on a slow/flaky network that delays the MCP handshake past the
@@ -291,51 +296,12 @@ function resolveGlobalEntry() {
291
296
  }
292
297
  return null;
293
298
  }
294
- export function needsDurableGlobalUpdate(runningEntry, durableEntry, targetVersion = MCP_PACKAGE_VERSION) {
295
- if (!isEphemeralNpxPath(runningEntry))
296
- return false;
297
- const installedVersion = durableEntry
298
- ? packageVersionFromPath(durableEntry)?.version
299
- : undefined;
300
- return installedVersion !== targetVersion;
301
- }
302
- function installDurableGlobalUpdate() {
303
- const runningEntry = (() => {
304
- try {
305
- return fs.realpathSync(process.argv[1] || "");
306
- }
307
- catch {
308
- return process.argv[1] || "";
309
- }
310
- })();
311
- const currentGlobalEntry = resolveGlobalEntry();
312
- if (!needsDurableGlobalUpdate(runningEntry, currentGlobalEntry))
299
+ function installDurableHeadlessRuntime() {
300
+ if (process.env.ECHO_DISABLE_RUNTIME_BOOTSTRAP === "1")
313
301
  return;
314
- const packageSpec = `${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`;
315
- console.log(`Installing durable ${packageSpec} before updating client configs…`);
316
- const npmExecPath = process.env.npm_execpath;
317
- try {
318
- if (npmExecPath && fs.existsSync(npmExecPath)) {
319
- execFileSync(process.execPath, [npmExecPath, "install", "-g", packageSpec], {
320
- stdio: "inherit",
321
- });
322
- }
323
- else {
324
- execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", packageSpec], {
325
- stdio: "inherit",
326
- });
327
- }
328
- }
329
- catch (error) {
330
- throw new Error(`Could not install ${packageSpec} globally: ${error instanceof Error ? error.message : String(error)}`);
331
- }
332
- const installedEntry = resolveGlobalEntry();
333
- const installedVersion = installedEntry
334
- ? packageVersionFromPath(installedEntry)?.version
335
- : undefined;
336
- if (installedVersion !== MCP_PACKAGE_VERSION) {
337
- throw new Error(`Global EchoMem bridge is ${installedVersion || "missing"} after update; expected ${MCP_PACKAGE_VERSION}.`);
338
- }
302
+ console.log(`Staging durable per-user ${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION} runtime…`);
303
+ const installation = installHeadlessRuntimeSync();
304
+ console.log(`✅ Activated ${MCP_PACKAGE_NAME}@${installation.version} for new MCP sessions.`);
339
305
  }
340
306
  /** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
341
307
  export function codexTomlBlock(entry) {
@@ -450,8 +416,30 @@ export function writeJsonClientConfig(configPath, entry) {
450
416
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
451
417
  }
452
418
  export function writeClaudeCodeConfig(entry) {
419
+ const addArguments = ["mcp", "add-json", "-s", "local", "echomem", JSON.stringify(entry)];
453
420
  try {
454
- execFileSync("claude", ["mcp", "add-json", "echomem", JSON.stringify(entry)], {
421
+ execFileSync("claude", addArguments, {
422
+ encoding: "utf8",
423
+ stdio: ["ignore", "pipe", "pipe"],
424
+ timeout: 10000,
425
+ });
426
+ return "wrote";
427
+ }
428
+ catch (error) {
429
+ const stderr = error.stderr;
430
+ const detail = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr ?? "");
431
+ if (!detail.includes("already exists"))
432
+ return "unavailable";
433
+ }
434
+ // Claude Code's CLI will not replace a same-name local server. Once the replacement entry is
435
+ // fully constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
436
+ try {
437
+ execFileSync("claude", ["mcp", "remove", "echomem", "-s", "local"], {
438
+ encoding: "utf8",
439
+ stdio: ["ignore", "pipe", "pipe"],
440
+ timeout: 10000,
441
+ });
442
+ execFileSync("claude", addArguments, {
455
443
  encoding: "utf8",
456
444
  stdio: ["ignore", "pipe", "pipe"],
457
445
  timeout: 10000,
@@ -2652,6 +2640,14 @@ function parseFlags(argv) {
2652
2640
  return flags;
2653
2641
  }
2654
2642
  async function cmdSetup(flags) {
2643
+ if (!flags.dev && !flags["skip-runtime-install"]) {
2644
+ try {
2645
+ installDurableHeadlessRuntime();
2646
+ }
2647
+ catch (error) {
2648
+ console.log(`ℹ️ Automatic runtime staging was unavailable; keeping the existing bridge (${error instanceof Error ? error.message : String(error)}).`);
2649
+ }
2650
+ }
2655
2651
  const entry = buildServerEntry({ devEntryPath: typeof flags.dev === "string" ? flags.dev : undefined });
2656
2652
  const requested = typeof flags.client === "string" ? flags.client : undefined;
2657
2653
  const targets = selectSetupTargets(requested, Boolean(flags.all));
@@ -2800,8 +2796,8 @@ function writeSaveCheckpointHooksForTargets(targets) {
2800
2796
  }
2801
2797
  async function cmdUpdate(flags) {
2802
2798
  if (!flags.dev)
2803
- installDurableGlobalUpdate();
2804
- await cmdSetup({ ...flags, "skip-login": true });
2799
+ installDurableHeadlessRuntime();
2800
+ await cmdSetup({ ...flags, "skip-login": true, "skip-runtime-install": true });
2805
2801
  console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
2806
2802
  }
2807
2803
  function selectSetupTargets(requested, all) {
@@ -107,10 +107,18 @@ export function startBackgroundUpdateCheck(onStatus) {
107
107
  export function formatUpdateNotice(status) {
108
108
  if (!status?.updateAvailable || !status.latestVersion)
109
109
  return undefined;
110
+ if (status.autoUpdateState === "ready") {
111
+ return `EchoMem ${status.latestVersion} was installed automatically. Start a new agent/MCP session to use it.`;
112
+ }
113
+ if (status.autoUpdateState === "installing") {
114
+ return `EchoMem ${status.latestVersion} is downloading automatically in the background. The next agent/MCP session will use it.`;
115
+ }
116
+ if (status.autoUpdateState === "failed") {
117
+ return `EchoMem ${status.latestVersion} could not be installed automatically. Run \`${status.command}\`, then start a new agent/MCP session.`;
118
+ }
110
119
  return [
111
120
  `EchoMem update available: installed ${status.currentVersion}, latest ${status.latestVersion}.`,
112
- `Offer to update, then run \`${status.command}\` if the user agrees.`,
113
- "After updating, start a new agent/MCP session.",
121
+ "It will install automatically in the background for the next agent/MCP session.",
114
122
  ].join(" ");
115
123
  }
116
124
  export function formatUpdateStatusText(status) {
@@ -120,8 +128,14 @@ export function formatUpdateStatusText(status) {
120
128
  `Update available: ${status.updateAvailable ? "yes" : "no"}`,
121
129
  ];
122
130
  if (status.updateAvailable) {
123
- lines.push(`Update command: ${status.command}`);
124
- lines.push("After updating, start a new agent/MCP session.");
131
+ if (status.autoUpdateState === "ready")
132
+ lines.push("Automatic update: installed for the next agent/MCP session");
133
+ else if (status.autoUpdateState === "installing")
134
+ lines.push("Automatic update: installing in the background");
135
+ else if (status.autoUpdateState === "failed")
136
+ lines.push(`Automatic update: failed; fallback command: ${status.command}`);
137
+ else
138
+ lines.push("Automatic update: scheduled in the background");
125
139
  }
126
140
  if (status.checkedAt)
127
141
  lines.push(`Checked at: ${status.checkedAt} (${status.source})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.36",
3
+ "version": "1.4.38",
4
4
  "description": "EchoMem MCP bridge: cloud-first memory tools and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "test:ui": "npm run build && node test/setup-ui.test.mjs",
34
34
  "test:onboarding-resilience": "npm run build && node test/onboarding-resilience.test.mjs",
35
35
  "test:billing-ui": "npm run build && node test/setup-ui.test.mjs billing",
36
- "test": "npm run build && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/onboarding-resilience.test.mjs && node test/local-auth.test.mjs && node test/retrieval-only.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/group-map.test.mjs && node test/update-check.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/save-checkpoint-hook.test.mjs",
36
+ "test": "npm run build && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/onboarding-resilience.test.mjs && node test/local-auth.test.mjs && node test/retrieval-only.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/group-map.test.mjs && node test/update-check.test.mjs && node test/headless-runtime.test.mjs && node test/claude-code-config.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/restart-recovery.test.mjs && node test/save-checkpoint-hook.test.mjs",
37
37
  "prepack": "npm run build && node scripts/bundle-city.mjs"
38
38
  },
39
39
  "dependencies": {