prism-mcp-server 20.2.1 → 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,7 +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
+ ];
28
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";
29
72
  export function normalizeHostName(value) {
30
73
  const normalized = value.trim().toLowerCase();
31
74
  const host = HOST_ALIASES[normalized];
@@ -52,6 +95,19 @@ export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
52
95
  }
53
96
  return realpathSync(serverPath);
54
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
+ }
55
111
  export function connectHosts(options = {}) {
56
112
  if (options.all && options.hosts?.length) {
57
113
  throw new Error("Use either --all or --host, not both.");
@@ -80,12 +136,775 @@ export function connectHosts(options = {}) {
80
136
  }
81
137
  const entry = buildMcpEntry(nodePath, serverPath, env);
82
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
+ }
83
155
  return {
84
156
  results,
85
157
  usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
86
158
  serverPath,
159
+ installationReceiptPath,
87
160
  };
88
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
+ }
89
908
  function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
90
909
  const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
91
910
  const cursorDetectionPaths = [join(homeDir, ".cursor")];
@@ -215,6 +1034,7 @@ function buildMcpEntry(nodePath, serverPath, env) {
215
1034
  }
216
1035
  const serverEnv = {
217
1036
  PRISM_INSTANCE: "prism-mcp",
1037
+ PRISM_AGENT_POLICY: LOCAL_FIRST_POLICY_ID,
218
1038
  PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
219
1039
  PRISM_STORAGE: storage,
220
1040
  };
@@ -282,34 +1102,37 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
282
1102
  : undefined;
283
1103
  if (existingKey) {
284
1104
  const existingEntry = mcpServers[existingKey];
1105
+ const startupCompatible = existingKey === "prism-mcp"
1106
+ && isManagedPrismEntry(existingEntry)
1107
+ && isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
285
1108
  if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
286
- 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);
287
1110
  }
288
1111
  const refreshedEntry = refreshManagedEntry(existingEntry, entry);
289
1112
  if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
290
- return result(definition, "existing", "Prism-managed entry is already current");
1113
+ return result(definition, "existing", "Prism-managed entry is already current", true);
291
1114
  }
292
1115
  if (dryRun) {
293
- return result(definition, "would-refresh");
1116
+ return result(definition, "would-refresh", undefined, true);
294
1117
  }
295
1118
  mcpServers[existingKey] = refreshedEntry;
296
1119
  config.mcpServers = mcpServers;
297
1120
  try {
298
1121
  writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
299
- return result(definition, "refreshed");
1122
+ return result(definition, "refreshed", undefined, true);
300
1123
  }
301
1124
  catch (error) {
302
1125
  return result(definition, "error", error instanceof Error ? error.message : String(error));
303
1126
  }
304
1127
  }
305
1128
  if (dryRun) {
306
- return result(definition, "would-register");
1129
+ return result(definition, "would-register", undefined, true);
307
1130
  }
308
1131
  mcpServers["prism-mcp"] = entry;
309
1132
  config.mcpServers = mcpServers;
310
1133
  try {
311
1134
  writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
312
- return result(definition, "registered");
1135
+ return result(definition, "registered", undefined, true);
313
1136
  }
314
1137
  catch (error) {
315
1138
  return result(definition, "error", error instanceof Error ? error.message : String(error));
@@ -372,18 +1195,22 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
372
1195
  if (hasCanonical || hasLegacy) {
373
1196
  const existingKey = hasCanonical ? "prism-mcp" : "prism";
374
1197
  const existingEntry = mcpServers[existingKey];
1198
+ const startupCompatible = existingKey === "prism-mcp"
1199
+ && managedBlock !== undefined
1200
+ && isManagedPrismEntry(existingEntry)
1201
+ && isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
375
1202
  if (!refresh || existingKey !== "prism-mcp") {
376
- 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);
377
1204
  }
378
1205
  if (!managedBlock) {
379
- 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);
380
1207
  }
381
1208
  if (!isManagedPrismEntry(existingEntry)) {
382
1209
  return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
383
1210
  }
384
1211
  const refreshedEntry = refreshManagedEntry(existingEntry, entry);
385
1212
  if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
386
- return result(definition, "existing", "Prism-managed entry is already current");
1213
+ return result(definition, "existing", "Prism-managed entry is already current", true);
387
1214
  }
388
1215
  let nextText;
389
1216
  try {
@@ -394,11 +1221,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
394
1221
  return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
395
1222
  }
396
1223
  if (dryRun) {
397
- return result(definition, "would-refresh");
1224
+ return result(definition, "would-refresh", undefined, true);
398
1225
  }
399
1226
  try {
400
1227
  writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
401
- return result(definition, "refreshed");
1228
+ return result(definition, "refreshed", undefined, true);
402
1229
  }
403
1230
  catch (error) {
404
1231
  return result(definition, "error", error instanceof Error ? error.message : String(error));
@@ -423,11 +1250,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
423
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`);
424
1251
  }
425
1252
  if (dryRun) {
426
- return result(definition, "would-register");
1253
+ return result(definition, "would-register", undefined, true);
427
1254
  }
428
1255
  try {
429
1256
  writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
430
- return result(definition, "registered");
1257
+ return result(definition, "registered", undefined, true);
431
1258
  }
432
1259
  catch (error) {
433
1260
  return result(definition, "error", error instanceof Error ? error.message : String(error));
@@ -526,18 +1353,24 @@ function writeTextAtomically(filePath, contents, expectedText, beforeCommit, sym
526
1353
  unlinkSync(tempPath);
527
1354
  }
528
1355
  }
529
- function result(definition, status, message) {
1356
+ function result(definition, status, message, startupCompatible = false) {
530
1357
  return {
531
1358
  host: definition.name,
532
1359
  label: definition.label,
533
1360
  path: definition.configPath,
534
1361
  status,
1362
+ startupCompatible,
535
1363
  message,
536
1364
  };
537
1365
  }
538
1366
  function isJsonObject(value) {
539
1367
  return typeof value === "object" && value !== null && !Array.isArray(value);
540
1368
  }
1369
+ function isPathWithin(candidate, parent) {
1370
+ const relativePath = relative(parent, candidate);
1371
+ return relativePath === ""
1372
+ || (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
1373
+ }
541
1374
  function isManagedPrismEntry(value) {
542
1375
  return isJsonObject(value)
543
1376
  && isJsonObject(value.env)