@echomem/mcp 1.4.49 → 1.4.50

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.
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation } from "./headless-runtime.js";
6
+ import { MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION } from "./package-metadata.js";
7
+ /**
8
+ * Resolving a DURABLE path to this package's own files.
9
+ *
10
+ * Anything we write into another program's config — an MCP server entry, a lifecycle hook command —
11
+ * outlives the process that wrote it. Pinning such a path to the npx cache (`…/_npx/<hash>/…`) works
12
+ * until npm garbage-collects that directory, after which the entry silently points at nothing. So
13
+ * prefer, in order: the managed runtime under ~/.echomem, the running copy when it is not itself
14
+ * ephemeral, then a global install.
15
+ */
16
+ /** True when a resolved entry lives inside npx's throwaway cache (`…/_npx/<hash>/…`). */
17
+ export function isEphemeralNpxPath(entry) {
18
+ return entry.split(path.sep).includes("_npx");
19
+ }
20
+ /**
21
+ * Locate a DURABLE global install of the bridge (the one `npm i -g @echomem/mcp` creates). Global
22
+ * modules sit next to the running node — `<node>/../lib/node_modules` (nvm/unix) or `<node>/node_modules`
23
+ * (Windows). Returns the realpath'd dist entry, or null when the package isn't globally installed.
24
+ */
25
+ export function resolveGlobalEntry() {
26
+ const nodeDir = path.dirname(process.execPath);
27
+ const pkgParts = MCP_PACKAGE_NAME.split("/"); // ["@echomem", "mcp"]
28
+ const candidates = [
29
+ path.join(nodeDir, "..", "lib", "node_modules", ...pkgParts, "dist", "index.js"),
30
+ path.join(nodeDir, "node_modules", ...pkgParts, "dist", "index.js"),
31
+ ];
32
+ for (const candidate of candidates) {
33
+ try {
34
+ if (fs.existsSync(candidate))
35
+ return fs.realpathSync(candidate);
36
+ }
37
+ catch {
38
+ /* keep trying */
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ /** The dist directory this module was loaded from. */
44
+ function runningDistDir() {
45
+ return fileURLToPath(new URL("./", import.meta.url));
46
+ }
47
+ /**
48
+ * Absolute path to `dist/<relative>` from the most durable copy of this package available.
49
+ *
50
+ * `ephemeral` in the result flags the last-resort case: no durable copy exists, so the caller is
51
+ * about to persist a path that npm may collect. A working hook now beats no hook at all, but the
52
+ * caller should say so rather than fail silently.
53
+ */
54
+ export function resolveDurableDistPath(relative) {
55
+ const managed = readHeadlessRuntimeInstallation();
56
+ if (managed?.entry) {
57
+ const candidate = path.join(path.dirname(managed.entry), relative);
58
+ if (fs.existsSync(candidate))
59
+ return { path: candidate, ephemeral: false };
60
+ }
61
+ const local = path.join(runningDistDir(), relative);
62
+ const localIsEphemeral = isEphemeralNpxPath(local);
63
+ if (!localIsEphemeral && fs.existsSync(local))
64
+ return { path: local, ephemeral: false };
65
+ const globalEntry = resolveGlobalEntry();
66
+ if (globalEntry) {
67
+ const candidate = path.join(path.dirname(globalEntry), relative);
68
+ if (fs.existsSync(candidate))
69
+ return { path: candidate, ephemeral: false };
70
+ }
71
+ return { path: local, ephemeral: localIsEphemeral };
72
+ }
73
+ /** Set on the child so a re-exec can never recurse. */
74
+ const REEXEC_GUARD = "ECHO_DURABLE_REEXEC";
75
+ /**
76
+ * Hand a config-writing command over to the durable runtime before it writes anything.
77
+ *
78
+ * `npx @echomem/mcp init` runs from `_npx/<hash>`, a directory npm garbage-collects. Every path such
79
+ * a process persists into someone else's config — hook commands, MCP entries — inherits that
80
+ * lifetime. Re-executing from `~/.echomem/mcp-runtime` first makes those writes durable by
81
+ * construction, instead of relying on each call site to remember.
82
+ *
83
+ * Version skew is resolved by upgrading rather than skipping: the durable copy is installed at the
84
+ * invoking package's version before control passes to it, so a newer `npx` never hands off to an
85
+ * older runtime. Installation is idempotent, so the child re-verifying costs nothing.
86
+ *
87
+ * Returns the child's exit status when it ran (the caller should exit with it), or null when the
88
+ * command should proceed in this process. Every failure path returns null: a re-exec that cannot
89
+ * happen must degrade to the old behaviour, never dead-end.
90
+ */
91
+ export function reexecFromDurableRuntime(argv) {
92
+ if (process.env[REEXEC_GUARD] === "1")
93
+ return null;
94
+ if (process.env.ECHO_DISABLE_RUNTIME_BOOTSTRAP === "1")
95
+ return null;
96
+ if (!isEphemeralNpxPath(runningDistDir()))
97
+ return null;
98
+ // Version comes from the package.json beside dist/. If that could not be read we would ask the
99
+ // registry for a version that does not exist, so stay in-process rather than spend the round trip.
100
+ if (MCP_PACKAGE_VERSION === "0.0.0")
101
+ return null;
102
+ let installation = readHeadlessRuntimeInstallation();
103
+ if (installation?.version !== MCP_PACKAGE_VERSION) {
104
+ try {
105
+ installation = installHeadlessRuntimeSync(MCP_PACKAGE_VERSION);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ if (!installation?.entry || !fs.existsSync(installation.entry))
112
+ return null;
113
+ console.log(`Running from the durable ${MCP_PACKAGE_NAME}@${installation.version} runtime so configured paths outlive the npx cache.`);
114
+ const result = spawnSync(process.execPath, [installation.entry, ...argv], {
115
+ stdio: "inherit",
116
+ env: { ...process.env, [REEXEC_GUARD]: "1" },
117
+ windowsHide: true,
118
+ });
119
+ if (result.error)
120
+ return null;
121
+ return result.status ?? 0;
122
+ }
package/dist/hud/hooks.js CHANGED
@@ -1,7 +1,20 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
4
+ import { resolveDurableDistPath } from "../durable-entry.js";
5
+ /**
6
+ * Hook commands are persisted into another program's settings and must keep working long after this
7
+ * process exits. Resolving them from `import.meta.url` pins them to whatever copy happens to be
8
+ * running — under `npx` that is the `_npx/<hash>` cache, which npm garbage-collects, leaving a hook
9
+ * that points at nothing. Always resolve through the durable-entry helper instead.
10
+ */
11
+ function hookCommandFor(distRelative, subcommand) {
12
+ const cli = resolveDurableDistPath(distRelative);
13
+ if (cli.ephemeral) {
14
+ console.warn(`⚠️ EchoMem hooks are being pinned to a temporary npx path (${cli.path}). They will stop working once npm clears its cache — install the durable runtime or a global package to make them permanent.`);
15
+ }
16
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(cli.path)} ${subcommand}`;
17
+ }
5
18
  export function installHooks(mode) {
6
19
  const written = [];
7
20
  if (mode === "codex" || mode === "both" || mode === "auto") {
@@ -36,7 +49,7 @@ function installCodexHooks() {
36
49
  const dir = path.join(os.homedir(), ".codex");
37
50
  const file = path.join(dir, "hooks.json");
38
51
  fs.mkdirSync(dir, { recursive: true });
39
- const hookCommand = `${JSON.stringify(process.execPath)} ${JSON.stringify(process.argv[1])} summary --client codex --json`;
52
+ const hookCommand = hookCommandFor("index.js", "summary --client codex --json");
40
53
  const content = readHooksFile(file);
41
54
  content.hooks = content.hooks || {};
42
55
  content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
@@ -46,12 +59,10 @@ function installCodexHooks() {
46
59
  return file;
47
60
  }
48
61
  function saveCheckpointCommand() {
49
- const lifecycleCli = fileURLToPath(new URL("./cli.js", import.meta.url));
50
- return `${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycleCli)} save-checkpoint`;
62
+ return hookCommandFor(path.join("hud", "cli.js"), "save-checkpoint");
51
63
  }
52
64
  function sourceSessionCommand() {
53
- const lifecycleCli = fileURLToPath(new URL("./cli.js", import.meta.url));
54
- return `${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycleCli)} bind-source-session`;
65
+ return hookCommandFor(path.join("hud", "cli.js"), "bind-source-session");
55
66
  }
56
67
  function installCodexSourceSessionHook() {
57
68
  const dir = path.join(os.homedir(), ".codex");
@@ -97,7 +97,7 @@ export function resolveClaudeDesktopSupportRoots(opts = {}) {
97
97
  if (typeof configured === "string" && configured.trim()) {
98
98
  candidates.push(expandCurrentUserHome(configured, homeDir));
99
99
  }
100
- if (platform === "darwin") {
100
+ else if (platform === "darwin") {
101
101
  candidates.push(path.join(homeDir, "Library", "Application Support", "Claude"));
102
102
  }
103
103
  else if (platform === "win32") {
package/dist/migrate.js CHANGED
@@ -320,16 +320,49 @@ function hasClaudeText(content) {
320
320
  return false;
321
321
  return content.some((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
322
322
  }
323
+ /**
324
+ * True when this turn opens the conversation rather than continuing one.
325
+ *
326
+ * `parentUuid === null` is the common case, but a session started by dropping in a file opens with
327
+ * an `attachment` record and parents its first real turn to that — requiring a null parent silently
328
+ * discarded those whole transcripts. Walk the chain instead: a turn opens the conversation when
329
+ * nothing conversational precedes it in this file. A parent the file does not contain means the
330
+ * thread continues from somewhere else, which is not a user-created session.
331
+ */
332
+ function startsConversation(obj, byUuid) {
333
+ let parentUuid = obj.parentUuid;
334
+ for (let depth = 0; depth < 64; depth += 1) {
335
+ if (parentUuid === null || parentUuid === undefined)
336
+ return true;
337
+ if (typeof parentUuid !== "string")
338
+ return false;
339
+ const parent = byUuid.get(parentUuid);
340
+ if (!parent)
341
+ return false;
342
+ if (parent.type === "user" || parent.type === "assistant")
343
+ return false;
344
+ parentUuid = parent.parentUuid;
345
+ }
346
+ return false; // pathological chain; treat as not session-initiating rather than loop
347
+ }
323
348
  function isUserCreatedClaudeSessionFile(file) {
324
- return initialJsonObjects(file).some((obj) => {
325
- if (!isRecord(obj) || obj.type !== "user")
349
+ const objects = initialJsonObjects(file).filter(isRecord);
350
+ const byUuid = new Map();
351
+ for (const obj of objects) {
352
+ if (typeof obj.uuid === "string" && obj.uuid)
353
+ byUuid.set(obj.uuid, obj);
354
+ }
355
+ return objects.some((obj) => {
356
+ if (obj.type !== "user")
326
357
  return false;
327
- if (obj.userType !== "external" || obj.isSidechain !== false || obj.parentUuid !== null)
358
+ if (obj.userType !== "external" || obj.isSidechain !== false)
328
359
  return false;
329
360
  if (obj.isMeta === true || obj.isCompactSummary === true)
330
361
  return false;
331
362
  if (typeof obj.agentId === "string" && obj.agentId.trim())
332
363
  return false;
364
+ if (!startsConversation(obj, byUuid))
365
+ return false;
333
366
  const message = isRecord(obj.message) ? obj.message : {};
334
367
  return hasClaudeText(message.content);
335
368
  });
package/dist/setup.js CHANGED
@@ -26,11 +26,12 @@ import axios from "axios";
26
26
  import { KeyStore } from "./keystore.js";
27
27
  import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
28
28
  import { buildOnboardingStatsPayload } from "./onboarding-stats.js";
29
- import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
29
+ import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, discoverSessions, dedupeSessionsByConversation, MIGRATE_CONCURRENCY, } from "./migrate.js";
30
30
  import { syncCodexUsage } from "./codex-sync.js";
31
31
  import { renderSetupPage } from "./setup-page.js";
32
32
  import { parseSetupPreviewState } from "./setup-preview.js";
33
- import { resolveClaudeDesktopSupportRoots } from "./local-data-paths.js";
33
+ import { resolveClaudeCoworkSessionRoots, resolveClaudeDesktopSupportRoots, resolveClaudeProjectsDir, resolveCodexSessionRoots, } from "./local-data-paths.js";
34
+ import { isEphemeralNpxPath, reexecFromDurableRuntime, resolveDurableDistPath, resolveGlobalEntry } from "./durable-entry.js";
34
35
  import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
35
36
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
37
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
@@ -216,18 +217,45 @@ function runtimeModuleUrl(name) {
216
217
  }
217
218
  return jsUrl.href;
218
219
  }
220
+ function claudeDesktopRootHasBundledCode(supportRoot, platform) {
221
+ return versionedClaudeCodeExecutables(path.join(supportRoot, "claude-code"), platform === "win32" ? "claude.exe" : "claude", 1).length > 0;
222
+ }
223
+ /**
224
+ * Config targeting needs installation evidence; history discovery intentionally does not.
225
+ * A stale support directory can still contain importable Cowork history, but must not make setup
226
+ * claim that Claude Desktop is installed. An explicit support-root override is itself evidence.
227
+ */
228
+ export function claudeDesktopConfigRoots(defaultRoot, options = {}) {
229
+ const env = options.env ?? process.env;
230
+ const platform = options.platform ?? process.platform;
231
+ const homeDir = options.homeDir ?? os.homedir();
232
+ const supportRoots = resolveClaudeDesktopSupportRoots(options);
233
+ if (env.CLAUDE_DESKTOP_SUPPORT_DIR?.trim())
234
+ return supportRoots;
235
+ if (platform === "darwin") {
236
+ const installed = [
237
+ "/Applications/Claude.app",
238
+ path.join(homeDir, "Applications", "Claude.app"),
239
+ ].some((appPath) => fs.existsSync(appPath));
240
+ if (!installed)
241
+ return [];
242
+ return supportRoots.length > 0 ? supportRoots : [defaultRoot];
243
+ }
244
+ return supportRoots.filter((supportRoot) => claudeDesktopRootHasBundledCode(supportRoot, platform));
245
+ }
219
246
  /** Known clients and where their MCP server map lives. */
220
247
  export function knownClients() {
221
248
  const appSupport = process.platform === "darwin"
222
249
  ? home("Library", "Application Support")
223
250
  : process.env.APPDATA || home(".config");
224
251
  const defaultClaudeDesktopRoot = path.join(appSupport, "Claude");
225
- const claudeDesktopRoots = resolveClaudeDesktopSupportRoots();
252
+ const claudeDesktopRoots = claudeDesktopConfigRoots(defaultClaudeDesktopRoot);
226
253
  const claudeDesktopClients = (claudeDesktopRoots.length > 0 ? claudeDesktopRoots : [defaultClaudeDesktopRoot]).map((supportRoot) => ({
227
254
  id: "claude-desktop",
228
255
  label: "Claude Desktop",
229
256
  kind: "json",
230
257
  configPath: path.join(supportRoot, "claude_desktop_config.json"),
258
+ detected: claudeDesktopRoots.length > 0,
231
259
  }));
232
260
  return [
233
261
  { id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
@@ -238,14 +266,27 @@ export function knownClients() {
238
266
  ];
239
267
  }
240
268
  /** A client is "present" if its config dir already exists — a cheap, side-effect-free heuristic. */
269
+ /**
270
+ * Claude Code is present when its CLI runs OR when it has left a profile behind.
271
+ *
272
+ * The CLI probe alone misses a Store-packaged Claude Desktop, whose bundled binary never reaches
273
+ * PATH; a profile alone used to mean "detected but unconfigurable". Now that writeClaudeCodeConfig
274
+ * can merge the user entry into ~/.claude.json without a CLI, a profile is enough to act on.
275
+ */
276
+ export function claudeCodeInstalled() {
277
+ return fs.existsSync(home(".claude")) || fs.existsSync(home(".claude.json")) || claudeCodeCliAvailable();
278
+ }
241
279
  export function detectClients() {
242
280
  return knownClients().filter((c) => {
243
- if (c.kind === "json")
281
+ if (c.kind === "json") {
282
+ if (c.id === "claude-desktop")
283
+ return c.detected === true;
244
284
  return fs.existsSync(path.dirname(c.configPath));
285
+ }
245
286
  if (c.kind === "command")
246
287
  return fs.existsSync(c.detectDir);
247
288
  if (c.id === "claude-code")
248
- return claudeCodeCliAvailable();
289
+ return claudeCodeInstalled();
249
290
  return false;
250
291
  });
251
292
  }
@@ -287,33 +328,6 @@ export function buildServerEntry(opts = {}) {
287
328
  // Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
288
329
  return { command: "npx", args: ["@echomem/mcp"] };
289
330
  }
290
- /** True when a resolved entry lives inside npx's throwaway cache (`…/_npx/<hash>/…`). */
291
- function isEphemeralNpxPath(entry) {
292
- return entry.split(path.sep).includes("_npx");
293
- }
294
- /**
295
- * Locate a DURABLE global install of the bridge (the one `npm i -g @echomem/mcp` creates). Global
296
- * modules sit next to the running node — `<node>/../lib/node_modules` (nvm/unix) or `<node>/node_modules`
297
- * (Windows). Returns the realpath'd dist entry, or null when the package isn't globally installed.
298
- */
299
- function resolveGlobalEntry() {
300
- const nodeDir = path.dirname(process.execPath);
301
- const pkgParts = MCP_PACKAGE_NAME.split("/"); // ["@echomem", "mcp"]
302
- const candidates = [
303
- path.join(nodeDir, "..", "lib", "node_modules", ...pkgParts, "dist", "index.js"),
304
- path.join(nodeDir, "node_modules", ...pkgParts, "dist", "index.js"),
305
- ];
306
- for (const candidate of candidates) {
307
- try {
308
- if (fs.existsSync(candidate))
309
- return fs.realpathSync(candidate);
310
- }
311
- catch {
312
- /* keep trying */
313
- }
314
- }
315
- return null;
316
- }
317
331
  function installDurableHeadlessRuntime() {
318
332
  if (process.env.ECHO_DISABLE_RUNTIME_BOOTSTRAP === "1")
319
333
  return;
@@ -552,7 +566,7 @@ function claudeCodeLocalEchoMemProjects(configPath) {
552
566
  .map(([projectPath]) => projectPath)
553
567
  .sort();
554
568
  }
555
- function versionedClaudeCodeExecutables(root, executable) {
569
+ function versionedClaudeCodeExecutables(root, executable, limit = Number.POSITIVE_INFINITY) {
556
570
  let versions;
557
571
  try {
558
572
  versions = fs.readdirSync(root, { withFileTypes: true });
@@ -564,7 +578,8 @@ function versionedClaudeCodeExecutables(root, executable) {
564
578
  .filter((entry) => entry.isDirectory())
565
579
  .sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }))
566
580
  .map((entry) => path.join(root, entry.name, executable))
567
- .filter((candidate) => fs.existsSync(candidate));
581
+ .filter((candidate) => fs.existsSync(candidate))
582
+ .slice(0, limit);
568
583
  }
569
584
  /**
570
585
  * Return every safe Claude Code launcher location worth trying. Claude Desktop bundles the CLI,
@@ -583,13 +598,13 @@ export function claudeCodeCommandCandidates(options = {}) {
583
598
  if (platform === "win32") {
584
599
  const appData = env.APPDATA?.trim() || path.join(homeDir, "AppData", "Roaming");
585
600
  const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir, "AppData", "Local");
586
- candidates.push(...versionedClaudeCodeExecutables(path.join(appData, "Claude", "claude-code"), "claude.exe"), ...versionedClaudeCodeExecutables(path.join(localAppData, "Claude", "claude-code"), "claude.exe"));
601
+ candidates.push(...versionedClaudeCodeExecutables(path.join(appData, "Claude", "claude-code"), "claude.exe", 1), ...versionedClaudeCodeExecutables(path.join(localAppData, "Claude", "claude-code"), "claude.exe", 1));
587
602
  const packagesRoot = path.join(localAppData, "Packages");
588
603
  try {
589
604
  for (const entry of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
590
605
  if (!entry.isDirectory() || !/^Claude_/i.test(entry.name))
591
606
  continue;
592
- candidates.push(...versionedClaudeCodeExecutables(path.join(packagesRoot, entry.name, "LocalCache", "Roaming", "Claude", "claude-code"), "claude.exe"));
607
+ candidates.push(...versionedClaudeCodeExecutables(path.join(packagesRoot, entry.name, "LocalCache", "Roaming", "Claude", "claude-code"), "claude.exe", 1));
593
608
  }
594
609
  }
595
610
  catch {
@@ -622,55 +637,106 @@ function escapeWindowsCmdArgument(value) {
622
637
  escaped = `"${escaped}"`;
623
638
  return escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
624
639
  }
625
- function execClaudeCodeSync(args, options, commandCandidates) {
640
+ function execClaudeCodeCommandSync(command, args, options) {
641
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
642
+ const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
643
+ const spawnOptions = {
644
+ ...options,
645
+ windowsHide: true,
646
+ windowsVerbatimArguments: true,
647
+ };
648
+ const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
649
+ if (result.error)
650
+ throw result.error;
651
+ if (result.status !== 0) {
652
+ throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
653
+ }
654
+ return result.stdout || "";
655
+ }
656
+ return execFileSync(command, args, process.platform === "win32" ? { ...options, windowsHide: true } : options);
657
+ }
658
+ function claudeCodeCommandsOnPath(timeout) {
659
+ if (process.platform !== "win32" || timeout <= 0)
660
+ return [];
661
+ try {
662
+ return execFileSync("where.exe", ["claude"], {
663
+ encoding: "utf8",
664
+ stdio: ["ignore", "pipe", "ignore"],
665
+ timeout,
666
+ windowsHide: true,
667
+ }).split(/\r?\n/).map((candidate) => candidate.trim()).filter(Boolean);
668
+ }
669
+ catch {
670
+ return [];
671
+ }
672
+ }
673
+ let cachedClaudeCodeCommand = null;
674
+ function resolveRunnableClaudeCodeCommand(commandCandidates, deadline = Date.now() + 3000) {
675
+ if (!commandCandidates && cachedClaudeCodeCommand)
676
+ return cachedClaudeCodeCommand;
626
677
  let candidates = commandCandidates ? [...commandCandidates] : claudeCodeCommandCandidates();
627
- if (process.platform === "win32" && !commandCandidates) {
678
+ if (!commandCandidates) {
679
+ const remainingForPath = Math.max(1, Math.min(1000, deadline - Date.now()));
680
+ candidates = [...new Set([...claudeCodeCommandsOnPath(remainingForPath), ...candidates])];
681
+ }
682
+ for (const command of candidates) {
683
+ const remaining = deadline - Date.now();
684
+ if (remaining <= 0)
685
+ break;
628
686
  try {
629
- const pathMatches = execFileSync("where.exe", ["claude"], {
687
+ execClaudeCodeCommandSync(command, ["--version"], {
630
688
  encoding: "utf8",
631
689
  stdio: ["ignore", "pipe", "ignore"],
632
- timeout: 3000,
633
- windowsHide: true,
634
- }).split(/\r?\n/).map((candidate) => candidate.trim()).filter(Boolean);
635
- candidates = [...new Set([...pathMatches, ...candidates])];
690
+ timeout: remaining,
691
+ });
692
+ if (!commandCandidates)
693
+ cachedClaudeCodeCommand = command;
694
+ return command;
636
695
  }
637
696
  catch {
638
- /* The bundled Desktop candidates below remain available. */
697
+ /* Keep trying only while the shared deadline has time left. */
639
698
  }
640
699
  }
641
- let lastError = new Error("Claude Code CLI was not found.");
642
- for (const command of candidates) {
643
- try {
644
- if (process.platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
645
- const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
646
- const spawnOptions = {
647
- ...options,
648
- windowsHide: true,
649
- windowsVerbatimArguments: true,
650
- };
651
- const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
652
- if (result.error)
653
- throw result.error;
654
- if (result.status !== 0) {
655
- throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
656
- }
657
- return result.stdout || "";
658
- }
659
- return execFileSync(command, args, process.platform === "win32" ? { ...options, windowsHide: true } : options);
660
- }
661
- catch (error) {
662
- lastError = error;
663
- }
664
- }
665
- throw lastError;
700
+ return null;
701
+ }
702
+ function execClaudeCodeSync(args, options, commandCandidates) {
703
+ const probeBudget = typeof options.timeout === "number" ? Math.min(options.timeout, 3000) : 3000;
704
+ const command = resolveRunnableClaudeCodeCommand(commandCandidates, Date.now() + probeBudget);
705
+ if (!command)
706
+ throw new Error("Claude Code CLI was not found within the setup time budget.");
707
+ return execClaudeCodeCommandSync(command, args, options);
666
708
  }
667
709
  export function claudeCodeCliAvailable() {
710
+ return resolveRunnableClaudeCodeCommand(undefined, Date.now() + 3000) !== null;
711
+ }
712
+ /**
713
+ * Merge the user-scope EchoMem entry straight into ~/.claude.json.
714
+ *
715
+ * Reserved for machines where NO Claude Code CLI can be executed at all — a Store-packaged Claude
716
+ * Desktop whose bundled binary never lands on PATH, an npm shim in a stripped environment. Without
717
+ * this, such a machine can never be configured, even though the file is trivial to update.
718
+ *
719
+ * Deliberately narrow: read immediately before write, touch only `mcpServers.echomem`, preserve
720
+ * every other key (this file also holds Claude account and session state), and swap it in via a
721
+ * same-directory temp file so a crash cannot truncate it. Project-local entries are still left to
722
+ * the CLI — removing those means knowing which project a scope belongs to.
723
+ */
724
+ function writeUserEntryDirectly(configPath, entry) {
668
725
  try {
669
- execClaudeCodeSync(["--version"], {
670
- encoding: "utf8",
671
- stdio: ["ignore", "pipe", "ignore"],
672
- timeout: 3000,
673
- });
726
+ const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8").trim() : "";
727
+ const parsed = raw ? JSON.parse(raw) : {};
728
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
729
+ return false;
730
+ const config = parsed;
731
+ const existing = config.mcpServers;
732
+ const servers = typeof existing === "object" && existing !== null && !Array.isArray(existing)
733
+ ? existing
734
+ : {};
735
+ servers.echomem = entry;
736
+ config.mcpServers = servers;
737
+ const tmp = `${configPath}.echomem-${process.pid}.tmp`;
738
+ fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`);
739
+ fs.renameSync(tmp, configPath);
674
740
  return true;
675
741
  }
676
742
  catch {
@@ -682,6 +748,7 @@ export function writeClaudeCodeConfig(entry, options = {}) {
682
748
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
683
749
  // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
684
750
  const configPath = options.configPath ?? home(".claude.json");
751
+ const configurationDeadline = Date.now() + 5000;
685
752
  let failureReason;
686
753
  const emptyResult = () => ({
687
754
  state: "unavailable",
@@ -692,14 +759,27 @@ export function writeClaudeCodeConfig(entry, options = {}) {
692
759
  preservedDesktopManaged: false,
693
760
  failureReason,
694
761
  });
762
+ let runnableCommand;
763
+ const cliCommand = () => {
764
+ if (runnableCommand === undefined) {
765
+ runnableCommand = resolveRunnableClaudeCodeCommand(options.claudeCommands, configurationDeadline);
766
+ }
767
+ return runnableCommand;
768
+ };
695
769
  const runClaude = (args, cwd) => {
770
+ const command = cliCommand();
771
+ const remaining = configurationDeadline - Date.now();
772
+ if (!command || remaining <= 0) {
773
+ failureReason = "Claude Code CLI was unavailable within the onboarding time budget.";
774
+ return false;
775
+ }
696
776
  try {
697
- execClaudeCodeSync(args, {
777
+ execClaudeCodeCommandSync(command, args, {
698
778
  cwd,
699
779
  encoding: "utf8",
700
780
  stdio: ["ignore", "pipe", "pipe"],
701
- timeout: 10000,
702
- }, options.claudeCommands);
781
+ timeout: Math.min(remaining, 5000),
782
+ });
703
783
  failureReason = undefined;
704
784
  return true;
705
785
  }
@@ -718,16 +798,31 @@ export function writeClaudeCodeConfig(entry, options = {}) {
718
798
  && validDesktopManagedEntry(previousUserEntry);
719
799
  const desiredUserEntry = preservedDesktopManaged ? previousUserEntry : entry;
720
800
  let restoredPreviousUserEntry = false;
801
+ let usedDirectWrite = false;
802
+ const fallbackWrite = () => {
803
+ // A working CLI that refuses is a real failure; masking it would hide a genuine problem.
804
+ if (cliCommand())
805
+ return false;
806
+ if (!writeUserEntryDirectly(configPath, desiredUserEntry))
807
+ return false;
808
+ usedDirectWrite = true;
809
+ failureReason = undefined;
810
+ return true;
811
+ };
721
812
  // Avoid interrupting active/new sessions when the correct global entry is already installed.
722
813
  if (!claudeEntriesMatch(previousUserEntry, desiredUserEntry)) {
723
- if (previousUserEntry && !removeUser())
724
- return emptyResult();
725
- if (!addUser(desiredUserEntry)) {
726
- const addFailureReason = failureReason;
727
- if (previousUserEntry)
728
- restoredPreviousUserEntry = addUser(previousUserEntry);
729
- failureReason = addFailureReason;
730
- return { ...emptyResult(), restoredPreviousUserEntry };
814
+ if (previousUserEntry && !removeUser()) {
815
+ if (!fallbackWrite())
816
+ return emptyResult();
817
+ }
818
+ else if (!addUser(desiredUserEntry)) {
819
+ if (!fallbackWrite()) {
820
+ const addFailureReason = failureReason;
821
+ if (previousUserEntry)
822
+ restoredPreviousUserEntry = addUser(previousUserEntry);
823
+ failureReason = addFailureReason;
824
+ return { ...emptyResult(), restoredPreviousUserEntry };
825
+ }
731
826
  }
732
827
  }
733
828
  const installedUserEntry = echoMemEntryFromServers(readClaudeCodeConfigFile(configPath).mcpServers);
@@ -768,6 +863,7 @@ export function writeClaudeCodeConfig(entry, options = {}) {
768
863
  failedLocalProjects: unresolved,
769
864
  restoredPreviousUserEntry,
770
865
  preservedDesktopManaged,
866
+ usedDirectWrite,
771
867
  };
772
868
  }
773
869
  function readJsonClientEntry(configPath) {
@@ -2611,6 +2707,11 @@ function parseFlags(argv) {
2611
2707
  }
2612
2708
  return flags;
2613
2709
  }
2710
+ /**
2711
+ * Configure MCP clients without starting local-history HTML onboarding. Echo Desktop on macOS owns
2712
+ * its native onboarding and invokes this setup surface with --skip-login; only CLI `init` below owns
2713
+ * the browser flow used by Windows/headless users.
2714
+ */
2614
2715
  async function cmdSetup(flags) {
2615
2716
  if (!flags.dev && !flags["skip-runtime-install"]) {
2616
2717
  try {
@@ -2663,6 +2764,9 @@ async function cmdSetup(flags) {
2663
2764
  if (result.preservedDesktopManaged) {
2664
2765
  console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
2665
2766
  }
2767
+ else if (result.usedDirectWrite) {
2768
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} directly (no runnable \`claude\` CLI was found) — start a new Claude Code session to load it.`);
2769
+ }
2666
2770
  else {
2667
2771
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2668
2772
  }
@@ -2733,7 +2837,8 @@ async function cmdSetup(flags) {
2733
2837
  }
2734
2838
  }
2735
2839
  /**
2736
- * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2840
+ * `echomem-mcp init` — the CLI-owned one-command install used by Windows/headless users. Configures
2841
+ * EVERY coding agent installed on this
2737
2842
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2738
2843
  * Codex skills and writes the AGENTS.md memory guidance. One browser
2739
2844
  * bridge then runs permission → login → plan if needed → extraction in that order.
@@ -2835,11 +2940,14 @@ async function cmdUpdate(flags) {
2835
2940
  function selectSetupTargets(requested, all) {
2836
2941
  if (all || requested === "all") {
2837
2942
  return knownClients().filter((client) => {
2838
- if (client.kind === "json")
2943
+ if (client.kind === "json") {
2944
+ if (client.id === "claude-desktop")
2945
+ return client.detected === true;
2839
2946
  return fs.existsSync(path.dirname(client.configPath));
2947
+ }
2840
2948
  if (client.kind === "command")
2841
2949
  return fs.existsSync(client.detectDir);
2842
- return client.id === "claude-code" && claudeCodeCliAvailable();
2950
+ return client.id === "claude-code" && claudeCodeInstalled();
2843
2951
  });
2844
2952
  }
2845
2953
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
@@ -3640,6 +3748,60 @@ async function cmdStatus(flags = {}) {
3640
3748
  }
3641
3749
  }
3642
3750
  }
3751
+ /**
3752
+ * Where this install lives and what it can actually see.
3753
+ *
3754
+ * `status` answers "who am I logged in as". This answers "why is EchoMem not finding my sessions",
3755
+ * which is the question that needs a command — a bare zero cannot distinguish "no store here" from
3756
+ * "store found, nothing in it". Local only, so it still works with no network and no account.
3757
+ */
3758
+ export function doctorReportLines() {
3759
+ const lines = ["Environment:"];
3760
+ const entry = resolveDurableDistPath("index.js");
3761
+ const runtime = readHeadlessRuntimeInstallation();
3762
+ lines.push(` package ${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`);
3763
+ lines.push(` entry point ${entry.path}${entry.ephemeral ? " [temporary npx cache — npm can delete this]" : ""}`);
3764
+ lines.push(` durable runtime ${runtime ? `${runtime.version} at ${runtime.entry}` : "not installed"}`);
3765
+ const candidates = claudeCodeCommandCandidates();
3766
+ lines.push(` claude CLI ${claudeCodeCliAvailable() ? "runnable" : `not runnable (${candidates.length} candidate${candidates.length === 1 ? "" : "s"} tried)`}`);
3767
+ const supportRoots = resolveClaudeDesktopSupportRoots();
3768
+ const containerised = supportRoots.some((root) => /[\\/]Packages[\\/]Claude_/i.test(root));
3769
+ lines.push(` Claude Desktop ${supportRoots.length === 0
3770
+ ? "not detected"
3771
+ : `${containerised ? "Store/MSIX container" : "unpackaged install"} (${supportRoots.length} support root${supportRoots.length === 1 ? "" : "s"})`}`);
3772
+ const claudeRoot = resolveClaudeProjectsDir();
3773
+ const codexRoot = resolveCodexSessionRoots()[0]?.path;
3774
+ const coworkRoots = resolveClaudeCoworkSessionRoots();
3775
+ // Dedupe as the import path does: inside an MSIX container the plain and LocalCache support roots
3776
+ // expose the same files, and realpath does not collapse that, so raw discovery counts them twice.
3777
+ const sessions = dedupeSessionsByConversation(discoverSessions({ claudeRoot: claudeRoot ?? undefined, coworkRoots, codexRoot }));
3778
+ const row = (label, source, root) => {
3779
+ const found = sessions.filter((session) => session.source === source).length;
3780
+ if (found > 0)
3781
+ return ` ${label.padEnd(15)} ${found} ${root ?? ""}`.trimEnd();
3782
+ // A zero must say which zero it is.
3783
+ return ` ${label.padEnd(15)} 0 ${root ? `store found at ${root}, nothing importable in it` : "no store on this machine"}`;
3784
+ };
3785
+ lines.push("", "Local sessions:");
3786
+ lines.push(row("Claude Code", "claude-code", claudeRoot ?? undefined));
3787
+ lines.push(row("Codex", "codex", codexRoot));
3788
+ lines.push(row("Cowork", "claude-desktop", coworkRoots[0]));
3789
+ return lines;
3790
+ }
3791
+ function cmdDoctor() {
3792
+ console.log(`EchoMem doctor — ${MCP_PACKAGE_LABEL}\n`);
3793
+ for (const line of doctorReportLines())
3794
+ console.log(line);
3795
+ const reports = inspectClientConfigs(MCP_PACKAGE_VERSION);
3796
+ if (reports.length) {
3797
+ console.log("\nClient MCP configs:");
3798
+ for (const report of reports) {
3799
+ for (const line of formatClientConfigReport(report))
3800
+ console.log(line);
3801
+ }
3802
+ }
3803
+ console.log("\nAccount, credentials and update checks: `status`.");
3804
+ }
3643
3805
  function cmdLogout() {
3644
3806
  const store = new KeyStore();
3645
3807
  try {
@@ -3688,9 +3850,18 @@ Manual / headless:
3688
3850
  Current bridge version: ${MCP_PACKAGE_VERSION}
3689
3851
  `;
3690
3852
  /** Returns true if argv was a recognized subcommand (and was handled). */
3853
+ /** Commands that persist paths into other programs' configs, and so must run from a durable copy. */
3854
+ const CONFIG_WRITING_COMMANDS = new Set(["init", "setup", "update"]);
3691
3855
  export async function runCli(argv) {
3692
3856
  const cmd = argv[0];
3693
3857
  const flags = parseFlags(argv.slice(1));
3858
+ if (cmd && CONFIG_WRITING_COMMANDS.has(cmd)) {
3859
+ const status = reexecFromDurableRuntime(argv);
3860
+ if (status !== null) {
3861
+ process.exitCode = status;
3862
+ return true;
3863
+ }
3864
+ }
3694
3865
  switch (cmd) {
3695
3866
  case "init":
3696
3867
  await cmdInit(flags);
@@ -3714,7 +3885,7 @@ export async function runCli(argv) {
3714
3885
  await cmdStatus(flags);
3715
3886
  return true;
3716
3887
  case "doctor":
3717
- await cmdStatus(flags);
3888
+ cmdDoctor();
3718
3889
  return true;
3719
3890
  case "logout":
3720
3891
  cmdLogout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.49",
3
+ "version": "1.4.50",
4
4
  "description": "EchoMem MCP bridge for cross-agent memory, local history import, and recall",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test:ui": "npm run build && node test/setup-ui.test.mjs",
32
32
  "test:onboarding-resilience": "npm run build && node test/onboarding-resilience.test.mjs",
33
33
  "test:billing-ui": "npm run build && node test/setup-ui.test.mjs billing",
34
- "test": "npm run build && node test/source-session.test.mjs && node test/source-session-hook.test.mjs && 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/canonical-golden.test.mjs && node test/tools.test.mjs && node test/workspace-selector.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",
34
+ "test": "npm run build && node test/source-session.test.mjs && node test/source-session-hook.test.mjs && 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/canonical-golden.test.mjs && node test/tools.test.mjs && node test/workspace-selector.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 && node test/durable-entry.test.mjs && node test/durable-reexec.test.mjs && node test/doctor.test.mjs && node test/environment-matrix.test.mjs",
35
35
  "prepack": "npm run build"
36
36
  },
37
37
  "dependencies": {