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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- 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.
|