@echomem/mcp 1.4.48 → 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");
@@ -85,41 +85,80 @@ export function resolveClaudeProjectsDir(opts = {}) {
85
85
  const root = configuredRoot("CLAUDE_CONFIG_DIR", ".claude", opts);
86
86
  return resolveReadableDirectory(root ? path.join(root, "projects") : null);
87
87
  }
88
- /**
89
- * Resolve Claude Desktop's local Cowork sandbox root.
90
- *
91
- * Cowork does not write into the normal `~/.claude/projects` profile. Claude Desktop creates an
92
- * OS-specific support directory and stores each local-agent session below
93
- * `local-agent-mode-sessions/<org>/<conversation>/local_<id>/`. The actual transcript is nested
94
- * again under that sandbox's `.claude/projects` directory.
95
- */
96
- export function resolveClaudeCoworkSessionsDir(opts = {}) {
88
+ /** Resolve every readable Claude Desktop support directory, including Windows Store/MSIX data. */
89
+ export function resolveClaudeDesktopSupportRoots(opts = {}) {
97
90
  const homeDir = normalizedHomeDir(opts.homeDir ?? os.homedir());
98
91
  if (!homeDir)
99
- return null;
92
+ return [];
100
93
  const env = opts.env ?? process.env;
101
94
  const configured = env.CLAUDE_DESKTOP_SUPPORT_DIR;
102
- let supportRoot = null;
95
+ const platform = opts.platform ?? process.platform;
96
+ const candidates = [];
103
97
  if (typeof configured === "string" && configured.trim()) {
104
- supportRoot = expandCurrentUserHome(configured, homeDir);
98
+ candidates.push(expandCurrentUserHome(configured, homeDir));
105
99
  }
106
- else {
107
- const platform = opts.platform ?? process.platform;
108
- if (platform === "darwin") {
109
- supportRoot = path.join(homeDir, "Library", "Application Support", "Claude");
110
- }
111
- else if (platform === "win32") {
112
- const appData = typeof env.APPDATA === "string" && env.APPDATA.trim()
113
- ? expandCurrentUserHome(env.APPDATA, homeDir)
114
- : path.join(homeDir, "AppData", "Roaming");
115
- supportRoot = appData ? path.join(appData, "Claude") : null;
116
- }
117
- else {
118
- const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
119
- ? expandCurrentUserHome(env.XDG_CONFIG_HOME, homeDir)
120
- : path.join(homeDir, ".config");
121
- supportRoot = configHome ? path.join(configHome, "Claude") : null;
100
+ else if (platform === "darwin") {
101
+ candidates.push(path.join(homeDir, "Library", "Application Support", "Claude"));
102
+ }
103
+ else if (platform === "win32") {
104
+ const appData = typeof env.APPDATA === "string" && env.APPDATA.trim()
105
+ ? expandCurrentUserHome(env.APPDATA, homeDir)
106
+ : path.join(homeDir, "AppData", "Roaming");
107
+ if (appData)
108
+ candidates.push(path.join(appData, "Claude"));
109
+ const localAppData = typeof env.LOCALAPPDATA === "string" && env.LOCALAPPDATA.trim()
110
+ ? expandCurrentUserHome(env.LOCALAPPDATA, homeDir)
111
+ : path.join(homeDir, "AppData", "Local");
112
+ if (localAppData) {
113
+ const packagesRoot = path.join(localAppData, "Packages");
114
+ try {
115
+ for (const entry of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
116
+ if (!entry.isDirectory() || !/^Claude_/i.test(entry.name))
117
+ continue;
118
+ candidates.push(path.join(packagesRoot, entry.name, "LocalCache", "Roaming", "Claude"));
119
+ }
120
+ }
121
+ catch {
122
+ /* A non-Store install has no Packages directory. */
123
+ }
122
124
  }
123
125
  }
124
- return resolveReadableDirectory(supportRoot ? path.join(supportRoot, "local-agent-mode-sessions") : null);
126
+ else {
127
+ const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
128
+ ? expandCurrentUserHome(env.XDG_CONFIG_HOME, homeDir)
129
+ : path.join(homeDir, ".config");
130
+ if (configHome)
131
+ candidates.push(path.join(configHome, "Claude"));
132
+ }
133
+ const roots = [];
134
+ const seenRealpaths = new Set();
135
+ for (const candidate of candidates) {
136
+ const resolved = resolveReadableDirectory(candidate);
137
+ if (!resolved || seenRealpaths.has(resolved))
138
+ continue;
139
+ seenRealpaths.add(resolved);
140
+ roots.push(resolved);
141
+ }
142
+ return roots;
143
+ }
144
+ /**
145
+ * Resolve every Claude Desktop Cowork sandbox root. Cowork transcripts live below
146
+ * `local-agent-mode-sessions/<org>/<conversation>/local_<id>/.claude/projects`; Windows Store
147
+ * installs can have several private Claude package containers below `Packages`.
148
+ */
149
+ export function resolveClaudeCoworkSessionRoots(opts = {}) {
150
+ const roots = [];
151
+ const seenRealpaths = new Set();
152
+ for (const supportRoot of resolveClaudeDesktopSupportRoots(opts)) {
153
+ const resolved = resolveReadableDirectory(path.join(supportRoot, "local-agent-mode-sessions"));
154
+ if (!resolved || seenRealpaths.has(resolved))
155
+ continue;
156
+ seenRealpaths.add(resolved);
157
+ roots.push(resolved);
158
+ }
159
+ return roots;
160
+ }
161
+ /** Backward-compatible first-root resolver. New discovery code should scan every returned root. */
162
+ export function resolveClaudeCoworkSessionsDir(opts = {}) {
163
+ return resolveClaudeCoworkSessionRoots(opts)[0] ?? null;
125
164
  }
package/dist/migrate.js CHANGED
@@ -29,7 +29,7 @@ import axios from "axios";
29
29
  import { KeyStore, echoConfigDir } from "./keystore.js";
30
30
  import { fetchEncryptionConfig } from "./encryption.js";
31
31
  import { discoverCodexSessionFiles } from "./codex-session-files.js";
32
- import { resolveClaudeCoworkSessionsDir, resolveClaudeProjectsDir } from "./local-data-paths.js";
32
+ import { resolveClaudeCoworkSessionRoots, resolveClaudeProjectsDir } from "./local-data-paths.js";
33
33
  import { eachJsonLine, walkLocalFiles } from "./local-jsonl.js";
34
34
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
35
35
  const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
@@ -232,10 +232,7 @@ function claudeSessionCandidates(opts = {}) {
232
232
  // Supplying an explicit Claude root is an isolated discovery request (tests, diagnostics, or a
233
233
  // moved profile). Callers can still add Cowork roots explicitly. Normal onboarding supplies no
234
234
  // roots, so the platform-default Cowork directory is included automatically.
235
- const defaultCoworkRoot = opts.claudeRoot === undefined
236
- ? resolveClaudeCoworkSessionsDir()
237
- : null;
238
- const coworkRoots = opts.coworkRoots ?? (defaultCoworkRoot ? [defaultCoworkRoot] : []);
235
+ const coworkRoots = opts.coworkRoots ?? (opts.claudeRoot === undefined ? resolveClaudeCoworkSessionRoots() : []);
239
236
  for (const root of coworkRoots) {
240
237
  for (const filePath of userCreatedClaudeFiles(root)) {
241
238
  const conversationId = coworkConversationId(filePath, root);
@@ -323,16 +320,49 @@ function hasClaudeText(content) {
323
320
  return false;
324
321
  return content.some((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
325
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
+ }
326
348
  function isUserCreatedClaudeSessionFile(file) {
327
- return initialJsonObjects(file).some((obj) => {
328
- 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")
329
357
  return false;
330
- if (obj.userType !== "external" || obj.isSidechain !== false || obj.parentUuid !== null)
358
+ if (obj.userType !== "external" || obj.isSidechain !== false)
331
359
  return false;
332
360
  if (obj.isMeta === true || obj.isCompactSummary === true)
333
361
  return false;
334
362
  if (typeof obj.agentId === "string" && obj.agentId.trim())
335
363
  return false;
364
+ if (!startsConversation(obj, byUuid))
365
+ return false;
336
366
  const message = isRecord(obj.message) ? obj.message : {};
337
367
  return hasClaudeText(message.content);
338
368
  });
package/dist/setup.js CHANGED
@@ -26,10 +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 { resolveClaudeCoworkSessionRoots, resolveClaudeDesktopSupportRoots, resolveClaudeProjectsDir, resolveCodexSessionRoots, } from "./local-data-paths.js";
34
+ import { isEphemeralNpxPath, reexecFromDurableRuntime, resolveDurableDistPath, resolveGlobalEntry } from "./durable-entry.js";
33
35
  import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
34
36
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
35
37
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
@@ -215,28 +217,76 @@ function runtimeModuleUrl(name) {
215
217
  }
216
218
  return jsUrl.href;
217
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
+ }
218
246
  /** Known clients and where their MCP server map lives. */
219
247
  export function knownClients() {
220
248
  const appSupport = process.platform === "darwin"
221
249
  ? home("Library", "Application Support")
222
250
  : process.env.APPDATA || home(".config");
251
+ const defaultClaudeDesktopRoot = path.join(appSupport, "Claude");
252
+ const claudeDesktopRoots = claudeDesktopConfigRoots(defaultClaudeDesktopRoot);
253
+ const claudeDesktopClients = (claudeDesktopRoots.length > 0 ? claudeDesktopRoots : [defaultClaudeDesktopRoot]).map((supportRoot) => ({
254
+ id: "claude-desktop",
255
+ label: "Claude Desktop",
256
+ kind: "json",
257
+ configPath: path.join(supportRoot, "claude_desktop_config.json"),
258
+ detected: claudeDesktopRoots.length > 0,
259
+ }));
223
260
  return [
224
261
  { id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
225
262
  { id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
226
- { id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
263
+ ...claudeDesktopClients,
227
264
  { id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json -s user echomem '<entry>' (or add to .mcp.json)" },
228
265
  { id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
229
266
  ];
230
267
  }
231
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
+ }
232
279
  export function detectClients() {
233
280
  return knownClients().filter((c) => {
234
- if (c.kind === "json")
281
+ if (c.kind === "json") {
282
+ if (c.id === "claude-desktop")
283
+ return c.detected === true;
235
284
  return fs.existsSync(path.dirname(c.configPath));
285
+ }
236
286
  if (c.kind === "command")
237
287
  return fs.existsSync(c.detectDir);
238
288
  if (c.id === "claude-code")
239
- return fs.existsSync(home(".claude"));
289
+ return claudeCodeInstalled();
240
290
  return false;
241
291
  });
242
292
  }
@@ -278,33 +328,6 @@ export function buildServerEntry(opts = {}) {
278
328
  // Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
279
329
  return { command: "npx", args: ["@echomem/mcp"] };
280
330
  }
281
- /** True when a resolved entry lives inside npx's throwaway cache (`…/_npx/<hash>/…`). */
282
- function isEphemeralNpxPath(entry) {
283
- return entry.split(path.sep).includes("_npx");
284
- }
285
- /**
286
- * Locate a DURABLE global install of the bridge (the one `npm i -g @echomem/mcp` creates). Global
287
- * modules sit next to the running node — `<node>/../lib/node_modules` (nvm/unix) or `<node>/node_modules`
288
- * (Windows). Returns the realpath'd dist entry, or null when the package isn't globally installed.
289
- */
290
- function resolveGlobalEntry() {
291
- const nodeDir = path.dirname(process.execPath);
292
- const pkgParts = MCP_PACKAGE_NAME.split("/"); // ["@echomem", "mcp"]
293
- const candidates = [
294
- path.join(nodeDir, "..", "lib", "node_modules", ...pkgParts, "dist", "index.js"),
295
- path.join(nodeDir, "node_modules", ...pkgParts, "dist", "index.js"),
296
- ];
297
- for (const candidate of candidates) {
298
- try {
299
- if (fs.existsSync(candidate))
300
- return fs.realpathSync(candidate);
301
- }
302
- catch {
303
- /* keep trying */
304
- }
305
- }
306
- return null;
307
- }
308
331
  function installDurableHeadlessRuntime() {
309
332
  if (process.env.ECHO_DISABLE_RUNTIME_BOOTSTRAP === "1")
310
333
  return;
@@ -543,7 +566,7 @@ function claudeCodeLocalEchoMemProjects(configPath) {
543
566
  .map(([projectPath]) => projectPath)
544
567
  .sort();
545
568
  }
546
- function versionedClaudeCodeExecutables(root, executable) {
569
+ function versionedClaudeCodeExecutables(root, executable, limit = Number.POSITIVE_INFINITY) {
547
570
  let versions;
548
571
  try {
549
572
  versions = fs.readdirSync(root, { withFileTypes: true });
@@ -555,7 +578,8 @@ function versionedClaudeCodeExecutables(root, executable) {
555
578
  .filter((entry) => entry.isDirectory())
556
579
  .sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }))
557
580
  .map((entry) => path.join(root, entry.name, executable))
558
- .filter((candidate) => fs.existsSync(candidate));
581
+ .filter((candidate) => fs.existsSync(candidate))
582
+ .slice(0, limit);
559
583
  }
560
584
  /**
561
585
  * Return every safe Claude Code launcher location worth trying. Claude Desktop bundles the CLI,
@@ -574,13 +598,13 @@ export function claudeCodeCommandCandidates(options = {}) {
574
598
  if (platform === "win32") {
575
599
  const appData = env.APPDATA?.trim() || path.join(homeDir, "AppData", "Roaming");
576
600
  const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir, "AppData", "Local");
577
- 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));
578
602
  const packagesRoot = path.join(localAppData, "Packages");
579
603
  try {
580
604
  for (const entry of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
581
605
  if (!entry.isDirectory() || !/^Claude_/i.test(entry.name))
582
606
  continue;
583
- 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));
584
608
  }
585
609
  }
586
610
  catch {
@@ -613,53 +637,118 @@ function escapeWindowsCmdArgument(value) {
613
637
  escaped = `"${escaped}"`;
614
638
  return escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
615
639
  }
616
- 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;
617
677
  let candidates = commandCandidates ? [...commandCandidates] : claudeCodeCommandCandidates();
618
- 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;
619
686
  try {
620
- const pathMatches = execFileSync("where.exe", ["claude"], {
687
+ execClaudeCodeCommandSync(command, ["--version"], {
621
688
  encoding: "utf8",
622
689
  stdio: ["ignore", "pipe", "ignore"],
623
- timeout: 3000,
624
- windowsHide: true,
625
- }).split(/\r?\n/).map((candidate) => candidate.trim()).filter(Boolean);
626
- candidates = [...new Set([...pathMatches, ...candidates])];
690
+ timeout: remaining,
691
+ });
692
+ if (!commandCandidates)
693
+ cachedClaudeCodeCommand = command;
694
+ return command;
627
695
  }
628
696
  catch {
629
- /* The bundled Desktop candidates below remain available. */
697
+ /* Keep trying only while the shared deadline has time left. */
630
698
  }
631
699
  }
632
- let lastError = new Error("Claude Code CLI was not found.");
633
- for (const command of candidates) {
634
- try {
635
- if (process.platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
636
- const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
637
- const spawnOptions = {
638
- ...options,
639
- windowsHide: true,
640
- windowsVerbatimArguments: true,
641
- };
642
- const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
643
- if (result.error)
644
- throw result.error;
645
- if (result.status !== 0) {
646
- throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
647
- }
648
- return result.stdout || "";
649
- }
650
- return execFileSync(command, args, process.platform === "win32" ? { ...options, windowsHide: true } : options);
651
- }
652
- catch (error) {
653
- lastError = error;
654
- }
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);
708
+ }
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) {
725
+ try {
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);
740
+ return true;
741
+ }
742
+ catch {
743
+ return false;
655
744
  }
656
- throw lastError;
657
745
  }
658
746
  export function writeClaudeCodeConfig(entry, options = {}) {
659
747
  // EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
660
748
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
661
749
  // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
662
750
  const configPath = options.configPath ?? home(".claude.json");
751
+ const configurationDeadline = Date.now() + 5000;
663
752
  let failureReason;
664
753
  const emptyResult = () => ({
665
754
  state: "unavailable",
@@ -670,14 +759,27 @@ export function writeClaudeCodeConfig(entry, options = {}) {
670
759
  preservedDesktopManaged: false,
671
760
  failureReason,
672
761
  });
762
+ let runnableCommand;
763
+ const cliCommand = () => {
764
+ if (runnableCommand === undefined) {
765
+ runnableCommand = resolveRunnableClaudeCodeCommand(options.claudeCommands, configurationDeadline);
766
+ }
767
+ return runnableCommand;
768
+ };
673
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
+ }
674
776
  try {
675
- execClaudeCodeSync(args, {
777
+ execClaudeCodeCommandSync(command, args, {
676
778
  cwd,
677
779
  encoding: "utf8",
678
780
  stdio: ["ignore", "pipe", "pipe"],
679
- timeout: 10000,
680
- }, options.claudeCommands);
781
+ timeout: Math.min(remaining, 5000),
782
+ });
681
783
  failureReason = undefined;
682
784
  return true;
683
785
  }
@@ -696,16 +798,31 @@ export function writeClaudeCodeConfig(entry, options = {}) {
696
798
  && validDesktopManagedEntry(previousUserEntry);
697
799
  const desiredUserEntry = preservedDesktopManaged ? previousUserEntry : entry;
698
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
+ };
699
812
  // Avoid interrupting active/new sessions when the correct global entry is already installed.
700
813
  if (!claudeEntriesMatch(previousUserEntry, desiredUserEntry)) {
701
- if (previousUserEntry && !removeUser())
702
- return emptyResult();
703
- if (!addUser(desiredUserEntry)) {
704
- const addFailureReason = failureReason;
705
- if (previousUserEntry)
706
- restoredPreviousUserEntry = addUser(previousUserEntry);
707
- failureReason = addFailureReason;
708
- 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
+ }
709
826
  }
710
827
  }
711
828
  const installedUserEntry = echoMemEntryFromServers(readClaudeCodeConfigFile(configPath).mcpServers);
@@ -746,6 +863,7 @@ export function writeClaudeCodeConfig(entry, options = {}) {
746
863
  failedLocalProjects: unresolved,
747
864
  restoredPreviousUserEntry,
748
865
  preservedDesktopManaged,
866
+ usedDirectWrite,
749
867
  };
750
868
  }
751
869
  function readJsonClientEntry(configPath) {
@@ -2589,6 +2707,11 @@ function parseFlags(argv) {
2589
2707
  }
2590
2708
  return flags;
2591
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
+ */
2592
2715
  async function cmdSetup(flags) {
2593
2716
  if (!flags.dev && !flags["skip-runtime-install"]) {
2594
2717
  try {
@@ -2641,6 +2764,9 @@ async function cmdSetup(flags) {
2641
2764
  if (result.preservedDesktopManaged) {
2642
2765
  console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
2643
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
+ }
2644
2770
  else {
2645
2771
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2646
2772
  }
@@ -2711,7 +2837,8 @@ async function cmdSetup(flags) {
2711
2837
  }
2712
2838
  }
2713
2839
  /**
2714
- * `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
2715
2842
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2716
2843
  * Codex skills and writes the AGENTS.md memory guidance. One browser
2717
2844
  * bridge then runs permission → login → plan if needed → extraction in that order.
@@ -2813,11 +2940,14 @@ async function cmdUpdate(flags) {
2813
2940
  function selectSetupTargets(requested, all) {
2814
2941
  if (all || requested === "all") {
2815
2942
  return knownClients().filter((client) => {
2816
- if (client.kind === "json")
2943
+ if (client.kind === "json") {
2944
+ if (client.id === "claude-desktop")
2945
+ return client.detected === true;
2817
2946
  return fs.existsSync(path.dirname(client.configPath));
2947
+ }
2818
2948
  if (client.kind === "command")
2819
2949
  return fs.existsSync(client.detectDir);
2820
- return client.id === "claude-code" && fs.existsSync(home(".claude"));
2950
+ return client.id === "claude-code" && claudeCodeInstalled();
2821
2951
  });
2822
2952
  }
2823
2953
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
@@ -3618,6 +3748,60 @@ async function cmdStatus(flags = {}) {
3618
3748
  }
3619
3749
  }
3620
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
+ }
3621
3805
  function cmdLogout() {
3622
3806
  const store = new KeyStore();
3623
3807
  try {
@@ -3666,9 +3850,18 @@ Manual / headless:
3666
3850
  Current bridge version: ${MCP_PACKAGE_VERSION}
3667
3851
  `;
3668
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"]);
3669
3855
  export async function runCli(argv) {
3670
3856
  const cmd = argv[0];
3671
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
+ }
3672
3865
  switch (cmd) {
3673
3866
  case "init":
3674
3867
  await cmdInit(flags);
@@ -3692,7 +3885,7 @@ export async function runCli(argv) {
3692
3885
  await cmdStatus(flags);
3693
3886
  return true;
3694
3887
  case "doctor":
3695
- await cmdStatus(flags);
3888
+ cmdDoctor();
3696
3889
  return true;
3697
3890
  case "logout":
3698
3891
  cmdLogout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.48",
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": {