davinci-resolve-mcp 2.54.2 → 2.54.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.54.4
6
+
7
+ Persistence-safety hardening (generalizes issue #71 to the analysis state
8
+ stores). A multi-agent audit found several state files whose **readers** swallow a
9
+ `JSONDecodeError` and reset to an empty default — so a later read-modify-write
10
+ writes back only the new field, silently wiping prior data. Atomic writes don't
11
+ help, because the loss happens at *read* time.
12
+
13
+ - **Fixed** read-modify-write paths now read strictly and **refuse to overwrite**
14
+ a corrupt existing file (via a shared `ConfigParseError` + `_read_json_strict`),
15
+ rather than clobbering it:
16
+ - media-analysis **preferences** — 5 write paths (set-defaults, timed-marker
17
+ default, sampling-mode default, AI governance, caps preset)
18
+ - **update-state** — configure/clear update settings (3 paths), now also written
19
+ atomically (temp + `os.replace`)
20
+ - per-clip **corrections** (human edit history — the highest-value store) —
21
+ update and revert paths
22
+ - **Fixed** non-atomic writes that a crash mid-write could truncate: the bin
23
+ summary markdown and Fusion `.setting` files now write to a temp file and
24
+ `os.replace`.
25
+ - **Fixed** a read-modify-write race on `analysis.json` under the threaded control
26
+ panel: transcript regeneration is now serialized under the existing state lock,
27
+ so a concurrent regen/batch write can't drop the other's updates.
28
+ - Read-only callers keep their forgiving behavior (they fall back to defaults and
29
+ never write). New tests in `tests/test_persistence_safety.py`.
30
+
31
+ ## What's New in v2.54.3
32
+
33
+ Follow-up to the v2.54.2 config-merge fix (#71): the JSONC sanitizer's
34
+ trailing-comma step was not string-aware.
35
+
36
+ - **Fixed** `_strip_jsonc` removed trailing commas with a regex applied to the
37
+ whole document, so a comma inside a string value followed by whitespace and a
38
+ closing brace/bracket — e.g. `"greeting": "hello, } world"` — had the comma
39
+ silently stripped *from inside the string* when merging a commented (JSONC)
40
+ client config. The trailing-comma pass is now string-aware (mirroring the
41
+ comment stripper), so string contents are never altered while real trailing
42
+ commas are still removed. Added regression tests covering string values that
43
+ contain `, }` / `, ]`. (Dropped the now-unused `re` import.)
44
+
5
45
  ## What's New in v2.54.2
6
46
 
7
47
  A destructive-overwrite bug in the installer (issue #71): the MCP client setup
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.54.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.54.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -17,7 +17,6 @@ import argparse
17
17
  import json
18
18
  import os
19
19
  import platform
20
- import re
21
20
  import shutil
22
21
  import subprocess
23
22
  import sys
@@ -36,7 +35,7 @@ from src.utils.update_check import (
36
35
 
37
36
  # ─── Version ──────────────────────────────────────────────────────────────────
38
37
 
39
- VERSION = "2.54.2"
38
+ VERSION = "2.54.4"
40
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
@@ -511,9 +510,42 @@ def _strip_jsonc(text):
511
510
  out.append(ch)
512
511
  i += 1
513
512
  stripped = "".join(out)
514
- # Drop trailing commas before a closing brace/bracket.
515
- stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
516
- return stripped
513
+ # Drop trailing commas before a closing brace/bracket — string-aware, so a
514
+ # comma inside a string value (e.g. "a, }") is never touched. A prior regex
515
+ # pass here was NOT string-aware and silently corrupted such values during a
516
+ # JSONC merge (follow-up to the issue #71 fix).
517
+ result = []
518
+ i = 0
519
+ n = len(stripped)
520
+ in_string = False
521
+ escape = False
522
+ while i < n:
523
+ ch = stripped[i]
524
+ if in_string:
525
+ result.append(ch)
526
+ if escape:
527
+ escape = False
528
+ elif ch == "\\":
529
+ escape = True
530
+ elif ch == '"':
531
+ in_string = False
532
+ i += 1
533
+ continue
534
+ if ch == '"':
535
+ in_string = True
536
+ result.append(ch)
537
+ i += 1
538
+ continue
539
+ if ch == ",":
540
+ j = i + 1
541
+ while j < n and stripped[j] in " \t\r\n":
542
+ j += 1
543
+ if j < n and stripped[j] in "}]":
544
+ i += 1 # trailing comma: drop it
545
+ continue
546
+ result.append(ch)
547
+ i += 1
548
+ return "".join(result)
517
549
 
518
550
 
519
551
  def read_json(path):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.54.2",
3
+ "version": "2.54.4",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -15176,14 +15176,20 @@ class Handler(BaseHTTPRequestHandler):
15176
15176
  self._json({"success": False, "error": "Transcript regeneration is loopback-only."}, HTTPStatus.FORBIDDEN)
15177
15177
  return
15178
15178
  clip_id = path.split("/")[3]
15179
- self._json(regenerate_clip_transcript(
15180
- self.state.project_root,
15181
- clip_id,
15182
- with_words=bool(body.get("with_words", True)),
15183
- backend=body.get("backend") or None,
15184
- language=body.get("language") or None,
15185
- model=body.get("model") or None,
15186
- ))
15179
+ # Serialize the analysis.json read-modify-write: this server is
15180
+ # threaded, so concurrent regenerations (or a regen racing a batch
15181
+ # job's report write) would interleave and the last writer would drop
15182
+ # the other's updates (PS9).
15183
+ with self.state.lock:
15184
+ result = regenerate_clip_transcript(
15185
+ self.state.project_root,
15186
+ clip_id,
15187
+ with_words=bool(body.get("with_words", True)),
15188
+ backend=body.get("backend") or None,
15189
+ language=body.get("language") or None,
15190
+ model=body.get("model") or None,
15191
+ )
15192
+ self._json(result)
15187
15193
  return
15188
15194
  if path.startswith("/api/clips/") and path.endswith("/transcript/corrections"):
15189
15195
  if not _request_is_loopback(self):
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.54.2"
83
+ VERSION = "2.54.4"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.54.2"
14
+ VERSION = "2.54.4"
15
15
 
16
16
  import base64
17
17
  import os
@@ -7273,6 +7273,39 @@ def _media_analysis_preferences_path() -> str:
7273
7273
  return os.path.join(project_dir, "logs", "media-analysis-preferences.json")
7274
7274
 
7275
7275
 
7276
+ class ConfigParseError(Exception):
7277
+ """A persisted state/config file exists but its contents could not be read.
7278
+
7279
+ Read-modify-write callers MUST refuse to overwrite when this is raised — a
7280
+ transient/corrupt read returning a default, followed by a write, silently
7281
+ wipes the user's prior data. This is the issue-#71 bug class generalized to
7282
+ the analysis state stores (see local gameplan Phase 5 / PS1–PS3).
7283
+ """
7284
+
7285
+
7286
+ def _read_json_strict(path: str) -> Dict[str, Any]:
7287
+ """Read JSON for a read-modify-write cycle.
7288
+
7289
+ Returns ``{}`` only when the file is absent or empty (safe to create). Raises
7290
+ :class:`ConfigParseError` when the file exists but cannot be read/parsed, so
7291
+ the caller can refuse to overwrite and avoid clobbering prior contents.
7292
+ """
7293
+ try:
7294
+ with open(path, "r", encoding="utf-8") as handle:
7295
+ raw = handle.read()
7296
+ except FileNotFoundError:
7297
+ return {}
7298
+ except OSError as exc:
7299
+ raise ConfigParseError(f"{path}: {exc}") from exc
7300
+ if not raw.strip():
7301
+ return {}
7302
+ try:
7303
+ payload = json.loads(raw)
7304
+ except json.JSONDecodeError as exc:
7305
+ raise ConfigParseError(f"{path}: {exc}") from exc
7306
+ return payload if isinstance(payload, dict) else {}
7307
+
7308
+
7276
7309
  def _read_media_analysis_preferences() -> Dict[str, Any]:
7277
7310
  path = _media_analysis_preferences_path()
7278
7311
  try:
@@ -7283,6 +7316,12 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
7283
7316
  return {}
7284
7317
 
7285
7318
 
7319
+ def _read_media_analysis_preferences_strict() -> Dict[str, Any]:
7320
+ """Strict prefs read for read-modify-write paths — raises ConfigParseError on
7321
+ a corrupt existing file so the caller refuses to overwrite (PS1)."""
7322
+ return _read_json_strict(_media_analysis_preferences_path())
7323
+
7324
+
7286
7325
  def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
7287
7326
  # Atomic replace: a crash mid-write must not truncate the file, because the
7288
7327
  # reader resets to {} on corruption and the next save would then wipe every
@@ -7669,6 +7708,12 @@ def _media_analysis_timed_marker_decision(p: Dict[str, Any]) -> Dict[str, Any]:
7669
7708
 
7670
7709
  if choice in {"default_yes", "default_no"}:
7671
7710
  saved = "yes" if choice == "default_yes" else "no"
7711
+ # Strict re-read before writing: a corrupt prefs file must refuse, not
7712
+ # clobber every saved preference (PS1).
7713
+ try:
7714
+ preferences = _read_media_analysis_preferences_strict()
7715
+ except ConfigParseError as exc:
7716
+ return _err(f"Refusing to save timed-marker default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
7672
7717
  preferences["timed_markers_default"] = saved
7673
7718
  preferences["timed_markers_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
7674
7719
  _write_media_analysis_preferences(preferences)
@@ -7833,6 +7878,10 @@ def _media_analysis_sampling_mode_decision(p: Dict[str, Any]) -> Dict[str, Any]:
7833
7878
  source = "explicit"
7834
7879
  if save_default:
7835
7880
  saved = mode
7881
+ try:
7882
+ preferences = _read_media_analysis_preferences_strict()
7883
+ except ConfigParseError as exc:
7884
+ return _err(f"Refusing to save sampling-mode default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
7836
7885
  preferences["sampling_mode_default"] = mode
7837
7886
  preferences["sampling_mode_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
7838
7887
  _write_media_analysis_preferences(preferences)
@@ -11040,6 +11089,31 @@ def _setup_update_state() -> Dict[str, Any]:
11040
11089
  return {}
11041
11090
 
11042
11091
 
11092
+ def _setup_update_state_strict() -> Dict[str, Any]:
11093
+ """Strict update-state read for read-modify-write paths — raises
11094
+ ConfigParseError on a corrupt existing file so the caller refuses to
11095
+ overwrite and wipe saved update prefs (PS2)."""
11096
+ return _read_json_strict(str(update_state_path(project_dir)))
11097
+
11098
+
11099
+ def _write_setup_update_state(state: Dict[str, Any]) -> None:
11100
+ """Atomically persist update state (temp + os.replace), so a crash mid-write
11101
+ can't truncate the file that _setup_update_state then resets to {} (PS2)."""
11102
+ path = str(update_state_path(project_dir))
11103
+ os.makedirs(os.path.dirname(path), exist_ok=True)
11104
+ tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
11105
+ try:
11106
+ with open(tmp_path, "w", encoding="utf-8") as handle:
11107
+ json.dump(state, handle, indent=2, sort_keys=True)
11108
+ handle.write("\n")
11109
+ os.replace(tmp_path, path)
11110
+ finally:
11111
+ try:
11112
+ os.remove(tmp_path)
11113
+ except OSError:
11114
+ pass
11115
+
11116
+
11043
11117
  def _setup_update_env() -> Dict[str, str]:
11044
11118
  env = dict(os.environ)
11045
11119
  state = _setup_update_state()
@@ -11263,7 +11337,13 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
11263
11337
  if not requested:
11264
11338
  return {"changed": False, "recognized": False}
11265
11339
 
11266
- preferences = _read_media_analysis_preferences()
11340
+ # Strict read: this is a read-modify-write of the prefs file, so a corrupt
11341
+ # existing file must refuse rather than seed the write from {} and wipe every
11342
+ # saved preference (PS1).
11343
+ try:
11344
+ preferences = _read_media_analysis_preferences_strict()
11345
+ except ConfigParseError as exc:
11346
+ return _err(f"Refusing to update media-analysis defaults: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
11267
11347
  before = _media_analysis_effective_preferences()
11268
11348
  next_preferences = dict(preferences)
11269
11349
  updates: Dict[str, Dict[str, Any]] = {}
@@ -11519,9 +11599,18 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
11519
11599
  if dry_run:
11520
11600
  return {"changed": True, "recognized": True, "updates": updates, "before": before, "dry_run": True}
11521
11601
 
11602
+ # Strict re-read before mutating: a corrupt update-state file must refuse,
11603
+ # not seed the write from {} and wipe saved update prefs (PS2).
11604
+ try:
11605
+ state = _setup_update_state_strict()
11606
+ except ConfigParseError as exc:
11607
+ return _err(f"Refusing to update settings: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
11522
11608
  if "mode" in updates:
11523
11609
  set_update_mode(project_dir, updates["mode"]["after"], env=_setup_update_env())
11524
- state = _setup_update_state()
11610
+ try:
11611
+ state = _setup_update_state_strict()
11612
+ except ConfigParseError as exc:
11613
+ return _err(f"Refusing to update settings: {exc}. The update-state file became unparseable; fix or delete it to avoid wiping saved update preferences.")
11525
11614
  updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
11526
11615
  if "check_interval_hours" in updates:
11527
11616
  state["check_interval_hours"] = updates["check_interval_hours"]["after"]
@@ -11529,9 +11618,7 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
11529
11618
  state["snooze_hours"] = updates["snooze_hours"]["after"]
11530
11619
  if updates:
11531
11620
  state["setup_defaults_updated_at"] = updated_at
11532
- with open(update_state_path(project_dir), "w", encoding="utf-8") as handle:
11533
- json.dump(state, handle, indent=2, sort_keys=True)
11534
- handle.write("\n")
11621
+ _write_setup_update_state(state)
11535
11622
  return {
11536
11623
  "changed": bool(updates),
11537
11624
  "recognized": True,
@@ -11602,26 +11689,28 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
11602
11689
  result["cleared"].append("updates.mode")
11603
11690
 
11604
11691
  if clear_all or normalized_keys & {"updates.check_interval_hours", "check_interval_hours", "update_interval_hours"}:
11605
- state = _setup_update_state()
11606
11692
  if dry_run:
11607
11693
  result["update_check_interval_hours"] = {"changed": True, "dry_run": True}
11608
11694
  else:
11695
+ try:
11696
+ state = _setup_update_state_strict()
11697
+ except ConfigParseError as exc:
11698
+ return _err(f"Refusing to clear update interval: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
11609
11699
  state.pop("check_interval_hours", None)
11610
- with open(update_state_path(project_dir), "w", encoding="utf-8") as handle:
11611
- json.dump(state, handle, indent=2, sort_keys=True)
11612
- handle.write("\n")
11700
+ _write_setup_update_state(state)
11613
11701
  result["update_check_interval_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
11614
11702
  result["cleared"].append("updates.check_interval_hours")
11615
11703
 
11616
11704
  if clear_all or normalized_keys & {"updates.snooze_hours", "snooze_hours", "update_snooze_hours"}:
11617
- state = _setup_update_state()
11618
11705
  if dry_run:
11619
11706
  result["update_snooze_hours"] = {"changed": True, "dry_run": True}
11620
11707
  else:
11708
+ try:
11709
+ state = _setup_update_state_strict()
11710
+ except ConfigParseError as exc:
11711
+ return _err(f"Refusing to clear snooze: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
11621
11712
  state.pop("snooze_hours", None)
11622
- with open(update_state_path(project_dir), "w", encoding="utf-8") as handle:
11623
- json.dump(state, handle, indent=2, sort_keys=True)
11624
- handle.write("\n")
11713
+ _write_setup_update_state(state)
11625
11714
  result["update_snooze_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
11626
11715
  result["cleared"].append("updates.snooze_hours")
11627
11716
 
@@ -11988,7 +12077,7 @@ def _v2_corrections_path_for_clip(project_root: str, clip_dir: Optional[str], cl
11988
12077
  return None
11989
12078
 
11990
12079
 
11991
- def _v2_read_corrections(path: str) -> Dict[str, Any]:
12080
+ def _v2_read_corrections(path: str, *, strict: bool = False) -> Dict[str, Any]:
11992
12081
  if not os.path.isfile(path):
11993
12082
  return {"schema_version": "2.0", "current": {}, "changelog": []}
11994
12083
  try:
@@ -12001,7 +12090,12 @@ def _v2_read_corrections(path: str) -> Dict[str, Any]:
12001
12090
  data.setdefault("current", {})
12002
12091
  data.setdefault("changelog", [])
12003
12092
  return data
12004
- except (OSError, json.JSONDecodeError):
12093
+ except (OSError, json.JSONDecodeError) as exc:
12094
+ # strict=True is for read-modify-write callers: an existing-but-corrupt
12095
+ # corrections file must refuse, not reset to empty and then erase the
12096
+ # user's human edit history on the next write (PS3, issue #71 class).
12097
+ if strict:
12098
+ raise ConfigParseError(f"{path}: {exc}") from exc
12005
12099
  return {"schema_version": "2.0", "current": {}, "changelog": []}
12006
12100
 
12007
12101
 
@@ -12049,7 +12143,10 @@ def _v2_update_field(project_root: str, p: Dict[str, Any], *, entity_type: str)
12049
12143
  if not path:
12050
12144
  return _err(f"Could not locate clip directory for clip_id={clip_id} under {project_root}/clips/. Pass clip_dir explicitly if the clip directory is non-standard.")
12051
12145
 
12052
- data = _v2_read_corrections(path)
12146
+ try:
12147
+ data = _v2_read_corrections(path, strict=True)
12148
+ except ConfigParseError as exc:
12149
+ return _err(f"Refusing to write correction: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
12053
12150
  if clip_id and "clip_id" not in data:
12054
12151
  data["clip_id"] = str(clip_id)
12055
12152
 
@@ -12149,7 +12246,10 @@ def _v2_revert_field(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]:
12149
12246
  path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id)
12150
12247
  if not path or not os.path.isfile(path):
12151
12248
  return _err("No corrections file exists for this clip; nothing to revert.")
12152
- data = _v2_read_corrections(path)
12249
+ try:
12250
+ data = _v2_read_corrections(path, strict=True)
12251
+ except ConfigParseError as exc:
12252
+ return _err(f"Refusing to revert: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
12153
12253
  key = f"{entity_type}:{entity_uuid}:{field_path}"
12154
12254
  if key not in data.get("current", {}):
12155
12255
  return _err(f"No current correction for {key}; nothing to revert.")
@@ -15896,7 +15996,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15896
15996
  return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}")
15897
15997
  if mode and mode not in _AI_GOVERNANCE_MODES:
15898
15998
  return _err(f"unknown mode '{mode}'. Valid: {list(_AI_GOVERNANCE_MODES)}")
15899
- prefs = _read_media_analysis_preferences()
15999
+ try:
16000
+ prefs = _read_media_analysis_preferences_strict()
16001
+ except ConfigParseError as exc:
16002
+ return _err(f"Refusing to save AI governance: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
15900
16003
  if preset:
15901
16004
  prefs["resolve_ai_governance_preset"] = preset
15902
16005
  if mode:
@@ -15916,7 +16019,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15916
16019
  if preset not in _ac.VALID_PRESETS:
15917
16020
  return _err(f"unknown preset '{preset}'. Valid: {sorted(_ac.VALID_PRESETS)}")
15918
16021
  # Update preference file directly (same pattern as set_defaults).
15919
- prefs = _read_media_analysis_preferences()
16022
+ try:
16023
+ prefs = _read_media_analysis_preferences_strict()
16024
+ except ConfigParseError as exc:
16025
+ return _err(f"Refusing to save caps preset: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
15920
16026
  prefs["analysis_caps_preset"] = preset
15921
16027
  if isinstance(p.get("overrides"), dict):
15922
16028
  prefs["analysis_caps_overrides"] = p["overrides"]
@@ -358,9 +358,13 @@ def regenerate_bin_summary_from_manifest(
358
358
  lines.append("\n")
359
359
 
360
360
  path = bin_summary_path(project_root)
361
+ # Atomic write (temp + os.replace): a crash mid-write must not truncate the
362
+ # bin summary that session_start_context reads at startup (PS4).
363
+ tmp_path = path + ".tmp"
361
364
  try:
362
- with open(path, "w", encoding="utf-8") as handle:
365
+ with open(tmp_path, "w", encoding="utf-8") as handle:
363
366
  handle.writelines(lines)
367
+ os.replace(tmp_path, path)
364
368
  return {
365
369
  "success": True,
366
370
  "path": path,
@@ -376,6 +380,11 @@ def regenerate_bin_summary_from_manifest(
376
380
  },
377
381
  }
378
382
  except OSError as exc:
383
+ if os.path.isfile(tmp_path):
384
+ try:
385
+ os.remove(tmp_path)
386
+ except OSError:
387
+ pass
379
388
  return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
380
389
 
381
390
 
@@ -304,8 +304,19 @@ def splice_inputs_block(
304
304
  after_inputs = parse_instance_input_block(new_inner)
305
305
 
306
306
  os.makedirs(os.path.dirname(os.path.abspath(dest_path)) or ".", exist_ok=True)
307
- with open(dest_path, "w", encoding="utf-8", newline="\n") as handle:
308
- handle.write(new_content)
307
+ # Atomic write (temp + os.replace): a crash mid-write must not truncate the
308
+ # Fusion .setting file and leave the GroupOperator config uneditable (PS5).
309
+ tmp_path = f"{dest_path}.tmp-{os.getpid()}"
310
+ try:
311
+ with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
312
+ handle.write(new_content)
313
+ os.replace(tmp_path, dest_path)
314
+ finally:
315
+ if os.path.isfile(tmp_path):
316
+ try:
317
+ os.remove(tmp_path)
318
+ except OSError:
319
+ pass
309
320
 
310
321
  return {
311
322
  "source_path": os.path.abspath(source_path),