@mnemom/mnemom 0.17.20 → 0.17.22

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/rc-proxy.mjs CHANGED
@@ -14,9 +14,10 @@
14
14
  // WHAT IT DOES
15
15
  // * Listens on 127.0.0.1 (ephemeral port) as an HTTP CONNECT proxy.
16
16
  // * Terminates TLS for api.anthropic.com ONLY, with an ephemeral CA (397 days, key held in
17
- // memory only) minted at startup into a private 0700 tmpdir (removed on exit), and a 7-day
18
- // leaf re-minted every 24 h and hot-swapped (see below). Every other host is tunnelled
19
- // byte-for-byte (telemetry, Datadog, MCP servers…).
17
+ // memory only) minted at startup into a private 0700 dir under ~/.mnemom/rc (removed on
18
+ // exit; see rc-state.mjs for why not the OS temp dir), and a 7-day leaf re-minted every 24 h
19
+ // and hot-swapped (see below). Every other host is tunnelled byte-for-byte (telemetry, Datadog,
20
+ // MCP servers…).
20
21
  // * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
21
22
  // gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
22
23
  // `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
@@ -90,6 +91,7 @@ import path from "node:path";
90
91
  import crypto from "node:crypto";
91
92
  import { execFileSync } from "node:child_process";
92
93
  import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
94
+ import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
93
95
 
94
96
  const MITM_HOST = "api.anthropic.com";
95
97
  const env = process.env;
@@ -125,7 +127,19 @@ const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
125
127
  // transcript. Implicit goal mode only — a sealed contract does not infer a goal from the user.
126
128
  const TURNS = GOAL_MODE && turnHeaderEnabled(env) ? new TurnOrigins() : null;
127
129
 
128
- const log = (line) => {
130
+ // If something deleted the log's dir, recreate it (once per line) rather than spill log lines onto
131
+ // the terminal Claude Code is drawing on (the launcher's stderr). True when the retry may go ahead.
132
+ const remakeLogDir = (e) => {
133
+ if (e.code !== "ENOENT") return false;
134
+ try {
135
+ fs.mkdirSync(path.dirname(LOGFILE), { recursive: true, mode: 0o700 });
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ };
141
+
142
+ const log = (line, retry = true) => {
129
143
  // One record per line: gateway header values and error bodies are spliced into log lines, so a
130
144
  // CR/LF inside them must not fabricate extra records (log injection).
131
145
  const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
@@ -133,8 +147,9 @@ const log = (line) => {
133
147
  try {
134
148
  fs.appendFileSync(LOGFILE, s);
135
149
  return;
136
- } catch {
137
- /* fall through */
150
+ } catch (e) {
151
+ // The retry goes through the one append above: there is a single write sink for log lines.
152
+ if (retry && remakeLogDir(e)) return log(line, false);
138
153
  }
139
154
  }
140
155
  process.stderr.write(s);
@@ -157,7 +172,7 @@ if (SUBSCRIPTION) {
157
172
  } else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
158
173
 
159
174
  // --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
160
- // Private 0700 dir under the OS tmpdir, removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
175
+ // Private 0700 dir under ~/.mnemom/rc (rc-state.mjs), removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
161
176
  // once at process start, so the CA it trusts can never change mid-session: the CA is valid for
162
177
  // CA_DAYS and its private key lives in memory only (read into a Buffer, then deleted from disk).
163
178
  // The leaf is short-lived and re-minted from that key every LEAF_ROTATE_MS — and on demand when
@@ -166,17 +181,42 @@ if (SUBSCRIPTION) {
166
181
  // startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
167
182
  // takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
168
183
  // after. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
169
- // MNEMOM_RC_LEAF_ROTATE_MS / MNEMOM_RC_LEAF_DAYS exist for tests only.
184
+ //
185
+ // Nothing may take the CA files mid-session. In the OS temp dir, macOS's dirhelper deleted
186
+ // ca.pem / leaf.ext from sessions older than ~3-4 days (files not accessed for 3 days; openssl
187
+ // reading them at each rotation does not move their atime), every later rotation failed, and
188
+ // Claude Code reported UNABLE_TO_VERIFY_LEAF_SIGNATURE until relaunch (2026-10-03). The dir now
189
+ // lives under ~/.mnemom/rc, which no cleaner touches (the OS temp dir only if home is not
190
+ // writable). Belt and braces: the CA PEM, the leaf.ext text and the owner pid live in memory too,
191
+ // and keepCaDir() — every CA_KEEPALIVE_MS and before every mint — recreates caDir if it is gone,
192
+ // restores any of the three that is missing or differs (ca.pem byte-identical at the same path:
193
+ // NODE_EXTRA_CA_CERTS points at it), and refreshes the atime/mtime of the dir and everything in
194
+ // it. At startup, sibling dirs left by dead sessions are swept (rc-state.mjs).
195
+ // MNEMOM_RC_LEAF_ROTATE_MS / MNEMOM_RC_LEAF_DAYS / MNEMOM_RC_CA_KEEPALIVE_MS exist for tests only.
170
196
  const CA_DAYS = 397;
171
197
  const LEAF_DAYS = Number(env.MNEMOM_RC_LEAF_DAYS || 7);
172
198
  const LEAF_ROTATE_MS = Number(env.MNEMOM_RC_LEAF_ROTATE_MS || 24 * 60 * 60 * 1000);
173
199
  const LEAF_MIN_LEFT_MS = 24 * 60 * 60 * 1000;
174
- const caDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnemom-code-rc-ca-"));
200
+ const CA_KEEPALIVE_MS = Number(env.MNEMOM_RC_CA_KEEPALIVE_MS || 6 * 60 * 60 * 1000);
201
+ let rcRoot;
202
+ try {
203
+ rcRoot = ensureRcStateRoot();
204
+ const swept = sweepRcDirs(rcRoot, { maxAgeMs: CA_DAYS * 24 * 60 * 60 * 1000 });
205
+ if (swept.length) log(`swept ${swept.length} dir(s) left by dead sessions under ${rcRoot}`);
206
+ } catch (e) {
207
+ rcRoot = os.tmpdir();
208
+ log(`cannot use ~/.mnemom/rc (${e.code || e.message}); CA dir falls back to ${rcRoot}`);
209
+ }
210
+ const caDir = fs.mkdtempSync(path.join(rcRoot, "mnemom-code-rc-ca-"));
175
211
  fs.chmodSync(caDir, 0o700);
176
212
  const p = (f) => path.join(caDir, f);
213
+ const OWNER = `${process.pid}\n`;
214
+ fs.writeFileSync(p(OWNER_FILE), OWNER, { mode: 0o600 });
177
215
  let rotateTimer = null;
216
+ let keepAliveTimer = null;
178
217
  const cleanup = () => {
179
218
  clearInterval(rotateTimer); // a no-op while still null
219
+ clearInterval(keepAliveTimer);
180
220
  try {
181
221
  fs.rmSync(caDir, { recursive: true, force: true });
182
222
  } catch {
@@ -190,6 +230,8 @@ const ossl = (...a) => execFileSync("openssl", a, { stdio: ["ignore", "ignore",
190
230
  const osslErr = (e) => (e.stderr && e.stderr.length ? e.stderr.toString().trim() : e.message);
191
231
 
192
232
  let caKey; // Buffer — never on disk outside mintLeaf()'s signing call
233
+ let caPem; // Buffer — the exact bytes of ca.pem, restored from here if the file goes missing
234
+ const LEAF_EXT = `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`;
193
235
  try {
194
236
  ossl(
195
237
  "req",
@@ -214,17 +256,43 @@ try {
214
256
  );
215
257
  caKey = fs.readFileSync(p("ca.key"));
216
258
  fs.rmSync(p("ca.key"));
217
- fs.writeFileSync(
218
- p("leaf.ext"),
219
- `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`,
220
- );
259
+ caPem = fs.readFileSync(p("ca.pem"));
260
+ fs.writeFileSync(p("leaf.ext"), LEAF_EXT);
221
261
  } catch (e) {
222
262
  fs.rmSync(p("ca.key"), { force: true });
223
263
  die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
224
264
  }
225
265
 
266
+ // Recreate caDir and its long-lived files from memory if something deleted them, and refresh
267
+ // every atime/mtime so the cleaner does not try again (see the block comment above).
268
+ // keepPrivateDir (rc-state.mjs) only ever reuses a dir that is ours: a real 0700 directory owned by
269
+ // this uid, checked on an fd opened without following a symlink. Anything else could hand Claude
270
+ // Code a CA someone else controls. Throws when the dir cannot be made safe (mintLeaf then fails
271
+ // and the current leaf stays); otherwise returns the names it restored.
272
+ const keepCaDir = () =>
273
+ keepPrivateDir(
274
+ caDir,
275
+ [
276
+ ["ca.pem", caPem, 0o644],
277
+ ["leaf.ext", Buffer.from(LEAF_EXT), 0o644],
278
+ [OWNER_FILE, Buffer.from(OWNER), 0o600],
279
+ ],
280
+ { logfile: LOGFILE },
281
+ );
282
+
283
+ function keepAlive(why) {
284
+ try {
285
+ const restored = keepCaDir();
286
+ if (restored.length) log(`ca keep-alive (${why}): restored ${restored.join(", ")}`);
287
+ } catch (e) {
288
+ log(`ca keep-alive FAILED (${why}): ${e.message}`);
289
+ }
290
+ }
291
+
226
292
  // Mint a fresh leaf key + cert signed by the in-memory CA key. Returns { key, cert, notAfter }.
227
293
  function mintLeaf() {
294
+ const restored = keepCaDir(); // a rotation must never fail on a deleted file
295
+ if (restored.length) log(`ca keep-alive (mint): restored ${restored.join(", ")}`);
228
296
  ossl(
229
297
  "req",
230
298
  "-newkey",
@@ -563,6 +631,8 @@ function rotateLeaf(why) {
563
631
  }
564
632
  rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
565
633
  rotateTimer.unref();
634
+ keepAliveTimer = setInterval(() => keepAlive("interval"), CA_KEEPALIVE_MS);
635
+ keepAliveTimer.unref();
566
636
 
567
637
  const proxy = http.createServer((req, res) => handle(req, res, true));
568
638
  proxy.on("connect", (req, sock, head) => {
@@ -0,0 +1,257 @@
1
+ // rc-state — where `mnemom agent --remote-control` keeps its per-session dirs, and the sweep that
2
+ // clears the ones a dead session left behind. Imported by rc-proxy.mjs; commands/agent.ts mirrors
3
+ // rcStateRoot() for the launcher's log dir (tsc emits no .mjs, so it cannot import this file).
4
+ //
5
+ // WHY NOT THE OS TEMP DIR
6
+ // The proxy's ephemeral CA must stay on disk for the whole session: Claude Code's
7
+ // NODE_EXTRA_CA_CERTS points at ca.pem, and every leaf rotation re-reads ca.pem and leaf.ext.
8
+ // OS temp dirs are cleaned behind a running process — macOS's dirhelper deletes $TMPDIR files
9
+ // not accessed for 3 days, and systemd-tmpfiles ages out /tmp on Linux — which broke every RC
10
+ // session older than ~3-4 days with UNABLE_TO_VERIFY_LEAF_SIGNATURE (2026-10-03). So the dirs
11
+ // live under the CLI's per-user state dir, ~/.mnemom (the convention auth.json, config.json and
12
+ // sessions.jsonl already follow on every platform), which nothing cleans. In exchange nothing
13
+ // cleans up after a crashed session either: each dir records its owner's pid in OWNER_FILE, and
14
+ // every proxy start sweeps the dirs whose owner is dead.
15
+ import crypto from "node:crypto";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+
20
+ /** Every per-session dir under the root starts with one of these: the CA dir, the log dir. */
21
+ export const RC_DIR_PREFIXES = ["mnemom-code-rc-ca-", "mnemom-code-rc."];
22
+ /** The pid of the process that owns (and removes on exit) the dir it sits in. */
23
+ export const OWNER_FILE = "owner.pid";
24
+
25
+ /** ~/.mnemom/rc on every platform. */
26
+ export function rcStateRoot(home = os.homedir()) {
27
+ return path.join(home, ".mnemom", "rc");
28
+ }
29
+
30
+ /**
31
+ * Create the root 0700 (and its parents) and return it. Throws when the home dir is not writable;
32
+ * the caller falls back to the OS temp dir, where the keep-alive in rc-proxy.mjs still applies.
33
+ */
34
+ export function ensureRcStateRoot(root = rcStateRoot()) {
35
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
36
+ fs.chmodSync(root, 0o700); // mkdir's mode is filtered through the umask, and skips an existing dir
37
+ return root;
38
+ }
39
+
40
+ /** Signal 0 probes without signalling. EPERM means it exists but is someone else's: alive. */
41
+ export function pidAlive(pid) {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch (e) {
46
+ return e.code === "EPERM";
47
+ }
48
+ }
49
+
50
+ // Open flags. O_NOFOLLOW / O_DIRECTORY / O_NONBLOCK are POSIX-only; on Windows they are absent from
51
+ // fs.constants and count as 0 (no symlink race to close there: no POSIX modes, per-user profile).
52
+ const C = fs.constants;
53
+ const NOFOLLOW = C.O_NOFOLLOW || 0;
54
+ const DIRECTORY = C.O_DIRECTORY || 0;
55
+ const NONBLOCK = C.O_NONBLOCK || 0; // a FIFO planted under a known name must not block the open
56
+
57
+ /** This process's uid, or null where there is none (native Windows): no owner check there. */
58
+ export function currentUid(proc = process) {
59
+ return typeof proc.getuid === "function" ? proc.getuid() : null;
60
+ }
61
+
62
+ /**
63
+ * Read a regular file without following a symlink at its last component (O_NOFOLLOW), checking
64
+ * the type on the OPEN fd. Returns null when it is missing, a symlink, or not a regular file.
65
+ */
66
+ export function readNoFollow(file, { fsImpl = fs } = {}) {
67
+ let fd;
68
+ try {
69
+ fd = fsImpl.openSync(file, C.O_RDONLY | NOFOLLOW | NONBLOCK);
70
+ } catch {
71
+ return null;
72
+ }
73
+ try {
74
+ return fsImpl.fstatSync(fd).isFile() ? fsImpl.readFileSync(fd) : null;
75
+ } finally {
76
+ fsImpl.closeSync(fd);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Set atime/mtime through an fd opened without following a symlink, so a planted link never moves
82
+ * its target's times. Returns false (and touches nothing) when the entry is missing or a symlink.
83
+ */
84
+ export function touchNoFollow(file, now, { fsImpl = fs } = {}) {
85
+ let fd;
86
+ try {
87
+ fd = fsImpl.openSync(file, C.O_RDONLY | NOFOLLOW | NONBLOCK);
88
+ } catch {
89
+ return false;
90
+ }
91
+ try {
92
+ fsImpl.futimesSync(fd, now, now);
93
+ return true;
94
+ } finally {
95
+ fsImpl.closeSync(fd);
96
+ }
97
+ }
98
+
99
+ /** Write `bytes` to `dir/name` via a fresh O_EXCL|O_NOFOLLOW temp file, then rename it in. */
100
+ function writeAtomic(fsImpl, dir, name, bytes, mode) {
101
+ const tmp = path.join(
102
+ dir,
103
+ `.${name}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`,
104
+ );
105
+ // O_EXCL: the temp name must not exist, so nothing planted there (a symlink included) is followed.
106
+ const fd = fsImpl.openSync(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | NOFOLLOW, mode);
107
+ try {
108
+ let off = 0;
109
+ while (off < bytes.length) off += fsImpl.writeSync(fd, bytes, off, bytes.length - off);
110
+ fsImpl.fchmodSync(fd, mode); // the open's mode is filtered through the umask
111
+ } finally {
112
+ fsImpl.closeSync(fd);
113
+ }
114
+ // Write-then-rename, so a reader (openssl, a starting Claude Code) never sees half a file.
115
+ fsImpl.renameSync(tmp, path.join(dir, name));
116
+ }
117
+
118
+ const refuse = (dir) =>
119
+ new Error(`refusing to reuse ${dir}: not a private directory owned by this user`);
120
+
121
+ /**
122
+ * Keep `dir` and its long-lived `files` ([name, bytes, mode] triples) present and fresh, against an
123
+ * OS cleaner that deletes what it has not seen accessed. Creates `dir` 0700 if it is gone, restores
124
+ * any file that is missing or differs (byte-identical, same path), and refreshes the atime/mtime of
125
+ * the dir, everything in it, and `logfile` (if any). Returns the names it restored ("dir" first if
126
+ * it had to recreate the dir).
127
+ *
128
+ * The dir decides what CA Claude Code trusts, so nothing here acts on a path it checked earlier:
129
+ * * the dir is opened ONCE without following a symlink (O_NOFOLLOW|O_DIRECTORY) and checked on
130
+ * that fd: a real directory, owned by this uid, with no group/other bits. A symlink, a file or
131
+ * a foreign/non-private dir at `dir` is refused (throws), and nothing is read or written.
132
+ * * every file read and every atime refresh opens the entry without following a symlink and
133
+ * checks the open fd; every write goes to a fresh O_EXCL|O_NOFOLLOW temp file and is renamed in.
134
+ * * Node has no openat(), so the per-file opens still resolve `dir` by path. Before returning, the
135
+ * path is checked to still name the directory the held fd was validated on (dev + inode); if
136
+ * anything swapped it in between, this throws. Who could swap it: the parent is ~/.mnemom/rc
137
+ * (0700, ours) or, in the fallback, the OS temp dir, whose sticky bit stops another user from
138
+ * replacing a dir we own; the fd check covers the case where it was deleted and re-planted.
139
+ */
140
+ export function keepPrivateDir(
141
+ dir,
142
+ files,
143
+ {
144
+ logfile = "",
145
+ now = new Date(),
146
+ fsImpl = fs,
147
+ uid = currentUid(),
148
+ platform = process.platform,
149
+ } = {},
150
+ ) {
151
+ const restored = [];
152
+ let created = false;
153
+ try {
154
+ fsImpl.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 });
155
+ fsImpl.mkdirSync(dir, { mode: 0o700 });
156
+ created = true;
157
+ restored.push("dir");
158
+ } catch (e) {
159
+ if (e.code !== "EEXIST") throw e;
160
+ }
161
+ let dfd;
162
+ try {
163
+ dfd = fsImpl.openSync(dir, C.O_RDONLY | DIRECTORY | NOFOLLOW);
164
+ } catch (e) {
165
+ // ELOOP: a symlink; ENOTDIR: a file (O_DIRECTORY). Either way not a dir we made.
166
+ if (e.code === "ELOOP" || e.code === "ENOTDIR") throw refuse(dir);
167
+ throw e;
168
+ }
169
+ try {
170
+ const st = fsImpl.fstatSync(dfd);
171
+ if (!st.isDirectory() || (uid !== null && st.uid !== uid)) throw refuse(dir);
172
+ if (created)
173
+ fsImpl.fchmodSync(dfd, 0o700); // mkdir's mode is filtered through the umask
174
+ else if (platform !== "win32" && (st.mode & 0o077) !== 0) throw refuse(dir); // no POSIX modes there
175
+ for (const [name, bytes, mode] of files) {
176
+ const cur = readNoFollow(path.join(dir, name), { fsImpl });
177
+ if (cur && cur.equals(bytes)) continue;
178
+ writeAtomic(fsImpl, dir, name, bytes, mode);
179
+ restored.push(name);
180
+ }
181
+ for (const f of fsImpl.readdirSync(dir)) {
182
+ // false: removed under us (e.g. ca.key between sign and rm), or a symlink: nothing to keep alive.
183
+ touchNoFollow(path.join(dir, f), now, { fsImpl });
184
+ }
185
+ fsImpl.futimesSync(dfd, now, now);
186
+ const at = fsImpl.lstatSync(dir);
187
+ const held = fsImpl.fstatSync(dfd);
188
+ if (at.dev !== held.dev || at.ino !== held.ino)
189
+ throw new Error(`${dir} was replaced while it was being kept alive`);
190
+ } finally {
191
+ fsImpl.closeSync(dfd);
192
+ }
193
+ // The log may live in a temp dir (a user's override). false: not created yet, or gone — log()
194
+ // recreates it.
195
+ if (logfile) touchNoFollow(logfile, now, { fsImpl });
196
+ return restored;
197
+ }
198
+
199
+ function ownerPid(dir, fsImpl) {
200
+ const b = readNoFollow(path.join(dir, OWNER_FILE), { fsImpl });
201
+ const n = b === null ? NaN : Number(b.toString("utf8").trim());
202
+ return Number.isInteger(n) && n > 0 ? n : null;
203
+ }
204
+
205
+ /** The name a swept dir is moved to before removal. It carries an RC prefix on purpose: if the
206
+ * sweep dies between the move and the removal, the next sweep finds it (its owner is dead). */
207
+ export const SWEEP_PREFIX = "mnemom-code-rc.swept-";
208
+
209
+ /**
210
+ * Remove the per-session dirs under `root` that no live process owns: those whose recorded owner
211
+ * is dead, and those with no readable owner that are older than `maxAgeMs` (a dir is only
212
+ * ownerless for the instant between its mkdtemp and its owner write, or after a crash in that
213
+ * instant). A dir whose owner is alive is never touched, however old. Returns the removed names.
214
+ *
215
+ * Race-safe removal: a candidate is first RENAMED to a fresh name under `root` (rename moves the
216
+ * entry itself and never follows a symlink), and only what was moved is checked (a real directory,
217
+ * via lstat) and removed. Nothing can be swapped in between the check and the removal, because the
218
+ * fresh name was never visible to anyone else. Whatever the move took that is not a directory is
219
+ * moved back. A dir that vanishes or changes mid-sweep (its owner's own cleanup) is skipped.
220
+ */
221
+ export function sweepRcDirs(
222
+ root,
223
+ { maxAgeMs, now = Date.now(), isAlive = pidAlive, fsImpl = fs } = {},
224
+ ) {
225
+ const removed = [];
226
+ let names;
227
+ try {
228
+ names = fsImpl.readdirSync(root);
229
+ } catch {
230
+ return removed;
231
+ }
232
+ for (const name of names) {
233
+ if (!RC_DIR_PREFIXES.some((pre) => name.startsWith(pre))) continue;
234
+ const dir = path.join(root, name);
235
+ try {
236
+ const st = fsImpl.lstatSync(dir);
237
+ if (!st.isDirectory()) continue;
238
+ const pid = ownerPid(dir, fsImpl);
239
+ if (pid !== null) {
240
+ if (pid === process.pid || isAlive(pid)) continue;
241
+ } else if (!(now - st.mtimeMs > maxAgeMs)) {
242
+ continue;
243
+ }
244
+ const moved = path.join(root, SWEEP_PREFIX + crypto.randomBytes(6).toString("hex"));
245
+ fsImpl.renameSync(dir, moved);
246
+ if (!fsImpl.lstatSync(moved).isDirectory()) {
247
+ fsImpl.renameSync(moved, dir); // swapped for a non-dir after the check: put it back
248
+ continue;
249
+ }
250
+ fsImpl.rmSync(moved, { recursive: true, force: true });
251
+ removed.push(name);
252
+ } catch {
253
+ /* raced with its owner's own cleanup: nothing to do */
254
+ }
255
+ }
256
+ return removed;
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.20",
3
+ "version": "0.17.22",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,10 @@
34
34
  "files": [
35
35
  "dist",
36
36
  "!dist/__tests__",
37
+ "plugins",
38
+ "!plugins/**/*.test.tsx",
39
+ "!plugins/mnemom/README.md",
40
+ "!plugins/mnemom/tsconfig.json",
37
41
  "README.md",
38
42
  "LICENSE"
39
43
  ],
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "mnemom",
3
+ "owner": { "name": "Mnemom", "email": "hello@mnemom.ai", "url": "https://www.mnemom.ai" },
4
+ "metadata": {
5
+ "description": "Mnemom plugins for Claude Code: governance, Trust Ratings and the Mnemom gateway.",
6
+ "version": "0.1.0"
7
+ },
8
+ "plugins": [
9
+ {
10
+ "name": "mnemom",
11
+ "source": "./mnemom",
12
+ "description": "Mnemom for Claude Code: the governed-agent band, /mnemom commands, the gateway guard and Mnemom attribution for a session launched by `mnemom agent`.",
13
+ "version": "0.1.0",
14
+ "author": { "name": "Mnemom", "email": "hello@mnemom.ai" },
15
+ "homepage": "https://docs.mnemom.ai",
16
+ "category": "productivity",
17
+ "keywords": ["mnemom", "governance", "trust", "gateway", "agent"]
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "mnemom",
3
+ "version": "0.1.0",
4
+ "description": "Mnemom for Claude Code: the governed-agent band, the goal (sealed contract or inferred, with guardrails and the gateway's verdict), /mnemom commands, the gateway guard and Mnemom attribution for a session launched by `mnemom agent`.",
5
+ "author": {
6
+ "name": "Mnemom",
7
+ "email": "hello@mnemom.ai",
8
+ "url": "https://www.mnemom.ai"
9
+ },
10
+ "homepage": "https://docs.mnemom.ai",
11
+ "repository": "https://github.com/mnemom/mnemom-platform",
12
+ "license": "Apache-2.0",
13
+ "keywords": ["mnemom", "governance", "trust", "gateway", "agent"],
14
+ "types": "./types/index.d.ts",
15
+ "userConfig": {
16
+ "showBand": {
17
+ "title": "Show the Mnemom band",
18
+ "type": "boolean",
19
+ "default": true,
20
+ "description": "Draw the Mnemom band above the prompt (agent, cell, goal and its verdict, Trust Rating). Hide it for the session with /mnemom hide."
21
+ },
22
+ "requireGovernance": {
23
+ "title": "Require a governed session",
24
+ "type": "boolean",
25
+ "default": false,
26
+ "description": "Refuse every tool call while this session is NOT routed through the Mnemom gateway (i.e. it was not launched by `mnemom agent`). Off: warn only."
27
+ },
28
+ "guardReroute": {
29
+ "title": "Guard the gateway routing",
30
+ "type": "boolean",
31
+ "default": true,
32
+ "description": "In a governed session, deny Bash commands that point ANTHROPIC_BASE_URL away from the Mnemom gateway or unset it."
33
+ },
34
+ "commitTrailer": {
35
+ "title": "Mnemom commit trailers",
36
+ "type": "boolean",
37
+ "default": true,
38
+ "description": "Append Mnemom-Agent / Mnemom-Conversation trailers to the commit attribution in a governed session."
39
+ },
40
+ "spinnerMark": {
41
+ "title": "Mark the spinner and turn line",
42
+ "type": "boolean",
43
+ "default": true,
44
+ "description": "Mark the working spinner and the turn-duration line with the Mnemom sign in a governed session."
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "mnemom": {
4
+ "type": "http",
5
+ "url": "https://api.mnemom.ai/mcp"
6
+ },
7
+ "mnemom-docs": {
8
+ "type": "http",
9
+ "url": "https://docs.mnemom.ai/mcp"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "modules": ["./register.tsx"]
3
+ }