froid-loop 0.11.1__py3-none-any.whl

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.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Post-run scratch cleanup for the froid-loop Unity engine plugin.
3
+
4
+ The IvanMurzak ``unity-mcp-cli`` downloads the Editor MCP server into Unity's
5
+ per-project temp dir — on Linux ``/tmp/<companyName>/<productName>/`` — as a
6
+ versioned ``unity-mcp-server-*.zip`` and never removes it, so a fresh zip
7
+ accumulates every time the pinned server version changes. The Editor also writes
8
+ an unbounded ``<project>/Temp/mcp-server/ai-editor-logs.txt``. Neither is needed
9
+ once a run is over; this script reclaims both, best-effort, at ``post_run``.
10
+
11
+ It runs once per run (both editor modes), after the loop has finished — so it
12
+ never races an in-flight ``setup-mcp`` download. Gated by ``FROID_LOOP_CLEAN_TMP``
13
+ (the engine maps [cleanup] clean_tmp onto it); a "0" disables it.
14
+
15
+ Env (injected by the engine):
16
+ FROID_LOOP_WORKTREE the Unity project path (the repo root in shared mode)
17
+ FROID_LOOP_REPO_ROOT main repo root (fallback for the project path)
18
+ FROID_LOOP_CLEAN_TMP "1" (default) enables; "0" disables
19
+ FROID_LOOP_UNITY_LOG_CAP_MB truncate the mcp log once it exceeds this (default 5)
20
+
21
+ Exit 0 always (best-effort): a cleanup failure must never fail a finished run.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+ import sys
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+ # companyName/productName are flat scalars in the otherwise-YAML asset.
33
+ _COMPANY_RE = re.compile(r"^\s*companyName:\s*(.+?)\s*$", re.MULTILINE)
34
+ _PRODUCT_RE = re.compile(r"^\s*productName:\s*(.+?)\s*$", re.MULTILINE)
35
+
36
+
37
+ def _project() -> Path | None:
38
+ p = os.environ.get("FROID_LOOP_WORKTREE") or os.environ.get("FROID_LOOP_REPO_ROOT")
39
+ return Path(p) if p else None
40
+
41
+
42
+ def read_project_identity(project: Path) -> tuple[str, str]:
43
+ """``(companyName, productName)`` from ProjectSettings/ProjectSettings.asset.
44
+ Falls back to ``("DefaultCompany", <dir name>)`` — Unity's own default
45
+ companyName — when the asset is missing or a field is unset."""
46
+ default = ("DefaultCompany", project.name)
47
+ asset = project / "ProjectSettings" / "ProjectSettings.asset"
48
+ try:
49
+ text = asset.read_text(encoding="utf-8", errors="replace")
50
+ except OSError:
51
+ return default
52
+ company = _COMPANY_RE.search(text)
53
+ product = _PRODUCT_RE.search(text)
54
+ return (
55
+ company.group(1) if company and company.group(1) else default[0],
56
+ product.group(1) if product and product.group(1) else default[1],
57
+ )
58
+
59
+
60
+ def _temp_cache_root() -> Path:
61
+ """Base of Unity's ``Application.temporaryCachePath`` (where unity-mcp-cli drops
62
+ the server zip), under which we look for ``<company>/<product>``.
63
+
64
+ On Linux this is exactly ``/tmp`` — keep it byte-for-byte (it *is* Unity's
65
+ documented temporaryCachePath base, not our scratch choice). On other platforms
66
+ (no /tmp) derive from ``tempfile.gettempdir()`` as a best-effort analogue.
67
+ NOTE: native Windows Unity uses a *different* cache root —
68
+ ``%USERPROFILE%\\AppData\\Local\\Temp\\<company>\\<product>`` — which
69
+ ``gettempdir()`` does not always resolve to; getting that exactly right is a
70
+ documented follow-up (no Windows backend ships this pass, so it isn't exercised)."""
71
+ if sys.platform.startswith("linux"):
72
+ return Path("/tmp") # nosec B108 portability: Unity temporaryCachePath base (Linux)
73
+ return Path(tempfile.gettempdir()) # portability: non-Linux temp root (see note above)
74
+
75
+
76
+ def _clean_tmp_zips(company: str, product: str) -> int:
77
+ """Remove the downloaded unity-mcp-server zips under the temp cache dir
78
+ ``<temp>/<company>/<product>``. Returns the number removed. Scoped to this
79
+ project's temp dir so a sibling project's downloads are never touched."""
80
+ # This is unity-mcp-cli's fixed download location, not our scratch choice.
81
+ tmp = _temp_cache_root() / company / product
82
+ if not tmp.is_dir():
83
+ return 0
84
+ removed = 0
85
+ for zip_path in tmp.glob("unity-mcp-server-*.zip"):
86
+ try:
87
+ zip_path.unlink()
88
+ removed += 1
89
+ print(f"unity_cleanup: removed {zip_path}", file=sys.stderr)
90
+ except OSError as exc: # best effort
91
+ print(f"unity_cleanup: could not remove {zip_path}: {exc}", file=sys.stderr)
92
+ return removed
93
+
94
+
95
+ def _truncate_mcp_log(project: Path) -> None:
96
+ """Reset the MCP editor log once it grows past the cap. Safe even while the
97
+ Editor/server holds it open — truncation just resets the size."""
98
+ try:
99
+ cap_mb = max(0, int(os.environ.get("FROID_LOOP_UNITY_LOG_CAP_MB", "5")))
100
+ except ValueError:
101
+ cap_mb = 5
102
+ log = project / "Temp" / "mcp-server" / "ai-editor-logs.txt"
103
+ try:
104
+ if log.is_file() and log.stat().st_size > cap_mb * 1_048_576:
105
+ log.write_bytes(b"")
106
+ print(f"unity_cleanup: truncated {log}", file=sys.stderr)
107
+ except OSError as exc: # best effort
108
+ print(f"unity_cleanup: could not truncate {log}: {exc}", file=sys.stderr)
109
+
110
+
111
+ def main() -> int:
112
+ if os.environ.get("FROID_LOOP_CLEAN_TMP", "1").strip() not in ("1", "true", "yes", "on", ""):
113
+ return 0
114
+ project = _project()
115
+ if project is None:
116
+ print("unity_cleanup: no project path (FROID_LOOP_WORKTREE/REPO_ROOT)", file=sys.stderr)
117
+ return 0
118
+ company, product = read_project_identity(project)
119
+ _clean_tmp_zips(company, product)
120
+ _truncate_mcp_log(project)
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """Detached, DETECT-ONLY Unity modal-dialog probe for the froid-loop Unity plugin.
3
+
4
+ Unity on Linux is an X11 (or XWayland) client, so its run-freezing modal dialogs —
5
+ "scene(s) have been modified" (reload / save-before-close), "conflicting scene
6
+ changes" — are ordinary X windows. This probe polls xdotool for a *visible*
7
+ Unity-owned window whose title matches one of those dialogs and, when it finds one,
8
+ **reports it and nothing more**: it NEVER clicks, keys, activates, or closes any
9
+ window. The scene-guard (auto-save) and the injected prompt facts are what *prevent*
10
+ the modals; this is the last-resort observability net that tells a human one slipped
11
+ through and is now freezing the MCP dispatch loop.
12
+
13
+ Report on a fresh detection (deduped per window id, per probe lifetime):
14
+ 1. a JSONL line to ``<run_dir>/unity-dialog-probe.jsonl`` — ``{ts, window, title}``;
15
+ 2. a line to ``<run_dir>/ATTENTION`` in the same format as ``gates.notify``;
16
+ 3. a best-effort ``notify-send`` desktop notification (guarded by ``shutil.which``,
17
+ gated by ``FROID_LOOP_UNITY_DIALOG_PROBE_NOTIFY``), mirroring ``gates.notify``.
18
+
19
+ Lifecycle: launched detached by ``UnityPlugin`` (shared mode: at ``pre_run``;
20
+ per_worktree: at ``pre_worktree_setup``) with the project/worktree path in argv, so
21
+ ``unity_teardown.py``'s ``/proc`` argv scan can find + reap it. It also writes its own
22
+ ``<run_dir>/unity-dialog-probe.pid`` (pid + start-time identity, via
23
+ ``runs.write_named_pid``) as the primary reap handle. Self-reaping backstop: it polls
24
+ ``<run_dir>/engine.pid`` liveness (``runs.engine_alive`` — the same identity-checked
25
+ logic the CLI uses) and exits when the engine dies, so an un-reaped probe can never
26
+ outlive its run.
27
+
28
+ Stdlib only, plus froid-loop's own dep-free process/pid helpers (no third-party deps).
29
+ No-op clean exit (0) when there is nothing to watch: ``DISPLAY`` unset, ``xdotool``
30
+ absent, or ``FROID_LOOP_RUN_DIR`` unset (Windows/macOS fall out here — out of scope).
31
+
32
+ Env (injected by the engine plugin):
33
+ FROID_LOOP_RUN_DIR run dir for outputs + engine.pid liveness
34
+ FROID_LOOP_UNITY_DIALOG_PROBE_INTERVAL_SEC poll interval seconds (default 5, min 1)
35
+ FROID_LOOP_UNITY_DIALOG_PROBE_NOTIFY 1/0 desktop notify (default 1)
36
+ FROID_LOOP_UNITY_DIALOG_PROBE_CLASS xdotool WM_CLASS regex (default Unity)
37
+
38
+ NOTE: the exact dialog titles + the "Unity" window class are UNVERIFIED against a live
39
+ Linux Editor build — they are best-effort heuristics. Detection is advisory; a miss
40
+ degrades to the pre-existing behavior (a human notices the frozen run), never worse.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import os
47
+ import shutil
48
+ import signal
49
+ import subprocess
50
+ import sys
51
+ import time
52
+ from pathlib import Path
53
+
54
+ # froid-loop's own dep-free liveness/pid helpers (run under sys.executable, so the
55
+ # package is importable — same contract unity_teardown.py relies on for process_host).
56
+ from froid_loop.runs import engine_alive, write_named_pid
57
+
58
+ PROBE_PID_FILE = "unity-dialog-probe.pid"
59
+ PROBE_JSONL = "unity-dialog-probe.jsonl"
60
+ ATTENTION_FILE = "ATTENTION"
61
+ _TITLE = "Unity modal dialog detected"
62
+
63
+ # Case-insensitive substrings identifying a run-freezing Unity modal dialog by its
64
+ # window title. UNVERIFIED against a live Editor — kept broad + advisory on purpose.
65
+ _DIALOG_PATTERNS = (
66
+ "changed on disk",
67
+ "save changes before",
68
+ "conflicting scene changes",
69
+ "scene(s) have been modified",
70
+ "reload the following",
71
+ "do you want to save",
72
+ "unapplied import settings",
73
+ )
74
+
75
+ # Set by the SIGTERM/SIGINT handler so a polite teardown ends the loop promptly.
76
+ _stop = False
77
+
78
+
79
+ def _truthy(value: str | None, default: bool) -> bool:
80
+ if value is None or value.strip() == "":
81
+ return default
82
+ return value.strip().lower() in ("1", "true", "yes", "on")
83
+
84
+
85
+ def _install_signals() -> None:
86
+ """Break the poll loop on SIGTERM/SIGINT so the plugin's polite reap suffices."""
87
+
88
+ def _handler(_signum, _frame):
89
+ global _stop
90
+ _stop = True
91
+
92
+ for name in ("SIGTERM", "SIGINT"):
93
+ sig = getattr(signal, name, None)
94
+ if sig is not None:
95
+ try:
96
+ signal.signal(sig, _handler)
97
+ except (ValueError, OSError): # pragma: no cover - not the main thread, etc.
98
+ pass
99
+
100
+
101
+ def _interval() -> float:
102
+ try:
103
+ return max(1.0, float(os.environ.get("FROID_LOOP_UNITY_DIALOG_PROBE_INTERVAL_SEC", "5")))
104
+ except (TypeError, ValueError):
105
+ return 5.0
106
+
107
+
108
+ def _run_xdotool(args: list[str], timeout: float = 10.0) -> subprocess.CompletedProcess:
109
+ return subprocess.run(["xdotool", *args], capture_output=True, text=True, timeout=timeout)
110
+
111
+
112
+ def _unity_window_ids(run) -> list[str]:
113
+ """Ids of *visible* Unity-owned windows (xdotool search by WM_CLASS regex).
114
+ xdotool exits non-zero when nothing matches — that is the normal "no dialog"
115
+ case, not an error. Any launch failure degrades to an empty list."""
116
+ cls = os.environ.get("FROID_LOOP_UNITY_DIALOG_PROBE_CLASS", "Unity")
117
+ try:
118
+ proc = run(["search", "--onlyvisible", "--class", cls])
119
+ except (subprocess.SubprocessError, OSError):
120
+ return []
121
+ if proc.returncode != 0:
122
+ return []
123
+ return [line.strip() for line in proc.stdout.splitlines() if line.strip().isdigit()]
124
+
125
+
126
+ def _window_name(run, wid: str) -> str:
127
+ try:
128
+ proc = run(["getwindowname", wid])
129
+ except (subprocess.SubprocessError, OSError):
130
+ return ""
131
+ return proc.stdout.strip() if proc.returncode == 0 else ""
132
+
133
+
134
+ def _matches_dialog(name: str) -> bool:
135
+ low = name.lower()
136
+ return any(pattern in low for pattern in _DIALOG_PATTERNS)
137
+
138
+
139
+ def _scan_for_dialogs(run) -> list[tuple[str, str]]:
140
+ """(window id, title) for every visible Unity window whose title matches a known
141
+ modal-dialog phrase. ``run`` is an injectable xdotool runner (for testing)."""
142
+ hits: list[tuple[str, str]] = []
143
+ for wid in _unity_window_ids(run):
144
+ name = _window_name(run, wid)
145
+ if name and _matches_dialog(name):
146
+ hits.append((wid, name))
147
+ return hits
148
+
149
+
150
+ def _report(run_dir: Path, wid: str, name: str, *, notify: bool) -> None:
151
+ """Record one fresh detection: JSONL + ATTENTION + (best-effort) notify-send.
152
+ Every sink is independently guarded — a filesystem or notify failure never stops
153
+ the others, and never raises."""
154
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S")
155
+ try:
156
+ with (run_dir / PROBE_JSONL).open("a", encoding="utf-8") as fh:
157
+ fh.write(json.dumps({"ts": ts, "window": wid, "title": name}) + "\n")
158
+ except OSError:
159
+ pass
160
+ # ATTENTION line: identical shape to gates.notify's file sink.
161
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
162
+ message = f"window {wid}: {name}"
163
+ try:
164
+ with (run_dir / ATTENTION_FILE).open("a", encoding="utf-8") as fh:
165
+ fh.write(f"[{stamp}] {_TITLE}: {message}\n")
166
+ except OSError:
167
+ pass
168
+ # portability: notify-send is Linux-only; the shutil.which guard makes this a
169
+ # no-op where it is absent (mirrors gates.notify).
170
+ if notify and shutil.which("notify-send"):
171
+ try:
172
+ subprocess.run(
173
+ ["notify-send", "--app-name=froid-loop", _TITLE, message],
174
+ timeout=10,
175
+ capture_output=True,
176
+ )
177
+ except (subprocess.SubprocessError, OSError):
178
+ pass # desktop notification is best-effort
179
+
180
+
181
+ def _scan_once(run, run_dir: Path, seen: set[str], *, notify: bool) -> int:
182
+ """One scan+report pass. Reports only window ids not already in ``seen`` (dedupe
183
+ repeat detections of the same dialog). Returns the count of NEW reports."""
184
+ reported = 0
185
+ for wid, name in _scan_for_dialogs(run):
186
+ if wid in seen:
187
+ continue
188
+ seen.add(wid)
189
+ _report(run_dir, wid, name, notify=notify)
190
+ reported += 1
191
+ return reported
192
+
193
+
194
+ def _sleep_until_next_poll(interval: float, run_dir: Path) -> None:
195
+ """Sleep ``interval`` in ~1s slices so a SIGTERM or a dead engine is noticed
196
+ within about a second regardless of how long the poll interval is."""
197
+ slept = 0.0
198
+ while not _stop and slept < interval and engine_alive(run_dir):
199
+ time.sleep(min(1.0, interval - slept))
200
+ slept += 1.0
201
+
202
+
203
+ def main() -> int:
204
+ if not os.environ.get("DISPLAY"):
205
+ print("unity_dialog_probe: DISPLAY unset; nothing to probe (exit 0)", file=sys.stderr)
206
+ return 0
207
+ if shutil.which("xdotool") is None:
208
+ print("unity_dialog_probe: xdotool not on PATH; nothing to probe (exit 0)", file=sys.stderr)
209
+ return 0
210
+ rd = (os.environ.get("FROID_LOOP_RUN_DIR") or "").strip()
211
+ if not rd:
212
+ # Without a run dir we have nowhere to report AND no engine pid to shadow —
213
+ # refuse rather than loop forever as an orphan.
214
+ print(
215
+ "unity_dialog_probe: FROID_LOOP_RUN_DIR unset; refusing to run with no "
216
+ "engine to shadow (exit 0)",
217
+ file=sys.stderr,
218
+ )
219
+ return 0
220
+
221
+ run_dir = Path(rd)
222
+ interval = _interval()
223
+ notify = _truthy(os.environ.get("FROID_LOOP_UNITY_DIALOG_PROBE_NOTIFY"), True)
224
+ _install_signals()
225
+ # primary reap handle: our own pid + start-time identity (mirrors runs.write_pid).
226
+ try:
227
+ write_named_pid(run_dir / PROBE_PID_FILE, os.getpid())
228
+ except OSError:
229
+ pass
230
+
231
+ seen: set[str] = set()
232
+ while not _stop and engine_alive(run_dir):
233
+ _scan_once(_run_xdotool, run_dir, seen, notify=notify)
234
+ _sleep_until_next_poll(interval, run_dir)
235
+ return 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ ## Unity scene-save discipline (froid-loop)
2
+
3
+ You are driving a live Unity Editor over MCP. Unity-MCP scene-mutating tools mark
4
+ scenes dirty but never save them, and a dirty or unsaved-on-quit scene raises modal
5
+ Editor dialogs ("scene changed on disk", "save changes before closing") that freeze
6
+ the Editor and every subsequent MCP call. Follow these rules:
7
+
8
+ - After any scene-mutating MCP batch (`gameobject-*`, `*component*`, `tilemap-*`,
9
+ `cinemachine-*`, prefab instantiate), call `scene-save` for **each modified open
10
+ scene** BEFORE any of: running tests (`tests-run` aborts on dirty scenes), any
11
+ `git` command, entering play mode (`editor-application-set-state`), or ending the
12
+ session.
13
+ - Never rewrite an open scene's `.unity`/`.prefab` on disk directly — prefer the MCP
14
+ tools. If a disk edit is unavoidable: `scene-save` first, make the edit, then
15
+ `assets-refresh`.
16
+ - A `SceneAutoSaveGuard` auto-saves dirty scenes after ~5s idle, but do **not** rely
17
+ on it for ordering — call `scene-save` explicitly at the boundaries above.