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,630 @@
|
|
|
1
|
+
"""Shared tmux-family backend base for the terminal-multiplexer seam.
|
|
2
|
+
|
|
3
|
+
This module is the **quarantine** for tmux/POSIX-shell knowledge: every tmux
|
|
4
|
+
invocation and POSIX-shell trailer lives here (and in its POSIX leaf
|
|
5
|
+
:mod:`.tmux_backend`). The point of the split is that a tmux-*family* backend —
|
|
6
|
+
the native-Windows :mod:`.psmux_backend` leaf — can subclass :class:`BaseTmuxBackend` and
|
|
7
|
+
swap only class attributes (:attr:`BaseTmuxBackend._BINARY` for the spawned
|
|
8
|
+
binary, :attr:`BaseTmuxBackend._ENCODING` / :attr:`BaseTmuxBackend._ERRORS`
|
|
9
|
+
for output decoding — a scrubbed
|
|
10
|
+
per-call ``env`` is a ``_run`` parameter, and an :meth:`BaseTmuxBackend._run`
|
|
11
|
+
override is left for timeout tweaks) plus the shell-dialect hooks (``_shell_wrap``, ``_join_argv``,
|
|
12
|
+
``_parked_trailer``, ``_source_prefix``, ``_window_launch`` and the
|
|
13
|
+
``_EXIT_CAPTURE``/``_ECHO``/``_PARK`` fragments), **without editing**
|
|
14
|
+
:mod:`.tmux_backend`. For :meth:`~BaseTmuxBackend.new_window` /
|
|
15
|
+
:meth:`~BaseTmuxBackend.new_parked_window` the hooks replace method-body
|
|
16
|
+
overrides entirely; :meth:`~BaseTmuxBackend.pipe_pane` still hands the
|
|
17
|
+
multiplexer a POSIX ``cat >>`` redirection, so a non-POSIX leaf overrides it
|
|
18
|
+
directly, alongside whatever divergences its multiplexer forces on it.
|
|
19
|
+
|
|
20
|
+
Every method that talks to tmux funnels through :meth:`BaseTmuxBackend._run`, the
|
|
21
|
+
one place a subprocess is spawned. See :mod:`.multiplexer` for the contract.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import shlex
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .multiplexer import MultiplexerError, TerminalMultiplexer, fold_version
|
|
35
|
+
|
|
36
|
+
TMUX_TIMEOUT_S = 30
|
|
37
|
+
# Per-window option value (vs a pane target) telling the parked trailer to detach
|
|
38
|
+
# the client rather than switch it. Recorded targets are backend-composed pane
|
|
39
|
+
# targets (bare %N on tmux, =session:%N on psmux — see
|
|
40
|
+
# TerminalMultiplexer.current_return_target), so this never collides with one.
|
|
41
|
+
PARKED_RETURN_DETACH = "detach"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TmuxError(MultiplexerError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BaseTmuxBackend(TerminalMultiplexer):
|
|
49
|
+
"""tmux-family backend: all argv construction and every contract method, with
|
|
50
|
+
one overridable subprocess primitive (:meth:`_run`) every call funnels through.
|
|
51
|
+
The seam-canonical target grammar (``=session[:window]``, see
|
|
52
|
+
:meth:`TerminalMultiplexer.target`) coincides with tmux's exact-match target
|
|
53
|
+
syntax, so targets pass straight through to tmux — never parsed here."""
|
|
54
|
+
|
|
55
|
+
#: The binary every spawn, PATH probe, and in-source client verb targets.
|
|
56
|
+
#: A tmux-family leaf whose binary is not literally named ``tmux`` overrides
|
|
57
|
+
#: this one name instead of any method body.
|
|
58
|
+
_BINARY = "tmux"
|
|
59
|
+
#: Output decoding for captured tmux text. ``None`` (POSIX) = locale default,
|
|
60
|
+
#: byte-identical to a bare ``text=True``; a Windows leaf sets ``"utf-8"``.
|
|
61
|
+
_ENCODING: str | None = None
|
|
62
|
+
#: Decode error handling to pair with :attr:`_ENCODING`. ``"backslashreplace"``
|
|
63
|
+
#: on every platform (#380): a stray byte the codec cannot decode degrades
|
|
64
|
+
#: visibly to a ``\xNN`` escape in the captured text instead of raising
|
|
65
|
+
#: mid-capture. Honored even where :attr:`_ENCODING` is None, so POSIX keeps
|
|
66
|
+
#: the locale codec and only stops being strict. A leaf may still override.
|
|
67
|
+
_ERRORS: str | None = "backslashreplace"
|
|
68
|
+
#: Diagnostic from the last :meth:`version` probe (see
|
|
69
|
+
#: :meth:`TerminalMultiplexer.version_error`). A class-level default so an
|
|
70
|
+
#: instance that never probed answers None instead of AttributeError.
|
|
71
|
+
#: Per-instance and unsynchronized: only a caller that OWNS the instance may
|
|
72
|
+
#: read it back (``detect_multiplexers`` builds one per row). The
|
|
73
|
+
#: ``get_multiplexer()`` singleton is shared across the TUI's worker threads,
|
|
74
|
+
#: and ``mux_usable`` probes ``version()`` on it — a reader there can be
|
|
75
|
+
#: handed another thread's failure.
|
|
76
|
+
_version_error: str | None = None
|
|
77
|
+
|
|
78
|
+
def _run(
|
|
79
|
+
self,
|
|
80
|
+
argv: list[str],
|
|
81
|
+
*,
|
|
82
|
+
check: bool = True,
|
|
83
|
+
env: dict[str, str] | None = None,
|
|
84
|
+
) -> subprocess.CompletedProcess[str]:
|
|
85
|
+
"""The ONE place tmux is spawned. ``argv`` are the args after the binary.
|
|
86
|
+
|
|
87
|
+
With ``check=True`` a non-zero exit raises :class:`TmuxError` (the strict
|
|
88
|
+
form behind ``_tmux``); with ``check=False`` the completed process is
|
|
89
|
+
returned as-is so callers can apply their own tolerant return-code handling.
|
|
90
|
+
A timeout / missing binary always propagates (``TimeoutExpired`` / ``OSError``)
|
|
91
|
+
so callers' existing ``try/except`` still fires.
|
|
92
|
+
|
|
93
|
+
``env`` (keyword-only, default ``None`` → inherit the parent env) lets one
|
|
94
|
+
caller spawn with a scrubbed env without mutating this process's; decoding is
|
|
95
|
+
the :attr:`_ENCODING` class attr. A leaf sets those rather than overriding here.
|
|
96
|
+
Build a scrubbed env by copying the parent env and *removing* the offending
|
|
97
|
+
vars — not from scratch (on Windows the child needs ``SystemRoot`` etc.).
|
|
98
|
+
"""
|
|
99
|
+
proc = subprocess.run(
|
|
100
|
+
[self._BINARY, *argv],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
encoding=self._ENCODING,
|
|
104
|
+
errors=self._ERRORS,
|
|
105
|
+
env=env,
|
|
106
|
+
timeout=TMUX_TIMEOUT_S,
|
|
107
|
+
)
|
|
108
|
+
if check and proc.returncode != 0:
|
|
109
|
+
raise TmuxError(f"{self._BINARY} {' '.join(argv[:2])} failed: {proc.stderr.strip()}")
|
|
110
|
+
return proc
|
|
111
|
+
|
|
112
|
+
def _tmux(self, *args: str) -> str:
|
|
113
|
+
# The strict form: a non-zero exit already raises TmuxError inside _run.
|
|
114
|
+
# A timeout / missing binary escapes _run raw, so trap it here once and
|
|
115
|
+
# re-raise as the seam type — this covers every _tmux caller (new_session,
|
|
116
|
+
# set_session_option, new_window, new_parked_window, send_text) and, via
|
|
117
|
+
# its own `except TmuxError`, pipe_pane too.
|
|
118
|
+
try:
|
|
119
|
+
return self._run(list(args), check=True).stdout.strip()
|
|
120
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
121
|
+
raise TmuxError(f"{self._BINARY} {args[0] if args else ''} failed: {exc}") from exc
|
|
122
|
+
|
|
123
|
+
# ----------------------------------------------------------- sessions
|
|
124
|
+
|
|
125
|
+
def has_session(self, name: str) -> bool:
|
|
126
|
+
# has-session returns nonzero for an absent session (a normal answer, not an
|
|
127
|
+
# error), so this can't use check=True. But a timeout or a missing binary
|
|
128
|
+
# is a real backend failure: raise the seam type so callers catch it via
|
|
129
|
+
# MultiplexerError instead of a raw subprocess error escaping.
|
|
130
|
+
#
|
|
131
|
+
# Strength of a False: EVERY nonzero exit maps to it — "no such session",
|
|
132
|
+
# "no server running", and a target the grammar could not parse alike. That
|
|
133
|
+
# is exactly right for the create-if-missing callers this predicate was
|
|
134
|
+
# written for, where a wrong False self-corrects on the next line. It is
|
|
135
|
+
# weaker than it looks for a caller that reports the answer as evidence
|
|
136
|
+
# (#489), which is why that one words its output as what the negative
|
|
137
|
+
# withdraws rather than what it proves. Deliberately NOT tightened here:
|
|
138
|
+
# `list_window_ids` raises on transport failure because it backs a liveness
|
|
139
|
+
# probe, and this predicate has no such duty to its existing callers.
|
|
140
|
+
try:
|
|
141
|
+
probe = self._run(["has-session", "-t", f"={name}"], check=False)
|
|
142
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
143
|
+
raise TmuxError(f"{self._BINARY} has-session failed: {exc}") from exc
|
|
144
|
+
return probe.returncode == 0
|
|
145
|
+
|
|
146
|
+
def new_session(
|
|
147
|
+
self, name: str, cwd: Path, cols: int | None = None, lines: int | None = None
|
|
148
|
+
) -> None:
|
|
149
|
+
# Window 0 is a plain shell so the session survives task windows closing.
|
|
150
|
+
# Geometry is pinned only when both dimensions are given (detached agent
|
|
151
|
+
# sessions); the control session omits it and takes tmux's default size.
|
|
152
|
+
geometry = ["-x", str(cols), "-y", str(lines)] if cols and lines else []
|
|
153
|
+
self._tmux("new-session", "-d", "-s", name, "-c", str(cwd), *geometry)
|
|
154
|
+
|
|
155
|
+
def set_session_option(self, name: str, option: str, value: str) -> None:
|
|
156
|
+
# set-option has no '=' exact-match form; callers pass a unique full
|
|
157
|
+
# session name so plain-name targeting resolves it unambiguously.
|
|
158
|
+
self._tmux("set-option", "-t", name, option, value)
|
|
159
|
+
|
|
160
|
+
def kill_session(self, name: str) -> None:
|
|
161
|
+
# Tolerant of the binary being absent / the session already gone: a
|
|
162
|
+
# best-effort teardown backstop, never a hard failure.
|
|
163
|
+
if not shutil.which(self._BINARY):
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
self._run(["kill-session", "-t", f"={name}"], check=False)
|
|
167
|
+
except (subprocess.SubprocessError, OSError):
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def list_sessions(self) -> list[str]:
|
|
171
|
+
# [] when the binary is missing, no server is running, or the query fails
|
|
172
|
+
# — the absence of sessions and the absence of the multiplexer are
|
|
173
|
+
# indistinguishable here and callers treat both as "nothing live".
|
|
174
|
+
if not shutil.which(self._BINARY):
|
|
175
|
+
return []
|
|
176
|
+
try:
|
|
177
|
+
proc = self._run(["list-sessions", "-F", "#{session_name}"], check=False)
|
|
178
|
+
except (subprocess.SubprocessError, OSError):
|
|
179
|
+
return []
|
|
180
|
+
if proc.returncode != 0: # no server / no sessions
|
|
181
|
+
return []
|
|
182
|
+
return [line for line in proc.stdout.splitlines() if line]
|
|
183
|
+
|
|
184
|
+
def session_options(self, option: str) -> dict[str, str]:
|
|
185
|
+
# Map session name -> value of ``option`` ("" when unset). Same missing
|
|
186
|
+
# binary / no-server tolerance as list_sessions().
|
|
187
|
+
if not shutil.which(self._BINARY):
|
|
188
|
+
return {}
|
|
189
|
+
try:
|
|
190
|
+
proc = self._run(
|
|
191
|
+
["list-sessions", "-F", f"#{{session_name}}\t#{{{option}}}"], check=False
|
|
192
|
+
)
|
|
193
|
+
except (subprocess.SubprocessError, OSError):
|
|
194
|
+
return {}
|
|
195
|
+
if proc.returncode != 0: # no server / no sessions
|
|
196
|
+
return {}
|
|
197
|
+
options: dict[str, str] = {}
|
|
198
|
+
for line in proc.stdout.splitlines():
|
|
199
|
+
name, _, value = line.partition("\t")
|
|
200
|
+
if name:
|
|
201
|
+
options[name] = value
|
|
202
|
+
return options
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------- shell dialect seam
|
|
205
|
+
|
|
206
|
+
# new_window / new_parked_window own the tmux argv construction and the
|
|
207
|
+
# parked-window protocol; everything shell-*dialect* about them routes
|
|
208
|
+
# through the hooks below so a non-POSIX leaf overrides string fragments,
|
|
209
|
+
# never a contract method body. Defaults are POSIX sh, so a non-POSIX leaf
|
|
210
|
+
# must override every hook whose default emits sh syntax: the three
|
|
211
|
+
# fragments, _join_argv, _parked_trailer, and _shell_wrap.
|
|
212
|
+
|
|
213
|
+
#: Fragments of the parked recipe. The banner line reads ``$ec`` verbatim
|
|
214
|
+
#: and stays dialect-neutral only because every dialect of the family
|
|
215
|
+
#: interpolates ``$ec`` inside its double-quoted strings — so an
|
|
216
|
+
#: _EXIT_CAPTURE override MUST bind the variable ``ec``.
|
|
217
|
+
_EXIT_CAPTURE = "ec=$?"
|
|
218
|
+
_ECHO = "echo"
|
|
219
|
+
_PARK = "read -r"
|
|
220
|
+
|
|
221
|
+
def _join_argv(self, argv: list[str]) -> str:
|
|
222
|
+
"""Render ``argv`` as one shell command line in this dialect."""
|
|
223
|
+
return shlex.join(argv)
|
|
224
|
+
|
|
225
|
+
def _source_prefix(self) -> str:
|
|
226
|
+
"""Dialect prelude prepended to a parked window's shell source.
|
|
227
|
+
|
|
228
|
+
The recipe adds no separator, so an override must return ``""`` or a
|
|
229
|
+
self-terminating statement ending in ``"; "``.
|
|
230
|
+
"""
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
def _shell_wrap(self, source: str) -> list[str]:
|
|
234
|
+
# Explicit `sh -c` (the user's login shell may be fish) — the one place
|
|
235
|
+
# a window's shell source is turned into a spawnable argv.
|
|
236
|
+
return ["sh", "-c", source]
|
|
237
|
+
|
|
238
|
+
def _parked_trailer(self, return_opt: str) -> str:
|
|
239
|
+
# After the park, switch an attached client back to its origin pane.
|
|
240
|
+
# `return_opt` names the per-window option; on its recorded value:
|
|
241
|
+
# - a pane target (backend-composed by current_return_target, replayed
|
|
242
|
+
# opaquely here — bare %N on tmux, =session:%N on psmux): switch
|
|
243
|
+
# that client back there (`switch-client -l` is a best-effort
|
|
244
|
+
# fallback when it is gone);
|
|
245
|
+
# - PARKED_RETURN_DETACH: detach the client so a blocking
|
|
246
|
+
# `tmux attach` returns and a suspended TUI resumes;
|
|
247
|
+
# - unset/empty: nobody attached interactively -> park as-is.
|
|
248
|
+
# The tmux verbs are protocol-identical across the family; only the
|
|
249
|
+
# surrounding control-flow syntax is dialect-specific.
|
|
250
|
+
mux = self._BINARY
|
|
251
|
+
return (
|
|
252
|
+
f"ret=$({mux} show-options -wqv {shlex.quote(return_opt)} 2>/dev/null); "
|
|
253
|
+
f'if [ "$ret" = "{PARKED_RETURN_DETACH}" ]; then {mux} detach-client 2>/dev/null; '
|
|
254
|
+
'elif [ -n "$ret" ]; then '
|
|
255
|
+
f'{mux} switch-client -t "$ret" 2>/dev/null || {mux} switch-client -l 2>/dev/null; '
|
|
256
|
+
"fi"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _window_launch(self, env: dict[str, str], command: str) -> list[str]:
|
|
260
|
+
"""Trailing ``new-window`` args: env injection plus the command itself.
|
|
261
|
+
|
|
262
|
+
Part of the dialect seam because the env-injection *strategy* is
|
|
263
|
+
dialect-coupled: bare ``-e`` flags plus the raw command here, an
|
|
264
|
+
in-source prelude for a leaf whose shell wraps the command.
|
|
265
|
+
"""
|
|
266
|
+
env_args: list[str] = []
|
|
267
|
+
for key, value in env.items():
|
|
268
|
+
env_args += ["-e", f"{key}={value}"]
|
|
269
|
+
return [*env_args, command]
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------ windows
|
|
272
|
+
|
|
273
|
+
def new_window(
|
|
274
|
+
self, session: str, name: str, cwd: Path, env: dict[str, str], command: str
|
|
275
|
+
) -> str:
|
|
276
|
+
return self._tmux(
|
|
277
|
+
"new-window",
|
|
278
|
+
"-t",
|
|
279
|
+
f"={session}:",
|
|
280
|
+
"-n",
|
|
281
|
+
name,
|
|
282
|
+
"-c",
|
|
283
|
+
str(cwd),
|
|
284
|
+
"-P",
|
|
285
|
+
"-F",
|
|
286
|
+
"#{window_id}",
|
|
287
|
+
*self._window_launch(env, command),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def new_parked_window(
|
|
291
|
+
self, session: str, name: str, cwd: Path, argv: list[str], return_opt: str
|
|
292
|
+
) -> str:
|
|
293
|
+
# Run argv, then park on a blocking read so the exit status stays
|
|
294
|
+
# inspectable instead of tmux closing the window the moment the process
|
|
295
|
+
# exits; the trailer (see _parked_trailer) then returns an attached
|
|
296
|
+
# client to where it came from.
|
|
297
|
+
source = self._source_prefix() + (
|
|
298
|
+
f"{self._join_argv(argv)}; {self._EXIT_CAPTURE}; "
|
|
299
|
+
f'{self._ECHO} "[froid-loop exited $ec — press enter]"; '
|
|
300
|
+
f"{self._PARK}; {self._parked_trailer(return_opt)}"
|
|
301
|
+
)
|
|
302
|
+
return self._tmux(
|
|
303
|
+
"new-window",
|
|
304
|
+
"-d",
|
|
305
|
+
"-P",
|
|
306
|
+
"-F",
|
|
307
|
+
"#{window_id}",
|
|
308
|
+
"-t",
|
|
309
|
+
f"={session}:",
|
|
310
|
+
"-n",
|
|
311
|
+
name,
|
|
312
|
+
"-c",
|
|
313
|
+
str(cwd),
|
|
314
|
+
*self._shell_wrap(source),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def list_window_ids(self, session: str) -> list[str]:
|
|
318
|
+
# display-message -t <dead-window> exits 0 with empty output, so list the
|
|
319
|
+
# session's window ids and check membership instead.
|
|
320
|
+
#
|
|
321
|
+
# A transport failure (timeout / missing binary) must RAISE, not return [].
|
|
322
|
+
# window_alive() is the engine's liveness probe; a sentinel [] would falsely
|
|
323
|
+
# read as "window dead -> session crashed" on a mere tmux hang. The honest
|
|
324
|
+
# answer to "is it alive?" is "unknowable" -> MultiplexerError. A real dead
|
|
325
|
+
# window still returns [] via the returncode != 0 path below (no exception).
|
|
326
|
+
#
|
|
327
|
+
# UnicodeError is a transport failure too. _run no longer decodes strictly
|
|
328
|
+
# on any platform (_ERRORS is backslashreplace, #380), so this arm is now
|
|
329
|
+
# defence for a leaf that overrides _ERRORS back to a strict handler: such
|
|
330
|
+
# a decode raises a ValueError-family error that neither exception arm
|
|
331
|
+
# above names. It stays because the seam-honesty contract above is what
|
|
332
|
+
# callers rely on — prune_ctl_windows takes its post-kill verdict from this
|
|
333
|
+
# probe and only a MultiplexerError lands the candidates in `unverifiable`
|
|
334
|
+
# rather than aborting cleanup mid-receipt (#435).
|
|
335
|
+
try:
|
|
336
|
+
probe = self._run(
|
|
337
|
+
["list-windows", "-t", f"={session}", "-F", "#{window_id}"], check=False
|
|
338
|
+
)
|
|
339
|
+
except (subprocess.TimeoutExpired, OSError, UnicodeError) as exc:
|
|
340
|
+
raise TmuxError(f"{self._BINARY} list-windows failed: {exc}") from exc
|
|
341
|
+
if probe.returncode != 0:
|
|
342
|
+
return []
|
|
343
|
+
return probe.stdout.split()
|
|
344
|
+
|
|
345
|
+
def pipe_pane(self, window_id: str, log_file: Path) -> None:
|
|
346
|
+
# A CLI that crashes on launch (bad args, instant auth failure) can take
|
|
347
|
+
# its window down before pipe-pane attaches, which races as "can't find
|
|
348
|
+
# window". That is not a setup failure, so tolerate it instead of raising
|
|
349
|
+
# — but say so, or an empty run log is unexplainable.
|
|
350
|
+
try:
|
|
351
|
+
self._tmux("pipe-pane", "-t", window_id, "-o", f"cat >> {shlex.quote(str(log_file))}")
|
|
352
|
+
except TmuxError as exc:
|
|
353
|
+
print(
|
|
354
|
+
f"warning: pipe-pane log capture failed for {window_id}: {exc}",
|
|
355
|
+
file=sys.stderr,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def send_text(self, window_id: str, text: str) -> None:
|
|
359
|
+
self._tmux("send-keys", "-t", window_id, "-l", text)
|
|
360
|
+
time.sleep(0.3) # let the TUI ingest the paste before submitting
|
|
361
|
+
self._tmux("send-keys", "-t", window_id, "Enter")
|
|
362
|
+
|
|
363
|
+
def kill_window(self, target: str) -> None:
|
|
364
|
+
# Best-effort teardown: a hang / missing binary is no worse than the window
|
|
365
|
+
# already being gone, so swallow to the documented no-op sentinel. A
|
|
366
|
+
# non-zero exit still says so out loud — the return value stays None and
|
|
367
|
+
# nothing raises — but only when the window the target names is still
|
|
368
|
+
# there to leak.
|
|
369
|
+
#
|
|
370
|
+
# An ALREADY-GONE window exits non-zero too, and that is ordinary
|
|
371
|
+
# teardown, not a fault — the DOMINANT case, not an edge one:
|
|
372
|
+
# CodingCLIAdapter.run kills in a `finally` on every session, and a
|
|
373
|
+
# session that completed by window death has nothing left to kill. The
|
|
374
|
+
# return code cannot separate the two, so the survivor is what decides.
|
|
375
|
+
# Replaying the failed target cannot decide it: a target the kill could
|
|
376
|
+
# not resolve is a target a probe cannot resolve either, so a
|
|
377
|
+
# same-target probe reads "gone" for the very failures it exists to
|
|
378
|
+
# catch. For a session-qualified id target the session's OWN window
|
|
379
|
+
# list answers instead — it resolves independently of the failed
|
|
380
|
+
# target, and a leaked window is by definition still in it. So the
|
|
381
|
+
# warning covers exactly the detectable leak class: the kill failed and
|
|
382
|
+
# the window it named is still listed. A target that names no window at
|
|
383
|
+
# all leaks nothing and stays silent. The probe is paid only on a
|
|
384
|
+
# non-zero exit — which on psmux includes ordinary window-death
|
|
385
|
+
# teardown, one listing alongside the ones the psmux override already
|
|
386
|
+
# pays. An unreadable probe stays silent: this is a diagnostic, and
|
|
387
|
+
# guessing would put the noise back on the path the probe exists to
|
|
388
|
+
# clear.
|
|
389
|
+
try:
|
|
390
|
+
proc = self._run(["kill-window", "-t", target], check=False)
|
|
391
|
+
except (subprocess.SubprocessError, OSError):
|
|
392
|
+
return
|
|
393
|
+
if proc.returncode == 0 or not self._window_survived_kill(target):
|
|
394
|
+
return
|
|
395
|
+
detail = proc.stderr.strip()
|
|
396
|
+
print(
|
|
397
|
+
f"warning: kill-window {target} exited {proc.returncode} and the window "
|
|
398
|
+
f"is still alive{f': {detail}' if detail else ''}",
|
|
399
|
+
file=sys.stderr,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def _window_survived_kill(self, target: str) -> bool:
|
|
403
|
+
"""Whether the window ``target`` names outlived a failed kill.
|
|
404
|
+
|
|
405
|
+
False for both "provably gone" and "cannot tell" — the caller only warns,
|
|
406
|
+
so an unanswerable probe must not manufacture a warning.
|
|
407
|
+
"""
|
|
408
|
+
session, sep, window = target.partition(":")
|
|
409
|
+
if sep and window.startswith("@"):
|
|
410
|
+
# Membership is checked against both id shapes list_window_ids can
|
|
411
|
+
# answer with: bare `@N` (this base) and session-qualified (the
|
|
412
|
+
# psmux override qualifies to match its native_id form). Both are
|
|
413
|
+
# rebuilt from the `=`-stripped session the listing was actually
|
|
414
|
+
# asked for, never compared against the raw target: an exact-match
|
|
415
|
+
# `=session:@N` target — the shape _option_scope also normalizes —
|
|
416
|
+
# matches neither listed shape verbatim, and would read a survivor
|
|
417
|
+
# as gone.
|
|
418
|
+
session = session.removeprefix("=")
|
|
419
|
+
try:
|
|
420
|
+
live = self.list_window_ids(session)
|
|
421
|
+
except TmuxError:
|
|
422
|
+
return False
|
|
423
|
+
return f"{session}:{window}" in live or window in live
|
|
424
|
+
# An unqualified or name-token target carries no session to list, so
|
|
425
|
+
# only the same-resolution probe remains: blind to a wrong-target leak,
|
|
426
|
+
# but it still catches a kill that failed while the target resolves.
|
|
427
|
+
# UnicodeError for the same reason list_window_ids names it: a leaf
|
|
428
|
+
# overriding _ERRORS back to a strict codec raises a ValueError-family
|
|
429
|
+
# decode error neither other arm covers, and this helper never raises.
|
|
430
|
+
try:
|
|
431
|
+
probe = self._run(["list-panes", "-t", target], check=False)
|
|
432
|
+
except (subprocess.SubprocessError, OSError, UnicodeError):
|
|
433
|
+
return False
|
|
434
|
+
return probe.returncode == 0
|
|
435
|
+
|
|
436
|
+
def window_pane_pids(self, target: str) -> list[int]:
|
|
437
|
+
# Capability method (see the seam default): a transport failure, a dead
|
|
438
|
+
# window, or unparsable output all degrade to the documented "unknown"
|
|
439
|
+
# sentinel [] — this feeds the kill escalation, which must never be the
|
|
440
|
+
# thing that raises.
|
|
441
|
+
try:
|
|
442
|
+
probe = self._run(["list-panes", "-t", target, "-F", "#{pane_pid}"], check=False)
|
|
443
|
+
if probe.returncode != 0:
|
|
444
|
+
return []
|
|
445
|
+
return [int(line) for line in probe.stdout.split()]
|
|
446
|
+
except (subprocess.SubprocessError, OSError, ValueError):
|
|
447
|
+
return []
|
|
448
|
+
|
|
449
|
+
def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]:
|
|
450
|
+
fmt = "\t".join(f"#{{{field}}}" for field in fields)
|
|
451
|
+
try:
|
|
452
|
+
probe = self._run(["list-windows", "-t", f"={session}", "-F", fmt], check=False)
|
|
453
|
+
except (subprocess.SubprocessError, OSError):
|
|
454
|
+
return []
|
|
455
|
+
if probe.returncode != 0:
|
|
456
|
+
return []
|
|
457
|
+
rows: list[tuple[str, ...]] = []
|
|
458
|
+
for line in probe.stdout.splitlines():
|
|
459
|
+
# Bounded split, so the LAST field may itself contain tabs. An
|
|
460
|
+
# unbounded split turns a row whose last field carries arbitrary
|
|
461
|
+
# text into extra parts that the slice below then truncates,
|
|
462
|
+
# silently corrupting the value. Callers requesting a free-text
|
|
463
|
+
# field must therefore ask for it last; every current caller does.
|
|
464
|
+
# This is the seam's standing contract, not a fix for one caller:
|
|
465
|
+
# no field requested today can hold a tab — PROJECT_OPTION is a
|
|
466
|
+
# digest and window names carry a shape-validated run id — so the
|
|
467
|
+
# bound is here for the next free-text field rather than these.
|
|
468
|
+
# (A newline in a value still splits the row, which no parse here
|
|
469
|
+
# can undo; runs.project_tag's digest is what keeps the tag clear
|
|
470
|
+
# of one.)
|
|
471
|
+
parts = line.split("\t", len(fields) - 1)
|
|
472
|
+
parts += [""] * (len(fields) - len(parts)) # tolerate unset trailing fields
|
|
473
|
+
rows.append(tuple(parts[: len(fields)]))
|
|
474
|
+
return rows
|
|
475
|
+
|
|
476
|
+
def window_alive(self, session: str, window_id: str) -> bool:
|
|
477
|
+
return window_id in self.list_window_ids(session)
|
|
478
|
+
|
|
479
|
+
def select_window(self, target: str) -> None:
|
|
480
|
+
# Best-effort focus change: swallow a transport failure to the no-op sentinel.
|
|
481
|
+
try:
|
|
482
|
+
self._run(["select-window", "-t", target], check=False)
|
|
483
|
+
except (subprocess.SubprocessError, OSError):
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
def set_window_option(self, target: str, option: str, value: str) -> None:
|
|
487
|
+
try:
|
|
488
|
+
self._run(["set-option", "-w", "-t", target, option, value], check=False)
|
|
489
|
+
except (subprocess.SubprocessError, OSError):
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
def unset_window_option(self, target: str, option: str) -> None:
|
|
493
|
+
try:
|
|
494
|
+
self._run(["set-option", "-wu", "-t", target, option], check=False)
|
|
495
|
+
except (subprocess.SubprocessError, OSError):
|
|
496
|
+
pass
|
|
497
|
+
|
|
498
|
+
def show_window_option(self, target: str, option: str) -> str:
|
|
499
|
+
# "" reads as "option unset" — fine as the failure sentinel for a hang too.
|
|
500
|
+
try:
|
|
501
|
+
proc = self._run(["show-options", "-wqv", "-t", target, option], check=False)
|
|
502
|
+
except (subprocess.SubprocessError, OSError):
|
|
503
|
+
return ""
|
|
504
|
+
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
505
|
+
|
|
506
|
+
# ----------------------------------------------------- client / attach
|
|
507
|
+
|
|
508
|
+
def attach_target_argv(self, target: str) -> list[str]:
|
|
509
|
+
# Inside tmux, nesting an attach is refused, so switch this client
|
|
510
|
+
# instead (a `switch-client -l` brings it back).
|
|
511
|
+
if os.environ.get("TMUX"):
|
|
512
|
+
return [self._BINARY, "switch-client", "-t", target]
|
|
513
|
+
return [self._BINARY, "attach", "-t", target]
|
|
514
|
+
|
|
515
|
+
def current_pane_id(self) -> str | None:
|
|
516
|
+
return self._display_message("#{pane_id}")
|
|
517
|
+
|
|
518
|
+
def current_window_id(self) -> str | None:
|
|
519
|
+
return self._display_message("#{window_id}")
|
|
520
|
+
|
|
521
|
+
def current_session(self) -> str | None:
|
|
522
|
+
return self._display_message("#{session_name}")
|
|
523
|
+
|
|
524
|
+
def _display_message(self, fmt: str) -> str | None:
|
|
525
|
+
"""Resolve a tmux format string against this process's client, or None
|
|
526
|
+
when not inside tmux / tmux is unavailable. The TMUX guard is what makes
|
|
527
|
+
"not inside" honest: against a live server, display-message would answer
|
|
528
|
+
for some OTHER client's session and misreport a plain shell as being
|
|
529
|
+
inside tmux — callers (in_ctl_session, the attach return-pane recording)
|
|
530
|
+
branch on exactly that distinction."""
|
|
531
|
+
if not os.environ.get("TMUX"):
|
|
532
|
+
return None
|
|
533
|
+
try:
|
|
534
|
+
proc = self._run(["display-message", "-p", fmt], check=False)
|
|
535
|
+
except (subprocess.SubprocessError, OSError):
|
|
536
|
+
return None
|
|
537
|
+
return proc.stdout.strip() if proc.returncode == 0 else None
|
|
538
|
+
|
|
539
|
+
def detach_client(self) -> bool:
|
|
540
|
+
# Returns True iff a client was detached; a transport failure didn't
|
|
541
|
+
# detach anything, so False is the honest answer.
|
|
542
|
+
try:
|
|
543
|
+
proc = self._run(["detach-client"], check=False)
|
|
544
|
+
except (subprocess.SubprocessError, OSError):
|
|
545
|
+
return False
|
|
546
|
+
return proc.returncode == 0
|
|
547
|
+
|
|
548
|
+
def _attached_here(self) -> int | None:
|
|
549
|
+
"""Clients attached to this process's session, or None when tmux cannot
|
|
550
|
+
say. Only :meth:`switch_client`'s failure path needs it, so it is read
|
|
551
|
+
after the verb — nothing moved on that path, and the success path pays
|
|
552
|
+
no probe at all."""
|
|
553
|
+
text = self._display_message("#{session_attached}")
|
|
554
|
+
return int(text) if text is not None and text.isdigit() else None
|
|
555
|
+
|
|
556
|
+
def switch_client(self, target: str, last_fallback: bool = False) -> bool | None:
|
|
557
|
+
# rc 0 is a real move and needs no gate: tmux runs one server, and it
|
|
558
|
+
# refuses rather than no-ops when there is nobody to move.
|
|
559
|
+
#
|
|
560
|
+
# A nonzero rc is where the exit code stops being the whole answer. It
|
|
561
|
+
# is TWO facts wearing one code — a target this client cannot reach, and
|
|
562
|
+
# no client here at all — and only the first is the seam's False.
|
|
563
|
+
# Measured on tmux 3.7c from inside a pane whose server had no attached
|
|
564
|
+
# client: `-t <live session>`, `-t <other session>`, `-l` and `-t
|
|
565
|
+
# <nonexistent>` ALL exit 1 with "no current client". So a bare rc would
|
|
566
|
+
# answer False — "no switch, and the client is still here" — for the one
|
|
567
|
+
# state where there is no client here at all, which is #659's hazard on
|
|
568
|
+
# the default backend. The attached count is what separates them, the
|
|
569
|
+
# same gate the psmux leaf applies to its own rc.
|
|
570
|
+
#
|
|
571
|
+
# A TIMEOUT is neither: the server may have completed the switch before
|
|
572
|
+
# the wait expired, so False would report a human still watching a
|
|
573
|
+
# window the client has already left. A spawn-level fault IS the joint
|
|
574
|
+
# claim — proof the verb never ran, so nothing moved.
|
|
575
|
+
try:
|
|
576
|
+
proc = self._run(["switch-client", "-t", target], check=False)
|
|
577
|
+
if proc.returncode == 0:
|
|
578
|
+
return True
|
|
579
|
+
if last_fallback and self._run(["switch-client", "-l"], check=False).returncode == 0:
|
|
580
|
+
return True
|
|
581
|
+
except subprocess.TimeoutExpired:
|
|
582
|
+
return None
|
|
583
|
+
except (subprocess.SubprocessError, OSError):
|
|
584
|
+
return False
|
|
585
|
+
attached = self._attached_here()
|
|
586
|
+
if attached is None or attached == 0:
|
|
587
|
+
# Unreadable and zero part company as facts and meet as verdicts:
|
|
588
|
+
# neither can vouch that a human is still in front of this window.
|
|
589
|
+
return None
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
def available(self) -> bool:
|
|
593
|
+
return shutil.which(self._BINARY) is not None
|
|
594
|
+
|
|
595
|
+
def version(self) -> str | None:
|
|
596
|
+
# Every exit path rewrites the diagnostic, so it always describes THIS
|
|
597
|
+
# call (the seam's read-it-after-version rule) — a probe that recovers
|
|
598
|
+
# must not leave the old failure standing for `mux` to warn about.
|
|
599
|
+
self._version_error = None
|
|
600
|
+
if not shutil.which(self._BINARY):
|
|
601
|
+
return None
|
|
602
|
+
try:
|
|
603
|
+
raw = self._tmux("-V")
|
|
604
|
+
# UnicodeError is in the list for a leaf that overrides _ERRORS back to a
|
|
605
|
+
# strict handler. _run still decodes with the LOCALE codec on POSIX
|
|
606
|
+
# (_ENCODING is None there), but no longer strictly on any platform
|
|
607
|
+
# (_ERRORS is backslashreplace, #380), so an undecodable byte — a corrupt
|
|
608
|
+
# install, or a binary emitting text in another encoding, exactly what
|
|
609
|
+
# this diagnostic exists for — now degrades to a \xNN escape rather than
|
|
610
|
+
# raising. Where a leaf does restore strictness it is a ValueError,
|
|
611
|
+
# outside the SubprocessError/OSError family, so it would escape as a raw
|
|
612
|
+
# crash for every caller above to guard; this arm keeps that closed.
|
|
613
|
+
except (MultiplexerError, subprocess.SubprocessError, OSError, UnicodeError) as exc:
|
|
614
|
+
# None stays the seam's answer, but the identity of the failure is
|
|
615
|
+
# what separates a crashing binary from one that reports no version
|
|
616
|
+
# (#428). On the nonzero-exit arm _run has already folded the probe's
|
|
617
|
+
# stderr into the TmuxError text, so str(exc) carries it; the other
|
|
618
|
+
# arms carry only the failure itself, which is all there is to carry.
|
|
619
|
+
self._version_error = str(exc)
|
|
620
|
+
return None
|
|
621
|
+
# The seam promises one line (TerminalMultiplexer.version). `-V` is one
|
|
622
|
+
# line on tmux, two on psmux (a `tmux X.Y.Z` compat line then its own),
|
|
623
|
+
# so fold rather than truncate — the tail is what names psmux as the
|
|
624
|
+
# answering binary. Order is load-bearing: PsmuxMultiplexer.available()
|
|
625
|
+
# parses the compat segment with an anchored match, so the first
|
|
626
|
+
# segment must stay first.
|
|
627
|
+
return fold_version(raw)
|
|
628
|
+
|
|
629
|
+
def version_error(self) -> str | None:
|
|
630
|
+
return self._version_error
|