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