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,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"
|