@echomem/mcp 1.4.49 → 1.4.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/config-files.js +63 -0
- package/dist/durable-entry.js +122 -0
- package/dist/headless-runtime.js +59 -8
- package/dist/hud/hooks.js +102 -37
- package/dist/index.js +129 -12
- package/dist/local-data-paths.js +1 -1
- package/dist/mcp-control.js +215 -0
- package/dist/migrate.js +36 -3
- package/dist/setup.js +670 -116
- package/dist/source-session.js +253 -75
- package/dist/v1-contract.js +34 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ npx -y @echomem/mcp@latest init
|
|
|
54
54
|
Windows PowerShell:
|
|
55
55
|
|
|
56
56
|
```powershell
|
|
57
|
-
npx.cmd -y @echomem/mcp@latest
|
|
57
|
+
npx.cmd -y @echomem/mcp@latest connect
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The one-off `npx` command is the recommended install path; it stages a durable per-user runtime
|
|
@@ -63,7 +63,9 @@ Node if `npx.cmd` is not found.
|
|
|
63
63
|
|
|
64
64
|
| Command | What it does |
|
|
65
65
|
|---|---|
|
|
66
|
-
| `
|
|
66
|
+
| `npx -y @echomem/mcp@latest connect` | Open Connect Echo for account readiness, per-host setup, Doctor, repair, and uninstall (use `npx.cmd` in Windows PowerShell) |
|
|
67
|
+
| `npx -y @echomem/mcp@latest init` | One-command setup for installed agents + login (use `npx.cmd` in Windows PowerShell) |
|
|
68
|
+
| `npm i -g @echomem/mcp@latest`, then `echomem-mcp init` | Optional global install for a persistent CLI command |
|
|
67
69
|
| `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and configure just the detected editor |
|
|
68
70
|
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
69
71
|
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
|
|
@@ -75,8 +77,31 @@ Node if `npx.cmd` is not found.
|
|
|
75
77
|
| `echomem-mcp lock` | Remove the local vault key while keeping the device login |
|
|
76
78
|
| `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
|
|
77
79
|
| `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
|
|
80
|
+
| `echomem-mcp control` / `manage` | Compatibility aliases for the returning-user Connect Echo page |
|
|
81
|
+
| `echomem-mcp reconnect` | Clean-reinstall EchoMem's managed runtime, clear inactive runtime cache, and repair detected host entries |
|
|
82
|
+
| `echomem-mcp uninstall --confirm` | Remove EchoMem MCP host entries/runtime while preserving login, vault credentials, and cloud memories |
|
|
78
83
|
| `echomem-mcp logout` | Remove stored credentials |
|
|
79
84
|
|
|
85
|
+
### Windows Connect Echo (no desktop app required)
|
|
86
|
+
|
|
87
|
+
If you installed the optional global CLI, run this from PowerShell:
|
|
88
|
+
|
|
89
|
+
```powershell
|
|
90
|
+
echomem-mcp connect
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For the recommended one-off install, launch the same local control page with:
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
npx.cmd -y @echomem/mcp@latest connect
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The page binds only to `127.0.0.1` and uses a per-launch nonce. It validates account/vault state,
|
|
100
|
+
shows each detected MCP host independently, and provides Connect, Repair, Disconnect, Doctor, and
|
|
101
|
+
clean Reconnect actions. **Uninstall MCP** stays under Advanced controls and removes only EchoMem-owned
|
|
102
|
+
host entries, hooks, guidance, skills, and managed runtime files; it does not remove the local
|
|
103
|
+
EchoMem credential store or any cloud memory. Restart each agent after configuration changes.
|
|
104
|
+
|
|
80
105
|
The bridge reports its package version in MCP server instructions and in tool descriptions. It also
|
|
81
106
|
checks npm for a newer published bridge using a cached, non-blocking check. Standalone installations
|
|
82
107
|
stage compatible updates in the background under `~/.echomem/mcp-runtime` and atomically activate
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function isErrno(error, code) {
|
|
5
|
+
return error instanceof Error
|
|
6
|
+
&& "code" in error
|
|
7
|
+
&& error.code === code;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read a user-owned JSON configuration without treating corruption as an empty file.
|
|
11
|
+
* Setup must fail closed here: replacing malformed JSON would destroy unrelated client settings.
|
|
12
|
+
*/
|
|
13
|
+
export function readJsonObjectFile(file, label = "configuration") {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = fs.readFileSync(file, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (isErrno(error, "ENOENT"))
|
|
20
|
+
return {};
|
|
21
|
+
throw new Error(`Could not read ${label} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
}
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw new Error(`Could not safely update ${label} at ${file} because the existing file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error(`Could not safely update ${label} at ${file} because the existing JSON root is not an object.`);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
/** Write beside the destination and rename only after the complete replacement is durable locally. */
|
|
36
|
+
export function atomicWriteTextFile(file, content) {
|
|
37
|
+
const directory = path.dirname(file);
|
|
38
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
39
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
40
|
+
let mode;
|
|
41
|
+
try {
|
|
42
|
+
mode = fs.statSync(file).mode;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* A fresh file uses the process default mode. */
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(temporary, content, mode === undefined ? undefined : { mode });
|
|
49
|
+
fs.renameSync(temporary, file);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
try {
|
|
53
|
+
fs.rmSync(temporary, { force: true });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* Preserve the original error. */
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function atomicWriteJsonObject(file, value) {
|
|
62
|
+
atomicWriteTextFile(file, JSON.stringify(value, null, 2));
|
|
63
|
+
}
|
|
@@ -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/headless-runtime.js
CHANGED
|
@@ -173,28 +173,79 @@ function finishStaging(staging, destination, version) {
|
|
|
173
173
|
fs.renameSync(staging, destination);
|
|
174
174
|
return activateRuntime(destination, version);
|
|
175
175
|
}
|
|
176
|
-
|
|
176
|
+
function pruneInactiveRuntimeVersions(activeVersion) {
|
|
177
|
+
const removed = [];
|
|
178
|
+
let entries = [];
|
|
179
|
+
try {
|
|
180
|
+
entries = fs.readdirSync(versionsRoot(), { withFileTypes: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return removed;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
// A dot-prefixed directory is another install's staging area. Never prune in-flight work.
|
|
187
|
+
if (entry.name === activeVersion || entry.name.startsWith("."))
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
safeVersion(entry.name);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const target = path.join(versionsRoot(), entry.name);
|
|
196
|
+
try {
|
|
197
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
198
|
+
removed.push(entry.name);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* Cache pruning is best effort; the active clean runtime is already safe. */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
|
206
|
+
export function installHeadlessRuntimeSync(targetVersion = MCP_PACKAGE_VERSION, options = {}) {
|
|
177
207
|
const version = safeVersion(targetVersion);
|
|
178
208
|
const existing = readHeadlessRuntimeInstallation();
|
|
179
|
-
if (existing?.version === version)
|
|
209
|
+
if (!options.force && existing?.version === version)
|
|
180
210
|
return existing;
|
|
181
211
|
const destination = path.join(versionsRoot(), version);
|
|
182
|
-
if (readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
|
|
183
|
-
|
|
212
|
+
if (!options.force && readPackageVersion(destination) === version && fs.existsSync(packageEntryForPrefix(destination))) {
|
|
213
|
+
const installation = activateRuntime(destination, version);
|
|
214
|
+
if (options.prune)
|
|
215
|
+
pruneInactiveRuntimeVersions(version);
|
|
216
|
+
return installation;
|
|
184
217
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
218
|
+
const releaseLock = acquireUpdateLock();
|
|
219
|
+
if (!releaseLock)
|
|
220
|
+
throw new Error("Another EchoMem runtime update is already in progress. Retry reconnect in a moment.");
|
|
221
|
+
let staging = "";
|
|
187
222
|
try {
|
|
223
|
+
staging = prepareStaging(version).staging;
|
|
224
|
+
const npm = resolveNpmInvocation();
|
|
188
225
|
execFileSync(npm.command, [...npm.prefixArgs, ...npmInstallArguments(staging, version)], {
|
|
189
226
|
stdio: "inherit",
|
|
190
227
|
windowsHide: true,
|
|
191
228
|
});
|
|
192
|
-
|
|
229
|
+
const installation = finishStaging(staging, destination, version);
|
|
230
|
+
if (options.prune)
|
|
231
|
+
pruneInactiveRuntimeVersions(version);
|
|
232
|
+
return installation;
|
|
193
233
|
}
|
|
194
234
|
catch (error) {
|
|
195
|
-
|
|
235
|
+
if (staging)
|
|
236
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
196
237
|
throw new Error(`Could not stage ${MCP_PACKAGE_NAME}@${version}: ${error instanceof Error ? error.message : String(error)}`);
|
|
197
238
|
}
|
|
239
|
+
finally {
|
|
240
|
+
releaseLock();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Remove only EchoMem's staged standalone runtime. Credentials and cloud data live elsewhere. */
|
|
244
|
+
export function removeHeadlessRuntime() {
|
|
245
|
+
const root = headlessRuntimeRoot();
|
|
246
|
+
const removed = fs.existsSync(root);
|
|
247
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
248
|
+
return { removed, path: root };
|
|
198
249
|
}
|
|
199
250
|
function acquireUpdateLock() {
|
|
200
251
|
const lock = path.join(headlessRuntimeRoot(), "update.lock");
|
package/dist/hud/hooks.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
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 { atomicWriteJsonObject, readJsonObjectFile } from "../config-files.js";
|
|
5
|
+
import { resolveDurableDistPath } from "../durable-entry.js";
|
|
6
|
+
/**
|
|
7
|
+
* Hook commands are persisted into another program's settings and must keep working long after this
|
|
8
|
+
* process exits. Resolving them from `import.meta.url` pins them to whatever copy happens to be
|
|
9
|
+
* running — under `npx` that is the `_npx/<hash>` cache, which npm garbage-collects, leaving a hook
|
|
10
|
+
* that points at nothing. Always resolve through the durable-entry helper instead.
|
|
11
|
+
*/
|
|
12
|
+
function hookCommandFor(distRelative, subcommand) {
|
|
13
|
+
const cli = resolveDurableDistPath(distRelative);
|
|
14
|
+
if (cli.ephemeral) {
|
|
15
|
+
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.`);
|
|
16
|
+
}
|
|
17
|
+
return `${JSON.stringify(process.execPath)} ${JSON.stringify(cli.path)} ${subcommand}`;
|
|
18
|
+
}
|
|
5
19
|
export function installHooks(mode) {
|
|
6
20
|
const written = [];
|
|
7
21
|
if (mode === "codex" || mode === "both" || mode === "auto") {
|
|
@@ -32,46 +46,81 @@ export function installSourceSessionHooks(mode) {
|
|
|
32
46
|
}
|
|
33
47
|
return written;
|
|
34
48
|
}
|
|
49
|
+
/** Remove only EchoMem-owned lifecycle hook groups and preserve every unrelated user hook. */
|
|
50
|
+
export function removeLifecycleHooks(mode) {
|
|
51
|
+
const files = [];
|
|
52
|
+
let removedGroups = 0;
|
|
53
|
+
const targets = [];
|
|
54
|
+
if (mode === "codex" || mode === "both" || mode === "auto") {
|
|
55
|
+
targets.push(path.join(profileDirectory("CODEX_HOME", ".codex"), "hooks.json"));
|
|
56
|
+
}
|
|
57
|
+
if (mode === "claude-code" || mode === "both" || mode === "auto") {
|
|
58
|
+
targets.push(path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "settings.json"));
|
|
59
|
+
}
|
|
60
|
+
for (const file of targets) {
|
|
61
|
+
if (!fs.existsSync(file))
|
|
62
|
+
continue;
|
|
63
|
+
const content = readHooksFile(file);
|
|
64
|
+
const hooks = content.hooks;
|
|
65
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
66
|
+
continue;
|
|
67
|
+
let changed = false;
|
|
68
|
+
for (const [event, value] of Object.entries(hooks)) {
|
|
69
|
+
if (!Array.isArray(value))
|
|
70
|
+
continue;
|
|
71
|
+
const kept = value.filter((group) => !isEchoLifecycleGroup(group));
|
|
72
|
+
const removed = value.length - kept.length;
|
|
73
|
+
if (removed === 0)
|
|
74
|
+
continue;
|
|
75
|
+
removedGroups += removed;
|
|
76
|
+
changed = true;
|
|
77
|
+
if (kept.length > 0)
|
|
78
|
+
hooks[event] = kept;
|
|
79
|
+
else
|
|
80
|
+
delete hooks[event];
|
|
81
|
+
}
|
|
82
|
+
if (!changed)
|
|
83
|
+
continue;
|
|
84
|
+
if (Object.keys(hooks).length === 0)
|
|
85
|
+
delete content.hooks;
|
|
86
|
+
atomicWriteJsonObject(file, content);
|
|
87
|
+
files.push(file);
|
|
88
|
+
}
|
|
89
|
+
return { files, removedGroups };
|
|
90
|
+
}
|
|
35
91
|
function installCodexHooks() {
|
|
36
|
-
const dir =
|
|
92
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
37
93
|
const file = path.join(dir, "hooks.json");
|
|
38
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
-
const hookCommand =
|
|
95
|
+
const hookCommand = hookCommandFor("index.js", "summary --client codex --json");
|
|
40
96
|
const content = readHooksFile(file);
|
|
41
97
|
content.hooks = content.hooks || {};
|
|
42
|
-
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [
|
|
43
|
-
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [
|
|
44
|
-
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [
|
|
45
|
-
|
|
98
|
+
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [codexCommandHook(hookCommand, 5)] });
|
|
99
|
+
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [codexCommandHook(hookCommand, 5)] });
|
|
100
|
+
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [codexCommandHook(hookCommand, 5)] });
|
|
101
|
+
atomicWriteJsonObject(file, content);
|
|
46
102
|
return file;
|
|
47
103
|
}
|
|
48
104
|
function saveCheckpointCommand() {
|
|
49
|
-
|
|
50
|
-
return `${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycleCli)} save-checkpoint`;
|
|
105
|
+
return hookCommandFor(path.join("hud", "cli.js"), "save-checkpoint");
|
|
51
106
|
}
|
|
52
107
|
function sourceSessionCommand() {
|
|
53
|
-
|
|
54
|
-
return `${JSON.stringify(process.execPath)} ${JSON.stringify(lifecycleCli)} bind-source-session`;
|
|
108
|
+
return hookCommandFor(path.join("hud", "cli.js"), "bind-source-session");
|
|
55
109
|
}
|
|
56
110
|
function installCodexSourceSessionHook() {
|
|
57
|
-
const dir =
|
|
111
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
58
112
|
const file = path.join(dir, "hooks.json");
|
|
59
113
|
fs.mkdirSync(dir, { recursive: true });
|
|
60
114
|
const content = readHooksFile(file);
|
|
61
115
|
content.hooks = content.hooks || {};
|
|
62
116
|
content.hooks.SessionStart = mergeSourceSessionGroup(content.hooks.SessionStart, {
|
|
63
|
-
hooks: [
|
|
64
|
-
type: "command",
|
|
65
|
-
command: sourceSessionCommand(),
|
|
66
|
-
timeout: 5,
|
|
67
|
-
statusMessage: "Binding EchoMem to this conversation",
|
|
68
|
-
}],
|
|
117
|
+
hooks: [codexCommandHook(sourceSessionCommand(), 5, "Binding EchoMem to this conversation")],
|
|
69
118
|
});
|
|
70
|
-
|
|
119
|
+
atomicWriteJsonObject(file, content);
|
|
71
120
|
return file;
|
|
72
121
|
}
|
|
73
122
|
function installClaudeCodeSourceSessionHook() {
|
|
74
|
-
const dir =
|
|
123
|
+
const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
75
124
|
const file = path.join(dir, "settings.json");
|
|
76
125
|
fs.mkdirSync(dir, { recursive: true });
|
|
77
126
|
const content = readHooksFile(file);
|
|
@@ -84,28 +133,23 @@ function installClaudeCodeSourceSessionHook() {
|
|
|
84
133
|
statusMessage: "Binding EchoMem to this conversation",
|
|
85
134
|
}],
|
|
86
135
|
});
|
|
87
|
-
|
|
136
|
+
atomicWriteJsonObject(file, content);
|
|
88
137
|
return file;
|
|
89
138
|
}
|
|
90
139
|
function installCodexSaveCheckpointHook() {
|
|
91
|
-
const dir =
|
|
140
|
+
const dir = profileDirectory("CODEX_HOME", ".codex");
|
|
92
141
|
const file = path.join(dir, "hooks.json");
|
|
93
142
|
fs.mkdirSync(dir, { recursive: true });
|
|
94
143
|
const content = readHooksFile(file);
|
|
95
144
|
content.hooks = content.hooks || {};
|
|
96
145
|
content.hooks.Stop = mergeSaveCheckpointGroup(content.hooks.Stop, {
|
|
97
|
-
hooks: [
|
|
98
|
-
type: "command",
|
|
99
|
-
command: saveCheckpointCommand(),
|
|
100
|
-
timeout: 10,
|
|
101
|
-
statusMessage: "Checking whether completed work should be remembered",
|
|
102
|
-
}],
|
|
146
|
+
hooks: [codexCommandHook(saveCheckpointCommand(), 10, "Checking whether completed work should be remembered")],
|
|
103
147
|
});
|
|
104
|
-
|
|
148
|
+
atomicWriteJsonObject(file, content);
|
|
105
149
|
return file;
|
|
106
150
|
}
|
|
107
151
|
function installClaudeCodeSaveCheckpointHook() {
|
|
108
|
-
const dir =
|
|
152
|
+
const dir = profileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
109
153
|
const file = path.join(dir, "settings.json");
|
|
110
154
|
fs.mkdirSync(dir, { recursive: true });
|
|
111
155
|
const content = readHooksFile(file);
|
|
@@ -118,22 +162,40 @@ function installClaudeCodeSaveCheckpointHook() {
|
|
|
118
162
|
statusMessage: "Checking whether completed work should be remembered",
|
|
119
163
|
}],
|
|
120
164
|
});
|
|
121
|
-
|
|
165
|
+
atomicWriteJsonObject(file, content);
|
|
122
166
|
return file;
|
|
123
167
|
}
|
|
124
168
|
function installClaudeCodeSnippet() {
|
|
125
|
-
const dir = path.join(
|
|
169
|
+
const dir = path.join(profileDirectory("CLAUDE_CONFIG_DIR", ".claude"), "echo-ctx");
|
|
126
170
|
fs.mkdirSync(dir, { recursive: true });
|
|
127
171
|
return dir;
|
|
128
172
|
}
|
|
129
173
|
function readHooksFile(file) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
174
|
+
return readJsonObjectFile(file, "agent hooks");
|
|
175
|
+
}
|
|
176
|
+
function profileDirectory(envKey, fallbackName) {
|
|
177
|
+
const home = os.homedir();
|
|
178
|
+
const configured = process.env[envKey]?.trim();
|
|
179
|
+
if (!configured)
|
|
180
|
+
return path.join(home, fallbackName);
|
|
181
|
+
if (configured === "~")
|
|
182
|
+
return home;
|
|
183
|
+
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
|
|
184
|
+
return path.join(home, configured.slice(2));
|
|
133
185
|
}
|
|
134
|
-
|
|
135
|
-
|
|
186
|
+
if (!path.isAbsolute(configured)) {
|
|
187
|
+
throw new Error(`${envKey} must be an absolute path or start with ~/`);
|
|
136
188
|
}
|
|
189
|
+
return path.normalize(configured);
|
|
190
|
+
}
|
|
191
|
+
function codexCommandHook(command, timeout, statusMessage) {
|
|
192
|
+
return {
|
|
193
|
+
type: "command",
|
|
194
|
+
command,
|
|
195
|
+
commandWindows: command,
|
|
196
|
+
timeout,
|
|
197
|
+
...(statusMessage ? { statusMessage } : {}),
|
|
198
|
+
};
|
|
137
199
|
}
|
|
138
200
|
function mergeHookGroup(existing, group) {
|
|
139
201
|
const groups = Array.isArray(existing) ? existing.filter((item) => !isEchoHudGroup(item)) : [];
|
|
@@ -165,3 +227,6 @@ function isEchoSaveCheckpointGroup(value) {
|
|
|
165
227
|
return false;
|
|
166
228
|
return JSON.stringify(value).includes("save-checkpoint");
|
|
167
229
|
}
|
|
230
|
+
function isEchoLifecycleGroup(value) {
|
|
231
|
+
return isEchoHudGroup(value) || isEchoSourceSessionGroup(value) || isEchoSaveCheckpointGroup(value);
|
|
232
|
+
}
|