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,408 @@
1
+ """Cross-platform process-lifecycle primitives behind a single seam.
2
+
3
+ The orchestrator needs four operations on a pid it launched: politely stop it,
4
+ force-kill it when it ignores that, check whether it is still alive, and — to
5
+ guard against pid reuse before a force-kill — read a stable per-process identity.
6
+ On POSIX these are ``os.kill`` calls; on Windows they are ``taskkill`` / psutil.
7
+ Quarantining them behind :class:`ProcessHost` lets a native-Windows backend slot
8
+ in as a new subclass + one registration line, with no edits to the POSIX bodies
9
+ or to the callers (`runs.stop_run`, the TUI liveness column).
10
+
11
+ On Linux/macOS — and WSL, which *is* Linux — these preserve today's exact
12
+ behavior; the Windows branch degrades gracefully and is not yet exercised (no
13
+ Windows backend ships in this pass). Selection mirrors the multiplexer registry
14
+ in :mod:`froid_loop.adapters.multiplexer`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import os
21
+ import shlex
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ from abc import ABC, abstractmethod
26
+ from pathlib import Path
27
+ from typing import Callable
28
+
29
+ from . import envvars
30
+
31
+ # SIGKILL is absent on Windows; fall back to SIGTERM so attribute access never
32
+ # raises. The POSIX host references this rather than ``signal.SIGKILL`` directly.
33
+ SIGKILL = getattr(signal, "SIGKILL", signal.SIGTERM) # portability: SIGKILL absent on Windows
34
+
35
+
36
+ class ProcessHostError(Exception):
37
+ """A process-lifecycle operation could not be carried out on this platform."""
38
+
39
+
40
+ class ProcessHost(ABC):
41
+ """The four pid operations the orchestrator needs, abstracted over the OS."""
42
+
43
+ @abstractmethod
44
+ def terminate(self, pid: int) -> None:
45
+ """Politely ask ``pid`` to stop (POSIX SIGTERM / Windows ``taskkill``).
46
+ Raises the ``OSError`` family (``ProcessLookupError``/``PermissionError``)
47
+ so callers keep their "already gone / not ours" handling."""
48
+
49
+ @abstractmethod
50
+ def force_kill(self, pid: int) -> None:
51
+ """Forcibly kill ``pid`` (POSIX SIGKILL / Windows ``taskkill /F /T``). The
52
+ escalation when ``terminate`` is ignored; only call once identity is
53
+ confirmed, never on a possibly-reused pid."""
54
+
55
+ @abstractmethod
56
+ def is_alive(self, pid: int) -> bool:
57
+ """Read-only liveness check for ``pid`` (no signal sent)."""
58
+
59
+ @abstractmethod
60
+ def identity(self, pid: int) -> float | None:
61
+ """A value that stays constant for the life of ``pid`` but changes if the
62
+ pid is reused — a PID-reuse guard for the force-kill escalation. ``None``
63
+ where the platform can't provide one (callers must refuse to force-kill
64
+ rather than risk an unrelated process)."""
65
+
66
+ @abstractmethod
67
+ def hook_interpreter(self) -> str:
68
+ """The command prefix that runs a froid-loop python hook script on this
69
+ host, interpolated into the hook registrations `install`/`probe` write
70
+ (the script path + canonical event are appended by the caller). POSIX runs
71
+ the ``python3`` on PATH; a Windows host overrides it (no ``python3`` there)
72
+ so hook registration never branches on ``sys.platform`` at the call site."""
73
+
74
+ def alive_and_ours(self, pid: int, identity: float | None) -> bool:
75
+ """Identity-aware liveness: True only when ``pid`` is alive **and** still the
76
+ same process whose ``identity`` we recorded. A reused pid (immediate on
77
+ Windows) fails the identity match and reads as gone — the reuse guard the
78
+ bare :meth:`is_alive` existence probe lacks.
79
+
80
+ ``identity is None`` (a legacy pid file with no persisted identity, or a
81
+ platform that can't provide one) degrades to :meth:`is_alive` — today's
82
+ bare-existence behavior, with the documented residual reuse risk. Kept
83
+ distinct from :meth:`is_alive` so existence and ownership are never
84
+ conflated again.
85
+
86
+ Destructive paths use this strict check; non-destructive TUI reads use
87
+ :meth:`liveness_of` to preserve an ``'unknown'`` state. Derived from
88
+ :meth:`liveness_of` — one decision table, two projections — so the binary
89
+ and tri-state probes can never drift: gone, reused, or unreadable
90
+ (``'unknown'``) all read not-ours here."""
91
+ return self.liveness_of(pid, identity) == "alive"
92
+
93
+ def liveness_of(self, pid: int, identity: float | None) -> str:
94
+ """Non-destructive tri-state read of *our* engine: ``'alive'`` |
95
+ ``'dead'`` | ``'unknown'``. A pid that still exists but whose identity is
96
+ unreadable reads ``'unknown'``, never ``'dead'``."""
97
+ if pid <= 0:
98
+ return "dead"
99
+ if identity is None:
100
+ return "alive" if self.is_alive(pid) else "dead"
101
+ current = self.identity(pid)
102
+ if current == identity:
103
+ return "alive"
104
+ if current is None and self.is_alive(pid):
105
+ return "unknown"
106
+ return "dead"
107
+
108
+ def descendants(self, pid: int) -> dict[int, float | None]:
109
+ """Transitive descendants of ``pid`` (children, grandchildren, …) as a
110
+ pid → :meth:`identity` snapshot, for the teardown reap that must chase a
111
+ session's whole process tree, not just the pane root — a detached straggler
112
+ (setsid, a double-fork survivor) escapes the pane pgid the window kill
113
+ signals. The identity is captured DURING enumeration — from the same
114
+ ``/proc`` read (Linux) or the same enumerated psutil ``Process`` object —
115
+ so a pid reaped-and-reused after enumeration can never be authenticated by
116
+ a later lookup; a member whose identity can't be captured is OMITTED, never
117
+ returned unstamped. ``None`` values are reserved for platforms that
118
+ genuinely can't stamp one — consumers must treat ``None`` as unconfirmable
119
+ (never signal it).
120
+
121
+ NEVER raises: ``{}`` on an unsupported platform, a missing psutil, a gone
122
+ pid, or any read error — this feeds the kill escalation, which must never be
123
+ the thing that raises. The default enumerates via psutil (macOS/Windows);
124
+ the Linux host overrides it with a psutil-free ``/proc`` scan so the core
125
+ stays dep-free there."""
126
+ if pid <= 0:
127
+ return {}
128
+ return _psutil_descendants(pid)
129
+
130
+ def shell_quote(self, arg: str) -> str:
131
+ """Quote ``arg`` for the shell that runs this host's hook commands, so the
132
+ argument-quoting axis sits behind the same seam as ``hook_interpreter``. Not
133
+ abstract: the default is POSIX ``shlex.quote``; a Windows host overrides it
134
+ (POSIX quoting mangles ``C:\\Program Files\\...`` paths)."""
135
+ return shlex.quote(arg)
136
+
137
+
138
+ class PosixProcessHost(ProcessHost):
139
+ """Linux/macOS/WSL: ``os.kill`` for signalling and the read-only existence
140
+ probe; ``/proc`` start-time (Linux) or psutil create-time for identity."""
141
+
142
+ def terminate(self, pid: int) -> None:
143
+ if pid <= 0:
144
+ # 0/negative target a process group (0 is the caller's own group), never
145
+ # a specific process — refuse so a corrupt pid file can't signal us.
146
+ return
147
+ os.kill(pid, signal.SIGTERM)
148
+
149
+ def force_kill(self, pid: int) -> None:
150
+ if pid <= 0:
151
+ return
152
+ os.kill(pid, SIGKILL)
153
+
154
+ def is_alive(self, pid: int) -> bool:
155
+ if pid <= 0:
156
+ # 0/negative target a process group, not a specific process — a corrupt
157
+ # pid file must read as "not alive", never as the caller's own group.
158
+ return False
159
+ try:
160
+ os.kill(pid, 0) # portability: read-only existence probe (POSIX); win32 uses psutil
161
+ except ProcessLookupError:
162
+ return False
163
+ except PermissionError:
164
+ return True # exists, just not ours to signal
165
+ return True
166
+
167
+ def identity(self, pid: int) -> float | None:
168
+ if pid <= 0:
169
+ return None
170
+ if sys.platform.startswith("linux"):
171
+ return _proc_starttime(pid)
172
+ # macOS (and any non-Linux POSIX): no /proc — fall back to psutil if the
173
+ # optional extra is present, else give up (None → callers won't force-kill).
174
+ try:
175
+ return _psutil().Process(pid).create_time()
176
+ except Exception:
177
+ return None
178
+
179
+ def descendants(self, pid: int) -> dict[int, float | None]:
180
+ if pid <= 0:
181
+ return {}
182
+ if sys.platform.startswith("linux"):
183
+ return _linux_descendants(pid) # psutil-free: keeps the Linux core dep-free
184
+ return super().descendants(pid) # macOS: psutil, guarded by the seam's never-raise
185
+
186
+ def hook_interpreter(self) -> str:
187
+ return "python3"
188
+
189
+
190
+ class WindowsProcessHost(ProcessHost):
191
+ """Native Windows: ``taskkill`` for signalling, psutil for the non-destructive
192
+ liveness probe and create-time identity. Not exercised in this pass — kept so a
193
+ psmux-class backend can register it without editing the POSIX bodies above."""
194
+
195
+ def terminate(self, pid: int) -> None:
196
+ if pid <= 0:
197
+ return
198
+ # portability: no os.kill(SIGTERM) on Windows — taskkill is the analogue.
199
+ subprocess.run([_taskkill(), "/PID", str(pid)], check=False, capture_output=True)
200
+
201
+ def force_kill(self, pid: int) -> None:
202
+ if pid <= 0:
203
+ return
204
+ # portability: SIGKILL has no Windows analogue — taskkill /F /T force-kills
205
+ # the process and its child tree.
206
+ subprocess.run(
207
+ [_taskkill(), "/F", "/T", "/PID", str(pid)], check=False, capture_output=True
208
+ )
209
+
210
+ def is_alive(self, pid: int) -> bool:
211
+ if pid <= 0:
212
+ return False
213
+ return _psutil().pid_exists(pid)
214
+
215
+ def identity(self, pid: int) -> float | None:
216
+ if pid <= 0:
217
+ return None
218
+ try:
219
+ return _psutil().Process(pid).create_time()
220
+ except Exception:
221
+ return None
222
+
223
+ def hook_interpreter(self) -> str:
224
+ # Windows ships no `python3` launcher; `uv run --no-project python` resolves
225
+ # an interpreter without activating a project venv (hooks fire detached).
226
+ return "uv run --no-project python"
227
+
228
+ def shell_quote(self, arg: str) -> str:
229
+ # POSIX single-quoting breaks Windows paths; list2cmdline is the stdlib's
230
+ # Windows argument quoter (the inverse of how CreateProcess parses argv).
231
+ return subprocess.list2cmdline([arg])
232
+
233
+
234
+ def _proc_starttime(pid: int) -> float | None:
235
+ """The process's start time (clock ticks since boot) from ``/proc/<pid>/stat``
236
+ field 22 — stable for the life of the pid, so it doubles as a reuse guard. The
237
+ comm field (2) is wrapped in parens and may itself contain spaces/parens, so we
238
+ split on the last ``)`` before tokenizing the rest. ``None`` if the process is
239
+ gone or unreadable."""
240
+ try:
241
+ proc = Path("/proc") # portability: Linux-only, guarded by identity()'s platform check
242
+ stat = proc.joinpath(str(pid), "stat").read_text(encoding="utf-8")
243
+ except OSError:
244
+ return None
245
+ try:
246
+ after_comm = stat[stat.rindex(")") + 1 :].split()
247
+ return float(after_comm[19]) # field 22 = index 19 after the comm token
248
+ except (ValueError, IndexError):
249
+ return None
250
+
251
+
252
+ def _linux_descendants(pid: int) -> dict[int, float | None]:
253
+ """Transitive descendants of ``pid``, with their pid-reuse identities, from a
254
+ single pass over ``/proc/*/stat`` (Linux only, psutil-free). Each entry's ppid
255
+ (field 4, index 1 after the comm token) AND starttime identity (field 22,
256
+ index 19 — the very value :func:`_proc_starttime` reads) come from ONE read of
257
+ the same ``stat`` line, split on the last ``)`` exactly as
258
+ :func:`_proc_starttime` does — so ancestry and identity are captured atomically
259
+ per process and a pid reused after the scan can never be authenticated by a
260
+ later lookup. BFS out from ``pid``. Best-effort: a process that exits mid-scan
261
+ simply drops out of the map, an unparsable line is omitted (never
262
+ authenticated), and any error yields ``{}`` so the kill seam never raises. A
263
+ ``seen`` set guards against a pid-reuse race fabricating a cycle."""
264
+ try:
265
+ children: dict[int, list[int]] = {}
266
+ identities: dict[int, float] = {}
267
+ for entry in Path("/proc").iterdir(): # portability: Linux-only, guarded by caller
268
+ if not entry.name.isdigit():
269
+ continue
270
+ try:
271
+ stat = (entry / "stat").read_text(encoding="utf-8")
272
+ after_comm = stat[stat.rindex(")") + 1 :].split()
273
+ ppid = int(after_comm[1]) # field 4 = ppid
274
+ starttime = float(after_comm[19]) # field 22 = starttime, the identity
275
+ except (OSError, ValueError, IndexError):
276
+ continue # vanished mid-scan / unparsable line — just skip it
277
+ child = int(entry.name)
278
+ children.setdefault(ppid, []).append(child)
279
+ identities[child] = starttime
280
+ except OSError:
281
+ return {}
282
+ out: dict[int, float | None] = {}
283
+ seen: set[int] = set()
284
+ stack = list(children.get(pid, ()))
285
+ while stack:
286
+ child = stack.pop()
287
+ if child in seen:
288
+ continue
289
+ seen.add(child)
290
+ out[child] = identities[child]
291
+ stack.extend(children.get(child, ()))
292
+ return out
293
+
294
+
295
+ def _psutil_descendants(pid: int) -> dict[int, float | None]:
296
+ """Transitive descendants via psutil (non-Linux POSIX + Windows), each stamped
297
+ with the ``create_time()`` of the SAME enumerated ``Process`` object and then
298
+ REVALIDATED against that object's construction-bound identity before it is
299
+ recorded.
300
+
301
+ The revalidation is load-bearing, not belt-and-braces — ``create_time()`` alone
302
+ is NOT reuse-safe here. Per ``psutil.Process._get_ident`` (7.2.2), only the
303
+ ``WINDOWS`` branch pre-populates the epoch cache (``self._create_time =
304
+ create_time(fast_only=True)``); the ``LINUX or NETBSD or OSX`` branch binds a
305
+ *monotonic* starttime and leaves ``self._create_time`` unset. So on macOS our
306
+ ``create_time()`` is the FIRST call — a raw call-time kernel read, and
307
+ ``create_time()`` does not consult ``_raise_if_pid_reused()``. A pid reaped and
308
+ reused between ``children()`` and the stamp would therefore hand back the
309
+ NEWCOMER's identity, which teardown would then authenticate successfully and
310
+ signal (#184 review). ``is_running()`` closes that window: it compares the
311
+ ident captured at construction against a freshly built ``Process``, so a
312
+ generation change reads as not-running and the member is omitted.
313
+
314
+ Order matters — read the identity, THEN validate, THEN record; validating first
315
+ would reopen the same TOCTOU gap. A member whose identity can't be confirmed is
316
+ OMITTED, never returned unstamped. Blanket-except → ``{}`` so a missing psutil
317
+ or an already-gone pid degrades silently — the never-raise contract the kill
318
+ escalation depends on."""
319
+ out: dict[int, float | None] = {}
320
+ try:
321
+ for child in _psutil().Process(pid).children(recursive=True):
322
+ try:
323
+ identity = child.create_time()
324
+ if not child.is_running(): # generation changed under us — don't stamp it
325
+ continue
326
+ out[child.pid] = identity
327
+ except Exception: # nosec B112 - gone/reused mid-walk: omit it
328
+ continue
329
+ except Exception: # the kill-path seam must never raise
330
+ return {}
331
+ return out
332
+
333
+
334
+ def _psutil():
335
+ """Lazily import psutil — a core dep on Windows, the ``non-linux`` extra on
336
+ macOS — used only for the non-destructive Windows/macOS liveness + identity
337
+ probes. The dep-free core never imports it on Linux; raise a clear, actionable
338
+ error if it's missing where it's needed."""
339
+ try:
340
+ import psutil # intentional lazy import — keeps the core dep-free
341
+ except ImportError as exc: # pragma: no cover - exercised only off Linux
342
+ raise ProcessHostError(
343
+ f"process_host: pid operations on {sys.platform!r} need psutil; "
344
+ "on Windows reinstall froid-loop (psutil is a required dependency there), "
345
+ "on macOS run `pip install 'froid-loop[non-linux]'`"
346
+ ) from exc
347
+ return psutil
348
+
349
+
350
+ def _taskkill() -> str:
351
+ """Absolute path to the Windows ``taskkill`` binary. Resolving it from
352
+ ``%SystemRoot%\\System32`` rather than invoking ``taskkill`` by name keeps the
353
+ Windows process-search order from picking up a same-named executable planted on
354
+ PATH or in the working directory."""
355
+ return os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "taskkill.exe")
356
+
357
+
358
+ # (name, matches(platform) -> bool, factory() -> ProcessHost)
359
+ _HOSTS: list[tuple[str, Callable[[str], bool], Callable[[], ProcessHost]]] = []
360
+ _BUILTINS_LOADED = False
361
+
362
+
363
+ def register_process_host(
364
+ name: str,
365
+ matches: Callable[[str], bool],
366
+ factory: Callable[[], ProcessHost],
367
+ ) -> None:
368
+ """Register a process host. ``matches(sys.platform)`` decides automatic
369
+ selection; ``name`` is the key for the ``FROID_LOOP_PROCESS_HOST`` override.
370
+ Bundled hosts register from :func:`_load_builtin_hosts`; an out-of-tree host
371
+ calls this at import time — no core edit required."""
372
+ _HOSTS.append((name, matches, factory))
373
+ get_process_host.cache_clear() # a later registration must not be shadowed by a cached pick
374
+
375
+
376
+ def _load_builtin_hosts() -> None:
377
+ """Register the bundled hosts. Idempotent and lazy (called from
378
+ :func:`get_process_host`) to match the multiplexer registry's shape; both
379
+ builtins live in this module, so there is nothing to import."""
380
+ global _BUILTINS_LOADED
381
+ if _BUILTINS_LOADED:
382
+ return
383
+ _BUILTINS_LOADED = True
384
+ register_process_host("posix", lambda platform: platform != "win32", PosixProcessHost)
385
+ register_process_host("windows", lambda platform: platform == "win32", WindowsProcessHost)
386
+
387
+
388
+ @functools.lru_cache(maxsize=1)
389
+ def get_process_host() -> ProcessHost:
390
+ """Return the process-wide process host, selected by registry.
391
+
392
+ ``FROID_LOOP_PROCESS_HOST`` forces a host by name (test / override hook);
393
+ otherwise the first host whose ``matches(sys.platform)`` is true wins. POSIX is
394
+ the default fallback, so behavior on Linux/macOS is unchanged. Cached — tests
395
+ that flip the env var must call ``get_process_host.cache_clear()``."""
396
+ forced = envvars.process_host()
397
+ _load_builtin_hosts()
398
+ for name, matches, factory in _HOSTS:
399
+ if name == forced or (not forced and matches(sys.platform)):
400
+ return factory()
401
+ if forced:
402
+ # An explicit override that matches nothing is a misconfiguration; never
403
+ # silently fall back to POSIX (on win32 os.kill(pid, 0) is destructive).
404
+ known = ", ".join(name for name, _, _ in _HOSTS) or "(none registered)"
405
+ raise ProcessHostError(
406
+ f"FROID_LOOP_PROCESS_HOST={forced!r} matches no registered host; known: {known}"
407
+ )
408
+ return PosixProcessHost() # default fallback