@evomap/evolver-mcp 2.0.0-beta.17 → 2.0.0-beta.19

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.
@@ -1,5 +1,5 @@
1
1
  import type { InjectionPlan, McpServerCmd, RuntimeId } from './injection.js';
2
- import { type InstallOptions, type InstallResult, type UninstallOptions } from './installer.js';
2
+ import { type InstallOptions, type InstallResult, type UninstallOptions } from './installerShared.js';
3
3
  export declare const ANTIGRAVITY_NAMESPACES: readonly ["antigravity", "antigravity-ide"];
4
4
  export interface AntigravityConfigTarget {
5
5
  namespace: (typeof ANTIGRAVITY_NAMESPACES)[number];
@@ -1,9 +1,9 @@
1
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
- import { randomUUID } from 'node:crypto';
1
+ import { lstatSync, mkdirSync, readFileSync, statSync, } from 'node:fs';
3
2
  import { homedir as osHomedir } from 'node:os';
4
3
  import { dirname, join } from 'node:path';
5
4
  import { util } from '@evomap/evolver-core';
6
- import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installer.js';
5
+ import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
6
+ import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
7
7
  const ENV_FILE_KEY = 'EVOLVER_ENV_FILE';
8
8
  const CONFIG_FILE = 'mcp_config.json';
9
9
  const CONFIG_WRITE_RETRIES = 5;
@@ -125,30 +125,6 @@ function existingFileMode(path) {
125
125
  throw error;
126
126
  }
127
127
  }
128
- function writeJsonAtomic(path, data) {
129
- const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
130
- const mode = existingFileMode(path) ?? NEW_CONFIG_MODE;
131
- const restoreMode = process.platform === 'win32' && existsSync(path) ? existingFileMode(path) : undefined;
132
- try {
133
- writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode });
134
- chmodSync(tmp, mode);
135
- if (process.platform === 'win32' && existsSync(path))
136
- chmodSync(path, mode | 0o200);
137
- renameSync(tmp, path);
138
- chmodSync(path, mode);
139
- }
140
- catch (error) {
141
- rmSync(tmp, { force: true });
142
- if (restoreMode !== undefined) {
143
- try {
144
- if (existsSync(path))
145
- chmodSync(path, restoreMode);
146
- }
147
- catch { /* preserve the original error */ }
148
- }
149
- throw error;
150
- }
151
- }
152
128
  function updateConfigWithRetry(path, update) {
153
129
  const lockPath = `${path}.evolver.lock`;
154
130
  util.acquireLock(lockPath);
@@ -160,8 +136,20 @@ function updateConfigWithRetry(path, update) {
160
136
  return false;
161
137
  if (readRawIfExists(path) !== snapshot.raw)
162
138
  continue;
163
- writeJsonAtomic(path, next.data);
164
- return true;
139
+ try {
140
+ commitSharedFile({
141
+ path,
142
+ expectedRaw: snapshot.raw ?? undefined,
143
+ nextRaw: `${JSON.stringify(next.data, null, 2)}\n`,
144
+ mode: existingFileMode(path) ?? NEW_CONFIG_MODE,
145
+ });
146
+ return true;
147
+ }
148
+ catch (error) {
149
+ if (error instanceof SharedFileConflictError)
150
+ continue;
151
+ throw error;
152
+ }
165
153
  }
166
154
  }
167
155
  finally {
@@ -1,7 +1,9 @@
1
1
  import type { RuntimeId, InjectionPlan } from './injection.js';
2
- import { type InstallResult, type InstallOptions } from './installer.js';
2
+ import { type InstallResult, type InstallOptions } from './installerShared.js';
3
3
  /** The MCP server id evolver registers under [mcp_servers.evolver] / removes on uninstall. */
4
4
  export declare const CODEX_MCP_SERVER_ID = "evolver";
5
+ type CodexConfigRaceHook = (path: string, attempt: number) => void;
6
+ export declare function _setCodexConfigRaceHookForTest(hook?: CodexConfigRaceHook): void;
5
7
  /** The [mcp_servers.evolver] table from a plan's launch command. env omitted when empty (no `[..env]` table). */
6
8
  export declare function codexMcpServerEntry(server: {
7
9
  command: string;
@@ -10,13 +12,15 @@ export declare function codexMcpServerEntry(server: {
10
12
  }): Record<string, unknown>;
11
13
  /** A single [[hooks.SessionStart]] entry that runs `command` at session start. Shape matches codex's hooks schema. */
12
14
  export declare function codexSessionStartHook(command: string): Record<string, unknown>;
15
+ /** UserPromptSubmit has no matcher in current Codex; five seconds keeps the prompt path fail-open and bounded. */
16
+ export declare function codexUserPromptSubmitHook(command: string): Record<string, unknown>;
13
17
  /**
14
18
  * Merge evolver's codex config into the user's existing parsed TOML. Idempotent + non-destructive:
15
19
  * - [mcp_servers] : set our `evolver` server, keep every other server the user registered.
16
20
  * - [[hooks.SessionStart]] : drop any prior evolver-owned entry, keep all user entries, append ours fresh.
17
21
  * The user's unrelated tables (model, approval_policy, other hook events, …) pass through untouched.
18
22
  */
19
- export declare function mergeCodexConfig(existing: Record<string, unknown>, mcpServer: Record<string, unknown>, sessionStartHook: Record<string, unknown>): Record<string, unknown>;
23
+ export declare function mergeCodexConfig(existing: Record<string, unknown>, mcpServer: Record<string, unknown>, sessionStartHook: Record<string, unknown>, userPromptSubmitHook?: Record<string, unknown>): Record<string, unknown>;
20
24
  /** Strip evolver's MCP server + SessionStart hook from parsed codex TOML (uninstall). Returns [changed, data]. */
21
25
  export declare function stripCodexManaged(data: Record<string, unknown>): {
22
26
  changed: boolean;
@@ -31,4 +35,5 @@ export declare function installCodex(plan: InjectionPlan, opts: InstallOptions):
31
35
  /** Remove evolver's MCP registration + SessionStart hook from a codex project config (leaves user content intact). */
32
36
  export declare function uninstallCodex(runtime: RuntimeId, opts: {
33
37
  configRoot: string;
34
- }): InstallResult;
38
+ }): InstallResult;
39
+ export {};
@@ -18,16 +18,18 @@
18
18
  // adapter-owned path, marker-managed so reinstall/uninstall only touch evolver's own entries, and a
19
19
  // hooks-UNION merge that preserves the user's existing hooks. TOML round-trips through smol-toml (spec
20
20
  // parser/serializer) so a user's hand-written config is preserved rather than clobbered.
21
- import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
23
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
24
- import { SymlinkRefusedError, DEFAULT_HOOK_COMMAND } from './installer.js';
24
+ import { util } from '@evomap/evolver-core';
25
+ import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
26
+ import { SymlinkRefusedError, DEFAULT_HOOK_COMMAND, DEFAULT_PROMPT_RECALL_HOOK_COMMAND, evolverManagedHookStatus, stripEvolverHookEntries, } from './installerShared.js';
25
27
  /** The MCP server id evolver registers under [mcp_servers.evolver] / removes on uninstall. */
26
28
  export const CODEX_MCP_SERVER_ID = 'evolver';
27
- /** A hook entry is evolver-owned if any command mentions this — used to replace-not-duplicate on reinstall. */
28
- const EVOLVER_HOOK_TAG = 'evolver';
29
29
  /** SessionStart matcher: codex fires `startup` on a fresh thread and `resume` on a resumed one — cover both. */
30
30
  const SESSION_START_MATCHER = 'startup|resume';
31
+ const CONFIG_WRITE_RETRIES = 5;
32
+ const CODEX_CONFIG_MODE = 0o600;
31
33
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
32
34
  // ── fs hardening (mirrors installer.ts) ──────────────────────────────────────
33
35
  function assertNotSymlink(path, label) {
@@ -43,21 +45,108 @@ function assertNotSymlink(path, label) {
43
45
  if (st.isSymbolicLink())
44
46
  throw new SymlinkRefusedError(label, path);
45
47
  }
46
- function readToml(path) {
48
+ function readTomlSnapshot(path) {
49
+ let raw;
47
50
  try {
48
- if (!existsSync(path))
49
- return {};
50
- const raw = readFileSync(path, 'utf8').trim();
51
- return raw ? parseToml(raw) : {};
51
+ raw = readFileSync(path, 'utf8');
52
52
  }
53
- catch {
54
- return {}; // unparseable → start fresh (merge re-adds evolver entries; a broken file isn't silently kept)
53
+ catch (error) {
54
+ if (error.code === 'ENOENT')
55
+ return { data: {}, raw: null, mode: CODEX_CONFIG_MODE };
56
+ throw error;
57
+ }
58
+ if (!raw.trim()) {
59
+ throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the existing file is empty.`);
60
+ }
61
+ try {
62
+ return { data: parseToml(raw), raw, mode: (statSync(path).mode & 0o777) & 0o700 };
63
+ }
64
+ catch (error) {
65
+ throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the existing file is not valid TOML.`, { cause: error });
66
+ }
67
+ }
68
+ function readRawIfExists(path) {
69
+ try {
70
+ return readFileSync(path, 'utf8');
71
+ }
72
+ catch (error) {
73
+ if (error.code === 'ENOENT')
74
+ return null;
75
+ throw error;
55
76
  }
56
77
  }
57
- function writeTomlAtomic(path, data) {
58
- const tmp = `${path}.tmp`;
59
- writeFileSync(tmp, `${stringifyToml(data)}\n`, 'utf8');
60
- renameSync(tmp, path);
78
+ let codexConfigRaceHookForTest;
79
+ export function _setCodexConfigRaceHookForTest(hook) {
80
+ codexConfigRaceHookForTest = hook;
81
+ }
82
+ function writeTomlWithRetry(path, update, assertSafe) {
83
+ const lockPath = `${path}.evolver.lock`;
84
+ assertSafe();
85
+ util.acquireLock(lockPath);
86
+ let operationResult = false;
87
+ let operationFailed = false;
88
+ let operationError;
89
+ try {
90
+ operationResult = (() => {
91
+ for (let attempt = 1; attempt <= CONFIG_WRITE_RETRIES; attempt += 1) {
92
+ assertSafe();
93
+ const snapshot = readTomlSnapshot(path);
94
+ const next = update(snapshot.data);
95
+ if (!next.changed)
96
+ return false;
97
+ codexConfigRaceHookForTest?.(path, attempt);
98
+ assertSafe();
99
+ if (readRawIfExists(path) !== snapshot.raw)
100
+ continue;
101
+ assertSafe();
102
+ try {
103
+ commitSharedFile({
104
+ path,
105
+ expectedRaw: snapshot.raw ?? undefined,
106
+ nextRaw: next.remove ? undefined : `${stringifyToml(next.data)}\n`,
107
+ mode: snapshot.mode,
108
+ });
109
+ return true;
110
+ }
111
+ catch (error) {
112
+ if (error instanceof SharedFileConflictError)
113
+ continue;
114
+ throw error;
115
+ }
116
+ }
117
+ throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the file changed repeatedly while evolver was merging it.`);
118
+ })();
119
+ }
120
+ catch (error) {
121
+ operationFailed = true;
122
+ operationError = error;
123
+ }
124
+ let releaseError;
125
+ try {
126
+ const released = util.releaseLock(lockPath);
127
+ if (!released.released)
128
+ releaseError = new util.LockReleaseError(released.reason);
129
+ }
130
+ catch (error) {
131
+ releaseError = error;
132
+ }
133
+ if (operationFailed) {
134
+ if (operationError instanceof Error && releaseError !== undefined) {
135
+ operationError.lockReleaseError = releaseError;
136
+ }
137
+ throw operationError;
138
+ }
139
+ if (releaseError !== undefined)
140
+ throw releaseError;
141
+ return operationResult;
142
+ }
143
+ function codexPathGuard(configRoot, codexDir, configPath) {
144
+ return () => {
145
+ assertNotSymlink(configRoot, 'config root');
146
+ assertNotSymlink(codexDir, '.codex');
147
+ assertNotSymlink(configPath, '.codex/config.toml');
148
+ assertNotSymlink(`${configPath}.evolver.lock`, '.codex/config.toml lock');
149
+ };
61
150
  }
62
151
  // ── pure merge (exported for tests) ──────────────────────────────────────────
63
152
  /** The [mcp_servers.evolver] table from a plan's launch command. env omitted when empty (no `[..env]` table). */
@@ -70,30 +159,66 @@ export function codexMcpServerEntry(server) {
70
159
  }
71
160
  /** A single [[hooks.SessionStart]] entry that runs `command` at session start. Shape matches codex's hooks schema. */
72
161
  export function codexSessionStartHook(command) {
73
- return { matcher: SESSION_START_MATCHER, hooks: [{ type: 'command', command }] };
162
+ return { matcher: SESSION_START_MATCHER, hooks: [{
163
+ type: 'command', command, statusMessage: evolverManagedHookStatus(command),
164
+ }] };
74
165
  }
75
- const hookIsEvolverOwned = (entry) => {
76
- if (!isObj(entry))
77
- return false;
78
- const inner = entry['hooks'];
79
- if (!Array.isArray(inner))
166
+ /** UserPromptSubmit has no matcher in current Codex; five seconds keeps the prompt path fail-open and bounded. */
167
+ export function codexUserPromptSubmitHook(command) {
168
+ return { hooks: [{
169
+ type: 'command', command, timeout: 5, statusMessage: evolverManagedHookStatus(command),
170
+ }] };
171
+ }
172
+ function hookHasExactCommand(entry, command) {
173
+ if (!isObj(entry) || !Array.isArray(entry['hooks']))
80
174
  return false;
81
- return inner.some((h) => isObj(h) && typeof h['command'] === 'string' && h['command'].includes(EVOLVER_HOOK_TAG));
82
- };
175
+ return entry['hooks'].some((handler) => isObj(handler) && handler['command'] === command);
176
+ }
177
+ function hookCommands(entries) {
178
+ const commands = new Set();
179
+ for (const entry of entries) {
180
+ if (!isObj(entry) || !Array.isArray(entry['hooks']))
181
+ continue;
182
+ for (const handler of entry['hooks']) {
183
+ if (isObj(handler) && typeof handler['command'] === 'string')
184
+ commands.add(handler['command']);
185
+ }
186
+ }
187
+ return commands;
188
+ }
83
189
  /**
84
190
  * Merge evolver's codex config into the user's existing parsed TOML. Idempotent + non-destructive:
85
191
  * - [mcp_servers] : set our `evolver` server, keep every other server the user registered.
86
192
  * - [[hooks.SessionStart]] : drop any prior evolver-owned entry, keep all user entries, append ours fresh.
87
193
  * The user's unrelated tables (model, approval_policy, other hook events, …) pass through untouched.
88
194
  */
89
- export function mergeCodexConfig(existing, mcpServer, sessionStartHook) {
195
+ export function mergeCodexConfig(existing, mcpServer, sessionStartHook, userPromptSubmitHook) {
90
196
  const out = { ...existing };
91
197
  const mcpServers = isObj(out['mcp_servers']) ? { ...out['mcp_servers'] } : {};
198
+ const trustManagedStatus = CODEX_MCP_SERVER_ID in mcpServers;
199
+ const managedCommands = hookCommands(userPromptSubmitHook ? [sessionStartHook, userPromptSubmitHook] : [sessionStartHook]);
92
200
  mcpServers[CODEX_MCP_SERVER_ID] = mcpServer;
93
201
  out['mcp_servers'] = mcpServers;
94
202
  const hooks = isObj(out['hooks']) ? { ...out['hooks'] } : {};
203
+ for (const event of Object.keys(hooks)) {
204
+ const value = hooks[event];
205
+ if (!Array.isArray(value))
206
+ continue;
207
+ const kept = stripEvolverHookEntries(value, trustManagedStatus, managedCommands).entries;
208
+ if (kept.length > 0)
209
+ hooks[event] = kept;
210
+ else
211
+ delete hooks[event];
212
+ }
95
213
  const prior = Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : [];
96
- hooks['SessionStart'] = [...prior.filter((e) => !hookIsEvolverOwned(e)), sessionStartHook];
214
+ hooks['SessionStart'] = [...prior, sessionStartHook];
215
+ if (userPromptSubmitHook) {
216
+ const priorPrompt = Array.isArray(hooks['UserPromptSubmit']) ? hooks['UserPromptSubmit'] : [];
217
+ hooks['UserPromptSubmit'] = [
218
+ ...priorPrompt,
219
+ userPromptSubmitHook,
220
+ ];
221
+ }
97
222
  out['hooks'] = hooks;
98
223
  return out;
99
224
  }
@@ -101,6 +226,8 @@ export function mergeCodexConfig(existing, mcpServer, sessionStartHook) {
101
226
  export function stripCodexManaged(data) {
102
227
  let changed = false;
103
228
  const out = { ...data };
229
+ const trustManagedStatus = isObj(out['mcp_servers'])
230
+ && CODEX_MCP_SERVER_ID in out['mcp_servers'];
104
231
  if (isObj(out['mcp_servers']) && CODEX_MCP_SERVER_ID in out['mcp_servers']) {
105
232
  const next = { ...out['mcp_servers'] };
106
233
  delete next[CODEX_MCP_SERVER_ID];
@@ -112,15 +239,18 @@ export function stripCodexManaged(data) {
112
239
  }
113
240
  if (isObj(out['hooks'])) {
114
241
  const hooks = { ...out['hooks'] };
115
- if (Array.isArray(hooks['SessionStart'])) {
116
- const arr = hooks['SessionStart'];
117
- const kept = arr.filter((e) => !hookIsEvolverOwned(e));
118
- if (kept.length !== arr.length)
242
+ for (const event of Object.keys(hooks)) {
243
+ const value = hooks[event];
244
+ if (!Array.isArray(value))
245
+ continue;
246
+ const stripped = stripEvolverHookEntries(value, trustManagedStatus);
247
+ const kept = stripped.entries;
248
+ if (stripped.changed)
119
249
  changed = true;
120
250
  if (kept.length > 0)
121
- hooks['SessionStart'] = kept;
251
+ hooks[event] = kept;
122
252
  else
123
- delete hooks['SessionStart'];
253
+ delete hooks[event];
124
254
  }
125
255
  if (Object.keys(hooks).length > 0)
126
256
  out['hooks'] = hooks;
@@ -130,8 +260,16 @@ export function stripCodexManaged(data) {
130
260
  return { changed, data: out };
131
261
  }
132
262
  /** True if evolver's MCP server is already registered in this parsed config (structural install marker). */
133
- function codexAlreadyInstalled(cfg) {
134
- return isObj(cfg['mcp_servers']) && CODEX_MCP_SERVER_ID in cfg['mcp_servers'];
263
+ function codexAlreadyInstalled(cfg, sessionStartCommand, promptRecallCommand) {
264
+ if (!isObj(cfg['mcp_servers']) || !(CODEX_MCP_SERVER_ID in cfg['mcp_servers']))
265
+ return false;
266
+ const hooks = cfg['hooks'];
267
+ if (!isObj(hooks))
268
+ return false;
269
+ const sessionStart = Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : [];
270
+ const promptSubmit = Array.isArray(hooks['UserPromptSubmit']) ? hooks['UserPromptSubmit'] : [];
271
+ return sessionStart.some((entry) => hookHasExactCommand(entry, sessionStartCommand))
272
+ && promptSubmit.some((entry) => hookHasExactCommand(entry, promptRecallCommand));
135
273
  }
136
274
  // ── install / uninstall ───────────────────────────────────────────────────────
137
275
  /**
@@ -141,31 +279,46 @@ function codexAlreadyInstalled(cfg) {
141
279
  */
142
280
  export function installCodex(plan, opts) {
143
281
  const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
282
+ const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
144
283
  const codexDir = join(opts.configRoot, '.codex');
145
284
  const configPath = join(codexDir, 'config.toml');
146
- assertNotSymlink(opts.configRoot, 'config root');
147
- assertNotSymlink(codexDir, '.codex');
148
- assertNotSymlink(configPath, '.codex/config.toml');
149
- const existing = readToml(configPath);
150
- if (!opts.force && codexAlreadyInstalled(existing)) {
151
- return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
152
- }
285
+ const assertSafe = codexPathGuard(opts.configRoot, codexDir, configPath);
286
+ assertSafe();
153
287
  const mcpServer = codexMcpServerEntry(opts.server);
154
288
  const sessionStartHook = codexSessionStartHook(hookCommand);
155
- const merged = mergeCodexConfig(existing, mcpServer, sessionStartHook);
289
+ const userPromptSubmitHook = codexUserPromptSubmitHook(promptRecallHookCommand);
156
290
  mkdirSync(codexDir, { recursive: true });
157
- writeTomlAtomic(configPath, merged);
158
- return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [configPath] };
291
+ const changed = writeTomlWithRetry(configPath, (current) => {
292
+ if (!opts.force && codexAlreadyInstalled(current, hookCommand, promptRecallHookCommand)) {
293
+ return { changed: false, data: current };
294
+ }
295
+ return {
296
+ changed: true,
297
+ data: mergeCodexConfig(current, mcpServer, sessionStartHook, userPromptSubmitHook),
298
+ };
299
+ }, assertSafe);
300
+ return {
301
+ ok: true,
302
+ runtime: plan.runtime,
303
+ mode: plan.mode,
304
+ files: changed ? [configPath] : [],
305
+ ...(!changed ? { alreadyInstalled: true } : {}),
306
+ };
159
307
  }
160
308
  /** Remove evolver's MCP registration + SessionStart hook from a codex project config (leaves user content intact). */
161
309
  export function uninstallCodex(runtime, opts) {
162
- const configPath = join(opts.configRoot, '.codex', 'config.toml');
163
- assertNotSymlink(configPath, '.codex/config.toml');
310
+ const codexDir = join(opts.configRoot, '.codex');
311
+ const configPath = join(codexDir, 'config.toml');
312
+ const assertSafe = codexPathGuard(opts.configRoot, codexDir, configPath);
313
+ assertSafe();
164
314
  if (!existsSync(configPath))
165
315
  return { ok: true, runtime, mode: 'uninstall', files: [] };
166
- const { changed, data } = stripCodexManaged(readToml(configPath));
167
- if (!changed)
168
- return { ok: true, runtime, mode: 'uninstall', files: [] };
169
- writeTomlAtomic(configPath, data);
170
- return { ok: true, runtime, mode: 'uninstall', files: [configPath] };
316
+ const changed = writeTomlWithRetry(configPath, (current) => {
317
+ const stripped = stripCodexManaged(current);
318
+ return {
319
+ ...stripped,
320
+ remove: stripped.changed && Object.keys(stripped.data).length === 0,
321
+ };
322
+ }, assertSafe);
323
+ return { ok: true, runtime, mode: 'uninstall', files: changed ? [configPath] : [] };
171
324
  }
@@ -1,4 +1,4 @@
1
- import { type InstallResult } from './installer.js';
1
+ import { type InstallResult } from './installerShared.js';
2
2
  /** Project-relative location cursor loads project rules from. */
3
3
  export declare const CURSOR_RULES_DIR: string;
4
4
  /** The single evolver-owned rules file. Other `.cursor/rules/*.mdc` (user-authored) are never touched. */
@@ -15,9 +15,10 @@
15
15
  // with an unchanged gene set is a byte-for-byte no-op) and we never clobber rules a user hand-wrote in the same
16
16
  // file. Hardened exactly like the other installers: atomic writes (tmp+rename) and refusal to follow a symlink
17
17
  // at any adapter-owned path (a hostile workspace could redirect writes/unlinks outside the project).
18
+ import { randomUUID } from 'node:crypto';
18
19
  import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import { SymlinkRefusedError } from './installer.js';
20
+ import { basename, dirname, join } from 'node:path';
21
+ import { SymlinkRefusedError } from './installerShared.js';
21
22
  /** Project-relative location cursor loads project rules from. */
22
23
  export const CURSOR_RULES_DIR = join('.cursor', 'rules');
23
24
  /** The single evolver-owned rules file. Other `.cursor/rules/*.mdc` (user-authored) are never touched. */
@@ -47,10 +48,60 @@ function assertNotSymlink(path, label) {
47
48
  if (st.isSymbolicLink())
48
49
  throw new SymlinkRefusedError(label, path);
49
50
  }
50
- function writeTextAtomic(path, text) {
51
- const tmp = `${path}.tmp`;
52
- writeFileSync(tmp, text, 'utf8');
53
- renameSync(tmp, path);
51
+ function cursorRulePaths(configRoot) {
52
+ return {
53
+ cursorDir: join(configRoot, '.cursor'),
54
+ rulesDir: join(configRoot, CURSOR_RULES_DIR),
55
+ filePath: cursorRulesPath(configRoot),
56
+ };
57
+ }
58
+ function assertCursorRulePathsNotSymlink(configRoot, paths) {
59
+ assertNotSymlink(configRoot, 'config root');
60
+ assertNotSymlink(paths.cursorDir, '.cursor');
61
+ assertNotSymlink(paths.rulesDir, '.cursor/rules');
62
+ assertNotSymlink(paths.filePath, '.cursor/rules/evolver.mdc');
63
+ }
64
+ function cleanupAtomicTemp(filePath) {
65
+ try {
66
+ const stat = lstatSync(filePath);
67
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
68
+ return new Error(`Refusing to clean non-file atomic temp path: ${filePath}`);
69
+ }
70
+ unlinkSync(filePath);
71
+ return undefined;
72
+ }
73
+ catch (error) {
74
+ return error.code === 'ENOENT' ? undefined : error;
75
+ }
76
+ }
77
+ function writeTextAtomic(filePath, text, beforeCommit) {
78
+ const tmp = join(dirname(filePath), `${basename(filePath)}.${randomUUID()}.tmp`);
79
+ let writeCompleted = false;
80
+ let renameCompleted = false;
81
+ let operationFailed = false;
82
+ let operationError;
83
+ let cleanupError;
84
+ try {
85
+ writeFileSync(tmp, text, { encoding: 'utf8', flag: 'wx' });
86
+ writeCompleted = true;
87
+ beforeCommit?.();
88
+ assertNotSymlink(filePath, '.cursor/rules/evolver.mdc');
89
+ renameSync(tmp, filePath);
90
+ renameCompleted = true;
91
+ }
92
+ catch (error) {
93
+ operationFailed = true;
94
+ operationError = error;
95
+ }
96
+ finally {
97
+ const collidedBeforeWrite = !writeCompleted && operationError?.code === 'EEXIST';
98
+ if (!renameCompleted && !collidedBeforeWrite)
99
+ cleanupError = cleanupAtomicTemp(tmp);
100
+ }
101
+ if (operationFailed)
102
+ throw operationError;
103
+ if (cleanupError !== undefined)
104
+ throw cleanupError;
54
105
  }
55
106
  // ── pure rendering (exported for tests) ───────────────────────────────────────
56
107
  /** One compact line for a gene in the rules body. Deterministic; defensively caps the hint length. */
@@ -135,14 +186,10 @@ export function cursorRulesPath(configRoot) {
135
186
  * Symlink-hardened on every adapter-owned path; other `.cursor/rules/*.mdc` files are never read or written.
136
187
  */
137
188
  export function installCursorRules(opts) {
138
- const rulesDir = join(opts.configRoot, CURSOR_RULES_DIR);
139
- const cursorDir = join(opts.configRoot, '.cursor');
140
- const filePath = cursorRulesPath(opts.configRoot);
189
+ const paths = cursorRulePaths(opts.configRoot);
190
+ const { rulesDir, filePath } = paths;
141
191
  const maxGenes = opts.maxGenes ?? DEFAULT_CURSOR_MAX_GENES;
142
- assertNotSymlink(opts.configRoot, 'config root');
143
- assertNotSymlink(cursorDir, '.cursor');
144
- assertNotSymlink(rulesDir, '.cursor/rules');
145
- assertNotSymlink(filePath, '.cursor/rules/evolver.mdc');
192
+ assertCursorRulePathsNotSymlink(opts.configRoot, paths);
146
193
  const block = renderManagedBlock(opts.genes, maxGenes);
147
194
  const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
148
195
  const next = existing ? spliceManagedBlock(existing, block) : renderCursorRulesFile(opts.genes, maxGenes);
@@ -151,7 +198,7 @@ export function installCursorRules(opts) {
151
198
  return { ok: true, runtime: 'cursor', mode: 'cursor-rules', files: [], rewritten: false };
152
199
  }
153
200
  mkdirSync(rulesDir, { recursive: true });
154
- writeTextAtomic(filePath, next);
201
+ writeTextAtomic(filePath, next, () => assertCursorRulePathsNotSymlink(opts.configRoot, paths));
155
202
  return { ok: true, runtime: 'cursor', mode: 'cursor-rules', files: [filePath], rewritten: true };
156
203
  }
157
204
  /**
@@ -159,19 +206,21 @@ export function installCursorRules(opts) {
159
206
  * file; otherwise strip just the managed block and keep the user's content. Other rules files are untouched.
160
207
  */
161
208
  export function uninstallCursorRules(opts) {
162
- const filePath = cursorRulesPath(opts.configRoot);
163
- assertNotSymlink(filePath, '.cursor/rules/evolver.mdc');
209
+ const paths = cursorRulePaths(opts.configRoot);
210
+ const { filePath } = paths;
211
+ assertCursorRulePathsNotSymlink(opts.configRoot, paths);
164
212
  if (!existsSync(filePath))
165
213
  return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [] };
166
214
  const text = readFileSync(filePath, 'utf8');
167
215
  if (isEvolverOnly(text)) {
216
+ assertCursorRulePathsNotSymlink(opts.configRoot, paths);
168
217
  unlinkSync(filePath);
169
218
  return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [filePath] };
170
219
  }
171
220
  const { changed, text: stripped } = stripManagedBlock(text);
172
221
  if (!changed)
173
222
  return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [] };
174
- writeTextAtomic(filePath, stripped.endsWith('\n') ? stripped : `${stripped}\n`);
223
+ writeTextAtomic(filePath, stripped.endsWith('\n') ? stripped : `${stripped}\n`, () => assertCursorRulePathsNotSymlink(opts.configRoot, paths));
175
224
  return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [filePath] };
176
225
  }
177
226
  /** Convenience for the daemon rewrite trigger: rewrite the rules file to reflect the current top genes. Returns
@@ -1,11 +1,16 @@
1
1
  import { type RuntimeId, type McpServerCmd, type InjectionPlan } from './injection.js';
2
2
  import { type CursorGene } from './cursorRulesInstaller.js';
3
+ export { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
3
4
  /** Marks a config file as containing evolver-managed entries, so uninstall only removes what we added. */
4
5
  export declare const MANAGED_MARKER = "_evolver_managed";
6
+ /** Official command-handler metadata, also gives custom commands an ownership marker for reinstall/uninstall. */
7
+ export declare const EVOLVER_HOOK_STATUS = "Loading Evolver memory";
5
8
  /** Default command the SessionStart hook runs to render + print the memory injection. The `--hook-stdin` flag opts
6
9
  * the entrypoint into reading the runtime's SessionStart JSON from stdin (to capture session_id, #205); only the
7
10
  * installed hook sets it, so a manual `evolver inject session-start` never reads stdin. */
8
11
  export declare const DEFAULT_HOOK_COMMAND = "evolver inject session-start --hook-stdin";
12
+ /** Local-only prompt recall. The handler is default-off and reads stdin only when EVOLVER_RECALL_MODE opts in. */
13
+ export declare const DEFAULT_PROMPT_RECALL_HOOK_COMMAND = "evolver inject prompt-recall --hook-stdin";
9
14
  /**
10
15
  * Where a claude-code install registers the evolver MCP server. This is the fix for "global install doesn't
11
16
  * actually globalize" (#290): Claude Code's MCP scopes are local / user / project, and a `.mcp.json` is the
@@ -30,6 +35,8 @@ export interface InstallOptions {
30
35
  server: McpServerCmd;
31
36
  /** Command the SessionStart hook runs to inject memory. Default 'evolver inject session-start'. */
32
37
  hookCommand?: string;
38
+ /** Command the UserPromptSubmit hook runs. Default is local-only, default-off prompt recall. */
39
+ promptRecallHookCommand?: string;
33
40
  /** Reinstall even if an evolver install is already present. */
34
41
  force?: boolean;
35
42
  /** Plan and validate without writing config or backup files. */
@@ -93,31 +100,16 @@ export interface InstallResult {
93
100
  backups?: string[];
94
101
  error?: string;
95
102
  }
96
- export declare class SymlinkRefusedError extends Error {
97
- constructor(label: string, path: string);
98
- }
99
- /**
100
- * Thrown when a SHARED user config (~/.claude.json or ~/.claude/settings.json) exists but does not parse as JSON.
101
- * These files are Claude Code's own state (projects/oauthAccount/userID/history/settings),
102
- * and user-scope install merges into them via a full-file atomic replace. The lenient readJson() returns {} on
103
- * a parse failure, which would make the merge emit ONLY evolver's entry and silently WIPE the whole file — a
104
- * realistic data-loss path because Claude Code writes these files non-atomically (a concurrent session can leave
105
- * one truncated). For the shared-config read we therefore refuse instead of clobbering. Project-scoped
106
- * .mcp.json/.claude/settings.json are evolver-owned, so their lenient fresh-start behavior stays unchanged.
107
- */
108
- export declare class UnparseableConfigError extends Error {
109
- constructor(label: string, path: string, owner?: string);
110
- }
111
- /**
112
- * Thrown when a SHARED user config exists but is empty or whitespace-only. Claude Code writes these files with a
113
- * truncating write, so present-empty can be a concurrent-write window rather than a fresh config.
114
- */
115
- export declare class EmptySharedConfigError extends Error {
116
- constructor(label: string, path: string, owner?: string);
117
- }
118
103
  type SharedConfigRaceHook = (path: string, attempt: number) => void;
119
104
  export declare function _setSharedConfigRaceHookForTest(hook?: SharedConfigRaceHook): void;
120
105
  export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
106
+ /** Bind custom-command ownership to that exact command without adding undocumented hook-schema fields. */
107
+ export declare function evolverManagedHookStatus(command: string): string;
108
+ /** Remove only Evolver-owned handlers, retaining user handlers that share the same matcher group. */
109
+ export declare function stripEvolverHookEntries(entries: readonly unknown[], trustManagedStatus?: boolean, managedCommands?: ReadonlySet<string>): {
110
+ changed: boolean;
111
+ entries: unknown[];
112
+ };
121
113
  /**
122
114
  * deepMerge, but for `hooks.<event>` arrays keep the user's existing entries and only replace evolver-owned
123
115
  * ones — so reinstalling refreshes evolver's hook without clobbering a user's own SessionStart/Stop hooks.
@@ -147,5 +139,4 @@ export declare function installInjection(plan: InjectionPlan, opts: InstallOptio
147
139
  * the mcpServers.evolver entry (and any evolver-owned hooks/marker), so it is safe on the shared ~/.claude.json. */
148
140
  export declare function uninstallInjection(runtime: RuntimeId, opts: UninstallOptions): InstallResult;
149
141
  /** Convenience: plan + install in one call for a runtime. */
150
- export declare function setupRuntime(runtime: RuntimeId, opts: InstallOptions): InstallResult;
151
- export {};
142
+ export declare function setupRuntime(runtime: RuntimeId, opts: InstallOptions): InstallResult;