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,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())