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,362 @@
1
+ #!/usr/bin/env python3
2
+ """per_worktree teardown for the froid-loop Unity engine plugin.
3
+
4
+ Runs once per unit when the worktree is finished — on success, on a deferral, and
5
+ on a pause/escalation — so the unit's managed Unity Editor never outlives its
6
+ worktree. Best effort: the engine logs a non-zero exit but the unit's outcome
7
+ stands (it does not re-defer a done/paused unit just because Editor-quit failed).
8
+
9
+ 1. Gracefully quit the worktree's Editor (`unity-mcp-cli close`, then --force).
10
+ 2. Fallback hard-kill: if an Editor *or its MCP server* whose argv references this
11
+ worktree is still alive afterwards, SIGTERM→SIGKILL it (Linux). ``close``
12
+ reports success even when it can't find the Editor — which happens precisely
13
+ when readiness failed and the Editor never registered with the MCP — so without
14
+ this a failed unit would leak a live Editor. The Unity plugin also spawns a
15
+ child ``gamedev-mcp-server`` (the local MCP HTTP server) that ``close`` does
16
+ NOT reap; a leaked server holds its port and poisons later runs (the plugin
17
+ declines to start a fresh server when a stale one lingers in the name-keyed
18
+ Library cache), so we sweep it up here too.
19
+ 3. Reap this unit's detect-only dialog probe (``unity_dialog_probe.py``, launched
20
+ per-worktree at setup): terminate it via its pid-file handle, with a ``/proc``
21
+ argv-scan backstop (matched on the worktree path + the probe script name, since
22
+ its exe basename is ``python``). Best effort — a probe we cannot reap self-exits
23
+ when the engine pid dies, so it never fails the unit.
24
+ 4. Drop the ``<worktree>/Library`` if setup left a *symlink* (the empty-cache
25
+ fallback), leaving the persistent cache it pointed at intact for the next run.
26
+ A real Library — the common case now that setup *primes* a warm reflink/CoW copy
27
+ in — is left untouched; it is removed cheaply when froid-loop deletes the worktree
28
+ (CoW-shared extents cost almost nothing to drop).
29
+
30
+ Verified against unity-mcp-cli v0.81.1 (`close <path>` keys off the project path).
31
+ Only the IvanMurzak MCP launches a managed per-worktree Editor, so only it is quit
32
+ here; for CoplayDev (shared :8080 server) override engine.worktree_teardown_cmd.
33
+
34
+ Env (injected by the engine):
35
+ FROID_LOOP_WORKTREE the worktree whose Editor to quit
36
+ FROID_LOOP_RUN_DIR run dir holding the dialog-probe pid-file handle
37
+ FROID_LOOP_ENGINE_MCP ivanmurzak | coplaydev (default ivanmurzak)
38
+ UNITY_MCP_CLI IvanMurzak CLI binary (default unity-mcp-cli)
39
+ FROID_LOOP_UNITY_CLOSE_TIMEOUT polite-quit seconds before --force (default 30)
40
+
41
+ Exit 0 = Editor quit + symlink dropped; non-zero = something failed (logged).
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import shutil
48
+ import subprocess
49
+ import sys
50
+ import time
51
+ from pathlib import Path
52
+
53
+ from froid_loop.process_host import get_process_host
54
+
55
+
56
+ def _psutil():
57
+ """Lazily import psutil — a core dep on Windows, the ``non-linux`` extra on
58
+ macOS — used only for non-Linux process discovery (macOS and Windows). The
59
+ dep-free core never imports it; raise a clear, actionable error if it's missing
60
+ on a platform that needs it."""
61
+ try:
62
+ import psutil # intentional lazy import — keeps the core dep-free
63
+ except ImportError as exc: # pragma: no cover - exercised only off Linux
64
+ raise RuntimeError(
65
+ f"unity_teardown: process discovery on {sys.platform!r} needs psutil; "
66
+ "on Windows reinstall froid-loop (psutil is a required dependency there), "
67
+ "on macOS run `pip install 'froid-loop[non-linux]'`"
68
+ ) from exc
69
+ return psutil
70
+
71
+
72
+ def _worktree() -> Path | None:
73
+ wt = os.environ.get("FROID_LOOP_WORKTREE")
74
+ return Path(wt) if wt else None
75
+
76
+
77
+ def _run_dir() -> Path | None:
78
+ rd = os.environ.get("FROID_LOOP_RUN_DIR")
79
+ return Path(rd) if rd else None
80
+
81
+
82
+ # Process basenames we reap when bound to the worktree path: the Unity Editor
83
+ # binary and the local MCP HTTP server the plugin spawns as a child.
84
+ _TARGET_BASENAMES = ("unity", "gamedev-mcp-server")
85
+
86
+ # The detect-only dialog probe's pid-file handle + script basename. The probe's exe
87
+ # basename is `python`, so it is INVISIBLE to the _TARGET_BASENAMES editor sweep (we
88
+ # deliberately do not loosen those — a loosened editor match could kill the wrong
89
+ # python); it gets its own argv-based matcher below.
90
+ _DIALOG_PROBE_PID_FILE = "unity-dialog-probe.pid"
91
+ _DIALOG_PROBE_SCRIPT_BASENAME = "unity_dialog_probe.py"
92
+
93
+
94
+ def _exe_basename(entry: Path) -> str:
95
+ try:
96
+ return os.path.basename(os.readlink(entry / "exe")).lower()
97
+ except OSError:
98
+ return ""
99
+
100
+
101
+ def _lingering_pids(worktree: Path) -> list[int]:
102
+ """PIDs of the Unity *Editor* or its *MCP server* bound to this worktree.
103
+
104
+ Tight on purpose: the process must (a) reference this exact worktree path in
105
+ argv — Unity gets ``-projectPath <path>`` and the server's binary lives under
106
+ ``<worktree>/Library/mcp-server/`` — and (b) have an executable basename of
107
+ exactly ``unity`` (the Editor) or ``gamedev-mcp-server`` (the MCP server). That
108
+ excludes the launcher shell, ``unity-mcp-cli``/node, python, greps, and the
109
+ operator's Editor/server on any other project, so we never kill the wrong one.
110
+
111
+ Linux uses the zero-dependency ``/proc`` fast path; other platforms (no /proc)
112
+ fall back to the same scan over psutil (the optional ``non-linux`` extra)."""
113
+ if sys.platform.startswith("linux"):
114
+ return _lingering_pids_proc(worktree)
115
+ return _lingering_pids_psutil(worktree)
116
+
117
+
118
+ def _lingering_pids_proc(worktree: Path) -> list[int]:
119
+ """Linux fast path: scan ``/proc`` for the worktree-bound Editor/server."""
120
+ needle = str(worktree)
121
+ pids: list[int] = []
122
+ for entry in Path("/proc").iterdir(): # portability: Linux-only /proc scan, guarded above
123
+ if not entry.name.isdigit():
124
+ continue
125
+ try:
126
+ argv = (
127
+ (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "replace")
128
+ )
129
+ except OSError:
130
+ continue # process gone or unreadable
131
+ if needle not in argv:
132
+ continue
133
+ argv0 = os.path.basename(argv.split(" ", 1)[0]).lower() if argv.strip() else ""
134
+ if _exe_basename(entry) in _TARGET_BASENAMES or argv0 in _TARGET_BASENAMES:
135
+ pids.append(int(entry.name))
136
+ return pids
137
+
138
+
139
+ def _lingering_pids_psutil(worktree: Path) -> list[int]:
140
+ """Non-Linux equivalent of the ``/proc`` scan via psutil (no /proc available).
141
+ Same tight match — argv references this worktree AND the process basename is a
142
+ target — with the basename's extension stripped so Windows' ``Unity.exe`` /
143
+ ``gamedev-mcp-server.exe`` match ``_TARGET_BASENAMES``. Not exercised on Linux."""
144
+ psutil = _psutil()
145
+ needle = str(worktree)
146
+ pids: list[int] = []
147
+ for proc in psutil.process_iter(["pid", "name", "exe", "cmdline"]):
148
+ try:
149
+ cmdline = proc.info.get("cmdline") or []
150
+ if needle not in " ".join(cmdline):
151
+ continue
152
+ exe = proc.info.get("exe") or ""
153
+ name = proc.info.get("name") or ""
154
+ argv0 = os.path.basename(cmdline[0]).lower() if cmdline else ""
155
+ exe_base = os.path.splitext(os.path.basename(exe))[0].lower() if exe else ""
156
+ name_base = os.path.splitext(name)[0].lower()
157
+ if (
158
+ exe_base in _TARGET_BASENAMES
159
+ or argv0 in _TARGET_BASENAMES
160
+ or name_base in _TARGET_BASENAMES
161
+ ):
162
+ pids.append(int(proc.info["pid"]))
163
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
164
+ continue # process vanished or unreadable mid-scan
165
+ return pids
166
+
167
+
168
+ def _terminate_identity_guarded(pids: list[int]) -> None:
169
+ """SIGTERM → grace → identity-checked SIGKILL a confirmed pid list. Snapshots
170
+ each pid's identity *before* signalling so the force-kill escalation can never
171
+ land on a pid the kernel recycled to an unrelated process (mirrors runs.stop_run's
172
+ guard; force_kill's own contract requires confirmed identity). Shared by the
173
+ Editor/MCP sweep and the dialog-probe reap."""
174
+ host = get_process_host()
175
+ ident = {pid: host.identity(pid) for pid in pids}
176
+ for pid in pids:
177
+ try:
178
+ host.terminate(pid)
179
+ except OSError:
180
+ pass
181
+ # give them a few seconds to exit politely, then hard-kill survivors
182
+ for _ in range(20):
183
+ if not any(host.is_alive(p) for p in pids):
184
+ break
185
+ time.sleep(0.5)
186
+ for pid in pids:
187
+ if not host.is_alive(pid):
188
+ continue
189
+ if ident[pid] is None or host.identity(pid) != ident[pid]:
190
+ print(
191
+ f"unity_teardown: pid {pid} survived terminate but its identity is "
192
+ "missing or changed since teardown began; skipping force-kill to "
193
+ "avoid hitting a reused pid",
194
+ file=sys.stderr,
195
+ )
196
+ continue
197
+ try:
198
+ host.force_kill(pid)
199
+ except OSError:
200
+ pass
201
+
202
+
203
+ def _force_kill_lingering(worktree: Path) -> int:
204
+ """Best-effort SIGTERM→SIGKILL of any Editor or MCP server left running for this
205
+ worktree after ``close``. Returns the number of processes targeted."""
206
+ pids = _lingering_pids(worktree)
207
+ # exclude ourselves just in case (our own argv has the worktree path too)
208
+ pids = [p for p in pids if p != os.getpid()]
209
+ if not pids:
210
+ return 0
211
+ print(
212
+ f"unity_teardown: 'close' left {len(pids)} Unity process(es) for {worktree} "
213
+ f"running ({pids}); hard-killing",
214
+ file=sys.stderr,
215
+ )
216
+ _terminate_identity_guarded(pids)
217
+ return len(pids)
218
+
219
+
220
+ def _probe_argv_matches(argv: str, worktree: Path) -> bool:
221
+ """True when ``argv`` is THIS worktree's detached dialog probe: it references both
222
+ the worktree path and the probe script basename. Scoped to the worktree so we
223
+ never touch another unit's probe, and keyed on the script name because the exe
224
+ basename is ``python`` (not a target editor binary)."""
225
+ return _DIALOG_PROBE_SCRIPT_BASENAME in argv and str(worktree) in argv
226
+
227
+
228
+ def _lingering_probe_pids(worktree: Path) -> list[int]:
229
+ """``/proc`` scan for the worktree's detached dialog probe (python argv match).
230
+ Backstop for the pid-file reap handle. Linux-only — the probe is X11/Linux-only,
231
+ and off-Linux the pid file is the sole handle (returns [] here)."""
232
+ if not sys.platform.startswith("linux"):
233
+ return []
234
+ pids: list[int] = []
235
+ for entry in Path("/proc").iterdir(): # portability: Linux-only /proc scan, guarded above
236
+ if not entry.name.isdigit():
237
+ continue
238
+ try:
239
+ argv = (
240
+ (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "replace")
241
+ )
242
+ except OSError:
243
+ continue # process gone or unreadable
244
+ if _probe_argv_matches(argv, worktree):
245
+ pids.append(int(entry.name))
246
+ return pids
247
+
248
+
249
+ def _probe_pidfile_pids(run_dir: Path | None) -> list[int]:
250
+ """The dialog probe recorded in ``<run_dir>/unity-dialog-probe.pid``, iff it is
251
+ still alive AND still our probe (identity-checked). The primary reap handle."""
252
+ if run_dir is None:
253
+ return []
254
+ from froid_loop.runs import read_named_pid_identity # lazy import
255
+
256
+ pid, identity = read_named_pid_identity(run_dir / _DIALOG_PROBE_PID_FILE)
257
+ if pid is None:
258
+ return []
259
+ return [pid] if get_process_host().alive_and_ours(pid, identity) else []
260
+
261
+
262
+ def _reap_dialog_probe(worktree: Path, run_dir: Path | None) -> int:
263
+ """Terminate this unit's detect-only dialog probe. Primary handle is the pid file
264
+ (``<run_dir>/unity-dialog-probe.pid``); the ``/proc`` argv scan is a backstop for a
265
+ probe whose pid file is missing/stale. Identity-guarded escalation, same as the
266
+ Editor sweep. Best effort — never changes the unit outcome (a survivor self-exits
267
+ when the engine pid dies). Returns the count targeted."""
268
+ pids = set(_probe_pidfile_pids(run_dir)) | set(_lingering_probe_pids(worktree))
269
+ pids.discard(os.getpid())
270
+ if not pids:
271
+ return 0
272
+ print(
273
+ f"unity_teardown: reaping {len(pids)} dialog-probe process(es) {sorted(pids)}",
274
+ file=sys.stderr,
275
+ )
276
+ _terminate_identity_guarded(sorted(pids))
277
+ if run_dir is not None:
278
+ try:
279
+ (run_dir / _DIALOG_PROBE_PID_FILE).unlink()
280
+ except OSError:
281
+ pass
282
+ return len(pids)
283
+
284
+
285
+ def _cli() -> str:
286
+ return os.environ.get("UNITY_MCP_CLI", "unity-mcp-cli")
287
+
288
+
289
+ def _drop_library_symlink(worktree: Path) -> None:
290
+ link = worktree / "Library"
291
+ if link.is_symlink():
292
+ try:
293
+ link.unlink()
294
+ print("unity_teardown: dropped Library symlink", file=sys.stderr)
295
+ except OSError as exc: # best effort
296
+ print(f"unity_teardown: could not drop Library symlink: {exc}", file=sys.stderr)
297
+
298
+
299
+ def _close_ivanmurzak(worktree: Path) -> int:
300
+ cli = _cli()
301
+ if shutil.which(cli) is None:
302
+ # nothing to quit against; not fatal — just drop the symlink below.
303
+ print(f"unity_teardown: {cli!r} not on PATH; skipping Editor quit", file=sys.stderr)
304
+ return 0
305
+ timeout = os.environ.get("FROID_LOOP_UNITY_CLOSE_TIMEOUT", "30")
306
+ proc = subprocess.run(
307
+ [cli, "close", str(worktree), "--timeout", timeout, "--force"],
308
+ capture_output=True,
309
+ text=True,
310
+ )
311
+ if proc.returncode != 0:
312
+ sys.stderr.write(proc.stdout + proc.stderr)
313
+ print(f"unity_teardown: 'close' exited {proc.returncode}", file=sys.stderr)
314
+ return proc.returncode
315
+
316
+
317
+ def main() -> int:
318
+ worktree = _worktree()
319
+ if worktree is None:
320
+ print("unity_teardown: FROID_LOOP_WORKTREE is not set", file=sys.stderr)
321
+ return 2
322
+ mcp = (os.environ.get("FROID_LOOP_ENGINE_MCP") or "ivanmurzak").strip().lower()
323
+ rc = 0
324
+ if mcp == "ivanmurzak":
325
+ rc = _close_ivanmurzak(worktree)
326
+ elif mcp == "coplaydev":
327
+ print(
328
+ "unity_teardown: CoplayDev per_worktree teardown is not wired; override "
329
+ "engine.worktree_teardown_cmd if you launched a managed Editor for it.",
330
+ file=sys.stderr,
331
+ )
332
+ else:
333
+ print(
334
+ f"unity_teardown: unknown FROID_LOOP_ENGINE_MCP={mcp!r} (expected ivanmurzak|coplaydev)",
335
+ file=sys.stderr,
336
+ )
337
+ rc = 2
338
+ # close reports success even when it can't find the Editor (e.g. readiness
339
+ # failed so it never registered) and never reaps the plugin's child MCP server
340
+ # — sweep up any Editor or gamedev-mcp-server still bound to this worktree so a
341
+ # failed unit never leaks a live process (a leaked server poisons later runs).
342
+ # Only a SURVIVING process (couldn't be killed) is a teardown failure; a leak we
343
+ # successfully reaped is still a clean teardown.
344
+ if _force_kill_lingering(worktree):
345
+ survivors = _lingering_pids(worktree)
346
+ if survivors:
347
+ print(
348
+ f"unity_teardown: {len(survivors)} Editor/server process(es) survived kill: "
349
+ f"{survivors}",
350
+ file=sys.stderr,
351
+ )
352
+ rc = rc or 1
353
+ # Reap this unit's detect-only dialog probe (pid-file handle + argv backstop).
354
+ # Best effort — a probe we cannot reap self-exits when the engine pid dies, so it
355
+ # never fails the unit (unlike a surviving Editor/server, which poisons later runs).
356
+ _reap_dialog_probe(worktree, _run_dir())
357
+ _drop_library_symlink(worktree)
358
+ return rc
359
+
360
+
361
+ if __name__ == "__main__":
362
+ raise SystemExit(main())
@@ -0,0 +1,52 @@
1
+ # Google Antigravity CLI (`agy`). `-i` runs the slash command as the initial prompt
2
+ # and stays interactive (a bare `-p` would run headless and exit). `Stop` is agy's
3
+ # turn-complete event, mapped to canonical Stop — agy fires no SessionStart/SessionEnd
4
+ # hooks, so only Stop is registered. Skills live in .agents/skills/, hooks in
5
+ # .agents/hooks.json. Verified against agy 1.1.3.
6
+ #
7
+ # usage_parser is "none" DELIBERATELY AND PERMANENTLY — not a deferral. agy's
8
+ # transcript (~/.gemini/antigravity-cli/brain/<conversationId>/.system_generated/
9
+ # logs/transcript_full.jsonl, the path its Stop hook reports as `transcriptPath`)
10
+ # carries only step_index/source/type/status/created_at/content/thinking. There is
11
+ # no usage block anywhere in it — verified by capturing a live turn and scanning
12
+ # every key recursively. agy does count tokens (the TUI shows them), but only
13
+ # inside conversations/<conversationId>.db, a SQLite store whose gen_metadata is
14
+ # an undocumented protobuf blob — unreadable through the JSONL-shaped
15
+ # `(transcript_path) -> TokenUsage` parser contract, and fragile against an
16
+ # internal schema. Runs work; the token columns stay empty. Revisit only if agy
17
+ # starts emitting usage in the transcript.
18
+ #
19
+ # Hook payloads are protojson: keys are camelCase (conversationId, transcriptPath,
20
+ # workspacePaths) — NOT the snake_case claude/codex use. agy sends no `cwd`; the
21
+ # relay reads workspacePaths[0] instead. Hook `timeout` is in SECONDS (default 30);
22
+ # handlers for Stop are a FLAT list with no matcher wrapper, and the file is keyed
23
+ # by hook-GROUP name at the top level with no "hooks" wrapper (see install.py).
24
+ # A hook's cwd is the dir holding hooks.json (.agents/), not the project root —
25
+ # the relay is registered by absolute path, so this never matters.
26
+ #
27
+ # TRUST (verified empirically against 1.1.3, 3-run matrix): agy gates every
28
+ # workspace on settings.json `trustedWorkspaces` and blocks on an interactive
29
+ # "Do you trust the contents of this project?" dialog until answered.
30
+ # `--dangerously-skip-permissions` does NOT bypass it — that flag covers tool
31
+ # permissions only. Matching is EXACT-PATH: trusting /tmp does not cover
32
+ # /tmp/foo. Consequences: `isolation = "none"` (the default) works once the
33
+ # project root is trusted; `isolation = "worktree"` does NOT — each worktree is
34
+ # a fresh path under .froid-loop/runs/, so every session would hang on the dialog.
35
+ name = "antigravity"
36
+ binary = "agy"
37
+ prompt_template = "{prompt}"
38
+ launch_args = ["-i"]
39
+ bypass_args = ["--dangerously-skip-permissions"]
40
+ model_flag = "--model"
41
+ usage_parser = "none"
42
+ first_run_note = "run `agy` once in the project and authenticate + answer 'Yes, I trust this folder' before `froid-loop run` — spawned sessions can't answer first-run dialogs, and a pending one reads as a session timeout. Requires Antigravity CLI (agy >= 1.1.3). Trust is exact-path (settings.json trustedWorkspaces): trusting a parent does NOT cover subdirectories, and --dangerously-skip-permissions does not bypass it — so antigravity requires isolation = \"none\" (the default); isolation = \"worktree\" will hang, as each worktree is an untrusted path."
43
+ skill_tree = ".agents/skills"
44
+ # .agents/hooks.json is agy's lifecycle-hook config. Seeded first, then the Stop
45
+ # relay is merged in, so a worktree checkout keeps any user/plugin hook groups
46
+ # already present instead of starting from an empty file.
47
+ seed_files = [".agents/hooks.json"]
48
+
49
+ [hooks]
50
+ dialect = "antigravity-hooks-json"
51
+ config_path = ".agents/hooks.json"
52
+ events = { Stop = "Stop" }
@@ -0,0 +1,85 @@
1
+ # Claude Code. Reference profile for the claude-settings-json hook dialect.
2
+ name = "claude"
3
+ binary = "claude"
4
+ prompt_template = "{prompt}"
5
+ bypass_args = ["--permission-mode", "bypassPermissions"]
6
+ model_flag = "--model"
7
+ usage_parser = "claude-jsonl"
8
+ first_run_note = "run `claude` once in the project to accept workspace trust and hooks approval"
9
+ skill_tree = ".claude/skills"
10
+ # Gitignored configs a worktree checkout omits: project MCP servers (.mcp.json)
11
+ # and Claude settings. .claude/settings.json is also the hook config_path — it is
12
+ # seeded first, then the Stop hook is merged into it (see provision_worktree).
13
+ seed_files = [
14
+ ".mcp.json",
15
+ ".claude/settings.json",
16
+ ".claude/settings.local.json",
17
+ ]
18
+
19
+ # Transport-failure classification (#194, #507, #323): regexes matched
20
+ # line-by-line against the ANSI-stripped 64 KiB tail of a non-completed
21
+ # (timeout/stalled/crashed) session's pane log, once the verdict has settled. A
22
+ # match reclassifies the session as an environment fault so the orchestrator can
23
+ # PAUSE (and re-arm with a fresh budget) instead of charging a dev attempt for a
24
+ # session that never got usable work out of the API.
25
+ #
26
+ # The scanned file is a tmux pane capture, so it carries THE MODEL'S OWN OUTPUT:
27
+ # an assertion, an acceptance criterion or a doc bullet that merely writes ABOUT
28
+ # a provider error lands in the scanned bytes. So every alternative below
29
+ # reproduces one COMPLETE Claude Code error sentence rather than a framing token
30
+ # plus a loose cause — a paraphrase cannot reach it. Every one is a captured line:
31
+ # * `API Error` … `Unable to connect to API` — #194's evidence, run
32
+ # 20260718-191419-44e7 on this adapter.
33
+ # * `API Error` … `Connection closed mid-response`, and the 5xx pair
34
+ # (`529 Overloaded` / `500 Internal server error`) — Claude Code transcript
35
+ # entries structurally tagged "isApiErrorMessage": true.
36
+ #
37
+ # The 5xx statuses are ENUMERATED, not ranged. A `5[0-9][0-9]` range admits 100
38
+ # codes against either reason phrase when exactly two pairings were ever
39
+ # captured, so an unverified string — `API Error` … `503 Internal server error`,
40
+ # which is what a story writing an HTTP error path prints — would pause the run.
41
+ # Same evidentiary bar as the quota note below: seed the captured pairing, never
42
+ # the plausible one.
43
+ #
44
+ # The single-character classes (`t[o]`, `respons[e]`, `5[2]9`, `5[0]0`) are
45
+ # load-bearing, not typos: each makes its pattern fail to match its OWN line
46
+ # here, so a session that prints or diffs this profile does not read its own
47
+ # configuration as a provider outage (#507). Pinned by
48
+ # test_shipped_patterns_do_not_match_their_own_profile_line.
49
+ #
50
+ # No usage-limit / quota cause is seeded: no captured Claude Code quota line
51
+ # exists, and on a pane capture that vocabulary is what a story IMPLEMENTING
52
+ # rate limiting prints all day. Seed one only from a captured line, citing the
53
+ # run it came from — the bar test_unseeded_profiles_stay_inert states.
54
+ #
55
+ # To disable or extend, copy this profile into .froid-loop/profiles/ and edit.
56
+ env_fault_patterns = [
57
+ "API Error: Unable to connect t[o] API",
58
+ "API Error: Connection closed mid-respons[e]",
59
+ "API Error: (5[2]9 Overloaded|5[0]0 Internal server error)",
60
+ ]
61
+
62
+ # Force Claude Code's classic (inline/scrollback) renderer. The fullscreen TUI
63
+ # (research preview, alt-screen ?1049h like vim/htop) repaints in place, so the
64
+ # tmux pane capture only ever holds the final frame and the Log tab shows a single
65
+ # page. The classic renderer keeps output in native scrollback, so the capture
66
+ # scrolls normally and the Log tab reconstructs the whole session. This env var
67
+ # overrides any `tui` setting (user/project/local/seeded) per Claude Code docs.
68
+ #
69
+ # Disable background tasks (issue #109): Claude Code can bias a session toward
70
+ # backgrounding Agent/bash work (run_in_background) despite the froid-dev-auto
71
+ # prompt ban on it. A session that ends its turn to await a background sub-agent
72
+ # yields a result-less Stop — and if the CLI then exits, the in-process sub-agent
73
+ # is stranded mid-implementation with the story stuck in-progress. The var forces
74
+ # every subagent and bash call to run synchronously, which is the behavior the
75
+ # skill contract already requires; froid-loop has no background-completion
76
+ # re-invocation, so nothing sanctioned is lost. To opt out, copy this profile
77
+ # into .froid-loop/profiles/ and drop the var.
78
+ [env]
79
+ CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1"
80
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS = "1"
81
+
82
+ [hooks]
83
+ dialect = "claude-settings-json"
84
+ config_path = ".claude/settings.json"
85
+ events = { SessionStart = "SessionStart", Stop = "Stop", SessionEnd = "SessionEnd", PreCompact = "PreCompact" }
@@ -0,0 +1,22 @@
1
+ # OpenAI Codex CLI (>= 0.139). Slash commands are NOT expanded when passed as
2
+ # the initial prompt argument; the $skill-name mention syntax attaches the
3
+ # skill instead (skills live in .agents/skills/) — live-verified 2026-06-11.
4
+ # "use subagents as needed" is required or Codex runs the skill's parallel
5
+ # phases (e.g. review layers) without spawning subagents. Codex has no
6
+ # SessionEnd hook event — the adapter's window-death fallback covers crashes.
7
+ name = "codex"
8
+ binary = "codex"
9
+ prompt_template = "Use the ${skill} skill now, and use subagents as needed: {args}"
10
+ bypass_args = ["--dangerously-bypass-approvals-and-sandbox"]
11
+ model_flag = "--model"
12
+ usage_parser = "codex-rollout"
13
+ first_run_note = "run `codex` once in the project and accept BOTH prompts: workspace trust, then 'Hooks need review' -> 'Trust all and continue' (untrusted hooks silently never fire); requires Codex >= 0.139"
14
+ skill_tree = ".agents/skills"
15
+ # Project-scoped Codex config (MCP servers under [mcp_servers]) — gitignored, so
16
+ # a worktree checkout omits it and isolated sessions lose their MCP servers.
17
+ seed_files = [".codex/config.toml"]
18
+
19
+ [hooks]
20
+ dialect = "codex-hooks-json"
21
+ config_path = ".codex/hooks.json"
22
+ events = { SessionStart = "SessionStart", Stop = "Stop" }
@@ -0,0 +1,52 @@
1
+ # GitHub Copilot CLI (GA since 2026-02). `-i "<prompt>"` starts an interactive
2
+ # session and auto-runs the prompt (a bare `-p` prompt runs headless and exits).
3
+ # Copilot has no native skill discovery, so the prompt tells it to read the
4
+ # SKILL.md directly (skills live in .agents/skills/, shared with codex/gemini and
5
+ # matching upstream Froid Plane's github-copilot installer). "use subagents as
6
+ # needed" keeps parallel skill phases (e.g. review layers) actually spawning
7
+ # subagents, same as codex.
8
+ #
9
+ # Hook events (verified against Copilot CLI 1.0.63 via `froid-loop probe-adapter
10
+ # copilot --probe`): the turn-end event is `agentStop` — PascalCase `Stop` does
11
+ # NOT fire on this build, so it must NOT be registered (it yields no completion
12
+ # signal and every session reads as a timeout). `sessionStart`/`sessionEnd` cover
13
+ # session lifecycle; Copilot has no PreCompact equivalent. Payload keys are
14
+ # camelCase (sessionId, transcriptPath, cwd) regardless of event-name casing, and
15
+ # the `agentStop` payload carries `transcriptPath` + `stopReason: end_turn` — the
16
+ # shared relay reads both camelCase and snake_case, so the usage_parser gets the
17
+ # transcript. CAVEAT: single build/OS (1.0.63, macOS); a future build may alias
18
+ # `Stop`. NOTE: an enterprise policy permissions.disableBypassPermissionsMode =
19
+ # 'disable' suppresses the --allow-all-* flags and will block unattended runs.
20
+ name = "copilot"
21
+ binary = "copilot"
22
+ prompt_template = "LOAD the FULL .agents/skills/{skill}/SKILL.md, read its entire contents and follow its directions exactly, using subagents as needed: {args}"
23
+ launch_args = ["-i"]
24
+ bypass_args = ["--allow-all-tools", "--allow-all-paths"]
25
+ model_flag = "--model"
26
+ usage_parser = "copilot-events"
27
+ # Copilot writes its token totals (modelMetrics) only in the trailing
28
+ # `session.shutdown` events.jsonl line, ~1s AFTER the agentStop hook fires — so
29
+ # read usage on a short grace poll, not the instant the turn ends, or it samples
30
+ # the transcript before the totals land (reads as 0/None).
31
+ usage_grace_s = 8.0
32
+ # agentStop fires PER response turn, so a parallel-subagent review (e.g. the
33
+ # froid-dev-auto step-04 hunter layers) ends several turns; the global default of
34
+ # 1 nudge would declare it stalled on the 2nd Stop. 5 lets a multi-turn review finish.
35
+ stop_without_result_nudges = 5
36
+ # agentStop also fires for each SUBAGENT turn — with an empty transcriptPath and a
37
+ # tool-use session id (toolu_…), not the main session's id. Those are not the main
38
+ # turn-end: ignore a Stop with no transcript so a subagent's premature Stop is not
39
+ # read as a (result-less) completion. Without this the dev stage (0 nudges) stalls
40
+ # outright on the first subagent Stop, before froid-dev-auto writes its terminal spec.
41
+ subagent_stop_without_transcript = true
42
+ first_run_note = "run `copilot` once and authenticate (gh / Copilot subscription); requires Copilot CLI GA (>= 2026-02)"
43
+ skill_tree = ".agents/skills"
44
+ # .github/copilot/settings.json is the inline hook config (and can also hold MCP
45
+ # servers) — gitignored in many projects, so a worktree checkout omits it and
46
+ # isolated sessions lose it; seeded first, then the agentStop hook is merged in.
47
+ seed_files = [".github/copilot/settings.json"]
48
+
49
+ [hooks]
50
+ dialect = "copilot-settings-json"
51
+ config_path = ".github/copilot/settings.json"
52
+ events = { agentStop = "Stop", sessionStart = "SessionStart", sessionEnd = "SessionEnd" }
@@ -0,0 +1,26 @@
1
+ # Google Gemini CLI (>= 0.46, hooks on by default). -i keeps the session
2
+ # interactive with the slash command as the initial prompt (a positional
3
+ # prompt would force headless mode). AfterAgent is the turn-complete event,
4
+ # mapped to canonical Stop. Custom commands live in .gemini/commands/, skills
5
+ # in .agents/skills/.
6
+ name = "gemini"
7
+ binary = "gemini"
8
+ prompt_template = "{prompt}"
9
+ launch_args = ["-i"]
10
+ bypass_args = ["--approval-mode=yolo"]
11
+ model_flag = "--model"
12
+ usage_parser = "gemini-chat"
13
+ first_run_note = "authenticate once (browser OAuth or GEMINI_API_KEY); requires Gemini CLI >= 0.46"
14
+ skill_tree = ".agents/skills"
15
+ # .gemini/settings.json holds project MCP servers (mcpServers) AND is the hook
16
+ # config_path: it is seeded first, then the Stop hook is merged in, so a worktree
17
+ # keeps the project's MCP servers instead of starting from an empty settings file.
18
+ seed_files = [".gemini/settings.json"]
19
+
20
+ [env]
21
+ GEMINI_CLI_TRUST_WORKSPACE = "true"
22
+
23
+ [hooks]
24
+ dialect = "gemini-settings-json"
25
+ config_path = ".gemini/settings.json"
26
+ events = { SessionStart = "SessionStart", AfterAgent = "Stop", SessionEnd = "SessionEnd" }
@@ -0,0 +1,54 @@
1
+ # OpenCode (opencode.ai) driven over its local HTTP server (`opencode serve`) —
2
+ # hookless: the adapter owns the server process and observes turn completion via
3
+ # SSE + polling, so no hook scripts or config merges are ever written.
4
+ # API facts pinned against opencode 1.18.2 (2026-07-16): see the adapters/opencode_http.py docstring.
5
+ name = "opencode-http"
6
+ binary = "opencode"
7
+ # Driven by the bundled HTTP/SSE adapter class (adapters/registry.py), not the
8
+ # tmux generic adapter — the selector for the coding-CLI adapter axis. Distinct
9
+ # from `hooks.dialect = "none"` below, which says only that no hook config is
10
+ # ever registered: the two axes are decoupled.
11
+ adapter = "opencode-http"
12
+ prompt_template = "Use the {skill} skill now: {args}"
13
+ usage_parser = "none"
14
+ skill_tree = ".claude/skills"
15
+ first_run_note = "run `opencode auth login` once on this machine; headless servers reuse the stored credentials"
16
+
17
+ # Transport/provider environment-fault classification (#194). Unlike the tmux
18
+ # adapters, the file scanned for this adapter is logs/<task_id>.server.out — the
19
+ # `opencode serve` process's own stdout/stderr, structured logfmt, which the model
20
+ # never writes to. So the anchor can be the ai-sdk error class opencode reports in
21
+ # the `error.error=` field of a `level=ERROR message="stream error"` line, which is
22
+ # both precise and incapable of colliding with story output.
23
+ #
24
+ # NOT logs/<task_id>.log: that is the curated `[froid]` conversation transcript and
25
+ # it carries the model's own words. These patterns are only safe against a
26
+ # model-free log, so the file the classifier reads is part of their contract —
27
+ # see EnvFaultMixin.ENV_FAULT_LOG_SUFFIX.
28
+ #
29
+ # Verified against a real 5-hour provider quota outage: 22 matching lines across
30
+ # one run, in two forms — a clean refusal, and the socket drop providers fall
31
+ # back to once they stop answering at all:
32
+ # ... message="stream error" ... error.error="AI_APICallError: Usage limit reached for 5 hour. Your limit will reset at 2026-07-26 22:49:46"
33
+ # ... message="stream error" ... error.error="AI_APICallError: Cannot connect to API: The socket connection was closed unexpectedly."
34
+ # To disable or extend, copy this profile into .froid-loop/profiles/ and edit.
35
+ # Anchored on the structured field name, not on the error class alone: the server
36
+ # reports the failure as `error.error="AI_APICallError: …"` inside a logfmt line.
37
+ # The bare class name would also match `const MSG = "AI_APICallError: Usage limit
38
+ # reached";` — unreachable in THIS log, but a pattern is copied into project
39
+ # overlays and read as an example, so it should not model a weaker rule than it
40
+ # means. Deliberately content-anchored rather than position-anchored: this file is
41
+ # a byte stream, and the tmux profiles' equivalent is a raw pipe-pane capture full
42
+ # of cursor-addressed repaints, so no column or line offset in it is meaningful.
43
+ # The gap is [^"], not `.`: it must stay INSIDE the quoted error value. With `.`
44
+ # the lazy gap walks past the closing quote and finds a cause word in a later
45
+ # logfmt field, so an unrelated `AI_APICallError: Invalid API key` on a line whose
46
+ # trailing fields mention a quota path classifies as a quota outage. In every
47
+ # captured line the cause is inside the quotes, so nothing real is lost.
48
+ env_fault_patterns = [
49
+ 'error[.]error="AI_APICallError: [^"]{0,400}?(?i:usage limit reached|429|too many requests|rate[ _-]?limit|quota|overloaded)',
50
+ 'error[.]error="AI_APICallError: [^"]{0,400}?(?i:cannot connect to api|socket connection was closed|connection ?(error|refused|reset|timed ?out)|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN)',
51
+ ]
52
+
53
+ [hooks]
54
+ dialect = "none"