prism-mcp-server 20.2.5 โ†’ 20.2.7

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/README.md CHANGED
@@ -57,6 +57,38 @@ features.
57
57
  <details>
58
58
  <summary>Release history (optional)</summary>
59
59
 
60
+ ## What's New in v20.2.7
61
+
62
+ ### Session Saves Survive Agent Restarts
63
+ Prism now remembers that a conversation successfully loaded its project context
64
+ when the MCP server restarts or another Prism process handles the next request.
65
+ `session_save_ledger` and `session_save_handoff` no longer fail with a false
66
+ `context_not_loaded` error in that flow.
67
+
68
+ The recovery remains fail-closed: authorization is limited to the exact project
69
+ and conversation, expires with the existing context window, and stores no
70
+ plaintext conversation identifier. Cross-project, forged, malformed, expired,
71
+ or future-dated receipts are still rejected. The release also updates PostCSS
72
+ to the patched 8.5.23 release.
73
+
74
+ ---
75
+
76
+ ## What's New in v20.2.6
77
+
78
+ ### Safer Configuration Updates Across Every Agent
79
+ `prism connect` now reads Claude, Cursor, Gemini, and Codex configuration
80
+ through a single verified file snapshot, preventing another process from
81
+ swapping a file between Prism's safety check and its read. Supported symlinked
82
+ dotfiles still work, while dangling or planted symlinks fail loudly instead of
83
+ being followed or overwritten. This release also carries the patched
84
+ dependencies and cross-platform release checks introduced in v20.2.5.
85
+
86
+ Cloud fallback is now documented consistently as Gemini 3.6 Flash. Plan
87
+ ceilings govern automatic `prism_infer` routing; direct use of any downloaded
88
+ model through local Ollama remains free on every tier.
89
+
90
+ ---
91
+
60
92
  ## What's New in v20.2.4
61
93
 
62
94
  ### Reliable Session Memory That Shows Work, Not Greetings
@@ -354,7 +386,7 @@ Every `prism_infer` call tracks which model handled it (local Ollama vs cloud) a
354
386
  synalux-27b: 2 calls, 1,500 tokens, avg 1,100ms
355
387
  ```
356
388
 
357
- **Cloud tokens saved** is the honest routing metric โ€” it accrues only when local Ollama handles a call that would otherwise have gone to Claude or the Synalux portal. A compact version appears inline after every 5th `prism_infer` call: `๐Ÿ“Š local 10 (83%) ยท cloud 2 (17%) ยท ~11,570 tok ยท avg 1,240ms ยท 11,570 cloud tok saved`.
389
+ **Cloud tokens saved** is the honest routing metric โ€” it accrues only when local Ollama handles a call that would otherwise have gone to Synalux cloud inference. A compact version appears inline after every 5th `prism_infer` call: `๐Ÿ“Š local 10 (83%) ยท cloud 2 (17%) ยท ~11,570 tok ยท avg 1,240ms ยท 11,570 cloud tok saved`.
358
390
 
359
391
  Local calls use actual Ollama token counts (`prompt_eval_count` / `eval_count` from Ollama); cloud calls use char/4 estimates. Metrics are tracked locally โ€” no portal dependency, no env vars, works offline. Per-call data is also forwarded to the Synalux portal as best-effort analytics (independent of the display).
360
392
 
@@ -413,7 +445,7 @@ The free tier runs entirely on your machine. Paid tiers add cloud sync through t
413
445
  | | Local tier (free) | Cloud tier (paid) |
414
446
  |---|---|---|
415
447
  | Memory storage | Local SQLite | Synalux portal (Supabase-backed) |
416
- | Inference | Local Ollama models | Local models + cloud fallback |
448
+ | Inference | Local Ollama models | Local models + Gemini 3.6 Flash fallback |
417
449
  | API keys required | None | Synalux subscription key |
418
450
  | Web search / scrape | Not included | Via Synalux portal (provider keys server-side) |
419
451
  | What leaves your machine | Nothing | Memory text + file paths + search queries, sent to the portal over TLS (PHI-redacted before transit) |
@@ -427,15 +459,19 @@ The free tier runs entirely on your machine. Paid tiers add cloud sync through t
427
459
 
428
460
  The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference. The 9B and 27B are fine-tuned with LoRA (r=128, all 64 layers including DeltaNet); the 2B and 4B use stock Qwen3.5-4B at different quantization levels. The 27B scored 100% on BFCL function-calling and 100% on an internal 15-problem coding eval at $0 inference cost.
429
461
 
430
- `prism_infer` supports three modes: `route` (tool routing, fast, nothink), `chat` (conversation with thinking), and `code` (code generation with thinking). In chat/code modes, the model uses `<think>` blocks for chain-of-thought reasoning, which are stripped before the response is served. If the local model fails a quality gate (empty, think-only, or truncated), paid tiers automatically escalate to Claude via the Synalux portal.
462
+ `prism_infer` supports three modes: `route` (tool routing, fast, nothink), `chat` (conversation with thinking), and `code` (code generation with thinking). In chat/code modes, the model uses `<think>` blocks for chain-of-thought reasoning, which are stripped before the response is served. If the local model fails a quality gate (empty, think-only, or truncated), paid tiers automatically escalate to Gemini 3.6 Flash via the Synalux portal.
431
463
 
432
- | Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/blogs/12_bfcl_v3_multi_turn.html) Accuracy | Role | Tier |
464
+ | Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/blogs/12_bfcl_v3_multi_turn.html) Accuracy | Role | Automatic routing tier |
433
465
  |---|---|---|---|---|---|
434
466
  | Qwen3.5-4B Q3_K_M | `prism-coder:2b` | 2.3 GB | 99.1% ร— 3 seeds | iPhone / mobile first gate | Free |
435
467
  | Qwen3.5-4B Q4_K_M | `prism-coder:4b` | 3.4 GB | 100% ร— 3 seeds | Verifier | Free |
436
468
  | Qwen3.5-9B (LoRA) | `prism-coder:9b` | 5.8 GB | 100% ร— 3 seeds | Default router | Standard+ |
437
469
  | Qwen3.5-27B (LoRA) | `prism-coder:27b` | 16 GB | 100% ร— 3 seeds | Quality tier (DeltaNet, 28.5 tok/s) | Advanced+ |
438
470
 
471
+ These tiers control automatic `prism_infer` selection, not Ollama itself. Any
472
+ user can run any downloaded on-device model directly through Ollama on every
473
+ plan.
474
+
439
475
  Weights: [huggingface.co/dcostenco](https://huggingface.co/dcostenco) (public GGUF). Latency depends on model size and hardware โ€” see [Benchmarks](#benchmarks) to measure it on your own machine rather than trusting a printed number.
440
476
 
441
477
  ### Cascade
@@ -445,7 +481,7 @@ query โ†’ prism-coder:9b (local router, default)
445
481
  โ†’ prism-coder:4b (grounding verifier)
446
482
  โ†’ prism-coder:2b (iPhone / mobile, auto-selected by RAM)
447
483
  โ†’ prism-coder:27b (complex tasks, on demand)
448
- โ†’ cloud fallback (paid tiers, for max quality)
484
+ โ†’ Gemini 3.6 Flash cloud fallback (paid tiers, for max quality)
449
485
  ```
450
486
 
451
487
  ### Multi-Layer Verification
@@ -466,15 +502,14 @@ Fail-closed on the verified path: when the grounding verifier runs (Standard tie
466
502
 
467
503
  ## Benchmarks
468
504
 
469
- **Reproduce every number yourself.** All evals are open-source and self-contained:
470
-
471
- ```bash
472
- git clone https://github.com/dcostenco/prism-coder && cd prism-coder
473
- pip install anthropic requests
474
- python3 tests/benchmarks/prism-routing-100/benchmark.py --models 2b 4b 9b 27b
475
- ```
505
+ Published benchmark numbers are concise summaries of internal deterministic
506
+ evaluation. Evaluators, exhaustive cases, exact tier-routing matrices, and raw
507
+ model outputs stay in the private engineering repository and are not included
508
+ in the npm package or public source tree.
476
509
 
477
- **Routing eval (115 cases, 12 categories, 3-seed mean).** Routing accuracy includes the deterministic L3 correction layer โ€” the same rules that run in production. On this narrow tool-routing task all fleet models achieve near-perfect accuracy. Be honest with yourself about what that means: the eval is **near-saturated** for this taxonomy โ€” it measures whether the right one of a small set of MCP tools is selected, not general capability. The useful takeaway is **offline routing reliability at zero cost**, not that a 2.3 GB model rivals a frontier model in general.
510
+ **Routing evaluation.** On a narrow tool-selection suite, the fleet achieved
511
+ near-saturated results across three seeds. This measures offline MCP routing
512
+ reliability, not general model capability.
478
513
 
479
514
  | Model | Routing accuracy | Notes |
480
515
  |---|---|---|
@@ -484,90 +519,17 @@ python3 tests/benchmarks/prism-routing-100/benchmark.py --models 2b 4b 9b 27b
484
519
 
485
520
  **Memory uplift (LoCoMo-Plus, self-published).** A separate long-context dialogue benchmark ([dcostenco/Locomo-Plus](https://github.com/dcostenco/Locomo-Plus)) measures how much structured memory helps a base model retain multi-day context. Results show large gains when a model is paired with Prism memory versus running raw. Note this benchmark is authored, run, and LLM-judged by this project โ€” treat it as a reproducible demonstration, not an independent third-party result, and run it yourself with the commands in that repo.
486
521
 
487
- ### Code Generation Quality (27B vs Claude Opus)
522
+ **Code generation evaluation.** In a small July 2026 deterministic execution
523
+ check, the local 9B passed 2/3 tasks; the local 27B and Gemini 3.6 Flash each
524
+ passed 3/3. This is a self-published regression signal, not an independent
525
+ leaderboard or a claim of broad model equivalence.
488
526
 
489
- Three progressively harder Python tasks run through `prism_infer(mode:"code", think:true)` on the local 27B and compared with Claude Opus. Both produce correct, production-quality code. The 27B is slightly more verbose (docstrings, examples); Opus is slightly tighter (`__slots__`, early-exit DFS). On routine coding the 27B at $0 replaces cloud calls entirely.
527
+ ### Cloud Escalation (`cloud_fallback: true`)
490
528
 
491
- | Task | Local 27B | Claude Opus | Verdict |
492
- |------|-----------|-------------|---------|
493
- | Fibonacci with memoization | `@lru_cache`, ValueError on negative, docstring | Nested `_fib` to keep cache private | Both correct, equivalent |
494
- | LRU Cache (OrderedDict, O(1)) | `Any` keys, isinstance capacity check, `__repr__` | `Hashable` key type (more precise), same ops | Both correct, Opus marginally tighter |
495
- | Trie with autocomplete | `.lower()` normalization, collect+sort+slice | `__slots__` on TrieNode, early-exit DFS at limit | Both correct, Opus slightly more optimized |
496
-
497
- <details>
498
- <summary>Local 27B output โ€” Trie with autocomplete (hardest task)</summary>
499
-
500
- ```python
501
- class TrieNode:
502
- def __init__(self):
503
- self.children: dict[str, 'TrieNode'] = {}
504
- self.is_end_of_word: bool = False
505
-
506
- class Trie:
507
- def __init__(self):
508
- self.root: TrieNode = TrieNode()
509
-
510
- def insert(self, word: str) -> None:
511
- node = self.root
512
- for char in word.lower():
513
- if char not in node.children:
514
- node.children[char] = TrieNode()
515
- node = node.children[char]
516
- node.is_end_of_word = True
517
-
518
- def search(self, word: str) -> bool:
519
- node = self._get_node(word.lower())
520
- return node is not None and node.is_end_of_word
521
-
522
- def starts_with(self, prefix: str) -> bool:
523
- return self._get_node(prefix.lower()) is not None
524
-
525
- def autocomplete(self, prefix: str, limit: int = 5) -> list[str]:
526
- node = self._get_node(prefix.lower())
527
- if node is None:
528
- return []
529
- results: list[str] = []
530
- self._collect_words(node, prefix.lower(), results)
531
- results.sort()
532
- return results[:limit]
533
-
534
- def _get_node(self, key: str) -> 'TrieNode | None':
535
- node = self.root
536
- for char in key:
537
- if char not in node.children:
538
- return None
539
- node = node.children[char]
540
- return node
541
-
542
- def _collect_words(self, node: TrieNode, prefix: str, results: list[str]) -> None:
543
- if node.is_end_of_word:
544
- results.append(prefix)
545
- for char, child in sorted(node.children.items()):
546
- self._collect_words(child, prefix + char, results)
547
- ```
548
-
549
- </details>
550
-
551
- | Metric | Local 27B | Cloud (Opus) |
552
- |--------|-----------|-------------|
553
- | Latency (Trie task) | ~30s | ~8s |
554
- | Cost | $0 | ~$0.05 |
555
- | Think mode | Enabled (stripped before serving) | N/A |
556
- | Quality gate | Passed (no escalation needed) | N/A |
557
-
558
- ### Cloud Escalation in Practice (`cloud_fallback: true`)
559
-
560
- The same three tasks with `cloud_fallback: true` โ€” the quality gate decides whether local output is good enough or needs cloud escalation.
561
-
562
- | Task | used_cloud | Quality Gate | Latency | What happened |
563
- |------|:----------:|-------------|---------|---------------|
564
- | Fibonacci (simple) | **no** | Passed | 11s | 27B served directly, $0 |
565
- | LRU Cache (medium) | **no** | Passed | 21s | 27B served directly, $0 |
566
- | Trie (hard) | **yes** | `loop_detected` | 55s | 27B looped โ†’ gate caught it โ†’ escalated to cloud 27B |
567
-
568
- The quality gate detected repeated sentences (โ‰ฅ3 of the same sentence in โ‰ฅ6 total) in the 27B's Trie output and escalated automatically. The cloud fallback returned clean code. On a second run of the same prompt, the 27B produced clean output without escalation โ€” the loop is stochastic, not systematic.
569
-
570
- **Takeaway:** for ~80โ€“90% of coding tasks, the 27B handles everything locally at $0. The quality gate + cloud escalation exists as a safety net for the remaining cases where the local model loops, truncates, or produces empty output. Paid tiers get automatic escalation; free tier gets the local result with a warning.
529
+ Prism always tries an eligible local model first. If the quality gate detects
530
+ an empty, truncated, think-only, or looping response, paid tiers can retry the
531
+ request through Gemini 3.6 Flash. Free-tier routing stays local and reports the
532
+ quality-gate outcome without making a cloud call.
571
533
 
572
534
  ---
573
535
 
@@ -618,17 +580,17 @@ and [Amazon Q Developer](https://aws.amazon.com/q/developer/pricing/).
618
580
 
619
581
  ## Plans
620
582
 
621
- All on-device models are free to run locally via Ollama on every tier. A subscription gates **cloud** features, higher model ceilings, and increased limits. Local model ceilings are advisory โ€” on-device models run on your Ollama regardless of plan; the ceiling gates cloud inference and `prism_infer` routing.
583
+ All on-device models are free to run locally via Ollama on every tier. A subscription gates **cloud** features, higher automatic-routing ceilings, and increased limits. On-device models run through your Ollama regardless of plan; the ceiling applies only to cloud inference and automatic `prism_infer` routing.
622
584
 
623
585
  | | **Free** | **Standard** $19/mo | **Advanced** $49/mo | **Enterprise** $99/mo |
624
586
  |---|---|---|---|---|
625
587
  | Seats | 1 | 1 | up to 5 | up to 25 |
626
- | Local model ceiling | up to 4b | up to 9b | up to 27b | up to 27b |
588
+ | Automatic `prism_infer` ceiling | up to 4b | up to 9b | up to 27b | up to 27b |
627
589
  | Cloud inference | -- | โœ… | โœ… | โœ… (priority) |
628
590
  | Cloud Coder (Web IDE) | -- | โœ… | โœ… | โœ… (priority) |
629
591
  | Cloud search | -- | โœ… | โœ… | โœ… |
630
592
  | Max output tokens | 512 | 1,024 | 2,048 | 4,096 |
631
- | Cloud fallback | -- | Claude Opus 4.7 | Claude Opus 4.7 | Priority + Opus 4.7 |
593
+ | Cloud fallback | -- | Gemini 3.6 Flash | Gemini 3.6 Flash | Gemini 3.6 Flash (priority) |
632
594
  | Grounding verifier (fact-check AI output) | -- | โœ… | โœ… | โœ… |
633
595
  | Memory sync (cloud) | -- | โœ… | โœ… | โœ… |
634
596
  | Knowledge / session memory | limited | unlimited | unlimited | unlimited |
@@ -650,13 +612,25 @@ Prism exposes 40+ MCP tools. The core memory loop:
650
612
  | `session_save_ledger` | Append an immutable session log entry |
651
613
  | `session_save_handoff` | Save live state for the next session |
652
614
  | `knowledge_search` | Semantic + keyword search over all memories |
653
- | `query_memory_natural` | Natural-language Q&A over the memory store |
615
+ | `query_memory_natural` | Memory-first Q&A with a grounded live-source fallback on paid tiers |
654
616
  | `session_detect_drift` | Detect when a session has drifted from its goal |
655
617
  | `verify_behavior` | Pre-edit scenario challenge โ€” catch bad changes before they happen |
656
618
  | `knowledge_ingest` | Teach Prism a codebase or document |
657
619
  | `prism_infer` | Local-first inference (route/chat/code modes, thinking, cloud escalation) |
658
620
  | `inference_metrics` | Session delegation or persisted MCP + VS Code panel local/cloud stats |
659
621
 
622
+ ### `query_memory_natural` โ€” memory first, current sources when needed
623
+
624
+ Ask one natural-language question instead of choosing separate memory, search,
625
+ scrape, and inference tools. Prism searches its accumulated project memory
626
+ first. If no useful evidence exists, paid tiers run one bounded Synalux search
627
+ (Firecrawl, Gemini 3.6 Google Search grounding, then legacy Brave fallback),
628
+ resolve and preserve the source URLs, scrape the leading page, and ask a
629
+ RAM-safe local Prism Coder model to answer from that evidence. The paid-tier
630
+ Gemini 3.6 verifier checks the draft before it is served. Reserved or uncertain
631
+ clinical content never enters the web-grounded local path; it follows Prism's
632
+ cloud-or-refuse safety boundary.
633
+
660
634
  ### `prism_infer` โ€” local-first inference with cloud escalation
661
635
 
662
636
  ```typescript
@@ -667,7 +641,7 @@ prism_infer({
667
641
  model_ceiling: "27b", // use the quality tier
668
642
  })
669
643
  // โ†’ 27B generates code locally ($0), with thinking for quality
670
- // โ†’ If quality gate fails + paid tier โ†’ auto-escalate to Claude
644
+ // โ†’ If quality gate fails + paid tier โ†’ auto-escalate to Gemini 3.6 Flash
671
645
  ```
672
646
 
673
647
  | Mode | Think | Model | Use case |
@@ -694,7 +668,7 @@ Call `inference_metrics` anytime mid-session to see how many `prism_infer` calls
694
668
 
695
669
  The same block also appears automatically in `session_save_ledger` and `session_save_handoff` responses at session end.
696
670
 
697
- **Note:** The default session view tracks this MCP process's `prism_infer` delegation. The all-time view combines persisted MCP calls with Synalux VS Code panel inference. Neither view includes your host model's (Claude's) own token spend; use Claude Code's `/cost` command for that.
671
+ **Note:** The default session view tracks this MCP process's `prism_infer` delegation. The all-time view combines persisted MCP calls with Synalux VS Code panel inference. Neither view includes the host agent's own token spend; use that host's native usage reporting when available.
698
672
 
699
673
  ### Local-model delegation (default)
700
674
 
@@ -883,7 +857,11 @@ ollama pull dcostenco/prism-coder:9b # default router
883
857
  export LOCAL_LLM_URL=http://localhost:11434
884
858
  ```
885
859
 
886
- Routing is automatic: `9b โ†’ 4b โ†’ cloud fallback` on desktop/server, `2b โ†’ cloud fallback` on mobile/iPhone. For iOS or another machine on the same network, run `OLLAMA_HOST=0.0.0.0 ollama serve` and point `LOCAL_LLM_URL` at the host's IP.
860
+ Self-hosted routing stays local: `9b โ†’ 4b` on desktop/server and `2b` on
861
+ mobile/iPhone, with 27B available when installed and RAM-safe. Synalux-hosted
862
+ paid tiers can use Gemini 3.6 Flash as the cloud fallback. For iOS or another
863
+ machine on the same network, run `OLLAMA_HOST=0.0.0.0 ollama serve` and point
864
+ `LOCAL_LLM_URL` at the host's IP.
887
865
 
888
866
  ---
889
867
 
@@ -934,7 +912,7 @@ It reads `~/.prism-mcp/data.db` and POSTs entries to the portal. Ledger entries
934
912
 
935
913
  | Feature | Details |
936
914
  |---------|---------|
937
- | Local inference | Ollama via `prism_infer`, capped at the 4B model tier |
915
+ | Local inference | Direct Ollama use is unrestricted; automatic `prism_infer` routing selects up to 4B |
938
916
  | Session memory | Persistent sessions, handoffs, ledger โ€” all local SQLite |
939
917
  | Knowledge search | Semantic search across session history |
940
918
  | Skills | All skills available locally (run `sync-skills.sh` to populate) |
@@ -946,7 +924,7 @@ Everything in Free, plus:
946
924
 
947
925
  | Feature | Details |
948
926
  |---------|---------|
949
- | Model ceiling | Up to 27B locally + cloud cascade (9B โ†’ 27B โ†’ Claude) when local is unavailable |
927
+ | Model ceiling | Automatic `prism_infer` routing up to 27B + Gemini 3.6 Flash fallback when local is unavailable |
950
928
  | Skill routing | Portal resolves which skills to load based on your project and prompt |
951
929
  | Cross-device memory | Supabase cloud sync โ€” sessions survive across machines |
952
930
  | Grounding verifier | L3 NLI verification on model outputs |
package/dist/connect.js CHANGED
@@ -1,4 +1,4 @@
1
- import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { accessSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as win32Path } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -69,6 +69,72 @@ const CODEX_LOCAL_FIRST_POLICY = {
69
69
  };
70
70
  const CODEX_POLICY_MARKER_PREFIX = "# >>> prism connect managed: local-first";
71
71
  const CODEX_POLICY_MARKER_SUFFIX = "# <<< prism connect managed: local-first";
72
+ /**
73
+ * Read a host-owned text file through one descriptor, then verify that the
74
+ * pathname still identifies that same regular file. This prevents a path from
75
+ * being swapped between a metadata check and the read while retaining Prism's
76
+ * documented support for symlinked dotfiles.
77
+ */
78
+ function readRegularTextSnapshot(filePath, allowSymlink = true) {
79
+ const nonBlocking = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
80
+ const noFollow = !allowSymlink && typeof constants.O_NOFOLLOW === "number"
81
+ ? constants.O_NOFOLLOW
82
+ : 0;
83
+ let descriptor;
84
+ try {
85
+ descriptor = openSync(filePath, constants.O_RDONLY | nonBlocking | noFollow);
86
+ }
87
+ catch (error) {
88
+ // A dangling symlink is an existing, unsafe entry rather than an absent
89
+ // config. Detect it only after the descriptor open failed, so this probe
90
+ // cannot become a check-then-read race.
91
+ if (isErrno(error, "ENOENT")) {
92
+ let pathInfo;
93
+ try {
94
+ pathInfo = lstatSync(filePath);
95
+ }
96
+ catch (probeError) {
97
+ if (!isErrno(probeError, "ENOENT"))
98
+ throw probeError;
99
+ }
100
+ if (pathInfo?.isSymbolicLink()) {
101
+ throw new Error(`symlink target is unavailable: ${filePath}`);
102
+ }
103
+ }
104
+ throw error;
105
+ }
106
+ try {
107
+ const openedInfo = fstatSync(descriptor);
108
+ if (!openedInfo.isFile())
109
+ throw new Error("target is not a regular file");
110
+ const pathInfo = lstatSync(filePath);
111
+ let writePath = filePath;
112
+ let symlinkPath;
113
+ if (pathInfo.isSymbolicLink()) {
114
+ if (!allowSymlink)
115
+ throw new Error("symlink is not allowed");
116
+ writePath = realpathSync(filePath);
117
+ symlinkPath = filePath;
118
+ }
119
+ else if (!pathInfo.isFile()) {
120
+ throw new Error("target is not a regular file");
121
+ }
122
+ const currentInfo = statSync(writePath);
123
+ if (!currentInfo.isFile()
124
+ || currentInfo.dev !== openedInfo.dev
125
+ || currentInfo.ino !== openedInfo.ino) {
126
+ throw new Error("file changed while Prism was opening it; retry");
127
+ }
128
+ return {
129
+ text: readFileSync(descriptor, "utf8"),
130
+ writePath,
131
+ symlinkPath,
132
+ };
133
+ }
134
+ finally {
135
+ closeSync(descriptor);
136
+ }
137
+ }
72
138
  export function normalizeHostName(value) {
73
139
  const normalized = value.trim().toLowerCase();
74
140
  const host = HOST_ALIASES[normalized];
@@ -188,11 +254,7 @@ export function writeInstallationReceipt(homeDir, receipt, beforeCommit) {
188
254
  }
189
255
  let originalText;
190
256
  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");
257
+ originalText = readRegularTextSnapshot(receiptPath, false).text;
196
258
  const current = JSON.parse(originalText);
197
259
  if (!isJsonObject(current)
198
260
  || current.schema_version !== INSTALLATION_RECEIPT_SCHEMA_VERSION
@@ -251,14 +313,10 @@ export function migrateLegacyClaudeProjectMcp(homeDir = homedir(), cwd = process
251
313
  let symlinkPath;
252
314
  let originalText;
253
315
  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");
316
+ const snapshot = readRegularTextSnapshot(configPath);
317
+ writePath = snapshot.writePath;
318
+ symlinkPath = snapshot.symlinkPath;
319
+ originalText = snapshot.text;
262
320
  }
263
321
  catch (error) {
264
322
  throw new Error(`Could not inspect Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
@@ -321,14 +379,10 @@ export function migrateLegacyClaudeHooks(homeDir = homedir(), dryRun = false, be
321
379
  let symlinkPath;
322
380
  let originalText;
323
381
  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");
382
+ const snapshot = readRegularTextSnapshot(configPath);
383
+ writePath = snapshot.writePath;
384
+ symlinkPath = snapshot.symlinkPath;
385
+ originalText = snapshot.text;
332
386
  }
333
387
  catch (error) {
334
388
  if (isErrno(error, "ENOENT"))
@@ -403,15 +457,11 @@ export function migrateLegacyClaudeInstructions(homeDir = homedir(), dryRun = fa
403
457
  let originalText;
404
458
  let instructionEntryExists = false;
405
459
  try {
406
- const pathInfo = lstatSync(instructionPath);
460
+ const snapshot = readRegularTextSnapshot(instructionPath);
407
461
  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");
462
+ writePath = snapshot.writePath;
463
+ symlinkPath = snapshot.symlinkPath;
464
+ originalText = snapshot.text;
415
465
  }
416
466
  catch (error) {
417
467
  if (!instructionEntryExists && isErrno(error, "ENOENT")) {
@@ -447,15 +497,11 @@ export function migrateLegacyClaudeManagedStartup(homeDir = homedir(), dryRun =
447
497
  let originalText;
448
498
  let instructionEntryExists = false;
449
499
  try {
450
- const pathInfo = lstatSync(instructionPath);
500
+ const snapshot = readRegularTextSnapshot(instructionPath);
451
501
  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");
502
+ writePath = snapshot.writePath;
503
+ symlinkPath = snapshot.symlinkPath;
504
+ originalText = snapshot.text;
459
505
  }
460
506
  catch (error) {
461
507
  if (!instructionEntryExists && isErrno(error, "ENOENT")) {
@@ -508,15 +554,11 @@ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false
508
554
  let originalText;
509
555
  let instructionEntryExists = false;
510
556
  try {
511
- const pathInfo = lstatSync(instructionPath);
557
+ const snapshot = readRegularTextSnapshot(instructionPath);
512
558
  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");
559
+ writePath = snapshot.writePath;
560
+ symlinkPath = snapshot.symlinkPath;
561
+ originalText = snapshot.text;
520
562
  }
521
563
  catch (error) {
522
564
  if (instructionEntryExists || !isErrno(error, "ENOENT")) {
@@ -604,15 +646,11 @@ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false
604
646
  let originalText;
605
647
  let instructionEntryExists = false;
606
648
  try {
607
- const pathInfo = lstatSync(instructionPath);
649
+ const snapshot = readRegularTextSnapshot(instructionPath);
608
650
  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");
651
+ writePath = snapshot.writePath;
652
+ symlinkPath = snapshot.symlinkPath;
653
+ originalText = snapshot.text;
616
654
  }
617
655
  catch (error) {
618
656
  if (instructionEntryExists || !isErrno(error, "ENOENT")) {
@@ -680,15 +718,11 @@ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommi
680
718
  let originalText;
681
719
  let instructionEntryExists = false;
682
720
  try {
683
- const pathInfo = lstatSync(instructionPath);
721
+ const snapshot = readRegularTextSnapshot(instructionPath);
684
722
  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");
723
+ writePath = snapshot.writePath;
724
+ symlinkPath = snapshot.symlinkPath;
725
+ originalText = snapshot.text;
692
726
  }
693
727
  catch (error) {
694
728
  if (instructionEntryExists || !isErrno(error, "ENOENT")) {
@@ -737,14 +771,10 @@ function configureJsonAgentPolicy(configPath, label, mutate, dryRun, beforeCommi
737
771
  let originalText;
738
772
  let config = {};
739
773
  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");
774
+ const snapshot = readRegularTextSnapshot(configPath);
775
+ writePath = snapshot.writePath;
776
+ symlinkPath = snapshot.symlinkPath;
777
+ originalText = snapshot.text;
748
778
  const parsed = JSON.parse(originalText);
749
779
  if (!isJsonObject(parsed))
750
780
  throw new Error("top-level JSON value must be an object");
@@ -858,14 +888,10 @@ export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit,
858
888
  let symlinkPath;
859
889
  let originalText;
860
890
  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");
891
+ const snapshot = readRegularTextSnapshot(configPath);
892
+ writePath = snapshot.writePath;
893
+ symlinkPath = snapshot.symlinkPath;
894
+ originalText = snapshot.text;
869
895
  parseToml(originalText);
870
896
  }
871
897
  catch (error) {
@@ -1063,18 +1089,18 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
1063
1089
  let writePath = configPath;
1064
1090
  let symlinkPath;
1065
1091
  try {
1066
- const pathInfo = lstatSync(configPath);
1067
- if (pathInfo.isSymbolicLink()) {
1068
- try {
1069
- writePath = realpathSync(configPath);
1070
- symlinkPath = configPath;
1071
- }
1072
- catch (error) {
1073
- return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
1074
- }
1092
+ const snapshot = readRegularTextSnapshot(configPath);
1093
+ writePath = snapshot.writePath;
1094
+ symlinkPath = snapshot.symlinkPath;
1095
+ originalText = snapshot.text;
1096
+ }
1097
+ catch (error) {
1098
+ if (!isErrno(error, "ENOENT")) {
1099
+ return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
1075
1100
  }
1101
+ }
1102
+ if (originalText !== undefined) {
1076
1103
  try {
1077
- originalText = readFileSync(writePath, "utf8");
1078
1104
  const parsed = JSON.parse(originalText);
1079
1105
  if (!isJsonObject(parsed)) {
1080
1106
  throw new Error("top-level JSON value must be an object");
@@ -1085,11 +1111,6 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
1085
1111
  return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
1086
1112
  }
1087
1113
  }
1088
- catch (error) {
1089
- if (!isErrno(error, "ENOENT")) {
1090
- return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
1091
- }
1092
- }
1093
1114
  const currentServers = config.mcpServers;
1094
1115
  if (currentServers !== undefined && !isJsonObject(currentServers)) {
1095
1116
  return result(definition, "error", '"mcpServers" must be a JSON object');
@@ -1145,21 +1166,18 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
1145
1166
  let writePath = configPath;
1146
1167
  let symlinkPath;
1147
1168
  try {
1148
- const pathInfo = lstatSync(configPath);
1149
- if (pathInfo.isSymbolicLink()) {
1150
- try {
1151
- writePath = realpathSync(configPath);
1152
- symlinkPath = configPath;
1153
- if (!statSync(writePath).isFile()) {
1154
- throw new Error("config symlink target is not a regular file");
1155
- }
1156
- }
1157
- catch (error) {
1158
- return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
1159
- }
1169
+ const snapshot = readRegularTextSnapshot(configPath);
1170
+ writePath = snapshot.writePath;
1171
+ symlinkPath = snapshot.symlinkPath;
1172
+ originalText = snapshot.text;
1173
+ }
1174
+ catch (error) {
1175
+ if (!isErrno(error, "ENOENT")) {
1176
+ return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
1160
1177
  }
1178
+ }
1179
+ if (originalText !== undefined) {
1161
1180
  try {
1162
- originalText = readFileSync(writePath, "utf8");
1163
1181
  const parsed = parseToml(originalText);
1164
1182
  if (!isJsonObject(parsed)) {
1165
1183
  throw new Error("top-level TOML value must be a table");
@@ -1170,11 +1188,6 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
1170
1188
  return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
1171
1189
  }
1172
1190
  }
1173
- catch (error) {
1174
- if (!isErrno(error, "ENOENT")) {
1175
- return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
1176
- }
1177
- }
1178
1191
  let managedBlock;
1179
1192
  try {
1180
1193
  managedBlock = locateCodexManagedBlock(originalText ?? "");