prism-mcp-server 20.2.0 → 20.2.2

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/connect.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as win32Path } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
7
+ import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
7
8
  export const CONNECT_HOSTS = [
8
9
  "claude-code",
9
10
  "claude-desktop",
@@ -25,6 +26,49 @@ const HOST_ALIASES = {
25
26
  };
26
27
  const CODEX_MANAGED_START = "# >>> prism connect managed: prism-mcp";
27
28
  const CODEX_MANAGED_END = "# <<< prism connect managed: prism-mcp";
29
+ const CLAUDE_STARTUP_MANAGED_START = "<!-- >>> prism connect managed: native startup -->";
30
+ const CLAUDE_STARTUP_MANAGED_END = "<!-- <<< prism connect managed: native startup -->";
31
+ const GEMINI_STARTUP_MANAGED_START = "<!-- >>> prism connect managed: native startup -->";
32
+ const GEMINI_STARTUP_MANAGED_END = "<!-- <<< prism connect managed: native startup -->";
33
+ const CODEX_STARTUP_MANAGED_START = "<!-- >>> prism connect managed: codex native startup -->";
34
+ const CODEX_STARTUP_MANAGED_END = "<!-- <<< prism connect managed: codex native startup -->";
35
+ const CODEX_STARTUP_BODY = [
36
+ "## Prism session startup",
37
+ "On the first user turn of every conversation, including a greeting, your first action must be",
38
+ "`session_bootstrap({})`, exactly once. Emit no preamble. Print the complete tool result verbatim as the",
39
+ "entire first-turn startup display, before any optional answer. Do not summarize, paraphrase, rename headings,",
40
+ "reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, stop",
41
+ "after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery to load that",
42
+ "exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection as",
43
+ "a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
44
+ "`Prism startup failure` and stop. Reuse the `conversation_id` returned in structuredContent for every",
45
+ "session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This hook-free",
46
+ "block is managed by `prism connect`; do not edit it manually.",
47
+ "",
48
+ ...LOCAL_FIRST_POLICY_LINES,
49
+ ];
50
+ const CONNECT_STORAGE_BACKENDS = ["auto", "local", "synalux", "supabase"];
51
+ const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
52
+ command: "npx",
53
+ args: ["-y", "prism-mcp-server"],
54
+ };
55
+ const INSTALLATION_RECEIPT_SCHEMA_VERSION = 1;
56
+ const INSTALLATION_RECEIPT_OWNER = "prism-connect";
57
+ const CLAUDE_FALLBACK_SUBAGENT_MODEL = "sonnet";
58
+ const CODEX_LOCAL_FIRST_POLICY = {
59
+ features: {
60
+ multi_agent: false,
61
+ },
62
+ agents: {
63
+ max_threads: 2,
64
+ max_depth: 1,
65
+ default_subagent_model: "gpt-5.6-terra",
66
+ default_subagent_reasoning_effort: "low",
67
+ job_max_runtime_seconds: 900,
68
+ },
69
+ };
70
+ const CODEX_POLICY_MARKER_PREFIX = "# >>> prism connect managed: local-first";
71
+ const CODEX_POLICY_MARKER_SUFFIX = "# <<< prism connect managed: local-first";
28
72
  export function normalizeHostName(value) {
29
73
  const normalized = value.trim().toLowerCase();
30
74
  const host = HOST_ALIASES[normalized];
@@ -51,6 +95,19 @@ export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
51
95
  }
52
96
  return realpathSync(serverPath);
53
97
  }
98
+ function resolveInstalledPackageVersion(moduleUrl = import.meta.url) {
99
+ const manifestPath = fileURLToPath(new URL("../package.json", moduleUrl));
100
+ try {
101
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
102
+ if (isJsonObject(manifest) && typeof manifest.version === "string" && manifest.version.trim()) {
103
+ return manifest.version;
104
+ }
105
+ }
106
+ catch (error) {
107
+ throw new Error(`Cannot read Prism package version at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ throw new Error(`Prism package manifest has no valid "version" entry: ${manifestPath}`);
110
+ }
54
111
  export function connectHosts(options = {}) {
55
112
  if (options.all && options.hosts?.length) {
56
113
  throw new Error("Use either --all or --host, not both.");
@@ -79,12 +136,775 @@ export function connectHosts(options = {}) {
79
136
  }
80
137
  const entry = buildMcpEntry(nodePath, serverPath, env);
81
138
  const results = selected.map((definition) => registerHost(definition, entry, !!options.dryRun, !!options.refresh, options.beforeCommit));
139
+ let installationReceiptPath;
140
+ if (!options.dryRun && results.some((item) => item.status !== "error" && item.startupCompatible)) {
141
+ const pathApi = platform === "win32" ? win32Path : { dirname, join, isAbsolute, resolve };
142
+ const absoluteNodePath = pathApi.isAbsolute(nodePath) ? nodePath : pathApi.resolve(nodePath);
143
+ const absoluteServerPath = pathApi.isAbsolute(serverPath) ? serverPath : pathApi.resolve(serverPath);
144
+ const rawCliPath = options.cliPath ?? pathApi.join(pathApi.dirname(absoluteServerPath), "cli.js");
145
+ const absoluteCliPath = pathApi.isAbsolute(rawCliPath) ? rawCliPath : pathApi.resolve(rawCliPath);
146
+ installationReceiptPath = writeInstallationReceipt(homeDir, {
147
+ schema_version: INSTALLATION_RECEIPT_SCHEMA_VERSION,
148
+ owner: INSTALLATION_RECEIPT_OWNER,
149
+ node_path: absoluteNodePath,
150
+ cli_path: absoluteCliPath,
151
+ server_path: absoluteServerPath,
152
+ package_version: options.packageVersion ?? resolveInstalledPackageVersion(),
153
+ }, options.beforeReceiptCommit);
154
+ }
82
155
  return {
83
156
  results,
84
157
  usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
85
158
  serverPath,
159
+ installationReceiptPath,
86
160
  };
87
161
  }
162
+ /** Write the Prism-owned GUI discovery receipt without following symlinks. */
163
+ export function writeInstallationReceipt(homeDir, receipt, beforeCommit) {
164
+ if (!isAbsolute(resolve(homeDir)))
165
+ throw new Error("Prism installation receipt home must resolve absolutely");
166
+ for (const [key, value] of Object.entries(receipt)) {
167
+ if (key.endsWith("_path") && (typeof value !== "string" || (!isAbsolute(value) && !win32Path.isAbsolute(value)))) {
168
+ throw new Error(`Prism installation receipt ${key} must be absolute`);
169
+ }
170
+ }
171
+ const directory = join(resolve(homeDir), ".prism-mcp");
172
+ const receiptPath = join(directory, "installation.json");
173
+ try {
174
+ const directoryInfo = lstatSync(directory);
175
+ if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) {
176
+ throw new Error("receipt directory must be a real directory");
177
+ }
178
+ }
179
+ catch (error) {
180
+ if (!isErrno(error, "ENOENT")) {
181
+ throw new Error(`Could not secure Prism installation receipt directory: ${error instanceof Error ? error.message : String(error)}`);
182
+ }
183
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
184
+ const directoryInfo = lstatSync(directory);
185
+ if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) {
186
+ throw new Error("Could not secure Prism installation receipt directory");
187
+ }
188
+ }
189
+ let originalText;
190
+ try {
191
+ const receiptInfo = lstatSync(receiptPath);
192
+ if (receiptInfo.isSymbolicLink() || !receiptInfo.isFile()) {
193
+ throw new Error("receipt must be a regular Prism-owned file");
194
+ }
195
+ originalText = readFileSync(receiptPath, "utf8");
196
+ const current = JSON.parse(originalText);
197
+ if (!isJsonObject(current)
198
+ || current.schema_version !== INSTALLATION_RECEIPT_SCHEMA_VERSION
199
+ || current.owner !== INSTALLATION_RECEIPT_OWNER) {
200
+ throw new Error("existing receipt is not owned by prism-connect");
201
+ }
202
+ }
203
+ catch (error) {
204
+ if (!isErrno(error, "ENOENT")) {
205
+ throw new Error(`Could not inspect Prism installation receipt: ${error instanceof Error ? error.message : String(error)}`);
206
+ }
207
+ }
208
+ writeTextAtomically(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, originalText, beforeCommit);
209
+ return receiptPath;
210
+ }
211
+ /**
212
+ * Remove the exact project-scoped registration written by Prism's former
213
+ * `npx -y prism-mcp-server` setup. Claude Code resolves the nearest ancestor
214
+ * `.mcp.json`, so a stale entry there can shadow the native user registration.
215
+ * Custom Prism commands, arguments, environment, and unrelated servers remain
216
+ * untouched.
217
+ */
218
+ export function migrateLegacyClaudeProjectMcp(homeDir = homedir(), cwd = process.cwd(), dryRun = false, beforeCommit) {
219
+ let resolvedHome;
220
+ let resolvedCwd;
221
+ try {
222
+ resolvedHome = realpathSync(resolve(homeDir));
223
+ resolvedCwd = realpathSync(resolve(cwd));
224
+ }
225
+ catch (error) {
226
+ throw new Error(`Could not resolve Claude project MCP search path: ${error instanceof Error ? error.message : String(error)}`);
227
+ }
228
+ const fallbackPath = join(resolvedHome, ".mcp.json");
229
+ if (!isPathWithin(resolvedCwd, resolvedHome)) {
230
+ return { path: fallbackPath, status: "unchanged", removed: 0 };
231
+ }
232
+ let configPath;
233
+ for (let directory = resolvedCwd;; directory = dirname(directory)) {
234
+ const candidate = join(directory, ".mcp.json");
235
+ try {
236
+ lstatSync(candidate);
237
+ configPath = candidate;
238
+ break;
239
+ }
240
+ catch (error) {
241
+ if (!isErrno(error, "ENOENT")) {
242
+ throw new Error(`Could not inspect Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
243
+ }
244
+ }
245
+ if (directory === resolvedHome)
246
+ break;
247
+ }
248
+ if (!configPath)
249
+ return { path: fallbackPath, status: "unchanged", removed: 0 };
250
+ let writePath = configPath;
251
+ let symlinkPath;
252
+ let originalText;
253
+ try {
254
+ const pathInfo = lstatSync(configPath);
255
+ if (pathInfo.isSymbolicLink()) {
256
+ writePath = realpathSync(configPath);
257
+ symlinkPath = configPath;
258
+ }
259
+ if (!statSync(writePath).isFile())
260
+ throw new Error("config is not a regular file");
261
+ originalText = readFileSync(writePath, "utf8");
262
+ }
263
+ catch (error) {
264
+ throw new Error(`Could not inspect Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
265
+ }
266
+ let config;
267
+ try {
268
+ const parsed = JSON.parse(originalText);
269
+ if (!isJsonObject(parsed))
270
+ throw new Error("top-level JSON value must be an object");
271
+ config = parsed;
272
+ }
273
+ catch (error) {
274
+ throw new Error(`Could not parse Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
275
+ }
276
+ const currentServers = config.mcpServers;
277
+ if (currentServers === undefined) {
278
+ return { path: configPath, status: "unchanged", removed: 0 };
279
+ }
280
+ if (!isJsonObject(currentServers)) {
281
+ throw new Error('Could not inspect Claude project MCP config: "mcpServers" must be a JSON object');
282
+ }
283
+ if (!Object.prototype.hasOwnProperty.call(currentServers, "prism-mcp")
284
+ || !isDeepStrictEqual(currentServers["prism-mcp"], LEGACY_CLAUDE_PROJECT_PRISM_ENTRY)) {
285
+ return { path: configPath, status: "unchanged", removed: 0 };
286
+ }
287
+ delete currentServers["prism-mcp"];
288
+ if (dryRun)
289
+ return { path: configPath, status: "would-remove", removed: 1 };
290
+ writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
291
+ return { path: configPath, status: "removed", removed: 1 };
292
+ }
293
+ function legacyClaudeHookTuples(homeDir) {
294
+ const hooksDir = join(homeDir, ".claude", "hooks");
295
+ const syncSkills = `${join(homeDir, "prism", "scripts", "sync-skills.sh")} > /dev/null 2>&1`;
296
+ const loadMatcher = "session_load_context|mcp__prism-mcp__session_load_context";
297
+ const driftMatcher = "session_detect_drift|mcp__prism-mcp__session_detect_drift|session_save_ledger|mcp__prism-mcp__session_save_ledger";
298
+ const stop = "python3 -c \"import json; print(json.dumps({'continue': True, 'suppressOutput': True, 'systemMessage': 'MANDATORY END WORKFLOW: 1) Call mcp__prism-mcp__session_save_ledger with project and summary. 2) Call mcp__prism-mcp__session_save_handoff with expected_version set to the loaded version.'}))\"";
299
+ const tuple = (event, matcher, command) => JSON.stringify([event, matcher, "command", command]);
300
+ return new Set([
301
+ tuple("SessionStart", "*", `python3 ${join(hooksDir, "prism-startup", "init.py")}`),
302
+ tuple("SessionStart", "*", syncSkills),
303
+ tuple("UserPromptSubmit", "*", `python3 ${join(hooksDir, "prism-startup", "guard_on_submit.py")}`),
304
+ tuple("UserPromptSubmit", "*", join(hooksDir, "prism-startup", "maybe_sync_skills.sh")),
305
+ tuple("PostToolUse", loadMatcher, `python3 ${join(hooksDir, "prism-startup", "mark_loaded.py")}`),
306
+ tuple("PostToolUse", driftMatcher, `python3 ${join(hooksDir, "drift-detection", "reset_timer.py")}`),
307
+ tuple("PostToolUseFailure", loadMatcher, `python3 ${join(hooksDir, "prism-startup", "record_retry.py")}`),
308
+ tuple("SessionEnd", "*", `python3 ${join(hooksDir, "prism-startup", "cleanup.py")}`),
309
+ tuple("SessionEnd", "*", syncSkills),
310
+ tuple("Stop", "*", stop),
311
+ ]);
312
+ }
313
+ /**
314
+ * Remove only the exact Claude lifecycle hooks installed by Prism's legacy
315
+ * bootstrap script. Native skills and server-side drift reminders now own the
316
+ * same behavior; unrelated and near-match user hooks must remain untouched.
317
+ */
318
+ export function migrateLegacyClaudeHooks(homeDir = homedir(), dryRun = false, beforeCommit) {
319
+ const configPath = join(homeDir, ".claude", "settings.json");
320
+ let writePath = configPath;
321
+ let symlinkPath;
322
+ let originalText;
323
+ try {
324
+ const pathInfo = lstatSync(configPath);
325
+ if (pathInfo.isSymbolicLink()) {
326
+ writePath = realpathSync(configPath);
327
+ symlinkPath = configPath;
328
+ if (!statSync(writePath).isFile())
329
+ throw new Error("target is not a regular file");
330
+ }
331
+ originalText = readFileSync(writePath, "utf8");
332
+ }
333
+ catch (error) {
334
+ if (isErrno(error, "ENOENT"))
335
+ return { path: configPath, status: "unchanged", removed: 0 };
336
+ throw new Error(`Could not inspect Claude hook settings: ${error instanceof Error ? error.message : String(error)}`);
337
+ }
338
+ let config;
339
+ try {
340
+ const parsed = JSON.parse(originalText);
341
+ if (!isJsonObject(parsed))
342
+ throw new Error("top-level JSON value must be an object");
343
+ config = parsed;
344
+ }
345
+ catch (error) {
346
+ throw new Error(`Could not parse Claude hook settings: ${error instanceof Error ? error.message : String(error)}`);
347
+ }
348
+ if (!isJsonObject(config.hooks))
349
+ return { path: configPath, status: "unchanged", removed: 0 };
350
+ const legacyTuples = legacyClaudeHookTuples(homeDir);
351
+ const nextEvents = {};
352
+ let removed = 0;
353
+ for (const [event, groups] of Object.entries(config.hooks)) {
354
+ if (!Array.isArray(groups)) {
355
+ nextEvents[event] = groups;
356
+ continue;
357
+ }
358
+ const nextGroups = [];
359
+ for (const group of groups) {
360
+ if (!isJsonObject(group) || !Array.isArray(group.hooks)) {
361
+ nextGroups.push(group);
362
+ continue;
363
+ }
364
+ let removedFromGroup = 0;
365
+ const nextHooks = group.hooks.filter((hook) => {
366
+ const owned = isJsonObject(hook)
367
+ && hook.type === "command"
368
+ && typeof hook.command === "string"
369
+ && legacyTuples.has(JSON.stringify([event, group.matcher, hook.type, hook.command]));
370
+ if (owned)
371
+ removedFromGroup += 1;
372
+ return !owned;
373
+ });
374
+ removed += removedFromGroup;
375
+ if (removedFromGroup === 0)
376
+ nextGroups.push(group);
377
+ else if (nextHooks.length > 0)
378
+ nextGroups.push({ ...group, hooks: nextHooks });
379
+ }
380
+ if (nextGroups.length > 0)
381
+ nextEvents[event] = nextGroups;
382
+ }
383
+ if (removed === 0)
384
+ return { path: configPath, status: "unchanged", removed: 0 };
385
+ if (Object.keys(nextEvents).length > 0)
386
+ config.hooks = nextEvents;
387
+ else
388
+ delete config.hooks;
389
+ if (dryRun)
390
+ return { path: configPath, status: "would-remove", removed };
391
+ writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
392
+ return { path: configPath, status: "removed", removed };
393
+ }
394
+ /**
395
+ * Remove the legacy Prism-only startup sections from ~/CLAUDE.md. The rest of
396
+ * the developer's global instructions remain byte-for-byte unchanged. Native
397
+ * skill and MCP metadata now own first-turn bootstrap selection.
398
+ */
399
+ export function migrateLegacyClaudeInstructions(homeDir = homedir(), dryRun = false, beforeCommit) {
400
+ const instructionPath = join(homeDir, "CLAUDE.md");
401
+ let writePath = instructionPath;
402
+ let symlinkPath;
403
+ let originalText;
404
+ let instructionEntryExists = false;
405
+ try {
406
+ const pathInfo = lstatSync(instructionPath);
407
+ instructionEntryExists = true;
408
+ if (pathInfo.isSymbolicLink()) {
409
+ writePath = realpathSync(instructionPath);
410
+ symlinkPath = instructionPath;
411
+ if (!statSync(writePath).isFile())
412
+ throw new Error("target is not a regular file");
413
+ }
414
+ originalText = readFileSync(writePath, "utf8");
415
+ }
416
+ catch (error) {
417
+ if (!instructionEntryExists && isErrno(error, "ENOENT")) {
418
+ return { path: instructionPath, status: "unchanged", removed: 0 };
419
+ }
420
+ throw new Error(`Could not inspect Claude instructions: ${error instanceof Error ? error.message : String(error)}`);
421
+ }
422
+ const stepOne = findExactLineRanges(originalText, "## STEP 1: Auto-Load Prism Memory (MUST BE YOUR FIRST ACTION — NO EXCEPTIONS)");
423
+ const hardGates = findExactLineRanges(originalText, "## HARD BEHAVIORAL GATES — SUPERSEDE ALL OTHER INSTRUCTIONS");
424
+ if (stepOne.length !== 1 || hardGates.length !== 1 || hardGates[0].start <= stepOne[0].start) {
425
+ return { path: instructionPath, status: "unchanged", removed: 0 };
426
+ }
427
+ const legacyBlock = originalText.slice(stepOne[0].start, hardGates[0].start);
428
+ const signatures = [
429
+ 'mcp__prism-mcp__session_load_context(project="prism-mcp")',
430
+ "## STEP 2: Display Startup Block (ONLY AFTER STEP 1 COMPLETES)",
431
+ "Use the `[📜 SKILL: ...]` blocks returned by session_load_context to build the display:",
432
+ ];
433
+ if (!signatures.every((signature) => legacyBlock.includes(signature))) {
434
+ return { path: instructionPath, status: "unchanged", removed: 0 };
435
+ }
436
+ const nextText = originalText.slice(0, stepOne[0].start) + originalText.slice(hardGates[0].start);
437
+ if (dryRun)
438
+ return { path: instructionPath, status: "would-remove", removed: 2 };
439
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
440
+ return { path: instructionPath, status: "removed", removed: 2 };
441
+ }
442
+ /** Remove only Prism's marked startup block from the former ~/CLAUDE.md path. */
443
+ export function migrateLegacyClaudeManagedStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
444
+ const instructionPath = join(homeDir, "CLAUDE.md");
445
+ let writePath = instructionPath;
446
+ let symlinkPath;
447
+ let originalText;
448
+ let instructionEntryExists = false;
449
+ try {
450
+ const pathInfo = lstatSync(instructionPath);
451
+ instructionEntryExists = true;
452
+ if (pathInfo.isSymbolicLink()) {
453
+ writePath = realpathSync(instructionPath);
454
+ symlinkPath = instructionPath;
455
+ if (!statSync(writePath).isFile())
456
+ throw new Error("target is not a regular file");
457
+ }
458
+ originalText = readFileSync(writePath, "utf8");
459
+ }
460
+ catch (error) {
461
+ if (!instructionEntryExists && isErrno(error, "ENOENT")) {
462
+ return { path: instructionPath, status: "unchanged", removed: 0 };
463
+ }
464
+ throw new Error(`Could not inspect legacy Claude instructions: ${error instanceof Error ? error.message : String(error)}`);
465
+ }
466
+ const startRanges = findExactLineRanges(originalText, CLAUDE_STARTUP_MANAGED_START);
467
+ const endRanges = findExactLineRanges(originalText, CLAUDE_STARTUP_MANAGED_END);
468
+ if (startRanges.length !== endRanges.length || startRanges.length > 1) {
469
+ throw new Error(`Legacy Claude instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
470
+ }
471
+ if (startRanges.length === 0)
472
+ return { path: instructionPath, status: "unchanged", removed: 0 };
473
+ if (endRanges[0].start <= startRanges[0].start) {
474
+ throw new Error(`Legacy Claude instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
475
+ }
476
+ const nextText = originalText.slice(0, startRanges[0].start) + originalText.slice(endRanges[0].end);
477
+ if (dryRun)
478
+ return { path: instructionPath, status: "would-remove", removed: 1 };
479
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
480
+ return { path: instructionPath, status: "removed", removed: 1 };
481
+ }
482
+ function serializeClaudeStartupBlock(newline) {
483
+ return [
484
+ CLAUDE_STARTUP_MANAGED_START,
485
+ "## Prism session startup",
486
+ "On the first user turn of every conversation, including a greeting, your first action must be to call",
487
+ "`mcp__prism-mcp__session_bootstrap` exactly once with an empty object (`{}`). Emit no preamble. Print the",
488
+ "complete tool result verbatim as the entire first-turn startup display, before any optional answer. Do not",
489
+ "summarize, paraphrase, rename headings, reformat, or omit any returned section. Preserve its order and line",
490
+ "content. For a greeting-only prompt, stop after the verbatim startup display. If `session_bootstrap` is",
491
+ "deferred, use native tool discovery/ToolSearch to load that",
492
+ "exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection",
493
+ "as a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
494
+ "`Prism startup failure` and stop. Reuse the `conversation_id` returned in structuredContent for every",
495
+ "session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This block is",
496
+ "managed by `prism connect`; do not edit it manually.",
497
+ "",
498
+ ...LOCAL_FIRST_POLICY_LINES,
499
+ CLAUDE_STARTUP_MANAGED_END,
500
+ "",
501
+ ].join(newline);
502
+ }
503
+ /** Install or refresh Claude Code's native, hook-free first-turn instruction. */
504
+ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
505
+ const instructionPath = join(homeDir, ".claude", "CLAUDE.md");
506
+ let writePath = instructionPath;
507
+ let symlinkPath;
508
+ let originalText;
509
+ let instructionEntryExists = false;
510
+ try {
511
+ const pathInfo = lstatSync(instructionPath);
512
+ instructionEntryExists = true;
513
+ if (pathInfo.isSymbolicLink()) {
514
+ writePath = realpathSync(instructionPath);
515
+ symlinkPath = instructionPath;
516
+ if (!statSync(writePath).isFile())
517
+ throw new Error("target is not a regular file");
518
+ }
519
+ originalText = readFileSync(writePath, "utf8");
520
+ }
521
+ catch (error) {
522
+ if (instructionEntryExists || !isErrno(error, "ENOENT")) {
523
+ throw new Error(`Could not inspect Claude instructions: ${error instanceof Error ? error.message : String(error)}`);
524
+ }
525
+ }
526
+ const currentText = originalText ?? "";
527
+ const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
528
+ const startRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_START);
529
+ const endRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_END);
530
+ if (startRanges.length !== endRanges.length || startRanges.length > 1) {
531
+ throw new Error(`Claude instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
532
+ }
533
+ const managedBlock = serializeClaudeStartupBlock(newline);
534
+ let nextText;
535
+ let action;
536
+ if (startRanges.length === 1) {
537
+ if (endRanges[0].start <= startRanges[0].start) {
538
+ throw new Error(`Claude instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
539
+ }
540
+ const managedEnd = endRanges[0].end;
541
+ if (currentText.slice(startRanges[0].start, managedEnd) === managedBlock) {
542
+ return { path: instructionPath, status: "unchanged" };
543
+ }
544
+ nextText = currentText.slice(0, startRanges[0].start) + managedBlock + currentText.slice(managedEnd);
545
+ action = "refresh";
546
+ }
547
+ else {
548
+ const separator = currentText.length === 0
549
+ ? ""
550
+ : currentText.endsWith("\n") || currentText.endsWith("\r")
551
+ ? newline
552
+ : `${newline}${newline}`;
553
+ nextText = currentText + separator + managedBlock;
554
+ action = "install";
555
+ }
556
+ if (dryRun) {
557
+ return { path: instructionPath, status: action === "install" ? "would-install" : "would-refresh" };
558
+ }
559
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
560
+ return { path: instructionPath, status: action === "install" ? "installed" : "refreshed" };
561
+ }
562
+ function serializeGeminiStartupBlock(newline) {
563
+ return [
564
+ GEMINI_STARTUP_MANAGED_START,
565
+ "## Prism session startup",
566
+ "On the first user turn of every conversation, including a greeting, your first action must be",
567
+ "`session_bootstrap({})`, exactly once. Emit no preamble. Print the complete tool result verbatim as the",
568
+ "entire first-turn startup display, before any optional answer. Do not summarize, paraphrase, rename headings,",
569
+ "reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, stop",
570
+ "after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery/ToolSearch",
571
+ "to load that exact tool, then invoke it.",
572
+ "Do not use shell commands, file reads, subagents, or unrelated tool inspection as a substitute. Do not call",
573
+ "`session_load_context`. If discovery or invocation fails, report `Prism startup failure` and stop. Reuse the",
574
+ "`conversation_id` returned in structuredContent for session_save_ledger, session_save_handoff, and",
575
+ "session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
576
+ "",
577
+ ...LOCAL_FIRST_POLICY_LINES,
578
+ GEMINI_STARTUP_MANAGED_END,
579
+ "",
580
+ ].join(newline);
581
+ }
582
+ /** The exact Gemini startup section provisioned before native bootstrap support. */
583
+ function legacyGeminiStartupBlock(newline) {
584
+ return [
585
+ "# Startup — MANDATORY",
586
+ "",
587
+ "Your first action in every conversation is loading Prism session context. Zero text before the tool call.",
588
+ "",
589
+ "**Dual-path detection:** Check your available toolset.",
590
+ "- **If `mcp_prism-mcp_session_load_context` exists:** Call it with `project: \"prism-mcp\"`, `level: \"deep\"`.",
591
+ "- **If no MCP tools are available (Antigravity):** Run `bash ~/.gemini/antigravity/scratch/prism_session_loader.sh prism-mcp` via `run_command`. This uses the `prism load` CLI under the hood, sharing the same storage layer (SQLite or Supabase) as the MCP tool.",
592
+ "",
593
+ "After success: echo agent identity, last summary, open TODOs, session version.",
594
+ "If any call fails: say \"Prism load failed — retrying\" and retry ONE more time.",
595
+ "",
596
+ "",
597
+ ].join(newline);
598
+ }
599
+ /** Install or refresh Gemini CLI's native, hook-free first-turn instruction. */
600
+ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
601
+ const instructionPath = join(homeDir, ".gemini", "GEMINI.md");
602
+ let writePath = instructionPath;
603
+ let symlinkPath;
604
+ let originalText;
605
+ let instructionEntryExists = false;
606
+ try {
607
+ const pathInfo = lstatSync(instructionPath);
608
+ instructionEntryExists = true;
609
+ if (pathInfo.isSymbolicLink()) {
610
+ writePath = realpathSync(instructionPath);
611
+ symlinkPath = instructionPath;
612
+ if (!statSync(writePath).isFile())
613
+ throw new Error("target is not a regular file");
614
+ }
615
+ originalText = readFileSync(writePath, "utf8");
616
+ }
617
+ catch (error) {
618
+ if (instructionEntryExists || !isErrno(error, "ENOENT")) {
619
+ throw new Error(`Could not inspect Gemini instructions: ${error instanceof Error ? error.message : String(error)}`);
620
+ }
621
+ }
622
+ const currentText = originalText ?? "";
623
+ const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
624
+ const startRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_START);
625
+ const endRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_END);
626
+ if (startRanges.length !== endRanges.length || startRanges.length > 1) {
627
+ throw new Error(`Gemini instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
628
+ }
629
+ const managedBlock = serializeGeminiStartupBlock(newline);
630
+ let nextText;
631
+ let action;
632
+ if (startRanges.length === 1) {
633
+ if (endRanges[0].start <= startRanges[0].start) {
634
+ throw new Error(`Gemini instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
635
+ }
636
+ const managedEnd = endRanges[0].end;
637
+ if (currentText.slice(startRanges[0].start, managedEnd) === managedBlock) {
638
+ return { path: instructionPath, status: "unchanged" };
639
+ }
640
+ nextText = currentText.slice(0, startRanges[0].start) + managedBlock + currentText.slice(managedEnd);
641
+ action = "refresh";
642
+ }
643
+ else {
644
+ const legacyBlock = legacyGeminiStartupBlock(newline);
645
+ if (currentText.startsWith(legacyBlock)) {
646
+ nextText = managedBlock + newline + currentText.slice(legacyBlock.length);
647
+ }
648
+ else {
649
+ const separator = currentText.length === 0
650
+ ? ""
651
+ : currentText.endsWith("\n") || currentText.endsWith("\r")
652
+ ? newline
653
+ : `${newline}${newline}`;
654
+ nextText = currentText + separator + managedBlock;
655
+ }
656
+ action = "install";
657
+ }
658
+ if (dryRun) {
659
+ return { path: instructionPath, status: action === "install" ? "would-install" : "would-refresh" };
660
+ }
661
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
662
+ return { path: instructionPath, status: action === "install" ? "installed" : "refreshed" };
663
+ }
664
+ function serializeCodexStartupBlock(newline) {
665
+ return [
666
+ CODEX_STARTUP_MANAGED_START,
667
+ ...CODEX_STARTUP_BODY,
668
+ CODEX_STARTUP_MANAGED_END,
669
+ "",
670
+ ].join(newline);
671
+ }
672
+ /** Install or refresh Codex's official global, hook-free first-turn instruction. */
673
+ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {}) {
674
+ const userHome = homeDir ?? homedir();
675
+ const configuredCodexHome = env.CODEX_HOME?.trim();
676
+ const codexHome = configuredCodexHome ? resolve(configuredCodexHome) : join(userHome, ".codex");
677
+ const instructionPath = join(codexHome, "AGENTS.md");
678
+ let writePath = instructionPath;
679
+ let symlinkPath;
680
+ let originalText;
681
+ let instructionEntryExists = false;
682
+ try {
683
+ const pathInfo = lstatSync(instructionPath);
684
+ instructionEntryExists = true;
685
+ if (pathInfo.isSymbolicLink()) {
686
+ writePath = realpathSync(instructionPath);
687
+ symlinkPath = instructionPath;
688
+ if (!statSync(writePath).isFile())
689
+ throw new Error("target is not a regular file");
690
+ }
691
+ originalText = readFileSync(writePath, "utf8");
692
+ }
693
+ catch (error) {
694
+ if (instructionEntryExists || !isErrno(error, "ENOENT")) {
695
+ throw new Error(`Could not inspect Codex instructions: ${error instanceof Error ? error.message : String(error)}`);
696
+ }
697
+ }
698
+ const currentText = originalText ?? "";
699
+ const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
700
+ const startRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_START);
701
+ const endRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_END);
702
+ if (startRanges.length !== endRanges.length || startRanges.length > 1) {
703
+ throw new Error(`Codex instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
704
+ }
705
+ const managedBlock = serializeCodexStartupBlock(newline);
706
+ let nextText;
707
+ let action;
708
+ if (startRanges.length === 1) {
709
+ if (endRanges[0].start <= startRanges[0].start) {
710
+ throw new Error(`Codex instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
711
+ }
712
+ const managedEnd = endRanges[0].end;
713
+ if (currentText.slice(startRanges[0].start, managedEnd) === managedBlock) {
714
+ return { path: instructionPath, status: "unchanged" };
715
+ }
716
+ nextText = currentText.slice(0, startRanges[0].start) + managedBlock + currentText.slice(managedEnd);
717
+ action = "refresh";
718
+ }
719
+ else {
720
+ const separator = currentText.length === 0
721
+ ? ""
722
+ : currentText.endsWith("\n") || currentText.endsWith("\r")
723
+ ? newline
724
+ : `${newline}${newline}`;
725
+ nextText = currentText + separator + managedBlock;
726
+ action = "install";
727
+ }
728
+ if (dryRun) {
729
+ return { path: instructionPath, status: action === "install" ? "would-install" : "would-refresh" };
730
+ }
731
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
732
+ return { path: instructionPath, status: action === "install" ? "installed" : "refreshed" };
733
+ }
734
+ function configureJsonAgentPolicy(configPath, label, mutate, dryRun, beforeCommit) {
735
+ let writePath = configPath;
736
+ let symlinkPath;
737
+ let originalText;
738
+ let config = {};
739
+ try {
740
+ const pathInfo = lstatSync(configPath);
741
+ if (pathInfo.isSymbolicLink()) {
742
+ writePath = realpathSync(configPath);
743
+ symlinkPath = configPath;
744
+ if (!statSync(writePath).isFile())
745
+ throw new Error("target is not a regular file");
746
+ }
747
+ originalText = readFileSync(writePath, "utf8");
748
+ const parsed = JSON.parse(originalText);
749
+ if (!isJsonObject(parsed))
750
+ throw new Error("top-level JSON value must be an object");
751
+ config = parsed;
752
+ }
753
+ catch (error) {
754
+ if (!isErrno(error, "ENOENT")) {
755
+ throw new Error(`Could not inspect ${label}: ${error instanceof Error ? error.message : String(error)}`);
756
+ }
757
+ }
758
+ const changed = mutate(config);
759
+ if (!changed)
760
+ return { path: configPath, status: "unchanged" };
761
+ const action = originalText === undefined ? "install" : "refresh";
762
+ if (dryRun) {
763
+ return { path: configPath, status: action === "install" ? "would-install" : "would-refresh" };
764
+ }
765
+ writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
766
+ return { path: configPath, status: action === "install" ? "installed" : "refreshed" };
767
+ }
768
+ /** Configure Claude Code's economy fallback model; policy text prevents routine fan-out. */
769
+ export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
770
+ const configPath = join(homeDir, ".claude", "settings.json");
771
+ return configureJsonAgentPolicy(configPath, "Claude agent settings", (config) => {
772
+ const currentEnv = config.env;
773
+ if (currentEnv !== undefined && !isJsonObject(currentEnv)) {
774
+ throw new Error('"env" must be a JSON object');
775
+ }
776
+ const env = (currentEnv ?? {});
777
+ if (env.CLAUDE_CODE_SUBAGENT_MODEL === CLAUDE_FALLBACK_SUBAGENT_MODEL)
778
+ return false;
779
+ env.CLAUDE_CODE_SUBAGENT_MODEL = CLAUDE_FALLBACK_SUBAGENT_MODEL;
780
+ config.env = env;
781
+ return true;
782
+ }, dryRun, beforeCommit);
783
+ }
784
+ /** Disable Gemini's native/remote subagents; Prism local workers remain available over MCP. */
785
+ export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
786
+ const configPath = join(homeDir, ".gemini", "settings.json");
787
+ return configureJsonAgentPolicy(configPath, "Gemini agent settings", (config) => {
788
+ const currentExperimental = config.experimental;
789
+ if (currentExperimental !== undefined && !isJsonObject(currentExperimental)) {
790
+ throw new Error('"experimental" must be a JSON object');
791
+ }
792
+ const experimental = (currentExperimental ?? {});
793
+ if (experimental.enableAgents === false)
794
+ return false;
795
+ experimental.enableAgents = false;
796
+ config.experimental = experimental;
797
+ return true;
798
+ }, dryRun, beforeCommit);
799
+ }
800
+ function tomlValue(value) {
801
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
802
+ }
803
+ function escapeRegExp(value) {
804
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
805
+ }
806
+ function applyTomlTablePolicy(source, table, desired) {
807
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
808
+ const hadFinalNewline = source.endsWith("\n");
809
+ const lines = source.length === 0 ? [] : source.split(/\r?\n/);
810
+ if (hadFinalNewline)
811
+ lines.pop();
812
+ const tablePattern = new RegExp(`^\\s*\\[${escapeRegExp(table)}\\]\\s*(?:#.*)?$`);
813
+ const tableIndexes = lines
814
+ .map((line, index) => tablePattern.test(line) ? index : -1)
815
+ .filter((index) => index >= 0);
816
+ if (tableIndexes.length > 1)
817
+ throw new Error(`duplicate [${table}] tables`);
818
+ let tableStart = tableIndexes[0];
819
+ if (tableStart === undefined) {
820
+ if (lines.length > 0 && lines[lines.length - 1] !== "")
821
+ lines.push("");
822
+ lines.push(`[${table}]`);
823
+ tableStart = lines.length - 1;
824
+ }
825
+ let tableEnd = lines.findIndex((line, index) => index > tableStart && /^\s*\[{1,2}[^\]]+\]{1,2}\s*(?:#.*)?$/.test(line));
826
+ if (tableEnd < 0)
827
+ tableEnd = lines.length;
828
+ const missing = [];
829
+ for (const [key, value] of Object.entries(desired)) {
830
+ const keyPattern = new RegExp(`^(\\s*${escapeRegExp(key)}\\s*=\\s*)(.*?)(\\s+#.*)?$`);
831
+ const matches = [];
832
+ for (let index = tableStart + 1; index < tableEnd; index++) {
833
+ if (keyPattern.test(lines[index]))
834
+ matches.push(index);
835
+ }
836
+ if (matches.length > 1)
837
+ throw new Error(`duplicate ${table}.${key} assignments`);
838
+ if (matches.length === 0) {
839
+ missing.push([key, value]);
840
+ continue;
841
+ }
842
+ lines[matches[0]] = lines[matches[0]].replace(keyPattern, (_match, prefix, _oldValue, comment) => `${prefix}${tomlValue(value)}${comment ?? ""}`);
843
+ }
844
+ if (missing.length > 0) {
845
+ const marker = `${CODEX_POLICY_MARKER_PREFIX} ${table}`;
846
+ const markerEnd = `${CODEX_POLICY_MARKER_SUFFIX} ${table}`;
847
+ lines.splice(tableEnd, 0, marker, ...missing.map(([key, value]) => `${key} = ${tomlValue(value)}`), markerEnd);
848
+ }
849
+ return `${lines.join(newline)}${hadFinalNewline || lines.length > 0 ? newline : ""}`;
850
+ }
851
+ /** Hard-disable Codex fan-out while retaining a bounded economy fallback profile. */
852
+ export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {}) {
853
+ const userHome = homeDir ?? homedir();
854
+ const configuredCodexHome = env.CODEX_HOME?.trim();
855
+ const codexHome = configuredCodexHome ? resolve(configuredCodexHome) : join(userHome, ".codex");
856
+ const configPath = join(codexHome, "config.toml");
857
+ let writePath = configPath;
858
+ let symlinkPath;
859
+ let originalText;
860
+ try {
861
+ const pathInfo = lstatSync(configPath);
862
+ if (pathInfo.isSymbolicLink()) {
863
+ writePath = realpathSync(configPath);
864
+ symlinkPath = configPath;
865
+ if (!statSync(writePath).isFile())
866
+ throw new Error("target is not a regular file");
867
+ }
868
+ originalText = readFileSync(writePath, "utf8");
869
+ parseToml(originalText);
870
+ }
871
+ catch (error) {
872
+ if (!isErrno(error, "ENOENT")) {
873
+ throw new Error(`Could not inspect Codex agent settings: ${error instanceof Error ? error.message : String(error)}`);
874
+ }
875
+ }
876
+ let nextText = originalText ?? "";
877
+ nextText = applyTomlTablePolicy(nextText, "features", CODEX_LOCAL_FIRST_POLICY.features);
878
+ nextText = applyTomlTablePolicy(nextText, "agents", CODEX_LOCAL_FIRST_POLICY.agents);
879
+ let parsed;
880
+ try {
881
+ parsed = parseToml(nextText);
882
+ }
883
+ catch (error) {
884
+ throw new Error(`Could not build valid Codex local-first policy: ${error instanceof Error ? error.message : String(error)}`);
885
+ }
886
+ const parsedFeatures = isJsonObject(parsed) && isJsonObject(parsed.features)
887
+ ? parsed.features
888
+ : undefined;
889
+ const parsedAgents = isJsonObject(parsed) && isJsonObject(parsed.agents)
890
+ ? parsed.agents
891
+ : undefined;
892
+ if (!parsedFeatures
893
+ || !parsedAgents
894
+ || !isDeepStrictEqual(parsedFeatures.multi_agent, false)
895
+ || !Object.entries(CODEX_LOCAL_FIRST_POLICY.agents)
896
+ .every(([key, value]) => isDeepStrictEqual(parsedAgents[key], value))) {
897
+ throw new Error("Generated Codex local-first policy failed validation");
898
+ }
899
+ if (nextText === originalText)
900
+ return { path: configPath, status: "unchanged" };
901
+ const action = originalText === undefined ? "install" : "refresh";
902
+ if (dryRun) {
903
+ return { path: configPath, status: action === "install" ? "would-install" : "would-refresh" };
904
+ }
905
+ writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
906
+ return { path: configPath, status: action === "install" ? "installed" : "refreshed" };
907
+ }
88
908
  function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
89
909
  const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
90
910
  const cursorDetectionPaths = [join(homeDir, ".cursor")];
@@ -208,10 +1028,15 @@ function executableExists(name, platform, pathEnv) {
208
1028
  return false;
209
1029
  }
210
1030
  function buildMcpEntry(nodePath, serverPath, env) {
1031
+ const storage = env.PRISM_STORAGE ?? "auto";
1032
+ if (!CONNECT_STORAGE_BACKENDS.includes(storage)) {
1033
+ throw new Error(`Invalid PRISM_STORAGE "${storage}". Choose one of: ${CONNECT_STORAGE_BACKENDS.join(", ")}`);
1034
+ }
211
1035
  const serverEnv = {
212
1036
  PRISM_INSTANCE: "prism-mcp",
1037
+ PRISM_AGENT_POLICY: LOCAL_FIRST_POLICY_ID,
213
1038
  PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
214
- PRISM_STORAGE: "auto",
1039
+ PRISM_STORAGE: storage,
215
1040
  };
216
1041
  if (env.PRISM_SYNALUX_API_KEY) {
217
1042
  serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
@@ -277,34 +1102,37 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
277
1102
  : undefined;
278
1103
  if (existingKey) {
279
1104
  const existingEntry = mcpServers[existingKey];
1105
+ const startupCompatible = existingKey === "prism-mcp"
1106
+ && isManagedPrismEntry(existingEntry)
1107
+ && isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
280
1108
  if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
281
- return result(definition, "existing", "Prism is already registered; existing entry left untouched");
1109
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
282
1110
  }
283
1111
  const refreshedEntry = refreshManagedEntry(existingEntry, entry);
284
1112
  if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
285
- return result(definition, "existing", "Prism-managed entry is already current");
1113
+ return result(definition, "existing", "Prism-managed entry is already current", true);
286
1114
  }
287
1115
  if (dryRun) {
288
- return result(definition, "would-refresh");
1116
+ return result(definition, "would-refresh", undefined, true);
289
1117
  }
290
1118
  mcpServers[existingKey] = refreshedEntry;
291
1119
  config.mcpServers = mcpServers;
292
1120
  try {
293
1121
  writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
294
- return result(definition, "refreshed");
1122
+ return result(definition, "refreshed", undefined, true);
295
1123
  }
296
1124
  catch (error) {
297
1125
  return result(definition, "error", error instanceof Error ? error.message : String(error));
298
1126
  }
299
1127
  }
300
1128
  if (dryRun) {
301
- return result(definition, "would-register");
1129
+ return result(definition, "would-register", undefined, true);
302
1130
  }
303
1131
  mcpServers["prism-mcp"] = entry;
304
1132
  config.mcpServers = mcpServers;
305
1133
  try {
306
1134
  writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
307
- return result(definition, "registered");
1135
+ return result(definition, "registered", undefined, true);
308
1136
  }
309
1137
  catch (error) {
310
1138
  return result(definition, "error", error instanceof Error ? error.message : String(error));
@@ -367,18 +1195,22 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
367
1195
  if (hasCanonical || hasLegacy) {
368
1196
  const existingKey = hasCanonical ? "prism-mcp" : "prism";
369
1197
  const existingEntry = mcpServers[existingKey];
1198
+ const startupCompatible = existingKey === "prism-mcp"
1199
+ && managedBlock !== undefined
1200
+ && isManagedPrismEntry(existingEntry)
1201
+ && isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
370
1202
  if (!refresh || existingKey !== "prism-mcp") {
371
- return result(definition, "existing", "Prism is already registered; existing entry left untouched");
1203
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
372
1204
  }
373
1205
  if (!managedBlock) {
374
- return result(definition, "existing", "Prism is already registered; existing entry left untouched");
1206
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched", false);
375
1207
  }
376
1208
  if (!isManagedPrismEntry(existingEntry)) {
377
1209
  return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
378
1210
  }
379
1211
  const refreshedEntry = refreshManagedEntry(existingEntry, entry);
380
1212
  if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
381
- return result(definition, "existing", "Prism-managed entry is already current");
1213
+ return result(definition, "existing", "Prism-managed entry is already current", true);
382
1214
  }
383
1215
  let nextText;
384
1216
  try {
@@ -389,11 +1221,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
389
1221
  return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
390
1222
  }
391
1223
  if (dryRun) {
392
- return result(definition, "would-refresh");
1224
+ return result(definition, "would-refresh", undefined, true);
393
1225
  }
394
1226
  try {
395
1227
  writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
396
- return result(definition, "refreshed");
1228
+ return result(definition, "refreshed", undefined, true);
397
1229
  }
398
1230
  catch (error) {
399
1231
  return result(definition, "error", error instanceof Error ? error.message : String(error));
@@ -418,11 +1250,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
418
1250
  return result(definition, "error", `could not safely extend Codex config without rewriting existing TOML: ${error instanceof Error ? error.message : String(error)}. Convert an inline mcp_servers value to standard TOML tables, then retry`);
419
1251
  }
420
1252
  if (dryRun) {
421
- return result(definition, "would-register");
1253
+ return result(definition, "would-register", undefined, true);
422
1254
  }
423
1255
  try {
424
1256
  writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
425
- return result(definition, "registered");
1257
+ return result(definition, "registered", undefined, true);
426
1258
  }
427
1259
  catch (error) {
428
1260
  return result(definition, "error", error instanceof Error ? error.message : String(error));
@@ -521,18 +1353,24 @@ function writeTextAtomically(filePath, contents, expectedText, beforeCommit, sym
521
1353
  unlinkSync(tempPath);
522
1354
  }
523
1355
  }
524
- function result(definition, status, message) {
1356
+ function result(definition, status, message, startupCompatible = false) {
525
1357
  return {
526
1358
  host: definition.name,
527
1359
  label: definition.label,
528
1360
  path: definition.configPath,
529
1361
  status,
1362
+ startupCompatible,
530
1363
  message,
531
1364
  };
532
1365
  }
533
1366
  function isJsonObject(value) {
534
1367
  return typeof value === "object" && value !== null && !Array.isArray(value);
535
1368
  }
1369
+ function isPathWithin(candidate, parent) {
1370
+ const relativePath = relative(parent, candidate);
1371
+ return relativePath === ""
1372
+ || (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
1373
+ }
536
1374
  function isManagedPrismEntry(value) {
537
1375
  return isJsonObject(value)
538
1376
  && isJsonObject(value.env)