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,914 @@
1
+ """Terminal-multiplexer seam.
2
+
3
+ The coding-CLI adapter (:class:`~.base.CodingCLIAdapter`) abstracts *which CLI*
4
+ to drive and how its prompts/hooks work. This module abstracts the orthogonal
5
+ **transport** axis: how sessions, windows, and panes are created, observed, and
6
+ torn down. The bundled backends are tmux
7
+ (:class:`~.tmux_backend.TmuxMultiplexer`) and the native-Windows psmux
8
+ (:class:`~.psmux_backend.PsmuxMultiplexer`); every other backend lives out-of-tree
9
+ (the reference is the herdr adapter, https://github.com/pbean/froid-loop-adapter-herdr)
10
+ and slots in without the rest of the codebase shelling out to ``tmux`` directly.
11
+
12
+ ``TerminalMultiplexer`` is the contract a backend author implements. Operation
13
+ names mirror today's call sites verbatim so the migration is mechanical. Backends
14
+ register themselves through :func:`register_multiplexer` (bundled ones from
15
+ :func:`_load_builtin_backends`, which :func:`register_multiplexer` seeds first so
16
+ a bundled name keeps first-wins no matter who registers earliest; out-of-tree
17
+ ones at import time — usually the ``froid_loop.mux_backends`` entry-point scan in
18
+ :func:`_load_external_backends`, so a pip/uv co-installed adapter package is
19
+ selectable with no config step, but *any* import reaches it, a plugin's
20
+ ``[python]`` module included); the process-wide backend is selected by registry
21
+ and returned by :func:`get_multiplexer`.
22
+
23
+ Selection precedence (issue #87): the ``FROID_LOOP_MUX_BACKEND`` env var, then the
24
+ policy ``[mux] backend`` choice (installed once per CLI invocation via
25
+ :func:`configure_multiplexer`), then the platform default when registered and
26
+ available, then the first registered backend that matches the platform and is
27
+ available, then the historical fallback (first platform match regardless of
28
+ availability, bottoming out at tmux). :func:`detect_multiplexers` enumerates the
29
+ registry for ``froid-loop mux`` and the ``validate`` preflight.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import functools
35
+ import importlib.metadata
36
+ import sys
37
+ from abc import ABC, abstractmethod
38
+ from collections.abc import Callable
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+
42
+ from .. import envvars
43
+ from .entrypoints import record_load_error
44
+
45
+
46
+ class MultiplexerError(Exception):
47
+ """A transport-backend operation failed. Backends raise a subclass (e.g.
48
+ :class:`~.tmux_backend.TmuxError`) so call sites can catch the seam-level type
49
+ without importing a backend."""
50
+
51
+
52
+ def parse_target(target: str) -> tuple[str, str | None] | None:
53
+ """Decode a seam-canonical window target (see :meth:`TerminalMultiplexer.target`).
54
+
55
+ Returns ``(session, window)`` for a canonical ``=session[:window]`` token —
56
+ ``window`` is None when absent *or* empty, so ``"=s"`` and ``"=s:"`` both
57
+ decode to ``("s", None)`` — or None when ``target`` does not start with
58
+ ``=``: a backend-native id (``"@1"``, ``"%3"``, ``"w1:p1"``, ...) the caller
59
+ resolves itself. The window part is everything after the *first* ``:``;
60
+ that split is safe because froid-loop mints window names
61
+ (``<kind>-<run_id>``) that never contain ``:``. Provided so a backend whose
62
+ native addressing differs decodes the grammar with one tested helper
63
+ instead of re-deriving it (see the herdr backend's ``_parse_target``)."""
64
+ if not target.startswith("="):
65
+ return None
66
+ session, _, window = target[1:].partition(":")
67
+ return (session, window or None)
68
+
69
+
70
+ class TerminalMultiplexer(ABC):
71
+ """Transport backend for agent sessions: sessions, windows, and clients.
72
+
73
+ A backend must shell out to (or otherwise drive) exactly one multiplexer and
74
+ nothing else — it is the single place POSIX-shell / tmux knowledge is allowed
75
+ to live. The full surface below is the contract; Phase 1 wired only the subset
76
+ the generic adapter needs, and Phase 2 fills in the rest as ``runs.py``,
77
+ ``tui/launch.py``, ``probe.py``, and ``tui/data.py`` migrate onto it.
78
+ """
79
+
80
+ # ------------------------------------------------------------ targets
81
+
82
+ def target(self, session: str, window: str | None = None) -> str:
83
+ """Format the seam-canonical target token for ``session`` (optionally
84
+ one of its windows, *by name*). The default grammar is
85
+ ``=session[:window]`` — historically tmux's exact-match syntax, now
86
+ owned by the seam: every target-taking method below accepts both this
87
+ token and the backend's native ids, and :func:`parse_target` is the
88
+ matching decoder. Backends MAY override to emit native ids, but the
89
+ result must stay a stable *by-name* reference: callers format targets
90
+ ahead of use (e.g. a parked window's return target), so eager
91
+ resolution to a live native id can go stale — keeping the token
92
+ symbolic and resolving lazily at use time is the recommended default."""
93
+ return f"={session}:{window}" if window else f"={session}"
94
+
95
+ # ----------------------------------------------------------- sessions
96
+
97
+ def session_name_key(self, name: str) -> str:
98
+ """Canonical comparison key for a session name on this transport: two
99
+ names denote the same live session exactly when their keys are equal.
100
+
101
+ Identity by default — tmux resolves session names case-sensitively
102
+ (measured on 3.4: ``froid-loop-ctl`` and ``froid-loop-CTL`` coexist), so
103
+ exact comparison is the truth there. A transport that resolves names
104
+ through a case-folding store overrides (psmux: the registry is a
105
+ directory of per-session files opened by name, and NTFS opens names
106
+ case-insensitively). Non-abstract so released out-of-tree backends
107
+ keep their exact-comparison behavior unchanged.
108
+
109
+ This is where "are these the same session name?" gets its answer:
110
+ core must never decide it with a constant, because the same fold that
111
+ is required on one transport destroys data on the other — a
112
+ case-variant agent session discounted as "the control session" on
113
+ tmux is a genuinely live session whose run dir then gets deleted."""
114
+ return name
115
+
116
+ @abstractmethod
117
+ def has_session(self, name: str) -> bool:
118
+ """True iff a session named exactly ``name`` exists.
119
+
120
+ Weak False (#489): a False means the backend did not *confirm* the
121
+ session, not that it provably no longer exists — implementations map
122
+ any failed lookup ("no such session", "no server running", a target
123
+ the grammar could not parse) to False alike. A transport failure
124
+ (the backend could not be asked at all) raises ``MultiplexerError``
125
+ rather than returning False. Callers that surface a False as
126
+ evidence must word it as what the negative withdraws, not what it
127
+ proves — see ``escalation.session_failure_reason``."""
128
+
129
+ @abstractmethod
130
+ def new_session(
131
+ self, name: str, cwd: Path, cols: int | None = None, lines: int | None = None
132
+ ) -> None:
133
+ """Create a detached session with a single shell window rooted at ``cwd``.
134
+ When ``cols``/``lines`` are given the session is pinned to that geometry
135
+ (agent sessions are observed detached, so their pane size must be fixed);
136
+ omit both for a session whose size is irrelevant (e.g. the control session,
137
+ which is only ever attached, and an attaching client resizes it anyway)."""
138
+
139
+ @abstractmethod
140
+ def kill_session(self, name: str) -> None:
141
+ """Kill the named session (tolerant of it already being gone)."""
142
+
143
+ @abstractmethod
144
+ def list_sessions(self) -> list[str]:
145
+ """Names of all live sessions."""
146
+
147
+ @abstractmethod
148
+ def session_options(self, option: str) -> dict[str, str]:
149
+ """Map of session name -> value of ``option`` across all sessions."""
150
+
151
+ @abstractmethod
152
+ def set_session_option(self, name: str, option: str, value: str) -> None:
153
+ """Set a user option on the named session. A transport failure raises
154
+ :class:`MultiplexerError` — unlike :meth:`set_window_option`, this write
155
+ is not best-effort.
156
+
157
+ A backend may still refuse a *value* it cannot carry to its server
158
+ verbatim (psmux does): it warns, leaves the option unset, and returns
159
+ without raising, because a value stored corrupted is worse than one
160
+ never stored. So read an unset option as "no answer" — never as proof
161
+ that nothing was ever written."""
162
+
163
+ # ------------------------------------------------------------ windows
164
+
165
+ @abstractmethod
166
+ def new_window(
167
+ self, session: str, name: str, cwd: Path, env: dict[str, str], command: str
168
+ ) -> str:
169
+ """Create a window running ``command`` (with ``env`` layered on) in
170
+ ``session``, rooted at ``cwd``. Returns the backend-native window id.
171
+
172
+ That id is **opaque to core**: it is replayed verbatim as the ``-t``
173
+ target of :meth:`pipe_pane`, :meth:`send_text`, :meth:`kill_window` and
174
+ :meth:`window_pane_pids`, and membership-tested against
175
+ :meth:`list_window_ids` — never parsed, and never re-composed through
176
+ :meth:`target`. So a backend MAY return an already-qualified target
177
+ instead of a bare id (psmux returns ``session:@N``), provided
178
+ :meth:`list_window_ids` emits the identical form — see its symmetry
179
+ rule.
180
+
181
+ ``command`` is a POSIX shlex-joined argv string, not a shell line:
182
+ shell-operator behavior (``&&``, ``|``, ...) is backend-defined —
183
+ one backend may hand the string to a shell, another may shlex
184
+ re-split it into literal argv — so callers must not rely on it."""
185
+
186
+ @abstractmethod
187
+ def new_parked_window(
188
+ self, session: str, name: str, cwd: Path, argv: list[str], return_opt: str
189
+ ) -> str:
190
+ """Create a window that runs ``argv`` then *parks* — waiting on a key so
191
+ the exit status stays inspectable instead of the window closing the moment
192
+ the process exits — and finally returns an attached client to its origin
193
+ (keyed by the per-window ``return_opt``). Returns the native window id.
194
+
195
+ That id is **opaque to core** exactly as :meth:`new_window`'s is, so a
196
+ backend MAY return an already-qualified target rather than a bare id
197
+ (psmux returns ``session:@N``) — and an obligation follows from that
198
+ choice here too, a different pairing than :meth:`new_window`'s: for the
199
+ form it binds the id to, see :meth:`list_window_ids`'s note on #482."""
200
+
201
+ @abstractmethod
202
+ def list_window_ids(self, session: str) -> list[str]:
203
+ """Native ids of every window in ``session`` (empty if it is gone).
204
+
205
+ SYMMETRY RULE: these must be the *same id form* :meth:`new_window`
206
+ returns, because :meth:`window_alive` is a membership test over this
207
+ list. A backend that qualifies one side and not the other reads every
208
+ live window as instantly dead. The form itself is the backend's own —
209
+ psmux emits ``session:@N`` because its ids are minted per server (one
210
+ server per session), so a bare ``@N`` replayed as a ``-t`` target
211
+ routes by the *caller's* server instead of the owning one.
212
+
213
+ :meth:`new_parked_window` is outside *this* list's rule. To preserve
214
+ #482's unambiguous lookup, however, its id must match the ``window_id``
215
+ column of :meth:`list_windows` (psmux qualifies both, #291). A backend
216
+ that diverges remains usable, but falls back to the ambiguous by-name
217
+ lookup whenever several kinds share a run id.
218
+
219
+ Raises :class:`MultiplexerError` if the transport itself fails (timeout /
220
+ missing binary): an empty list means "no windows" and must not be
221
+ conflated with "couldn't ask" — this op backs the engine's liveness
222
+ probe (:meth:`window_alive`)."""
223
+
224
+ @abstractmethod
225
+ def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]:
226
+ """One tuple per window in ``session``, each holding the requested
227
+ backend fields in order. Best-effort: returns ``[]`` on a transport
228
+ failure (unlike :meth:`list_window_ids`, this is metadata, not a liveness
229
+ probe, so a sentinel is safe).
230
+
231
+ A ``window_id`` column carries the same id form :meth:`current_window_id`
232
+ AND :meth:`list_window_ids` return; core compares all three directly. The
233
+ second pairing is load-bearing for the ctl-window prune's kill verdict,
234
+ which is a membership test of this column against that listing
235
+ (:func:`froid_loop.tui.launch.prune_ctl_windows`): a backend that
236
+ qualifies one side and not the other reports every killed window as
237
+ verifiably gone — silently, and in the optimistic direction the verdict
238
+ exists to remove (#435)."""
239
+
240
+ @abstractmethod
241
+ def window_alive(self, session: str, window_id: str) -> bool:
242
+ """True iff ``window_id`` is still a window of ``session``.
243
+
244
+ May raise :class:`MultiplexerError` when liveness is unknowable (a
245
+ transport timeout / missing binary) — callers must treat that as "don't
246
+ know", not "dead", and must not tear down a possibly-working session on
247
+ it."""
248
+
249
+ @abstractmethod
250
+ def kill_window(self, target: str) -> None:
251
+ """Kill the targeted window (tolerant of it already being gone, and a
252
+ no-op on a transport failure). ``target`` is a :meth:`target` token or
253
+ a backend-native window id."""
254
+
255
+ @abstractmethod
256
+ def select_window(self, target: str) -> None:
257
+ """Make ``target`` the current window of its session (best-effort: a no-op
258
+ on a transport failure). ``target`` is a :meth:`target` token or a
259
+ backend-native window id."""
260
+
261
+ @abstractmethod
262
+ def set_window_option(self, target: str, option: str, value: str) -> None:
263
+ """Set a user option on the targeted window (best-effort: a no-op on a
264
+ transport failure). ``target`` is a :meth:`target` token or a
265
+ backend-native window id.
266
+
267
+ The contract is the (window, option) keying, not the storage: a backend
268
+ without per-window option scope may key the value however it likes
269
+ (psmux does), so read it back only through :meth:`show_window_option`
270
+ or :meth:`list_windows`, never by running the multiplexer's own option
271
+ verbs by hand."""
272
+
273
+ @abstractmethod
274
+ def unset_window_option(self, target: str, option: str) -> None:
275
+ """Remove a user option from the targeted window (so a later read sees it
276
+ as unset, not as an empty value). Best-effort: a no-op on a transport
277
+ failure. ``target`` is a :meth:`target` token or a backend-native
278
+ window id."""
279
+
280
+ @abstractmethod
281
+ def show_window_option(self, target: str, option: str) -> str:
282
+ """Value of a user option on the targeted window ('' if unset, and '' on a
283
+ transport failure). ``target`` is a :meth:`target` token or a
284
+ backend-native window id."""
285
+
286
+ @abstractmethod
287
+ def pipe_pane(self, window_id: str, log_file: Path) -> None:
288
+ """Tee the window's pane output to ``log_file`` (tolerant of the window
289
+ having already died)."""
290
+
291
+ @abstractmethod
292
+ def send_text(self, window_id: str, text: str) -> None:
293
+ """Send ``text`` literally to the window, then submit it (Enter)."""
294
+
295
+ # ----------------------------------------------------- client / attach
296
+
297
+ @abstractmethod
298
+ def attach_target_argv(self, target: str) -> list[str]:
299
+ """argv that attaches the caller's terminal to ``target`` (a
300
+ :meth:`target` token — session-only or session+window — or a
301
+ backend-native id)."""
302
+
303
+ @abstractmethod
304
+ def current_pane_id(self) -> str | None:
305
+ """Native id of the pane this process runs in, or None when not inside
306
+ the multiplexer."""
307
+
308
+ @abstractmethod
309
+ def current_window_id(self) -> str | None:
310
+ """Native id of the window this process runs in, or None when not inside
311
+ the multiplexer. Must match the form :meth:`list_windows` puts in a
312
+ ``window_id`` column — the ctl-window prune skips its own window by
313
+ comparing them."""
314
+
315
+ @abstractmethod
316
+ def current_session(self) -> str | None:
317
+ """Name of the session this process runs in, or None when not inside the
318
+ multiplexer."""
319
+
320
+ def current_return_target(self) -> str | None:
321
+ """Target an interactive attach records so the parked-window return
322
+ trailer / :meth:`switch_client` can send the client back to the pane
323
+ this process runs in; None when not inside the multiplexer. The value
324
+ is backend-composed and replayed opaquely, so each backend emits
325
+ whatever its own ``switch-client`` resolves best. Default: the native
326
+ pane id — globally unique on a one-server multiplexer (tmux) and the
327
+ pass-through form for native-id backends. A backend whose ids do not
328
+ resolve from another session's context (e.g. psmux, one server per
329
+ session) overrides this to emit a qualified form."""
330
+ return self.current_pane_id() or None
331
+
332
+ @abstractmethod
333
+ def detach_client(self) -> bool:
334
+ """Detach the client viewing the current session. Returns True iff a
335
+ client was actually detached — **effect, not dispatch**: a transport
336
+ failure answers False, and so does a backend with no real detach.
337
+ tmux gets this from the exit code (`detach-client` fails with "no
338
+ current client"); a backend whose CLI exits 0 either way measures it
339
+ instead (psmux counts the session's attached clients across the call).
340
+ Callers that only want the terminal handed back may ignore the answer;
341
+ the parked-window return path cannot — it clears its return option on a
342
+ True, and a vacuous one strands the human. It reads a False as
343
+ UNREACHABLE: on tmux that is positive evidence nobody is watching this
344
+ window any more, but off tmux the same False also covers an effect the
345
+ backend could not observe and a backend with no detach verb at all, so
346
+ the response is a policy for the uncertainty rather than proof (see
347
+ tui.launch.return_attached_client)."""
348
+
349
+ @abstractmethod
350
+ def switch_client(self, target: str, last_fallback: bool = False) -> bool | None:
351
+ """Switch the current client to ``target`` (optionally falling back to
352
+ the last client on failure). ``target`` is a :meth:`target` token or a
353
+ backend-native id.
354
+
355
+ Three answers, because the parked-window return path asks two questions
356
+ of the one verb — did the switch happen, and is anyone still at this
357
+ terminal:
358
+
359
+ - ``True`` — a switch happened. Effect, not dispatch, the same rule as
360
+ :meth:`detach_client`.
361
+ - ``False`` — the **joint** claim: no switch happened *and* the client
362
+ is still here. The verb ran, refused, and moved nobody.
363
+ :func:`tui.launch.return_attached_client` reads it as ATTENDED and
364
+ keeps prompting this terminal, so do not answer it for the first half
365
+ alone.
366
+ - ``None`` — cannot vouch for the second half: the verb's answer never
367
+ arrived (a timed-out call), its effect was unobservable, or there was
368
+ no client here to move. That reads as UNREACHABLE — the sweep keeps
369
+ its return option but stops prompting, which is the safe way to be
370
+ wrong, since prompting into a window nobody is viewing blocks a
371
+ ``--repeat`` sweep on ``input()`` forever and the parked trailer's
372
+ retry cannot recover it (it sits behind that same blocking read).
373
+
374
+ A backend that never widened to the third state keeps working — a bool
375
+ is a valid answer and the seam only loses a distinction that backend
376
+ never drew. What no backend may do is answer ``False`` for a move it
377
+ merely could not confirm, or a vacuous ``True`` (#659)."""
378
+
379
+ @abstractmethod
380
+ def available(self) -> bool:
381
+ """True iff this backend can run on the current host (e.g. its binary is
382
+ on PATH)."""
383
+
384
+ def version(self) -> str | None:
385
+ """The backend binary's version string, or None when unavailable. Not
386
+ abstract: backends that can't report one inherit this default. The
387
+ implementation owns the binary invocation so it stays behind the seam.
388
+
389
+ **One bounded line.** Consumers render this inline — the `froid-loop mux`
390
+ table, `validate`'s preflight finding, the diagnostic dump, the
391
+ forced-backend warning — so a binary whose `--version` prints several
392
+ lines (psmux prints a `tmux X.Y.Z` compatibility line plus its own)
393
+ must fold them into one here rather than leave each caller to cope, and
394
+ a very long one line breaks the same surfaces a newline does.
395
+ :func:`fold_version` is the canonical fold, and the inline consumers
396
+ also apply it defensively: an out-of-tree backend can only be asked to
397
+ keep this promise, not made to. The one caller that also *parses* this
398
+ string (the psmux backend's version gate) anchors at its start, so a
399
+ folding backend keeps the identifying version in the first segment."""
400
+ return None
401
+
402
+ def version_error(self) -> str | None:
403
+ """Why the most recent :meth:`version` call answered None despite the
404
+ binary being there — a crashing probe, a hung server, an AV-blocked exe.
405
+ None when that call succeeded, when there was no binary to ask, when no
406
+ probe has run yet, or when the backend keeps no such record (the default
407
+ here, so an out-of-tree backend inherits silence rather than breaking).
408
+
409
+ This is a *diagnostic*, not a second contract: `version()` keeps its None
410
+ sentinel (observation may degrade) and this only recovers the identity of
411
+ the failure it dropped, which is otherwise indistinguishable from "the
412
+ binary reports no version" (#428). Must not raise.
413
+
414
+ It describes the LAST probe, so read it directly after :meth:`version`,
415
+ **on an instance you own** — nothing recomputes it, a later successful
416
+ probe clears it, and the record is unsynchronized per-instance state. The
417
+ process-wide :func:`get_multiplexer` backend is shared across the TUI's
418
+ worker threads, so a caller reading the accessor off THAT instance can be
419
+ handed another thread's probe. :func:`detect_multiplexers` is the one
420
+ in-tree reader and builds its own instance per row."""
421
+ return None
422
+
423
+ def registry_root(self) -> str | None:
424
+ """The registry this backend's verbs currently resolve targets through,
425
+ or ``None`` when the backend has no registry namespace at all.
426
+
427
+ ``None`` is the default and the tmux answer: tmux addresses a server by
428
+ socket, and there is no root an operator could be pointed at. Backends
429
+ that DO namespace (see :meth:`legacy_registries` for the concept) answer
430
+ the root in force, so a frontend can disclose it — an operator whose own
431
+ client reads a different root sees none of these sessions, and is told
432
+ "no sessions" rather than an error.
433
+
434
+ A diagnostic, like :meth:`version_error`: must not raise, and a value it
435
+ cannot use (one the transport would reject) still comes back verbatim
436
+ rather than as ``None`` — "the root is unusable" and "there is no root"
437
+ are different facts and the caller acts on the difference.
438
+
439
+ ``None`` from a backend that DOES namespace means "no root in force":
440
+ its verbs then address the transport's own *default* registry, which is
441
+ shared with every project and with the operator. That is a different
442
+ fact from tmux's ``None`` (no namespace exists), and
443
+ :meth:`has_registry_namespace` is how a caller tells them apart."""
444
+ return None
445
+
446
+ def has_registry_namespace(self) -> bool:
447
+ """Whether this transport namespaces sessions by registry at all — a
448
+ property of the backend, independent of whether a root is currently in
449
+ force (see :meth:`registry_root` / :meth:`legacy_registries` for the
450
+ concept).
451
+
452
+ ``False`` is the default and the tmux answer: one server for the
453
+ machine, and ``registry_root() is None`` means exactly that. A backend
454
+ answering ``True`` here with ``registry_root()`` ``None`` is running on
455
+ its own default registry — shared, not this project's — which is what
456
+ ``runs._registry_proves_ownership`` needs to know before it lets an
457
+ untagged session be claimed on run-directory evidence."""
458
+ return False
459
+
460
+ def legacy_registries(self) -> list[TerminalMultiplexer]:
461
+ """Backends addressing *other* registries this one's own sessions may
462
+ still be living in, for the cleanup sweep. ``[]`` by default — a backend
463
+ with a single registry, tmux included, has nothing to sweep.
464
+
465
+ **The registry-namespace seam concept.** A *registry* is wherever a
466
+ multiplexer keeps the per-session addressing state its verbs resolve a
467
+ target through: for psmux, the ``PSMUX_DATA_DIR`` directory of
468
+ ``.port``/``.key`` files, one per session. It is a namespace, not a
469
+ filter — a session in registry A is not merely hidden from a verb aimed
470
+ at registry B, it is unaddressable from it. froid-loop aims psmux at a
471
+ per-project root (``runs.mux_registry_root``), which is what makes this
472
+ method necessary: sessions created before that root existed are in
473
+ psmux's default registry, addressable only by a backend pointed there.
474
+
475
+ Each element must be an independent instance bound to its registry, and
476
+ must NOT work by mutating this process's environment: the callers include
477
+ a TUI worker thread running beside other threads issuing ordinary verbs,
478
+ and a global swap would silently aim one of *those* at the wrong
479
+ registry — the same live-session-reads-as-gone failure the per-project
480
+ root exists to prevent.
481
+
482
+ A porting note for a new OS or multiplexer: if the transport has no such
483
+ namespace, inherit this default and nothing else changes. If it does,
484
+ the seam wants the derivation in ``runs`` (keyed on the project, never on
485
+ the run or the shell) and the sweep here — see
486
+ ``docs/porting-to-a-new-os.md``."""
487
+ return []
488
+
489
+ def window_pane_pids(self, target: str) -> list[int]:
490
+ """Best-effort OS pids of ``target``'s pane root processes, for the kill
491
+ escalation. Not abstract: backends that can't (or don't) report pids
492
+ inherit this default. ``[]`` means unknown or capability not offered —
493
+ callers must degrade (skip the pid-level escalation) and never read
494
+ ``[]`` as "no processes". Must not raise."""
495
+ return []
496
+
497
+
498
+ # A version is rendered inline, and `froid-loop mux` sizes every column off its
499
+ # widest cell — so one 300-char version pads the VERSION column to 306 and the
500
+ # row past 350, unreadable for the same reason an embedded newline was (#321).
501
+ # Length is half the seam's promise, not a separate concern. 80 keeps the widest
502
+ # cell inside a standard terminal with room to spare over the real probes, which
503
+ # fold to ~42 (`tmux 3.4; psmux 3.3.8 (66cf613 2026-08-18)`).
504
+ VERSION_MAX_CHARS = 80
505
+
506
+
507
+ def fold_version(raw: str | None) -> str | None:
508
+ """Collapse a version string onto the one bounded line the
509
+ :meth:`TerminalMultiplexer.version` seam promises. Segments keep their
510
+ order (the psmux version gate anchors a parse at the first) and are
511
+ stripped; a fold over :data:`VERSION_MAX_CHARS` is cut at the tail, which
512
+ that anchored parse never reads; an all-blank value folds to None — the
513
+ seam's "no version" sentinel — never to ``""``. Idempotent, so the seam's
514
+ own fold and each consumer's defensive one compose.
515
+
516
+ Line breaks only: a tab or an ANSI escape *inside* a segment survives and
517
+ can still misalign a table. Widening this to collapse all whitespace would
518
+ rewrite well-behaved single-line versions, which the seam promises not to
519
+ touch. And the fold is one-way — ``"; "`` is a plausible substring of a real
520
+ version banner, so a boundary is not recoverable by splitting on it."""
521
+ if not raw:
522
+ return None
523
+ folded = "; ".join(line.strip() for line in raw.splitlines() if line.strip())
524
+ if len(folded) > VERSION_MAX_CHARS:
525
+ folded = folded[: VERSION_MAX_CHARS - 1] + "…"
526
+ return folded or None
527
+
528
+
529
+ # (name, matches(platform) -> bool, factory() -> TerminalMultiplexer)
530
+ _BACKENDS: list[tuple[str, Callable[[str], bool], Callable[[], TerminalMultiplexer]]] = []
531
+ _BUILTINS_LOADED = False
532
+ # The policy [mux] backend choice — (name, origin policy path) — installed once
533
+ # per CLI invocation by cli._configure_mux via configure_multiplexer. None = auto.
534
+ _CONFIGURED: tuple[str, Path | None] | None = None
535
+
536
+ # Per-platform default backend name, consulted only when that backend is both
537
+ # registered AND available on this host. psmux is a bundled builtin (registered
538
+ # below), so on a win32 host it applies whenever psmux reports available; if it
539
+ # isn't, selection falls through to the first platform match / fallback.
540
+ _PLATFORM_DEFAULTS: dict[str, str] = {"win32": "psmux"}
541
+ _DEFAULT_BACKEND = "tmux" # every platform not listed above
542
+
543
+
544
+ def register_multiplexer(
545
+ name: str,
546
+ matches: Callable[[str], bool],
547
+ factory: Callable[[], TerminalMultiplexer],
548
+ ) -> None:
549
+ """Register a transport backend. ``matches(sys.platform)`` decides automatic
550
+ selection; ``name`` is the key for the ``FROID_LOOP_MUX_BACKEND`` override.
551
+ Bundled backends register from :func:`_load_builtin_backends`, seeded here
552
+ rather than only by the resolution entry points, so an out-of-tree package can
553
+ never shadow a bundled name. An out-of-tree backend calls this at import time
554
+ — no core edit required.
555
+
556
+ Seeding on *this* side is what makes first-wins an invariant instead of an
557
+ ordering coincidence. ``_BACKENDS`` is an ordered list and every consumer
558
+ takes the first entry under a name (:func:`_factory_by_name` and all three
559
+ :func:`_select` loops), so whichever registration lands first owns the name.
560
+ An external module runs its ``register_multiplexer`` calls as an import side
561
+ effect, and that import is not always triggered by a mux resolution: a
562
+ plugin's ``[python]`` module is exec'd in-process by ``plugins/registry.py``,
563
+ which has no ordering relationship to the first :func:`get_multiplexer` call.
564
+ Arriving first, it would land ahead of the bundled tmux entry and be selected
565
+ in its place. Seeding keeps the bundled entry first; the external stays behind
566
+ it, since this list appends rather than dedups — which is exactly what a
567
+ shadowed name should look like."""
568
+ _load_builtin_backends()
569
+ _BACKENDS.append((name, matches, factory))
570
+ get_multiplexer.cache_clear() # a later registration must not be shadowed by a cached pick
571
+
572
+
573
+ def _load_builtin_backends() -> None:
574
+ """Register the bundled backends — tmux (POSIX) and psmux (native Windows);
575
+ every other backend is out-of-tree and arrives via
576
+ :func:`_load_external_backends` or a manual import. Idempotent and lazy
577
+ (called from :func:`get_multiplexer` and from :func:`register_multiplexer`,
578
+ not at module import) to stay cycle-safe. Registers inline rather than via
579
+ tmux_backend's import side effect so the registry can be cleared and
580
+ re-loaded deterministically (a re-import is a no-op once cached) —
581
+ mirroring ``process_host._load_builtin_hosts``.
582
+
583
+ The flag sits between the imports and the registrations, and both halves of
584
+ that position are load-bearing: below the imports so a transient import
585
+ failure leaves the seeding retryable, above the registrations because they
586
+ re-enter this function through :func:`register_multiplexer`. The adapter twin
587
+ sets it at the very top only because its builtins are lazy thunks with
588
+ nothing to import first."""
589
+ global _BUILTINS_LOADED
590
+ if _BUILTINS_LOADED:
591
+ return
592
+ from .psmux_backend import PsmuxMultiplexer
593
+ from .tmux_backend import TmuxMultiplexer
594
+
595
+ # Set after the imports but BEFORE the registrations. Below the imports so a
596
+ # transient import failure still retries; above the registrations because they
597
+ # re-enter this function through register_multiplexer, and a flag set
598
+ # afterwards would recurse without end.
599
+ _BUILTINS_LOADED = True
600
+ # tmux is the default everywhere except native Windows (no tmux binary there);
601
+ # get_multiplexer still falls back to tmux when no backend matches. Builtins
602
+ # register before externals, so tmux keeps first-wins on any name collision.
603
+ register_multiplexer("tmux", lambda platform: platform != "win32", TmuxMultiplexer)
604
+ # psmux speaks the tmux CLI through its own distinctly-named binary, so
605
+ # native Windows gets the tmux-family backend with a PowerShell dialect.
606
+ register_multiplexer("psmux", lambda platform: platform == "win32", PsmuxMultiplexer)
607
+
608
+
609
+ # The entry-point group an out-of-tree backend package advertises its module
610
+ # under; importing the module runs its register_multiplexer call. Loader state:
611
+ # scanned-once flag + per-entry-point failure reasons for mux/validate to show.
612
+ MUX_BACKENDS_GROUP = "froid_loop.mux_backends"
613
+ _EXTERNALS_LOADED = False
614
+ _EXTERNAL_ERRORS: dict[str, str] = {}
615
+
616
+
617
+ def _load_external_backends() -> None:
618
+ """Import every ``froid_loop.mux_backends`` entry point; each module
619
+ self-registers via :func:`register_multiplexer` at import time. Called after
620
+ :func:`_load_builtin_backends`, so builtins keep first registration (tmux
621
+ stays first-wins on a name collision) and selection precedence is unchanged.
622
+
623
+ A broken third-party distribution must never break backend selection:
624
+ failures are recorded in ``_EXTERNAL_ERRORS`` (surfaced by ``froid-loop mux``
625
+ and the ``validate`` preflight via :func:`external_backend_errors`), not
626
+ raised. Unlike ``_BUILTINS_LOADED``, the loaded-flag is set up front: a
627
+ third-party import failure is not transient, and retrying on every
628
+ selection would re-import (and re-fail) each time.
629
+
630
+ Entry points are visited in (name, distribution) order. ``importlib.metadata``
631
+ yields them in distribution-discovery order, which varies with ``sys.path``, so
632
+ without an explicit sort two hosts carrying the same packages could register a
633
+ collision in a different order — and the order failures are recorded in would
634
+ be a fact about the install rather than about the packages.
635
+
636
+ The distribution belongs in the key because the name alone is NOT a total
637
+ order. ``entry_points(group=...)`` does not dedup across distributions, so two
638
+ packages advertising the same entry-point name come back as two entries, and
639
+ ``sorted`` is stable — a name-only key resolves that tie straight back into
640
+ ``sys.path`` order.
641
+
642
+ Such a same-named failure now ACCUMULATES rather than overwriting: recording
643
+ goes through :func:`~.entrypoints.record_load_error`, which appends under the
644
+ entry-point name and labels each reason with its distribution."""
645
+ global _EXTERNALS_LOADED
646
+ if _EXTERNALS_LOADED:
647
+ return
648
+ _EXTERNALS_LOADED = True
649
+ try:
650
+ eps = sorted(
651
+ importlib.metadata.entry_points(group=MUX_BACKENDS_GROUP),
652
+ key=lambda e: (e.name, getattr(e.dist, "name", "") or ""),
653
+ )
654
+ except Exception as exc: # diagnostics path, never crash selection
655
+ _EXTERNAL_ERRORS["<entry-point scan>"] = f"{type(exc).__name__}: {exc}"
656
+ return
657
+ for ep in eps:
658
+ try:
659
+ ep.load() # module import runs register_multiplexer(...)
660
+ except Exception as exc: # one bad package must not hide the rest
661
+ record_load_error(_EXTERNAL_ERRORS, ep, exc)
662
+
663
+
664
+ def external_backend_errors() -> dict[str, str]:
665
+ """Entry-point name -> failure reason(s) for every external backend that failed
666
+ to load this process (empty when all loaded). For diagnostics surfaces.
667
+
668
+ One value may carry MORE than one reason, ``"; "``-joined: two distributions
669
+ may advertise the same entry-point name, and each of their failures is kept
670
+ (see :func:`~.entrypoints.record_load_error`). Each reason is labelled with
671
+ its distribution whenever one is resolvable."""
672
+ return dict(_EXTERNAL_ERRORS)
673
+
674
+
675
+ def configure_multiplexer(name: str | None, *, origin: Path | None = None) -> None:
676
+ """Install the policy ``[mux] backend`` choice (``None``/``""`` = auto).
677
+
678
+ Called once per CLI invocation (``cli.main``, after parsing ``--project``)
679
+ before any :func:`get_multiplexer` consumer runs, so probe/diagnose/attach —
680
+ which never load policy themselves — select under the persisted choice too.
681
+ Idempotent: the selection cache is cleared only when the effective value
682
+ changes, so the process-wide singleton identity survives repeated
683
+ same-value configuration."""
684
+ global _CONFIGURED
685
+ new = (name, origin) if name else None
686
+ if new == _CONFIGURED:
687
+ return
688
+ _CONFIGURED = new
689
+ get_multiplexer.cache_clear()
690
+
691
+
692
+ def _known() -> str:
693
+ return ", ".join(name for name, _, _ in _BACKENDS) or "(none registered)"
694
+
695
+
696
+ def _factory_by_name(name: str) -> Callable[[], TerminalMultiplexer] | None:
697
+ for reg_name, _, factory in _BACKENDS:
698
+ if reg_name == name: # duplicate registrations: first wins, as in the loop below
699
+ return factory
700
+ return None
701
+
702
+
703
+ def _usable(backend: TerminalMultiplexer) -> bool:
704
+ """``available()`` read through a guard: selection must never crash on a
705
+ backend's host probe, so a missing or raising probe reads as unavailable."""
706
+ try:
707
+ return bool(backend.available())
708
+ except Exception:
709
+ return False
710
+
711
+
712
+ def backend_forced() -> bool:
713
+ """True when selection is pinned by the env var or the policy choice.
714
+
715
+ A forced name bypasses ``available()`` throughout (an explicit choice is
716
+ trusted; the backend fails loudly if it can't run), so launch preflights
717
+ that refuse an unusable backend must stand down for it too — via
718
+ :func:`mux_usable`, which stands down loudly."""
719
+ return bool(envvars.mux_backend()) or _CONFIGURED is not None
720
+
721
+
722
+ _FORCED_UNUSABLE_WARNED = False
723
+
724
+
725
+ def mux_usable(backend: TerminalMultiplexer | None = None) -> bool:
726
+ """The one usability gate for launch preflights and TUI observers
727
+ (attach, liveness, prune): the backend probes available, or its selection
728
+ is forced. Every gate must share this rule — if launch trusts a forced
729
+ backend but observers don't, a launched run becomes invisible to the rest
730
+ of the TUI with no error anywhere.
731
+
732
+ A forced backend that probes unavailable is still trusted, but says so
733
+ once per process on stderr: a missing binary fails loudly on first use
734
+ anyway, while a version-gated binary works right up until the gated defect
735
+ fires — proceeding must not be silent."""
736
+ global _FORCED_UNUSABLE_WARNED
737
+ if backend is None:
738
+ backend = get_multiplexer()
739
+ if _usable(backend):
740
+ return True
741
+ if not backend_forced():
742
+ return False
743
+ if not _FORCED_UNUSABLE_WARNED:
744
+ _FORCED_UNUSABLE_WARNED = True
745
+ try:
746
+ version = fold_version(backend.version())
747
+ except Exception: # a broken probe must not break the warning
748
+ version = None
749
+ print(
750
+ f"warning: forced multiplexer backend {type(backend).__name__} reports "
751
+ f"unavailable (version: {version!r}); proceeding because the choice is "
752
+ "pinned — a version-gated backend can misbehave mid-run",
753
+ file=sys.stderr,
754
+ )
755
+ return True
756
+
757
+
758
+ def _select() -> tuple[TerminalMultiplexer, str, str]:
759
+ """Resolve the backend by precedence; returns ``(instance, name, reason)``.
760
+
761
+ 1. ``env`` — ``FROID_LOOP_MUX_BACKEND`` forces a backend by name
762
+ 2. ``policy`` — the ``[mux] backend`` choice installed by
763
+ :func:`configure_multiplexer`, same forced-by-name semantics
764
+ 3. ``platform-default`` — this platform's default, iff registered +
765
+ platform match + available
766
+ 4. ``first-match`` — first registered backend matching the platform that is
767
+ available (registration order breaks ties among available backends)
768
+ 5. ``fallback`` — the historical behavior, preserved so a POSIX host without
769
+ tmux still returns TmuxMultiplexer and ``validate`` reports it
770
+ unavailable: first platform match regardless of availability, then tmux
771
+
772
+ A forced name (1-2) bypasses both the platform predicate and ``available()``
773
+ — an explicit choice is trusted, and the backend itself fails loudly if it
774
+ can't run. A forced name matching nothing is a misconfiguration; never
775
+ silently fall back to tmux (wrong/unsafe on a non-POSIX host)."""
776
+ _load_builtin_backends()
777
+ _load_external_backends()
778
+ forced = envvars.mux_backend()
779
+ if forced:
780
+ factory = _factory_by_name(forced)
781
+ if factory is None:
782
+ raise MultiplexerError(
783
+ f"FROID_LOOP_MUX_BACKEND={forced!r} matches no registered backend; known: {_known()}"
784
+ )
785
+ return factory(), forced, "env"
786
+ if _CONFIGURED is not None:
787
+ name, origin = _CONFIGURED
788
+ factory = _factory_by_name(name)
789
+ if factory is None:
790
+ where = f"[mux] backend = {name!r}" + (f" in {origin}" if origin else "")
791
+ raise MultiplexerError(f"{where} matches no registered backend; known: {_known()}")
792
+ return factory(), name, "policy"
793
+
794
+ # Construct each candidate at most once across the remaining steps.
795
+ instances: dict[str, TerminalMultiplexer] = {}
796
+
797
+ def _instance(name: str, factory: Callable[[], TerminalMultiplexer]) -> TerminalMultiplexer:
798
+ if name not in instances:
799
+ instances[name] = factory()
800
+ return instances[name]
801
+
802
+ default = _PLATFORM_DEFAULTS.get(sys.platform, _DEFAULT_BACKEND)
803
+ for name, matches, factory in _BACKENDS:
804
+ if name != default:
805
+ continue
806
+ # first registration with the default name wins, as everywhere else;
807
+ # it must also claim this platform — a name-colliding backend for
808
+ # another platform doesn't get defaulted onto this one.
809
+ if matches(sys.platform):
810
+ backend = _instance(name, factory)
811
+ if _usable(backend):
812
+ return backend, name, "platform-default"
813
+ break
814
+ for name, matches, factory in _BACKENDS:
815
+ if matches(sys.platform) and _usable(_instance(name, factory)):
816
+ return instances[name], name, "first-match"
817
+ for name, matches, factory in _BACKENDS:
818
+ if matches(sys.platform):
819
+ return _instance(name, factory), name, "fallback"
820
+ from .tmux_backend import TmuxMultiplexer # bottom fallback, as before
821
+
822
+ return TmuxMultiplexer(), "tmux", "fallback"
823
+
824
+
825
+ @functools.lru_cache(maxsize=1)
826
+ def get_multiplexer() -> TerminalMultiplexer:
827
+ """Return the process-wide terminal multiplexer, selected by registry.
828
+
829
+ Selection precedence lives in :func:`_select` (env var, policy choice,
830
+ platform default, first available match, historical fallback). Cached —
831
+ tests that flip the env var must call ``get_multiplexer.cache_clear()``;
832
+ :func:`register_multiplexer` and :func:`configure_multiplexer` clear it
833
+ themselves."""
834
+ return _select()[0]
835
+
836
+
837
+ @dataclass(frozen=True)
838
+ class MuxBackendInfo:
839
+ """One registered backend's detection row, for ``froid-loop mux`` and the
840
+ ``validate`` preflight."""
841
+
842
+ name: str
843
+ matches_platform: bool
844
+ available: bool
845
+ version: str | None
846
+ selected: bool
847
+ reason: str # "" unless selected: env | policy | platform-default | first-match | fallback
848
+ # The diagnostic version() dropped, when it answered None with the binary
849
+ # present (TerminalMultiplexer.version_error). Defaulted so it is additive
850
+ # for anyone constructing this row positionally.
851
+ version_error: str | None = None
852
+
853
+
854
+ def detect_multiplexers() -> list[MuxBackendInfo]:
855
+ """Probe every registered backend: availability, version, platform match,
856
+ and which one :func:`_select` would pick (with its reason).
857
+
858
+ Never raises — this feeds diagnostics, which must work on a misconfigured
859
+ host: a forced unknown name yields rows with no selected mark, and a
860
+ backend whose factory or probes blow up reads as unavailable. Constructs
861
+ every registered backend, so factories must stay cheap, side-effect-free
862
+ constructors (true of the tmux family)."""
863
+ _load_builtin_backends()
864
+ _load_external_backends()
865
+ try:
866
+ _, selected_name, reason = _select()
867
+ except MultiplexerError:
868
+ selected_name, reason = None, ""
869
+ rows: list[MuxBackendInfo] = []
870
+ seen: set[str] = set()
871
+ for name, matches, factory in _BACKENDS:
872
+ if name in seen: # duplicate registrations: only the selectable (first) one is shown
873
+ continue
874
+ seen.add(name)
875
+ try:
876
+ matches_platform = bool(matches(sys.platform))
877
+ except Exception:
878
+ matches_platform = False
879
+ version: str | None = None
880
+ version_error: str | None = None
881
+ try:
882
+ backend = factory()
883
+ available = _usable(backend)
884
+ except Exception:
885
+ available = False
886
+ else:
887
+ # version() is cosmetic: its failure must not overwrite the
888
+ # already-computed availability (a selected backend would
889
+ # otherwise show a contradictory available=False row).
890
+ try:
891
+ version = fold_version(backend.version())
892
+ except Exception:
893
+ version = None
894
+ if version is None:
895
+ # Read only after version(), which is what it describes, and
896
+ # only when there is a None to explain. Guarded like every other
897
+ # probe here — this function never raises.
898
+ try:
899
+ version_error = backend.version_error()
900
+ except Exception:
901
+ version_error = None
902
+ selected = name == selected_name
903
+ rows.append(
904
+ MuxBackendInfo(
905
+ name=name,
906
+ matches_platform=matches_platform,
907
+ available=available,
908
+ version=version,
909
+ selected=selected,
910
+ reason=reason if selected else "",
911
+ version_error=version_error,
912
+ )
913
+ )
914
+ return rows