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,551 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""per_worktree setup for the froid-loop Unity engine plugin.
|
|
3
|
+
|
|
4
|
+
Runs once per unit, right after froid-loop cuts the unit's git worktree and before
|
|
5
|
+
the readiness gate, to turn that fresh checkout into a usable Unity project with
|
|
6
|
+
its own managed Editor:
|
|
7
|
+
|
|
8
|
+
1. Give the worktree a *warm* ``Library`` by reflink/CoW-copying the project's
|
|
9
|
+
main ``Library`` into it (NOT the operator's live Library directly, and NOT a
|
|
10
|
+
symlink to it — sharing one Library across two Editors corrupts it). A fresh
|
|
11
|
+
worktree has no Library (it is gitignored, so never checked out); opening Unity
|
|
12
|
+
on an empty Library forces a *cold full reimport* of the whole project, which on
|
|
13
|
+
a real project melts the import workers and SIGFPEs Burst mid-artifact-write
|
|
14
|
+
(the "Opening file VirtualArtifacts/Primary/<hash>" crash). Seeding a warm
|
|
15
|
+
Library makes it an *incremental* reimport of just the changed assets instead.
|
|
16
|
+
On a CoW filesystem (btrfs/xfs) the copy is near-instant and shares extents, so
|
|
17
|
+
it costs almost no time or disk; elsewhere it falls back to a deep copy, then to
|
|
18
|
+
a symlinked empty per-worktree cache (the old behavior) if no warm source exists.
|
|
19
|
+
2. Write the worktree's MCP client config via ``setup-mcp`` for *every* agent that
|
|
20
|
+
runs in the worktree (dev + review may be different CLIs, each reading its own
|
|
21
|
+
config file — ``.mcp.json`` for claude, ``.codex/config.toml`` for codex, …)
|
|
22
|
+
and pin the *Unity project* to local ("Custom") mode via ``bootstrap-local``.
|
|
23
|
+
The IvanMurzak CLI derives a deterministic MCP port from the *project path*, so
|
|
24
|
+
a worktree at a different path automatically gets its own port and self-isolates
|
|
25
|
+
from the operator's main Editor with no manual port wiring. Each agent's config
|
|
26
|
+
is then verified to point at that port — a worktree-local config seeded from the
|
|
27
|
+
main repo (pinned to the main project's port) would otherwise silently route the
|
|
28
|
+
agent's asset writes into the main checkout, so a mismatch fails the hook.
|
|
29
|
+
3. Launch a Unity Editor on the worktree path (detached) **with local-connection
|
|
30
|
+
env** so it connects to that per-path local server and *hosts the server
|
|
31
|
+
itself* (``--start-server true``) rather than the cloud. The plugin's
|
|
32
|
+
``ready_cmd`` (the engine's readiness gate) then blocks until that Editor +
|
|
33
|
+
server are up, so this script only needs to start it, not wait for it.
|
|
34
|
+
|
|
35
|
+
Why custom/local mode matters: a bare ``open`` passes no MCP connection env, so the
|
|
36
|
+
Editor falls back to its persisted config (cloud by default) — the worktree Editor
|
|
37
|
+
then isn't talking to its per-worktree server at all. And without ``--start-server
|
|
38
|
+
true`` the local server only exists once an MCP *client* (the agent) spawns it, so
|
|
39
|
+
the readiness gate can't observe it before the agent runs. Pinning Custom mode +
|
|
40
|
+
Editor-hosted server fixes both: the Editor connects locally and readiness becomes
|
|
41
|
+
observable without any client. (A loopback ``--url`` is what flips the Editor off
|
|
42
|
+
cloud; ``bootstrap-local`` persists ``connectionMode: Custom`` in the project's
|
|
43
|
+
``UserSettings/AI-Game-Developer-Config.json`` so the Editor UI shows it too.)
|
|
44
|
+
|
|
45
|
+
The MCP tool *skill* files are not written here — they are gitignored and copied
|
|
46
|
+
in from the main repo by the plugin's ``seed_globs`` (``.claude/skills/*``).
|
|
47
|
+
|
|
48
|
+
Verified against unity-mcp-cli v0.81.1 (`setup-mcp` writes ``.mcp.json`` with the
|
|
49
|
+
deterministic local URL; `bootstrap-local --url --token` pins Custom mode; `open`
|
|
50
|
+
injects UNITY_MCP_* env only when connection options are passed). The exact flags
|
|
51
|
+
move between releases — override engine.worktree_setup_cmd in a project-local
|
|
52
|
+
plugin if yours differ.
|
|
53
|
+
|
|
54
|
+
Only the IvanMurzak MCP is wired for a managed per-worktree launch. CoplayDev runs
|
|
55
|
+
one shared :8080 server multiplexing Editors by instance id, so its per-worktree
|
|
56
|
+
story differs — point engine.worktree_setup_cmd at your own script for it.
|
|
57
|
+
|
|
58
|
+
Env (injected by the engine, all optional except the worktree):
|
|
59
|
+
FROID_LOOP_WORKTREE the unit's worktree (the Unity project to manage)
|
|
60
|
+
FROID_LOOP_REPO_ROOT main repo root (parent of the Library cache)
|
|
61
|
+
FROID_LOOP_ENGINE_MCP ivanmurzak | coplaydev (default ivanmurzak)
|
|
62
|
+
FROID_LOOP_UNITY_PATH explicit Editor binary (skips Unity Hub discovery)
|
|
63
|
+
FROID_LOOP_ENGINE_AGENTS comma-separated setup-mcp agent ids to point at the
|
|
64
|
+
worktree Editor — every CLI that runs here, dev AND
|
|
65
|
+
review (auto-wired by the engine from the loaded
|
|
66
|
+
profiles; default claude-code)
|
|
67
|
+
FROID_LOOP_ENGINE_AGENT legacy single-agent fallback for the above
|
|
68
|
+
FROID_LOOP_UNITY_LIBRARY_CACHE override the symlink-fallback Library cache root
|
|
69
|
+
FROID_LOOP_UNITY_LIBRARY_SEED warm Library to prime from (default <repo>/Library;
|
|
70
|
+
empty string disables priming → symlink fallback)
|
|
71
|
+
FROID_LOOP_UNITY_LIBRARY_SEED_MODE reflink | copy | symlink | off (default reflink)
|
|
72
|
+
UNITY_MCP_CLI IvanMurzak CLI binary (default unity-mcp-cli)
|
|
73
|
+
|
|
74
|
+
Local-connection knobs (defaults reproduce the recommended Custom/local launch):
|
|
75
|
+
FROID_LOOP_UNITY_MCP_LOCAL 1/true to pin Custom mode (default); 0/false
|
|
76
|
+
reverts to a bare cloud-config ``open``
|
|
77
|
+
FROID_LOOP_UNITY_MCP_URL local server URL (default: read from .mcp.json)
|
|
78
|
+
FROID_LOOP_UNITY_MCP_TOKEN bearer token (default empty — auth none)
|
|
79
|
+
FROID_LOOP_UNITY_MCP_TRANSPORT streamableHttp | stdio (default streamableHttp)
|
|
80
|
+
FROID_LOOP_UNITY_MCP_AUTH none | required (default none)
|
|
81
|
+
FROID_LOOP_UNITY_MCP_START_SERVER true | false (default true)
|
|
82
|
+
FROID_LOOP_UNITY_MCP_KEEP_CONNECTED true | false (default true)
|
|
83
|
+
|
|
84
|
+
Exit 0 = the worktree Editor is launching; non-zero = setup failed (the engine
|
|
85
|
+
defers the unit and notifies).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import json
|
|
91
|
+
import os
|
|
92
|
+
import shutil
|
|
93
|
+
import subprocess
|
|
94
|
+
import sys
|
|
95
|
+
import time
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
# how long to watch a freshly-launched Editor for an immediate crash before
|
|
99
|
+
# treating "still running" as a successful launch (the ready gate does the wait).
|
|
100
|
+
_LAUNCH_GRACE_SEC = 15
|
|
101
|
+
# the MCP server name setup-mcp writes into .mcp.json (IvanMurzak's agent config).
|
|
102
|
+
_MCP_SERVER_NAME = "ai-game-developer"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _truthy(value: str | None, default: bool) -> bool:
|
|
106
|
+
if value is None or value.strip() == "":
|
|
107
|
+
return default
|
|
108
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _detach_kwargs() -> dict[str, object]:
|
|
112
|
+
"""``Popen`` kwargs that detach the Editor so it outlives this hook. POSIX uses
|
|
113
|
+
``start_new_session``; Windows uses a new process group (not exercised yet)."""
|
|
114
|
+
if sys.platform == "win32":
|
|
115
|
+
# portability: start_new_session is POSIX-only; CREATE_NEW_PROCESS_GROUP
|
|
116
|
+
# is the Windows analogue.
|
|
117
|
+
return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
118
|
+
return {"start_new_session": True} # portability: POSIX detach kwarg; Windows branch above
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _worktree() -> Path | None:
|
|
122
|
+
wt = os.environ.get("FROID_LOOP_WORKTREE")
|
|
123
|
+
return Path(wt) if wt else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cli() -> str:
|
|
127
|
+
return os.environ.get("UNITY_MCP_CLI", "unity-mcp-cli")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _library_cache(worktree: Path) -> Path:
|
|
131
|
+
"""A persistent, per-worktree Library cache dir (keyed by worktree name).
|
|
132
|
+
|
|
133
|
+
Lives under the repo's gitignored .froid-loop/cache/ (init adds the ignore);
|
|
134
|
+
relocate with FROID_LOOP_UNITY_LIBRARY_CACHE (e.g. onto a faster disk)."""
|
|
135
|
+
override = os.environ.get("FROID_LOOP_UNITY_LIBRARY_CACHE")
|
|
136
|
+
if override:
|
|
137
|
+
root = Path(override)
|
|
138
|
+
else:
|
|
139
|
+
repo = Path(os.environ.get("FROID_LOOP_REPO_ROOT", worktree.parent))
|
|
140
|
+
root = repo / ".froid-loop" / "cache" / "unity" / "Library"
|
|
141
|
+
return root / worktree.name
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Top-level Library entries a primed copy must NOT carry into the worktree:
|
|
145
|
+
# the per-Editor identity file (would make the worktree Editor think another
|
|
146
|
+
# instance owns the project) plus locks/pids. Globs (*-lock, *.pid) are also
|
|
147
|
+
# stripped. The MCP server binary under mcp-server/ is intentionally kept (it is
|
|
148
|
+
# expensive to re-extract and is launched by port arg, not a stale on-disk config).
|
|
149
|
+
_LIBRARY_VOLATILE = ("EditorInstance.json",)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _seed_source() -> Path | None:
|
|
153
|
+
"""The warm Library to prime a worktree from. Defaults to ``<repo>/Library``
|
|
154
|
+
(the operator's main project Library); ``FROID_LOOP_UNITY_LIBRARY_SEED`` overrides
|
|
155
|
+
it, and an explicit empty value disables priming. Returns None if no non-empty
|
|
156
|
+
source is available (the caller then falls back to a symlinked empty cache)."""
|
|
157
|
+
override = os.environ.get("FROID_LOOP_UNITY_LIBRARY_SEED")
|
|
158
|
+
if override is not None:
|
|
159
|
+
override = override.strip()
|
|
160
|
+
if not override:
|
|
161
|
+
return None # explicitly disabled
|
|
162
|
+
src = Path(override)
|
|
163
|
+
else:
|
|
164
|
+
repo = os.environ.get("FROID_LOOP_REPO_ROOT", "").strip()
|
|
165
|
+
if not repo:
|
|
166
|
+
return None
|
|
167
|
+
src = Path(repo) / "Library"
|
|
168
|
+
try:
|
|
169
|
+
return src if src.is_dir() and any(src.iterdir()) else None
|
|
170
|
+
except OSError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _cp_library_tree(src: Path, dest: Path, *, reflink: bool) -> bool:
|
|
175
|
+
"""POSIX fast path: ``cp -a`` (with CoW reflink when asked). ``cp -a src/. dest``
|
|
176
|
+
copies the tree's contents (incl. dotfiles) into dest, creating dest if absent;
|
|
177
|
+
-a preserves perms/timestamps so caches stay valid. Returns True on success."""
|
|
178
|
+
reflink_arg = "--reflink=auto" if reflink else "--reflink=never"
|
|
179
|
+
proc = subprocess.run(
|
|
180
|
+
["cp", "-a", reflink_arg, f"{src}/.", str(dest)],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
)
|
|
184
|
+
if proc.returncode != 0:
|
|
185
|
+
sys.stderr.write(proc.stdout + proc.stderr)
|
|
186
|
+
kind = "reflink" if reflink else "copy"
|
|
187
|
+
print(f"unity_setup: Library prime ({kind}) failed; cleaning up", file=sys.stderr)
|
|
188
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
189
|
+
return False
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _copytree_library(src: Path, dest: Path) -> bool:
|
|
194
|
+
"""Portable fallback when ``cp`` is unavailable (Windows / no coreutils): a deep
|
|
195
|
+
``shutil.copytree``. No CoW — always a full copy — but functionally identical.
|
|
196
|
+
Returns True on success (a partial copy is cleaned up)."""
|
|
197
|
+
try:
|
|
198
|
+
shutil.copytree(src, dest, copy_function=shutil.copy2, dirs_exist_ok=True)
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
sys.stderr.write(f"{exc}\n")
|
|
201
|
+
print("unity_setup: Library prime (copytree) failed; cleaning up", file=sys.stderr)
|
|
202
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
203
|
+
return False
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _copy_library(src: Path, dest: Path, *, reflink: bool) -> bool:
|
|
208
|
+
"""Reflink (CoW) or deep-copy a warm Library into the worktree so Unity does an
|
|
209
|
+
incremental — not cold — import. ``reflink=auto`` is ~free on btrfs/xfs and
|
|
210
|
+
silently deep-copies elsewhere. Strips per-Editor identity/lock/pid files the
|
|
211
|
+
copy must not carry. Returns True on success (a partial copy is cleaned up)."""
|
|
212
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
# portability: keep the cp fast path on Linux only — its GNU coreutils cp is the
|
|
214
|
+
# one with --reflink CoW. macOS/BSD cp rejects --reflink (it would error and
|
|
215
|
+
# force a failed prime), so off Linux we deep-copy via shutil instead.
|
|
216
|
+
if sys.platform.startswith("linux") and shutil.which("cp"):
|
|
217
|
+
kind = "reflink" if reflink else "copy"
|
|
218
|
+
if not _cp_library_tree(src, dest, reflink=reflink):
|
|
219
|
+
return False
|
|
220
|
+
else:
|
|
221
|
+
kind = "copytree"
|
|
222
|
+
if not _copytree_library(src, dest):
|
|
223
|
+
return False
|
|
224
|
+
for victim in (
|
|
225
|
+
[dest / name for name in _LIBRARY_VOLATILE]
|
|
226
|
+
+ list(dest.glob("*-lock"))
|
|
227
|
+
+ list(dest.glob("*.pid"))
|
|
228
|
+
):
|
|
229
|
+
if victim.is_dir() and not victim.is_symlink():
|
|
230
|
+
shutil.rmtree(victim, ignore_errors=True)
|
|
231
|
+
else:
|
|
232
|
+
try:
|
|
233
|
+
victim.unlink()
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
print(f"unity_setup: Library primed from {src} ({kind})", file=sys.stderr)
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _link_library_cache(worktree: Path) -> None:
|
|
241
|
+
"""Fallback: point <worktree>/Library at an (initially empty) per-worktree cache
|
|
242
|
+
via symlink. Used only when priming is off or no warm source is available — the
|
|
243
|
+
first run is then cold (slow, and on a big project crash-prone), so priming is
|
|
244
|
+
preferred. The cache survives across runs to amortize re-runs of the same unit."""
|
|
245
|
+
link = worktree / "Library"
|
|
246
|
+
cache = _library_cache(worktree)
|
|
247
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
try:
|
|
249
|
+
link.symlink_to(cache, target_is_directory=True)
|
|
250
|
+
print(f"unity_setup: Library -> {cache} (symlink; cold first import)", file=sys.stderr)
|
|
251
|
+
except OSError as exc:
|
|
252
|
+
# portability: symlinks need privilege/Developer Mode on Windows. Fall back
|
|
253
|
+
# to a real per-worktree Library dir — it just won't share the cross-run
|
|
254
|
+
# cache (each run starts cold), which is correct, only slower.
|
|
255
|
+
link.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
print(
|
|
257
|
+
f"unity_setup: could not symlink Library -> {cache} ({exc}); using a real "
|
|
258
|
+
"per-worktree Library dir instead (cold first import, no cross-run cache)",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _prime_library(worktree: Path) -> None:
|
|
264
|
+
"""Give the worktree a warm Library so Unity imports incrementally, not cold.
|
|
265
|
+
|
|
266
|
+
Leaves an already-populated real Library untouched (a lone ``ScriptAssemblies``
|
|
267
|
+
dir counts as empty — a cold leftover — so we still prime). Drops a stale symlink
|
|
268
|
+
from the old symlink-mode setup. Then reflink/copies the warm seed Library in, or
|
|
269
|
+
falls back to a symlinked empty cache when priming is off or no seed exists."""
|
|
270
|
+
link = worktree / "Library"
|
|
271
|
+
if link.is_symlink():
|
|
272
|
+
link.unlink() # stale symlink from the old symlink-mode setup
|
|
273
|
+
elif link.exists():
|
|
274
|
+
try:
|
|
275
|
+
substantive = {p.name for p in link.iterdir()} - {"ScriptAssemblies"}
|
|
276
|
+
except OSError:
|
|
277
|
+
substantive = {"?"} # unreadable — assume real, don't clobber
|
|
278
|
+
if substantive:
|
|
279
|
+
return # a genuine Library is in place — never clobber the operator's tree
|
|
280
|
+
shutil.rmtree(link, ignore_errors=True) # cold leftover (ScriptAssemblies only)
|
|
281
|
+
|
|
282
|
+
mode = os.environ.get("FROID_LOOP_UNITY_LIBRARY_SEED_MODE", "reflink").strip().lower()
|
|
283
|
+
if mode != "off" and mode != "symlink":
|
|
284
|
+
src = _seed_source()
|
|
285
|
+
if src is not None and _copy_library(src, link, reflink=(mode != "copy")):
|
|
286
|
+
return
|
|
287
|
+
if src is None:
|
|
288
|
+
print(
|
|
289
|
+
"unity_setup: no warm Library to prime from "
|
|
290
|
+
"(set FROID_LOOP_UNITY_LIBRARY_SEED or run the main Editor once); "
|
|
291
|
+
"falling back to a symlinked empty cache — first import will be cold",
|
|
292
|
+
file=sys.stderr,
|
|
293
|
+
)
|
|
294
|
+
_link_library_cache(worktree)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _local_url(worktree: Path) -> str | None:
|
|
298
|
+
"""The worktree's local MCP server URL: an explicit override, else the URL
|
|
299
|
+
setup-mcp wrote into ``<worktree>/.mcp.json`` (its deterministic per-path
|
|
300
|
+
port). Returns None if neither is available — the caller then opens in the
|
|
301
|
+
project's persisted (cloud) mode rather than guessing a port."""
|
|
302
|
+
override = os.environ.get("FROID_LOOP_UNITY_MCP_URL")
|
|
303
|
+
if override and override.strip():
|
|
304
|
+
return override.strip()
|
|
305
|
+
cfg = worktree / ".mcp.json"
|
|
306
|
+
try:
|
|
307
|
+
data = json.loads(cfg.read_text(encoding="utf-8-sig"))
|
|
308
|
+
servers = data.get("mcpServers", {})
|
|
309
|
+
entry = servers.get(_MCP_SERVER_NAME) or next(iter(servers.values()), {})
|
|
310
|
+
url = entry.get("url")
|
|
311
|
+
return url.strip() if isinstance(url, str) and url.strip() else None
|
|
312
|
+
except (OSError, ValueError, AttributeError):
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _bootstrap_local(cli: str, worktree: Path, url: str) -> int:
|
|
317
|
+
"""Pin the Unity project to local ("Custom") mode so its Editor connects to
|
|
318
|
+
the per-worktree server, not the cloud. Idempotent; --token is mandatory but
|
|
319
|
+
unused under auth=none (the agent's .mcp.json is tokenless)."""
|
|
320
|
+
token = os.environ.get("FROID_LOOP_UNITY_MCP_TOKEN", "")
|
|
321
|
+
proc = subprocess.run(
|
|
322
|
+
[cli, "bootstrap-local", str(worktree), "--url", url, "--token", token],
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
)
|
|
326
|
+
if proc.returncode != 0:
|
|
327
|
+
sys.stderr.write(proc.stdout + proc.stderr)
|
|
328
|
+
print(
|
|
329
|
+
"unity_setup: bootstrap-local failed (Editor may open in cloud mode)", file=sys.stderr
|
|
330
|
+
)
|
|
331
|
+
return proc.returncode
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _open_command(cli: str, worktree: Path, url: str | None) -> list[str]:
|
|
335
|
+
"""The ``open`` argv. With a local URL we pass connection options so the
|
|
336
|
+
Editor opens in Custom mode and hosts the server itself; without one we fall
|
|
337
|
+
back to a bare open (the project's persisted/cloud config)."""
|
|
338
|
+
cmd = [cli, "open", str(worktree)]
|
|
339
|
+
if url is not None and _truthy(os.environ.get("FROID_LOOP_UNITY_MCP_LOCAL"), True):
|
|
340
|
+
transport = os.environ.get("FROID_LOOP_UNITY_MCP_TRANSPORT", "streamableHttp")
|
|
341
|
+
auth = os.environ.get("FROID_LOOP_UNITY_MCP_AUTH", "none")
|
|
342
|
+
start_server = (
|
|
343
|
+
"true" if _truthy(os.environ.get("FROID_LOOP_UNITY_MCP_START_SERVER"), True) else "false"
|
|
344
|
+
)
|
|
345
|
+
cmd += [
|
|
346
|
+
"--url",
|
|
347
|
+
url, # a loopback URL flips the Editor off cloud → Custom mode
|
|
348
|
+
"--transport",
|
|
349
|
+
transport,
|
|
350
|
+
"--auth",
|
|
351
|
+
auth,
|
|
352
|
+
"--start-server",
|
|
353
|
+
start_server, # Editor hosts the server (client-independent readiness)
|
|
354
|
+
]
|
|
355
|
+
token = os.environ.get("FROID_LOOP_UNITY_MCP_TOKEN", "")
|
|
356
|
+
if token:
|
|
357
|
+
cmd += ["--token", token]
|
|
358
|
+
if _truthy(os.environ.get("FROID_LOOP_UNITY_MCP_KEEP_CONNECTED"), True):
|
|
359
|
+
cmd += ["--keep-connected"] # bare flag: hold the bridge open before/after the client
|
|
360
|
+
editor = os.environ.get("FROID_LOOP_UNITY_PATH")
|
|
361
|
+
if editor:
|
|
362
|
+
cmd += ["--editor-path", editor]
|
|
363
|
+
return cmd
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# Where each setup-mcp agent writes its *project-local* MCP config, relative to the
|
|
367
|
+
# worktree (mirrors `unity-mcp-cli setup-mcp --list`). Agents whose config lives in
|
|
368
|
+
# the home dir (claude-desktop, antigravity, …) aren't worktree-scoped, so they
|
|
369
|
+
# can't be isolated per worktree and are intentionally absent — they're skipped
|
|
370
|
+
# (with a warning) by the isolation check rather than silently "verified".
|
|
371
|
+
_AGENT_CONFIG_PATHS = {
|
|
372
|
+
"claude-code": ".mcp.json",
|
|
373
|
+
"codex": ".codex/config.toml",
|
|
374
|
+
"gemini": ".gemini/settings.json",
|
|
375
|
+
"cursor": ".cursor/mcp.json",
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _engine_agent_ids() -> list[str]:
|
|
380
|
+
"""The setup-mcp agent ids whose worktree config must point at this Editor.
|
|
381
|
+
The engine injects FROID_LOOP_ENGINE_AGENTS (comma-separated dev+review agents);
|
|
382
|
+
FROID_LOOP_ENGINE_AGENT (singular) is honored as a legacy fallback. Defaults to
|
|
383
|
+
claude-code. Deduped, order-preserving."""
|
|
384
|
+
raw = os.environ.get("FROID_LOOP_ENGINE_AGENTS") or os.environ.get(
|
|
385
|
+
"FROID_LOOP_ENGINE_AGENT", "claude-code"
|
|
386
|
+
)
|
|
387
|
+
ids: list[str] = []
|
|
388
|
+
for token in raw.split(","):
|
|
389
|
+
agent = token.strip()
|
|
390
|
+
if agent and agent not in ids:
|
|
391
|
+
ids.append(agent)
|
|
392
|
+
return ids or ["claude-code"]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _run_setup_mcp(cli: str, agent: str, worktree: Path, url: str | None = None) -> int:
|
|
396
|
+
"""Write ``agent``'s MCP client config for ``worktree``. With ``url`` we force
|
|
397
|
+
that exact server URL; without it the CLI derives the deterministic per-path
|
|
398
|
+
port itself (same value for every agent on the same path)."""
|
|
399
|
+
cmd = [cli, "setup-mcp", agent, str(worktree)]
|
|
400
|
+
if url is not None:
|
|
401
|
+
cmd += ["--url", url]
|
|
402
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
403
|
+
if proc.returncode != 0:
|
|
404
|
+
sys.stderr.write(proc.stdout + proc.stderr)
|
|
405
|
+
print(f"unity_setup: setup-mcp {agent} failed", file=sys.stderr)
|
|
406
|
+
return proc.returncode
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _verify_agent_isolation(agent: str, worktree: Path, url: str) -> bool:
|
|
410
|
+
"""Assert ``agent``'s worktree config points at this worktree's Editor port,
|
|
411
|
+
not a leaked main-project port. Returns True on a confirmed match; False (the
|
|
412
|
+
caller then fails the hook so the unit is deferred before any work) when the
|
|
413
|
+
config is missing or carries the wrong port. Agents with no worktree-local
|
|
414
|
+
config path are not verifiable here — warn and pass them through."""
|
|
415
|
+
rel = _AGENT_CONFIG_PATHS.get(agent)
|
|
416
|
+
if rel is None:
|
|
417
|
+
print(
|
|
418
|
+
f"unity_setup: cannot verify per-worktree MCP isolation for agent "
|
|
419
|
+
f"{agent!r} (no worktree-local config path); skipping its check",
|
|
420
|
+
file=sys.stderr,
|
|
421
|
+
)
|
|
422
|
+
return True
|
|
423
|
+
# the deterministic port is the strongest format-agnostic invariant: if the
|
|
424
|
+
# config still pointed at the main project's Editor, this port would be absent.
|
|
425
|
+
port = url.rsplit(":", 1)[-1].strip("/")
|
|
426
|
+
cfg = worktree / rel
|
|
427
|
+
try:
|
|
428
|
+
text = cfg.read_text(encoding="utf-8-sig")
|
|
429
|
+
except (OSError, UnicodeDecodeError):
|
|
430
|
+
print(
|
|
431
|
+
f"unity_setup: agent {agent!r} MCP config {rel} missing or unreadable after setup; "
|
|
432
|
+
"cannot guarantee per-worktree isolation",
|
|
433
|
+
file=sys.stderr,
|
|
434
|
+
)
|
|
435
|
+
return False
|
|
436
|
+
if f":{port}" not in text:
|
|
437
|
+
print(
|
|
438
|
+
f"unity_setup: agent {agent!r} MCP config {rel} does not point at the "
|
|
439
|
+
f"worktree Editor (expected port {port}); refusing to run to avoid "
|
|
440
|
+
"leaking asset writes into the main checkout",
|
|
441
|
+
file=sys.stderr,
|
|
442
|
+
)
|
|
443
|
+
return False
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _setup_ivanmurzak(worktree: Path) -> int:
|
|
448
|
+
cli = _cli()
|
|
449
|
+
if shutil.which(cli) is None:
|
|
450
|
+
print(
|
|
451
|
+
f"unity_setup: {cli!r} not found on PATH; install the Unity-MCP CLI, set "
|
|
452
|
+
"UNITY_MCP_CLI, or override engine.worktree_setup_cmd",
|
|
453
|
+
file=sys.stderr,
|
|
454
|
+
)
|
|
455
|
+
return 2
|
|
456
|
+
|
|
457
|
+
# 1. worktree MCP client config (deterministic per-path port; no Editor needed).
|
|
458
|
+
# Always write claude-code's .mcp.json first as the canonical URL source
|
|
459
|
+
# (_local_url parses it), then derive the per-path URL from it.
|
|
460
|
+
rc = _run_setup_mcp(cli, "claude-code", worktree)
|
|
461
|
+
if rc != 0:
|
|
462
|
+
return rc
|
|
463
|
+
url = _local_url(worktree)
|
|
464
|
+
|
|
465
|
+
# 2. point EVERY worktree agent (dev + review may differ) at that URL so each
|
|
466
|
+
# talks to this worktree's Editor — not a main-project port leaked in via a
|
|
467
|
+
# seeded config. Without this, e.g. codex reads the seeded .codex/config.toml
|
|
468
|
+
# (main project's port) and its asset writes land in the main checkout.
|
|
469
|
+
agents = _engine_agent_ids()
|
|
470
|
+
for agent in agents:
|
|
471
|
+
if agent == "claude-code":
|
|
472
|
+
continue # already written in step 1
|
|
473
|
+
rc = _run_setup_mcp(cli, agent, worktree, url=url)
|
|
474
|
+
if rc != 0:
|
|
475
|
+
return rc
|
|
476
|
+
|
|
477
|
+
# 3. isolation guarantee: every agent's config must now point at this worktree's
|
|
478
|
+
# Editor port. Fail loud (engine defers the unit) rather than let an agent
|
|
479
|
+
# run against the wrong Editor and leak writes into the main checkout.
|
|
480
|
+
if url is not None:
|
|
481
|
+
for agent in agents:
|
|
482
|
+
if not _verify_agent_isolation(agent, worktree, url):
|
|
483
|
+
return 1
|
|
484
|
+
|
|
485
|
+
# 4. pin the project to local/Custom mode (so the Editor connects to the
|
|
486
|
+
# per-worktree server, not the cloud). Best effort: a failure here just
|
|
487
|
+
# means the Editor may open in cloud mode — the open below still passes the
|
|
488
|
+
# local --url, which is what actually flips it off cloud for this session.
|
|
489
|
+
if url is not None and _truthy(os.environ.get("FROID_LOOP_UNITY_MCP_LOCAL"), True):
|
|
490
|
+
_bootstrap_local(cli, worktree, url)
|
|
491
|
+
elif url is None:
|
|
492
|
+
print(
|
|
493
|
+
"unity_setup: could not derive a local MCP URL (no .mcp.json url / "
|
|
494
|
+
"FROID_LOOP_UNITY_MCP_URL); opening in the project's persisted mode",
|
|
495
|
+
file=sys.stderr,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# 5. launch the worktree's Editor, detached, and watch briefly for a crash.
|
|
499
|
+
cmd = _open_command(cli, worktree, url)
|
|
500
|
+
try:
|
|
501
|
+
proc = subprocess.Popen(
|
|
502
|
+
cmd,
|
|
503
|
+
stdout=subprocess.DEVNULL,
|
|
504
|
+
stderr=subprocess.DEVNULL,
|
|
505
|
+
**_detach_kwargs(), # detach so the Editor outlives this hook
|
|
506
|
+
)
|
|
507
|
+
except OSError as exc:
|
|
508
|
+
print(f"unity_setup: could not launch Editor: {exc}", file=sys.stderr)
|
|
509
|
+
return 1
|
|
510
|
+
deadline = time.monotonic() + _LAUNCH_GRACE_SEC
|
|
511
|
+
while time.monotonic() < deadline:
|
|
512
|
+
rc = proc.poll()
|
|
513
|
+
if rc is not None:
|
|
514
|
+
# `open` either daemonizes the Editor and exits 0 (fine), or failed.
|
|
515
|
+
if rc != 0:
|
|
516
|
+
print(f"unity_setup: 'open' exited {rc} during launch", file=sys.stderr)
|
|
517
|
+
return rc
|
|
518
|
+
time.sleep(1)
|
|
519
|
+
# still running after the grace window: an attached launch we leave detached;
|
|
520
|
+
# the readiness gate confirms the Editor + MCP actually came up.
|
|
521
|
+
print("unity_setup: Editor launching (readiness gate will confirm)", file=sys.stderr)
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def main() -> int:
|
|
526
|
+
worktree = _worktree()
|
|
527
|
+
if worktree is None:
|
|
528
|
+
print("unity_setup: FROID_LOOP_WORKTREE is not set", file=sys.stderr)
|
|
529
|
+
return 2
|
|
530
|
+
mcp = (os.environ.get("FROID_LOOP_ENGINE_MCP") or "ivanmurzak").strip().lower()
|
|
531
|
+
_prime_library(worktree)
|
|
532
|
+
if mcp == "ivanmurzak":
|
|
533
|
+
return _setup_ivanmurzak(worktree)
|
|
534
|
+
if mcp == "coplaydev":
|
|
535
|
+
print(
|
|
536
|
+
"unity_setup: per_worktree managed-launch is not wired for the CoplayDev "
|
|
537
|
+
"MCP (one shared :8080 server multiplexes Editors). Override "
|
|
538
|
+
"engine.worktree_setup_cmd with a CoplayDev launcher, or use editor_mode "
|
|
539
|
+
"= 'shared'.",
|
|
540
|
+
file=sys.stderr,
|
|
541
|
+
)
|
|
542
|
+
return 2
|
|
543
|
+
print(
|
|
544
|
+
f"unity_setup: unknown FROID_LOOP_ENGINE_MCP={mcp!r} (expected ivanmurzak|coplaydev)",
|
|
545
|
+
file=sys.stderr,
|
|
546
|
+
)
|
|
547
|
+
return 2
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
if __name__ == "__main__":
|
|
551
|
+
raise SystemExit(main())
|