@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.
- package/dist/durable-entry.js +122 -0
- package/dist/hud/hooks.js +17 -6
- package/dist/local-data-paths.js +68 -29
- package/dist/migrate.js +38 -8
- package/dist/setup.js +275 -82
- package/package.json +2 -2
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/dist/local-data-paths.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
+
return [];
|
|
100
93
|
const env = opts.env ?? process.env;
|
|
101
94
|
const configured = env.CLAUDE_DESKTOP_SUPPORT_DIR;
|
|
102
|
-
|
|
95
|
+
const platform = opts.platform ?? process.platform;
|
|
96
|
+
const candidates = [];
|
|
103
97
|
if (typeof configured === "string" && configured.trim()) {
|
|
104
|
-
|
|
98
|
+
candidates.push(expandCurrentUserHome(configured, homeDir));
|
|
105
99
|
}
|
|
106
|
-
else {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
687
|
+
execClaudeCodeCommandSync(command, ["--version"], {
|
|
621
688
|
encoding: "utf8",
|
|
622
689
|
stdio: ["ignore", "pipe", "ignore"],
|
|
623
|
-
timeout:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
690
|
+
timeout: remaining,
|
|
691
|
+
});
|
|
692
|
+
if (!commandCandidates)
|
|
693
|
+
cachedClaudeCodeCommand = command;
|
|
694
|
+
return command;
|
|
627
695
|
}
|
|
628
696
|
catch {
|
|
629
|
-
/*
|
|
697
|
+
/* Keep trying only while the shared deadline has time left. */
|
|
630
698
|
}
|
|
631
699
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
777
|
+
execClaudeCodeCommandSync(command, args, {
|
|
676
778
|
cwd,
|
|
677
779
|
encoding: "utf8",
|
|
678
780
|
stdio: ["ignore", "pipe", "pipe"],
|
|
679
|
-
timeout:
|
|
680
|
-
}
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
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" &&
|
|
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
|
-
|
|
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.
|
|
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": {
|