memtrace-skills 1.1.12 → 1.2.0

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.
@@ -4,6 +4,7 @@ import { loadSkills } from '../skills.js';
4
4
  import { ALL_TRANSFORMERS, findTransformer } from '../transformers/index.js';
5
5
  import { commandExists, execCommand } from '../utils.js';
6
6
  import { canPrompt, promptForChoices } from './picker.js';
7
+ import { persistHookOptOut } from '../rail-runtime.js';
7
8
  export async function resolveMemtraceBinary() {
8
9
  if (await commandExists('memtrace'))
9
10
  return 'memtrace';
@@ -31,6 +32,9 @@ function selectTransformers(options) {
31
32
  return picks;
32
33
  }
33
34
  export async function runInstall(options) {
35
+ if (options.scope === 'global' && process.env.MEMTRACE_INSTALL_NO_HOOKS === '1') {
36
+ persistHookOptOut();
37
+ }
34
38
  console.log('\n Memtrace Skills Installer\n');
35
39
  // Interactive prompt only when TTY is available and user didn't pre-specify
36
40
  if (!options.yes && (!options.only || options.only.length === 0) && canPrompt()) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ /** Cached host hooks stop here when Memtrace is absent. No output and no network. */
2
+ import { spawn } from 'node:child_process';
3
+ import http from 'node:http';
4
+ import { railIsActive, runtimeForCwd, pidAlive } from './rail-runtime.js';
5
+ async function main() {
6
+ if (process.env.MEMTRACE_RAIL?.trim().toLowerCase() === 'off'
7
+ || process.env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow'
8
+ || !railIsActive())
9
+ return;
10
+ let input = '';
11
+ for await (const chunk of process.stdin) {
12
+ input += chunk;
13
+ if (input.length > 2_000_000)
14
+ return;
15
+ }
16
+ const payload = JSON.parse(input);
17
+ const cwd = payload.cwd || payload.workspace_roots?.[0] || process.cwd();
18
+ if (typeof cwd !== 'string')
19
+ return;
20
+ const runtime = runtimeForCwd(cwd);
21
+ if (!runtime)
22
+ return;
23
+ const hostIndex = process.argv.indexOf('--host');
24
+ const host = hostIndex >= 0 ? process.argv[hostIndex + 1] : 'generic';
25
+ const jsonMode = process.argv.includes('--json');
26
+ // JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
27
+ // Other hosts use the in-process daemon router with no native binary spawn.
28
+ if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
29
+ const query = new URLSearchParams({ host, mode: process.env.MEMTRACE_RAIL || '',
30
+ escape: process.env.MEMTRACE_RAW_SEARCH || '' });
31
+ const body = await new Promise(resolve => {
32
+ let finished = false;
33
+ const finish = (value = '') => {
34
+ if (finished)
35
+ return;
36
+ finished = true;
37
+ clearTimeout(deadline);
38
+ clearInterval(monitor);
39
+ resolve(value);
40
+ };
41
+ const request = http.request({ hostname: '127.0.0.1', port: runtime.port,
42
+ method: 'POST', path: `/api/rail/hook?${query}`,
43
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(input) },
44
+ }, response => {
45
+ let value = '';
46
+ response.on('data', chunk => {
47
+ value += chunk;
48
+ if (value.length > 2_000_000) {
49
+ finish();
50
+ request.destroy();
51
+ }
52
+ });
53
+ response.on('error', () => finish());
54
+ response.on('end', () => finish(response.statusCode === 200 ? value : ''));
55
+ });
56
+ const deadline = setTimeout(() => { finish(); request.destroy(); }, 5500);
57
+ const monitor = setInterval(() => {
58
+ if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)) {
59
+ finish();
60
+ request.destroy();
61
+ }
62
+ }, 50);
63
+ request.on('error', () => finish());
64
+ request.end(input);
65
+ });
66
+ if (body && runtimeForCwd(cwd)) {
67
+ JSON.parse(body); // never emit malformed endpoint output into an agent
68
+ process.stdout.write(body);
69
+ }
70
+ return;
71
+ }
72
+ const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
73
+ cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
74
+ env: { ...process.env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
75
+ });
76
+ let output = '';
77
+ let stderr = '';
78
+ child.stdout.on('data', chunk => { if (output.length < 2_000_000)
79
+ output += chunk; });
80
+ child.stderr.on('data', chunk => { if (stderr.length < 2_000_000)
81
+ stderr += chunk; });
82
+ child.stdin.on('error', () => { });
83
+ child.stdin.end(input);
84
+ const timer = setTimeout(() => child.kill(), 5500);
85
+ // If the daemon exits while a request is in flight, discard the response.
86
+ const monitor = setInterval(() => {
87
+ if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid))
88
+ child.kill();
89
+ }, 50);
90
+ const code = await new Promise(resolve => {
91
+ child.once('error', () => resolve(null));
92
+ child.once('close', resolve);
93
+ });
94
+ clearTimeout(timer);
95
+ clearInterval(monitor);
96
+ if (!runtimeForCwd(cwd))
97
+ return;
98
+ if (code === 0)
99
+ process.stdout.write(output);
100
+ else if (host === 'windsurf' && code === 2) {
101
+ process.stderr.write(stderr);
102
+ process.exitCode = 2;
103
+ }
104
+ }
105
+ main().catch(() => { });
@@ -6,6 +6,7 @@
6
6
  import fs from 'fs';
7
7
  import os from 'os';
8
8
  import path from 'path';
9
+ import { railIsActive } from './rail-runtime.js';
9
10
  import { registerRailHookInSettingsAt, removeRailHookFromSettingsAt, } from './transformers/claude.js';
10
11
  import { geminiSettingsPath } from './transformers/gemini.js';
11
12
  import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodeRailPluginPaths, openCodePluginSource, } from './transformers/rail-hooks.js';
@@ -27,6 +28,9 @@ function cursorHooksPath(ctx) {
27
28
  * Idempotent. Does not install skills or MCP — hooks only.
28
29
  */
29
30
  export function installRailHooks(ctx) {
31
+ if (!railIsActive()) {
32
+ return uninstallRailHooks(ctx).map(result => ({ ...result, skipped: 'Memtrace is not active; no hook registered' }));
33
+ }
30
34
  const bin = ctx.memtraceBinary?.trim() ?? '';
31
35
  if (!bin) {
32
36
  throw new Error('memtrace binary path is required to install Rail hooks');
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ /** The daemon owns this process through stdin; EOF also covers SIGKILL/crashes. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { installRailHooks, uninstallRailHooks } from './rail-install.js';
6
+ import { runtimeDirectory, railDisabled, activeRailRuntimes, pidAlive } from './rail-runtime.js';
7
+ import { writeJsonAtomic } from './fs-safe.js';
8
+ const arg = (name) => {
9
+ const index = process.argv.indexOf(name);
10
+ return index >= 0 ? process.argv[index + 1] : '';
11
+ };
12
+ const pid = Number(arg('--pid'));
13
+ const port = Number(arg('--port'));
14
+ const binary = arg('--binary');
15
+ const cwd = arg('--cwd');
16
+ const routeBinary = arg('--route-binary') || undefined;
17
+ async function main() {
18
+ if (process.argv.includes('--sync')) {
19
+ const live = activeRailRuntimes();
20
+ const ctx = { scope: 'global', cwd: process.cwd(),
21
+ memtraceBinary: live[0]?.binary || '', skipMcp: true };
22
+ if (live.length)
23
+ installRailHooks(ctx);
24
+ else
25
+ uninstallRailHooks(ctx);
26
+ return;
27
+ }
28
+ if (!pidAlive(pid) || !path.isAbsolute(binary || '') || !path.isAbsolute(cwd || '')
29
+ || !Number.isInteger(port) || port < 1 || port > 65535)
30
+ return;
31
+ const ctx = { scope: 'global', cwd, memtraceBinary: binary, skipMcp: true };
32
+ const directory = runtimeDirectory();
33
+ fs.mkdirSync(directory, { recursive: true });
34
+ const lease = path.join(directory, `${pid}-${randomUUID()}.json`);
35
+ let done = false;
36
+ let wired;
37
+ const reconcile = () => {
38
+ if (done)
39
+ return;
40
+ if (!pidAlive(pid)) {
41
+ stop();
42
+ return;
43
+ }
44
+ writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
45
+ const enabled = !railDisabled();
46
+ if (enabled === wired)
47
+ return;
48
+ if (enabled)
49
+ installRailHooks(ctx);
50
+ else
51
+ uninstallRailHooks(ctx);
52
+ wired = enabled;
53
+ };
54
+ const stop = () => {
55
+ if (done)
56
+ return;
57
+ done = true;
58
+ clearInterval(timer);
59
+ try {
60
+ fs.unlinkSync(lease);
61
+ }
62
+ catch { /* already absent */ }
63
+ // One departing daemon must not disable a different live workspace.
64
+ if (activeRailRuntimes().length === 0)
65
+ uninstallRailHooks(ctx);
66
+ process.stdin.destroy();
67
+ };
68
+ const timer = setInterval(() => {
69
+ try {
70
+ reconcile();
71
+ }
72
+ catch {
73
+ stop();
74
+ }
75
+ }, 500);
76
+ if (!process.argv.includes('--watch-pid')) {
77
+ process.stdin.on('end', stop);
78
+ // Windows named pipes report a killed writer as ECONNRESET/close.
79
+ process.stdin.on('error', stop);
80
+ process.stdin.on('close', stop);
81
+ process.stdin.resume();
82
+ }
83
+ process.on('SIGINT', stop);
84
+ process.on('SIGTERM', stop);
85
+ reconcile();
86
+ }
87
+ main().catch(() => { process.exitCode = 1; });
@@ -0,0 +1,26 @@
1
+ export interface RailRuntime {
2
+ pid: number;
3
+ guardianPid: number;
4
+ updatedAt: number;
5
+ cwd: string;
6
+ binary: string;
7
+ port: number;
8
+ /** Local development override; packaged hooks normally use the daemon HTTP API. */
9
+ routeBinary?: string;
10
+ }
11
+ export declare const LEASE_MAX_AGE_MS = 3000;
12
+ export declare const RAIL_GUARD_MARKER = "rail-hook.js";
13
+ export declare function runtimeDirectory(): string;
14
+ export declare function railDisabled(): boolean;
15
+ export declare function persistHookOptOut(): void;
16
+ export declare function pidAlive(pid: number): boolean;
17
+ export declare function activeRailRuntimes(): RailRuntime[];
18
+ export declare function railIsActive(): boolean;
19
+ export declare function runtimeForCwd(cwd: string): RailRuntime | undefined;
20
+ export declare function railHookInvocation(host: string): {
21
+ command: string;
22
+ args: string[];
23
+ };
24
+ /** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
25
+ export declare function railHookCommand(host: string, platform?: NodeJS.Platform): string;
26
+ export declare function isRailCommand(command: string): boolean;
@@ -0,0 +1,112 @@
1
+ /** Local daemon leases. Reading these never contacts a server or starts Memtrace. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { writeJsonAtomic } from './fs-safe.js';
7
+ export const LEASE_MAX_AGE_MS = 3000;
8
+ export const RAIL_GUARD_MARKER = 'rail-hook.js';
9
+ export function runtimeDirectory() {
10
+ return process.env.MEMTRACE_RAIL_RUNTIME_DIR?.trim()
11
+ || path.join(os.homedir(), '.memtrace', 'rail-runtimes');
12
+ }
13
+ export function railDisabled() {
14
+ if (process.env.MEMTRACE_INSTALL_NO_HOOKS === '1')
15
+ return true;
16
+ const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
17
+ || path.join(os.homedir(), '.memtrace', 'rail.json');
18
+ try {
19
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
20
+ // An explicit durable opt-out wins over an old agent's environment.
21
+ return config.mode === 'off' || config.hooksEnabled === false;
22
+ }
23
+ catch (error) {
24
+ return error.code !== 'ENOENT';
25
+ }
26
+ }
27
+ export function persistHookOptOut() {
28
+ const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
29
+ || path.join(os.homedir(), '.memtrace', 'rail.json');
30
+ let config = {};
31
+ try {
32
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 'ENOENT')
36
+ throw error;
37
+ }
38
+ writeJsonAtomic(configPath, { ...config, hooksEnabled: false });
39
+ }
40
+ export function pidAlive(pid) {
41
+ if (!Number.isSafeInteger(pid) || pid <= 0)
42
+ return false;
43
+ try {
44
+ process.kill(pid, 0);
45
+ return true;
46
+ }
47
+ catch (error) {
48
+ // EPERM means the PID exists but this token cannot signal it (for
49
+ // example an elevated Windows daemon). No signal is sent with signal 0.
50
+ // ESRCH is absence; leases also require a live guardian and fresh heartbeat.
51
+ return error.code === 'EPERM';
52
+ }
53
+ }
54
+ export function activeRailRuntimes() {
55
+ if (railDisabled())
56
+ return [];
57
+ try {
58
+ const now = Date.now();
59
+ return fs.readdirSync(runtimeDirectory()).filter(name => name.endsWith('.json')).flatMap(name => {
60
+ try {
61
+ const runtime = JSON.parse(fs.readFileSync(path.join(runtimeDirectory(), name), 'utf8'));
62
+ const age = now - runtime.updatedAt;
63
+ if (!Number.isFinite(age) || age < -1000 || age > LEASE_MAX_AGE_MS
64
+ || !pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)
65
+ || typeof runtime.binary !== 'string' || !path.isAbsolute(runtime.binary)
66
+ || (runtime.routeBinary !== undefined && !path.isAbsolute(runtime.routeBinary))
67
+ || typeof runtime.cwd !== 'string' || !path.isAbsolute(runtime.cwd)
68
+ || !Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65535)
69
+ return [];
70
+ return [runtime];
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ });
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
81
+ export function railIsActive() { return activeRailRuntimes().length > 0; }
82
+ function normalizedWorkspacePath(directory) {
83
+ let resolved = path.resolve(directory);
84
+ try {
85
+ resolved = fs.realpathSync.native(resolved);
86
+ }
87
+ catch { /* Unavailable paths retain the existing lexical matching behavior. */ }
88
+ const normalized = resolved.replace(/\\/g, '/').replace(/\/$/, '');
89
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
90
+ }
91
+ export function runtimeForCwd(cwd) {
92
+ const target = normalizedWorkspacePath(cwd);
93
+ return activeRailRuntimes()
94
+ .map(runtime => ({ runtime, root: normalizedWorkspacePath(runtime.cwd) }))
95
+ .filter(({ root }) => target === root || target.startsWith(root + '/'))
96
+ .sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
97
+ }
98
+ export function railHookInvocation(host) {
99
+ return { command: process.execPath,
100
+ args: [fileURLToPath(new URL('./rail-hook.js', import.meta.url)), '--host', host] };
101
+ }
102
+ /** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
103
+ export function railHookCommand(host, platform = process.platform) {
104
+ const invocation = railHookInvocation(host);
105
+ const quote = platform === 'win32'
106
+ ? (s) => '"' + s.replace(/\\/g, '/').replace(/"/g, '\\"') + '"'
107
+ : (s) => "'" + s.replace(/'/g, "'\\''") + "'";
108
+ return [invocation.command, ...invocation.args].map(quote).join(' ');
109
+ }
110
+ export function isRailCommand(command) {
111
+ return command.includes('route --hook') || command.includes(RAIL_GUARD_MARKER);
112
+ }
@@ -19,33 +19,20 @@ export interface SettingsMutationResult {
19
19
  * Merge-add the memtrace MCP server into a Claude settings.json file.
20
20
  * Never overwrites a malformed file — backs it up and returns registered=false.
21
21
  */
22
- export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string): SettingsMutationResult;
22
+ export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string, legacyEnv?: Record<string, string>): SettingsMutationResult;
23
23
  /**
24
- * The Claude Code PreToolUse hook command.
25
- *
26
- * POSIX hosts POST the hook payload to the running daemon's in-process router
27
- * (`POST /api/rail/hook`) with one `curl` — ~5ms of process instead of the
28
- * full memtrace binary (node shim + native pair, ~59MB alive for ~0.5s) per
29
- * Grep/Glob/Bash call. Fail-open is structural: no daemon (connection
30
- * refused, bounded by `--connect-timeout`) or an old daemon without the
31
- * endpoint (`-f` on 404) produces no output, and `|| true` exits 0 — Claude
32
- * Code treats that as allow-with-no-decision. The full binary is NEVER the
33
- * fallback. The agent session's `MEMTRACE_RAIL` / `MEMTRACE_RAW_SEARCH` ride
34
- * along as query params, so per-session mode/escape semantics are identical
35
- * to the spawn era; `MEMTRACE_RAIL_SEARCH_URL` / `MEMTRACE_UI_PORT` override
36
- * the endpoint the same way they steered the binary's own daemon lookup.
37
- *
38
- * Windows keeps the gen-1 binary spawn: hook commands there do not run under
39
- * a POSIX shell, so the env-forwarding one-liner has no portable equivalent.
24
+ * Every host enters a local liveness guard before contacting Memtrace. The
25
+ * daemon lifecycle registers/removes this command; a cached registration is
26
+ * silent after a stop or crash. Windows uses exec-form command/args below.
40
27
  */
41
- export declare function claudeRailHookCommand(memtraceBinary: string, platform?: NodeJS.Platform): string;
28
+ export declare function claudeRailHookCommand(_memtraceBinary: string, platform?: NodeJS.Platform): string;
42
29
  /**
43
30
  * Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
44
31
  *
45
32
  * This is what makes Rail "part of memtrace" rather than a manual install:
46
33
  * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
47
34
  * attempt through the Rail router (see {@link claudeRailHookCommand} for the
48
- * transport). The hook defaults to **observe** (zero behavior change)
35
+ * transport). While a daemon is running the hook defaults to **observe** —
49
36
  * `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
50
37
  * baked into the command so the user can change it via env without
51
38
  * re-installing.
@@ -75,6 +62,11 @@ export interface SettingsCleanupResult {
75
62
  * installer versions and Claude marketplace schema changes.
76
63
  */
77
64
  export declare function removeClaudeSettingsEntriesAt(settingsPath: string): SettingsCleanupResult;
65
+ /**
66
+ * Register the memtrace MCP server in Claude Code's settings.
67
+ * This adds the MCP server config so Claude Code can connect to memtrace tools.
68
+ */
69
+ export declare function registerMcpServer(memtraceBinaryPath: string): Promise<void>;
78
70
  /** `~/.claude.json` — where Claude Code stores user-scope `mcpServers`. */
79
71
  export declare function claudeUserConfigFile(): string;
80
72
  /**
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  import { copyCanonicalPluginAssets, copyCanonicalPluginManifest, readCanonicalPluginVersion, } from '../../plugin-assets/index.js';
6
6
  import { execCommand, commandExists } from '../utils.js';
7
7
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
8
+ import { railIsActive, railHookInvocation, railHookCommand, RAIL_GUARD_MARKER } from '../rail-runtime.js';
8
9
  import { LEGACY_MCP_SERVER_NAME, MEMTRACE_MCP_ENV, removeLegacyMcpServer, } from './shared.js';
9
10
  const PLUGIN_NAME = 'memtrace-skills';
10
11
  const MARKETPLACE_NAME = 'memtrace';
@@ -74,7 +75,10 @@ async function tryMcpAddJson(memtraceBinary) {
74
75
  // The legacy server is normally absent. Removal is a best-effort upgrade migration.
75
76
  }
76
77
  const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
77
- const existingEnv = readExistingMemtraceEnv(settingsFile);
78
+ const existingEnv = {
79
+ ...readExistingMemtraceEnv(settingsFile),
80
+ ...readExistingMemtraceEnv(path.join(os.homedir(), '.claude.json')),
81
+ };
78
82
  const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
79
83
  const config = JSON.stringify({
80
84
  command: memtraceBinary,
@@ -181,7 +185,7 @@ async function tryClaudeCliInstall() {
181
185
  * Merge-add the memtrace MCP server into a Claude settings.json file.
182
186
  * Never overwrites a malformed file — backs it up and returns registered=false.
183
187
  */
184
- export function registerMcpInSettingsAt(settingsPath, memtraceBinary) {
188
+ export function registerMcpInSettingsAt(settingsPath, memtraceBinary, legacyEnv = {}) {
185
189
  const { value, corrupted, backupPath } = safeReadJson(settingsPath);
186
190
  if (corrupted) {
187
191
  console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped MCP registration for Claude.`);
@@ -198,7 +202,7 @@ export function registerMcpInSettingsAt(settingsPath, memtraceBinary) {
198
202
  const existingEnv = (isRecord(existing) && isRecord(existing.env))
199
203
  ? existing.env
200
204
  : {};
201
- const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
205
+ const mergedEnv = { ...legacyEnv, ...existingEnv, ...MEMTRACE_MCP_ENV };
202
206
  // Hosts defer MCP tool definitions behind ToolSearch by default, so an
203
207
  // unmarked server reaches the model as bare tool names while built-in
204
208
  // Grep/Glob/Read arrive with full schemas — a routing bias no instruction
@@ -231,29 +235,12 @@ const LEGACY_HOOK_SCRIPT_BASENAMES = [
231
235
  'posttool-mcp-telemetry.sh',
232
236
  ];
233
237
  /**
234
- * The Claude Code PreToolUse hook command.
235
- *
236
- * POSIX hosts POST the hook payload to the running daemon's in-process router
237
- * (`POST /api/rail/hook`) with one `curl` — ~5ms of process instead of the
238
- * full memtrace binary (node shim + native pair, ~59MB alive for ~0.5s) per
239
- * Grep/Glob/Bash call. Fail-open is structural: no daemon (connection
240
- * refused, bounded by `--connect-timeout`) or an old daemon without the
241
- * endpoint (`-f` on 404) produces no output, and `|| true` exits 0 — Claude
242
- * Code treats that as allow-with-no-decision. The full binary is NEVER the
243
- * fallback. The agent session's `MEMTRACE_RAIL` / `MEMTRACE_RAW_SEARCH` ride
244
- * along as query params, so per-session mode/escape semantics are identical
245
- * to the spawn era; `MEMTRACE_RAIL_SEARCH_URL` / `MEMTRACE_UI_PORT` override
246
- * the endpoint the same way they steered the binary's own daemon lookup.
247
- *
248
- * Windows keeps the gen-1 binary spawn: hook commands there do not run under
249
- * a POSIX shell, so the env-forwarding one-liner has no portable equivalent.
238
+ * Every host enters a local liveness guard before contacting Memtrace. The
239
+ * daemon lifecycle registers/removes this command; a cached registration is
240
+ * silent after a stop or crash. Windows uses exec-form command/args below.
250
241
  */
251
- export function claudeRailHookCommand(memtraceBinary, platform = process.platform) {
252
- if (platform === 'win32')
253
- return `${memtraceBinary} route --hook`;
254
- const endpoint = '${MEMTRACE_RAIL_SEARCH_URL:-http://127.0.0.1:${MEMTRACE_UI_PORT:-3030}}';
255
- const query = 'host=claude-code&mode=${MEMTRACE_RAIL:-}&escape=${MEMTRACE_RAW_SEARCH:-}';
256
- return `curl -sf --connect-timeout 0.05 --max-time 2 --data-binary @- "${endpoint}/api/rail/hook?${query}" 2>/dev/null || true`;
242
+ export function claudeRailHookCommand(_memtraceBinary, platform = process.platform) {
243
+ return railHookCommand('claude-code', platform);
257
244
  }
258
245
  /**
259
246
  * Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
@@ -261,7 +248,7 @@ export function claudeRailHookCommand(memtraceBinary, platform = process.platfor
261
248
  * This is what makes Rail "part of memtrace" rather than a manual install:
262
249
  * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
263
250
  * attempt through the Rail router (see {@link claudeRailHookCommand} for the
264
- * transport). The hook defaults to **observe** (zero behavior change)
251
+ * transport). While a daemon is running the hook defaults to **observe** —
265
252
  * `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
266
253
  * baked into the command so the user can change it via env without
267
254
  * re-installing.
@@ -271,6 +258,10 @@ export function claudeRailHookCommand(memtraceBinary, platform = process.platfor
271
258
  * a malformed file.
272
259
  */
273
260
  export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platform = process.platform) {
261
+ if (!railIsActive()) {
262
+ removeRailHookFromSettingsAt(settingsPath);
263
+ return { registered: false };
264
+ }
274
265
  const { value, corrupted, backupPath } = safeReadJson(settingsPath);
275
266
  if (corrupted) {
276
267
  console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped Rail hook registration.`);
@@ -289,7 +280,9 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platf
289
280
  hooks: [
290
281
  {
291
282
  type: 'command',
292
- command: claudeRailHookCommand(memtraceBinary, platform),
283
+ ...(platform === 'win32'
284
+ ? railHookInvocation('claude-code')
285
+ : { command: claudeRailHookCommand(memtraceBinary, platform) }),
293
286
  },
294
287
  ],
295
288
  });
@@ -299,7 +292,7 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platf
299
292
  }
300
293
  /** True when a PreToolUse entry is a memtrace-owned Rail hook (any generation). */
301
294
  function isRailHookEntry(entry) {
302
- return hookCommands(entry).some((command) => RAIL_HOOK_MARKERS.some((marker) => command.includes(marker)));
295
+ return hookCommands(entry).some((command) => command.includes(RAIL_GUARD_MARKER) || RAIL_HOOK_MARKERS.some((marker) => command.includes(marker)));
303
296
  }
304
297
  function isLegacyMemtraceHookEntry(entry) {
305
298
  return hookCommands(entry).some((command) => (LEGACY_HOOK_SCRIPT_BASENAMES.some((basename) => command.includes(basename))));
@@ -317,7 +310,8 @@ function hookCommands(entry) {
317
310
  if (Array.isArray(entry.hooks)) {
318
311
  for (const hook of entry.hooks) {
319
312
  if (isRecord(hook) && typeof hook.command === 'string') {
320
- commands.push(hook.command);
313
+ const args = Array.isArray(hook.args) ? hook.args.filter((arg) => typeof arg === 'string') : [];
314
+ commands.push([hook.command, ...args].join(' '));
321
315
  }
322
316
  }
323
317
  }
@@ -329,11 +323,23 @@ function pruneMemtraceOwnedHookEntries(hooks) {
329
323
  const entries = hooks[key];
330
324
  if (!Array.isArray(entries))
331
325
  continue;
332
- const preserved = entries.filter((entry) => !isMemtraceOwnedHookEntry(entry));
333
- if (preserved.length === entries.length)
334
- continue;
335
- changed = true;
336
- hooks[key] = preserved.length === 0 ? undefined : preserved;
326
+ const preserved = entries.flatMap((entry) => {
327
+ if (isRecord(entry) && Array.isArray(entry.hooks)) {
328
+ const remaining = entry.hooks.filter(hook => !isMemtraceOwnedHookEntry({ hooks: [hook] }));
329
+ if (remaining.length === entry.hooks.length)
330
+ return [entry];
331
+ changed = true;
332
+ return remaining.length ? [{ ...entry, hooks: remaining }] : [];
333
+ }
334
+ if (!isMemtraceOwnedHookEntry(entry))
335
+ return [entry];
336
+ changed = true;
337
+ return [];
338
+ });
339
+ if (preserved.length === 0)
340
+ delete hooks[key];
341
+ else
342
+ hooks[key] = preserved;
337
343
  }
338
344
  return changed;
339
345
  }
@@ -354,19 +360,13 @@ export function removeRailHookFromSettingsAt(settingsPath) {
354
360
  if (!value)
355
361
  return { changed: false };
356
362
  const settings = value;
357
- if (!isRecord(settings.hooks) || !Array.isArray(settings.hooks.PreToolUse)) {
363
+ if (!isRecord(settings.hooks)) {
358
364
  return { changed: false };
359
365
  }
360
- const before = settings.hooks.PreToolUse;
361
- const after = before.filter((entry) => !isRailHookEntry(entry));
362
- if (after.length === before.length)
366
+ // Retire legacy advisory/telemetry hooks too: they must not wake an agent
367
+ // while Memtrace is stopped, and the running Rail integration replaces them.
368
+ if (!pruneMemtraceOwnedHookEntries(settings.hooks))
363
369
  return { changed: false };
364
- if (after.length === 0) {
365
- delete settings.hooks.PreToolUse;
366
- }
367
- else {
368
- settings.hooks.PreToolUse = after;
369
- }
370
370
  if (Object.keys(settings.hooks).length === 0)
371
371
  delete settings.hooks;
372
372
  writeJsonAtomic(settingsPath, settings);
@@ -482,12 +482,12 @@ function removeClaudeInstalledPluginMetadata() {
482
482
  * Register the memtrace MCP server in Claude Code's settings.
483
483
  * This adds the MCP server config so Claude Code can connect to memtrace tools.
484
484
  */
485
- async function registerMcpServer(memtraceBinaryPath) {
485
+ export async function registerMcpServer(memtraceBinaryPath) {
486
486
  const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
487
- // ─── memtrace-rail ─── Always wire the Memtrace-first discovery hook so it
487
+ // ─── memtrace-rail ─── Reconcile the daemon-owned discovery hook so it
488
488
  // ships as part of `memtrace install` (not a manual step). Hooks live in
489
489
  // settings.json regardless of which MCP-registration strategy wins, and the
490
- // hook defaults to observe mode (zero behavior change) until the user opts
490
+ // hook is absent while stopped, and observes while active until the user opts
491
491
  // into MEMTRACE_RAIL=nudge|rail|strict. Failure here must not block MCP
492
492
  // registration.
493
493
  // `memtrace install --no-hooks` (memtrace-public #25) propagates here via
@@ -500,20 +500,18 @@ async function registerMcpServer(memtraceBinaryPath) {
500
500
  console.warn(`memtrace: failed to register Rail hook: ${e.message}`);
501
501
  }
502
502
  }
503
+ else {
504
+ removeRailHookFromSettingsAt(settingsFile);
505
+ }
503
506
  // Strategy 1 (preferred): claude CLI's own add-json path
504
507
  const viaCli = await tryMcpAddJson(memtraceBinaryPath);
505
508
  if (viaCli)
506
509
  return;
507
- // Strategy 2 (fallback): direct JSON merge (safe + atomic).
508
- //
509
- // MEMBIZ-66: Claude Code reads user-scope MCP servers from `~/.claude.json`,
510
- // not from `~/.claude/settings.json`. When the `claude` CLI was missing or
511
- // slow (a 5 s timeout on Windows is easy to hit), this fallback wrote only
512
- // settings.json — skills loaded, every `mcp__memtrace__*` tool was absent,
513
- // and `memtrace doctor` still reported the registration as healthy. Write
514
- // the file the CLI actually reads, and keep settings.json for older
515
- // tooling that still looks there.
516
- registerMcpInSettingsAt(claudeUserConfigFile(), memtraceBinaryPath);
510
+ // Desktop-only installs have no `claude` CLI. Claude Code reads user MCP
511
+ // registrations from ~/.claude.json, not ~/.claude/settings.json (hooks).
512
+ // Migrate legacy env values without overriding the authoritative user entry.
513
+ registerMcpInSettingsAt(claudeUserConfigFile(), memtraceBinaryPath, readExistingMemtraceEnv(settingsFile));
514
+ // Retain staging's mirror for older tooling after migrating the user entry.
517
515
  registerMcpInSettingsAt(settingsFile, memtraceBinaryPath);
518
516
  }
519
517
  /** `~/.claude.json` — where Claude Code stores user-scope `mcpServers`. */
@@ -613,13 +611,17 @@ export async function uninstallClaudePlugin() {
613
611
  if (fs.existsSync(cacheRoot)) {
614
612
  fs.rmSync(cacheRoot, { recursive: true });
615
613
  }
616
- // Remove from settings.json
617
- const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
618
- if (fs.existsSync(settingsFile)) {
619
- try {
620
- removeClaudeSettingsEntriesAt(settingsFile);
614
+ // Hooks/plugin settings and user MCP registrations are separate files.
615
+ for (const settingsFile of [
616
+ path.join(os.homedir(), '.claude', 'settings.json'),
617
+ path.join(os.homedir(), '.claude.json'),
618
+ ]) {
619
+ if (fs.existsSync(settingsFile)) {
620
+ try {
621
+ removeClaudeSettingsEntriesAt(settingsFile);
622
+ }
623
+ catch { /* */ }
621
624
  }
622
- catch { /* */ }
623
625
  }
624
626
  removeClaudeMarketplaceCacheDirs();
625
627
  removeClaudeInstalledPluginMetadata();
@@ -676,7 +678,7 @@ export const claudeTransformer = {
676
678
  agent: 'claude',
677
679
  skillsWritten: skillCount,
678
680
  skillsDir: path.join(os.homedir(), '.claude', 'skills'),
679
- mcpConfigPath: path.join(os.homedir(), '.claude', 'settings.json'),
681
+ mcpConfigPath: path.join(os.homedir(), '.claude.json'),
680
682
  mcpRegistered: !ctx.skipMcp,
681
683
  warnings: [],
682
684
  };
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import { railIsActive } from '../rail-runtime.js';
2
3
  import path from 'path';
3
4
  import { registerOpenCodeMcpAt, removeMemtraceSkills, removeOpenCodeMcpAt, writeSkills, } from './shared.js';
4
5
  import { openCodeConfigDir, openCodeRailPluginPaths, openCodePluginSource, } from './rail-hooks.js';
@@ -32,6 +33,10 @@ export const opencodeTransformer = {
32
33
  try {
33
34
  const src = openCodePluginSource(ctx.memtraceBinary);
34
35
  for (const pluginPath of openCodeRailPluginPaths(ctx)) {
36
+ if (!railIsActive()) {
37
+ fs.rmSync(pluginPath, { force: true });
38
+ continue;
39
+ }
35
40
  fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
36
41
  fs.writeFileSync(pluginPath, src);
37
42
  }
@@ -4,7 +4,7 @@ export declare const RAIL_HOOK_MARKER = "route --hook";
4
4
  export declare const CLAUDE_MATCHER = "Grep|Glob|Bash";
5
5
  export declare const CODEX_MATCHER = "Bash";
6
6
  export declare const GEMINI_MATCHER = "run_shell_command|read_file|read_many_files|glob|search_file_content";
7
- export declare function railCommand(binary: string, host: string): string;
7
+ export declare function railCommand(_binary: string, host: string): string;
8
8
  export interface OpenCodePathContext {
9
9
  scope: 'global' | 'local';
10
10
  cwd: string;
@@ -19,14 +19,15 @@ import fs from 'fs';
19
19
  import os from 'os';
20
20
  import path from 'path';
21
21
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
22
+ import { railIsActive, railHookCommand, railHookInvocation, isRailCommand } from '../rail-runtime.js';
22
23
  /** Substring identifying a memtrace-owned Rail hook entry. */
23
24
  export const RAIL_HOOK_MARKER = 'route --hook';
24
25
  /** The discovery tools each host surfaces. */
25
26
  export const CLAUDE_MATCHER = 'Grep|Glob|Bash';
26
27
  export const CODEX_MATCHER = 'Bash';
27
28
  export const GEMINI_MATCHER = 'run_shell_command|read_file|read_many_files|glob|search_file_content';
28
- export function railCommand(binary, host) {
29
- return `${binary} route --hook --host ${host}`;
29
+ export function railCommand(_binary, host) {
30
+ return railHookCommand(host);
30
31
  }
31
32
  function trimmed(value) {
32
33
  const clean = value?.trim();
@@ -84,7 +85,7 @@ function isRecord(v) {
84
85
  export function isRailHookEntry(entry) {
85
86
  if (!isRecord(entry) || !Array.isArray(entry.hooks))
86
87
  return false;
87
- return entry.hooks.some((h) => isRecord(h) && typeof h.command === 'string' && h.command.includes(RAIL_HOOK_MARKER));
88
+ return entry.hooks.some((h) => isRecord(h) && typeof h.command === 'string' && isRailCommand(h.command));
88
89
  }
89
90
  /**
90
91
  * Register a Claude-style settings.json hook (Codex `PreToolUse`, Gemini
@@ -92,6 +93,10 @@ export function isRailHookEntry(entry) {
92
93
  * malformed file.
93
94
  */
94
95
  export function registerSettingsHook(settingsPath, binary, host, eventName, matcher, extraSettings) {
96
+ if (!railIsActive()) {
97
+ removeSettingsHook(settingsPath, eventName);
98
+ return { registered: false };
99
+ }
95
100
  const { value, corrupted, backupPath } = safeReadJson(settingsPath);
96
101
  if (corrupted) {
97
102
  console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped Rail hook.`);
@@ -150,6 +155,10 @@ export function removeSettingsHook(settingsPath, eventName) {
150
155
  * both shell execution (where rg/grep/find run) and file reads.
151
156
  */
152
157
  export function registerCursorRailHook(hooksPath, binary) {
158
+ if (!railIsActive()) {
159
+ removeCursorRailHook(hooksPath);
160
+ return { registered: false };
161
+ }
153
162
  const { value, corrupted, backupPath } = safeReadJson(hooksPath);
154
163
  if (corrupted) {
155
164
  console.warn(`memtrace: ${hooksPath} is malformed; backed up to ${backupPath}. Skipped Cursor Rail hook.`);
@@ -164,7 +173,7 @@ export function registerCursorRailHook(hooksPath, binary) {
164
173
  // hooking it would spawn a router subprocess on every file read for no benefit.
165
174
  const event = 'beforeShellExecution';
166
175
  const existing = Array.isArray(cfg.hooks[event]) ? cfg.hooks[event] : [];
167
- const preserved = existing.filter((e) => !(isRecord(e) && typeof e.command === 'string' && e.command.includes(RAIL_HOOK_MARKER)));
176
+ const preserved = existing.filter((e) => !(isRecord(e) && typeof e.command === 'string' && isRailCommand(e.command)));
168
177
  preserved.push({ command: cmd });
169
178
  cfg.hooks[event] = preserved;
170
179
  writeJsonAtomic(hooksPath, cfg);
@@ -180,6 +189,10 @@ export function windsurfHooksPath() {
180
189
  * `show_output: true` so nudge context on stdout is visible in Cascade.
181
190
  */
182
191
  export function registerWindsurfRailHook(hooksPath, binary) {
192
+ if (!railIsActive()) {
193
+ removeWindsurfRailHook(hooksPath);
194
+ return { registered: false };
195
+ }
183
196
  const { value, corrupted, backupPath } = safeReadJson(hooksPath);
184
197
  if (corrupted) {
185
198
  console.warn(`memtrace: ${hooksPath} is malformed; backed up to ${backupPath}. Skipped Windsurf Rail hook.`);
@@ -192,7 +205,7 @@ export function registerWindsurfRailHook(hooksPath, binary) {
192
205
  const existing = Array.isArray(cfg.hooks[event]) ? cfg.hooks[event] : [];
193
206
  const preserved = existing.filter((e) => !(isRecord(e) &&
194
207
  typeof e.command === 'string' &&
195
- e.command.includes(RAIL_HOOK_MARKER)));
208
+ isRailCommand(e.command)));
196
209
  preserved.push({ command: cmd, show_output: true });
197
210
  cfg.hooks[event] = preserved;
198
211
  writeJsonAtomic(hooksPath, cfg);
@@ -211,7 +224,7 @@ export function removeWindsurfRailHook(hooksPath) {
211
224
  const before = cfg.hooks.pre_run_command;
212
225
  const after = before.filter((e) => !(isRecord(e) &&
213
226
  typeof e.command === 'string' &&
214
- e.command.includes(RAIL_HOOK_MARKER)));
227
+ isRailCommand(e.command)));
215
228
  if (after.length === before.length)
216
229
  return { changed: false };
217
230
  if (after.length === 0)
@@ -232,6 +245,10 @@ export function vscodeCopilotRailHookPath() {
232
245
  * Writes a dedicated file under `~/.copilot/hooks/` (loaded by default in recent VS Code).
233
246
  */
234
247
  export function registerVsCodeRailHook(hookFilePath, binary) {
248
+ if (!railIsActive()) {
249
+ removeVsCodeRailHook(hookFilePath);
250
+ return { registered: false };
251
+ }
235
252
  const { value, corrupted, backupPath } = safeReadJson(hookFilePath);
236
253
  if (corrupted) {
237
254
  console.warn(`memtrace: ${hookFilePath} is malformed; backed up to ${backupPath}. Skipped VS Code Rail hook.`);
@@ -242,7 +259,7 @@ export function registerVsCodeRailHook(hookFilePath, binary) {
242
259
  const pre = Array.isArray(existing.hooks?.PreToolUse) ? existing.hooks.PreToolUse : [];
243
260
  const preserved = pre.filter((e) => !(isRecord(e) &&
244
261
  typeof e.command === 'string' &&
245
- e.command.includes(RAIL_HOOK_MARKER)));
262
+ isRailCommand(e.command)));
246
263
  preserved.push({ type: 'command', command: cmd, timeout: 30 });
247
264
  writeJsonAtomic(hookFilePath, {
248
265
  hooks: {
@@ -263,7 +280,7 @@ export function removeVsCodeRailHook(hookFilePath) {
263
280
  const before = cfg.hooks.PreToolUse;
264
281
  const after = before.filter((e) => !(isRecord(e) &&
265
282
  typeof e.command === 'string' &&
266
- e.command.includes(RAIL_HOOK_MARKER)));
283
+ isRailCommand(e.command)));
267
284
  if (after.length === before.length)
268
285
  return { changed: false };
269
286
  if (after.length === 0) {
@@ -292,7 +309,7 @@ export function removeCursorRailHook(hooksPath) {
292
309
  if (!Array.isArray(cfg.hooks[event]))
293
310
  continue;
294
311
  const before = cfg.hooks[event];
295
- const after = before.filter((e) => !(isRecord(e) && typeof e.command === 'string' && e.command.includes(RAIL_HOOK_MARKER)));
312
+ const after = before.filter((e) => !(isRecord(e) && typeof e.command === 'string' && isRailCommand(e.command)));
296
313
  if (after.length !== before.length) {
297
314
  changed = true;
298
315
  if (after.length === 0)
@@ -316,12 +333,14 @@ export function removeCursorRailHook(hooksPath) {
316
333
  * the Memtrace result as the error the agent sees.
317
334
  */
318
335
  export function openCodePluginSource(binary) {
336
+ const hook = railHookInvocation('open-code');
319
337
  return `// AUTO-GENERATED by memtrace install — Memtrace Rail discovery hook.
320
338
  // Routes OpenCode tool calls through \`${binary} route\`. Defaults to observe
321
339
  // (no-op) unless MEMTRACE_RAIL=nudge|rail|strict. Remove via 'memtrace uninstall'.
322
340
  import { execFileSync } from 'node:child_process';
323
341
 
324
- const BIN = ${JSON.stringify(binary)};
342
+ const BIN = ${JSON.stringify(hook.command)};
343
+ const ARGS = ${JSON.stringify([...hook.args, '--json'])};
325
344
 
326
345
  export const MemtraceRail = async ({ project, client, $ }) => ({
327
346
  'tool.execute.before': async (input, output) => {
@@ -335,8 +354,8 @@ export const MemtraceRail = async ({ project, client, $ }) => ({
335
354
  input: output?.args || {},
336
355
  session_id: input?.sessionID || 'opencode',
337
356
  };
338
- const out = execFileSync(BIN, ['route', '--json', '-'], {
339
- input: JSON.stringify(attempt), timeout: 1500, encoding: 'utf8',
357
+ const out = execFileSync(BIN, ARGS, {
358
+ input: JSON.stringify(attempt), timeout: 6000, encoding: 'utf8', windowsHide: true,
340
359
  });
341
360
  const decision = JSON.parse(out);
342
361
  if (decision.action === 'suppress_raw_with_result') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace-skills",
3
- "version": "1.1.12",
3
+ "version": "1.2.0",
4
4
  "description": "Memtrace skills for AI coding agents — codebase exploration, temporal evolution, impact analysis, and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "prebuild": "node scripts/copy-skills.js",
18
18
  "build": "tsc",
19
19
  "prepublishOnly": "npm run build",
20
- "test": "vitest run",
20
+ "test": "vitest run && tsc && node --test tests/runtime/rail-lifecycle.test.mjs",
21
21
  "test:watch": "vitest"
22
22
  },
23
23
  "dependencies": {